Files
sganggs 1205336a27 feedback7 R22-FIELDS: an enemy whose route ends on a corner portal stays hidden 3 s inside it before it comes out of [5,10], like the explicit crossings of the same tiles (review of the #337 commit); golden: 7 fields, 4 matches — solo-NORMAL-1's Hidden Core still fails
The Grok review of fb7-fields (MEDIUM): portalPickup spliced DISAPPEAR and APPEAR with nothing between, so the enemy
vanished on [1,3] / [1,17] and stood on [5,10] in the same tick, targetable at once, while the data's explicit
crossings of the same tiles hide it: WAIT 3 s in 85 of the 95, 5 s in 9, 1 s in 1. portalPickup now splices a 3 s
WAIT (PORTAL_WAIT, the modal explicit wait) between them; [ASSUMED] the 3 s, no source times this crossing. Docs: the
SIM.md sentence and the portalPickup comment.

Tests: test/sim/portal-appear.test.js — the two route-end tests record the test enemy's hidden spans: one crossing,
entrance → [5,10], hidden 3 s (± a tick); both fail on the parent (0.000 s). 9/9.

Golden (against the 0 s version; the rounds of every match unchanged):
- matches solo-NORMAL-1 (Hidden Core boss_8 胄, act1autochess_h08_01_s): still failed — hiddenCleared false, team LP 0,
  14 rounds — with the leader left at 63897 instead of 99894 (cleared with 26 LP in 0.2.1). The same course as with
  0 s: the third vtsk, which leaked at the portal at 105 s in 0.2.1, comes out of [5,10] 3 s later, walks through the
  team's line and leaks at the door at 185.5 s (170.2 s before); 0–2 of the 8 operators stand between 100 and 320 s, the
  leader stalls near 108000 from 280 s, the overtime drain from 300 game s takes the 25 LP left by 350 s while the
  redeployed operators bring the leader to 63897 (notes trace-hidden*.mjs).
- matches solo-HARD-1 (Hidden Core failed as before): pool left 2839368 → 2982555; solo-HARD-9-boosted (cleared): team LP
  361 → 376 (383 in 0.2.1); coop2-NORMAL-14-standins (Final Assault lost as before): pool left 97588 → 108473.
- fields boss-boss_2-solo (leader left 2763024 → 2757148), boss-boss_4-solo (3759921 → 3757752), boss-boss_6-pair
  (killed 6 → 5, one hllevi_2 still walking at the 200 s cap, both players still perfect; leader 6972383 → 6932482),
  boss-boss_6-solo (3904791 → 3907393), hidden-boss_8-pair (the vtsk that comes back near the cap is still inside the
  portal at 200 s: one appear fewer, the last snapshot hash), hidden-boss_8-solo (killed 8 → 6, leaks 10 → 12),
  hidden-boss_10-solo (killed 11 → 10, leader left 7323370 → 7333760).
- unchanged by the wait: solo-FUNNY-2, coop2-ABYSS-10-boosted, the other fields, and the roster / bonds / standins / diy
  families.
2026-10-08 21:05:18 +08:00

138 lines
8.0 KiB
JavaScript

// The 传送门 (portal) of the boss / hidden-core templates (GitHub #336, PR #337 by @2321Robin): an enemy flies or walks
// into the portal and reappears on the far side. Four official routes spell the crossing as DISAPPEAR → WAIT → MOVE-to-the-exit with no APPEAR step
// (act1autochess_h07_02 route #4, act1autochess_h08_02 routes #5/#9, act2autochess_h07_05 route #6), while their twin
// routes in the same templates write it out as DISAPPEAR → WAIT → APPEAR[exit]. Taken literally, an appear-less route
// made the enemy walk its whole remaining route invisible and then leak unseen — the blue door takes the leak and the
// team never sees who or how (user report: 打 boss 时小怪进传送门后被判定进蓝门,全程隐身). normalizeRoute re-inserts
// the exit APPEAR ahead of that MOVE — at the MOVE's own target, which is exactly where the twins appear.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { normalizeRoute, hasGeneratedData } from '../../server/sim/simdata.js';
import { makeBattle, enemyRec } from '../helpers/battleHarness.js';
const REAL = { skip: !hasGeneratedData() && 'no generated data' };
/** Leg signature of a normalized route: `MOVE[r,c]` / `APPEAR[r,c]` / `DISAPPEAR` / `WAIT`. */
const sig = (r) => r.checkpoints.map((c) => c.type + (c.type === 'MOVE' || c.type === 'APPEAR' ? `[${c.pos[0]},${c.pos[1]}]` : ''));
/** The test enemy's leaks (the templates' own spawns leak too). */
const leaksOf = (h) => h.hooksOf('enemyLeak').filter((l) => l.enemy.defId === 'enemy_test_portal');
/** The test enemy's hidden spans: [{ from: [r,c], to: [r,c], dt }] (vanish tile, reappear tile, seconds hidden). */
function hiddenSpans(h) {
const spans = [];
let open = null;
const set = h.b._setHidden.bind(h.b);
h.b._setHidden = (e, on) => {
if (e.defId === 'enemy_test_portal' && on !== !!e.hidden) {
const at = [Math.round(e.y), Math.round(e.x)];
if (on) open = { at, t: h.b.time };
else if (open) { spans.push({ from: open.at, to: at, dt: h.b.time - open.t }); open = null; }
}
return set(e, on);
};
return spans;
}
test('normalizeRoute: DISAPPEAR → WAIT → MOVE gains the twin APPEAR at the move target', () => {
const r = normalizeRoute({
motion: 'FLY', start: [2, 10], end: [1, 3],
steps: [
{ t: 'move', p: [2, 11] }, { t: 'move', p: [1, 17] }, { t: 'disappear' }, { t: 'wait', s: 3 },
{ t: 'move', p: [5, 10] }, { t: 'move', p: [5, 9] },
],
});
assert.deepEqual(sig(r), ['MOVE[2,11]', 'MOVE[1,17]', 'DISAPPEAR', 'WAIT', 'APPEAR[5,10]', 'MOVE[5,10]', 'MOVE[5,9]']);
});
test('normalizeRoute: a DISAPPEAR the data does spell out with an APPEAR is kept as the only one', () => {
const r = normalizeRoute({
motion: 'FLY', start: [2, 10], end: [1, 17],
steps: [
{ t: 'move', p: [1, 3] }, { t: 'disappear' }, { t: 'wait', s: 3 }, { t: 'appear', p: [5, 10] }, { t: 'move', p: [5, 13] },
],
});
assert.equal(r.checkpoints.filter((c) => c.type === 'APPEAR').length, 1);
assert.deepEqual(sig(r), ['MOVE[1,3]', 'DISAPPEAR', 'WAIT', 'APPEAR[5,10]', 'MOVE[5,13]']);
});
test('normalizeRoute: idempotent — a second pass over a fixed route changes nothing', () => {
const once = normalizeRoute({
motion: 'FLY', start: [2, 10], end: [1, 3],
steps: [{ t: 'move', p: [1, 17] }, { t: 'disappear' }, { t: 'wait', s: 3 }, { t: 'move', p: [5, 10] }],
});
assert.deepEqual(normalizeRoute(once), once);
});
// The four official appear-less routes on their real templates: after the portal the enemy must be back in the open
// at the exit [5,10] (where the twins appear) and the route's leak at the blue door must happen VISIBLE.
const PORTAL_EXIT = [5, 10];
const CASES = [
['act1autochess_h07_02', 4, 'FLY'],
['act1autochess_h08_02', 5, 'WALK'],
['act1autochess_h08_02', 9, 'WALK'],
['act2autochess_h07_05', 6, 'FLY'],
];
for (const [tplId, routeIdx, motion] of CASES) {
test(`${tplId} route #${routeIdx}: portal crossing reappears at [${PORTAL_EXIT}] and leaks visible`, REAL, () => {
const h = makeBattle({
kind: 'boss', waveTemplate: tplId, content: 'none',
defs: { enemies: { enemy_test_portal: enemyRec({ key: 'enemy_test_portal', hp: 1e6, speed: 3, motion }) } },
enemies: [{ key: 'enemy_test_portal', route: routeIdx }],
autoFinish: true, timeLimit: 400, hooks: ['enemyLeak'],
});
const e = () => h.enemy('enemy_test_portal');
assert.ok(h.runUntil(() => e() && e().hidden, 400), 'reaches the portal (disappears)');
assert.ok(h.runUntil(() => e() && !e().hidden, 400), 'reappears after the portal');
assert.deepEqual([Math.round(e().y), Math.round(e().x)], PORTAL_EXIT, 'reappears at the portal exit');
h.runToEnd(400);
const [leak, ...more] = leaksOf(h);
assert.ok(leak, 'the route still ends in its leak');
assert.equal(more.length, 0, 'one leak');
assert.equal(leak.enemy.hidden, false, 'the leak is visible');
});
}
// A route that ENDS on a portal entrance does not leak there: the entrance teleports the enemy out of the far exit
// ([5,10] — the same pairing every explicit DISAPPEAR / APPEAR pair in the data reads) and it walks on to the blue
// door on that side of the field, where the leak happens (GitHub #336: 打 boss 小怪第一次进传送门就被判定进蓝门,
// 官方是传送后走向蓝门). The solo boss routes are exactly this shape: straight from the centre into the corner portal.
// Inside the portal it stays hidden for 3 s, as the explicit crossings of the same tiles (WAIT 3 in 85 of the 95).
test('solo boss: a route ending on the left portal entrance leaks at the left blue door, not the portal', REAL, () => {
const h = makeBattle({
kind: 'boss', stageId: 'act1autochess_m01', rect: { r0: 0, r1: 5, c0: 0, c1: 20 },
waveTemplate: 'act1autochess_h07_02_s', content: 'none',
defs: { enemies: { enemy_test_portal: enemyRec({ key: 'enemy_test_portal', hp: 1e6, speed: 3 }) } },
enemies: [{ key: 'enemy_test_portal', route: 0 }],
autoFinish: true, timeLimit: 300, hooks: ['enemyLeak'],
});
const spans = hiddenSpans(h);
const e = () => h.enemy('enemy_test_portal');
assert.ok(h.runUntil(() => e() && Math.round(e().y) === 1 && Math.round(e().x) === 3, 300), 'reaches the portal entrance [1,3]');
assert.ok(h.runUntil(() => e() && Math.round(e().y) === 5 && Math.round(e().x) === 10, 300), 'teleports out of the exit [5,10]');
h.runToEnd(400);
const [leak, ...more] = leaksOf(h);
assert.ok(leak, 'leaks eventually');
assert.deepEqual([Math.round(leak.enemy.y), Math.round(leak.enemy.x)], [2, 2], 'the leak is at the left blue door [2,2]');
assert.equal(more.length, 0, 'stepping into the portal entrance is no leak of its own');
assert.equal(spans.length, 1, 'one portal crossing');
assert.deepEqual([spans[0].from, spans[0].to], [[1, 3], [5, 10]], 'hidden from the entrance to the exit');
assert.ok(Math.abs(spans[0].dt - 3) < h.TICK * 1.5, `hidden 3 s inside the portal (${spans[0].dt.toFixed(3)} s)`);
});
test('a route ending on the right portal entrance mirrors: leak at the right blue door', REAL, () => {
const h = makeBattle({
kind: 'boss', stageId: 'act1autochess_m01', rect: { r0: 0, r1: 5, c0: 0, c1: 20 }, content: 'none',
defs: { enemies: { enemy_test_portal: enemyRec({ key: 'enemy_test_portal', hp: 1e6, speed: 3 }) } },
enemies: [{ key: 'enemy_test_portal', route: { motion: 'WALK', start: [2, 10], end: [1, 17], checkpoints: [] } }],
autoFinish: true, timeLimit: 300, hooks: ['enemyLeak'],
});
const spans = hiddenSpans(h);
h.runToEnd(400);
const [leak, ...more] = leaksOf(h);
assert.ok(leak, 'leaks eventually');
assert.deepEqual([Math.round(leak.enemy.y), Math.round(leak.enemy.x)], [2, 18], 'the leak is at the right blue door [2,18]');
assert.equal(more.length, 0, 'stepping into the portal entrance is no leak of its own');
assert.deepEqual(spans.map((x) => [x.from, x.to]), [[[1, 17], [5, 10]]], 'one crossing, entrance to exit');
assert.ok(Math.abs(spans[0].dt - 3) < h.TICK * 1.5, `hidden 3 s inside the portal (${spans[0].dt.toFixed(3)} s)`);
});