feedback1d WO: official route wherever it crosses no more floor (D5 战场#04 diagonal)

Community report: on 战场#04 活性源石 the lower-gate enemies walked two tiles left
of the red gate and then straight up instead of crossing diagonally from row 9
into row 10 as in the official game. The playtest #2 road-over-floor smoothing
counted the corner tiles of a diagonal step as crossed, so it bent the official
(9,10) -> (10,7) into (9,10) -> (9,8) -> (10,8) although that line only brushes
the corner of the floor tile (10,9).

grid.js now keeps the official flow field (research 08 §3.1) and replaces a
tile's pointer by the road-over-floor preference one only where its route
crosses strictly fewer non-blockable tiles (or as few with a farther waypoint);
a segment crosses the tiles whose interior it passes through (segmentTiles,
exact geometry). 140 of the 154 stage x gate x field routes are now official
(was 109); the 14 left avoid floor the official route walks over (战场#01's
col-8 road of playtest #2, boss exits of 战场#01 / #02, two 联防 partner gates).
data/stages.json groundPaths regenerated.
This commit is contained in:
sganggs committed 2026-10-03 04:14:43 +08:00
1 parent 7f134737ce
commit d8c62a96b5
9 files changed
+460 -153

No files matched your search

+1 -1
View File
@@ -1 +1 @@
{"act1autochess_m01":{"id":"act1autochess_m01","name":"战场#01","weight":50,"active":true,"modes":["mode_training_1","mode_single_normal","mode_single_funny","mode_multi_normal","mode_multi_funny"],"size":[19,21],"rows":["aaaaaaaaaa#aaaaaaaaaa","###IAAAAA###AAAAAI###","##Err###rrOrr###rrE##","###brbbbrfffrbbbrb###","###brbbbrfffrbbbrb###","###brrrrrfOfrrrrrb###","XXXXXXXXXXXXXXXXXXXXX","aaaaaaaaaa###########","####AAAAA##I#########","##Err###rrSrr###rrS##","###brbbbrf#brbbbrf###","###brbbbrf#brbbbrf###","###brrrrrfSbrrrrrfS##","XXXXXXXXXXXXXXXXXXXXX","#######fffffff#######","#######Sffffff#######","#######ppppppp#######","#######fffffff#######","#######Sffffff#######"],"tiles":{"a":{"tileKey":"tile_achand","height":"HIGH","buildable":"ALL","passable":"FLY_ONLY","groundPassable":false,"flyPassable":true,"special":null,"bb":{"isValidHand":1}},"#":{"tileKey":"tile_forbidden","height":"HIGH","buildable":"NONE","passable":"FLY_ONLY","groundPassable":false,"flyPassable":true,"special":null,"bb":{}},"I":{"tileKey":"tile_telin","height":"LOW","buildable":"NONE","passable":"ALL","groundPassable":true,"flyPassable":true,"special":null,"bb":{}},"A":{"tileKey":"tile_achand","height":"HIGH","buildable":"ALL","passable":"FLY_ONLY","groundPassable":false,"flyPassable":true,"special":null,"bb":{}},"E":{"tileKey":"tile_end","height":"LOW","buildable":"NONE","passable":"ALL","groundPassable":true,"flyPassable":true,"special":null,"bb":{}},"r":{"tileKey":"tile_road","height":"LOW","buildable":"ALL","passable":"ALL","groundPassable":true,"flyPassable":true,"special":null,"bb":{}},"O":{"tileKey":"tile_telout","height":"LOW","buildable":"NONE","passable":"ALL","groundPassable":true,"flyPassable":true,"special":null,"bb":{}},"b":{"tileKey":"tile_fence_bound","height":"LOW","buildable":"ALL","passable":"FLY_ONLY","groundPassable":false,"flyPassable":true,"special":null,"bb":{}},"f":{"tileKey":"tile_floor","height":"LOW","buildable":"NONE","passable":"ALL","groundPassable":true,"flyPassable":true,"special":null,"bb":{}},"X":{"tileKey":"tile_forbidden","height":"HIGH","buildable":"NONE","passable":"NONE","groundPassable":false,"flyPassable":false,"special":null,"bb":{}},"S":{"tileKey":"tile_start","height":"LOW","buildable":"NONE","passable":"ALL","groundPassable":true,"flyPassable":true,"special":null,"bb":{}},"p":{"tileKey":"tile_floor","height":"LOW","buildable":"NONE","passable":"ALL","groundPassable":true,"flyPassable":true,"special":null,"bb":{"previewNotAlloed":1}}},"devices":[{"key":"trap_1112_acblzd","name":"盟约寒风","alias":null,"pos":[18,0],"dir":"UP","hidden":false,"role":"coldWind","stats":{"maxHp":100,"atk":0,"def":0,"res":0,"bat":1,"aspd":100,"blockCnt":0},"rangeGrid":null,"rangeTiles":null,"skill":{"skillId":"sktok_acblzd","name":"谢拉格寒风","level":1,"desc":"每隔一定时间对全场敌人造成一定时间的寒冷","descRaw":"每隔一定时间对全场敌人造成一定时间的<$ba.cold>寒冷</>","bb":{},"bbStr":{}},"desc":null,"active":true},{"key":"trap_1104_aclasert","name":"“双眼皮”","alias":"trap_1104_aclasert#1","pos":[9,7],"dir":"UP","hidden":true,"role":"turret","stats":{"maxHp":3000,"atk":900,"def":200,"res":0,"bat":4,"aspd":100,"blockCnt":0},"rangeGrid":[[1,0],[1,1],[1,2],[1,3],[1,4],[0,0],[0,1],[0,2],[0,3],[0,4],[-1,0],[-1,1],[-1,2],[-1,3],[-1,4]],"rangeTiles":[[9,6],[10,6],[11,6],[12,6],[13,6],[9,7],[10,7],[11,7],[12,7],[13,7],[9,8],[10,8],[11,8],[12,8],[13,8]],"skill":{"skillId":"sktok_aclasert","name":"“双眼皮”","level":1,"desc":"根据当前层数最高的盟约获得强化:每层盟约层数使自身攻击速度+1,攻击额外附带造成0.1%脆弱,持续2秒(至多300层)","descRaw":"根据当前层数最高的盟约获得强化:每层盟约层数使自身攻击速度<@ba.vup>+1</>,攻击额外附带造成<@ba.vup>0.1%</><$ba.fragile>脆弱</>,持续2秒(至多300层)","bb":{"attack_speed_per_stack":1,"damage_scale_per_stack":0.001,"max_attack_speed":300,"max_damage_scale":1.3},"bbStr":{}},"desc":null,"active":false},{"key":"trap_1104_aclasert","name":"“双眼皮”","alias":"trap_1104_aclasert#2","pos":[9,15],"dir":"UP","hidden":true,"role":"turret","stats":{"maxHp":3000,"atk":900,"def":200,"res":0,"bat":4,"aspd":100,"blockCnt":0},"rangeGrid":[[1,0],[1,1],[1,2],[1,3],[1,4],[0,0],[0,1],[0,2],[0,3],[0,4],[-1,0],[-1,1],[-1,2],[-1,3],[-1,4]],"rangeTiles":[[9,14],[10,14],[11,14],[12,14],[13,14],[9,15],[10,15],[11,15],[12,15],[13,15],[9,16],[10,16],[11,16],[12,16],[13,16]],"skill":{"skillId":"sktok_aclasert","name":"“双眼皮”","level":1,"desc":"根据当前层数最高的盟约获得强化:每层盟约层数使自身攻击速度+1,攻击额外附带造成0.1%脆弱,持续2秒(至多300层)","descRaw":"根据当前层数最高的盟约获得强化:每层盟约层数使自身攻击速度<@ba.vup>+1</>,攻击额外附带造成<@ba.vup>0.1%</><$ba.fragile>脆弱</>,持续2秒(至多300层)","bb":{"attack_speed_per_stack":1,"daLine truncated
{"act1autochess_m01":{"id":"act1autochess_m01","name":"战场#01","weight":50,"active":true,"modes":["mode_training_1","mode_single_normal","mode_single_funny","mode_multi_normal","mode_multi_funny"],"size":[19,21],"rows":["aaaaaaaaaa#aaaaaaaaaa","###IAAAAA###AAAAAI###","##Err###rrOrr###rrE##","###brbbbrfffrbbbrb###","###brbbbrfffrbbbrb###","###brrrrrfOfrrrrrb###","XXXXXXXXXXXXXXXXXXXXX","aaaaaaaaaa###########","####AAAAA##I#########","##Err###rrSrr###rrS##","###brbbbrf#brbbbrf###","###brbbbrf#brbbbrf###","###brrrrrfSbrrrrrfS##","XXXXXXXXXXXXXXXXXXXXX","#######fffffff#######","#######Sffffff#######","#######ppppppp#######","#######fffffff#######","#######Sffffff#######"],"tiles":{"a":{"tileKey":"tile_achand","height":"HIGH","buildable":"ALL","passable":"FLY_ONLY","groundPassable":false,"flyPassable":true,"special":null,"bb":{"isValidHand":1}},"#":{"tileKey":"tile_forbidden","height":"HIGH","buildable":"NONE","passable":"FLY_ONLY","groundPassable":false,"flyPassable":true,"special":null,"bb":{}},"I":{"tileKey":"tile_telin","height":"LOW","buildable":"NONE","passable":"ALL","groundPassable":true,"flyPassable":true,"special":null,"bb":{}},"A":{"tileKey":"tile_achand","height":"HIGH","buildable":"ALL","passable":"FLY_ONLY","groundPassable":false,"flyPassable":true,"special":null,"bb":{}},"E":{"tileKey":"tile_end","height":"LOW","buildable":"NONE","passable":"ALL","groundPassable":true,"flyPassable":true,"special":null,"bb":{}},"r":{"tileKey":"tile_road","height":"LOW","buildable":"ALL","passable":"ALL","groundPassable":true,"flyPassable":true,"special":null,"bb":{}},"O":{"tileKey":"tile_telout","height":"LOW","buildable":"NONE","passable":"ALL","groundPassable":true,"flyPassable":true,"special":null,"bb":{}},"b":{"tileKey":"tile_fence_bound","height":"LOW","buildable":"ALL","passable":"FLY_ONLY","groundPassable":false,"flyPassable":true,"special":null,"bb":{}},"f":{"tileKey":"tile_floor","height":"LOW","buildable":"NONE","passable":"ALL","groundPassable":true,"flyPassable":true,"special":null,"bb":{}},"X":{"tileKey":"tile_forbidden","height":"HIGH","buildable":"NONE","passable":"NONE","groundPassable":false,"flyPassable":false,"special":null,"bb":{}},"S":{"tileKey":"tile_start","height":"LOW","buildable":"NONE","passable":"ALL","groundPassable":true,"flyPassable":true,"special":null,"bb":{}},"p":{"tileKey":"tile_floor","height":"LOW","buildable":"NONE","passable":"ALL","groundPassable":true,"flyPassable":true,"special":null,"bb":{"previewNotAlloed":1}}},"devices":[{"key":"trap_1112_acblzd","name":"盟约寒风","alias":null,"pos":[18,0],"dir":"UP","hidden":false,"role":"coldWind","stats":{"maxHp":100,"atk":0,"def":0,"res":0,"bat":1,"aspd":100,"blockCnt":0},"rangeGrid":null,"rangeTiles":null,"skill":{"skillId":"sktok_acblzd","name":"谢拉格寒风","level":1,"desc":"每隔一定时间对全场敌人造成一定时间的寒冷","descRaw":"每隔一定时间对全场敌人造成一定时间的<$ba.cold>寒冷</>","bb":{},"bbStr":{}},"desc":null,"active":true},{"key":"trap_1104_aclasert","name":"“双眼皮”","alias":"trap_1104_aclasert#1","pos":[9,7],"dir":"UP","hidden":true,"role":"turret","stats":{"maxHp":3000,"atk":900,"def":200,"res":0,"bat":4,"aspd":100,"blockCnt":0},"rangeGrid":[[1,0],[1,1],[1,2],[1,3],[1,4],[0,0],[0,1],[0,2],[0,3],[0,4],[-1,0],[-1,1],[-1,2],[-1,3],[-1,4]],"rangeTiles":[[9,6],[10,6],[11,6],[12,6],[13,6],[9,7],[10,7],[11,7],[12,7],[13,7],[9,8],[10,8],[11,8],[12,8],[13,8]],"skill":{"skillId":"sktok_aclasert","name":"“双眼皮”","level":1,"desc":"根据当前层数最高的盟约获得强化:每层盟约层数使自身攻击速度+1,攻击额外附带造成0.1%脆弱,持续2秒(至多300层)","descRaw":"根据当前层数最高的盟约获得强化:每层盟约层数使自身攻击速度<@ba.vup>+1</>,攻击额外附带造成<@ba.vup>0.1%</><$ba.fragile>脆弱</>,持续2秒(至多300层)","bb":{"attack_speed_per_stack":1,"damage_scale_per_stack":0.001,"max_attack_speed":300,"max_damage_scale":1.3},"bbStr":{}},"desc":null,"active":false},{"key":"trap_1104_aclasert","name":"“双眼皮”","alias":"trap_1104_aclasert#2","pos":[9,15],"dir":"UP","hidden":true,"role":"turret","stats":{"maxHp":3000,"atk":900,"def":200,"res":0,"bat":4,"aspd":100,"blockCnt":0},"rangeGrid":[[1,0],[1,1],[1,2],[1,3],[1,4],[0,0],[0,1],[0,2],[0,3],[0,4],[-1,0],[-1,1],[-1,2],[-1,3],[-1,4]],"rangeTiles":[[9,14],[10,14],[11,14],[12,14],[13,14],[9,15],[10,15],[11,15],[12,15],[13,15],[9,16],[10,16],[11,16],[12,16],[13,16]],"skill":{"skillId":"sktok_aclasert","name":"“双眼皮”","level":1,"desc":"根据当前层数最高的盟约获得强化:每层盟约层数使自身攻击速度+1,攻击额外附带造成0.1%脆弱,持续2秒(至多300层)","descRaw":"根据当前层数最高的盟约获得强化:每层盟约层数使自身攻击速度<@ba.vup>+1</>,攻击额外附带造成<@ba.vup>0.1%</><$ba.fragile>脆弱</>,持续2秒(至多300层)","bb":{"attack_speed_per_stack":1,"daLine truncated
+1 -1
View File
@@ -409,7 +409,7 @@ exactly (the builder warns if a multi-fragment template ever appears).
| `special` | `{"mire":{"intervalSec":3,"aspdPerStack":-0.05,"moveMulPerStack":-0.05,"maxStacks":10,…}}`, `{"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) |
| `deployTiles` | `{"normal":{"melee":[[9,3],…],"rangedOnly":[[10,4],…],"changedByDevices":[…]},"bossLeft":{…},"bossRight":{…}}` | deployable tiles at match start: `melee` = LOW buildable ALL/MELEE (ranged may use them too), `rangedOnly` = high ground / active platforms; tiles under active crates/mounds 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 — plus the remake's road-over-floor preference: among equal-length routes the fewest non-blockable floor / gate tiles, and smoothing never cuts across floor the grid route does not walk, e.g. 战场#01's lower gate takes the col-8 road, not the col-9 floor lane; DESIGN §14 corrections) 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 road-over-floor preference route crosses 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 not counting 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…}` | |
Glyph legend (`rows`):
+8 -3
View File
@@ -149,9 +149,14 @@ ends by spawning a new 初始模式 copy on its level branch route (`left_hand_o
HP, then `kill(old, null)` — uncounted, no bounty; the client sees a `die` and a `spawn`.
WALK legs pathfind on the stage grid inside the rect with the official flow field (grid.js: 4-direction SPFA from the
destination, crates cost 1000, then Bresenham line-of-sight smoothing — research 08 §3.4). Among equal-length routes the
one crossing the fewest non-blockable tiles (floor / gate lanes) wins, and a smoothed segment never cuts across floor
the grid route does not walk (user playtest #2 item 2; test/sim/pathing-blockable.test.js). Enemies re-path whenever
destination, crates cost 1000, then Bresenham line-of-sight smoothing — research 08 §3.4). The official route stays
unless the road-over-floor preference route (the fewest non-blockable tiles among equal-length chains, smoothing that
never cuts across floor its grid route does not walk; user playtest #2 item 2) crosses fewer non-blockable tiles
(floor / gate lanes) — "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 diagonally from row 9 into row 10 as
officially). test/sim/pathing-official.test.js lists the 14 of 154 stage routes that still differ from the official
ones (战场#01's col-8 road, the boss exits of 战场#01 / #02 …); test/sim/pathing-blockable.test.js audits the floor
crossed. Enemies re-path whenever
an obstacle changes (`grid.version`), after a displacement and when 诱导 / 恐惧 ends (the 恐惧 and 诱导 walks likewise
re-plan from where the enemy stands after an obstacle change or when something else moved it — a push, or for 诱导 an
outranking 恐惧); the smoothed chain is only line-of-sight
+15 -9
View File
@@ -347,15 +347,21 @@ Example B. Types DOT / TIMES / ELEMENT.
The raw BFS chains and smoothed waypoints are in scratch `lanes_official.txt`.
**Remake deviation (user playtest #2, 2026-09-29).** The user reported the 战场#01 lower-gate enemies walking up the
col-9 floor lane, where no operator can block them. `server/sim/grid.js` keeps every official route LENGTH but breaks
ties between equal-length routes towards the fewest non-blockable (floor / gate) tiles, and its smoothing never cuts
across floor the grid route does not walk. Lanes that change against the table above:
- act1 m01 #01, lower gate: (9,10) → (9,8) → **col 8 road up** → (12,8) → row 12 → col 4 → E (was col 9 floor).
- act1 m04 #04, lower gate: (9,10) → (9,8) → (10,8) → **row 10** → (10,4) → (9,4) → E (no longer cuts across (10,9)).
- Boss fields: the exits from (2,10) take the road on 战场#01 / #04 and the right half of 战场#05, and cross less floor on
战场#02 / #07. Floor the terrain forces stays (gates, (12,9) beside the upper gate, the col-9 floor where walls or a
crate block the road: #02 lower (10,9)/(11,9), #04 upper (11,9), #05 upper (11,9)/(10,9), #07 lower (10,9)).
**Remake deviation (user playtest #2, 2026-09-29; narrowed after the community report D5, 2026-10-03).** The user
reported the 战场#01 lower-gate enemies walking up the col-9 floor lane, where no operator can block them.
`server/sim/grid.js` keeps every official route LENGTH and the official route itself unless a 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) crosses fewer non-blockable tiles; a segment that only touches a floor
tile's corner does not cross it. The first version (2026-09-29) counted those corner tiles as crossed and so 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) → (10,7), which only brushes the corner of the floor (10,9). Lanes that
still differ from the table above (14 of the 154 stage × gate × field routes, test/sim/pathing-official.test.js):
- act1 m01 #01, lower gate: (9,10) → (9,8) → **col 8 road up** → (12,8) → row 12 → col 4 → E (official: (9,9) → (12,8)
through the floor (10,9)); the 联防 partner routes pass the same exit.
- Boss fields: the exits from (2,10) on 战场#01 / #02 avoid part of the arena's central floor; 战场#02's 联防 partner
lower gate avoids (12,17); the inactive 战场#07 (上半) 联防 partner upper gate takes row 9 instead of row 12's floor.
Floor the terrain forces stays (gates, (12,9) beside the upper gate, the col-9 floor where walls or a crate block the
road: #02 lower (10,9)/(11,9), #04 upper (11,9), #05 upper (11,9)/(10,9), #07 lower (10,9)).
Full audit: test/sim/pathing-blockable.test.js.
### 3.3 What we do and the root cause of "upper-gate enemies walk the lower lane"
+131 -54
View File
@@ -14,19 +14,23 @@
// tile on the line walkable and not a crate; a diagonal step needs both orthogonal neighbours clear). A ground enemy
// walks straight toward `next[tile]` of its current tile (ai.js). Fields are cached per (destination, grid version).
//
// Blockable-ground preference (user playtest: "the lower-gate enemies of 战场#01 walk up the col-9 floor lane where no
// operator can block them"). Two refinements on top of the official algorithm; `dist` (so the crate cost 1000 and
// every grid route length) stays exactly the official one:
// * tie-break: the SPFA relaxes on (dist, pen) lexicographically, `pen` = number of NON-BLOCKABLE walkable tiles
// (floor / gate / goal / teleport tiles — `blockable()` false: not LOW ground buildable for melee) on the chain.
// Among equal-length chains the one with the fewest non-blockable tiles wins; remaining ties go to the first
// parent in SPFA order (the official order unless a pen improvement re-queued a tile).
// * smoothing: a line of sight may only cross a non-blockable tile (including the corner tiles of a diagonal step)
// that lies on the tile's own raw chain between its two ends — a smoothed segment never cuts across floor the grid
// route does not walk, so an enemy never slips past an operator standing on the road next to the floor lane.
// This one also bends routes whose raw chain is the official one (a diagonal that would clip off-chain floor
// becomes an L, mostly inside the boss arena's central floor): the smoothed polyline can be up to 2 tiles
// longer than the official one (test/sim/pathing-blockable.test.js bounds it).
// Blockable-ground preference (user playtest #2: "the lower-gate enemies of 战场#01 walk up the col-9 floor lane where
// no operator can block them"), kept to where the official route really walks over more floor (community report after
// 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
// ground buildable for melee (floor / gate / goal / teleport — `blockable()` false). A segment CROSSES the tiles whose
// interior it passes through (`crossTiles`, exact geometry); one it only touches at a corner point is not crossed.
// 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) and the official line-of-sight walk:
// * official: the plain algorithm above;
// * preference: the SPFA relaxes on (dist, pen) lexicographically, `pen` = non-blockable tiles on the chain (among
// equal-length chains the fewest wins; remaining ties go to the first parent in SPFA order), and a smoothed
// segment that would cross a non-blockable tile off the tile's own raw chain backs off to the farthest earlier
// waypoint whose segment does not — it never cuts across floor its grid route does not walk.
// `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), or as few with a farther
// waypoint. So the route from every tile crosses no more floor than either candidate and is the official one wherever
// that is not floor-heavier (test/sim/pathing-official.test.js lists the routes that still differ on every stage).
// Unavoidable non-blockable tiles (gates, goals, teleports and single-exit floor, e.g. (12,9) next to the upper
// gate) stay on the route; test/sim/pathing-blockable.test.js lists them per stage, gate and field.
//
@@ -236,18 +240,59 @@ export class Grid {
_buildField(er, ec, allowDiagonal, ignore) {
const N = ROWS * COLS;
const dist = new Int32Array(N).fill(-1);
const parent = new Int32Array(N).fill(-1);
/** non-blockable tiles on the raw chain (tile included, destination excluded) — the equal-length tie-break */
const pen = new Int32Array(N);
const f = { dest: -1, dist, parent, pen, next: parent, len: null, version: this.version, allowDiagonal, ignore };
if (!this.inBounds(er, ec)) return f;
const f = { dest: -1, dist: null, parent: null, pen: null, next: null, official: null, cost: null, len: null, version: this.version, allowDiagonal, ignore };
if (!this.inBounds(er, ec)) {
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) });
}
const dest = er * COLS + ec;
f.dest = dest;
const inQ = new Uint8Array(N);
const unb = this.unblockable;
const crate = (k) => !ignore && (this.obstacle[k] & OB_CRATE) !== 0;
// SPFA (FIFO), seeded with the destination (walkable or not); relaxation on (dist, pen) lexicographically
const official = this._spfa(dest, ignore, null);
const pref = this._spfa(dest, ignore, unb);
const dist = pref.dist; // the lexicographic relaxation leaves every distance the official one
const walk = (r, c) => this.walkable(r, c, ignore) && (ignore || !(this.obstacle[r * COLS + c] & OB_CRATE));
const los = allowDiagonal ? (a, b) => bresenhamClear(a, b, walk) : (a, b) => segmentClear(a, b, walk);
const nextO = smoothChains(official.parent, dist, los, null);
// preference smoothing: a segment may cross a non-blockable tile only on the tile's own raw chain between its ends
const onChain = new Int32Array(N).fill(-1);
const nextP = smoothChains(pref.parent, dist, los, {
begin: (n) => { for (let x = n, g = N; x >= 0 && g-- > 0; x = pref.parent[x]) onChain[x] = n; },
ok: (n, b) => crossTiles(n, b, (r, c) => { const k = r * COLS + c; return !unb[k] || (onChain[k] === n && dist[k] >= dist[b]); }),
});
// per tile, in increasing distance: the official pointer unless the preference one crosses fewer non-blockable
// tiles on the way to the destination (or as few with a farther waypoint)
const order = [];
for (let k = 0; k < N; k++) if (dist[k] >= 0) order.push(k);
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 next = new Int32Array(N).fill(-1);
const cost = new Int32Array(N);
for (const k of order) {
const o = nextO[k], p = nextP[k];
if (o < 0) continue; // the destination
let use = o, best = through(k, o) + cost[o];
if (p >= 0 && p !== o) {
const cp = through(k, p) + cost[p];
if (cp < best || (cp === best && dist[p] < dist[o])) { use = p; best = cp; }
}
next[k] = use;
cost[k] = best;
}
return Object.assign(f, { dist, parent: pref.parent, pen: pref.pen, next, official: nextO, cost });
}
/**
* FIFO SPFA from `dest` (walkable or not) with the official neighbour order and move costs. With `penalty` (per-tile
* 0/1) the relaxation is lexicographic on (dist, pen): equal-length chains keep the fewest penalised tiles.
*/
_spfa(dest, ignore, penalty) {
const N = ROWS * COLS;
const dist = new Int32Array(N).fill(-1);
const parent = new Int32Array(N).fill(-1);
/** penalised tiles on the raw chain (tile included, destination excluded) */
const pen = new Int32Array(N);
const inQ = new Uint8Array(N);
const q = new Int32Array(N * 4 + 8);
let qh = 0, qt = 0;
const push = (k) => {
@@ -265,8 +310,8 @@ export class Grid {
const nr = r + FOUR_WAYS[i][0], nc = c + FOUR_WAYS[i][1];
if (!this.walkable(nr, nc, ignore)) continue;
const nb = nr * COLS + nc;
const nd = dist[cur] + (crate(nb) ? OBSTACLE_COST : 1);
const np = pen[cur] + unb[nb];
const nd = dist[cur] + (!ignore && (this.obstacle[nb] & OB_CRATE) ? OBSTACLE_COST : 1);
const np = penalty ? pen[cur] + penalty[nb] : 0;
if (dist[nb] < 0 || nd < dist[nb] || (nd === dist[nb] && np < pen[nb])) {
dist[nb] = nd;
pen[nb] = np;
@@ -275,32 +320,7 @@ export class Grid {
}
}
}
// smoothing: row-major, in place (client `_PostprocessAndMakeNextMapSmoothly`); a line from n toward ancestor a
// may cross a non-blockable tile only when that tile is on n's raw chain between n and a (onChain / minDist)
const next = new Int32Array(parent);
const onChain = new Int32Array(N).fill(-1);
let from = -1, minDist = 0;
const clear = (r, c) => {
if (!this.walkable(r, c, ignore)) return false;
const k = r * COLS + c;
if (crate(k)) return false;
return !unb[k] || (onChain[k] === from && dist[k] >= minDist);
};
const los = allowDiagonal ? (a, b) => bresenhamClear(a, b, clear) : (a, b) => segmentClear(a, b, clear);
for (let n = 0; n < N; n++) {
if (dist[n] < 0 || next[n] < 0) continue;
for (let x = n, guard = N; x >= 0 && guard-- > 0; x = parent[x]) onChain[x] = n;
from = n;
let b = next[n];
while (next[b] >= 0) {
minDist = dist[next[b]];
if (!los(n, next[b])) break;
b = next[b];
}
next[n] = b;
}
f.next = next;
return f;
return { dist, parent, pen };
}
/** Geometric length (tiles) of the smoothed route from tile key k to the field's destination (memoised). */
@@ -370,13 +390,41 @@ export class Grid {
/**
* @typedef {object} FlowField
* @property {number} dest destination tile key (-1 outside the grid)
* @property {Int32Array} dist SPFA distance to the destination (-1 = unreachable)
* @property {Int32Array} parent raw BFS parent
* @property {Int32Array} pen non-blockable tiles on the raw chain (tie-break among equal `dist`)
* @property {Int32Array} next smoothed parent (the tile to walk straight toward)
* @property {Int32Array} dist SPFA distance to the destination (-1 = unreachable; the official one)
* @property {Int32Array} parent raw SPFA parent of the preference chain (fewest non-blockable tiles among equal `dist`)
* @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} next the tile to walk straight toward (official, or the preference pointer when it crosses fewer
* non-blockable tiles — see the module header)
* @property {Int32Array} cost non-blockable tiles the route of `next` crosses from the tile (excluded) to the destination
* @property {Float64Array|null} len memoised smoothed length (fieldLength)
*/
/**
* Row-major, in-place smoothing (client `_PostprocessAndMakeNextMapSmoothly`): each tile's pointer jumps along its chain
* (already smoothed pointers for tiles processed earlier) while `los(tile, ancestor)` holds. With `guard`
* ({ begin(n), ok(n, b) }) the jump then backs off to the farthest of the waypoints it passed that `ok` accepts (the
* raw parent always is). Returns the smoothed pointers.
*/
function smoothChains(parent, dist, los, guard) {
const N = parent.length;
const next = new Int32Array(parent);
const hops = new Int32Array(N);
for (let n = 0; n < N; n++) {
if (dist[n] < 0 || next[n] < 0) continue;
let h = 0, b = next[n];
hops[h++] = b;
while (next[b] >= 0 && los(n, next[b])) { b = next[b]; hops[h++] = b; }
if (guard && h > 1) {
guard.begin(n);
while (h > 1 && !guard.ok(n, hops[h - 1])) h--;
b = hops[h - 1];
}
next[n] = b;
}
return next;
}
/** Bresenham line of sight between tile keys a → b (client `_RaycastBresenhamLine`). */
function bresenhamClear(a, b, clear) {
let r = (a / COLS) | 0, c = a - r * COLS;
@@ -429,6 +477,35 @@ export function bresenhamTiles(a, b) {
return out;
}
/**
* Exact traversal of the straight segment between the centres of tile keys a → b: calls `fn(r, c)` for every tile whose
* interior the segment crosses, a and b included, in order — a tile it only touches at a corner point (the segment
* passes exactly through a lattice corner) is skipped. Stops early and returns false when `fn` returns false.
* Integer arithmetic: the k-th column boundary is crossed at t = (2k − 1) / 2dx, the m-th row boundary at
* (2m − 1) / 2dy.
*/
function crossTiles(a, b, fn) {
let r = (a / COLS) | 0, c = a - r * COLS;
const r1 = (b / COLS) | 0, c1 = b - r1 * COLS;
const dy = Math.abs(r1 - r), dx = Math.abs(c1 - c);
const sr = r1 > r ? 1 : -1, sc = c1 > c ? 1 : -1;
if (fn(r, c) === false) return false;
for (let kx = 1, ky = 1; r !== r1 || c !== c1;) {
const tx = kx <= dx ? (2 * kx - 1) * dy : Infinity;
const ty = ky <= dy ? (2 * ky - 1) * dx : Infinity;
if (tx === ty) { c += sc; r += sr; kx++; ky++; } else if (tx < ty) { c += sc; kx++; } else { r += sr; ky++; }
if (fn(r, c) === false) return false;
}
return true;
}
/** Tiles [[r,c]…] whose interior the segment between the centres of tiles a = [r,c] and b crosses (crossTiles). */
export function segmentTiles(a, b) {
const out = [];
crossTiles(a[0] * COLS + a[1], b[0] * COLS + b[1], (r, c) => { out.push([r, c]); });
return out;
}
/**
* Whether a straight move from (x, y) to the centre of tile p = {x: col, y: row} only crosses ground-walkable,
* crate-free tiles (the start and end tiles excepted). Exact grid traversal (tile (r,c) spans c ± 0.5, r ± 0.5); a
+74 -32
View File
@@ -1,12 +1,14 @@
// Blockable-ground preference of the ground pathing (grid.js header; user playtest: on 战场#01 the lower-gate enemies
// walked up the col-9 floor lane, where no operator can stand, instead of the col-8 road). Among equal-length routes the
// flow field takes the one with the fewest non-blockable tiles, and smoothing never cuts across floor the grid route
// does not walk. 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 proven unavoidable (no route of equal
// or near-equal length, up to +2 tiles, crosses fewer).
// 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
// 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
// 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,
// boss both halves incl. the solo `_s` templates) — the non-blockable tiles an enemy still crosses are listed and
// proven unavoidable (no 4-connected route of equal or near-equal length, up to +2 tiles, crosses fewer).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { Grid, bresenhamTiles } from '../../server/sim/grid.js';
import { Grid, bresenhamTiles, segmentTiles } from '../../server/sim/grid.js';
import { getDefaultSource, hasGeneratedData, normalizeRoute } from '../../server/sim/simdata.js';
import { GEO } from '../../shared/constants.js';
import { makeBattle, flatStage, chessRec, enemyRec } from '../helpers/battleHarness.js';
@@ -29,7 +31,7 @@ function stageGrid(id, rect) {
return g;
}
/** Every tile the enemy's rounded position can take on the smoothed route: Bresenham tiles + diagonal-step corners. */
/** The official line-of-sight footprint of the smoothed route: Bresenham tiles + diagonal-step corners (all walkable). */
function covered(wp) {
const out = [wp[0]];
for (let i = 1; i < wp.length; i++) {
@@ -43,12 +45,20 @@ function covered(wp) {
return out;
}
/** Every tile the enemy's position crosses on the smoothed route (exact segments; a corner touch is not a crossing). */
function crossed(wp) {
const out = [wp[0]];
for (let i = 1; i < wp.length; i++) out.push(...segmentTiles(wp[i - 1], wp[i]).slice(1));
return out;
}
/** Non-blockable tiles crossed from s to e (start and goal excluded), in route order. */
function nbCrossed(g, s, e) {
const wp = g.waypoints(s[0], s[1], e[0], e[1]);
assert.ok(wp, `(${s}) → (${e}) reachable`);
for (const [r, c] of covered(wp)) assert.ok(g.walkable(r, c), `(${s}) → (${e}) line of sight over unwalkable (${r},${c})`);
const seen = new Set();
for (const [r, c] of covered(wp)) {
for (const [r, c] of crossed(wp)) {
const k = `${r},${c}`;
if ((r === s[0] && c === s[1]) || (r === e[0] && c === e[1]) || g.blockable(r, c)) continue;
assert.ok(g.walkable(r, c), `(${s}) → (${e}) crosses unwalkable (${k})`);
@@ -128,12 +138,35 @@ test('smoothing never cuts a corner across floor that the grid route does not wa
const g = new Grid(flatStage({ rows }), GEO.NORMAL_RECT);
for (const s of [[9, 9], [9, 10]]) {
const wp = g.waypoints(s[0], s[1], 12, 3);
for (const [r, c] of covered(wp)) assert.ok(g.blockable(r, c) || (r === s[0] && c === s[1]), `(${s}) → (12,3) crosses (${r},${c}): ${JSON.stringify(wp)}`);
for (const [r, c] of crossed(wp)) assert.ok(g.blockable(r, c) || (r === s[0] && c === s[1]), `(${s}) → (12,3) crosses (${r},${c}): ${JSON.stringify(wp)}`);
}
// the straight line stays when it only crosses blockable ground
assert.deepEqual(g.waypoints(9, 9, 12, 3), [[9, 9], [12, 3]]);
});
test('a diagonal that only brushes a floor tile\'s corner is the official one (D5); one through the floor is not', () => {
// col 0123456789 10
// row 10 ##hrrrrrrf# floor (10,9) right of the road; the route turns from row 9 into row 10
// row 9 ##Er###rrS# gate (9,10) → (9,2) via row 10
const rows = { 12: '##hhhhhhhhh##########', 11: '##hhhhhhhhh##########', 10: '##hrrrrrrf###########', 9: '##Err###rrS##########' };
const g = new Grid(flatStage({ rows }), GEO.NORMAL_RECT);
const p = new Grid(flatStage({ rows }), GEO.NORMAL_RECT);
p.unblockable.fill(0);
// official: (9,10) → (10,7) touches (10,9) only at the corner point (8.5, 9.5)
assert.deepEqual(g.waypoints(9, 10, 9, 2), p.waypoints(9, 10, 9, 2));
assert.deepEqual(g.waypoints(9, 10, 9, 2)[1], [10, 7]);
assert.deepEqual(nbCrossed(g, [9, 10], [9, 2]), []);
// a floor column next to a road column (战场#01): the official (9,9) → (12,8) runs through the floor (10,9); the road
// route crosses none, so it wins
const rows1 = { 12: '##hrrrrrrrfS#########', 11: '##hhhhhhrf###########', 10: '##hhhhhhrf###########', 9: '##Er###rrS###########' };
const g1 = new Grid(flatStage({ rows: rows1 }), GEO.NORMAL_RECT), p1 = new Grid(flatStage({ rows: rows1 }), GEO.NORMAL_RECT);
p1.unblockable.fill(0);
assert.deepEqual(p1.waypoints(9, 9, 12, 3), [[9, 9], [12, 8], [12, 3]], 'official');
assert.deepEqual(nbCrossed(p1, [9, 9], [12, 3]), ['10,9'], 'the official diagonal runs through the floor (10,9)');
assert.deepEqual(g1.waypoints(9, 9, 12, 3), [[9, 9], [9, 8], [12, 8], [12, 3]]);
assert.deepEqual(nbCrossed(g1, [9, 9], [12, 3]), []);
});
test('the preference is only a tie-break: crates keep cost 1000, route lengths stay the official ones', REAL, () => {
// 战场#01 lower gate: a crate on the col-8 road makes the floor lane strictly shorter → it is taken again
const g = stageGrid('act1autochess_m01', GEO.NORMAL_RECT);
@@ -184,8 +217,8 @@ const UNAVOIDABLE = {
act1autochess_m01: { low: '', up: '12,9', uLow: '9,10', uUp: '12,17 9,10', b5: '5,9|5,11', b2: '|' },
act1autochess_m02: { low: '10,9 11,9', up: '12,9', uLow: '10,17 11,17 12,10 12,9', uUp: '12,17 12,10 12,9', b5: '5,9|5,11', b2: '3,9 4,9|3,11 4,11' },
act1autochess_m03: { low: '', up: '12,9', uLow: '9,10', uUp: '12,17 9,10', b5: '5,9|5,11', b2: '|' },
act1autochess_m04: { low: '', up: '12,9 11,9', uLow: '9,10', uUp: '12,17 11,17 9,10', b5: '5,9 4,9|5,11 4,11', b2: '|' },
act2autochess_m01: { low: '', up: '12,9 11,9 10,9', uLow: '9,10', uUp: '12,17 11,17 10,17 9,10', b5: '5,9 4,9 3,9|5,11 4,11 3,11', b2: '|' },
act1autochess_m04: { low: '', up: '12,9 11,9', uLow: '9,10', uUp: '12,17 11,17 9,10', b5: '4,9|4,11', b2: '|' },
act2autochess_m01: { low: '', up: '12,9 11,9 10,9', uLow: '9,10', uUp: '12,17 11,17 10,17 9,10', b5: '4,10 3,9|4,10 3,11', b2: '|' },
act2autochess_m02: { low: '', up: '12,9', uLow: '9,10', uUp: '12,17 9,10', b5: '5,9|5,11', b2: '|' },
act2autochess_m03: { low: '10,9', up: '12,9', uLow: '10,17 9,10 10,9', uUp: '12,17 9,10 10,9', b5: '5,9|5,11', b2: '3,9|3,11' },
act2autochess_m04: { low: '', up: '12,9', uLow: '9,10', uUp: '12,17 9,10', b5: '5,9|5,11', b2: '|' },
@@ -201,7 +234,7 @@ test('audit: every active stage × gate × field — the non-blockable tiles cro
assert.equal(got.join(' '), want, `${sid} ${what} (${s}) → (${e}): non-blockable tiles crossed`);
const m = fewestNb(g, s, e, 2);
assert.ok(m, `${sid} ${what}: reachable without crates`);
assert.equal(got.length, m.min, `${sid} ${what} (${s}) → (${e}): a route of length ≤ ${m.best}+2 crosses only ${m.min}`);
assert.ok(got.length <= m.min, `${sid} ${what} (${s}) → (${e}): a route of length ≤ ${m.best}+2 crosses only ${m.min}`);
n++;
};
check(nrm, [9, 10], [9, 2], exp.low, 'normal lower gate');
@@ -246,7 +279,8 @@ test('audit: every WALK leg of every wave template (normal / 联防 / boss / hid
// the boss arena's central floor (rows 3–5 × cols 9–11: the (5,10) / (2,10) teleport exits, boss_8's parts and
// patrols); a blockable detour around that floor is ≥ 2 tiles longer, and the official route length is kept.
const arena = (t) => { const [r, c] = t.split(',').map(Number); return r >= 3 && r <= 5 && c >= 9 && c <= 11; };
let n = 0, extra = 0, accepted = 0;
let n = 0, extra = 0;
const accepted = [];
for (const sid of STAGES) {
const grids = new Map();
for (const { rect, from, to, id } of legs.values()) {
@@ -258,18 +292,18 @@ test('audit: every WALK leg of every wave template (normal / 联防 / boss / hid
if (!eq) continue;
const got = nbCrossed(g, from, to);
const what = `${sid} ${id} (${from}) → (${to}) crosses ${got.join(' ')}`;
assert.equal(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);
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}`);
accepted++;
accepted.push(what);
}
extra += got.length;
n++;
}
}
assert.ok(n > 200, `${n} legs audited (${extra} unavoidable non-blockable crossings)`);
assert.equal(accepted, 17, 'accepted boss-arena exceptions (update the count only after reviewing a new one)');
assert.equal(accepted.length, 16, `accepted boss-arena exceptions (update the count only after reviewing a new one):\n${accepted.join('\n')}`);
});
// ---------------------------------------------------------------------------------------------------------------
@@ -323,7 +357,7 @@ test('map cards (移除全部阻隔工事 / 阻隔工事变为射击台): with t
}
const got = nbCrossed(g, s, e);
const m = fewestNb(g, s, e, 0);
assert.equal(got.length, m.min, `${sid} crates ${mode} ${kind} (${s}) → (${e}) crosses ${got.join(' ')}; an equal-length route only ${m.min}`);
assert.ok(got.length <= m.min, `${sid} crates ${mode} ${kind} (${s}) → (${e}) crosses ${got.join(' ')}; an equal-length route only ${m.min}`);
n++;
}
}
@@ -359,7 +393,7 @@ test('deviation from the official route is bounded: same grid length everywhere,
assert.ok(n > 2000 && longer > 0 && worst > 1, `${n} tiles, ${longer} longer, worst +${worst.toFixed(2)}`);
});
test('random layouts: official lengths, fewest non-blockable tiles among equal-length chains, no off-chain floor / crate / wall on a smoothed segment', () => {
test('random layouts: official lengths, fewest non-blockable tiles among equal-length chains, official line of sight, never more floor than the official route', () => {
let seed = 20260929;
const rnd = () => ((seed = (Math.imul(seed, 1103515245) + 12345) >>> 0) / 4294967296);
const RECT = GEO.UNITE_RECT;
@@ -387,23 +421,31 @@ test('random layouts: official lengths, fewest non-blockable tiles among equal-l
}
assert.equal(f.pen[k], best[k], `${what}: (${r},${c}) pen`);
}
// smoothed chains: strictly closer each hop; segments cross only walkable, crate-free tiles and on-chain floor
// routes: strictly closer each hop; every segment is official line of sight (walkable, crate-free Bresenham
// footprint); `cost` = the non-blockable tiles the route crosses; never more than the pure official route, nor than
// the fewest-floor chain (pen); a pointer other than the official one crosses strictly less floor, or as little
// with a farther waypoint
assert.deepEqual([...f.official], [...fp.next], `${what}: the official pointers`);
const nbSeg = (x, y) => segmentTiles([(x / COLS) | 0, x % COLS], [(y / COLS) | 0, y % COLS]).slice(1).filter(([r, c]) => g.unblockable[r * COLS + c]).length;
const offNb = new Int32Array(N);
for (const k of order) {
if (k === dest) continue;
const chain = new Set();
for (let x = k; x >= 0; x = f.parent[x]) chain.add(x);
for (let x = k; x !== dest;) {
const y = f.next[x];
assert.ok(y >= 0 && f.dist[y] < f.dist[x], `${what}: next of ${x}`);
const a = [(x / COLS) | 0, x % COLS], b = [(y / COLS) | 0, y % COLS];
const hop = Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) === 1;
const y = f.next[k];
assert.ok(y >= 0 && f.dist[y] < f.dist[k], `${what}: next of ${k}`);
const a = [(k / COLS) | 0, k % COLS], b = [(y / COLS) | 0, y % COLS];
if (Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) > 1) {
for (const [r, c] of covered([a, b])) {
const kk = r * COLS + c;
if (kk === dest || hop) continue;
assert.ok(g.walkable(r, c) && !g.isCrate(r, c), `${what}: (${a}) → (${b}) crosses (${r},${c})`);
assert.ok(!g.unblockable[kk] || chain.has(kk), `${what}: (${a}) → (${b}) cuts across off-chain floor (${r},${c})`);
if (r * COLS + c !== dest) assert.ok(g.walkable(r, c) && !g.isCrate(r, c), `${what}: (${a}) → (${b}) crosses (${r},${c})`);
}
x = y;
}
assert.equal(f.cost[k], nbSeg(k, y) + f.cost[y], `${what}: (${a}) cost`);
const o = fp.next[k];
offNb[k] = nbSeg(k, o) + offNb[o];
assert.ok(f.cost[k] <= offNb[k], `${what}: (${a}) crosses ${f.cost[k]} non-blockable tiles, the official route ${offNb[k]}`);
assert.ok(f.cost[k] <= f.pen[k] - g.unblockable[k] + g.unblockable[dest], `${what}: (${a}) more floor than its fewest-floor chain`);
if (y !== o) {
const co = nbSeg(k, o) + f.cost[o];
assert.ok(f.cost[k] < co || (f.cost[k] === co && f.dist[y] < f.dist[o]), `${what}: (${a}) leaves the official pointer without less floor`);
}
}
}
+96 -50
View File
@@ -1,9 +1,11 @@
// Adversarial cross-check of grid.js against an independent port of the official client pathing (research 08 §3.4,
// `Torappu.Battle.SPFA`: FIFO SPFA from the destination over the WHOLE level map, UP/RIGHT/DOWN/LEFT, crates cost 1000,
// strict improvement; row-major in-place Bresenham smoothing with the diagonal corner rule), run on the raw level files
// in .cache/gamedata (skipped when absent), extended by our blockable-ground preference (grid.js header: equal-length
// ties go to the chain with the fewest non-blockable tiles; a smoothed line crosses non-blockable tiles only on its own
// raw chain):
// in .cache/gamedata (skipped when absent), extended by our blockable-ground preference (grid.js header: a second,
// preference field — equal-length ties to the chain with the fewest non-blockable tiles, smoothing that backs off from
// segments through non-blockable tiles off the own raw chain — whose pointer replaces the official one where its route
// crosses fewer non-blockable tiles, or as few with a farther waypoint; "crosses" = 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,
// 联防 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;
@@ -26,9 +28,8 @@ const FOUR = [[1, 0], [0, 1], [-1, 0], [0, -1]];
/**
* The official algorithm on a raw level (non-hidden crates cost 1000; platforms / mounds blocked like the sim [ASSUMED]).
* `prefer` adds the blockable-ground preference (lexicographic (dist, non-blockable count) relaxation; smoothing lines
* cross non-blockable tiles only on the tile's own raw chain between its ends). Returns dest → (start → chain string),
* with `.dist(start)` / `.covered(start)` (tiles of the smoothed route incl. diagonal-step corners) on each dest.
* `prefer` adds the blockable-ground preference (see the header). Returns dest → (start → chain string), with
* `.dist(start)` / `.crossedNb(start)` (non-blockable tiles the route crosses, start excluded) on each dest.
*/
function official(sid, { crates: extraCrates = [], blocks: extraBlocks = [], rect = null, prefer = true } = {}) {
const lv = JSON.parse(readFileSync(file(sid), 'utf8'));
@@ -66,48 +67,94 @@ function official(sid, { crates: extraCrates = [], blocks: extraBlocks = [], rec
}
return clear ? out.every(([a, b]) => clear(a, b)) : out;
};
return (dest) => {
const D = dest.join();
const dist = new Map([[D, 0]]);
const pen = new Map([[D, 0]]);
const par = new Map();
const q = [dest];
const inq = new Set([D]);
while (q.length) {
const cur = q.shift();
const ck = cur.join();
inq.delete(ck);
for (const [dr, dc] of FOUR) {
const nb2 = [cur[0] + dr, cur[1] + dc];
const nk = nb2.join();
if (!passable(nb2[0], nb2[1])) continue;
const nd = dist.get(ck) + (crates.has(nk) ? 1000 : 1);
const np = pen.get(ck) + (nb(nb2[0], nb2[1]) ? 1 : 0);
const better = !dist.has(nk) || nd < dist.get(nk) || (prefer && nd === dist.get(nk) && np < pen.get(nk));
if (better) {
dist.set(nk, nd);
pen.set(nk, np);
par.set(nk, cur);
if (!inq.has(nk)) { q.push(nb2); inq.add(nk); }
// tiles the centre-to-centre segment a → b passes through with positive length (Liang–Barsky clip per tile)
const through = ([r0, c0], [r1, c1]) => {
const out = [];
for (let r = Math.min(r0, r1); r <= Math.max(r0, r1); r++) {
for (let c = Math.min(c0, c1); c <= Math.max(c0, c1); c++) {
let t0 = 0, t1 = 1;
for (const [pp, qq] of [[-(c1 - c0), c0 - (c - 0.5)], [c1 - c0, c + 0.5 - c0], [-(r1 - r0), r0 - (r - 0.5)], [r1 - r0, r + 0.5 - r0]]) {
if (pp === 0) { if (qq < 0) t1 = -1; continue; }
if (pp < 0) t0 = Math.max(t0, qq / pp); else t1 = Math.min(t1, qq / pp);
}
if (t1 - t0 > 1e-9) out.push([r, c]);
}
}
const nxt = new Map(par);
for (let r = 0; r < H; r++) {
for (let c = 0; c < W; c++) {
const k = `${r},${c}`;
if (!nxt.has(k)) continue;
const chain = new Set();
for (let x = [r, c]; x; x = par.get(x.join())) chain.add(x.join());
const los = (a) => {
const lo = dist.get(a.join());
return bres([r, c], a, (y, x) => passable(y, x) && !crates.has(`${y},${x}`)
&& (!prefer || !nb(y, x) || (chain.has(`${y},${x}`) && dist.get(`${y},${x}`) >= lo)));
};
let b = nxt.get(k);
while (nxt.has(b.join()) && los(nxt.get(b.join()))) b = nxt.get(b.join());
nxt.set(k, b);
return out;
};
const key = (p) => p.join();
const unkey = (k) => k.split(',').map(Number);
const losClear = (y, x) => passable(y, x) && !crates.has(`${y},${x}`);
return (dest) => {
const D = dest.join();
const spfa = (usePen) => {
const dist = new Map([[D, 0]]);
const pen = new Map([[D, 0]]);
const par = new Map();
const q = [dest];
const inq = new Set([D]);
while (q.length) {
const cur = q.shift();
const ck = cur.join();
inq.delete(ck);
for (const [dr, dc] of FOUR) {
const nb2 = [cur[0] + dr, cur[1] + dc];
const nk = nb2.join();
if (!passable(nb2[0], nb2[1])) continue;
const nd = dist.get(ck) + (crates.has(nk) ? 1000 : 1);
const np = pen.get(ck) + (usePen && nb(nb2[0], nb2[1]) ? 1 : 0);
if (!dist.has(nk) || nd < dist.get(nk) || (usePen && nd === dist.get(nk) && np < pen.get(nk))) {
dist.set(nk, nd);
pen.set(nk, np);
par.set(nk, cur);
if (!inq.has(nk)) { q.push(nb2); inq.add(nk); }
}
}
}
return { dist, par };
};
// row-major in-place smoothing; `guard`: back off to the farthest passed waypoint whose segment crosses
// non-blockable tiles only on the tile's own raw chain
const smooth = ({ dist, par }, guard) => {
const nxt = new Map(par);
for (let r = 0; r < H; r++) {
for (let c = 0; c < W; c++) {
const k = `${r},${c}`;
if (!nxt.has(k)) continue;
let b = nxt.get(k);
const hops = [b];
while (nxt.has(b.join()) && bres([r, c], nxt.get(b.join()), losClear)) { b = nxt.get(b.join()); hops.push(b); }
if (guard) {
const chain = new Set();
for (let x = [r, c]; x; x = par.get(x.join())) chain.add(x.join());
const ok = (a) => through([r, c], a).every(([y, x]) => !nb(y, x) || (chain.has(`${y},${x}`) && dist.get(`${y},${x}`) >= dist.get(a.join())));
while (hops.length > 1 && !ok(hops[hops.length - 1])) hops.pop();
b = hops[hops.length - 1];
}
nxt.set(k, b);
}
}
return nxt;
};
const off = spfa(false);
const dist = off.dist;
const segNb = (a, b) => through(a, b).filter(([y, x]) => !(y === a[0] && x === a[1]) && nb(y, x)).length;
let nxt = smooth(off, false);
if (prefer) {
// per tile in increasing distance: the preference pointer when its route crosses fewer non-blockable tiles (or as
// few with a farther waypoint)
const nxtP = smooth(spfa(true), true);
const cost = new Map([[D, 0]]);
const chosen = new Map();
for (const k of [...dist.keys()].sort((a, b) => dist.get(a) - dist.get(b))) {
if (k === D) continue;
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 useP = cp < co || (cp === co && dist.get(key(p)) < dist.get(key(o)));
chosen.set(k, useP ? p : o);
cost.set(k, useP ? cp : co);
}
nxt = chosen;
}
const chainOf = (start) => {
if (!dist.has(start.join())) return null;
@@ -118,13 +165,12 @@ function official(sid, { crates: extraCrates = [], blocks: extraBlocks = [], rec
};
const fn = (start) => { const ch = chainOf(start); return ch ? ch.map((p) => `(${p})`).join(' ') : null; };
fn.dist = (start) => dist.get(start.join()) ?? -1;
fn.coveredNb = (start) => {
fn.crossedNb = (start) => {
const ch = chainOf(start);
if (!ch) return null;
const s = new Set();
for (let i = 1; i < ch.length; i++) for (const [y, x] of bres(ch[i - 1], ch[i])) if (nb(y, x)) s.add(`${y},${x}`);
if (nb(...ch[0])) s.add(ch[0].join());
return s;
let n = 0;
for (let i = 1; i < ch.length; i++) n += segNb(ch[i - 1], ch[i]);
return n;
};
return fn;
};
@@ -200,7 +246,7 @@ test('vs the PURE official algorithm: same route length from every tile, never m
for (let c = rect.c0; c <= rect.c1; c++) {
if (!g.walkable(r, c) || p.dist([r, c]) < 0) continue;
assert.equal(f.dist[r * 21 + c], p.dist([r, c]), `${sid} (${r},${c}) → (${dest}): official length`);
const a = p.coveredNb([r, c]).size, b = o.coveredNb([r, c]).size;
const a = p.crossedNb([r, c]), b = o.crossedNb([r, c]);
assert.ok(b <= a, `${sid} (${r},${c}) → (${dest}): ${b} non-blockable tiles vs official ${a}`);
if (b < a) fewer++;
n++;
+130
View File
@@ -0,0 +1,130 @@
// 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
// the official game). grid.js keeps the official flow field (research 08 §3.1) wherever its route crosses no more
// non-blockable tiles (floor, gates) than the blockable-ground preference of user playtest #2 — 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
// official algorithm; the routes that still differ are listed, each avoiding floor the official route walks over.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { Grid, segmentTiles } from '../../server/sim/grid.js';
import { getDefaultSource, hasGeneratedData } from '../../server/sim/simdata.js';
import { GEO } from '../../shared/constants.js';
import { makeBattle, chessRec, enemyRec } from '../helpers/battleHarness.js';
const REAL = { skip: !hasGeneratedData() && 'no generated data' };
const COLS = 21;
const M04 = 'act1autochess_m04';
const RECT = { normal: GEO.NORMAL_RECT, unite: GEO.UNITE_RECT, boss: GEO.BOSS_RECT };
/** Grid of a real stage with its match-start devices; `pure` = the official algorithm (every tile counted blockable). */
function stageGrid(id, rect, pure = false) {
const st = getDefaultSource().getStage(id);
if (!st) return null;
const g = new Grid(st, rect);
for (const d of st.raw.devices) {
if (!d.active || !d.pos) continue;
if (d.role === 'crate') g.setObstacle(d.pos[0], d.pos[1], true, 'crate');
if (d.role === 'platform' || d.role === 'mound') g.setObstacle(d.pos[0], d.pos[1], true);
}
if (pure) g.unblockable.fill(0);
return g;
}
const str = (wp) => (wp ? wp.map((p) => p.join(',')).join(' ') : 'null');
test('D5 战场#04 活性源石: the lower-gate route is the official diagonal from row 9 into row 10 (normal and 联防 fields)', REAL, () => {
const official = [[9, 10], [10, 7], [10, 4], [9, 4], [9, 2]];
assert.deepEqual(stageGrid(M04, GEO.NORMAL_RECT, true).waypoints(9, 10, 9, 2), official, 'the pure official algorithm');
assert.deepEqual(stageGrid(M04, GEO.NORMAL_RECT).waypoints(9, 10, 9, 2), official, 'normal field');
assert.deepEqual(stageGrid(M04, GEO.UNITE_RECT).waypoints(9, 10, 9, 2), official, '联防 field, own lower gate');
// the segment (9,10) → (10,7) only brushes the corner of the floor tile (10,9): it crosses no non-blockable tile
assert.deepEqual(segmentTiles([9, 10], [10, 7]), [[9, 10], [9, 9], [10, 8], [10, 7]]);
});
test('D5 real battle (act1 m04, round-1 template act1autochess_01): the 源石虫 leave the gate diagonally, never along row 9 to col 8', REAL, () => {
const h = makeBattle({ stageId: M04, waveTemplate: 'act1autochess_01', units: [], autoFinish: false });
let id = null, defId = null;
const tiles = [];
let atCol9 = null;
h.b.on('tick', () => {
const e = h.b.enemies.find((u) => (id == null ? u.motion === 'WALK' : u.id === id));
if (!e) return;
id = e.id;
defId = e.defId;
const k = `${Math.round(e.y)},${Math.round(e.x)}`;
if (tiles[tiles.length - 1] !== k) tiles.push(k);
if (atCol9 == null && e.x <= 9) atCol9 = e.y;
});
h.run(30);
assert.equal(defId, 'enemy_1007_slime', 'the round-1 源石虫 of the lower gate');
assert.deepEqual(tiles.slice(0, 5), ['9,10', '9,9', '10,8', '10,7', '10,6'], `tiles: ${tiles.join(' ')}`);
assert.ok(!tiles.includes('9,8'), `never on (9,8): ${tiles.join(' ')}`);
// on the official segment (9,10) → (10,7) the walker is a third of a row up when it passes the col-9 centre line
assert.ok(Math.abs(atCol9 - (9 + 1 / 3)) < 0.02, `row ${atCol9} at col 9 (straight along row 9 would be 9.00)`);
});
// Routes that differ from the official one (ours, as waypoints): each crosses fewer non-blockable tiles — user playtest
// #2's 战场#01 col-9 floor lane (its 联防 partner routes pass the same exit), the boss exits (2,10) of 战场#01 / #02
// that would cut across the arena's central floor, 战场#02's 联防 partner lower gate (floor (12,17)) and the inactive
// 战场#07 (上半)'s 联防 partner upper gate (row 12 through (12,9) vs row 9). Every other route of every stage is official.
const DEVIATIONS = {
'act1autochess_m01 normal 9,10>9,2': '9,10 9,8 12,8 12,4 9,4 9,2',
'act1autochess_m01 unite 9,10>9,2': '9,10 9,8 12,8 12,4 9,4 9,2',
'act1autochess_m01 unite 9,18>9,2': '9,18 9,16 12,16 12,12 9,12 9,8 12,8 12,4 9,4 9,2',
'act1autochess_m01 unite 12,18>9,2': '12,18 12,12 9,12 9,8 12,8 12,4 9,4 9,2',
'act1autochess_m01 boss 2,10>1,3': '2,10 2,8 5,8 5,4 2,4 2,3 1,3',
'act1autochess_m01 boss 2,10>2,2': '2,10 2,8 5,8 5,4 2,4 2,2',
'act1autochess_m01 boss 2,10>1,17': '2,10 2,12 5,12 5,16 2,16 2,17 1,17',
'act1autochess_m01 boss 2,10>2,18': '2,10 2,12 5,12 5,16 2,16 2,18',
'act1autochess_m02 unite 9,18>9,2': '9,18 9,17 11,17 12,12 12,3 9,3 9,2',
'act1autochess_m02 boss 2,10>1,3': '2,10 2,9 4,9 4,6 2,6 2,3 1,3',
'act1autochess_m02 boss 2,10>2,2': '2,10 2,9 4,9 4,6 2,6 2,2',
'act1autochess_m02 boss 2,10>1,17': '2,10 3,11 4,11 4,14 2,14 2,17 1,17',
'act1autochess_m02 boss 2,10>2,18': '2,10 3,11 4,11 4,14 2,14 2,18',
'act1autochess_m07 unite 12,18>9,2': '12,18 12,11 9,11 9,2',
};
test('audit: every route of every stage (11, active or not) × gate × field is the official one, except the listed floor-avoiding ones', REAL, () => {
const stages = ['act1autochess_m01', 'act1autochess_m02', 'act1autochess_m03', 'act1autochess_m04', 'act1autochess_m05', 'act1autochess_m06', 'act1autochess_m07',
'act2autochess_m01', 'act2autochess_m02', 'act2autochess_m03', 'act2autochess_m04'];
const legs = [
['normal', [9, 10], [9, 2]], ['normal', [12, 10], [9, 2]],
['unite', [9, 10], [9, 2]], ['unite', [12, 10], [9, 2]], ['unite', [9, 18], [9, 2]], ['unite', [12, 18], [9, 2]],
...[[5, 10], [2, 10]].flatMap((s) => [[1, 3], [2, 2], [1, 17], [2, 18]].map((e) => ['boss', s, e])),
];
const floor = (g, wp) => {
const out = [];
for (let i = 1; i < wp.length; i++) for (const [r, c] of segmentTiles(wp[i - 1], wp[i]).slice(1)) if (g.unblockable[r * COLS + c]) out.push(`${r},${c}`);
return out;
};
const seen = new Set();
let official = 0;
for (const sid of stages) {
for (const [kind, s, e] of legs) {
const g = stageGrid(sid, RECT[kind]), p = stageGrid(sid, RECT[kind], true);
assert.ok(g, `${sid} in data/stages.json`);
const ours = g.waypoints(s[0], s[1], e[0], e[1]), off = p.waypoints(s[0], s[1], e[0], e[1]);
const key = `${sid} ${kind} ${s}>${e}`;
assert.ok(ours && off, `${key} reachable`);
assert.equal(g.flowField(e[0], e[1]).dist[s[0] * COLS + s[1]], p.flowField(e[0], e[1]).dist[s[0] * COLS + s[1]], `${key}: official length`);
if (str(ours) === str(off)) { assert.ok(!(key in DEVIATIONS), `${key} is official now: drop it from DEVIATIONS`); official++; continue; }
seen.add(key);
assert.equal(str(ours), DEVIATIONS[key], `${key} differs from the official ${str(off)}`);
const fo = floor(g, ours), ff = floor(g, off);
assert.ok(fo.length < ff.length, `${key}: crosses ${fo.join(' ')}, the official route ${ff.join(' ')}`);
}
}
assert.equal(seen.size, Object.keys(DEVIATIONS).length, 'every listed deviation still exists');
assert.equal(official, stages.length * legs.length - seen.size);
});
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 walker = enemyRec({ key: 'enemy_walker', hp: 1e6, speed: 1 });
const route = { motion: 'WALK', start: [9, 10], end: [9, 2], checkpoints: [] };
for (const [r, c] of [[9, 9], [9, 8], [10, 8]]) {
const h = makeBattle({ stageId: M04, defs: { chess: { t_guard: guard }, enemies: { enemy_walker: walker } },
units: [{ chessId: 't_guard', row: r, col: c }], enemies: [{ key: 'enemy_walker', route }], content: 'none', autoFinish: false, timeLimit: 60 });
const u = h.unit('t_guard');
assert.ok(h.runUntil(() => h.enemies()[0]?.blockedBy === u, 30), `blocked by the operator on (${r},${c})`);
}
});
+4 -3
View File
@@ -108,13 +108,14 @@ test('flow fields are cached per destination and grid version', () => {
});
test('real stages: the official lanes of research 08 §3.2 (crates = non-hidden level predefines, platforms blocking) + blockable-ground preference', REAL, () => {
// m01 / m04 lower gate: equal-length choice between the col-9 floor lane and the col-8 road — the road (blockable)
// wins (official: (9,9) → (12,8) over the floor of (10,9) / (11,9), and (9,10) → (10,7) over (10,9))
// m01 lower gate: equal-length choice between the col-9 floor lane and the col-8 road — the road (blockable) wins
// (official: (9,9) → (12,8) through the floor of (10,9)); m04 lower gate: the official diagonal (9,10) → (10,7) only
// brushes the corner of the floor (10,9) and stays (community report D5, test/sim/pathing-official.test.js)
const lanes = {
act1autochess_m01: ['(12,10) (12,4) (9,4) (9,2)', '(9,10) (9,8) (12,8) (12,4) (9,4) (9,2)'],
act1autochess_m02: ['(12,10) (12,3) (9,3) (9,2)', '(9,10) (9,9) (11,9) (11,6) (9,6) (9,2)'],
act1autochess_m03: ['(12,10) (12,6) (11,6) (9,5) (9,2)', '(9,10) (9,2)'], // 射击台 on (10,3)/(10,4) block [ASSUMED]
act1autochess_m04: ['(12,10) (12,9) (11,9) (11,4) (9,4) (9,2)', '(9,10) (9,8) (10,8) (10,4) (9,4) (9,2)'],
act1autochess_m04: ['(12,10) (12,9) (11,9) (11,4) (9,4) (9,2)', '(9,10) (10,7) (10,4) (9,4) (9,2)'],
act2autochess_m01: ['(12,10) (12,9) (9,9) (9,7) (12,7) (12,5) (9,5) (9,2)', '(9,10) (9,7) (12,7) (12,5) (9,5) (9,2)'],
act2autochess_m02: ['(12,10) (12,7) (9,7) (9,2)', '(9,10) (9,2)'],
act2autochess_m03: ['(12,10) (12,4) (9,4) (9,2)', '(9,10) (9,9) (10,9) (10,6) (9,6) (9,2)'],