feat: add Ticket to Ride (USA edition) game

Implement a fully playable Ticket to Ride game featuring a pure logic engine,
heuristic AI opponent, and Phaser UI scene.

Key additions:
- Board geometry, route definitions, destination tickets, and train card data
- State management, turn flow, payment validation, and scoring (routes, tickets, longest path bonus)
- AI decision-making for claiming routes, drawing cards/tickets, and endgame strategy
- Frontend integration (scene registration, opponent count default, slug dispatch)
- Server game registry configuration (supports 2-5 players)

Follows the existing architecture separating data, logic, AI, and rendering.
This commit is contained in:
Brian Fertig 2026-05-30 08:01:02 -06:00
parent 2958de16ce
commit d9a68de8e4
8 changed files with 1747 additions and 2 deletions

View File

@ -0,0 +1,197 @@
// TicketToRideAI.js — heuristic AI for Ticket to Ride. Pure synchronous
// choose*() functions returning action descriptors; no Phaser, no async.
import {
ROUTES, CITIES, ROUTE_ADJ, ROUTE_SCORE, TICKETS,
} from './TicketToRideBoard.js';
import { legalClaims, canDrawTickets } from './TicketToRideLogic.js';
const TICKET_BY_ID = TICKETS.reduce((m, t) => { m[t.id] = t; return m; }, {});
// ── connectivity helpers ───────────────────────────────────────────────────────
function seatComponents(s, seat) {
const parent = Array.from({ length: CITIES.length }, (_, i) => i);
const find = (x) => { while (parent[x] !== x) { parent[x] = parent[parent[x]]; x = parent[x]; } return x; };
for (const rid of s.players[seat].routes) {
const r = ROUTES[rid];
parent[find(r.a)] = find(r.b);
}
return find;
}
function ticketDone(find, ticket) {
return find(ticket.a) === find(ticket.b);
}
// ── shortest path over available routes ────────────────────────────────────────
// Dijkstra weighted by route length. A route is "available" to `seat` if it is
// unclaimed or already owned by `seat`. Returns { routeIds, cost } or null.
export function shortestRoutePath(s, from, to, seat) {
const dist = new Map([[from, 0]]);
const prev = new Map(); // cityId -> { routeId, fromCity }
const visited = new Set();
while (true) {
let u = null, best = Infinity;
for (const [city, d] of dist) {
if (!visited.has(city) && d < best) { best = d; u = city; }
}
if (u == null) break;
if (u === to) break;
visited.add(u);
for (const e of ROUTE_ADJ.get(u) || []) {
const owner = s.claimed[e.routeId];
if (owner != null && owner !== seat) continue; // opponent owns it
const nd = best + e.length;
if (nd < (dist.get(e.other) ?? Infinity)) {
dist.set(e.other, nd);
prev.set(e.other, { routeId: e.routeId, fromCity: u });
}
}
}
if (!dist.has(to)) return null;
const routeIds = [];
let cur = to;
while (cur !== from) {
const p = prev.get(cur);
if (!p) break;
routeIds.push(p.routeId);
cur = p.fromCity;
}
return { routeIds, cost: dist.get(to) };
}
// ── ticket selection ───────────────────────────────────────────────────────────
// Returns the ticket ids to keep from `drawn` (at least minKeep).
export function chooseTicketsToKeep(s, seat, drawn, minKeep) {
const scored = drawn.map((id) => {
const t = { ...ticketOf(id) };
const path = shortestRoutePath(s, t.a, t.b, seat);
const cost = path ? path.cost : 99;
// Value: reward points, penalise the train cars required to connect.
return { id, value: t.points - cost * 1.1, achievable: !!path, points: t.points };
});
scored.sort((a, b) => b.value - a.value);
const keep = scored.slice(0, minKeep).map((x) => x.id);
// Keep additional tickets only if they look clearly worthwhile.
for (const x of scored.slice(minKeep)) {
if (x.achievable && x.value > 0) keep.push(x.id);
}
return keep;
}
function ticketOf(id) {
// tickets carry resolved {id,a,b,points}; the AI receives ids during setup.
return TICKET_BY_ID[id];
}
// ── per-turn action ─────────────────────────────────────────────────────────────
// Returns one of:
// { type:'claimRoute', routeId, payment }
// { type:'drawTrain', source:'faceUp'|'deck', index? }
// { type:'drawTickets' }
export function chooseAction(s, seat) {
const player = s.players[seat];
const find = seatComponents(s, seat);
const endgame = s.finalTurnsLeft != null;
// 1. Wanted routes: union of shortest paths for unfulfilled kept tickets.
const wanted = new Set();
const wantedColors = new Map(); // color -> desirability weight
for (const t of player.tickets) {
if (ticketDone(find, t)) continue;
const path = shortestRoutePath(s, t.a, t.b, seat);
if (!path) continue;
for (const rid of path.routeIds) {
if (s.claimed[rid] != null) continue; // already owned (by us) — skip
wanted.add(rid);
const col = ROUTES[rid].color;
if (col !== 'gray') wantedColors.set(col, (wantedColors.get(col) || 0) + ROUTES[rid].length);
}
}
const claims = legalClaims(s, seat);
const claimById = new Map(claims.map((c) => [c.routeId, c]));
const scoreClaim = (routeId) => {
const r = ROUTES[routeId];
return ROUTE_SCORE[r.length] + (wanted.has(routeId) ? 8 : 0) + r.length * 0.6;
};
// 2. Claim a wanted route if we can afford one.
const claimableWanted = claims.filter((c) => wanted.has(c.routeId));
if (claimableWanted.length) {
const best = claimableWanted.reduce((a, b) => (scoreClaim(b.routeId) > scoreClaim(a.routeId) ? b : a));
return { type: 'claimRoute', routeId: best.routeId, payment: best.payment };
}
// 3. Endgame: grab points wherever possible, otherwise draw cards.
if (endgame) {
if (claims.length) {
const best = claims.reduce((a, b) => (scoreClaim(b.routeId) > scoreClaim(a.routeId) ? b : a));
return { type: 'claimRoute', routeId: best.routeId, payment: best.payment };
}
return drawDecision(s, seat, wantedColors);
}
// 4. Still have goals but can't afford them yet -> accumulate cards.
if (wanted.size > 0) {
return drawDecision(s, seat, wantedColors);
}
// 5. All tickets fulfilled: chase more tickets early, else bank route points.
if (canDrawTickets(s) && player.trainsLeft > 15) {
return { type: 'drawTickets' };
}
if (claims.length) {
const best = claims.reduce((a, b) => (scoreClaim(b.routeId) > scoreClaim(a.routeId) ? b : a));
return { type: 'claimRoute', routeId: best.routeId, payment: best.payment };
}
return drawDecision(s, seat, wantedColors);
}
// First train-card draw of the turn: prefer a useful face-up card or locomotive.
function drawDecision(s, seat, wantedColors) {
// A face-up locomotive (first draw) is strong — it counts as the whole turn.
const locoIdx = s.faceUp.indexOf('locomotive');
if (locoIdx !== -1) return { type: 'drawTrain', source: 'faceUp', index: locoIdx };
const idx = bestFaceUpColorIndex(s, wantedColors);
if (idx !== -1) return { type: 'drawTrain', source: 'faceUp', index: idx };
return { type: 'drawTrain', source: 'deck' };
}
// Second draw of the turn (cannot take a face-up locomotive here).
export function chooseSecondDraw(s, seat) {
const player = s.players[seat];
const find = seatComponents(s, seat);
const wantedColors = new Map();
for (const t of player.tickets) {
if (ticketDone(find, t)) continue;
const path = shortestRoutePath(s, t.a, t.b, seat);
if (!path) continue;
for (const rid of path.routeIds) {
const col = ROUTES[rid].color;
if (col !== 'gray' && s.claimed[rid] == null) wantedColors.set(col, (wantedColors.get(col) || 0) + 1);
}
}
const idx = bestFaceUpColorIndex(s, wantedColors);
if (idx !== -1) return { type: 'drawTrain', source: 'faceUp', index: idx };
return { type: 'drawTrain', source: 'deck' };
}
// Index of the most useful non-locomotive face-up card, or -1.
function bestFaceUpColorIndex(s, wantedColors) {
let bestIdx = -1, bestW = 0;
s.faceUp.forEach((c, i) => {
if (c === 'locomotive') return;
const w = (wantedColors.get(c) || 0) + 0.1; // any colour has slight value
if (w > bestW) { bestW = w; bestIdx = i; }
});
// Only divert to a face-up card if it is genuinely wanted; otherwise blind-draw
// (keeps more of the deck unseen). Take it when it matches a goal colour.
if (bestIdx !== -1 && bestW > 0.5) return bestIdx;
return -1;
}
export { ticketOf };

View File

