OBJECTIVE: Eliminate all rival factions and conquer all 42 territories to establish total wasteland dominance.
+
OBJECTIVE: Wipe out all rival factions and take over all 42 territories in the wasteland!
-
+
-
PHASE 1: DEPLOY
- Click your territories to deploy reserve troops. Hold SHIFT to place all at once.
+
1. DEPLOY: Place your reserve troops onto territories you already own. Hold SHIFT to place them all at once.
-
PHASE 2: ATTACK (V.A.T.S.)
- Select a staging territory, then click an adjacent enemy. V.A.T.S. targeting calculates probability matrices for the assault.
+
2. ATTACK: Pick one of your territories, then click a connected enemy to attack! V.A.T.S. will show you the odds. If you win, you get to choose how many troops to move into the newly conquered land.
-
PHASE 3: AWAITING DEPLOYMENT
- Also known as the Maneuver phase. Move troops between connected territories you own. You are allowed one maneuver per turn.
+
3. MANEUVER: Once per turn, you can move troops between your own connected territories to reinforce your borders.
+
+
BOTTLE CAPS: Taking over land earns you Bottle Caps. Trade in 3 matching Caps (or 1 of each kind) to hire more troops. Wipe out an enemy faction entirely to steal all their Caps!
+
+
FIXED REINFORCEMENTS: By default, trading in Caps gives you more and more troops as the game goes on. Check this box on the start screen if you want trades to ALWAYS give exactly 3 troops instead.
-
-
HP & AP GAUGES
- The HP Bar represents your territorial health compared to the rest of the map. The AP Bar tracks your Action Points; it will deplete as you run out of legal moves or reinforcements.
-
-
TURBO MODE
- Toggle the switch to bypass V.A.T.S. animations and significantly increase AI processing speed for faster gameplay.
+
+
+
UI GAUGES: The HP Bar shows how much of the wasteland you control. The AP Bar shows your Action Points, which drain as you run out of moves or troops.
-
RADSTORMS & WILD GHOULS
- Optional hazards. Radstorms kill 10-25% of troops in affected zones after a 2-day warning. Wild Ghouls are neutral threats with a 15% defensive bonus.
+
RADIO: Click the RADIO tab at the bottom to listen to wasteland broadcasts. Turn it OFF and ON to skip songs (expect a few seconds of static in between!).
+
+
TURBO MODE: Turn this on to skip the V.A.T.S. animations and make the AI take its turns much faster.
+
+
HAZARD - RADSTORMS: Watch the skies! Radstorms give a short warning before wiping out 10-25% of the troops caught in the blast zone.
+
+
HAZARD - WILD GHOULS: Unclaimed territories are filled with feral ghouls. They fight back, and they will slowly multiply depending on your difficulty setting.
-
- RADIO BROADCASTS:
- Click the RADIO tab in the bottom navigation to tune into local wasteland frequencies. Cycle the power (turn OFF, then ON) to skip tracks. Signals are unstable, so expect a 5-second delay between broadcasts. Track data is integrated directly into your combat log.
-
+
-
- CAPS (CARDS):
- Conquering territory earns Bottle Caps. To trade for reinforcements, select 3: either three of the same kind or one of each unique kind. The "Stash" button will pulse and update when a valid trade is ready.
-
-
-
-
-
-
-
-
Caps Yield:
-
Caps Yield:
-
Caps Yield:
-
Caps Yield:
-
Caps Yield:
-
Caps Yield:
-
Caps Yield:
+
+
+
Reinforcements:
+
Reinforcements:
+
Reinforcements:
+
Reinforcements:
+
Reinforcements:
+
Reinforcements:
+
Reinforcements:
@@ -1034,12 +1059,21 @@ function generateDeck() {
countries.forEach((country, index) => {
deck.push({ country: country.name, type: cardTypes[index % 3] });
});
- deck.push({ country: "Wild", type: "Wild" });
- deck.push({ country: "Wild", type: "Wild" });
+
+ // Injects 8 Wild Caps to bring the total pool to exactly 50
+ for (let i = 0; i < 8; i++) {
+ deck.push({ country: "Wild", type: "Wild" });
+ }
shuffle(deck);
}
function getTradeBonus() {
+ // Check if the fixed trade option was selected
+ if (Gamestate.flatTrade) {
+ return 3;
+ }
+
+ // Default escalating Risk trade rules
tradeCount++;
if (tradeCount <= 5) return 2 + (tradeCount * 2);
return 15 + ((tradeCount - 6) * 5);
@@ -1064,10 +1098,74 @@ const areas = Array.from(document.getElementsByClassName('area'));
const bar = Array.from(document.getElementsByClassName('bar'));
const map = document.querySelector('svg');
+// --- Initialize V.A.T.S. Tooltip Element ---
+const vatsTooltip = document.createElement('div');
+vatsTooltip.id = 'vats-tooltip';
+document.body.appendChild(vatsTooltip);
+
+map.addEventListener('mousemove', (e) => {
+ // Only show V.A.T.S. if we are in Battle phase and have a staging territory selected
+ if (Gamestate.stage !== "Battle" || !Gamestate.prevCountry || Gamestate.aiTurn) {
+ vatsTooltip.style.display = "none";
+ return;
+ }
+
+ let targetId = e.target.id;
+ let targetCountry = Gamestate.countries.find(c => c.name === targetId);
+
+ // If hovering over a valid enemy neighbor
+ if (targetCountry && Gamestate.prevCountry.neighbours.includes(targetCountry.name) && targetCountry.owner !== Gamestate.player.name && Gamestate.prevCountry.army > 1) {
+
+ // 1. Get the per-troop combat odds
+ let baseChance = 0.50;
+ if (Gamestate.difficulty === "Easy") baseChance = 0.60;
+ if (Gamestate.difficulty === "Hard") baseChance = 0.40;
+
+ let isNeutral = Gamestate.players.find(p => p.name === targetCountry.owner).isNeutral;
+ if (isNeutral) baseChance -= 0.15;
+
+ // 2. Calculate complete battle victory probability (Gambler's Ruin Algorithm)
+ let a = Gamestate.prevCountry.army - 1; // Troops available to attack
+ let d = targetCountry.army; // Troops defending
+ let winProb = 0;
+
+ if (baseChance === 0.5) {
+ winProb = a / (a + d);
+ } else {
+ let q = 1 - baseChance;
+ let ratio = q / baseChance;
+ winProb = (1 - Math.pow(ratio, a)) / (1 - Math.pow(ratio, a + d));
+ }
+
+ let chancePercent = Math.round(winProb * 100);
+
+ // 3. Authentic Fallout Cap (V.A.T.S. never guarantees 100%)
+ if (chancePercent > 95) chancePercent = 95;
+ if (chancePercent < 1) chancePercent = 1;
+
+ vatsTooltip.innerHTML = `
+
V.A.T.S. TARGETING
+ TARGET: ${targetCountry.name}
+ DEFENDERS: ${targetCountry.army}
+ WIN CHANCE: ${chancePercent}%
+ `;
+
+ // Position tooltip slightly offset from the mouse pointer
+ vatsTooltip.style.left = (e.clientX + 20) + "px";
+ vatsTooltip.style.top = (e.clientY + 20) + "px";
+ vatsTooltip.style.display = "block";
+ } else {
+ vatsTooltip.style.display = "none";
+ }
+});
+// Hide tooltip if the mouse leaves the map area
+map.addEventListener('mouseleave', () => { vatsTooltip.style.display = "none"; });
+
const modal = document.querySelector('#start-modal');
const reserveDisplay = document.querySelector('#reserve');
const chosenLeader = document.querySelector('#chosen-leader');
const chosenCountry = document.querySelector('#chosen-country');
+const chosenColor = document.querySelector('#chosen-color'); // <-- NEW LINE
const submitName = document.querySelector('#submit-name');
const winModal = document.querySelector('#win-modal');
const winMessage = document.querySelector('.win-message');
@@ -1298,7 +1396,8 @@ Gamestate.start = async function(){
let optRadstorms = document.getElementById('opt-radstorms') && document.getElementById('opt-radstorms').checked;
let optHorrors = document.getElementById('opt-horrors') && document.getElementById('opt-horrors').checked;
- this.hazardsEnabled = optRadstorms;
+this.flatTrade = document.getElementById('opt-flat-trade') && document.getElementById('opt-flat-trade').checked; // NEW SETTING
+this.hazardsEnabled = optRadstorms;
this.radstorm = { state: 'none', timer: 0, cooldown: Math.floor(Math.random() * 11) + 5, areas: [] };
@@ -1352,7 +1451,22 @@ Gamestate.start = async function(){
if (playerName) playerName.textContent = chosenLeader.value;
if (playerCountry) playerCountry.textContent = chosenCountry.value;
}
-
+// --- SET CUSTOM FACTION COLOR & PREVENT DUPLICATES ---
+ if (chosenColor) {
+ let selectedHex = chosenColor.value;
+ let defaultPlayerColor = this.players[0].color; // Save Player 1's default starting color
+
+ // Scan the AI players to see if anyone is using the color you just picked
+ let colorConflictAI = this.players.find((p, index) => index !== 0 && p.color === selectedHex);
+
+ if (colorConflictAI) {
+ // Give the AI your default color so they aren't left blank
+ colorConflictAI.color = defaultPlayerColor;
+ }
+
+ // Finally, assign your chosen color to your faction
+ this.players[0].color = selectedHex;
+ }
generateDeck();
tradeCount = 0;
this.players.forEach(p => { p.cards = []; p.conqueredThisTurn = false; });
@@ -1457,33 +1571,56 @@ Gamestate.updateInfo = function(){
let sortedPlayers = [...this.players].sort((a, b) => b.army - a.army);
- this.players.forEach((player, i) => {
- let infoBox = infoName[i] ? infoName[i].parentElement : null;
- if (!infoBox) return;
+this.players.forEach((player, i) => {
+ let infoBox = infoName[i] ? infoName[i].parentElement : null;
+ if (!infoBox) return;
- if (player.alive) {
- if (player.isNeutral) {
- if (infoIncome[i]) infoIncome[i].parentElement.style.display = "none";
- } else {
+ // --- DYNAMIC CAPS DISPLAY ---
+ // This creates a new line specifically for Caps right under Reinforcements
+ let capsDiv = infoBox.querySelector('.caps-display');
+ if (!capsDiv) {
+ capsDiv = document.createElement('div');
+ capsDiv.className = 'caps-display';
+ capsDiv.style.color = 'var(--pip-color)';
+ capsDiv.style.fontSize = '14px';
+ capsDiv.style.marginTop = '2px';
if (infoIncome[i]) {
- infoIncome[i].innerHTML = player.bonus;
- infoIncome[i].parentElement.style.display = "block";
+ infoIncome[i].parentElement.after(capsDiv);
}
}
- if (bar[i]) bar[i].style.width = (player.army / totalArmy) * 230 + 'px';
- } else {
- if (infoIncome[i]) {
- infoIncome[i].innerHTML = "OFFLINE";
- infoIncome[i].parentElement.style.display = "block";
+
+ if (player.alive) {
+ // Restore Leader and Faction texts properly
+ if (infoLeader[i]) infoLeader[i].innerHTML = player.name;
+ if (infoName[i]) infoName[i].innerHTML = player.country;
+
+ if (player.isNeutral) {
+ if (infoIncome[i]) infoIncome[i].parentElement.style.display = "none";
+ capsDiv.style.display = "none";
+ } else {
+ if (infoIncome[i]) {
+ infoIncome[i].innerHTML = player.bonus;
+ infoIncome[i].parentElement.style.display = "block";
+ }
+ capsDiv.innerHTML = `Bottle Caps: ${player.cards.length}`;
+ capsDiv.style.display = "block";
+ }
+ if (bar[i]) bar[i].style.width = (player.army / totalArmy) * 230 + 'px';
+ } else {
+ if (infoIncome[i]) {
+ infoIncome[i].innerHTML = "OFFLINE";
+ infoIncome[i].parentElement.style.display = "block";
+ }
+ if (infoLeader[i]) infoLeader[i].innerHTML = `${player.name}`;
+ if (infoName[i]) infoName[i].innerHTML = `${player.country}`;
+ capsDiv.style.display = "none";
+ if (bar[i]) bar[i].style.width = "0px";
}
- if (bar[i]) bar[i].style.width = "0px";
- }
-
- let rank = sortedPlayers.findIndex(p => p.name === player.name);
- infoBox.style.display = "block";
- infoBox.style.order = rank;
- });
-
+
+ let rank = sortedPlayers.findIndex(p => p.name === player.name);
+ infoBox.style.display = "block";
+ infoBox.style.order = rank;
+ });
if (this.players.length === 6 && infoName[6] && infoName[6].parentElement) {
infoName[6].parentElement.style.display = "none";
}
@@ -1860,8 +1997,10 @@ Gamestate.handleEndTurn = async function(){
}
}
- if (this.player.conqueredThisTurn && deck.length > 0) {
- this.player.cards.push(deck.pop());
+if (this.player.conqueredThisTurn) {
+ // If the 50-cap deck empties, forge a new wild cap instead of crashing
+ let newCap = deck.length > 0 ? deck.pop() : { country: "Wasteland Salvage", type: "Wild" };
+ this.player.cards.push(newCap);
this.updateInfo();
if (this.getBestTrade(this.player.cards)) {
await this.logAction("STASH FULL: Enough Caps collected to hire more troops.");
@@ -2012,8 +2151,21 @@ Gamestate.maneuver = function(e){
this.maneuverSource = this.prevCountry.name;
this.maneuverDest = country.name;
- let moveAmount = e.shiftKey ? (this.prevCountry.army - 1) : 1;
+let maxMove = this.prevCountry.army - 1;
+ let moveAmount = 1;
+ if (e.shiftKey) {
+ moveAmount = maxMove; // Still keep Shift as a shortcut for "Move All"
+ } else {
+ let input = prompt(`MANEUVER: How many troops to move to ${country.name}? (1 to ${maxMove})`, maxMove);
+ if (input === null) return; // User clicked Cancel
+
+ moveAmount = parseInt(input);
+ if (isNaN(moveAmount) || moveAmount < 1 || moveAmount > maxMove) {
+ Gamestate.showToast("Invalid troop transfer amount.", "red");
+ return; // Abort if they type letters or invalid numbers
+ }
+ }
country.army += moveAmount;
this.prevCountry.army -= moveAmount;
@@ -2069,8 +2221,44 @@ Gamestate.aiMove = async function(){
if (infoName[i-1]) infoName[i-1].parentElement.classList.remove('highlight');
if(this.players[i].alive){
- if (this.players[i].isNeutral) {
- continue;
+if (this.players[i].isNeutral) {
+ // --- NEUTRAL THREAT: HORDE MULTIPLICATION ---
+ if (this.players[i].name === "Wasteland Horrors") {
+ let ownedAreas = this.countries.filter(c => c.owner === this.players[i].name);
+
+ if (ownedAreas.length > 0) {
+ let totalSpawned = 0;
+ ownedAreas.forEach(c => {
+ if (this.difficulty === "Hard") {
+ // Hard: 2 to 6 units added to ALL Horror territories
+ let spawn = Math.floor(Math.random() * 5) + 2;
+ c.army += spawn;
+ this.players[i].army += spawn;
+ totalSpawned += spawn;
+ } else if (this.difficulty === "Normal") {
+ // Normal: 1 to 3 units added to SOME Horror territories (approx 30% chance per territory)
+ if (Math.random() < 0.30) {
+ let spawn = Math.floor(Math.random() * 3) + 1;
+ c.army += spawn;
+ this.players[i].army += spawn;
+ totalSpawned += spawn;
+ }
+ }
+
+ // Update the specific territory number on the map
+ let areaOnMap = document.getElementById(c.name);
+ if (areaOnMap && areaOnMap.nextElementSibling) areaOnMap.nextElementSibling.textContent = c.army;
+ });
+
+ // Log the infestation if any units spawned
+ if (totalSpawned > 0 && Gamestate.logAction) {
+ Gamestate.logAction(`[ WARNING ] The Wasteland Horrors are multiplying... (+${totalSpawned} hostiles detected).`, true);
+ }
+ }
+ }
+
+ this.updateInfo();
+ continue; // Move to the next player
}
if (infoName[i]) infoName[i].parentElement.classList.add('highlight')
@@ -2141,8 +2329,9 @@ Gamestate.aiMove = async function(){
this.aiManeuver(i);
- if (this.players[i].conqueredThisTurn && deck.length > 0) {
- this.players[i].cards.push(deck.pop());
+if (this.players[i].conqueredThisTurn) {
+ let newCap = deck.length > 0 ? deck.pop() : { country: "Wasteland Salvage", type: "Wild" };
+ this.players[i].cards.push(newCap);
this.players[i].conqueredThisTurn = false;
}
@@ -2258,7 +2447,36 @@ Gamestate.battle = async function(country, opponent, player, i){
opponent.color = player.color;
player.areas.push(opponent.name);
- let movedTroops = Math.max(1, Math.floor(country.army / 2));
+ // --- POST-ATTACK MOVEMENT SELECTION ---
+ let maxMove = country.army - 1;
+ let minMove = 1;
+ let movedTroops = minMove;
+
+ if (player === this.player && maxMove > minMove) {
+ let validInput = false;
+ while (!validInput) {
+ let input = prompt(`VICTORY! How many troops to move into ${opponent.name}? (${minMove} to ${maxMove})`, maxMove);
+
+ if (input === null) {
+ // If they hit cancel, default to the maximum allowable to prevent a game-freeze
+ movedTroops = maxMove;
+ validInput = true;
+ } else {
+ let parsed = parseInt(input);
+ if (!isNaN(parsed) && parsed >= minMove && parsed <= maxMove) {
+ movedTroops = parsed;
+ validInput = true;
+ } else {
+ // The warning alert
+ alert(`WARNING: Insufficient garrison logic. You must move between ${minMove} and ${maxMove} troops.`);
+ }
+ }
+ }
+ } else if (player !== this.player) {
+ // AI automatically moves maximum allowable troops to the new frontline
+ movedTroops = maxMove;
+ }
+
opponent.army = movedTroops;
country.army -= movedTroops;
@@ -2266,10 +2484,29 @@ Gamestate.battle = async function(country, opponent, player, i){
if (defender && defender.nextElementSibling) defender.nextElementSibling.textContent = opponent.army;
if (attacker && attacker.nextElementSibling) attacker.nextElementSibling.textContent = country.army;
+ // --- PLAYER ELIMINATION & CAP STEALING ---
if(opp.areas.length === 0){
opp.alive = false;
let index = this.players.indexOf(opp)
if (infoName[index]) infoName[index].parentElement.classList.add('defeated');
+
+ // Steal their caps
+ if (opp.cards.length > 0) {
+ player.cards.push(...opp.cards);
+
+ const lootFlavors = [
+ `pried a stash of Caps from ${originalOwner}'s cold, dead hands.`,
+ `raided ${originalOwner}'s footlocker and secured their funds.`,
+ `hacked ${originalOwner}'s personal terminal and drained their Cap reserves.`,
+ `shook down the remaining survivors for every last Bottle Cap.`
+ ];
+ let flavorText = lootFlavors[Math.floor(Math.random() * lootFlavors.length)];
+
+ if (Gamestate.logAction) {
+ Gamestate.logAction(`[ LOOT ] ${player.name} ${flavorText} (+${opp.cards.length} Caps)`, true);
+ }
+ opp.cards = [];
+ }
}
}
@@ -2302,9 +2539,8 @@ Gamestate.battle = async function(country, opponent, player, i){
if(player.areas.length === 42){
this.gameOver = true;
this.win(player);
- }
+ }
}
-
Gamestate.init();
// --- FALL RADIO BROADCAST SYSTEM ---