feedback7 R22-FIELDS: the path tie-break never wades into more 深水区 than the official route — on 战场#08(下半) the patrolling 铳 / 卢西恩 take the official upper road from (3,9) to (2,3) again (GitHub #375); golden: boss-boss_2-pair, coop2-ABYSS-10-boosted
GitHub #375 (FrogThai): 「水图boss会从下面这条路往蓝门走,实际应该从上路走」. The walking leaders of 战场#08(下半) 涨潮控制 (act2autochess_m04) are 铳 (level_act1autochess_h07_02 route 6; the Hidden Core 铳 on act1autochess_h08_02's patrol routes) and 卢西恩 (act2autochess_h07_05 route 8): PATROL_MOVE (3,9) → (2,3) → (5,9). Measured (notes repro-375.mjs): ours 「3,9 2,9 2,6 4,6 4,4 2,4 2,3」 — the lower road through the 深水区 (3,6) / (4,6) — against the official SPFA 「3,9 5,9 5,4 2,4 2,3」 along row 5 with one 深水区 (5,6); the mirrored (3,11) → (2,17) split the same way. Cause: grid.js _buildField takes the blockable-ground preference pointer (user playtest #2) when its route crosses strictly fewer non-blockable tiles; 深水区 is non-blockable itself (no deployment), so the lower road counted 2 against the upper road's 3 (two floor tiles and one water tile) and won. Official pathfinding is the plain 4-neighbour SPFA (research 08 §3.1). Now the preference pointer is taken only when its route also crosses no more 深水区 than the official pointer's (a per-tile `deep` count beside `cost`, start excluded). Rule from the Grok triage (scratch-i1b/375.patch), re-implemented with a precomputed Grid.deepWater mask (terrain 'deepsea': tile_deepsea and tile_deepwater). Scope (notes dump-fields.mjs / check-official.mjs, every stage × normal / 联防 / boss rect × every walkable destination): 48 fields change, only on the two water maps (act2 m04 boss field; the inactive act1 m05), 149 pointers, every one back to the official pointer. The 21 accepted DEVIATIONS (战场#01 / #02 floor, #07, the inactive #05's 7 water-avoiding routes) are unchanged, as are all 154 audited gate routes and data/stages.json's groundPaths (tools/build-data.mjs --offline into a scratch dir: every data file identical). Other boss-field preference routes of m04 that cross fewer 深水区 than the official one stay. Tests: test/sim/pathing-official.test.js — two new #375 tests (the four patrol legs equal the pure official waypoints; in a real battle the 铳 climbs to row 5 and steps on the 深水区 (5,6) only), both failing on the parent; test/sim/pathing-crosscheck.test.js — the independent reference port gets the same 深水区 rule (it flagged both the patrol leg and a random-crate normal-field route of act2 m04 until then); test/sim/pathing-blockable.test.js — the WALK-leg audit compares with the equal-length routes that cross no more 深水区 than ours (the 14 accepted arena exceptions unchanged). Grid-related suites 777/777. Golden: - fields boss-boss_2-pair (铳 pair on act2autochess_m04, act1autochess_h07_02): the two 铳 patrol the upper roads; leader left 5311560 → 5301010, p1 damage up / p2 down, deaths 0 / 2 → 1 / 3; still 8 / 8 kills. - matches coop2-ABYSS-10-boosted (act2 m04; Final Assault 卢西恩, Hidden Core 铳): still cleared, team LP 800 → 615. The bots' operators stand on the lower rows (the same tiles as before); with the leaders on the upper road the Final Assault lasts 335 game s instead of 247 and the Hidden Core 638 instead of 190, both past the 150 real-second mark: the overtime drain takes 17 + 168 LP (none before), no leak.
This commit is contained in:
1 parent
f14b3a062f
commit
8d896e40ea
10 files changed
+167
-86
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in new issue
Block a user