@ -0,0 +1,321 @@
// TicketToRideBoard.js — pure data + geometry for Ticket to Ride (USA edition).
// No Phaser imports, no game state. Everything here is computed once at module
// load and shared by Logic, AI, and the scene so they agree on one model.
//
// Coordinate space is the 1920×1080 canvas. The map occupies the left/centre
// (x < ~1500, y < ~850); the right column is reserved for the card market and
// piles, and the bottom strip for the human's hand.
// ── Cities ──────────────────────────────────────────────────────────────────
// 36 cities, positioned to mirror the real US/Canada geography of the board.
export const CITIES = [
{ id: 0, name: 'Vancouver', x: 160, y: 170 },
{ id: 1, name: 'Seattle', x: 180, y: 260 },
{ id: 2, name: 'Portland', x: 150, y: 355 },
{ id: 3, name: 'San Francisco', x: 120, y: 520 },
{ id: 4, name: 'Los Angeles', x: 200, y: 665 },
{ id: 5, name: 'Calgary', x: 340, y: 150 },
{ id: 6, name: 'Winnipeg', x: 640, y: 160 },
{ id: 7, name: 'Helena', x: 450, y: 330 },
{ id: 8, name: 'Duluth', x: 740, y: 300 },
{ id: 9, name: 'Salt Lake City', x: 340, y: 475 },
{ id: 10, name: 'Las Vegas', x: 290, y: 590 },
{ id: 11, name: 'Phoenix', x: 380, y: 675 },
{ id: 12, name: 'Santa Fe', x: 490, y: 585 },
{ id: 13, name: 'Denver', x: 510, y: 485 },
{ id: 14, name: 'El Paso', x: 540, y: 705 },
{ id: 15, name: 'Omaha', x: 740, y: 430 },
{ id: 16, name: 'Kansas City', x: 775, y: 505 },
{ id: 17, name: 'Oklahoma City', x: 730, y: 625 },
{ id: 18, name: 'Dallas', x: 740, y: 720 },
{ id: 19, name: 'Houston', x: 800, y: 800 },
{ id: 20, name: 'Little Rock', x: 845, y: 645 },
{ id: 21, name: 'Chicago', x: 915, y: 400 },
{ id: 22, name: 'Saint Louis', x: 880, y: 545 },
{ id: 23, name: 'Sault Ste Marie', x: 940, y: 235 },
{ id: 24, name: 'Nashville', x: 975, y: 595 },
{ id: 25, name: 'New Orleans', x: 915, y: 765 },
{ id: 26, name: 'Atlanta', x: 1045, y: 645 },
{ id: 27, name: 'Toronto', x: 1090, y: 300 },
{ id: 28, name: 'Montreal', x: 1230, y: 215 },
{ id: 29, name: 'Boston', x: 1360, y: 290 },
{ id: 30, name: 'New York', x: 1300, y: 365 },
{ id: 31, name: 'Pittsburgh', x: 1110, y: 425 },
{ id: 32, name: 'Washington', x: 1280, y: 455 },
{ id: 33, name: 'Raleigh', x: 1170, y: 560 },
{ id: 34, name: 'Charleston', x: 1230, y: 660 },
{ id: 35, name: 'Miami', x: 1235, y: 825 },
];
export function cityAt(id) { return CITIES[id]; }
export function cityId(name) { return CITIES.find((c) => c.name === name)?.id ?? -1; }
// ── Train-card colours ────────────────────────────────────────────────────────
// The 8 train colours (TTR's "pink" is rendered here as purple), plus 'gray'
// for wild routes (claimable with any single colour) and 'locomotive' wilds.
export const TRAIN_COLORS = ['red', 'orange', 'yellow', 'green', 'blue', 'purple', 'black', 'white'];
export const CARD_COLOR_HEX = {
red: 0xd23b3b,
orange: 0xe08a1e,
yellow: 0xe0b000,
green: 0x2e7d32,
blue: 0x2d6cdf,
purple: 0x8e44ad,
black: 0x2b2b2b,
white: 0xe8e4d8,
gray: 0x9a8f7d, // neutral route colour
locomotive: 0xdda0dd, // wild — rendered with a rainbow accent in the scene
};
export const CARD_LABEL = {
red: 'Red', orange: 'Orange', yellow: 'Yellow', green: 'Green',
blue: 'Blue', purple: 'Purple', black: 'Black', white: 'White',
locomotive: 'Locomotive',
};
// 110-card train deck: 12 of each of the 8 colours + 14 locomotive wilds.
export const TRAIN_DECK = [
...TRAIN_COLORS.flatMap((c) => Array(12).fill(c)),
...Array(14).fill('locomotive'),
];
// ── Player colours ─────────────────────────────────────────────────────────────
export const PLAYER_COLORS = [
{ key: 'blue', hex: 0x2d6cdf, hexDark: 0x1c4490, name: 'Blue' },
{ key: 'red', hex: 0xd23b3b, hexDark: 0x8f2424, name: 'Red' },
{ key: 'green', hex: 0x2e9e4f, hexDark: 0x1d6633, name: 'Green' },
{ key: 'yellow', hex: 0xe0b000, hexDark: 0x9c7a00, name: 'Yellow' },
{ key: 'black', hex: 0x3a3a3a, hexDark: 0x161616, name: 'Black' },
];
// ── Scoring + counts ────────────────────────────────────────────────────────────
export const ROUTE_SCORE = { 1: 1, 2: 2, 3: 4, 4: 7, 5: 10, 6: 15 };
export const TRAINS_PER_PLAYER = 45;
export const LONGEST_PATH_BONUS = 10;
export const FACE_UP_COUNT = 5;
export const ENDGAME_TRAIN_THRESHOLD = 2; // a turn ending with <= this triggers the last round
// ── Routes ───────────────────────────────────────────────────────────────────
// Authored compactly as [cityA, cityB, length, colour]. A double route between
// the same two cities uses [cityA, cityB, length, [colour1, colour2]] and is
// expanded into two parallel ROUTE entries sharing a doubleGroup id.
// 'pink' in the physical game maps to 'purple' here.
const ROUTE_DEFS = [
[0, 5, 3, 'gray'], // Vancouver Calgary
[0, 1, 1, ['gray', 'gray']], // Vancouver Seattle
[1, 5, 4, 'gray'], // Seattle Calgary
[1, 2, 1, ['gray', 'gray']], // Seattle Portland
[2, 3, 5, ['green', 'purple']], // Portland San Francisco
[2, 9, 6, 'blue'], // Portland Salt Lake City
[5, 6, 6, 'white'], // Calgary Winnipeg
[5, 7, 4, 'gray'], // Calgary Helena
[1, 7, 6, 'yellow'], // Seattle Helena
[3, 9, 5, ['orange', 'white']], // San Francisco Salt Lake City
[3, 4, 3, ['yellow', 'purple']], // San Francisco Los Angeles
[9, 7, 3, 'purple'], // Salt Lake City Helena
[9, 10, 3, 'orange'], // Salt Lake City Las Vegas
[9, 13, 3, ['red', 'yellow']], // Salt Lake City Denver
[4, 10, 2, 'gray'], // Los Angeles Las Vegas
[4, 11, 3, 'gray'], // Los Angeles Phoenix
[4, 14, 6, 'black'], // Los Angeles El Paso
[7, 6, 4, 'blue'], // Helena Winnipeg
[7, 8, 6, 'orange'], // Helena Duluth
[7, 13, 4, 'green'], // Helena Denver
[11, 13, 5, 'white'], // Phoenix Denver
[11, 12, 3, 'gray'], // Phoenix Santa Fe
[11, 14, 3, 'gray'], // Phoenix El Paso
[13, 12, 2, 'gray'], // Denver Santa Fe
[12, 14, 2, 'gray'], // Santa Fe El Paso
[13, 15, 4, 'purple'], // Denver Omaha
[13, 16, 4, ['black', 'orange']], // Denver Kansas City
[13, 17, 4, 'red'], // Denver Oklahoma City
[12, 17, 3, 'blue'], // Santa Fe Oklahoma City
[14, 17, 5, 'yellow'], // El Paso Oklahoma City
[14, 18, 4, 'red'], // El Paso Dallas
[14, 19, 6, 'green'], // El Paso Houston
[6, 23, 6, 'gray'], // Winnipeg Sault Ste Marie
[6, 8, 4, 'black'], // Winnipeg Duluth
[8, 23, 3, 'gray'], // Duluth Sault Ste Marie
[8, 15, 2, ['gray', 'gray']], // Duluth Omaha
[8, 21, 3, 'red'], // Duluth Chicago
[8, 27, 6, 'purple'], // Duluth Toronto
[15, 16, 1, ['gray', 'gray']], // Omaha Kansas City
[15, 21, 4, 'blue'], // Omaha Chicago
[16, 17, 2, ['gray', 'gray']], // Kansas City Oklahoma City
[16, 22, 2, ['blue', 'purple']], // Kansas City Saint Louis
[17, 20, 2, 'gray'], // Oklahoma City Little Rock
[17, 18, 2, ['gray', 'gray']], // Oklahoma City Dallas
[18, 19, 1, ['gray', 'gray']], // Dallas Houston
[19, 25, 2, 'gray'], // Houston New Orleans
[20, 22, 2, 'gray'], // Little Rock Saint Louis
[20, 24, 3, 'white'], // Little Rock Nashville
[20, 25, 3, 'green'], // Little Rock New Orleans
[22, 21, 2, ['green', 'white']], // Saint Louis Chicago
[22, 31, 5, 'green'], // Saint Louis Pittsburgh
[22, 24, 2, 'gray'], // Saint Louis Nashville
[21, 31, 3, ['orange', 'black']], // Chicago Pittsburgh
[21, 27, 4, 'white'], // Chicago Toronto
[23, 27, 2, 'gray'], // Sault Ste Marie Toronto
[23, 28, 5, 'black'], // Sault Ste Marie Montreal
[27, 28, 3, 'gray'], // Toronto Montreal
[27, 31, 2, 'gray'], // Toronto Pittsburgh
[28, 29, 2, ['gray', 'gray']], // Montreal Boston
[28, 30, 3, 'blue'], // Montreal New York
[29, 30, 2, ['gray', 'gray']], // Boston New York
[30, 31, 2, ['white', 'green']], // New York Pittsburgh
[30, 32, 2, ['orange', 'black']], // New York Washington
[31, 32, 2, 'gray'], // Pittsburgh Washington
[31, 33, 2, 'gray'], // Pittsburgh Raleigh
[31, 24, 4, 'yellow'], // Pittsburgh Nashville
[24, 33, 3, 'black'], // Nashville Raleigh
[24, 26, 1, 'gray'], // Nashville Atlanta
[32, 33, 2, ['gray', 'gray']], // Washington Raleigh
[33, 26, 2, ['gray', 'gray']], // Raleigh Atlanta
[33, 34, 2, 'gray'], // Raleigh Charleston
[26, 34, 2, 'gray'], // Atlanta Charleston
[26, 35, 5, 'blue'], // Atlanta Miami
[26, 25, 4, ['yellow', 'orange']], // Atlanta New Orleans
[34, 35, 4, 'purple'], // Charleston Miami
[25, 35, 6, 'red'], // New Orleans Miami
];
// Expand ROUTE_DEFS into the flat ROUTES array, generating ids, double-route
// grouping, and parallelSide so the two strips of a double render side-by-side.
export const ROUTES = (() => {
const out = [];
for (const [a, b, length, colour] of ROUTE_DEFS) {
if (Array.isArray(colour)) {
const group = `${a}-${b}`;
colour.forEach((c, i) => {
out.push({ id: out.length, a, b, length, color: c, doubleGroup: group, parallelSide: i });
});
} else {
out.push({ id: out.length, a, b, length, color: colour, doubleGroup: null, parallelSide: 0 });
}
}
return out;
})();
// ── Destination tickets ──────────────────────────────────────────────────────
// The 30 USA destination tickets [cityNameA, cityNameB, points].
const TICKET_DEFS = [
['Los Angeles', 'New York', 21],
['Duluth', 'Houston', 8],
['Sault Ste Marie', 'Nashville', 8],
['New York', 'Atlanta', 6],
['Portland', 'Nashville', 17],
['Vancouver', 'Montreal', 20],
['Duluth', 'El Paso', 10],
['Toronto', 'Miami', 10],
['Portland', 'Phoenix', 11],
['Dallas', 'New York', 11],
['Calgary', 'Salt Lake City', 7],
['Calgary', 'Phoenix', 13],
['Los Angeles', 'Miami', 20],
['Winnipeg', 'Little Rock', 11],
['San Francisco', 'Atlanta', 17],
['Kansas City', 'Houston', 5],
['Los Angeles', 'Chicago', 16],
['Denver', 'Pittsburgh', 11],
['Chicago', 'Santa Fe', 9],
['Vancouver', 'Santa Fe', 13],
['Boston', 'Miami', 12],
['Chicago', 'New Orleans', 7],
['Montreal', 'Atlanta', 9],
['Seattle', 'New York', 22],
['Denver', 'El Paso', 4],
['Helena', 'Los Angeles', 8],
['Winnipeg', 'Houston', 12],
['Montreal', 'New Orleans', 13],
['Sault Ste Marie', 'Oklahoma City', 9],
['Seattle', 'Los Angeles', 9],
];
export const TICKETS = TICKET_DEFS.map(([nameA, nameB, points], id) => ({
id, a: cityId(nameA), b: cityId(nameB), points,
}));
// ── Adjacency index (for pathfinding / connectivity) ───────────────────────────
// cityId -> [{ routeId, other, length, color, doubleGroup }]
function buildAdj(cities, routes) {
const adj = new Map();
for (const c of cities) adj.set(c.id, []);
for (const r of routes) {
adj.get(r.a).push({ routeId: r.id, other: r.b, length: r.length, color: r.color, doubleGroup: r.doubleGroup });
adj.get(r.b).push({ routeId: r.id, other: r.a, length: r.length, color: r.color, doubleGroup: r.doubleGroup });
}
return adj;
}
export const ROUTE_ADJ = buildAdj(CITIES, ROUTES);
// ── Route segment geometry ──────────────────────────────────────────────────────
const CITY_MARGIN = 30; // keep car slots clear of the city dots
const CAR_GAP = 6; // pixel gap between adjacent cars
const CAR_WIDTH = 16; // perpendicular thickness of a car
const DOUBLE_OFFSET = 12; // perpendicular shift for each strip of a double route
// Returns one slot per train-length: { cx, cy, angle, w, h } rotated to the A→B
// line. parallelSide shifts the whole strip perpendicular so double routes sit
// side-by-side.
export function routeSegments(route) {
const A = CITIES[route.a];
const B = CITIES[route.b];
const dx = B.x - A.x;
const dy = B.y - A.y;
const len = Math.hypot(dx, dy) || 1;
const ux = dx / len, uy = dy / len; // unit vector along the line
const px = -uy, py = ux; // unit perpendicular
const off = route.doubleGroup ? (route.parallelSide === 0 ? -DOUBLE_OFFSET : DOUBLE_OFFSET) : 0;
const span = len - CITY_MARGIN * 2;
const n = route.length;
const carLen = (span - CAR_GAP * (n - 1)) / n;
const angle = Math.atan2(dy, dx);
const segs = [];
for (let i = 0; i < n; i++) {
const t = CITY_MARGIN + carLen / 2 + i * (carLen + CAR_GAP);
segs.push({
cx: A.x + ux * t + px * off,
cy: A.y + uy * t + py * off,
angle,
w: carLen,
h: CAR_WIDTH,
});
}
return segs;
}
// Midpoint of a route's strip (used for the rotated hit-area rectangle).
export function routeMidpoint(route) {
const A = CITIES[route.a];
const B = CITIES[route.b];
const dx = B.x - A.x, dy = B.y - A.y;
const len = Math.hypot(dx, dy) || 1;
const px = -dy / len, py = dx / len;
const off = route.doubleGroup ? (route.parallelSide === 0 ? -DOUBLE_OFFSET : DOUBLE_OFFSET) : 0;
return {
x: (A.x + B.x) / 2 + px * off,
y: (A.y + B.y) / 2 + py * off,
angle: Math.atan2(dy, dx),
length: len - CITY_MARGIN * 2,
width: CAR_WIDTH + 8,
};
}
// ── Land silhouette (decorative backdrop) ──────────────────────────────────────
// A simplified US lower-48 + southern Canada outline, hand-traced clockwise in
// canvas space. Drawn as a filled polygon behind the routes and cities.
export const LAND_OUTLINE = [
[110, 210], [150, 140], [340, 120], [640, 128], [770, 150],
[905, 178], [1060, 178], [1190, 168], [1285, 205],
[1390, 270], [1365, 335], [1325, 385], [1300, 430], [1290, 480],
[1255, 545], [1235, 625], [1255, 705], [1265, 815], [1230, 855],
[1180, 840], [1050, 822], [920, 812], [820, 832], [760, 802],
[640, 762], [560, 732], [520, 712], [420, 690], [300, 642],
[220, 682], [160, 562], [120, 522], [110, 402], [132, 342], [118, 262],
];
// Subtle Great Lakes hint between Duluth, Sault Ste Marie, Toronto and Chicago.
export const GREAT_LAKES = [
[800, 300], [905, 250], [1000, 270], [1010, 340], [930, 380], [840, 360],
];

