From 777a7c660f628d13e0588dfe778aa17fcb675795 Mon Sep 17 00:00:00 2001 From: Jeany Date: Thu, 15 Oct 2026 21:02:32 -0600 Subject: [PATCH] Walkability 1.4.0: chokes and mining, measured by the game With scm-sim 0.8.0's measure(), the panel's "The game's own pathing" section has the game put a group of twelve through every choke listed (timed till ten of them are through, against one alone; the rest counted as stragglers) and mine at every start location, and any base clicked, with 8, 16 and 24 workers. Chokes are listed by queueing time and labelled on the map; each base's minerals per minute show under its hall, with a warning when one start location mines noticeably less than another. Both go in the report. game.ts: a choke's two ends (a straight line through it on open ground, from one of its areas to the other), a group's queueing and stragglers, the mining gap between starts. Co-Authored-By: Claude Opus 5.5 --- README.md | 32 +++++- dist/plugin.js | 269 ++++++++++++++++++++++++++++++++++++++++++-- game.ts | 76 ++++++++++++- ko.ts | 27 +++++ package.json | 2 +- plugin.json | 4 +- plugin.ts | 188 +++++++++++++++++++++++++++++-- tests/game.test.ts | 57 +++++++++- vendor/scm-sim.d.ts | 102 ++++++++++++++++- 9 files changed, 726 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 686a5c1..271a57a 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ Options: ## 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 +[scm-sim](https://github.com/scm-js/plugin-scm-sim) plugin, version 0.7.0 or later (0.8.0 for the measures), 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. @@ -124,14 +124,40 @@ 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. +### Chokes and mining + +Since 1.4.0, with scm-sim 0.8.0 or later, the same section can have the game measure two +more things. Both are repeatable: measuring the same map twice gives the same numbers. + +**Chokes ▸ Measure** puts a group of twelve units (Dragoons to begin with; the list beside +the button chooses) through every choke the panel lists, those up to *Chokes up to* wide. +For each choke the group stands about eight tiles before it, in one of the two areas it +joins. One of them walks to about eight tiles past it alone, then all twelve walk there +together, and each is timed as it gets through the choke. A choke is judged by the time +for ten of the twelve against the one alone: that is the time the group spends queueing. +The few that go round or wait come through much later, and are counted as stragglers. On +Lost Civilization, twelve Dragoons queue 5 to 17 seconds at the 2.5-tile passages and 2 to +4 seconds at chokes 4 to 8 tiles wide. The list is sorted by queueing time, and the map +shows it beside each choke's width, in red past three seconds. + +**Mining ▸ Measure** mines at every start location. Each gets a Nexus on its spot and +Probes sent to its mineral fields, nearest first, as players split them: first 8, then +16, then 24. After 25 seconds to settle, two game minutes are counted, and the result is +minerals per minute at each count. *Add a base…* asks for one click on the middle of where +a town hall would stand and measures that base too. The panel warns when one start +location mines noticeably less than another (five per cent or more at some worker count): +on Lost Civilization, Player 4's start mines 10 % less than Player 1's with 24 workers. +Gas is not measured, since the game cannot be made to build an Assimilator for the +purpose. + ## 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) | +| `game.ts` | the game's own pathing and measures: the walks matched against the estimate, the findings, a choke's two ends, a group's queueing and stragglers, the mining gap between starts (pure, tested) | +| `vendor/scm-sim.d.ts` | a copy of the scm-sim plugin's service types (`contract.d.ts` at v0.8.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 | diff --git a/dist/plugin.js b/dist/plugin.js index 3876a7c..db188c7 100644 --- a/dist/plugin.js +++ b/dist/plugin.js @@ -790,6 +790,57 @@ function walkedFrom(result, bothWays) { const slots = result.starts.map((s) => s.owner); return bothWays ? slots : slots.slice(0, -1); } +function chokeEnds(a, c, reach = 32) { + const px = (p) => ({ x: p.x * CELL_PX + CELL_PX / 2, y: p.y * CELL_PX + CELL_PX / 2 }); + for (const r of [reach, reach * 0.75, reach * 0.5, reach * 0.35].map(Math.round)) { + let best = null, score = -1; + for (let k = 0; k < 32; k++) { + const dx = Math.cos(k * Math.PI / 16), dy = Math.sin(k * Math.PI / 16); + const end = (sign) => { + for (let step = 1; step <= r; step++) { + const x = Math.round(c.x + dx * step * sign), y = Math.round(c.y + dy * step * sign); + if (x < 0 || y < 0 || x >= a.w || y >= a.h || !a.open[y * a.w + x]) return null; + } + return { x: Math.round(c.x + dx * r * sign), y: Math.round(c.y + dy * r * sign) }; + }; + const p = end(1), q = end(-1); + if (!p || !q) continue; + const ap = a.area[p.y * a.w + p.x], aq = a.area[q.y * a.w + q.x]; + const pair = ap === c.a && aq === c.b ? { from: p, to: q } : ap === c.b && aq === c.a ? { from: q, to: p } : null; + if (!pair) continue; + const room = a.alt[p.y * a.w + p.x] + a.alt[q.y * a.w + q.x]; + if (room > score) { + score = room; + best = { from: px(pair.from), to: px(pair.to) }; + } + } + if (best) return best; + } + return null; +} +var MOST = 10 / 12; +function groupThrough(g) { + const count = g.made, need = Math.max(1, Math.ceil(count * MOST)); + if (g.alone === null || g.times.length < need) return null; + const most = g.times[need - 1]; + return { most, queue: Math.max(0, most - g.alone), stragglers: count - g.times.filter((f) => f <= 2 * most).length, count: need }; +} +function queueFrames(g) { + return groupThrough(g)?.queue ?? null; +} +function miningGap(bases, least = 0.05) { + const starts = bases.filter((b) => b.id !== void 0 && b.rates.length); + let best = null; + const counts = [...new Set(starts.flatMap((b) => b.rates.map((r) => r.workers)))]; + for (const workers of counts) { + const at = starts.map((b) => ({ id: b.id, rate: b.rates.find((r) => r.workers === workers)?.perMinute ?? 0 })).filter((x) => x.rate > 0); + if (at.length < 2) continue; + const fast = at.reduce((x, y) => y.rate > x.rate ? y : x), slow = at.reduce((x, y) => y.rate < x.rate ? y : x); + const share = (fast.rate - slow.rate) / fast.rate; + if (share >= least && (!best || share > best.share)) best = { slow: slow.id, fast: fast.id, workers, share }; + } + return best; +} // ko.ts var KO = { @@ -957,7 +1008,34 @@ var KO = { "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" + "not placed": "\uB193\uC9C0 \uBABB\uD568", + "Walkability: the measuring was stopped": "\uC774\uB3D9 \uAC00\uB2A5 \uC9C0\uD615: \uCE21\uC815\uC744 \uBA48\uCDC4\uC2B5\uB2C8\uB2E4", + "Walkability: no choke to measure": "\uC774\uB3D9 \uAC00\uB2A5 \uC9C0\uD615: \uCE21\uC815\uD560 \uAE38\uBAA9\uC774 \uC5C6\uC2B5\uB2C8\uB2E4", + "Walkability: {n, plural, one {# choke} other {# chokes}} measured, the slowest queueing {s} s": "\uC774\uB3D9 \uAC00\uB2A5 \uC9C0\uD615: \uAE38\uBAA9 {n}\uAC1C\uB97C \uCE21\uC815\uD588\uC2B5\uB2C8\uB2E4. \uAC00\uC7A5 \uC624\uB798 \uBC00\uB9B0 \uACF3\uC740 {s}\uCD08", + "Walkability: {n, plural, one {# base} other {# bases}} measured": "\uC774\uB3D9 \uAC00\uB2A5 \uC9C0\uD615: \uAE30\uC9C0 {n}\uAC1C\uB97C \uCE21\uC815\uD588\uC2B5\uB2C8\uB2E4", + "Click the middle of where the town hall would stand": "\uBCF8\uC9C4 \uAC74\uBB3C\uC774 \uC124 \uC790\uB9AC\uC758 \uAC00\uC6B4\uB370\uB97C \uD074\uB9AD\uD558\uC138\uC694", + "{player}'s start location": "{player}\uC758 \uC2DC\uC791 \uC704\uCE58", + "The base at {x}, {y}": "{x}, {y}\uC758 \uAE30\uC9C0", + "no town hall fits here": "\uBCF8\uC9C4 \uAC74\uBB3C\uC744 \uB193\uC744 \uC218 \uC5C6\uC74C", + "no mineral fields nearby": "\uAC00\uAE4C\uC6B4 \uBBF8\uB124\uB784 \uD544\uB4DC \uC5C6\uC74C", + "{slow}'s start location mines {pct} % less than {fast}'s with {n} workers": "\uC77C\uAFBC {n}\uAE30\uC77C \uB54C {slow}\uC758 \uC2DC\uC791 \uC704\uCE58\uAC00 {fast}\uC758 \uC2DC\uC791 \uC704\uCE58\uBCF4\uB2E4 {pct}% \uB35C \uCEA1\uB2C8\uB2E4", + "did not get through": "\uC9C0\uB098\uAC00\uC9C0 \uBABB\uD568", + "Mining per game minute (8, 16 and 24 workers)": "\uAC8C\uC784 \uC2DC\uAC04 1\uBD84\uB2F9 \uCC44\uCDE8\uB7C9 (\uC77C\uAFBC 8, 16, 24\uAE30)", + "/min": "/\uBD84", + "Measure": "\uCE21\uC815", + "Put a group of twelve through every choke listed, and one alone, and time them": "\uBAA9\uB85D\uC758 \uBAA8\uB4E0 \uAE38\uBAA9\uC5D0 12\uAE30 \uBB34\uB9AC\uC640 1\uAE30\uB97C \uAC01\uAC01 \uC9C0\uB098\uAC00\uAC8C \uD558\uACE0 \uC2DC\uAC04\uC744 \uC7BD\uB2C8\uB2E4", + "12 \xD7": "12 \xD7", + "Mining": "\uCC44\uCDE8", + "Mine at every start location with 8, 16 and 24 workers, and count the minerals per minute": "\uBAA8\uB4E0 \uC2DC\uC791 \uC704\uCE58\uC5D0\uC11C \uC77C\uAFBC 8, 16, 24\uAE30\uB85C \uCC44\uCDE8\uD558\uACE0 \uBD84\uB2F9 \uBBF8\uB124\uB784\uC744 \uC149\uB2C8\uB2E4", + "Add a base\u2026": "\uAE30\uC9C0 \uCD94\uAC00\u2026", + "Click where a town hall would stand, and measure the mining there too": "\uBCF8\uC9C4 \uAC74\uBB3C\uC774 \uC124 \uC790\uB9AC\uB97C \uD074\uB9AD\uD558\uBA74 \uADF8\uACF3\uC758 \uCC44\uCDE8\uB7C9\uB3C4 \uCE21\uC815\uD569\uB2C8\uB2E4", + "Measuring {done} of {total}\u2026": "{total}\uAC1C \uC911 {done}\uAC1C \uCE21\uC815 \uC911\u2026", + "+{q} s ({alone} s alone)": "+{q}\uCD08 (\uD63C\uC790 {alone}\uCD08)", + "Chokes, twelve {unit} at once": "\uAE38\uBAA9, {unit} 12\uAE30 \uB3D9\uC2DC \uD1B5\uACFC", + "Minerals per minute with 8, 16 and 24 workers": "\uC77C\uAFBC 8, 16, 24\uAE30\uC77C \uB54C \uBD84\uB2F9 \uBBF8\uB124\uB784", + ", {n, plural, one {# straggler} other {# stragglers}}": ", \uB4A4\uCC98\uC9C4 \uC720\uB2DB {n}\uAE30", + "{alone} s alone, {most} s for {n} of {count}: {q} s queueing": "\uD63C\uC790 {alone}\uCD08, {count}\uAE30 \uC911 {n}\uAE30 {most}\uCD08: {q}\uCD08 \uBC00\uB9BC", + "Chokes, a group of twelve {unit} against one alone, timed till ten of them are through": "\uAE38\uBAA9, {unit} 12\uAE30 \uBB34\uB9AC\uC640 1\uAE30 \uBE44\uAD50 (10\uAE30\uAC00 \uC9C0\uB0A0 \uB54C\uAE4C\uC9C0)" }; // plugin.ts @@ -1008,6 +1086,7 @@ var STYLE = ` .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-dim { color: var(--text-dim, #99a2b3); } .wlk .wlk-h { font-weight: 600; color: var(--text, #e6e9ef); border-top: 1px solid var(--border, #333); padding-top: 7px; } `; var MODES = [ @@ -1024,13 +1103,14 @@ 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, gameUnits: [...GAME_UNITS], gameBothWays: false }; +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, chokeUnit: 66 }; 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)); + if (!GAME_UNITS.includes(s.chokeUnit)) s.chokeUnit = DEFAULTS.chokeUnit; return s; } function hsl(hue, s, l) { @@ -1080,7 +1160,7 @@ 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 }; + game = { service: null, usable: null, reason: null, result: null, doc: null, stale: false, findings: [], custom: [], canMeasure: null, chokes: null, mining: null, halls: [], unplaced: [], customUnplaced: [], running: null, error: null }; /** The game's regions as a picture, a pixel a minitile. */ regions = null; api; @@ -1239,6 +1319,8 @@ var Session = class { return p.id < (this.game.result?.walks.length ?? 0); case "custom": return p.id < this.game.custom.length; + case "base": + return p.id < (this.game.mining?.length ?? 0); } } clear() { @@ -1278,6 +1360,7 @@ var Session = class { } const status = await sim.status(); this.game.usable = status.available && status.pathing !== null; + this.game.canMeasure = this.game.usable && typeof sim.measure === "function" && status.measure != 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(); } @@ -1363,9 +1446,100 @@ var Session = class { stopGame() { this.game.running?.stop.abort(); } + /** A measure run: the engine asked through `ask`, with progress, a stop, and a word when it fails. */ + async measureRun(what, ask) { + const sim = this.game.service; + if (!sim?.measure || this.game.running) return; + if (!this.api.document.isOpen()) { + this.api.ui.status(t("Walkability: open a map first")); + return; + } + const stop = new AbortController(); + const running = { done: 0, total: 0, stop, what }; + this.game.running = running; + this.game.error = null; + this.notify(); + try { + await ask(sim, { signal: stop.signal, onProgress: (p) => { + running.done = p.done; + running.total = p.total; + this.notify(); + } }); + if (this.game.doc === null) this.game.doc = this.api.document.id(); + } catch (err) { + if (err instanceof Error && err.name === "AbortError") this.api.ui.status(t("Walkability: the measuring was stopped")); + else this.game.error = err instanceof Error ? err.message : String(err); + } finally { + this.game.running = null; + this.redraw(); + this.notify(); + } + } + /** Put a group of twelve through every choke listed (those up to the width set), each against one alone. */ + async measureChokes() { + if (!this.analysis || this.stale) await this.run(); + const a = this.analysis; + if (!a) return; + const unit = this.settings.chokeUnit; + const groups = this.chokes().flatMap((c) => { + const ends = chokeEnds(a, c); + return ends ? [{ id: c.id, unit, count: 12, ...ends }] : []; + }); + if (!groups.length) { + this.api.ui.status(t("Walkability: no choke to measure")); + return; + } + await this.measureRun("chokes", async (sim, options) => { + const r = await sim.measure(void 0, { ...options, groups }); + this.game.chokes = r.groups; + this.game.stale = false; + const worst = r.groups.map(queueFrames).filter((q) => q !== null).reduce((x, y) => Math.max(x, y), 0); + this.api.ui.status(t("Walkability: {n, plural, one {# choke} other {# chokes}} measured, the slowest queueing {s} s", { n: r.groups.length, s: seconds(worst) })); + if (!this.active) this.show(); + }); + } + /** Measure every start location's mining, and the halls added. */ + async measureMining() { + await this.measureRun("mining", async (sim, options) => { + const r = await sim.measure(void 0, { ...options, starts: true, bases: this.game.halls.map((hall) => ({ hall })) }); + this.game.mining = r.bases; + this.game.stale = false; + this.api.ui.status(t("Walkability: {n, plural, one {# base} other {# bases}} measured", { n: r.bases.length })); + if (!this.active) this.show(); + }); + } + /** Add a base by its hall's spot (one click: the hall's middle tile) and measure the mining again. */ + async addBase() { + const tile = await this.api.ui.pickTile({ prompt: t("Click the middle of where the town hall would stand") }); + if (!tile) return; + this.game.halls.push({ x: tile.x * 32 + 16, y: tile.y * 32 + 16 }); + await this.measureMining(); + } + /** How a group got through a choke, in words: short for a list's hint, long for the report. */ + throughText(g, long = false) { + const r = groupThrough(g); + if (!r) return t("did not get through"); + const stragglers = r.stragglers ? t(", {n, plural, one {# straggler} other {# stragglers}}", { n: r.stragglers }) : ""; + return long ? t("{alone} s alone, {most} s for {n} of {count}: {q} s queueing", { alone: seconds(g.alone), most: seconds(r.most), n: r.count, count: g.made, q: seconds(r.queue) }) + stragglers : t("+{q} s ({alone} s alone)", { q: seconds(r.queue), alone: seconds(g.alone) }) + stragglers; + } + /** A base in words: the start location's player, or where its hall is. */ + baseName(b) { + return b.id !== void 0 ? t("{player}'s start location", { player: playerName(b.id) }) : t("The base at {x}, {y}", { x: pxTiles(b.hall.x), y: pxTiles(b.hall.y) }); + } + /** A base's rates in a line: `8: 512 · 16: 848 · 24: 1,112`. */ + rateLine(b) { + if (!b.hallMade) return t("no town hall fits here"); + if (!b.fields) return t("no mineral fields nearby"); + return b.rates.map((r) => `${r.workers}: ${r.perMinute}`).join(" \xB7 "); + } + /** The widest mining gap between start locations, in words; null when they mine alike. */ + miningGapText() { + const gap = this.game.mining ? miningGap(this.game.mining) : null; + return gap ? t("{slow}'s start location mines {pct} % less than {fast}'s with {n} workers", { slow: playerName(gap.slow), fast: playerName(gap.fast), pct: Math.round(gap.share * 100), n: gap.workers }) : null; + } /** 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.result && !this.game.custom.length && !this.game.chokes && !this.game.mining) return; if (!this.game.stale) { this.game.stale = true; this.notify(); @@ -1376,6 +1550,9 @@ var Session = class { this.game.running?.stop.abort(); this.game.result = null; this.game.custom = []; + this.game.chokes = null; + this.game.mining = null; + this.game.halls = []; this.game.findings = []; this.game.unplaced = []; this.game.customUnplaced = []; @@ -1383,7 +1560,7 @@ var Session = class { 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.pick?.kind === "walk" || this.pick?.kind === "custom" || this.pick?.kind === "base") this.pick = null; if (this.settings.mode === "game") this.overlay = null; this.redraw(); this.notify(); @@ -1415,7 +1592,7 @@ var Session = class { /** The game's walks as lines of text, for the report. */ gameReport() { const g = this.game.result; - if (!g && !this.game.custom.length) return []; + if (!g && !this.game.custom.length && !this.game.chokes && !this.game.mining) 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 })}`); @@ -1427,6 +1604,20 @@ var Session = class { } } for (const w of this.game.custom) lines.push(` ${this.walkLabel(w)}: ${describeWalk(w)}`); + const a = this.analysis; + if (this.game.chokes?.length) { + lines.push("", t("Chokes, a group of twelve {unit} against one alone, timed till ten of them are through", { unit: this.unitName(this.game.chokes[0].unit) })); + for (const g2 of this.game.chokes) { + const c = a?.chokes[g2.id ?? -1]; + lines.push(` ${c ? t("{width} tiles wide at {x}, {y}", { width: tiles(c.width), x: tiles(c.x), y: tiles(c.y) }) : "?"}: ${this.throughText(g2, true)}`); + } + } + if (this.game.mining?.length) { + lines.push("", t("Mining per game minute (8, 16 and 24 workers)")); + for (const b of this.game.mining) lines.push(` ${this.baseName(b)}: ${this.rateLine(b)}`); + const gap = this.miningGapText(); + if (gap) lines.push(` ${gap}`); + } 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; @@ -1532,6 +1723,11 @@ var Session = class { else this.api.view.goTo({ kind: "unit", index: a.starts[p.a].index }); break; } + case "base": { + const b = this.game.mining?.[pick.id]; + if (b) this.api.view.center(Math.floor(b.hall.x / 32), Math.floor(b.hall.y / 32)); + break; + } case "walk": case "custom": { const w = this.walkOf(pick); @@ -1748,7 +1944,8 @@ var Session = class { ctx.beginPath(); ctx.arc(cx(c.x), cy(c.y), r, 0, Math.PI * 2); ctx.stroke(); - if (showLabels) label(`${tiles(c.width)}`, cx(c.x), cy(c.y) - r - 10, "#ffd166"); + const measured = this.game.chokes?.find((g) => g.id === c.id), q = measured ? queueFrames(measured) : null; + if (showLabels) label(measured ? `${tiles(c.width)} \xB7 ${q === null ? "\u2715" : `+${seconds(q)} s`}` : `${tiles(c.width)}`, cx(c.x), cy(c.y) - r - 10, measured && (q === null || q > 72) ? "#ff8a7a" : "#ffd166"); } } if (this.settings.seams) { @@ -1810,6 +2007,12 @@ var Session = class { ctx.setLineDash([]); } this.drawWalks(ctx, view, showLabels, label); + if (showLabels) { + this.game.mining?.forEach((b, i) => { + const picked = this.pick?.kind === "base" && this.pick.id === i; + label(`${this.rateLine(b)}${b.hallMade && b.fields ? ` ${t("/min")}` : ""}`, view.x(b.hall.x), view.y(b.hall.y) + 1.5 * view.tilePx + 28, picked ? "#fff" : b.hallMade && b.fields ? "#9fe6a0" : "#ff8a7a"); + }); + } if (this.hover) { ctx.strokeStyle = "#fff"; ctx.lineWidth = 1; @@ -2009,7 +2212,7 @@ function mountPanel(session, body) { 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", "game"]); + const openSections = /* @__PURE__ */ new Set(["problems", "game", "chokes-measured", "mining"]); let cover = null; if (session.game.service && session.game.usable === null) void session.checkGame(); function renderGame() { @@ -2045,8 +2248,28 @@ function mountPanel(session, body) { 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 (g.canMeasure) { + const unitSel = W.select(GAME_UNITS.map((u) => ({ value: u, label: session.unitName(u) })), { value: s.chokeUnit, onChange: (v) => { + s.chokeUnit = Number(v); + session.save(); + } }); + gameBox.append(h("div", { className: "wlk-row" }, h("label", null, t("Chokes")), h( + "div", + { className: "wlk-in" }, + W.button(t("Measure"), { disabled: running !== null, title: t("Put a group of twelve through every choke listed, and one alone, and time them"), onClick: () => void session.measureChokes() }), + h("span", { className: "wlk-dim" }, t("12 \xD7")), + unitSel + ))); + gameBox.append(h("div", { className: "wlk-row" }, h("label", null, t("Mining")), h( + "div", + { className: "wlk-in" }, + W.button(t("Measure"), { disabled: running !== null, title: t("Mine at every start location with 8, 16 and 24 workers, and count the minerals per minute"), onClick: () => void session.measureMining() }), + W.button(t("Add a base\u2026"), { ghost: true, disabled: running !== null, title: t("Click where a town hall would stand, and measure the mining there too"), onClick: () => void session.addBase() }) + ))); + } 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") })); + const measuring = running.what === "chokes" || running.what === "mining"; + gameBox.append(W.spinner({ size: "sm", label: !running.total ? t("Starting the game\u2026") : measuring ? t("Measuring {done} of {total}\u2026", { done: running.done, total: running.total }) : t("Walking {done} of {total}\u2026", { done: running.done, total: running.total }) })); return; } if (g.error) gameBox.append(h("div", { className: "wlk-status", style: "color:#ff9f7a" }, g.error)); @@ -2092,6 +2315,34 @@ function mountPanel(session, body) { 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) }))); } + const a = session.analysis; + if (g.chokes?.length) { + const queue = (x) => queueFrames(x) ?? Infinity; + const rows = [...g.chokes].sort((x, y) => queue(y) - queue(x)).map((m) => { + const c = a?.chokes[m.id ?? -1], q = queueFrames(m); + return { + label: c ? t("{width} tiles wide at {x}, {y}", { width: tiles(c.width), x: tiles(c.x), y: tiles(c.y) }) : "?", + hint: session.throughText(m), + bad: q === null || q > 72 || (groupThrough(m)?.stragglers ?? 0) > 1, + on: session.pick?.kind === "choke" && session.pick.id === m.id, + pick: m.id !== void 0 && c ? { kind: "choke", id: m.id } : null + }; + }); + gameBox.append(section(session, openSections, "chokes-measured", t("Chokes, twelve {unit} at once", { unit: session.unitName(g.chokes[0].unit) }), rows.length, rows, "", g.stale ? t("out of date") : void 0)); + } + if (g.mining?.length) { + const rows = g.mining.map((b, i) => ({ + label: session.baseName(b), + hint: session.rateLine(b), + bad: !b.hallMade || !b.fields, + color: b.id !== void 0 ? api.palette.playerColor(b.id) : void 0, + on: session.pick?.kind === "base" && session.pick.id === i, + pick: { kind: "base", id: i } + })); + const gap = session.miningGapText(); + if (gap) rows.unshift({ label: gap, bad: true, pick: null }); + gameBox.append(section(session, openSections, "mining", t("Minerals per minute with 8, 16 and 24 workers"), g.mining.length, rows, "", g.stale ? t("out of date") : void 0)); + } } function render() { renderGame(); diff --git a/game.ts b/game.ts index 96a79f3..8a0f57f 100644 --- a/game.ts +++ b/game.ts @@ -5,8 +5,8 @@ * 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 type { MeasureBaseResult, MeasureGroupResult, PathingResult, PathingWalk } from "./vendor/scm-sim"; +import { CELL_PX, type Analysis, type Choke } 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. */ @@ -116,3 +116,75 @@ export function walkedFrom(result: PathingResult, bothWays: boolean): number[] { const slots = result.starts.map((s) => s.owner); return bothWays ? slots : slots.slice(0, -1); } + +/* ── Measures: groups through chokes, and each base's mining ── */ + +/** + * Two points a group walks between to cross a choke: `reach` cells out on each side, along a straight line through + * its middle that stays on open ground, ending in the two areas the choke joins (from area `a` to area `b`). Of the + * lines that do, the one ending furthest from walls. Shorter reaches are tried when none does; null when nothing fits. + * Map pixels. + */ +export function chokeEnds(a: Analysis, c: Choke, reach = 32): { from: { x: number; y: number }; to: { x: number; y: number } } | null { + const px = (p: { x: number; y: number }) => ({ x: p.x * CELL_PX + CELL_PX / 2, y: p.y * CELL_PX + CELL_PX / 2 }); + for (const r of [reach, reach * 0.75, reach * 0.5, reach * 0.35].map(Math.round)) { + let best: { from: { x: number; y: number }; to: { x: number; y: number } } | null = null, score = -1; + for (let k = 0; k < 32; k++) { + const dx = Math.cos((k * Math.PI) / 16), dy = Math.sin((k * Math.PI) / 16); + const end = (sign: number) => { + for (let step = 1; step <= r; step++) { + const x = Math.round(c.x + dx * step * sign), y = Math.round(c.y + dy * step * sign); + if (x < 0 || y < 0 || x >= a.w || y >= a.h || !a.open[y * a.w + x]) return null; + } + return { x: Math.round(c.x + dx * r * sign), y: Math.round(c.y + dy * r * sign) }; + }; + const p = end(1), q = end(-1); + if (!p || !q) continue; + const ap = a.area[p.y * a.w + p.x], aq = a.area[q.y * a.w + q.x]; + const pair = ap === c.a && aq === c.b ? { from: p, to: q } : ap === c.b && aq === c.a ? { from: q, to: p } : null; + if (!pair) continue; + const room = a.alt[p.y * a.w + p.x] + a.alt[q.y * a.w + q.x]; + if (room > score) { score = room; best = { from: px(pair.from), to: px(pair.to) }; } + } + if (best) return best; + } + return null; +} + +/** The share of a group whose time stands for it: a few stragglers go round or wait, and would decide the last. */ +export const MOST = 10 / 12; + +/** + * How a group got through a choke: the frames for most of it (`MOST`, ten of twelve) against one alone, the time it + * spent queueing (the difference), and the stragglers (those through after twice that, or not at all). Null when one + * alone or most of the group did not get through. + */ +export function groupThrough(g: MeasureGroupResult): { most: number; queue: number; stragglers: number; count: number } | null { + const count = g.made, need = Math.max(1, Math.ceil(count * MOST)); + if (g.alone === null || g.times.length < need) return null; + const most = g.times[need - 1]; + return { most, queue: Math.max(0, most - g.alone), stragglers: count - g.times.filter((f) => f <= 2 * most).length, count: need }; +} + +/** The frames a group spent queueing (`groupThrough`); null when it did not get through. */ +export function queueFrames(g: MeasureGroupResult): number | null { + return groupThrough(g)?.queue ?? null; +} + +/** + * The widest gap in mining between the bases of two start locations (their `id` a player slot), at the worker count + * where it is widest, when it is at least `least` (a share: 0.05 is five per cent). Null when the starts mine alike. + */ +export function miningGap(bases: readonly MeasureBaseResult[], least = 0.05): { slow: number; fast: number; workers: number; share: number } | null { + const starts = bases.filter((b) => b.id !== undefined && b.rates.length); + let best: { slow: number; fast: number; workers: number; share: number } | null = null; + const counts = [...new Set(starts.flatMap((b) => b.rates.map((r) => r.workers)))]; + for (const workers of counts) { + const at = starts.map((b) => ({ id: b.id!, rate: b.rates.find((r) => r.workers === workers)?.perMinute ?? 0 })).filter((x) => x.rate > 0); + if (at.length < 2) continue; + const fast = at.reduce((x, y) => (y.rate > x.rate ? y : x)), slow = at.reduce((x, y) => (y.rate < x.rate ? y : x)); + const share = (fast.rate - slow.rate) / fast.rate; + if (share >= least && (!best || share > best.share)) best = { slow: slow.id, fast: fast.id, workers, share }; + } + return best; +} diff --git a/ko.ts b/ko.ts index 476479d..378a703 100644 --- a/ko.ts +++ b/ko.ts @@ -166,4 +166,31 @@ export const KO: Record = { "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": "놓지 못함", + "Walkability: the measuring was stopped": "이동 가능 지형: 측정을 멈췄습니다", + "Walkability: no choke to measure": "이동 가능 지형: 측정할 길목이 없습니다", + "Walkability: {n, plural, one {# choke} other {# chokes}} measured, the slowest queueing {s} s": "이동 가능 지형: 길목 {n}개를 측정했습니다. 가장 오래 밀린 곳은 {s}초", + "Walkability: {n, plural, one {# base} other {# bases}} measured": "이동 가능 지형: 기지 {n}개를 측정했습니다", + "Click the middle of where the town hall would stand": "본진 건물이 설 자리의 가운데를 클릭하세요", + "{player}'s start location": "{player}의 시작 위치", + "The base at {x}, {y}": "{x}, {y}의 기지", + "no town hall fits here": "본진 건물을 놓을 수 없음", + "no mineral fields nearby": "가까운 미네랄 필드 없음", + "{slow}'s start location mines {pct} % less than {fast}'s with {n} workers": "일꾼 {n}기일 때 {slow}의 시작 위치가 {fast}의 시작 위치보다 {pct}% 덜 캡니다", + "did not get through": "지나가지 못함", + "Mining per game minute (8, 16 and 24 workers)": "게임 시간 1분당 채취량 (일꾼 8, 16, 24기)", + "/min": "/분", + "Measure": "측정", + "Put a group of twelve through every choke listed, and one alone, and time them": "목록의 모든 길목에 12기 무리와 1기를 각각 지나가게 하고 시간을 잽니다", + "12 ×": "12 ×", + "Mining": "채취", + "Mine at every start location with 8, 16 and 24 workers, and count the minerals per minute": "모든 시작 위치에서 일꾼 8, 16, 24기로 채취하고 분당 미네랄을 셉니다", + "Add a base…": "기지 추가…", + "Click where a town hall would stand, and measure the mining there too": "본진 건물이 설 자리를 클릭하면 그곳의 채취량도 측정합니다", + "Measuring {done} of {total}…": "{total}개 중 {done}개 측정 중…", + "+{q} s ({alone} s alone)": "+{q}초 (혼자 {alone}초)", + "Chokes, twelve {unit} at once": "길목, {unit} 12기 동시 통과", + "Minerals per minute with 8, 16 and 24 workers": "일꾼 8, 16, 24기일 때 분당 미네랄", + ", {n, plural, one {# straggler} other {# stragglers}}": ", 뒤처진 유닛 {n}기", + "{alone} s alone, {most} s for {n} of {count}: {q} s queueing": "혼자 {alone}초, {count}기 중 {n}기 {most}초: {q}초 밀림", + "Chokes, a group of twelve {unit} against one alone, timed till ten of them are through": "길목, {unit} 12기 무리와 1기 비교 (10기가 지날 때까지)", }; diff --git a/package.json b/package.json index 23cc454..936e1ff 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "scmjs-plugin-walkability", - "version": "1.3.0", + "version": "1.4.0", "private": true, "description": "Walkability — a plugin for the scmJS StarCraft map editor.", "author": "Jeany", diff --git a/plugin.json b/plugin.json index 00a7ddf..03b943a 100644 --- a/plugin.json +++ b/plugin.json @@ -1,8 +1,8 @@ { "name": "Walkability", "id": "walkability", - "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.", + "version": "1.4.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, time groups through the chokes, and measure each base's mining.", "author": "Jeany", "homepage": "https://github.com/scm-js/plugin-walkability", "entry": "plugin.ts", diff --git a/plugin.ts b/plugin.ts index ad2d9a6..cfd737e 100644 --- a/plugin.ts +++ b/plugin.ts @@ -29,10 +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 { chokeEnds, describeWalk, findings, GAME_UNITS, groupThrough, miningGap, outcomes, queueFrames, seconds, 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"; +import type { MeasureBaseResult, MeasureGroupResult, PathingResult, PathingWalk, ScmSimService } from "./vendor/scm-sim"; /* ── DOM helpers ────────────────────────────────────────── */ @@ -86,6 +86,7 @@ const STYLE = ` .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-dim { color: var(--text-dim, #99a2b3); } .wlk .wlk-h { font-weight: 600; color: var(--text, #e6e9ef); border-top: 1px solid var(--border, #333); padding-top: 7px; } `; @@ -130,9 +131,11 @@ interface Settings { gameUnits: number[]; /** Walk both ways between two start locations. */ gameBothWays: boolean; + /** units.dat id of the group of twelve put through the chokes. */ + chokeUnit: number; } -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 }; +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, chokeUnit: 66 }; function loadSettings(api: PluginApi): Settings { const stored = api.storage.get>("settings", {}); @@ -140,6 +143,7 @@ function loadSettings(api: PluginApi): Settings { 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)); + if (!GAME_UNITS.includes(s.chokeUnit)) s.chokeUnit = DEFAULTS.chokeUnit; return s; } @@ -185,6 +189,8 @@ type Pick = /** A walk of the game's: `PathingResult.walks[id]`, or `customWalks[id]`. */ | { kind: "walk"; id: number } | { kind: "custom"; id: number } + /** A base whose mining was measured: `GameState.mining[id]`. */ + | { kind: "base"; id: number } | null; /** A colour per unit the game walks, by its place in `GAME_UNITS`. */ @@ -204,10 +210,16 @@ interface GameState { findings: Finding[]; /** Walks between two points the person picked. */ custom: PathingWalk[]; + /** Whether the engine can measure (scm-sim 0.8.0 and a page that answers it); null until asked. */ + canMeasure: boolean | null; + /** A group of twelve through each choke measured (`id` is the choke's), and each base's mining; the halls the person added. */ + chokes: MeasureGroupResult[] | null; + mining: MeasureBaseResult[] | null; + halls: { x: number; y: number }[]; /** 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; + running: { done: number; total: number; stop: AbortController; what: "map" | "points" | "chokes" | "mining" } | null; error: string | null; } @@ -245,7 +257,7 @@ 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 }; + game: GameState = { service: null, usable: null, reason: null, result: null, doc: null, stale: false, findings: [], custom: [], canMeasure: null, chokes: null, mining: null, halls: [], unplaced: [], customUnplaced: [], running: null, error: null }; /** The game's regions as a picture, a pixel a minitile. */ regions: HTMLCanvasElement | null = null; @@ -375,6 +387,7 @@ class Session { 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; + case "base": return p.id < (this.game.mining?.length ?? 0); } } @@ -413,6 +426,7 @@ class Session { } const status = await sim.status(); this.game.usable = status.available && status.pathing !== null; + this.game.canMeasure = this.game.usable && typeof sim.measure === "function" && (status as { measure?: number | null }).measure != 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(); } @@ -489,9 +503,97 @@ class Session { stopGame() { this.game.running?.stop.abort(); } + /** A measure run: the engine asked through `ask`, with progress, a stop, and a word when it fails. */ + private async measureRun(what: "chokes" | "mining", ask: (sim: ScmSimService, options: { signal: AbortSignal; onProgress: (p: { done: number; total: number }) => void }) => Promise) { + const sim = this.game.service; + if (!sim?.measure || this.game.running) return; + if (!this.api.document.isOpen()) { this.api.ui.status(t("Walkability: open a map first")); return; } + const stop = new AbortController(); + const running = { done: 0, total: 0, stop, what }; + this.game.running = running; + this.game.error = null; + this.notify(); + try { + await ask(sim, { signal: stop.signal, onProgress: (p) => { running.done = p.done; running.total = p.total; this.notify(); } }); + if (this.game.doc === null) this.game.doc = this.api.document.id(); + } catch (err) { + if (err instanceof Error && err.name === "AbortError") this.api.ui.status(t("Walkability: the measuring was stopped")); + else this.game.error = err instanceof Error ? err.message : String(err); + } finally { + this.game.running = null; + this.redraw(); + this.notify(); + } + } + + /** Put a group of twelve through every choke listed (those up to the width set), each against one alone. */ + async measureChokes() { + if (!this.analysis || this.stale) await this.run(); + const a = this.analysis; + if (!a) return; + const unit = this.settings.chokeUnit; + const groups = this.chokes().flatMap((c) => { const ends = chokeEnds(a, c); return ends ? [{ id: c.id, unit, count: 12, ...ends }] : []; }); + if (!groups.length) { this.api.ui.status(t("Walkability: no choke to measure")); return; } + await this.measureRun("chokes", async (sim, options) => { + const r = await sim.measure!(undefined, { ...options, groups }); + this.game.chokes = r.groups; + this.game.stale = false; + const worst = r.groups.map(queueFrames).filter((q): q is number => q !== null).reduce((x, y) => Math.max(x, y), 0); + this.api.ui.status(t("Walkability: {n, plural, one {# choke} other {# chokes}} measured, the slowest queueing {s} s", { n: r.groups.length, s: seconds(worst) })); + if (!this.active) this.show(); + }); + } + + /** Measure every start location's mining, and the halls added. */ + async measureMining() { + await this.measureRun("mining", async (sim, options) => { + const r = await sim.measure!(undefined, { ...options, starts: true, bases: this.game.halls.map((hall) => ({ hall })) }); + this.game.mining = r.bases; + this.game.stale = false; + this.api.ui.status(t("Walkability: {n, plural, one {# base} other {# bases}} measured", { n: r.bases.length })); + if (!this.active) this.show(); + }); + } + + /** Add a base by its hall's spot (one click: the hall's middle tile) and measure the mining again. */ + async addBase() { + const tile = await this.api.ui.pickTile({ prompt: t("Click the middle of where the town hall would stand") }); + if (!tile) return; + this.game.halls.push({ x: tile.x * 32 + 16, y: tile.y * 32 + 16 }); + await this.measureMining(); + } + + /** How a group got through a choke, in words: short for a list's hint, long for the report. */ + throughText(g: MeasureGroupResult, long = false): string { + const r = groupThrough(g); + if (!r) return t("did not get through"); + const stragglers = r.stragglers ? t(", {n, plural, one {# straggler} other {# stragglers}}", { n: r.stragglers }) : ""; + return long + ? t("{alone} s alone, {most} s for {n} of {count}: {q} s queueing", { alone: seconds(g.alone!), most: seconds(r.most), n: r.count, count: g.made, q: seconds(r.queue) }) + stragglers + : t("+{q} s ({alone} s alone)", { q: seconds(r.queue), alone: seconds(g.alone!) }) + stragglers; + } + + /** A base in words: the start location's player, or where its hall is. */ + baseName(b: MeasureBaseResult): string { + return b.id !== undefined ? t("{player}'s start location", { player: playerName(b.id) }) : t("The base at {x}, {y}", { x: pxTiles(b.hall.x), y: pxTiles(b.hall.y) }); + } + + /** A base's rates in a line: `8: 512 · 16: 848 · 24: 1,112`. */ + rateLine(b: MeasureBaseResult): string { + if (!b.hallMade) return t("no town hall fits here"); + if (!b.fields) return t("no mineral fields nearby"); + return b.rates.map((r) => `${r.workers}: ${r.perMinute}`).join(" · "); + } + + /** The widest mining gap between start locations, in words; null when they mine alike. */ + miningGapText(): string | null { + const gap = this.game.mining ? miningGap(this.game.mining) : null; + return gap ? t("{slow}'s start location mines {pct} % less than {fast}'s with {n} workers", { slow: playerName(gap.slow), fast: playerName(gap.fast), pct: Math.round(gap.share * 100), n: gap.workers }) : null; + } + /** 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.result && !this.game.custom.length && !this.game.chokes && !this.game.mining) return; if (!this.game.stale) { this.game.stale = true; this.notify(); } } @@ -500,6 +602,9 @@ class Session { this.game.running?.stop.abort(); this.game.result = null; this.game.custom = []; + this.game.chokes = null; + this.game.mining = null; + this.game.halls = []; this.game.findings = []; this.game.unplaced = []; this.game.customUnplaced = []; @@ -507,7 +612,7 @@ class Session { 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.pick?.kind === "walk" || this.pick?.kind === "custom" || this.pick?.kind === "base") this.pick = null; if (this.settings.mode === "game") this.overlay = null; this.redraw(); this.notify(); @@ -537,7 +642,7 @@ class Session { /** 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 []; + if (!g && !this.game.custom.length && !this.game.chokes && !this.game.mining) 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 })}`); @@ -549,6 +654,20 @@ class Session { } } for (const w of this.game.custom) lines.push(` ${this.walkLabel(w)}: ${describeWalk(w)}`); + const a = this.analysis; + if (this.game.chokes?.length) { + lines.push("", t("Chokes, a group of twelve {unit} against one alone, timed till ten of them are through", { unit: this.unitName(this.game.chokes[0].unit) })); + for (const g of this.game.chokes) { + const c = a?.chokes[g.id ?? -1]; + lines.push(` ${c ? t("{width} tiles wide at {x}, {y}", { width: tiles(c.width), x: tiles(c.x), y: tiles(c.y) }) : "?"}: ${this.throughText(g, true)}`); + } + } + if (this.game.mining?.length) { + lines.push("", t("Mining per game minute (8, 16 and 24 workers)")); + for (const b of this.game.mining) lines.push(` ${this.baseName(b)}: ${this.rateLine(b)}`); + const gap = this.miningGapText(); + if (gap) lines.push(` ${gap}`); + } 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; @@ -635,6 +754,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 "base": { const b = this.game.mining?.[pick.id]; if (b) this.api.view.center(Math.floor(b.hall.x / 32), Math.floor(b.hall.y / 32)); 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(); @@ -837,7 +957,8 @@ class Session { ctx.beginPath(); ctx.arc(cx(c.x), cy(c.y), r, 0, Math.PI * 2); ctx.stroke(); - if (showLabels) label(`${tiles(c.width)}`, cx(c.x), cy(c.y) - r - 10, "#ffd166"); + const measured = this.game.chokes?.find((g) => g.id === c.id), q = measured ? queueFrames(measured) : null; + if (showLabels) label(measured ? `${tiles(c.width)} · ${q === null ? "✕" : `+${seconds(q)} s`}` : `${tiles(c.width)}`, cx(c.x), cy(c.y) - r - 10, measured && (q === null || q > 72) ? "#ff8a7a" : "#ffd166"); } } // Seams: a marker per cluster so a two-minitile seam is not lost at far zoom. @@ -903,6 +1024,13 @@ class Session { ctx.setLineDash([]); } this.drawWalks(ctx, view, showLabels, label); + // Each base's mining, under its hall. + if (showLabels) { + this.game.mining?.forEach((b, i) => { + const picked = this.pick?.kind === "base" && this.pick.id === i; + label(`${this.rateLine(b)}${b.hallMade && b.fields ? ` ${t("/min")}` : ""}`, view.x(b.hall.x), view.y(b.hall.y) + 1.5 * view.tilePx + 28, picked ? "#fff" : b.hallMade && b.fields ? "#9fe6a0" : "#ff8a7a"); + }); + } // The hovered cell. if (this.hover) { ctx.strokeStyle = "#fff"; @@ -1068,7 +1196,7 @@ function mountPanel(session: Session, body: HTMLElement): () => void { 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", "game"]); + const openSections = new Set(["problems", "game", "chokes-measured", "mining"]); let cover: BusyHandle | null = null; if (session.game.service && session.game.usable === null) void session.checkGame(); @@ -1094,8 +1222,18 @@ function mountPanel(session: Session, body: HTMLElement): () => void { 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 (g.canMeasure) { + const unitSel = W.select(GAME_UNITS.map((u) => ({ value: u, label: session.unitName(u) })), { value: s.chokeUnit, onChange: (v) => { s.chokeUnit = Number(v); session.save(); } }); + gameBox.append(h("div", { className: "wlk-row" }, h("label", null, t("Chokes")), h("div", { className: "wlk-in" }, + W.button(t("Measure"), { disabled: running !== null, title: t("Put a group of twelve through every choke listed, and one alone, and time them"), onClick: () => void session.measureChokes() }), + h("span", { className: "wlk-dim" }, t("12 ×")), unitSel))); + gameBox.append(h("div", { className: "wlk-row" }, h("label", null, t("Mining")), h("div", { className: "wlk-in" }, + W.button(t("Measure"), { disabled: running !== null, title: t("Mine at every start location with 8, 16 and 24 workers, and count the minerals per minute"), onClick: () => void session.measureMining() }), + W.button(t("Add a base…"), { ghost: true, disabled: running !== null, title: t("Click where a town hall would stand, and measure the mining there too"), onClick: () => void session.addBase() })))); + } 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…") })); + const measuring = running.what === "chokes" || running.what === "mining"; + gameBox.append(W.spinner({ size: "sm", label: !running.total ? t("Starting the game…") : measuring ? t("Measuring {done} of {total}…", { done: running.done, total: running.total }) : t("Walking {done} of {total}…", { done: running.done, total: running.total }) })); return; } if (g.error) gameBox.append(h("div", { className: "wlk-status", style: "color:#ff9f7a" }, g.error)); @@ -1131,6 +1269,34 @@ function mountPanel(session: Session, body: HTMLElement): () => void { 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) }))); } + const a = session.analysis; + if (g.chokes?.length) { + const queue = (x: MeasureGroupResult) => queueFrames(x) ?? Infinity; + const rows: Row[] = [...g.chokes].sort((x, y) => queue(y) - queue(x)).map((m) => { + const c = a?.chokes[m.id ?? -1], q = queueFrames(m); + return { + label: c ? t("{width} tiles wide at {x}, {y}", { width: tiles(c.width), x: tiles(c.x), y: tiles(c.y) }) : "?", + hint: session.throughText(m), + bad: q === null || q > 72 || (groupThrough(m)?.stragglers ?? 0) > 1, + on: session.pick?.kind === "choke" && session.pick.id === m.id, + pick: m.id !== undefined && c ? { kind: "choke", id: m.id } : null, + }; + }); + gameBox.append(section(session, openSections, "chokes-measured", t("Chokes, twelve {unit} at once", { unit: session.unitName(g.chokes[0].unit) }), rows.length, rows, "", g.stale ? t("out of date") : undefined)); + } + if (g.mining?.length) { + const rows: Row[] = g.mining.map((b, i) => ({ + label: session.baseName(b), + hint: session.rateLine(b), + bad: !b.hallMade || !b.fields, + color: b.id !== undefined ? api.palette.playerColor(b.id) : undefined, + on: session.pick?.kind === "base" && session.pick.id === i, + pick: { kind: "base", id: i } as Pick, + })); + const gap = session.miningGapText(); + if (gap) rows.unshift({ label: gap, bad: true, pick: null }); + gameBox.append(section(session, openSections, "mining", t("Minerals per minute with 8, 16 and 24 workers"), g.mining.length, rows, "", g.stale ? t("out of date") : undefined)); + } } function render() { diff --git a/tests/game.test.ts b/tests/game.test.ts index 0b15191..81e8aa3 100644 --- a/tests/game.test.ts +++ b/tests/game.test.ts @@ -1,7 +1,7 @@ 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"; +import { chokeEnds, describeWalk, groupThrough, estimatePair, findings, miningGap, outcomes, queueFrames, seconds, unplaced, walkedFrom, walkFocus } from "../game"; +import type { MeasureBaseResult, MeasureGroupResult, 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 { @@ -78,3 +78,56 @@ describe("what Walkability makes of the game's walks", () => { expect(walkedFrom(r, true)).toEqual([0, 3, 5]); }); }); + +/** 80 × 40 cells: a wall down column 40 with a gap at rows 16 to 22; area 0 left of it, area 1 right. */ +function walled(): Analysis { + const w = 80, h = 40, n = w * h; + const open = new Uint8Array(n), area = new Int32Array(n), alt = new Float32Array(n); + for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) { + const i = y * w + x, wall = x === 40 && (y < 16 || y > 22); + open[i] = wall ? 0 : 1; + area[i] = wall ? -1 : x < 40 ? 0 : 1; + alt[i] = wall ? 0 : Math.min(Math.abs(x - 40) + (y >= 16 && y <= 22 ? 4 : 0), 10); + } + return { w, h, open, area, alt } as unknown as Analysis; +} + +describe("the measures", () => { + it("finds a line through a choke from one of its areas to the other", () => { + const a = walled(); + const ends = chokeEnds(a, { id: 0, x: 40, y: 19, clearance: 3, width: 7, a: 0, b: 1 }); + expect(ends).not.toBeNull(); + // From area 0 (the left) to area 1 (the right), 32 cells either side: map pixels. + expect(ends!.from.x).toBeLessThan(40 * 8 - 100); + expect(ends!.to.x).toBeGreaterThan(40 * 8 + 100); + // The other way round when the choke names the areas the other way. + const back = chokeEnds(a, { id: 0, x: 40, y: 19, clearance: 3, width: 7, a: 1, b: 0 })!; + expect(back.from.x).toBeGreaterThan(back.to.x); + }); + + it("finds nothing where no open line crosses", () => { + const a = walled(); + for (let i = 0; i < a.open.length; i++) if (i % a.w > 41) a.open[i] = 0; + expect(chokeEnds(a, { id: 0, x: 40, y: 19, clearance: 3, width: 7, a: 0, b: 1 })).toBeNull(); + }); + + it("measures the queue from most of the group against one alone, and counts the stragglers", () => { + const g = (alone: number | null, times: number[], made = 12) => ({ alone, times, made } as MeasureGroupResult); + // Ten of twelve through by 132; two much later. + const ten = [52, 55, 61, 71, 73, 79, 79, 84, 96, 132, 454, 517]; + expect(groupThrough(g(52, ten))).toEqual({ most: 132, queue: 80, stragglers: 2, count: 10 }); + expect(queueFrames(g(52, ten))).toBe(80); + // One that never got through is a straggler too. + expect(groupThrough(g(60, ten.slice(0, 11)))?.stragglers).toBe(2); + expect(queueFrames(g(140, [100, 110, 120, 130, 130, 130, 130, 130, 130, 130]))).toBe(0); + expect(queueFrames(g(null, ten))).toBeNull(); + expect(queueFrames(g(52, ten.slice(0, 9)))).toBeNull(); + }); + + it("finds the widest gap in mining between start locations", () => { + const base = (id: number | undefined, rates: number[]) => ({ id, rates: [8, 16, 24].map((workers, i) => ({ workers, mined: 0, perMinute: rates[i] })) } as MeasureBaseResult); + const bases = [base(0, [510, 850, 1110]), base(1, [540, 810, 1070]), base(3, [510, 780, 990]), base(undefined, [100, 100, 100])]; + expect(miningGap(bases)).toEqual({ slow: 3, fast: 0, workers: 24, share: (1110 - 990) / 1110 }); + expect(miningGap(bases, 0.2)).toBeNull(); + }); +}); diff --git a/vendor/scm-sim.d.ts b/vendor/scm-sim.d.ts index 0220fbc..5758a49 100644 --- a/vendor/scm-sim.d.ts +++ b/vendor/scm-sim.d.ts @@ -1,4 +1,4 @@ -// 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. +// A copy of scm-js/plugin-scm-sim's contract.d.ts at v0.8.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", …)`. @@ -167,6 +167,95 @@ export interface ScmSimStatus { * asked or its page is from before `pathing` (scm-sim 0.7.0). */ pathing: number | null; + /** + * The `MeasureResult.version` the engine answers `measure` with; null when it could not be + * asked or its page is from before `measure` (scm-sim 0.8.0). + */ + measure: number | null; +} + +/* ── A map's measures (`ScmSimService.measure`) ── */ + +/** A group trial: `count` (1 to 12, one selection) of `unit` walked from `from` to `to`. `id` comes back with the result. */ +export interface MeasureGroup { id?: number; unit: number; count: number; from: PathingPoint; to: PathingPoint } +/** A base to mine at: its hall's centre, as a start location gives it. */ +export interface MeasureBase { id?: number; hall: PathingPoint } + +export interface MeasureGroupResult { + /** The id asked with, else the group's place in `groups`. */ + id?: number; + unit: number; + count: number; + from: PathingPoint; + to: PathingPoint; + /** How many of the group the game made (fewer when there was not room). */ + made: number; + /** + * Frames from the order until the one of the group nearest `to`, walking alone, is through: + * two tiles past the middle of the way from `from` to `to` (for a choke's two ends, past the + * choke). Timed there and not at `to`, where a group jostles for the one point long after it + * is through. Null when it did not get through. 24 frames is a second at Fastest. + */ + alone: number | null; + /** The same for the first and the last of the whole group, walking together; null when none got through. The last less `alone` is the time spent queueing. */ + first: number | null; + last: number | null; + /** + * The frames of each of the group that got through, soonest first. A group in a narrow place + * often gets most of the way through quickly and leaves a few stragglers that went round or + * waited; the time for most of it (say ten of twelve) says more of the choke than `last`. + */ + times: number[]; + /** How many of the group got through, and how many stood still ten seconds still meaning to move. */ + through: number; + stuck: number; +} + +export interface MeasureBaseRate { + workers: number; + /** Minerals taken from the base's own fields in the frames measured, and that per game minute at Fastest. */ + mined: number; + perMinute: number; +} + +export interface MeasureBaseResult { + /** The id asked with; a start location's player slot for `starts`. */ + id?: number; + hall: PathingPoint; + /** False when the game would not take a Nexus on the spot (then there are no rates). */ + hallMade: boolean; + /** The base's mineral fields (within ten tiles of the hall, nearer it than any other hall measured) and the minerals in them. */ + fields: number; + minerals: number; + rates: MeasureBaseRate[]; +} + +export interface MeasureResult { + version: number; + /** Tiles. */ + map: { width: number; height: number }; + groups: MeasureGroupResult[]; + bases: MeasureBaseResult[]; +} + +/** `starting`: the engine and the first game; `measuring`: `done` of `total` trials. */ +export interface MeasureProgress { stage: "starting" | "measuring"; done: number; total: number } + +export interface MeasureOptions { + /** Groups to walk, each against one of them alone, timed as they get through the middle of the way. Groups near each other are run in different games. */ + groups?: MeasureGroup[]; + /** Bases to mine at, and `starts` for the base at every start location. */ + bases?: MeasureBase[]; + starts?: boolean; + /** Worker counts to mine with (default 8, 16, 24; at most 24). */ + workers?: number[]; + /** Frames before the minerals are counted (default 600) and the frames they are counted over (default 2,858, two game minutes). */ + warmup?: number; + frames?: number; + /** Frames a group trial may take (default 3,000). */ + limit?: number; + signal?: AbortSignal; + onProgress?: (progress: MeasureProgress) => void; } /* ── A map's pathing (`ScmSimService.pathing`) ── */ @@ -507,4 +596,15 @@ export interface ScmSimService { * (`status().pathing`): check both before offering it. */ pathing?(scenario?: Uint8Array, options?: PathingOptions): Promise; + /** + * Measures of a map taken by the game itself: groups of units through chokes against one + * unit alone (the time a group spends queueing), and each base's mining per game minute + * at several worker counts, read from what goes from its own mineral fields. Gas is not + * measured. `scenario` is the bare scenario (`.chk` bytes); left out, the open map. + * Nobody plays and the map is not changed. A game for the bases and one for each set of + * groups far enough apart, a few seconds each; one request runs at a time. Rejects with an + * `Error` worded for the person. Added in 0.8.0, and needs a play page that answers it + * (`status().measure`): check both before offering it. + */ + measure?(scenario?: Uint8Array, options?: MeasureOptions): Promise; }