diff --git a/public/assets/images/monopoly-pawns.png b/public/assets/images/monopoly-pawns.png index a89ffcc..d1e1eb7 100644 Binary files a/public/assets/images/monopoly-pawns.png and b/public/assets/images/monopoly-pawns.png differ diff --git a/public/assets/images/originals/Risk_board.svg.png b/public/assets/images/originals/Risk_board.svg.png new file mode 100644 index 0000000..4acf6fd Binary files /dev/null and b/public/assets/images/originals/Risk_board.svg.png differ diff --git a/public/assets/images/risk-board.png b/public/assets/images/risk-board.png new file mode 100644 index 0000000..9a7ac28 Binary files /dev/null and b/public/assets/images/risk-board.png differ diff --git a/public/src/games/risk/RiskAI.js b/public/src/games/risk/RiskAI.js new file mode 100644 index 0000000..da63a14 --- /dev/null +++ b/public/src/games/risk/RiskAI.js @@ -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; diff --git a/public/src/games/risk/RiskData.js b/public/src/games/risk/RiskData.js new file mode 100644 index 0000000..b62cb53 --- /dev/null +++ b/public/src/games/risk/RiskData.js @@ -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']; diff --git a/public/src/games/risk/RiskGame.js b/public/src/games/risk/RiskGame.js new file mode 100644 index 0000000..c8ca23e --- /dev/null +++ b/public/src/games/risk/RiskGame.js @@ -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, + }); + } +} diff --git a/public/src/games/risk/RiskLogic.js b/public/src/games/risk/RiskLogic.js new file mode 100644 index 0000000..db6df21 --- /dev/null +++ b/public/src/games/risk/RiskLogic.js @@ -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'; } diff --git a/public/src/games/risk/tutorial.md b/public/src/games/risk/tutorial.md new file mode 100644 index 0000000..2103d0e --- /dev/null +++ b/public/src/games/risk/tutorial.md @@ -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.* diff --git a/public/src/main.js b/public/src/main.js index 66dbb9c..e4b9474 100644 --- a/public/src/main.js +++ b/public/src/main.js @@ -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, ], }; diff --git a/public/src/scenes/GameRoomScene.js b/public/src/scenes/GameRoomScene.js index 43adf4e..a7f44f7 100644 --- a/public/src/scenes/GameRoomScene.js +++ b/public/src/scenes/GameRoomScene.js @@ -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, diff --git a/public/src/scenes/PreloadScene.js b/public/src/scenes/PreloadScene.js index 359097b..6c9f2a3 100644 --- a/public/src/scenes/PreloadScene.js +++ b/public/src/scenes/PreloadScene.js @@ -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'); diff --git a/server/games/registry.js b/server/games/registry.js index 9691d9c..3719046 100644 --- a/server/games/registry.js +++ b/server/games/registry.js @@ -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 }); diff --git a/server/scripts/verifyRisk.js b/server/scripts/verifyRisk.js new file mode 100644 index 0000000..2293aab --- /dev/null +++ b/server/scripts/verifyRisk.js @@ -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);