Playtest #5 fixes: hovering enemies, target order, blocking hand-over, 联防 knocked-out, elements, huge-boss hit area, maps, gear icon, phone HUD, 至简 price

- #1 近地悬浮 enemies count as air units (Unit.isFlying): ground-only attacks, splash, terrain and traps skip them;
  PRTS 不可对空 skill effects made ground-only; 掠海漂移体 / 吉兆飞鳞 drop modes; the bot plans them as air
- #2 an operator knocked out before 联防 is fielded and force-exited (PRTS 强制退场): it lies on its tile with the
  redeploy ring and redeploys (full timer [ASSUMED]); partners, observers and the server fallback included
- #3 element audit of every operator, item and enemy: 海嗣 numbers are official; bursts are 无来源, 元素脆弱 only
  raises 元素伤害, 元素抗性 applied, 盟约·辅助干员 fills all three gauges, 菲莱 / 哈洛德 / 纯烬 / enemy kits fixed
- #4 blocking by the official contact radius (0.7071): another operator in contact takes over at once when the
  blocker dies, withdraws or is stunned; melee blockers attack what they block
- #5 enemy target order per PRTS 索敌: blocker, special priority, taunt, last deployed; initial deploy 从上到下>从左到右,
  operators before summons; special priorities break ties by taunt then deploy order
- #6 act2 m01's normal-field blower machines (row 13) are drawn in the normal / 联防 views
- #7 Final Assault prep legality reads the player's half of the boss field (fence tiles deployable); all 11 stages
  audited against the official level files
- #8 regular settings gear; #9 the prep camera keeps the bench and temp rows clear of the shop bar on phones
- #10 huge bosses have the official 4.95 × 2.95 hit area (sim/body.js) and are 自缚 / unblockable
- #11 至简 costs 1: 购买价格为N's bb.price is a discount off the tier price, applied before other price effects
- DESIGN §19 and the normative §3 / §5 / §6 / §7 lines, SIM / META / DATA / PLAYING updated; 2595 unit tests and
  every browser suite green; dev recordings regenerated
This commit is contained in:
sganggs committed 2026-09-30 19:12:36 +08:00
1 parent d4f868cd19
commit 15291c2293
104 files changed
+5539 -662

No files matched your search

