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

+287
View File
@@ -0,0 +1,287 @@
// User playtest #6 items 1 and 2 (both from a friend): 凯瑟琳's device and 赫默's drone could not be placed by hand.
// The official rule (PRTS 卫戍协议/帮助 §战斗部署): "如果部署的干员拥有可手动部署的附属召唤物,则该召唤物会立刻加入手牌区 …
// 根据召唤物部署数量上限(非初始持有量),发送等量召唤物至手牌区" — every manually deployable summon (shop state
// tokenDisplayType DEFAULT; 凯瑟琳's 爬行号·防护单元 has no entry and is deployable in the base game) is a hand card as
// soon as its owner stands on the board, a skill's summon only while that skill is equipped; "移动干员时,其所属召唤物全部
// 退场并重置至手牌区" (also when a summon dragged onto its owner swaps the owner away); a summon stack left in the
// 临时整备区 at the prep deadline "会于下一回合返还" (§手牌区). The battle side is test/content/playtest6_summons.test.js.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { ERR, PHASE } from '../../shared/constants.js';
import { checkLoadout } from '../../shared/protocol.js';
import { DATA, makeMatch, give, giveItem, checkInvariants, legalTileFor } from './harness.js';
import { botPrep } from '../../server/match/bot.js';
const SILENCE = 'chess_char_2_02_a';
const CATHY = 'chess_char_4_11_a';
const SHAMARE = 'chess_char_3_15_a';
const DRONE = 'token_10000_silent_healrb';
const DEVICE = 'token_10041_cathy_catsld';
const DOLL = 'token_10006_vodfox_doll';
const chess = (id) => (Object.hasOwn(DATA.chess, id) ? DATA.chess[id] : null);
function prep({ loadout = null, stageId = 'act2autochess_m04', seed = 11 } = {}) {
const seats = [{ seat: 0, playerId: 'p_0', name: 'P0', isBot: false, connected: true, loadout }];
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seats, seed }).start();
h.toPrep(1);
h.setStage(stageId);
const ps = h.ps('p_0');
for (const p of [...ps.board.values(), ...ps.hand.filter(Boolean)]) if (p.kind === 'chess') ps.returnCopies(p);
ps.board.clear();
ps.hand.fill(null);
ps.recompute();
return { h, m: h.m, ps };
}
const tokensOf = (ps, id) => [...ps.hand, ...ps.temp, ...ps.board.values()].filter((p) => p && p.kind === 'token' && p.id === id);
const stackOf = (ps, id) => ps.hand.find((p) => p && p.kind === 'token' && p.id === id) ?? null;
const move = (m, uid, to, dir) => m.handle('p_0', { t: 'g.move', uid, to, ...(dir ? { dir } : {}) });
test('tokens.json: the manually deployable summons are hand pieces — 医疗探机, 诅咒娃娃, 爬行号·防护单元 with the talent ones', () => {
const placeable = Object.values(DATA.tokens).filter((t) => t.placeable).map((t) => t.tokenId).sort();
assert.deepEqual(placeable, [DRONE, DOLL, 'token_10017_skadi2_dedant', 'token_10028_vigil_wolf', 'token_10030_mlyss_wtrman', DEVICE].sort());
// HIDDEN shop-state tokens stay battle-only (PRTS: 新约能天使 with 使命必达! provides no 投递坐标 card)
for (const t of Object.values(DATA.tokens)) if (t.displayType === 'HIDDEN') assert.equal(t.placeable, false, t.name);
assert.equal(DATA.tokens.token_10056_angel2_target.placeable, false);
// 凯瑟琳's device: no shop-state entry; the hand gets its deploy limit (2), not the 3 carried
assert.equal(DATA.tokens[DEVICE].displayType, null);
assert.equal(DATA.tokens[DEVICE].deployLimit, 2);
});
test('#2 赫默 on the board sends a 医疗探机 card to the hand; it is placed by hand like any summon (S2 only)', () => {
const { m, ps } = prep();
const hm = give(m, ps, SILENCE);
const at = legalTileFor(m, ps, SILENCE);
assert.deepEqual(move(m, hm.uid, { area: 'board', row: at[0], col: at[1] }), { ok: true });
const stack = stackOf(ps, DRONE);
assert.ok(stack, 'the drone card is in the hand');
assert.equal(stack.ownerUid, hm.uid);
assert.equal(stack.count, 1, 'deploy limit 1');
const deployed = ps.deployCount;
const t = legalTileFor(m, ps, SILENCE);
assert.deepEqual(move(m, stack.uid, { area: 'board', row: t[0], col: t[1] }, 'UP'), { ok: true });
const piece = ps.board.get(`${t[0]},${t[1]}`);
assert.equal(piece.kind, 'token');
assert.equal(piece.id, DRONE);
assert.equal(piece.dir, 'UP');
assert.equal(ps.deployCount, deployed, 'summons use no deploy slot (PRTS 医疗探机 部署占用数 0)');
const input = ps.battleInput({ side: 'L', colOffset: 0 });
assert.ok(input.units.some((u) => u.kind === 'token' && u.tokenId === DRONE && u.ownerUid === hm.uid && u.row === t[0] && u.col === t[1]));
checkInvariants(m);
m.dispose();
// S1 治疗强化·γ型 makes no drone: no card
const lo = checkLoadout({ [SILENCE]: { skill: 0 } }, chess).loadout;
const s1 = prep({ loadout: lo });
const hm1 = give(s1.m, s1.ps, SILENCE, 'board', [9, 3]);
assert.ok(hm1);
assert.equal(tokensOf(s1.ps, DRONE).length, 0, '赫默 S1: no drone card');
s1.m.dispose();
});
test('#1 凯瑟琳 on the board sends her 支援装置 (2 = deploy limit) to the hand; both can be placed and turned', () => {
const { m, ps } = prep();
const cat = give(m, ps, CATHY);
assert.deepEqual(move(m, cat.uid, { area: 'board', row: 10, col: 4 }), { ok: true });
const stack = stackOf(ps, DEVICE);
assert.ok(stack, 'the device card is in the hand');
assert.equal(stack.count, 2);
assert.deepEqual(move(m, stack.uid, { area: 'board', row: 10, col: 3 }, 'RIGHT'), { ok: true });
assert.equal(stack.count, 1, 'one left on the card');
assert.deepEqual(move(m, stack.uid, { area: 'board', row: 11, col: 4 }, 'DOWN'), { ok: true });
const devs = [...ps.board.values()].filter((p) => p.kind === 'token' && p.id === DEVICE);
assert.deepEqual(devs.map((p) => p.dir).sort(), ['DOWN', 'RIGHT']);
assert.equal(stackOf(ps, DEVICE), null, 'the whole stack is on the board');
checkInvariants(m);
m.dispose();
});
test('巫恋 (S2 诅咒娃娃) sends a doll card like 赫默', () => {
const { m, ps } = prep();
give(m, ps, SHAMARE, 'board', [9, 3]);
assert.equal(tokensOf(ps, DOLL).length, 1);
m.dispose();
});
test('moving an owner on the board sends its placed summons back to its card (PRTS "移动干员时…重置至手牌区")', () => {
const { m, ps } = prep();
const cat = give(m, ps, CATHY);
assert.deepEqual(move(m, cat.uid, { area: 'board', row: 10, col: 4 }), { ok: true });
const stack = stackOf(ps, DEVICE);
assert.deepEqual(move(m, stack.uid, { area: 'board', row: 10, col: 3 }), { ok: true });
assert.deepEqual(move(m, stack.uid, { area: 'board', row: 11, col: 4 }), { ok: true });
assert.equal(ps.board.get('10,3')?.id, DEVICE);
assert.equal(ps.board.get('11,4')?.id, DEVICE);
// re-orienting in place is not a move: the devices stay
assert.deepEqual(move(m, cat.uid, { area: 'board', row: 10, col: 4 }, 'UP'), { ok: true });
assert.equal([...ps.board.values()].filter((p) => p.kind === 'token').length, 2);
// a real move: both devices leave the board and go back onto one card of 2
assert.deepEqual(move(m, cat.uid, { area: 'board', row: 10, col: 6 }), { ok: true });
assert.equal([...ps.board.values()].filter((p) => p.kind === 'token').length, 0, 'no device left on the board');
const back = tokensOf(ps, DEVICE);
assert.equal(back.length, 1);
assert.equal(back[0].count, 2);
checkInvariants(m);
m.dispose();
});
test('bots place the summon cards: 赫默\'s drone and 凯瑟琳\'s devices end up on the board, each device facing an operator', () => {
for (const [owner, token] of [[SILENCE, DRONE], [CATHY, DEVICE]]) {
const h = makeMatch({ mode: 'coop', humans: 1, bots: 1, seed: 21 }).start();
h.toPrep(2);
const bp = h.ps('ai_0');
for (const p of [...bp.board.values(), ...bp.hand.filter(Boolean)]) if (p.kind === 'chess') bp.returnCopies(p);
bp.board.clear();
bp.hand.fill(null);
bp.recompute();
give(h.m, bp, 'chess_char_1_10_a'); // 古米: a blocker for the devices to face
give(h.m, bp, owner);
botPrep(h.m, bp);
const placed = [...bp.board.entries()].filter(([, p]) => p.kind === 'token' && p.id === token);
assert.ok(placed.length >= 1, `${token}: placed by the bot`);
if (token === DEVICE) {
for (const [k, p] of placed) {
const [r, c] = k.split(',').map(Number);
const d = { RIGHT: [0, 1], LEFT: [0, -1], UP: [1, 0], DOWN: [-1, 0] }[p.dir ?? 'RIGHT'];
const front = bp.board.get(`${r + d[0]},${c + d[1]}`);
assert.ok(front && front.kind === 'chess', `device at ${k} faces an operator (${p.dir})`);
}
}
h.m.dispose();
}
});
test('bots re-aim placed devices with every layout: a device left facing an operator the plan moved away is lifted and placed again (QA)', () => {
// the bot arranges twice (before and after its items) and may switch to the rehearsed plan: operators moved without
// their summons left 凯瑟琳's devices facing an empty tile (3.5–8.6 % of devices in QA's bot matches)
const D = { RIGHT: [0, 1], LEFT: [0, -1], UP: [1, 0], DOWN: [-1, 0] };
const others = ['chess_char_1_10_a', 'chess_char_2_02_a', 'chess_char_1_01_a', 'chess_char_1_02_a', 'chess_char_1_03_a', 'chess_char_1_04_a'];
let devices = 0;
for (const seed of [21, 22, 23, 24, 25, 26, 27, 28]) {
const h = makeMatch({ mode: 'coop', humans: 1, bots: 1, seed }).start();
h.toPrep(seed % 2 ? 3 : 5);
const bp = h.ps('ai_0');
for (const p of [...bp.board.values(), ...bp.hand.filter(Boolean)]) if (p.kind === 'chess') bp.returnCopies(p);
bp.board.clear();
bp.hand.fill(null);
bp.recompute();
// a hand-made layout the bot will rearrange: every operator on the first legal tile, both devices facing one of them
for (const id of [CATHY, ...others.slice(0, bp.deployCap - 1)]) {
const p = give(h.m, bp, id);
const t = legalTileFor(h.m, bp, id);
if (t) bp.move(p.uid, { area: 'board', row: t[0], col: t[1] }, 'RIGHT');
}
const [fk] = [...bp.board.entries()].find(([, p]) => p.kind === 'chess' && p.id !== CATHY);
const [fr, fc] = fk.split(',').map(Number);
for (const [dr, dc, dir] of [[0, -1, 'RIGHT'], [1, 0, 'DOWN'], [-1, 0, 'UP'], [0, 1, 'LEFT']]) {
const stack = stackOf(bp, DEVICE);
if (!stack) break;
if (!bp.board.has(`${fr + dr},${fc + dc}`)) bp.move(stack.uid, { area: 'board', row: fr + dr, col: fc + dc }, dir);
}
botPrep(h.m, bp);
for (const [k, p] of bp.board) {
if (p.kind !== 'token' || p.id !== DEVICE) continue;
devices++;
const [r, c] = k.split(',').map(Number);
const d = D[p.dir ?? 'RIGHT'];
const front = bp.board.get(`${r + d[0]},${c + d[1]}`);
assert.ok(front && front.kind === 'chess', `seed ${seed}: the device at ${k} (${p.dir}) faces an operator`);
}
checkInvariants(h.m);
h.m.dispose();
}
assert.ok(devices >= 12, `devices checked: ${devices}`);
});
test('a summon card needs its owner on the board; the owner withdrawn takes its summons along', () => {
const { m, ps } = prep();
const hm = give(m, ps, SILENCE);
assert.deepEqual(move(m, hm.uid, { area: 'board', row: 10, col: 4 }), { ok: true });
const stack = stackOf(ps, DRONE);
assert.deepEqual(move(m, stack.uid, { area: 'board', row: 10, col: 5 }), { ok: true });
const free = ps.hand.findIndex((x) => x == null);
assert.deepEqual(move(m, hm.uid, { area: 'hand', idx: free }), { ok: true });
assert.equal(tokensOf(ps, DRONE).length, 0, 'withdrawn with its owner');
// back on the board: a new card; it cannot be placed once the owner is gone again, and summons are not sold
const at = legalTileFor(m, ps, SILENCE);
assert.deepEqual(move(m, hm.uid, { area: 'board', row: at[0], col: at[1] }), { ok: true });
const card = stackOf(ps, DRONE);
assert.ok(card);
assert.equal(m.handle('p_0', { t: 'g.sell', uid: card.uid }).error, ERR.BAD_TARGET);
checkInvariants(m);
m.dispose();
});
test('a summon dragged onto an operator swaps it away: that operator\'s other placed summons go back onto their card', () => {
const { m, ps } = prep();
const cat = give(m, ps, CATHY);
const hm = give(m, ps, SILENCE);
assert.deepEqual(move(m, cat.uid, { area: 'board', row: 10, col: 4 }), { ok: true });
assert.deepEqual(move(m, hm.uid, { area: 'board', row: 10, col: 6 }), { ok: true });
const devCard = stackOf(ps, DEVICE);
assert.deepEqual(move(m, devCard.uid, { area: 'board', row: 10, col: 3 }), { ok: true });
assert.deepEqual(move(m, devCard.uid, { area: 'board', row: 11, col: 4 }, 'UP'), { ok: true });
assert.deepEqual(move(m, stackOf(ps, DRONE).uid, { area: 'board', row: 10, col: 7 }), { ok: true });
// 凯瑟琳's device (10,3) dropped onto 赫默 (10,6): 赫默 takes 10,3, so her drone goes back to her card; 凯瑟琳 did not move
const devA = ps.board.get('10,3');
assert.deepEqual(move(m, devA.uid, { area: 'board', row: 10, col: 6 }), { ok: true });
assert.equal(ps.board.get('10,3')?.uid, hm.uid);
assert.equal(ps.board.get('10,6')?.uid, devA.uid, 'the dragged device is where it was dropped');
assert.equal(ps.board.get('10,7'), undefined, '赫默\'s placed drone left the board');
assert.equal(stackOf(ps, DRONE)?.count, 1, '… back onto her card');
assert.equal(ps.board.get('11,4')?.id, DEVICE, '凯瑟琳\'s other device stays');
// the other device (11,4) dropped onto its own owner (10,4): 凯瑟琳 takes 11,4; the dropped device stays at 10,4, her
// remaining placed device (10,6) goes back onto her card
const devB = ps.board.get('11,4');
assert.deepEqual(move(m, devB.uid, { area: 'board', row: 10, col: 4 }), { ok: true });
assert.equal(ps.board.get('11,4')?.uid, cat.uid);
assert.equal(ps.board.get('10,4')?.uid, devB.uid);
assert.equal(ps.board.get('10,6'), undefined);
assert.equal(stackOf(ps, DEVICE)?.count, 1);
assert.equal(tokensOf(ps, DEVICE).reduce((n, p) => n + (p.count || 1), 0), 2, 'still 2 devices in all');
checkInvariants(m);
m.dispose();
});
/** Plain equipment (no equip / merge side effects) to fill the hand. */
const PLAIN = Object.values(DATA.items).filter((i) => i.itemType === 'EQUIP' && !i.isGolden && i.kind === 'passive').map((i) => i.itemId ?? i.id).filter(Boolean);
test('a summon stack left in temp at the prep deadline comes back next round (PRTS "干员所属召唤物会于下一回合返还")', () => {
// co-op of 2 humans: a timed prep whose deadline resolves temp (PlayerState.resolveTemp), FakeBattle
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 2, seed: 1601, fake: true }).start();
h.toPrep(1);
h.setStage('act2autochess_m04');
const m = h.m;
const ps = h.ps('p_0');
for (const p of [...ps.board.values(), ...ps.hand.filter(Boolean)]) if (p.kind === 'chess') ps.returnCopies(p);
ps.board.clear();
ps.hand.fill(null);
ps.recompute();
const cat = give(m, ps, CATHY);
assert.deepEqual(move(m, cat.uid, { area: 'board', row: 10, col: 4 }), { ok: true });
const card = stackOf(ps, DEVICE);
assert.deepEqual(move(m, card.uid, { area: 'board', row: 10, col: 3 }), { ok: true });
assert.deepEqual(move(m, card.uid, { area: 'board', row: 11, col: 4 }, 'UP'), { ok: true });
for (let i = 0; ps.hand.some((x) => x == null); i++) giveItem(m, ps, PLAIN[i]);
// moving 凯瑟琳 with a full hand: her devices go back as one card of 2, which overflows into temp and blocks Ready
assert.deepEqual(move(m, cat.uid, { area: 'board', row: 10, col: 6 }), { ok: true });
const inTemp = ps.temp.find((p) => p && p.kind === 'token' && p.id === DEVICE);
assert.equal(inTemp?.count, 2);
assert.deepEqual(m.handle('p_0', { t: 'g.ready', ready: true }), { error: ERR.TEMP_NOT_EMPTY });
// the player leaves it there: the prep deadline removes the temp card …
h.drive(() => m.phase === PHASE.COMBAT && m.round === 1, { ready: false });
assert.equal(tokensOf(ps, DEVICE).length, 0, 'removed with the temp slot at the deadline');
// … and it comes back at the next round (still a full hand ⇒ temp again, where it can be placed)
h.drive(() => m.phase === PHASE.PREP && m.round === 2, { ready: false });
const back = tokensOf(ps, DEVICE);
assert.equal(back.length, 1);
assert.equal(back[0].count, 2, 'the deploy limit again');
assert.equal(back[0].ownerUid, cat.uid);
assert.deepEqual(move(m, back[0].uid, { area: 'board', row: 10, col: 5 }, 'RIGHT'), { ok: true });
assert.deepEqual(move(m, back[0].uid, { area: 'board', row: 11, col: 6 }, 'UP'), { ok: true });
assert.ok(ps.tempEmpty, 'placing both cleared temp');
// a later round start grants nothing more: both devices are on the board
h.drive(() => m.phase === PHASE.PREP && m.round === 3);
assert.equal(tokensOf(ps, DEVICE).reduce((n, p) => n + (p.count || 1), 0), 2);
checkInvariants(m);
m.dispose();
});