feedback1d WO: the preference route is 0.1.0's again; equal floor keeps the official route
Review follow-up to the D5 fix. Two changes in grid.js _buildField: - The road-over-floor preference smoothing is 0.1.0's again: its line of sight may cover non-blockable tiles (Bresenham footprint, diagonal-step corners included) only on the tile's own raw chain, and the jump stops at the first ancestor it cannot see so. Exact crossings (a corner touch is not a crossing) are used only to choose between the official and the preference pointer. The previous version let the preference brush the corner of off-chain floor, so on 战场#02's Final Assault the right-half exit route (2,10) -> (3,11) slipped past an operator on (2,11) while the left half was still blocked on (2,9). Both halves now mirror each other again ((2,10) -> (2,11) -> (4,11)). - On an equal floor count the official pointer stays; the preference pointer wins only when it merely skips a collinear official waypoint (same walk). The old "farther waypoint" tie-break could bend an official route without saving any floor, e.g. a crate on (10,6) of 战场#04 brought back the L via (9,8) for the lower gate. Still 140 of 154 stage routes official; data/stages.json groundPaths regenerated (战场#02 boss right exit back to 0.1.0's, device-less helper paths of #03 / #04 / #06 follow the same rule). New tests: 战场#02 boss exits mirror and are blocked beside the exit; a single crate on any tile of an active stage never leaves a gate route non-official without crossing strictly less floor. The cross-check reference port follows both rules.
This commit is contained in:
1 parent
d8c62a96b5
commit
7a3918eab9
8 files changed
+147
-74
No files matched your search
+1
-1
@@ -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
@@ -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 — 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 |
|
||||
| `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 |
|
||||
| `options`, `config` | `{"characterLimit":8,"moveMultiplier":0.5}`, `{…configBlackBoard strings…}` | |
|
||||
|
||||
Glyph legend (`rows`):
|
||||
|
||||
+8
-7
@@ -150,13 +150,14 @@ HP, then `kill(old, null)` — uncounted, no bounty; the client sees a `die` and
|
||||
|
||||
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). 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
|
||||
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)
|
||||
crosses strictly 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), and on equal counts the official route stays.
|
||||
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
|
||||
|
||||
@@ -349,13 +349,14 @@ The raw BFS chains and smoothed waypoints are in scratch `lanes_official.txt`.
|
||||
|
||||
**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):
|
||||
`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,
|
||||
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
|
||||
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) →
|
||||
(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
|
||||
@@ -363,6 +364,11 @@ still differ from the table above (14 of the 154 stage × gate × field routes,
|
||||
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.
|
||||
- Official again since D5 (0.1.0 took L routes there): 战场#04's lower gate (normal, 联防) and boss exits, the boss exit
|
||||
(5,10) of 战场#05(下半), the boss exit (2,10) of 战场#07(下半) and the inactive 战场#06(上半). The diagonal first
|
||||
step out of the (2,10) exit of 战场#07(下半) (and #06(上半)) passes its side tiles (2,9) / (2,11) at exactly
|
||||
√0.5 ≈ 0.70711, just beyond the ground block radius 0.70709997 (PRTS 游戏数据基础 §阻挡半径), so an operator standing
|
||||
there no longer catches those enemies; every other blocking tile of the 154 routes is unchanged.
|
||||
|
||||
### 3.3 What we do and the root cause of "upper-gate enemies walk the lower lane"
|
||||
|
||||
|
||||
+44
-35
@@ -18,19 +18,22 @@
|
||||
// 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:
|
||||
// ground buildable for melee (floor / gate / goal / teleport — `blockable()` false). 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):
|
||||
// * 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).
|
||||
// * preference (0.1.0's): 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 the line of
|
||||
// sight may cover a non-blockable tile — its Bresenham footprint, the corner tiles of a diagonal step included —
|
||||
// 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.
|
||||
// `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
|
||||
// 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
|
||||
// counts the official pointer stays, except where the preference one merely skips it (the official waypoint lies on
|
||||
// the straight line to it and leads there: the same walk, one waypoint fewer). So the route from every tile crosses
|
||||
// no more floor than the official one and leaves it only for a step that crosses less (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.
|
||||
//
|
||||
@@ -252,16 +255,23 @@ export class Grid {
|
||||
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 ray = allowDiagonal ? bresenhamClear : segmentClear;
|
||||
const nextO = smoothChains(official.parent, dist, (a, b) => ray(a, b, walk), null);
|
||||
// preference smoothing (0.1.0's, playtest #2): the line of sight from n toward ancestor `to` may also cover a
|
||||
// non-blockable tile — the Bresenham footprint, corner tiles of a diagonal step included — only when that tile is on
|
||||
// n's own raw chain between n and `to`; the jump stops at the first ancestor not visible under that rule
|
||||
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)
|
||||
let from = -1, to = -1;
|
||||
const walkP = (r, c) => {
|
||||
if (!walk(r, c)) return false;
|
||||
const k = r * COLS + c;
|
||||
return !unb[k] || (onChain[k] === from && dist[k] >= dist[to]);
|
||||
};
|
||||
const nextP = smoothChains(pref.parent, dist, (a, b) => { from = a; to = b; return ray(a, b, walkP); },
|
||||
(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
|
||||
// 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)
|
||||
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);
|
||||
@@ -274,7 +284,7 @@ export class Grid {
|
||||
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; }
|
||||
if (cp < best || (cp === best && next[o] === p && onSegment(k, o, p))) { use = p; best = cp; }
|
||||
}
|
||||
next[k] = use;
|
||||
cost[k] = best;
|
||||
@@ -402,29 +412,28 @@ export class Grid {
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* (already smoothed pointers for tiles processed earlier) while `los(tile, ancestor)` holds; `begin(tile)`, when given,
|
||||
* runs before the tile's jump. Returns the smoothed pointers.
|
||||
*/
|
||||
function smoothChains(parent, dist, los, guard) {
|
||||
function smoothChains(parent, dist, los, begin) {
|
||||
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];
|
||||
}
|
||||
if (begin) begin(n);
|
||||
let b = next[n];
|
||||
while (next[b] >= 0 && los(n, next[b])) b = next[b];
|
||||
next[n] = b;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Whether tile key b lies strictly inside the straight segment between tile keys a and c. */
|
||||
function onSegment(a, b, c) {
|
||||
const ar = (a / COLS) | 0, ac = a - ar * COLS, br = (b / COLS) | 0, bc = b - br * COLS, cr = (c / COLS) | 0, cc = c - cr * COLS;
|
||||
return (br - ar) * (cc - ac) === (bc - ac) * (cr - ar) && (br - ar) * (cr - br) + (bc - ac) * (cc - bc) > 0;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
|
||||
@@ -424,7 +424,7 @@ test('random layouts: official lengths, fewest non-blockable tiles among equal-l
|
||||
// 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
|
||||
// while it only skips the official waypoint (which lies on the straight line to it and leads there)
|
||||
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);
|
||||
@@ -445,7 +445,10 @@ test('random layouts: official lengths, fewest non-blockable tiles among equal-l
|
||||
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`);
|
||||
const oo = [(o / COLS) | 0, o % COLS];
|
||||
const skips = f.next[o] === y && (oo[0] - a[0]) * (b[1] - a[1]) === (oo[1] - a[1]) * (b[0] - a[0])
|
||||
&& segmentTiles(a, b).some(([r, c]) => r === oo[0] && c === oo[1]);
|
||||
assert.ok(f.cost[k] < co || (f.cost[k] === co && skips), `${what}: (${a}) leaves the official pointer without less floor`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
// `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: 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):
|
||||
// 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
|
||||
// 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
|
||||
// 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;
|
||||
@@ -113,24 +114,21 @@ function official(sid, { crates: extraCrates = [], blocks: extraBlocks = [], rec
|
||||
}
|
||||
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
|
||||
// row-major in-place smoothing; `guard` (0.1.0's preference smoothing): the line of sight toward ancestor a may
|
||||
// also cover non-blockable tiles (Bresenham footprint, diagonal-step corners included) only on the tile's own raw
|
||||
// chain between the tile and a — the jump stops at the first ancestor it cannot see so
|
||||
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;
|
||||
const chain = new Set();
|
||||
if (guard) for (let x = [r, c]; x; x = par.get(x.join())) chain.add(x.join());
|
||||
const sees = (a) => bres([r, c], a, (y, x) => losClear(y, x)
|
||||
&& (!guard || !nb(y, x) || (chain.has(`${y},${x}`) && dist.get(`${y},${x}`) >= dist.get(a.join()))));
|
||||
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];
|
||||
}
|
||||
while (nxt.has(b.join()) && sees(nxt.get(b.join()))) b = nxt.get(b.join());
|
||||
nxt.set(k, b);
|
||||
}
|
||||
}
|
||||
@@ -142,15 +140,19 @@ function official(sid, { crates: extraCrates = [], blocks: extraBlocks = [], rec
|
||||
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)
|
||||
// few while it only skips the official waypoint: o strictly inside the segment a → p, and o's chosen pointer is p)
|
||||
const nxtP = smooth(spfa(true), true);
|
||||
const cost = new Map([[D, 0]]);
|
||||
const chosen = new Map();
|
||||
const skips = (a, o, p) => {
|
||||
const t = through(a, p);
|
||||
return key(o) !== key(a) && key(o) !== key(p) && (o[0] - a[0]) * (p[1] - a[1]) === (o[1] - a[1]) * (p[0] - a[0]) && t.some((x) => key(x) === key(o));
|
||||
};
|
||||
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)));
|
||||
const useP = 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);
|
||||
cost.set(k, useP ? cp : co);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// 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
|
||||
// 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
|
||||
// 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';
|
||||
@@ -78,8 +78,8 @@ const DEVIATIONS = {
|
||||
'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_m02 boss 2,10>1,17': '2,10 2,11 4,11 4,14 2,14 2,17 1,17',
|
||||
'act1autochess_m02 boss 2,10>2,18': '2,10 2,11 4,11 4,14 2,14 2,18',
|
||||
'act1autochess_m07 unite 12,18>9,2': '12,18 12,11 9,11 9,2',
|
||||
};
|
||||
|
||||
@@ -128,3 +128,55 @@ test('D5: an operator on (9,9), (9,8) or (10,8) blocks the lower-gate enemy on t
|
||||
assert.ok(h.runUntil(() => h.enemies()[0]?.blockedBy === u, 30), `blocked by the operator on (${r},${c})`);
|
||||
}
|
||||
});
|
||||
|
||||
test('战场#02 Final Assault: the (2,10) exit routes mirror each other and an operator beside the exit on (2,9) / (2,11) blocks them', REAL, () => {
|
||||
// the preference route is 0.1.0's (a diagonal step's corner tiles count in its line of sight); only the choice between
|
||||
// it and the official route counts exact crossings — otherwise the right half slipped diagonally past (2,11)
|
||||
const g = stageGrid('act1autochess_m02', GEO.BOSS_RECT);
|
||||
const mirror = (wp) => wp.map(([r, c]) => [r, 20 - c]);
|
||||
assert.deepEqual(g.waypoints(2, 10, 1, 17), mirror(g.waypoints(2, 10, 1, 3)));
|
||||
assert.deepEqual(g.waypoints(2, 10, 2, 18), mirror(g.waypoints(2, 10, 2, 2)));
|
||||
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 });
|
||||
for (const [tile, end] of [[[2, 9], [1, 3]], [[2, 9], [2, 2]], [[2, 11], [1, 17]], [[2, 11], [2, 18]]]) {
|
||||
const h = makeBattle({ stageId: 'act1autochess_m02', kind: 'boss', defs: { chess: { t_guard: guard }, enemies: { enemy_walker: walker } },
|
||||
units: [{ chessId: 't_guard', row: tile[0], col: tile[1] }], enemies: [{ key: 'enemy_walker', route: { motion: 'WALK', start: [2, 10], end, checkpoints: [] } }],
|
||||
content: 'none', autoFinish: false, timeLimit: 60 });
|
||||
const u = h.unit('t_guard');
|
||||
assert.ok(h.runUntil(() => h.enemies()[0]?.blockedBy === u, 20), `the enemy (2,10) → (${end}) is blocked on (${tile})`);
|
||||
assert.ok(u.deployed && u.tileR === tile[0] && u.tileC === tile[1], `operator on (${tile})`);
|
||||
}
|
||||
});
|
||||
|
||||
test('equal floor never bends the official route: one crate on any tile of an active stage leaves every gate route official or crossing strictly fewer non-blockable tiles', REAL, () => {
|
||||
const stages = ['act1autochess_m01', 'act1autochess_m02', 'act1autochess_m03', 'act1autochess_m04', 'act2autochess_m01', 'act2autochess_m02', 'act2autochess_m03', 'act2autochess_m04'];
|
||||
const floor = (g, wp) => {
|
||||
let n = 0;
|
||||
for (let i = 1; i < wp.length; i++) for (const [r, c] of segmentTiles(wp[i - 1], wp[i]).slice(1)) n += g.unblockable[r * COLS + c];
|
||||
return n;
|
||||
};
|
||||
const R = GEO.NORMAL_RECT;
|
||||
let n = 0;
|
||||
for (const sid of stages) {
|
||||
for (let r = R.r0; r <= R.r1; r++) {
|
||||
for (let c = R.c0; c <= R.c1; c++) {
|
||||
const g = stageGrid(sid, R), p = stageGrid(sid, R, true);
|
||||
if (!g.walkable(r, c) || g.obstacle[r * COLS + c]) continue;
|
||||
g.setObstacle(r, c, true, 'crate');
|
||||
p.setObstacle(r, c, true, 'crate');
|
||||
for (const s of [[9, 10], [12, 10]]) {
|
||||
const ours = g.waypoints(s[0], s[1], 9, 2), off = p.waypoints(s[0], s[1], 9, 2);
|
||||
if (!ours || str(ours) === str(off)) continue;
|
||||
n++;
|
||||
assert.ok(floor(g, ours) < floor(g, off), `${sid} crate (${r},${c}) from (${s}): ${str(ours)} vs the official ${str(off)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.ok(n > 0, 'some layouts keep a floor-avoiding deviation');
|
||||
// the case that used to slip through: a crate on (10,6) of 战场#04 — official (9,10) → (9,9) → (11,7), as much floor as
|
||||
// the L via (9,8), so the official one stays
|
||||
const g = stageGrid(M04, R);
|
||||
g.setObstacle(10, 6, true, 'crate');
|
||||
assert.equal(str(g.waypoints(9, 10, 9, 2)), '9,10 9,9 11,7 11,4 9,4 9,2');
|
||||
});
|
||||
Reference in new issue
Block a user