feat: add Risk board game with AI opponents
Implement the classic Risk board game with full game logic, AI players
with adjustable skill levels (1-5), and Phaser-based rendering.
New files:
- RiskData.js: map data, adjacency graph, continent bonuses, card deck
- RiskLogic.js: pure game engine (reinforce, attack, fortify phases)
- RiskAI.js: heuristic AI with 5 skill tiers and configurable behavior
- RiskGame.js: Phaser scene with combat animations and UI
- tutorial.md: in-game tutorial ("A Field Manual by General Ironside")
- verifyRisk.js: headless fixture and self-play verification script
Integrates with existing game framework via registry, game room dispatch,
and preload scene. Includes board image and updated pawn sprites.
This commit is contained in:
parent
2a22bdbcde
commit
ae30299838
Binary file not shown.
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 26 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.4 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 492 KiB |
|
|
@ -0,0 +1,194 @@
|
|||
// Risk AI — synchronous, in-browser heuristic. No deep search; a strategic
|
||||
// 1-ply evaluation over reinforce / attack / fortify decisions with a skill
|
||||
// model (1–5) tuning blunder rate, score noise, aggression and how much it
|
||||
// concentrates forces. Same shape as MonopolyAI / SplendorAI.
|
||||
|
||||
import {
|
||||
NUM_TERRITORIES, ADJ, CONTINENTS, CONTINENT_TERRITORIES,
|
||||
} from './RiskData.js';
|
||||
import {
|
||||
territoriesOf, countTerritories, legalAttacks, connectedOwned,
|
||||
hasValidSet, mustTradeCards,
|
||||
} from './RiskLogic.js';
|
||||
import { setValue } from './RiskData.js';
|
||||
|
||||
const SKILL_PROFILES = {
|
||||
1: { blunder: 0.40, noise: 5, minEdge: -1, riskier: 0.85, focus: 0.45, stopCard: 0.35, delay: [750, 1300] },
|
||||
2: { blunder: 0.24, noise: 4, minEdge: 0, riskier: 0.65, focus: 0.60, stopCard: 0.25, delay: [680, 1150] },
|
||||
3: { blunder: 0.12, noise: 3, minEdge: 0, riskier: 0.45, focus: 0.72, stopCard: 0.18, delay: [600, 1000] },
|
||||
4: { blunder: 0.05, noise: 2, minEdge: 1, riskier: 0.30, focus: 0.82, stopCard: 0.12, delay: [520, 900] },
|
||||
5: { blunder: 0.00, noise: 0, minEdge: 1, riskier: 0.20, focus: 0.90, stopCard: 0.08, delay: [440, 820] },
|
||||
};
|
||||
function profileFor(skill) {
|
||||
return SKILL_PROFILES[Math.max(1, Math.min(5, skill | 0))] ?? SKILL_PROFILES[3];
|
||||
}
|
||||
export function nextThinkDelay(skill) {
|
||||
const [lo, hi] = profileFor(skill).delay;
|
||||
return lo + Math.random() * (hi - lo);
|
||||
}
|
||||
const rand = (n) => Math.random() * n;
|
||||
const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];
|
||||
|
||||
// ── shared helpers ────────────────────────────────────────────────────────────
|
||||
function enemyNeighborArmies(s, seat, t) {
|
||||
let max = 0, sum = 0, count = 0;
|
||||
for (const nb of ADJ[t]) {
|
||||
if (s.owner[nb] !== seat) { sum += s.armies[nb]; count++; if (s.armies[nb] > max) max = s.armies[nb]; }
|
||||
}
|
||||
return { max, sum, count };
|
||||
}
|
||||
function isBorder(s, seat, t) {
|
||||
return ADJ[t].some((nb) => s.owner[nb] !== seat);
|
||||
}
|
||||
// Continents we fully own (worth defending) and ones we nearly own (worth taking).
|
||||
function continentStatus(s, seat) {
|
||||
const owned = [], nearly = [];
|
||||
for (const c of CONTINENTS) {
|
||||
const ids = CONTINENT_TERRITORIES[c.id];
|
||||
const mine = ids.filter((t) => s.owner[t] === seat).length;
|
||||
if (mine === ids.length) owned.push(c.id);
|
||||
else if (mine >= ids.length - 2) nearly.push(c.id);
|
||||
}
|
||||
return { owned, nearly };
|
||||
}
|
||||
|
||||
// ── card trading ──────────────────────────────────────────────────────────────
|
||||
// Returns a 3-card-id set to trade, or null. Always trades when forced (≥5
|
||||
// cards); otherwise cashes a set once it is worth a useful number of armies.
|
||||
export function chooseTrade(s, seat, skill) {
|
||||
const cards = s.players[seat].cards;
|
||||
const set = hasValidSet(cards);
|
||||
if (!set) return null;
|
||||
if (mustTradeCards(s, seat)) return set;
|
||||
const worth = setValue(s.setsCashed);
|
||||
// Higher skill waits for a slightly better escalating value before cashing.
|
||||
const threshold = profileFor(skill).minEdge >= 1 ? 8 : 6;
|
||||
if (worth >= threshold && cards.length >= 3) return set;
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── reinforcement placement ───────────────────────────────────────────────────
|
||||
// Returns a plan: [{ terr, n }] summing to s.reinforcements. Concentrates on the
|
||||
// most valuable border (threat to hold + opportunity to break a continent).
|
||||
export function planReinforcements(s, seat, skill) {
|
||||
const prof = profileFor(skill);
|
||||
const pool = s.reinforcements;
|
||||
if (pool <= 0) return [];
|
||||
|
||||
const mine = territoriesOf(s, seat);
|
||||
const borders = mine.filter((t) => isBorder(s, seat, t));
|
||||
if (borders.length === 0) return [{ terr: mine[0], n: pool }];
|
||||
|
||||
const { owned, nearly } = continentStatus(s, seat);
|
||||
const scored = borders.map((t) => {
|
||||
const en = enemyNeighborArmies(s, seat, t);
|
||||
let score = en.max * 1.0 + en.sum * 0.15; // threat to defend
|
||||
// opportunity: a weak enemy neighbour we could punch through
|
||||
for (const nb of ADJ[t]) {
|
||||
if (s.owner[nb] !== seat && s.armies[nb] < s.armies[t]) score += (s.armies[t] - s.armies[nb]) * 0.4;
|
||||
}
|
||||
if (owned.includes(CONTINENTS[territoryCont(t)].id)) score += 4; // hold the bonus
|
||||
if (nearly.includes(CONTINENTS[territoryCont(t)].id)) score += 3; // push to finish
|
||||
for (const nb of ADJ[t]) if (nearly.includes(CONTINENTS[territoryCont(nb)].id) && s.owner[nb] !== seat) score += 5;
|
||||
return { terr: t, score: score + rand(prof.noise) + 1 };
|
||||
});
|
||||
|
||||
// Blunder: just dump everything somewhere random.
|
||||
if (Math.random() < prof.blunder) return [{ terr: pick(borders), n: pool }];
|
||||
|
||||
scored.sort((a, b) => b.score - a.score);
|
||||
const plan = [];
|
||||
let left = pool;
|
||||
const primary = Math.max(1, Math.round(pool * prof.focus));
|
||||
plan.push({ terr: scored[0].terr, n: Math.min(primary, left) });
|
||||
left -= plan[0].n;
|
||||
let i = 1;
|
||||
while (left > 0 && i < scored.length) {
|
||||
const n = i === scored.length - 1 ? left : Math.max(1, Math.round(left / 2));
|
||||
plan.push({ terr: scored[i].terr, n: Math.min(n, left) });
|
||||
left -= n;
|
||||
i++;
|
||||
}
|
||||
if (left > 0) plan[0].n += left; // rounding remainder
|
||||
return plan;
|
||||
}
|
||||
function territoryCont(t) { return TERRITORY_CONT[t]; }
|
||||
|
||||
// ── attack decisions ──────────────────────────────────────────────────────────
|
||||
// Returns { from, to, numDice } or null to stop attacking this turn.
|
||||
export function chooseAttack(s, seat, skill) {
|
||||
const prof = profileFor(skill);
|
||||
const moves = legalAttacks(s, seat);
|
||||
if (moves.length === 0) return null;
|
||||
|
||||
const { nearly } = continentStatus(s, seat);
|
||||
const scored = [];
|
||||
for (const m of moves) {
|
||||
const att = s.armies[m.from] - 1; // attackers available
|
||||
const def = s.armies[m.to];
|
||||
const edge = att - def;
|
||||
let score = edge;
|
||||
// near-certain captures (lots of attackers vs 1–2 defenders) are great
|
||||
if (def <= 2 && att >= def + 2) score += 4;
|
||||
// completing / advancing a continent
|
||||
if (nearly.includes(CONTINENTS[TERRITORY_CONT[m.to]].id)) score += 5;
|
||||
// eliminating a player (they only hold this one) → steal their cards
|
||||
const defender = s.owner[m.to];
|
||||
if (defender >= 0 && countTerritories(s, defender) === 1) score += 6;
|
||||
scored.push({ m, edge, att, def, score: score + rand(prof.noise) });
|
||||
}
|
||||
scored.sort((a, b) => b.score - a.score);
|
||||
|
||||
// Acceptable edge depends on skill; with some probability take one riskier shot.
|
||||
const minEdge = Math.random() < prof.riskier ? prof.minEdge - 1 : prof.minEdge;
|
||||
const best = scored[0];
|
||||
const accept = best.edge >= minEdge || (best.def <= 2 && best.att >= best.def + 1);
|
||||
if (!accept) return null;
|
||||
|
||||
// Once we've already earned a card this turn, sometimes stop to preserve force.
|
||||
if (s.conqueredThisTurn && best.edge < prof.minEdge && Math.random() < prof.stopCard) return null;
|
||||
|
||||
return { from: best.m.from, to: best.m.to, numDice: Math.min(3, best.att) };
|
||||
}
|
||||
|
||||
// After a conquest, decide how many armies to pour into the captured territory.
|
||||
export function chooseAdvance(s, seat, skill) {
|
||||
const pc = s.pendingConquest;
|
||||
if (!pc) return 0;
|
||||
const fromBorder = isBorder(s, seat, pc.from);
|
||||
const toBorder = isBorder(s, seat, pc.to);
|
||||
let n;
|
||||
if (!fromBorder) n = pc.maxMove; // rear is safe → push everything forward
|
||||
else if (toBorder) n = Math.ceil((pc.minMove + pc.maxMove) / 2); // both fronts → split
|
||||
else n = pc.minMove; // captured a safe pocket → keep rear strong
|
||||
return Math.max(pc.minMove, Math.min(n, pc.maxMove));
|
||||
}
|
||||
|
||||
// ── fortify ───────────────────────────────────────────────────────────────────
|
||||
// Move a surplus from a safe interior territory toward the most threatened
|
||||
// connected border. Returns { from, to, n } or null to skip.
|
||||
export function chooseFortify(s, seat, skill) {
|
||||
const mine = territoriesOf(s, seat);
|
||||
// candidate sources: owned, >1 army, no enemy neighbours (safe interior)
|
||||
let best = null;
|
||||
for (const from of mine) {
|
||||
if (s.armies[from] < 2 || isBorder(s, seat, from)) continue;
|
||||
const reach = connectedOwned(s, seat, from).filter((t) => isBorder(s, seat, t));
|
||||
if (reach.length === 0) continue;
|
||||
// most threatened reachable border
|
||||
let target = null, threat = -1;
|
||||
for (const t of reach) {
|
||||
const en = enemyNeighborArmies(s, seat, t).max - s.armies[t];
|
||||
if (en > threat) { threat = en; target = t; }
|
||||
}
|
||||
const surplus = s.armies[from] - 1;
|
||||
const gain = surplus + threat;
|
||||
if (target != null && (!best || gain > best.gain)) best = { from, to: target, n: surplus, gain };
|
||||
}
|
||||
if (!best) return null;
|
||||
return { from: best.from, to: best.to, n: best.n };
|
||||
}
|
||||
|
||||
// Precomputed territory→continent lookup (avoids importing TERRITORIES here).
|
||||
const TERRITORY_CONT = new Array(NUM_TERRITORIES);
|
||||
for (const c of CONTINENTS) for (const t of CONTINENT_TERRITORIES[c.id]) TERRITORY_CONT[t] = c.id;
|
||||
|
|
@ -0,0 +1,204 @@
|
|||
// Risk — static map data. No state, no Phaser. The canonical 6-continent,
|
||||
// 42-territory world map plus the standard adjacency graph (including the
|
||||
// inter-continent links), continent bonuses, the territory-card deck, player
|
||||
// colours and the starting-army table.
|
||||
//
|
||||
// Territory anchors are expressed in the supplied board image's native pixel
|
||||
// space (risk-board.png is 1300×900). RiskGame maps these to screen space via a
|
||||
// single transform, so army badges land in the right spot at any display scale.
|
||||
|
||||
export const BOARD_W = 1300;
|
||||
export const BOARD_H = 900;
|
||||
|
||||
// ── Continents ────────────────────────────────────────────────────────────────
|
||||
// color is the fill used on the board image (for matching highlight tints).
|
||||
export const CONTINENTS = [
|
||||
{ id: 0, name: 'North America', bonus: 5, color: 0xc9c63e, colorHex: '#c9c63e' },
|
||||
{ id: 1, name: 'South America', bonus: 2, color: 0xe0492b, colorHex: '#e0492b' },
|
||||
{ id: 2, name: 'Europe', bonus: 5, color: 0x40b4e0, colorHex: '#40b4e0' },
|
||||
{ id: 3, name: 'Africa', bonus: 3, color: 0xa9772a, colorHex: '#a9772a' },
|
||||
{ id: 4, name: 'Asia', bonus: 7, color: 0x6ec46e, colorHex: '#6ec46e' },
|
||||
{ id: 5, name: 'Australia', bonus: 2, color: 0xb24fc8, colorHex: '#b24fc8' },
|
||||
];
|
||||
|
||||
// ── Territories ───────────────────────────────────────────────────────────────
|
||||
// id is the index in this array (0..41). cont is the continent id above.
|
||||
// x/y are board-native anchor coordinates for the army badge. These are derived
|
||||
// from the standard map layout and may want a small visual tuning pass against
|
||||
// the real image — RiskGame has a debug overlay (press D) to make that quick.
|
||||
export const TERRITORIES = [
|
||||
// North America (0..8)
|
||||
{ name: 'Alaska', cont: 0, x: 105, y: 140 },
|
||||
{ name: 'Northwest Territory', cont: 0, x: 215, y: 145 },
|
||||
{ name: 'Greenland', cont: 0, x: 460, y: 95 },
|
||||
{ name: 'Alberta', cont: 0, x: 205, y: 210 },
|
||||
{ name: 'Ontario', cont: 0, x: 275, y: 220 },
|
||||
{ name: 'Quebec', cont: 0, x: 375, y: 220 },
|
||||
{ name: 'Western United States', cont: 0, x: 205, y: 305 },
|
||||
{ name: 'Eastern United States', cont: 0, x: 310, y: 330 },
|
||||
{ name: 'Central America', cont: 0, x: 215, y: 410 },
|
||||
// South America (9..12)
|
||||
{ name: 'Venezuela', cont: 1, x: 305, y: 490 },
|
||||
{ name: 'Brazil', cont: 1, x: 405, y: 575 },
|
||||
{ name: 'Peru', cont: 1, x: 330, y: 600 },
|
||||
{ name: 'Argentina', cont: 1, x: 335, y: 690 },
|
||||
// Europe (13..19)
|
||||
{ name: 'Iceland', cont: 2, x: 560, y: 185 },
|
||||
{ name: 'Scandinavia', cont: 2, x: 660, y: 165 },
|
||||
{ name: 'Great Britain', cont: 2, x: 530, y: 260 },
|
||||
{ name: 'Northern Europe', cont: 2, x: 660, y: 300 },
|
||||
{ name: 'Western Europe', cont: 2, x: 570, y: 385 },
|
||||
{ name: 'Southern Europe', cont: 2, x: 670, y: 375 },
|
||||
{ name: 'Ukraine', cont: 2, x: 780, y: 235 },
|
||||
// Africa (20..25)
|
||||
{ name: 'North Africa', cont: 3, x: 595, y: 530 },
|
||||
{ name: 'Egypt', cont: 3, x: 700, y: 505 },
|
||||
{ name: 'East Africa', cont: 3, x: 745, y: 580 },
|
||||
{ name: 'Congo', cont: 3, x: 705, y: 645 },
|
||||
{ name: 'South Africa', cont: 3, x: 715, y: 760 },
|
||||
{ name: 'Madagascar', cont: 3, x: 825, y: 770 },
|
||||
// Asia (26..37)
|
||||
{ name: 'Ural', cont: 4, x: 890, y: 210 },
|
||||
{ name: 'Siberia', cont: 4, x: 970, y: 165 },
|
||||
{ name: 'Yakutsk', cont: 4, x: 1055, y: 125 },
|
||||
{ name: 'Kamchatka', cont: 4, x: 1150, y: 120 },
|
||||
{ name: 'Irkutsk', cont: 4, x: 1040, y: 225 },
|
||||
{ name: 'Mongolia', cont: 4, x: 1060, y: 305 },
|
||||
{ name: 'Japan', cont: 4, x: 1185, y: 305 },
|
||||
{ name: 'Afghanistan', cont: 4, x: 865, y: 320 },
|
||||
{ name: 'China', cont: 4, x: 1015, y: 395 },
|
||||
{ name: 'Middle East', cont: 4, x: 795, y: 450 },
|
||||
{ name: 'India', cont: 4, x: 945, y: 450 },
|
||||
{ name: 'Siam', cont: 4, x: 1045, y: 490 },
|
||||
// Australia (38..41)
|
||||
{ name: 'Indonesia', cont: 5, x: 1070, y: 635 },
|
||||
{ name: 'New Guinea', cont: 5, x: 1180, y: 600 },
|
||||
{ name: 'Western Australia', cont: 5, x: 1115, y: 755 },
|
||||
{ name: 'Eastern Australia', cont: 5, x: 1195, y: 710 },
|
||||
].map((t, id) => ({ id, ...t }));
|
||||
|
||||
export const NUM_TERRITORIES = TERRITORIES.length; // 42
|
||||
|
||||
// Name → id lookup (used to declare adjacency readably).
|
||||
const ID = {};
|
||||
for (const t of TERRITORIES) ID[t.name] = t.id;
|
||||
|
||||
// ── Adjacency (standard Risk) ─────────────────────────────────────────────────
|
||||
// Declared as undirected edges by name; buildAdj() makes a symmetric id graph.
|
||||
const EDGES = [
|
||||
// North America
|
||||
['Alaska', 'Northwest Territory'], ['Alaska', 'Alberta'], ['Alaska', 'Kamchatka'],
|
||||
['Northwest Territory', 'Alberta'], ['Northwest Territory', 'Ontario'], ['Northwest Territory', 'Greenland'],
|
||||
['Greenland', 'Ontario'], ['Greenland', 'Quebec'], ['Greenland', 'Iceland'],
|
||||
['Alberta', 'Ontario'], ['Alberta', 'Western United States'],
|
||||
['Ontario', 'Quebec'], ['Ontario', 'Western United States'], ['Ontario', 'Eastern United States'],
|
||||
['Quebec', 'Eastern United States'],
|
||||
['Western United States', 'Eastern United States'], ['Western United States', 'Central America'],
|
||||
['Eastern United States', 'Central America'],
|
||||
['Central America', 'Venezuela'],
|
||||
// South America
|
||||
['Venezuela', 'Brazil'], ['Venezuela', 'Peru'],
|
||||
['Brazil', 'Peru'], ['Brazil', 'Argentina'], ['Brazil', 'North Africa'],
|
||||
['Peru', 'Argentina'],
|
||||
// Europe
|
||||
['Iceland', 'Great Britain'], ['Iceland', 'Scandinavia'],
|
||||
['Scandinavia', 'Great Britain'], ['Scandinavia', 'Northern Europe'], ['Scandinavia', 'Ukraine'],
|
||||
['Great Britain', 'Northern Europe'], ['Great Britain', 'Western Europe'],
|
||||
['Northern Europe', 'Western Europe'], ['Northern Europe', 'Southern Europe'], ['Northern Europe', 'Ukraine'],
|
||||
['Western Europe', 'Southern Europe'], ['Western Europe', 'North Africa'],
|
||||
['Southern Europe', 'Ukraine'], ['Southern Europe', 'Middle East'], ['Southern Europe', 'Egypt'], ['Southern Europe', 'North Africa'],
|
||||
['Ukraine', 'Ural'], ['Ukraine', 'Afghanistan'], ['Ukraine', 'Middle East'],
|
||||
// Africa
|
||||
['North Africa', 'Egypt'], ['North Africa', 'East Africa'], ['North Africa', 'Congo'],
|
||||
['Egypt', 'East Africa'], ['Egypt', 'Middle East'],
|
||||
['East Africa', 'Congo'], ['East Africa', 'South Africa'], ['East Africa', 'Madagascar'], ['East Africa', 'Middle East'],
|
||||
['Congo', 'South Africa'],
|
||||
['South Africa', 'Madagascar'],
|
||||
// Asia
|
||||
['Ural', 'Siberia'], ['Ural', 'China'], ['Ural', 'Afghanistan'],
|
||||
['Siberia', 'Yakutsk'], ['Siberia', 'Irkutsk'], ['Siberia', 'Mongolia'], ['Siberia', 'China'],
|
||||
['Yakutsk', 'Irkutsk'], ['Yakutsk', 'Kamchatka'],
|
||||
['Kamchatka', 'Irkutsk'], ['Kamchatka', 'Mongolia'], ['Kamchatka', 'Japan'],
|
||||
['Irkutsk', 'Mongolia'],
|
||||
['Mongolia', 'Japan'], ['Mongolia', 'China'],
|
||||
['Afghanistan', 'China'], ['Afghanistan', 'India'], ['Afghanistan', 'Middle East'],
|
||||
['China', 'India'], ['China', 'Siam'],
|
||||
['Middle East', 'India'],
|
||||
['India', 'Siam'],
|
||||
['Siam', 'Indonesia'],
|
||||
// Australia
|
||||
['Indonesia', 'New Guinea'], ['Indonesia', 'Western Australia'],
|
||||
['New Guinea', 'Western Australia'], ['New Guinea', 'Eastern Australia'],
|
||||
['Western Australia', 'Eastern Australia'],
|
||||
];
|
||||
|
||||
// adj[id] = sorted array of neighbouring territory ids.
|
||||
export function buildAdj() {
|
||||
const adj = TERRITORIES.map(() => new Set());
|
||||
for (const [a, b] of EDGES) {
|
||||
const ia = ID[a], ib = ID[b];
|
||||
if (ia === undefined || ib === undefined) throw new Error(`bad edge ${a}-${b}`);
|
||||
adj[ia].add(ib);
|
||||
adj[ib].add(ia);
|
||||
}
|
||||
return adj.map((s) => [...s].sort((x, y) => x - y));
|
||||
}
|
||||
|
||||
export const ADJ = buildAdj();
|
||||
|
||||
// territoryIds per continent, for fast continent-control checks.
|
||||
export const CONTINENT_TERRITORIES = CONTINENTS.map((c) =>
|
||||
TERRITORIES.filter((t) => t.cont === c.id).map((t) => t.id),
|
||||
);
|
||||
|
||||
// ── Territory cards ───────────────────────────────────────────────────────────
|
||||
// 42 territory cards (one per territory) cycling the three unit types, plus two
|
||||
// wilds → 44 cards total, exactly like a standard Risk deck.
|
||||
export const CARD_INFANTRY = 'infantry';
|
||||
export const CARD_CAVALRY = 'cavalry';
|
||||
export const CARD_ARTILLERY = 'artillery';
|
||||
export const CARD_WILD = 'wild';
|
||||
|
||||
const CARD_CYCLE = [CARD_INFANTRY, CARD_CAVALRY, CARD_ARTILLERY];
|
||||
|
||||
// Returns a fresh deck: [{ id, territory|null, type }]. territory is the id the
|
||||
// card pictures (null for wilds — wilds never grant the +2 territory bonus).
|
||||
export function makeDeck() {
|
||||
const deck = TERRITORIES.map((t, i) => ({ id: i, territory: t.id, type: CARD_CYCLE[i % 3] }));
|
||||
deck.push({ id: 42, territory: null, type: CARD_WILD });
|
||||
deck.push({ id: 43, territory: null, type: CARD_WILD });
|
||||
return deck;
|
||||
}
|
||||
|
||||
// Escalating set values (modern official): 4, 6, 8, 10, 12, 15, then +5 each.
|
||||
// n = number of sets already cashed in this match (0-based for the next set).
|
||||
export function setValue(n) {
|
||||
const base = [4, 6, 8, 10, 12, 15];
|
||||
if (n < base.length) return base[n];
|
||||
return 15 + 5 * (n - base.length + 1);
|
||||
}
|
||||
|
||||
// Three cards form a valid set if all same type, all three different types, or
|
||||
// any combination including at least one wild. (i.e. wilds are universal.)
|
||||
export function isValidSet(cards) {
|
||||
if (cards.length !== 3) return false;
|
||||
const types = cards.map((c) => c.type);
|
||||
const wilds = types.filter((t) => t === CARD_WILD).length;
|
||||
if (wilds > 0) return true;
|
||||
const uniq = new Set(types);
|
||||
return uniq.size === 1 || uniq.size === 3;
|
||||
}
|
||||
|
||||
// ── Starting armies by player count (official) ────────────────────────────────
|
||||
export const STARTING_ARMIES = { 2: 40, 3: 35, 4: 30, 5: 25, 6: 20 };
|
||||
|
||||
// ── Player colours ────────────────────────────────────────────────────────────
|
||||
export const PLAYER_COLORS = [
|
||||
0xd83a3a, // red
|
||||
0x3a7bd8, // blue
|
||||
0x3ab54a, // green
|
||||
0xe0b020, // gold
|
||||
0x9b4fc8, // purple
|
||||
0x20232b, // black
|
||||
];
|
||||
export const PLAYER_COLOR_HEX = ['#d83a3a', '#3a7bd8', '#3ab54a', '#e0b020', '#9b4fc8', '#20232b'];
|
||||
|
|
@ -0,0 +1,627 @@
|
|||
import * as Phaser from 'phaser';
|
||||
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
|
||||
import { Button } from '../../ui/Button.js';
|
||||
import { auth } from '../../services/auth.js';
|
||||
import { api } from '../../services/api.js';
|
||||
import { playSound, SFX } from '../../ui/Sounds.js';
|
||||
import { MusicPlayer } from '../../ui/MusicPlayer.js';
|
||||
import { createOpponentPortrait, createPlayerPortrait } from '../../ui/Portrait.js';
|
||||
import {
|
||||
TERRITORIES, NUM_TERRITORIES, ADJ, CONTINENTS, CONTINENT_TERRITORIES,
|
||||
BOARD_W, BOARD_H, PLAYER_COLORS, PLAYER_COLOR_HEX, setValue,
|
||||
CARD_INFANTRY, CARD_CAVALRY, CARD_ARTILLERY, CARD_WILD,
|
||||
} from './RiskData.js';
|
||||
import {
|
||||
createInitialState, reinforcementCount, placeArmies, tradeCards,
|
||||
resolveAttack, advanceArmies, endAttack, fortify, endTurn,
|
||||
canAttack, connectedOwned, hasValidSet, mustTradeCards,
|
||||
territoriesOf, countTerritories, continentBonus, ownsContinent, isGameOver,
|
||||
} from './RiskLogic.js';
|
||||
import {
|
||||
chooseTrade, planReinforcements, chooseAttack, chooseAdvance, chooseFortify,
|
||||
nextThinkDelay,
|
||||
} from './RiskAI.js';
|
||||
|
||||
// ── Layout ──────────────────────────────────────────────────────────────────
|
||||
const PANEL_X = 1560;
|
||||
const PANEL_W = GAME_WIDTH - PANEL_X - 16; // ~344
|
||||
const MAP_LEFT = 16, MAP_TOP = 16;
|
||||
const MAP_AREA_W = PANEL_X - MAP_LEFT - 16; // ~1528
|
||||
const MAP_AREA_H = GAME_HEIGHT - MAP_TOP - 16; // ~1048
|
||||
const MAP_SCALE = Math.min(MAP_AREA_W / BOARD_W, MAP_AREA_H / BOARD_H);
|
||||
const DISP_W = BOARD_W * MAP_SCALE, DISP_H = BOARD_H * MAP_SCALE;
|
||||
const MAP_X = MAP_LEFT + (MAP_AREA_W - DISP_W) / 2;
|
||||
const MAP_Y = MAP_TOP + (MAP_AREA_H - DISP_H) / 2;
|
||||
const BADGE_R = 17;
|
||||
|
||||
const DEPTH = {
|
||||
bg: 0, board: 2, link: 3, highlight: 4, badge: 8, label: 9,
|
||||
panel: 20, ui: 25, dice: 40, popup: 50, banner: 90,
|
||||
};
|
||||
|
||||
const CARD_GLYPH = {
|
||||
[CARD_INFANTRY]: '🛡', [CARD_CAVALRY]: '🐎', [CARD_ARTILLERY]: '🎯', [CARD_WILD]: '★',
|
||||
};
|
||||
const CARD_LABEL = {
|
||||
[CARD_INFANTRY]: 'Infantry', [CARD_CAVALRY]: 'Cavalry', [CARD_ARTILLERY]: 'Artillery', [CARD_WILD]: 'Wild',
|
||||
};
|
||||
|
||||
export default class RiskGame extends Phaser.Scene {
|
||||
constructor() { super('RiskGame'); }
|
||||
|
||||
init(data) {
|
||||
this.gameDef = data.game ?? { slug: 'risk', name: 'Risk' };
|
||||
this.opponents = data.opponents ?? [];
|
||||
this.humanSeat = 0;
|
||||
this.gs = null;
|
||||
this.busy = false;
|
||||
this.selFrom = null; // selected source territory (attack/fortify)
|
||||
this.hoverTerr = null;
|
||||
this.dyn = []; // per-render disposables
|
||||
this.zones = []; // 42 interactive zones (created once)
|
||||
this.portraits = [];
|
||||
this.debug = false;
|
||||
this.gameOverShown = false;
|
||||
}
|
||||
|
||||
create() {
|
||||
try { new MusicPlayer(this, this.cache.json.get('music')?.tracks ?? []); } catch { /* */ }
|
||||
|
||||
const playerCount = Math.max(2, Math.min(6, 1 + this.opponents.length));
|
||||
const names = [];
|
||||
const skills = {};
|
||||
for (let seat = 0; seat < playerCount; seat++) {
|
||||
if (seat === this.humanSeat) { names.push(auth.user?.username ?? 'You'); skills[seat] = 5; }
|
||||
else {
|
||||
const opp = this.opponents[seat - 1];
|
||||
names.push(opp?.name ?? `Player ${seat + 1}`);
|
||||
skills[seat] = Math.max(1, Math.min(5, opp?.skill ?? 3));
|
||||
}
|
||||
}
|
||||
this.gs = createInitialState({ playerCount, names, skills });
|
||||
|
||||
this.buildBackground();
|
||||
this.buildBoard();
|
||||
this.buildPanel();
|
||||
this.buildPortraits();
|
||||
|
||||
new Button(this, GAME_WIDTH - 86, GAME_HEIGHT - 30, 'Leave',
|
||||
() => this.scene.start('GameMenu'),
|
||||
{ variant: 'ghost', width: 140, height: 44, fontSize: 20 }).setDepth(DEPTH.ui);
|
||||
|
||||
// Debug overlay toggle (territory ids/names) for coordinate tuning.
|
||||
this.input.keyboard?.on('keydown-D', () => { this.debug = !this.debug; this.render(); });
|
||||
|
||||
this.render();
|
||||
this.advance();
|
||||
}
|
||||
|
||||
// ── coordinate transform ────────────────────────────────────────────────────
|
||||
boardToScreen(x, y) { return { x: MAP_X + x * MAP_SCALE, y: MAP_Y + y * MAP_SCALE }; }
|
||||
|
||||
// ── static build ────────────────────────────────────────────────────────────
|
||||
buildBackground() {
|
||||
const g = this.add.graphics().setDepth(DEPTH.bg);
|
||||
g.fillStyle(0x0a1822, 1); g.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT);
|
||||
// subtle ocean panel behind the map
|
||||
g.fillStyle(0x0e2230, 1);
|
||||
g.fillRoundedRect(MAP_X - 8, MAP_Y - 8, DISP_W + 16, DISP_H + 16, 10);
|
||||
}
|
||||
|
||||
buildBoard() {
|
||||
if (this.textures.exists('risk-board')) {
|
||||
this.add.image(MAP_X + DISP_W / 2, MAP_Y + DISP_H / 2, 'risk-board')
|
||||
.setDisplaySize(DISP_W, DISP_H).setDepth(DEPTH.board);
|
||||
}
|
||||
// one interactive zone per territory (zones have a native hit area — no
|
||||
// Container-hitbox pitfall).
|
||||
const zr = Math.max(26, BADGE_R * 2.4);
|
||||
for (const t of TERRITORIES) {
|
||||
const p = this.boardToScreen(t.x, t.y);
|
||||
const z = this.add.zone(p.x, p.y, zr, zr).setInteractive({ useHandCursor: true }).setDepth(DEPTH.badge + 1);
|
||||
z.on('pointerover', () => { this.hoverTerr = t.id; this.render(); });
|
||||
z.on('pointerout', () => { if (this.hoverTerr === t.id) this.hoverTerr = null; this.render(); });
|
||||
z.on('pointerdown', () => this.onTerritoryClick(t.id));
|
||||
this.zones.push(z);
|
||||
}
|
||||
}
|
||||
|
||||
buildPanel() {
|
||||
const g = this.add.graphics().setDepth(DEPTH.panel);
|
||||
g.fillStyle(COLORS.panel, 0.96);
|
||||
g.fillRoundedRect(PANEL_X, 12, PANEL_W, GAME_HEIGHT - 24, 12);
|
||||
g.lineStyle(2, COLORS.accent, 0.8);
|
||||
g.strokeRoundedRect(PANEL_X, 12, PANEL_W, GAME_HEIGHT - 24, 12);
|
||||
this.add.text(PANEL_X + PANEL_W / 2, 40, 'RISK', {
|
||||
fontFamily: 'Righteous', fontSize: '34px', color: COLORS.goldHex,
|
||||
}).setOrigin(0.5).setDepth(DEPTH.panel);
|
||||
}
|
||||
|
||||
buildPortraits() {
|
||||
// small portraits stacked under the title for each player
|
||||
const x = PANEL_X + 34;
|
||||
let y = 96;
|
||||
for (let seat = 0; seat < this.gs.playerCount; seat++) {
|
||||
const p = this.gs.players[seat];
|
||||
let portrait = null;
|
||||
if (seat === this.humanSeat) portrait = createPlayerPortrait(this, x, y, 22, DEPTH.panel + 1, 'RiskGame');
|
||||
else portrait = createOpponentPortrait(this, this.opponents[seat - 1], x, y, 22, DEPTH.panel + 1, { playIntro: false });
|
||||
this.portraits.push({ seat, portrait, y });
|
||||
y += 60;
|
||||
}
|
||||
this.panelRowY = y; // first free y under portraits
|
||||
}
|
||||
|
||||
// ── helpers ─────────────────────────────────────────────────────────────────
|
||||
colorOf(seat) { return PLAYER_COLORS[seat % PLAYER_COLORS.length]; }
|
||||
colorHexOf(seat) { return PLAYER_COLOR_HEX[seat % PLAYER_COLOR_HEX.length]; }
|
||||
isHumanTurn() { return this.gs.current === this.humanSeat && !this.busy && !isGameOver(this.gs); }
|
||||
delay(ms) { return new Promise((res) => this.time.delayedCall(ms, res)); }
|
||||
|
||||
// territories the current selection can act on (attack targets / fortify targets)
|
||||
validTargets() {
|
||||
if (this.selFrom == null) return new Set();
|
||||
const s = this.gs, seat = s.current;
|
||||
if (s.phase === 'attack') return new Set(ADJ[this.selFrom].filter((t) => s.owner[t] !== seat));
|
||||
if (s.phase === 'fortify') return new Set(connectedOwned(s, seat, this.selFrom));
|
||||
return new Set();
|
||||
}
|
||||
|
||||
// ── render ────────────────────────────────────────────────────────────────────
|
||||
render() {
|
||||
this.dyn.forEach((o) => o.destroy?.());
|
||||
this.dyn = [];
|
||||
if (!this.gs) return;
|
||||
|
||||
const hi = this.add.graphics().setDepth(DEPTH.highlight); this.dyn.push(hi);
|
||||
const targets = this.validTargets();
|
||||
const s = this.gs;
|
||||
|
||||
// selection + target rings
|
||||
for (const t of TERRITORIES) {
|
||||
const p = this.boardToScreen(t.x, t.y);
|
||||
if (this.selFrom === t.id) {
|
||||
hi.lineStyle(4, COLORS.gold, 1); hi.strokeCircle(p.x, p.y, BADGE_R + 8);
|
||||
} else if (targets.has(t.id)) {
|
||||
const col = s.phase === 'attack' ? COLORS.danger : 0x4fd06a;
|
||||
hi.lineStyle(3, col, 0.95); hi.strokeCircle(p.x, p.y, BADGE_R + 6);
|
||||
}
|
||||
}
|
||||
|
||||
// army badges
|
||||
for (const t of TERRITORIES) {
|
||||
const p = this.boardToScreen(t.x, t.y);
|
||||
const owner = s.owner[t.id];
|
||||
const g = this.add.graphics().setDepth(DEPTH.badge); this.dyn.push(g);
|
||||
g.fillStyle(0x000000, 0.35); g.fillCircle(p.x + 1, p.y + 2, BADGE_R);
|
||||
g.fillStyle(this.colorOf(owner), 1); g.fillCircle(p.x, p.y, BADGE_R);
|
||||
g.lineStyle(2, 0xffffff, 0.9); g.strokeCircle(p.x, p.y, BADGE_R);
|
||||
const txt = this.add.text(p.x, p.y, String(s.armies[t.id]), {
|
||||
fontFamily: 'Righteous', fontSize: '18px', color: '#ffffff',
|
||||
}).setOrigin(0.5).setDepth(DEPTH.label); this.dyn.push(txt);
|
||||
}
|
||||
|
||||
// hovered / selected territory name label
|
||||
const nameId = this.hoverTerr ?? this.selFrom;
|
||||
if (nameId != null) {
|
||||
const t = TERRITORIES[nameId];
|
||||
const p = this.boardToScreen(t.x, t.y);
|
||||
const lbl = this.add.text(p.x, p.y - BADGE_R - 12, t.name, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '15px', color: COLORS.textHex,
|
||||
backgroundColor: '#000000aa', padding: { x: 5, y: 2 },
|
||||
}).setOrigin(0.5, 1).setDepth(DEPTH.label); this.dyn.push(lbl);
|
||||
}
|
||||
|
||||
// debug ids
|
||||
if (this.debug) {
|
||||
for (const t of TERRITORIES) {
|
||||
const p = this.boardToScreen(t.x, t.y);
|
||||
const d = this.add.text(p.x, p.y + BADGE_R + 2, `${t.id}`, {
|
||||
fontFamily: 'monospace', fontSize: '11px', color: '#ffff66',
|
||||
}).setOrigin(0.5, 0).setDepth(DEPTH.label); this.dyn.push(d);
|
||||
}
|
||||
}
|
||||
|
||||
this.renderPanel();
|
||||
}
|
||||
|
||||
renderPanel() {
|
||||
const s = this.gs;
|
||||
let y = this.panelRowY + 6;
|
||||
const lx = PANEL_X + 16, rx = PANEL_X + PANEL_W - 16;
|
||||
|
||||
// per-player rows
|
||||
for (let seat = 0; seat < s.playerCount; seat++) {
|
||||
const p = s.players[seat];
|
||||
const isCur = seat === s.current && !isGameOver(s);
|
||||
const terr = countTerritories(s, seat);
|
||||
let army = 0; for (let t = 0; t < NUM_TERRITORIES; t++) if (s.owner[t] === seat) army += s.armies[t];
|
||||
const name = p.alive ? p.name : `${p.name} (out)`;
|
||||
const col = p.alive ? this.colorHexOf(seat) : COLORS.mutedHex;
|
||||
const sw = this.add.graphics().setDepth(DEPTH.panel + 1); this.dyn.push(sw);
|
||||
sw.fillStyle(this.colorOf(seat), p.alive ? 1 : 0.4); sw.fillRoundedRect(lx, y, 14, 14, 3);
|
||||
if (isCur) { sw.lineStyle(2, COLORS.gold, 1); sw.strokeRoundedRect(lx - 2, y - 2, 18, 18, 4); }
|
||||
const t1 = this.add.text(lx + 22, y - 1, name, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '16px', color: isCur ? COLORS.goldHex : col,
|
||||
}).setOrigin(0, 0).setDepth(DEPTH.panel + 1); this.dyn.push(t1);
|
||||
const t2 = this.add.text(rx, y - 1, `${terr}⬡ ${army}⚔`, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '15px', color: COLORS.mutedHex,
|
||||
}).setOrigin(1, 0).setDepth(DEPTH.panel + 1); this.dyn.push(t2);
|
||||
y += 26;
|
||||
}
|
||||
y += 6;
|
||||
const sep = this.add.graphics().setDepth(DEPTH.panel + 1); this.dyn.push(sep);
|
||||
sep.lineStyle(1, COLORS.accent, 0.4); sep.lineBetween(lx, y, rx, y);
|
||||
y += 12;
|
||||
|
||||
// current phase + instructions
|
||||
const phaseName = isGameOver(s) ? 'Game Over' :
|
||||
({ reinforce: 'Reinforce', attack: 'Attack', fortify: 'Fortify' }[s.phase] ?? s.phase);
|
||||
const who = s.current === this.humanSeat ? 'Your' : `${s.players[s.current].name}'s`;
|
||||
const ph = this.add.text(lx, y, `${who} turn — ${phaseName}`, {
|
||||
fontFamily: 'Righteous', fontSize: '18px', color: COLORS.textHex, wordWrap: { width: PANEL_W - 32 },
|
||||
}).setOrigin(0, 0).setDepth(DEPTH.panel + 1); this.dyn.push(ph);
|
||||
y += 34;
|
||||
|
||||
if (s.phase === 'reinforce' && !isGameOver(s)) {
|
||||
const info = this.add.text(lx, y, `Armies to place: ${s.reinforcements}`, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '17px', color: COLORS.goldHex,
|
||||
}).setOrigin(0, 0).setDepth(DEPTH.panel + 1); this.dyn.push(info);
|
||||
y += 28;
|
||||
}
|
||||
|
||||
// current player's card count
|
||||
const cardN = s.players[s.current].cards.length;
|
||||
const cc = this.add.text(lx, y, `Cards: ${cardN}` + (s.current === this.humanSeat ? '' : ''), {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '15px', color: COLORS.mutedHex,
|
||||
}).setOrigin(0, 0).setDepth(DEPTH.panel + 1); this.dyn.push(cc);
|
||||
y += 30;
|
||||
|
||||
this.panelControlsY = y;
|
||||
this.renderControls();
|
||||
}
|
||||
|
||||
renderControls() {
|
||||
if (!this.isHumanTurn()) return;
|
||||
const s = this.gs;
|
||||
const cx = PANEL_X + PANEL_W / 2;
|
||||
let y = this.panelControlsY + 4;
|
||||
const mk = (label, fn, opts = {}) => {
|
||||
const b = new Button(this, cx, y, label, fn,
|
||||
{ width: PANEL_W - 40, height: 46, fontSize: 20, ...opts }).setDepth(DEPTH.ui);
|
||||
this.dyn.push(b); y += 56;
|
||||
return b;
|
||||
};
|
||||
|
||||
if (s.phase === 'reinforce') {
|
||||
const human = s.players[this.humanSeat];
|
||||
const set = hasValidSet(human.cards);
|
||||
if (set) mk(mustTradeCards(s, this.humanSeat) ? 'Trade Cards (required)' : 'Trade Cards', () => this.openTradeModal());
|
||||
const hint = this.add.text(cx, y, 'Click your territories to place armies', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.mutedHex,
|
||||
align: 'center', wordWrap: { width: PANEL_W - 36 },
|
||||
}).setOrigin(0.5, 0).setDepth(DEPTH.ui); this.dyn.push(hint);
|
||||
} else if (s.phase === 'attack') {
|
||||
mk('End Attack ▸', () => { this.selFrom = null; this.gs = endAttack(this.gs); this.render(); this.advance(); });
|
||||
const hint = this.add.text(cx, y, this.selFrom == null
|
||||
? 'Click one of your territories (2+ armies) to attack from'
|
||||
: 'Click a red enemy territory to attack', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.mutedHex,
|
||||
align: 'center', wordWrap: { width: PANEL_W - 36 },
|
||||
}).setOrigin(0.5, 0).setDepth(DEPTH.ui); this.dyn.push(hint);
|
||||
} else if (s.phase === 'fortify') {
|
||||
mk('Skip / End Turn ▸', () => { this.selFrom = null; this.gs = endTurn(this.gs); this.render(); this.advance(); });
|
||||
const hint = this.add.text(cx, y, this.selFrom == null
|
||||
? 'Optionally fortify: click a source territory'
|
||||
: 'Click a green connected territory to move armies', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.mutedHex,
|
||||
align: 'center', wordWrap: { width: PANEL_W - 36 },
|
||||
}).setOrigin(0.5, 0).setDepth(DEPTH.ui); this.dyn.push(hint);
|
||||
}
|
||||
}
|
||||
|
||||
// ── human input ───────────────────────────────────────────────────────────────
|
||||
onTerritoryClick(id) {
|
||||
if (!this.isHumanTurn()) return;
|
||||
const s = this.gs, seat = s.current;
|
||||
|
||||
if (s.phase === 'reinforce') {
|
||||
if (s.owner[id] !== seat) return;
|
||||
if (mustTradeCards(s, seat)) { this.openTradeModal(); return; }
|
||||
playSound(this, SFX.PIECE_CLICK);
|
||||
this.gs = placeArmies(s, id, 1);
|
||||
this.render();
|
||||
if (this.gs.phase !== 'reinforce') this.advance(); // pool emptied → attack
|
||||
return;
|
||||
}
|
||||
|
||||
if (s.phase === 'attack') {
|
||||
if (s.owner[id] === seat && s.armies[id] >= 2 && ADJ[id].some((t) => s.owner[t] !== seat)) {
|
||||
this.selFrom = id; this.render(); return;
|
||||
}
|
||||
if (this.selFrom != null && canAttack(s, seat, this.selFrom, id)) { this.doHumanAttack(this.selFrom, id); return; }
|
||||
this.selFrom = null; this.render(); return;
|
||||
}
|
||||
|
||||
if (s.phase === 'fortify') {
|
||||
if (this.selFrom == null) {
|
||||
if (s.owner[id] === seat && s.armies[id] >= 2 && connectedOwned(s, seat, id).length > 0) {
|
||||
this.selFrom = id; this.render();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (id === this.selFrom) { this.selFrom = null; this.render(); return; }
|
||||
if (s.owner[id] === seat && connectedOwned(s, seat, this.selFrom).includes(id)) {
|
||||
const from = this.selFrom, to = id;
|
||||
this.openMoveModal(s.armies[from] - 1, s.armies[from] - 1, 1, (n) => {
|
||||
this.selFrom = null;
|
||||
this.gs = fortify(this.gs, from, to, n);
|
||||
this.render(); this.advance();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async doHumanAttack(from, to) {
|
||||
this.busy = true;
|
||||
const before = this.gs;
|
||||
this.gs = resolveAttack(this.gs, from, to);
|
||||
await this.animateBattle(this.gs.lastBattle);
|
||||
this.render();
|
||||
|
||||
if (this.gs.pendingConquest) {
|
||||
const pc = this.gs.pendingConquest;
|
||||
this.busy = false;
|
||||
this.openMoveModal(pc.minMove, pc.maxMove, pc.minMove, (n) => {
|
||||
this.gs = advanceArmies(this.gs, n);
|
||||
// keep attacking from same territory if still able
|
||||
if (this.gs.owner[from] === before.current && this.gs.armies[from] < 2) this.selFrom = null;
|
||||
this.render();
|
||||
if (isGameOver(this.gs)) this.showGameOver();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.gs.armies[from] < 2) this.selFrom = null;
|
||||
this.busy = false;
|
||||
this.render();
|
||||
if (isGameOver(this.gs)) this.showGameOver();
|
||||
}
|
||||
|
||||
// ── trade modal ───────────────────────────────────────────────────────────────
|
||||
openTradeModal() {
|
||||
const seat = this.humanSeat;
|
||||
const cards = this.gs.players[seat].cards;
|
||||
const objs = [];
|
||||
const overlay = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.6)
|
||||
.setInteractive().setDepth(DEPTH.popup); objs.push(overlay);
|
||||
const W = 720, H = 420, cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
|
||||
const panel = this.add.graphics().setDepth(DEPTH.popup + 1); objs.push(panel);
|
||||
panel.fillStyle(COLORS.panel, 1); panel.fillRoundedRect(cx - W / 2, cy - H / 2, W, H, 14);
|
||||
panel.lineStyle(2, COLORS.accent, 1); panel.strokeRoundedRect(cx - W / 2, cy - H / 2, W, H, 14);
|
||||
objs.push(this.add.text(cx, cy - H / 2 + 32, `Trade a Set (next set = ${setValue(this.gs.setsCashed)} armies)`, {
|
||||
fontFamily: 'Righteous', fontSize: '26px', color: COLORS.goldHex,
|
||||
}).setOrigin(0.5).setDepth(DEPTH.popup + 2));
|
||||
|
||||
const selected = new Set();
|
||||
const chipObjs = [];
|
||||
let tradeBtn = null;
|
||||
|
||||
const refreshTradeBtn = () => {
|
||||
const sel = [...selected].map((i) => cards[i]);
|
||||
const valid = sel.length === 3 && hasValidSet(sel);
|
||||
tradeBtn.setAlpha(valid ? 1 : 0.4);
|
||||
tradeBtn.disabledForTrade = !valid;
|
||||
};
|
||||
|
||||
const drawChips = () => {
|
||||
chipObjs.forEach((o) => o.destroy());
|
||||
chipObjs.length = 0;
|
||||
const per = 5;
|
||||
const cw = 120, ch = 150, gap = 14;
|
||||
cards.forEach((card, i) => {
|
||||
const row = Math.floor(i / per), colI = i % per;
|
||||
const rowCount = Math.min(per, cards.length - row * per);
|
||||
const startX = cx - ((rowCount * cw + (rowCount - 1) * gap) / 2) + cw / 2;
|
||||
const x = startX + colI * (cw + gap);
|
||||
const yy = cy - 40 + row * (ch + gap);
|
||||
const g = this.add.graphics().setDepth(DEPTH.popup + 2);
|
||||
const on = selected.has(i);
|
||||
g.fillStyle(on ? COLORS.gold : 0x2a2418, 1); g.fillRoundedRect(x - cw / 2, yy - ch / 2, cw, ch, 10);
|
||||
g.lineStyle(2, on ? 0xffffff : COLORS.accent, 0.9); g.strokeRoundedRect(x - cw / 2, yy - ch / 2, cw, ch, 10);
|
||||
const glyph = this.add.text(x, yy - 30, CARD_GLYPH[card.type], { fontSize: '40px' }).setOrigin(0.5).setDepth(DEPTH.popup + 3);
|
||||
const tl = this.add.text(x, yy + 16, CARD_LABEL[card.type], {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '15px', color: on ? COLORS.textDarkHex : COLORS.textHex,
|
||||
}).setOrigin(0.5).setDepth(DEPTH.popup + 3);
|
||||
const tn = this.add.text(x, yy + 42, card.territory != null ? TERRITORIES[card.territory].name : '—', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '12px', color: on ? COLORS.textDarkHex : COLORS.mutedHex,
|
||||
wordWrap: { width: cw - 12 }, align: 'center',
|
||||
}).setOrigin(0.5, 0).setDepth(DEPTH.popup + 3);
|
||||
const z = this.add.zone(x, yy, cw, ch).setInteractive({ useHandCursor: true }).setDepth(DEPTH.popup + 4);
|
||||
z.on('pointerdown', () => {
|
||||
if (selected.has(i)) selected.delete(i);
|
||||
else { if (selected.size >= 3) selected.delete([...selected][0]); selected.add(i); }
|
||||
drawChips(); refreshTradeBtn();
|
||||
});
|
||||
chipObjs.push(g, glyph, tl, tn, z);
|
||||
});
|
||||
};
|
||||
|
||||
const close = () => { objs.forEach((o) => o.destroy()); chipObjs.forEach((o) => o.destroy()); this.render(); };
|
||||
|
||||
tradeBtn = new Button(this, cx - 130, cy + H / 2 - 40, 'Trade', () => {
|
||||
if (tradeBtn.disabledForTrade) return;
|
||||
const ids = [...selected].map((i) => cards[i].id);
|
||||
this.gs = tradeCards(this.gs, ids);
|
||||
playSound(this, SFX.COINS);
|
||||
close();
|
||||
if (!mustTradeCards(this.gs, seat)) { /* allow closing */ }
|
||||
else { this.openTradeModal(); return; } // still forced → reopen
|
||||
this.render();
|
||||
}, { width: 220, height: 48, fontSize: 22 }).setDepth(DEPTH.popup + 3);
|
||||
objs.push(tradeBtn);
|
||||
|
||||
if (!mustTradeCards(this.gs, seat)) {
|
||||
const closeBtn = new Button(this, cx + 130, cy + H / 2 - 40, 'Close', close,
|
||||
{ width: 220, height: 48, fontSize: 22, variant: 'ghost' }).setDepth(DEPTH.popup + 3);
|
||||
objs.push(closeBtn);
|
||||
}
|
||||
|
||||
drawChips(); refreshTradeBtn();
|
||||
}
|
||||
|
||||
// ── move-amount modal (advance after conquest / fortify) ────────────────────────
|
||||
openMoveModal(min, max, initial, onConfirm) {
|
||||
if (max <= min) { onConfirm(min); return; }
|
||||
let val = Math.max(min, Math.min(initial, max));
|
||||
const objs = [];
|
||||
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
|
||||
const W = 460, H = 240;
|
||||
const overlay = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.55).setInteractive().setDepth(DEPTH.popup); objs.push(overlay);
|
||||
const panel = this.add.graphics().setDepth(DEPTH.popup + 1); objs.push(panel);
|
||||
panel.fillStyle(COLORS.panel, 1); panel.fillRoundedRect(cx - W / 2, cy - H / 2, W, H, 14);
|
||||
panel.lineStyle(2, COLORS.accent, 1); panel.strokeRoundedRect(cx - W / 2, cy - H / 2, W, H, 14);
|
||||
objs.push(this.add.text(cx, cy - H / 2 + 30, 'Move armies', {
|
||||
fontFamily: 'Righteous', fontSize: '24px', color: COLORS.goldHex,
|
||||
}).setOrigin(0.5).setDepth(DEPTH.popup + 2));
|
||||
const valTxt = this.add.text(cx, cy - 10, String(val), {
|
||||
fontFamily: 'Righteous', fontSize: '52px', color: COLORS.textHex,
|
||||
}).setOrigin(0.5).setDepth(DEPTH.popup + 2); objs.push(valTxt);
|
||||
const rng = this.add.text(cx, cy + 34, `min ${min} · max ${max}`, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '15px', color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5).setDepth(DEPTH.popup + 2); objs.push(rng);
|
||||
const upd = () => valTxt.setText(String(val));
|
||||
const minus = new Button(this, cx - 150, cy - 4, '−', () => { val = Math.max(min, val - 1); upd(); }, { width: 64, height: 64, fontSize: 34 }).setDepth(DEPTH.popup + 2); objs.push(minus);
|
||||
const plus = new Button(this, cx + 150, cy - 4, '+', () => { val = Math.min(max, val + 1); upd(); }, { width: 64, height: 64, fontSize: 34 }).setDepth(DEPTH.popup + 2); objs.push(plus);
|
||||
const ok = new Button(this, cx, cy + H / 2 - 34, 'Confirm', () => { objs.forEach((o) => o.destroy()); onConfirm(val); }, { width: 260, height: 46, fontSize: 22 }).setDepth(DEPTH.popup + 2); objs.push(ok);
|
||||
}
|
||||
|
||||
// ── combat animation ────────────────────────────────────────────────────────────
|
||||
async animateBattle(b) {
|
||||
if (!b) return;
|
||||
playSound(this, SFX.DICE_ROLL);
|
||||
const from = this.boardToScreen(TERRITORIES[b.from].x, TERRITORIES[b.from].y);
|
||||
const to = this.boardToScreen(TERRITORIES[b.to].x, TERRITORIES[b.to].y);
|
||||
const mx = (from.x + to.x) / 2, my = (from.y + to.y) / 2;
|
||||
const objs = [];
|
||||
const drawDie = (x, y, val, col) => {
|
||||
const g = this.add.graphics().setDepth(DEPTH.dice);
|
||||
g.fillStyle(col, 1); g.fillRoundedRect(x - 18, y - 18, 36, 36, 6);
|
||||
g.lineStyle(2, 0x000000, 0.6); g.strokeRoundedRect(x - 18, y - 18, 36, 36, 6);
|
||||
const t = this.add.text(x, y, String(val), { fontFamily: 'Righteous', fontSize: '22px', color: col === 0xffffff ? '#222' : '#fff' }).setOrigin(0.5).setDepth(DEPTH.dice + 1);
|
||||
objs.push(g, t);
|
||||
};
|
||||
b.aRolls.forEach((v, i) => drawDie(mx - 28, my - 26 + i * 40, v, 0xd83a3a));
|
||||
b.dRolls.forEach((v, i) => drawDie(mx + 28, my - 26 + i * 40, v, 0xffffff));
|
||||
const res = this.add.text(mx, my + 64, `−${b.aLoss} / −${b.dLoss}`, {
|
||||
fontFamily: 'Righteous', fontSize: '20px', color: COLORS.goldHex, backgroundColor: '#000000aa', padding: { x: 6, y: 3 },
|
||||
}).setOrigin(0.5).setDepth(DEPTH.dice + 1); objs.push(res);
|
||||
if (b.conquered) playSound(this, SFX.SWORD_HIT);
|
||||
await this.delay(b.conquered ? 720 : 560);
|
||||
objs.forEach((o) => o.destroy());
|
||||
}
|
||||
|
||||
// ── turn loop ───────────────────────────────────────────────────────────────────
|
||||
async advance() {
|
||||
if (this.busy) return;
|
||||
if (isGameOver(this.gs)) { this.showGameOver(); return; }
|
||||
if (this.gs.current === this.humanSeat) { this.render(); return; } // wait for input
|
||||
this.busy = true;
|
||||
try {
|
||||
await this.runAiTurn(this.gs.current);
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
this.render();
|
||||
if (isGameOver(this.gs)) { this.showGameOver(); return; }
|
||||
this.time.delayedCall(60, () => this.advance());
|
||||
}
|
||||
|
||||
async runAiTurn(seat) {
|
||||
const skill = this.gs.players[seat].skill;
|
||||
this.setPortraitThinking(seat, true);
|
||||
await this.delay(nextThinkDelay(skill));
|
||||
|
||||
// reinforce: trades then placements
|
||||
let g = 0;
|
||||
while (this.gs.phase === 'reinforce' && g++ < 12) {
|
||||
const set = chooseTrade(this.gs, seat, skill);
|
||||
if (!set) break;
|
||||
this.gs = tradeCards(this.gs, set);
|
||||
this.render(); await this.delay(420);
|
||||
}
|
||||
for (const step of planReinforcements(this.gs, seat, skill)) {
|
||||
if (this.gs.phase !== 'reinforce') break;
|
||||
this.gs = placeArmies(this.gs, step.terr, step.n);
|
||||
this.render(); await this.delay(260);
|
||||
}
|
||||
g = 0;
|
||||
while (this.gs.phase === 'reinforce' && g++ < 200) {
|
||||
const mine = territoriesOf(this.gs, seat);
|
||||
this.gs = placeArmies(this.gs, mine[0], this.gs.reinforcements);
|
||||
}
|
||||
|
||||
// attack
|
||||
g = 0;
|
||||
while (this.gs.phase === 'attack' && g++ < 400) {
|
||||
const atk = chooseAttack(this.gs, seat, skill);
|
||||
if (!atk) { this.gs = endAttack(this.gs); break; }
|
||||
this.gs = resolveAttack(this.gs, atk.from, atk.to, atk.numDice);
|
||||
await this.animateBattle(this.gs.lastBattle);
|
||||
if (this.gs.pendingConquest) this.gs = advanceArmies(this.gs, chooseAdvance(this.gs, seat, skill));
|
||||
this.render();
|
||||
if (isGameOver(this.gs)) { this.setPortraitThinking(seat, false); return; }
|
||||
await this.delay(200);
|
||||
}
|
||||
|
||||
// fortify
|
||||
if (this.gs.phase === 'fortify') {
|
||||
const f = chooseFortify(this.gs, seat, skill);
|
||||
this.gs = f ? fortify(this.gs, f.from, f.to, f.n) : endTurn(this.gs);
|
||||
this.render(); await this.delay(220);
|
||||
}
|
||||
this.setPortraitThinking(seat, false);
|
||||
}
|
||||
|
||||
setPortraitThinking(seat, on) {
|
||||
const p = this.portraits.find((x) => x.seat === seat);
|
||||
p?.portrait?.playEmotion?.(on ? 'happy' : 'idle');
|
||||
}
|
||||
|
||||
// ── game over ───────────────────────────────────────────────────────────────────
|
||||
showGameOver() {
|
||||
if (this.gameOverShown) return;
|
||||
this.gameOverShown = true;
|
||||
playSound(this, SFX.VICTORY_SHORT);
|
||||
this.postHistory().catch(() => {});
|
||||
|
||||
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
|
||||
this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.7).setInteractive().setDepth(DEPTH.banner);
|
||||
const W = 640, H = 320;
|
||||
const g = this.add.graphics().setDepth(DEPTH.banner + 1);
|
||||
g.fillStyle(COLORS.panel, 1); g.fillRoundedRect(cx - W / 2, cy - H / 2, W, H, 16);
|
||||
g.lineStyle(3, COLORS.gold, 1); g.strokeRoundedRect(cx - W / 2, cy - H / 2, W, H, 16);
|
||||
const winner = this.gs.winner;
|
||||
const won = winner === this.humanSeat;
|
||||
this.add.text(cx, cy - 80, won ? 'Victory!' : 'Defeat', {
|
||||
fontFamily: 'Righteous', fontSize: '56px', color: won ? COLORS.goldHex : COLORS.dangerHex,
|
||||
}).setOrigin(0.5).setDepth(DEPTH.banner + 2);
|
||||
this.add.text(cx, cy - 8, winner != null ? `${this.gs.players[winner].name} conquers the world` : 'Stalemate', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.textHex,
|
||||
}).setOrigin(0.5).setDepth(DEPTH.banner + 2);
|
||||
new Button(this, cx, cy + H / 2 - 46, 'Back to Menu', () => this.scene.start('GameMenu'),
|
||||
{ width: 300, fontSize: 24 }).setDepth(DEPTH.banner + 2);
|
||||
}
|
||||
|
||||
async postHistory() {
|
||||
const s = this.gs;
|
||||
const counts = s.players.map((p, seat) => countTerritories(s, seat));
|
||||
const result = s.winner === this.humanSeat ? 'win' : 'loss';
|
||||
await api.post('/history/single-player', {
|
||||
slug: 'risk',
|
||||
score: counts[this.humanSeat],
|
||||
opponentScores: counts.filter((_, i) => i !== this.humanSeat),
|
||||
result,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,339 @@
|
|||
// Risk — pure game engine. No Phaser, no timers. Deterministic given a seed so
|
||||
// the AI self-play verification is reproducible. Every exported mutator clones
|
||||
// the input state, mutates the clone (including the RNG cursor) and returns it.
|
||||
//
|
||||
// Modern official "World Domination" rules: reinforce → attack → fortify, dice
|
||||
// combat (attacker up to 3 dice, defender up to 2, ties to the defender),
|
||||
// territory cards with escalating set values, continent bonuses, and victory by
|
||||
// controlling all 42 territories.
|
||||
|
||||
import {
|
||||
TERRITORIES, NUM_TERRITORIES, ADJ, CONTINENTS, CONTINENT_TERRITORIES,
|
||||
STARTING_ARMIES, makeDeck, setValue, isValidSet, CARD_WILD,
|
||||
} from './RiskData.js';
|
||||
|
||||
// ── seeded RNG (mulberry32, cursor stored on the state) ───────────────────────
|
||||
function rng(s) {
|
||||
let a = s.rngState | 0;
|
||||
a = (a + 0x6d2b79f5) | 0;
|
||||
s.rngState = a;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
}
|
||||
function rollDie(s) { return 1 + Math.floor(rng(s) * 6); }
|
||||
function shuffle(s, arr) {
|
||||
const a = arr.slice();
|
||||
for (let i = a.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(rng(s) * (i + 1));
|
||||
[a[i], a[j]] = [a[j], a[i]];
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
// ── clone ─────────────────────────────────────────────────────────────────────
|
||||
function clone(s) {
|
||||
return {
|
||||
...s,
|
||||
players: s.players.map((p) => ({ ...p, cards: p.cards.slice() })),
|
||||
owner: s.owner.slice(),
|
||||
armies: s.armies.slice(),
|
||||
deck: s.deck.slice(),
|
||||
discard: s.discard.slice(),
|
||||
pendingConquest: s.pendingConquest ? { ...s.pendingConquest } : null,
|
||||
lastBattle: s.lastBattle ? { ...s.lastBattle } : null,
|
||||
};
|
||||
}
|
||||
|
||||
// ── queries ───────────────────────────────────────────────────────────────────
|
||||
export function territoriesOf(s, seat) {
|
||||
const out = [];
|
||||
for (let t = 0; t < NUM_TERRITORIES; t++) if (s.owner[t] === seat) out.push(t);
|
||||
return out;
|
||||
}
|
||||
export function countTerritories(s, seat) {
|
||||
let n = 0;
|
||||
for (let t = 0; t < NUM_TERRITORIES; t++) if (s.owner[t] === seat) n++;
|
||||
return n;
|
||||
}
|
||||
export function ownsContinent(s, seat, contId) {
|
||||
return CONTINENT_TERRITORIES[contId].every((t) => s.owner[t] === seat);
|
||||
}
|
||||
export function continentBonus(s, seat) {
|
||||
let b = 0;
|
||||
for (const c of CONTINENTS) if (ownsContinent(s, seat, c.id)) b += c.bonus;
|
||||
return b;
|
||||
}
|
||||
// Armies a player gets to place at the start of their reinforce phase (before
|
||||
// any card trade): max(3, floor(territories/3)) + continent bonuses.
|
||||
export function reinforcementCount(s, seat) {
|
||||
const terr = countTerritories(s, seat);
|
||||
return Math.max(3, Math.floor(terr / 3)) + continentBonus(s, seat);
|
||||
}
|
||||
export function isAlive(s, seat) { return s.players[seat].alive; }
|
||||
export function aliveSeats(s) { return s.players.filter((p) => p.alive).map((p) => p.seat); }
|
||||
|
||||
// from→to is a legal attack for `seat`?
|
||||
export function canAttack(s, seat, from, to) {
|
||||
return s.owner[from] === seat && s.owner[to] !== seat &&
|
||||
s.armies[from] >= 2 && ADJ[from].includes(to);
|
||||
}
|
||||
// All legal attacks for seat: [{ from, to }].
|
||||
export function legalAttacks(s, seat) {
|
||||
const out = [];
|
||||
for (let f = 0; f < NUM_TERRITORIES; f++) {
|
||||
if (s.owner[f] !== seat || s.armies[f] < 2) continue;
|
||||
for (const to of ADJ[f]) if (s.owner[to] !== seat) out.push({ from: f, to });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
// Territories reachable from `from` through a chain of seat-owned territories
|
||||
// (used for the modern "fortify along a connected path" rule). Excludes `from`.
|
||||
export function connectedOwned(s, seat, from) {
|
||||
const seen = new Set([from]);
|
||||
const stack = [from];
|
||||
while (stack.length) {
|
||||
const cur = stack.pop();
|
||||
for (const nb of ADJ[cur]) {
|
||||
if (s.owner[nb] === seat && !seen.has(nb)) { seen.add(nb); stack.push(nb); }
|
||||
}
|
||||
}
|
||||
seen.delete(from);
|
||||
return [...seen];
|
||||
}
|
||||
export function mustTradeCards(s, seat) { return s.players[seat].cards.length >= 5; }
|
||||
export function hasValidSet(cards) {
|
||||
for (let i = 0; i < cards.length; i++)
|
||||
for (let j = i + 1; j < cards.length; j++)
|
||||
for (let k = j + 1; k < cards.length; k++)
|
||||
if (isValidSet([cards[i], cards[j], cards[k]])) return [cards[i].id, cards[j].id, cards[k].id];
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── setup ─────────────────────────────────────────────────────────────────────
|
||||
// Auto-deal quick start: distribute all 42 territories round-robin, scatter each
|
||||
// player's starting armies across their own territories, then play begins with
|
||||
// seat 0's reinforce phase.
|
||||
export function createInitialState({ playerCount, names = [], skills = {}, seed } = {}) {
|
||||
const pc = Math.max(2, Math.min(6, playerCount | 0));
|
||||
const s0 = { rngState: (seed ?? (Date.now() ^ (Math.random() * 1e9))) | 0 };
|
||||
|
||||
const players = [];
|
||||
for (let seat = 0; seat < pc; seat++) {
|
||||
players.push({
|
||||
seat,
|
||||
name: names[seat] ?? `Player ${seat + 1}`,
|
||||
skill: Math.max(1, Math.min(5, skills[seat] ?? 3)),
|
||||
alive: true,
|
||||
cards: [],
|
||||
});
|
||||
}
|
||||
|
||||
const owner = new Array(NUM_TERRITORIES).fill(-1);
|
||||
const armies = new Array(NUM_TERRITORIES).fill(0);
|
||||
|
||||
// Deal territories round-robin in random order.
|
||||
const order = shuffle(s0, TERRITORIES.map((t) => t.id));
|
||||
order.forEach((tid, i) => { owner[tid] = i % pc; armies[tid] = 1; });
|
||||
|
||||
// Scatter remaining starting armies across each player's own territories.
|
||||
const startTotal = STARTING_ARMIES[pc];
|
||||
for (let seat = 0; seat < pc; seat++) {
|
||||
const mine = order.filter((tid) => owner[tid] === seat);
|
||||
let remaining = startTotal - mine.length;
|
||||
while (remaining > 0) {
|
||||
armies[mine[Math.floor(rng(s0) * mine.length)]]++;
|
||||
remaining--;
|
||||
}
|
||||
}
|
||||
|
||||
const deck = shuffle(s0, makeDeck());
|
||||
|
||||
const s = {
|
||||
rngState: s0.rngState,
|
||||
playerCount: pc,
|
||||
players,
|
||||
owner,
|
||||
armies,
|
||||
deck,
|
||||
discard: [],
|
||||
setsCashed: 0,
|
||||
current: 0,
|
||||
phase: 'reinforce',
|
||||
reinforcements: 0,
|
||||
conqueredThisTurn: false,
|
||||
pendingConquest: null,
|
||||
lastBattle: null,
|
||||
winner: null,
|
||||
};
|
||||
s.reinforcements = reinforcementCount(s, 0);
|
||||
return s;
|
||||
}
|
||||
|
||||
// ── reinforce phase ───────────────────────────────────────────────────────────
|
||||
// Place `n` armies on an owned territory. When the pool empties, advance to the
|
||||
// attack phase automatically (all reinforcements must be placed).
|
||||
export function placeArmies(s0, terr, n = 1) {
|
||||
const s = clone(s0);
|
||||
if (s.phase !== 'reinforce' || s.owner[terr] !== s.current) return s;
|
||||
const amt = Math.max(0, Math.min(n, s.reinforcements));
|
||||
s.armies[terr] += amt;
|
||||
s.reinforcements -= amt;
|
||||
if (s.reinforcements <= 0) s.phase = 'attack';
|
||||
return s;
|
||||
}
|
||||
|
||||
// Cash in a 3-card set. Adds the (escalating) set value to the reinforcement
|
||||
// pool; if the player owns a territory pictured on a traded card, +2 armies go
|
||||
// straight onto one such territory (official territory bonus). Cards discarded.
|
||||
export function tradeCards(s0, cardIds) {
|
||||
const s = clone(s0);
|
||||
if (s.phase !== 'reinforce') return s;
|
||||
const seat = s.current;
|
||||
const p = s.players[seat];
|
||||
const chosen = cardIds.map((id) => p.cards.find((c) => c.id === id)).filter(Boolean);
|
||||
if (chosen.length !== 3 || !isValidSet(chosen)) return s;
|
||||
|
||||
// remove from hand → discard
|
||||
p.cards = p.cards.filter((c) => !cardIds.includes(c.id));
|
||||
for (const c of chosen) s.discard.push(c);
|
||||
|
||||
s.reinforcements += setValue(s.setsCashed);
|
||||
s.setsCashed += 1;
|
||||
|
||||
// +2 territory bonus onto one matching owned territory.
|
||||
const match = chosen.find((c) => c.territory != null && s.owner[c.territory] === seat);
|
||||
if (match) s.armies[match.territory] += 2;
|
||||
|
||||
return s;
|
||||
}
|
||||
|
||||
// ── attack phase ──────────────────────────────────────────────────────────────
|
||||
// Resolve a single dice exchange from→to. `numDice` defaults to the legal max.
|
||||
// On a conquest the defender's territory flips to the attacker and a
|
||||
// pendingConquest is set — the caller must then call advanceArmies().
|
||||
export function resolveAttack(s0, from, to, numDice) {
|
||||
const s = clone(s0);
|
||||
const seat = s.current;
|
||||
if (s.phase !== 'attack' || s.pendingConquest) return s;
|
||||
if (!canAttack(s, seat, from, to)) return s;
|
||||
|
||||
const aDiceMax = Math.min(3, s.armies[from] - 1);
|
||||
const aDice = Math.max(1, Math.min(numDice ?? aDiceMax, aDiceMax));
|
||||
const dDice = Math.min(2, s.armies[to]);
|
||||
|
||||
const aRolls = Array.from({ length: aDice }, () => rollDie(s)).sort((x, y) => y - x);
|
||||
const dRolls = Array.from({ length: dDice }, () => rollDie(s)).sort((x, y) => y - x);
|
||||
|
||||
let aLoss = 0, dLoss = 0;
|
||||
const pairs = Math.min(aDice, dDice);
|
||||
for (let i = 0; i < pairs; i++) {
|
||||
if (aRolls[i] > dRolls[i]) dLoss++; else aLoss++; // ties to defender
|
||||
}
|
||||
s.armies[from] -= aLoss;
|
||||
s.armies[to] -= dLoss;
|
||||
|
||||
const conquered = s.armies[to] <= 0;
|
||||
s.lastBattle = { from, to, aRolls, dRolls, aLoss, dLoss, conquered };
|
||||
|
||||
if (conquered) {
|
||||
const defender = s.owner[to];
|
||||
s.owner[to] = seat;
|
||||
s.armies[to] = 0;
|
||||
s.conqueredThisTurn = true;
|
||||
const minMove = aDice; // must move at least as many as dice rolled
|
||||
const maxMove = s.armies[from] - 1;
|
||||
s.pendingConquest = { from, to, minMove, maxMove };
|
||||
// Defender elimination → steal their cards.
|
||||
if (countTerritories(s, defender) === 0) {
|
||||
s.players[defender].alive = false;
|
||||
s.players[seat].cards.push(...s.players[defender].cards);
|
||||
s.players[defender].cards = [];
|
||||
}
|
||||
if (countTerritories(s, seat) === NUM_TERRITORIES) {
|
||||
// World domination — move the minimum into the last territory so it never
|
||||
// sits at zero armies, then end the game.
|
||||
s.armies[from] -= minMove;
|
||||
s.armies[to] += minMove;
|
||||
s.winner = seat;
|
||||
s.phase = 'gameOver';
|
||||
s.pendingConquest = null;
|
||||
}
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// Move `n` armies from the conquering territory into the just-captured one.
|
||||
export function advanceArmies(s0, n) {
|
||||
const s = clone(s0);
|
||||
const pc = s.pendingConquest;
|
||||
if (!pc) return s;
|
||||
const amt = Math.max(pc.minMove, Math.min(n, pc.maxMove));
|
||||
s.armies[pc.from] -= amt;
|
||||
s.armies[pc.to] += amt;
|
||||
s.pendingConquest = null;
|
||||
return s;
|
||||
}
|
||||
|
||||
// End the attack phase → fortify.
|
||||
export function endAttack(s0) {
|
||||
const s = clone(s0);
|
||||
if (s.phase === 'attack' && !s.pendingConquest) s.phase = 'fortify';
|
||||
return s;
|
||||
}
|
||||
|
||||
// ── fortify phase ─────────────────────────────────────────────────────────────
|
||||
// Move armies from one owned territory to a connected owned territory (once),
|
||||
// then the turn ends. n is clamped to leave at least one army behind.
|
||||
export function fortify(s0, from, to, n) {
|
||||
let s = clone(s0);
|
||||
if (s.phase !== 'fortify') return s;
|
||||
const seat = s.current;
|
||||
if (s.owner[from] !== seat || s.owner[to] !== seat || from === to) return s;
|
||||
if (!connectedOwned(s, seat, from).includes(to)) return s;
|
||||
const amt = Math.max(0, Math.min(n, s.armies[from] - 1));
|
||||
s.armies[from] -= amt;
|
||||
s.armies[to] += amt;
|
||||
return endTurn(s);
|
||||
}
|
||||
|
||||
// ── turn transition ───────────────────────────────────────────────────────────
|
||||
function drawCard(s) {
|
||||
if (s.deck.length === 0) {
|
||||
s.deck = shuffle(s, s.discard);
|
||||
s.discard = [];
|
||||
}
|
||||
return s.deck.length ? s.deck.pop() : null;
|
||||
}
|
||||
|
||||
// End the current player's turn: award a card if they conquered, then pass to
|
||||
// the next living player and open their reinforce phase. Detects victory.
|
||||
export function endTurn(s0) {
|
||||
const s = clone(s0);
|
||||
if (s.phase === 'gameOver') return s;
|
||||
const seat = s.current;
|
||||
|
||||
if (s.conqueredThisTurn) {
|
||||
const card = drawCard(s);
|
||||
if (card) s.players[seat].cards.push(card);
|
||||
}
|
||||
|
||||
const alive = aliveSeats(s);
|
||||
if (alive.length <= 1) {
|
||||
s.winner = alive[0] ?? null;
|
||||
s.phase = 'gameOver';
|
||||
return s;
|
||||
}
|
||||
|
||||
// next living seat
|
||||
let next = s.current;
|
||||
do { next = (next + 1) % s.playerCount; } while (!s.players[next].alive);
|
||||
s.current = next;
|
||||
s.phase = 'reinforce';
|
||||
s.reinforcements = reinforcementCount(s, next);
|
||||
s.conqueredThisTurn = false;
|
||||
s.pendingConquest = null;
|
||||
return s;
|
||||
}
|
||||
|
||||
export function isGameOver(s) { return s.phase === 'gameOver'; }
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
# Risk — A Field Manual by General Ironside
|
||||
|
||||
*At ease, recruit. They tell me you've never commanded so much as a parking lot, and now you want the whole world. Bold. I like bold. Bold gets continents. Bold also gets you eliminated by turn six, so listen up while I explain how we conquer the planet — properly.*
|
||||
|
||||
---
|
||||
|
||||
## The Objective
|
||||
|
||||
There is exactly one way to win Risk: **own all 42 territories.** Every other player must be wiped off the map. There are no points, no second place, no participation ribbons. Total world domination, soldier. That's the job.
|
||||
|
||||
Be patient. The board starts split between 2 to 6 commanders, and grinding everyone down takes time. Win the long campaign, not the loud skirmish.
|
||||
|
||||
---
|
||||
|
||||
## The Board
|
||||
|
||||
The world is divided into **6 continents** and **42 territories**. Territories connect by land borders — and a few sea routes I've marked into the lines on the map (Alaska shakes hands with Kamchatka; Brazil with North Africa; and so on). You may only attack a territory that borders one of yours.
|
||||
|
||||
Holding an **entire continent** at the start of your turn pays a bonus in extra armies every turn:
|
||||
|
||||
| Continent | Bonus Armies |
|
||||
|-----------|--------------|
|
||||
| Asia | +7 |
|
||||
| North America | +5 |
|
||||
| Europe | +5 |
|
||||
| Africa | +3 |
|
||||
| South America | +2 |
|
||||
| Australia | +2 |
|
||||
|
||||
Australia is the rookie's fortress — only **one** border to defend (through Indonesia). Asia is the prize, but it's a sprawling deathtrap with too many doors. Choose your real estate like your life depends on it. It does.
|
||||
|
||||
---
|
||||
|
||||
## A Turn, Step by Step
|
||||
|
||||
Each of your turns has **three phases**, in order:
|
||||
|
||||
### 1. Reinforce
|
||||
You receive new armies to place on your own territories:
|
||||
- **Territories ÷ 3** (rounded down), but never fewer than **3**.
|
||||
- **Plus** any continent bonuses you hold.
|
||||
- **Plus** armies from trading in a matching set of cards (below).
|
||||
|
||||
Click your territories to drop the armies wherever you'll need them — stack a fist where you mean to punch, or a wall where you expect to be hit.
|
||||
|
||||
### 2. Attack
|
||||
Click one of your territories (it needs **at least 2 armies**), then click a bordering enemy territory.
|
||||
|
||||
Dice decide it:
|
||||
- The **attacker** rolls up to **3 dice** (one fewer than the armies in the attacking territory, max 3).
|
||||
- The **defender** rolls up to **2 dice** (max 2, equal to armies present).
|
||||
- Highest die vs highest die, second vs second. **Higher wins; ties go to the defender.** Each loss removes one army.
|
||||
|
||||
Keep attacking as long as you like. Reduce a territory to zero and it's **yours** — you must then advance at least as many armies as the dice you rolled. When you're done, end the attack phase.
|
||||
|
||||
### 3. Fortify
|
||||
Once per turn you may shuffle armies inward or outward: move any number from one territory to **another connected territory you own** (leaving at least one behind). Consolidate your front. Then your turn ends.
|
||||
|
||||
---
|
||||
|
||||
## Territory Cards
|
||||
|
||||
Capture **at least one territory** on your turn and you earn **one card** at turn's end. Cards come as **Infantry**, **Cavalry**, or **Artillery** (plus a couple of **Wild** cards).
|
||||
|
||||
Trade in a set of **three of a kind** or **three different types** (wilds fill any gap) during your Reinforce phase for bonus armies. The sets escalate every time *anyone* cashes one: **4, 6, 8, 10, 12, 15**, then +5 each after. If a traded card pictures a territory you own, drop **2 extra armies** right onto it.
|
||||
|
||||
Hold **5 cards and you must trade** at the start of your turn. Eliminate a rival and you seize their hand — a fat stack of cards is a sudden army waiting to happen.
|
||||
|
||||
---
|
||||
|
||||
## Ironside's Doctrine
|
||||
|
||||
- **Take a continent, then defend its borders.** A bonus every turn compounds like nothing else.
|
||||
- **Don't overextend.** A thin line of single armies is just a row of dominoes for the next commander.
|
||||
- **Mass your dice.** Always attack with the full 3 where you can — the math favors the bigger stack.
|
||||
- **Time your cards.** Sometimes the smart move is one more conquest to grab a card before you stop.
|
||||
- **Pick on the weak, and watch the strong.** Eliminate stragglers for their cards; never wake a sleeping giant on the far side of the board.
|
||||
|
||||
*Now get out there. The world won't take itself. Dismissed.*
|
||||
|
|
@ -79,6 +79,7 @@ import DotLinkGame from './games/dotlink/DotLinkGame.js';
|
|||
import Game2048 from './games/2048/2048Game.js';
|
||||
import RummikubGame from './games/rummikub/RummikubGame.js';
|
||||
import GinRummyGame from './games/ginrummy/GinRummyGame.js';
|
||||
import RiskGame from './games/risk/RiskGame.js';
|
||||
|
||||
const config = {
|
||||
type: Phaser.AUTO,
|
||||
|
|
@ -171,6 +172,7 @@ const config = {
|
|||
Game2048,
|
||||
RummikubGame,
|
||||
GinRummyGame,
|
||||
RiskGame,
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export default class GameRoomScene extends Phaser.Scene {
|
|||
}
|
||||
|
||||
create() {
|
||||
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame' };
|
||||
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame' };
|
||||
if (slugDispatch[this.game.slug]) {
|
||||
this.scene.start(slugDispatch[this.game.slug], {
|
||||
game: this.game,
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ export default class PreloadScene extends Phaser.Scene {
|
|||
frameWidth: 312,
|
||||
frameHeight: 312,
|
||||
});
|
||||
this.load.image('risk-board', '/assets/images/risk-board.png');
|
||||
this.load.image('catan-robber', '/assets/images/catan-robber.png');
|
||||
this.load.image('catan-pirate', '/assets/images/catan-pirate.png');
|
||||
this.load.image('bg-menu', '/assets/images/background-menu.png');
|
||||
|
|
|
|||
|
|
@ -94,3 +94,4 @@ registerGame({ slug: 'dotlink', name: 'Dot Link', category: 'logic', minPlayers:
|
|||
registerGame({ slug: '2048', name: '2048', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 67 });
|
||||
registerGame({ slug: 'rummikub', name: 'Rummikub', category: 'cards', cardGame: true, minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, hasTutorial: true, iconFrame: 68 });
|
||||
registerGame({ slug: 'ginrummy', name: 'Gin Rummy', category: 'cards', cardGame: true, minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, hasTutorial: false, iconFrame: 69 });
|
||||
registerGame({ slug: 'risk', name: 'Risk', category: 'tabletop', minPlayers: 2, maxPlayers: 6, minOpponents: 1, maxOpponents: 5, hasTutorial: true, iconFrame: 54 });
|
||||
|
|
|
|||
|
|
@ -0,0 +1,213 @@
|
|||
// Headless verification for Risk.
|
||||
// node server/scripts/verifyRisk.js [--games=N]
|
||||
// Exits non-zero on any failure.
|
||||
//
|
||||
// 1. Fixture tests: map integrity (adjacency symmetry, continents, deck),
|
||||
// card-set values/validity, combat-loss bounds, reinforcement math.
|
||||
// 2. Self-play: full all-AI games asserting invariants every turn (42 owned
|
||||
// territories, ≥1 army each, valid phases) and that the match terminates
|
||||
// with a single winner, over many seeded games.
|
||||
|
||||
import {
|
||||
TERRITORIES, NUM_TERRITORIES, ADJ, CONTINENTS, CONTINENT_TERRITORIES,
|
||||
makeDeck, setValue, isValidSet, CARD_INFANTRY, CARD_CAVALRY, CARD_ARTILLERY,
|
||||
CARD_WILD,
|
||||
} from '../../public/src/games/risk/RiskData.js';
|
||||
import {
|
||||
createInitialState, reinforcementCount, resolveAttack, advanceArmies,
|
||||
placeArmies, tradeCards, endAttack, fortify, endTurn, isGameOver,
|
||||
territoriesOf, countTerritories, legalAttacks, canAttack,
|
||||
} from '../../public/src/games/risk/RiskLogic.js';
|
||||
import {
|
||||
chooseTrade, planReinforcements, chooseAttack, chooseAdvance, chooseFortify,
|
||||
} from '../../public/src/games/risk/RiskAI.js';
|
||||
|
||||
let failures = 0;
|
||||
function check(name, cond, detail = '') {
|
||||
if (cond) { console.log(` ok ${name}`); return; }
|
||||
failures++;
|
||||
console.error(` FAIL ${name}${detail ? ` — ${detail}` : ''}`);
|
||||
}
|
||||
|
||||
const arg = process.argv.find((a) => a.startsWith('--games='));
|
||||
const GAMES = arg ? Math.max(1, parseInt(arg.split('=')[1], 10) || 0) : 200;
|
||||
|
||||
// ── 1. Map integrity ────────────────────────────────────────────────────────────
|
||||
console.log('Map integrity:');
|
||||
check('42 territories', NUM_TERRITORIES === 42, `got ${NUM_TERRITORIES}`);
|
||||
{
|
||||
// adjacency symmetric, no self-loops, valid ids
|
||||
let sym = true, selfLoop = false, bad = false;
|
||||
for (let t = 0; t < NUM_TERRITORIES; t++) {
|
||||
for (const nb of ADJ[t]) {
|
||||
if (nb === t) selfLoop = true;
|
||||
if (nb < 0 || nb >= NUM_TERRITORIES) bad = true;
|
||||
if (!ADJ[nb].includes(t)) sym = false;
|
||||
}
|
||||
}
|
||||
check('adjacency symmetric', sym);
|
||||
check('no self-loops', !selfLoop);
|
||||
check('adjacency ids valid', !bad);
|
||||
// graph connected (you can reach every territory)
|
||||
const seen = new Set([0]); const stack = [0];
|
||||
while (stack.length) { for (const nb of ADJ[stack.pop()]) if (!seen.has(nb)) { seen.add(nb); stack.push(nb); } }
|
||||
check('map fully connected', seen.size === NUM_TERRITORIES, `reached ${seen.size}`);
|
||||
}
|
||||
{
|
||||
// each territory in exactly one continent; continents partition the map
|
||||
const counts = new Array(NUM_TERRITORIES).fill(0);
|
||||
for (const c of CONTINENTS) for (const t of CONTINENT_TERRITORIES[c.id]) counts[t]++;
|
||||
check('continents partition map', counts.every((n) => n === 1));
|
||||
const total = CONTINENT_TERRITORIES.reduce((a, ids) => a + ids.length, 0);
|
||||
check('continent territory total = 42', total === 42, `got ${total}`);
|
||||
}
|
||||
|
||||
// ── 2. Cards ────────────────────────────────────────────────────────────────────
|
||||
console.log('Cards:');
|
||||
{
|
||||
const deck = makeDeck();
|
||||
check('deck has 44 cards', deck.length === 44, `got ${deck.length}`);
|
||||
check('deck has 2 wilds', deck.filter((c) => c.type === CARD_WILD).length === 2);
|
||||
const T = (type) => ({ id: Math.random(), territory: null, type });
|
||||
check('three different is a set', isValidSet([T(CARD_INFANTRY), T(CARD_CAVALRY), T(CARD_ARTILLERY)]));
|
||||
check('three same is a set', isValidSet([T(CARD_CAVALRY), T(CARD_CAVALRY), T(CARD_CAVALRY)]));
|
||||
check('wild completes a set', isValidSet([T(CARD_INFANTRY), T(CARD_INFANTRY), T(CARD_WILD)]));
|
||||
check('two same + one diff is NOT a set', !isValidSet([T(CARD_INFANTRY), T(CARD_INFANTRY), T(CARD_CAVALRY)]));
|
||||
check('set values escalate 4,6,8,10,12,15', [0, 1, 2, 3, 4, 5].map(setValue).join(',') === '4,6,8,10,12,15');
|
||||
check('set value 6th=20, 7th=25', setValue(6) === 20 && setValue(7) === 25, `${setValue(6)},${setValue(7)}`);
|
||||
}
|
||||
|
||||
// ── 3. Combat loss bounds ────────────────────────────────────────────────────────
|
||||
console.log('Combat:');
|
||||
{
|
||||
// From a controlled 2-player state, run many single exchanges and check that
|
||||
// per-exchange losses never exceed 2 and a conquest flips ownership.
|
||||
let badLoss = false, conquestSeen = false, ownershipOk = true;
|
||||
let s = createInitialState({ playerCount: 2, seed: 123456 });
|
||||
// Force a known battle: seat 0 owns territory 0 with a big stack attacking nb.
|
||||
const from = 0, to = ADJ[0][0];
|
||||
s = { ...s, owner: s.owner.slice(), armies: s.armies.slice() };
|
||||
s.owner[from] = 0; s.owner[to] = 1; s.armies[from] = 20; s.armies[to] = 5;
|
||||
s.current = 0; s.phase = 'attack'; s.pendingConquest = null;
|
||||
for (let i = 0; i < 200 && s.owner[to] === 1; i++) {
|
||||
const beforeA = s.armies[from], beforeD = s.armies[to];
|
||||
s = resolveAttack(s, from, to);
|
||||
const lost = (beforeA - s.armies[from]) + (beforeD - Math.max(0, s.armies[to]));
|
||||
if (lost > 2) badLoss = true;
|
||||
if (s.pendingConquest) { conquestSeen = true; s = advanceArmies(s, s.pendingConquest.maxMove); }
|
||||
}
|
||||
check('per-exchange losses ≤ 2', !badLoss);
|
||||
check('conquest occurs & flips ownership', conquestSeen && s.owner[to] === 0);
|
||||
if (s.owner[to] === 0 && s.armies[to] < 1) ownershipOk = false;
|
||||
check('captured territory keeps ≥1 army', ownershipOk);
|
||||
}
|
||||
|
||||
// ── 4. Reinforcement math ────────────────────────────────────────────────────────
|
||||
console.log('Reinforcements:');
|
||||
{
|
||||
let s = createInitialState({ playerCount: 3, seed: 99 });
|
||||
// Give seat 0 all of Australia (continent 5, +2) plus enough territories.
|
||||
s = { ...s, owner: s.owner.slice() };
|
||||
for (let t = 0; t < NUM_TERRITORIES; t++) s.owner[t] = 1; // everything to seat 1
|
||||
const aus = CONTINENT_TERRITORIES[5];
|
||||
for (const t of aus) s.owner[t] = 0; // seat 0 owns Australia (4)
|
||||
const base = Math.max(3, Math.floor(4 / 3)); // 3
|
||||
check('floor(terr/3) min 3 + continent bonus', reinforcementCount(s, 0) === base + 2,
|
||||
`got ${reinforcementCount(s, 0)}`);
|
||||
}
|
||||
|
||||
// ── 5. Self-play ─────────────────────────────────────────────────────────────────
|
||||
console.log(`Self-play (${GAMES} games):`);
|
||||
const TURN_CAP = 3000;
|
||||
let wins = {}, draws = 0, exceptions = 0, invariantFails = 0, longest = 0, totalTurns = 0;
|
||||
|
||||
function checkInvariants(s) {
|
||||
let owned = 0, minArmy = Infinity;
|
||||
for (let t = 0; t < NUM_TERRITORIES; t++) {
|
||||
const o = s.owner[t];
|
||||
if (o < 0 || o >= s.playerCount) { invariantFails++; return; }
|
||||
if (!s.players[o].alive) { invariantFails++; return; }
|
||||
owned++;
|
||||
if (s.armies[t] < minArmy) minArmy = s.armies[t];
|
||||
}
|
||||
if (owned !== NUM_TERRITORIES) invariantFails++;
|
||||
if (minArmy < 1) invariantFails++;
|
||||
}
|
||||
|
||||
function playOneTurn(s) {
|
||||
const seat = s.current;
|
||||
const skill = s.players[seat].skill;
|
||||
|
||||
// reinforce: trade (forced or worthwhile), then place all armies
|
||||
let g = 0;
|
||||
while (s.phase === 'reinforce' && g++ < 12) {
|
||||
const set = chooseTrade(s, seat, skill);
|
||||
if (!set) break;
|
||||
s = tradeCards(s, set);
|
||||
}
|
||||
for (const step of planReinforcements(s, seat, skill)) {
|
||||
if (s.phase !== 'reinforce') break;
|
||||
s = placeArmies(s, step.terr, step.n);
|
||||
}
|
||||
g = 0;
|
||||
while (s.phase === 'reinforce' && g++ < 200) { // safety: dump any remainder
|
||||
const mine = territoriesOf(s, seat);
|
||||
s = placeArmies(s, mine[0], s.reinforcements);
|
||||
}
|
||||
|
||||
// attack
|
||||
g = 0;
|
||||
while (s.phase === 'attack' && g++ < 2000) {
|
||||
const atk = chooseAttack(s, seat, skill);
|
||||
if (!atk) { s = endAttack(s); break; }
|
||||
s = resolveAttack(s, atk.from, atk.to, atk.numDice);
|
||||
if (s.pendingConquest) s = advanceArmies(s, chooseAdvance(s, seat, skill));
|
||||
if (isGameOver(s)) return s;
|
||||
}
|
||||
if (isGameOver(s)) return s;
|
||||
|
||||
// fortify (and end turn)
|
||||
if (s.phase === 'fortify') {
|
||||
const f = chooseFortify(s, seat, skill);
|
||||
s = f ? fortify(s, f.from, f.to, f.n) : endTurn(s);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
for (let game = 0; game < GAMES; game++) {
|
||||
const pc = 2 + (game % 5); // cycle 2..6 players
|
||||
const skills = {};
|
||||
for (let i = 0; i < pc; i++) skills[i] = 2 + ((game + i) % 4); // skills 2..5
|
||||
try {
|
||||
let s = createInitialState({ playerCount: pc, skills, seed: (game * 2654435761) | 0 });
|
||||
let turns = 0;
|
||||
while (!isGameOver(s) && turns < TURN_CAP) {
|
||||
s = playOneTurn(s);
|
||||
checkInvariants(s);
|
||||
turns++;
|
||||
}
|
||||
totalTurns += turns;
|
||||
longest = Math.max(longest, turns);
|
||||
if (isGameOver(s) && s.winner != null) wins[s.winner] = (wins[s.winner] ?? 0) + 1;
|
||||
else draws++; // hit the turn cap → unresolved
|
||||
} catch (e) {
|
||||
exceptions++;
|
||||
if (exceptions <= 3) console.error(' exception:', e?.stack ?? e);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(` games: ${GAMES}`);
|
||||
console.log(` resolved: ${GAMES - draws - exceptions}`);
|
||||
console.log(` unresolved: ${draws} (hit ${TURN_CAP}-turn cap)`);
|
||||
console.log(` exceptions: ${exceptions}`);
|
||||
console.log(` invariantFail:${invariantFails}`);
|
||||
console.log(` avg turns: ${(totalTurns / Math.max(1, GAMES)).toFixed(1)}, longest ${longest}`);
|
||||
console.log(` wins by seat: ${JSON.stringify(wins)}`);
|
||||
|
||||
check('no exceptions', exceptions === 0);
|
||||
check('no invariant violations', invariantFails === 0);
|
||||
check('most games resolve to a winner', (GAMES - draws - exceptions) >= Math.ceil(GAMES * 0.9),
|
||||
`${GAMES - draws - exceptions}/${GAMES}`);
|
||||
|
||||
console.log(failures ? `\n${failures} FAILURE(S)` : '\nAll checks passed.');
|
||||
process.exit(failures ? 1 : 0);
|
||||
Loading…
Reference in New Issue