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:
Brian Fertig 2026-06-17 20:30:14 -06:00
parent 2a22bdbcde
commit ae30299838
13 changed files with 1661 additions and 1 deletions

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

View File

@ -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 (15) 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 12 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;

View File

@ -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'];

View File

@ -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,
});
}
}

View File

@ -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'; }

View File

@ -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.*

View File

@ -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,
],
};

View File

@ -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,

View File

@ -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');

View File

@ -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 });

View File

@ -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);