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

+15 -8
View File
@@ -8,7 +8,7 @@ import { makeMatch, give, giveItem, DATA } from '../match/harness.js';
import { createRegistry } from '../../server/match/effectsMeta.js';
import { hasBattlePart, amedicCharsFor, PRIO_BAND_REVIVE } from '../../server/sim/content/bands/battle.js';
import { bandMetaHandler, duckReplace } from '../../server/sim/content/bands/meta.js';
import { PRIO_REVIVE } from '../../server/sim/content/items/battle.js';
import { PRIO_RESPAWN } from '../../server/sim/content/items/battle.js';
import * as bands from '../../server/sim/content/bands.js';
const QUIET = { warn() {}, error() {}, info() {} };
@@ -223,16 +223,16 @@ test('绮良 通关奖励: every 20 funds spent ⇒ 1 random operator of tier
cover('band_kirara');
});
test('佩佩 博学多通: upgrading to 2 / 4 / 6 gives 1 special (free) refresh whose operators are <萨尔贡> first', () => {
test('佩佩 博学多通: upgrading to 2 / 4 / 6 makes the next refresh special (paid as usual) — its operators are <萨尔贡> first', () => {
const seed = seedWithBond('sargonShip', 2, 2);
const { m, ps } = setup({ band: 'band_pepe', seed });
ps.shop.upgradePrice = 0;
assert.deepEqual(m.handle('p_0', { t: 'g.levelUp' }), OK);
assert.equal(ps.shop.level, 2);
assert.equal(ps.shop.freeRefreshes, 1);
assert.equal(ps.shop.freeRefreshes, 0, 'no free refresh comes with it (community report of 2026-10-06)');
const f0 = ps.funds;
assert.deepEqual(m.handle('p_0', { t: 'g.refresh' }), OK);
assert.equal(ps.funds, f0, 'free');
assert.equal(ps.funds, f0 - m.gd.refreshPrice, 'the usual refresh price');
const chess = ps.shop.slots.filter((sl) => sl && sl.kind === 'chess');
assert.ok(chess.length >= 3 && chess.every((sl) => DATA.chess[sl.id].bonds.includes('sargonShip')), 'all 萨尔贡');
assert.equal(ps.counters['band:pepe:special'], 0, 'consumed');
@@ -302,7 +302,7 @@ test('余 文火慢炖: R8 round start — exactly 1 active bond +36 layers, oth
cover('band_yu');
});
test('凯瑟琳 定向投放: every shop upgrade offers 3 different items (tier ≤ new level), 1 free pick', () => {
test('凯瑟琳 定向投放: every shop upgrade offers 3 different shop items (any tier — community report of 2026-10-06), 1 free pick', () => {
const { m, ps } = setup({ band: 'band_cathy', seed: 9 });
for (const lvl of [2, 3]) {
ps.shop.upgradePrice = 0;
@@ -313,7 +313,7 @@ test('凯瑟琳 定向投放: every shop upgrade offers 3 different items (tier
const ids = offer.slots.map((sl) => sl.id);
assert.equal(ids.length, 3);
assert.equal(new Set(ids).size, 3);
for (const id of ids) assert.ok(offer.slots[0].kind === 'item' && DATA.items[id].tier <= lvl && !DATA.items[id].isGolden, id);
for (const id of ids) assert.ok(offer.slots[0].kind === 'item' && !DATA.items[id].shopExcluded && !DATA.items[id].isGolden, id);
}
const f0 = ps.funds;
const pick = ps.offers[0].slots[2].id;
@@ -532,9 +532,12 @@ test('阿米娅 众志合一: 3 / 4 / ≥ 5 active bonds ⇒ every operator ATK
cover('band_amiya');
});
test('埃芒加德 命结之秘: the first 3 knock-downs of the battle revive at full HP (after the operators\' own revive items)', () => {
test('埃芒加德 命结之秘: the first 3 knock-downs of the battle revive at full HP (after the operators\' own revive items) — a knock-out and a free redeploy', () => {
const ops = { t_op: op('t_op'), t_b: op('t_b') };
const h = fight({ band: 'band_ermengard', ops, units: [{ chessId: 't_op', row: 10, col: 4, items: [A('4_12')] }, { chessId: 't_b', row: 11, col: 4 }], foes: [[10, 9]] });
const log = [];
h.b.on('death', (c) => log.push(['death', c.unit.defId, c.revivedBy ?? null]), { priority: -1000 });
h.b.on('deploy', (c) => { if (!c.initial && c.unit.side === 'ally') log.push(['deploy', c.unit.defId]); }, { priority: -1000 });
h.step(1);
const [a, b] = [h.unit('t_op'), h.unit('t_b')];
const kill = (u) => h.b.dealDamage(foe(h), u, { amount: 1e6, type: 'true' });
@@ -543,9 +546,13 @@ test('埃芒加德 命结之秘: the first 3 knock-downs of the battle revive at
kill(a); kill(b); kill(b); // band revives 1, 2, 3
assert.ok(a.alive && b.alive);
close(b.hp, 2000, 'full HP');
// PRTS 备注 "“复活”的实现方式为:受益者因移动之外的原因退场时下次部署的再部署时间和费用归零": each revive is a knock-out
// (death hooks — 被击倒时 effects) and an immediate free redeploy where it lies (deploy hooks — 部署时 effects)
assert.deepEqual(log.filter((x) => x[0] === 'death').map((x) => x.slice(1)), [['t_op', 'item'], ['t_op', 'band'], ['t_b', 'band'], ['t_b', 'band']]);
assert.deepEqual(log.filter((x) => x[0] === 'deploy').map((x) => x[1]), ['t_op', 't_op', 't_b', 't_b'], 'each knock-out redeployed at once');
kill(a);
assert.equal(a.alive, false, '4th knock-down of the band: dies');
assert.ok(PRIO_BAND_REVIVE < PRIO_REVIVE);
assert.ok(PRIO_BAND_REVIVE < PRIO_RESPAWN, 'the band after the item revive (both death hooks, after every fatal saver)');
cover('band_ermengard');
});
+12 -8
View File
@@ -542,7 +542,7 @@ test('阿戈尔 5: a member devoured by several 阿戈尔 spends one slot at mos
checkInvariants(t.b);
});
test('阿戈尔 5: an operator\'s own save uses no slot (斯卡蒂\'s DRE-Y, M3茧甲, 埃芒加德 [ASSUMED for the band]); the slot waits for its first real knock-out', () => {
test('阿戈尔 5: an operator\'s own save or revive uses no slot (斯卡蒂\'s DRE-Y, M3茧甲, 埃芒加德 [ASSUMED for the band]); the slot waits for its first unanswered knock-out', () => {
// 斯卡蒂 (elite, module DRE-Y by default) enters 联防 at half HP in front of 乌尔比安: his mark is lethal, DRE-Y saves her
// (PRTS: once per deployment, full HP with max HP −60 %) — no knock-out, so no slot
const SKADI = 'chess_char_3_05_b', ULPIA = 'chess_char_5_05_a', GHOST = 'chess_char_2_07_a', HN = 'chess_char_3_09_a', DEEP = 'chess_char_1_04_a';
@@ -568,20 +568,23 @@ test('阿戈尔 5: an operator\'s own save uses no slot (斯卡蒂\'s DRE-Y, M3
const list = [['g0_a', ['egirShip']], ['g1_a', ['egirShip']], ['g2_a', ['egirShip']], ['g3_a', ['egirShip']], ['g4_a', ['egirShip']]];
const units = (it = {}) => list.map(([chessId], i) => ({ chessId, row: 9 + (i % 2) * 3, col: 3 + i, items: it[i] }));
const killed = (b, id) => { const u = b.unit(id); b.b.dealDamage(null, u, { amount: 1e9, type: 'true' }); return u; };
// M3茧甲 (an item 复活, in place): g0 is saved, the 3 slots go to g1, g2, g3; g4 stays down
// M3茧甲 (an item 复活 — since 0.2.0 PRTS's form: a knock-out answered by a free redeploy, items reviveNow): g0 is knocked
// out and back at once, the 3 slots go to g1, g2, g3; g4 stays down
const hi = makeBattle({ defs: defsOf(list), autoFinish: false, timeLimit: 60, hooks: ['death', 'deploy'], units: units({ 0: ['chess_item_4_12_e_a'] }), bonds: { egirShip: bondOn(5, 0, null, [3, 5]) } });
hi.step(1);
const g0 = killed(hi, 'g0_a');
assert.ok(g0.alive && hi.hooksOf('death').filter((c) => c.unit === g0).length === 0, 'item: saved in place (no knock-out)');
assert.deepEqual(['g1_a', 'g2_a', 'g3_a', 'g4_a'].map((id) => killed(hi, id).alive), [true, true, true, false], 'item: three slots left after the save');
assert.ok(g0.alive && hi.hooksOf('death').filter((c) => c.unit === g0 && c.reason === 'killed').length === 1, 'item: knocked out and revived');
assert.equal(hi.hooksOf('death').find((c) => c.unit === g0).revivedBy, 'item');
assert.deepEqual(['g1_a', 'g2_a', 'g3_a', 'g4_a'].map((id) => killed(hi, id).alive), [true, true, true, false], 'item: three slots left after the revive');
checkInvariants(hi.b);
// 埃芒加德 (命结之秘: the battle's first 3 knock-downs revive in place — its own count, unchanged): g0, g1, g2 saved by it;
// the 阿戈尔 slots then go to g3, g4 and g0's first real knock-out; g1's real one finds none
// 埃芒加德 (命结之秘: the battle's first 3 knock-downs revive at once — its own count, unchanged): g0, g1, g2 revived by it;
// the 阿戈尔 slots then go to g3, g4 and g0's next knock-out (its first unanswered one); g1's finds none
const hb = makeBattle({ defs: defsOf(list), autoFinish: false, timeLimit: 60, hooks: ['death', 'deploy'], units: units(), bandId: 'band_ermengard', bonds: { egirShip: bondOn(5, 0, null, [3, 5]) } });
hb.step(1);
for (const id of ['g0_a', 'g1_a', 'g2_a']) {
const u = killed(hb, id);
assert.ok(u.alive && hb.hooksOf('death').filter((c) => c.unit === u).length === 0, `band: ${id} saved in place`);
const deaths = hb.hooksOf('death').filter((c) => c.unit === u);
assert.ok(u.alive && deaths.length === 1 && deaths[0].revivedBy === 'band', `band: ${id} knocked out and revived by the band`);
}
assert.deepEqual(hb.eventsOf('fx').filter((x) => x[1] === 'revive' && x[4] && x[4].left != null).map((x) => x[4].left), [2, 1, 0], '埃芒加德 spent its 3');
assert.deepEqual(['g3_a', 'g4_a', 'g0_a', 'g1_a'].map((id) => killed(hb, id).alive), [true, true, true, false], 'band: the 阿戈尔 slots are untouched');
@@ -657,7 +660,8 @@ test('阿戈尔 devour, a unit back during the pass: a marker revived by the 5-t
['fod_a', ['indomShip'], { tier: 2, stats: { maxHp: 3000 } }],
];
const units2 = [{ chessId: 'g1_a', row: 10, col: 3 }, { chessId: 'g2_a', row: 10, col: 4 }, { chessId: 'fod_a', row: 10, col: 5 }, { chessId: 'g3_a', row: 12, col: 3 }];
// 埃芒加德 (命结之秘: the first 3 knocked-out operators revive in place — a new deployment, items revivedInPlace)
// 埃芒加德 (命结之秘: the first 3 knocked-out operators revive — knocked out, then redeployed at once where they lie,
// a new deployment: items reviveNow)
const e = makeBattle({ defs: defsOf(list2), units: units2, bandId: 'band_ermengard', bonds: { egirShip: bondOn(3, 0, null, [3, 5]) }, hooks: ['damaged'], captureNoisy: true });
e.step(1);
const fe = e.unit('fod_a');
+2 -1
View File
@@ -156,7 +156,8 @@ test('F1 坚固维式重锤 in front of an 阿戈尔: the battle-start devour sp
});
test('F1 坚固维式重锤 + M3茧甲: the lock comes before the revive whatever the equip order (PRTS: a revive acts on 退场)', () => {
assert.ok(PRIO_RESPAWN < PRIO_REVIVE && PRIO_RESPAWN > PRIO_BAND_REVIVE, 'revive items: after every 不死, before 埃芒加德');
// revive items: a `death` hook (after every 不死 — a fatal saver), before 埃芒加德's
assert.ok(Number.isFinite(PRIO_REVIVE) && PRIO_RESPAWN > PRIO_BAND_REVIVE, 'revive items before 埃芒加德');
for (const items of [[M3, HAMMER], [HAMMER, M3]]) {
const c = carrier(items);
const log = fxLog(c.h, c.u);
@@ -0,0 +1,44 @@
// Community report of 2026-10-06 「维多利亚的锁血锤和烛煌的锁血只生效了一个」 (item 5): both work, one after the other. On
// 烛煌's lethal hit her talent 绝处重燃 (kit `fatal` −50: 倒地, 6000 barrier, PRTS 备注 「次数不限」 outside 【重燃】) comes first;
// 坚固维式重锤's once-per-deployment 不死 lock (PRIO_REVIVE −100: consumable savers run last, so a talent's own undying never
// wastes the charge — items/battle.js header, DESIGN §21.21 / §22) waits for a lethal hit while she is 倒地, then holds her
// at 1 HP for 8 s. No source gives that order (the official autochess hammer buff is not in the client's buff templates);
// the owner kept it (the owner's decision of 2026-10-07: 绝处重燃 first, then the lock; DESIGN §25.21.1).
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() };
/** 烛煌 (elite) carrying `items`, hit for 3000 true damage every 2 s by a far enemy; the timeline of her savers. */
function run(items) {
const h = makeBattle({
units: [{ chessId: 'chess_char_5_03_b', row: 9, col: 4, items }],
enemies: [{ key: 'enemy_far', time: 0, route: 0 }],
defs: { enemies: { enemy_far: enemyRec({ key: 'enemy_far', hp: 1e9, atk: 0, bat: 99, speed: 0.0001 }) } },
timeLimit: 120, seed: 3,
});
const u = h.unit('chess_char_5_03_b');
const out = { downedAt: null, lockAt: null, deathAt: null };
h.b.every(2, () => { const e = h.b.enemies.find((x) => x.alive); if (e && u.alive) h.b.dealDamage(e, u, { amount: 3000, type: 'true', canDodge: false }); });
h.b.on('death', (c) => { if (c.unit === u && out.deathAt == null) out.deathAt = h.b.time; }, { priority: -1000 });
h.step(); // the battle-start deployment
while (!h.b.finished && h.b.time < 60 && u.alive) {
h.step();
if (out.downedAt == null && u.mem.downed) out.downedAt = h.b.time;
if (out.lockAt == null && u.mem.undyingUntil != null) out.lockAt = h.b.time;
}
return out;
}
test('烛煌 + 坚固维式重锤: 绝处重燃 on the first lethal hit, the lock on the next one while 倒地, death 8 s later', REAL, () => {
const w = run(['chess_item_3_09_e_a']);
assert.ok(w.downedAt != null && w.downedAt < 2.1, `倒地 at the first lethal hit (2 s), got ${w.downedAt}`);
assert.ok(w.lockAt != null && w.lockAt > 7.9 && w.lockAt < 8.1, `the lock at the hit that breaks the barrier (8 s), got ${w.lockAt}`);
assert.ok(Math.abs(w.deathAt - 16) < 0.1, `8 s of 不死, then the next lethal hit (16 s), got ${w.deathAt}`);
const bare = run([]);
assert.equal(bare.lockAt, null);
assert.ok(Math.abs(bare.deathAt - 8) < 0.1, `without the hammer she falls at 8 s, got ${bare.deathAt}`);
});
+70
View File
@@ -0,0 +1,70 @@
// Community reports of 2026-10-06 that are the official behaviour (items 45, 49, 50) — pinned so the answer stays true.
// 49 「炎祐能吃到掉血道具的效果」: PRTS 卫戍协议:盟约 下半/PRTS盟约记录, 源石溶剂 备注 「携带后,全场范围内的所有敌人类我方单位也会
// 获得此装备的“每秒受到60真实伤害”效果(该效果的付与为我方阵营索敌,可对空,无视目标可选性)」 — the 炎佑 is one (no ATK bonus).
// 50 「两个空弦转职阿戈尔然后连一起…最后面的空弦不能吃前面的空弦」: two operators made 阿戈尔 by 变形同构体 + 阿戈尔重刃 devour like any
// other (same name or not); two NORMAL copies of one item merge (the official merge rule), so the pair needs a normal
// and an 进阶 set.
// 45 「寻呼模块和电话对干员缪尔赛思用不会给缪尔赛思,会直接道具被吞」: 寻呼模块 「(不高于当前调度中心等级)」 and 简易通讯机 (the
// phone icon; 备注 「获得的干员等阶不高于当前调度中心等级」) look for an operator sharing her only bond, 调和: 缪尔赛思 herself, tier
// VI — below shop level 6 there is none and the item is used up with nothing, as 拟态物质 at the copy cap (§25.13.1).
// The owner kept the official cap, no fallback to any tier (the owner's decision of 2026-10-07; DESIGN §25.21.10).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle } from '../helpers/battleHarness.js';
import { makeMatch } from '../match/harness.js';
import { hasGeneratedData } from '../../server/sim/simdata.js';
const REAL = { skip: !hasGeneratedData() };
test('49: a 源石溶剂 carrier on the field drains the 炎佑 60 HP a second, with no ATK bonus', REAL, () => {
const ids = ['chess_char_1_03', 'chess_char_2_04', 'chess_char_3_03', 'chess_char_3_04', 'chess_char_4_15', 'chess_char_4_17'];
const tiles = [[11, 3], [11, 4], [12, 3], [12, 4], [11, 5], [12, 5]];
const h = makeBattle({
units: ids.map((id, i) => ({ chessId: `${id}_a`, row: tiles[i][0], col: tiles[i][1], items: i === 0 ? ['chess_item_1_05_e_a'] : [] })),
bonds: { yanShip: { count: 6, layers: 1, active: true, tier: 2 } }, timeLimit: 30, seed: 1, autoFinish: false,
});
h.step(2);
const y = h.b.allyUnits.find((u) => u.defId === 'enemy_9012_acloon');
const hp0 = y.hp, atk0 = y.s.atk;
h.run(10);
assert.ok(Math.abs(hp0 - y.hp - 600) < 1e-6, `600 HP in 10 s, got ${hp0 - y.hp}`);
assert.equal(y.s.atk, atk0, 'no ATK from the item');
});
test('50: two 空弦 made 阿戈尔 (变形同构体 + 阿戈尔重刃) in a row — the back one devours the front one; any pair does', REAL, () => {
const morph = (chessId, col, golden = false) => ({ chessId, row: 10, col, items: golden ? ['chess_item_6_09_e_b', 'chess_item_3_07_e_b'] : ['chess_item_6_09_e_a', 'chess_item_3_07_e_a'] });
for (const [label, pair] of [
['two 空弦', [morph('chess_char_3_21_a', 3), morph('chess_char_3_21_a', 4, true)]],
['空弦 + 哈洛德', [morph('chess_char_3_21_a', 3), morph('chess_char_2_05_a', 4, true)]],
]) {
const h = makeBattle({
units: [...pair, { chessId: 'chess_char_2_07_a', row: 12, col: 8 }, { chessId: 'chess_char_3_09_a', row: 9, col: 9 }],
bonds: { egirShip: { count: 3, active: true, tier: 1, layers: 0 } }, timeLimit: 10, seed: 1, autoFinish: false,
});
const marks = [];
h.b.on('damaged', (c) => { if (c.dmg?.tags?.includes('bond:egir:devour')) marks.push([c.source.tileC, c.target.tileC]); }, { priority: -1000 });
h.step(2);
assert.deepEqual(marks, [[3, 4]], `${label}: the back one (col 3) devours the front one (col 4)`);
}
});
test('45: 寻呼模块 / 简易通讯机 on 缪尔赛思 below shop level 6 — nothing qualifies (调和, tier ≤ shop level), the item is used up', () => {
for (const itemId of ['chess_item_4_01_e_a', 'chess_item_2_06_e_a']) {
for (const level of [5, 6]) {
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', humans: 1, seed: 7, fake: true }).start();
h.toPrep(1);
const ps = h.ps('p_0');
ps.shop.level = level;
const mly = ps.acquireChess('chess_char_6_11_a', { source: 'test' });
const item = ps.acquireItem(itemId, { source: 'test' });
const owned = () => [...ps.board.values(), ...ps.hand, ...ps.temp].filter((p) => p && p.id === 'chess_char_6_11_a').length;
const n0 = owned(), offers0 = ps.offers.length;
assert.deepEqual(h.m.handle('p_0', { t: 'g.equip', itemUid: item.uid, targetUid: mly.uid }), { ok: true });
assert.equal(ps.find(item.uid), null, `${itemId} @${level}: consumed`);
const got = owned() - n0 + (ps.offers.length - offers0);
if (level < 6) assert.equal(got, 0, `${itemId} @${level}: nothing (no 调和 operator of tier ≤ ${level})`);
else assert.equal(got, 1, `${itemId} @6: 缪尔赛思 (granted, or offered by 寻呼模块)`);
}
}
});
@@ -0,0 +1,80 @@
// Community report of 2026-10-06 (items 9 / 37) 「卡西米尔和艾芒加德策略似乎有BUG:像砾和瑕光这种死亡和部署的叠层效果,如果有
// 艾芒加德的3次复活似乎是无法触发…只有最后一次真的死了没站起来才叠层了」: 埃芒加德's 命结之秘 (and M3茧甲) revived in place — a
// `fatal` saver — so no 被击倒时 and no 部署时 effect fired. PRTS (卫戍协议:盟约 下半/PRTS盟约记录, 埃芒加德 and M3茧甲 备注):
// "“复活”的实现方式为:受益者因移动之外的原因退场时下次部署的再部署时间和费用归零" — the knock-out stands and the operator
// redeploys at once, free: items/battle.js reviveNow, a `death` hook.
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 GRAVEL = 'chess_char_2_12_a'; // 砾: <部署时>已激活的【卡西米尔】+1, <被击倒时>已激活的【不屈】+2
const BLEMISH = 'chess_char_3_12_a'; // 瑕光: <部署时>自身已激活的盟约 +4 (每场作战至多12层)
function setup({ band = 'band_ermengard', items = {} } = {}) {
const h = makeBattle({
units: [{ chessId: GRAVEL, row: 9, col: 4, items: items[GRAVEL] }, { chessId: BLEMISH, row: 11, col: 4, items: items[BLEMISH] }],
bonds: {
kazimierzShip: { count: 3, layers: 1, active: true, tier: 1 },
indomShip: { count: 2, layers: 1, active: true, tier: 1 },
raidShip: { count: 2, layers: 1, active: true, tier: 1 },
},
bandId: band,
enemies: [{ key: 'enemy_far', time: 0, route: 0 }],
defs: { enemies: { enemy_far: enemyRec({ key: 'enemy_far', hp: 1e9, atk: 0, bat: 99, speed: 0.0001 }) } },
timeLimit: 120, seed: 3,
});
const gains = [];
h.b.on('layerGain', (c) => gains.push([c.source?.defId, c.bondId, c.n]), { priority: -1000 });
h.step();
const kill = (id) => { const u = h.unit(id); h.b.dealDamage(h.b.enemies[0], u, { amount: 1e7, type: 'true', canDodge: false }); return u; };
const sum = (id, bond) => gains.filter(([s, b]) => s === id && b === bond).reduce((a, [, , n]) => a + n, 0);
return { h, kill, sum, gains };
}
test('埃芒加德: each of the 3 revives is a knock-out and a deployment — 砾 gains 不屈 +2 and 卡西米尔 +1 every time', REAL, () => {
const { h, kill, sum } = setup();
assert.equal(sum(GRAVEL, 'kazimierzShip'), 1, 'the battle-start deployment');
for (let i = 1; i <= 3; i++) {
const u = kill(GRAVEL);
assert.ok(u.alive, `revive ${i}: standing again`);
assert.equal(sum(GRAVEL, 'indomShip'), 2 * i, `revive ${i}: <被击倒时> 不屈 +2 (the parent: 0 until the 4th)`);
assert.equal(sum(GRAVEL, 'kazimierzShip'), 1 + i, `revive ${i}: <部署时> 卡西米尔 +1`);
h.run(0.5);
}
assert.deepEqual(h.eventsOf('fx').filter((e) => e[1] === 'revive' && e[4] && e[4].left != null).map((e) => e[4].left), [2, 1, 0], 'the band\'s 3');
});
test('埃芒加德: 瑕光\'s <部署时> +4 to her own active bonds fires on a revive, up to its 12 per battle', REAL, () => {
const { kill, sum } = setup();
assert.equal(sum(BLEMISH, 'kazimierzShip'), 4);
kill(BLEMISH);
assert.equal(sum(BLEMISH, 'kazimierzShip'), 8, 'first revive');
assert.equal(sum(BLEMISH, 'raidShip'), 8);
kill(BLEMISH);
assert.equal(sum(BLEMISH, 'kazimierzShip'), 12, 'second revive: the cap');
kill(BLEMISH);
assert.equal(sum(BLEMISH, 'kazimierzShip'), 12, 'third revive: still 12 (max_add_count_per_battle)');
});
test('埃芒加德: a revive resets the operator like a redeploy (SP back to its initial value, a new deployment)', REAL, () => {
const { h, kill } = setup();
const u = h.unit(BLEMISH);
u.skill.gainSp(3, 'test');
const seq = u.deploySeq;
assert.ok(u.skill.sp > u.skill.initSp + 1e-6, `SP charged (${u.skill.sp})`);
kill(BLEMISH);
assert.ok(u.deploySeq > seq, 'a new deployment');
assert.ok(Math.abs(u.skill.sp - u.skill.initSp) < 1e-6, `SP reset to ${u.skill.initSp}, got ${u.skill.sp}`);
assert.equal(Math.round(u.hp), Math.round(u.s.maxHp), 'full HP');
});
test('M3茧甲: the same 复活 — a knock-out (砾 不屈 +2) and a deployment (卡西米尔 +1), once per battle', REAL, () => {
const { kill, sum } = setup({ band: null, items: { [GRAVEL]: ['chess_item_4_12_e_a'] } });
const u = kill(GRAVEL);
assert.ok(u.alive);
assert.equal(sum(GRAVEL, 'indomShip'), 2);
assert.equal(sum(GRAVEL, 'kazimierzShip'), 2);
});
@@ -0,0 +1,44 @@
// Community report of 2026-10-06 「Touch策略给的医疗干员会跟炎盟约的炎祐冲突,无法同时出场」 (item 23): the 炎 bond's 炎佑 and the
// 外勤医疗 map character spawn at the same battle start; the 炎佑's free-tile search (tokens.js airTile: void tiles nearest the
// half's centre) took the medic's stage slot (data/stages.json mapChars, (10, 2)) on 战场#08 涨潮控制 (and 战场#07 with two
// 炎佑), so the medic found its tile taken and never stood. The map characters' positions are held for them now.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle } from '../helpers/battleHarness.js';
import { hasGeneratedData } from '../../server/sim/simdata.js';
const REAL = { skip: !hasGeneratedData() };
const YAN = ['chess_char_1_03', 'chess_char_2_04', 'chess_char_3_03', 'chess_char_3_04', 'chess_char_4_15', 'chess_char_4_17', 'chess_char_5_03', 'chess_char_5_12', 'chess_char_6_03'];
const TILES = [[9, 3], [9, 4], [10, 3], [10, 4], [11, 3], [11, 4], [9, 5], [11, 5], [10, 5]];
function tokensAt(stageId, n, elite = false) {
const h = makeBattle({
stageId, units: YAN.slice(0, n).map((id, i) => ({ chessId: `${id}_${elite && i < 2 ? 'b' : 'a'}`, row: TILES[i][0], col: TILES[i][1] })),
bonds: { yanShip: { count: n, layers: 1, active: true, tier: n >= 9 ? 3 : 2 } }, bandId: 'band_amedic', timeLimit: 10, seed: 1, autoFinish: false,
});
h.step(3);
const toks = h.b.allyUnits.filter((u) => u.kind === 'token' && u.alive);
return {
yan: toks.filter((u) => u.defId === 'enemy_9012_acloon'),
medic: toks.find((u) => u.defId === 'char_605_cmedic' || u.defId === 'char_613_acmedc') ?? null,
};
}
test('战场#08 涨潮控制: the 炎佑 and 外勤医疗\'s medic both stand, the medic on its own slot', REAL, () => {
const t = tokensAt('act2autochess_m04', 6);
assert.equal(t.yan.length, 1);
assert.ok(t.medic, 'the medic stands (the parent: none — the 炎佑 sat on (10, 2))');
assert.deepEqual([t.medic.tileR, t.medic.tileC], [10, 2]);
assert.ok(!(t.yan[0].tileR === 10 && t.yan[0].tileC === 2));
const touch = tokensAt('act2autochess_m04', 6, true);
assert.equal(touch.medic?.defId, 'char_613_acmedc', 'two elites: Touch, on the same slot');
});
test('9 炎: two 炎佑 and the medic on every season map', REAL, () => {
for (const stageId of ['act2autochess_m01', 'act2autochess_m02', 'act2autochess_m03', 'act2autochess_m04']) {
const t = tokensAt(stageId, 9);
assert.equal(t.yan.length, 2, stageId);
assert.ok(t.medic, `${stageId}: the medic stands`);
}
});
+4 -1
View File
@@ -503,7 +503,10 @@ test('灵知 S2: 130 % ATK arts + 2.5 s cold to all in range; fully charged cast
assert.ok(h.runUntil(() => u.skill.activations >= 2, 20));
const starts = noisy(h, 'skillStart').filter((c) => c.unit === u).map((c) => c.t);
const colds = (T) => noisy(h, 'statusApplied').filter((c) => c.source === u && c.status === 'cold' && c.duration === bb.cold && c.t === T).length;
assert.equal(colds(starts[0]), 4, 'charged: two colds on each of the two enemies');
const freezes = (T) => noisy(h, 'statusApplied').filter((c) => c.source === u && c.status === 'freeze' && c.t === T).length;
// charged: two colds on each of the two enemies — each enemy's pair becomes one freeze (友方寒冷 「两两一对」, since 0.2.0)
assert.equal(colds(starts[0]), 2, 'charged: the first cold on each enemy lands as a cold');
assert.equal(freezes(starts[0]), 2, 'charged: the second cold on each enemy turns the pair into a freeze');
assert.equal(colds(starts[1]), 2, 'single charge: one cold each');
// normal attacks chill for 1 s
assert.ok(noisy(h, 'statusApplied').some((c) => c.source === u && c.status === 'cold' && c.duration === t0.cold));
+2 -1
View File
@@ -8,6 +8,7 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { deploymentOf } from '../../server/sim/content/items/battle.js';
import { makeBattle, enemyRec, chessRec, checkInvariants } from '../helpers/battleHarness.js';
import { KITTED_CHARS, OPERATOR_KITS, KITS } from '../../server/sim/content/kits/index.js';
import { DUEL_KEY, OATH_KEY, PAIN_KEY, REBORN_KEY } from '../../server/sim/content/kits/ops/op-lessng.js';
@@ -327,7 +328,7 @@ test('DRE-Y “苦修者的抗压训练”: the first lethal hit of a deployment
done(h);
// a running 不死 (坚固维式重锤's window) holds him instead: the module is not used
const r = field({ tier, elite: true, mod: DREY });
r.u.mem.undyingAt = `${r.u.deploySeq}:0`;
r.u.mem.undyingAt = deploymentOf(r.u);
r.u.mem.undyingUntil = r.h.b.time + 5;
const hammer = r.h.b.on('fatal', (c) => { if (c.unit === r.u && !c.prevented) c.prevented = true; }, { priority: -99 });
const f = r.h.spawn('enemy_dummy', { pos: [11, 8] });
+2 -1
View File
@@ -8,6 +8,7 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { deploymentOf } from '../../server/sim/content/items/battle.js';
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { KITTED_CHARS, OPERATOR_KITS, KITS } from '../../server/sim/content/kits/index.js';
import { diyPool, validateDiyPicks } from '../../shared/diy.js';
@@ -350,7 +351,7 @@ test('SBL-Y (stages 1 and 3): knocked out ⇒ he stays and heals 30 % max HP, on
// a running 不死 (坚固维式重锤's window) keeps him up instead
h.b.redeploy(u);
const r = field({ tier, elite: true, mod: SBLY, skill: 1 });
r.u.mem.undyingAt = `${r.u.deploySeq}:0`;
r.u.mem.undyingAt = deploymentOf(r.u);
r.u.mem.undyingUntil = r.h.b.time + 5;
const hammer = r.h.b.on('fatal', (c) => { if (c.unit === r.u && !c.prevented) c.prevented = true; }, { priority: -99 });
const f = r.h.spawn('enemy_dummy', { pos: [10, 6] });