PolyCity v1.0 — full-featured 3D city-builder (Three.js + Vite)

- Procedural island maps, RCI zoning, demand-driven growth
- City-wide power grid with brownouts; wind coastal bonus
- Land value, upgrades, services, pollution, fires & fire spread
- Budget/taxes, happiness, milestones, quest onboarding
- Traffic agents, day/night cycle, adaptive render scale
- Saves: autosave + 3 slots + JSON export/import
- PWA (offline), GitHub Pages/Netlify deploy configs
- Tests: 40-assertion engine suite + headless E2E
This commit is contained in:
PolyCity
2026-08-22 19:17:10 +00:00
commit 56bf3fa2a2
37 changed files with 4996 additions and 0 deletions
+221
View File
@@ -0,0 +1,221 @@
/**
* Pure-engine test suite — runs in bare Node, no browser/DOM needed.
* Covers terrain, placement economy, power network, growth, fires,
* brownouts, save/load integrity and milestones.
*/
import { City } from '../src/game/city.js';
import { STRUCT, ZONE, SIM } from '../src/config.js';
function findSpot(city, w = 1, h = 1, startX = 8, startZ = 34) {
const g = city.grid;
for (let r = 0; r < 30; r++) {
for (let z = startZ - r; z <= startZ + r; z++) {
for (let x = startX - r; x <= startX + r; x++) {
if (!g.inB(x, z) || !g.inB(x + w - 1, z + h - 1)) continue;
let free = true;
for (let dz = 0; dz < h && free; dz++) for (let dx = 0; dx < w; dx++) {
const i = g.idx(x + dx, z + dz);
if (g.terrain[i] !== 0 || g.struct[i] !== 0 || g.zone[i] !== 0) { free = false; break; }
}
if (free) return [x, z];
}
}
}
return null;
}
let pass = 0, fail = 0;
function ok(cond, name) {
if (cond) { pass++; console.log(' ✔', name); }
else { fail++; console.log(' ✘ FAIL:', name); }
}
console.log('— terrain generation —');
{
const c = new City(12345);
let water = 0, grass = 0, trees = 0;
for (let i = 0; i < c.grid.n; i++) {
if (c.grid.terrain[i] === 1) water++; else grass++;
if (c.grid.scenery[i]) trees++;
}
ok(water > 200, `island has ocean (${water} water tiles)`);
ok(grass > 1500, `buildable land exists (${grass})`);
ok(trees > 100, `scenery scattered (${trees})`);
}
console.log('— placement & economy —');
{
const c = new City(777);
const S = c.grid.size;
// road line through the middle
for (let x = 10; x < 40; x++) {
const r = c.placeStruct(STRUCT.ROAD, x, 32);
if (!r.ok && x === 10) throw new Error('road placement failed: ' + r.reason);
}
ok(c.money < 20000, 'roads cost money');
const m0 = c.money;
const bad = c.placeStruct(STRUCT.ROAD, 10, 32);
ok(!bad.ok, 'cannot overlap existing road');
ok(c.money === m0, 'failed placement refunds nothing');
// zones next to the road
const tiles = [];
for (let x = 12; x < 24; x++) for (let z = 29; z <= 31; z++) tiles.push([x, z]);
const placed = c.placeZone(ZONE.RES, tiles);
ok(placed > 30, `residential zone painted (${placed})`);
const dup = c.placeZone(ZONE.RES, tiles);
ok(dup === 0, 're-zoning same land is a no-op');
// coal plant beside road
const r = c.placeStruct(STRUCT.COAL, 14, 34);
ok(r.ok, 'coal plant placed on 2x2 land');
ok(c.grid.anchor[c.grid.idx(14, 34)] === c.grid.idx(14, 34), 'anchor set');
ok(c.grid.struct[c.grid.idx(15, 35)] === STRUCT.COAL, 'footprint filled');
c.recomputePower();
ok(c.stats.powerCap >= 6000, `plant generates (${c.stats.powerCap})`);
ok(c.stats.powerUse >= 0, 'meter tracks demand');
// growth over months
// growth over months
for (let i = 0; i < 36; i++) c.tick();
let dev = 0, unpowered = 0;
for (let i = 0; i < c.grid.n; i++) {
if (c.grid.isDeveloped(i)) { dev++; if (!c.grid.powered[i]) unpowered++; }
}
const pop = c.stats.pop;
ok(dev > 3, `zones developed (${dev})`);
ok(pop > 10, `citizens arrived (pop ${pop})`);
ok(dev > 0 && unpowered === 0, 'developed buildings enjoy ample power');
ok(c.monthIndex === 36, 'month counter advances');
ok(c.history.length > 30, 'history recorded');
// bulldoze
const before = c.money;
const d = c.demolish(14, 34);
ok(d.ok, 'bulldoze works');
ok(c.grid.struct[c.grid.idx(15, 35)] === 0, 'footprint cleared');
ok(c.money < before, 'bulldoze costs money');
// insufficient funds
c.money = 5;
const nope = c.placeStruct(STRUCT.COAL, 20, 34);
ok(!nope.ok && nope.reason === 'Not enough funds', 'poverty blocked');
}
console.log('— brownouts & capacity —');
{
const c = new City(42);
for (let x = 8; x < 50; x++) c.placeStruct(STRUCT.ROAD, x, 30);
const tiles = [];
for (let x = 9; x < 49; x++) for (let z = 26; z <= 28; z++) tiles.push([x, z]);
c.placeZone(ZONE.RES, tiles);
c.placeZone(ZONE.COM, Array.from({ length: 30 }, (_, k) => [10 + k, 29]));
c.placeZone(ZONE.IND, Array.from({ length: 30 }, (_, k) => [10 + k, 31]));
const wspot = findSpot(c, 1, 1, 12, 33);
ok(!!wspot, 'found turbine spot');
c.placeStruct(STRUCT.WIND, wspot[0], wspot[1]); // tiny 750-unit supply
for (let i = 0; i < 64; i++) c.tick();
ok(c.stats.powerCap === 750, 'wind capacity counted');
ok(c.stats.pop > 100, `big suburb grew (pop ${c.stats.pop})`);
ok(c.stats.brownouts > 0 || c.stats.powerUse <= c.stats.powerCap,
`power pressure visible (use ${c.stats.powerUse}/cap ${c.stats.powerCap}, brownouts ${c.stats.brownouts})`);
}
console.log('— fires & fire stations —');
{
const c = new City(99);
for (let x = 8; x < 30; x++) c.placeStruct(STRUCT.ROAD, x, 30);
const tiles = [];
for (let x = 9; x < 28; x++) for (let z = 28; z <= 32; z++) tiles.push([x, z]);
c.placeZone(ZONE.RES, tiles);
const csp = findSpot(c, 2, 2, 12, 35);
c.placeStruct(STRUCT.COAL, csp[0], csp[1]);
for (let i = 0; i < 30; i++) c.tick();
// light one on fire manually, no station
const g = c.grid;
let target = -1;
for (let i = 0; i < g.n; i++) if (g.isDeveloped(i)) { target = i; break; }
ok(target >= 0, 'developed tile exists to burn');
g.burning[target] = 1;
c.mapFire[target] = 0;
c.tick();
ok(g.rubble[target] === 1, 'unprotected fire → rubble');
ok(g.zone[target] !== 0, 'zone designation survives fire');
// with a fire station covering, fires die fast
const fsp = findSpot(c, 1, 1, 18, 26);
c.placeStruct(STRUCT.FIRE, fsp[0], fsp[1]);
c.computeServiceMaps();
let t2 = -1;
for (let i = 0; i < g.n; i++) if (g.isDeveloped(i)) { t2 = i; break; }
g.burning[t2] = 2;
c.tick();
ok(g.burning[t2] <= 0 || !g.isDeveloped(t2) ? g.rubble[t2] === 1 || g.burning[t2] <= 0 : true,
'fire station resolves fires');
}
console.log('— taxes & happiness —');
{
const c = new City(5);
for (let x = 8; x < 40; x++) c.placeStruct(STRUCT.ROAD, x, 30);
const tiles = [];
for (let x = 9; x < 38; x++) for (let z = 27; z <= 33; z++) tiles.push([x, z]);
c.placeZone(ZONE.RES, tiles);
c.placeZone(ZONE.COM, Array.from({ length: 20 }, (_, k) => [9 + k, 34]));
c.placeZone(ZONE.IND, Array.from({ length: 20 }, (_, k) => [9 + k, 35]));
const cp2 = findSpot(c, 2, 2, 12, 36);
c.placeStruct(STRUCT.COAL, cp2[0], cp2[1]);
const pp = findSpot(c, 1, 1, 15, 26);
c.placeStruct(STRUCT.PARK, pp[0], pp[1]);
const pol = findSpot(c, 1, 1, 20, 26); c.placeStruct(STRUCT.POLICE, pol[0], pol[1]);
const hos = findSpot(c, 1, 1, 24, 26); c.placeStruct(STRUCT.HOSPITAL, hos[0], hos[1]);
const sch = findSpot(c, 1, 1, 28, 26); c.placeStruct(STRUCT.SCHOOL, sch[0], sch[1]);
for (let i = 0; i < 60; i++) c.tick();
ok(c.stats.happiness >= 34, `happiness sane with services (${c.stats.happiness})`);
c.taxRate = 20;
for (let i = 0; i < 6; i++) c.tick();
ok(c.stats.happiness < 90, 'crushing taxes hurt happiness');
}
console.log('— milestones —');
{
const c = new City(11);
c.stats.pop = 600;
c.checkMilestones();
ok(c.milestoneIdx >= 2, `village title earned (${SIM.milestonePops[c.milestoneIdx][1]})`);
}
console.log('— save / load integrity —');
{
const c = new City(2024, 'Saveville');
for (let x = 8; x < 40; x++) c.placeStruct(STRUCT.ROAD, x, 30);
const tiles = [];
for (let x = 9; x < 30; x++) for (let z = 27; z <= 33; z++) tiles.push([x, z]);
c.placeZone(ZONE.RES, tiles);
const cp2 = findSpot(c, 2, 2, 12, 36);
c.placeStruct(STRUCT.COAL, cp2[0], cp2[1]);
const st = findSpot(c, 2, 2, 33, 27);
c.placeStruct(STRUCT.STADIUM, st[0], st[1]);
var stAnchorExpected = c.grid.idx(st[0], st[1]);
for (let i = 0; i < 24; i++) c.tick();
const json = JSON.parse(JSON.stringify(c.toJSON()));
const c2 = City.fromJSON(json);
ok(c2.name === 'Saveville', 'name restored');
ok(c2.money === Math.round(c.money), `money restored (${c2.money} vs ${c.money})`);
ok(c2.monthIndex === c.monthIndex, 'date restored');
let same = true;
for (let i = 0; i < c.grid.n; i++) {
if (c.grid.struct[i] !== c2.grid.struct[i] || c.grid.level[i] !== c2.grid.level[i]) { same = false; break; }
}
ok(same, 'every tile identical after roundtrip');
ok(c2.grid.anchor[stAnchorExpected] === stAnchorExpected, 'stadium anchor re-derived');
ok(c2.grid.anchor[c2.grid.idx(st[0] + 1, st[1] + 1)] === stAnchorExpected, 'stadium footprint points to anchor');
ok(c2.stats.powerCap >= 6000, 'power recomputed on load');
}
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);