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:
+294
@@ -0,0 +1,294 @@
|
||||
// ============================================================
|
||||
// BATTLE SIMULATION — pure round-based tactical resolution.
|
||||
// Produces a replayable record consumed by the battle scene.
|
||||
// ============================================================
|
||||
import { UNIT_TYPES, COUNTER, FORMATIONS } from "./data.js";
|
||||
import { rand, randInt, clamp, chance } from "./state.js";
|
||||
|
||||
export const TERRAIN_MODS = {
|
||||
plains: { name: "Plains", icon: "🌾", cav: 1.2, bow: 1.1, spear: 1.0 },
|
||||
hills: { name: "Hills", icon: "⛰", cav: 0.9, bow: 1.05, spear: 1.05 },
|
||||
mountain: { name: "Mountains",icon: "🏔", cav: 0.6, bow: 0.9, spear: 1.15, defBonus: 1.25 },
|
||||
river: { name: "Riverbank",icon: "🌊", cav: 0.75, bow: 1.0, spear: 0.95 },
|
||||
city: { name: "City Streets", icon: "🏯", cav: 0.7, bow: 0.85, spear: 1.1 },
|
||||
};
|
||||
|
||||
const TYPES = Object.keys(UNIT_TYPES);
|
||||
|
||||
function troopCount(troops) { return TYPES.reduce((s, t) => s + (troops[t] || 0), 0); }
|
||||
|
||||
function composition(troops) {
|
||||
const total = Math.max(1, troopCount(troops));
|
||||
const comp = {};
|
||||
for (const t of TYPES) comp[t] = (troops[t] || 0) / total;
|
||||
return comp;
|
||||
}
|
||||
|
||||
// weighted counter multiplier of my composition vs theirs
|
||||
function counterMul(myComp, theirComp, form) {
|
||||
let mul = 1;
|
||||
for (const mt of TYPES) {
|
||||
let vs = 0;
|
||||
for (const tt of TYPES) vs += (COUNTER[mt]?.[tt] ?? 1) * theirComp[tt];
|
||||
mul += myComp[mt] * (vs - 1);
|
||||
}
|
||||
// formation interactions
|
||||
if (form === "spearwall") mul *= 1.15; // anti-cav baked into stance below too
|
||||
return mul;
|
||||
}
|
||||
|
||||
function powerOf(side, otherSide, round, terrainKey) {
|
||||
const { troops, gen, formation, stance } = side;
|
||||
const total = troopCount(troops);
|
||||
if (total <= 0) return 0;
|
||||
const tm = TERRAIN_MODS[terrainKey] || TERRAIN_MODS.plains;
|
||||
const myComp = composition(troops);
|
||||
const theirComp = composition(otherSide.troops);
|
||||
let pow = 0;
|
||||
for (const t of TYPES) {
|
||||
const n = troops[t] || 0;
|
||||
if (!n) continue;
|
||||
const ut = UNIT_TYPES[t];
|
||||
let m = ut.atk / 25;
|
||||
if (t === "cav" || t === "hcav") m *= tm.cav;
|
||||
if (t === "bow" || t === "xb") m *= tm.bow;
|
||||
if (t === "spear") m *= tm.spear;
|
||||
pow += (n / 100) * ut.atk * 0.5 * m;
|
||||
}
|
||||
pow *= counterMul(myComp, theirComp, formation);
|
||||
|
||||
// formation mods
|
||||
const fm = FORMATIONS[formation]?.mods || {};
|
||||
pow *= fm.atkMul || 1;
|
||||
if (fm.burst && round <= 3) pow *= fm.burst;
|
||||
if (fm.ramp) pow *= Math.pow(fm.ramp, round);
|
||||
if (fm.ranged && (myComp.bow + myComp.xb) > 0.3) pow *= fm.ranged;
|
||||
|
||||
// stance
|
||||
pow *= stance === "assault" ? 1.15 : stance === "defensive" ? 0.85 : 1;
|
||||
|
||||
// commander
|
||||
if (gen) {
|
||||
pow *= 1 + (gen.st.ldr * 0.7 + gen.st.war * 0.3) / 300;
|
||||
if (gen.traits.includes("brave")) pow *= 1.06;
|
||||
if (gen.traits.includes("vanguard")) pow *= round <= 2 ? 1.18 : 1.03;
|
||||
if (gen.traits.includes("impulsive")) pow *= 0.88 + rand() * 0.3;
|
||||
if (gen.wounded > 0) pow *= 0.8;
|
||||
}
|
||||
// fatigue
|
||||
pow *= Math.max(0.55, 1 - round * 0.02);
|
||||
return pow;
|
||||
}
|
||||
|
||||
export function simulateBattle(cfg) {
|
||||
const atk = { ...cfg.atk, morale0: startMorale(cfg.atk.gen), buffs: {}, burn: 0 };
|
||||
const def = { ...cfg.def, morale0: startMorale(cfg.def.gen, cfg.def.walls || 0), buffs: {}, burn: 0 };
|
||||
const terrainKey = cfg.terrain || "plains";
|
||||
const rounds = [];
|
||||
let winner = null;
|
||||
let aMorale = atk.morale0, dMorale = def.morale0;
|
||||
let aTroops = { ...atk.troops }, dTroops = { ...def.troops };
|
||||
let skillCdA = randInt(1, 2), skillCdD = randInt(1, 2);
|
||||
const eventsAll = [];
|
||||
|
||||
const S = {
|
||||
atk: { gen: atk.gen, name: atk.name, faction: atk.faction, formation: atk.formation, stance: atk.stance },
|
||||
def: { gen: def.gen, name: def.name, faction: def.faction, formation: def.formation, stance: def.stance },
|
||||
};
|
||||
|
||||
for (let round = 1; round <= 24 && !winner; round++) {
|
||||
const events = [];
|
||||
// --- hero skills ---
|
||||
if (atk.gen && skillCdA <= 0 && chance(0.16 + atk.gen.st.cha / 500)) {
|
||||
const ev = triggerSkill(atk, def, aTroops, dTroops, () => aMorale, terrainKey);
|
||||
events.push(ev); eventsAll.push({ round, side: "atk", ...ev });
|
||||
aMorale = ev.moraleSelf != null ? ev.moraleSelf : aMorale;
|
||||
dMorale += (ev.moraleHit || 0);
|
||||
if (ev.duelResult != null) {
|
||||
if (ev.duelResult === "win") dMorale -= 28; else aMorale -= 14;
|
||||
}
|
||||
skillCdA = 4;
|
||||
}
|
||||
if (def.gen && skillCdD <= 0 && chance(0.13 + def.gen.st.cha / 550)) {
|
||||
const ev = triggerSkill(def, atk, dTroops, aTroops, () => dMorale, terrainKey);
|
||||
ev.side = "def"; events.push(ev); eventsAll.push({ round, side: "def", ...ev });
|
||||
dMorale = ev.moraleSelf != null ? ev.moraleSelf : dMorale;
|
||||
aMorale += (ev.moraleHit || 0);
|
||||
if (ev.duelResult != null) {
|
||||
if (ev.duelResult === "win") aMorale -= 28; else dMorale -= 14;
|
||||
}
|
||||
skillCdD = 4;
|
||||
}
|
||||
skillCdA--; skillCdD--;
|
||||
|
||||
// --- combat power ---
|
||||
const A = { troops: aTroops, gen: atk.gen, formation: atk.formation, stance: atk.stance };
|
||||
const Df = { troops: dTroops, gen: def.gen, formation: def.formation, stance: def.stance };
|
||||
const wallMul = 1 + (cfg.def.walls || 0) * 0.17;
|
||||
let rawA = powerOf(A, Df, round, terrainKey) * (0.88 + rand() * 0.24);
|
||||
let rawD = powerOf(Df, A, round, terrainKey) * wallMul * (0.88 + rand() * 0.24);
|
||||
|
||||
// defensive formations
|
||||
const dfm = FORMATIONS[def.formation]?.mods || {};
|
||||
const afm = FORMATIONS[atk.formation]?.mods || {};
|
||||
rawD *= dfm.defMul || 1;
|
||||
rawA *= afm.defMul || 1;
|
||||
if ((afm.antiCav || 0) > 0) {
|
||||
const cavShare = (dTroops.cav || 0) + (dTroops.hcav || 0);
|
||||
rawA *= 1 + Math.min(0.35, (cavShare / Math.max(1, troopCount(dTroops))) * afm.antiCav * 0.5);
|
||||
}
|
||||
|
||||
// burns from fire skills
|
||||
if (atk.burn > 0) { rawA *= 1 + atk.burn; atk.burn = Math.max(0, atk.burn - 0.5); }
|
||||
if (def.burn > 0) { rawD *= 1 + def.burn; def.burn = Math.max(0, def.burn - 0.5); }
|
||||
|
||||
// convert to casualties (kills scale ~ dmg/45)
|
||||
const aLossN = Math.round(rawD / 42 * (10 + randInt(0, 6)));
|
||||
const dLossN = Math.round(rawA / 42 * (10 + randInt(0, 6)));
|
||||
|
||||
const aBefore = troopCount(aTroops), dBefore = troopCount(dTroops);
|
||||
applyLosses(aTroops, aLossN);
|
||||
applyLosses(dTroops, dLossN);
|
||||
const aAfter = troopCount(aTroops), dAfter = troopCount(dTroops);
|
||||
|
||||
// morale swings
|
||||
const aLossPct = aBefore ? (aBefore - aAfter) / aBefore : 0;
|
||||
const dLossPct = dBefore ? (dBefore - dAfter) / dBefore : 0;
|
||||
aMorale += dLossPct * 160 - aLossPct * 200;
|
||||
dMorale += aLossPct * 160 - dLossPct * 200;
|
||||
// defensive stances bleed less morale
|
||||
if (atk.stance === "defensive") aMorale += aLossPct * 40;
|
||||
if (def.stance === "defensive") dMorale += dLossPct * 40;
|
||||
// ironwill / cautious
|
||||
if (atk.gen?.traits.includes("ironwill")) aMorale += 1.5;
|
||||
if (def.gen?.traits.includes("ironwill")) dMorale += 1.5;
|
||||
|
||||
rounds.push({
|
||||
round,
|
||||
attLoss: aBefore - aAfter, defLoss: dBefore - dAfter,
|
||||
attMorale: Math.round(clamp(aMorale, 0, 120)), defMorale: Math.round(clamp(dMorale, 0, 120)),
|
||||
attTroops: aAfter, defTroops: dAfter,
|
||||
events,
|
||||
});
|
||||
|
||||
if (dAfter <= 0 || dMorale <= 0) { winner = "atk"; if (dAfter > 0) rounds[rounds.length - 1].rout = "def"; }
|
||||
else if (aAfter <= 0 || aMorale <= 0) { winner = "def"; if (aAfter > 0) rounds[rounds.length - 1].rout = "atk"; }
|
||||
}
|
||||
if (!winner) winner = troopCount(aTroops) >= troopCount(dTroops) ? "atk" : "def";
|
||||
|
||||
// rout extra losses
|
||||
const lastRound = rounds[rounds.length - 1];
|
||||
if (lastRound?.rout === "def") applyLosses(dTroops, Math.round(troopCount(dTroops) * (0.12 + rand() * 0.15)));
|
||||
if (lastRound?.rout === "atk") applyLosses(aTroops, Math.round(troopCount(aTroops) * (0.12 + rand() * 0.15)));
|
||||
|
||||
// fate of defeated commanders handled by caller using these hints
|
||||
const loserGen = winner === "atk" ? def.gen : atk.gen;
|
||||
let captureChance = 0.32, killChance = 0.07;
|
||||
if (loserGen) {
|
||||
if (loserGen.traits.includes("ironwill")) killChance -= 0.04;
|
||||
if (loserGen.st.war > 90) captureChance -= 0.1; // fights free
|
||||
if (cfg.surrender) captureChance = 0.55;
|
||||
}
|
||||
|
||||
return {
|
||||
winner, terrain: terrainKey,
|
||||
rounds, eventsAll,
|
||||
atkTroopsLeft: aTroops, defTroopsLeft: dTroops,
|
||||
atkLost: cfg.atk.troops ? troopCount(cfg.atk.troops) - troopCount(aTroops) : 0,
|
||||
defLost: cfg.def.troops ? troopCount(cfg.def.troops) - troopCount(dTroops) : 0,
|
||||
captureChance, killChance,
|
||||
meta: { atkName: atk.name, defName: def.name, atkFaction: atk.faction, defFaction: def.faction, walls: cfg.def.walls || 0 },
|
||||
};
|
||||
}
|
||||
|
||||
function startMorale(gen, walls = 0) {
|
||||
let m = 62 + (gen ? gen.st.ldr / 5 : 0) + walls * 5;
|
||||
if (gen?.traits.includes("charismatic")) m += 8;
|
||||
return clamp(m + randInt(-6, 6), 30, 110);
|
||||
}
|
||||
|
||||
function applyLosses(troops, loss) {
|
||||
const types = TYPES.filter(t => (troops[t] || 0) > 0);
|
||||
const total = troopCount(troops);
|
||||
if (!total || loss <= 0) return;
|
||||
let remaining = Math.min(loss, total);
|
||||
// elite units slightly more resilient
|
||||
const weight = t => t === "hcav" ? 0.7 : t === "cav" ? 0.9 : 1;
|
||||
const wsum = types.reduce((s, t) => s + weight(t) * troops[t], 0);
|
||||
for (const t of types) {
|
||||
const share = Math.round(remaining * (weight(t) * troops[t]) / wsum);
|
||||
troops[t] = Math.max(0, troops[t] - share);
|
||||
}
|
||||
// rounding remainder off the biggest unit
|
||||
let diff = troopCount(troops) - (total - remaining);
|
||||
if (diff !== 0) {
|
||||
const big = types.sort((a, b) => troops[b] - troops[a])[0];
|
||||
troops[big] = Math.max(0, troops[big] + diff);
|
||||
}
|
||||
}
|
||||
|
||||
function triggerSkill(side, enemy, myTroops, enemyTroops, getMorale, terrainKey) {
|
||||
const gen = side.gen;
|
||||
const sk = gen.skill || "rally";
|
||||
const base = {
|
||||
type: "skill", skillId: sk, who: gen.name, text: "",
|
||||
};
|
||||
switch (sk) {
|
||||
case "greendragon": case "dragonspear": case "weststorm": case "littleconq": case "chargecall": {
|
||||
const pow = skillPow(gen, 1.6);
|
||||
base.effect = { kind: "charge", dmg: Math.round(troopCount(enemyTroops) * 0.06 * pow) };
|
||||
base.text = `${gen.name} rides down the foe — the line buckles!`;
|
||||
break;
|
||||
}
|
||||
case "volley": case "divinearchery": case "fallingstar": {
|
||||
const pow = skillPow(gen, 1.5);
|
||||
base.effect = { kind: "volley", dmg: Math.round(troopCount(enemyTroops) * 0.05 * pow) };
|
||||
base.text = `Arrows rise like rain at ${gen.name}'s command.`;
|
||||
break;
|
||||
}
|
||||
case "tigerroar": case "thunder": case "vengeance": case "rally": {
|
||||
const pow = sk === "rally" ? 14 : 24;
|
||||
base.effect = { kind: "morale" };
|
||||
base.moraleSelf = clamp(getMorale() + pow + gen.st.cha / 10, 0, 115);
|
||||
base.moraleHit = -(pow / 2);
|
||||
base.text = sk === "rally" ? `${gen.name} steadies the line — banners high!` : `${gen.name}'s roar echoes — the enemy falters!`;
|
||||
break;
|
||||
}
|
||||
case "fireattack": case "ambush": case "ruthlessscheme": {
|
||||
base.effect = { kind: "fire", burn: 0.35 * skillPow(gen, 1.3) };
|
||||
base.text = sk === "ambush" ? `Horns from the flanks — it was a trap!` : `Fire takes the dry grass and spreads along the lines!`;
|
||||
break;
|
||||
}
|
||||
case "skypiercer": case "madox": {
|
||||
const myWar = gen.st.war + randInt(0, 20);
|
||||
const foe = enemy.gen ? enemy.gen.st.war + randInt(0, 20) : 50;
|
||||
base.effect = { kind: "duel", dmg: myWar > foe ? Math.round(troopCount(enemyTroops) * 0.05) : 0 };
|
||||
base.duelResult = myWar > foe ? "win" : "lose";
|
||||
base.text = myWar > foe
|
||||
? `${gen.name} seeks out the enemy champion — and cuts him down before the armies!`
|
||||
: `${gen.name} charges the enemy champion — the duel is fierce, and he gives ground!`;
|
||||
break;
|
||||
}
|
||||
case "eightform": case "patience": case "ironguard": {
|
||||
base.effect = { kind: "guard", mult: 0.7 };
|
||||
base.guardNext = true;
|
||||
base.moraleSelf = clamp(getMorale() + 8, 0, 115);
|
||||
base.text = `${gen.name} sets the formation — an unbreakable wall.`;
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
base.effect = { kind: "morale" };
|
||||
base.moraleSelf = clamp(getMorale() + 10, 0, 115);
|
||||
base.text = `${gen.name} rallies the troops!`;
|
||||
}
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
function skillPow(gen, base) {
|
||||
let p = base * (0.8 + gen.st.war / 250);
|
||||
if (gen.traits.includes("genius")) p *= 1.2;
|
||||
return p;
|
||||
}
|
||||
|
||||
export { troopCount };
|
||||
Reference in New Issue
Block a user