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)
This commit is contained in:
deepseek
2026-08-23 06:59:40 +00:00
commit f040bb6be0
29 changed files with 60362 additions and 0 deletions
+61
View File
@@ -0,0 +1,61 @@
// map validation script (node)
import { MAP_ROWS, PROVINCE_LETTERS, CITY_DEFS, hexToWorld, RIVERS } from "../js/data.js";
const rows = MAP_ROWS;
const H = rows.length;
const at = (c, r) => (r < 0 || r >= H || c < 0 || c >= (rows[r]?.length ?? 0)) ? "." : rows[r][c];
const isLand = ch => ch !== ".";
const provOf = ch => isLand(ch) ? ch.toLowerCase() : null;
// neighbors odd-r offset
function nbrs(c, r) {
const odd = r % 2;
return [[c-1,r],[c+1,r],[c+odd-1,r-1],[c+odd,r-1],[c+odd-1,r+1],[c+odd,r+1]];
}
// contiguity per province
for (const P of PROVINCE_LETTERS) {
const cells = [];
for (let r = 0; r < H; r++) for (let c = 0; c < (rows[r].length); c++) if (provOf(rows[r][c]) === P) cells.push([c, r]);
if (!cells.length) { console.log(`!! province ${P} EMPTY`); continue; }
const set = new Set(cells.map(([c, r]) => c + "," + r));
const seen = new Set([cells[0].join(",")]);
const stack = [cells[0]];
while (stack.length) {
const [c, r] = stack.pop();
for (const [nc, nr] of nbrs(c, r)) {
if (provOf(at(nc, nr)) === P && !seen.has(nc + "," + nr)) { seen.add(nc + "," + nr); stack.push([nc, nr]); }
}
}
console.log(`${set.size === seen.size ? "OK " : "SPLIT"} ${P}: ${cells.length} hexes${set.size !== seen.size ? " (" + (set.size - seen.size) + " orphan)" : ""}`);
}
// adjacency between provinces
const adj = new Set();
for (let r = 0; r < H; r++) for (let c = 0; c < rows[r].length; c++) {
const p = provOf(rows[r][c]); if (!p) continue;
for (const [nc, nr] of nbrs(c, r)) { const q = provOf(at(nc, nr)); if (q && q !== p) adj.add([p, q].sort().join("")); }
}
console.log("adjacency:", [...adj].sort().join(" "));
// city checks
let bad = 0;
for (const [id, name, , prov, c, r] of CITY_DEFS) {
const p = provOf(at(c, r));
if (p !== prov) { console.log(`CITY BAD: ${name} wants ${prov} but tile(${c},${r})='${at(c, r)}'`); bad++; }
}
// uniqueness of city tiles
const tiles = new Set();
for (const [id, name, , , c, r] of CITY_DEFS) { const k = c + "," + r; if (tiles.has(k)) { console.log("CITY OVERLAP at", k); bad++; } tiles.add(k); }
console.log(bad ? `${bad} city problems` : `all ${CITY_DEFS.length} cities valid & unique`);
// river points on land?
for (const rv of RIVERS) for (const [c, r] of rv.pts) if (!isLand(at(c, r))) console.log(`river ${rv.name} off-land at ${c},${r}`);
// world extent
let minX = 1e9, maxX = -1e9, minZ = 1e9, maxZ = -1e9;
for (let r = 0; r < H; r++) for (let c = 0; c < rows[r].length; c++) if (isLand(rows[r][c])) {
const [x, z] = hexToWorld(c, r);
minX = Math.min(minX, x); maxX = Math.max(maxX, x); minZ = Math.min(minZ, z); maxZ = Math.max(maxZ, z);
}
console.log(`map world extent x:[${minX.toFixed(0)},${maxX.toFixed(0)}] z:[${minZ.toFixed(0)},${maxZ.toFixed(0)}]`);
+118
View File
@@ -0,0 +1,118 @@
// Generates the ASCII hex map by painting province polygons, prints preview.
const W = 27, H = 29;
const grid = Array.from({ length: H }, () => Array(W).fill("."));
// point in polygon (hex-center space, col/row floats)
function pip(x, y, poly) {
let inside = false;
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
const [xi, yi] = poly[i], [xj, yj] = poly[j];
if ((yi > y) !== (yj > y) && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside;
}
return inside;
}
// painted in order; later paints overwrite
const PAINTS = [
["g", [[1,10],[8,9],[10,12],[10,15],[9,18],[8,22],[6,26],[3,26],[1,20],[0,14]]],
["n", [[6,11],[14,11],[16,13],[16,17],[14,21],[11,22],[8,20],[6,16]]],
["h", [[15,11],[21,11],[25,14],[24,19],[19,21],[15,18],[15,13]]],
["u", [[11,10],[17,10],[18,13],[16,16],[12,16],[10,13]]],
["x", [[15,9],[21,9],[22,12],[19,15],[16,14],[15,11]]],
["l", [[0,0],[8,0],[8,4],[6,6],[3,7],[0,6]]],
["o", [[4,4],[10,4],[11,7],[10,10],[7,11],[5,9],[3,7]]],
["s", [[7,7],[12,7],[13,10],[11,12],[8,12],[6,9]]],
["y", [[9,1],[17,1],[18,4],[16,6],[11,6],[9,4]]],
["j", [[9,4],[17,5],[17,8],[13,9],[10,7],[9,5]]],
["q", [[15,6],[22,6],[23,9],[20,12],[17,11],[15,9]]],
["a", [[11,8],[16,8],[17,11],[15,13],[11,13],[10,10]]],
];
for (const [ch, poly] of PAINTS)
for (let r = 0; r < H; r++)
for (let c = 0; c < W; c++)
if (pip(c, r, poly)) grid[r][c] = ch;
// mountain ranges (uppercase) — hand bands
function band(pts, chs) {
// mark cells near polyline
for (let r = 0; r < H; r++) for (let c = 0; c < W; c++) {
if (grid[r][c] === ".") continue;
let d = 1e9;
for (let i = 0; i < pts.length - 1; i++) {
const [x1, y1] = pts[i], [x2, y2] = pts[i + 1];
const dx = x2 - x1, dy = y2 - y1;
const t = Math.max(0, Math.min(1, ((c - x1) * dx + (r - y1) * dy) / (dx * dx + dy * dy)));
d = Math.min(d, Math.hypot(c - (x1 + t * dx), r - (y1 + t * dy)));
}
if (d < 0.75 && chs.includes(grid[r][c])) grid[r][c] = grid[r][c].toUpperCase();
}
}
band([[9,4],[11,5],[11,7]], ["j","o"]); // Taihang
band([[6,9],[9,10],[11,11]], ["o","s","g"]); // Qinling west
band([[11,12],[13,12],[15,13]], ["s","u","n"]); // Funiu
band([[2,12],[4,13],[6,15]], ["g"]); // Daba/Shu west
band([[7,17],[8,20],[7,23]], ["g","n"]); // southern highlands
band([[1,1],[3,3],[5,5]], ["l"]); // Hexi mountains
band([[16,1],[17,3],[16,5]], ["y"]); // Yan mountains
band([[19,6],[21,8]], ["q"]); // Shandong hills
console.log(grid.map((row, r) => String(r).padStart(2) + " " + row.join("")).join("\n"));
// ---------- bake ----------
const CITIES = [
["ji","y",12,2],["beiping","y",15,2],
["ye","j",12,6],["nanpi","j",15,5],
["linzi","q",17,7],["beihai","q",21,8],
["puyang","a",12,9],["chenliu","a",14,10],
["luoyang","s",9,9],["hongnong","s",8,11],
["changan","o",7,8],["anding","o",5,6],
["wuwei","l",2,3],["tianshui","l",4,5],
["xiaopei","x",16,12],["xiapi","x",19,13],
["xuchang","u",14,12],["wan","u",12,14],["runan","u",15,14],
["shouchun","h",18,15],["jianye","h",21,14],["lujiang","h",18,17],["wu","h",21,17],
["xiangyang","n",10,14],["jiangling","n",11,17],["jiangxia","n",14,15],["changsha","n",11,20],
["hanzhong","g",4,11],["chengdu","g",3,15],["zitong","g",3,13],["jiangzhou","g",6,17],["yunnan","g",6,22],
];
function snap(prov, c, r) {
let best = null, bd = 1e9;
for (let rr = 0; rr < H; rr++) for (let cc = 0; cc < W; cc++) {
if (grid[rr][cc].toLowerCase() !== prov) continue;
const d = Math.hypot(cc - c, rr - r);
if (d < bd) { bd = d; best = [cc, rr]; }
}
return best;
}
console.log("\n=== CITY COORDS ===");
const used = new Set();
for (const [id, p, c, r] of CITIES) {
const [cc, rr] = snap(p, c, r);
if (!cc && cc !== 0) { console.log(`!! ${id} NO TILE for ${p}`); continue; }
if (used.has(cc+","+rr)) console.log(`!! overlap ${id} @${cc},${rr}`);
used.add(cc+","+rr);
console.log(`["${id}", "${cc}", "${rr}"],`);
}
const RIVERS = [
["Yellow River", [[5,6],[6,7],[7,8],[8,8],[9,9],[10,9],[11,8],[12,7],[13,7],[14,8],[16,8],[18,8],[20,8]]],
["Yangtze", [[3,15],[4,15],[6,15],[8,15],[10,15],[12,15],[13,15],[15,15],[17,16],[19,16],[21,16],[23,17]]],
["Han", [[9,12],[9,13],[10,14]]],
["Huai", [[14,13],[16,13],[18,14],[19,15]]],
];
console.log("\n=== RIVERS ===");
for (const [name, pts] of RIVERS) {
const out = pts.map(([c, r]) => {
// nearest land tile
let best = null, bd = 1e9;
for (let rr = 0; rr < H; rr++) for (let cc = 0; cc < W; cc++) {
if (grid[rr][cc] === ".") continue;
const d = Math.hypot(cc - c, rr - r);
if (d < bd) { bd = d; best = [cc, rr]; }
}
return best;
});
console.log(`{ name: "${name}", pts: [${out.map(([c,r])=>`[${c},${r}]`).join(",")}] },`);
}
console.log("\n=== MAP_ROWS ===");
console.log("export const MAP_ROWS = [");
for (const row of grid) console.log(` "${row.join("")}",`);
console.log("];");
+74
View File
@@ -0,0 +1,74 @@
// Headless smoke test: create a game, simulate many turns.
const shim = new Map();
globalThis.localStorage = {
getItem: k => shim.get(k) ?? null,
setItem: (k, v) => shim.set(k, String(v)),
removeItem: k => shim.delete(k),
get length() { return shim.size; },
key: i => [...shim.keys()][i] ?? null,
};
import { newGame, G, F, saveGame, loadGame, listSaves } from "../js/state.js";
import { endTurn, recruitToGarrison } from "../js/sim.js";
import { factionTroopCount } from "../js/state.js";
import { applyEffect } from "../js/events.js";
const opts = {
mode: "campaign", difficulty: "normal", playerType: "custom",
custom: { rulerName: "Minh", color: "#c2452d", emblem: "⚔", origin: "governor", cityId: "xuchang", factionName: "" },
};
let g = newGame(opts);
console.log("factions:", Object.keys(g.factions).length);
console.log("generals:", Object.keys(g.generals).length);
console.log("cities:", Object.keys(g.cities).length);
const pf = F("player");
console.log("player cities:", pf.cities, "gold:", pf.gold, "gens:", Object.values(g.generals).filter(x => x.faction === "player").map(x => x.name));
// sanity: leaders assigned
for (const f of Object.values(g.factions)) {
if (f.id === "neutral") continue;
if (!f.leader) console.log(`!! faction ${f.id} has no leader`);
}
// resolve any pending events each turn by choosing first option
function drainEvents() {
let guard = 0;
while (g.pendingEvents.length && guard++ < 50) {
const ev = g.pendingEvents.shift();
const choice = ev.choices[0];
try { applyEffect(choice.effect); } catch (e) { console.log("EFFECT ERR", ev.title, e.message); }
}
}
let battles = 0, errors = [];
for (let t = 0; t < 60; t++) {
try {
// simple player activity: recruit sometimes
if (t % 3 === 0) { const c = g.cities[pf.cities[0]]; if (c) recruitToGarrison(c, "spear", Math.min(c.levies, 200)); }
endTurn();
battles += g.replays.filter(r => r.rounds?.length || r.kind === "surrender").length;
drainEvents();
} catch (e) {
errors.push(`turn ${t}: ${e.stack.split("\n").slice(0, 3).join(" | ")}`);
break;
}
}
console.log("\nafter 60 turns => year", g.year, "/", g.month);
for (const f of Object.values(g.factions)) {
if (!f.alive) { console.log(`${f.name} destroyed`); continue; }
console.log(` ${f.name.padEnd(16)} cities:${String(f.cities.length).padStart(2)} gold:${String(f.gold).padStart(6)} food:${String(f.food).padStart(6)} troops:${factionTroopCount(f.id)} legit:${f.legitimacy} rank:${f.rank}`);
}
console.log("battles fought:", battles);
console.log("journal entries:", g.journal.length);
console.log("chronicle:", g.chronicle.slice(0, 6).map(c => `${c.y}/${c.m} ${c.text}`));
if (errors.length) { console.log("ERRORS:\n" + errors.join("\n")); process.exit(1); }
// save/load roundtrip
saveGame("test");
const saves = listSaves();
console.log("saves:", saves.map(s => s.slot));
const loaded = loadGame("test");
console.log("loaded ok:", loaded.year === g.year && loaded.playerFaction === "player");
// battle sanity
console.log("\nSMOKE TEST PASSED");
+100
View File
@@ -0,0 +1,100 @@
// Headless stress test: long runs, aggressive player, faction start, save/load.
import { newGame, G, F, saveGame, loadGame, listSaves, factionTroopCount } from "../js/state.js";
import { endTurn, recruitToGarrison, moveArmy, raiseArmyFromCity } from "../js/sim.js";
import { applyEffect } from "../js/events.js";
// localStorage shim
const store = new Map();
globalThis.localStorage = {
getItem: k => store.get(k) ?? null,
setItem: (k, v) => store.set(k, String(v)),
removeItem: k => store.delete(k),
key: i => [...store.keys()][i] ?? null,
get length() { return store.size; },
};
function drainEvents(choose = 0) {
let guard = 0;
while (G.pendingEvents.length && guard++ < 80) {
const ev = G.pendingEvents.shift();
const choice = ev.choices[Math.min(choose, ev.choices.length - 1)];
try { applyEffect(choice.effect); } catch (e) { console.log("EFFECT ERR", ev.title, e.message); }
}
}
function run(label, opts, turns, playerBehavior) {
newGame(opts);
let battles = 0, sieges = 0, errors = [];
for (let t = 0; t < turns; t++) {
try {
playerBehavior?.(t);
endTurn();
battles += G.replays.filter(r => r.kind === "field").length;
sieges += G.replays.filter(r => r.kind === "siege" || r.kind === "surrender").length;
drainEvents(t % 4);
if (G.gameOver) break;
} catch (e) {
errors.push(`turn ${t}: ${e.stack.split("\n").slice(0, 4).join(" | ")}`);
break;
}
}
console.log(`\n== ${label} == turns:${G.turnCount} over:${G.gameOver}(${G.victory ?? "-"}) battles:${battles} sieges:${sieges} errors:${errors.length}`);
const top = Object.values(G.factions).filter(f => f.alive && f.id !== "neutral").sort((a, b) => b.cities.length - a.cities.length).slice(0, 5);
console.log("top:", top.map(f => `${f.name}:${f.cities.length}`).join(", "));
if (errors.length) console.log(errors.join("\n"));
return errors;
}
const custom = { mode: "campaign", difficulty: "normal", playerType: "custom", custom: { rulerName: "Minh", color: "#c2452d", emblem: "⚔", origin: "governor", cityId: "xuchang", factionName: "" } };
let errs = [];
errs += run("passive 150t", custom, 150);
// aggressive player: raise army & attack neighbors
errs += run("aggressive 100t", { ...custom }, 100, (t) => {
const pf = F(G.playerFaction);
const capCity = G.cities[pf.capital];
// keep garrison fed
if (capCity && capCity.levies > 300) recruitToGarrison(capCity, "spear", Math.min(capCity.levies, 400));
// raise army with best idle general
const idle = Object.values(G.generals).filter(g => g.alive && g.faction === pf.id && !g.isLeader &&
typeof g.location === "string" && G.cities[g.location]?.owner === pf.id &&
!Object.values(G.armies).some(a => a.genId === g.id));
if (idle.length && capCity && totalTroopsLocal(capCity.garrison) > 1600) {
const troops = {};
let rem = Math.floor(totalTroopsLocal(capCity.garrison) * 0.7);
for (const tt of ["cav", "bow", "sword", "spear"]) {
const take = Math.min(capCity.garrison[tt] || 0, rem); if (take > 0) { troops[tt] = take; rem -= take; }
}
raiseArmyFromCity(capCity, idle[0].id, troops);
}
// move armies toward adjacent hostile/neutral provinces
for (const army of Object.values(G.armies)) {
if (army.faction !== pf.id || army.moved) continue;
const nbrs = provNbrsLocal(army.prov);
for (const n of nbrs) {
const res = moveArmy(army, n, { attackNeutral: true });
if (res.ok) break;
}
}
});
// faction start
errs += run("cao-campaign 120t", { mode: "campaign", difficulty: "hard", playerType: "faction", factionId: "cao" }, 120);
// challenge
errs += run("challenge 12t", { ...custom, mode: "challenge" }, 12);
// save / load roundtrip mid-game
newGame(custom);
for (let t = 0; t < 10; t++) { endTurn(); drainEvents(); }
saveGame("mid");
const ok = loadGame("mid");
console.log("\nsave/load roundtrip:", ok ? `ok (year ${ok.year})` : "FAILED");
if (!ok) errs.push("save/load failed");
function totalTroopsLocal(troops) { return Object.values(troops || {}).reduce((s, v) => s + v, 0); }
import { PROV_ADJ } from "../js/world.js";
function provNbrsLocal(p) { return [...PROV_ADJ[p]]; }
console.log(errs.length ? "\nSTRESS FAILED" : "\nSTRESS PASSED");
process.exit(errs.length ? 1 : 0);