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:
@@ -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 |
|
||||
|
||||
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 |
+392
-186
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+5
-2
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
+7
-3
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user