Professional City Advisor rewrite + wall-clock simulation

Advisor v2:
- Phased policy (founding/village/town/city) with scaling reserves
- Maximal-coverage siting: services score candidate spots by count of
  previously-uncovered developed buildings in radius
- RCI gradient genesis: homes north, commerce buffering, industry
  set back 2 extra tiles; per-tile separation filters on new districts
- District placement scored by runway length and distance from housing
- Power sized to PROJECTED demand (history-smoothed growth x14 months)
  with tech diversity cap (<=3 per type) and home-distance scoring
- Collector roads: cluster orphaned plots, one straight L-path
- Budget discipline: one big-ticket purchase max per pass

Simulation core:
- City clock now advances in wall time (tick-guard bounded), decoupled
  from render fps — slow devices no longer freeze the city
- Smoke test polls in game-time (monthIndex targets), not wall time
This commit is contained in:
PolyCity
2026-08-23 04:59:48 +00:00
parent 1b6b31732b
commit 209d8efdf5
7 changed files with 442 additions and 192 deletions
+1 -1
View File
@@ -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 | | 💰 **Budget & taxes** | Live tax slider, itemized income/upkeep ledger, debt warnings |
| 🚗 **Traffic** | Animated cars flow along your road network | | 🚗 **Traffic** | Animated cars flow along your road network |
| 🌗 **Day/night cycle** | Windows light up as the sun sets | | 🌗 **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 | | 🎯 **Goals & milestones** | From *Outpost* to *Megalopolis*, with an onboarding quest list |
| 💾 **Saves** | Autosave, 3 manual slots, JSON export/import | | 💾 **Saves** | Autosave, 3 manual slots, JSON export/import |
| 📱 **Touch support** | Paint with one finger, pinch-zoom, two-finger rotate | | 📱 **Touch support** | Paint with one finger, pinch-zoom, two-finger rotate |
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 174 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

