Files
ark-stronghold-protocol/test/content/feedback5-duck-restealth.test.js
T
sganggs dd66adfbd1 feedback5 R19E: the 鸭爵 strategy's swapped-in 流泪小子 hides again 1 s after a block ends instead of the official 0 s — a deliberate deviation, the owner's decision of 2026-10-07 (「要不等待个1秒这样再重新隐匿」); a separate commit the integrator can drop; golden: 1 scenario moves (matches solo-HARD-2)
content/enemies/archetypes.js stealth: an enemy spawned with the band's swap tag 'duck' (content/bands/meta.js
duckReplace) gets its 隐匿 back no sooner than DUCK_STEALTH_RESTORE (1 s, content/enemies/helpers.js) after a block ends
— in practice 流泪小子 (enemy_2034_sythef_2), the only one of the four swaps with 隐匿, officially 0 s (PRTS 流泪小子
「隐匿(解除阻挡0秒后恢复)」). The same enemy anywhere else keeps 0 s: the bounty card's 流泪小子 (enemy_2034_sythef), an
untagged spawn; a 3 s 隐匿 stays 3 s; a copy leaked into 联防 keeps its tag and the 1 s. After each strike (its stunned
blocker lets go) it is now revealed through its 0.8 s post-strike stand and 0.2 s of walking.

Measured, 8 seeds each — leaked / HP left / revealed s per battle; W = 3 elite melee in a lane + an elite sniper vs the
R6 wave's ground enemies with one swapped 流泪小子, B = 8 elites on 战场#01 vs the whole R6 wave with one swap: before
the clip fix W 5/8, 42 %, 2.3 / B 8/8, 47 %, 3.2; after it W 5/8, 31 %, 2.6 / B 8/8, 58 %, 2.5; with the 1 s W 4/8,
31 %, 4.0 / B 8/8, 56 %, 5.5. Options for the owner (measured on the fixed engine, not built): 2 s W 4/8, 31 %, 5.4 /
B 8/8, 46 %, 8.5; 3 s (the general 隐匿 rule) W 4/8, 31 %, 6.4 / B 8/8, 39 %, 11.5; a 1.5 s stun on the copy (below its
2 s interval, so its blocker takes it over again) W 4/8, 2 %, 4.0 / B 4/8, 6 %, 5.5; a 1 s stun: knocked out in all 16;
the 1 s delay + a 2 s stun W 4/8, 21 %, 5.1 / B 5/8, 26 %, 8.1. Worse: half speed while 隐匿; a stunned operator that
keeps blocking it (pins that operator for good).

Golden: matches solo-HARD-2 — its bot holds 鸭爵; the swapped 流泪小子 of round 13 is free and revealed for 49 ticks in
its 1 s windows (the round-12 one is never released by a block), so the match parts at round 13 (a victory instead of
a defeat); the other 282 scenarios unchanged.

Tests: test/content/feedback5-duck-restealth.test.js (3; the swap's 1 s and the band path fail on the parent, the 0 s
guard passes on both); enemies_bosses, feedback2-surtr-stealth, feedback1-ducklord, feedback5-attack-clip-rest pass.
Docs: CHANGELOG 0.2.0 有意与官方不同的调整 (and the 核实后不是问题 line points to it), docs/history/0.2.0.md §25.22.6,
docs/PLAYING.md, docs/SIM.md, design/engine.md.
2026-10-07 12:58:06 +08:00

80 lines
4.9 KiB
JavaScript

