Files
deepseek f040bb6be0 Three Kingdoms: Warlord's Fate — complete playable game
- Stylized 3D ink-painting map of China (12 provinces, 32 cities, 17 factions)
- Custom warlord creation (8 origins, banner, starting city) or historical factions
- City management: 7 buildings, 5 dev tiers, recruitment from levies
- Character system: stats, traits, loyalty, relationships, wounds, capture, death, succession
- Turn-based tactical battles with formations, stances, hero skills, cinematic 3D replay
- Sieges: assault, starvation, bribery, infiltration
- Diplomacy with trust memory, alliances, NAPs, trade, marriage, espionage, betrayal
- Scripted diverging history (Dong Zhuo, Guandu, Red Cliffs...) + world crises + court events
- AI factions with distinct personalities; prisoners (execute/release/recruit/ransom)
- Procedural guqin/taiko WebAudio score; save/load; victory + dynasty chronicle screens
- View-relative camera controls; headless test suites (smoke, stress, map validator)
2026-08-23 06:59:40 +00:00

380 lines
15 KiB
JavaScript

// ============================================================
// 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; }
}