feedback2: merge WI — 归溟幽灵鲨's 替身 is drawn (form + status icon), makes no attack and casts nothing, S2's end switches her (GitHub #44)

This commit is contained in:
sganggs committed 2026-10-04 00:13:31 +08:00
commit 0ac7dc7dd2
17 files changed
+572 -67

No files matched your search

+200
View File
@@ -0,0 +1,200 @@
// test/render/feedback2-doll.test.js — GitHub issue #44 「归鲨死了没替身」 (DESIGN §22.11), client side: a 傀儡师 fighting as
// its <替身> is in the model form 'doll' (sim professions.js: fx 'substitute' { form: 'doll', dur } … 'swap' / 'dollEnd'
// { form: null }, UnitInfo `form`), drawn with the skeleton's *_B clips (render/units.js FORMS): 归溟幽灵鲨's Start_B fades
// the 替身 in, Idle_B, Die_B breaks it apart over its last second, the 本体 comes back on Start_2 (the form's `leave`
// clip); knocked out as the 替身 she collapses on Die_B_2 and stays so. 0.1.1 drew the 本体 all along. Headless fake PIXI
// (test/render/fakepixi.js); the clip meanings are checked on the real skeleton when the assets are fetched.
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, existsSync } from 'node:fs';
import { createRequire } from 'node:module';
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 { statusIconKey } from '../../public/js/render/style.js';
import { STATUS_KEYS } from '../../public/js/render/textures.js';
import { fxForm } from '../../shared/protocol.js';
import { makeBattle, enemyRec } from '../helpers/battleHarness.js';
import { unitInfo } from '../../server/sim/snapshot.js';
import { DOLL_SWITCH } from '../../server/sim/professions.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const assets = JSON.parse(readFileSync(path.join(ROOT, 'data/assets.json'), 'utf8'));
const GHOST = 'char_1023_ghost2', KAZEMA = 'char_4016_kazema';
let fake, UnitView, FORMS, renderInfo;
before(async () => {
fake = installFakePixi();
({ UnitView, FORMS } = await import('../../public/js/render/units.js'));
({ renderInfo } = await import('../../public/js/render/app.js'));
});
after(() => fake.restore());
const tick = () => new Promise((r) => setImmediate(r));
const cam = () => presetCamera('normal', { width: 1280, height: 720 });
/** Asset store with the operator's real Front / Back manifest entries and their animation names. */
function store(id) {
const sp = assets.chars[id].spine;
return {
picture: () => null,
image: async () => null,
hasBack: () => !!sp.back,
spineEntry: (_, o = {}) => (o.back ? sp.back : sp.front),
spine: { acquire: async (entry) => ({ animations: Object.keys(entry.animations || {}).map((name) => ({ name })) }), release() {} },
};
}
async function op(id, info = {}) {
const ctx = fakeViewCtx(fake.P, { assets: store(id), cam });
const v = new UnitView(ctx, { id: 1, side: 'ally', kind: 'op', defId: id, spine: id, tier: 5, x: 5, y: 9, maxHp: 2657, dir: 'RIGHT', skillIndex: 1, ...info });
await tick(); await tick();
assert.ok(v.actor, 'Spine model built');
return v;
}
const clip = (v) => v.actor.current;
const frames = (v, n, dt = 1 / 60) => { for (let i = 0; i < n; i++) v.update(dt, cam(), i * dt); };
const secs = (v, s) => frames(v, Math.round(s * 60));
describe('归溟幽灵鲨\'s 替身 (FORMS doll)', () => {
test('the clip set exists in her Front skeleton; the Back one (facing UP) only has Idle_B and Start_2', () => {
const f = FORMS[GHOST].doll;
const front = assets.chars[GHOST].spine.front.animations, back = assets.chars[GHOST].spine.back.animations;
for (const name of [f.change, f.end, f.leave, f.roles.idle, f.roles.die]) assert.ok(name in front, name);
assert.equal(f.roles.attack, null, 'the 替身 never attacks (PRTS 特性备注)');
assert.equal(f.roles.skill, null);
for (const name of ['Idle_B', 'Start_2']) assert.ok(name in back, `Back ${name}`);
for (const name of ['Start_B', 'Die_B', 'Die_B_2']) assert.ok(!(name in back), `no Back ${name}`);
assert.equal(assets.chars[GHOST].spine.front.anims.idle, 'Idle', 'the manifest keeps the 本体');
});
test('the switch: Start_B, Idle_B (no attack clip), Die_B over its last second, the 本体 back on Start_2 with her own skill clip', async () => {
const v = await op(GHOST, { skillIndex: 2 });
assert.equal(clip(v), 'Idle');
const dur = DOLL_SWITCH + 20;
v.setForm('doll', { id: 1, form: 'doll', dur });
assert.equal(clip(v), 'Start_B', 'the 替身 fades in');
secs(v, 1.1);
assert.equal(clip(v), 'Idle_B');
v.onAttack(null, 2);
assert.equal(clip(v), 'Idle_B', 'no attack clip');
secs(v, dur - 1 - 1.1 + 0.05);
assert.equal(clip(v), 'Die_B', 'it breaks apart over its last second');
secs(v, 0.9);
v.setForm(null, { id: 1, form: null }); // the sim's 'swap' fx
assert.equal(clip(v), 'Start_2', 'the 本体 comes back');
secs(v, 1.1);
assert.equal(clip(v), 'Idle');
v.setSkill(true);
assert.equal(clip(v), 'Skill_3_Begin', 'her equipped skill\'s clips again (S3), not the manifest default');
v.die();
assert.equal(clip(v), 'Die', 'the 本体\'s death clip');
});
test('knocked out as the 替身: Die_B_2, held through the form reset (\'dollEnd\'); the redeploy is the 本体', async () => {
const v = await op(GHOST);
v.setForm('doll', { dur: 21 });
secs(v, 2);
v.die();
assert.equal(clip(v), 'Die_B_2', 'the 替身 collapses');
v.setForm(null, { form: null }); // fx 'dollEnd', after the 'die' event
assert.equal(clip(v), 'Die_B_2', 'no Start_2 on a knocked-out view');
assert.equal(v.form, null);
secs(v, 3);
v.onDeploy();
assert.equal(clip(v), 'Start', 'redeployed as the 本体');
secs(v, 1.2);
assert.equal(clip(v), 'Idle');
});
test('a view built while she is the 替身 (UnitInfo form) starts on Idle_B; facing UP the Back model shows the 替身 without the switch clips it lacks', async () => {
const v = await op(GHOST, { form: 'doll' });
assert.equal(clip(v), 'Idle_B');
const up = await op(GHOST, { dir: 'UP' });
assert.ok(up.entryBack, 'the Back model');
up.setForm('doll', { dur: 21 });
assert.equal(clip(up), 'Idle_B', 'no Start_B in the Back skeleton: straight to the 替身');
secs(up, 21);
up.setForm(null, { form: null });
assert.equal(clip(up), 'Start_2', 'its Start_2');
const turned = await op(GHOST, { form: 'doll' });
turned.setDir('UP');
await tick(); await tick();
assert.ok(turned.entryBack && clip(turned) === 'Idle_B', 'turned UP mid-form: the Back model keeps the 替身');
});
test('the 替身\'s status icon; a late switch fx skips a change clip that would already have ended', async () => {
assert.equal(statusIconKey('trait:substitute'), 'doll');
assert.ok(STATUS_KEYS.includes('doll'));
const v = await op(GHOST);
v.setForm('doll', { dur: 21, late: 1.5 });
assert.equal(clip(v), 'Idle_B');
v.setForm(null, { form: null, late: 2 });
assert.equal(clip(v), 'Idle', 'no Start_2 when it would be over');
});
});
test('风丸\'s 替身: Start_B, Idle_B / Attack_B (it attacks), Die_B; the 本体 back on Start', async () => {
const anims = assets.chars[KAZEMA].spine.front.animations;
for (const name of ['Start_B', 'Idle_B', 'Attack_B', 'Die_B', 'Start']) assert.ok(name in anims, name);
const v = await op(KAZEMA);
v.setForm('doll', { dur: 21 });
assert.equal(clip(v), 'Start_B');
secs(v, 1.1);
assert.equal(clip(v), 'Idle_B');
v.onAttack(null, 2);
assert.equal(clip(v), 'Attack_B');
secs(v, 3);
v.setForm(null, { form: null });
assert.equal(clip(v), 'Start');
const w = await op(KAZEMA, { form: 'doll' });
w.die();
assert.equal(clip(w), 'Die_B');
});
test('sim → view: the real 归溟幽灵鲨\'s lethal hit puts UnitInfo `form` doll (renderInfo keeps it) and its fx carry the forms the view switches on', async () => {
const h = makeBattle({ autoFinish: false, timeLimit: 60, defs: { enemies: { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 1e9, atk: 0, speed: 0 }) } },
units: [{ chessId: 'chess_char_5_13_a', row: 9, col: 5 }], enemies: [{ key: 'enemy_dummy', pos: [9, 6] }] });
h.step();
const g = h.unit('chess_char_5_13_a');
const v = await op(GHOST, renderInfo(unitInfo(g)));
const apply = () => { for (const e of h.eventsOf('fx')) if (e[4]?.id === g.id && fxForm(e) !== undefined) v.setForm(fxForm(e), e[4]); h.events.length = 0; };
h.b.dealDamage(null, g, { amount: 1e9, type: 'true' });
apply();
assert.equal(renderInfo(unitInfo(g)).form, 'doll');
assert.equal(clip(v), 'Start_B');
secs(v, 1.1);
h.run(DOLL_SWITCH + 20.05);
apply();
assert.equal(clip(v), 'Start_2', 'back to the 本体');
assert.equal(renderInfo(unitInfo(g)).form, undefined);
});
// The clip meanings, read from the official skeleton (tools/assets/skel.mjs's parser, @pixi-spine/runtime-3.8): the *_B
// clips draw only the 替身's own slots (names ending in _T), Start_B and Start_2 fade in, Die_B ends faded out (the 替身
// gone: the switch back), Die_B_2 keeps it visible (collapsed: a knock-out).
const SKEL = path.join(ROOT, `public/assets/spine/op/${GHOST}/front/${GHOST}.skel`);
test('the official skeleton: *_B clips are the 替身, Die_B ends gone, Die_B_2 ends down, Start_2 brings the 本体 back', { skip: !existsSync(SKEL) && 'assets not fetched' }, () => {
const require = createRequire(path.join(ROOT, 'package.json'));
const r = require('@pixi-spine/runtime-3.8'), B = require('@pixi-spine/base');
const att = (n) => new r.RegionAttachment(n);
const loader = { newRegionAttachment: (s, n) => att(n), newMeshAttachment: (s, n) => new r.MeshAttachment(n), newBoundingBoxAttachment: (s, n) => new r.BoundingBoxAttachment(n), newPathAttachment: (s, n) => new r.PathAttachment(n), newPointAttachment: (s, n) => new r.PointAttachment(n), newClippingAttachment: (s, n) => new r.ClippingAttachment(n) };
const data = new r.SkeletonBinary(loader).readSkeletonData(new Uint8Array(readFileSync(SKEL)));
const sk = new r.Skeleton(data);
/** Slots drawn (attached, alpha > 0) at time t of clip `name`: { doll, body } counts. */
const drawn = (name, t) => {
const a = data.findAnimation(name);
sk.setToSetupPose();
a.apply(sk, 0, Math.min(t, a.duration), false, null, 1, B.MixBlend.setup, B.MixDirection.mixIn);
let doll = 0, body = 0;
for (const s of sk.slots) if (s.attachment && s.color.a > 0.01) { if (/_T\d*$/.test(s.data.name)) doll++; else body++; }
return { doll, body };
};
for (const name of ['Idle_B', 'Start_B', 'Die_B', 'Die_B_2']) assert.equal(drawn(name, 0.5).body, 0, `${name}: no 本体 slot`);
for (const name of ['Idle', 'Start_2', 'Die']) assert.equal(drawn(name, 0.5).doll, 0, `${name}: no 替身 slot`);
assert.ok(drawn('Idle_B', 0.5).doll > 30, 'Idle_B draws the 替身');
assert.equal(drawn('Start_B', 0).doll, 0, 'Start_B: it fades in');
assert.equal(drawn('Die_B', 1).doll, 0, 'Die_B: gone at its end');
assert.ok(drawn('Die_B_2', 1).doll > 30, 'Die_B_2: still there, collapsed');
assert.equal(drawn('Start_2', 0).body, 0, 'Start_2: the 本体 fades in');
});
+169
View File
@@ -0,0 +1,169 @@
// test/sim/feedback2-doll.test.js — GitHub issue #44 「归鲨死了没替身」 (DESIGN §22.11), sim side. 归溟幽灵鲨
// (chess_char_5_13_a / _b) is a 傀儡师 (server/sim/professions.js installDollkeeper). Official: PRTS 分支特性信息 傀儡师 —
// a lethal hit on the <本体> without 不死 starts a switch animation, then she fights as her <替身> for 20 s holding 阻回
// (block 0), then switches back; PRTS 归溟幽灵鲨 特性备注 "<替身>不进行普通攻击"; S2 生存的渴望 holds 不死 while it runs and
// "技能结束后立刻切换为<替身>" (PRTS's correction of 视为被击倒). 0.1.1: the 替身 attacked, could cast S2 (whose end then
// killed her) and the client never drew it (no model form). Cases A–D of the report's repro + 风丸 (her 替身 attacks).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { DOLL_SWITCH } from '../../server/sim/professions.js';
import { hasGeneratedData } from '../../server/sim/simdata.js';
import { fxForm } from '../../shared/protocol.js';
const skip = !hasGeneratedData();
const G = 'chess_char_5_13_a';
const approx = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps, `${msg ?? ''} ${a} ≈ ${b}`);
const dummy = () => enemyRec({ key: 'enemy_dummy', hp: 1e9, atk: 0, speed: 0 });
/** 归溟幽灵鲨 on (9,5) facing right; a harmless parked enemy on (9,6) (in her range) unless `enemy` is false. */
function setup({ id = G, skillIndex = 1, sp = 0, enemy = true, capture = false } = {}) {
const h = makeBattle({
seed: 4, timeLimit: 300, autoFinish: false, captureNoisy: capture,
defs: { enemies: { enemy_dummy: dummy() } },
units: [{ chessId: id, row: 9, col: 5, dir: 'RIGHT', skillIndex, carryState: { sp } }],
enemies: enemy ? [{ key: 'enemy_dummy', pos: [9, 6] }] : [],
});
h.step();
const g = h.unit(id);
const log = { attacksAsDoll: 0, attacks: 0, castsAsDoll: [], casts: [] };
h.b.on('attack', (c) => { if (c.attacker === g) { log.attacks++; if (g.trait.doll) log.attacksAsDoll++; } }, { priority: -2000 });
h.b.on('skillStart', (c) => { if (c.unit === g) { log.casts.push(h.b.time); if (g.trait.doll) log.castsAsDoll.push(c.skill.id); } }, { priority: -2000 });
const lethal = () => h.b.dealDamage(null, g, { amount: 1e9, type: 'true' });
const fx = (kind) => h.eventsOf('fx').filter((e) => e[1] === kind && e[4]?.id === g.id);
return { h, g, log, lethal, fx };
}
test('A: a lethal hit with no skill running ⇒ the 替身 (form, full HP, block 0, 阻回), no normal attack, 拥抱自我 works; back after the 1 s switch + 20 s', { skip }, () => {
for (const [id, skillIndex] of [[G, 1], [G, 0], [G, 2], ['chess_char_5_13_b', 1]]) {
const { h, g, log, lethal, fx } = setup({ id, skillIndex, capture: true });
h.run(1.5);
assert.ok(log.attacks > 0, `${id}/${skillIndex}: the 本体 attacks`);
const atk0 = log.attacks;
const full = g.s.maxHp;
const sp = g.skill.sp;
lethal();
assert.ok(g.alive && g.trait.doll && g.form === 'doll', `${id}/${skillIndex}: the 替身`);
approx(g.hp, g.s.maxHp, 'full HP');
approx(g.s.maxHp, full * (1 + (g.def.traitBb.max_hp || 0)), 'her own max HP (no 替身 token) [ASSUMED]');
assert.equal(g.s.blockCnt, 0);
const [sub] = fx('substitute');
assert.ok(sub && fxForm(sub) === 'doll', 'the fx puts the model in its 替身 form');
approx(sub[4].dur, DOLL_SWITCH + 20, 'until the switch back');
h.run(DOLL_SWITCH + 19);
assert.equal(log.attacks, atk0, 'no normal attack as the 替身 (PRTS 特性备注)');
approx(g.skill.sp, sp, '阻回: the SP waits');
const e = h.enemy('enemy_dummy');
assert.ok(e.findBuff('ghost2:embrace'), '拥抱自我 slows');
assert.ok(h.hooksOf('damaged').some((c) => c.source === g && c.target === e && (c.dmg.tags || []).includes('embrace')), '…and deals arts damage');
h.run(1.05);
assert.ok(!g.trait.doll && g.form === null && g.trait.dollSwitch, 'switching back after 1 s + 20 s');
assert.ok(fxForm(fx('swap')[0]) === null, 'the fx gives the 本体 its model back');
assert.equal(g.s.blockCnt, 2);
approx(g.hp, g.s.maxHp, 'full HP again');
h.run(DOLL_SWITCH + 1.5);
assert.ok(log.attacks > atk0, 'the 本体 attacks again');
assert.ok(g.skill.sp > sp, 'SP again');
checkInvariants(h.b);
}
});
test('B: S2 生存的渴望 — a lethal hit during it leaves 1 HP (不死), its end switches her to the 替身 at once (no lethal HP loss, no knockout fx)', { skip }, () => {
for (const hit of [true, false]) {
const { h, g, lethal, fx } = setup({ sp: 999 });
assert.ok(h.runUntil(() => g.skill.active, 5), 'S2 cast');
h.run(1);
if (hit) {
lethal();
assert.ok(g.alive && !g.trait.doll && g.hp >= 1 && g.hp < 2, '不死: 1 HP, not the 替身');
}
const fatals = h.hooksOf('fatal').length;
assert.ok(h.runUntil(() => !g.skill.active, 30));
assert.ok(g.alive && g.trait.doll && g.form === 'doll', 'the 替身 when S2 ends');
approx(g.hp, g.s.maxHp, 'at full HP');
assert.equal(h.hooksOf('fatal').length, fatals, 'a switch, not a lethal HP loss');
assert.equal(fx('knockout').length, 0);
assert.equal(fx('substitute').length, 1);
h.run(DOLL_SWITCH + 20.05);
assert.ok(g.alive && !g.trait.doll, 'back to the 本体, alive');
checkInvariants(h.b);
}
});
test('C: a second lethal hit while she is the 替身 knocks her out: the death clip first, then the model form back; the redeploy is the 本体', { skip }, () => {
const { h, g, log, lethal } = setup();
h.run(1);
lethal();
h.run(DOLL_SWITCH / 2);
lethal();
assert.ok(g.alive && g.trait.doll, 'not during the switch animation (无敌)');
h.run(DOLL_SWITCH);
lethal();
assert.equal(g.alive, false, 'knocked out');
assert.ok(!g.trait.doll && g.form === null);
h.step();
const ev = h.events.filter((e) => (e[0] === 'die' && e[1] === g.id) || (e[0] === 'fx' && e[4]?.id === g.id && fxForm(e) !== undefined));
assert.deepEqual(ev.slice(-2).map((e) => (e[0] === 'die' ? 'die' : `${e[1]}:${fxForm(e)}`)), ['die', 'dollEnd:null'], 'its 替身 death clip plays, then the form resets');
assert.ok(h.b.redeploy(g, { free: true }), 'redeployed');
assert.ok(g.alive && !g.trait.doll && g.form === null && g.s.blockCnt === 2, 'the 本体');
const n = log.attacks;
h.run(2);
assert.ok(log.attacks > n, 'and she attacks');
checkInvariants(h.b);
});
test('D: SP near full or full when she switches ⇒ no skill cast as the 替身 (S2 DEFAULT, S1 技能范围); cast again as the 本体', { skip }, () => {
// S2 38 / 40: 阻回 holds it under the cost; after the switch back it fills and S2 is cast
{
const { h, g, log, lethal } = setup({ sp: 38 });
h.run(0.5);
lethal();
h.run(DOLL_SWITCH + 20 + DOLL_SWITCH + 5);
assert.deepEqual(log.castsAsDoll, [], 'no S2 as the 替身');
assert.ok(log.casts.length === 1 && log.casts[0] > 0.5 + 2 * DOLL_SWITCH + 20, 'S2 after the switch back');
assert.ok(g.alive);
}
// S1 (SKILL_RANGE) ready with no enemy around, then an enemy steps next to the 替身: no cast until she is back
{
const { h, g, log, lethal } = setup({ skillIndex: 0, sp: 999, enemy: false });
h.run(0.5);
assert.ok(g.skill.ready && !g.skill.active && log.casts.length === 0, 'S1 ready, nothing in its range');
lethal();
h.run(DOLL_SWITCH + 0.5);
h.spawn('enemy_dummy', { pos: [9, 6] });
h.run(15);
assert.deepEqual(log.castsAsDoll, [], 'no S1 as the 替身 (silenced with its disarm) [ASSUMED]');
assert.ok(g.skill.ready, 'still ready');
h.run(10);
assert.equal(log.casts.length, 1, 'cast once she is the 本体 again');
assert.equal(log.castsAsDoll.length, 0);
}
});
test('S1 / S3 running at a lethal hit end with the switch (清除自身一切Buff); the content hook switches her at once, once', { skip }, () => {
const { h, g, lethal } = setup({ skillIndex: 2, sp: 999 });
const full = g.base.maxHp * (1 + g.findBuff('ghost2:abyss').mods.hpPct); // 阿戈尔的深邃 on herself
assert.ok(h.runUntil(() => g.skill.active, 5), 'S3 cast');
assert.ok(g.s.maxHp > full * 2, 'S3 max HP +');
lethal();
assert.ok(g.trait.doll && !g.skill.active, 'S3 over');
assert.equal(h.hooksOf('skillEnd').at(-1).reason, 'substitute');
approx(g.s.maxHp, full, 'no S3 max HP on the 替身');
approx(g.hp, full, 'at its full HP');
const s2 = setup();
const c = s2.h.b.emit('dollSwitch', { unit: s2.g, reason: 'test', done: false });
assert.ok(c.done && s2.g.trait.doll && s2.g.form === 'doll');
assert.equal(s2.h.b.emit('dollSwitch', { unit: s2.g, reason: 'test', done: false }).done, false, 'already the 替身');
});
test('风丸: her 替身 still attacks (PRTS "<替身>状态下可对空"); the same 替身 form, switch and 阻回', { skip }, () => {
const id = 'chess_char_2_11_a';
const { h, g, log, lethal } = setup({ id, skillIndex: 0 });
h.run(1);
lethal();
assert.ok(g.trait.doll && g.form === 'doll' && g.s.flags.noSp);
h.run(DOLL_SWITCH + 5);
assert.ok(log.attacksAsDoll > 0, 'the 替身 attacks');
assert.ok(!g.s.flags.silence && !g.s.flags.disarm);
});
+4 -2
View File
@@ -253,14 +253,16 @@ test('librator module (玛恩纳 elite init_atk): +100 % ATK right after deploym
approx(n.unit('chess_char_5_19_a').s.atk, n.unit('chess_char_5_19_a').base.atk, 1e-6);
});
test('dollkeeper without a substitute token (归溟幽灵鲨) uses half its own HP, not 风丸\'s 纸偶', { skip: !hasGeneratedData() }, () => {
test('dollkeeper without a substitute token (归溟幽灵鲨) keeps its own max HP, not 风丸\'s 纸偶', { skip: !hasGeneratedData() }, () => {
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, units: [{ chessId: 'chess_char_5_13_a', row: 9, col: 5 }], content: 'none', autoFinish: false, timeLimit: 30 });
h.step();
const u = h.unit('chess_char_5_13_a');
const full = u.s.maxHp;
h.b.dealDamage(null, u, { amount: 1e7, type: 'true' });
assert.ok(u.alive && u.hasBuff('trait:substitute'));
approx(u.s.maxHp, full * 0.5, 1e-6);
// PRTS 分支特性信息 傀儡师 "重设自身生命至最大值"; no 替身 of hers in character_table [ASSUMED: her own max HP]
approx(u.s.maxHp, full, 1e-6);
approx(u.hp, full, 1e-6);
});
// ---------------------------------------------------------------------------------------------------------------
+24 -6
View File
@@ -159,21 +159,39 @@ test('geek loses HP over time; merchant drains DP and retreats when broke; charg
approx(h3.b.getPlayer('p1').dp, 13);
});
test('dollkeeper swaps to a substitute on fatal damage and swaps back after 20 s', () => {
test('dollkeeper: fatal damage ⇒ a 1 s switch, 20 s as the substitute (block 0, 阻回), the switch back; the substitute dies ⇒ the unit dies', () => {
const h = makeBattle({ defs: { chess: { t_dollkeeper: mk('dollkeeper', 'SPECIAL', { stats: { maxHp: 2000, blockCnt: 2 } }) } }, units: [{ chessId: 't_dollkeeper', row: 9, col: 5 }], content: 'none' });
h.step();
const u = h.unit('t_dollkeeper');
h.b.dealDamage(null, u, { amount: 1e5, type: 'true' });
assert.equal(u.alive, true, 'substitute instead of death');
assert.equal(u.s.blockCnt, 0);
assert.ok(u.trait.doll);
h.run(20.1);
assert.equal(u.trait.doll, false);
approx(u.hp, 2000);
assert.equal(u.s.blockCnt, 2);
assert.ok(u.trait.doll && u.trait.dollSwitch, 'the switch animation');
assert.equal(u.form, 'doll');
approx(u.hp, 2000); // no 替身 token: its own max HP
// the switch animation (PRTS 分支特性信息 傀儡师): 无敌, 不死 (a 流失 stops at 1 HP), 阻回, 眩晕 immunity
h.b.dealDamage(null, u, { amount: 1e5, type: 'true' });
approx(u.hp, 2000); // 无敌
assert.equal(h.b.applyStatus(u, 'stun', { duration: 5 }), false, '眩晕 immune');
h.b.loseHp(u, 1e5);
assert.ok(u.alive && u.trait.doll && u.hp >= 1, '不死');
u.hp = 2000;
h.run(1.05);
assert.ok(u.trait.doll && !u.trait.dollSwitch, 'fighting as the substitute');
h.run(19.9);
assert.ok(u.trait.doll && u.s.flags.noSp, 'the 20 s form, 阻回');
h.run(0.1);
assert.ok(!u.trait.doll && u.trait.dollSwitch && u.form === null, 'switching back');
assert.equal(u.s.blockCnt, 2, 'blocks again from the start of the switch back');
approx(u.hp, 2000);
h.run(1.05);
assert.ok(!u.trait.dollSwitch && !u.s.flags.noSp, 'the body again, no 阻回');
h.b.dealDamage(null, u, { amount: 1e5, type: 'true' });
h.run(1.05);
h.b.dealDamage(null, u, { amount: 1e5, type: 'true' });
assert.equal(u.alive, false, 'substitute dies ⇒ unit dies');
assert.ok(!u.trait.doll && u.form === null, 'the redeploy is the body again');
checkInvariants(h.b);
});
test('phalanx never attacks until its skill; librator ramps ATK while idle and resets on skill end', () => {