feedback5: merge CB1 — community reports (bonds / strategies / items): a pair of colds on an enemy becomes the freeze and leaves no cold (the 谢拉格 wind no longer freezes for good), a 复活 (埃芒加德's 命结之秘, M3茧甲) is a knock-out and an immediate free redeploy where the operator lies (砾's and 瑕光's 被击倒时 / 部署时 layers fire), 佩佩's special refresh is paid, the result card hides the layers of 调和 / 协防干员 / 独行 / 绝技, the 炎佑 keeps off 外勤医疗's slot, 凯瑟琳's 定向投放 offers every shop item; 烛煌's 绝处重燃 before the hammer's lock and the 寻呼模块 / 简易通讯机 tier cap kept as they are (the owner's decisions of 2026-10-07); §25.15 → §25.21 (§25.15 is the shortcuts'); golden: vs feedback5 CB1's own rules — roster-005/009/010/012/014/015/016/018/026/029/031/033/036/040/042/046/047/049, bond-egirShip-high, bond-indomShip-low, bond-kjeragShip-high, bond-maniShip-high, bond-raidShip-low, bond-steadShip-high, bond-suntShip-low, boss-boss_2/3/4/6-pair, hidden-boss_8/9/10-pair, standin-07, matches solo-FUNNY-1, solo-NORMAL-1, solo-HARD-1, solo-ABYSS-1, coop3-ABYSS-5, coop4-FUNNY-6, coop4-ABYSS-7; vs CB1 what feedback5 changed since its base (CB2, CB3, CB4, CB5) plus roster-014

Conflicts resolved by hand:
- server/sim/content/items/battle.js: CB3 had extended `revivedInPlace` (an in-place 复活 restarts a deploy-timed
  skill, item 44); CB1 replaced the in-place revive by PRTS's form — `reviveNow`, a `death` hook that redeploys the
  operator at once, free, where it lies — and removed `revivedInPlace`. CB1's side is kept: its redeploy resets the
  skill, so an `activateOnDeploy` skill starts anew by itself; CB3's tests (test/sim/feedback5-raid-revive.test.js:
  the jump at once after M3茧甲 / 埃芒加德, the window back to full, 砾's fresh barrier) pass on it unchanged. Their
  header, bonds_core's comment, DESIGN §25.17.5 say so.
- docs/SIM.md, docs/design/engine.md: CB1's `fatal` rows (the 复活 are `death` hooks now); the `death` rows also say a
  deploy-timed skill starts anew (§25.17.5).
- data/choices.json: no conflict; `node tools/build-data.mjs --offline` on the merged tree rewrites all 15 data files
  byte-identically, and the JSON compare with feedback5 shows only pools.pool_equip_kathe.maxTier removed (CB1).
- docs/history/0.2.0.md: CB1's section renumbered §25.21 (its §25.15.1–10 and two self-citations; no citation of it
  elsewhere), placed last; the owner's decisions of 2026-10-07 on items 5 and 45 recorded there, in the intro and in the
  two pin tests' headers (feedback5-blaze-hammer, feedback5-cb1-pins).