// test/content/feedback5-duck-restealth.test.js — the owner's decision of 2026-10-07 (「鸭爵策略产生的泪眼汪汪我想了一下,要不
// 等待个1秒这样再重新隐匿」, after the community report of 2026-10-07 and GitHub #214): the 鸭爵 strategy's swapped-in
// 流泪小子 (spawn tag 'duck', content/bands/meta.js duckReplace) hides again 1 s after a block ends instead of the official
// 0 s (content/enemies/helpers.js DUCK_STEALTH_RESTORE) — a deliberate deviation, so the operators beside the one it
// stuns get a second to hit it. The same enemy anywhere else keeps the official 0 s.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { makeBattle, chessRec, checkInvariants } from '../helpers/battleHarness.js';
import * as enemiesMod from '../../server/sim/content/enemies.js';
import { duckReplace } from '../../server/sim/content/bands/meta.js';
import { TICK } from '../../server/sim/constants.js';
import { enemyStealthed } from '../../server/sim/targeting.js';
const E = JSON.parse(readFileSync(new URL('../../data/enemies.json', import.meta.url), 'utf8'));
const BANDS = JSON.parse(readFileSync(new URL('../../data/bands.json', import.meta.url), 'utf8'));
const DOG = 'enemy_2034_sythef_2';
const BIG = [];
for (let dr = -4; dr <= 4; dr++) for (let dc = -12; dc <= 12; dc++) BIG.push([dr, dc]);
const WALL = chessRec({ id: 'w1', profession: 'TANK', stats: { atk: 0, maxHp: 1e7, def: 0, blockCnt: 1 }, rangeGrid: [[0, 0]], skill: null });
const GUN = chessRec({ id: 'gun', profession: 'SNIPER', projectile: 'none', stats: { atk: 400, maxHp: 1e7, bat: 1, blockCnt: 0 }, rangeGrid: BIG, skill: null });
function arena(o = {}) {
return makeBattle({
content: 'generic', extraContent: [enemiesMod], seed: 7, autoFinish: false, timeLimit: 600,
defs: { chess: { w1: WALL, gun: GUN } }, kits: { w1: () => ({ trait: { noAttack: true } }) }, ...o,
});
}
test('the 鸭爵 swap\'s 流泪小子 hides again 1 s after its strike frees it (its stunned blocker lets go), and a ranged operator hits it in that second', () => {
const h = arena({ units: [{ chessId: 'w1', row: 9, col: 6 }, { chessId: 'gun', row: 11, col: 3 }] });
h.step();
const e = h.spawn(DOG, { routeIndex: 0, tag: 'duck', bounty: { coins: 1, ownerPlayerId: 'p1' } });
assert.equal(e.findBuff('ab:stealth').data.stealthRestore, 1);
assert.ok(enemyStealthed(e), '隐匿 on its way');
assert.ok(h.runUntil(() => e.stats.attacks >= 1, 60), 'it strikes its blocker');
assert.equal(e.blockedBy, null, 'the stunned wall let it go');
const ts = h.b.time, hp0 = e.hp;
h.run(1 - 2 * TICK);
assert.equal(enemyStealthed(e), false, 'still revealed 1 s after (officially 隐匿 at once)');
assert.ok(e.hp < hp0, 'the sniper hit it in that second');
h.run(4 * TICK);
assert.equal(enemyStealthed(e), true, `隐匿 again ${(h.b.time - ts).toFixed(3)} s after the strike`);
checkInvariants(h.b);
});
test('the same enemy outside the swap keeps the official 0 s: the act2 copy spawned without the tag, and the original 流泪小子 of the bounty card', () => {
const h = arena({ units: [{ chessId: 'w1', row: 9, col: 6 }] });
h.step();
for (const key of [DOG, 'enemy_2034_sythef']) {
const e = h.spawn(key, { pos: [10, 8], routeIndex: 0, mods: { speedMul: 0 } });
assert.equal(e.findBuff('ab:stealth').data.stealthRestore, 0, key);
}
const e = h.spawn(DOG, { routeIndex: 0 });
assert.ok(h.runUntil(() => e.stats.attacks >= 1, 60));
assert.equal(enemyStealthed(e), true, 'untagged: 隐匿 the moment its strike frees it');
// a 3 s 隐匿 stays 3 s even when tagged (the rule only lengthens a shorter restore)
const lurker = h.spawn('enemy_1009_lurker', { pos: [10, 7], routeIndex: 0, tag: 'duck', mods: { speedMul: 0 } });
assert.equal(lurker.findBuff('ab:stealth').data.stealthRestore, undefined);
});
test('the band\'s own swap (duckReplace) tags the copy, and the battle it builds gives that copy the 1 s', () => {
const p = BANDS.band_ducklord.params;
assert.ok(p.enemylist.split(',').includes(DOG));
const spawns = [{ time: 2, enemyKey: 'enemy_1007_slime', routeIndex: 0, count: 4, interval: 3 }];
const ctx = { gd: { enemy: (k) => E[k] }, rng: { int: () => 1, shuffle: (a) => a.slice(), pick: (a) => (a.includes(DOG) ? DOG : a[0]) } };
const out = duckReplace(ctx, spawns, p, 'p1');
assert.equal(out.length, 1);
assert.equal(out[0].enemyKey, DOG);
assert.equal(out[0].tag, 'duck');
const h = arena({
enemies: spawns.map((s) => ({ key: s.enemyKey, time: s.time, route: s.routeIndex, count: s.count ?? 1, interval: s.interval ?? 0, tag: s.tag ?? null, bounty: s.bounty ?? null })),
});
assert.ok(h.runUntil(() => h.b.enemies.some((x) => x.defId === DOG), 30), 'the copy spawns');
const dog = h.b.enemies.find((x) => x.defId === DOG);
assert.equal(dog.tag, 'duck');
assert.equal(dog.findBuff('ab:stealth').data.stealthRestore, 1);
});