Guest User

Untitled

a guest
Nov 7th, 2025
12
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
text 19.28 KB | None | 0 0
  1. const CIV_DATA = [
  2. { name: "Chinese", winRate: 0.597 },
  3. { name: "Celts", winRate: 0.576 },
  4. { name: "Mayans", winRate: 0.573 },
  5. { name: "Malians", winRate: 0.559 },
  6. { name: "Malay", winRate: 0.558 },
  7. { name: "Shu", winRate: 0.556 },
  8. { name: "Khitans", winRate: 0.551 },
  9. { name: "Ethiopians", winRate: 0.548 },
  10. { name: "Bengalis", winRate: 0.539 },
  11. { name: "Romans", winRate: 0.538 },
  12. { name: "Khmer", winRate: 0.529 },
  13. { name: "Mongols", winRate: 0.529 },
  14. { name: "Portuguese", winRate: 0.528 },
  15. { name: "Vikings", winRate: 0.523 },
  16. { name: "Koreans", winRate: 0.518 },
  17. { name: "Slavs", winRate: 0.516 },
  18. { name: "Goths", winRate: 0.514 },
  19. { name: "Gurjaras", winRate: 0.513 },
  20. { name: "Huns", winRate: 0.513 },
  21. { name: "Wei", winRate: 0.509 },
  22. { name: "Poles", winRate: 0.508 },
  23. { name: "Franks", winRate: 0.501 },
  24. { name: "Bulgarians", winRate: 0.5 },
  25. { name: "Wu", winRate: 0.499 },
  26. { name: "Tatars", winRate: 0.496 },
  27. { name: "Jurchens", winRate: 0.493 },
  28. { name: "Japanese", winRate: 0.49 },
  29. { name: "Byzantines", winRate: 0.49 },
  30. { name: "Burmese", winRate: 0.49 },
  31. { name: "Bohemians", winRate: 0.488 },
  32. { name: "Persians", winRate: 0.487 },
  33. { name: "Vietnamese", winRate: 0.483 },
  34. { name: "Britons", winRate: 0.477 },
  35. { name: "Dravidians", winRate: 0.477 },
  36. { name: "Magyars", winRate: 0.472 },
  37. { name: "Burgundians", winRate: 0.47 },
  38. { name: "Italians", winRate: 0.47 },
  39. { name: "Sicilians", winRate: 0.465 },
  40. { name: "Lithuanians", winRate: 0.465 },
  41. { name: "Teutons", winRate: 0.464 },
  42. { name: "Armenians", winRate: 0.463 },
  43. { name: "Spanish", winRate: 0.462 },
  44. { name: "Berbers", winRate: 0.456 },
  45. { name: "Aztecs", winRate: 0.451 },
  46. { name: "Saracens", winRate: 0.446 },
  47. { name: "Cumans", winRate: 0.443 },
  48. { name: "Georgians", winRate: 0.438 },
  49. { name: "Incas", winRate: 0.428 },
  50. { name: "Hindustanis", winRate: 0.426 }
  51. ];
  52.  
  53. const CIV_NAMES = CIV_DATA.map((civ) => civ.name);
  54.  
  55. const BASE_WIN_RATE = 0.5;
  56. const BASE_STRENGTH = 0.5;
  57. const PEAK_WIN_RATE = 0.597;
  58. const PEAK_STRENGTH = 0.78;
  59. const STRENGTH_SLOPE = (PEAK_STRENGTH - BASE_STRENGTH) / (PEAK_WIN_RATE - BASE_WIN_RATE);
  60. const STRENGTH_INTERCEPT = BASE_STRENGTH - STRENGTH_SLOPE * BASE_WIN_RATE;
  61. const STRENGTH_COEFF = 150;
  62.  
  63. const baseCivs = CIV_DATA.map(({ name, winRate }) => {
  64. const baseStrength = convertWinRateToStrength(winRate);
  65. return {
  66. name,
  67. baseStrength,
  68. strength: baseStrength
  69. };
  70. });
  71.  
  72. function convertWinRateToStrength(winRate) {
  73. const normalizedRate = clamp(winRate, 0, 1);
  74. const strength = STRENGTH_SLOPE * normalizedRate + STRENGTH_INTERCEPT;
  75. return clamp(strength, 0, 1) * STRENGTH_COEFF;
  76. }
  77.  
  78. const civs = baseCivs;
  79.  
  80. let expectedRandomWinRates = new Map();
  81. const DEFAULT_STRENGTH_SPREAD = 1.4;
  82. let strengthSpread = DEFAULT_STRENGTH_SPREAD;
  83.  
  84. const tableBody = document.querySelector("#civTable tbody");
  85. const simulateBtn = document.querySelector("#simulateBtn");
  86. const matchTicker = document.querySelector("#matchTicker");
  87. const playerTicker = document.querySelector("#playerTicker");
  88. const avgStrengthEl = document.querySelector("#avgStrength");
  89. const progressBar = document.querySelector("#progressBar");
  90. const statusLog = document.querySelector("#statusLog");
  91. const speedSelect = document.querySelector("#speed");
  92. const matchmakingSelect = document.querySelector("#matchmakingMode");
  93. const insightsContent = document.querySelector("#insightsContent");
  94. const strengthSpreadInput = document.querySelector("#strengthSpread");
  95. const strengthSpreadValue = document.querySelector("#strengthSpreadValue");
  96. const conclusionsPanel = document.querySelector("#conclusionsPanel");
  97.  
  98. let currentState = {
  99. running: false,
  100. players: [],
  101. civStats: new Map(),
  102. matches: 0,
  103. totalMatches: 0,
  104. matchmakingMode: "elo"
  105. };
  106.  
  107. initialize();
  108.  
  109. simulateBtn.addEventListener("click", () => {
  110. if (currentState.running) {
  111. return;
  112. }
  113. const playerCount = Number(document.querySelector("#playerCount").value) || 3000;
  114. const matchCount = Number(document.querySelector("#matchCount").value) || 25000;
  115. const kFactor = Number(document.querySelector("#kFactor").value) || 24;
  116. const matchmakingMode = (matchmakingSelect?.value ?? "elo").toLowerCase();
  117.  
  118. runSimulation({ playerCount, matchCount, kFactor, matchmakingMode }).catch((error) => {
  119. console.error(error);
  120. pushStatus(`Simulation failed: ${error.message}`);
  121. setRunning(false);
  122. });
  123. });
  124.  
  125. function initialize() {
  126. currentState.civStats = initializeCivStats();
  127.  
  128. const initialSpread = strengthSpreadInput
  129. ? Number(strengthSpreadInput.value) || DEFAULT_STRENGTH_SPREAD
  130. : DEFAULT_STRENGTH_SPREAD;
  131. updateStrengthSpreadValue(initialSpread);
  132. updateCivStrengths(initialSpread);
  133. updateTable();
  134.  
  135. if (strengthSpreadInput) {
  136. strengthSpreadInput.addEventListener("input", (event) => {
  137. const scale = Number(event.target.value) || DEFAULT_STRENGTH_SPREAD;
  138. updateStrengthSpreadValue(scale);
  139. if (currentState.running) {
  140. return;
  141. }
  142. updateCivStrengths(scale);
  143. currentState.players = [];
  144. currentState.civStats = initializeCivStats();
  145. currentState.matches = 0;
  146. currentState.totalMatches = 0;
  147. if (matchTicker) {
  148. matchTicker.textContent = "0";
  149. }
  150. if (playerTicker) {
  151. playerTicker.textContent = "0";
  152. }
  153. if (progressBar) {
  154. progressBar.style.width = "0%";
  155. }
  156. if (conclusionsPanel) {
  157. conclusionsPanel.classList.remove("visible");
  158. }
  159. setInsightsMessage("Strength distribution updated. Run a simulation to generate fresh results.");
  160. pushStatus("Civ strength spread adjusted — awaiting simulation.");
  161. updateTable();
  162. });
  163. }
  164.  
  165. setInsightsMessage("Run a simulation to uncover which civilizations buck the balance curve.");
  166. pushStatus("Ready. Configure parameters and hit run.");
  167. }
  168.  
  169. function updateCivStrengths(scale) {
  170. const effectiveScale = Number.isFinite(scale) ? scale : DEFAULT_STRENGTH_SPREAD;
  171. strengthSpread = effectiveScale;
  172. civs.forEach((civ) => {
  173. const adjusted = 0.5 + (civ.baseStrength - 0.5) * strengthSpread;
  174. civ.strength = Number(adjusted.toFixed(3));
  175. });
  176. expectedRandomWinRates = computeExpectedRandomWinRates(civs);
  177. renderCivTable();
  178. updateAverageStrength();
  179. }
  180.  
  181. function renderCivTable() {
  182. if (!tableBody) return;
  183. const fragment = document.createDocumentFragment();
  184. civs
  185. .slice()
  186. .sort((a, b) => b.strength - a.strength)
  187. .forEach((civ) => {
  188. const expectedRate = expectedRandomWinRates.get(civ.name) ?? civ.strength;
  189. const row = document.createElement("tr");
  190. row.innerHTML = `
  191. <td class="civ-name">${civ.name}</td>
  192. <td>
  193. <div class="strength-bar" aria-label="Base strength ${civ.strength}">
  194. <span style="width: ${(civ.strength * 100).toFixed(1)}%"></span>
  195. </div>
  196. <small>${civ.strength.toFixed(3)}</small>
  197. </td>
  198. <td class="expected-rate" data-civ="${civ.name}">${(expectedRate * 100).toFixed(1)}%</td>
  199. <td class="win-rate" data-civ="${civ.name}">--</td>
  200. <td class="difference" data-civ="${civ.name}">--</td>
  201. <td class="match-count" data-civ="${civ.name}">0</td>
  202. `;
  203. fragment.appendChild(row);
  204. });
  205. tableBody.innerHTML = "";
  206. tableBody.appendChild(fragment);
  207. }
  208.  
  209. function updateStrengthSpreadValue(scale) {
  210. if (!strengthSpreadValue) return;
  211. strengthSpreadValue.textContent = `${scale.toFixed(2)}x`;
  212. }
  213.  
  214. function updateAverageStrength() {
  215. if (!avgStrengthEl) return;
  216. const avgStrength = civs.reduce((sum, civ) => sum + civ.strength, 0) / civs.length;
  217. avgStrengthEl.textContent = avgStrength.toFixed(3);
  218. }
  219.  
  220. async function runSimulation({ playerCount, matchCount, kFactor, matchmakingMode }) {
  221. setRunning(true);
  222. if (conclusionsPanel) {
  223. conclusionsPanel.classList.remove("visible");
  224. }
  225. pushStatus(`Generating ${playerCount} players and scheduling ${matchCount.toLocaleString()} matches.`);
  226. setInsightsMessage("Crunching numbers &mdash; insights will refresh when the simulation completes.");
  227.  
  228. currentState.players = createPlayers(playerCount);
  229. currentState.civStats = initializeCivStats();
  230. currentState.matches = 0;
  231. currentState.totalMatches = matchCount;
  232. currentState.matchmakingMode = matchmakingMode === "random" ? "random" : "elo";
  233.  
  234. playerTicker.textContent = playerCount.toLocaleString();
  235. matchTicker.textContent = "0";
  236. progressBar.style.width = "0%";
  237. updateTable();
  238.  
  239. const speed = speedSelect.value;
  240. const delayMap = {
  241. fast: 0,
  242. medium: 16,
  243. slow: 32
  244. };
  245. const batchMap = {
  246. fast: 80,
  247. medium: 20,
  248. slow: 10
  249. };
  250. const delay = delayMap[speed] ?? 14;
  251. const batchSize = batchMap[speed] ?? 18;
  252.  
  253. for (let i = 0; i < matchCount; i++) {
  254. simulateMatch(kFactor, currentState.matchmakingMode);
  255. currentState.matches++;
  256.  
  257. if (i % batchSize === 0) {
  258. updateTable();
  259. updateProgress();
  260. await wait(delay);
  261. }
  262. }
  263.  
  264. updateTable();
  265. updateProgress(true);
  266. pushStatus("Simulation complete. Balance restored through ELO!");
  267. renderInsights();
  268. if (conclusionsPanel) {
  269. conclusionsPanel.classList.toggle("visible", currentState.matchmakingMode === "elo");
  270. }
  271. setRunning(false);
  272. }
  273.  
  274. function simulateMatch(kFactor, matchmakingMode) {
  275. const { players, civStats } = currentState;
  276. const playerAIndex = randomInt(players.length);
  277. const playerBIndex = getOpponentIndex(playerAIndex, matchmakingMode, 100);
  278.  
  279. const playerA = players[playerAIndex];
  280. const playerB = players[playerBIndex];
  281. const civA = civs[randomInt(civs.length)];
  282. const civB = civs[randomInt(civs.length)];
  283.  
  284. const eloDiff = (playerA.skill + civA.strength - playerB.skill - civB.strength) / kFactor;
  285. const winChanceB = clamp(1.0 / (1.0 + Math.pow(10.0, eloDiff)), 0.02, 0.98);
  286. const winChanceA = 1.0 - winChanceB;
  287. const aWins = Math.random() < winChanceA ? 1 : 0;
  288. const bWins = 1 - aWins;
  289.  
  290. updateCivStats(civStats, civA.name, aWins === 1);
  291. updateCivStats(civStats, civB.name, bWins === 1);
  292.  
  293. if (matchmakingMode !== "random") {
  294. applyElo(playerA, playerB, aWins, kFactor);
  295. updateSkill(playerA);
  296. updateSkill(playerB);
  297. }
  298. }
  299.  
  300. function createPlayers(count) {
  301. const players = [];
  302. for (let i = 0; i < count; i++) {
  303. const skill = i;
  304. const skillTrend = clamp(randomNormal(0, 0.01), -0.02, 0.02);
  305. players.push({
  306. id: i,
  307. rating: 1000 + skill + randomNormal(0, 90),
  308. skill,
  309. skillTrend,
  310. volatility: clamp(Math.abs(randomNormal(0.004, 0.002)), 0.001, 0.012)
  311. });
  312. }
  313. return players;
  314. }
  315.  
  316. function initializeCivStats() {
  317. const map = new Map();
  318. civs.forEach((civ) => {
  319. map.set(civ.name, {
  320. wins: 0,
  321. games: 0,
  322. winRate: 0
  323. });
  324. });
  325. return map;
  326. }
  327.  
  328. function updateCivStats(map, civName, won) {
  329. const entry = map.get(civName);
  330. if (!entry) return;
  331. entry.games += 1;
  332. if (won) {
  333. entry.wins += 1;
  334. }
  335. entry.winRate = entry.games > 0 ? entry.wins / entry.games : 0;
  336. }
  337.  
  338. function applyElo(playerA, playerB, aWins, kFactor) {
  339. const expectedA = 1 / (1 + Math.pow(10, (playerB.rating - playerA.rating) / 400));
  340. const expectedB = 1 - expectedA;
  341. const resultA = aWins;
  342. const resultB = 1 - aWins;
  343.  
  344. playerA.rating += kFactor * (resultA - expectedA);
  345. playerB.rating += kFactor * (resultB - expectedB);
  346. }
  347.  
  348. function updateSkill(player) {
  349. const drift = player.skillTrend + randomNormal(0, player.volatility);
  350. player.skill += drift;
  351. }
  352.  
  353. function updateTable() {
  354. const { civStats } = currentState;
  355. civs.forEach((civ) => {
  356. const expectedCell = tableBody.querySelector(`.expected-rate[data-civ="${civ.name}"]`);
  357. const winRateCell = tableBody.querySelector(`.win-rate[data-civ="${civ.name}"]`);
  358. const diffCell = tableBody.querySelector(`.difference[data-civ="${civ.name}"]`);
  359. const matchCell = tableBody.querySelector(`.match-count[data-civ="${civ.name}"]`);
  360. const stats = civStats.get(civ.name);
  361. const expectedRate = expectedRandomWinRates.get(civ.name) ?? civ.strength;
  362.  
  363. if (expectedCell) {
  364. expectedCell.textContent = `${(expectedRate * 100).toFixed(1)}%`;
  365. }
  366.  
  367. if (!stats || !winRateCell || !matchCell || !diffCell) return;
  368. if (stats.games === 0) {
  369. winRateCell.textContent = "--";
  370. winRateCell.classList.remove("positive", "negative");
  371. diffCell.textContent = "--";
  372. diffCell.classList.remove("positive", "negative");
  373. matchCell.textContent = "0";
  374. } else {
  375. const rate = stats.winRate;
  376. winRateCell.textContent = `${(rate * 100).toFixed(1)}%`;
  377. winRateCell.classList.toggle("positive", rate > 0.5);
  378. winRateCell.classList.toggle("negative", rate < 0.5);
  379. const diff = rate - expectedRate;
  380. const diffPct = (diff * 100).toFixed(1);
  381. const formattedDiff = diff > 0 ? `+${diffPct}%` : `${diffPct}%`;
  382. diffCell.textContent = formattedDiff;
  383. diffCell.classList.toggle("positive", diff > 0);
  384. diffCell.classList.toggle("negative", diff < 0);
  385. if (diff === 0) {
  386. diffCell.classList.remove("positive", "negative");
  387. }
  388. matchCell.textContent = stats.games.toLocaleString();
  389. }
  390. });
  391. }
  392.  
  393. function updateProgress(final = false) {
  394. const { matches, totalMatches } = currentState;
  395. const ratio = totalMatches === 0 ? 0 : matches / totalMatches;
  396. progressBar.style.width = `${Math.min(ratio * 100, 100).toFixed(1)}%`;
  397. matchTicker.textContent = matches.toLocaleString();
  398. if (final) {
  399. pushStatus(`Processed ${matches.toLocaleString()} matches.`);
  400. } else if (matches > 0 && matches % Math.max(200, Math.round(totalMatches / 10)) === 0) {
  401. const percent = (ratio * 100).toFixed(0);
  402. pushStatus(`${percent}% complete — civ win rates are converging.`);
  403. }
  404. }
  405.  
  406. function pushStatus(message) {
  407. statusLog.textContent = message;
  408. }
  409.  
  410. function setInsightsMessage(message) {
  411. if (!insightsContent) return;
  412. insightsContent.innerHTML = `<p>${message}</p>`;
  413. }
  414.  
  415. function renderInsights() {
  416. if (!insightsContent) return;
  417. const { civStats, totalMatches } = currentState;
  418. const results = civs
  419. .map((civ) => {
  420. const stats = civStats.get(civ.name);
  421. const games = stats?.games ?? 0;
  422. const winRate = games > 0 ? stats.wins / games : null;
  423. const expectedRandom = expectedRandomWinRates.get(civ.name) ?? civ.strength;
  424. return {
  425. name: civ.name,
  426. strength: civ.strength,
  427. games,
  428. winRate,
  429. expectedRandom,
  430. diff: winRate !== null ? winRate - civ.strength : null
  431. };
  432. })
  433. .filter((entry) => entry.winRate !== null && entry.games >= Math.max(30, Math.round(totalMatches * 0.003)));
  434.  
  435. if (results.length === 0) {
  436. setInsightsMessage("Not enough data yet. Try running a longer simulation to surface meaningful outliers.");
  437. return;
  438. }
  439.  
  440. const overperformers = results
  441. .filter((entry) => entry.diff > 0.02)
  442. .sort((a, b) => b.diff - a.diff)
  443. .slice(0, 3);
  444. const underperformers = results
  445. .filter((entry) => entry.diff < -0.02)
  446. .sort((a, b) => a.diff - b.diff)
  447. .slice(0, 3);
  448.  
  449. const buildItem = (entry, type) => {
  450. const ratePct = (entry.winRate * 100).toFixed(1);
  451. const strengthPct = (entry.strength * 100).toFixed(1);
  452. const expectedPct = (entry.expectedRandom * 100).toFixed(1);
  453. const diffPct = (entry.diff * 100).toFixed(1);
  454. const diffLabel = `${diffPct > 0 ? "+" : ""}${diffPct}`;
  455. const className = type === "over" ? "insight-highlight" : "insight-warning";
  456. const gamesPlayed = entry.games.toLocaleString();
  457. return `<li><span class="${className}">${entry.name}</span> posted a ${ratePct}% win rate across ${gamesPlayed} games versus a ${strengthPct}% strength baseline (${diffLabel} pts) and would expect ${expectedPct}% in equal-skill random matchups.</li>`;
  458. };
  459.  
  460. const sections = [];
  461. sections.push(`<p>Reviewed ${totalMatches.toLocaleString()} simulated matches to gauge balance drift.</p>`);
  462.  
  463. if (overperformers.length > 0) {
  464. sections.push(
  465. `<div><strong>Overperforming civs</strong><ul class="insight-list">${overperformers
  466. .map((entry) => buildItem(entry, "over"))
  467. .join("")}</ul></div>`
  468. );
  469. }
  470.  
  471. if (underperformers.length > 0) {
  472. sections.push(
  473. `<div><strong>Underperforming civs</strong><ul class="insight-list">${underperformers
  474. .map((entry) => buildItem(entry, "under"))
  475. .join("")}</ul></div>`
  476. );
  477. }
  478.  
  479. if (sections.length === 1) {
  480. sections.push(
  481. `<p>The remaining civilizations tracked closely with their theoretical strength &mdash; evidence the Elo system kept the field in check.</p>`
  482. );
  483. }
  484.  
  485. insightsContent.innerHTML = sections.join("");
  486. }
  487.  
  488. function setRunning(value) {
  489. currentState.running = value;
  490. simulateBtn.disabled = value;
  491. simulateBtn.textContent = value ? "Simulating..." : "Run Simulation";
  492. if (strengthSpreadInput) {
  493. strengthSpreadInput.disabled = value;
  494. }
  495. }
  496.  
  497. function computeExpectedRandomWinRates(civList) {
  498. const map = new Map();
  499. const total = civList.length;
  500. civList.forEach((civ) => {
  501. let sum = 0;
  502. civList.forEach((opponent) => {
  503. if (opponent === civ) {
  504. sum += 0.5;
  505. } else {
  506. sum += civ.strength / (civ.strength + opponent.strength);
  507. }
  508. });
  509. map.set(civ.name, sum / total);
  510. });
  511. return map;
  512. }
  513.  
  514. function wait(ms) {
  515. return new Promise((resolve) => setTimeout(resolve, ms));
  516. }
  517.  
  518. function randomInt(max) {
  519. return Math.floor(Math.random() * max);
  520. }
  521.  
  522. function getOpponentIndex(playerIndex, matchmakingMode, maxDifference) {
  523. if (matchmakingMode === "random") {
  524. const { players } = currentState;
  525. if (players.length <= 1) {
  526. return playerIndex;
  527. }
  528. let opponentIndex = playerIndex;
  529. while (opponentIndex === playerIndex) {
  530. opponentIndex = randomInt(players.length);
  531. }
  532. return opponentIndex;
  533. }
  534.  
  535. return selectOpponentIndex(playerIndex, maxDifference);
  536. }
  537.  
  538. function selectOpponentIndex(playerIndex, maxDifference) {
  539. const { players } = currentState;
  540. const anchor = players[playerIndex];
  541. if (!anchor) return playerIndex;
  542.  
  543. // Build a pool of opponents whose rating is within the allowed window.
  544. const closeMatches = [];
  545. let fallbackIndex = -1;
  546. let fallbackDiff = Infinity;
  547.  
  548. for (let i = 0; i < players.length; i++) {
  549. if (i === playerIndex) continue;
  550. const diff = Math.abs(players[i].rating - anchor.rating);
  551. if (diff <= maxDifference) {
  552. closeMatches.push(i);
  553. }
  554. if (diff < fallbackDiff) {
  555. fallbackDiff = diff;
  556. fallbackIndex = i;
  557. }
  558. }
  559.  
  560. if (closeMatches.length > 0) {
  561. return closeMatches[randomInt(closeMatches.length)];
  562. }
  563.  
  564. if (fallbackIndex !== -1) {
  565. return fallbackIndex;
  566. }
  567.  
  568. if (players.length <= 1) {
  569. return playerIndex;
  570. }
  571.  
  572. return playerIndex === 0 ? 1 : 0;
  573. }
  574.  
  575. function randomNormal(mean = 0, stdDev = 1) {
  576. let u = 0,
  577. v = 0;
  578. while (u === 0) u = Math.random();
  579. while (v === 0) v = Math.random();
  580. const standard = Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v);
  581. return standard * stdDev + mean;
  582. }
  583.  
  584. function clamp(value, min, max) {
  585. return Math.min(Math.max(value, min), max);
  586. }
  587.  
Advertisement
Add Comment
Please, Sign In to add comment