Compare commits

..

No commits in common. "9dbf3feae4b8c2bdfcbaf7ff44a0c0c192e54989" and "57eeb3bfeeb8e547ee54f963053d1ea02e3d9222" have entirely different histories.

23 changed files with 349 additions and 2332 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 MiB

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

View File

@ -2,18 +2,18 @@
// The scene drives AI turns by repeatedly calling chooseAction() and applying
// the returned action until it returns { type: 'endTurn' }.
import { pipCount, COSTS, RESOURCE_TYPES } from './CatanBoard.js';
import { NODES, EDGES, HEXES, pipCount, COSTS, RESOURCE_TYPES } from './CatanBoard.js';
import {
legalSettlementNodes, legalRoadEdges, canAfford, bestTradeRatio,
handSize, nodeBuilding, stealTargets, publicVictoryPoints,
victoryPoints, longestRoadFor, geoFor, winVpFor,
victoryPoints, WIN_VP, longestRoadFor,
} from './CatanLogic.js';
// Value of a vertex = production potential of its adjacent hexes + diversity.
function nodeValue(state, nodeId) {
let v = 0;
const seen = new Set();
for (const hx of geoFor(state).nodes[nodeId].hexes) {
for (const hx of NODES[nodeId].hexes) {
const hex = state.hexes[hx];
if (hex.resource === 'desert' || hex.number == null) continue;
v += pipCount(hex.number);
@ -27,7 +27,7 @@ function productionByResource(state, seat) {
const prod = { brick: 0, lumber: 0, wool: 0, grain: 0, ore: 0 };
const p = state.players[seat];
const tally = (nodeId, mult) => {
for (const hx of geoFor(state).nodes[nodeId].hexes) {
for (const hx of NODES[nodeId].hexes) {
const hex = state.hexes[hx];
if (hex.resource === 'desert' || hex.number == null) continue;
prod[hex.resource] += pipCount(hex.number) * mult;
@ -46,7 +46,7 @@ export function chooseSetupSettlement(state, seat) {
for (const n of nodes) {
let score = nodeValue(state, n);
// Bonus for grabbing resources the seat doesn't yet produce.
for (const hx of geoFor(state).nodes[n].hexes) {
for (const hx of NODES[n].hexes) {
const hex = state.hexes[hx];
if (hex.resource !== 'desert' && hex.number != null && prod[hex.resource] === 0) score += 1.5;
}
@ -59,9 +59,8 @@ export function chooseSetupRoad(state, seat) {
const from = state.setup.lastSettlement;
const edges = legalRoadEdges(state, seat, true, from);
let best = edges[0], bestScore = -Infinity;
const geo = geoFor(state);
for (const eid of edges) {
const [a, b] = geo.edges[eid].nodes;
const [a, b] = EDGES[eid].nodes;
const far = a === from ? b : a;
const score = nodeValue(state, far);
if (score > bestScore) { bestScore = score; best = eid; }
@ -86,25 +85,14 @@ export function chooseDiscard(state, seat) {
return discard;
}
// Pick `amount` free resources from a gold hex — prioritise what is under-produced.
export function chooseGoldPick(state, seat, amount) {
const prod = productionByResource(state, seat);
const order = [...RESOURCE_TYPES].sort((a, b) => prod[a] - prod[b]);
const picks = [];
for (let i = 0; i < amount; i++) picks.push(order[i % order.length]);
return picks;
}
export function chooseRobberMove(state, seat) {
let best = null, bestScore = -Infinity, bestTarget = null;
const geo = geoFor(state);
for (const hex of state.hexes) {
if (hex.hasRobber) continue;
if (hex.kind && hex.kind !== 'land') continue; // robber only on land hexes
let score = -1;
let touchesSelf = false;
let richest = null, richestCards = -1;
for (const nodeId of geo.hexes[hex.id].corners) {
for (const nodeId of HEXES[hex.id].corners) {
const bld = nodeBuilding(state, nodeId);
if (!bld) continue;
if (bld.seat === seat) { touchesSelf = true; continue; }
@ -117,10 +105,8 @@ export function chooseRobberMove(state, seat) {
if (touchesSelf) score -= 100; // never hurt ourselves
if (score > bestScore) { bestScore = score; best = hex.id; bestTarget = richest; }
}
// Fallback: any legal land hex.
if (best === null) {
best = state.hexes.find((h) => !h.hasRobber && (!h.kind || h.kind === 'land'))?.id ?? state.robberHex;
}
// Fallback: any legal hex.
if (best === null) best = state.hexes.find((h) => !h.hasRobber)?.id ?? state.robberHex;
return { hexId: best, targetSeat: bestTarget };
}
@ -128,7 +114,7 @@ export function chooseRobberMove(state, seat) {
export function choosePreRoll(state, seat) {
const p = state.players[seat];
if (p.playedDevThisTurn || !p.devCards.includes('knight')) return null;
const robberOnOurs = geoFor(state).hexes[state.robberHex].corners.some((n) => {
const robberOnOurs = HEXES[state.robberHex].corners.some((n) => {
const b = nodeBuilding(state, n);
return b && b.seat === seat;
});
@ -224,12 +210,11 @@ function canReachNewSpot(state, seat) {
function chooseExpansionRoad(state, seat) {
const edges = legalRoadEdges(state, seat, false);
if (!edges.length) return null;
const geo = geoFor(state);
// Build road adjacency and find connected components of the player's network.
const roadAdj = new Map();
for (const rid of state.players[seat].roads) {
const [ra, rb] = geo.edges[rid].nodes;
const [ra, rb] = EDGES[rid].nodes;
if (!roadAdj.has(ra)) roadAdj.set(ra, new Set());
if (!roadAdj.has(rb)) roadAdj.set(rb, new Set());
roadAdj.get(ra).add(rb);
@ -252,18 +237,18 @@ function chooseExpansionRoad(state, seat) {
let best = null, bestScore = -Infinity;
for (const eid of edges) {
const [a, b] = geo.edges[eid].nodes;
const [a, b] = EDGES[eid].nodes;
let score = 0;
for (const node of [a, b]) {
// Direct endpoint: full value if buildable.
if (!nodeBuilding(state, node) && !geo.nodes[node].adj.some((x) => nodeBuilding(state, x))) {
if (!nodeBuilding(state, node) && !NODES[node].adj.some((x) => nodeBuilding(state, x))) {
score += nodeValue(state, node);
}
// 1-hop lookahead: nodes one road-length further, half weight.
for (const adj of geo.nodes[node].adj) {
for (const adj of NODES[node].adj) {
if (adj === a || adj === b) continue;
if (!nodeBuilding(state, adj) && !geo.nodes[adj].adj.some((x) => nodeBuilding(state, x))) {
if (!nodeBuilding(state, adj) && !NODES[adj].adj.some((x) => nodeBuilding(state, x))) {
score += nodeValue(state, adj) * 0.5;
}
}
@ -408,8 +393,7 @@ function tradeWinsGame(state, seat, give, get) {
}
const afford2 = (cost) => RESOURCE_TYPES.every((r) => have2[r] >= (cost[r] || 0));
const ownVP = victoryPoints(state, seat);
const win = winVpFor(state);
if (p.settlements.length && afford2(COSTS.city) && ownVP + 1 >= win) return true;
if (legalSettlementNodes(state, seat, false).length && afford2(COSTS.settlement) && ownVP + 1 >= win) return true;
if (p.settlements.length && afford2(COSTS.city) && ownVP + 1 >= WIN_VP) return true;
if (legalSettlementNodes(state, seat, false).length && afford2(COSTS.settlement) && ownVP + 1 >= WIN_VP) return true;
return false;
}

View File

@ -11,6 +11,7 @@ export const HEX_SIZE = 92; // centre-to-corner radius (pointy-top)
const SQRT3 = Math.sqrt(3);
export const HEX_W = SQRT3 * HEX_SIZE; // flat-to-flat width / in-row spacing
const ROW_V = 1.5 * HEX_SIZE; // vertical spacing between rows
export const HEX_ROWS = [3, 4, 5, 4, 3];
@ -106,43 +107,25 @@ function hexCorners(cx, cy, size) {
return pts;
}
function midpoint(nodes, edge) {
const a = nodes[edge.nodes[0]], b = nodes[edge.nodes[1]];
return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
}
// Hex centres for a board made of horizontally-centred rows (e.g. [3,4,5,4,3]).
// Hex ids are assigned left-to-right, top-to-bottom. Used by the base island and
// by Seafarers scenarios whose layouts are rectangular bands of hexes.
export function rowCenters(rows, cx = BOARD_CX, cy = BOARD_CY, size = HEX_SIZE) {
const hexW = SQRT3 * size;
const rowV = 1.5 * size;
const centers = [];
function buildGeometry(cx, cy, size) {
const hexes = [];
// Hex centres, rows of [3,4,5,4,3], each row horizontally centred.
let id = 0;
for (let r = 0; r < rows.length; r++) {
const count = rows[r];
const rowY = cy + (r - (rows.length - 1) / 2) * rowV;
const startX = cx - ((count - 1) / 2) * hexW;
for (let r = 0; r < HEX_ROWS.length; r++) {
const count = HEX_ROWS[r];
const rowY = cy + (r - 2) * ROW_V;
const startX = cx - ((count - 1) / 2) * HEX_W;
for (let c = 0; c < count; c++) {
centers.push({ id: id++, cx: startX + c * hexW, cy: rowY, row: r, col: c });
hexes.push({ id: id++, cx: startX + c * HEX_W, cy: rowY, row: r, col: c });
}
}
return centers;
}
// Build node/edge topology from a list of hex centres. Pure geometry; carries no
// resource/number/robber data (that lives in game state, keyed by hex id).
// `centers` must be in hex-id order. Optional `portCount`>0 auto-distributes that
// many generic port slots around the rim (the base island uses 9); scenarios pass
// 0 and attach their own ports via portsFromEdges().
function assemble(centers, size, cx, cy, portCount = 0) {
const hexes = centers.map((c) => ({ id: c.id, cx: c.cx, cy: c.cy, row: c.row, col: c.col }));
// Nodes: dedup hex corners by rounded pixel key.
const nodeKey = (p) => `${Math.round(p.x)}_${Math.round(p.y)}`;
const nodeMap = new Map(); // key -> node
const nodes = [];
const ensureNode = (p) => {
const k = `${Math.round(p.x)}_${Math.round(p.y)}`;
const k = nodeKey(p);
let n = nodeMap.get(k);
if (!n) {
n = { id: nodes.length, x: Math.round(p.x), y: Math.round(p.y), hexes: [], adj: [] };
@ -172,7 +155,9 @@ function assemble(centers, size, cx, cy, portCount = 0) {
hex.corners = corners.map((n) => n.id);
for (const n of corners) if (!n.hexes.includes(hex.id)) n.hexes.push(hex.id);
for (let i = 0; i < 6; i++) {
ensureEdge(corners[i].id, corners[(i + 1) % 6].id);
const a = corners[i].id;
const b = corners[(i + 1) % 6].id;
ensureEdge(a, b);
}
}
@ -184,71 +169,45 @@ function assemble(centers, size, cx, cy, portCount = 0) {
e.hexes = nodes[a].hexes.filter((h) => nodes[b].hexes.includes(h));
}
// O(1) edge lookup by node pair.
const edgeIndex = edgeMap;
let portSlots = [];
if (portCount > 0) {
// Coastal edges (touch exactly 1 hex), `portCount` spaced around the rim.
// Port slots: coastal edges (touch exactly 1 hex), 9 spaced around the rim.
const coastal = edges.filter((e) => e.hexes.length === 1);
coastal.sort((p, q) => {
const pm = midpoint(nodes, p), qm = midpoint(nodes, q);
return Math.atan2(pm.y - cy, pm.x - cx) - Math.atan2(qm.y - cy, qm.x - cx);
});
for (let i = 0; i < portCount; i++) {
const e = coastal[Math.round((i * coastal.length) / portCount) % coastal.length];
portSlots.push(portSlot(nodes, e, cx, cy));
}
}
return { hexes, nodes, edges, edgeIndex, portSlots, cx, cy, size };
}
// A port marker anchored on a coastal edge, pointing offshore from the centre.
function portSlot(nodes, edge, cx, cy) {
const m = midpoint(nodes, edge);
return {
edgeId: edge.id,
nodes: [...edge.nodes],
const portSlots = [];
for (let i = 0; i < 9; i++) {
const e = coastal[Math.round((i * coastal.length) / 9) % coastal.length];
const m = midpoint(nodes, e);
portSlots.push({
edgeId: e.id,
nodes: [...e.nodes],
x: m.x, y: m.y,
// outward direction (board centre → edge midpoint), for drawing the marker offshore
angle: Math.atan2(m.y - cy, m.x - cx),
};
}
// Build explicit port slots for a scenario, given [{ edgeId, type }] entries.
// Returns slots carrying the resolved type so callers can drop them into state.ports.
export function portsFromEdges(geo, entries) {
return entries.map(({ edgeId, type }) => {
const edge = geo.edges[edgeId];
return { ...portSlot(geo.nodes, edge, geo.cx, geo.cy), type };
});
}
// Default island geometry baked at module load — shared by every consumer.
export const GEOMETRY = assemble(rowCenters(HEX_ROWS), HEX_SIZE, BOARD_CX, BOARD_CY, 9);
return { hexes, nodes, edges, portSlots };
}
// Convenience accessors (the base-island geometry).
function midpoint(nodes, edge) {
const a = nodes[edge.nodes[0]], b = nodes[edge.nodes[1]];
return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
}
// Default geometry baked at module load — shared by every consumer.
export const GEOMETRY = buildGeometry(BOARD_CX, BOARD_CY, HEX_SIZE);
// Convenience accessors.
export const HEXES = GEOMETRY.hexes;
export const NODES = GEOMETRY.nodes;
export const EDGES = GEOMETRY.edges;
export const PORT_SLOTS = GEOMETRY.portSlots;
// Build an arbitrary board from a hex-centre list (Seafarers scenarios).
export function buildBoard({ centers, size = HEX_SIZE, cx = BOARD_CX, cy = BOARD_CY }) {
return assemble(centers, size, cx, cy, 0);
}
// ── Board registry ───────────────────────────────────────────────────────────
// Pure topology keyed by board id. Dynamic per-hex data (resource/number/kind/
// robber) lives in game state, not here, so geometry is never deep-cloned per
// action. The base island is always available under 'base'.
const BOARDS = { base: GEOMETRY };
export function registerBoard(id, geo) { BOARDS[id] = geo; return geo; }
export function getBoard(id) { return BOARDS[id] ?? GEOMETRY; }
// Edge id between two adjacent node ids within a given geometry, or -1.
export function edgeBetween(geo, a, b) {
// Edge id between two adjacent node ids, or -1.
export function edgeBetween(a, b) {
const lo = Math.min(a, b), hi = Math.max(a, b);
const e = geo.edgeIndex.get(`${lo}_${hi}`);
const e = EDGES.find((x) => x.nodes[0] === lo && x.nodes[1] === hi);
return e ? e.id : -1;
}

View File

@ -8,8 +8,8 @@ import { playSound, SFX } from '../../ui/Sounds.js';
import { enqueue as enqueueSpeech } from '../../ui/SpeechQueue.js';
import { MusicPlayer } from '../../ui/MusicPlayer.js';
import {
RESOURCE_INFO, RESOURCE_TYPES, DESERT_COLOR,
PLAYER_COLORS, COSTS, DEV_INFO, pipCount, HEX_SIZE,
NODES, EDGES, HEXES, PORT_SLOTS, RESOURCE_INFO, RESOURCE_TYPES, DESERT_COLOR,
PLAYER_COLORS, COSTS, DEV_INFO, pipCount, WIN_VP, HEX_SIZE, HEX_W,
} from './CatanBoard.js';
import * as L from './CatanLogic.js';
import * as AI from './CatanAI.js';
@ -25,8 +25,6 @@ export default class CatanGame extends Phaser.Scene {
this.playfield = data.playfield ?? null;
this.cardBack = data.cardBack ?? null;
this.tilePlacement = data.tilePlacement ?? 'standard';
this.expansion = data.expansion ?? 'base';
this.scenario = data.scenario ?? null;
this.gs = null;
this.busy = false;
this.highlights = [];
@ -61,15 +59,12 @@ export default class CatanGame extends Phaser.Scene {
}
// ── coordinate helpers ──────────────────────────────────────────────────────
// Active board geometry (base island, or the selected Seafarers scenario).
get geo() { return L.geoFor(this.gs); }
nodePos(id) { const n = this.geo.nodes[id]; return { x: n.x, y: n.y }; }
nodePos(id) { return { x: NODES[id].x, y: NODES[id].y }; }
edgePos(id) {
const [a, b] = this.geo.edges[id].nodes;
const N = this.geo.nodes;
return { x: (N[a].x + N[b].x) / 2, y: (N[a].y + N[b].y) / 2 };
const [a, b] = EDGES[id].nodes;
return { x: (NODES[a].x + NODES[b].x) / 2, y: (NODES[a].y + NODES[b].y) / 2 };
}
hexPos(id) { const h = this.geo.hexes[id]; return { x: h.cx, y: h.cy }; }
hexPos(id) { return { x: HEXES[id].cx, y: HEXES[id].cy }; }
playerColor(seat) { return PLAYER_COLORS[this.gs.players[seat].colorIndex]; }
pname(seat) { return L.playerName(this.gs, seat); }
@ -119,9 +114,6 @@ export default class CatanGame extends Phaser.Scene {
ore: [6, 7],
grain: [8, 9],
desert: [10, 11],
sea: [12, 13],
gold: [14, 15],
fog: [16, 17],
};
drawHexes() {
@ -138,48 +130,41 @@ export default class CatanGame extends Phaser.Scene {
const inset = (pts, cx, cy, s) =>
pts.map(p => ({ x: cx + (p.x - cx) * s, y: cy + (p.y - cy) * s }));
// Border insets/image size scale with the active board's hex size (the base
// island uses 92; larger Seafarers boards use a smaller hex).
const size = this.geo.size ?? HEX_SIZE;
const hexW = Math.sqrt(3) * size;
const inradius = size * Math.sqrt(3) / 2;
for (const hex of this.gs.hexes) {
const pts = this.geo.hexes[hex.id].corners.map((c) => ({ x: this.geo.nodes[c].x, y: this.geo.nodes[c].y }));
const pts = HEXES[hex.id].corners.map((c) => ({ x: NODES[c].x, y: NODES[c].y }));
const { x, y } = this.hexPos(hex.id);
const terr = this.hexTerrain(hex);
// Scale factors for the 7px colored ring + 4px dark ring (absolute pixels).
const s1 = 1 - 7 / inradius; // after colored border
const s2 = 1 - 11 / inradius; // after dark border (image area)
// Inradius ≈ 79.7; compute scale factors for 7px colored ring + 4px dark ring
const s1 = 1 - 7 / (HEX_SIZE * Math.sqrt(3) / 2); // after colored border
const s2 = 1 - 11 / (HEX_SIZE * Math.sqrt(3) / 2); // after dark border (image area)
const innerPts = inset(pts, x, y, s1);
const imagePts = inset(pts, x, y, s2);
// Layer 1: terrain swatch fill (outer colored border ring)
g.fillStyle(terr.swatch, 0.55);
// Layer 1: resource swatch fill (outer colored border ring)
const swatch = hex.resource === 'desert' ? DESERT_COLOR : RESOURCE_INFO[hex.resource].swatch;
g.fillStyle(swatch, 0.55);
g.fillPoints(pts, true);
// Layer 2: dark fill inset (inner black border ring)
g.fillStyle(0x111111, 1);
g.fillPoints(innerPts, true);
// Layer 3: tile image masked to innermost polygon (land/desert only)
if (terr.tileFrames && this.textures.exists('catan-tiles')) {
if (this.hexTileFrames[hex.id] == null) {
this.hexTileFrames[hex.id] = terr.tileFrames[Math.floor(Math.random() * 2)];
}
const frame = this.hexTileFrames[hex.id];
// Layer 3: tile image masked to innermost polygon
if (this.textures.exists('catan-tiles')) {
const frames = CatanGame.TILE_FRAMES[hex.resource] ?? [10, 11];
const frame = frames[Math.floor(Math.random() * 2)];
const maskG = this.make.graphics({ x: 0, y: 0, add: false });
maskG.fillStyle(0xffffff);
maskG.fillPoints(imagePts, true);
const img = this.add.image(x, y, 'catan-tiles', frame)
.setDisplaySize(hexW * s2, size * 2 * s2)
.setDisplaySize(HEX_W * s2, HEX_SIZE * 2 * s2)
.setMask(maskG.createGeometryMask())
.setDepth(D.board + 1);
this.hexImgs.push({ img, maskG });
} else {
// Fallback / water / gold / fog: solid color fill in the image area
g.fillStyle(terr.color, 1);
// Fallback: resource color fill in the image area
const color = hex.resource === 'desert' ? DESERT_COLOR : RESOURCE_INFO[hex.resource].color;
g.fillStyle(color, 1);
g.fillPoints(imagePts, true);
}
@ -187,31 +172,14 @@ export default class CatanGame extends Phaser.Scene {
this.hexBorderGfx.lineStyle(2, 0x4a3210, 0.35);
this.hexBorderGfx.strokePoints(pts, true);
// Terrain label
this.hexLabels.push(this.add.text(x, y - 56, terr.label, {
// Resource label
const label = hex.resource === 'desert' ? 'Desert' : RESOURCE_INFO[hex.resource].tile;
this.hexLabels.push(this.add.text(x, y - 56, label, {
fontFamily: '"Julius Sans One"', fontSize: '15px', color: '#2a2118',
}).setOrigin(0.5).setAlpha(0.65).setDepth(D.board + 3));
}
}
// Visual styling for a hex by terrain kind (Seafarers adds sea/gold/fog).
hexTerrain(hex) {
switch (hex.kind) {
case 'sea':
return { swatch: 0x2f6f9e, color: 0x2f6f9e, label: 'Sea', tileFrames: CatanGame.TILE_FRAMES.sea };
case 'gold':
return { swatch: 0xe8c14a, color: 0xd9a91f, label: 'Gold', tileFrames: CatanGame.TILE_FRAMES.gold };
case 'fog':
return { swatch: 0x6c7a86, color: 0x55606b, label: '?', tileFrames: CatanGame.TILE_FRAMES.fog };
case 'desert':
return { swatch: DESERT_COLOR, color: DESERT_COLOR, label: 'Desert', tileFrames: CatanGame.TILE_FRAMES.desert };
default: {
const info = RESOURCE_INFO[hex.resource];
return { swatch: info.swatch, color: info.color, label: info.tile, tileFrames: CatanGame.TILE_FRAMES[hex.resource] ?? [10, 11] };
}
}
}
drawPorts() {
this.portObjs.forEach((o) => o.destroy());
this.portObjs = [];
@ -242,7 +210,7 @@ export default class CatanGame extends Phaser.Scene {
// little jetties to the two coastal nodes
const jg = this.add.graphics().setDepth(D.port - 1);
jg.lineStyle(3, 0x6b4a1a, 0.8);
for (const nid of port.nodes) jg.lineBetween(px, py, this.geo.nodes[nid].x, this.geo.nodes[nid].y);
for (const nid of port.nodes) jg.lineBetween(px, py, NODES[nid].x, NODES[nid].y);
this.portObjs.push(c, jg);
}
}
@ -347,12 +315,10 @@ export default class CatanGame extends Phaser.Scene {
// X flies straight to landing; Y arcs up then bounces down
this.tweens.add({ targets: c, x: lx, duration: totalMs, ease: 'Quad.Out' });
this.tweens.chain({
targets: c, tweens: [
this.tweens.chain({ targets: c, tweens: [
{ y: arcY, duration: outMs, ease: 'Quad.Out' },
{ y: ly, duration: backMs, ease: 'Bounce.Out' },
]
});
]});
// Scale up as die approaches
this.tweens.add({ targets: c, scale: 1, duration: outMs + backMs * 0.55, ease: 'Quad.Out' });
// Spin
@ -429,7 +395,6 @@ export default class CatanGame extends Phaser.Scene {
const mk = (key, label, fn) => { const b = new Button(this, bx, by, label, fn, { width: 200, height: 46, fontSize: 19 }).setDepth(D.hud); this.buttons[key] = b; by += step; return b; };
mk('roll', 'Roll Dice', () => this.onRoll());
mk('road', 'Build Road', () => this.enterPlace('road'));
if (this.expansion === 'seafarers') mk('ship', 'Build Ship', () => this.enterPlace('ship'));
mk('settlement', 'Build Settlement', () => this.enterPlace('settlement'));
mk('city', 'Build City', () => this.enterPlace('city'));
mk('buyDev', 'Buy Dev Card', () => this.onBuyDev());
@ -460,11 +425,8 @@ export default class CatanGame extends Phaser.Scene {
return c;
};
const cardY = this.expansion === 'seafarers' ? 830 : 760;
this._lrCard = makeCard(1775, 0, 0xdaa520);
this._laCard = makeCard(1855, 1, 0xb03030);
this._lrCard.setY(cardY);
this._laCard.setY(cardY);
this._prevSpecialCardOwners = { longestRoad: null, largestArmy: null };
this._specialCardAnimating = { longestRoad: false, largestArmy: false };
@ -492,10 +454,9 @@ export default class CatanGame extends Phaser.Scene {
_getSpecialCardPos(cardType, owner) {
if (owner === null) {
const cardY = this.expansion === 'seafarers' ? 830 : 760;
return cardType === 'longestRoad'
? { x: 1775, y: cardY, scale: 1 }
: { x: 1855, y: cardY, scale: 1 };
? { x: 1775, y: 760, scale: 1 }
: { x: 1855, y: 760, scale: 1 };
}
if (owner === 0) {
return cardType === 'longestRoad'
@ -541,16 +502,13 @@ export default class CatanGame extends Phaser.Scene {
const midScale = (fromPos.scale + toPos.scale) / 2;
const half = 380;
this.tweens.chain({
targets: card, tweens: [
this.tweens.chain({ targets: card, tweens: [
{ x: midX, y: peakY, scale: midScale, duration: half, ease: 'Quad.Out' },
{
x: toPos.x, y: toPos.y, scale: toPos.scale,
{ x: toPos.x, y: toPos.y, scale: toPos.scale,
duration: half, ease: 'Quad.In',
onComplete: () => { this._specialCardAnimating[cardType] = false; },
},
]
});
]});
enqueueSpeech(cardType === 'longestRoad' ? 'catan-card-road' : 'catan-card-army');
}
@ -692,23 +650,7 @@ export default class CatanGame extends Phaser.Scene {
const panelRight = 1900;
const panelW = 320;
const cx = panelRight - panelW / 2;
const rows = [
{ name: 'Road', resources: ['brick', 'lumber'] },
{ name: 'Settlement', resources: ['brick', 'lumber', 'wool', 'grain'] },
{ name: 'City', resources: ['grain', 'grain', 'ore', 'ore', 'ore'] },
{ name: 'Dev Card', resources: ['wool', 'grain', 'ore'] },
];
if (this.expansion === 'seafarers') {
rows.push({ name: 'Ship', resources: ['lumber', 'wool'] });
}
// Base: original fixed values. Seafarers: taller panel to fit 5th row, still inside bar (y 8931080).
const seafarers = this.expansion === 'seafarers';
const bgH = seafarers ? 180 : 164;
const bgCy = seafarers ? 987 : 980;
const rowPad = 44;
const rowStep = (bgH - rowPad - 14) / (rows.length - 1);
const bgCy = 980, bgH = 164;
const panel = this.add.container(0, 0).setDepth(D.hud);
panel.add(this.add.rectangle(cx, bgCy, panelW, bgH, 0x000000, 0.3).setStrokeStyle(1, COLORS.accent, 0.5));
@ -716,10 +658,18 @@ export default class CatanGame extends Phaser.Scene {
fontFamily: 'Righteous', fontSize: '20px', color: COLORS.goldHex,
}).setOrigin(0.5, 0));
const rows = [
{ name: 'Road', resources: ['brick', 'lumber'] },
{ name: 'Settlement', resources: ['brick', 'lumber', 'wool', 'grain'] },
{ name: 'City', resources: ['grain', 'grain', 'ore', 'ore', 'ore'] },
{ name: 'Dev Card', resources: ['wool', 'grain', 'ore'] },
];
const lx = panelRight - panelW + 14;
const rx = panelRight - 14;
const rowY0 = bgCy - bgH / 2 + rowPad;
const SW = 16, SH = 16, SG = 4, SR = 3;
const rowY0 = bgCy - bgH / 2 + 44;
const rowStep = (bgH - 44 - 14) / (rows.length - 1);
const SW = 16, SH = 16, SG = 4, SR = 3; // swatch w/h/gap/radius
const g = this.add.graphics();
panel.add(g);
@ -1029,53 +979,25 @@ export default class CatanGame extends Phaser.Scene {
renderPieces() {
this.pieceObjs.forEach((o) => o.destroy());
this.pieceObjs = [];
const N = this.geo.nodes;
// roads
for (const p of this.gs.players) {
const col = PLAYER_COLORS[p.colorIndex];
for (const eid of p.roads) {
const [a, b] = this.geo.edges[eid].nodes;
const [a, b] = EDGES[eid].nodes;
const g = this.add.graphics().setDepth(D.road);
const ax = N[a].x, ay = N[a].y, bx = N[b].x, by = N[b].y;
const ax = NODES[a].x, ay = NODES[a].y, bx = NODES[b].x, by = NODES[b].y;
g.lineStyle(16, 0xffffff, 0.9); g.lineBetween(ax, ay, bx, by);
g.lineStyle(12, col.hexDark, 1); g.lineBetween(ax, ay, bx, by);
g.lineStyle(7, col.hex, 1); g.lineBetween(ax, ay, bx, by);
this.pieceObjs.push(g);
}
// ships (Seafarers): dashed maritime route in the player's colour
for (const eid of (p.ships ?? [])) {
const [a, b] = this.geo.edges[eid].nodes;
this.pieceObjs.push(this.makeShip(N[a].x, N[a].y, N[b].x, N[b].y, col));
}
}
// settlements + cities
for (const p of this.gs.players) {
const col = PLAYER_COLORS[p.colorIndex];
for (const nid of p.settlements) this.pieceObjs.push(this.makeSettlement(N[nid].x, N[nid].y, col));
for (const nid of p.cities) this.pieceObjs.push(this.makeCity(N[nid].x, N[nid].y, col));
for (const nid of p.settlements) this.pieceObjs.push(this.makeSettlement(NODES[nid].x, NODES[nid].y, col));
for (const nid of p.cities) this.pieceObjs.push(this.makeCity(NODES[nid].x, NODES[nid].y, col));
}
// pirate (Seafarers): a sea-robber token on its hex
if (this.gs.pirateHex != null) {
const { x, y } = this.hexPos(this.gs.pirateHex);
this.pieceObjs.push(
this.add.image(x, y, 'catan-pirate').setOrigin(0.5).setDisplaySize(48, 48).setDepth(D.robber)
);
}
}
// A ship piece: a thick coloured bar along the sea edge with a sail nub.
makeShip(ax, ay, bx, by, col) {
const g = this.add.graphics().setDepth(D.road);
g.lineStyle(15, 0xffffff, 0.9); g.lineBetween(ax, ay, bx, by);
g.lineStyle(11, col.hexDark, 1); g.lineBetween(ax, ay, bx, by);
g.lineStyle(6, col.hex, 1); g.lineBetween(ax, ay, bx, by);
// sail at the midpoint
const mx = (ax + bx) / 2, my = (ay + by) / 2;
g.fillStyle(0xffffff, 0.95);
g.fillTriangle(mx, my - 16, mx, my + 4, mx + 14, my - 6);
g.lineStyle(2, col.hexDark, 1);
g.strokeTriangle(mx, my - 16, mx, my + 4, mx + 14, my - 6);
return g;
}
makeSettlement(x, y, col) {
@ -1300,19 +1222,15 @@ export default class CatanGame extends Phaser.Scene {
const peakY = Math.min(srcPos.y, destY) - arcHeight;
const half = duration / 2;
this.tweens.add({ targets: container, x: destX, duration, ease: 'Quad.InOut' });
this.tweens.chain({
targets: container, tweens: [
this.tweens.chain({ targets: container, tweens: [
{ y: peakY, duration: half, ease: 'Quad.Out' },
{
y: destY, duration: half, ease: 'Quad.In', onComplete: () => {
{ y: destY, duration: half, ease: 'Quad.In', onComplete: () => {
emitter.stop();
container.destroy();
this.time.delayedCall(500, () => emitter.destroy());
resolve();
}
},
]
});
}},
]});
});
}
@ -1333,19 +1251,15 @@ export default class CatanGame extends Phaser.Scene {
const peakY = Math.min(bankPos.y, destPos.y) - arcHeight;
const half = duration / 2;
this.tweens.add({ targets: container, x: destPos.x, duration, ease: 'Quad.InOut' });
this.tweens.chain({
targets: container, tweens: [
this.tweens.chain({ targets: container, tweens: [
{ y: peakY, duration: half, ease: 'Quad.Out' },
{
y: destPos.y, duration: half, ease: 'Quad.In', onComplete: () => {
{ y: destPos.y, duration: half, ease: 'Quad.In', onComplete: () => {
this.tweens.add({
targets: container, alpha: 0, scaleX: 1.5, scaleY: 1.5, duration: 280,
onComplete: () => { container.destroy(); resolve(); },
});
}
},
]
});
}},
]});
});
}
@ -1554,9 +1468,6 @@ export default class CatanGame extends Phaser.Scene {
const hasRoadSpot = action && L.legalRoadEdges(s, 0, false).length > 0;
set('roll', me && s.phase === 'rollPhase');
set('road', (action && hasRoadSpot && L.canAfford(p, COSTS.road)) || (action && s.freeRoads > 0 && hasRoadSpot));
const hasShipSpot = action && L.legalShipEdges(s, 0).length > 0;
const sCost = L.shipCost(s);
set('ship', hasShipSpot && ((action && sCost && L.canAfford(p, sCost)) || (action && s.freeShips > 0)));
set('settlement', hasSettleSpot && L.canAfford(p, COSTS.settlement));
set('city', action && p.settlements.length > 0 && L.canAfford(p, COSTS.city));
set('buyDev', action && s.devDeck.length > 0 && L.canAfford(p, COSTS.devCard));
@ -1586,14 +1497,9 @@ export default class CatanGame extends Phaser.Scene {
this.busy = false;
this.placeMode = null;
const playerCount = Math.min(4, 1 + this.opponents.length);
this.gs = L.createInitialState(playerCount, {
tilePlacement: this.tilePlacement,
expansion: this.expansion,
scenario: this.scenario,
});
this.gs = L.createInitialState(playerCount, { tilePlacement: this.tilePlacement });
const names = ['You', ...this.opponents.map((o) => o?.name ?? 'CPU')];
L.setPlayerNames(this.gs, names);
this.hexTileFrames = {};
this.drawHexes();
this.drawPorts();
this.drawChits();
@ -1612,8 +1518,6 @@ export default class CatanGame extends Phaser.Scene {
} else if (s.phase === 'rollPhase') {
if (me) { /* wait for Roll button */ }
else await this.aiRoll();
} else if (s.phase === 'goldPick') {
await this.handleGoldPickPhase();
} else if (s.phase === 'discard') {
await this.handleDiscardPhase();
} else if (s.phase === 'moveRobber') {
@ -2012,31 +1916,6 @@ export default class CatanGame extends Phaser.Scene {
this.advance();
}
// ── gold picks ───────────────────────────────────────────────────────────────
async handleGoldPickPhase() {
this.busy = true;
// Process all AI entries in queue order.
for (const entry of [...this.gs.goldPickQueue]) {
if (entry.seat === 0) continue;
const picks = AI.chooseGoldPick(this.gs, entry.seat, entry.amount);
this.gs = L.resolveGoldPick(this.gs, entry.seat, picks);
}
this.renderAll();
// If human has a pick, let them choose.
const humanEntry = this.gs.goldPickQueue.find((e) => e.seat === 0);
if (humanEntry) {
this.busy = false;
this.pickResources(humanEntry.amount, `Gold Field! Choose ${humanEntry.amount} resource${humanEntry.amount > 1 ? 's' : ''}`, (rs) => {
this.gs = L.resolveGoldPick(this.gs, 0, rs);
this.advance();
});
return;
}
await this.delay(300);
this.busy = false;
this.advance();
}
// ── human: discards ─────────────────────────────────────────────────────────
async handleDiscardPhase() {
this.busy = true;
@ -2111,11 +1990,6 @@ export default class CatanGame extends Phaser.Scene {
const { x, y } = this.nodePos(nid);
this.addHighlight(x, y, () => this.doBuild('city', nid), 0xffd700);
}
} else if (type === 'ship') {
for (const eid of L.legalShipEdges(s, 0)) {
const { x, y } = this.edgePos(eid);
this.addHighlight(x, y, () => this.doBuild('ship', eid), COLORS.gold, 13);
}
}
this.statusText.setText(`Choose where to build a ${type} (or pick another action)`);
}
@ -2128,16 +2002,9 @@ export default class CatanGame extends Phaser.Scene {
enqueueSpeech(`catan-purchase-${type}`);
await this.animateCostPayment(0, type);
await this.animatePiecePlacement(0, type, dest.x, dest.y);
const prevGs = this.gs;
if (type === 'road') this.gs = L.buildRoad(this.gs, 0, id);
if (type === 'settlement') this.gs = L.buildSettlement(this.gs, 0, id);
if (type === 'city') this.gs = L.buildCity(this.gs, 0, id);
if (type === 'ship') this.gs = L.buildShip(this.gs, 0, id);
// Redraw board if any fog hexes were revealed by this road/ship.
if (type === 'road' || type === 'ship') {
const revealed = this.gs.hexes.some((h, i) => prevGs.hexes[i].kind === 'fog' && h.kind !== 'fog');
if (revealed) { this.drawHexes(); this.drawChits(); }
}
playSound(this, SFX.PIECE_CLICK);
this.busy = false;
this.advance();
@ -2406,10 +2273,8 @@ export default class CatanGame extends Phaser.Scene {
backgroundColor: '#111923ee', padding: { x: 26, y: 12 },
}).setOrigin(0.5).setDepth(D.banner);
banner.setAlpha(0);
this.tweens.add({
targets: banner, alpha: 1, y: 140, duration: 280, ease: 'Back.easeOut',
onComplete: () => this.time.delayedCall(900, () => this.tweens.add({ targets: banner, alpha: 0, y: 120, duration: 220, onComplete: () => banner.destroy() }))
});
this.tweens.add({ targets: banner, alpha: 1, y: 140, duration: 280, ease: 'Back.easeOut',
onComplete: () => this.time.delayedCall(900, () => this.tweens.add({ targets: banner, alpha: 0, y: 120, duration: 220, onComplete: () => banner.destroy() })) });
}
// ── game over ─────────────────────────────────────────────────────────────────

View File

@ -2,22 +2,10 @@
// Every action takes a state and returns a NEW state (deep-cloned first).
import {
getBoard, edgeBetween,
NODES, EDGES, HEXES, PORT_SLOTS, edgeBetween,
RESOURCE_BAG, STANDARD_RESOURCES, PORT_BAG, COSTS, DEV_DECK, RESOURCE_TYPES, WIN_VP,
CHIT_SPIRAL, CHIT_SEQUENCE,
} from './CatanBoard.js';
import { getExpansion } from './expansions/index.js';
// Topology (nodes/edges/hexes/ports) for the board this state is played on.
// The base island lives under 'base'; Seafarers scenarios register their own.
export function geoFor(state) {
return getBoard(state.boardId ?? 'base');
}
// Victory-point target: scenarios may raise it (e.g. Seafarers New Shores = 13).
export function winVpFor(state) {
return state.winVP ?? WIN_VP;
}
// ── small utilities ─────────────────────────────────────────────────────────
function shuffle(arr) {
@ -54,14 +42,13 @@ export function edgeOwner(state, edgeId) {
}
// ── initial state ─────────────────────────────────────────────────────────────
// Builds the standard 19-hex island (kind/resource/number/ports) — the base game
// board, factored out so a Seafarers scenario can supply its own board instead.
function buildBaseIsland(tilePlacement) {
const geo = getBoard('base');
export function createInitialState(playerCount = 3, { tilePlacement = 'random' } = {}) {
const n = Math.max(3, Math.min(4, playerCount));
// Resources onto hexes.
const resources = tilePlacement === 'standard' ? [...STANDARD_RESOURCES] : shuffle(RESOURCE_BAG);
const hexes = geo.hexes.map((h, i) => ({
const hexes = HEXES.map((h, i) => ({
id: h.id,
kind: resources[i] === 'desert' ? 'desert' : 'land',
resource: resources[i],
number: null,
hasRobber: false,
@ -72,29 +59,20 @@ function buildBaseIsland(tilePlacement) {
// Number chits: walk the standard spiral, skip desert, assign fixed sequence AR.
let chitIdx = 0;
for (const hexId of CHIT_SPIRAL) {
if (hexes[hexId].resource !== 'desert') hexes[hexId].number = CHIT_SEQUENCE[chitIdx++];
if (hexes[hexId].resource !== 'desert') {
hexes[hexId].number = CHIT_SEQUENCE[chitIdx++];
}
}
// Port types onto fixed slots.
const portTypes = shuffle(PORT_BAG);
const ports = geo.portSlots.map((slot, i) => ({
const ports = PORT_SLOTS.map((slot, i) => ({
edgeId: slot.edgeId,
nodes: [...slot.nodes],
type: portTypes[i],
x: slot.x, y: slot.y, angle: slot.angle,
}));
return { boardId: 'base', hexes, ports, robberHex: desertHex.id, pirateHex: null, homeIds: [], winVP: WIN_VP };
}
export function createInitialState(playerCount = 3, { tilePlacement = 'random', expansion = 'base', scenario = null } = {}) {
const n = Math.max(3, Math.min(4, playerCount));
const exp = getExpansion(expansion);
const sc = exp.scenarios?.[scenario] ?? null;
// Board: a Seafarers scenario builds its own; otherwise the standard island.
const board = sc ? sc.buildBoard() : buildBaseIsland(tilePlacement);
const players = [];
for (let seat = 0; seat < n; seat++) {
players.push({
@ -105,7 +83,6 @@ export function createInitialState(playerCount = 3, { tilePlacement = 'random',
settlements: [],
cities: [],
roads: [],
ships: [], // Seafarers: edge ids carrying a ship (empty in base)
devCards: [], // playable now (bought on a previous turn)
newDevCards: [], // bought this turn, not yet playable
vpCards: 0, // hidden victory-point dev cards
@ -118,20 +95,14 @@ export function createInitialState(playerCount = 3, { tilePlacement = 'random',
const seats = shuffle([...Array(n).keys()]);
const order = [...seats, ...[...seats].reverse()];
const state = {
return {
playerCount: n,
expansion,
scenario,
boardId: board.boardId,
winVP: board.winVP ?? WIN_VP,
hexes: board.hexes,
ports: board.ports,
homeIds: board.homeIds ?? [], // Seafarers: home-island hex ids for bonus VP
hexes,
ports,
players,
bank: { brick: 19, lumber: 19, wool: 19, grain: 19, ore: 19 },
devDeck: shuffle(DEV_DECK),
robberHex: board.robberHex,
pirateHex: board.pirateHex ?? null, // Seafarers: sea-robber position
robberHex: desertHex.id,
phase: 'setup',
setup: { order, idx: 0, placing: 'settlement', lastSettlement: null },
currentPlayer: order[0],
@ -139,20 +110,12 @@ export function createInitialState(playerCount = 3, { tilePlacement = 'random',
diceTotal: null,
robberReturnPhase: 'action',
discardQueue: [],
goldPickQueue: [],
freeRoads: 0,
freeShips: 0, // Seafarers: free ships (e.g. from a future card)
longestRoad: { owner: null, length: 0 },
largestArmy: { owner: null, count: 0 },
winner: null,
log: [],
};
// Expansion + scenario setup rules (run once).
for (const rule of exp.setupRules ?? []) rule(state);
for (const rule of sc?.setupRules ?? []) rule(state);
return state;
}
function logEvent(state, msg) {
@ -162,74 +125,42 @@ function logEvent(state, msg) {
// ── legality helpers ──────────────────────────────────────────────────────────
export function legalSettlementNodes(state, seat, setup = false) {
const geo = geoFor(state);
const out = [];
for (const node of geo.nodes) {
if (!nodeOnLand(state, node.id)) continue; // Seafarers: must touch land
for (const node of NODES) {
if (nodeBuilding(state, node.id)) continue;
if (node.adj.some((a) => nodeBuilding(state, a))) continue; // distance rule
if (!setup) {
const touchesRoute = node.adj.some((a) => {
const eid = edgeBetween(geo, node.id, a);
return eid >= 0 && (state.players[seat].roads.includes(eid) || state.players[seat].ships.includes(eid));
const touchesRoad = node.adj.some((a) => {
const eid = edgeBetween(node.id, a);
return eid >= 0 && state.players[seat].roads.includes(eid);
});
if (!touchesRoute) continue;
if (!touchesRoad) continue;
}
out.push(node.id);
}
return out;
}
// A node is buildable only if at least one adjacent hex is land/gold (not all sea).
// On the base island every hex is land, so this is always true there.
export function nodeOnLand(state, nodeId) {
const geo = geoFor(state);
const hexes = geo.nodes[nodeId].hexes;
if (!hexes.length) return false;
return hexes.some((h) => {
const k = state.hexes[h]?.kind;
return k !== 'sea' && k !== 'fog';
});
}
export function legalCityNodes(state, seat) {
return [...state.players[seat].settlements];
}
function nodeConnectsForRoad(state, seat, nodeId, excludeEdgeId) {
const geo = geoFor(state);
const bld = nodeBuilding(state, nodeId);
if (bld && bld.seat !== seat) return false; // blocked by opponent building
if (bld && bld.seat === seat) return true; // own settlement/city
for (const adj of geo.nodes[nodeId].adj) {
const eid = edgeBetween(geo, nodeId, adj);
for (const adj of NODES[nodeId].adj) {
const eid = edgeBetween(nodeId, adj);
if (eid === excludeEdgeId || eid < 0) continue;
if (state.players[seat].roads.includes(eid)) return true;
}
return false;
}
// An edge carries a road or a ship belonging to any player.
function edgeOccupied(state, edgeId) {
for (const p of state.players) {
if (p.roads.includes(edgeId) || p.ships.includes(edgeId)) return true;
}
return false;
}
// Is an edge on land (both endpoints touch land) — roads go here.
function edgeIsLand(state, edgeId) {
const geo = geoFor(state);
const [a, b] = geo.edges[edgeId].nodes;
return nodeOnLand(state, a) && nodeOnLand(state, b);
}
export function legalRoadEdges(state, seat, setup = false, fromNode = null) {
const geo = geoFor(state);
const out = [];
for (const e of geo.edges) {
if (edgeOccupied(state, e.id)) continue;
if (!edgeIsLand(state, e.id)) continue; // roads only on land edges
for (const e of EDGES) {
if (edgeOwner(state, e.id) !== null) continue;
const [a, b] = e.nodes;
if (setup) {
if (a === fromNode || b === fromNode) out.push(e.id);
@ -263,9 +194,8 @@ export function placeSetupSettlement(state, seat, nodeId) {
s.setup.placing = 'road';
// Second round (idx >= playerCount): grant resources from adjacent hexes.
if (s.setup.idx >= s.playerCount) {
for (const hx of geoFor(s).nodes[nodeId].hexes) {
for (const hx of NODES[nodeId].hexes) {
const hex = s.hexes[hx];
if (hex.kind && hex.kind !== 'land') continue; // skip sea/gold/fog/desert
if (hex.resource === 'desert') continue;
if (s.bank[hex.resource] > 0) { s.players[seat].resources[hex.resource]++; s.bank[hex.resource]--; }
}
@ -307,18 +237,15 @@ export function rollDice(state) {
s.phase = s.discardQueue.length ? 'discard' : 'moveRobber';
} else {
produceResources(s, s.diceTotal);
if (s.phase !== 'goldPick') s.phase = 'action';
s.phase = 'action';
}
return s;
}
function produceResources(s, total) {
const geo = geoFor(s);
for (const hex of s.hexes) {
if (hex.number !== total || hex.hasRobber) continue;
if (hex.kind && hex.kind !== 'land') continue; // sea/gold/fog handled elsewhere
if (!RESOURCE_TYPES.includes(hex.resource)) continue;
const corners = geo.hexes[hex.id].corners;
const corners = HEXES[hex.id].corners;
for (const nodeId of corners) {
const bld = nodeBuilding(s, nodeId);
if (!bld) continue;
@ -327,31 +254,6 @@ function produceResources(s, total) {
if (give > 0) { s.players[bld.seat].resources[hex.resource] += give; s.bank[hex.resource] -= give; }
}
}
// Gold hexes: each settlement/city adjacent earns 1/2 free-choice resources.
for (const hex of s.hexes) {
if (hex.kind !== 'gold' || hex.number !== total || hex.hasRobber) continue;
for (const nodeId of geo.hexes[hex.id].corners) {
const bld = nodeBuilding(s, nodeId);
if (!bld) continue;
const amt = bld.type === 'city' ? 2 : 1;
s.goldPickQueue.push({ seat: bld.seat, amount: amt });
}
}
if (s.goldPickQueue.length) s.phase = 'goldPick';
}
// resources: array of resource strings with length matching the queue entry's amount.
export function resolveGoldPick(state, seat, resources) {
const s = cloneState(state);
const idx = s.goldPickQueue.findIndex((e) => e.seat === seat);
if (idx === -1) return s;
for (const r of resources) {
if (s.bank[r] > 0) { s.players[seat].resources[r]++; s.bank[r]--; }
}
logEvent(s, `${playerName(s, seat)} takes ${resources.join(', ')} from gold.`);
s.goldPickQueue.splice(idx, 1);
if (s.goldPickQueue.length === 0) s.phase = 'action';
return s;
}
// ── discard / robber ───────────────────────────────────────────────────────────
@ -382,7 +284,7 @@ export function applyDiscard(state, seat, discard) {
// Seats with a building on the given hex (excluding `seat`) that have cards.
export function stealTargets(state, hexId, seat) {
const targets = new Set();
for (const nodeId of geoFor(state).hexes[hexId].corners) {
for (const nodeId of HEXES[hexId].corners) {
const bld = nodeBuilding(state, nodeId);
if (bld && bld.seat !== seat && handSize(state.players[bld.seat]) > 0) targets.add(bld.seat);
}
@ -422,84 +324,6 @@ export function buildRoad(state, seat, edgeId) {
if (!free && !canAfford(s.players[seat], COSTS.road)) return s;
if (free) s.freeRoads--; else pay(s, s.players[seat], COSTS.road);
s.players[seat].roads.push(edgeId);
revealFogHexes(s, edgeId);
recomputeLongestRoad(s);
checkWin(s, seat);
return s;
}
// Reveal any fog hexes that share an edge with edgeId.
function revealFogHexes(s, edgeId) {
const geo = geoFor(s);
for (const hexId of geo.edges[edgeId].hexes) {
const hex = s.hexes[hexId];
if (!hex || hex.kind !== 'fog') continue;
if (hex.fogData) {
hex.kind = hex.fogData.kind;
hex.resource = hex.fogData.resource;
hex.number = hex.fogData.number;
} else {
hex.kind = 'sea';
}
hex.fogData = null;
}
}
// ── ships (Seafarers) ──────────────────────────────────────────────────────────
// Ship cost is supplied by the active expansion; null in the base game (no ships).
export function shipCost(state) {
return getExpansion(state.expansion).costs?.ship ?? null;
}
// An edge borders open water (sea or unexplored fog) — ships are built here.
function edgeIsSea(state, edgeId) {
const geo = geoFor(state);
return geo.edges[edgeId].hexes.some((h) => {
const k = state.hexes[h]?.kind;
return k === 'sea' || k === 'fog';
});
}
// A ship edge connects to the player's network: a coastal settlement/city of
// theirs, or an existing ship of theirs (a continuous route, not blocked by an
// opponent's building at the joining node).
function shipConnects(state, seat, edge) {
const geo = geoFor(state);
for (const node of edge.nodes) {
const bld = nodeBuilding(state, node);
if (bld && bld.seat !== seat) continue; // opponent building blocks the route
if (bld && bld.seat === seat) return true; // own coastal building
for (const adj of geo.nodes[node].adj) {
const eid = edgeBetween(geo, node, adj);
if (eid >= 0 && state.players[seat].ships.includes(eid)) return true;
}
}
return false;
}
export function legalShipEdges(state, seat) {
if (!shipCost(state)) return [];
const geo = geoFor(state);
const out = [];
for (const e of geo.edges) {
if (edgeOccupied(state, e.id)) continue;
if (!edgeIsSea(state, e.id)) continue;
if (state.pirateHex != null && e.hexes.includes(state.pirateHex)) continue; // pirate blocks
if (shipConnects(state, seat, e)) out.push(e.id);
}
return out;
}
export function buildShip(state, seat, edgeId) {
const s = cloneState(state);
if (s.phase !== 'action' || s.currentPlayer !== seat) return s;
if (!legalShipEdges(s, seat).includes(edgeId)) return s;
const cost = shipCost(s);
const free = s.freeShips > 0;
if (!free && !canAfford(s.players[seat], cost)) return s;
if (free) s.freeShips--; else pay(s, s.players[seat], cost);
s.players[seat].ships.push(edgeId);
revealFogHexes(s, edgeId);
recomputeLongestRoad(s);
checkWin(s, seat);
return s;
@ -674,13 +498,11 @@ export function endTurn(state) {
// ── longest road / largest army / victory ──────────────────────────────────────
export function longestRoadFor(state, seat) {
const geo = geoFor(state);
// Seafarers: a trade route is roads + ships combined; base players have no ships.
const roads = [...state.players[seat].roads, ...state.players[seat].ships];
const roads = state.players[seat].roads;
if (roads.length === 0) return 0;
const incident = new Map();
for (const eid of roads) {
for (const node of geo.edges[eid].nodes) {
for (const node of EDGES[eid].nodes) {
if (!incident.has(node)) incident.set(node, []);
incident.get(node).push(eid);
}
@ -695,7 +517,7 @@ export function longestRoadFor(state, seat) {
if (len > 0 && blocked(node)) return;
for (const eid of incident.get(node) || []) {
if (used.has(eid)) continue;
const [a, b] = geo.edges[eid].nodes;
const [a, b] = EDGES[eid].nodes;
const next = a === node ? b : a;
used.add(eid);
dfs(next, used, len + 1);
@ -735,17 +557,12 @@ export function recomputeLargestArmy(state) {
state.largestArmy = { owner, count: maxC };
}
// Seafarers new-island (and any other expansion) bonus VP. 0 in the base game.
function bonusVP(state, seat) {
return getExpansion(state.expansion).scoring?.bonusVP?.(state, seat) ?? 0;
}
export function victoryPoints(state, seat) {
const p = state.players[seat];
let vp = p.settlements.length + p.cities.length * 2 + p.vpCards;
if (state.longestRoad.owner === seat) vp += 2;
if (state.largestArmy.owner === seat) vp += 2;
return vp + bonusVP(state, seat);
return vp;
}
// Public VP excludes hidden VP dev cards (for opponent display).
@ -754,11 +571,11 @@ export function publicVictoryPoints(state, seat) {
let vp = p.settlements.length + p.cities.length * 2;
if (state.longestRoad.owner === seat) vp += 2;
if (state.largestArmy.owner === seat) vp += 2;
return vp + bonusVP(state, seat);
return vp;
}
function checkWin(state, seat) {
if (victoryPoints(state, seat) >= winVpFor(state)) {
if (victoryPoints(state, seat) >= WIN_VP) {
state.winner = seat;
state.phase = 'gameOver';
logEvent(state, `${playerName(state, seat)} wins!`);

View File

@ -1,45 +0,0 @@
// Catan — expansion registry.
//
// Mirrors the Dominion expansion framework (public/src/games/dominion/expansions):
// the base game's rules live in CatanLogic's built-in functions; an expansion is a
// hook object the engine consults via getExpansion(state.expansion) ONLY when a
// non-base expansion is active, so base play is untouched. Unlike Dominion,
// Seafarers also changes the board, so an expansion may carry `scenarios` whose
// buildBoard() produces the topology and per-hex assignments at game start.
//
// An expansion may expose any of:
// costs extra build costs (e.g. { ship: { lumber:1, wool:1 } })
// scenarios { id: scenarioObj } selectable layouts (see seafarers.js)
// setupRules [ (state) => void ] applied once after base setup
// onProduce (state, total) => void extra production (e.g. gold hexes)
// legality { legalShipEdges, ... } expansion-piece placement rules
// actions { buildShip, moveShip } expansion build actions
// robber pirate (sea-robber) hooks
// reveal (state, hexId, seat) => void fog-tile exploration
// scoring { bonusVP } extra victory points (e.g. new-island bonus)
// ai { chooseShip, choosePirate, chooseGoldPick, chooseReveal }
import { seafarers } from './seafarers.js';
const BASE = {
id: 'base',
name: 'Base Game',
scenarios: null,
};
export const EXPANSIONS = {
base: BASE,
seafarers,
};
// Order shown in the setup screen.
export const EXPANSION_ORDER = ['base', 'seafarers'];
export function getExpansion(id) {
return EXPANSIONS[id] ?? BASE;
}
// The scenario object for a given expansion/scenario id pair, or null.
export function getScenario(expansionId, scenarioId) {
return getExpansion(expansionId).scenarios?.[scenarioId] ?? null;
}

View File

@ -1,55 +0,0 @@
// Seafarers — "The Fog Island".
//
// A home island on the left side (10 hexes, fully visible) separated from a fog
// island on the right side (10 hexes, hidden until ships reach them) by a sea
// channel of 1-3 hexes. Two gold hexes are hidden in the fog.
//
// Home island: ids 4,5,9,10,15,16,22,23,28,29
// Fog island: ids 7,8,13,14,20,21,26,27,31,32
import { assembleBoard } from './shared.js';
const S = { kind: 'sea' };
const D = { kind: 'desert' };
const L = (resource, number) => ({ kind: 'land', resource, number });
const G = (number) => ({ kind: 'gold', number });
const F = (reveal) => ({ kind: 'fog', reveal });
// rows: [4,5,6,7,6,5,4] = 37 hexes
// row0: 0 1 2 3
// row1: 4 5 6 7 8
// row2: 9 10 11 12 13 14
// row3: 15 16 17 18 19 20 21
// row4: 22 23 24 25 26 27
// row5: 28 29 30 31 32
// row6: 33 34 35 36
const CELLS = [
S, S, S, S, // row0: 0- 3
L('lumber',9), L('ore',6), S, F(L('wool',5)), F(G(12)), // row1: 4- 8
L('brick',6), L('grain',8), S, S, F(L('grain',9)), F(L('lumber',3)), // row2: 9-14
L('wool',3), L('lumber',11), S, S, S, F(L('brick',11)), F(L('ore',8)), // row3: 15-21
D, L('grain',4), S, S, F(G(10)), F(L('grain',4)), // row4: 22-27
L('wool',10), L('ore',2), S, F(L('ore',5)), F(L('wool',12)), // row5: 28-32
S, S, S, S, // row6: 33-36
];
const HOME_ISLAND = [4,5, 9,10, 15,16, 22,23, 28,29];
export const fogIsland = {
id: 'fog-island',
name: 'The Fog Island',
winVP: 12,
newIslandBonus: 2,
homeIslandHexIds: HOME_ISLAND,
buildBoard() {
return assembleBoard({
id: 'seafarers:fog-island',
rows: [4, 5, 6, 7, 6, 5, 4],
cells: CELLS,
homeIds: HOME_ISLAND,
pirate: 18,
winVP: 12,
size: 65,
});
},
};

View File

@ -1,47 +0,0 @@
// Seafarers — "The Four Islands".
//
// Four separated, internally-connected island clusters in the four quadrants of
// a 37-hex [4,5,6,7,6,5,4] grid. No single home island — all islands are equal
// targets. First settlement on each new island earns 2 bonus VP.
//
// Island A (NW): ids 0,1,4,5,9
// Island B (NE): ids 3,7,8,13,14
// Island C (SW): ids 22,23,28,29,33
// Island D (SE): ids 26,27,31,32,36
import { assembleBoard } from './shared.js';
const S = { kind: 'sea' };
const D = { kind: 'desert' };
const L = (resource, number) => ({ kind: 'land', resource, number });
const G = (number) => ({ kind: 'gold', number });
// rows: [4,5,6,7,6,5,4] = 37 hexes
const CELLS = [
L('lumber',6), L('wool',3), S, L('ore',4), // row0: 0- 3
L('lumber',9), L('brick',5), S, L('wool',8), L('grain',10), // row1: 4- 8
L('grain',11), S, S, S, L('brick',12), G(5), // row2: 9-14
S, S, S, S, S, S, S, // row3: 15-21
S, L('grain',3), L('lumber',9), S, L('ore',10), L('lumber',2), // row4: 22-27
L('brick',11), L('wool',4), S, L('ore',6), G(2), // row5: 28-32
L('grain',8), S, S, D, // row6: 33-36
];
export const fourIslands = {
id: 'four-islands',
name: 'The Four Islands',
winVP: 12,
newIslandBonus: 2,
homeIslandHexIds: [],
buildBoard() {
return assembleBoard({
id: 'seafarers:four-islands',
rows: [4, 5, 6, 7, 6, 5, 4],
cells: CELLS,
homeIds: [],
pirate: 18,
winVP: 12,
size: 65,
});
},
};

View File

@ -1,60 +0,0 @@
// Seafarers — "Heading for New Shores".
//
// The home island is the full standard 19-hex Catan layout (resources and chits
// from STANDARD_RESOURCES + CHIT_SPIRAL/CHIT_SEQUENCE), centered in a 37-hex
// [4,5,6,7,6,5,4] grid. Outer islands: two gold hexes at the top corners, three
// small resource islands on the flanks.
import { assembleBoard } from './shared.js';
const S = { kind: 'sea' };
const D = { kind: 'desert' };
const L = (resource, number) => ({ kind: 'land', resource, number });
const G = (number) => ({ kind: 'gold', number });
// rows: [4,5,6,7,6,5,4] = 37 hexes
// row0: 0 1 2 3
// row1: 4 5 6 7 8
// row2: 9 10 11 12 13 14
// row3: 15 16 17 18 19 20 21
// row4: 22 23 24 25 26 27
// row5: 28 29 30 31 32
// row6: 33 34 35 36
//
// Home island (19 hexes) = standard Catan layout centered in rows 15.
// Standard hex → grid id: each row is offset +1 col into the wider grid row.
// std row0 [3] → grid row1 cols 1-3 → ids 5,6,7
// std row1 [4] → grid row2 cols 1-4 → ids 10,11,12,13
// std row2 [5] → grid row3 cols 1-5 → ids 16,17,18,19,20
// std row3 [4] → grid row4 cols 1-4 → ids 23,24,25,26
// std row4 [3] → grid row5 cols 1-3 → ids 29,30,31
const CELLS = [
G(4), S, S, G(10), // row0: 0- 3
S, L('ore',5), L('wool',2), L('lumber',6), S, // row1: 4- 8
L('ore',9), L('grain',10), L('brick',9), L('wool',4), L('brick',3), L('lumber',8), // row2: 9-14
S, L('lumber',8), L('grain',11), D, L('grain',5), L('ore',8), S, // row3: 15-21
S, L('lumber',4), L('ore',3), L('grain',6), L('wool',10), L('wool',5), // row4: 22-27
S, L('brick',11), L('wool',12), L('lumber',9), S, // row5: 28-32
S, S, S, S, // row6: 33-36
];
const HOME_ISLAND = [5,6,7, 10,11,12,13, 16,17,18,19,20, 23,24,25,26, 29,30,31];
export const newShores = {
id: 'new-shores',
name: 'Heading for New Shores',
winVP: 13,
newIslandBonus: 2,
homeIslandHexIds: HOME_ISLAND,
buildBoard() {
return assembleBoard({
id: 'seafarers:new-shores',
rows: [4, 5, 6, 7, 6, 5, 4],
cells: CELLS,
homeIds: HOME_ISLAND,
pirate: 1,
winVP: 13,
size: 65,
});
},
};

View File

@ -1,61 +0,0 @@
// Seafarers — "Oceania" (random sea board).
//
// A fixed set of land positions ringed by sea, but the resources, numbers, and
// the two gold hexes are shuffled fresh every game for replayability.
//
// NOTE: digital adaptation — the land footprint is fixed so every board stays
// fully connected and settleable; only the tiles on it are randomized.
import { assembleBoard, shuffle } from './shared.js';
// Land hex ids on the [4,5,6,5,4] grid — a central island plus outer spurs.
// Three coherent island clusters: NW (0,4,5,9), NE (3,7,8,13,14), S (20,21,22,23).
const LAND_POS = [0, 3, 4, 5, 7, 8, 9, 13, 14, 20, 21, 22, 23];
// Resource mix for the producing hexes (one fewer than LAND_POS to leave a desert
// slot; two of these become gold). Sized to LAND_POS.length.
const RESOURCE_MIX = [
'brick', 'brick', 'lumber', 'lumber', 'lumber', 'wool', 'wool',
'grain', 'grain', 'grain', 'ore', 'ore', 'ore',
];
const NUMBERS = [2, 3, 3, 4, 4, 5, 5, 6, 8, 9, 9, 10, 11];
export const oceania = {
id: 'oceania',
name: 'Oceania (Random)',
winVP: 13,
newIslandBonus: 2,
homeIslandHexIds: [],
buildBoard() {
const cells = Array.from({ length: 24 }, () => ({ kind: 'sea' }));
const land = shuffle(LAND_POS);
const desertId = land[0];
const goldIds = new Set([land[1], land[2]]);
const producing = land.slice(3);
const res = shuffle(RESOURCE_MIX);
const nums = shuffle(NUMBERS);
cells[desertId] = { kind: 'desert' };
let ni = 0;
for (const id of goldIds) cells[id] = { kind: 'gold', number: nums[ni++] };
for (const id of producing) {
cells[id] = { kind: 'land', resource: res[ni % res.length], number: nums[ni % nums.length] };
ni++;
}
// Pirate starts on a random sea hex.
const seaIds = cells.map((c, i) => (c.kind === 'sea' ? i : -1)).filter((i) => i >= 0);
const pirate = seaIds.length ? shuffle(seaIds)[0] : null;
return assembleBoard({
id: 'seafarers:oceania',
rows: [4, 5, 6, 5, 4],
cells,
homeIds: [],
pirate,
winVP: 13,
});
},
};

View File

@ -1,83 +0,0 @@
// Shared helpers for Seafarers scenario boards.
//
// A scenario describes its board as a list of `cells` (one per hex, in hex-id
// order) plus a row layout. assembleBoard() turns that into registered topology
// + the per-hex state data CatanLogic expects. No Phaser here.
import {
rowCenters, buildBoard, registerBoard, portsFromEdges,
HEX_SIZE, BOARD_CX, BOARD_CY, PORT_BAG,
} from '../../CatanBoard.js';
export const SHIP_COST = { wool: 1, lumber: 1 };
// Larger Seafarers boards use a slightly smaller hex so they fit the canvas.
export const SEA_HEX_SIZE = 74;
export function shuffle(arr) {
const a = [...arr];
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
// A cell: { kind: 'land'|'sea'|'gold'|'fog'|'desert', resource?, number? }.
// Distribute generic + 2:1 ports onto coastal land edges, spaced around the rim.
function autoPorts(geo, hexes, cx, cy) {
const isLand = (hid) => {
const k = hexes[hid]?.kind;
return k === 'land' || k === 'gold' || k === 'desert';
};
// Shore edges: touch exactly one land hex (the other side is water or
// off-board), so a settlement on that shore could use the port.
const ring = geo.edges.filter((e) => e.hexes.filter(isLand).length === 1);
ring.sort((p, q) => {
const pm = midOf(geo, p), qm = midOf(geo, q);
return Math.atan2(pm.y - cy, pm.x - cx) - Math.atan2(qm.y - cy, qm.x - cx);
});
const bag = shuffle(PORT_BAG);
const n = Math.min(bag.length, ring.length);
const entries = [];
const used = new Set();
for (let i = 0; i < n && ring.length; i++) {
let idx = Math.round((i * ring.length) / n) % ring.length;
while (used.has(idx)) idx = (idx + 1) % ring.length;
used.add(idx);
entries.push({ edgeId: ring[idx].id, type: bag[i] });
}
return portsFromEdges(geo, entries);
}
function midOf(geo, edge) {
const a = geo.nodes[edge.nodes[0]], b = geo.nodes[edge.nodes[1]];
return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
}
// Build a scenario board from cells. Returns the board payload createInitialState
// merges into game state. `pirate` is a sea hex id (or null).
export function assembleBoard({ id, rows, cells, homeIds = [], pirate = null, winVP = 13, size = SEA_HEX_SIZE, cx = BOARD_CX, cy = BOARD_CY }) {
const centers = rowCenters(rows, cx, cy, size);
const geo = buildBoard({ centers, size, cx, cy });
registerBoard(id, geo);
const hexes = cells.map((c, i) => ({
id: i,
kind: c.kind,
resource: c.resource ?? null,
number: c.number ?? null,
hasRobber: false,
// Fog hexes carry hidden data that replaces kind/resource/number on reveal.
fogData: c.kind === 'fog' && c.reveal ? { kind: c.reveal.kind, resource: c.reveal.resource ?? null, number: c.reveal.number ?? null } : null,
}));
// Robber starts on the (first) desert, if any.
const desert = hexes.find((h) => h.kind === 'desert');
let robberHex = desert ? desert.id : null;
if (desert) desert.hasRobber = true;
const ports = autoPorts(geo, hexes, cx, cy);
return { boardId: id, hexes, ports, robberHex, pirateHex: pirate, homeIds: [...homeIds], winVP };
}

View File

@ -1,31 +0,0 @@
// Seafarers expansion for Catan.
//
// Pure data + functions, no Phaser. The engine (CatanLogic/CatanAI) calls these
// hooks only while this expansion is the active one. Board layouts live in
// ./scenarios/*; this module collects them and declares the shared ship rules,
// gold/pirate/fog mechanics, and AI hooks.
import { SHIP_COST } from './scenarios/shared.js';
import { newShores } from './scenarios/new-shores.js';
import { fourIslands } from './scenarios/four-islands.js';
import { oceania } from './scenarios/oceania.js';
import { fogIsland } from './scenarios/fog-island.js';
const SCENARIOS = {
'new-shores': newShores,
'four-islands': fourIslands,
'oceania': oceania,
'fog-island': fogIsland,
};
// Order shown in the setup screen.
export const SCENARIO_ORDER = ['new-shores', 'four-islands', 'oceania', 'fog-island'];
export const seafarers = {
id: 'seafarers',
name: 'Seafarers',
sheet: 'catan-seafarers', // optional art; procedural fallback
costs: { ship: SHIP_COST },
scenarios: SCENARIOS,
scenarioOrder: SCENARIO_ORDER,
};

View File

@ -10,9 +10,8 @@ import {
} from './DominionCards.js';
import {
legalActionIids, allCards, handCoinValue,
affordableSupply, canGain, cardCost,
affordableSupply, canGain,
} from './DominionLogic.js';
import { getExpansion } from './expansions/index.js';
function countOwned(state, seat, id) {
return allCards(state, seat).filter((c) => c.id === id).length;
@ -27,17 +26,11 @@ const TERMINAL_PRIORITY = {
workshop: 45, throneroom: 40, chapel: 35, cellar: 30, artisan: 25, vassal: 20,
};
// Base priorities merged with the active expansion's (expansion wins on overlap).
function terminalPriority(state) {
return { ...TERMINAL_PRIORITY, ...(getExpansion(state.expansion).ai?.terminalPriority ?? {}) };
}
export function chooseAction(state, seat) {
const legal = legalActionIids(state);
if (legal.length === 0) return null;
const p = state.players[seat];
const byIid = new Map(p.hand.map((c) => [c.iid, c]));
const TP = terminalPriority(state);
// Play cantrips / villages first (they replace the action they cost).
const cantrips = legal.filter((iid) => getCard(byIid.get(iid).id).plus.actions >= 1);
@ -50,19 +43,18 @@ export function chooseAction(state, seat) {
return cantrips[0];
}
// Only terminals remain (one action left). Throne-variants need another action to be worth it.
// Only terminals remain (one action left). Throne Room needs another action to be worth it.
const terminals = legal.slice();
terminals.sort((a, b) => {
const ida = byIid.get(a).id, idb = byIid.get(b).id;
return (TP[idb] ?? 0) - (TP[ida] ?? 0);
return (TERMINAL_PRIORITY[idb] ?? 0) - (TERMINAL_PRIORITY[ida] ?? 0);
});
const best = terminals[0];
const bestId = byIid.get(best).id;
if (bestId === 'throneroom' || bestId === 'kingscourt') {
if (byIid.get(best).id === 'throneroom') {
const hasOther = p.hand.some((c) => c.iid !== best && isType(c.id, 'action'));
if (!hasOther) {
// Skip the multiplier; play the next-best terminal instead, if any.
const alt = terminals.find((iid) => byIid.get(iid).id !== bestId);
// Skip Throne Room; play the next-best terminal instead, if any.
const alt = terminals.find((iid) => byIid.get(iid).id !== 'throneroom');
return alt ?? best;
}
}
@ -86,37 +78,28 @@ export function chooseBuy(state, seat, skill = 3) {
const coins = p.coins;
if (p.buys <= 0) return null;
const provincesLeft = state.supply.province ?? 0;
const colonyGame = state.supply.colony !== undefined;
const coloniesLeft = state.supply.colony ?? 0;
// Top-end victory: Colony (Prosperity) sits above Province.
if (colonyGame && coins >= 11 && coloniesLeft > 0) return 'colony';
if (coins >= 8 && provincesLeft > 0) return 'province';
// Late-game greening — thresholds widen with skill (better players green sooner).
const greenAt = 3 + Math.round(skill / 2);
if ((colonyGame ? coloniesLeft : provincesLeft) <= greenAt) {
if (provincesLeft <= greenAt) {
if (coins >= 5 && (state.supply.duchy ?? 0) > 0) return 'duchy';
if (coins >= 2 && provincesLeft <= 2 && (state.supply.estate ?? 0) > 0) return 'estate';
}
// Engine building (mid coins; never skip Gold/Province). Expansion engine cards
// are considered first, then the base list.
const engineBuys = [...(getExpansion(state.expansion).ai?.engineBuys ?? []), ...ENGINE_BUYS];
// Base keeps its original ≤5 gate; expansions may have worthwhile $6 engine cards.
const engineCoinCap = state.expansion && state.expansion !== 'base' ? 6 : 5;
if (skill >= 3 && coins <= engineCoinCap) {
for (const { id, cap } of engineBuys) {
// Engine building (mid coins; never skip Gold/Province).
if (skill >= 3 && coins <= 5) {
for (const { id, cap } of ENGINE_BUYS) {
if (!state.kingdom.includes(id)) continue;
if (cardCost(state, id, { seat, phase: 'buy' }) > coins) continue;
if (getCard(id).cost > coins) continue;
if ((state.supply[id] ?? 0) <= 0) continue;
if (countOwned(state, seat, id) >= cap) continue;
// Keep terminals roughly balanced against villages at higher skill.
return id;
}
}
// Treasure economy. In Colony games Platinum is the premier economy buy.
if (colonyGame && coins >= 5 && (state.supply.platinum ?? 0) > 0) return 'platinum';
if (coins >= 6 && (state.supply.gold ?? 0) > 0) return 'gold';
if (coins >= 3 && (state.supply.silver ?? 0) > 0) return 'silver';
return null;
@ -142,15 +125,12 @@ function trashRank(id, copperKeep) {
return 0; // never auto-trash anything else
}
function bestGain(state, seat, maxCost, filterTreasure, opts = {}) {
let options = affordableSupply(state, maxCost, filterTreasure, !!opts.exact);
if (opts.exclude && opts.exclude.length) options = options.filter((id) => !opts.exclude.includes(id));
function bestGain(state, seat, maxCost, filterTreasure) {
const options = affordableSupply(state, maxCost, filterTreasure);
if (options.length === 0) return null;
// Gain value ordering.
const rank = (id) => {
if (id === 'colony') return 1100;
if (id === 'province') return 1000;
if (id === 'platinum') return 950;
if (id === 'gold') return 900;
if (id === 'duchy' && (state.supply.province ?? 0) <= 4) return 850;
const def = getCard(id);
@ -163,16 +143,6 @@ function bestGain(state, seat, maxCost, filterTreasure, opts = {}) {
return options[0];
}
// Helpers an expansion's AI pending-resolvers operate through.
const AI_API = {
cardCost,
bestGain,
discardRank,
countOwned,
isType,
getCard,
};
export function resolvePending(state, skill = 3) {
const pend = state.pending;
if (!pend) return {};
@ -277,10 +247,7 @@ export function resolvePending(state, skill = 3) {
const opts = (pend.options ?? []).slice().sort((a, b) => getCard(a.id).cost - getCard(b.id).cost);
return { iid: opts[0]?.iid ?? null };
}
default: {
// Expansion-defined pending kind.
const fn = getExpansion(state.expansion).ai?.pending?.[pend.kind];
return fn ? fn(state, seat, pend, AI_API) : {};
}
default:
return {};
}
}

View File

@ -11,15 +11,12 @@
// engine auto-applies and the UI renders as the icon summary. Everything
// beyond vanilla lives in DominionLogic's CARD_EFFECTS registry.
// `sheet` is the Phaser texture key the `frame` indexes into. Base cards live on
// the 'dominion-cards' sheet; expansions ship their own (e.g. 'dominion-prosperity').
export const def = (id, frame, cost, types, extra = {}) => ({
const def = (id, frame, cost, types, extra = {}) => ({
id, name: extra.name ?? titleCase(id), frame, cost, types,
plus: { cards: 0, actions: 0, buys: 0, coins: 0, ...(extra.plus ?? {}) },
coin: extra.coin, // treasure value
vp: extra.vp, // fixed victory points (Gardens is dynamic → handled in logic)
text: extra.text ?? '',
sheet: extra.sheet ?? 'dominion-cards',
});
function titleCase(id) {
@ -120,23 +117,18 @@ export function isType(id, type) {
return getCard(id).types.includes(type);
}
// Pick 10 distinct Kingdom ids from `pool` using the supplied rng (0..1).
export function chooseRandomKingdom(rand, pool = KINGDOM_POOL) {
const p = pool.slice();
for (let i = p.length - 1; i > 0; i--) {
// Pick 10 distinct Kingdom ids from the pool using the supplied rng (0..1).
export function chooseRandomKingdom(rand) {
const pool = KINGDOM_POOL.slice();
for (let i = pool.length - 1; i > 0; i--) {
const j = Math.floor(rand() * (i + 1));
[p[i], p[j]] = [p[j], p[i]];
[pool[i], pool[j]] = [pool[j], pool[i]];
}
return p.slice(0, 10).sort((a, b) => getCard(a).cost - getCard(b).cost);
return pool.slice(0, 10).sort((a, b) => getCard(a).cost - getCard(b).cost);
}
// Resolve a Kingdom for the chosen deck mode. `opts.pool`/`opts.presets` let an
// expansion supply its own card pool and recommended layouts; both default to
// the base set, so base play is unchanged.
export function kingdomFor(deckMode, rand, opts = {}) {
const pool = opts.pool ?? KINGDOM_POOL;
const presets = opts.presets ?? KINGDOM_PRESETS;
if (deckMode === 'random') return chooseRandomKingdom(rand, pool);
const preset = presets[deckMode] ?? presets[Object.keys(presets)[0]] ?? FIRST_GAME;
export function kingdomFor(deckMode, rand) {
if (deckMode === 'random') return chooseRandomKingdom(rand);
const preset = KINGDOM_PRESETS[deckMode] ?? FIRST_GAME;
return preset.slice().sort((a, b) => getCard(a).cost - getCard(b).cost);
}

View File

@ -10,7 +10,7 @@ import { getCard, isType } from './DominionCards.js';
import {
createInitialState, playAction, endActionPhase, playTreasure, playAllTreasures,
buyCard, endTurn, resolvePending, isGameOver, finalScores,
legalActionIids, canGain, emptyPileCount, buyCost, buyAllowed,
legalActionIids, canGain, emptyPileCount,
} from './DominionLogic.js';
import * as AI from './DominionAI.js';
@ -69,7 +69,6 @@ export default class DominionGame extends Phaser.Scene {
this.opponents = data.opponents ?? [];
this.cardBack = data.cardBack ?? null;
this.playfield = data.playfield ?? null;
this.expansion = data.expansion ?? 'base';
this.deckMode = data.deckMode ?? 'standard';
this.playerCount = this.opponents.length + 1;
@ -151,7 +150,6 @@ export default class DominionGame extends Phaser.Scene {
seed: (Date.now() ^ (Math.random() * 1e9)) >>> 0,
playerCount: this.playerCount,
deckMode: this.deckMode,
expansion: this.expansion,
});
// Show an empty hand first, then animate the deal.
const p0 = initialState.players[0];
@ -463,12 +461,7 @@ export default class DominionGame extends Phaser.Scene {
renderSupply() {
const gs = this.gs;
// Basic supply row; Prosperity inserts Platinum after Gold and Colony after Province.
const base = ['copper', 'silver', 'gold'];
if (gs.supply.platinum !== undefined) base.push('platinum');
base.push('estate', 'duchy', 'province');
if (gs.supply.colony !== undefined) base.push('colony');
base.push('curse');
const base = ['copper', 'silver', 'gold', 'estate', 'duchy', 'province', 'curse'];
this.layoutPileRow(base, 100);
const k = gs.kingdom;
this.layoutPileRow(k.slice(0, 5), 274);
@ -521,7 +514,7 @@ export default class DominionGame extends Phaser.Scene {
// Normal buy wiring (human buy phase, no pending).
if (!gs.pending && gs.turn === 0 && gs.phase === 'buy') {
const p = gs.players[0];
const affordable = count > 0 && p.buys > 0 && p.coins >= buyCost(gs, id) && buyAllowed(gs, id);
const affordable = count > 0 && p.buys > 0 && p.coins >= def.cost;
if (affordable) {
hit.setInteractive({ useHandCursor: true });
hit.on('pointerup', () => this.humanBuy(id));
@ -797,32 +790,9 @@ export default class DominionGame extends Phaser.Scene {
}).setOrigin(0.5).setDepth(D.hud));
const provLeft = gs.supply.province ?? 0;
const colonyNote = gs.supply.colony !== undefined ? ` Colonies left: ${gs.supply.colony ?? 0}` : '';
this.dynamicLayer.add(this.add.text(CX, 786, `Provinces left: ${provLeft}${colonyNote} Empty piles: ${emptyPileCount(gs)}/3`, {
this.dynamicLayer.add(this.add.text(CX, 786, `Provinces left: ${provLeft} Empty piles: ${emptyPileCount(gs)}/3`, {
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(D.hud));
this.renderVpTokens();
}
// VP-token badges (Prosperity: Monument, Bishop, …). Drawn per seat near each
// portrait; only shown for seats that have accrued tokens.
renderVpTokens() {
const gs = this.gs;
gs.players.forEach((p, seat) => {
const n = p.vpTokens ?? 0;
if (n <= 0) return;
const pos = seat === 0 ? { x: 92 + 46, y: 928 - 46 } : (() => {
const s = this.oppSlot(seat - 1); return { x: s.x - s.r - 6, y: s.y - s.r - 2 };
})();
const g = this.add.graphics().setDepth(D.hud + 1);
g.fillStyle(0x000000, 0.82); g.fillCircle(pos.x, pos.y, 18);
g.lineStyle(2, COLORS.gold, 1); g.strokeCircle(pos.x, pos.y, 18);
this.dynamicLayer.add(g);
this.dynamicLayer.add(this.add.text(pos.x, pos.y, `${n}`, {
fontFamily: 'Righteous', fontSize: '14px', color: COLORS.goldHex,
}).setOrigin(0.5).setDepth(D.hud + 2));
});
}
updateControls() {
@ -861,9 +831,8 @@ export default class DominionGame extends Phaser.Scene {
const fsTitle = Phaser.Math.Clamp(Math.round(h * 0.085), 9, 20);
const fsType = Phaser.Math.Clamp(Math.round(h * 0.05), 7, 12);
const sheetKey = def.sheet ?? 'dominion-cards';
if (this.textures.exists(sheetKey)) {
c.add(this.add.image(0, 0, sheetKey, def.frame).setDisplaySize(w - 6, h - 6));
if (this.textures.exists('dominion-cards')) {
c.add(this.add.image(0, 0, 'dominion-cards', def.frame).setDisplaySize(w - 6, h - 6));
// legibility band over the lower portion
c.add(this.add.rectangle(0, bandTop + (h / 2 - bandTop) / 2, w - 6, h / 2 - bandTop, style.band, 0.9));
} else {
@ -1357,41 +1326,6 @@ export default class DominionGame extends Phaser.Scene {
return this.promptYesNo('You are under attack! Reveal Moat to block it?', (yes) => this.resolveHuman({ reveal: yes }));
case 'sentry':
return this.promptSentry(pend);
// ── Prosperity ────────────────────────────────────────────────────────
case 'bishopTrash':
return this.promptPickHand(pend, { filter: () => true, banner: 'Bishop: trash a card for +VP (half its cost).', allowSkip: false, key: 'iid' });
case 'bishopMayTrash':
return this.promptPickHand(pend, { filter: () => true, banner: 'Bishop: you may trash a card from your hand.', allowSkip: true, key: 'iid' });
case 'vaultDiscard':
return this.promptMultiHand(pend, { min: 0, max: 99, banner: 'Vault: discard any number of cards for +1 Coin each.', confirm: 'Discard' });
case 'vaultMayDiscard':
return this.promptMultiHand(pend, { min: 0, max: 2, banner: 'Vault: discard 2 cards to draw 1 (or none).', confirm: 'Discard' });
case 'expandTrash':
return this.promptPickHand(pend, { filter: () => true, banner: 'Expand: trash a card (gain one costing up to 3 more).', allowSkip: false, key: 'iid' });
case 'forgeTrash':
return this.promptMultiHand(pend, { min: 0, max: 99, banner: 'Forge: trash any number of cards; gain one costing their exact total.', confirm: 'Trash' });
case 'forgeGain':
return this.promptGain(pend, { exact: true, cost: pend.exactCost });
case 'kingsCourtChoose':
return this.promptPickHand(pend, { filter: (c) => isType(c.id, 'action'), banner: "King's Court: choose an Action to play three times (or skip).", allowSkip: true, key: 'iid' });
case 'mintReveal':
return this.promptPickHand(pend, { filter: (c) => isType(c.id, 'treasure'), banner: 'Mint: reveal a Treasure to gain a copy (or skip).', allowSkip: true, key: 'iid' });
case 'anvilDiscard':
return this.promptPickHand(pend, { filter: (c) => isType(c.id, 'treasure'), banner: 'Anvil: discard a Treasure to gain a card costing up to 4 (or skip).', allowSkip: true, key: 'iid' });
case 'clerkTopdeck':
return this.promptPickHand(pend, { filter: () => true, banner: 'Clerk: put a card from your hand onto your deck.', allowSkip: false, key: 'iid' });
case 'tiaraTopdeck':
return this.promptYesNo(`Tiara: put the gained ${getCard(pend.cardId).name} onto your deck?`, (yes) => this.resolveHuman({ topdeck: yes }));
case 'crystalBall':
return this.promptCrystalBall(pend);
case 'investmentChoice':
return this.promptInvestment(pend);
case 'watchtowerReact':
return this.promptWatchtower(pend);
case 'warchestGain':
return this.promptGain(pend, { maxCost: 5, exclude: pend.named ?? [] });
default:
return this.resolveHuman({});
}
@ -1428,11 +1362,10 @@ export default class DominionGame extends Phaser.Scene {
}
// Single pick from the hand (immediate resolve). key = which choice field.
// choiceExtra is merged into the resolved choice (e.g. Investment's mode).
promptPickHand(pend, { filter, banner, allowSkip, key = 'iid', choiceExtra = {} }) {
promptPickHand(pend, { filter, banner, allowSkip, key = 'iid' }) {
this.promptBanner(banner);
if (allowSkip) {
this.promptButton(CX + 230, 'Skip', () => this.resolveHuman({ [key]: null, ...choiceExtra }), { variant: 'ghost' });
this.promptButton(CX + 230, 'Skip', () => this.resolveHuman({ [key]: null }), { variant: 'ghost' });
}
for (const hs of this.handSprites) {
const ok = filter(hs);
@ -1440,28 +1373,17 @@ export default class DominionGame extends Phaser.Scene {
hs.hit.setInteractive({ useHandCursor: true });
hs.hit.removeAllListeners('pointerup');
this.highlightFace(hs.face, COLORS.danger);
hs.hit.on('pointerup', () => this.resolveHuman({ [key]: hs.iid, ...choiceExtra }));
hs.hit.on('pointerup', () => this.resolveHuman({ [key]: hs.iid }));
}
}
// Gain a card from the Supply (highlight eligible piles).
// opts: { exact, cost } for an exact-cost gain (Forge); { maxCost, exclude } overrides.
promptGain(pend, opts = {}) {
const exact = !!opts.exact;
const cost = opts.cost ?? pend.exactCost;
const maxCost = opts.maxCost ?? pend.maxCost;
const exclude = opts.exclude ?? [];
const banner = exact
? `Gain a card costing exactly ${cost}.`
: `Gain a${pend.filterTreasure ? ' Treasure' : ''} card costing up to ${maxCost}.`;
this.promptBanner(banner);
promptGain(pend) {
const treasureNote = pend.filterTreasure ? ' Treasure' : '';
this.promptBanner(`Gain a${treasureNote} card costing up to ${pend.maxCost}.`);
let any = false;
for (const sp of this.supplySprites) {
if (exclude.includes(sp.id)) continue;
const ok = exact
? canGain(this.gs, sp.id, cost, false, true)
: canGain(this.gs, sp.id, maxCost, pend.filterTreasure);
if (!ok) continue;
if (!canGain(this.gs, sp.id, pend.maxCost, pend.filterTreasure)) continue;
any = true;
sp.hit.setInteractive({ useHandCursor: true });
sp.hit.removeAllListeners('pointerup');
@ -1470,42 +1392,7 @@ export default class DominionGame extends Phaser.Scene {
this.promptObjs.push(glow);
sp.hit.on('pointerup', () => this.resolveHuman({ id: sp.id }));
}
if (!any) this.resolveHuman(exact ? {} : { id: null });
}
// Crystal Ball: act on the revealed top card of your deck.
promptCrystalBall(pend) {
const def = getCard(pend.cardId);
this.promptBanner(`Crystal Ball reveals ${def.name}.`);
const playable = isType(pend.cardId, 'action') || isType(pend.cardId, 'treasure');
let x = CX - (playable ? 285 : 190);
this.promptButton(x, 'Trash', () => this.resolveHuman({ action: 'trash' }), { variant: 'ghost' }); x += 190;
this.promptButton(x, 'Discard', () => this.resolveHuman({ action: 'discard' }), { variant: 'ghost' }); x += 190;
if (playable) { this.promptButton(x, 'Play', () => this.resolveHuman({ action: 'play' }), { bg: COLORS.accent, bgHover: COLORS.gold, textColor: COLORS.textDarkHex, textHoverColor: COLORS.textDarkHex }); x += 190; }
this.promptButton(x, 'Keep', () => this.resolveHuman({ action: 'keep' }), { variant: 'ghost' });
}
// Investment: choose +1 Coin, or trash a Treasure for VP.
promptInvestment(pend) {
this.promptBanner('Investment: choose a benefit.');
this.promptButton(CX - 150, '+1 Coin', () => this.resolveHuman({ mode: 'coin' }), { variant: 'ghost' });
this.promptButton(CX + 150, 'Trash a Treasure', () => {
this.clearPrompt();
this.promptPickHand(pend, {
filter: (c) => isType(c.id, 'treasure'),
banner: 'Investment: trash a Treasure (+1 VP, +1 VP per differently named Treasure in hand).',
allowSkip: false, key: 'iid',
choiceExtra: { mode: 'trash' },
});
}, { bg: COLORS.accent, bgHover: COLORS.gold, textColor: COLORS.textDarkHex, textHoverColor: COLORS.textDarkHex });
}
// Watchtower: react to a just-gained card.
promptWatchtower(pend) {
this.promptBanner(`Watchtower: you gained ${getCard(pend.cardId).name}. Reveal Watchtower to…`);
this.promptButton(CX - 200, 'Trash it', () => this.resolveHuman({ action: 'trash' }), { variant: 'ghost' });
this.promptButton(CX, 'Top-deck it', () => this.resolveHuman({ action: 'topdeck' }), { bg: COLORS.accent, bgHover: COLORS.gold, textColor: COLORS.textDarkHex, textHoverColor: COLORS.textDarkHex });
this.promptButton(CX + 200, 'Keep it', () => this.resolveHuman({ action: 'keep' }), { variant: 'ghost' });
if (!any) this.resolveHuman({ id: null });
}
promptYesNo(banner, cb) {

View File

@ -17,7 +17,6 @@ import {
getCard, CARDS, BASE_TREASURES,
kingdomFor, isType,
} from './DominionCards.js';
import { getExpansion, registerExpansionCards } from './expansions/index.js';
// Mulberry32 — seedable PRNG (mirrors the other games).
function rng(seed) {
@ -44,11 +43,9 @@ const HAND_SIZE = 5;
// ── State construction ──────────────────────────────────────────────────────
export function createInitialState({ seed, playerCount = 4, deckMode = 'standard', expansion = 'base' } = {}) {
registerExpansionCards();
export function createInitialState({ seed, playerCount = 4, deckMode = 'standard' } = {}) {
const rand = seed === undefined ? Math.random : rng(seed);
const exp = getExpansion(expansion);
const kingdom = kingdomFor(deckMode, rand, { pool: exp.kingdomPool ?? undefined, presets: exp.presets ?? undefined });
const kingdom = kingdomFor(deckMode, rand);
const state = {
playerCount,
@ -65,17 +62,11 @@ export function createInitialState({ seed, playerCount = 4, deckMode = 'standard
winnerSeats: [],
seed: seed ?? null,
deckMode,
expansion,
_rand: rand,
_nextIid: 1,
// transient per-effect scratch
sentryLook: null,
libraryAside: null,
// expansion scratch (reset each turn / persisted across cloneState)
curseIsCopper: false,
tiaraTopdeckArmed: 0,
tiaraDoubleArmed: false,
_warchestNamed: [],
};
const victoryPile = playerCount <= 2 ? 8 : 12;
@ -89,26 +80,17 @@ export function createInitialState({ seed, playerCount = 4, deckMode = 'standard
state.supply.province = victoryPile;
state.supply.curse = 10 * (playerCount - 1);
// Expansion basic cards (e.g. Prosperity's Platinum & Colony).
for (const id of exp.basics ?? []) {
state.supply[id] = exp.basicPileSize ? exp.basicPileSize(id, playerCount) : 10;
}
// Kingdom supply (Victory-type Kingdom cards, e.g. Gardens, use the Victory count).
for (const id of kingdom) {
state.supply[id] = isType(id, 'victory') ? victoryPile : 10;
}
// Expansion setup rules (e.g. Charlatan → Curses count as Coppers).
for (const rule of exp.setupRules ?? []) rule(state);
// Players + starting decks (7 Copper, 3 Estate), shuffled, draw 5.
for (let seat = 0; seat < playerCount; seat++) {
const p = {
seat,
deck: [], hand: [], discard: [], inPlay: [],
actions: 0, buys: 0, coins: 0,
vpTokens: 0,
merchantSilverBonus: 0,
firstSilverPlayed: false,
};
@ -145,7 +127,6 @@ export function cloneState(state) {
actions: p.actions,
buys: p.buys,
coins: p.coins,
vpTokens: p.vpTokens ?? 0,
merchantSilverBonus: p.merchantSilverBonus,
firstSilverPlayed: p.firstSilverPlayed,
})),
@ -161,15 +142,10 @@ export function cloneState(state) {
winnerSeats: state.winnerSeats.slice(),
seed: state.seed,
deckMode: state.deckMode,
expansion: state.expansion,
_rand: state._rand,
_nextIid: state._nextIid,
sentryLook: state.sentryLook ? cloneInsts(state.sentryLook) : null,
libraryAside: state.libraryAside ? cloneInsts(state.libraryAside) : null,
curseIsCopper: state.curseIsCopper ?? false,
tiaraTopdeckArmed: state.tiaraTopdeckArmed ?? 0,
tiaraDoubleArmed: state.tiaraDoubleArmed ?? false,
_warchestNamed: (state._warchestNamed ?? []).slice(),
};
return out;
}
@ -206,82 +182,9 @@ function gain(state, seat, id, dest = 'discard') {
else if (dest === 'hand') p.hand.push(inst);
else p.discard.push(inst);
state.log.push({ kind: 'gain', seat, id, dest });
runGainTriggers(state, seat, inst);
return inst;
}
// While-in-play gain triggers (Hoard, Collection, Tiara) and reveal-from-hand
// gain reactions (Watchtower). No-op for the base game.
function runGainTriggers(state, seat, inst) {
const exp = getExpansion(state.expansion);
if (exp.id === 'base') return;
for (const key of Object.keys(exp.onGain ?? {})) {
exp.onGain[key](state, seat, inst, ENGINE_API);
if (state.pending) return; // a trigger asked for a decision
}
for (const key of Object.keys(exp.gainReactions ?? {})) {
exp.gainReactions[key](state, seat, inst, ENGINE_API);
if (state.pending) return;
}
}
function trashFromInPlay(state, seat, iid) {
const p = state.players[seat];
const idx = p.inPlay.findIndex((c) => c.iid === iid);
if (idx === -1) return null;
const [c] = p.inPlay.splice(idx, 1);
state.trash.push(c);
state.log.push({ kind: 'trash', seat, id: c.id });
return c;
}
function gainVP(state, seat, n) {
if (!n) return;
const p = state.players[seat];
p.vpTokens = (p.vpTokens ?? 0) + n;
state.log.push({ kind: 'vp', seat, n });
}
// A card's current cost: printed cost plus any active expansion cost modifiers
// (Quarry, Peddler, …), clamped at 0. Equals the printed cost for the base game.
export function cardCost(state, id, ctx = {}) {
const def = CARDS[id];
if (!def) return Infinity;
let cost = def.cost;
for (const mod of getExpansion(state.expansion).costModifiers ?? []) {
cost += mod(state, id, ctx);
}
return Math.max(0, cost);
}
// The surface expansion effect/resolver/task functions operate through. All
// helpers are hoisted function declarations, so referencing them here is safe.
const ENGINE_API = {
getCard, isType, CARDS,
cardCost,
emptyPileCount: (state) => emptyPileCount(state),
otherSeats: (state, seat) => otherSeats(state, seat),
draw: (state, seat, n) => drawInto(state, state.players[seat], n),
gain: (state, seat, id, dest) => gain(state, seat, id, dest),
reshuffle: (state, seat) => reshuffle(state, state.players[seat]),
trashFromHand: (state, seat, iid) => trashFrom(state, state.players[seat], iid),
trashFromInPlay: (state, seat, iid) => trashFromInPlay(state, seat, iid),
trashCard: (state, card) => {
state.trash.push({ iid: card.iid, id: card.id });
state.log.push({ kind: 'trash', seat: state.turn, id: card.id });
},
discardFromHand: (state, seat, iid) => discardFromHand(state, state.players[seat], iid),
gainVP: (state, seat, n) => gainVP(state, seat, n),
queueAttack: (state, seat, factory, source) => queueAttack(state, seat, factory, source),
queue: (state, tasks) => state.queue.unshift(...tasks),
setPending: (state, pend) => { state.pending = pend; },
log: (state, entry) => state.log.push(entry),
countOwned: (state, seat, id) => allCards(state, seat).filter((c) => c.id === id).length,
warchestName: (state, namerSeat, ownerSeat) =>
getExpansion(state.expansion).ai?.warchestName?.(state, namerSeat, ownerSeat, ENGINE_API) ?? null,
playTreasureEffect: (state, seat, card) => resolveTreasure(state, seat, card),
};
function trashFrom(state, p, iid) {
const idx = p.hand.findIndex((c) => c.iid === iid);
if (idx === -1) return null;
@ -311,10 +214,6 @@ function startTurn(state) {
state.phase = 'action';
state.pending = null;
state.queue = [];
// Reset per-turn expansion scratch.
state.tiaraTopdeckArmed = 0;
state.tiaraDoubleArmed = false;
state._warchestNamed = [];
state.turnsTaken[state.turn] += 1;
state.log.push({ kind: 'turnStart', seat: state.turn });
}
@ -387,7 +286,6 @@ export function finalScores(state) {
if (c.id === 'gardens') vp += Math.floor(cards.length / 10);
else if (def.vp !== undefined) vp += def.vp;
}
vp += p.vpTokens ?? 0;
return { seat: p.seat, vp, cards: cards.length };
});
}
@ -435,8 +333,7 @@ export function playAllTreasures(state) {
const next = cloneState(state);
const p = next.players[next.turn];
let idx;
// Stop if a treasure effect (Anvil, Crystal Ball, …) demands a decision.
while (!next.pending && (idx = p.hand.findIndex((c) => isType(c.id, 'treasure'))) !== -1) {
while ((idx = p.hand.findIndex((c) => isType(c.id, 'treasure'))) !== -1) {
applyTreasure(next, next.turn, idx);
}
return next;
@ -446,37 +343,13 @@ function applyTreasure(state, seat, handIdx) {
const p = state.players[seat];
const [card] = p.hand.splice(handIdx, 1);
p.inPlay.push(card);
resolveTreasure(state, seat, card);
state.log.push({ kind: 'playTreasure', seat, id: card.id });
runQueue(state);
}
// Apply a treasure's value + vanilla bonuses + any expansion on-play effect for
// an already-in-play card. Used by applyTreasure and by cards that play a
// treasure from elsewhere (e.g. Crystal Ball).
function resolveTreasure(state, seat, card) {
const p = state.players[seat];
const def = getCard(card.id);
let coin = def.coin ?? 0;
p.coins += def.coin ?? 0;
if (card.id === 'silver' && !p.firstSilverPlayed) {
coin += p.merchantSilverBonus;
p.coins += p.merchantSilverBonus;
p.firstSilverPlayed = true;
}
// Tiara (approximate): the first treasure played each turn while Tiara is in
// play produces double its coins. Single-shot for now.
if (state.tiaraDoubleArmed && coin > 0) {
coin *= 2;
state.tiaraDoubleArmed = false;
}
p.coins += coin;
// Vanilla bonuses some treasures carry (Collection/Tiara +Buy, etc.).
if (def.plus.actions) p.actions += def.plus.actions;
if (def.plus.buys) p.buys += def.plus.buys;
if (def.plus.coins) p.coins += def.plus.coins;
if (def.plus.cards) drawInto(state, p, def.plus.cards);
// Expansion treasure on-play effect.
const fn = getExpansion(state.expansion).treasureEffects?.[card.id];
if (fn) fn(state, seat, ENGINE_API);
state.log.push({ kind: 'playTreasure', seat, id: card.id });
}
export function buyCard(state, id) {
@ -486,35 +359,17 @@ export function buyCard(state, id) {
if (!def) return state;
if (p.buys <= 0) return state;
if ((state.supply[id] ?? 0) <= 0) return state;
const cost = cardCost(state, id, { seat: state.turn, phase: 'buy' });
if (p.coins < cost) return state;
const restrict = getExpansion(state.expansion).buyRestrictions?.[id];
if (restrict && !restrict(state, state.turn)) return state;
if (p.coins < def.cost) return state;
const next = cloneState(state);
const np = next.players[next.turn];
np.coins -= cost;
np.coins -= def.cost;
np.buys -= 1;
gain(next, next.turn, id, 'discard');
next.log.push({ kind: 'buy', seat: next.turn, id });
const onBuy = getExpansion(next.expansion).onBuy?.[id];
if (onBuy) onBuy(next, next.turn, ENGINE_API);
runQueue(next);
return next;
}
// Current cost to BUY a card now (printed + modifiers), for the UI/AI.
export function buyCost(state, id) {
return cardCost(state, id, { seat: state.turn, phase: 'buy' });
}
// Whether the active player could buy `id` right now (ignoring coins), honoring
// expansion buy restrictions (e.g. Grand Market with Copper in play).
export function buyAllowed(state, id) {
const restrict = getExpansion(state.expansion).buyRestrictions?.[id];
return !restrict || restrict(state, state.turn);
}
// ── Effect engine ─────────────────────────────────────────────────────────────
// Process queued tasks depth-first until the queue drains or a player decision
@ -566,14 +421,10 @@ function execTask(state, task) {
case 'bureaucratAttack':
bureaucratAttack(state, task.seat);
break;
default: {
// Expansion-defined task type.
const fn = getExpansion(state.expansion).tasks?.[task.type];
if (fn) fn(state, task, ENGINE_API);
default:
break;
}
}
}
// Apply a card's printed effect for the active player `seat`.
function applyEffect(state, seat, id) {
@ -586,9 +437,9 @@ function applyEffect(state, seat, id) {
if (def.plus.coins) p.coins += def.plus.coins;
if (def.plus.cards) drawInto(state, p, def.plus.cards);
// Card-specific: base table first, then the active expansion's effects.
const fn = SPECIAL[id] ?? getExpansion(state.expansion).effects?.[id];
if (fn) fn(state, seat, ENGINE_API);
// Card-specific.
const fn = SPECIAL[id];
if (fn) fn(state, seat);
}
// Other players' seats in turn order starting after `seat`.
@ -844,7 +695,7 @@ export function resolvePending(state, choice) {
const iid = choice?.iid;
const c = iid != null ? trashFrom(next, p, iid) : null;
if (c) {
const maxCost = cardCost(next, c.id, { seat }) + 2;
const maxCost = getCard(c.id).cost + 2;
next.pending = { seat, kind: 'gainFromSupply', maxCost, dest: 'discard', source: 'remodel' };
}
break;
@ -856,7 +707,7 @@ export function resolvePending(state, choice) {
const [c] = p.hand.splice(idx, 1);
next.trash.push(c);
next.log.push({ kind: 'trash', seat, id: c.id });
const maxCost = cardCost(next, c.id, { seat }) + 3;
const maxCost = getCard(c.id).cost + 3;
next.pending = { seat, kind: 'gainFromSupply', maxCost, dest: 'hand', filterTreasure: true, source: 'mine' };
}
break;
@ -969,13 +820,9 @@ export function resolvePending(state, choice) {
}
break;
}
default: {
// Expansion-defined pending kind.
const fn = getExpansion(next.expansion).resolvers?.[pend.kind];
if (fn) fn(next, pend, choice, ENGINE_API);
default:
break;
}
}
runQueue(next);
return next;
@ -983,24 +830,21 @@ export function resolvePending(state, choice) {
// ── Query helpers (used by the AI and UI) ──────────────────────────────────────
// `exactCost` (Forge) requires cost === maxCost rather than ≤.
export function canGain(state, id, maxCost, filterTreasure = false, exactCost = false) {
export function canGain(state, id, maxCost, filterTreasure = false) {
const def = CARDS[id];
if (!def) return false;
if ((state.supply[id] ?? 0) <= 0) return false;
const cost = cardCost(state, id, { seat: state.turn });
if (exactCost ? cost !== maxCost : cost > maxCost) return false;
if (def.cost > maxCost) return false;
if (filterTreasure && !def.types.includes('treasure')) return false;
return true;
}
export function affordableSupply(state, maxCost, filterTreasure = false, exactCost = false) {
return supplyIds(state).filter((id) => canGain(state, id, maxCost, filterTreasure, exactCost));
export function affordableSupply(state, maxCost, filterTreasure = false) {
return supplyIds(state).filter((id) => canGain(state, id, maxCost, filterTreasure));
}
export function supplyIds(state) {
const basics = getExpansion(state.expansion).basics ?? [];
return [...BASE_TREASURES, ...basics, 'estate', 'duchy', 'province', 'curse', ...state.kingdom];
return [...BASE_TREASURES, 'estate', 'duchy', 'province', 'curse', ...state.kingdom];
}
export function emptyPileCount(state) {

View File

@ -1,61 +0,0 @@
// Dominion — expansion registry.
//
// The base game's card effects live in DominionLogic's built-in tables. An
// expansion is pure data plus effect functions that operate through the engine
// `api` object the dispatcher passes in (see DominionLogic's ENGINE_API). This
// keeps base play untouched: the engine composes `base + activeExpansion` only
// when a non-base expansion is selected.
//
// Each expansion exposes any of:
// cards card defs to merge into the global CARDS map
// basics extra basic-supply pile ids active while this expansion is in play
// basicPileSize (id) -> pile size for those basics
// kingdomPool ids Random draws from
// presets { deckMode: [10 ids] } recommended layouts
// setupRules [ (state) => void ] applied once at game start
// effects { id: (state, seat, api) => void } Action on-play effects
// treasureEffects { id: (state, seat, api) => void } Treasure on-play effects
// onGain { id: (state, gainSeat, gainedId, api) => void } while-in-play gain triggers
// gainReactions { id: (state, gainSeat, gainedIid, api) => void } reveal-from-hand reactions
// onBuy { id: (state, seat, api) => void } when this card is bought
// costModifiers [ (state, id, ctx) => number ] delta applied to a card's cost
// buyRestrictions { id: (state, seat) => boolean } false = cannot buy right now
// tasks { type: (state, task, api) => void } custom queue task handlers
// resolvers { kind: (state, pend, choice, api) => void } custom pending resolution
// ai { terminalPriority, engineBuys, pending } AI hooks
import { CARDS } from '../DominionCards.js';
import { prosperity } from './prosperity.js';
const BASE = {
id: 'base',
name: 'Base Game',
cards: [],
basics: [],
// null → use the base KINGDOM_PRESETS / KINGDOM_POOL in DominionCards.
presets: null,
kingdomPool: null,
};
export const EXPANSIONS = {
base: BASE,
prosperity,
};
// Order shown in the setup screen.
export const EXPANSION_ORDER = ['base', 'prosperity'];
export function getExpansion(id) {
return EXPANSIONS[id] ?? BASE;
}
// Merge every expansion's card defs into the global CARDS map so getCard()
// resolves them everywhere. Idempotent; called from createInitialState.
let _registered = false;
export function registerExpansionCards() {
if (_registered) return;
for (const exp of Object.values(EXPANSIONS)) {
for (const c of exp.cards ?? []) CARDS[c.id] = c;
}
_registered = true;
}

View File

@ -1,672 +0,0 @@
// Dominion — Prosperity expansion (2nd edition, full 25-card set + Colony/Platinum).
//
// Pure data + effect functions. Effects never touch Phaser and never import the
// engine directly; they operate through the `api` object the dispatcher passes
// (see DominionLogic's ENGINE_API). State stays plain/cloneable — only data is
// written onto it.
//
// ── Spritesheet: public/assets/images/dominion-prosperity.png ───────────────────
// 270×390 per cell, same convention as the base sheet (art fills the top; the
// title/icon band is drawn at runtime over the lower portion). Frame index is
// flat, left→right, top→bottom. THIS ORDER MUST MATCH THE ART:
//
// 0 platinum 1 colony
// ── $3 ── 2 anvil 3 watchtower
// ── $4 ── 4 bishop 5 clerk 6 investment 7 monument
// 8 quarry 9 tiara 10 workersvillage
// ── $5 ── 11 charlatan 12 city 13 collection 14 crystalball
// 15 magnate 16 mint 17 rabble 18 vault 19 warchest
// ── $6 ── 20 grandmarket 21 hoard
// ── $7 ── 22 bank 23 expand 24 forge 25 kingscourt
// ── $8 ── 26 peddler
//
// NOTE: A handful of cards (Tiara, War Chest, Charlatan's setup rule, Clerk's
// start-of-turn reaction) have subtle official wording; the chosen interpretation
// is commented at each. Adjust freely against your physical copy.
import { def, isType } from '../DominionCards.js';
const SHEET = 'dominion-prosperity';
const d = (id, frame, cost, types, extra = {}) =>
def(id, frame, cost, types, { ...extra, sheet: SHEET });
// ── Card table ──────────────────────────────────────────────────────────────
const CARD_DEFS = [
// Basic cards (added to the supply whenever Prosperity is in play).
d('platinum', 0, 5, ['treasure'], { name: 'Platinum', coin: 5, text: 'Worth 5 Coins.' }),
d('colony', 1, 11, ['victory'], { name: 'Colony', vp: 10, text: 'Worth 10 Victory Points.' }),
// $3
d('anvil', 2, 3, ['treasure'], { name: 'Anvil', coin: 1, text: '+1 Coin. When you play this, you may discard a Treasure to gain a card costing up to 4 Coins.' }),
d('watchtower', 3, 3, ['action', 'reaction'], { name: 'Watchtower', text: 'Draw until you have 6 cards in hand. When you gain a card, you may reveal this from your hand, to either trash that card or put it onto your deck.' }),
// $4
d('bishop', 4, 4, ['action'], { name: 'Bishop', plus: { coins: 1 }, text: '+1 Coin, +1 VP. Trash a card from your hand. +VP equal to half its cost in Coins, rounded down. Each other player may trash a card from their hand.' }),
d('clerk', 5, 4, ['action', 'reaction', 'attack'], { name: 'Clerk', plus: { coins: 2 }, text: '+2 Coins. Each other player with 5 or more cards in hand puts one onto their deck. At the start of your turn, you may play this from your hand.' }),
d('investment', 6, 4, ['treasure'], { name: 'Investment', coin: 0, text: 'Trash this. Choose one: +1 Coin; or trash a Treasure from your hand, +1 VP, and reveal your hand for +1 VP per differently named Treasure in it.' }),
d('monument', 7, 4, ['action'], { name: 'Monument', plus: { coins: 2 }, text: '+2 Coins, +1 VP.' }),
d('quarry', 8, 4, ['treasure'], { name: 'Quarry', coin: 1, text: '+1 Coin. While this is in play, Action cards cost 2 Coins less, but not less than 0.' }),
d('tiara', 9, 4, ['treasure'], { name: 'Tiara', plus: { buys: 1 }, coin: 0, text: '+1 Buy. The next time you gain a card this turn, you may put it onto your deck. While this is in play, the first time you play a Treasure each turn, it produces double its Coins.' }),
d('workersvillage', 10, 4, ['action'], { name: "Worker's Village", plus: { cards: 1, actions: 2, buys: 1 }, text: '+1 Card, +2 Actions, +1 Buy.' }),
// $5
d('charlatan', 11, 5, ['action', 'attack'], { name: 'Charlatan', plus: { coins: 3 }, text: '+3 Coins. Each other player gains a Curse. (In games using this, Curses are also Coppers worth 1 Coin.)' }),
d('city', 12, 5, ['action'], { name: 'City', plus: { cards: 1, actions: 2 }, text: '+1 Card, +2 Actions. If there is 1 or more empty Supply pile, +1 Card. If 2 or more, +1 Buy and +1 Coin.' }),
d('collection', 13, 5, ['treasure'], { name: 'Collection', plus: { buys: 1 }, coin: 2, text: '+2 Coins, +1 Buy. While this is in play, when you gain an Action card, +1 VP.' }),
d('crystalball', 14, 5, ['treasure'], { name: 'Crystal Ball', coin: 1, text: '+1 Coin. When you play this, look at the top card of your deck. You may trash it, discard it, or play it if it is an Action or Treasure.' }),
d('magnate', 15, 5, ['treasure'], { name: 'Magnate', coin: 0, text: 'Reveal your hand. +1 Card per Treasure in it.' }),
d('mint', 16, 5, ['action'], { name: 'Mint', text: 'You may reveal a Treasure from your hand. Gain a copy of it. (When you buy this, trash all Treasures you have in play.)' }),
d('rabble', 17, 5, ['action', 'attack'], { name: 'Rabble', plus: { cards: 3 }, text: '+3 Cards. Each other player reveals the top 3 cards of their deck, discards the Actions and Treasures, and puts the rest back on top in any order.' }),
d('vault', 18, 5, ['action'], { name: 'Vault', plus: { cards: 2 }, text: '+2 Cards. Discard any number of cards for +1 Coin each. Each other player may discard 2 cards to draw a card.' }),
d('warchest', 19, 5, ['treasure'], { name: 'War Chest', coin: 0, text: 'The player to your left names a card. Gain a card costing up to 5 Coins that has not been named for this War Chest this turn.' }),
// $6
d('grandmarket', 20, 6, ['action'], { name: 'Grand Market', plus: { cards: 1, actions: 1, buys: 1, coins: 2 }, text: "+1 Card, +1 Action, +1 Buy, +2 Coins. You can't buy this if you have any Copper in play." }),
d('hoard', 21, 6, ['treasure'], { name: 'Hoard', coin: 2, text: '+2 Coins. While this is in play, when you gain a Victory card, gain a Gold.' }),
// $7
d('bank', 22, 7, ['treasure'], { name: 'Bank', coin: 0, text: 'When you play this, it is worth 1 Coin per Treasure you have in play (counting this).' }),
d('expand', 23, 7, ['action'], { name: 'Expand', text: 'Trash a card from your hand. Gain a card costing up to 3 Coins more than it.' }),
d('forge', 24, 7, ['action'], { name: 'Forge', text: 'Trash any number of cards from your hand. Gain a card costing exactly the total Coin cost of the trashed cards.' }),
d('kingscourt', 25, 7, ['action'], { name: "King's Court", text: 'You may play an Action card from your hand three times.' }),
// $8
d('peddler', 26, 8, ['action'], { name: 'Peddler', plus: { cards: 1, actions: 1, coins: 1 }, text: '+1 Card, +1 Action, +1 Coin. During your Buy phase, this costs 2 Coins less per Action card you have in play.' }),
];
// 25 Kingdom ids (everything except the two basics), in the frame order above.
const KINGDOM_POOL = CARD_DEFS.filter((c) => c.id !== 'platinum' && c.id !== 'colony').map((c) => c.id);
// ── Recommended Kingdoms ──────────────────────────────────────────────────────
// Themed 10-card layouts. Each uses only implemented Prosperity cards. Labels are
// shown as pills in the setup screen; tweak freely.
const PRESETS = {
'beginners': ['bishop', 'city', 'expand', 'forge', 'hoard', 'monument', 'rabble', 'vault', 'watchtower', 'workersvillage'],
'friendly-interactive':['bishop', 'charlatan', 'city', 'collection', 'grandmarket', 'hoard', 'magnate', 'monument', 'rabble', 'vault'],
'bigger-treasures': ['anvil', 'bank', 'crystalball', 'hoard', 'investment', 'magnate', 'mint', 'quarry', 'tiara', 'warchest'],
'the-king': ['bank', 'city', 'expand', 'kingscourt', 'monument', 'peddler', 'quarry', 'rabble', 'vault', 'watchtower'],
};
export const PROSPERITY_PRESET_LABELS = {
'beginners': 'Beginners',
'friendly-interactive':'Friendly Interactive',
'bigger-treasures': 'Bigger Treasures',
'the-king': 'The King',
};
// ── Setup rules ───────────────────────────────────────────────────────────────
// Charlatan: while it is in the Supply, Curses are also Coppers (worth 1 Coin).
function charlatanCurseIsCopper(state) {
if (!state.kingdom.includes('charlatan')) return;
state.curseIsCopper = true; // engine treats Curse as a $1 treasure when this is set
}
// ── Small helpers ───────────────────────────────────────────────────────────
const isTreasure = (id) => isType(id, 'treasure');
const isAction = (id) => isType(id, 'action');
const isVictory = (id) => isType(id, 'victory');
// ── Action effects (on-play) ──────────────────────────────────────────────────
const effects = {
bishop(state, seat, api) {
api.gainVP(state, seat, 1);
const p = state.players[seat];
// Always trash one (player may have to trash their only card; engine allows
// trashing nothing only if hand is empty).
if (p.hand.length > 0) api.setPending(state, { seat, kind: 'bishopTrash' });
else queueBishopOthers(state, seat, api);
},
monument(state, seat, api) {
api.gainVP(state, seat, 1);
},
city(state, seat, api) {
const empties = api.emptyPileCount(state);
const p = state.players[seat];
if (empties >= 1) api.draw(state, seat, 1);
if (empties >= 2) { p.buys += 1; p.coins += 1; }
},
rabble(state, seat, api) {
api.queueAttack(state, seat, (o) => ({ type: 'rabbleAttack', seat: o }), 'rabble');
},
charlatan(state, seat, api) {
api.queueAttack(state, seat, (o) => ({ type: 'gainCurse', seat: o }), 'charlatan');
},
clerk(state, seat, api) {
// Each other player with 5+ cards topdecks one (their choice). Moat-gated.
api.queueAttack(state, seat, (o) => ({ type: 'clerkTopdeck', seat: o }), 'clerk');
},
vault(state, seat, api) {
const p = state.players[seat];
if (p.hand.length > 0) api.setPending(state, { seat, kind: 'vaultDiscard' });
else queueVaultOthers(state, seat, api);
},
expand(state, seat, api) {
const p = state.players[seat];
if (p.hand.length > 0) api.setPending(state, { seat, kind: 'expandTrash' });
},
forge(state, seat, api) {
api.setPending(state, { seat, kind: 'forgeTrash' });
},
kingscourt(state, seat, api) {
const p = state.players[seat];
if (p.hand.some((c) => isAction(c.id))) api.setPending(state, { seat, kind: 'kingsCourtChoose' });
},
mint(state, seat, api) {
const p = state.players[seat];
if (p.hand.some((c) => isTreasure(c.id))) api.setPending(state, { seat, kind: 'mintReveal' });
},
watchtower(state, seat, api) {
const p = state.players[seat];
let guard = 0;
while (p.hand.length < 6 && guard++ < 20) {
if (api.draw(state, seat, 1) === 0) break;
}
},
};
// ── Treasure effects (on-play) ──────────────────────────────────────────────
const treasureEffects = {
bank(state, seat, api) {
const p = state.players[seat];
const treasures = p.inPlay.filter((c) => isTreasure(c.id)).length; // Bank already in play
p.coins += treasures;
},
magnate(state, seat, api) {
const p = state.players[seat];
const treasures = p.hand.filter((c) => isTreasure(c.id)).length;
if (treasures > 0) api.draw(state, seat, treasures);
},
anvil(state, seat, api) {
const p = state.players[seat];
// May discard a Treasure (other than this Anvil, still in hand?) to gain up to $4.
if (p.hand.some((c) => isTreasure(c.id))) api.setPending(state, { seat, kind: 'anvilDiscard' });
},
crystalball(state, seat, api) {
const p = state.players[seat];
if (p.deck.length === 0) api.reshuffle(state, seat);
if (p.deck.length === 0) return;
const top = p.deck[0];
api.setPending(state, { seat, kind: 'crystalBall', cardIid: top.iid, cardId: top.id });
},
investment(state, seat, api) {
// Trash this Investment from play, then choose mode.
api.trashFromInPlay(state, seat, currentlyPlayed(state, seat, 'investment'));
api.setPending(state, { seat, kind: 'investmentChoice' });
},
collection(state, seat, api) {
// Coins/buys applied via def.plus in applyTreasure; gain trigger handled in onGain.
},
quarry() { /* cost reduction handled by costModifiers */ },
hoard() { /* gain trigger handled by onGain */ },
tiara(state, seat, api) {
// Arm "next gain may be topdecked" for the rest of this turn.
state.tiaraTopdeckArmed = (state.tiaraTopdeckArmed ?? 0) + 1;
// (Treasure-doubling for the first Treasure each turn is approximated: the
// engine doubles the next Treasure's coin output via state.tiaraDoubleArmed.)
state.tiaraDoubleArmed = true;
},
warchest(state, seat, api) {
// The player to your left names a card to deny; we gain the best legal card
// costing up to $5 that wasn't named for this War Chest this turn.
const left = (seat + 1) % state.playerCount;
state._warchestNamed ??= [];
const named = api.warchestName(state, left, seat); // AI/opponent names a card id
if (named) state._warchestNamed.push(named);
api.setPending(state, { seat, kind: 'warchestGain', named: state._warchestNamed.slice() });
},
};
function currentlyPlayed(state, seat, id) {
const p = state.players[seat];
// The just-played copy is the last matching card in play.
for (let i = p.inPlay.length - 1; i >= 0; i--) if (p.inPlay[i].id === id) return p.inPlay[i].iid;
return null;
}
// ── While-in-play gain triggers ───────────────────────────────────────────────
const onGain = {
hoard(state, gainSeat, gainedInst, api) {
// Active player's Hoard: when they gain a Victory card (by any means), gain a Gold.
if (gainSeat !== state.turn) return;
if (!isVictory(gainedInst.id)) return;
const hoards = state.players[gainSeat].inPlay.filter((c) => c.id === 'hoard').length;
for (let i = 0; i < hoards; i++) api.gain(state, gainSeat, 'gold', 'discard');
},
collection(state, gainSeat, gainedInst, api) {
if (gainSeat !== state.turn) return;
if (!isAction(gainedInst.id)) return;
const n = state.players[gainSeat].inPlay.filter((c) => c.id === 'collection').length;
if (n > 0) api.gainVP(state, gainSeat, n);
},
tiara(state, gainSeat, gainedInst, api) {
if (gainSeat !== state.turn) return;
if (!(state.tiaraTopdeckArmed > 0)) return;
// Offer to topdeck the just-gained card (only if it's somewhere we can move it).
state.tiaraTopdeckArmed -= 1;
api.setPending(state, { seat: gainSeat, kind: 'tiaraTopdeck', cardIid: gainedInst.iid, cardId: gainedInst.id });
},
};
// ── Reaction-on-gain (revealed from hand) ─────────────────────────────────────
const gainReactions = {
watchtower(state, gainSeat, gainedInst, api) {
const p = state.players[gainSeat];
if (!p.hand.some((c) => c.id === 'watchtower')) return;
api.setPending(state, { seat: gainSeat, kind: 'watchtowerReact', cardIid: gainedInst.iid, cardId: gainedInst.id });
},
};
// ── On-buy effects ────────────────────────────────────────────────────────────
const onBuy = {
mint(state, seat, api) {
const p = state.players[seat];
const treasures = p.inPlay.filter((c) => isTreasure(c.id));
p.inPlay = p.inPlay.filter((c) => !isTreasure(c.id));
for (const c of treasures) api.trashCard(state, c);
},
};
// ── Cost modifiers ────────────────────────────────────────────────────────────
function quarryMod(state, id, ctx) {
// Quarry: while in play, Action cards cost $2 less each Quarry.
if (!isAction(id)) return 0;
const seat = ctx.seat ?? state.turn;
const n = state.players[seat]?.inPlay.filter((c) => c.id === 'quarry').length ?? 0;
return -2 * n;
}
function peddlerMod(state, id, ctx) {
// Peddler: during the owner's Buy phase, costs $2 less per Action in play.
if (id !== 'peddler') return 0;
if (ctx.phase !== 'buy') return 0;
const seat = ctx.seat ?? state.turn;
const actions = state.players[seat]?.inPlay.filter((c) => isAction(c.id)).length ?? 0;
return -2 * actions;
}
// ── Buy restrictions ──────────────────────────────────────────────────────────
const buyRestrictions = {
grandmarket(state, seat) {
// Can't buy Grand Market with any Copper in play.
return !state.players[seat].inPlay.some((c) => c.id === 'copper');
},
};
// ── Custom queue tasks ────────────────────────────────────────────────────────
const tasks = {
gainCurse(state, task, api) {
api.gain(state, task.seat, 'curse', 'discard');
},
rabbleAttack(state, task, api) {
const seat = task.seat;
const p = state.players[seat];
const revealed = [];
for (let i = 0; i < 3; i++) {
if (p.deck.length === 0) api.reshuffle(state, seat);
if (p.deck.length === 0) break;
revealed.push(p.deck.shift());
}
api.log(state, { kind: 'reveal', seat, ids: revealed.map((c) => c.id) });
const back = [];
for (const c of revealed) {
if (isAction(c.id) || isTreasure(c.id)) p.discard.push(c);
else back.push(c);
}
// Put the rest back on top (victims rarely care about order vs the AI; keep order).
for (let i = back.length - 1; i >= 0; i--) p.deck.unshift(back[i]);
},
clerkTopdeck(state, task, api) {
const seat = task.seat;
const p = state.players[seat];
if (p.hand.length >= 5) api.setPending(state, { seat, kind: 'clerkTopdeck' });
},
bishopMayTrash(state, task, api) {
const seat = task.seat;
if (state.players[seat].hand.length > 0) api.setPending(state, { seat, kind: 'bishopMayTrash' });
},
vaultMayDiscard(state, task, api) {
const seat = task.seat;
if (state.players[seat].hand.length >= 2) api.setPending(state, { seat, kind: 'vaultMayDiscard' });
},
};
function queueBishopOthers(state, seat, api) {
api.queue(state, api.otherSeats(state, seat).map((o) => ({ type: 'bishopMayTrash', seat: o })));
}
function queueVaultOthers(state, seat, api) {
api.queue(state, api.otherSeats(state, seat).map((o) => ({ type: 'vaultMayDiscard', seat: o })));
}
// ── Pending resolution (applies a chosen action; engine resumes the queue) ─────
const resolvers = {
bishopTrash(state, pend, choice, api) {
const seat = pend.seat;
const iid = choice?.iid;
const c = iid != null ? api.trashFromHand(state, seat, iid) : null;
if (c) {
const half = Math.floor(api.cardCost(state, c.id, { seat }) / 2);
if (half > 0) api.gainVP(state, seat, half);
}
queueBishopOthers(state, seat, api);
},
bishopMayTrash(state, pend, choice, api) {
const seat = pend.seat;
if (choice?.iid != null) api.trashFromHand(state, seat, choice.iid);
},
vaultDiscard(state, pend, choice, api) {
const seat = pend.seat;
const p = state.players[seat];
const iids = (choice?.iids ?? []).filter((id) => p.hand.some((c) => c.iid === id));
for (const iid of iids) api.discardFromHand(state, seat, iid);
p.coins += iids.length;
queueVaultOthers(state, seat, api);
},
vaultMayDiscard(state, pend, choice, api) {
const seat = pend.seat;
const p = state.players[seat];
const iids = (choice?.iids ?? []).filter((id) => p.hand.some((c) => c.iid === id));
if (iids.length >= 2) {
api.discardFromHand(state, seat, iids[0]);
api.discardFromHand(state, seat, iids[1]);
api.draw(state, seat, 1);
}
},
expandTrash(state, pend, choice, api) {
const seat = pend.seat;
const c = choice?.iid != null ? api.trashFromHand(state, seat, choice.iid) : null;
if (c) {
const maxCost = api.cardCost(state, c.id, { seat }) + 3;
api.setPending(state, { seat, kind: 'gainFromSupply', maxCost, dest: 'discard', source: 'expand' });
}
},
forgeTrash(state, pend, choice, api) {
const seat = pend.seat;
const p = state.players[seat];
const iids = (choice?.iids ?? []).filter((id) => p.hand.some((c) => c.iid === id));
let total = 0;
for (const iid of iids) {
const c = p.hand.find((h) => h.iid === iid);
if (c) total += api.cardCost(state, c.id, { seat });
}
for (const iid of iids) api.trashFromHand(state, seat, iid);
// Gain a card costing EXACTLY `total`.
api.setPending(state, { seat, kind: 'forgeGain', exactCost: total });
},
forgeGain(state, pend, choice, api) {
const seat = pend.seat;
const id = choice?.id;
if (id && api.cardCost(state, id, { seat }) === pend.exactCost && (state.supply[id] ?? 0) > 0) {
api.gain(state, seat, id, 'discard');
}
},
kingsCourtChoose(state, pend, choice, api) {
const seat = pend.seat;
const p = state.players[seat];
const idx = choice?.iid != null ? p.hand.findIndex((c) => c.iid === choice.iid && isAction(c.id)) : -1;
if (idx !== -1) {
const [c] = p.hand.splice(idx, 1);
p.inPlay.push(c);
api.log(state, { kind: 'play', seat, id: c.id, throne: true });
api.queue(state, [
{ type: 'effect', seat, id: c.id },
{ type: 'effect', seat, id: c.id },
{ type: 'effect', seat, id: c.id },
]);
}
},
mintReveal(state, pend, choice, api) {
const seat = pend.seat;
const p = state.players[seat];
const c = choice?.iid != null ? p.hand.find((h) => h.iid === choice.iid && isTreasure(h.id)) : null;
if (c) api.gain(state, seat, c.id, 'discard');
},
anvilDiscard(state, pend, choice, api) {
const seat = pend.seat;
if (choice?.iid != null) {
const c = api.discardFromHand(state, seat, choice.iid);
if (c) api.setPending(state, { seat, kind: 'gainFromSupply', maxCost: 4, dest: 'discard', source: 'anvil' });
}
},
crystalBall(state, pend, choice, api) {
const seat = pend.seat;
const p = state.players[seat];
const idx = p.deck.findIndex((c) => c.iid === pend.cardIid);
if (idx !== 0) return; // top card moved unexpectedly
if (choice?.action === 'trash') {
const [c] = p.deck.splice(0, 1);
api.trashCard(state, c);
} else if (choice?.action === 'discard') {
const [c] = p.deck.splice(0, 1);
p.discard.push(c);
} else if (choice?.action === 'play' && (isAction(pend.cardId) || isTreasure(pend.cardId))) {
const [c] = p.deck.splice(0, 1);
p.inPlay.push(c);
api.log(state, { kind: 'play', seat, id: c.id });
if (isTreasure(c.id)) api.playTreasureEffect(state, seat, c);
else api.queue(state, [{ type: 'effect', seat, id: c.id }]);
}
// else: leave on top
},
investmentChoice(state, pend, choice, api) {
const seat = pend.seat;
const p = state.players[seat];
if (choice?.mode === 'coin') {
p.coins += 1;
} else {
// trash a Treasure from hand, +1 VP, +1 VP per differently named Treasure revealed.
if (choice?.iid != null) api.trashFromHand(state, seat, choice.iid);
api.gainVP(state, seat, 1);
const names = new Set(p.hand.filter((c) => isTreasure(c.id)).map((c) => c.id));
api.gainVP(state, seat, names.size);
}
},
tiaraTopdeck(state, pend, choice, api) {
if (!choice?.topdeck) return;
const seat = pend.seat;
const p = state.players[seat];
// Find the gained card in discard (default gain dest) or hand and move to deck top.
let idx = p.discard.findIndex((c) => c.iid === pend.cardIid);
if (idx !== -1) { const [c] = p.discard.splice(idx, 1); p.deck.unshift(c); api.log(state, { kind: 'topdeck', seat, id: c.id }); return; }
idx = p.hand.findIndex((c) => c.iid === pend.cardIid);
if (idx !== -1) { const [c] = p.hand.splice(idx, 1); p.deck.unshift(c); api.log(state, { kind: 'topdeck', seat, id: c.id }); }
},
watchtowerReact(state, pend, choice, api) {
const seat = pend.seat;
const p = state.players[seat];
if (choice?.action === 'trash') {
// Trash the just-gained card from wherever it landed (discard by default).
let idx = p.discard.findIndex((c) => c.iid === pend.cardIid);
if (idx !== -1) { const [c] = p.discard.splice(idx, 1); api.trashCard(state, c); return; }
idx = p.hand.findIndex((c) => c.iid === pend.cardIid);
if (idx !== -1) { const [c] = p.hand.splice(idx, 1); api.trashCard(state, c); }
} else if (choice?.action === 'topdeck') {
let idx = p.discard.findIndex((c) => c.iid === pend.cardIid);
if (idx !== -1) { const [c] = p.discard.splice(idx, 1); p.deck.unshift(c); api.log(state, { kind: 'topdeck', seat, id: c.id }); }
}
},
clerkTopdeck(state, pend, choice, api) {
const seat = pend.seat;
const p = state.players[seat];
let iid = choice?.iid;
if (iid == null || !p.hand.some((c) => c.iid === iid)) iid = p.hand[0]?.iid; // must topdeck something
if (iid != null) {
const idx = p.hand.findIndex((c) => c.iid === iid);
const [c] = p.hand.splice(idx, 1);
p.deck.unshift(c);
api.log(state, { kind: 'topdeck', seat, id: c.id });
}
},
warchestGain(state, pend, choice, api) {
const seat = pend.seat;
const id = choice?.id;
const named = new Set(pend.named ?? []);
if (id && !named.has(id) && api.cardCost(state, id, { seat }) <= 5 && (state.supply[id] ?? 0) > 0) {
api.gain(state, seat, id, 'discard');
}
},
};
// ── AI hooks (layered on base; absent keys fall through to base defaults) ─────
const ai = {
terminalPriority: {
witch: 96, rabble: 88, charlatan: 86, militia: 80, clerk: 78,
kingscourt: 72, expand: 58, forge: 57, bishop: 52, mint: 48,
monument: 46, vault: 44, city: 20, watchtower: 18,
},
engineBuys: [
{ id: 'grandmarket', cap: 4 }, { id: 'kingscourt', cap: 2 }, { id: 'city', cap: 4 },
{ id: 'peddler', cap: 5 }, { id: 'monument', cap: 3 }, { id: 'collection', cap: 2 },
{ id: 'hoard', cap: 2 }, { id: 'vault', cap: 2 }, { id: 'rabble', cap: 1 },
{ id: 'bishop', cap: 1 }, { id: 'watchtower', cap: 1 }, { id: 'workersvillage', cap: 4 },
{ id: 'charlatan', cap: 1 },
],
pending: {
bishopTrash(state, seat, pend, api) {
const p = state.players[seat];
const c = pickToTrash(state, seat, api) ?? p.hand.slice().sort((a, b) => api.cardCost(state, a.id) - api.cardCost(state, b.id))[0];
return { iid: c?.iid ?? null };
},
bishopMayTrash(state, seat, pend, api) {
const junk = junkInHand(state, seat, api);
return { iid: junk?.iid ?? null };
},
vaultDiscard(state, seat, pend, api) {
const p = state.players[seat];
const junk = p.hand.filter((c) => isVictory(c.id) || c.id === 'curse' || c.id === 'copper');
return { iids: junk.map((c) => c.iid) };
},
vaultMayDiscard(state, seat, pend, api) {
const p = state.players[seat];
const junk = p.hand.filter((c) => isVictory(c.id) || c.id === 'curse').slice(0, 2);
return { iids: junk.length >= 2 ? junk.map((c) => c.iid) : [] };
},
expandTrash(state, seat, pend, api) {
const c = junkInHand(state, seat, api) ?? state.players[seat].hand.slice().sort((a, b) => api.cardCost(state, a.id) - api.cardCost(state, b.id))[0];
return { iid: c?.iid ?? null };
},
forgeTrash(state, seat, pend, api) {
// Trash Coppers/Estates/Curses to forge toward a $6-$8 card if total lands right.
const p = state.players[seat];
const junk = p.hand.filter((c) => c.id === 'copper' || c.id === 'estate' || c.id === 'curse');
return { iids: junk.map((c) => c.iid) };
},
forgeGain(state, seat, pend, api) {
const id = api.bestGain(state, seat, pend.exactCost, false, { exact: true });
return { id };
},
kingsCourtChoose(state, seat, pend, api) {
const p = state.players[seat];
const actions = p.hand.filter((c) => isAction(c.id) && c.id !== 'kingscourt')
.sort((a, b) => (ai.terminalPriority[b.id] ?? api.cardCost(state, b.id)) - (ai.terminalPriority[a.id] ?? api.cardCost(state, a.id)));
return { iid: actions[0]?.iid ?? null };
},
mintReveal(state, seat, pend, api) {
const p = state.players[seat];
const best = p.hand.filter((c) => isTreasure(c.id)).sort((a, b) => api.cardCost(state, b.id) - api.cardCost(state, a.id))[0];
return { iid: best?.iid ?? null };
},
anvilDiscard(state, seat, pend, api) {
// Discard a Copper to gain a $4 if a good $4 exists; else decline.
const p = state.players[seat];
const copper = p.hand.find((c) => c.id === 'copper');
return { iid: copper?.iid ?? null };
},
crystalBall(state, seat, pend, api) {
const id = pend.cardId;
if (id === 'curse' || id === 'estate') return { action: 'trash' };
if (isVictory(id)) return { action: 'discard' };
if (isAction(id) || isTreasure(id)) return { action: 'play' };
return { action: 'keep' };
},
investmentChoice(state, seat, pend, api) {
const p = state.players[seat];
const copper = p.hand.find((c) => c.id === 'copper');
if (copper) return { mode: 'trash', iid: copper.iid };
return { mode: 'coin' };
},
tiaraTopdeck(state, seat, pend, api) {
// Topdeck good non-victory gains; leave junk in discard.
const good = isAction(pend.cardId) || pend.cardId === 'gold' || pend.cardId === 'platinum';
return { topdeck: good };
},
watchtowerReact(state, seat, pend, api) {
if (pend.cardId === 'curse') return { action: 'trash' };
if (pend.cardId === 'copper' && api.countOwned(state, seat, 'copper') > 3) return { action: 'trash' };
return { action: 'keep' };
},
clerkTopdeck(state, seat, pend, api) {
const p = state.players[seat];
const ranked = p.hand.slice().sort((a, b) => api.discardRank(b.id) - api.discardRank(a.id));
return { iid: ranked[0]?.iid ?? null };
},
warchestGain(state, seat, pend, api) {
const id = api.bestGain(state, seat, 5, false, { exclude: pend.named });
return { id };
},
},
// The opponent to a War Chest player names a card to deny (we deny Province/Gold/best action).
warchestName(state, namerSeat, ownerSeat, api) {
if ((state.supply.province ?? 0) > 0) return 'province';
if ((state.supply.gold ?? 0) > 0) return 'gold';
return null;
},
};
function pickToTrash(state, seat, api) {
const p = state.players[seat];
return p.hand.find((c) => c.id === 'curse')
?? p.hand.find((c) => c.id === 'estate')
?? p.hand.find((c) => c.id === 'copper');
}
function junkInHand(state, seat, api) {
const p = state.players[seat];
return p.hand.find((c) => c.id === 'curse')
?? p.hand.find((c) => c.id === 'estate')
?? p.hand.find((c) => c.id === 'copper');
}
export const prosperity = {
id: 'prosperity',
name: 'Prosperity',
sheet: SHEET,
cards: CARD_DEFS,
basics: ['platinum', 'colony'],
basicPileSize: (id, playerCount) => {
if (id === 'platinum') return 12;
if (id === 'colony') return playerCount <= 2 ? 8 : 12;
return 10;
},
kingdomPool: KINGDOM_POOL,
presets: PRESETS,
presetLabels: PROSPERITY_PRESET_LABELS,
setupRules: [charlatanCurseIsCopper],
effects,
treasureEffects,
onGain,
gainReactions,
onBuy,
costModifiers: [quarryMod, peddlerMod],
buyRestrictions,
tasks,
resolvers,
ai,
};

View File

@ -13,8 +13,6 @@ export default class GameRoomScene extends Phaser.Scene {
this.playfield = data.playfield ?? null;
this.cardBack = data.cardBack ?? null;
this.tilePlacement = data.tilePlacement ?? 'standard';
this.expansion = data.expansion ?? 'base';
this.scenario = data.scenario ?? null;
this.deckMode = data.deckMode ?? 'standard';
this.wordLength = data.wordLength ?? 4;
}
@ -28,8 +26,6 @@ export default class GameRoomScene extends Phaser.Scene {
playfield: this.playfield,
cardBack: this.cardBack,
tilePlacement: this.tilePlacement,
expansion: this.expansion,
scenario: this.scenario,
deckMode: this.deckMode,
wordLength: this.wordLength,
});

View File

@ -33,8 +33,6 @@ export default class OpponentSelectScene extends Phaser.Scene {
this.cardBackTiles = [];
this.selectedTilePlacement = 'standard';
this.selectedMatchVariant = 4;
this.selectedExpansion = 'base';
this.selectedScenario = 'new-shores'; // Catan Seafarers scenario
this.selectedDeckMode = 'standard';
this.selectedWordLength = 4;
this._initializing = false;
@ -118,7 +116,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
if (!this._startingGame) setMenuMusicVolume(0.6);
});
if (isCatan) this.buildCatanExpansionSection(340, 1013);
if (isCatan) this.buildTilePlacementSection(340, 1013);
if (isGoFish) this.buildMatchVariantSection(340, 1013);
@ -496,115 +494,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
this.startBtn.setEnabled(this.selected.size >= min);
}
// ── Catan: Expansion + (Scenario | Tile placement) selection ───────────────
static CATAN_EXPANSIONS = [
{ id: 'base', label: 'Base Game' },
{ id: 'seafarers', label: 'Seafarers' },
];
// Scenario ids must match the keys in games/catan/expansions/seafarers.js.
static CATAN_SCENARIOS = [
{ id: 'new-shores', label: 'New Shores' },
{ id: 'four-islands', label: 'Four Islands' },
{ id: 'oceania', label: 'Oceania' },
{ id: 'fog-island', label: 'Fog Island' },
];
buildCatanExpansionSection(centerX, centerY) {
const C = OpponentSelectScene;
const expLabelY = centerY - 80;
const expRowY = centerY - 52;
this._catanSubLabelY = centerY - 14;
this._catanSubRow0Y = centerY + 12;
this._catanSubRow1Y = centerY + 46;
this._catanCenterX = centerX;
const mkLabel = (y, text) => {
const t = this.add.text(centerX, y, text, {
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex,
}).setOrigin(0.5);
const bg = this.add.rectangle(centerX, y, t.width + 28, t.height + 12, 0x000000, 0.72);
this.children.moveBelow(bg, t);
};
// Expansion picker (Base / Seafarers).
mkLabel(expLabelY, 'Expansion');
const pillW = 150, pillH = 36, gap = 14;
const rowW = C.CATAN_EXPANSIONS.length * pillW + (C.CATAN_EXPANSIONS.length - 1) * gap;
this._catanExpBtns = [];
C.CATAN_EXPANSIONS.forEach((opt, i) => {
const x = centerX - rowW / 2 + i * (pillW + gap) + pillW / 2;
const sel = this.selectedExpansion === opt.id;
const bg = this.add.rectangle(x, expRowY, pillW, pillH, COLORS.panel)
.setStrokeStyle(3, sel ? COLORS.accent : COLORS.muted)
.setInteractive({ useHandCursor: true });
const pillBg = this.add.rectangle(x, expRowY, pillW, pillH, 0x000000, 0.72);
this.children.moveBelow(pillBg, bg);
this.add.text(x, expRowY, opt.label, {
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.textHex,
}).setOrigin(0.5);
bg.on('pointerup', () => {
if (this.selectedExpansion === opt.id) return;
this.selectedExpansion = opt.id;
this._catanExpBtns.forEach(({ bg: b, id }) =>
b.setStrokeStyle(3, id === this.selectedExpansion ? COLORS.accent : COLORS.muted));
this.renderCatanSubsection();
});
bg.on('pointerover', () => { if (this.selectedExpansion !== opt.id) bg.setStrokeStyle(3, COLORS.text); });
bg.on('pointerout', () => { if (this.selectedExpansion !== opt.id) bg.setStrokeStyle(3, COLORS.muted); });
this._catanExpBtns.push({ bg, id: opt.id });
});
this._catanSubObjs = [];
this.renderCatanSubsection();
}
// Base → Tile Placement pills; Seafarers → Scenario pills.
renderCatanSubsection() {
const C = OpponentSelectScene;
(this._catanSubObjs ?? []).forEach((o) => o.destroy());
this._catanSubObjs = [];
const centerX = this._catanCenterX;
const seafarers = this.selectedExpansion === 'seafarers';
const t = this.add.text(centerX, this._catanSubLabelY, seafarers ? 'Scenario' : 'Tile Placement', {
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex,
}).setOrigin(0.5);
const lbg = this.add.rectangle(centerX, this._catanSubLabelY, t.width + 28, t.height + 12, 0x000000, 0.72);
this.children.moveBelow(lbg, t);
this._catanSubObjs.push(t, lbg);
const options = seafarers
? C.CATAN_SCENARIOS
: [{ id: 'random', label: 'Random' }, { id: 'standard', label: 'Standard' }];
const selKey = () => (seafarers ? this.selectedScenario : this.selectedTilePlacement);
const setSel = (id) => { if (seafarers) this.selectedScenario = id; else this.selectedTilePlacement = id; };
const pillW = 150, pillH = 34, gap = 12, cols = 2;
const rowW = cols * pillW + (cols - 1) * gap;
this._catanSubBtns = [];
options.forEach((opt, i) => {
const col = i % cols, row = Math.floor(i / cols);
const x = centerX - rowW / 2 + col * (pillW + gap) + pillW / 2;
const y = row === 0 ? this._catanSubRow0Y : this._catanSubRow1Y;
const bg = this.add.rectangle(x, y, pillW, pillH, COLORS.panel)
.setStrokeStyle(3, selKey() === opt.id ? COLORS.accent : COLORS.muted)
.setInteractive({ useHandCursor: true });
const pillBg = this.add.rectangle(x, y, pillW, pillH, 0x000000, 0.72);
this.children.moveBelow(pillBg, bg);
const label = this.add.text(x, y, opt.label, {
fontFamily: '"Julius Sans One"', fontSize: '15px', color: COLORS.textHex,
}).setOrigin(0.5);
const refresh = () => this._catanSubBtns.forEach(({ bg: b, id }) =>
b.setStrokeStyle(3, id === selKey() ? COLORS.accent : COLORS.muted));
bg.on('pointerup', () => { setSel(opt.id); refresh(); });
bg.on('pointerover', () => { if (selKey() !== opt.id) bg.setStrokeStyle(3, COLORS.text); });
bg.on('pointerout', () => { if (selKey() !== opt.id) bg.setStrokeStyle(3, COLORS.muted); });
this._catanSubBtns.push({ bg, id: opt.id });
this._catanSubObjs.push(bg, pillBg, label);
});
}
// ── Catan: tile placement toggle (legacy; superseded by the expansion picker)
// ── Catan: tile placement toggle ───────────────────────────────────────────
buildTilePlacementSection(centerX, centerY) {
const options = [
{ id: 'random', label: 'Random' },
@ -650,121 +540,59 @@ export default class OpponentSelectScene extends Phaser.Scene {
});
}
// ── Dominion: Expansion + Kingdom selection ────────────────────────────────
// Kingdom preset options per expansion. Each id must match a preset key in the
// corresponding expansion module (or the base KINGDOM_PRESETS), plus 'random'.
static DOMINION_EXPANSIONS = [
{ id: 'base', label: 'Base Game' },
{ id: 'prosperity', label: 'Prosperity' },
];
static DOMINION_KINGDOMS = {
base: [
// ── Dominion: Kingdom deck mode toggle ─────────────────────────────────────
buildDeckModeSection(centerX, centerY) {
const options = [
{ id: 'standard', label: 'Standard' },
{ id: 'size-distortion', label: 'Size Distortion' },
{ id: 'deck-top', label: 'Deck Top' },
{ id: 'silver-gold', label: 'Silver & Gold' },
{ id: 'helpful-actions', label: 'Helpful Actions' },
{ id: 'random', label: 'Random' },
],
prosperity: [
{ id: 'beginners', label: 'Beginners' },
{ id: 'friendly-interactive', label: 'Friendly' },
{ id: 'bigger-treasures', label: 'Bigger Treasures' },
{ id: 'the-king', label: 'The King' },
{ id: 'random', label: 'Random' },
],
};
buildDeckModeSection(centerX, centerY) {
const C = OpponentSelectScene;
// Vertical budget in the left column (~y 930..1078).
const expLabelY = centerY - 80;
const expRowY = centerY - 52;
this._kingdomLabelY = centerY - 14;
this._kingdomRow0Y = centerY + 12;
this._kingdomRow1Y = centerY + 46;
this._kingdomCenterX = centerX;
const mkLabel = (y, text) => {
const t = this.add.text(centerX, y, text, {
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex,
}).setOrigin(0.5);
const bg = this.add.rectangle(centerX, y, t.width + 28, t.height + 12, 0x000000, 0.72);
this.children.moveBelow(bg, t);
};
// Expansion picker.
mkLabel(expLabelY, 'Expansion');
const expPillW = 150, expPillH = 36, expGap = 14;
const expRowW = C.DOMINION_EXPANSIONS.length * expPillW + (C.DOMINION_EXPANSIONS.length - 1) * expGap;
this._expansionBtns = [];
C.DOMINION_EXPANSIONS.forEach((opt, i) => {
const x = centerX - expRowW / 2 + i * (expPillW + expGap) + expPillW / 2;
const sel = this.selectedExpansion === opt.id;
const bg = this.add.rectangle(x, expRowY, expPillW, expPillH, COLORS.panel)
.setStrokeStyle(3, sel ? COLORS.accent : COLORS.muted)
.setInteractive({ useHandCursor: true });
const pillBg = this.add.rectangle(x, expRowY, expPillW, expPillH, 0x000000, 0.72);
this.children.moveBelow(pillBg, bg);
this.add.text(x, expRowY, opt.label, {
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.textHex,
}).setOrigin(0.5);
bg.on('pointerup', () => {
if (this.selectedExpansion === opt.id) return;
this.selectedExpansion = opt.id;
// Reset Kingdom to the first preset of the newly-chosen expansion.
this.selectedDeckMode = (C.DOMINION_KINGDOMS[opt.id] ?? C.DOMINION_KINGDOMS.base)[0].id;
this._expansionBtns.forEach(({ bg: b, id }) =>
b.setStrokeStyle(3, id === this.selectedExpansion ? COLORS.accent : COLORS.muted));
this.renderKingdomPills();
});
bg.on('pointerover', () => { if (this.selectedExpansion !== opt.id) bg.setStrokeStyle(3, COLORS.text); });
bg.on('pointerout', () => { if (this.selectedExpansion !== opt.id) bg.setStrokeStyle(3, COLORS.muted); });
this._expansionBtns.push({ bg, id: opt.id });
});
// Kingdom label + (dynamic) pills.
mkLabel(this._kingdomLabelY, 'Kingdom');
this._kingdomObjs = [];
this.renderKingdomPills();
}
// (Re)build the Kingdom preset pills for the current expansion.
renderKingdomPills() {
const C = OpponentSelectScene;
(this._kingdomObjs ?? []).forEach((o) => o.destroy());
this._kingdomObjs = [];
this._deckModeBtns = [];
const options = C.DOMINION_KINGDOMS[this.selectedExpansion] ?? C.DOMINION_KINGDOMS.base;
if (!options.some((o) => o.id === this.selectedDeckMode)) this.selectedDeckMode = options[0].id;
const centerX = this._kingdomCenterX;
const pillW = 150, pillH = 34, pillGap = 12, cols = 3;
];
const pillW = 150, pillH = 40, pillGap = 12;
const cols = 3;
const rowW = cols * pillW + (cols - 1) * pillGap;
// Two rows — shift the whole section up so both rows sit above y=1080.
const labelY = centerY - 53;
const row0Y = centerY - 12;
const row1Y = centerY + 38;
const labelText = this.add.text(centerX, labelY, 'Kingdom', {
fontFamily: '"Julius Sans One"',
fontSize: '20px',
color: COLORS.mutedHex,
}).setOrigin(0.5);
const labelBg = this.add.rectangle(centerX, labelY, labelText.width + 32, labelText.height + 14, 0x000000, 0.72);
this.children.moveBelow(labelBg, labelText);
this._deckModeBtns = [];
options.forEach((opt, i) => {
const col = i % cols, row = Math.floor(i / cols);
const col = i % cols;
const row = Math.floor(i / cols);
const x = centerX - rowW / 2 + col * (pillW + pillGap) + pillW / 2;
const y = row === 0 ? this._kingdomRow0Y : this._kingdomRow1Y;
const sel = this.selectedDeckMode === opt.id;
const y = row === 0 ? row0Y : row1Y;
const isSelected = this.selectedDeckMode === opt.id;
const bg = this.add.rectangle(x, y, pillW, pillH, COLORS.panel)
.setStrokeStyle(3, sel ? COLORS.accent : COLORS.muted)
.setStrokeStyle(3, isSelected ? COLORS.accent : COLORS.muted)
.setInteractive({ useHandCursor: true });
const pillBg = this.add.rectangle(x, y, pillW, pillH, 0x000000, 0.72);
this.children.moveBelow(pillBg, bg);
const label = this.add.text(x, y, opt.label, {
fontFamily: '"Julius Sans One"', fontSize: '15px', color: COLORS.textHex,
this.add.text(x, y, opt.label, {
fontFamily: '"Julius Sans One"',
fontSize: '16px',
color: COLORS.textHex,
}).setOrigin(0.5);
const refresh = () => this._deckModeBtns.forEach(({ bg: b, id }) =>
b.setStrokeStyle(3, id === this.selectedDeckMode ? COLORS.accent : COLORS.muted));
const refresh = () => {
this._deckModeBtns.forEach(({ bg: b, id }) =>
b.setStrokeStyle(3, id === this.selectedDeckMode ? COLORS.accent : COLORS.muted)
);
};
bg.on('pointerup', () => { this.selectedDeckMode = opt.id; refresh(); });
bg.on('pointerover', () => { if (this.selectedDeckMode !== opt.id) bg.setStrokeStyle(3, COLORS.text); });
bg.on('pointerout', () => { if (this.selectedDeckMode !== opt.id) bg.setStrokeStyle(3, COLORS.muted); });
this._deckModeBtns.push({ bg, id: opt.id });
this._kingdomObjs.push(bg, pillBg, label);
});
}
@ -981,9 +809,6 @@ export default class OpponentSelectScene extends Phaser.Scene {
cardBack: this.selectedCardBack,
tilePlacement: this.selectedTilePlacement,
matchVariant: this.selectedMatchVariant,
expansion: this.selectedExpansion,
scenario: (this.gameDef.slug === 'catan' && this.selectedExpansion !== 'base')
? this.selectedScenario : null,
deckMode: this.selectedDeckMode,
wordLength: this.selectedWordLength,
});

View File

@ -39,7 +39,6 @@ export default class PreloadScene extends Phaser.Scene {
frameHeight: 312,
});
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');
this.load.image('bg-room', '/assets/images/background-room.png');
this.load.image('bg-casino', '/assets/images/background-casino.png');
@ -70,9 +69,6 @@ export default class PreloadScene extends Phaser.Scene {
// the title/icon band is drawn at runtime). Optional — the scene falls back
// to procedural placeholders when the sheet is absent.
this.load.spritesheet('dominion-cards', '/assets/images/dominioncards.png', { frameWidth: 270, frameHeight: 390 });
// Prosperity expansion art (frame order documented in expansions/prosperity.js).
// Optional — same procedural fallback applies when the sheet is absent.
this.load.spritesheet('dominion-prosperity', '/assets/images/dominion-prosperity.png', { frameWidth: 270, frameHeight: 390 });
}
async create() {