View File

@ -0,0 +1,779 @@
import * as Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
import { Button } from '../../ui/Button.js';
import { auth } from '../../services/auth.js';
import { api } from '../../services/api.js';
import { createOpponentPortrait, createPlayerPortrait } from '../../ui/Portrait.js';
import { playSound, SFX } from '../../ui/Sounds.js';
import { MusicPlayer } from '../../ui/MusicPlayer.js';
import {
CITIES, ROUTES, TICKETS, TRAIN_COLORS, CARD_COLOR_HEX, CARD_LABEL,
PLAYER_COLORS, ROUTE_SCORE, LONGEST_PATH_BONUS,
routeSegments, routeMidpoint, LAND_OUTLINE, GREAT_LAKES,
} from './TicketToRideBoard.js';
import * as L from './TicketToRideLogic.js';
import * as AI from './TicketToRideAI.js';
const D = {
sea: 0, land: 1, lakes: 2, route: 6, train: 10, city: 14, label: 15,
hover: 18, zone: 19, hud: 30, market: 34, panel: 60, modal: 70, banner: 80,
};
const HAND_ORDER = [...TRAIN_COLORS, 'locomotive'];
// Right-band + bottom-strip layout (1920×1080). The map occupies x<1400.
const RB = { x0: 1408, cx: 1660, w: 512 };
const OPP_X = 1452, OPP_Y0 = 70, OPP_STEP = 96, OPP_R = 32;
const MK_X = 1470, MK_Y0 = 392, MK_STEP = 86, CARD_W = 78, CARD_H = 74;
const PILE_X = 1640;
const BOT_Y = 980;
export default class TicketToRideGame extends Phaser.Scene {
constructor() { super('TicketToRideGame'); }
init(data) {
this.gameDef = data.game;
this.opponents = data.opponents ?? [];
this.playfield = data.playfield ?? null;
this.gs = null;
this.busy = false;
this.opponentPortraits = [];
this.modalObjs = [];
this.marketObjs = [];
this.handObjs = [];
this.hoverRouteId = null;
this.bannerShownFor = -1;
}
create() {
try { new MusicPlayer(this, this.cache.json.get('music')?.tracks ?? []); } catch (_) { /* no music json */ }
this.buildParticleTexture();
this.buildBackdrop();
this.buildStaticBoard();
this.buildRightPanel();
this.buildHUD();
this.startNewMatch();
}
buildParticleTexture() {
const g = this.make.graphics({ x: 0, y: 0, add: false });
g.fillStyle(0xffffff, 1); g.fillCircle(5, 5, 5);
g.generateTexture('ttrParticle', 10, 10);
g.destroy();
}
// ── backdrop: sea + land silhouette ─────────────────────────────────────────
buildBackdrop() {
if (this.playfield?.key && this.textures.exists(this.playfield.key)) {
this.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, this.playfield.key)
.setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(D.sea - 1);
}
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x10384a).setDepth(D.sea);
const land = this.add.graphics().setDepth(D.land);
const pts = LAND_OUTLINE.map(([x, y]) => ({ x, y }));
land.fillStyle(0xcdb585, 1);
land.fillPoints(pts, true);
land.lineStyle(5, 0x8a6f3e, 0.9);
land.strokePoints(pts, true);
// soft inland shading
land.fillStyle(0xd8c498, 0.4);
land.fillPoints(pts.map((p) => ({ x: p.x, y: p.y - 6 })), true);
const lakes = this.add.graphics().setDepth(D.lakes);
lakes.fillStyle(0x2f6f8a, 0.85);
lakes.fillPoints(GREAT_LAKES.map(([x, y]) => ({ x, y })), true);
}
// ── cities + route segments ─────────────────────────────────────────────────
buildStaticBoard() {
// Precompute segment + midpoint geometry once.
this.segCache = ROUTES.map((r) => routeSegments(r));
this.midCache = ROUTES.map((r) => routeMidpoint(r));
this.routeGfx = this.add.graphics().setDepth(D.route);
this.hoverGfx = this.add.graphics().setDepth(D.hover);
// Invisible rotated hit-zone per route.
this.routeZones = ROUTES.map((r, id) => {
const m = this.midCache[id];
const zone = this.add.zone(m.x, m.y, Math.max(m.length, 24), m.width + 12)
.setRotation(m.angle)
.setInteractive({ useHandCursor: true })
.setDepth(D.zone);
zone.on('pointerover', () => this.onRouteHover(id));
zone.on('pointerout', () => this.onRouteHover(null));
zone.on('pointerdown', () => this.onRouteClick(id));
return zone;
});
// Cities (static): dot + label.
for (const c of CITIES) {
const g = this.add.graphics().setDepth(D.city);
g.fillStyle(0x2a2118, 1); g.fillCircle(c.x, c.y, 9);
g.fillStyle(0xfdf3d8, 1); g.fillCircle(c.x, c.y, 5.5);
g.lineStyle(2, 0x2a2118, 1); g.strokeCircle(c.x, c.y, 9);
this.add.text(c.x, c.y - 16, c.name, {
fontFamily: '"Julius Sans One"', fontSize: '14px', color: '#241a0c',
backgroundColor: 'rgba(253,243,216,0.7)', padding: { x: 4, y: 1 },
}).setOrigin(0.5, 1).setDepth(D.label);
}
}
// ── right band: market, piles, ticket deck, opponent panels ───────────────────
buildRightPanel() {
this.add.rectangle(RB.cx, GAME_HEIGHT / 2, RB.w, GAME_HEIGHT, COLORS.panel, 0.9).setDepth(D.hud - 2);
this.add.rectangle(RB.x0, GAME_HEIGHT / 2, 4, GAME_HEIGHT, COLORS.accent, 0.6).setDepth(D.hud - 2);
// Opponent panels (seats 1..n-1) — built in buildOpponentPanels once names exist.
this.oppPanelText = [];
// Market label.
this.add.text(RB.x0 + 24, MK_Y0 - 56, 'Train Card Market', {
fontFamily: 'Righteous', fontSize: '20px', color: COLORS.accentHex,
}).setOrigin(0, 0.5).setDepth(D.hud);
// Draw pile (face-down) — clickable.
this.deckCard = this.add.graphics().setDepth(D.hud);
this.drawPileFace(this.deckCard, PILE_X, MK_Y0, 'Deck');
this.deckCount = this.add.text(PILE_X, MK_Y0 + 4, '', {
fontFamily: 'Righteous', fontSize: '24px', color: '#fdf3d8',
}).setOrigin(0.5).setDepth(D.hud + 1);
this.add.text(PILE_X, MK_Y0 - CARD_H / 2 - 14, 'Draw', {
fontFamily: '"Julius Sans One"', fontSize: '15px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(D.hud);
this.makeZone(PILE_X, MK_Y0, CARD_W, CARD_H, () => this.onDeckClick());
// Discard pile (display only).
const discY = MK_Y0 + MK_STEP * 1.5;
this.discardCard = this.add.graphics().setDepth(D.hud);
this.drawPileFace(this.discardCard, PILE_X, discY, 'Discard', 0x3a2f22);
this.discardCount = this.add.text(PILE_X, discY + 4, '', {
fontFamily: 'Righteous', fontSize: '20px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(D.hud + 1);
this.add.text(PILE_X, discY - CARD_H / 2 - 14, 'Discard', {
fontFamily: '"Julius Sans One"', fontSize: '15px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(D.hud);
// Ticket deck — clickable.
const tY = MK_Y0 + MK_STEP * 3;
this.ticketCard = this.add.graphics().setDepth(D.hud);
this.ticketCard.fillStyle(0x4a7c3a, 1);
this.ticketCard.fillRoundedRect(PILE_X - CARD_W / 2, tY - CARD_H / 2, CARD_W, CARD_H, 8);
this.ticketCard.lineStyle(3, 0xfdf3d8, 0.9);
this.ticketCard.strokeRoundedRect(PILE_X - CARD_W / 2, tY - CARD_H / 2, CARD_W, CARD_H, 8);
this.add.text(PILE_X, tY - 14, '🎫', { fontSize: '26px' }).setOrigin(0.5).setDepth(D.hud + 1);
this.ticketCount = this.add.text(PILE_X, tY + 16, '', {
fontFamily: 'Righteous', fontSize: '16px', color: '#fdf3d8',
}).setOrigin(0.5).setDepth(D.hud + 1);
this.add.text(PILE_X, tY - CARD_H / 2 - 14, 'Destination Tickets', {
fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(D.hud);
this.makeZone(PILE_X, tY, CARD_W, CARD_H, () => this.onTicketDeckClick());
}
drawPileFace(g, x, y, _label, fill = 0x243b52) {
g.fillStyle(fill, 1);
g.fillRoundedRect(x - CARD_W / 2, y - CARD_H / 2, CARD_W, CARD_H, 8);
g.lineStyle(3, 0xfdf3d8, 0.85);
g.strokeRoundedRect(x - CARD_W / 2, y - CARD_H / 2, CARD_W, CARD_H, 8);
}
makeZone(x, y, w, h, fn) {
const z = this.add.zone(x, y, w, h).setInteractive({ useHandCursor: true }).setDepth(D.hud + 4);
z.on('pointerdown', fn);
return z;
}
buildOpponentPanels() {
this.opponentPortraits.forEach((p) => p.destroy?.());
this.opponentPortraits = [];
this.oppPanelText.forEach((t) => t.destroy());
this.oppPanelText = [];
this.oppRingGfx?.destroy();
this.oppRingGfx = this.add.graphics().setDepth(D.hud + 3);
for (let seat = 1; seat < this.gs.playerCount; seat++) {
const y = OPP_Y0 + (seat - 1) * OPP_STEP;
const opp = this.opponents[seat - 1];
const portrait = createOpponentPortrait(this, opp, OPP_X, y, OPP_R, D.hud, { playIntro: seat === 1 });
this.opponentPortraits.push(portrait);
const col = PLAYER_COLORS[this.gs.players[seat].colorIndex];
this.add.circle(OPP_X, y, OPP_R + 4, col.hex, 0).setStrokeStyle(3, col.hex, 0.95).setDepth(D.hud + 2);
this.add.text(OPP_X + OPP_R + 16, y - 20, this.pname(seat), {
fontFamily: 'Righteous', fontSize: '17px', color: COLORS.textHex,
}).setOrigin(0, 0.5).setDepth(D.hud + 1);
const info = this.add.text(OPP_X + OPP_R + 16, y + 6, '', {
fontFamily: '"Julius Sans One"', fontSize: '13px', color: COLORS.mutedHex, lineSpacing: 2,
}).setOrigin(0, 0.5).setDepth(D.hud + 1);
this.oppPanelText[seat] = info;
}
}
// ── bottom HUD: human portrait, hand, status, leave ───────────────────────────
buildHUD() {
this.add.rectangle(GAME_WIDTH / 2, BOT_Y + 30, GAME_WIDTH, 200, COLORS.panel, 0.92).setDepth(D.hud - 1);
this.add.rectangle(GAME_WIDTH / 2, BOT_Y - 70, GAME_WIDTH, 4, COLORS.accent, 0.6).setDepth(D.hud - 1);
createPlayerPortrait(this, 80, BOT_Y, 46, D.hud, 'TicketToRideGame');
this.add.text(80, BOT_Y + 60, auth.user?.username ?? 'You', {
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(D.hud);
this.playerScoreText = this.add.text(80, BOT_Y - 64, '0', {
fontFamily: 'Righteous', fontSize: '22px', color: COLORS.goldHex,
}).setOrigin(0.5).setDepth(D.hud + 1);
this.add.text(80, BOT_Y - 90, 'SCORE', {
fontFamily: '"Julius Sans One"', fontSize: '11px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(D.hud + 1);
this.add.text(180, BOT_Y - 70, 'Your Trains', {
fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.mutedHex,
}).setOrigin(0, 0.5).setDepth(D.hud);
this.trainsText = this.add.text(300, BOT_Y - 70, '', {
fontFamily: 'Righteous', fontSize: '16px', color: COLORS.textHex,
}).setOrigin(0, 0.5).setDepth(D.hud);
this.ticketsBtn = new Button(this, 1760, BOT_Y, 'My Tickets', () => this.showMyTickets(),
{ width: 200, height: 50, fontSize: 20 }).setDepth(D.hud);
this.statusText = this.add.text(GAME_WIDTH / 2, 38, '', {
fontFamily: 'Righteous', fontSize: '26px', color: COLORS.textHex,
backgroundColor: '#111923cc', padding: { x: 18, y: 8 },
}).setOrigin(0.5).setDepth(D.banner);
this.logText = this.add.text(GAME_WIDTH / 2, 1068, '', {
fontFamily: '"Julius Sans One"', fontSize: '15px', color: COLORS.mutedHex,
}).setOrigin(0.5, 1).setDepth(D.hud);
new Button(this, 80, 40, 'Leave', () => this.scene.start('GameMenu'),
{ variant: 'ghost', width: 120, height: 40, fontSize: 17 }).setDepth(D.banner);
}
// ── helpers ───────────────────────────────────────────────────────────────────
playerColor(seat) { return PLAYER_COLORS[this.gs.players[seat].colorIndex]; }
pname(seat) { return L.playerName(this.gs, seat); }
textColorFor(colorKey) {
return (colorKey === 'white' || colorKey === 'yellow') ? '#241a0c' : '#fdf3d8';
}
// ── new match / turn driver ───────────────────────────────────────────────────
startNewMatch() {
this.busy = false;
this.closeModal();
const playerCount = Math.min(5, Math.max(2, 1 + this.opponents.length));
this.gs = L.createInitialState(playerCount);
const names = ['You', ...this.opponents.map((o) => o?.name ?? 'CPU')];
L.setPlayerNames(this.gs, names);
this.buildOpponentPanels();
this.renderAll();
this.time.delayedCall(600, () => this.advance());
}
async advance() {
const s = this.gs;
this.renderAll();
if (!s) return;
if (s.phase === 'gameOver') { this.onGameOver(); return; }
if (s.pendingTickets) {
if (s.pendingTickets.seat === 0) this.promptTicketKeep();
else await this.aiResolveTickets();
return;
}
if (s.currentPlayer === 0) {
// Human turn — controls are always-on zones guarded by canHumanAct().
return;
}
await this.aiTurn();
}
canHumanAct() {
const s = this.gs;
return !this.busy && s && (s.phase === 'turn' || s.phase === 'lastRound')
&& s.currentPlayer === 0 && !s.pendingTickets && s.winner == null;
}
applyHuman(newGs, sfx) {
this.gs = newGs;
if (sfx) playSound(this, sfx);
this.advance();
}
// ── human input handlers ────────────────────────────────────────────────────
onMarketClick(i) {
if (!this.canHumanAct()) return;
if (i >= this.gs.faceUp.length) return;
if (this.gs.faceUp[i] === 'locomotive' && this.gs.drawnThisTurn !== 0) {
this.flash('Take a face-up locomotive only as your first draw'); return;
}
this.applyHuman(L.drawTrainCard(this.gs, 0, 'faceUp', i), SFX.CARD_DEAL);
}
onDeckClick() {
if (!this.canHumanAct()) return;
if (!L.canDrawTrains(this.gs)) { this.flash('No cards left to draw'); return; }
this.applyHuman(L.drawTrainCard(this.gs, 0, 'deck'), SFX.CARD_DEAL);
}
onTicketDeckClick() {
if (!this.canHumanAct()) return;
if (this.gs.drawnThisTurn > 0) { this.flash('Finish drawing train cards this turn'); return; }
if (!L.canDrawTickets(this.gs)) { this.flash('No tickets left'); return; }
this.gs = L.drawTickets(this.gs, 0);
this.advance();
}
onRouteHover(id) {
if (id != null && (!this.canHumanAct() || this.gs.drawnThisTurn > 0 || !L.routeClaimable(this.gs, 0, id))) {
id = null;
}
this.hoverRouteId = id;
this.renderHover();
}
onRouteClick(id) {
if (!this.canHumanAct()) return;
if (this.gs.drawnThisTurn > 0) { this.flash('You are drawing cards this turn'); return; }
if (!L.routeClaimable(this.gs, 0, id)) { this.flash('That route is not available'); return; }
const opts = L.paymentOptions(this.gs.players[0], ROUTES[id]);
if (opts.length === 0) { this.flash('Not enough matching cards'); return; }
if (opts.length === 1) { this.applyHuman(L.claimRoute(this.gs, 0, id, opts[0]), SFX.CARD_PLACE); return; }
this.promptClaimPayment(id, opts);
}
// ── AI driver ─────────────────────────────────────────────────────────────────
async aiTurn() {
this.busy = true;
const seat = this.gs.currentPlayer;
this.showTurnBanner(`${this.pname(seat)}'s Turn`);
await this.delay(650);
const a = AI.chooseAction(this.gs, seat);
if (a.type === 'claimRoute') {
this.gs = L.claimRoute(this.gs, seat, a.routeId, a.payment);
playSound(this, SFX.CARD_PLACE);
this.opponentPortraits[seat - 1]?.playEmotion?.('happy');
} else if (a.type === 'drawTickets') {
this.gs = L.drawTickets(this.gs, seat);
// pendingTickets now set for this AI seat — advance() routes to aiResolveTickets.
} else { // drawTrain
this.gs = L.drawTrainCard(this.gs, seat, a.source, a.index);
playSound(this, SFX.CARD_DEAL);
this.renderAll();
// Second draw if the turn is still in progress.
if ((this.gs.phase === 'turn' || this.gs.phase === 'lastRound')
&& this.gs.currentPlayer === seat && this.gs.drawnThisTurn === 1 && !this.gs.pendingTickets) {
await this.delay(450);
const b = AI.chooseSecondDraw(this.gs, seat);
this.gs = L.drawTrainCard(this.gs, seat, b.source, b.index);
playSound(this, SFX.CARD_DEAL);
}
}
this.renderAll();
await this.delay(450);
this.busy = false;
this.advance();
}
async aiResolveTickets() {
this.busy = true;
await this.delay(550);
const pend = this.gs.pendingTickets;
const keep = AI.chooseTicketsToKeep(this.gs, pend.seat, pend.drawn, pend.minKeep);
this.gs = L.resolveTicketKeep(this.gs, pend.seat, keep);
this.busy = false;
this.advance();
}
// ── rendering ─────────────────────────────────────────────────────────────────
renderAll() {
if (!this.gs) return;
this.renderRoutes();
this.renderHover();
this.renderMarket();
this.renderHand();
this.renderPiles();
this.renderPanels();
this.renderStatus();
}
renderRoutes() {
const g = this.routeGfx;
g.clear();
ROUTES.forEach((r, id) => {
const owner = this.gs.claimed[id];
const segs = this.segCache[id];
if (owner != null) {
const col = this.playerColor(owner);
for (const seg of segs) this.drawCar(g, seg, col.hex, col.hexDark, 0xffffff);
} else {
const fill = CARD_COLOR_HEX[r.color] ?? CARD_COLOR_HEX.gray;
for (const seg of segs) this.drawCar(g, seg, fill, 0x2a2118, null);
}
});
}
renderHover() {
const g = this.hoverGfx;
g.clear();
if (this.hoverRouteId == null) return;
for (const seg of this.segCache[this.hoverRouteId]) {
this.drawCar(g, seg, 0xffe680, 0xffffff, 0xffffff);
}
}
drawCar(g, seg, fill, stroke, halo) {
const w = seg.w, h = seg.h;
g.save();
g.translateCanvas(seg.cx, seg.cy);
g.rotateCanvas(seg.angle);
if (halo != null) { g.fillStyle(halo, 1); g.fillRoundedRect(-w / 2 - 2, -h / 2 - 2, w + 4, h + 4, 4); }
g.fillStyle(fill, 1);
g.fillRoundedRect(-w / 2, -h / 2, w, h, 3);
g.lineStyle(1.5, stroke, 0.9);
g.strokeRoundedRect(-w / 2, -h / 2, w, h, 3);
g.restore();
}
renderMarket() {
this.marketObjs.forEach((o) => o.destroy());
this.marketObjs = [];
const myTurn = this.canHumanAct();
for (let i = 0; i < 5; i++) {
const y = MK_Y0 + i * MK_STEP;
const color = this.gs.faceUp[i];
if (color == null) {
const g = this.add.graphics().setDepth(D.market);
g.lineStyle(2, COLORS.mutedHex, 0.5);
g.strokeRoundedRect(MK_X - CARD_W / 2, y - CARD_H / 2, CARD_W, CARD_H, 8);
this.marketObjs.push(g);
continue;
}
this.marketObjs.push(...this.makeCardFace(MK_X, y, CARD_W, CARD_H, color, D.market, ''));
const z = this.add.zone(MK_X, y, CARD_W, CARD_H).setDepth(D.market + 2);
if (myTurn) z.setInteractive({ useHandCursor: true });
z.on('pointerdown', () => this.onMarketClick(i));
this.marketObjs.push(z);
}
}
// Returns the display objects for a card face (graphics + label text).
makeCardFace(x, y, w, h, colorKey, depth, label) {
const objs = [];
const g = this.add.graphics().setDepth(depth);
const fill = CARD_COLOR_HEX[colorKey] ?? 0x888888;
g.fillStyle(fill, 1);
g.fillRoundedRect(x - w / 2, y - h / 2, w, h, 8);
if (colorKey === 'locomotive') {
// rainbow accent stripe for the wild card
const cols = [0xd23b3b, 0xe0b000, 0x2e7d32, 0x2d6cdf, 0x8e44ad];
cols.forEach((c, i) => {
g.fillStyle(c, 0.9);
g.fillRect(x - w / 2 + 6 + i * ((w - 12) / cols.length), y - 8, (w - 12) / cols.length, 16);
});
}
g.lineStyle(2.5, 0xfdf3d8, 0.9);
g.strokeRoundedRect(x - w / 2, y - h / 2, w, h, 8);
objs.push(g);
const txt = colorKey === 'locomotive' ? 'LOCO' : CARD_LABEL[colorKey];
objs.push(this.add.text(x, y + h / 2 - 12, label || txt, {
fontFamily: '"Julius Sans One"', fontSize: '13px', color: this.textColorFor(colorKey),
}).setOrigin(0.5).setDepth(depth + 1));
return objs;
}
renderHand() {
this.handObjs.forEach((o) => o.destroy());
this.handObjs = [];
const hand = this.gs.players[0].hand;
const w = 66, h = 84, step = 88, x0 = 400;
HAND_ORDER.forEach((colorKey, i) => {
const x = x0 + i * step;
const count = hand[colorKey];
const objs = this.makeCardFace(x, BOT_Y + 2, w, h, colorKey, D.hud, '');
if (count === 0) objs.forEach((o) => o.setAlpha(0.3));
this.handObjs.push(...objs);
this.handObjs.push(this.add.text(x, BOT_Y - h / 2 - 8, `×${count}`, {
fontFamily: 'Righteous', fontSize: '18px', color: count > 0 ? COLORS.textHex : COLORS.mutedHex,
}).setOrigin(0.5).setDepth(D.hud + 1));
});
}
renderPiles() {
this.deckCount.setText(String(this.gs.trainDeck.length));
this.discardCount.setText(String(this.gs.discard.length));
this.ticketCount.setText(`${this.gs.ticketDeck.length} left`);
}
renderPanels() {
this.playerScoreText.setText(String(L.publicScore(this.gs, 0)));
this.trainsText.setText(String(this.gs.players[0].trainsLeft));
this.oppRingGfx.clear();
for (let seat = 1; seat < this.gs.playerCount; seat++) {
const p = this.gs.players[seat];
const info = this.oppPanelText[seat];
if (info) {
info.setText(`Trains ${p.trainsLeft} Cards ${L.handCount(p)}\nTickets ${p.tickets.length} Score ${L.publicScore(this.gs, seat)}`);
}
if (this.gs.currentPlayer === seat) {
const y = OPP_Y0 + (seat - 1) * OPP_STEP;
this.oppRingGfx.lineStyle(4, COLORS.goldHex, 1);
this.oppRingGfx.strokeCircle(OPP_X, y, OPP_R + 8);
}
}
}
renderStatus() {
const s = this.gs;
let msg = '';
if (s.phase === 'gameOver') msg = '';
else if (s.pendingTickets && s.pendingTickets.seat === 0) msg = '';
else if (s.currentPlayer === 0) {
if (s.drawnThisTurn === 1) msg = 'Draw one more train card (deck or face-up).';
else msg = 'Your turn — claim a route, draw train cards, or draw tickets.';
if (s.phase === 'lastRound') msg = 'FINAL ROUND! ' + msg;
} else {
msg = `${this.pname(s.currentPlayer)} is thinking…`;
}
this.statusText.setText(msg);
this.logText.setText(s.log[s.log.length - 1] ?? '');
}
flash(message) {
const t = this.add.text(GAME_WIDTH / 2, 100, message, {
fontFamily: '"Julius Sans One"', fontSize: '20px', color: '#ffd86b',
backgroundColor: '#111923ee', padding: { x: 14, y: 8 },
}).setOrigin(0.5).setDepth(D.banner + 2);
this.tweens.add({ targets: t, alpha: 0, y: 70, delay: 1100, duration: 500, onComplete: () => t.destroy() });
}
showTurnBanner(text) {
const banner = this.add.text(700, 120, text, {
fontFamily: 'Righteous', fontSize: '32px', color: COLORS.textHex,
backgroundColor: '#111923ee', padding: { x: 24, y: 12 },
}).setOrigin(0.5).setDepth(D.banner);
banner.setAlpha(0);
this.tweens.add({
targets: banner, alpha: 1, y: 140, duration: 260, ease: 'Back.easeOut',
onComplete: () => this.time.delayedCall(800, () => this.tweens.add({
targets: banner, alpha: 0, y: 120, duration: 200, onComplete: () => banner.destroy(),
})),
});
}
// ── modals ─────────────────────────────────────────────────────────────────────
closeModal() {
this.modalObjs.forEach((o) => o.destroy());
this.modalObjs = [];
}
promptTicketKeep() {
this.closeModal();
const pend = this.gs.pendingTickets;
const setup = pend.context === 'setup';
const selected = new Set(pend.drawn); // pre-keep everything
const PX = 700, PW = 720;
const rowH = 70;
const PH = 200 + pend.drawn.length * rowH;
const PY = 540;
const overlay = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.55)
.setDepth(D.modal).setInteractive();
const panel = this.add.rectangle(PX, PY, PW, PH, 0x14202c, 0.98).setStrokeStyle(3, COLORS.accent).setDepth(D.modal + 1);
const title = this.add.text(PX, PY - PH / 2 + 36,
setup ? 'Keep at least 2 destination tickets' : 'Keep at least 1 destination ticket', {
fontFamily: 'Righteous', fontSize: '26px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(D.modal + 2);
this.modalObjs.push(overlay, panel, title);
const rowObjs = [];
const confirmRef = {};
const redraw = () => {
const ok = selected.size >= pend.minKeep;
confirmRef.btn?.setEnabled(ok);
};
pend.drawn.forEach((id, i) => {
const t = TICKETS[id];
const ry = PY - PH / 2 + 90 + i * rowH;
const box = this.add.rectangle(PX, ry, PW - 80, rowH - 12, 0x223344, 1)
.setStrokeStyle(3, COLORS.gold).setDepth(D.modal + 2).setInteractive({ useHandCursor: true });
const label = this.add.text(PX - PW / 2 + 60, ry, `${CITIES[t.a].name}${CITIES[t.b].name}`, {
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.textHex,
}).setOrigin(0, 0.5).setDepth(D.modal + 3);
const pts = this.add.text(PX + PW / 2 - 60, ry, `${t.points} pts`, {
fontFamily: 'Righteous', fontSize: '20px', color: COLORS.goldHex,
}).setOrigin(1, 0.5).setDepth(D.modal + 3);
const mark = this.add.text(PX - PW / 2 + 32, ry, '✓', {
fontFamily: 'Righteous', fontSize: '22px', color: '#5fd97a',
}).setOrigin(0.5).setDepth(D.modal + 3);
const apply = () => {
const on = selected.has(id);
box.setStrokeStyle(3, on ? COLORS.gold : 0x55626f);
box.setFillStyle(on ? 0x2c4a2c : 0x223344, 1);
mark.setVisible(on);
};
box.on('pointerdown', () => {
if (selected.has(id)) selected.delete(id); else selected.add(id);
apply(); redraw();
});
apply();
rowObjs.push(box, label, pts, mark);
});
this.modalObjs.push(...rowObjs);
confirmRef.btn = new Button(this, PX, PY + PH / 2 - 44, 'Confirm', () => {
const keep = [...selected];
this.closeModal();
this.gs = L.resolveTicketKeep(this.gs, 0, keep);
this.advance();
}, { width: 240, height: 54, fontSize: 22 }).setDepth(D.modal + 2);
this.modalObjs.push(confirmRef.btn);
redraw();
}
promptClaimPayment(routeId, options) {
this.closeModal();
this.busy = true; // block other input while choosing
const route = ROUTES[routeId];
const PX = 700, PW = 640;
const PH = 180 + options.length * 70;
const PY = 540;
const overlay = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.55)
.setDepth(D.modal).setInteractive();
const panel = this.add.rectangle(PX, PY, PW, PH, 0x14202c, 0.98).setStrokeStyle(3, COLORS.accent).setDepth(D.modal + 1);
const title = this.add.text(PX, PY - PH / 2 + 34,
`Claim ${CITIES[route.a].name} ${CITIES[route.b].name}\nPay with:`, {
fontFamily: 'Righteous', fontSize: '22px', color: COLORS.textHex, align: 'center',
}).setOrigin(0.5).setDepth(D.modal + 2);
this.modalObjs.push(overlay, panel, title);
const finish = (payment) => {
this.closeModal();
this.busy = false;
if (payment) this.applyHuman(L.claimRoute(this.gs, 0, routeId, payment), SFX.CARD_PLACE);
else this.advance();
};
options.forEach((opt, i) => {
const parts = [];
if (opt.colorCount > 0) parts.push(`${opt.colorCount} ${CARD_LABEL[opt.color]}`);
if (opt.locos > 0) parts.push(`${opt.locos} Locomotive`);
const by = PY - PH / 2 + 86 + i * 70;
this.modalObjs.push(new Button(this, PX, by, parts.join(' + '), () => finish(opt),
{ width: PW - 120, height: 52, fontSize: 20 }).setDepth(D.modal + 2));
});
this.modalObjs.push(new Button(this, PX, PY + PH / 2 - 40, 'Cancel', () => finish(null),
{ variant: 'ghost', width: 200, height: 46, fontSize: 18 }).setDepth(D.modal + 2));
}
showMyTickets() {
this.closeModal();
const tickets = this.gs.players[0].tickets;
const find = (() => {
const parent = Array.from({ length: CITIES.length }, (_, i) => i);
const f = (x) => { while (parent[x] !== x) { parent[x] = parent[parent[x]]; x = parent[x]; } return x; };
for (const rid of this.gs.players[0].routes) { const r = ROUTES[rid]; parent[f(r.a)] = f(r.b); }
return f;
})();
const PX = 700, PW = 700, rowH = 64;
const PH = 170 + Math.max(1, tickets.length) * rowH;
const PY = 540;
const overlay = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.55)
.setDepth(D.modal).setInteractive();
const panel = this.add.rectangle(PX, PY, PW, PH, 0x14202c, 0.98).setStrokeStyle(3, COLORS.accent).setDepth(D.modal + 1);
const title = this.add.text(PX, PY - PH / 2 + 34, 'Your Destination Tickets', {
fontFamily: 'Righteous', fontSize: '24px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(D.modal + 2);
this.modalObjs.push(overlay, panel, title);
if (tickets.length === 0) {
this.modalObjs.push(this.add.text(PX, PY, 'No tickets.', {
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(D.modal + 2));
}
tickets.forEach((t, i) => {
const ry = PY - PH / 2 + 84 + i * rowH;
const done = this.gs.players[0].routes.length > 0 && find(t.a) === find(t.b);
this.modalObjs.push(this.add.text(PX - PW / 2 + 40, ry, `${CITIES[t.a].name}${CITIES[t.b].name}`, {
fontFamily: '"Julius Sans One"', fontSize: '19px', color: COLORS.textHex,
}).setOrigin(0, 0.5).setDepth(D.modal + 2));
this.modalObjs.push(this.add.text(PX + PW / 2 - 150, ry, `${t.points} pts`, {
fontFamily: 'Righteous', fontSize: '18px', color: COLORS.goldHex,
}).setOrigin(1, 0.5).setDepth(D.modal + 2));
this.modalObjs.push(this.add.text(PX + PW / 2 - 40, ry, done ? '✓ done' : 'open', {
fontFamily: '"Julius Sans One"', fontSize: '16px', color: done ? '#5fd97a' : COLORS.mutedHex,
}).setOrigin(1, 0.5).setDepth(D.modal + 2));
});
this.modalObjs.push(new Button(this, PX, PY + PH / 2 - 40, 'Close', () => this.closeModal(),
{ variant: 'ghost', width: 200, height: 46, fontSize: 18 }).setDepth(D.modal + 2));
}
// ── game over ─────────────────────────────────────────────────────────────────
onGameOver() {
this.closeModal();
this.hoverRouteId = null; this.renderHover();
const scores = this.gs.scores;
const winner = this.gs.winner;
const isHuman = winner === 0;
this.recordHistory();
const PW = 820, PH = 660, PX = 700, PY = 540;
const fw = this.add.particles(PX, PY, 'ttrParticle', {
speed: { min: 80, max: 460 }, lifespan: 1400, scale: { start: 1.1, end: 0 },
alpha: { start: 1, end: 0 }, quantity: 3, frequency: 40,
tint: [0xffd700, 0xff6644, 0xffffff, 0x44aaff, 0x88ff44],
angle: { min: 0, max: 360 },
emitZone: { type: 'random', source: new Phaser.Geom.Rectangle(-PW / 2, -PH / 2, PW, PH) },
}).setDepth(D.banner + 8);
this.time.delayedCall(3000, () => { fw.stop(); this.time.delayedCall(1400, () => fw.destroy()); });
const overlay = this.add.rectangle(PX, PY, PW, PH, 0x0a0e14, 0.95).setStrokeStyle(3, COLORS.accent).setDepth(D.banner);
const title = this.add.text(PX, PY - PH / 2 + 56, isHuman ? 'Victory!' : `${this.pname(winner)} wins`, {
fontFamily: 'Righteous', fontSize: '46px', color: isHuman ? '#ffd700' : COLORS.textHex,
}).setOrigin(0.5).setDepth(D.banner + 1);
// Score table.
const header = this.add.text(PX, PY - PH / 2 + 120,
'Player Routes Tickets Longest Total', {
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.accentHex,
}).setOrigin(0.5).setDepth(D.banner + 1);
const rows = scores.slice().sort((a, b) => b.total - a.total).map((sc) => {
const name = this.pname(sc.seat).padEnd(12).slice(0, 12);
const lp = `${sc.longestPath}${sc.longestBonus ? '+10' : ''}`;
return `${name} ${String(sc.routePoints).padStart(5)} ${String(sc.ticketPoints).padStart(6)} ${lp.padStart(7)} ${String(sc.total).padStart(5)}`;
}).join('\n');
const body = this.add.text(PX, PY - 30, rows, {
fontFamily: 'monospace', fontSize: '22px', color: COLORS.textHex, align: 'left', lineSpacing: 8,
}).setOrigin(0.5).setDepth(D.banner + 1);
const cleanup = () => {
overlay.destroy(); title.destroy(); header.destroy(); body.destroy();
playAgain.destroy(); leave.destroy();
};
const playAgain = new Button(this, PX - 120, PY + PH / 2 - 60, 'Play Again', () => {
cleanup(); this.startNewMatch();
}, { width: 210, fontSize: 22 }).setDepth(D.banner + 1);
const leave = new Button(this, PX + 120, PY + PH / 2 - 60, 'Leave', () => {
cleanup(); this.scene.start('GameMenu');
}, { variant: 'ghost', width: 210, fontSize: 22 }).setDepth(D.banner + 1);
}
async recordHistory() {
const totals = this.gs.scores.map((sc) => sc.total);
const result = this.gs.winner === 0 ? 'win' : 'loss';
try {
await api.post('/history/single-player', {
slug: 'tickettoride', score: totals[0], opponentScores: totals.slice(1), result,
});
} catch (_) { /* offline / not signed in — ignore */ }
}
delay(ms) { return new Promise((res) => this.time.delayedCall(ms, res)); }
}

View File

@ -0,0 +1,443 @@
// TicketToRideLogic.js — pure state engine for Ticket to Ride (USA). No Phaser.
// Every exported action takes a state and returns a NEW state (deep-cloned at the
// top); internal helpers prefixed `_` mutate the already-cloned working state.
import {
ROUTES, CITIES, TICKETS, TRAIN_DECK, TRAIN_COLORS, ROUTE_SCORE,
TRAINS_PER_PLAYER, LONGEST_PATH_BONUS, FACE_UP_COUNT, ENDGAME_TRAIN_THRESHOLD,
} from './TicketToRideBoard.js';
// ── small utilities ─────────────────────────────────────────────────────────
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;
}
export function cloneState(state) {
return JSON.parse(JSON.stringify(state));
}
export function emptyHand() {
const h = { locomotive: 0 };
for (const c of TRAIN_COLORS) h[c] = 0;
return h;
}
export function handCount(player) {
return player.hand.locomotive + TRAIN_COLORS.reduce((s, c) => s + player.hand[c], 0);
}
function logEvent(s, msg) {
s.log.push(msg);
if (s.log.length > 12) s.log.shift();
}
// ── deck helpers (mutate working state) ──────────────────────────────────────
function _reshuffleIfNeeded(s) {
if (s.trainDeck.length === 0 && s.discard.length > 0) {
s.trainDeck = shuffle(s.discard);
s.discard = [];
}
}
function _drawFromDeck(s) {
_reshuffleIfNeeded(s);
return s.trainDeck.length ? s.trainDeck.pop() : null;
}
// Refill the face-up market to FACE_UP_COUNT; if 3+ of the 5 are locomotives,
// discard all five and re-deal (official rule). Guarded against tiny decks.
function _refillFaceUp(s) {
let guard = 0;
do {
while (s.faceUp.length < FACE_UP_COUNT) {
const c = _drawFromDeck(s);
if (c == null) break;
s.faceUp.push(c);
}
const locos = s.faceUp.filter((c) => c === 'locomotive').length;
const available = s.faceUp.length + s.trainDeck.length + s.discard.length;
if (locos >= 3 && s.faceUp.length === FACE_UP_COUNT && available > FACE_UP_COUNT) {
s.discard.push(...s.faceUp);
s.faceUp = [];
continue;
}
break;
} while (++guard < 10);
}
// ── ticket dealing ────────────────────────────────────────────────────────────
function _dealTicketsToSeat(s, seat, count, minKeep, context) {
const drawn = [];
for (let i = 0; i < count && s.ticketDeck.length; i++) drawn.push(s.ticketDeck.shift());
s.pendingTickets = { seat, drawn, minKeep: Math.min(minKeep, drawn.length), context };
}
// ── initial state ──────────────────────────────────────────────────────────────
export function createInitialState(playerCount = 4) {
const n = Math.max(2, Math.min(5, playerCount));
const s = {
playerCount: n,
phase: 'ticketSetup',
currentPlayer: 0,
trainDeck: shuffle(TRAIN_DECK),
discard: [],
faceUp: [],
ticketDeck: shuffle(TICKETS.map((t) => t.id)),
pendingTickets: null,
claimed: {}, // routeId -> seat
players: [],
drawnThisTurn: 0,
drewLocoFaceUp: false,
endTriggered: false,
finalTurnsLeft: null,
triggerSeat: null,
winner: null,
scores: null,
log: [],
};
for (let seat = 0; seat < n; seat++) {
const hand = emptyHand();
for (let i = 0; i < 4; i++) { // deal 4 train cards
const c = _drawFromDeck(s);
if (c != null) hand[c]++;
}
s.players.push({
seat,
colorIndex: seat,
isHuman: seat === 0,
name: seat === 0 ? 'You' : `Player ${seat}`,
hand,
tickets: [],
trainsLeft: TRAINS_PER_PLAYER,
routes: [],
});
}
_refillFaceUp(s);
_dealTicketsToSeat(s, 0, 3, 2, 'setup'); // seat 0 chooses first
return s;
}
// ── ticket keep resolution (setup + mid-game draws) ──────────────────────────
export function resolveTicketKeep(state, seat, keepIds) {
const s = cloneState(state);
const pend = s.pendingTickets;
if (!pend || pend.seat !== seat) return s;
// Sanitise the request: only ids that were drawn, at least minKeep of them.
let keep = (keepIds || []).filter((id) => pend.drawn.includes(id));
if (keep.length < pend.minKeep) {
for (const id of pend.drawn) {
if (keep.length >= pend.minKeep) break;
if (!keep.includes(id)) keep.push(id);
}
}
const discardIds = pend.drawn.filter((id) => !keep.includes(id));
for (const id of keep) s.players[seat].tickets.push({ ...TICKETS[id] });
s.ticketDeck.push(...discardIds); // returned to the bottom
logEvent(s, `${playerName(s, seat)} kept ${keep.length} ticket${keep.length === 1 ? '' : 's'}.`);
const wasSetup = pend.context === 'setup';
s.pendingTickets = null;
if (wasSetup) {
const next = seat + 1;
if (next < s.playerCount) {
_dealTicketsToSeat(s, next, 3, 2, 'setup');
} else {
s.phase = 'turn';
s.currentPlayer = 0;
logEvent(s, 'Game on! Your move.');
}
return s;
}
// Mid-game ticket draw completes the turn.
return _endTurn(s);
}
// ── train-card draws ─────────────────────────────────────────────────────────
export function canDrawTrains(s) {
return s.trainDeck.length + s.discard.length + s.faceUp.length > 0;
}
// source: 'deck' or 'faceUp'; index used only for 'faceUp'.
export function drawTrainCard(state, seat, source, index = 0) {
const s = cloneState(state);
if (!_isTurnPhase(s) || s.currentPlayer !== seat || s.pendingTickets) return s;
if (s.drawnThisTurn >= 2) return s;
if (source === 'faceUp') {
if (index < 0 || index >= s.faceUp.length) return s;
const card = s.faceUp[index];
if (card === 'locomotive') {
// A face-up locomotive may only be taken as the first draw; it ends the turn.
if (s.drawnThisTurn !== 0) return s;
s.faceUp.splice(index, 1);
s.players[seat].hand.locomotive++;
s.drewLocoFaceUp = true;
_refillFaceUp(s);
logEvent(s, `${playerName(s, seat)} drew a locomotive.`);
return _endTurn(s);
}
s.faceUp.splice(index, 1);
s.players[seat].hand[card]++;
s.drawnThisTurn++;
_refillFaceUp(s);
logEvent(s, `${playerName(s, seat)} drew a face-up card.`);
} else {
const card = _drawFromDeck(s);
if (card == null) return s;
s.players[seat].hand[card]++;
s.drawnThisTurn++;
logEvent(s, `${playerName(s, seat)} drew from the deck.`);
}
if (s.drawnThisTurn >= 2) return _endTurn(s);
return s;
}
// ── route claiming ───────────────────────────────────────────────────────────
// Returns the cheapest concrete payment {color, colorCount, locos} that claims
// `route` from `player`'s hand, or null if unaffordable. Shared with the AI so
// legality and AI agree. Prefers spending the fewest locomotives.
export function cardsForRoute(player, route) {
const opts = paymentOptions(player, route);
return opts.length ? opts[0] : null;
}
// All viable payments for a route, best (fewest locos, then most colour cards) first.
export function paymentOptions(player, route) {
const hand = player.hand;
const need = route.length;
const locos = hand.locomotive;
const out = [];
const colors = route.color === 'gray' ? TRAIN_COLORS : [route.color];
for (const c of colors) {
const have = hand[c];
// Only colour-bearing payments here; a 0-colour payment is just pure-loco
// (added once below) regardless of which colour slot produced it.
if (have > 0 && have + locos >= need) {
const colorCount = Math.min(have, need);
out.push({ color: c, colorCount, locos: need - colorCount });
}
}
// Pure-locomotive payment (always allowed if enough wilds).
if (locos >= need) out.push({ color: null, colorCount: 0, locos: need });
out.sort((p, q) => (p.locos - q.locos) || (q.colorCount - p.colorCount));
return out;
}
// Is `routeId` claimable by `seat` right now, ignoring the hand (occupancy +
// double-route lockout + trains)? Used by both legality and rendering.
export function routeClaimable(s, seat, routeId) {
if (s.claimed[routeId] != null) return false;
const route = ROUTES[routeId];
if (s.players[seat].trainsLeft < route.length) return false;
if (route.doubleGroup) {
for (const r of ROUTES) {
if (r.id === routeId || r.doubleGroup !== route.doubleGroup) continue;
const owner = s.claimed[r.id];
if (owner === seat) return false; // can't hold both halves
if (owner != null && s.playerCount < 4) return false; // <4 players: one locks the pair
}
}
return true;
}
// Every route `seat` can claim now, with its default payment.
export function legalClaims(s, seat) {
if (!_isTurnPhase(s) || s.currentPlayer !== seat || s.pendingTickets || s.drawnThisTurn > 0) return [];
const out = [];
for (const route of ROUTES) {
if (!routeClaimable(s, seat, route.id)) continue;
const payment = cardsForRoute(s.players[seat], route);
if (payment) out.push({ routeId: route.id, payment });
}
return out;
}
function _validatePayment(player, route, payment) {
if (!payment) return false;
const { color, colorCount, locos } = payment;
if (colorCount < 0 || locos < 0) return false;
if (colorCount + locos !== route.length) return false;
if (locos > player.hand.locomotive) return false;
if (colorCount > 0) {
if (!TRAIN_COLORS.includes(color)) return false;
if (route.color !== 'gray' && color !== route.color) return false;
if (player.hand[color] < colorCount) return false;
}
return true;
}
// payment optional; if omitted the cheapest valid payment is used.
export function claimRoute(state, seat, routeId, payment = null) {
const s = cloneState(state);
if (!_isTurnPhase(s) || s.currentPlayer !== seat || s.pendingTickets) return s;
if (s.drawnThisTurn > 0) return s; // can't claim after drawing
if (!routeClaimable(s, seat, routeId)) return s;
const route = ROUTES[routeId];
const player = s.players[seat];
const pay = payment ?? cardsForRoute(player, route);
if (!_validatePayment(player, route, pay)) return s;
if (pay.colorCount > 0) { player.hand[pay.color] -= pay.colorCount; for (let i = 0; i < pay.colorCount; i++) s.discard.push(pay.color); }
if (pay.locos > 0) { player.hand.locomotive -= pay.locos; for (let i = 0; i < pay.locos; i++) s.discard.push('locomotive'); }
player.trainsLeft -= route.length;
player.routes.push(routeId);
s.claimed[routeId] = seat;
logEvent(s, `${playerName(s, seat)} claimed ${CITIES[route.a].name}${CITIES[route.b].name} (+${ROUTE_SCORE[route.length]}).`);
return _endTurn(s);
}
// ── destination-ticket draws (mid-game) ────────────────────────────────────────
export function canDrawTickets(s) {
return s.ticketDeck.length > 0;
}
export function drawTickets(state, seat) {
const s = cloneState(state);
if (!_isTurnPhase(s) || s.currentPlayer !== seat || s.pendingTickets) return s;
if (s.drawnThisTurn > 0) return s; // a full action only
if (s.ticketDeck.length === 0) return s;
_dealTicketsToSeat(s, seat, 3, 1, 'turn');
logEvent(s, `${playerName(s, seat)} is drawing tickets…`);
return s;
}
// ── turn flow ────────────────────────────────────────────────────────────────
function _isTurnPhase(s) {
return s.phase === 'turn' || s.phase === 'lastRound';
}
// Apply end-of-turn: end-game trigger, last-round countdown, hand off to next.
function _endTurn(s) {
const finishing = s.currentPlayer;
const wasTriggeredBefore = s.endTriggered;
if (!s.endTriggered && s.players[finishing].trainsLeft <= ENDGAME_TRAIN_THRESHOLD) {
s.endTriggered = true;
s.finalTurnsLeft = s.playerCount; // one final turn for every player, incl. this one
s.triggerSeat = finishing;
s.phase = 'lastRound';
logEvent(s, `${playerName(s, finishing)} is low on trains — final round!`);
}
if (wasTriggeredBefore) {
s.finalTurnsLeft -= 1;
if (s.finalTurnsLeft <= 0) return _finalize(s);
}
s.drawnThisTurn = 0;
s.drewLocoFaceUp = false;
s.currentPlayer = (finishing + 1) % s.playerCount;
return s;
}
// ── scoring ──────────────────────────────────────────────────────────────────
export function routePoints(s, seat) {
return s.players[seat].routes.reduce((sum, rid) => sum + ROUTE_SCORE[ROUTES[rid].length], 0);
}
// Public score shown during play (route points only — tickets stay hidden).
export function publicScore(s, seat) {
return routePoints(s, seat);
}
class UnionFind {
constructor(n) { this.p = Array.from({ length: n }, (_, i) => i); }
find(x) { while (this.p[x] !== x) { this.p[x] = this.p[this.p[x]]; x = this.p[x]; } return x; }
union(a, b) { this.p[this.find(a)] = this.find(b); }
}
export function ticketScore(s, seat) {
const uf = new UnionFind(CITIES.length);
for (const rid of s.players[seat].routes) { const r = ROUTES[rid]; uf.union(r.a, r.b); }
let points = 0, completed = 0;
for (const t of s.players[seat].tickets) {
const ok = s.players[seat].routes.length > 0 && uf.find(t.a) === uf.find(t.b);
if (ok) { points += t.points; completed++; } else { points -= t.points; }
}
return { points, completed };
}
// Longest continuous path (trail): each claimed route used at most once, maximise
// total train length. Per-player subgraphs are tiny, so exhaustive DFS is fine.
export function longestPathFor(s, seat) {
const inc = new Map(); // cityId -> [{ routeId, other, len }]
for (const rid of s.players[seat].routes) {
const r = ROUTES[rid];
if (!inc.has(r.a)) inc.set(r.a, []);
if (!inc.has(r.b)) inc.set(r.b, []);
inc.get(r.a).push({ routeId: rid, other: r.b, len: r.length });
inc.get(r.b).push({ routeId: rid, other: r.a, len: r.length });
}
let best = 0;
let steps = 0;
const dfs = (node, used, total) => {
if (total > best) best = total;
if (++steps > 200000) return; // defensive guard
for (const e of inc.get(node) || []) {
if (used.has(e.routeId)) continue;
used.add(e.routeId);
dfs(e.other, used, total + e.len);
used.delete(e.routeId);
}
};
for (const node of inc.keys()) dfs(node, new Set(), 0);
return best;
}
// Compute final scores and decide the winner. Mutates+returns the working state.
function _finalize(s) {
const longest = s.players.map((p) => longestPathFor(s, p.seat));
const maxLongest = Math.max(0, ...longest);
s.scores = s.players.map((p) => {
const rp = routePoints(s, p.seat);
const ts = ticketScore(s, p.seat);
const longestBonus = (maxLongest > 0 && longest[p.seat] === maxLongest) ? LONGEST_PATH_BONUS : 0;
return {
seat: p.seat,
routePoints: rp,
ticketPoints: ts.points,
ticketsCompleted: ts.completed,
longestPath: longest[p.seat],
longestBonus,
total: rp + ts.points + longestBonus,
};
});
// Winner: highest total, tie-break by tickets completed, then longest path.
let win = s.scores[0];
for (const sc of s.scores) {
if (sc.total > win.total ||
(sc.total === win.total && sc.ticketsCompleted > win.ticketsCompleted) ||
(sc.total === win.total && sc.ticketsCompleted === win.ticketsCompleted && sc.longestPath > win.longestPath)) {
win = sc;
}
}
s.winner = win.seat;
s.phase = 'gameOver';
logEvent(s, `${playerName(s, s.winner)} wins with ${win.total} points!`);
return s;
}
// ── names ──────────────────────────────────────────────────────────────────────
export function playerName(s, seat) {
return s.players[seat]?.name ?? (seat === 0 ? 'You' : `Player ${seat}`);
}
export function setPlayerNames(state, names) {
state.players.forEach((p, i) => { p.name = names[i] ?? (i === 0 ? 'You' : `Player ${i}`); });
}
export { ROUTES, CITIES, TICKETS, ROUTE_SCORE, TRAIN_COLORS };

View File

@ -25,6 +25,7 @@ import RouletteGame from './games/roulette/RouletteGame.js';
import MexicanTrainGame from './games/mexicantrain/MexicanTrainGame.js';
import HeartsGame from './games/hearts/HeartsGame.js';
import CatanGame from './games/catan/CatanGame.js';
import TicketToRideGame from './games/tickettoride/TicketToRideGame.js';
import NertsGame from './games/nerts/NertsGame.js';
import BingoGame from './games/bingo/BingoGame.js';
import BaccaratGame from './games/baccarat/BaccaratGame.js';
@ -74,6 +75,7 @@ const config = {
MexicanTrainGame,
HeartsGame,
CatanGame,
TicketToRideGame,
NertsGame,
BingoGame,
BaccaratGame,

View File

@ -20,7 +20,7 @@ export default class GameRoomScene extends Phaser.Scene {
}
create() {
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame' };
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame' };
if (slugDispatch[this.game.slug]) {
this.scene.start(slugDispatch[this.game.slug], {
game: this.game,

View File

@ -107,7 +107,9 @@ export default class OpponentSelectScene extends Phaser.Scene {
this.buildOpponentGrid(opponents, oppScrollH);
const max = this.gameDef.maxOpponents ?? 1;
const defaultCount = this.gameDef.slug === 'nerts' ? 1 : max;
const defaultCount = this.gameDef.slug === 'nerts' ? 1
: this.gameDef.slug === 'tickettoride' ? Math.min(3, max)
: max;
this._initializing = true;
this.cards.slice(0, defaultCount).forEach(({ opp, el }) => this.toggleOpponent(opp, el));
this._initializing = false;

View File

@ -38,6 +38,7 @@ registerGame({ slug: 'roulette', name: 'Roulette', category: 'casino', minPlayer
registerGame({ slug: 'mexicantrain', name: 'Mexican Train', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3 });
registerGame({ slug: 'hearts', name: 'Hearts', category: 'cards', cardGame: true, minPlayers: 4, maxPlayers: 4, minOpponents: 3, maxOpponents: 3 });
registerGame({ slug: 'catan', name: 'Settlers of Catan', category: 'tabletop', cardGame: true, minPlayers: 3, maxPlayers: 4, minOpponents: 2, maxOpponents: 3 });
registerGame({ slug: 'tickettoride', name: 'Ticket to Ride', category: 'tabletop', cardGame: true, minPlayers: 2, maxPlayers: 5, minOpponents: 1, maxOpponents: 4 });
registerGame({ slug: 'nerts', name: 'Nerts', category: 'cards', cardGame: true, minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3 });
registerGame({ slug: 'bingo', name: 'Bingo', category: 'casino', minPlayers: 2, maxPlayers: 11, minOpponents: 1, maxOpponents: 10 });
registerGame({ slug: 'baccarat', name: 'Baccarat', category: 'casino', cardGame: true, minPlayers: 2, maxPlayers: 7, minOpponents: 1, maxOpponents: 6 });