diff --git a/README.md b/README.md index 50891e0..058fbe8 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ procedural graphics (no asset downloads), and a simulation modeled on the classi | 💰 **Budget & taxes** | Live tax slider, itemized income/upkeep ledger, debt warnings | | 🚗 **Traffic** | Animated cars flow along your road network | | 🌗 **Day/night cycle** | Windows light up as the sun sets | -| 🪄 **Auto-Build advisor** | One click founds a town, then keeps power ahead of demand, connects zoned land, adds districts and services where coverage lags — always keeping an emergency reserve | +| 🪄 **City Advisor (Auto-Build)** | Phased planning policy: founds towns with an RCI gradient, projects power demand ~14 months ahead with tech diversity, connects orphaned plots with collector roads, sites services by maximal marginal coverage, keeps industry set back from homes — one big purchase per pass, emergency reserve always intact | | 🎯 **Goals & milestones** | From *Outpost* to *Megalopolis*, with an onboarding quest list | | 💾 **Saves** | Autosave, 3 manual slots, JSON export/import | | 📱 **Touch support** | Paint with one finger, pinch-zoom, two-finger rotate | diff --git a/shots/01-boot.png b/shots/01-boot.png index ab50e7e..7feecc8 100644 Binary files a/shots/01-boot.png and b/shots/01-boot.png differ diff --git a/shots/scene-autobuild.png b/shots/scene-autobuild.png index b7b7a76..f77d887 100644 Binary files a/shots/scene-autobuild.png and b/shots/scene-autobuild.png differ diff --git a/src/game/autobuilder.js b/src/game/autobuilder.js index c36dbe2..1202581 100644 --- a/src/game/autobuilder.js +++ b/src/game/autobuilder.js @@ -1,43 +1,214 @@ import { STRUCT, ZONE, BUILDINGS, START_MONEY } from '../config.js'; -import { lineTiles } from '../utils.js'; /** - * AutoBuilder — a SimCity-style advisor that spends one "planning pass" - * per call, always keeping an emergency reserve. Priorities: - * 1. Power headroom (blackouts stall everything) - * 2. Road access for zoned land (nothing develops without a road) - * 3. New districts when demand is high and empty zone plots run out - * 4. Missing service coverage over developed tiles - * 5. Leisure to push land value over upgrade thresholds + * AutoBuilder — a phased city advisor, not a spammer. + * + * Design principles + * ----------------- + * 1. PHASED POLICY — priorities shift with population (founding → village → + * town → city), and the emergency reserve grows with the phase. + * 2. MARGINAL-COVERAGE SITING — services go where they unlock the most + * previously-uncovered developed tiles, not just "near the middle". + * 3. SEPARATION — industry keeps its distance from housing; commerce acts + * as the buffer along main streets. + * 4. PROJECTION — power capacity targets ~14 months of expected growth, + * not today's snapshot, with type diversity so one tech never dominates. + * 5. INFILL BEFORE SPRAWL — connect and fill existing zoned land before + * founding new districts. + * 6. BUDGET DISCIPLINE — at most one big-ticket purchase per pass; never + * dip below the phase reserve. */ + +const BIG_TICKET = 1400; // ≥ this costs counts as a major purchase +const COVERAGE_TARGET = [0.36, 0.32, 0.28, 0.24]; // per phase, service avg +const RESERVE_BY_PHASE = [1200, 2600, 5200, 9000]; + export class AutoBuilder { constructor(city) { this.city = city; - this.reserve = Math.max(1500, Math.round(START_MONEY * 0.12)); + this.bigSpends = 0; + this._dev = null; } get g() { return this.city.grid; } - canAfford(cost) { return this.city.money - this.reserve >= cost; } + get phase() { + const p = this.city.stats.pop; + return p < 150 ? 0 : p < 600 ? 1 : p < 2000 ? 2 : 3; + } + get reserve() { return Math.max(Math.round(START_MONEY * 0.08), RESERVE_BY_PHASE[this.phase]); } - /** Run one pass; returns human-readable list of what was built. */ + canAfford(cost) { + if (this.city.money - this.reserve < cost) return false; + if (cost >= BIG_TICKET && this.bigSpends >= 1) return false; + return true; + } + markSpend(cost) { if (cost >= BIG_TICKET) this.bigSpends++; } + + /** One planning pass. Returns human-readable action summaries. */ run() { + this.bigSpends = 0; + this._dev = null; const actions = []; - for (const step of [this.stepGenesis, this.stepPower, this.stepRoadAccess, - this.stepDistricts, this.stepServices, this.stepLeisure]) { - try { const r = step.call(this); if (r) { actions.push(r); this.city.recomputePower(); } } catch { /* keep going */ } + const steps = [this.stepGenesis, this.stepPower, this.stepRoadAccess, + this.stepDistricts, this.stepServices, this.stepLeisure]; + for (const step of steps) { + try { + const r = step.call(this); + if (r) { + actions.push(r); + this.city.recomputePower(); + // stop burning money once we're close to the reserve floor + if (this.city.money < this.reserve + 700) break; + } + } catch { /* a failing step must never kill the pass */ } } return actions; } - /** Blank-map bootstrap: lay a main street through open land and zone - * homes on one side, shops + industry on the other. */ + // ================= analysis helpers ================= + + devTiles() { + if (!this._dev) { + const g = this.g, out = []; + for (let i = 0; i < g.n; i++) if (g.level[i] > 0) out.push(i); + this._dev = out; + } + return this._dev; + } + + centroid() { + const dev = this.devTiles(), g = this.g; + if (!dev.length) return null; + let sx = 0, sz = 0; + for (const i of dev) { sx += i % g.size; sz += (i / g.size) | 0; } + return [(sx / dev.length) | 0, (sz / dev.length) | 0]; + } + + coverageAvg(map) { + const dev = this.devTiles(); + if (!dev.length) return 1; + let sum = 0; + for (const i of dev) sum += map[i]; + return sum / dev.length; + } + + freeForBuilding(i) { + const g = this.g; + return g.terrain[i] === 0 && !g.struct[i] && !g.rubble[i] && !g.burning[i] && !g.level[i]; + } + + canPlaceAt(x, z, w = 1, h = 1) { + const g = this.g; + for (let dz = 0; dz < h; dz++) for (let dx = 0; dx < w; dx++) { + if (!g.inB(x + dx, z + dz)) return false; + if (!this.freeForBuilding(g.idx(x + dx, z + dz))) return false; + } + return true; + } + + place(sid, cx, cz, opts = {}) { + const meta = BUILDINGS[sid]; + if (!this.canAfford(meta.cost)) return null; + const spot = this.bestSpot({ + w: meta.w, h: meta.h, cx, cz, + pool: opts.pool, score: opts.score, minScore: opts.minScore ?? -Infinity + }); + if (!spot) return null; + const r = this.city.placeStruct(sid, spot[0], spot[1]); + if (!r.ok) return null; + this.markSpend(meta.cost); + return { x: spot[0], z: spot[1], gain: spot.gain }; + } + + /** Evaluate a pool of candidate top-left corners with a scorer and pick + * the best placeable one. Scorer returns higher = better. */ + bestSpot({ w = 1, h = 1, cx = 16, cz = 16, pool = null, score = null, minScore = -Infinity }) { + const g = this.g; + const cands = pool ?? this.candidatePool(cx, cz); + let best = null, bestVal = minScore; + for (const [x, z] of cands) { + let ok = true; + for (let dz = -1; dz <= h && ok; dz++) { + for (let dx = -1; dx <= w; dx++) { + if (!g.inB(x + dx, z + dz)) { ok = false; break; } + const j = g.idx(x + dx, z + dz); + // buildings may claim EMPTY zoned plots (same rule as manual play) + const claimable = this.freeForBuilding(j) || + (g.zone[j] !== 0 && !g.level[j] && !g.struct[j] && !g.rubble[j] && !g.burning[j]); + if (!claimable) { ok = false; break; } + } + } + if (!ok) continue; + const val = score ? score(x, z) : 0; + if (val > bestVal) { bestVal = val; best = [x, z]; best.gain = val; } + } + return best; + } + + /** Free spots sampled around developed areas, plus a spiral fallback. */ + candidatePool(cx, cz, k = 34) { + const g = this.g, S = g.size, out = [], seen = new Set(); + const dev = this.devTiles(); + const push = (x, z) => { + if (!g.inB(x, z)) return; + const key = g.idx(x, z); + if (!seen.has(key) && this.freeForBuilding(key)) { + seen.add(key); out.push([x, z]); + } + }; + // ring samples around random developed tiles + let guard = 400; + while (out.length < k && dev.length && guard-- > 0) { + const i = dev[(Math.random() * dev.length) | 0]; + const x = i % S, z = (i / S) | 0; + const ang = Math.random() * Math.PI * 2; + const rad = 2 + ((Math.random() * 5) | 0); + push(Math.round(x + Math.cos(ang) * rad), Math.round(z + Math.sin(ang) * rad)); + } + // spiral fallback from the given centre + if (out.length < 8) { + for (let r = 2; r < 34 && out.length < 12; r += 2) { + for (let a = 0; a < 8; a++) { + push(Math.round(cx + Math.cos(a) * r), Math.round(cz + Math.sin(a) * r)); + } + } + } + return out; + } + + /** Population growth per month, smoothed from history. */ + growthPerMonth() { + const h = this.city.history; + if (h.length < 3) return 2; // optimistic default for young towns + const a = h[Math.max(0, h.length - 13)], b = h[h.length - 1]; + const months = Math.max(1, b.m - a.m); + return (b.pop - a.pop) / months; + } + + distToNearestResTile(x, z, maxR) { + const g = this.g; + for (let r = 0; r <= maxR; r++) { + for (let dz = -r; dz <= r; dz++) for (let dx = -r; dx <= r; dx++) { + if (Math.max(Math.abs(dx), Math.abs(dz)) !== r) continue; + const nx = x + dx, nz = z + dz; + if (!g.inB(nx, nz)) continue; + if (g.zone[g.idx(nx, nz)] === ZONE.RES) return r; + } + } + return maxR + 1; + } + + // ================= steps ================= + + /** Blank-map bootstrap with a proper RCI gradient: + * homes north of the avenue, shops buffering south-west, + * industry pushed to the far south-east corner of the street. */ stepGenesis() { const g = this.g, S = g.size; let any = false; for (let i = 0; i < g.n && !any; i++) if (g.struct[i] || g.zone[i]) any = true; if (any) return null; - // find the longest clear horizontal runway near the vertical middle let best = null, bestLen = 0; for (let z = (S >> 2); z < S - (S >> 2); z++) { let run = 0, startX = -1; @@ -68,250 +239,285 @@ export class AutoBuilder { if (tiles.length >= 6) this.city.placeZone(zid, tiles); }; const half = ((len / 2) | 0) - 1; + + // homes: entire north bank band(x0, x0 + len - 1, z - 3, z - 1, ZONE.RES); - band(mid - half, mid + half, z + 1, z + 2, ZONE.COM); - band(mid + 2, x0 + len - 1, z + 1, z + 3, ZONE.IND); - return `Founded the town: main street \u00d7${len} with homes, shops and industry`; + // shops: south-west block, buffering homes from industry + band(mid - half, mid - 1, z + 1, z + 3, ZONE.COM); + // industry: south-east, pushed two extra tiles away from homes + band(mid + 2, x0 + len - 1, z + 2, z + 4, ZONE.IND); + return 'Founded the town: avenue, homes north, shops buffering, industry set back'; } - // ---------- helpers ---------- - - freeForBuilding(i) { - const g = this.g; - return g.terrain[i] === 0 && !g.struct[i] && !g.rubble[i] && !g.burning[i] && !g.level[i]; - } - - findSpot(w, h, cx, cz, allowZone = true) { - const g = this.g; - for (let r = 1; r < 42; r++) { - for (let z = cz - r; z <= cz + r; z++) { - for (let x = cx - r; x <= cx + r; x++) { - let okSpot = true; - for (let dz = -1; dz <= h && okSpot; dz++) { - for (let dx = -1; dx <= w; dx++) { - if (!g.inB(x + dx, z + dz)) { okSpot = false; break; } - const j = g.idx(x + dx, z + dz); - const blocked = !this.freeForBuilding(j) || (allowZone ? false : !!g.zone[j]); - // allow claiming EMPTY zoned plots (same rule as manual placement) - const zonedOk = allowZone && g.zone[j] !== 0 && !g.level[j] && !g.struct[j] && !g.rubble[j] && !g.burning[j] && g.terrain[j] === 0; - if (blocked && !zonedOk) { okSpot = false; break; } - } - } - if (okSpot) return [x, z]; - } - } - } - return null; - } - - place(sid, w = 1, h = 1, nearX = 32, nearZ = 32) { - const cost = BUILDINGS[sid].cost; - if (!this.canAfford(cost)) return null; - const p = this.findSpot(w, h, nearX, nearZ); - if (!p) return null; - const r = this.city.placeStruct(sid, p[0], p[1]); - return r.ok ? p : null; - } - - developedCentroid() { - const g = this.g; - let sx = 0, sz = 0, n = 0; - for (let i = 0; i < g.n; i++) { - if (g.level[i] > 0 || g.struct[i]) { - sx += i % g.size; sz += (i / g.size) | 0; n++; - } - } - if (!n) return null; - return [(sx / n) | 0, (sz / n) | 0]; - } - - avgCoverage(map) { - const g = this.g; - let sum = 0, n = 0; - for (let i = 0; i < g.n; i++) { - if (g.level[i] > 0) { sum += map[i]; n++; } - } - return n ? sum / n : 1; // nothing developed → services not needed yet - } - - // ---------- steps ---------- - + /** Keep capacity ahead of PROJECTED demand with tech diversity. */ stepPower() { const s = this.city.stats; - // plan ahead: zoned land with zero capacity needs the first plant NOW + this.city.recomputePower(); + let hasZones = false; for (let i = 0; i < this.g.n && !hasZones; i++) if (this.g.zone[i]) hasZones = true; - const strain = s.brownouts > 0 || s.powerUse > s.powerCap * 0.65 || - (s.powerCap === 0 && hasZones); - if (!strain) return null; - const deficit = Math.max(600, s.powerUse * 0.8 - s.powerCap); + + const growth = this.growthPerMonth(); + const projPop = Math.max(s.pop, s.pop + growth * 14); + const scale = Math.min(2.4, Math.max(1, projPop / Math.max(s.pop, 25))); + const projected = s.powerUse * scale + (s.powerCap === 0 && hasZones ? 240 : 0); + const need = projected * 1.18 - s.powerCap; + + if (need <= 0 && s.brownouts === 0) return null; + if (!hasZones && s.powerCap === 0) return null; + + // size the plant to the gap let sid; - if (deficit > 3000 && this.canAfford(BUILDINGS[STRUCT.COAL].cost)) sid = STRUCT.COAL; - else if (deficit > 1200 && this.canAfford(BUILDINGS[STRUCT.SOLAR].cost)) sid = STRUCT.SOLAR; + if (need > 2600 && this.canAfford(BUILDINGS[STRUCT.COAL].cost)) sid = STRUCT.COAL; + else if (need > 1100 && this.canAfford(BUILDINGS[STRUCT.SOLAR].cost)) sid = STRUCT.SOLAR; else sid = STRUCT.WIND; - const c = this.developedCentroid() || [32, 32]; - const p = this.place(sid, BUILDINGS[sid].w, BUILDINGS[sid].h, c[0], c[1]); - return p ? `${BUILDINGS[sid].name} for ${deficit | 0}W deficit` : null; + + // diversity: never let one tech exceed 3 units if an alternative fits + const counts = {}; + for (let i = 0; i < this.g.n; i++) { + const st = this.g.struct[i]; + if (st >= STRUCT.COAL && st <= STRUCT.WIND && i === this.g.anchor[i]) { + counts[st] = (counts[st] || 0) + 1; + } + } + if ((counts[sid] || 0) >= 3) { + const alt = [STRUCT.WIND, STRUCT.SOLAR, STRUCT.COAL] + .find(t => t !== sid && (counts[t] || 0) < 3 && this.canAfford(BUILDINGS[t].cost)); + if (alt) sid = alt; + } + + const ctr = this.centroid() || [32, 32]; + const p = this.place(sid, ctr[0], ctr[1], { + pool: this.plantPool(), + // keep plants a polite distance from homes + score: (x, z) => -Math.max(0, 4 - this.distToNearestResTile(x, z, 5)), + minScore: -4 + }); + return p ? `${BUILDINGS[sid].name} — capacity headroom for ${Math.round(projected)}W projected draw` : null; } - /** Zoned-empty plots with no road within 2 tiles never develop — connect them. */ + plantPool() { + // wider pool than usual: plants are noisy, look everywhere + const g = this.g, out = [], seen = new Set(); + let guard = 500; + while (out.length < 26 && guard-- > 0) { + const x = 3 + ((Math.random() * (g.size - 6)) | 0); + const z = 3 + ((Math.random() * (g.size - 6)) | 0); + const j = g.idx(x, z); + if (seen.has(j)) continue; + seen.add(j); + if (this.freeForBuilding(j)) out.push([x, z]); + } + return out; + } + + distToNearestRoad(x, z, maxR) { + const g = this.g; + for (let r = 0; r <= maxR; r++) { + for (let dz = -r; dz <= r; dz++) for (let dx = -r; dx <= r; dx++) { + if (Math.max(Math.abs(dx), Math.abs(dz)) !== r) continue; + const nx = x + dx, nz = z + dz; + if (!g.inB(nx, nz)) continue; + if (g.struct[g.idx(nx, nz)] === STRUCT.ROAD) return r; + } + } + return maxR + 1; + } + + /** Cluster orphaned zoned plots and serve them with ONE collector road + * (straight L), instead of a zig-zag spur per plot. */ stepRoadAccess() { const g = this.g, S = g.size; - let orphan = null; - outer: - for (let z = 2; z < S - 2; z++) { + const orphans = []; + for (let z = 2; z < S - 2 && orphans.length < 40; z++) { for (let x = 2; x < S - 2; x++) { const i = g.idx(x, z); - if (g.zone[i] && !g.level[i] && !g.struct[i] && !g.rubble[i]) { - if (!this.city.roadNear(x, z, 3)) { orphan = [x, z]; break outer; } + if (g.zone[i] && !g.level[i] && !g.struct[i] && !g.rubble[i] && + !this.city.roadNear(x, z, 3)) { + orphans.push([x, z]); } } } - if (!orphan) return null; + if (!orphans.length) return null; - // BFS to nearest existing road through buildable land, then lay it - const [ox, oz] = orphan; - const prev = new Map(); - const q = [[ox, oz]]; - const seen = new Set([g.idx(ox, oz)]); - let goal = null; - while (q.length && !goal) { - const [x, z] = q.shift(); - for (const [dx, dz] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { - const nx = x + dx, nz = z + dz; - if (!g.inB(nx, nz)) continue; - const j = g.idx(nx, nz); - if (seen.has(j)) continue; - seen.add(j); - if (g.struct[j] === STRUCT.ROAD) { goal = [nx, nz]; prev.set(j, g.idx(x, z)); break; } - if (g.terrain[j] !== 0 || g.struct[j] || g.rubble[j] || g.level[j]) continue; - prev.set(j, g.idx(x, z)); - q.push([nx, nz]); - } + // cluster around the first orphan + const [ox, oz] = orphans[0]; + const cluster = orphans.filter(([x, z]) => Math.max(Math.abs(x - ox), Math.abs(z - oz)) <= 6); + + let cx = 0, cz = 0; + for (const [x, z] of cluster) { cx += x; cz += z; } + cx = Math.round(cx / cluster.length); cz = Math.round(cz / cluster.length); + + let goal = null, goalDist = Infinity; + for (let i = 0; i < g.n; i++) { + if (g.struct[i] !== STRUCT.ROAD) continue; + const x = i % S, z = (i / S) | 0; + const d = Math.abs(x - cx) + Math.abs(z - cz); + if (d < goalDist) { goalDist = d; goal = [x, z]; } } - if (!goal) return null; + if (!goal || goalDist > 16) return null; - // walk back from goal to orphan, laying road on every tile except the - // final one adjacent to the orphan (that's the connection point) - let laid = 0; - let cur = g.idx(goal[0], goal[1]); - const orphanIdx = g.idx(ox, oz); - while (cur !== orphanIdx && laid < 14) { - const x = cur % S, z = (cur / S) | 0; - if (g.struct[cur] !== STRUCT.ROAD) { + const cost = goalDist * BUILDINGS[STRUCT.ROAD].cost; + if (!this.canAfford(cost)) return null; + + // straight L-path from the road towards the cluster centroid + let laid = 0, x = goal[0], z = goal[1]; + while ((x !== cx || z !== cz) && laid < goalDist) { + if (Math.abs(cx - x) >= Math.abs(cz - z)) x += Math.sign(cx - x); + else z += Math.sign(cz - z); + const j = g.idx(x, z); + if (g.struct[j] !== STRUCT.ROAD) { + if (g.terrain[j] !== 0 || g.struct[j] || g.level[j] || g.rubble[j]) break; if (!this.canAfford(BUILDINGS[STRUCT.ROAD].cost)) break; this.city.placeStruct(STRUCT.ROAD, x, z); laid++; } - if (!prev.has(cur)) break; - cur = prev.get(cur); } - return laid ? `Road spur ×${laid} to reach zoned land` : null; + return laid ? `Collector road ×${laid} serving ${cluster.length} zoned plots` : null; } - /** Stamp a fresh district (road spur + flanking zone bands) for the - * demand type that most needs land. */ + /** Infill first; found a scored, separated district only when needed. */ stepDistricts() { const s = this.city.stats; - const needs = [ + const wants = [ { d: s.resDemand, z: ZONE.RES, name: 'Homes' }, { d: s.comDemand, z: ZONE.COM, name: 'Shops' }, { d: s.indDemand, z: ZONE.IND, name: 'Industry' } ].sort((a, b) => b.d - a.d); - const want = needs[0]; - if (want.d < 0.22) return null; + const want = wants[0]; + if (want.d < 0.2) return null; - // count empty zoned plots for that type; plenty left → no need const g = this.g; - let empty = 0; + let emptyServed = 0; for (let i = 0; i < g.n; i++) { if (g.zone[i] === want.z && !g.level[i] && !g.struct[i] && - this.city.roadNear(i % g.size, (i / g.size) | 0, 2)) empty++; + this.city.roadNear(i % g.size, (i / g.size) | 0, 2)) emptyServed++; } - if (empty >= 14) return null; + if (emptyServed >= 12) return null; // infill potential remains - // seed: road tile with the most buildable space around its far end - const seeds = []; + // score every road-end runway into open space + let best = null, bestScore = -Infinity; for (let i = 0; i < g.n; i++) { if (g.struct[i] !== STRUCT.ROAD) continue; - seeds.push(i); - } - if (!seeds.length) return null; - let best = null, bestScore = -1; - for (const si of seeds) { - const sx = si % g.size, sz = (si / g.size) | 0; - for (const [dx, dz] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { - // measure open runway 6 tiles out in this direction + const sx = i % g.size, sz = (i / g.size) | 0; + for (const dir of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { + const [dx, dz] = dir; let open = 0; - for (let k = 1; k <= 6; k++) { + for (let k = 1; k <= 7; k++) { const x = sx + dx * k, z = sz + dz * k; if (!g.inB(x, z)) break; const j = g.idx(x, z); if (g.terrain[j] !== 0 || g.struct[j] || g.level[j] || g.rubble[j]) break; open++; } - if (open > bestScore) { bestScore = open; best = [sx, sz, dx, dz]; } + if (open < 5) continue; + const ex = sx + dx * open, ez = sz + dz * open; + let score = open; + if (want.z === ZONE.IND) { + // industry wants DISTANCE from housing + score += Math.min(10, this.distToNearestResTile(ex, ez, 9)) * 1.6; + } else if (want.z === ZONE.RES) { + score -= Math.max(0, 3 - this.distToNearestResTile(ex, ez, 4)) * 0.8; + } + if (score > bestScore) { bestScore = score; best = [sx, sz, dx, dz, open]; } } } - if (!best || bestScore < 4) return null; - const [sx, sz, dx, dz] = best; + if (!best) return null; - // budget guard for the whole district - const estCost = (bestScore + 1) * BUILDINGS[STRUCT.ROAD].cost; - if (!this.canAfford(estCost)) return null; - - // lay the spur - let endX = sx, endZ = sz; - for (let k = 1; k <= bestScore; k++) { - endX = sx + dx * k; endZ = sz + dz * k; - this.city.placeStruct(STRUCT.ROAD, endX, endZ); + const [sx, sz, dx, dz, open] = best; + if (!this.canAfford((open + 2) * BUILDINGS[STRUCT.ROAD].cost)) return null; + let ex = sx, ez = sz; + for (let k = 1; k <= open; k++) { + ex = sx + dx * k; ez = sz + dz * k; + this.city.placeStruct(STRUCT.ROAD, ex, ez); } - // perpendicular axis for bands + + // perpendicular bands with separation filters const px = dz !== 0 ? 1 : 0, pz = dx !== 0 ? 1 : 0; + let zoned = 0; for (const side of [1, -1]) { const tiles = []; for (let along = -2; along <= 2; along++) { for (let depth = 1; depth <= 3; depth++) { - const bx = endX + px * side * depth + dx * along; - const bz = endZ + pz * side * depth + dz * along; + const bx = ex + px * side * depth + dx * along; + const bz = ez + pz * side * depth + dz * along; if (!g.inB(bx, bz)) continue; const j = g.idx(bx, bz); - if (this.freeForBuilding(j)) tiles.push([bx, bz]); + if (!this.freeForBuilding(j)) continue; + if (want.z === ZONE.IND && this.distToNearestResTile(bx, bz, 3) <= 3) continue; + tiles.push([bx, bz]); } } - if (tiles.length >= 6) this.city.placeZone(want.z, tiles); + if (tiles.length >= 5) { this.city.placeZone(want.z, tiles); zoned += tiles.length; } } - return `${want.name} district: spur + two zone bands`; + return zoned ? `${want.name} district (+${zoned} plots${want.z === ZONE.IND ? ', set back from homes' : ''})` : null; } + /** Greedy maximal-coverage siting for the most deficient service. */ stepServices() { const c = this.city; - const plan = [ + const roster = [ { sid: STRUCT.POLICE, map: c.mapPolice, name: 'Police' }, { sid: STRUCT.FIRE, map: c.mapFire, name: 'Fire Stn' }, { sid: STRUCT.HOSPITAL, map: c.mapHealth, name: 'Hospital' }, { sid: STRUCT.SCHOOL, map: c.mapEdu, name: 'School' } ]; - const worst = plan - .map(p => ({ ...p, avg: this.avgCoverage(p.map) })) + const target = roster + .map(r => ({ ...r, avg: this.coverageAvg(r.map) })) .sort((a, b) => a.avg - b.avg)[0]; - if (!worst || worst.avg > 0.34) return null; - const ctr = this.developedCentroid(); - if (!ctr) return null; - // services like being NEAR town but not in the middle of zones — search - // outward from centroid; empty-zone claims are allowed - const p = this.place(worst.sid, 1, 1, ctr[0] + 4, ctr[1] + 3); - return p ? `${worst.name} (coverage was ${(worst.avg * 100) | 0}%)` : null; + if (!target || this.devTiles().length < 6) return null; + if (target.avg > COVERAGE_TARGET[this.phase]) return null; + if (!this.canAfford(BUILDINGS[target.sid].cost)) return null; + + const meta = BUILDINGS[target.sid]; + const R = meta.radius; + const ctr = this.centroid(); + const p = this.place(target.sid, ctr[0], ctr[1], { + pool: this.candidatePool(ctr[0], ctr[1], 40), + score: (x, z) => { + let gain = 0; + const g = this.g; + for (const i of this.devTiles()) { + if (target.map[i] >= 0.4) continue; // already covered + const ix = i % g.size, iz = (i / g.size) | 0; + if (Math.max(Math.abs(ix - x), Math.abs(iz - z)) <= R) gain++; + } + return gain; + }, + minScore: 4 + }); + return p ? `${target.name} sited to cover ${p.gain} unserved buildings` : null; } + /** Parks go where leisure is weakest across homes, not at random. */ stepLeisure() { - if (this.avgCoverage(this.city.mapLeisure) > 0.30) return null; - if (this.city.stats.pop < 60) return null; - const ctr = this.developedCentroid(); - if (!ctr) return null; - const pick = this.canAfford(BUILDINGS[STRUCT.PLAZA].cost) && - Math.random() < 0.5 ? STRUCT.PLAZA : STRUCT.PARK; - const p = this.place(pick, 1, 1, ctr[0] - 3, ctr[1] + 4); - return p ? BUILDINGS[pick].name : null; + if (this.city.stats.pop < 50) return null; + const g = this.g; + let worst = null, worstVal = Infinity; + for (const i of this.devTiles()) { + if (g.zone[i] !== ZONE.RES) continue; + const v = this.city.mapLeisure[i] * 2 + g.landValue[i] * 0.02; + if (v < worstVal) { worstVal = v; worst = i; } + } + if (!worst) return null; + const wx = worst % g.size, wz = (worst / g.size) | 0; + + const sid = this.canAfford(BUILDINGS[STRUCT.PLAZA].cost) && Math.random() < 0.45 + ? STRUCT.PLAZA : STRUCT.PARK; + const R = BUILDINGS[sid].radius || 7; + const p = this.place(sid, wx, wz, { + pool: this.candidatePool(wx, wz, 22), + score: (x, z) => { + let gain = 0; + for (const i of this.devTiles()) { + if (this.city.mapLeisure[i] >= 0.35) continue; + const ix = i % g.size, iz = (i / g.size) | 0; + if (Math.max(Math.abs(ix - x), Math.abs(iz - z)) <= R) gain++; + } + return gain; + }, + minScore: 3 + }); + return p ? `${BUILDINGS[sid].name} in the least-served pocket` : null; } } diff --git a/src/main.js b/src/main.js index a89ecfd..c475a2d 100644 --- a/src/main.js +++ b/src/main.js @@ -169,10 +169,13 @@ class Game { const tick = (now) => { this._raf = requestAnimationFrame(tick); this._lastFrameAt = now; - const dt = Math.min(0.05, (now - this._lastT) / 1000); + const gapSec = Math.min(30, (now - this._lastT) / 1000); + const dt = Math.min(0.05, gapSec); // render/pan delta stays gentle this._lastT = now; - this.advance(dt); + // simulation advances in WALL time (bounded by tick-guard inside + // advance), so slow rendering never slows the city clock + this.advance(gapSec); this._syncSoon(); if (this._parts) { diff --git a/tests/engine.mjs b/tests/engine.mjs index 85d5cd7..9295e1d 100644 --- a/tests/engine.mjs +++ b/tests/engine.mjs @@ -292,5 +292,42 @@ console.log('— save / load integrity —'); ok(actions2.length <= 2, `second pass is conservative (${actions2.length} actions)`); } +// ---------------- advisor professionalism ---------------- +{ + // RCI separation: industry must keep its distance from homes + const c9 = new City(31337, 'Zonedville'); + const a9 = new AutoBuilder(c9); + a9.run(); + const g9 = c9.grid, S = g9.size; + const resTiles = [], indTiles = []; + for (let i = 0; i < g9.n; i++) { + if (g9.zone[i] === ZONE.RES) resTiles.push([i % S, (i / S) | 0]); + if (g9.zone[i] === ZONE.IND) indTiles.push([i % S, (i / S) | 0]); + } + ok(resTiles.length > 0 && indTiles.length > 0, `genesis zones both banks (res ${resTiles.length}, ind ${indTiles.length})`); + let minDist = Infinity; + for (const [rx, rz] of resTiles) for (const [ix, iz] of indTiles) { + const d = Math.max(Math.abs(rx - ix), Math.abs(rz - iz)); + if (d < minDist) minDist = d; + } + ok(minDist >= 3, `industry set back from homes (min gap ${minDist})`); + + // budget discipline: never more than one big-ticket purchase per pass + const c10 = new City(555, 'Richville', ); + c10.money = 40000; + for (let x = 8; x < 40; x++) c10.placeStruct(STRUCT.ROAD, x, 30); + const rt = []; + for (let x = 9; x < 30; x++) for (let z = 27; z <= 33; z++) rt.push([x, z]); + c10.placeZone(ZONE.RES, rt); + for (let i = 0; i < 6; i++) c10.tick(); + const a10 = new AutoBuilder(c10); + let worstBig = 0; + for (let pass = 0; pass < 4; pass++) { + a10.run(); + worstBig = Math.max(worstBig, a10.bigSpends); + } + ok(worstBig <= 1, `at most one big-ticket per pass (worst ${worstBig})`); +} + console.log(`\n${pass} passed, ${fail} failed`); process.exit(fail ? 1 : 0); diff --git a/tests/smoke.mjs b/tests/smoke.mjs index 3d2a35d..4dd049a 100644 --- a/tests/smoke.mjs +++ b/tests/smoke.mjs @@ -149,13 +149,17 @@ await safe('shot-built', () => shot(join(SHOTS,'x') || { path: join(SHOTS, '02-b // ---- simulate ~14 months at fast speed ---- await safe('speed2', () => page.evaluate(() => window.POLYCITY.setSpeed(2))); -for (let k = 0; k < 11; k++) { +// wait in GAME time, not wall time: environments render at wildly +// different speeds, so poll until the city has lived >= 8 months +for (let k = 0; k < 24; k++) { await sleep(2000); const s = await safe('probe' + k, () => page.evaluate(() => ({ pop: window.POLYCITY.city.stats.pop, - dev: [...window.POLYCITY.city.grid.level].filter(v => v > 0).length + dev: [...window.POLYCITY.city.grid.level].filter(v => v > 0).length, + m: window.POLYCITY.city.monthIndex })), 8000); - LOG(`t+${(k + 1) * 2}s pop=${s?.pop ?? '?'} dev=${s?.dev ?? '?'}`); + LOG(`probe${k}: month=${s?.m ?? '?'} pop=${s?.pop ?? '?'} dev=${s?.dev ?? '?'}`); + if (s && s.m >= 8 && s.pop > 40) { LOG('sim target reached'); break; } } const state = await safe('state', () => page.evaluate(() => {