diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index da0a394..47d93fc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,6 +46,27 @@ jobs: path: coverage/ retention-days: 7 + gridlock: + name: GRIDLOCK Simulation Tests + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + # WHY node 20 and no install step: the GRIDLOCK simulation core is + # dependency-free ES modules on the built-in node test runner, so it needs + # neither the RN toolchain nor npm ci. + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Test simulation core + working-directory: games/gridlock + run: node --test + android-build: name: Android Build Check runs-on: ubuntu-latest diff --git a/README.md b/README.md index 5a6ca06..1941d42 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,30 @@ Open `demo.html` directly in Chrome — no server needed. Runs at native 4K on h --- +## Also in this repo: GRIDLOCK + +> **Everything runs through you.** An isometric neon city-builder. + +Open `games/gridlock/index.html` — zero build step, zero dependencies. + +Zone it, power it, cool it, and keep it moving. Three pressures separate it from +a city-painter: **adjacency synergy** (the same building is worth more or less +depending on what it touches), a rising **Heat Index** (your industry cooks the +planet you are building on), and **crisis cards** that pause the world and force +a trade with no clean answer. Build the Zero Point interchange to win. + +The simulation core is pure, dependency-free ES modules — `tickMonth(state) → +state`, no DOM, no clock, no `Math.random` — so it runs headless under +`node --test`: + +```bash +npm run test:gridlock # 77 tests, no framework +``` + +Full design and architecture notes: [`games/gridlock/README.md`](games/gridlock/README.md). + +--- + ## Development ```bash diff --git a/games/gridlock/README.md b/games/gridlock/README.md new file mode 100644 index 0000000..152938a --- /dev/null +++ b/games/gridlock/README.md @@ -0,0 +1,195 @@ +# GRIDLOCK + +> **Everything runs through you.** +> +> An isometric neon city-builder. Zone it, power it, cool it, and keep the whole +> thing moving — while the planet keeps score. + +Open `games/gridlock/index.html` in a browser. No build step, no dependencies, +no server required. + +--- + +## What makes it not-SimCity + +SimCity's core loop is zone → watch it fill. GRIDLOCK keeps that loop and adds +three pressures that make every tile placement a decision: + +### 1. Adjacency synergy — placement is a puzzle + +The same building is worth materially more or less depending on what it touches. +Commerce beside housing sells more. Housing beside a foundry loses approval. +A server farm on the waterfront dumps its heat into the bay. + +| Synergy | Subject | Neighbour | Effect | Cap | +|---|---|---|---|---| +| Foot Traffic | Commercial | Residential | +8% revenue each | 4 | +| Retail Cluster | Commercial | Commercial | +5% revenue each | 2 | +| Supply Chain | Industry | Industry | +7% revenue each | 3 | +| Direct Feed | Data | Power | +9% revenue each | 2 | +| Coolant Draw | Data | Open water | −18% heat each | 3 | +| Green Outlook | Residential | Green | +2.5 appeal each | 3 | +| Downwind | Residential | Industry | −3.5 appeal each | 3 | +| Connected | Residential | Transit | +6% housing each | 2 | +| Canopy | Green | Green | +12% cooling each | 3 | +| Campus | Data | Civic | +15% research each | 2 | +| Shoreline | Residential | Open water | +1.5 appeal each | 3 | + +The inspector shows exactly what a tile *would* score before you commit. + +### 2. The Heat Index — your industry cooks the planet you build on + +Generators, factories, and server farms emit heat. Past **55** the index raises +city-wide cooling demand and starts costing approval; at **88** the city is in +crisis and the whole map takes on a red cast. Parks, sky gardens, and carbon +scrubbers pull it back. There is no way to build a large dirty city and ignore it. + +### 3. Crisis cards — the world interrupts you + +Twelve cards, each fired by real conditions in your city, each pausing the game +to force a trade with no clean answer: + +- **Grid Surge** — shed load city-wide, or run the plants hot and gamble one. +- **Heatwave** — ration water and take the anger, or buy reserves at spot price. +- **Corporate Bid** — 18,000 credits now for a decade of thermal load. +- **Transit Strike** — meet their terms, or break the strike and be remembered. +- **Arrival Wave** — open the gates, or seal the perimeter. +- …plus Data Breach, Sovereign Grant, Ground Tremor, Smog Alert, Fusion + Breakthrough, Blackout Riots, and the Pulse Festival. + +Cards you have not seen are three times as likely to be drawn, so the deck stays +surprising well into a long game. + +--- + +## The three chains + +A structure produces nothing unless all three hold: + +``` +road ──▶ power ──▶ water ──▶ live +``` + +1. **Road** — it must touch a road wired back to the Nexus. Cut the street and + the district goes dark. The HUD shows a pulsing `N CUT OFF` chip. +2. **Power** — when supply falls short, rationing is deterministic and + priority-ordered: water plants and clinics stay lit, factories go dark first. +3. **Water** — resolved *after* power, so an unpowered pump makes no water. The + failure cascades in one direction, on purpose. + +--- + +## Progression + +Research is the only thing that advances the era, and academies and labs are the +only things that bank it. + +| Era | Name | Research | Unlocks | +|---|---|---|---| +| 1 | Diesel Age | — | Habitat blocks, markets, fabricators, diesel, solar, pumps | +| 2 | Solar Age | 120 | Sky lofts, plazas, robotics, server farms, wind, geothermal, desal, arena, transit | +| 3 | Fusion Age | 640 | Arcology spires, corporate towers, orbital foundries, AI labs, fusion, scrubbers, mag-lev | + +Build and power the **Zero Point** interchange to win. Eleven directives run in a +queue of three, paying out credits and research as you clear them. + +--- + +## Controls + +| Input | Action | +|---|---| +| Click a structure, then click/drag the map | Build (drag is how you lay roads) | +| Right-drag, or drag with no tool held | Pan | +| Wheel | Zoom toward the cursor | +| `Space` | Pause / resume | +| `1` `2` `3` | Speed | +| `X` | Bulldoze (35% refund) | +| `O` | Cycle map view | +| `S` | Save · `H` Help · `Esc` Drop tool | +| `` ` `` | Telemetry overlay | + +Five map views: **City**, **Desirability**, **Utilities**, **Services**, +**Emissions**. + +--- + +## Architecture + +Zero-build ES modules. The simulation core is pure and runs headless in Node, +which is what makes it testable. + +``` +games/gridlock/ +├── index.html — static shell; all structure, no logic +├── styles.css — obsidian / gold / violet theme +└── src/ + ├── config/ — DATA ONLY: balance, catalog, synergy, eras, crises, directives + │ ├── balance.js every tuning knob in the game + │ ├── buildings.js 31 structures, signed power/water convention + │ ├── synergy.js the adjacency rule table + │ ├── events.js the crisis deck + │ └── directives.js the objective queue + ├── core/ — PURE SIM: no DOM, no clock, no Math.random + │ ├── rng.js seeded streams + value noise + │ ├── grid.js terrain generation and neighbourhood queries + │ ├── state.js construction, cloning, logging + │ ├── commands.js place / demolish / tax / speed, all validated here + │ ├── network.js road connectivity from the Nexus + │ ├── rationing.js deterministic priority-ordered shortfalls + │ ├── synergy.js adjacency scoring + │ ├── fields.js desirability and service coverage + │ ├── stats.js the one grid scan per month + │ ├── economy.js approval, migration, heat + │ ├── events.js crisis draw and effect interpretation + │ ├── directives.js objective evaluation + │ └── simulate.js tickMonth(state) → state + ├── render/ — canvas: iso projection, terrain, structures, overlays, effects + ├── ui/ — DOM: hud, build palette, inspector, panels, modal, toasts + ├── io/ — input, localStorage persistence, WebAudio + └── telemetry/ — HEALTH / PRESSURE / EFFICIENCY probes +``` + +**State flows one way.** Input produces a command, the command returns a *new* +state, the renderer and UI read it. `tickMonth(state) → state` is pure: no +wall-clock reads, no `Math.random`, no DOM. Every stochastic decision draws from +`(seed, rollCount)`, so a save file replays exactly and every crisis outcome is +reproducible. + +**Buildings are data.** Adding a structure to `config/buildings.js` makes it +appear in the palette, on the map, and in the economy with no other code change — +the renderer draws every structure procedurally from its catalog entry. + +--- + +## Tests + +```bash +cd games/gridlock +npm test # node --test tests/*.test.mjs +``` + +77 tests, no framework and no dependencies: + +| Suite | Covers | +|---|---| +| `grid` | terrain determinism, buildable centre, neighbour clipping, seeded RNG | +| `iso` | projection round-trip under zoom and pan, tile picking, camera clamps | +| `commands` | every placement rule, refunds, the un-removable Nexus, clamped settings | +| `network` | road connectivity, orphan islands, deterministic rationing order | +| `synergy` | per-neighbour multipliers, caps, penalties, non-mutating preview | +| `simulate` | tick purity and determinism, growth, brownouts, heat bounds, solvency | +| `events` | deck integrity, cost gating, seeded risk, the Nexus surviving destruction | +| `conditions` | crisis trigger conditions, grace period, cooldown, single-crisis rule | +| `directives` | queue refill, streak reset, rewards, victory | +| `balance` | a fixed district over 10 years: fills, pays for itself, stays cool | + +The `conditions` suite includes a guard that no crisis condition compares the +clamped utility ratio against a value above 1 — a bug that shipped once and made +Grid Surge fire every single month. + +## Telemetry + +Press `` ` `` in game, or load `?debug=1`, for the live HEALTH / PRESSURE / +EFFICIENCY readout: frame rate, draw and tick cost, heap, dropped-frame rate, +and particle count. A built-out city ticks in well under 1ms and draws in ~3ms. diff --git a/games/gridlock/index.html b/games/gridlock/index.html new file mode 100644 index 0000000..a513766 --- /dev/null +++ b/games/gridlock/index.html @@ -0,0 +1,131 @@ + + + + + +GRIDLOCK — Everything runs through you + + + + + + + + +
+ + +
+
+ GRIDLOCK + JAN 2087 + STABLE +
+ +
+ Treasury + ₡0 + +₡0/mo +
+ +
+ Residents + 0 + 0 working +
+ +
+ Approval + 68% +
+
+ +
+ Power + 0/0 +
+
+ +
+ Water + 0/0 +
+
+ +
+ Heat Index + 12 +
+
+ +
+ Era + ERA 1 · DIESEL AGE + 120 research to Solar Age +
+ +
+
+ + + + + + + + +
+ +
+ + + + diff --git a/games/gridlock/package.json b/games/gridlock/package.json new file mode 100644 index 0000000..c9f7d32 --- /dev/null +++ b/games/gridlock/package.json @@ -0,0 +1,10 @@ +{ + "name": "gridlock", + "version": "1.0.0", + "private": true, + "type": "module", + "description": "GRIDLOCK — isometric neon city-builder. Zero-build ES modules, pure simulation core.", + "scripts": { + "test": "node --test tests/*.test.mjs" + } +} diff --git a/games/gridlock/src/config/balance.js b/games/gridlock/src/config/balance.js new file mode 100644 index 0000000..f4db52f --- /dev/null +++ b/games/gridlock/src/config/balance.js @@ -0,0 +1,100 @@ +/** + * Economic + simulation constants for GRIDLOCK. + * + * WHY a single table: every tuning knob lives here so balance changes never + * require touching simulation logic. The sim reads constants, it never invents + * them. Changing a number here is a config change, not a code change. + */ + +export const GRID = Object.freeze({ width: 32, height: 32 }); + +/** Real milliseconds per simulated month, indexed by speed setting. */ +export const SPEED_MS = Object.freeze([Infinity, 2000, 900, 380]); +export const SPEED_LABEL = Object.freeze(['PAUSED', '1×', '2×', '3×']); + +export const START = Object.freeze({ + credits: 30000, + approval: 68, + heat: 12, + taxRate: 0.09, +}); + +export const TAX = Object.freeze({ + min: 0.0, + max: 0.22, + step: 0.01, + /** Credits per resident per month at 100% tax. Scaled by taxRate. */ + perCapita: 19.5, + /** Approval swing per percentage point away from the neutral rate. */ + neutral: 0.09, + approvalSlope: 210, +}); + +export const LABOR = Object.freeze({ + /** Fraction of population that seeks work. */ + participation: 0.62, + /** Population may exceed jobs by this factor before growth stalls. */ + slack: 1.18, +}); + +export const GROWTH = Object.freeze({ + /** Fraction of the gap to target population closed each month. */ + rate: 0.12, + /** Minimum share of housing that fills even at rock-bottom approval. */ + floorShare: 0.4, + /** Residents who leave per month when a utility is fully out. */ + exodusRate: 0.09, +}); + +export const APPROVAL = Object.freeze({ + base: 62, + inertia: 0.16, + unemploymentWeight: 46, + pollutionWeight: 28, + powerWeight: 34, + waterWeight: 28, + congestionWeight: 22, + coverageWeight: 22, + heatWeight: 26, + appealCap: 26, +}); + +export const CLIMATE = Object.freeze({ + /** Heat index gained per unit of net thermal load per month. */ + gain: 0.05, + /** Natural radiative decay per month, before emissions. */ + decay: 0.35, + /** Heat index above which penalties begin. */ + threshold: 55, + /** Heat index at which the city is in full crisis. */ + critical: 88, + /** Extra power demand per point of heat above threshold, as a fraction. */ + coolingLoad: 0.006, + max: 100, +}); + +export const TRAFFIC = Object.freeze({ + /** Commuters one road tile can carry per month. */ + roadCapacity: 26, + /** Congestion above this level starts costing commerce and approval. */ + tolerance: 0.7, +}); + +export const SERVICE_KINDS = Object.freeze(['health', 'education', 'safety']); + +export const TECH = Object.freeze({ + /** Research required to unlock each era, cumulative. */ + eraCost: [0, 120, 640], +}); + +export const EVENTS = Object.freeze({ + /** Months of quiet guaranteed after any crisis resolves. */ + cooldown: 7, + /** Earliest month a crisis may fire. */ + graceMonths: 10, + /** Per-month chance a crisis is drawn once off cooldown. */ + chance: 0.16, +}); + +export const SAVE_KEY = 'gridlock.save.v1'; +export const SAVE_VERSION = 1; diff --git a/games/gridlock/src/config/buildings.js b/games/gridlock/src/config/buildings.js new file mode 100644 index 0000000..a385d83 --- /dev/null +++ b/games/gridlock/src/config/buildings.js @@ -0,0 +1,284 @@ +/** + * The GRIDLOCK build catalog. + * + * Signed convention: `power` and `water` are positive when a structure supplies + * the grid and negative when it draws from it. `pollution` and `heat` are + * positive when emitted, negative when scrubbed. One field, one sign, no + * special cases in the simulation. + * + * INVARIANT: every entry is 1×1. Footprint variety is expressed through height + * and silhouette, which keeps placement, pathing, and demolition trivial. + */ + +/** @typedef {'road'|'residential'|'commercial'|'industry'|'data'|'power'|'water'|'civic'|'green'|'transit'|'special'} Category */ + +const B = (def) => Object.freeze({ + era: 1, + upkeep: 0, + power: 0, + water: 0, + residents: 0, + jobs: 0, + revenue: 0, + pollution: 0, + heat: 0, + tech: 0, + appeal: 0, + appealRadius: 0, + traffic: 0, + needs: null, + ...def, +}); + +export const CATALOG = Object.freeze([ + B({ + id: 'nexus', name: 'The Nexus', cat: 'special', cost: 0, upkeep: 0, hidden: true, + power: 60, water: 60, jobs: 10, appeal: 6, appealRadius: 5, + h: 1.4, color: '#3a3160', accent: '#ffd97a', + desc: 'Founding hub. Every road traces its power back here. It cannot be removed.', + }), + + B({ + id: 'road', name: 'Neon Road', cat: 'road', cost: 14, upkeep: 0.25, + h: 0.06, color: '#2a2740', accent: '#8b5cf6', + desc: 'Carries commuters. Every zone must touch a road wired back to the Nexus.', + }), + + // ── Residential ───────────────────────────────────────────────────────── + B({ + id: 'hab', name: 'Habitat Block', cat: 'residential', cost: 300, upkeep: 4, + residents: 26, power: -6, water: -5, pollution: 0.6, heat: 0.2, + h: 0.9, color: '#3b3560', accent: '#c9a84c', + desc: 'Stacked micro-housing. Cheap, dense, and the backbone of a young city.', + }), + B({ + id: 'loft', name: 'Sky Loft', cat: 'residential', era: 2, cost: 1100, upkeep: 12, + residents: 84, power: -16, water: -13, pollution: 0.8, heat: 0.4, appeal: 1, appealRadius: 2, + h: 1.9, color: '#453d78', accent: '#e6c76a', + desc: 'Vertical living for citizens who can pay for altitude.', + }), + B({ + id: 'spire', name: 'Arcology Spire', cat: 'residential', era: 3, cost: 5200, upkeep: 46, + residents: 360, power: -62, water: -48, pollution: 1.2, heat: 0.9, appeal: 3, appealRadius: 3, + h: 3.6, color: '#4d4490', accent: '#ffd97a', + desc: 'A self-contained district folded into a single tower.', + }), + + // ── Commercial ────────────────────────────────────────────────────────── + B({ + id: 'market', name: 'Market Row', cat: 'commercial', cost: 340, upkeep: 5, + jobs: 20, revenue: 46, power: -7, water: -4, appeal: 1, appealRadius: 2, + h: 0.7, color: '#2f4a55', accent: '#38e1c8', + desc: 'Street-level trade. Thrives beside housing, starves in isolation.', + }), + B({ + id: 'plaza', name: 'Neon Plaza', cat: 'commercial', era: 2, cost: 1250, upkeep: 14, + jobs: 68, revenue: 168, power: -18, water: -9, appeal: 3, appealRadius: 3, + h: 1.5, color: '#2f5a6b', accent: '#4df0d6', + desc: 'Night markets, holo-signage, and a permanent crowd.', + }), + B({ + id: 'tower', name: 'Corporate Tower', cat: 'commercial', era: 3, cost: 4800, upkeep: 44, + jobs: 240, revenue: 620, power: -55, water: -26, appeal: 2, appealRadius: 3, + h: 3.2, color: '#2c6478', accent: '#7ef7e2', + desc: 'Where the city\'s money actually lives.', + }), + + // ── Industry ──────────────────────────────────────────────────────────── + B({ + id: 'fab', name: 'Fabricator', cat: 'industry', cost: 520, upkeep: 7, + jobs: 32, revenue: 92, power: -14, water: -10, pollution: 6, heat: 1.2, + appeal: -4, appealRadius: 3, + h: 0.8, color: '#5a3a2c', accent: '#ff7a3d', + desc: 'Prints goods and grievances in equal measure.', + }), + B({ + id: 'robotics', name: 'Robotics Yard', cat: 'industry', era: 2, cost: 1700, upkeep: 18, + jobs: 96, revenue: 320, power: -38, water: -20, pollution: 8, heat: 1.8, + appeal: -5, appealRadius: 3, + h: 1.3, color: '#66422f', accent: '#ff9147', + desc: 'Automated assembly at district scale. Loud, hot, profitable.', + }), + B({ + id: 'foundry', name: 'Orbital Foundry', cat: 'industry', era: 3, cost: 6400, upkeep: 58, + jobs: 300, revenue: 1150, power: -110, water: -60, pollution: 10, heat: 2.6, + appeal: -6, appealRadius: 4, + h: 2.2, color: '#6e4632', accent: '#ffa85c', + desc: 'Feeds the launch corridor. Nobody wants to live downwind.', + }), + + // ── Data ──────────────────────────────────────────────────────────────── + B({ + id: 'server', name: 'Server Farm', cat: 'data', era: 2, cost: 2200, upkeep: 22, + jobs: 24, revenue: 430, power: -85, water: -30, heat: 3.2, tech: 1, + h: 0.9, color: '#2b3350', accent: '#5ea9ff', + desc: 'Enormous revenue, enormous thirst, enormous waste heat.', + }), + B({ + id: 'ailab', name: 'AI Lab', cat: 'data', era: 3, cost: 4000, upkeep: 38, + jobs: 60, revenue: 260, power: -70, water: -24, heat: 1.4, tech: 4, + appeal: 2, appealRadius: 3, + h: 1.7, color: '#33305e', accent: '#a78bfa', + desc: 'Converts electricity into research. The fastest road to a new era.', + }), + + // ── Power ─────────────────────────────────────────────────────────────── + B({ + id: 'diesel', name: 'Diesel Generator', cat: 'power', cost: 600, upkeep: 14, + power: 90, pollution: 12, heat: 3.0, appeal: -6, appealRadius: 4, + h: 0.8, color: '#4a3b30', accent: '#ff5a3c', + desc: 'Instant power at a filthy price. A crutch, never a plan.', + }), + B({ + id: 'solar', name: 'Solar Array', cat: 'power', cost: 900, upkeep: 4, + power: 55, heat: -0.1, + h: 0.25, color: '#1e2a4a', accent: '#ffd166', + desc: 'Silent and clean. Output dips through the night cycle.', + }), + B({ + id: 'wind', name: 'Wind Spire', cat: 'power', era: 2, cost: 1400, upkeep: 6, + power: 120, appeal: -1, appealRadius: 2, + h: 2.8, color: '#38455e', accent: '#d8e6ff', + desc: 'Tall, clean, and faintly resented by everyone beneath it.', + }), + B({ + id: 'geo', name: 'Geothermal Tap', cat: 'power', era: 2, cost: 2600, upkeep: 16, + power: 230, heat: 0.6, needs: 'hill', + h: 1.0, color: '#3f3348', accent: '#ff8ab0', + desc: 'Must be sunk into rock. Steady baseload with no smoke.', + }), + B({ + id: 'fusion', name: 'Fusion Reactor', cat: 'power', era: 3, cost: 9000, upkeep: 60, + power: 900, heat: 1.0, appeal: -2, appealRadius: 4, + h: 1.6, color: '#2a4a4a', accent: '#5ff2c8', + desc: 'One reactor ends the power question for good.', + }), + + // ── Water ─────────────────────────────────────────────────────────────── + B({ + id: 'pump', name: 'Water Pump', cat: 'water', cost: 380, upkeep: 5, + water: 90, power: -6, needs: 'water', + h: 0.4, color: '#25415c', accent: '#5ec8ff', + desc: 'Must sit on the shoreline. The cheapest litre in the city.', + }), + B({ + id: 'desal', name: 'Desalination Plant', cat: 'water', era: 2, cost: 2100, upkeep: 18, + water: 340, power: -40, heat: 0.8, needs: 'water', + h: 0.9, color: '#204a63', accent: '#7ad9ff', + desc: 'Turns the coast into a reservoir, for a price in megawatts.', + }), + B({ + id: 'condenser', name: 'Atmospheric Condenser', cat: 'water', era: 3, cost: 3000, upkeep: 20, + water: 260, power: -55, + h: 1.4, color: '#2b4f66', accent: '#9fe8ff', + desc: 'Pulls water from open air. Build it anywhere, run it forever.', + }), + + // ── Civic ─────────────────────────────────────────────────────────────── + B({ + id: 'clinic', name: 'Med Bay', cat: 'civic', cost: 700, upkeep: 12, + jobs: 14, power: -9, water: -8, appeal: 2, appealRadius: 3, + service: 'health', serviceRadius: 6, + h: 0.7, color: '#42324a', accent: '#ff6f91', + desc: 'Keeps citizens alive and, more importantly, keeps them here.', + }), + B({ + id: 'academy', name: 'Academy', cat: 'civic', cost: 950, upkeep: 15, + jobs: 20, power: -11, water: -9, tech: 1.5, appeal: 2, appealRadius: 3, + service: 'education', serviceRadius: 7, + h: 0.9, color: '#3a3457', accent: '#c9a84c', + desc: 'Slow, steady research and a permanent lift in civic mood.', + }), + B({ + id: 'security', name: 'Watch Post', cat: 'civic', cost: 550, upkeep: 10, + jobs: 12, power: -6, water: -4, appeal: 1, appealRadius: 2, + service: 'safety', serviceRadius: 6, + h: 0.6, color: '#3d3646', accent: '#8fa8ff', + desc: 'Order on the block. Unloved until the night it is missing.', + }), + + // ── Green ─────────────────────────────────────────────────────────────── + B({ + id: 'park', name: 'Bio Park', cat: 'green', cost: 180, upkeep: 2, + appeal: 6, appealRadius: 4, heat: -0.6, pollution: -1.5, + h: 0.14, color: '#1f3a2c', accent: '#5ee08a', + desc: 'The cheapest approval in the game. Sprinkle liberally.', + }), + B({ + id: 'skygarden', name: 'Sky Garden', cat: 'green', era: 2, cost: 1300, upkeep: 9, + appeal: 12, appealRadius: 5, heat: -1.8, pollution: -4, water: -6, + h: 1.1, color: '#22463a', accent: '#7dfba8', + desc: 'A terraced canopy that cools the district it crowns.', + }), + B({ + id: 'recycler', name: 'Recycler', cat: 'green', era: 2, cost: 1500, upkeep: 14, + jobs: 18, power: -20, pollution: -12, appeal: -1, appealRadius: 2, + h: 0.7, color: '#31432f', accent: '#a8e05e', + desc: 'Eats the smog your factories exhale.', + }), + B({ + id: 'scrubber', name: 'Carbon Scrubber', cat: 'green', era: 3, cost: 3600, upkeep: 26, + power: -60, heat: -4.5, pollution: -6, + h: 1.5, color: '#28483f', accent: '#5ff2c8', + desc: 'The only structure that meaningfully reverses the Heat Index.', + }), + + // ── Transit + spectacle ───────────────────────────────────────────────── + B({ + id: 'stadium', name: 'Pulse Arena', cat: 'transit', era: 2, cost: 3200, upkeep: 30, + jobs: 40, revenue: 180, power: -30, water: -18, appeal: 18, appealRadius: 7, + h: 1.2, color: '#4a2f56', accent: '#ff5ec4', + desc: 'A city-wide mood swing you can build on purpose.', + }), + B({ + id: 'transit', name: 'Transit Hub', cat: 'transit', era: 2, cost: 1600, upkeep: 16, + jobs: 22, power: -14, appeal: 2, appealRadius: 3, traffic: 0.14, + h: 0.8, color: '#33395c', accent: '#8b5cf6', + desc: 'Takes commuters off your roads. Congestion relief, city-wide.', + }), + B({ + id: 'maglev', name: 'Mag-Lev Loop', cat: 'transit', era: 3, cost: 5000, upkeep: 40, + power: -60, appeal: 4, appealRadius: 5, traffic: 0.26, + h: 2.0, color: '#39406b', accent: '#b39cff', + desc: 'Frictionless transit. The road network stops being your ceiling.', + }), + + // ── Landmark ──────────────────────────────────────────────────────────── + B({ + id: 'zeropoint', name: 'Zero Point', cat: 'special', era: 3, cost: 14000, upkeep: 80, + power: -140, water: -20, appeal: 25, appealRadius: 12, tech: 6, traffic: 0.35, + h: 4.4, color: '#3a2f5e', accent: '#ffe9a8', + desc: 'The interchange that ends gridlock forever. Finishing it wins the city.', + }), +]); + +/** @type {Map} */ +const INDEX = new Map(CATALOG.map((b) => [b.id, b])); + +/** @returns {typeof CATALOG[number]|undefined} */ +export const getBuilding = (id) => INDEX.get(id); + +/** Player-placeable entries available at or below the given era, in catalog order. */ +export const unlockedAt = (era) => CATALOG.filter((b) => !b.hidden && b.era <= era); + +/** Player-placeable entries locked behind a later era. */ +export const lockedAbove = (era) => CATALOG.filter((b) => !b.hidden && b.era > era); + +export const CATEGORY_LABEL = Object.freeze({ + road: 'Roads', + residential: 'Residential', + commercial: 'Commercial', + industry: 'Industry', + data: 'Data', + power: 'Power', + water: 'Water', + civic: 'Civic', + green: 'Green', + transit: 'Transit', + special: 'Landmark', +}); + +/** Categories that must touch a connected road to function. */ +export const NEEDS_ROAD = Object.freeze( + new Set(['residential', 'commercial', 'industry', 'data', 'civic', 'transit', 'special']), +); diff --git a/games/gridlock/src/config/directives.js b/games/gridlock/src/config/directives.js new file mode 100644 index 0000000..e7d50bb --- /dev/null +++ b/games/gridlock/src/config/directives.js @@ -0,0 +1,81 @@ +/** + * Directives — the ordered objective queue. Three are active at a time; each + * completion pays out and pulls the next one in, so there is always a concrete + * next thing to build. + * + * `test` is instantaneous. `streak` requires the condition to hold for N + * consecutive months, which is how the hard ones (stability, climate control) + * are expressed. + */ + +export const DIRECTIVES = Object.freeze([ + { + id: 'first-roots', title: 'First Roots', detail: 'Grow the city to 250 residents.', + reward: { credits: 1800 }, + test: (s) => s.population >= 250, + progress: (s) => [s.population, 250], + }, + { + id: 'keep-the-lights-on', title: 'Keep the Lights On', detail: 'Hold full power and water supply for 6 straight months.', + reward: { credits: 2400 }, + streak: 6, + holds: (s, st) => st.power.ratio >= 1 && st.water.ratio >= 1 && s.population > 100, + }, + { + id: 'green-belt', title: 'Green Belt', detail: 'Operate 6 green structures.', + reward: { credits: 2000, tech: 20 }, + test: (s, st) => (st.counts.green ?? 0) >= 6, + progress: (s, st) => [st.counts.green ?? 0, 6], + }, + { + id: 'trade-district', title: 'Trade District', detail: 'Reach ₡700 per month in business revenue.', + reward: { credits: 3000 }, + test: (s, st) => st.business >= 700, + progress: (s, st) => [st.business, 700], + }, + { + id: 'second-era', title: 'The Solar Age', detail: 'Bank enough research to reach Era 2.', + reward: { credits: 3500 }, + test: (s) => s.era >= 2, + progress: (s) => [s.tech, 120], + }, + { + id: 'metropolis', title: 'Metropolis', detail: 'Grow the city to 2,000 residents.', + reward: { credits: 5000, tech: 30 }, + test: (s) => s.population >= 2000, + progress: (s) => [s.population, 2000], + }, + { + id: 'cool-head', title: 'Cool Head', detail: 'Hold the Heat Index under 45 for 8 months at 1,500+ residents.', + reward: { credits: 6000, tech: 40 }, + streak: 8, + holds: (s) => s.heat < 45 && s.population >= 1500, + }, + { + id: 'free-flowing', title: 'Free Flowing', detail: 'Keep congestion under 50% with 2,500+ residents.', + reward: { credits: 6000 }, + streak: 5, + holds: (s, st) => st.congestion < 0.5 && s.population >= 2500, + }, + { + id: 'beloved', title: 'Beloved', detail: 'Hold approval above 80 for 6 months.', + reward: { credits: 8000, tech: 50 }, + streak: 6, + holds: (s) => s.approval > 80, + }, + { + id: 'third-era', title: 'The Fusion Age', detail: 'Bank enough research to reach Era 3.', + reward: { credits: 10000 }, + test: (s) => s.era >= 3, + progress: (s) => [s.tech, 640], + }, + { + id: 'zero-point', title: 'Zero Point', detail: 'Build and power the Zero Point interchange.', + reward: { credits: 0 }, + victory: true, + test: (s, st) => s.grid.tiles.some((t) => t.b === 'zeropoint' && st.live.has(t.i)), + }, +]); + +export const MAX_ACTIVE_DIRECTIVES = 3; +export const directiveById = (id) => DIRECTIVES.find((d) => d.id === id); diff --git a/games/gridlock/src/config/eras.js b/games/gridlock/src/config/eras.js new file mode 100644 index 0000000..a8f8f9e --- /dev/null +++ b/games/gridlock/src/config/eras.js @@ -0,0 +1,48 @@ +/** + * Era progression. Research (tech) is the only gate — population and credits + * follow from it, so the player always has one clear thing to chase. + */ + +import { TECH } from './balance.js'; + +export const ERAS = Object.freeze([ + { + id: 1, + name: 'Diesel Age', + tagline: 'Smoke, scaffolding, and a grid held together by nerve.', + techCost: TECH.eraCost[0], + palette: '#ff7a3d', + }, + { + id: 2, + name: 'Solar Age', + tagline: 'The city learns to breathe. Data becomes the second economy.', + techCost: TECH.eraCost[1], + palette: '#38e1c8', + }, + { + id: 3, + name: 'Fusion Age', + tagline: 'Limitless power, one unfinished spire, and a planet running a fever.', + techCost: TECH.eraCost[2], + palette: '#a78bfa', + }, +]); + +export const eraById = (id) => ERAS.find((e) => e.id === id) ?? ERAS[0]; + +/** + * Highest era affordable at the given cumulative research. + * @param {number} tech + */ +export const eraForTech = (tech) => { + let era = ERAS[0].id; + for (const e of ERAS) if (tech >= e.techCost) era = e.id; + return era; +}; + +/** Research still required to reach the next era, or null at max era. */ +export const techToNextEra = (tech) => { + const next = ERAS.find((e) => e.techCost > tech); + return next ? { era: next, remaining: next.techCost - tech } : null; +}; diff --git a/games/gridlock/src/config/events.js b/games/gridlock/src/config/events.js new file mode 100644 index 0000000..5262fdc --- /dev/null +++ b/games/gridlock/src/config/events.js @@ -0,0 +1,236 @@ +/** + * The crisis deck. + * + * Crises are the difference between a city-painter and a game: they pause the + * world and force a trade with no clean answer. Every card is data — the + * engine in core/events.js interprets the effect kinds and never hard-codes a + * specific crisis. + * + * Effect kinds: + * credits {value} — signed, may be a fn(state, stats) + * approval {value} — signed points, applied immediately + * pop {mul} | {value} — migration shock + * tech {value} — research grant + * heat {value} — instant heat index shift + * modifier {id,label,months,effects} — timed multiplier, see core/stats.js + * destroy {count, cat?} — level structures, seeded-random pick + * risk {chance, then, otherwise} — a gamble the player opts into + */ + +const mod = (id, label, months, effects) => ({ kind: 'modifier', id, label, months, effects }); + +export const CRISIS_DECK = Object.freeze([ + { + id: 'grid-surge', + title: 'Grid Surge', + flavor: 'Load is running ahead of generation. Engineering wants a decision before the transformers make one for them.', + weight: 3, + // WHY not power.ratio: that value is clamped at 1, so it can never express + // "supply is only just keeping up". Compare the raw figures instead. + when: (s, st) => (st.counts.power ?? 0) >= 1 + && st.power.demand > 0 + && st.power.supply < st.power.demand * 1.15, + choices: [ + { + label: 'Shed load city-wide', + detail: 'Rolling brownouts for four months. Nobody will thank you.', + effects: [ + mod('surge-shed', 'Rolling Brownouts', 4, { powerSupplyMul: 0.78 }), + { kind: 'approval', value: -5 }, + ], + }, + { + label: 'Run the plants hot', + detail: 'Hold full output and hope the hardware holds with it.', + effects: [ + { + kind: 'risk', chance: 0.45, + then: [{ kind: 'destroy', count: 1, cat: 'power' }, { kind: 'approval', value: -8 }], + otherwise: [{ kind: 'approval', value: 3 }], + }, + ], + }, + ], + }, + { + id: 'heatwave', + title: 'Heatwave', + flavor: 'Six straight weeks above record. Water draw is spiking and the grid is carrying every cooling unit in the city.', + weight: 3, + when: (s) => s.heat > 38, + onFire: [mod('heatwave-base', 'Heatwave', 5, { waterDemandMul: 1.35, powerDemandMul: 1.12 })], + choices: [ + { + label: 'Ration water', + detail: 'Cut consumption by a quarter. Expect anger.', + effects: [ + mod('heatwave-ration', 'Water Rationing', 5, { waterDemandMul: 0.72 }), + { kind: 'approval', value: -9 }, + ], + }, + { + label: 'Buy emergency reserves', + detail: 'Truck it in at spot price.', + cost: (s) => Math.round(1200 + s.population * 3.5), + effects: [{ kind: 'approval', value: 2 }], + }, + ], + }, + { + id: 'corp-bid', + title: 'Corporate Bid', + flavor: 'Helion Group wants tax-free siting rights for a compute campus. The cheque is enormous. So is the waste heat.', + weight: 2, + when: (s) => s.era >= 2, + choices: [ + { + label: 'Take the deal', + detail: 'Cash now, a decade of thermal load later.', + effects: [ + { kind: 'credits', value: 18000 }, + mod('corp-heat', 'Helion Campus', 14, { heatAdd: 4.5, upkeepMul: 1.06 }), + { kind: 'approval', value: -6 }, + ], + }, + { label: 'Refuse the siting rights', detail: 'Keep the skyline yours.', effects: [{ kind: 'approval', value: 4 }] }, + ], + }, + { + id: 'influx', + title: 'Arrival Wave', + flavor: 'Three coastal cities went under this season. Their people are at your perimeter.', + weight: 2, + when: (s, st) => st.capacity > s.population * 1.05, + choices: [ + { + label: 'Open the gates', + detail: 'Instant population, instant strain on every service you have.', + effects: [{ kind: 'pop', mul: 1.16 }, { kind: 'approval', value: -4 }, + mod('influx-strain', 'Service Strain', 6, { waterDemandMul: 1.1 })], + }, + { label: 'Seal the perimeter', detail: 'The city stays as it is. So does its conscience.', effects: [{ kind: 'approval', value: -11 }] }, + ], + }, + { + id: 'transit-strike', + title: 'Transit Strike', + flavor: 'Operators walked at midnight. Every arterial is a parking lot.', + weight: 3, + when: (s, st) => st.congestion > 0.55, + choices: [ + { + label: 'Meet their terms', + detail: 'Expensive, immediate, and quietly popular.', + cost: (s, st) => Math.round(st.expenses * 6 + 800), + effects: [{ kind: 'approval', value: 5 }], + }, + { + label: 'Break the strike', + detail: 'Trains run tomorrow. The city remembers for years.', + effects: [{ kind: 'approval', value: -13 }, mod('strike-fallout', 'Labour Fallout', 5, { revenueMul: 0.86 })], + }, + ], + }, + { + id: 'breach', + title: 'Data Breach', + flavor: 'Someone walked out of your compute district with the resident registry.', + weight: 2, + when: (s, st) => (st.counts.data ?? 0) >= 1, + choices: [ + { label: 'Pay the ransom', detail: 'Quiet, fast, and never truly over.', cost: 6500, effects: [{ kind: 'approval', value: -2 }] }, + { + label: 'Go public', + detail: 'Full disclosure. Take the hit in daylight.', + effects: [{ kind: 'approval', value: -9 }, mod('breach-fallout', 'Investor Flight', 6, { revenueMul: 0.82 })], + }, + ], + }, + { + id: 'windfall', + title: 'Sovereign Grant', + flavor: 'Your approval numbers reached the capital. They would like to be associated with success.', + weight: 2, + when: (s) => s.approval > 72, + choices: [ + { + label: 'Accept the grant', + detail: 'Enormous capital, permanent oversight overhead.', + effects: [{ kind: 'credits', value: 12000 }, mod('grant-admin', 'Federal Oversight', 12, { upkeepMul: 1.12 })], + }, + { label: 'Decline politely', detail: 'Independence has its own value.', effects: [{ kind: 'approval', value: 4 }] }, + ], + }, + { + id: 'tremor', + title: 'Ground Tremor', + flavor: 'The fault under the ridge moved four centimetres. Structural is calling it a warning shot.', + weight: 2, + when: (s) => s.month > 24, + choices: [ + { label: 'Emergency retrofit', detail: 'Brace the district before the next one.', cost: 5200, effects: [] }, + { + label: 'Ride it out', + detail: 'Spend nothing. Risk a building.', + effects: [{ + kind: 'risk', chance: 0.55, + then: [{ kind: 'destroy', count: 1 }, { kind: 'approval', value: -6 }], + otherwise: [], + }], + }, + ], + }, + { + id: 'smog', + title: 'Smog Alert', + flavor: 'Particulate is off the scale. The clinics are full of people who did nothing but breathe.', + weight: 3, + when: (s, st) => st.pollution > 24, + choices: [ + { + label: 'Idle the factories', + detail: 'Clean air, empty ledgers.', + effects: [mod('smog-idle', 'Industrial Shutdown', 4, { revenueMul: 0.72 }), { kind: 'approval', value: 6 }], + }, + { label: 'Dispute the readings', detail: 'Production continues. So does the coughing.', effects: [{ kind: 'approval', value: -14 }] }, + ], + }, + { + id: 'breakthrough', + title: 'Fusion Breakthrough', + flavor: 'A lab team has containment holding past nine minutes. They need funding tonight, not next quarter.', + weight: 2, + when: (s, st) => (st.counts.data ?? 0) >= 1 && s.era >= 2, + choices: [ + { label: 'Fund it', detail: 'A generational leap in research.', cost: 8000, effects: [{ kind: 'tech', value: 110 }, { kind: 'approval', value: 3 }] }, + { label: 'Shelve the programme', detail: 'The money stays in the treasury.', effects: [] }, + ], + }, + { + id: 'unrest', + title: 'Blackout Riots', + flavor: 'Four districts went dark and stayed dark. The crowd outside city hall is not a protest any more.', + weight: 4, + when: (s) => s.approval < 34, + choices: [ + { label: 'Impose curfew', detail: 'Order tonight, resentment for a year.', effects: [{ kind: 'approval', value: 7 }, mod('curfew', 'Curfew', 6, { growthMul: 0.55 })] }, + { label: 'Emergency spending package', detail: 'Buy back the city\'s goodwill.', cost: 9000, effects: [{ kind: 'approval', value: 15 }] }, + ], + }, + { + id: 'festival', + title: 'The Pulse Festival', + flavor: 'Promoters want the whole waterfront for a week. It would be the loudest thing this city has ever done.', + weight: 2, + when: (s) => s.credits > 6000 && s.approval > 40, + choices: [ + { + label: 'Fund the festival', + detail: 'A citywide high and a tourism spike.', + cost: 4200, + effects: [{ kind: 'approval', value: 11 }, mod('festival-boom', 'Festival Boom', 4, { revenueMul: 1.18 })], + }, + { label: 'Deny the permit', detail: 'Quiet streets, quiet ledger.', effects: [{ kind: 'approval', value: -3 }] }, + ], + }, +]); diff --git a/games/gridlock/src/config/synergy.js b/games/gridlock/src/config/synergy.js new file mode 100644 index 0000000..f90f3eb --- /dev/null +++ b/games/gridlock/src/config/synergy.js @@ -0,0 +1,77 @@ +/** + * Adjacency synergy rules — the placement puzzle at the heart of GRIDLOCK. + * + * A rule reads: "for each 8-neighbour of `subject` that matches `neighbor` + * (a build category) or `terrain`, apply `per`, up to `cap` neighbours." + * + * Multipliers are additive fractions applied to the subject's own output: + * revenueMul +0.08 × 3 neighbours → ×1.24 revenue + * appealAdd -3 × 2 neighbours → -6 appeal + * + * WHY data, not code: a designer can retune the entire spatial meta-game here + * without touching the aggregation pass in core/synergy.js. + */ + +export const SYNERGY_RULES = Object.freeze([ + { + id: 'foot-traffic', subject: 'commercial', neighbor: 'residential', + per: { revenueMul: 0.08 }, cap: 4, label: 'Foot Traffic', + hint: 'Commerce next to housing sells more.', + }, + { + id: 'retail-cluster', subject: 'commercial', neighbor: 'commercial', + per: { revenueMul: 0.05 }, cap: 2, label: 'Retail Cluster', + hint: 'Shops draw crowds to each other.', + }, + { + id: 'supply-chain', subject: 'industry', neighbor: 'industry', + per: { revenueMul: 0.07 }, cap: 3, label: 'Supply Chain', + hint: 'Factories that share a fence share a pipeline.', + }, + { + id: 'direct-feed', subject: 'data', neighbor: 'power', + per: { revenueMul: 0.09 }, cap: 2, label: 'Direct Feed', + hint: 'Server farms wired straight to a plant lose nothing in transit.', + }, + { + id: 'coolant-draw', subject: 'data', terrain: 'water', + per: { heatMul: -0.18 }, cap: 3, label: 'Coolant Draw', + hint: 'Waterfront data centres dump heat into the bay.', + }, + { + id: 'green-outlook', subject: 'residential', neighbor: 'green', + per: { appealAdd: 2.5 }, cap: 3, label: 'Green Outlook', + hint: 'A window onto a park is worth real approval.', + }, + { + id: 'downwind', subject: 'residential', neighbor: 'industry', + per: { appealAdd: -3.5 }, cap: 3, label: 'Downwind', + hint: 'Nobody wants to sleep beside a foundry.', + }, + { + id: 'connected', subject: 'residential', neighbor: 'transit', + per: { capacityMul: 0.06 }, cap: 2, label: 'Connected', + hint: 'Housing beside transit packs in more residents.', + }, + { + id: 'canopy', subject: 'green', neighbor: 'green', + per: { heatMul: 0.12 }, cap: 3, label: 'Canopy', + hint: 'Contiguous greenery cools harder than scattered planters.', + }, + { + id: 'campus', subject: 'data', neighbor: 'civic', + per: { techMul: 0.15 }, cap: 2, label: 'Campus', + hint: 'Labs beside academies research faster.', + }, + { + id: 'shoreline', subject: 'residential', terrain: 'water', + per: { appealAdd: 1.5 }, cap: 3, label: 'Shoreline', + hint: 'A view of the water lifts a whole block.', + }, +]); + +/** Rules indexed by subject category — the sim only scans what can apply. */ +export const RULES_BY_SUBJECT = SYNERGY_RULES.reduce((acc, rule) => { + (acc[rule.subject] ||= []).push(rule); + return acc; +}, /** @type {Record} */ ({})); diff --git a/games/gridlock/src/core/commands.js b/games/gridlock/src/core/commands.js new file mode 100644 index 0000000..2c360bc --- /dev/null +++ b/games/gridlock/src/core/commands.js @@ -0,0 +1,78 @@ +/** + * Player commands. Each returns a new state — the caller never mutates. + * Validation lives here and nowhere else, so the UI, the tests, and any future + * scripted agent all get identical rules. + */ + +import { TAX } from '../config/balance.js'; +import { getBuilding } from '../config/buildings.js'; +import { TERRAIN, idx, inBounds, touchesTerrain } from './grid.js'; +import { cloneState, pushLog } from './state.js'; + +export const REFUND_RATE = 0.35; + +/** Structures that may sit on a ridge; everything else needs flat ground. */ +const HILL_OK = new Set(['geo', 'park', 'road', 'wind']); + +/** @returns {{ok: boolean, reason?: string}} */ +export const canPlace = (state, x, y, buildingId) => { + if (!inBounds(state.grid, x, y)) return { ok: false, reason: 'Outside the map.' }; + const def = getBuilding(buildingId); + if (!def || def.hidden) return { ok: false, reason: 'Unknown structure.' }; + if (def.era > state.era) return { ok: false, reason: `Locked until Era ${def.era}.` }; + + const tile = state.grid.tiles[idx(state.grid, x, y)]; + if (tile.b !== null) return { ok: false, reason: 'Tile occupied.' }; + if (tile.t === TERRAIN.WATER) return { ok: false, reason: 'Cannot build on water.' }; + if (tile.t === TERRAIN.HILL && !HILL_OK.has(def.id)) return { ok: false, reason: 'Ridge is too steep.' }; + + if (def.needs === 'water' && !touchesTerrain(state.grid, x, y, TERRAIN.WATER)) { + return { ok: false, reason: 'Must touch open water.' }; + } + if (def.needs === 'hill' && tile.t !== TERRAIN.HILL) { + return { ok: false, reason: 'Must be sunk into a ridge.' }; + } + if (def.cost > state.credits) return { ok: false, reason: 'Not enough credits.' }; + return { ok: true }; +}; + +/** @returns {{state: object, ok: boolean, reason?: string}} */ +export const place = (state, x, y, buildingId) => { + const check = canPlace(state, x, y, buildingId); + if (!check.ok) return { state, ...check }; + + const def = getBuilding(buildingId); + const draft = cloneState(state); + const tile = draft.grid.tiles[idx(draft.grid, x, y)]; + tile.b = buildingId; + tile.age = 0; + draft.credits -= def.cost; + if (def.cost >= 2000) pushLog(draft, `${def.name} under construction.`, 'info'); + return { state: draft, ok: true }; +}; + +/** @returns {{state: object, ok: boolean, reason?: string}} */ +export const demolish = (state, x, y) => { + if (!inBounds(state.grid, x, y)) return { state, ok: false, reason: 'Outside the map.' }; + const tile = state.grid.tiles[idx(state.grid, x, y)]; + if (tile.b === null) return { state, ok: false, reason: 'Nothing here.' }; + if (tile.b === 'nexus') return { state, ok: false, reason: 'The Nexus cannot be removed.' }; + + const def = getBuilding(tile.b); + const draft = cloneState(state); + const target = draft.grid.tiles[idx(draft.grid, x, y)]; + target.b = null; + target.on = false; + draft.credits += Math.round((def?.cost ?? 0) * REFUND_RATE); + return { state: draft, ok: true }; +}; + +export const setTax = (state, rate) => ({ + ...state, + taxRate: Math.max(TAX.min, Math.min(TAX.max, Math.round(rate * 100) / 100)), +}); + +export const setSpeed = (state, speed) => ({ + ...state, + speed: Math.max(0, Math.min(3, speed | 0)), +}); diff --git a/games/gridlock/src/core/directives.js b/games/gridlock/src/core/directives.js new file mode 100644 index 0000000..ad8156e --- /dev/null +++ b/games/gridlock/src/core/directives.js @@ -0,0 +1,69 @@ +/** + * Directive queue evaluation. Runs once per month against a draft state. + */ + +import { DIRECTIVES, MAX_ACTIVE_DIRECTIVES, directiveById } from '../config/directives.js'; +import { pushLog } from './state.js'; + +/** Fill the active slots from the queue, in order, skipping finished ones. */ +export const refillDirectives = (state) => { + const { active, done } = state.directives; + for (const directive of DIRECTIVES) { + if (active.length >= MAX_ACTIVE_DIRECTIVES) break; + if (done.includes(directive.id) || active.includes(directive.id)) continue; + active.push(directive.id); + } + return state; +}; + +/** True when the directive's condition is met right now. */ +export const isComplete = (directive, state, stats) => { + if (directive.test) return Boolean(directive.test(state, stats)); + if (directive.streak) return (state.directives.progress[directive.id] ?? 0) >= directive.streak; + return false; +}; + +/** Current/goal pair for the progress bar, or null when not measurable. */ +export const directiveProgress = (directive, state, stats) => { + if (directive.streak) { + return [state.directives.progress[directive.id] ?? 0, directive.streak]; + } + if (directive.progress) { + const [current, goal] = directive.progress(state, stats); + return [Math.min(current, goal), goal]; + } + return null; +}; + +/** + * Advance streaks, pay out completions, and pull in replacements. + * Mutates the draft state — called only from the tick. + */ +export const evaluateDirectives = (state, stats) => { + refillDirectives(state); + + for (const id of [...state.directives.active]) { + const directive = directiveById(id); + if (!directive) continue; + + if (directive.streak) { + const held = directive.holds(state, stats); + const current = state.directives.progress[id] ?? 0; + state.directives.progress[id] = held ? current + 1 : 0; + } + if (!isComplete(directive, state, stats)) continue; + + state.directives.active = state.directives.active.filter((a) => a !== id); + state.directives.done.push(id); + state.credits += directive.reward.credits ?? 0; + state.tech += directive.reward.tech ?? 0; + + const reward = directive.reward.credits + ? ` +₡${directive.reward.credits.toLocaleString()}` + : ''; + pushLog(state, `Directive complete — ${directive.title}.${reward}`, 'good'); + if (directive.victory) state.won = true; + } + + return refillDirectives(state); +}; diff --git a/games/gridlock/src/core/economy.js b/games/gridlock/src/core/economy.js new file mode 100644 index 0000000..d655fc8 --- /dev/null +++ b/games/gridlock/src/core/economy.js @@ -0,0 +1,75 @@ +/** + * The three feedback loops that make a city feel alive: approval, migration, + * and heat. Each is a pure function of (state, stats) so they can be tested + * in isolation and reasoned about one at a time. + */ + +import { APPROVAL, CLIMATE, GROWTH, LABOR, TAX } from '../config/balance.js'; + +const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v)); + +/** Pollution normalised against city size — a big city tolerates more smoke. */ +export const pollutionPressure = (stats) => + clamp(stats.pollution / Math.max(14, stats.housingCount * 2.2), 0, 1.4); + +/** 0 below the threshold, 1 at the critical index. */ +export const heatPressure = (heat) => + clamp((heat - CLIMATE.threshold) / (CLIMATE.critical - CLIMATE.threshold), 0, 1); + +/** + * Where approval is heading. Approval itself chases this with inertia, so a + * single bad month never collapses a city and a single park never saves one. + */ +export const approvalTarget = (state, stats) => { + const target = APPROVAL.base + + clamp(stats.appeal, -APPROVAL.appealCap, APPROVAL.appealCap) + - stats.unemployment * APPROVAL.unemploymentWeight + - pollutionPressure(stats) * APPROVAL.pollutionWeight + - (1 - stats.power.ratio) * APPROVAL.powerWeight + - (1 - stats.water.ratio) * APPROVAL.waterWeight + - stats.gridlock * APPROVAL.congestionWeight + + (stats.coverage - 0.5) * APPROVAL.coverageWeight + - heatPressure(state.heat) * APPROVAL.heatWeight + - (state.taxRate - TAX.neutral) * TAX.approvalSlope + + stats.mods.approvalAdd; + return clamp(target, 0, 100); +}; + +export const nextApproval = (state, stats) => { + const target = approvalTarget(state, stats); + return clamp(state.approval + (target - state.approval) * APPROVAL.inertia, 0, 100); +}; + +/** + * Migration. Housing sets the ceiling, approval sets how full it gets, and + * jobs cap the whole thing — a city with no work sheds people no matter how + * pretty it is. + */ +export const nextPopulation = (state, stats) => { + const desired = stats.capacity * (GROWTH.floorShare + (1 - GROWTH.floorShare) * (state.approval / 100)); + const jobCeiling = (stats.jobs / LABOR.participation) * LABOR.slack; + const target = Math.min(desired, jobCeiling); + let pop = state.population + (target - state.population) * GROWTH.rate * stats.mods.growthMul; + + // Utility failure drives people out faster than approval alone would. + const outage = Math.max(1 - stats.power.ratio, 1 - stats.water.ratio); + if (outage > 0) pop -= pop * GROWTH.exodusRate * outage; + + return Math.max(0, pop); +}; + +export const nextHeat = (state, stats) => { + const delta = stats.heatLoad * CLIMATE.gain - CLIMATE.decay * (state.heat / 40); + return clamp(state.heat + delta, 0, CLIMATE.max); +}; + +/** Grade shown on the HUD. Purely cosmetic, but players chase it. */ +export const cityGrade = (approval, heat) => { + const score = approval - heatPressure(heat) * 25; + if (score >= 88) return { label: 'UTOPIAN', tone: 'good' }; + if (score >= 74) return { label: 'THRIVING', tone: 'good' }; + if (score >= 58) return { label: 'STABLE', tone: 'ok' }; + if (score >= 42) return { label: 'STRAINED', tone: 'warn' }; + if (score >= 25) return { label: 'FAILING', tone: 'bad' }; + return { label: 'COLLAPSING', tone: 'bad' }; +}; diff --git a/games/gridlock/src/core/events.js b/games/gridlock/src/core/events.js new file mode 100644 index 0000000..1e2ea41 --- /dev/null +++ b/games/gridlock/src/core/events.js @@ -0,0 +1,129 @@ +/** + * Crisis engine — draws cards from the deck and interprets their effects. + * + * Every random decision routes through state.rollCount so a save replays + * identically: the roll is a function of (seed, rollCount), never of wall + * clock or Math.random. + */ + +import { EVENTS } from '../config/balance.js'; +import { CRISIS_DECK } from '../config/events.js'; +import { getBuilding } from '../config/buildings.js'; +import { makeRng, weightedPick } from './rng.js'; +import { cloneState, pushLog } from './state.js'; + +/** Advance the deterministic stream and return the next value in [0,1). */ +export const nextRoll = (state) => { + state.rollCount += 1; + return makeRng(state.seed + state.rollCount * 7919)(); +}; + +const byId = new Map(CRISIS_DECK.map((c) => [c.id, c])); +export const getCrisis = (id) => byId.get(id); + +/** + * Decide whether a crisis fires this month. Mutates the draft state's roll + * counter and returns the drawn card, or null. + */ +export const drawCrisis = (state, stats) => { + if (state.crisis.active) return null; + if (state.month < EVENTS.graceMonths || state.month < state.crisis.cooldownUntil) return null; + if (nextRoll(state) > EVENTS.chance) return null; + + const eligible = CRISIS_DECK.filter((card) => { + try { + return card.when(state, stats); + } catch { + return false; // A malformed condition must never take the game down. + } + }); + if (eligible.length === 0) return null; + + // Unseen cards are three times as likely — the deck stays surprising longer. + const weightOf = (card) => card.weight * (state.crisis.seen.includes(card.id) ? 1 : 3); + return weightedPick(eligible, weightOf, nextRoll(state)); +}; + +const destroyOne = (state, cat) => { + const targets = state.grid.tiles.filter((t) => { + if (t.b === null || t.b === 'nexus') return false; + return cat ? getBuilding(t.b)?.cat === cat : true; + }); + if (targets.length === 0) return null; + const victim = targets[Math.floor(nextRoll(state) * targets.length) % targets.length]; + const name = getBuilding(victim.b)?.name ?? 'structure'; + victim.b = null; + victim.on = false; + return name; +}; + +const applyEffects = (state, stats, effects) => { + for (const effect of effects ?? []) { + switch (effect.kind) { + case 'credits': + state.credits += typeof effect.value === 'function' ? effect.value(state, stats) : effect.value; + break; + case 'approval': + state.approval = Math.max(0, Math.min(100, state.approval + effect.value)); + break; + case 'pop': + state.population = effect.mul ? state.population * effect.mul : state.population + effect.value; + break; + case 'tech': + state.tech += effect.value; + break; + case 'heat': + state.heat = Math.max(0, Math.min(100, state.heat + effect.value)); + break; + case 'modifier': + state.modifiers = [ + ...state.modifiers.filter((m) => m.id !== effect.id), + { id: effect.id, label: effect.label, months: effect.months, effects: effect.effects }, + ]; + break; + case 'destroy': { + for (let n = 0; n < (effect.count ?? 1); n++) { + const lost = destroyOne(state, effect.cat); + if (lost) pushLog(state, `${lost} lost.`, 'bad'); + } + break; + } + case 'risk': { + const hit = nextRoll(state) < effect.chance; + applyEffects(state, stats, hit ? effect.then : effect.otherwise); + break; + } + default: + break; + } + } +}; + +/** + * Resolve the active crisis with the chosen option. + * @returns {{state: object, ok: boolean, reason?: string}} + */ +export const resolveCrisis = (state, stats, choiceIndex) => { + const card = state.crisis.active ? getCrisis(state.crisis.active) : null; + if (!card) return { state, ok: false, reason: 'No active crisis.' }; + const choice = card.choices[choiceIndex]; + if (!choice) return { state, ok: false, reason: 'Unknown option.' }; + + const cost = typeof choice.cost === 'function' ? choice.cost(state, stats) : (choice.cost ?? 0); + if (cost > state.credits) return { state, ok: false, reason: 'Not enough credits.' }; + + const draft = cloneState(state); + draft.credits -= cost; + applyEffects(draft, stats, choice.effects); + draft.crisis = { + active: null, + cooldownUntil: draft.month + EVENTS.cooldown, + seen: draft.crisis.seen.includes(card.id) ? draft.crisis.seen : [...draft.crisis.seen, card.id], + }; + pushLog(draft, `${card.title}: ${choice.label}.`, 'event'); + return { state: draft, ok: true }; +}; + +/** Cost of an option right now, for the modal to render and disable buttons. */ +export const choiceCost = (choice, state, stats) => + typeof choice.cost === 'function' ? choice.cost(state, stats) : (choice.cost ?? 0); diff --git a/games/gridlock/src/core/fields.js b/games/gridlock/src/core/fields.js new file mode 100644 index 0000000..75d6517 --- /dev/null +++ b/games/gridlock/src/core/fields.js @@ -0,0 +1,67 @@ +/** + * Spatial fields: desirability and service coverage. + * + * Both are radial and linearly falling off, computed once per tick and reused + * by the economy, the renderer overlays, and the tile inspector. + */ + +import { getBuilding } from '../config/buildings.js'; +import { SERVICE_KINDS } from '../config/balance.js'; +import { forEachInRadius, TERRAIN } from './grid.js'; +import { tileSynergy } from './synergy.js'; + +/** + * @param {object} grid + * @param {Set} live tiles that are road-connected and utility-fed + * @returns {{appeal: Float32Array, services: Record}} + */ +export const computeFields = (grid, live) => { + const size = grid.w * grid.h; + const appeal = new Float32Array(size); + const services = Object.fromEntries(SERVICE_KINDS.map((k) => [k, new Float32Array(size)])); + + for (const tile of grid.tiles) { + // Open water is pleasant to look at even where nothing is built. + if (tile.t === TERRAIN.WATER) appeal[tile.i] += 1.5; + if (tile.b === null) continue; + const def = getBuilding(tile.b); + if (!def || !live.has(tile.i)) continue; + + if (def.appeal && def.appealRadius) { + const bonus = def.appeal + tileSynergy(grid, tile).appealAdd; + forEachInRadius(grid, tile.x, tile.y, def.appealRadius, (t, dist) => { + appeal[t.i] += bonus * (1 - dist / (def.appealRadius + 1)); + }); + } + if (def.service) { + const field = services[def.service]; + forEachInRadius(grid, tile.x, tile.y, def.serviceRadius, (t, dist) => { + field[t.i] = Math.max(field[t.i], 1 - (dist / (def.serviceRadius + 1)) * 0.5); + }); + } + } + return { appeal, services }; +}; + +/** + * Mean service coverage across housing, 0–1. Empty cities read as fully + * covered so a brand-new map is not punished for having no citizens yet. + */ +export const housingCoverage = (grid, fields, housingTiles) => { + if (housingTiles.length === 0) return 1; + let total = 0; + for (const tile of housingTiles) { + let sum = 0; + for (const kind of SERVICE_KINDS) sum += fields.services[kind][tile.i]; + total += sum / SERVICE_KINDS.length; + } + return total / housingTiles.length; +}; + +/** Mean desirability across housing, used for approval and growth. */ +export const housingAppeal = (grid, fields, housingTiles) => { + if (housingTiles.length === 0) return 0; + let total = 0; + for (const tile of housingTiles) total += fields.appeal[tile.i]; + return total / housingTiles.length; +}; diff --git a/games/gridlock/src/core/grid.js b/games/gridlock/src/core/grid.js new file mode 100644 index 0000000..59c0bfe --- /dev/null +++ b/games/gridlock/src/core/grid.js @@ -0,0 +1,90 @@ +/** + * Tile grid: terrain generation, indexing, and neighbourhood queries. + * Knows nothing about economics — it is pure geometry. + */ + +import { GRID } from '../config/balance.js'; +import { noise2, hash2 } from './rng.js'; + +export const TERRAIN = Object.freeze({ LAND: 0, WATER: 1, HILL: 2 }); + +export const idx = (grid, x, y) => y * grid.w + x; +export const inBounds = (grid, x, y) => x >= 0 && y >= 0 && x < grid.w && y < grid.h; +export const tileAt = (grid, x, y) => (inBounds(grid, x, y) ? grid.tiles[idx(grid, x, y)] : null); + +const NEIGHBORS_4 = [[1, 0], [-1, 0], [0, 1], [0, -1]]; +const NEIGHBORS_8 = [...NEIGHBORS_4, [1, 1], [1, -1], [-1, 1], [-1, -1]]; + +/** Orthogonal neighbours — used for road networks and terrain requirements. */ +export const neighbors4 = (grid, x, y) => + NEIGHBORS_4.map(([dx, dy]) => tileAt(grid, x + dx, y + dy)).filter(Boolean); + +/** Full ring — used for adjacency synergy. */ +export const neighbors8 = (grid, x, y) => + NEIGHBORS_8.map(([dx, dy]) => tileAt(grid, x + dx, y + dy)).filter(Boolean); + +const makeTile = (i, w, terrain, seed) => ({ + i, + x: i % w, + y: Math.floor(i / w), + t: terrain, + b: null, + age: 0, + on: false, + variant: hash2(i % w, Math.floor(i / w), seed + 77), +}); + +/** + * Generate a bay-and-ridge map. The centre 6×6 is forced to buildable land so + * the opening move is never blocked by terrain. + */ +export const createGrid = (seed, w = GRID.width, h = GRID.height) => { + const grid = { w, h, tiles: new Array(w * h) }; + const cx = (w - 1) / 2; + const cy = (h - 1) / 2; + + for (let i = 0; i < w * h; i++) { + const x = i % w; + const y = Math.floor(i / w); + const coast = noise2(x, y, 9, seed) * 0.75 + noise2(x, y, 3.5, seed + 1) * 0.25; + // Push water toward the south-east corner so the bay reads as a coastline. + const bay = (x / w) * 0.28 + (y / h) * 0.34; + const ridge = noise2(x, y, 6, seed + 400); + + let terrain = TERRAIN.LAND; + if (coast + bay > 0.86) terrain = TERRAIN.WATER; + else if (ridge > 0.78) terrain = TERRAIN.HILL; + + const nearCentre = Math.abs(x - cx) < 3 && Math.abs(y - cy) < 3; + grid.tiles[i] = makeTile(i, w, nearCentre ? TERRAIN.LAND : terrain, seed); + } + return grid; +}; + +/** Shallow-copies every tile so a simulation pass never mutates prior state. */ +export const cloneGrid = (grid) => ({ + w: grid.w, + h: grid.h, + tiles: grid.tiles.map((t) => ({ ...t })), +}); + +/** True when any orthogonal neighbour has the given terrain type. */ +export const touchesTerrain = (grid, x, y, terrain) => + neighbors4(grid, x, y).some((t) => t.t === terrain); + +/** Every built tile, in index order. */ +export const builtTiles = (grid) => grid.tiles.filter((t) => t.b !== null); + +/** Iterate tiles inside a square radius without allocating an array. */ +export const forEachInRadius = (grid, x, y, radius, fn) => { + const x0 = Math.max(0, x - radius); + const x1 = Math.min(grid.w - 1, x + radius); + const y0 = Math.max(0, y - radius); + const y1 = Math.min(grid.h - 1, y + radius); + for (let ty = y0; ty <= y1; ty++) { + for (let tx = x0; tx <= x1; tx++) { + const dist = Math.hypot(tx - x, ty - y); + if (dist <= radius) fn(grid.tiles[idx(grid, tx, ty)], dist); + } + } +}; diff --git a/games/gridlock/src/core/network.js b/games/gridlock/src/core/network.js new file mode 100644 index 0000000..8185929 --- /dev/null +++ b/games/gridlock/src/core/network.js @@ -0,0 +1,78 @@ +/** + * Road connectivity. + * + * A structure is live only when it touches a road that traces back to the + * Nexus. This is what makes roads a real decision instead of decoration. + */ + +import { NEEDS_ROAD, getBuilding } from '../config/buildings.js'; +import { idx, inBounds } from './grid.js'; + +const STEPS = [[1, 0], [-1, 0], [0, 1], [0, -1]]; + +/** + * @returns {{roads: Set, connected: Set, roadCount: number}} + * `roads` — road tiles wired to the Nexus. + * `connected` — every tile whose structure is legally serviced. + */ +export const computeNetwork = (grid) => { + const roads = new Set(); + const connected = new Set(); + const origin = grid.tiles.find((t) => t.b === 'nexus'); + if (!origin) return { roads, connected, roadCount: 0 }; + + connected.add(origin.i); + + // Flood the road graph outward from the tiles orthogonal to the Nexus. + const queue = []; + for (const [dx, dy] of STEPS) { + const nx = origin.x + dx; + const ny = origin.y + dy; + if (!inBounds(grid, nx, ny)) continue; + const t = grid.tiles[idx(grid, nx, ny)]; + if (t.b === 'road' && !roads.has(t.i)) { + roads.add(t.i); + queue.push(t); + } + } + while (queue.length) { + const tile = queue.pop(); + for (const [dx, dy] of STEPS) { + const nx = tile.x + dx; + const ny = tile.y + dy; + if (!inBounds(grid, nx, ny)) continue; + const next = grid.tiles[idx(grid, nx, ny)]; + if (next.b === 'road' && !roads.has(next.i)) { + roads.add(next.i); + queue.push(next); + } + } + } + + for (const i of roads) connected.add(i); + + // Any structure orthogonally touching the live road graph is serviced. + // Structures outside NEEDS_ROAD (power, water, parks) run standalone. + for (const tile of grid.tiles) { + if (tile.b === null || connected.has(tile.i)) continue; + const def = getBuilding(tile.b); + if (!def) continue; + if (!NEEDS_ROAD.has(def.cat)) { + connected.add(tile.i); + continue; + } + for (const [dx, dy] of STEPS) { + const nx = tile.x + dx; + const ny = tile.y + dy; + if (inBounds(grid, nx, ny) && roads.has(idx(grid, nx, ny))) { + connected.add(tile.i); + break; + } + } + } + + let roadCount = 0; + for (const tile of grid.tiles) if (tile.b === 'road') roadCount++; + + return { roads, connected, roadCount }; +}; diff --git a/games/gridlock/src/core/rationing.js b/games/gridlock/src/core/rationing.js new file mode 100644 index 0000000..8f86abe --- /dev/null +++ b/games/gridlock/src/core/rationing.js @@ -0,0 +1,42 @@ +/** + * Deterministic utility rationing. + * + * When supply falls short, the shortfall must land somewhere specific and + * repeatable — never on a random building. Consumers are served in a fixed + * priority order, then by tile index, so the same shortfall always browns out + * the same blocks and the player can reason about it. + */ + +/** Lower rank is served first. Factories go dark before hospitals do. */ +export const POWER_PRIORITY = Object.freeze({ + water: 0, civic: 1, special: 2, residential: 3, transit: 4, + green: 5, commercial: 6, data: 7, industry: 8, road: 9, power: 9, +}); + +export const WATER_PRIORITY = Object.freeze({ + residential: 0, civic: 1, special: 2, green: 3, commercial: 4, + data: 5, industry: 6, transit: 7, road: 9, power: 9, water: 9, +}); + +/** + * @param {{i: number, cat: string, draw: number}[]} consumers + * @param {number} supply + * @param {Record} priority + * @returns {{served: Set, demand: number, ratio: number}} + */ +export const ration = (consumers, supply, priority) => { + const demand = consumers.reduce((sum, c) => sum + c.draw, 0); + const served = new Set(); + if (demand === 0) return { served, demand: 0, ratio: 1 }; + + const queue = [...consumers].sort( + (a, b) => (priority[a.cat] ?? 9) - (priority[b.cat] ?? 9) || a.i - b.i, + ); + let budget = supply; + for (const consumer of queue) { + if (consumer.draw > budget) continue; // Skip, don't stop: small loads still fit. + budget -= consumer.draw; + served.add(consumer.i); + } + return { served, demand, ratio: Math.min(1, supply / demand) }; +}; diff --git a/games/gridlock/src/core/rng.js b/games/gridlock/src/core/rng.js new file mode 100644 index 0000000..5d2d479 --- /dev/null +++ b/games/gridlock/src/core/rng.js @@ -0,0 +1,54 @@ +/** + * Deterministic randomness. + * + * INVARIANT: the simulation never calls Math.random. Every stochastic decision + * draws from a seeded stream stored in state, so a save file replays exactly. + */ + +/** mulberry32 — small, fast, good enough for terrain and event draws. */ +export const makeRng = (seed) => { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +}; + +/** Stable 2-D hash in [0,1). Used for terrain and per-tile render variance. */ +export const hash2 = (x, y, seed = 0) => { + let h = Math.imul(x | 0, 374761393) ^ Math.imul(y | 0, 668265263) ^ Math.imul(seed | 0, 2246822519); + h = Math.imul(h ^ (h >>> 13), 1274126177); + return ((h ^ (h >>> 16)) >>> 0) / 4294967296; +}; + +const smooth = (t) => t * t * (3 - 2 * t); + +/** Value noise over the tile plane at the given cell size. */ +export const noise2 = (x, y, scale, seed) => { + const px = x / scale; + const py = y / scale; + const x0 = Math.floor(px); + const y0 = Math.floor(py); + const fx = smooth(px - x0); + const fy = smooth(py - y0); + const a = hash2(x0, y0, seed); + const b = hash2(x0 + 1, y0, seed); + const c = hash2(x0, y0 + 1, seed); + const d = hash2(x0 + 1, y0 + 1, seed); + return (a * (1 - fx) + b * fx) * (1 - fy) + (c * (1 - fx) + d * fx) * fy; +}; + +/** Pick one weighted entry. `weightOf` must return a non-negative number. */ +export const weightedPick = (items, weightOf, roll) => { + const total = items.reduce((sum, item) => sum + Math.max(0, weightOf(item)), 0); + if (total <= 0) return null; + let cursor = roll * total; + for (const item of items) { + cursor -= Math.max(0, weightOf(item)); + if (cursor <= 0) return item; + } + return items[items.length - 1]; +}; diff --git a/games/gridlock/src/core/simulate.js b/games/gridlock/src/core/simulate.js new file mode 100644 index 0000000..4d6658c --- /dev/null +++ b/games/gridlock/src/core/simulate.js @@ -0,0 +1,101 @@ +/** + * The monthly tick — the only place time moves. + * + * Pure: tickMonth(state) → new state. Nothing here reads the clock, the DOM, + * or Math.random, which is what makes the whole simulation testable and a save + * file replayable. + */ + +import { eraForTech, eraById } from '../config/eras.js'; +import { computeStats } from './stats.js'; +import { nextApproval, nextHeat, nextPopulation } from './economy.js'; +import { evaluateDirectives } from './directives.js'; +import { drawCrisis } from './events.js'; +import { cloneState, pushHistory, pushLog } from './state.js'; + +export const BANKRUPT_FLOOR = -2000; + +/** Expire timed crisis modifiers, logging the ones that lapse. */ +const ageModifiers = (draft) => { + const survivors = []; + for (const mod of draft.modifiers) { + const months = mod.months - 1; + if (months > 0) survivors.push({ ...mod, months }); + else pushLog(draft, `${mod.label} has lifted.`, 'info'); + } + draft.modifiers = survivors; +}; + +/** Mirror the live set onto tiles so the renderer needs no simulation logic. */ +const syncTiles = (draft, stats) => { + for (const tile of draft.grid.tiles) { + if (tile.b === null) continue; + tile.on = stats.live.has(tile.i); + tile.age += 1; + } +}; + +const advanceEra = (draft) => { + const era = eraForTech(draft.tech); + if (era <= draft.era) return; + draft.era = era; + const info = eraById(era); + pushLog(draft, `Era ${era} — ${info.name}. New structures unlocked.`, 'good'); +}; + +const recordHistory = (draft, stats) => { + pushHistory(draft.history.population, Math.round(draft.population)); + pushHistory(draft.history.credits, Math.round(draft.credits)); + pushHistory(draft.history.approval, Math.round(draft.approval)); + pushHistory(draft.history.heat, Math.round(draft.heat)); + pushHistory(draft.history.net, Math.round(stats.net)); +}; + +/** + * @param {object} state + * @returns {object} the next state, with `stats` holding this month's snapshot + */ +export const tickMonth = (state) => { + const stats = computeStats(state); + const draft = cloneState(state); + + draft.month += 1; + draft.credits += stats.net; + draft.population = nextPopulation(state, stats); + draft.approval = nextApproval(state, stats); + draft.heat = nextHeat(state, stats); + draft.tech += stats.techGain; + + advanceEra(draft); + ageModifiers(draft); + syncTiles(draft, stats); + evaluateDirectives(draft, stats); + + const card = drawCrisis(draft, stats); + if (card) { + draft.crisis = { ...draft.crisis, active: card.id }; + draft.speed = 0; // A crisis stops the world until the player answers it. + for (const effect of card.onFire ?? []) { + draft.modifiers = [ + ...draft.modifiers.filter((m) => m.id !== effect.id), + { id: effect.id, label: effect.label, months: effect.months, effects: effect.effects }, + ]; + } + pushLog(draft, `${card.title} — a decision is required.`, 'event'); + } + + if (draft.credits < BANKRUPT_FLOOR && !draft.bankrupt) { + draft.bankrupt = true; + draft.speed = 0; + pushLog(draft, 'The treasury is empty. The city is in receivership.', 'bad'); + } else if (draft.credits >= 0 && draft.bankrupt) { + draft.bankrupt = false; + } + + recordHistory(draft, stats); + draft.stats = stats; + return draft; +}; + +/** Recompute the stats snapshot without advancing time (used after edits). */ +export const refreshStats = (state) => ({ ...state, stats: computeStats(state) }); diff --git a/games/gridlock/src/core/state.js b/games/gridlock/src/core/state.js new file mode 100644 index 0000000..64a6f09 --- /dev/null +++ b/games/gridlock/src/core/state.js @@ -0,0 +1,95 @@ +/** + * State construction and cloning. + * + * INVARIANT: state is plain, serialisable data. No class instances, no + * functions, no DOM references — so a save file is `JSON.stringify(state)` and + * a replay is exact. + */ + +import { GRID, START } from '../config/balance.js'; +import { createGrid, cloneGrid, idx } from './grid.js'; + +export const LOG_LIMIT = 40; +export const HISTORY_LIMIT = 72; + +export const createInitialState = (seed = (Date.now() & 0xffffff)) => { + const grid = createGrid(seed); + const cx = Math.floor(GRID.width / 2); + const cy = Math.floor(GRID.height / 2); + const nexus = grid.tiles[idx(grid, cx, cy)]; + nexus.b = 'nexus'; + nexus.on = true; + + // Seed a short road stub so the first zone has somewhere legal to sit. + for (const [dx, dy] of [[1, 0], [2, 0], [-1, 0], [-2, 0], [0, 1], [0, -1]]) { + grid.tiles[idx(grid, cx + dx, cy + dy)].b = 'road'; + } + + const state = { + version: 1, + seed, + month: 0, + credits: START.credits, + population: 0, + approval: START.approval, + heat: START.heat, + taxRate: START.taxRate, + tech: 0, + era: 1, + speed: 1, + won: false, + bankrupt: false, + grid, + stats: null, + crisis: { active: null, cooldownUntil: 0, seen: [] }, + modifiers: [], + directives: { active: [], done: [], progress: {} }, + history: { population: [], credits: [], approval: [], heat: [], net: [] }, + log: [], + rollCount: 0, + }; + return pushLog(state, 'The Nexus is online. Build outward.', 'good'); +}; + +/** + * Structural clone. Cheap enough to run per command and per tick (1024 shallow + * tile copies ≈ tens of microseconds) and it removes every aliasing bug class. + */ +export const cloneState = (state) => ({ + ...state, + grid: cloneGrid(state.grid), + crisis: { ...state.crisis, seen: [...state.crisis.seen] }, + modifiers: state.modifiers.map((m) => ({ ...m })), + directives: { + active: [...state.directives.active], + done: [...state.directives.done], + progress: { ...state.directives.progress }, + }, + history: { + population: [...state.history.population], + credits: [...state.history.credits], + approval: [...state.history.approval], + heat: [...state.history.heat], + net: [...state.history.net], + }, + log: [...state.log], +}); + +/** Append a dated line to the city log, newest first, bounded. */ +export const pushLog = (state, text, kind = 'info') => { + state.log = [{ month: state.month, text, kind }, ...state.log].slice(0, LOG_LIMIT); + return state; +}; + +/** Append to a bounded history series. */ +export const pushHistory = (series, value) => { + series.push(value); + if (series.length > HISTORY_LIMIT) series.shift(); + return series; +}; + +/** Month index → in-world date. Month 0 is Jan 2087. */ +export const formatDate = (month) => { + const names = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC']; + return `${names[month % 12]} ${2087 + Math.floor(month / 12)}`; +}; diff --git a/games/gridlock/src/core/stats.js b/games/gridlock/src/core/stats.js new file mode 100644 index 0000000..fd55710 --- /dev/null +++ b/games/gridlock/src/core/stats.js @@ -0,0 +1,142 @@ +/** + * The aggregation pass: one grid scan per month producing every number the + * rest of the game reads. Pure — takes state, returns a stats snapshot. + * + * Resolution order matters and flows one way: + * roads → power → water → live structures → output → traffic + * Water plants that lost power cannot pump; that dependency is expressed by + * ordering, not by special-casing. + */ + +import { CLIMATE, LABOR, TRAFFIC } from '../config/balance.js'; +import { getBuilding } from '../config/buildings.js'; +import { computeNetwork } from './network.js'; +import { ration, POWER_PRIORITY, WATER_PRIORITY } from './rationing.js'; +import { computeFields, housingAppeal, housingCoverage } from './fields.js'; +import { tileSynergy } from './synergy.js'; + +/** Collapse active crisis modifiers into one multiplier set. */ +export const aggregateModifiers = (modifiers) => { + const acc = { + powerSupplyMul: 1, powerDemandMul: 1, waterSupplyMul: 1, waterDemandMul: 1, + revenueMul: 1, upkeepMul: 1, growthMul: 1, approvalAdd: 0, heatAdd: 0, + }; + for (const mod of modifiers) { + for (const [key, value] of Object.entries(mod.effects ?? {})) { + if (key === 'approvalAdd' || key === 'heatAdd') acc[key] += value; + else if (key in acc) acc[key] *= value; + } + } + return acc; +}; + +const emptyBreakdown = () => ({ tax: 0, commercial: 0, industry: 0, data: 0, civic: 0 }); + +export const computeStats = (state) => { + const { grid } = state; + const mods = aggregateModifiers(state.modifiers); + const network = computeNetwork(grid); + const built = grid.tiles.filter((t) => t.b !== null); + + // ── Power ──────────────────────────────────────────────────────────────── + const coolingMul = 1 + Math.max(0, state.heat - CLIMATE.threshold) * CLIMATE.coolingLoad; + let powerSupply = 0; + const powerConsumers = []; + for (const tile of built) { + const def = getBuilding(tile.b); + if (!def || !network.connected.has(tile.i)) continue; + if (def.power > 0) powerSupply += def.power; + else if (def.power < 0) { + powerConsumers.push({ + i: tile.i, cat: def.cat, draw: -def.power * coolingMul * mods.powerDemandMul, + }); + } + } + powerSupply *= mods.powerSupplyMul; + const power = ration(powerConsumers, powerSupply, POWER_PRIORITY); + const isPowered = (tile, def) => + network.connected.has(tile.i) && (def.power >= 0 || power.served.has(tile.i)); + + // ── Water (depends on power) ───────────────────────────────────────────── + let waterSupply = 0; + const waterConsumers = []; + for (const tile of built) { + const def = getBuilding(tile.b); + if (!def || !isPowered(tile, def)) continue; + if (def.water > 0) waterSupply += def.water; + else if (def.water < 0) { + waterConsumers.push({ i: tile.i, cat: def.cat, draw: -def.water * mods.waterDemandMul }); + } + } + waterSupply *= mods.waterSupplyMul; + const water = ration(waterConsumers, waterSupply, WATER_PRIORITY); + + // ── Live structures ────────────────────────────────────────────────────── + const live = new Set(); + const housing = []; + let stranded = 0; + for (const tile of built) { + const def = getBuilding(tile.b); + if (!def) continue; + const ok = isPowered(tile, def) && (def.water >= 0 || water.served.has(tile.i)); + if (ok) live.add(tile.i); + // Cut off from the road graph — the failure players most often miss. + else if (def.cat !== 'road' && !network.connected.has(tile.i)) stranded += 1; + if (def.cat === 'residential') housing.push(tile); + } + + const fields = computeFields(grid, live); + + // ── Output ─────────────────────────────────────────────────────────────── + let capacity = 0, jobs = 0, pollution = 0, heatLoad = 0, techGain = 0; + let upkeep = 0, trafficRelief = 0; + const gross = emptyBreakdown(); + const counts = {}; + + for (const tile of built) { + const def = getBuilding(tile.b); + if (!def) continue; + counts[def.cat] = (counts[def.cat] ?? 0) + 1; + upkeep += def.upkeep; // Infrastructure costs money whether or not it runs. + if (!live.has(tile.i)) continue; + + const syn = tileSynergy(grid, tile); + capacity += def.residents * syn.capacityMul; + jobs += def.jobs; + pollution += def.pollution; + heatLoad += def.heat * syn.heatMul; + techGain += def.tech * syn.techMul; + trafficRelief += def.traffic; + if (def.revenue) gross[def.cat] = (gross[def.cat] ?? 0) + def.revenue * syn.revenueMul; + } + + // ── Labour + traffic ───────────────────────────────────────────────────── + const workforce = state.population * LABOR.participation; + const jobsFilled = Math.min(workforce, jobs); + const employment = workforce > 0 ? jobsFilled / workforce : 1; + const staffing = jobs > 0 ? jobsFilled / jobs : 1; + + const roadCapacity = Math.max(1, network.roadCount * TRAFFIC.roadCapacity); + const congestion = (jobsFilled / roadCapacity) * Math.max(0.2, 1 - trafficRelief); + const gridlock = Math.max(0, Math.min(1, (congestion - TRAFFIC.tolerance) / (1 - TRAFFIC.tolerance))); + + // ── Money ──────────────────────────────────────────────────────────────── + const business = (gross.commercial + gross.industry + gross.data + gross.civic) * staffing; + const taxIncome = state.population * state.taxRate * 19.5; + const revenue = (taxIncome + business * (1 - gridlock * 0.3)) * mods.revenueMul; + const expenses = upkeep * mods.upkeepMul; + + return { + network, fields, live, powered: power.served, watered: water.served, + power: { supply: powerSupply, demand: power.demand, ratio: power.ratio }, + water: { supply: waterSupply, demand: water.demand, ratio: water.ratio }, + capacity, jobs, jobsFilled, workforce, employment, staffing, + unemployment: Math.max(0, 1 - employment), + congestion, gridlock, roadCapacity, + pollution, heatLoad: heatLoad + mods.heatAdd, techGain, + appeal: housingAppeal(grid, fields, housing), + coverage: housingCoverage(grid, fields, housing), + revenue, expenses, net: revenue - expenses, taxIncome, business, gross, upkeep, + counts, housingCount: housing.length, stranded, mods, + }; +}; diff --git a/games/gridlock/src/core/synergy.js b/games/gridlock/src/core/synergy.js new file mode 100644 index 0000000..abd9a3a --- /dev/null +++ b/games/gridlock/src/core/synergy.js @@ -0,0 +1,60 @@ +/** + * Adjacency scoring. Turns the map from a canvas into a puzzle: the same + * building is worth materially more or less depending on what it touches. + */ + +import { getBuilding } from '../config/buildings.js'; +import { RULES_BY_SUBJECT } from '../config/synergy.js'; +import { neighbors8, TERRAIN } from './grid.js'; + +export const NEUTRAL = Object.freeze({ + revenueMul: 1, capacityMul: 1, heatMul: 1, techMul: 1, appealAdd: 0, hits: [], +}); + +const TERRAIN_OF = { water: TERRAIN.WATER, hill: TERRAIN.HILL }; + +const matches = (rule, neighbor) => { + if (rule.neighbor) { + const def = neighbor.b ? getBuilding(neighbor.b) : null; + return Boolean(def) && def.cat === rule.neighbor; + } + if (rule.terrain) return neighbor.t === TERRAIN_OF[rule.terrain]; + return false; +}; + +/** + * Multipliers earned by one tile from its eight neighbours. + * @returns {typeof NEUTRAL} + */ +export const tileSynergy = (grid, tile) => { + const def = tile.b ? getBuilding(tile.b) : null; + if (!def) return NEUTRAL; + const rules = RULES_BY_SUBJECT[def.cat]; + if (!rules) return NEUTRAL; + + const ring = neighbors8(grid, tile.x, tile.y); + const out = { revenueMul: 1, capacityMul: 1, heatMul: 1, techMul: 1, appealAdd: 0, hits: [] }; + + for (const rule of rules) { + let count = 0; + for (const neighbor of ring) if (matches(rule, neighbor)) count++; + count = Math.min(count, rule.cap); + if (count === 0) continue; + + const { revenueMul = 0, capacityMul = 0, heatMul = 0, techMul = 0, appealAdd = 0 } = rule.per; + out.revenueMul += revenueMul * count; + out.capacityMul += capacityMul * count; + out.heatMul += heatMul * count; + out.techMul += techMul * count; + out.appealAdd += appealAdd * count; + out.hits.push({ id: rule.id, label: rule.label, count, cap: rule.cap, hint: rule.hint }); + } + return out; +}; + +/** + * What a candidate building *would* score at (x, y), for the placement preview. + * Evaluates against a temporary tile so nothing is mutated. + */ +export const previewSynergy = (grid, x, y, buildingId) => + tileSynergy(grid, { x, y, b: buildingId, t: TERRAIN.LAND, i: -1 }); diff --git a/games/gridlock/src/io/audio.js b/games/gridlock/src/io/audio.js new file mode 100644 index 0000000..011d1a4 --- /dev/null +++ b/games/gridlock/src/io/audio.js @@ -0,0 +1,64 @@ +/** + * Synthesised UI audio — no asset files, a few hundred bytes of oscillator. + * + * The context is created lazily on the first gesture because browsers refuse + * to start audio before one, and every call is wrapped: a blocked or missing + * AudioContext must never break a click handler. + */ + +const VOICES = { + place: { freq: 520, type: 'triangle', ms: 90, gain: 0.16 }, + bulldoze: { freq: 180, type: 'sawtooth', ms: 130, gain: 0.13 }, + deny: { freq: 120, type: 'square', ms: 150, gain: 0.1 }, + tick: { freq: 880, type: 'sine', ms: 40, gain: 0.05 }, + good: { freq: 700, type: 'sine', ms: 220, gain: 0.14, slide: 1.5 }, + crisis: { freq: 260, type: 'sawtooth', ms: 420, gain: 0.16, slide: 0.6 }, +}; + +export const createAudio = () => { + let ctx = null; + let muted = false; + + const ensure = () => { + if (ctx) return ctx; + const Ctor = window.AudioContext || window.webkitAudioContext; + if (!Ctor) return null; + try { + ctx = new Ctor(); + } catch { + ctx = null; + } + return ctx; + }; + + return { + play(name) { + if (muted) return; + const voice = VOICES[name]; + const audio = ensure(); + if (!voice || !audio) return; + try { + if (audio.state === 'suspended') audio.resume(); + const osc = audio.createOscillator(); + const gain = audio.createGain(); + const now = audio.currentTime; + const end = now + voice.ms / 1000; + osc.type = voice.type; + osc.frequency.setValueAtTime(voice.freq, now); + if (voice.slide) osc.frequency.exponentialRampToValueAtTime(voice.freq * voice.slide, end); + gain.gain.setValueAtTime(voice.gain, now); + gain.gain.exponentialRampToValueAtTime(0.0001, end); + osc.connect(gain).connect(audio.destination); + osc.start(now); + osc.stop(end); + } catch { + // Audio is a nicety; failure is silent by design. + } + }, + toggleMute() { + muted = !muted; + return muted; + }, + isMuted: () => muted, + }; +}; diff --git a/games/gridlock/src/io/input.js b/games/gridlock/src/io/input.js new file mode 100644 index 0000000..e08e119 --- /dev/null +++ b/games/gridlock/src/io/input.js @@ -0,0 +1,97 @@ +/** + * Pointer and keyboard handling. + * + * Two gestures matter: drag-to-build (roads are unusable without it) and + * zoom-toward-cursor (panning to find the spot you were already pointing at is + * the fastest way to make a builder feel cheap). + */ + +import { clampCamera, pickTile } from '../render/iso.js'; + +export const attachInput = (canvas, cam, view, getState, handlers) => { + let painting = false; + let panning = false; + let lastPan = { x: 0, y: 0 }; + let lastPainted = -1; + + const localPoint = (event) => { + const rect = canvas.getBoundingClientRect(); + return { px: event.clientX - rect.left, py: event.clientY - rect.top }; + }; + + const tileUnder = (event) => { + const { px, py } = localPoint(event); + return pickTile(px, py, cam, view, getState().grid); + }; + + canvas.addEventListener('pointerdown', (event) => { + canvas.setPointerCapture(event.pointerId); + const tile = tileUnder(event); + const wantsPan = event.button === 1 || event.button === 2 || !handlers.hasTool(); + + if (wantsPan) { + panning = true; + lastPan = { x: event.clientX, y: event.clientY }; + canvas.style.cursor = 'grabbing'; + return; + } + painting = true; + lastPainted = -1; + if (tile) { + lastPainted = tile.y * getState().grid.w + tile.x; + handlers.onPaint(tile); + } + }); + + canvas.addEventListener('pointermove', (event) => { + if (panning) { + cam.panX += (event.clientX - lastPan.x) / cam.zoom; + cam.panY += (event.clientY - lastPan.y) / cam.zoom; + lastPan = { x: event.clientX, y: event.clientY }; + clampCamera(cam, getState().grid); + return; + } + const tile = tileUnder(event); + handlers.onHover(tile); + if (!painting || !tile) return; + const key = tile.y * getState().grid.w + tile.x; + if (key === lastPainted) return; // One action per tile per drag. + lastPainted = key; + handlers.onPaint(tile); + }); + + const endGesture = (event) => { + if (canvas.hasPointerCapture?.(event.pointerId)) canvas.releasePointerCapture(event.pointerId); + painting = false; + panning = false; + canvas.style.cursor = ''; + }; + canvas.addEventListener('pointerup', endGesture); + canvas.addEventListener('pointercancel', endGesture); + canvas.addEventListener('pointerleave', () => handlers.onHover(null)); + canvas.addEventListener('contextmenu', (event) => event.preventDefault()); + + canvas.addEventListener('wheel', (event) => { + event.preventDefault(); + const { px, py } = localPoint(event); + const z0 = cam.zoom; + cam.zoom *= event.deltaY < 0 ? 1.12 : 1 / 1.12; + clampCamera(cam, getState().grid); + // Hold the world point under the cursor still: + // world = (p - centre)/zoom - pan ⇒ pan' = pan + (p - centre)(1/z' - 1/z) + const invDelta = 1 / cam.zoom - 1 / z0; + cam.panX += (px - view.width / 2) * invDelta; + cam.panY += (py - view.height / 2) * invDelta; + clampCamera(cam, getState().grid); + }, { passive: false }); + + window.addEventListener('keydown', (event) => { + if (event.target instanceof HTMLInputElement) return; + handlers.onKey(event); + }); + + return { + isPanning: () => panning, + detach: () => canvas.replaceWith(canvas.cloneNode(true)), + }; +}; diff --git a/games/gridlock/src/io/persist.js b/games/gridlock/src/io/persist.js new file mode 100644 index 0000000..b55b5e6 --- /dev/null +++ b/games/gridlock/src/io/persist.js @@ -0,0 +1,58 @@ +/** + * Save/load. State is plain JSON by construction, so persistence is a + * stringify with the derived stats snapshot stripped — derived data is always + * recomputed, never trusted from disk. + * + * Every path is guarded: a browser with storage disabled, a full quota, or a + * corrupted payload must degrade to "no save", never to a broken game. + */ + +import { SAVE_KEY, SAVE_VERSION } from '../config/balance.js'; + +const storage = () => { + try { + const probe = '__gridlock__'; + window.localStorage.setItem(probe, '1'); + window.localStorage.removeItem(probe); + return window.localStorage; + } catch { + return null; // Private mode, blocked cookies, or no storage at all. + } +}; + +export const saveGame = (state) => { + const store = storage(); + if (!store) return { ok: false, reason: 'Storage unavailable in this browser.' }; + try { + const payload = JSON.stringify({ v: SAVE_VERSION, savedAt: Date.now(), state: { ...state, stats: null } }); + store.setItem(SAVE_KEY, payload); + return { ok: true, bytes: payload.length }; + } catch (error) { + return { ok: false, reason: error?.name === 'QuotaExceededError' ? 'Save file too large.' : 'Save failed.' }; + } +}; + +export const hasSave = () => { + const store = storage(); + return Boolean(store && store.getItem(SAVE_KEY)); +}; + +export const loadGame = () => { + const store = storage(); + if (!store) return { ok: false, reason: 'Storage unavailable.' }; + const raw = store.getItem(SAVE_KEY); + if (!raw) return { ok: false, reason: 'No saved city.' }; + try { + const parsed = JSON.parse(raw); + if (parsed.v !== SAVE_VERSION) return { ok: false, reason: 'Save is from an older build.' }; + if (!parsed.state?.grid?.tiles?.length) return { ok: false, reason: 'Save file is corrupt.' }; + return { ok: true, state: parsed.state, savedAt: parsed.savedAt }; + } catch { + return { ok: false, reason: 'Save file is corrupt.' }; + } +}; + +export const clearSave = () => { + const store = storage(); + if (store) store.removeItem(SAVE_KEY); +}; diff --git a/games/gridlock/src/main.js b/games/gridlock/src/main.js new file mode 100644 index 0000000..d72b6bc --- /dev/null +++ b/games/gridlock/src/main.js @@ -0,0 +1,307 @@ +/** + * Application shell: owns the state reference, the frame loop, and the wiring + * between input, simulation, and view. Everything else is pure or presentational. + * + * State moves in exactly one direction: + * input → command/tick (pure, returns new state) → renderer + UI read it + */ + +import { SPEED_MS } from './config/balance.js'; +import { canPlace, demolish, place, setSpeed, setTax } from './core/commands.js'; +import { getCrisis, resolveCrisis } from './core/events.js'; +import { createInitialState } from './core/state.js'; +import { refreshStats, tickMonth } from './core/simulate.js'; +import { createCamera } from './render/iso.js'; +import { createEffects } from './render/effects.js'; +import { createRenderer } from './render/renderer.js'; +import { OVERLAYS, nextOverlay } from './render/overlays.js'; +import { attachInput } from './io/input.js'; +import { createAudio } from './io/audio.js'; +import { hasSave, loadGame, saveGame } from './io/persist.js'; +import { createTelemetry } from './telemetry/telemetry.js'; +import { $, el, percent, setClass, setText } from './ui/dom.js'; +import { BULLDOZE, createBuildBar } from './ui/build.js'; +import { createHud } from './ui/hud.js'; +import { createInspector } from './ui/inspector.js'; +import { createModal } from './ui/modal.js'; +import { createPanels } from './ui/panels.js'; +import { createToaster } from './ui/toast.js'; +import { BANKRUPT, HELP, INTRO, VICTORY } from './ui/copy.js'; + +const MAX_CATCHUP_TICKS = 4; + +const boot = () => { + const canvas = $('#stage'); + const effects = createEffects(); + const renderer = createRenderer(canvas, effects); + const telemetry = createTelemetry(); + const audio = createAudio(); + const toaster = createToaster($('#toasts')); + const modal = createModal($('#modal')); + const hud = createHud(document); + const panels = createPanels(document); + const inspector = createInspector($('#inspector')); + + let state = refreshStats(createInitialState()); + // WHY one mutable camera object rather than reassignment: the input handler + // captures this reference for the life of the page. + const cam = createCamera(state.grid); + const resetCamera = (grid) => Object.assign(cam, createCamera(grid)); + let hover = null; + let tool = null; + let hoverDef = null; + let overlay = 'none'; + let accumulator = 0; + let lastFrame = performance.now(); + let announcedVictory = false; + let announcedBankruptcy = false; + + // ── View sync ────────────────────────────────────────────────────────── + const legalHere = () => + !hover || !tool || tool === BULLDOZE || canPlace(state, hover.x, hover.y, tool).ok; + + const refreshInspector = () => inspector.show(state, hover, tool, hoverDef); + + const refreshAll = () => { + hud.update(state); + panels.update(state); + build.update(state); + refreshInspector(); + setText($('#tax-value'), percent(state.taxRate)); + for (const btn of document.querySelectorAll('#speed-group [data-speed]')) { + setClass(btn, 'active', Number(btn.dataset.speed) === state.speed); + } + }; + + const commit = (next) => { + state = refreshStats(next); + refreshAll(); + }; + + // ── Commands ─────────────────────────────────────────────────────────── + const paint = (tile) => { + if (!tile || modal.isOpen()) return; + const result = tool === BULLDOZE + ? demolish(state, tile.x, tile.y) + : place(state, tile.x, tile.y, tool); + + if (!result.ok) { + toaster.push(result.reason ?? 'Cannot build there.', 'bad'); + audio.play('deny'); + return; + } + audio.play(tool === BULLDOZE ? 'bulldoze' : 'place'); + commit(result.state); + }; + + const build = createBuildBar($('#build-list'), { + onSelect: (id) => { + tool = id; + canvas.style.cursor = id ? 'crosshair' : 'grab'; + refreshInspector(); + }, + onHoverDef: (def) => { + hoverDef = def; + refreshInspector(); + }, + }); + + // ── Simulation ───────────────────────────────────────────────────────── + const advance = () => { + const started = performance.now(); + state = tickMonth(state); + telemetry.tick(performance.now() - started); + effects.emit(state, state.stats); + refreshAll(); + checkInterrupts(); + }; + + const checkInterrupts = () => { + if (state.crisis.active && !modal.isOpen()) { + const crisis = getCrisis(state.crisis.active); + if (crisis) { + audio.play('crisis'); + modal.showCrisis(crisis, state, state.stats, (index) => { + const result = resolveCrisis(state, state.stats, index); + if (!result.ok) return toaster.push(result.reason ?? 'Cannot do that.', 'bad'); + modal.hide(); + audio.play('good'); + commit(setSpeed(result.state, 1)); + return undefined; + }); + return; + } + } + if (state.won && !announcedVictory) { + announcedVictory = true; + audio.play('good'); + modal.showMessage({ ...VICTORY, actions: [{ label: 'Keep building', primary: true, onClick: modal.hide }] }); + return; + } + if (state.bankrupt && !announcedBankruptcy) { + announcedBankruptcy = true; + modal.showMessage({ ...BANKRUPT, kind: 'crisis', actions: [{ label: 'Understood', primary: true, onClick: modal.hide }] }); + } + if (!state.bankrupt) announcedBankruptcy = false; + }; + + // ── Frame loop ───────────────────────────────────────────────────────── + const frame = (now) => { + const dt = Math.min(now - lastFrame, 250); // Tab-switch guard. + lastFrame = now; + telemetry.frame(dt); + + if (!modal.isOpen() && state.speed > 0) { + accumulator += dt; + const step = SPEED_MS[state.speed]; + let ticks = 0; + while (accumulator >= step && ticks < MAX_CATCHUP_TICKS) { + accumulator -= step; + ticks += 1; + advance(); + } + if (ticks === MAX_CATCHUP_TICKS) accumulator = 0; // Never spiral. + } + + effects.update(dt); + const drawMs = renderer.draw(state, { + cam, hover, tool, overlay, time: now, canPlaceHere: legalHere(), + }); + telemetry.draw(drawMs); + renderTelemetry(); + requestAnimationFrame(frame); + }; + + // ── Chrome wiring ────────────────────────────────────────────────────── + const overlayGroup = $('#overlay-group'); + for (const view of OVERLAYS) { + const btn = el('button', 'btn tiny', view.label); + btn.dataset.overlay = view.id; + btn.title = view.hint; + btn.addEventListener('click', () => setOverlay(view.id)); + overlayGroup.appendChild(btn); + } + const setOverlay = (id) => { + overlay = id; + for (const btn of overlayGroup.querySelectorAll('[data-overlay]')) { + setClass(btn, 'active', btn.dataset.overlay === id); + } + }; + setOverlay('none'); + + for (const btn of document.querySelectorAll('#speed-group [data-speed]')) { + btn.addEventListener('click', () => commit(setSpeed(state, Number(btn.dataset.speed)))); + } + $('#tax-up').addEventListener('click', () => commit(setTax(state, state.taxRate + 0.01))); + $('#tax-down').addEventListener('click', () => commit(setTax(state, state.taxRate - 0.01))); + + $('#btn-save').addEventListener('click', () => { + const result = saveGame(state); + toaster.push(result.ok ? 'City saved to this browser.' : result.reason, result.ok ? 'good' : 'bad'); + }); + $('#btn-load').addEventListener('click', () => loadCity()); + $('#btn-new').addEventListener('click', () => newCity()); + $('#btn-mute').addEventListener('click', (event) => { + const muted = audio.toggleMute(); + event.currentTarget.textContent = muted ? '♪̸' : '♪'; + }); + $('#btn-help').addEventListener('click', () => showHelp()); + + const loadCity = () => { + const result = loadGame(); + if (!result.ok) return toaster.push(result.reason, 'bad'); + resetCamera(result.state.grid); + announcedVictory = result.state.won; + commit(result.state); + modal.hide(); + toaster.push('City restored.', 'good'); + return undefined; + }; + + const newCity = () => { + state = refreshStats(createInitialState()); + resetCamera(state.grid); + announcedVictory = false; + announcedBankruptcy = false; + build.select(null); + refreshAll(); + modal.hide(); + }; + + const showHelp = () => modal.showMessage({ + ...HELP, + actions: [{ label: 'Back to the city', primary: true, onClick: modal.hide }], + }); + + const showIntro = () => modal.showMessage({ + ...INTRO, + actions: [ + { label: 'Found the city', primary: true, onClick: modal.hide }, + ...(hasSave() ? [{ label: 'Load saved city', onClick: loadCity }] : []), + { label: 'How to play', onClick: showHelp }, + ], + }); + + // ── Keyboard ─────────────────────────────────────────────────────────── + const onKey = (event) => { + const key = event.key.toLowerCase(); + if (key === 'escape') { + if (modal.isOpen() && !state.crisis.active) modal.hide(); + else build.select(null); + return; + } + if (modal.isOpen()) return; + if (key === ' ') { + event.preventDefault(); + commit(setSpeed(state, state.speed === 0 ? 1 : 0)); + } else if (['1', '2', '3'].includes(key)) commit(setSpeed(state, Number(key))); + else if (key === 'x') build.select(BULLDOZE); + else if (key === 'o') setOverlay(nextOverlay(overlay)); + else if (key === 's') $('#btn-save').click(); + else if (key === 'h') showHelp(); + else if (key === '`') $('#telemetry').classList.toggle('hidden'); + }; + + const telemetryNode = $('#telemetry'); + const renderTelemetry = () => { + if (telemetryNode.classList.contains('hidden')) return; + const snap = telemetry.snapshot(); + telemetryNode.textContent = [ + `HEALTH alive=${snap.health.alive} frames=${snap.health.frames}`, + `PRESSURE fps=${snap.pressure.fps.toFixed(0)} draw=${snap.pressure.drawMs.toFixed(2)}ms tick=${snap.pressure.tickMs.toFixed(2)}ms`, + `EFFICIENCY heap=${snap.efficiency.heapMb ? `${snap.efficiency.heapMb.toFixed(1)}MB` : 'n/a'} drops=${percent(snap.efficiency.dropRate, 1)}`, + `WORLD month=${state.month} tiles=${state.grid.tiles.length} fx=${effects.count()}`, + ].join('\n'); + }; + + attachInput(canvas, cam, renderer.view, () => state, { + hasTool: () => tool !== null, + onHover: (tile) => { + hover = tile; + refreshInspector(); + }, + onPaint: paint, + onKey, + }); + + // Opt-in inspection hook for automated smoke tests and balance tuning. + // Read-only by contract: it exposes state, it does not accept commands. + if (new URLSearchParams(window.location.search).has('debug')) { + window.__gridlock = { + state: () => state, + camera: () => cam, + telemetry: () => telemetry.snapshot(), + modalOpen: () => modal.isOpen(), + }; + $('#telemetry').classList.remove('hidden'); + } + + window.addEventListener('resize', () => renderer.resize()); + renderer.resize(); + refreshAll(); + showIntro(); + requestAnimationFrame(frame); +}; + +if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); +else boot(); diff --git a/games/gridlock/src/render/effects.js b/games/gridlock/src/render/effects.js new file mode 100644 index 0000000..0fb3953 --- /dev/null +++ b/games/gridlock/src/render/effects.js @@ -0,0 +1,85 @@ +/** + * Particles and traffic. Pure eye-candy, hard-capped so it can never become a + * performance problem: the pool is bounded and old particles are recycled. + */ + +import { getBuilding } from '../config/buildings.js'; +import { LEVEL_H } from './iso.js'; +import { alpha } from './theme.js'; + +const MAX_PARTICLES = 180; + +export const createEffects = () => { + const pool = []; + + const spawn = (tile, def) => { + if (pool.length >= MAX_PARTICLES) return; + const smoke = def.pollution > 2 || def.heat > 1.5; + pool.push({ + x: tile.x, y: tile.y, + life: 0, + ttl: smoke ? 2600 : 1600, + rise: def.h * LEVEL_H + 6, + drift: (Math.random() - 0.5) * 10, + color: smoke ? '#8a7f9c' : def.accent, + size: smoke ? 4.5 : 2, + }); + }; + + return { + /** Emit from live emitters once per simulated month. */ + emit(state, stats) { + if (!stats) return; + for (const tile of state.grid.tiles) { + if (tile.b === null || !stats.live.has(tile.i)) continue; + const def = getBuilding(tile.b); + if (!def) continue; + if (def.pollution > 2 || def.heat > 1.5 || def.cat === 'special') spawn(tile, def); + } + }, + + update(dtMs) { + for (let i = pool.length - 1; i >= 0; i--) { + pool[i].life += dtMs; + if (pool[i].life >= pool[i].ttl) pool.splice(i, 1); + } + }, + + /** @param {(x:number,y:number)=>{sx:number,sy:number}} project */ + draw(ctx, project, zoom) { + for (const p of pool) { + const t = p.life / p.ttl; + const { sx, sy } = project(p.x, p.y); + ctx.fillStyle = alpha(p.color, (1 - t) * 0.5); + ctx.beginPath(); + ctx.arc( + sx + p.drift * t * zoom, + sy - (p.rise + t * 34) * zoom, + (p.size + t * 3) * zoom, + 0, Math.PI * 2, + ); + ctx.fill(); + } + }, + + count: () => pool.length, + }; +}; + +/** + * Commuter lights running along the road graph. Density and colour follow + * congestion, so a jammed city visibly clots. + */ +export const drawTraffic = (ctx, tile, sx, sy, zoom, time, congestion) => { + if (zoom < 0.55) return; + const density = Math.min(2, 1 + Math.floor(congestion * 2)); + const jam = Math.min(1, congestion); + const color = jam > 0.75 ? '#ff5470' : jam > 0.45 ? '#ffb347' : '#7ef7e2'; + const speed = 1 - jam * 0.7; + for (let i = 0; i < density; i++) { + const phase = ((time * 0.00035 * speed) + (tile.i * 0.37) + i * 0.5) % 1; + const ox = (phase - 0.5) * 30 * zoom; + ctx.fillStyle = alpha(color, 0.75); + ctx.fillRect(sx + ox, sy - 1.5 * zoom, 2.4 * zoom, 2 * zoom); + } +}; diff --git a/games/gridlock/src/render/iso.js b/games/gridlock/src/render/iso.js new file mode 100644 index 0000000..6dfac29 --- /dev/null +++ b/games/gridlock/src/render/iso.js @@ -0,0 +1,66 @@ +/** + * Isometric projection. Pure maths, no canvas — so the inverse mapping used by + * the mouse can be unit-tested without a browser. + */ + +export const TILE_W = 64; +export const TILE_H = 32; +/** Vertical pixels per unit of building height at zoom 1. */ +export const LEVEL_H = 30; + +export const createCamera = (grid) => ({ + // Centre the map under the viewport: tile (w/2, h/2) sits at screen centre. + panX: 0, + panY: -((grid.w + grid.h) * TILE_H) / 4, + zoom: 1, + minZoom: 0.45, + maxZoom: 2.4, +}); + +/** Tile coordinates → unscaled world pixels (origin at tile 0,0 top corner). */ +export const tileToWorld = (x, y) => ({ + wx: (x - y) * (TILE_W / 2), + wy: (x + y) * (TILE_H / 2), +}); + +/** Tile coordinates → canvas pixels. */ +export const tileToScreen = (x, y, cam, view) => { + const { wx, wy } = tileToWorld(x, y); + return { + sx: view.width / 2 + (wx + cam.panX) * cam.zoom, + sy: view.height / 2 + (wy + cam.panY) * cam.zoom, + }; +}; + +/** Canvas pixels → fractional tile coordinates. Inverse of tileToScreen. */ +export const screenToTile = (sx, sy, cam, view) => { + const wx = (sx - view.width / 2) / cam.zoom - cam.panX; + const wy = (sy - view.height / 2) / cam.zoom - cam.panY; + const hx = wx / (TILE_W / 2); + const hy = wy / (TILE_H / 2); + return { x: (hx + hy) / 2, y: (hy - hx) / 2 }; +}; + +/** + * Nearest tile under a screen point, or null when off-grid. + * WHY round, not floor: tileToScreen puts tile centres on integer coordinates, + * so the unit square around an integer maps exactly onto that tile's diamond. + */ +export const pickTile = (sx, sy, cam, view, grid) => { + const { x, y } = screenToTile(sx, sy, cam, view); + const tx = Math.round(x); + const ty = Math.round(y); + if (tx < 0 || ty < 0 || tx >= grid.w || ty >= grid.h) return null; + return { x: tx, y: ty }; +}; + +/** Painter's-algorithm order: back to front, so near tiles overdraw far ones. */ +export const depthOf = (x, y) => x + y; + +export const clampCamera = (cam, grid) => { + const span = (grid.w + grid.h) * (TILE_H / 2) + 400; + cam.panX = Math.max(-span, Math.min(span, cam.panX)); + cam.panY = Math.max(-span, Math.min(span, cam.panY)); + cam.zoom = Math.max(cam.minZoom, Math.min(cam.maxZoom, cam.zoom)); + return cam; +}; diff --git a/games/gridlock/src/render/overlays.js b/games/gridlock/src/render/overlays.js new file mode 100644 index 0000000..975324e --- /dev/null +++ b/games/gridlock/src/render/overlays.js @@ -0,0 +1,72 @@ +/** + * Data views. The map is the dashboard: each overlay washes the ground with a + * single legible signal instead of hiding it in a side panel. + */ + +import { SERVICE_KINDS } from '../config/balance.js'; +import { getBuilding } from '../config/buildings.js'; + +export const OVERLAYS = Object.freeze([ + { id: 'none', label: 'City', hint: 'The skyline as it stands.' }, + { id: 'appeal', label: 'Desirability', hint: 'Where citizens want to live.' }, + { id: 'utilities', label: 'Utilities', hint: 'What is powered, watered, and wired.' }, + { id: 'services', label: 'Services', hint: 'Clinic, academy, and watch coverage.' }, + { id: 'emissions', label: 'Emissions', hint: 'Who is heating the planet.' }, +]); + +const ramp = (t, negative, positive) => { + const clamped = Math.max(-1, Math.min(1, t)); + return clamped >= 0 + ? `rgba(${positive},${0.08 + clamped * 0.42})` + : `rgba(${negative},${0.08 + -clamped * 0.42})`; +}; + +const GOOD = '94,224,138'; +const BAD = '255,84,112'; +const COOL = '94,169,255'; +const WARM = '255,140,60'; + +/** + * @returns {string|null} an rgba wash for this tile, or null for no tint + */ +export const overlayWash = (mode, stats, tile) => { + if (!stats || mode === 'none') return null; + + switch (mode) { + case 'appeal': + return ramp(stats.fields.appeal[tile.i] / 12, BAD, GOOD); + + case 'utilities': { + if (tile.b === null) return null; + const def = getBuilding(tile.b); + if (!def) return null; + if (stats.live.has(tile.i)) return `rgba(${GOOD},0.3)`; + if (stats.network.connected.has(tile.i)) return `rgba(${WARM},0.36)`; + return `rgba(${BAD},0.4)`; + } + + case 'services': { + let sum = 0; + for (const kind of SERVICE_KINDS) sum += stats.fields.services[kind][tile.i]; + const coverage = sum / SERVICE_KINDS.length; + return coverage <= 0.02 ? `rgba(${BAD},0.2)` : `rgba(${COOL},${0.1 + coverage * 0.4})`; + } + + case 'emissions': { + if (tile.b === null) return null; + const def = getBuilding(tile.b); + if (!def) return null; + const load = def.heat + def.pollution * 0.3; + if (Math.abs(load) < 0.05) return null; + return ramp(-load / 8, WARM, GOOD); + } + + default: + return null; + } +}; + +export const nextOverlay = (current) => { + const i = OVERLAYS.findIndex((o) => o.id === current); + return OVERLAYS[(i + 1) % OVERLAYS.length].id; +}; diff --git a/games/gridlock/src/render/renderer.js b/games/gridlock/src/render/renderer.js new file mode 100644 index 0000000..486ab6e --- /dev/null +++ b/games/gridlock/src/render/renderer.js @@ -0,0 +1,145 @@ +/** + * Canvas draw loop. Painter's algorithm over the tile diagonal, with viewport + * culling so map size costs nothing when zoomed in. + * + * The renderer reads state and never writes it. + */ + +import { getBuilding } from '../config/buildings.js'; +import { LEVEL_H, TILE_H, tileToScreen } from './iso.js'; +import { PALETTE, ambient, alpha, dayPhase, heatCast, nightFactor } from './theme.js'; +import { drawCursor, drawGround, drawHalo } from './terrain.js'; +import { drawOutage, drawStructure } from './structures.js'; +import { drawTraffic } from './effects.js'; +import { overlayWash } from './overlays.js'; + +const CULL_MARGIN = 160; + +/** Bitmask of orthogonal neighbours a road should visually connect to. */ +const LINKS = ['road', 'nexus', 'transit', 'maglev']; +const connectionMask = (grid, x, y) => { + const steps = [[1, 0], [-1, 0], [0, 1], [0, -1]]; + let mask = 0; + steps.forEach(([dx, dy], bit) => { + const nx = x + dx; + const ny = y + dy; + if (nx < 0 || ny < 0 || nx >= grid.w || ny >= grid.h) return; + if (LINKS.includes(grid.tiles[ny * grid.w + nx].b)) mask |= 1 << bit; + }); + return mask; +}; + +export const createRenderer = (canvas, effects) => { + const ctx = canvas.getContext('2d'); + const view = { width: 0, height: 0, dpr: 1 }; + + const resize = () => { + const dpr = Math.min(window.devicePixelRatio || 1, 2); + const rect = canvas.getBoundingClientRect(); + view.width = rect.width; + view.height = rect.height; + view.dpr = dpr; + canvas.width = Math.round(rect.width * dpr); + canvas.height = Math.round(rect.height * dpr); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + }; + + const drawBackdrop = (night) => { + const sky = ctx.createLinearGradient(0, 0, 0, view.height); + sky.addColorStop(0, night > 0.5 ? '#0a0716' : '#161334'); + sky.addColorStop(1, PALETTE.void); + ctx.fillStyle = sky; + ctx.fillRect(0, 0, view.width, view.height); + }; + + const draw = (state, opts) => { + const started = performance.now(); + const { cam, hover, tool, overlay, time } = opts; + const { grid, stats } = state; + const night = nightFactor(dayPhase(time)); + const air = ambient(night); + const glow = air.windowGlow; + + resizeIfNeeded(); + drawBackdrop(night); + + const project = (x, y) => tileToScreen(x, y, cam, view); + const congestion = stats ? stats.congestion : 0; + + for (let d = 0; d <= grid.w + grid.h - 2; d++) { + const xStart = Math.max(0, d - grid.h + 1); + const xEnd = Math.min(grid.w - 1, d); + for (let x = xStart; x <= xEnd; x++) { + const y = d - x; + const tile = grid.tiles[y * grid.w + x]; + const { sx, sy } = project(x, y); + if (sx < -CULL_MARGIN || sx > view.width + CULL_MARGIN) continue; + if (sy < -CULL_MARGIN * 3 || sy > view.height + CULL_MARGIN) continue; + + drawGround(ctx, tile, { sx, sy, zoom: cam.zoom, night, time }, overlayWash(overlay, stats, tile)); + + if (tile.b === null) continue; + const def = getBuilding(tile.b); + if (!def) continue; + const live = tile.on || stats === null; + + const roadMask = def.id === 'road' ? connectionMask(grid, x, y) : 0; + drawStructure(ctx, def, { sx, sy, zoom: cam.zoom, glow, time, seed: tile.i, live, roadMask }); + if (def.id === 'road') { + drawTraffic(ctx, tile, sx, sy, cam.zoom, time, congestion); + continue; + } + if (!live) drawOutage(ctx, sx, sy, cam.zoom, def.h * LEVEL_H * cam.zoom); + } + } + + effects.draw(ctx, project, cam.zoom); + drawPointer(state, opts, project); + + // Time-of-day wash and, above the heat threshold, a red planetary cast. + ctx.fillStyle = alpha(air.tint, air.strength * 0.55); + ctx.fillRect(0, 0, view.width, view.height); + const cast = heatCast(state.heat); + if (cast > 0) { + ctx.fillStyle = `rgba(255,90,40,${cast})`; + ctx.fillRect(0, 0, view.width, view.height); + } + + return performance.now() - started; + }; + + /** Cursor diamond plus dashed halos on the neighbours a placement would use. */ + const drawPointer = (state, { hover, tool, canPlaceHere, cam }, project) => { + if (!hover) return; + const { sx, sy } = project(hover.x, hover.y); + drawCursor(ctx, sx, sy, cam.zoom, canPlaceHere); + if (!tool || tool === 'bulldoze') return; + for (let dy = -1; dy <= 1; dy++) { + for (let dx = -1; dx <= 1; dx++) { + if (dx === 0 && dy === 0) continue; + const nx = hover.x + dx; + const ny = hover.y + dy; + if (nx < 0 || ny < 0 || nx >= state.grid.w || ny >= state.grid.h) continue; + if (state.grid.tiles[ny * state.grid.w + nx].b === null) continue; + const p = project(nx, ny); + drawHalo(ctx, p.sx, p.sy, cam.zoom, 'rgba(201,168,76,0.35)'); + } + } + }; + + let lastW = 0; + let lastH = 0; + const resizeIfNeeded = () => { + const rect = canvas.getBoundingClientRect(); + if (rect.width !== lastW || rect.height !== lastH) { + lastW = rect.width; + lastH = rect.height; + resize(); + } + }; + + return { draw, resize, view, ctx }; +}; + +/** Screen height in pixels of a structure, used for hit-testing tooltips. */ +export const structureHeight = (def, zoom) => (def?.h ?? 0) * LEVEL_H * zoom + TILE_H * zoom; diff --git a/games/gridlock/src/render/structures.js b/games/gridlock/src/render/structures.js new file mode 100644 index 0000000..61bd18f --- /dev/null +++ b/games/gridlock/src/render/structures.js @@ -0,0 +1,207 @@ +/** + * Structure rendering. Every building is drawn procedurally from its catalog + * entry — no sprite sheet, no asset pipeline, and adding a building to the + * catalog makes it appear on the map with no art task attached. + */ + +import { TILE_W, TILE_H, LEVEL_H } from './iso.js'; +import { alpha, mix, shade } from './theme.js'; +import { diamondPath } from './terrain.js'; + +const facePaths = (ctx, sx, sy, halfW, halfH, hPx) => { + ctx.beginPath(); // left + ctx.moveTo(sx - halfW, sy - hPx); + ctx.lineTo(sx, sy + halfH - hPx); + ctx.lineTo(sx, sy + halfH); + ctx.lineTo(sx - halfW, sy); + ctx.closePath(); +}; + +const rightPath = (ctx, sx, sy, halfW, halfH, hPx) => { + ctx.beginPath(); + ctx.moveTo(sx + halfW, sy - hPx); + ctx.lineTo(sx, sy + halfH - hPx); + ctx.lineTo(sx, sy + halfH); + ctx.lineTo(sx + halfW, sy); + ctx.closePath(); +}; + +/** Lit windows climbing both visible faces. The city's night-time signature. */ +const drawWindows = (ctx, sx, sy, halfW, halfH, hPx, accent, glow, seed) => { + if (glow <= 0.02 || hPx < 16) return; + const rows = Math.max(1, Math.floor(hPx / 12)); + const cols = 3; + ctx.save(); + for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) { + // Deterministic per-window flicker keyed off the tile seed. + const on = ((seed * 977 + r * 31 + c * 7) % 10) > 3; + if (!on) continue; + const t = (c + 0.5) / cols; + const yOff = hPx - (r + 0.7) * 12; + ctx.fillStyle = alpha(accent, 0.15 + glow * 0.75); + ctx.fillRect(sx - halfW * (1 - t) - 2, sy - yOff + halfH * (1 - t) * 0.5 - 2, 3, 4); + ctx.fillRect(sx + halfW * (1 - t) - 1, sy - yOff + halfH * (1 - t) * 0.5 - 2, 3, 4); + } + } + ctx.restore(); +}; + +const drawBox = (ctx, sx, sy, zoom, hPx, base, accent, glow, seed) => { + const halfW = (TILE_W / 2) * zoom * 0.82; + const halfH = (TILE_H / 2) * zoom * 0.82; + + ctx.fillStyle = shade(base, -0.32); + facePaths(ctx, sx, sy, halfW, halfH, hPx); + ctx.fill(); + + ctx.fillStyle = shade(base, -0.14); + rightPath(ctx, sx, sy, halfW, halfH, hPx); + ctx.fill(); + + ctx.fillStyle = shade(base, 0.14); + diamondPath(ctx, sx, sy - hPx, zoom * 0.82); + ctx.fill(); + + drawWindows(ctx, sx, sy, halfW, halfH, hPx, accent, glow, seed); + + // Roof line picks up the accent so silhouettes stay readable at low zoom. + ctx.strokeStyle = alpha(accent, 0.25 + glow * 0.5); + ctx.lineWidth = 1; + diamondPath(ctx, sx, sy - hPx, zoom * 0.82); + ctx.stroke(); +}; + +/** Edge-midpoint offsets keyed by the road connection bitmask. */ +const ROAD_DIRS = [ + [0.5, 0.5], // +x + [-0.5, -0.5], // -x + [-0.5, 0.5], // +y + [0.5, -0.5], // -y +]; + +/** + * Roads draw as asphalt with a neon spine running to each connected + * neighbour, so junctions and dead ends are visible at a glance. + * @param {number} mask bit i set when neighbour i (see ROAD_DIRS) is road + */ +const drawRoad = (ctx, sx, sy, zoom, base, accent, glow, mask) => { + const halfW = (TILE_W / 2) * zoom; + const halfH = (TILE_H / 2) * zoom; + + ctx.fillStyle = shade(base, 0.16); + diamondPath(ctx, sx, sy, zoom, 0.5); + ctx.fill(); + + ctx.strokeStyle = alpha(accent, 0.35 + glow * 0.5); + ctx.lineWidth = Math.max(1, 2 * zoom); + ctx.lineCap = 'round'; + let drawn = 0; + for (let dir = 0; dir < 4; dir++) { + if ((mask & (1 << dir)) === 0) continue; + drawn += 1; + const [fx, fy] = ROAD_DIRS[dir]; + ctx.beginPath(); + ctx.moveTo(sx, sy); + ctx.lineTo(sx + fx * halfW * 2, sy + fy * halfH * 2); + ctx.stroke(); + } + if (drawn === 0) { + ctx.beginPath(); + ctx.arc(sx, sy, 2.4 * zoom, 0, Math.PI * 2); + ctx.fillStyle = alpha(accent, 0.5); + ctx.fill(); + } +}; + +/** Solar reads as a tilted panel array rather than an empty outline. */ +const drawPanels = (ctx, sx, sy, zoom, base, accent, glow) => { + ctx.fillStyle = shade(base, 0.1); + diamondPath(ctx, sx, sy, zoom * 0.92); + ctx.fill(); + const halfW = (TILE_W / 2) * zoom * 0.92; + const halfH = (TILE_H / 2) * zoom * 0.92; + ctx.strokeStyle = alpha(accent, 0.5 + glow * 0.4); + ctx.lineWidth = Math.max(1, 1.4 * zoom); + // Rows run along the +x axis, offset along the +y axis so they lie flat. + for (const t of [-0.42, 0, 0.42]) { + ctx.beginPath(); + ctx.moveTo(sx - halfW * 0.5 - t * halfW, sy - halfH * 0.5 + t * halfH); + ctx.lineTo(sx + halfW * 0.5 - t * halfW, sy + halfH * 0.5 + t * halfH); + ctx.stroke(); + } +}; + +const drawWind = (ctx, sx, sy, zoom, hPx, base, accent, time) => { + ctx.strokeStyle = shade(base, 0.25); + ctx.lineWidth = 2.5 * zoom; + ctx.beginPath(); + ctx.moveTo(sx, sy); + ctx.lineTo(sx, sy - hPx); + ctx.stroke(); + const spin = time / 260; + ctx.strokeStyle = accent; + ctx.lineWidth = 1.6 * zoom; + for (let i = 0; i < 3; i++) { + const a = spin + (i * Math.PI * 2) / 3; + ctx.beginPath(); + ctx.moveTo(sx, sy - hPx); + ctx.lineTo(sx + Math.cos(a) * 12 * zoom, sy - hPx + Math.sin(a) * 8 * zoom); + ctx.stroke(); + } +}; + +const drawGreen = (ctx, sx, sy, zoom, base, accent, seed) => { + ctx.fillStyle = shade(base, -0.05); + diamondPath(ctx, sx, sy, zoom * 0.92); + ctx.fill(); + for (let i = 0; i < 3; i++) { + const ox = ((seed * (i + 3) * 37) % 20) - 10; + const oy = ((seed * (i + 5) * 17) % 10) - 5; + ctx.fillStyle = alpha(accent, 0.55); + ctx.beginPath(); + ctx.ellipse(sx + ox * zoom * 0.5, sy + oy * zoom * 0.4, 5 * zoom, 3.4 * zoom, 0, 0, Math.PI * 2); + ctx.fill(); + } +}; + +/** + * @param {CanvasRenderingContext2D} ctx + * @param {object} def catalog entry + * @param {{sx, sy, zoom, glow, time, seed, live}} view + */ +export const drawStructure = (ctx, def, view) => { + const { sx, sy, zoom, time, seed, live } = view; + const glow = live ? view.glow : 0; + const base = live ? def.color : mix(def.color, '#15121f', 0.55); + const accent = live ? def.accent : '#6b6480'; + const hPx = def.h * LEVEL_H * zoom; + + if (def.id === 'road') return drawRoad(ctx, sx, sy, zoom, base, accent, glow, view.roadMask ?? 0); + if (def.id === 'solar') return drawPanels(ctx, sx, sy, zoom, base, accent, glow); + if (def.cat === 'green' && def.h < 0.5) return drawGreen(ctx, sx, sy, zoom, base, accent, seed); + if (def.id === 'wind') return drawWind(ctx, sx, sy, zoom, hPx, base, accent, time); + + drawBox(ctx, sx, sy, zoom, hPx, base, accent, glow, seed); + + // Landmarks get a beacon so they read from across the map. + if (def.cat === 'special' && live) { + const pulse = 0.5 + Math.sin(time / 420) * 0.5; + ctx.fillStyle = alpha(def.accent, 0.35 + pulse * 0.5); + ctx.beginPath(); + ctx.arc(sx, sy - hPx - 6 * zoom, (3 + pulse * 2) * zoom, 0, Math.PI * 2); + ctx.fill(); + } + return undefined; +}; + +/** Small red bolt over structures that are cut off from power or water. */ +export const drawOutage = (ctx, sx, sy, zoom, hPx) => { + if (zoom < 0.6) return; + ctx.save(); + ctx.fillStyle = '#ff5470'; + ctx.font = `${Math.round(13 * zoom)}px "DM Mono", monospace`; + ctx.textAlign = 'center'; + ctx.fillText('!', sx, sy - hPx - 6 * zoom); + ctx.restore(); +}; diff --git a/games/gridlock/src/render/terrain.js b/games/gridlock/src/render/terrain.js new file mode 100644 index 0000000..502438f --- /dev/null +++ b/games/gridlock/src/render/terrain.js @@ -0,0 +1,76 @@ +/** + * Ground rendering: one diamond per tile, plus the overlay wash used by the + * data views (appeal, power, heat). + */ + +import { TILE_W, TILE_H } from './iso.js'; +import { PALETTE, mix, shade } from './theme.js'; +import { TERRAIN } from '../core/grid.js'; + +/** Trace the tile diamond centred on (sx, sy). */ +export const diamondPath = (ctx, sx, sy, zoom, inset = 0) => { + const halfW = (TILE_W / 2) * zoom - inset; + const halfH = (TILE_H / 2) * zoom - inset * 0.5; + ctx.beginPath(); + ctx.moveTo(sx, sy - halfH); + ctx.lineTo(sx + halfW, sy); + ctx.lineTo(sx, sy + halfH); + ctx.lineTo(sx - halfW, sy); + ctx.closePath(); +}; + +const groundColor = (tile, night, time) => { + if (tile.t === TERRAIN.WATER) { + const wave = Math.sin(time / 900 + tile.x * 0.7 + tile.y * 0.5) * 0.5 + 0.5; + return mix(PALETTE.water, PALETTE.waterLit, 0.25 + wave * 0.35 * (1 - night * 0.5)); + } + if (tile.t === TERRAIN.HILL) return shade(PALETTE.hill, -0.05 + tile.variant * 0.12); + return mix(PALETTE.ground, PALETTE.groundAlt, tile.variant); +}; + +/** + * @param {CanvasRenderingContext2D} ctx + * @param {object} tile + * @param {{sx:number, sy:number, zoom:number, night:number, time:number}} ctxInfo + * @param {string|null} overlay rgba wash from an active data view + */ +export const drawGround = (ctx, tile, { sx, sy, zoom, night, time }, overlay) => { + ctx.fillStyle = shade(groundColor(tile, night, time), -night * 0.22); + diamondPath(ctx, sx, sy, zoom); + ctx.fill(); + + if (overlay) { + ctx.fillStyle = overlay; + ctx.fill(); + } + + if (zoom > 0.7) { + ctx.strokeStyle = PALETTE.grid; + ctx.lineWidth = 1; + ctx.stroke(); + } +}; + +/** Cursor ring under the pointer — green when legal, red when not. */ +export const drawCursor = (ctx, sx, sy, zoom, ok) => { + const color = ok ? '#5ee08a' : '#ff5470'; + ctx.save(); + ctx.lineWidth = 2; + ctx.strokeStyle = color; + ctx.fillStyle = `${color}22`; + diamondPath(ctx, sx, sy, zoom, 1); + ctx.fill(); + ctx.stroke(); + ctx.restore(); +}; + +/** Highlight ring used to show synergy neighbours during placement. */ +export const drawHalo = (ctx, sx, sy, zoom, color) => { + ctx.save(); + ctx.lineWidth = 1.5; + ctx.strokeStyle = color; + ctx.setLineDash([4, 4]); + diamondPath(ctx, sx, sy, zoom, 3); + ctx.stroke(); + ctx.restore(); +}; diff --git a/games/gridlock/src/render/theme.js b/games/gridlock/src/render/theme.js new file mode 100644 index 0000000..fb9f2cc --- /dev/null +++ b/games/gridlock/src/render/theme.js @@ -0,0 +1,64 @@ +/** + * Palette and lighting. Every colour the renderer uses resolves here so the + * whole city can be re-lit — day, night, or overlay mode — from one place. + */ + +export const PALETTE = Object.freeze({ + void: '#08060f', + ground: '#1b1830', + groundAlt: '#221d3a', + hill: '#2d2743', + water: '#0e2038', + waterLit: '#1b3f63', + grid: 'rgba(139,92,246,0.16)', + gold: '#c9a84c', + violet: '#8b5cf6', + mint: '#38e1c8', + ember: '#ff7a3d', + rose: '#ff5ec4', + ink: '#f2ecff', +}); + +const hex = (h) => { + const v = parseInt(h.slice(1), 16); + return [(v >> 16) & 255, (v >> 8) & 255, v & 255]; +}; + +const toHex = (r, g, b) => + `#${[r, g, b].map((c) => Math.max(0, Math.min(255, Math.round(c))).toString(16).padStart(2, '0')).join('')}`; + +/** Linear blend between two hex colours. */ +export const mix = (a, b, t) => { + const [ar, ag, ab] = hex(a); + const [br, bg, bb] = hex(b); + return toHex(ar + (br - ar) * t, ag + (bg - ag) * t, ab + (bb - ab) * t); +}; + +/** Positive amount lightens, negative darkens. */ +export const shade = (color, amount) => + amount >= 0 ? mix(color, '#ffffff', amount) : mix(color, '#000000', -amount); + +export const alpha = (color, a) => { + const [r, g, b] = hex(color); + return `rgba(${r},${g},${b},${a})`; +}; + +/** Real-time day cycle, 96 seconds per full day. 0 = midnight, 0.5 = noon. */ +export const DAY_MS = 96000; +export const dayPhase = (nowMs) => (nowMs % DAY_MS) / DAY_MS; + +/** 0 in full daylight, 1 in deep night. Smooth, so dusk reads as dusk. */ +export const nightFactor = (phase) => { + const daylight = Math.sin(phase * Math.PI * 2 - Math.PI / 2) * 0.5 + 0.5; + return 1 - Math.max(0, Math.min(1, (daylight - 0.15) / 0.6)); +}; + +/** Ambient wash applied over the whole map for the current time of day. */ +export const ambient = (night) => ({ + tint: mix('#3d4f8a', '#0a0718', night), + strength: 0.16 + night * 0.34, + windowGlow: Math.max(0, night - 0.15), +}); + +/** Heat-stressed cities take on a visible red cast. */ +export const heatCast = (heat) => Math.max(0, Math.min(0.4, (heat - 45) / 140)); diff --git a/games/gridlock/src/telemetry/telemetry.js b/games/gridlock/src/telemetry/telemetry.js new file mode 100644 index 0000000..f2acd4c --- /dev/null +++ b/games/gridlock/src/telemetry/telemetry.js @@ -0,0 +1,54 @@ +/** + * Runtime self-report. Per the engine contract every non-trivial module must + * answer three questions at runtime: am I alive, how hard am I working, and + * what am I leaking. + * + * Zero cost when nothing reads it — the probes are two counters and an EMA. + */ + +const EMA = (prev, next, alpha = 0.12) => (prev === 0 ? next : prev * (1 - alpha) + next * alpha); + +export const createTelemetry = () => { + const t = { + frames: 0, ticks: 0, drops: 0, + frameMs: 0, drawMs: 0, tickMs: 0, + lastFrameAt: 0, startedAt: Date.now(), + }; + + return { + /** Called once per animation frame with the frame delta. */ + frame(deltaMs) { + t.frames += 1; + t.frameMs = EMA(t.frameMs, deltaMs); + if (deltaMs > 50) t.drops += 1; // Below ~20fps counts as a dropped frame. + t.lastFrameAt = Date.now(); + }, + draw(ms) { t.drawMs = EMA(t.drawMs, ms); }, + tick(ms) { t.ticks += 1; t.tickMs = EMA(t.tickMs, ms); }, + + /** HEALTH — is the loop alive and recent? */ + health() { + const silentMs = Date.now() - t.lastFrameAt; + return { alive: silentMs < 2000, silentMs, upMs: Date.now() - t.startedAt, frames: t.frames }; + }, + /** PRESSURE — how hard is it working? */ + pressure() { + return { + fps: t.frameMs > 0 ? 1000 / t.frameMs : 0, + drawMs: t.drawMs, + tickMs: t.tickMs, + ticks: t.ticks, + }; + }, + /** EFFICIENCY — what is it leaking? */ + efficiency() { + const heap = typeof performance !== 'undefined' && performance.memory + ? performance.memory.usedJSHeapSize / 1048576 + : null; + return { heapMb: heap, dropRate: t.frames ? t.drops / t.frames : 0 }; + }, + snapshot() { + return { health: this.health(), pressure: this.pressure(), efficiency: this.efficiency() }; + }, + }; +}; diff --git a/games/gridlock/src/ui/build.js b/games/gridlock/src/ui/build.js new file mode 100644 index 0000000..a8c1546 --- /dev/null +++ b/games/gridlock/src/ui/build.js @@ -0,0 +1,87 @@ +/** + * Build palette. Renders the full catalog once, then only toggles state — + * locked, unaffordable, selected — so switching tools never rebuilds the DOM. + */ + +import { CATALOG, CATEGORY_LABEL } from '../config/buildings.js'; +import { clear, credits, el, setClass } from './dom.js'; + +export const BULLDOZE = 'bulldoze'; + +const statLine = (def) => { + const parts = []; + if (def.residents) parts.push(`${def.residents} res`); + if (def.jobs) parts.push(`${def.jobs} jobs`); + if (def.revenue) parts.push(`₡${def.revenue}/mo`); + if (def.power > 0) parts.push(`+${def.power}MW`); + if (def.power < 0) parts.push(`${def.power}MW`); + if (def.water > 0) parts.push(`+${def.water}L`); + if (def.water < 0) parts.push(`${def.water}L`); + if (def.tech) parts.push(`${def.tech} res/mo`); + return parts.join(' · '); +}; + +export const createBuildBar = (root, { onSelect, onHoverDef }) => { + const buttons = new Map(); + let tool = null; + + const bulldozeBtn = el('button', 'build-btn bulldoze'); + bulldozeBtn.appendChild(el('span', 'bb-name', 'Bulldoze')); + bulldozeBtn.appendChild(el('span', 'bb-cost', '35% back')); + bulldozeBtn.addEventListener('click', () => select(tool === BULLDOZE ? null : BULLDOZE)); + bulldozeBtn.title = 'Demolish a structure and recover part of its cost. [X]'; + + const render = () => { + clear(root); + const group = el('div', 'build-group'); + group.appendChild(el('h3', 'build-cat', 'Tools')); + group.appendChild(bulldozeBtn); + root.appendChild(group); + + let current = null; + let container = null; + for (const def of CATALOG) { + if (def.hidden) continue; + if (def.cat !== current) { + current = def.cat; + container = el('div', 'build-group'); + container.appendChild(el('h3', 'build-cat', CATEGORY_LABEL[def.cat] ?? def.cat)); + root.appendChild(container); + } + const btn = el('button', 'build-btn'); + btn.appendChild(el('span', 'bb-name', def.name)); + btn.appendChild(el('span', 'bb-cost', credits(def.cost))); + btn.appendChild(el('span', 'bb-stats', statLine(def))); + btn.style.setProperty('--accent', def.accent); + btn.addEventListener('click', () => select(tool === def.id ? null : def.id)); + btn.addEventListener('pointerenter', () => onHoverDef?.(def)); + btn.addEventListener('pointerleave', () => onHoverDef?.(null)); + container.appendChild(btn); + buttons.set(def.id, btn); + } + }; + + const select = (id) => { + tool = id; + for (const [key, btn] of buttons) setClass(btn, 'selected', key === id); + setClass(bulldozeBtn, 'selected', id === BULLDOZE); + onSelect(id); + }; + + /** Reflect era locks and affordability against the current state. */ + const update = (state) => { + for (const def of CATALOG) { + const btn = buttons.get(def.id); + if (!btn) continue; + const locked = def.era > state.era; + const poor = def.cost > state.credits; + setClass(btn, 'locked', locked); + setClass(btn, 'poor', !locked && poor); + btn.disabled = locked; + btn.title = locked ? `Unlocks in Era ${def.era}` : `${def.desc}`; + } + }; + + render(); + return { update, select, getTool: () => tool }; +}; diff --git a/games/gridlock/src/ui/copy.js b/games/gridlock/src/ui/copy.js new file mode 100644 index 0000000..db876f7 --- /dev/null +++ b/games/gridlock/src/ui/copy.js @@ -0,0 +1,45 @@ +/** + * Long-form player-facing text. Kept out of the logic modules so copy edits + * never touch code. + */ + +export const INTRO = { + eyebrow: 'City Directive 001', + title: 'GRIDLOCK', + body: [ + 'The coast moved inland, the old capital drowned, and what is left of the region has handed you a bay, a road stub, and thirty thousand credits.', + 'Zone it. Power it. Cool it. Every watt, every litre and every commuter runs through decisions you make on this map — and the planet is keeping score in the Heat Index.', + 'Build the Zero Point interchange to win. Go bankrupt, and the city goes into receivership without you.', + ].join('\n\n'), +}; + +export const HELP = { + eyebrow: 'Field Manual', + title: 'How to run a city', + body: [ + 'BUILDING — pick a structure on the left, then click or drag across the map. Drag is how you lay roads. Right-drag or drag with no tool selected to pan; the wheel zooms toward the cursor.', + 'THE THREE CHAINS — a structure works only if it touches a road wired back to the Nexus, draws power, and draws water. Power failures cascade: unpowered pumps stop making water. Watch the Utilities view.', + 'ADJACENCY — placement is the puzzle. Commerce beside housing earns more, housing beside industry loses approval, data centres on the waterfront run cooler. The inspector shows exactly what a tile would score before you commit.', + 'HEAT — industry, generators, and server farms raise the Heat Index. Past 55 it raises cooling demand and costs approval; past 88 the city is in crisis. Parks, sky gardens, and carbon scrubbers pull it back.', + 'RESEARCH — academies and labs bank research, and research is the only thing that advances the era. New eras unlock everything worth building.', + 'KEYS — [Space] pause · [1][2][3] speed · [X] bulldoze · [O] cycle map view · [S] save · [H] this screen · [Esc] drop tool.', + ].join('\n\n'), +}; + +export const VICTORY = { + eyebrow: 'Zero Point online', + title: 'The city moves', + body: [ + 'The interchange lit at 04:12 and the arterials cleared for the first time since the founding. Nothing about this city is stuck any more.', + 'You can keep building — there is no cap on what this becomes.', + ].join('\n\n'), +}; + +export const BANKRUPT = { + eyebrow: 'Receivership', + title: 'The treasury is empty', + body: [ + 'Creditors have taken the ledger. You keep your desk, for now, but every month below zero digs the hole deeper.', + 'Raise the tax rate, bulldoze what you cannot afford to run, and get business revenue moving again.', + ].join('\n\n'), +}; diff --git a/games/gridlock/src/ui/dom.js b/games/gridlock/src/ui/dom.js new file mode 100644 index 0000000..cbd24f8 --- /dev/null +++ b/games/gridlock/src/ui/dom.js @@ -0,0 +1,68 @@ +/** + * DOM and formatting helpers. Every UI module builds nodes through these, so + * text formatting is consistent and nothing anywhere concatenates HTML from + * user-controlled strings. + */ + +export const $ = (selector, root = document) => root.querySelector(selector); + +export const el = (tag, className, text) => { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = String(text); + return node; +}; + +export const clear = (node) => { + while (node.firstChild) node.removeChild(node.firstChild); + return node; +}; + +/** 1234 → "1.2k", 1234567 → "1.2M". Keeps the HUD from reflowing. */ +export const compact = (value) => { + const n = Math.round(value); + const abs = Math.abs(n); + if (abs >= 1e6) return `${(n / 1e6).toFixed(1)}M`; + if (abs >= 10000) return `${(n / 1000).toFixed(1)}k`; + return n.toLocaleString('en-US'); +}; + +export const credits = (value) => `₡${compact(value)}`; + +export const signed = (value) => `${value >= 0 ? '+' : '−'}₡${compact(Math.abs(value))}`; + +export const percent = (value, digits = 0) => `${(value * 100).toFixed(digits)}%`; + +/** Set text only when it changed — avoids per-frame layout thrash. */ +export const setText = (node, text) => { + const next = String(text); + if (node && node.textContent !== next) node.textContent = next; +}; + +export const setClass = (node, className, on) => { + if (node) node.classList.toggle(className, Boolean(on)); +}; + +/** Inline SVG sparkline from a numeric series. */ +export const sparkline = (series, width = 132, height = 30, color = '#c9a84c') => { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('viewBox', `0 0 ${width} ${height}`); + svg.setAttribute('class', 'spark'); + if (series.length < 2) return svg; + + const min = Math.min(...series); + const max = Math.max(...series); + const span = max - min || 1; + const step = width / (series.length - 1); + const points = series + .map((v, i) => `${(i * step).toFixed(1)},${(height - ((v - min) / span) * (height - 4) - 2).toFixed(1)}`) + .join(' '); + + const line = document.createElementNS('http://www.w3.org/2000/svg', 'polyline'); + line.setAttribute('points', points); + line.setAttribute('fill', 'none'); + line.setAttribute('stroke', color); + line.setAttribute('stroke-width', '1.5'); + svg.appendChild(line); + return svg; +}; diff --git a/games/gridlock/src/ui/hud.js b/games/gridlock/src/ui/hud.js new file mode 100644 index 0000000..d66c9e9 --- /dev/null +++ b/games/gridlock/src/ui/hud.js @@ -0,0 +1,118 @@ +/** + * Top status bar. Binds once to the static markup in index.html and then only + * writes values — no per-frame DOM construction. + */ + +import { SPEED_LABEL, CLIMATE } from '../config/balance.js'; +import { eraById, techToNextEra } from '../config/eras.js'; +import { cityGrade } from '../core/economy.js'; +import { formatDate } from '../core/state.js'; +import { $, clear, compact, credits, el, percent, setClass, setText, signed } from './dom.js'; + +/** + * The failures worth interrupting the player for. Ordered by how quickly they + * kill a city — a district cut off from the road graph produces nothing at all, + * which is the mistake players most often stare straight past. + */ +const alertsFor = (state, stats) => { + const out = []; + if (stats.stranded > 0) out.push({ text: `${stats.stranded} CUT OFF`, tone: 'bad', title: 'Structures with no road back to the Nexus. They produce nothing.' }); + if (stats.power.ratio < 1) out.push({ text: 'BROWNOUT', tone: 'bad', title: 'Power demand exceeds supply. Low-priority blocks are dark.' }); + if (stats.water.ratio < 1) out.push({ text: 'WATER SHORT', tone: 'bad', title: 'Water demand exceeds supply.' }); + if (stats.gridlock > 0.3) out.push({ text: 'GRIDLOCK', tone: 'warn', title: 'Congestion is eating business revenue. Build roads or transit.' }); + if (stats.net < 0) out.push({ text: 'DEFICIT', tone: 'warn', title: 'The city spends more than it earns.' }); + if (state.heat >= CLIMATE.threshold) out.push({ text: 'OVERHEATING', tone: 'warn', title: 'The Heat Index is past the threshold. Cooling costs are rising.' }); + return out; +}; + +const meter = (node, ratio, tone) => { + if (!node) return; + const fill = node.firstElementChild; + if (fill) fill.style.width = `${Math.max(0, Math.min(1, ratio)) * 100}%`; + node.dataset.tone = tone; +}; + +const utilityTone = (ratio) => (ratio >= 1 ? 'good' : ratio >= 0.85 ? 'warn' : 'bad'); + +export const createHud = (root) => { + const nodes = { + date: $('#hud-date', root), + credits: $('#hud-credits', root), + net: $('#hud-net', root), + pop: $('#hud-pop', root), + popNote: $('#hud-pop-note', root), + approval: $('#hud-approval', root), + approvalBar: $('#hud-approval-bar', root), + power: $('#hud-power', root), + powerBar: $('#hud-power-bar', root), + water: $('#hud-water', root), + waterBar: $('#hud-water-bar', root), + heat: $('#hud-heat', root), + heatBar: $('#hud-heat-bar', root), + era: $('#hud-era', root), + eraNote: $('#hud-era-note', root), + grade: $('#hud-grade', root), + speed: $('#hud-speed', root), + alerts: $('#hud-alerts', root), + }; + + let lastAlertKey = null; + const renderAlerts = (state, stats) => { + const alerts = alertsFor(state, stats); + const key = alerts.map((a) => a.text).join('|'); + if (key === lastAlertKey) return; // Rebuild only when the set changes. + lastAlertKey = key; + const host = clear(nodes.alerts); + for (const alert of alerts) { + const chip = el('span', 'alert-chip', alert.text); + chip.dataset.tone = alert.tone; + chip.title = alert.title; + host.appendChild(chip); + } + }; + + const update = (state) => { + const stats = state.stats; + setText(nodes.date, formatDate(state.month)); + setText(nodes.credits, credits(state.credits)); + setClass(nodes.credits, 'negative', state.credits < 0); + setText(nodes.pop, compact(state.population)); + setText(nodes.speed, SPEED_LABEL[state.speed]); + + const grade = cityGrade(state.approval, state.heat); + setText(nodes.grade, grade.label); + if (nodes.grade) nodes.grade.dataset.tone = grade.tone; + + const era = eraById(state.era); + setText(nodes.era, `ERA ${state.era} · ${era.name.toUpperCase()}`); + const next = techToNextEra(state.tech); + setText(nodes.eraNote, next + ? `${Math.round(next.remaining)} research to ${next.era.name}` + : 'Final era reached'); + + setText(nodes.approval, `${Math.round(state.approval)}%`); + meter(nodes.approvalBar, state.approval / 100, + state.approval > 65 ? 'good' : state.approval > 40 ? 'warn' : 'bad'); + + setText(nodes.heat, Math.round(state.heat)); + meter(nodes.heatBar, state.heat / CLIMATE.max, + state.heat < CLIMATE.threshold ? 'good' : state.heat < CLIMATE.critical ? 'warn' : 'bad'); + + if (!stats) return; + setText(nodes.net, `${signed(stats.net)}/mo`); + setClass(nodes.net, 'negative', stats.net < 0); + setText(nodes.popNote, `${compact(stats.jobsFilled)} working · ${percent(stats.unemployment)} idle`); + + setText(nodes.power, `${compact(stats.power.supply)}/${compact(stats.power.demand)}`); + meter(nodes.powerBar, stats.power.demand ? stats.power.supply / stats.power.demand : 1, + utilityTone(stats.power.ratio)); + + setText(nodes.water, `${compact(stats.water.supply)}/${compact(stats.water.demand)}`); + meter(nodes.waterBar, stats.water.demand ? stats.water.supply / stats.water.demand : 1, + utilityTone(stats.water.ratio)); + + renderAlerts(state, stats); + }; + + return { update }; +}; diff --git a/games/gridlock/src/ui/inspector.js b/games/gridlock/src/ui/inspector.js new file mode 100644 index 0000000..c9ac676 --- /dev/null +++ b/games/gridlock/src/ui/inspector.js @@ -0,0 +1,109 @@ +/** + * Context panel. Answers exactly one question at a time: "what happens if I + * click here?" — or, with no tool held, "what is this thing doing?" + */ + +import { getBuilding } from '../config/buildings.js'; +import { canPlace } from '../core/commands.js'; +import { idx, TERRAIN } from '../core/grid.js'; +import { previewSynergy, tileSynergy } from '../core/synergy.js'; +import { clear, credits, el } from './dom.js'; +import { BULLDOZE } from './build.js'; + +const TERRAIN_NAME = { [TERRAIN.LAND]: 'Open ground', [TERRAIN.WATER]: 'Open water', [TERRAIN.HILL]: 'Ridge' }; + +const synergyList = (syn) => { + const wrap = el('div', 'insp-syn'); + if (syn.hits.length === 0) { + wrap.appendChild(el('p', 'insp-dim', 'No adjacency bonuses here.')); + return wrap; + } + for (const hit of syn.hits) { + const row = el('div', 'syn-row'); + const positive = !hit.label.includes('Downwind'); + row.dataset.tone = positive ? 'good' : 'bad'; + row.appendChild(el('span', 'syn-label', hit.label)); + row.appendChild(el('span', 'syn-count', `×${hit.count}${hit.count >= hit.cap ? ' max' : ''}`)); + row.appendChild(el('span', 'syn-hint', hit.hint)); + wrap.appendChild(row); + } + return wrap; +}; + +const multipliers = (syn) => { + const out = []; + if (syn.revenueMul !== 1) out.push(`revenue ×${syn.revenueMul.toFixed(2)}`); + if (syn.capacityMul !== 1) out.push(`housing ×${syn.capacityMul.toFixed(2)}`); + if (syn.heatMul !== 1) out.push(`heat ×${syn.heatMul.toFixed(2)}`); + if (syn.techMul !== 1) out.push(`research ×${syn.techMul.toFixed(2)}`); + if (syn.appealAdd) out.push(`appeal ${syn.appealAdd > 0 ? '+' : ''}${syn.appealAdd.toFixed(1)}`); + return out.join(' · '); +}; + +const statusOf = (state, tile) => { + const stats = state.stats; + if (!stats) return { label: 'Starting up', tone: 'warn' }; + if (stats.live.has(tile.i)) return { label: 'Operating', tone: 'good' }; + if (!stats.network.connected.has(tile.i)) return { label: 'No road connection', tone: 'bad' }; + if (!stats.powered.has(tile.i)) return { label: 'No power', tone: 'bad' }; + return { label: 'No water', tone: 'bad' }; +}; + +export const createInspector = (root) => { + const show = (state, hover, tool, hoverDef) => { + clear(root); + + // Palette hover wins: the player is asking about a building, not a tile. + const def = hoverDef ?? (tool && tool !== BULLDOZE ? getBuilding(tool) : null); + + if (def && hover) { + const check = canPlace(state, hover.x, hover.y, def.id); + root.appendChild(el('h2', 'insp-title', def.name)); + root.appendChild(el('p', 'insp-desc', def.desc)); + const verdict = el('p', check.ok ? 'insp-ok' : 'insp-bad', + check.ok ? `Build here — ${credits(def.cost)}` : check.reason); + root.appendChild(verdict); + root.appendChild(synergyList(previewSynergy(state.grid, hover.x, hover.y, def.id))); + return; + } + if (def) { + root.appendChild(el('h2', 'insp-title', def.name)); + root.appendChild(el('p', 'insp-desc', def.desc)); + root.appendChild(el('p', 'insp-dim', `${credits(def.cost)} · upkeep ₡${def.upkeep}/mo`)); + return; + } + if (!hover) { + root.appendChild(el('h2', 'insp-title', 'The Nexus City')); + root.appendChild(el('p', 'insp-desc', + 'Pick a structure on the left, then drag on the map to build. Right-drag pans, wheel zooms.')); + return; + } + + const tile = state.grid.tiles[idx(state.grid, hover.x, hover.y)]; + if (tile.b === null) { + root.appendChild(el('h2', 'insp-title', TERRAIN_NAME[tile.t] ?? 'Ground')); + root.appendChild(el('p', 'insp-dim', `Tile ${hover.x},${hover.y}`)); + if (state.stats) { + const appeal = state.stats.fields.appeal[tile.i]; + root.appendChild(el('p', 'insp-desc', `Desirability ${appeal >= 0 ? '+' : ''}${appeal.toFixed(1)}`)); + } + return; + } + + const built = getBuilding(tile.b); + const status = statusOf(state, tile); + root.appendChild(el('h2', 'insp-title', built?.name ?? 'Structure')); + const badge = el('p', 'insp-status', status.label); + badge.dataset.tone = status.tone; + root.appendChild(badge); + root.appendChild(el('p', 'insp-desc', built?.desc ?? '')); + + const syn = tileSynergy(state.grid, tile); + const mults = multipliers(syn); + if (mults) root.appendChild(el('p', 'insp-mult', mults)); + root.appendChild(synergyList(syn)); + root.appendChild(el('p', 'insp-dim', `Upkeep ₡${built?.upkeep ?? 0}/mo · standing ${tile.age} months`)); + }; + + return { show }; +}; diff --git a/games/gridlock/src/ui/modal.js b/games/gridlock/src/ui/modal.js new file mode 100644 index 0000000..da9f357 --- /dev/null +++ b/games/gridlock/src/ui/modal.js @@ -0,0 +1,75 @@ +/** + * Modal layer: crisis cards, the opening brief, and end states. + * One node, one visibility flag, no stacking — a modal is always the only + * thing the player can act on. + */ + +import { choiceCost } from '../core/events.js'; +import { clear, credits, el } from './dom.js'; + +export const createModal = (root) => { + const card = el('div', 'modal-card'); + root.appendChild(card); + + const hide = () => { + root.classList.remove('open'); + clear(card); + }; + + const open = () => root.classList.add('open'); + + /** + * @param {object} crisis deck entry + * @param {(index:number)=>void} onChoose + */ + const showCrisis = (crisis, state, stats, onChoose) => { + clear(card); + card.dataset.kind = 'crisis'; + card.appendChild(el('span', 'modal-eyebrow', 'Crisis')); + card.appendChild(el('h2', 'modal-title', crisis.title)); + card.appendChild(el('p', 'modal-body', crisis.flavor)); + + const options = el('div', 'modal-choices'); + crisis.choices.forEach((choice, index) => { + const cost = choiceCost(choice, state, stats); + const affordable = cost <= state.credits; + const btn = el('button', 'choice'); + btn.appendChild(el('span', 'choice-label', choice.label)); + btn.appendChild(el('span', 'choice-detail', choice.detail)); + btn.appendChild(el('span', 'choice-cost', cost > 0 ? `Costs ${credits(cost)}` : 'No direct cost')); + btn.disabled = !affordable; + if (!affordable) btn.title = 'The treasury cannot cover this.'; + btn.addEventListener('click', () => onChoose(index)); + options.appendChild(btn); + }); + card.appendChild(options); + open(); + }; + + /** + * @param {{eyebrow?:string, title:string, body:string, kind?:string, + * actions:{label:string, onClick:()=>void, primary?:boolean}[]}} spec + */ + const showMessage = (spec) => { + clear(card); + card.dataset.kind = spec.kind ?? 'info'; + if (spec.eyebrow) card.appendChild(el('span', 'modal-eyebrow', spec.eyebrow)); + card.appendChild(el('h2', 'modal-title', spec.title)); + for (const paragraph of spec.body.split('\n\n')) { + card.appendChild(el('p', 'modal-body', paragraph)); + } + const actions = el('div', 'modal-actions'); + for (const action of spec.actions) { + const btn = el('button', action.primary ? 'btn primary' : 'btn'); + btn.textContent = action.label; + btn.addEventListener('click', action.onClick); + actions.appendChild(btn); + } + card.appendChild(actions); + open(); + }; + + const isOpen = () => root.classList.contains('open'); + + return { showCrisis, showMessage, hide, isOpen }; +}; diff --git a/games/gridlock/src/ui/panels.js b/games/gridlock/src/ui/panels.js new file mode 100644 index 0000000..5ed4d2b --- /dev/null +++ b/games/gridlock/src/ui/panels.js @@ -0,0 +1,108 @@ +/** + * Right-hand column: directives, active crisis modifiers, trend lines, and the + * city log. Rebuilt on month boundaries only — never per frame. + */ + +import { directiveById } from '../config/directives.js'; +import { directiveProgress } from '../core/directives.js'; +import { formatDate } from '../core/state.js'; +import { $, clear, compact, el, sparkline } from './dom.js'; + +const progressBar = (current, goal) => { + const bar = el('div', 'dir-bar'); + const fill = el('i'); + fill.style.width = `${Math.max(0, Math.min(1, current / goal)) * 100}%`; + bar.appendChild(fill); + return bar; +}; + +export const createPanels = (root) => { + const nodes = { + directives: $('#panel-directives', root), + modifiers: $('#panel-modifiers', root), + trends: $('#panel-trends', root), + log: $('#panel-log', root), + }; + + const renderDirectives = (state, stats) => { + const host = clear(nodes.directives); + for (const id of state.directives.active) { + const directive = directiveById(id); + if (!directive) continue; + const card = el('div', 'dir-card'); + card.appendChild(el('h4', 'dir-title', directive.title)); + card.appendChild(el('p', 'dir-detail', directive.detail)); + const progress = directiveProgress(directive, state, stats); + if (progress) { + card.appendChild(progressBar(progress[0], progress[1])); + card.appendChild(el('span', 'dir-count', + directive.streak + ? `${progress[0]}/${progress[1]} months` + : `${compact(progress[0])} / ${compact(progress[1])}`)); + } + if (directive.reward.credits) { + card.appendChild(el('span', 'dir-reward', `Reward ₡${directive.reward.credits.toLocaleString()}`)); + } + host.appendChild(card); + } + if (state.directives.active.length === 0) { + host.appendChild(el('p', 'insp-dim', 'Every directive complete. The city is yours.')); + } + }; + + const renderModifiers = (state) => { + const host = clear(nodes.modifiers); + if (state.modifiers.length === 0) { + host.appendChild(el('p', 'insp-dim', 'No active pressures.')); + return; + } + for (const mod of state.modifiers) { + const row = el('div', 'mod-row'); + row.appendChild(el('span', 'mod-label', mod.label)); + row.appendChild(el('span', 'mod-months', `${mod.months}mo`)); + host.appendChild(row); + } + }; + + const renderTrends = (state) => { + const host = clear(nodes.trends); + const series = [ + { label: 'Population', data: state.history.population, color: '#8b5cf6' }, + { label: 'Treasury', data: state.history.credits, color: '#c9a84c' }, + { label: 'Approval', data: state.history.approval, color: '#38e1c8' }, + { label: 'Heat Index', data: state.history.heat, color: '#ff7a3d' }, + ]; + for (const s of series) { + const row = el('div', 'trend-row'); + const head = el('div', 'trend-head'); + head.appendChild(el('span', 'trend-label', s.label)); + head.appendChild(el('span', 'trend-value', s.data.length ? compact(s.data[s.data.length - 1]) : '—')); + row.appendChild(head); + row.appendChild(sparkline(s.data, 150, 26, s.color)); + host.appendChild(row); + } + }; + + const renderLog = (state) => { + const host = clear(nodes.log); + for (const entry of state.log.slice(0, 14)) { + const row = el('div', 'log-row'); + row.dataset.tone = entry.kind; + row.appendChild(el('span', 'log-date', formatDate(entry.month))); + row.appendChild(el('span', 'log-text', entry.text)); + host.appendChild(row); + } + if (state.log.length === 0) host.appendChild(el('p', 'insp-dim', 'Nothing to report.')); + }; + + const update = (state) => { + const stats = state.stats; + if (!stats) return; + renderDirectives(state, stats); + renderModifiers(state); + renderTrends(state); + renderLog(state); + }; + + return { update }; +}; diff --git a/games/gridlock/src/ui/toast.js b/games/gridlock/src/ui/toast.js new file mode 100644 index 0000000..e9c34fc --- /dev/null +++ b/games/gridlock/src/ui/toast.js @@ -0,0 +1,34 @@ +/** + * Transient feedback for rejected actions and small wins. Bounded queue: + * spamming a blocked tile can never fill the screen with toasts. + */ + +import { el } from './dom.js'; + +const MAX_VISIBLE = 3; +const LIFETIME_MS = 2200; + +export const createToaster = (root) => { + let lastMessage = ''; + let lastAt = 0; + + const push = (message, tone = 'info') => { + const now = Date.now(); + // Collapse repeats: dragging across illegal tiles emits the same refusal. + if (message === lastMessage && now - lastAt < LIFETIME_MS) return; + lastMessage = message; + lastAt = now; + + const node = el('div', 'toast', message); + node.dataset.tone = tone; + root.appendChild(node); + while (root.childElementCount > MAX_VISIBLE) root.removeChild(root.firstChild); + + setTimeout(() => { + node.classList.add('leaving'); + setTimeout(() => node.remove(), 260); + }, LIFETIME_MS); + }; + + return { push }; +}; diff --git a/games/gridlock/styles.css b/games/gridlock/styles.css new file mode 100644 index 0000000..0232f7e --- /dev/null +++ b/games/gridlock/styles.css @@ -0,0 +1,263 @@ +/* GRIDLOCK — dark-luxury obsidian shell. Cinzel for display, DM Mono for data. + Layout is a fixed frame over a full-bleed canvas: the map is the page. */ + +:root { + --void: #08060f; + --panel: rgba(14, 11, 24, 0.86); + --panel-solid: #0e0b18; + --edge: rgba(139, 92, 246, 0.2); + --edge-strong: rgba(139, 92, 246, 0.42); + --gold: #c9a84c; + --gold-bright: #f0d489; + --violet: #8b5cf6; + --mint: #38e1c8; + --ember: #ff7a3d; + --rose: #ff5ec4; + --ink: #efe9ff; + --dim: #8b83a6; + --good: #5ee08a; + --warn: #ffb347; + --bad: #ff5470; + --panel-w: 264px; +} + +* { margin: 0; padding: 0; box-sizing: border-box; } + +html, body { + width: 100%; height: 100%; overflow: hidden; + background: var(--void); color: var(--ink); + font-family: 'DM Mono', ui-monospace, monospace; + font-size: 13px; -webkit-font-smoothing: antialiased; +} + +#app { position: relative; width: 100%; height: 100%; } +#stage { position: absolute; inset: 0; width: 100%; height: 100%; display: block; cursor: crosshair; } + +/* ── HUD ──────────────────────────────────────────────────────────────── */ +.hud { + position: absolute; top: 0; left: 0; right: 0; z-index: 5; + display: flex; align-items: stretch; gap: 2px; + background: linear-gradient(180deg, rgba(8, 6, 15, 0.96), rgba(8, 6, 15, 0.72)); + border-bottom: 1px solid var(--edge); + backdrop-filter: blur(10px); + padding: 8px 12px; +} + +.hud-brand { display: flex; flex-direction: column; justify-content: center; padding-right: 18px; min-width: 168px; } +.brand-mark { + font-family: 'Cinzel', serif; font-weight: 900; font-size: 20px; letter-spacing: 0.16em; + background: linear-gradient(120deg, var(--gold) 10%, var(--gold-bright) 45%, var(--violet) 100%); + -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; +} +.brand-date { color: var(--dim); font-size: 11px; letter-spacing: 0.22em; } +.brand-grade { font-size: 10px; letter-spacing: 0.24em; margin-top: 2px; } + +.hud-stat { + display: flex; flex-direction: column; justify-content: center; gap: 2px; + padding: 2px 16px; border-left: 1px solid rgba(139, 92, 246, 0.12); min-width: 118px; +} +.hud-stat.wide { min-width: 200px; } +.hs-label { font-size: 9px; letter-spacing: 0.22em; text-transform: uppercase; color: var(--dim); } +.hs-value { font-size: 16px; font-weight: 500; color: var(--ink); letter-spacing: 0.02em; } +.hs-value.negative { color: var(--bad); } +.hs-note { font-size: 10px; color: var(--dim); letter-spacing: 0.06em; } +.hs-note.negative { color: var(--bad); } + +.meter { height: 4px; width: 100%; background: rgba(255, 255, 255, 0.08); border-radius: 2px; overflow: hidden; } +.meter i { display: block; height: 100%; background: var(--good); transition: width 0.35s ease; } +.meter[data-tone='warn'] i { background: var(--warn); } +.meter[data-tone='bad'] i { background: var(--bad); } +[data-tone='good'] { color: var(--good); } +[data-tone='warn'] { color: var(--warn); } +[data-tone='bad'] { color: var(--bad); } +[data-tone='ok'] { color: var(--mint); } + +/* ── Side panels ──────────────────────────────────────────────────────── */ +.panel { + position: absolute; top: 74px; bottom: 52px; z-index: 4; width: var(--panel-w); + background: var(--panel); backdrop-filter: blur(12px); + border: 1px solid var(--edge); display: flex; flex-direction: column; + overflow: hidden; +} +.panel.left { left: 0; border-left: none; } +.panel.right { right: 0; border-right: none; overflow-y: auto; } + +.panel-head { + font-family: 'Cinzel', serif; font-size: 12px; letter-spacing: 0.24em; text-transform: uppercase; + color: var(--gold); padding: 12px 14px 8px; border-bottom: 1px solid var(--edge); + display: flex; flex-direction: column; gap: 3px; +} +.panel-hint { font-family: 'DM Mono', monospace; font-size: 9px; letter-spacing: 0.08em; color: var(--dim); text-transform: none; } + +.build-list { overflow-y: auto; padding: 6px 8px 18px; flex: 1; } +.build-group { margin-bottom: 10px; } +.build-cat { + font-size: 9px; letter-spacing: 0.24em; text-transform: uppercase; color: var(--dim); + padding: 8px 6px 5px; border-bottom: 1px dashed rgba(139, 92, 246, 0.14); margin-bottom: 5px; +} + +.build-btn { + --accent: var(--violet); + display: grid; grid-template-columns: 1fr auto; gap: 1px 8px; width: 100%; + text-align: left; cursor: pointer; color: var(--ink); + background: rgba(255, 255, 255, 0.028); border: 1px solid transparent; + border-left: 2px solid var(--accent); border-radius: 3px; + padding: 6px 8px; margin-bottom: 4px; font-family: inherit; font-size: 12px; + transition: background 0.12s ease, border-color 0.12s ease, transform 0.08s ease; +} +.build-btn:hover:not(:disabled) { background: rgba(139, 92, 246, 0.14); transform: translateX(2px); } +.build-btn.selected { background: rgba(201, 168, 76, 0.16); border-color: var(--gold); } +.build-btn.poor { opacity: 0.55; } +.build-btn.locked { opacity: 0.3; cursor: not-allowed; filter: grayscale(1); } +.bb-name { font-weight: 500; letter-spacing: 0.03em; } +.bb-cost { color: var(--gold); font-size: 11px; } +.bb-stats { grid-column: 1 / -1; font-size: 9.5px; color: var(--dim); letter-spacing: 0.02em; } +.build-btn.bulldoze { --accent: var(--bad); } + +/* ── Inspector + right column ─────────────────────────────────────────── */ +.inspector { padding: 12px 14px; border-bottom: 1px solid var(--edge); min-height: 150px; } +.insp-title { font-family: 'Cinzel', serif; font-size: 15px; letter-spacing: 0.08em; color: var(--gold-bright); margin-bottom: 4px; } +.insp-desc { font-size: 11px; line-height: 1.55; color: #cfc6e6; margin-bottom: 6px; } +.insp-dim { font-size: 10px; color: var(--dim); line-height: 1.5; } +.insp-status { font-size: 10px; letter-spacing: 0.18em; text-transform: uppercase; margin-bottom: 6px; } +.insp-ok { font-size: 11px; color: var(--good); margin-bottom: 6px; } +.insp-bad { font-size: 11px; color: var(--bad); margin-bottom: 6px; } +.insp-mult { font-size: 10.5px; color: var(--mint); margin-bottom: 6px; letter-spacing: 0.04em; } + +.insp-syn { display: flex; flex-direction: column; gap: 4px; } +.syn-row { + display: grid; grid-template-columns: 1fr auto; gap: 0 8px; + padding: 5px 7px; border-radius: 3px; background: rgba(94, 224, 138, 0.08); + border-left: 2px solid var(--good); font-size: 10.5px; +} +.syn-row[data-tone='bad'] { background: rgba(255, 84, 112, 0.09); border-left-color: var(--bad); } +.syn-label { font-weight: 500; } +.syn-count { color: var(--dim); } +.syn-hint { grid-column: 1 / -1; color: var(--dim); font-size: 9.5px; line-height: 1.4; } + +.panel-section { padding: 10px 14px; border-bottom: 1px solid rgba(139, 92, 246, 0.1); } +.panel-section h3 { + font-family: 'Cinzel', serif; font-size: 10px; letter-spacing: 0.26em; text-transform: uppercase; + color: var(--gold); margin-bottom: 8px; +} + +.dir-card { padding: 7px 0 9px; border-bottom: 1px dashed rgba(139, 92, 246, 0.14); } +.dir-card:last-child { border-bottom: none; } +.dir-title { font-size: 12px; color: var(--ink); letter-spacing: 0.04em; margin-bottom: 2px; } +.dir-detail { font-size: 10px; color: var(--dim); line-height: 1.45; margin-bottom: 5px; } +.dir-bar { height: 3px; background: rgba(255, 255, 255, 0.08); border-radius: 2px; overflow: hidden; } +.dir-bar i { display: block; height: 100%; background: linear-gradient(90deg, var(--violet), var(--gold)); } +.dir-count { font-size: 9.5px; color: var(--dim); } +.dir-reward { display: block; font-size: 9.5px; color: var(--gold); margin-top: 2px; } + +.mod-row { display: flex; justify-content: space-between; font-size: 10.5px; padding: 3px 0; color: var(--warn); } +.mod-months { color: var(--dim); } + +.trend-row { margin-bottom: 8px; } +.trend-head { display: flex; justify-content: space-between; font-size: 10px; color: var(--dim); } +.trend-value { color: var(--ink); } +.spark { width: 100%; height: 26px; display: block; } + +.log-row { display: grid; grid-template-columns: 62px 1fr; gap: 6px; font-size: 10px; padding: 3px 0; line-height: 1.4; } +.log-date { color: var(--dim); letter-spacing: 0.06em; } +.log-row[data-tone='good'] .log-text { color: var(--good); } +.log-row[data-tone='bad'] .log-text { color: var(--bad); } +.log-row[data-tone='event'] .log-text { color: var(--gold); } +.log-text { color: #cfc6e6; } + +/* ── Controls ─────────────────────────────────────────────────────────── */ +.controls { + position: absolute; left: 0; right: 0; bottom: 0; z-index: 5; height: 52px; + display: flex; align-items: center; gap: 18px; padding: 0 14px; + background: linear-gradient(0deg, rgba(8, 6, 15, 0.97), rgba(8, 6, 15, 0.7)); + border-top: 1px solid var(--edge); backdrop-filter: blur(10px); +} +.ctrl-group { display: flex; align-items: center; gap: 5px; } +.ctrl-group.right-align { margin-left: auto; } +.ctrl-label { font-size: 9px; letter-spacing: 0.2em; text-transform: uppercase; color: var(--dim); margin-right: 4px; } +.ctrl-label b { color: var(--gold); letter-spacing: 0.06em; } + +.btn { + font-family: inherit; font-size: 11px; letter-spacing: 0.1em; color: var(--ink); + background: rgba(255, 255, 255, 0.05); border: 1px solid var(--edge); + border-radius: 3px; padding: 6px 12px; cursor: pointer; transition: all 0.12s ease; +} +.btn:hover:not(:disabled) { background: rgba(139, 92, 246, 0.2); border-color: var(--edge-strong); } +.btn:disabled { opacity: 0.35; cursor: not-allowed; } +.btn.tiny { padding: 5px 9px; font-size: 10px; } +.btn.active { background: rgba(201, 168, 76, 0.2); border-color: var(--gold); color: var(--gold-bright); } +.btn.primary { background: linear-gradient(120deg, var(--gold), #a8862f); color: #14100a; border-color: transparent; font-weight: 500; } +.btn.primary:hover { filter: brightness(1.12); } + +/* ── Modal ────────────────────────────────────────────────────────────── */ +.modal { + position: absolute; inset: 0; z-index: 20; display: none; + align-items: center; justify-content: center; + background: rgba(4, 3, 9, 0.78); backdrop-filter: blur(6px); +} +.modal.open { display: flex; } +.modal-card { + width: min(620px, 92vw); max-height: 86vh; overflow-y: auto; + background: linear-gradient(160deg, #17122a 0%, var(--panel-solid) 60%); + border: 1px solid var(--edge-strong); border-radius: 6px; + padding: 30px 34px; box-shadow: 0 30px 90px rgba(0, 0, 0, 0.7), 0 0 60px rgba(139, 92, 246, 0.12); + animation: rise 0.24s ease; +} +@keyframes rise { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: none; } } +.modal-card[data-kind='crisis'] { border-color: rgba(255, 84, 112, 0.5); } +.modal-eyebrow { font-size: 10px; letter-spacing: 0.34em; text-transform: uppercase; color: var(--ember); } +.modal-title { font-family: 'Cinzel', serif; font-size: 30px; letter-spacing: 0.06em; margin: 6px 0 12px; color: var(--gold-bright); } +.modal-body { font-size: 12.5px; line-height: 1.75; color: #cfc6e6; margin-bottom: 12px; } +.modal-choices { display: flex; flex-direction: column; gap: 10px; margin-top: 18px; } +.choice { + display: flex; flex-direction: column; gap: 3px; text-align: left; cursor: pointer; + background: rgba(255, 255, 255, 0.04); border: 1px solid var(--edge); + border-left: 3px solid var(--gold); border-radius: 4px; padding: 12px 14px; + color: var(--ink); font-family: inherit; transition: all 0.12s ease; +} +.choice:hover:not(:disabled) { background: rgba(139, 92, 246, 0.18); transform: translateX(3px); } +.choice:disabled { opacity: 0.4; cursor: not-allowed; } +.choice-label { font-size: 13px; font-weight: 500; letter-spacing: 0.04em; } +.choice-detail { font-size: 11px; color: var(--dim); line-height: 1.5; } +.choice-cost { font-size: 10px; color: var(--gold); letter-spacing: 0.08em; } +.modal-actions { display: flex; gap: 10px; margin-top: 22px; flex-wrap: wrap; } + +/* ── Toasts + telemetry ───────────────────────────────────────────────── */ +.toasts { position: absolute; bottom: 66px; left: 50%; transform: translateX(-50%); z-index: 15; display: flex; flex-direction: column; gap: 6px; align-items: center; pointer-events: none; } +.toast { + background: rgba(14, 11, 24, 0.94); border: 1px solid var(--edge); border-left: 3px solid var(--bad); + border-radius: 3px; padding: 7px 14px; font-size: 11px; letter-spacing: 0.04em; + animation: rise 0.16s ease; white-space: nowrap; +} +.toast[data-tone='good'] { border-left-color: var(--good); } +.toast[data-tone='info'] { border-left-color: var(--violet); } +.toast.leaving { opacity: 0; transition: opacity 0.25s ease; } + +.telemetry { + position: absolute; right: calc(var(--panel-w) + 12px); top: 84px; z-index: 12; + background: rgba(8, 6, 15, 0.9); border: 1px solid var(--edge); border-radius: 3px; + padding: 8px 10px; font-size: 10px; line-height: 1.6; color: var(--mint); pointer-events: none; +} +.telemetry.hidden { display: none; } + +/* ── Narrow screens ───────────────────────────────────────────────────── */ +@media (max-width: 1180px) { + :root { --panel-w: 218px; } + .hud-stat { min-width: 96px; padding: 2px 10px; } + .hs-value { font-size: 14px; } +} +@media (max-width: 900px) { + .panel.right { display: none; } + .hud { overflow-x: auto; } +} + +/* ── HUD alerts ───────────────────────────────────────────────────────── */ +.hud-alerts { display: flex; align-items: center; gap: 6px; padding-left: 16px; flex-wrap: wrap; max-width: 260px; } +.alert-chip { + font-size: 9px; letter-spacing: 0.16em; padding: 3px 7px; border-radius: 2px; + border: 1px solid currentColor; white-space: nowrap; + animation: pulse 1.8s ease-in-out infinite; +} +.alert-chip[data-tone='bad'] { color: var(--bad); background: rgba(255, 84, 112, 0.1); } +.alert-chip[data-tone='warn'] { color: var(--warn); background: rgba(255, 179, 71, 0.1); animation: none; } +@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } } diff --git a/games/gridlock/tests/balance.test.mjs b/games/gridlock/tests/balance.test.mjs new file mode 100644 index 0000000..b8fdda2 --- /dev/null +++ b/games/gridlock/tests/balance.test.mjs @@ -0,0 +1,158 @@ +/** + * Balance regression. Not unit tests — these build a fixed district and assert + * the shape of the curve it produces over a decade. If a tuning change makes a + * well-built city stall, starve, or go broke, this is what catches it. + * + * The fixtures are hand-placed rather than played by a scripted agent: the + * point is to pin the economy, not to benchmark an AI. + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { place } from '../src/core/commands.js'; +import { tickMonth } from '../src/core/simulate.js'; +import { TERRAIN } from '../src/core/grid.js'; +import { newCity, centre, flatten, tile } from './helpers.mjs'; + +/** Build the given [dx, dy, id] offsets, asserting every one lands. */ +const build = (state, entries) => { + const { x, y } = centre(state); + let next = state; + for (const [dx, dy, id] of entries) { + const result = place(next, x + dx, y + dy, id); + assert.equal(result.ok, true, `fixture failed at ${dx},${dy} (${id}): ${result.reason}`); + next = result.state; + } + return next; +}; + +const run = (state, months) => { + let next = state; + for (let i = 0; i < months; i++) { + next = tickMonth(next); + // Fixtures are about the economy, so crises are held off rather than + // resolved — the crisis engine has its own tests. + next = { ...next, crisis: { ...next.crisis, active: null, cooldownUntil: next.month + 24 } }; + } + return next; +}; + +/** + * A balanced era-1 district: twelve habitat blocks, six market rows, services, + * four solar arrays, and two shoreline pumps, all on a road grid. + */ +const district = () => { + const state = flatten(newCity(60000)); + const { x, y } = centre(state); + // Carve a small bay to the south so the pumps have a shoreline. + for (let d = -2; d <= 2; d++) tile(state, x + d, y + 7).t = TERRAIN.WATER; + + // Two arterials plus two cross streets. Deduped, and tiles the founding + // stub already paved are skipped so the fixture never fights itself. + const wanted = new Map(); + for (let d = -5; d <= 5; d++) { + wanted.set(`${d},0`, [d, 0]); + wanted.set(`0,${d}`, [0, d]); + } + for (let d = -4; d <= 4; d++) { + wanted.set(`${d},3`, [d, 3]); + wanted.set(`${d},-3`, [d, -3]); + } + const roads = [...wanted.values()] + .filter(([dx, dy]) => tile(state, x + dx, y + dy).b === null) + .map(([dx, dy]) => [dx, dy, 'road']); + + return build(state, [ + ...roads, + // Housing along both cross streets. + [-4, 2, 'hab'], [-3, 2, 'hab'], [-2, 2, 'hab'], [-1, 2, 'hab'], + [1, 2, 'hab'], [2, 2, 'hab'], [3, 2, 'hab'], [4, 2, 'hab'], + [-4, -2, 'hab'], [-3, -2, 'hab'], [3, -2, 'hab'], [4, -2, 'hab'], + // Commerce facing the housing, which is where Foot Traffic pays. + [-2, -2, 'market'], [-1, -2, 'market'], [1, -2, 'market'], [2, -2, 'market'], + [-4, 4, 'market'], [-3, 4, 'market'], + // Services and greenery. + [1, 4, 'clinic'], [2, 4, 'academy'], [3, 4, 'security'], [4, 4, 'park'], + // Utilities, off the road grid. + [-7, 0, 'solar'], [-7, 1, 'solar'], [-8, 0, 'solar'], [-8, 1, 'solar'], + [-1, 6, 'pump'], [1, 6, 'pump'], + ]); +}; + +test('the fixture district starts fully serviced', () => { + const state = run(district(), 1); + assert.equal(state.stats.power.ratio, 1, 'power must be in surplus'); + assert.equal(state.stats.water.ratio, 1, 'water must be in surplus'); + assert.equal(state.stats.stranded, 0, 'every structure must reach the Nexus'); +}); + +test('a balanced district fills up over a decade', () => { + const state = run(district(), 120); + const capacity = state.stats.capacity; + assert.ok(capacity > 300, `expected real housing capacity, got ${Math.round(capacity)}`); + // Migration targets a share of capacity scaled by approval — a healthy city + // should settle in the upper half of what it built. + assert.ok( + state.population > capacity * 0.6, + `population ${Math.round(state.population)} against capacity ${Math.round(capacity)}`, + ); + assert.ok(state.approval > 55, `approval settled at ${Math.round(state.approval)}`); +}); + +test('a balanced district pays for itself', () => { + const start = district(); + const state = run(start, 120); + assert.equal(state.bankrupt, false); + assert.ok(state.stats.net > 0, `net was ${Math.round(state.stats.net)}/mo at year 10`); + assert.ok(state.credits > start.credits, 'a working city should accumulate reserves'); +}); + +test('academies bank enough research to reach the next era', () => { + const state = run(district(), 120); + assert.ok(state.tech > 100, `only ${Math.round(state.tech)} research in ten years`); +}); + +test('a clean district does not overheat', () => { + const state = run(district(), 120); + assert.ok(state.heat < 30, `heat drifted to ${Math.round(state.heat)} with no dirty industry`); +}); + +test('swapping in dirty industry drives the heat index up', () => { + let state = district(); + // Fabricators face the southern street; generators sit off the grid entirely. + state = build(state, [[-1, 4, 'fab'], [-2, 4, 'fab'], [6, 1, 'diesel'], [6, 2, 'diesel']]); + const dirty = run(state, 120); + const clean = run(district(), 120); + assert.ok(dirty.heat > clean.heat + 15, `dirty ${Math.round(dirty.heat)} vs clean ${Math.round(clean.heat)}`); +}); + +test('cutting the road to a district takes it offline', () => { + const state = run(district(), 24); + const { x, y } = centre(state); + const severed = { ...state, grid: { ...state.grid, tiles: state.grid.tiles.map((t) => ({ ...t })) } }; + // Remove the entire southern cross street. + for (let d = -4; d <= 4; d++) { + const target = severed.grid.tiles[(y + 3) * severed.grid.w + (x + d)]; + if (target.b === 'road') target.b = null; + } + const after = run(severed, 1); + assert.ok(after.stats.stranded > 0, 'severed structures must report as cut off'); + assert.ok(after.stats.capacity < state.stats.capacity, 'stranded housing must stop counting'); +}); + +test('doing nothing for 20 years is survivable but goes nowhere', () => { + let state = newCity(30000); + for (let i = 0; i < 240; i++) state = tickMonth(state); + assert.ok(state.population < 25, 'an unbuilt city must not grow on its own'); + assert.ok(state.credits > 0, 'the starting grant must outlast an idle player'); +}); + +test('the simulation stays fast enough for 3× speed', () => { + const state = run(district(), 24); + const started = performance.now(); + for (let i = 0; i < 50; i++) tickMonth(state); + const perTick = (performance.now() - started) / 50; + // 3× speed ticks every 380ms; anything near that budget would stutter. + assert.ok(perTick < 20, `tick cost ${perTick.toFixed(2)}ms on a built-out city`); +}); diff --git a/games/gridlock/tests/commands.test.mjs b/games/gridlock/tests/commands.test.mjs new file mode 100644 index 0000000..daaf7ea --- /dev/null +++ b/games/gridlock/tests/commands.test.mjs @@ -0,0 +1,90 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { canPlace, demolish, place, setSpeed, setTax, REFUND_RATE } from '../src/core/commands.js'; +import { getBuilding } from '../src/config/buildings.js'; +import { TERRAIN } from '../src/core/grid.js'; +import { TAX } from '../src/config/balance.js'; +import { newCity, centre, tile, flatten } from './helpers.mjs'; + +test('placing deducts the cost and marks the tile', () => { + const state = newCity(); + const { x, y } = centre(state); + const result = place(state, x + 1, y + 1, 'hab'); + assert.equal(result.ok, true); + assert.equal(tile(result.state, x + 1, y + 1).b, 'hab'); + assert.equal(result.state.credits, state.credits - getBuilding('hab').cost); +}); + +test('commands never mutate the state they were given', () => { + const state = newCity(); + const { x, y } = centre(state); + const before = state.credits; + place(state, x + 1, y + 1, 'hab'); + assert.equal(state.credits, before); + assert.equal(tile(state, x + 1, y + 1).b, null); +}); + +test('occupied tiles are refused', () => { + const state = newCity(); + const { x, y } = centre(state); + const first = place(state, x + 1, y + 1, 'hab').state; + assert.equal(canPlace(first, x + 1, y + 1, 'hab').ok, false); +}); + +test('water and ridges block the wrong structures', () => { + const state = newCity(); + const { x, y } = centre(state); + tile(state, x + 3, y).t = TERRAIN.WATER; + tile(state, x + 4, y).t = TERRAIN.HILL; + assert.equal(canPlace(state, x + 3, y, 'hab').reason, 'Cannot build on water.'); + assert.equal(canPlace(state, x + 4, y, 'hab').reason, 'Ridge is too steep.'); + assert.equal(canPlace(state, x + 4, y, 'road').ok, true, 'roads may cross a ridge'); +}); + +test('shoreline structures require adjacent water', () => { + const state = flatten(newCity()); + const { x, y } = centre(state); + assert.equal(canPlace(state, x + 2, y + 2, 'pump').ok, false); + tile(state, x + 2, y + 3).t = TERRAIN.WATER; + assert.equal(canPlace(state, x + 2, y + 2, 'pump').ok, true); +}); + +test('geothermal must be sunk into a ridge', () => { + const state = flatten(newCity()); + const { x, y } = centre(state); + state.era = 2; + assert.equal(canPlace(state, x + 2, y + 2, 'geo').ok, false); + tile(state, x + 2, y + 2).t = TERRAIN.HILL; + assert.equal(canPlace(state, x + 2, y + 2, 'geo').ok, true); +}); + +test('era locks and affordability are enforced', () => { + const state = newCity(100); + const { x, y } = centre(state); + assert.equal(canPlace(state, x + 1, y + 1, 'spire').reason, 'Locked until Era 3.'); + assert.equal(canPlace(state, x + 1, y + 1, 'hab').reason, 'Not enough credits.'); +}); + +test('demolishing refunds a share and cannot remove the Nexus', () => { + const state = newCity(); + const { x, y } = centre(state); + const built = place(state, x + 1, y + 1, 'fab').state; + const razed = demolish(built, x + 1, y + 1); + assert.equal(razed.ok, true); + assert.equal(tile(razed.state, x + 1, y + 1).b, null); + assert.equal( + razed.state.credits, + built.credits + Math.round(getBuilding('fab').cost * REFUND_RATE), + ); + assert.equal(demolish(built, x, y).reason, 'The Nexus cannot be removed.'); + assert.equal(demolish(built, x + 6, y + 6).reason, 'Nothing here.'); +}); + +test('tax and speed settings are clamped', () => { + const state = newCity(); + assert.equal(setTax(state, 5).taxRate, TAX.max); + assert.equal(setTax(state, -1).taxRate, TAX.min); + assert.equal(setSpeed(state, 9).speed, 3); + assert.equal(setSpeed(state, -4).speed, 0); +}); diff --git a/games/gridlock/tests/conditions.test.mjs b/games/gridlock/tests/conditions.test.mjs new file mode 100644 index 0000000..1737727 --- /dev/null +++ b/games/gridlock/tests/conditions.test.mjs @@ -0,0 +1,60 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { CRISIS_DECK } from '../src/config/events.js'; +import { computeStats } from '../src/core/stats.js'; +import { drawCrisis } from '../src/core/events.js'; +import { EVENTS } from '../src/config/balance.js'; +import { newCity } from './helpers.mjs'; + +const fire = (id, state, stats) => CRISIS_DECK.find((c) => c.id === id).when(state, stats); + +test('a comfortably over-supplied grid does not raise a surge', () => { + const state = newCity(); + const stats = { ...computeStats(state), counts: { power: 2 } }; + stats.power = { supply: 400, demand: 100, ratio: 1 }; + assert.equal(fire('grid-surge', state, stats), false); +}); + +test('a grid running at the margin does raise a surge', () => { + const state = newCity(); + const stats = { ...computeStats(state), counts: { power: 2 } }; + stats.power = { supply: 104, demand: 100, ratio: 1 }; + assert.equal(fire('grid-surge', state, stats), true); +}); + +test('an idle grid with no draw never raises a surge', () => { + const state = newCity(); + const stats = { ...computeStats(state), counts: { power: 1 } }; + stats.power = { supply: 60, demand: 0, ratio: 1 }; + assert.equal(fire('grid-surge', state, stats), false); +}); + +test('no condition depends on the clamped utility ratio being above 1', () => { + // power.ratio and water.ratio are min(1, supply/demand): a condition of the + // form `ratio < k` for k > 1 is always true and would fire every month. + const source = CRISIS_DECK.map((c) => c.when.toString()).join('\n'); + assert.equal(/ratio\s*<\s*(1\.\d+|[2-9])/.test(source), false); +}); + +test('crises never fire during the grace period', () => { + const state = newCity(); + state.month = EVENTS.graceMonths - 1; + const stats = computeStats(state); + for (let i = 0; i < 200; i++) assert.equal(drawCrisis(state, stats), null); +}); + +test('crises respect the cooldown after one resolves', () => { + const state = newCity(); + state.month = 40; + state.crisis = { active: null, cooldownUntil: 50, seen: [] }; + const stats = computeStats(state); + for (let i = 0; i < 200; i++) assert.equal(drawCrisis(state, stats), null); +}); + +test('only one crisis is open at a time', () => { + const state = newCity(); + state.month = 40; + state.crisis = { active: 'heatwave', cooldownUntil: 0, seen: [] }; + assert.equal(drawCrisis(state, computeStats(state)), null); +}); diff --git a/games/gridlock/tests/directives.test.mjs b/games/gridlock/tests/directives.test.mjs new file mode 100644 index 0000000..7f759a1 --- /dev/null +++ b/games/gridlock/tests/directives.test.mjs @@ -0,0 +1,88 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { DIRECTIVES, MAX_ACTIVE_DIRECTIVES, directiveById } from '../src/config/directives.js'; +import { directiveProgress, evaluateDirectives, refillDirectives } from '../src/core/directives.js'; +import { computeStats } from '../src/core/stats.js'; +import { newCity } from './helpers.mjs'; + +const withStats = (mutate = () => {}) => { + const state = newCity(); + mutate(state); + return { state, stats: computeStats(state) }; +}; + +test('the queue fills to the active limit and no further', () => { + const { state } = withStats(); + refillDirectives(state); + assert.equal(state.directives.active.length, MAX_ACTIVE_DIRECTIVES); + refillDirectives(state); + assert.equal(state.directives.active.length, MAX_ACTIVE_DIRECTIVES); +}); + +test('completing a directive pays out and pulls in the next one', () => { + const { state, stats } = withStats((s) => { s.population = 400; }); + const credits = state.credits; + evaluateDirectives(state, stats); + assert.ok(state.directives.done.includes('first-roots')); + assert.equal(state.credits, credits + directiveById('first-roots').reward.credits); + assert.equal(state.directives.active.length, MAX_ACTIVE_DIRECTIVES); + assert.equal(state.directives.active.includes('first-roots'), false); +}); + +test('streak directives require consecutive months and reset on a break', () => { + const { state, stats } = withStats((s) => { s.population = 400; }); + state.directives.active = ['keep-the-lights-on']; + const held = { ...stats, power: { ratio: 1 }, water: { ratio: 1 } }; + + for (let i = 0; i < 3; i++) evaluateDirectives(state, held); + assert.equal(state.directives.progress['keep-the-lights-on'], 3); + + evaluateDirectives(state, { ...held, power: { ratio: 0.4 } }); + assert.equal(state.directives.progress['keep-the-lights-on'], 0, 'a blackout resets the streak'); + + for (let i = 0; i < 6; i++) evaluateDirectives(state, held); + assert.ok(state.directives.done.includes('keep-the-lights-on')); +}); + +test('progress is reported as current/goal and never exceeds the goal', () => { + const { state, stats } = withStats((s) => { s.population = 9999; }); + const [current, goal] = directiveProgress(directiveById('first-roots'), state, stats); + assert.equal(goal, 250); + assert.equal(current, 250, 'progress is clamped for the bar'); +}); + +test('the victory directive flags a win once Zero Point is running', () => { + const state = newCity(); + const cx = Math.floor(state.grid.w / 2); + const cy = Math.floor(state.grid.h / 2); + const at = (x, y, id) => { state.grid.tiles[y * state.grid.w + x].b = id; }; + at(cx + 1, cy + 1, 'zeropoint'); // beside the starting road stub + for (let i = 0; i < 3; i++) at(cx + 5 + i, cy + 5, 'solar'); // enough generation + + state.directives.active = ['zero-point']; + evaluateDirectives(state, computeStats(state)); + assert.equal(state.won, true); +}); + +test('an unpowered Zero Point does not win the game', () => { + const state = newCity(); + const cx = Math.floor(state.grid.w / 2); + const cy = Math.floor(state.grid.h / 2); + state.grid.tiles[(cy + 1) * state.grid.w + (cx + 1)].b = 'zeropoint'; + + state.directives.active = ['zero-point']; + evaluateDirectives(state, computeStats(state)); + assert.equal(state.won, false, 'the Nexus alone cannot carry a 140MW draw'); +}); + +test('every directive is measurable and uniquely named', () => { + const ids = new Set(); + for (const directive of DIRECTIVES) { + assert.ok(!ids.has(directive.id), `duplicate directive: ${directive.id}`); + ids.add(directive.id); + assert.ok(directive.title && directive.detail); + assert.ok(directive.test || directive.holds, `${directive.id} has no completion condition`); + if (directive.streak) assert.equal(typeof directive.holds, 'function'); + } +}); diff --git a/games/gridlock/tests/events.test.mjs b/games/gridlock/tests/events.test.mjs new file mode 100644 index 0000000..febb3b9 --- /dev/null +++ b/games/gridlock/tests/events.test.mjs @@ -0,0 +1,104 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { getCrisis, nextRoll, resolveCrisis } from '../src/core/events.js'; +import { computeStats } from '../src/core/stats.js'; +import { CRISIS_DECK } from '../src/config/events.js'; +import { newCity, centre, forceTile } from './helpers.mjs'; + +const withCrisis = (id, credits = 50000) => { + const state = newCity(credits); + state.crisis = { ...state.crisis, active: id }; + return state; +}; + +test('every deck entry is well formed', () => { + const ids = new Set(); + for (const card of CRISIS_DECK) { + assert.ok(card.id && !ids.has(card.id), `duplicate or missing id: ${card.id}`); + ids.add(card.id); + assert.equal(typeof card.when, 'function'); + assert.ok(card.choices.length >= 2, `${card.id} needs a real trade-off`); + for (const choice of card.choices) { + assert.ok(choice.label && choice.detail, `${card.id} choice needs copy`); + assert.ok(Array.isArray(choice.effects)); + } + } +}); + +test('resolving applies credits, approval, and a timed modifier', () => { + const state = withCrisis('grid-surge'); + const stats = computeStats(state); + const result = resolveCrisis(state, stats, 0); // shed load + assert.equal(result.ok, true); + assert.ok(result.state.approval < state.approval); + assert.equal(result.state.modifiers.length, 1); + assert.equal(result.state.modifiers[0].id, 'surge-shed'); + assert.equal(result.state.crisis.active, null); + assert.ok(result.state.crisis.seen.includes('grid-surge')); +}); + +test('resolving never mutates the state it was given', () => { + const state = withCrisis('grid-surge'); + const snapshot = JSON.stringify({ ...state, stats: null }); + resolveCrisis(state, computeStats(state), 0); + assert.equal(JSON.stringify({ ...state, stats: null }), snapshot); +}); + +test('a choice the treasury cannot cover is refused', () => { + const state = withCrisis('breach', 100); + const result = resolveCrisis(state, computeStats(state), 0); // 6,500 ransom + assert.equal(result.ok, false); + assert.equal(result.reason, 'Not enough credits.'); + assert.equal(result.state.crisis.active, 'breach', 'the crisis stays open'); +}); + +test('costs are deducted exactly once', () => { + const state = withCrisis('breach'); + const result = resolveCrisis(state, computeStats(state), 0); + assert.equal(result.state.credits, state.credits - 6500); +}); + +test('unknown crises and options fail closed', () => { + const state = withCrisis('grid-surge'); + assert.equal(resolveCrisis(state, computeStats(state), 99).ok, false); + assert.equal(resolveCrisis(newCity(), computeStats(newCity()), 0).ok, false); +}); + +test('risk outcomes are seeded, not random', () => { + const a = withCrisis('tremor'); + const b = withCrisis('tremor'); + const first = resolveCrisis(a, computeStats(a), 1).state; + const second = resolveCrisis(b, computeStats(b), 1).state; + assert.equal(first.rollCount, second.rollCount); + assert.equal(first.approval, second.approval); +}); + +test('destruction never takes the Nexus', () => { + const state = withCrisis('tremor'); + const { x, y } = centre(state); + forceTile(state, x + 5, y + 5, 'fab'); + // Force the destructive branch by exhausting the risk roll many times. + for (let i = 0; i < 40; i++) { + const attempt = resolveCrisis({ ...state, rollCount: i }, computeStats(state), 1); + const nexus = attempt.state.grid.tiles.find((t) => t.b === 'nexus'); + assert.ok(nexus, 'the Nexus must always survive'); + } +}); + +test('the roll stream advances deterministically', () => { + const a = newCity(); + const b = newCity(); + assert.equal(nextRoll(a), nextRoll(b)); + assert.equal(a.rollCount, 1); + assert.notEqual(nextRoll(a), nextRoll({ ...b, rollCount: 5 })); +}); + +test('deck conditions never throw on a fresh city', () => { + const state = newCity(); + const stats = computeStats(state); + for (const card of CRISIS_DECK) { + assert.doesNotThrow(() => card.when(state, stats), `${card.id} condition threw`); + } + assert.ok(getCrisis('heatwave')); +}); diff --git a/games/gridlock/tests/grid.test.mjs b/games/gridlock/tests/grid.test.mjs new file mode 100644 index 0000000..81bdddf --- /dev/null +++ b/games/gridlock/tests/grid.test.mjs @@ -0,0 +1,69 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { createGrid, TERRAIN, neighbors4, neighbors8, touchesTerrain, idx } from '../src/core/grid.js'; +import { makeRng, weightedPick } from '../src/core/rng.js'; + +test('terrain generation is deterministic for a seed', () => { + const a = createGrid(1234); + const b = createGrid(1234); + assert.deepEqual(a.tiles.map((t) => t.t), b.tiles.map((t) => t.t)); +}); + +test('different seeds produce different maps', () => { + const a = createGrid(1).tiles.map((t) => t.t).join(''); + const b = createGrid(2).tiles.map((t) => t.t).join(''); + assert.notEqual(a, b); +}); + +test('the founding centre is always buildable land', () => { + for (const seed of [1, 77, 4242, 999999]) { + const grid = createGrid(seed); + const cx = Math.floor(grid.w / 2); + const cy = Math.floor(grid.h / 2); + for (let dy = -2; dy <= 2; dy++) { + for (let dx = -2; dx <= 2; dx++) { + assert.equal(grid.tiles[idx(grid, cx + dx, cy + dy)].t, TERRAIN.LAND, `seed ${seed}`); + } + } + } +}); + +test('every map has land, and water is generated for coastal builds', () => { + const grid = createGrid(4242); + const kinds = new Set(grid.tiles.map((t) => t.t)); + assert.ok(kinds.has(TERRAIN.LAND)); + assert.ok(kinds.has(TERRAIN.WATER), 'expected a bay on this seed'); +}); + +test('neighbour queries clip at the map edge', () => { + const grid = createGrid(7); + assert.equal(neighbors4(grid, 0, 0).length, 2); + assert.equal(neighbors8(grid, 0, 0).length, 3); + assert.equal(neighbors4(grid, 5, 5).length, 4); + assert.equal(neighbors8(grid, 5, 5).length, 8); +}); + +test('touchesTerrain only looks orthogonally', () => { + const grid = createGrid(7); + grid.tiles.forEach((t) => { t.t = TERRAIN.LAND; }); + grid.tiles[idx(grid, 4, 3)].t = TERRAIN.WATER; + assert.equal(touchesTerrain(grid, 4, 4, TERRAIN.WATER), true); + assert.equal(touchesTerrain(grid, 5, 4, TERRAIN.WATER), false, 'diagonal must not count'); +}); + +test('rng is reproducible and bounded', () => { + const a = makeRng(99); + const b = makeRng(99); + for (let i = 0; i < 50; i++) { + const value = a(); + assert.equal(value, b()); + assert.ok(value >= 0 && value < 1); + } +}); + +test('weightedPick respects weights and handles a zero-weight set', () => { + const items = [{ w: 0 }, { w: 10 }]; + assert.equal(weightedPick(items, (i) => i.w, 0.5), items[1]); + assert.equal(weightedPick(items, () => 0, 0.5), null); +}); diff --git a/games/gridlock/tests/helpers.mjs b/games/gridlock/tests/helpers.mjs new file mode 100644 index 0000000..5d92596 --- /dev/null +++ b/games/gridlock/tests/helpers.mjs @@ -0,0 +1,47 @@ +import { createInitialState } from '../src/core/state.js'; +import { idx, TERRAIN } from '../src/core/grid.js'; +import { place } from '../src/core/commands.js'; +import { refreshStats } from '../src/core/simulate.js'; + +export const FIXED_SEED = 20870101; + +/** A deterministic starting city for every test. */ +export const newCity = (credits = 1e6) => { + const state = createInitialState(FIXED_SEED); + state.credits = credits; + return state; +}; + +/** + * Flatten the whole map to buildable land. Terrain is seeded, so any test + * about placement rules must remove terrain luck first. + */ +export const flatten = (state) => { + state.grid.tiles.forEach((t) => { t.t = TERRAIN.LAND; }); + return state; +}; + +export const centre = (state) => ({ + x: Math.floor(state.grid.w / 2), + y: Math.floor(state.grid.h / 2), +}); + +export const tile = (state, x, y) => state.grid.tiles[idx(state.grid, x, y)]; + +/** Force a tile's contents, bypassing validation — for building fixtures. */ +export const forceTile = (state, x, y, buildingId) => { + tile(state, x, y).b = buildingId; + return state; +}; + +/** Place a list of [dx, dy, id] offsets relative to the Nexus. */ +export const placeAll = (state, entries) => { + const { x, y } = centre(state); + let next = state; + for (const [dx, dy, id] of entries) { + const result = place(next, x + dx, y + dy, id); + if (!result.ok) throw new Error(`fixture placement failed at ${dx},${dy} (${id}): ${result.reason}`); + next = result.state; + } + return refreshStats(next); +}; diff --git a/games/gridlock/tests/iso.test.mjs b/games/gridlock/tests/iso.test.mjs new file mode 100644 index 0000000..983782a --- /dev/null +++ b/games/gridlock/tests/iso.test.mjs @@ -0,0 +1,50 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { createGrid } from '../src/core/grid.js'; +import { clampCamera, createCamera, pickTile, screenToTile, tileToScreen } from '../src/render/iso.js'; + +const view = { width: 1200, height: 800 }; + +test('screen projection round-trips back to the same tile', () => { + const grid = createGrid(11); + const cam = createCamera(grid); + for (const [x, y] of [[0, 0], [5, 12], [31, 31], [17, 3]]) { + const { sx, sy } = tileToScreen(x, y, cam, view); + const back = screenToTile(sx, sy, cam, view); + assert.ok(Math.abs(back.x - x) < 1e-9, `x ${back.x} != ${x}`); + assert.ok(Math.abs(back.y - y) < 1e-9, `y ${back.y} != ${y}`); + } +}); + +test('round-trip holds under zoom and pan', () => { + const grid = createGrid(11); + const cam = { ...createCamera(grid), zoom: 1.9, panX: -120, panY: 64 }; + const { sx, sy } = tileToScreen(9, 4, cam, view); + assert.deepEqual(pickTile(sx, sy, cam, view, grid), { x: 9, y: 4 }); +}); + +test('picking snaps to the nearest tile centre, not the floor', () => { + const grid = createGrid(11); + const cam = createCamera(grid); + const { sx, sy } = tileToScreen(6, 6, cam, view); + // A few pixels off-centre must still resolve to the same tile. + assert.deepEqual(pickTile(sx + 6, sy + 2, cam, view, grid), { x: 6, y: 6 }); +}); + +test('picking off the grid returns null', () => { + const grid = createGrid(11); + const cam = createCamera(grid); + assert.equal(pickTile(-5000, -5000, cam, view, grid), null); +}); + +test('camera zoom is clamped to its configured bounds', () => { + const grid = createGrid(11); + const cam = createCamera(grid); + cam.zoom = 99; + clampCamera(cam, grid); + assert.equal(cam.zoom, cam.maxZoom); + cam.zoom = 0.001; + clampCamera(cam, grid); + assert.equal(cam.zoom, cam.minZoom); +}); diff --git a/games/gridlock/tests/network.test.mjs b/games/gridlock/tests/network.test.mjs new file mode 100644 index 0000000..96ebe13 --- /dev/null +++ b/games/gridlock/tests/network.test.mjs @@ -0,0 +1,74 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { computeNetwork } from '../src/core/network.js'; +import { ration, POWER_PRIORITY } from '../src/core/rationing.js'; +import { idx } from '../src/core/grid.js'; +import { newCity, centre, forceTile } from './helpers.mjs'; + +test('the starting road stub is wired to the Nexus', () => { + const state = newCity(); + const { x, y } = centre(state); + const net = computeNetwork(state.grid); + assert.ok(net.roads.has(idx(state.grid, x + 1, y))); + assert.ok(net.roads.has(idx(state.grid, x + 2, y))); + assert.equal(net.roadCount, 6); +}); + +test('a structure touching a live road is connected', () => { + const state = newCity(); + const { x, y } = centre(state); + forceTile(state, x + 1, y + 1, 'hab'); // orthogonal to the road at (x+1, y) + const net = computeNetwork(state.grid); + assert.ok(net.connected.has(idx(state.grid, x + 1, y + 1))); +}); + +test('a structure on an orphan road island is not connected', () => { + const state = newCity(); + const { x, y } = centre(state); + forceTile(state, x + 8, y + 8, 'road'); + forceTile(state, x + 8, y + 9, 'hab'); + const net = computeNetwork(state.grid); + assert.equal(net.roads.has(idx(state.grid, x + 8, y + 8)), false); + assert.equal(net.connected.has(idx(state.grid, x + 8, y + 9)), false); +}); + +test('utilities and parks run without a road', () => { + const state = newCity(); + const { x, y } = centre(state); + forceTile(state, x + 9, y + 9, 'solar'); + forceTile(state, x + 9, y + 10, 'park'); + const net = computeNetwork(state.grid); + assert.ok(net.connected.has(idx(state.grid, x + 9, y + 9))); + assert.ok(net.connected.has(idx(state.grid, x + 9, y + 10))); +}); + +test('extending the road graph reaches further structures', () => { + const state = newCity(); + const { x, y } = centre(state); + for (let i = 3; i <= 6; i++) forceTile(state, x + i, y, 'road'); + forceTile(state, x + 6, y + 1, 'hab'); + const net = computeNetwork(state.grid); + assert.ok(net.connected.has(idx(state.grid, x + 6, y + 1))); +}); + +test('rationing serves by priority then index, and is deterministic', () => { + const consumers = [ + { i: 5, cat: 'industry', draw: 60 }, + { i: 1, cat: 'residential', draw: 30 }, + { i: 9, cat: 'civic', draw: 20 }, + ]; + const first = ration(consumers, 55, POWER_PRIORITY); + const second = ration(consumers, 55, POWER_PRIORITY); + assert.deepEqual([...first.served].sort(), [...second.served].sort()); + assert.ok(first.served.has(9), 'civic is served before residential'); + assert.ok(first.served.has(1), 'residential fits in the remaining budget'); + assert.equal(first.served.has(5), false, 'industry browns out first'); + assert.equal(first.demand, 110); +}); + +test('rationing with no consumers reports a healthy ratio', () => { + const result = ration([], 0, POWER_PRIORITY); + assert.equal(result.ratio, 1); + assert.equal(result.demand, 0); +}); diff --git a/games/gridlock/tests/simulate.test.mjs b/games/gridlock/tests/simulate.test.mjs new file mode 100644 index 0000000..4f1f66c --- /dev/null +++ b/games/gridlock/tests/simulate.test.mjs @@ -0,0 +1,100 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { tickMonth } from '../src/core/simulate.js'; +import { computeStats } from '../src/core/stats.js'; +import { nextHeat } from '../src/core/economy.js'; +import { TERRAIN } from '../src/core/grid.js'; +import { newCity, centre, placeAll, tile } from './helpers.mjs'; + +/** A minimal working city: road, housing, jobs, power, and a shoreline pump. */ +const workingCity = () => { + const state = newCity(); + const { x, y } = centre(state); + state.grid.tiles.forEach((t) => { t.t = TERRAIN.LAND; }); // flat map, no terrain luck + tile(state, x + 3, y + 4).t = TERRAIN.WATER; // shoreline for the pump below + return placeAll(state, [ + [1, 1, 'hab'], [1, -1, 'hab'], [2, 1, 'hab'], + [-1, 1, 'market'], [-1, -1, 'market'], + [0, 3, 'diesel'], [0, -3, 'solar'], + [3, 3, 'pump'], + ]); +}; + +test('ticking is pure — the input state is untouched', () => { + const before = workingCity(); + const snapshot = JSON.stringify({ ...before, stats: null }); + tickMonth(before); + assert.equal(JSON.stringify({ ...before, stats: null }), snapshot); +}); + +test('ticking is deterministic for identical states', () => { + const state = workingCity(); + const a = tickMonth(state); + const b = tickMonth(state); + assert.equal( + JSON.stringify({ ...a, stats: null }), + JSON.stringify({ ...b, stats: null }), + ); +}); + +test('a serviced city gains residents', () => { + let state = newCity(); + state.grid.tiles.forEach((t) => { t.t = TERRAIN.LAND; }); + state.era = 3; // the condenser is the terrain-free water source + state = placeAll(state, [ + [1, 1, 'hab'], [2, 1, 'hab'], [1, -1, 'hab'], + [-1, 1, 'market'], [-2, 1, 'market'], + [0, 3, 'solar'], [0, 4, 'solar'], [3, 0, 'condenser'], + ]); + for (let i = 0; i < 12; i++) state = tickMonth(state); + assert.ok(state.population > 20, `expected growth, got ${state.population}`); + assert.equal(Number.isFinite(state.credits), true); +}); + +test('housing beyond the Nexus supply browns out until utilities are built', () => { + let state = newCity(); + state.grid.tiles.forEach((t) => { t.t = TERRAIN.LAND; }); + // Twelve habitat blocks draw 72MW against the Nexus's own 60MW. + state = placeAll(state, [ + [1, 1, 'hab'], [1, -1, 'hab'], [2, 1, 'hab'], [2, -1, 'hab'], + [-1, 1, 'hab'], [-1, -1, 'hab'], [-2, 1, 'hab'], [-2, -1, 'hab'], + [3, 0, 'hab'], [-3, 0, 'hab'], [0, 2, 'hab'], [0, -2, 'hab'], + ]); + state = tickMonth(state); + assert.ok(state.stats.power.ratio < 1, 'demand must exceed the Nexus supply'); + const dark = state.grid.tiles.filter((t) => t.b === 'hab' && !t.on); + assert.ok(dark.length > 0, 'some blocks must be unpowered'); + + // Adding generation lights the whole district back up. + state = placeAll(state, [[0, 4, 'solar'], [0, 5, 'solar']]); + state = tickMonth(state); + assert.equal(state.stats.power.ratio, 1); + assert.equal(state.grid.tiles.filter((t) => t.b === 'hab' && !t.on).length, 0); +}); + +test('the treasury moves by exactly the reported net', () => { + const state = workingCity(); + const stats = computeStats(state); + const next = tickMonth(state); + assert.ok(Math.abs(next.credits - (state.credits + stats.net)) < 1e-9); +}); + +test('dirty generation raises the heat index, clean generation does not', () => { + const dirty = nextHeat({ heat: 20 }, { heatLoad: 12 }); + const clean = nextHeat({ heat: 20 }, { heatLoad: 0 }); + assert.ok(dirty > 20, 'emissions must warm the city'); + assert.ok(clean < 20, 'with no load the index decays'); +}); + +test('the heat index is bounded', () => { + assert.equal(nextHeat({ heat: 99.9 }, { heatLoad: 5000 }), 100); + assert.equal(nextHeat({ heat: 0 }, { heatLoad: 0 }), 0); +}); + +test('an empty city stays solvent long enough to build', () => { + let state = newCity(30000); + for (let i = 0; i < 24; i++) state = tickMonth(state); + assert.equal(state.bankrupt, false); + assert.equal(state.month, 24); +}); diff --git a/games/gridlock/tests/synergy.test.mjs b/games/gridlock/tests/synergy.test.mjs new file mode 100644 index 0000000..cda7c81 --- /dev/null +++ b/games/gridlock/tests/synergy.test.mjs @@ -0,0 +1,72 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { previewSynergy, tileSynergy } from '../src/core/synergy.js'; +import { TERRAIN } from '../src/core/grid.js'; +import { newCity, centre, forceTile, tile, flatten } from './helpers.mjs'; + +test('commerce beside housing earns a revenue multiplier per neighbour', () => { + const state = flatten(newCity()); + const { x, y } = centre(state); + forceTile(state, x + 5, y + 5, 'market'); + forceTile(state, x + 5, y + 4, 'hab'); + forceTile(state, x + 6, y + 5, 'hab'); + const syn = tileSynergy(state.grid, tile(state, x + 5, y + 5)); + assert.ok(Math.abs(syn.revenueMul - 1.16) < 1e-9, `got ${syn.revenueMul}`); + assert.equal(syn.hits[0].label, 'Foot Traffic'); + assert.equal(syn.hits[0].count, 2); +}); + +test('synergy counts are capped', () => { + const state = flatten(newCity()); + const { x, y } = centre(state); + forceTile(state, x + 5, y + 5, 'market'); + for (const [dx, dy] of [[0, 1], [0, -1], [1, 0], [-1, 0], [1, 1], [-1, -1]]) { + forceTile(state, x + 5 + dx, y + 5 + dy, 'hab'); + } + const syn = tileSynergy(state.grid, tile(state, x + 5, y + 5)); + assert.equal(syn.hits[0].count, 4, 'Foot Traffic caps at four neighbours'); + assert.ok(Math.abs(syn.revenueMul - 1.32) < 1e-9); +}); + +test('housing beside industry loses appeal', () => { + const state = flatten(newCity()); + const { x, y } = centre(state); + forceTile(state, x + 7, y + 7, 'hab'); + forceTile(state, x + 7, y + 8, 'fab'); + const syn = tileSynergy(state.grid, tile(state, x + 7, y + 7)); + assert.ok(syn.appealAdd < 0, `expected a penalty, got ${syn.appealAdd}`); +}); + +test('waterfront data centres run cooler', () => { + const state = flatten(newCity()); + const { x, y } = centre(state); + tile(state, x + 7, y + 8).t = TERRAIN.WATER; + forceTile(state, x + 7, y + 7, 'server'); + const syn = tileSynergy(state.grid, tile(state, x + 7, y + 7)); + assert.ok(syn.heatMul < 1, `expected cooling, got ${syn.heatMul}`); +}); + +test('a lone structure scores neutral', () => { + const state = flatten(newCity()); + const { x, y } = centre(state); + forceTile(state, x + 9, y + 9, 'market'); + const syn = tileSynergy(state.grid, tile(state, x + 9, y + 9)); + assert.equal(syn.revenueMul, 1); + assert.equal(syn.hits.length, 0); +}); + +test('preview matches what the tile would score, without mutating the grid', () => { + const state = flatten(newCity()); + const { x, y } = centre(state); + forceTile(state, x + 5, y + 4, 'hab'); + const preview = previewSynergy(state.grid, x + 5, y + 5, 'market'); + assert.equal(preview.hits[0].label, 'Foot Traffic'); + assert.equal(tile(state, x + 5, y + 5).b, null, 'preview must not place anything'); +}); + +test('empty tiles have no synergy', () => { + const state = flatten(newCity()); + const { x, y } = centre(state); + assert.equal(tileSynergy(state.grid, tile(state, x + 9, y + 9)).hits.length, 0); +}); diff --git a/package.json b/package.json index 84872db..e624691 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "start": "react-native start", "test": "jest --passWithNoTests", "lint": "eslint . --ext .ts,.tsx", - "type-check": "tsc --noEmit" + "type-check": "tsc --noEmit", + "test:gridlock": "node --test --test-reporter=spec games/gridlock/tests/*.test.mjs" }, "dependencies": { "react": "18.2.0", @@ -68,4 +69,4 @@ "url": "https://github.com/cloudygetty-ai/run-down.git" }, "license": "MIT" -} \ No newline at end of file +} diff --git a/vercel.json b/vercel.json index b4db9fc..9f066a9 100644 --- a/vercel.json +++ b/vercel.json @@ -1,5 +1,12 @@ { "version": 2, - "builds": [{ "src": "index.html", "use": "@vercel/static" }], - "routes": [{ "src": "/(.*)", "dest": "/index.html" }] + "builds": [ + { "src": "index.html", "use": "@vercel/static" }, + { "src": "games/**", "use": "@vercel/static" } + ], + "routes": [ + { "src": "/gridlock/?", "dest": "/games/gridlock/index.html" }, + { "src": "/games/(.*)", "dest": "/games/$1" }, + { "src": "/(.*)", "dest": "/index.html" } + ] }