Files
warlords-fate/js/battlesim.js
T
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

295 lines
12 KiB
JavaScript

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