feedback7 R22-FIELDS: the path tie-break never wades into more 深水区 than the official route — on 战场#08(下半) the patrolling 铳 / 卢西恩 take the official upper road from (3,9) to (2,3) again (GitHub #375); golden: boss-boss_2-pair, coop2-ABYSS-10-boosted

GitHub #375 (FrogThai): 「水图boss会从下面这条路往蓝门走,实际应该从上路走」. The walking leaders of 战场#08(下半) 涨潮控制
(act2autochess_m04) are 铳 (level_act1autochess_h07_02 route 6; the Hidden Core 铳 on act1autochess_h08_02's patrol
routes) and 卢西恩 (act2autochess_h07_05 route 8): PATROL_MOVE (3,9) → (2,3) → (5,9). Measured (notes repro-375.mjs):
ours 「3,9 2,9 2,6 4,6 4,4 2,4 2,3」 — the lower road through the 深水区 (3,6) / (4,6) — against the official SPFA
「3,9 5,9 5,4 2,4 2,3」 along row 5 with one 深水区 (5,6); the mirrored (3,11) → (2,17) split the same way.
Cause: grid.js _buildField takes the blockable-ground preference pointer (user playtest #2) when its route crosses
strictly fewer non-blockable tiles; 深水区 is non-blockable itself (no deployment), so the lower road counted 2 against
the upper road's 3 (two floor tiles and one water tile) and won. Official pathfinding is the plain 4-neighbour SPFA
(research 08 §3.1). Now the preference pointer is taken only when its route also crosses no more 深水区 than the official
pointer's (a per-tile `deep` count beside `cost`, start excluded). Rule from the Grok triage (scratch-i1b/375.patch),
re-implemented with a precomputed Grid.deepWater mask (terrain 'deepsea': tile_deepsea and tile_deepwater).

Scope (notes dump-fields.mjs / check-official.mjs, every stage × normal / 联防 / boss rect × every walkable
destination): 48 fields change, only on the two water maps (act2 m04 boss field; the inactive act1 m05), 149 pointers,
every one back to the official pointer. The 21 accepted DEVIATIONS (战场#01 / #02 floor, #07, the inactive #05's 7
water-avoiding routes) are unchanged, as are all 154 audited gate routes and data/stages.json's groundPaths
(tools/build-data.mjs --offline into a scratch dir: every data file identical). Other boss-field preference routes of
m04 that cross fewer 深水区 than the official one stay.

Tests: test/sim/pathing-official.test.js — two new #375 tests (the four patrol legs equal the pure official waypoints;
in a real battle the 铳 climbs to row 5 and steps on the 深水区 (5,6) only), both failing on the parent;
test/sim/pathing-crosscheck.test.js — the independent reference port gets the same 深水区 rule (it flagged both the
patrol leg and a random-crate normal-field route of act2 m04 until then); test/sim/pathing-blockable.test.js — the WALK-leg
audit compares with the equal-length routes that cross no more 深水区 than ours (the 14 accepted arena exceptions
unchanged). Grid-related suites 777/777.

Golden:
- fields boss-boss_2-pair (铳 pair on act2autochess_m04, act1autochess_h07_02): the two 铳 patrol the upper roads;
  leader left 5311560 → 5301010, p1 damage up / p2 down, deaths 0 / 2 → 1 / 3; still 8 / 8 kills.
- matches coop2-ABYSS-10-boosted (act2 m04; Final Assault 卢西恩, Hidden Core 铳): still cleared, team LP 800 → 615. The
  bots' operators stand on the lower rows (the same tiles as before); with the leaders on the upper road the Final
  Assault lasts 335 game s instead of 247 and the Hidden Core 638 instead of 190, both past the 150 real-second mark:
  the overtime drain takes 17 + 168 LP (none before), no leak.
This commit is contained in:
sganggs committed 2026-10-08 20:14:37 +08:00
1 parent f14b3a062f
commit 8d896e40ea
10 files changed
+167 -86

No files matched your search

