Files
ark-stronghold-protocol/test/sim/portal-appear.test.js
T
2321Robin a87cc1e9fd feedback7 R22-FIELDS: boss-field portals teleport — a route ending on a portal entrance comes out of the far exit and leaks at that side's blue door, the four appear-less crossings reappear at the exit (GitHub #336, PR #337 by @2321Robin); golden: 8 fields, 6 matches
PR #337 by @2321Robin (GitHub #336, both with a recording of the official game), applied with small changes: the exit
distance uses detmath hypot (PR #373 bans Math.hypot in the sim), comments cite the data counts as measured here
(137 routes end on [1,3] / [1,17]; 95 explicit DISAPPEAR / APPEAR pairs, all → [5,10]), the end-teleport's zero wait
is marked [ASSUMED] beside the pairing, and the test reads the test enemy's own leaks (the templates' escorts leak too)
and checks the entrance itself is no leak.

- simdata.js portalAppear: act1autochess_h07_02 #4, act1autochess_h08_02 #5 / #9 and act2autochess_h07_05 #6 write
  DISAPPEAR → WAIT → MOVE[5,10] with no APPEAR (the level files too); the exit APPEAR is inserted before that move.
  Before, the enemy walked the rest of its route hidden and leaked unseen.
- ai.js portalPickup: a route that ends on tile_telin (the boss / Hidden Core circuits) vanishes the enemy, brings it
  out of the farthest telout ([5,10]) and walks it to the nearest blue door ([2,2] / [2,18]), where it leaks. Before,
  stepping into the corner portal was the leak. Mid-route crossings are unchanged (every MOVE onto an entrance in the
  data is followed by an explicit DISAPPEAR).

Golden — only boss / Hidden Core fields and the matches' final rounds move (no round row of any match moves):
- fields boss-boss_2-solo (铳): the 2 lrrook escorts still leak, now at the door after the portal, and take 120652
  damage instead of 66943; leader left 2761255 → 2763024.
- fields boss-boss_4-solo (昆图斯): the 2 dspred leaks now happen at the door; one more operator death (6 → 7);
  leader left 3759559 → 3759921.
- fields boss-boss_6-pair (阿利斯泰尔): the 2 hllevi_2 that leaked at the portals are killed on the way back:
  killed 4 → 6, both players perfect (1 leak each → 0); leader left 7017881 → 6972383.
- fields boss-boss_6-solo: killed 5 → 8, leaks 5 → 2 (the 3 msfshu that leaked are killed; the 2 hllevi_2 leak at the
  door); leader damage 105194 → 75209.
- fields hidden-boss_8-pair (胄): p2's one vtsk leak is gone — that vtsk is still walking to the door at the 200 s
  cap; p2 perfect.
- fields hidden-boss_8-solo: killed 3 → 8, total 13 → 19 (more sfshu die, so more msfshu split off: 3 → 9), leaks
  10 → 10; leader damage 402606 → 191576 (the escorts that come back out of [5,10] draw the fire).
- fields hidden-boss_9-pair (铳): from portalAppear alone — h08_02's routes #5 / #9 no longer count the portal hop as
  8 tiles of walking, so the targeting order of those escorts changes; leader left 3570058 → 3511007.
- fields hidden-boss_10-solo (管): the yokai_3 escort crosses back (taken 68252 → 101682), 4 more operator deaths;
  leader left 7323878 → 7323370.
- matches solo-FUNNY-2 (boss_5 Final Assault, lost at 8 rounds): pool left 94860 → 94412, kills 143 → 144.
- matches solo-NORMAL-1 (m03, hidden boss_8 胄, act1autochess_h08_01_s): the Hidden Core goes from cleared to failed
  (hiddenCleared true → false, team LP 26 → 0, roundsPassed 15 → 14, pool left 0 → 99894; victory stays true — the
  Final Assault was won). The fight is identical until 105 s, when the third vtsk escort reaches the corner portal
  [1,3]: before, it leaked there (27 → 26 LP) and the leader died at 214 s. Now it comes out of [5,10], walks through
  the team's line to the blue door [2,2] and leaks at 170 s; meanwhile only 0–2 of the 8 operators stand (2–4 before),
  the leader stalls near 120000 HP, the fight passes the 150 real-second mark (300 game s) and the overtime drain (1 LP
  per real second) plus two drone leaks at the door take the last 25 LP at 348 s with 99894 HP left.
- matches solo-HARD-1 (m03, hidden boss_8): still failed at 0 LP after 14 rounds; pool left 3046117 → 2839368, kills
  419 → 422.
- matches solo-HARD-9-boosted (hidden boss_10 管): still cleared; team LP 383 → 361 — the 管 echoes (acdums) leaked
  7 times at the portal before and once at the door now, but the longer fight pays 37 overtime LP instead of 9.
- matches coop2-ABYSS-10-boosted (hidden boss_9 铳): from portalAppear alone (the remaining-distance change above);
  still cleared at 800 LP, the two seats' leader damage shifts (6609998 / 7282442 → 7523075 / 6369365) and their
  titles swap (坚若磐石 / 卫戍之星 → 卫戍之星 / 不朽盟约).
- matches coop2-NORMAL-14-standins (boss_5 Final Assault, lost at 13 rounds): pool left 107915 → 97588.

Tests: test/sim/portal-appear.test.js (new, 9; 7 fail on the parent: the APPEAR insertion, the four real routes,
the two route-end portals).
2026-10-08 19:00:50 +08:00

115 lines
6.8 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');
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.
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 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');
});
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'],
});
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');
});