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

+10 -3
View File
@@ -26,7 +26,9 @@ function dataWith(modeId, round, family, { tactic = null, bounty = null, cards =
if (cards) r.cards = cards;
const out = { ...DATA, choices: { ...ch, schedule: { ...ch.schedule, [modeId]: { ...sch, rounds: { ...sch.rounds, [String(round)]: r } } }, cards: { ...ch.cards } } };
if (tactic) out.choices.cards.tactic = ch.cards.tactic.filter((c) => tactic.includes(c.effectId));
if (bounty) out.choices.cards.bounty = ch.cards.bounty.filter((c) => bounty.includes(c.effectId));
// the listed bounty cards are offered by this draft even when the real one would not (战术特训 comes from 法术教鞭
// only — choices.json `draft`, user playtest #6 item 4): these tests drive the payouts through the draft
if (bounty) out.choices.cards.bounty = ch.cards.bounty.filter((c) => bounty.includes(c.effectId)).map((c) => ({ ...c, draft: true }));
return out;
}
@@ -628,12 +630,17 @@ test('自愈 (real sim): every damage instance an own unit takes heals 50; not H
assert.equal(u1.hp, afterPhys - 300, '禁疗');
hit(u2, 300);
assert.equal(u2.hp, 1700, 'the partner has no 自愈');
// summons are 我方单位 too
const tok = h.b.spawnToken(u1, 'token_10011_beewax_oblisk', 11, 4, { stats: { maxHp: 1000 } });
// summons are 我方单位 too (纸偶) — but not one that holds 禁疗 (沙之碑, PRTS 备注 "持有禁疗"; tokens.json abnormal)
const tok = h.b.spawnToken(u1, 'token_10022_kazema_shadow', 11, 4, { stats: { maxHp: 1000 } });
assert.ok(tok, 'summon placed');
const before = tok.hp;
hit(tok, 200);
assert.equal(tok.hp, before - 200 + val, 'summon healed');
const ob = h.b.spawnToken(u1, 'token_10011_beewax_oblisk', 12, 4, { stats: { maxHp: 1000 } });
assert.ok(ob, '沙之碑 placed');
const ob0 = ob.hp;
hit(ob, 200);
assert.equal(ob.hp, ob0 - 200, '沙之碑 (禁疗) not healed');
checkInvariants(h.b);
});
+1 -1
View File
@@ -561,7 +561,7 @@ for (const key of ['enemy_1270_nhstlk', 'enemy_1270_nhstlk_2']) {
}
for (const key of ['enemy_1272_nhtank', 'enemy_1272_nhtank_2']) {
test(`${nm(key)}: ground targets only, melee hits ×${tb(key, 'Empty.attack@chuang_atk_scale')}, every 3rd attack leaves a pollution zone`, () => {
test(`${nm(key)}: ground targets only, melee hits ×${tb(key, 'Empty.attack@chuang_atk_scale')}, 秽蚀轰击 (a pollution zone) on the 1st attack, then every 3rd`, () => {
const hi = arena({ units: [{ chessId: 't_gun', row: 10, col: 2 }] }); // (10,2) is high ground
hi.step();
put(hi, key, [10, 3]);
+26 -14
View File
@@ -184,7 +184,9 @@ test('见行者 (3_07) 惊爆射击 pushes along her direction (DOWN: the enemy
checkInvariants(h.b);
});
test('薄绿 (3_08) 聚能涡旋 pulls toward the tile in front along her direction', () => {
test('薄绿 (3_08) 聚能涡旋 pushes the target towards her centre whatever her direction (PRTS 备注: a reversed radial push)', () => {
// PRTS 薄绿 S2 备注 "此技能的“拖拽”机制实际为反方向(指向薄绿方向)的推开" / 推与拉 (radial force): towards her centre,
// the official push distance (小力 vs weight 0: 1.7 tiles), stopping at the 急停 radius in front of her [ASSUMED]
const id = 'chess_char_3_08_a';
const run = (dir) => {
const h = makeBattle({ defs: { enemies: { enemy_d: dummy('enemy_d', { mass: 0 }) } }, timeLimit: 60, units: [{ chessId: id, row: 9, col: 4, dir }], enemies: [{ key: 'enemy_d', pos: [11, 4] }] });
@@ -195,10 +197,11 @@ test('薄绿 (3_08) 聚能涡旋 pulls toward the tile in front along her direct
h.run(3);
return h.enemy('enemy_d');
};
const up = run('UP');
assert.ok(up.y < 11 - 0.2 && Math.abs(up.x - 4) < 0.05, `UP: pulled straight down toward (10,4) (${up.x}, ${up.y})`);
const right = run('RIGHT');
assert.ok(right.x > 4.2, `RIGHT: pulled toward (9,5) (${right.x}, ${right.y})`);
for (const dir of ['UP', 'RIGHT']) {
const e = run(dir);
assert.ok(e.y < 11 - 0.2 && Math.abs(e.x - 4) < 0.05, `${dir}: pushed straight down towards her (${e.x}, ${e.y})`);
assert.ok(Math.hypot(e.x - 4, e.y - 9) >= 0.6708 - 1e-6, `${dir}: stops in front of her, not on her tile (${e.y})`);
}
});
test('乌尔比安 (5_05) S3 throws the anchor straight ahead along his direction (and triggers on the rotated grid)', () => {
@@ -223,18 +226,27 @@ test('乌尔比安 (5_05) S3 throws the anchor straight ahead along his directio
assert.ok(!r.h.hooksOf('skillStart').some((c) => c.unit === r.u), 'facing RIGHT: nothing ahead, no cast');
});
test('凯瑟琳 (4_11) places a device behind her operator along ITS direction, facing the operator', () => {
test('凯瑟琳 (4_11): a placed device shields the operator on the tile IT faces (range 1-1 rotated by its direction)', REAL, () => {
const id = 'chess_char_4_11_a', ally = 'chess_char_1_02_a';
const place = (dir) => {
const h = makeBattle({ units: [{ chessId: id, row: 12, col: 2 }, { chessId: ally, row: 10, col: 5, dir }], timeLimit: 30, hooks: [] });
// the device at (10,5); operators on its four neighbours; its direction picks the one it serves (user playtest #6)
const serve = (dir) => {
const h = makeBattle({
units: [
{ chessId: id, row: 12, col: 2, uid: 1 },
{ chessId: ally, row: 10, col: 6, uid: 2 }, { chessId: ally, row: 11, col: 5, uid: 3 },
{ chessId: ally, row: 9, col: 5, uid: 4 }, { chessId: ally, row: 10, col: 4, uid: 5 },
{ kind: 'token', tokenId: 'token_10041_cathy_catsld', ownerUid: 1, row: 10, col: 5, uid: 6, dir },
],
timeLimit: 30, hooks: [],
});
h.run(0.2);
const d = h.b.allyUnits.find((x) => x.defId === 'token_10041_cathy_catsld' && x.alive && x.mem.tr === 10 && x.mem.tc === 5);
assert.ok(d, `a device serves the ${dir} operator`);
return [d.tileR, d.tileC, d.dir];
const shielded = h.b.allyUnits.filter((x) => x.kind === 'op' && x.findBuff('cathy:shield')).map((x) => `${x.tileR},${x.tileC}`);
return shielded;
};
assert.deepEqual(place('UP'), [9, 5, 'UP'], 'below an UP operator, pointing up at it');
assert.deepEqual(place('RIGHT'), [10, 4, 'RIGHT']);
assert.deepEqual(place('LEFT'), [10, 6, 'LEFT']);
assert.deepEqual(serve('RIGHT'), ['10,6']);
assert.deepEqual(serve('UP'), ['11,5'], 'row 0 is the bottom: UP faces the row above');
assert.deepEqual(serve('DOWN'), ['9,5']);
assert.deepEqual(serve('LEFT'), ['10,4']);
});
test('余 (6_03) S3 fire wall runs through his tile perpendicular to his direction (UP / DOWN: his row; RIGHT / LEFT: his column)', REAL, () => {
+38 -7
View File
@@ -881,6 +881,34 @@ test('信标: target and item destroyed, offer of 2 same-tier operators; co-op:
cover(A('5_04'), B('5_04'));
});
test('pick-one chess offers never repeat an operator — 信标, 寻呼模块, any offer list (user playtest #6 item 19)', () => {
let n = 0;
for (let seed = 1; seed <= 24; seed++) {
for (const lvl of [1, 3, 6]) {
// 寻呼模块: same-bond operators up to the shop level — fewer cards when the pool has no other one, never a repeat
let { m, ps, equip } = setup({ seed });
ps.shop.level = lvl;
const pool1 = plain((c) => c.bonds.length && c.tier <= lvl);
const cid = pool1[seed % pool1.length];
const t = give(m, ps, cid, 'hand');
assert.deepEqual(equip(giveItem(m, ps, A('4_01')), t), OK);
for (const o of ps.offers) { const ids = o.slots.map((x) => x.id); assert.equal(new Set(ids).size, ids.length, `寻呼模块 seed ${seed} lv ${lvl}: ${ids}`); n++; }
// 信标: operators of the target's tier (topped up from the tier below), never a repeat
({ m, ps, equip } = setup({ seed }));
const pool2 = plain((c) => c.tier === lvl && c.bonds.length);
const t2 = give(m, ps, pool2[seed % pool2.length], 'hand');
assert.deepEqual(equip(giveItem(m, ps, A('5_04')), t2), OK);
for (const o of ps.offers) { const ids = o.slots.map((x) => x.id); assert.equal(new Set(ids).size, ids.length, `信标 seed ${seed} tier ${lvl}: ${ids}`); n++; }
}
}
assert.ok(n >= 100, `offers checked (${n})`);
// whoever builds the list, the offer drops a repeated operator
const { ps } = setup({ seed: 5 });
const [a, b] = plain((c) => c.tier === 2);
const offer = ps.pushRewardOffer('effect', { ids: [a, a, b, a] });
assert.deepEqual(offer.slots.map((x) => x.id), [a, b]);
});
test('拟态物质: owning 2 copies gives the 3rd (→ elite); otherwise a random same-bond normal operator', () => {
let { m, ps, equip } = setup({ seed: 9 });
const cid = plain((c) => c.tier === 2 && c.bonds.length)[0];
@@ -1012,9 +1040,12 @@ test('画卷 (Art): copies the operator on the tile / in front with its elite st
cover('chess_item_6_02_m');
});
test('教鞭 / “神秘顾客” (Arts): a band bounty (enemyeffect_b_*: +1 enemy next battle, killer gets its coins); 神秘顾客 destroyed ⇒ +1 fund, passes on', () => {
test('教鞭 (Art): a 战术特训 bounty (PRTS "于3个战术特训的悬赏任务中选择一项": extra enemies, coins for a perfect own phase); “神秘顾客”: a band bounty; destroyed ⇒ +1 fund, passes on', () => {
// the 20 战术特训 cards — the ones the 悬赏决策 draft leaves out ("※以下悬赏任务仅由法术教鞭生成")
const training = new Set(DATA.choices.cards.bounty.filter((c) => c.draftExcluded === 'perfect').map((c) => c.effectId));
assert.equal(training.size, 20);
const seen = new Set();
for (let s = 0; s < 4; s++) {
for (let s = 0; s < 6; s++) {
const { m, ps } = setup({ seed: 60 + s });
for (let k = 0; k < 2; k++) {
const art = giveItem(m, ps, 'chess_item_6_03_m');
@@ -1022,14 +1053,14 @@ test('教鞭 / “神秘顾客” (Arts): a band bounty (enemyeffect_b_*: +1 ene
}
assert.equal(ps.bounties.length, 2);
for (const b of ps.bounties) {
assert.match(b.card.effectId, /^enemyeffect_b_\d+$/, 'band bounty family (research 04 §7)');
assert.equal(b.card.payout, 'kill');
assert.equal(b.card.rounds, 1);
assert.equal(b.card.count, 1);
assert.ok(training.has(b.card.effectId), `${b.card.effectId} ${b.card.name}: a 战术特训 card (user playtest #6 item 4 review)`);
assert.match(b.card.name, /战术特训/);
assert.equal(b.card.payout, 'perfect', 'pays for a perfect own phase, not per kill');
assert.ok(m.gd.enemy(b.card.enemyKey) && !m.gd.inactiveEnemies.has(b.card.enemyKey), 'its enemy can appear in the mode');
seen.add(b.card.effectId);
}
}
assert.ok(seen.size >= 4, 'random pick');
assert.ok(seen.size >= 5, 'random pick');
const { h, m } = setup({ mode: 'coop', humans: 2, seed: 7 });
const p0 = h.ps('p_0'), p1 = h.ps('p_1');
const art = giveItem(m, p0, 'chess_item_6_01_m');
+10 -6
View File
@@ -309,7 +309,8 @@ test('1_10 古米 S2 食粮烹制: disarm s cooking (no attacks, DEF +def), then
const s = rec(id, 'skchr_sunbr_2'), bb = s.bb, tb = raw(id).trait.bb;
const ally = chessRec({ id: 't_ally', profession: 'WARRIOR', rangeGrid: [[0, 0]], skill: null, stats: { maxHp: 20000 } });
const h = run({
defs: { enemies: { e: dummy('e') }, chess: { ...noGarrison(id), t_ally: ally } },
// a MANUAL 重装 skill: the TAKE_DAMAGE strategy (PRTS 卫戍协议/帮助 "不受技能范围影响,受到伤害时释放技能")
defs: { enemies: { e: dummy('e', { atk: 300, bat: 1 }) }, chess: { ...noGarrison(id), t_ally: ally } },
units: [entry(id, s.skillId, { row: 9, col: 5, carryState: READY }), { chessId: 't_ally', row: 10, col: 6 }],
enemies: [{ key: 'e', pos: [9, 5] }],
});
@@ -318,7 +319,7 @@ test('1_10 古米 S2 食粮烹制: disarm s cooking (no attacks, DEF +def), then
a.hp = a.s.maxHp * 0.3;
assert.ok(h.runUntil(() => u.skill.active, 5));
const st = started(h, u)[0].t;
assert.equal(started(h, u)[0].reason, 'DEFAULT');
assert.equal(started(h, u)[0].reason, 'TAKE_DAMAGE');
approx(u.s.def, u.base.def * (1 + bb.def), `${id} cooking DEF`);
approx(u.s.atk, u.base.atk, 'no ATK while cooking');
h.runUntil(() => h.b.time >= st + bb.disarm - 0.2, 20);
@@ -482,19 +483,20 @@ test('1_19 野鬃 S1 骑枪刺击 (PASSIVE): ASPD +attack_speed for `duration` s
}
});
test('1_20 雷蛇 S1 充能防御: TAKE_DAMAGE — DEF +def for bb.duration s; the next hit is blocked (no 战术防御 SP from it)', () => {
test('1_20 雷蛇 S1 充能防御: 自动触发 "技能自动开启" (SP_FULL, no 技能策略) — DEF +def for bb.duration s; the next hit is blocked (no 战术防御 SP from it)', () => {
for (const id of pair('20')) {
const s = rec(id, 'skchr_liskam_1'), bb = s.bb, t = tal(id);
const mate = chessRec({ id: 't_mate', profession: 'WARRIOR', rangeGrid: [[0, 0]], skill: { spType: 'INCREASE_WHEN_ATTACK', spCost: 200 } });
const h = run({
defs: { enemies: { a: dummy('a', { atk: 700, bat: 1 }) }, chess: { ...noGarrison(id), t_mate: mate } },
units: [entry(id, s.skillId, { row: 9, col: 4, carryState: READY }), { chessId: 't_mate', row: 10, col: 4 }],
enemies: [{ key: 'a', pos: [9, 4] }],
enemies: [{ key: 'a', pos: [9, 4], time: 1 }], // arrives after the skill is on (it needs no hit to start)
});
const u = sel(h, id, s.skillId), m = h.unit('t_mate');
assert.ok(h.runUntil(() => u.skill.active, 10));
const st = h.b.time;
assert.equal(started(h, u)[0].reason, 'TAKE_DAMAGE');
assert.equal(started(h, u)[0].reason, 'SP_FULL');
assert.ok(st < 0.5, 'on as soon as it is ready, before any hit');
approx(u.s.def, u.base.def * (1 + bb.def), `${id} DEF`);
const hp = u.hp;
const sp = m.skill.sp;
@@ -671,9 +673,11 @@ test('1_20 雷蛇 反击电弧: attack interval +70 % of the base (PRTS 增大(+
for (const id of pair('20')) {
const bb = raw(id).skill.bb;
assert.equal(raw(id).skill.skillId, 'skchr_liskam_2');
const h = run({ defs: { enemies: { e: dummy('e') }, chess: noGarrison(id) }, units: [{ chessId: id, row: 9, col: 4, carryState: READY }], enemies: [{ key: 'e', pos: [9, 5] }] });
// 重装 S2: TAKE_DAMAGE (the enemy has to hit her)
const h = run({ defs: { enemies: { e: dummy('e', { atk: 300, bat: 1 }) }, chess: noGarrison(id) }, units: [{ chessId: id, row: 9, col: 4, carryState: READY }], enemies: [{ key: 'e', pos: [9, 4] }] });
const u = h.unit(id);
assert.ok(h.runUntil(() => u.skill.active, 5));
assert.equal(started(h, u)[0].reason, 'TAKE_DAMAGE');
approx(u.s.interval, u.base.bat * (1 + bb.base_attack_time) * 100 / u.s.aspd, `${id} interval ×${1 + bb.base_attack_time}`);
h.runUntil(() => !u.skill.active, 30);
approx(u.s.interval, u.base.bat * 100 / u.s.aspd, 'restored');
+14 -7
View File
@@ -455,28 +455,35 @@ test('2_17 折桠 S1 绝境抵抗: TAKE_DAMAGE; DEF +def and 抵抗 (control sta
}
});
test('2_18 灰毫 S2 专注轰击: blocks nothing, ranged splash bombs only, shorter interval, ATK +atk', () => {
test('2_18 灰毫 S2 专注轰击: cast when hit (重装 TAKE_DAMAGE), then blocks nothing, ranged splash bombs only, shorter interval, ATK +atk', () => {
for (const id of both('chess_char_2_18')) {
const bb = bbAlt(id);
const enemies = { w: enemyRec({ key: 'w', hp: 1e7, speed: 2 }) };
const enemies = { w: enemyRec({ key: 'w', hp: 1e7, speed: 2, atk: 50, bat: 1 }) };
// control: the default skill blocks the walker
const c = run({ defs: { enemies }, units: [{ chessId: id, row: 9, col: 5 }], enemies: [{ key: 'w', route: 0 }] });
assert.ok(c.runUntil(() => c.enemies()[0]?.blockedBy === c.unit(id), 10), 'default: blocked');
done(c);
const h = run({ defs: { enemies }, units: [U(id, 9, 5, { carryState: READY })], enemies: [{ key: 'w', route: 0 }] });
// (a second walker comes by while the skill runs: she bombs it from range, unblocked)
const h = run({ defs: { enemies }, units: [U(id, 9, 5, { carryState: READY })], enemies: [{ key: 'w', route: 0 }, { key: 'w', route: 0, time: 5 }] });
const u = h.unit(id);
usesAlt(u, id);
h.runUntil(() => u.skill.active, 6);
assert.equal(u.skill.rule, 'TAKE_DAMAGE', 'a MANUAL 重装 skill: "不受技能范围影响,受到伤害时释放技能"');
assert.ok(h.runUntil(() => u.skill.active, 8));
const st = h.b.time;
const atk0 = h.eventsOf('atk').length;
assert.ok(h.hooksOf('damaged').some((x) => x.target === u), 'cast by the hit of the enemy she blocked');
assert.ok(u.s.flags.noBlock);
approx(u.s.atk, u.base.atk * (1 + u.findBuff('talent:ashlok').mods.atkPct + bb.atk), 'ATK (+ 炮术研习)');
approx(u.s.interval, (u.base.bat + bb.base_attack_time) * 100 / u.s.aspd, 'base attack time shortened');
const w = h.enemies()[0];
assert.ok(h.runUntil(() => w.x < 4.5, 8), 'walks past her');
const w2 = h.enemies()[1];
assert.ok(h.runUntil(() => w2 && w2.x < 4.5, 12), 'the second one walks past her too');
assert.ok(u.skill.active);
assert.ok(!h.hooksOf('damaged').some((x) => x.target === u), 'never blocked ⇒ never hit');
const shots = h.eventsOf('atk').filter((e) => e[1] === u.id);
assert.ok(shots.length > 0 && shots.every((e) => e[3] !== 'none'), 'ranged shots only');
assert.ok(!h.hooksOf('damaged').some((x) => x.target === u && x.t > st + 0.1), 'released, never blocked again ⇒ not hit again');
const shots = h.eventsOf('atk').slice(atk0).filter((e) => e[1] === u.id);
assert.ok(shots.length > 0 && shots.every((e) => e[3] !== 'none'), `ranged shots only ${JSON.stringify(shots)}`);
done(h);
}
});
+19 -12
View File
@@ -460,7 +460,10 @@ test('3_10 松果 S1 RMA长钉 (charges): an immediate extra shot ×atk_scale ig
assert.ok(sk.some((c) => c.target.defId === 'enemy_s'), 'every enemy in range');
const plain = atkHits(h, u, false).find((c) => c.target.defId === 'enemy_d');
approx(plain.amount, u.s.atk * front - 300, 1e-6, 'the ignore is for the skill shot only');
assert.ok(h.runUntil(() => u.skill.activations >= d.skill.maxCharges, 8), 'every stored charge is used, one per attack');
// every stored charge is used, one per attack — at least AUTO_OP_COOLDOWN (3 s) apart ("自动操作具有3s冷却")
assert.ok(h.runUntil(() => u.skill.activations >= d.skill.maxCharges, 15), 'every stored charge is used');
const casts = h.hooksOf('attack').filter((c) => c.attacker === u && c.isSkill).map((c) => c.t);
for (let i = 1; i < casts.length; i++) assert.ok(casts[i] - casts[i - 1] >= 3 - 1e-6, `casts ${casts[i - 1]} → ${casts[i]}`);
done(h);
}
});
@@ -490,20 +493,20 @@ test('3_11 雪猎 S1 强力击·β型: next shot ×atk_scale (× trait), 裂云
}
});
test('3_12 瑕光 S1 光芒涌动 (TAKE_DAMAGE, charges): next attack ×atk_scale + heals the most injured ally of the 3×3', () => {
test('3_12 瑕光 S1 光芒涌动 (自动触发 ⇒ DEFAULT, charges): next attack ×atk_scale + heals the most injured ally of the 3×3', () => {
for (const id of BOTH('chess_char_3_12_a')) {
const b = SB(id, 'skchr_blemsh_1');
const h = makeBattle({ defs: { enemies: { enemy_d: dummy('enemy_d') } }, timeLimit: 60, hooks: ['damaged', 'heal'], captureNoisy: true,
units: [U(id, 'skchr_blemsh_1', 10, 4), { chessId: 'chess_char_3_16_a', row: 11, col: 4 }, { chessId: 'chess_char_3_05_a', row: 12, col: 4 }],
enemies: [{ key: 'enemy_d', pos: [10, 5] }] });
const u = h.unit(id), ally = h.unit('chess_char_3_16_a'), far = h.unit('chess_char_3_05_a');
assert.equal(u.skill.rule, 'TAKE_DAMAGE');
// an AUTO skill takes no 技能策略 (the 重装 TAKE_DAMAGE row is for MANUAL skills): it fires with her next attack
assert.equal(u.skill.rule, 'DEFAULT');
h.step();
ally.hp = ally.s.maxHp * 0.6;
far.hp = far.s.maxHp * 0.3; // outside the 3×3
fill(u);
h.b.dealDamage(h.enemy('enemy_d'), u, { type: 'phys', amount: 1 });
assert.ok(u.skill.active && u.skill.pending, 'cast when hit; next attack armed');
assert.ok(h.runUntil(() => u.skill.activations >= 1, 5), 'cast with her next attack, no hit needed');
h.runUntil(() => !u.skill.active, 5);
const sk = atkHits(h, u, true);
assert.equal(sk.length, 1);
@@ -519,7 +522,8 @@ test('3_12 瑕光 S2 慑敌辉光: ATK +, ground enemies on her tile sleep for t
for (const id of BOTH('chess_char_3_12_a')) {
const b = SB(id, 'skchr_blemsh_2'), t1 = TB(id, 1);
const dur = LD(id, 'skchr_blemsh_2').skill.duration;
const h = makeBattle({ defs: { enemies: { enemy_d: dummy('enemy_d'), enemy_n: dummy('enemy_n') } }, timeLimit: 60, hooks: ['damaged', 'heal'], captureNoisy: true,
// (a MANUAL 重装 skill: TAKE_DAMAGE — the enemy she blocks hits her)
const h = makeBattle({ defs: { enemies: { enemy_d: dummy('enemy_d', { atk: 200, bat: 1 }), enemy_n: dummy('enemy_n') } }, timeLimit: 60, hooks: ['damaged', 'heal', 'skillStart'], captureNoisy: true,
units: [U(id, 'skchr_blemsh_2', 10, 4), { chessId: 'chess_char_3_16_a', row: 10, col: 6 }],
enemies: [{ key: 'enemy_d', pos: [10, 4] }, { key: 'enemy_n', pos: [10, 5] }] });
const u = h.unit(id), ally = h.unit('chess_char_3_16_a');
@@ -529,6 +533,7 @@ test('3_12 瑕光 S2 慑敌辉光: ATK +, ground enemies on her tile sleep for t
ally.hp = ally.s.maxHp * 0.3;
fill(u);
assert.ok(h.runUntil(() => u.skill.active, 3));
assert.equal(h.hooksOf('skillStart').find((c) => c.unit === u).reason, 'TAKE_DAMAGE');
h.step();
approx(u.s.atk, u.base.atk * (1 + b.atk));
assert.ok(e.s.flags.sleep, 'her tile: asleep');
@@ -557,14 +562,13 @@ test('3_12 瑕光 精锐 module GUA-X: heals on allies under 50 % HP ×1.15 (not
assert.equal(u.findBuff('trait:blemsh_guard'), null);
ally.hp = ally.s.maxHp * 0.4;
fill(u);
h.b.dealDamage(h.enemy('enemy_d'), u, { type: 'phys', amount: 1 });
h.runUntil(() => !u.skill.pending, 5);
h.runUntil(() => u.skill.activations >= 1 && !u.skill.pending, 5);
let heal = h.hooksOf('heal').filter((c) => c.source === u).at(-1);
approx(heal.amount, u.s.atk * b.heal_scale * tb.heal_scale, 1e-6, 'under 50 %');
ally.hp = ally.s.maxHp * 0.6;
const n1 = u.skill.activations;
fill(u);
h.b.dealDamage(h.enemy('enemy_d'), u, { type: 'phys', amount: 1 });
h.runUntil(() => !u.skill.pending, 5);
h.runUntil(() => u.skill.activations > n1 && !u.skill.pending, 5);
heal = h.hooksOf('heal').filter((c) => c.source === u).at(-1);
approx(heal.amount, u.s.atk * b.heal_scale, 1e-6, 'above 50 %');
done(h);
@@ -677,7 +681,9 @@ test('3_18 忍冬 S1 小施惩戒: next attack + extra arts and +DP; S2 坠刃
assert.equal(atkHits(g, v).length, 0, 'nothing in her own range');
const gdp = gp.dp;
v.skill.gainSp(v.skill.spCost - v.skill.sp + 0.01, 'test'); // one charge
assert.ok(g.runUntil(() => v.skill.activations === 1, 1), 'CUSTOM_RANGE on the skill range');
assert.equal(v.skill.rule, 'SKILL_RANGE');
assert.ok(g.runUntil(() => v.skill.activations === 1, 1), 'SKILL_RANGE: an enemy on the skill range');
const first = g.b.time;
approx(gp.dp, gdp + b2.cost);
const e1 = g.enemy('enemy_e1');
const hit = tagged(g, 'vulpisTorture', v);
@@ -687,7 +693,8 @@ test('3_18 忍冬 S1 小施惩戒: next attack + extra arts and +DP; S2 坠刃
assert.ok(!e1.s.flags.stun, 'not 停顿 before: no stun');
for (const [i, k] of keys.slice(1).entries()) g.spawn(k, { pos: [[9, 5], [11, 5], [10, 5], [10, 6], [11, 4], [9, 4]][i] });
v.skill.gainSp(v.skill.spCost + 0.01, 'test');
assert.ok(g.runUntil(() => v.skill.activations === 2, 1));
assert.ok(g.runUntil(() => v.skill.activations === 2, 4));
assert.ok(g.b.time - first >= 3 - 1e-6, 'the next automatic cast waits the 3 s operation cooldown');
const second = tagged(g, 'vulpisTorture', v).slice(1);
assert.equal(second.length, b2.max_target, '≤ max_target enemies');
if (second.some((c) => c.target === e1)) assert.ok(e1.s.flags.stun, 'already 停顿 ⇒ stunned');
+31 -13
View File
@@ -86,7 +86,7 @@ test('tier4 loadouts: every chess × selectable skill × module fights a mixed w
// ---------------------------------------------------------------------------------------------------------------
// 信仰搅拌机
test('信仰搅拌机 S1 铳骑主考官: taking a hit arms it; next attack = 3 hits × atk_scale; reloads one adjacent 拉特兰 ammo skill', () => {
test('信仰搅拌机 S1 铳骑主考官 (自动触发 ⇒ DEFAULT: hurt SP, fires with the next attack); next attack = 3 hits × atk_scale; reloads one adjacent 拉特兰 ammo skill', () => {
for (const id of pair('chess_char_4_01_a')) {
const bb = D(id, 0).skill.bb;
const h = battle([U(id, 9, 5, 0, gold(id) === id ? 'none' : null), U('chess_char_1_01_a', 10, 4)], {
@@ -95,13 +95,13 @@ test('信仰搅拌机 S1 铳骑主考官: taking a hit arms it; next attack = 3
h.step();
const u = h.unit(id), ins = h.unit('chess_char_1_01_a');
assert.equal(u.skill.id, 'skchr_rmixer_1');
assert.equal(u.skill.rule, 'TAKE_DAMAGE');
assert.equal(u.skill.rule, 'DEFAULT', 'an AUTO skill takes no 技能策略 (the 重装 row is for MANUAL skills)');
assert.ok(ins.skill.activate('test', { free: true }));
u.skill.gainSp(1000);
const ammo0 = ins.skill.ammoLeft;
h.hooksOf('attack').length = 0;
h.spawn('enemy_hitter', { pos: [9, 5] });
assert.ok(h.runUntil(() => h.hooksOf('skillStart').some((c) => c.unit === u), 10), `${id} S1 armed by a hit`);
assert.ok(h.runUntil(() => h.hooksOf('skillStart').some((c) => c.unit === u), 10), `${id} S1 cast with the next attack`);
assert.ok(h.runUntil(() => h.hooksOf('skillEnd').some((c) => c.unit === u), 10), 'the armed attack happened');
const skillHits = dmgBy(h, u, (c) => c.dmg.isAttack && c.dmg.isSkill);
assert.equal(skillHits.length, 3, `${id}: triple hit`);
@@ -311,7 +311,7 @@ test('寒芒克洛丝 S1 无痕: ATK up, every attack is a double shot, 迷彩 (
assert.ok(u.skill.activate('test', { free: true }));
assert.equal(u.skill.kind, 'duration');
approx(u.s.atk, u.base.atk * (1 + bb.atk), 1e-6, `${id} ATK`);
assert.ok(u.s.flags.stealth, '迷彩');
assert.ok(u.s.flags.camou && !u.s.flags.stealth, '迷彩 (not 隐匿)');
assert.equal(canTargetAlly(e, u, true), false, 'not a ranged target');
h.run(3);
const shots = dmgBy(h, u, (c) => c.dmg.isAttack);
@@ -320,7 +320,7 @@ test('寒芒克洛丝 S1 无痕: ATK up, every attack is a double shot, 迷彩 (
assert.ok(per.size >= 2 && [...per.values()].every((n) => n === 2), `${id}: 2 shots per attack`);
assert.equal(u.mem.kroosHits ?? 0, 0, 'S2 hit counter untouched');
h.runUntil(() => !u.skill.active, 30);
assert.ok(!u.s.flags.stealth);
assert.ok(!u.s.flags.camou);
}
});
@@ -442,10 +442,12 @@ test('阿罗玛 S1 强效清洁: charges; next blast atk_scale arts, flying vict
}
});
test('凯瑟琳 S1 岁月锻打: passive ATK/DEF up for her and every operator holding a device shield; module none: 3 devices (CRA-X 4)', () => {
test('凯瑟琳 S1 岁月锻打: passive ATK/DEF up for her and every operator holding a device shield; the hand card is her deploy limit', () => {
for (const id of pair('chess_char_4_11_a')) {
const bb = D(id, 0).skill.bb;
const h = battle([U(id, 10, 4, 0), U('chess_char_4_17_a', 10, 6), U('chess_char_1_01_a', 12, 3)]);
// her device is a placed piece (user playtest #6): facing 4_17 on its right
const dev = { kind: 'token', tokenId: 'token_10041_cathy_catsld', ownerUid: 1, row: 10, col: 5, dir: 'RIGHT' };
const h = battle([U(id, 10, 4, 0, null, { uid: 1 }), U('chess_char_4_17_a', 10, 6), U('chess_char_1_01_a', 12, 3), dev]);
h.run(1);
const u = h.unit(id), hs = h.unit('chess_char_4_17_a'), ins = h.unit('chess_char_1_01_a');
assert.equal(u.skill.kind, 'passive');
@@ -458,9 +460,10 @@ test('凯瑟琳 S1 岁月锻打: passive ATK/DEF up for her and every operator h
}
assert.ok(hs.findBuff(`cathy:forge:${u.id}`), 'the shielded blocker gets it');
}
const carried = (m) => { const h = battle([U('chess_char_4_11_b', 10, 4, 0, m)]); h.step(); const u = h.unit('chess_char_4_11_b'); return u.mem.devicesLeft + u.mem.devices.length; };
assert.equal(carried('none'), C.chess_char_4_11_b.talentsBase[0].bb.cnt);
assert.equal(carried(null), C.chess_char_4_11_b.talents[0].bb.cnt);
// the module changes how many devices she carries (none 3, CRA-X 4); the hand gets the deploy limit (2) either way
// (PRTS 卫戍协议/帮助 "根据召唤物部署数量上限(非初始持有量),发送等量召唤物至手牌区")
assert.deepEqual([C.chess_char_4_11_b.talentsBase[0].bb.cnt, C.chess_char_4_11_b.talents[0].bb.cnt], [3, 4]);
for (const id of pair('chess_char_4_11_a')) assert.equal(ds.raw.tokens.token_10041_cathy_catsld.variants[id].stats.deployLimit, 2, `${id}: deploy limit`);
});
// ---------------------------------------------------------------------------------------------------------------
@@ -481,7 +484,8 @@ test('歌蕾蒂娅 S1 缺水的大洋裂断: charges; next attack atk_scale (×1
assert.equal(sk.length, 1);
approx(sk[0].amount, u.s.atk * bb.atk_scale * t1.atk_scale, 1e-6, `${id} hit`);
assert.ok(e.x < x0 - 0.5, `${id}: pulled towards her (${x0} → ${e.x})`);
assert.ok(e.x >= u.x + 1 - 1e-6, 'never past her front tile');
// 中力 vs weight 1 (受力等级 0): "必定拉至身前" — to the 急停 radius 0.6708 around her centre (PRTS 推与拉), never past it
approx(e.x, u.x + 0.6708, 1e-6, `${id}: stops at her front`);
}
});
@@ -506,11 +510,12 @@ test('歌蕾蒂娅 S2 缺水的掌握怒海: BAT +0.5 s, wider range, 2 targets
});
test('歌蕾蒂娅 module HOK-Y 淡金坠饰: pulling a far enemy (> 2.5 tiles) to herself is one force level stronger', () => {
// a weight-2 enemy: 中力 (1) − 2 = 受力等级 −1 ⇒ 35 % of the way; +1 level (HOK-Y) ⇒ 0 ⇒ all the way (PRTS 推与拉)
const moved = (m) => {
const h = battle([U('chess_char_4_12_b', 10, 3, 0, m)]);
const h = battle([U('chess_char_4_12_b', 10, 3, 0, m)], { enemies: { enemy_heavy: dummy({ key: 'enemy_heavy', mass: 2 }) } });
h.step();
const u = h.unit('chess_char_4_12_b');
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
const e = h.spawn('enemy_heavy', { pos: [10, 6] });
assert.ok(u.skill.activate('test', { free: true }));
h.runUntil(() => !u.skill.active, 10);
h.run(0.5);
@@ -763,6 +768,19 @@ test('焰尾 S1 迅敏直觉: +6 DP and the next physical attack on her is dodge
}
});
test('焰尾 S1 迅敏直觉 (AUTO): fires as soon as SP is full — no enemy, no attack needed (like 伺夜 S1; playtest #6 review)', () => {
for (const id of pair('chess_char_4_19_a')) {
const h = battle([U(id, 10, 4, 0)], { flags: { dpPerSec: 0 } });
h.step();
const u = h.unit(id);
assert.equal(u.skill.rule, 'SP_FULL');
const due = h.b.time + (u.skill.spCost - u.skill.sp) / u.s.spRecovery;
assert.ok(h.runUntil(() => u.skill.activations === 1, 40), `${id}: cast with no enemy on the field`);
const st = h.hooksOf('skillStart').find((c) => c.unit === u);
assert.ok(Math.abs(st.t - due) <= 0.05, `${id}: at full SP (${st.t} vs ${due})`);
}
});
test('焰尾 S2 “红松林”: +11/12 DP; ≤ 6 enemies around: 2 × atk_scale phys + stun; allies around +40 %/45 % phys dodge 10 s', () => {
for (const id of pair('chess_char_4_19_a')) {
const bb = D(id, 1).skill.bb;
+79 -38
View File
@@ -90,7 +90,8 @@ test('tier 5: every chess × every legal skill × every module fights with its a
const tag = `${id} S${skillIndex + 1} ${moduleId ?? 'default module'}`;
assert.equal(u.def.loadout.skillIndex, skillIndex, tag);
assert.ok(['skills', undefined].includes(u.kit.skillSource), `${tag}: ${u.kit.skillSource}`);
for (let t = 0; t < 60 && !h.b.finished; t += 5) { u.skill.gainSp(1000); h.run(5); }
const mate = h.b.allyUnits[1]; // (塞雷娅 S1 急救 casts only for an ally of its area below half HP)
for (let t = 0; t < 60 && !h.b.finished; t += 5) { if (mate.alive) mate.hp = Math.min(mate.hp, mate.s.maxHp * 0.3); u.skill.gainSp(1000); h.run(5); }
assert.ok(u.skill.activations > 0, `${tag}: cast`);
assert.deepEqual(h.b.errors.map((e) => `${e.label}: ${e.message}`), [], tag);
checkInvariants(h.b);
@@ -500,7 +501,7 @@ test('史尔特尔 module AFT-Y (旅游必需品): blocked enemies are 法术脆
// =================================================================================================================
// 号角
test('号角 S1 照明榴弹 (TAKE_DAMAGE): the next ranged shot deals atk_scale × ATK, splashes projectile_range and lights the impact', () => {
test('号角 S1 照明榴弹 (自动触发 ⇒ DEFAULT): the next ranged shot deals atk_scale × ATK, splashes projectile_range and lights the impact', () => {
for (const id of pair('08')) {
const h = run({
defs: { enemies: { enemy_dummy: dummy('enemy_dummy') } },
@@ -512,11 +513,10 @@ test('号角 S1 照明榴弹 (TAKE_DAMAGE): the next ranged shot deals atk_scale
const [e1, e2] = h.b.enemies;
h.step();
u.skill.gainSp(1000);
h.run(1);
assert.equal(u.skill.activations, 0, 'TAKE_DAMAGE: not before a hit');
h.b.dealDamage(e1, u, { amount: 10, type: 'phys' });
assert.ok(u.skill.pending, 'armed by the hit');
assert.ok(h.runUntil(() => dealt(h, u, (c) => c.dmg.isSkill && c.target === e1).length > 0, 5));
assert.equal(u.skill.rule, 'DEFAULT', 'an AUTO skill takes no 技能策略 (the 重装 row is for MANUAL skills)');
assert.ok(h.runUntil(() => dealt(h, u, (c) => c.dmg.isSkill && c.target === e1).length > 0, 5), 'her next shot, no hit needed');
u.skill.charges = 0; // (no further flare in the window below: it would light the targets again)
u.skill.spCostMul = 1000;
approx(dealt(h, u, (c) => c.dmg.isSkill && c.target === e1)[0].amount, u.s.atk * bb.atk_scale, 'atk_scale');
assert.ok(dealt(h, u, (c) => c.dmg.isSkill && c.target === e2).length > 0, `the splash reaches 1.6 tiles (${bb.projectile_range})`);
h.run(0.5);
@@ -534,7 +534,11 @@ test('号角 S2 暴风号令: 10 rounds of attack@s2.atk_scale × ATK splash; th
const bb = bbOf(u);
const e = h.b.enemies[0];
h.step();
cast(h, u);
u.skill.gainSp(1000);
h.run(1);
assert.equal(u.skill.activations, 0, '重装 TAKE_DAMAGE: not before a hit');
h.b.dealDamage(e, u, { amount: 10, type: 'phys' });
assert.equal(u.skill.activations, 1, 'cast by the hit');
assert.equal(u.skill.kind, 'ammo');
assert.equal(u.skill.ammoLeft + h.hooksOf('ammoUsed').filter((c) => c.unit === u).length, bb['attack@s2.trigger_time']);
assert.ok(h.runUntil(() => !u.skill.active, 60));
@@ -610,24 +614,30 @@ test('铃兰 module DEC-Y (孩子们): longer 停顿 (1.2 s) and no SP rider', (
// =================================================================================================================
// 塞雷娅
test('塞雷娅 S1 急救 (TAKE_DAMAGE): her next attack heals the ally below half HP around her for heal_scale × ATK', () => {
test('塞雷娅 S1 急救 (自动触发, at her attack: an ally of the area at ≤ half HP): that attack heals the lowest such ally for heal_scale × ATK instead', () => {
for (const id of pair('11')) {
// PRTS 备注 "此技能仅在周围有符合血量条件的友方单位时可触发,触发时会替换当次攻击"; "血量小于等于一半" (PRTS 修正)
const h = run({
defs: { enemies: { enemy_hitter: dummy('enemy_hitter', { atk: 50, bat: 1 }) }, chess: { t_low: ally('t_low'), t_mid: ally('t_mid') } },
defs: { enemies: { enemy_dummy: dummy('enemy_dummy') }, chess: { t_low: ally('t_low'), t_mid: ally('t_mid') } },
units: [entry(id, 'skchr_demkni_1', { row: 10, col: 4 }), { chessId: 't_low', row: 11, col: 3 }, { chessId: 't_mid', row: 9, col: 4 }],
enemies: [{ key: 'enemy_hitter', pos: [10, 4] }],
enemies: [{ key: 'enemy_dummy', pos: [10, 4] }],
});
const u = sel(h, id, 'skchr_demkni_1');
const bb = bbOf(u), tb = u.def.traitBb;
const low = h.unit('t_low'), mid = h.unit('t_mid');
const e = h.b.enemies[0];
h.step();
low.hp = low.s.maxHp * 0.3;
mid.hp = mid.s.maxHp * 0.45;
u.skill.gainSp(1000);
assert.ok(h.runUntil(() => heals(h, u, (c) => c.target === low).length > 0, 10));
assert.equal(heals(h, u, (c) => c.target === mid).length, 0, 'one ally: the lowest ratio');
approx(heals(h, u, (c) => c.target === low)[0].amount, u.s.atk * bb.heal_scale * (tb.heal_scale && low.hpRatio < tb.hp_ratio + 0.1 ? tb.heal_scale : 1), 'heal_scale (× GUA-X below 50 % on the elite)', 1e-3);
assert.equal(h.hooksOf('skillStart').filter((c) => c.unit === u)[0].reason, 'TAKE_DAMAGE');
const first = heals(h, u, (c) => c.target === low)[0];
assert.ok(first.amount > 0 && heals(h, u)[0] === first, 'the first heal goes to the lowest ratio');
approx(first.amount, u.s.atk * bb.heal_scale * (tb.heal_scale && tb.hp_ratio != null ? tb.heal_scale : 1), 'heal_scale (× GUA-X below 50 % on the elite)', 1e-3);
const start = h.hooksOf('skillStart').find((x) => x.unit === u);
assert.equal(start.reason, 'DEFAULT');
assert.equal(dealt(h, u, (c) => c.target === e && c.t === start.t).length, 0, 'the heal replaced the attack of that moment');
assert.ok(heals(h, u).every((c) => c.t >= start.t), 'only skill heals: she has no heal of her own');
done(h);
}
});
@@ -635,7 +645,7 @@ test('塞雷娅 S1 急救 (TAKE_DAMAGE): her next attack heals the ally below ha
test('塞雷娅 S3 钙质化: allies in the area heal attack@heal_scale × ATK/s; enemies there take arts ×damage_scale and move −60 %', () => {
for (const id of pair('11')) {
const h = run({
defs: { enemies: { enemy_dummy: dummy('enemy_dummy') }, chess: { t_a: ally('t_a') } },
defs: { enemies: { enemy_dummy: dummy('enemy_dummy', { atk: 100, bat: 1 }) }, chess: { t_a: ally('t_a') } }, // 重装: TAKE_DAMAGE
units: [entry(id, 'skchr_demkni_3', { row: 10, col: 4 }), { chessId: 't_a', row: 10, col: 6 }],
enemies: [{ key: 'enemy_dummy', pos: [10, 4] }, { key: 'enemy_dummy', pos: [9, 6] }, { key: 'enemy_dummy', pos: [12, 9] }],
});
@@ -878,6 +888,19 @@ test('凛御银灰 S1 周旋的谋略: +10 DP; the waiting op nearest to the eye
}
});
test('凛御银灰 S1 周旋的谋略 (AUTO): fires as soon as SP is full — no enemy, no attack needed (like 伺夜 S1; playtest #6 review)', () => {
for (const id of pair('14')) {
const h = run({ units: [entry(id, 'skchr_svash2_1', { row: 10, col: 4 })], flags: { dpPerSec: 0 } });
const u = sel(h, id, 'skchr_svash2_1');
assert.equal(u.skill.rule, 'SP_FULL');
const due = h.b.time + (u.skill.spCost - u.skill.sp) / u.s.spRecovery; // 精锐 talents start her with more SP
assert.ok(h.runUntil(() => u.skill.activations === 1, 60), `${id}: cast with no enemy on the field`);
const st = h.hooksOf('skillStart').find((c) => c.unit === u);
assert.ok(Math.abs(st.t - due) <= 0.05, `${id}: at full SP (${st.t} vs ${due})`);
done(h);
}
});
test('凛御银灰 S3 变革已至: skill range, line attacks bird_atk_scale × ATK + 脆弱, DP 5/6 then +1 per 2 s, first cast swaps waiting costs', () => {
for (const id of pair('14')) {
const h = run({
@@ -1295,36 +1318,54 @@ test('default-skill machinery stays with the default skill: 圣约送葬人 S1 n
// =================================================================================================================
// adversarial review regressions
test('号角 S1 / 塞雷娅 S1 (TAKE_DAMAGE, elite: 2 charges): hits taken while a charge is armed never spend the other one; after the shot the next hit arms it', () => {
// (号角 shoots 4 tiles ahead; 塞雷娅's range is her own tile)
for (const [id, skillId, col, ecol] of [['chess_char_5_08_b', 'skchr_horn_1', 2, 6], ['chess_char_5_11_b', 'skchr_demkni_1', 4, 4]]) {
const h = run({
defs: { enemies: { enemy_dummy: dummy('enemy_dummy') } },
units: [entry(id, skillId, { row: 10, col })],
enemies: [{ key: 'enemy_dummy', pos: [10, ecol] }],
});
const u = sel(h, id, skillId);
test('号角 S1 / 塞雷娅 S1 (自动触发, elite: 2 charges): hits never arm them; a cast "next attack" still waiting never spends the other charge', () => {
// 号角 S1: the data rule DEFAULT — each charge is cast with one of her attacks (hits taken do nothing)
{
const id = 'chess_char_5_08_b';
const h = run({ defs: { enemies: { enemy_dummy: dummy('enemy_dummy') } }, units: [entry(id, 'skchr_horn_1', { row: 10, col: 2 })], enemies: [{ key: 'enemy_dummy', pos: [10, 6] }] });
const u = sel(h, id, 'skchr_horn_1');
const e = h.b.enemies[0];
const starts = () => h.hooksOf('skillStart').filter((c) => c.unit === u).length;
const starts = () => h.hooksOf('skillStart').filter((c) => c.unit === u);
h.step();
u.atkCd = 3;
u.skill.gainSp(1000);
assert.equal(u.skill.charges, 2, `${id}: 2 charges`);
for (let i = 0; i < 3; i++) h.b.dealDamage(e, u, { amount: 10, type: 'phys' });
assert.equal(starts(), 1, `${id}: one charge armed by the first hit`);
assert.equal(u.skill.charges, 1, `${id}: the second charge stays stored`);
assert.ok(u.skill.pending, `${id}: armed`);
assert.ok(h.runUntil(() => !u.skill.pending, 10), `${id}: the armed attack is made`);
assert.equal(starts(), 1);
h.b.dealDamage(e, u, { amount: 10, type: 'phys' });
assert.equal(starts(), 2, `${id}: the next hit arms the stored charge`);
assert.ok(u.skill.pending);
// silenced / element-gauge hits never arm it (the engine TAKE_DAMAGE conditions)
assert.ok(h.runUntil(() => !u.skill.pending, 10));
assert.equal(starts().length, 0, 'hits do not arm it');
assert.ok(h.runUntil(() => starts().length >= 2, 15), 'both charges are cast');
const shots = h.hooksOf('attack').filter((c) => c.attacker === u && c.isSkill);
assert.equal(new Set(shots.slice(0, 2).map((c) => c.t)).size, 2, 'one charge per attack');
done(h);
}
// 塞雷娅 S1: DEFAULT + an ally of the area at ≤ half HP — each charge replaces one of her attacks (no heal mode waits)
{
const id = 'chess_char_5_11_b';
const h = run({ defs: { enemies: { enemy_dummy: dummy('enemy_dummy') }, chess: { t_low: ally('t_low'), t_low2: ally('t_low2') } },
units: [entry(id, 'skchr_demkni_1', { row: 10, col: 4 }), { chessId: 't_low', row: 11, col: 4 }, { chessId: 't_low2', row: 9, col: 4 }],
enemies: [{ key: 'enemy_dummy', pos: [10, 4] }] });
const u = sel(h, id, 'skchr_demkni_1');
const e = h.b.enemies[0];
const starts = () => h.hooksOf('skillStart').filter((c) => c.unit === u).length;
h.step();
u.atkCd = 2;
u.skill.gainSp(1000);
h.b.dealDamage(e, u, { type: 'element', element: 'burn', amount: 10 });
assert.equal(starts(), 2, `${id}: an element-gauge hit does not arm it`);
assert.equal(u.skill.charges, 2, `${id}: 2 charges`);
h.unit('t_low').hp = h.unit('t_low').s.maxHp * 0.3;
h.unit('t_low2').hp = h.unit('t_low2').s.maxHp * 0.3;
for (let i = 0; i < 3; i++) h.b.dealDamage(e, u, { amount: 10, type: 'phys' });
h.run(1);
assert.equal(starts(), 0, 'hits do not arm it, nor does the injured ally alone: it waits for her attack');
assert.ok(h.runUntil(() => starts() === 1, 2), 'her attack casts one charge');
assert.ok(!u.skill.pending, 'and is the heal');
assert.equal(u.skill.charges, 1, 'the second charge stays stored');
assert.ok(h.runUntil(() => starts() === 2, 5), 'her next attack heals the other one');
assert.equal(heals(h, u).length, 2);
assert.notEqual(heals(h, u)[0].target, heals(h, u)[1].target);
assert.ok(!u.skill.pending);
mute(h, u);
h.b.dealDamage(e, u, { amount: 10, type: 'phys' });
u.skill.gainSp(1000);
h.unit('t_low').hp = h.unit('t_low').s.maxHp * 0.3;
h.run(3);
assert.equal(starts(), 2, `${id}: silenced`);
done(h);
}
+11 -5
View File
@@ -164,7 +164,9 @@ test('6_01 蕾缪安 S2: a low-HP wanted target is sniped at once (ATK × scale
test('6_02 圣聆初雪 S1 铃音吹雪: 2 charges (cast with enemies in range); atk_scale × ATK arts + cold on every enemy in range, pushed along her direction, snow spreads 5 tiles forward', () => {
for (const id of both('chess_char_6_02')) {
const sid = 'skchr_sbell2_1', bb = bbOf(id, sid);
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [U(id, sid, 10, 4, { carryState: READY })], enemies: [{ key: 'e', pos: [10, 5] }, { key: 'e', pos: [11, 5] }] });
// weight-2 dummies: 中力 (1) − 2 = 受力等级 −1 ⇒ the official 0.44-tile push (PRTS 游戏数据基础 §重量公式), so they stay
// in her range for the second charge
const h = run({ defs: { enemies: { e: dummy('e', { mass: 2 }) } }, units: [U(id, sid, 10, 4, { carryState: READY })], enemies: [{ key: 'e', pos: [10, 5] }, { key: 'e', pos: [11, 5] }] });
const u = h.unit(id);
usesSkill(u, sid);
assert.equal(u.skill.maxCharges, rec(id, sid).maxChargeTime);
@@ -179,11 +181,15 @@ test('6_02 圣聆初雪 S1 铃音吹雪: 2 charges (cast with enemies in range);
assert.equal(colds.length, 2);
for (const c of colds) approx(c.duration, bb.cold, 'cold');
const [e1] = h.enemies();
assert.ok(e1.x > 5.5, `pushed forward (${e1.x})`);
approx(e1.x, 5 + 0.44 * started(h, u).length, 'pushed forward along her direction by the official 0.44 tiles per cast');
approx(e1.y, 10, 'no sideways push');
// (the pushed enemies clear the snow of the tiles they leave: "首个敌人离开该地块时积雪消失")
for (let d = 3; d <= bb.trig_cnt; d++) assert.ok((u.mem.snow.get(10 * COLS + 4 + d) ?? 0) >= 1, `snow ${d} tiles ahead`);
h.run(2.5);
assert.equal(started(h, u).length, 1, 'the next automatic cast waits the 3 s operation cooldown ("自动操作具有3s冷却")');
h.run(1);
assert.equal(started(h, u).length, 2, 'the second charge fires too');
assert.ok(started(h, u)[1].t - t0 >= 3 - 1e-6);
done(h);
}
});
@@ -255,11 +261,11 @@ test('6_03 余 S1 今日做东: taunt +1 while carried; TAKE_DAMAGE cast, HP/DEF
assert.equal(d.unit('chess_char_6_03_a').s.taunt, 0);
});
test('6_03 余 S2 厚礼上宾: atk_scale × ATK arts around him, reachable ground enemies teleported onto his tile; block +2, HP/ATK +, arts attacks', () => {
test('6_03 余 S2 厚礼上宾: cast when hit (重装 TAKE_DAMAGE); atk_scale × ATK arts around him, reachable ground enemies teleported onto his tile; block +2, HP/ATK +, arts attacks', () => {
for (const id of both('chess_char_6_03')) {
const sid = 'skchr_yu_2', bb = bbOf(id, sid);
const h = run({
defs: { enemies: { e: dummy('e') } },
defs: { enemies: { e: dummy('e', { atk: 100, bat: 1 }) } },
units: [U(id, sid, 10, 4, { carryState: READY })],
enemies: [{ key: 'e', pos: [10, 4] }, { key: 'e', pos: [10, 6] }, { key: 'e', pos: [11, 5] }],
});
@@ -1153,7 +1159,7 @@ test('default-skill-only hooks do not run under an alternate skill (余 / 维娜
// 余 S2 active: 闲云隐市 is NOT given to every operator (S3 only)
{
const h = run({
defs: { chess: { o1: plain('o1'), o2: plain('o2'), o3: plain('o3') }, enemies: { e: dummy('e') } },
defs: { chess: { o1: plain('o1'), o2: plain('o2'), o3: plain('o3') }, enemies: { e: dummy('e', { atk: 100, bat: 1 }) } }, // (重装: TAKE_DAMAGE)
units: [U('chess_char_6_03_a', 'skchr_yu_2', 10, 4, { carryState: READY }), { chessId: 'o1', row: 9, col: 6 }, { chessId: 'o2', row: 11, col: 6 }, { chessId: 'o3', row: 12, col: 6 }],
enemies: [{ key: 'e', pos: [10, 4] }],
});
+25 -18
View File
@@ -82,14 +82,14 @@ test('1_01 隐现 elite: +self_ammo after `duration` s on field; a random other
done(h);
});
test('1_02 角峰: 抗寒体质 HP/DEF/RES ×(1+x); 雪原卫士 RES +flat (normal and elite)', () => {
test('1_02 角峰: 抗寒体质 (重装 TAKE_DAMAGE) HP/DEF/RES ×(1+x); 雪原卫士 RES +flat (normal and elite)', () => {
for (const id of ['chess_char_1_02_a', 'chess_char_1_02_b']) {
const bb = bbOf(id), t = tal(id);
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [{ chessId: id, row: 9, col: 5, carryState: READY }], enemies: [{ key: 'e', pos: [9, 5] }] });
const h = run({ defs: { enemies: { e: dummy('e', { atk: 100, bat: 1 }) } }, units: [{ chessId: id, row: 9, col: 5, carryState: READY }], enemies: [{ key: 'e', pos: [9, 5] }] });
const u = h.unit(id);
approx(u.s.res, u.base.res + t.magic_resistance, `${id} talent RES`);
h.step();
assert.ok(u.skill.active);
assert.ok(h.runUntil(() => u.skill.active, 3), 'cast by the first hit');
assert.equal(h.hooksOf('skillStart').find((c) => c.unit === u).reason, 'TAKE_DAMAGE');
approx(u.s.maxHp, u.base.maxHp * (1 + bb.max_hp), 'HP');
approx(u.s.def, u.base.def * (1 + bb.def), 'DEF');
approx(u.s.res, (u.base.res + t.magic_resistance) * (1 + bb.magic_resistance), 'RES');
@@ -131,10 +131,12 @@ test('1_04 深巡: fin darts pierce attack@max_target enemies on the line + 1 s
enemies: [{ key: 'e_sea', pos: [9, 5] }, { key: 'e', pos: [9, 6] }, { key: 'e', pos: [9, 7] }],
});
const u = h.unit(id);
h.step();
h.b.dealDamage(h.enemies()[0], u, { amount: 10, type: 'phys' }); // 重装: TAKE_DAMAGE
h.run(3.5);
assert.ok(u.skill.active);
approx(u.s.atk, u.base.atk * (1 + bb.atk));
const firstAtk = h.hooksOf('attack').find((c) => c.attacker === u);
const firstAtk = h.hooksOf('attack').find((c) => c.attacker === u && c.isSkill);
assert.equal(firstAtk.targets.length, bb['attack@max_target'], 'hits every enemy of the line');
const sl = statuses(h, 'sluggish', (c) => c.source === u);
assert.ok(sl.length >= 3 && sl.every((c) => c.duration === bb['attack@sluggish']));
@@ -328,7 +330,7 @@ test('1_09 跃跃 回环射手: each attack is a boomerang (out 15, back 3.75 ti
done(h2);
});
test('1_10 古米: TAKE_DAMAGE 备用军粮 heals the most injured nearby ally for heal_scale × ATK; elite low-HP bonus; pan stun', () => {
test('1_10 古米: 备用军粮 (自动触发: an injured ally of the skill range) heals the most injured nearby ally for heal_scale × ATK; elite low-HP bonus; pan stun', () => {
for (const id of ['chess_char_1_10_a', 'chess_char_1_10_b']) {
const bb = bbOf(id), tb = tbOf(id), t = tal(id);
const h = run({
@@ -343,7 +345,7 @@ test('1_10 古米: TAKE_DAMAGE 备用军粮 heals the most injured nearby ally f
const hh = heals(h, u, (c) => c.target === yak)[0];
const k = tb.hp_ratio != null ? tb.heal_scale : 1;
approx(hh.amount, u.s.atk * bb.heal_scale * k, `${id} heal`);
assert.equal(h.hooksOf('skillStart').find((c) => c.unit === u).reason, 'TAKE_DAMAGE');
assert.equal(h.hooksOf('skillStart').find((c) => c.unit === u).reason, 'SKILL_RANGE');
// talent: pan procs stun the enemy t.stun s
h.runUntil(() => statuses(h, 'stun', (c) => c.source === u).length > 0, 200);
const st = statuses(h, 'stun', (c) => c.source === u)[0];
@@ -588,11 +590,12 @@ test('1_20 雷蛇: 反击电弧 arts on up to 3 enemies, self-stun `stun` s afte
const u = h.unit(id);
h.run(3);
assert.ok(u.skill.active);
assert.equal(h.hooksOf('skillStart').find((c) => c.unit === u).reason, 'TAKE_DAMAGE', '重装: cast by the hit');
approx(u.s.atk, u.base.atk * (1 + bb.atk));
approx(u.s.interval, u.base.bat * (1 + bb.base_attack_time) * 100 / u.s.aspd, 'attack interval +70 % (PRTS 增大(+70%): a ratio, not +0.7 s)');
const a0 = h.hooksOf('attack').find((c) => c.attacker === u);
const a0 = h.hooksOf('attack').find((c) => c.attacker === u && c.isSkill);
assert.equal(a0.targets.length, 3);
assert.ok(dealt(h, u, (c) => c.dmg.isAttack).every((c) => c.type === 'arts'));
assert.ok(dealt(h, u, (c) => c.dmg.isAttack && c.dmg.isSkill).every((c) => c.type === 'arts'));
h.runUntil(() => !u.skill.active, 30);
h.step();
const st = statuses(h, 'stun', (c) => c.target === u);
@@ -631,9 +634,11 @@ test('2_01 送葬人: 最终旅程 double hits and shorter interval; 终结改
done(h);
});
test('2_02 赫默: 医疗无人机 lands next to the injured ally and expires; 强化注射 MEDIC ASPD; elite ×1.15 on ground units', () => {
test('2_02 赫默: 医疗无人机 lands on the tile its piece was placed on and expires; 强化注射 MEDIC ASPD; elite ×1.15 on ground units', () => {
const id = 'chess_char_2_02_a', t = tal(id);
const h = run({ units: [{ chessId: id, row: 10, col: 4, carryState: READY }, { chessId: 'chess_char_1_02_a', row: 9, col: 6 }, { chessId: 'chess_char_2_14_a', row: 12, col: 4 }] });
// the drone is a hand piece the player placed next to the ally (user playtest #6); S2 brings it there
const h = run({ units: [{ chessId: id, row: 10, col: 4, carryState: READY, uid: 1 }, { chessId: 'chess_char_1_02_a', row: 9, col: 6 }, { chessId: 'chess_char_2_14_a', row: 12, col: 4 },
{ kind: 'token', tokenId: 'token_10000_silent_healrb', ownerUid: 1, row: 9, col: 5 }] });
const u = h.unit(id), yak = h.unit('chess_char_1_02_a'), fl = h.unit('chess_char_2_14_a');
h.step();
yak.hp = yak.s.maxHp * 0.3;
@@ -643,7 +648,7 @@ test('2_02 赫默: 医疗无人机 lands next to the injured ally and expires;
approx(yak.s.aspd, yak.base.aspd, 'non-medic unaffected');
const drone = h.b.allyUnits.find((x) => x.kind === 'token');
assert.ok(drone && drone.alive, 'drone deployed');
assert.ok(Math.max(Math.abs(drone.tileR - yak.tileR), Math.abs(drone.tileC - yak.tileC)) <= 1, 'next to the injured ally');
assert.deepEqual([drone.tileR, drone.tileC], [9, 5], 'on its placed tile, next to the injured ally');
assert.ok(drone.s.flags.untargetable);
h.run(10);
assert.equal(drone.alive, false, 'self-destructs after its lifetime');
@@ -669,7 +674,8 @@ test('2_03 崖心 (hidden): 束缚链 drags max_target enemies to the front tile
const pulled = h.enemies().filter((e) => e.x < 6);
assert.equal(pulled.length, bb.max_target);
for (const e of pulled) {
assert.ok(Math.hypot(e.x - 5, e.y - 10) < 0.3, `at the front tile (${e.x},${e.y})`);
// 中力 vs weight 1 (受力等级 0): "必定拉至身前" — to the 急停 radius 0.6708 around her centre (PRTS 推与拉)
approx(Math.hypot(e.x - 4, e.y - 10), 0.6708, `at her front (${e.x},${e.y})`, 1e-6);
const tr = dealt(h, u, (c) => c.target === e && c.type === 'true');
assert.equal(tr.length, 1);
approx(tr[0].amount, u.s.atk * bb.atk_scale);
@@ -686,7 +692,7 @@ test('2_03 崖心 (hidden): 束缚链 drags max_target enemies to the front tile
h3.step();
const drag = dealt(h3, h3.unit(idb), tagged('drag'));
assert.equal(drag.length, 1);
approx(drag[0].amount, tb.value * 2 / tb.dist, 'value per dist tiles', 0.06);
approx(drag[0].amount, tb.value * (3 - 0.6708) / tb.dist, 'value per dist tiles', 0.06);
done(h3);
});
@@ -1065,8 +1071,7 @@ test('2_17 折桠: 生存决心 trembles ground enemies around, ATK/DEF up, hits
units: [{ chessId: id, row: 9, col: 5, carryState: READY }], enemies: [{ key: 'e', pos: [9, 5] }, { key: 'r', pos: [10, 6] }, { key: 'e', pos: [9, 8] }],
});
const u = h.unit(id);
h.step();
assert.ok(u.skill.active);
assert.ok(h.runUntil(() => u.skill.active, 2), '重装 TAKE_DAMAGE: cast by the first hit');
approx(u.s.atk, u.base.atk * (1 + bb.atk));
approx(u.s.def, u.base.def * (1 + bb.def));
const [blk, adj, far] = h.enemies();
@@ -1227,8 +1232,9 @@ test('1_19 野鬃 normal: 一致向前 only on the first deployment', () => {
done(h);
});
test('2_02 赫默: the drone never lands on the home tile of a knocked-out operator', () => {
const h = run({ units: [{ chessId: 'chess_char_2_02_a', row: 10, col: 4, carryState: READY }, { chessId: 'chess_char_1_02_a', row: 10, col: 5 }, { chessId: 'chess_char_1_10_a', row: 9, col: 5 }] });
test('2_02 赫默: the drone takes its own placed tile, never the home tile of a knocked-out operator', () => {
const h = run({ units: [{ chessId: 'chess_char_2_02_a', row: 10, col: 4, carryState: READY, uid: 1 }, { chessId: 'chess_char_1_02_a', row: 10, col: 5 }, { chessId: 'chess_char_1_10_a', row: 9, col: 5 },
{ kind: 'token', tokenId: 'token_10000_silent_healrb', ownerUid: 1, row: 11, col: 5 }] });
h.step();
const yak = h.unit('chess_char_1_02_a'), gum = h.unit('chess_char_1_10_a');
gum.hp = gum.s.maxHp * 0.3;
@@ -1236,6 +1242,7 @@ test('2_02 赫默: the drone never lands on the home tile of a knocked-out opera
h.run(0.5);
const d = h.b.allyUnits.find((x) => x.kind === 'token');
assert.ok(d && d.alive, 'drone deployed');
assert.deepEqual([d.tileR, d.tileC], [11, 5], 'its placed tile');
assert.ok(!(d.tileR === yak.homeR && d.tileC === yak.homeC), 'not on the dead operator tile');
done(h);
});
+41 -22
View File
@@ -508,11 +508,12 @@ test('3_14 初雪: 自然震慑 DEF/RES shred aura; 虚弱化 fragile under 40 %
done(h);
});
test('3_15 巫恋: 诅咒娃娃 placed where enemies are, −ATK/DEF around it, gone after its lifetime; 溃败暗示 fragile', () => {
test('3_15 巫恋: S2 brings the placed 诅咒娃娃 onto its tile, −ATK/DEF around it, gone after its lifetime; 溃败暗示 fragile', () => {
const id = 'chess_char_3_15_a', bb = BB(id), t0 = TB(id, 0);
const h = makeBattle({
defs: { enemies: { enemy_a: dummy('enemy_a', { def: 200, atk: 100 }), enemy_b: dummy('enemy_b', { def: 200, atk: 100 }) } }, timeLimit: 80,
units: [{ chessId: id, row: 10, col: 3 }], enemies: [{ key: 'enemy_a', pos: [11, 5] }, { key: 'enemy_b', pos: [10, 5] }],
units: [{ chessId: id, row: 10, col: 3, uid: 1 }, { kind: 'token', tokenId: 'token_10006_vodfox_doll', ownerUid: 1, row: 10, col: 4, uid: 2 }],
enemies: [{ key: 'enemy_a', pos: [11, 5] }, { key: 'enemy_b', pos: [10, 5] }],
});
const u = h.unit(id);
h.step();
@@ -520,15 +521,20 @@ test('3_15 巫恋: 诅咒娃娃 placed where enemies are, −ATK/DEF around it,
a.hp = a.s.maxHp * (t0.hp_ratio / 2);
h.run(0.3);
approx(a.s.dmgTakenMul, t0.damage_scale);
const doll = h.unit(2);
const life = ds.getToken('token_10006_vodfox_doll', id).skill.duration;
assert.equal(doll.alive, true, 'the placed doll deploys once at the start (PRTS; shared/constants.js SKILL_SUMMON_START_DEPLOY)');
assert.ok(h.runUntil(() => !doll.alive, life + 1), 'its lifetime');
h.run(doll.base.respawnTime + 0.1);
assert.equal(doll.alive, u.skill.activations > 0, 'then only with S2');
const n0 = u.skill.activations;
fill(u);
assert.ok(h.runUntil(() => u.skill.activations === 1, 5));
const doll = u.mem.doll;
assert.ok(doll && doll.alive && doll.defId === 'token_10006_vodfox_doll');
assert.ok(Math.abs(doll.tileR - 10.5) <= 1 && Math.abs(doll.tileC - 5) <= 1, 'next to both enemies');
assert.ok(h.runUntil(() => u.skill.activations === n0 + 1 && doll.alive, 5));
assert.ok(doll.alive && doll.defId === 'token_10006_vodfox_doll');
assert.deepEqual([doll.tileR, doll.tileC], [10, 4], 'on the tile the player chose');
h.run(0.3);
approx(b.s.def, 200 * (1 + bb.def));
approx(b.s.atk, 100 * (1 + bb.atk));
const life = ds.getToken('token_10006_vodfox_doll', id).skill.duration;
h.run(life);
assert.equal(doll.alive, false, 'destroyed');
h.run(0.3);
@@ -608,7 +614,7 @@ test('3_18 忍冬: 隐狐之艺 DP, decaying ASPD, all blocked + stun, camouflag
assert.ok(h.hooksOf('attack').some((c) => c.attacker === u && c.isSkill && c.targets.length === 2), 'hits every blocked enemy');
assert.ok(h.hooksOf('statusApplied').some((c) => c.source === u && c.status === 'stun' && Math.abs(c.duration - bb['attack@stun']) < 1e-9));
h.runUntil(() => !u.skill.active, 20);
assert.ok(!u.s.flags.stealth, 'no kill ⇒ no camouflage');
assert.ok(!u.s.flags.camou, 'no kill ⇒ no camouflage');
done(h);
const c = makeBattle({
@@ -621,11 +627,11 @@ test('3_18 忍冬: 隐狐之艺 DP, decaying ASPD, all blocked + stun, camouflag
c.spawn('enemy_f', { pos: [10, 5] });
assert.ok(c.runUntil(() => z.skill.active, 5));
c.runUntil(() => !z.skill.active, 20);
assert.ok(z.s.flags.stealth, 'camouflage after a kill during the skill');
assert.ok(z.s.flags.camou && !z.s.flags.stealth, 'camouflage (迷彩, not 隐匿) after a kill during the skill');
fill(z);
c.spawn('enemy_f', { pos: [10, 5] });
assert.ok(c.runUntil(() => z.skill.activations === 2, 5));
assert.ok(!z.s.flags.stealth, 'until the next cast');
assert.ok(!z.s.flags.camou, 'until the next cast');
done(c);
const q = makeBattle({ units: [{ chessId: id, row: 10, col: 4 }], timeLimit: 30 });
@@ -937,28 +943,41 @@ test('3_10 松果: 电能过载 opens only once an enemy stands inside the short
done(h);
});
test('3_15 巫恋: a cast replaces the doll placed in the prep phase (one doll), never on a dead operator’s tile; 精锐 −30 %', () => {
test('3_15 巫恋: the doll placed in the prep phase deploys at the start, then waits for S2 and is the one doll (each cast brings it back); 精锐 −30 %', () => {
const gid = 'chess_char_3_15_b', bb = BB(gid);
const h = makeBattle({
defs: { enemies: { enemy_a: dummy('enemy_a', { def: 200, atk: 100 }), enemy_b: dummy('enemy_b', { def: 200, atk: 100 }) } }, timeLimit: 60,
defs: { enemies: { enemy_a: dummy('enemy_a', { def: 200, atk: 100 }), enemy_b: dummy('enemy_b', { def: 200, atk: 100 }) } }, timeLimit: 120,
units: [{ chessId: gid, row: 10, col: 3, uid: 1 }, { kind: 'token', tokenId: 'token_10006_vodfox_doll', ownerUid: 1, row: 10, col: 5, uid: 2 },
{ chessId: 'chess_char_3_16_a', row: 11, col: 4, uid: 3 }],
enemies: [{ key: 'enemy_a', pos: [12, 4] }, { key: 'enemy_b', pos: [11, 5] }],
});
const u = h.unit(1), piece = h.unit(2), cu = h.unit(3);
const u = h.unit(1), piece = h.unit(2);
h.step();
assert.ok(piece.alive && piece.ownerUnit === u, 'board doll deployed');
h.b.dealDamage(null, cu, { type: 'true', amount: 1e7 });
assert.ok(piece.alive && piece.ownerUnit === u, 'the board doll deploys once at the start');
assert.ok(h.runUntil(() => !piece.alive, 20), 'its lifetime');
h.run(piece.base.respawnTime + 0.1);
const n0 = u.skill.activations;
assert.equal(piece.alive, n0 > 0, 'then only with the skill');
fill(u);
assert.ok(h.runUntil(() => u.skill.activations === 1, 5));
const dolls = h.b.allyUnits.filter((t) => t.alive && t.defId === 'token_10006_vodfox_doll');
assert.equal(dolls.length, 1, 'deploy limit 1');
assert.equal(piece.alive, false, 'the prep-phase doll was replaced');
assert.notDeepEqual([dolls[0].tileR, dolls[0].tileC], [11, 4], 'not on the dead operator’s home tile');
assert.ok(h.runUntil(() => u.skill.activations === n0 + 1 && piece.alive, 5));
const dolls = () => h.b.allyUnits.filter((t) => t.alive && t.defId === 'token_10006_vodfox_doll');
assert.equal(dolls().length, 1, 'one doll');
assert.equal(dolls()[0], piece, 'the prep-phase piece itself');
assert.deepEqual([piece.tileR, piece.tileC], [10, 5]);
h.run(0.3);
const b = h.enemy('enemy_b');
approx(b.s.def, 200 * (1 + bb.def));
assert.equal(b.findBuff('skill:vodfox_doll').mods.atkPct, bb.atk, 'ATK −30 % (her module weaken stacks on top)');
assert.equal(b.findBuff('token:curseDoll').mods.atkPct, bb.atk, 'ATK −30 % (her module weaken stacks on top)');
// gone after its lifetime, back on the same tile with the next cast
assert.ok(h.runUntil(() => !piece.alive, 20));
const gone = h.b.time;
fill(u);
assert.ok(h.runUntil(() => u.skill.activations === n0 + 2, 5));
// the doll's redeploy time (data respawnTime 5 s) runs from the moment it left
assert.ok(h.runUntil(() => piece.alive, 6));
assert.ok(h.b.time - gone >= piece.base.respawnTime - 1e-6, 'after its redeploy time');
assert.ok(piece.tileR === 10 && piece.tileC === 5, 'the next cast: the same tile');
assert.equal(dolls().length, 1);
done(h);
});
@@ -1134,7 +1153,7 @@ test('忍冬 迷彩: after a kill during 隐狐之艺 ranged enemies stop target
h.spawn('enemy_f', { pos: [10, 5] });
assert.ok(h.runUntil(() => u.skill.active, 5));
h.runUntil(() => !u.skill.active, 20);
assert.ok(u.s.flags.stealth);
assert.ok(u.s.flags.camou);
h.spawn('enemy_r', { pos: [11, 6] });
h.run(4);
assert.equal(h.hooksOf('damaged').filter((c) => c.target === u && c.source?.defId === 'enemy_r').length, 0, 'not targeted while camouflaged');
+25 -12
View File
@@ -389,9 +389,17 @@ test('阿罗玛 elite module: farther targets take up to ×1.1', () => {
approx(hits[1].amount, u.s.atk * (1 + tb.damage_scale), 1e-6, 'distance 5 ≥ max_dist');
});
test('凯瑟琳: 2 support devices shield allies (20 % of her HP), 6 %/s after 5 s unhit; S2: 6 %/s regardless, no attacks', () => {
test('凯瑟琳: 2 placed support devices shield allies (20 % of her HP), 6 %/s after 5 s unhit; S2: 6 %/s regardless, no attacks', () => {
const id = 'chess_char_4_11_a', bb = D(id).skill.bb;
const h = makeBattle({ units: [{ chessId: id, row: 10, col: 5 }, { chessId: 'chess_char_1_02_a', row: 9, col: 6 }], timeLimit: 120, hooks: ['attack'], captureNoisy: true });
// the devices are hand pieces the player places and turns (user playtest #6): one faces the ally, one faces her
const h = makeBattle({
units: [
{ chessId: id, row: 10, col: 5, uid: 1 }, { chessId: 'chess_char_1_02_a', row: 9, col: 6, uid: 2 },
{ kind: 'token', tokenId: 'token_10041_cathy_catsld', ownerUid: 1, row: 9, col: 5, uid: 3, dir: 'RIGHT' },
{ kind: 'token', tokenId: 'token_10041_cathy_catsld', ownerUid: 1, row: 10, col: 6, uid: 4, dir: 'LEFT' },
],
timeLimit: 120, hooks: ['attack'], captureNoisy: true,
});
h.run(0.2);
const u = h.unit(id), ally = h.unit('chess_char_1_02_a');
const devs = h.b.allyUnits.filter((x) => x.defId === 'token_10041_cathy_catsld' && x.alive);
@@ -1103,7 +1111,8 @@ test('elite (精锐) kits read the Lv7 blackboard: skill magnitudes differ from
assert.equal(waves.size, D('chess_char_4_16_b').skill.duration);
assert.ok([...waves.values()].every((n) => n === bb.max_target));
}
// 歌蕾蒂娅: force 1 pulls twice as far per pulse as force 0
// 歌蕾蒂娅 S3 tornado pulse on a weight-1 enemy √2 from the marked point (PRTS 推与拉): elite 中力 (1) = 受力等级 0 ⇒ all the
// way to the point (its 0.05 急停); normal 小力 (0) = −1 ⇒ 35 % of the starting distance
{
const pulled = (id) => {
const [h, u] = mk(id);
@@ -1115,7 +1124,8 @@ test('elite (精锐) kits read the Lv7 blackboard: skill magnitudes differ from
h.run(D(id).skill.bb.interval + 0.1);
return Math.hypot(side.x - x0, side.y - y0);
};
approx(pulled('chess_char_4_12_b'), 2 * pulled('chess_char_4_12_a'), 0.05);
approx(pulled('chess_char_4_12_b'), Math.SQRT2 - 0.05, 0.02);
approx(pulled('chess_char_4_12_a'), 0.35 * Math.SQRT2, 0.02);
}
});
@@ -1137,18 +1147,21 @@ test('焰尾 S3 dodge stacks independently with the 卡西米尔 aura (elite 80
}
});
test('凯瑟琳: a device sits orthogonally next to its operator and re-shields it after it redeploys on that tile', () => {
test('凯瑟琳: a placed device serves the operator it faces and re-shields it after it redeploys on that tile', () => {
const id = 'chess_char_4_11_a';
const h = makeBattle({ units: [{ chessId: id, row: 10, col: 5 }, { chessId: 'chess_char_1_02_a', row: 9, col: 7 }], timeLimit: 200, hooks: [] });
const h = makeBattle({
units: [
{ chessId: id, row: 10, col: 5, uid: 1 }, { chessId: 'chess_char_1_02_a', row: 9, col: 7, uid: 2 },
{ kind: 'token', tokenId: 'token_10041_cathy_catsld', ownerUid: 1, row: 9, col: 6, uid: 3, dir: 'RIGHT' },
],
timeLimit: 200, hooks: [],
});
h.run(0.2);
const u = h.unit(id), ally = h.unit('chess_char_1_02_a');
const tb = h.b.data.getToken('token_10041_cathy_catsld', u.defId).talents[0].bb;
const devs = h.b.allyUnits.filter((x) => x.defId === 'token_10041_cathy_catsld' && x.alive);
for (const d of devs) {
const t = h.b.unitAt(d.mem.tr, d.mem.tc);
assert.equal(Math.abs(d.tileR - d.mem.tr) + Math.abs(d.tileC - d.mem.tc), 1, 'orthogonal neighbour');
assert.ok(t && t.kind === 'op');
}
const dev = h.b.allyUnits.find((x) => x.defId === 'token_10041_cathy_catsld' && x.alive);
assert.ok(dev && dev.tileR === 9 && dev.tileC === 6, 'on its placed tile');
assert.equal(dev.mem.target, ally, 'serves the operator in front of it');
h.b.dealDamage(null, ally, { amount: 1e9, type: 'true' });
assert.equal(ally.alive, false);
assert.ok(h.runUntil(() => ally.alive, 150), 'redeployed');
+17 -11
View File
@@ -376,6 +376,9 @@ test('号角 S3: ATK +25 %, BAT −1.2 s, then overload ATK +50 % with HP loss;
approx(h.unit('t_tank').s.atk, 100 * (1 + t0.atk), 1e-6, '军事要塞 on another Defender');
approx(u.s.atk, u.base.atk * (1 + t0.atk), 1e-6, '… and on herself');
u.skill.gainSp(1000);
h.run(1);
assert.equal(u.skill.activations, 0, '重装 TAKE_DAMAGE: not before a hit');
h.b.dealDamage(h.enemies()[0], u, { amount: 10, type: 'phys' });
assert.ok(h.runUntil(() => u.skill.active, 5));
const start = h.b.time;
const total = u.def.skill.duration, ov = bb['horn_s_3[overload_start].damage_duration'];
@@ -622,7 +625,9 @@ test('凛御银灰 S2: 6 front enemies in the skill range take 260 % ATK phys +
});
// ------------------------------------------------------------------------------------------------------------------
test('凛御银灰 S2 auto-cast follows DEFAULT: an enemy in his initial (melee) range, not merely in the skill range', () => {
test('凛御银灰 S2 auto-cast: SKILL_RANGE — an enemy inside the skill range casts it, no attack needed (PRTS 技能策略)', () => {
// PRTS 卫戍协议/帮助 技能操作: "携带拥有技能范围的技能(非攻击距离增加)的干员:不通过普通攻击/治疗触发技能,仅在技能范围内存在敌人
// (无视其不可选中)时释放技能" (it used to wait for an enemy in his melee range — user playtest #6 report 15)
const h = makeBattle({
defs: { enemies: { enemy_dummy: dummy() } },
units: [{ chessId: 'chess_char_5_14_a', row: 10, col: 3 }],
@@ -630,15 +635,14 @@ test('凛御银灰 S2 auto-cast follows DEFAULT: an enemy in his initial (melee)
hooks: ['damaged'], captureNoisy: true, autoFinish: false, timeLimit: 30,
});
const u = h.unit('chess_char_5_14_a');
assert.equal(u.skill.rule, 'SKILL_RANGE');
h.step();
u.skill.gainSp(1000);
h.run(3);
assert.equal(u.skill.activations, 0, 'enemies only in the skill range: no cast');
const e = h.spawn('enemy_dummy', { pos: [10, 4] });
assert.ok(h.runUntil(() => u.skill.activations > 0, 3), 'cast right before attacking the enemy in front');
h.run(0.2);
assert.equal(u.skill.activations, 1, 'enemies only in the skill range: cast');
assert.equal(h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isAttack && !c.dmg.isSkill).length, 0, 'no attack needed');
const hits = tagged(h, 'svash2');
assert.equal(new Set(hits.map((c) => c.target.id)).size, 3, 'the burst still covers the whole skill range');
assert.ok(hits.some((c) => c.target === e));
assert.equal(new Set(hits.map((c) => c.target.id)).size, 2, 'the burst covers the skill range');
clean(h);
});
@@ -937,8 +941,8 @@ test('录武官 S2: ATK +45 %; healed allies heal 80 HP per hit taken for 10 s;
const hp = a.hp;
h.b.dealDamage(null, a, { amount: 500, type: 'true' });
approx(a.hp, hp - 500 + bb['attack@fixed_heal_value'], 1e-6);
// "治疗干员后": a summon healed by her gets no guard
const tok = h.b.spawnToken(a, 'token_10015_dusk_drgn', 11, 3, { kit: { skill: null } });
// "治疗干员后": a summon healed by her gets no guard (纸偶: a summon without 禁疗 — “小自在” holds it, PRTS)
const tok = h.b.spawnToken(a, 'token_10022_kazema_shadow', 11, 3, { kit: { skill: null } });
tok.hp = 100;
assert.ok(h.runUntil(() => tok.hp > 100, 5));
assert.ok(!tok.hasBuff('reckpr:guard'));
@@ -1236,7 +1240,7 @@ test('elite 夕 / 白面鸮: the module range is part of the initial range the D
clean(h);
});
test('安洁莉娜 T2 兼职工作: no regeneration for allies under 禁疗 (PRTS)', () => {
test('安洁莉娜 T2 兼职工作: an HP-regen attribute — allies under 禁疗 regenerate too (PRTS 备注 "不受治疗加成和禁疗影响")', () => {
const h = makeBattle({
defs: { chess: { t_a: ally('t_a'), t_b: ally('t_b') } },
units: [{ chessId: 'chess_char_5_20_a', row: 10, col: 4 }, { chessId: 't_a', row: 12, col: 4 }, { chessId: 't_b', row: 12, col: 6 }],
@@ -1248,7 +1252,9 @@ test('安洁莉娜 T2 兼职工作: no regeneration for allies under 禁疗 (PRT
a.hp = b.hp = 5000;
h.run(2);
assert.ok(a.hp > 5000);
assert.equal(b.hp, 5000, '禁疗');
assert.ok(b.hp > 5000, '禁疗 does not stop a 生命回复速度 attribute (PRTS 异常效果 禁疗)');
approx(b.hp, a.hp, 1e-6, 'same regen');
assert.equal(h.b.heal(h.unit('chess_char_5_20_a'), b, 100), 0, 'while heals still miss it');
clean(h);
});
+27
View File
@@ -760,6 +760,29 @@ test('锏: S3 = 10 slashes (180 % ×1.6 talent) on ≤5 enemies + a 300 % finish
checkInvariants(h.b);
});
test('锏 S3: invulnerable, stun- and freeze-immune during the slashes (PRTS 备注); normal again after them', () => {
const h = battle({
units: [{ chessId: 'chess_char_6_19_a', row: 10, col: 5, carryState: { sp: 34 } }],
enemies: [{ key: 'enemy_dummy', pos: [10, 6] }],
});
const u = h.unit('chess_char_6_19_a');
assert.ok(h.runUntil(() => u.skill.active, 10));
h.step();
const hp = u.hp;
h.b.dealDamage(null, u, { amount: 500, type: 'true' });
assert.equal(u.hp, hp, '无敌');
assert.equal(h.b.applyStatus(u, 'stun', { duration: 3 }), false, '晕眩免疫');
assert.equal(h.b.applyStatus(u, 'freeze', { duration: 3 }), false, '冻结免疫');
h.b.applyStatus(u, 'cold', { duration: 5 });
h.b.applyStatus(u, 'cold', { duration: 5 });
assert.ok(!u.s.flags.freeze, 'cold on cold does not freeze her either');
assert.ok(h.runUntil(() => !u.skill.active, 10));
h.b.dealDamage(null, u, { amount: 500, type: 'true' });
assert.ok(u.hp < hp, 'takes damage again after the slashes');
assert.ok(h.b.applyStatus(u, 'stun', { duration: 1 }), 'can be stunned again');
checkInvariants(h.b);
});
test('纯烬艾雅法拉: S3 heals across the whole field in 5 shots (35 %); 火山灰疗愈 max HP +6 %; 氤氲 HoT', () => {
const h = battle({
units: [
@@ -969,6 +992,10 @@ test('缪尔赛思: 流形 copies stats/range/damage type but not the attack sha
enemies: [{ key: 'enemy_dummy', pos: [10, 6] }],
});
const d = h.unit('chess_char_6_19_a');
// 锏 only lends her stats here: her S3 fires on its own 技能范围 (playtest #6 item 15) and pulls the dummy all the way
// to her (official pull, item 14), where she blocks it outside the copy's range — keep her SP empty (阻回)
h.step();
assert.ok(h.b.addBuff(d, { key: 'test:noSp', flags: { noSp: true }, persist: true }));
const tok = () => h.b.allyUnits.find((x) => x.defId === 'token_10030_mlyss_wtrman' && x.alive);
h.run(6);
const t = tok();
+15 -9
View File
@@ -1,6 +1,8 @@
// Content tests for user playtest #4 items 11 and 12 (server/sim/content/tokens.js, kits/tier2.js):
// #11 赫默's 医疗无人机 appears only when her S2 fires — never on the field at battle start (tools/build-data.mjs:
// a skill's summon is not a prep hand card; test/match/loadout.test.js covers the prep side);
// #11 赫默's 医疗无人机 comes with her S2 — since user playtest #6 it is a hand piece the player places, it deploys
// once on that tile at the battle start (PRTS 卫戍协议/帮助 §作战阶段; settled by the user after playtest #6 —
// shared/constants.js SKILL_SUMMON_START_DEPLOY) and re-appears there each time S2 fires
// (test/content/playtest6_summons.test.js, the prep side test/match/playtest6_summons.test.js);
// #12 the 炎 bond's “炎佑” (PRTS “炎佑” 级别0(卫戍协议)): follows the highest-aggro enemy of the whole field and stays
// where it is without one (no fixed return point); 祛恶之焰 [模式乙] locks one target, channels up to 20 s (no normal
// attacks meanwhile), ends when the lock is lost or on silence; burn on every damage it deals; 元素脆弱 aura
@@ -20,19 +22,23 @@ const flameRec = (h) => h.b.data.rawToken(TOKEN_IDS.yanyou).skills.find((s) => s
// =====================================================================================================================
// #11 赫默
test('#11 赫默 (full kit, default S2): no drone at battle start; the 医疗探机 appears when 医疗无人机 fires', REAL, () => {
const h = makeBattle({ defs: { chess: { test_guard: guard() } }, units: [{ chessId: 'chess_char_2_02_a', row: 10, col: 3 }, { chessId: 'test_guard', row: 10, col: 4 }], autoFinish: false, timeLimit: 90 });
test('#11 赫默 (full kit, default S2): the placed 医疗探机 deploys once at battle start (10 s), then again when 医疗无人机 fires', REAL, () => {
const h = makeBattle({
defs: { chess: { test_guard: guard() } },
units: [{ chessId: 'chess_char_2_02_a', row: 10, col: 3, uid: 1 }, { chessId: 'test_guard', row: 10, col: 4, uid: 2 }, { kind: 'token', tokenId: TOKEN_IDS.healDrone, ownerUid: 1, row: 9, col: 4, uid: 3 }],
autoFinish: false, timeLimit: 90,
});
h.step();
const hm = h.unit('chess_char_2_02_a');
assert.equal(hm.skill.id, 'skchr_silent_2');
const drones = () => h.b.allyUnits.filter((u) => u.defId === TOKEN_IDS.healDrone && u.alive);
assert.equal(drones().length, 0, 'nothing but the operators at t = 0');
assert.equal(drones().length, 1, 'the free start deploy (PRTS: "作战开始时无视持有状态自动部署1个")');
assert.deepEqual([drones()[0].tileR, drones()[0].tileC], [9, 4], 'on its piece\'s tile');
h.unit('test_guard').hp = 1000;
const cost = hm.skill.spCost;
h.run(cost - 2);
h.run(10.5);
assert.equal(hm.skill.activations, 0);
assert.equal(drones().length, 0, 'no drone before the skill is ready');
assert.ok(h.runUntil(() => hm.skill.activations >= 1, 10), 'S2 fires once its SP is full (initSp 0)');
assert.equal(drones().length, 0, 'gone after its 10 s; no second one before the skill');
assert.ok(h.runUntil(() => hm.skill.activations >= 1, 30), 'S2 fires once its SP is full (initSp 0)');
h.step();
assert.equal(drones().length, 1, 'the drone comes with the skill');
assert.equal(drones()[0].ownerUnit, hm);
+241
View File
@@ -0,0 +1,241 @@
// User playtest #6 items 1 and 2 (from a friend): 凯瑟琳's device and 赫默's drone could not be placed by hand. The
// prep side is test/match/playtest6_summons.test.js; this file is the battle side of the placed pieces
// (server/sim/content/tokens.js dockSkillSummons / releaseSkillSummon, Battle.start `deferDeploy`, kits tier2 / tier3 /
// tier4, the 爬行号·防护单元 token kit):
// * PRTS 卫戍协议/帮助 §作战阶段: the board summons deploy after the operators; "若战场区初始部署有召唤物,若召唤物在
// 战斗期间退场,将在满足条件后立即原地再部署1个" — a placed piece marks the tile its summon deploys on;
// * a skill's summon (赫默 医疗探机, 巫恋 诅咒娃娃: "获得一个…") deploys once on its tile at the battle start, for free
// ("所有手动部署的召唤物,无视所属干员的持有状态…作战开始时立即部署一次", example 赫默's drone; settled by the user
// after playtest #6 — shared/constants.js SKILL_SUMMON_START_DEPLOY), then waits on its tile and takes the field
// there each time the owner's skill gives one — also as soon as its owner is back with one in stock;
// not placed ⇒ it never appears (the hidden 待部署区 deploys nothing by itself);
// * a talent's summon the owner holds from the start (凯瑟琳 "携带3个支援装置(最多部署2个)") deploys with the board;
// * not placed: a skill's summon, a device or 海嗣 never appears; the tacticians' 狼群 / 流形 still come as their 援军 on
// a tactical point (docs/PLAYING.md §4 and docs/SIM.md say exactly this).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, chessRec, checkInvariants } from '../helpers/battleHarness.js';
import { hasGeneratedData } from '../../server/sim/simdata.js';
import { TOKEN_IDS } from '../../server/sim/content/tokens.js';
const REAL = { skip: !hasGeneratedData() };
const guard = (o = {}) => chessRec({ id: 'test_guard', profession: 'WARRIOR', skill: null, stats: { atk: 0, maxHp: 1e5, def: 0, blockCnt: 2 }, ...o });
const SILENCE = 'chess_char_2_02_a';
const CATHY = 'chess_char_4_11_a';
const SHAMARE = 'chess_char_3_15_a';
test('#2 赫默: the placed 医疗探机 deploys once at the start (10 s); each S2 brings it back onto its own tile', REAL, () => {
const h = makeBattle({
defs: { chess: { test_guard: guard() } },
units: [{ chessId: SILENCE, row: 10, col: 3, uid: 1 }, { chessId: 'test_guard', row: 10, col: 4, uid: 2 }, { kind: 'token', tokenId: TOKEN_IDS.healDrone, ownerUid: 1, row: 11, col: 4, uid: 3, dir: 'RIGHT' }],
autoFinish: false, timeLimit: 120, hooks: ['death', 'deploy'],
});
h.step();
const hm = h.unit(SILENCE);
const piece = h.b.allyUnits.find((u) => u.uid === 3);
assert.ok(piece, 'the board piece is a unit of the battle');
assert.equal(piece.alive, true, 'the free start deploy, with the board');
assert.deepEqual([piece.tileR, piece.tileC], [11, 4]);
const order = h.hooksOf('deploy').filter((c) => c.initial).map((c) => c.unit);
assert.ok(order.indexOf(piece) > order.indexOf(hm) && order.indexOf(piece) > order.indexOf(h.unit('test_guard')), 'after the operators');
assert.equal(hm.skill.activations, 0, 'before any skill: the start deploy ignores the holding');
assert.ok(h.runUntil(() => !piece.alive, 12));
assert.ok(Math.abs(piece.deathAt - 10) < 0.1, 'gone after its 10 s');
assert.equal(h.b.isReservedTile(11, 4), true, 'its tile stays reserved');
const drones = () => h.b.allyUnits.filter((u) => u.defId === TOKEN_IDS.healDrone && u.alive);
h.unit('test_guard').hp = 1000;
assert.ok(h.runUntil(() => hm.skill.activations >= 1, 40), 'S2 fires');
h.step();
assert.equal(drones().length, 1, 'the drone comes with the skill');
assert.equal(drones()[0], piece, 'it is the placed piece');
assert.deepEqual([piece.tileR, piece.tileC], [11, 4], 'on the tile the player chose');
const t0 = piece.deployedAt;
assert.ok(h.runUntil(() => !piece.alive, 12));
assert.ok(Math.abs(piece.deathAt - t0 - 10) < 0.05, 'gone after 10 s');
assert.equal(h.b.isReservedTile(11, 4), true, 'the tile is still its own');
// the next S2: back on the same tile
assert.ok(h.runUntil(() => hm.skill.activations >= 2, 40), 'S2 again');
h.step();
assert.equal(piece.alive, true);
assert.deepEqual([piece.tileR, piece.tileC], [11, 4]);
assert.equal(drones().length, 1, 'one drone at a time');
checkInvariants(h.b);
});
test('#2 赫默 without a placed drone piece: S2 fires but no drone appears (it only deploys where a piece was placed)', REAL, () => {
const h = makeBattle({ defs: { chess: { test_guard: guard() } }, units: [{ chessId: SILENCE, row: 10, col: 3 }, { chessId: 'test_guard', row: 10, col: 4 }], autoFinish: false, timeLimit: 90 });
h.step();
const hm = h.unit(SILENCE);
h.unit('test_guard').hp = 1000;
assert.ok(h.runUntil(() => hm.skill.activations >= 1, 40));
h.run(1);
assert.equal(h.b.allyUnits.filter((u) => u.defId === TOKEN_IDS.healDrone).length, 0);
checkInvariants(h.b);
});
test('巫恋: the placed 诅咒娃娃 deploys once at the start, then appears on its tile when S2 fires (15 s)', REAL, () => {
const h = makeBattle({
units: [{ chessId: SHAMARE, row: 10, col: 3, uid: 1 }, { kind: 'token', tokenId: TOKEN_IDS.curseDoll, ownerUid: 1, row: 10, col: 6, uid: 2 }],
autoFinish: false, timeLimit: 120,
});
h.step();
const sh = h.unit(SHAMARE);
const doll = h.b.allyUnits.find((u) => u.uid === 2);
assert.equal(doll.alive, true, 'the free start deploy');
assert.ok(h.runUntil(() => !doll.alive, 20));
assert.ok(Math.abs(doll.deathAt - 15) < 0.1, 'its 15 s');
h.run(6); // past the token's redeploy time: nothing without a cast
assert.equal(doll.alive, false, 'no second doll before S2');
sh.skill.sp = sh.skill.spCost;
sh.skill.activate('test', { free: true });
h.step();
assert.equal(doll.alive, true);
assert.deepEqual([doll.tileR, doll.tileC], [10, 6]);
const t0 = doll.deployedAt;
assert.ok(h.runUntil(() => !doll.alive, 20));
assert.ok(Math.abs(doll.deathAt - t0 - 15) < 0.05, '15 s');
checkInvariants(h.b);
});
test('#1 凯瑟琳: the two placed devices deploy with the board on their tiles and shield the operator each faces', REAL, () => {
const h = makeBattle({
defs: { chess: { test_guard: guard() } },
units: [
{ chessId: CATHY, row: 12, col: 3, uid: 1 },
{ chessId: 'test_guard', row: 10, col: 5, uid: 2 },
{ chessId: 'test_guard', row: 11, col: 7, uid: 3 },
{ kind: 'token', tokenId: TOKEN_IDS.catShield, ownerUid: 1, row: 10, col: 4, uid: 4, dir: 'RIGHT' }, // faces (10,5)
{ kind: 'token', tokenId: TOKEN_IDS.catShield, ownerUid: 1, row: 12, col: 7, uid: 5, dir: 'DOWN' }, // faces (11,7)
],
autoFinish: false, timeLimit: 30,
});
h.step();
const cat = h.unit(CATHY);
const devs = h.b.allyUnits.filter((u) => u.defId === TOKEN_IDS.catShield);
assert.equal(devs.length, 2, 'no device beyond the placed ones');
assert.ok(devs.every((d) => d.alive), 'both deployed at the start');
assert.deepEqual(devs.map((d) => `${d.tileR},${d.tileC}`).sort(), ['10,4', '12,7']);
const cap = cat.s.maxHp * h.b.tokenDef(TOKEN_IDS.catShield, cat).talents[0].bb.max_shield_ratio;
for (const uid of [2, 3]) {
const g = h.b.allyUnits.find((u) => u.uid === uid);
assert.ok(Math.abs(g.s.shield - cap) < 1e-6, `operator ${uid} shielded (${g.s.shield} vs ${cap})`);
}
// S1 岁月锻打 reads the device shield: the operators holding one are recognised
assert.ok(h.b.allyUnits.find((u) => u.uid === 2).findBuff('cathy:shield'));
checkInvariants(h.b);
});
test('#1 凯瑟琳 with no device placed: no device in battle', REAL, () => {
const h = makeBattle({ defs: { chess: { test_guard: guard() } }, units: [{ chessId: CATHY, row: 12, col: 3 }, { chessId: 'test_guard', row: 10, col: 5 }], autoFinish: false, timeLimit: 20 });
h.run(2);
assert.equal(h.b.allyUnits.filter((u) => u.defId === TOKEN_IDS.catShield).length, 0);
checkInvariants(h.b);
});
test('not placed: 赫默 / 巫恋 / 凯瑟琳 / 浊心斯卡蒂 summon nothing; 伺夜 / 缪尔赛思 still bring their 援军 on a tactical point', REAL, () => {
const run = (chessId, cast = false) => {
const h = makeBattle({ units: [{ chessId, row: 10, col: 3, uid: 1 }], autoFinish: false, timeLimit: 20 });
h.run(2);
if (cast) { // the skill that gives the summon fires: still nothing without a piece
const u = h.unit(chessId);
u.skill.sp = u.skill.spCost;
u.skill.activate('test', { free: true });
h.run(1);
assert.ok(u.skill.activations >= 1, `${chessId} cast its skill`);
}
const toks = h.b.allyUnits.filter((u) => u.kind === 'token' && u.alive);
checkInvariants(h.b);
return toks;
};
for (const id of [SILENCE, SHAMARE]) assert.equal(run(id, true).length, 0, id);
for (const id of [CATHY, 'chess_char_6_04_a']) assert.equal(run(id).length, 0, id);
const wolf = run('chess_char_3_19_a');
assert.deepEqual(wolf.map((u) => u.defId), [TOKEN_IDS.wolfPack], '伺夜: her 狼群 as 援军');
const mf = run('chess_char_6_11_a');
assert.deepEqual(mf.map((u) => u.defId), [TOKEN_IDS.manifold], '缪尔赛思: her 流形 as 援军');
});
test('巫恋 leaves ⇒ her doll leaves (PRTS 诅咒娃娃 备注); 赫默 leaves ⇒ her drone stays (PRTS 医疗探机 备注)', REAL, () => {
const cast = (h, id) => { const u = h.unit(id); u.skill.sp = u.skill.spCost; u.skill.activate('test', { free: true }); h.step(); return u; };
// 巫恋 knocked out while her doll is up: the doll is withdrawn, and it does not come back while she is down
const h = makeBattle({
units: [{ chessId: SHAMARE, row: 10, col: 3, uid: 1 }, { kind: 'token', tokenId: TOKEN_IDS.curseDoll, ownerUid: 1, row: 10, col: 6, uid: 2 }],
autoFinish: false, timeLimit: 120,
});
h.step();
const sh = cast(h, SHAMARE);
const doll = h.b.allyUnits.find((u) => u.uid === 2);
assert.equal(doll.alive, true);
cast(h, SHAMARE); // a second doll in stock while the first one stands
h.b.dealDamage(null, sh, { amount: 1e9, type: 'true' });
h.step();
assert.equal(sh.alive, false);
assert.equal(doll.alive, false, 'the doll leaves with 巫恋');
h.run(10);
assert.equal(doll.alive, false, 'no doll while 巫恋 is off the field (the stocked one waits)');
assert.equal(h.b.isReservedTile(10, 6), true, 'the piece keeps its tile');
checkInvariants(h.b);
// 赫默 knocked out while her drone is up: the drone keeps healing until its 10 s are over
const g = makeBattle({
defs: { chess: { test_guard: guard() } },
units: [{ chessId: SILENCE, row: 10, col: 3, uid: 1 }, { chessId: 'test_guard', row: 10, col: 4, uid: 2 }, { kind: 'token', tokenId: TOKEN_IDS.healDrone, ownerUid: 1, row: 11, col: 4, uid: 3 }],
autoFinish: false, timeLimit: 120,
});
g.step();
const hm = cast(g, SILENCE);
const drone = g.b.allyUnits.find((u) => u.uid === 3);
assert.equal(drone.alive, true);
const t0 = drone.deployedAt;
g.b.dealDamage(null, hm, { amount: 1e9, type: 'true' });
g.step();
assert.equal(hm.alive, false);
assert.equal(drone.alive, true, 'the drone stays when 赫默 leaves');
assert.ok(g.runUntil(() => !drone.alive, 12));
assert.ok(Math.abs(drone.deathAt - t0 - 10) < 0.05, 'until its own 10 s end');
checkInvariants(g.b);
});
test('a stocked summon takes the field as soon as its owner is back (PRTS "满足条件后立即原地再部署1个"; playtest #6 review)', REAL, () => {
const cast = (h, id) => { const u = h.unit(id); u.skill.sp = u.skill.spCost; u.skill.activate('test', { free: true }); h.step(); return u; };
// 赫默: S2 while the drone stands ⇒ one in stock; knocked out, the drone runs out; she redeploys ⇒ the drone at once
const g = makeBattle({
defs: { chess: { test_guard: guard() } },
units: [{ chessId: SILENCE, row: 10, col: 3, uid: 1 }, { chessId: 'test_guard', row: 10, col: 4, uid: 2 }, { kind: 'token', tokenId: TOKEN_IDS.healDrone, ownerUid: 1, row: 11, col: 4, uid: 3 }],
autoFinish: false, timeLimit: 120,
});
g.step();
const drone = g.b.allyUnits.find((u) => u.uid === 3);
assert.equal(drone.alive, true, 'the start deploy');
const hm = cast(g, SILENCE);
assert.equal(hm.mem.summonStock[TOKEN_IDS.healDrone], 1, 'the S2 drone waits in stock while the first one stands');
g.b.dealDamage(null, hm, { amount: 1e9, type: 'true' });
g.step();
assert.ok(g.runUntil(() => !drone.alive, 12), 'the drone runs out its 10 s');
g.run(8);
assert.equal(drone.alive, false, 'nothing while 赫默 is down');
assert.ok(g.b.redeploy(hm, { free: true }), '赫默 back on her tile');
g.step();
assert.equal(drone.alive, true, 'the stocked drone deploys at once');
assert.deepEqual([drone.tileR, drone.tileC], [11, 4]);
assert.equal(hm.mem.summonStock[TOKEN_IDS.healDrone], 0, 'stock used');
checkInvariants(g.b);
// 巫恋: her doll leaves with her; the stocked one comes back with her
const h = makeBattle({
units: [{ chessId: SHAMARE, row: 10, col: 3, uid: 1 }, { kind: 'token', tokenId: TOKEN_IDS.curseDoll, ownerUid: 1, row: 10, col: 6, uid: 2 }],
autoFinish: false, timeLimit: 120,
});
h.step();
const doll = h.b.allyUnits.find((u) => u.uid === 2);
const sh = cast(h, SHAMARE);
assert.equal(doll.alive, true);
h.b.dealDamage(null, sh, { amount: 1e9, type: 'true' });
h.step();
assert.equal(doll.alive, false, 'the doll leaves with 巫恋');
h.run(8);
assert.ok(h.b.redeploy(sh, { free: true }));
h.step();
assert.equal(doll.alive, true, 'the stocked doll deploys as soon as 巫恋 is back');
checkInvariants(h.b);
});
+14 -5
View File
@@ -80,7 +80,14 @@ test('赫默: the medical drone lives its owner\'s withdraw duration (module var
const life = (lo) => (lo === NONE ? 16 : base.rawToken(DRONE).variants[HEMO].skill.duration);
assert.equal(life(DEF), 10);
for (const lo of ORDERS(DEF, NONE)) {
const { h, ops } = duel({ chessId: HEMO, lo, tokens });
// the drone is a hand piece the player places; it deploys once with the board (SKILL_SUMMON_START_DEPLOY), then
// S2 brings it back onto its tile (user playtest #6) — both lifetimes are checked on the start deploy and on a cast
const { h, ops } = duel({ chessId: HEMO, lo, tokens, extra: [{ kind: 'token', tokenId: DRONE, ownerUid: 1, row: 11, col: 4 }] });
const start = ops.map((u) => summons(h, u, DRONE)[0]);
assert.ok(start.every((d) => d && d.alive), 'both start deploys');
assertLifetimes(h, start, lo.map(life));
h.run(6); // past the token's redeploy time
assert.ok(ops.every((u) => u.skill.activations === 0), 'no cast yet');
const drones = castAndCollect(h, ops, DRONE);
assertLifetimes(h, drones, lo.map(life));
done(h);
@@ -146,7 +153,7 @@ test('巫恋: the 诅咒娃娃 lasts its owner\'s module variant of the token sk
const life = (lo) => want(tokens, DOLL, VOD, lo).skill.duration;
assert.deepEqual([life(DEF), life(NONE)], [15, 5]);
for (const lo of ORDERS(DEF, NONE)) {
const { h, ops } = duel({ chessId: VOD, lo, tokens });
const { h, ops } = duel({ chessId: VOD, lo, tokens, extra: [{ kind: 'token', tokenId: DOLL, ownerUid: 1, row: 11, col: 4 }] });
const dolls = castAndCollect(h, ops, DOLL);
assertLifetimes(h, dolls, lo.map(life));
done(h);
@@ -176,14 +183,16 @@ test('凯瑟琳: device shield cap and deploy limit follow the owner\'s module v
const limit = (lo) => (lo === NONE ? 1 : base.rawToken(CATSLD).variants[CATHY].stats.deployLimit);
assert.deepEqual([cap(DEF), cap(NONE), limit(DEF)], [0.2, 0.5, 2]);
for (const lo of ORDERS(DEF, NONE)) {
// one more operator each, so the default limit (2) and the patched one (1) differ in devices placed
const { h, ops } = duel({ chessId: CATHY, lo, tokens, extra: [{ chessId: 'chess_char_1_02_a', row: 11, col: 4 }] });
// two placed devices each (hand pieces, user playtest #6), facing her and one more operator: the default limit (2)
// keeps both, the patched one (1) withdraws the first deployed
const dev = (row, col) => ({ kind: 'token', tokenId: CATSLD, ownerUid: 1, row, col });
const { h, ops } = duel({ chessId: CATHY, lo, tokens, extra: [{ chessId: 'chess_char_1_02_a', row: 11, col: 4 }, dev(10, 3), dev(11, 3)] });
h.run(0.5);
ops.forEach((u, i) => {
const devs = summons(h, u, CATSLD).filter((d) => d.alive);
assert.equal(devs.length, limit(lo[i]), `${u.player.playerId} devices (deploy limit)`);
for (const d of devs) {
const t = h.b.unitAt(d.mem.tr, d.mem.tc);
const t = d.mem.target;
assert.ok(t && t.kind === 'op' && t.ownerId === u.ownerId, `${u.player.playerId} device points at its operator`);
approx(t.findBuff('cathy:shield')?.shield ?? 0, u.s.maxHp * cap(lo[i]), `${u.player.playerId} shield`);
}
+23 -10
View File
@@ -512,15 +512,21 @@ test('band map characters: spawnMapChar puts 预备干员-医疗 at its stage sl
checkInvariants(h.b);
});
test('fallbacks for generic summoners: 赫默 drone on skill, 夕 小自在 on the first attack, 凯瑟琳 devices at battle start', REAL, () => {
// 赫默 (generic kit): S2 fires on an injured ally → drone
const h = makeBattle({ defs: { chess: { test_guard: guard({ stats: { maxHp: 1e5, atk: 0 } }) } }, kits: { chess_char_2_02_a: genericKit }, units: [{ chessId: 'chess_char_2_02_a', row: 10, col: 3 }, { chessId: 'test_guard', row: 10, col: 4 }], autoFinish: false, timeLimit: 60 });
test('fallbacks for generic summoners: 赫默 placed drone on skill, 夕 小自在 on the first attack; 凯瑟琳 places nothing herself', REAL, () => {
// 赫默 (generic kit): S2 fires on an injured ally → the placed drone piece takes the field on its tile
const h = makeBattle({
defs: { chess: { test_guard: guard({ stats: { maxHp: 1e5, atk: 0 } }) } }, kits: { chess_char_2_02_a: genericKit },
units: [{ chessId: 'chess_char_2_02_a', row: 10, col: 3, uid: 1 }, { chessId: 'test_guard', row: 10, col: 4, uid: 2 }, { kind: 'token', tokenId: TOKEN_IDS.healDrone, ownerUid: 1, row: 11, col: 3, uid: 3 }],
autoFinish: false, timeLimit: 60,
});
h.step();
h.unit('test_guard').hp = 500;
const hm = h.unit('chess_char_2_02_a');
assert.equal(h.unit(3).alive, true, 'the free start deploy (PRTS; shared/constants.js SKILL_SUMMON_START_DEPLOY)');
assert.ok(h.runUntil(() => !h.unit(3).alive, 12), 'its 10 s');
h.unit('test_guard').hp = 500;
assert.ok(h.runUntil(() => hm.skill.activations >= 1, 40));
h.step();
assert.ok(h.b.allyUnits.some((u) => u.defId === TOKEN_IDS.healDrone && u.alive && u.ownerUnit === hm), 'drone deployed');
assert.ok(h.b.allyUnits.some((u) => u.defId === TOKEN_IDS.healDrone && u.alive && u.ownerUnit === hm && u.tileR === 11 && u.tileC === 3), 'drone deployed on its tile');
// 夕 (generic): 小自在 near the first target
const h2 = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, kits: { chess_char_5_12_a: genericKit }, units: [{ chessId: 'chess_char_5_12_a', row: 10, col: 3 }], enemies: [{ key: 'enemy_dummy', pos: [10, 5] }], autoFinish: false, timeLimit: 30 });
assert.ok(h2.runUntil(() => h2.b.allyUnits.some((u) => u.defId === TOKEN_IDS.duskDragon), 10));
@@ -528,13 +534,20 @@ test('fallbacks for generic summoners: 赫默 drone on skill, 夕 小自在 on t
assert.ok(Math.abs(dd.tileC - 5) + Math.abs(dd.tileR - 10) <= 1, 'at the target');
h2.runUntil(() => !dd.alive, 30);
approx(dd.deathAt - dd.deployedAt, 25, 0.05);
// 凯瑟琳 (generic): devices in front of her allies at battle start (≤ deployLimit)
// 凯瑟琳 (generic): her devices are hand pieces (user playtest #6) — none placed, none in battle; a placed one serves
const h3 = makeBattle({ defs: { chess: { test_guard: guard() } }, kits: { chess_char_4_11_a: genericKit }, units: [{ chessId: 'chess_char_4_11_a', row: 10, col: 3 }, { chessId: 'test_guard', row: 10, col: 5 }, { chessId: 'test_guard', row: 11, col: 5, uid: 9 }], autoFinish: false, timeLimit: 10 });
h3.step();
const devs = h3.b.allyUnits.filter((u) => u.defId === TOKEN_IDS.catShield && u.alive);
assert.ok(devs.length >= 1 && devs.length <= 2, `devices ${devs.length}`);
assert.ok(h3.b.allyUnits.filter((u) => u.defId === 'test_guard').some((g) => g.s.shield > 0));
h3.run(1);
assert.equal(h3.b.allyUnits.filter((u) => u.defId === TOKEN_IDS.catShield).length, 0, 'no device of her own');
const h4 = makeBattle({
defs: { chess: { test_guard: guard() } }, kits: { chess_char_4_11_a: genericKit },
units: [{ chessId: 'chess_char_4_11_a', row: 10, col: 3, uid: 1 }, { chessId: 'test_guard', row: 10, col: 5, uid: 2 }, { kind: 'token', tokenId: TOKEN_IDS.catShield, ownerUid: 1, row: 10, col: 4, uid: 3, dir: 'RIGHT' }],
autoFinish: false, timeLimit: 10,
});
h4.step();
assert.ok(h4.unit(3).alive, 'the placed device deploys with the board');
assert.ok(h4.unit(2).s.shield > 0, 'and shields the operator it faces');
checkInvariants(h3.b);
checkInvariants(h4.b);
});
// =================================================================================================================
+24 -10
View File
@@ -416,16 +416,17 @@ test('chess/tokens: talent tokens resolve and every token variant says where it
assert.deepEqual(tokens.token_10057_svash2_eagle1.variants.chess_char_5_14_a.sources, ['display']);
// 夕's skill "cnt" is a charge count, not a token count.
assert.equal(tokens.token_10015_dusk_drgn.variants.chess_char_5_12_a.count, null);
// Placeable (hand) tokens = the TALENT summons the shop state shows (tokenDisplayType DEFAULT); a skill's summon
// ("获得一个医疗无人机" 赫默, "获得一个诅咒娃娃" 巫恋) appears when the skill fires, never at battle start (user
// playtest #4 item 11).
// Placeable (hand) tokens = the manually deployable summons (PRTS 卫戍协议/帮助 §战斗部署; user playtest #6): shop
// state DEFAULT or no shop-state entry (凯瑟琳's device), never HIDDEN, made by some owner loadout (talent or skill) —
// 赫默's 医疗探机 and 巫恋's 诅咒娃娃 included (in battle they wait on their tile for the skill).
const makes = (list) => list.includes('talent') || list.includes('skill');
for (const t of Object.values(tokens)) {
if (t.kind !== 'summon') continue;
const talent = Object.values(t.variants).some((v) => v.sources.includes('talent') || Object.values(v.bySkill || {}).some((b) => b.sources.includes('talent')));
assert.equal(t.placeable, t.displayType === 'DEFAULT' && talent, `${t.tokenId} (${t.name}): placeable`);
const made = Object.values(t.variants).some((v) => makes(v.sources) || Object.values(v.bySkill || {}).some((b) => makes(b.sources)));
assert.equal(t.placeable, t.displayType !== 'HIDDEN' && made, `${t.tokenId} (${t.name}): placeable`);
}
assert.deepEqual(Object.values(tokens).filter((t) => t.placeable).map((t) => t.name).sort(), ['斯卡蒂的海嗣', '流形', '狼群'].sort());
for (const id of ['token_10000_silent_healrb', 'token_10006_vodfox_doll']) assert.equal(tokens[id].placeable, false, `${id}: skill summon`);
assert.deepEqual(Object.values(tokens).filter((t) => t.placeable).map((t) => t.name).sort(),
['医疗探机', '诅咒娃娃', '斯卡蒂的海嗣', '流形', '狼群', '爬行号·防护单元'].sort());
assert.equal(tokens.enemy_9012_acloon.stats.deployLimit, tokens.enemy_9012_acloon.deployLimit);
});
@@ -456,11 +457,24 @@ test('chess: skills[] = every skill unlocked at the status, at the chess skill l
}
if (c.tier <= 2 && !c.isGolden) assert.ok(idx.length <= 2, `${c.chessId}: E1 chess has no S3`);
}
// Triggers are resolved per skill index: the TANK row (skillIndex 0) applies to S1 only.
// Triggers per skill (PRTS 卫戍协议/帮助 技能策略; tools/build-data.mjs resolveTrigger): the class rows cover every MANUAL
// skill of the class and no AUTO one; a MANUAL skill with a 技能范围 of its own (not an attack-range change) is SKILL_RANGE.
const rules = (id) => chess[id].skills.map((s) => s.trigger.rule);
assert.deepEqual(rules('chess_char_1_02_a'), ['TAKE_DAMAGE', 'DEFAULT']); // 角峰
assert.deepEqual(rules('chess_char_1_10_b'), ['TAKE_DAMAGE', 'DEFAULT']); // 古米
assert.deepEqual(rules('chess_char_1_02_a'), ['TAKE_DAMAGE', 'TAKE_DAMAGE']); // 角峰 (重装, both MANUAL)
assert.deepEqual(rules('chess_char_1_10_b'), ['DEFAULT', 'TAKE_DAMAGE']); // 古米 (S1 自动触发)
assert.deepEqual(rules('chess_char_3_08_a'), ['SEARCH', 'SEARCH']); // 薄绿 (阵法术师: the default S2 too)
assert.deepEqual(rules('chess_char_3_19_a'), ['DEFAULT', 'DEFAULT', 'SP_FULL']); // 伺夜 (战术家: S1/S2 are AUTO)
assert.deepEqual(rules('chess_char_6_11_b'), ['MLYSS_WTRMAN', 'MLYSS_WTRMAN', 'MLYSS_WTRMAN']); // 缪尔赛思 (charId row −1)
assert.deepEqual(rules('chess_char_1_08_a'), ['DEFAULT', 'SKILL_RANGE']); // 德克萨斯 S2 剑雨: 对周围所有敌人
assert.deepEqual(chess.chess_char_1_08_a.skills[1].trigger.customRangeGrid, chess.chess_char_1_08_a.skills[1].rangeGrid);
assert.deepEqual(rules('chess_char_4_22_a'), ['DEFAULT', 'DEFAULT', 'DEFAULT']); // 银灰: "攻击范围缩小 / 扩大" = attack range
assert.deepEqual(rules('chess_char_3_18_a'), ['DEFAULT', 'SKILL_RANGE', 'DEFAULT']); // 忍冬: S2 对周围…, S3 攻击距离+1
for (const c of Object.values(chess)) {
for (const s of c.skills || []) {
if (s.skillType !== 'MANUAL') assert.ok(!['TAKE_DAMAGE', 'SEARCH', 'SKILL_RANGE'].includes(s.trigger.rule), `${c.chessId} ${s.skillId}: an AUTO / PASSIVE skill takes no strategy row`);
if (s.trigger.rule === 'SKILL_RANGE') assert.ok(s.rangeGrid && s.skillType === 'MANUAL' && !/攻击(范围|距离)(与溅射范围)?(扩大|改变|缩小|缩短|加长|增加|\+)/.test(s.desc), `${c.chessId} ${s.skillId}: SKILL_RANGE only for a 技能范围`);
}
}
assert.deepEqual(chess.chess_char_1_01_a.skills.map((s) => [s.skillId, s.level, s.spCost, s.isDefault]),
[['skchr_inside_1', 4, 14, false], ['skchr_inside_2', 4, 24, true]]); // 隐现 Lv4 (default S2)
const sw = chess.chess_char_3_04_b; // 琳琅诗怀雅: S2 makes the 香槟
+98 -11
View File
@@ -10,7 +10,10 @@
// dragged model held under it, a single human untimed, the strategy draft's one countdown, 机变 two taps, knocked-out
// operators and the official element gauges, live stats, the shop-only items, skill summons, 炎佑; user playtest #5
// (DESIGN §19): blocking by contact radius, 联防 forced exit, huge-boss hit areas and 自缚, the element pipeline rules,
// boss-field deployment, the phone prep camera — and the normative §3 / §5.1 / §5.5 / §6.1 / §7 lines that changed.
// boss-field deployment, the phone prep camera — and the normative §3 / §5.1 / §5.5 / §6.1 / §7 lines that changed; user
// playtest #6 (DESIGN §20): summons placed by hand (start deploy), skill triggers and the operation cooldown, every
// blocker hits what it blocks, push force vs weight, the ASPD floor, the enemies' collider reach, the boss pool floor,
// multi-round bounties lasting two battles, the 联防 counter, the element gauge — and the user's settled decisions.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
@@ -23,7 +26,9 @@ import { SOLO_RECONNECT_FALLBACK_SEC } from '../server/lobby.js';
import { moduleTypeIconUrl } from '../public/js/ui/assetUrls.js';
import { validateC2S } from '../shared/protocol.js';
import { ERR, PHASE } from '../shared/constants.js';
import { PROJECTILE_SPEEDS, BOOMERANG_RETURN_SPEED, ELEMENT, ELEMENT_ORDER, DOWN_STATE, BLOCK_RADIUS, FORCED_EXIT } from '../server/sim/constants.js';
import { PROJECTILE_SPEEDS, BOOMERANG_RETURN_SPEED, ELEMENT, ELEMENT_ORDER, DOWN_STATE, BLOCK_RADIUS, FORCED_EXIT, ASPD_MIN, BOSS_POOL_MIN_HP, AUTO_OP_COOLDOWN, ALLY_COLLIDER_RADIUS } from '../server/sim/constants.js';
import { SKILL_SUMMON_START_DEPLOY } from '../shared/constants.js';
import { MULTI_ROUND_BOUNTY_BATTLES } from '../server/match/choices.js';
import { SELF_BOUND } from '../server/sim/content/bosses.js';
import * as BOARD from '../server/match/board.js';
import * as DAMAGE from '../server/sim/damage.js';
@@ -303,8 +308,9 @@ test('user playtest #4 (DESIGN §18): picking by tile, timers, 机变 two taps,
assert.ok(special.shopExcluded && special.shopExcludedBy, '灼燃维式重锤 is never sold');
assert.match(dataRow('shopExcluded`, `shopExcludedBy'), /isShopItem/);
assert.match(PLAYING, /突变细胞只来自昆图斯的策略/);
assert.equal(DATA.tokens.token_10000_silent_healrb.placeable, false, '赫默\'s drone is no hand piece');
assert.match(dataRow('displayType`, `placeable'), /DEFAULT \*\*and\*\* a talent summon/);
// (#11 revisited by user playtest #6: the drone is a hand piece again, still only on the field when her skill fires)
assert.equal(DATA.tokens.token_10000_silent_healrb.placeable, true, '赫默\'s drone is a hand piece (user playtest #6)');
assert.match(dataRow('displayType`, `placeable'), /manually deployable summon/);
assert.ok(!/ATK\/HP are \*\*replaced\*\*/.test(DATA_MD), 'DATA: 炎佑 stats are added (最终加算)');
assert.match(DESIGN, /with no enemy on the field it stays where it is/);
});
@@ -361,13 +367,10 @@ test('user playtest #5 (DESIGN §19): blocking, 联防 forced exit, huge bosses,
assert.match(PLAYING, /\*\*立刻接替阻挡\*\*/);
assert.match(PLAYING, /召唤物在所有干员之后/);
assert.match(PLAYING, /近地悬浮/);
// integration QA residuals: blocked-first / out-of-range selection is melee-only (code = §5.5 = §19.2 = SIM = PLAYING);
// the forced-out timer is re-read after battleStart; the gauge intake's 5 % floor; the boss field's devices
assert.match(s55, /\(1\) the enemies it blocks \(melee units only, in range or not\)/);
assert.match(s55, /a ranged operator on a melee tile blocks but attacks only what its range holds/);
assert.ok(!/every blocker, ranged ones on melee tiles included/.test(DESIGN), '§19.2: the old [ASSUMED] is gone');
assert.match(SIM, /A ranged operator standing on a melee tile blocks, but targets by its range alone/);
assert.match(PLAYING, /站在近战位的远程干员也能阻挡/);
// integration QA residuals (the melee-only blocked-first rule is superseded by §20.3 — the user's "阻挡了就一定要能打到",
// checked in the §20 test); the forced-out timer is re-read after battleStart; the gauge intake's 5 % floor; the boss
// field's devices
assert.match(S19, /Ranged operators on melee tiles[^\n]*superseded by §20\.3/);
assert.match(s55, /after which the forced-out operators' timers are re-read/);
assert.match(SIM, /Right after `battleStart`\s+its timer is re-read/);
assert.match(s55, /max\(0\.05, 1 − 损伤抵抗\/100\)/);
@@ -382,3 +385,87 @@ test('user playtest #5 (DESIGN §19): blocking, 联防 forced exit, huge bosses,
assert.match(S19, /进联防复活时间确实是重新算/);
assert.ok(!/its full redeploy timer \[ASSUMED\]/.test(DESIGN), 'the full 联防 timer is no longer [ASSUMED]');
});
test('user playtest #6 (DESIGN §20): summons, skill triggers, blocking, push force, enemies, boss pool, bounties, element gauge — code and every doc agree', () => {
const sec = (n) => DESIGN.slice(DESIGN.indexOf(`## ${n}.`), DESIGN.indexOf(`## ${n + 1}.`) > 0 ? DESIGN.indexOf(`## ${n + 1}.`) : undefined);
const sub = (a, b) => DESIGN.slice(DESIGN.indexOf(`### ${a}`), DESIGN.indexOf(`### ${b}`));
const S20 = sec(20);
assert.match(DESIGN, /## 20\. User playtest #6 \(v2\.5\)/);
for (let i = 1; i <= 9; i++) assert.match(S20, new RegExp(`### 20\\.${i} `), `§20.${i}`);
const intro = S20.slice(0, S20.indexOf('### 20.1'));
for (let i = 1; i <= 19; i++) assert.match(intro, new RegExp(`#${i} `), `the intro maps report #${i}`);
const s55 = DESIGN.slice(DESIGN.indexOf('### 5.5'), DESIGN.indexOf('### 5.6'));
const s56 = DESIGN.slice(DESIGN.indexOf('### 5.6'), DESIGN.indexOf('## 6.'));
const s209 = S20.slice(S20.indexOf('### 20.9'));
// the user's settled decisions (2026-10-01): code = §20 = §20.9 table (with the one-line flips) = SIM / PLAYING / META
assert.equal(SKILL_SUMMON_START_DEPLOY, true, '#1/#2: a placed skill summon deploys once at the battle start (PRTS)');
assert.match(s209, /SKILL_SUMMON_START_DEPLOY = false/);
assert.match(s55, /a skill's placed summon \(赫默 医疗探机, 巫恋 诅咒娃娃\) included, once and free/);
assert.match(SIM, /deploys once at the battle start/);
assert.match(PLAYING, /医疗探机、诅咒娃娃开战时也在摆放的位置免费部署一次/);
assert.match(sec(18), /#11 skill summons are not prep pieces\*\* — \*\*superseded by §20\.1/);
assert.match(s209, /token_10041_cathy_catsld/, '凯瑟琳\'s device stays a hand card');
for (const [name, text] of [['build-data', readFileSync(join(ROOT, 'tools/build-data.mjs'), 'utf8')], ['DATA.md', DATA_MD]]) {
assert.ok(!/question to the user|awaits the user's confirmation/.test(text), `${name}: the 凯瑟琳 question is settled`);
}
assert.equal(MULTI_ROUND_BOUNTY_BATTLES, 2, '#4: multi-round bounties last two battles (the user\'s call)');
assert.match(s209, /MULTI_ROUND_BOUNTY_BATTLES = null/);
assert.match(META, /MULTI_ROUND_BOUNTY_BATTLES = null` restores the official red "每场"/);
assert.match(PLAYING, /官方的「多轮悬赏」(原文写「之后的每场作战」)在这里也按「接下来两场作战」处理/);
assert.ok(!/红字「每场」的是多轮悬赏/.test(PLAYING), 'PLAYING: no 每场 card any more');
assert.equal(ALLY_COLLIDER_RADIUS, 0.25, '#12: ranged enemies reach an operator by its 0.25 collider');
assert.match(s209, /ALLY_COLLIDER_RADIUS = 0/);
assert.match(sec(3), /centre distance ≤ `rangeRadius` \+ 0\.25/);
assert.match(s55, /whose 0\.25 collider touches their radius/);
assert.match(SIM, /centre distance ≤ `rangeRadius` \+ `ALLY_COLLIDER_RADIUS` 0\.25/);
assert.match(S20, /reach 2\.2 → 2\.45 \(13 → 21 tiles around it\)/);
// #17: every blocker hits what it blocks (code: no melee gate; §5.5 = §19.2 superseded = §20.3 = SIM = PLAYING)
assert.match(s209, /gate `Battle\.blockedTargets` \/ `sortEnemyTargets` blocked-first on a melee position again/);
assert.match(s55, /\*\*Every blocker\*\* — melee units/);
assert.match(s55, /\(1\) the enemies it blocks \(every blocker, in range or not, §20\.3\)/);
assert.ok(!/melee units only, in range or not|a ranged operator on a melee tile blocks but attacks only what its range holds/.test(DESIGN), '§5.5: the melee-only rule is gone');
assert.match(SIM, /\*\*every blocker\*\* — melee units/);
assert.ok(!/targets by its range alone: no/.test(SIM), 'SIM: the melee-only rule is gone');
assert.match(PLAYING, /\*\*阻挡了就一定能打到\*\*/);
const battleSrc = readFileSync(join(ROOT, 'server/sim/Battle.js'), 'utf8');
assert.ok(!/meleeUnit/.test(battleSrc) && !/export function meleeUnit/.test(readFileSync(join(ROOT, 'server/sim/targeting.js'), 'utf8')), 'no melee gate in the engine');
// #15 / #16: skill triggers and the 3 s operation cooldown (code = §5.6 = §19.2 superseded)
assert.equal(AUTO_OP_COOLDOWN, 3);
assert.match(s56, /`SKILL_RANGE` \(a MANUAL skill with a 技能范围 of its own/);
assert.match(s56, /\*\*every MANUAL skill\*\* of the class and never an AUTO skill/);
assert.match(s56, /`AUTO_OP_COOLDOWN` \(3 s\) after its previous cast and after the unit's battle-start deployment/);
assert.match(sec(19), /灰毫 S2 专注轰击 — \*\*superseded by §20\.2\*\*/);
assert.ok(!/so her S1 is TAKE_DAMAGE and S2 stays DEFAULT/.test(DESIGN), '§19.2: 灰毫 S2 is TAKE_DAMAGE now');
assert.ok(!/the battle-start part is left out/.test(readFileSync(join(ROOT, 'server/sim/constants.js'), 'utf8')), 'AUTO_OP_COOLDOWN comment');
assert.match(DATA_MD, /13 MANUAL skills \(26 normal \+ elite records\)/);
assert.match(sub('20.2', '20.3'), /180 % \(精锐 200 %\)/, '#8: the elite shield ratio');
// #14 / #17: push force vs weight, ASPD floor, 孤立 (code = §5.2 / §5.3 / §5.4 = §20.3)
assert.equal(ASPD_MIN, 20);
assert.match(sec(5), /`aspd = clamp\(100 \+ Σaspd \+ base-100, 20, 600\)`/);
assert.match(sec(5), /isolated \(孤立: no friendly selector picks it/);
assert.match(sec(5), /`battle\.push\(e, force, \{ from, dir, fixed, fixedAngle, inward, effect \}\)`/);
assert.match(sec(19), /锏 S3 归于宁静 \(added by §20\.3\)/);
// #5: the boss pool floor and the verdict (code = §5.5 = §14 = §6.1)
assert.equal(BOSS_POOL_MIN_HP, 1);
assert.match(s55, /A pool holding less than 1 HP is empty \(`constants\.js BOSS_POOL_MIN_HP`/);
assert.match(sec(14), /the first one the server registers decides the verdict \(`Match\._finalEnding`\)/);
assert.match(sec(6), /team LP 0 ⇒ defeat at once \(PRTS 直接失败\)/);
// #7 / #19: the 联防 counter (§17.5 revised, §8.2, §14) and the promotion reward (§6.2)
assert.match(sec(17), /\*\*revised by §20\.6\*\*/);
assert.ok(!/the own battle's count stays on show as an upper bound tagged 联防中/.test(DESIGN), '§17.5: the frozen 联防 count is gone');
assert.match(sec(8), /uniteLeft\? \/\* 联防: the leaker's enemies still standing/);
assert.match(sec(14), /`b\.progress \{ battleId, gt, killed, total, leaks\?, left\?, bossDmg\?, by\?, done\? \}`/);
assert.match(sec(6), /3 \*\*different\*\* free chess of tier `min\(level\+1, 6\)`/);
assert.match(S20, /105 cards: 56 next-battle incl\. 源石虫·特训, 42 two-battle, 7 multi-round/);
// #11 / #9 / #13: the gauge look (§18.3 / §19.5 superseded, §8.2 fill), model scale and fear (§9, §5.3, §2)
assert.match(sec(18), /superseded by §20\.8/);
assert.match(sec(19), /The gauge's look is the official icon \+ white bar since §20\.8/);
assert.match(sec(8), /rounded DOWN and kept at 0\.01–0\.99 outside a burst/);
assert.match(sec(9), /enemies\.json `modelScale`/);
assert.match(sec(2), /fear\.js +恐惧 movement of enemies/);
// the 机变 card and the card tap (§10 = §18.2 = §20.7)
assert.match(sec(10), /a tap anywhere on the card, its confirm strip included, is the card's tap/);
assert.match(sec(18), /Each card shows its full effect text \(§20\.7\)/);
// README: the test count stays in the right order of magnitude
assert.match(README, /约 27\d0 项/);
});
+15 -1
View File
@@ -1063,13 +1063,27 @@ describe('browser E2E against the real server', { skip: !ENABLED && 'needs Chrom
await c.page.waitForSelector('.dband.is-taken .dband__taken', { timeout: 4000 });
if (!shotDraft.has('taken')) {
shotDraft.add('taken');
// the strategy grid scrolls and the AI's weighted pick can sit below the fold: scroll it into view
// like a player would (a 15 s "nothing clickable" flake whenever the taken band was out of view)
await c.page.evaluate(() => document.querySelector('.dband.is-taken')?.scrollIntoView({ block: 'center' }));
await sleep(150);
await c.click('.dband.is-taken', null, { any: true });
await sleep(250);
assert.equal(await c.exists('.draft-detail__btns .btn--primary:not([disabled])'), false, '队友已选: confirm disabled');
await c.shot('draft-taken');
}
}
await c.click('.dband:not(.is-taken)', null, { nth: c === host ? 3 : 7 });
// the nth free strategy, skipping 老鲤 (【得闲饮茶】 holds the funds of rounds 1–2 back until round 3: a human on
// it has nothing to deploy in the first battles, and the round-1 facing chain needs board operators)
const bandName = await c.page.evaluate((nth) => {
const free = [...document.querySelectorAll('.dband:not(.is-taken)')].filter((el) => el.querySelector('.dband__name')?.textContent.trim() !== '老鲤');
const el = free[nth] || free[0];
el?.scrollIntoView({ block: 'nearest' });
return el?.querySelector('.dband__name')?.textContent.trim() || null;
}, c === host ? 3 : 7);
assert.ok(bandName, `${c.label}: a free strategy to pick`);
await sleep(150);
await c.click('.dband:not(.is-taken)', bandName);
await sleep(250);
if (!shotDraft.has(`${c.label}-sel`)) { shotDraft.add(`${c.label}-sel`); await c.shot('draft-selected'); }
await c.click('.draft-detail__btns .btn--primary', '确认选择');
+5 -2
View File
@@ -22,7 +22,10 @@
// kits { [baseChessId]: (bb, chess, def) => Kit } injected kits (take precedence over kits/tier*.js)
// extraContent [{ install(battle) }] extra content modules installed after the domain modules (content tests)
// defs { chess: {id: record}, enemies: {key: record}, tokens: {id: record} } extra/override data records
// flags, sharedBoss, modeId, round, setup(battle), recordEvents (default true)
// flags, sharedBoss, modeId, round, setup(battle), recordEvents (default true). The harness sets
// flags.startOpCooldown = 0 unless given: the official 3 s operation cooldown of the battle-start
// deployment (Battle default AUTO_OP_COOLDOWN) would hold back every test that fills a skill's SP at
// t ≈ 0 to stand for a skill that became ready mid-fight; tests of that rule pass it explicitly
// autoFinish end when all enemies are dead (default: true when any spawn is scheduled, else false so a
// battle without enemies keeps running until its time limit)
// hooks list of hook names to capture (default: all DESIGN events)
@@ -187,7 +190,7 @@ export function makeBattle(opts = {}) {
const battle = new Battle({
seed: opts.seed ?? 1, kind, modeId: opts.modeId ?? 'mode_multi_normal', round: opts.round ?? 1, stage, rect: opts.rect,
timeLimit: timeLimit ?? (kind === 'boss' || kind === 'hidden' ? Infinity : 60), players, spawns, routes,
sharedBoss: opts.sharedBoss ?? null, flags: opts.flags, fieldId: opts.fieldId ?? 'test', data,
sharedBoss: opts.sharedBoss ?? null, flags: { startOpCooldown: 0, ...opts.flags }, fieldId: opts.fieldId ?? 'test', data,
content: opts.content ?? 'full', recordEvents: opts.recordEvents !== false, logger: opts.logger ?? quietLogger(opts.verbose),
quiet: opts.verbose ? false : true, devices: opts.devices,
autoFinish: opts.autoFinish ?? spawns.length > 0, kits: opts.kits, extraContent: opts.extraContent,
+15 -2
View File
@@ -1,7 +1,7 @@
// tools/local-extract/extract.py (DESIGN §13 / §15): the job table, the manifest merge of --only, and the helpers that
// summarise Materials (map/<theme>/materials.json) and GameObjects (mesh/<bundle>/prefab.json) for the official 3D
// board. The Python helpers run without UnityPy (duck-typed fakes); the real-output checks skip when nothing was
// extracted on this machine.
// board, and enemy_scales.py's table of the official enemy model sizes (build-data MODEL_SCALES). The Python helpers
// run without UnityPy (duck-typed fakes); the real-output checks skip when nothing was extracted on this machine.
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
@@ -161,3 +161,16 @@ describe('extracted board-scene files (when a local client was extracted)', { sk
assert.deepEqual(bkg.map((n) => n.name), ['S_Background_common', 'S_Background_shadow']);
});
});
describe('enemy_scales.py (official enemy model sizes → build-data MODEL_SCALES, user playtest #6 item 9)', { skip: !PY && 'no python3' }, () => {
test('group_table: prefabs off the 0.27 standard, grouped by value, "enemy_" dropped; build-data carries the same values', () => {
const src = `import sys, json\nsys.path.insert(0, ${JSON.stringify(TOOL)})\nimport enemy_scales as s\n`
+ `print(json.dumps(s.group_table({'enemy_1005_yokai_3': 0.16, 'enemy_1005_yokai': 0.2, 'enemy_1040_bombd': 0.2, 'enemy_10083_hlbird': 0.27, 'enemy_x': None})))`;
const r = spawnSync(PY, ['-c', src], { encoding: 'utf8', env: ENV });
assert.equal(r.status, 0, r.stderr);
const lines = JSON.parse(r.stdout);
assert.deepEqual(lines, [" [0.16, ['1005_yokai_3']],", " [0.2, ['1005_yokai', '1040_bombd']],"]);
const bd = readFileSync(path.join(ROOT, 'tools/build-data.mjs'), 'utf8');
assert.ok(bd.includes(lines[0].trim()), 'build-data MODEL_SCALES: 威龙 0.16');
});
});
+3 -2
View File
@@ -4,7 +4,7 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import { PHASE } from '../../shared/constants.js';
import { GameData, COMBAT_TIME_SCALE } from '../../server/match/gamedata.js';
import { generateDraft, reinforcementBond, cardTargetBonds } from '../../server/match/choices.js';
import { generateDraft, reinforcementBond, cardTargetBonds, bountyBattles } from '../../server/match/choices.js';
import { assignTitles } from '../../server/match/results.js';
import { routeByMotion } from '../../server/match/waves.js';
import { fieldModel } from '../../server/match/bot.js';
@@ -49,7 +49,8 @@ test('multi-round bounties spawn in the Final Assault on the owner\'s half, show
const pending = p0.pendingFunds;
const end = h.runToEnd();
assert.equal(end.victory, true);
assert.equal(p0.bounties[0].roundsLeft, card.rounds - 1, 'the Final Assault used one of the bounty\'s battles');
// (a multi-round card lasts two battles since the user's playtest #6 answer — choices.js MULTI_ROUND_BOUNTY_BATTLES)
assert.equal(p0.bounties[0].roundsLeft, bountyBattles(card) - 1, 'the Final Assault used one of the bounty\'s battles');
assert.equal(p0.pendingFunds, pending + 3, 'kill-bounty coins of the boss field are credited (spent in the Hidden Core prep)');
checkInvariants(m);
m.dispose();
+7 -5
View File
@@ -269,15 +269,17 @@ test('co-op keeps its guards: INFO_CHECK 25 s, prep timer, transition deadlines'
// ---- review fixes -----------------------------------------------------------------------------------------------------------
test('a skill summon is never a hand card (user playtest #4): 赫默 on any skill gets no 医疗无人机 token in the prep phase', async () => {
test('a skill summon is a hand card only with that skill (user playtest #6): 赫默 on S2 gets her 医疗探机, on S1 none', async () => {
const { give } = await import('./harness.js');
const SILENCE = 'chess_char_2_02_a';
const DRONE = 'token_10000_silent_healrb';
assert.equal(chess(SILENCE).skills.find((s) => s.overrideTokenKey === DRONE)?.isDefault, true, 'fixture: the drone is the default S2 summon');
// "获得一个医疗无人机": the drone appears in battle when S2 fires (test/content/tokens_devices.test.js), never before
// the drone is placed by hand (PRTS 卫戍协议/帮助 "可手动部署的附属召唤物…加入手牌区"); in battle it takes its tile when S2
// fires (test/content/playtest6_summons.test.js) — only S2 (index 1, the default) makes it
const tokensIn = (ps, id = DRONE) => [...ps.hand, ...ps.temp, ...ps.board.values()].filter((p) => p && p.kind === 'token' && p.id === id);
assert.equal(DATA.tokens[DRONE].placeable, false, 'tokens.json: a skill summon is not placeable');
assert.equal(DATA.tokens[DRONE].placeable, true, 'tokens.json: a manually deployable summon');
for (const skill of [null, 0, 1]) {
const want = skill === 0 ? 0 : 1;
const lo = skill == null ? null : checkLoadout({ [SILENCE]: { skill } }, chess).loadout;
const h = makeMatch({ mode: 'solo', seats: seatsWith(lo), seed: 5 }).start();
h.toPrep(1);
@@ -286,11 +288,11 @@ test('a skill summon is never a hand card (user playtest #4): 赫默 on any skil
ps.board.clear();
ps.hand.fill(null);
give(h.m, ps, SILENCE, 'board', [9, 3]);
assert.equal(tokensIn(ps).length, 0, `normal 赫默, skill ${skill ?? 'default'}`);
assert.equal(tokensIn(ps).length, want, `normal 赫默, skill ${skill ?? 'default'}`);
ps.board.clear();
ps.hand.fill(null);
give(h.m, ps, chess(SILENCE).goldenId, 'board', [9, 4]);
assert.equal(tokensIn(ps).length, 0, `elite 赫默, skill ${skill ?? 'default'}`);
assert.equal(tokensIn(ps).length, want, `elite 赫默, skill ${skill ?? 'default'}`);
// a talent summon still is a hand card: 伺夜 → 狼群
ps.board.clear();
ps.hand.fill(null);
+155
View File
@@ -0,0 +1,155 @@
// User playtest #6 item 5 — "图中的boss没有血量了但是不死,还在战斗,直到超时我们的生命被扣完才结束,但是最后的判定仍是胜利".
// The user's match: 2 humans (the AI teammates eliminated), 绝境, act2autochess_m01, client-side combat, one pair
// field; the HUD read 0.0 %, the leader kept fighting, the overtime drain took the team LP and the result said 胜利.
//
// Reproduced with real matches (real sim, SimClients as the browsers): on v2.4.1 about one boss fight in three stalled.
// The server pool kept float dust from crediting a report per player (40000 − 12345.6 − 7654.3 − 20000.1 = 3.6e-12);
// the authority's LocalBossPool showed exactly that and every hit added 3.6e-12 to a cumulative counter of 40000 —
// absorbed, so its pool never reached 0 and it never reported anything new. The fight ran on until the team LP was 0;
// the verdict then came from the pool after the final b.result, whose per-player damage is rounded: rounding up
// emptied the dust → 胜利 (what the user saw), rounding down → 失败.
//
// Now (sim/constants.js BOSS_POOL_MIN_HP, finalAssault.js SharedBossPool, sim/spec.js LocalBossPool, Match.js): a pool
// never holds less than 1 HP (the hit that would leave less takes the rest; a browser pool reading below 1 is 0), so
// the leader dies on every field as soon as the pool shows 0 and the fight ends; the first end condition the server
// registers decides the verdict (pool 0 → victory, team LP 0 → defeat: PRTS 卫戍协议:盟约 下半 "…使目标生命值扣除至0,
// 则无视倒计时直接失败"), and reports arriving after the team LP ran out credit nothing.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { PHASE } from '../../shared/constants.js';
import { makeMatch } from './harness.js';
/** A real client-combat match (real sim) of 2 humans to the Final Assault on the user's stage and difficulty. */
function realFinalAssault({ bossId, seed, clientCombat = true }) {
const h = makeMatch({ mode: 'coop', difficulty: 'HARD', humans: 2, bots: 0, seed, captureFrames: false, clientCombat, pace: 'paced' });
h.setStage('act2autochess_m01');
const m = h.m;
m.bossId = bossId;
// the autoplay lineups cannot clear a full 绝境 pool: 5 % of it (the tuning knob GameData.bossHpMul) — the pool's
// arithmetic, not its size, stalled the fight
m.gd.bossHpMul = () => 0.05;
h.autoHumans();
m.start();
let last = '';
h.run(() => {
const k = `${m.phase}:${m.round}`;
if (k !== last) { last = k; if (m.phase === PHASE.PREP && m.round === 1) for (const ps of m.players.values()) ps.lp = 300; }
return h.ended != null || m.phase === PHASE.FINAL_ASSAULT;
}, { maxSteps: 8e6 });
assert.equal(m.phase, PHASE.FINAL_ASSAULT, `${bossId} seed ${seed}: reached the Final Assault`);
return h;
}
// (boss, seed) pairs whose fight stalled on v2.4.1: pool < 1 HP within 30 s, then ~11 minutes of overtime drain;
// boss_3 / 11 and boss_10 / 12 ended as a 胜利 after the team LP ran out (the user's report), the others as a 失败
const STALLED = [['boss_1', 12], ['boss_2', 12], ['boss_3', 11], ['boss_10', 12]];
for (const [bossId, seed] of STALLED) {
test(`#5 real match (client-side combat): ${bossId} seed ${seed} — the leader dies as soon as the shared pool is empty, victory, no overtime`, () => {
const h = realFinalAssault({ bossId, seed });
const m = h.m;
const f = m.fields[0];
assert.equal(m.fields.length, 1, 'one pair field');
assert.deepEqual([f.mode, f.authority], ['client', 'p_0']);
const t0 = h.sched.now();
const lp0 = m.teamLp;
const pools = [];
let emptyAt = null, endAt = null;
h.onBroadcast.push((msg) => {
if (msg.t !== 'b.pool') return;
pools.push(msg.hp);
if (msg.hp < 1 && emptyAt == null) emptyAt = h.sched.now() - t0;
});
h.onSend.push((pid, msg) => { if (msg.t === 'b.end' && endAt == null) endAt = h.sched.now() - t0; });
h.run(() => m.phase !== PHASE.FINAL_ASSAULT, { maxSteps: 8e6 });
assert.ok(emptyAt != null, 'the team emptied the pool');
assert.ok(pools.every((hp) => hp === 0 || hp >= 1), `the pool never holds float dust (${pools.filter((hp) => hp > 0 && hp < 1)})`);
assert.ok(endAt != null && endAt <= emptyAt, `b.end went out when the pool emptied (${endAt} ms vs ${emptyAt} ms)`);
assert.equal(m._finalEnding, 'cleared');
assert.equal(m.overtimeApplied, 0, 'no overtime drain');
assert.ok(m.teamLp > 0 && m.teamLp >= lp0 - 30, `team LP kept (${lp0} → ${m.teamLp})`);
const c = h.clients.get('p_0');
const local = c.battles.get(f.battleId).battle;
assert.equal(local.reason, 'cleared', 'the authority\'s own battle saw the pool reach 0');
assert.deepEqual(local.enemies.filter((e) => e.alive && e.isBoss).map((e) => e.defId), [], 'no leader left standing');
assert.equal(local.sharedBoss.hp, 0);
m.flush(true);
const pub = h.lastBc('m.public');
assert.deepEqual(pub.bossHp, { hp: 0, max: m.bossPool.maxHp });
const end = h.runToEnd();
assert.equal(end.victory, true);
assert.equal(m.errorCount, 0);
assert.equal(m.verifyStats.rejected, 0);
m.dispose();
});
}
test('#5 real match (server-run combat, SP_COMBAT=server): the Final Assault ends when the pool empties, never on float dust', () => {
const h = realFinalAssault({ bossId: 'boss_3', seed: 11, clientCombat: false });
const m = h.m;
const pool = m.bossPool;
const seen = [];
const orig = pool.damage.bind(pool);
pool.damage = (pid, amount) => { const d = orig(pid, amount); seen.push(pool.hp); return d; };
const end = h.runToEnd();
assert.ok(seen.length > 0);
assert.ok(seen.every((hp) => hp === 0 || hp >= 1), 'the pool is 0 or at least 1 HP after every hit');
assert.equal(pool.hp, 0);
assert.equal(end.victory, true);
assert.equal(m.overtimeApplied, 0);
m.dispose();
});
// ---- the shared pool through the real crediting path (FakeBattle hits a real LocalBossPool; deterministic) ---------
test('#5 client-side combat: a boss dps whose per-player credits used to leave 1.5e-11 in the pool ends the fight when the pool empties', () => {
for (const dps of [3456.789, 24680.13]) {
const h = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 2, seed: 9110, fake: true, clientCombat: true, instant: false,
script: (b) => (b.kind === 'boss' ? { bossDps: dps } : { duration: 2 }) }).start();
const m = h.m;
h.autoHumans();
h.drive(() => m.phase === PHASE.FINAL_ASSAULT);
const t0 = h.sched.now();
const lp0 = m.teamLp;
let emptyAt = null, endAt = null;
const pools = [];
h.onBroadcast.push((msg) => { if (msg.t === 'b.pool') { pools.push(msg.hp); if (msg.hp < 1 && emptyAt == null) emptyAt = h.sched.now() - t0; } });
h.onSend.push((pid, msg) => { if (msg.t === 'b.end' && endAt == null) endAt = h.sched.now() - t0; });
h.run(() => m.phase !== PHASE.FINAL_ASSAULT, { maxSteps: 5e6 });
assert.ok(emptyAt != null, `dps ${dps}: the pool emptied`);
assert.ok(pools.every((hp) => hp === 0 || hp >= 1), `dps ${dps}: never float dust (${pools.filter((hp) => hp > 0 && hp < 1)})`);
assert.ok(endAt != null && endAt <= emptyAt, `dps ${dps}: b.end cleared went out with the empty pool (${emptyAt} / ${endAt} ms; v2.4.1 stalled until the drain at ~205 s)`);
assert.equal(m._finalEnding, 'cleared');
assert.equal(m.teamLp, lp0, 'no overtime drain');
const end = h.runToEnd();
assert.equal(end.victory, true);
m.dispose();
}
});
test('#5 verdict: once the team LP runs out the run is lost — a boss field\'s final result can never credit damage or turn it into a victory', () => {
const h = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 2, seed: 9109, fake: true, clientCombat: true, instant: false,
script: (b) => (b.kind === 'boss' ? { bossDps: 0, leakEvents: [{ at: 4, lpr: 1 }] } : { duration: 2 }),
// the authority's b.result after the forced end reports more boss damage than was ever credited (in flight / rounded)
perPlayer: { p_0: { tamper: (result, spec) => {
if (spec.kind !== 'boss') return result;
for (const pp of Object.values(result.perPlayer)) pp.bossDamage = 1e6;
return result;
} } } }).start();
const m = h.m;
h.autoHumans();
h.drive(() => m.phase === PHASE.FINAL_ASSAULT);
h.sched.advance(1000);
m.teamLp = 1; // the last target LP point: the leak at 4 game s takes it
m.bossPool.hp = 50; // a leader 50 HP from death
h.run(() => m.phase !== PHASE.FINAL_ASSAULT, { maxSteps: 5e6 });
assert.equal(m._finalEnding, 'forced', 'the team LP ran out first');
assert.ok(h.clients.get('p_0').log.some((x) => x.t === 'b.result'), 'the authority reported after b.end forced');
assert.equal(m.bossPool.hp, 50, 'nothing reported after the forced end is credited');
const end = h.runToEnd();
assert.equal(end.victory, false, 'defeat: the team LP reached 0 before the pool');
assert.equal(end.reason, 'defeat');
assert.equal(m.teamLp, 0);
m.dispose();
});
+446
View File
@@ -0,0 +1,446 @@
// User playtest #6, match flow (workstream WC): #4 悬赏决策 bounty enemies keep coming back, #19 the merge reward offers
// the same operator twice, #7 the 联防 leak counter (server side; the HUD helpers are in test/ui/playtest6-unite.test.js).
// Real match paths (the match harness in virtual time), real data.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { PHASE } from '../../shared/constants.js';
import { DATA, makeMatch, give, chessOfTier, checkInvariants } from './harness.js';
import { FakeBattle } from './fakeBattle.js';
import { GameData } from '../../server/match/gamedata.js';
import { generateDraft, cardView, MULTI_ROUND_BOUNTY_BATTLES } from '../../server/match/choices.js';
import { createRng } from '../../server/sim/rng.js';
import { Battle } from '../../server/sim/Battle.js';
import { uniteLeft, battleProgress } from '../../server/sim/spec.js';
import { uniteSurvivors } from '../../server/match/unite.js';
import { attachSimClients } from './simClient.js';
// =====================================================================================================================
// #19 — "拿到三个相同干员合成高级干员时赠送的3选1干员经常出现两个一样的,没记错的话官方原版不会"
/** A solo prep at shop level `level` with an empty board and hand (the pool untouched). */
function soloPrep(seed, level = 1) {
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed }).start();
h.toPrep(1);
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.shop.level = level;
ps.recompute();
ps.funds = 100;
return { h, m: h.m, ps };
}
test('#19 the promotion reward (3 copies → 精锐) offers three DIFFERENT operators of tier level + 1 — the real merge path', () => {
let offers = 0;
for (let seed = 1; seed <= 12; seed++) {
const { m, ps } = soloPrep(600 + seed, 1 + (seed % 5));
// merge three copies of one tier-I operator (two in the hand, the third bought from the shop)
const id = chessOfTier(1).find((x) => m.pool.has(x) && m.pool.left(x) >= 3);
give(m, ps, id);
give(m, ps, id);
ps.shop.slots[0] = { kind: 'chess', id, basePrice: m.gd.chessPrice(id), frozen: false, sold: false };
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { ok: true });
assert.equal(ps.stats.merges, 1, 'merged');
const offer = ps.privateView().shop.rewardOffer;
assert.ok(offer, 'a reward offer');
const tier = Math.min(ps.shop.level + 1, 6);
assert.equal(offer.tier, tier);
const ids = offer.slots.map((s) => s.id);
assert.equal(ids.length, 3);
assert.equal(new Set(ids).size, 3, `seed ${seed}: three different operators (${ids.join(', ')})`);
for (const x of ids) assert.equal(DATA.chess[x].tier, tier, `${x} is tier ${tier}`);
offers++;
checkInvariants(m);
m.dispose();
}
assert.equal(offers, 12);
});
test('#19 many reward offers at every shop level: never the same operator twice; offers reserve no copies', () => {
const { m, ps } = soloPrep(640);
let dupBefore = 0;
for (let level = 1; level <= 6; level++) {
ps.shop.level = level;
for (let k = 0; k < 60; k++) {
const before = m.pool.snapshot();
const offer = ps.pushRewardOffer('merge');
const ids = offer.slots.map((s) => s.id);
if (new Set(ids).size !== ids.length) dupBefore++;
assert.equal(ids.length, 3);
for (const x of ids) assert.equal(DATA.chess[x].tier, Math.min(level + 1, 6));
assert.deepEqual(m.pool.snapshot(), before, 'an offer takes no copies (only the pick does)');
ps.offers.length = 0;
}
}
assert.equal(dupBefore, 0, `${dupBefore} of 360 offers showed an operator twice`);
m.dispose();
});
test('#19 a tier short of different operators: the offer tops up from the tier below, still three different ones; the pick keeps the pool exact', () => {
const { m, ps } = soloPrep(650, 3); // offers of tier IV
const t4 = chessOfTier(4).filter((x) => m.pool.has(x));
const keep = t4.slice(0, 2);
const drained = new Map();
for (const x of t4) if (!keep.includes(x)) drained.set(x, m.pool.take(x, 100));
for (let k = 0; k < 20; k++) {
const offer = ps.pushRewardOffer('merge');
const ids = offer.slots.map((s) => s.id);
assert.equal(new Set(ids).size, 3, ids.join(', '));
assert.deepEqual(ids.filter((x) => DATA.chess[x].tier === 4).sort(), keep.slice().sort(), 'both tier-IV operators left are offered');
assert.equal(ids.filter((x) => DATA.chess[x].tier === 3).length, 1, 'the third comes from tier III');
if (k < 19) ps.offers.length = 0;
}
// pick the tier-III one: one copy leaves the pool, into the hand
const offer = ps.offers[0];
const idx = offer.slots.findIndex((s) => DATA.chess[s.id].tier === 3);
const id = offer.slots[idx].id;
const left = m.pool.left(id);
assert.deepEqual(m.handle('p_0', { t: 'g.reward', idx }), { ok: true });
assert.equal(m.pool.left(id), left - 1);
assert.ok(ps.hand.some((p) => p && p.id === id));
for (const [x, n] of drained) m.pool.give(x, n);
checkInvariants(m);
m.dispose();
});
// =====================================================================================================================
// #4 — "官方版悬赏决策的敌人我没记错的话正常只会在选择之后出现两回合,现在是一直出"
// The draft pool follows the PRTS 敌人轮选 table (卫戍协议:盟约 下半/PRTS盟约记录 §机变阶段), which lists the multi-round
// "之后 / 后续的每场作战" cards too: v2.4.1 also drafted 战术特训 (only 法术教鞭 makes those) and the hidden 鸭爵 set, put
// one or more multi-round cards in about half of the co-op R3 drafts and showed every card as plain text, so a "每场"
// card looked like a "下场" one. Now: no 战术特训 / 鸭爵 set (战术特训 is the 教鞭 Art's, test/content/items.test.js), the
// multi-round cards stay in as PRTS lists them, and each card names its battles in the official colours (blue "下场作战" /
// "两场作战", red "每场"); a 1–2-battle bounty stops after its battles.
const BOUNTY_MODES = ['mode_multi_normal', 'mode_multi_hard', 'mode_multi_abyss', 'mode_single_hard', 'mode_single_abyss'];
/** 鸭爵 / 高普尼克 / 流泪小子 / 圆仔·悬赏: commented out of the PRTS 敌人轮选 table */
const HIDDEN = new Set(['enemyeffect_5', 'enemyeffect_6', 'enemyeffect_7', 'enemyeffect_8']);
/** The official rich text of a card's battles (activity_table effectDesc): 1 / 2 battles in blue, every battle in red. */
const DURATION_RE = { 1: /<@ba\.vup>下场(作战|战斗)<\/>/, 2: /<@ba\.vup>两场作战<\/>/, 99: /<@ba\.vdown>每场<\/>/ };
/** The official multi-round cards ("之后 / 后续的每场作战"): two battles since the user's playtest #6 answer. */
const MULTI = new Set(DATA.choices.cards.bounty.filter((c) => c.multiRound).map((c) => c.effectId));
test('#4 the 悬赏决策 draft: PRTS 敌人轮选 cards only (no 战术特训, no 鸭爵 set), each naming its battles in the official colours', () => {
let bountyDrafts = 0;
let cards = 0;
let withMulti = 0;
for (const modeId of BOUNTY_MODES) {
const gd = new GameData(DATA, modeId);
const sch = DATA.choices.schedule[modeId];
for (const r of sch.spRounds) {
for (let seed = 1; seed <= 25; seed++) {
const d = generateDraft(gd, createRng(seed * 131 + r), r, { stageId: 'act2autochess_m01' });
if (!d || d.family !== 'bounty') continue;
bountyDrafts++;
let multi = 0;
assert.equal(new Set(d.cards.map((c) => c.id)).size, d.cards.length, 'distinct cards');
for (const c of d.cards) {
cards++;
assert.equal(c.payout, 'kill', `${modeId} R${r}: ${c.id} ${c.name} — 战术特训 comes from 法术教鞭 only`);
assert.ok(!HIDDEN.has(c.id), `${c.id} ${c.name}: hidden from the PRTS table`);
assert.ok([1, 2].includes(c.rounds), `${c.id}: ${c.rounds} battles`);
if (MULTI.has(c.id)) {
multi++;
assert.equal(c.rounds, 2, `${c.id} ${c.name}: a multi-round card lasts two battles (the user's call)`);
assert.ok(!/每场/.test(c.descRaw || ''), `${c.id}: no 每场 left in the card text`);
}
assert.match(c.descRaw || '', DURATION_RE[c.rounds], `${c.id} ${c.name}: the card text shows its battles (${c.descRaw})`);
assert.equal(cardView(c).descRaw, c.descRaw, 'the public card carries the rich text (ui/choiceOverlay.js renders it first)');
}
if (multi) withMulti++;
}
}
}
assert.ok(bountyDrafts >= 100 && cards >= 400, `${bountyDrafts} bounty drafts, ${cards} cards`);
assert.ok(withMulti > 0, 'the multi-round cards are still offered (PRTS lists them), as two-battle cards');
});
test('#4 data: cards.bounty marks the draft pool after the PRTS table — 战术特训 (教鞭) and the 鸭爵 set are out, the multi-round cards and 源石虫·特训 in', () => {
const list = DATA.choices.cards.bounty;
const reasons = {};
for (const c of list) {
const expect = c.payout === 'kill' && !HIDDEN.has(c.effectId);
assert.equal(c.draft, expect, `${c.effectId} ${c.name}: draft ${c.draft}`);
if (!c.draft) reasons[c.draftExcluded] = (reasons[c.draftExcluded] || 0) + 1;
else assert.equal(c.draftExcluded, null);
}
assert.deepEqual(reasons, { perfect: 20, hidden: 4 });
assert.equal(list.filter((c) => c.draft).length, 105);
const multi = list.filter((c) => c.rounds >= 99 && c.payout === 'kill');
assert.equal(multi.length, 7, '多轮悬赏 · 假想敌 ×6 + 山海众头目·多轮悬赏');
assert.ok(multi.every((c) => c.draft), 'PRTS lists them in 敌人轮选');
assert.equal(list.find((c) => c.effectId === 'enemyeffect_5_1').draft, true, '源石虫·特训 is in the table too');
});
/** Drive a co-op 绝境 match to its R3 bounty draft; the human takes the first free card `want(card, free)` accepts. */
function pickAtR3(seed, want) {
const h = makeMatch({ mode: 'coop', difficulty: 'HARD', humans: 1, bots: 1, seed, fake: true }).start();
const m = h.m;
const ok = h.drive(() => m.phase === PHASE.SP_DRAFT && m.round === 3, { ready: true });
assert.ok(ok && m.sp.family === 'bounty', `seed ${seed}: the R3 bounty draft`);
let picked = null;
for (let guard = 0; guard < 200 && m.phase === PHASE.SP_DRAFT; guard++) {
if (m.spTurn() === 'p_0') {
const free = m.sp.cards.filter((c) => m.sp.taken[c.idx] == null);
picked = free.find((c) => want(c, free)) || null;
if (!picked) break;
assert.deepEqual(m.handle('p_0', { t: 'g.choice', idx: picked.idx }), { ok: true });
} else h.sched.runNext();
}
return { h, m, ps: h.ps('p_0'), picked };
}
/** Rounds (≤ R8) whose normal battle of p_0 spawned bounty `bountyId`. */
function bountyRounds(bountyId) {
const out = [];
for (const f of FakeBattle.instances) {
if (f.kind !== 'normal' || !f.players.includes('p_0')) continue;
if (f.spawns.some((s) => s.tag === 'bounty' && s.mods && s.mods.bountyId === bountyId)) out.push(f.round);
}
return out;
}
test('#4 E2E (co-op 绝境, the real R3 draft): a 1–2-battle bounty\'s enemies come for its battles and never again', () => {
const seen = [];
for (let seed = 1; seed <= 6; seed++) {
// the human takes the longest short bounty on offer (a "接下来两场作战" one when there is one)
const { h, m, ps, picked } = pickAtR3(660 + seed, (c, free) => c.rounds <= 2 && c.rounds === Math.max(...free.filter((x) => x.rounds <= 2).map((x) => x.rounds)));
assert.ok(picked, 'picked a card');
const b = ps.bounties.find((x) => x.card.effectId === picked.id);
assert.ok(b, 'the picker holds the bounty');
h.drive(() => m.phase === PHASE.ROUND_START && m.round === 9, { ready: true });
const expect = picked.rounds === 1 ? [3] : [3, 4];
const got = bountyRounds(b.id);
assert.deepEqual(got, expect, `seed ${seed}: ${picked.id} ${picked.name} (${picked.rounds} battles) spawned in R${got.join(', R')}`);
assert.equal(ps.bounties.length, 0, 'no bounty left after its battles');
seen.push(picked.rounds);
m.dispose();
}
assert.ok(seen.includes(2), 'a two-battle card was picked at least once');
});
test('#4 E2E: a multi-round card lasts two battles like the "两场作战" cards — blue text on the card and in the effects column (the user\'s call)', () => {
// "我不记得有过多轮悬赏" (user, after playtest #6): choices.js MULTI_ROUND_BOUNTY_BATTLES = 2; null restores 每场
assert.equal(MULTI_ROUND_BOUNTY_BATTLES, 2);
let done = false;
for (let seed = 1; seed <= 40 && !done; seed++) {
const { h, m, ps, picked } = pickAtR3(700 + seed, (c) => MULTI.has(c.id));
if (!picked) { m.dispose(); continue; }
done = true;
assert.equal(picked.rounds, 2);
assert.match(picked.descRaw, /接下来<@ba\.vup>两场作战<\/>/, 'the draft card says 两场作战 in blue');
assert.ok(!/每场/.test(picked.descRaw), 'no red 每场');
const b = ps.bounties.find((x) => x.card.effectId === picked.id);
assert.equal(b.roundsLeft, 2);
const e = ps.privateView().effects.find((x) => x.id === b.id);
assert.equal(e.counterText, '还剩 2 场作战');
assert.match(e.desc, /<@ba\.vup>两场作战<\/>/, 'the effects tooltip says the same');
h.drive(() => m.phase === PHASE.ROUND_START && m.round === 9, { ready: true });
assert.deepEqual(bountyRounds(b.id), [3, 4], 'its enemy comes for two battles, then never again');
assert.ok(!ps.bounties.some((x) => x.id === b.id), 'and the bounty is gone');
m.dispose();
}
assert.ok(done, 'some R3 draft offered a multi-round card');
});
test('#4 the active bounty says how many battles it has left, with its card text in the official colours (effects column)', () => {
const h = makeMatch({ mode: 'coop', difficulty: 'HARD', humans: 1, bots: 1, seed: 670, fake: true }).start();
const m = h.m;
h.toPrep(1);
const ps = h.ps('p_0');
const card = DATA.choices.cards.bounty.find((c) => c.draft && c.rounds === 2 && m.gd.enemy(c.enemyKey));
m.addBounty(ps, card);
const e = ps.privateView().effects.find((x) => x.name === card.name);
assert.ok(e, 'listed');
assert.equal(e.counter, 2);
assert.equal(e.counterText, '还剩 2 场作战');
assert.match(e.desc, /<@ba\.vup>两场作战<\/>/);
m.dispose();
});
// =====================================================================================================================
// #7 — "联防阶段队友漏掉的怪要是被打掉了漏怪的数字实时回调(现在好像最高只显示漏10个…十个以上最多只会一次扣10滴血的规则保持不变)"
const LEAKS = 14; // more than the 10-LP cap
const SLIME = 'enemy_1007_slime';
/** Real battles whose 联防 helpers strike down one of the leaked enemies every `every` game seconds (from 4 s on). */
function slowHelpers(every = 6) {
return class SlowHelpers extends Battle {
constructor(opts) {
super(opts);
if (this.kind !== 'unite') return;
let next = 4;
this.on('tick', () => {
if (this.time < next) return;
const e = this.enemies.find((x) => x.alive && x.counted);
if (!e) return;
this.kill(e, null);
next += every;
});
}
};
}
/** Normal-battle results of the round: `leaker` let `n` `key` enemies (LEAKS 源石虫) through, everyone else was perfect. */
function craftedResults(m, leaker, key = SLIME, n = LEAKS) {
const out = new Map();
for (const ps of m.alivePlayers()) {
const leaked = ps.playerId === leaker
? Array.from({ length: n }, () => ({ enemyKey: key, mods: { hpMul: 1, atkMul: 1, speedMul: 1 }, lpr: 1, sourcePlayerId: leaker, tag: null, counted: true }))
: [];
out.set(ps.playerId, { leaked, perfect: leaked.length === 0, coins: 0, layerGains: {}, killed: 5, total: 5 + leaked.length, damageDealt: 0, unitsEnd: [] });
}
return out;
}
/** Enter the 联防 of round 1 from crafted COMBAT results (the real planner, field and settlement from there). */
function enterUnite(h, leaker, key = SLIME, n = LEAKS) {
const m = h.m;
h.drive(() => m.phase === PHASE.PREP && m.round === 1, { ready: false });
m.phase = PHASE.COMBAT;
m.fields = [];
m.lastResults = craftedResults(m, leaker, key, n);
m._afterCombat();
assert.equal(m.phase, PHASE.UNITE, '联防');
assert.deepEqual(m.unitePlan.leakers.map((p) => p.playerId), [leaker]);
}
/** m.public players[] entry of a player. */
const pubOf = (m, pid) => m.publicView().players.find((p) => p.playerId === pid);
/** Follow the leaker's public count until the 联防 ends; returns the samples [uniteLeft, pendingLp]. */
function followUnite(h, leaker, stepMs = 500) {
const m = h.m;
const seen = [];
for (let i = 0; i < 4000 && m.phase === PHASE.UNITE; i++) {
const p = pubOf(m, leaker);
if (p && Number.isFinite(p.uniteLeft)) seen.push([p.uniteLeft, p.pendingLp ?? 0]);
h.sched.advance(stepMs);
}
return seen;
}
function checkCounter(seen, lpBefore, lpAfter) {
assert.ok(seen.length > 5, `${seen.length} samples`);
assert.equal(seen[0][0], LEAKS, `the counter starts at every enemy that got through (${LEAKS}, not capped at 10)`);
assert.equal(seen[0][1], 10, 'the LP about to be lost is capped at 10');
// 源石虫 never split or summon: here it can only fall (a splitting enemy raises it — the 磨砻 test below)
for (let i = 1; i < seen.length; i++) assert.ok(seen[i][0] <= seen[i - 1][0], `no split, so it never rises (${seen[i - 1][0]} → ${seen[i][0]})`);
for (const [left, pending] of seen) assert.equal(pending, Math.min(10, left), `pendingLp = min(10, ${left})`);
const distinct = new Set(seen.map((s) => s[0]));
assert.ok(distinct.size >= 4, `it moved live: ${[...distinct].join(' → ')}`);
const last = seen[seen.length - 1][0];
assert.ok(last < 10, `helpers brought it under the cap (${last})`);
assert.equal(lpBefore - lpAfter, Math.min(10, last), `settled LP loss = min(10, ${last} left)`);
}
/** Run on until the round's settlement has been applied. */
const toSettled = (h) => assert.ok(h.drive(() => h.m.phase !== PHASE.UNITE && h.m.phase !== PHASE.SETTLE, { ready: false }));
test('#7 联防 (server-run): the leaker\'s count of its enemies still standing falls live as the helpers kill them; LP loss stays capped at 10', () => {
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 2, seed: 700, instant: false });
h.m.BattleClass = slowHelpers();
h.start();
enterUnite(h, 'p_1');
const leaker = h.ps('p_1');
const lp = leaker.lp;
assert.equal(pubOf(h.m, 'p_0').uniteLeft, undefined, 'only leakers carry the counter');
const seen = followUnite(h, 'p_1');
toSettled(h);
checkCounter(seen, lp, leaker.lp);
h.m.dispose();
});
test('#7 联防 (client-side combat, a human helper runs it): b.progress carries `left`; the server republishes it to everyone', () => {
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 2, seed: 701, instant: false, clientCombat: true, clients: false });
h.clients = attachSimClients(h, { BattleClass: slowHelpers(), pace: 'paced' });
h.start();
enterUnite(h, 'p_1');
const leaker = h.ps('p_1');
const lp = leaker.lp;
const seen = followUnite(h, 'p_1');
toSettled(h);
checkCounter(seen, lp, leaker.lp);
const lefts = h.clients.get('p_0').log.filter((x) => x.t === 'b.progress' && x.left);
assert.ok(lefts.length > 3, 'the authority reported the leakers\' counts');
assert.ok(lefts.every((x) => Object.keys(x.left).every((k) => k === 'p_1')), 'only the leaker\'s enemies');
h.clients.closeAll();
h.m.dispose();
});
test('#7 联防 (client-side combat, a bot helper: the server runs it headlessly) — the timeline carries the counts on the field clock', () => {
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 1, bots: 1, seed: 702, instant: false, clientCombat: true });
h.m.BattleClass = slowHelpers();
h.start();
enterUnite(h, 'p_0');
assert.equal(h.m.fields[0].mode, 'server', 'the bot helper\'s field runs on the server');
const leaker = h.ps('p_0');
const lp = leaker.lp;
const seen = followUnite(h, 'p_0');
toSettled(h);
checkCounter(seen, lp, leaker.lp);
h.clients.closeAll();
h.m.dispose();
});
test('#7 uniteLeft (sim) = each source\'s enemies unspawned + alive + through again; at the end = unite.js uniteSurvivors', () => {
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 2, seed: 703, instant: false });
h.m.BattleClass = slowHelpers(9);
h.start();
enterUnite(h, 'p_1');
const b = h.m.fields[0].battle;
const samples = [];
for (let i = 0; i < 4000 && !b.finished; i++) {
h.sched.advance(250);
if (b.finished) break;
const left = uniteLeft(b);
const pending = b._pending.filter((p) => p.sourcePlayerId === 'p_1').length;
const alive = b.enemies.filter((e) => e.alive && e.counted && e.sourcePlayerId === 'p_1').length;
const through = Object.values(b._perPlayer).flatMap((pp) => pp.leaked).filter((l) => l.counted !== false && l.sourcePlayerId === 'p_1').length;
assert.equal(left.p_1 || 0, pending + alive + through);
assert.equal((left.p_1 || 0) + b.killed, LEAKS, 'standing + struck down = what got through');
samples.push(left.p_1 || 0);
}
assert.ok(b.finished);
const survivors = uniteSurvivors(h.m.unitePlan, b.result()).get('p_1') || 0;
assert.equal(uniteLeft(b).p_1 || 0, survivors, 'the finished field: exactly the survivors settle() charges');
assert.equal(battleProgress(b).left.p_1 || 0, survivors, 'battleProgress carries it (b.progress `left`)');
assert.ok(samples[0] === LEAKS && samples[samples.length - 1] >= survivors);
h.m.dispose();
});
test('#7 a leaked enemy that splits (磨砻: DeadSpawn ×2) raises the counter — the children are billed to the same leaker; the clamp is what settlement can bill, not what was sent in', () => {
const MOLONG = 'enemy_1195_sfyin';
const SENT = 4;
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 2, seed: 710, instant: false });
h.m.BattleClass = slowHelpers(3);
h.start();
enterUnite(h, 'p_1', MOLONG, SENT);
const m = h.m;
const leaker = h.ps('p_1');
const lp = leaker.lp;
const b = m.fields[0].battle;
const seen = [];
for (let i = 0; i < 4000 && m.phase === PHASE.UNITE; i++) {
const p = pubOf(m, 'p_1');
if (!b.finished) {
const alive = b.enemies.filter((e) => e.alive && e.counted && e.sourcePlayerId === 'p_1').length;
const pending = b._pending.filter((x) => x.sourcePlayerId === 'p_1').length;
const through = Object.values(b._perPlayer).flatMap((pp) => pp.leaked).filter((l) => l.counted !== false && l.sourcePlayerId === 'p_1').length;
assert.equal(p.uniteLeft, pending + alive + through, 'm.public = the leaker\'s enemies standing on the field, children included');
}
seen.push(p.uniteLeft);
h.sched.advance(500);
}
assert.ok(b.total > SENT, `the 磨砻 split (${SENT} → ${b.total} on the field)`);
const rises = seen.filter((v, i) => i > 0 && v > seen[i - 1]).length;
assert.ok(rises > 0, `the counter rose after a split: ${seen.filter((v, i) => i === 0 || v !== seen[i - 1]).join(' → ')}`);
assert.ok(Math.max(...seen) > SENT, 'above the number sent in (the old clamp hid it)');
assert.ok(Math.max(...seen) <= SENT * 3, 'within what 4 磨砻 can leave behind (2 each)');
const last = seen[seen.length - 1];
toSettled(h);
assert.equal(lp - leaker.lp, Math.min(10, last), `settled LP loss = min(10, ${last} left)`);
h.m.dispose();
});
+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();
});
+4 -1
View File
@@ -206,9 +206,12 @@ test('boss field: the local pool follows b.pool (server hp − unacknowledged lo
const send = r.net.send;
r.net.send = (t, fields) => { if (t === 'b.progress') cumAtSend.push(pool.cum); return send(t, fields); };
r.advance(3000);
const first = r.net.sent.filter((x) => x.t === 'b.progress');
assert.ok(first.length >= 10 && first.length <= 14, `4 Hz (${first.length} in 3 s)`);
// how soon the operators reach the leader depends on the board the autoplayed match built
for (let i = 0; i < 30 && !(pool.cum > 0); i++) r.advance(1000);
assert.ok(pool.cum > 0, 'local damage to the pool');
const prog = r.net.sent.filter((x) => x.t === 'b.progress');
assert.ok(prog.length >= 10 && prog.length <= 14, `4 Hz (${prog.length} in 3 s)`);
const last = prog[prog.length - 1];
assert.equal(last.bossDmg, cumAtSend[cumAtSend.length - 1]);
assert.ok(last.bossDmg <= pool.cum);
+2
View File
@@ -137,6 +137,8 @@ export class SimClient {
if (pool && pool.byPlayer) msg.by = { ...pool.byPlayer };
} else {
msg.leaks = p.leaks;
// 联防: the leakers' enemies still standing (like public/js/battle/runner.js)
if (p.left) msg.left = Object.fromEntries(Object.entries(p.left).slice(0, 4));
}
this.send(msg);
}
+6 -6
View File
@@ -2,8 +2,8 @@
// (public/dev/render-demo.html, real Spine models): frames fed to the field view like the client runner does (local
// feed) with b.snap `down` / `elem` — a knocked-out operator stays on its tile in its held Die pose under a redeploy
// ring counting down, then "DP", then comes back with its deploy clip (a press on its tile selects it meanwhile);
// operators and enemies show the official element icon beside their bars, refilling over a 爆发冷却; a summon and an
// enemy that die still vanish.
// operators and enemies show the official element gauge under their bars (icon + white bar, user playtest #6), refilling
// over a 爆发冷却; a summon and an enemy that die still vanish.
//
// Opt-in (starts Chrome): RENDER_E2E=1 node --test test/render/downelem.browser.test.js
// Chrome path: $CHROME_PATH or the macOS default. Screenshots → test/e2e/out/downelem-*.png.
@@ -44,7 +44,7 @@ function installFeed() {
const out = {};
for (const [id, x] of v.debug.views) {
out[id] = { alive: x.alive, down: x.down ? x.down.state : null, ring: !!x._downRing?.root?.visible, label: x._downRing?.text?.text ?? null,
el: x._elRing?.root?.visible ? x.el : null, spine: !!x.spineReady, dieClip: x.actor?.current ?? null, alpha: +(x.alpha ?? 0).toFixed(2) };
el: x._elBar?.root?.visible ? x.el : null, spine: !!x.spineReady, dieClip: x.actor?.current ?? null, alpha: +(x.alpha ?? 0).toFixed(2) };
}
return out;
},
@@ -88,8 +88,8 @@ describe('knocked-out operators and element gauges in headless Chrome', { skip }
await new Promise((r) => setTimeout(r, 1500)); // Spine models load
await run(`const U = f.units; f.step(gt, [U[0], U[1], U[2], U[3], U[4]], { elem: [[2, 'burn', 0.4, 0, 0], [4, 'neural', 1, 12, 10]] });`, 3, 4);
let st = await page.evaluate(() => window.__feed.state());
assert.equal(st[2].el, 'burn', 'operator element icon');
assert.equal(st[4].el, 'neural', 'enemy element icon (cooldown)');
assert.equal(st[2].el, 'burn', 'operator element gauge');
assert.equal(st[4].el, 'neural', 'enemy element gauge (cooldown)');
assert.equal(st[1].el, null);
// phase 2: texas (1) is knocked out, the wolf summon (3) and an enemy (5) die
await page.evaluate(() => {
@@ -128,7 +128,7 @@ describe('knocked-out operators and element gauges in headless Chrome', { skip }
assert.equal(st[1].alive, true, 'back on its tile');
assert.equal(st[1].down, null);
assert.equal(st[1].ring, false);
assert.equal(st[2].el, null, 'the gauge left the snapshot: icon hidden');
assert.equal(st[2].el, null, 'the gauge left the snapshot: row hidden');
assert.deepEqual(problems, []);
} finally {
await page.close();
+53 -16
View File
@@ -1,8 +1,9 @@
// test/render/downring.test.js — user playtest #4 items 8 and 9, client side: render/interp.js carries b.snap `elem`
// (the element gauge a unit shows) and `down` (knocked-out operators waiting to redeploy); render/units.js keeps a
// knocked-out operator on its tile in its held Die pose under a redeploy ring (countdown → "DP" / "!" → redeploy)
// and draws the official element icon beside the bars (headless fake PIXI, test/render/fakepixi.js). User playtest #5
// item 2: an operator entering 联防 knocked out ('die' reason FORCED_EXIT) goes straight to the held pose, no burst.
// and draws the official element gauge — since user playtest #6 a row under the bars: the element icon and a white bar
// of the remaining 元素值, refilling in the element's colour over a 爆发冷却 (headless fake PIXI, test/render/fakepixi.js). User playtest #5 item 2: an operator entering
// 联防 knocked out ('die' reason FORCED_EXIT) goes straight to the held pose, no burst.
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
@@ -141,55 +142,91 @@ describe('knocked-down operator (UnitView.setDown)', () => {
});
});
describe('element gauge ring', () => {
describe('element gauge row (official: element icon + white bar of the remaining 元素值; user playtest #6)', () => {
const sample = (o = {}) => ({ x: 5, y: 10, hp: 1000, maxHp: 1000, sp: 0, spMax: 0, flags: 0, anim: 0, vx: 0, vy: 0, el: null, elFill: 0, elUntil: 0, elDur: 0, ...o });
/** The white bar's drawn share of its track (the track is 2 px wider: its dark outline). */
const share = (r) => (r.fill.visible ? r.fill.width / (r.bg.width - 2) : 0);
test('operators: the element disc with its glyph in a white ring of the remaining 元素值', () => {
test('operators: the element disc with its glyph and a white bar of the remaining 元素值 (1 − fill)', () => {
const v = view();
v.sync(sample(), 5);
frames(v, 2);
assert.equal(v._elRing, null, 'nothing built without a gauge');
assert.equal(v._elBar, null, 'nothing built without a gauge');
v.sync(sample({ el: 'burn', elFill: 0.25 }), 5);
frames(v, 1);
const r = v._elRing;
const r = v._elBar;
assert.ok(r.root.visible);
assert.equal(r.disc.texture, T.hudRings().disc.burn);
assert.equal(r.arc.texture, T.ringArc(0.75), 'remaining EP = 1 − fill');
assert.ok(Math.abs(share(r) - 0.75) < 1e-9, `remaining EP = 1 − fill (${share(r)})`);
assert.equal(r.fill.tint, 0xffffff, 'a white bar');
v.sync(sample({ el: 'neural', elFill: 0.6 }), 5);
frames(v, 1);
assert.equal(r.disc.texture, T.hudRings().disc.neural);
assert.equal(r.arc.texture, T.ringArc(0.4));
assert.ok(Math.abs(share(r) - 0.4) < 1e-9);
assert.equal(v.elementLeft(), 0.4);
const kids = r.root.children.length;
frames(v, 30);
assert.equal(r.root.children.length, kids, 'no per-frame allocation');
v.sync(sample(), 6);
frames(v, 1);
assert.equal(r.root.visible, false, 'hidden once the gauges are empty');
});
test('enemies: a smaller plain disc; during a 爆发冷却 the ring refills over the cooldown', () => {
test('enemies: a smaller plain disc; during a 爆发冷却 the bar refills over the cooldown', () => {
const e = view({ id: 3, side: 'enemy', kind: 'enemy', defId: 'enemy_x', dir: undefined });
e.sync(sample({ el: 'apoptosis', elFill: 1, elUntil: 25, elDur: 15 }), 10);
frames(e, 1);
const r = e._elRing;
const r = e._elBar;
assert.equal(r.disc.texture, T.hudRings().discEnemy.apoptosis);
assert.equal(r.arc.texture, T.ringArc(0), 'empty at the burst');
assert.equal(share(r), 0, 'empty at the burst');
e.sync(sample({ el: 'apoptosis', elFill: 1, elUntil: 25, elDur: 15 }), 17.5);
frames(e, 1);
assert.equal(r.arc.texture, T.ringArc(0.5), 'half refilled halfway through');
assert.ok(Math.abs(share(r) - 0.5) < 1e-9, 'half refilled halfway through');
const op = view({ id: 4 });
op.sync(sample({ el: 'apoptosis', elFill: 0.5 }), 1);
frames(op, 1);
assert.ok(r.root.scale.x < op._elRing.root.scale.x, 'the enemy icon is smaller');
assert.ok(r.disc.width < op._elBar.disc.width, 'the enemy icon is smaller');
});
test('a burst lock status (爆发冷却) is not repeated in the status row while the element icon shows it', () => {
test('the 爆发冷却 refill is not the white 元素值 bar: element colour, pulsing disc; white again after the cooldown', () => {
const op = view({ id: 6 });
op.sync(sample({ el: 'neural', elFill: 0.97 }), 30);
frames(op, 1);
const r = op._elBar;
assert.equal(r.fill.tint, 0xffffff, 'the 元素值 left: white');
assert.equal(r.disc.alpha, 1);
// the burst: 神经 stun + 1000 true damage while the bar refills over the 10 s cooldown
op.sync(sample({ el: 'neural', elFill: 1, elUntil: 40.5, elDur: 10 }), 34);
const alphas = new Set();
for (let i = 0; i < 40; i++) { op.update(1 / 60, cam(), 100 + i / 60); alphas.add(Math.round(r.disc.alpha * 100)); }
assert.ok(op.elementCooling());
assert.ok(Math.abs(share(r) - 0.35) < 1e-9, `refilled by the cooldown run (${share(r)})`);
assert.equal(r.fill.tint, T.ELEMENT_RING.neural.tint, 'the refill is drawn in the element\'s colour');
assert.notEqual(r.fill.tint, 0xffffff);
assert.ok(alphas.size > 5 && Math.min(...alphas) >= 44 && Math.max(...alphas) <= 100, `the disc pulses (${[...alphas]})`);
for (const el of ['erosion', 'burn', 'apoptosis']) {
op.sync(sample({ el, elFill: 1, elUntil: 44, elDur: 10 }), 35);
frames(op, 1);
assert.equal(r.fill.tint, T.ELEMENT_RING[el].tint, el);
}
// the cooldown ends: every gauge resets; a new hit shows white again
op.sync(sample({ el: 'burn', elFill: 0.1 }), 45);
frames(op, 1);
assert.equal(op.elementCooling(), false);
assert.equal(r.fill.tint, 0xffffff);
assert.equal(r.disc.alpha, 1);
});
test('a burst lock status (爆发冷却) is not repeated in the status row while the element gauge shows it', () => {
const e = view({ id: 5, side: 'enemy', kind: 'enemy', defId: 'enemy_x', dir: undefined });
e.onStatus('burnBurst', true);
e.onStatus('fragile', true);
assert.deepEqual([...e._iconKeys()], ['burn', 'fragile'], 'no gauge in the feed: the status shows the burst');
e.sync(sample({ el: 'burn', elFill: 1, elUntil: 20, elDur: 10 }), 12);
assert.deepEqual([...e._iconKeys()], ['fragile'], 'the element icon carries the 爆发冷却');
assert.deepEqual([...e._iconKeys()], ['fragile'], 'the element gauge carries the 爆发冷却');
});
test('the atlas: one frame per step, every element in both styles', () => {
test('the atlas: one ring frame per step (the redeploy ring), every element disc in both styles', () => {
const R = T.hudRings();
assert.equal(R.arcs.length, T.RING_STEPS + 1);
for (const el of ['neural', 'erosion', 'burn', 'apoptosis', 'necrosis']) { assert.ok(R.disc[el]); assert.ok(R.discEnemy[el]); }
+200
View File
@@ -0,0 +1,200 @@
// test/render/elembar.browser.test.js — user playtest #6 report 11 in headless Chrome ("干员血条右边的元素损伤条积累速度
// 不对,损伤条远远没扣完但是实际已经爆条"): a real battle (古米 against 底海滑动者) is simulated in Node, its frames are fed
// to the real field view (public/dev/render-demo.html, real Spine models) the way the client runner does (local feed,
// one tick per animation frame), and the drawn element gauge is read back every frame:
// · the white bar is the sim's remaining 元素值 at the frame shown (within 1 %, never below it, never empty before the
// burst), and the burst shows as the bar refilling over the 爆发冷却 in the element's colour (never white);
// · a row of five operators with gauges (desktop and a 844×390 phone at DPR 3): each gauge row lies under its own
// unit's bars and inside their span (the v2.3 ring sat on the next operator's tier chip and bars).
//
// Opt-in (starts Chrome): RENDER_E2E=1 node --test test/render/elembar.browser.test.js
// Chrome path: $CHROME_PATH or the macOS default. Screenshots → test/e2e/out/elembar-*.png.
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
import { makeBattle } from '../helpers/battleHarness.js';
import { ELEMENT_ORDER } from '../../server/sim/constants.js';
import { ELEMENT_RING } from '../../public/js/render/textures.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const OUT = path.join(ROOT, 'test/e2e/out');
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const enabled = process.env.RENDER_E2E === '1' && existsSync(CHROME) && existsSync(path.join(ROOT, 'public/assets'));
const skip = enabled ? false : 'set RENDER_E2E=1 (needs Chrome and downloaded assets)';
const NEURAL_TINT = ELEMENT_RING.neural.tint;
/** Real frames: 古米 blocking 底海滑动者 (15 % ATK 神经 per hit) until past its first burst. */
function recordBattle(seconds = 54) {
const h = makeBattle({ units: [{ chessId: 'chess_char_1_10_a', row: 10, col: 5 }], seed: 3, autoFinish: false, timeLimit: 600 });
h.step();
h.spawn('enemy_1148_dssbr', { pos: [10, 5.6], routeIndex: 0, mods: { speedMul: 0, hpMul: 1000 } });
const u = h.allies()[0];
const meta = { ...h.b.fieldMeta(), stageId: 'act2autochess_m01' };
h.b.drainEvents();
const frames = [];
for (let i = 0; i < seconds * 30; i++) {
h.step();
if (u.alive) u.hp = u.s.maxHp;
let bv = 0, el = null;
for (const k of ELEMENT_ORDER) if (u.elem[k] > bv) { bv = u.elem[k]; el = k; }
const { t, ...rest } = h.b.snapshot();
frames.push({ snap: { ...rest, t: 'b.snap', gt: t }, ev: h.b.drainEvents(), gt: t, el, left: el ? 1 - bv / u.gaugeMax : null, lock: !!u.s.flags.burstLock });
}
return { meta, frames, opId: u.id };
}
/** Page side: the drawn gauge of unit `id` (null: none) — element, the white bar's share, the render clock. */
function readGauge(id) {
const v = window.__demo.view;
const x = v.debug.views.get(id);
const r = x && x._elBar;
if (!r || !r.root.visible) return { el: null, renderT: v.debug.interp.renderT };
return { el: x.el, share: r.fill.visible ? r.fill.width / (r.bg.width - 2) : 0, tint: r.fill.tint, renderT: v.debug.interp.renderT };
}
describe('element gauge row in headless Chrome (user playtest #6)', { skip }, () => {
let srv, browser;
before(async () => {
const require = createRequire(path.join(ROOT, 'package.json'));
const puppeteer = require('puppeteer-core');
const { startServer } = await import('../../server/index.js');
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
browser = await puppeteer.launch({ executablePath: CHROME, headless: 'new', args: ['--no-first-run'] });
mkdirSync(OUT, { recursive: true });
});
after(async () => {
await browser?.close();
await srv?.close();
});
const open = async (viewport) => {
const page = await browser.newPage();
const problems = [];
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
page.on('response', (r) => { if (r.status() >= 400) problems.push(`HTTP ${r.status()} ${r.url()}`); });
await page.setViewport(viewport);
await page.goto(`http://127.0.0.1:${srv.port}/dev/render-demo.html?scene=normal-m01&paused=1&panel=0`);
await page.waitForFunction('window.__demo && (window.__demo.ready || window.__demo.error)', { timeout: 30000 });
return { page, problems };
};
test('a real battle: the white bar drawn every frame is the sim\'s remaining 元素值; the burst refills it', async () => {
const { meta, frames, opId } = recordBattle();
const { page, problems } = await open({ width: 1280, height: 720 });
try {
await page.evaluate((meta) => {
const v = window.__demo.view;
v.enterBattle(meta);
v.setCamera('normal', { rect: meta.rect, side: 'L', instant: true });
v.setLocalFeed({ on: true, speed: 2 });
}, meta);
const byT = new Map(frames.map((f) => [f.gt, f]));
const rows = [];
const CHUNK = 30;
for (let i = 0; i < frames.length; i += CHUNK) {
const got = await page.evaluate(async (chunk, id, readSrc) => {
const read = new Function(`return (${readSrc})`)();
const v = window.__demo.view, out = [];
for (const f of chunk) {
if (f.ev.length) v.pushEvents({ t: 'b.ev', fieldId: f.snap.fieldId, gt: f.gt, ev: f.ev });
v.pushSnapshot(f.snap);
await new Promise((r) => requestAnimationFrame(() => r()));
const g = read(id);
const s = v.debug.interp.snaps;
let shown = s[0]?.t;
for (const x of s) if (x.t <= g.renderT) shown = x.t;
out.push({ ...g, shownT: shown });
}
return out;
}, frames.slice(i, i + CHUNK), opId, readGauge.toString());
rows.push(...got);
}
let checked = 0, refills = 0, prevLock = false, lastBefore = null;
for (const row of rows) {
const f = byT.get(row.shownT);
if (!f) continue;
if (f.lock) {
assert.equal(row.el, 'neural', 'the 爆发冷却 shows');
if (!prevLock) { refills++; assert.ok(row.share < 0.03, `the refill starts empty (${row.share})`); }
assert.equal(row.tint, NEURAL_TINT, 'the refill is drawn in the 神经 colour, not white');
} else if (f.el) {
checked++;
assert.equal(row.el, f.el);
assert.equal(row.tint, 0xffffff, 'the 元素值 left is white');
assert.ok(row.share >= f.left - 1e-6 && row.share - f.left < 0.01 + 1e-6, `gt ${f.gt}: bar ${row.share} vs sim ${f.left}`);
assert.ok(row.share > 0, 'never empty before the burst');
lastBefore = row.share;
} else assert.equal(row.el, null);
prevLock = f.lock;
}
assert.ok(checked > 1000, `${checked} frames checked`);
assert.equal(refills, 1, 'one 神经 burst');
assert.ok(lastBefore <= 0.05, `the burst comes off a sliver (${lastBefore})`);
await page.screenshot({ path: path.join(OUT, 'elembar-cooldown.png') });
assert.deepEqual(problems, []);
} finally {
await page.close();
}
});
for (const [name, viewport] of [['desktop', { width: 1280, height: 720 }], ['phone', { width: 844, height: 390, deviceScaleFactor: 3 }]]) {
test(`a row of operators (${name}): every gauge row lies under its own unit's bars, inside their span`, async () => {
const { page, problems } = await open(viewport);
try {
await page.evaluate(() => {
const v = window.__demo.view;
const U = (id, side, spine, x, y) => ({ id, kind: side === 'enemy' ? 'enemy' : 'op', side, defId: spine, spine, avatar: spine, x, y, maxHp: 1000, facing: 1, dir: side === 'enemy' ? undefined : 'RIGHT', tier: 3 });
const units = [3, 4, 5, 6, 7].map((c, i) => U(i + 1, 'ally', i % 2 ? 'char_1016_agoat2' : 'char_102_texas', c, 10))
.concat([U(6, 'enemy', 'enemy_1000_gopro_2', 3, 11.4), U(7, 'enemy', 'enemy_1000_gopro_2', 6, 11.4)]);
v.enterBattle({ fieldId: 'eb', kind: 'normal', rect: { r0: 9, r1: 12, c0: 0, c1: 10 }, stageId: 'act2autochess_m01', units });
v.setCamera('normal', { rect: { r0: 9, r1: 12, c0: 0, c1: 10 }, side: 'L', instant: true });
v.setLocalFeed({ on: true, speed: 2 });
window.__units = units;
});
const feed = (from, to) => page.evaluate(async (from, to) => {
const v = window.__demo.view, units = window.__units;
for (let gt = from; gt <= to + 1e-9; gt += 1 / 30) {
v.pushSnapshot({ t: 'b.snap', fieldId: 'eb', gt: +gt.toFixed(4), units: units.map((u) => [u.id, u.x, u.y, u.side === 'enemy' ? 700 : 1000, 1000, 5, 10, 0, 0]), dp: 5, killed: 0, total: 2,
elem: [[1, 'neural', 0.1, 0, 0], [2, 'erosion', 0.5, 0, 0], [3, 'burn', 0.9, 0, 0], [4, 'apoptosis', 0.97, 0, 0], [5, 'neural', 1, 12, 10], [6, 'neural', 0.25, 0, 0], [7, 'erosion', 0.75, 0, 0]] });
await new Promise((r) => requestAnimationFrame(() => r()));
}
}, from, to);
await feed(1, 2.5);
await new Promise((r) => setTimeout(r, 1500)); // Spine models load
await feed(2.5, 7);
const geo = await page.evaluate(() => {
const out = {};
for (const [id, x] of window.__demo.view.debug.views) {
const r = x._elBar;
if (!r || !r.root.visible) continue;
const x0 = x.hpBg.position.x + 1, bw = x.hpBg.width - 2;
const barsBottom = x.spFill.visible ? x.spFill.position.y + x.spFill.height / 2 : x.hpBg.position.y + x.hpBg.height / 2;
out[id] = { x0, bw, barsBottom, discL: r.disc.position.x - r.disc.width * 0.336, discTop: r.disc.position.y - r.disc.height * 0.336,
barR: r.bg.position.x + r.bg.width, share: r.fill.visible ? r.fill.width / (r.bg.width - 2) : 0, el: x.el, tint: r.fill.tint };
}
return out;
});
const want = { 1: 0.9, 2: 0.5, 3: 0.1, 4: 0.03, 6: 0.75, 7: 0.25 };
for (const [id, left] of Object.entries(want)) {
const g = geo[id];
assert.ok(g, `unit ${id} shows its gauge`);
assert.ok(Math.abs(g.share - left) < 1e-6, `unit ${id}: bar ${g.share} = remaining ${left}`);
assert.ok(g.discL >= g.x0 - 1 && g.barR <= g.x0 + g.bw + 1.5, `unit ${id}: inside its bars' span`);
assert.ok(g.discTop >= g.barsBottom, `unit ${id}: under its bars`);
assert.equal(g.tint, 0xffffff, `unit ${id}: 元素值 left is white`);
}
assert.ok(geo[5].share > 0.4 && geo[5].share < 0.6, `the 爆发冷却 half refilled (${geo[5].share})`);
assert.equal(geo[5].tint, NEURAL_TINT, 'the 爆发冷却 refill in the element\'s colour, unlike unit 1\'s white 神经 bar');
await page.screenshot({ path: path.join(OUT, `elembar-row-${name}.png`) });
assert.deepEqual(problems, []);
} finally {
await page.close();
}
});
}
});
@@ -0,0 +1,122 @@
// test/render/playtest6-enemies.browser.test.js — user playtest #6 items 9 and 13 in headless Chrome: the real sim runs
// in the page (the client runner's loadBrowserSim) and feeds the render demo's field view with the real Spine models.
// #9 威龙 (enemy_1005_yokai_3) is drawn at its official prefab scale: its body is ≈ 1.08× as wide as a 妖怪's on screen
// (official 0.16 vs 0.20 on skeletons 689 vs 509 units wide), not 1.35× (enemies.json modelScale; render/units.js).
// #13 a “萨科塔之翼” below half HP flutters at random inside its tile for 5 s: its model turns left and right.
//
// Opt-in (starts Chrome): RENDER_E2E=1 node --test test/render/playtest6-enemies.browser.test.js
// Chrome path: $CHROME_PATH or the macOS default. Screenshot → test/e2e/out/playtest6-drones.png.
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
import { buildBattleSpec } from '../../server/sim/spec.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const OUT = path.join(ROOT, 'test/e2e/out');
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const enabled = process.env.RENDER_E2E === '1' && existsSync(CHROME) && existsSync(path.join(ROOT, 'public/assets'));
const skip = enabled ? false : 'set RENDER_E2E=1 (needs Chrome and downloaded assets)';
// three slow flyers side by side on act2 m01's normal field: 妖怪, 威龙, “萨科塔之翼”
const fly = (r) => ({ motion: 'FLY', start: [r, 9], end: [r, 2], checkpoints: [] });
const SPEC = buildBattleSpec({
battleId: 'e2e.pt6', fieldId: 'n:P1', kind: 'normal', seed: 9, modeId: 'mode_multi_hard', round: 6, stageId: 'act2autochess_m01', timeLimit: 400,
players: [{ playerId: 'P1', seat: 0, side: 'L', colOffset: 0, units: [], bonds: {}, playerEffects: [] }],
spawns: [
{ time: 0, enemyKey: 'enemy_1005_yokai', routeIndex: 0, count: 1, mods: { hpMul: 50, speedMul: 0.15 } },
{ time: 0, enemyKey: 'enemy_1005_yokai_3', routeIndex: 1, count: 1, mods: { hpMul: 50, speedMul: 0.15 } },
{ time: 0, enemyKey: 'enemy_10083_hlbird', routeIndex: 2, count: 1, mods: { hpMul: 50, speedMul: 1 } },
],
routes: [fly(12), fly(10.5), fly(9)], flags: {},
});
describe('enemy model sizes and the 萨科塔 flutter (headless Chrome, real sim + real models)', { skip }, () => {
let srv, browser;
before(async () => {
const require = createRequire(path.join(ROOT, 'package.json'));
const puppeteer = require('puppeteer-core');
const { startServer } = await import('../../server/index.js');
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
browser = await puppeteer.launch({ executablePath: CHROME, headless: 'new', args: ['--no-first-run'] });
mkdirSync(OUT, { recursive: true });
});
after(async () => {
await browser?.close();
await srv?.close();
});
test('威龙 ≈ 1.08× a 妖怪 on screen; the feared bird turns left and right inside its tile', async () => {
const page = await browser.newPage();
const problems = [];
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
page.on('response', (r) => { if (r.status() >= 400) problems.push(`HTTP ${r.status()} ${r.url()}`); });
try {
await page.setViewport({ width: 1280, height: 720 });
await page.goto(`http://127.0.0.1:${srv.port}/dev/render-demo.html?scene=normal-m01&paused=1&panel=0`);
await page.waitForFunction('window.__demo && (window.__demo.ready || window.__demo.error)', { timeout: 30000 });
const log = await page.evaluate(async (spec) => {
const { loadBrowserSim } = await import('/js/battle/runner.js');
const { data } = await import('/js/data.js');
const { spec: S, ds } = await loadBrowserSim();
const b = S.createBattleFromSpec(spec, ds, { quiet: true });
const v = window.__demo.view;
v.setStage(data.lookup('stages', spec.stageId));
b.step();
const meta = b.fieldMeta();
v.enterBattle(meta);
v.setCamera('normal', { rect: meta.rect, side: 'L', instant: true });
v.setLocalFeed({ on: true, speed: 2 });
const raf = () => new Promise((r) => requestAnimationFrame(() => r()));
const feed = async () => {
b.step();
const ev = b.drainEvents();
if (ev.length) v.pushEvents({ t: 'b.ev', fieldId: meta.fieldId, gt: b.time, ev });
v.pushSnapshot(b.snapshot());
await raf();
};
const byKey = (k) => b.enemies.find((x) => x.defId === k);
for (let i = 0; i < 30; i++) await feed();
await new Promise((r) => setTimeout(r, 2500)); // the Spine models load
for (let i = 0; i < 30; i++) await feed();
const size = {};
for (const k of ['enemy_1005_yokai', 'enemy_1005_yokai_3', 'enemy_10083_hlbird']) {
const view = v.debug.views.get(byKey(k).id);
const bd = view.actor.spine.getLocalBounds();
size[k] = { spine: !!view.spineReady, modelK: view.modelK, widthTiles: (bd.width * Math.abs(view.actor.spine.scale.x)) / view.screen.s };
}
// #13: below half HP the bird fears itself
const bird = byKey('enemy_10083_hlbird');
const bv = v.debug.views.get(bird.id);
b.dealDamage(null, bird, { amount: bird.s.maxHp * 0.6, type: 'true' });
const tr = Math.round(bird.y), tc = Math.round(bird.x);
const faces = [];
let maxOff = 0;
for (let i = 0; i < 30 * 4.5; i++) {
await feed();
if (i > 30) maxOff = Math.max(maxOff, Math.abs(bird.x - tc), Math.abs(bird.y - tr));
if (i % 2 === 0) faces.push(bv.visFacing);
}
let flips = 0;
for (let i = 1; i < faces.length; i++) if (faces[i] !== faces[i - 1]) flips++;
return { size, flips, maxOff, feared: !!bird.s.flags.fear };
}, SPEC);
await page.screenshot({ path: path.join(OUT, 'playtest6-drones.png') });
const s = log.size;
assert.ok(s.enemy_1005_yokai.spine && s.enemy_1005_yokai_3.spine, 'models loaded');
assert.ok(Math.abs(s.enemy_1005_yokai_3.modelK - 0.5926) < 1e-6 && Math.abs(s.enemy_1005_yokai.modelK - 0.7407) < 1e-6);
const ratio = s.enemy_1005_yokai_3.widthTiles / s.enemy_1005_yokai.widthTiles;
assert.ok(ratio > 0.9 && ratio < 1.25, `威龙 / 妖怪 drawn width ${ratio.toFixed(2)} (official ≈ 1.08; it was ≈ 1.35)`);
assert.ok(s.enemy_1005_yokai_3.widthTiles < 1.8, `威龙 ${s.enemy_1005_yokai_3.widthTiles.toFixed(2)} tiles wide`);
assert.ok(log.flips >= 2, `the bird's model turns (${log.flips} flips)`);
assert.ok(log.maxOff <= 0.26, `inside its tile's 0.5 square (${log.maxOff.toFixed(3)})`);
assert.deepEqual(problems, []);
} finally {
await page.close();
}
});
});
+117
View File
@@ -0,0 +1,117 @@
// test/render/playtest6-enemies.test.js — user playtest #6, workstream WG, the renderer side (headless fake PIXI):
// #9 the flying enemy 威龙 was drawn far too large. The official battle prefab scales every enemy's Spine model
// (Graphic / FaceSwitcher / Spine transforms; SkeletonDataAsset 0.01 everywhere): 0.27 is the standard, 威龙 0.16,
// 妖怪 0.20, 妖怪MKII 0.22 (tools/build-data.mjs MODEL_SCALES → enemies.json `modelScale` = scale / 0.27). The
// renderer drew every skeleton at one UNIT.modelScale, so 威龙's 35 % wider skeleton came out 1.35× a 妖怪; now it
// is drawn at its official 0.16 / 0.27 (1.08× a 妖怪, as in the game).
// #13 a self-feared “萨科塔之翼” flutters around inside its tile: the real sim's positions → the snapshot buffer →
// UnitView.sync — the model flips left / right as the random flight turns (render/units.js `visFacing`).
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { installFakePixi, fakeViewCtx } from './fakepixi.js';
import { presetCamera } from '../../public/js/render/projection.js';
import { UNIT } from '../../public/js/render/style.js';
import { SnapshotBuffer } from '../../public/js/render/interp.js';
import { makeBattle } from '../helpers/battleHarness.js';
const E = JSON.parse(fs.readFileSync(new URL('../../data/enemies.json', import.meta.url), 'utf8'));
const A = JSON.parse(fs.readFileSync(new URL('../../data/assets.json', import.meta.url), 'utf8'));
let fake, U;
before(async () => {
fake = installFakePixi();
U = await import('../../public/js/render/units.js');
});
after(() => fake.restore());
const tick = () => new Promise((r) => setImmediate(r));
const cam = () => presetCamera('normal', { width: 1280, height: 720 });
const store = ({ bounds = true } = {}) => {
const entry = { skel: '/s/x.skel', atlas: '/s/x.atlas', textures: ['/s/x.png'], anims: { idle: 'Idle' }, animations: { Idle: 1 }, ...(bounds ? { bounds: { x: -300, y: -150, width: 690, height: 390 } } : {}) };
return {
picture: () => null, image: async () => null,
spineEntry: () => entry,
spine: { acquire: async () => ({ animations: [{ name: 'Idle' }] }), release() {} },
};
};
function view(info, opts) {
const ctx = fakeViewCtx(fake.P, { assets: store(opts), cam, lookupDef: (i) => (i.side === 'enemy' ? E[i.defId] || null : null) });
return new U.UnitView(ctx, { id: 1, side: 'enemy', kind: 'enemy', tier: 1, x: 6, y: 10, maxHp: 1000, motion: 'FLY', ...info });
}
const width = (key) => A.enemies[key].spine.bounds.width;
describe('#9 enemy models at their official prefab scale', () => {
test('data: 威龙 0.16 / 0.27, 妖怪 0.20 / 0.27, 妖怪MKII 0.22 / 0.27; standard-size enemies carry none', () => {
assert.equal(E.enemy_1005_yokai_3.modelScale, 0.5926);
assert.equal(E.enemy_1005_yokai.modelScale, 0.7407);
assert.equal(E.enemy_1005_yokai_2.modelScale, 0.8148);
assert.equal(E.enemy_10083_hlbird.modelScale, undefined, '“萨科塔之翼”: 0.27, the standard');
assert.equal(E.enemy_9013_acstmk.modelScale, undefined, '假想敌:胄: 0.27');
for (const [k, e] of Object.entries(E)) if (e.modelScale != null) assert.ok(e.modelScale > 0.5 && e.modelScale < 2.3 && e.modelScale !== 1, `${k} ${e.modelScale}`);
// drawn width 威龙 : 妖怪 — was 1.35 (skeletons alone), officially 689·0.16 : 509·0.20 ≈ 1.08
const ratio = (width('enemy_1005_yokai_3') * E.enemy_1005_yokai_3.modelScale) / (width('enemy_1005_yokai') * E.enemy_1005_yokai.modelScale);
assert.ok(Math.abs(ratio - (689.149 * 0.16) / (509 * 0.2)) < 0.01, `ratio ${ratio}`);
assert.ok(width('enemy_1005_yokai_3') / width('enemy_1005_yokai') > 1.3, 'the skeleton alone is 35 % wider');
});
test('UnitView: 威龙\'s skeleton and bar height follow modelScale; operators and standard enemies are unchanged', async () => {
const drone = view({ defId: 'enemy_1005_yokai_3' });
const bird = view({ defId: 'enemy_10083_hlbird' });
const op = view({ side: 'ally', kind: 'chess', defId: 'chess_char_1_01_a', motion: undefined });
await tick(); await tick();
for (const v of [drone, bird, op]) { assert.ok(v.spineReady); v.update(1 / 60, cam(), 0); }
const sc = (v) => Math.abs(v.actor.spine.scale.y) / v.screen.s;
assert.ok(Math.abs(sc(drone) - UNIT.modelScale * 0.5926) < 1e-9, `威龙 ${sc(drone)}`);
assert.ok(Math.abs(sc(bird) - UNIT.modelScale) < 1e-9);
assert.ok(Math.abs(sc(op) - UNIT.modelScale) < 1e-9);
assert.equal(U.enemyModelScale(E.enemy_1005_yokai_3), 0.5926);
assert.equal(U.enemyModelScale(null), 1);
assert.equal(U.enemyModelScale({ modelScale: 'x' }), 1);
// the HP bar sits on its (smaller) model: 390 units × 1/320 × 0.5926 × 0.92, clamped at 0.55
assert.ok(Math.abs(drone._headTiles - Math.max(0.55, 390 * UNIT.modelScale * 0.5926 * 0.92)) < 1e-9);
assert.ok(bird._headTiles > drone._headTiles);
});
test('UnitView without setup-pose bounds: the bar height is the chibi headroom × modelScale (bosses keep 2.2)', async () => {
const sailor = view({ defId: 'enemy_1160_hvyslr', motion: 'WALK' }, { bounds: false }); // 码头水手 0.24 / 0.27
const bird = view({ defId: 'enemy_10083_hlbird' }, { bounds: false });
const boss = view({ defId: 'enemy_1160_hvyslr', motion: 'WALK', boss: true }, { bounds: false });
await tick(); await tick();
for (const v of [sailor, bird, boss]) { assert.ok(v.spineReady); v.update(1 / 60, cam(), 0); }
assert.equal(E.enemy_1160_hvyslr.modelScale, 0.8889);
assert.ok(Math.abs(sailor._headTiles - UNIT.headroom * 0.8889) < 1e-9, `码头水手 ${sailor._headTiles}`);
assert.equal(bird._headTiles, UNIT.headroom, 'a standard-size model keeps the chibi headroom');
assert.equal(boss._headTiles, 2.2);
});
});
describe('#13 the self-feared “萨科塔之翼” turns left and right on screen', () => {
test('real sim → snapshot buffer → UnitView: facing flips while it flutters, none while it flies its route', () => {
const h = makeBattle({ seed: 3, autoFinish: false, timeLimit: 120, units: [] });
const e = h.spawn('enemy_10083_hlbird', { routeIndex: 2 }); // fly lane (9,10) → (9,2)
const buf = new SnapshotBuffer({ delay: 0.1, rate: 2 });
const v = view({ id: e.id, defId: 'enemy_10083_hlbird', x: e.x, y: e.y });
let now = 0, flipsRoute = 0, flipsFear = 0, last = v.visFacing, fearFrom = null;
for (let i = 0; i < 30 * 10; i++) {
h.step();
if (i === 30 * 3) { h.b.dealDamage(null, e, { amount: e.s.maxHp * 0.6, type: 'true' }); fearFrom = h.b.time; }
if (i % 3 !== 2) continue;
now += 3 / 60; // 2× speed: 3 ticks = 1/20 real s
buf.push(h.b.snapshot(), now);
buf.update(now);
const s = buf.sample().get(e.id);
if (!s) continue;
v.sync(s, buf.renderT);
if (v.visFacing !== last) {
const t = buf.renderT;
if (fearFrom != null && t > fearFrom + 0.2 && t < fearFrom + 5) flipsFear++;
else if (fearFrom == null) flipsRoute++;
last = v.visFacing;
}
}
assert.equal(flipsRoute, 0, 'flying its route (leftwards) it keeps facing left');
assert.ok(flipsFear >= 2, `flips while fluttering (${flipsFear})`);
});
});
+134
View File
@@ -0,0 +1,134 @@
// test/render/playtest6_elembar.test.js — user playtest #6 report 11, client side: "干员血条右边的元素损伤条积累速度不对,
// 损伤条远远没扣完但是实际已经爆条受到伤害了". The whole display path in Node (headless fake PIXI, test/render/fakepixi.js):
// a real battle (古米 against real 海嗣) → Battle.snapshot() every tick like the client runner → render/interp.js
// SnapshotBuffer with the local-feed clock → sample() → render/units.js UnitView. Checks:
// · the white bar drawn is the sim's remaining 元素值 at the render clock (within 1 %, never less than what is left,
// never empty before the burst), the icon is the element with the least 元素值 left, and the burst shows as a
// refill (PRTS 元素 "以白条显示剩余的元素值" / "元素条显示缓慢恢复至上限") drawn in the element's colour, never the
// white of 元素值 left — a white bar growing while the burst's stun / damage hits read as "not used up yet, but it
// burst";
// · the render clock trails the sim by ≤ 3 ticks;
// · the gauge row is the unit's own: under its HP / SP bars and inside their span — the v2.3 ring right of the bars
// sat on the next operator's tier chip and bars (drawn under them) and read as that operator's gauge.
import { test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { installFakePixi, fakeViewCtx } from './fakepixi.js';
import { presetCamera } from '../../public/js/render/projection.js';
import { SnapshotBuffer } from '../../public/js/render/interp.js';
import { makeBattle } from '../helpers/battleHarness.js';
import { ELEMENT_ORDER, TICK } from '../../server/sim/constants.js';
let fake, UnitView, EL_BAR, ELEMENT_RING;
before(async () => {
fake = installFakePixi();
({ UnitView, EL_BAR } = await import('../../public/js/render/units.js'));
({ ELEMENT_RING } = await import('../../public/js/render/textures.js'));
});
after(() => fake.restore());
const cam = () => presetCamera('normal', { width: 1280, height: 720 });
const OP = 'chess_char_1_10_a'; // 古米
/** Remaining 元素值 share of the element with the least left (the sim's truth), null without a gauge. */
function simLeft(u) {
let bv = 0, el = null;
for (const k of ELEMENT_ORDER) if (u.elem[k] > bv) { bv = u.elem[k]; el = k; }
return el ? { el, left: 1 - bv / u.gaugeMax } : null;
}
/**
* Run a real battle and render the operator every tick through the client path; returns the per-frame rows
* { t, renderT, sim (at the snapshot shown), el, share, lock } and the view.
*/
function renderRun(keys, { mods = {}, seconds = 70 } = {}) {
const h = makeBattle({ units: [{ chessId: OP, row: 10, col: 5 }], seed: 3, autoFinish: false, timeLimit: 600 });
h.step();
const pos = [[10, 5.6], [10, 4.4]];
keys.forEach((k, i) => h.spawn(k, { pos: pos[i], routeIndex: 0, mods: { speedMul: 0, hpMul: 1000, ...mods } }));
const u = h.allies()[0];
const info = h.b.fieldMeta().units.find((x) => x.id === u.id);
const v = new UnitView(fakeViewCtx(fake.P, { cam }), info);
// the client runner's local feed (render/app.js setLocalFeed): ~2 frames of delay, the battle's speed
const buf = new SnapshotBuffer({ delay: 0.034, rate: 2, maxRate: 8 });
const truth = new Map(); // snapshot time → the sim's gauge then
const out = new Map();
const rows = [];
for (let i = 0; i < seconds / TICK; i++) {
h.step();
if (u.alive) u.hp = u.s.maxHp; // keep the operator standing: only its gauge matters
const snap = h.b.snapshot();
truth.set(snap.t, { g: simLeft(u), lock: !!u.s.flags.burstLock });
const now = (i + 1) / 60; // 60 ticks per real second at 2× (DESIGN §4)
buf.push({ ...snap, gt: snap.t }, now);
const rt = buf.update(now);
buf.sample(rt, out);
const s = out.get(u.id);
if (!s) continue;
v.sync(s, rt);
v.update(1 / 60, cam(), now);
const shownT = buf.snaps[Math.max(0, buf._indexAt(rt))].t;
const r = v._elBar;
rows.push({
t: snap.t, renderT: rt, sim: truth.get(shownT), el: r && r.root.visible ? v.el : null,
share: r && r.root.visible ? (r.fill.visible ? r.fill.width / (r.bg.width - 2) : 0) : null,
tint: r ? r.fill.tint : null, v,
});
}
return { rows, v };
}
function checkRows(rows) {
let bursts = 0, prevLock = false, lastLeftBefore = null;
for (const row of rows) {
assert.ok(row.t - row.renderT <= 3 * TICK + 1e-9, `the render clock trails by ≤ 3 ticks (${row.t} vs ${row.renderT})`);
const { g, lock } = row.sim;
if (lock) {
assert.ok(row.el, 'the 爆发冷却 shows');
if (!prevLock) { bursts++; assert.ok(row.share < 0.02, `the refill starts empty (${row.share})`); }
assert.equal(row.tint, ELEMENT_RING[row.el].tint, `t=${row.t}: the refill is drawn in the element's colour, not white`);
} else if (!g) assert.equal(row.el, null, 'no gauge, no row');
else {
assert.equal(row.el, g.el, `t=${row.t}: the icon is the element with the least 元素值 left`);
assert.equal(row.tint, 0xffffff, `t=${row.t}: 元素值 left is white`);
assert.ok(row.share >= g.left - 1e-9, `t=${row.t}: bar ${row.share} never below the real remainder ${g.left}`);
assert.ok(row.share - g.left < 0.01 + 1e-9, `t=${row.t}: bar ${row.share} within 1 % of ${g.left}`);
assert.ok(row.share > 0, `t=${row.t}: never empty before the burst`);
lastLeftBefore = row.share;
}
prevLock = lock;
}
return { bursts, lastLeftBefore };
}
test('底海滑动者 on 古米: the white bar drawn is the sim\'s remaining 元素值 every frame; the burst comes off a sliver', () => {
const { rows } = renderRun(['enemy_1148_dssbr'], { seconds: 62 });
const { bursts } = checkRows(rows);
assert.equal(bursts, 1, 'the 神经 burst at ≈ 49 game s');
const before = rows[rows.findIndex((r) => r.sim.lock) - 1];
assert.ok(before.share <= 0.05, `the last bar before the burst: ${before.share}`);
// the refill runs over the 10 s 爆发冷却
const cool = rows.filter((r) => r.sim.lock);
const mid = cool[Math.floor(cool.length / 2)];
assert.ok(Math.abs(mid.share - 0.5) < 0.05, `halfway through the cooldown the bar is half refilled (${mid.share})`);
});
test('掠海漂移体 + 骨海漂流体 on 古米 (绝境 R11 ATK ×1.77): chunky 侵蚀 hits — the bar follows every chunk exactly', () => {
const { rows } = renderRun(['enemy_2025_syufo', 'enemy_2021_syfish'], { mods: { atkMul: 1.771561 }, seconds: 40 });
const { bursts } = checkRows(rows);
assert.ok(bursts >= 1);
});
test('the gauge row belongs to its unit: under its own HP / SP bars and inside their span', () => {
const { rows, v } = renderRun(['enemy_1148_dssbr'], { seconds: 12 });
assert.ok(rows.at(-1).el, 'a gauge shows');
const r = v._elBar;
const x0 = v.hpBg.position.x + 1, bw = v.hpBg.width - 2;
const spBottom = v.spFill.visible ? v.spFill.position.y + v.spFill.height / 2 : v.hpFill.position.y + v.hpFill.height / 2;
const discR = r.disc.width * 0.5 * (43 / 64); // the drawn disc (textures.js HUD_DISC of the cell)
assert.ok(r.disc.position.x - discR >= x0 - 0.5, 'the icon starts at the bars\' left edge');
assert.ok(r.bg.position.x + r.bg.width <= x0 + bw + 1.5, 'the bar ends at the bars\' right end (never beyond, over a neighbour)');
assert.ok(r.fill.position.x + r.fill.width <= x0 + bw + 0.5);
assert.ok(r.disc.position.y - discR >= spBottom + EL_BAR.gap - 0.01, 'the row lies under the SP bar');
assert.ok(r.bg.position.y - r.bg.height / 2 > spBottom, 'the white bar too');
});
+5 -4
View File
@@ -289,7 +289,7 @@ test('淤困 (enemy_9007_acelem) parasite hosts side by side: one burst spreads
assert.ok(hpA - A.hp < 2 * ELEMENT.burn.ally.damage && hpB - B.hp < 2 * ELEMENT.burn.ally.damage);
});
test('statuses (official terms): tremble only stops attacks while blocked; fear = unblockable + no attack + no advance; stunned blocker lets go', () => {
test('statuses (official terms): tremble only stops attacks while blocked; fear = unblockable + no attack + no advance (no source: it stays on its tile, fear.js); stunned blocker lets go', () => {
const g = chessRec({ id: 't_guard', profession: 'WARRIOR', stats: { atk: 0, blockCnt: 2, maxHp: 1e6 }, skill: null });
const h = makeBattle({
defs: { chess: { t_guard: g }, enemies: { enemy_walker: walker({ atk: 100, bat: 1 }) } },
@@ -309,13 +309,14 @@ test('statuses (official terms): tremble only stops attacks while blocked; fear
assert.equal(e.blockedBy, null, 'a stunned operator blocks nothing');
assert.equal(u.blocking.length, 0);
h.run(1.2);
// fear: released, no attacks, does not advance
// fear: released, no attacks, does not advance — without a source there is no 恐惧可达地块: it flutters on its own tile
if (!e.blockedBy) assert.ok(h.runUntil(() => e.blockedBy === u, 20));
h.b.applyStatus(e, 'fear', { duration: 2 });
assert.equal(e.blockedBy, null);
const x0 = e.x, a1 = e.stats.attacks;
const x0 = e.x, a1 = e.stats.attacks, tc = Math.round(e.x);
h.run(1.9);
approx(e.x, x0);
assert.ok(Math.abs(e.x - tc) <= 0.5 && Math.round(e.x) === Math.round(x0), `stays on its tile (${x0} → ${e.x})`);
assert.equal(e.blockedBy, null);
assert.equal(e.stats.attacks, a1);
h.run(0.5);
assert.ok(e.blockedBy === u || e.x < x0, 'walks / is blocked again after the fear');
+1 -1
View File
@@ -134,7 +134,7 @@ test('stat aggregation: (base+flat)(1+pct)Πmul, res clamp, aspd clamp, interval
approx(u.hp, 1000, 1e-6);
u.buffs.push({ key: 'c', stacks: 1, mods: { aspd: -500 } });
u.markDirty();
assert.equal(u.s.aspd, 10);
assert.equal(u.s.aspd, 20, 'ASPD floor 20 (PRTS 数值范围)');
});
test('buffs: replace / extend / stack / independent / keep, expiry and onExpire', () => {
+33 -26
View File
@@ -2,7 +2,9 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, enemyRec, chessRec, checkInvariants } from '../helpers/battleHarness.js';
import { releaseSkillSummon } from '../../server/sim/content/tokens.js';
const approx = (a, b, msg = '', eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg} ${a} ≈ ${b}`);
const dummy = (o = {}) => enemyRec({ key: 'enemy_dummy', hp: 1e7, speed: 0, ...o });
test('SIM.md §10: 隐现 S2 fires 14 shots then ends', () => {
@@ -64,50 +66,55 @@ test('SIM.md example 2: 幽灵鲨 undying during the skill, self-stun afterwards
assert.equal(u.alive, false, 'undying ended with the skill');
});
test('SIM.md example 5: 赫默 summons a medical drone that expires after 10 s', () => {
const kit = () => ({
test('SIM.md example 5: 赫默 brings her placed medical drone onto its tile; it expires after 10 s', () => {
const kit = (bb) => ({
skill: {
kind: 'instant', trigger: 'SP_FULL',
onStart({ battle, unit }) {
for (const k of unit.rangeKeys) {
const r = Math.floor(k / 21), c = k % 21;
if (!battle.unitAt(r, c) && battle.grid.canStand(r, c, { ranged: true })) { battle.spawnToken(unit, 'token_10000_silent_healrb', r, c, { duration: 10 }); break; }
}
},
kind: 'instant', heal: true,
onStart({ battle, unit }) { releaseSkillSummon(battle, unit, 'token_10000_silent_healrb', { cap: bb.cnt }); },
},
});
const h = makeBattle({ kits: { chess_char_2_02_a: kit }, units: [{ chessId: 'chess_char_2_02_a', row: 10, col: 4 }] });
const h = makeBattle({
defs: { chess: { t_guard: chessRec({ id: 't_guard', profession: 'WARRIOR', skill: null, stats: { atk: 0, maxHp: 1e5 } }) } },
kits: { chess_char_2_02_a: kit },
units: [{ chessId: 'chess_char_2_02_a', row: 10, col: 4, uid: 1 }, { chessId: 't_guard', row: 10, col: 5, uid: 2 }, { kind: 'token', tokenId: 'token_10000_silent_healrb', ownerUid: 1, row: 11, col: 4, uid: 3 }],
});
const u = h.unit('chess_char_2_02_a');
const drone = h.unit(3);
h.step();
assert.equal(drone.alive, true, 'the placed piece deploys once with the board (SKILL_SUMMON_START_DEPLOY)');
h.run(10.1);
assert.equal(drone.alive, false, 'its 10 s');
h.unit('t_guard').hp = 1000;
h.runUntil(() => u.skill.activations === 1, 60);
const drone = h.b.allyUnits.find((x) => x.kind === 'token');
assert.ok(drone && drone.alive);
h.step();
assert.ok(drone.alive && drone.tileR === 11 && drone.tileC === 4);
assert.equal(drone.profile.dmgType, 'heal');
h.run(10.1);
assert.equal(drone.alive, false);
});
test('SIM.md example 6: 古米 TAKE_DAMAGE heal on the next attack', () => {
const kit = (bb) => ({
test('SIM.md example 6: 古米 备用军粮 — an AUTO heal skill cast by an injured ally of its range (no enemy), the next attack is the heal', () => {
const kit = (bb, chess, def) => ({
skill: {
kind: 'instant',
attack: { onHit({ battle, unit }) {
const ally = battle.alliesInRadius(unit.x, unit.y, 1.5, unit.ownerId).sort((a, b) => a.hpRatio - b.hpRatio)[0];
if (ally) battle.heal(unit, ally, unit.s.atk * bb.heal_scale);
} },
kind: 'instant', heal: true,
trigger: { rule: 'SKILL_RANGE', grid: def.skill.rangeGrid, allies: true },
targeting: { rangeGrid: def.skill.rangeGrid },
attack: { dmgType: 'heal', heal: { mode: 'single' }, healScale: bb.heal_scale },
},
});
const h = makeBattle({
defs: { chess: { t_ally: chessRec({ id: 't_ally', skill: null, stats: { maxHp: 10000, atk: 0 } }) }, enemies: { enemy_dummy: dummy({ atk: 50, bat: 1 }) } },
defs: { chess: { t_ally: chessRec({ id: 't_ally', skill: null, stats: { maxHp: 10000, atk: 0 } }) } },
kits: { chess_char_1_10_a: kit },
units: [{ chessId: 'chess_char_1_10_a', row: 9, col: 5 }, { chessId: 't_ally', row: 10, col: 5 }],
enemies: [{ key: 'enemy_dummy', pos: [9, 5], time: 8 }], autoFinish: false, // spawns on 古米's tile ⇒ blocked
autoFinish: false, // no enemy at all
});
const g = h.unit('chess_char_1_10_a');
h.run(7);
assert.equal(g.skill.rule, 'SKILL_RANGE');
assert.equal(g.skill.activations, 0, 'ready, nobody injured');
h.unit('t_ally').hp = 2000;
assert.equal(g.skill.rule, 'TAKE_DAMAGE');
assert.equal(g.skill.activations, 0, 'ready but not hit yet');
h.run(4);
assert.ok(g.skill.activations >= 1);
assert.ok(h.unit('t_ally').hp > 2000, 'healed by the empowered attack');
h.run(2);
assert.equal(g.skill.activations, 1);
approx(h.unit('t_ally').hp, 2000 + g.s.atk * g.def.skill.bb.heal_scale, 'healed by her heal-mode attack');
checkInvariants(h.b);
});
+5 -4
View File
@@ -107,15 +107,16 @@ test('massFlat: current massLevel (weight) for displacement and 浮空 halving;
h.step();
const e = h.spawn('enemy_heavy', { pos: [10, 5] });
assert.equal(e.s.massLevel, 4);
const d1 = h.b.displace(e, { x: 1, y: 0 }, 1, { force: 1 });
approx(d1, 0.3, 0.051, 'mass 4 vs force 1: 25 %');
// official 力度 − 重量 (PRTS 游戏数据基础 §重量公式): a 中力 push (1) on weight 4 = 受力等级 −3 ⇒ nothing
const d1 = h.b.push(e, 1, { from: { x: 4, y: 10 } });
assert.equal(d1, 0, 'weight 4 vs 中力: not moved');
h.b.addBuff(e, { key: 'weightless', mods: { massFlat: -1 }, duration: 10 });
assert.equal(e.s.massLevel, 3);
assert.equal(e.weight, 3);
assert.equal(e.base.massLevel, 4, 'base untouched');
e.x = 5; e.y = 10;
const d2 = h.b.displace(e, { x: 1, y: 0 }, 1, { force: 1 });
approx(d2, 0.5, 0.051, 'mass 3: 50 %');
const d2 = h.b.push(e, 1, { from: { x: 4, y: 10 } });
approx(d2, 0.12, 1e-6, '失重 weight 3 vs 中力: 受力等级 −2 ⇒ 0.12 tiles');
h.b.applyStatus(e, 'levitate', { duration: 4 });
approx(e.findBuff('levitate').timeLeft, 4, 1e-9, 'weight 3: full 浮空');
h.b.removeStatus(e, 'levitate');
+20 -11
View File
@@ -48,15 +48,19 @@ function runUntil(b, pred, sec = 10) {
test('two players in one field, same chess, different skills / modules: summons spawned mid-battle get each owner\'s variant', { skip }, () => {
const spec = uniteSpec(
[{ uid: 1, chessId: MLYSS, row: 10, col: 4, dir: 'RIGHT', skillIndex: 0, moduleId: 'uniequip_003_mlyss' }, { uid: 2, chessId: CATHY, row: 11, col: 4, dir: 'RIGHT', skillIndex: 0 }],
[{ uid: 1, chessId: MLYSS, row: 10, col: 4, dir: 'RIGHT', skillIndex: 0, moduleId: 'uniequip_003_mlyss' }, { uid: 2, chessId: CATHY, row: 11, col: 4, dir: 'RIGHT', skillIndex: 0 },
{ uid: 3, kind: 'token', tokenId: CATSLD, ownerUid: 2, row: 11, col: 5, dir: 'LEFT' }],
// p2: MLYSS with another explicit choice, CATHY without loadout fields (= the default, never p1's choice)
[{ uid: 1, chessId: MLYSS, row: 10, col: 4, dir: 'RIGHT', skillIndex: 1, moduleId: 'none' }, { uid: 2, chessId: CATHY, row: 11, col: 4, dir: 'RIGHT' }],
[{ uid: 1, chessId: MLYSS, row: 10, col: 4, dir: 'RIGHT', skillIndex: 1, moduleId: 'none' }, { uid: 2, chessId: CATHY, row: 11, col: 4, dir: 'RIGHT' },
{ uid: 3, kind: 'token', tokenId: CATSLD, ownerUid: 2, row: 11, col: 5, dir: 'LEFT' }],
);
const b = createBattleFromSpec(spec, freshDs(), { recordEvents: false, quiet: true });
assert.deepEqual([...b.data.loadoutConflicts].sort(), [CATHY, MLYSS].sort(), 'id-only lookups would give p2 p1\'s choices');
const m1 = opOf(b, 'p1', 1), m2 = opOf(b, 'p2', 1), c1 = opOf(b, 'p1', 2), c2 = opOf(b, 'p2', 2);
// no board pieces: the 流形 (援军) and 凯瑟琳's devices are summoned during the battle
assert.ok(runUntil(b, () => [m1, m2, c1, c2].every((o) => summonsOf(b, o, o === m1 || o === m2 ? WTRMAN : CATSLD).length > 0), 5), 'every owner summoned');
// no 流形 piece: the 援军 is summoned during the battle; 凯瑟琳's devices are board pieces (user playtest #6) whose
// def follows the owner's loadout too
const piecesOf = (owner) => b.allyUnits.filter((u) => u.kind === 'token' && u.uid != null && u.ownerUnit === owner && u.defId === CATSLD);
assert.ok(runUntil(b, () => [m1, m2].every((o) => summonsOf(b, o, WTRMAN).length > 0) && [c1, c2].every((o) => piecesOf(o).some((t) => t.alive)), 5), 'every owner summoned');
const d = freshDs();
const w1 = summonsOf(b, m1, WTRMAN)[0], w2 = summonsOf(b, m2, WTRMAN)[0];
assert.equal(w1.def.skill.id, 'sktok_mlyss_wtrman_1', 'p1 流形: its owner\'s S1');
@@ -65,8 +69,9 @@ test('two players in one field, same chess, different skills / modules: summons
assert.ok(!w2.def.talents.some((t) => t.bb.taunt_level === 1), 'p2 流形 (no module): no module talent');
assert.deepEqual(w2.def.talents, d.getToken(WTRMAN, MLYSS, { skillIndex: 1, moduleId: 'none' }).talents);
assert.equal(w2.base.maxHp, d.getToken(WTRMAN, MLYSS, { skillIndex: 1, moduleId: 'none' }).stats.maxHp);
for (const t of summonsOf(b, c1, CATSLD)) assert.equal(t.def.skill.id, 'sktok_cathy_catsld_1', 'p1 devices: S1 token skill');
for (const t of summonsOf(b, c2, CATSLD)) assert.equal(t.def.skill.id, 'sktok_cathy_catsld_2', 'p2 devices (default loadout): S2 token skill');
assert.ok(piecesOf(c1).length === 1 && piecesOf(c2).length === 1);
for (const t of piecesOf(c1)) assert.equal(t.def.skill.id, 'sktok_cathy_catsld_1', 'p1 devices: S1 token skill');
for (const t of piecesOf(c2)) assert.equal(t.def.skill.id, 'sktok_cathy_catsld_2', 'p2 devices (default loadout): S2 token skill');
// Battle.tokenDef / spawnToken (content summons) resolve the owner's loadout as well
assert.equal(b.tokenDef(WTRMAN, m1).skill.id, 'sktok_mlyss_wtrman_1');
assert.equal(b.tokenDef(WTRMAN, m2).skill.id, 'sktok_mlyss_wtrman_2');
@@ -231,17 +236,21 @@ test('tokens.js variantOf: the deploy limit of a summon follows the owner\'s mod
});
test('tokens.js tokenSources: the generic-kit skill-summon fallback reads the owner loadout (赫默 S1 makes no drone)', { skip }, () => {
const run = (lo) => {
const h = makeBattle({ players: [mk('p1', 0, 0, [{ uid: 1, chessId: HEMO, row: 10, col: 5, ...lo }])], data: freshDs(), kits: { [HEMO]: () => null } });
// the drone is a placed piece (user playtest #6): S2 deploys it once with the board (SKILL_SUMMON_START_DEPLOY) and
// brings it back onto its tile with each cast; S1 never makes one (its piece never deploys)
const run = (lo, atStart) => {
const units = [{ uid: 1, chessId: HEMO, row: 10, col: 5, ...lo }, { uid: 2, kind: 'token', tokenId: DRONE, ownerUid: 1, row: 10, col: 6 }];
const h = makeBattle({ players: [mk('p1', 0, 0, units)], data: freshDs(), kits: { [HEMO]: () => null } });
h.step();
const u = h.b.allyUnits.find((x) => x.uid === 1);
assert.equal(u.kit.generic, true);
assert.equal(h.b.allyUnits.find((x) => x.uid === 2).alive, atStart, 'with the board: S2 yes, S1 no');
h.b.producesToken = () => true; // only the fallback's own check decides here
assert.ok(u.skill.activate('test', { free: true }));
return summonsOf(h.b, u, DRONE).length;
return h.b.allyUnits.filter((t) => t.kind === 'token' && t.defId === DRONE && t.ownerUnit === u && t.alive).length;
};
assert.equal(run({}), 1, 'S2 (default): the drone');
assert.equal(run({ skillIndex: 0 }), 0, 'S1: none');
assert.equal(run({}, true), 1, 'S2 (default): the drone');
assert.equal(run({ skillIndex: 0 }, false), 0, 'S1: none');
});
test('dollkeeper: the substitute token is looked up with the unit\'s own loadout', { skip }, () => {
+16 -11
View File
@@ -84,9 +84,12 @@ test('getChess(id, loadout): selected skill (bb, SP, trigger) and module (stats,
assert.deepEqual(s1.stats, def0.stats, 'a skill choice never changes stats');
assert.ok(Object.isFrozen(s1) && Object.isFrozen(s1.skill.bb), 'variant defs are frozen like every def');
assert.equal(C[INSIDE].skill.skillId, 'skchr_inside_2', 'the raw record is never mutated');
// per-index trigger: 角峰 S1 casts when hit (TANK row, skill 1 only); S2 is DEFAULT
// per-skill trigger (PRTS 技能策略): 角峰's MANUAL skills both cast when hit (the 重装 row covers every skill); 古米's
// AUTO S1 keeps its own rule (DEFAULT in data, its kit: an injured ally of the skill range), his MANUAL S2 TAKE_DAMAGE
assert.equal(d.getChess('chess_char_1_02_a', { skillIndex: 0 }).skill.trigger.rule, 'TAKE_DAMAGE');
assert.equal(d.getChess('chess_char_1_02_a', { skillIndex: 1 }).skill.trigger.rule, 'DEFAULT');
assert.equal(d.getChess('chess_char_1_02_a', { skillIndex: 1 }).skill.trigger.rule, 'TAKE_DAMAGE');
assert.equal(d.getChess('chess_char_1_10_a', { skillIndex: 0 }).skill.trigger.rule, 'DEFAULT');
assert.equal(d.getChess('chess_char_1_10_a', { skillIndex: 1 }).skill.trigger.rule, 'TAKE_DAMAGE');
// modules on the elite: statsBase + module attr; trait / talents of that module; 'none' = the base
const g = C[MLYSS];
@@ -358,22 +361,24 @@ test('getToken: def.sources / def.count follow the owner loadout (which summons
});
test('a Battle built WITHOUT the spec path (server-run fields) resolves mid-battle summons with the owner loadout', { skip }, () => {
const CATHY = 'chess_char_4_11_a', SHIELD = 'token_10041_cathy_catsld';
// 缪尔赛思 without a 流形 piece: her 援军 is summoned during the battle (凯瑟琳's devices, the old example, are placed
// pieces since user playtest #6)
const MLYSS = 'chess_char_6_11_a', WTRMAN = 'token_10030_mlyss_wtrman';
const run = (skillIndex) => {
const players = [{ playerId: 'p1', seat: 0, side: 'L', colOffset: 0, bonds: {}, playerEffects: [],
units: [{ uid: 1, kind: 'chess', chessId: CATHY, row: 10, col: 5, dir: 'RIGHT', ...(skillIndex != null ? { skillIndex } : {}) }] }];
units: [{ uid: 1, kind: 'chess', chessId: MLYSS, row: 10, col: 5, dir: 'RIGHT', ...(skillIndex != null ? { skillIndex } : {}) }] }];
const b = new Battle({ seed: 3, kind: 'normal', stageId: 'act2autochess_m01', timeLimit: 30, players, spawns: [], routes: [], data: freshDs(), recordEvents: false, quiet: true });
for (let i = 0; i < 60 && !b.allyUnits.some((u) => u.defId === SHIELD); i++) b.step();
const t = b.allyUnits.find((u) => u.defId === SHIELD);
assert.ok(t && t.uid == null, 'a device spawned during the battle');
for (let i = 0; i < 60 && !b.allyUnits.some((u) => u.defId === WTRMAN); i++) b.step();
const t = b.allyUnits.find((u) => u.defId === WTRMAN);
assert.ok(t && t.uid == null, 'a 流形 spawned during the battle');
return [b, t];
};
const [b1, t1] = run(0);
assert.ok(isLoadoutView(b1.data), 'installContent gave the battle the loadout view');
assert.equal(b1.allyUnits.find((u) => u.uid === 1).skill.id, C[CATHY].skills[0].skillId);
assert.equal(t1.def.skill.id, 'sktok_cathy_catsld_1', 'the summon follows the owner\'s S1');
const [, t2] = run(1);
assert.equal(t2.def.skill.id, 'sktok_cathy_catsld_2', 'default owner ⇒ default token skill');
assert.equal(b1.allyUnits.find((u) => u.uid === 1).skill.id, C[MLYSS].skills[0].skillId);
assert.equal(t1.def.skill.id, 'sktok_mlyss_wtrman_1', 'the summon follows the owner\'s S1');
const [, t2] = run(null);
assert.equal(t2.def.skill.id, 'sktok_mlyss_wtrman_3', 'default owner (S3) ⇒ default token skill');
// the spec path is not wrapped twice
const spec = normalSpec([{ uid: 1, chessId: INSIDE, row: 10, col: 5, dir: 'RIGHT', skillIndex: 0 }]);
const b3 = createBattleFromSpec(spec, freshDs(), { recordEvents: false, quiet: true });
+4 -3
View File
@@ -201,7 +201,6 @@ function floaterDuel(id, skillIndex, { col = 5, secs = 30 } = {}) {
test('#1 ground-only skills and talents spare a hovering enemy (the worst 731a01c offenders + the PRTS "不可对空" notes)', () => {
const cases = [
['chess_char_6_19_a', 1, 5, '锏 S2 无声的嘲笑'],
['chess_char_6_19_a', 2, 5, '锏 S3 归于宁静'],
['chess_char_6_07_a', 0, 5, '维娜·维多利亚 S1 重铸晖光'],
['chess_char_5_06_a', 2, 5, '隐德来希 S3 灵与欲的惜别'],
['chess_char_6_12_a', 2, 4, '迷迭香 S3 “如你所愿”'],
@@ -217,8 +216,10 @@ test('#1 ground-only skills and talents spare a hovering enemy (the worst 731a01
}
});
test('#1 skills PRTS marks "可对空" still hit a hovering enemy (德克萨斯 S2 剑雨, 焰尾 S2 “红松林”)', () => {
for (const [id, idx, tag, name] of [['chess_char_1_08_a', 1, 'skill', '德克萨斯 S2'], ['chess_char_4_19_a', 1, 'redPine', '焰尾 S2']]) {
test('#1 skills PRTS marks "可对空" still hit a hovering enemy (德克萨斯 S2 剑雨, 焰尾 S2 “红松林”, 锏 S3 归于宁静)', () => {
// 锏 S3: PRTS 备注 "※可对空。不会拖拽自身中心半径0.6708范围内的敌人" (user playtest #6, WF audit — it used to sit in the
// ground-only list above)
for (const [id, idx, tag, name] of [['chess_char_1_08_a', 1, 'skill', '德克萨斯 S2'], ['chess_char_4_19_a', 1, 'redPine', '焰尾 S2'], ['chess_char_6_19_a', 2, 'slash', '锏 S3']]) {
const r = floaterDuel(id, idx);
assert.ok((r.floaterTags.get(tag) || 0) > 0, `${name}: ${JSON.stringify([...r.floaterTags])}`);
}
+23 -19
View File
@@ -168,11 +168,12 @@ test('#4: a blocker attacks the enemy it blocks even when it stands outside its
assert.ok(h.hooksOf('damaged').some((c) => c.source === u && c.target === x()), 'it hits its blocked enemy');
});
test('#4: a ranged operator on a melee tile blocks, but never attacks or casts for the enemy held behind it (阻挡(近战限定))', () => {
// PRTS 索敌的概念: "我方索敌优先级:阻挡(近战限定)"; "远程位干员通常而言不会优先攻击自己阻挡的目标;甚至如果这个目标被阻挡在
// 该干员的身后,该干员可能因为敌人未处于自身攻击范围而无法攻击到这个敌人". Facing LEFT, its range (9,6),(9,5) never holds
// the enemy blocked at x ≈ 6.71; a melee operator in the same spot hits it and casts its DEFAULT skill (帮助: "敌人被近战
// 干员自身阻挡" satisfies the target condition).
test('#4 (revised by the user after playtest #6): a ranged operator on a melee tile also hits and casts for the enemy it holds behind it', () => {
// The playtest #5 QA had made blocked-first melee-only (PRTS 索敌的概念 "阻挡(近战限定)"); the user's rule after
// playtest #6 — "阻挡了就一定要能打到": officially the collision pushes a blocked enemy to its blocker's front, so
// whatever blocks an enemy can hit it. Facing LEFT, the range (9,6),(9,5) never holds the enemy blocked at x ≈ 6.71;
// the ranged blocker hits it and casts its DEFAULT skill like a melee one (帮助: the blocked enemy satisfies the
// target condition).
const run = (id, profession) => {
const h = makeBattle({
defs: { chess: { [id]: chessRec({ id, profession, stats: { atk: 100, blockCnt: 1, maxHp: 1e6 }, skill: { spCost: 1, initSp: 1, duration: 5 } }) }, enemies: { enemy_x: foe('enemy_x') } },
@@ -187,13 +188,12 @@ test('#4: a ranged operator on a melee tile blocks, but never attacks or casts f
checkInvariants(h.b);
return { hits: h.hooksOf('damaged').filter((c) => c.source === u && c.target === x()).length, casts: u.skill.activations, still: x().blockedBy === u };
};
const ranged = run('t_rg', 'SNIPER');
assert.equal(ranged.hits, 0, 'the ranged blocker never hits the enemy behind it');
assert.equal(ranged.casts, 0, 'nor casts its skill for it');
assert.ok(ranged.still, 'it still holds the enemy');
const melee = run('t_ml', 'WARRIOR');
assert.ok(melee.hits > 0, 'a melee blocker hits it');
assert.equal(melee.casts, 1, 'and casts its skill');
for (const [id, profession] of [['t_rg', 'SNIPER'], ['t_ml', 'WARRIOR']]) {
const r = run(id, profession);
assert.ok(r.hits > 0, `${profession}: hits the enemy it blocks behind it`);
assert.equal(r.casts, 1, `${profession}: and casts its skill`);
assert.ok(r.still, `${profession}: it still holds the enemy`);
}
});
test('#4: "melee" is the deploy position — a 领主 with a ranged attack (银灰) still hits the enemy it holds behind it', () => {
@@ -213,10 +213,10 @@ test('#4: "melee" is the deploy position — a 领主 with a ranged attack (银
assert.ok(h.hooksOf('damaged').some((c) => c.source === u && c.target === x()), 'it hits the enemy it blocks');
});
test('#4: blocked-first is a melee priority — a ranged blocker shoots the enemy nearest the goal', () => {
test('#4 (revised after playtest #6): blocked-first holds for every blocker — a ranged blocker keeps shooting the enemy it blocks', () => {
// range (9,5)–(9,7) facing RIGHT: the walker it blocks stands on (9,7); a flyer passes over the operator towards the
// goal. The ranged operator picks by its own order (least remaining distance first), a melee one (able to hit air)
// keeps hitting the enemy it blocks.
// goal. Melee or ranged, the blocker keeps hitting the enemy it blocks (user's rule: the blocked enemy stands in front
// of its blocker officially) — the ranged one no longer switches to the flyer nearer the goal.
const run = (id, o) => {
const h = makeBattle({
defs: {
@@ -232,10 +232,13 @@ test('#4: blocked-first is a melee priority — a ranged blocker shoots the enem
assert.ok(h.runUntil(() => walker()?.blockedBy === u, 30), 'the walker is blocked');
assert.ok(h.runUntil(() => flyer() && flyer().x < 4.4, 30), 'the flyer passed');
assert.equal(walker().blockedBy, u);
return h.hooksOf('damaged').filter((c) => c.source === u && c.target === flyer()).length;
return { fl: h.hooksOf('damaged').filter((c) => c.source === u && c.target === flyer()).length, wk: h.hooksOf('damaged').filter((c) => c.source === u && c.target === walker()).length };
};
assert.ok(run('t_rg2', { profession: 'SNIPER' }) > 0, 'the ranged blocker shot the flyer while it was nearer the goal');
assert.equal(run('t_ml2', { profession: 'WARRIOR', attackKind: 'melee', canHitFly: true }), 0, 'the melee blocker kept to its blocked enemy');
for (const [id, o] of [['t_rg2', { profession: 'SNIPER' }], ['t_ml2', { profession: 'WARRIOR', attackKind: 'melee', canHitFly: true }]]) {
const r = run(id, o);
assert.equal(r.fl, 0, `${o.profession}: kept to its blocked enemy`);
assert.ok(r.wk > 0, `${o.profession}: hit the walker it blocks`);
}
});
test('#4: a skywalker (起飞: blockFly) blocks flyers at the air block radius 0.8944', () => {
@@ -257,7 +260,8 @@ test('#4: 瑕光 S2 puts the enemy she blocks to sleep although it stands outsid
const id = 'chess_char_3_12_a';
const skillIndex = ds.rawChess(id).skills.find((s) => s.skillId === 'skchr_blemsh_2').index;
const h = makeBattle({
defs: { enemies: { enemy_x: foe('enemy_x') } }, timeLimit: 60,
// (a MANUAL 重装 skill: cast by the blocked enemy's hit — TAKE_DAMAGE)
defs: { enemies: { enemy_x: foe('enemy_x', { atk: 100, bat: 1 }) } }, timeLimit: 60,
units: [{ chessId: id, row: 9, col: 6, skillIndex }], enemies: [{ key: 'enemy_x', time: 0 }],
});
const u = h.unit(id);
+606
View File
@@ -0,0 +1,606 @@
// User playtest #6, workstream WF (combat rules) — reports #17, #14, #18.
//
// #17 "两个干员之间卡了敌人,靠后的干员死亡之后敌人确实会被靠前的干员阻挡了,但是只有敌人攻击干员,靠前的干员不会攻击那个敌人":
// PRTS 选择器 "可以选择且优先选择阻挡单位" — and the user's rule after playtest #6, "阻挡了就一定要能打到": officially the
// collision pushes a blocked enemy to its blocker's front, so EVERY blocker — a ranged operator on a melee tile included
// (the playtest #5 QA had made that melee-only, PRTS 索敌的概念 "阻挡(近战限定)") — attacks the enemies it blocks, in
// range or not, whatever its facing, and takes them first. The real sim does for every blocking chess of the pool in
// a plain block and in the user's layout, whether the take-over happens at once or once the enemy slid behind the
// front operator's tile (the regressions below); only healers and 停止攻击 skills never hit it.
// Fixed on the way: 流形's melee copy (data position ALL) ignored the enemy it held outside its range; heavy slows
// (沼泽 −50 + 寒霜 −50) took ASPD down to 10 (interval 10 × BAT) — officially ASPD never drops below 20 (PRTS 数值范围
// ATTACK_SPEED 默认下限 20; 游戏数据基础 "攻击速度属性实际被限制了下限为20"), so it attacks every 5 × BAT.
// #14 "敌人的重量以及可以推动敌人的干员技能的力度…感觉野鬃干员开技能能把推动好多敌人": official 位移 = 力度 − 重量 (PRTS 游戏数据基础
// §重量公式, 推与拉): a 中力 push moves weight 0 / 1 / 2 / 3 enemies 2.14 / 1.7 / 0.44 / 0.12 tiles and heavier ones not at
// all; the remake pushed 1 tile scaled by 25 % per missing level, so weight 3–5 enemies still flew 0.75–0.25 tiles.
// #18 "炎祐错误的吃到了医疗干员造成的治疗": PRTS “炎佑” 天赋 "{{特殊机制|我方单位}},{{异常效果|孤立}}" — 孤立 = "无法被同阵营选中"
// (PRTS 异常效果 ALLY_TARGET_FREE); summons holding 禁疗 (HEAL_FREE) are never healed either.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { getDefaultSource, hasGeneratedData } from '../../server/sim/simdata.js';
import { effectiveProfile } from '../../server/sim/ai.js';
import { PUSH_TILES, PUSH_TILES_EFFECT, PULL_STOP_RADIUS, ASPD_MIN, COLS } from '../../server/sim/constants.js';
import { alliesInGridOf } from '../../server/sim/content/kits/tier1.js';
import { Unit } from '../../server/sim/units.js';
import { spawnYanyou, TOKEN_IDS } from '../../server/sim/content/tokens.js';
const REAL = { skip: !hasGeneratedData() };
const approx = (a, b, eps, msg) => assert.ok(Math.abs(a - b) <= eps, `${msg ?? ''} ${a} ≈ ${b}`);
// =====================================================================================================================
// #17 — the enemy held between two operators
const REAR = 'test_rear_a';
const foe = (key, o = {}) => enemyRec({ key, hp: 1e8, speed: 1, atk: 0, ...o });
/**
* The user's layout on act2 m01: the lane goes up column 7 from the lower gate row; the front operator F stands on
* (10,7), the rear one R on (11,7). F's block is filled by decoys so the next enemy E walks through it and is held by R —
* between the two. The decoys die, then R dies: F takes E over. Returns { h, F, E, hits, couldAttack } over `secs` s.
*/
function heldBetween(chessId, dir, secs = 8, { late = false } = {}) {
const ds = getDefaultSource();
const cap = ds.rawChess(chessId).stats.blockCnt;
const enemies = { enemy_e: foe('enemy_e') };
const spawns = [];
for (let i = 0; i < cap; i++) { enemies[`enemy_x${i}`] = foe(`enemy_x${i}`); spawns.push({ key: `enemy_x${i}`, time: i * 0.6 }); }
spawns.push({ key: 'enemy_e', time: cap * 0.6 + 0.3 });
const h = makeBattle({
stageId: 'act2autochess_m01', timeLimit: 200, hooks: ['damaged'], captureNoisy: true,
defs: { chess: { [REAR]: chessRec({ id: REAR, stats: { atk: 0, blockCnt: 1, maxHp: 1e9, respawnTime: 999 }, skill: null }) }, enemies },
units: [{ chessId, row: 10, col: 7, dir }, { chessId: REAR, row: 11, col: 7, dir }],
enemies: spawns,
});
const F = h.unit(chessId), R = h.unit(REAR);
const decoys = () => h.b.enemies.filter((e) => e.defId.startsWith('enemy_x'));
const E = () => h.b.enemies.find((e) => e.defId === 'enemy_e');
const held = h.runUntil(() => decoys().length === cap && decoys().every((x) => x.blockedBy === F) && E()?.blockedBy === R, 40);
if (!held) return null; // (a summoner's own summon took a decoy: not this layout)
const e = E();
if (!late) {
for (const x of decoys()) h.b.loseHp(x, 1e12);
h.step(2);
h.b.loseHp(R, 1e12);
} else {
// the rear operator dies while the front one is still full: the enemy walks on past it, and the front one frees a
// slot once the enemy is beyond its own tile but still inside the contact radius (0.52–0.69 tile from its centre)
h.b.loseHp(R, 1e12);
let ok = false;
for (let i = 0; i < 120 && e.alive; i++) {
h.step();
const d = Math.hypot(e.x - F.x, e.y - F.y);
if (d > 0.52 && d < 0.69) { ok = true; break; }
}
if (!ok) return null;
for (const x of decoys()) h.b.loseHp(x, 1e12);
}
h.step(2);
const t0 = h.b.time;
let couldAttack = 0, inRange = false;
const end = t0 + secs;
while (h.b.time < end && e.alive) {
h.step();
const p = effectiveProfile(F);
if (F.canAct && !p.noAttack && !(p.noAttackUnlessSkill && !F.skill?.active) && !F.s.flags.disarm && p.dmgType !== 'heal' && e.blockedBy === F) couldAttack += 1 / 30;
if (F.rangeKeySet?.has(Math.round(e.y) * COLS + Math.round(e.x))) inRange = true;
}
const hits = h.hooksOf('damaged').filter((c) => c.source === F && c.target === e && c.t > t0).length;
return { h, F, E: e, hits, couldAttack, inRange };
}
test('#17 the user\'s case on act2 m01: the front operator takes over the enemy held between the two AND attacks it — every blocking chess, 4 directions', REAL, () => {
// the held enemy stands ≈ 0.29 tile behind the front operator's centre, on its own tile: a melee unit may always hit
// it (blocked-first), a ranged one on a melee tile has it in range too (its own tile) and hits it by its own order
const ds = getDefaultSource();
const blockers = Object.values(ds.raw.chess).filter((c) => c.stats && c.visible !== false && !c.isGolden && c.stats.blockCnt > 0);
assert.ok(blockers.filter((c) => c.position === 'MELEE').length >= 50, 'every melee blocker of the pool');
const branches = new Set();
let checked = 0;
for (const c of blockers) {
for (const dir of ['UP', 'RIGHT', 'DOWN', 'LEFT']) {
const r = heldBetween(c.chessId, dir);
if (!r) continue;
checked++;
branches.add(c.subProfessionId);
if (r.E.alive && r.E.blockedBy !== r.F) {
// only a status that cannot be blocked (妮芙's 恐惧, 浮空 …) or a displacement out of contact lets it go
const f = r.E.s.flags;
assert.ok(f.fear || f.unblockable || f.levitate || f.attract || Math.hypot(r.E.x - r.F.x, r.E.y - r.F.y) > 0.7071, `${c.name} ${dir}: takes the enemy over`);
continue;
}
// healers, and skills that say 停止攻击 (泡泡 “挨打”, 凯瑟琳 战火淬炼 …) / attack only while active (阵法术师), are the
// only reasons not to hit it
if (r.couldAttack >= 2) assert.ok(r.hits > 0, `${c.name} (${c.subProfessionId}, ${c.position}) ${dir}: attacks the enemy it took over (could attack ${r.couldAttack.toFixed(1)} s)`);
}
}
assert.ok(checked >= 350, `layouts checked: ${checked}`);
assert.ok(branches.size >= 45, `branches: ${branches.size}`);
});
test('#17 a late take-over (the enemy slid behind the front operator\'s tile): every blocker hits it, ranged ones on melee tiles included (the user\'s rule)', REAL, () => {
// the rear operator dies while the front one is full; the front one frees a slot when the enemy is 0.52–0.69 tile past
// its centre (outside its tile, inside the 0.7071 contact radius) and takes it over. Every blocking chess of the pool,
// the hidden ones too (见行者, 崖心, 百炼嘉维尔), × 4 directions
const ds = getDefaultSource();
const blockers = Object.values(ds.raw.chess).filter((c) => c.stats && !c.isGolden && c.stats.blockCnt > 0 && (c.visible !== false || c.isHidden));
assert.ok(blockers.some((c) => c.isHidden && c.position === 'MELEE'), 'hidden melee chess included');
let melee = 0, ranged = 0, rangedOut = 0;
const branches = new Set();
for (const c of blockers) {
for (const dir of ['UP', 'RIGHT', 'DOWN', 'LEFT']) {
const r = heldBetween(c.chessId, dir, 8, { late: true });
if (!r || r.couldAttack < 2) continue; // (no take-over, or a 停止攻击 skill / healer: never attacks)
branches.add(c.subProfessionId);
if (c.position === 'MELEE') melee++; else { ranged++; if (!r.inRange) rangedOut++; }
assert.ok(r.hits > 0, `${c.name} (${c.subProfessionId}, ${c.position}) ${dir}: hits the enemy it took over behind its tile${r.inRange ? '' : ', out of its range'} (could attack ${r.couldAttack.toFixed(1)} s)`);
}
}
assert.ok(melee >= 150, `melee take-overs checked: ${melee}`);
assert.ok(ranged >= 10 && rangedOut >= 10, `ranged take-overs checked: ${ranged} (${rangedOut} out of range)`);
assert.ok(branches.size >= 40, `branches: ${branches.size}`);
});
/**
* A plain block on act2 m01: the blocker alone on (10,7) facing `dir`, one walker coming up the lane. Returns
* { F, E, hits, couldAttack, inRange } over `secs` s once it blocks, or null (a summon of its own took the walker).
*/
function plainBlock(chessId, dir, secs = 8) {
const h = makeBattle({
stageId: 'act2autochess_m01', timeLimit: 200, hooks: ['damaged'], captureNoisy: true,
defs: { enemies: { enemy_e: foe('enemy_e') } },
units: [{ chessId, row: 10, col: 7, dir }],
enemies: [{ key: 'enemy_e', time: 0.3 }],
});
const F = h.unit(chessId);
const E = () => h.b.enemies.find((e) => e.defId === 'enemy_e');
if (!h.runUntil(() => E()?.blockedBy === F, 40)) return null;
const e = E();
const t0 = h.b.time;
let couldAttack = 0, inRange = true;
while (h.b.time < t0 + secs && e.alive) {
h.step();
const p = effectiveProfile(F);
if (F.canAct && !p.noAttack && !(p.noAttackUnlessSkill && !F.skill?.active) && !F.s.flags.disarm && p.dmgType !== 'heal' && e.blockedBy === F) couldAttack += 1 / 30;
if (!F.rangeKeySet?.has(Math.round(e.y) * COLS + Math.round(e.x))) inRange = false;
}
const hits = h.hooksOf('damaged').filter((c) => c.source === F && c.target === e && c.t > t0).length;
return { F, E: e, hits, couldAttack, inRange };
}
test('#17 a plain block, every blocking chess × 4 facings: the blocker damages the enemy it blocks — in range or not (the user\'s rule)', REAL, () => {
// "阻挡了就一定要能打到" (user, after playtest #6): melee and ranged blockers alike, whichever way they face; healers and
// skills that stop attacking (泡泡 “挨打”, 凯瑟琳 战火淬炼 … — couldAttack) are the only exceptions
const ds = getDefaultSource();
const blockers = Object.values(ds.raw.chess).filter((c) => c.stats && !c.isGolden && c.stats.blockCnt > 0 && (c.visible !== false || c.isHidden));
let checked = 0, out = 0, ranged = 0;
for (const c of blockers) {
for (const dir of ['UP', 'RIGHT', 'DOWN', 'LEFT']) {
const r = plainBlock(c.chessId, dir);
if (!r || r.couldAttack < 2) continue;
checked++;
if (!r.inRange) out++;
if (c.position !== 'MELEE') ranged++;
assert.ok(r.hits > 0, `${c.name} (${c.subProfessionId}, ${c.position}) ${dir}: hits the enemy it blocks${r.inRange ? '' : ' (out of its range)'} — could attack ${r.couldAttack.toFixed(1)} s`);
}
}
assert.ok(checked >= 350, `blocks checked: ${checked}`);
assert.ok(out >= 50, `blocked enemies outside the blocker's range at some point: ${out}`);
assert.ok(ranged >= 20, `ranged blockers: ${ranged}`);
});
test('#17 skills that pick their own targets keep the enemy the operator blocks (QA residual): 迷迭香 S3, 荒芜拉普兰德 S2, 佩佩 S2', REAL, () => {
// the flat stage: the blocker on (9,6) faces LEFT, so the walker it stops coming from the gate (its right) stays outside
// its range while a still enemy on (9,4) stands inside it. These skills chose from the range only and never hit the
// blocked one again; 普罗旺斯 S2 keeps its official text ("普通攻击不再以生命值高于80%的敌人作为目标") — the exception
const ds = getDefaultSource();
const cases = [['迷迭香', 'skchr_rosmon_3', true], ['荒芜拉普兰德', 'skchr_whitw2_2', true], ['佩佩', 'skchr_pepe_2', true], ['普罗旺斯', 'skchr_prove_2', false]];
for (const [name, skillId, hitsIt] of cases) {
for (const gold of [false, true]) {
const c = Object.values(ds.raw.chess).find((x) => x.name === name && !!x.isGolden === gold);
const skillIndex = c.skills.findIndex((s) => s.skillId === skillId);
assert.ok(skillIndex >= 0, `${name} ${skillId}`);
const h = makeBattle({
timeLimit: 400, hooks: ['damaged'], captureNoisy: true,
defs: { enemies: { enemy_e: foe('enemy_e'), enemy_o: foe('enemy_o', { speed: 0 }) } },
units: [{ chessId: c.chessId, row: 9, col: 6, dir: 'LEFT', skillIndex }],
enemies: [{ key: 'enemy_e', time: 0.2 }, { key: 'enemy_o', time: 0.1, route: { motion: 'WALK', start: [9, 4], end: [9, 2], checkpoints: [] } }],
});
const F = h.unit(c.chessId);
const E = () => h.b.enemies.find((x) => x.defId === 'enemy_e');
assert.ok(h.runUntil(() => E()?.blockedBy === F, 40), `${name}: blocks the walker`);
const e = E();
h.step(30);
if (!F.skill.active) F.skill.activate('manual', { free: true });
assert.ok(F.skill.active && F.skill.id === skillId, `${name}: ${skillId} on`);
const t0 = h.b.time;
let outside = true;
while (h.b.time < t0 + 6) {
h.step();
if (F.rangeKeySet?.has(Math.round(e.y) * COLS + Math.round(e.x))) outside = false;
}
assert.ok(outside, `${name}: the blocked walker stays outside the range`);
const hits = h.hooksOf('damaged').filter((x) => x.t > t0 && x.source === F && x.target === e).length;
if (hitsIt) assert.ok(hits > 0, `${name}${gold ? ' (golden)' : ''} ${skillId}: hits the enemy it blocks (${hits})`);
else assert.equal(hits, 0, `${name} ${skillId}: official — no normal attack on an enemy above 80 % HP, blocked or not`);
checkInvariants(h.b);
}
}
});
test('#17 流形\'s melee copy (a summon of no fixed position) attacks the enemy it blocks outside its range', REAL, () => {
// 缪尔赛思's 流形 copies the melee guard; facing LEFT its copied range (its tile + the one to its left) never holds the
// enemy it stops coming from the gate side (≈ 0.71 tile to its right)
const guard = chessRec({ id: 'test_guard_a', profession: 'WARRIOR', position: 'MELEE', stats: { atk: 500, blockCnt: 2, maxHp: 1e6, def: 0 }, skill: null });
const h = makeBattle({
defs: { chess: { test_guard_a: guard }, enemies: { enemy_w: foe('enemy_w') } }, timeLimit: 120, hooks: ['damaged'], captureNoisy: true,
units: [
{ chessId: 'chess_char_6_11_a', row: 12, col: 3 },
{ chessId: 'test_guard_a', row: 12, col: 6 },
{ kind: 'token', tokenId: 'token_10030_mlyss_wtrman', row: 9, col: 6, ownerUid: 1, dir: 'LEFT' },
],
enemies: [{ key: 'enemy_w', time: 9 }],
});
const tok = () => h.b.allyUnits.find((x) => x.defId === 'token_10030_mlyss_wtrman' && x.alive);
assert.ok(h.runUntil(() => tok()?.mem.mlyss && !tok().mem.mlyss.ranged, 9), 'a melee copy');
const e = () => h.b.enemies.find((x) => x.defId === 'enemy_w');
assert.ok(h.runUntil(() => e()?.blockedBy === tok(), 30), 'the copy blocks the walker');
const t = tok();
assert.ok(!t.rangeKeySet.has(Math.round(e().y) * 21 + Math.round(e().x)), 'outside its range (behind its facing)');
const t0 = h.b.time;
h.run(4);
assert.ok(h.hooksOf('damaged').some((c) => c.source === t && c.target === e() && c.t > t0), 'it hits the enemy it blocks');
checkInvariants(h.b);
});
test('#17 heavy slows: ASPD never drops below 20 (PRTS 数值范围) — two −50 slows on a BAT 1.2 unit give a 6 s interval, not 12 s', () => {
assert.equal(ASPD_MIN, 20);
const u = new Unit({ id: 1, side: 'ally', kind: 'op', base: { maxHp: 1000, atk: 100, def: 0, res: 0, aspd: 100, bat: 1.2 } });
u.alive = true;
u.buffs.push({ key: 'terrain:mire', stacks: 1, mods: { aspd: -50 } }, { key: 'ab:frost', stacks: 1, mods: { aspd: -50 } });
u.markDirty();
assert.equal(u.s.aspd, 20);
approx(u.s.interval, 6, 1e-9, 'interval = 1.2 × 100 / 20');
u.buffs.push({ key: 'more', stacks: 1, mods: { aspd: -300 } });
u.markDirty();
assert.equal(u.s.aspd, 20, 'still 20 under any slow');
});
test('#17 the user\'s case under heavy slows: the front blocker that took the enemy over hits it every 5 × BAT', () => {
// the held enemy between two operators on act2 m01 (as above); the front one stands on 沼泽 next to a 寒霜 aura
// (ASPD −50 each): officially it still attacks every 1.2 × 100 / 20 = 6 s — v2.4.1 swung every 12 s
const front = chessRec({ id: 'test_front_a', profession: 'TANK', subProfessionId: 'guardian', position: 'MELEE', stats: { atk: 100, def: 0, blockCnt: 1, maxHp: 1e9, bat: 1.2 }, skill: null });
const rear = chessRec({ id: REAR, stats: { atk: 0, blockCnt: 1, maxHp: 1e9, respawnTime: 999 }, skill: null });
const h = makeBattle({
stageId: 'act2autochess_m01', timeLimit: 200, hooks: ['damaged'], captureNoisy: true,
defs: { chess: { test_front_a: front, [REAR]: rear }, enemies: { enemy_x0: foe('enemy_x0'), enemy_e: foe('enemy_e') } },
units: [{ chessId: 'test_front_a', row: 10, col: 7 }, { chessId: REAR, row: 11, col: 7 }],
enemies: [{ key: 'enemy_x0', time: 0 }, { key: 'enemy_e', time: 0.9 }],
});
const F = h.unit('test_front_a'), R = h.unit(REAR);
const x = () => h.b.enemies.find((e) => e.defId === 'enemy_x0');
const E = () => h.b.enemies.find((e) => e.defId === 'enemy_e');
assert.ok(h.runUntil(() => x()?.blockedBy === F && E()?.blockedBy === R, 40), 'held between the two');
h.b.loseHp(x(), 1e12);
h.b.loseHp(R, 1e12);
h.step(2);
assert.equal(E().blockedBy, F, 'taken over');
h.b.addBuff(F, { key: 'terrain:mire', persist: true, mods: { aspd: -50 } });
h.b.addBuff(F, { key: 'ab:frost', persist: true, mods: { aspd: -50 } });
assert.equal(F.s.aspd, 20);
const t0 = h.b.time;
h.run(30);
const hits = h.hooksOf('damaged').filter((c) => c.source === F && c.target === E() && c.t > t0).length;
assert.ok(hits >= 4 && hits <= 6, `≈ 30 / 6 hits in 30 s (got ${hits}; ASPD 10 would give ≤ 3)`);
});
// =====================================================================================================================
// #14 — 力度 vs 重量
const PUSH = [[-3, 0], [-2, 0.12], [-1, 0.44], [0, 1.7], [1, 2.14], [2, 2.96], [3, 3.53], [4, 3.53]];
function lane(extraEnemies = {}, units = []) {
const enemies = {};
for (let m = 0; m <= 10; m++) enemies[`enemy_m${m}`] = foe(`enemy_m${m}`, { speed: 0, mass: m });
return makeBattle({ defs: { enemies: { ...enemies, ...extraEnemies } }, units, content: 'full', autoFinish: false, timeLimit: 60, hooks: ['damaged'], captureNoisy: true });
}
test('#14 the official push table: 受力等级 = 力度 − 重量 (PRTS 游戏数据基础 §重量公式 / 推与拉)', () => {
for (const [lvl, tiles] of PUSH) {
if (lvl >= -2 && lvl <= 3) approx(PUSH_TILES[lvl], tiles, 1e-9, `table ${lvl}`);
}
const h = lane();
h.step();
for (let m = 0; m <= 6; m++) {
for (const force of [-1, 0, 1, 2, 3]) {
const e = h.spawn(`enemy_m${m}`, { pos: [10, 3] });
assert.equal(h.b.forceLevel(e, force), force - m);
const want = PUSH.find(([l]) => l === Math.max(-3, Math.min(4, force - m)))[1];
const moved = h.b.push(e, force, { from: { x: 2, y: 10 } });
approx(moved, want, 1e-9, `force ${force} vs weight ${m}`);
approx(e.x, 3 + want, 1e-9, 'radial: straight away from the pusher');
h.b.loseHp(e, 1e12);
}
}
// 失重 counts (current weight); 失衡免疫 and leaders never move
const w = h.spawn('enemy_m3', { pos: [10, 3] });
h.b.addBuff(w, { key: 'weightless', mods: { massFlat: -1 } });
approx(h.b.push(w, 1, { from: { x: 2, y: 10 } }), 0.44, 1e-9, '失重 weight 3 → 2');
const f = h.spawn('enemy_m0', { pos: [11, 3] });
h.b.addBuff(f, { key: 'noDisp', flags: { noDisplace: true } });
assert.equal(h.b.push(f, 5, { from: { x: 2, y: 11 } }), 0, '失衡免疫');
checkInvariants(h.b);
});
test('#14 directional pushes: > 45° off the direction or nearer than 0.25 → radial with 受力等级 −2, unless fixed (见行者 / 圣聆初雪)', () => {
const h = lane();
h.step();
const from = { x: 3, y: 10 }, right = { x: 1, y: 0 };
const a = h.spawn('enemy_m1', { pos: [10, 4] });
approx(h.b.push(a, 1, { from, dir: right }), 1.7, 1e-9, 'straight ahead: 中力 vs 1 = 1.7 along the direction');
approx(a.y, 10, 1e-9);
const b = h.spawn('enemy_m1', { pos: [11, 3.5] }); // 63° off the direction
const moved = h.b.push(b, 1, { from, dir: right });
approx(moved, 0.12, 1e-9, 'radial, 受力等级 0 − 2 = −2');
assert.ok(b.y > 11, 'pushed away from the pusher, not along its direction');
const c = h.spawn('enemy_m1', { pos: [11, 3.5] });
approx(h.b.push(c, 1, { from, dir: right, fixed: true }), 1.7, 1e-9, 'fixed: along the direction, full force');
approx(c.y, 11, 1e-9);
// 见行者 S2 (PRTS 备注 waives only the angle): 63° off → along the direction at full force; nearer than 0.25 → radial −2
const d = h.spawn('enemy_m1', { pos: [12, 3.5] });
approx(h.b.push(d, 1, { from: { x: 3, y: 11 }, dir: right, fixedAngle: true }), 1.7, 1e-9, 'fixedAngle: angle waived');
approx(d.y, 12, 1e-9);
const n = h.spawn('enemy_m1', { pos: [9, 3.1] });
approx(h.b.push(n, 1, { from: { x: 3, y: 9 }, dir: right, fixedAngle: true }), 0.12, 1e-9, 'fixedAngle: < 0.25 tile still radial −2');
const n2 = h.spawn('enemy_m1', { pos: [8, 3.1] });
approx(h.b.push(n2, 1, { from: { x: 3, y: 8 }, dir: right, fixed: true }), 1.7, 1e-9, 'fixed (圣聆初雪): < 0.25 tile waived too');
checkInvariants(h.b);
});
test('#14 见行者 S2 惊爆射击: angle waived, a target nearer than 0.25 tile radial −2 — and only a real wall lengthens the stun', REAL, () => {
const id = 'chess_char_3_07_a';
const ds = getDefaultSource();
const bb = ds.rawChess(id).skill.bb;
const run = (pos) => {
const h = makeBattle({
defs: { enemies: { enemy_t: foe('enemy_t', { speed: 0, mass: 0 }) } }, units: [{ chessId: id, row: 10, col: 3, dir: 'RIGHT', carryState: { sp: 1e3 } }],
enemies: [{ key: 'enemy_t', pos }], autoFinish: false, timeLimit: 30, hooks: ['statusApplied'], captureNoisy: true,
});
const u = h.unit(id);
assert.ok(h.runUntil(() => h.hooksOf('statusApplied').some((c) => c.source === u && c.status === 'stun'), 6), 'cast');
const st = h.hooksOf('statusApplied').find((c) => c.source === u && c.status === 'stun');
return { e: h.b.enemies[0], stun: st.duration };
};
// straight ahead, open ground: 中力 vs weight 0 = 受力等级 1 — a 特效 push (PRTS 推与拉 names 见行者's skills 特效类):
// 1.987 tiles along her direction, the short stun
const a = run([10, 4]);
approx(a.e.x, 4 + PUSH_TILES_EFFECT[1], 1e-6, 'ahead: 1.987');
approx(a.stun, bb['forcer_s_2[hit_directly].stun'], 1e-9, 'open ground: hit_directly stun');
// on her own tile, nearer than 0.25: radial at 受力等级 1 − 2 = −1 (0.374), still the short stun (no wall)
const b = run([10.1, 3.1]);
approx(Math.hypot(b.e.x - 3.1, b.e.y - 10.1), PUSH_TILES_EFFECT[-1], 1e-6, 'near: radial −2');
approx(b.stun, bb['forcer_s_2[hit_directly].stun'], 1e-9, 'near target: not a wall');
});
test('#14 特效 pushes (PRTS 推与拉: one frame less of travel than 弹道 ones): the 特效 column, and 见行者 S1 护身射击 uses it', REAL, () => {
const EFFECT = [[-3, 0], [-2, 0.085], [-1, 0.374], [0, 1.562], [1, 1.987], [2, 2.773], [3, 3.331], [4, 3.331]];
const h = lane();
h.step();
for (let m = 0; m <= 4; m++) {
const e = h.spawn(`enemy_m${m}`, { pos: [10, 3] });
const want = EFFECT.find(([l]) => l === Math.max(-3, Math.min(4, 1 - m)))[1];
approx(h.b.pushDistance(e, 1, { effect: true }), want, 1e-9, `distance: 中力 vs weight ${m}`);
approx(h.b.push(e, 1, { from: { x: 2, y: 10 }, effect: true }), want, 1e-9, `push: 中力 vs weight ${m}`);
h.b.loseHp(e, 1e12);
}
// 见行者 S1 (generic kit, 推击手 directional push on her next attack, bb.force 1) on a weight-0 enemy ahead: 1.987, not 2.14
const id = 'chess_char_3_07_a';
const k = makeBattle({
defs: { enemies: { enemy_t: foe('enemy_t', { speed: 0, mass: 0 }) } }, units: [{ chessId: id, row: 10, col: 3, dir: 'RIGHT', skillIndex: 0, carryState: { sp: 1e3 } }],
enemies: [{ key: 'enemy_t', pos: [10, 4] }], autoFinish: false, timeLimit: 30,
});
const u = k.unit(id);
assert.equal(u.skill.id, 'skchr_forcer_1');
k.step();
const e = k.b.enemies[0];
assert.ok(e && k.runUntil(() => e.x > 4.01, 6), 'pushed');
k.step(2);
approx(e.x, 4 + PUSH_TILES_EFFECT[1], 1e-6, '特效 column');
});
test('#14 锏 S3 归于宁静 slashes and pulls air units too (PRTS 备注 "※可对空。不会拖拽自身中心半径0.6708范围内的敌人")', REAL, () => {
const id = 'chess_char_6_19_a';
const h = makeBattle({
defs: { enemies: { enemy_fly: foe('enemy_fly', { speed: 0, mass: 0, motion: 'FLY' }), enemy_near: foe('enemy_near', { speed: 0, mass: 0 }) } },
units: [{ chessId: id, row: 10, col: 4, dir: 'RIGHT' }], enemies: [{ key: 'enemy_fly', pos: [10, 6] }, { key: 'enemy_near', pos: [10, 4.5] }],
autoFinish: false, timeLimit: 30, hooks: ['damaged'], captureNoisy: true,
});
h.step();
const u = h.unit(id);
assert.equal(u.skill.id, 'skchr_blkkgt_3');
const fly = h.b.enemies.find((x) => x.defId === 'enemy_fly'), near = h.b.enemies.find((x) => x.defId === 'enemy_near');
assert.ok(fly.isFlying);
assert.ok(u.skill.activate('test', { free: true }), 'S3 on');
h.run(4.5);
const slashes = h.hooksOf('damaged').filter((c) => c.source === u && c.target === fly && c.dmg?.isSkill).length;
assert.ok(slashes >= 10, `the flyer is slashed (${slashes} skill hits)`);
// 中力 pull vs weight 0 (受力等级 1): all the way, to the 急停 radius around her centre
approx(Math.hypot(fly.x - u.x, fly.y - u.y), PULL_STOP_RADIUS, 1e-6, 'pulled in to 0.6708 from her centre');
approx(near.x, 4.5, 1e-9, 'an enemy inside the 0.6708 radius is not pulled');
});
test('#14 pulls: 受力等级 ≥ 0 all the way to the 急停 radius, −1 → 35 % of the way, −2 → 0.03, ≤ −3 → nothing; the puller\'s own blocked enemy stays', () => {
const guard = chessRec({ id: 'test_puller_a', stats: { atk: 0, blockCnt: 0, maxHp: 1e6 }, skill: null });
const h = makeBattle({ defs: { chess: { test_puller_a: guard }, enemies: Object.fromEntries([0, 1, 2, 3, 4].map((m) => [`enemy_m${m}`, foe(`enemy_m${m}`, { speed: 0, mass: m })])) },
units: [{ chessId: 'test_puller_a', row: 10, col: 3 }], content: 'none', autoFinish: false, timeLimit: 60 });
h.step();
const u = h.unit('test_puller_a');
const start = 7;
const expect = { 0: start - 3 - PULL_STOP_RADIUS, 1: start - 3 - PULL_STOP_RADIUS, 2: 0.35 * (start - 3.5), 3: 0.03, 4: 0 };
for (const m of [0, 1, 2, 3, 4]) {
const e = h.spawn(`enemy_m${m}`, { pos: [10, start] });
approx(h.b.pullToFront(e, u, 1), expect[m], 1e-6, `中力 vs weight ${m}`);
h.b.loseHp(e, 1e12);
}
const g = h.spawn('enemy_m0', { pos: [10, 3.7] });
g.blockedBy = u; u.blocking.push(g);
assert.equal(h.b.pullToFront(g, u, 3), 0, 'already held in front of it');
assert.equal(g.blockedBy, u);
});
test('#14 流形 S3 pulse: the enemy a melee copy blocks is not pulled (so not released) and is stunned at every pulse', REAL, () => {
const guard = chessRec({ id: 'test_guard_a', profession: 'WARRIOR', position: 'MELEE', stats: { atk: 500, blockCnt: 2, maxHp: 1e6, def: 0 }, skill: null });
for (const mass of [0, 2]) {
const h = makeBattle({
defs: { chess: { test_guard_a: guard }, enemies: { enemy_w: foe('enemy_w', { mass }) } }, timeLimit: 120, captureNoisy: true,
units: [
{ chessId: 'chess_char_6_11_a', row: 12, col: 3, skillIndex: 2 },
{ chessId: 'test_guard_a', row: 12, col: 6 },
{ kind: 'token', tokenId: 'token_10030_mlyss_wtrman', row: 9, col: 6, ownerUid: 1, dir: 'LEFT' },
],
enemies: [{ key: 'enemy_w', time: 9 }],
});
const tok = () => h.b.allyUnits.find((x) => x.defId === 'token_10030_mlyss_wtrman' && x.alive);
assert.ok(h.runUntil(() => tok()?.mem.mlyss && !tok().mem.mlyss.ranged, 9), 'a melee copy');
const E = () => h.b.enemies.find((x) => x.defId === 'enemy_w');
assert.ok(h.runUntil(() => E()?.blockedBy === tok(), 30), 'blocked by the copy');
const u = h.unit('chess_char_6_11_a');
assert.equal(u.skill.id, 'skchr_mlyss_3');
assert.ok(u.skill.activate('test', { free: true }), 'S3 on');
const e = E(), x0 = e.x, y0 = e.y;
let stunned = 0, released = 0;
for (let i = 0; i < 30 * 6; i++) { h.step(); if (e.s.flags.stun) stunned++; if (e.blockedBy !== tok()) released++; }
assert.equal(released, 0, `weight ${mass}: stays blocked`);
assert.equal(stunned, 180, `weight ${mass}: stunned through every pulse`);
approx(Math.hypot(e.x - x0, e.y - y0), 0, 1e-9, 'not pulled');
}
});
test('#14 the user\'s case: 野鬃 S2 夹枪冲锋 pushes by 力度 − 重量 — light enemies far, weight ≥ 4 not at all', REAL, () => {
const ds = getDefaultSource();
const id = 'chess_char_1_19_a';
assert.equal(ds.rawChess(id).skill.bb['attack@force'], 1, 'data: 中等力度 = 1');
// the displacement of her first skill hit: the enemy's position before the attack and right after it (the melee hit and
// its push resolve inside the attack)
const moved = (mass) => {
const pushes = [];
const h = makeBattle({
defs: { enemies: { enemy_t: foe('enemy_t', { speed: 0, mass }) } }, units: [{ chessId: id, row: 10, col: 3, carryState: { sp: 1e3 } }],
enemies: [{ key: 'enemy_t', pos: [10, 4] }], autoFinish: false, timeLimit: 30, hooks: [],
setup(b) {
let x0 = null;
b.on('beforeAttack', (c) => { if (c.attacker.defId === id && c.isSkill) x0 = c.targets[0]?.x ?? null; }, { priority: -2000 });
b.on('attack', (c) => { if (c.attacker.defId === id && c.isSkill && x0 != null) { pushes.push(c.targets[0].x - x0); x0 = null; } }, { priority: -2000 });
},
});
const u = h.unit(id);
assert.ok(h.runUntil(() => pushes.length > 0, 5), 'a skill hit');
assert.ok(u.skill.active);
return pushes[0];
};
const want = { 0: 2.14, 1: 1.7, 2: 0.44, 3: 0.12, 4: 0, 5: 0, 6: 0 };
for (const [m, d] of Object.entries(want)) {
const got = moved(Number(m));
approx(got, d, 1e-6, `weight ${m}`);
}
});
// =====================================================================================================================
// #18 — 炎佑 (孤立) and the 禁疗 summons are never healed
const medic = (o = {}) => chessRec({
id: 'test_medic_a', profession: 'MEDIC', subProfessionId: 'physician', position: 'RANGED', dmgType: 'heal',
rangeGrid: [...Array(9)].flatMap((_, r) => [...Array(21)].map((__, c) => [r - 4, c - 10])), stats: { atk: 400, maxHp: 1e6, blockCnt: 1, bat: 0.5 }, skill: null, ...o,
});
test('#18 炎佑 holds 孤立 (PRTS “炎佑” 天赋): medics never heal it, auras never pick it — while they heal an injured operator', REAL, () => {
const ds = getDefaultSource();
assert.deepEqual(ds.rawToken(TOKEN_IDS.yanyou).abnormal, ['isolated'], 'data: tokens.json abnormal');
const h = makeBattle({ defs: { chess: { test_medic_a: medic(), test_hurt_a: chessRec({ id: 'test_hurt_a', stats: { atk: 0, maxHp: 1e5 }, skill: null }) } },
units: [{ chessId: 'test_medic_a', row: 10, col: 5 }, { chessId: 'test_hurt_a', row: 12, col: 3 }], autoFinish: false, timeLimit: 60, hooks: ['heal'], captureNoisy: true });
h.step();
const [y] = spawnYanyou(h.b, 'p1', { atk: 0, hp: 5000 });
const m = h.unit('test_medic_a'), op = h.unit('test_hurt_a');
assert.ok(y.s.flags.isolated && y.s.flags.noHeal, '孤立 flags');
y.hp = y.s.maxHp * 0.3;
op.hp = op.s.maxHp * 0.6;
assert.ok(!h.b.alliesInGrid(m).includes(y), 'no ally selection picks it');
assert.equal(h.b.heal(m, y, 1000), 0, 'the heal pipeline refuses it');
h.run(5);
approx(y.hp, y.s.maxHp * 0.3, 1e-6, '炎佑 not healed');
assert.ok(op.hp > op.s.maxHp * 0.6, 'the injured operator is');
assert.ok(!h.hooksOf('heal').some((c) => c.target === y), 'no heal event on 炎佑');
checkInvariants(h.b);
});
test('#18 the summons that hold 禁疗 (PRTS summon pages) are not healed by others: data list + 狼群 next to a medic', REAL, () => {
const ds = getDefaultSource();
const healFree = ['token_10015_dusk_drgn', 'token_10019_nearl2_sword', 'token_10017_skadi2_dedant', 'token_10011_beewax_oblisk',
'token_10030_mlyss_wtrman', 'token_10028_vigil_wolf', 'token_10012_rosmon_shield', 'token_10040_siege2_vlion', 'token_10058_sbell2_icetgt'];
for (const id of healFree) assert.deepEqual(ds.rawToken(id).abnormal, ['healFree'], id);
assert.deepEqual(ds.rawToken('token_10039_ulpia_block').abnormal, ['isolated']);
for (const id of ['token_10000_silent_healrb', 'token_10006_vodfox_doll', 'token_10022_kazema_shadow', 'char_605_cmedic']) assert.deepEqual(ds.rawToken(id).abnormal, [], id);
const h = makeBattle({ defs: { chess: { test_medic_a: medic() } },
units: [{ chessId: 'chess_char_3_19_a', row: 12, col: 3 }, { chessId: 'test_medic_a', row: 11, col: 3 }, { kind: 'token', tokenId: 'token_10028_vigil_wolf', row: 10, col: 5, ownerUid: 1 }],
autoFinish: false, timeLimit: 60, hooks: ['heal'], captureNoisy: true });
h.step();
const w = h.b.allyUnits.find((x) => x.defId === 'token_10028_vigil_wolf' && x.alive);
assert.ok(w && w.s.flags.noHeal, '禁疗');
w.hp = w.s.maxHp * 0.5;
h.run(4);
approx(w.hp, w.s.maxHp * 0.5, 1e-6, '狼群 not healed');
assert.ok(!h.hooksOf('heal').some((c) => c.target === w && c.source !== w));
});
test('#18 圣聆初雪\'s 保护目标(冻结状态) holds 禁疗 (PRTS 备注 "持有禁疗、无法撤退"): a medic next to it does not heal it', REAL, () => {
const h = makeBattle({ defs: { chess: { test_medic_a: medic() } },
units: [{ chessId: 'chess_char_6_02_a', row: 12, col: 3 }, { chessId: 'test_medic_a', row: 11, col: 3 }],
autoFinish: false, timeLimit: 60, hooks: ['heal'], captureNoisy: true });
h.step();
const ice = h.b.spawnToken(h.unit('chess_char_6_02_a'), 'token_10058_sbell2_icetgt', 10, 5);
assert.ok(ice && ice.alive && ice.s.flags.noHeal, '禁疗');
ice.hp = ice.s.maxHp * 0.5;
h.run(4);
approx(ice.hp, ice.s.maxHp * 0.5, 1e-6, 'not healed');
assert.ok(!h.hooksOf('heal').some((c) => c.target === ice && c.source !== ice));
});
test('#18 安洁莉娜 兼职工作 is an HP-regen attribute (PRTS 备注 "不受治疗加成和禁疗影响"): the 禁疗 狼群 gets it; 炎佑 (孤立) is never selected', REAL, () => {
const h = makeBattle({
units: [{ chessId: 'chess_char_3_19_a', row: 12, col: 3 }, { chessId: 'chess_char_5_20_a', row: 11, col: 3, carryState: { sp: 0 } },
{ kind: 'token', tokenId: 'token_10028_vigil_wolf', row: 10, col: 5, ownerUid: 1 }],
autoFinish: false, timeLimit: 60,
});
h.step();
const [y] = spawnYanyou(h.b, 'p1', { atk: 0, hp: 5000 });
h.run(2);
const ag = h.unit('chess_char_5_20_a');
assert.ok(!ag.skill.active, 'skill off');
const w = h.b.allyUnits.find((x) => x.defId === 'token_10028_vigil_wolf' && x.alive);
assert.ok(w.s.flags.noHeal, '狼群 holds 禁疗');
assert.equal(w.findBuff('aglina:parttime')?.mods.hpRegen, 20, '禁疗 does not stop the regen attribute');
assert.ok(w.s.hpRegen >= 20);
assert.ok(!y.findBuff('aglina:parttime') && !y.findBuff('aglina:field'), '孤立: no ally talent selects 炎佑');
assert.ok(h.unit('chess_char_3_19_a').findBuff('aglina:field'), 'the operators get 加速力场');
});
test('#18 孤立 in every ally selection: 刺玫\'s 荆藤庇荫 picks the operator in her range, not 炎佑 (higher max HP)', REAL, () => {
const h = makeBattle({
defs: { chess: { test_op_a: chessRec({ id: 'test_op_a', stats: { atk: 0, maxHp: 3000 }, skill: null }) } },
units: [{ chessId: 'chess_char_1_06_a', row: 10, col: 3, dir: 'RIGHT' }, { chessId: 'test_op_a', row: 10, col: 4 }], autoFinish: false, timeLimit: 60,
});
h.step();
const [y] = spawnYanyou(h.b, 'p1', { atk: 0, hp: 50000 });
const cm = h.unit('chess_char_1_06_a'), op = h.unit('test_op_a');
assert.ok(h.b.relocate(y, 10, 5), '炎佑 inside her range');
assert.ok(y.s.maxHp > op.s.maxHp);
assert.deepEqual(alliesInGridOf(h.b, cm).filter((a) => a !== cm), [op], 'the range selection skips 炎佑');
assert.ok(cm.skill.activate('test', { free: true }), 'skill on');
assert.equal(cm.mem.protege, op, 'protégé = the operator');
assert.ok(!y.buffs.some((b) => b.key.startsWith('vendla')), '炎佑 not taunted');
assert.equal(h.b.allySelectable(y, cm), false);
assert.equal(h.b.allySelectable(y, y), true, 'its own effects still reach it');
checkInvariants(h.b);
});
+297
View File
@@ -0,0 +1,297 @@
// test/sim/playtest6-enemies.test.js — user playtest #6, workstream WG (enemies), real enemy data in the real sim:
// #13 “萨科塔之翼”'s 恐惧 (the user's "战栗(小范围乱飞)"): below half HP it fears itself for 5 s (SelfFear, ×1.5 speed) —
// official 恐惧 = "无法被阻挡并四散逃跑" (ba.fear); a self-inflicted fear has no 恐惧可达地块, so the unit keeps picking
// a random point of its own tile (±0.25) and flies there (PRTS 诱发移动 §恐惧). It used to hang still. Also
// “萨科塔之眼” / “萨科塔昂首” (same talent) and an operator's fear (runs away from the source, fan of ±45°).
// #12 萨卡兹枯朽战车 / 尖端: initial SP 2 = max ⇒ the FIRST attack is 秽蚀轰击 (100 % + a 【污染秽蚀】 of radius 1.7 for 10 s),
// then every 3rd; 污染秽蚀 = 50 / 25 true damage per second on low / high ground, same-name zones do not stack
// (PRTS 萨卡兹枯朽战车 / 萨卡兹枯朽战士). It used to pollute only from the 3rd attack, radius 1 [ASSUMED], HP loss, stacking.
// Review follow-up: the 恐惧 and 诱导 walks re-plan from where the enemy stands after a push (Battle.displace), an
// obstacle change or (诱导) an outranking 恐惧 — a stale path walked through walls (real stages m01–m04).
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { makeBattle, chessRec } from '../helpers/battleHarness.js';
import * as enemiesMod from '../../server/sim/content/enemies.js';
import * as bossesMod from '../../server/sim/content/bosses.js';
import { reachableTiles, FEAR_RADIUS } from '../../server/sim/fear.js';
import { createRng } from '../../server/sim/rng.js';
import { ALLY_COLLIDER_RADIUS } from '../../server/sim/constants.js';
const E = JSON.parse(fs.readFileSync(new URL('../../data/enemies.json', import.meta.url), 'utf8'));
const tb = (key, k) => E[key].talents.bb[k];
const WALL = (id, o = {}) => chessRec({ id, profession: o.profession ?? 'TANK', position: o.position, stats: { atk: 0, maxHp: 1e7, def: 0, res: 0, blockCnt: o.block ?? 3 }, rangeGrid: [[0, 0]], skill: null });
const NOATK = () => ({ trait: { noAttack: true } });
function arena(o = {}) {
const { chess = {}, kits = {}, ...rest } = o;
return makeBattle({
content: 'generic', extraContent: [enemiesMod, bossesMod], seed: 11, autoFinish: false, timeLimit: 600,
defs: { chess: { t_wall: WALL('t_wall'), t_wall2: WALL('t_wall2'), t_hi: WALL('t_hi', { profession: 'SNIPER', position: 'RANGED', block: 0 }), ...chess } },
kits: { t_wall: NOATK, t_wall2: NOATK, t_hi: NOATK, ...kits }, ...rest,
});
}
/** Positions of `e` over `secs` game seconds (one sample per tick). */
function track(h, e, secs) {
const out = [];
const n = Math.round(secs * 30);
for (let i = 0; i < n; i++) { h.step(); out.push({ t: h.b.time, x: e.x, y: e.y, moving: e.moving, blocked: !!e.blockedBy, atk: e.stats.attacks }); }
return out;
}
const pathLen = (p) => p.reduce((s, q, i) => (i ? s + Math.hypot(q.x - p[i - 1].x, q.y - p[i - 1].y) : 0), 0);
/** Sign changes of the horizontal movement (ignoring standstill). */
function turns(p) {
let n = 0, last = 0;
for (let i = 1; i < p.length; i++) {
const dx = p[i].x - p[i - 1].x;
if (Math.abs(dx) < 1e-6) continue;
const s = dx > 0 ? 1 : -1;
if (last && s !== last) n++;
last = s;
}
return n;
}
describe('#13 “萨科塔” self-fear: flutters at random inside a small area, then flies on', () => {
for (const key of ['enemy_10083_hlbird', 'enemy_10084_hlegle', 'enemy_10085_hllevi_2']) {
test(`${key} ${E[key].name}: 5 s of random flight inside its tile (±0.25), ×1.5 speed, then its route again`, () => {
const h = arena({ units: [{ chessId: 't_hi', row: 11, col: 2 }] });
const e = h.spawn(key, { routeIndex: 2 }); // flat stage fly lane (9,10) → (9,2)
h.run(4); // on its way (x ≈ 10 − 0.45·4)
assert.equal(e.motion, 'FLY');
const x0 = e.x;
h.b.dealDamage(h.unit('t_hi'), e, { amount: e.s.maxHp * 0.6, type: 'true' });
assert.ok(e.s.flags.fear, 'feared');
const tr = Math.round(e.y), tc = Math.round(e.x);
const fear = tb(key, 'SelfFear.fear');
const p = track(h, e, fear - 0.1);
const settled = p.filter((q) => q.t > h.b.time - fear + 1.2);
for (const q of p) assert.ok(Math.abs(q.x - tc) <= 0.5 + 1e-6 && Math.abs(q.y - tr) <= 0.5 + 1e-6, `stays on its tile: ${q.x.toFixed(2)},${q.y.toFixed(2)}`);
for (const q of settled) assert.ok(Math.abs(q.x - tc) <= 0.25 + 1e-6 && Math.abs(q.y - tr) <= 0.25 + 1e-6, `inside the 0.5 square: ${q.x.toFixed(2)},${q.y.toFixed(2)}`);
const len = pathLen(p);
const speed = e.base.moveSpeed * 0.5 * tb(key, 'SelfFear.move_speed');
assert.ok(len > 0.6 * speed * fear, `keeps flying (${len.toFixed(2)} tiles in ${fear} s, speed ${speed})`);
assert.ok(turns(p) >= 3, `changes direction at random (${turns(p)} turns)`);
assert.ok(p.filter((q) => q.moving).length > 0.8 * p.length, 'animated as moving');
assert.ok(Math.abs(p[p.length - 1].x - x0) < 0.6, 'does not advance along its route meanwhile');
h.run(0.2);
assert.ok(!e.s.flags.fear, 'the fear ended');
const xe = e.x;
h.run(3);
assert.ok(e.x < xe - 0.8 * e.s.moveSpeed * 0.5 * 3, `flies on toward the goal (${xe.toFixed(2)} → ${e.x.toFixed(2)})`);
});
}
test('deterministic: the same seed flies the same wander', () => {
const run = () => {
const h = arena({ units: [{ chessId: 't_hi', row: 11, col: 2 }] });
const e = h.spawn('enemy_10083_hlbird', { routeIndex: 2 });
h.run(3);
h.b.dealDamage(null, e, { amount: e.s.maxHp * 0.6, type: 'true' });
return track(h, e, 4).map((q) => `${q.x.toFixed(6)},${q.y.toFixed(6)}`).join(' ');
};
assert.equal(run(), run());
});
});
describe('#13 恐惧 from an operator: unblockable, flees into the fan away from the source', () => {
test('a walking enemy feared by an operator west of it runs east (±45°), never attacks, then walks its route again', () => {
const walker = { key: 'enemy_walker', name: 'w', rank: 'NORMAL', applyWay: 'MELEE', stats: { maxHp: 1e7, atk: 100, def: 0, res: 0, moveSpeed: 1, bat: 1, aspd: 100, rangeRadius: 0, blockCnt: 1, massLevel: 1, lpr: 1, dmgType: 'phys', motion: 'WALK', immunities: {}, otherImmunities: [] }, talents: { bb: {} }, skills: [], abilities: [], tags: [] };
const h = arena({ units: [{ chessId: 't_wall', row: 9, col: 5 }], defs: { chess: { t_wall: WALL('t_wall') }, enemies: { enemy_walker: walker } } });
const e = h.spawn('enemy_walker', { routeIndex: 0 });
const u = h.unit('t_wall');
assert.ok(h.runUntil(() => e.blockedBy === u, 20), 'blocked first');
h.b.applyStatus(e, 'fear', { duration: 4, source: u });
assert.equal(e.blockedBy, null, 'released');
const tiles = reachableTiles(h.b, e, e.findBuff('fear').data.fear);
assert.ok(tiles.length > 3, `a fan of reachable tiles (${tiles.length})`);
for (const k of tiles) {
const r = Math.floor(k / 21), c = k % 21;
const vx = c - e.x, vy = r - e.y, L = Math.hypot(vx, vy);
assert.ok(L <= FEAR_RADIUS + 1e-6 && (L < 1e-6 || vx / L >= Math.SQRT1_2 - 1e-6), `(${r},${c}) inside the eastern fan`);
assert.ok(h.b.grid.walkable(r, c, true) && h.b.grid.tile(r, c).special !== 'end');
}
const d0 = Math.hypot(e.x - u.x, e.y - u.y);
const a0 = e.stats.attacks;
const p = track(h, e, 3.9);
assert.ok(p.every((q) => !q.blocked), 'never blocked while feared');
assert.equal(e.stats.attacks, a0, 'no attack while feared');
assert.ok(pathLen(p) > 1.5, 'it runs');
assert.ok(Math.hypot(e.x - u.x, e.y - u.y) > d0 + 0.5, 'farther from the source');
for (const q of p) assert.ok(h.b.grid.walkable(Math.round(q.y), Math.round(q.x), true), 'on walkable ground');
h.run(0.3);
assert.ok(!e.s.flags.fear);
assert.ok(h.runUntil(() => e.blockedBy === u, 20), 'walks back its route and is blocked again');
});
test('恐惧 outranks 诱导 (PRTS 诱移优先级)', () => {
const walker = { key: 'enemy_walker', name: 'w', rank: 'NORMAL', applyWay: 'MELEE', stats: { maxHp: 1e7, atk: 0, def: 0, res: 0, moveSpeed: 1, bat: 1, aspd: 100, rangeRadius: 0, blockCnt: 1, massLevel: 1, lpr: 1, dmgType: 'none', motion: 'WALK', immunities: {}, otherImmunities: [] }, talents: { bb: {} }, skills: [], abilities: [], tags: [] };
const h = arena({ units: [{ chessId: 't_hi', row: 11, col: 2 }], defs: { chess: { t_hi: WALL('t_hi', { profession: 'SNIPER', position: 'RANGED', block: 0 }) }, enemies: { enemy_walker: walker } } });
const e = h.spawn('enemy_walker', { routeIndex: 0, pos: [9, 6], mods: { speedMul: 1 } });
h.step();
h.b.applyStatus(e, 'attract', { duration: 3, point: [12, 6] });
h.b.applyStatus(e, 'fear', { duration: 3, source: null }); // no source: no reachable tile, flutters on its tile
const r0 = Math.round(e.y);
track(h, e, 2.5);
assert.equal(Math.round(e.y), r0, 'stays on its tile instead of walking to the attract point');
});
});
describe('#13 恐惧 / 诱导 walks re-plan when something else moves the enemy (review follow-up)', () => {
const WALKER = { key: 'enemy_walker', name: 'w', rank: 'NORMAL', applyWay: 'MELEE', stats: { maxHp: 1e7, atk: 0, def: 0, res: 0, moveSpeed: 1, bat: 1, aspd: 100, rangeRadius: 0, blockCnt: 1, massLevel: 0, lpr: 1, dmgType: 'none', motion: 'WALK', immunities: {}, otherImmunities: [] }, talents: { bb: {} }, skills: [], abilities: [], tags: [] };
const STAGES = ['act2autochess_m01', 'act2autochess_m02', 'act2autochess_m03', 'act2autochess_m04'];
/** A walker on a real stage at a deterministic walkable tile of the lower lanes. */
function realWalker(stageId, seed, pick) {
const h = makeBattle({ stageId, seed, autoFinish: false, timeLimit: 300, content: 'generic', defs: { enemies: { enemy_walker: WALKER } },
routes: [{ motion: 'WALK', start: [9, 10], end: [9, 2], checkpoints: [] }] });
const g = h.b.grid;
const walk = [];
for (let r = 9; r <= 12; r++) for (let c = 2; c <= 18; c++) if (g.walkable(r, c) && g.tile(r, c).special !== 'end') walk.push([r, c]);
const e = h.spawn('enemy_walker', { routeIndex: 0, pos: walk[pick(walk.length)] });
h.step();
return { h, g, e, walk };
}
/** Ticks (over `secs`) the enemy spent on a tile it cannot walk, `when(e)` filtering the ticks counted. */
function offGround(h, g, e, secs, when = () => true) {
let n = 0;
for (let i = 0; i < Math.round(secs * 30); i++) { h.step(); if (when(e) && !g.walkable(Math.round(e.y), Math.round(e.x), true)) n++; }
return n;
}
test('a feared walker pushed mid-hop (Battle.displace) re-plans from where it lands: it never cuts through walls', () => {
const rng = createRng(5);
let pushed = 0;
const bad = [];
for (const stageId of STAGES) {
for (let n = 0; n < 60; n++) {
const { h, g, e } = realWalker(stageId, n + 1, (len) => (n * 7 + 3) % len);
const a1 = rng() * Math.PI * 2, a2 = rng() * Math.PI * 2;
h.b.applyStatus(e, 'fear', { duration: 4, source: { x: e.x + Math.cos(a1), y: e.y + Math.sin(a1), side: 'ally' } });
h.run(0.3 + rng());
if (h.b.displace(e, { x: Math.cos(a2), y: Math.sin(a2) }, 1.5, { force: 3 }) > 0) pushed++;
if (offGround(h, g, e, 4)) bad.push(`${stageId}#${n + 1}`);
}
}
assert.ok(pushed > 150, `pushes landed (${pushed})`);
assert.deepEqual(bad, [], 'walked onto an unwalkable tile after the push');
});
test('an obstacle appearing on a feared walker\'s path (grid.version) is walked around, not into', () => {
let tried = 0;
for (let seed = 1; seed <= 60; seed++) {
const h = arena({ seed, defs: { enemies: { enemy_walker: { ...WALKER, stats: { ...WALKER.stats, massLevel: 1 } } } } });
const e = h.spawn('enemy_walker', { routeIndex: 0, pos: [10, 8] });
h.step();
h.b.applyStatus(e, 'fear', { duration: 6, source: { x: 6, y: 10, side: 'ally' } }); // flee east
h.step();
const m = e.mem.fearMove;
const p = m && m.pts && m.pts[m.i];
if (!p) continue;
const dx = p.x - e.x, dy = p.y - e.y, d = Math.hypot(dx, dy);
if (d < 1.2) continue;
const r = Math.round(e.y + dy / d), c = Math.round(e.x + dx / d); // the tile one step ahead
if (r === Math.round(e.y) && c === Math.round(e.x)) continue;
h.b.setObstacle(r, c, true);
tried++;
const t = track(h, e, 3);
assert.ok(t.every((q) => Math.round(q.y) !== r || Math.round(q.x) !== c), `seed ${seed}: kept off the new obstacle (${r},${c})`);
assert.ok(pathLen(t) > 1, `seed ${seed}: still running`);
}
assert.ok(tried >= 20, `obstacles placed ahead in ${tried} runs`);
});
test('诱导 after an outranking 恐惧, and 诱导 after a push, re-plan from where the enemy stands (no wall cutting)', () => {
const rng = createRng(77);
const afterFear = [], afterPush = [];
for (const stageId of STAGES) {
for (let n = 0; n < 60; n++) {
{
const { h, g, e, walk } = realWalker(stageId, n + 1, (len) => Math.floor(rng() * len));
h.b.applyStatus(e, 'attract', { duration: 14, point: walk[Math.floor(rng() * walk.length)] });
h.run(0.5 + rng() * 2);
const ang = rng() * Math.PI * 2;
h.b.applyStatus(e, 'fear', { duration: 2 + rng() * 2, source: { x: e.x + Math.cos(ang), y: e.y + Math.sin(ang), side: 'ally' } });
if (offGround(h, g, e, 10, (x) => !x.s.flags.fear)) afterFear.push(`${stageId}#${n + 1}`);
}
{
const { h, g, e, walk } = realWalker(stageId, n + 1, (len) => (n * 7 + 3) % len);
h.b.applyStatus(e, 'attract', { duration: 6, point: walk[(n * 13 + 5) % walk.length] });
h.run(0.3 + rng());
const a = rng() * Math.PI * 2;
h.b.displace(e, { x: Math.cos(a), y: Math.sin(a) }, 1.5, { force: 3 });
if (offGround(h, g, e, 4)) afterPush.push(`${stageId}#${n + 1}`);
}
}
}
assert.deepEqual(afterFear, [], 'the 诱导 walk after the 恐惧 cut through an unwalkable tile');
assert.deepEqual(afterPush, [], 'the 诱导 walk after a push cut through an unwalkable tile');
});
});
describe('#12 萨卡兹枯朽战车: 秽蚀轰击 first, 污染秽蚀 radius 1.7, true damage, no stacking', () => {
for (const key of ['enemy_1272_nhtank', 'enemy_1272_nhtank_2']) {
test(`${key} ${E[key].name}: attacks 1, 4, 7 are 秽蚀轰击 (100 % + zone r 1.7 / 10 s); the zone hits 50 / 25 true per second`, () => {
// (10,7) road = low ground; (11,7) raised to high ground (ranged tile) inside the zone
const flat = { rows: { 11: '##hrrrrhrrfrrrrrrrf##' } };
const h = arena({ flat, units: [{ chessId: 't_wall', row: 10, col: 7 }, { chessId: 't_hi', row: 11, col: 7 }], captureNoisy: true, hooks: ['damaged', 'attack'] });
h.step();
const e = h.spawn(key, { pos: [9, 8], routeIndex: 0, mods: { speedMul: 0 } });
h.runUntil(() => e.stats.attacks >= 7, 60);
h.run(1); // the 7th shot lands
const fx = h.eventsOf('fx').filter((f) => f[1] === 'zone' && f[4].kind === 'pollution');
assert.equal(fx.length, 3, `three zones in seven attacks (${fx.length})`);
for (const f of fx) { assert.equal(f[4].r, 1.7, 'radius 1.7 (PRTS)'); assert.equal(f[4].dur, E[key].skills[0].bb.projectile_life_time); }
const dmg = h.hooksOf('damaged').filter((c) => c.source === e);
const atk = e.s.atk;
const hits = dmg.filter((c) => c.dmg.isAttack && c.target === h.unit('t_wall')).map((c) => c.amount / atk);
assert.deepEqual(hits.slice(0, 7).map((x) => Math.round(x * 100) / 100), [1, 1, 1, 1, 1, 1, 1], 'unblocked: 100 % per attack');
const poll = dmg.filter((c) => (c.dmg.tags || []).includes('pollution'));
assert.ok(poll.length > 0 && poll.every((c) => c.type === 'true'), '真实伤害');
const lo = poll.filter((c) => c.target === h.unit('t_wall')), hi = poll.filter((c) => c.target === h.unit('t_hi'));
assert.ok(lo.length > 5 && lo.every((c) => Math.abs(c.amount - 50) < 1e-6), 'low ground 50 / s');
assert.ok(hi.length > 5 && hi.every((c) => Math.abs(c.amount - 25) < 1e-6), 'high ground 25 / s (it never targets the high ground)');
assert.equal(dmg.filter((c) => c.dmg.isAttack && c.target === h.unit('t_hi')).length, 0);
// the first zone is up before the second attack
const t1 = h.hooksOf('attack').filter((c) => c.attacker === e)[1].t;
assert.ok(lo.some((c) => c.t < t1 + 1e-6), 'polluting from the first attack on');
});
}
test('reach: its 2.2 range circle takes an operator whose 0.25 collider touches it (PRTS 作战机制 §碰撞体积) — 2.45 from the centre', () => {
// user follow-up (2026-10-01): "远程攻击射程挺远、伤害很高". The data range stays 2.2; the official target search
// uses the operators' colliders, which the remake measured from the centre (2.2 reached 13 tiles around it, 2.45 21)
assert.equal(ALLY_COLLIDER_RADIUS, 0.25);
assert.equal(E.enemy_1272_nhtank.stats.rangeRadius, 2.2);
const hitsOn = (r, c) => {
const h = arena({ units: [{ chessId: 't_wall', row: r, col: c }], captureNoisy: true, hooks: ['damaged'] });
h.step();
const e = h.spawn('enemy_1272_nhtank', { pos: [10, 5], routeIndex: 0, mods: { speedMul: 0 } });
h.run(20);
return h.hooksOf('damaged').filter((x) => x.source === e && x.dmg.isAttack).length;
};
assert.ok(hitsOn(10, 7) >= 4, 'd 2.0: in range');
assert.ok(hitsOn(11, 7) >= 4, 'd 2.236 > 2.2: in range through the collider (2.45)');
assert.ok(hitsOn(12, 6) >= 4, 'd 2.236 (the other diagonal)');
assert.equal(hitsOn(12, 7), 0, 'd 2.83 > 2.45: out of range');
assert.equal(hitsOn(10, 8), 0, 'd 3.0: out of range');
});
test('blocked: normal attacks hit ×2 (近战), the skill attack 100 %; overlapping 污染秽蚀 do not stack', () => {
const h = arena({ units: [{ chessId: 't_wall', row: 9, col: 5 }], captureNoisy: true, hooks: ['damaged'] });
h.step();
const a = h.spawn('enemy_1272_nhtank', { pos: [9, 5.6], routeIndex: 0, mods: { speedMul: 0 } });
const b2 = h.spawn('enemy_1272_nhtank', { pos: [9, 5.7], routeIndex: 0, mods: { speedMul: 0 } });
h.runUntil(() => a.stats.attacks >= 4 && b2.stats.attacks >= 4, 60);
const w = h.unit('t_wall');
const blockedBy = (e) => h.hooksOf('damaged').filter((c) => c.source === e && c.dmg.isAttack && c.target === w).map((c) => Math.round((c.amount / e.s.atk) * 100) / 100);
const blocked = [a, b2].find((e) => e.blockedBy === w);
assert.ok(blocked, 'one of them is blocked');
assert.deepEqual(blockedBy(blocked).slice(0, 4), [1, 2, 2, 1], 'skill 100 %, melee 200 %, 200 %, skill 100 %');
// both tanks' zones cover the wall: one 50 per second, not two
const poll = h.hooksOf('damaged').filter((c) => c.target === w && (c.dmg.tags || []).includes('pollution'));
assert.ok(poll.length > 4);
for (let i = 1; i < poll.length; i++) assert.ok(poll[i].t - poll[i - 1].t > 1 - 1e-6, `one tick per second (${poll[i - 1].t.toFixed(2)} → ${poll[i].t.toFixed(2)})`);
});
});
+398
View File
@@ -0,0 +1,398 @@
// test/sim/playtest6-skills.test.js — user playtest #6, workstream WE (skill triggers and buffs), with the real data and kits.
// #15 "技能范围比攻击范围大的技能在敌人进入技能范围时不触发": PRTS 卫戍协议/帮助 §作战阶段 技能操作 — "携带拥有技能范围的技能
// (非攻击距离增加)的干员:不通过普通攻击/治疗触发技能,仅在技能范围内存在敌人(无视其不可选中)时释放技能" (SKILL_RANGE);
// an attack-range change keeps the basic strategy; the class rows (重装 "不受技能范围影响,受到伤害时释放技能" …) cover every
// MANUAL skill of the class; "自动操作具有3s冷却"; GDGLOW_SKILL_2 "全场存在可选目标时释放技能".
// #16 "古米的治疗队友的技能在身边队友受伤时并没触发,古米接敌之后才触发": 备用军粮 is 自动触发 (no 技能策略); PRTS 备注 "此技能
// 在存在生命值不满的可治疗角色时可触发;技能触发后古米将切换至治疗模式…直至古米完成一次普通攻击的治疗". Audit: 塞雷娅
// S1 "仅在周围有符合血量条件的友方单位时可触发,触发时会替换当次攻击" — checked at her attack (≤ half HP), no waiting.
// #8 "新约能天使2技能给队友套护盾好像没生效": PRTS 备注 "选择的友方干员为攻击范围内仇恨值最高的我方干员"; "获得的屏障均以
// 自身生命上限为标准计算;屏障每秒衰减量为:初始屏障量/30".
// #3 "忍冬的叙拉古阵营隐身不会结束" (friend): the 叙拉古 隐匿 lasts its bond time; her S3 迷彩 (ba.camou "不阻挡时不成为敌方
// 普通攻击的目标"; PRTS 异常效果: "与'阻挡时解除'没有直接关系") keeps every enemy but the one she blocks off her, as
// 隐匿 does, lasts until her next cast ("直至下一次开启技能"), is not 隐匿 and no longer merges with a timed 隐匿 (伪装服).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { getDefaultSource } from '../../server/sim/simdata.js';
import { absoluteRangeKeys, canTargetAlly } from '../../server/sim/targeting.js';
import { flagsOf } from '../../server/sim/snapshot.js';
import { AUTO_OP_COOLDOWN, COLS } from '../../server/sim/constants.js';
import { UF } from '../../shared/constants.js';
import { statusIconKey } from '../../public/js/render/style.js';
const ds = getDefaultSource();
const raw = (id) => ds.rawChess(id);
const skillOf = (id, skillId) => raw(id).skills.find((s) => s.skillId === skillId);
const approx = (a, b, msg = '', eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg} ${a} ≈ ${b}`);
const dummy = (key, o = {}) => enemyRec({ key, hp: 1e7, speed: 0, ...o });
const noGarrison = (...ids) => Object.fromEntries(ids.map((id) => [id, { ...raw(id), garrisonIds: [] }]));
const done = (h) => { checkInvariants(h.b); assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0])); };
const HOOKS = ['damaged', 'heal', 'skillStart', 'skillEnd', 'attack', 'statusApplied'];
const run = (o) => makeBattle({ seed: 3, autoFinish: false, timeLimit: 300, hooks: HOOKS, captureNoisy: true, ...o });
const starts = (h, u) => h.hooksOf('skillStart').filter((c) => c.unit === u);
const fill = (u) => u.skill.gainSp(u.skill.spCost * u.skill.maxCharges + 1, 'test');
// =================================================================================================================
// #15 skill ranges
test('#15 德克萨斯 S2 剑雨 (skill range 13 tiles, attack range 2): an enemy entering the skill range casts it — no attack, stealthed ones count', () => {
const id = 'chess_char_1_08_a';
const stealthy = dummy('enemy_sneak');
for (const sneak of [false, true]) {
const h = run({ defs: { enemies: { enemy_d: dummy('enemy_d'), enemy_sneak: stealthy }, chess: noGarrison(id) }, units: [{ chessId: id, row: 10, col: 4 }] });
const u = h.unit(id);
assert.equal(u.skill.id, 'skchr_texas_2');
assert.equal(u.skill.rule, 'SKILL_RANGE');
h.step();
fill(u);
h.run(1);
assert.equal(u.skill.activations, 0, 'nobody in the skill range');
// (10,6): two tiles ahead — inside 剑雨's range, outside her 2-tile attack range
const keys = new Set(absoluteRangeKeys(u.def.skill.rangeGrid, 10, 4, u.dir, 0));
assert.ok(keys.has(10 * COLS + 6) && !u.rangeKeySet.has(10 * COLS + 6));
const e = h.spawn(sneak ? 'enemy_sneak' : 'enemy_d', { pos: [10, 6] });
if (sneak) h.b.addBuff(e, { key: 'ab:stealth', flags: { stealth: true }, persist: true });
h.step(2);
assert.equal(u.skill.activations, 1, `cast at once${sneak ? ' (无视其不可选中: a stealthed enemy counts)' : ''}`);
assert.equal(h.hooksOf('attack').filter((c) => c.attacker === u).length, 0, 'without attacking');
done(h);
}
});
test('#15 audit: every MANUAL chess skill with its own 技能范围 casts with an enemy only on a skill-range tile (normal + elite)', () => {
const pool = ds.chessIds().filter((id) => (raw(id).skills || []).length); // hidden chess too (effects bring them)
let n = 0;
for (const id of pool) {
for (const s of raw(id).skills || []) {
if (s.trigger?.rule !== 'SKILL_RANGE') continue;
const h = run({ defs: { enemies: { enemy_d: dummy('enemy_d') }, chess: noGarrison(id) }, units: [{ chessId: id, row: 10, col: 5, skillIndex: s.index }] });
const u = h.unit(id);
assert.equal(u.skill.id, s.skillId);
assert.equal(u.skill.rule, 'SKILL_RANGE', `${id} ${s.skillId}`);
h.step();
const skill = absoluteRangeKeys(s.rangeGrid, 10, 5, u.dir, 0).filter((k) => { const r = Math.floor(k / COLS), c = k % COLS; return r >= 9 && r <= 12 && c >= 2 && c <= 10 && k !== 10 * COLS + 5; });
const outside = skill.filter((k) => !u.rangeKeySet.has(k));
const k = (outside.length ? outside : skill)[0];
assert.ok(k != null, `${id} ${s.skillId}: a field tile in the skill range`);
fill(u);
h.spawn('enemy_d', { pos: [Math.floor(k / COLS), k % COLS] });
h.run(0.5);
assert.ok(u.skill.activations >= 1, `${id} ${s.skillId} (${s.name}): cast by an enemy on its skill range${outside.length ? ' outside the attack range' : ''}`);
done(h);
n++;
}
}
assert.ok(n >= 20, `${n} SKILL_RANGE skills`);
});
test('#15 an attack-range change ("攻击范围扩大") keeps the basic strategy: 银灰 S3 waits for an enemy in his initial range', () => {
const id = 'chess_char_4_22_a';
const h = run({ defs: { enemies: { enemy_d: dummy('enemy_d') }, chess: noGarrison(id) }, units: [{ chessId: id, row: 10, col: 3 }] });
const u = h.unit(id);
assert.equal(u.skill.id, 'skchr_svrash_3');
assert.equal(u.skill.rule, 'DEFAULT');
h.step();
fill(u);
const far = absoluteRangeKeys(u.def.skill.rangeGrid, 10, 3, u.dir, 0).find((k) => !u.rangeKeySet.has(k) && Math.floor(k / COLS) === 10);
h.spawn('enemy_d', { pos: [10, far % COLS] });
h.run(1);
assert.equal(u.skill.activations, 0, 'an enemy only in the expanded range: no cast (PRTS "非攻击距离增加")');
h.spawn('enemy_d', { pos: [10, 4] });
assert.ok(h.runUntil(() => u.skill.activations === 1, 3), 'an enemy in the initial range: cast before the attack');
done(h);
});
test('#15 重装 "不受技能范围影响,受到伤害时释放技能": 瑕光 S3 ignores enemies in its skill range until she is hit', () => {
const id = 'chess_char_3_12_a';
const h = run({ defs: { enemies: { enemy_d: dummy('enemy_d'), enemy_hit: dummy('enemy_hit', { atk: 200, bat: 1 }) }, chess: noGarrison(id) }, units: [{ chessId: id, row: 10, col: 4 }] });
const u = h.unit(id);
assert.equal(u.skill.id, 'skchr_blemsh_3');
assert.equal(u.skill.rule, 'TAKE_DAMAGE');
h.step();
fill(u);
h.spawn('enemy_d', { pos: [10, 5] });
h.run(2);
assert.equal(u.skill.activations, 0, 'enemies in range, attacked by her, but she is not hit');
h.spawn('enemy_hit', { pos: [10, 4] });
assert.ok(h.runUntil(() => u.skill.activations === 1, 3), 'the first hit casts it');
assert.equal(starts(h, u)[0].reason, 'TAKE_DAMAGE');
done(h);
});
test('#15 the automatic operations cool down 3 s: 锏 S2 (2 charges, skill range) spends its charges 3 s apart', () => {
const id = 'chess_char_6_19_b';
const s = skillOf(id, 'skchr_blkkgt_2');
const h = run({ defs: { enemies: { enemy_d: dummy('enemy_d') }, chess: noGarrison(id) }, units: [{ chessId: id, row: 10, col: 4, skillIndex: s.index }], enemies: [{ key: 'enemy_d', pos: [10, 5] }] });
const u = h.unit(id);
assert.equal(u.skill.maxCharges, 2);
h.step();
fill(u);
assert.ok(h.runUntil(() => u.skill.activations === 1, 1));
const t1 = h.b.time;
h.run(AUTO_OP_COOLDOWN - 0.2);
assert.equal(u.skill.activations, 1, 'the stored charge waits');
assert.ok(h.runUntil(() => u.skill.activations === 2, 1));
assert.ok(h.b.time - t1 >= AUTO_OP_COOLDOWN - 1e-6);
done(h);
});
test('#15 "作战开始时部署的单位将进入冷却": a MANUAL skill ready at the battle start waits 3 s after the initial deployment (the engine default); an AUTO one does not', () => {
const rec = (id, skillType) => chessRec({ id, profession: 'SUPPORT', subProfessionId: 'bard', stats: { atk: 0 }, skill: { skillType, spCost: 10, initSp: 10, durationType: 'NONE', duration: 5, trigger: { rule: 'SP_FULL' } } });
// flags.startOpCooldown undefined = the Battle default the matches use (the harness otherwise turns it off)
const h = run({ flags: { startOpCooldown: undefined }, defs: { chess: { t_man: rec('t_man', 'MANUAL'), t_auto: rec('t_auto', 'AUTO') } },
units: [{ chessId: 't_man', row: 10, col: 3 }, { chessId: 't_auto', row: 10, col: 6 }] });
assert.equal(h.b.flags.startOpCooldown, AUTO_OP_COOLDOWN);
const man = h.unit('t_man'), auto = h.unit('t_auto');
h.step();
assert.ok(man.skill.ready && man.skill.rule === 'SP_FULL');
assert.equal(auto.skill.activations, 1, 'AUTO: at once');
assert.ok(h.runUntil(() => man.skill.activations === 1, 5));
assert.ok(h.b.time >= AUTO_OP_COOLDOWN - 1e-6, `MANUAL: not before ${AUTO_OP_COOLDOWN} s (${h.b.time})`);
done(h);
});
test('#15 GDGLOW_SKILL_2 "全场存在可选目标时释放技能": 荒芜拉普兰德 S3 casts with an enemy anywhere on the field, 纯烬艾雅法拉 S3 with an injured ally anywhere', () => {
const w = 'chess_char_6_18_a';
let h = run({ defs: { enemies: { enemy_d: dummy('enemy_d') }, chess: noGarrison(w) }, units: [{ chessId: w, row: 12, col: 2 }] });
let u = h.unit(w);
assert.equal(u.skill.rule, 'GDGLOW_SKILL_2');
h.step();
fill(u);
h.run(1);
assert.equal(u.skill.activations, 0, 'no enemy');
h.spawn('enemy_d', { pos: [9, 10] }); // far outside her range
assert.ok(!u.rangeKeySet.has(9 * COLS + 10));
h.step(2);
assert.equal(u.skill.activations, 1, 'an enemy anywhere');
done(h);
const a = 'chess_char_6_20_a';
const mate = chessRec({ id: 't_mate', profession: 'WARRIOR', skill: null, stats: { maxHp: 10000 } });
h = run({ defs: { chess: { ...noGarrison(a), t_mate: mate } }, units: [{ chessId: a, row: 12, col: 2 }, { chessId: 't_mate', row: 9, col: 10 }] });
u = h.unit(a);
assert.equal(u.skill.rule, 'GDGLOW_SKILL_2');
h.step();
fill(u);
h.run(1);
assert.equal(u.skill.activations, 0, 'nobody to heal');
h.unit('t_mate').hp = 5000;
h.step(2);
assert.equal(u.skill.activations, 1, 'an injured ally anywhere');
done(h);
});
// =================================================================================================================
// #16 古米 / 塞雷娅 heal skills
test('#16 古米 备用军粮: an injured ally next to her casts it with no enemy on the field; her next attack heals that ally (normal + elite)', () => {
for (const id of ['chess_char_1_10_a', 'chess_char_1_10_b']) {
const mate = chessRec({ id: 't_mate', profession: 'WARRIOR', skill: null, stats: { maxHp: 10000, atk: 0 } });
const h = run({ defs: { chess: { ...noGarrison(id), t_mate: mate } }, units: [{ chessId: id, row: 10, col: 4 }, { chessId: 't_mate', row: 11, col: 5 }] });
const u = h.unit(id), m = h.unit('t_mate');
assert.equal(u.skill.id, 'skchr_sunbr_1');
h.step();
fill(u);
h.run(2);
assert.equal(u.skill.activations, 0, 'nobody injured');
m.hp = 4000;
assert.ok(h.runUntil(() => h.hooksOf('heal').some((c) => c.source === u && c.target === m), 2), 'healed without any enemy (before: only once she was hit)');
const heal = h.hooksOf('heal').find((c) => c.source === u && c.target === m);
const tb = raw(id).trait.bb;
const k = tb.hp_ratio != null && 0.4 < tb.hp_ratio ? tb.heal_scale : 1;
approx(heal.amount, u.s.atk * u.def.skill.bb.heal_scale * k, `${id} heal_scale × ATK`);
assert.equal(starts(h, u)[0].reason, 'SKILL_RANGE');
done(h);
}
});
test('#16 古米 备用军粮 with an enemy blocked: the cast turns her next attack into the heal (the enemy is not hit by it); no second charge on the same heal', () => {
const id = 'chess_char_1_10_b';
const mate = chessRec({ id: 't_mate', profession: 'WARRIOR', skill: null, stats: { maxHp: 10000, atk: 0 } });
const h = run({ defs: { enemies: { enemy_d: dummy('enemy_d') }, chess: { ...noGarrison(id), t_mate: mate, t_mate2: { ...mate, chessId: 't_mate2', baseId: 't_mate2' } } },
units: [{ chessId: id, row: 10, col: 4 }, { chessId: 't_mate', row: 11, col: 4 }, { chessId: 't_mate2', row: 9, col: 4 }], enemies: [{ key: 'enemy_d', pos: [10, 4] }] });
const u = h.unit(id), m = h.unit('t_mate'), m2 = h.unit('t_mate2');
h.step();
assert.equal(u.skill.maxCharges, 2);
u.atkCd = 1.5; // the heal waits for her next attack
fill(u);
m.hp = 3000;
h.run(1);
assert.equal(starts(h, u).length, 1, 'one charge cast');
assert.equal(u.skill.charges, 1, 'the other stays stored while the heal waits');
const hits0 = h.hooksOf('damaged').filter((c) => c.source === u).length;
assert.ok(h.runUntil(() => h.hooksOf('heal').some((c) => c.source === u && c.target === m), 2));
assert.equal(h.hooksOf('damaged').filter((c) => c.source === u).length, hits0, 'that attack was the heal, not a hit');
m2.hp = 3000;
assert.ok(h.runUntil(() => h.hooksOf('heal').some((c) => c.source === u && c.target === m2), 5), 'the stored charge heals the next injured ally');
done(h);
});
test('#16 塞雷娅 S1 急救 (audit): checked at her attack — an ally of her area at ≤ half HP turns that attack into the heal; healed back meanwhile ⇒ she just attacks', () => {
// PRTS 备注 "此技能仅在周围有符合血量条件的友方单位时可触发,触发时会替换当次攻击"; text corrected to "血量小于等于一半"
const id = 'chess_char_5_11_a';
const s = skillOf(id, 'skchr_demkni_1');
const mate = chessRec({ id: 't_mate', profession: 'WARRIOR', skill: null, stats: { maxHp: 10000, atk: 0, blockCnt: 0 } });
const setup = (withEnemy) => {
const h = run({ defs: { enemies: { enemy_d: dummy('enemy_d') }, chess: { ...noGarrison(id), t_mate: mate } },
units: [{ chessId: id, row: 10, col: 4, skillIndex: s.index }, { chessId: 't_mate', row: 10, col: 5 }],
enemies: withEnemy ? [{ key: 'enemy_d', pos: [10, 4] }] : [] });
h.step();
return [h, h.unit(id), h.unit('t_mate')];
};
let [h, u, m] = setup(false);
assert.equal(u.skill.rule, 'DEFAULT');
fill(u);
m.hp = 4000;
h.run(2);
assert.equal(u.skill.activations, 0, 'no enemy, no attack: no cast');
done(h);
[h, u, m] = setup(true);
const e = h.enemies()[0];
const hits = () => h.hooksOf('damaged').filter((c) => c.source === u && c.target === e).length;
fill(u);
m.hp = 6000;
assert.ok(h.runUntil(() => hits() >= 2, 5), '60 %: she attacks the enemy she blocks');
assert.equal(u.skill.activations, 0, 'and casts nothing');
// an ally at exactly half HP: her next attack is the heal (the enemy is not hit by it)
u.atkCd = 0.5;
m.hp = 5000;
const hits0 = hits();
assert.ok(h.runUntil(() => u.skill.activations === 1, 2));
assert.equal(starts(h, u)[0].reason, 'DEFAULT');
const heal = h.hooksOf('heal').find((c) => c.source === u && c.target === m);
assert.ok(heal, 'healed at once, in place of that attack');
approx(heal.amount, u.s.atk * s.bb.heal_scale);
assert.equal(hits(), hits0, 'that attack hit nobody');
assert.ok(!u.skill.pending);
// dropped to 40 %, healed back to 80 % before her attack: no cast, she keeps attacking (no heal mode left waiting)
fill(u);
u.atkCd = 1;
m.hp = 4000;
h.run(0.5);
m.hp = 8000;
const hits1 = hits();
h.run(4);
assert.equal(u.skill.activations, 1, 'no second cast');
assert.ok(hits() >= hits1 + 3, `she kept hitting the enemy (${hits() - hits1} hits in 4 s)`);
// the engine's safety net: a cast whose ally condition fails before the attack is withdrawn, its charge returned
m.hp = 4000;
u.atkCd = 5;
assert.ok(u.skill.activate('test'));
assert.ok(u.skill.pending && u.skill.charges === 0);
m.hp = 10000;
h.step();
assert.ok(!u.skill.pending && u.skill.charges === 1, 'withdrawn, the charge kept');
done(h);
});
// =================================================================================================================
// #8 新约能天使 S2
test('#8 新约能天使 S2 开火成瘾症: the ally is the highest-仇恨值 operator of her range (taunt, then the latest deployed); both shields = 180 % of their own max HP, −initial/30 per s', () => {
const id = 'chess_char_6_13_a';
const mate = (key, o = {}) => chessRec({ id: key, profession: 'SNIPER', skill: null, stats: { maxHp: 3000, atk: 0, aspd: 100, ...o } });
// rows deploy top first (row 11 before row 10): t_back (11,4) comes before t_front (10,5); both are in her range
const setup = (taunt) => {
const h = run({
defs: { enemies: { enemy_d: dummy('enemy_d') }, chess: { ...noGarrison(id), t_back: mate('t_back', { aspd: 150 }), t_front: mate('t_front', { maxHp: 5000 }) } },
units: [{ chessId: id, row: 10, col: 3 }, { chessId: 't_back', row: 11, col: 4 }, { chessId: 't_front', row: 10, col: 5 }],
enemies: [{ key: 'enemy_d', pos: [10, 6] }],
});
const u = h.unit(id);
h.step();
if (taunt) h.b.addBuff(h.unit('t_back'), { key: 'test:taunt', mods: { taunt: 1 }, persist: true });
fill(u);
assert.ok(h.runUntil(() => u.skill.active, 3));
return h;
};
let h = setup(false);
const u = h.unit(id), back = h.unit('t_back'), front = h.unit('t_front');
const bb = u.def.skill.bb;
assert.ok(front.aggroSeq > back.aggroSeq);
assert.ok(front.findBuff('angel2:stolen') && !back.findBuff('angel2:stolen'), 'the latest deployed (not the fastest attacker) is chosen');
approx(front.s.shield, front.s.maxHp * bb.shield_max_hp_ratio, 'her shield: 180 % of HER max HP');
approx(u.s.shield, u.s.maxHp * bb.shield_max_hp_ratio, 'his own: of his');
assert.ok(flagsOf(front) & UF.SHIELD, 'the ally shows the shield (b.snap flag: the HP-bar shield line)');
const st = h.eventsOf('status').find((e) => e[1] === front.id && e[2] === 'angel2:barrier' && e[3] === 1);
assert.ok(st && statusIconKey(st[2]) === 'shield', 'and the shield icon in its status row');
const s0 = front.s.shield;
h.run(3);
approx(front.s.shield, s0 * (1 - 3 / bb.shield_max_duration), 'decays by initial/30 per second', 0.02);
h.b.dealDamage(h.enemies()[0], front, { amount: 1000, type: 'true' });
assert.equal(front.hp, front.s.maxHp, 'the shield absorbs the damage');
done(h);
h = setup(true);
assert.ok(h.unit('t_back').findBuff('angel2:stolen'), 'a higher taunt level comes first');
done(h);
});
// =================================================================================================================
// #3 忍冬: 叙拉古 隐匿 and her S3 迷彩
test('#3 忍冬 with 6 叙拉古: the bond 隐匿 ends at 32 + 0.4 × layers s; her S3 迷彩 is not 隐匿, keeps all but the enemy she blocks off her, until the next cast', () => {
const id = 'chess_char_3_18_a', L = 20;
const h = run({
defs: { enemies: { enemy_f: dummy('enemy_f', { hp: 1 }), enemy_b: dummy('enemy_b'), enemy_r: dummy('enemy_r', { atk: 50, range: 3, applyWay: 'RANGED', bat: 1 }) }, chess: noGarrison(id) },
units: [{ chessId: id, row: 10, col: 4 }], bonds: { siracusaShip: { count: 6, active: true, tier: 2, layers: L } },
});
const u = h.unit(id);
h.step();
assert.ok(u.s.flags.stealth, '叙拉古 6: 隐匿 after deployment');
// a kill during 隐狐之艺 ⇒ 迷彩 at its end
fill(u);
h.spawn('enemy_f', { pos: [10, 5] });
assert.ok(h.runUntil(() => u.skill.active, 5));
h.runUntil(() => !u.skill.active, 20);
assert.ok(u.s.flags.camou, '迷彩 after the skill (a kill during it)');
h.runUntil(() => h.b.time >= 32 + 0.4 * L + 0.1, 60);
assert.ok(!u.s.flags.stealth, 'the bond 隐匿 is over at its time');
assert.ok(u.s.flags.camou && !u.blocking.length);
assert.ok(flagsOf(u) & UF.STEALTH, 'the 迷彩 shows (see-through)');
const r = h.spawn('enemy_r', { pos: [11, 6] });
assert.equal(canTargetAlly(r, u, true), false, 'and works (no ranged target)');
// an enemy she blocks: only that one may attack her — 迷彩, like 隐匿, is not lifted by blocking (PRTS 异常效果)
u.skill.charges = 0; u.skill.sp = 0; // (her SP is full again by now: no new cast on that enemy yet)
const b = h.spawn('enemy_b', { pos: [10, 4] });
h.step(2);
assert.equal(b.blockedBy, u);
assert.ok(flagsOf(u) & UF.STEALTH, 'blocking: still shown camouflaged');
assert.equal(canTargetAlly(b, u, true), true, 'the enemy she blocks may target her');
assert.equal(canTargetAlly(r, u, true), false, 'the ranged enemy still may not');
h.run(3);
assert.equal(h.hooksOf('damaged').filter((c) => c.source === r && c.target === u).length, 0, 'and does not');
// it lasts until her next cast
fill(u);
assert.ok(h.runUntil(() => u.skill.activations === 2, 5));
assert.ok(!u.s.flags.camou, 'the next cast ends it');
done(h);
});
test('#3 伪装服 (timed 隐匿) on 忍冬 is neither made permanent nor stripped by her S3 迷彩 (their keys used to collide)', () => {
const id = 'chess_char_3_18_a';
const item = 'chess_item_4_04_e_a';
const h = run({
defs: { enemies: { enemy_f: dummy('enemy_f', { hp: 1 }) }, chess: noGarrison(id) },
units: [{ chessId: id, row: 10, col: 4, items: [item] }],
});
const u = h.unit(id);
h.step();
fill(u);
h.spawn('enemy_f', { pos: [10, 5] });
assert.ok(h.runUntil(() => u.skill.active, 5));
h.runUntil(() => !u.skill.active, 20);
assert.ok(u.s.flags.camou);
h.b.dealDamage(null, u, { amount: 10, type: 'true' }); // 伪装服: the first damage taken ⇒ 隐匿 for its duration
const cloak = u.findBuff('stealth');
assert.ok(cloak && Number.isFinite(cloak.timeLeft) && u.s.flags.stealth, '伪装服 隐匿, timed');
h.run(cloak.timeLeft + 0.2);
assert.ok(!u.s.flags.stealth, 'the 隐匿 ends at its time although the 迷彩 lasts');
assert.ok(u.s.flags.camou);
// and her next cast removes the 迷彩 only (a fresh 伪装服 隐匿 would stay: another key)
h.b.applyStatus(u, 'stealth', { duration: 5, source: u });
fill(u);
h.spawn('enemy_f', { pos: [10, 5] });
assert.ok(h.runUntil(() => u.skill.activations === 2, 5));
assert.ok(!u.s.flags.camou && u.s.flags.stealth, 'the cast strips the 迷彩, not a 隐匿');
done(h);
});
+150
View File
@@ -0,0 +1,150 @@
// User playtest #6 item 5 — "图中的boss没有血量了但是不死,还在战斗,直到超时我们的生命被扣完才结束,但是最后的判定仍是胜利"
// (绝境 co-op Final Assault on act2autochess_m01, client-side combat: the HUD read 0.0 %, the leader kept fighting until
// the overtime drain took the team LP, and the result still said 胜利).
//
// Cause (reproduced in real matches, test/match/playtest6-bosspool.test.js): the shared pool's float arithmetic. The
// server subtracts each field's reported damage per player (Match._creditBoss); the pieces add up to the report only
// in exact arithmetic, so the server pool can keep float dust (e.g. 40000 − 12345.6 − 7654.3 − 20000.1 = 3.6e-12). The
// browser's LocalBossPool shows `server hp − (cum − acked)`: with 3.6e-12 left, every hit adds 3.6e-12 to a cumulative
// counter of 40000 — less than half its ulp, so `cum` never changes, the pool never reaches 0, the leader never dies
// and nothing more is reported. The same absorption stalls a killing hit whose `cum + rest` rounds down.
//
// Rule now (server/sim/constants.js BOSS_POOL_MIN_HP): a shared pool holding less than 1 HP is empty — the hit that
// would leave less takes the rest (SharedBossPool, LocalBossPool), and a pool reading below 1 HP is 0 (LocalBossPool),
// so a leader whose pool shows 0 is dead on every field at once and the fight ends.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { LocalBossPool, buildBattleSpec, createBattleFromSpec } from '../../server/sim/spec.js';
import { SharedBossPool, CreditPool } from '../../server/match/finalAssault.js';
import * as SIM_CONST from '../../server/sim/constants.js';
import { GameData } from '../../server/match/gamedata.js';
import { buildBossWave, setupMatchWaves } from '../../server/match/waves.js';
import { createRng } from '../../server/sim/rng.js';
import { GEO } from '../../shared/constants.js';
import { getData } from '../../server/data.js';
const { BOSS_POOL_MIN_HP } = SIM_CONST;
const DATA = getData({ log: { warn() {}, error() {}, info() {} } });
/** The stalled state a real 2-human 绝境 match reached (boss_2 at 5 % pool, seed 7): server hp, acked = local cum. */
const STALL = Object.freeze({ max: 40000, serverHp: 3.637978807091713e-12, cum: 40000 });
test('BOSS_POOL_MIN_HP is 1 (HP counts in whole points on every display)', () => {
assert.equal(BOSS_POOL_MIN_HP, 1);
});
test('SharedBossPool: per-player credits that add up to the pool leave no float dust; a hit leaving < 1 HP takes the rest', () => {
const p = new SharedBossPool(40000);
assert.equal(p.damage('p_0', 12345.6), 12345.6);
assert.equal(p.damage('p_1', 7654.3), 7654.3);
p.damage('p_0', 20000.1); // 40000 − 12345.6 − 7654.3 − 20000.1 = 3.637978807091713e-12 in doubles
assert.equal(p.hp, 0, 'empty, not 3.6e-12');
assert.equal(p.damage('p_1', 5), 0, 'nothing more to deal');
const q = new SharedBossPool(1000);
q.damage('p_0', 998.7);
assert.ok(Math.abs(q.hp - 1.3) < 1e-9, '1.3 HP is a living leader');
assert.ok(Math.abs(q.damage('p_1', 0.5) - 1.3) < 1e-9, 'the hit that would leave 0.8 HP takes the rest (credited to it)');
assert.equal(q.hp, 0);
assert.ok(Math.abs(q.byPlayer.get('p_1') - 1.3) < 1e-9);
// a takeover's crediting wrapper goes through the same rule
const r = new SharedBossPool(500);
r.damage('p_0', 100); // what the client reported before the server took its field over
const cp = new CreditPool(r, { acked: 100 });
cp.damage('p_0', 100); // re-simulated damage the client already reported: nothing new
assert.equal(r.hp, 400);
cp.damage('p_0', 399.5);
assert.equal(r.hp, 0, '0.5 HP left is empty');
});
test('LocalBossPool: the stalled state of the real match reads 0 HP (dead), not float dust', () => {
const pool = new LocalBossPool(STALL.max);
pool.cum = STALL.cum;
pool.byPlayer = { p_0: 25000, p_1: 15000 };
pool.sync(STALL.serverHp, STALL.cum);
assert.equal(pool.hp, 0, 'less than 1 HP left = empty');
assert.equal(pool.damage('p_0', 5000), 0);
assert.equal(pool.cum, STALL.cum);
});
test('LocalBossPool: a killing hit empties the pool whatever the float alignment of server hp and local damage', () => {
let stuck = 0;
for (let k = 0; k < 400; k++) {
const p = new LocalBossPool(1800000);
p.damage('p_0', 1799000.123 + k * 0.0137);
const A = p.cum;
// a server remnant whose granularity differs from the local counter's (the other field / per-player crediting)
p.sync(1800000 - A + (k % 7) * 1.1e-13, A);
for (let hits = 0; p.hp > 0 && hits < 50; hits++) p.damage('p_1', 5000);
if (p.hp !== 0) stuck++;
}
assert.equal(stuck, 0, 'no pool stays alive on float dust');
// below 1 HP reads 0; the rest of a normal pool is untouched
const p = new LocalBossPool(100);
p.damage('p_0', 99.2);
assert.equal(p.hp, 0, '0.8 HP left reads 0');
const q = new LocalBossPool(100);
q.damage('p_0', 98.5);
assert.ok(Math.abs(q.hp - 1.5) < 1e-9);
assert.equal(q.damage('p_0', 0.7), 1.5, 'the hit that would leave 0.8 HP takes the rest');
assert.equal(q.hp, 0);
});
// ---------------------------------------------------------------------------------------------------------------
// every leader, on a real boss field (co-op pair template, 绝境)
const gd = new GameData(DATA, 'mode_multi_hard');
const setup = setupMatchWaves(gd, createRng(3));
const PLAYERS = [
{ playerId: 'p_0', seat: 0, side: 'L', colOffset: 0, units: [], bonds: {}, playerEffects: [] },
{ playerId: 'p_1', seat: 1, side: 'R', colOffset: 8, units: [], bonds: {}, playerEffects: [] },
];
const BOSSES = ['boss_1', 'boss_2', 'boss_3', 'boss_4', 'boss_5', 'boss_6', 'boss_7', 'boss_8', 'boss_9', 'boss_10'];
function bossBattle(bossId, sharedBoss) {
const wave = buildBossWave(gd, createRng(1), setup.factions, 14, { bossId, solo: false });
const max = sharedBoss ? sharedBoss.maxHp : gd.boss(bossId).bloodPoint.HARD;
const spec = buildBattleSpec({
battleId: `t.${bossId}`, fieldId: 'b1', kind: 'boss', seed: 11, modeId: 'mode_multi_hard', round: 14, stageId: 'act2autochess_m01',
rect: { ...GEO.BOSS_RECT }, timeLimit: null, players: PLAYERS, spawns: wave.spawns.filter((s) => s && gd.enemy(s.enemyKey)),
routes: wave.routes, flags: { layerGainsEnabled: false, ...gd.dp }, enemyOverrides: wave.overrides, waveId: wave.templateId,
bossId, boss: { poolHp: max, poolMax: max },
});
const b = createBattleFromSpec(spec, undefined, { sharedBoss, recordEvents: false, quiet: true });
for (let i = 0; i < 30 * 90 && !b.enemies.some((e) => e.alive && e.isBoss); i++) b.step();
const leaders = b.enemies.filter((e) => e.alive && e.isBoss);
assert.ok(leaders.length > 0, `${bossId}: the leader spawned`);
return { b, leaders, spec };
}
for (const bossId of BOSSES) {
test(`${bossId}: client field — a pool left with float dust means a dead leader at once and a 'cleared' field`, () => {
const { b, spec } = bossBattle(bossId, null);
const pool = b.sharedBoss;
assert.ok(pool instanceof LocalBossPool, 'the browser pool');
// the real stalled state: everything this field dealt is acknowledged, the server holds 3.6e-12
pool.cum = spec.boss.poolMax;
pool.byPlayer = { p_0: spec.boss.poolMax };
pool.sync(STALL.serverHp, pool.cum);
const leader = b.enemies.find((e) => e.alive && e.isBoss);
b.dealDamage(null, leader, { amount: 5000, type: 'true' }); // an operator's hit
for (let i = 0; i < 3 && !b.finished; i++) b.step();
assert.equal(b.finished, true, `${bossId}: the field ended`);
assert.equal(b.reason, 'cleared');
assert.deepEqual(b.enemies.filter((e) => e.alive && e.isBoss).map((e) => e.defId), [], 'every copy of the leader is down');
assert.equal(b.snapshot().boss.hp, 0);
});
test(`${bossId}: server field — the hit that would leave less than 1 HP kills the leader at once`, () => {
const max = gd.boss(bossId).bloodPoint.HARD;
const pool = new SharedBossPool(max);
const { b } = bossBattle(bossId, pool);
const leader = b.enemies.find((e) => e.alive && e.isBoss);
const dealt = b.dealDamage(null, leader, { amount: 1e9, type: 'true', mul: (max - 0.4) / 1e9 });
assert.ok(dealt > max - 1, `${bossId}: the hit took the whole pool (${dealt} of ${max})`);
assert.equal(pool.hp, 0, 'not 0.4 HP: nobody would ever see it and no hit may be needed to finish it');
for (let i = 0; i < 3 && !b.finished; i++) b.step();
assert.equal(b.finished, true);
assert.equal(b.reason, 'cleared');
assert.deepEqual(b.enemies.filter((e) => e.alive && e.isBoss).map((e) => e.defId), []);
});
}
+108
View File
@@ -0,0 +1,108 @@
// test/sim/playtest6_elemgauge.test.js — user playtest #6 report 11, sim side: "干员血条右边的元素损伤条积累速度不对,损伤条
// 远远没扣完但是实际已经爆条受到伤害了". The gauge a unit shows (damage.js elementView → b.snap `elem`) must be exactly
// the gauge that bursts (PRTS 元素: "以白条显示剩余的元素值", the shown element = 当前损伤元素, lowest 元素值 > element id):
// · every tick, the shown element is the one with the least 元素值 left, and the shown remainder (1 − fill) is the
// sim's remainder rounded UP to 1 % — never less than what is left, so the bar is never empty before the burst;
// · the element that bursts is the one shown the tick before (one source), and the burst shows as its 爆发冷却;
// · real 海嗣 against a real operator (古米): 底海滑动者 (15 % ATK 神经 per hit) and 掠海漂移体 (50 % ATK 侵蚀 per hit,
// × the round's ATK multiplier: a chunk of the gauge per hit) with 骨海漂流体 (20 % 侵蚀).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle } from '../helpers/battleHarness.js';
import { elementView } from '../../server/sim/damage.js';
import { ELEMENT_ORDER } from '../../server/sim/constants.js';
const OP = 'chess_char_1_10_a'; // 古米 (T1 defender, real kit)
/** A real operator with real 海嗣 pinned in contact; returns the harness and the operator. */
function arena(keys, mods = {}) {
const h = makeBattle({ units: [{ chessId: OP, row: 10, col: 5 }], seed: 3, autoFinish: false, timeLimit: 600 });
h.step();
const pos = [[10, 5.6], [10, 4.4], [10.6, 5]];
keys.forEach((k, i) => h.spawn(k, { pos: pos[i], routeIndex: 0, mods: { speedMul: 0, hpMul: 1000, ...mods } }));
return { h, u: h.allies()[0] };
}
/** The sim's own view of the gauge: the element with the least 元素值 left (ties: official id) and that remainder. */
function simGauge(u) {
let best = null, bv = 0;
for (const k of ELEMENT_ORDER) if (u.elem[k] > bv) { bv = u.elem[k]; best = k; }
return best ? { el: best, left: 1 - bv / u.gaugeMax } : null;
}
/** Run until `bursts` bursts of the operator happened, checking the shown gauge against the sim's every tick. */
function followGauge(h, u, bursts, maxSeconds = 200) {
const seen = [];
let prevShown = null;
h.b.on('elementBurst', (c) => { if (c.target === u) seen.push({ el: c.element, shownBefore: prevShown }); });
for (let i = 0; i < maxSeconds * 30 && seen.length < bursts; i++) {
const snap = h.b.snapshot();
const entry = (snap.elem || []).find((x) => x[0] === u.id) || null;
const g = simGauge(u);
const locked = !!u.s.flags.burstLock;
if (!locked) {
if (!g) assert.equal(entry, null, `t=${h.b.time}: no gauge, nothing shown`);
else {
assert.ok(entry, `t=${h.b.time}: a gauge (${g.el} ${g.left}) is shown`);
assert.equal(entry[1], g.el, `t=${h.b.time}: the shown element is the one with the least 元素值 left`);
const shownLeft = 1 - entry[2];
assert.ok(shownLeft >= g.left - 1e-9, `t=${h.b.time}: shown remainder ${shownLeft} is never below the real ${g.left}`);
assert.ok(shownLeft - g.left < 0.01 + 1e-9, `t=${h.b.time}: shown remainder ${shownLeft} within 1 % of the real ${g.left}`);
assert.ok(shownLeft > 0, `t=${h.b.time}: the bar is not empty before the burst`);
assert.equal(entry[3], 0, 'no cooldown numbers outside a burst');
}
} else {
assert.equal(entry[2], 1, 'the burst shows full with its cooldown');
assert.ok(entry[3] > h.b.time && entry[4] > 0);
}
prevShown = entry && !locked ? { el: entry[1], left: +(1 - entry[2]).toFixed(2) } : null;
h.step();
if (u.alive) u.hp = u.s.maxHp; // keep the operator standing (only its gauge matters)
assert.ok(u.alive, 'the operator stays on the field');
}
return seen;
}
test('底海滑动者 on 古米: the shown gauge is the sim\'s gauge every tick; the burst comes as the bar runs out', () => {
const { h, u } = arena(['enemy_1148_dssbr']);
const seen = followGauge(h, u, 2);
assert.equal(seen.length, 2, 'two 神经 bursts');
for (const s of seen) {
assert.equal(s.el, 'neural');
assert.equal(s.shownBefore.el, 'neural', 'the burst is the element shown the tick before');
assert.ok(s.shownBefore.left <= 0.05, `a steady 42-per-hit source bursts off a sliver (${s.shownBefore.left} left)`);
}
});
test('掠海漂移体 (a chunk per hit, × the round\'s ATK) with 骨海漂流体 on 古米: shown = sim every tick, bursts off the shown element', () => {
const { h, u } = arena(['enemy_2025_syufo', 'enemy_2021_syfish'], { atkMul: 1.771561 }); // 绝境 multi R11
const seen = followGauge(h, u, 2);
assert.equal(seen.length, 2);
for (const s of seen) {
assert.equal(s.el, 'erosion');
assert.equal(s.shownBefore.el, 'erosion');
// one 掠海漂移体 hit = 500 × 1.77 × 0.5 ≈ 443 of 1000: officially the bar drops by ~44 % per hit, so a burst can
// come off a bar that still shows up to that much — the sim and the bar agree on it
assert.ok(s.shownBefore.left <= 0.45, `${s.shownBefore.left} left before the burst ≤ one hit`);
}
});
test('a gauge a few 元素值 short of bursting still shows a sliver (the remainder rounds up, never to an empty bar)', () => {
const { h, u } = arena([]);
h.b.dealDamage(null, u, { type: 'element', element: 'neural', amount: 996 });
assert.equal(u.s.flags.burstLock, undefined, 'no burst yet');
const v = elementView(u, h.b.time);
assert.equal(v[0], 'neural');
assert.ok(v[1] < 1, `fill ${v[1]} < 1: 4 元素值 are left`);
assert.equal(v[1], 0.99);
h.b.dealDamage(null, u, { type: 'element', element: 'neural', amount: 3 });
assert.equal(elementView(u, h.b.time)[1], 0.99, '1 元素值 left still shows');
h.b.dealDamage(null, u, { type: 'element', element: 'neural', amount: 1 });
assert.ok(u.s.flags.burstLock, 'the last point bursts');
assert.deepEqual(elementView(u, h.b.time).slice(0, 2), ['neural', 1], 'now it shows the 爆发冷却');
// a tiny first hit shows at least 1 %
const { h: h2, u: u2 } = arena([]);
h2.b.dealDamage(null, u2, { type: 'element', element: 'burn', amount: 3 });
assert.equal(elementView(u2, h2.b.time)[1], 0.01);
});
+7 -3
View File
@@ -131,7 +131,7 @@ test('ammo skills end when ammo runs out; ammoUsed fires per shot; no SP gain wh
assert.equal(h.hooksOf('skillEnd')[0].reason, 'ammo');
});
test('charges accumulate up to maxCharges and are spent one per cast', () => {
test('charges accumulate up to maxCharges and are spent one per cast, 3 s apart (the automatic operation cooldown)', () => {
const h = makeBattle({
defs: { chess: { t_sn: sniperWith({ duration: 0, spCost: 2, initSp: 0, maxChargeTime: 3, bb: { atk_scale: 3 } }) }, enemies: { enemy_dummy: dummy() } },
units: [{ chessId: 't_sn', row: 10, col: 4 }], content: 'generic', autoFinish: false,
@@ -143,10 +143,14 @@ test('charges accumulate up to maxCharges and are spent one per cast', () => {
assert.equal(u.skill.sp, 2);
h.spawn('enemy_dummy', { pos: [10, 6] });
h.step(1);
const t1 = h.b.time;
assert.equal(u.skill.charges, 2);
assert.equal(u.skill.sp, 0, 'SP bar restarts after using a full stack');
h.run(1.05);
assert.ok(u.skill.activations >= 2);
// PRTS 卫戍协议/帮助 技能操作 "自动操作具有3s冷却": a MANUAL skill's next automatic cast comes ≥ 3 s later
h.run(2.8);
assert.equal(u.skill.activations, 1, 'not within 3 s although she attacks every second');
assert.ok(h.runUntil(() => u.skill.activations >= 2, 2));
assert.ok(h.b.time - t1 >= 3 - 1e-6);
});
test('instant skills with an attack override apply to exactly one attack', () => {
+2 -2
View File
@@ -133,8 +133,8 @@ describe('in-match UI (mock harness, headless Chrome)', { skip: !ENABLED && 'set
await page.keyboard.press('Space');
await sleep(300);
assert.equal((await mockState(page)).ready, false);
// detail + Esc
await page.click('.scard .scard__detail');
// detail + Esc: the first tap on a card opens its detail (there is no ⓘ corner — user playtest #6 item 10)
await page.click('.shopbar__cards .scard:not(.scard--sold)');
await page.waitForSelector('.dpanel');
await page.keyboard.press('Escape');
await sleep(200);
+10 -10
View File
@@ -130,7 +130,7 @@ describe('2: the own LP drops live while the battle runs', () => {
const r = liveLp(null, { phase, round: 3, lp: 40, leaks: 9 });
assert.deepEqual([r.pending, r.shown, r.base], [0, 40, null], phase);
}
assert.deepEqual(liveLp(null, { phase: PHASE.COMBAT, round: 3, lp: undefined, leaks: 2 }), { base: null, pending: 0, shown: null, unite: false });
assert.deepEqual(liveLp(null, { phase: PHASE.COMBAT, round: 3, lp: undefined, leaks: 2 }), { base: null, pending: 0, shown: null, unite: false, left: null });
assert.match(pendingTip(30, 3), /结算时扣除 3 点/);
assert.match(pendingTip(30, 10, { unite: true }), /联防中/);
assert.equal(pendingTip(30, 0), null);
@@ -138,16 +138,16 @@ describe('2: the own LP drops live while the battle runs', () => {
test('team panel rows: the own row takes the top bar\'s live value, a teammate\'s m.public players[].pendingLp; only in COMBAT / 联防', () => {
const pub = { phase: PHASE.COMBAT };
assert.deepEqual(rowLp({ lp: 30, pendingLp: 3 }, pub), { lp: 30, pending: 3, unite: false });
assert.deepEqual(rowLp({ lp: 2, pendingLp: 3 }, pub), { lp: 2, pending: 2, unite: false }, 'clamped at the LP left');
assert.deepEqual(rowLp({ lp: 30 }, pub), { lp: 30, pending: 0, unite: false });
assert.deepEqual(rowLp({ lp: 30, pendingLp: 4 }, { phase: PHASE.UNITE }), { lp: 30, pending: 4, unite: true });
assert.deepEqual(rowLp({ lp: 30, pendingLp: 4 }, { phase: PHASE.SETTLE }), { lp: 30, pending: 0, unite: false });
assert.deepEqual(rowLp({ lp: 30, pendingLp: 4, alive: false }, pub), { lp: 30, pending: 0, unite: false });
assert.deepEqual(rowLp({ lp: 30, pendingLp: 3 }, pub), { lp: 30, pending: 3, unite: false, left: null });
assert.deepEqual(rowLp({ lp: 2, pendingLp: 3 }, pub), { lp: 2, pending: 2, unite: false, left: null }, 'clamped at the LP left');
assert.deepEqual(rowLp({ lp: 30 }, pub), { lp: 30, pending: 0, unite: false, left: null });
assert.deepEqual(rowLp({ lp: 30, pendingLp: 4 }, { phase: PHASE.UNITE }), { lp: 30, pending: 4, unite: true, left: null });
assert.deepEqual(rowLp({ lp: 30, pendingLp: 4 }, { phase: PHASE.SETTLE }), { lp: 30, pending: 0, unite: false, left: null });
assert.deepEqual(rowLp({ lp: 30, pendingLp: 4, alive: false }, pub), { lp: 30, pending: 0, unite: false, left: null });
// the own row: the top bar's numbers (m.private lp — m.public may lag behind it by a throttle interval)
assert.deepEqual(rowLp({ lp: 30, pendingLp: 1 }, pub, { lp: 30, pending: 5, unite: false }), { lp: 30, pending: 5, unite: false });
assert.deepEqual(rowLp({ lp: 30 }, pub, { lp: 25, pending: 0, unite: false }), { lp: 25, pending: 0, unite: false }, 'settled m.private first');
assert.deepEqual(rowLp({ lp: 30 }, { phase: PHASE.FINAL_ASSAULT }, { lp: 99, pending: 5 }), { lp: 30, pending: 0, unite: false }, 'boss rounds: the row keeps its own share');
assert.deepEqual(rowLp({ lp: 30, pendingLp: 1 }, pub, { lp: 30, pending: 5, unite: false }), { lp: 30, pending: 5, unite: false, left: null });
assert.deepEqual(rowLp({ lp: 30 }, pub, { lp: 25, pending: 0, unite: false }), { lp: 25, pending: 0, unite: false, left: null }, 'settled m.private first');
assert.deepEqual(rowLp({ lp: 30 }, { phase: PHASE.FINAL_ASSAULT }, { lp: 99, pending: 5 }), { lp: 30, pending: 0, unite: false, left: null }, 'boss rounds: the row keeps its own share');
});
test('LP tower: lp − pending in red with a −N tick keyed by the pending value (it pops again on every leak), the 联防中 tag', () => {
+57
View File
@@ -0,0 +1,57 @@
// User playtest #6 item 5 (HUD part) — the boss bar read "0.0%" while the leader kept fighting. The leader's death is a
// sim / match fix (test/match/playtest6-bosspool.test.js: a pool never holds less than 1 HP, so 0 on the HUD means the
// leader is down); the bar itself now (ui/gameLogic.js bossPctText, ui/hud.js PhaseCapsule)
// * never reads 0.0 % while the leader has HP left (a sliver reads "<0.1%"),
// * shows the live pool of the battle on screen (its snapshot: the local simulation's own damage on top of b.pool)
// before the ~1 Hz m.public.bossHp, which lagged the leader's death.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { PHASE } from '../../shared/constants.js';
const { bossPctText, bossFrac } = await import('../../public/js/ui/gameLogic.js');
const { PhaseCapsule } = await import('../../public/js/ui/hud.js');
/** The text of a vnode tree in document order. */
const textOf = (v) => {
if (v == null || typeof v === 'boolean') return '';
if (typeof v === 'string' || typeof v === 'number') return String(v);
if (Array.isArray(v)) return v.map(textOf).join('');
return typeof v === 'object' ? textOf(v.props?.children) : '';
};
function* walk(v) {
if (Array.isArray(v)) { for (const x of v) yield* walk(x); return; }
if (!v || typeof v !== 'object') return;
yield v;
yield* walk(v.props?.children);
}
const barText = (vnode) => {
for (const v of walk(vnode)) if (typeof v.props?.class === 'string' && v.props.class.includes('bossbar__txt')) return textOf(v);
return null;
};
test('bossPctText: whole percents from 10 %, one decimal below, never 0.0 % for a living leader', () => {
assert.equal(bossPctText(1), '100%');
assert.equal(bossPctText(0.456), '46%');
assert.equal(bossPctText(0.1), '10%');
assert.equal(bossPctText(0.0934), '9.3%');
assert.equal(bossPctText(0.001), '0.1%');
assert.equal(bossPctText(800 / 1800000), '<0.1%', 'the user\'s screenshot: a leader a few hundred HP from death');
assert.equal(bossPctText(1 / 3600000), '<0.1%', 'the last HP point of the largest pool');
assert.equal(bossPctText(0), '0.0%', 'only a leader that is down reads 0.0 %');
assert.equal(bossPctText(null), null);
assert.equal(bossPctText(NaN), null);
assert.equal(bossPctText(bossFrac({ hp: 1, max: 1800000 })), '<0.1%');
});
test('PhaseCapsule: the live pool of the battle on screen wins over the ~1 Hz m.public value', () => {
const pub = { phase: PHASE.FINAL_ASSAULT, bossHp: { hp: 900, max: 1800000 } };
// the local battle already saw the leader die (its snapshot pool is 0): the bar says so at once
assert.equal(barText(PhaseCapsule({ pub, hud: { killed: 15, total: 20, boss: { hp: 0, max: 1800000 } } })), '0.0%');
// a living leader never reads 0.0 %
assert.equal(barText(PhaseCapsule({ pub, hud: { killed: 15, total: 20, boss: { hp: 700, max: 1800000 } } })), '<0.1%');
// no snapshot yet (the battle is loading): m.public
assert.equal(barText(PhaseCapsule({ pub, hud: null })), '0.1%', '900 of 1 800 000 = 0.05 %');
assert.equal(barText(PhaseCapsule({ pub: { phase: PHASE.FINAL_ASSAULT, bossHp: { hp: 900000, max: 1800000 } }, hud: { killed: 1, total: 20, boss: null } })), '50%');
assert.equal(barText(PhaseCapsule({ pub: { phase: PHASE.FINAL_ASSAULT }, hud: null })), '敌方领袖');
});
+55
View File
@@ -0,0 +1,55 @@
// User playtest #6 item 4 (workstream WC), the client side: a 悬赏决策 card shows how many battles its enemies come for
// the way the official card does — "下场作战" / "两场作战" in blue, a multi-round card's "每场" in red (activity_table
// effectDesc rich text) — instead of plain text. A real draft (server/match/choices.js generateDraft → cardView), through
// m.public's normalisation (ui/gameLogic.js normalizeSp), resolved by the overlay (ui/choiceOverlay.js resolveSpCard).
// The server side is in test/match/playtest6-matchflow.test.js.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { DATA } from '../match/harness.js';
import { GameData } from '../../server/match/gamedata.js';
import { generateDraft, cardView } from '../../server/match/choices.js';
import { createRng } from '../../server/sim/rng.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
// the browser data store reads the real data files from disk
globalThis.fetch = async (url) => {
const name = String(url).split('/').pop();
try {
const body = readFileSync(path.join(ROOT, 'data', name), 'utf8');
return { ok: true, status: 200, json: async () => JSON.parse(body) };
} catch {
return { ok: false, status: 404, json: async () => ({}) };
}
};
const { data } = await import('../../public/js/data.js');
const { resolveSpCard } = await import('../../public/js/ui/choiceOverlay.js');
const { normalizeSp } = await import('../../public/js/ui/gameLogic.js');
const { parseRichText } = await import('../../public/js/ui/richText.js');
test('#4 the draft overlay shows each bounty card\'s battles in the official colours (blue 下场 / 两场; a multi-round card reads 两场 too)', async () => {
await data.loadAll('choices', 'effects', 'items', 'assets', 'enemies');
const gd = new GameData(DATA, 'mode_multi_hard');
// the official multi-round cards: two battles, in the two-battle cards' blue (the user's call after playtest #6)
const MULTI = new Set(DATA.choices.cards.bounty.filter((c) => c.multiRound).map((c) => c.effectId));
const seen = new Set();
for (let seed = 1; seed <= 40 && seen.size < 3; seed++) {
const d = generateDraft(gd, createRng(seed * 977 + 3), 3, { stageId: 'act2autochess_m02' });
assert.equal(d.family, 'bounty', 'co-op 绝境 R3 is a bounty draft');
const sp = normalizeSp({ family: d.family, cards: d.cards.map(cardView), order: ['p_0'], turn: 'p_0', picks: {}, taken: {} }, [{ playerId: 'p_0' }]);
for (const card of sp.cards) {
const view = resolveSpCard(card, sp.family);
const segs = parseRichText(view.desc).filter((s) => s.text);
const styled = (cls) => segs.filter((s) => s.cls.includes(cls)).map((s) => s.text).join('|');
assert.ok(!styled('ba.vdown').includes('每场'), `${card.id} ${view.name}: no red 每场 (${view.desc})`);
if (card.rounds === 2) assert.ok(styled('ba.vup').includes('两场作战'), `${card.id} ${view.name}: blue 两场作战 (${view.desc})`);
else assert.match(styled('ba.vup'), /下场(作战|战斗)/, `${card.id} ${view.name}: blue 下场 (${view.desc})`);
seen.add(MULTI.has(card.id) ? 'multi' : card.rounds);
}
}
assert.deepEqual([...seen].map(String).sort(), ['1', '2', 'multi'], 'drafts with 1-, 2-battle and multi-round cards were checked');
});
+136
View File
@@ -0,0 +1,136 @@
// Real-server browser E2E of user playtest #6 items 1 and 2 (from a friend): 凯瑟琳's device and 赫默's drone could not be
// placed by hand. A solo prep (test/e2e/fastServer.mjs hook SP_START_ROUND 2 + SP_START_CHESS: 古米, 凯瑟琳, 赫默 in the
// hand) is driven with real mouse drags: the three operators go onto the board through the direction wheel, their
// summon cards appear in the hand (赫默's 医疗探机 ×1, 凯瑟琳's 支援装置 ×2), the drone is dropped on a free tile and a
// device next to 古米 facing it — then the battle starts with both on the field: the device fights from the start and
// the placed drone deploys once at the battle start (PRTS 卫戍协议/帮助 §作战阶段; settled by the user after playtest #6 —
// shared/constants.js SKILL_SUMMON_START_DEPLOY), then comes back with each S2 (sim/content/tokens.js). Zero console
// errors.
//
// SP_E2E=1 node --test test/ui/playtest6-summons.e2e.test.js
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { Client, ROOT, sleep, hasChrome, startRealServer, problemsOf } from '../e2e/client.mjs';
const ENABLED = process.env.SP_E2E === '1' && hasChrome() && existsSync(path.join(ROOT, 'public/assets'));
const GUMMY = 'chess_char_1_10_a', CATHY = 'chess_char_4_11_a', SILENCE = 'chess_char_2_02_a';
const DRONE = 'token_10000_silent_healrb', DEVICE = 'token_10041_cathy_catsld';
async function soloToPrep(c) {
await c.open();
await c.enter('煌');
await c.click('.mode-card', '独立模拟');
await c.click('.diff-card', '险境');
await c.click('.create-box button', '开始独立模拟');
await c.waitFor((s) => !!s.room, 'solo room');
if (!(await c.st()).phase) await c.click('.room-bar__right button', '开始模拟', { timeout: 20000 });
await c.waitFor((s) => s.phase === 'INFO_CHECK', 'briefing', 30000);
await c.click('.brief__foot .btn--primary', '准备就绪');
await c.waitFor((s) => s.phase === 'BAND_DRAFT', 'band draft', 30000);
await c.click('.dband', null, { nth: 1 });
await c.click('.draft-detail__btns .btn--primary', '确认选择');
await c.waitFor((x) => x.phase === 'PREP' && !x.ready, 'prep', 60000);
await sleep(1800);
}
/** Drag hand piece `uid` onto (row, col) and pick `dir` on the wheel; returns the board piece. */
async function place(c, uid, tile, dir) {
const from = await c.piecePoint(uid);
const to = await c.tilePoint(tile.row, tile.col);
assert.ok(from && to, `piece ${uid} and tile ${tile.row},${tile.col} on screen`);
await c.drag(from, to);
await c.page.waitForSelector('.fwheel__dia', { timeout: 4000 });
await c.swipe(dir);
await sleep(500);
}
const handOf = (c, id) => c.page.evaluate((id) => (globalThis.__SP__.store.get().match.private?.hand || []).find((p) => p && p.id === id) || null, id);
const boardOf = (c, id) => c.page.evaluate((id) => (globalThis.__SP__.store.get().match.private?.board || []).filter((p) => p.id === id), id);
/**
* A free deploy tile orthogonally next to one of the board's operators and the direction that faces it from there
* (board coordinates): { row, col, dir } or null.
*/
function spotNextToOperator(c) {
return c.page.evaluate(() => {
const { store, data } = globalThis.__SP__;
const s = store.get();
const st = data.lookup('stages', s.match.public.stageId);
const dt = st.deployTiles.normal;
const ok = new Set([...dt.rangedOnly, ...dt.melee].map(([a, b]) => `${a},${b}`));
const used = new Set(s.match.private.board.map((p) => `${p.row},${p.col}`));
for (const op of s.match.private.board.filter((p) => p.kind === 'chess')) {
for (const [dr, dc, dir] of [[0, -1, 'RIGHT'], [1, 0, 'DOWN'], [-1, 0, 'UP'], [0, 1, 'LEFT']]) {
const k = `${op.row + dr},${op.col + dc}`;
if (ok.has(k) && !used.has(k)) return { row: op.row + dr, col: op.col + dc, dir };
}
}
return null;
});
}
describe('user playtest #6: summons placed by hand (real server, headless Chrome)', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome + assets) to run' }, () => {
let puppeteer = null;
test('赫默\'s drone and 凯瑟琳\'s devices are hand cards, placed with the wheel; both are on the field at the battle start', { timeout: 240000 }, async () => {
puppeteer = puppeteer || (await import('puppeteer-core')).default;
const srv = await startRealServer({ fast: { timerScale: 0.5, combatSpeed: 4, startRound: 2, chess: [GUMMY, CATHY, SILENCE] } });
const c = new Client(puppeteer, srv.base, 'summons', { prefix: 'pt6' });
try {
await soloToPrep(c);
await c.hookRequests();
// the three operators onto the board (melee first: 古米 and 凯瑟琳 take melee tiles)
for (const id of [GUMMY, CATHY, SILENCE]) {
const p = await handOf(c, id);
assert.ok(p, `${id} in the starter kit`);
const tile = await c.freeTileFor(p.uid);
await place(c, p.uid, tile, 'RIGHT');
assert.equal((await boardOf(c, id)).length, 1, `${id} placed`);
}
// their summon cards
const drone = await handOf(c, DRONE);
const dev = await handOf(c, DEVICE);
assert.ok(drone, '赫默\'s 医疗探机 card is in the hand');
assert.ok(dev && dev.count === 2, `凯瑟琳's 支援装置 card ×2 (${dev && dev.count})`);
await c.shot('hand-cards');
// the drone on a free tile, facing UP
const dt = await c.freeTileFor(drone.uid, { prefer: 'back' });
await place(c, drone.uid, dt, 'UP');
const [dp] = await boardOf(c, DRONE);
assert.ok(dp && dp.row === dt.row && dp.col === dt.col && dp.dir === 'UP', `drone placed (${JSON.stringify(dp)})`);
// a device next to an operator, facing it
const spot = await spotNextToOperator(c);
assert.ok(spot, 'a free tile next to an operator');
await place(c, dev.uid, spot, spot.dir);
const devs = await boardOf(c, DEVICE);
assert.equal(devs.length, 1);
assert.deepEqual([devs[0].row, devs[0].col, devs[0].dir], [spot.row, spot.col, spot.dir], 'device placed facing the operator');
assert.equal((await handOf(c, DEVICE))?.count, 1, 'one device left on the card');
assert.ok((await c.requests('g.move')).length >= 5, 'every placement went through g.move');
// the rest of the stack (one device) is drawn back on its bench slot at once (not left over the placed copy)
await sleep(450);
const slot = await c.page.evaluate((id) => (globalThis.__SP__.store.get().match.private?.hand || []).findIndex((p) => p && p.id === id), DEVICE);
const home = await c.tilePoint(7, slot);
const at = await c.piecePoint(dev.uid, 0.5);
assert.ok(home && at && Math.hypot(at.x - home.x, at.y - home.y) < 90, `the device card is back on its bench slot (${JSON.stringify({ home, at })})`);
await c.shot('placed');
// the battle: the device deploys with the board, and so does the placed drone, once (then with each S2)
await c.click('.readybtn');
await c.waitFor((s) => s.phase === 'COMBAT', 'combat', 30000);
// the battle views (render/app.js: unit id → view with its UnitInfo) once the board has deployed
const onField = () => c.page.evaluate(() => [...(globalThis.__SP_VIEW__?.raw?.debug?.views?.values() || [])].filter((v) => v?.info && v.alive !== false).map((v) => v.info.defId));
await c.page.waitForFunction((id) => [...(globalThis.__SP_VIEW__?.raw?.debug?.views?.values() || [])].some((v) => v?.info?.defId === id), { timeout: 30000 }, GUMMY);
await sleep(600);
const start = await onField();
assert.ok(start.includes(DEVICE), `the device is on the field from the start (${start.join(', ')})`);
assert.ok(start.includes(DRONE), `the placed drone deploys once at the battle start (${start.join(', ')})`);
await c.shot('battle');
assert.deepEqual(problemsOf([c]), []);
} finally {
await c.close();
await srv.stop();
}
});
});
+233
View File
@@ -0,0 +1,233 @@
// Browser regressions of user playtest #6, UI items 6 / 10, through the mock harness (public/dev/game-mock.html) in
// headless Chrome (puppeteer-core + system Chrome). Opt-in: SP_E2E=1.
//
// SP_E2E=1 node --test test/ui/playtest6-ui.e2e.test.js
//
// 6 every 机变 card shows its whole effect text inside the card: all 51 shop items (道具补给 / 机密商店), all 129
// bounty and all 43 tactic cards, at the phone landscape viewports (the smallest supported 640×360, 667×375,
// 720×360, the user's Android 756×366, 800×360, 844×390, 932×430) and at 1280×720 / 1920×1080 — the text is not
// clamped, ends inside the card's padding, and is at least 7.5 px (phones) / .19rem (16:9); every card is measured
// once taken (the taker's round avatar at its top-right) and once not, and a taken card's name and tags never run
// under the badge — measured with the page's web font Noto Sans SC when Google Fonts can be reached (QA: with it
// 海沟实验体 took 8 lines at 640×360). v2.4.1 at 756×366: 0.2 lines of it were visible (screenshot 2).
// 10 a tap anywhere on the armed shop card (operator and item) buys, the 确认购买 strip included, on touch at
// 640×360 / 756×366 / 800×360 / 844×390 / 932×430 and with the mouse at 1920×1080: every point of a 12 × 12 grid over the
// armed card hit-tests to the card, and real taps on the strip's centre / right end and the card's bottom-right
// buy. v2.4.1: 120 of 144 points (the ⓘ corner's hidden 44 px touch area over the bottom-right quarter) — the strip's
// centre did not buy; at 640×360 the left-docked detail card opened by the first tap also covered the top quarter of
// the first cards (84 / 96 of 144).
// Screenshots: test/e2e/out/p6-*.png.
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const OUT = path.join(ROOT, 'test/e2e/out');
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const ENABLED = process.env.SP_E2E === '1' && existsSync(CHROME);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/** name, width, height, touch */
const PHONES = [['756x366', 756, 366, true], ['800x360', 800, 360, true], ['844x390', 844, 390, true], ['932x430', 932, 430, true]];
/** narrower phones (text only): the smallest supported phone (DESIGN §10, devices.e2e) and the iPhone SE / 8 */
const NARROW = [['640x360', 640, 360, true], ['667x375', 667, 375, true], ['720x360', 720, 360, true]];
/** two pick maps over the 4 mock players (p1 = me): every one of 6 cards is taken in one pass and free in the other */
const TAKEN = [{ p4: 0, p3: 1, ai_2: 2, p1: 3 }, { p4: 4, p3: 5 }];
const DESKTOP = [['1280x720', 1280, 720, false], ['1920x1080', 1920, 1080, false]];
/** public/index.html's Google Fonts stylesheet */
const WEB_FONTS = 'https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;700;900&family=Oxanium:wght@400;500;600;700&family=Rajdhani:wght@500;600;700&display=swap';
describe('user playtest #6 — UI items 6 / 10 (mock harness, headless Chrome)', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome) to run' }, () => {
let srv;
let browser;
let base;
before(async () => {
const { startServer } = await import('../../server/index.js');
const puppeteer = (await import('puppeteer-core')).default;
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
base = `http://127.0.0.1:${srv.port}`;
browser = await puppeteer.launch({ executablePath: CHROME, headless: 'new', args: ['--no-sandbox'] });
mkdirSync(OUT, { recursive: true });
});
after(async () => {
await browser?.close();
await srv?.close();
});
async function open(query, { w, h, touch, webFont = false }) {
const page = await browser.newPage();
await page.setViewport({ width: w, height: h, deviceScaleFactor: 1, isMobile: touch, hasTouch: touch });
const problems = [];
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
await page.goto(`${base}/dev/game-mock.html?shot=1&${query}`, { waitUntil: 'networkidle0' });
await page.waitForFunction(() => !!document.querySelector('.screen:not(.gload)'), { timeout: 20000 });
// the real page's web font (index.html loads it from Google Fonts; Android's CJK font has its metrics): the mock has
// no <link> for it, and the system fallback wraps differently (QA: 海沟实验体 took 8 lines at 640×360 with it) —
// measured with it whenever it can be fetched
const noto = webFont && await Promise.race([
page.addStyleTag({ url: WEB_FONTS }).then(() => page.evaluate(async () => {
await Promise.all(['400', '700', '900'].map((wt) => document.fonts.load(`${wt} 12px "Noto Sans SC"`, '获得伤害减免')));
return document.fonts.check('12px "Noto Sans SC"', '获得');
})).catch(() => false),
sleep(8000).then(() => false),
]);
await page.evaluate(() => document.fonts.ready);
await sleep(900);
return { page, problems, noto };
}
/** Each 机变 card's text: clamped?, bottom beyond the card's padding (px), font size; taken?, name / tags under the badge? */
const cardTexts = (page) => page.evaluate(() => [...document.querySelectorAll('.spcard')].map((c) => {
const r = c.getBoundingClientRect();
const d = c.querySelector('.spcard__desc');
if (!d) return { name: c.querySelector('.spcard__name')?.textContent, missing: true };
const dr = d.getBoundingClientRect();
const cs = getComputedStyle(d);
const badge = c.querySelector('.spcard__taker')?.getBoundingClientRect();
let under = false;
for (const el of badge ? c.querySelectorAll('.spcard__name, .spcard__tag') : []) {
const range = document.createRange();
range.selectNodeContents(el);
for (const a of [...range.getClientRects(), el.getBoundingClientRect()]) {
if (a.right > badge.left + 0.5 && a.left < badge.right - 0.5 && a.bottom > badge.top + 0.5 && a.top < badge.bottom - 0.5) under = true;
}
}
return { name: c.querySelector('.spcard__name')?.textContent, text: d.textContent.length, clamped: d.scrollHeight > d.clientHeight + 1,
beyond: dr.bottom - (r.bottom - parseFloat(getComputedStyle(c).paddingBottom)), font: parseFloat(cs.fontSize), rem: parseFloat(getComputedStyle(document.documentElement).fontSize),
taken: !!badge, under };
}));
test('6: every 机变 card shows its whole effect text inside the card, taken or not, on phones and on desktop', { timeout: 15 * 60 * 1000 }, async () => {
const report = [];
for (const [name, w, h, touch] of [...NARROW, ...PHONES, ...DESKTOP]) {
const { page, problems, noto } = await open('phase=SP_DRAFT&variant=supply', { w, h, touch, webFont: true });
if (name === '756x366' || name === '640x360') await page.screenshot({ path: path.join(OUT, `p6-supply-${name}.png`) });
const sets = await page.evaluate(async () => {
const { data } = await import('/js/data.js');
const ch = data.get('choices');
return {
supply: data.list('items').filter((i) => !i.isGolden && i.itemType === 'EQUIP' && !i.shopExcluded).map((i) => ({ itemId: i.id })),
bounty: ch.cards.bounty.map((b) => ({ effectId: b.effectId })),
tactic: ch.cards.tactic.map((t) => ({ effectId: t.effectId })),
};
});
for (const [family, list] of Object.entries(sets)) {
let n = 0;
let taken = 0;
for (let i = 0; i < list.length; i += 6) {
for (const picks of TAKEN) {
await page.evaluate((cards, family, picks) => {
const S = globalThis.__MOCK__.S();
S.pub.sp = { ...S.pub.sp, family, cards, picks };
globalThis.__MOCK__.pushPublic();
}, list.slice(i, i + 6), family, picks);
await sleep(120);
for (const t of await cardTexts(page)) {
n++;
if (t.taken) taken++;
const tag = `${name} ${family} ${t.name}${t.taken ? ' (taken)' : ''}`;
assert.ok(!t.missing && t.text > 0, `${tag}: an effect text`);
assert.ok(!t.clamped, `${tag}: the whole text (not clamped)`);
assert.ok(t.beyond <= 0.5, `${tag}: inside the card (${t.beyond.toFixed(1)} px beyond its padding)`);
assert.ok(touch ? t.font >= 7.5 : t.font >= 0.19 * t.rem - 0.01, `${tag}: ${t.font} px`);
assert.ok(!t.under, `${tag}: the name and tags keep clear of the taker's badge`);
}
}
}
assert.equal(n, list.length * TAKEN.length, `${name} ${family}: every card rendered`);
assert.equal(taken, list.length, `${name} ${family}: every card measured taken once`);
}
report.push(`${name}: ${Object.values(sets).reduce((a, l) => a + l.length, 0)} texts in full, taken or not (${noto ? 'Noto Sans SC' : 'system font — Google Fonts unreachable'})`);
assert.deepEqual(noto ? problems : problems.filter((x) => !/Failed to load resource/.test(x)), [], name);
await page.close();
}
console.log(report.join('\n'));
});
test('6: the taken-by badge and the two-tap strip stay on the new card (756×366)', async () => {
const { page, problems } = await open('phase=SP_DRAFT&variant=supply', { w: 756, h: 366, touch: true });
assert.ok(await page.$('.spcard.is-taken .spcard__taker'), 'the taker\'s badge');
const tap = async (sel) => {
const r = await page.$eval(sel, (el) => { const b = el.getBoundingClientRect(); return { x: b.left + b.width / 2, y: b.bottom - 4 }; });
await page.touchscreen.tap(r.x, r.y);
await sleep(350);
};
await tap('.spcard.is-pickable');
assert.ok(await page.$('.spcard.is-armed .spcard__confirm'), 'first tap: selected, 确认选择 strip');
await page.screenshot({ path: path.join(OUT, 'p6-supply-armed-756x366.png') });
await tap('.spcard.is-armed'); // on the strip
await page.waitForSelector('.spcard.is-mine', { timeout: 3000 });
assert.deepEqual(problems, []);
await page.close();
});
/** Arm the first buyable card matching `sel`, then hit-test a 12 × 12 grid over it: a point counts when the tap target
* there (the nearest button / role=button) is the armed card itself — not an inner control such as the old ⓘ, and not
* the detail card its first tap opened. */
async function armAndProbe(page, sel, touch) {
const tapAt = async (x, y) => { if (touch) await page.touchscreen.tap(x, y); else await page.mouse.click(x, y); await sleep(400); };
const b = await page.$eval(sel, (el) => { const r = el.getBoundingClientRect(); return { x: r.left, y: r.top, w: r.width, h: r.height }; });
await tapAt(b.x + b.w * 0.85, b.y + b.h * 0.85); // the old ⓘ corner: arms like the rest of the card
return page.evaluate(() => {
const a = document.querySelector('.scard.is-armed');
if (!a) return null;
const r = a.getBoundingClientRect();
const N = 12;
let hit = 0;
for (let j = 0; j < N; j++) for (let i = 0; i < N; i++) {
const el = document.elementFromPoint(r.left + (i + 0.5) * r.width / N, r.top + (j + 0.5) * r.height / N);
if (el && el.closest('button, [role="button"], a, input') === a) hit++;
}
const s = a.querySelector('.scard__confirm')?.getBoundingClientRect();
return { hit, n: N * N, box: { x: r.left, y: r.top, w: r.width, h: r.height }, strip: s && { x: s.left, y: s.top, w: s.width, h: s.height } };
});
}
test('10: a tap anywhere on the armed shop card buys — the 确认购买 strip included (touch on phones, mouse on desktop)', { timeout: 10 * 60 * 1000 }, async () => {
const report = [];
const SPOTS = ['strip centre', 'strip right', 'card bottom-right'];
for (const [name, w, h, touch] of [NARROW[0], ...PHONES, DESKTOP[1]]) {
const { page, problems } = await open('phase=PREP', { w, h, touch });
await page.evaluate(() => globalThis.__MOCK__.mutate((S) => { S.priv.funds = 99; S.priv.hand = S.priv.hand.map(() => null); }));
await sleep(200);
for (const [kind, sel] of [['operator', '.shopbar__cards .scard:not(.scard--sold):not(.is-disabled)'], ['item', '.shopbar__item .scard:not(.scard--sold):not(.is-disabled)']]) {
for (const spot of SPOTS) {
// a fresh card for every spot: the next operator card; the item slot comes back with a refresh (R)
if (!(await page.$(sel))) { await page.keyboard.press('KeyR'); await sleep(350); }
const slotIdx = await page.evaluate((s) => {
const el = document.querySelector(s);
const S = globalThis.__MOCK__.S();
const isItem = !!el.closest('.shopbar__item');
const cards = [...document.querySelectorAll(isItem ? '.shopbar__item .scard' : '.shopbar__cards .scard')];
const slots = S.priv.shop.slots.map((x, i) => ({ x, i })).filter(({ x }) => (isItem ? x && x.kind === 'item' : !x || x.kind !== 'item'));
return slots[cards.indexOf(el)]?.i ?? null;
}, sel);
assert.ok(Number.isInteger(slotIdx), `${name} ${kind}: a card to buy`);
const probe = await armAndProbe(page, sel, touch);
assert.ok(probe, `${name} ${kind}: the card is armed by a tap on its bottom-right`);
assert.equal(probe.hit, probe.n, `${name} ${kind}: ${probe.hit}/${probe.n} points of the armed card are the card`);
const funds0 = await page.evaluate(() => globalThis.__MOCK__.S().priv.funds);
const { box, strip } = probe;
const at = spot === 'strip centre' ? [strip.x + strip.w / 2, strip.y + strip.h / 2]
: spot === 'strip right' ? [strip.x + strip.w * 0.9, strip.y + strip.h * 0.6] : [box.x + box.w * 0.94, box.y + box.h * 0.94];
if (touch) await page.touchscreen.tap(at[0], at[1]); else await page.mouse.click(at[0], at[1]);
await sleep(450);
const after = await page.evaluate((i) => ({ funds: globalThis.__MOCK__.S().priv.funds, sold: !!globalThis.__MOCK__.S().priv.shop.slots[i]?.sold }), slotIdx);
assert.ok(after.sold && after.funds < funds0, `${name} ${kind}: a ${touch ? 'tap' : 'click'} on the ${spot} buys`);
if (spot === 'strip centre' && kind === 'operator') report.push(`${name}: ${probe.hit}/${probe.n}`);
await page.evaluate(() => globalThis.__MOCK__.mutate((S) => { S.priv.funds = 99; S.priv.hand = S.priv.hand.map(() => null); }));
await sleep(150);
}
}
assert.deepEqual(problems, [], name);
await page.close();
}
console.log(report.join('\n'));
});
});
+207
View File
@@ -0,0 +1,207 @@
// Regression tests (Node) of user playtest #6, client-UI items (workstream WD):
// 6 the 机变 道具补给 overlay showed only icon, name and tier on each card — no effect text (the user's Android,
// 756×366 CSS px: the description sat below the fold of a 105 px card and was clipped). The cards now use the
// official layout (research 06 §11.5 / the official 机变 screenshots): a framed icon with the name beside it, the
// description across the card under both, left-aligned; the description is the item's / effect's official rich text
// (the same text as the detail card: items.json / effects.json `descRaw`), even when the server card carries the
// plain `desc`. The taker's badge is the round avatar alone (official) and a taken card's name keeps clear of it.
// The on-screen check (every card's text inside its card, taken or not, no name under the badge, at the phone
// viewports down to 640×360 and on desktop) is the browser test test/ui/playtest6-ui.e2e.test.js.
// 10 on phones the second tap on the armed shop card's 确认购买 strip did nothing: the card's ⓘ corner (invisible on
// touch, opacity 0 without hover) had a 44 px touch hit area (css/devices.css — its 30 px override lost on
// specificity) over the bottom-right quarter of the 67 × 97 px card, right half of the strip included; a tap there
// re-opened the detail (stopPropagation) instead of buying. The ⓘ is gone (official cards have none: the first tap
// selects and shows the detail, the second confirms); a tap anywhere on a card is the card's tap, and a card that
// cannot be bought now (bar not editable) opens its detail instead.
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const read = (p) => readFileSync(path.join(ROOT, p), 'utf8');
// the browser data store reads the real data files from disk
globalThis.fetch = async (url) => {
const name = String(url).split('/').pop();
try {
const body = readFileSync(path.join(ROOT, 'data', name), 'utf8');
return { ok: true, status: 200, json: async () => JSON.parse(body) };
} catch {
return { ok: false, status: 404, json: async () => ({}) };
}
};
const { ChoiceView, resolveSpCard, cardText } = await import('../../public/js/ui/choiceOverlay.js');
const { ChessCard, ItemCard } = await import('../../public/js/ui/shopBar.js');
const { normalizeSp } = await import('../../public/js/ui/gameLogic.js');
const { richTextPlain } = await import('../../public/js/ui/richText.js');
const { cardView } = await import('../../server/match/choices.js');
const { data } = await import('../../public/js/data.js');
/** Every vnode of a preact tree (htm output), depth first. */
function* walk(v) {
if (Array.isArray(v)) { for (const x of v) yield* walk(x); return; }
if (!v || typeof v !== 'object') return;
yield v;
yield* walk(v.props?.children);
}
const hasClass = (v, c) => typeof v?.props?.class === 'string' && v.props.class.split(/\s+/).includes(c);
const children = (v) => [v.props?.children].flat(Infinity).filter((x) => x && typeof x === 'object');
await data.loadAll('items', 'effects', 'choices', 'assets', 'chess', 'bonds');
// ---- 6: 机变 cards show their effect text ----------------------------------------------------------------------------
describe('6: 机变 cards (道具补给 / 机密商店 / 悬赏 / 战术) show the effect text in the official layout', () => {
const players = [{ playerId: 'me', seat: 0, name: 'Me' }, { playerId: 'p2', seat: 1, name: 'P2' }];
// server-shaped public cards (server/match/choices.js itemCard → cardView): the plain `desc` of the item
const shopItems = data.list('items').filter((i) => i.itemType === 'EQUIP' && !i.isGolden && !i.shopExcluded);
const longest = [...shopItems].sort((a, b) => b.descRaw.length - a.descRaw.length).slice(0, 6);
const serverSp = (family, cards) => normalizeSp({ family, cards: cards.map((c, idx) => cardView({ ...c, idx })), order: ['me', 'p2'], turn: 'me', picks: { p2: 5 } }, players);
const itemCards = longest.map((it) => ({ kind: 'item', id: it.id, name: it.name, desc: it.desc, tier: it.tier }));
for (const family of ['supply', 'shop']) {
test(`${family}: every item card carries the item's official rich description (the detail card's text), beside nothing`, () => {
const sp = serverSp(family, itemCards);
const nodes = [...walk(ChoiceView({ pub: { players, deadline: 0 }, sp, myId: 'me', solo: false }))];
const cards = nodes.filter((n) => hasClass(n, 'spcard'));
assert.equal(cards.length, 6);
cards.forEach((card, i) => {
const it = longest[i];
const desc = [...walk(card)].find((n) => hasClass(n, 'spcard__desc'));
assert.ok(desc, `${it.name}: a description`);
// RichText renders descRaw (ba.vup numbers, ba.acrem set bonus, line breaks) — the plain server text lost them
assert.equal(desc.props.text, it.descRaw, `${it.name}: the rich text, as ItemDetail shows it`);
assert.equal(richTextPlain(desc.props.text).replace(/\s/g, ''), it.desc.replace(/\s/g, ''));
});
});
}
test('bounty / tactic cards: the effect\'s rich text too (numbers and 下场作战 highlighted like the official cards)', () => {
const ch = data.get('choices');
for (const [family, list] of [['bounty', ch.cards.bounty], ['tactic', ch.cards.tactic]]) {
const src = list.slice(0, 6);
const sp = serverSp(family, src.map((c) => ({ kind: family, id: c.effectId, name: c.name, desc: c.desc, tier: c.tier ?? null, team: c.team, tacticKind: c.kind, enemyKey: c.enemyKey, coin: c.coin })));
sp.cards.forEach((card, i) => {
const r = resolveSpCard(card, family);
const eff = data.lookup('effects', src[i].effectId);
assert.equal(r.desc, eff.descRaw, `${family} ${src[i].name}`);
assert.match(r.desc, /<@ba\.v(up|down)>/, `${family} ${src[i].name}: highlighted parts`);
});
}
});
test('cardText: the data\'s rich text only while it says what the server says; a server-side wording wins', () => {
const rich = '为自身<@ba.vup>下场作战</>添加1只A,将其击倒者获得<@ba.vup>2</>资金';
assert.equal(cardText({ desc: '为自身下场作战添加1只A,将其击倒者获得2资金' }, rich), rich);
assert.equal(cardText({}, rich), rich, 'no server text (the mock harness): the data');
const changed = '为自身之后2场作战添加1只A,将其击倒者获得2资金';
assert.equal(cardText({ desc: changed }, rich), changed, 'a different server wording is shown as sent');
assert.equal(cardText({ descRaw: '<@ba.vup>x</>', desc: 'x' }, rich), '<@ba.vup>x</>', 'the card\'s own rich text first');
assert.equal(cardText({ desc: 'plain' }, ''), 'plain');
});
test('layout: a head row (framed icon + name, tags under the name), the description under it across the card', () => {
const sp = serverSp('supply', itemCards);
const v = ChoiceView({ pub: { players, deadline: 0 }, sp, myId: 'me', solo: false });
for (const card of [...walk(v)].filter((n) => hasClass(n, 'spcard'))) {
const kids = children(card);
const head = kids.find((n) => hasClass(n, 'spcard__head'));
assert.ok(head, 'a head row is a direct child of the card');
const headNodes = [...walk(head)];
assert.ok(headNodes.some((n) => hasClass(n, 'spcard__icon')) && headNodes.some((n) => hasClass(n, 'spcard__name')), 'icon + name in the head');
const desc = kids.find((n) => hasClass(n, 'spcard__desc'));
assert.ok(desc, 'the description is a direct child of the card');
assert.ok(kids.indexOf(desc) > kids.indexOf(head), 'under the head');
}
// the taker's badge and the two-tap strip stay (DESIGN §18.2); the badge is the round avatar alone, as in the
// official screenshots — the taker's name is the badge's tooltip and part of the card's accessible name (the
// avatar + name pill ran under long card names once the name moved into the top row)
const taken = [...walk(v)].filter((n) => hasClass(n, 'spcard') && hasClass(n, 'is-taken'));
assert.equal(taken.length, 1);
const badge = [...walk(taken[0])].find((n) => hasClass(n, 'spcard__taker'));
assert.ok(badge, 'the taker\'s badge');
assert.equal(children(badge).length, 1, 'avatar only');
assert.equal(children(badge)[0].type?.name, 'PlayerAvatar');
assert.equal(badge.props.title, 'P2 已选择');
assert.match(taken[0].props['aria-label'], /,P2已选择$/);
const armed = [...walk(ChoiceView({ pub: { players, deadline: 0 }, sp, myId: 'me', solo: false, armed: 0 }))];
assert.ok(armed.some((n) => hasClass(n, 'spcard__confirm')));
});
test('CSS: left-aligned official card; the description is clamped by lines, larger on desktop, ≥ .18rem on short phones', () => {
const css = read('public/css/screens/game-panels.css');
const rule = (sel, src = css) => {
const m = src.match(new RegExp(`(?:^|\\n)\\s*${sel.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')} \\{([^}]*)\\}`));
return m ? m[1] : null;
};
assert.match(rule('.spcard'), /text-align: left/);
assert.match(rule('.spcard__head'), /display: flex/);
const desc = rule('.spcard__desc');
assert.match(desc, /-webkit-line-clamp: \d/);
const size = (r) => Number(r.match(/font-size: (\.\d+)rem/)[1]);
assert.ok(size(desc) >= 0.19, `desktop description ${size(desc)}rem`);
// short landscape phones (height < 10.8rem because the root size is clamped at 40 px)
const phone = css.match(/@media \(max-height: 431\.98px\) \{([\s\S]*?)\n\}/);
assert.ok(phone, 'a short-screen block');
const pdesc = rule('.spcard__desc', phone[1]);
assert.ok(pdesc && size(pdesc) >= 0.18, 'phones keep a readable description');
assert.doesNotMatch(pdesc, /line-clamp/, 'phones show as many lines as 16:9 (信标 takes 7 below ≈ 740 px wide)');
assert.ok(rule('.spcard__icon', phone[1]), 'phones use a smaller icon');
assert.match(rule('.spov__inner', phone[1]) || '', /top: \.\d+rem/, 'phones: a tighter header gives the grid more height');
// a taken card's head keeps clear of the taker's badge (top-right corner), on phones too
assert.match(rule('.spcard.is-taken .spcard__head'), /padding-right: calc\(var\(--tk\) \+ var\(--tk-at\) - var\(--sp-px\)/);
assert.match(rule('.spcard__taker'), /right: var\(--tk-at\); top: var\(--tk-at\)/);
assert.match(rule('.spcard', phone[1]), /--sp-px: [^;]+; --tk: [^;]+; --tk-at:/);
});
});
// ---- 10: the armed shop card confirms wherever it is tapped --------------------------------------------------------
describe('10: a tap anywhere on the armed shop card buys (no inner target swallows the confirm)', () => {
const chess = data.list('chess').find((c) => c.visible && !c.isGolden && c.tier === 3);
const item = data.list('items').find((i) => i.itemType === 'EQUIP' && !i.isGolden && !i.shopExcluded);
const priv = { funds: 20, hand: [], board: [], temp: [], loadout: {} };
const clickables = (v) => [...walk(v)].filter((n) => typeof n.props?.onClick === 'function');
for (const [kind, Card, slot] of [['operator', ChessCard, { kind: 'chess', id: chess.chessId, price: 3, basePrice: 3 }], ['item', ItemCard, { kind: 'item', id: item.id, price: 2, basePrice: 2 }]]) {
test(`${kind} card: armed, the card is the only tap target in it (no ⓘ corner) and its tap confirms`, () => {
const taps = [];
const v = Card({ slot, idx: 2, priv, armed: true, onTap: (i) => taps.push(['tap', i]), onBuy: (i) => taps.push(['buy', i]), onDetail: (id) => taps.push(['detail', id]) });
const nodes = [...walk(v)];
assert.ok(!nodes.some((n) => hasClass(n, 'scard__detail')), 'no ⓘ corner on the card');
const cl = clickables(v);
assert.equal(cl.length, 1, 'one click handler: the card itself');
assert.ok(hasClass(cl[0], 'scard') && hasClass(cl[0], 'is-armed'));
assert.ok(nodes.some((n) => n.type?.name === 'ArmedTag'), 'the 确认购买 strip');
cl[0].props.onClick();
assert.deepEqual(taps, [['tap', 2]], 'the second tap goes to the two-tap handler (ShopBar buys)');
});
test(`${kind} card: not armed, no inner target either; a card that cannot be bought now opens its detail`, () => {
const taps = [];
const idle = Card({ slot, idx: 1, priv, onTap: (i) => taps.push(['tap', i]), onBuy: (i) => taps.push(['buy', i]), onDetail: (id) => taps.push(['detail', id]) });
assert.equal(clickables(idle).length, 1);
clickables(idle)[0].props.onClick();
// not editable (ready / round start): no two-tap, a reason — the tap shows the detail (the ⓘ did before)
const locked = Card({ slot, idx: 1, priv, reason: '已准备就绪,取消准备后才能操作', onBuy: (i) => taps.push(['buy', i]), onDetail: (id) => taps.push(['detail', id]) });
const lockedNodes = [...walk(locked)];
assert.ok(!lockedNodes.some((n) => hasClass(n, 'scard__detail')));
const btn = lockedNodes.find((n) => hasClass(n, 'scard'));
assert.notEqual(btn.props['aria-disabled'], 'true', 'its tap does something: not announced as disabled');
btn.props.onClick();
assert.deepEqual(taps, [['tap', 1], ['detail', slot.id]]);
});
}
test('CSS: no hit area of an ⓘ corner is left (it covered the bottom-right quarter of a phone card)', () => {
assert.doesNotMatch(read('public/css/devices.css'), /scard__detail/);
assert.doesNotMatch(read('public/css/screens/game-shop.css'), /scard__detail/);
assert.doesNotMatch(read('public/js/ui/shopBar.js'), /scard__detail/);
// a locked bar's card opens its detail: a help cursor, not "not allowed"
assert.match(read('public/css/screens/game-shop.css'), /\.shopbar\.is-locked \.scard:not\(\.scard--sold\) \{ cursor: help; \}/);
});
});
+237
View File
@@ -0,0 +1,237 @@
// User playtest #6 item 7 (workstream WC), the client side: during 联防 a leaker's count of its enemies still standing
// on the 联防 field falls live as the helpers kill them — uncapped in the official runner tag ×N (phase capsule, team
// rows), while the LP loss stays min(10, N). Pure HUD helpers (ui/hud.js, ui/teamPanel.js) and the browser runner
// (public/js/battle/runner.js state().uniteLeft / b.progress `left`) on a real 联防 spec. The server side is in
// test/match/playtest6-matchflow.test.js.
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { PHASE } from '../../shared/constants.js';
import { createBattleRunner } from '../../public/js/battle/runner.js';
import { createStore, initialState } from '../../public/js/store.js';
import * as specMod from '../../server/sim/spec.js';
import { DataSource } from '../../server/sim/simdata.js';
import { Battle } from '../../server/sim/Battle.js';
import { validateC2S } from '../../shared/protocol.js';
import { DATA, makeMatch } from '../match/harness.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const read = (p) => readFileSync(path.join(ROOT, p), 'utf8');
const { liveLp, uniteRemaining, pendingTip, PhaseCapsule, MissTag, missTip } = await import('../../public/js/ui/hud.js');
const { rowLp, rowLpTip } = await import('../../public/js/ui/teamPanel.js');
function* walk(v) {
if (Array.isArray(v)) { for (const x of v) yield* walk(x); return; }
if (!v || typeof v !== 'object') return;
yield v;
yield* walk(v.props?.children);
}
const textOf = (v) => {
if (v == null || typeof v === 'boolean') return '';
if (typeof v === 'string' || typeof v === 'number') return String(v);
if (Array.isArray(v)) return v.map(textOf).join('');
return typeof v === 'object' ? textOf(v.props?.children) : '';
};
const hasClass = (v, c) => typeof v?.props?.class === 'string' && v.props.class.split(/\s+/).includes(c);
describe('#7 the 联防 leak counter (HUD)', () => {
test('uniteRemaining: the local 联防 replica\'s count while it runs (the field on screen), else the server\'s; null when unknown', () => {
assert.equal(uniteRemaining(12, 14), 12, 'the local replica is ahead of the ~1 Hz report');
assert.equal(uniteRemaining(15, 13), 15, 'a split raised it on the field before the next report: the replica shows it');
assert.equal(uniteRemaining(undefined, 13), 13, 'no local replica yet (loading / not on screen)');
assert.equal(uniteRemaining(0, 3), 0);
for (const [a, b] of [[undefined, undefined], [null, NaN], [-1, 'x']]) assert.equal(uniteRemaining(a, b), null);
});
test('liveLp in 联防: the count replaces the own battle\'s leaks — uncapped in `left`, the LP loss capped at 10, falling live', () => {
let base = null;
const step = (o) => { const r = liveLp(base, { phase: PHASE.UNITE, round: 7, lp: 30, statsLeaks: 2, leaks: 14, cap: 10, ...o }); base = r.base; return r; };
const seq = [15, 14, 12, 10, 9, 4, 0].map((n) => step({ uniteLeft: n }));
assert.deepEqual(seq.map((r) => r.left), [15, 14, 12, 10, 9, 4, 0]);
assert.deepEqual(seq.map((r) => r.pending), [10, 10, 10, 10, 9, 4, 0], 'min(10, left)');
assert.deepEqual(seq.map((r) => r.shown), [20, 20, 20, 20, 21, 26, 30]);
assert.ok(seq.every((r) => r.unite), '联防中 while the player is a leaker (also at 0: the tag shows ×0)');
// the settlement lands (m.private lp 26): nothing pending any more, no tag
const settled = step({ uniteLeft: 4, lp: 26, statsLeaks: 16 });
assert.deepEqual([settled.pending, settled.left, settled.shown], [0, null, 26]);
// not a leaker / no count: the own battle's count (the old behaviour)
const helper = liveLp(null, { phase: PHASE.UNITE, round: 7, lp: 30, leaks: 0 });
assert.deepEqual([helper.pending, helper.left, helper.unite], [0, null, false]);
const legacy = liveLp(null, { phase: PHASE.UNITE, round: 7, lp: 30, leaks: 14 });
assert.deepEqual([legacy.pending, legacy.left], [10, null]);
// COMBAT ignores it
assert.equal(liveLp(null, { phase: PHASE.COMBAT, round: 7, lp: 30, leaks: 2, uniteLeft: 9 }).left, null);
});
test('tooltips name the true count and the capped loss', () => {
const over = pendingTip(30, 10, { unite: true, left: 15 });
assert.match(over, /还剩 15 个.*剩余不足 10 个后,队友每击倒一个少扣 1 点.*扣除 10 点(每回合至多 10 点)/);
assert.doesNotMatch(over, /队友每击倒一个就少扣/, 'above the cap a kill does not lower the loss');
assert.match(pendingTip(30, 4, { unite: true, left: 4 }), /还剩 4 个,队友每击倒一个就少扣 1 点/);
assert.match(pendingTip(30, 8, { unite: true, left: 9, cap: 8 }), /剩余不足 8 个后.*每回合至多 8 点/);
assert.equal(rowLpTip({ lp: 30, pending: 10, unite: true, left: 13 }), '目标生命值 30,联防中:漏过的敌人还剩 13 个,按现在结算扣除 10 点(每回合至多 10 点)');
assert.match(rowLpTip({ lp: 30, pending: 8, unite: true, left: 13 }, 8), /每回合至多 8 点/, 'the configured cap');
assert.equal(rowLpTip({ lp: 30, pending: 0, unite: true, left: 0 }), null);
});
test('team rows: a teammate\'s m.public players[].uniteLeft (uncapped) next to lp − pendingLp; the own row the top bar\'s value', () => {
const pub = { phase: PHASE.UNITE };
assert.deepEqual(rowLp({ lp: 30, pendingLp: 10, uniteLeft: 13 }, pub), { lp: 30, pending: 10, unite: true, left: 13 });
assert.deepEqual(rowLp({ lp: 30, uniteLeft: 0 }, pub), { lp: 30, pending: 0, unite: true, left: 0 }, 'all struck down: ×0');
assert.deepEqual(rowLp({ lp: 30 }, pub), { lp: 30, pending: 0, unite: false, left: null }, 'a helper');
assert.deepEqual(rowLp({ lp: 30, pendingLp: 2, uniteLeft: 2 }, { phase: PHASE.COMBAT }), { lp: 30, pending: 2, unite: false, left: null }, 'COMBAT: no tag');
assert.deepEqual(rowLp({ lp: 30, uniteLeft: 12 }, pub, { lp: 30, pending: 10, unite: true, left: 11 }), { lp: 30, pending: 10, unite: true, left: 11 });
});
test('team rows of a helper watching the 联防: a teammate\'s count comes from the local replica on screen (live with the kills), else m.public', () => {
const pub = { phase: PHASE.UNITE, unite: { helpers: ['me'], leakers: ['b', 'c'] } };
const b = { playerId: 'b', lp: 30, pendingLp: 10, uniteLeft: 13 }; // the authority's last report (~1 Hz, a kill behind)
const c = { playerId: 'c', lp: 4, pendingLp: 4, uniteLeft: 6 };
const local = { b: 12, c: 9 };
assert.deepEqual(rowLp(b, pub, null, { uniteLocal: local }), { lp: 30, pending: 10, unite: true, left: 12 }, 'the replica wins');
assert.deepEqual(rowLp(b, pub, null, { uniteLocal: { b: 7 } }), { lp: 30, pending: 7, unite: true, left: 7 }, 'loss = min(10, left) from the same count');
assert.deepEqual(rowLp(b, pub, null, { uniteLocal: { b: 7 }, cap: 5 }), { lp: 30, pending: 5, unite: true, left: 7 }, 'the configured cap');
assert.deepEqual(rowLp(c, pub, null, { uniteLocal: local }), { lp: 4, pending: 4, unite: true, left: 9 }, 'never more than the LP left');
assert.deepEqual(rowLp(b, pub, null, { uniteLocal: {} }), { lp: 30, pending: 0, unite: true, left: 0 }, 'absent from the replica = all struck down');
assert.deepEqual(rowLp(b, pub, null, { uniteLocal: null }), { lp: 30, pending: 10, unite: true, left: 13 }, 'no replica on screen: m.public');
assert.deepEqual(rowLp({ playerId: 'me', lp: 30 }, pub, null, { uniteLocal: local }), { lp: 30, pending: 0, unite: false, left: null }, 'a helper row gets no tag');
assert.deepEqual(rowLp(b, { ...pub, phase: PHASE.COMBAT }, null, { uniteLocal: local }), { lp: 30, pending: 10, unite: false, left: null }, 'COMBAT ignores it');
const own = { lp: 30, pending: 10, unite: true, left: 11 };
assert.deepEqual(rowLp(b, pub, own, { uniteLocal: { b: 2 } }), own, 'the own row keeps the top bar\'s value');
// wiring: the game screen hands the runner's per-leaker counts to the team panel, which passes them to rowLp
assert.match(read('public/js/screens/game.js'), /const uniteLocal = phase === PHASE\.UNITE && battleState && battleState\.uniteLeft \? battleState\.uniteLeft : null;/);
assert.match(read('public/js/screens/game.js'), /<\$\{TeamPanel\}[^\n]*uniteLocal=\$\{uniteLocal\}/);
assert.match(read('public/js/ui/teamPanel.js'), /rowLp\(p, pub, self \? selfLive : null, \{ uniteLocal, cap \}\)/);
});
test('the tag\'s tooltip speaks to the leaker on the own tag, names the teammate on a teammate\'s row', () => {
assert.equal(missTip(5), '你漏过的敌人还剩 5 个(队友正在迎战)');
assert.equal(missTip(0), '你漏过的敌人已全部被击倒');
assert.equal(missTip(5, 'Doctor·B'), 'Doctor·B 漏过的敌人还剩 5 个(联防中)');
assert.equal(missTip(0, 'Doctor·B'), 'Doctor·B 漏过的敌人已全部被击倒');
assert.equal(MissTag({ n: 5, name: 'Doctor·B' }).props.title, 'Doctor·B 漏过的敌人还剩 5 个(联防中)');
assert.equal(MissTag({ n: 5 }).props.title, '你漏过的敌人还剩 5 个(队友正在迎战)');
assert.match(read('public/js/ui/teamPanel.js'), /<\$\{MissTag\} n=\$\{lp\.left\} name=\$\{self \? null : p\.name \|\| '博士'\} \/>/);
});
test('the phase capsule carries the official runner tag ×N in 联防 only; the tag reads the uncapped number', () => {
const tagOf = (v) => [...walk(v)].find((n) => n.type === MissTag);
const unite = PhaseCapsule({ pub: { phase: PHASE.UNITE }, hud: { killed: 3, total: 17 }, miss: 14 });
assert.equal(tagOf(unite)?.props.n, 14);
assert.equal(tagOf(PhaseCapsule({ pub: { phase: PHASE.UNITE }, hud: { killed: 3, total: 17 }, miss: null })), undefined, 'a helper / observer');
assert.equal(tagOf(PhaseCapsule({ pub: { phase: PHASE.COMBAT }, hud: { killed: 3, total: 17 }, miss: 5 })), undefined);
const tag = MissTag({ n: 14 });
assert.ok(hasClass(tag, 'misstag'));
assert.match(textOf(tag), /14$/);
assert.match(tag.props.title, /还剩 14 个/);
assert.ok(hasClass(MissTag({ n: 0 }), 'is-clear'));
// wiring: the top bar hands the live value to the capsule; the game screen feeds liveLp with the leaker's count
assert.match(read('public/js/ui/hud.js'), /PhaseCapsule} pub=\$\{pub\} hud=\$\{hud\} miss=/);
assert.match(read('public/js/screens/game.js'), /uniteLeft: leaker \? uniteRemaining\(localLeft, meP\?\.uniteLeft\) : null/);
assert.match(read('public/js/ui/teamPanel.js'), /<\$\{MissTag\} n=\$\{lp\.left\} name=/);
});
});
// ---- the browser runner on a real 联防 spec --------------------------------------------------------------------------
const DS = new DataSource(DATA, null);
const LEAKS = 14;
/** Real battles whose 联防 helpers strike down a leaked enemy every 6 game seconds (from 4 s on). */
class SlowHelpers extends Battle {
constructor(opts) {
super(opts);
if (this.kind !== 'unite') return;
let next = 4;
this.on('tick', () => {
if (this.time < next) return;
const e = this.enemies.find((x) => x.alive && x.counted);
if (!e) return;
this.kill(e, null);
next += 6;
});
}
}
/** The b.start frames of a 联防 where p_1 let LEAKS 源石虫 through and p_0 helps (client-side combat, no clients). */
function uniteStarts() {
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 2, seed: 710, captureFrames: false, clientCombat: true, clients: false });
h.m.start();
h.drive(() => h.m.phase === PHASE.PREP && h.m.round === 1, { ready: false });
const m = h.m;
m.phase = PHASE.COMBAT;
m.fields = [];
m.lastResults = new Map([
['p_0', { leaked: [], perfect: true, coins: 0, layerGains: {}, killed: 5, total: 5, unitsEnd: [] }],
['p_1', { leaked: Array.from({ length: LEAKS }, () => ({ enemyKey: 'enemy_1007_slime', mods: null, lpr: 1, sourcePlayerId: 'p_1', tag: null, counted: true })), perfect: false, coins: 0, layerGains: {}, killed: 0, total: LEAKS, unitsEnd: [] }],
]);
m._afterCombat();
assert.equal(m.phase, PHASE.UNITE);
const out = { helper: h.lastTo('p_0', 'b.start'), leaker: h.lastTo('p_1', 'b.start') };
m.dispose();
return out;
}
function rig() {
let t = 1000;
const frames = [];
const sent = [];
const handlers = new Map();
const net = {
on(ty, fn) { if (!handlers.has(ty)) handlers.set(ty, new Set()); handlers.get(ty).add(fn); return () => handlers.get(ty).delete(fn); },
emit(ty, msg) { for (const fn of handlers.get(ty) || []) fn({ t: ty, ...msg }); },
send(ty, fields) { const msg = { ...fields, t: ty }; assert.equal(validateC2S(msg), null, `invalid ${ty}`); sent.push(msg); return true; },
request(ty, fields) { const msg = { ...fields, t: ty, rid: 1 }; assert.equal(validateC2S(msg), null, `invalid ${ty}`); sent.push(msg); return Promise.resolve({ t: 'ok' }); },
};
const store = createStore(initialState);
const spec = { ...specMod, createBattleFromSpec: (s, ds, o = {}) => specMod.createBattleFromSpec(s, ds, { ...o, BattleClass: SlowHelpers }) };
const runner = createBattleRunner({
net, store, doc: { hidden: false, addEventListener() {} }, now: () => t,
raf: (fn) => { frames.push(fn); return frames.length; }, caf: () => {},
setInterval: () => 1, clearInterval: () => {},
loadSim: async () => ({ spec, ds: DS }),
logger: { error() {}, warn() {}, info() {}, debug() {} },
});
return {
runner, net, store, sent,
advance(ms, step = 1000 / 60) { const end = t + ms; while (t < end) { t = Math.min(end, t + step); for (const fn of frames.splice(0)) fn(t); } },
async settle() { for (let i = 0; i < 50; i++) { await new Promise((res) => setImmediate(res)); for (const fn of frames.splice(0)) fn(t); } },
};
}
test('#7 runner: a 联防 replica publishes state().uniteLeft as it falls; the authority reports b.progress `left`', async () => {
const { helper, leaker } = uniteStarts();
assert.equal(helper.kind, 'unite');
assert.equal(helper.authoritative, true, 'the helper runs the 联防');
assert.equal(leaker.authoritative, false, 'the leaker watches a display replica');
// the leaker's browser: a display replica — its top bar reads state().uniteLeft (store.match.battle)
const L = rig();
L.net.emit('b.start', leaker);
await L.settle();
const seen = [];
for (let i = 0; i < 160; i++) {
L.advance(500);
const s = L.store.get().match.battle;
if (s && s.uniteLeft) seen.push(s.uniteLeft.p_1 ?? 0);
}
assert.equal(seen[0], LEAKS, 'starts at every enemy that got through');
for (let i = 1; i < seen.length; i++) assert.ok(seen[i] <= seen[i - 1], '源石虫 never split: it only falls here');
assert.ok(new Set(seen).size >= 4 && seen[seen.length - 1] < 10, `falls live: ${[...new Set(seen)].join(' → ')}`);
assert.equal(L.sent.filter((x) => x.t === 'b.progress').length, 0, 'a replica never reports');
L.runner.dispose();
// the helper's browser: the authority's reports carry the counts
const H = rig();
H.net.emit('b.start', helper);
await H.settle();
H.advance(20_000);
const prog = H.sent.filter((x) => x.t === 'b.progress');
assert.ok(prog.length >= 15, `${prog.length} reports`);
assert.ok(prog.every((x) => x.left && Object.keys(x.left).every((k) => k === 'p_1')), 'left: the leaker only');
assert.ok(prog[prog.length - 1].left.p_1 < prog[0].left.p_1, 'the reported count falls');
H.runner.dispose();
});
+117
View File
@@ -0,0 +1,117 @@
// User playtest #6 items 1 and 2, the UI side: a summon piece in the hand / on the board says how it takes the field
// (ui/detailPanel.js summonDeployHint / TokenDetail) — 赫默's 医疗探机 once at the battle start, then each time her skill
// fires; 凯瑟琳's 支援装置 with the board — and shows what it does (the device's talent 定向支援信号, the doll's token
// skill), with the numbers of the owner's own variant (a golden owner's summon is stronger: resolveDetail finds the
// owner by `ownerUid`). One switch, shared/constants.js SKILL_SUMMON_START_DEPLOY (the PRTS start-of-battle deploy,
// settled by the user after playtest #6), drives the sim and the hint; the guide (docs/PLAYING.md §4) and docs/SIM.md
// must say the same. The rules themselves:
// test/match/playtest6_summons.test.js (prep) and test/content/playtest6_summons.test.js (battle).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
globalThis.fetch = async (url) => {
const name = String(url).split('/').pop();
try {
const body = readFileSync(path.join(ROOT, 'data', name), 'utf8');
return { ok: true, status: 200, json: async () => JSON.parse(body) };
} catch {
return { ok: false, status: 404, json: async () => ({}) };
}
};
const { TokenDetail, summonDeployHint, resolveDetail, tokenVariantFor } = await import('../../public/js/ui/detailPanel.js');
const { indexPieces } = await import('../../public/js/ui/gameLogic.js');
const { SKILL_SUMMON_START_DEPLOY } = await import('../../shared/constants.js');
const simTokens = await import('../../server/sim/content/tokens.js');
const { data } = await import('../../public/js/data.js');
const TOKENS = JSON.parse(readFileSync(path.join(ROOT, 'data', 'tokens.json'), 'utf8'));
const textOf = (v) => {
if (v == null || typeof v === 'boolean') return '';
if (typeof v === 'string' || typeof v === 'number') return String(v);
if (Array.isArray(v)) return v.map(textOf).join('');
if (typeof v === 'object' && typeof v.type === 'function' && ['Section', 'Stat', 'LiveTag'].includes(v.type.name)) return textOf(v.type(v.props));
return typeof v === 'object' ? textOf(v.props?.children) : '';
};
test('summonDeployHint: skill summons at the start and with the skill, talent summons with the board, other tokens say nothing', () => {
assert.equal(SKILL_SUMMON_START_DEPLOY, true, 'the user\'s answer after playtest #6: the PRTS start deploy');
assert.match(summonDeployHint(TOKENS.token_10000_silent_healrb), /^作战开始时在摆放的位置部署一次.*发动技能时/);
assert.match(summonDeployHint(TOKENS.token_10006_vodfox_doll), /^作战开始时在摆放的位置部署一次.*发动技能时/);
for (const id of ['token_10041_cathy_catsld', 'token_10028_vigil_wolf', 'token_10017_skadi2_dedant', 'token_10030_mlyss_wtrman']) {
assert.match(summonDeployHint(TOKENS[id]), /作战开始时/, id);
}
assert.equal(summonDeployHint(TOKENS.token_10056_angel2_target), null, '投递坐标 is no hand piece');
assert.equal(summonDeployHint(TOKENS.enemy_9012_acloon), null);
});
test('one switch for the start-of-battle deploy: the sim and the hint read shared/constants.js SKILL_SUMMON_START_DEPLOY', () => {
assert.equal(simTokens.SKILL_SUMMON_START_DEPLOY, SKILL_SUMMON_START_DEPLOY, 'the sim re-exports the shared switch');
const drone = TOKENS.token_10000_silent_healrb;
assert.equal(summonDeployHint(drone), summonDeployHint(drone, SKILL_SUMMON_START_DEPLOY), 'the hint defaults to it');
// off (the playtest #4 reading): only with the skill; on (PRTS, the default): once at the start, then with each skill
assert.match(summonDeployHint(drone, false), /^所属干员发动技能时才在摆放的位置出现/);
assert.match(summonDeployHint(drone, true), /^作战开始时在摆放的位置部署一次,之后所属干员每次发动技能时再次出现/);
assert.match(summonDeployHint(TOKENS.token_10006_vodfox_doll, true), /作战开始时.*部署一次/);
// talent summons deploy with the board either way
assert.equal(summonDeployHint(TOKENS.token_10041_cathy_catsld, true), summonDeployHint(TOKENS.token_10041_cathy_catsld, false));
});
test('the guide and SIM.md state the start-of-battle rule the switch selects (flip it ⇒ update both)', () => {
const playing = readFileSync(path.join(ROOT, 'docs', 'PLAYING.md'), 'utf8');
const sim = readFileSync(path.join(ROOT, 'docs', 'SIM.md'), 'utf8');
if (SKILL_SUMMON_START_DEPLOY) {
assert.match(playing, /医疗探机、诅咒娃娃[^\n]*开战时[^\n]*部署一次/, 'PLAYING.md §4: the drone / doll also deploy once at the start');
assert.match(sim, /deploys once at the battle start/, 'SIM.md token pieces: the PRTS start deploy');
} else {
assert.match(playing, /医疗探机、诅咒娃娃要等所属干员发动技能时才在摆放的位置出现/, 'PLAYING.md §4: only with the skill');
assert.match(sim, /a skill's summon[^.]*waits on its tile and takes the field there\s+each time the skill gives one/, 'SIM.md token pieces');
}
// the exception for unplaced summons is stated for the summons it holds for (test/content/playtest6_summons)
assert.match(playing, /狼群、流形(战术家的援军)没有摆放时会在战术点出现/);
assert.ok(!/没有摆放的召唤物不会出现/.test(playing), 'no blanket "unplaced summons never appear"');
});
test('the summon card: 凯瑟琳\'s device shows the hint and its shield talent; 巫恋\'s doll its token skill', async () => {
await data.loadAll('tokens', 'assets');
const dev = textOf(TokenDetail({ token: TOKENS.token_10041_cathy_catsld, piece: { kind: 'token', id: 'token_10041_cathy_catsld', count: 2 } }));
assert.match(dev, /作战开始时在摆放的位置部署/);
assert.match(dev, /定向支援信号/);
assert.match(dev, /屏障/);
const drone = textOf(TokenDetail({ token: TOKENS.token_10000_silent_healrb, piece: { kind: 'token', id: 'token_10000_silent_healrb', count: 1 } }));
assert.match(drone, /作战开始时在摆放的位置部署一次,之后所属干员每次发动技能时再次出现/);
assert.ok(!/自我销毁/.test(drone), 'the withdraw skill is not listed');
const doll = textOf(TokenDetail({ token: TOKENS.token_10006_vodfox_doll, piece: { kind: 'token', id: 'token_10006_vodfox_doll', count: 1 } }));
assert.match(doll, /攻击力和防御力-25%/);
// a summon on the battlefield (no prep piece) shows no prep hint
assert.ok(!/摆放的位置/.test(textOf(TokenDetail({ token: TOKENS.token_10041_cathy_catsld, piece: null }))));
});
test('a golden owner\'s summon card shows the golden variant: 精锐 巫恋\'s doll −30%, 精锐 赫默\'s drone ATK 114', async () => {
await data.loadAll('tokens', 'assets');
assert.equal(tokenVariantFor(TOKENS.token_10000_silent_healrb, 'chess_char_2_02_b').stats.atk, 114);
assert.equal(tokenVariantFor(TOKENS.token_10000_silent_healrb, 'chess_char_2_02_a').stats.atk, 85);
assert.equal(tokenVariantFor(TOKENS.token_10000_silent_healrb, null).stats.atk, 85, 'unknown owner: the first variant');
// the private view: golden 巫恋 on the board, her doll card in the hand (resolveDetail reads the owner by ownerUid)
const priv = {
board: [{ uid: 7, kind: 'chess', id: 'chess_char_3_15_b', row: 10, col: 4 }, { uid: 9, kind: 'chess', id: 'chess_char_2_02_b', row: 10, col: 5 }],
hand: [{ uid: 8, kind: 'token', id: 'token_10006_vodfox_doll', ownerUid: 7, count: 1 }, { uid: 10, kind: 'token', id: 'token_10000_silent_healrb', ownerUid: 9, count: 1 }],
temp: [],
};
const pieces = indexPieces(priv);
const doll = resolveDetail({ kind: 'piece', uid: 8 }, pieces);
assert.equal(doll.ownerId, 'chess_char_3_15_b');
const dollText = textOf(TokenDetail(doll));
assert.match(dollText, /攻击力和防御力-30%/);
assert.ok(!/-25%/.test(dollText));
const drone = resolveDetail({ kind: 'piece', uid: 10 }, pieces);
assert.match(textOf(TokenDetail(drone)), /114/);
// in battle the own summon unit carries its piece uid: the same owner variant
const unit = resolveDetail({ kind: 'unit', unit: { id: 3, side: 'ally', defId: 'token_10006_vodfox_doll', uid: 8 } }, pieces);
assert.equal(unit.type, 'token');
assert.equal(unit.ownerId, 'chess_char_3_15_b');
});