diff --git a/README.md b/README.md
index fcd63c5..686a5c1 100644
--- a/README.md
+++ b/README.md
@@ -27,6 +27,11 @@ Buildings and resources count as walls (they block the game's pathing too), and
ask for the ground as a small, medium or large unit sees it, since a Siege Tank does not
fit where a Zergling does.
+All of that is an estimate worked out from the minitiles. In the editor at scmjs.dev, the
+panel can also have **the game itself** walk the map (see below): the regions the game
+plans ground routes over, and real units walked between the start locations by the
+game's own pathfinding, with what went wrong on the way.
+
## Install
In scmJS: **Plugins ▸ Manage Plugins…**, paste
@@ -76,16 +81,61 @@ Options:
- **Buildings and resources block the way** treats every building and mineral field or
geyser as a wall, as the game does. Untick it to see the terrain alone.
+## The game's own pathing
+
+Since 1.3.0. Only in the editor at scmjs.dev: it needs the
+[scm-sim](https://github.com/scm-js/plugin-scm-sim) plugin, version 0.7.0 or later, and
+the game engine served beside the editor. A copy of the game from StarEdit's download is
+enough. Elsewhere, for example in the desktop app, the section does not appear.
+
+**Walk the map**, in the panel's *The game's own pathing* section, starts the map in the
+game, out of sight, and walks the ticked units (Probe, Zergling, Dragoon, Siege Tank and
+Ultralisk to begin with) from each start location to every other one. Each start
+location walked from is a game of its own and takes a few seconds: a 4-player map takes
+about ten seconds, an 8-player map about forty. Nobody plays and the map is not changed.
+The game is started on a copy that has a neutral Nexus on every start location and none of
+the map's triggers.
+
+The overlay then shows *The game's regions and walks*:
+
+- **Regions**: one colour per region of the game's, darker at its edges. These are what
+ the game plans ground routes over. Pointing at the map names the region and its ground
+ group (ground of one group is joined).
+- **Walks**: each unit's route in its own colour. A walk that went wrong ends in a red
+ cross.
+
+*What the game found* lists:
+
+- A walk that **stops short**. The game can find no way all the way, or gives up
+ looking on a long route. On Bridge to Bridge '98, every unit sent between two of the
+ start locations stops about 150 tiles away.
+- A walk that **gets stuck**, or is **still walking** after seven minutes.
+- Two start locations the game finds **no ground between**, and whether Walkability's
+ estimate disagrees.
+- A route the game walks **much longer than the shortest ground route** (30 % and ten
+ tiles more).
+- A unit the game **could not place** near a start location (its spot was a cliff edge
+ or too tight for it), so it walked nothing from there.
+
+Click a row to go to it. *Both ways* walks each pair in both directions, since a route is
+not always the same backwards. **Walk between two points** asks for two clicks and walks
+the ticked units between them.
+
+The details panel lists every walk with its time at Fastest. *Copy report* includes them.
+An edit to the map marks the walks out of date; walk the map again to bring them up to date.
+
## Layout
| | |
| --- | --- |
| `plugin.json` | the manifest the editor reads (name, version, `entry`, `icon`, the API version it needs) |
| `plugin.ts` | `activate(api)`: building the grid from the open map, the overlay bitmaps, the overlay (`api.ui.overlay`) and the two panels |
+| `game.ts` | the game's own pathing: the walks matched against the estimate, the findings, the wording (pure, tested) |
+| `vendor/scm-sim.d.ts` | a copy of the scm-sim plugin's service types (`contract.d.ts` at v0.7.0) |
| `analysis.ts` | the pure part: the minitile grid, clearance (an exact Euclidean distance transform), islands, the watershed into areas and chokes, seams, the start-to-start routes, the text report |
| `i18n.ts`, `ko.ts` | the plugin's words in the editor's language: `t()` / `msg()` and the Korean catalogue |
| `dist/plugin.js` | the bundle the editor loads; `npm run build` writes it, CI commits it |
-| `tests/` | vitest over `analysis.ts`, and a check that `ko.ts` has every string the plugin shows |
+| `tests/` | vitest over `analysis.ts` and `game.ts`, and a check that `ko.ts` has every string the plugin shows |
Types come from [`@scm-js/plugin-api`](https://github.com/scm-js/plugin-api), a devDependency
generated from the editor's own `src/plugins/api.ts`; `npm update @scm-js/plugin-api` takes the
diff --git a/dist/plugin.js b/dist/plugin.js
index 2339339..3876a7c 100644
--- a/dist/plugin.js
+++ b/dist/plugin.js
@@ -716,6 +716,81 @@ function report(a, names) {
return lines.join("\n");
}
+// game.ts
+var GAME_UNITS = [64, 37, 66, 5, 39];
+var FASTEST = 1e3 / 42;
+function seconds(frames) {
+ return (frames / FASTEST).toFixed(1);
+}
+var DETOUR_RATIO = 1.3;
+var DETOUR_MIN_PX = 320;
+function estimatePair(a, ownerA, ownerB) {
+ return a.pairs.find((p) => {
+ const x = a.starts[p.a].owner, y = a.starts[p.b].owner;
+ return x === ownerA && y === ownerB || x === ownerB && y === ownerA;
+ }) ?? null;
+}
+function findings(result, analysis) {
+ const out = [];
+ result.walks.forEach((w, i) => {
+ if (w.outcome === "stopped" || w.outcome === "stuck" || w.outcome === "limit") {
+ out.push({ kind: w.outcome, walk: i });
+ return;
+ }
+ if (w.outcome === "apart") {
+ const pair2 = analysis && w.fromStart !== void 0 && w.toStart !== void 0 ? estimatePair(analysis, w.fromStart, w.toStart) : null;
+ out.push({ kind: "apart", walk: i, estimateHasRoute: pair2 ? pair2.ground !== null : void 0 });
+ return;
+ }
+ if (w.outcome !== "arrived" || !analysis || w.fromStart === void 0 || w.toStart === void 0) return;
+ const pair = estimatePair(analysis, w.fromStart, w.toStart);
+ if (!pair || pair.ground === null) return;
+ const extra = w.travelled - pair.ground;
+ if (w.travelled >= pair.ground * DETOUR_RATIO && extra >= DETOUR_MIN_PX) out.push({ kind: "detour", walk: i, extra });
+ });
+ const rank = { stuck: 0, stopped: 1, limit: 2, apart: 3, detour: 4 };
+ return out.sort((x, y) => rank[x.kind] - rank[y.kind] || x.walk - y.walk);
+}
+function outcomes(walks) {
+ const out = {};
+ for (const w of walks) out[w.outcome] = (out[w.outcome] ?? 0) + 1;
+ return out;
+}
+function describeWalk(w) {
+ const tilesOff = Math.round(w.distance / 32);
+ switch (w.outcome) {
+ case "arrived":
+ return t("{s} s", { s: seconds(w.frames) });
+ case "stopped":
+ return t("stopped {n} tiles short", { n: tilesOff });
+ case "stuck":
+ return t("stuck {n} tiles short", { n: tilesOff });
+ case "limit":
+ return t("still walking after {s} s", { s: seconds(w.frames) });
+ case "apart":
+ return t("no ground between");
+ case "unmoved":
+ return t("did not move");
+ }
+}
+function walkFocus(w) {
+ if (w.outcome !== "arrived" && w.x.length) return { x: w.x[w.x.length - 1], y: w.y[w.y.length - 1] };
+ const mid = Math.floor(w.x.length / 2);
+ return w.x.length ? { x: w.x[mid], y: w.y[mid] } : w.from;
+}
+function unplaced(walks, asked, froms) {
+ const out = [];
+ for (const from of froms) {
+ const there = new Set(walks.filter((w) => w.fromStart === from).map((w) => w.unit));
+ for (const unit of asked) if (!there.has(unit)) out.push(from === void 0 ? { unit } : { unit, fromStart: from });
+ }
+ return out;
+}
+function walkedFrom(result, bothWays) {
+ const slots = result.starts.map((s) => s.owner);
+ return bothWays ? slots : slots.slice(0, -1);
+}
+
// ko.ts
var KO = {
"(minitile {x}, {y})": "(\uBBF8\uB2C8\uD0C0\uC77C {x}, {y})",
@@ -835,7 +910,54 @@ var KO = {
"{size} minitiles at {x}, {y}: height {from} meets {to}": "{x}, {y}\uC758 \uBBF8\uB2C8\uD0C0\uC77C {size}\uAC1C: \uB192\uC774 {from}\uACFC(\uC640) {to}\uC774(\uAC00) \uB9DE\uB2FF\uC74C",
"{size} tiles, {chokes, plural, one {# choke} other {# chokes}}": "{size}\uD0C0\uC77C, \uAE38\uBAA9 {chokes}\uAC1C",
"{width} tiles wide at {x}, {y}": "{x}, {y}\uC758 \uD3ED {width}\uD0C0\uC77C",
- "{width} tiles wide at {x}, {y} between {a} and {b}": "{a}\uC640(\uACFC) {b} \uC0AC\uC774, {x}, {y}\uC758 \uD3ED {width}\uD0C0\uC77C"
+ "{width} tiles wide at {x}, {y} between {a} and {b}": "{a}\uC640(\uACFC) {b} \uC0AC\uC774, {x}, {y}\uC758 \uD3ED {width}\uD0C0\uC77C",
+ "The game's regions and walks": "\uAC8C\uC784\uC758 \uC9C0\uC5ED\uACFC \uC774\uB3D9",
+ "This needs version 0.7.0 or later of the scm-sim plugin.": "scm-sim \uD50C\uB7EC\uADF8\uC778 0.7.0 \uC774\uC0C1\uC774 \uD544\uC694\uD569\uB2C8\uB2E4.",
+ "The engine's page cannot work out a map's pathing yet. Reload the editor and try again.": "\uC5D4\uC9C4 \uD398\uC774\uC9C0\uAC00 \uC544\uC9C1 \uB9F5\uC758 \uACBD\uB85C\uB97C \uACC4\uC0B0\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4. \uD3B8\uC9D1\uAE30\uB97C \uC0C8\uB85C \uACE0\uCE5C \uB4A4 \uB2E4\uC2DC \uC2DC\uB3C4\uD558\uC138\uC694.",
+ "{x}, {y}": "{x}, {y}",
+ "{unit}: {from} \u2192 {to}": "{unit}: {from} \u2192 {to}",
+ "Click where the units start": "\uC720\uB2DB\uC774 \uCD9C\uBC1C\uD560 \uACF3\uC744 \uD074\uB9AD\uD558\uC138\uC694",
+ "Click where they should walk to": "\uC720\uB2DB\uC774 \uAC78\uC5B4\uAC08 \uACF3\uC744 \uD074\uB9AD\uD558\uC138\uC694",
+ "Walkability: the game walked {n, plural, one {# route} other {# routes}}, {arrived} arrived, {problems, plural, =0 {no problems} one {# problem} other {# problems}}": "\uC774\uB3D9 \uAC00\uB2A5 \uC9C0\uD615: \uAC8C\uC784\uC774 \uACBD\uB85C {n}\uAC1C\uB97C \uAC78\uC5C8\uC2B5\uB2C8\uB2E4. {arrived}\uAC1C \uB3C4\uCC29, {problems, plural, =0 {\uBB38\uC81C \uC5C6\uC74C} other {\uBB38\uC81C #\uAC1C}}",
+ "Walkability: the game's walk was stopped": "\uC774\uB3D9 \uAC00\uB2A5 \uC9C0\uD615: \uAC8C\uC784\uC758 \uC774\uB3D9\uC744 \uBA48\uCDC4\uC2B5\uB2C8\uB2E4",
+ "{unit} from {from} to {to} stops short": "{from}\uC5D0\uC11C {to|\uC73C\uB85C} \uAC00\uB294 {unit|\uC774} \uB3C4\uC911\uC5D0 \uBA48\uCDA5\uB2C8\uB2E4",
+ "{unit} from {from} to {to} gets stuck": "{from}\uC5D0\uC11C {to|\uC73C\uB85C} \uAC00\uB294 {unit|\uC774} \uAC07\uD799\uB2C8\uB2E4",
+ "{unit} from {from} to {to} is still walking when the time runs out": "{from}\uC5D0\uC11C {to|\uC73C\uB85C} \uAC00\uB294 {unit|\uC774} \uC2DC\uAC04\uC774 \uB2E4 \uB420 \uB54C\uAE4C\uC9C0 \uAC77\uACE0 \uC788\uC2B5\uB2C8\uB2E4",
+ "The game finds no ground between {from} and {to}, though the estimate does": "\uCD94\uC815\uC73C\uB85C\uB294 \uC9C0\uC0C1 \uACBD\uB85C\uAC00 \uC788\uC9C0\uB9CC, \uAC8C\uC784\uC740 {from|\uACFC} {to} \uC0AC\uC774\uC5D0 \uC9C0\uC0C1 \uACBD\uB85C\uB97C \uCC3E\uC9C0 \uBABB\uD569\uB2C8\uB2E4",
+ "No ground between {from} and {to}": "{from|\uACFC} {to} \uC0AC\uC774\uC5D0 \uC9C0\uC0C1 \uACBD\uB85C \uC5C6\uC74C",
+ "{unit} from {from} to {to} walks {n} tiles more than the shortest ground route": "{from}\uC5D0\uC11C {to|\uC73C\uB85C} \uAC00\uB294 {unit|\uC774} \uAC00\uC7A5 \uC9E7\uC740 \uC9C0\uC0C1 \uACBD\uB85C\uBCF4\uB2E4 {n}\uD0C0\uC77C \uB354 \uAC77\uC2B5\uB2C8\uB2E4",
+ "The game's own pathing": "\uAC8C\uC784 \uC790\uCCB4\uC758 \uACBD\uB85C \uD0D0\uC0C9",
+ "{n, plural, one {# walkable region} other {# walkable regions}} of {all}": "\uC9C0\uC5ED {all}\uAC1C \uC911 \uAC78\uC744 \uC218 \uC788\uB294 \uC9C0\uC5ED {n}\uAC1C",
+ "What the game found": "\uAC8C\uC784\uC774 \uCC3E\uC740 \uAC83",
+ "the map was edited after the game walked it": "\uAC8C\uC784\uC774 \uAC78\uC740 \uB4A4 \uB9F5\uC774 \uD3B8\uC9D1\uB428",
+ "the game's region {r}, ground group {g}": "\uAC8C\uC784\uC758 \uC9C0\uC5ED {r}, \uC9C0\uC0C1 \uADF8\uB8F9 {g}",
+ "the game's region {r}, unwalkable": "\uAC8C\uC784\uC758 \uC9C0\uC5ED {r}, \uAC78\uC744 \uC218 \uC5C6\uC74C",
+ "The game's engine is not available here.": "\uC5EC\uAE30\uC11C\uB294 \uAC8C\uC784 \uC5D4\uC9C4\uC744 \uC4F8 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4.",
+ "Asking the game's engine\u2026": "\uAC8C\uC784 \uC5D4\uC9C4\uC5D0 \uBB3B\uB294 \uC911\u2026",
+ "The game itself walks units between the start locations, with its own regions and pathfinding. Each start location walked from takes a few seconds.": "\uAC8C\uC784\uC774 \uC9C1\uC811 \uC790\uCCB4 \uC9C0\uC5ED\uACFC \uACBD\uB85C \uD0D0\uC0C9\uC73C\uB85C \uC2DC\uC791 \uC704\uCE58 \uC0AC\uC774\uC5D0 \uC720\uB2DB\uC744 \uAC77\uAC8C \uD569\uB2C8\uB2E4. \uCD9C\uBC1C\uD558\uB294 \uC2DC\uC791 \uC704\uCE58\uB9C8\uB2E4 \uBA87 \uCD08\uAC00 \uAC78\uB9BD\uB2C8\uB2E4.",
+ "Both ways": "\uC591\uBC29\uD5A5",
+ "Walk from each start location to each other one, not each pair once": "\uAC01 \uC30D\uC744 \uD55C \uBC88\uC529\uC774 \uC544\uB2C8\uB77C, \uAC01 \uC2DC\uC791 \uC704\uCE58\uC5D0\uC11C \uB2E4\uB978 \uBAA8\uB4E0 \uC2DC\uC791 \uC704\uCE58\uB85C \uAC77\uC2B5\uB2C8\uB2E4",
+ "Walk the map": "\uB9F5 \uAC78\uC5B4 \uBCF4\uAE30",
+ "Walk between two points": "\uB450 \uC9C0\uC810 \uC0AC\uC774 \uAC77\uAE30",
+ "Stop": "\uBA48\uCDA4",
+ "Walking {done} of {total}\u2026": "{total}\uAC1C \uC911 {done}\uAC1C \uAC77\uB294 \uC911\u2026",
+ "Starting the game\u2026": "\uAC8C\uC784\uC744 \uC2DC\uC791\uD558\uB294 \uC911\u2026",
+ "{n, plural, one {# walkable region} other {# walkable regions}}": "\uAC78\uC744 \uC218 \uC788\uB294 \uC9C0\uC5ED {n}\uAC1C",
+ "{n, plural, one {# walk} other {# walks}}, {arrived} arrived": "\uC774\uB3D9 {n}\uAC1C, {arrived}\uAC1C \uB3C4\uCC29",
+ "Show": "\uBCF4\uAE30",
+ "Show the game's regions and walks on the map": "\uAC8C\uC784\uC758 \uC9C0\uC5ED\uACFC \uC774\uB3D9\uC744 \uB9F5\uC5D0 \uD45C\uC2DC\uD569\uB2C8\uB2E4",
+ "Every walk arrived, by routes close to the shortest.": "\uBAA8\uB4E0 \uC774\uB3D9\uC774 \uAC00\uC7A5 \uC9E7\uC740 \uACBD\uB85C\uC5D0 \uAC00\uAE5D\uAC8C \uB3C4\uCC29\uD588\uC2B5\uB2C8\uB2E4.",
+ "The game's walks": "\uAC8C\uC784\uC758 \uC774\uB3D9",
+ "{n} tiles": "{n}\uD0C0\uC77C",
+ "{s} s": "{s}\uCD08",
+ "stopped {n} tiles short": "{n}\uD0C0\uC77C \uC55E\uC5D0\uC11C \uBA48\uCDA4",
+ "stuck {n} tiles short": "{n}\uD0C0\uC77C \uC55E\uC5D0\uC11C \uAC07\uD798",
+ "still walking after {s} s": "{s}\uCD08 \uB4A4\uC5D0\uB3C4 \uAC77\uB294 \uC911",
+ "no ground between": "\uC9C0\uC0C1 \uACBD\uB85C \uC5C6\uC74C",
+ "did not move": "\uC6C0\uC9C1\uC774\uC9C0 \uC54A\uC74C",
+ "The game could not place a {unit} at the point picked": "\uAC8C\uC784\uC774 \uACE0\uB978 \uC9C0\uC810\uC5D0 {unit|\uC744} \uB193\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4",
+ "The game could not place a {unit} near {from}'s start location, so it walked nothing from there": "\uAC8C\uC784\uC774 {from}\uC758 \uC2DC\uC791 \uC704\uCE58 \uADFC\uCC98\uC5D0 {unit|\uC744} \uB193\uC9C0 \uBABB\uD574 \uADF8\uACF3\uC5D0\uC11C\uB294 \uC544\uBB34\uAC83\uB3C4 \uAC77\uC9C0 \uC54A\uC558\uC2B5\uB2C8\uB2E4",
+ "not placed": "\uB193\uC9C0 \uBABB\uD568"
};
// plugin.ts
@@ -884,13 +1006,17 @@ var STYLE = `
.wlk .wlk-empty { padding: 4px 8px; color: var(--text-faint, #6b7382); }
.wlk .wlk-keys { color: var(--text-faint, #6b7382); font-size: 11px; line-height: 1.4; }
.wlk .wlk-keys kbd { font-family: inherit; color: var(--text-dim, #99a2b3); }
+.wlk .wlk-game { display: flex; flex-direction: column; gap: 6px; }
+.wlk .wlk-game:empty { display: none; }
+.wlk .wlk-h { font-weight: 600; color: var(--text, #e6e9ef); border-top: 1px solid var(--border, #333); padding-top: 7px; }
`;
var MODES = [
{ id: "areas", label: msg("Areas and chokes") },
{ id: "islands", label: msg("Islands and pockets") },
{ id: "clearance", label: msg("Clearance (room to move)") },
{ id: "height", label: msg("Ground height and seams") },
- { id: "walk", label: msg("Walkable ground") }
+ { id: "walk", label: msg("Walkable ground") },
+ { id: "game", label: msg("The game's regions and walks") }
];
var UNIT_SIZES = [
{ radius: 0, label: msg("Ground as flagged") },
@@ -898,11 +1024,13 @@ var UNIT_SIZES = [
{ radius: 2, label: msg("Medium units (Dragoon, Hydralisk)") },
{ radius: 3, label: msg("Large units (Siege Tank, Ultralisk)") }
];
-var DEFAULTS = { mode: "areas", chokes: true, seams: true, starts: true, labels: true, opacity: 0.45, unitRadius: 1, buildingsBlock: true, minArea: 24, minPocket: 2, maxChoke: 12 };
+var DEFAULTS = { mode: "areas", chokes: true, seams: true, starts: true, labels: true, opacity: 0.45, unitRadius: 1, buildingsBlock: true, minArea: 24, minPocket: 2, maxChoke: 12, gameUnits: [...GAME_UNITS], gameBothWays: false };
function loadSettings(api) {
const stored = api.storage.get("settings", {});
const s = { ...DEFAULTS, ...stored };
if (!MODES.some((m) => m.id === s.mode)) s.mode = DEFAULTS.mode;
+ if (!Array.isArray(s.gameUnits)) s.gameUnits = [...DEFAULTS.gameUnits];
+ s.gameUnits = s.gameUnits.filter((u) => GAME_UNITS.includes(u));
return s;
}
function hsl(hue, s, l) {
@@ -926,6 +1054,8 @@ var playerName = (slot) => t("Player {n}", { n: slot + 1 });
var RED = [255, 80, 70];
var GREY = [40, 40, 48];
var LEVELS = [[70, 120, 255], [80, 200, 120], [250, 210, 90]];
+var UNIT_COLORS = [[240, 240, 240], [255, 214, 10], [40, 200, 255], [255, 140, 30], [255, 70, 210]];
+var unitColor = (unit) => UNIT_COLORS[GAME_UNITS.indexOf(unit)] ?? [240, 240, 240];
var Session = class {
settings;
panel = null;
@@ -950,6 +1080,9 @@ var Session = class {
timer = null;
/** A run is under way: the panels show it (a ring in the status, the last results dimmed under a note). */
running = false;
+ game = { service: null, usable: null, reason: null, result: null, doc: null, stale: false, findings: [], custom: [], unplaced: [], customUnplaced: [], running: null, error: null };
+ /** The game's regions as a picture, a pixel a minitile. */
+ regions = null;
api;
constructor(api) {
this.api = api;
@@ -1066,6 +1199,7 @@ var Session = class {
this.stranded.push({ index, name: api.palette.unitName(u.unitId), x: u.x, y: u.y, island, geyser: u.unitId === GEYSER });
});
}
+ if (this.game.result) this.game.findings = findings(this.game.result, a);
if (this.pick && !this.pickStillValid()) this.pick = null;
this.overlay = null;
this.highlight = null;
@@ -1101,6 +1235,10 @@ var Session = class {
return p.id < a.starts.length;
case "pair":
return p.id < a.pairs.length;
+ case "walk":
+ return p.id < (this.game.result?.walks.length ?? 0);
+ case "custom":
+ return p.id < this.game.custom.length;
}
}
clear() {
@@ -1114,6 +1252,191 @@ var Session = class {
this.redraw();
this.notify();
}
+ /* ── the game's own pathing (the scm-sim plugin's engine) ── */
+ /** The engine came or went (the scm-sim plugin was switched on or off, or updated). */
+ setService(service) {
+ this.game.service = service;
+ this.game.usable = null;
+ this.game.reason = null;
+ if (this.panel?.isOpen()) void this.checkGame();
+ this.notify();
+ }
+ /** Ask the engine whether it can walk a map here. */
+ async checkGame() {
+ const sim = this.game.service;
+ if (!sim || !sim.present) {
+ this.game.usable = false;
+ this.game.reason = null;
+ this.notify();
+ return;
+ }
+ if (typeof sim.pathing !== "function") {
+ this.game.usable = false;
+ this.game.reason = t("This needs version 0.7.0 or later of the scm-sim plugin.");
+ this.notify();
+ return;
+ }
+ const status = await sim.status();
+ this.game.usable = status.available && status.pathing !== null;
+ this.game.reason = !status.available ? status.reason : status.pathing === null ? t("The engine's page cannot work out a map's pathing yet. Reload the editor and try again.") : null;
+ this.notify();
+ }
+ /** A unit's name as the editor gives it. */
+ unitName(unit) {
+ return this.api.palette.unitName(unit);
+ }
+ /** Where a walk went between, in words: the two start locations' players, or two tiles. */
+ walkLabel(w) {
+ const end = (slot, p) => slot !== void 0 ? playerName(slot) : t("{x}, {y}", { x: pxTiles(p.x), y: pxTiles(p.y) });
+ return t("{unit}: {from} \u2192 {to}", { unit: this.unitName(w.unit), from: end(w.fromStart, w.from), to: end(w.toStart, w.to) });
+ }
+ /** Have the game walk the map: the ticked units between every pair of start locations. */
+ async walkMap() {
+ await this.walkGame("map");
+ }
+ /** Have the game walk the ticked units between two tiles the person clicks. */
+ async walkPoints() {
+ const from = await this.api.ui.pickTile({ prompt: t("Click where the units start") });
+ if (!from) return;
+ const to = await this.api.ui.pickTile({ prompt: t("Click where they should walk to") });
+ if (!to) return;
+ const centre = (p) => ({ x: p.x * 32 + 16, y: p.y * 32 + 16 });
+ await this.walkGame("points", centre(from), centre(to));
+ }
+ async walkGame(what, from, to) {
+ const sim = this.game.service;
+ if (!sim?.pathing || this.game.running) return;
+ if (!this.api.document.isOpen()) {
+ this.api.ui.status(t("Walkability: open a map first"));
+ return;
+ }
+ const units = this.settings.gameUnits.length ? this.settings.gameUnits : [...GAME_UNITS];
+ const stop = new AbortController();
+ const running = { done: 0, total: 0, stop, what };
+ this.game.running = running;
+ this.game.error = null;
+ this.notify();
+ try {
+ const options = { units, signal: stop.signal, onProgress: (p) => {
+ running.done = p.done;
+ running.total = p.total;
+ this.notify();
+ } };
+ if (what === "map") {
+ if (!this.analysis || this.stale) await this.run();
+ const result = await sim.pathing(void 0, { ...options, directed: this.settings.gameBothWays });
+ this.game.result = result;
+ this.game.doc = this.api.document.id();
+ this.game.stale = false;
+ this.game.findings = findings(result, this.analysis);
+ this.game.unplaced = unplaced(result.walks, units, walkedFrom(result, this.settings.gameBothWays));
+ this.regions = null;
+ if (this.pick?.kind === "walk") this.pick = null;
+ const n = outcomes(result.walks);
+ this.api.ui.status(t(
+ "Walkability: the game walked {n, plural, one {# route} other {# routes}}, {arrived} arrived, {problems, plural, =0 {no problems} one {# problem} other {# problems}}",
+ { n: result.walks.length, arrived: n.arrived ?? 0, problems: this.game.findings.length }
+ ));
+ if (this.settings.mode !== "game") {
+ this.settings.mode = "game";
+ this.save();
+ this.overlay = null;
+ }
+ if (!this.active) this.show();
+ } else {
+ const result = await sim.pathing(void 0, { ...options, from, to: [to], regions: false });
+ this.game.custom = result.walks;
+ this.game.customUnplaced = unplaced(result.walks, units, [void 0]).map((u) => u.unit);
+ if (this.game.doc === null) this.game.doc = this.api.document.id();
+ this.select(result.walks.length ? { kind: "custom", id: 0 } : null);
+ if (!this.active) this.show();
+ }
+ } catch (err) {
+ if (err instanceof Error && err.name === "AbortError") this.api.ui.status(t("Walkability: the game's walk was stopped"));
+ else this.game.error = err instanceof Error ? err.message : String(err);
+ } finally {
+ this.game.running = null;
+ this.redraw();
+ this.notify();
+ }
+ }
+ stopGame() {
+ this.game.running?.stop.abort();
+ }
+ /** The map was edited: the game's walks are of the map as it was. */
+ gameEdited() {
+ if (!this.game.result && !this.game.custom.length) return;
+ if (!this.game.stale) {
+ this.game.stale = true;
+ this.notify();
+ }
+ }
+ /** Another map is in front, or none: the walks were of the one before. */
+ clearGame() {
+ this.game.running?.stop.abort();
+ this.game.result = null;
+ this.game.custom = [];
+ this.game.findings = [];
+ this.game.unplaced = [];
+ this.game.customUnplaced = [];
+ this.game.doc = null;
+ this.game.stale = false;
+ this.game.error = null;
+ this.regions = null;
+ if (this.pick?.kind === "walk" || this.pick?.kind === "custom") this.pick = null;
+ if (this.settings.mode === "game") this.overlay = null;
+ this.redraw();
+ this.notify();
+ }
+ /** A finding in words, for the panel's list and the report. */
+ findingText(f) {
+ const w = this.game.result.walks[f.walk];
+ const between = { unit: this.unitName(w.unit), from: w.fromStart !== void 0 ? playerName(w.fromStart) : "?", to: w.toStart !== void 0 ? playerName(w.toStart) : "?" };
+ switch (f.kind) {
+ case "stopped":
+ return { label: t("{unit} from {from} to {to} stops short", between), hint: describeWalk(w) };
+ case "stuck":
+ return { label: t("{unit} from {from} to {to} gets stuck", between), hint: describeWalk(w) };
+ case "limit":
+ return { label: t("{unit} from {from} to {to} is still walking when the time runs out", between), hint: describeWalk(w) };
+ case "apart":
+ return {
+ label: f.estimateHasRoute ? t("The game finds no ground between {from} and {to}, though the estimate does", between) : t("No ground between {from} and {to}", between),
+ hint: this.unitName(w.unit)
+ };
+ case "detour":
+ return { label: t("{unit} from {from} to {to} walks {n} tiles more than the shortest ground route", { ...between, n: Math.round((f.extra ?? 0) / 32) }), hint: describeWalk(w) };
+ }
+ }
+ /** A unit the game left out of the walks from a start location, in words. */
+ unplacedText(u) {
+ return t("The game could not place a {unit} near {from}'s start location, so it walked nothing from there", { unit: this.unitName(u.unit), from: playerName(u.fromStart ?? 0) });
+ }
+ /** The game's walks as lines of text, for the report. */
+ gameReport() {
+ const g = this.game.result;
+ if (!g && !this.game.custom.length) return [];
+ const lines = ["", t("The game's own pathing")];
+ if (g) {
+ if (g.regions) lines.push(` ${t("{n, plural, one {# walkable region} other {# walkable regions}} of {all}", { n: g.regions.walkable.reduce((n, w) => n + w, 0), all: g.regions.count })}`);
+ for (const w of g.walks) lines.push(` ${this.walkLabel(w)}: ${describeWalk(w)}`);
+ if (this.game.findings.length || this.game.unplaced.length) {
+ lines.push("", t("What the game found"));
+ for (const u of this.game.unplaced) lines.push(` ${this.unplacedText(u)}`);
+ for (const f of this.game.findings) lines.push(` ${this.findingText(f).label}`);
+ }
+ }
+ for (const w of this.game.custom) lines.push(` ${this.walkLabel(w)}: ${describeWalk(w)}`);
+ for (const u of this.game.customUnplaced) lines.push(` ${t("The game could not place a {unit} at the point picked", { unit: this.unitName(u) })}`);
+ if (this.game.stale) lines.push(` (${t("the map was edited after the game walked it")})`);
+ return lines;
+ }
+ /** The walk a pick names. */
+ walkOf(pick) {
+ if (pick?.kind === "walk") return this.game.result?.walks[pick.id] ?? null;
+ if (pick?.kind === "custom") return this.game.custom[pick.id] ?? null;
+ return null;
+ }
/* ── the overlay: the picture and the readout ── */
show() {
this.view?.show();
@@ -1209,6 +1532,15 @@ var Session = class {
else this.api.view.goTo({ kind: "unit", index: a.starts[p.a].index });
break;
}
+ case "walk":
+ case "custom": {
+ const w = this.walkOf(pick);
+ if (w) {
+ const f = walkFocus(w);
+ this.api.view.center(Math.floor(f.x / 32), Math.floor(f.y / 32));
+ }
+ break;
+ }
}
if (!this.active) this.show();
this.select(pick);
@@ -1237,6 +1569,11 @@ var Session = class {
const ar = a.areas[area];
parts.push(`${t("Area {n}", { n: area + 1 })} (${t("{size} tiles, {chokes, plural, one {# choke} other {# chokes}}", { size: tiles(ar.size / CELLS_PER_TILE), chokes: ar.chokes.length })})`);
}
+ const g = this.game.result?.regions;
+ if (g && this.settings.mode === "game" && x < g.width && y < g.height) {
+ const r = g.grid[y * g.width + x];
+ parts.push(g.walkable[r] ? t("the game's region {r}, ground group {g}", { r, g: g.group[r] }) : t("the game's region {r}, unwalkable", { r }));
+ }
return parts.join(" \xB7 ");
}
/* ── drawing ── */
@@ -1309,6 +1646,37 @@ var Session = class {
ctx.putImageData(img, 0, 0);
return canvas;
}
+ /** The game's regions, a pixel a minitile: each walkable region its own colour, darker at its edges; unwalkable ground clear. */
+ buildRegions() {
+ const g = this.game.result?.regions;
+ if (!g) return null;
+ const canvas = document.createElement("canvas");
+ canvas.width = g.width;
+ canvas.height = g.height;
+ const ctx = canvas.getContext("2d");
+ const img = ctx.createImageData(g.width, g.height);
+ const d = img.data;
+ const colors = /* @__PURE__ */ new Map();
+ for (let y = 0; y < g.height; y++) {
+ for (let x = 0; x < g.width; x++) {
+ const i = y * g.width + x, r = g.grid[i];
+ if (!g.walkable[r]) continue;
+ let c = colors.get(r);
+ if (!c) {
+ c = idColor(r, 0.6, 0.5);
+ colors.set(r, c);
+ }
+ const edge = x > 0 && g.grid[i - 1] !== r || y > 0 && g.grid[i - g.width] !== r || x + 1 < g.width && g.grid[i + 1] !== r || y + 1 < g.height && g.grid[i + g.width] !== r;
+ const k = edge ? 0.45 : 1;
+ d[i * 4] = c[0] * k;
+ d[i * 4 + 1] = c[1] * k;
+ d[i * 4 + 2] = c[2] * k;
+ d[i * 4 + 3] = edge ? 235 : 150;
+ }
+ }
+ ctx.putImageData(img, 0, 0);
+ return canvas;
+ }
buildHighlight() {
const a = this.analysis;
const p = this.pick;
@@ -1343,11 +1711,11 @@ var Session = class {
if (x1 <= x0 || y1 <= y0) return;
const dx = view.x(x0 * CELL_PX);
const dy = view.y(y0 * CELL_PX);
- this.overlay ??= this.buildOverlay();
+ const picture = this.settings.mode === "game" ? this.regions ??= this.buildRegions() : this.overlay ??= this.buildOverlay();
ctx.save();
ctx.imageSmoothingEnabled = false;
ctx.globalAlpha = this.settings.opacity;
- ctx.drawImage(this.overlay, x0, y0, x1 - x0, y1 - y0, dx, dy, (x1 - x0) * cellPx, (y1 - y0) * cellPx);
+ if (picture) ctx.drawImage(picture, x0, y0, x1 - x0, y1 - y0, dx, dy, (x1 - x0) * cellPx, (y1 - y0) * cellPx);
if (this.pick) {
this.highlight ??= this.buildHighlight();
if (this.highlight) {
@@ -1441,12 +1809,66 @@ var Session = class {
ctx.stroke();
ctx.setLineDash([]);
}
+ this.drawWalks(ctx, view, showLabels, label);
if (this.hover) {
ctx.strokeStyle = "#fff";
ctx.lineWidth = 1;
ctx.strokeRect(Math.round(view.x(this.hover.x * CELL_PX)) + 0.5, Math.round(view.y(this.hover.y * CELL_PX)) + 0.5, Math.max(1, Math.round(cellPx) - 1), Math.max(1, Math.round(cellPx) - 1));
}
}
+ /**
+ * The game's walks: all of them in the game's view, else only a picked one; the person's own
+ * walks always. A route in its unit's colour, a picked one on top in a white casing, and a
+ * cross where a walk that went wrong ended.
+ */
+ drawWalks(ctx, view, showLabels, label) {
+ const walks = [];
+ const isPicked = (kind, i) => this.pick?.kind === kind && this.pick.id === i;
+ if (this.settings.mode === "game") (this.game.result?.walks ?? []).forEach((w, i) => walks.push({ w, picked: isPicked("walk", i) }));
+ else if (this.pick?.kind === "walk") {
+ const w = this.walkOf(this.pick);
+ if (w) walks.push({ w, picked: true });
+ }
+ this.game.custom.forEach((w, i) => walks.push({ w, picked: isPicked("custom", i) }));
+ if (!walks.length) return;
+ walks.sort((p, q) => Number(p.picked) - Number(q.picked));
+ ctx.save();
+ ctx.lineJoin = "round";
+ ctx.lineCap = "round";
+ for (const { w, picked } of walks) {
+ if (w.x.length < 2) continue;
+ const path = () => {
+ ctx.beginPath();
+ ctx.moveTo(view.x(w.x[0]), view.y(w.y[0]));
+ for (let k = 1; k < w.x.length; k++) ctx.lineTo(view.x(w.x[k]), view.y(w.y[k]));
+ };
+ if (picked) {
+ ctx.strokeStyle = "#fff";
+ ctx.lineWidth = 6;
+ path();
+ ctx.stroke();
+ }
+ ctx.strokeStyle = rgb(unitColor(w.unit));
+ ctx.lineWidth = picked ? 3 : 1.5;
+ ctx.globalAlpha = picked || walks.length < 12 ? 1 : 0.8;
+ path();
+ ctx.stroke();
+ ctx.globalAlpha = 1;
+ if (w.outcome !== "arrived") {
+ const ex = view.x(w.x[w.x.length - 1]), ey = view.y(w.y[w.y.length - 1]), r = Math.max(5, view.tilePx * 0.6);
+ ctx.strokeStyle = "#ff5a4a";
+ ctx.lineWidth = 3;
+ ctx.beginPath();
+ ctx.moveTo(ex - r, ey - r);
+ ctx.lineTo(ex + r, ey + r);
+ ctx.moveTo(ex + r, ey - r);
+ ctx.lineTo(ex - r, ey + r);
+ ctx.stroke();
+ if (showLabels && picked) label(`${this.unitName(w.unit)}: ${describeWalk(w)}`, ex, ey - r - 10, "#ff8a7a");
+ }
+ }
+ ctx.restore();
+ }
/** Stranded resources grouped by island, largest island first (-1 last). */
strandedByIsland() {
const groups = /* @__PURE__ */ new Map();
@@ -1468,10 +1890,11 @@ var Session = class {
const counts = { patches: group.patches, geysers: group.geysers };
extra.push(` ${island < 0 ? t("{patches, plural, one {# mineral field} other {# mineral fields}} and {geysers, plural, one {# geyser} other {# geysers}} with no walkable ground around them: no start location reaches them by ground", counts) : t("{patches, plural, one {# mineral field} other {# mineral fields}} and {geysers, plural, one {# geyser} other {# geysers}} on island {n}: no start location reaches them by ground", { ...counts, n: island + 1 })}`);
}
- const full = extra.length ? `${text}
+ const full = (extra.length ? `${text}
${t("Problems")}
-${extra.join("\n")}` : text;
+${extra.join("\n")}` : text) + this.gameReport().map((l) => `
+${l}`).join("");
void navigator.clipboard?.writeText(full).then(
() => this.api.ui.status(t("Walkability: report copied")),
() => this.api.ui.status(t("Walkability: the browser refused the clipboard"))
@@ -1582,13 +2005,99 @@ function mountPanel(session, body) {
root.append(h("div", { className: "wlk-top" }, pickBtn));
const results = h("div", { style: "display:flex;flex-direction:column;gap:6px" });
root.append(results);
+ const gameBox = h("div", { className: "wlk-game" });
+ root.append(gameBox);
const keys = t("The overlay stays on while you work on any layer and follows every edit; View \u25B8 Walkability, the Layers panel or {key} switch it off and on.").split("{key}");
root.append(h("div", { className: "wlk-keys" }, keys[0], h("kbd", null, "Ctrl+Shift+W"), keys[1] ?? ""));
- const openSections = /* @__PURE__ */ new Set(["problems"]);
+ const openSections = /* @__PURE__ */ new Set(["problems", "game"]);
let cover = null;
+ if (session.game.service && session.game.usable === null) void session.checkGame();
+ function renderGame() {
+ const g = session.game;
+ gameBox.replaceChildren();
+ if (!g.service?.present) return;
+ gameBox.append(h("div", { className: "wlk-h" }, t("The game's own pathing")));
+ if (g.usable === false) {
+ gameBox.append(h("div", { className: "wlk-status" }, g.reason ?? t("The game's engine is not available here.")));
+ return;
+ }
+ if (g.usable === null) {
+ gameBox.append(W.spinner({ size: "sm", label: t("Asking the game's engine\u2026") }));
+ return;
+ }
+ gameBox.append(h("div", { className: "wlk-status" }, t("The game itself walks units between the start locations, with its own regions and pathfinding. Each start location walked from takes a few seconds.")));
+ const ticks = h("div", { className: "wlk-ticks" });
+ for (const unit of GAME_UNITS) {
+ ticks.append(W.checkbox(session.unitName(unit), {
+ value: s.gameUnits.includes(unit),
+ onChange: (v) => {
+ s.gameUnits = GAME_UNITS.filter((u) => u === unit ? v : s.gameUnits.includes(u));
+ session.save();
+ }
+ }));
+ }
+ ticks.append(W.checkbox(t("Both ways"), { value: s.gameBothWays, title: t("Walk from each start location to each other one, not each pair once"), onChange: (v) => {
+ s.gameBothWays = v;
+ session.save();
+ } }));
+ gameBox.append(ticks);
+ const running = g.running;
+ const walkBtn = W.button(t("Walk the map"), { primary: true, disabled: running !== null, onClick: () => void session.walkMap() });
+ const pointsBtn = W.button(t("Walk between two points"), { ghost: true, disabled: running !== null, onClick: () => void session.walkPoints() });
+ gameBox.append(h("div", { className: "wlk-top" }, walkBtn, pointsBtn, running ? W.button(t("Stop"), { ghost: true, onClick: () => session.stopGame() }) : null));
+ if (running) {
+ gameBox.append(W.spinner({ size: "sm", label: running.total ? t("Walking {done} of {total}\u2026", { done: running.done, total: running.total }) : t("Starting the game\u2026") }));
+ return;
+ }
+ if (g.error) gameBox.append(h("div", { className: "wlk-status", style: "color:#ff9f7a" }, g.error));
+ const r = g.result;
+ if (r) {
+ const n = outcomes(r.walks);
+ const regions = r.regions ? r.regions.walkable.reduce((k, w) => k + w, 0) : null;
+ const line = [
+ regions !== null ? t("{n, plural, one {# walkable region} other {# walkable regions}}", { n: regions }) : null,
+ t("{n, plural, one {# walk} other {# walks}}, {arrived} arrived", { n: r.walks.length, arrived: n.arrived ?? 0 }),
+ g.stale ? t("out of date") : null
+ ].filter(Boolean).join(" \xB7 ");
+ const showBtn = s.mode === "game" ? null : W.button(t("Show"), { ghost: true, title: t("Show the game's regions and walks on the map"), onClick: () => {
+ s.mode = "game";
+ session.save();
+ session.overlay = null;
+ session.show();
+ session.redraw();
+ session.notify();
+ } });
+ gameBox.append(h("div", { className: "wlk-top" }, h("span", { className: "wlk-status", style: "flex:1" }, line), showBtn));
+ const is = (i) => session.pick?.kind === "walk" && session.pick.id === i;
+ const startPick = (slot) => {
+ const i = session.analysis?.starts.findIndex((st) => st.owner === slot) ?? -1;
+ return i >= 0 ? { kind: "start", id: i } : null;
+ };
+ const rows = [
+ ...g.unplaced.map((u) => ({ label: session.unplacedText(u), hint: t("not placed"), bad: true, color: rgb(unitColor(u.unit)), pick: startPick(u.fromStart) })),
+ ...g.findings.map((f) => ({ ...session.findingText(f), bad: f.kind !== "detour", color: rgb(unitColor(r.walks[f.walk].unit)), on: is(f.walk), pick: { kind: "walk", id: f.walk } }))
+ ];
+ gameBox.append(section(session, openSections, "game", t("What the game found"), rows.length, rows, t("Every walk arrived, by routes close to the shortest."), void 0));
+ }
+ g.custom.forEach((w, i) => {
+ const on = session.pick?.kind === "custom" && session.pick.id === i;
+ gameBox.append(h(
+ "div",
+ { className: `wlk-item${on ? " on" : ""}${w.outcome !== "arrived" ? " bad" : ""}`, onClick: () => session.goTo({ kind: "custom", id: i }) },
+ h("span", { className: "wlk-sw", style: `background:${rgb(unitColor(w.unit))}` }),
+ h("span", { className: "wlk-grow" }, session.walkLabel(w)),
+ h("span", { className: "wlk-hint" }, describeWalk(w))
+ ));
+ });
+ for (const u of g.customUnplaced) {
+ gameBox.append(h("div", { className: "wlk-status", style: "color:#ff9f7a" }, t("The game could not place a {unit} at the point picked", { unit: session.unitName(u) })));
+ }
+ }
function render() {
+ renderGame();
const a = session.analysis;
shown.input.checked = session.active;
+ modeSel.value = s.mode;
if (session.running) {
status.replaceChildren(W.spinner({ size: "sm", label: a ? t("Reading the map again\u2026") : t("Reading the map\u2026") }));
if (results.childElementCount) cover ??= W.busy(results, t("Reading\u2026"));
@@ -1710,6 +2219,17 @@ function mountDetails(session, body) {
on: is("area", ar.id),
pick: { kind: "area", id: ar.id }
})), t("No areas."));
+ const game = session.game.result;
+ if (game?.walks.length) {
+ list("walks", t("The game's walks"), game.walks.length, game.walks.map((w, i) => ({
+ label: session.walkLabel(w),
+ hint: `${describeWalk(w)}${w.outcome === "arrived" ? ` \xB7 ${t("{n} tiles", { n: Math.round(w.travelled / 32) })}` : ""}`,
+ color: rgb(unitColor(w.unit)),
+ bad: w.outcome !== "arrived",
+ on: is("walk", i),
+ pick: { kind: "walk", id: i }
+ })), "", session.game.stale ? t("out of date") : void 0);
+ }
list("chokes", t("Chokes"), chokes.length, chokes.map((c) => ({
label: t("{width} tiles wide at {x}, {y}", { width: tiles(c.width), x: tiles(c.x), y: tiles(c.y) }),
hint: t("area {a} \u2194 {b}", { a: c.a + 1, b: c.b + 1 }),
@@ -1782,10 +2302,15 @@ function activate(api) {
api.menu.add("Tools", { label: msg("Walkability\u2026"), enabled: () => api.document.isOpen(), command: "open" });
api.contextMenu.add("viewport", { label: msg("Walkability overlay"), command: "toggle" });
api.hotkeys.add("Ctrl+Shift+W", { command: "toggle" });
- for (const event of ["terrain", "units", "doodads", "settings"]) api.events.on(event, () => session.schedule());
+ api.services.watch("scm-sim.engine", (service) => session.setService(service));
+ for (const event of ["terrain", "units", "doodads", "settings"]) api.events.on(event, () => {
+ session.schedule();
+ session.gameEdited();
+ });
api.events.on("document", () => {
if (!api.document.isOpen()) session.clear();
else session.schedule();
+ if (session.game.doc !== null && session.game.doc !== api.document.id()) session.clearGame();
});
api.events.on("language", () => {
const reopen = (handle, openIt) => {
diff --git a/game.ts b/game.ts
new file mode 100644
index 0000000..96a79f3
--- /dev/null
+++ b/game.ts
@@ -0,0 +1,118 @@
+/**
+ * The game's own pathing, as the scm-sim plugin's engine answers it (`vendor/scm-sim.d.ts`,
+ * `pathing()`): what Walkability makes of the answer. The engine walks real units between the
+ * start locations with the game's own code; here the walks are matched with Walkability's
+ * own estimate of each route and turned into findings for the panels and the report. Pure,
+ * with its own tests; the editor side is in `plugin.ts`.
+ */
+import type { PathingResult, PathingWalk } from "./vendor/scm-sim";
+import type { Analysis } from "./analysis";
+import { t } from "./i18n";
+
+/** The units the game can walk here (units.dat ids): Probe, Zergling, Dragoon, Siege Tank, Ultralisk. Their names come from the editor. */
+export const GAME_UNITS: readonly number[] = [64, 37, 66, 5, 39];
+
+/** Frames a second at Fastest, the speed games are played at (42 ms a frame). */
+export const FASTEST = 1000 / 42;
+
+/** A walk's time at Fastest, in seconds with one decimal. */
+export function seconds(frames: number): string {
+ return (frames / FASTEST).toFixed(1);
+}
+
+/** A route the game walks at least this much longer than Walkability's shortest ground route is called a detour. */
+export const DETOUR_RATIO = 1.3;
+/** …and only when the difference is at least this many pixels (ten tiles): short routes vary by more than a ratio says. */
+export const DETOUR_MIN_PX = 320;
+
+export type FindingKind = "stopped" | "stuck" | "limit" | "detour" | "apart";
+
+/** Something about a walk worth a map maker's attention. `walk` indexes `PathingResult.walks`. */
+export interface Finding {
+ kind: FindingKind;
+ walk: number;
+ /** For a detour: the pixels the game walked over the estimate's shortest ground route. */
+ extra?: number;
+ /** For `apart`: whether Walkability's estimate found a ground route all the same (the two disagree). */
+ estimateHasRoute?: boolean;
+}
+
+/** The estimate's pair between the two start locations of player slots `a` and `b`, either way round. */
+export function estimatePair(a: Analysis, ownerA: number, ownerB: number) {
+ return a.pairs.find((p) => {
+ const x = a.starts[p.a].owner, y = a.starts[p.b].owner;
+ return (x === ownerA && y === ownerB) || (x === ownerB && y === ownerA);
+ }) ?? null;
+}
+
+/**
+ * The findings, worst first: walks that stopped short, got stuck or ran out of time, starts
+ * the game finds no ground between, and routes the game walks much longer than the shortest
+ * ground route Walkability estimates (`analysis`, when there is one).
+ */
+export function findings(result: PathingResult, analysis: Analysis | null): Finding[] {
+ const out: Finding[] = [];
+ result.walks.forEach((w, i) => {
+ if (w.outcome === "stopped" || w.outcome === "stuck" || w.outcome === "limit") { out.push({ kind: w.outcome, walk: i }); return; }
+ if (w.outcome === "apart") {
+ const pair = analysis && w.fromStart !== undefined && w.toStart !== undefined ? estimatePair(analysis, w.fromStart, w.toStart) : null;
+ out.push({ kind: "apart", walk: i, estimateHasRoute: pair ? pair.ground !== null : undefined });
+ return;
+ }
+ if (w.outcome !== "arrived" || !analysis || w.fromStart === undefined || w.toStart === undefined) return;
+ const pair = estimatePair(analysis, w.fromStart, w.toStart);
+ if (!pair || pair.ground === null) return;
+ const extra = w.travelled - pair.ground;
+ if (w.travelled >= pair.ground * DETOUR_RATIO && extra >= DETOUR_MIN_PX) out.push({ kind: "detour", walk: i, extra });
+ });
+ const rank: Record = { stuck: 0, stopped: 1, limit: 2, apart: 3, detour: 4 };
+ return out.sort((x, y) => rank[x.kind] - rank[y.kind] || x.walk - y.walk);
+}
+
+/** How many walks ended each way. */
+export function outcomes(walks: readonly PathingWalk[]): Record {
+ const out: Record = {};
+ for (const w of walks) out[w.outcome] = (out[w.outcome] ?? 0) + 1;
+ return out;
+}
+
+/** A walk's outcome in words, for a list's hint. */
+export function describeWalk(w: PathingWalk): string {
+ const tilesOff = Math.round(w.distance / 32);
+ switch (w.outcome) {
+ case "arrived": return t("{s} s", { s: seconds(w.frames) });
+ case "stopped": return t("stopped {n} tiles short", { n: tilesOff });
+ case "stuck": return t("stuck {n} tiles short", { n: tilesOff });
+ case "limit": return t("still walking after {s} s", { s: seconds(w.frames) });
+ case "apart": return t("no ground between");
+ case "unmoved": return t("did not move");
+ }
+}
+
+/** Where to look at a walk: where it ended when it went wrong, else its middle. */
+export function walkFocus(w: PathingWalk): { x: number; y: number } {
+ if (w.outcome !== "arrived" && w.x.length) return { x: w.x[w.x.length - 1], y: w.y[w.y.length - 1] };
+ const mid = Math.floor(w.x.length / 2);
+ return w.x.length ? { x: w.x[mid], y: w.y[mid] } : w.from;
+}
+
+/**
+ * The units the game did not place: the engine stands the test units near each point walked
+ * from, and the game leaves out a unit whose spot it would not take (a cliff edge, too tight
+ * for its size), so it has no walks from there. `froms` are the start locations walked from
+ * (their player slots), or `undefined` for the person's own point.
+ */
+export function unplaced(walks: readonly PathingWalk[], asked: readonly number[], froms: readonly (number | undefined)[]): { unit: number; fromStart?: number }[] {
+ const out: { unit: number; fromStart?: number }[] = [];
+ for (const from of froms) {
+ const there = new Set(walks.filter((w) => w.fromStart === from).map((w) => w.unit));
+ for (const unit of asked) if (!there.has(unit)) out.push(from === undefined ? { unit } : { unit, fromStart: from });
+ }
+ return out;
+}
+
+/** The start locations a walk of the map goes from: each but the last, or every one when walked both ways. */
+export function walkedFrom(result: PathingResult, bothWays: boolean): number[] {
+ const slots = result.starts.map((s) => s.owner);
+ return bothWays ? slots : slots.slice(0, -1);
+}
diff --git a/ko.ts b/ko.ts
index b6c0f3a..476479d 100644
--- a/ko.ts
+++ b/ko.ts
@@ -119,4 +119,51 @@ export const KO: Record = {
"{size} tiles, {chokes, plural, one {# choke} other {# chokes}}": "{size}타일, 길목 {chokes}개",
"{width} tiles wide at {x}, {y}": "{x}, {y}의 폭 {width}타일",
"{width} tiles wide at {x}, {y} between {a} and {b}": "{a}와(과) {b} 사이, {x}, {y}의 폭 {width}타일",
+ "The game's regions and walks": "게임의 지역과 이동",
+ "This needs version 0.7.0 or later of the scm-sim plugin.": "scm-sim 플러그인 0.7.0 이상이 필요합니다.",
+ "The engine's page cannot work out a map's pathing yet. Reload the editor and try again.": "엔진 페이지가 아직 맵의 경로를 계산할 수 없습니다. 편집기를 새로 고친 뒤 다시 시도하세요.",
+ "{x}, {y}": "{x}, {y}",
+ "{unit}: {from} → {to}": "{unit}: {from} → {to}",
+ "Click where the units start": "유닛이 출발할 곳을 클릭하세요",
+ "Click where they should walk to": "유닛이 걸어갈 곳을 클릭하세요",
+ "Walkability: the game walked {n, plural, one {# route} other {# routes}}, {arrived} arrived, {problems, plural, =0 {no problems} one {# problem} other {# problems}}": "이동 가능 지형: 게임이 경로 {n}개를 걸었습니다. {arrived}개 도착, {problems, plural, =0 {문제 없음} other {문제 #개}}",
+ "Walkability: the game's walk was stopped": "이동 가능 지형: 게임의 이동을 멈췄습니다",
+ "{unit} from {from} to {to} stops short": "{from}에서 {to|으로} 가는 {unit|이} 도중에 멈춥니다",
+ "{unit} from {from} to {to} gets stuck": "{from}에서 {to|으로} 가는 {unit|이} 갇힙니다",
+ "{unit} from {from} to {to} is still walking when the time runs out": "{from}에서 {to|으로} 가는 {unit|이} 시간이 다 될 때까지 걷고 있습니다",
+ "The game finds no ground between {from} and {to}, though the estimate does": "추정으로는 지상 경로가 있지만, 게임은 {from|과} {to} 사이에 지상 경로를 찾지 못합니다",
+ "No ground between {from} and {to}": "{from|과} {to} 사이에 지상 경로 없음",
+ "{unit} from {from} to {to} walks {n} tiles more than the shortest ground route": "{from}에서 {to|으로} 가는 {unit|이} 가장 짧은 지상 경로보다 {n}타일 더 걷습니다",
+ "The game's own pathing": "게임 자체의 경로 탐색",
+ "{n, plural, one {# walkable region} other {# walkable regions}} of {all}": "지역 {all}개 중 걸을 수 있는 지역 {n}개",
+ "What the game found": "게임이 찾은 것",
+ "the map was edited after the game walked it": "게임이 걸은 뒤 맵이 편집됨",
+ "the game's region {r}, ground group {g}": "게임의 지역 {r}, 지상 그룹 {g}",
+ "the game's region {r}, unwalkable": "게임의 지역 {r}, 걸을 수 없음",
+ "The game's engine is not available here.": "여기서는 게임 엔진을 쓸 수 없습니다.",
+ "Asking the game's engine…": "게임 엔진에 묻는 중…",
+ "The game itself walks units between the start locations, with its own regions and pathfinding. Each start location walked from takes a few seconds.": "게임이 직접 자체 지역과 경로 탐색으로 시작 위치 사이에 유닛을 걷게 합니다. 출발하는 시작 위치마다 몇 초가 걸립니다.",
+ "Both ways": "양방향",
+ "Walk from each start location to each other one, not each pair once": "각 쌍을 한 번씩이 아니라, 각 시작 위치에서 다른 모든 시작 위치로 걷습니다",
+ "Walk the map": "맵 걸어 보기",
+ "Walk between two points": "두 지점 사이 걷기",
+ "Stop": "멈춤",
+ "Walking {done} of {total}…": "{total}개 중 {done}개 걷는 중…",
+ "Starting the game…": "게임을 시작하는 중…",
+ "{n, plural, one {# walkable region} other {# walkable regions}}": "걸을 수 있는 지역 {n}개",
+ "{n, plural, one {# walk} other {# walks}}, {arrived} arrived": "이동 {n}개, {arrived}개 도착",
+ "Show": "보기",
+ "Show the game's regions and walks on the map": "게임의 지역과 이동을 맵에 표시합니다",
+ "Every walk arrived, by routes close to the shortest.": "모든 이동이 가장 짧은 경로에 가깝게 도착했습니다.",
+ "The game's walks": "게임의 이동",
+ "{n} tiles": "{n}타일",
+ "{s} s": "{s}초",
+ "stopped {n} tiles short": "{n}타일 앞에서 멈춤",
+ "stuck {n} tiles short": "{n}타일 앞에서 갇힘",
+ "still walking after {s} s": "{s}초 뒤에도 걷는 중",
+ "no ground between": "지상 경로 없음",
+ "did not move": "움직이지 않음",
+ "The game could not place a {unit} at the point picked": "게임이 고른 지점에 {unit|을} 놓지 못했습니다",
+ "The game could not place a {unit} near {from}'s start location, so it walked nothing from there": "게임이 {from}의 시작 위치 근처에 {unit|을} 놓지 못해 그곳에서는 아무것도 걷지 않았습니다",
+ "not placed": "놓지 못함",
};
diff --git a/package.json b/package.json
index 360f004..23cc454 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "scmjs-plugin-walkability",
- "version": "1.2.0",
+ "version": "1.3.0",
"private": true,
"description": "Walkability — a plugin for the scmJS StarCraft map editor.",
"author": "Jeany",
diff --git a/plugin.json b/plugin.json
index cd82815..00a7ddf 100644
--- a/plugin.json
+++ b/plugin.json
@@ -1,8 +1,8 @@
{
"name": "Walkability",
"id": "walkability",
- "version": "1.2.0",
- "description": "Reads the ground the way a unit does: islands, unreachable pockets, the areas a map divides into and the chokes between them with their widths, cliff seams with no ramp, and the ground distance between every pair of start locations, drawn over the map.",
+ "version": "1.3.0",
+ "description": "Reads the ground the way a unit does: islands, unreachable pockets, the areas a map divides into and the chokes between them with their widths, cliff seams with no ramp, and the ground distance between every pair of start locations, drawn over the map. At scmjs.dev it can also have the game itself walk the map, with the game's own regions and pathfinding.",
"author": "Jeany",
"homepage": "https://github.com/scm-js/plugin-walkability",
"entry": "plugin.ts",
diff --git a/plugin.ts b/plugin.ts
index 66d7d66..ad2d9a6 100644
--- a/plugin.ts
+++ b/plugin.ts
@@ -13,6 +13,10 @@
* following every edit. Hovering the map reads out the cell under the pointer; the
* panel holds the settings and the problems, and a second panel the full lists.
*
+ * Where the scm-sim plugin's engine is beside the editor (scmjs.dev), the panel can also
+ * have the game itself walk the map (`game.ts`): its own regions, and real units walked
+ * between the start locations with its own pathfinding, matched against the estimate.
+ *
* `analysis.ts` is the pure part — the grid, the clearance transform, the components,
* the watershed segmentation, the seams and the start-to-start routes — with its own
* tests. This file is the editor side: building the grid from the open map, the
@@ -25,8 +29,10 @@ import {
analyse, blockBoxes, CELL_PX, CELLS_PER_TILE, Cell, componentsAround, gridFromTiles, pxTiles, report, tiles,
type Analysis, type PixelBox, type StartInput,
} from "./analysis";
+import { describeWalk, findings, GAME_UNITS, outcomes, unplaced, walkedFrom, walkFocus, type Finding } from "./game";
import { bindLanguage, msg, t, translate } from "./i18n";
import { KO } from "./ko";
+import type { PathingResult, PathingWalk, ScmSimService } from "./vendor/scm-sim";
/* ── DOM helpers ────────────────────────────────────────── */
@@ -78,11 +84,14 @@ const STYLE = `
.wlk .wlk-empty { padding: 4px 8px; color: var(--text-faint, #6b7382); }
.wlk .wlk-keys { color: var(--text-faint, #6b7382); font-size: 11px; line-height: 1.4; }
.wlk .wlk-keys kbd { font-family: inherit; color: var(--text-dim, #99a2b3); }
+.wlk .wlk-game { display: flex; flex-direction: column; gap: 6px; }
+.wlk .wlk-game:empty { display: none; }
+.wlk .wlk-h { font-weight: 600; color: var(--text, #e6e9ef); border-top: 1px solid var(--border, #333); padding-top: 7px; }
`;
/* ── Settings ───────────────────────────────────────────── */
-type Mode = "areas" | "islands" | "clearance" | "height" | "walk";
+type Mode = "areas" | "islands" | "clearance" | "height" | "walk" | "game";
const MODES: { id: Mode; label: string }[] = [
{ id: "areas", label: msg("Areas and chokes") },
@@ -90,6 +99,7 @@ const MODES: { id: Mode; label: string }[] = [
{ id: "clearance", label: msg("Clearance (room to move)") },
{ id: "height", label: msg("Ground height and seams") },
{ id: "walk", label: msg("Walkable ground") },
+ { id: "game", label: msg("The game's regions and walks") },
];
/** Cells of clearance a unit needs; a Marine is 23 px across, three cells. */
@@ -116,14 +126,20 @@ interface Settings {
minPocket: number;
/** Tiles: a passage wider than this is not a choke worth showing. */
maxChoke: number;
+ /** units.dat ids the game walks (`GAME_UNITS`). */
+ gameUnits: number[];
+ /** Walk both ways between two start locations. */
+ gameBothWays: boolean;
}
-const DEFAULTS: Settings = { mode: "areas", chokes: true, seams: true, starts: true, labels: true, opacity: 0.45, unitRadius: 1, buildingsBlock: true, minArea: 24, minPocket: 2, maxChoke: 12 };
+const DEFAULTS: Settings = { mode: "areas", chokes: true, seams: true, starts: true, labels: true, opacity: 0.45, unitRadius: 1, buildingsBlock: true, minArea: 24, minPocket: 2, maxChoke: 12, gameUnits: [...GAME_UNITS], gameBothWays: false };
function loadSettings(api: PluginApi): Settings {
const stored = api.storage.get>("settings", {});
const s = { ...DEFAULTS, ...stored };
if (!MODES.some((m) => m.id === s.mode)) s.mode = DEFAULTS.mode;
+ if (!Array.isArray(s.gameUnits)) s.gameUnits = [...DEFAULTS.gameUnits];
+ s.gameUnits = s.gameUnits.filter((u) => GAME_UNITS.includes(u));
return s;
}
@@ -166,8 +182,35 @@ type Pick =
| { kind: "seam"; id: number }
| { kind: "start"; id: number }
| { kind: "pair"; id: number }
+ /** A walk of the game's: `PathingResult.walks[id]`, or `customWalks[id]`. */
+ | { kind: "walk"; id: number }
+ | { kind: "custom"; id: number }
| null;
+/** A colour per unit the game walks, by its place in `GAME_UNITS`. */
+const UNIT_COLORS: [number, number, number][] = [[240, 240, 240], [255, 214, 10], [40, 200, 255], [255, 140, 30], [255, 70, 210]];
+const unitColor = (unit: number): [number, number, number] => UNIT_COLORS[GAME_UNITS.indexOf(unit)] ?? [240, 240, 240];
+
+/** Where the game's own pathing stands: the scm-sim plugin's engine, and what it said last. */
+interface GameState {
+ service: ScmSimService | null;
+ /** Whether it can walk a map here; null until asked. `reason` says why not, for the person. */
+ usable: boolean | null;
+ reason: string | null;
+ result: PathingResult | null;
+ /** The map the result is of (`document.id()`), and whether it was edited since. */
+ doc: number | null;
+ stale: boolean;
+ findings: Finding[];
+ /** Walks between two points the person picked. */
+ custom: PathingWalk[];
+ /** Units the game left out (no spot it would take near the point walked from): of the map's walks, and of the person's. */
+ unplaced: { unit: number; fromStart?: number }[];
+ customUnplaced: number[];
+ running: { done: number; total: number; stop: AbortController; what: "map" | "points" } | null;
+ error: string | null;
+}
+
/** Resources on an island no start location shares: an island expansion, or a mistake. */
interface Stranded {
index: number;
@@ -202,6 +245,9 @@ class Session {
private timer: ReturnType | null = null;
/** A run is under way: the panels show it (a ring in the status, the last results dimmed under a note). */
running = false;
+ game: GameState = { service: null, usable: null, reason: null, result: null, doc: null, stale: false, findings: [], custom: [], unplaced: [], customUnplaced: [], running: null, error: null };
+ /** The game's regions as a picture, a pixel a minitile. */
+ regions: HTMLCanvasElement | null = null;
readonly api: PluginApi;
@@ -297,6 +343,7 @@ class Session {
this.stranded.push({ index, name: api.palette.unitName(u.unitId), x: u.x, y: u.y, island, geyser: u.unitId === GEYSER });
});
}
+ if (this.game.result) this.game.findings = findings(this.game.result, a);
if (this.pick && !this.pickStillValid()) this.pick = null;
this.overlay = null;
this.highlight = null;
@@ -326,6 +373,8 @@ class Session {
case "seam": return p.id < a.seams.length;
case "start": return p.id < a.starts.length;
case "pair": return p.id < a.pairs.length;
+ case "walk": return p.id < (this.game.result?.walks.length ?? 0);
+ case "custom": return p.id < this.game.custom.length;
}
}
@@ -341,6 +390,177 @@ class Session {
this.notify();
}
+ /* ── the game's own pathing (the scm-sim plugin's engine) ── */
+
+ /** The engine came or went (the scm-sim plugin was switched on or off, or updated). */
+ setService(service: ScmSimService | null) {
+ this.game.service = service;
+ this.game.usable = null;
+ this.game.reason = null;
+ if (this.panel?.isOpen()) void this.checkGame();
+ this.notify();
+ }
+
+ /** Ask the engine whether it can walk a map here. */
+ async checkGame() {
+ const sim = this.game.service;
+ if (!sim || !sim.present) { this.game.usable = false; this.game.reason = null; this.notify(); return; }
+ if (typeof sim.pathing !== "function") {
+ this.game.usable = false;
+ this.game.reason = t("This needs version 0.7.0 or later of the scm-sim plugin.");
+ this.notify();
+ return;
+ }
+ const status = await sim.status();
+ this.game.usable = status.available && status.pathing !== null;
+ this.game.reason = !status.available ? status.reason : status.pathing === null ? t("The engine's page cannot work out a map's pathing yet. Reload the editor and try again.") : null;
+ this.notify();
+ }
+
+ /** A unit's name as the editor gives it. */
+ unitName(unit: number): string { return this.api.palette.unitName(unit); }
+
+ /** Where a walk went between, in words: the two start locations' players, or two tiles. */
+ walkLabel(w: PathingWalk): string {
+ const end = (slot: number | undefined, p: { x: number; y: number }) => (slot !== undefined ? playerName(slot) : t("{x}, {y}", { x: pxTiles(p.x), y: pxTiles(p.y) }));
+ return t("{unit}: {from} → {to}", { unit: this.unitName(w.unit), from: end(w.fromStart, w.from), to: end(w.toStart, w.to) });
+ }
+
+ /** Have the game walk the map: the ticked units between every pair of start locations. */
+ async walkMap() {
+ await this.walkGame("map");
+ }
+
+ /** Have the game walk the ticked units between two tiles the person clicks. */
+ async walkPoints() {
+ const from = await this.api.ui.pickTile({ prompt: t("Click where the units start") });
+ if (!from) return;
+ const to = await this.api.ui.pickTile({ prompt: t("Click where they should walk to") });
+ if (!to) return;
+ const centre = (p: { x: number; y: number }) => ({ x: p.x * 32 + 16, y: p.y * 32 + 16 });
+ await this.walkGame("points", centre(from), centre(to));
+ }
+
+ private async walkGame(what: "map" | "points", from?: { x: number; y: number }, to?: { x: number; y: number }) {
+ const sim = this.game.service;
+ if (!sim?.pathing || this.game.running) return;
+ if (!this.api.document.isOpen()) { this.api.ui.status(t("Walkability: open a map first")); return; }
+ const units = this.settings.gameUnits.length ? this.settings.gameUnits : [...GAME_UNITS];
+ const stop = new AbortController();
+ const running = { done: 0, total: 0, stop, what };
+ this.game.running = running;
+ this.game.error = null;
+ this.notify();
+ try {
+ const options = { units, signal: stop.signal, onProgress: (p: { done: number; total: number }) => { running.done = p.done; running.total = p.total; this.notify(); } };
+ if (what === "map") {
+ // The estimate first, so the game's routes are matched with it.
+ if (!this.analysis || this.stale) await this.run();
+ const result = await sim.pathing(undefined, { ...options, directed: this.settings.gameBothWays });
+ this.game.result = result;
+ this.game.doc = this.api.document.id();
+ this.game.stale = false;
+ this.game.findings = findings(result, this.analysis);
+ this.game.unplaced = unplaced(result.walks, units, walkedFrom(result, this.settings.gameBothWays));
+ this.regions = null;
+ if (this.pick?.kind === "walk") this.pick = null;
+ const n = outcomes(result.walks);
+ this.api.ui.status(t("Walkability: the game walked {n, plural, one {# route} other {# routes}}, {arrived} arrived, {problems, plural, =0 {no problems} one {# problem} other {# problems}}",
+ { n: result.walks.length, arrived: n.arrived ?? 0, problems: this.game.findings.length }));
+ if (this.settings.mode !== "game") { this.settings.mode = "game"; this.save(); this.overlay = null; }
+ if (!this.active) this.show();
+ } else {
+ const result = await sim.pathing(undefined, { ...options, from, to: [to!], regions: false });
+ this.game.custom = result.walks;
+ this.game.customUnplaced = unplaced(result.walks, units, [undefined]).map((u) => u.unit);
+ if (this.game.doc === null) this.game.doc = this.api.document.id();
+ this.select(result.walks.length ? { kind: "custom", id: 0 } : null);
+ if (!this.active) this.show();
+ }
+ } catch (err) {
+ if (err instanceof Error && err.name === "AbortError") this.api.ui.status(t("Walkability: the game's walk was stopped"));
+ else this.game.error = err instanceof Error ? err.message : String(err);
+ } finally {
+ this.game.running = null;
+ this.redraw();
+ this.notify();
+ }
+ }
+
+ stopGame() { this.game.running?.stop.abort(); }
+
+ /** The map was edited: the game's walks are of the map as it was. */
+ gameEdited() {
+ if (!this.game.result && !this.game.custom.length) return;
+ if (!this.game.stale) { this.game.stale = true; this.notify(); }
+ }
+
+ /** Another map is in front, or none: the walks were of the one before. */
+ clearGame() {
+ this.game.running?.stop.abort();
+ this.game.result = null;
+ this.game.custom = [];
+ this.game.findings = [];
+ this.game.unplaced = [];
+ this.game.customUnplaced = [];
+ this.game.doc = null;
+ this.game.stale = false;
+ this.game.error = null;
+ this.regions = null;
+ if (this.pick?.kind === "walk" || this.pick?.kind === "custom") this.pick = null;
+ if (this.settings.mode === "game") this.overlay = null;
+ this.redraw();
+ this.notify();
+ }
+
+ /** A finding in words, for the panel's list and the report. */
+ findingText(f: Finding): { label: string; hint: string } {
+ const w = this.game.result!.walks[f.walk];
+ const between = { unit: this.unitName(w.unit), from: w.fromStart !== undefined ? playerName(w.fromStart) : "?", to: w.toStart !== undefined ? playerName(w.toStart) : "?" };
+ switch (f.kind) {
+ case "stopped": return { label: t("{unit} from {from} to {to} stops short", between), hint: describeWalk(w) };
+ case "stuck": return { label: t("{unit} from {from} to {to} gets stuck", between), hint: describeWalk(w) };
+ case "limit": return { label: t("{unit} from {from} to {to} is still walking when the time runs out", between), hint: describeWalk(w) };
+ case "apart": return {
+ label: f.estimateHasRoute ? t("The game finds no ground between {from} and {to}, though the estimate does", between) : t("No ground between {from} and {to}", between),
+ hint: this.unitName(w.unit),
+ };
+ case "detour": return { label: t("{unit} from {from} to {to} walks {n} tiles more than the shortest ground route", { ...between, n: Math.round((f.extra ?? 0) / 32) }), hint: describeWalk(w) };
+ }
+ }
+
+ /** A unit the game left out of the walks from a start location, in words. */
+ unplacedText(u: { unit: number; fromStart?: number }): string {
+ return t("The game could not place a {unit} near {from}'s start location, so it walked nothing from there", { unit: this.unitName(u.unit), from: playerName(u.fromStart ?? 0) });
+ }
+
+ /** The game's walks as lines of text, for the report. */
+ gameReport(): string[] {
+ const g = this.game.result;
+ if (!g && !this.game.custom.length) return [];
+ const lines: string[] = ["", t("The game's own pathing")];
+ if (g) {
+ if (g.regions) lines.push(` ${t("{n, plural, one {# walkable region} other {# walkable regions}} of {all}", { n: g.regions.walkable.reduce((n, w) => n + w, 0), all: g.regions.count })}`);
+ for (const w of g.walks) lines.push(` ${this.walkLabel(w)}: ${describeWalk(w)}`);
+ if (this.game.findings.length || this.game.unplaced.length) {
+ lines.push("", t("What the game found"));
+ for (const u of this.game.unplaced) lines.push(` ${this.unplacedText(u)}`);
+ for (const f of this.game.findings) lines.push(` ${this.findingText(f).label}`);
+ }
+ }
+ for (const w of this.game.custom) lines.push(` ${this.walkLabel(w)}: ${describeWalk(w)}`);
+ for (const u of this.game.customUnplaced) lines.push(` ${t("The game could not place a {unit} at the point picked", { unit: this.unitName(u) })}`);
+ if (this.game.stale) lines.push(` (${t("the map was edited after the game walked it")})`);
+ return lines;
+ }
+
+ /** The walk a pick names. */
+ walkOf(pick: Pick): PathingWalk | null {
+ if (pick?.kind === "walk") return this.game.result?.walks[pick.id] ?? null;
+ if (pick?.kind === "custom") return this.game.custom[pick.id] ?? null;
+ return null;
+ }
+
/* ── the overlay: the picture and the readout ── */
show() { this.view?.show(); }
@@ -415,6 +635,7 @@ class Session {
case "seam": { const s = a.seams[pick.id]; centre(s.centre.x, s.centre.y); break; }
case "start": { const s = a.starts[pick.id]; this.api.view.goTo({ kind: "unit", index: s.index }); break; }
case "pair": { const p = a.pairs[pick.id]; if (p.chokeAt) centre(p.chokeAt.x, p.chokeAt.y); else this.api.view.goTo({ kind: "unit", index: a.starts[p.a].index }); break; }
+ case "walk": case "custom": { const w = this.walkOf(pick); if (w) { const f = walkFocus(w); this.api.view.center(Math.floor(f.x / 32), Math.floor(f.y / 32)); } break; }
}
if (!this.active) this.show();
this.select(pick);
@@ -444,6 +665,12 @@ class Session {
const ar = a.areas[area];
parts.push(`${t("Area {n}", { n: area + 1 })} (${t("{size} tiles, {chokes, plural, one {# choke} other {# chokes}}", { size: tiles(ar.size / CELLS_PER_TILE), chokes: ar.chokes.length })})`);
}
+ // The game's own region here, in its view (the grids are both a cell a minitile).
+ const g = this.game.result?.regions;
+ if (g && this.settings.mode === "game" && x < g.width && y < g.height) {
+ const r = g.grid[y * g.width + x];
+ parts.push(g.walkable[r] ? t("the game's region {r}, ground group {g}", { r, g: g.group[r] }) : t("the game's region {r}, unwalkable", { r }));
+ }
return parts.join(" · ");
}
@@ -512,6 +739,32 @@ class Session {
return canvas;
}
+ /** The game's regions, a pixel a minitile: each walkable region its own colour, darker at its edges; unwalkable ground clear. */
+ private buildRegions(): HTMLCanvasElement | null {
+ const g = this.game.result?.regions;
+ if (!g) return null;
+ const canvas = document.createElement("canvas");
+ canvas.width = g.width;
+ canvas.height = g.height;
+ const ctx = canvas.getContext("2d")!;
+ const img = ctx.createImageData(g.width, g.height);
+ const d = img.data;
+ const colors = new Map();
+ for (let y = 0; y < g.height; y++) {
+ for (let x = 0; x < g.width; x++) {
+ const i = y * g.width + x, r = g.grid[i];
+ if (!g.walkable[r]) continue;
+ let c = colors.get(r);
+ if (!c) { c = idColor(r, 0.6, 0.5); colors.set(r, c); }
+ const edge = (x > 0 && g.grid[i - 1] !== r) || (y > 0 && g.grid[i - g.width] !== r) || (x + 1 < g.width && g.grid[i + 1] !== r) || (y + 1 < g.height && g.grid[i + g.width] !== r);
+ const k = edge ? 0.45 : 1;
+ d[i * 4] = c[0] * k; d[i * 4 + 1] = c[1] * k; d[i * 4 + 2] = c[2] * k; d[i * 4 + 3] = edge ? 235 : 150;
+ }
+ }
+ ctx.putImageData(img, 0, 0);
+ return canvas;
+ }
+
private buildHighlight(): HTMLCanvasElement | null {
const a = this.analysis;
const p = this.pick;
@@ -544,11 +797,11 @@ class Session {
if (x1 <= x0 || y1 <= y0) return;
const dx = view.x(x0 * CELL_PX);
const dy = view.y(y0 * CELL_PX);
- this.overlay ??= this.buildOverlay();
+ const picture = this.settings.mode === "game" ? (this.regions ??= this.buildRegions()) : (this.overlay ??= this.buildOverlay());
ctx.save();
ctx.imageSmoothingEnabled = false;
ctx.globalAlpha = this.settings.opacity;
- ctx.drawImage(this.overlay, x0, y0, x1 - x0, y1 - y0, dx, dy, (x1 - x0) * cellPx, (y1 - y0) * cellPx);
+ if (picture) ctx.drawImage(picture, x0, y0, x1 - x0, y1 - y0, dx, dy, (x1 - x0) * cellPx, (y1 - y0) * cellPx);
if (this.pick) {
this.highlight ??= this.buildHighlight();
if (this.highlight) {
@@ -649,6 +902,7 @@ class Session {
ctx.stroke();
ctx.setLineDash([]);
}
+ this.drawWalks(ctx, view, showLabels, label);
// The hovered cell.
if (this.hover) {
ctx.strokeStyle = "#fff";
@@ -657,6 +911,50 @@ class Session {
}
}
+ /**
+ * The game's walks: all of them in the game's view, else only a picked one; the person's own
+ * walks always. A route in its unit's colour, a picked one on top in a white casing, and a
+ * cross where a walk that went wrong ended.
+ */
+ private drawWalks(ctx: CanvasRenderingContext2D, view: MapView, showLabels: boolean, label: (text: string, x: number, y: number, color: string) => void) {
+ const walks: { w: PathingWalk; picked: boolean }[] = [];
+ const isPicked = (kind: "walk" | "custom", i: number) => this.pick?.kind === kind && this.pick.id === i;
+ if (this.settings.mode === "game") (this.game.result?.walks ?? []).forEach((w, i) => walks.push({ w, picked: isPicked("walk", i) }));
+ else if (this.pick?.kind === "walk") { const w = this.walkOf(this.pick); if (w) walks.push({ w, picked: true }); }
+ this.game.custom.forEach((w, i) => walks.push({ w, picked: isPicked("custom", i) }));
+ if (!walks.length) return;
+ walks.sort((p, q) => Number(p.picked) - Number(q.picked));
+ ctx.save();
+ ctx.lineJoin = "round";
+ ctx.lineCap = "round";
+ for (const { w, picked } of walks) {
+ if (w.x.length < 2) continue;
+ const path = () => {
+ ctx.beginPath();
+ ctx.moveTo(view.x(w.x[0]), view.y(w.y[0]));
+ for (let k = 1; k < w.x.length; k++) ctx.lineTo(view.x(w.x[k]), view.y(w.y[k]));
+ };
+ if (picked) { ctx.strokeStyle = "#fff"; ctx.lineWidth = 6; path(); ctx.stroke(); }
+ ctx.strokeStyle = rgb(unitColor(w.unit));
+ ctx.lineWidth = picked ? 3 : 1.5;
+ ctx.globalAlpha = picked || walks.length < 12 ? 1 : 0.8;
+ path();
+ ctx.stroke();
+ ctx.globalAlpha = 1;
+ if (w.outcome !== "arrived") {
+ const ex = view.x(w.x[w.x.length - 1]), ey = view.y(w.y[w.y.length - 1]), r = Math.max(5, view.tilePx * 0.6);
+ ctx.strokeStyle = "#ff5a4a";
+ ctx.lineWidth = 3;
+ ctx.beginPath();
+ ctx.moveTo(ex - r, ey - r); ctx.lineTo(ex + r, ey + r);
+ ctx.moveTo(ex + r, ey - r); ctx.lineTo(ex - r, ey + r);
+ ctx.stroke();
+ if (showLabels && picked) label(`${this.unitName(w.unit)}: ${describeWalk(w)}`, ex, ey - r - 10, "#ff8a7a");
+ }
+ }
+ ctx.restore();
+ }
+
/** Stranded resources grouped by island, largest island first (-1 last). */
strandedByIsland(): [number, { patches: number; geysers: number; first: Stranded }][] {
const groups = new Map();
@@ -680,7 +978,7 @@ class Session {
? t("{patches, plural, one {# mineral field} other {# mineral fields}} and {geysers, plural, one {# geyser} other {# geysers}} with no walkable ground around them: no start location reaches them by ground", counts)
: t("{patches, plural, one {# mineral field} other {# mineral fields}} and {geysers, plural, one {# geyser} other {# geysers}} on island {n}: no start location reaches them by ground", { ...counts, n: island + 1 })}`);
}
- const full = extra.length ? `${text}\n\n${t("Problems")}\n${extra.join("\n")}` : text;
+ const full = (extra.length ? `${text}\n\n${t("Problems")}\n${extra.join("\n")}` : text) + this.gameReport().map((l) => `\n${l}`).join("");
void navigator.clipboard?.writeText(full).then(
() => this.api.ui.status(t("Walkability: report copied")),
() => this.api.ui.status(t("Walkability: the browser refused the clipboard")),
@@ -765,15 +1063,82 @@ function mountPanel(session: Session, body: HTMLElement): () => void {
const results = h("div", { style: "display:flex;flex-direction:column;gap:6px" });
root.append(results);
+ const gameBox = h("div", { className: "wlk-game" });
+ root.append(gameBox);
const keys = t("The overlay stays on while you work on any layer and follows every edit; View ▸ Walkability, the Layers panel or {key} switch it off and on.").split("{key}");
root.append(h("div", { className: "wlk-keys" }, keys[0], h("kbd", null, "Ctrl+Shift+W"), keys[1] ?? ""));
- const openSections = new Set(["problems"]);
+ const openSections = new Set(["problems", "game"]);
let cover: BusyHandle | null = null;
+ if (session.game.service && session.game.usable === null) void session.checkGame();
+
+ /** The game's own pathing: only where the scm-sim plugin's engine is beside the editor. */
+ function renderGame() {
+ const g = session.game;
+ gameBox.replaceChildren();
+ if (!g.service?.present) return;
+ gameBox.append(h("div", { className: "wlk-h" }, t("The game's own pathing")));
+ if (g.usable === false) { gameBox.append(h("div", { className: "wlk-status" }, g.reason ?? t("The game's engine is not available here."))); return; }
+ if (g.usable === null) { gameBox.append(W.spinner({ size: "sm", label: t("Asking the game's engine…") })); return; }
+ gameBox.append(h("div", { className: "wlk-status" }, t("The game itself walks units between the start locations, with its own regions and pathfinding. Each start location walked from takes a few seconds.")));
+ const ticks = h("div", { className: "wlk-ticks" });
+ for (const unit of GAME_UNITS) {
+ ticks.append(W.checkbox(session.unitName(unit), {
+ value: s.gameUnits.includes(unit),
+ onChange: (v) => { s.gameUnits = GAME_UNITS.filter((u) => (u === unit ? v : s.gameUnits.includes(u))); session.save(); },
+ }));
+ }
+ ticks.append(W.checkbox(t("Both ways"), { value: s.gameBothWays, title: t("Walk from each start location to each other one, not each pair once"), onChange: (v) => { s.gameBothWays = v; session.save(); } }));
+ gameBox.append(ticks);
+ const running = g.running;
+ const walkBtn = W.button(t("Walk the map"), { primary: true, disabled: running !== null, onClick: () => void session.walkMap() });
+ const pointsBtn = W.button(t("Walk between two points"), { ghost: true, disabled: running !== null, onClick: () => void session.walkPoints() });
+ gameBox.append(h("div", { className: "wlk-top" }, walkBtn, pointsBtn, running ? W.button(t("Stop"), { ghost: true, onClick: () => session.stopGame() }) : null));
+ if (running) {
+ gameBox.append(W.spinner({ size: "sm", label: running.total ? t("Walking {done} of {total}…", { done: running.done, total: running.total }) : t("Starting the game…") }));
+ return;
+ }
+ if (g.error) gameBox.append(h("div", { className: "wlk-status", style: "color:#ff9f7a" }, g.error));
+ const r = g.result;
+ if (r) {
+ const n = outcomes(r.walks);
+ const regions = r.regions ? r.regions.walkable.reduce((k, w) => k + w, 0) : null;
+ const line = [
+ regions !== null ? t("{n, plural, one {# walkable region} other {# walkable regions}}", { n: regions }) : null,
+ t("{n, plural, one {# walk} other {# walks}}, {arrived} arrived", { n: r.walks.length, arrived: n.arrived ?? 0 }),
+ g.stale ? t("out of date") : null,
+ ].filter(Boolean).join(" · ");
+ const showBtn = s.mode === "game" ? null : W.button(t("Show"), { ghost: true, title: t("Show the game's regions and walks on the map"), onClick: () => { s.mode = "game"; session.save(); session.overlay = null; session.show(); session.redraw(); session.notify(); } });
+ gameBox.append(h("div", { className: "wlk-top" }, h("span", { className: "wlk-status", style: "flex:1" }, line), showBtn));
+ const is = (i: number) => session.pick?.kind === "walk" && session.pick.id === i;
+ const startPick = (slot: number | undefined): Pick => {
+ const i = session.analysis?.starts.findIndex((st) => st.owner === slot) ?? -1;
+ return i >= 0 ? { kind: "start", id: i } : null;
+ };
+ const rows: Row[] = [
+ ...g.unplaced.map((u) => ({ label: session.unplacedText(u), hint: t("not placed"), bad: true, color: rgb(unitColor(u.unit)), pick: startPick(u.fromStart) })),
+ ...g.findings.map((f) => ({ ...session.findingText(f), bad: f.kind !== "detour", color: rgb(unitColor(r.walks[f.walk].unit)), on: is(f.walk), pick: { kind: "walk", id: f.walk } as Pick })),
+ ];
+ gameBox.append(section(session, openSections, "game", t("What the game found"), rows.length, rows, t("Every walk arrived, by routes close to the shortest."), undefined));
+ }
+ g.custom.forEach((w, i) => {
+ const on = session.pick?.kind === "custom" && session.pick.id === i;
+ gameBox.append(h("div", { className: `wlk-item${on ? " on" : ""}${w.outcome !== "arrived" ? " bad" : ""}`, onClick: () => session.goTo({ kind: "custom", id: i }) },
+ h("span", { className: "wlk-sw", style: `background:${rgb(unitColor(w.unit))}` }),
+ h("span", { className: "wlk-grow" }, session.walkLabel(w)),
+ h("span", { className: "wlk-hint" }, describeWalk(w))));
+ });
+ for (const u of g.customUnplaced) {
+ gameBox.append(h("div", { className: "wlk-status", style: "color:#ff9f7a" }, t("The game could not place a {unit} at the point picked", { unit: session.unitName(u) })));
+ }
+ }
function render() {
+ renderGame();
const a = session.analysis;
shown.input.checked = session.active;
+ // The mode is also set from elsewhere (a walk of the map shows the game's view).
+ modeSel.value = s.mode;
if (session.running) {
// The last readout stays, dimmed under a note, until the new one replaces it.
status.replaceChildren(W.spinner({ size: "sm", label: a ? t("Reading the map again…") : t("Reading the map…") }));
@@ -906,6 +1271,18 @@ function mountDetails(session: Session, body: HTMLElement): () => void {
pick: { kind: "area", id: ar.id },
})), t("No areas."));
+ const game = session.game.result;
+ if (game?.walks.length) {
+ list("walks", t("The game's walks"), game.walks.length, game.walks.map((w, i) => ({
+ label: session.walkLabel(w),
+ hint: `${describeWalk(w)}${w.outcome === "arrived" ? ` · ${t("{n} tiles", { n: Math.round(w.travelled / 32) })}` : ""}`,
+ color: rgb(unitColor(w.unit)),
+ bad: w.outcome !== "arrived",
+ on: is("walk", i),
+ pick: { kind: "walk", id: i },
+ })), "", session.game.stale ? t("out of date") : undefined);
+ }
+
list("chokes", t("Chokes"), chokes.length, chokes.map((c) => ({
label: t("{width} tiles wide at {x}, {y}", { width: tiles(c.width), x: tiles(c.x), y: tiles(c.y) }),
hint: t("area {a} ↔ {b}", { a: c.a + 1, b: c.b + 1 }),
@@ -976,10 +1353,15 @@ export default function activate(api: PluginApi) {
api.contextMenu.add("viewport", { label: msg("Walkability overlay"), command: "toggle" });
api.hotkeys.add("Ctrl+Shift+W", { command: "toggle" });
- for (const event of ["terrain", "units", "doodads", "settings"] as const) api.events.on(event, () => session.schedule());
+ // The game's own pathing, where the scm-sim plugin provides its engine (and it answers `pathing`).
+ api.services.watch("scm-sim.engine", (service) => session.setService(service));
+
+ for (const event of ["terrain", "units", "doodads", "settings"] as const) api.events.on(event, () => { session.schedule(); session.gameEdited(); });
api.events.on("document", () => {
if (!api.document.isOpen()) session.clear();
else session.schedule();
+ // The game's walks belong to the map they were made on.
+ if (session.game.doc !== null && session.game.doc !== api.document.id()) session.clearGame();
});
// A new language: the panels are built again in it, and the picture's labels follow on the next draw.
api.events.on("language", () => {
diff --git a/tests/game.test.ts b/tests/game.test.ts
new file mode 100644
index 0000000..0b15191
--- /dev/null
+++ b/tests/game.test.ts
@@ -0,0 +1,80 @@
+import { describe, expect, it } from "vitest";
+import type { Analysis } from "../analysis";
+import { describeWalk, estimatePair, findings, outcomes, seconds, unplaced, walkedFrom, walkFocus } from "../game";
+import type { PathingResult, PathingWalk } from "../vendor/scm-sim";
+
+/** A walk between two start locations (player slots), with the fields the findings read. */
+function walk(fromStart: number, toStart: number, outcome: PathingWalk["outcome"], travelled: number, more: Partial = {}): PathingWalk {
+ return {
+ unit: 66, from: { x: 0, y: 0 }, to: { x: 1000, y: 0 }, fromStart, toStart, outcome, frames: 480, travelled, distance: outcome === "arrived" ? 80 : 2000, regions: 9,
+ every: 8, x: Int16Array.of(0, 500, 900), y: Int16Array.of(0, 10, 20), ...more,
+ };
+}
+const result = (walks: PathingWalk[]) => ({ version: 1, map: { width: 64, height: 64 }, starts: [], regions: null, walks }) as PathingResult;
+/** The estimate: three starts (slots 0, 1, 5), ground routes 0–1 of 1,000 px and 0–5 of 2,000 px, none 1–5. */
+const estimate = {
+ starts: [{ owner: 0 }, { owner: 1 }, { owner: 5 }],
+ pairs: [{ a: 0, b: 1, ground: 1000 }, { a: 0, b: 2, ground: 2000 }, { a: 1, b: 2, ground: null }],
+} as unknown as Analysis;
+
+describe("what Walkability makes of the game's walks", () => {
+ it("finds the estimate's pair either way round", () => {
+ expect(estimatePair(estimate, 1, 0)?.ground).toBe(1000);
+ expect(estimatePair(estimate, 5, 0)?.ground).toBe(2000);
+ expect(estimatePair(estimate, 3, 0)).toBeNull();
+ });
+
+ it("reports walks that went wrong, worst first, then detours", () => {
+ const r = result([
+ walk(0, 1, "arrived", 1100), // close to the estimate: nothing
+ walk(0, 1, "arrived", 1400), // 40 % and 400 px more: a detour
+ walk(0, 5, "stopped", 3000),
+ walk(1, 0, "stuck", 300),
+ walk(0, 5, "limit", 9000),
+ ]);
+ expect(findings(r, estimate).map((f) => [f.kind, f.walk])).toEqual([["stuck", 3], ["stopped", 2], ["limit", 4], ["detour", 1]]);
+ expect(findings(r, estimate).find((f) => f.kind === "detour")?.extra).toBe(400);
+ });
+
+ it("calls nothing a detour on a short route, or without an estimate", () => {
+ const short = { ...estimate, pairs: [{ a: 0, b: 1, ground: 400 }] } as unknown as Analysis;
+ // 60 % more, but only 240 px: within what short routes vary by.
+ expect(findings(result([walk(0, 1, "arrived", 640)]), short)).toEqual([]);
+ expect(findings(result([walk(0, 1, "arrived", 5000)]), null)).toEqual([]);
+ });
+
+ it("says when the game finds no ground where the estimate does", () => {
+ const r = result([walk(0, 1, "apart", 0), walk(1, 5, "apart", 0)]);
+ expect(findings(r, estimate).map((f) => [f.walk, f.estimateHasRoute])).toEqual([[0, true], [1, false]]);
+ });
+
+ it("counts the outcomes", () => {
+ expect(outcomes([walk(0, 1, "arrived", 1), walk(0, 1, "arrived", 1), walk(0, 1, "stuck", 1)])).toEqual({ arrived: 2, stuck: 1 });
+ });
+
+ it("puts a walk in words and seconds at Fastest", () => {
+ expect(seconds(24)).toBe("1.0");
+ expect(seconds(882)).toBe("37.0");
+ expect(describeWalk(walk(0, 1, "arrived", 1))).toBe("20.2 s");
+ expect(describeWalk(walk(0, 1, "stopped", 1, { distance: 4800 }))).toBe("stopped 150 tiles short");
+ expect(describeWalk(walk(0, 1, "apart", 0))).toBe("no ground between");
+ });
+
+ it("looks at where a walk that went wrong ended, else at its middle", () => {
+ expect(walkFocus(walk(0, 1, "stopped", 1))).toEqual({ x: 900, y: 20 });
+ expect(walkFocus(walk(0, 1, "arrived", 1))).toEqual({ x: 500, y: 10 });
+ });
+
+ it("names the units the game did not place, per point walked from", () => {
+ const walks = [walk(0, 1, "arrived", 1, { unit: 64 }), walk(0, 5, "arrived", 1, { unit: 64 }), walk(1, 5, "arrived", 1, { unit: 66 })];
+ expect(unplaced(walks, [64, 66], [0, 1])).toEqual([{ unit: 66, fromStart: 0 }, { unit: 64, fromStart: 1 }]);
+ const own = [{ ...walk(0, 1, "arrived", 1, { unit: 37 }), fromStart: undefined, toStart: undefined }];
+ expect(unplaced(own, [37, 66], [undefined])).toEqual([{ unit: 66 }]);
+ });
+
+ it("walks from every start but the last, or from all of them both ways", () => {
+ const r = { ...result([]), starts: [{ owner: 0, x: 0, y: 0 }, { owner: 3, x: 0, y: 0 }, { owner: 5, x: 0, y: 0 }] } as PathingResult;
+ expect(walkedFrom(r, false)).toEqual([0, 3]);
+ expect(walkedFrom(r, true)).toEqual([0, 3, 5]);
+ });
+});
diff --git a/tests/ko.test.ts b/tests/ko.test.ts
index c90c53a..20ddf96 100644
--- a/tests/ko.test.ts
+++ b/tests/ko.test.ts
@@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest";
import { KO } from "../ko";
// Every literal the plugin shows through `t("…")` or keeps for later through `msg("…")`, read from the source the way the editor's own extractor reads it.
-const sources = ["plugin.ts", "analysis.ts"].map((f) => readFileSync(new URL(`../${f}`, import.meta.url), "utf8"));
+const sources = ["plugin.ts", "analysis.ts", "game.ts"].map((f) => readFileSync(new URL(`../${f}`, import.meta.url), "utf8"));
const keys = new Set(sources.flatMap((source) => [...source.matchAll(/\b(?:t|msg)\("((?:[^"\\]|\\.)*)"/g)].map((m) => JSON.parse(`"${m[1]}"`) as string)));
/** The placeholders a string fills: `{name}`, `{n, plural, …}`, `{name|을}` — not the words inside a plural's branches. */
diff --git a/tsconfig.json b/tsconfig.json
index 5f9c0b1..0fbfc57 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -15,5 +15,5 @@
"skipLibCheck": true,
"noEmit": true
},
- "include": ["plugin.ts", "analysis.ts"]
+ "include": ["plugin.ts", "analysis.ts", "game.ts"]
}
diff --git a/vendor/scm-sim.d.ts b/vendor/scm-sim.d.ts
new file mode 100644
index 0000000..0220fbc
--- /dev/null
+++ b/vendor/scm-sim.d.ts
@@ -0,0 +1,510 @@
+// A copy of scm-js/plugin-scm-sim's contract.d.ts at v0.7.0: the `scm-sim.engine` service's types. Types only.
+/**
+ * The `scm-sim.engine` service, as other plugins see it. Types only: take this file with
+ * `import type` and reach the object through `api.services.watch("scm-sim.engine", …)`.
+ * The provider's `ServiceInfo.version` is 1 for this shape.
+ *
+ * The timeline types are the engine's own (scm-sim `src/analysis/timeline.ts`), copied
+ * here; `ReplayTimeline.version` says which form a timeline has.
+ */
+
+/**
+ * `born`: the unit is there for the first time (a building's site, a unit leaving its
+ * factory, a larva). `done`: it is finished. `morph`: it is another type now (`from` the
+ * type before): a larva an egg, an egg a zergling, a drone a hatchery, a hatchery a lair,
+ * a geyser a refinery. `given`: another player's now (`from` the owner before); a player
+ * who leaves gives everything to a neutral player. `died`: gone, at the place it was
+ * last seen.
+ */
+export type UnitEventKind = "born" | "done" | "morph" | "given" | "died";
+
+export interface UnitEvent {
+ frame: number;
+ kind: UnitEventKind;
+ /** The unit's number in this timeline, from 0 in the order of appearing. */
+ unit: number;
+ /** units.dat id, after the event. */
+ type: number;
+ /** Player slot 0..11, after the event. */
+ owner: number;
+ /** Pixels, the unit's centre. */
+ x: number;
+ y: number;
+ /** `morph`: the type before. `given`: the owner before. */
+ from?: number;
+ /** `born`: a hallucination. */
+ hallucination?: true;
+ /**
+ * `died`, from timeline version 2: the unit the game credited with the kill, its type and
+ * owner then, and where it stood. The game counts a kill to a unit's name (a reaver's for
+ * its scarab's, a carrier's for its interceptors', a templar's for its storm's). A death
+ * nobody is credited with has none of these: a unit used up (a scarab, a spider mine), a
+ * spell running out, a building cancelled, and what a spider mine killed.
+ */
+ by?: number;
+ byType?: number;
+ byOwner?: number;
+ byX?: number;
+ byY?: number;
+}
+
+/** An upgrade's level reached or a technology researched, seen at a sample (so `frame` is late by up to `samples.every`). */
+export interface ResearchEvent {
+ frame: number;
+ player: number;
+ kind: "upgrade" | "tech";
+ /** upgrades.dat or techdata.dat id. */
+ id: number;
+ /** An upgrade's level; 1 for a technology. */
+ level: number;
+}
+
+/** One player's numbers, an entry per sample (`samples.frame`). */
+export interface PlayerSeries {
+ slot: number;
+ minerals: number[];
+ gas: number[];
+ supplyUsed: number[];
+ supplyMax: number[];
+ /** Finished workers. */
+ workers: number[];
+ /** Finished units that are neither buildings nor workers (larvae, eggs and what a unit fires are not counted). */
+ army: number[];
+ /** What those cost, minerals and gas together. */
+ armyValue: number[];
+ /** Finished buildings. */
+ buildings: number[];
+ /**
+ * From timeline version 4: everything gathered since the start, the game's own counts (its
+ * score screen's "mined"). A melee game counts the 50 minerals it starts with; what a map's
+ * triggers give is not counted. Income is the difference between two samples.
+ */
+ mineralsGathered?: number[];
+ gasGathered?: number[];
+}
+
+/** Where the units stood at regular frames: sample `i` is rows `start[i]` to `start[i + 1]` of `unit`, `x`, `y`. Buildings and units inside something are left out. */
+export interface Positions {
+ every: number;
+ frame: number[];
+ start: Uint32Array;
+ unit: Uint32Array;
+ x: Uint16Array;
+ y: Uint16Array;
+}
+
+/**
+ * Whether the game played by the engine stayed the game the replay was recorded from. A
+ * replay's commands name units by the game's own slots, so each name is held against the
+ * game as it is played: a name of a unit the game had not made yet cannot come from the game
+ * that was played.
+ */
+export interface ReplayCheck {
+ /** Units the commands named, and of those: the unit had died before the command reached the game (no sign of anything). */
+ named: number;
+ gone: number;
+ /** Names of a unit the game had not made. */
+ ahead: number;
+ /** The frame of the first of those: what the timeline says from there on is not what happened. null: none, the whole game stands. */
+ parted: number | null;
+}
+
+export interface ReplayTimeline {
+ /** 1: as first written. 2: a `died` event says who killed the unit. 3: `check`. 4: what each player gathered. */
+ version: number;
+ /** Frames played, and the replay's length. */
+ frames: number;
+ total: number;
+ /** How the game stopped, in the engine's words, or null when the replay simply ran out. */
+ ended: string | null;
+ /** In tiles. */
+ map: { width: number; height: number };
+ /** Race: 0 zerg, 1 terran, 2 protoss. */
+ players: { slot: number; name: string; race: number }[];
+ events: UnitEvent[];
+ research: ResearchEvent[];
+ samples: { every: number; frame: number[]; players: PlayerSeries[] };
+ /** Null when not asked for. */
+ positions: Positions | null;
+ /** From timeline version 3; absent in a timeline of an engine before that. */
+ check?: ReplayCheck | null;
+}
+
+export interface AnalyseProgress {
+ /** "starting": the engine is being fetched and the game started. "playing": `frame` of `frames`. */
+ stage: "starting" | "playing";
+ frame: number;
+ frames: number;
+}
+
+export interface AnalyseOptions {
+ /** Frames between samples of the players' numbers; 48 (two seconds at Fastest) when left out. */
+ sampleEvery?: number;
+ /** Frames between samples of where units stood; the same as `sampleEvery` when left out, 0 for none. */
+ positionsEvery?: number;
+ /** The file's name, for the engine's own messages. */
+ name?: string;
+ onProgress?(progress: AnalyseProgress): void;
+ /** Aborting stops the game and rejects the promise with an `AbortError`. */
+ signal?: AbortSignal;
+}
+
+export interface ScmSimStatus {
+ /** Whether a replay or a map can be run here now. */
+ available: boolean;
+ /**
+ * Where the engine reads the game from: the person's own 1.16.1 archives, a copy the
+ * editor made (the Game Data page), a development server's files, or null when the
+ * browser has none.
+ */
+ files: "archives" | "copy" | "server" | null;
+ /** When not available: why, in words for the person. */
+ reason: string | null;
+ /** The `ReplayTimeline.version` the engine writes, or null when it could not be asked. */
+ timeline: number | null;
+ /**
+ * The `PathingResult.version` the engine answers `pathing` with; null when it could not be
+ * asked or its page is from before `pathing` (scm-sim 0.7.0).
+ */
+ pathing: number | null;
+}
+
+/* ── A map's pathing (`ScmSimService.pathing`) ── */
+
+export interface PathingPoint { x: number; y: number }
+/** A start location of the map: its player slot and where it is, in pixels. */
+export interface PathingStart extends PathingPoint { owner: number }
+
+/**
+ * The game's regions for the map, a minitile (8 px) at a time. `grid` holds a region id for
+ * every minitile, row by row, `width × height` of them (the map's size in tiles × 4). The
+ * other arrays are a column per region, by id.
+ */
+export interface PathingRegions {
+ width: number;
+ height: number;
+ grid: Uint16Array;
+ count: number;
+ /** 1 walkable, 0 not. */
+ walkable: Uint8Array;
+ /** Regions of one group are joined by ground; units cannot walk between two groups. */
+ group: Uint16Array;
+ /** The region's centre as the game keeps it, pixels. */
+ x: Int32Array;
+ y: Int32Array;
+ /** Tiles in the region (a tile split between two regions counts for one of them). */
+ tiles: Uint16Array;
+ /** Neighbours of region r: `neighbours[neighbourAt[r]]` up to `neighbourAt[r + 1]`, walkable ones first. */
+ neighbourAt: Uint32Array;
+ neighbours: Uint16Array;
+}
+
+/**
+ * `arrived`: it stopped within reach of where it was sent. `stopped`: it stopped short (the
+ * game found no way all the way there, or gave up looking on a long route). `stuck`: it stood
+ * still for ten seconds with its order still to move. `limit`: still walking when `limit` ran
+ * out. `apart`: not walked, the two places being on ground that does not join. `unmoved`: the
+ * order never took.
+ */
+export type PathingWalkOutcome = "arrived" | "stopped" | "stuck" | "limit" | "apart" | "unmoved";
+
+export interface PathingWalk {
+ /** units.dat id. */
+ unit: number;
+ from: PathingPoint;
+ to: PathingPoint;
+ /** The start locations (their player slots) walked between, when the walk was between two. */
+ fromStart?: number;
+ toStart?: number;
+ outcome: PathingWalkOutcome;
+ /** From the frame the unit took its order to the frame it stopped. 24 frames is a second at Fastest. */
+ frames: number;
+ /** Pixels walked, and how far from `to` it ended. */
+ travelled: number;
+ distance: number;
+ /** Regions it stood in on the way. */
+ regions: number;
+ /** Where it was every `every` frames from the order on, ending where it stopped. */
+ every: number;
+ x: Int16Array;
+ y: Int16Array;
+}
+
+export interface PathingResult {
+ version: number;
+ /** Tiles. */
+ map: { width: number; height: number };
+ starts: PathingStart[];
+ /** Null when `regions: false` was asked. */
+ regions: PathingRegions | null;
+ walks: PathingWalk[];
+}
+
+/** `starting`: the engine and the first game; `walking`: `done` of `total` walks. */
+export interface PathingProgress { stage: "starting" | "walking"; done: number; total: number }
+
+export interface PathingOptions {
+ /** units.dat ids of the ground units to walk; default Probe, Zergling, Dragoon, Siege Tank, Ultralisk. Air units and buildings are left out. */
+ units?: number[];
+ /** Walk both ways between two start locations (default: each pair once, from the lower slot). */
+ directed?: boolean;
+ /** Walk from this point to each of `to` in place of the start locations. */
+ from?: PathingPoint;
+ to?: PathingPoint[];
+ /** Leave the regions out of the answer (default: in). */
+ regions?: boolean;
+ /** Frames a walk may take before it is given up (default 10,000). */
+ limit?: number;
+ /** Keep a walk's position every this many frames (default 8). */
+ every?: number;
+ signal?: AbortSignal;
+ onProgress?: (progress: PathingProgress) => void;
+}
+
+export interface Disposable { dispose(): void }
+
+/** What the view's page found when it loaded: where it reads the game from, or why it cannot play. */
+export interface ViewReady {
+ files: ScmSimStatus["files"];
+ /** When `files` is null: why, in words for the person. */
+ reason: string | null;
+}
+
+/** What the game is drawn with: what the person's copy of the game allows, and what is on. */
+export interface ViewLooks {
+ /** The copy has Remastered's pictures, and whether they are shown. */
+ remastered: boolean;
+ remasteredOn: boolean;
+ /** The copy has the files for Remastered's real-time lighting (on a graphics card that can), and whether it is on. */
+ lighting: boolean;
+ lightingOn: boolean;
+}
+
+/** A replay the view has loaded. It stands paused at frame 0. */
+export interface ViewedReplay {
+ name: string;
+ frames: number;
+ players: { slot: number; name: string; race: number }[];
+ /** In tiles. */
+ map: { width: number; height: number };
+ /** Whether a seek backwards is quick (the engine keeps moments of the game as it goes); otherwise it starts the game again. */
+ rewinds: boolean;
+ looks: ViewLooks;
+ /** Whether the page plays a camera track (`ScmSimView.follow`); a page from before tracks ignores one. */
+ follows: boolean;
+}
+
+/**
+ * Where the camera looks at each moment of the game, for `ScmSimView.follow`: the middle of the view, in map pixels, at
+ * every `every` frames. The page curves through the points by the frame it shows (a fraction of one too), and does not
+ * bend across a cut.
+ */
+export interface ViewTrack {
+ /** Frames between points: point `i` is at frame `i * every`. */
+ every: number;
+ x: Float32Array;
+ y: Float32Array;
+ /** 1 where the camera cuts to its point rather than moving to it from the point before. */
+ cut: Uint8Array;
+}
+
+export interface ViewOptions {
+ /** Fog of war through the eyes of `player` (off at first: a replay is watched seeing everything). */
+ fog?: boolean;
+ /** The game's sounds (on at first) and their volume, 0 to 1. */
+ sound?: boolean;
+ volume?: number;
+ /**
+ * The music of a race (0 zerg, 1 terran, 2 protoss), or none (null, as at first). Not remembered: the play page's own
+ * Music box stays as it is. A page from before music ignores it.
+ */
+ music?: number | null;
+ /** The sounds and the music turned down to this fraction (1 at first), eased there: under a voice of the plugin's own. */
+ duck?: number;
+ /** Remastered's pictures and its lighting, where the copy has them. The same choice as the play page's Options. */
+ remastered?: boolean;
+ lighting?: boolean;
+ /** The game's console under the map (off at first: the editor has the controls, and the map gets the whole height). */
+ console?: boolean;
+}
+
+/** The frame shown: at most ten times a second, and at once when playing or seeking changes. */
+export interface ViewFrame {
+ frame: number;
+ playing: boolean;
+ /** The frame a seek started from while it runs ahead, null otherwise. */
+ seeking: number | null;
+}
+
+/** Where the view looks, after it moved: its middle and the map it shows, in pixels. */
+export interface ViewCamera {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+ /** The person moved it (the minimap, the arrow keys), not `camera`. */
+ byUser: boolean;
+}
+
+/**
+ * What the game's minimap draws, for a minimap of your own: per square its middle and size in
+ * map pixels (`boxes`, four numbers each) and its colour (`colours`, 0xRRGGBB) — the units seen
+ * through the fog when it is on (all of them without it), and what is remembered under it,
+ * dimmed; and the fog itself, to darken a minimap's ground the way the game's is. At most
+ * eight times a second, when the frame, the fog or the eyes changed.
+ */
+export interface ViewUnits {
+ frame: number;
+ boxes: Float32Array;
+ colours: Uint32Array;
+ /**
+ * Per tile, row by row, `wide` tiles a row: 0 never seen, 1 seen before (the game dims it), 2 seen
+ * now. Null with the fog off, and from a game page before scm-sim 0.6.0.
+ */
+ fog: Uint8Array | null;
+ wide: number;
+}
+
+/**
+ * The unit under the pointer, as the game's console would show it — for a tooltip of your own, as
+ * the view shows the game without its console. Only what the eyes the game is seen through can see.
+ */
+export interface ViewHoverUnit {
+ /** The engine's slot for it: the same unit while it lives. */
+ id: number;
+ /** units.dat id. */
+ type: number;
+ /** The player it belongs to, 0–11. */
+ owner: number;
+ hp: number;
+ hpMax: number;
+ /** A unit that cannot be hurt (its hit points are not shown in the game). */
+ invincible: boolean;
+ /** -1 for a unit with no shields. */
+ shields: number;
+ shieldsMax: number;
+ /** -1 for a unit that casts nothing. */
+ energy: number;
+ energyMax: number;
+ /** False while it goes up, trains or is an egg. */
+ completed: boolean;
+ /** What it is busy with, and `workId` what: the unit type made or morphed into, a techdata.dat or upgrades.dat id. */
+ work: "construction" | "train" | "research" | "upgrade" | "morph" | "addon" | "nuke" | null;
+ workId: number;
+ /** 0–100, how far that work (or its own building) has gone; -1 for none. */
+ progress: number;
+ /** The unit types in training, the first under way. */
+ queue: number[];
+ /** What is left in a mineral field, a geyser or a gas building; -1 for anything else. */
+ resources: number;
+}
+
+/**
+ * What the pointer is on over the view: a unit, and where the pointer is in the element's CSS
+ * pixels; null once it is on nothing. At most thirty times a second, when it or the pointer moved.
+ */
+export interface ViewHover {
+ unit: ViewHoverUnit;
+ x: number;
+ y: number;
+}
+
+/** A key pressed while the view had the keyboard that it does not use itself (all but the arrows, which scroll). */
+export interface ViewKey {
+ key: string;
+ code: string;
+ shiftKey: boolean;
+ ctrlKey: boolean;
+ altKey: boolean;
+ metaKey: boolean;
+}
+
+/**
+ * The game itself, drawn by the play page inside an element of yours (`ScmSimService.view`):
+ * the replay played out with the game's own graphics, Remastered's where the person's copy
+ * has them, and its console. You drive it; it says what it shows.
+ */
+export interface ScmSimView {
+ /** Resolves once the page has looked for the game's files (never rejects: `files` is null and `reason` says why). */
+ readonly ready: Promise;
+ /** Plays this replay (a whole .rep file) from the start, paused at frame 0. A load asked for before the last finished replaces it. */
+ load(replay: Uint8Array, name?: string): Promise;
+ play(playing: boolean): void;
+ /** 1 is Fastest, the game's own pace; 2, 4, 8, 16 faster. */
+ speed(multiplier: number): void;
+ /** Goes to `frame`, playing or paused as it was. */
+ seek(frame: number): void;
+ /**
+ * Looks at this point of the map (pixels: the middle of the view). With `height`, shows that
+ * much of the map top to bottom (pixels, 240 to 960; the game's own 480 at first), so the view
+ * can match another's scale — without the console only.
+ */
+ camera(x: number, y: number, height?: number): void;
+ /**
+ * Moves the camera along `track` by the game's clock, in the page's own drawing (smooth however fast the game
+ * plays, and in step with what it shows), until `camera`, another track or null, or the person moving the view
+ * (said as a camera `byUser`). Only where the replay loaded says `follows`.
+ */
+ follow(track: ViewTrack | null): void;
+ /** Whose eyes the fog is drawn through. */
+ player(slot: number): void;
+ options(options: ViewOptions): Promise;
+ onFrame(listener: (frame: ViewFrame) => void): Disposable;
+ onCamera(listener: (camera: ViewCamera) => void): Disposable;
+ /** The squares for a minimap of your own, as the game's minimap draws them. */
+ onUnits(listener: (units: ViewUnits) => void): Disposable;
+ /** The unit under the pointer, for a tooltip; null once the pointer is on nothing (and when it leaves the view). From scm-sim 0.6.0. */
+ onHover(listener: (hover: ViewHover | null) => void): Disposable;
+ /**
+ * A key the view handed back. It is also pressed again on the editor's window, so the
+ * editor's hotkeys and every plugin's act on it as if it had been pressed there.
+ */
+ onKey(listener: (key: ViewKey) => void): Disposable;
+ /** Takes the frame away and the engine with it. */
+ dispose(): void;
+}
+
+export interface ScmSimService {
+ /**
+ * Whether this editor has the engine beside it at all: true at scmjs.dev and on a
+ * stand-in for the site on one's own machine, false in the desktop app and an editor
+ * hosted elsewhere. When false every other call rejects; offer what needs it only
+ * when true.
+ */
+ readonly present: boolean;
+ /** Asks the engine's page whether it can run a game now (it needs the person's copy of StarCraft). Asks again on every call. */
+ status(): Promise;
+ /**
+ * Plays a replay to its end without showing it and resolves with what happened. A
+ * twenty-minute game takes about ten to fifteen seconds; one replay runs at a time and
+ * further calls wait their turn. Rejects with an `Error` worded for the person.
+ */
+ analyse(replay: Uint8Array, options?: AnalyseOptions): Promise;
+ /**
+ * Starts a map on the play page, in a tab of its own (one tab, used again by the next
+ * call). `map` is a whole .scm / .scx file; left out, the open map as Save would write
+ * it. Call it from the click that asks for it: a browser only lets a click open a tab.
+ * It is played as the plugin's Preferences page says (the kind of game, the race, the
+ * slot, melee computers). Resolves when the game is on the page, or, where the page says
+ * to choose on the play page, when the map is on its setup screen.
+ */
+ play(map?: { bytes: Uint8Array; name: string }): Promise;
+ /**
+ * The game drawn inside `element` (filling it), for watching a replay with its own
+ * graphics: see `ScmSimView`. The engine is a gigabyte of memory, so keep one view and
+ * load replays into it rather than one per replay. Added in 0.5.0: check that the
+ * function is there before calling it.
+ */
+ view?(element: HTMLElement): ScmSimView;
+ /**
+ * The game's own pathing for a map: the regions the game builds when it loads it, and units
+ * walked by the game between its start locations (or from one point to others), with how
+ * long each walk took and the way it went. `scenario` is the bare scenario (`.chk` bytes);
+ * left out, the open map. Nothing is shown and nobody plays. Each start location walked
+ * from is a game of its own, a few seconds each: a 4-player map with the five default units
+ * takes about eight seconds. One request runs at a time, replays included. Rejects with an
+ * `Error` worded for the person. Added in 0.7.0, and needs a play page that answers it
+ * (`status().pathing`): check both before offering it.
+ */
+ pathing?(scenario?: Uint8Array, options?: PathingOptions): Promise;
+}