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);
});
// =================================================================================================================