+1 -1
View File
@@ -442,7 +442,7 @@ as official level data, and sim tests use them as a plain two-halves road.
| `special` | `{"mire":{"intervalSec":1,"aspdPerStack":-0.05,"moveMulPerStack":-0.05,"maxStacks":10,"heavyWeight":3,…}}`, `{"infection":{"bb":{"damage":70,"atk":0.2,"attack_speed":20,"duration":300}}}`, `{"deepsea":{"skillId":"sktok_tidectrl_3","bb":{…}}}`, `{"smog":{"rule":"operatorsNotTargetableByEnemyRanged"}}`, `{"blower":{…}}` | special-terrain parameters | | `special` | `{"mire":{"intervalSec":1,"aspdPerStack":-0.05,"moveMulPerStack":-0.05,"maxStacks":10,"heavyWeight":3,…}}`, `{"infection":{"bb":{"damage":70,"atk":0.2,"attack_speed":20,"duration":300}}}`, `{"deepsea":{"skillId":"sktok_tidectrl_3","bb":{…}}}`, `{"smog":{"rule":"operatorsNotTargetableByEnemyRanged"}}`, `{"blower":{…}}` | special-terrain parameters |
| `runes[]`, `globalBuffs[]` | `{"key":"map_tile_blackb_assign","bb":{…},"bbStr":{…}}` | level runes (act2 m03 reed rune has no reed tiles — leftover) | | `runes[]`, `globalBuffs[]` | `{"key":"map_tile_blackb_assign","bb":{…},"bbStr":{…}}` | level runes (act2 m03 reed rune has no reed tiles — leftover) |
| `deployTiles` | `{"normal":{"melee":[[9,3],…],"rangedOnly":[[10,4],…],"changedByDevices":[…]},"bossLeft":{…},"bossRight":{…}}` | deployable tiles at match start: `melee` = LOW (effective) buildable ALL/MELEE (ranged may use them too) — and a 深水区 under an active 特制水上平台 (`waterPlatform`, "在水上建立可以部署任意单位的平台"; act1 m05 only — the prep's deploy map only: the sim does not model the canoes, so its Grid keeps those tiles NONE, automatic placements avoid them and the path tie-break counts them as non-blockable [ASSUMED, weight 0 this season]) —, `rangedOnly` = high ground / active platforms; tiles under active crates/mounds and the 深水区 excluded. `bossLeft` / `bossRight` (field coordinates, boss rows 1–5) are what a player may deploy on in a boss round's prep: board (r, c) = boss (r − 7, c) / the right half mirrored (r − 7, 20 − c) (server/match/board.js `field`, user playtest #5 item 7) | | `deployTiles` | `{"normal":{"melee":[[9,3],…],"rangedOnly":[[10,4],…],"changedByDevices":[…]},"bossLeft":{…},"bossRight":{…}}` | deployable tiles at match start: `melee` = LOW (effective) buildable ALL/MELEE (ranged may use them too) — and a 深水区 under an active 特制水上平台 (`waterPlatform`, "在水上建立可以部署任意单位的平台"; act1 m05 only — the prep's deploy map only: the sim does not model the canoes, so its Grid keeps those tiles NONE, automatic placements avoid them and the path tie-break counts them as non-blockable [ASSUMED, weight 0 this season]) —, `rangedOnly` = high ground / active platforms; tiles under active crates/mounds and the 深水区 excluded. `bossLeft` / `bossRight` (field coordinates, boss rows 1–5) are what a player may deploy on in a boss round's prep: board (r, c) = boss (r − 7, c) / the right half mirrored (r − 7, 20 − c) (server/match/board.js `field`, user playtest #5 item 7) |
| `groundPaths` / `groundPathsWithDevices` | `{"12,10->9,2":[[12,10],[12,9],…,[12,3],[11,3],[10,3],[9,3],[9,2]]}` | the ground route of the sim's own `server/sim/grid.js` flow field (research 08 §3.4: 4-direction SPFA from the goal, crates cost 1000, Bresenham line-of-sight smoothing — official route lengths — kept unless the remake's (0.1.0's) road-over-floor preference route crosses strictly fewer non-blockable floor / gate / 深水区 tiles — the fewest among equal-length chains, smoothing that never cuts across floor its grid route does not walk; a corner touch does not count as a crossing — e.g. 战场#01's lower gate takes the col-8 road, not the col-9 floor lane, while 战场#04's keeps the official diagonal from row 9 into row 10; DESIGN §14 corrections, test/sim/pathing-official.test.js) as the tiles an enemy crosses, over the whole map, without / with the match-start devices (crates cost 1000, platforms / mounds blocking [ASSUMED]); the battle re-reads its flow fields live | | `groundPaths` / `groundPathsWithDevices` | `{"12,10->9,2":[[12,10],[12,9],…,[12,3],[11,3],[10,3],[9,3],[9,2]]}` | the ground route of the sim's own `server/sim/grid.js` flow field (research 08 §3.4: 4-direction SPFA from the goal, crates cost 1000, Bresenham line-of-sight smoothing — official route lengths — kept unless the remake's (0.1.0's) road-over-floor preference route crosses strictly fewer non-blockable floor / gate / 深水区 tiles and no more 深水区 (GitHub #375) — the fewest among equal-length chains, smoothing that never cuts across floor its grid route does not walk; a corner touch does not count as a crossing — e.g. 战场#01's lower gate takes the col-8 road, not the col-9 floor lane, while 战场#04's keeps the official diagonal from row 9 into row 10; DESIGN §14 corrections, test/sim/pathing-official.test.js) as the tiles an enemy crosses, over the whole map, without / with the match-start devices (crates cost 1000, platforms / mounds blocking [ASSUMED]); the battle re-reads its flow fields live |
| `options`, `config` | `{"characterLimit":8,"moveMultiplier":0.5}`, `{…configBlackBoard strings…}` | | | `options`, `config` | `{"characterLimit":8,"moveMultiplier":0.5}`, `{…configBlackBoard strings…}` | |
Glyph legend (`rows`): Glyph legend (`rows`):
+3 -1
View File
@@ -215,7 +215,9 @@ WALK legs pathfind on the stage grid inside the rect with the official flow fiel
destination, crates cost 1000, then Bresenham line-of-sight smoothing — research 08 §3.4). The official route stays destination, crates cost 1000, then Bresenham line-of-sight smoothing — research 08 §3.4). The official route stays
unless 0.1.0's road-over-floor preference route (the fewest non-blockable tiles among equal-length chains, a line of unless 0.1.0's road-over-floor preference route (the fewest non-blockable tiles among equal-length chains, a line of
sight that never covers floor — diagonal-step corners included — its grid route does not walk; user playtest #2 item 2) sight that never covers floor — diagonal-step corners included — its grid route does not walk; user playtest #2 item 2)
crosses strictly fewer non-blockable tiles (floor / gate lanes); "crosses" = passes through the tile's interior, a crosses strictly fewer non-blockable tiles (floor / gate lanes) and no more 深水区 (GitHub #375: on 战场#08(下半) the
patrolling 铳 / 卢西恩 waded through two water tiles to dodge one floor tile; the official upper road crosses one);
"crosses" = passes through the tile's interior, a
corner touch does not count (`grid.js` `segmentTiles`; community report D5 after 0.1.0: 战场#04's lower-gate enemies cut corner touch does not count (`grid.js` `segmentTiles`; community report D5 after 0.1.0: 战场#04's lower-gate enemies cut
diagonally from row 9 into row 10 as officially), and on equal counts the official route stays. diagonally from row 9 into row 10 as officially), and on equal counts the official route stays.
test/sim/pathing-official.test.js lists the 21 of 154 stage routes that still differ from the official ones (战场#01's test/sim/pathing-official.test.js lists the 21 of 154 stage routes that still differ from the official ones (战场#01's
+1 -1
View File
@@ -95,7 +95,7 @@ Piece = { uid, kind: 'chess'|'item'|'token', id, golden: bool, tier, items: [ {
**Corrections from research 08/09 (normative):** **Corrections from research 08/09 (normative):**
- Waves: official generator (research 08 §2): 3 of 6 types, each owns exactly 3 of the 15 round slots, SPECIAL the rest (shuffled per match); per round one type + one weighted special entry (half by round); each template SPAWN **action** becomes `clamp(roundHalfEven(n·P(t)/P(new)), 1, 5)` units over the same window; placeholder actions of the other movement class are skipped. Stage per match from the mode's list (标准 = 战场#01 only, §6.5). No custom balance multipliers (`data/tuning.json` keeps only titles); co-op boss pool = `bloodPoint[difficulty]` unscaled. - Waves: official generator (research 08 §2): 3 of 6 types, each owns exactly 3 of the 15 round slots, SPECIAL the rest (shuffled per match); per round one type + one weighted special entry (half by round); each template SPAWN **action** becomes `clamp(roundHalfEven(n·P(t)/P(new)), 1, 5)` units over the same window; placeholder actions of the other movement class are skipped. Stage per match from the mode's list (标准 = 战场#01 only, §6.5). No custom balance multipliers (`data/tuning.json` keeps only titles); co-op boss pool = `bloodPoint[difficulty]` unscaled.
- Ground pathing: official SPFA-style 4-direction search from the goal with crates as cost-1000 tiles, then straight-line smoothing (research 08 §3.4). act1 m02 crates #001–#004 inactive in 下半. Remake refinement (user playtest #2, narrowed by §21.15): route lengths and routes stay the official ones unless 0.1.0's road-over-floor preference route crosses strictly fewer non-blockable (floor / gate / 深水区) tiles — a corner touch is not a crossing; ties keep the official route. E.g. 战场#01's lower-gate enemies take the col-8 road, while 战场#04's keep the official diagonal from row 9 into row 10 (server/sim/grid.js header; test/sim/pathing-official.test.js, pathing-blockable.test.js). - Ground pathing: official SPFA-style 4-direction search from the goal with crates as cost-1000 tiles, then straight-line smoothing (research 08 §3.4). act1 m02 crates #001–#004 inactive in 下半. Remake refinement (user playtest #2, narrowed by §21.15): route lengths and routes stay the official ones unless 0.1.0's road-over-floor preference route crosses strictly fewer non-blockable (floor / gate / 深水区) tiles and no more 深水区 (GitHub #375) — a corner touch is not a crossing; ties keep the official route. E.g. 战场#01's lower-gate enemies take the col-8 road, while 战场#04's keep the official diagonal from row 9 into row 10 (server/sim/grid.js header; test/sim/pathing-official.test.js, pathing-blockable.test.js).
- Observing: no watching while your own normal battle runs; after it ends (and in prep) tap a teammate → 前往查看 (local replica, fast-forwarded). ‹ › only inside 联防/FA fields; FA: own pair only; eliminated: free. - Observing: no watching while your own normal battle runs; after it ends (and in prep) tap a teammate → 前往查看 (local replica, fast-forwarded). ‹ › only inside 联防/FA fields; FA: own pair only; eliminated: free.
- Prep interactions: tap a unit → underframe with 出售 +1 (and 撤退 on board); items/Arts → 销毁; shop and upgrade are two-tap (select → confirm; the remake's 机变 cards too, §18.2); strategy draft forbids a strategy already taken by a teammate (队友已选). - Prep interactions: tap a unit → underframe with 出售 +1 (and 撤退 on board); items/Arts → 销毁; shop and upgrade are two-tap (select → confirm; the remake's 机变 cards too, §18.2); strategy draft forbids a strategy already taken by a teammate (队友已选).
- Emotes: 6 themes × 6 battle emotes (research 09 §4.2), image-only bubbles beside the sender avatar, 1 s cooldown, 3 s bubble; `g.emote {id}` with the official emoji id. - Emotes: 6 themes × 6 battle emotes (research 09 §4.2), image-only bubbles beside the sender avatar, 1 s cooldown, 3 s bubble; `g.emote {id}` with the official emoji id.
+3 -2
View File
@@ -352,8 +352,9 @@ reported the 战场#01 lower-gate enemies walking up the col-9 floor lane, where
`server/sim/grid.js` keeps every official route LENGTH and the official route itself unless the 2026-09-29 `server/sim/grid.js` keeps every official route LENGTH and the official route itself unless the 2026-09-29
road-over-floor preference route (equal-length ties to the chain with the fewest non-blockable floor / gate tiles, road-over-floor preference route (equal-length ties to the chain with the fewest non-blockable floor / gate tiles,
smoothing that never cuts across floor its grid route does not walk, the corner tiles of a diagonal step included) smoothing that never cuts across floor its grid route does not walk, the corner tiles of a diagonal step included)
crosses strictly fewer non-blockable tiles; in that comparison a segment that only touches a floor tile's corner does crosses strictly fewer non-blockable tiles and no more 深水区 (GitHub #375, 0.2.2: the 深水区 is non-blockable too, and
not cross it. Used alone (0.1.0) the preference also bent official diagonals into L shapes — the D5 report after on 战场#08(下半) the patrolling leaders had waded through two water tiles to dodge one floor tile); in that comparison a
segment that only touches a floor tile's corner does not cross it. Used alone (0.1.0) the preference also bent official diagonals into L shapes — the D5 report after
0.1.0: on 战场#04 the lower-gate enemies walked (9,10) → (9,8) → (10,8) instead of the official diagonal (9,10) → 0.1.0: on 战场#04 the lower-gate enemies walked (9,10) → (9,8) → (10,8) instead of the official diagonal (9,10) →
(10,7), which only brushes the corner of the floor (10,9). Lanes that still differ from the table above (21 of the 154 (10,7), which only brushes the corner of the floor (10,9). Lanes that still differ from the table above (21 of the 154
stage × gate × field routes, test/sim/pathing-official.test.js): stage × gate × field routes, test/sim/pathing-official.test.js):
+22 -11
View File
@@ -26,8 +26,7 @@
// 0.1.0: on 战场#04 活性源石 the lower-gate enemies must cross diagonally from row 9 into row 10 as officially, not // 0.1.0: on 战场#04 活性源石 the lower-gate enemies must cross diagonally from row 9 into row 10 as officially, not
// walk two tiles left of the gate and then straight up). A tile is non-blockable when it is walkable but not LOW // walk two tiles left of the gate and then straight up). A tile is non-blockable when it is walkable but not LOW
// ground buildable for melee (floor / gate / goal / teleport / 深水区 tiles — `blockable()` false; no gate route of 战场#08 // ground buildable for melee (floor / gate / goal / teleport / 深水区 tiles — `blockable()` false; no gate route of 战场#08
// changed when its 深水区 became non-buildable, a few off-route smoothed steps of its boss field, rows 4–5, go around the // changed when its 深水区 became non-buildable). Each field holds two candidate
// water instead of cutting across it). Each field holds two candidate
// pointers per tile, both with the official `dist` (so the crate cost 1000 and every grid route length stay official): // pointers per tile, both with the official `dist` (so the crate cost 1000 and every grid route length stay official):
// * official: the plain algorithm above; // * official: the plain algorithm above;
// * preference (0.1.0's): the SPFA relaxes on (dist, pen) lexicographically, `pen` = non-blockable tiles on the chain // * preference (0.1.0's): the SPFA relaxes on (dist, pen) lexicographically, `pen` = non-blockable tiles on the chain
@@ -36,7 +35,10 @@
// only on the tile's own raw chain between its two ends, so it never slips past a road tile beside floor its grid // only on the tile's own raw chain between its two ends, so it never slips past a road tile beside floor its grid
// route does not walk. // route does not walk.
// `next[tile]` is the official pointer unless the preference pointer leads to a route CROSSING strictly fewer // `next[tile]` is the official pointer unless the preference pointer leads to a route CROSSING strictly fewer
// non-blockable tiles (`cost`, counted to the destination in increasing `dist` order). A segment crosses the tiles // non-blockable tiles (`cost`, counted to the destination in increasing `dist` order) and no more 深水区 (`deep`): 深水区
// is non-blockable itself, so without that test the preference waded into the pool to dodge floor (GitHub #375: on
// 战场#08(下半) the patrolling 铳 / 卢西恩 took the lower road (3,9) → (2,3) through two water tiles, one floor tile fewer
// than the official upper road with one). A segment crosses the tiles
// whose interior it passes through (`crossTiles`, exact geometry); one it only touches at a corner point is not crossed // whose interior it passes through (`crossTiles`, exact geometry); one it only touches at a corner point is not crossed
// — the first version counted the corner tiles there too and so bent official diagonals into L shapes (D5). On equal // — the first version counted the corner tiles there too and so bent official diagonals into L shapes (D5). On equal
// counts the official pointer stays, except where the preference one merely skips it (the official waypoint lies on // counts the official pointer stays, except where the preference one merely skips it (the official waypoint lies on
@@ -166,9 +168,12 @@ export class Grid {
} }
/** 1 = walkable terrain an operator cannot block on (floor, gate, goal, teleport…): the path tie-break penalty */ /** 1 = walkable terrain an operator cannot block on (floor, gate, goal, teleport…): the path tie-break penalty */
this.unblockable = new Uint8Array(ROWS * COLS); this.unblockable = new Uint8Array(ROWS * COLS);
/** 1 = walkable 深水区 (terrain 'deepsea': tile_deepsea / tile_deepwater): the tie-break never walks into more of it */
this.deepWater = new Uint8Array(ROWS * COLS);
for (let k = 0; k < ROWS * COLS; k++) { for (let k = 0; k < ROWS * COLS; k++) {
const t = this.tiles[k]; const t = this.tiles[k];
this.unblockable[k] = t.pass === 'ALL' && !isBlockableTile(t) ? 1 : 0; this.unblockable[k] = t.pass === 'ALL' && !isBlockableTile(t) ? 1 : 0;
this.deepWater[k] = t.pass === 'ALL' && t.terrain === 'deepsea' ? 1 : 0;
} }
/** obstacle bits per tile (OB_BLOCK | OB_CRATE) */ /** obstacle bits per tile (OB_BLOCK | OB_CRATE) */
this.obstacle = new Uint8Array(ROWS * COLS); this.obstacle = new Uint8Array(ROWS * COLS);
@@ -272,10 +277,10 @@ export class Grid {
_buildField(er, ec, allowDiagonal, ignore) { _buildField(er, ec, allowDiagonal, ignore) {
const N = ROWS * COLS; const N = ROWS * COLS;
const f = { dest: -1, dist: null, parent: null, pen: null, next: null, official: null, cost: null, len: null, version: this.version, allowDiagonal, ignore }; const f = { dest: -1, dist: null, parent: null, pen: null, next: null, official: null, cost: null, deep: null, len: null, version: this.version, allowDiagonal, ignore };
if (!this.inBounds(er, ec)) { if (!this.inBounds(er, ec)) {
const none = new Int32Array(N).fill(-1); const none = new Int32Array(N).fill(-1);
return Object.assign(f, { dist: none, parent: none, next: none, official: none, pen: new Int32Array(N), cost: new Int32Array(N) }); return Object.assign(f, { dist: none, parent: none, next: none, official: none, pen: new Int32Array(N), cost: new Int32Array(N), deep: new Int32Array(N) });
} }
const dest = er * COLS + ec; const dest = er * COLS + ec;
f.dest = dest; f.dest = dest;
@@ -300,25 +305,30 @@ export class Grid {
(n) => { for (let x = n, g = N; x >= 0 && g-- > 0; x = pref.parent[x]) onChain[x] = n; }); (n) => { for (let x = n, g = N; x >= 0 && g-- > 0; x = pref.parent[x]) onChain[x] = n; });
// per tile, in increasing distance: the official pointer unless the preference one crosses strictly fewer // per tile, in increasing distance: the official pointer unless the preference one crosses strictly fewer
// non-blockable tiles on the way to the destination — or as few while only skipping the official waypoint o, which // non-blockable tiles on the way to the destination — or as few while only skipping the official waypoint o, which
// then lies on the straight line to it and leads there (the same walk, one waypoint fewer) // then lies on the straight line to it and leads there (the same walk, one waypoint fewer) — and no more 深水区
// than the official one (GitHub #375)
const order = []; const order = [];
for (let k = 0; k < N; k++) if (dist[k] >= 0) order.push(k); for (let k = 0; k < N; k++) if (dist[k] >= 0) order.push(k);
order.sort((a, b) => dist[a] - dist[b] || a - b); order.sort((a, b) => dist[a] - dist[b] || a - b);
const through = (a, b) => { let n = -unb[a]; crossTiles(a, b, (r, c) => { n += unb[r * COLS + c]; }); return n; }; const through = (a, b) => { let n = -unb[a]; crossTiles(a, b, (r, c) => { n += unb[r * COLS + c]; }); return n; };
const deepW = this.deepWater;
const wet = (a, b) => { let n = -deepW[a]; crossTiles(a, b, (r, c) => { n += deepW[r * COLS + c]; }); return n; };
const next = new Int32Array(N).fill(-1); const next = new Int32Array(N).fill(-1);
const cost = new Int32Array(N); const cost = new Int32Array(N);
const deep = new Int32Array(N);
for (const k of order) { for (const k of order) {
const o = nextO[k], p = nextP[k]; const o = nextO[k], p = nextP[k];
if (o < 0) continue; // the destination if (o < 0) continue; // the destination
let use = o, best = through(k, o) + cost[o]; let use = o, best = through(k, o) + cost[o], bestWet = wet(k, o) + deep[o];
if (p >= 0 && p !== o) { if (p >= 0 && p !== o) {
const cp = through(k, p) + cost[p]; const cp = through(k, p) + cost[p], wp = wet(k, p) + deep[p];
if (cp < best || (cp === best && next[o] === p && onSegment(k, o, p))) { use = p; best = cp; } if (wp <= bestWet && (cp < best || (cp === best && next[o] === p && onSegment(k, o, p)))) { use = p; best = cp; bestWet = wp; }
} }
next[k] = use; next[k] = use;
cost[k] = best; cost[k] = best;
deep[k] = bestWet;
} }
return Object.assign(f, { dist, parent: pref.parent, pen: pref.pen, next, official: nextO, cost }); return Object.assign(f, { dist, parent: pref.parent, pen: pref.pen, next, official: nextO, cost, deep });
} }
/** /**
@@ -434,8 +444,9 @@ export class Grid {
* @property {Int32Array} pen non-blockable tiles on that raw chain (tile included, destination excluded) * @property {Int32Array} pen non-blockable tiles on that raw chain (tile included, destination excluded)
* @property {Int32Array} official smoothed parent of the pure official algorithm * @property {Int32Array} official smoothed parent of the pure official algorithm
* @property {Int32Array} next the tile to walk straight toward (official, or the preference pointer when it crosses fewer * @property {Int32Array} next the tile to walk straight toward (official, or the preference pointer when it crosses fewer
* non-blockable tiles — see the module header) * non-blockable tiles and no more 深水区 — see the module header)
* @property {Int32Array} cost non-blockable tiles the route of `next` crosses from the tile (excluded) to the destination * @property {Int32Array} cost non-blockable tiles the route of `next` crosses from the tile (excluded) to the destination
* @property {Int32Array} deep 深水区 tiles the route of `next` crosses from the tile (excluded) to the destination
* @property {Float64Array|null} len memoised smoothed length (fieldLength) * @property {Float64Array|null} len memoised smoothed length (fieldLength)
*/ */
+48 -48
View File
@@ -142,75 +142,75 @@
"killed": 8, "killed": 8,
"total": 8, "total": 8,
"unspawned": 0, "unspawned": 0,
"bossHpLeft": 5311560, "bossHpLeft": 5301010,
"rngDraws": 839, "rngDraws": 783,
"errors": {"count":0,"kinds":[]}, "errors": {"count":0,"kinds":[]},
"players": { "players": {
"p1": {"killed":8,"total":8,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":612448,"bossDmg":313008,"heal":16689,"deaths":0,"layerGains":{}}, "p1": {"killed":8,"total":8,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":741043,"bossDmg":366117,"heal":19816,"deaths":1,"layerGains":{}},
"p2": {"killed":0,"total":0,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":937710,"bossDmg":372608,"heal":6945,"deaths":2,"layerGains":{}} "p2": {"killed":0,"total":0,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":819707,"bossDmg":330056,"heal":4781,"deaths":3,"layerGains":{}}
}, },
"units": [ "units": [
["p1",1,"op","chess_char_5_19_b",2,"uniequip_002_mlynar",3907,1,9,0,183438,1132,0,53,3], ["p1",1,"op","chess_char_5_19_b",2,"uniequip_002_mlynar",3907,1,9,0,235262,2487,117,74,3],
["p1",2,"op","chess_char_4_01_b",2,"uniequip_002_rmixer",5482.35,1,11,0,3903,11319,305,35,1], ["p1",2,"op","chess_char_4_01_b",2,"uniequip_002_rmixer",5482.35,1,11,0,12166,13761,318,33,1],
["p1",3,"op","chess_char_5_14_b",1,"none",2174,1,7,0,30509,953,294,58,17], ["p1",3,"op","chess_char_5_14_b",1,"none",2174,1,7,0,24813,953,148,16,12],
["p1",4,"op","chess_char_5_17_b",1,"uniequip_002_f12yin",2738,1,13,0,10026,0,0,66,1], ["p1",4,"op","chess_char_5_17_b",1,"uniequip_002_f12yin",2738,1,13,0,5871,0,0,20,1],
["p1",5,"op","chess_char_5_15_b",1,"uniequip_002_thorn2",1170,1,1,0,79315,0,168,32,8], ["p1",5,"op","chess_char_5_15_b",1,"uniequip_002_thorn2",1170,1,1,0,123408,0,5557,77,10],
["p1",6,"op","chess_char_5_20_b",2,"uniequip_002_aglina",2418.5,1,3,0,78933,0,0,44,4], ["p1",6,"op","chess_char_5_20_b",2,"uniequip_002_aglina",2418.5,1,3,0,81805,0,0,44,4],
["p1",7,"op","chess_char_5_21_b",1,"uniequip_002_sntlla",2769.3,1,17,1,26033,0,0,43,2], ["p1",7,"op","chess_char_5_21_b",1,"uniequip_002_sntlla",2769.3,1,17,2,56882,1768,780,100,3],
["p1",8,"op","chess_char_5_22_b",1,"uniequip_002_nymph",1611,1,19,2,168011,1748,1748,55,18], ["p1",8,"op","chess_char_5_22_b",1,"uniequip_002_nymph",737.6,1,43,1,153989,3270,350,46,16],
["p1",9,"op","chess_char_5_23_b",1,"uniequip_002_reckpr",1610,1,5,0,0,0,14119,21,3], ["p1",9,"op","chess_char_5_23_b",1,"uniequip_002_reckpr",1610,1,5,0,0,0,12546,19,3],
["p1",10,"op","chess_char_4_02_b",2,"uniequip_002_mostma",1695,1,15,0,32280,1383,55,35,2], ["p1",10,"op","chess_char_4_02_b",2,"uniequip_002_mostma",1695,1,15,1,46847,0,0,34,2],
["p2",11,"op","chess_char_4_11_b",1,"uniequip_002_cathy",836.62,1,12,0,0,3919,1500,0,2], ["p2",11,"op","chess_char_4_11_b",1,"uniequip_002_cathy",2172.64,1,50,0,0,5191,1078,0,1],
["p2",12,"op","chess_char_4_04_b",1,"uniequip_002_ines",3545.85,1,51,2,253580,496,458,287,8], ["p2",12,"op","chess_char_4_04_b",1,"uniequip_002_ines",3545.85,1,51,2,259014,0,0,279,8],
["p2",13,"op","chess_char_4_07_b",1,"uniequip_002_bpipe",2099.31,1,8,0,4500,1149,865,24,8], ["p2",13,"op","chess_char_4_07_b",1,"uniequip_002_bpipe",2383,1,52,0,3678,2726,343,18,7],
["p2",14,"op","chess_char_4_16_b",2,"uniequip_002_texas2",2671.35,1,52,1,112288,6746,1602,147,13], ["p2",14,"op","chess_char_4_16_b",2,"uniequip_002_texas2",2671.35,1,49,1,97836,2233,1258,153,10],
["p2",15,"op","chess_char_4_09_b",1,"uniequip_002_mizuki",2777.6,1,10,0,62072,0,0,54,3], ["p2",15,"op","chess_char_4_09_b",1,"uniequip_002_mizuki",2777.6,1,10,0,82406,0,0,73,4],
["p2",16,"op","chess_char_4_06_b",1,"uniequip_002_kroos2",1561,1,2,1,303642,0,0,181,3], ["p2",16,"op","chess_char_4_06_b",1,"uniequip_002_kroos2",1561,1,2,0,179138,0,0,231,3],
["p2",17,"op","chess_char_4_10_b",1,"uniequip_002_aroma",1664,1,4,0,0,0,0,0,0], ["p2",17,"op","chess_char_4_10_b",1,"uniequip_002_aroma",1664,1,4,0,0,0,0,0,0],
["p2",18,"op","chess_char_4_12_b",2,"uniequip_002_glady",1418.27,1,18,1,69028,3331,2517,58,3], ["p2",18,"op","chess_char_4_12_b",2,"uniequip_002_glady",1003.26,1,18,0,72377,3328,2099,61,4],
["p2",19,"op","chess_char_4_13_b",1,"uniequip_002_gnosis",1989,1,40,0,44456,1991,2,63,18], ["p2",19,"op","chess_char_4_13_b",1,"uniequip_002_gnosis",1989,1,40,0,62819,1991,2,81,22],
["p2",20,"op","chess_char_4_14_b",1,"uniequip_002_lionhd",1626,1,6,0,88144,0,0,57,14], ["p2",20,"op","chess_char_4_14_b",1,"uniequip_002_lionhd",1626,1,6,1,62440,0,0,47,13],
["p2",21,"token","token_10041_cathy_catsld",null,null,100,1,21,0,0,0,0,0,null], ["p2",21,"token","token_10041_cathy_catsld",null,null,100,1,21,0,0,0,0,0,null],
["p2",22,"token","token_10041_cathy_catsld",null,null,100,1,22,0,0,0,0,0,null] ["p2",22,"token","token_10041_cathy_catsld",null,null,100,1,22,0,0,0,0,0,null]
], ],
"enemies": {"enemy_1005_yokai_2":[6,6,0,3179,62190],"enemy_1430_lrrook":[2,2,0,0,802449],"enemy_9017_achunt":[2,0,0,30987,688440]}, "enemies": {"enemy_1005_yokai_2":[6,6,0,3956,62190],"enemy_1430_lrrook":[2,2,0,0,802449],"enemy_9017_achunt":[2,0,0,33752,698990]},
"hooks": [1,50,6000,1312,1325,2789,2834,4850,11,26,131,125,21,35636,978,0,10,0,1,11,0,13,1], "hooks": [1,50,6000,1371,1409,2907,2944,3285,13,26,127,123,21,38164,1047,0,10,0,1,13,0,12,1],
"events": {"atk":1342,"deploy":40,"die":26,"dmg":2826,"engage":17,"fx":602,"heal":43,"skill":256,"spawn":32,"status":489}, "events": {"atk":1410,"deploy":40,"die":26,"dmg":2934,"engage":17,"fx":696,"heal":80,"skill":250,"spawn":32,"status":549},
"fx": { "fx": {
"aoe": 38, "aoe": 33,
"appear": 2, "appear": 2,
"blink": 16, "blink": 14,
"burst": 13, "burst": 12,
"catShield": 1, "catShield": 1,
"charge": 8, "charge": 7,
"counter": 21, "counter": 21,
"crit": 88, "crit": 89,
"dilemma": 3, "dilemma": 4,
"disappear": 2, "disappear": 2,
"dodge": 1, "dodge": 1,
"explosion": 14, "explosion": 13,
"frostNova": 18, "frostNova": 22,
"garrisonGrant": 1, "garrisonGrant": 1,
"iceSpike": 39, "iceSpike": 89,
"overclock": 2, "overclock": 1,
"pull": 3, "pull": 4,
"quadShot": 2, "quadShot": 2,
"ripple": 13, "ripple": 20,
"sentry": 6, "sentry": 6,
"sermon": 1, "sermon": 1,
"shadowWeave": 11, "shadowWeave": 10,
"shield": 6, "shield": 5,
"soul": 5, "soul": 4,
"sp": 11, "sp": 7,
"stealth": 8, "stealth": 8,
"swordRain": 96, "swordRain": 82,
"swordStorm": 13, "swordStorm": 10,
"tornado": 3, "tornado": 4,
"tornadoPulse": 15, "tornadoPulse": 20,
"weightless": 4, "weightless": 4,
"zone": 138 "zone": 197
}, },
"snaps": "7917cfe4 ec53e8ac 09d8b201 823345e1 5d4f2a95 9efdd7ee 56e0addd 78c7726c 4ee3695b 20aee5b5 ba3311c0 d4d127c9 0c500cac d842272b 4cbf4a87 2525db60 e8039c1e 209c57b0 af85310c 92ca4257" "snaps": "7917cfe4 719c3d1e 5fce9836 dcc6d1db c6372862 d28f9510 24fc36dd 51354537 24003a40 0c6277ff 0e828fa2 41a80b07 a7179da1 e40e2870 d6867511 cc2e7940 487d9e8e b4112909 c46a7eff 812e6963"
}, },
"boss-boss_2-solo": { "boss-boss_2-solo": {
"about": "假想敌:铳 solo pool 3000000: chess_char_4_17_b chess_char_4_18_b chess_char_4_23_b chess_char_4_19_b chess_char_4_22_b chess_char_4_20_b chess_char_4_21_b chess_char_4_24_b chess_char_4_25_b chess_char_4_26_b", "about": "假想敌:铳 solo pool 3000000: chess_char_4_17_b chess_char_4_18_b chess_char_4_23_b chess_char_4_19_b chess_char_4_22_b chess_char_4_20_b chess_char_4_21_b chess_char_4_24_b chess_char_4_25_b chess_char_4_26_b",
+7 -7
View File
@@ -1404,7 +1404,7 @@
"combat": "client", "combat": "client",
"boost": {"lp":400,"layers":300}, "boost": {"lp":400,"layers":300},
"setup": {"stageId":"act2autochess_m04","bossId":"boss_5","hiddenBossId":"boss_9","factions":["FLY","ELEMENT","INVISIBLE"]}, "setup": {"stageId":"act2autochess_m04","bossId":"boss_5","hiddenBossId":"boss_9","factions":["FLY","ELEMENT","INVISIBLE"]},
"end": {"victory":true,"roundsPassed":15,"hiddenReached":true,"hiddenCleared":true,"reason":"victory","teamLp":800,"bossPoolLeft":0,"virtualMs":121650}, "end": {"victory":true,"roundsPassed":15,"hiddenReached":true,"hiddenCleared":true,"reason":"victory","teamLp":615,"bossPoolLeft":0,"virtualMs":121650},
"rng": {"setup":25,"shop":590,"waves":0,"draft":61,"bots":22075,"meta":141}, "rng": {"setup":25,"shop":590,"waves":0,"draft":61,"bots":22075,"meta":141},
"errors": {"engine":0,"logged":0,"sim":0,"dispatcher":0}, "errors": {"engine":0,"logged":0,"sim":0,"dispatcher":0},
"rounds": [ "rounds": [
@@ -1480,12 +1480,12 @@
"victory": true, "victory": true,
"roundsPassed": 15, "roundsPassed": 15,
"eliminatedRound": null, "eliminatedRound": null,
"lp": 400, "lp": 308,
"bandId": "band_sarkazb", "bandId": "band_sarkazb",
"title": {"id":"comment_1","name":"卫戍之星","picId":"comment_icon_1","text":"对敌方领袖造成伤害最高(仅胜利时)"}, "title": {"id":"comment_3","name":"坚若磐石","picId":"comment_icon_3","text":"目标生命值损失最少"},
"trophies": 8, "trophies": 8,
"reward": 425, "reward": 425,
"stats": {"activatedLayers":2267,"bossDamage":6992346,"dmgDealt":24545248,"gold":149,"itemsEquipped":23,"kills":290,"leaks":0,"lpLost":0,"merges":6,"perfectRounds":13,"refreshes":16}, "stats": {"activatedLayers":2267,"bossDamage":5899848,"dmgDealt":17549821,"gold":149,"itemsEquipped":23,"kills":301,"leaks":0,"lpLost":0,"merges":6,"perfectRounds":13,"refreshes":16},
"lineup": ["3_16_b@12,8[3_01_e_a+1_01_e_b]","4_09_b@11,5","3_11_b@11,8[3_02_e_a+6_06_e_a]","1_02_b@10,4[1_02_e_a+4_02_e_a]","5_14_a@10,8[3_03_e_a+1_05_e_a]","6_20_a@9,6","3_20_a@9,8[3_07_e_a+3_08_e_a]","6_05_a@9,9[2_04_e_a+5_02_e_b]"], "lineup": ["3_16_b@12,8[3_01_e_a+1_01_e_b]","4_09_b@11,5","3_11_b@11,8[3_02_e_a+6_06_e_a]","1_02_b@10,4[1_02_e_a+4_02_e_a]","5_14_a@10,8[3_03_e_a+1_05_e_a]","6_20_a@9,6","3_20_a@9,8[3_07_e_a+3_08_e_a]","6_05_a@9,9[2_04_e_a+5_02_e_b]"],
"bonds": ["arcaneShip 0/316","deputShip 1/312","egirShip 0/300","emptyShip 0/300","indomShip 0/300","investShip 0/300","kazimierzShip 0/302","kjeragShip 1/343","lateranoShip 0/300","maniShip 0/300","miraShip 0/300","preciShip 1/343","raidShip 0/300","sargonShip 0/300","siracusaShip 0/300","skillfulShip 0/319","soloShip 1/310","steadShip 1/341","suntShip 1/310","swiftShip 1/308","victoriaShip 0/300","visiShip 0/300","yanShip 0/300"] "bonds": ["arcaneShip 0/316","deputShip 1/312","egirShip 0/300","emptyShip 0/300","indomShip 0/300","investShip 0/300","kazimierzShip 0/302","kjeragShip 1/343","lateranoShip 0/300","maniShip 0/300","miraShip 0/300","preciShip 1/343","raidShip 0/300","sargonShip 0/300","siracusaShip 0/300","skillfulShip 0/319","soloShip 1/310","steadShip 1/341","suntShip 1/310","swiftShip 1/308","victoriaShip 0/300","visiShip 0/300","yanShip 0/300"]
}, },
@@ -1494,12 +1494,12 @@
"victory": true, "victory": true,
"roundsPassed": 15, "roundsPassed": 15,
"eliminatedRound": null, "eliminatedRound": null,
"lp": 400, "lp": 307,
"bandId": "band_doberm", "bandId": "band_doberm",
"title": {"id":"comment_2","name":"不朽盟约","picId":"comment_icon_2","text":"激活的盟约层数总和最高"}, "title": {"id":"comment_1","name":"卫戍之星","picId":"comment_icon_1","text":"对敌方领袖造成伤害最高(仅胜利时)"},
"trophies": 8, "trophies": 8,
"reward": 425, "reward": 425,
"stats": {"activatedLayers":2301,"bossDamage":6900614,"dmgDealt":23861530,"gold":165,"itemsEquipped":31,"kills":276,"leaks":0,"lpLost":0,"merges":5,"perfectRounds":13,"refreshes":38}, "stats": {"activatedLayers":2301,"bossDamage":7980752,"dmgDealt":27393341,"gold":165,"itemsEquipped":31,"kills":276,"leaks":0,"lpLost":0,"merges":5,"perfectRounds":13,"refreshes":38},
"lineup": ["2_10_a@11,5[2_03_e_b+3_09_e_a]","5_03_a@11,8[1_02_e_a+5_02_e_a]","1_06_a@10,3[1_05_e_a+3_02_e_a]","3_16_a@10,4[3_03_e_b+4_04_e_b]","5_06_a@10,8[3_09_e_b+3_09_e_b]","4_21_b@9,6[4_06_e_a+3_10_e_b]","5_02_a@9,8[4_09_e_a+3_06_e_b]","1_17_b@9,9[3_08_e_a+1_01_e_a]"], "lineup": ["2_10_a@11,5[2_03_e_b+3_09_e_a]","5_03_a@11,8[1_02_e_a+5_02_e_a]","1_06_a@10,3[1_05_e_a+3_02_e_a]","3_16_a@10,4[3_03_e_b+4_04_e_b]","5_06_a@10,8[3_09_e_b+3_09_e_b]","4_21_b@9,6[4_06_e_a+3_10_e_b]","5_02_a@9,8[4_09_e_a+3_06_e_b]","1_17_b@9,9[3_08_e_a+1_01_e_a]"],
"bonds": ["arcaneShip 1/327","deputShip 1/318","egirShip 0/300","emptyShip 0/300","indomShip 1/308","investShip 0/300","kazimierzShip 0/300","kjeragShip 0/300","lateranoShip 0/300","maniShip 0/300","miraShip 0/300","preciShip 1/404","raidShip 0/300","sargonShip 0/300","siracusaShip 0/300","skillfulShip 1/308","soloShip 0/310","steadShip 0/323","suntShip 1/304","swiftShip 0/300","victoriaShip 1/332","visiShip 0/300","yanShip 0/305"] "bonds": ["arcaneShip 1/327","deputShip 1/318","egirShip 0/300","emptyShip 0/300","indomShip 1/308","investShip 0/300","kazimierzShip 0/300","kjeragShip 0/300","lateranoShip 0/300","maniShip 0/300","miraShip 0/300","preciShip 1/404","raidShip 0/300","sargonShip 0/300","siracusaShip 0/300","skillfulShip 1/308","soloShip 0/310","steadShip 0/323","suntShip 1/304","swiftShip 0/300","victoriaShip 1/332","visiShip 0/300","yanShip 0/305"]
} }
+32 -11
View File
@@ -1,7 +1,8 @@
// Blockable-ground preference of the ground pathing (grid.js header; user playtest #2: on 战场#01 the lower-gate enemies // Blockable-ground preference of the ground pathing (grid.js header; user playtest #2: on 战场#01 the lower-gate enemies
// walked up the col-9 floor lane, where no operator can stand, instead of the col-8 road). The flow field keeps the // walked up the col-9 floor lane, where no operator can stand, instead of the col-8 road). The flow field keeps the
// official route unless the preference route — the fewest non-blockable tiles among equal-length chains, smoothing // official route unless the preference route — the fewest non-blockable tiles among equal-length chains, smoothing
// that never cuts across floor its grid route does not walk — crosses fewer non-blockable tiles. "Crosses" = the // that never cuts across floor its grid route does not walk — crosses fewer non-blockable tiles and no more 深水区
// (GitHub #375). "Crosses" = the
// segment passes through the tile's interior (grid.js segmentTiles; brushing a corner is not crossing — the D5 report // segment passes through the tile's interior (grid.js segmentTiles; brushing a corner is not crossing — the D5 report
// after 0.1.0, test/sim/pathing-official.test.js). Audit: every active stage × gate × field (normal, 联防 both halves, // after 0.1.0, test/sim/pathing-official.test.js). Audit: every active stage × gate × field (normal, 联防 both halves,
// boss both halves incl. the solo `_s` templates) — the non-blockable tiles an enemy still crosses are listed and // boss both halves incl. the solo `_s` templates) — the non-blockable tiles an enemy still crosses are listed and
@@ -69,27 +70,36 @@ function nbCrossed(g, s, e) {
/** /**
* Fewest non-blockable tiles (start and goal excluded) over all 4-connected crate-free routes s → e of length ≤ the * Fewest non-blockable tiles (start and goal excluded) over all 4-connected crate-free routes s → e of length ≤ the
* shortest + `slack`; { best, min } (min = the minimum over those lengths). * shortest + `slack` that cross at most `maxWet` 深水区 tiles (grid.js: the tie-break never walks into more 深水区 than
* the official route — GitHub #375); { best, min } (min = the minimum over those lengths).
*/ */
function fewestNb(g, s, e, slack = 2) { function fewestNb(g, s, e, slack = 2, maxWet = Infinity) {
const f = g.flowField(e[0], e[1]); const f = g.flowField(e[0], e[1]);
const best = f.dist[s[0] * COLS + s[1]]; const best = f.dist[s[0] * COLS + s[1]];
if (best < 0 || best >= 1000) return null; if (best < 0 || best >= 1000) return null;
const ek = e[0] * COLS + e[1]; const ek = e[0] * COLS + e[1];
const cost = (k) => (k === ek ? 0 : g.unblockable[k]); const cost = (k) => (k === ek ? 0 : g.unblockable[k]);
let cur = new Map([[s[0] * COLS + s[1], 0]]); const wetOf = Number.isFinite(maxWet) ? (k) => (k === ek ? 0 : g.deepWater[k]) : () => 0;
// tile → (深水区 so far → fewest non-blockable so far)
let cur = new Map([[s[0] * COLS + s[1], new Map([[0, 0]])]]);
let min = Infinity; let min = Infinity;
for (let L = 0; L <= best + slack; L++) { for (let L = 0; L <= best + slack; L++) {
if (cur.has(ek)) min = Math.min(min, cur.get(ek)); if (cur.has(ek)) for (const p of cur.get(ek).values()) min = Math.min(min, p);
const nx = new Map(); const nx = new Map();
for (const [k, p] of cur) { for (const [k, byWet] of cur) {
if (k === ek) continue; if (k === ek) continue;
const r = (k / COLS) | 0, c = k % COLS; const r = (k / COLS) | 0, c = k % COLS;
for (const [dr, dc] of [[1, 0], [0, 1], [-1, 0], [0, -1]]) { for (const [dr, dc] of [[1, 0], [0, 1], [-1, 0], [0, -1]]) {
const nr = r + dr, nc = c + dc; const nr = r + dr, nc = c + dc;
if (!g.walkable(nr, nc) || g.isCrate(nr, nc)) continue; if (!g.walkable(nr, nc) || g.isCrate(nr, nc)) continue;
const nk = nr * COLS + nc, np = p + cost(nk); const nk = nr * COLS + nc;
if (!nx.has(nk) || nx.get(nk) > np) nx.set(nk, np); let m = nx.get(nk);
for (const [w, p] of byWet) {
const nw = w + wetOf(nk), np = p + cost(nk);
if (nw > maxWet) continue;
if (!m) nx.set(nk, m = new Map());
if (!m.has(nw) || m.get(nw) > np) m.set(nw, np);
}
} }
} }
cur = nx; cur = nx;
@@ -97,6 +107,14 @@ function fewestNb(g, s, e, slack = 2) {
return { best, min }; return { best, min };
} }
/** 深水区 tiles crossed from s to e (start and goal excluded). */
function wetCrossed(g, s, e) {
const wp = g.waypoints(s[0], s[1], e[0], e[1]);
const seen = new Set();
for (const [r, c] of crossed(wp)) if (!(r === s[0] && c === s[1]) && !(r === e[0] && c === e[1]) && g.deepWater[r * COLS + c]) seen.add(`${r},${c}`);
return seen.size;
}
/** Tiles an enemy of `route` visits (rounded position per tick) and whether `u` blocked it. */ /** Tiles an enemy of `route` visits (rounded position per tick) and whether `u` blocked it. */
function walk(h) { function walk(h) {
const seen = []; const seen = [];
@@ -293,12 +311,15 @@ test('audit: every WALK leg of every wave template (normal / 联防 / boss / hid
if (!grids.has(key)) grids.set(key, stageGrid(sid, rect)); if (!grids.has(key)) grids.set(key, stageGrid(sid, rect));
const g = grids.get(key); const g = grids.get(key);
if (!g.walkable(from[0], from[1]) || !g.walkable(to[0], to[1])) continue; if (!g.walkable(from[0], from[1]) || !g.walkable(to[0], to[1])) continue;
const eq = fewestNb(g, from, to, 0); if (!fewestNb(g, from, to, 0)) continue;
if (!eq) continue;
const got = nbCrossed(g, from, to); const got = nbCrossed(g, from, to);
// compared with the routes that wade through no more 深水区 than ours (#375: 战场#08(下半)'s patrol legs to (2,3) /
// (2,17) keep the official upper road, not the lower one through two water tiles that crosses one floor tile less)
const wet = wetCrossed(g, from, to);
const eq = fewestNb(g, from, to, 0, wet);
const what = `${sid} ${id} (${from}) → (${to}) crosses ${got.join(' ')}`; const what = `${sid} ${id} (${from}) → (${to}) crosses ${got.join(' ')}`;
assert.ok(got.length <= eq.min, `${what}, an equal-length route only ${eq.min}`); assert.ok(got.length <= eq.min, `${what}, an equal-length route only ${eq.min}`);
const near = fewestNb(g, from, to, 2); const near = fewestNb(g, from, to, 2, wet);
if (near.min < got.length) { if (near.min < got.length) {
assert.ok(rect === GEO.BOSS_RECT && got.every(arena), `${what}: a route of length ≤ ${near.best}+2 crosses only ${near.min}`); assert.ok(rect === GEO.BOSS_RECT && got.every(arena), `${what}: a route of length ≤ ${near.best}+2 crosses only ${near.min}`);
accepted.push(what); accepted.push(what);
+11 -3
View File
@@ -5,8 +5,9 @@
// preference field = 0.1.0's — equal-length ties to the chain with the fewest non-blockable tiles, a line of sight that // preference field = 0.1.0's — equal-length ties to the chain with the fewest non-blockable tiles, a line of sight that
// covers non-blockable tiles, diagonal-step corners included, only on the own raw chain — whose pointer replaces the // covers non-blockable tiles, diagonal-step corners included, only on the own raw chain — whose pointer replaces the
// official one where its route crosses fewer non-blockable tiles, or as few while only skipping the official waypoint // official one where its route crosses fewer non-blockable tiles, or as few while only skipping the official waypoint
// (on the straight line to it, leading there); "crosses" = positive-length intersection with the tile, found here by // (on the straight line to it, leading there), and no more 深水区 than the official route (GitHub #375); "crosses" =
// clipping the segment against each tile, independently of grid.js crossTiles): // positive-length intersection with the tile, found here by clipping the segment against each tile, independently of
// grid.js crossTiles):
// * the 8 active stages with their match-start devices: the smoothed chain from EVERY walkable tile of the normal, // * the 8 active stages with their match-start devices: the smoothed chain from EVERY walkable tile of the normal,
// 联防 and boss rects to every goal equals the full-map reference one (the sim's rect limit changes nothing); // 联防 and boss rects to every goal equals the full-map reference one (the sim's rect limit changes nothing);
// * 400 random crate / block layouts: identical chains to the same algorithm limited to the rect; // * 400 random crate / block layouts: identical chains to the same algorithm limited to the rect;
@@ -56,6 +57,8 @@ function official(sid, { crates: extraCrates = [], blocks: extraBlocks = [], rec
if (t.tileKey === 'tile_deepsea') return true; if (t.tileKey === 'tile_deepsea') return true;
return !(t.heightType === 'LOWLAND' && (t.buildableType === 'ALL' || t.buildableType === 'MELEE')); return !(t.heightType === 'LOWLAND' && (t.buildableType === 'ALL' || t.buildableType === 'MELEE'));
}; };
/** 深水区 — the preference may not cross more of it than the official route (#375) */
const deep = (r, c) => ['tile_deepsea', 'tile_deepwater'].includes(tile(r, c).tileKey);
const bres = ([r0, c0], [r1, c1], clear) => { const bres = ([r0, c0], [r1, c1], clear) => {
const dr = Math.abs(r1 - r0), dc = Math.abs(c1 - c0), sr = r1 > r0 ? 1 : -1, sc = c1 > c0 ? 1 : -1; const dr = Math.abs(r1 - r0), dc = Math.abs(c1 - c0), sr = r1 > r0 ? 1 : -1, sc = c1 > c0 ? 1 : -1;
let err = dc - dr, r = r0, c = c0; let err = dc - dr, r = r0, c = c0;
@@ -140,12 +143,15 @@ function official(sid, { crates: extraCrates = [], blocks: extraBlocks = [], rec
const off = spfa(false); const off = spfa(false);
const dist = off.dist; const dist = off.dist;
const segNb = (a, b) => through(a, b).filter(([y, x]) => !(y === a[0] && x === a[1]) && nb(y, x)).length; const segNb = (a, b) => through(a, b).filter(([y, x]) => !(y === a[0] && x === a[1]) && nb(y, x)).length;
const segWet = (a, b) => through(a, b).filter(([y, x]) => !(y === a[0] && x === a[1]) && deep(y, x)).length;
let nxt = smooth(off, false); let nxt = smooth(off, false);
if (prefer) { if (prefer) {
// per tile in increasing distance: the preference pointer when its route crosses fewer non-blockable tiles (or as // per tile in increasing distance: the preference pointer when its route crosses fewer non-blockable tiles (or as
// few while it only skips the official waypoint: o strictly inside the segment a → p, and o's chosen pointer is p) // few while it only skips the official waypoint: o strictly inside the segment a → p, and o's chosen pointer is p)
// and no more 深水区 than the official pointer's route
const nxtP = smooth(spfa(true), true); const nxtP = smooth(spfa(true), true);
const cost = new Map([[D, 0]]); const cost = new Map([[D, 0]]);
const wet = new Map([[D, 0]]);
const chosen = new Map(); const chosen = new Map();
const skips = (a, o, p) => { const skips = (a, o, p) => {
const t = through(a, p); const t = through(a, p);
@@ -155,9 +161,11 @@ function official(sid, { crates: extraCrates = [], blocks: extraBlocks = [], rec
if (k === D) continue; if (k === D) continue;
const a = unkey(k), o = nxt.get(k), p = nxtP.get(k); const a = unkey(k), o = nxt.get(k), p = nxtP.get(k);
const co = segNb(a, o) + cost.get(key(o)), cp = segNb(a, p) + cost.get(key(p)); const co = segNb(a, o) + cost.get(key(o)), cp = segNb(a, p) + cost.get(key(p));
const useP = cp < co || (cp === co && key(o) !== key(p) && key(chosen.get(key(o)) ?? []) === key(p) && skips(a, o, p)); const wo = segWet(a, o) + wet.get(key(o)), wp = segWet(a, p) + wet.get(key(p));
const useP = wp <= wo && (cp < co || (cp === co && key(o) !== key(p) && key(chosen.get(key(o)) ?? []) === key(p) && skips(a, o, p)));
chosen.set(k, useP ? p : o); chosen.set(k, useP ? p : o);
cost.set(k, useP ? cp : co); cost.set(k, useP ? cp : co);
wet.set(k, useP ? wp : wo);
} }
nxt = chosen; nxt = chosen;
} }
+39 -1
View File
@@ -1,7 +1,8 @@
// Official route shapes (community report after 0.1.0, D5: on 战场#04 活性源石 the lower-gate enemies walked two tiles // Official route shapes (community report after 0.1.0, D5: on 战场#04 活性源石 the lower-gate enemies walked two tiles
// straight left of the red gate and then straight up, instead of crossing diagonally from the 4th row to the 3rd as in // straight left of the red gate and then straight up, instead of crossing diagonally from the 4th row to the 3rd as in
// the official game). grid.js keeps the official flow field (research 08 §3.1) unless the blockable-ground preference // the official game). grid.js keeps the official flow field (research 08 §3.1) unless the blockable-ground preference
// route of user playtest #2 (0.1.0's) crosses strictly fewer non-blockable tiles (floor, gates) — a segment that only // route of user playtest #2 (0.1.0's) crosses strictly fewer non-blockable tiles (floor, gates) and no more 深水区
// (GitHub #375) — a segment that only
// brushes a floor tile's corner does not cross it. Audit: every stage (active or not) × gate × field against the pure // brushes a floor tile's corner does not cross it. Audit: every stage (active or not) × gate × field against the pure
// official algorithm; the routes that still differ are listed, each avoiding floor the official route walks over. // official algorithm; the routes that still differ are listed, each avoiding floor the official route walks over.
import { test } from 'node:test'; import { test } from 'node:test';
@@ -127,6 +128,43 @@ test('audit: every route of every stage (11, active or not) × gate × field is
assert.equal(official, stages.length * legs.length - seen.size); assert.equal(official, stages.length * legs.length - seen.size);
}); });
// GitHub #375 (FrogThai: 「水图boss会从下面这条路往蓝门走,实际应该从上路走」): on 战场#08(下半) 涨潮控制 the patrolling leaders
// (铳 — level_act1autochess_h07_02 route 6 —, 卢西恩 — act2autochess_h07_05 route 8: PATROL_MOVE (3,9) → (2,3) → (5,9))
// took the lower road (3,9) → (2,3) through the two 深水区 tiles (3,6) / (4,6) — one floor tile fewer than the official
// upper road along row 5, which crosses one water tile. The tie-break may not wade into more 深水区 than the official
// route (grid.js), so these legs are official again.
test('#375 战场#08(下半): the (3,9) → (2,3) patrol leg stays on the official upper road', REAL, () => {
const sid = 'act2autochess_m04';
const g = stageGrid(sid, GEO.BOSS_RECT), pure = stageGrid(sid, GEO.BOSS_RECT, true);
for (const [s, e, wp] of [
[[3, 9], [2, 3], '3,9 5,9 5,4 2,4 2,3'],
[[3, 11], [2, 17], '3,11 5,11 5,16 2,16 2,17'],
[[3, 10], [2, 3], '3,10 5,9 5,4 2,4 2,3'],
[[4, 10], [3, 9], '4,10 3,9'],
]) {
assert.deepEqual(g.waypoints(s[0], s[1], e[0], e[1]), pure.waypoints(s[0], s[1], e[0], e[1]), `(${s}) → (${e})`);
assert.equal(str(g.waypoints(s[0], s[1], e[0], e[1])), wp);
}
});
test('#375 real battle (act2 m04, 铳 on act1autochess_h07_02): the patrol walks row 5 to (2,3) and steps on one 深水区 only', REAL, () => {
const h = makeBattle({ kind: 'boss', stageId: 'act2autochess_m04', waveTemplate: 'act1autochess_h07_02', units: [], autoFinish: false, timeLimit: 120,
sharedBoss: { hp: 1e12, maxHp: 1e12, damage(pid, n) { this.hp -= n; } } });
const rows = getDefaultSource().getStage('act2autochess_m04').raw.rows;
const tiles = [];
h.b.on('tick', () => {
const e = h.b.enemies.find((u) => u.alive && u.defId === 'enemy_9017_achunt');
if (!e) return;
const k = `${Math.round(e.y)},${Math.round(e.x)}`;
if (tiles[tiles.length - 1] !== k) tiles.push(k);
});
assert.ok(h.runUntil(() => tiles.includes('2,4'), 60), `reaches (2,4): ${tiles.join(' ')}`);
const upTo = tiles.slice(0, tiles.indexOf('2,4') + 1);
assert.deepEqual(upTo.slice(0, 4), ['4,10', '3,9', '4,9', '5,9'], `up to row 5 first: ${upTo.join(' ')}`);
const water = upTo.filter((k) => { const [r, c] = k.split(',').map(Number); return rows[r][c] === 'd'; });
assert.deepEqual(water, ['5,6'], `深水区 stepped on: ${water.join(' ')} (route ${upTo.join(' ')})`);
});
test('D5: an operator on (9,9), (9,8) or (10,8) blocks the lower-gate enemy on the official diagonal', REAL, () => { test('D5: an operator on (9,9), (9,8) or (10,8) blocks the lower-gate enemy on the official diagonal', REAL, () => {
const guard = chessRec({ id: 't_guard', profession: 'WARRIOR', stats: { atk: 0, blockCnt: 3, maxHp: 1e6 }, skill: null }); const guard = chessRec({ id: 't_guard', profession: 'WARRIOR', stats: { atk: 0, blockCnt: 3, maxHp: 1e6 }, skill: null });
const walker = enemyRec({ key: 'enemy_walker', hp: 1e6, speed: 1 }); const walker = enemyRec({ key: 'enemy_walker', hp: 1e6, speed: 1 });