Golden: solo-FUNNY-1 (outside CB1's list) has 6 埃芒加德 revives and 85 freezes on feedback5's path (instrumented).
roster-014 differs from CB1's own digest by 玛恩纳's 无动于衷: CB3's rule (every enemy damage instance) answers the
萨卡兹枯朽战车's pollution that hits a 卡西米尔 operator in CB1's timeline — with that counter limited to attacks the merged
roster-014 equals CB1's digest exactly (checked).

Gate: non-browser suites 5021 / 5021, npm run golden ok, test/golden.test.js 9 / 9, lint 0 errors, check:imports ok,
typecheck ok.
This commit is contained in:
sganggs committed 2026-10-07 03:54:35 +08:00
commit 9ca52ba798
39 files changed
+1433 -861

No files matched your search

+9 -8
View File
@@ -1,6 +1,7 @@
// 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.
// PRTS 术语释义 寒冷 (id ba.cold): 友方寒冷 pairs into 友方冻结, 「持续时间取双方之中最高」 — max(remaining, incoming). Since
// 0.2.0 the pair BECOMES the freeze (「两两一对产生友方冻结」, 异常效果 COLD 「转变为冻结」): no cold is left on the enemy
// (until then the older cold stayed on for as long as the freeze — test/sim/feedback5-kjerag-cold.test.js).
// 抵抗 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.
@@ -28,8 +29,8 @@ test('cold 10s then immediately another cold 1s → freeze lasts about 10s, not
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.findBuff('cold'), null, 'the pair became the freeze: no cold left');
close(e.findBuff('freeze').timeLeft, 10, 1e-6, 'freeze: the longer cold, not the shorter one');
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');
@@ -44,7 +45,7 @@ test('cold 1s then cold 8s → freeze lasts about 8s, not 3', () => {
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);
assert.equal(e.findBuff('cold'), null);
close(e.findBuff('freeze').timeLeft, 8, 1e-6);
h.run(3.2);
assert.ok(e.s.flags.freeze, 'still frozen past 3 s');
@@ -61,7 +62,7 @@ test('a shorter second cold does not shorten a longer remaining cold\'s freeze',
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');
assert.equal(e.findBuff('cold'), null, 'both colds became the freeze');
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');
@@ -76,8 +77,8 @@ test('抵抗 still shortens the incoming duration before the max', () => {
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');
// incoming 10 × (1 − 0.5) = 5, remaining 2 → the freeze is 5, not 10 and not 2.5 (and no cold is left)
assert.equal(e.findBuff('cold'), null, '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);
+86
View File
@@ -0,0 +1,86 @@
// Community reports of 2026-10-06 「谢拉格盟约冰冻时间没有随层数正确成长」 and 「谢拉格盟约冰冻敌人有概率在未受到后续干员施加
// 寒冷的情况下,被在无法被任何干员攻击到的地方永控」 (items 7 / 8): the 6-member wind (every 25 s, 寒冷 20 + 0.1 × layers s,
// data/bonds.json kjeragShip) froze an enemy for the rest of the battle from 51 layers on and never below: a second cold
// froze for max(remaining, incoming) and the older cold stayed on for as long, so the next gust always found a cold.
// PRTS 术语释义 寒冷: 「友方寒冷始终需要两两一对产生友方冻结,持续时间取双方之中最高」; 异常效果 COLD 「在特定条件下转变为冻结」 —
// the pair becomes the freeze, no cold is left. A 敌方 cold (on an operator) keeps the old rule.
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}`);
/** One harmless enemy parked at the gate, out of every operator's reach; 谢拉格 6 at `layers`; seconds frozen per window. */
function windTimeline(layers, until = 200) {
const h = makeBattle({
units: [{ chessId: 'chess_char_4_22_a', row: 11, col: 3 }],
bonds: { kjeragShip: { count: 6, layers, active: true, tier: 2 } },
enemies: [{ key: 'enemy_park', time: 0, route: 0 }],
defs: { enemies: { enemy_park: enemyRec({ key: 'enemy_park', hp: 1e9, atk: 0, speed: 0.00001 }) } },
timeLimit: until, seed: 1, hooks: ['statusApplied'],
});
const frozenAt = [];
while (!h.b.finished && h.b.time < until - 0.1) {
h.step();
const e = h.b.enemies[0];
frozenAt.push([h.b.time, !!(e && e.s.flags.freeze), !!(e && e.findBuff('cold'))]);
}
const frozenBetween = (t0, t1) => frozenAt.filter(([t, f]) => t >= t0 && t < t1 && f).length * h.TICK;
return { h, frozenAt, frozenBetween, at: (t) => frozenAt.find(([x]) => x >= t) };
}
test('谢拉格 wind alone: below 50 layers it never freezes (each 20 + 0.1·L s cold ends before the next gust)', REAL, () => {
const { frozenBetween } = windTimeline(40, 160);
assert.equal(frozenBetween(0, 160), 0);
});
test('谢拉格 wind alone at 60 layers: frozen 26 s out of every 50 s, not for the rest of the battle', REAL, () => {
const { frozenBetween, at } = windTimeline(60);
// gust 1 (25 s): cold 26 s; gust 2 (50 s): the pair → 26 s freeze, no cold left; gust 3 (75 s): a lone cold, still frozen
// to 76 s; gust 4 (100 s): the pair again …
close(frozenBetween(50, 100), 26, 0.2, 'the first freeze lasts the wind cold');
assert.equal(at(80)[1], false, 'free at 80 s (the parent kept it frozen from 50 s to the end)');
assert.equal(at(80)[2], true, 'with gust 3\'s lone cold on it');
assert.equal(at(101)[1], true, 'gust 4 pairs that cold: frozen again');
close(frozenBetween(100, 150), 26, 0.2);
});
test('谢拉格 wind alone: the frozen share grows with the layers (60 → 26 s, 200 → 40 s per 50 s), all of it from 300 on', REAL, () => {
close(windTimeline(60).frozenBetween(100, 150), 26, 0.2, '60 layers');
close(windTimeline(200).frozenBetween(100, 150), 40, 0.2, '200 layers');
close(windTimeline(300).frozenBetween(100, 150), 50, 0.2, '300 layers: 50 s of cold covers the 50 s cycle');
});
test('友方寒冷: the pair becomes the freeze; a lone cold on a frozen enemy is a cold and does not extend the freeze', () => {
const h = makeBattle({
defs: { enemies: { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 1e6, speed: 0 }) } },
enemies: [{ key: 'enemy_dummy', pos: [11, 8] }], content: 'none', autoFinish: false, timeLimit: 60, hooks: ['statusApplied'],
});
h.step();
const e = h.enemy('enemy_dummy');
h.b.applyStatus(e, 'cold', { duration: 6 });
h.b.applyStatus(e, 'cold', { duration: 4 });
close(e.findBuff('freeze').timeLeft, 6, 1e-6, 'max of the pair');
assert.equal(e.findBuff('cold'), null, 'no cold left');
h.run(1);
h.b.applyStatus(e, 'cold', { duration: 10 });
close(e.findBuff('freeze').timeLeft, 5, 0.05, 'a third cold does not freeze again (it has no partner)');
close(e.findBuff('cold').timeLeft, 10, 1e-6, 'it waits as a cold');
h.b.applyStatus(e, 'cold', { duration: 2 });
close(e.findBuff('freeze').timeLeft, 10, 1e-6, 'a fourth one pairs with it: max(10, 2)');
assert.equal(e.findBuff('cold'), null);
assert.equal(h.hooksOf('statusApplied').filter((c) => c.status === 'freeze').length, 2, 'two pairs, two freezes');
});
test('敌方寒冷 on an operator keeps the old rule: the freeze and the longer cold both stay', () => {
const h = makeBattle({ units: [{ chessId: 'chess_char_1_02_a', row: 10, col: 4 }], content: 'none', autoFinish: false, timeLimit: 60 });
h.step();
const u = h.unit('chess_char_1_02_a');
h.b.applyStatus(u, 'cold', { duration: 6 });
h.b.applyStatus(u, 'cold', { duration: 4 });
close(u.findBuff('freeze').timeLeft, 6, 1e-6);
close(u.findBuff('cold').timeLeft, 6, 1e-6);
});
+4 -2
View File
@@ -2,8 +2,10 @@
// 立马突袭出去,还要等突袭CD」. A 突袭 member counts a running deploy-timed skill ("部署后…秒内": 缄默德克萨斯 S1–S3 …) as 技能
// 就绪 (bonds/addon/battle.js raidPoll, GitHub #49 / #109), so after a redeploy it jumps at once when nothing is in its range.
// PRTS's 复活 (M3茧甲, 埃芒加德) is 退场 + a 0-time / 0-cost redeploy (PRTS 盟约记录 备注) — a new deployment — but the remake
// revives in place: her window, over by then, stayed over and she waited out the 10 s idle time. Now an in-place revive
// starts a deploy-timed skill again (content/items/battle.js revivedInPlace); 不屈's real redeploy already did.
// revived in place: her window, over by then, stayed over and she waited out the 10 s idle time. CB3 started a deploy-timed
// skill again after the in-place revive; since the CB1 merge the 复活 IS that redeploy (content/items/battle.js reviveNow:
// knocked out, back at once, free, on her tile), whose skill reset starts the window anew — the tests hold for both, and
// 不屈's real redeploy already did.
// Run: node --test test/sim/feedback5-raid-revive.test.js
import { test } from 'node:test';