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