"use strict";
class Battle {
constructor(attacker, defender) {
if (customization) return;
closeDialogs(".stable");
customization = 13; // enter customization to avoid unwanted dialog closing
Battle.prototype.context = this; // store context
this.x = defender.x;
this.y = defender.y;
this.name = this.getBattleName();
this.iteration = 0;
this.attackers = {regiments:[], distances:[], morale:100, casualties:0};
this.defenders = {regiments:[], distances:[], morale:100, casualties:0};
this.addHeaders();
this.addRegiment("attackers", attacker);
this.addRegiment("defenders", defender);
this.randomize();
this.calculateStrength("attackers");
this.calculateStrength("defenders");
this.getInitialMorale();
$("#battleScreen").dialog({
title: this.name, resizable: false, width: fitContent(),
position: {my: "center", at: "center", of: "#map"},
close: () => Battle.prototype.context.cancelResults()
});
if (modules.Battle) return;
modules.Battle = true;
// add listeners
document.getElementById("battleAddRegiment").addEventListener("click", this.addSide);
document.getElementById("battleRoll").addEventListener("click", () => Battle.prototype.context.randomize());
document.getElementById("battleRun").addEventListener("click", () => Battle.prototype.context.run());
document.getElementById("battleApply").addEventListener("click", () => Battle.prototype.context.applyResults());
document.getElementById("battleCancel").addEventListener("click", () => Battle.prototype.context.cancelResults());
document.getElementById("battlePhase_attackers").addEventListener("click", ev => this.toggleChangePhase(ev, "attackers"));
document.getElementById("battlePhase_attackers").nextElementSibling.addEventListener("click", ev => Battle.prototype.context.changePhase(ev, "attackers"));
document.getElementById("battlePhase_defenders").addEventListener("click", ev => this.toggleChangePhase(ev, "defenders"));
document.getElementById("battlePhase_defenders").nextElementSibling.addEventListener("click", ev => Battle.prototype.context.changePhase(ev, "defenders"));
document.getElementById("battleDie_attackers").addEventListener("click", () => Battle.prototype.context.rollDie("attackers"));
document.getElementById("battleDie_defenders").addEventListener("click", () => Battle.prototype.context.rollDie("defenders"));
}
getBattleName() {
const cell = findCell(this.x, this.y);
const burg = pack.cells.burg[cell] ? pack.burgs[pack.cells.burg[cell]].name : null;
return burg ? burg + " Battle" : Names.getCulture(pack.cells.culture[cell]) + " Battle"
}
addHeaders() {
let headers = " | | ";
for (const u of options.military) {
const label = capitalize(u.name.replace(/_/g, ' '));
headers += `${u.icon} | `;
}
headers += "Total |
";
battleAttackers.innerHTML = battleDefenders.innerHTML = headers;
}
addRegiment(side, regiment) {
regiment.casualties = Object.keys(regiment.u).reduce((a,b) => (a[b]=0,a), {});
regiment.survivors = Object.assign({}, regiment.u);
const state = pack.states[regiment.state];
const distance = Math.hypot(this.y-regiment.by, this.x-regiment.bx) * distanceScaleInput.value | 0; // distance between regiment and its base
const color = state.color[0] === "#" ? state.color : "#999";
const icon = ``;
const body = `
`;
let initial = `| ${icon} | ${regiment.name.slice(0, 24)} | `;
let casualties = `
| ${state.fullName.slice(0, 26)} | `;
let survivors = `
| Distance to base: ${distance} ${distanceUnitInput.value} | `;
for (const u of options.military) {
initial += `${regiment.u[u.name]||0} | `;
casualties += `0 | `;
survivors += `${regiment.u[u.name]||0} | `;
}
initial += `${regiment.a||0} |
`;
casualties += `0 | `;
survivors += `${regiment.a||0} | `;
const div = side === "attackers" ? battleAttackers : battleDefenders;
div.innerHTML += body + initial + casualties + survivors + "";
this[side].regiments.push(regiment);
this[side].distances.push(distance);
}
addSide() {
const body = document.getElementById("regimentSelectorBody");
const context = Battle.prototype.context;
const regiments = pack.states.filter(s => s.military && !s.removed).map(s => s.military).flat();
const distance = reg => rn(Math.hypot(context.y-reg.y, context.x-reg.x) * distanceScaleInput.value) + " " + distanceUnitInput.value;
const isAdded = reg => context.defenders.regiments.some(r => r === reg) || context.attackers.regiments.some(r => r === reg);
body.innerHTML = regiments.map(r => {
const s = pack.states[r.state], added = isAdded(r), dist = added ? "0 " + distanceUnitInput.value : distance(r);
return `
${s.name.slice(0, 11)}
${r.icon}
${r.name.slice(0, 24)}
${r.a}
${dist}
`;
}).join("");
$("#regimentSelectorScreen").dialog({
resizable: false, width: fitContent(), title: "Add regiment to the battle",
position: {my: "left center", at: "right+10 center", of: "#battleScreen"}, close: addSideClosed,
buttons: {
"Add to attackers": () => addSideClicked("attackers"),
"Add to defenders": () => addSideClicked("defenders"),
Cancel: () => $("#regimentSelectorScreen").dialog("close")
}
});
applySorting(regimentSelectorHeader);
body.addEventListener("click", selectLine);
function selectLine(ev) {
if (ev.target.className === "inactive") {tip("Regiment is already in the battle", false, "error"); return};
ev.target.classList.toggle("selected");
}
function addSideClicked(side) {
const selected = body.querySelectorAll(".selected");
if (!selected.length) {tip("Please select a regiment first", false, "error"); return}
$("#regimentSelectorScreen").dialog("close");
selected.forEach(line => {
const state = pack.states[line.dataset.s];
const regiment = state.military.find(r => r.i == +line.dataset.i);
Battle.prototype.addRegiment.call(context, side, regiment);
Battle.prototype.calculateStrength.call(context, side);
Battle.prototype.getInitialMorale.call(context);
// move regiment
const defenders = context.defenders.regiments, attackers = context.attackers.regiments;
const shift = side === "attackers" ? attackers.length * -8 : (defenders.length-1) * 8;
regiment.px = regiment.x;
regiment.py = regiment.y;
Military.moveRegiment(regiment, defenders[0].x, defenders[0].y + shift);
});
}
function addSideClosed() {
body.innerHTML = "";
body.removeEventListener("click", selectLine);
}
}
getJoinedForces(regiments) {
return regiments.reduce((a, b) => {
for (let k in b.survivors) {
if (!b.survivors.hasOwnProperty(k)) continue;
a[k] = (a[k] || 0) + b.survivors[k];
}
return a;
}, {});
}
calculateStrength(side) {
const scheme = {
"skirmish": {"melee":.2, "ranged":2.4, "mounted":.1, "machinery":3, "naval":1, "armored":.2, "aviation":1.8, "magical":1.8}, // ranged excel
"melee": {"melee":2, "ranged":1.2, "mounted":1.5, "machinery":.5, "naval":.2, "armored":2, "aviation":.8, "magical":.8}, // melee excel
"pursue": {"melee":1, "ranged":1, "mounted":4, "machinery":.05, "naval":1, "armored":1, "aviation":1.5, "magical":.6}, // mounted excel
"retreat": {"melee":.1, "ranged":.01, "mounted":.5, "machinery":.01, "naval":.2, "armored":.1, "aviation":.8, "magical":.05} // mounted excel
};
const forces = this.getJoinedForces(this[side].regiments);
const phase = this[side].phase;
const adjuster = populationRate.value / 10; // population adjuster, by default 100
this[side].power = d3.sum(options.military.map(u => (forces[u.name] || 0) * u.power * scheme[phase][u.type])) / adjuster;
const UIvalue = this[side].power ? Math.max(this[side].power|0, 1) : 0;
document.getElementById("battlePower_"+side).innerHTML = UIvalue;
}
getInitialMorale() {
const powerFee = diff => Math.min(Math.max(100 - diff ** 1.5 * 10 + 10, 50), 100);
const distanceFee = dist => Math.min(d3.mean(dist) / 50, 15);
const powerDiff = this.defenders.power / this.attackers.power;
this.attackers.morale = powerFee(powerDiff) - distanceFee(this.attackers.distances);
this.defenders.morale = powerFee(1 / powerDiff) - distanceFee(this.defenders.distances);
this.updateMorale("attackers");
this.updateMorale("defenders");
}
updateMorale(side) {
const morale = document.getElementById("battleMorale_"+side);
morale.dataset.tip = morale.dataset.tip.replace(morale.value, "");
morale.value = this[side].morale | 0;
morale.dataset.tip += morale.value;
}
randomize() {
this.rollDie("attackers");
this.rollDie("defenders");
this.selectPhase();
this.calculateStrength("attackers");
this.calculateStrength("defenders");
}
rollDie(side) {
const el = document.getElementById("battleDie_"+side);
const prev = +el.innerHTML;
do {el.innerHTML = rand(1, 6)} while (el.innerHTML === prev)
this[side].die = +el.innerHTML;
}
selectPhase() {
const phase = this.getPhase();
this.attackers.phase = phase[0];
this.defenders.phase = phase[1];
document.getElementById("battlePhase_attackers").className = "icon-button-" + this.attackers.phase;
document.getElementById("battlePhase_defenders").className = "icon-button-" + this.defenders.phase;
}
getPhase() {
const i = this.iteration;
const prev = [this.attackers.phase || "skirmish", this.defenders.phase || "skirmish"]; // previous phase
const morale = [this.attackers.morale, this.defenders.morale];
if (P(1 - morale[0] / 25)) return ["retreat", "pursue"];
if (P(1 - morale[1] / 25)) return ["pursue", "retreat"];
if (prev[0] === "skirmish" && prev[1] === "skirmish") {
const forces = this.getJoinedForces(this.attackers.regiments.concat(this.defenders.regiments));
const total = d3.sum(Object.values(forces)); // total forces
const ranged = d3.sum(options.military.filter(u => u.type === "ranged").map(u => u.name).map(u => forces[u])) / total;
if (ranged && (P(ranged) || P(.8-i/10))) return ["skirmish", "skirmish"];
}
return ["melee", "melee"]; // default option
}
run() {
// validations
if (!this.attackers.power) {tip("Attackers army destroyed", false, "warn"); return}
if (!this.defenders.power) {tip("Defenders army destroyed", false, "warn"); return}
// calculate casualties
const attack = this.attackers.power * (this.attackers.die / 10 + .4);
const defence = this.defenders.power * (this.defenders.die / 10 + .4);
const phase = {"skirmish":.1, "melee":.2, "pursue":.3, "retreat":.3}; // casualties modifier for phase
const casualties = Math.random() * phase[this.attackers.phase]; // total casualties, ~10% per iteration
const casualtiesA = casualties * defence / (attack + defence); // attackers casualties, ~5% per iteration
const casualtiesD = casualties * attack / (attack + defence); // defenders casualties, ~5% per iteration
this.calculateCasualties("attackers", casualtiesA);
this.calculateCasualties("defenders", casualtiesD);
this.attackers.casualties += casualtiesA;
this.defenders.casualties += casualtiesD;
// change morale
this.attackers.morale = Math.max(this.attackers.morale - casualtiesA * 100, 0);
this.defenders.morale = Math.max(this.defenders.morale - casualtiesD * 100, 0);
// update table values
this.updateTable("attackers");
this.updateTable("defenders");
// prepare for next iteration
this.iteration += 1;
this.selectPhase();
this.calculateStrength("attackers");
this.calculateStrength("defenders");
}
calculateCasualties(side, casualties) {
for (const r of this[side].regiments) {
for (const unit in r.u) {
const rand = .8 + Math.random() * .4;
const died = Math.min(Pint(r.u[unit] * casualties * rand), r.survivors[unit]);
r.casualties[unit] -= died;
r.survivors[unit] -= died;
}
}
}
updateTable(side) {
for (const r of this[side].regiments) {
const tbody = document.getElementById("battle" + r.state + "-" + r.i);
const battleCasualties = tbody.querySelector(".battleCasualties");
const battleSurvivors = tbody.querySelector(".battleSurvivors");
let index = 3; // index to find table element easily
for (const u of options.military) {
battleCasualties.querySelector(`td:nth-child(${index})`).innerHTML = r.casualties[u.name] || 0;
battleSurvivors.querySelector(`td:nth-child(${index})`).innerHTML = r.survivors[u.name] || 0;
index++;
}
battleCasualties.querySelector(`td:nth-child(${index})`).innerHTML = d3.sum(Object.values(r.casualties));
battleSurvivors.querySelector(`td:nth-child(${index})`).innerHTML = d3.sum(Object.values(r.survivors));
}
this.updateMorale(side);
}
toggleChangePhase(ev, side) {
ev.stopPropagation();
const button = document.getElementById("battlePhase_"+side);
const div = button.nextElementSibling;
button.style.opacity = .5;
div.style.display = "block";
const hideSection = function() {button.style.opacity = 1; div.style.display = "none"}
document.getElementsByTagName("body")[0].addEventListener("click", hideSection, {once: true});
}
changePhase(ev, side) {
if (ev.target.tagName !== "BUTTON") return;
const phase = this[side].phase = ev.target.dataset.phase;
const button = document.getElementById("battlePhase_"+side);
button.className = "icon-button-" + phase;
this.calculateStrength(side);
}
applyResults() {
const battleName = this.name;
const maxCasualties = Math.max(this.attackers.casualties, this.attackers.casualties);
const relativeCasualties = this.defenders.casualties / (this.attackers.casualties + this.attackers.casualties);
const battleStatus = getBattleStatus(relativeCasualties, maxCasualties);
function getBattleStatus(relative, max) {
if (isNaN(relative)) return ["standoff", "standoff"]; // if no casualties at all
if (max < .05) return ["minor skirmishes", "minor skirmishes"];
if (relative > 95) return ["attackers flawless victory", "disorderly retreat of defenders"];
if (relative > .7) return ["attackers decisive victory", "defenders disastrous defeat"];
if (relative > .6) return ["attackers victory", "defenders defeat"];
if (relative > .4) return ["stalemate", "stalemate"];
if (relative > .3) return ["attackers defeat", "defenders victory"];
if (relative > 0.5) return ["attackers disastrous defeat", "decisive victory of defenders"];
if (relative >= 0) return ["attackers disorderly retreat", "flawless victory of defenders"];
return ["stalemate", "stalemate"]; // exception
}
this.attackers.regiments.forEach(r => applyResultForSide(r, "attackers"));
this.defenders.regiments.forEach(r => applyResultForSide(r, "defenders"));
function applyResultForSide(r, side) {
const id = "regiment" + r.state + "-" + r.i;
// add result to regiment note
const note = notes.find(n => n.id === id);
if (note) {
const status = side === "attackers" ? battleStatus[0] : battleStatus[1];
const losses = r.a ? Math.abs(d3.sum(Object.values(r.casualties))) / r.a : 1;
const regStatus =
losses === 1 ? "is destroyed" :
losses > .8 ? "is almost completely destroyed" :
losses > .5 ? "suffered terrible losses" :
losses > .3 ? "suffered severe losses" :
losses > .2 ? "suffered heavy losses" :
losses > .05 ? "suffered significant losses" :
losses > 0 ? "suffered unsignificant losses" :
"left the battle without loss";
const casualties = Object.keys(r.casualties).map(t => r.casualties[t] ? `${Math.abs(r.casualties[t])} ${t}` : null).filter(c => c).join(", ");
const casualtiesText = casualties ? " Casualties: " + casualties : "";
const legend = `\r\n\r\n${battleName} (${options.year} ${options.eraShort}): ${status}. The regiment ${regStatus}.${casualtiesText}`;
note.legend += legend;
}
r.u = Object.assign({}, r.survivors);
r.a = d3.sum(Object.values(r.u)); // reg total
armies.select(`g#${id} > text`).text(Military.getTotal(r)); // update reg box
Military.moveRegiment(r, r.x + rand(20) - 10, r.y + rand(20) - 10);
}
// append battlefield marker
void function addMarkerSymbol() {
if (svg.select("#defs-markers").select("#marker_battlefield").size()) return;
const symbol = svg.select("#defs-markers").append("symbol").attr("id", "marker_battlefield").attr("viewBox", "0 0 30 30");
symbol.append("path").attr("d", "M6,19 l9,10 L24,19").attr("fill", "#000000").attr("stroke", "none");
symbol.append("circle").attr("cx", 15).attr("cy", 15).attr("r", 10).attr("fill", "#ffffff").attr("stroke", "#000000").attr("stroke-width", 1);
symbol.append("text").attr("x", "50%").attr("y", "52%").attr("fill", "#000000").attr("stroke", "#3200ff").attr("stroke-width", 0)
.attr("font-size", "12px").attr("dominant-baseline", "central").text("⚔️");
}()
const getSide = (regs, n) => regs.length > 1 ?
`${n ? "regiments" : "forces"} of ${[... new Set(regs.map(r => pack.states[r.state].name))].join(", ")}` :
getAdjective(pack.states[regs[0].state].name) + " " + regs[0].name;
const getLosses = casualties => Math.min(rn(casualties * 100), 100);
const legend = `${this.name} took place in ${options.year} ${options.eraShort}. It was fought between ${getSide(this.attackers.regiments, 1)} and ${getSide(this.defenders.regiments, 0)}. The battle ended in ${battleStatus[+P(.7)]}.
\r\nAttackers losses: ${getLosses(this.attackers.casualties)}%, defenders losses: ${getLosses(this.defenders.casualties)}%`;
const id = getNextId("markerElement");
notes.push({id, name:this.name, legend});
markers.append("use").attr("id", id)
.attr("xlink:href", "#marker_battlefield").attr("data-id", "#marker_battlefield")
.attr("data-x", this.x).attr("data-y", this.y).attr("x", this.x - 15).attr("y", this.y - 30)
.attr("data-size", 1).attr("width", 30).attr("height", 30);
$("#battleScreen").dialog("destroy");
this.cleanData();
}
cancelResults() {
// move regiments back to initial positions
this.attackers.regiments.concat(this.defenders.regiments).forEach(r => Military.moveRegiment(r, r.px, r.py));
$("#battleScreen").dialog("close");
this.cleanData();
}
cleanData() {
battleAttackers.innerHTML = battleDefenders.innerHTML = ""; // clean DOM
customization = 0; // exit edit mode
// clean temp data
this.attackers.regiments.concat(this.defenders.regiments).forEach(r => {
delete r.px;
delete r.py;
delete r.casualties;
delete r.survivors;
});
delete Battle.prototype.context;
}
}