diff --git a/docs/SIM.md b/docs/SIM.md index f7a69ebe..85b64913 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -564,7 +564,7 @@ of coverage per 3 s), kept because the current wording no longer says so (feedba |---|---|---| | `stun` | cannot act / move; **a stunned operator blocks nothing** (its blocked enemies are released: taken over by another operator in contact with room, else they walk on — §1.2 Blocking) | – | | `freeze` | stun; **enemies** also RES −15 | – | -| `cold` | ASPD −30; a 2nd cold while cold ⇒ `freeze` 3 s (unless frozen-immune) | – | +| `cold` | ASPD −30; a 2nd cold while cold ⇒ `freeze` for max(remaining cold, the incoming cold after 抵抗) — PRTS 术语释义 寒冷 「持续时间取双方之中最高」 (`COLD_FREEZE_DURATION` 3 s only when neither side has a duration; unless frozen-immune). [ASSUMED] the one catalogue cold uses that 友方 sentence for an enemy-applied cold too | – | | `sleep` | 无敌且无法行动: inactive, untargetable, **takes no damage** (unless the attacker profile has `hitSleep` or the damage `ignoreSleep`), blocks nothing | – | | `slow` | moveMul 1 − value (*strongest*) | default 0.5 | | `sluggish` (停顿) | moveMul 0.2 | – | diff --git a/server/sim/Battle.js b/server/sim/Battle.js index e50bd35d..f97ab036 100644 --- a/server/sim/Battle.js +++ b/server/sim/Battle.js @@ -1340,10 +1340,12 @@ export class Battle { } /** - * Apply a catalogue status. opts: { duration, source, value, force, refresh, point } — returns true when applied. - * Honours enemy immunities (stun/silence/sleep/frozen/levitate/feared) unless `force`. `beforeStatus` handlers may - * cancel it or change `duration` / `value`. Official rules (buffs.js STATUS): 抵抗 (the `resist` status) shortens the - * RESIST_STATUSES by its value (default half; applied after `beforeStatus`); 浮空 lasts half as long on units heavier + * Apply a catalogue status. opts: { duration, source, value, force, refresh, point, resistApplied } — returns true + * when applied. Honours enemy immunities (stun/silence/sleep/frozen/levitate/feared) unless `force`. `beforeStatus` + * handlers may cancel it or change `duration` / `value`. Official rules (buffs.js STATUS): 抵抗 (the `resist` status) + * shortens the RESIST_STATUSES by its value (default half; applied after `beforeStatus`; `resistApplied` skips that + * pass — the cold-on-cold 冻结 below already used post-抵抗 lengths). A second 寒冷 while 寒冷 remains applies 冻结 for + * max(remaining, this cold after 抵抗) (PRTS 术语释义 寒冷 「持续时间取双方之中最高」). 浮空 lasts half as long on units heavier * than LEVITATE_HALF_WEIGHT (current massLevel); 冻结's RES cut hits enemies only; 麻痹 adds stacks; "同名效果取最高" * statuses (`valued`) keep the strongest value — a weaker application only extends past the stronger one's end (it * then resumes); other statuses refresh to the longer duration. 诱导 (`attract`) walks the enemy to `point` @@ -1379,14 +1381,26 @@ export class Battle { else if (Number.isFinite(d) && d <= 0) return false; value = c.value; } - if (RESIST_STATUSES.has(key)) { + if (RESIST_STATUSES.has(key) && !opts.resistApplied) { const rv = this.resistOf(target); if (rv > 0) duration *= 1 - rv; } if (key === 'levitate' && target.s.massLevel > LEVITATE_HALF_WEIGHT) duration /= 2; if (!(duration > 0)) return false; if (key === 'cold' && target.findBuff('cold') && !(immune && immune.has('frozen'))) { - this.applyStatus(target, 'freeze', { duration: COLD_FREEZE_DURATION, source: opts.source, force: opts.force }); + // PRTS 术语释义 寒冷: 友方寒冷 pairs into 冻结, 「持续时间取双方之中最高」. `duration` is this cold after 抵抗; + // the cold already on the target keeps timeLeft. addBuff refresh 'extend' below sets that cold to the same max. + // COLD_FREEZE_DURATION is only the fallback when neither side has a duration. resistApplied: that max is already + // post-抵抗, so the freeze must not be halved again. + // [ASSUMED] one catalogue cold, so an enemy-applied second cold uses this max too. PRTS states it on the 友方 + // line only (敌方 cold becomes 冻结 when the target already has 敌方 cold or 敌方 冻结). + const prev = target.findBuff('cold'); + const spans = [prev.timeLeft, duration].filter((t) => t === Infinity || (Number.isFinite(t) && t > 0)); + const freezeFor = spans.length ? Math.max(...spans) : COLD_FREEZE_DURATION; + this.applyStatus(target, 'freeze', { + duration: freezeFor, source: opts.source, force: opts.force, + ...(spans.length ? { resistApplied: true } : {}), + }); if (!target.alive) return false; } const source = opts.source ?? null; diff --git a/server/sim/buffs.js b/server/sim/buffs.js index aafcba12..b69b6189 100644 --- a/server/sim/buffs.js +++ b/server/sim/buffs.js @@ -55,6 +55,8 @@ export const STATUS = Object.freeze({ stun: { flags: { stun: true, noBlock: true }, immune: 'stun' }, // 冻结: 无法移动、攻击及使用技能; 敌方被冻结时,法术抗性-15 (the RES cut applies to enemies only) freeze: { flags: { freeze: true, stun: true }, mods: { resFlat: -FREEZE_RES_DOWN }, enemyOnlyMods: true, immune: 'frozen' }, + // 寒冷: ASPD −30. A second cold while this one remains applies 冻结 for max(remaining, incoming) + // (Battle.applyStatus; PRTS 术语释义 寒冷 「持续时间取双方之中最高」). cold: { flags: { cold: true }, mods: { aspd: COLD_ASPD } }, // 沉睡: 无敌且无法行动 (untargetable, takes no damage — except from `hitSleep` attackers — and blocks nothing) sleep: { flags: { sleep: true, noBlock: true }, immune: 'sleep' }, diff --git a/server/sim/constants.js b/server/sim/constants.js index 44210f81..e6229ace 100644 --- a/server/sim/constants.js +++ b/server/sim/constants.js @@ -129,7 +129,10 @@ export const PULL_ORIGIN = 0.5; export const PULL_STOP_RADIUS = 0.6708; export const PUSH_DIRECTIONAL_MIN_DIST = 0.25; -/** Freeze caused by cold on cold (DESIGN §5.3). */ +/** + * Fallback freeze when a second 寒冷 lands and neither the remaining cold nor the incoming one has a duration + * (Battle.applyStatus). A real duration uses max(remaining, incoming) — PRTS 术语释义 寒冷 「持续时间取双方之中最高」. + */ export const COLD_FREEZE_DURATION = 3; /** 浮空 (ba.levitate): the duration is halved on units heavier than this weight (massLevel). */ export const LEVITATE_HALF_WEIGHT = 3; diff --git a/test/sim/combat.test.js b/test/sim/combat.test.js index fac05af5..59e0b90b 100644 --- a/test/sim/combat.test.js +++ b/test/sim/combat.test.js @@ -55,7 +55,7 @@ test('stunned operator neither attacks nor casts but keeps its natural SP recove assert.equal(u.skill.activations, 0, 'silenced: no cast'); }); -test('cold on cold ⇒ freeze 3 s (res −15), frozen-immune enemies only get cold', () => { +test('cold on cold ⇒ freeze for the longer cold (res −15), not a fixed 3 s; frozen-immune enemies only get cold', () => { const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy({ res: 30 }), enemy_icy: enemyRec({ key: 'enemy_icy', speed: 0, hp: 1e6, immunities: { frozen: true } }) } }, enemies: [{ key: 'enemy_dummy', pos: [11, 8] }, { key: 'enemy_icy', pos: [10, 8] }], content: 'none', @@ -70,8 +70,11 @@ test('cold on cold ⇒ freeze 3 s (res −15), frozen-immune enemies only get co assert.equal(e.s.flags.freeze, true); assert.equal(e.s.flags.stun, true); assert.equal(e.s.res, 15); + assert.ok(Math.abs(e.findBuff('freeze').timeLeft - 5) < 1e-6, 'freeze lasts the longer cold (5 s), not 3 s'); h.run(3.1); - assert.ok(!e.s.flags.freeze, 'freeze lasts 3 s'); + assert.ok(e.s.flags.freeze, 'still frozen past the old fixed 3 s'); + h.run(2); + assert.ok(!e.s.flags.freeze, 'freeze ends with that cold'); h.b.applyStatus(icy, 'cold', { duration: 5 }); h.b.applyStatus(icy, 'cold', { duration: 5 }); assert.ok(!icy.s.flags.freeze); diff --git a/test/sim/feedback3-cold-freeze.test.js b/test/sim/feedback3-cold-freeze.test.js new file mode 100644 index 00000000..59c50ab2 --- /dev/null +++ b/test/sim/feedback3-cold-freeze.test.js @@ -0,0 +1,135 @@ +// A second 寒冷 used to apply a fixed 3 s 冻结 (COLD_FREEZE_DURATION) whatever the two colds lasted. +// PRTS 术语释义 寒冷 (id ba.cold): 友方寒冷 pairs into 友方冻结, 「持续时间取双方之中最高」. The cold that remains is +// already max(remaining, incoming) via addBuff refresh 'extend'; the freeze in the same moment must match it. +// 抵抗 shortens the incoming duration before that max. A frozen-immune enemy still gets no freeze. +// [ASSUMED] the engine's one cold uses that 友方 sentence for an enemy-applied cold too (PRTS states the max on +// the 友方 line only). gamedata_const ba.cold names the freeze and not its length. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { makeBattle, enemyRec } from '../helpers/battleHarness.js'; +import { hasGeneratedData } from '../../server/sim/simdata.js'; + +const REAL = { skip: !hasGeneratedData() }; +const close = (a, b, eps, msg = '') => assert.ok(Math.abs(a - b) <= eps, `${msg} expected ${b} ± ${eps}, got ${a}`); + +function coldBattle(extra = {}) { + return makeBattle({ + defs: { enemies: { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 1e6, speed: 0, res: 30 }) } }, + enemies: [{ key: 'enemy_dummy', pos: [11, 8] }], + content: 'none', autoFinish: false, timeLimit: 60, + ...extra, + }); +} + +test('cold 10s then immediately another cold 1s → freeze lasts about 10s, not 3', () => { + const h = coldBattle(); + h.step(); + const e = h.enemy('enemy_dummy'); + h.b.applyStatus(e, 'cold', { duration: 10 }); + h.b.applyStatus(e, 'cold', { duration: 1 }); + close(e.findBuff('cold').timeLeft, 10, 1e-6, 'the shorter cold does not replace the longer one'); + close(e.findBuff('freeze').timeLeft, 10, 1e-6, 'freeze'); + assert.equal(e.s.res, 15, 'frozen: RES −15'); + h.run(3.2); + assert.ok(e.s.flags.freeze, 'still frozen past the old fixed 3 s'); + h.run(7); + assert.ok(!e.s.flags.freeze, 'ends with the 10 s cold'); + assert.equal(e.s.res, 30); +}); + +test('cold 1s then cold 8s → freeze lasts about 8s, not 3', () => { + const h = coldBattle(); + h.step(); + const e = h.enemy('enemy_dummy'); + h.b.applyStatus(e, 'cold', { duration: 1 }); + h.b.applyStatus(e, 'cold', { duration: 8 }); + close(e.findBuff('cold').timeLeft, 8, 1e-6); + close(e.findBuff('freeze').timeLeft, 8, 1e-6); + h.run(3.2); + assert.ok(e.s.flags.freeze, 'still frozen past 3 s'); + h.run(5); + assert.ok(!e.s.flags.freeze, 'ends with the 8 s cold'); +}); + +test('a shorter second cold does not shorten a longer remaining cold\'s freeze', () => { + const h = coldBattle(); + h.step(); + const e = h.enemy('enemy_dummy'); + h.b.applyStatus(e, 'cold', { duration: 10 }); + h.run(6); + const remain = e.findBuff('cold').timeLeft; + assert.ok(remain > 3.5 && remain < 4.5, `about 4 s left, got ${remain}`); + h.b.applyStatus(e, 'cold', { duration: 1 }); + close(e.findBuff('cold').timeLeft, remain, 1e-6, 'cold'); + close(e.findBuff('freeze').timeLeft, remain, 1e-6, 'freeze follows the remaining cold, not 1 s and not 3 s'); + h.run(3.2); + assert.ok(e.s.flags.freeze, 'a 1 s or 3 s freeze would have ended'); + h.run(1.2); + assert.ok(!e.s.flags.freeze); +}); + +test('抵抗 still shortens the incoming duration before the max', () => { + const h = coldBattle({ hooks: ['statusApplied'] }); + h.step(); + const e = h.enemy('enemy_dummy'); + h.b.applyStatus(e, 'cold', { duration: 2 }); + h.b.applyStatus(e, 'resist', { value: 0.5 }); + h.b.applyStatus(e, 'cold', { duration: 10 }); + // incoming 10 × (1 − 0.5) = 5, remaining 2 → both cold and freeze are 5, not 10 and not 2.5 + close(e.findBuff('cold').timeLeft, 5, 1e-6, 'cold'); + close(e.findBuff('freeze').timeLeft, 5, 1e-6, 'freeze'); + const freeze = h.hooksOf('statusApplied').filter((c) => c.target === e && c.status === 'freeze'); + assert.equal(freeze.length, 1); + close(freeze[0].duration, 5, 1e-9, 'the reported freeze duration'); + h.run(3.2); + assert.ok(e.s.flags.freeze, 'not halved a second time down to 2.5 s'); + h.run(2); + assert.ok(!e.s.flags.freeze, 'not the unresisted 10 s'); +}); + +test('a frozen-immune enemy gets the second cold and no freeze', () => { + const h = makeBattle({ + defs: { enemies: { enemy_icy: enemyRec({ key: 'enemy_icy', hp: 1e6, speed: 0, immunities: { frozen: true } }) } }, + enemies: [{ key: 'enemy_icy', pos: [10, 8] }], + content: 'none', autoFinish: false, + }); + h.step(); + const icy = h.enemy('enemy_icy'); + h.b.applyStatus(icy, 'cold', { duration: 10 }); + h.b.applyStatus(icy, 'cold', { duration: 1 }); + assert.ok(icy.s.flags.cold); + close(icy.findBuff('cold').timeLeft, 10, 1e-6, 'the colds still take the longer duration'); + assert.equal(icy.findBuff('freeze'), null); + assert.ok(!icy.s.flags.freeze); +}); + +test('吉兆飞鳞: the stun when a freeze ends follows the longer or shorter cold, not a fixed 3 s', REAL, () => { + const run = (first, second) => { + const h = makeBattle({ + autoFinish: false, timeLimit: 40, hooks: ['statusApplied'], + enemies: [{ key: 'enemy_10045_parrot', pos: [10, 6], mods: { speedMul: 0, hpMul: 100 } }], + }); + h.step(); + const e = h.enemy('enemy_10045_parrot'); + h.b.applyStatus(e, 'cold', { duration: first }); + h.b.applyStatus(e, 'cold', { duration: second }); + const want = Math.max(first, second); + close(e.findBuff('freeze').timeLeft, want, 1e-6, `freeze ${first} then ${second}`); + h.run(3.2); + const early = h.hooksOf('statusApplied').filter((c) => c.target === e && c.status === 'stun'); + if (want > 3.2) { + assert.ok(e.s.flags.freeze, 'still frozen at 3.2 s'); + assert.equal(early.length, 0, '失温坠落 has not fired at the old 3 s mark'); + } + h.run(want - 3.2 + 0.3); + assert.ok(!e.s.flags.freeze, 'the freeze has ended'); + const stuns = h.hooksOf('statusApplied').filter((c) => c.target === e && c.status === 'stun'); + // the 0.05 s 【失温坠落】 stun is what the freeze's end applies; its own status hook then grounds the bird + // (Stun.duration, 8 s). The harness hears the 8 s one first because that hook runs inside the 0.05 s emit. + assert.ok(stuns.some((c) => Math.abs(c.duration - 0.05) < 1e-6), `【失温坠落】 0.05 s stun, got ${stuns.map((c) => c.duration)}`); + return h; + }; + run(1, 1); // shorter than the old 3 s: the stun is not held until 3 s + run(10, 1); // longer than 3 s: no stun at 3 s, stun when the 10 s freeze ends +});