+395 -189
View File
@@ -1,43 +1,214 @@
import { STRUCT, ZONE, BUILDINGS, START_MONEY } from '../config.js'; import { STRUCT, ZONE, BUILDINGS, START_MONEY } from '../config.js';
import { lineTiles } from '../utils.js';
/** /**
* AutoBuilder — a SimCity-style advisor that spends one "planning pass" * AutoBuilder — a phased city advisor, not a spammer.
* per call, always keeping an emergency reserve. Priorities: *
* 1. Power headroom (blackouts stall everything) * Design principles
* 2. Road access for zoned land (nothing develops without a road) * -----------------
* 3. New districts when demand is high and empty zone plots run out * 1. PHASED POLICY — priorities shift with population (founding → village →
* 4. Missing service coverage over developed tiles * town → city), and the emergency reserve grows with the phase.
* 5. Leisure to push land value over upgrade thresholds * 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 { export class AutoBuilder {
constructor(city) { constructor(city) {
this.city = 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; } 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() { run() {
this.bigSpends = 0;
this._dev = null;
const actions = []; const actions = [];
for (const step of [this.stepGenesis, this.stepPower, this.stepRoadAccess, const steps = [this.stepGenesis, this.stepPower, this.stepRoadAccess,
this.stepDistricts, this.stepServices, this.stepLeisure]) { this.stepDistricts, this.stepServices, this.stepLeisure];
try { const r = step.call(this); if (r) { actions.push(r); this.city.recomputePower(); } } catch { /* keep going */ } 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; return actions;
} }
/** Blank-map bootstrap: lay a main street through open land and zone // ================= analysis helpers =================
* homes on one side, shops + industry on the other. */
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() { stepGenesis() {
const g = this.g, S = g.size; const g = this.g, S = g.size;
let any = false; let any = false;
for (let i = 0; i < g.n && !any; i++) if (g.struct[i] || g.zone[i]) any = true; for (let i = 0; i < g.n && !any; i++) if (g.struct[i] || g.zone[i]) any = true;
if (any) return null; if (any) return null;
// find the longest clear horizontal runway near the vertical middle
let best = null, bestLen = 0; let best = null, bestLen = 0;
for (let z = (S >> 2); z < S - (S >> 2); z++) { for (let z = (S >> 2); z < S - (S >> 2); z++) {
let run = 0, startX = -1; let run = 0, startX = -1;
@@ -68,250 +239,285 @@ export class AutoBuilder {
if (tiles.length >= 6) this.city.placeZone(zid, tiles); if (tiles.length >= 6) this.city.placeZone(zid, tiles);
}; };
const half = ((len / 2) | 0) - 1; const half = ((len / 2) | 0) - 1;
// homes: entire north bank
band(x0, x0 + len - 1, z - 3, z - 1, ZONE.RES); band(x0, x0 + len - 1, z - 3, z - 1, ZONE.RES);
band(mid - half, mid + half, z + 1, z + 2, ZONE.COM); // shops: south-west block, buffering homes from industry
band(mid + 2, x0 + len - 1, z + 1, z + 3, ZONE.IND); band(mid - half, mid - 1, z + 1, z + 3, ZONE.COM);
return `Founded the town: main street \u00d7${len} with homes, shops and industry`; // 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 ---------- /** Keep capacity ahead of PROJECTED demand with tech diversity. */
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 ----------
stepPower() { stepPower() {
const s = this.city.stats; const s = this.city.stats;
// plan ahead: zoned land with zero capacity needs the first plant NOW this.city.recomputePower();
let hasZones = false; let hasZones = false;
for (let i = 0; i < this.g.n && !hasZones; i++) if (this.g.zone[i]) hasZones = true; 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); const growth = this.growthPerMonth();
if (!strain) return null; const projPop = Math.max(s.pop, s.pop + growth * 14);
const deficit = Math.max(600, s.powerUse * 0.8 - s.powerCap); 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; let sid;
if (deficit > 3000 && this.canAfford(BUILDINGS[STRUCT.COAL].cost)) sid = STRUCT.COAL; if (need > 2600 && this.canAfford(BUILDINGS[STRUCT.COAL].cost)) sid = STRUCT.COAL;
else if (deficit > 1200 && this.canAfford(BUILDINGS[STRUCT.SOLAR].cost)) sid = STRUCT.SOLAR; else if (need > 1100 && this.canAfford(BUILDINGS[STRUCT.SOLAR].cost)) sid = STRUCT.SOLAR;
else sid = STRUCT.WIND; 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]); // diversity: never let one tech exceed 3 units if an alternative fits
return p ? `${BUILDINGS[sid].name} for ${deficit | 0}W deficit` : null; 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;
} }
/** Zoned-empty plots with no road within 2 tiles never develop — connect them. */ const ctr = this.centroid() || [32, 32];
stepRoadAccess() { const p = this.place(sid, ctr[0], ctr[1], {
const g = this.g, S = g.size; pool: this.plantPool(),
let orphan = null; // keep plants a polite distance from homes
outer: score: (x, z) => -Math.max(0, 4 - this.distToNearestResTile(x, z, 5)),
for (let z = 2; z < S - 2; z++) { minScore: -4
for (let x = 2; x < S - 2; x++) { });
const i = g.idx(x, z); return p ? `${BUILDINGS[sid].name} — capacity headroom for ${Math.round(projected)}W projected draw` : null;
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 (!orphan) return null;
// BFS to nearest existing road through buildable land, then lay it plantPool() {
const [ox, oz] = orphan; // wider pool than usual: plants are noisy, look everywhere
const prev = new Map(); const g = this.g, out = [], seen = new Set();
const q = [[ox, oz]]; let guard = 500;
const seen = new Set([g.idx(ox, oz)]); while (out.length < 26 && guard-- > 0) {
let goal = null; const x = 3 + ((Math.random() * (g.size - 6)) | 0);
while (q.length && !goal) { const z = 3 + ((Math.random() * (g.size - 6)) | 0);
const [x, z] = q.shift(); const j = g.idx(x, z);
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; if (seen.has(j)) continue;
seen.add(j); seen.add(j);
if (g.struct[j] === STRUCT.ROAD) { goal = [nx, nz]; prev.set(j, g.idx(x, z)); break; } if (this.freeForBuilding(j)) out.push([x, z]);
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]);
} }
return out;
} }
if (!goal) return null;
// walk back from goal to orphan, laying road on every tile except the distToNearestRoad(x, z, maxR) {
// final one adjacent to the orphan (that's the connection point) const g = this.g;
let laid = 0; for (let r = 0; r <= maxR; r++) {
let cur = g.idx(goal[0], goal[1]); for (let dz = -r; dz <= r; dz++) for (let dx = -r; dx <= r; dx++) {
const orphanIdx = g.idx(ox, oz); if (Math.max(Math.abs(dx), Math.abs(dz)) !== r) continue;
while (cur !== orphanIdx && laid < 14) { const nx = x + dx, nz = z + dz;
const x = cur % S, z = (cur / S) | 0; if (!g.inB(nx, nz)) continue;
if (g.struct[cur] !== STRUCT.ROAD) { 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;
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] &&
!this.city.roadNear(x, z, 3)) {
orphans.push([x, z]);
}
}
}
if (!orphans.length) return null;
// 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 || goalDist > 16) return null;
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; if (!this.canAfford(BUILDINGS[STRUCT.ROAD].cost)) break;
this.city.placeStruct(STRUCT.ROAD, x, z); this.city.placeStruct(STRUCT.ROAD, x, z);
laid++; 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 /** Infill first; found a scored, separated district only when needed. */
* demand type that most needs land. */
stepDistricts() { stepDistricts() {
const s = this.city.stats; const s = this.city.stats;
const needs = [ const wants = [
{ d: s.resDemand, z: ZONE.RES, name: 'Homes' }, { d: s.resDemand, z: ZONE.RES, name: 'Homes' },
{ d: s.comDemand, z: ZONE.COM, name: 'Shops' }, { d: s.comDemand, z: ZONE.COM, name: 'Shops' },
{ d: s.indDemand, z: ZONE.IND, name: 'Industry' } { d: s.indDemand, z: ZONE.IND, name: 'Industry' }
].sort((a, b) => b.d - a.d); ].sort((a, b) => b.d - a.d);
const want = needs[0]; const want = wants[0];
if (want.d < 0.22) return null; if (want.d < 0.2) return null;
// count empty zoned plots for that type; plenty left → no need
const g = this.g; const g = this.g;
let empty = 0; let emptyServed = 0;
for (let i = 0; i < g.n; i++) { for (let i = 0; i < g.n; i++) {
if (g.zone[i] === want.z && !g.level[i] && !g.struct[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 // score every road-end runway into open space
const seeds = []; let best = null, bestScore = -Infinity;
for (let i = 0; i < g.n; i++) { for (let i = 0; i < g.n; i++) {
if (g.struct[i] !== STRUCT.ROAD) continue; if (g.struct[i] !== STRUCT.ROAD) continue;
seeds.push(i); const sx = i % g.size, sz = (i / g.size) | 0;
} for (const dir of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
if (!seeds.length) return null; const [dx, dz] = dir;
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
let open = 0; 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; const x = sx + dx * k, z = sz + dz * k;
if (!g.inB(x, z)) break; if (!g.inB(x, z)) break;
const j = g.idx(x, z); const j = g.idx(x, z);
if (g.terrain[j] !== 0 || g.struct[j] || g.level[j] || g.rubble[j]) break; if (g.terrain[j] !== 0 || g.struct[j] || g.level[j] || g.rubble[j]) break;
open++; 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; if (!best) return null;
const [sx, sz, dx, dz] = best;
// budget guard for the whole district const [sx, sz, dx, dz, open] = best;
const estCost = (bestScore + 1) * BUILDINGS[STRUCT.ROAD].cost; if (!this.canAfford((open + 2) * BUILDINGS[STRUCT.ROAD].cost)) return null;
if (!this.canAfford(estCost)) return null; let ex = sx, ez = sz;
for (let k = 1; k <= open; k++) {
// lay the spur ex = sx + dx * k; ez = sz + dz * k;
let endX = sx, endZ = sz; this.city.placeStruct(STRUCT.ROAD, ex, ez);
for (let k = 1; k <= bestScore; k++) {
endX = sx + dx * k; endZ = sz + dz * k;
this.city.placeStruct(STRUCT.ROAD, endX, endZ);
} }
// perpendicular axis for bands
// perpendicular bands with separation filters
const px = dz !== 0 ? 1 : 0, pz = dx !== 0 ? 1 : 0; const px = dz !== 0 ? 1 : 0, pz = dx !== 0 ? 1 : 0;
let zoned = 0;
for (const side of [1, -1]) { for (const side of [1, -1]) {
const tiles = []; const tiles = [];
for (let along = -2; along <= 2; along++) { for (let along = -2; along <= 2; along++) {
for (let depth = 1; depth <= 3; depth++) { for (let depth = 1; depth <= 3; depth++) {
const bx = endX + px * side * depth + dx * along; const bx = ex + px * side * depth + dx * along;
const bz = endZ + pz * side * depth + dz * along; const bz = ez + pz * side * depth + dz * along;
if (!g.inB(bx, bz)) continue; if (!g.inB(bx, bz)) continue;
const j = g.idx(bx, bz); 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() { stepServices() {
const c = this.city; const c = this.city;
const plan = [ const roster = [
{ sid: STRUCT.POLICE, map: c.mapPolice, name: 'Police' }, { sid: STRUCT.POLICE, map: c.mapPolice, name: 'Police' },
{ sid: STRUCT.FIRE, map: c.mapFire, name: 'Fire Stn' }, { sid: STRUCT.FIRE, map: c.mapFire, name: 'Fire Stn' },
{ sid: STRUCT.HOSPITAL, map: c.mapHealth, name: 'Hospital' }, { sid: STRUCT.HOSPITAL, map: c.mapHealth, name: 'Hospital' },
{ sid: STRUCT.SCHOOL, map: c.mapEdu, name: 'School' } { sid: STRUCT.SCHOOL, map: c.mapEdu, name: 'School' }
]; ];
const worst = plan const target = roster
.map(p => ({ ...p, avg: this.avgCoverage(p.map) })) .map(r => ({ ...r, avg: this.coverageAvg(r.map) }))
.sort((a, b) => a.avg - b.avg)[0]; .sort((a, b) => a.avg - b.avg)[0];
if (!worst || worst.avg > 0.34) return null; if (!target || this.devTiles().length < 6) return null;
const ctr = this.developedCentroid(); if (target.avg > COVERAGE_TARGET[this.phase]) return null;
if (!ctr) return null; if (!this.canAfford(BUILDINGS[target.sid].cost)) return null;
// services like being NEAR town but not in the middle of zones — search
// outward from centroid; empty-zone claims are allowed const meta = BUILDINGS[target.sid];
const p = this.place(worst.sid, 1, 1, ctr[0] + 4, ctr[1] + 3); const R = meta.radius;
return p ? `${worst.name} (coverage was ${(worst.avg * 100) | 0}%)` : null; 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() { stepLeisure() {
if (this.avgCoverage(this.city.mapLeisure) > 0.30) return null; if (this.city.stats.pop < 50) return null;
if (this.city.stats.pop < 60) return null; const g = this.g;
const ctr = this.developedCentroid(); let worst = null, worstVal = Infinity;
if (!ctr) return null; for (const i of this.devTiles()) {
const pick = this.canAfford(BUILDINGS[STRUCT.PLAZA].cost) && if (g.zone[i] !== ZONE.RES) continue;
Math.random() < 0.5 ? STRUCT.PLAZA : STRUCT.PARK; const v = this.city.mapLeisure[i] * 2 + g.landValue[i] * 0.02;
const p = this.place(pick, 1, 1, ctr[0] - 3, ctr[1] + 4); if (v < worstVal) { worstVal = v; worst = i; }
return p ? BUILDINGS[pick].name : null; }
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;
} }
} }
+5 -2
View File
@@ -169,10 +169,13 @@ class Game {
const tick = (now) => { const tick = (now) => {
this._raf = requestAnimationFrame(tick); this._raf = requestAnimationFrame(tick);
this._lastFrameAt = now; 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._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(); this._syncSoon();
if (this._parts) { if (this._parts) {
+37
View File
@@ -292,5 +292,42 @@ console.log('— save / load integrity —');
ok(actions2.length <= 2, `second pass is conservative (${actions2.length} actions)`); 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`); console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0); process.exit(fail ? 1 : 0);
+7 -3
View File
@@ -149,13 +149,17 @@ await safe('shot-built', () => shot(join(SHOTS,'x') || { path: join(SHOTS, '02-b
// ---- simulate ~14 months at fast speed ---- // ---- simulate ~14 months at fast speed ----
await safe('speed2', () => page.evaluate(() => window.POLYCITY.setSpeed(2))); 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); await sleep(2000);
const s = await safe('probe' + k, () => page.evaluate(() => ({ const s = await safe('probe' + k, () => page.evaluate(() => ({
pop: window.POLYCITY.city.stats.pop, 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); })), 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(() => { const state = await safe('state', () => page.evaluate(() => {