Compare commits
No commits in common. "9dbf3feae4b8c2bdfcbaf7ff44a0c0c192e54989" and "57eeb3bfeeb8e547ee54f963053d1ea02e3d9222" have entirely different histories.
9dbf3feae4
...
57eeb3bfee
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.
|
|
@ -2,18 +2,18 @@
|
||||||
// The scene drives AI turns by repeatedly calling chooseAction() and applying
|
// The scene drives AI turns by repeatedly calling chooseAction() and applying
|
||||||
// the returned action until it returns { type: 'endTurn' }.
|
// 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 {
|
import {
|
||||||
legalSettlementNodes, legalRoadEdges, canAfford, bestTradeRatio,
|
legalSettlementNodes, legalRoadEdges, canAfford, bestTradeRatio,
|
||||||
handSize, nodeBuilding, stealTargets, publicVictoryPoints,
|
handSize, nodeBuilding, stealTargets, publicVictoryPoints,
|
||||||
victoryPoints, longestRoadFor, geoFor, winVpFor,
|
victoryPoints, WIN_VP, longestRoadFor,
|
||||||
} from './CatanLogic.js';
|
} from './CatanLogic.js';
|
||||||
|
|
||||||
// Value of a vertex = production potential of its adjacent hexes + diversity.
|
// Value of a vertex = production potential of its adjacent hexes + diversity.
|
||||||
function nodeValue(state, nodeId) {
|
function nodeValue(state, nodeId) {
|
||||||
let v = 0;
|
let v = 0;
|
||||||
const seen = new Set();
|
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];
|
const hex = state.hexes[hx];
|
||||||
if (hex.resource === 'desert' || hex.number == null) continue;
|
if (hex.resource === 'desert' || hex.number == null) continue;
|
||||||
v += pipCount(hex.number);
|
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 prod = { brick: 0, lumber: 0, wool: 0, grain: 0, ore: 0 };
|
||||||
const p = state.players[seat];
|
const p = state.players[seat];
|
||||||
const tally = (nodeId, mult) => {
|
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];
|
const hex = state.hexes[hx];
|
||||||
if (hex.resource === 'desert' || hex.number == null) continue;
|
if (hex.resource === 'desert' || hex.number == null) continue;
|
||||||
prod[hex.resource] += pipCount(hex.number) * mult;
|
prod[hex.resource] += pipCount(hex.number) * mult;
|
||||||
|
|
@ -46,7 +46,7 @@ export function chooseSetupSettlement(state, seat) {
|
||||||
for (const n of nodes) {
|
for (const n of nodes) {
|
||||||
let score = nodeValue(state, n);
|
let score = nodeValue(state, n);
|
||||||
// Bonus for grabbing resources the seat doesn't yet produce.
|
// 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];
|
const hex = state.hexes[hx];
|
||||||
if (hex.resource !== 'desert' && hex.number != null && prod[hex.resource] === 0) score += 1.5;
|
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 from = state.setup.lastSettlement;
|
||||||
const edges = legalRoadEdges(state, seat, true, from);
|
const edges = legalRoadEdges(state, seat, true, from);
|
||||||
let best = edges[0], bestScore = -Infinity;
|
let best = edges[0], bestScore = -Infinity;
|
||||||
const geo = geoFor(state);
|
|
||||||
for (const eid of edges) {
|
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 far = a === from ? b : a;
|
||||||
const score = nodeValue(state, far);
|
const score = nodeValue(state, far);
|
||||||
if (score > bestScore) { bestScore = score; best = eid; }
|
if (score > bestScore) { bestScore = score; best = eid; }
|
||||||
|
|
@ -86,25 +85,14 @@ export function chooseDiscard(state, seat) {
|
||||||
return discard;
|
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) {
|
export function chooseRobberMove(state, seat) {
|
||||||
let best = null, bestScore = -Infinity, bestTarget = null;
|
let best = null, bestScore = -Infinity, bestTarget = null;
|
||||||
const geo = geoFor(state);
|
|
||||||
for (const hex of state.hexes) {
|
for (const hex of state.hexes) {
|
||||||
if (hex.hasRobber) continue;
|
if (hex.hasRobber) continue;
|
||||||
if (hex.kind && hex.kind !== 'land') continue; // robber only on land hexes
|
|
||||||
let score = -1;
|
let score = -1;
|
||||||
let touchesSelf = false;
|
let touchesSelf = false;
|
||||||
let richest = null, richestCards = -1;
|
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);
|
const bld = nodeBuilding(state, nodeId);
|
||||||
if (!bld) continue;
|
if (!bld) continue;
|
||||||
if (bld.seat === seat) { touchesSelf = true; 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 (touchesSelf) score -= 100; // never hurt ourselves
|
||||||
if (score > bestScore) { bestScore = score; best = hex.id; bestTarget = richest; }
|
if (score > bestScore) { bestScore = score; best = hex.id; bestTarget = richest; }
|
||||||
}
|
}
|
||||||
// Fallback: any legal land hex.
|
// Fallback: any legal hex.
|
||||||
if (best === null) {
|
if (best === null) best = state.hexes.find((h) => !h.hasRobber)?.id ?? state.robberHex;
|
||||||
best = state.hexes.find((h) => !h.hasRobber && (!h.kind || h.kind === 'land'))?.id ?? state.robberHex;
|
|
||||||
}
|
|
||||||
return { hexId: best, targetSeat: bestTarget };
|
return { hexId: best, targetSeat: bestTarget };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -128,7 +114,7 @@ export function chooseRobberMove(state, seat) {
|
||||||
export function choosePreRoll(state, seat) {
|
export function choosePreRoll(state, seat) {
|
||||||
const p = state.players[seat];
|
const p = state.players[seat];
|
||||||
if (p.playedDevThisTurn || !p.devCards.includes('knight')) return null;
|
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);
|
const b = nodeBuilding(state, n);
|
||||||
return b && b.seat === seat;
|
return b && b.seat === seat;
|
||||||
});
|
});
|
||||||
|
|
@ -224,12 +210,11 @@ function canReachNewSpot(state, seat) {
|
||||||
function chooseExpansionRoad(state, seat) {
|
function chooseExpansionRoad(state, seat) {
|
||||||
const edges = legalRoadEdges(state, seat, false);
|
const edges = legalRoadEdges(state, seat, false);
|
||||||
if (!edges.length) return null;
|
if (!edges.length) return null;
|
||||||
const geo = geoFor(state);
|
|
||||||
|
|
||||||
// Build road adjacency and find connected components of the player's network.
|
// Build road adjacency and find connected components of the player's network.
|
||||||
const roadAdj = new Map();
|
const roadAdj = new Map();
|
||||||
for (const rid of state.players[seat].roads) {
|
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(ra)) roadAdj.set(ra, new Set());
|
||||||
if (!roadAdj.has(rb)) roadAdj.set(rb, new Set());
|
if (!roadAdj.has(rb)) roadAdj.set(rb, new Set());
|
||||||
roadAdj.get(ra).add(rb);
|
roadAdj.get(ra).add(rb);
|
||||||
|
|
@ -252,18 +237,18 @@ function chooseExpansionRoad(state, seat) {
|
||||||
|
|
||||||
let best = null, bestScore = -Infinity;
|
let best = null, bestScore = -Infinity;
|
||||||
for (const eid of edges) {
|
for (const eid of edges) {
|
||||||
const [a, b] = geo.edges[eid].nodes;
|
const [a, b] = EDGES[eid].nodes;
|
||||||
let score = 0;
|
let score = 0;
|
||||||
|
|
||||||
for (const node of [a, b]) {
|
for (const node of [a, b]) {
|
||||||
// Direct endpoint: full value if buildable.
|
// 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);
|
score += nodeValue(state, node);
|
||||||
}
|
}
|
||||||
// 1-hop lookahead: nodes one road-length further, half weight.
|
// 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 (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;
|
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 afford2 = (cost) => RESOURCE_TYPES.every((r) => have2[r] >= (cost[r] || 0));
|
||||||
const ownVP = victoryPoints(state, seat);
|
const ownVP = victoryPoints(state, seat);
|
||||||
const win = winVpFor(state);
|
if (p.settlements.length && afford2(COSTS.city) && ownVP + 1 >= WIN_VP) return true;
|
||||||
if (p.settlements.length && afford2(COSTS.city) && ownVP + 1 >= win) return true;
|
if (legalSettlementNodes(state, seat, false).length && afford2(COSTS.settlement) && ownVP + 1 >= WIN_VP) return true;
|
||||||
if (legalSettlementNodes(state, seat, false).length && afford2(COSTS.settlement) && ownVP + 1 >= win) return true;
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ export const HEX_SIZE = 92; // centre-to-corner radius (pointy-top)
|
||||||
|
|
||||||
const SQRT3 = Math.sqrt(3);
|
const SQRT3 = Math.sqrt(3);
|
||||||
export const HEX_W = SQRT3 * HEX_SIZE; // flat-to-flat width / in-row spacing
|
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];
|
export const HEX_ROWS = [3, 4, 5, 4, 3];
|
||||||
|
|
||||||
|
|
@ -106,43 +107,25 @@ function hexCorners(cx, cy, size) {
|
||||||
return pts;
|
return pts;
|
||||||
}
|
}
|
||||||
|
|
||||||
function midpoint(nodes, edge) {
|
function buildGeometry(cx, cy, size) {
|
||||||
const a = nodes[edge.nodes[0]], b = nodes[edge.nodes[1]];
|
const hexes = [];
|
||||||
return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
|
// Hex centres, rows of [3,4,5,4,3], each row horizontally centred.
|
||||||
}
|
|
||||||
|
|
||||||
// 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 = [];
|
|
||||||
let id = 0;
|
let id = 0;
|
||||||
for (let r = 0; r < rows.length; r++) {
|
for (let r = 0; r < HEX_ROWS.length; r++) {
|
||||||
const count = rows[r];
|
const count = HEX_ROWS[r];
|
||||||
const rowY = cy + (r - (rows.length - 1) / 2) * rowV;
|
const rowY = cy + (r - 2) * ROW_V;
|
||||||
const startX = cx - ((count - 1) / 2) * hexW;
|
const startX = cx - ((count - 1) / 2) * HEX_W;
|
||||||
for (let c = 0; c < count; c++) {
|
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.
|
// 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 nodeMap = new Map(); // key -> node
|
||||||
const nodes = [];
|
const nodes = [];
|
||||||
const ensureNode = (p) => {
|
const ensureNode = (p) => {
|
||||||
const k = `${Math.round(p.x)}_${Math.round(p.y)}`;
|
const k = nodeKey(p);
|
||||||
let n = nodeMap.get(k);
|
let n = nodeMap.get(k);
|
||||||
if (!n) {
|
if (!n) {
|
||||||
n = { id: nodes.length, x: Math.round(p.x), y: Math.round(p.y), hexes: [], adj: [] };
|
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);
|
hex.corners = corners.map((n) => n.id);
|
||||||
for (const n of corners) if (!n.hexes.includes(hex.id)) n.hexes.push(hex.id);
|
for (const n of corners) if (!n.hexes.includes(hex.id)) n.hexes.push(hex.id);
|
||||||
for (let i = 0; i < 6; i++) {
|
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));
|
e.hexes = nodes[a].hexes.filter((h) => nodes[b].hexes.includes(h));
|
||||||
}
|
}
|
||||||
|
|
||||||
// O(1) edge lookup by node pair.
|
// Port slots: coastal edges (touch exactly 1 hex), 9 spaced around the rim.
|
||||||
const edgeIndex = edgeMap;
|
|
||||||
|
|
||||||
let portSlots = [];
|
|
||||||
if (portCount > 0) {
|
|
||||||
// Coastal edges (touch exactly 1 hex), `portCount` spaced around the rim.
|
|
||||||
const coastal = edges.filter((e) => e.hexes.length === 1);
|
const coastal = edges.filter((e) => e.hexes.length === 1);
|
||||||
coastal.sort((p, q) => {
|
coastal.sort((p, q) => {
|
||||||
const pm = midpoint(nodes, p), qm = midpoint(nodes, 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);
|
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 portSlots = [];
|
||||||
const e = coastal[Math.round((i * coastal.length) / portCount) % coastal.length];
|
for (let i = 0; i < 9; i++) {
|
||||||
portSlots.push(portSlot(nodes, e, cx, cy));
|
const e = coastal[Math.round((i * coastal.length) / 9) % coastal.length];
|
||||||
}
|
const m = midpoint(nodes, e);
|
||||||
}
|
portSlots.push({
|
||||||
|
edgeId: e.id,
|
||||||
return { hexes, nodes, edges, edgeIndex, portSlots, cx, cy, size };
|
nodes: [...e.nodes],
|
||||||
}
|
|
||||||
|
|
||||||
// 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],
|
|
||||||
x: m.x, y: m.y,
|
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),
|
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.
|
return { hexes, nodes, edges, portSlots };
|
||||||
export const GEOMETRY = assemble(rowCenters(HEX_ROWS), HEX_SIZE, BOARD_CX, BOARD_CY, 9);
|
}
|
||||||
|
|
||||||
// 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 HEXES = GEOMETRY.hexes;
|
||||||
export const NODES = GEOMETRY.nodes;
|
export const NODES = GEOMETRY.nodes;
|
||||||
export const EDGES = GEOMETRY.edges;
|
export const EDGES = GEOMETRY.edges;
|
||||||
export const PORT_SLOTS = GEOMETRY.portSlots;
|
export const PORT_SLOTS = GEOMETRY.portSlots;
|
||||||
|
|
||||||
// Build an arbitrary board from a hex-centre list (Seafarers scenarios).
|
// Edge id between two adjacent node ids, or -1.
|
||||||
export function buildBoard({ centers, size = HEX_SIZE, cx = BOARD_CX, cy = BOARD_CY }) {
|
export function edgeBetween(a, b) {
|
||||||
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) {
|
|
||||||
const lo = Math.min(a, b), hi = Math.max(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;
|
return e ? e.id : -1;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@ import { playSound, SFX } from '../../ui/Sounds.js';
|
||||||
import { enqueue as enqueueSpeech } from '../../ui/SpeechQueue.js';
|
import { enqueue as enqueueSpeech } from '../../ui/SpeechQueue.js';
|
||||||
import { MusicPlayer } from '../../ui/MusicPlayer.js';
|
import { MusicPlayer } from '../../ui/MusicPlayer.js';
|
||||||
import {
|
import {
|
||||||
RESOURCE_INFO, RESOURCE_TYPES, DESERT_COLOR,
|
NODES, EDGES, HEXES, PORT_SLOTS, RESOURCE_INFO, RESOURCE_TYPES, DESERT_COLOR,
|
||||||
PLAYER_COLORS, COSTS, DEV_INFO, pipCount, HEX_SIZE,
|
PLAYER_COLORS, COSTS, DEV_INFO, pipCount, WIN_VP, HEX_SIZE, HEX_W,
|
||||||
} from './CatanBoard.js';
|
} from './CatanBoard.js';
|
||||||
import * as L from './CatanLogic.js';
|
import * as L from './CatanLogic.js';
|
||||||
import * as AI from './CatanAI.js';
|
import * as AI from './CatanAI.js';
|
||||||
|
|
@ -25,8 +25,6 @@ export default class CatanGame extends Phaser.Scene {
|
||||||
this.playfield = data.playfield ?? null;
|
this.playfield = data.playfield ?? null;
|
||||||
this.cardBack = data.cardBack ?? null;
|
this.cardBack = data.cardBack ?? null;
|
||||||
this.tilePlacement = data.tilePlacement ?? 'standard';
|
this.tilePlacement = data.tilePlacement ?? 'standard';
|
||||||
this.expansion = data.expansion ?? 'base';
|
|
||||||
this.scenario = data.scenario ?? null;
|
|
||||||
this.gs = null;
|
this.gs = null;
|
||||||
this.busy = false;
|
this.busy = false;
|
||||||
this.highlights = [];
|
this.highlights = [];
|
||||||
|
|
@ -61,15 +59,12 @@ export default class CatanGame extends Phaser.Scene {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── coordinate helpers ──────────────────────────────────────────────────────
|
// ── coordinate helpers ──────────────────────────────────────────────────────
|
||||||
// Active board geometry (base island, or the selected Seafarers scenario).
|
nodePos(id) { return { x: NODES[id].x, y: NODES[id].y }; }
|
||||||
get geo() { return L.geoFor(this.gs); }
|
|
||||||
nodePos(id) { const n = this.geo.nodes[id]; return { x: n.x, y: n.y }; }
|
|
||||||
edgePos(id) {
|
edgePos(id) {
|
||||||
const [a, b] = this.geo.edges[id].nodes;
|
const [a, b] = EDGES[id].nodes;
|
||||||
const N = this.geo.nodes;
|
return { x: (NODES[a].x + NODES[b].x) / 2, y: (NODES[a].y + NODES[b].y) / 2 };
|
||||||
return { x: (N[a].x + N[b].x) / 2, y: (N[a].y + N[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]; }
|
playerColor(seat) { return PLAYER_COLORS[this.gs.players[seat].colorIndex]; }
|
||||||
pname(seat) { return L.playerName(this.gs, seat); }
|
pname(seat) { return L.playerName(this.gs, seat); }
|
||||||
|
|
||||||
|
|
@ -119,9 +114,6 @@ export default class CatanGame extends Phaser.Scene {
|
||||||
ore: [6, 7],
|
ore: [6, 7],
|
||||||
grain: [8, 9],
|
grain: [8, 9],
|
||||||
desert: [10, 11],
|
desert: [10, 11],
|
||||||
sea: [12, 13],
|
|
||||||
gold: [14, 15],
|
|
||||||
fog: [16, 17],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
drawHexes() {
|
drawHexes() {
|
||||||
|
|
@ -138,48 +130,41 @@ export default class CatanGame extends Phaser.Scene {
|
||||||
const inset = (pts, cx, cy, s) =>
|
const inset = (pts, cx, cy, s) =>
|
||||||
pts.map(p => ({ x: cx + (p.x - cx) * s, y: cy + (p.y - 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) {
|
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 { x, y } = this.hexPos(hex.id);
|
||||||
const terr = this.hexTerrain(hex);
|
|
||||||
|
|
||||||
// Scale factors for the 7px colored ring + 4px dark ring (absolute pixels).
|
// Inradius ≈ 79.7; compute scale factors for 7px colored ring + 4px dark ring
|
||||||
const s1 = 1 - 7 / inradius; // after colored border
|
const s1 = 1 - 7 / (HEX_SIZE * Math.sqrt(3) / 2); // after colored border
|
||||||
const s2 = 1 - 11 / inradius; // after dark border (image area)
|
const s2 = 1 - 11 / (HEX_SIZE * Math.sqrt(3) / 2); // after dark border (image area)
|
||||||
const innerPts = inset(pts, x, y, s1);
|
const innerPts = inset(pts, x, y, s1);
|
||||||
const imagePts = inset(pts, x, y, s2);
|
const imagePts = inset(pts, x, y, s2);
|
||||||
|
|
||||||
// Layer 1: terrain swatch fill (outer colored border ring)
|
// Layer 1: resource swatch fill (outer colored border ring)
|
||||||
g.fillStyle(terr.swatch, 0.55);
|
const swatch = hex.resource === 'desert' ? DESERT_COLOR : RESOURCE_INFO[hex.resource].swatch;
|
||||||
|
g.fillStyle(swatch, 0.55);
|
||||||
g.fillPoints(pts, true);
|
g.fillPoints(pts, true);
|
||||||
|
|
||||||
// Layer 2: dark fill inset (inner black border ring)
|
// Layer 2: dark fill inset (inner black border ring)
|
||||||
g.fillStyle(0x111111, 1);
|
g.fillStyle(0x111111, 1);
|
||||||
g.fillPoints(innerPts, true);
|
g.fillPoints(innerPts, true);
|
||||||
|
|
||||||
// Layer 3: tile image masked to innermost polygon (land/desert only)
|
// Layer 3: tile image masked to innermost polygon
|
||||||
if (terr.tileFrames && this.textures.exists('catan-tiles')) {
|
if (this.textures.exists('catan-tiles')) {
|
||||||
if (this.hexTileFrames[hex.id] == null) {
|
const frames = CatanGame.TILE_FRAMES[hex.resource] ?? [10, 11];
|
||||||
this.hexTileFrames[hex.id] = terr.tileFrames[Math.floor(Math.random() * 2)];
|
const frame = frames[Math.floor(Math.random() * 2)];
|
||||||
}
|
|
||||||
const frame = this.hexTileFrames[hex.id];
|
|
||||||
const maskG = this.make.graphics({ x: 0, y: 0, add: false });
|
const maskG = this.make.graphics({ x: 0, y: 0, add: false });
|
||||||
maskG.fillStyle(0xffffff);
|
maskG.fillStyle(0xffffff);
|
||||||
maskG.fillPoints(imagePts, true);
|
maskG.fillPoints(imagePts, true);
|
||||||
const img = this.add.image(x, y, 'catan-tiles', frame)
|
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())
|
.setMask(maskG.createGeometryMask())
|
||||||
.setDepth(D.board + 1);
|
.setDepth(D.board + 1);
|
||||||
this.hexImgs.push({ img, maskG });
|
this.hexImgs.push({ img, maskG });
|
||||||
} else {
|
} else {
|
||||||
// Fallback / water / gold / fog: solid color fill in the image area
|
// Fallback: resource color fill in the image area
|
||||||
g.fillStyle(terr.color, 1);
|
const color = hex.resource === 'desert' ? DESERT_COLOR : RESOURCE_INFO[hex.resource].color;
|
||||||
|
g.fillStyle(color, 1);
|
||||||
g.fillPoints(imagePts, true);
|
g.fillPoints(imagePts, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -187,31 +172,14 @@ export default class CatanGame extends Phaser.Scene {
|
||||||
this.hexBorderGfx.lineStyle(2, 0x4a3210, 0.35);
|
this.hexBorderGfx.lineStyle(2, 0x4a3210, 0.35);
|
||||||
this.hexBorderGfx.strokePoints(pts, true);
|
this.hexBorderGfx.strokePoints(pts, true);
|
||||||
|
|
||||||
// Terrain label
|
// Resource label
|
||||||
this.hexLabels.push(this.add.text(x, y - 56, terr.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',
|
fontFamily: '"Julius Sans One"', fontSize: '15px', color: '#2a2118',
|
||||||
}).setOrigin(0.5).setAlpha(0.65).setDepth(D.board + 3));
|
}).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() {
|
drawPorts() {
|
||||||
this.portObjs.forEach((o) => o.destroy());
|
this.portObjs.forEach((o) => o.destroy());
|
||||||
this.portObjs = [];
|
this.portObjs = [];
|
||||||
|
|
@ -242,7 +210,7 @@ export default class CatanGame extends Phaser.Scene {
|
||||||
// little jetties to the two coastal nodes
|
// little jetties to the two coastal nodes
|
||||||
const jg = this.add.graphics().setDepth(D.port - 1);
|
const jg = this.add.graphics().setDepth(D.port - 1);
|
||||||
jg.lineStyle(3, 0x6b4a1a, 0.8);
|
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);
|
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
|
// 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.add({ targets: c, x: lx, duration: totalMs, ease: 'Quad.Out' });
|
||||||
this.tweens.chain({
|
this.tweens.chain({ targets: c, tweens: [
|
||||||
targets: c, tweens: [
|
|
||||||
{ y: arcY, duration: outMs, ease: 'Quad.Out' },
|
{ y: arcY, duration: outMs, ease: 'Quad.Out' },
|
||||||
{ y: ly, duration: backMs, ease: 'Bounce.Out' },
|
{ y: ly, duration: backMs, ease: 'Bounce.Out' },
|
||||||
]
|
]});
|
||||||
});
|
|
||||||
// Scale up as die approaches
|
// Scale up as die approaches
|
||||||
this.tweens.add({ targets: c, scale: 1, duration: outMs + backMs * 0.55, ease: 'Quad.Out' });
|
this.tweens.add({ targets: c, scale: 1, duration: outMs + backMs * 0.55, ease: 'Quad.Out' });
|
||||||
// Spin
|
// 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; };
|
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('roll', 'Roll Dice', () => this.onRoll());
|
||||||
mk('road', 'Build Road', () => this.enterPlace('road'));
|
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('settlement', 'Build Settlement', () => this.enterPlace('settlement'));
|
||||||
mk('city', 'Build City', () => this.enterPlace('city'));
|
mk('city', 'Build City', () => this.enterPlace('city'));
|
||||||
mk('buyDev', 'Buy Dev Card', () => this.onBuyDev());
|
mk('buyDev', 'Buy Dev Card', () => this.onBuyDev());
|
||||||
|
|
@ -460,11 +425,8 @@ export default class CatanGame extends Phaser.Scene {
|
||||||
return c;
|
return c;
|
||||||
};
|
};
|
||||||
|
|
||||||
const cardY = this.expansion === 'seafarers' ? 830 : 760;
|
|
||||||
this._lrCard = makeCard(1775, 0, 0xdaa520);
|
this._lrCard = makeCard(1775, 0, 0xdaa520);
|
||||||
this._laCard = makeCard(1855, 1, 0xb03030);
|
this._laCard = makeCard(1855, 1, 0xb03030);
|
||||||
this._lrCard.setY(cardY);
|
|
||||||
this._laCard.setY(cardY);
|
|
||||||
this._prevSpecialCardOwners = { longestRoad: null, largestArmy: null };
|
this._prevSpecialCardOwners = { longestRoad: null, largestArmy: null };
|
||||||
this._specialCardAnimating = { longestRoad: false, largestArmy: false };
|
this._specialCardAnimating = { longestRoad: false, largestArmy: false };
|
||||||
|
|
||||||
|
|
@ -492,10 +454,9 @@ export default class CatanGame extends Phaser.Scene {
|
||||||
|
|
||||||
_getSpecialCardPos(cardType, owner) {
|
_getSpecialCardPos(cardType, owner) {
|
||||||
if (owner === null) {
|
if (owner === null) {
|
||||||
const cardY = this.expansion === 'seafarers' ? 830 : 760;
|
|
||||||
return cardType === 'longestRoad'
|
return cardType === 'longestRoad'
|
||||||
? { x: 1775, y: cardY, scale: 1 }
|
? { x: 1775, y: 760, scale: 1 }
|
||||||
: { x: 1855, y: cardY, scale: 1 };
|
: { x: 1855, y: 760, scale: 1 };
|
||||||
}
|
}
|
||||||
if (owner === 0) {
|
if (owner === 0) {
|
||||||
return cardType === 'longestRoad'
|
return cardType === 'longestRoad'
|
||||||
|
|
@ -541,16 +502,13 @@ export default class CatanGame extends Phaser.Scene {
|
||||||
const midScale = (fromPos.scale + toPos.scale) / 2;
|
const midScale = (fromPos.scale + toPos.scale) / 2;
|
||||||
const half = 380;
|
const half = 380;
|
||||||
|
|
||||||
this.tweens.chain({
|
this.tweens.chain({ targets: card, tweens: [
|
||||||
targets: card, tweens: [
|
|
||||||
{ x: midX, y: peakY, scale: midScale, duration: half, ease: 'Quad.Out' },
|
{ 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',
|
duration: half, ease: 'Quad.In',
|
||||||
onComplete: () => { this._specialCardAnimating[cardType] = false; },
|
onComplete: () => { this._specialCardAnimating[cardType] = false; },
|
||||||
},
|
},
|
||||||
]
|
]});
|
||||||
});
|
|
||||||
|
|
||||||
enqueueSpeech(cardType === 'longestRoad' ? 'catan-card-road' : 'catan-card-army');
|
enqueueSpeech(cardType === 'longestRoad' ? 'catan-card-road' : 'catan-card-army');
|
||||||
}
|
}
|
||||||
|
|
@ -692,23 +650,7 @@ export default class CatanGame extends Phaser.Scene {
|
||||||
const panelRight = 1900;
|
const panelRight = 1900;
|
||||||
const panelW = 320;
|
const panelW = 320;
|
||||||
const cx = panelRight - panelW / 2;
|
const cx = panelRight - panelW / 2;
|
||||||
|
const bgCy = 980, bgH = 164;
|
||||||
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 893–1080).
|
|
||||||
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 panel = this.add.container(0, 0).setDepth(D.hud);
|
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));
|
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,
|
fontFamily: 'Righteous', fontSize: '20px', color: COLORS.goldHex,
|
||||||
}).setOrigin(0.5, 0));
|
}).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 lx = panelRight - panelW + 14;
|
||||||
const rx = panelRight - 14;
|
const rx = panelRight - 14;
|
||||||
const rowY0 = bgCy - bgH / 2 + rowPad;
|
const rowY0 = bgCy - bgH / 2 + 44;
|
||||||
const SW = 16, SH = 16, SG = 4, SR = 3;
|
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();
|
const g = this.add.graphics();
|
||||||
panel.add(g);
|
panel.add(g);
|
||||||
|
|
@ -1029,53 +979,25 @@ export default class CatanGame extends Phaser.Scene {
|
||||||
renderPieces() {
|
renderPieces() {
|
||||||
this.pieceObjs.forEach((o) => o.destroy());
|
this.pieceObjs.forEach((o) => o.destroy());
|
||||||
this.pieceObjs = [];
|
this.pieceObjs = [];
|
||||||
const N = this.geo.nodes;
|
|
||||||
// roads
|
// roads
|
||||||
for (const p of this.gs.players) {
|
for (const p of this.gs.players) {
|
||||||
const col = PLAYER_COLORS[p.colorIndex];
|
const col = PLAYER_COLORS[p.colorIndex];
|
||||||
for (const eid of p.roads) {
|
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 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(16, 0xffffff, 0.9); g.lineBetween(ax, ay, bx, by);
|
||||||
g.lineStyle(12, col.hexDark, 1); 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);
|
g.lineStyle(7, col.hex, 1); g.lineBetween(ax, ay, bx, by);
|
||||||
this.pieceObjs.push(g);
|
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
|
// settlements + cities
|
||||||
for (const p of this.gs.players) {
|
for (const p of this.gs.players) {
|
||||||
const col = PLAYER_COLORS[p.colorIndex];
|
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.settlements) this.pieceObjs.push(this.makeSettlement(NODES[nid].x, NODES[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.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) {
|
makeSettlement(x, y, col) {
|
||||||
|
|
@ -1300,19 +1222,15 @@ export default class CatanGame extends Phaser.Scene {
|
||||||
const peakY = Math.min(srcPos.y, destY) - arcHeight;
|
const peakY = Math.min(srcPos.y, destY) - arcHeight;
|
||||||
const half = duration / 2;
|
const half = duration / 2;
|
||||||
this.tweens.add({ targets: container, x: destX, duration, ease: 'Quad.InOut' });
|
this.tweens.add({ targets: container, x: destX, duration, ease: 'Quad.InOut' });
|
||||||
this.tweens.chain({
|
this.tweens.chain({ targets: container, tweens: [
|
||||||
targets: container, tweens: [
|
|
||||||
{ y: peakY, duration: half, ease: 'Quad.Out' },
|
{ 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();
|
emitter.stop();
|
||||||
container.destroy();
|
container.destroy();
|
||||||
this.time.delayedCall(500, () => emitter.destroy());
|
this.time.delayedCall(500, () => emitter.destroy());
|
||||||
resolve();
|
resolve();
|
||||||
}
|
}},
|
||||||
},
|
]});
|
||||||
]
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1333,19 +1251,15 @@ export default class CatanGame extends Phaser.Scene {
|
||||||
const peakY = Math.min(bankPos.y, destPos.y) - arcHeight;
|
const peakY = Math.min(bankPos.y, destPos.y) - arcHeight;
|
||||||
const half = duration / 2;
|
const half = duration / 2;
|
||||||
this.tweens.add({ targets: container, x: destPos.x, duration, ease: 'Quad.InOut' });
|
this.tweens.add({ targets: container, x: destPos.x, duration, ease: 'Quad.InOut' });
|
||||||
this.tweens.chain({
|
this.tweens.chain({ targets: container, tweens: [
|
||||||
targets: container, tweens: [
|
|
||||||
{ y: peakY, duration: half, ease: 'Quad.Out' },
|
{ 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({
|
this.tweens.add({
|
||||||
targets: container, alpha: 0, scaleX: 1.5, scaleY: 1.5, duration: 280,
|
targets: container, alpha: 0, scaleX: 1.5, scaleY: 1.5, duration: 280,
|
||||||
onComplete: () => { container.destroy(); resolve(); },
|
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;
|
const hasRoadSpot = action && L.legalRoadEdges(s, 0, false).length > 0;
|
||||||
set('roll', me && s.phase === 'rollPhase');
|
set('roll', me && s.phase === 'rollPhase');
|
||||||
set('road', (action && hasRoadSpot && L.canAfford(p, COSTS.road)) || (action && s.freeRoads > 0 && hasRoadSpot));
|
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('settlement', hasSettleSpot && L.canAfford(p, COSTS.settlement));
|
||||||
set('city', action && p.settlements.length > 0 && L.canAfford(p, COSTS.city));
|
set('city', action && p.settlements.length > 0 && L.canAfford(p, COSTS.city));
|
||||||
set('buyDev', action && s.devDeck.length > 0 && L.canAfford(p, COSTS.devCard));
|
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.busy = false;
|
||||||
this.placeMode = null;
|
this.placeMode = null;
|
||||||
const playerCount = Math.min(4, 1 + this.opponents.length);
|
const playerCount = Math.min(4, 1 + this.opponents.length);
|
||||||
this.gs = L.createInitialState(playerCount, {
|
this.gs = L.createInitialState(playerCount, { tilePlacement: this.tilePlacement });
|
||||||
tilePlacement: this.tilePlacement,
|
|
||||||
expansion: this.expansion,
|
|
||||||
scenario: this.scenario,
|
|
||||||
});
|
|
||||||
const names = ['You', ...this.opponents.map((o) => o?.name ?? 'CPU')];
|
const names = ['You', ...this.opponents.map((o) => o?.name ?? 'CPU')];
|
||||||
L.setPlayerNames(this.gs, names);
|
L.setPlayerNames(this.gs, names);
|
||||||
this.hexTileFrames = {};
|
|
||||||
this.drawHexes();
|
this.drawHexes();
|
||||||
this.drawPorts();
|
this.drawPorts();
|
||||||
this.drawChits();
|
this.drawChits();
|
||||||
|
|
@ -1612,8 +1518,6 @@ export default class CatanGame extends Phaser.Scene {
|
||||||
} else if (s.phase === 'rollPhase') {
|
} else if (s.phase === 'rollPhase') {
|
||||||
if (me) { /* wait for Roll button */ }
|
if (me) { /* wait for Roll button */ }
|
||||||
else await this.aiRoll();
|
else await this.aiRoll();
|
||||||
} else if (s.phase === 'goldPick') {
|
|
||||||
await this.handleGoldPickPhase();
|
|
||||||
} else if (s.phase === 'discard') {
|
} else if (s.phase === 'discard') {
|
||||||
await this.handleDiscardPhase();
|
await this.handleDiscardPhase();
|
||||||
} else if (s.phase === 'moveRobber') {
|
} else if (s.phase === 'moveRobber') {
|
||||||
|
|
@ -2012,31 +1916,6 @@ export default class CatanGame extends Phaser.Scene {
|
||||||
this.advance();
|
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 ─────────────────────────────────────────────────────────
|
// ── human: discards ─────────────────────────────────────────────────────────
|
||||||
async handleDiscardPhase() {
|
async handleDiscardPhase() {
|
||||||
this.busy = true;
|
this.busy = true;
|
||||||
|
|
@ -2111,11 +1990,6 @@ export default class CatanGame extends Phaser.Scene {
|
||||||
const { x, y } = this.nodePos(nid);
|
const { x, y } = this.nodePos(nid);
|
||||||
this.addHighlight(x, y, () => this.doBuild('city', nid), 0xffd700);
|
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)`);
|
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}`);
|
enqueueSpeech(`catan-purchase-${type}`);
|
||||||
await this.animateCostPayment(0, type);
|
await this.animateCostPayment(0, type);
|
||||||
await this.animatePiecePlacement(0, type, dest.x, dest.y);
|
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 === 'road') this.gs = L.buildRoad(this.gs, 0, id);
|
||||||
if (type === 'settlement') this.gs = L.buildSettlement(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 === '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);
|
playSound(this, SFX.PIECE_CLICK);
|
||||||
this.busy = false;
|
this.busy = false;
|
||||||
this.advance();
|
this.advance();
|
||||||
|
|
@ -2406,10 +2273,8 @@ export default class CatanGame extends Phaser.Scene {
|
||||||
backgroundColor: '#111923ee', padding: { x: 26, y: 12 },
|
backgroundColor: '#111923ee', padding: { x: 26, y: 12 },
|
||||||
}).setOrigin(0.5).setDepth(D.banner);
|
}).setOrigin(0.5).setDepth(D.banner);
|
||||||
banner.setAlpha(0);
|
banner.setAlpha(0);
|
||||||
this.tweens.add({
|
this.tweens.add({ targets: banner, alpha: 1, y: 140, duration: 280, ease: 'Back.easeOut',
|
||||||
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() })) });
|
||||||
onComplete: () => this.time.delayedCall(900, () => this.tweens.add({ targets: banner, alpha: 0, y: 120, duration: 220, onComplete: () => banner.destroy() }))
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── game over ─────────────────────────────────────────────────────────────────
|
// ── game over ─────────────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -2,22 +2,10 @@
|
||||||
// Every action takes a state and returns a NEW state (deep-cloned first).
|
// Every action takes a state and returns a NEW state (deep-cloned first).
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getBoard, edgeBetween,
|
NODES, EDGES, HEXES, PORT_SLOTS, edgeBetween,
|
||||||
RESOURCE_BAG, STANDARD_RESOURCES, PORT_BAG, COSTS, DEV_DECK, RESOURCE_TYPES, WIN_VP,
|
RESOURCE_BAG, STANDARD_RESOURCES, PORT_BAG, COSTS, DEV_DECK, RESOURCE_TYPES, WIN_VP,
|
||||||
CHIT_SPIRAL, CHIT_SEQUENCE,
|
CHIT_SPIRAL, CHIT_SEQUENCE,
|
||||||
} from './CatanBoard.js';
|
} 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 ─────────────────────────────────────────────────────────
|
// ── small utilities ─────────────────────────────────────────────────────────
|
||||||
function shuffle(arr) {
|
function shuffle(arr) {
|
||||||
|
|
@ -54,14 +42,13 @@ export function edgeOwner(state, edgeId) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── initial state ─────────────────────────────────────────────────────────────
|
// ── initial state ─────────────────────────────────────────────────────────────
|
||||||
// Builds the standard 19-hex island (kind/resource/number/ports) — the base game
|
export function createInitialState(playerCount = 3, { tilePlacement = 'random' } = {}) {
|
||||||
// board, factored out so a Seafarers scenario can supply its own board instead.
|
const n = Math.max(3, Math.min(4, playerCount));
|
||||||
function buildBaseIsland(tilePlacement) {
|
|
||||||
const geo = getBoard('base');
|
// Resources onto hexes.
|
||||||
const resources = tilePlacement === 'standard' ? [...STANDARD_RESOURCES] : shuffle(RESOURCE_BAG);
|
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,
|
id: h.id,
|
||||||
kind: resources[i] === 'desert' ? 'desert' : 'land',
|
|
||||||
resource: resources[i],
|
resource: resources[i],
|
||||||
number: null,
|
number: null,
|
||||||
hasRobber: false,
|
hasRobber: false,
|
||||||
|
|
@ -72,29 +59,20 @@ function buildBaseIsland(tilePlacement) {
|
||||||
// Number chits: walk the standard spiral, skip desert, assign fixed sequence A–R.
|
// Number chits: walk the standard spiral, skip desert, assign fixed sequence A–R.
|
||||||
let chitIdx = 0;
|
let chitIdx = 0;
|
||||||
for (const hexId of CHIT_SPIRAL) {
|
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.
|
// Port types onto fixed slots.
|
||||||
const portTypes = shuffle(PORT_BAG);
|
const portTypes = shuffle(PORT_BAG);
|
||||||
const ports = geo.portSlots.map((slot, i) => ({
|
const ports = PORT_SLOTS.map((slot, i) => ({
|
||||||
edgeId: slot.edgeId,
|
edgeId: slot.edgeId,
|
||||||
nodes: [...slot.nodes],
|
nodes: [...slot.nodes],
|
||||||
type: portTypes[i],
|
type: portTypes[i],
|
||||||
x: slot.x, y: slot.y, angle: slot.angle,
|
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 = [];
|
const players = [];
|
||||||
for (let seat = 0; seat < n; seat++) {
|
for (let seat = 0; seat < n; seat++) {
|
||||||
players.push({
|
players.push({
|
||||||
|
|
@ -105,7 +83,6 @@ export function createInitialState(playerCount = 3, { tilePlacement = 'random',
|
||||||
settlements: [],
|
settlements: [],
|
||||||
cities: [],
|
cities: [],
|
||||||
roads: [],
|
roads: [],
|
||||||
ships: [], // Seafarers: edge ids carrying a ship (empty in base)
|
|
||||||
devCards: [], // playable now (bought on a previous turn)
|
devCards: [], // playable now (bought on a previous turn)
|
||||||
newDevCards: [], // bought this turn, not yet playable
|
newDevCards: [], // bought this turn, not yet playable
|
||||||
vpCards: 0, // hidden victory-point dev cards
|
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 seats = shuffle([...Array(n).keys()]);
|
||||||
const order = [...seats, ...[...seats].reverse()];
|
const order = [...seats, ...[...seats].reverse()];
|
||||||
|
|
||||||
const state = {
|
return {
|
||||||
playerCount: n,
|
playerCount: n,
|
||||||
expansion,
|
hexes,
|
||||||
scenario,
|
ports,
|
||||||
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
|
|
||||||
players,
|
players,
|
||||||
bank: { brick: 19, lumber: 19, wool: 19, grain: 19, ore: 19 },
|
bank: { brick: 19, lumber: 19, wool: 19, grain: 19, ore: 19 },
|
||||||
devDeck: shuffle(DEV_DECK),
|
devDeck: shuffle(DEV_DECK),
|
||||||
robberHex: board.robberHex,
|
robberHex: desertHex.id,
|
||||||
pirateHex: board.pirateHex ?? null, // Seafarers: sea-robber position
|
|
||||||
phase: 'setup',
|
phase: 'setup',
|
||||||
setup: { order, idx: 0, placing: 'settlement', lastSettlement: null },
|
setup: { order, idx: 0, placing: 'settlement', lastSettlement: null },
|
||||||
currentPlayer: order[0],
|
currentPlayer: order[0],
|
||||||
|
|
@ -139,20 +110,12 @@ export function createInitialState(playerCount = 3, { tilePlacement = 'random',
|
||||||
diceTotal: null,
|
diceTotal: null,
|
||||||
robberReturnPhase: 'action',
|
robberReturnPhase: 'action',
|
||||||
discardQueue: [],
|
discardQueue: [],
|
||||||
goldPickQueue: [],
|
|
||||||
freeRoads: 0,
|
freeRoads: 0,
|
||||||
freeShips: 0, // Seafarers: free ships (e.g. from a future card)
|
|
||||||
longestRoad: { owner: null, length: 0 },
|
longestRoad: { owner: null, length: 0 },
|
||||||
largestArmy: { owner: null, count: 0 },
|
largestArmy: { owner: null, count: 0 },
|
||||||
winner: null,
|
winner: null,
|
||||||
log: [],
|
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) {
|
function logEvent(state, msg) {
|
||||||
|
|
@ -162,74 +125,42 @@ function logEvent(state, msg) {
|
||||||
|
|
||||||
// ── legality helpers ──────────────────────────────────────────────────────────
|
// ── legality helpers ──────────────────────────────────────────────────────────
|
||||||
export function legalSettlementNodes(state, seat, setup = false) {
|
export function legalSettlementNodes(state, seat, setup = false) {
|
||||||
const geo = geoFor(state);
|
|
||||||
const out = [];
|
const out = [];
|
||||||
for (const node of geo.nodes) {
|
for (const node of NODES) {
|
||||||
if (!nodeOnLand(state, node.id)) continue; // Seafarers: must touch land
|
|
||||||
if (nodeBuilding(state, node.id)) continue;
|
if (nodeBuilding(state, node.id)) continue;
|
||||||
if (node.adj.some((a) => nodeBuilding(state, a))) continue; // distance rule
|
if (node.adj.some((a) => nodeBuilding(state, a))) continue; // distance rule
|
||||||
if (!setup) {
|
if (!setup) {
|
||||||
const touchesRoute = node.adj.some((a) => {
|
const touchesRoad = node.adj.some((a) => {
|
||||||
const eid = edgeBetween(geo, node.id, a);
|
const eid = edgeBetween(node.id, a);
|
||||||
return eid >= 0 && (state.players[seat].roads.includes(eid) || state.players[seat].ships.includes(eid));
|
return eid >= 0 && state.players[seat].roads.includes(eid);
|
||||||
});
|
});
|
||||||
if (!touchesRoute) continue;
|
if (!touchesRoad) continue;
|
||||||
}
|
}
|
||||||
out.push(node.id);
|
out.push(node.id);
|
||||||
}
|
}
|
||||||
return out;
|
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) {
|
export function legalCityNodes(state, seat) {
|
||||||
return [...state.players[seat].settlements];
|
return [...state.players[seat].settlements];
|
||||||
}
|
}
|
||||||
|
|
||||||
function nodeConnectsForRoad(state, seat, nodeId, excludeEdgeId) {
|
function nodeConnectsForRoad(state, seat, nodeId, excludeEdgeId) {
|
||||||
const geo = geoFor(state);
|
|
||||||
const bld = nodeBuilding(state, nodeId);
|
const bld = nodeBuilding(state, nodeId);
|
||||||
if (bld && bld.seat !== seat) return false; // blocked by opponent building
|
if (bld && bld.seat !== seat) return false; // blocked by opponent building
|
||||||
if (bld && bld.seat === seat) return true; // own settlement/city
|
if (bld && bld.seat === seat) return true; // own settlement/city
|
||||||
for (const adj of geo.nodes[nodeId].adj) {
|
for (const adj of NODES[nodeId].adj) {
|
||||||
const eid = edgeBetween(geo, nodeId, adj);
|
const eid = edgeBetween(nodeId, adj);
|
||||||
if (eid === excludeEdgeId || eid < 0) continue;
|
if (eid === excludeEdgeId || eid < 0) continue;
|
||||||
if (state.players[seat].roads.includes(eid)) return true;
|
if (state.players[seat].roads.includes(eid)) return true;
|
||||||
}
|
}
|
||||||
return false;
|
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) {
|
export function legalRoadEdges(state, seat, setup = false, fromNode = null) {
|
||||||
const geo = geoFor(state);
|
|
||||||
const out = [];
|
const out = [];
|
||||||
for (const e of geo.edges) {
|
for (const e of EDGES) {
|
||||||
if (edgeOccupied(state, e.id)) continue;
|
if (edgeOwner(state, e.id) !== null) continue;
|
||||||
if (!edgeIsLand(state, e.id)) continue; // roads only on land edges
|
|
||||||
const [a, b] = e.nodes;
|
const [a, b] = e.nodes;
|
||||||
if (setup) {
|
if (setup) {
|
||||||
if (a === fromNode || b === fromNode) out.push(e.id);
|
if (a === fromNode || b === fromNode) out.push(e.id);
|
||||||
|
|
@ -263,9 +194,8 @@ export function placeSetupSettlement(state, seat, nodeId) {
|
||||||
s.setup.placing = 'road';
|
s.setup.placing = 'road';
|
||||||
// Second round (idx >= playerCount): grant resources from adjacent hexes.
|
// Second round (idx >= playerCount): grant resources from adjacent hexes.
|
||||||
if (s.setup.idx >= s.playerCount) {
|
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];
|
const hex = s.hexes[hx];
|
||||||
if (hex.kind && hex.kind !== 'land') continue; // skip sea/gold/fog/desert
|
|
||||||
if (hex.resource === 'desert') continue;
|
if (hex.resource === 'desert') continue;
|
||||||
if (s.bank[hex.resource] > 0) { s.players[seat].resources[hex.resource]++; s.bank[hex.resource]--; }
|
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';
|
s.phase = s.discardQueue.length ? 'discard' : 'moveRobber';
|
||||||
} else {
|
} else {
|
||||||
produceResources(s, s.diceTotal);
|
produceResources(s, s.diceTotal);
|
||||||
if (s.phase !== 'goldPick') s.phase = 'action';
|
s.phase = 'action';
|
||||||
}
|
}
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
function produceResources(s, total) {
|
function produceResources(s, total) {
|
||||||
const geo = geoFor(s);
|
|
||||||
for (const hex of s.hexes) {
|
for (const hex of s.hexes) {
|
||||||
if (hex.number !== total || hex.hasRobber) continue;
|
if (hex.number !== total || hex.hasRobber) continue;
|
||||||
if (hex.kind && hex.kind !== 'land') continue; // sea/gold/fog handled elsewhere
|
const corners = HEXES[hex.id].corners;
|
||||||
if (!RESOURCE_TYPES.includes(hex.resource)) continue;
|
|
||||||
const corners = geo.hexes[hex.id].corners;
|
|
||||||
for (const nodeId of corners) {
|
for (const nodeId of corners) {
|
||||||
const bld = nodeBuilding(s, nodeId);
|
const bld = nodeBuilding(s, nodeId);
|
||||||
if (!bld) continue;
|
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; }
|
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 ───────────────────────────────────────────────────────────
|
// ── 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.
|
// Seats with a building on the given hex (excluding `seat`) that have cards.
|
||||||
export function stealTargets(state, hexId, seat) {
|
export function stealTargets(state, hexId, seat) {
|
||||||
const targets = new Set();
|
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);
|
const bld = nodeBuilding(state, nodeId);
|
||||||
if (bld && bld.seat !== seat && handSize(state.players[bld.seat]) > 0) targets.add(bld.seat);
|
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 && !canAfford(s.players[seat], COSTS.road)) return s;
|
||||||
if (free) s.freeRoads--; else pay(s, s.players[seat], COSTS.road);
|
if (free) s.freeRoads--; else pay(s, s.players[seat], COSTS.road);
|
||||||
s.players[seat].roads.push(edgeId);
|
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);
|
recomputeLongestRoad(s);
|
||||||
checkWin(s, seat);
|
checkWin(s, seat);
|
||||||
return s;
|
return s;
|
||||||
|
|
@ -674,13 +498,11 @@ export function endTurn(state) {
|
||||||
|
|
||||||
// ── longest road / largest army / victory ──────────────────────────────────────
|
// ── longest road / largest army / victory ──────────────────────────────────────
|
||||||
export function longestRoadFor(state, seat) {
|
export function longestRoadFor(state, seat) {
|
||||||
const geo = geoFor(state);
|
const roads = state.players[seat].roads;
|
||||||
// Seafarers: a trade route is roads + ships combined; base players have no ships.
|
|
||||||
const roads = [...state.players[seat].roads, ...state.players[seat].ships];
|
|
||||||
if (roads.length === 0) return 0;
|
if (roads.length === 0) return 0;
|
||||||
const incident = new Map();
|
const incident = new Map();
|
||||||
for (const eid of roads) {
|
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, []);
|
if (!incident.has(node)) incident.set(node, []);
|
||||||
incident.get(node).push(eid);
|
incident.get(node).push(eid);
|
||||||
}
|
}
|
||||||
|
|
@ -695,7 +517,7 @@ export function longestRoadFor(state, seat) {
|
||||||
if (len > 0 && blocked(node)) return;
|
if (len > 0 && blocked(node)) return;
|
||||||
for (const eid of incident.get(node) || []) {
|
for (const eid of incident.get(node) || []) {
|
||||||
if (used.has(eid)) continue;
|
if (used.has(eid)) continue;
|
||||||
const [a, b] = geo.edges[eid].nodes;
|
const [a, b] = EDGES[eid].nodes;
|
||||||
const next = a === node ? b : a;
|
const next = a === node ? b : a;
|
||||||
used.add(eid);
|
used.add(eid);
|
||||||
dfs(next, used, len + 1);
|
dfs(next, used, len + 1);
|
||||||
|
|
@ -735,17 +557,12 @@ export function recomputeLargestArmy(state) {
|
||||||
state.largestArmy = { owner, count: maxC };
|
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) {
|
export function victoryPoints(state, seat) {
|
||||||
const p = state.players[seat];
|
const p = state.players[seat];
|
||||||
let vp = p.settlements.length + p.cities.length * 2 + p.vpCards;
|
let vp = p.settlements.length + p.cities.length * 2 + p.vpCards;
|
||||||
if (state.longestRoad.owner === seat) vp += 2;
|
if (state.longestRoad.owner === seat) vp += 2;
|
||||||
if (state.largestArmy.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).
|
// 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;
|
let vp = p.settlements.length + p.cities.length * 2;
|
||||||
if (state.longestRoad.owner === seat) vp += 2;
|
if (state.longestRoad.owner === seat) vp += 2;
|
||||||
if (state.largestArmy.owner === seat) vp += 2;
|
if (state.largestArmy.owner === seat) vp += 2;
|
||||||
return vp + bonusVP(state, seat);
|
return vp;
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkWin(state, seat) {
|
function checkWin(state, seat) {
|
||||||
if (victoryPoints(state, seat) >= winVpFor(state)) {
|
if (victoryPoints(state, seat) >= WIN_VP) {
|
||||||
state.winner = seat;
|
state.winner = seat;
|
||||||
state.phase = 'gameOver';
|
state.phase = 'gameOver';
|
||||||
logEvent(state, `${playerName(state, seat)} wins!`);
|
logEvent(state, `${playerName(state, seat)} wins!`);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
@ -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,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
@ -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 1–5.
|
|
||||||
// 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,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
@ -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,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
@ -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 };
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
|
||||||
};
|
|
||||||
|
|
@ -10,9 +10,8 @@ import {
|
||||||
} from './DominionCards.js';
|
} from './DominionCards.js';
|
||||||
import {
|
import {
|
||||||
legalActionIids, allCards, handCoinValue,
|
legalActionIids, allCards, handCoinValue,
|
||||||
affordableSupply, canGain, cardCost,
|
affordableSupply, canGain,
|
||||||
} from './DominionLogic.js';
|
} from './DominionLogic.js';
|
||||||
import { getExpansion } from './expansions/index.js';
|
|
||||||
|
|
||||||
function countOwned(state, seat, id) {
|
function countOwned(state, seat, id) {
|
||||||
return allCards(state, seat).filter((c) => c.id === id).length;
|
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,
|
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) {
|
export function chooseAction(state, seat) {
|
||||||
const legal = legalActionIids(state);
|
const legal = legalActionIids(state);
|
||||||
if (legal.length === 0) return null;
|
if (legal.length === 0) return null;
|
||||||
const p = state.players[seat];
|
const p = state.players[seat];
|
||||||
const byIid = new Map(p.hand.map((c) => [c.iid, c]));
|
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).
|
// Play cantrips / villages first (they replace the action they cost).
|
||||||
const cantrips = legal.filter((iid) => getCard(byIid.get(iid).id).plus.actions >= 1);
|
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];
|
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();
|
const terminals = legal.slice();
|
||||||
terminals.sort((a, b) => {
|
terminals.sort((a, b) => {
|
||||||
const ida = byIid.get(a).id, idb = byIid.get(b).id;
|
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 best = terminals[0];
|
||||||
const bestId = byIid.get(best).id;
|
if (byIid.get(best).id === 'throneroom') {
|
||||||
if (bestId === 'throneroom' || bestId === 'kingscourt') {
|
|
||||||
const hasOther = p.hand.some((c) => c.iid !== best && isType(c.id, 'action'));
|
const hasOther = p.hand.some((c) => c.iid !== best && isType(c.id, 'action'));
|
||||||
if (!hasOther) {
|
if (!hasOther) {
|
||||||
// Skip the multiplier; play the next-best terminal instead, if any.
|
// Skip Throne Room; play the next-best terminal instead, if any.
|
||||||
const alt = terminals.find((iid) => byIid.get(iid).id !== bestId);
|
const alt = terminals.find((iid) => byIid.get(iid).id !== 'throneroom');
|
||||||
return alt ?? best;
|
return alt ?? best;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -86,37 +78,28 @@ export function chooseBuy(state, seat, skill = 3) {
|
||||||
const coins = p.coins;
|
const coins = p.coins;
|
||||||
if (p.buys <= 0) return null;
|
if (p.buys <= 0) return null;
|
||||||
const provincesLeft = state.supply.province ?? 0;
|
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';
|
if (coins >= 8 && provincesLeft > 0) return 'province';
|
||||||
|
|
||||||
// Late-game greening — thresholds widen with skill (better players green sooner).
|
// Late-game greening — thresholds widen with skill (better players green sooner).
|
||||||
const greenAt = 3 + Math.round(skill / 2);
|
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 >= 5 && (state.supply.duchy ?? 0) > 0) return 'duchy';
|
||||||
if (coins >= 2 && provincesLeft <= 2 && (state.supply.estate ?? 0) > 0) return 'estate';
|
if (coins >= 2 && provincesLeft <= 2 && (state.supply.estate ?? 0) > 0) return 'estate';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Engine building (mid coins; never skip Gold/Province). Expansion engine cards
|
// Engine building (mid coins; never skip Gold/Province).
|
||||||
// are considered first, then the base list.
|
if (skill >= 3 && coins <= 5) {
|
||||||
const engineBuys = [...(getExpansion(state.expansion).ai?.engineBuys ?? []), ...ENGINE_BUYS];
|
for (const { id, cap } of 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) {
|
|
||||||
if (!state.kingdom.includes(id)) continue;
|
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 ((state.supply[id] ?? 0) <= 0) continue;
|
||||||
if (countOwned(state, seat, id) >= cap) continue;
|
if (countOwned(state, seat, id) >= cap) continue;
|
||||||
|
// Keep terminals roughly balanced against villages at higher skill.
|
||||||
return id;
|
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 >= 6 && (state.supply.gold ?? 0) > 0) return 'gold';
|
||||||
if (coins >= 3 && (state.supply.silver ?? 0) > 0) return 'silver';
|
if (coins >= 3 && (state.supply.silver ?? 0) > 0) return 'silver';
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -142,15 +125,12 @@ function trashRank(id, copperKeep) {
|
||||||
return 0; // never auto-trash anything else
|
return 0; // never auto-trash anything else
|
||||||
}
|
}
|
||||||
|
|
||||||
function bestGain(state, seat, maxCost, filterTreasure, opts = {}) {
|
function bestGain(state, seat, maxCost, filterTreasure) {
|
||||||
let options = affordableSupply(state, maxCost, filterTreasure, !!opts.exact);
|
const options = affordableSupply(state, maxCost, filterTreasure);
|
||||||
if (opts.exclude && opts.exclude.length) options = options.filter((id) => !opts.exclude.includes(id));
|
|
||||||
if (options.length === 0) return null;
|
if (options.length === 0) return null;
|
||||||
// Gain value ordering.
|
// Gain value ordering.
|
||||||
const rank = (id) => {
|
const rank = (id) => {
|
||||||
if (id === 'colony') return 1100;
|
|
||||||
if (id === 'province') return 1000;
|
if (id === 'province') return 1000;
|
||||||
if (id === 'platinum') return 950;
|
|
||||||
if (id === 'gold') return 900;
|
if (id === 'gold') return 900;
|
||||||
if (id === 'duchy' && (state.supply.province ?? 0) <= 4) return 850;
|
if (id === 'duchy' && (state.supply.province ?? 0) <= 4) return 850;
|
||||||
const def = getCard(id);
|
const def = getCard(id);
|
||||||
|
|
@ -163,16 +143,6 @@ function bestGain(state, seat, maxCost, filterTreasure, opts = {}) {
|
||||||
return options[0];
|
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) {
|
export function resolvePending(state, skill = 3) {
|
||||||
const pend = state.pending;
|
const pend = state.pending;
|
||||||
if (!pend) return {};
|
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);
|
const opts = (pend.options ?? []).slice().sort((a, b) => getCard(a.id).cost - getCard(b.id).cost);
|
||||||
return { iid: opts[0]?.iid ?? null };
|
return { iid: opts[0]?.iid ?? null };
|
||||||
}
|
}
|
||||||
default: {
|
default:
|
||||||
// Expansion-defined pending kind.
|
return {};
|
||||||
const fn = getExpansion(state.expansion).ai?.pending?.[pend.kind];
|
|
||||||
return fn ? fn(state, seat, pend, AI_API) : {};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,15 +11,12 @@
|
||||||
// engine auto-applies and the UI renders as the icon summary. Everything
|
// engine auto-applies and the UI renders as the icon summary. Everything
|
||||||
// beyond vanilla lives in DominionLogic's CARD_EFFECTS registry.
|
// beyond vanilla lives in DominionLogic's CARD_EFFECTS registry.
|
||||||
|
|
||||||
// `sheet` is the Phaser texture key the `frame` indexes into. Base cards live on
|
const def = (id, frame, cost, types, extra = {}) => ({
|
||||||
// the 'dominion-cards' sheet; expansions ship their own (e.g. 'dominion-prosperity').
|
|
||||||
export const def = (id, frame, cost, types, extra = {}) => ({
|
|
||||||
id, name: extra.name ?? titleCase(id), frame, cost, types,
|
id, name: extra.name ?? titleCase(id), frame, cost, types,
|
||||||
plus: { cards: 0, actions: 0, buys: 0, coins: 0, ...(extra.plus ?? {}) },
|
plus: { cards: 0, actions: 0, buys: 0, coins: 0, ...(extra.plus ?? {}) },
|
||||||
coin: extra.coin, // treasure value
|
coin: extra.coin, // treasure value
|
||||||
vp: extra.vp, // fixed victory points (Gardens is dynamic → handled in logic)
|
vp: extra.vp, // fixed victory points (Gardens is dynamic → handled in logic)
|
||||||
text: extra.text ?? '',
|
text: extra.text ?? '',
|
||||||
sheet: extra.sheet ?? 'dominion-cards',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function titleCase(id) {
|
function titleCase(id) {
|
||||||
|
|
@ -120,23 +117,18 @@ export function isType(id, type) {
|
||||||
return getCard(id).types.includes(type);
|
return getCard(id).types.includes(type);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pick 10 distinct Kingdom ids from `pool` using the supplied rng (0..1).
|
// Pick 10 distinct Kingdom ids from the pool using the supplied rng (0..1).
|
||||||
export function chooseRandomKingdom(rand, pool = KINGDOM_POOL) {
|
export function chooseRandomKingdom(rand) {
|
||||||
const p = pool.slice();
|
const pool = KINGDOM_POOL.slice();
|
||||||
for (let i = p.length - 1; i > 0; i--) {
|
for (let i = pool.length - 1; i > 0; i--) {
|
||||||
const j = Math.floor(rand() * (i + 1));
|
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
|
export function kingdomFor(deckMode, rand) {
|
||||||
// expansion supply its own card pool and recommended layouts; both default to
|
if (deckMode === 'random') return chooseRandomKingdom(rand);
|
||||||
// the base set, so base play is unchanged.
|
const preset = KINGDOM_PRESETS[deckMode] ?? FIRST_GAME;
|
||||||
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;
|
|
||||||
return preset.slice().sort((a, b) => getCard(a).cost - getCard(b).cost);
|
return preset.slice().sort((a, b) => getCard(a).cost - getCard(b).cost);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import { getCard, isType } from './DominionCards.js';
|
||||||
import {
|
import {
|
||||||
createInitialState, playAction, endActionPhase, playTreasure, playAllTreasures,
|
createInitialState, playAction, endActionPhase, playTreasure, playAllTreasures,
|
||||||
buyCard, endTurn, resolvePending, isGameOver, finalScores,
|
buyCard, endTurn, resolvePending, isGameOver, finalScores,
|
||||||
legalActionIids, canGain, emptyPileCount, buyCost, buyAllowed,
|
legalActionIids, canGain, emptyPileCount,
|
||||||
} from './DominionLogic.js';
|
} from './DominionLogic.js';
|
||||||
import * as AI from './DominionAI.js';
|
import * as AI from './DominionAI.js';
|
||||||
|
|
||||||
|
|
@ -69,7 +69,6 @@ export default class DominionGame extends Phaser.Scene {
|
||||||
this.opponents = data.opponents ?? [];
|
this.opponents = data.opponents ?? [];
|
||||||
this.cardBack = data.cardBack ?? null;
|
this.cardBack = data.cardBack ?? null;
|
||||||
this.playfield = data.playfield ?? null;
|
this.playfield = data.playfield ?? null;
|
||||||
this.expansion = data.expansion ?? 'base';
|
|
||||||
this.deckMode = data.deckMode ?? 'standard';
|
this.deckMode = data.deckMode ?? 'standard';
|
||||||
this.playerCount = this.opponents.length + 1;
|
this.playerCount = this.opponents.length + 1;
|
||||||
|
|
||||||
|
|
@ -151,7 +150,6 @@ export default class DominionGame extends Phaser.Scene {
|
||||||
seed: (Date.now() ^ (Math.random() * 1e9)) >>> 0,
|
seed: (Date.now() ^ (Math.random() * 1e9)) >>> 0,
|
||||||
playerCount: this.playerCount,
|
playerCount: this.playerCount,
|
||||||
deckMode: this.deckMode,
|
deckMode: this.deckMode,
|
||||||
expansion: this.expansion,
|
|
||||||
});
|
});
|
||||||
// Show an empty hand first, then animate the deal.
|
// Show an empty hand first, then animate the deal.
|
||||||
const p0 = initialState.players[0];
|
const p0 = initialState.players[0];
|
||||||
|
|
@ -463,12 +461,7 @@ export default class DominionGame extends Phaser.Scene {
|
||||||
|
|
||||||
renderSupply() {
|
renderSupply() {
|
||||||
const gs = this.gs;
|
const gs = this.gs;
|
||||||
// Basic supply row; Prosperity inserts Platinum after Gold and Colony after Province.
|
const base = ['copper', 'silver', 'gold', 'estate', 'duchy', 'province', 'curse'];
|
||||||
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');
|
|
||||||
this.layoutPileRow(base, 100);
|
this.layoutPileRow(base, 100);
|
||||||
const k = gs.kingdom;
|
const k = gs.kingdom;
|
||||||
this.layoutPileRow(k.slice(0, 5), 274);
|
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).
|
// Normal buy wiring (human buy phase, no pending).
|
||||||
if (!gs.pending && gs.turn === 0 && gs.phase === 'buy') {
|
if (!gs.pending && gs.turn === 0 && gs.phase === 'buy') {
|
||||||
const p = gs.players[0];
|
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) {
|
if (affordable) {
|
||||||
hit.setInteractive({ useHandCursor: true });
|
hit.setInteractive({ useHandCursor: true });
|
||||||
hit.on('pointerup', () => this.humanBuy(id));
|
hit.on('pointerup', () => this.humanBuy(id));
|
||||||
|
|
@ -797,32 +790,9 @@ export default class DominionGame extends Phaser.Scene {
|
||||||
}).setOrigin(0.5).setDepth(D.hud));
|
}).setOrigin(0.5).setDepth(D.hud));
|
||||||
|
|
||||||
const provLeft = gs.supply.province ?? 0;
|
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} Empty piles: ${emptyPileCount(gs)}/3`, {
|
||||||
this.dynamicLayer.add(this.add.text(CX, 786, `Provinces left: ${provLeft}${colonyNote} Empty piles: ${emptyPileCount(gs)}/3`, {
|
|
||||||
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex,
|
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex,
|
||||||
}).setOrigin(0.5).setDepth(D.hud));
|
}).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() {
|
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 fsTitle = Phaser.Math.Clamp(Math.round(h * 0.085), 9, 20);
|
||||||
const fsType = Phaser.Math.Clamp(Math.round(h * 0.05), 7, 12);
|
const fsType = Phaser.Math.Clamp(Math.round(h * 0.05), 7, 12);
|
||||||
|
|
||||||
const sheetKey = def.sheet ?? 'dominion-cards';
|
if (this.textures.exists('dominion-cards')) {
|
||||||
if (this.textures.exists(sheetKey)) {
|
c.add(this.add.image(0, 0, 'dominion-cards', def.frame).setDisplaySize(w - 6, h - 6));
|
||||||
c.add(this.add.image(0, 0, sheetKey, def.frame).setDisplaySize(w - 6, h - 6));
|
|
||||||
// legibility band over the lower portion
|
// 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));
|
c.add(this.add.rectangle(0, bandTop + (h / 2 - bandTop) / 2, w - 6, h / 2 - bandTop, style.band, 0.9));
|
||||||
} else {
|
} 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 }));
|
return this.promptYesNo('You are under attack! Reveal Moat to block it?', (yes) => this.resolveHuman({ reveal: yes }));
|
||||||
case 'sentry':
|
case 'sentry':
|
||||||
return this.promptSentry(pend);
|
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:
|
default:
|
||||||
return this.resolveHuman({});
|
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.
|
// 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' }) {
|
||||||
promptPickHand(pend, { filter, banner, allowSkip, key = 'iid', choiceExtra = {} }) {
|
|
||||||
this.promptBanner(banner);
|
this.promptBanner(banner);
|
||||||
if (allowSkip) {
|
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) {
|
for (const hs of this.handSprites) {
|
||||||
const ok = filter(hs);
|
const ok = filter(hs);
|
||||||
|
|
@ -1440,28 +1373,17 @@ export default class DominionGame extends Phaser.Scene {
|
||||||
hs.hit.setInteractive({ useHandCursor: true });
|
hs.hit.setInteractive({ useHandCursor: true });
|
||||||
hs.hit.removeAllListeners('pointerup');
|
hs.hit.removeAllListeners('pointerup');
|
||||||
this.highlightFace(hs.face, COLORS.danger);
|
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).
|
// Gain a card from the Supply (highlight eligible piles).
|
||||||
// opts: { exact, cost } for an exact-cost gain (Forge); { maxCost, exclude } overrides.
|
promptGain(pend) {
|
||||||
promptGain(pend, opts = {}) {
|
const treasureNote = pend.filterTreasure ? ' Treasure' : '';
|
||||||
const exact = !!opts.exact;
|
this.promptBanner(`Gain a${treasureNote} card costing up to ${pend.maxCost}.`);
|
||||||
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);
|
|
||||||
let any = false;
|
let any = false;
|
||||||
for (const sp of this.supplySprites) {
|
for (const sp of this.supplySprites) {
|
||||||
if (exclude.includes(sp.id)) continue;
|
if (!canGain(this.gs, sp.id, pend.maxCost, pend.filterTreasure)) continue;
|
||||||
const ok = exact
|
|
||||||
? canGain(this.gs, sp.id, cost, false, true)
|
|
||||||
: canGain(this.gs, sp.id, maxCost, pend.filterTreasure);
|
|
||||||
if (!ok) continue;
|
|
||||||
any = true;
|
any = true;
|
||||||
sp.hit.setInteractive({ useHandCursor: true });
|
sp.hit.setInteractive({ useHandCursor: true });
|
||||||
sp.hit.removeAllListeners('pointerup');
|
sp.hit.removeAllListeners('pointerup');
|
||||||
|
|
@ -1470,42 +1392,7 @@ export default class DominionGame extends Phaser.Scene {
|
||||||
this.promptObjs.push(glow);
|
this.promptObjs.push(glow);
|
||||||
sp.hit.on('pointerup', () => this.resolveHuman({ id: sp.id }));
|
sp.hit.on('pointerup', () => this.resolveHuman({ id: sp.id }));
|
||||||
}
|
}
|
||||||
if (!any) this.resolveHuman(exact ? {} : { id: null });
|
if (!any) this.resolveHuman({ 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' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
promptYesNo(banner, cb) {
|
promptYesNo(banner, cb) {
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@ import {
|
||||||
getCard, CARDS, BASE_TREASURES,
|
getCard, CARDS, BASE_TREASURES,
|
||||||
kingdomFor, isType,
|
kingdomFor, isType,
|
||||||
} from './DominionCards.js';
|
} from './DominionCards.js';
|
||||||
import { getExpansion, registerExpansionCards } from './expansions/index.js';
|
|
||||||
|
|
||||||
// Mulberry32 — seedable PRNG (mirrors the other games).
|
// Mulberry32 — seedable PRNG (mirrors the other games).
|
||||||
function rng(seed) {
|
function rng(seed) {
|
||||||
|
|
@ -44,11 +43,9 @@ const HAND_SIZE = 5;
|
||||||
|
|
||||||
// ── State construction ──────────────────────────────────────────────────────
|
// ── State construction ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function createInitialState({ seed, playerCount = 4, deckMode = 'standard', expansion = 'base' } = {}) {
|
export function createInitialState({ seed, playerCount = 4, deckMode = 'standard' } = {}) {
|
||||||
registerExpansionCards();
|
|
||||||
const rand = seed === undefined ? Math.random : rng(seed);
|
const rand = seed === undefined ? Math.random : rng(seed);
|
||||||
const exp = getExpansion(expansion);
|
const kingdom = kingdomFor(deckMode, rand);
|
||||||
const kingdom = kingdomFor(deckMode, rand, { pool: exp.kingdomPool ?? undefined, presets: exp.presets ?? undefined });
|
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
playerCount,
|
playerCount,
|
||||||
|
|
@ -65,17 +62,11 @@ export function createInitialState({ seed, playerCount = 4, deckMode = 'standard
|
||||||
winnerSeats: [],
|
winnerSeats: [],
|
||||||
seed: seed ?? null,
|
seed: seed ?? null,
|
||||||
deckMode,
|
deckMode,
|
||||||
expansion,
|
|
||||||
_rand: rand,
|
_rand: rand,
|
||||||
_nextIid: 1,
|
_nextIid: 1,
|
||||||
// transient per-effect scratch
|
// transient per-effect scratch
|
||||||
sentryLook: null,
|
sentryLook: null,
|
||||||
libraryAside: null,
|
libraryAside: null,
|
||||||
// expansion scratch (reset each turn / persisted across cloneState)
|
|
||||||
curseIsCopper: false,
|
|
||||||
tiaraTopdeckArmed: 0,
|
|
||||||
tiaraDoubleArmed: false,
|
|
||||||
_warchestNamed: [],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const victoryPile = playerCount <= 2 ? 8 : 12;
|
const victoryPile = playerCount <= 2 ? 8 : 12;
|
||||||
|
|
@ -89,26 +80,17 @@ export function createInitialState({ seed, playerCount = 4, deckMode = 'standard
|
||||||
state.supply.province = victoryPile;
|
state.supply.province = victoryPile;
|
||||||
state.supply.curse = 10 * (playerCount - 1);
|
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).
|
// Kingdom supply (Victory-type Kingdom cards, e.g. Gardens, use the Victory count).
|
||||||
for (const id of kingdom) {
|
for (const id of kingdom) {
|
||||||
state.supply[id] = isType(id, 'victory') ? victoryPile : 10;
|
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.
|
// Players + starting decks (7 Copper, 3 Estate), shuffled, draw 5.
|
||||||
for (let seat = 0; seat < playerCount; seat++) {
|
for (let seat = 0; seat < playerCount; seat++) {
|
||||||
const p = {
|
const p = {
|
||||||
seat,
|
seat,
|
||||||
deck: [], hand: [], discard: [], inPlay: [],
|
deck: [], hand: [], discard: [], inPlay: [],
|
||||||
actions: 0, buys: 0, coins: 0,
|
actions: 0, buys: 0, coins: 0,
|
||||||
vpTokens: 0,
|
|
||||||
merchantSilverBonus: 0,
|
merchantSilverBonus: 0,
|
||||||
firstSilverPlayed: false,
|
firstSilverPlayed: false,
|
||||||
};
|
};
|
||||||
|
|
@ -145,7 +127,6 @@ export function cloneState(state) {
|
||||||
actions: p.actions,
|
actions: p.actions,
|
||||||
buys: p.buys,
|
buys: p.buys,
|
||||||
coins: p.coins,
|
coins: p.coins,
|
||||||
vpTokens: p.vpTokens ?? 0,
|
|
||||||
merchantSilverBonus: p.merchantSilverBonus,
|
merchantSilverBonus: p.merchantSilverBonus,
|
||||||
firstSilverPlayed: p.firstSilverPlayed,
|
firstSilverPlayed: p.firstSilverPlayed,
|
||||||
})),
|
})),
|
||||||
|
|
@ -161,15 +142,10 @@ export function cloneState(state) {
|
||||||
winnerSeats: state.winnerSeats.slice(),
|
winnerSeats: state.winnerSeats.slice(),
|
||||||
seed: state.seed,
|
seed: state.seed,
|
||||||
deckMode: state.deckMode,
|
deckMode: state.deckMode,
|
||||||
expansion: state.expansion,
|
|
||||||
_rand: state._rand,
|
_rand: state._rand,
|
||||||
_nextIid: state._nextIid,
|
_nextIid: state._nextIid,
|
||||||
sentryLook: state.sentryLook ? cloneInsts(state.sentryLook) : null,
|
sentryLook: state.sentryLook ? cloneInsts(state.sentryLook) : null,
|
||||||
libraryAside: state.libraryAside ? cloneInsts(state.libraryAside) : 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;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
@ -206,82 +182,9 @@ function gain(state, seat, id, dest = 'discard') {
|
||||||
else if (dest === 'hand') p.hand.push(inst);
|
else if (dest === 'hand') p.hand.push(inst);
|
||||||
else p.discard.push(inst);
|
else p.discard.push(inst);
|
||||||
state.log.push({ kind: 'gain', seat, id, dest });
|
state.log.push({ kind: 'gain', seat, id, dest });
|
||||||
runGainTriggers(state, seat, inst);
|
|
||||||
return 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) {
|
function trashFrom(state, p, iid) {
|
||||||
const idx = p.hand.findIndex((c) => c.iid === iid);
|
const idx = p.hand.findIndex((c) => c.iid === iid);
|
||||||
if (idx === -1) return null;
|
if (idx === -1) return null;
|
||||||
|
|
@ -311,10 +214,6 @@ function startTurn(state) {
|
||||||
state.phase = 'action';
|
state.phase = 'action';
|
||||||
state.pending = null;
|
state.pending = null;
|
||||||
state.queue = [];
|
state.queue = [];
|
||||||
// Reset per-turn expansion scratch.
|
|
||||||
state.tiaraTopdeckArmed = 0;
|
|
||||||
state.tiaraDoubleArmed = false;
|
|
||||||
state._warchestNamed = [];
|
|
||||||
state.turnsTaken[state.turn] += 1;
|
state.turnsTaken[state.turn] += 1;
|
||||||
state.log.push({ kind: 'turnStart', seat: state.turn });
|
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);
|
if (c.id === 'gardens') vp += Math.floor(cards.length / 10);
|
||||||
else if (def.vp !== undefined) vp += def.vp;
|
else if (def.vp !== undefined) vp += def.vp;
|
||||||
}
|
}
|
||||||
vp += p.vpTokens ?? 0;
|
|
||||||
return { seat: p.seat, vp, cards: cards.length };
|
return { seat: p.seat, vp, cards: cards.length };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -435,8 +333,7 @@ export function playAllTreasures(state) {
|
||||||
const next = cloneState(state);
|
const next = cloneState(state);
|
||||||
const p = next.players[next.turn];
|
const p = next.players[next.turn];
|
||||||
let idx;
|
let idx;
|
||||||
// Stop if a treasure effect (Anvil, Crystal Ball, …) demands a decision.
|
while ((idx = p.hand.findIndex((c) => isType(c.id, 'treasure'))) !== -1) {
|
||||||
while (!next.pending && (idx = p.hand.findIndex((c) => isType(c.id, 'treasure'))) !== -1) {
|
|
||||||
applyTreasure(next, next.turn, idx);
|
applyTreasure(next, next.turn, idx);
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
|
|
@ -446,37 +343,13 @@ function applyTreasure(state, seat, handIdx) {
|
||||||
const p = state.players[seat];
|
const p = state.players[seat];
|
||||||
const [card] = p.hand.splice(handIdx, 1);
|
const [card] = p.hand.splice(handIdx, 1);
|
||||||
p.inPlay.push(card);
|
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);
|
const def = getCard(card.id);
|
||||||
let coin = def.coin ?? 0;
|
p.coins += def.coin ?? 0;
|
||||||
if (card.id === 'silver' && !p.firstSilverPlayed) {
|
if (card.id === 'silver' && !p.firstSilverPlayed) {
|
||||||
coin += p.merchantSilverBonus;
|
p.coins += p.merchantSilverBonus;
|
||||||
p.firstSilverPlayed = true;
|
p.firstSilverPlayed = true;
|
||||||
}
|
}
|
||||||
// Tiara (approximate): the first treasure played each turn while Tiara is in
|
state.log.push({ kind: 'playTreasure', seat, id: card.id });
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buyCard(state, id) {
|
export function buyCard(state, id) {
|
||||||
|
|
@ -486,35 +359,17 @@ export function buyCard(state, id) {
|
||||||
if (!def) return state;
|
if (!def) return state;
|
||||||
if (p.buys <= 0) return state;
|
if (p.buys <= 0) return state;
|
||||||
if ((state.supply[id] ?? 0) <= 0) return state;
|
if ((state.supply[id] ?? 0) <= 0) return state;
|
||||||
const cost = cardCost(state, id, { seat: state.turn, phase: 'buy' });
|
if (p.coins < def.cost) return state;
|
||||||
if (p.coins < cost) return state;
|
|
||||||
const restrict = getExpansion(state.expansion).buyRestrictions?.[id];
|
|
||||||
if (restrict && !restrict(state, state.turn)) return state;
|
|
||||||
|
|
||||||
const next = cloneState(state);
|
const next = cloneState(state);
|
||||||
const np = next.players[next.turn];
|
const np = next.players[next.turn];
|
||||||
np.coins -= cost;
|
np.coins -= def.cost;
|
||||||
np.buys -= 1;
|
np.buys -= 1;
|
||||||
gain(next, next.turn, id, 'discard');
|
gain(next, next.turn, id, 'discard');
|
||||||
next.log.push({ kind: 'buy', seat: next.turn, id });
|
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;
|
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 ─────────────────────────────────────────────────────────────
|
// ── Effect engine ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// Process queued tasks depth-first until the queue drains or a player decision
|
// Process queued tasks depth-first until the queue drains or a player decision
|
||||||
|
|
@ -566,14 +421,10 @@ function execTask(state, task) {
|
||||||
case 'bureaucratAttack':
|
case 'bureaucratAttack':
|
||||||
bureaucratAttack(state, task.seat);
|
bureaucratAttack(state, task.seat);
|
||||||
break;
|
break;
|
||||||
default: {
|
default:
|
||||||
// Expansion-defined task type.
|
|
||||||
const fn = getExpansion(state.expansion).tasks?.[task.type];
|
|
||||||
if (fn) fn(state, task, ENGINE_API);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Apply a card's printed effect for the active player `seat`.
|
// Apply a card's printed effect for the active player `seat`.
|
||||||
function applyEffect(state, seat, id) {
|
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.coins) p.coins += def.plus.coins;
|
||||||
if (def.plus.cards) drawInto(state, p, def.plus.cards);
|
if (def.plus.cards) drawInto(state, p, def.plus.cards);
|
||||||
|
|
||||||
// Card-specific: base table first, then the active expansion's effects.
|
// Card-specific.
|
||||||
const fn = SPECIAL[id] ?? getExpansion(state.expansion).effects?.[id];
|
const fn = SPECIAL[id];
|
||||||
if (fn) fn(state, seat, ENGINE_API);
|
if (fn) fn(state, seat);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Other players' seats in turn order starting after `seat`.
|
// Other players' seats in turn order starting after `seat`.
|
||||||
|
|
@ -844,7 +695,7 @@ export function resolvePending(state, choice) {
|
||||||
const iid = choice?.iid;
|
const iid = choice?.iid;
|
||||||
const c = iid != null ? trashFrom(next, p, iid) : null;
|
const c = iid != null ? trashFrom(next, p, iid) : null;
|
||||||
if (c) {
|
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' };
|
next.pending = { seat, kind: 'gainFromSupply', maxCost, dest: 'discard', source: 'remodel' };
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
@ -856,7 +707,7 @@ export function resolvePending(state, choice) {
|
||||||
const [c] = p.hand.splice(idx, 1);
|
const [c] = p.hand.splice(idx, 1);
|
||||||
next.trash.push(c);
|
next.trash.push(c);
|
||||||
next.log.push({ kind: 'trash', seat, id: c.id });
|
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' };
|
next.pending = { seat, kind: 'gainFromSupply', maxCost, dest: 'hand', filterTreasure: true, source: 'mine' };
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
@ -969,13 +820,9 @@ export function resolvePending(state, choice) {
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default: {
|
default:
|
||||||
// Expansion-defined pending kind.
|
|
||||||
const fn = getExpansion(next.expansion).resolvers?.[pend.kind];
|
|
||||||
if (fn) fn(next, pend, choice, ENGINE_API);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
runQueue(next);
|
runQueue(next);
|
||||||
return next;
|
return next;
|
||||||
|
|
@ -983,24 +830,21 @@ export function resolvePending(state, choice) {
|
||||||
|
|
||||||
// ── Query helpers (used by the AI and UI) ──────────────────────────────────────
|
// ── Query helpers (used by the AI and UI) ──────────────────────────────────────
|
||||||
|
|
||||||
// `exactCost` (Forge) requires cost === maxCost rather than ≤.
|
export function canGain(state, id, maxCost, filterTreasure = false) {
|
||||||
export function canGain(state, id, maxCost, filterTreasure = false, exactCost = false) {
|
|
||||||
const def = CARDS[id];
|
const def = CARDS[id];
|
||||||
if (!def) return false;
|
if (!def) return false;
|
||||||
if ((state.supply[id] ?? 0) <= 0) return false;
|
if ((state.supply[id] ?? 0) <= 0) return false;
|
||||||
const cost = cardCost(state, id, { seat: state.turn });
|
if (def.cost > maxCost) return false;
|
||||||
if (exactCost ? cost !== maxCost : cost > maxCost) return false;
|
|
||||||
if (filterTreasure && !def.types.includes('treasure')) return false;
|
if (filterTreasure && !def.types.includes('treasure')) return false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function affordableSupply(state, maxCost, filterTreasure = false, exactCost = false) {
|
export function affordableSupply(state, maxCost, filterTreasure = false) {
|
||||||
return supplyIds(state).filter((id) => canGain(state, id, maxCost, filterTreasure, exactCost));
|
return supplyIds(state).filter((id) => canGain(state, id, maxCost, filterTreasure));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function supplyIds(state) {
|
export function supplyIds(state) {
|
||||||
const basics = getExpansion(state.expansion).basics ?? [];
|
return [...BASE_TREASURES, 'estate', 'duchy', 'province', 'curse', ...state.kingdom];
|
||||||
return [...BASE_TREASURES, ...basics, 'estate', 'duchy', 'province', 'curse', ...state.kingdom];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function emptyPileCount(state) {
|
export function emptyPileCount(state) {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
|
||||||
};
|
|
||||||
|
|
@ -13,8 +13,6 @@ export default class GameRoomScene extends Phaser.Scene {
|
||||||
this.playfield = data.playfield ?? null;
|
this.playfield = data.playfield ?? null;
|
||||||
this.cardBack = data.cardBack ?? null;
|
this.cardBack = data.cardBack ?? null;
|
||||||
this.tilePlacement = data.tilePlacement ?? 'standard';
|
this.tilePlacement = data.tilePlacement ?? 'standard';
|
||||||
this.expansion = data.expansion ?? 'base';
|
|
||||||
this.scenario = data.scenario ?? null;
|
|
||||||
this.deckMode = data.deckMode ?? 'standard';
|
this.deckMode = data.deckMode ?? 'standard';
|
||||||
this.wordLength = data.wordLength ?? 4;
|
this.wordLength = data.wordLength ?? 4;
|
||||||
}
|
}
|
||||||
|
|
@ -28,8 +26,6 @@ export default class GameRoomScene extends Phaser.Scene {
|
||||||
playfield: this.playfield,
|
playfield: this.playfield,
|
||||||
cardBack: this.cardBack,
|
cardBack: this.cardBack,
|
||||||
tilePlacement: this.tilePlacement,
|
tilePlacement: this.tilePlacement,
|
||||||
expansion: this.expansion,
|
|
||||||
scenario: this.scenario,
|
|
||||||
deckMode: this.deckMode,
|
deckMode: this.deckMode,
|
||||||
wordLength: this.wordLength,
|
wordLength: this.wordLength,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -33,8 +33,6 @@ export default class OpponentSelectScene extends Phaser.Scene {
|
||||||
this.cardBackTiles = [];
|
this.cardBackTiles = [];
|
||||||
this.selectedTilePlacement = 'standard';
|
this.selectedTilePlacement = 'standard';
|
||||||
this.selectedMatchVariant = 4;
|
this.selectedMatchVariant = 4;
|
||||||
this.selectedExpansion = 'base';
|
|
||||||
this.selectedScenario = 'new-shores'; // Catan Seafarers scenario
|
|
||||||
this.selectedDeckMode = 'standard';
|
this.selectedDeckMode = 'standard';
|
||||||
this.selectedWordLength = 4;
|
this.selectedWordLength = 4;
|
||||||
this._initializing = false;
|
this._initializing = false;
|
||||||
|
|
@ -118,7 +116,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
|
||||||
if (!this._startingGame) setMenuMusicVolume(0.6);
|
if (!this._startingGame) setMenuMusicVolume(0.6);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isCatan) this.buildCatanExpansionSection(340, 1013);
|
if (isCatan) this.buildTilePlacementSection(340, 1013);
|
||||||
|
|
||||||
if (isGoFish) this.buildMatchVariantSection(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);
|
this.startBtn.setEnabled(this.selected.size >= min);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Catan: Expansion + (Scenario | Tile placement) selection ───────────────
|
// ── Catan: tile placement toggle ───────────────────────────────────────────
|
||||||
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)
|
|
||||||
buildTilePlacementSection(centerX, centerY) {
|
buildTilePlacementSection(centerX, centerY) {
|
||||||
const options = [
|
const options = [
|
||||||
{ id: 'random', label: 'Random' },
|
{ id: 'random', label: 'Random' },
|
||||||
|
|
@ -650,121 +540,59 @@ export default class OpponentSelectScene extends Phaser.Scene {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Dominion: Expansion + Kingdom selection ────────────────────────────────
|
// ── Dominion: Kingdom deck mode toggle ─────────────────────────────────────
|
||||||
// Kingdom preset options per expansion. Each id must match a preset key in the
|
buildDeckModeSection(centerX, centerY) {
|
||||||
// corresponding expansion module (or the base KINGDOM_PRESETS), plus 'random'.
|
const options = [
|
||||||
static DOMINION_EXPANSIONS = [
|
|
||||||
{ id: 'base', label: 'Base Game' },
|
|
||||||
{ id: 'prosperity', label: 'Prosperity' },
|
|
||||||
];
|
|
||||||
static DOMINION_KINGDOMS = {
|
|
||||||
base: [
|
|
||||||
{ id: 'standard', label: 'Standard' },
|
{ id: 'standard', label: 'Standard' },
|
||||||
{ id: 'size-distortion', label: 'Size Distortion' },
|
{ id: 'size-distortion', label: 'Size Distortion' },
|
||||||
{ id: 'deck-top', label: 'Deck Top' },
|
{ id: 'deck-top', label: 'Deck Top' },
|
||||||
{ id: 'silver-gold', label: 'Silver & Gold' },
|
{ id: 'silver-gold', label: 'Silver & Gold' },
|
||||||
{ id: 'helpful-actions', label: 'Helpful Actions' },
|
{ id: 'helpful-actions', label: 'Helpful Actions' },
|
||||||
{ id: 'random', label: 'Random' },
|
{ id: 'random', label: 'Random' },
|
||||||
],
|
];
|
||||||
prosperity: [
|
const pillW = 150, pillH = 40, pillGap = 12;
|
||||||
{ id: 'beginners', label: 'Beginners' },
|
const cols = 3;
|
||||||
{ 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 rowW = cols * pillW + (cols - 1) * pillGap;
|
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) => {
|
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 x = centerX - rowW / 2 + col * (pillW + pillGap) + pillW / 2;
|
||||||
const y = row === 0 ? this._kingdomRow0Y : this._kingdomRow1Y;
|
const y = row === 0 ? row0Y : row1Y;
|
||||||
const sel = this.selectedDeckMode === opt.id;
|
const isSelected = this.selectedDeckMode === opt.id;
|
||||||
const bg = this.add.rectangle(x, y, pillW, pillH, COLORS.panel)
|
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 });
|
.setInteractive({ useHandCursor: true });
|
||||||
const pillBg = this.add.rectangle(x, y, pillW, pillH, 0x000000, 0.72);
|
const pillBg = this.add.rectangle(x, y, pillW, pillH, 0x000000, 0.72);
|
||||||
this.children.moveBelow(pillBg, bg);
|
this.children.moveBelow(pillBg, bg);
|
||||||
const label = this.add.text(x, y, opt.label, {
|
this.add.text(x, y, opt.label, {
|
||||||
fontFamily: '"Julius Sans One"', fontSize: '15px', color: COLORS.textHex,
|
fontFamily: '"Julius Sans One"',
|
||||||
|
fontSize: '16px',
|
||||||
|
color: COLORS.textHex,
|
||||||
}).setOrigin(0.5);
|
}).setOrigin(0.5);
|
||||||
|
|
||||||
const refresh = () => this._deckModeBtns.forEach(({ bg: b, id }) =>
|
const refresh = () => {
|
||||||
b.setStrokeStyle(3, id === this.selectedDeckMode ? COLORS.accent : COLORS.muted));
|
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('pointerup', () => { this.selectedDeckMode = opt.id; refresh(); });
|
||||||
bg.on('pointerover', () => { if (this.selectedDeckMode !== opt.id) bg.setStrokeStyle(3, COLORS.text); });
|
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); });
|
bg.on('pointerout', () => { if (this.selectedDeckMode !== opt.id) bg.setStrokeStyle(3, COLORS.muted); });
|
||||||
this._deckModeBtns.push({ bg, id: opt.id });
|
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,
|
cardBack: this.selectedCardBack,
|
||||||
tilePlacement: this.selectedTilePlacement,
|
tilePlacement: this.selectedTilePlacement,
|
||||||
matchVariant: this.selectedMatchVariant,
|
matchVariant: this.selectedMatchVariant,
|
||||||
expansion: this.selectedExpansion,
|
|
||||||
scenario: (this.gameDef.slug === 'catan' && this.selectedExpansion !== 'base')
|
|
||||||
? this.selectedScenario : null,
|
|
||||||
deckMode: this.selectedDeckMode,
|
deckMode: this.selectedDeckMode,
|
||||||
wordLength: this.selectedWordLength,
|
wordLength: this.selectedWordLength,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,6 @@ export default class PreloadScene extends Phaser.Scene {
|
||||||
frameHeight: 312,
|
frameHeight: 312,
|
||||||
});
|
});
|
||||||
this.load.image('catan-robber', '/assets/images/catan-robber.png');
|
this.load.image('catan-robber', '/assets/images/catan-robber.png');
|
||||||
this.load.image('catan-pirate', '/assets/images/catan-pirate.png');
|
|
||||||
this.load.image('bg-menu', '/assets/images/background-menu.png');
|
this.load.image('bg-menu', '/assets/images/background-menu.png');
|
||||||
this.load.image('bg-room', '/assets/images/background-room.png');
|
this.load.image('bg-room', '/assets/images/background-room.png');
|
||||||
this.load.image('bg-casino', '/assets/images/background-casino.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
|
// the title/icon band is drawn at runtime). Optional — the scene falls back
|
||||||
// to procedural placeholders when the sheet is absent.
|
// to procedural placeholders when the sheet is absent.
|
||||||
this.load.spritesheet('dominion-cards', '/assets/images/dominioncards.png', { frameWidth: 270, frameHeight: 390 });
|
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() {
|
async create() {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue