diff --git a/docs/ASSETS.md b/docs/ASSETS.md index 0211e77d..a723f2ab 100644 --- a/docs/ASSETS.md +++ b/docs/ASSETS.md @@ -221,9 +221,9 @@ A skill clip may also come from directional-only animations when a model has no The resolver's full precedence list is in the header of `tools/assets/anim-roles.mjs`. -The manifest roles describe an enemy's first form. Enemies whose skeleton holds another form's clip set get it from +The manifest roles describe a unit's first form. Units whose skeleton holds another form's clip set get it from `public/js/render/units.js FORMS` (keyed by Spine id, switched by the `form` of the sim's 'phase' / 'ember' / 'revive' -/ 'telegraph' / 'stone' fx — `shared/protocol.js fxForm`; no client stage drops these fx: the runner keeps them through +/ 'telegraph' / 'stone' / 'substitute' / 'swap' / 'dollEnd' fx — `shared/protocol.js fxForm`; no client stage drops these fx: the runner keeps them through catch-up frames and hidden tabs (`keepsState`), the game screen's pre-entry buffer (`keepEarly`) and the render engine's event queue (`render/interp.js isCosmeticEvent`) too — or, for a view built mid-battle, UnitInfo `form`, which `render/app.js renderInfo` passes to the view; a `change` clip plays once first, an `end` clip is timed from the fx's `dur` to finish as that state ends, keeping the @@ -236,6 +236,11 @@ stealth bit only while its 隐匿 is on: - the leaders' 重生: 锏 (`Revive1`, `Revive2` held, `Revive3`, then `B_*`), 扎罗 (`A_revive_1` / `_2` / `_3`, then `B_*`), “复仇者” (`Revive_Begin` / `_Loop` / `_End`), 杰斯顿 (`C1_Die`, then `C2_*`); - 守墓石像 (the statue on `Sleep` [ASSUMED by name], then the flyer's `*_2`). +- the 傀儡师 operators' <替身> (form `doll` of the sim's 'substitute' / 'swap' / 'dollEnd' fx, DESIGN §22.11; the `*_B` + clips draw the 替身's own slots and hide the 本体's): 归溟幽灵鲨 `Start_B` (it fades in), `Idle_B`, `Die_B` over its last + second (it breaks apart and fades), `Die_B_2` when it is knocked out (it collapses), and the 本体 back on `Start_2` (a + form's `leave` clip); 风丸 `Start_B`, `Idle_B` / `Attack_B` / `Die_B`, the 本体 back on `Start`. Their Back skeletons + (facing up) only have `Idle_B` (归溟幽灵鲨 also `Start_2`): the missing switch clips are skipped. Not mapped (clip names ambiguous): “自在”, “巨大的丑东西”, 主角阵营角色 and “余音” (`*_A` / `*_B`: which of its two forms is A is not known) keep their manifest clips. diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 6f042828..bb4847e9 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -212,7 +212,7 @@ b.on(event, fn, { priority=0, owner } ) / b.off(...) // hook bus (§5.4) ### 5.2 Units & stats -`Unit` fields (minimum): `id` (int, unique per battle), `side` ('ally'|'enemy'), `kind` ('op'|'token'|'enemy'|'device'), `defId`, `ownerId` (playerId; enemies: the field owner / source), `x, y`, `tileR, tileC` (ops), `dir` (allies: UP|RIGHT|DOWN|LEFT deploy direction, §3; `sim/dir.js`), `facing` (derived horizontal sign +1 / −1: LEFT ⇒ −1), `hp`, `alive`, `sp`, `buffs[]`, `statusFlags`, `blocking[]` (ops), `blockedBy` (enemies), `liveRangeGrid` (allies: the 攻击范围 the detail card shows — the running skill's grid, else the unit's own, plus `rangeExtend`; never a kit's target-selection grid, §21.16), `form` (enemies: the model form it is in — UnitInfo `form`, §21.4). +`Unit` fields (minimum): `id` (int, unique per battle), `side` ('ally'|'enemy'), `kind` ('op'|'token'|'enemy'|'device'), `defId`, `ownerId` (playerId; enemies: the field owner / source), `x, y`, `tileR, tileC` (ops), `dir` (allies: UP|RIGHT|DOWN|LEFT deploy direction, §3; `sim/dir.js`), `facing` (derived horizontal sign +1 / −1: LEFT ⇒ −1), `hp`, `alive`, `sp`, `buffs[]`, `statusFlags`, `blocking[]` (ops), `blockedBy` (enemies), `liveRangeGrid` (allies: the 攻击范围 the detail card shows — the running skill's grid, else the unit's own, plus `rangeExtend`; never a kit's target-selection grid, §21.16), `form` (the model form it is in — an enemy's mode, §21.4, a 傀儡师's 替身 `'doll'`, §22.11 — UnitInfo `form`). Base stats (from data): `maxHp, atk, def, res` (0–100), `aspd` (100 base), `bat` (base attack time, s), `blockCnt`, `rangeGrid` (ops) / `rangeRadius` (enemies, 0 = melee), `moveSpeed`, `massLevel`, `weight`, `lpr` (enemies), `dmgType` ('phys'|'arts'|'heal'|'true'), `motion` ('WALK'|'FLY'), `cost`, `respawnTime`, `spRecovery`. @@ -420,11 +420,11 @@ S→C: - `m.field` — field meta when watching starts: `{ fieldId, kind, rect, stageId, units: [UnitInfo] }` (the units on the field, knocked-out operators waiting to redeploy included — `Battle.fieldMeta`, §18.3) - NOTE: game time in `b.snap`/`b.ev` is carried as **`gt`** (the frame's `t` is the message type). - `b.snap` — `{ fieldId, gt, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]] , dp, killed, total, down?, elem? }` 20 Hz. Optional, only when non-empty (§18.3): `down: [[id, respawnAt (game s), respawnTime (s), state, row, col]]` — knocked-out operators waiting to redeploy, `state` 0 counting | 1 timer done, waiting for DP | 2 timer done, its tile taken — a safeguard, no ally deploys there (sim/constants.js `DOWN_STATE`); `row, col` = the tile it lies on and comes back on (§21.24); `elem: [[id, element, fill, cooldownEnd (game s), cooldown (s)]]` — the one gauge a unit shows: the fullest (ties by the official element id 神经 < 侵蚀 < 灼燃 < 凋亡), fill with 2 decimals rounded DOWN and kept at 0.01–0.99 outside a burst (§20.8); during a 爆发冷却 the bursting element with fill 1 and the cooldown numbers (else 0, 0) -- `b.ev` — `{ fieldId, ev: [ ... ] }` event tuples: `['spawn', UnitInfo]`, `['atk', srcId, tgtId, projKind]`, `['dmg', tgtId, amount, type]`, `['heal', tgtId, amount]`, `['skill', id, on:0|1]`, `['die', id, reason]` (reason `'killed'`, `'retreat'`, `'expired'` …; only `'killed'` plays an operator's knock-down sound), `['status', id, key, on]`, `['fx', kind, x, y, extra]` (`'hitCap' {id, n}` = a cancelled 限伤 hit, drawn as nothing, §20.12; `extra.form` = the enemy's model form from then on — `shared/protocol.js fxForm`, state like a spawn: never dropped by the client, §21.4), `['layer', playerId, bondId, n]`, `['bounty', playerId, coins]`, `['deploy', id]` +- `b.ev` — `{ fieldId, ev: [ ... ] }` event tuples: `['spawn', UnitInfo]`, `['atk', srcId, tgtId, projKind]`, `['dmg', tgtId, amount, type]`, `['heal', tgtId, amount]`, `['skill', id, on:0|1]`, `['die', id, reason]` (reason `'killed'`, `'retreat'`, `'expired'` …; only `'killed'` plays an operator's knock-down sound), `['status', id, key, on]`, `['fx', kind, x, y, extra]` (`'hitCap' {id, n}` = a cancelled 限伤 hit, drawn as nothing, §20.12; `extra.form` = the unit's model form from then on (an enemy's mode, a 傀儡师's 替身 §22.11) — `shared/protocol.js fxForm`, state like a spawn: never dropped by the client, §21.4), `['layer', playerId, bondId, n]`, `['bounty', playerId, coins]`, `['deploy', id]` - `m.toast {kind, text}` · `m.ticker {text, id, type, priority, playerId}` (the strip plays the highest priority first, §21.10) · `m.emote {playerId, id}` · `m.result {...}` - `m.unitStats {seq, round, units: [unitStatsEntry]}` — the answer to `g.unitStats` (`seq` echoed): every own board operator / summon by uid with `{id, uid, defId, hp, alive, maxHp, atk, def, res, interval, blockCnt, moveSpeed, range?, base: {…same}}` (shared/protocol.js `unitStatsEntry`; `range` = an ally's live range grid, facing RIGHT, never a target-selection grid, §21.16) as its next battle starts (§18.5) -`UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, skillIndex?, moduleId?, items?, form? }` (`dir` = the ally's deploy direction; the renderer picks the model (Back for UP, mirrored for LEFT) and the ground wedge from it; `skillIndex` = the ally's equipped skill (§16): the renderer plays that skill's Spine clip (`anims.skills[i]`) and the audio its own ON_SKILL_START sound; `moduleId` = an elite ally's equipped module (uniEquipId | 'none'); `items` = an ally operator's equipped item ids (absent without any; never on enemies or summons): the bond popup lists a 变形同构体 wearer from them and the detail card shows its 同构 chip and 装备 (§21.11); all three also on the prep-scouting `m.field` units (`Match.prepFieldMeta`), so a teammate's unit shows its owner's loadout in the detail card; `form` = an enemy's current model form (§21.4, a view built mid-battle starts in it)). +`UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, skillIndex?, moduleId?, items?, form? }` (`dir` = the ally's deploy direction; the renderer picks the model (Back for UP, mirrored for LEFT) and the ground wedge from it; `skillIndex` = the ally's equipped skill (§16): the renderer plays that skill's Spine clip (`anims.skills[i]`) and the audio its own ON_SKILL_START sound; `moduleId` = an elite ally's equipped module (uniEquipId | 'none'); `items` = an ally operator's equipped item ids (absent without any; never on enemies or summons): the bond popup lists a 变形同构体 wearer from them and the detail card shows its 同构 chip and 装备 (§21.11); all three also on the prep-scouting `m.field` units (`Match.prepFieldMeta`), so a teammate's unit shows its owner's loadout in the detail card; `form` = the unit's current model form (an enemy's mode §21.4, a 傀儡师's 替身 §22.11; a view built mid-battle starts in it)). ### 8.3 Exact view shapes (normative for match ⇄ UI) diff --git a/docs/SIM.md b/docs/SIM.md index b8ba089b..bca5db18 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -314,7 +314,10 @@ instance or a block starts a 2 s change). Each form change goes through `setForm unit keeps it (`e.form`, published as UnitInfo `form`, so a view built mid-battle from `fieldMeta()` — a watched teammate's field, 联防 observers, a reconnect — starts in it: `render/app.js renderInfo` hands it to the view) and the fx announces it as its `form`: a 'phase' fx (crawl, translator_* — also its `kind`), 'ember' ('husk'), 'revive' -('revived' / 'form2' / 'fly'), 'telegraph' ('reborn') or 'stone' ('stone') — render/units.js FORMS. Barrier / charge +('revived' / 'form2' / 'fly'), 'telegraph' ('reborn') or 'stone' ('stone') — render/units.js FORMS. An operator has one +form too: a 傀儡师's <替身> (`professions.js installDollkeeper`: `u.form` 'doll' from the switch to it — fx 'substitute' +`{ form: 'doll', dur }` — until the switch back, fx 'swap' `{ form: null }`, or its knock-out, fx 'dollEnd' `{ form: null }` +after the 'die' event). Barrier / charge 'phase' kinds carry no `form` and are no forms. A form is state, not decoration (b.snap tuples carry none): the client keeps every fx with a `form` (`shared/protocol.js fxForm`) where it drops other events — the runner's catch-up frames and its hidden-tab backlog (`battle/runner.js keepsState`; a backlog past `HELD_MAX` drops only superseded status / @@ -675,6 +678,7 @@ registration order. `battle.off(handle)` / `battle.off(name, fn)` / `battle.offO | `damaged` | `{ source, target, amount, type, dmg, credit }` | after application (`amount` may be 0 when shielded); element fills too (with their source); 无来源: `source` null, `credit` set | | `heal` | `{ source, target, amount, opts }` | mutable `amount` | | `fatal` | `{ unit, source, credit, dmg, amount, prevented }` | HP would reach 0 — set `prevented` (substitutes, kit savers, 不死 / 复活 items, 埃芒加德; 不屈 is a `death` hook). Fired by every HP loss of a unit without a boss pool — hits of any type, element bursts, 无来源 damage, `loseHp` 流失. Order: kits' own savers (10 … −60) → items' 不死 (坚固维式重锤 — once per deployment: `items/battle.js deploymentOf`, a key every deploy changes and an in-place 复活 changes too; one battle-level hook holds the running windows (`holdsUndying`), so a window outlasts a lend, DESIGN §21.21 — `PRIO_REVIVE` −100; substitutes −100) → items' 复活 (M3茧甲, `PRIO_RESPAWN` −101: PRTS "复活" acts on a knock-out, which a 不死 prevents) → 埃芒加德 (−110); both 复活 revive in place and call `revivedInPlace` (a new deployment for the lock) | +| `dollSwitch` | `{ unit, reason, done }` | content switches a 傀儡师 to its <替身> now (归溟幽灵鲨 S2 "技能结束后立刻切换为<替身>": no lethal HP loss); its trait does it unless it already is one or is not on the field, and sets `done` | | `kill` | `{ killer, victim }` | victim HP reached 0 (a handler may revive by restoring HP) | | `death` | `{ unit, reason:'killed'|'leak'|'retreat'|'merchant'|'expired'|'forcedExit', killer }` | unit removed (`'forcedExit'`: an operator entering 联防 knocked out, §1.1) | | `skillStart` / `skillEnd` | `{ unit, skill, reason }` | mutate `skill.ammoLeft` / `skill.timeLeft` in skillStart | @@ -1094,7 +1098,7 @@ table: `hitSleep` (targets and damages sleeping enemies — "可以攻击沉睡 | agent / hookmaster | can hit FLY (ranged reach); hook displacement comes from skills (generic: `force`) | | bearer | block 0 while the skill is active | | alchemist | ranged lob, can hit FLY | -| dollkeeper | fatal damage ⇒ substitute for 20 s (bb duration): block 0, doll HP (its own substitute token's stats, else 50 % max HP: 归溟幽灵鲨); swaps back at full HP; dies if the doll dies | +| dollkeeper | fatal damage (no 不死) ⇒ a 1 s switch animation [ASSUMED length] (无敌, 不死, 阻回, 禁疗, 孤立, 缴械, 眩晕 / 冻结 / 睡眠 immune; ends the running skill and removes the statuses), then the <替身> for 20 s (bb duration): block 0 from the switch on, 阻回, HP = its own 替身 token's (风丸 纸偶), else its own max HP (归溟幽灵鲨 [ASSUMED]); then the switch back (the same animation) at full HP; dies if the 替身 dies. Kit flag `dollNoAttack` (归溟幽灵鲨): the 替身 makes no normal attack and casts no skill. Hook `dollSwitch` switches it at once (归溟幽灵鲨 S2's end). Model form `'doll'` (PRTS 分支特性信息 傀儡师, DESIGN §22.11) | | geek | loses 1–3 % max HP per second (bb hp_ratio), never lethal on its own | | merchant | −3 DP every 3 s (bb cost/interval); retreats when DP runs out | | skywalker | can block FLY enemies (蒂比's kit: only while airborne — 起飞, flag `liftoff`, which also releases the ground enemies she blocked) | @@ -1122,9 +1126,9 @@ Unknown subprofessions fall back to the profession default (test `professions.te e.g. `mortar` for 帝国炮火先兆者, which the renderer does not draw — its fx `bombardShell` is the shell), `['dmg', tgt, amount, type]` (`phys|arts|true|burn|neural|necrosis|apoptosis`), `['heal', tgt, amount]`, `['skill', id, 1|0]`, `['die', id, reason]`, `['leak', id]`, `['status', id, key, 1|0]`, `['fx', kind, x, y, extra]` (`hitCap` `{ id, n }`: a leader's hit cancelled by 限伤 — the renderer draws nothing; - `extra.form` = the enemy's model form from then on, `content/enemies.js setForm` / `shared/protocol.js fxForm`), + `extra.form` = the unit's model form from then on — an enemy's `content/enemies.js setForm`, a 傀儡师's 替身 — `shared/protocol.js fxForm`), `['layer', playerId, bondId, n]` (n = the layers actually added, capped at 999), `['bounty', playerId, coins]`. -- `UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, motion?, boss?, uid?, form?, skillIndex?, moduleId?, items? }` (`skillIndex`: an ally's equipped skill, DESIGN §16; `form`: an enemy's current model form — `content/enemies.js setForm`: 掠海漂移体 `'crawl'`, 暴鸰 `'bombed'` after its drop, 转译基底·α's forms … — so a view built mid-battle from `fieldMeta()` starts on that clip set; `items`: an ally operator's equipped item ids — a 变形同构体 wearer is a member of the bond it grants on the client too, the bond popup and the detail card's chips) +- `UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, motion?, boss?, uid?, form?, skillIndex?, moduleId?, items? }` (`skillIndex`: an ally's equipped skill, DESIGN §16; `form`: the unit's current model form — `content/enemies.js setForm`: 掠海漂移体 `'crawl'`, 暴鸰 `'bombed'` after its drop, 转译基底·α's forms …; a 傀儡师 fighting as its 替身 `'doll'` — so a view built mid-battle from `fieldMeta()` starts on that clip set; `items`: an ally operator's equipped item ids — a 变形同构体 wearer is a member of the bond it grants on the client too, the bond popup and the detail card's chips) (`dir` = the unit direction, allies meaningful, enemies 'RIGHT'; `facing` = its horizontal sign for sprite flipping) (`spine`/`avatar` are asset ids from data). - flags: UF bits (blocked 1, stunned 2, frozen 4, stealth 8 — 隐匿 (an enemy's only while not blocked / revealed) or an ally's 迷彩 — skill 16, shield 32, invuln 64, cold 128, sleep 256, flying 512); diff --git a/public/js/render/fx.js b/public/js/render/fx.js index f0855de9..bde545d1 100644 --- a/public/js/render/fx.js +++ b/public/js/render/fx.js @@ -184,6 +184,9 @@ export const FX_KINDS = Object.freeze({ appear: { a: 'summon', c: 0xb36bff }, copy: { a: 'summon', c: 0xd8b0ff }, manifoldCopy: { a: 'summon', c: 0xd8b0ff }, split: { a: 'summon', c: 0xff9a6a }, disappear: { a: 'vanish', c: 0xb36bff }, stealth: { a: 'vanish', c: 0x8fa0b0 }, camouflage: { a: 'vanish', c: 0x8fb08f }, phase: { a: 'vanish', c: 0xb36bff }, substitute: { a: 'vanish', c: 0xd8b0ff }, swap: { a: 'blink', c: 0xd8b0ff }, blink: { a: 'blink', c: 0xb36bff }, teleport: { a: 'blink', c: 0xb36bff }, + // a dollkeeper knocked out as its <替身> (sim professions.js): only its model form goes back to the 本体 for the + // redeploy — the 替身's death clip already shows the knock-out, nothing is drawn + dollEnd: { a: 'none', c: 0xd8b0ff }, ulpiaReturn: { a: 'blink', c: 0x9ff0dc }, manifoldSplit: { a: 'blink', c: 0xd8b0ff }, // displacement pull: { a: 'move', c: 0x9fd4ff }, push: { a: 'move', c: 0xffd9a0 }, displace: { a: 'move', c: 0xd0c0a0 }, lure: { a: 'move', c: 0xffb3ec }, diff --git a/public/js/render/spine.js b/public/js/render/spine.js index 24067e31..ea78f0ca 100644 --- a/public/js/render/spine.js +++ b/public/js/render/spine.js @@ -15,7 +15,7 @@ // die() die clip once (callers fade out afterwards); a skeleton without one holds its // idle clip's first frame (GitHub issue #25: the attack loop went on) // stunned (setBase('stun')) stun clip, or the current track frozen at timeScale 0 -// setForm(roles, change, end) another clip set of the skeleton (an enemy's mode), after a change clip +// setForm(roles, change, end) another clip set of the skeleton (an enemy's mode, a 傀儡师's 替身), after a change clip // (no attack cuts the change clip short); `end` = { clip, in, roles? }: a // closing clip timed to end `in` s from now (a 重生's last clip ends with the // 重生), landing in `roles` @@ -122,18 +122,22 @@ export class SpineActor { setSkillIndex(index) { const anims = this.entry?.anims || {}; const clip = Number.isInteger(index) && anims.skills ? anims.skills[String(index)] : null; - this.roles = clip ? { ...anims, skill: clip } : anims; + this.roles = this.baseRoles = clip ? { ...anims, skill: clip } : anims; } + /** The unit's own roles: the manifest's with its equipped skill's clip (setSkillIndex) — what a form ends in. */ + _baseRoles() { return this.baseRoles || this.entry?.anims || {}; } + /** * Another clip set of the same skeleton — an enemy's mode (render/units.js FORMS: 掠海漂移体's 爬行模式 plays its *_02 - * clips): `roles` override the manifest roles (null = back to them); `change` = a transition clip played once first - * (also while stunned: the pose it ends in is the one a stun then holds). `end` = { clip, in, roles? } (game s): a - * closing clip played the same way so that it ends `in` s from now, landing in `roles` — a leader's 重生 ends on its - * last clip while the sim still holds it, and the next form starts on its own clips. + * clips), a 傀儡师's 替身: `roles` override the unit's own roles (null = back to them — the equipped skill's clip + * included); `change` = a transition clip played once first (also while stunned: the pose it ends in is the one a stun + * then holds). `end` = { clip, in, roles? } (game s): a closing clip played the same way so that it ends `in` s from + * now, landing in `roles` — a leader's 重生 ends on its last clip while the sim still holds it, and the next form + * starts on its own clips. */ setForm(roles, change = null, end = null) { - const anims = this.entry?.anims || {}; + const anims = this._baseRoles(); this.roles = roles ? { ...anims, ...roles } : anims; this.endClip = null; if (this.dead) return; @@ -391,7 +395,7 @@ export class SpineActor { if (this.endClip && this.clock >= this.endAt) { const clip = this.endClip; this.endClip = null; - if (this.endRoles) this.roles = { ...(this.entry?.anims || {}), ...this.endRoles }; + if (this.endRoles) this.roles = { ...this._baseRoles(), ...this.endRoles }; if (!this.dead) this._change(clip); } if (this.windUntil != null && this.clock >= this.windUntil) { diff --git a/public/js/render/style.js b/public/js/render/style.js index 8729b145..fb791be3 100644 --- a/public/js/render/style.js +++ b/public/js/render/style.js @@ -125,6 +125,8 @@ export const STATUS_ICON = Object.freeze({ silence: 'silence', slow: 'slow', sluggish: 'slow', bind: 'bind', fear: 'fear', tremble: 'fear', weaken: 'weaken', levitate: 'levitate', taunt: 'taunt', defDown: 'weaken', resDown: 'weaken', aspdDown: 'slow', disarm: 'silence', burn: 'burn', burnBurst: 'burn', neural: 'neural', neuralBurst: 'neural', necrosis: 'necrosis', apoptosis: 'necrosis', + // a 傀儡师 fighting as its <替身> (sim professions.js buff 'trait:substitute', the 20 s form) + substitute: 'doll', }); /** Keyword fallbacks for namespaced / content status keys ('ab:frost', 'reed2:scorch', 'skill:shotst_shred' …). */ diff --git a/public/js/render/textures.js b/public/js/render/textures.js index 725f2f6e..d7d2c7b4 100644 --- a/public/js/render/textures.js +++ b/public/js/render/textures.js @@ -835,7 +835,7 @@ export function fxAtlas() { return _fx; } -export const STATUS_KEYS = ['stun', 'freeze', 'cold', 'stealth', 'shield', 'fragile', 'sleep', 'invuln', 'silence', 'slow', 'bind', 'fear', 'weaken', 'levitate', 'taunt', 'burn', 'neural', 'necrosis', 'blocked', 'skill']; +export const STATUS_KEYS = ['stun', 'freeze', 'cold', 'stealth', 'shield', 'fragile', 'sleep', 'invuln', 'silence', 'slow', 'bind', 'fear', 'weaken', 'levitate', 'taunt', 'burn', 'neural', 'necrosis', 'blocked', 'skill', 'doll']; function drawStatusIcon(c, key, x, y, s) { const cx = x + s / 2, cy = y + s / 2; @@ -930,6 +930,17 @@ function drawStatusIcon(c, key, x, y, s) { c.fillStyle = '#ffe066'; star(c, cx, cy, 10, 4.5); break; } + case 'doll': { + // a 傀儡师's <替身>: a puppet hanging from two strings + disc('rgba(40,25,55,0.92)', '#d8b0ff'); + c.strokeStyle = '#f0dcff'; c.lineWidth = 1.2; + c.beginPath(); c.moveTo(cx - 6, cy - 12); c.lineTo(cx - 6, cy - 1); c.moveTo(cx + 6, cy - 12); c.lineTo(cx + 6, cy - 1); c.stroke(); + c.fillStyle = '#f0dcff'; + c.beginPath(); c.arc(cx, cy - 5, 3.2, 0, Math.PI * 2); c.fill(); + c.beginPath(); c.moveTo(cx - 6, cy - 1); c.lineTo(cx + 6, cy - 1); c.lineTo(cx + 3, cy + 5); c.lineTo(cx + 5, cy + 11); c.lineTo(cx + 1.5, cy + 11); + c.lineTo(cx, cy + 6); c.lineTo(cx - 1.5, cy + 11); c.lineTo(cx - 5, cy + 11); c.lineTo(cx - 3, cy + 5); c.closePath(); c.fill(); + break; + } default: disc('rgba(30,30,30,0.9)', '#ccc'); } } diff --git a/public/js/render/units.js b/public/js/render/units.js index 3cd7f78c..6feb58dc 100644 --- a/public/js/render/units.js +++ b/public/js/render/units.js @@ -157,6 +157,13 @@ export const EL_BAR = Object.freeze({ icon: 0.15, min: 8, max: 15, enemy: 0.8, g * clip (`end`) is timed from the 重生's `dur` (the 'telegraph' fx) to end with it, so the second form walks and * attacks on its own clips at once (a view that missed the timing — built mid-重生 — skips the closing clip); * - 守墓石像 (forms 'stone' → 'fly'): the statue on Sleep [ASSUMED by name], then the flyer's *_2 clips. + * - the 傀儡师 operators' <替身> (sim professions.js installDollkeeper: form 'doll' from the start of the switch to it + * until the switch back starts, GitHub issue #44): the skeletons draw it on their *_B clips (their own slots — the + * 本体's are hidden). 归溟幽灵鲨: Start_B fades it in (the 1 s switch), Idle_B (it never attacks), Die_B breaks it + * apart over its last second (`end`, timed from the 'substitute' fx's `dur`), the 本体 comes back on Start_2 (`leave`: + * played when the form ends); knocked out as the 替身 it collapses on Die_B_2 and stays down so. 风丸: Start_B, then + * Idle_B / Attack_B (her 替身 attacks), Die_B; the 本体 comes back on Start. A Back skeleton (facing UP) only has + * Idle_B (归溟幽灵鲨 also Start_2): the switch clips it lacks are skipped. * A kind without a clip set of this skeleton (barriers, charges, …) changes nothing. 吉兆飞鳞's 晕眩模式 is its Stun clip. */ const loop = (name, via = null) => Object.freeze(via ? { begin: null, loop: name, end: null, via } : { begin: null, loop: name, end: null }); @@ -180,7 +187,17 @@ const STATUE = Object.freeze({ fly: Object.freeze({ change: null, roles: clipSet('Idle_2', 'Move_2', 'Die_2', 'Attack_2') }), }); const JAKILL2 = clipSet('C2_Idle', 'C2_Move', 'C2_Die', 'C2_Attack'); +/** A 傀儡师's 替身 roles: idle `idle`, death `die`, attack `attack` (null: none), no skill clip of its own. */ +const dollRoles = (idle, die, attack = null) => Object.freeze({ + idle, deploy: idle, die, attack: attack ? Object.freeze({ begin: null, loop: attack, end: null }) : null, attackDown: null, skill: null, +}); export const FORMS = Object.freeze({ + char_1023_ghost2: Object.freeze({ + doll: Object.freeze({ change: 'Start_B', end: 'Die_B', leave: 'Start_2', roles: dollRoles('Idle_B', 'Die_B_2') }), + }), + char_4016_kazema: Object.freeze({ + doll: Object.freeze({ change: 'Start_B', leave: 'Start', roles: dollRoles('Idle_B', 'Die_B', 'Attack_B') }), + }), enemy_1040_bombd: Object.freeze({ bombed: Object.freeze({ roles: Object.freeze({ @@ -487,14 +504,15 @@ export class UnitView { * The unit changed mode (the `form` of a sim fx — shared/protocol.js fxForm; `fx` = that fx's extra): the mode's clip * set (FORMS) after its change clip, and its closing clip (`end`, landing in the `next` form's clips) timed to end * `fx.dur` game s later — the unit is still in this mode while it plays (an ember can be beaten in its last second), - * so this mode's death clip stays until the next mode's fx; null goes back to the manifest clips; a kind this skeleton - * has no clip set for (an arts barrier, a broken charge …) changes nothing. Kept for a model built later. + * so this mode's death clip stays until the next mode's fx; null goes back to the manifest clips (through the old + * mode's `leave` clip when it has one: a 替身's 本体 coming back); a kind this skeleton has no clip set for (an arts + * barrier, a broken charge …) changes nothing. Kept for a model built later. */ setForm(kind, fx = null) { const k = typeof kind === 'string' ? kind : null; if (k === this.form) return; if (k && !FORMS[this.info.spine || this.info.defId]?.[k]) return; - const had = !!this._formSpec(); + const prev = this._formSpec(); this.form = k; this.info.form = k; const f = this._formSpec(); @@ -506,7 +524,7 @@ export class UnitView { const late = fx && Number(fx.late) > 0 ? Number(fx.late) : 0; const change = f && f.change && !(late > 0 && late >= (this.actor.dur?.(f.change) ?? Infinity)) ? f.change : null; if (f) this.actor.setForm(f.roles, change, f.end && dur > 0 ? { clip: f.end, in: dur, roles: next } : null); - else if (had) this.actor.setForm(null); + else if (prev) this.actor.setForm(null, prev.leave && !(late > 0 && late >= (this.actor.dur?.(prev.leave) ?? Infinity)) ? prev.leave : null); } // ---- HUD ------------------------------------------------------------------------------------------------- diff --git a/server/sim/content/kits/tier5.js b/server/sim/content/kits/tier5.js index 97d2822a..f6fd8cb9 100644 --- a/server/sim/content/kits/tier5.js +++ b/server/sim/content/kits/tier5.js @@ -29,7 +29,7 @@ // every MANUAL skill; AUTO skills never take a class row). // - fx kinds emitted (battle.fx(kind, {x, y, id, …})): 'aoe' {r, skill}, 'healAoe' {r}, 'summon' {token}, 'anchor' // {fromX, fromY, r}, 'teleport', 'zone' {r, duration}, 'iceSpike' {r}, 'extraAttack', 'downed', 'revive' {r}, -// 'overload', 'ember', 'bloodBattle', 'reborn', 'candle', 'wake' {scale}, 'mote', 'hpShare', 'knockout', 'crit', +// 'overload', 'ember', 'bloodBattle', 'reborn', 'candle', 'wake' {scale}, 'mote', 'hpShare', 'crit', // 'meltdown', 'soul', 'sleepGuard', 'weightless'; engine kinds used: 'dodge'. import { COLS } from '../../constants.js'; @@ -1642,10 +1642,13 @@ const KITS = { }, // --------------------------------------------------------------------------------------------------------------- - // 归溟幽灵鲨 — dollkeeper. S2 生存的渴望 (15/17 s): ATK/ASPD +, HP never below 1; afterwards she counts as knocked out - // (→ substitute, or death if already one). T1 拥抱自我: the substitute slows nearby enemies −40 % and deals 40 % ATK - // arts/s to them (PRTS 备注 "伤害与减速不可对空": ground enemies only). T2 阿戈尔的深邃: Abyssal Hunters in the team - // max HP +20 %. Module (elite): substitute ATK +15 %. + // 归溟幽灵鲨 — dollkeeper (professions.js installDollkeeper: the 替身 form, its switch animations, 阻回). Her <替身> makes + // no normal attack (PRTS 特性备注 "<替身>不进行普通攻击") and so casts no skill (kit trait `dollNoAttack`). + // S2 生存的渴望 (15/17 s): ATK/ASPD +, HP never below 1 (PRTS 备注: 不死 — a lethal hit does not switch her meanwhile); + // when it ends she switches to the 替身 at once (PRTS 修正 "技能结束后立刻切换为<替身>", the text's 视为被击倒: no lethal + // HP loss, so no 不死 / 复活 effect takes it). T1 拥抱自我: the 替身 slows nearby enemies −40 % and deals 40 % ATK + // arts/s to them (PRTS 备注 "伤害与减速不可对空": ground enemies only) — once it fights (not during its switch + // animation). T2 阿戈尔的深邃: Abyssal Hunters in the team max HP +20 %. Module (elite): substitute ATK +15 %. // S1 生存的技巧 (duration): swaps HP ratios with the other operator of the skill area (周围) with the lowest HP ratio, // ATK +. S3 生存的重压 (duration): BAT +1 s, hits every blocked enemy, ATK +, max HP +; an attacked enemy whose HP ratio // is ≥ hers takes attack@atk_scale_ex × ATK phys more, otherwise she loses attack@hp_ratio of her max HP. @@ -1685,21 +1688,23 @@ const KITS = { skill: { kind: 'duration', mods: mods({ atkPct: num(bb.atk), aspd: num(bb.attack_speed) }), + // "技能结束后立刻切换为<替身>": nothing when the skill ended with her (death) or by her switch to the 替身, or she + // already is one onEnd({ battle, unit, reason }) { - if (reason === 'death' || !unit.alive || !unit.deployed) return; - battle.fx('knockout', { x: unit.x, y: unit.y, id: unit.id }); - battle.loseHp(unit, unit.hp + 1, { source: unit }); + if (reason === 'death' || reason === 'substitute' || !unit.alive || !unit.deployed || unit.trait.doll) return; + battle.emit('dollSwitch', { unit, reason: 'skill', done: false }); }, }, + trait: { dollNoAttack: true }, talents: [ - { install(battle, unit) { // 拥抱自我 + { install(battle, unit) { // 拥抱自我 (the 替身 fighting: not during a switch animation) const slow = num(t0.move_speed), scale = num(t0.atk_scale); whileOn(battle, unit, AURA_IV, () => { - if (!unit.trait.doll || !slow) return; + if (!unit.trait.doll || unit.trait.dollSwitch || !slow) return; for (const e of battle.unitsInGrid(unit, aroundGrid, { side: 'enemy' })) if (!e.isFlying) battle.addBuff(e, { key: 'ghost2:embrace', duration: AURA_DUR, mods: { moveMul: Math.max(0, 1 + slow) } }); }); whileOn(battle, unit, 1, () => { - if (!unit.trait.doll || !(scale > 0)) return; + if (!unit.trait.doll || unit.trait.dollSwitch || !(scale > 0)) return; for (const e of battle.unitsInGrid(unit, aroundGrid, { side: 'enemy' })) if (!e.isFlying) battle.dealDamage(unit, e, { amount: unit.s.atk * scale, type: 'arts', tags: ['talent', 'embrace'] }); }); } }, diff --git a/server/sim/professions.js b/server/sim/professions.js index 0c23d796..9ea79718 100644 --- a/server/sim/professions.js +++ b/server/sim/professions.js @@ -22,6 +22,7 @@ // dmgMul(battle, unit, target) → number afterHit(battle, unit, target, {dealt,x,y}) // canAttack(battle, unit) → bool afterAttack(battle, unit, targets) // hitsFn(battle, unit) → n install(battle, unit) — per-unit hooks, called once at setup +// dollNoAttack bool (傀儡师: its <替身> makes no normal attack and casts no skill — 归溟幽灵鲨, kit trait) // tb — the unit's trait blackboard (data `trait.bb`), used for tunables (module upgrades included on elites) // Behaviour per subprofession is documented in docs/SIM.md §Professions. Front / side tests use the unit's direction // (`dir`, sim/dir.js): offsets are compared in its facing-RIGHT frame. @@ -90,33 +91,96 @@ const installCharger = (battle, unit) => { }, { owner: unit }); }; +/** + * 傀儡师 (dollkeeper) trait "受到致命伤时不撤退,切换成<替身>作战(替身阻挡数为0),持续20秒后自身再次替换<替身>" with the + * branch rules of PRTS 分支特性信息 傀儡师: a lethal hit on the <本体> (one it survives with 不死 does not count) starts a + * switch animation, after which the operator fights as its <替身> for the trait's 20 s (bb duration) holding 阻回; then + * it switches back the same way. A switch animation clears the operator's Buffs and resets its HP to the max of the form + * it switches to; during one the operator holds 不死, 无敌, 阻回, 禁疗, 孤立, 强制缴械 and is immune to 眩晕 / 冻结 / 睡眠. + * The 替身 blocks nothing from the start of the switch to it until the switch back starts; a lethal hit on it knocks the + * operator out. Content may switch the operator at once (hook `dollSwitch` { unit, reason, done }: 归溟幽灵鲨 S2 "技能结束 + * 后立刻切换为<替身>"). A kit's `dollNoAttack` profile flag (归溟幽灵鲨, PRTS 特性备注 "<替身>不进行普通攻击") disarms the 替身 + * and keeps it from casting (the basic 技能策略 casts on an attack the 替身 never makes; [ASSUMED] every other rule — S1's + * 技能范围 — likewise); 风丸's 替身 attacks (PRTS: "<替身>状态下可对空"). + * The form is the unit's model state (`unit.form` 'doll', snapshot.js unitInfo; fx 'substitute' / 'swap' / 'dollEnd' with + * `form`, shared/protocol.js fxForm): the client draws the 替身 on the skeleton's *_B clips (render/units.js FORMS). + * [ASSUMED]: a switch animation lasts DOLL_SWITCH (the skeletons' 1 s Start_B — the 替身 appearing — and Start_2 — the + * 本体 back); "清除自身一切Buff" ends the running skill and removes the statuses (buffs with a catalogue `status`) — the + * remake's bond / item / talent effects are buffs too and stay; a dollkeeper without a 替身 token of its own (归溟幽灵鲨) keeps + * its own max HP (PRTS: HP "重设…至最大值"; character_table has no 替身 of hers, and 风丸's 纸偶 token has her HP). + */ +export const DOLL_SWITCH = 1; +const DOLL_SWITCH_IMMUNE = new Set(['stun', 'freeze', 'sleep']); + const installDollkeeper = (battle, unit) => { - // the substitute's HP comes from the unit's own substitute token (风丸 纸偶) for its selected skill / module - // (DESIGN §16); a dollkeeper without one (归溟幽灵鲨) must not borrow another operator's token stats → 50 % of its - // own max HP - const dollHp = () => { + // the 替身's max HP comes from the unit's own 替身 token (风丸 纸偶) for its selected skill / module (DESIGN §16); a + // dollkeeper without one (归溟幽灵鲨) must not borrow another operator's token stats: its own max HP (header) + const dollHpMul = () => { const tokId = (unit.def.tokens || []).map((t) => (typeof t === 'string' ? t : t?.tokenId)).find((t) => t && /shadow|doll/.test(t)); const tok = tokId ? battle.data.getToken?.(tokId, unit.defId, unit.def?.loadout ?? null) : null; - return tok && tok.stats.maxHp > 0 ? tok.stats.maxHp : unit.base.maxHp * 0.5; + return tok && tok.stats.maxHp > 0 ? Math.max(0.05, tok.stats.maxHp / Math.max(1, unit.base.maxHp)) : 1; }; - battle.on('fatal', (ctx) => { - if (ctx.unit !== unit || ctx.prevented || unit.trait.doll) return; - ctx.prevented = true; - unit.trait.doll = true; + const at = (extra) => ({ x: unit.x, y: unit.y, id: unit.id, ...extra }); + // a switch animation: "切换动画开始时会清除自身一切Buff" (header: the running skill and the statuses), then 1 s of 不死 + // (the fatal hook below) 无敌 阻回 禁疗 孤立 强制缴械 and 眩晕 / 冻结 / 睡眠 immunity (beforeStatus below) + const startSwitch = () => { + const sk = unit.skill; + if (sk && sk.active && sk.kind !== 'passive') sk.end('substitute'); + for (const b of unit.buffs.slice()) if (b.status) battle.removeBuff(unit, b); + unit.trait.dollSwitch = true; + const done = () => { unit.trait.dollSwitch = false; }; battle.addBuff(unit, { - key: 'trait:substitute', duration: unit.profile.dollDuration ?? 20, visible: true, - mods: { blockCnt: -99, hpMul: Math.max(0.05, dollHp() / Math.max(1, unit.base.maxHp)) }, - onExpire: () => { - unit.trait.doll = false; - if (unit.alive) { unit.markDirty(); unit.hp = unit.s.maxHp; battle.fx('swap', { x: unit.x, y: unit.y, id: unit.id }); } - }, + key: 'trait:dollSwitch', duration: DOLL_SWITCH, onExpire: done, onRemove: done, + flags: { invulnerable: true, noSp: true, noHeal: true, isolated: true, disarm: true }, }); - battle.releaseBlocked(unit); + }; + const leave = () => { + if (!unit.trait.doll) return; + unit.trait.doll = false; + unit.form = null; + if (!unit.alive || !unit.deployed) return; + startSwitch(); unit.markDirty(); unit.hp = unit.s.maxHp; - battle.fx('substitute', { x: unit.x, y: unit.y, id: unit.id }); + battle.fx('swap', at({ form: null })); + }; + const enter = () => { + if (unit.trait.doll || !unit.alive || !unit.deployed) return false; + unit.trait.doll = true; + startSwitch(); + const dur = DOLL_SWITCH + (unit.profile.dollDuration ?? 20); + const mul = dollHpMul(); + battle.addBuff(unit, { + key: 'trait:substitute', duration: dur, visible: true, onExpire: leave, onRemove: leave, + mods: { blockCnt: -99, ...(mul !== 1 ? { hpMul: mul } : null) }, + // 阻回 for the whole form; a 替身 that makes no normal attack casts no skill either (header) + flags: { noSp: true, ...(unit.profile.dollNoAttack ? { disarm: true, silence: true } : null) }, + }); + battle.releaseBlocked(unit); + unit.form = 'doll'; + unit.markDirty(); + unit.hp = unit.s.maxHp; + // `dur`: until the switch back (the client times the 替身's closing clip with it) + battle.fx('substitute', at({ form: 'doll', dur })); + return true; + }; + battle.on('fatal', (ctx) => { + if (ctx.unit !== unit || ctx.prevented) return; + if (unit.trait.dollSwitch) { ctx.prevented = true; return; } // 不死 while switching (a 流失 ignores 无敌) + if (unit.trait.doll) return; // the 替身 is knocked out + ctx.prevented = enter(); }, { owner: unit, priority: -100 }); - battle.on('death', (ctx) => { if (ctx.unit === unit) unit.trait.doll = false; }, { owner: unit }); + battle.on('dollSwitch', (ctx) => { if (ctx.unit === unit && !ctx.done) ctx.done = enter(); }, { owner: unit }); + battle.on('beforeStatus', (ctx) => { + if (ctx.target === unit && unit.trait.dollSwitch && DOLL_SWITCH_IMMUNE.has(ctx.status)) ctx.cancel = true; + }, { owner: unit }); + battle.on('death', (ctx) => { + if (ctx.unit !== unit) return; + unit.trait.doll = false; + unit.trait.dollSwitch = false; + // knocked out as the 替身: its death clip has played (the 'die' event came first); the redeploy is the 本体 again + if (unit.form) { unit.form = null; battle.fx('dollEnd', at({ form: null })); } + }, { owner: unit }); }; const installLibrator = (battle, unit) => { diff --git a/server/sim/snapshot.js b/server/sim/snapshot.js index 6b0d383e..89304c94 100644 --- a/server/sim/snapshot.js +++ b/server/sim/snapshot.js @@ -2,9 +2,9 @@ // // b.snap = { fieldId, t, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]], dp, killed, total } // UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, motion?, boss?, uid?, -// form?, skillIndex?, moduleId?, items? } (form = an enemy's current model form, content/enemies.js setForm — 掠海漂移体 -// 'crawl', 暴鸰 'bombed', 转译基底·α's forms …: a view built after the change, a field opened mid-battle, draws it — -// render/units.js FORMS) +// form?, skillIndex?, moduleId?, items? } (form = the unit's current model form — an enemy's, content/enemies.js setForm: +// 掠海漂移体 'crawl', 暴鸰 'bombed', 转译基底·α's forms …; a 傀儡师 fighting as its 替身 'doll', professions.js — a view built +// after the change, a field opened mid-battle, draws it: render/units.js FORMS) // dir = 'UP'|'RIGHT'|'DOWN'|'LEFT' (allies: the deploy direction, sim/dir.js); facing = its horizontal sign (±1). // items = an ally operator's equipped item ids (absent without any). // flags bits & anim codes come from shared/constants.js (UF / ANIM); an enemy's stealth bit = its 隐匿 is on (not while it @@ -37,8 +37,8 @@ export function unitInfo(u) { maxHp: Math.max(1, Math.round(u.s.maxHp)), motion: u.motion === 'FLY' ? 'FLY' : undefined, boss: u.isBoss ? true : undefined, - // an enemy's current model form (content/enemies.js setForm, render/units.js FORMS): a view built mid-battle - // (fieldMeta — a watched teammate's field, 联防 observers, a reconnect) starts on that clip set + // the unit's current model form (an enemy's content/enemies.js setForm, a 傀儡师's 替身 — render/units.js FORMS): a + // view built mid-battle (fieldMeta — a watched teammate's field, 联防 observers, a reconnect) starts on that clip set form: typeof u.form === 'string' ? u.form : undefined, uid: u.uid ?? undefined, // DESIGN §16: the equipped skill's index (the renderer / audio pick that skill's Spine clip and sound) diff --git a/server/sim/units.js b/server/sim/units.js index c9e277aa..7be21141 100644 --- a/server/sim/units.js +++ b/server/sim/units.js @@ -85,7 +85,7 @@ export class Unit { this.stats = { dmg: 0, kills: 0, heal: 0, taken: 0, attacks: 0 }; this.hidden = false; // enemies inside a DISAPPEAR segment this.moving = false; // enemies: walked this tick (drawn on the move clip; ai.js updateEnemy) - this.form = null; // enemies: the model's current form (content/enemies.js setForm → snapshot.js unitInfo) + this.form = null; // the model's current form (content/enemies.js setForm, a 傀儡师's 替身 'doll' → snapshot.js unitInfo) this.anim = 0; this.persist = { redeployMul: 1, freeRedeploys: 0 }; this.isBoss = false; diff --git a/shared/protocol.js b/shared/protocol.js index 943cec8a..206ee7bc 100644 --- a/shared/protocol.js +++ b/shared/protocol.js @@ -337,8 +337,8 @@ export const EV = Object.freeze({ /** * The model form a `b.ev` 'fx' tuple ['fx', kind, x, y, extra] puts its unit in: `extra.form` (sim content/enemies.js - * setForm — 转译基底·α's forms, a 逐火 余烬 and its revival, a leader's 重生, 守墓石像's modes, 掠海漂移体's crawl; a string is - * that clip set, null the base one), undefined for any other tuple. A form is state, not decoration: a view that misses + * setForm — 转译基底·α's forms, a 逐火 余烬 and its revival, a leader's 重生, 守墓石像's modes, 掠海漂移体's crawl; a 傀儡师's 替身, + * sim professions.js; a string is that clip set, null the base one), undefined for any other tuple. A form is state, not decoration: a view that misses * the fx keeps drawing the old model (player report #5 after 0.1.0), so the client's catch-up frames, its hidden-tab * backlog (battle/runner.js) and the events buffered before a field is entered (screens/game.js) keep these tuples. */ diff --git a/test/render/feedback2-doll.test.js b/test/render/feedback2-doll.test.js new file mode 100644 index 00000000..b8fcf318 --- /dev/null +++ b/test/render/feedback2-doll.test.js @@ -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'); +}); diff --git a/test/sim/feedback2-doll.test.js b/test/sim/feedback2-doll.test.js new file mode 100644 index 00000000..97c59783 --- /dev/null +++ b/test/sim/feedback2-doll.test.js @@ -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); +}); diff --git a/test/sim/fidelity.test.js b/test/sim/fidelity.test.js index 1f69d153..010b87c6 100644 --- a/test/sim/fidelity.test.js +++ b/test/sim/fidelity.test.js @@ -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); }); // --------------------------------------------------------------------------------------------------------------- diff --git a/test/sim/professions.test.js b/test/sim/professions.test.js index 38c828c1..2a8a38fa 100644 --- a/test/sim/professions.test.js +++ b/test/sim/professions.test.js @@ -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', () => {