Playtest #6 fixes: summons placed by hand, boss pool stall, bounties, 联防 leak counter, distinct offers, 机变 card text, phone buy tap, skill triggers, blocking, push force, 孤立, enemies, element bar

- #1 #2 skill / device summons (赫默's drone, 巫恋's doll, 凯瑟琳's devices) are hand cards placed by the player;
  placed summons deploy once at battle start, then a skill summon re-appears at its tile on each cast (the user's call)
- #3 叙拉古 隐匿 ends on time (32 + 0.4 × layers s); 忍冬 S3 迷彩 lasts until her next cast (official), kept apart from 隐匿
- #4 multi-round bounty cards last two battles (the user's call; MULTI_ROUND_BOUNTY_BATTLES), blue / red card text
- #5 the shared boss pool's float dust (≈3.6e-12 HP) stalled Final Assault leaders at 0 %; < 1 HP now clears the pool,
  and the team LP reaching 0 first is a defeat (PRTS)
- #6 #10 机变 cards show their official effect text; an invisible 详情 button no longer swallows the confirming tap on phones
- #7 live ×N counter of a leaker's enemies still on the 联防 field (LP loss stays capped at 10)
- #8 #15 #16 skill triggers per 技能策略: skill-range triggers, injured-ally heals (古米, 塞雷娅), tank take-damage
  triggers with a 3 s cooldown; 新约能天使 S2 shields the highest-aggro operator in range
- #9 official per-enemy model scales (威龙)
- #11 element bar under the unit's HP bar (white = 元素值 left, element-coloured refill after a burst), floor rounding
- #12 枯朽战车 kit per PRTS; every ranged enemy reaches its target's 0.25 collider
- #13 恐惧 wander (萨科塔之翼 at half HP, operator fears make enemies flee) with re-planning after pushes
- #14 push / pull distance by 力度 − 重量 (PRTS table); #17 every blocker attacks what it blocks; ASPD floor 20
- #18 炎佑 is 孤立: no heals or ally buffs
- #19 every free pick-one chess offer (promotion reward, 信标, 寻呼模块) shows different operators
- DESIGN §20 and the normative lines, SIM / META / DATA / PLAYING updated; 2747 unit tests and all 27 browser suites
  green; dev recordings regenerated
This commit is contained in:
sganggs committed 2026-10-01 19:08:23 +08:00
1 parent e6628f0cfe
commit 6464e4b46a
138 files changed
+7946 -1379

No files matched your search

+55 -9
View File
@@ -31,6 +31,8 @@
// botPrepBeginSteps / botPrepEndSteps (and planLayoutSteps, arrangeSteps, createRehearsalSteps) are step
// generators that yield between whole actions (never with a transient board) — the same actions in the same
// order as the one-shot functions (runSteps), hence the same rng draws and decisions.
// The summon cards of the placed operators (赫默's 医疗探机, 伺夜's 狼群 …; user playtest #6) are placed after
// them on the best remaining tiles, 凯瑟琳's 支援装置 next to the best operator no device faces yet, facing it.
// 5. equip items on the strongest deployed damage dealers (consume-on-equip items / Arts only with a handler)
// 6. resolve the temp slots, keep one hand slot free, then Ready.
// Placement quality (tools/matchrun sweeps, research-faithful waves): the planner beats random layouts by ≈ 8 points
@@ -38,7 +40,8 @@
import { GEO } from '../../shared/constants.js';
import { deriveSeed } from '../sim/rng.js';
import { freeSlot, legalTiles, positionClass, parseKey, tileKey, FIELD, pieceDir, boardTileOf, BOSS_MIRROR_COL } from './board.js';
import { ASPD_MIN } from '../sim/constants.js';
import { freeSlot, legalTiles, canPlace, positionClass, parseKey, tileKey, FIELD, pieceDir, boardTileOf, BOSS_MIRROR_COL } from './board.js';
import { rotateOffset, normDir, mirrorDir, oppositeDir } from '../sim/dir.js';
import { itemKey } from './gamedata.js';
import { computeBonds } from './bondsMeta.js';
@@ -497,7 +500,7 @@ export function rangeTiles(rec, r, c, dir = 'RIGHT') {
function dpsOf(rec) {
const st = rec && rec.stats;
if (!st || isHealer(rec)) return 0;
const interval = Math.max(0.2, (st.bat || 1) * 100 / Math.max(10, st.aspd || 100));
const interval = Math.max(0.2, (st.bat || 1) * 100 / Math.max(ASPD_MIN, st.aspd || 100));
const d = (st.atk || 0) / interval;
return rec.attackKind === 'none' ? d * 0.4 : d;
}
@@ -920,11 +923,7 @@ export function botPrepEnd(m, ps, job = null) {
/** botPrepEnd as a step generator (see botPrepBeginSteps). */
export function* botPrepEndSteps(m, ps, job = null) {
if (!ps.alive || ps.ready) return;
if (job && job.done && job.best !== job.plans[0]) {
// summons placed around the default plan would sit on the new plan's tiles: back to their stacks, re-placed after
liftTokens(ps);
yield* applyPlanSteps(m, ps, job.chosen, job.best);
}
if (job && job.done && job.best !== job.plans[0]) yield* applyPlanSteps(m, ps, job.chosen, job.best);
// 5. temp → hand / sell / destroy; keep one hand slot free for next round's merges
resolveTemp(m, ps);
if (freeSlot(ps.hand) < 0) sellWeakestHand(m, ps);
@@ -1055,8 +1054,13 @@ export function* arrangeSteps(m, ps, { final = false, defer = false } = {}) {
return null;
}
/** Put the chosen pieces on their planned tiles, fill what is still off the board, then place summons. */
/**
* Put the chosen pieces on their planned tiles, fill what is still off the board, then place summons. Placed summons go
* back to their stacks first: they would sit on the plan's tiles (an operator moved onto one swaps it elsewhere), and a
* 凯瑟琳 device left beside a tile its operator moved away from would face nothing (QA, playtest #6).
*/
function* applyPlanSteps(m, ps, chosen, target) {
liftTokens(ps);
// move pieces onto their targets (board → board moves swap; hand → board may swap an occupant back to the hand)
for (let pass = 0; pass < 3; pass++) {
let moved = false;
@@ -1097,12 +1101,53 @@ function liftTokens(ps) {
}
}
/**
* Summons that help the operator on the tile they face (range 1-1: their own tile + the one in front) instead of
* covering the enemy path: 凯瑟琳's 爬行号·防护单元 (a hand piece since user playtest #6).
*/
const FRONT_SUPPORT_TOKENS = new Set(['token_10041_cathy_catsld']);
/** Device tile offsets around an operator (behind it — enemies come from the gates on the right —, above, below, in front) and the facing that points back at it. */
const SUPPORT_SPOTS = Object.freeze([[0, -1, 'RIGHT'], [1, 0, 'DOWN'], [-1, 0, 'UP'], [0, 1, 'LEFT']]);
/**
* A tile + direction for a front-support device: next to the most valuable operator no device faces yet (blockers
* first — they take the hits —, then DPS), pointing at it; null when none is free.
*/
function supportSpot(m, ps, p) {
const rec = m.gd.token(p.id);
if (!rec) return null;
const map = ps.deployMap();
const pos = positionClass(rec);
const faced = new Set();
for (const [k, q] of ps.board) {
if (q.kind !== 'token' || !FRONT_SUPPORT_TOKENS.has(q.id)) continue;
const [r, c] = parseKey(k);
const [dr, dc] = rotateOffset(0, 1, pieceDir(q));
faced.add(tileKey(r + dr, c + dc));
}
const ops = [...ps.board.entries()].filter(([k, q]) => q.kind === 'chess' && !faced.has(k)).map(([k, q]) => [k, m.gd.chess(q.id)]).filter(([, c]) => c)
.sort((a, b) => Number(isBlocker(b[1])) - Number(isBlocker(a[1])) || dpsOf(b[1]) - dpsOf(a[1]) || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
for (const [k] of ops) {
const [r, c] = parseKey(k);
for (const [dr, dc, dir] of SUPPORT_SPOTS) {
const t = tileKey(r + dr, c + dc);
if (!ps.board.has(t) && canPlace(map, pos, r + dr, c + dc)) return [r + dr, c + dc, dir];
}
}
return null;
}
/** Placeable summons from the hand / temp onto the best free tiles (one per stack count). */
function* placeTokensSteps(m, ps) {
for (const p of [...ps.hand, ...ps.temp]) {
if (!p || p.kind !== 'token') continue;
for (let n = p.count || 1; n > 0; n--) {
yield;
if (FRONT_SUPPORT_TOKENS.has(p.id)) {
const spot = supportSpot(m, ps, p);
if (!spot || !tryDo(() => ps.move(p.uid, { area: 'board', row: spot[0], col: spot[1] }, spot[2]))) break;
continue;
}
const plan = yield* planLayoutSteps(m, ps, [p], LAYOUT_PARAMS, { occupied: new Set(ps.board.keys()) });
const k = plan.get(p.uid);
if (!k) break;
@@ -1156,7 +1201,8 @@ function resolveTemp(m, ps) {
tryDo(() => ps.destroy(p.uid));
}
}
// anything left (e.g. sells failed): final sweep — tokens cannot be sold, they are dropped with the temp slot
// anything left (e.g. sells failed): final sweep — tokens cannot be sold, they are dropped with the temp slot (and
// come back at the next round start, PlayerState.startRound)
for (let i = 0; i < ps.temp.length; i++) {
const p = ps.temp[i];
if (!p) continue;