feedback3 WG: a 冻结 from stacked 寒冷 lasts the longer of the two colds, not a fixed 3 s
This commit is contained in:
1 parent
13e71fccba
commit
365ebf6cbd
6 files changed
+167
-10
No files matched your search
+1
-1
@@ -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 | – |
|
||||
|
||||
+20
-6
@@ -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;
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
});
|
||||
Reference in new issue
Block a user