diff --git a/docs/SIM.md b/docs/SIM.md index eff403ca..3b8cc5d9 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -89,8 +89,9 @@ where it fell for the `kill` / `death` handlers. "倒地干员所在地块视为 tile and `isReservedTile` reports it, so every automatic picker skips it. The rule covers every 退场 (GitHub #60): an operator forced out by its own effects (`retreat` reason `'retreat'`: 史尔特尔's 余烬, 耀骑士临光 S2, 骑士戒律 + 竞技旗, 伊内丝 S3; `'merchant'`: a 商人 that cannot pay) lies down and comes back the same way — still no kill (its death reason is -not `'killed'`: no 被击倒 effects, 不屈, 阿戈尔 or knock-down count); only the 突袭 retreat (`'raid'`, redeployed at once on -its landing tile) and permanent removals leave nothing. +not `'killed'`: no 被击倒 effects, 阿戈尔 or knock-down count), but 不屈 rolls on it (PRTS 盟约记录 不屈 修正 "被击倒、撤退、 +切换<替身>与<本体>时": a hit redeploys it at once, free, where it lies; addon/battle.js); only the 突袭 retreat (`'raid'`, +redeployed at once on its landing tile — no 不屈 roll) and permanent removals leave nothing. ### 1.1 Coordinates (PlayerBattleInput units) @@ -716,6 +717,7 @@ registration order. `battle.off(handle)` / `battle.off(name, fn)` / `battle.offO | `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 — the lock `PRIO_REVIVE` −100 after the substitutes (−100, registered first), the running windows `PRIO_UNDYING_HELD` −99 before them: a 傀儡师 holding 不死 does not switch, PRTS 分支特性信息 傀儡师 "未持有不死的情况下", DESIGN §22.11) → 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` | +| `dollSwap` | `{ unit, form }` | a 傀儡师 starts a switch — to its <替身> (`form` `'doll'`) or back to its <本体> (`null`); not when it is knocked out as the 替身 (不屈 rolls on it: "切换<替身>与<本体>时") | | `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 | diff --git a/server/sim/content/bonds/addon/battle.js b/server/sim/content/bonds/addon/battle.js index 38d02349..ff2888d2 100644 --- a/server/sim/content/bonds/addon/battle.js +++ b/server/sim/content/bonds/addon/battle.js @@ -431,24 +431,42 @@ export function install(battle) { }); } - // 不屈 + // 不屈 (PRTS 卫戍协议:盟约 下半/PRTS盟约记录, its 修正: "地面干员被击倒、撤退、切换<替身>与<本体>时,有(18+0.4×层数)%概率立刻 + // 重新部署"; "※“立刻重新部署”的实现方式为:令受益者下次部署的再部署时间和费用归零"; owner's decision 2026-10-04 to follow it). + // Rolls on a knock-out ('killed') and on a 撤退 ('retreat' — 史尔特尔's 余烬, 耀骑士临光 S2, 伊内丝 S3 … — and 行商's + // 'merchant' withdrawal): a hit redeploys it at once, free, where it lies (engine rest tile, §22.15). Not on the 突袭 + // retreat ('raid': it redeploys at once and free anyway — the zeroed next deployment would change nothing [ASSUMED: no + // roll]) nor on the 联防 forced exit (FORCED_EXIT: the battle's setup, not a 撤退 — §19.3). A 傀儡师 switch (hook + // `dollSwap`, to the 替身 and back) rolls too; she stays on the field, so a hit only zeroes her NEXT deployment: the + // next time she leaves the field (knocked out, withdrawn) she is back at once and free (`u.mem.indomFreeDeploy`) + // [ASSUMED: one such deployment at a time, spent by her next deployment whatever brings it]. Tier 2 (+sp SP to every + // operator on the field) stays a knock-out effect: its own line ("地面干员被击倒时使场上所有干员技力+5") was not corrected. if (has(ID.indom)) { + const INDOM_EXITS = new Set(['killed', 'retreat', 'merchant']); + const stOf = (u) => { const st = isGroundOp(u) ? byPid[u.ownerId] : null; return st && st.tiers[ID.indom] ? st : null; }; battle.on('death', (c) => { const u = c.unit; - if (c.reason !== 'killed' || !isGroundOp(u)) return; - const st = byPid[u.ownerId]; - if (!st || !st.tiers[ID.indom]) return; - const bb = st.bb[ID.indom]; - if (st.tiers[ID.indom] >= 2) { + const banked = !!(u && u.mem && u.mem.indomFreeDeploy); + if (!INDOM_EXITS.has(c.reason) || !(stOf(u) || (banked && u.kind === 'op'))) return; + const st = stOf(u); + const bb = st ? st.bb[ID.indom] : null; + if (st && c.reason === 'killed' && st.tiers[ID.indom] >= 2) { const sp = num(bb.sp); if (sp > 0) for (const o of st.ops) if (onField(o) && o.skill) o.skill.gainSp(sp, 'bond'); } if (u.alive || u.removed || u.mem[ID.indom] === battle.time) return; - if (battle.rng() < prob(bb, L(battle, st, ID.indom))) { + if (banked || (st && battle.rng() < prob(bb, L(battle, st, ID.indom)))) { u.mem[ID.indom] = battle.time; if (battle.redeploy(u, { free: true })) fxOn(battle, 'revive', u, 'bond:indomShip', 'redeploy'); } }, { priority: 10 }); + battle.on('dollSwap', ({ unit: u }) => { + const st = stOf(u); + if (!st || !u.alive || u.mem.indomFreeDeploy) return; + if (battle.rng() < prob(st.bb[ID.indom], L(battle, st, ID.indom))) u.mem.indomFreeDeploy = true; + }); + // the zeroed deployment is the next one, whatever brings it (this redeploy, the 阿戈尔 revive, the 突袭 jump …) + battle.on('deploy', ({ unit: u }) => { if (u && u.mem && u.mem.indomFreeDeploy) u.mem.indomFreeDeploy = false; }); } // 坚守 (tier 2) redirect + thorns, 奥术 vulnerability diff --git a/server/sim/professions.js b/server/sim/professions.js index ee300f34..b23f279e 100644 --- a/server/sim/professions.js +++ b/server/sim/professions.js @@ -149,6 +149,7 @@ const installDollkeeper = (battle, unit) => { unit.markDirty(); unit.hp = unit.s.maxHp; battle.fx('swap', at({ form: null })); + if (battle.hasHook('dollSwap')) battle.emit('dollSwap', { unit, form: null }); }; const enter = () => { if (unit.trait.doll || !unit.alive || !unit.deployed) return false; @@ -168,6 +169,9 @@ const installDollkeeper = (battle, unit) => { 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 })); + // `dollSwap` { unit, form }: a switch started — to the 替身 ('doll') or back to the 本体 (null); not fired when she is + // knocked out as the 替身 (不屈 rolls on both switches: PRTS 盟约记录 "切换<替身>与<本体>时") + if (battle.hasHook('dollSwap')) battle.emit('dollSwap', { unit, form: 'doll' }); return true; }; battle.on('fatal', (ctx) => { diff --git a/test/content/feedback3-indom-exits.test.js b/test/content/feedback3-indom-exits.test.js new file mode 100644 index 00000000..3d424267 --- /dev/null +++ b/test/content/feedback3-indom-exits.test.js @@ -0,0 +1,114 @@ +// test/content/feedback3-indom-exits.test.js — 不屈 (owner's decision 2026-10-04, PRTS 卫戍协议:盟约 下半/PRTS盟约记录 不屈 +// 修正: "被击倒、撤退、切换<替身>与<本体>时,有(18+0.4×层数)%概率立刻重新部署"; "※“立刻重新部署”的实现方式为:令受益者下次 +// 部署的再部署时间和费用归零"): retreats and 傀儡师 switches roll too, not only knock-outs. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { makeBattle, chessRec, checkInvariants } from '../helpers/battleHarness.js'; +import { FORCED_EXIT } from '../../server/sim/constants.js'; + +const close = (a, b, eps = 1e-6, msg = '') => assert.ok(Math.abs(a - b) <= eps, `${msg} expected ${b}, got ${a}`); +const bondOn = (count, layers = 0, tier = null, thresholds = [3, 6, 9]) => ({ + count, active: count >= thresholds[0], tier: tier ?? thresholds.filter((t) => count >= t).length, layers, +}); + +// --------------------------------------------------------------------------------------------------------------------- +// 不屈 on 撤退 and on 替身 switches + +const groundOp = (id, extra = {}) => chessRec({ id, profession: 'WARRIOR', skill: null, bonds: ['indomShip'], stats: { atk: 100, maxHp: 5000, def: 0, respawnTime: 70 }, ...extra }); +const spOp = (id) => groundOp(id, { skill: { duration: 5, spCost: 50, initSp: 0, bb: { atk: 1 } } }); +const INDOM = (layers, count = 2) => ({ indomShip: bondOn(count, layers, null, [2, 3]) }); // p = min(1, 0.18 + 0.004 × layers) +const GH2 = 'chess_char_5_13_a'; // 归溟幽灵鲨: 傀儡师, a member of 阿戈尔 and 不屈 + +test('不屈: a 撤退 rolls like a knock-out — at p = 1 it is back at once, free, on its tile (行商\'s withdrawal too); tier 2\'s +5 SP stays a knock-out effect', () => { + const h = makeBattle({ + defs: { chess: { t_a: groundOp('t_a'), t_b: spOp('t_b'), t_c: groundOp('t_c') } }, + units: [{ chessId: 't_a', row: 10, col: 4 }, { chessId: 't_b', row: 10, col: 6 }, { chessId: 't_c', row: 10, col: 8 }], + bonds: INDOM(300, 3), autoFinish: false, timeLimit: 60, hooks: ['death', 'deploy'], + }); + h.step(); + const [a, b] = [h.unit('t_a'), h.unit('t_b')]; + const dp0 = h.b.getPlayer('p1').dp; + const sp0 = b.skill.sp; + h.b.retreat(a, { reason: 'retreat' }); + assert.ok(a.alive && a.deployed, 'back at once'); + assert.equal(h.hooksOf('deploy').filter((c) => c.unit === a && !c.initial).length, 1); + assert.deepEqual([a.tileR, a.tileC], [a.homeR, a.homeC], 'on its tile'); + close(h.b.getPlayer('p1').dp, dp0, 1e-9, 'free'); + close(b.skill.sp, sp0, 1e-9, 'no +5 SP for a retreat'); + h.step(); // (不屈 fires once per unit and instant) + h.b.retreat(a, { reason: 'merchant' }); + assert.ok(a.alive, '行商\'s automatic withdrawal is a 撤退 too'); + h.step(); + const sp1 = b.skill.sp; + h.b.dealDamage(null, a, { amount: 1e9, type: 'true' }); + assert.ok(a.alive, 'a knock-out: back too'); + close(b.skill.sp, sp1 + 5, 1e-9, 'a knock-out gives the tier-2 +5 SP'); + checkInvariants(h.b); +}); + +test('不屈: no roll on the 突袭 retreat (it redeploys at once anyway) nor on the 联防 forced exit; a missed roll leaves the withdrawn operator down', () => { + const h = makeBattle({ + kind: 'unite', defs: { chess: { t_a: groundOp('t_a'), t_b: groundOp('t_b') } }, + units: [{ chessId: 't_a', row: 10, col: 4, carryState: { down: true } }, { chessId: 't_b', row: 10, col: 6 }], bonds: INDOM(300), + autoFinish: false, timeLimit: 60, + }); + h.step(); + const a = h.unit('t_a'); + assert.ok(!a.alive && a.removeReason === FORCED_EXIT && h.b.isDown(a), 'the 联防 forced exit is no 撤退: it stays down with its timer'); + const b = h.unit('t_b'); + h.b.retreat(b, { reason: 'raid' }); + assert.ok(!b.alive, 'the 突袭 retreat: 不屈 leaves it to the raid\'s own redeploy'); + // p = 0.18: a seed whose roll misses leaves it down where it stood (§22.15) + let missed = false; + for (let seed = 1; seed < 40 && !missed; seed++) { + const m = makeBattle({ + seed, defs: { chess: { t_a: groundOp('t_a'), t_b: groundOp('t_b') } }, + units: [{ chessId: 't_a', row: 10, col: 4 }, { chessId: 't_b', row: 10, col: 6 }], bonds: INDOM(0), autoFinish: false, timeLimit: 60, + }); + m.step(); + const x = m.unit('t_a'); + m.b.retreat(x, { reason: 'retreat' }); + if (!x.alive) { missed = true; assert.ok(m.b.isDown(x), 'down on its tile with its timer'); } + } + assert.ok(missed, 'a missed roll within 39 seeds at p = 0.18'); +}); + +test('不屈: a 傀儡师 switch to the 替身 rolls — she stays on the field, and a hit makes her next deployment immediate and free', () => { + const h = makeBattle({ units: [{ chessId: GH2, row: 10, col: 4 }], bonds: INDOM(300), autoFinish: false, timeLimit: 120, captureNoisy: true, hooks: ['deploy'] }); + h.step(); + const u = h.unit(GH2); + assert.ok(u.ground, 'a ground operator'); + h.b.dealDamage(null, u, { amount: 1e9, type: 'true' }); + assert.ok(u.alive && u.trait.doll, 'switched to her 替身, still on the field'); + assert.equal(h.hooksOf('deploy').filter((c) => c.unit === u && !c.initial).length, 0, 'no redeploy for the switch itself'); + assert.equal(u.mem.indomFreeDeploy, true, 'the roll hit: her next deployment is zeroed'); + h.run(2); + const dp0 = h.b.getPlayer('p1').dp; + h.b.dealDamage(null, u, { amount: 1e9, type: 'true' }); // a lethal hit on the 替身 knocks her out + assert.ok(u.alive && u.deployed && !u.trait.doll, 'back at once as her 本体'); + close(h.b.getPlayer('p1').dp, dp0, 1e-9, 'free'); + assert.equal(u.mem.indomFreeDeploy, false, 'spent by that deployment'); + checkInvariants(h.b); +}); + +test('不屈: the switch back to the 本体 rolls too; a zeroed deployment brings her back even when the knock-out roll misses', () => { + const h = makeBattle({ units: [{ chessId: GH2, row: 10, col: 4 }], bonds: INDOM(300), autoFinish: false, timeLimit: 120, captureNoisy: true }); + h.step(); + const u = h.unit(GH2); + h.b.dealDamage(null, u, { amount: 1e9, type: 'true' }); + u.mem.indomFreeDeploy = false; // forget the first switch's roll: the switch back must roll on its own + h.runUntil(() => !u.trait.doll, 30); + assert.ok(u.alive && !u.trait.doll, 'back to her 本体'); + assert.equal(u.mem.indomFreeDeploy, true, 'the switch back rolled (p = 1)'); + // p = 0.18 (no layers): with a zeroed deployment banked she comes back after any knock-out, rolled or not + for (let seed = 1; seed <= 5; seed++) { + const m = makeBattle({ seed, defs: { chess: { t_a: groundOp('t_a') } }, units: [{ chessId: 't_a', row: 10, col: 4 }], bonds: INDOM(0), autoFinish: false, timeLimit: 60 }); + m.step(); + const x = m.unit('t_a'); + x.mem.indomFreeDeploy = true; + m.b.dealDamage(null, x, { amount: 1e9, type: 'true' }); + assert.ok(x.alive && !x.mem.indomFreeDeploy, `seed ${seed}: back at once`); + } + checkInvariants(h.b); +});