feedback5: merge CB3 — community reports (operator kits): 砾's normal attack hits twice at 50 % 伤害倍率 (a 频次 enemy loses 2), 乌尔比安's S3 moves only onto his own board (never behind the one-helper 联防 map's red gate nor onto a boss field's other half), the 「受到攻击时」 counters answer every enemy damage instance (菲莱 S2 on 深溟巢涌者's pulse, 星熊, 泡泡, 刺玫, 余, 玛恩纳, 年, 斩业星熊), an in-place 复活 restarts a deploy-timed skill so a 突袭 member jumps at once, 银灰's normal attack plays its own impact (and 雷蛇 / 初雪 / 圣约送葬人 drop a skill mode's sound); five reports pinned; §25.17 between CB2's §25.16 and CB4's §25.18; data/assets.json lists 银灰's impact (p_imp_spear_n.mp3 fetched); golden: vs feedback5 CB3's own rules — roster-002/004/005/008/010/012/016/024/033/044, bond-indomShip-low/high, bond-kazimierzShip-low/high, bond-lateranoShip-low, boss-boss_2-solo, boss-boss_4-pair/-solo, hidden-boss_8-pair/-solo, hidden-boss_9-solo, diy-051, matches solo-FUNNY-2, coop2-NORMAL-3, coop4-ABYSS-7, coop2-ABYSS-10-boosted; vs CB3 exactly what feedback5 changed since its base (CB2, CB4, CB5)
The merge:
- server/sim/battle/players.js: CB3's onOwnBoard hunk is byte-identical to CB4's (git kept one); its comment and
docs/SIM.md's onOwnBoard row now name 乌尔比安's S3 【移动】 next to the 突袭 landing (DESIGN §25.17.2 / §25.18).
- test/sim/facing-invariance.test.js: CB3's scenario setup (onOwnBoard off on the open field) and the CB4 merge's
突袭 test (its enemy inside the board) merged cleanly, both kept.
- data/assets.json: CB3 had regenerated it offline without 银灰's entry (his official impact file was not on disk);
`node tools/fetch-assets.mjs --add-only` (shared public/assets, add-only) downloaded p_imp_spear_n.mp3 — 8.8 KB,
the only new file (ok 1, skipped 7968; the 117 misses are the known upstream gaps) — and rewrote the manifest.
Field-wise JSON compare with CB3's manifest: only audio.sfx.units.char_172_svrash = { hit: p_imp_spear_n } plus hash
and stats (files / bytes / sfxUnits +1); against feedback5: CB3's four operators (银灰 now the spear impact and no
S3 swing, 雷蛇 / 初雪 / 圣约送葬人 without the skill-mode impact) plus hash / stats. §25.17.6 says so.
- docs/history/0.2.0.md: §25.17 placed in order, the intro's per-stream list and workstream list name CB3.
Golden moves outside CB3's commit list: coop2-NORMAL-3 fields 砾 from round 3 (her double hit) and 刺玫 in rounds 4-7
(instrumented); solo-FUNNY-1 (in CB3's list through the counters) fields no counter operator on feedback5's path, only
砾, whose two half hits change no kill, leak, LP or end stat there, so its digest stays.
Gate: non-browser suites 5003 / 5003, npm run golden ok, test/golden.test.js 9 / 9, lint 0 errors, check:imports ok,
typecheck ok.
This commit is contained in:
commit
d841d6ac22
33 files changed
+956
-245
No files matched your search
@@ -48,7 +48,12 @@ function scenario(chessId, dir, secs) {
|
||||
e_fly: enemyRec({ key: 'e_fly', hp: 1e6, speed: 0, motion: 'FLY', def: 50, res: 30 }),
|
||||
},
|
||||
},
|
||||
setup: (b) => b.on('deploy', ({ unit }) => { if (unit.uid >= 100) unit.hp = unit.s.maxHp * (0.3 + 0.1 * (unit.uid - 100)); }),
|
||||
setup: (b) => {
|
||||
b.on('deploy', ({ unit }) => { if (unit.uid >= 100) unit.hp = unit.s.maxHp * (0.3 + 0.1 * (unit.uid - 100)); });
|
||||
// the open field has no boards: "a tile of the player's own board" (Battle.onOwnBoard — 乌尔比安 S3's landing) is a
|
||||
// board-position rule (DESIGN), not a facing one — every tile counts here, so the anchor's landing turns with him
|
||||
b.onOwnBoard = () => true;
|
||||
},
|
||||
});
|
||||
h.run(secs);
|
||||
checkInvariants(h.b);
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
// test/sim/feedback5-cb3-pins.test.js — community reports of 2026-10-06 that this tree already answers (stream CB3; they pass
|
||||
// on the parent commit — nothing changed for them), pinned:
|
||||
// item 2 「敌人在干员铃兰技能范围内没有开技能,在攻击范围才开」 — 铃兰's default S3 狐火渺然 ("攻击范围扩大") was on the basic
|
||||
// strategy up to 0.1.4 (DEFAULT: an enemy in her own 2-tile range); 0.2.0's ACTIVE_RANGE (the owner's rule of 2026-10-05,
|
||||
// tools/build-data.mjs resolveTrigger) casts it with an enemy inside its running range y-8 (3 tiles ahead).
|
||||
// item 16.1 「突袭转职玛恩纳…攻击范围里有怪的时候即使长时间没攻击也不会突袭」 — the bond text itself: "干员10秒内未进行攻击或技能
|
||||
// 就绪时,若范围内没有敌人,则…再部署": a 解放者 never attacks with his skill off, so his 10 s are always met, and an enemy in
|
||||
// his range is the one thing that holds the jump — as for every member.
|
||||
// item 16.4 「归鲨被吃的时候不是变替身而是进了再部署」 — an 阿戈尔 devour mark on 归溟幽灵鲨 switches her to her 替身 (normal, 联防,
|
||||
// boss fields); never a knock-out (a sweep of 400 random chains on this tree and v0.1.2–v0.1.4: 0).
|
||||
// item 46 「荒芜拉普兰德开技能之后好像没有普攻了…叙拉古恐惧概率不高」 — during her default S3 every drone is out chasing (PRTS
|
||||
// 技能流程), so she makes no attack of her own; her attacks come back when it ends. 叙拉古 6's proc is PRTS's pseudo-random
|
||||
// rule: 0.139 % per 普通伤害 attempt, +0.139 % per miss, shared by the player's members (3 % on average).
|
||||
// Run: node --test test/sim/feedback5-cb3-pins.test.js
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
import { getDefaultSource } from '../../server/sim/simdata.js';
|
||||
import { prdConstant } from '../../server/sim/content/bonds/core.js';
|
||||
|
||||
const ds = getDefaultSource();
|
||||
const dummy = (key, o = {}) => enemyRec({ key, hp: 1e9, speed: 0, mass: 0, atk: 0, ...o });
|
||||
|
||||
test('item 2 — 铃兰 S3 狐火渺然: ACTIVE_RANGE on its y-8; casts with an enemy 3 tiles ahead (outside her own range), not 4', () => {
|
||||
for (const id of ['chess_char_5_10_a', 'chess_char_5_10_b']) {
|
||||
const rec = ds.rawChess(id);
|
||||
const s3 = rec.skills.find((s) => s.skillId === 'skchr_lisa_3');
|
||||
assert.deepEqual([rec.skill.skillId, s3.trigger.rule, s3.trigger.rawRule], ['skchr_lisa_3', 'ACTIVE_RANGE', 'DEFAULT'], `${id}: her default S3`);
|
||||
assert.ok(!rec.rangeGrid.some(([r, c]) => r === 0 && c === 3) && s3.trigger.customRangeGrid.some(([r, c]) => r === 0 && c === 3), `${id}: 3 ahead is in y-8 only`);
|
||||
const casts = (pos) => {
|
||||
const h = makeBattle({ seed: 3, autoFinish: false, timeLimit: 60, defs: { enemies: { enemy_pin: dummy('enemy_pin') } }, units: [{ chessId: id, row: 10, col: 4 }] });
|
||||
h.step();
|
||||
const u = h.unit(id);
|
||||
u.skill.gainSp(999, 'test');
|
||||
h.spawn('enemy_pin', { pos });
|
||||
h.run(3);
|
||||
checkInvariants(h.b);
|
||||
return u.skill.activations;
|
||||
};
|
||||
assert.equal(casts([10, 7]), 1, `${id}: 3 tiles ahead`);
|
||||
assert.equal(casts([10, 8]), 0, `${id}: not 4 tiles ahead`);
|
||||
}
|
||||
});
|
||||
|
||||
test('item 16.1 — a 突袭 玛恩纳 (变形同构体 + 突袭手雷): no jump while an enemy stays in his range ("若范围内没有敌人"); at once when none is', () => {
|
||||
const h = makeBattle({
|
||||
seed: 5, autoFinish: false, timeLimit: 120, hooks: ['deploy'], bonds: { raidShip: { count: 3, active: true, tier: 1, layers: 1 } },
|
||||
defs: { enemies: { enemy_pin: dummy('enemy_pin') } },
|
||||
units: [{ chessId: 'chess_char_5_19_a', row: 10, col: 4, items: ['chess_item_6_09_e_a', 'chess_item_3_11_e_a'] }],
|
||||
});
|
||||
h.step();
|
||||
const u = h.unit('chess_char_5_19_a');
|
||||
const near = h.spawn('enemy_pin', { pos: [10, 5] });
|
||||
h.spawn('enemy_pin', { pos: [12, 8] });
|
||||
const jumps = () => h.hooksOf('deploy').filter((c) => c.unit === u && !c.initial).length;
|
||||
h.run(20);
|
||||
assert.equal(u.lastAttackAt, -Infinity, 'a 解放者 makes no attack with his skill off (his 10 s are met)');
|
||||
assert.equal(jumps(), 0, 'an enemy in his range: no jump');
|
||||
h.b.kill(near, null);
|
||||
assert.ok(h.runUntil(() => jumps() > 0, 1), 'none in range: he jumps at once');
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('item 16.4 — 归溟幽灵鲨 devoured by an 阿戈尔 (normal, 联防, boss): her 替身 at full HP, never knocked out', () => {
|
||||
for (const kind of ['normal', 'unite', 'boss']) {
|
||||
for (const g of ['chess_char_5_13_a', 'chess_char_5_13_b']) {
|
||||
const row = kind === 'boss' ? 2 : 10;
|
||||
const h = makeBattle({
|
||||
kind, seed: 4, autoFinish: false, timeLimit: 60, captureNoisy: true, hooks: ['damaged', 'death'],
|
||||
bonds: { egirShip: { count: 2, active: true, tier: 1, layers: 0 } },
|
||||
players: [{ playerId: 'p1', seat: 0, side: 'L', colOffset: 0, units: [{ uid: 1, kind: 'chess', chessId: 'chess_char_2_07_a', row: kind === 'boss' ? 9 : 10, col: 4 }, { uid: 2, kind: 'chess', chessId: g, row: kind === 'boss' ? 9 : 10, col: 5 }], bonds: { egirShip: { count: 2, active: true, tier: 1, layers: 0 } } }],
|
||||
});
|
||||
h.step();
|
||||
const u = h.unit(g);
|
||||
assert.equal(u.tileR, row, `${kind}: on the field`);
|
||||
assert.equal(h.hooksOf('damaged').filter((c) => c.target === u && (c.dmg?.tags || []).includes('bond:egir:devour')).length, 1, `${kind} ${g}: one devour mark`);
|
||||
assert.ok(u.alive && u.trait.doll && u.form === 'doll', `${kind} ${g}: her 替身`);
|
||||
assert.ok(Math.abs(u.hp - u.s.maxHp) < 1e-6, `${kind} ${g}: at full HP`);
|
||||
assert.equal(h.hooksOf('death').filter((c) => c.unit === u).length, 0, `${kind} ${g}: no knock-out`);
|
||||
checkInvariants(h.b);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('item 46 — 荒芜拉普兰德 S3: no attack of her own while her drones are out (they hit), attacks again after; 叙拉古 6 = PRTS\'s 0.139 % pseudo-random', () => {
|
||||
const h = makeBattle({
|
||||
seed: 3, autoFinish: false, timeLimit: 120, captureNoisy: true, hooks: ['attack', 'damaged', 'skillStart', 'skillEnd'],
|
||||
defs: { enemies: { enemy_pin: dummy('enemy_pin', { atk: 50 }) } }, units: [{ chessId: 'chess_char_6_18_a', row: 10, col: 4 }],
|
||||
enemies: [{ key: 'enemy_pin', pos: [10, 4] }],
|
||||
});
|
||||
const u = () => h.unit('chess_char_6_18_a');
|
||||
assert.ok(h.runUntil(() => u().skill.active, 60), 'S3 cast');
|
||||
const t0 = h.b.time;
|
||||
const attacks = () => h.hooksOf('attack').filter((c) => c.attacker === u()).length;
|
||||
assert.ok(h.runUntil(() => !u().skill.active, 45), 'S3 over');
|
||||
const t1 = h.hooksOf('skillEnd').filter((c) => c.unit === u()).at(-1).t;
|
||||
const n0 = attacks();
|
||||
// (the tick that ends the skill already attacks again: strictly inside the window)
|
||||
assert.equal(h.hooksOf('attack').filter((c) => c.attacker === u() && c.t > t0 + 1e-9 && c.t < t1 - 1e-9).length, 0, 'no attack of her own during S3');
|
||||
assert.ok(h.hooksOf('damaged').some((c) => c.source === u() && c.t > t0 && (c.dmg.tags || []).includes('droneAttack')), 'her drones hit');
|
||||
h.run(5);
|
||||
assert.ok(attacks() > n0, 'her attacks resume after the skill');
|
||||
checkInvariants(h.b);
|
||||
const c = prdConstant(0.03);
|
||||
assert.ok(Math.abs(c - 0.00139) < 0.00001, `叙拉古 6 PRD constant ${c} ≈ PRTS 0.139 %`);
|
||||
assert.ok(Math.ceil(1 / c) <= 722, 'a proc by the 722nd attempt at the latest (PRTS: 720 with the rounded 0.139 %)');
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
// test/sim/feedback5-counters-any-damage.test.js — community report of 2026-10-06 (item 30) 「退行的潮涌者的攻击无法触发菲莱2技能
|
||||
// 的受击反击(排查其他类似技能的干员)」. The enemy is 深溟巢涌者 / 富营养的巢涌者 (the 退行的巢涌者 of the main story): it makes no
|
||||
// normal attack — its talent hurts every operator in range once a second (PRTS "无途径法术伤害"; buff_template_data
|
||||
// enemy_dsubrl_aoe[aura]: an AdvancedApplyDamage with the enemy as its source). The official counters fire on ON_TAKE_DAMAGE —
|
||||
// 菲莱 S2 philae_s_2 with no filter at all, the others on a source of the other side (inverse_damage — 星熊 S2 —,
|
||||
// inverse_damage[magic] + nian_s_2 — 年 S2 —, bubble_s_2 / bubble_t_1 — 泡泡 —, vendla_s_2 — 刺玫 S2 —, yu_s_1[inverse_damage] —
|
||||
// 余 S1 —, hsgma2_s_1 — 斩业星熊 S1 —, mlynar_t_2[inverse] — 玛恩纳 无动于衷) — but the remake took enemy attacks only.
|
||||
// Now any damage instance from an enemy (kits/shared/tier1.js byEnemyAttack; 菲莱: any instance), never a 流失.
|
||||
// Run: node --test test/sim/feedback5-counters-any-damage.test.js
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, checkInvariants } from '../helpers/battleHarness.js';
|
||||
|
||||
const NEST = 'enemy_1234_dsubrl';
|
||||
|
||||
/**
|
||||
* `units` on the flat field, a parked 深溟巢涌者 on `nestAt` (speed ×0: it pulses every operator within 1.6 every second and
|
||||
* never attacks); `cast` = the unit whose skill is cast at once. Returns the harness after `seconds`.
|
||||
*/
|
||||
function nestFight(units, { cast = null, nestAt = [10, 5], seconds = 8 } = {}) {
|
||||
const h = makeBattle({ seed: 7, autoFinish: false, timeLimit: 60, captureNoisy: true, hooks: ['damaged', 'attack'], units });
|
||||
h.step();
|
||||
const nest = h.spawn(NEST, { pos: nestAt, mods: { speedMul: 0 } });
|
||||
if (cast) { const u = h.unit(cast); u.skill.gainSp(999, 'test'); if (!u.skill.active) assert.ok(u.skill.activate('test'), `${cast}: cast`); }
|
||||
h.run(seconds);
|
||||
checkInvariants(h.b);
|
||||
return { h, nest };
|
||||
}
|
||||
const fromTo = (h, src, tgt, pred) => h.hooksOf('damaged').filter((c) => c.source === src && c.target === tgt && pred(c));
|
||||
const tagged = (tag) => (c) => (c.dmg?.tags || []).includes(tag);
|
||||
/** The pulses `u` took (the 巢涌者's arts instances; its 神经损伤 rider is an element 损伤, no counter's business). */
|
||||
const pulsesOn = (h, nest, u) => fromTo(h, nest, u, (c) => c.type === 'arts').length;
|
||||
|
||||
test('the 巢涌者 never attacks — its pulse is the only damage it deals (the operators take it)', () => {
|
||||
const { h, nest } = nestFight([{ chessId: 'chess_char_4_17_a', row: 10, col: 4, skillIndex: 1 }]);
|
||||
assert.equal(h.hooksOf('attack').filter((c) => c.attacker === nest).length, 0, 'no normal attack');
|
||||
const pulses = h.hooksOf('damaged').filter((c) => c.source === nest && c.target.side === 'ally');
|
||||
assert.ok(pulses.length >= 3, `pulses ${pulses.length}`);
|
||||
assert.ok(pulses.every((c) => !c.dmg.isAttack), 'not an attack');
|
||||
});
|
||||
|
||||
test('菲莱 S2 冥河诅咒: the pulse sets off her blast (every 2 s at most) — until 0.2.0 it never did', () => {
|
||||
for (const id of ['chess_char_3_06_a', 'chess_char_3_06_b']) {
|
||||
const { h, nest } = nestFight([{ chessId: id, row: 10, col: 4, skillIndex: 1 }], { cast: id });
|
||||
const u = h.unit(id);
|
||||
const n = pulsesOn(h, nest, u);
|
||||
const blasts = fromTo(h, u, nest, tagged('counter'));
|
||||
assert.ok(n >= 4, `${id}: ${n} pulses`);
|
||||
assert.equal(blasts.length, Math.ceil(n / 2), `${id}: ${blasts.length} blasts for ${n} pulses a second apart (aoe_cd 2 s)`);
|
||||
}
|
||||
});
|
||||
|
||||
test('星熊 S2 荆棘, 泡泡 “挨打” (+ 尖刺盾), 余 S1, 玛恩纳 无动于衷: each answers the pulse', () => {
|
||||
const each = (label, f, unitId, pred) => {
|
||||
const u = f.h.unit(unitId), n = pulsesOn(f.h, f.nest, u);
|
||||
assert.ok(n >= 4, `${label}: ${n} pulses`);
|
||||
assert.equal(fromTo(f.h, u, f.nest, pred).length, n, `${label}: one answer per pulse`);
|
||||
};
|
||||
each('星熊 S2 荆棘', nestFight([{ chessId: 'chess_char_4_17_a', row: 10, col: 4, skillIndex: 1 }]), 'chess_char_4_17_a', tagged('counter'));
|
||||
const bb = nestFight([{ chessId: 'chess_char_2_08_a', row: 10, col: 4, skillIndex: 1 }], { cast: 'chess_char_2_08_a' });
|
||||
each('泡泡 S2 “挨打”', bb, 'chess_char_2_08_a', tagged('counter'));
|
||||
assert.ok(bb.nest.findBuff('bubble:spike'), '泡泡 尖刺盾 on the pulse\'s source');
|
||||
// 余 S1 — the 灼燃损伤 rider: once the 巢涌者's gauge bursts, its 爆发冷却 refuses further fills, so count the answers that landed
|
||||
const yu = nestFight([{ chessId: 'chess_char_6_03_a', row: 10, col: 4, skillIndex: 0 }], { cast: 'chess_char_6_03_a' });
|
||||
const yuN = fromTo(yu.h, yu.h.unit('chess_char_6_03_a'), yu.nest, (c) => c.type === 'element').length;
|
||||
assert.ok(yuN >= 3, `余 S1 灼燃损伤: ${yuN} answers`);
|
||||
each('玛恩纳 无动于衷', nestFight([{ chessId: 'chess_char_5_19_a', row: 10, col: 4 }]), 'chess_char_5_19_a', tagged('reflect'));
|
||||
});
|
||||
|
||||
test('刺玫 S2 荆藤庇荫: a pulse on her protégé is answered (the counter names the protégé for her trait heal)', () => {
|
||||
const { h, nest } = nestFight([
|
||||
{ chessId: 'chess_char_1_06_a', row: 10, col: 3, skillIndex: 1 },
|
||||
{ chessId: 'chess_char_4_17_a', row: 10, col: 4, skillIndex: 0 },
|
||||
], { cast: 'chess_char_1_06_a' });
|
||||
const v = h.unit('chess_char_1_06_a'), p = v.mem.protege;
|
||||
assert.ok(p && p.defId === 'chess_char_4_17_a', 'the protégé');
|
||||
const hits = fromTo(h, v, nest, tagged('counter'));
|
||||
const n = pulsesOn(h, nest, p);
|
||||
assert.ok(n >= 4, `${n} pulses on the protégé`);
|
||||
assert.equal(hits.length, n, `one counter per pulse on the protégé (${hits.length})`);
|
||||
assert.ok(hits.every((c) => c.dmg.traitAlly === p));
|
||||
});
|
||||
|
||||
test('自选 年 S2 铜印 and 斩业星熊 S1 恶业苦果 answer the pulse too', () => {
|
||||
const ni = nestFight([{ diy: { slot: 6, charId: 'char_2014_nian', skillIndex: 1 }, row: 10, col: 4 }]);
|
||||
const n = ni.h.allies().find((u) => u.def?.charId === 'char_2014_nian' || u.charId === 'char_2014_nian' || /nian/.test(u.def?.diy?.charId ?? ''));
|
||||
assert.ok(n, '年 fielded');
|
||||
n.skill.gainSp(999, 'test');
|
||||
if (!n.skill.active) n.skill.activate('test');
|
||||
const before = pulsesOn(ni.h, ni.nest, n);
|
||||
ni.h.run(6);
|
||||
assert.ok(fromTo(ni.h, n, ni.nest, tagged('counter')).length >= pulsesOn(ni.h, ni.nest, n) - before && pulsesOn(ni.h, ni.nest, n) - before >= 4, '年 S2');
|
||||
assert.ok(ni.nest.findBuff('silence') || ni.nest.s.flags.silence, '年 S2 silences the source');
|
||||
|
||||
const hm = nestFight([{ diy: { slot: 6, charId: 'char_1044_hsgma2', skillIndex: 0 }, row: 10, col: 4 }]);
|
||||
const s = hm.h.allies()[0];
|
||||
s.skill.gainSp(999, 'test');
|
||||
if (!s.skill.active) s.skill.activate('test');
|
||||
const before2 = pulsesOn(hm.h, hm.nest, s);
|
||||
hm.h.run(6);
|
||||
assert.ok(fromTo(hm.h, s, hm.nest, tagged('counter')).length >= pulsesOn(hm.h, hm.nest, s) - before2 && pulsesOn(hm.h, hm.nest, s) - before2 >= 4, '斩业星熊 S1');
|
||||
});
|
||||
|
||||
test('never a 流失: an enemy\'s HP loss on 星熊 sets no 荆棘 off; another counter / reflection does not either', () => {
|
||||
const h = makeBattle({ seed: 7, autoFinish: false, timeLimit: 60, captureNoisy: true, hooks: ['damaged'],
|
||||
units: [{ chessId: 'chess_char_4_17_a', row: 10, col: 4, skillIndex: 1 }] });
|
||||
h.step();
|
||||
const nest = h.spawn(NEST, { pos: [10, 8], mods: { speedMul: 0 } }); // out of pulse range
|
||||
const u = h.unit('chess_char_4_17_a');
|
||||
u.skill.gainSp(999, 'test');
|
||||
if (!u.skill.active) u.skill.activate('test');
|
||||
h.step();
|
||||
h.b.loseHp(u, 10, { source: nest });
|
||||
h.b.dealDamage(nest, u, { amount: 10, type: 'true', tags: ['counter'] });
|
||||
h.b.dealDamage(nest, u, { amount: 10, type: 'true', tags: ['reflect'] });
|
||||
assert.equal(fromTo(h, u, nest, tagged('counter')).length, 0, 'none of the three');
|
||||
h.b.dealDamage(nest, u, { amount: 10, type: 'true' }); // (true damage: her 战术装甲 negates only phys / arts)
|
||||
assert.equal(fromTo(h, u, nest, tagged('counter')).length, 1, 'a plain damage instance does');
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
// test/sim/feedback5-gravel-double-hit.test.js — community report of 2026-10-06 (item 16.5) 「砾的平A是比较特殊的双判,是两次50%
|
||||
// 伤害倍率(不是攻击倍率),对频次怪的效率应该比正常干员高,但是现在游戏内还是单次判定」: PRTS 砾 特性备注 "在砾的攻击动作下,每次普通攻击造成
|
||||
// 两段伤害,但是每段最终只会造成50%的伤害(在计算防御/减伤后,在重设伤害前);第二段伤害不会触发目标的受击回复". Up to 0.2.0 every attack was
|
||||
// one instance. Now two instances of 50 % 伤害倍率 (DamageInfo `mul`, after DEF — the profile's `hitDmgMul`, ai.js resolveHit).
|
||||
// Run: node --test test/sim/feedback5-gravel-double-hit.test.js
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
import { hitCount } from '../../server/sim/content/enemies/helpers.js';
|
||||
import { MIN_DAMAGE_RATIO } from '../../server/sim/constants.js';
|
||||
|
||||
const near = (a, b, msg) => assert.ok(Math.abs(a - b) <= 1e-6, `${msg}: ${a} vs ${b}`);
|
||||
|
||||
/** 砾 on (10,4) facing right, one parked enemy on (10,5); returns her attacks and her damage instances on it. */
|
||||
function fight(id, enemy, seconds = 5) {
|
||||
const h = makeBattle({
|
||||
seed: 2, autoFinish: false, timeLimit: 60, captureNoisy: true, hooks: ['damaged', 'attack'],
|
||||
defs: { enemies: { enemy_g: enemyRec({ key: 'enemy_g', hp: 1e9, speed: 0, mass: 0, ...enemy }) } },
|
||||
units: [{ chessId: id, row: 10, col: 4 }],
|
||||
});
|
||||
h.step();
|
||||
const e = h.spawn('enemy_g', { pos: [10, 5] });
|
||||
return { h, e, run: () => h.run(seconds) };
|
||||
}
|
||||
|
||||
test('砾 (normal, elite): every normal attack is two damage instances of 50 % 伤害倍率 after DEF — the same total, not 50 % 攻击倍率', () => {
|
||||
for (const id of ['chess_char_2_12_a', 'chess_char_2_12_b']) {
|
||||
for (const def of [0, 300, 5000]) {
|
||||
const { h, run } = fight(id, { def });
|
||||
run();
|
||||
const u = h.unit(id);
|
||||
const attacks = h.hooksOf('attack').filter((c) => c.attacker === u).length;
|
||||
const hits = h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isAttack);
|
||||
assert.ok(attacks >= 3, `${id}: she attacks (${attacks})`);
|
||||
assert.equal(hits.length, 2 * attacks, `${id} DEF ${def}: two instances per attack`);
|
||||
const atk = u.s.atk;
|
||||
const full = Math.max(atk - def, MIN_DAMAGE_RATIO * atk); // one instance of the whole attack, after DEF
|
||||
for (const c of hits) near(c.amount, 0.5 * full, `${id} DEF ${def}: each instance is 50 % of the mitigated hit`);
|
||||
// first instance of each attack may give 受击回复, the second may not
|
||||
const byAttack = new Map();
|
||||
for (const c of hits) { const l = byAttack.get(c.dmg.attackId) ?? []; l.push(c); byAttack.set(c.dmg.attackId, l); }
|
||||
for (const l of byAttack.values()) assert.deepEqual(l.map((c) => !!c.dmg.noSp), [false, true], `${id}: the second instance carries no 受击回复`);
|
||||
checkInvariants(h.b);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('砾 against a 频次 enemy: two counted hits per attack (one before 0.2.0)', () => {
|
||||
for (const id of ['chess_char_2_12_a', 'chess_char_2_12_b']) {
|
||||
const { h, e, run } = fight(id, { hp: 100 });
|
||||
hitCount(h.b, e, true);
|
||||
run();
|
||||
const u = h.unit(id);
|
||||
const attacks = h.hooksOf('attack').filter((c) => c.attacker === u).length;
|
||||
assert.ok(attacks >= 3, `${id}: she attacks`);
|
||||
assert.equal(100 - e.hp, 2 * attacks, `${id}: 2 per attack`);
|
||||
checkInvariants(h.b);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
// test/sim/feedback5-raid-revive.test.js — community report of 2026-10-06 (item 44) 「突袭盟约的干员如果带被动技能,复活之后不会
|
||||
// 立马突袭出去,还要等突袭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.
|
||||
// Run: node --test test/sim/feedback5-raid-revive.test.js
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
|
||||
const TEX = 'chess_char_4_16_a';
|
||||
const DUMMY = { enemy_rv: enemyRec({ key: 'enemy_rv', hp: 1e9, speed: 0, mass: 0, atk: 0 }) };
|
||||
const RAID = { raidShip: { count: 3, active: true, tier: 1, layers: 10 } };
|
||||
|
||||
/**
|
||||
* 缄默德克萨斯 (S3, a 6 s deploy-timed skill) in 突袭 with `extra` (items / band): she jumps to a parked enemy, stays past her
|
||||
* window, the enemy is removed, she is knocked out with nothing in range, a new enemy parks away from her. Returns how long
|
||||
* after the knock-out her next jump comes (null: none within 15 s) and her skill state right after the revive.
|
||||
*/
|
||||
function reviveAndJump({ items, bandId = null, bonds = {} }) {
|
||||
const h = makeBattle({
|
||||
seed: 3, autoFinish: false, timeLimit: 120, hooks: ['deploy', 'death'], defs: { enemies: DUMMY }, bandId,
|
||||
bonds: { ...RAID, ...bonds }, enemies: [{ key: 'enemy_rv', pos: [9, 9] }], units: [{ chessId: TEX, row: 12, col: 3, skillIndex: 2, items }],
|
||||
});
|
||||
h.step();
|
||||
const u = h.unit(TEX);
|
||||
const jumps = () => h.hooksOf('deploy').filter((c) => c.unit === u && !c.initial);
|
||||
assert.ok(h.runUntil(() => jumps().length >= 1, 10), 'her first jump');
|
||||
h.run(8);
|
||||
assert.equal(u.skill.active, false, 'her 6 s window is over');
|
||||
for (const e of h.b.enemies.filter((x) => x.alive)) h.b.kill(e, null);
|
||||
const t0 = h.b.time;
|
||||
h.b.dealDamage(null, u, { amount: 1e9, type: 'true' });
|
||||
h.spawn('enemy_rv', { pos: [12, 9] });
|
||||
h.step();
|
||||
const state = { alive: u.alive, active: u.skill.active, timeLeft: u.skill.timeLeft };
|
||||
const n = jumps().length;
|
||||
const jumped = h.runUntil(() => jumps().length > n && u.tileR === 12, 15);
|
||||
checkInvariants(h.b);
|
||||
return { after: jumped ? h.b.time - t0 : null, state, u, h };
|
||||
}
|
||||
|
||||
test('M3茧甲 / 埃芒加德 revive 缄默德克萨斯 in place: her deploy-timed S3 runs again and she jumps at once (until 0.2.0 only after the 10 s idle)', () => {
|
||||
for (const [label, opts] of [['M3茧甲', { items: ['chess_item_4_12_e_a'] }], ['埃芒加德', { bandId: 'band_ermengard' }]]) {
|
||||
const { after, state } = reviveAndJump(opts);
|
||||
assert.ok(state.alive, `${label}: revived in place`);
|
||||
assert.ok(state.active, `${label}: her deploy-timed S3 runs again`);
|
||||
assert.ok(after != null && after < 1, `${label}: jumped ${after?.toFixed(2)} s after the knock-out`);
|
||||
}
|
||||
});
|
||||
|
||||
test('不屈\'s 立刻重新部署 (a real redeploy) already jumped at once — unchanged', () => {
|
||||
const { after } = reviveAndJump({ bonds: { indomShip: { count: 2, active: true, tier: 1, layers: 300 } } });
|
||||
assert.ok(after != null && after < 1, `jumped ${after?.toFixed(2)} s after the knock-out`);
|
||||
});
|
||||
|
||||
test('an in-place revive inside the window sets it back to its full length; 砾 revived by M3茧甲 gets a fresh barrier', () => {
|
||||
const h = makeBattle({ seed: 3, autoFinish: false, timeLimit: 60, units: [{ chessId: TEX, row: 12, col: 3, skillIndex: 2, items: ['chess_item_4_12_e_a'] }] });
|
||||
h.step();
|
||||
const u = h.unit(TEX);
|
||||
h.run(3);
|
||||
assert.ok(u.skill.active && u.skill.timeLeft < u.skill.duration - 2, 'half-way through the window');
|
||||
h.b.dealDamage(null, u, { amount: 1e9, type: 'true' });
|
||||
h.step(2);
|
||||
assert.ok(u.alive && u.skill.active && u.skill.timeLeft > u.skill.duration - 0.2, `back to the full ${u.skill.duration} s (${u.skill.timeLeft.toFixed(2)})`);
|
||||
checkInvariants(h.b);
|
||||
|
||||
const g = makeBattle({ seed: 3, autoFinish: false, timeLimit: 60, units: [{ chessId: 'chess_char_2_12_a', row: 10, col: 4, items: ['chess_item_4_12_e_a'] }] });
|
||||
g.step();
|
||||
const gr = g.unit('chess_char_2_12_a');
|
||||
g.run(12);
|
||||
assert.ok(!gr.findBuff('gravel:rats'), 'the deployment barrier has run out');
|
||||
g.b.dealDamage(null, gr, { amount: 1e9, type: 'true' });
|
||||
g.step(2);
|
||||
assert.ok(gr.alive && gr.findBuff('gravel:rats')?.shield > 0, 'a fresh barrier after the revive');
|
||||
checkInvariants(g.b);
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
// test/sim/feedback5-ulpia-own-board.test.js — community report of 2026-10-06 (item 27) 「乌尔比安使用3技能会在联防阶段跳到红门
|
||||
// 后」. 乌尔比安 S3 必须开辟的通路 throws the anchor up to 6 tiles ahead (the nearest enemy of the line, else the farthest tile) and
|
||||
// moves there when the tile "可以部署" (PRTS 备注 ③: the landing tile > the tile one beyond > his own tile). On the one-helper 联防
|
||||
// map (act1autochess_escaped_single: the helper's pieces on the left half, the enemies out of the middle gate at col 10) the
|
||||
// right half is no tile his player deploys on, yet an anchor that met no enemy flew there and he moved behind the red gate for
|
||||
// the whole skill. Now the landing must lie on his own board (Battle.onOwnBoard, the board region mapped onto the field).
|
||||
// Run: node --test test/sim/feedback5-ulpia-own-board.test.js
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
|
||||
const ULPIA = 'chess_char_5_05_a';
|
||||
const dummy = enemyRec({ key: 'enemy_anchor_dummy', hp: 1e9, speed: 0, mass: 0, atk: 0 });
|
||||
|
||||
/** 乌尔比安 cast S3 on `tile` of `kind`'s field; `enemy` = a parked enemy's tile or null. Returns where he stands after the cast. */
|
||||
function cast({ kind = 'unite', stageId = 'act1autochess_escaped_single', tile = [10, 7], enemy = null, colOffset = 0 } = {}) {
|
||||
const h = makeBattle({
|
||||
kind, stageId, seed: 5, autoFinish: false, timeLimit: 120, defs: { enemies: { enemy_anchor_dummy: dummy } },
|
||||
players: [{ playerId: 'p1', seat: 0, side: 'L', colOffset, units: [{ uid: 1, kind: 'chess', chessId: ULPIA, row: tile[0], col: tile[1] }], bonds: {} }],
|
||||
});
|
||||
h.step();
|
||||
const u = h.unit(ULPIA);
|
||||
const home = [u.tileR, u.tileC];
|
||||
if (enemy) { h.spawn('enemy_anchor_dummy', { pos: enemy }); h.step(); } // (one tick: the enemy joins the tile index)
|
||||
u.skill.gainSp(999, 'test');
|
||||
// (its CUSTOM_RANGE trigger waits for an enemy on the line: with none the cast is made by hand, as a player's would be)
|
||||
if (!u.skill.active) assert.ok(u.skill.activate('test'), 'S3 cast');
|
||||
h.run(0.2);
|
||||
const at = [u.tileR, u.tileC];
|
||||
checkInvariants(h.b);
|
||||
return { h, u, home, at };
|
||||
}
|
||||
|
||||
test('one-helper 联防: an anchor that meets no enemy flies on past the middle gate — he stays on his own half (until 0.2.0 he moved behind the red gate)', () => {
|
||||
const { h, u, home, at } = cast({ tile: [10, 7] });
|
||||
assert.deepEqual(home, [10, 7]);
|
||||
assert.ok(h.b.rect.c1 >= 13, 'the 联防 field spans both halves');
|
||||
assert.deepEqual(at, home, `stays on (10,7) — not ${at}`);
|
||||
assert.ok(h.b.onOwnBoard(u.player, 10, 7) && !h.b.onOwnBoard(u.player, 10, 13), 'cols 11+ are not his board');
|
||||
assert.equal(h.b.allyUnits.filter((a) => a.kind === 'token' && a.alive).length, 0, 'no 从不混淆的方向: nothing to return from');
|
||||
});
|
||||
|
||||
test('one-helper 联防: an enemy on his half still draws the anchor and he moves next to it (own board)', () => {
|
||||
const { at } = cast({ tile: [10, 4], enemy: [10, 8] });
|
||||
assert.deepEqual(at, [10, 8], 'he moves onto the anchor tile on his half');
|
||||
});
|
||||
|
||||
test('two-helper 联防: the left helper never lands on the right helper\'s half; the right helper moves on its own half', () => {
|
||||
const left = cast({ stageId: 'act1autochess_escaped_multi', tile: [10, 7] });
|
||||
assert.deepEqual(left.at, left.home, `left helper stays (${left.at})`);
|
||||
// the right-hand helper (colOffset 8): board col 3 → field col 11, enemy 3 tiles ahead on its own half
|
||||
const right = cast({ stageId: 'act1autochess_escaped_multi', tile: [10, 3], colOffset: 8, enemy: [10, 14] });
|
||||
assert.deepEqual(right.home, [10, 11]);
|
||||
assert.deepEqual(right.at, [10, 14], 'moves on its own half');
|
||||
assert.ok(right.h.b.onOwnBoard(right.u.player, 10, 14) && !right.h.b.onOwnBoard(right.u.player, 10, 7));
|
||||
});
|
||||
|
||||
test('a normal battle is unchanged: the anchor stops at the field edge and he moves onto the farthest tile of his board', () => {
|
||||
const { at, h, u } = cast({ kind: 'normal', stageId: 'flat', tile: [10, 3] });
|
||||
assert.deepEqual(at, [10, 9], `6 tiles ahead, the last road tile before the gate column (${at})`);
|
||||
assert.ok(h.b.onOwnBoard(u.player, 10, 9));
|
||||
});
|
||||
Reference in new issue
Block a user