+15 -7
View File
@@ -127,7 +127,7 @@ test(`${nm('enemy_1404_msnip')}: 直击 — a unit in line is shot (arts ATK×at
assert.ok(e.findBuff('ab:revealed') || h.b.time > s.initCooldown + s.bb.duration);
});
test(`${nm('enemy_10034_cnvsax')}: never attacks while stealthed; once blocked it counter-attacks and burns around itself`, () => {
test(`${nm('enemy_10034_cnvsax')}: never attacks while stealthed; once blocked it counter-attacks and burns its locked target`, () => {
const h = arena({ units: [{ chessId: 't_wall', row: 10, col: 5 }] });
h.step();
const free = put(h, 'enemy_10034_cnvsax', [9, 5]); // next to the wall, not blocked
@@ -137,7 +137,7 @@ test(`${nm('enemy_10034_cnvsax')}: never attacks while stealthed; once blocked i
h.run(5);
const w = h.unit('t_wall');
assert.ok(e.stats.attacks > 0);
assert.ok(w.elem.burn > 0, 'fire aura burns');
assert.ok(w.elem.burn > 0, '狂欢式演奏 burns its target (the blocker)');
});
test(`${nm('enemy_9008_acbunn')}: attacks several targets at once while stealthed`, () => {
@@ -483,7 +483,11 @@ test(`${nm('enemy_9007_acelem')}: harder to target (taunt −1); parasitises its
h.run(2.6);
const w = h.unit('t_wall');
assert.ok(w.findBuff('ab:parasite'));
approx(w.s.elemTakenMul, tb('enemy_9007_acelem', '1.ep_damage_scale'));
// "受到的元素损伤提高至130%": an elementHit multiplier on the host's gauge fills (SIM.md §7.2), not the elemTakenMul mod
assert.equal(w.s.elemTakenMul, 1);
const n0 = w.elem.neural;
h.b.dealDamage(null, w, { type: 'element', element: 'neural', amount: 100 });
approx(w.elem.neural - n0, 100 * tb('enemy_9007_acelem', '1.ep_damage_scale'), 1e-9);
approx(w.stats.taken, 2 * e.s.atk * tb('enemy_9007_acelem', '1.atk_scale'), 1e-6);
killed(h, e, null);
assert.ok(!w.findBuff('ab:parasite'));
@@ -1140,14 +1144,16 @@ test(`${nm('enemy_10044_wintun')}: fast with its barrel; first hit ×${skb('enem
approx(e.s.dodgePhys, skb('enemy_10044_wintun', 'BlockedBoom').bb.prob);
});
test(`${nm('enemy_10045_parrot')}: 近地悬浮; sprints when first hit`, () => {
test(`${nm('enemy_10045_parrot')}: 近地悬浮; sprints when first hit (final ×M0SpeedUp.move_speed — PRTS "最终提升至300%")`, () => {
const h = arena();
h.step();
const e = put(h, 'enemy_10045_parrot', [10, 7], { move: true });
const v = e.s.moveSpeed;
assert.ok(e.mem.ab.float && e.s.flags.unblockable);
assert.ok(e.findBuff('ab:float') && e.s.flags.unblockable && e.isFlying && e.motion === 'WALK');
h.b.dealDamage(null, e, { amount: 1, type: 'true' });
approx(e.s.moveSpeed, v * (1 + tb('enemy_10045_parrot', 'M0SpeedUp.move_speed')));
approx(e.s.moveSpeed, v * tb('enemy_10045_parrot', 'M0SpeedUp.move_speed'));
h.run(tb('enemy_10045_parrot', 'M0SpeedUp.duration') + 0.1);
approx(e.s.moveSpeed, v);
});
test(`${nm('enemy_10087_hlchgr')}: spends ammo every ${tb('enemy_10087_hlchgr', 'SkillTrigger.interval')} s for permanent speed and ATK`, () => {
@@ -1173,7 +1179,9 @@ test(`${nm('enemy_10116_ymgtop')}: spinning phase deals ATK×${tb('enemy_10116_y
h.run(skb('enemy_10116_ymgtop', 'SwitchModeTrigger').initCooldown + 3.05);
const w = h.unit('t_wall');
assert.ok(w.stats.taken >= 2 * e.s.atk * tb('enemy_10116_ymgtop', 'RotateDamage.attack@atk_scale') - 1e-6);
h.b.displace(e, { x: -1, y: 0 }, 0.5, { force: 3 }); // pushed back towards the wall: still in reach
// pushed towards the wall (to x 6.8): still in reach, not in contact (0.8 > block radius 0.71: the wall would
// block it)
h.b.displace(e, { x: -1, y: 0 }, 0.2, { force: 3 });
h.step();
const t0 = w.stats.taken;
h.run(3);
+17 -8
View File
@@ -491,12 +491,16 @@ test('coin / refresh counters: 溯光星源 121 per 3 spent, 阿罗玛 122 / 安
// ---------------------------------------------------------------------------------------------------------------------
// price, items, refreshes, funds
test('SERVER_CHESS_PRICE: 至简 13 costs its bb.price (2; the text says 1), 红豆 04 (hidden) costs 1', () => {
test('SERVER_CHESS_PRICE: 购买价格为1 — bb.price is the discount off the tier price (至简 Ⅲ 3 − 2, 红豆 Ⅰ 2 − 1; user playtest #5)', () => {
for (const { gid, g, owners } of idsOf('SERVER_PRICE', 'SERVER_CHESS_PRICE')) {
const n = Number(/购买价格为(\d+)/.exec(g.desc)?.[1]);
assert.equal(n, 1, `${gid}: the official text`);
for (const owner of owners) {
const s = setup();
s.ps.shop.slots[0] = { kind: 'chess', id: owner, basePrice: s.m.gd.chessPrice(owner), frozen: false, sold: false };
assert.equal(s.ps.priceOf(s.ps.shop.slots[0]), g.bb.price, `${gid} ${owner}`);
const tierPrice = s.m.gd.chessPrice(owner);
assert.equal(tierPrice - g.bb.price, n, `${gid} ${owner}: tier price − bb.price = the text's price`);
s.ps.shop.slots[0] = { kind: 'chess', id: owner, basePrice: tierPrice, frozen: false, sold: false };
assert.equal(s.ps.priceOf(s.ps.shop.slots[0]), n, `${gid} ${owner}`);
}
cover(gid);
}
@@ -504,20 +508,25 @@ test('SERVER_CHESS_PRICE: 至简 13 costs its bb.price (2; the text says 1), 红
s.ps.shop.slots[0] = { kind: 'chess', id: 'chess_char_3_13_a', basePrice: 3, frozen: false, sold: false };
const funds = s.ps.funds;
assert.deepEqual(s.m.handle('p_0', { t: 'g.buy', slot: 0 }), { ok: true });
assert.equal(s.ps.funds, funds - 2);
assert.equal(s.ps.funds, funds - 1, '至简 costs 1');
assert.equal(GR('garrison_13_a').bb.price, 2);
assert.equal(GR('garrison_13_b').bb.price, 2);
});
test('regression: 购买价格 replaces the base price but keeps discounts applied earlier in the dispatch (远见-style −1)', () => {
test('regression: 购买价格为1 runs before the other price modifiers (远见 −1 never below 1 leaves 至简 at 1)', () => {
const s = setup();
s.m.dispatcher.registry.global('gar_test_disc', { onPrice(ctx) { ctx.modifyPrice(-1); } });
const seen = [];
s.m.dispatcher.registry.global('gar_test_seen', { onPrice(ctx, ev) { seen.push(ev.price); } });
try {
s.ps.shop.slots[0] = { kind: 'chess', id: 'chess_char_3_13_a', basePrice: 3, frozen: false, sold: false };
s.ps.shop.slots[1] = { kind: 'chess', id: 'chess_char_1_02_a', basePrice: 2, frozen: false, sold: false };
assert.equal(s.ps.priceOf(s.ps.shop.slots[0]), 1, '至简 2 − 1');
assert.equal(s.ps.priceOf(s.ps.shop.slots[0]), 1);
assert.deepEqual(seen, [1], 'a global modifier already sees 至简 at 1');
s.ps.counters['bondaddon:visi:disc'] = 2; // 远见 150 layers: every chess −1, never below 1
assert.equal(s.ps.priceOf(s.ps.shop.slots[0]), 1, '至简 3 − 2 = 1; 远见 leaves it at 1');
assert.equal(s.ps.priceOf(s.ps.shop.slots[1]), 1, 'other chess: 2 − 1');
} finally {
s.m.dispatcher.registry.unregister('global:gar_test_disc');
s.m.dispatcher.registry.unregister('global:gar_test_seen');
}
});
+3 -1
View File
@@ -746,7 +746,9 @@ test('夕 S3 写意胜形: BAT +0.4 s, ATK +, splash 1.7; every attack summons /
test('夕 S3 写意胜形: unblocked enemies first (the blocked one is attacked only without another choice)', () => {
const h = run({
defs: { enemies: { enemy_walk: enemyRec({ key: 'enemy_walk', hp: 1e7, speed: 1 }), enemy_dummy: dummy('enemy_dummy') }, chess: { t_block: ally('t_block', { stats: { blockCnt: 1 } }) } },
units: [entry('chess_char_5_12_a', 'skchr_dusk_3', { row: 10, col: 3 }), { chessId: 't_block', row: 9, col: 5 }],
// (the blocker at col 4: the walker stops 0.71 tile in front of it — block radius, user playtest #5 — on col 5,
// inside 夕's 3-column range)
units: [entry('chess_char_5_12_a', 'skchr_dusk_3', { row: 10, col: 3 }), { chessId: 't_block', row: 9, col: 4 }],
enemies: [{ key: 'enemy_walk', route: 0 }, { key: 'enemy_dummy', pos: [10, 6] }],
});
const u = h.unit('chess_char_5_12_a');
+30 -3
View File
@@ -441,7 +441,7 @@ test('1_14 格雷伊: 静电场 停顿 on the attacked target, ×talent_scale du
done(h1); done(h2);
});
test('1_15 盟约·辅助干员 (hidden): 迭代元素 18 % ATK 神经损伤 per hit; skill 2 targets + ASPD; elite boost vs ELITE', () => {
test('1_15 盟约·辅助干员 (hidden): 迭代元素 18 % ATK 神经 + 灼燃 + 凋亡 per damage; skill 2 targets + ASPD; elite boost vs ELITE', () => {
const id = 'chess_char_1_15_a', bb = bbOf(id), t = tal(id);
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [{ chessId: id, row: 10, col: 4, carryState: READY }], enemies: [{ key: 'e', pos: [10, 5] }, { key: 'e', pos: [10, 6] }] });
const u = h.unit(id);
@@ -450,7 +450,9 @@ test('1_15 盟约·辅助干员 (hidden): 迭代元素 18 % ATK 神经损伤 per
approx(u.s.aspd - u.base.aspd, bb.attack_speed);
assert.equal(h.hooksOf('attack').find((c) => c.attacker === u).targets.length, bb['attack@max_target']);
const el = dealt(h, u, (c) => c.type === 'element');
assert.ok(el.length >= 2 && el.every((c) => c.dmg.element === 'neural'));
assert.ok(el.length >= 6);
// every damage she deals attaches all three, in the official order (神经 "(优先)", then 灼燃, then 凋亡)
for (let i = 0; i + 2 < el.length; i += 3) assert.deepEqual(el.slice(i, i + 3).map((c) => c.dmg.element), ['neural', 'burn', 'apoptosis']);
for (const c of el) approx(c.amount, u.s.atk * t.ep_damage_ratio);
done(h);
@@ -724,7 +726,32 @@ test('2_05 哈洛德: 重症优先 heals the heaviest element load first with ×
assert.equal(first.targets[0], elem, 'element-damaged ally first');
approx(elem.elem.neural, 900 - u.s.atk * tbOf(id).ep_heal_ratio * bb.trait_scale, 'recovery ×1.6');
h.run(0.3);
approx(elem.s.elemTakenMul, 1 - t.ep_damage_resistance, '我即军营');
// 我即军营 cuts the 元素损伤 (gauge fill) of an ally over half, on the hit itself — not elemTakenMul (元素伤害 / 元素脆弱)
assert.ok(elem.elem.neural > 500, 'still over half');
const b0 = elem.elem.burn;
h.b.dealDamage(null, elem, { type: 'element', element: 'burn', amount: 100 });
approx(elem.elem.burn - b0, 100 * (1 - t.ep_damage_resistance), '我即军营');
approx(elem.s.elemTakenMul, 1, 'no 元素伤害 / 元素脆弱 change');
// under half: no cut
const n0 = low.elem.neural;
h.b.dealDamage(null, low, { type: 'element', element: 'neural', amount: 100 });
approx(low.elem.neural - n0, 100, 'gauge under half: full fill');
done(h);
});
test('2_05 哈洛德 (user playtest #5 #3): 侵蚀 counts as element damage for 重症优先 and 我即军营', () => {
const id = 'chess_char_2_05_a', t = tal(id);
const h = run({ units: [{ chessId: id, row: 10, col: 4, carryState: READY }, { chessId: 'chess_char_1_02_a', row: 10, col: 5 }, { chessId: 'chess_char_1_10_a', row: 11, col: 5 }] });
const u = h.unit(id), low = h.unit('chess_char_1_02_a'), eroded = h.unit('chess_char_1_10_a');
h.step();
low.hp = low.s.maxHp * 0.3;
eroded.elem.erosion = 900;
h.step();
const first = h.hooksOf('attack').find((c) => c.attacker === u);
assert.equal(first.targets[0], eroded, 'the eroded ally is the heaviest element load');
const e0 = eroded.elem.erosion;
h.b.dealDamage(null, eroded, { type: 'element', element: 'erosion', amount: 100 });
approx(eroded.elem.erosion - e0, 100 * (1 - t.ep_damage_resistance), '我即军营 on an eroded ally over half');
done(h);
});
+4 -2
View File
@@ -191,9 +191,11 @@ test('3_06 菲莱: 冥河诅咒 no attacks, HP +, counter arts + apoptosis (cd),
const u = h.unit(id);
h.run(0.2);
const e = h.enemy('enemy_h');
approx(u.s.elemTakenMul, 1 - t0.damage_resistance);
const sp0 = u.skill.sp;
// 神河谕使: a 元素损伤 multiplier on the hit (the gauge fill), not elemTakenMul (元素伤害 / 元素脆弱)
approx(u.s.elemTakenMul, 1);
const sp0 = u.skill.sp, a0 = u.elem.apoptosis;
h.b.dealDamage(e, u, { type: 'element', element: 'apoptosis', amount: 10 });
approx(u.elem.apoptosis - a0, 10 * (1 - t0.damage_resistance), 1e-6, 'element taken −damage_resistance');
approx(u.skill.sp, sp0 + t0.sp, 1e-6, '+SP on apoptosis');
fill(u);
assert.ok(h.runUntil(() => u.skill.active, 10));
+4 -4
View File
@@ -972,7 +972,7 @@ test('圣约送葬人 T1 受选之人: the extra attack consumes no ammo and nev
clean(h);
});
test('元素伤害 uses the engine elemental type: × elemTakenMul, never × trueTakenMul (烛煌 熔点引爆, 妮芙 失魂)', () => {
test('元素伤害 uses the engine elemental type: × elementalTakenMul (元素脆弱), never × trueTakenMul (烛煌 熔点引爆, 妮芙 失魂)', () => {
// one battle per burst: after a burst the enemy's gauges stay locked for its 爆发冷却 (damage.js)
const arena = () => {
const h = makeBattle({
@@ -983,7 +983,7 @@ test('元素伤害 uses the engine elemental type: × elemTakenMul, never × tru
h.step();
const e = h.enemy('enemy_dummy');
h.b.addBuff(e, { key: 't:true', mods: { trueTakenMul: 2 } });
h.b.addBuff(e, { key: 't:elem', mods: { elemTakenMul: 1.5 } });
h.b.addBuff(e, { key: 't:elem', mods: { elementalTakenMul: 1.5 } });
return { h, e, blaze: h.unit('chess_char_5_03_a'), nymph: h.unit('chess_char_5_22_a') };
};
const a = arena();
@@ -991,14 +991,14 @@ test('元素伤害 uses the engine elemental type: × elemTakenMul, never × tru
const melt = tagged(a.h, 'blazeMelt', a.e);
assert.equal(melt.length, 1);
assert.equal(melt[0].type, 'elemental');
approx(melt[0].amount, a.blaze.s.atk * tal(a.blaze, 0).ep_damage_scale * 1.5, 1e-6, 'meltdown × elemTakenMul only');
approx(melt[0].amount, a.blaze.s.atk * tal(a.blaze, 0).ep_damage_scale * 1.5, 1e-6, 'meltdown × elementalTakenMul only');
clean(a.h);
const { h, e, nymph } = arena();
h.b.dealDamage(null, e, { type: 'element', element: 'apoptosis', amount: 1000 });
assert.ok(h.runUntil(() => tagged(h, 'nymphSoul', e).length > 0, 6));
const soul = tagged(h, 'nymphSoul', e)[0];
assert.equal(soul.type, 'elemental');
approx(soul.amount, nymph.s.atk * tal(nymph, 0).element_atk_scale * 1.5, 1e-6, '失魂 × elemTakenMul only');
approx(soul.amount, nymph.s.atk * tal(nymph, 0).element_atk_scale * 1.5, 1e-6, '失魂 × elementalTakenMul only');
clean(h);
});
+3 -3
View File
@@ -143,14 +143,14 @@ test('#12 炎佑: 元素脆弱 aura within 1.5 of itself, immune to element dama
const [near, far] = h.enemies();
assert.ok(near.findBuff('elemFragile'), '1.2 away: 元素脆弱');
assert.ok(!far.findBuff('elemFragile'), '3.2 away: none');
assert.ok(Math.abs(near.s.elemTakenMul - 1.2) < 1e-9, 'element damage ×1.2');
assert.ok(Math.abs(near.s.elementalTakenMul - 1.2) < 1e-9, '元素伤害 ×1.2');
// element damage on the dragon is cancelled
h.b.dealDamage(near, y, { type: 'element', element: 'burn', amount: 800 });
assert.equal(y.elem.burn, 0, 'no burn gauge on 炎佑');
// normal hits: burn = ATK × 0.2 (× the 1.2 fragility) per hit
// normal hits: burn = ATK × 0.2 per hit — the 1.2 元素脆弱 (2.damage_scale) raises 元素伤害, never the gauge fill
const before = near.elem.burn;
assert.ok(h.runUntil(() => near.elem.burn > before, 5));
const tal = h.b.data.rawToken(TOKEN_IDS.yanyou).talents.bb;
assert.ok(Math.abs(near.elem.burn - before - y.s.atk * tal['2.ep_damage_ratio'] * tal['2.damage_scale']) < 1e-6, `burn per hit ${near.elem.burn - before}`);
assert.ok(Math.abs(near.elem.burn - before - y.s.atk * tal['2.ep_damage_ratio']) < 1e-6, `burn per hit ${near.elem.burn - before}`);
checkInvariants(h.b);
});
+231
View File
@@ -0,0 +1,231 @@
// User playtest #5 report #3 (operator side, workstream W3a): element damage (元素损伤) dealt by the player side and the
// operator-side gauge modifiers, against the official rules.
// * 盟约·辅助干员 迭代元素 — every damage she deals attaches 神经 + 灼燃 + 凋亡 (18 % ATK each, 神经 first); the kit used
// to fill 神经 only (its "first element not bursting" pick was dead: a burst locks every gauge). "造成伤害时": a
// dodged / fully absorbed hit attaches nothing. Elite RIT-X: ep_damage_ratio_boss vs elite / leader — the talent
// only.
// * 菲莱 精锐 PRP-X "阻挡敌人时,自身造成的元素损伤提升15%" — every element fill she deals (a carried 灼燃维式重锤 too);
// S2 冥河诅咒's counter area = range x-4 (the 3×3 tiles around her, PRTS 备注).
// * 受到的元素损伤降低 (菲莱 神河谕使, 哈洛德 我即军营, 纯烬艾雅法拉 火山灰疗愈) — a 元素损伤 multiplier on the hit, not
// elemTakenMul (元素伤害 / 元素脆弱: gamedata_const termDescriptionDict ba.elementfragile "受到的元素伤害提升").
// * a killing blow attaches no element (迭代元素, 灼燃维式重锤, 炎佑, 妮芙's S2 rider): their `damaged` hooks run before the
// kill, while the target is `alive` at 0 HP — a fill there used to burst the corpse (a second fatal, 烛煌's heal).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, enemyRec, chessRec, checkInvariants } from '../helpers/battleHarness.js';
import { getDefaultSource } from '../../server/sim/simdata.js';
import { PITHST_ELEMENTS } from '../../server/sim/content/kits/tier6.js';
import { spawnYanyou } from '../../server/sim/content/tokens.js';
import { COLS } from '../../server/sim/constants.js';
const SRC = getDefaultSource();
const raw = (id) => SRC.rawChess(id);
const tal = (id, i = 0) => raw(id).talents.filter((t) => t.index !== -1)[i].bb;
const dummy = (key, o = {}) => enemyRec({ key, hp: 1e7, speed: 0, ...o });
const approx = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg}: ${a} ≠ ${b}`);
const HAMMER = 'chess_item_4_09_e_a'; // 灼燃维式重锤: arts damage attaches 10 % of it as 灼燃损伤
const DEFS = {
enemies: {
enemy_w3a: dummy('enemy_w3a'), enemy_w3a_elite: dummy('enemy_w3a_elite', { rank: 'ELITE' }),
enemy_w3a_hit: dummy('enemy_w3a_hit', { atk: 150, bat: 0.5 }), enemy_w3a_fly: dummy('enemy_w3a_fly', { motion: 'FLY' }),
enemy_w3a_low: dummy('enemy_w3a_low', { hp: 5 }),
},
chess: { t_guard: chessRec({ id: 't_guard', profession: 'WARRIOR', stats: { maxHp: 5000 }, skill: null }) },
};
const battle = (o) => makeBattle({ defs: DEFS, timeLimit: 120, autoFinish: false, seed: 5, ...o });
const PITH = 'chess_char_1_15_a', PITH_B = 'chess_char_1_15_b';
// ---------------------------------------------------------------------------------------------------------------------
// 盟约·辅助干员
test('#3 盟约·辅助干员: each damage attaches 18 % ATK of 神经, 灼燃 and 凋亡 (in that order); alone, 神经 bursts', () => {
assert.deepEqual([...PITHST_ELEMENTS], ['neural', 'burn', 'apoptosis']);
const h = battle({ units: [{ chessId: PITH, row: 10, col: 4 }], enemies: [{ key: 'enemy_w3a', pos: [10, 5] }] });
const u = h.unit(PITH);
h.step();
const e = h.enemy('enemy_w3a');
const r = tal(PITH).ep_damage_ratio;
assert.ok(h.runUntil(() => e.elem.neural > 0, 5), 'first hit');
for (const el of PITHST_ELEMENTS) approx(e.elem[el], u.s.atk * r, `${el} = 18 % ATK`);
// the three gauges fill together; 神经 is applied first, so it is the one that bursts (PRTS 元素)
assert.ok(h.runUntil(() => h.hooksOf('elementBurst').length > 0, 40), 'a burst');
assert.equal(h.hooksOf('elementBurst')[0].element, 'neural');
assert.equal(h.hooksOf('elementBurst').length, 1, 'the burst locks the other two');
checkInvariants(h.b);
});
test('#3 盟约·辅助干员: her 灼燃 / 凋亡 complete the gauges her team builds (was: 神经 only)', () => {
for (const el of ['burn', 'apoptosis']) {
const h = battle({ units: [{ chessId: PITH, row: 10, col: 4 }], enemies: [{ key: 'enemy_w3a', pos: [10, 8] }] });
const u = h.unit(PITH);
h.step();
const e = h.enemy('enemy_w3a');
for (const k of Object.keys(e.elem)) e.elem[k] = 0;
e.elem[el] = 950; // e.g. 余's burn / 塑心's apoptosis so far
h.b.dealDamage(u, e, { amount: 100, type: 'arts', canDodge: false });
const b = h.hooksOf('elementBurst').filter((c) => c.target === e);
assert.equal(b.length, 1, `${el} burst`);
assert.equal(b[0].element, el);
const one = u.s.atk * tal(PITH).ep_damage_ratio;
approx(e.elem.neural, one, '神经 filled first');
// 灼燃 comes before 凋亡: it fills when 凋亡 bursts; the burst locks 凋亡 when 灼燃 bursts
if (el === 'apoptosis') approx(e.elem.burn, one, '灼燃 before the 凋亡 burst');
else assert.equal(e.elem.apoptosis, 0, 'the burst locks the element after it');
}
});
test('#3 盟约·辅助干员 "造成伤害时": a dodged or fully absorbed hit attaches no element; element damage never re-triggers it', () => {
for (const guard of [{ key: 't:barrier', shield: 1e9 }, { key: 't:dodge', mods: { dodgeArts: 1 } }]) {
const h = battle({ units: [{ chessId: PITH, row: 10, col: 4 }], enemies: [{ key: 'enemy_w3a', pos: [10, 5] }] });
const u = h.unit(PITH);
h.step();
const e = h.enemy('enemy_w3a');
for (const k of Object.keys(e.elem)) e.elem[k] = 0;
h.b.addBuff(e, guard);
const atk0 = u.stats.attacks;
h.run(6);
assert.ok(u.stats.attacks >= atk0 + 2, 'she attacked');
assert.equal(e.elem.neural + e.elem.burn + e.elem.apoptosis, 0, `${guard.key}: nothing attached`);
}
const h = battle({ units: [{ chessId: PITH, row: 10, col: 4 }], enemies: [{ key: 'enemy_w3a', pos: [10, 8] }] });
const u = h.unit(PITH);
h.step();
const e = h.enemy('enemy_w3a');
for (const k of Object.keys(e.elem)) e.elem[k] = 0;
h.b.dealDamage(u, e, { type: 'element', element: 'burn', amount: 10 });
h.b.dealDamage(u, e, { type: 'elemental', element: 'burn', amount: 10 });
assert.equal(e.elem.neural, 0, 'element fills / 元素伤害 are not "damage she deals" for 迭代元素');
});
test('#3 盟约·辅助干员 (elite): the RIT-X ratio vs elite enemies is the talent only — a carried 灼燃维式重锤 stays 10 %', () => {
const h = battle({ units: [{ chessId: PITH_B, row: 10, col: 4, items: [HAMMER] }], enemies: [{ key: 'enemy_w3a_elite', pos: [10, 8] }] });
const u = h.unit(PITH_B);
h.step();
const e = h.enemy('enemy_w3a_elite');
for (const k of Object.keys(e.elem)) e.elem[k] = 0;
const t = tal(PITH_B);
assert.ok(t.ep_damage_ratio_boss > t.ep_damage_ratio);
h.b.dealDamage(u, e, { amount: 1000, type: 'arts', canDodge: false });
approx(e.elem.neural, u.s.atk * t.ep_damage_ratio_boss, 'talent ×1.18 vs elite');
approx(e.elem.burn, u.s.atk * t.ep_damage_ratio_boss + 1000 * 0.1, 'talent + hammer (10 % of the arts damage, no ×1.18)');
});
test('#3 a killing blow attaches no element: no burst on the corpse, one fatal, no 烛煌 熔点引爆 heal', () => {
const BLAZE = 'chess_char_5_03_a', NYMPH = 'chess_char_5_22_a';
const cases = [
['盟约·辅助干员 迭代元素', [{ chessId: PITH, row: 10, col: 4 }], (h) => h.unit(PITH), {}],
['灼燃维式重锤', [{ chessId: 't_guard', row: 10, col: 4, items: [HAMMER] }], (h) => h.unit('t_guard'), {}],
['炎佑', [], (h) => spawnYanyou(h.b, 'p1', { atk: 400, hp: 5000 })[0], {}],
['妮芙 S2 凋亡', [{ chessId: NYMPH, row: 11, col: 3 }], (h) => h.unit(NYMPH), { isSkill: true, isAttack: true }],
];
for (const [label, units, srcOf, flags] of cases) {
const h = battle({ units: [...units, { chessId: BLAZE, row: 11, col: 6 }], enemies: [{ key: 'enemy_w3a_low', pos: [10, 12] }] });
h.step();
const src = srcOf(h), blaze = h.unit(BLAZE), e = h.enemy('enemy_w3a_low');
assert.ok(e.alive && src && src.deployed && blaze.deployed, label);
// 灼燃 / 凋亡 one fill from bursting (a burn burst would trigger 烛煌 熔点引爆); 神经 empty (迭代元素 applies it first)
for (const k of Object.keys(e.elem)) e.elem[k] = k === 'burn' || k === 'apoptosis' ? 990 : 0;
blaze.hp = blaze.s.maxHp * 0.5;
h.b.dealDamage(src, e, { amount: 1000, type: 'arts', canDodge: false, ...flags });
assert.equal(e.alive, false, `${label}: killed`);
assert.equal(h.hooksOf('elementBurst').filter((c) => c.target === e).length, 0, `${label}: no burst on the corpse`);
assert.equal(h.hooksOf('fatal').filter((c) => c.unit === e).length, 1, `${label}: one fatal`);
assert.equal(h.hooksOf('kill').filter((c) => c.victim === e).length, 1, `${label}: one kill`);
assert.equal(blaze.hp, blaze.s.maxHp * 0.5, `${label}: no 熔点引爆 heal`);
checkInvariants(h.b);
}
// control: the same hit on a living target still attaches (and bursts)
const h = battle({ units: [{ chessId: PITH, row: 10, col: 4 }], enemies: [{ key: 'enemy_w3a', pos: [10, 12] }] });
h.step();
const u = h.unit(PITH), e = h.enemy('enemy_w3a');
for (const k of Object.keys(e.elem)) e.elem[k] = k === 'burn' ? 990 : 0;
h.b.dealDamage(u, e, { amount: 1000, type: 'arts', canDodge: false });
assert.deepEqual(h.hooksOf('elementBurst').filter((c) => c.target === e).map((c) => c.element), ['burn'], 'alive: 灼燃 bursts');
});
// ---------------------------------------------------------------------------------------------------------------------
// 菲莱
test('#3 菲莱 (elite): PRP-X ×1.15 applies to every element fill she deals while blocking — a carried 灼燃维式重锤 too', () => {
const id = 'chess_char_3_06_b';
const scale = raw(id).trait.bb.ep_damage_scale;
assert.ok(scale > 1);
for (const [pos, blocking] of [[[10, 5], true], [[10, 8], false]]) {
const h = battle({ units: [{ chessId: id, row: 10, col: 5, items: [HAMMER] }], enemies: [{ key: 'enemy_w3a', pos }] });
const u = h.unit(id);
h.run(0.2);
const e = h.enemy('enemy_w3a');
assert.equal(u.blocking.includes(e), blocking);
for (const k of Object.keys(e.elem)) e.elem[k] = 0;
h.b.dealDamage(u, e, { amount: 1000, type: 'arts', canDodge: false });
approx(e.elem.burn, 1000 * 0.1 * (blocking ? scale : 1), blocking ? 'hammer burn ×1.15 while blocking' : 'not blocking: ×1');
}
});
test('#3 菲莱 S2 冥河诅咒: the counter hits the ground enemies of the 3×3 tiles around her (range x-4), not a 1.5 radius', () => {
const id = 'chess_char_3_06_a';
const h = battle({
units: [{ chessId: id, row: 10, col: 5, carryState: { sp: 999 } }],
enemies: [
{ key: 'enemy_w3a_hit', pos: [10, 5] }, // blocked, attacks her → the counter
{ key: 'enemy_w3a', pos: [10.45, 6.45] }, // tile (10, 6) of the 3×3, 1.52 tiles away
{ key: 'enemy_w3a', pos: [10, 7] }, // two tiles away
{ key: 'enemy_w3a_fly', pos: [11, 6] }, // flying: not a ground enemy
],
});
const u = h.unit(id);
assert.ok(h.runUntil(() => u.skill.active, 10), 'S2');
const [near, far] = h.b.enemies.filter((x) => x.defId === 'enemy_w3a').sort((a, b) => a.x - b.x);
const fly = h.enemy('enemy_w3a_fly');
const ep = raw(id).skill.bb.ep_damage_ratio;
assert.ok(h.runUntil(() => near.elem.apoptosis > 0, 10), 'the off-centre enemy of the 3×3 is hit');
approx(near.elem.apoptosis, u.s.atk * ep, 'apoptosis = ep_damage_ratio × ATK');
assert.equal(far.elem.apoptosis, 0, 'two tiles away: not hit');
assert.equal(fly.elem.apoptosis, 0, 'flying: not hit');
});
// ---------------------------------------------------------------------------------------------------------------------
// 受到的元素损伤降低: gauge only
test('#3 纯烬艾雅法拉 火山灰疗愈: allies in range take −12 % 元素损伤 (×talent_scale in S3) — the gauge, not elemTakenMul', () => {
const id = 'chess_char_6_20_a';
const h = battle({ units: [{ chessId: id, row: 10, col: 4 }, { chessId: 't_guard', row: 10, col: 5 }] });
const u = h.unit(id), g = h.unit('t_guard');
h.run(0.6);
const er = tal(id, 1).ep_damage_resistance;
assert.ok(g.findBuff('agoat2:ash'), 'in her range');
const n0 = g.elem.neural;
h.b.dealDamage(null, g, { type: 'element', element: 'neural', amount: 100 });
approx(g.elem.neural - n0, 100 * (1 - er), '−12 %');
approx(g.s.elemTakenMul, 1, 'no 元素伤害 / 元素脆弱 change');
// S3 (the default skill): the talent ×talent_scale
g.hp = g.s.maxHp * 0.5;
u.skill.sp = u.skill.spCost;
assert.ok(h.runUntil(() => u.skill.active, 10), 'S3');
h.run(0.6);
const b0 = g.elem.burn;
h.b.dealDamage(null, g, { type: 'element', element: 'burn', amount: 100 });
approx(g.elem.burn - b0, 100 * Math.max(0, 1 - er * raw(id).skill.bb.talent_scale), 'S3: ×talent_scale');
});
test('#3 纯烬艾雅法拉 氤氲: the bonus heal recovers heal_scale × the trait ep_heal_ratio × ATK of 元素损伤 per stack (was: heal_scale × ATK)', () => {
const id = 'chess_char_6_20_a';
// the guard stands outside her range: only the manual heals below reach it
const h = battle({ units: [{ chessId: id, row: 12, col: 2 }, { chessId: 't_guard', row: 9, col: 10 }] });
const u = h.unit(id), g = h.unit('t_guard');
h.step();
assert.ok(!u.rangeKeySet.has(g.tileR * COLS + g.tileC), 'guard out of her range');
const t0 = tal(id, 0), ratio = raw(id).trait.bb.ep_heal_ratio;
assert.ok(ratio > 0 && ratio < 1);
g.elem.neural = 900;
h.b.heal(u, g, 1); // a normal heal of hers → 1 stack
const n0 = g.elem.neural;
h.run(1.01);
approx(n0 - g.elem.neural, u.s.atk * t0.heal_scale * ratio, 'one stack: 10 % × 50 % ATK');
h.b.heal(u, g, 1); h.b.heal(u, g, 1); // 3 stacks (max), the duration refreshed
const mist = g.findBuff(`agoat2:mist:${u.id}`); // keyed per 纯烬 (two of them keep their own stacks)
assert.equal(mist.stacks, t0.max_stack_cnt);
const n1 = g.elem.neural;
h.run(1.01);
approx(n1 - g.elem.neural, u.s.atk * t0.heal_scale * ratio * t0.max_stack_cnt, '3 stacks');
});
+140
View File
@@ -0,0 +1,140 @@
// test/content/playtest5_followups.test.js — user playtest #5, cross-workstream follow-ups of the integration
// (docs/DESIGN.md §19): the element pipeline rules the workstreams left to the integrator, 塑心 S2's timing and
// 纯烬艾雅法拉's per-source 氤氲.
// * damage.js applyElement refuses a target with no HP left (hasHp — one shared helper): a rider in a `damaged` hook
// of a killing blow cannot burst the corpse, whatever content adds it.
// * 脆弱 (dmgTakenMul) does not scale 元素伤害 — gamedata_const ba.fragile "受到的物理、法术、真实伤害提升"; 元素脆弱
// (elementalTakenMul) does.
// * element healing lowers EVERY element type by the amount, each on its own — PRTS 菲莱 备注: "清除元素损伤" = "一次等同
// 于自身最大元素值(通常为1000)的全类型元素损伤治疗".
// * 塑心 S2 安魂的弥撒: "…凋亡损伤生效于当次触发的伤害之前,该造成的凋亡损伤的来源始终为塑心" (PRTS 备注) — the 凋亡 lands
// before the partner's damage, so a killing blow still carries it.
// * 纯烬艾雅法拉 氤氲 is keyed per 纯烬 (two of them keep their own stacks and heal source).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, enemyRec, chessRec, checkInvariants } from '../helpers/battleHarness.js';
import { getDefaultSource } from '../../server/sim/simdata.js';
import { hasHp } from '../../server/sim/damage.js';
const SRC = getDefaultSource();
const raw = (id) => SRC.rawChess(id);
const approx = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg}: ${a} ≠ ${b}`);
const dummy = (key, o = {}) => enemyRec({ key, hp: 1e7, speed: 0, ...o });
const plain = (id, o = {}) => chessRec({ id, skill: null, stats: { maxHp: 1e6, ...(o.stats || {}) }, ...o });
// ---------------------------------------------------------------------------------------------------------------------
// damage.js
test('applyElement refuses a target with no HP left: a rider on a killing blow bursts nothing (pipeline guard)', () => {
for (const lethal of [true, false]) {
let riders = 0;
const h = makeBattle({
content: 'none', autoFinish: false, timeLimit: 30,
defs: { enemies: { enemy_fu: dummy('enemy_fu', { hp: 1000 }) } },
enemies: [{ key: 'enemy_fu', pos: [10, 6] }],
// a content rider with no HP check of its own: 1000 灼燃 on every damage it sees
setup: (b) => b.on('damaged', (c) => {
if (c.type === 'element' || c.type === 'elemental' || c.target.side !== 'enemy') return;
riders++;
b.dealDamage(null, c.target, { type: 'element', element: 'burn', amount: 1000 });
}),
});
h.step();
const e = h.enemy('enemy_fu');
h.b.dealDamage(null, e, { amount: lethal ? 5000 : 10, type: 'true' });
assert.equal(riders, 1, 'the rider ran');
if (lethal) {
assert.equal(e.alive, false);
assert.equal(e.elem.burn, 0, 'no fill on the corpse');
assert.equal(h.hooksOf('elementBurst').length, 0, 'no burst on the corpse');
assert.equal(h.hooksOf('fatal').length, 1, 'one fatal');
assert.equal(h.hooksOf('kill').length, 1, 'one kill');
} else {
assert.equal(h.hooksOf('elementBurst').length, 1, 'a living target still bursts');
}
checkInvariants(h.b);
}
assert.equal(hasHp(null), false);
assert.equal(hasHp({ alive: true, hp: 0 }), false);
assert.equal(hasHp({ alive: true, hp: 0, bossPool: { hp: 5 } }), true, 'a boss: its pool');
assert.equal(hasHp({ alive: false, hp: 5 }), false);
});
test('脆弱 does not scale 元素伤害 (ba.fragile: 物理、法术、真实); 元素脆弱 does', () => {
const h = makeBattle({ content: 'none', autoFinish: false, timeLimit: 30, defs: { enemies: { enemy_fu: dummy('enemy_fu') } }, enemies: [{ key: 'enemy_fu', pos: [10, 6] }] });
h.step();
const e = h.enemy('enemy_fu');
const loss = (d) => { const hp0 = e.hp; h.b.dealDamage(null, e, d); return hp0 - e.hp; };
h.b.applyStatus(e, 'fragile', { duration: 10, value: 0.5 });
approx(loss({ amount: 1000, type: 'true' }), 1500, '脆弱 on true damage');
approx(loss({ amount: 1000, type: 'elemental', element: 'burn' }), 1000, '脆弱 skips 元素伤害');
h.b.applyStatus(e, 'elemFragile', { duration: 10, value: 0.2 });
approx(loss({ amount: 1000, type: 'elemental', element: 'burn' }), 1200, '元素脆弱');
approx(loss({ amount: 1000, type: 'true' }), 1500, '元素脆弱 skips true damage');
});
test('element healing lowers every element type by the amount, each on its own (PRTS 菲莱 备注)', () => {
const h = makeBattle({ content: 'none', autoFinish: false, timeLimit: 30, defs: { chess: { g: plain('g') } }, units: [{ chessId: 'g', row: 10, col: 4 }] });
h.step();
const g = h.unit('g');
Object.assign(g.elem, { burn: 300, neural: 500, apoptosis: 0 });
approx(h.b.reduceElement(g, 400), 700, 'total removed');
assert.deepEqual([g.elem.burn, g.elem.neural, g.elem.apoptosis], [0, 100, 0], 'each type −400 (was: 400 in total)');
Object.assign(g.elem, { burn: 300, neural: 500 });
approx(h.b.reduceElement(g, 200, 'neural'), 200, 'one element');
assert.deepEqual([g.elem.burn, g.elem.neural], [300, 300]);
});
// ---------------------------------------------------------------------------------------------------------------------
// 塑心 S2
test('塑心 S2 安魂的弥撒: the partner\'s 凋亡 lands before its damage (PRTS 备注) — a killing blow still carries it', () => {
const id = 'chess_char_6_09_a', sid = 'skchr_cello_2';
const s2 = raw(id).skills.find((s) => s.skillId === sid);
for (const hp of [1e7, 50]) {
const seq = [];
const h = makeBattle({
seed: 7, autoFinish: false, timeLimit: 60,
defs: { chess: { big: plain('big', { stats: { atk: 2000 } }) }, enemies: { enemy_fu: dummy('enemy_fu', { hp }) } },
units: [{ chessId: id, row: 10, col: 4, skillIndex: s2.index, carryState: { sp: 999 } }, { chessId: 'big', row: 10, col: 5 }],
enemies: [{ key: 'enemy_fu', pos: [10, 6] }],
setup: (b) => {
b.on('elementHit', (c) => { if (c.dmg.element === 'apoptosis' && c.target.side === 'enemy') seq.push(['el', c.source?.defId, c.target.hp]); });
b.on('damaged', (c) => { if (c.source?.defId === 'big' && c.type === 'phys') seq.push(['dmg', 'big', c.target.hp]); });
},
});
const u = h.unit(id), big = h.unit('big');
assert.ok(h.runUntil(() => u.skill.active && u.mem.celloPartner === big, 5), 'S2 running, partner picked');
assert.ok(h.runUntil(() => seq.some((x) => x[0] === 'dmg'), 10), 'the partner hits');
const i = seq.findIndex((x) => x[0] === 'dmg');
assert.ok(i > 0 && seq[i - 1][0] === 'el' && seq[i - 1][1] === id, `the 凋亡 (source 塑心) right before the damage: ${JSON.stringify(seq.slice(0, i + 1))}`);
assert.ok(seq[i - 1][2] > 0, 'the target still had its HP when the 凋亡 landed');
if (hp < 1000) assert.equal(h.enemy('enemy_fu')?.alive ?? false, false, 'the killing blow landed after it');
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
}
});
// ---------------------------------------------------------------------------------------------------------------------
// 纯烬艾雅法拉 氤氲
test('纯烬艾雅法拉 氤氲 is keyed per 纯烬: two of them keep their own stacks and each heals as itself', () => {
const id = 'chess_char_6_20_a';
const h = makeBattle({
seed: 5, autoFinish: false, timeLimit: 60,
defs: { chess: { g: plain('g') } },
// the ward stands outside both ranges: only the manual heals below reach it
units: [{ chessId: id, uid: 1, row: 12, col: 2 }, { chessId: id, uid: 2, row: 12, col: 3 }, { chessId: 'g', uid: 3, row: 9, col: 10 }],
});
h.step();
const a = h.unit(1), b = h.unit(2), g = h.unit(3);
h.b.heal(a, g, 1);
h.b.heal(b, g, 1);
h.b.heal(b, g, 1);
const ma = g.findBuff(`agoat2:mist:${a.id}`), mb = g.findBuff(`agoat2:mist:${b.id}`);
assert.ok(ma && mb, 'one 氤氲 per 纯烬');
assert.deepEqual([ma.stacks, mb.stacks], [1, 2]);
assert.equal(ma.source, a);
assert.equal(mb.source, b);
});
+3 -3
View File
@@ -478,7 +478,7 @@ test('炎佑: spawnYanyou — flying ally, bond stats, 3 targets with burn + ele
assert.ok(burned, 'burn gauge');
const frag = h.enemies().find((e) => e.findBuff('elemFragile'));
assert.ok(frag, 'elemental fragility (元素脆弱 status)');
approx(frag.s.elemTakenMul, 1.2, 1e-9);
approx(frag.s.elementalTakenMul, 1.2, 1e-9);
assert.ok(h.runUntil(() => y.skill.activations >= 1, 10), '祛恶之焰');
approx(y.skill.lastStart, 15, 1.5, 'first flame after initCooldown');
h.run(2);
@@ -885,8 +885,8 @@ test('炎佑 details: plain spawnToken works (enemy-shaped record), burn gauge =
assert.equal(y.motion, 'FLY');
const e = h.enemy('enemy_dummy');
assert.ok(h.runUntil(() => y.stats.attacks >= 1 && e.elem.burn > 0, 5));
// the hit's own 元素脆弱 (×1.2) lands before its burn fill
approx(e.elem.burn, 1000 * 0.2 * 1.2, 1e-6, 'gauge per hit');
// 元素脆弱 raises 元素伤害 only ("受到的元素伤害提升"), never the gauge fill (damage.js)
approx(e.elem.burn, 1000 * 0.2, 1e-6, 'gauge per hit');
assert.ok(h.runUntil(() => h.hooksOf('elementBurst').length > 0, 20), 'burn burst');
const burst = h.hooksOf('damaged').find((c) => c.type === 'elemental' && c.target === e);
assert.ok(burst, 'elemental burst damage');
+73 -2
View File
@@ -8,7 +8,9 @@
// act in (never wiped at the round start), the 回环射手 boomerang and 蕾缪安's shells one by one, the live LP, the detail
// card order and the static game data; user playtest #4 (DESIGN §18): picking by the tile under the pointer and the
// dragged model held under it, a single human untimed, the strategy draft's one countdown, 机变 two taps, knocked-out
// operators and the official element gauges, live stats, the shop-only items, skill summons, 炎佑.
// operators and the official element gauges, live stats, the shop-only items, skill summons, 炎佑; user playtest #5
// (DESIGN §19): blocking by contact radius, 联防 forced exit, huge-boss hit areas and 自缚, the element pipeline rules,
// boss-field deployment, the phone prep camera — and the normative §3 / §5.1 / §5.5 / §6.1 / §7 lines that changed.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
@@ -21,7 +23,10 @@ import { SOLO_RECONNECT_FALLBACK_SEC } from '../server/lobby.js';
import { moduleTypeIconUrl } from '../public/js/ui/assetUrls.js';
import { validateC2S } from '../shared/protocol.js';
import { ERR, PHASE } from '../shared/constants.js';
import { PROJECTILE_SPEEDS, BOOMERANG_RETURN_SPEED, ELEMENT, ELEMENT_ORDER, DOWN_STATE } from '../server/sim/constants.js';
import { PROJECTILE_SPEEDS, BOOMERANG_RETURN_SPEED, ELEMENT, ELEMENT_ORDER, DOWN_STATE, BLOCK_RADIUS, FORCED_EXIT } from '../server/sim/constants.js';
import { SELF_BOUND } from '../server/sim/content/bosses.js';
import * as BOARD from '../server/match/board.js';
import * as DAMAGE from '../server/sim/damage.js';
import { SUB } from '../server/sim/professions.js';
import { ENEMY_REACH } from '../public/js/render/pick.js';
import { BAND_TURN_SECONDS } from '../server/match/Match.js';
@@ -303,3 +308,69 @@ test('user playtest #4 (DESIGN §18): picking by tile, timers, 机变 two taps,
assert.ok(!/ATK\/HP are \*\*replaced\*\*/.test(DATA_MD), 'DATA: 炎佑 stats are added (最终加算)');
assert.match(DESIGN, /with no enemy on the field it stays where it is/);
});
test('user playtest #5 (DESIGN §19): blocking, 联防 forced exit, huge bosses, element pipeline, maps, phone camera — code and every doc agree', () => {
const sec = (n) => DESIGN.slice(DESIGN.indexOf(`## ${n}.`), DESIGN.indexOf(`## ${n + 1}.`) > 0 ? DESIGN.indexOf(`## ${n + 1}.`) : undefined);
const S19 = sec(19);
assert.match(DESIGN, /## 19\. User playtest #5 \(v2\.4\)/);
for (let i = 1; i <= 9; i++) assert.match(S19, new RegExp(`### 19\\.${i} `), `§19.${i}`);
for (let i = 1; i <= 11; i++) assert.match(S19.slice(0, S19.indexOf('### 19.1')), new RegExp(`#${i} `), `the intro maps report #${i}`);
// #4 blocking: the contact radius (code = §5.5 = SIM)
assert.deepEqual({ ...BLOCK_RADIUS }, { ground: 0.70709997, fly: 0.8944, device: 0.4472 });
const s55 = DESIGN.slice(DESIGN.indexOf('### 5.5'), DESIGN.indexOf('### 5.6'));
assert.match(s55, /ground 0\.7071/);
assert.match(s55, /takes the enemy over when its blocker dies, is withdrawn or is stunned/);
assert.ok(!/whose position enters its tile \(\|dx\|,\|dy\| ≤ 0\.5\)/.test(s55), '§5.5: the old same-tile block rule is gone');
assert.match(SIM, /taken over by another operator in contact with room, else they walk on/);
// #2 联防: carryState { down: true } → FORCED_EXIT (code = §5.1 / §5.5 / §6.1 / §18.3)
assert.equal(FORCED_EXIT, 'forcedExit');
assert.match(DESIGN, /carryState\?: \{ hpPct, sp, skillActive \} \| \{ down: true \}/);
assert.match(DESIGN, /or `FORCED_EXIT`, entering 联防 knocked out/);
assert.match(sec(6), /knocked out at the end of its own combat enters down/);
assert.match(PLAYING, /作战结束时已被击倒的干员在原位倒地/);
// #10 huge bosses: hit areas (§3), 自缚 (§7)
assert.equal(SELF_BOUND.length, 7);
assert.match(sec(3), /data `hitArea`/);
assert.match(sec(3), /`sim\/body\.js`/);
assert.match(sec(3), /中点判定/);
assert.match(sec(7), /`content\/bosses\.js SELF_BOUND`/);
assert.match(PLAYING, /自缚、无法被阻挡/);
// #3 elements: one hasHp, the pipeline guard, 脆弱 vs 元素伤害, element healing per type
assert.equal(typeof DAMAGE.hasHp, 'function');
for (const f of ['server/sim/content/kits/tier5.js', 'server/sim/content/kits/tier6.js', 'server/sim/content/items/battle.js']) {
assert.ok(!/const hasHp = /.test(readFileSync(join(ROOT, f), 'utf8')), `${f}: no local hasHp copy`);
}
assert.match(s55, /元素伤害 takes 元素脆弱 \(`elementalTakenMul`\) alone, not `dmgTakenMul`/);
assert.match(SIM, /lowers element\s+`el`, or every element type, each by `amount` on its own/);
assert.match(PLAYING, /无来源伤害/);
// audit rows as corrected by the reviews
assert.match(S19, /卢西恩 ATK 700 \(solo 600\)/);
assert.match(S19, /T2 1\.5 % max HP of HP and element per s with ≥ 4 operators on the field \(elite ≥ 2/);
assert.match(S19, /特制水上平台 are ×64/);
assert.match(S19, /756×366 CSS px/);
assert.ok(!/1\.5\/2 % max HP|×68|830×381|1500 \/ 750/.test(S19), '§19: no refuted audit figure');
// #7 boss-field deploy: the board row shift has one name, the opposite of prepfield's
assert.equal(BOARD.BOARD_ROWS_ABOVE_BOSS, 7);
assert.equal(BOARD.BOSS_ROW_SHIFT, undefined, 'board.js: no BOSS_ROW_SHIFT (render/prepfield.js has the opposite sign)');
assert.match(PLAYING, /关底战场上的半场/);
// #9 phone prep camera: §18.1's v2.3 figures superseded
assert.match(sec(18), /superseded by §19\.8/);
assert.ok(!/71 % now/.test(DESIGN), 'DESIGN §18.1: the v2.3 notched-phone figure is not current');
assert.ok(!/71 % now/.test(readFileSync(join(ROOT, 'public/css/devices.css'), 'utf8')), 'devices.css: no stale figure');
// #1 / #5 / #4 player guide
assert.match(PLAYING, /\*\*立刻接替阻挡\*\*/);
assert.match(PLAYING, /召唤物在所有干员之后/);
assert.match(PLAYING, /近地悬浮/);
// integration QA residuals: blocked-first / out-of-range selection is melee-only (code = §5.5 = §19.2 = SIM = PLAYING);
// the forced-out timer is re-read after battleStart; the gauge intake's 5 % floor; the boss field's devices
assert.match(s55, /\(1\) the enemies it blocks \(melee units only, in range or not\)/);
assert.match(s55, /a ranged operator on a melee tile blocks but attacks only what its range holds/);
assert.ok(!/every blocker, ranged ones on melee tiles included/.test(DESIGN), '§19.2: the old [ASSUMED] is gone');
assert.match(SIM, /A ranged operator standing on a melee tile blocks, but targets by its range alone/);
assert.match(PLAYING, /站在近战位的远程干员也能阻挡/);
assert.match(s55, /after which the forced-out operators' timers are re-read/);
assert.match(SIM, /Right after `battleStart`\s+its timer is re-read/);
assert.match(s55, /max\(0\.05, 1 − 损伤抵抗\/100\)/);
assert.match(SIM, /max\(5 %, 1 − 损伤抵抗 \/ 100\)/);
assert.match(S19, /the boss field's devices are drawn with the boss field only/);
});
+3 -1
View File
@@ -746,7 +746,9 @@ async function observeAfterFinish(c) {
});
if (pen) {
assert.equal(pen.visible, 0, '返回战场: no enemy pen figure');
assert.ok(pen.band[1] <= 13 && (pen.area == null || pen.area <= 12), `返回战场: no pen rows / area (${JSON.stringify(pen)})`);
// the 3D area ends at the field's separator row 13 (its devices blow into the field: act2 m01's blowers, user
// playtest #5 item 6); the pen starts at row 14
assert.ok(pen.band[1] <= 13 && (pen.area == null || pen.area <= 13), `返回战场: no pen rows / area (${JSON.stringify(pen)})`);
}
}
c.note(`observed ${other.fieldId} after the own battle (${st.units} views) and went back`);
+18
View File
@@ -36,6 +36,24 @@ test('field model: the round\'s routes traced over the own board, weighted by th
m.dispose();
});
test('field model: 近地悬浮 enemies (掠海漂移体) walk the ground path but count as air units (no blocker credit, anti-air needed)', () => {
const h = soloBot({ seed: 2 }).start();
const m = h.m;
h.run(() => m.phase === PHASE.PREP && m.round === 2);
const groundRi = new Set(m.wave.routes.map((r, i) => (r.motion !== 'FLY' ? i : -1)).filter((i) => i >= 0));
const before = fieldModel(m);
assert.ok(before.ground.size > 0);
m.wave = { ...m.wave, spawns: m.wave.spawns.map((s) => (groundRi.has(s.routeIndex) && s.tag !== 'boss' ? { ...s, enemyKey: 'enemy_2025_syufo' } : s)) };
m._botPath = null;
const model = fieldModel(m);
const hover = m.wave.spawns.filter((s) => s.enemyKey === 'enemy_2025_syufo').reduce((a, s) => a + Math.max(1, s.count || 1), 0);
assert.ok(hover > 0);
assert.ok(model.flyTotal >= hover, `${model.flyTotal} ≥ ${hover}`);
const hov = model.routes.filter((r) => r.fly && r.tiles.some((k) => before.ground.has(k)));
assert.ok(hov.length > 0, 'an air route on the ground path tiles');
m.dispose();
});
test('layout planner: legal distinct tiles; blockers on the enemy road, ranged units cover it without standing on it', () => {
const h = soloBot({ seed: 3 }).start();
const m = h.m;
+5 -3
View File
@@ -166,7 +166,7 @@ test('联防: trigger, helpers (≤ 2, highest LP then seat), carry state, sourc
m.dispose();
});
test('联防: an operator dead at the end of the helper\'s own combat stays out (with its summons); the living carry their state', () => {
test('联防: an operator knocked out at the end of the helper\'s own combat is fielded `down` (强制退场, with its summons); the living carry their state', () => {
let deadUid = null;
const h = makeMatch({
mode: 'coop', humans: 2, seed: 37, fake: true,
@@ -194,8 +194,10 @@ test('联防: an operator dead at the end of the helper\'s own combat stays out
assert.deepEqual(m.unitePlan.helpers.map((p) => p.playerId), ['p_1']);
const u = FakeBattle.instances.find((b) => b.kind === 'unite');
const units = u.opts.players[0].units;
assert.ok(!units.some((x) => x.uid === hemo.uid), 'the dead operator is not redeployed at full HP');
assert.ok(!units.some((x) => x.kind === 'token' && x.ownerUid === hemo.uid), 'nor are its summons');
// PRTS 卫戍协议/帮助: deployed, then "上一阶段为退场状态的干员强制退场" — the sim puts it down on its tile with its redeploy
// timer (user playtest #5 item 2: it used to be left out and vanished); its summons are fielded as the board has them
assert.deepEqual(units.find((x) => x.uid === hemo.uid)?.carryState, { down: true }, 'the knocked-out operator enters 联防 down');
assert.ok(units.some((x) => x.kind === 'token' && x.ownerUid === hemo.uid), 'its summons are fielded');
assert.deepEqual(units.find((x) => x.uid === alive.uid).carryState, { hpPct: 0.5, sp: 3, skillActive: false });
assert.ok(helper.board.has(`${t0[0]},${t0[1]}`) && helper.board.has(placed), 'the board itself is untouched (next round as usual)');
// during 联防 an 'n:<pid>' id names no field: refused, and the viewer keeps streaming the 联防 field
+1 -1
View File
@@ -173,7 +173,7 @@ function legacyInvariants(m) {
const all = [...ps.board.values(), ...ps.hand.filter(Boolean), ...ps.temp.filter(Boolean)];
for (const p of all) if (p.kind === 'chess') chessUids.add(p.uid);
let deployed = 0;
const dmap = ps.deployMap();
const dmap = m.deployMapFor(ps); // a pure read (as server/match/invariants.js): no PlayerState cache touched
for (const [k, p] of ps.board) {
const [r, c] = k.split(',').map(Number);
assert.ok(r >= FIELD.r0 && r <= FIELD.r1 && c >= FIELD.c0 && c <= FIELD.c1, `piece outside the board ${k}`);
+316
View File
@@ -0,0 +1,316 @@
// User playtest #5 item 7 — the Final Assault prep deploys on the player's half of the BOSS field (research 09 §1.2,
// official ConvertChessPositionInfoToBossMap / player_map_ud_offset 7): placement legality (server deploy map, the
// client's canPlace + legal-tile highlights, the bot) reads the boss field's tiles, not the normal field's.
// User: "这张关底boss图靠右侧由1*3的管子框起来的地块官方原版是能部署干员的" — act2autochess_m01's 1×3 orange-railed
// tiles (tile_fence_bound, LOW / buildable ALL) at boss (3–5, 8) and (3–5, 12) = board (10–12, 8) for both sides of a
// pair; on the normal field board (10–12, 8) is '#' (tile_forbidden), which the remake used to read in R14 too.
// Audit: for every stage and every deploy field the server map equals the builder's official deploy tiles
// (stages.json deployTiles.normal / bossLeft / bossRight, from the level files' buildableType / heightType + devices),
// the client mirror equals the server, and nothing legal on the normal board becomes illegal in the boss round.
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { buildDeployMap, canPlace, fieldTile, boardTileOf, tileKey, FIELD } from '../../server/match/board.js';
import { bossFieldPlacement } from '../../server/match/finalAssault.js';
import { ERR, PHASE } from '../../shared/constants.js';
import {
deployMap as clientDeployMap, deploySets, placementContext, canPlace as clientCanPlace, boardTargets, deployFieldOf,
fieldTile as clientFieldTile,
} from '../../public/js/ui/gameLogic.js';
import { planLayout } from '../../server/match/bot.js';
import { collectViolations } from '../../server/match/invariants.js';
import { DATA, makeMatch, give, chessOfTier, checkInvariants } from './harness.js';
const STAGE = 'act2autochess_m01';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '../..');
const LEVELS = join(ROOT, '.cache', 'gamedata', 'levels', 'activities');
const levelFile = (id) => join(LEVELS, id.startsWith('act2') ? 'act2autochess' : 'act1autochess', `level_${id}.json`);
const HAS_LEVELS = Object.keys(DATA.stages).every((id) => existsSync(levelFile(id)));
const MELEE = (c) => c.position === 'MELEE';
const RANGED = (c) => c.position === 'RANGED';
const keysOf = (map, cls) => [...map].filter(([, v]) => !cls || v === cls).map(([k]) => k).sort();
/** stages.json deploy tiles of a boss half (field coordinates, rows 1–5) → board keys of deploy field `field`. */
const officialBoss = (st, field) => {
const dt = st.deployTiles[field === 'bossR' ? 'bossRight' : 'bossLeft'];
const toKey = ([r, c]) => { const [br, bc] = boardTileOf(field, r, c); return tileKey(br, bc); };
const inBoard = ([r, c]) => { const [br, bc] = boardTileOf(field, r, c); return br >= FIELD.r0 && br <= FIELD.r1 && bc >= FIELD.c0 && bc <= FIELD.c1; };
return { melee: dt.melee.filter(inBoard).map(toKey).sort(), ranged: dt.rangedOnly.filter(inBoard).map(toKey).sort() };
};
/** A 2-human co-op match on act2 m01 at the prep of round `round` (fake battles: nobody leaks). */
function coopAt(round, o = {}) {
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 2, seed: o.seed ?? 21, fake: true }).start();
h.setStage(STAGE);
h.toPrep(round);
return h;
}
/** Empty the player's board and hand (deterministic scenario). */
function clean(ps) {
for (const p of [...ps.board.values(), ...ps.hand.filter(Boolean)]) if (p.kind === 'chess') ps.returnCopies(p);
ps.board.clear();
ps.hand.fill(null);
ps.recompute();
}
describe('#7 the Final Assault prep deploys on the boss field', () => {
test('repro: act2 m01 R14 — a melee operator may stand on the 1×3 fenced tiles (board col 8, rows 10–12), both sides', () => {
const h = coopAt(14);
const m = h.m;
assert.equal(m.round, m.gd.bossRound);
assert.equal(m.phase, PHASE.PREP);
const meleeIds = [1, 2, 3].flatMap((t) => chessOfTier(t, MELEE)).filter((id) => m.pool.has(id));
let next = 0;
for (const [pid, field, bossCol] of [['p_0', 'bossL', 8], ['p_1', 'bossR', 12]]) {
const ps = h.ps(pid);
clean(ps);
assert.equal(m.deployFieldOf(ps), field, `${pid} deploys on ${field}`);
for (const row of [10, 11, 12]) {
// what the player sees under that board tile: the boss field's fenced tile
const at = bossFieldPlacement(field === 'bossR' ? 'R' : 'L', row, 8);
assert.deepEqual([at.row, at.col], [row - 7, bossCol]);
assert.equal(DATA.stages[STAGE].rows[at.row][at.col], 'b', 'tile_fence_bound');
const a = give(m, ps, meleeIds[next++]);
assert.deepEqual(m.handle(pid, { t: 'g.move', uid: a.uid, to: { area: 'board', row, col: 8 } }), { ok: true }, `${pid} melee on board ${row},8`);
}
// the lane (floor, buildable NONE) stays closed on the boss field too
const b = give(m, ps, meleeIds[next++]);
assert.deepEqual(m.handle(pid, { t: 'g.move', uid: b.uid, to: { area: 'board', row: 10, col: 9 } }), { error: ERR.BAD_TILE });
}
checkInvariants(m);
});
test('a normal round still reads the normal field (board col 8 rows 10–12 is forbidden there)', () => {
const h = coopAt(13);
const m = h.m;
const ps = h.ps('p_0');
clean(ps);
assert.equal(m.deployFieldOf(ps), 'normal');
const a = give(m, ps, chessOfTier(1, MELEE).find((id) => m.pool.has(id)));
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: a.uid, to: { area: 'board', row: 11, col: 8 } }), { error: ERR.BAD_TILE });
});
test('the boss round keeps every piece placed in the normal rounds and the boss battle stands them on the boss tiles', () => {
const h = coopAt(13);
const m = h.m;
const ps = h.ps('p_0');
clean(ps);
const r1 = give(m, ps, chessOfTier(1, RANGED).find((id) => m.pool.has(id)));
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: r1.uid, to: { area: 'board', row: 11, col: 4 } }), { ok: true }, 'high ground');
h.toPrep(14);
assert.equal(ps.board.get('11,4')?.uid, r1.uid, 'kept on its tile (normal ⊆ boss legality on every stage)');
const input = ps.battleInput({ side: 'L' });
assert.ok(input.units.some((u) => u.uid === r1.uid && u.row === 11 && u.col === 4), 'board coordinates in the battle input');
});
test('boss → boss: pieces on the fenced tiles in R14 stay there into the R15 (隐秘核心) prep, no terrain-change toast', () => {
// review finding: Match.startRound cleared bossWaves before PlayerState.startRound's recompute, so R15's first
// legality check read the NORMAL field and withdrew the pieces standing on board (10–12, 8)
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 2, seed: 52, fake: true, script: (b) => (b.kind === 'boss' || b.kind === 'hidden' ? { bossDps: 1e9 } : {}) }).start();
h.setStage(STAGE);
const m = h.m;
h.drive(() => m.phase === PHASE.PREP && m.round === m.gd.bossRound);
const toasts = [];
const toast = m.toast.bind(m);
m.toast = (ps, kind, text) => { toasts.push(`${ps.playerId}:${text}`); return toast(ps, kind, text); };
const fields = [];
m.deployFieldOf = ((orig) => function (ps) { const f = orig.call(this, ps); if (this.round === this.gd.hiddenRound) fields.push(f); return f; })(m.deployFieldOf);
const placed = {};
const meleeIds = chessOfTier(1, MELEE).filter((id) => m.pool.has(id));
for (const [i, pid] of ['p_0', 'p_1'].entries()) {
const ps = h.ps(pid);
clean(ps);
const a = give(m, ps, meleeIds[i]);
assert.deepEqual(m.handle(pid, { t: 'g.move', uid: a.uid, to: { area: 'board', row: 11, col: 8 } }), { ok: true });
placed[pid] = a.uid;
// Σ activated layers > 1200 (co-op) unlocks the hidden core after the win (finalAssault.test.js)
ps.bondCountBonus.yanShip = 3; ps.layers.yanShip = 601; ps.recompute();
}
h.drive(() => m.phase === PHASE.FINAL_ASSAULT);
h.drive(() => m.phase === PHASE.PREP && m.round === m.gd.hiddenRound);
assert.ok(fields.length > 0 && fields.every((f) => f === 'bossL' || f === 'bossR'), `R15 never reads the normal field (${[...new Set(fields)]})`);
for (const pid of ['p_0', 'p_1']) assert.equal(h.ps(pid).board.get('11,8')?.uid, placed[pid], `${pid} kept on the fenced tile in R15`);
assert.deepEqual(toasts.filter((t) => t.includes('地形变化')), []);
checkInvariants(m);
m.dispose();
});
test('re-pairing before the fight (a teammate quits) moves the right player onto the left half', () => {
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 3, seed: 5, fake: true }).start();
h.setStage(STAGE);
h.toPrep(14);
const m = h.m;
assert.equal(m.deployFieldOf(h.ps('p_1')), 'bossR');
assert.equal(m.deployFieldOf(h.ps('p_2')), 'bossL', 'the odd player alone on the left half');
m.onLeave('p_0');
assert.equal(m.deployFieldOf(h.ps('p_1')), 'bossL');
assert.equal(m.deployFieldOf(h.ps('p_2')), 'bossR');
// re-checked at once (Match._quit recomputes the players left), not at their next action
for (const [id, field] of [['p_1', 'bossL'], ['p_2', 'bossR']]) {
assert.equal(h.ps(id)._deployField, field, `${id} board re-checked on ${field}`);
assert.equal(h.ps(id)._legalityStale, false);
}
checkInvariants(m);
});
test('the invariant checker reads the deploy field without touching the player\'s cache', () => {
const h = coopAt(13);
const m = h.m;
const ps = h.ps('p_1');
ps.deployMap();
assert.equal(ps._deployField, 'normal');
m._planBossWaves(); // the boss pairing appears (as at R14's round start) before the player's next recompute
assert.equal(m.deployFieldOf(ps), 'bossR');
collectViolations(m);
checkInvariants(m);
assert.equal(ps._deployField, 'normal', 'collectViolations / checkInvariants are pure reads');
assert.equal(ps._legalityStale, false);
});
test('the bot plans boss-round layouts on the boss field (it may use the fenced tiles)', () => {
const h = coopAt(14);
const m = h.m;
const ps = h.ps('p_0');
clean(ps);
const ids = chessOfTier(2, MELEE).filter((id) => m.pool.has(id)).slice(0, 6);
const pieces = ids.map((id) => give(m, ps, id));
const plan = planLayout(m, ps, pieces);
const map = ps.deployMap();
for (const k of plan.values()) assert.equal(map.get(k), 'melee', `planned ${k} is a melee tile of the boss half`);
assert.equal(map.get('11,8'), 'melee');
});
});
describe('#7 audit: every stage × deploy field (server = official level data = client)', () => {
test('server deploy map on each field equals the builder\'s official deploy tiles', () => {
for (const [id, st] of Object.entries(DATA.stages)) {
const n = buildDeployMap(st);
assert.deepEqual(keysOf(n, 'melee'), st.deployTiles.normal.melee.map(([r, c]) => tileKey(r, c)).sort(), `${id} normal melee`);
assert.deepEqual(keysOf(n, 'ranged'), st.deployTiles.normal.rangedOnly.map(([r, c]) => tileKey(r, c)).sort(), `${id} normal ranged`);
for (const field of ['bossL', 'bossR']) {
const b = buildDeployMap(st, { field });
const off = officialBoss(st, field);
assert.deepEqual(keysOf(b, 'melee'), off.melee, `${id} ${field} melee`);
assert.deepEqual(keysOf(b, 'ranged'), off.ranged, `${id} ${field} ranged`);
}
}
});
test('client mirror equals the server on every field (with and without terrain overrides)', () => {
for (const [id, st] of Object.entries(DATA.stages)) {
const aliases = (st.devices || []).filter((d) => d.alias && (d.role === 'crate' || d.role === 'platform'));
const variants = [{}, { deviceOverrides: Object.fromEntries(aliases.map((d) => [d.alias, !d.active])) }];
for (const field of ['normal', 'bossL', 'bossR']) {
for (const ov of variants) {
const srv = buildDeployMap(st, { ...ov, field });
const cli = clientDeployMap(st, { ...ov, field });
assert.deepEqual([...cli.entries()].sort(), [...srv.entries()].sort(), `${id} ${field} ${JSON.stringify(ov).slice(0, 40)}`);
}
const sets = deploySets(st, field);
const srv = buildDeployMap(st, { field });
assert.deepEqual([...sets.melee].sort(), keysOf(srv, 'melee'), `${id} ${field} deploySets melee`);
assert.deepEqual([...sets.ranged].sort(), keysOf(srv).sort(), `${id} ${field} deploySets ranged`);
}
}
for (const f of ['normal', 'bossL', 'bossR']) for (const [r, c] of [[9, 2], [12, 10], [10, 8]]) assert.deepEqual(clientFieldTile(f, r, c), fieldTile(f, r, c));
});
test('independent check against the raw official level files (buildableType / heightType + non-hidden devices)', { skip: !HAS_LEVELS && 'no .cache/gamedata level files (run node tools/build-data.mjs once)' }, () => {
const ROLE = { trap_1105_accrate: 'block', trap_032_mound: 'block', trap_1106_achplat: 'platform' };
for (const [id, st] of Object.entries(DATA.stages)) {
const lv = JSON.parse(readFileSync(levelFile(id), 'utf8'));
const map = lv.mapData.map, tiles = lv.mapData.tiles, H = map.length;
const dev = new Map();
for (const t of lv.predefines?.tokenInsts || []) if (ROLE[t.inst?.characterKey] && !t.hidden) dev.set(`${t.position.row},${t.position.col}`, ROLE[t.inst.characterKey]);
const official = (r, c) => {
const t = tiles[map[H - 1 - r]?.[c]];
const d = dev.get(`${r},${c}`);
if (!t || d === 'block') return null;
// 射击台: ranged only [ASSUMED, DATA §15.11] (the remake's rule for a raised platform)
if (d === 'platform') return 'ranged';
if (t.heightType === 'LOWLAND' && (t.buildableType === 'ALL' || t.buildableType === 'MELEE')) return 'melee';
if (t.buildableType === 'RANGED' || (t.heightType === 'HIGHLAND' && t.buildableType === 'ALL')) return 'ranged';
return null;
};
for (const field of ['normal', 'bossL', 'bossR']) {
const srv = buildDeployMap(st, { field });
for (let r = FIELD.r0; r <= FIELD.r1; r++) for (let c = FIELD.c0; c <= FIELD.c1; c++) {
const [sr, sc] = fieldTile(field, r, c);
assert.equal(srv.get(tileKey(r, c)) ?? null, official(sr, sc), `${id} ${field} board ${r},${c} = stage ${sr},${sc}`);
}
}
// 联防: the second helper's board stands on the right half shifted by player_map_lr_offset 8 — the same classes
for (let r = FIELD.r0; r <= FIELD.r1; r++) for (let c = FIELD.c0; c <= FIELD.c1; c++) {
if (official(r, c)) assert.equal(official(r, c + 8), official(r, c), `${id} 联防 right half ${r},${c + 8}`);
}
// benches: hand row 7 (boss row 0, the right half mirrored), temp row 8 cols 4–8 (boss row 1)
const key = (r, c) => tiles[map[H - 1 - r][c]].tileKey;
for (let i = 0; i < 10; i++) for (const [r, c] of [[7, i], [0, i], [0, 20 - i]]) assert.equal(key(r, c), 'tile_achand', `${id} hand ${r},${c}`);
for (let c = 4; c <= 8; c++) for (const [r, cc] of [[8, c], [1, c], [1, 20 - c]]) assert.equal(key(r, cc), 'tile_achand', `${id} temp ${r},${cc}`);
}
});
test('carry-over: a tile legal on the normal board is legal (same class) on both boss halves of every active stage', () => {
for (const [id, st] of Object.entries(DATA.stages)) {
const n = buildDeployMap(st);
for (const field of ['bossL', 'bossR']) {
const b = buildDeployMap(st, { field });
for (const [k, cls] of n) {
const [r, c] = k.split(',').map(Number);
if (cls === 'melee') assert.ok(canPlace(b, 'melee', r, c), `${id} ${field}: melee ${k}`);
else assert.ok(canPlace(b, 'ranged', r, c), `${id} ${field}: ranged ${k}`);
}
}
}
});
test('client placement: the boss prep lights and accepts the fenced tiles; the normal prep refuses them', () => {
const st = DATA.stages[STAGE];
const meleeId = chessOfTier(1, MELEE)[0];
const priv = { hand: [{ uid: 1, kind: 'chess', id: meleeId }, ...Array(9).fill(null)], temp: Array(5).fill(null), board: [], deployCap: 8, deployCount: 0 };
const getChess = (id) => DATA.chess[id];
for (const field of ['bossL', 'bossR']) {
const ctx = placementContext({ priv, stage: st, editable: true, field, getChess });
assert.equal(ctx.field, field);
for (const row of [10, 11, 12]) assert.equal(clientCanPlace(ctx, 1, { area: 'board', row, col: 8 }).ok, true, `${field} ${row},8`);
const lit = boardTargets(ctx, 1).legal.map(([r, c]) => tileKey(r, c));
assert.ok(['10,8', '11,8', '12,8'].every((k) => lit.includes(k)), `${field}: legal highlights ${lit}`);
}
const ctx = placementContext({ priv, stage: st, editable: true, getChess });
assert.equal(clientCanPlace(ctx, 1, { area: 'board', row: 11, col: 8 }).ok, false);
});
test('the DOM fallback board draws the boss half in the boss prep: the fenced tiles are floor, not walls', async () => {
// integration QA: with ?render=fallback (or no WebGL) the boss prep drew the normal field, whose board (10–12, 8) is
// '#' — highlighted as legal but drawn as walls
const { fallbackTileClass } = await import('../../public/js/ui/fallbackField.js');
const st = DATA.stages[STAGE];
for (const row of [10, 11, 12]) {
assert.equal(fallbackTileClass(st, row, 8), 'void', `normal field ${row},8: a wall`);
for (const field of ['bossL', 'bossR']) {
const [r, c] = clientFieldTile(field, row, 8);
assert.equal(fallbackTileClass(st, r, c), 'floor', `${field} board ${row},8 = boss ${r},${c}`);
}
}
const src = readFileSync(join(ROOT, 'public/js/ui/fallbackField.js'), 'utf8');
assert.match(src, /fieldTile\(st\.deployField, row, col\)/, 'the fallback maps its board tiles through fieldTile in prep');
assert.match(src, /st\.deployField = kind === 'bossPrep' \? \(o\?\.side === 'R' \? 'bossR' : 'bossL'\) : 'normal'/);
const game = readFileSync(join(ROOT, 'public/js/screens/game.js'), 'utf8');
assert.match(game, /const prepCam = prepCamera\(pub, myId\);/, 'the match screen hands the fallback view the boss prep camera too');
});
test('deployFieldOf follows the boss-round pairing (alive players by seat, two by two)', () => {
const players = [{ playerId: 'a', seat: 0 }, { playerId: 'b', seat: 1 }, { playerId: 'c', seat: 2, alive: false }, { playerId: 'd', seat: 3 }];
const pub = { round: 14, bossRound: 14, hiddenRound: 15, players };
assert.equal(deployFieldOf(pub, 'a'), 'bossL');
assert.equal(deployFieldOf(pub, 'b'), 'bossR');
assert.equal(deployFieldOf(pub, 'd'), 'bossL');
assert.equal(deployFieldOf(pub, 'c'), 'normal', 'eliminated');
assert.equal(deployFieldOf({ ...pub, round: 15 }, 'b'), 'bossR', 'hidden core');
assert.equal(deployFieldOf({ ...pub, round: 13 }, 'b'), 'normal');
assert.equal(deployFieldOf(null, 'a'), 'normal');
});
});
+122
View File
@@ -0,0 +1,122 @@
// test/match/unitedown.test.js — user playtest #5 item 2: an operator knocked out (濒死) in a 联防 helper's own combat
// vanished in the 联防 phase instead of keeping its redeploy countdown and redeploying (unite.js left it out of the
// battle input). Official (PRTS 卫戍协议/帮助 §联防阶段): "部署完成后,将对应单位的生命比例、技力修改至与上一阶段结束时相同
// (召唤物仅修改技力,上一阶段为退场状态的干员强制退场)" — it is deployed with everyone and forced out at once: down on its
// own tile with the redeploy ring, its timer running, and back like after any knock-out (tile free, DP ≥ cost).
// The match builds the input (both combat modes); the real sim runs it the way the authority, the display replicas
// (partner, observers) and the server fallback do.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { PHASE } from '../../shared/constants.js';
import { Battle } from '../../server/sim/Battle.js';
import { createBattleFromSpec } from '../../server/sim/spec.js';
import { DataSource } from '../../server/sim/simdata.js';
import { DOWN_STATE, FORCED_EXIT } from '../../server/sim/constants.js';
import { FakeBattle } from './fakeBattle.js';
import { DATA, makeMatch, give, chessOfTier, legalTileFor, checkInvariants } from './harness.js';
/** A Battle that never ends by itself (the check needs the whole redeploy timer, whatever the enemies do). */
class NoFinish extends Battle {
constructor(o) { super({ ...o, autoFinish: false, timeLimit: 1e6 }); }
}
const downOf = (b, id) => (b.snapshot().down || []).find((d) => d[0] === id) || null;
/**
* p_0 leaks; p_1 and p_2 are perfect. p_1 (more units: the right-hand field) and p_2 each have an operator knocked out
* at the end of their own combat next to one that stands.
*/
function scenario(clientCombat) {
const dead = new Set();
const h = makeMatch({
mode: 'coop', humans: 3, seed: 3702, fake: true, clientCombat,
script: (b) => (b.kind === 'normal' ? { leaks: { p_0: 3 }, deadUids: [...dead] } : {}),
}).start();
const m = h.m;
h.toPrep(1);
const melee = chessOfTier(1, (c) => c.position === 'MELEE').filter((x) => m.pool.has(x));
const ranged = chessOfTier(1, (c) => c.position === 'RANGED').filter((x) => m.pool.has(x));
const place = (ps, id) => give(m, ps, id, 'board', legalTileFor(m, ps, id));
const p1 = h.ps('p_1'), p2 = h.ps('p_2');
const down1 = place(p1, melee[0]);
const up1 = place(p1, ranged[0]);
place(p1, ranged[1]); // p_1 fields more units: the first helper, on the right-hand half
const down2 = place(p2, melee[1]);
const up2 = place(p2, ranged[2]);
dead.add(down1.uid);
dead.add(down2.uid);
h.drive(() => m.phase === PHASE.UNITE);
assert.deepEqual(m.unitePlan.helpers.map((p) => p.playerId), ['p_1', 'p_2']);
return { h, m, down1, up1, down2, up2 };
}
/** The 联防 field's battle input and a real battle built from it, as each combat mode builds it. */
function uniteBattle(m, clientCombat) {
if (clientCombat) {
const f = m.fields[0];
assert.equal(f.fieldId, 'u');
// a browser (authority or display replica) builds the battle from the spec over its own copy of the data
return { players: f.spec.players, battle: createBattleFromSpec(f.spec, new DataSource(DATA, null), { BattleClass: NoFinish, recordEvents: true }) };
}
const u = FakeBattle.instances.find((b) => b.kind === 'unite');
return { players: u.opts.players, battle: new NoFinish({ ...u.opts, data: m.ds, logger: { warn() {}, error() {}, info() {}, debug() {} } }) };
}
for (const clientCombat of [true, false]) {
test(`联防 (${clientCombat ? 'client-side combat' : 'server-run fallback'}): a helper's operator knocked out in its own combat enters down on its tile, counts down and redeploys`, () => {
const { h, m, down1, up1, down2, up2 } = scenario(clientCombat);
const { players, battle: b } = uniteBattle(m, clientCombat);
// the input: the knocked-out operators are fielded, flagged `down`; the standing ones carry HP% / SP
const inp = (pid, uid) => players.find((p) => p.playerId === pid).units.find((x) => x.uid === uid);
assert.deepEqual(inp('p_1', down1.uid)?.carryState, { down: true }, 'the right-hand helper\'s knocked-out operator is in the 联防 battle');
assert.deepEqual(inp('p_2', down2.uid)?.carryState, { down: true }, 'the left-hand helper\'s too');
assert.deepEqual(inp('p_1', up1.uid).carryState, { hpPct: 0.5, sp: 3, skillActive: false });
assert.deepEqual(inp('p_2', up2.uid).carryState, { hpPct: 0.5, sp: 3, skillActive: false });
b.step();
const unitOf = (uid, pid) => b.allyUnits.find((u) => u.uid === uid && u.ownerId === pid);
for (const [piece, pid] of [[down1, 'p_1'], [down2, 'p_2']]) {
const u = unitOf(piece.uid, pid);
assert.ok(u, `${pid}: its unit exists`);
assert.ok(!u.alive && b.isDown(u), `${pid}: knocked out on the field from the start`);
const d = downOf(b, u.id);
assert.ok(d, `${pid}: in b.snap down (the redeploy ring)`);
assert.ok(Math.abs(d[2] - u.base.respawnTime) < 0.011, `${pid}: its full redeploy timer (${d[2]} s)`);
assert.equal(d[3], DOWN_STATE.COUNTING);
assert.ok(b.fieldMeta().units.some((x) => x.id === u.id), `${pid}: a client joining the field shows it down`);
assert.equal(u.removeReason, FORCED_EXIT);
}
const board1 = [...h.ps('p_1').board.entries()].find(([, p]) => p.uid === down1.uid)[0].split(',').map(Number);
const u1 = unitOf(down1.uid, 'p_1');
assert.deepEqual([u1.tileR, u1.tileC], [board1[0], board1[1] + 8], 'the first helper holds the right-hand half (colOffset +8), on its board tile');
assert.ok([unitOf(up1.uid, 'p_1'), unitOf(up2.uid, 'p_2')].every((u) => u.alive && u.deployed), 'the standing operators fight');
// redeploy: after the timer, on its own tile, at full HP (DP init 10 + 1/s ≥ cost by then)
for (const [piece, pid] of [[down1, 'p_1'], [down2, 'p_2']]) {
const u = unitOf(piece.uid, pid);
const home = [u.homeR, u.homeC];
while (b.time < u.respawnAt + 0.2 && !u.alive) b.step();
assert.ok(u.alive && u.deployed, `${pid}: redeployed at ${b.time.toFixed(2)} s (respawnAt ${u.respawnAt.toFixed(2)})`);
assert.deepEqual([u.tileR, u.tileC], home, `${pid}: on its own tile`);
assert.ok(Math.abs(u.hp - u.s.maxHp) < 1e-6, `${pid}: full HP on redeploy (${u.hp} / ${u.s.maxHp})`);
assert.equal(downOf(b, u.id), null);
}
checkInvariants(m);
m.dispose();
});
}
test('the docs describe the rule (docs/META.md §4, docs/SIM.md §1.1 / §9, docs/PLAYING.md §5)', async () => {
const { readFileSync } = await import('node:fs');
const read = (f) => readFileSync(new URL(`../../docs/${f}`, import.meta.url), 'utf8');
const META = read('META.md'), SIM = read('SIM.md'), PLAYING = read('PLAYING.md');
assert.match(META, /knocked out at the end of the helper's own\s+combat carries `\{ down: true \}`/);
assert.ok(!/stays out of the 联防 battle/.test(META), 'META: the old "stays out" rule is gone');
assert.ok(!/do not take part in the 联防 battle/.test(META), 'META §7: the old assumption is gone');
assert.match(META, /knocked out at the end of its own combat is deployed and forced out at once/);
assert.match(SIM, /`carryState: \{ down: true \}`/);
assert.match(SIM, /reason `'killed'` or `FORCED_EXIT`/);
assert.match(PLAYING, /作战结束时已被击倒的干员在原位倒地/);
assert.ok(!/已倒下的干员不参加/.test(PLAYING));
});
+5 -4
View File
@@ -134,9 +134,10 @@ describe('3D board in headless Chrome', { skip }, () => {
assert.deepEqual(problems, []);
assert.equal(keys[0], keys[3], 'back to the normal area');
// user playtest #2 item 6: the prep board no longer builds the enemy pen block (only the pen camera does), so the
// union while flying is the prep field + the boss field
assert.equal(keys[0], '6,12,0,10', 'prep: the board without the pen block');
assert.equal(keys[1], '0,6,0,20;6,12,0,10', `union while flying: ${keys[1]}`);
// union while flying is the prep field + the boss field; the field takes its separator row 13 (user playtest #5
// item 6: act2 m01's blowers stand on it)
assert.equal(keys[0], '6,13,0,10', 'prep: the board without the pen block');
assert.equal(keys[1], '0,6,0,20;6,13,0,10', `union while flying: ${keys[1]}`);
assert.equal(keys[2], '0,6,0,20');
});
@@ -178,7 +179,7 @@ describe('3D board in headless Chrome', { skip }, () => {
assert.equal(r.faArea, '0,6,0,20', 'FA prep builds the boss field');
assert.deepEqual(r.faBand, [0, 13]);
assert.ok(r.faCamTx > 10, `right-hand boss prep camera (tx ${r.faCamTx})`);
assert.equal(r.prepArea, '6,12,0,10', 'prep board without the pen block (user playtest #2 item 6)');
assert.equal(r.prepArea, '6,13,0,10', 'prep board without the pen block (user playtest #2 item 6), with the row-13 wall');
assert.equal(r.bossArea, '0,6,0,20');
assert.equal(r.devices, 2);
assert.equal(r.attached, 2, 'both crate meshes still in the scene after the flight');
+89
View File
@@ -0,0 +1,89 @@
// test/render/bossbody.browser.test.js — user playtest #5 item 10 in headless Chrome through the render demo (real
// Spine models, the official boss camera): a huge boss (假想敌:胄, data/enemies.json `hitArea` 4.95 × 2.95 up 1 — the
// sim's hit rectangle, server/sim/body.js) is picked by a press anywhere on its hit area — the 5 × 3 tiles under its
// drawn body — while an operator standing on a tile inside that area is still picked on its tile, and a press beside
// the area picks nothing (render/pick.js AREA_PICK).
//
// Opt-in (starts Chrome): RENDER_E2E=1 node --test test/render/bossbody.browser.test.js
// Chrome path: $CHROME_PATH or the macOS default. Screenshot → test/e2e/out/bossbody.png.
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const OUT = path.join(ROOT, 'test/e2e/out');
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const enabled = process.env.RENDER_E2E === '1' && existsSync(CHROME) && existsSync(path.join(ROOT, 'public/assets'));
const skip = enabled ? false : 'set RENDER_E2E=1 (needs Chrome and downloaded assets)';
/** Page side: the boss field of 源石流发生装置 with 假想敌:胄 at (3,10), an operator on the fence (4,8), a regular enemy. */
function installBoss() {
const v = window.__demo.view;
const rect = { r0: 0, r1: 5, c0: 0, c1: 20 };
const units = [
{ id: 1, kind: 'op', side: 'ally', defId: 'char_102_texas', spine: 'char_102_texas', avatar: 'char_102_texas', x: 8, y: 4, maxHp: 1000, facing: 1, dir: 'RIGHT', tier: 3 },
{ id: 2, kind: 'enemy', side: 'enemy', defId: 'enemy_9013_acstmk', spine: 'enemy_9013_acstmk', avatar: 'enemy_9013_acstmk', x: 10, y: 3, maxHp: 600000, facing: -1, boss: true },
{ id: 3, kind: 'enemy', side: 'enemy', defId: 'enemy_1000_gopro_2', spine: 'enemy_1000_gopro_2', avatar: 'enemy_1000_gopro_2', x: 15, y: 2, maxHp: 1000, facing: -1 },
];
v.enterBattle({ fieldId: 'bb', kind: 'boss', rect, stageId: 'act2autochess_m01', units });
v.setCamera('boss', { rect, side: 'L', instant: true });
v.setLocalFeed({ on: true, speed: 2 });
const tup = (u) => [u.id, u.x, u.y, u.maxHp, u.maxHp, 0, 0, 0, 0];
window.__boss = {
step(gt) { v.pushSnapshot({ t: 'b.snap', fieldId: 'bb', gt, units: units.map(tup), dp: 5, killed: 0, total: 3 }); },
/** The unit id picked by a press on a spot of tile (row, col) (u, v ∈ [−0.5, 0.5] across / along it). */
pickAt(row, col) {
const t = v.tileScreen(row, col), r = v.debug.app.view.getBoundingClientRect();
return v.debug.pick.battleUnitAt(t.x - r.left, t.y - r.top)?.id ?? null;
},
};
}
describe('a huge boss is picked anywhere on its hit area (headless Chrome)', { skip }, () => {
let srv, browser;
before(async () => {
const require = createRequire(path.join(ROOT, 'package.json'));
const puppeteer = require('puppeteer-core');
const { startServer } = await import('../../server/index.js');
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
browser = await puppeteer.launch({ executablePath: CHROME, headless: 'new', args: ['--no-first-run'] });
mkdirSync(OUT, { recursive: true });
});
after(async () => {
await browser?.close();
await srv?.close();
});
test('the 5 × 3 body picks the boss; the operator on the fence tile inside it and the tiles beside it do not', async () => {
const page = await browser.newPage();
const problems = [];
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
page.on('response', (r) => { if (r.status() >= 400) problems.push(`HTTP ${r.status()} ${r.url()}`); });
try {
await page.setViewport({ width: 1280, height: 720 });
await page.goto(`http://127.0.0.1:${srv.port}/dev/render-demo.html?scene=normal-m01&paused=1&panel=0`);
await page.waitForFunction('window.__demo && (window.__demo.ready || window.__demo.error)', { timeout: 30000 });
await page.evaluate(installBoss);
const feed = (from, to) => page.evaluate(async (from, to) => {
for (let gt = from; gt <= to + 1e-9; gt += 1 / 30) { window.__boss.step(+gt.toFixed(4)); await new Promise((r) => requestAnimationFrame(() => r())); }
}, from, to);
await feed(1, 2);
await new Promise((r) => setTimeout(r, 1500)); // Spine models load
await feed(2, 3);
await page.screenshot({ path: path.join(OUT, 'bossbody.png') });
const pick = (r, c) => page.evaluate((r, c) => window.__boss.pickAt(r, c), r, c);
for (const [r, c] of [[3, 10], [4, 9], [5, 11], [5, 12], [3, 12], [4, 11], [3, 8], [5, 8]]) assert.equal(await pick(r, c), 2, `(${r},${c}) on the boss body`);
assert.equal(await pick(4, 8), 1, 'the operator on its fence tile inside the area');
for (const [r, c] of [[3, 14], [2, 7], [4, 6]]) assert.equal(await pick(r, c), null, `(${r},${c}) beside the body`);
assert.equal(await pick(2, 15), 3, 'a regular enemy on its tile');
assert.deepEqual(problems, []);
} finally {
await page.close();
}
});
});
+25 -1
View File
@@ -1,13 +1,15 @@
// test/render/downring.test.js — user playtest #4 items 8 and 9, client side: render/interp.js carries b.snap `elem`
// (the element gauge a unit shows) and `down` (knocked-out operators waiting to redeploy); render/units.js keeps a
// knocked-out operator on its tile in its held Die pose under a redeploy ring (countdown → "DP" / "!" → redeploy)
// and draws the official element icon beside the bars (headless fake PIXI, test/render/fakepixi.js).
// and draws the official element icon beside the bars (headless fake PIXI, test/render/fakepixi.js). User playtest #5
// item 2: an operator entering 联防 knocked out ('die' reason FORCED_EXIT) goes straight to the held pose, no burst.
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { installFakePixi, fakeViewCtx } from './fakepixi.js';
import { presetCamera } from '../../public/js/render/projection.js';
import { SnapshotBuffer, normalizeSnapshot, TUPLE } from '../../public/js/render/interp.js';
import { FORCED_EXIT as SIM_FORCED_EXIT } from '../../server/sim/constants.js';
let fake, UnitView, DOWN_STATE, DOWN_LOOK, T;
before(async () => {
@@ -102,6 +104,28 @@ describe('knocked-down operator (UnitView.setDown)', () => {
assert.equal(v._downRing.text.text, '18');
});
test('an operator entering the battle knocked out (联防, reason FORCED_EXIT): the held pose at once, no death burst, then its ring', async () => {
const { FORCED_EXIT, showsDeathFx } = await import('../../public/js/render/app.js');
assert.equal(FORCED_EXIT, SIM_FORCED_EXIT, 'the renderer mirrors the sim reason');
const info = { side: 'ally', kind: 'chess' };
assert.equal(showsDeathFx(info, false, FORCED_EXIT), false, 'no death particles');
assert.equal(showsDeathFx(info, false, 'killed'), true, 'a knock-out keeps them');
const v = view();
frames(v, 2);
v.die(true); // b.ev ['die', id, 'forcedExit'] (render/app.js handleEvent)
assert.equal(v.alive, false);
assert.ok(v.dieT >= 30, 'no fall: the end of the Die clip');
v.setDown([1, 70, 70, DOWN_STATE.COUNTING], 0.03); // b.snap down, same frame
frames(v, 240, 1 / 60, () => 2);
assert.equal(v.remove, false, 'stays on its tile');
assert.ok(v._downRing.root.visible);
assert.equal(v._downRing.text.text, '68');
const w = view({ id: 2 });
frames(w, 2);
w.die();
assert.ok(w.dieT < 1, 'an ordinary death still plays the clip from its start');
});
test('leaving the `down` list without a redeploy fades the view out; a normal death still fades', () => {
const v = view();
v.setDown([1, 30, 20, DOWN_STATE.COUNTING], 10);
+102
View File
@@ -0,0 +1,102 @@
// test/render/forms.browser.test.js — user playtest #5 item 1 in headless Chrome: a 掠海漂移体 hovering down act2 m01's
// lower lane is stunned, drops to 爬行模式 for good (sim content/enemies.js kitSyufo: a ground unit from then on, blocked
// and hit by 山), and its view follows — the real sim runs in the page (the client runner's loadBrowserSim) and feeds the
// render demo's field view: before the drop the hover clips (*_01), on the drop 'Change', then the crawl clips (*_02)
// while 山 blocks it (render/app.js fx 'phase' → render/units.js setForm / FORMS). It used to keep hovering.
//
// Opt-in (starts Chrome): RENDER_E2E=1 node --test test/render/forms.browser.test.js
// Chrome path: $CHROME_PATH or the macOS default. Screenshot → test/e2e/out/forms-syufo-crawl.png.
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
import { buildBattleSpec } from '../../server/sim/spec.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const OUT = path.join(ROOT, 'test/e2e/out');
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const enabled = process.env.RENDER_E2E === '1' && existsSync(CHROME) && existsSync(path.join(ROOT, 'public/assets'));
const skip = enabled ? false : 'set RENDER_E2E=1 (needs Chrome and downloaded assets)';
// 山 on (9,8) facing the gate; one tanky, harmless 掠海漂移体 walking the lower lane (9,10) → (9,2)
const SPEC = buildBattleSpec({
battleId: 'e2e.forms', fieldId: 'n:P1', kind: 'normal', seed: 5, modeId: 'mode_multi_hard', round: 12, stageId: 'act2autochess_m01', timeLimit: 400,
players: [{ playerId: 'P1', seat: 0, side: 'L', colOffset: 0, units: [{ uid: 1, kind: 'chess', chessId: 'chess_char_5_17_a', row: 9, col: 8, dir: 'RIGHT' }], bonds: {}, playerEffects: [] }],
spawns: [{ time: 0, enemyKey: 'enemy_2025_syufo', routeIndex: 0, count: 1, mods: { hpMul: 50, atkMul: 0.01 } }],
routes: [{ motion: 'WALK', start: [9, 10], end: [9, 2], checkpoints: [] }], flags: {},
});
describe('掠海漂移体 drops to 爬行模式: its model crawls (headless Chrome, real sim)', { skip }, () => {
let srv, browser;
before(async () => {
const require = createRequire(path.join(ROOT, 'package.json'));
const puppeteer = require('puppeteer-core');
const { startServer } = await import('../../server/index.js');
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
browser = await puppeteer.launch({ executablePath: CHROME, headless: 'new', args: ['--no-first-run'] });
mkdirSync(OUT, { recursive: true });
});
after(async () => {
await browser?.close();
await srv?.close();
});
test('hover clips before the stun, \'Change\' on the drop, then the crawl clips while 山 blocks and hits it', async () => {
const page = await browser.newPage();
const problems = [];
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
page.on('response', (r) => { if (r.status() >= 400) problems.push(`HTTP ${r.status()} ${r.url()}`); });
try {
await page.setViewport({ width: 1280, height: 720 });
await page.goto(`http://127.0.0.1:${srv.port}/dev/render-demo.html?scene=normal-m01&paused=1&panel=0`);
await page.waitForFunction('window.__demo && (window.__demo.ready || window.__demo.error)', { timeout: 30000 });
const log = await page.evaluate(async (spec) => {
const { loadBrowserSim } = await import('/js/battle/runner.js');
const { data } = await import('/js/data.js');
const { spec: S, ds } = await loadBrowserSim();
const b = S.createBattleFromSpec(spec, ds, { quiet: true });
const v = window.__demo.view;
v.setStage(data.lookup('stages', spec.stageId));
b.step();
const meta = b.fieldMeta();
v.enterBattle(meta);
v.setCamera('normal', { rect: meta.rect, side: 'L', instant: true });
v.setLocalFeed({ on: true, speed: 2 });
const raf = () => new Promise((r) => requestAnimationFrame(() => r()));
const out = [];
let stunAt = null;
for (let i = 0; i < 30 * 14; i++) {
b.step();
const e = b.enemies.find((x) => x.defId === 'enemy_2025_syufo');
if (stunAt == null && e && b.time >= 6) { stunAt = b.time; b.applyStatus(e, 'stun', { duration: 1, source: null }); }
if (i % 3 === 2) {
const ev = b.drainEvents();
if (ev.length) v.pushEvents({ t: 'b.ev', fieldId: meta.fieldId, gt: b.time, ev });
v.pushSnapshot(b.snapshot());
await raf();
if (i === 30 * 3) await new Promise((r) => setTimeout(r, 2000)); // the Spine models load
const view = e && v.debug.views.get(e.id);
if (e && i % 6 === 5) out.push({ t: +b.time.toFixed(2), flying: e.isFlying, blocked: !!e.blockedBy, clip: view?.actor?.current ?? null, form: view?.form ?? null, spine: !!view?.spineReady });
}
}
return { out, stunAt };
}, SPEC);
await page.screenshot({ path: path.join(OUT, 'forms-syufo-crawl.png') });
const before = log.out.filter((x) => x.spine && x.t < log.stunAt);
assert.ok(before.length > 0, 'the model loaded before the stun');
assert.ok(before.every((x) => x.flying && /_01$/.test(x.clip)), `hovering: the *_01 clips (${[...new Set(before.map((x) => x.clip))]})`);
const after = log.out.filter((x) => x.t > log.stunAt);
assert.ok(after.some((x) => x.clip === 'Change'), `the drop plays 'Change' (${[...new Set(after.map((x) => x.clip))]})`);
const late = after.filter((x) => x.t > log.stunAt + 3);
assert.ok(late.length > 0 && late.every((x) => !x.flying && x.form === 'crawl' && /_02$/.test(x.clip)), `crawling: the *_02 clips (${[...new Set(late.map((x) => x.clip))]})`);
assert.ok(late.some((x) => x.blocked), '山 blocks it once it crawls');
assert.deepEqual(problems, []);
} finally {
await page.close();
}
});
});
+122
View File
@@ -0,0 +1,122 @@
// test/render/forms.test.js — user playtest #5 item 1, client side: 掠海漂移体 drops from 近地悬浮 to 爬行模式 for good
// when stunned / frozen / put to sleep (sim content/enemies.js kitSyufo, fx 'phase' { id, kind: 'crawl' }); from then on
// melee operators block and hit it, so its model must stop hovering: render/units.js FORMS switches the view to the
// skeleton's crawl clips (*_02) after its 'Change' clip (render/spine.js SpineActor.setForm), also for a view built
// after the change (render/app.js keeps the mode on the unit info). Headless fake PIXI (test/render/fakepixi.js).
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { installFakePixi, fakeViewCtx } from './fakepixi.js';
import { presetCamera } from '../../public/js/render/projection.js';
import { UF, ANIM } from '../../shared/constants.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const assets = JSON.parse(readFileSync(path.join(ROOT, 'data/assets.json'), 'utf8'));
const SYUFO = 'enemy_2025_syufo';
let fake, UnitView, FORMS;
before(async () => {
fake = installFakePixi();
({ UnitView, FORMS } = await import('../../public/js/render/units.js'));
});
after(() => fake.restore());
const tick = () => new Promise((r) => setImmediate(r));
const cam = () => presetCamera('normal', { width: 1280, height: 720 });
/** Asset store with the real manifest entry of the skeleton and its animation names. */
function store(id) {
const entry = assets.enemies[id].spine;
const names = Object.keys(entry.animations || {});
return {
picture: () => null,
image: async () => null,
spineEntry: () => entry,
spine: { acquire: async () => ({ animations: names.map((name) => ({ name })) }), release() {} },
};
}
async function syufo(info = {}) {
const ctx = fakeViewCtx(fake.P, { assets: store(SYUFO), cam });
const v = new UnitView(ctx, { id: 7, side: 'enemy', kind: 'enemy', defId: SYUFO, spine: SYUFO, tier: 1, x: 8, y: 9, maxHp: 1000, facing: -1, ...info });
await tick(); await tick();
assert.ok(v.actor, 'Spine model built');
return v;
}
const clip = (v) => v.actor.current;
const sample = (flags = 0, anim = 0) => ({ x: 8, y: 9, hp: 1000, maxHp: 1000, sp: 0, spMax: 0, flags, anim, vx: 0 });
const frames = (v, n, dt = 1 / 60) => { for (let i = 0; i < n; i++) v.update(dt, cam(), i * dt); };
describe('掠海漂移体 爬行模式 (FORMS)', () => {
test('the crawl set exists in the skeleton the manifest lists', () => {
const f = FORMS[SYUFO].crawl;
const anims = assets.enemies[SYUFO].spine.animations;
for (const name of [f.change, f.roles.idle, f.roles.die, f.roles.move.loop, f.roles.attack.loop]) assert.ok(name in anims, name);
assert.equal(assets.enemies[SYUFO].spine.anims.idle, 'Idle_01', 'the manifest keeps the hover set as the default');
});
test('hovering: the hover clips; on the change: \'Change\' once, then the crawl clips (idle, move, attack, die)', async () => {
const v = await syufo();
v.sync(sample(), 1);
assert.equal(clip(v), 'Idle_01');
v.setForm('crawl');
assert.equal(clip(v), 'Change', 'the transition first');
frames(v, 50); // Change is 0.667 s
assert.equal(clip(v), 'Idle_02', 'crawling idle');
v.sync(sample(0, ANIM.MOVE), 2);
assert.equal(clip(v), 'Move_02');
v.onAttack?.(null, 2.1);
assert.equal(clip(v), 'Attack_02');
v.die();
assert.equal(clip(v), 'Die_02');
});
test('stunned when it drops (the usual case): the change plays out, then the stun holds, then it crawls', async () => {
const v = await syufo();
v.sync(sample(UF.STUNNED), 1);
assert.equal(v.actor.mode, 'stun');
v.setForm('crawl');
assert.equal(clip(v), 'Change');
frames(v, 10);
v.sync(sample(UF.STUNNED), 1.2); // still stunned: the change is not cut short
assert.equal(v.actor.mode, 'change');
frames(v, 40);
assert.equal(v.actor.mode, 'stun', 'then the stun holds (its pose frozen)');
v.sync(sample(0), 3);
assert.equal(clip(v), 'Idle_02', 'the stun over: crawling');
});
test('a view built after the change (culled, rejoined) starts on the crawl set without replaying \'Change\'', async () => {
const v = await syufo({ form: 'crawl' });
assert.equal(v.form, 'crawl');
v.sync(sample(), 1);
assert.equal(clip(v), 'Idle_02');
const w = await syufo({ form: 'crawl' });
w.die();
assert.equal(clip(w), 'Die_02');
});
test('modes without a clip set change nothing; the same mode twice is a no-op', async () => {
const v = await syufo();
v.setForm('grounded');
assert.equal(clip(v), 'Idle_01');
v.setForm('crawl');
frames(v, 50);
v.setForm('crawl');
assert.equal(clip(v), 'Idle_02', 'no second Change');
const ctx = fakeViewCtx(fake.P, { assets: store('enemy_10045_parrot'), cam });
const p = new UnitView(ctx, { id: 8, side: 'enemy', kind: 'enemy', defId: 'enemy_10045_parrot', spine: 'enemy_10045_parrot', tier: 1, x: 8, y: 9, maxHp: 1000 });
await tick(); await tick();
p.setForm('grounded');
p.setForm('float');
assert.equal(clip(p), 'Idle_A', '吉兆飞鳞 keeps its own clips (its 晕眩模式 is the Stun clip)');
});
});
test('render/app.js hands the sim\'s fx \'phase\' to the view and keeps the mode on the unit info', () => {
const src = readFileSync(path.join(ROOT, 'public/js/render/app.js'), 'utf8');
assert.match(src, /e\[1\] === 'phase'[\s\S]{0,300}inf\.form = [\s\S]{0,200}setForm\?\.\(e\[4\]\.kind\)/);
});
+2 -1
View File
@@ -451,7 +451,8 @@ describe('user playtest #3 items 1 and 7 (mock match, headless Chrome)', { skip
// a board unit back onto the free bench slot 5: the finger on the slot → lands; on the shop bar below it → back
const unit = await page.evaluate(() => { const u = globalThis.__MOCK__.S().priv.board[0]; return { uid: u.uid, row: u.row, col: u.col }; });
const slot = await tileSpot(page, 7, 5);
// the first point below the slot's centre that the shop bar covers (it overlaps the lower half of bench slots 4–9)
// the first point below the slot's centre that the shop bar covers (it starts right below the bench — the prep
// camera keeps the bench clear of it, user playtest #5 item 9; before, it overlapped the lower half of slots 4–9)
const bar = await page.evaluate((p) => {
const cv = globalThis.__SP_VIEW__.raw.debug.app.view;
for (let dy = 2; dy < 80; dy += 2) { const el = document.elementFromPoint(p.x, p.y + dy); if (el && el !== cv && el.closest('.shopbar')) return { x: p.x, y: p.y + dy + 3 }; }
+29 -1
View File
@@ -8,7 +8,8 @@
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { presetCamera, pickTile } from '../../public/js/render/projection.js';
import { pickOnTile, pickBattle, ENEMY_REACH } from '../../public/js/render/pick.js';
import { pickOnTile, pickBattle, ENEMY_REACH, AREA_PICK, hitRectAt } from '../../public/js/render/pick.js';
import { hitRect } from '../../server/sim/body.js';
import { unitDepthKey } from '../../public/js/render/units.js';
import { TILE_H } from '../../public/js/render/style.js';
import { GEO } from '../../shared/constants.js';
@@ -152,6 +153,33 @@ describe('pickBattle: allies by their tile, walking enemies by their ground posi
assert.equal(pickBattle([A, e], at(5, 10.6), 400, 400 - 1.0 * 80).key, 'e', 'on the enemy\'s head (over the near edge of that tile)');
});
test('a huge boss (user playtest #5 item 10): a press anywhere on its hit area picks it; allies on their tile and nearer enemies win', () => {
// 假想敌:胄 at (3,10): 4.95 × 2.95 up 1 → rows 3–5 × cols 8–12 (the sim's rectangle, server/sim/body.js)
const HA = { w: 4.95, h: 2.95, dx: 0, dy: 1 };
const area = hitRectAt(10, 3, HA);
assert.deepEqual(area, hitRect({ x: 10, y: 3, hitArea: HA }), 'the same rectangle as the sim');
assert.equal(hitRectAt(10, 3, null), null);
const boss = { key: 'boss', tile: null, x: 10, y: 3, area, body: { x: 600, top: 600 - 3 * 90, feet: 600, s: 90 } };
const far = [2000, 2000]; // pointer far from its drawn body line on screen: only the ground area counts
for (const [x, y] of [[8, 3], [12.4, 5.4], [7.6, 4], [10, 5]]) assert.equal(pickBattle([boss], at(x, y), ...far)?.key, 'boss', `(${x},${y}) on the area`);
for (const [x, y] of [[7.4, 4], [10, 5.6], [9, 2.3], [12.6, 3]]) assert.equal(pickBattle([boss], at(x, y), ...far), null, `(${x},${y}) off the area`);
const A = ally('A', 4, 8), e = foe('e', 11, 4.2);
assert.equal(pickBattle([boss, A], at(8.3, 4.3), ...far).key, 'A', 'an ally on the fence tile inside the area');
assert.equal(pickBattle([boss, A], at(9.2, 4.3), ...far).key, 'boss', 'the next tile: the boss');
assert.equal(pickBattle([boss, e], at(11.2, 4.1), ...far).key, 'e', 'a regular enemy near the pointer');
assert.ok(AREA_PICK > Math.SQRT1_2 && AREA_PICK > ENEMY_REACH);
// a flying unit never uses a ground area
assert.equal(pickBattle([{ ...boss, fly: true }], at(9, 4), ...far), null);
// the drawn body: an upright box from its feet to its head, hw tiles either side (its px per tile) — off the grid
// too
const big = { ...boss, body: { ...boss.body, hw: 2.475 } };
assert.equal(pickBattle([big], null, 600 + 2.2 * 90, 600 - 2.8 * 90)?.key, 'boss', 'its shoulder, far from the centre line');
assert.equal(pickBattle([boss], null, 600 + 2.2 * 90, 600 - 2.8 * 90), null, 'without hw: the centre line only');
assert.equal(pickBattle([big], null, 600 + 2.6 * 90, 600 - 1 * 90), null, 'beside the box');
assert.equal(pickBattle([big], null, 600 + 1.5 * 90, 600 - 3.8 * 90), null, 'above its head (beyond the line reach too)');
assert.equal(pickBattle([big, A], at(8.1, 4.1), 600 - 2 * 90, 600 - 1 * 90).key, 'A', 'the ally on its tile inside the box');
});
test('bad input: nothing', () => {
assert.equal(pickBattle(null, at(1, 1), 0, 0), null);
assert.equal(pickBattle([null, foe('x', NaN, 1), { key: 'y', tile: null, x: 1, y: 1, fly: true, body: { x: 1, top: 0, feet: 1, s: 0 } }], at(1, 1), 1, 1), null);
+144
View File
@@ -0,0 +1,144 @@
// User playtest #5 item 6 — "有两个吹风的风道确实做出来了,但是吹风机的贴图丢失了,反而在关底吹风机的贴图又出现了":
// act2autochess_m01 (战场#05(下半) 源石流发生装置) has 8 blowers (trap_013_blower, dir DOWN): the normal field's on the
// separator row 13 (cols 5 / 9, the partner's 13 / 17) blowing into rows 12–10, the boss field's on row 6 (cols 5 / 9 /
// 11 / 15) blowing into rows 5–3. The 3D board's normal / 联防 area stopped at row 12, so the normal field's blowers
// were never built (their airflow streaks — Pixi — still showed), while the boss field's were (the Final Assault view
// and — facing away, under the bench on the row-6 wall — the normal view).
// Now: the normal / 联防 areas take the separator row 13, and every active device standing on a built tile is drawn
// (render/board3d/layout.js stageDevices; the 2D board render/tiles.js _stageDevices follows the same rule) — except
// the boss field's (rows ≤ 6, its top wall under the bench included), drawn with the boss field only: the official
// normal cameras never show that wall (under the shop bar in prep, off-screen in battle), and the remake's prep view
// showed the boss round's machines under the bench ("反而在关底吹风机的贴图又出现了", QA residual of the integration).
// Audit: for every stage and view the 3D board and the 2D board draw the same devices, and every active device is
// drawn exactly in the views whose area holds its tile (the boss field's only in the boss views).
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { buildBoard, stageDevices, classifyStage, AREAS, BOSS_WALL_ROW } from '../../public/js/render/board3d/layout.js';
import { boardArea, bandFor, fieldRows, viewKind } from '../../public/js/render/app.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const stages = JSON.parse(readFileSync(path.join(ROOT, 'data/stages.json'), 'utf8'));
const M01 = stages.act2autochess_m01;
const devicesIn3d = (st, vk) => buildBoard(st, { area: boardArea(vk) }).devices.map((d) => `${d.kind}@${d.r},${d.c}`).sort();
const blowersIn3d = (st, vk) => buildBoard(st, { area: boardArea(vk) }).devices.filter((d) => d.kind === 'blower').map((d) => `${d.r},${d.c}`).sort();
/**
* Inside the 3D area of a view (the 2D board also shows the partner's half of rows 9–12 as a display in the own views,
* with its devices; the 3D board builds cols 0–10 there — plus the pen block, whose front wall holds the partner's
* blower (13,13) in the pen view).
*/
const inArea = (vk) => (k) => {
const [r, c] = k.split('@').pop().split(',').map(Number);
return boardArea(vk).some((a) => r >= a.r0 && r <= a.r1 && c >= a.c0 && c <= a.c1);
};
/** The 2D board's device list for a view kind, within that view's 3D area (external mode: no Pixi meshes needed). */
async function devicesIn2d(st, vk) {
const { installFakePixi } = await import('./fakepixi.js');
const fake = installFakePixi();
try {
const P = globalThis.PIXI;
P.BLEND_MODES = { ADD: 1, NORMAL: 0 };
const { TileField } = await import('../../public/js/render/tiles.js');
const layers = { ground: new P.Container(), overlay: new P.Container(), props: new P.Container(), anim: new P.Container() };
for (const l of Object.values(layers)) l.addChild = function (...kids) { for (const k of kids) { k.parent = this; this.children.push(k); } return kids[0]; };
const f = new TileField(layers);
f.setExternal(true);
f.setView(bandFor(vk), null, fieldRows(vk));
f.setStage(st);
const out = f._stageDevices().map((d) => `${d.role}@${d.pos[0]},${d.pos[1]}`).filter(inArea(vk)).sort();
const flow = f.blowers.map((b) => `${b.row},${b.col}`).filter(inArea(vk)).sort();
f.destroy();
return { devices: out, flow };
} finally {
fake.restore();
}
}
const VIEWS = ['prep', 'normal', 'unite', 'bossPrep', 'boss', 'pen'];
describe('#6 act2 m01 blowers in every view', () => {
test('repro: the normal / prep view builds the own field\'s row-13 blowers (cols 5, 9) above the wind lanes — and not the boss field\'s row-6 machines under the bench', () => {
for (const vk of ['prep', 'normal']) assert.deepEqual(blowersIn3d(M01, vk), ['13,5', '13,9'], vk);
});
test('联防 builds both halves\' row-13 blowers; the Final Assault builds the four row-6 blowers', () => {
assert.deepEqual(blowersIn3d(M01, 'unite'), ['13,13', '13,17', '13,5', '13,9']);
for (const vk of ['boss', 'bossPrep', viewKind('hidden'), viewKind('prep', { rect: { r0: 0, r1: 5, c0: 0, c1: 10 } })]) {
assert.deepEqual(blowersIn3d(M01, vk), ['6,11', '6,15', '6,5', '6,9'], vk);
}
});
test('the blower machines stand on the separator blocks; the row-13 ones blow onto the field below them', () => {
const b = buildBoard(M01, { area: boardArea('prep') });
for (const d of b.devices.filter((x) => x.kind === 'blower')) {
const t = b.grid[d.r][d.c];
assert.equal(t.glyph, 'X');
assert.ok(t.drawn && d.z0 === t.h && t.h > 0, 'on top of the drawn separator block');
assert.equal(d.dir, 'DOWN');
// row 13: onto the built field's lanes (rows 12–10)
assert.equal(d.r, 13, `${d.r},${d.c}: only the field's own machines`);
const onto = d.rangeTiles.slice(1).map(([r, c]) => !!b.grid[r][c].content);
assert.deepEqual(onto, [true, true, true], `${d.r},${d.c}`);
}
// the separator row is built along the field's top edge (the pen rows 14–18 are still not)
const rows = new Set(b.grid.flat().filter((t) => t.drawn).map((t) => t.r));
assert.ok(rows.has(13) && !rows.has(14) && !rows.has(5), [...rows].join());
});
test('the 2D board draws the same blowers and airflow per view', async () => {
for (const vk of VIEWS) {
const d2 = await devicesIn2d(M01, vk);
assert.deepEqual(d2.flow, blowersIn3d(M01, vk), `${vk}: airflow streaks = built blowers`);
}
});
});
describe('#6 audit: every device of every stage is drawn in the views of the field it belongs to', () => {
test('3D board = 2D board for every stage and view', async () => {
for (const [id, st] of Object.entries(stages)) {
for (const vk of VIEWS) {
const d3 = devicesIn3d(st, vk);
const d2 = (await devicesIn2d(st, vk)).devices;
assert.deepEqual(d3, d2, `${id} ${vk}`);
}
}
});
test('every active device standing on the built island of a view is drawn — every field device and every device acting on the field among them', () => {
const KINDS = new Set(['crate', 'platform', 'mound', 'blower', 'turret', 'waterPlatform', 'bush', 'sealedFloor']);
const inRect = (r, c, R) => r >= R.r0 && r <= R.r1 && c >= R.c0 && c <= R.c1;
// the fields inside their separator walls (normal / 联防: rows 7–12; boss: rows 0–5); the boss field's devices
// (rows ≤ 6: its top wall under the bench too) are drawn in the boss views only
const INNER = { prep: { r0: 7, r1: 12, c0: 0, c1: 10 }, unite: { r0: 7, r1: 12, c0: 0, c1: 20 }, boss: { r0: 0, r1: 5, c0: 0, c1: 20 } };
for (const [id, st] of Object.entries(stages)) {
for (const [vk, F] of Object.entries(INNER)) {
const b = buildBoard(st, { area: boardArea(vk) });
const built = new Set(b.devices.map((d) => `${d.kind}@${d.r},${d.c}`));
for (const d of st.devices || []) {
if (!KINDS.has(d.role) || !(typeof d.active === 'boolean' ? d.active : !d.hidden)) continue;
const [r, c] = d.pos;
const key = `${d.role}@${r},${c}`;
const onIsland = boardArea(vk).some((a) => inRect(r, c, a)) && !!b.grid[r]?.[c]?.drawn && (vk === 'boss' || r > BOSS_WALL_ROW);
const acts = (d.rangeTiles || []).some(([rr, cc]) => (rr !== r || cc !== c) && inRect(rr, cc, F) && b.grid[rr]?.[cc]?.content);
assert.equal(built.has(key), onIsland, `${id} ${vk}: ${key} (${d.alias || d.key}) drawn iff on the built island`);
if (inRect(r, c, F) || acts) assert.ok(built.has(key), `${id} ${vk}: ${key} of this field drawn`);
}
}
}
});
test('stageDevices keeps every active device on a drawn tile — the boss field\'s only when the area builds the boss field', () => {
const blowers = (area) => stageDevices(M01, classifyStage(M01, area), area).filter((d) => d.kind === 'blower').map((d) => `${d.r},${d.c}`).sort();
assert.deepEqual(blowers(AREAS.boss), ['6,11', '6,15', '6,5', '6,9']);
assert.deepEqual(blowers(AREAS.normal), ['13,13', '13,5', '13,9'], 'the pen block\'s front wall holds the partner\'s (13,13); the row-6 wall under the bench none');
assert.deepEqual(blowers([...AREAS.boss, ...AREAS.normal]), ['13,13', '13,5', '13,9', '6,11', '6,15', '6,5', '6,9'], 'a camera flight between the fields: both');
assert.equal(blowers(AREAS.all).length, 8, 'the whole map: all 8');
assert.equal(stageDevices(M01, classifyStage(M01, null)).filter((d) => d.kind === 'blower').length, 8, 'no area: the whole map');
});
});
+135 -1
View File
@@ -7,7 +7,7 @@ import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import {
Camera, fitCamera, presetCamera, lerpCamera, pickTile, tileQuad, normRect, CAMERA_PRESETS, easeInOutCubic,
OFFICIAL, OFFICIAL_PARAMS, officialCamera, parseCameraParam, threeCameraParams, syncThreeCamera,
OFFICIAL, OFFICIAL_PARAMS, officialCamera, parseCameraParam, threeCameraParams, syncThreeCamera, clearHud,
} from '../../public/js/render/projection.js';
import * as THREE from 'three';
@@ -247,6 +247,140 @@ describe('pickTile / tileQuad', () => {
});
});
describe('HUD clearance of the prep views (user playtest #5 item 9)', () => {
// the in-match HUD in rem (ui/fieldHost.js HUD_REM): the bond strip ends 2.16rem below the top, the shop bar starts
// 2.64rem + 3 px above the bottom; the root font size is clamp(40px, min(W / 19.2, H / 10.8), 240px) (css/theme.css)
const hudAt = (w, h) => {
const rem = Math.max(40, Math.min(w / 19.2, h / 10.8, 240));
return { top: 2.16 * rem, bottom: 2.64 * rem + 3 };
};
const band = (cam, kind) => {
const k = CAMERA_PRESETS[kind].keep;
return { near: cam.project(cam.tx, k.near, k.zNear).y, far: cam.project(cam.tx, k.far, k.zFar).y };
};
// phones in landscape (CSS px): iPhone 12–15 (19.5:9), iPhone Pro Max, Galaxy S20+ / 2400×1080 (20:9), 800×360,
// Xperia 21:9, 924×424, 740×360, and the user's Android: its screenshot is 2772×1272 px with the page drawn right of
// a 141 px black cutout band, i.e. 2631×1272 px at DPR ≈ 3.48 → 756×366 (its exit and 准备就绪 buttons match
// to ±1 px there), and 798×366 if the page also covered the cutout
const PHONES = [[844, 390], [932, 430], [915, 412], [914, 411], [800, 360], [960, 411], [756, 366], [798, 366], [924, 424], [740, 360]];
const DESKTOPS = [[1920, 1080], [1680, 1050], [1280, 720], [1366, 768], [1440, 900], [1536, 864], [1600, 900], [2560, 1440],
[3840, 2160], [2560, 1080], [3440, 1440], [1600, 1200], [1180, 820], [1024, 768], [1920, 1200]];
const cams = (kind, w, h, hud) => (kind === 'bossPrep' ? ['L', 'R'] : ['L']).map((side) => [side, presetCamera(kind, { width: w, height: h }, { side, hud })]);
test('reproduction: the official shop camera puts the bench under the shop bar on phones (844×390: ~11 px, 800×360: ~19 px, 756×366 as on the user\'s Android: ~17 px)', () => {
for (const [w, h] of [[844, 390], [800, 360], [756, 366]]) {
for (const kind of ['prep', 'bossPrep']) {
const b = band(presetCamera(kind, { width: w, height: h }), kind);
assert.ok(b.near > h - hudAt(w, h).bottom + 8, `${kind} ${w}×${h}: bench edge ${b.near} vs shop bar ${h - hudAt(w, h).bottom}`);
}
}
});
test('with the HUD bands the bench clears the shop bar and the back row the bond strip, as large as fits', () => {
for (const [w, h] of PHONES) {
const hud = hudAt(w, h);
const top = hud.top, bottom = h - hud.bottom;
for (const kind of ['prep', 'bossPrep']) {
for (const [side, cam] of cams(kind, w, h, hud)) {
const off = presetCamera(kind, { width: w, height: h }, { side });
const b = band(cam, kind), b0 = band(off, kind);
const tag = `${kind}/${side} ${w}×${h}`;
assert.ok(b.near <= bottom + 1e-6, `${tag}: bench edge ${b.near} above the shop bar ${bottom}`);
assert.ok(b.far >= top - 1e-6, `${tag}: back row ${b.far} below the bond strip ${top}`);
// the official perspective (a 2D pan / zoom of the image): same pinhole, pitch and target
for (const k of ['tx', 'ty', 'tz', 'tilt', 'dist']) assert.equal(cam[k], off[k], `${tag}: ${k}`);
const f = cam.scale / off.scale;
assert.ok(f <= 1 + 1e-12 && f > 0.8, `${tag}: zoom ${f}`);
// zoomed out only as far as needed: the band then fills the space between the HUD bands less 1 px each end
if (f < 1 - 1e-9) assert.ok(near(b.near - b.far, bottom - top - 2, 1e-6), `${tag}: fills the free space`);
else assert.ok(near(b.near - b.far, b0.near - b0.far, 1e-6), `${tag}: pan only`);
}
}
}
});
test('desktop viewports keep the official prep camera (16:9 is flush: bench on the shop bar, back row under the strip)', () => {
for (const [w, h] of DESKTOPS) {
const hud = hudAt(w, h);
assert.deepEqual(presetCamera('prep', { width: w, height: h }, { hud }).params(), presetCamera('prep', { width: w, height: h }).params(), `prep ${w}×${h}`);
// the Final Assault bench sits up to 1.2 px lower (under the bar's top edge at 16:9): nudged up < 3 px, no zoom
for (const [side, cam] of cams('bossPrep', w, h, hud)) {
const off = presetCamera('bossPrep', { width: w, height: h }, { side });
assert.equal(cam.scale, off.scale, `bossPrep/${side} ${w}×${h}: no zoom`);
assert.ok(cam.cy <= off.cy && off.cy - cam.cy < 3, `bossPrep/${side} ${w}×${h}: nudge ${off.cy - cam.cy}`);
}
}
// other camera kinds ignore the HUD bands; the fitted (portrait) prep camera too
for (const kind of ['normal', 'unite', 'boss', 'pen']) {
assert.deepEqual(presetCamera(kind, { width: 844, height: 390 }, { hud: hudAt(844, 390) }).params(), presetCamera(kind, { width: 844, height: 390 }).params(), kind);
}
assert.deepEqual(presetCamera('prep', { width: 390, height: 844 }, { hud: hudAt(390, 844) }).params(), presetCamera('prep', { width: 390, height: 844 }).params(), 'portrait');
});
test('clearHud: unchanged when clear, pan when the band fits, zoom about the centre when it does not; garbage-safe', () => {
const vp = { width: 1000, height: 500 };
const keep = { near: 6.5, zNear: 0.16, far: 12.5, zFar: 0.42 };
const cam = presetCamera('prep', vp);
const b0 = band(cam, 'prep');
const bandH = b0.near - b0.far;
assert.equal(clearHud(cam, { top: b0.far - 10, bottom: vp.height - b0.near - 10 }, keep, vp), cam, 'clear → same object');
assert.equal(clearHud(cam, { top: b0.far + 0.4, bottom: vp.height - b0.near + 0.4 }, keep, vp), cam, '≤ 0.5 px overlap is clear');
// the bottom band reaches 30 px over the bench, 60 px free above: pan up by 31 px (1 px gap), no zoom
const pan = clearHud(cam, { top: b0.far - 60, bottom: vp.height - b0.near + 30 }, keep, vp);
assert.equal(pan.scale, cam.scale);
assert.equal(pan.cx, cam.cx);
assert.ok(near(pan.cy - cam.cy, -31, 1e-9), `pan ${pan.cy - cam.cy}`);
// 5 px over the bench and only 6 px above the back row: the gap shrinks to half the spare pixel
const tight = clearHud(cam, { top: b0.far - 6, bottom: vp.height - b0.near + 5 }, keep, vp);
assert.equal(tight.scale, cam.scale);
assert.ok(near(tight.cy - cam.cy, -5.5, 1e-9), `tight ${tight.cy - cam.cy}`);
// top band over the back row: pan down
const down = clearHud(cam, { top: b0.far + 20, bottom: 0 }, keep, vp);
assert.ok(near(down.cy - cam.cy, 21, 1e-9));
// both: zoom out about the centre column to fill [top + 1, H − bottom − 1]
const hud = { top: b0.far + 20, bottom: vp.height - b0.near + 20 };
const z = clearHud(cam, hud, keep, vp);
const f = (bandH - 42) / bandH;
assert.ok(near(z.scale / cam.scale, f, 1e-9), `zoom ${z.scale / cam.scale}`);
const bz = band(z, 'prep');
assert.ok(near(bz.far, hud.top + 1, 1e-6) && near(bz.near, vp.height - hud.bottom - 1, 1e-6));
const c0 = cam.project(0, 9, 0), c1 = z.project(0, 9, 0);
assert.ok(near(c1.x - vp.width / 2, f * (c0.x - vp.width / 2), 1e-6), 'x shrinks towards the centre');
assert.ok(near(c1.s, f * c0.s, 1e-9), 'px per tile scales by f');
// no usable space / bad input: the official camera
for (const bad of [null, 'x', { top: 300, bottom: 300 }, { top: NaN, bottom: -5 }]) {
const out = clearHud(cam, bad, keep, vp);
assert.ok(out === cam || out.params().scale === cam.scale, JSON.stringify(bad));
for (const v of Object.values(out.params())) assert.ok(Number.isFinite(v));
}
assert.equal(clearHud(cam, hud, null, vp), cam);
});
test('an adjusted camera picks, projects and lerps like any other (bench, raised and low tiles; three.js too)', () => {
const heights = (r, c) => (r === 7 ? 0.16 : r === 12 && c === 4 ? 0.42 : 0);
for (const [w, h] of [[844, 390], [800, 360]]) {
const cam = presetCamera('prep', { width: w, height: h }, { hud: hudAt(w, h) });
for (let r = 7; r <= 12; r++) for (let c = 0; c <= 10; c++) {
const p = cam.project(c, r, heights(r, c));
const t = pickTile(cam, p.x, p.y, heights, [0.42, 0.16, 0]);
assert.deepEqual([t.row, t.col], [r, c], `${w}×${h} pick ${r},${c}`);
}
const three = syncThreeCamera(cam, new THREE.PerspectiveCamera(), w, h);
const v = new THREE.Vector3();
let worst = 0;
for (let r = 0; r < 19; r++) for (let c = 0; c < 21; c++) {
if (cam.depthOf(c, r, 0) < 0.5) continue;
const p = cam.project(c, r, 0);
v.set(c, r, 0).project(three);
worst = Math.max(worst, Math.abs((v.x + 1) / 2 * w - p.x), Math.abs((1 - v.y) / 2 * h - p.y));
}
assert.ok(worst < 1e-6, `${w}×${h} three.js: ${worst} px`);
const m = lerpCamera(presetCamera('normal', { width: w, height: h }), cam, 1).params();
for (const [k, x] of Object.entries(cam.params())) assert.ok(near(m[k], x, 1e-9), k);
}
});
});
describe('three.js PerspectiveCamera equivalence (the 3D board layer)', () => {
const kinds = ['prep', 'normal', 'unite', 'boss', 'pen', 'bossPrep'];
+138
View File
@@ -0,0 +1,138 @@
// test/render/unitedown.browser.test.js — user playtest #5 item 2 in headless Chrome: a 联防 helper's operator knocked
// out in its own combat enters the 联防 field down. The real sim runs in the page (the /sim/ modules and /data/*.json,
// like the client runner's loadBrowserSim) on a 联防 BattleSpec whose operator carries `carryState: { down: true }`,
// and feeds the render demo's field view frame by frame (local feed, the runner's b.snap / b.ev frames): from the first
// frame the operator lies on its tile in its held Die pose (no fall) under the redeploy ring counting its full timer
// while its teammate stands, then it comes back on its tile.
//
// Opt-in (starts Chrome): RENDER_E2E=1 node --test test/render/unitedown.browser.test.js
// Chrome path: $CHROME_PATH or the macOS default. Screenshots → test/e2e/out/unitedown-*.png.
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
import { buildBattleSpec } from '../../server/sim/spec.js';
import { GEO } from '../../shared/constants.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const OUT = path.join(ROOT, 'test/e2e/out');
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const enabled = process.env.RENDER_E2E === '1' && existsSync(CHROME) && existsSync(path.join(ROOT, 'public/assets'));
const skip = enabled ? false : 'set RENDER_E2E=1 (needs Chrome and downloaded assets)';
// the right-hand helper of a two-helper 联防 (colOffset +8): 德克萨斯 knocked out in its own combat,
// with 艾雅法拉 standing at half HP; the only enemy spawns long after the check (it keeps the battle running)
const SPEC = buildBattleSpec({
battleId: 'e2e.1.1.u', fieldId: 'u', kind: 'unite', seed: 77, modeId: 'mode_multi_normal', round: 3, stageId: 'act2autochess_m01',
rect: { ...GEO.UNITE_RECT }, timeLimit: 400, flags: { layerGainsEnabled: false },
spawns: [{ time: 390, enemyKey: 'enemy_1000_gopro_2', routeIndex: 0 }], routes: [{ motion: 'WALK', start: [12, 18], end: [9, 2], checkpoints: [] }],
players: [{
playerId: 'p_1', seat: 1, side: 'L', colOffset: 8, bonds: {}, playerEffects: [],
units: [
{ uid: 1, kind: 'chess', chessId: 'chess_char_1_08_a', row: 10, col: 4, dir: 'RIGHT', items: [], carryState: { down: true } },
{ uid: 2, kind: 'chess', chessId: 'chess_char_6_20_a', row: 11, col: 3, dir: 'RIGHT', items: [], carryState: { hpPct: 0.5, sp: 0, skillActive: false } },
],
}],
});
/** Page side: the sim in the browser + a feed into the demo view. `advance(ticks)` steps and pushes one frame. */
async function installSim(spec) {
const [S, simdata, support] = await Promise.all([import('/sim/spec.js'), import('/sim/simdata.js'), import('/sim/content/support/index.js')]);
const names = ['chess', 'enemies', 'tokens', 'stages', 'waves', 'bonds', 'items', 'garrisons', 'bands', 'effects'];
const raw = {};
for (const n of names) raw[n] = await fetch(`/data/${n}.json`).then((r) => r.json());
simdata.setSimData(raw);
if (typeof support.setGameData === 'function') support.setGameData(null);
const battle = S.createBattleFromSpec(spec, new simdata.DataSource(raw, null));
const v = window.__demo.view;
// what the runner's show() publishes: the field meta before the first step (nothing deployed yet)
v.enterBattle({ ...battle.fieldMeta(), fieldId: 'u', kind: 'unite', rect: spec.rect, stageId: spec.stageId });
v.setCamera('unite', { rect: spec.rect, side: 'R', instant: true });
v.setLocalFeed({ on: true, speed: 2 });
const frame = () => {
const ev = battle.drainEvents();
const gt = battle.time;
if (ev.length) v.pushEvents({ t: 'b.ev', fieldId: 'u', gt, ev });
const { t, ...rest } = battle.snapshot();
v.pushSnapshot({ ...rest, t: 'b.snap', fieldId: 'u', gt: t });
};
frame();
window.__sim = {
battle,
advance(n) { for (let i = 0; i < n; i++) battle.step(); frame(); },
unit(uid) { return battle.allyUnits.find((u) => u.uid === uid); },
state() {
const out = {};
for (const [id, x] of v.debug.views) {
out[id] = { alive: x.alive, down: x.down ? x.down.state : null, ring: !!x._downRing?.root?.visible, label: x._downRing?.text?.text ?? null,
dieClip: x.actor?.current ?? null, dieT: x.dieT ?? null, alpha: +(x.alpha ?? 0).toFixed(2), x: x.x, y: x.y, spine: !!x.spineReady };
}
return out;
},
};
}
describe('联防: an operator knocked out in its own combat enters down (headless Chrome, real sim)', { skip }, () => {
let srv, browser;
before(async () => {
const require = createRequire(path.join(ROOT, 'package.json'));
const puppeteer = require('puppeteer-core');
const { startServer } = await import('../../server/index.js');
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
browser = await puppeteer.launch({ executablePath: CHROME, headless: 'new', args: ['--no-first-run'] });
mkdirSync(OUT, { recursive: true });
});
after(async () => {
await browser?.close();
await srv?.close();
});
test('down on its tile from the first frame, ring counting its full timer, then redeployed', async () => {
const page = await browser.newPage();
const problems = [];
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
page.on('response', (r) => { if (r.status() >= 400) problems.push(`HTTP ${r.status()} ${r.url()}`); });
try {
await page.setViewport({ width: 1280, height: 720 });
await page.goto(`http://127.0.0.1:${srv.port}/dev/render-demo.html?scene=normal-m01&paused=1&panel=0`);
await page.waitForFunction('window.__demo && (window.__demo.ready || window.__demo.error)', { timeout: 30000 });
await page.evaluate(installSim, SPEC);
// real speed for 2 game s (2 ticks per frame at 60 fps), then the Spine models load
const run = (frames, ticks) => page.evaluate(async (frames, ticks) => {
for (let i = 0; i < frames; i++) { window.__sim.advance(ticks); await new Promise((r) => requestAnimationFrame(() => r())); }
}, frames, ticks);
await run(60, 2);
await new Promise((r) => setTimeout(r, 1500));
await run(30, 2);
const texas = await page.evaluate(() => window.__sim.unit(1).id);
const eyja = await page.evaluate(() => window.__sim.unit(2).id);
let st = await page.evaluate(() => window.__sim.state());
await page.screenshot({ path: path.join(OUT, 'unitedown-start.png') });
assert.ok(st[texas], 'the knocked-out operator has a view on the 联防 field');
assert.equal(st[texas].alive, false);
assert.equal(st[texas].down, 0, 'down, its timer counting');
assert.ok(st[texas].ring, 'redeploy ring');
const gt = await page.evaluate(() => window.__sim.battle.time);
assert.ok(gt > 5.9 && ['64', '65', '66'].includes(st[texas].label), `its full 70 s timer (${st[texas].label} s left at ${gt.toFixed(2)} s)`);
assert.equal(st[texas].dieClip, 'Die', 'held Die pose');
assert.ok(st[texas].alpha > 0.8, `drawn (alpha ${st[texas].alpha})`);
assert.deepEqual([Math.round(st[texas].x), Math.round(st[texas].y)], [12, 10], 'on its own tile (board col 4 + 8)');
assert.equal(st[eyja].alive, true, 'the teammate stands');
// fast-forward to the redeploy (DP 10 + 1/s ≥ cost 13 long before the 70 s timer ends)
await run(40, 60);
await run(30, 2);
st = await page.evaluate(() => window.__sim.state());
await page.screenshot({ path: path.join(OUT, 'unitedown-redeployed.png') });
assert.equal(st[texas].alive, true, 'back on its tile');
assert.equal(st[texas].down, null);
assert.equal(st[texas].ring, false);
assert.deepEqual(problems, []);
} finally {
await page.close();
}
});
});
+11 -2
View File
@@ -312,10 +312,19 @@ test('boss field with a shared pool: an emptied field (leader leaked) keeps runn
});
test('Final Assault h07_04 (pair): 昆图斯 walking into the objective does not end the field while the pool is full', { skip: !hasGeneratedData() && 'no generated data' }, () => {
// Regression: the official pair route of the leader has no wait step; with nobody blocking him he leaks at ~180 s
// Regression: the official pair route of the leader has no wait step; with nobody blocking him he leaked at ~180 s
// and the field used to finish 'cleared' at once, ending the Final Assault as a defeat with team LP left.
// Officially he is 自缚 (PRTS 天赋; content/bosses.js SELF_BOUND, user playtest #5) and never walks it — the engine
// rule is still checked by lifting the 自缚 buff.
const pool = new LocalBossPool(708750);
const h = makeBattle({ kind: 'boss', stageId: 'act2autochess_m02', rect: { r0: 0, r1: 5, c0: 0, c1: 20 }, waveTemplate: 'act1autochess_h07_04', sharedBoss: pool, units: [] });
const opts = { kind: 'boss', stageId: 'act2autochess_m02', rect: { r0: 0, r1: 5, c0: 0, c1: 20 }, waveTemplate: 'act1autochess_h07_04', sharedBoss: pool, units: [] };
const bound = makeBattle({ ...opts, sharedBoss: new LocalBossPool(708750) });
bound.run(400);
assert.ok(!bound.hooks.enemyLeak.some((x) => x.enemy.defId === 'enemy_1521_dslily'), '自缚: he stays');
assert.equal(bound.b.finished, false);
const h = makeBattle(opts);
assert.ok(h.runUntil(() => h.b.enemies.some((e) => e.defId === 'enemy_1521_dslily'), 5));
for (const e of h.b.enemies) if (e.defId === 'enemy_1521_dslily') h.b.removeBuff(e, 'boss:selfBound');
assert.ok(h.runUntil(() => h.hooks.enemyLeak.some((x) => x.enemy.defId === 'enemy_1521_dslily'), 400), 'the leader leaks');
h.run(200);
assert.equal(h.b.finished, false);
+2 -2
View File
@@ -118,8 +118,8 @@ test('element bursts on enemies ("·我方" terms): burn 7000 元素伤害 + RES
assert.equal(e.findBuff('palsy').stacks, ELEMENT.neural.enemy.palsy);
assert.ok(!e.s.flags.stun, 'enemies are not stunned by a neural burst');
assert.equal(h.hooksOf('elementBurst').length, 2);
// element fragility multiplies 元素伤害
h.b.addBuff(e, { key: 'test:ef', mods: { elemTakenMul: 1.5 } });
// 元素脆弱 (elementalTakenMul) multiplies 元素伤害; the gauge's 元素损伤倍率 (elemTakenMul) does not
h.b.addBuff(e, { key: 'test:ef', mods: { elementalTakenMul: 1.5, elemTakenMul: 3 } });
const hp2 = e.hp;
h.b.dealDamage(null, e, { amount: 100, type: 'elemental', element: 'burn' });
approx(hp2 - e.hp, 150);
+77 -2
View File
@@ -1,13 +1,15 @@
// test/sim/downelem.test.js — user playtest #4 items 8 and 9, sim side (server/sim/Battle.js snapshot / fieldMeta,
// damage.js elementView): b.snap `down` lists the knocked-out operators waiting to redeploy on their own tile (the
// client keeps them on the field knocked down with a redeploy countdown) and `elem` the element gauge each unit shows
// (PRTS 元素: the fullest gauge, its 爆发冷却) — entries, states, and what is left out.
// (PRTS 元素: the fullest gauge, its 爆发冷却) — entries, states, and what is left out. User playtest #5 item 2: an
// operator entering a battle knocked out (联防 carryState `down`, constants.js FORCED_EXIT) is down the same way.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, chessRec, enemyRec } from '../helpers/battleHarness.js';
import { DOWN_STATE, ELEMENT, ELEMENT_ORDER } from '../../server/sim/constants.js';
import { DOWN_STATE, ELEMENT, ELEMENT_ORDER, FORCED_EXIT } from '../../server/sim/constants.js';
import { elementView } from '../../server/sim/damage.js';
import { unitStatsEntry } from '../../shared/protocol.js';
const approx = (a, b, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps, `${a} ≈ ${b}`);
const op = (id, stats = {}) => chessRec({ id, profession: 'WARRIOR', stats: { atk: 0, maxHp: 1000, def: 0, blockCnt: 0, cost: 12, respawnTime: 6, ...stats }, skill: null });
@@ -65,6 +67,79 @@ test('summons, devices, enemies and withdrawn / removed operators are never `dow
assert.ok(ids.has(h.unit('t_c').id) && !ids.has(h.unit('t_a').id) && !ids.has(tok.id));
});
test('carryState.down (联防): deployed with everyone, forced out at once — down on its tile without the knock-out hooks — then back after its timer', () => {
// PRTS 卫戍协议/帮助 §联防阶段: "部署完成后…上一阶段为退场状态的干员强制退场"
const h = makeBattle({
kind: 'unite', defs: { chess: { t_a: op('t_a'), t_b: op('t_b') } },
units: [{ chessId: 't_a', row: 9, col: 5, carryState: { down: true } }, { chessId: 't_b', row: 10, col: 5, carryState: { hpPct: 0.5, sp: 0 } }],
content: 'none', autoFinish: false, timeLimit: 120, flags: { dpInit: 0, dpPerSec: 1, dpMax: 99 },
});
h.step();
const a = h.unit('t_a'), b = h.unit('t_b');
assert.ok(!a.alive && h.b.isDown(a), 'down from the start');
assert.equal(a.removeReason, FORCED_EXIT);
assert.equal(a.hp, 0, 'HP 0 while down, like a knocked-out operator (the live detail card reads unit.hp)');
assert.equal(unitStatsEntry(a).hp, 0);
assert.deepEqual([a.tileR, a.tileC], [9, 5], 'on its own tile');
const d = downOf(h, a.id);
approx(d[1], 6, 0.011);
approx(d[2], 6, 0.011);
assert.equal(d[3], DOWN_STATE.COUNTING, 'its whole redeploy timer, counting');
assert.ok(h.b.fieldMeta().units.some((u) => u.id === a.id), 'a client joining shows it down');
assert.ok(b.alive && Math.abs(b.hp - 500) < 1e-6, 'the standing operator keeps its HP ratio');
// deployed (deploy effects fire), then withdrawn before battleStart; no `kill`, no 'killed' death, no death counted
const dep = h.hooksOf('deploy').find((x) => x.unit === a);
assert.ok(dep && dep.initial === true, 'initial deploy hook');
const deaths = h.hooksOf('death').filter((x) => x.unit === a);
assert.deepEqual(deaths.map((x) => x.reason), [FORCED_EXIT]);
assert.equal(h.hooksOf('kill').length, 0);
assert.equal(h.hooksOf('battleStart').length, 1);
assert.deepEqual(h.eventsOf('die').filter((e) => e[1] === a.id), [['die', a.id, FORCED_EXIT]]);
assert.equal(h.b.result().perPlayer.p1.deaths, 0, 'not a new knock-out');
h.run(6.5);
assert.equal(downOf(h, a.id)[3], DOWN_STATE.WAIT_DP, 'timer done, DP short of its cost');
h.run(6);
assert.ok(a.alive && a.deployed, 'redeployed once DP ≥ cost');
assert.deepEqual([a.tileR, a.tileC], [9, 5]);
assert.equal(a.hp, a.s.maxHp, 'at full HP');
assert.equal(downOf(h, a.id), null);
// a later knock-out in the 联防 battle is an ordinary one
h.b.kill(a);
assert.equal(downOf(h, a.id)[3], DOWN_STATE.COUNTING);
assert.equal(h.b.result().perPlayer.p1.deaths, 1);
});
test('carryState.down (联防): a redeploy-time effect that starts with the battle (机变 征召) covers the operator forced out', () => {
// 征召 "开始作战时若场上至少有一行存在3名干员,所有干员的再部署时间-50%" is switched on by a battleStart handler, after
// the forced exit: the forced-out operator's timer is re-read with it (6 → 3 s), like a later knock-out's
const run = (card) => {
const h = makeBattle({
kind: 'unite', defs: { chess: { t_a: op('t_a'), t_b: op('t_b'), t_c: op('t_c'), t_d: op('t_d') } },
players: [{
playerId: 'p1', seat: 0, side: 'L', colOffset: 0, bonds: {}, bandId: null,
playerEffects: card ? [{ key: 'choice:allybuff_select_14', id: 'choice:allybuff_select_14#1', data: { effectId: 'allybuff_select_14' } }] : [],
units: [
{ uid: 1, kind: 'chess', chessId: 't_a', row: 9, col: 5, dir: 'RIGHT', items: [], carryState: { down: true } },
...['t_b', 't_c', 't_d'].map((chessId, i) => ({ uid: 2 + i, kind: 'chess', chessId, row: 10, col: 4 + i, dir: 'RIGHT', items: [], carryState: { hpPct: 1, sp: 0 } })),
],
}],
autoFinish: false, timeLimit: 120,
});
h.step();
const a = h.unit('t_a'), b = h.unit('t_b');
const forced = downOf(h, a.id);
h.b.kill(b);
const later = downOf(h, b.id);
return { forced: forced[2], forcedAt: forced[1], later: later[2] };
};
const off = run(false), on = run(true);
approx(off.forced, 6, 0.011);
approx(off.later, 6, 0.011);
approx(on.forced, 3, 0.011);
approx(on.forcedAt, 3, 0.011);
approx(on.later, 3, 0.011);
});
test('elementView: the fullest gauge (ties: the official element order), its cooldown while bursting, nothing when empty', () => {
const h = makeBattle({
defs: { enemies: { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 1e6, speed: 0 }), enemy_lead: enemyRec({ key: 'enemy_lead', hp: 1e6, speed: 0, rank: 'BOSS' }) } },
+6 -5
View File
@@ -143,11 +143,12 @@ test('attract: the enemy is released, walks (own speed) to the point, waits ther
assert.equal(e().blockedBy, null, 'released');
assert.ok(e().s.flags.unblockable && e().s.flags.attract);
assert.equal(h.hooksOf('statusApplied').at(-1).entered, true);
const [x0, y0] = [e().x, e().y];
h.run(1);
// grid path: back to its tile centre (9,6), then down the column — axis-aligned legs
approx(Math.abs(e().x - x0) + Math.abs(e().y - y0), 0.5, 0.02, 'own speed (1 × MOVE_SCALE) along the path');
h.run(4.2);
// grid path: back to the centre of the tile it stands on, then on to the point (it was blocked 0.71 tile in front of
// the guard — the block radius — i.e. on (9,7))
let walked = 0, [px, py] = [e().x, e().y];
for (let i = 0; i < 30; i++) { h.step(); walked += Math.hypot(e().x - px, e().y - py); [px, py] = [e().x, e().y]; }
approx(walked, 0.5, 0.02, 'own speed (1 × MOVE_SCALE) along the path');
h.run(5);
approx(e().x, 6, 1e-6); approx(e().y, 11, 1e-6, 'arrived');
h.run(1);
approx(e().y, 11, 1e-6, 'waits there');
+393
View File
@@ -0,0 +1,393 @@
// test/sim/playtest5-targeting.test.js — user playtest #5 items 1 and 5 (DESIGN §19).
// #1 "有近地悬浮的敌人会被只能攻击地面单位的干员打到": a hovering enemy (近地悬浮, PRTS 术语释义 ba.float) is an air unit for
// every targeting rule (Unit.isFlying) while it keeps the ground path; it cannot be blocked or displaced (失衡免疫);
// 掠海漂移体 / 吉兆飞鳞 lose the float when stunned (their PRTS pages); 浮空 (levitate) is an air unit too.
// #5 "敌人优先锁定的干员顺序": PRTS 作战机制 索敌 — 敌方 "阻挡→特殊优先级→仇恨值(更容易被攻击→…→最后部署的目标→不容易被
// 攻击)→最早出现"; PRTS 卫戍协议/帮助 — "按从上到下>从左到右的顺序部署。优先部署干员,随后为召唤物"; a stealthed ally
// is attacked only by the enemy it blocks (PRTS 作战机制 §隐匿); an enemy's own rule (只攻击地面单位) filters its
// candidates before the order; a special priority (优先攻击防御力最高的…) breaks ties by taunt, then latest deployed.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import * as enemiesMod from '../../server/sim/content/enemies.js';
import * as bossesMod from '../../server/sim/content/bosses.js';
import { canTargetEnemy } from '../../server/sim/targeting.js';
const FLOATER = 'enemy_2025_syufo'; // 掠海漂移体 (海嗣, ELEMENT faction)
const PARROT = 'enemy_10045_parrot'; // 吉兆飞鳞 (悬赏·飞行II)
const approx = (a, b, eps = 1e-6, msg = '') => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg} ${a} ≈ ${b}`);
/** Damage `src` dealt to `target` (recorded `damaged` hooks; needs captureNoisy). */
const dealt = (h, src, target) => h.hooksOf('damaged').filter((c) => c.source === src && c.target === target).reduce((a, c) => a + c.amount, 0);
/** A pinned enemy (speed ×0) on a tile; `o.mods` merged. */
const put = (h, key, pos, o = {}) => h.spawn(key, { pos, routeIndex: 0, mods: { speedMul: o.move ? 1 : 0, ...(o.mods || {}) } });
// synthetic allies: a wall that never attacks, a melee blade, a long-range gun
const WALL = chessRec({ id: 't_wall', profession: 'TANK', stats: { atk: 0, maxHp: 1e7, blockCnt: 3 }, rangeGrid: [[0, 0]], skill: null });
const BLADE = chessRec({ id: 't_blade', profession: 'WARRIOR', stats: { atk: 300, maxHp: 1e7, bat: 1, blockCnt: 2 }, rangeGrid: [[0, 0], [0, 1]], skill: null });
const BIG = [];
for (let dr = -4; dr <= 4; dr++) for (let dc = -12; dc <= 12; dc++) BIG.push([dr, dc]);
const GUN = chessRec({ id: 't_gun', profession: 'SNIPER', projectile: 'none', stats: { atk: 300, maxHp: 1e7, bat: 1, blockCnt: 0 }, rangeGrid: BIG, skill: null });
const SHOOTER = enemyRec({ key: 'enemy_t_shooter', hp: 1e7, atk: 10, range: 9, bat: 1, speed: 0 });
const SLIME = enemyRec({ key: 'enemy_t_slime', hp: 1e7, atk: 0, speed: 0 });
const BAT = enemyRec({ key: 'enemy_t_bat', hp: 1e7, atk: 0, speed: 0, motion: 'FLY' });
const SYN = { chess: { t_wall: WALL, t_blade: BLADE, t_gun: GUN }, enemies: { enemy_t_shooter: SHOOTER, enemy_t_slime: SLIME, enemy_t_bat: BAT } };
const NOATK = () => ({ trait: { noAttack: true } });
/** Synthetic arena: generic kits + the enemies module only (other content never interferes). */
function arena(o = {}) {
return makeBattle({ content: 'generic', extraContent: [enemiesMod], seed: 7, autoFinish: false, timeLimit: 300, defs: SYN,
kits: { t_wall: NOATK }, captureNoisy: true, ...o });
}
// ---------------------------------------------------------------------------------------------------------------
// #1 近地悬浮
test('#1 the user\'s case: ground-only operators never hit a 近地悬浮 enemy (melee 山 / 泡泡, 迷迭香\'s ground-only shots); ranged 能天使 and the lord 银灰 do', () => {
const cases = [
['chess_char_5_17_a', 10, 5, false], // 山 (fighter, melee)
['chess_char_2_08_a', 10, 6, false], // 泡泡 (protector, melee — standing on the floater's tile)
['chess_char_6_12_a', 10, 4, false], // 迷迭香 (bombarder: "攻击对小范围的地面敌人造成…" — ranged but ground only)
['chess_char_3_01_a', 10, 4, true], // 能天使 (fastshot, ranged)
['chess_char_4_22_a', 10, 5, true], // 银灰 (lord: ranged reach, can hit air)
];
for (const [chessId, row, col, hits] of cases) {
const h = makeBattle({ units: [{ chessId, row, col }], seed: 3, autoFinish: false, timeLimit: 120, captureNoisy: true });
h.step();
const e = put(h, FLOATER, [10, 6], { mods: { hpMul: 100, atkMul: 0 } });
assert.ok(e.isFlying && e.motion === 'WALK', 'an air unit on the ground path');
h.run(20);
const op = h.b.allyUnits.find((u) => u.kind === 'op');
const d = dealt(h, op, e);
assert.equal(d > 0, hits, `${op.name}: ${d}`);
assert.ok(!e.blockedBy, `${op.name}: never blocks it`);
checkInvariants(h.b);
}
});
test('#1 ground-only splash spares a hovering enemy next to its ground target (迷迭香 + aftershocks)', () => {
const h = makeBattle({ units: [{ chessId: 'chess_char_6_12_a', row: 10, col: 3 }], seed: 3, autoFinish: false, timeLimit: 120, captureNoisy: true, defs: SYN });
h.step();
const g = put(h, 'enemy_t_slime', [10, 6]);
const e = put(h, FLOATER, [10, 6], { mods: { hpMul: 100, atkMul: 0 } });
h.run(15);
const op = h.b.allyUnits.find((u) => u.kind === 'op');
assert.ok(dealt(h, op, g) > 0, 'the ground enemy is shot');
assert.equal(dealt(h, op, e), 0, 'the floater on the same tile takes no splash');
});
test('#1 a 近地悬浮 enemy walks the ground path through blockers, cannot be displaced (失衡免疫) and ignores deep water', () => {
const h = arena({ units: [{ chessId: 't_wall', row: 9, col: 6 }] });
h.step();
const e = h.spawn(FLOATER, { routeIndex: 0, mods: { atkMul: 0 } });
const ys = new Set();
h.b.on('tick', () => { if (e.alive) ys.add(Math.round(e.y)); });
h.runUntil(() => e.x < 5.5, 60);
assert.ok(e.x < 5.5 && !e.blockedBy, 'passed the wall on (9,6)');
assert.deepEqual([...ys], [9], 'stayed on its ground lane');
const x0 = e.x;
assert.equal(h.b.displace(e, { x: 1, y: 0 }, 2, { force: 3 }), 0);
assert.equal(e.x, x0);
// 深水 (act2 m04): the floater hovers over the water — no drowning damage or slow
const w = makeBattle({ stageId: 'act2autochess_m04', autoFinish: false, timeLimit: 30,
routes: [{ motion: 'WALK', start: [11, 6], end: [9, 2], checkpoints: [{ type: 'WAIT', time: 99 }] }],
enemies: [{ key: FLOATER, route: { motion: 'WALK', start: [11, 6], end: [9, 2], checkpoints: [{ type: 'WAIT', time: 99 }] } }] });
w.run(3);
const f = w.enemy(FLOATER);
assert.equal(f.hp, f.s.maxHp, 'not in the water');
assert.equal(f.findBuff('terrain:deepsea'), null);
});
test('#1 掠海漂移体 stunned ⇒ 爬行模式 for good (PRTS): a ground unit — blocked, hit by melee — that only attacks its blocker', () => {
const h = arena({ units: [{ chessId: 't_blade', row: 10, col: 5 }, { chessId: 't_gun', row: 12, col: 3 }] });
h.step();
const e = put(h, FLOATER, [10, 6], { mods: { hpMul: 100 } });
const blade = h.unit('t_blade'), gun = h.unit('t_gun');
h.run(3);
assert.equal(dealt(h, blade, e), 0, 'floating: the blade cannot hit it');
assert.ok(e.stats.attacks > 0, 'floating: attacks at range');
assert.ok(h.b.applyStatus(e, 'stun', { duration: 0.2, source: gun }));
assert.ok(!e.isFlying && !e.s.flags.unblockable && !e.s.flags.noDisplace, 'crawling: a ground unit');
approx(e.findBuff('stun').timeLeft, 0.5, 1e-9, 'the 0.5 s drop stun (the longer one)');
assert.ok(e.profile.melee, '仅进行阻挡攻击');
h.run(3);
assert.ok(dealt(h, blade, e) > 0, 'the blade hits it now');
// walking again it is blocked by a ground operator on its way
const h2 = arena({ units: [{ chessId: 't_wall', row: 9, col: 6 }] });
h2.step();
const e2 = h2.spawn(FLOATER, { routeIndex: 0, mods: { atkMul: 0 } });
h2.b.applyStatus(e2, 'freeze', { duration: 0.5 });
h2.runUntil(() => !!e2.blockedBy || e2.x < 5.5, 60);
assert.equal(e2.blockedBy, h2.unit('t_wall'), '冻结 drops it too; then the wall blocks it');
});
test('#1 吉兆飞鳞: a stun grounds it for Stun.duration s (unblockable), then it floats again; a freeze grounds it when it ends', () => {
const h = arena();
h.step();
const e = put(h, PARROT, [10, 7]);
const down = e.mem.ab.t['Stun.duration'];
assert.ok(down > 0 && e.isFlying);
h.b.applyStatus(e, 'stun', { duration: 0.5 });
assert.ok(!e.isFlying && e.s.flags.unblockable, '晕眩模式: no float, still unblockable');
approx(e.findBuff('stun').timeLeft, down, 1e-9);
h.run(down - 0.2);
assert.ok(!e.isFlying);
h.run(0.4);
assert.ok(e.isFlying && e.s.flags.noDisplace, 'floats again (初始模式)');
h.b.applyStatus(e, 'freeze', { duration: 1 });
h.run(0.5);
assert.ok(e.isFlying, 'frozen: still floating');
h.run(0.6);
assert.ok(!e.isFlying && e.findBuff('stun'), '【失温坠落】: grounded when the freeze ends');
// "离开上述异常效果影响后进入初始模式": a sleep that outlasts the mode stun keeps it down until the sleep ends
const z = put(h, PARROT, [11, 7]);
assert.ok(h.b.applyStatus(z, 'sleep', { duration: down + 3 }));
h.run(down + 0.5);
assert.ok(!z.isFlying && z.s.flags.sleep && !z.findBuff('stun'), 'asleep after the 8 s stun: still grounded');
h.run(3);
assert.ok(z.isFlying && !z.s.flags.sleep, 'awake: floats again');
});
test('#1 浮空 (levitate) makes a ground enemy an air unit (melee cannot hit it); data flyers and levitated units refuse it, a 近地悬浮 enemy does not', () => {
const h = arena({ units: [{ chessId: 't_blade', row: 10, col: 5 }] });
h.step();
const g = put(h, 'enemy_t_slime', [10, 6]);
const blade = h.unit('t_blade');
assert.ok(canTargetEnemy(blade, g, blade.profile));
assert.ok(h.b.applyStatus(g, 'levitate', { duration: 3 }));
assert.ok(g.isFlying && !canTargetEnemy(blade, g, blade.profile) && canTargetEnemy(blade, g, { canHitFly: true }));
// PRTS 行动方式: "行动类型(数据)为飞行的单位、以及已持有浮空异常的单位无法被施加浮空Buff"
assert.equal(h.b.applyStatus(g, 'levitate', { duration: 3 }), false, 'already levitated');
assert.equal(h.b.applyStatus(put(h, 'enemy_t_bat', [9, 6]), 'levitate', { duration: 3 }), false, 'a data flyer');
// 近地悬浮 is WALK in the data: it can be levitated; 浮空 is none of 掠海漂移体's drop triggers (晕眩/无法行动/沉睡/冻结/缚地)
const f = put(h, FLOATER, [11, 6]);
assert.ok(h.b.applyStatus(f, 'levitate', { duration: 3 }), 'a hovering enemy can be levitated');
assert.ok(f.isFlying && f.s.flags.stun && f.findBuff('ab:float'), 'levitated: helpless, still an air unit, still hovering');
assert.ok(!f.profile.melee, 'not in 爬行模式');
h.run(3.1);
assert.ok(!g.isFlying && canTargetEnemy(blade, g, blade.profile), 'back on the ground');
assert.ok(f.isFlying && f.findBuff('ab:float') && !f.s.flags.levitate && !f.profile.melee, 'the floater keeps hovering');
});
/**
* Chess `id` with skill `skillIndex` at (10, col) next to a pinned floating 掠海漂移体 and a ground bait on (10, 6); the
* skill is refilled every second for `secs` s. Returns the damage its side (the operator, its summons) dealt to the bait
* and — only while it still hovered — to the floater, per damage tag.
*/
function floaterDuel(id, skillIndex, { col = 5, secs = 30 } = {}) {
const BAIT = enemyRec({ key: 'enemy_t_bait', hp: 1e9, atk: 0, speed: 0 });
const h = makeBattle({ units: [{ chessId: id, row: 10, col, skillIndex }], seed: 3, autoFinish: false, timeLimit: 200, captureNoisy: true, defs: { enemies: { enemy_t_bait: BAIT } } });
h.step();
const f = put(h, FLOATER, [10, 6], { mods: { hpMul: 1e5, atkMul: 0 } });
const g = put(h, 'enemy_t_bait', [10, 6]);
const out = { floater: 0, bait: 0, floaterTags: new Map() };
h.b.on('damaged', (c) => {
if (!c.source || c.source.side !== 'ally' || !(c.amount > 0)) return;
if (c.target === g) out.bait += c.amount;
if (c.target !== f || !f.findBuff('ab:float')) return;
out.floater += c.amount;
for (const t of c.dmg?.tags || ['-']) out.floaterTags.set(t, (out.floaterTags.get(t) || 0) + c.amount);
});
const op = h.b.allyUnits.find((u) => u.kind === 'op');
for (let t = 0; t < secs; t++) {
if (op.skill && !op.skill.active) op.skill.gainSp(op.skill.spCost * (op.skill.maxCharges || 1) + 1, 'test');
h.run(1);
}
assert.deepEqual(h.b.errors.map((e) => `${e.label} ${e.message}`), []);
return out;
}
test('#1 ground-only skills and talents spare a hovering enemy (the worst 731a01c offenders + the PRTS "不可对空" notes)', () => {
const cases = [
['chess_char_6_19_a', 1, 5, '锏 S2 无声的嘲笑'],
['chess_char_6_19_a', 2, 5, '锏 S3 归于宁静'],
['chess_char_6_07_a', 0, 5, '维娜·维多利亚 S1 重铸晖光'],
['chess_char_5_06_a', 2, 5, '隐德来希 S3 灵与欲的惜别'],
['chess_char_6_12_a', 2, 4, '迷迭香 S3 “如你所愿”'],
['chess_char_5_06_a', 1, 5, '隐德来希 S2 绯红壁合 (PRTS 备注: 血镰 可对空 only on a 起飞 carrier)'],
['chess_char_5_13_a', 1, 5, '归溟幽灵鲨 拥抱自我 (PRTS 备注: "伤害与减速不可对空")'],
['chess_char_3_04_a', 2, 5, '琳琅诗怀雅 S3 千金一掷 (PRTS 备注: 地面敌方单位, 弹道不可对空)'],
['chess_char_5_05_a', 0, 5, '乌尔比安 S1 必须促成的接触 (捕网, like 雪雉\'s "不对空" net)'],
];
for (const [id, idx, col, name] of cases) for (const cid of [id, id.replace(/_a$/, '_b')]) {
const r = floaterDuel(cid, idx, { col });
assert.ok(r.bait > 0, `${name} (${cid}): the ground bait is hit`);
assert.equal(r.floater, 0, `${name} (${cid}): ${JSON.stringify([...r.floaterTags])}`);
}
});
test('#1 skills PRTS marks "可对空" still hit a hovering enemy (德克萨斯 S2 剑雨, 焰尾 S2 “红松林”)', () => {
for (const [id, idx, tag, name] of [['chess_char_1_08_a', 1, 'skill', '德克萨斯 S2'], ['chess_char_4_19_a', 1, 'redPine', '焰尾 S2']]) {
const r = floaterDuel(id, idx);
assert.ok((r.floaterTags.get(tag) || 0) > 0, `${name}: ${JSON.stringify([...r.floaterTags])}`);
}
});
test('#1 阿罗玛\'s bubble levitates a hovering 掠海漂移体 (WALK in its data) without dropping it to 爬行模式', () => {
const h = makeBattle({ units: [{ chessId: 'chess_char_4_10_a', row: 10, col: 3 }], seed: 3, autoFinish: false, timeLimit: 60 });
h.step();
const f = put(h, FLOATER, [10, 6], { mods: { hpMul: 100, atkMul: 0 } });
h.runUntil(() => h.hooksOf('statusApplied').some((c) => c.target === f && c.status === 'levitate'), 10);
const lev = h.hooksOf('statusApplied').find((c) => c.target === f && c.status === 'levitate');
assert.ok(lev, 'levitated by her first hit');
assert.ok(f.isFlying && f.findBuff('ab:float') && !f.profile.melee, 'still hovering, not crawling');
});
test('#1 HOVER_KEYS lists exactly the enemies that spawn hovering (the match\'s bot counts them as air units)', () => {
const E = JSON.parse(fs.readFileSync(new URL('../../data/enemies.json', import.meta.url), 'utf8'));
const h = arena();
h.step();
const hov = [];
for (const key of Object.keys(E)) {
const e = put(h, key, [10, 6]);
if (e && e.findBuff('ab:float')) hov.push(key);
}
assert.deepEqual(hov.sort(), [...enemiesMod.HOVER_KEYS].sort());
});
// ---------------------------------------------------------------------------------------------------------------
// #5 enemy target order
/** The ally the latest attack of `enemy` targeted. */
function lastTarget(h, enemy) {
const ev = h.eventsOf('atk').filter((x) => x[1] === enemy.id).pop();
return ev ? h.b.unitById(ev[2]) : null;
}
test('#5 unblocked ranged enemies: highest taunt, then the latest deployed — the bottom row, rightmost (从上到下 > 从左到右)', () => {
const units = [[12, 5], [11, 3], [9, 4], [9, 7], [10, 8]].map(([row, col], i) => ({ chessId: 't_wall', row, col, uid: i + 1 }));
const h = arena({ units, enemies: [{ key: 'enemy_t_shooter', pos: [10.5, 6] }] });
h.step();
const order = h.hooksOf('deploy').filter((c) => c.initial).map((c) => [c.unit.tileR, c.unit.tileC]);
assert.deepEqual(order, [[12, 5], [11, 3], [10, 8], [9, 4], [9, 7]]);
h.run(1.2);
const e = h.enemy('enemy_t_shooter');
const t = lastTarget(h, e);
assert.deepEqual([t.tileR, t.tileC], [9, 7], 'the last deployed');
// a taunt level beats deployment order
const h2 = arena({ units, enemies: [{ key: 'enemy_t_shooter', pos: [10.5, 6] }] });
h2.step();
h2.b.addBuff(h2.unit(1), { key: 'test:taunt', mods: { taunt: 1 }, persist: true });
h2.run(1.2);
assert.equal(lastTarget(h2, h2.enemy('enemy_t_shooter')), h2.unit(1));
});
test('#5 the initial deployment puts every operator before the summons (PRTS 卫戍协议/帮助) — a summon piece draws the fire', () => {
const tok = { kind: 'token', tokenId: 'token_10028_vigil_wolf', ownerUid: 1, row: 12, col: 4, uid: 3, def: { name: 'tok', stats: { maxHp: 1e7, atk: 0, blockCnt: 0 } } };
const h = arena({ units: [{ chessId: 't_wall', row: 12, col: 3, uid: 1 }, { chessId: 't_wall', row: 9, col: 8, uid: 2 }, tok], enemies: [{ key: 'enemy_t_shooter', pos: [10.5, 6] }] });
h.step();
const order = h.hooksOf('deploy').filter((c) => c.initial).map((c) => c.unit.uid);
assert.deepEqual(order, [1, 2, 3], 'operators top→bottom, then the summon');
const t = h.unit(3);
assert.ok(t.aggroSeq > h.unit(1).aggroSeq && t.aggroSeq > h.unit(2).aggroSeq);
h.run(1.2);
assert.equal(lastTarget(h, h.enemy('enemy_t_shooter')), t);
// a summon an operator brings along at the start ranks after every operator too
const h2 = arena({ units: [{ chessId: 't_wall', row: 12, col: 3, uid: 1 }, { chessId: 't_wall', row: 9, col: 8, uid: 2 }], enemies: [{ key: 'enemy_t_shooter', pos: [10.5, 6] }],
setup: (b) => b.on('deploy', (c) => { if (c.initial && c.unit.uid === 1) b.spawnToken(c.unit, 'token_10028_vigil_wolf', 12, 4, { def: { name: 'tok', stats: { maxHp: 1e7, atk: 0, blockCnt: 0 } } }); }) });
h2.step();
const s = h2.b.allyUnits.find((u) => u.kind === 'token');
assert.ok(s.deploySeq < h2.unit(2).deploySeq, 'it came in during the first operator\'s deployment');
assert.ok(s.aggroSeq > h2.unit(2).aggroSeq, '…but ranks after every operator');
h2.run(1.2);
assert.equal(lastTarget(h2, h2.enemy('enemy_t_shooter')), s);
// a redeployed operator is the latest deployed again
h2.b.retreat(h2.unit(1), { reason: 'retreat' });
h2.b.redeploy(h2.unit(1), { free: true });
assert.ok(h2.unit(1).aggroSeq > s.aggroSeq);
});
test('#5 a blocked ranged enemy attacks its blocker first', () => {
const h = arena({ units: [{ chessId: 't_wall', row: 10, col: 6, uid: 1 }, { chessId: 't_wall', row: 9, col: 7, uid: 2 }], enemies: [{ key: 'enemy_t_shooter', pos: [10, 6] }] });
h.step();
h.run(1.2);
const e = h.enemy('enemy_t_shooter');
assert.equal(e.blockedBy, h.unit(1));
assert.equal(lastTarget(h, e), h.unit(1), 'not the later-deployed one beside it');
});
test('#5 隐匿 / 迷彩 allies: only the enemy they block attacks them — blocking does not expose them to other ranged enemies (PRTS 作战机制 §隐匿, 索敌的概念)', () => {
const units = [{ chessId: 't_wall', row: 12, col: 5, uid: 1 }, { chessId: 't_wall', row: 9, col: 7, uid: 2 }];
const mk = (stealth) => {
const h = arena({ units, enemies: [{ key: 'enemy_t_shooter', pos: [10.5, 5] }, { key: 'enemy_t_shooter', pos: [9, 7] }] });
h.step();
if (stealth) h.b.applyStatus(h.unit(2), 'stealth', { duration: 99 });
h.run(1.2);
const held = h.b.enemies.find((e) => e.blockedBy === h.unit(2));
const free = h.b.enemies.find((e) => e !== held);
assert.ok(held && free && !free.blockedBy);
return { h, held, free };
};
const a = mk(true);
assert.equal(lastTarget(a.h, a.held), a.h.unit(2), 'the enemy it blocks attacks it ("强行无视对方可选性")');
assert.equal(lastTarget(a.h, a.free), a.h.unit(1), 'an unblocked ranged enemy skips it although it blocks ("我方干员并不会因为阻挡而解除隐匿")');
const b = mk(false);
assert.equal(lastTarget(b.h, b.free), b.h.unit(2), 'control: without stealth the latest deployed is shot');
// 排气格栅 (烟雾) uses the same stealth flag: test/content/tokens_devices.test.js
});
test('#5 an enemy\'s own rule filters its candidates first: 萨卡兹枯朽战车 (只攻击地面单位) shoots the ground operator, never idles', () => {
// (10,2) is high ground: that operator deploys last (row 10 after row 11) and would be the preferred target
const h = arena({ units: [{ chessId: 't_wall', row: 11, col: 5, uid: 1 }, { chessId: 't_wall', row: 10, col: 2, uid: 2 }] });
h.step();
assert.equal(h.unit(2).ground, false);
const e = put(h, 'enemy_1272_nhtank', [10, 4], { mods: { atkMul: 0.01 } });
h.runUntil(() => e.stats.attacks >= 2, 20);
assert.ok(e.stats.attacks >= 2, 'it attacks');
const targets = h.eventsOf('atk').filter((x) => x[1] === e.id).map((x) => x[2]);
assert.ok(targets.every((id) => id === h.unit(1).id), 'only the ground operator');
// PRTS 天赋 "普通攻击只攻击位于低地的我方单位,且不会攻击飞行单位": a flying ally on a low tile (the 炎佑 dragon) is no target
const h2 = arena({ units: [{ chessId: 't_wall', row: 11, col: 5, uid: 1 }, { chessId: 't_wall', row: 10, col: 5, uid: 2 }] });
h2.step();
h2.unit(2).motion = 'FLY';
assert.ok(h2.unit(2).ground && h2.unit(2).isFlying);
const e2 = put(h2, 'enemy_1272_nhtank', [10, 4], { mods: { atkMul: 0.01 } });
h2.runUntil(() => e2.stats.attacks >= 2, 20);
assert.ok(e2.stats.attacks >= 2);
assert.ok(h2.eventsOf('atk').filter((x) => x[1] === e2.id).every((x) => x[2] === h2.unit(1).id), 'never the flyer');
});
test('#5 “萨科塔之眼” (不会攻击飞行单位) filters its candidates: it shoots the ground ally, never the later-deployed flyer', () => {
const h = arena({ units: [{ chessId: 't_wall', row: 11, col: 5, uid: 1 }, { chessId: 't_wall', row: 10, col: 5, uid: 2 }] });
h.step();
h.unit(2).motion = 'FLY';
const e = put(h, 'enemy_10084_hlegle', [10.5, 5.5], { mods: { atkMul: 0.01 } });
h.runUntil(() => e.stats.attacks >= 2, 20);
assert.ok(e.stats.attacks >= 2, 'it attacks');
assert.ok(h.eventsOf('atk').filter((x) => x[1] === e.id).every((x) => x[2] === h.unit(1).id), 'only the ground ally');
});
test('#5 a shared field (boss / 联防) deploys the players side by side — the i-th operators together, then the summons [ASSUMED]', () => {
const W = chessRec({ id: 't_w', profession: 'TANK', stats: { atk: 0, maxHp: 1e7, blockCnt: 3 }, rangeGrid: [[0, 0]], skill: null });
const tok = { kind: 'token', tokenId: 'token_10028_vigil_wolf', ownerUid: 1, row: 12, col: 6, uid: 5, def: { name: 'tok', stats: { maxHp: 1e7, atk: 0, blockCnt: 0 } } };
const h = makeBattle({ kind: 'boss', content: 'none', autoFinish: false, defs: { chess: { t_w: W } },
players: [
{ playerId: 'L1', side: 'L', colOffset: 0, units: [{ uid: 1, chessId: 't_w', row: 12, col: 4 }, { uid: 2, chessId: 't_w', row: 11, col: 4 }, { uid: 3, chessId: 't_w', row: 10, col: 4 }, tok] },
{ playerId: 'R1', side: 'R', colOffset: 8, units: [{ uid: 4, chessId: 't_w', row: 12, col: 4 }] },
] });
h.step();
assert.deepEqual(h.hooksOf('deploy').filter((c) => c.initial).map((c) => c.unit.uid), [1, 4, 2, 3, 5],
'not every left operator before the right one: at equal taunt the leader does not always prefer one player');
const seq = (uid) => h.b.allyUnits.find((u) => u.uid === uid).aggroSeq;
assert.ok(seq(5) > Math.max(seq(1), seq(2), seq(3), seq(4)), 'the summon ranks after every operator');
});
test('#5 a special priority breaks ties by taunt, then the latest deployed (假想敌:铳 "优先攻击防御力最高的单位"; PRTS 索敌: 特殊优先级 → 仇恨值)', () => {
const pool = { hp: 1e7, maxHp: 1e7, damage(pid, a) { this.hp = Math.max(0, this.hp - a); } };
const duel = (taunt) => {
const h = makeBattle({ kind: 'boss', sharedBoss: pool, content: 'generic', extraContent: [enemiesMod, bossesMod], seed: 7, autoFinish: false, timeLimit: 600,
defs: SYN, kits: { t_wall: NOATK }, units: [{ chessId: 't_wall', row: 10, col: 9, uid: 1 }, { chessId: 't_wall', row: 10, col: 11, uid: 2 }] });
h.step();
if (taunt) h.b.addBuff(h.unit(1), { key: 'test:taunt', mods: { taunt: 1 }, persist: true });
const e = h.spawn('enemy_9017_achunt', { pos: [3, 10], routeIndex: 0, mods: { speedMul: 0 }, tag: 'boss' });
h.runUntil(() => e.stats.attacks >= 1, 60);
assert.ok(e.stats.attacks >= 1);
assert.equal(h.unit(1).s.def, h.unit(2).s.def, 'equal DEF');
return { h, t: lastTarget(h, e) };
};
const a = duel(false);
assert.equal(a.t, a.h.unit(2), 'equal DEF: the latest deployed');
const b = duel(true);
assert.equal(b.t, b.h.unit(1), 'equal DEF: the higher taunt level first');
});
+271
View File
@@ -0,0 +1,271 @@
// User playtest #5 item 4 — "如果敌人被卡在两个干员之间的话,靠后的干员死亡敌人会直接走掉,而不是换成靠前面的干员接替阻挡".
//
// Official rule (PRTS 游戏数据基础 §阻挡半径, 作战机制 §碰撞体积与位置识别 "中点判定 … 案例: 阻挡"): an unblocked ground
// enemy whose position lies within the ground block radius 0.7071 (squared 0.49999037) of a ground operator's centre is
// blocked by it while the operator has free block capacity — checked continuously, so an enemy that overlaps an
// operator when its blocker dies (or when the operator's capacity frees up) is taken over at once. Flying enemies:
// air block radius 0.8944 (起飞 / blockFly units); devices (阻隔工事 / 障碍物): 0.4472.
//
// Flat stage: lower-gate enemies walk row 9 from the gate (9,10) to the goal (9,2), i.e. towards smaller columns.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { BLOCK_RADIUS } from '../../server/sim/constants.js';
import { getDefaultSource } from '../../server/sim/simdata.js';
const FRONT = 'test_front_a', BACK = 'test_back_a';
const op = (id, blockCnt = 1) => chessRec({ id, stats: { atk: 0, blockCnt, maxHp: 1e6, respawnTime: 999 }, skill: null });
const foe = (key, o = {}) => enemyRec({ key, hp: 1e7, speed: 1, ...o });
function lane(units, enemies, defs = {}) {
return makeBattle({
defs: {
chess: { [FRONT]: op(FRONT, defs.frontBlock ?? 1), [BACK]: op(BACK, defs.backBlock ?? 1), ...(defs.chess || {}) },
enemies: { enemy_x: foe('enemy_x'), enemy_e: foe('enemy_e'), enemy_heavy: foe('enemy_heavy', { blockCnt: 2 }), ...(defs.enemies || {}) },
},
units, enemies, timeLimit: 200, content: 'none',
});
}
const kill = (h, u) => h.b.loseHp(u, 1e12);
test('the official block radii are the PRTS numbers', () => {
assert.ok(Math.abs(BLOCK_RADIUS.ground - 0.7071) < 1e-3);
assert.ok(Math.abs(BLOCK_RADIUS.fly - 0.8944) < 1e-3);
assert.ok(Math.abs(BLOCK_RADIUS.device - 0.4472) < 1e-3);
});
test('#4: the enemy between two operators is taken over by the front one when its blocker (the rear one) dies', () => {
// front (9,6) blocks X; E walks through the full front operator and is blocked by the rear one (9,5) — it now
// overlaps both. X dies (front has room again), then the rear blocker dies: E must stay, blocked by the front one.
const h = lane([{ chessId: FRONT, row: 9, col: 6 }, { chessId: BACK, row: 9, col: 5 }],
[{ key: 'enemy_x', time: 0 }, { key: 'enemy_e', time: 1 }]);
const front = h.unit(FRONT), back = h.unit(BACK);
const x = () => h.b.enemies.find((e) => e.defId === 'enemy_x');
const e = () => h.b.enemies.find((q) => q.defId === 'enemy_e');
assert.ok(h.runUntil(() => x()?.blockedBy === front && e()?.blockedBy === back, 30), 'X blocked by the front, E by the rear operator');
const ex = e().x;
assert.ok(Math.hypot(ex - 6, e().y - 9) < BLOCK_RADIUS.ground, `E overlaps the front operator too (x=${ex.toFixed(3)})`);
kill(h, x());
h.step(2);
assert.equal(e().blockedBy, back, 'E stays with its blocker while the rear operator lives');
kill(h, back);
h.step(2);
assert.equal(e().blockedBy, front, 'the front operator takes E over');
h.run(5);
assert.equal(e().blockedBy, front);
assert.ok(Math.abs(e().x - ex) < 1e-6, 'E did not walk on');
checkInvariants(h.b);
});
test('#4: a withdrawn or stunned blocker hands its enemy to the operator it still touches', () => {
for (const how of ['retreat', 'stun']) {
const h = lane([{ chessId: FRONT, row: 9, col: 6 }, { chessId: BACK, row: 9, col: 5 }],
[{ key: 'enemy_x', time: 0 }, { key: 'enemy_e', time: 1 }]);
const front = h.unit(FRONT), back = h.unit(BACK);
const x = () => h.b.enemies.find((e) => e.defId === 'enemy_x');
const e = () => h.b.enemies.find((q) => q.defId === 'enemy_e');
assert.ok(h.runUntil(() => x()?.blockedBy === front && e()?.blockedBy === back, 30));
kill(h, x());
if (how === 'retreat') h.b.retreat(back); else h.b.applyStatus(back, 'stun', { duration: 5 });
h.step(2);
assert.equal(e().blockedBy, front, how);
checkInvariants(h.b);
}
});
test('#4: the rear blocker dies while the front operator is still full — the enemy walks on (no room)', () => {
const h = lane([{ chessId: FRONT, row: 9, col: 6 }, { chessId: BACK, row: 9, col: 5 }],
[{ key: 'enemy_x', time: 0 }, { key: 'enemy_e', time: 1 }]);
const front = h.unit(FRONT), back = h.unit(BACK);
const x = () => h.b.enemies.find((e) => e.defId === 'enemy_x');
const e = () => h.b.enemies.find((q) => q.defId === 'enemy_e');
assert.ok(h.runUntil(() => x()?.blockedBy === front && e()?.blockedBy === back, 30));
kill(h, back);
h.step(2);
assert.equal(e().blockedBy, null, 'no room at the front → E walks on');
const x0 = e().x;
h.run(1);
assert.ok(e().x < x0 - 0.2, 'E is walking again');
checkInvariants(h.b);
});
test('#4: an operator whose capacity frees up blocks an enemy that still overlaps it (past its tile, inside 0.7071)', () => {
const h = lane([{ chessId: FRONT, row: 9, col: 6 }], [{ key: 'enemy_x', time: 0 }, { key: 'enemy_e', time: 1 }]);
const front = h.unit(FRONT);
const x = () => h.b.enemies.find((e) => e.defId === 'enemy_x');
const e = () => h.b.enemies.find((q) => q.defId === 'enemy_e');
assert.ok(h.runUntil(() => x()?.blockedBy === front, 30));
// E walks through the full operator; free the capacity once E has left the operator's tile (x < 5.5) but is still
// within the block radius
assert.ok(h.runUntil(() => e() && e().x < 5.42, 30));
assert.equal(e().blockedBy, null, 'passing through while full');
kill(h, x());
h.step(1);
assert.equal(e().blockedBy, front, 'taken as soon as the operator has room');
checkInvariants(h.b);
});
test('#4: pass-through when full — an enemy that never finds room walks past the operator', () => {
const h = lane([{ chessId: FRONT, row: 9, col: 6 }], [{ key: 'enemy_x', time: 0 }, { key: 'enemy_e', time: 1 }]);
const front = h.unit(FRONT);
const e = () => h.b.enemies.find((q) => q.defId === 'enemy_e');
assert.ok(h.runUntil(() => e() && e().x < 4.5, 40), 'E walked past the full operator');
assert.equal(e().blockedBy, null);
assert.equal(front.blocking.length, 1);
checkInvariants(h.b);
});
test('#4: contact happens at the block radius (the enemy stops ~0.71 tile from the operator, outside its tile)', () => {
const h = lane([{ chessId: FRONT, row: 9, col: 6 }], [{ key: 'enemy_x', time: 0 }]);
const front = h.unit(FRONT);
const x = () => h.b.enemies.find((e) => e.defId === 'enemy_x');
assert.ok(h.runUntil(() => x()?.blockedBy === front, 30));
const d = Math.hypot(x().x - 6, x().y - 9);
assert.ok(d <= BLOCK_RADIUS.ground + 1e-9 && d > BLOCK_RADIUS.ground - 0.05, `blocked at contact (d=${d.toFixed(3)})`);
checkInvariants(h.b);
});
test('#4: an operator beside the lane (one row off) never touches the enemies walking it', () => {
const h = lane([{ chessId: FRONT, row: 10, col: 6 }], [{ key: 'enemy_x', time: 0 }]);
const x = () => h.b.enemies.find((e) => e.defId === 'enemy_x');
assert.ok(h.runUntil(() => x() && x().x < 4, 40));
assert.equal(h.hooksOf('blocked').length, 0);
});
test('#4: block weight — a 2-weight enemy needs 2 free block', () => {
const h1 = lane([{ chessId: FRONT, row: 9, col: 6 }], [{ key: 'enemy_heavy', time: 0 }]);
const heavy1 = () => h1.b.enemies.find((e) => e.defId === 'enemy_heavy');
assert.ok(h1.runUntil(() => heavy1() && heavy1().x < 4.5, 40), 'block 1 cannot hold a 2-weight enemy');
assert.equal(heavy1().blockedBy, null);
const h2 = lane([{ chessId: FRONT, row: 9, col: 6 }], [{ key: 'enemy_heavy', time: 0 }], { frontBlock: 2 });
assert.ok(h2.runUntil(() => h2.b.enemies[0]?.blockedBy === h2.unit(FRONT), 30), 'block 2 holds it');
});
test('#4: of two free operators in contact the nearer one blocks', () => {
// an enemy standing still between (9,6) and (9,5), nearer to (9,5)
const h = lane([{ chessId: FRONT, row: 9, col: 6 }, { chessId: BACK, row: 9, col: 5 }], [],
{ enemies: { enemy_still: foe('enemy_still', { speed: 0 }) } });
h.step(1);
const e = h.spawn('enemy_still', { pos: [9, 5.4] });
h.step(1);
assert.equal(e.blockedBy, h.unit(BACK));
checkInvariants(h.b);
});
test('#4: a blocker attacks the enemy it blocks even when it stands outside its range (可以选择且优先选择阻挡单位)', () => {
// facing LEFT (away from the gate): its range (9,6),(9,5) never holds the enemy blocked at x ≈ 6.71 (tile (9,7))
const h = makeBattle({
defs: { chess: { t_back: chessRec({ id: 't_back', stats: { atk: 100, blockCnt: 1, maxHp: 1e6 }, skill: null }) }, enemies: { enemy_x: foe('enemy_x') } },
units: [{ chessId: 't_back', row: 9, col: 6, dir: 'LEFT' }], enemies: [{ key: 'enemy_x', time: 0 }], timeLimit: 60,
hooks: ['damaged'], captureNoisy: true,
});
const u = h.unit('t_back');
const x = () => h.b.enemies[0];
assert.ok(h.runUntil(() => x()?.blockedBy === u, 30));
assert.ok(!u.rangeKeySet.has(Math.round(x().y) * 21 + Math.round(x().x)), 'outside its range');
h.run(3);
assert.ok(h.hooksOf('damaged').some((c) => c.source === u && c.target === x()), 'it hits its blocked enemy');
});
test('#4: a ranged operator on a melee tile blocks, but never attacks or casts for the enemy held behind it (阻挡(近战限定))', () => {
// PRTS 索敌的概念: "我方索敌优先级:阻挡(近战限定)"; "远程位干员通常而言不会优先攻击自己阻挡的目标;甚至如果这个目标被阻挡在
// 该干员的身后,该干员可能因为敌人未处于自身攻击范围而无法攻击到这个敌人". Facing LEFT, its range (9,6),(9,5) never holds
// the enemy blocked at x ≈ 6.71; a melee operator in the same spot hits it and casts its DEFAULT skill (帮助: "敌人被近战
// 干员自身阻挡" satisfies the target condition).
const run = (id, profession) => {
const h = makeBattle({
defs: { chess: { [id]: chessRec({ id, profession, stats: { atk: 100, blockCnt: 1, maxHp: 1e6 }, skill: { spCost: 1, initSp: 1, duration: 5 } }) }, enemies: { enemy_x: foe('enemy_x') } },
units: [{ chessId: id, row: 9, col: 6, dir: 'LEFT' }], enemies: [{ key: 'enemy_x', time: 0 }], timeLimit: 60,
content: 'generic', kits: { [id]: () => ({ skill: { kind: 'duration', duration: 5 } }) }, hooks: ['damaged'], captureNoisy: true,
});
const u = h.unit(id);
const x = () => h.b.enemies[0];
assert.ok(h.runUntil(() => x()?.blockedBy === u, 30), `${profession} blocks`);
assert.ok(!u.rangeKeySet.has(Math.round(x().y) * 21 + Math.round(x().x)), 'outside its range');
h.run(3);
checkInvariants(h.b);
return { hits: h.hooksOf('damaged').filter((c) => c.source === u && c.target === x()).length, casts: u.skill.activations, still: x().blockedBy === u };
};
const ranged = run('t_rg', 'SNIPER');
assert.equal(ranged.hits, 0, 'the ranged blocker never hits the enemy behind it');
assert.equal(ranged.casts, 0, 'nor casts its skill for it');
assert.ok(ranged.still, 'it still holds the enemy');
const melee = run('t_ml', 'WARRIOR');
assert.ok(melee.hits > 0, 'a melee blocker hits it');
assert.equal(melee.casts, 1, 'and casts its skill');
});
test('#4: "melee" is the deploy position — a 领主 with a ranged attack (银灰) still hits the enemy it holds behind it', () => {
// data position MELEE, attackKind ranged (领主 / 哨戒铁卫 / 要塞 / 钩索师 …): a melee unit for the blocked-first selector
const id = 'chess_char_4_22_a';
const h = makeBattle({
defs: { enemies: { enemy_x: foe('enemy_x') } }, timeLimit: 60, hooks: ['damaged'], captureNoisy: true,
units: [{ chessId: id, row: 9, col: 6, dir: 'LEFT' }], enemies: [{ key: 'enemy_x', time: 0 }],
});
const u = h.unit(id);
const x = () => h.b.enemies[0];
assert.equal(u.def.position, 'MELEE');
assert.equal(u.profile.attack, 'ranged', 'a ranged attack profile');
assert.ok(h.runUntil(() => x()?.blockedBy === u, 30), 'blocks');
assert.ok(!u.rangeKeySet.has(Math.round(x().y) * 21 + Math.round(x().x)), 'outside its range (behind it)');
h.run(4);
assert.ok(h.hooksOf('damaged').some((c) => c.source === u && c.target === x()), 'it hits the enemy it blocks');
});
test('#4: blocked-first is a melee priority — a ranged blocker shoots the enemy nearest the goal', () => {
// range (9,5)–(9,7) facing RIGHT: the walker it blocks stands on (9,7); a flyer passes over the operator towards the
// goal. The ranged operator picks by its own order (least remaining distance first), a melee one (able to hit air)
// keeps hitting the enemy it blocks.
const run = (id, o) => {
const h = makeBattle({
defs: {
chess: { [id]: chessRec({ id, rangeGrid: [[0, -1], [0, 0], [0, 1]], stats: { atk: 1, bat: 0.2, blockCnt: 1, maxHp: 1e6 }, skill: null, ...o }) },
enemies: { enemy_x: foe('enemy_x'), enemy_fl: foe('enemy_fl', { motion: 'FLY' }) },
},
units: [{ chessId: id, row: 9, col: 6 }], enemies: [{ key: 'enemy_x', time: 0 }, { key: 'enemy_fl', time: 5, route: 2 }], timeLimit: 60,
content: 'none', hooks: ['damaged'], captureNoisy: true,
});
const u = h.unit(id);
const walker = () => h.b.enemies.find((e) => e.defId === 'enemy_x');
const flyer = () => h.b.enemies.find((e) => e.defId === 'enemy_fl');
assert.ok(h.runUntil(() => walker()?.blockedBy === u, 30), 'the walker is blocked');
assert.ok(h.runUntil(() => flyer() && flyer().x < 4.4, 30), 'the flyer passed');
assert.equal(walker().blockedBy, u);
return h.hooksOf('damaged').filter((c) => c.source === u && c.target === flyer()).length;
};
assert.ok(run('t_rg2', { profession: 'SNIPER' }) > 0, 'the ranged blocker shot the flyer while it was nearer the goal');
assert.equal(run('t_ml2', { profession: 'WARRIOR', attackKind: 'melee', canHitFly: true }), 0, 'the melee blocker kept to its blocked enemy');
});
test('#4: a skywalker (起飞: blockFly) blocks flyers at the air block radius 0.8944', () => {
const h = makeBattle({
defs: { chess: { t_sky: chessRec({ id: 't_sky', subProfessionId: 'skywalker', stats: { atk: 0, blockCnt: 2, maxHp: 1e6 }, skill: null }) },
enemies: { enemy_fl: foe('enemy_fl', { motion: 'FLY' }) } },
units: [{ chessId: 't_sky', row: 9, col: 6 }], enemies: [{ key: 'enemy_fl', route: 2 }], timeLimit: 60,
});
const sky = h.unit('t_sky');
const f = () => h.b.enemies[0];
assert.ok(h.runUntil(() => f()?.blockedBy === sky, 30), 'the flyer is blocked');
const d = Math.hypot(f().x - 6, f().y - 9);
assert.ok(d <= BLOCK_RADIUS.fly + 1e-9 && d > BLOCK_RADIUS.ground, `at the air radius (d=${d.toFixed(3)})`);
checkInvariants(h.b);
});
test('#4: 瑕光 S2 puts the enemy she blocks to sleep although it stands outside her tile (PRTS: 自身这格内…及自身阻挡的敌人)', () => {
const ds = getDefaultSource();
const id = 'chess_char_3_12_a';
const skillIndex = ds.rawChess(id).skills.find((s) => s.skillId === 'skchr_blemsh_2').index;
const h = makeBattle({
defs: { enemies: { enemy_x: foe('enemy_x') } }, timeLimit: 60,
units: [{ chessId: id, row: 9, col: 6, skillIndex }], enemies: [{ key: 'enemy_x', time: 0 }],
});
const u = h.unit(id);
const x = () => h.b.enemies[0];
assert.ok(h.runUntil(() => x()?.blockedBy === u, 30));
assert.notEqual(Math.round(x().x), 6, 'the blocked enemy is on the tile in front of her');
u.skill.gainSp(u.skill.spCost * u.skill.maxCharges + 1, 'test');
assert.ok(h.runUntil(() => u.skill.active, 5));
h.step();
assert.ok(x().s.flags.sleep, 'asleep');
});
+230
View File
@@ -0,0 +1,230 @@
// User playtest #5 item 10 — "大体型的关底boss实际受击判定范围很小,是不是就一格,跟官方原版不一样,原版是很大的一片区域".
//
// Official (PRTS enemy pages, 天赋 "巨型单位:受击判定区域为长4.95、宽2.95的长方形,向上偏移1.0"; 假想敌:管 隐秘核心 also
// "向右偏移1.0"; 卫戍协议:盟约 下半/PRTS盟约记录: the season's 阿利斯泰尔 / “萨米的意志” have the same 4.95 × 2.95, up 1) and
// PRTS 作战机制 "巨型BOSS单位的每一个占据的格子都可以让其本身通过格子判定": a huge boss is hit anywhere on its rectangle —
// every tile it occupies counts for grid ranges, radius effects measure to the rectangle. Regular enemies are
// unchanged.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { bodyKeys, bodyDist, bodyInKeys, bodyOnTile, bodyTileReach, hitRect, normHitArea } from '../../server/sim/body.js';
import { COLS } from '../../server/sim/constants.js';
const E = JSON.parse(fs.readFileSync(new URL('../../data/enemies.json', import.meta.url), 'utf8'));
const tiles = (keys) => keys.map((k) => [Math.floor(k / COLS), k % COLS]).sort((a, b) => a[0] - b[0] || a[1] - b[1]);
const block = (r0, r1, c0, c1) => { const o = []; for (let r = r0; r <= r1; r++) for (let c = c0; c <= c1; c++) o.push([r, c]); return o; };
test('data: the huge bosses carry the PRTS hit areas, everything else is a point', () => {
const want = {
enemy_9013_acstmk: { w: 4.95, h: 2.95, dx: 0, dy: 1 }, enemy_9013_acstmk_2: { w: 4.95, h: 2.95, dx: 0, dy: 1 },
enemy_9021_acduml: { w: 4.95, h: 2.95, dx: 0, dy: 1 }, enemy_9021_acduml_2: { w: 4.95, h: 2.95, dx: 1, dy: 1 },
enemy_1521_dslily: { w: 4.95, h: 2.95, dx: 0, dy: 1 }, enemy_9032_aclionk: { w: 4.95, h: 2.95, dx: 0, dy: 1 },
enemy_9033_acdeer: { w: 4.95, h: 2.95, dx: 0, dy: 1 },
};
for (const [k, v] of Object.entries(want)) assert.deepEqual(E[k].hitArea, v, k);
const others = Object.keys(E).filter((k) => E[k].hitArea && !want[k]);
assert.deepEqual(others, [], 'no other enemy is huge (假想敌:铳 / 卢西恩 / the parts are regular)');
for (const k of ['enemy_9017_achunt', 'enemy_9017_achunt_2', 'enemy_2016_csphtm', 'enemy_9014_acstma', 'enemy_9022_acdumm']) assert.equal(E[k].hitArea, undefined, k);
assert.equal(normHitArea({ w: 0, h: 1 }), null);
assert.equal(normHitArea(null), null);
});
test('geometry: 4.95 × 2.95 up 1 at the boss tile (3,10) occupies rows 3–5 × cols 8–12; 管 (隐秘核心) at (3,9) the same block', () => {
const boss = { x: 10, y: 3, hitArea: normHitArea(E.enemy_9013_acstmk.hitArea) };
assert.deepEqual(tiles(bodyKeys(boss)), block(3, 5, 8, 12));
const pipe2 = { x: 9, y: 3, hitArea: normHitArea(E.enemy_9021_acduml_2.hitArea) };
assert.deepEqual(tiles(bodyKeys(pipe2)), block(3, 5, 8, 12));
assert.ok(bodyOnTile(boss, 5, 12) && bodyOnTile(boss, 3, 8) && !bodyOnTile(boss, 2, 10) && !bodyOnTile(boss, 4, 13));
const R = hitRect(boss);
assert.deepEqual([R.x0, R.x1, R.y0, R.y1].map((v) => +v.toFixed(3)), [7.525, 12.475, 2.525, 5.475]);
assert.equal(bodyDist(boss, 10, 4), 0, 'inside');
assert.ok(Math.abs(bodyDist(boss, 13.475, 4) - 1) < 1e-9, '1 tile right of the edge');
assert.equal(bodyTileReach(boss, 4, 13), 1);
assert.equal(bodyTileReach(boss, 4, 12), 0);
// a regular enemy is its position's tile / a point
const e = { x: 6.4, y: 9.6 };
assert.deepEqual(tiles(bodyKeys(e)), [[10, 6]]);
assert.ok(bodyInKeys(e, new Set([10 * COLS + 6])) && !bodyInKeys(e, [10 * COLS + 7]));
assert.ok(Math.abs(bodyDist(e, 6.4, 10.6) - 1) < 1e-9);
});
// ---------------------------------------------------------------------------------------------------------------
// battles
const HUGE = { ...enemyRec({ key: 'enemy_huge', hp: 1e8, speed: 0, rank: 'BOSS' }), hitArea: { w: 4.95, h: 2.95, dx: 0, dy: 1 } };
const SMALL = enemyRec({ key: 'enemy_small', hp: 1e8, speed: 0 });
const blade = (id, rangeGrid = [[0, 0], [0, 1]]) => chessRec({ id, stats: { atk: 100, blockCnt: 0, maxHp: 1e7 }, rangeGrid, skill: null });
function bossField(units, key, pos = [3, 10], extra = {}) {
const h = makeBattle({
kind: 'boss', content: 'none', autoFinish: false, timeLimit: 60, hooks: ['damaged'], captureNoisy: true,
defs: { chess: { t_edge: blade('t_edge'), t_far: blade('t_far'), t_aoe: extra.aoe ?? blade('t_aoe') }, enemies: { enemy_huge: HUGE, enemy_small: SMALL } },
players: [{ playerId: 'p1', seat: 0, side: 'L', coords: 'field', units: units.map((u, i) => ({ uid: i + 1, kind: 'chess', ...u })), bonds: {} }],
});
h.step();
const e = h.spawn(key, { pos, routeIndex: 0 });
return { h, e };
}
const hitsOn = (h, e, u) => h.hooksOf('damaged').filter((c) => c.target === e && c.source === u).length;
test('#10: an operator whose range touches only the edge of the boss area hits it (and not a regular enemy there)', () => {
// (4,7) facing RIGHT covers (4,7),(4,8) — (4,8) is the left column of the boss body; the boss's own tile (3,10) is
// far
for (const [key, expectHit] of [['enemy_huge', true], ['enemy_small', false]]) {
const { h, e } = bossField([{ chessId: 't_edge', row: 4, col: 7, dir: 'RIGHT' }], key);
const u = h.unit('t_edge');
h.run(5);
assert.equal(hitsOn(h, e, u) > 0, expectHit, `${key}: ${hitsOn(h, e, u)} hits`);
checkInvariants(h.b);
}
});
test('#10: every tile of the 5 × 3 body counts; one tile outside it does not', () => {
// a 1-tile range operator (its own tile only) on the boss's corner tiles (5,12) and (3,8) hits; on (2,9) (below it) /
// (4,13) (right of it) it does not
const one = [[0, 0]];
for (const [r, c, want] of [[5, 12, true], [3, 8, true], [5, 8, true], [4, 13, false], [2, 9, false]]) {
const h = makeBattle({
kind: 'boss', content: 'none', autoFinish: false, timeLimit: 60, hooks: ['damaged'], captureNoisy: true,
defs: { chess: { t_one: blade('t_one', one) }, enemies: { enemy_huge: HUGE } },
players: [{ playerId: 'p1', seat: 0, side: 'L', coords: 'field', units: [{ uid: 1, kind: 'chess', chessId: 't_one', row: r, col: c }], bonds: {} }],
});
h.step();
const e = h.spawn('enemy_huge', { pos: [3, 10], routeIndex: 0 });
h.run(4);
assert.equal(hitsOn(h, e, h.unit('t_one')) > 0, want, `(${r},${c})`);
}
});
test('#10: radius effects reach the body — an AoE 1 tile beyond the rectangle edge hits it, a regular enemy there is not hit', () => {
for (const [key, want] of [['enemy_huge', 1], ['enemy_small', 0]]) {
const { h, e } = bossField([], key);
const got = h.b.enemiesInRadius(13.475, 4, 1.0).filter((x) => x === e).length;
assert.equal(got, want, key);
assert.equal(h.b.enemiesInRadius(13.6, 4, 1.0).filter((x) => x === e).length, 0, `${key}: beyond reach`);
// splash around a target is a 中点判定 (PRTS 作战机制): by position, the body does not count
assert.equal(h.b.enemiesInRadius(13.475, 4, 1.0, true).filter((x) => x === e).length, 0, `${key}: centre-point radius`);
assert.equal(h.b.enemiesInRadius(11, 3, 1.0, true).filter((x) => x === e).length, 1, `${key}: its position within r`);
}
});
test('#10 (follow-up): an operator\'s splash on an escort next to the huge boss does not reach the boss (中点判定)', () => {
// escort at (2,9): 0.525 below the boss rectangle (rows 2.525–5.475), 1.41 from the boss's position (3,10). A splash
// caster targeting only the escort (range: its tile) splashes 1.1 around it — by PRTS 作战机制 "中点判定…酒神1天赋的1.3
// 溅射半径" the boss (centre 1.41 away) is not hit; a regular enemy 1.0 away from the escort is
const aoe = chessRec({ id: 't_aoe', profession: 'CASTER', subProfessionId: 'splashcaster', dmgType: 'arts', stats: { atk: 100, blockCnt: 0, maxHp: 1e7 }, rangeGrid: [[0, 3]], skill: null });
const { h, e } = bossField([{ chessId: 't_aoe', row: 2, col: 6, dir: 'RIGHT' }], 'enemy_huge', [3, 10], { aoe });
const esc = h.spawn('enemy_small', { pos: [2, 9], routeIndex: 0 });
const near = h.spawn('enemy_small', { pos: [2, 10], routeIndex: 0 });
const u = h.unit('t_aoe');
assert.ok(h.runUntil(() => hitsOn(h, esc, u) > 0, 10), 'the caster hits the escort');
h.run(3);
assert.ok(hitsOn(h, near, u) > 0, 'a regular enemy 1.0 from the escort takes the splash');
assert.equal(hitsOn(h, e, u), 0, 'the huge boss (position 1.41 away, body 0.53 away) takes none');
checkInvariants(h.b);
});
test('#10: the enemy index lists a huge enemy once per query and unitsInGrid sees its body', () => {
const { h, e } = bossField([{ chessId: 't_far', row: 4, col: 3, dir: 'RIGHT' }], 'enemy_huge');
h.step();
const all = [];
for (let r = 0; r <= 5; r++) for (let c = 0; c <= 20; c++) all.push(r * COLS + c);
const u = h.unit('t_far');
assert.deepEqual(h.b.enemiesInKeys(all, u, { canHitFly: true }).map((x) => x.id), [e.id]);
assert.deepEqual(h.b.unitsInGrid(u, [[0, 5]]).map((x) => x.id), [e.id], '(4,8) is on the body');
assert.deepEqual(h.b.unitsInGrid(u, [[0, 4]]).map((x) => x.id), [], '(4,7) is not');
});
test('#10: the real 假想敌:胄 on the real boss stage — an operator on the 1×3 fence left of the pipe block hits it', () => {
// act2autochess_m01 boss field: floor cols 9–11 rows 3–5 framed by fence tiles (b, deployable) at cols 8 and 12;
// the boss stands at (3,10). A melee operator on the fence (4,8) facing RIGHT covers (4,8),(4,9) — both on the boss
// body.
const h = makeBattle({
kind: 'boss', stageId: 'act2autochess_m01', content: 'none', autoFinish: false, timeLimit: 60, hooks: ['damaged'], captureNoisy: true,
defs: { chess: { t_edge: blade('t_edge') } },
players: [{ playerId: 'p1', seat: 0, side: 'L', coords: 'field', units: [{ uid: 1, kind: 'chess', chessId: 't_edge', row: 4, col: 8, dir: 'RIGHT' }], bonds: {} }],
});
h.step();
const boss = h.spawn('enemy_9013_acstmk', { pos: [3, 10], routeIndex: 0, tag: 'boss', mods: { speedMul: 0 } });
assert.ok(boss.hitArea, 'the boss unit carries its hit area');
const u = h.unit('t_edge');
assert.equal(u.tileR, 4);
h.run(6);
assert.ok(hitsOn(h, boss, u) > 0, 'hits the boss');
checkInvariants(h.b);
});
// ---------------------------------------------------------------------------------------------------------------
// 自缚 + 无法被阻挡 — every 巨型单位 page lists "{{异常效果|自缚}} … 不可阻挡 / 无法被阻挡" (PRTS 假想敌:胄 / 假想敌:管 /
// 盐风主教昆图斯 / 阿利斯泰尔,帝国余晖 / “萨米的意志”; 异常效果 自缚 = 无法移动, not recognised as 束缚). The hit area moves
// with the boss, so a boss that walked would drag its 5 × 3 body off the pipe block (reviewer: the co-op 昆图斯 walked its
// route (3,10) → (2,2) and leaked at ≈ 280 game s).
const HUGE_TEMPLATES = [
['act1autochess_h07_01_s', 'enemy_9013_acstmk'], ['act1autochess_h07_03_s', 'enemy_9021_acduml'],
['act1autochess_h07_04_s', 'enemy_1521_dslily'], ['act1autochess_h07_06_s', 'enemy_9032_aclionk'],
['act1autochess_h07_07_s', 'enemy_9033_acdeer'], ['act1autochess_h08_01_s', 'enemy_9013_acstmk_2'],
['act1autochess_h08_03_s', 'enemy_9021_acduml_2'],
];
const realBoss = (waveTemplate, units = [], timeLimit = 60, two = false) => makeBattle({
kind: 'boss', stageId: 'act2autochess_m01', waveTemplate, content: 'full', autoFinish: false, timeLimit, hooks: ['enemyLeak'],
players: [
{ playerId: 'p1', seat: 0, side: 'L', coords: 'field', units, bonds: {} },
...(two ? [{ playerId: 'p2', seat: 1, side: 'R', coords: 'field', units: [], bonds: {} }] : []),
],
});
test('#10: every huge boss is 自缚 and unblockable (PRTS 天赋) — in its real template, from its spawn', async () => {
const { SELF_BOUND } = await import('../../server/sim/content/bosses.js');
assert.deepEqual([...SELF_BOUND].sort(), Object.keys(E).filter((k) => E[k].hitArea).sort(), 'the 巨型单位 are exactly the 自缚 leaders');
for (const [tpl, key] of HUGE_TEMPLATES) {
const h = realBoss(tpl);
h.run(2); // 管 spawns at 1 s
const e = h.b.enemies.find((x) => x.defId === key);
assert.ok(e, `${tpl}: ${key} spawned`);
assert.ok(e.s.flags.noMove && e.s.flags.unblockable, `${key}: 自缚 + 无法被阻挡`);
assert.ok(!e.s.flags.bind, `${key}: 自缚 is not recognised as 束缚`);
}
});
test('#10: the co-op 盐风主教昆图斯 stays on its pipe block (its route would walk it to (2,2)) and never leaks', () => {
const h = realBoss('act1autochess_h07_04', [], 900, true);
h.run(1);
const bosses = h.b.enemies.filter((e) => e.defId === 'enemy_1521_dslily');
const at = bosses.map((e) => [e.x, e.y]);
h.run(400);
const q = h.b.enemies.filter((e) => e.defId === 'enemy_1521_dslily');
assert.ok(q.length >= 1 && q.every((e) => e.alive), 'still on the field');
for (const [i, e] of bosses.entries()) assert.deepEqual([e.x, e.y], at[i], `copy ${i} did not move`);
assert.equal(h.hooksOf('enemyLeak').filter((c) => c.enemy && c.enemy.defId === 'enemy_1521_dslily').length, 0, 'no leak');
checkInvariants(h.b);
});
test('#10: an operator standing on the boss (a free-capacity blocker in contact) does not block it', () => {
const op = chessRec({ id: 't_wall', stats: { atk: 10, blockCnt: 3, maxHp: 1e9, def: 5000 }, rangeGrid: [[0, 0]], skill: null });
const h = makeBattle({
kind: 'boss', stageId: 'act2autochess_m01', content: 'full', autoFinish: false, timeLimit: 60, defs: { chess: { t_wall: op } },
players: [{ playerId: 'p1', seat: 0, side: 'L', coords: 'field', units: [{ uid: 1, kind: 'chess', chessId: 't_wall', row: 4, col: 8, dir: 'RIGHT' }], bonds: {} }],
});
h.step();
const u = h.unit('t_wall');
const boss = h.spawn('enemy_1521_dslily', { pos: [u.tileR, u.tileC], routeIndex: 0, tag: 'boss' });
h.run(3);
assert.equal(boss.blockedBy, null);
assert.equal(u.blocking.length, 0);
assert.deepEqual([boss.y, boss.x], [u.tileR, u.tileC], 'and it does not walk away either');
});
test('docs: SIM.md / DATA.md describe the hit areas and the block radius (user playtest #5)', () => {
const SIM = fs.readFileSync(new URL('../../docs/SIM.md', import.meta.url), 'utf8');
const DATA_MD = fs.readFileSync(new URL('../../docs/DATA.md', import.meta.url), 'utf8');
assert.match(SIM, /body\.js/);
assert.match(SIM, /受击判定区域为长4\.95、宽2\.95的长方形,向上偏移1\.0/);
assert.match(SIM, /BLOCK_RADIUS`: ground 0\.7071/);
assert.match(SIM, /Battle\.blockedTargets/);
assert.match(SIM, /自缚 \+ 无法被阻挡[\s\S]{0,80}SELF_BOUND/);
assert.match(DATA_MD, /\| `hitArea` \|/);
});
+423
View File
@@ -0,0 +1,423 @@
// test/sim/playtest5_elements.test.js — user playtest #5 report 3 (enemy side): "海嗣敌人元素损伤效率有点低 — check every
// enemy's element damage and element gauge against the official game". Encodes the official numbers (enemy_database /
// PRTS 元素 / 游戏数据基础 / the enemies' PRTS pages / gamedata_const termDescriptionDict) for the gauge pipeline
// (server/sim/damage.js) and the enemies' element abilities (content/enemies.js):
// · 海嗣 enemies take element damage at the full rate (损伤抵抗 0, 1000 / leader 2000) and deal ATK × ratio per hit;
// · 元素损伤倍率 (elemTakenMul) scales the gauge, 元素脆弱 (elementalTakenMul) the 元素伤害 — never each other;
// · 损伤抵抗 (epResistance) reduces the gauge, 元素抗性 (epDamageResistance) the 元素伤害 (both with the 5 % floor);
// · bursts deal 无来源 damage (no damage-dealt multiplier / penetration of the unit that filled the gauge, no content hook
// sees a source — 深巡's 海怪 ×1.5, 精准狙击镜 — while the credit, the kill and damage sharing keep working);
// · 麻痹免疫 enemies get no 麻痹 from a 神经 burst;
// · 淤困 spreads 1000, 灼藤's first attack covers 3×3, 节日爵士乐手 channels on one locked target;
// · target-side 抵挡 (星熊 战术装甲, 拉普兰德 日晷) still negates a 无来源 burst — it has no source condition.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { makeBattle, chessRec, enemyRec } from '../helpers/battleHarness.js';
import { ELEMENT, ELEMENT_GAUGE_MAX, ELEMENT_GAUGE_MAX_LEADER } from '../../server/sim/constants.js';
import * as enemiesMod from '../../server/sim/content/enemies.js';
import * as bossesMod from '../../server/sim/content/bosses.js';
const E = JSON.parse(fs.readFileSync(new URL('../../data/enemies.json', import.meta.url), 'utf8'));
const approx = (a, b, eps = 1e-6, msg = '') => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg} ${a} ≈ ${b}`);
const WALL = (id, o = {}) => chessRec({ id, profession: 'TANK', stats: { atk: 0, maxHp: 1e7, def: 0, res: o.res ?? 0, blockCnt: 3 }, rangeGrid: [[0, 0]], skill: null });
const NOATK = () => ({ trait: { noAttack: true } });
const CHESS = { t_wall: WALL('t_wall'), t_wall2: WALL('t_wall2'), t_wall3: WALL('t_wall3'), t_wall4: WALL('t_wall4') };
const KITS = { t_wall: NOATK, t_wall2: NOATK, t_wall3: NOATK, t_wall4: NOATK };
/** Real enemies with their content (enemies.js / bosses.js) against inert synthetic walls. */
const arena = (o = {}) => makeBattle({
content: 'generic', extraContent: [enemiesMod, bossesMod], seed: 7, autoFinish: false, timeLimit: 600,
defs: { chess: CHESS }, kits: KITS, captureNoisy: true, hooks: ['elementBurst', 'damaged'], ...o,
});
/** Spawn a pinned enemy (speed ×0) at a tile. */
const put = (h, key, pos, mods = {}) => h.spawn(key, { pos, routeIndex: 0, mods: { speedMul: 0, ...mods } });
const elemHits = (h, target, source = null) => h.hooksOf('damaged').filter((c) => c.type === 'element' && c.target === target && (!source || c.source === source));
const dummy = (o = {}) => enemyRec({ key: 'enemy_dummy', hp: 1e6, speed: 0, ...o });
const SEA = Object.values(E).filter((e) => e.tags.includes('seamonster'));
test('海嗣 enemies take element damage at the official rate: 损伤抵抗 0, gauge 1000 (leaders 2000), 元素抗性 0 on the burst', () => {
assert.ok(SEA.length >= 10, `${SEA.length} 海嗣 enemies in data`);
for (const rec of SEA) {
assert.equal(rec.stats.elementRes, 0, `${rec.name}: enemy_database epResistance (损伤抵抗) 0`);
assert.equal(rec.stats.elementDmgRes, 0, `${rec.name}: epDamageResistance (元素抗性) 0`);
const h = arena();
h.step();
const e = put(h, rec.key, [11, 8]);
assert.equal(e.gaugeMax, rec.rank === 'BOSS' ? ELEMENT_GAUGE_MAX_LEADER : ELEMENT_GAUGE_MAX, rec.name);
h.b.dealDamage(null, e, { type: 'element', element: 'burn', amount: 250 });
approx(e.elem.burn, 250, 1e-9, `${rec.name}: no hidden reduction`);
if (rec.rank === 'BOSS' || e.bossPool) continue;
const hp0 = e.hp;
h.b.dealDamage(null, e, { type: 'element', element: 'burn', amount: e.gaugeMax });
assert.ok(e.findBuff('burnBurst'), `${rec.name} bursts`);
approx(hp0 - e.hp, Math.min(hp0, ELEMENT.burn.enemy.elemDamage), 1e-9, `${rec.name}: 7000 元素伤害`);
}
});
test('海嗣 element attackers deal ATK × the official ratio per hit (PRTS talents), with the round\'s ATK multiplier', () => {
const cases = [
['enemy_1148_dssbr', 'neural', 'epdamage.attack@ep_damage_ratio', [9, 5]], // 底海滑动者 普通攻击额外造成攻击力15%的神经损伤
['enemy_1148_dssbr_2', 'neural', 'epdamage.attack@ep_damage_ratio', [9, 5]], // 富营养的滑动者 15 %
['enemy_2021_syfish', 'erosion', 'EpDamage.attack@ep_damage_ratio', [9, 5]], // 骨海漂流体 攻击附加相当于攻击力20%的侵蚀损伤
['enemy_2025_syufo', 'erosion', 'EpDamage.attack@ep_damage_ratio', [9, 7]], // 掠海漂移体 普通攻击附加攻击力50%的侵蚀损伤 (ranged 2.6)
['enemy_1234_dsubrl', 'neural', 'EpDamage.ep_damage_ratio', [9, 6]], // 深溟巢涌者 每次输出伤害时,再造成攻击力5%的神经损伤
['enemy_1521_dslily', 'neural', 'epdamage.attack@ep_damage_ratio', null], // 盐风主教昆图斯 攻击力20%的神经损伤
];
for (const [key, el, k, pos] of cases) {
const h = arena({ units: [{ chessId: 't_wall', row: 9, col: 5 }] });
h.step();
const e = pos ? put(h, key, pos, { atkMul: 1.331 }) : h.spawn(key, { pos: [9, 7], routeIndex: 0, mods: { speedMul: 0, atkMul: 1.331 } });
approx(e.s.atk, E[key].stats.atk * 1.331, 1e-9, `${E[key].name} ATK × round multiplier`);
const w = h.unit('t_wall');
assert.ok(h.runUntil(() => elemHits(h, w, e).length > 0, 30), `${E[key].name} hits the wall`);
const first = elemHits(h, w, e)[0];
assert.equal(first.dmg.element, el);
approx(first.amount, e.s.atk * E[key].talents.bb[k], 1e-9, `${E[key].name}: ATK × ${E[key].talents.bb[k]}`);
}
// 元核孽生者 (season override: ATK 400, 1.ep_damage_ratio 0.05): once hurt, every second, radius 2.5
const h = arena({ units: [{ chessId: 't_wall', row: 9, col: 5 }] });
h.step();
const e = put(h, 'enemy_1439_dslntf', [9, 7]);
h.b.dealDamage(null, e, { amount: 1, type: 'true' });
h.run(3.05);
const w = h.unit('t_wall');
const hits = elemHits(h, w, e);
assert.equal(hits.length, 3, 'one pulse per second');
approx(hits[0].amount, 400 * 0.05, 1e-9);
});
test('the user\'s case in numbers: blocking a 底海滑动者 (险境 R5, ATK ×0.968) bursts 神经 on the 25th hit — the official rate', () => {
const h = arena({ units: [{ chessId: 't_wall', row: 9, col: 5 }] });
h.step();
const e = put(h, 'enemy_1148_dssbr', [9, 5], { atkMul: 0.968 });
const w = h.unit('t_wall');
const per = 280 * 0.968 * 0.15;
const need = Math.ceil(ELEMENT_GAUGE_MAX / per);
assert.equal(need, 25);
assert.ok(h.runUntil(() => h.hooksOf('elementBurst').some((c) => c.target === w), 120), 'the wall bursts');
assert.equal(elemHits(h, w, e).length, need, `${need} hits of ${per.toFixed(1)}`);
approx(h.b.time, need * E.enemy_1148_dssbr.stats.bat, 0.2, '≈ 25 attacks × 2 s (game s)');
});
test('元素损伤倍率 (elemTakenMul) scales the gauge only; 元素脆弱 (elementalTakenMul) the 元素伤害 only', () => {
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, enemies: [{ key: 'enemy_dummy', pos: [11, 8] }], content: 'none', autoFinish: false });
h.step();
const e = h.enemy('enemy_dummy');
h.b.applyStatus(e, 'elemFragile', { duration: 60, value: 0.2 });
approx(e.s.elementalTakenMul, 1.2);
approx(e.s.elemTakenMul, 1, 1e-9, '元素脆弱 ("受到的元素伤害提升") leaves the gauge alone');
h.b.dealDamage(null, e, { type: 'element', element: 'burn', amount: 500 });
approx(e.elem.burn, 500, 1e-9, 'gauge fill not × 元素脆弱');
const hp0 = e.hp;
h.b.dealDamage(null, e, { type: 'element', element: 'burn', amount: 500 });
approx(hp0 - e.hp, ELEMENT.burn.enemy.elemDamage * 1.2, 1e-9, '7000 元素伤害 × 元素脆弱');
// "受到的元素损伤提高至130%" (淤困) / "…降低12%" (operators' talents): the gauge, not 元素伤害
const h2 = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, enemies: [{ key: 'enemy_dummy', pos: [11, 8] }], content: 'none', autoFinish: false });
h2.step();
const e2 = h2.enemy('enemy_dummy');
h2.b.addBuff(e2, { key: 't:ep', mods: { elemTakenMul: 1.3 } });
h2.b.dealDamage(null, e2, { type: 'element', element: 'burn', amount: 500 });
approx(e2.elem.burn, 650);
const hp1 = e2.hp;
h2.b.dealDamage(null, e2, { amount: 100, type: 'elemental', element: 'burn' });
approx(hp1 - e2.hp, 100, 1e-9, '元素伤害 not × 元素损伤倍率');
});
test('损伤抵抗 (epResistance) reduces the gauge; 元素抗性 (epDamageResistance) reduces 元素伤害 — both with the 5 % floor (PRTS 游戏数据基础)', () => {
const rec = dummy({ key: 'enemy_res' });
rec.stats.elementRes = 10;
rec.stats.elementDmgRes = 30;
const hard = dummy({ key: 'enemy_hard' });
hard.stats.elementDmgRes = 100;
// "目标受到元素损伤时也可以使用该公式计算,只需要将 D 值改为目标的损伤抵抗即可": max(0.05A, A(1 − 损伤抵抗 %))
hard.stats.elementRes = 98;
const h = makeBattle({ defs: { enemies: { enemy_res: rec, enemy_hard: hard } }, enemies: [{ key: 'enemy_res', pos: [11, 8] }, { key: 'enemy_hard', pos: [10, 8] }], content: 'none', autoFinish: false });
h.step();
const e = h.enemy('enemy_res');
h.b.dealDamage(null, e, { type: 'element', element: 'burn', amount: 500 });
approx(e.elem.burn, 450, 1e-9, '× (1 − 10 %)');
const hp0 = e.hp;
h.b.dealDamage(null, e, { amount: 1000, type: 'elemental' });
approx(hp0 - e.hp, 700, 1e-9, '元素伤害 × (1 − 30 %)');
const x = h.enemy('enemy_hard');
const hp1 = x.hp;
h.b.dealDamage(null, x, { amount: 1000, type: 'elemental' });
approx(hp1 - x.hp, 50, 1e-9, 'at least 5 %');
h.b.dealDamage(null, x, { type: 'element', element: 'burn', amount: 100 });
approx(x.elem.burn, 5, 1e-9, 'the gauge takes at least 5 % too (损伤抵抗 98 → 5, not 2)');
// content that predicts the pipeline (enemies.js expectedFinal — 吸收屏障 etc.) uses the same 元素伤害 terms
h.b.addBuff(e, { key: 't:frag', mods: { elementalTakenMul: 1.2 } });
const d = h.b.makeDamage({ amount: 1000, type: 'elemental' });
const hp2 = e.hp;
const f = enemiesMod.expectedFinal(null, e, d);
approx(f, 1000 * 0.7 * 1.2, 1e-9, 'expectedFinal: 元素抗性 30 and 元素脆弱 20 %');
h.b.dealDamage(null, e, d);
approx(hp2 - e.hp, f, 1e-9, '= what the pipeline deals');
});
test('element bursts deal 无来源 damage: the filler\'s damage-dealt multipliers and penetration never apply; it keeps the credit', () => {
const op = chessRec({ id: 't_op', profession: 'CASTER', stats: { atk: 0, maxHp: 1e6, def: 0, res: 50, blockCnt: 0 }, skill: null });
const h = makeBattle({
defs: { chess: { t_op: op }, enemies: { enemy_dummy: dummy({ res: 50 }) } }, units: [{ chessId: 't_op', row: 9, col: 5 }],
enemies: [{ key: 'enemy_dummy', pos: [11, 8] }], content: 'none', autoFinish: false,
});
h.step();
const u = h.unit('t_op'), e = h.enemy('enemy_dummy');
h.b.addBuff(u, { key: 't:boost', mods: { dmgDealtMul: 2, artsDealtMul: 1.5, resIgnoreFlat: 50 } });
h.b.addBuff(e, { key: 't:boost', mods: { dmgDealtMul: 2, artsDealtMul: 1.5, resIgnoreFlat: 50 } });
const hp0 = e.hp;
h.b.dealDamage(u, e, { type: 'element', element: 'burn', amount: 1000 });
approx(hp0 - e.hp, ELEMENT.burn.enemy.elemDamage, 1e-9, '7000, not ×2');
approx(u.stats.dmg, ELEMENT.burn.enemy.elemDamage, 1e-9, 'credited to the operator that filled the gauge');
const hp1 = u.hp;
h.b.dealDamage(e, u, { type: 'element', element: 'burn', amount: 1000 });
approx(hp1 - u.hp, ELEMENT.burn.ally.damage * (1 - (50 - ELEMENT.burn.ally.resDown) / 100), 1e-9, '1200 arts through RES 50 − 20, no penetration, not ×3');
// apoptosis ticks on enemies too
h.run(10.1);
const hp2 = e.hp;
h.b.dealDamage(u, e, { type: 'element', element: 'apoptosis', amount: 1000 });
h.run(3.02);
approx(hp2 - e.hp, 3 * ELEMENT.apoptosis.enemy.elemDps, 1e-9);
});
test('无来源 reaches the content hooks too: `hit` / `damaged` get no source, `credit` and the kill keep the filler (PRTS 伤害分类)', () => {
const op = chessRec({ id: 't_op', profession: 'CASTER', stats: { atk: 0, maxHp: 1e6, def: 0, res: 0, blockCnt: 0 }, skill: null });
const h = makeBattle({
defs: { chess: { t_op: op }, enemies: { enemy_dummy: dummy(), enemy_small: dummy({ key: 'enemy_small', hp: 5000 }) } },
units: [{ chessId: 't_op', row: 9, col: 5 }], enemies: [{ key: 'enemy_dummy', pos: [11, 8] }, { key: 'enemy_small', pos: [10, 8] }],
content: 'none', autoFinish: false, hooks: ['hit', 'damaged', 'kill'], captureNoisy: true,
});
h.step();
const u = h.unit('t_op'), e = h.enemy('enemy_dummy'), small = h.enemy('enemy_small');
// a content hook keyed on the attacker ("攻击…时攻击力提升至150%" style) — it must not see the burst
h.b.on('hit', (c) => { if (c.source === u && c.target.side === 'enemy') c.dmg.mul *= 1.5; });
h.b.dealDamage(u, e, { amount: 1000, type: 'true' });
approx(1e6 - e.hp, 1500, 1e-9, 'the hook works on the operator\'s own damage');
const hp0 = e.hp;
h.b.dealDamage(u, e, { type: 'element', element: 'burn', amount: 1000 });
approx(hp0 - e.hp, ELEMENT.burn.enemy.elemDamage, 1e-9, '7000, not ×1.5');
const burstHit = h.hooksOf('hit').find((c) => c.dmg.sourceless && c.target === e);
assert.equal(burstHit.source, null, 'hit: no source');
assert.equal(burstHit.credit, u, 'hit: credit');
const burstDmg = h.hooksOf('damaged').find((c) => c.dmg && c.dmg.sourceless && c.target === e);
assert.equal(burstDmg.source, null, 'damaged: no source');
assert.equal(burstDmg.credit, u, 'damaged: credit');
const gauge = h.hooksOf('damaged').find((c) => c.type === 'element' && c.target === e);
assert.equal(gauge.source, u, 'the 元素损伤 itself keeps its source');
// "无来源伤害的击杀也能追溯击杀来源"
h.b.dealDamage(u, small, { type: 'element', element: 'burn', amount: 1000 });
assert.equal(small.alive, false);
assert.equal(h.hooksOf('kill').find((c) => c.victim === small)?.killer, u, 'kill credited to the filler');
});
test('the user\'s pairing: a real 深巡 (garrison "攻击海怪敌人时攻击力提升至150%") bursting a real 海嗣 deals 7000, not 10500', () => {
const h = makeBattle({ units: [{ chessId: 'chess_char_1_04_a', row: 10, col: 3 }], content: 'full', autoFinish: false, timeLimit: 600, seed: 3 });
h.step();
const u = h.allies()[0];
assert.deepEqual(u.def.raw.garrisonIds, ['garrison_18_a']);
const e = put(h, 'enemy_1439_dslntf', [11, 8]);
assert.ok(e.def.tags.includes('seamonster'), '元核孽生者 is a 海怪');
const hp0 = e.hp;
h.b.dealDamage(u, e, { amount: 100, type: 'true' });
approx(hp0 - e.hp, 100 * 1.5 * u.s.dmgDealtMul, 1e-9, 'its own damage vs a 海怪: ×1.5');
const hp1 = e.hp;
h.b.dealDamage(u, e, { type: 'element', element: 'burn', amount: e.gaugeMax });
assert.ok(e.findBuff('burnBurst'));
approx(hp1 - e.hp, ELEMENT.burn.enemy.elemDamage, 1e-9, 'the burst is 无来源: 7000');
});
test('精准狙击镜 (×damage_scale at ≥ 3 tiles) boosts its carrier\'s hits, never the burst it causes', () => {
const op = chessRec({ id: 't_op', profession: 'SNIPER', stats: { atk: 0, maxHp: 1e6, def: 0, res: 0, blockCnt: 0 }, skill: null });
const h = makeBattle({
defs: { chess: { t_op: op }, enemies: { enemy_dummy: dummy() } }, units: [{ chessId: 't_op', row: 10, col: 3, items: ['chess_item_3_02_e_a'] }],
enemies: [{ key: 'enemy_dummy', pos: [10, 8] }], content: 'full', autoFinish: false, timeLimit: 600,
});
h.step();
const u = h.unit('t_op'), e = h.enemy('enemy_dummy');
h.b.dealDamage(u, e, { amount: 1000, type: 'true' });
const boosted = (1e6 - e.hp) / u.s.dmgDealtMul;
assert.ok(boosted > 1000 + 1e-6, `the scope works at range (${boosted})`);
const hp0 = e.hp;
h.b.dealDamage(u, e, { type: 'element', element: 'erosion', amount: 1000 });
approx(hp0 - e.hp, ELEMENT.erosion.enemy.elemDamage, 1e-9, '5000 侵蚀 burst, not × the scope');
});
test('a 无来源 burst still passes on through content that shares damage: 圣杯 (credited, no source multiplier) and 盲信之誓', () => {
const h = arena({ units: [{ chessId: 't_wall', row: 12, col: 3 }] });
h.step();
const w = h.unit('t_wall');
h.b.addBuff(w, { key: 't:boost', mods: { dmgDealtMul: 2 } });
const c = put(h, 'enemy_1430_lrrook', [10, 7]);
const o = put(h, 'enemy_1439_dslntf', [10, 6]);
const share = E.enemy_1430_lrrook.talents.bb['takeDmg.damage_scale'];
const c0 = c.hp, o0 = o.hp;
h.b.dealDamage(w, o, { type: 'element', element: 'burn', amount: o.gaugeMax });
const burst = ELEMENT.burn.enemy.elemDamage;
approx(c0 - c.hp, Math.min(c0, burst * share), 1e-9, 'the chalice takes its share of 7000 (not ×2)');
approx(o0 - o.hp, Math.min(o0, burst * (1 - share)), 1e-9);
approx(w.stats.dmg, (c0 - c.hp) + (o0 - o.hp), 1e-9, 'both parts credited to the filler');
// 盲信之誓: a spring passes PART_TRANSFER of what it takes to 假想敌:铳 — a burst too
const hb = arena({ kind: 'boss', sharedBoss: { hp: 1e6, maxHp: 1e6, damage(pid, a) { this.hp = Math.max(0, this.hp - a); } }, units: [{ chessId: 't_wall', row: 12, col: 3 }] });
hb.step();
const g = hb.spawn('enemy_9017_achunt_2', { pos: [3, 18], routeIndex: 0, mods: { speedMul: 0 }, tag: 'boss' });
g.profile.noAttack = true;
assert.ok(g.bossPool, '铳 on the shared pool');
const sp = hb.spawn('enemy_9020_actrpc', { pos: [3, 4], routeIndex: 0, mods: { speedMul: 0 }, tag: 'part' });
const pool0 = hb.b.sharedBoss.hp, sp0 = sp.hp;
hb.b.dealDamage(hb.unit('t_wall'), sp, { type: 'element', element: 'burn', amount: sp.gaugeMax });
assert.ok(sp0 - sp.hp > 0, 'the spring takes the burst');
approx(pool0 - hb.b.sharedBoss.hp, (sp0 - sp.hp) * bossesMod.PART_TRANSFER, 1e-9, '…and passes its share to 铳');
});
test('凋亡 burst on an enemy: 800 元素伤害 per second, the 50 % 虚弱 drops to 50 % × 剩余时间 ÷ 15 s at each tick (PRTS 元素)', () => {
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy({ atk: 1000 }) } }, enemies: [{ key: 'enemy_dummy', pos: [11, 8] }], content: 'none', autoFinish: false });
h.step();
const e = h.enemy('enemy_dummy');
h.b.dealDamage(null, e, { type: 'element', element: 'apoptosis', amount: 1000 });
approx(e.s.atk, 500, 1e-9, '50 % at once');
for (const t of [1, 5, 14]) {
h.run(t - (h.b.time - 1 / 30) + 0.02);
const left = ELEMENT.apoptosis.enemy.duration - t;
assert.ok(Math.abs(e.s.atk - 1000 * (1 - 0.5 * left / ELEMENT.apoptosis.enemy.duration)) < 3, `${t} s: ATK ${e.s.atk.toFixed(1)}`);
}
approx(1e6 - e.hp, 14 * ELEMENT.apoptosis.enemy.elemDps, 1e-9);
});
test('麻痹免疫 enemies get no 麻痹 from a 神经 burst (PRTS 元素 "若单位不具有麻痹免疫") — still the 6000 元素伤害', () => {
const imm = dummy({ key: 'enemy_imm', otherImmunities: ['palsy'] });
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy(), enemy_imm: imm } }, enemies: [{ key: 'enemy_dummy', pos: [11, 8] }, { key: 'enemy_imm', pos: [10, 8] }], content: 'none', autoFinish: false });
h.step();
const a = h.enemy('enemy_dummy'), b = h.enemy('enemy_imm');
for (const e of [a, b]) h.b.dealDamage(null, e, { type: 'element', element: 'neural', amount: 1000 });
assert.equal(a.findBuff('palsy')?.stacks, ELEMENT.neural.enemy.palsy);
assert.equal(b.findBuff('palsy'), null, 'immune');
approx(1e6 - b.hp, ELEMENT.neural.enemy.elemDamage);
// a real one: 假想敌:铳 (palsyImmune)
assert.ok(E.enemy_9017_achunt.stats.otherImmunities.includes('palsy'));
});
test('假想敌:淤困: its host\'s burst deals 1000 of the same element to the allies of the 4 tiles around (PRTS)', () => {
const h = arena({ units: [{ chessId: 't_wall', row: 10, col: 5 }, { chessId: 't_wall2', row: 11, col: 5 }, { chessId: 't_wall3', row: 10, col: 6 }, { chessId: 't_wall4', row: 11, col: 6 }] });
h.step();
put(h, 'enemy_9007_acelem', [10, 5]);
h.run(0.5);
const host = h.unit('t_wall');
assert.ok(host.findBuff('ab:parasite'), 'parasitised');
h.b.dealDamage(null, host, { type: 'element', element: 'neural', amount: 1000 });
const spread = (u) => elemHits(h, u).filter((c) => c.dmg.element === 'neural');
for (const id of ['t_wall2', 't_wall3']) {
const s = spread(h.unit(id));
assert.equal(s.length, 1, `${id} (next to the host) takes one spread`);
approx(s[0].amount, 1000, 1e-9, `${id}: 1000`);
assert.ok(h.unit(id).findBuff('neuralBurst'), `${id} bursts`);
}
assert.equal(spread(h.unit('t_wall4')).length, 0, 'the diagonal tile is not one of the 4');
});
test('灼藤: its first attack burns and hits every other ally of the target\'s 3×3 (x-4), diagonals included', () => {
const h = arena({ units: [{ chessId: 't_wall', row: 10, col: 5 }, { chessId: 't_wall2', row: 11, col: 6 }, { chessId: 't_wall3', row: 9, col: 5 }, { chessId: 't_wall4', row: 12, col: 5 }] });
h.step();
const v = put(h, 'enemy_10067_ftsjc', [10, 5]);
assert.ok(h.runUntil(() => v.stats.attacks >= 1, 10), 'first attack');
const atk = elemHits(h, h.unit('t_wall'), v)[0].amount / E.enemy_10067_ftsjc.talents.bb['pow.attack@ep_damage_ratio'];
for (const id of ['t_wall2', 't_wall3']) {
const u = h.unit(id);
approx(elemHits(h, u, v)[0]?.amount ?? 0, atk * 0.25, 1e-6, `${id}: 25 % burn`);
assert.ok(u.stats.taken > 0, `${id}: 100 % arts splash`);
}
assert.equal(elemHits(h, h.unit('t_wall4'), v).length, 0, 'two rows away: outside');
});
test('节日爵士乐手 狂欢式演奏: channels on ONE locked target (20 % arts + 10 % burn every 0.5 s), not on everyone around', () => {
const h = arena({ units: [{ chessId: 't_wall', row: 10, col: 5 }, { chessId: 't_wall2', row: 10, col: 6 }, { chessId: 't_wall3', row: 11, col: 5 }] });
h.step();
const e = put(h, 'enemy_10034_cnvsax', [10, 5]);
h.run(6);
const w = h.unit('t_wall');
const hits = elemHits(h, w, e);
assert.ok(hits.length >= 4, `the locked target burns (${hits.length})`);
approx(hits[0].amount, e.s.atk * 0.1, 1e-9);
for (const id of ['t_wall2', 't_wall3']) assert.equal(elemHits(h, h.unit(id), e).length, 0, `${id} is not the target`);
});
test('节日爵士乐手: channels never overlap (cooldown 10 s < channel 10.6 s) — 21 ticks each, no normal attack while channelling', () => {
const h = arena({ units: [{ chessId: 't_wall', row: 10, col: 5 }] });
h.step();
const e = put(h, 'enemy_10034_cnvsax', [10, 5]);
const w = h.unit('t_wall');
const sk = E.enemy_10034_cnvsax.skills.find((x) => x.prefabKey === 'fire');
const iv = sk.bb.hit_interval, n = Math.floor(sk.bb['enemy_cnvsax[cd].duration'] / iv + 1e-9);
assert.equal(n, 21, 'floor(10.6 / 0.5)');
const arts = [], during = [];
h.b.on('damaged', (c) => {
if (c.target !== w || c.source !== e) return;
if (c.type === 'arts') arts.push(h.b.time);
else if (c.type === 'phys' && e.findBuff('ab:channel')) during.push(h.b.time);
});
const skill = e.mem.ab.list.find((a) => a && a.fire && a.cd != null);
const casts = [];
for (let i = 0; i < 30 * 30; i++) {
h.step();
if ((skill.casts ?? 0) > casts.length) casts.push(h.b.time);
}
assert.ok(casts.length >= 2, `cast twice in 30 s (${casts.map((t) => t.toFixed(2))})`);
for (let i = 1; i < arts.length; i++) assert.ok(arts[i] - arts[i - 1] >= iv - 0.02, `no doubled tick at ${arts[i].toFixed(2)}`);
for (let i = 0; i + 1 < casts.length; i++) {
const k = arts.filter((t) => t > casts[i] && t <= casts[i + 1]).length;
assert.equal(k, n, `cast ${i + 1}: ${n} ticks`);
assert.ok(casts[i + 1] - casts[i] >= n * iv - 1e-6, 'the next cast waits for the channel');
}
assert.deepEqual(during, [], 'no normal attack while a channel runs');
});
test('抵挡 is target-side: 星熊 (战术装甲) and 拉普兰德 S1 日晷 still negate a 无来源 burst on themselves (source null, credit = the enemy)', async () => {
const { getDefaultSource } = await import('../../server/sim/simdata.js');
const ds = getDefaultSource();
const altIdx = (id) => ds.rawChess(id).skills.find((s) => !s.isDefault).index;
const cases = [
['chess_char_4_17_a', {}, ['erosion', 'burn']], // 星熊: phys (侵蚀 800) and arts (灼燃 1200)
['chess_char_2_16_a', { skillIndex: altIdx('chess_char_2_16_a'), carryState: { sp: 999 } }, ['erosion']], // 日晷: phys only
];
for (const [id, extra, elements] of cases) {
for (const roll of [true, false]) {
for (const element of elements) {
const h = makeBattle({
units: [{ chessId: id, row: 9, col: 5, ...extra }], defs: { enemies: { enemy_dummy: dummy() } },
enemies: [{ key: 'enemy_dummy', pos: [9, 5] }], content: 'full', autoFinish: false, timeLimit: 600, seed: 3, // blocked: in range
setup: (b) => { b.rng.chance = () => roll; },
});
h.step();
const u = h.unit(id), e = h.enemy('enemy_dummy');
if (extra.skillIndex != null) assert.ok(h.runUntil(() => u.skill.active, 3), `${id}: 日晷 on`);
const hp0 = u.hp;
h.b.dealDamage(e, u, { type: 'element', element, amount: u.gaugeMax });
assert.ok(u.findBuff(`${element}Burst`), `${id} ${element}: bursts`);
if (roll) assert.equal(u.hp, hp0, `${id} ${element}: the burst is 抵挡-ed`);
else assert.ok(u.hp < hp0, `${id} ${element}: a failed roll lands`);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
}
}
}
});
test('卢西恩 / 不祥幻影 in their only stage (boss_5 = act2autochess_h07_05 / _s): the level\'s overrides — ATK 700 (solo 600) / 200, 12 % 神经 per hit', () => {
const W = JSON.parse(fs.readFileSync(new URL('../../data/waves.json', import.meta.url), 'utf8'));
// level_act2autochess_h07_05(_s).json enemyDbRefs[5] / [7] overwrittenData: atk 700 (600) / 200,
// combat.attack@ep_damage_ratio 0.12
for (const [tpl, key, atk] of [['act2autochess_h07_05', 'enemy_2016_csphtm', 700], ['act2autochess_h07_05_s', 'enemy_2016_csphtm', 600], ['act2autochess_h07_05', 'enemy_2017_csphts', 200]]) {
const h = arena({
kind: 'boss', sharedBoss: { hp: 1e7, maxHp: 1e7, damage(pid, a) { this.hp = Math.max(0, this.hp - a); } },
units: [{ chessId: 't_wall', row: 10, col: 5 }], setup(b) { b.enemyOverrides = W[tpl].overrides; },
});
h.step();
const w = h.unit('t_wall');
// blocked on the wall's tile: its first normal attack lands before the 5 s initial cooldown of its aoe skill (0.2)
const e = h.spawn(key, { pos: [w.y, w.x].map(Math.round), routeIndex: 0, mods: { speedMul: 0 }, tag: key === 'enemy_2016_csphtm' ? 'boss' : undefined });
assert.equal(e.s.atk, atk, `${tpl} ${E[key].name}: ATK`);
assert.ok(h.runUntil(() => elemHits(h, w, e).length > 0, 4.5), `${E[key].name} hits`);
const first = elemHits(h, w, e)[0];
assert.equal(first.dmg.element, 'neural');
approx(first.amount, atk * 0.12, 1e-9, `${tpl} ${E[key].name}: ATK × 0.12 (base data: ${E[key].stats.atk} × ${E[key].talents.bb['combat.attack@ep_damage_ratio']})`);
}
});
+7 -3
View File
@@ -392,6 +392,10 @@ describe('user playtest #2 — UI fixes (mock harness, headless Chrome)', { skip
};
});
// The field's 3D area ends at its separator row 13 — the row-13 devices blow into the field (act2 m01's blowers, user
// playtest #5 item 6: render/app.js boardArea) — and the enemy preview pen starts at row 14.
const NO_PEN_R1 = 13;
test('6: the enemy pen shows only in the pen view — never with the prep board (after 返回战场) or any battle', { skip: skipUnless('pen'), timeout: 5 * 60 * 1000 }, async () => {
const { page, problems } = await open('phase=PREP', { w: 1920, h: 1080 });
let s = await penState(page);
@@ -399,7 +403,7 @@ describe('user playtest #2 — UI fixes (mock harness, headless Chrome)', { skip
assert.ok(s.pen > 0, 'the pen holds the next round\'s enemies');
assert.equal(s.penVisible, 0, 'prep board: no pen figure shown');
assert.equal(s.penRowsDrawn, false, 'prep board: pen rows not drawn');
if (s.board3d) assert.ok(s.area3d <= 12, `prep board: 3D area without the pen (${s.area3d})`);
if (s.board3d) assert.ok(s.area3d <= NO_PEN_R1, `prep board: 3D area without the pen (${s.area3d})`);
await page.screenshot({ path: path.join(OUT, 'fix-pen-prep.png') });
await page.click('.enemybtn');
await page.waitForFunction(() => document.querySelector('.gm')?.dataset.camera === 'pen', { timeout: 3000 });
@@ -416,7 +420,7 @@ describe('user playtest #2 — UI fixes (mock harness, headless Chrome)', { skip
s = await penState(page);
assert.equal(s.penVisible, 0, 'back on the board (返回战场): no pen figure');
assert.equal(s.penRowsDrawn, false, 'back on the board: pen rows not drawn');
if (s.board3d) assert.ok(s.area3d <= 12, 'back on the board: no pen area');
if (s.board3d) assert.ok(s.area3d <= NO_PEN_R1, `back on the board: no pen area (${s.area3d})`);
await page.screenshot({ path: path.join(OUT, 'fix-pen-back.png') });
assert.deepEqual(problems, []);
await page.close();
@@ -428,7 +432,7 @@ describe('user playtest #2 — UI fixes (mock harness, headless Chrome)', { skip
const b = await penState(o.page);
assert.equal(b.penVisible, 0, `${phase}: no pen figure`);
assert.equal(b.penRowsDrawn, false, `${phase}: pen rows not drawn`);
if (b.board3d) assert.ok(b.area3d <= 12, `${phase}: no pen area (${b.area3d})`);
if (b.board3d) assert.ok(b.area3d <= NO_PEN_R1, `${phase}: no pen area (${b.area3d})`);
await o.page.screenshot({ path: path.join(OUT, `fix-pen-${phase.toLowerCase()}.png`) });
assert.deepEqual(o.problems, [], phase);
await o.page.close();
+5 -1
View File
@@ -201,12 +201,16 @@ async function untilPrep(c, pred, what, timeout = 90000) {
}
}
// The field's 3D area ends at its separator row 13 — the row-13 devices blow into the field (act2 m01's blowers, user
// playtest #5 item 6: render/app.js boardArea) — and the enemy preview pen starts at row 14.
const NO_PEN_R1 = 13;
function assertNoPen(v, tag) {
assert.deepEqual(v.orphans, [], `${tag}: no stale unit sprite of another field`);
assert.equal(v.penVisible, 0, `${tag}: no pen figure`);
assert.equal(v.penRowsDrawn, false, `${tag}: no pen rows drawn (${v.band})`);
assert.ok(v.band && v.band[1] <= 13, `${tag}: drawn rows ${v.band}`);
if (v.area3d != null) assert.ok(v.area3d <= 12, `${tag}: 3D area without the pen block (${v.area3d})`);
if (v.area3d != null) assert.ok(v.area3d <= NO_PEN_R1, `${tag}: 3D area without the pen block (${v.area3d})`);
}
describe('user playtest #2 item 6 — 前往查看 → 返回战场 in combat (real server)', { skip: !ENABLED && 'set SP_E2E=1 (Chrome + public/assets)' }, () => {
+4 -3
View File
@@ -26,7 +26,8 @@ describe('6: the enemy pen only with the pen camera', () => {
test('prep / battle / 联防 cameras draw no pen rows and build no pen area; the pen camera does', () => {
for (const k of ['prep', 'normal', 'unite']) {
assert.ok(bandFor(k)[1] <= 13, `${k}: 2D rows ${bandFor(k)}`);
assert.ok(maxRow(boardArea(k)) <= 12, `${k}: 3D area rows ≤ 12`);
// the field and its separator rows (row 13 carries devices blowing into the field, user playtest #5 item 6)
assert.ok(maxRow(boardArea(k)) <= 13, `${k}: 3D area rows ≤ 13 (no pen rows 14–18)`);
assert.equal(penShown(k), false, `${k}: pen figures hidden`);
}
for (const k of ['boss', 'hidden', 'bossPrep']) {
@@ -44,8 +45,8 @@ describe('6: the enemy pen only with the pen camera', () => {
});
test('the field areas are the official ones minus the pen block', () => {
assert.deepEqual(boardArea('normal'), AREAS.normal.filter((a) => a.r1 <= 12));
assert.deepEqual(boardArea('unite'), AREAS.unite.filter((a) => a.r1 <= 12));
assert.deepEqual(boardArea('normal'), AREAS.normal.filter((a) => a.r1 <= 13));
assert.deepEqual(boardArea('unite'), AREAS.unite.filter((a) => a.r1 <= 13));
assert.ok(boardArea('normal').some((a) => a.r0 <= 6 && a.r1 >= 12 && a.c0 === 0 && a.c1 >= 10), 'own field + bench');
});
+202
View File
@@ -0,0 +1,202 @@
// Browser regressions of user playtest #5, UI items 8 / 9, through the mock harness (public/dev/game-mock.html) in
// headless Chrome (puppeteer-core + system Chrome). Opt-in: SP_E2E=1.
//
// SP_E2E=1 node --test test/ui/playtest5-ui.e2e.test.js
//
// 9 during prep no bench (row 7) / temp (row 8) tile is covered by DOM overlays (the shop bar, the corner buttons, the
// tools) and the field's back row is free of the bond strip — own board and Final Assault half, at the phone
// landscape viewports (844×390 and 932×430 with and without the iPhone insets, 915×412, 800×360, 914×411,
// 960×411, 924×424, and the user's Android: 756×366 — its 2772×1272 px screenshot shows the page right of a
// 141 px black cutout band, 2631×1272 px at DPR ≈ 3.48, the exit and 准备就绪 buttons matching to ±1 px — and
// 798×366 with a 41 px left inset had the page covered the cutout) and at desktop 16:9 / 16:10 / 3:2 / 21:9, where
// the official shop camera is unchanged (122 px per bench tile at 1920×1080). Each tile top's projected quad is
// sampled on a 24 × 24 grid with elementFromPoint (every HUD element hit-testable); a sample counts as covered
// when the element there paints (background, border, text, image) — v2.3 left 71 % of bench pads 4–9 free at
// 844×390, 49 % at 800×360 and 54 % at 756×366 (the user's screenshot: pads 3–9 about half covered).
// 9 an armed shop card (first tap of the two-tap buy) at 756×366 rises ≤ 5 px above the bar and covers ≤ 4 px of
// the bench pads' near corners (accepted, ui/fieldHost.js hudBands)
// 8 the corner's ⚙ is the regular gear glyph (ui/gameComponents.js gearPath), square, as large as 📖 / ⛶ (half the
// grown button on phones)
// Screenshots: test/e2e/out/p5-*.png.
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const OUT = path.join(ROOT, 'test/e2e/out');
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const ENABLED = process.env.SP_E2E === '1' && existsSync(CHROME);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const IPH = (s) => ({ l: s, r: s, t: 0, b: 21 }); // iPhone landscape safe-area insets (px)
/** name, width, height, touch, insets */
const VIEWS = [
['844x390', 844, 390, true, null], ['844x390-notch', 844, 390, true, IPH(47)], ['932x430', 932, 430, true, null],
['932x430-notch', 932, 430, true, IPH(59)], ['915x412', 915, 412, true, null], ['800x360', 800, 360, true, null],
['914x411', 914, 411, true, null], ['960x411', 960, 411, true, null], ['924x424', 924, 424, true, null],
['756x366', 756, 366, true, null], ['798x366-inset41', 798, 366, true, { l: 41, r: 0, t: 0, b: 0 }],
['1920x1080', 1920, 1080, false, null], ['1680x1050', 1680, 1050, false, null], ['1560x1040', 1560, 1040, false, null],
['2560x1080', 2560, 1080, false, null],
];
const view = (name) => VIEWS.find((v) => v[0] === name);
describe('user playtest #5 — UI items 8 / 9 (mock harness, headless Chrome)', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome) to run' }, () => {
let srv;
let browser;
let base;
before(async () => {
const { startServer } = await import('../../server/index.js');
const puppeteer = (await import('puppeteer-core')).default;
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
base = `http://127.0.0.1:${srv.port}`;
browser = await puppeteer.launch({ executablePath: CHROME, headless: 'new', args: ['--no-sandbox'] });
mkdirSync(OUT, { recursive: true });
});
after(async () => {
await browser?.close();
await srv?.close();
});
async function open(query, { w, h, touch, insets }) {
const page = await browser.newPage();
await page.setViewport({ width: w, height: h, deviceScaleFactor: 1, isMobile: touch, hasTouch: touch });
const problems = [];
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
await page.goto(`${base}/dev/game-mock.html?shot=1&${query}`, { waitUntil: 'networkidle0' });
await page.waitForFunction(() => !!document.querySelector('.screen:not(.gload)') && !!globalThis.__SP_VIEW__, { timeout: 20000 });
if (insets) {
await page.evaluate((i) => {
const s = document.documentElement.style;
s.setProperty('--sa-l', `${i.l}px`); s.setProperty('--sa-r', `${i.r}px`); s.setProperty('--sa-t', `${i.t}px`); s.setProperty('--sa-b', `${i.b}px`);
}, insets);
await page.evaluate(() => globalThis.__SP_VIEW__.raw.resize());
}
await sleep(1800);
return { page, problems };
}
/** Free fraction of each tile top of the listed board tiles (board coordinates; the boss prep maps them itself). */
const freeFractions = (page, tiles) => page.evaluate((tiles) => {
const view = globalThis.__SP_VIEW__.raw;
const st = document.createElement('style');
st.textContent = '.gm__hud, .gm__hud * { pointer-events: auto !important; }';
document.head.appendChild(st);
const clear = (c) => !c || c === 'transparent' || /rgba\([^)]*,\s*0\)$/.test(c);
const paints = (el) => {
if (!el || el.tagName === 'CANVAS' || el.closest('.gm__field')) return false;
const cs = getComputedStyle(el);
if (cs.visibility === 'hidden' || Number(cs.opacity) === 0) return false;
if (['IMG', 'svg', 'path', 'B', 'SPAN', 'KBD', 'I', 'P', 'LABEL'].includes(el.tagName)) return true;
if (!clear(cs.backgroundColor) || cs.backgroundImage !== 'none') return true;
if (['Top', 'Right', 'Bottom', 'Left'].some((s) => parseFloat(cs[`border${s}Width`]) > 0 && !clear(cs[`border${s}Color`]))) return true;
return [...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim());
};
const inside = (poly, x, y) => {
let c = false;
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
const [xi, yi] = poly[i], [xj, yj] = poly[j];
if ((yi > y) !== (yj > y) && x < (xj - xi) * (y - yi) / (yj - yi) + xi) c = !c;
}
return c;
};
const out = tiles.map(([r, c]) => {
const t = view.tileScreen(r, c);
if (!t) return { r, c, free: -1 };
const xs = t.poly.map((p) => p[0]), ys = t.poly.map((p) => p[1]);
const x0 = Math.min(...xs), x1 = Math.max(...xs), y0 = Math.min(...ys), y1 = Math.max(...ys);
let n = 0, free = 0;
const N = 24;
for (let i = 0; i < N; i++) for (let j = 0; j < N; j++) {
const x = x0 + (x1 - x0) * (i + 0.5) / N, y = y0 + (y1 - y0) * (j + 0.5) / N;
if (!inside(t.poly, x, y)) continue;
n++;
if (x >= 0 && y >= 0 && x < innerWidth && y < innerHeight && !paints(document.elementFromPoint(x, y))) free++;
}
return { r, c, free: n ? free / n : 0, s: t.s };
});
st.remove();
return out;
}, tiles);
const BENCH = Array.from({ length: 10 }, (_, c) => [7, c]);
const TEMP = [4, 5, 6, 7, 8].map((c) => [8, c]);
const BACK = [4, 5, 6, 7, 8, 9, 10].map((c) => [12, c]); // the playable back row of 战场#01 (cols 0–3: forbidden)
test('9: bench, temp and the back row are free of the HUD in prep and in the Final Assault prep', { timeout: 10 * 60 * 1000 }, async () => {
const report = [];
for (const [name, w, h, touch, insets] of VIEWS) {
for (const variant of ['', 'boss']) {
const { page, problems } = await open(`phase=PREP${variant ? `&variant=${variant}` : ''}`, { w, h, touch, insets });
const tag = `${name}${variant ? ' (Final Assault prep)' : ''}`;
const bench = await freeFractions(page, BENCH);
const temp = await freeFractions(page, TEMP);
const back = await freeFractions(page, BACK);
for (const t of [...bench, ...temp]) assert.ok(t.free >= 0.99, `${tag}: ${t.r === 7 ? 'bench' : 'temp'} ${t.c} ${Math.round(t.free * 100)} % free`);
for (const t of back) assert.ok(t.free >= 0.99, `${tag}: back row ${t.c} ${Math.round(t.free * 100)} % free`);
// desktop 16:9 keeps the official shop camera
if (name === '1920x1080' && !variant) assert.ok(Math.abs(bench[5].s - 122) < 1, `1920×1080 px per bench tile ${bench[5].s}`);
report.push(`${tag}: bench ${Math.round(Math.min(...bench.map((t) => t.free)) * 100)} %, ${Math.round(bench[5].s)} px/tile`);
if (['844x390', '756x366', '800x360', '1920x1080'].includes(name)) await page.screenshot({ path: path.join(OUT, `p5-bench-${name}${variant ? '-boss' : ''}.png`) });
assert.deepEqual(problems, [], tag);
await page.close();
}
}
console.log(report.join('\n'));
});
test('9: an armed shop card (two-tap buy) barely reaches the bench on the user\'s phone', async () => {
// accepted overlap (ui/fieldHost.js hudBands): the armed card rises 4 px above the bar at 1rem = 40 px and covers
// the pads' near corners by ≈ 3 px — less than under the official camera at 1920×1080 (13 px / ≈ 11 px)
const [, w, h, touch] = view('756x366');
const { page, problems } = await open('phase=PREP', { w, h, touch });
const cards = await page.$$('.scard:not(.scard--sold)');
assert.ok(cards.length > 0, 'a shop card to arm');
await cards[0].tap();
await sleep(400);
const r = await page.evaluate(() => {
const a = document.querySelector('.scard.is-armed');
if (!a) return null;
const b = a.getBoundingClientRect(), row = document.querySelector('.shopbar__row').getBoundingClientRect();
const view = globalThis.__SP_VIEW__.raw;
let over = 0;
for (let c = 0; c < 10; c++) {
const t = view.tileScreen(7, c);
const xs = t.poly.map((p) => p[0]), y1 = Math.max(...t.poly.map((p) => p[1]));
if (Math.max(...xs) > b.left && Math.min(...xs) < b.right) over = Math.max(over, y1 - b.top);
}
return { lift: row.top - b.top, over };
});
assert.ok(r, 'the first tap arms the card');
assert.ok(r.lift <= 5, `armed card ${r.lift} px above the bar`);
assert.ok(r.over <= 4, `armed card over the bench pads by ${r.over} px`);
assert.deepEqual(problems, []);
await page.close();
});
test('8: the corner gear is the regular glyph, square and as large as its neighbours', async () => {
const { GLYPHS } = await import('../../public/js/ui/gameComponents.js');
for (const [name, w, h, touch] of [view('844x390'), view('756x366'), view('1920x1080')]) {
const { page, problems } = await open('phase=PREP', { w, h, touch });
const r = await page.evaluate(() => [...document.querySelectorAll('.gm__corner .gm__gear')].map((b) => {
const svg = b.querySelector('svg.icon');
const s = svg.getBoundingClientRect(), br = b.getBoundingClientRect();
return { cls: b.className, w: s.width, h: s.height, bw: br.width, d: svg.querySelector('path')?.getAttribute('d') };
}));
assert.equal(r.length, 3, `${name}: ⚙ 📖 ⛶`);
assert.equal(r[0].d, GLYPHS.gear, `${name}: the gear glyph`);
for (const b of r) {
assert.ok(Math.abs(b.w - b.h) < 0.01, `${name}: ${b.cls} square (${b.w}×${b.h})`);
assert.ok(b.w >= 0.45 * b.bw && b.w <= 0.55 * b.bw, `${name}: ${b.cls} icon ${b.w} in a ${b.bw} button`);
}
await page.screenshot({ path: path.join(OUT, `p5-corner-${name}.png`), clip: { x: 0, y: h - 90, width: 420, height: 90 } });
assert.deepEqual(problems, [], name);
await page.close();
}
});
});
+185
View File
@@ -0,0 +1,185 @@
// Regression tests (Node) of user playtest #5, UI items (workstream W5):
// 8 the settings button's gear (bottom-left corner, next to 交流 / 📖 / ⛶) looked deformed: the hand-written path had
// teeth of different sizes on a rim that was not round. It is now a regular 8-tooth gear (ui/gameComponents.js
// gearPath): every vertex on the tip / root circle, 8-fold rotational and mirror symmetric, a centred round hole.
// On phones the corner glyphs keep half the grown button (css/devices.css).
// 9 on phones in landscape the shop bar covered the lower part of the bench (整备区, row 7): the prep camera keeps
// the bench / temp rows and the field's back row between the HUD bands (ui/fieldHost.js hudBands →
// render/app.js → render/projection.js clearHud; the camera math itself: test/render/projection.test.js). The
// bands mirror the CSS; here: the CSS rules they depend on, the wiring, and every bench / temp / back-row tile at
// the phone viewports through the real hudBands. Browser measurement: test/ui/playtest5-ui.e2e.test.js.
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const read = (p) => readFileSync(path.join(ROOT, p), 'utf8');
const { GLYPHS, gearPath, GIcon } = await import('../../public/js/ui/gameComponents.js');
const { hudBands, HUD_REM } = await import('../../public/js/ui/fieldHost.js');
const { presetCamera, tileQuad } = await import('../../public/js/render/projection.js');
// ---- 8: the gear ----------------------------------------------------------------------------------------------------
/** Outer-contour vertices (endpoints of M / L / A commands before the first Z) and the arcs' radii of a gear path. */
function contour(d) {
const outer = d.slice(0, d.indexOf('Z'));
const pts = [];
const radii = [];
for (const m of outer.matchAll(/([MLA])([^MLAZ]+)/g)) {
const n = m[2].trim().split(/[\s,]+/).map(Number);
if (m[1] === 'A') { radii.push(n[0], n[1]); pts.push([n[5], n[6]]); } else pts.push([n[0], n[1]]);
}
return { pts, radii };
}
/** A regular gear: vertices on two circles about (12, 12), invariant under a 1/teeth turn and the mirror x → 24 − x. */
function regularGear(d, teeth = 8, eps = 0.03) {
// absolute commands only (the old hand-written path used relative ones — not a generated gear at all)
if (/[a-z]/.test(d.replace(/e-?\d/g, ''))) return false;
const { pts } = contour(d);
const r = pts.map(([x, y]) => Math.hypot(x - 12, y - 12));
const rs = [...new Set(r.map((v) => v.toFixed(1)))];
if (rs.length !== 2) return false;
const has = ([x, y]) => pts.some(([u, v]) => Math.abs(u - x) < eps && Math.abs(v - y) < eps);
const a = (2 * Math.PI) / teeth;
for (const [x, y] of pts) {
const dx = x - 12, dy = y - 12;
if (!has([12 + dx * Math.cos(a) - dy * Math.sin(a), 12 + dx * Math.sin(a) + dy * Math.cos(a)])) return false;
if (!has([24 - x, y])) return false;
}
return true;
}
describe('8: the settings gear is a clean, regular icon', () => {
const OLD = 'M10.3 2h3.4l.5 2.6c.6.2 1.2.5 1.7.9l2.5-.9 1.7 2.9-2 1.8c.1.6.1 1.2 0 1.8l2 1.8-1.7 2.9-2.5-.9c-.5.4-1.1.7-1.7.9l-.5 2.6h-3.4l-.5-2.6c-.6-.2-1.2-.5-1.7-.9l-2.5.9L2 14.9l2-1.8a6 6 0 0 1 0-1.8L2 9.5l1.7-2.9 2.5.9c.5-.4 1.1-.7 1.7-.9zM12 8.8a3.2 3.2 0 1 0 0 6.4 3.2 3.2 0 0 0 0-6.4z';
test('reproduction: the v2.3 path was not a regular gear', () => {
assert.equal(regularGear(OLD), false);
});
test('the gear glyph: 8 teeth, every vertex on the tip (r 10) or root (r 7.4) circle, rotational + mirror symmetric', () => {
assert.equal(GLYPHS.gear, gearPath());
assert.ok(regularGear(GLYPHS.gear), GLYPHS.gear);
const { pts, radii } = contour(GLYPHS.gear);
assert.equal(pts.length, 1 + 8 * 4, 'start + 4 vertices per tooth');
const rs = pts.map(([x, y]) => Math.hypot(x - 12, y - 12));
assert.ok(Math.min(...rs) > 7.38 && Math.max(...rs) < 10.02, `${Math.min(...rs)}..${Math.max(...rs)}`);
assert.deepEqual([...new Set(radii)].sort(), ['10', '7.4'].map(Number).sort());
// inside the 24 × 24 box, centred: the same extent as the 📖 glyph next to it (x 2–22)
const xs = pts.map((p) => p[0]), ys = pts.map((p) => p[1]);
assert.ok(Math.min(...xs) >= 2 && Math.max(...xs) <= 22 && Math.min(...ys) >= 2 && Math.max(...ys) <= 22);
assert.ok(Math.abs(Math.min(...xs) + Math.max(...xs) - 24) < 0.02 && Math.abs(Math.min(...ys) + Math.max(...ys) - 24) < 0.02);
// the hole: a circle of r 3.3 about the centre (even-odd)
assert.match(GLYPHS.gear, /ZM12 8\.7A3\.3 3\.3 0 1 0 12 15\.3A3\.3 3\.3 0 1 0 12 8\.7Z$/);
// other gear shapes stay regular too (the generator, not one lucky constant)
assert.ok(regularGear(gearPath({ teeth: 6, tipW: 3.6, rootW: 5 }), 6));
});
test('GIcon draws it as an even-odd 24 × 24 SVG path', () => {
const v = GIcon({ name: 'gear' });
assert.equal(v.type, 'svg');
assert.equal(v.props.viewBox, '0 0 24 24');
const p = [v.props.children].flat(3).find((c) => c && c.type === 'path');
assert.equal(p.props.d, GLYPHS.gear);
assert.equal(p.props['fill-rule'], 'evenodd');
});
test('phones: the corner glyphs keep half of the grown 34 px buttons (not an 11 px glyph)', () => {
const css = read('public/css/devices.css');
const phone = css.slice(css.indexOf('@media (max-height: 600px) and (pointer: coarse)'));
const block = phone.slice(0, phone.indexOf('\n}\n'));
assert.match(block, /\.gm__gear \{ width: max\(\.56rem, 34px\); height: max\(\.56rem, 34px\); \}/);
assert.match(block, /\.gm__corner \.gm__gear \.icon \{ width: max\(\.28rem, 17px\); height: max\(\.28rem, 17px\); \}/);
// desktop: .28rem in a .56rem button — the same half
assert.match(read('public/css/screens/game.css'), /\.gm__gear \.icon \{ width: \.28rem; height: \.28rem; \}/);
});
});
// ---- 9: the bench stays clear of the shop bar on phones -------------------------------------------------------------
/** Root font size of css/theme.css: clamp(40px, min(100vw / 19.2, 100vh / 10.8), 240px). */
const remAt = (w, h) => Math.max(40, Math.min(w / 19.2, h / 10.8, 240));
/** Run `fn` with a stubbed DOM: the root font size, and the HUD layer's top (safe-area inset). */
function withDom(rem, hudTop, fn) {
const g = globalThis;
const saved = { document: g.document, getComputedStyle: g.getComputedStyle };
g.getComputedStyle = () => ({ fontSize: `${rem}px` });
g.document = { documentElement: {}, querySelector: (s) => (s === '.gm__hud' ? { getBoundingClientRect: () => ({ top: hudTop }) } : null) };
try { return fn(); } finally { g.document = saved.document; g.getComputedStyle = saved.getComputedStyle; }
}
describe('9: the prep camera keeps the bench clear of the shop bar on phones in landscape', () => {
test('HUD_REM mirrors the CSS it measures (bond strip bottom, shop bar top, the notched-phone rule)', () => {
const game = read('public/css/screens/game.css');
const shop = read('public/css/screens/game-shop.css');
// bond strip: top 1.36rem + a .52rem disc and its name line → measured 2.14–2.15rem in Chrome; 2.16rem kept
assert.match(game, /\.gm__bonds \{ position: absolute; left: 1\.56rem; top: 1\.36rem;/);
assert.match(game, /\.bslot \.bond \{ --disc: \.52rem; \}/);
assert.equal(HUD_REM.bondStripBottom, 2.16);
// shop bar: bottom .2rem + row padding .1rem × 2 + 2.24rem cards (level / operator / item) + 2 px + 1 px borders
assert.match(shop, /\.shopbar \{\n {2}position: absolute; right: \.26rem; bottom: \.2rem;/);
assert.match(shop, /\.shopbar__row \{\n {2}position: relative; display: flex; align-items: stretch; gap: \.08rem; padding: \.1rem;\n[^}]*border: 1px solid var\(--line-2\); border-top: 2px solid var\(--mint-700\);/);
assert.match(shop, /\.lvcard \{\n {2}position: relative; width: 1\.24rem; height: 2\.24rem;/);
assert.match(shop, /\.scard \{\n {2}--tc: var\(--tier-1\);\n {2}position: relative; width: 1\.56rem; height: 2\.24rem;/);
assert.equal(HUD_REM.shopBarTop, 0.2 + 0.1 * 2 + 2.24);
assert.equal(HUD_REM.shopBarBorderPx, 3);
// the bar stays on the viewport's bottom edge on a notched phone (DESIGN §18.1): no bottom inset to add
assert.match(read('public/css/devices.css'), /\.gm__hud > \.shopbar \{ bottom: calc\(\.2rem - var\(--sa-b\)\); \}/);
});
test('hudBands: prep / Final Assault prep only; rem-scaled; below the top safe-area inset; clamped', () => {
withDom(40, 0, () => {
for (const k of ['normal', 'unite', 'boss', 'hidden', 'pen']) assert.equal(hudBands(k, { width: 844, height: 390 }), null, k);
for (const k of ['prep', 'bossPrep']) {
const b = hudBands(k, { width: 844, height: 390 });
assert.ok(Math.abs(b.top - 86.4) < 1e-9 && Math.abs(b.bottom - 108.6) < 1e-9, `${k} ${JSON.stringify(b)}`);
}
});
withDom(100, 0, () => assert.deepEqual(hudBands('prep', { width: 1920, height: 1080 }), { top: 216, bottom: 267 }));
withDom(40, 12, () => assert.ok(Math.abs(hudBands('prep', { width: 844, height: 390 }).top - 98.4) < 1e-9, 'top inset'));
withDom(40, 0, () => assert.deepEqual(hudBands('prep', { width: 640, height: 200 }), { top: 80, bottom: 80 }), 'clamped at 40 % of the height');
});
test('wiring: the game hands hudBands to the view, the view to the prep cameras', () => {
assert.match(read('public/js/ui/fieldHost.js'), /padding: hudPadding, hud: hudBands \}/);
const app = read('public/js/render/app.js');
assert.match(app, /hud: hudBands\(vk, sz\),/);
assert.match(app, /presetCamera\('prep', \{ width: s0\.width, height: s0\.height, padding: defaultPadding\('prep', s0\) \}, \{ hud: hudBands\('prep', s0\) \}\)/);
});
// phones in landscape (CSS px) incl. the user's Android: its 2772×1272 px screenshot shows the page right of a 141 px
// black cutout band, 2631×1272 px at DPR ≈ 3.48 = 756×366 (798×366 had the page covered the cutout too)
const PHONES = [[844, 390], [932, 430], [915, 412], [914, 411], [800, 360], [960, 411], [756, 366], [798, 366], [924, 424]];
const stage = JSON.parse(read('data/stages.json')).act2autochess_m01;
const H = { h: 0.42, '#': 0.3, X: 0.55, a: 0.16, A: 0.16 }; // render/style.js TILE_H by glyph
const heightAt = (r, c) => H[stage.rows[r]?.[c]] ?? 0; // rows[0] = the bottom row (DATA.md)
/** Screen y extent of the bench (row 7 / boss row 0), temp (8 / 1) and back row (12 / 5) tile tops. */
function extents(cam, rows) {
const ys = (r, c0, c1) => { const out = []; for (let c = c0; c <= c1; c++) for (const p of tileQuad(cam, r, c, heightAt(r, c))) out.push(p.y); return out; };
return { benchBottom: Math.max(...ys(rows.bench, 0, 9)), tempBottom: Math.max(...ys(rows.temp, 4, 8)), backTop: Math.min(...ys(rows.back, 4, 10)) };
}
test('reproduction + fix: at every phone viewport every bench / temp tile ends above the shop bar, the back row below the bond strip', () => {
assert.ok(stage, 'stage act2autochess_m01');
for (const [w, h] of PHONES) {
const rem = remAt(w, h);
const shopTop = h - (HUD_REM.shopBarTop * rem + 3);
for (const [kind, rows] of [['prep', { bench: 7, temp: 8, back: 12 }], ['bossPrep', { bench: 0, temp: 1, back: 5 }]]) {
const hud = withDom(rem, 0, () => hudBands(kind, { width: w, height: h }));
const before = extents(presetCamera(kind, { width: w, height: h }), rows);
const after = extents(presetCamera(kind, { width: w, height: h }, { hud }), rows);
const tag = `${kind} ${w}×${h}`;
if (w <= 844) assert.ok(before.benchBottom > shopTop + 5, `${tag}: v2.3 bench under the shop bar (${before.benchBottom} vs ${shopTop})`);
assert.ok(after.benchBottom <= shopTop + 1e-6, `${tag}: bench ${after.benchBottom} ≤ shop bar ${shopTop}`);
assert.ok(after.tempBottom < after.benchBottom, `${tag}: temp row above the bench`);
assert.ok(after.backTop >= hud.top - 1e-6, `${tag}: back row ${after.backTop} ≥ bond strip ${hud.top}`);
}
}
});
});