// ============ state.js — central game state, time, spawning, objectives ============ import { GameMap } from '../world/map.js'; import { makeRng, uid, resetUid, clamp, choice } from '../core/util.js'; import { SCENARIOS, WEATHER, RIDE_TYPES, SHOP_TYPES, SCENERY_TYPES, STAFF_TYPES, GUEST_NAMES, GUEST_COLORS, AWARDS_POOL, MAX_Z, UNLOCKS } from '../core/config.js'; import { monthClose, tickCampaigns } from './economy.js'; export const HOUR_RATE = 24 / 480; // 1 in-game day = 480 real seconds at 1× export const DAYS_PER_MONTH = 8; // short months keep finance cycles lively export const GUEST_CAP = 320; let S = null; export const getState = () => S; export function setState(s) { S = s; } export function newGame(scenarioId) { const scen = SCENARIOS.find(s => s.id === scenarioId) || SCENARIOS[0]; resetUid(1); const st = { version: 3, scenario: scen.id, sandbox: !!scen.sandbox, startedAt: Date.now(), rngSeed: (Math.random() * 0xffffffff) >>> 0, cash: scen.cash, loan: 0, loanLimit: scen.loanLimit, park: { name: pickParkName(scen), open: true, entranceFee: scen.id === 'meadows' ? 0 : 5, }, time: { hour: 8.5, day: 1, month: 0, year: 1 }, weather: { cur: 'sunny', timer: 90 + Math.random() * 120 }, mana: 40, manaMax: 40, research: { rp: 0, spentTotal: 0, unlocked: [] }, spells: { active: {}, cds: {} }, campaigns: [], map: null, rides: [], shops: [], sceneryList: [], staff: [], guests: [], heroes: [], monsters: [], effects: [], // transient visual effects {kind,x,y,t,dur,...} floatTexts: [], guild: null, // {x,y,w,h,cap} heroStats: { kills: 0, lootGold: 0, losses: 0 }, invasion: { nextMonthIdx: 0, waveActive: false, repelled: 0, bossKilled: false, count: 0 }, stats: null, finance: { current: {}, history: [], lastMonthProfit: 0 }, ratingHistory: [0], awards: [], vandalism: 0, objectivesDone: {}, won: false, lost: false, toasts: [], // drained by UI guestSpawnAcc: 0, autosaveMonthCounter: 0, uiHintsSeen: {}, }; st.map = buildMap(scen); placeInitialLayout(st, scen); if (st.sandbox) unlockEverythingSync(st); st.invasion.nextMonthIdx = monthIndex(st) + scen.invasionStartMonth; recomputeManaCap(st); recomputeStats(st); setState(st); return st; } function pickParkName(scen) { const names = ['Everdawn Park', 'Moonhollow Gardens', 'Silverbranch Park', 'Emberfall Kingdom', 'Starweald Gardens']; return names[Math.floor(Math.random() * names.length)]; } function buildMap(scen) { const m = new GameMap(scen.mapSize); m.generate(scen.gen, scen.mapSeed); return m; } /** Entrance plaza: gate marker + initial paths + guild plot reserved */ function placeInitialLayout(st, scen) { const m = st.map; const ex = m.entranceX, ey = m.entranceY; // clear & pave entrance corridor + plaza for (let y = ey - 2; y < Math.min(m.size, ey + 4); y++) { for (let x = ex - 2; x <= ex + 2; x++) { const i = m.idx(x, y); if (m.terrain[i] === 3) m.terrain[i] = 0; // dry any water near entrance m.pathType[i] = 1; } } // plaza square for (let y = ey - 6; y < ey - 2; y++) { for (let x = ex - 4; x <= ex + 4; x++) { if (!m.inBounds(x, y)) continue; const i = m.idx(x, y); if (m.terrain[i] === 3) m.terrain[i] = 0; m.pathType[i] = 1; } } // a couple of starter trees around plaza const rng = makeRng(scen.mapSeed ^ 777); let placedTrees = 0; for (let tries = 0; tries < 400 && placedTrees < 10; tries++) { const x = Math.floor(rng() * m.size), y = Math.floor(rng() * m.size); if (!m.isBuildable(x, y) || m.occupied(x, y)) continue; if (Math.abs(x - ex) < 6 && y > ey - 8) continue; addSceneryObj(st, 'tree_' + (rng() < .5 ? 'oak' : 'pine'), x, y, true); placedTrees++; } } // ---------------- entity factories ---------------- export function addRideObj(state, typeId, x, y, opts = {}) { const def = RIDE_TYPES[typeId]; const ride = { id: uid(), type: typeId, def, name: opts.name || def.name, x, y, w: def.w, h: def.h, price: Math.round(def.excite * 0.8), status: 'closed', // closed | testing | open | broken queue: [], // guest ids waiting riders: [], // guest ids currently riding cycleT: 0, cycleDur: def.rideTime, breakdownT: 0, reliability: 0.92 + Math.random() * 0.06, totalRiders: 0, income: 0, animPhase: 0, excite: def.excite, intensity: def.intensity, nausea: def.nausea, isCustomCoaster: !!opts.coaster, track: opts.track || null, // coaster piece list train: opts.train || null, stats: opts.stats || null, entranceX: opts.entranceX ?? x, entranceY: opts.entranceY ?? y, exitTile: opts.exitTile || null, brokenCount: 0, }; state.rides.push(ride); if (!opts.coaster) { for (let yy = 0; yy < def.h; yy++) for (let xx = 0; xx < def.w; xx++) { state.map.setObject(x + xx, y + yy, { kind: 'ride', id: ride.id, ox: xx, oy: yy }); state.map.pathType[state.map.idx(x + xx, y + yy)] = 0; } } return ride; } export function addShopObj(state, typeId, x, y) { const def = SHOP_TYPES[typeId]; const shop = { id: uid(), type: typeId, def, x, y, price: def.price, stock: def.stock >= 999999 ? Infinity : def.stock, sold: 0, income: 0, damaged: 0, }; state.shops.push(shop); state.map.setObject(x, y, { kind: 'shop', id: shop.id }); state.map.pathType[state.map.idx(x, y)] = 0; return shop; } export function addSceneryObj(state, typeId, x, y, free = false) { const def = SCENERY_TYPES[typeId]; if (!def) return null; if (def.size === 2 && !free && !canPlaceRect(state.map, x, y, 2, 2)) return null; const obj = { id: uid(), type: typeId, def, x, y }; state.sceneryList.push(obj); const size = def.size || 1; for (let yy = 0; yy < size; yy++) for (let xx = 0; xx < size; xx++) state.map.setObject(x + xx, y + yy, { kind: 'scenery', id: obj.id, ox: xx, oy: yy }); return obj; } export function removeScenery(state, obj) { state.sceneryList = state.sceneryList.filter(o => o !== obj); const size = obj.def.size || 1; for (let yy = 0; yy < size; yy++) for (let xx = 0; xx < size; xx++) { const o = state.map.getObject(obj.x + xx, obj.y + yy); if (o && o.kind === 'scenery' && o.id === obj.id) state.map.clearObject(obj.x + xx, obj.y + yy); } } export function canPlaceRect(map, x, y, w, h) { for (let yy = 0; yy < h; yy++) for (let xx = 0; xx < w; xx++) { if (!map.isBuildable(x + xx, y + yy) || map.occupied(x + xx, y + yy)) return false; } return true; } // ---------------- main step ---------------- export function step(state, dt) { if (state.won || state.lost) dt = Math.min(dt, 0); // freeze sim on end advanceTime(state, dt); tickWeather(state, dt); // imported lazily by main via update modules } export function advanceTime(state, dt) { const t = state.time; t.hour += dt * HOUR_RATE; while (t.hour >= 24) { t.hour -= 24; t.day++; if (t.day > DAYS_PER_MONTH) { t.day = 1; t.month++; onNewMonth(state); if (t.month > 11) { t.month = 0; t.year++; onNewYear(state); } } } } export function monthIndex(state) { return state.time.year * 12 + state.time.month; } function onNewMonth(state) { monthClose(state); tickCampaigns(state); quarterlyAwards(state); checkInvasionSchedule(state); state.autosaveMonthCounter++; state.toasts.push({ kind: 'month', title: 'New Month', text: `Welcome to ${['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'][state.time.month]}, Year ${state.time.year}.` }); } function onNewYear(state) { state.toasts.push({ kind: 'gold', title: `Year ${state.time.year} begins!`, text: 'The kingdom grows stronger.' }); } function quarterlyAwards(state) { if ((state.time.month % 3) !== 0) return; recomputeStats(state); const s = state.stats; for (const a of AWARDS_POOL) { if (state.awards.includes(a.id)) continue; try { if (a.test(s)) { state.awards.push(a.id); state.toasts.push({ kind: 'gold', title: 'Award Won!', text: `${a.name} — your park is famous!` }); } } catch { } } } function checkInvasionSchedule(state) { const scen = SCENARIOS.find(s => s.id === state.scenario); if (!scen || !scen.invasionEvery) return; if (state.invasion.nextMonthIdx <= monthIndex(state)) { state.invasion.nextMonthIdx = monthIndex(state) + scen.invasionEvery; state.pendingInvasion = true; // consumed by heroes module } } // ---------------- weather ---------------- function tickWeather(state, dt) { state.weather.timer -= dt; if (state.weather.timer <= 0) { const roll = Math.random(); const order = state.weather.cur === 'sunny' ? ['cloudy', 'sunny', 'rain'] : state.weather.cur === 'cloudy' ? ['sunny', 'rain', 'cloudy', 'storm'] : state.weather.cur === 'rain' ? ['cloudy', 'rain', 'sunny', 'storm'] : ['rain', 'cloudy', 'sunny']; let next = order[0]; if (roll < 0.45) next = order[0]; else if (roll < 0.75) next = order[1] || next; else next = order[order.length - 1]; setWeather(state, next); state.weather.timer = 80 + Math.random() * 160; } // sunburst spell forces sunny if (state.spells.active.sunburst) setWeather(state, 'sunny'); } export function setWeather(state, w) { if (state.weather.cur === w) return; state.weather.cur = w; state.toasts.push({ kind: 'info', title: `Weather: ${WEATHER[w].name}`, text: '' }); } // ---------------- mana ---------------- export function recomputeManaCap(state) { let cap = 40, regen = 0.4; for (const sc of state.sceneryList) { if (sc.def.manaCap) cap += sc.def.manaCap; if (sc.def.manaRegen) regen += sc.def.manaRegen; } cap = Math.min(cap, 300); state.manaMax = cap; state.manaRegen = regen; } // ---------------- stats & rating ---------------- export function recomputeStats(state) { const m = state.map; let happySum = 0, happyN = 0; for (const g of state.guests) { happySum += g.happiness; happyN++; } const avgHappy = happyN ? happySum / happyN : 70; const litterCount = m.countLitter(); let magicCount = 0, beautySum = 0, lightCount = 0; for (const sc of state.sceneryList) { beautySum += sc.def.beauty || 0; if (sc.def.magic) magicCount++; if (sc.def.light) lightCount++; } const openRideTypes = new Set(); let openRides = 0, brokenRides = 0; for (const r of state.rides) { if (r.status === 'open') { openRides++; openRideTypes.add(r.type); } if (r.status === 'broken') brokenRides++; } let bestExcite = 0; for (const r of state.rides) bestExcite = Math.max(bestExcite, r.excite || 0); const hasFood = state.shops.some(s => s.type === 'food'); const hasDrink = state.shops.some(s => s.type === 'drinks'); const hasToilet = state.shops.some(s => s.type === 'toilet'); const facilities = (hasFood ? 40 : 0) + (hasDrink ? 30 : 0) + (hasToilet ? 30 : 0); const pathTiles = countPaths(m); const avgBeauty = pathTiles ? beautySum / pathTiles : 0; const rating = clamp(Math.round( Math.min(openRideTypes.size, 8) / 8 * 150 + avgHappy / 100 * 260 + Math.max(0, 190 - litterCount * 4 - state.vandalism * 12) + Math.min(avgBeauty * 14, 140) + facilities + Math.min(state.heroStats.kills * 1.2, 60) ), 0, 999); state.stats = { avgHappy, litterCount, avgBeauty, magicCount, openRides, openRideTypes: openRideTypes.size, brokenRides, bestExcite, facilities, rating, vandalism: state.vandalism, guests: state.guests.length, shops: state.shops.length, }; state.ratingHistory.push(rating); if (state.ratingHistory.length > 240) state.ratingHistory.shift(); return state.stats; } function countPaths(m) { let c = 0; for (let i = 0; i < m.pathType.length; i++) if (m.pathType[i]) c++; return c; } export function parkValue(state) { let v = state.cash - state.loan; for (const r of state.rides) v += r.def.cost * 0.7; for (const s of state.shops) v += s.def.cost * 0.7; for (const sc of state.sceneryList) v += (sc.def.cost || 0) * 0.5; return Math.round(v); } // ---------------- objectives ---------------- export function objectiveProgress(state, goal) { switch (goal.type) { case 'guests': return state.guests.length; case 'rating': return state.stats?.rating || 0; case 'cash': return parkValue(state); case 'invasions': return state.invasion.repelled; case 'bossKill': return state.invasion.bossKilled ? 1 : 0; case 'coasterExcite': { let best = 0; for (const r of state.rides) if (r.isCustomCoaster && r.status === 'open') best = Math.max(best, r.excite); return best; } default: return 0; } } export function checkObjectives(state) { if (state.won || state.freeplay) return; const scen = SCENARIOS.find(s => s.id === state.scenario); if (!scen || !scen.goals.length) return; let all = true; for (const g of scen.goals) { const done = objectiveProgress(state, g) >= g.value; if (!done) all = false; state.objectivesDone[g.id] = done; } if (all) { state.won = true; state.toasts.push({ kind: 'gold', title: '🏆 VICTORY!', text: `${scen.name} conquered — all objectives complete!` }); } if (state.cash < scen.loseCash && !state.lost) { state.lost = true; state.toasts.push({ kind: 'bad', title: 'BANKRUPTCY', text: 'The kingdom has run out of gold…' }); } } export function isNight(t) { return t.hour >= 20 || t.hour < 6; } // ---------------- research RP accrual ---------------- export function tickResearch(state, dt) { let rate = 0.05; rate += state.stats.openRides * 0.02; rate += state.stats.magicCount * 0.03; if (state.guests.length > 50) rate += 0.03; state.research.rp += rate * dt; } // ---------------- sandbox unlock ---------------- export function unlockEverythingSync(state) { for (const u of UNLOCKS) if (!state.research.unlocked.includes(u.key)) state.research.unlocked.push(u.key); } // ---------------- serialization ---------------- export function serialize(state) { const m = state.map; return { ...state, map: undefined, mapData: { size: m.size, terrain: Array.from(m.terrain), pathType: Array.from(m.pathType), litter: Array.from(m.litter), vomit: Array.from(m.vomit), objects: m.objects, scatterSeed: m.scatterSeed, entranceX: m.entranceX, entranceY: m.entranceY, }, rngState: typeof state._rng?.getState === 'function' ? state._rng.getState() : 0, stats: undefined, _statsSnapshot: state.stats, }; } export function deserialize(data) { resetUid(data.startedAt % 100000 || 1); const scen = SCENARIOS.find(s => s.id === data.scenario) || SCENARIOS[0]; const st = JSON.parse(JSON.stringify({ ...data, mapData: undefined })); const m = new GameMap(data.mapData.size); m.terrain = Uint8Array.from(data.mapData.terrain); m.pathType = Uint8Array.from(data.mapData.pathType); m.litter = Float32Array.from(data.mapData.litter); m.vomit = Float32Array.from(data.mapData.vomit); m.objects = data.mapData.objects; m.scatterSeed = data.mapData.scatterSeed; m.entranceX = data.mapData.entranceX; m.entranceY = data.mapData.entranceY; st.map = m; // restore def references lost through JSON for (const r of st.rides) { r.def = RIDE_TYPES[r.type]; } for (const s of st.shops) { s.def = SHOP_TYPES[s.type]; } for (const sc of st.sceneryList) { sc.def = SCENERY_TYPES[sc.type]; } for (const sf of st.staff) { sf.def = STAFF_TYPES[sf.type]; } // restore uid counter beyond any loaded id let maxId = 1; for (const arr of [st.rides, st.shops, st.sceneryList, st.staff, st.guests, st.heroes, st.monsters]) { if (!Array.isArray(arr)) continue; for (const e of arr) if (e && typeof e.id === 'number' && e.id > maxId) maxId = e.id; } resetUid(maxId + 1); st.stats = st._statsSnapshot || recomputeStats(st); st.rngSeed = data.rngSeed ?? 12345; setState(st); return st; } /** attach runtime rng */ export function ensureRng(state) { if (!state._rng || typeof state._rng !== 'function') { state._rng = makeRng(state.rngSeed || 42); } return state._rng; }