feedback1 WD: forms reach late views, stable enemy shapes, review fixes

- Every form change goes through content setForm(): the unit keeps it (e.form)
  and UnitInfo publishes it, so a view built mid-battle from fieldMeta (a
  watched teammate's field, 联防 observers, a reconnect) starts in the
  current form instead of the first-form model.
- Enemy profile (dmgType) and Unit moving / form are initialised at spawn:
  one object shape for the sim's hot loop (the boss-field bench is back at
  the base speed).
- 转译基底·α: an HP loss cannot knock out its original form (no death clip).
- 假想敌:再生: 1 s 重生 and walking 傀儡 cited from PRTS; shields within 1.8
  as the 再生状态 begins.
- Renderer: a 重生's closing clip (and the ember's stand-up clip) is timed
  from the fx dur to end with it, so the next form attacks on its own clips.
- Docs: ASSETS FORMS list, SIM form fx, PLAYING 隐匿 wording.
This commit is contained in:
sganggs committed 2026-10-03 03:02:46 +08:00
1 parent 06be81f67e
commit 5c23eeb804
12 files changed
+318 -88

No files matched your search

+75 -20
View File
@@ -5,8 +5,9 @@
// after the change (render/app.js keeps the mode on the unit info). Headless fake PIXI (test/render/fakepixi.js).
// Player reports after 0.1.0 (#5, #8): 转译基底·α changes on its 2 s A_Die_B / _C / _D clip into the 寻仇者 B_*, 幽灵 C_* or
// 特战术师 D_* set (it used to stay on A_Idle / A_Move and die on B_Die — "加载变身动画然后就没了"); a knocked-out 逐火 plays
// 'Die' (its 1 s 重生) and walks as the ember on Idle_2 / Move_2, dying on Die_2, and 'Revive' brings the warrior back
// (sim fx 'ember' / 'revive' { form: 'husk' | 'revived' }); 假想敌:再生's 傀儡 the same with A_Die / B_* / B_Revive.
// 'Die' (its 1 s 重生) and walks as the ember on Idle_2 / Move_2, dying on Die_2, and 'Revive' — timed from the 'ember'
// fx's `dur` to end as it stands up — brings the warrior back (sim fx 'ember' / 'revive' { form: 'husk' | 'revived' });
// 假想敌:再生's 傀儡 the same with A_Die / B_* / B_Revive; the leaders' 重生 close on their last clip as the 重生 ends.
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
@@ -16,6 +17,7 @@ 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';
import { makeBattle, chessRec } from '../helpers/battleHarness.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const assets = JSON.parse(readFileSync(path.join(ROOT, 'data/assets.json'), 'utf8'));
@@ -172,44 +174,60 @@ describe('转译基底·α forms (user report after 0.1.0, #5)', () => {
describe('逐火 embers and the 再生 puppet (user report after 0.1.0, #8)', () => {
for (const id of ['enemy_1288_duskls', 'enemy_1288_duskls_2', 'enemy_1292_duskld']) {
test(`${id}: knocked out ⇒ 'Die' once, the ember on Idle_2 / Move_2 / Die_2; revived ⇒ 'Revive', then the warrior's clips`, async () => {
test(`${id}: knocked out ⇒ 'Die' once, the ember on Idle_2 / Move_2 / Die_2; 'Revive' ends as it stands up, then the warrior's clips`, async () => {
const anims = assets.enemies[id].spine.animations;
for (const name of ['Die', 'Idle_2', 'Move_2', 'Die_2', 'Revive']) assert.ok(name in anims, name);
const v = await enemy(id);
v.sync(sample(0, ANIM.MOVE), 1);
assert.equal(clip(v), 'Move');
v.setForm('husk');
const dur = 4; // the 'ember' fx: 1 s 重生 + the husk (10 s officially)
v.setForm('husk', { dur });
assert.equal(clip(v), 'Die', 'the knock-out (its 1 s 重生)');
frames(v, 65);
assert.equal(clip(v), 'Move_2', 'the ember walks');
v.sync(sample(UF.STEALTH, ANIM.IDLE), 2);
assert.equal(clip(v), 'Idle_2');
v.setForm('revived');
assert.equal(clip(v), 'Revive');
frames(v, 80);
frames(v, Math.round((dur - anims.Revive - 65 / 60) * 60) + 3);
assert.equal(clip(v), 'Revive', 'the stand-up clip starts so that it ends with the husk');
frames(v, Math.round(anims.Revive * 60) - 6);
v.setForm('revived'); // the sim's 'revive' fx
assert.equal(clip(v), 'Revive', 'plays out, not restarted');
frames(v, 10);
assert.equal(clip(v), 'Idle', 'the warrior again');
v.onAttack(null, 4);
assert.equal(clip(v), 'Attack');
v.setForm('husk');
v.onAttack(null, 6);
assert.equal(clip(v), 'Attack', 'it attacks at once');
v.setForm('husk', { dur });
frames(v, 65);
v.die();
assert.equal(clip(v), 'Die_2', 'the ember dies on its own clip');
frames(v, 300);
assert.equal(clip(v), 'Die_2', 'no stand-up clip after its death');
});
}
test('假想敌:再生: A_Die, the 傀儡 on B_Idle / B_Move / B_Die; B_Revive back to the A_* clips', async () => {
const v = await enemy('enemy_9010_acpupp');
v.setForm('husk');
v.setForm('husk', { dur: 3 });
assert.equal(clip(v), 'A_Die');
frames(v, 65);
assert.equal(clip(v), 'B_Idle');
v.sync(sample(0, ANIM.MOVE), 2);
assert.equal(clip(v), 'B_Move');
frames(v, 60);
assert.equal(clip(v), 'B_Revive', 'its last second');
frames(v, 57);
v.setForm('revived');
assert.equal(clip(v), 'B_Revive');
frames(v, 65);
frames(v, 6);
assert.equal(clip(v), 'A_Move');
});
test('a view built mid-husk (UnitInfo form, no timing) shows the husk; the revival lands in the manifest clips', async () => {
const v = await enemy('enemy_1288_duskls', { form: 'husk' });
v.sync(sample(UF.STEALTH, ANIM.MOVE), 1);
assert.equal(clip(v), 'Move_2');
v.setForm('revived');
assert.equal(clip(v), 'Move', 'no stand-up clip without the timing');
});
});
describe('the leaders\' 重生 and 守墓石像 (audit of the knock-out forms after report #5)', () => {
@@ -224,15 +242,21 @@ describe('the leaders\' 重生 and 守墓石像 (audit of the knock-out forms af
for (const name of [begin, hold, end, idle2, die2]) assert.ok(name in anims, name);
const v = await enemy(id);
v.sync(sample(UF.STUNNED, ANIM.STUN), 1);
v.setForm('reborn');
const dur = 6; // the 'telegraph' fx's dur (Reborn.duration)
v.setForm('reborn', { dur });
assert.equal(clip(v), begin);
frames(v, Math.ceil(anims[begin] * 60) + 5);
assert.equal(clip(v), hold, 'held (its stun role) while the sim keeps it stunned');
v.sync(sample(0, ANIM.IDLE), 6);
const at = Math.ceil(anims[begin] * 60) + 5;
frames(v, Math.round((dur - anims[end]) * 60) - at + 3);
assert.equal(clip(v), end, 'the closing clip starts so that it ends with the 重生');
frames(v, Math.round(anims[end] * 60) - 6);
v.sync(sample(0, ANIM.IDLE), dur + 1); // the sim's 'revive' fx: the 重生 is over
v.setForm('form2');
assert.equal(clip(v), end);
frames(v, Math.ceil(anims[end] * 60) + 5);
assert.equal(clip(v), idle2);
frames(v, 6);
assert.equal(clip(v), idle2, 'the second form at once');
v.onAttack(null, dur + 1.2);
assert.notEqual(clip(v), idle2, 'its first attack is drawn, not swallowed by a change clip');
v.die();
assert.equal(clip(v), die2);
});
@@ -273,7 +297,38 @@ describe('the leaders\' 重生 and 守墓石像 (audit of the knock-out forms af
});
});
test('render/app.js hands the sim\'s fx \'phase\' kind — or the `form` of any fx — to the view and keeps the mode on the unit info', () => {
test('a view built mid-battle from the real sim\'s fieldMeta (fx dropped by a silent catch-up) starts in the current form: the changed 转译基底·α on D_Idle, the 逐火 ember on Idle_2, 锏 after its 重生 on B_Idle', async () => {
const mage = chessRec({ id: 't_mage', profession: 'CASTER', stats: { atk: 50, blockCnt: 0 }, rangeGrid: [[0, 0]], skill: null });
const h = makeBattle({
stageId: 'act2autochess_m01', content: 'full', seed: 7, autoFinish: false, timeLimit: 600, modeId: 'mode_multi_hard', round: 3,
defs: { chess: { t_mage: mage } }, units: [{ chessId: 't_mage', row: 12, col: 2 }], kits: { t_mage: () => ({ trait: { noAttack: true } }) },
});
h.step();
const still = { mods: { speedMul: 0 } };
const tr = h.b.spawnEnemy('enemy_10081_mpplai', { pos: [9, 8], ...still });
const ember = h.b.spawnEnemy('enemy_1288_duskls', { pos: [10, 8], ...still });
const mace = h.b.spawnEnemy('enemy_1525_blkswb', { pos: [11, 8], ...still });
const m = h.unit('t_mage');
for (let i = 0; i < 4; i++) h.b.dealDamage(m, tr, { amount: 1, type: 'arts' }); // the 4th arts hit ⇒ 特战术师
h.b.kill(ember, m);
h.b.kill(mace, m);
const until = h.b.time + 6;
while (h.b.time < until) { h.b.step(); h.b.drainEvents(); }
const meta = h.b.fieldMeta();
const view = async (u) => {
const info = meta.units.find((x) => x.id === u.id);
const ctx = fakeViewCtx(fake.P, { assets: store(info.spine), cam });
const v = new UnitView(ctx, info);
await tick(); await tick();
v.sync(sample(), 1);
return v;
};
assert.equal(clip(await view(tr)), 'D_Idle', '特战术师, not the A model');
assert.equal(clip(await view(ember)), 'Idle_2', 'the ember, not the warrior');
assert.equal(clip(await view(mace)), 'B_Idle', '锏\'s second form');
});
test('render/app.js hands the sim\'s fx \'phase\' kind — or the `form` of any fx, with the fx — 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' \? ex4\.kind : 'form' in ex4 \? ex4\.form[\s\S]{0,200}inf\.form = [\s\S]{0,120}setForm\?\.\(form\)/);
assert.match(src, /e\[1\] === 'phase' \? ex4\.kind : 'form' in ex4 \? ex4\.form[\s\S]{0,200}inf\.form = [\s\S]{0,120}setForm\?\.\(form, ex4\)/);
});
+96 -1
View File
@@ -11,6 +11,10 @@
// every 逐火 enemy of a round leaked.
// The real-round tests build the battle like Match._normalOpts: an official wave (waves.js buildNormalWave, a 悬赏 card
// through withBounties), the real stage 战场#01, real chess, full content, buildBattleSpec → createBattleFromSpec.
// Review follow-ups: a view built mid-battle (fieldMeta — a watched teammate's field, 联防 observers, a reconnect; the
// client runner's silent catch-up drops the fx) learns the current form from UnitInfo `form`; an HP loss (流失) cannot
// knock 转译基底·α out before its change; 假想敌:再生's shields come with its 再生状态, within 1.8 (PRTS 天赋); enemy
// profiles keep one object shape from spawn (the sim's hot loop).
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
@@ -23,7 +27,7 @@ import { getDefaultSource } from '../../server/sim/simdata.js';
import { GEO } from '../../shared/constants.js';
import { makeBattle, chessRec, checkInvariants } from '../helpers/battleHarness.js';
import { canTargetEnemy } from '../../server/sim/targeting.js';
import { HUSK_REBIRTH, TRANSLATOR_CHANGE } from '../../server/sim/content/enemies.js';
import { HUSK_REBIRTH, TRANSLATOR_CHANGE, abOf } from '../../server/sim/content/enemies.js';
const QUIET = { warn() {}, error() {}, info() {} };
const DATA = getData({ log: QUIET });
@@ -128,6 +132,7 @@ describe('#8 深池逐火: a knock-out is a walking 隐匿 ember that a blocker
walked = Math.max(walked, Math.hypot(u.x - m.x, u.y - m.y));
}
check(u.stats.attacks === m.attacks, 'an ember never attacks');
if (n % 30 === 0) check(b.fieldMeta().units.find((x) => x.id === u.id)?.form === 'husk', 'a view built now (fieldMeta) shows the ember');
}
}
const r = b.result();
@@ -209,6 +214,23 @@ describe('#8 深池逐火: a knock-out is a walking 隐匿 ember that a blocker
assert.equal(e.hp, n - 4);
assert.ok(e.alive);
});
test('假想敌:再生 (PRTS 天赋 "被击倒后重生,持续1s,随后变为再生状态 … 进入此形态时,使半径1.8范围内的其他敌方单位…获得5层…护盾"): no shields during the 1 s 重生, then 5-hit shields within 1.8 only', () => {
const h = arena({ units: [{ chessId: 't_gun', row: 10, col: 4 }], kits: QUIET_GUNS });
h.step();
const still = { mods: { speedMul: 0 } };
const e = put(h, 'enemy_9010_acpupp', { pos: [10, 8], ...still });
const near = put(h, 'enemy_1007_slime', { pos: [10, 6.3], ...still }); // 1.7 away
const far = put(h, 'enemy_1007_slime', { pos: [11.9, 8], ...still }); // 1.9 away
h.b.kill(e, h.unit('t_gun'));
assert.ok(e.alive && e.form === 'husk');
h.run(HUSK_REBIRTH - 0.1);
assert.equal(abOf(h.b, near).hitShield ?? 0, 0, 'no shield while it is still in its 重生');
h.run(0.2);
assert.equal(abOf(h.b, near).hitShield, tb('enemy_9010_acpupp', 'Aura.max_damage_block_cnt'), 'the 再生状态 begins: 5 hits of shield within 1.8');
assert.equal(abOf(h.b, far).hitShield ?? 0, 0, 'nothing 1.9 away');
assert.ok(e.s.flags.unblockable && !e.s.flags.noMove, '不可阻挡, free to move after the 1 s');
});
});
// ---------------------------------------------------------------------------------------------------------------
@@ -268,10 +290,12 @@ describe('#5 转译基底·α: damage is cancelled until its form change; only t
],
});
let tr = null, phaseAt = null, kind = null, diedAt = null, formAt = null, maxBefore = null, firstFormLoss = null, earlyAttack = false;
let lateForm; // UnitInfo.form of the translator in fieldMeta() 1 s after its change began
let n = 0;
while (!b.finished && n++ < 30 * 300) {
b.step();
tr = tr ?? b.enemies.find((e) => e.defId === TR) ?? null;
if (tr && tr.alive && phaseAt != null && lateForm === undefined && b.time >= phaseAt + 1) lateForm = b.fieldMeta().units.find((u) => u.id === tr.id)?.form ?? null;
for (const ev of b.drainEvents()) {
if (!tr) continue;
if (ev[0] === 'fx' && ev[1] === 'phase' && ev[4].id === tr.id) { phaseAt = b.time; kind = ev[4].kind; }
@@ -289,6 +313,7 @@ describe('#5 转译基底·α: damage is cancelled until its form change; only t
assert.ok(phaseAt != null && diedAt > phaseAt + TRANSLATOR_CHANGE - 0.05, 'it changed form before it died — never killed in its first form');
assert.equal(firstFormLoss, null, 'its first form loses no HP');
assert.equal(kind, 'translator_fuchou', '4 physical hits first ⇒ 寻仇者');
assert.equal(lateForm, 'translator_fuchou', 'a view built mid-battle (fieldMeta) draws the changing form, not the A model');
approx(formAt - phaseAt, TRANSLATOR_CHANGE, 0.02, 'the change lasts 2 s');
assert.ok(!earlyAttack, 'no attack before its change');
const r = b.result();
@@ -356,6 +381,24 @@ describe('#5 转译基底·α: damage is cancelled until its form change; only t
checkInvariants(h2.b);
});
test('an HP loss (流失 — no damage instance to cancel, e.g. 隐德来希\'s 心烛 hand-over) cannot knock out its original form; once changed it can', () => {
const h = arena({ units: [{ chessId: 't_gun', row: 10, col: 4 }], kits: QUIET_GUNS });
h.step();
const e = put(h, TR, { mods: { speedMul: 0 } });
const g = h.unit('t_gun');
h.b.loseHp(e, 1e9, { source: g });
assert.ok(e.alive && e.hp >= 1, 'still standing in its first form');
assert.equal(h.b.killed, 0);
assert.equal(h.eventsOf('die').filter((ev) => ev[1] === e.id).length, 0, 'no die event: its first form has no death clip');
for (let i = 0; i < 4; i++) h.b.dealDamage(g, e, { amount: 1, type: 'phys' });
h.b.loseHp(e, 1e9, { source: g });
assert.ok(e.alive, 'not during its change either');
h.run(TRANSLATOR_CHANGE + 0.1);
h.b.loseHp(e, 1e9, { source: g });
assert.ok(!e.alive && e.removeReason === 'killed', 'the changed form dies');
assert.equal(h.b.killed, 1);
});
test('the 4th arts hit ⇒ 特战术师: its stats, ranged arts attacks on 2 targets at once', () => {
const h = arena({ units: [{ chessId: 't_mage', row: 11, col: 6 }, { chessId: 't_gun', row: 10, col: 7 }], kits: QUIET_GUNS, hooks: ['damaged'], captureNoisy: true });
h.step();
@@ -397,3 +440,55 @@ describe('#5 转译基底·α: damage is cancelled until its form change; only t
assert.equal(e.mem.ab.list.length, 1);
});
});
// ---------------------------------------------------------------------------------------------------------------
// review follow-ups: the form reaches a view built mid-battle; stable enemy shapes
describe('a view built mid-battle learns the form (UnitInfo `form` in fieldMeta)', () => {
const formOf = (h, e) => h.b.fieldMeta().units.find((u) => u.id === e.id)?.form;
test('spawned: no form; 转译基底·α changing / changed, a 逐火 ember and its revival, a leader\'s 重生 and second form, 守墓石像 — each fieldMeta carries the current one', () => {
const h = arena({ units: [{ chessId: 't_gun', row: 10, col: 4 }, { chessId: 't_mage', row: 11, col: 4 }], kits: QUIET_GUNS });
h.step();
const still = { mods: { speedMul: 0 } };
const tr = put(h, TR, { pos: [9, 8], ...still });
const ember = put(h, 'enemy_1288_duskls', { pos: [10, 8], ...still });
const mace = put(h, 'enemy_1525_blkswb', { pos: [11, 8], ...still });
const stone = put(h, 'enemy_1172_dugago', { pos: [12, 8], ...still });
const spawned = h.eventsOf('spawn').find((ev) => ev[1].id === tr.id)[1];
assert.equal(spawned.form, undefined, 'a fresh enemy has no form');
assert.equal(formOf(h, tr), undefined);
const m = h.unit('t_mage');
for (let i = 0; i < 4; i++) h.b.dealDamage(m, tr, { amount: 1, type: 'arts' });
h.b.kill(ember, m);
h.b.kill(mace, m);
h.b.kill(stone, m);
// the client runner's silent catch-up: the fx are drained and dropped
h.b.step();
h.b.drainEvents();
assert.equal(formOf(h, tr), 'translator_shushi', 'changing');
assert.equal(formOf(h, ember), 'husk');
assert.equal(formOf(h, mace), 'reborn');
assert.equal(formOf(h, stone), 'stone');
h.b.kill(ember, null); // still in its 1 s 重生: nothing changes
while (h.b.time < 12.5) { h.b.step(); h.b.drainEvents(); }
assert.equal(formOf(h, tr), 'translator_shushi', 'changed');
assert.equal(formOf(h, ember), 'revived', 'the warrior again');
assert.equal(formOf(h, mace), 'form2', 'after its 重生');
assert.equal(formOf(h, stone), 'fly');
});
});
test('every enemy starts with the same profile fields and `moving` / `form` (one object shape for the hot loop)', () => {
const h = arena({ units: [{ chessId: 't_gun', row: 10, col: 4 }], kits: QUIET_GUNS });
h.step();
const keys = [TR, ...EMBERS, 'enemy_1007_slime', 'enemy_1172_dugago', 'enemy_1525_blkswb', 'enemy_9010_acpupp'];
const shapes = new Set();
for (const key of keys) {
const e = put(h, key);
assert.equal(e.moving, false, `${key} moving`);
assert.equal(e.form, null, `${key} form`);
assert.ok('dmgType' in e.profile && e.profile.dmgType === null, `${key} profile.dmgType`);
shapes.add(Object.keys(e).join());
}
assert.equal(shapes.size, 1, 'one key order for every enemy');
});