// ============================================================ // WARLORD'S FATE — game state, RNG, save/load // ============================================================ import * as D from "./data.js"; export let G = null; export const uid = (() => { let n = 1000; return (p) => `${p}${++n}`; })(); // ---------- seeded RNG ---------- export function mulberry32(seed) { let a = seed >>> 0; return function () { a |= 0; a = (a + 0x6D2B79F5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } export function rand() { return G ? G.rand() : Math.random(); } export function randInt(a, b) { return a + Math.floor(rand() * (b - a + 1)); } export function pick(arr) { return arr[Math.floor(rand() * arr.length)]; } export function chance(p) { return rand() < p; } export function shuffle(arr) { const a = arr.slice(); for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(rand() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; } export function clamp(v, lo, hi) { return v < lo ? lo : v > hi ? hi : v; } // ---------- helpers ---------- export const F = id => G.factions[id]; export const GEN = id => G.generals[id]; export const CITY = id => G.cities[id]; export const provOfLetter = letter => D.PROVINCES[letter]; export const playerFaction = () => G.factions[G.playerFaction]; export const isPlayerFaction = id => id === G.playerFaction; export function factionGenerals(fid) { return Object.values(G.generals).filter(g => g.faction === fid && g.alive); } export function factionCities(fid) { return G.factions[fid].cities.map(id => G.cities[id]); } export function factionArmies(fid) { return Object.values(G.armies).filter(a => a.faction === fid); } export function warKey(a, b) { return [a, b].sort().join("|"); } export const atWar = (a, b) => G.wars.includes(warKey(a, b)); export const allied = (a, b) => G.alliances.includes(warKey(a, b)); export const hasNap = (a, b) => G.naps.includes(warKey(a, b)); export const tradesWith = (a, b) => G.trade.includes(warKey(a, b)); export function declareWar(a, b, reason) { const k = warKey(a, b); if (!G.wars.includes(k)) { G.wars.push(k); G.alliances = G.alliances.filter(x => x !== k); G.naps = G.naps.filter(x => x !== k); G.trade = G.trade.filter(x => x !== k); trustDrop(a, b, -40); F(a).fame += 2; F(b).fear += 4; return true; } return false; } export function makePeace(a, b) { const k = warKey(a, b); if (G.wars.includes(k)) { G.wars = G.wars.filter(x => x !== k); trustBump(a, b, 10); return true; } return false; } export function trustBump(a, b, d) { const fa = F(a), fb = F(b); if (!fa || !fb || fa.id === fb.id) return; fa.trust[fb.id] = clamp((fa.trust[fb.id] ?? 0) + d, -100, 100); fb.trust[fa.id] = clamp((fb.trust[fa.id] ?? 0) + d, -100, 100); } export function trustDrop(a, b, d) { trustBump(a, b, d); } export function getTrust(a, b) { return F(a)?.trust[b] ?? 0; } export function log(text, type = "info") { G.journal.unshift({ y: G.year, m: G.month, text, type, t: Date.now() }); if (G.journal.length > 400) G.journal.pop(); } export function chronicle(text, type = "epic") { G.chronicle.unshift({ y: G.year, m: G.month, text, type }); if (G.chronicle.length > 200) G.chronicle.pop(); } export function totalTroops(troops) { return Object.values(troops || {}).reduce((s, v) => s + v, 0); } export function factionTroopCount(fid) { let n = factionCities(fid).reduce((s, c) => s + totalTroops(c.garrison), 0); n += factionArmies(fid).reduce((s, a) => s + totalTroops(a.troops), 0); return n; } // army strength estimate (raw power points) export function armyPower(troops, gen, mods = {}) { let pow = 0; for (const [t, n] of Object.entries(troops || {})) { const ut = D.UNIT_TYPES[t]; if (!ut) continue; pow += (n / 100) * (ut.atk + ut.def) * 0.5; } if (gen) { pow *= 1 + (gen.st.ldr + gen.st.war) / 400; if (gen.traits.includes("brave")) pow *= 1.06; } return pow * (mods.mult || 1); } // ---------- NEW GAME ---------- export function newGame(opts) { const seed = opts.seed ?? (Math.random() * 1e9) | 0; const state = { version: 3, seed, rngState: seed, rand: null, year: D.START_YEAR, month: D.START_MONTH, turnCount: 0, mode: opts.mode || "campaign", challengeDaysLeft: opts.mode === "challenge" ? 8 : Infinity, difficulty: opts.difficulty || "normal", playerFaction: null, factions: {}, generals: {}, cities: {}, armies: {}, wars: [], alliances: [], naps: [], trade: [], prisoners: [], journal: [], chronicle: [], pendingEvents: [], flags: {}, stats: { battlesWon: 0, battlesLost: 0, citiesTaken: 0, citiesLost: 0, recruited: 0, lostGenerals: 0, executed: 0, warsDeclared: 0, betrayals: 0, discoveries: 0 }, gameOver: false, victory: null, replays: [], }; G = state; G.rand = mulberry32(seed); // --- provinces index --- G.provinces = {}; for (const letter of Object.keys(D.PROVINCES)) { G.provinces[letter] = { letter, cities: [], name: D.PROVINCES[letter].name }; } // --- cities --- for (const [id, name, cn, prov, col, row, pop, com, fer] of D.CITY_DEFS) { const [wx, wz] = D.hexToWorld(col, row); G.cities[id] = { id, name, cn, prov, col, row, x: wx, z: wz, owner: null, pop, order: 55 + Math.floor(G.rand() * 15), dev: 20 + Math.floor(G.rand() * 25), commerceBase: com, fertility: fer, buildings: { farm: 1, market: 1, barracks: 0, wall: 0, academy: 0, workshop: 0, granary: 0 }, garrison: { spear: pop * 4, bow: pop * 2 }, levies: Math.floor(pop * 1.5), food: pop * 20, capitalOf: null, unrest: 0, siegedBy: null, }; G.provinces[prov].cities.push(id); } // --- factions --- for (const fdef of D.FACTION_DEFS) { G.factions[fdef.id] = { id: fdef.id, name: fdef.name, color: fdef.color, personality: fdef.personality, leader: null, heir: null, isPlayer: false, alive: true, gold: fdef.id === "neutral" ? 1200 : 2400 + Math.floor(G.rand() * 1400), food: 6000 + Math.floor(G.rand() * 4000), legitimacy: 50, fame: 10, fear: 5, honor: 50, corruption: 10, rank: "Governor", cities: [], trust: {}, memory: [], capital: null, taxRate: 0.55, lastIncome: 0, lastFood: 0, }; for (const cid of fdef.startCities) { assignCity(cid, fdef.id, true); } } // neutral leader-less G.factions.neutral.leader = null; G.factions.huangjin.leader = "guanhai"; // --- generals --- for (const gd of D.GENERAL_DEFS) { const fid = gd.servingDong ? "dong" : gd.f; const g = makeGeneral(gd.n, fid, gd.b, gd.st, gd.tr, gd.sk, gd.t); if (gd.hidden) g.hidden = true; if (gd.freeAgent) g.freeAgent = true; if (gd.heirOf) G.factions[gd.heirOf].heir = g.id; const fdef = D.FACTION_DEFS.find(f => f.id === fid); if (!gd.hidden && fdef?.leader === gd.n.toLowerCase().replace(/[\s']/g, "")) { const fac = G.factions[fid]; if (fac) { fac.leader = g.id; g.isLeader = true; } } } // Lü Bu leads his own faction const lb = findGen("Lü Bu"); lb.faction = "lubu"; lb.isLeader = true; G.factions.lubu.leader = lb.id; // place generals in their faction's cities (leader in capital) for (const fac of Object.values(G.factions)) { if (!fac.cities.length) continue; fac.capital = fac.cities[0]; G.cities[fac.capital].capitalOf = fac.id; const gens = factionGenerals(fac.id).filter(g => !g.hidden && !g.freeAgent); gens.forEach((g, i) => { g.location = fac.cities[i % fac.cities.length]; }); if (fac.leader) GEN(fac.leader).location = fac.capital; } // hidden talents wait in specific towns; free agents roam if (G.generals.huangzhong) G.generals.huangzhong.location = "changsha"; if (G.generals.weiyan) G.generals.weiyan.location = "changsha"; if (G.generals.ganning) G.generals.ganning.location = "jiangxia"; if (G.generals.taishici) { G.generals.taishici.location = null; G.generals.taishici.roams = true; } // relationships applyRelations(); // --- player setup --- setupPlayer(opts); // opening chronicle chronicle(`Winter ${G.year}. The Han dynasty crumbles. Warlords carve the empire.`, "epic"); log("The campaign begins. History is unwritten.", "epic"); return G; } export function makeGeneral(name, faction, birth, st, traits, skillId, title) { let id = name.toLowerCase().replace(/[^a-z]/g, "") || uid("gen"); if (G.generals[id]) id = uid("gen"); const g = { id, name, title: title || "", faction, birth, st: { ldr: st[0], war: st[1], int: st[2], pol: st[3], cha: st[4] }, traits: traits || [], skill: skillId || "rally", age: G.year - birth, loyalty: 60 + Math.floor(G.rand() * 30), morale: 70, xp: 0, level: 1, wounded: 0, alive: true, hidden: false, freeAgent: false, location: null, ambition: 30 + Math.floor(G.rand() * 40), salary: 0, kills: 0, isLeader: false, renown: 0, bondPlayer: 0, }; g.salary = Math.round((st[0] + st[1] + st[2]) / 3) * 2; G.generals[id] = g; return g; } function applyRelations() { for (const [a, b] of D.RELATIONS.sworn) { const ga = findGen(a), gb = findGen(b); if (ga && gb) { ga.sworn = ga.sworn || []; ga.sworn.push(gb.id); gb.sworn = gb.sworn || []; gb.sworn.push(ga.id); ga.loyalty = clamp(ga.loyalty + 10, 0, 100); gb.loyalty = clamp(gb.loyalty + 10, 0, 100); } } for (const [a, b] of D.RELATIONS.rivals) { const ga = findGen(a), gb = findGen(b); if (ga && gb) { ga.rivals = ga.rivals || []; ga.rivals.push(gb.id); gb.rivals = gb.rivals || []; gb.rivals.push(ga.id); } } for (const [a, b] of D.RELATIONS.friends) { const ga = findGen(a), gb = findGen(b); if (ga && gb) { ga.friends = ga.friends || []; ga.friends.push(gb.id); gb.friends = gb.friends || []; gb.friends.push(ga.id); } } for (const [a, b] of D.RELATIONS.family) { const ga = findGen(a), gb = findGen(b); if (ga && gb) { ga.family = ga.family || []; ga.family.push(gb.id); gb.family = gb.family || []; gb.family.push(ga.id); } } } export function findGen(name) { return Object.values(G.generals).find(g => g.name === name); } export function assignCity(cityId, factionId, initial = false) { const c = G.cities[cityId]; const oldFac = c.owner; if (oldFac && G.factions[oldFac]) { G.factions[oldFac].cities = G.factions[oldFac].cities.filter(x => x !== cityId); } c.owner = factionId; if (factionId && G.factions[factionId]) { if (!G.factions[factionId].cities.includes(cityId)) G.factions[factionId].cities.push(cityId); if (!initial) { c.unrest = 30; c.order = Math.max(c.order - 25, 15); G.stats.citiesTaken += isPlayerFaction(factionId) ? 1 : 0; G.stats.citiesLost += oldFac && isPlayerFaction(oldFac) ? 1 : 0; } } } export function generateName() { const s = pick(D.SURNAME); const surname = s.endsWith("2") ? s.slice(0, -1) : s; return `${surname} ${pick(D.GIVEN)}`; } export function makeGenericGeneral(faction, minPower = 50) { const name = generateName(); const q = minPower + Math.floor(rand() * 30); const g = makeGeneral(name, faction, G.year - randInt(22, 45), [q + randInt(-8, 12), q + randInt(-8, 12), q + randInt(-15, 15), q + randInt(-18, 10), q + randInt(-15, 15)], [pick(Object.keys(D.TRAITS))], pick(["chargecall", "volley", "rally", "ambush"]), ""); g.generic = true; g.loyalty = 45 + randInt(0, 25); return g; } // ---------- PLAYER ---------- function setupPlayer(opts) { if (opts.playerType === "faction") { G.playerFaction = opts.factionId; F(opts.factionId).isPlayer = true; return; } // custom warlord spawns replacing a neutral spawn city const cityId = opts.custom.cityId; const city = G.cities[cityId]; const fid = "player"; const fac = { id: fid, name: opts.custom.factionName || `House of ${opts.custom.rulerName}`, color: opts.custom.color, personality: "hawk", leader: null, heir: null, isPlayer: true, alive: true, gold: 2800, food: 7000, legitimacy: 35 + (D.ORIGINS[opts.custom.origin].bonus.legit || 0), fame: 5 + (D.ORIGINS[opts.custom.origin].bonus.fame || 0), fear: (D.ORIGINS[opts.custom.origin].bonus.fear || 0), honor: 50 + (D.ORIGINS[opts.custom.origin].bonus.honor || 0), corruption: 8, rank: "Governor", cities: [], trust: {}, memory: [], capital: cityId, taxRate: 0.55, lastIncome: 0, lastFood: 0, emblem: opts.custom.emblem, origin: opts.custom.origin, }; G.factions[fid] = fac; G.playerFaction = fid; assignCity(cityId, fid, true); city.order = 65; city.unrest = 0; // ruler general const ob = D.ORIGINS[opts.custom.origin].bonus; const base = { governor: [62, 46, 66, 82, 74], soldier: [72, 80, 48, 42, 58], noble: [64, 52, 58, 68, 78], bandit: [70, 84, 40, 30, 52], merchant: [54, 40, 66, 80, 84], scholar: [56, 36, 84, 76, 72], exile: [80, 86, 62, 50, 66], rebel: [74, 72, 56, 44, 80] }[opts.custom.origin]; const ruler = makeGeneral(opts.custom.rulerName, fid, G.year - randInt(24, 34), base, ["charismatic"], "rally", "Ruler"); ruler.isLeader = true; ruler.loyalty = 100; ruler.location = cityId; fac.leader = ruler.id; // companions const comp1 = makeGenericGeneral(fid, 58); comp1.location = cityId; comp1.loyalty = 85; const comp2 = makeGenericGeneral(fid, 52); comp2.location = cityId; comp2.loyalty = 80; if (ob.companion) { const vet = findGen("Taishi Ci") || findGen("Huang Zhong"); if (vet && !vet.faction || (vet && (vet.freeAgent || vet.hidden))) { vet.faction = fid; vet.hidden = false; vet.freeAgent = false; vet.location = cityId; vet.loyalty = 95; vet.sworn = [ruler.id]; vet.title = "Sworn Shield"; } else { const v = makeGenericGeneral(fid, 70); v.location = cityId; v.loyalty = 95; v.title = "Sworn Shield"; v.sworn = [ruler.id]; } } // starting forces city.garrison = { spear: 1400, bow: 700, sword: 400 }; if (ob.extraTroops) city.garrison.spear += ob.extraTroops * 3; } // ---------- SAVE / LOAD ---------- const SAVE_PREFIX = "warlords-fate-save-"; function store() { try { return globalThis.localStorage; } catch { return null; } } export function saveGame(slot = "autosave") { const data = JSON.stringify(G, (k, v) => (v instanceof Set ? [...v] : v)); try { store()?.setItem(SAVE_PREFIX + slot, JSON.stringify({ when: Date.now(), label: `${F(G.playerFaction)?.name ?? "?"} — Winter ${G.year}/${String(G.month).padStart(2, "0")} — ${slot}`, data })); return true; } catch (e) { console.warn("save failed", e); return false; } } export function listSaves() { const out = []; try { for (let i = 0; i < localStorage.length; i++) { const k = localStorage.key(i); if (k?.startsWith(SAVE_PREFIX)) { try { const rec = JSON.parse(store()?.getItem(k)); out.push({ slot: k.slice(SAVE_PREFIX.length), when: rec.when, label: rec.label }); } catch { } } } } catch { } return out.sort((a, b) => b.when - a.when); } export function loadGame(slot) { try { const raw = store()?.getItem(SAVE_PREFIX + slot); if (!raw) return null; const rec = JSON.parse(raw); G = JSON.parse(rec.data); G.rand = mulberry32(G.seed + G.turnCount * 7919); return G; } catch (e) { console.warn("load failed", e); return null; } } export function hasAutosave() { try { return !!store()?.getItem(SAVE_PREFIX + "autosave"); } catch { return false; } }