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