feat: add Monopoly board game
- Add complete Monopoly implementation with pure state engine (MonopolyLogic.js), static data (MonopolyData.js), AI (MonopolyAI.js), and Phaser scene (MonopolyGame.js) - Implement full game rules: property buying, auctions, building houses/hotels, mortgages, jail, chance/community chest cards, rent calculation, bankruptcy - Add 5-level AI with configurable greed, blunder rate, and thinking delay - Add spritesheet loading for monopoly pawns and cards with graceful fallbacks - Register game in registry, main.js, and game room scene dispatcher - Add spritesheet creation guide (sprites.md) and update game-icons.png
This commit is contained in:
parent
27635d166f
commit
10ac18ab6e
Binary file not shown.
|
Before Width: | Height: | Size: 177 KiB After Width: | Height: | Size: 182 KiB |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
|
|
@ -0,0 +1,82 @@
|
||||||
|
// Monopoly — AI decisions. Greedy heuristic, skill 1–5.
|
||||||
|
|
||||||
|
import {
|
||||||
|
SPACES, RAILROADS, UTILITIES, PURCHASABLE, GROUPS,
|
||||||
|
} from './MonopolyData.js';
|
||||||
|
import {
|
||||||
|
ownsGroup, canBuildHouse, canBuildHotel, netWorth,
|
||||||
|
} from './MonopolyLogic.js';
|
||||||
|
|
||||||
|
const PROFILES = {
|
||||||
|
1: { reserve:0, maxBidMult:0.70, noise:50, blunder:0.35, delay:[900,1500] },
|
||||||
|
2: { reserve:100, maxBidMult:0.80, noise:30, blunder:0.20, delay:[750,1300] },
|
||||||
|
3: { reserve:200, maxBidMult:0.90, noise:15, blunder:0.10, delay:[600,1100] },
|
||||||
|
4: { reserve:300, maxBidMult:1.00, noise:5, blunder:0.03, delay:[500,950] },
|
||||||
|
5: { reserve:400, maxBidMult:1.10, noise:0, blunder:0.00, delay:[400,800] },
|
||||||
|
};
|
||||||
|
|
||||||
|
function rnd(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min; }
|
||||||
|
function noise(n) { return (Math.random() - 0.5) * n; }
|
||||||
|
|
||||||
|
export function nextThinkDelay(skill) {
|
||||||
|
const [lo, hi] = PROFILES[skill]?.delay ?? [600, 1100];
|
||||||
|
return rnd(lo, hi);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Buy decision ───────────────────────────────────────────────────────────────
|
||||||
|
export function chooseBuy(state, seat, skill) {
|
||||||
|
const prof = PROFILES[skill] ?? PROFILES[3];
|
||||||
|
if (Math.random() < prof.blunder) return Math.random() < 0.5;
|
||||||
|
const { spaceIdx } = state.pendingBuy;
|
||||||
|
const sp = SPACES[spaceIdx];
|
||||||
|
const p = state.players[seat];
|
||||||
|
const reserve = prof.reserve + noise(prof.noise);
|
||||||
|
return p.cash - sp.price >= reserve;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Auction bid ───────────────────────────────────────────────────────────────
|
||||||
|
export function chooseBid(state, seat, skill) {
|
||||||
|
const prof = PROFILES[skill] ?? PROFILES[3];
|
||||||
|
const auc = state.pendingAuction;
|
||||||
|
const sp = SPACES[auc.spaceIdx];
|
||||||
|
const p = state.players[seat];
|
||||||
|
const maxBid = Math.min(p.cash * 0.7, sp.price * prof.maxBidMult + noise(prof.noise));
|
||||||
|
if (maxBid <= auc.highBid) return null; // pass
|
||||||
|
return Math.max(auc.highBid + 1, Math.floor(maxBid));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Jail decision ─────────────────────────────────────────────────────────────
|
||||||
|
export function chooseJailAction(state, seat, skill) {
|
||||||
|
const p = state.players[seat];
|
||||||
|
if (p.getOutOfJailFree > 0) return 'card';
|
||||||
|
if (skill >= 3 && p.cash >= 50) {
|
||||||
|
// Higher skill: pay to get back in action
|
||||||
|
return 'pay';
|
||||||
|
}
|
||||||
|
return 'roll';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Build decisions ────────────────────────────────────────────────────────────
|
||||||
|
// Returns { action: 'house'|'hotel', spaceIdx } or null
|
||||||
|
export function chooseBuild(state, seat, skill) {
|
||||||
|
const prof = PROFILES[skill] ?? PROFILES[3];
|
||||||
|
const p = state.players[seat];
|
||||||
|
|
||||||
|
// Try to build hotels first on complete sets
|
||||||
|
for (const idx of PURCHASABLE) {
|
||||||
|
if (!canBuildHotel(state, seat, idx)) continue;
|
||||||
|
const sp = SPACES[idx];
|
||||||
|
if (p.cash - sp.houseCost >= prof.reserve) {
|
||||||
|
return { action: 'hotel', spaceIdx: idx };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Build houses on monopolies
|
||||||
|
for (const idx of PURCHASABLE) {
|
||||||
|
if (!canBuildHouse(state, seat, idx)) continue;
|
||||||
|
const sp = SPACES[idx];
|
||||||
|
if (p.cash - sp.houseCost >= prof.reserve) {
|
||||||
|
return { action: 'house', spaceIdx: idx };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,165 @@
|
||||||
|
// Monopoly — static data. No Phaser, no state.
|
||||||
|
|
||||||
|
export const BOARD_SIZE = 840;
|
||||||
|
export const CORNER_SIZE = 105;
|
||||||
|
export const SPACE_W = 70;
|
||||||
|
export const BAND_H = 18;
|
||||||
|
|
||||||
|
export const GROUP_COLORS = {
|
||||||
|
brown: 0x9B5524,
|
||||||
|
lightblue:0x00B3E8,
|
||||||
|
pink: 0xD63384,
|
||||||
|
orange: 0xE77A2C,
|
||||||
|
red: 0xDC3545,
|
||||||
|
yellow: 0xE8C12C,
|
||||||
|
green: 0x1F8C3B,
|
||||||
|
darkblue: 0x003580,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GROUP_HEX = {
|
||||||
|
brown: '#9B5524',
|
||||||
|
lightblue:'#00B3E8',
|
||||||
|
pink: '#D63384',
|
||||||
|
orange: '#E77A2C',
|
||||||
|
red: '#DC3545',
|
||||||
|
yellow: '#E8C12C',
|
||||||
|
green: '#1F8C3B',
|
||||||
|
darkblue: '#003580',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 40 spaces, index 0–39, clockwise starting from Go.
|
||||||
|
// rent[]: [base, monopoly, 1house, 2h, 3h, 4h, hotel]
|
||||||
|
export const SPACES = [
|
||||||
|
{ type:'go', name:'GO' },
|
||||||
|
{ type:'property', name:'Mediterranean Ave', group:'brown', price:60, rent:[2,4,10,30,90,160,250], houseCost:50, mortgage:30 },
|
||||||
|
{ type:'community_chest', name:'Community Chest' },
|
||||||
|
{ type:'property', name:'Baltic Ave', group:'brown', price:60, rent:[4,8,20,60,180,320,450], houseCost:50, mortgage:30 },
|
||||||
|
{ type:'tax', name:'Income Tax', amount:200 },
|
||||||
|
{ type:'railroad', name:'Reading Railroad', price:200, mortgage:100 },
|
||||||
|
{ type:'property', name:'Oriental Ave', group:'lightblue',price:100, rent:[6,12,30,90,270,400,550], houseCost:50, mortgage:50 },
|
||||||
|
{ type:'chance', name:'Chance' },
|
||||||
|
{ type:'property', name:'Vermont Ave', group:'lightblue',price:100, rent:[6,12,30,90,270,400,550], houseCost:50, mortgage:50 },
|
||||||
|
{ type:'property', name:'Connecticut Ave', group:'lightblue',price:120, rent:[8,16,40,100,300,450,600], houseCost:50, mortgage:60 },
|
||||||
|
{ type:'jail', name:'Jail / Just Visiting' },
|
||||||
|
{ type:'property', name:'St. Charles Place', group:'pink', price:140, rent:[10,20,50,150,450,625,750], houseCost:100, mortgage:70 },
|
||||||
|
{ type:'utility', name:'Electric Company', price:150, mortgage:75 },
|
||||||
|
{ type:'property', name:'States Ave', group:'pink', price:140, rent:[10,20,50,150,450,625,750], houseCost:100, mortgage:70 },
|
||||||
|
{ type:'property', name:'Virginia Ave', group:'pink', price:160, rent:[12,24,60,180,500,700,900], houseCost:100, mortgage:80 },
|
||||||
|
{ type:'railroad', name:'Pennsylvania Railroad',price:200, mortgage:100 },
|
||||||
|
{ type:'property', name:'St. James Place', group:'orange', price:180, rent:[14,28,70,200,550,750,950], houseCost:100, mortgage:90 },
|
||||||
|
{ type:'community_chest', name:'Community Chest' },
|
||||||
|
{ type:'property', name:'Tennessee Ave', group:'orange', price:180, rent:[14,28,70,200,550,750,950], houseCost:100, mortgage:90 },
|
||||||
|
{ type:'property', name:'New York Ave', group:'orange', price:200, rent:[16,32,80,220,600,800,1000], houseCost:100, mortgage:100 },
|
||||||
|
{ type:'freeparking', name:'Free Parking' },
|
||||||
|
{ type:'property', name:'Kentucky Ave', group:'red', price:220, rent:[18,36,90,250,700,875,1050], houseCost:150, mortgage:110 },
|
||||||
|
{ type:'chance', name:'Chance' },
|
||||||
|
{ type:'property', name:'Indiana Ave', group:'red', price:220, rent:[18,36,90,250,700,875,1050], houseCost:150, mortgage:110 },
|
||||||
|
{ type:'property', name:'Illinois Ave', group:'red', price:240, rent:[20,40,100,300,750,925,1100], houseCost:150, mortgage:120 },
|
||||||
|
{ type:'railroad', name:'B&O Railroad', price:200, mortgage:100 },
|
||||||
|
{ type:'property', name:'Atlantic Ave', group:'yellow', price:260, rent:[22,44,110,330,800,975,1150], houseCost:150, mortgage:130 },
|
||||||
|
{ type:'property', name:'Ventnor Ave', group:'yellow', price:260, rent:[22,44,110,330,800,975,1150], houseCost:150, mortgage:130 },
|
||||||
|
{ type:'utility', name:'Water Works', price:150, mortgage:75 },
|
||||||
|
{ type:'property', name:'Marvin Gardens', group:'yellow', price:280, rent:[24,48,120,360,850,1025,1200], houseCost:150, mortgage:140 },
|
||||||
|
{ type:'gotojail', name:'Go To Jail' },
|
||||||
|
{ type:'property', name:'Pacific Ave', group:'green', price:300, rent:[26,52,130,390,900,1100,1275], houseCost:200, mortgage:150 },
|
||||||
|
{ type:'property', name:'North Carolina Ave', group:'green', price:300, rent:[26,52,130,390,900,1100,1275], houseCost:200, mortgage:150 },
|
||||||
|
{ type:'community_chest', name:'Community Chest' },
|
||||||
|
{ type:'property', name:'Pennsylvania Ave', group:'green', price:320, rent:[28,56,150,450,1000,1200,1400], houseCost:200, mortgage:160 },
|
||||||
|
{ type:'railroad', name:'Short Line Railroad', price:200, mortgage:100 },
|
||||||
|
{ type:'chance', name:'Chance' },
|
||||||
|
{ type:'property', name:'Park Place', group:'darkblue',price:350, rent:[35,70,175,500,1100,1300,1500], houseCost:200, mortgage:175 },
|
||||||
|
{ type:'tax', name:'Luxury Tax', amount:100 },
|
||||||
|
{ type:'property', name:'Boardwalk', group:'darkblue',price:400, rent:[50,100,200,600,1400,1700,2000], houseCost:200, mortgage:200 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const RAILROADS = [5, 15, 25, 35];
|
||||||
|
export const UTILITIES = [12, 28];
|
||||||
|
export const PURCHASABLE = SPACES.reduce((a,sp,i) =>
|
||||||
|
(sp.type==='property'||sp.type==='railroad'||sp.type==='utility') ? [...a,i] : a, []);
|
||||||
|
|
||||||
|
export const GROUPS = {
|
||||||
|
brown: [1, 3],
|
||||||
|
lightblue:[6, 8, 9],
|
||||||
|
pink: [11, 13, 14],
|
||||||
|
orange: [16, 18, 19],
|
||||||
|
red: [21, 23, 24],
|
||||||
|
yellow: [26, 27, 29],
|
||||||
|
green: [31, 32, 34],
|
||||||
|
darkblue: [37, 39],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PLAYER_COLORS = [0xE53935, 0x1565C0, 0x2E7D32, 0xF57F17];
|
||||||
|
export const PLAYER_COLOR_HEX = ['#E53935', '#1565C0', '#2E7D32', '#F57F17'];
|
||||||
|
|
||||||
|
// Chance cards (16)
|
||||||
|
export const CHANCE_CARDS = [
|
||||||
|
{ text:'Advance to GO.\nCollect $200.', effect:'advance_to', target:0 },
|
||||||
|
{ text:'Advance to Illinois Ave.\nIf you pass GO, collect $200.', effect:'advance_to', target:24 },
|
||||||
|
{ text:'Advance to St. Charles Place.\nIf you pass GO, collect $200.', effect:'advance_to', target:11 },
|
||||||
|
{ text:'Advance to the nearest Railroad.\nIf unowned, you may buy it.\nIf owned, pay double rent.', effect:'nearest_railroad', doubleRent:true },
|
||||||
|
{ text:'Advance to the nearest Railroad.\nIf unowned, you may buy it.\nIf owned, pay double rent.', effect:'nearest_railroad', doubleRent:true },
|
||||||
|
{ text:'Advance to the nearest Utility.\nIf unowned, you may buy it.\nIf owned, pay 10× your dice.', effect:'nearest_utility' },
|
||||||
|
{ text:'Bank pays you a dividend of $50.', effect:'collect', amount:50 },
|
||||||
|
{ text:'Get Out of Jail Free.\nKeep this card until needed.', effect:'goojf' },
|
||||||
|
{ text:'Go back 3 spaces.', effect:'back3' },
|
||||||
|
{ text:'Go directly to Jail.\nDo not pass GO. Do not collect $200.', effect:'go_to_jail' },
|
||||||
|
{ text:'Make general repairs on all your property.\n$25 per house · $100 per hotel.', effect:'repairs', house:25, hotel:100 },
|
||||||
|
{ text:'Pay a poor tax of $15.', effect:'pay', amount:15 },
|
||||||
|
{ text:'Take a trip to Reading Railroad.\nIf you pass GO, collect $200.', effect:'advance_to', target:5 },
|
||||||
|
{ text:'Advance to Boardwalk.', effect:'advance_to', target:39 },
|
||||||
|
{ text:'You have been elected Chairman of the Board.\nPay each player $50.', effect:'pay_each', amount:50 },
|
||||||
|
{ text:'Your building and loan matures.\nCollect $150.', effect:'collect', amount:150 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Community Chest cards (16)
|
||||||
|
export const CC_CARDS = [
|
||||||
|
{ text:'Advance to GO.\nCollect $200.', effect:'advance_to', target:0 },
|
||||||
|
{ text:'Bank error in your favor.\nCollect $200.', effect:'collect', amount:200 },
|
||||||
|
{ text:"Doctor's fee.\nPay $50.", effect:'pay', amount:50 },
|
||||||
|
{ text:'From sale of stock you get $50.', effect:'collect', amount:50 },
|
||||||
|
{ text:'Get Out of Jail Free.\nKeep this card until needed.', effect:'goojf' },
|
||||||
|
{ text:'Go directly to Jail.\nDo not pass GO. Do not collect $200.', effect:'go_to_jail' },
|
||||||
|
{ text:'Grand Opera Night.\nCollect $50 from every player.', effect:'collect_each', amount:50 },
|
||||||
|
{ text:'Holiday fund matures.\nCollect $100.', effect:'collect', amount:100 },
|
||||||
|
{ text:'Income tax refund.\nCollect $20.', effect:'collect', amount:20 },
|
||||||
|
{ text:'It is your birthday!\nCollect $10 from every player.', effect:'collect_each', amount:10 },
|
||||||
|
{ text:'Life insurance matures.\nCollect $100.', effect:'collect', amount:100 },
|
||||||
|
{ text:'Pay hospital fees of $100.', effect:'pay', amount:100 },
|
||||||
|
{ text:'Pay school fees of $150.', effect:'pay', amount:150 },
|
||||||
|
{ text:'Receive $25 consultancy fee.', effect:'collect', amount:25 },
|
||||||
|
{ text:'You are assessed for street repairs.\n$40 per house · $115 per hotel.', effect:'repairs', house:40, hotel:115 },
|
||||||
|
{ text:'You have won second prize in a beauty contest.\nCollect $10.', effect:'collect', amount:10 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Board-relative space geometry (add boardLeft, boardTop in scene)
|
||||||
|
export function spaceGeometry(idx) {
|
||||||
|
const C = CORNER_SIZE, W = SPACE_W, S = BOARD_SIZE;
|
||||||
|
if (idx === 0) return { x:S-C, y:S-C, w:C, h:C, isCorner:true };
|
||||||
|
if (idx === 10) return { x:0, y:S-C, w:C, h:C, isCorner:true };
|
||||||
|
if (idx === 20) return { x:0, y:0, w:C, h:C, isCorner:true };
|
||||||
|
if (idx === 30) return { x:S-C, y:0, w:C, h:C, isCorner:true };
|
||||||
|
if (idx >= 1 && idx <= 9) return { x:S-C-W*idx, y:S-C, w:W, h:C, bandEdge:'top', rotation:0 };
|
||||||
|
if (idx >= 11 && idx <= 19) return { x:0, y:S-C-W*(idx-10), w:C, h:W, bandEdge:'right', rotation:-Math.PI/2 };
|
||||||
|
if (idx >= 21 && idx <= 29) return { x:C+W*(idx-21), y:0, w:W, h:C, bandEdge:'bottom', rotation:Math.PI };
|
||||||
|
if (idx >= 31 && idx <= 39) return { x:S-C, y:C+W*(idx-31),w:C, h:W, bandEdge:'left', rotation:Math.PI/2 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function spaceCenter(idx) {
|
||||||
|
const g = spaceGeometry(idx);
|
||||||
|
return { x: g.x + g.w / 2, y: g.y + g.h / 2 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nearestRailroad(pos) {
|
||||||
|
for (const r of RAILROADS) if (r > pos) return r;
|
||||||
|
return RAILROADS[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nearestUtility(pos) {
|
||||||
|
for (const u of UTILITIES) if (u > pos) return u;
|
||||||
|
return UTILITIES[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pawn spritesheet: frame = seat index (0-3), 80×80 px cells
|
||||||
|
export const PAWN_FRAME = (seat) => seat;
|
||||||
|
// Card spritesheet: frame 0 = Chance, frame 1 = Community Chest, 200×300 px cells
|
||||||
|
export const CARD_FRAME = { chance: 0, community_chest: 1 };
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,721 @@
|
||||||
|
// Monopoly — pure state engine. No Phaser, no rendering.
|
||||||
|
// All mutators deep-clone and return a fresh state.
|
||||||
|
|
||||||
|
import {
|
||||||
|
SPACES, RAILROADS, UTILITIES, PURCHASABLE, GROUPS, GROUP_COLORS,
|
||||||
|
CHANCE_CARDS, CC_CARDS, nearestRailroad, nearestUtility,
|
||||||
|
} from './MonopolyData.js';
|
||||||
|
|
||||||
|
// ── RNG ───────────────────────────────────────────────────────────────────────
|
||||||
|
function rngFrom(seed) {
|
||||||
|
let a = (seed >>> 0) || 1;
|
||||||
|
return () => {
|
||||||
|
a |= 0; a = (a + 0x6D2B79F5) | 0;
|
||||||
|
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||||
|
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function shuffle(arr, rng) {
|
||||||
|
const a = arr.slice();
|
||||||
|
for (let i = a.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(rng() * (i + 1));
|
||||||
|
[a[i], a[j]] = [a[j], a[i]];
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clone = (s) => JSON.parse(JSON.stringify(s));
|
||||||
|
|
||||||
|
// ── State ─────────────────────────────────────────────────────────────────────
|
||||||
|
export function createInitialState({ playerCount, names, seed = Date.now() }) {
|
||||||
|
const rng = rngFrom(seed);
|
||||||
|
const board = {};
|
||||||
|
for (const idx of PURCHASABLE) board[idx] = { owner: null, houses: 0, hotel: false, mortgaged: false };
|
||||||
|
|
||||||
|
return {
|
||||||
|
playerCount,
|
||||||
|
seed,
|
||||||
|
phase: 'preroll',
|
||||||
|
current: 0,
|
||||||
|
doublesCount: 0,
|
||||||
|
diceRoll: null,
|
||||||
|
houseSupply: 32,
|
||||||
|
hotelSupply: 12,
|
||||||
|
players: Array.from({ length: playerCount }, (_, seat) => ({
|
||||||
|
seat,
|
||||||
|
name: names[seat] ?? `Player ${seat + 1}`,
|
||||||
|
cash: 1500,
|
||||||
|
position: 0,
|
||||||
|
jailed: false,
|
||||||
|
jailTurns: 0,
|
||||||
|
getOutOfJailFree: 0,
|
||||||
|
bankrupt: false,
|
||||||
|
active: true,
|
||||||
|
})),
|
||||||
|
board,
|
||||||
|
chanceOrder: shuffle(CHANCE_CARDS.map((_, i) => i), rng),
|
||||||
|
chanceIdx: 0,
|
||||||
|
ccOrder: shuffle(CC_CARDS.map((_, i) => i), rng),
|
||||||
|
ccIdx: 0,
|
||||||
|
pendingCard: null,
|
||||||
|
pendingBuy: null,
|
||||||
|
pendingAuction: null,
|
||||||
|
winner: null,
|
||||||
|
log: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
function log(s, msg) { s.log = [msg, ...s.log.slice(0, 49)]; }
|
||||||
|
|
||||||
|
export function ownsGroup(state, seat, group) {
|
||||||
|
const indices = GROUPS[group];
|
||||||
|
if (!indices) return false;
|
||||||
|
return indices.every(i => state.board[i]?.owner === seat);
|
||||||
|
}
|
||||||
|
|
||||||
|
function railroadsOwned(state, seat) {
|
||||||
|
return RAILROADS.filter(i => state.board[i]?.owner === seat).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function utilitiesOwned(state, seat) {
|
||||||
|
return UTILITIES.filter(i => state.board[i]?.owner === seat).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateRent(state, spaceIdx, diceTotal, forceUtilityMult = null) {
|
||||||
|
const sp = SPACES[spaceIdx];
|
||||||
|
const own = state.board[spaceIdx];
|
||||||
|
if (!own || own.owner === null || own.mortgaged) return 0;
|
||||||
|
const { owner, houses, hotel } = own;
|
||||||
|
|
||||||
|
if (sp.type === 'property') {
|
||||||
|
let idx;
|
||||||
|
if (hotel) idx = 6;
|
||||||
|
else if (houses>0) idx = houses + 1;
|
||||||
|
else if (ownsGroup(state, owner, sp.group)) idx = 1;
|
||||||
|
else idx = 0;
|
||||||
|
return sp.rent[idx];
|
||||||
|
}
|
||||||
|
if (sp.type === 'railroad') {
|
||||||
|
const cnt = railroadsOwned(state, owner);
|
||||||
|
return 25 * Math.pow(2, cnt - 1);
|
||||||
|
}
|
||||||
|
if (sp.type === 'utility') {
|
||||||
|
const mult = forceUtilityMult ?? (utilitiesOwned(state, owner) === 2 ? 10 : 4);
|
||||||
|
return mult * diceTotal;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function netWorth(state, seat) {
|
||||||
|
const p = state.players[seat];
|
||||||
|
let w = p.cash;
|
||||||
|
for (const idx of PURCHASABLE) {
|
||||||
|
const own = state.board[idx];
|
||||||
|
if (own?.owner !== seat) continue;
|
||||||
|
const sp = SPACES[idx];
|
||||||
|
w += sp.mortgage ?? 0; // half price
|
||||||
|
if (!own.mortgaged) {
|
||||||
|
if (sp.type === 'property') {
|
||||||
|
w += own.houses * (sp.houseCost / 2);
|
||||||
|
if (own.hotel) w += sp.houseCost * 2; // hotel = 4 houses → sell back half
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return w;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-liquidate houses and mortgage properties to cover a debt.
|
||||||
|
// Returns new state; sets player bankrupt if still can't pay.
|
||||||
|
function liquidate(state, seat, creditorSeat) {
|
||||||
|
const s = clone(state);
|
||||||
|
const p = s.players[seat];
|
||||||
|
|
||||||
|
// Sell houses first (even selling), then hotels
|
||||||
|
let progress = true;
|
||||||
|
while (p.cash < 0 && progress) {
|
||||||
|
progress = false;
|
||||||
|
for (const idx of PURCHASABLE) {
|
||||||
|
if (p.cash >= 0) break;
|
||||||
|
const own = s.board[idx];
|
||||||
|
if (own?.owner !== seat) continue;
|
||||||
|
const sp = SPACES[idx];
|
||||||
|
if (own.hotel) {
|
||||||
|
own.hotel = false;
|
||||||
|
own.houses = 4; // hotels become 4 houses
|
||||||
|
p.cash += sp.houseCost; // sell hotel back at half
|
||||||
|
s.hotelSupply++;
|
||||||
|
progress = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const idx of PURCHASABLE) {
|
||||||
|
if (p.cash >= 0) break;
|
||||||
|
const own = s.board[idx];
|
||||||
|
if (own?.owner !== seat || own.houses === 0) continue;
|
||||||
|
const sp = SPACES[idx];
|
||||||
|
own.houses--;
|
||||||
|
p.cash += sp.houseCost / 2;
|
||||||
|
s.houseSupply++;
|
||||||
|
progress = true;
|
||||||
|
}
|
||||||
|
// Mortgage un-mortgaged properties
|
||||||
|
for (const idx of PURCHASABLE) {
|
||||||
|
if (p.cash >= 0) break;
|
||||||
|
const own = s.board[idx];
|
||||||
|
if (own?.owner !== seat || own.mortgaged || own.houses > 0 || own.hotel) continue;
|
||||||
|
const sp = SPACES[idx];
|
||||||
|
own.mortgaged = true;
|
||||||
|
p.cash += sp.mortgage;
|
||||||
|
progress = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p.cash < 0) {
|
||||||
|
// Bankrupt: transfer remaining properties to creditor (or bank if creditorSeat null)
|
||||||
|
for (const idx of PURCHASABLE) {
|
||||||
|
const own = s.board[idx];
|
||||||
|
if (own?.owner !== seat) continue;
|
||||||
|
if (creditorSeat !== null && creditorSeat !== undefined) {
|
||||||
|
own.owner = creditorSeat;
|
||||||
|
own.mortgaged = true; // creditor inherits mortgaged
|
||||||
|
own.houses = 0; own.hotel = false;
|
||||||
|
} else {
|
||||||
|
own.owner = null;
|
||||||
|
own.houses = 0; own.hotel = false;
|
||||||
|
own.mortgaged = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (creditorSeat !== null && creditorSeat !== undefined) {
|
||||||
|
s.players[creditorSeat].cash += Math.max(0, p.cash + p.cash); // whatever was left (≤0, so skip)
|
||||||
|
}
|
||||||
|
p.cash = 0;
|
||||||
|
p.bankrupt = true;
|
||||||
|
p.active = false;
|
||||||
|
log(s, `${p.name} is bankrupt and out of the game.`);
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function payTo(state, payerSeat, receiverSeat, amount) {
|
||||||
|
const s = clone(state);
|
||||||
|
s.players[payerSeat].cash -= amount;
|
||||||
|
if (receiverSeat !== null && receiverSeat !== undefined) {
|
||||||
|
s.players[receiverSeat].cash += amount;
|
||||||
|
}
|
||||||
|
if (s.players[payerSeat].cash < 0) {
|
||||||
|
return liquidate(s, payerSeat, receiverSeat);
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkGameOver(state) {
|
||||||
|
const s = clone(state);
|
||||||
|
const active = s.players.filter(p => p.active);
|
||||||
|
if (active.length <= 1) {
|
||||||
|
s.phase = 'gameover';
|
||||||
|
s.winner = active[0]?.seat ?? null;
|
||||||
|
if (s.winner !== null) log(s, `${s.players[s.winner].name} wins!`);
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Roll & Move ───────────────────────────────────────────────────────────────
|
||||||
|
export function rollDice(state, seat, d1, d2) {
|
||||||
|
const s = clone(state);
|
||||||
|
s.diceRoll = [d1, d2];
|
||||||
|
const p = s.players[seat];
|
||||||
|
const isDoubles = d1 === d2;
|
||||||
|
|
||||||
|
if (p.jailed) {
|
||||||
|
if (isDoubles) {
|
||||||
|
p.jailed = false;
|
||||||
|
p.jailTurns = 0;
|
||||||
|
const newPos = (p.position + d1 + d2) % 40;
|
||||||
|
p.position = newPos;
|
||||||
|
// Don't get another turn from jail doubles
|
||||||
|
s.doublesCount = 0;
|
||||||
|
log(s, `${p.name} rolls doubles and escapes jail!`);
|
||||||
|
} else {
|
||||||
|
p.jailTurns++;
|
||||||
|
if (p.jailTurns >= 3) {
|
||||||
|
p.cash -= 50;
|
||||||
|
if (p.cash < 0) { const ls = liquidate(s, seat, null); Object.assign(s, ls); }
|
||||||
|
p.jailed = false;
|
||||||
|
p.jailTurns = 0;
|
||||||
|
const newPos = (p.position + d1 + d2) % 40;
|
||||||
|
s.players[seat].position = newPos;
|
||||||
|
log(s, `${p.name} pays $50 to leave jail after 3 turns.`);
|
||||||
|
} else {
|
||||||
|
log(s, `${p.name} fails to roll doubles in jail (turn ${p.jailTurns}).`);
|
||||||
|
s.phase = 'endturn';
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (isDoubles) {
|
||||||
|
s.doublesCount++;
|
||||||
|
if (s.doublesCount >= 3) {
|
||||||
|
// Three doubles: go to jail
|
||||||
|
p.position = 10;
|
||||||
|
p.jailed = true;
|
||||||
|
p.jailTurns = 0;
|
||||||
|
s.doublesCount = 0;
|
||||||
|
log(s, `${p.name} rolls 3 doubles in a row — Go to Jail!`);
|
||||||
|
s.phase = 'endturn';
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
s.doublesCount = 0;
|
||||||
|
}
|
||||||
|
const oldPos = p.position;
|
||||||
|
const newPos = (oldPos + d1 + d2) % 40;
|
||||||
|
p.position = newPos;
|
||||||
|
if (newPos < oldPos || (oldPos === 0 && newPos > 0)) {
|
||||||
|
p.cash += 200;
|
||||||
|
log(s, `${p.name} passes GO, collects $200.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolveSpace(s, seat);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Space Resolution ──────────────────────────────────────────────────────────
|
||||||
|
export function resolveSpace(state, seat) {
|
||||||
|
const s = clone(state);
|
||||||
|
const p = s.players[seat];
|
||||||
|
const spIdx = p.position;
|
||||||
|
const sp = SPACES[spIdx];
|
||||||
|
|
||||||
|
switch (sp.type) {
|
||||||
|
case 'go':
|
||||||
|
s.phase = 'endturn';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'property':
|
||||||
|
case 'railroad':
|
||||||
|
case 'utility': {
|
||||||
|
const own = s.board[spIdx];
|
||||||
|
if (!own || own.owner === null) {
|
||||||
|
s.pendingBuy = { spaceIdx: spIdx };
|
||||||
|
s.phase = 'buy';
|
||||||
|
} else if (own.owner === seat) {
|
||||||
|
log(s, `${p.name} owns this property.`);
|
||||||
|
s.phase = 'endturn';
|
||||||
|
} else if (own.mortgaged) {
|
||||||
|
log(s, `${SPACES[spIdx].name} is mortgaged — no rent.`);
|
||||||
|
s.phase = 'endturn';
|
||||||
|
} else {
|
||||||
|
const dice = s.diceRoll[0] + s.diceRoll[1];
|
||||||
|
const rent = calculateRent(s, spIdx, dice);
|
||||||
|
const result = payTo(s, seat, own.owner, rent);
|
||||||
|
Object.assign(s, result);
|
||||||
|
log(s, `${p.name} pays $${rent} rent to ${s.players[own.owner].name}.`);
|
||||||
|
s.phase = 'endturn';
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'tax': {
|
||||||
|
const result = payTo(s, seat, null, sp.amount);
|
||||||
|
Object.assign(s, result);
|
||||||
|
log(s, `${p.name} pays ${sp.name} ($${sp.amount}).`);
|
||||||
|
s.phase = 'endturn';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'chance': {
|
||||||
|
const cardIdx = s.chanceOrder[s.chanceIdx % s.chanceOrder.length];
|
||||||
|
s.chanceIdx++;
|
||||||
|
const card = CHANCE_CARDS[cardIdx];
|
||||||
|
s.pendingCard = { cardType: 'chance', cardIdx, text: card.text, effect: card };
|
||||||
|
s.phase = 'card';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'community_chest': {
|
||||||
|
const cardIdx = s.ccOrder[s.ccIdx % s.ccOrder.length];
|
||||||
|
s.ccIdx++;
|
||||||
|
const card = CC_CARDS[cardIdx];
|
||||||
|
s.pendingCard = { cardType: 'community_chest', cardIdx, text: card.text, effect: card };
|
||||||
|
s.phase = 'card';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'gotojail':
|
||||||
|
p.position = 10;
|
||||||
|
p.jailed = true;
|
||||||
|
p.jailTurns = 0;
|
||||||
|
s.doublesCount = 0;
|
||||||
|
log(s, `${p.name} is sent to Jail!`);
|
||||||
|
s.phase = 'endturn';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'jail':
|
||||||
|
case 'freeparking':
|
||||||
|
default:
|
||||||
|
s.phase = 'endturn';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Card Effects ──────────────────────────────────────────────────────────────
|
||||||
|
export function applyCardEffect(state, seat) {
|
||||||
|
let s = clone(state);
|
||||||
|
const p = s.players[seat];
|
||||||
|
const card = s.pendingCard?.effect;
|
||||||
|
if (!card) { s.pendingCard = null; s.phase = 'endturn'; return s; }
|
||||||
|
|
||||||
|
switch (card.effect) {
|
||||||
|
case 'advance_to': {
|
||||||
|
const target = card.target;
|
||||||
|
const old = p.position;
|
||||||
|
p.position = target;
|
||||||
|
if (target <= old && target !== old) {
|
||||||
|
p.cash += 200;
|
||||||
|
log(s, `${p.name} passes GO, collects $200.`);
|
||||||
|
}
|
||||||
|
s.pendingCard = null;
|
||||||
|
s = resolveSpace(s, seat);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'nearest_railroad': {
|
||||||
|
const target = nearestRailroad(p.position);
|
||||||
|
const old = p.position;
|
||||||
|
p.position = target;
|
||||||
|
if (target <= old) { p.cash += 200; log(s, `${p.name} passes GO, collects $200.`); }
|
||||||
|
s.pendingCard = null;
|
||||||
|
const own = s.board[target];
|
||||||
|
if (!own || own.owner === null) {
|
||||||
|
s.pendingBuy = { spaceIdx: target };
|
||||||
|
s.phase = 'buy';
|
||||||
|
} else if (own.owner !== seat && !own.mortgaged) {
|
||||||
|
const cnt = railroadsOwned(s, own.owner);
|
||||||
|
const rent = 25 * Math.pow(2, cnt - 1) * (card.doubleRent ? 2 : 1);
|
||||||
|
const result = payTo(s, seat, own.owner, rent);
|
||||||
|
Object.assign(s, result);
|
||||||
|
log(s, `${p.name} pays $${rent} (double) railroad rent.`);
|
||||||
|
s.phase = 'endturn';
|
||||||
|
} else {
|
||||||
|
s.phase = 'endturn';
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'nearest_utility': {
|
||||||
|
const target = nearestUtility(p.position);
|
||||||
|
const old = p.position;
|
||||||
|
p.position = target;
|
||||||
|
if (target <= old) { p.cash += 200; log(s, `${p.name} passes GO, collects $200.`); }
|
||||||
|
s.pendingCard = null;
|
||||||
|
const own = s.board[target];
|
||||||
|
if (!own || own.owner === null) {
|
||||||
|
s.pendingBuy = { spaceIdx: target };
|
||||||
|
s.phase = 'buy';
|
||||||
|
} else if (own.owner !== seat && !own.mortgaged) {
|
||||||
|
const dice = s.diceRoll[0] + s.diceRoll[1];
|
||||||
|
const rent = 10 * dice;
|
||||||
|
const result = payTo(s, seat, own.owner, rent);
|
||||||
|
Object.assign(s, result);
|
||||||
|
log(s, `${p.name} pays $${rent} (10× dice) utility rent.`);
|
||||||
|
s.phase = 'endturn';
|
||||||
|
} else {
|
||||||
|
s.phase = 'endturn';
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'collect':
|
||||||
|
p.cash += card.amount;
|
||||||
|
log(s, `${p.name} collects $${card.amount}.`);
|
||||||
|
s.pendingCard = null;
|
||||||
|
s.phase = 'endturn';
|
||||||
|
break;
|
||||||
|
case 'pay': {
|
||||||
|
const result = payTo(s, seat, null, card.amount);
|
||||||
|
Object.assign(s, result);
|
||||||
|
log(s, `${p.name} pays $${card.amount}.`);
|
||||||
|
s.pendingCard = null;
|
||||||
|
s.phase = 'endturn';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'goojf':
|
||||||
|
p.getOutOfJailFree++;
|
||||||
|
log(s, `${p.name} receives a Get Out of Jail Free card.`);
|
||||||
|
s.pendingCard = null;
|
||||||
|
s.phase = 'endturn';
|
||||||
|
break;
|
||||||
|
case 'go_to_jail':
|
||||||
|
p.position = 10;
|
||||||
|
p.jailed = true;
|
||||||
|
p.jailTurns = 0;
|
||||||
|
s.doublesCount = 0;
|
||||||
|
log(s, `${p.name} goes to Jail!`);
|
||||||
|
s.pendingCard = null;
|
||||||
|
s.phase = 'endturn';
|
||||||
|
break;
|
||||||
|
case 'back3': {
|
||||||
|
p.position = (p.position - 3 + 40) % 40;
|
||||||
|
s.pendingCard = null;
|
||||||
|
s = resolveSpace(s, seat);
|
||||||
|
// Don't award Go on back3
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'repairs': {
|
||||||
|
let total = 0;
|
||||||
|
for (const idx of PURCHASABLE) {
|
||||||
|
const own = s.board[idx];
|
||||||
|
if (own?.owner !== seat) continue;
|
||||||
|
total += own.houses * card.house;
|
||||||
|
if (own.hotel) total += card.hotel;
|
||||||
|
}
|
||||||
|
if (total > 0) {
|
||||||
|
const result = payTo(s, seat, null, total);
|
||||||
|
Object.assign(s, result);
|
||||||
|
log(s, `${p.name} pays $${total} for repairs.`);
|
||||||
|
}
|
||||||
|
s.pendingCard = null;
|
||||||
|
s.phase = 'endturn';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'pay_each': {
|
||||||
|
let paid = 0;
|
||||||
|
for (const other of s.players) {
|
||||||
|
if (!other.active || other.seat === seat) continue;
|
||||||
|
const amount = Math.min(card.amount, s.players[seat].cash);
|
||||||
|
s.players[seat].cash -= amount;
|
||||||
|
other.cash += amount;
|
||||||
|
paid += amount;
|
||||||
|
}
|
||||||
|
log(s, `${p.name} pays $${card.amount} to each player.`);
|
||||||
|
s.pendingCard = null;
|
||||||
|
s.phase = 'endturn';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'collect_each': {
|
||||||
|
for (const other of s.players) {
|
||||||
|
if (!other.active || other.seat === seat) continue;
|
||||||
|
const amount = Math.min(card.amount, other.cash);
|
||||||
|
other.cash -= amount;
|
||||||
|
s.players[seat].cash += amount;
|
||||||
|
}
|
||||||
|
log(s, `${p.name} collects $${card.amount} from each player.`);
|
||||||
|
s.pendingCard = null;
|
||||||
|
s.phase = 'endturn';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
s.pendingCard = null;
|
||||||
|
s.phase = 'endturn';
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Property Purchase ─────────────────────────────────────────────────────────
|
||||||
|
export function buyProperty(state, seat) {
|
||||||
|
const s = clone(state);
|
||||||
|
const { spaceIdx } = s.pendingBuy;
|
||||||
|
const sp = SPACES[spaceIdx];
|
||||||
|
s.players[seat].cash -= sp.price;
|
||||||
|
s.board[spaceIdx].owner = seat;
|
||||||
|
log(s, `${s.players[seat].name} buys ${sp.name} for $${sp.price}.`);
|
||||||
|
s.pendingBuy = null;
|
||||||
|
s.phase = s.doublesCount > 0 ? 'preroll' : 'endturn';
|
||||||
|
return checkGameOver(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function declineProperty(state, seat) {
|
||||||
|
return startAuction(state, state.pendingBuy.spaceIdx);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Auction ───────────────────────────────────────────────────────────────────
|
||||||
|
function startAuction(state, spaceIdx) {
|
||||||
|
const s = clone(state);
|
||||||
|
const bidOrder = [];
|
||||||
|
for (let i = 0; i < s.playerCount; i++) {
|
||||||
|
const seat = (s.current + i) % s.playerCount;
|
||||||
|
if (s.players[seat].active) bidOrder.push(seat);
|
||||||
|
}
|
||||||
|
s.pendingBuy = null;
|
||||||
|
s.pendingAuction = { spaceIdx, bidOrder, currentBidderIdx: 0, bids: {}, highBid: 0, highBidder: null };
|
||||||
|
s.phase = 'auction';
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function placeBid(state, seat, amount) {
|
||||||
|
const s = clone(state);
|
||||||
|
const auc = s.pendingAuction;
|
||||||
|
if (amount > s.players[seat].cash) amount = s.players[seat].cash;
|
||||||
|
if (amount <= auc.highBid) return passAuction(s, seat);
|
||||||
|
auc.bids[seat] = amount;
|
||||||
|
auc.highBid = amount;
|
||||||
|
auc.highBidder = seat;
|
||||||
|
auc.currentBidderIdx = (auc.currentBidderIdx + 1) % auc.bidOrder.length;
|
||||||
|
// Skip to next active bidder who hasn't committed to their max
|
||||||
|
return advanceAuction(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function passAuction(state, seat) {
|
||||||
|
const s = clone(state);
|
||||||
|
const auc = s.pendingAuction;
|
||||||
|
auc.bidOrder = auc.bidOrder.filter(bs => bs !== seat);
|
||||||
|
if (auc.currentBidderIdx >= auc.bidOrder.length) auc.currentBidderIdx = 0;
|
||||||
|
return advanceAuction(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
function advanceAuction(state) {
|
||||||
|
const s = clone(state);
|
||||||
|
const auc = s.pendingAuction;
|
||||||
|
if (auc.bidOrder.length === 0 || (auc.bidOrder.length === 1 && auc.highBidder === auc.bidOrder[0])) {
|
||||||
|
return resolveAuction(s);
|
||||||
|
}
|
||||||
|
if (auc.bidOrder.length === 1 && auc.highBidder === null) {
|
||||||
|
return resolveAuction(s); // no one bid
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAuction(state) {
|
||||||
|
const s = clone(state);
|
||||||
|
const auc = s.pendingAuction;
|
||||||
|
if (auc.highBidder !== null && auc.highBid > 0) {
|
||||||
|
s.players[auc.highBidder].cash -= auc.highBid;
|
||||||
|
s.board[auc.spaceIdx].owner = auc.highBidder;
|
||||||
|
log(s, `${s.players[auc.highBidder].name} wins auction for ${SPACES[auc.spaceIdx].name} at $${auc.highBid}.`);
|
||||||
|
} else {
|
||||||
|
log(s, `No one bid on ${SPACES[auc.spaceIdx].name} — it stays in the bank.`);
|
||||||
|
}
|
||||||
|
s.pendingAuction = null;
|
||||||
|
s.phase = s.doublesCount > 0 ? 'preroll' : 'endturn';
|
||||||
|
return checkGameOver(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Building ──────────────────────────────────────────────────────────────────
|
||||||
|
export function canBuildHouse(state, seat, spaceIdx) {
|
||||||
|
const sp = SPACES[spaceIdx];
|
||||||
|
const own = state.board[spaceIdx];
|
||||||
|
if (!own || own.owner !== seat || own.mortgaged || own.hotel) return false;
|
||||||
|
if (sp.type !== 'property') return false;
|
||||||
|
if (!ownsGroup(state, seat, sp.group)) return false;
|
||||||
|
if (state.houseSupply <= 0) return false;
|
||||||
|
// Even building: can't have more than 1 house more than any other in group
|
||||||
|
const groupMin = Math.min(...GROUPS[sp.group].map(i => state.board[i]?.houses ?? 0));
|
||||||
|
return own.houses <= groupMin && own.houses < 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildHouse(state, seat, spaceIdx) {
|
||||||
|
const s = clone(state);
|
||||||
|
const sp = SPACES[spaceIdx];
|
||||||
|
s.players[seat].cash -= sp.houseCost;
|
||||||
|
s.board[spaceIdx].houses++;
|
||||||
|
s.houseSupply--;
|
||||||
|
log(s, `${s.players[seat].name} builds a house on ${sp.name}.`);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canBuildHotel(state, seat, spaceIdx) {
|
||||||
|
const sp = SPACES[spaceIdx];
|
||||||
|
const own = state.board[spaceIdx];
|
||||||
|
if (!own || own.owner !== seat || own.mortgaged || own.hotel) return false;
|
||||||
|
if (sp.type !== 'property') return false;
|
||||||
|
if (!ownsGroup(state, seat, sp.group)) return false;
|
||||||
|
if (state.hotelSupply <= 0) return false;
|
||||||
|
// All properties in group must have 4 houses
|
||||||
|
return GROUPS[sp.group].every(i => state.board[i]?.houses === 4 || state.board[i]?.hotel);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildHotel(state, seat, spaceIdx) {
|
||||||
|
const s = clone(state);
|
||||||
|
const sp = SPACES[spaceIdx];
|
||||||
|
s.players[seat].cash -= sp.houseCost;
|
||||||
|
s.board[spaceIdx].houses = 0;
|
||||||
|
s.board[spaceIdx].hotel = true;
|
||||||
|
s.houseSupply += 4; // return the 4 houses
|
||||||
|
s.hotelSupply--;
|
||||||
|
log(s, `${s.players[seat].name} builds a hotel on ${sp.name}.`);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sellHouse(state, seat, spaceIdx) {
|
||||||
|
const s = clone(state);
|
||||||
|
const sp = SPACES[spaceIdx];
|
||||||
|
s.board[spaceIdx].houses--;
|
||||||
|
s.players[seat].cash += Math.floor(sp.houseCost / 2);
|
||||||
|
s.houseSupply++;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sellHotel(state, seat, spaceIdx) {
|
||||||
|
const s = clone(state);
|
||||||
|
const sp = SPACES[spaceIdx];
|
||||||
|
s.board[spaceIdx].hotel = false;
|
||||||
|
s.board[spaceIdx].houses = 0;
|
||||||
|
s.players[seat].cash += Math.floor(sp.houseCost / 2);
|
||||||
|
s.hotelSupply++;
|
||||||
|
s.houseSupply -= 4;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mortgageProperty(state, seat, spaceIdx) {
|
||||||
|
const s = clone(state);
|
||||||
|
const sp = SPACES[spaceIdx];
|
||||||
|
const own = s.board[spaceIdx];
|
||||||
|
if (own.houses > 0 || own.hotel) return s; // must sell buildings first
|
||||||
|
own.mortgaged = true;
|
||||||
|
s.players[seat].cash += sp.mortgage;
|
||||||
|
log(s, `${s.players[seat].name} mortgages ${sp.name} for $${sp.mortgage}.`);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unmortgageProperty(state, seat, spaceIdx) {
|
||||||
|
const s = clone(state);
|
||||||
|
const sp = SPACES[spaceIdx];
|
||||||
|
const cost = Math.ceil(sp.mortgage * 1.1);
|
||||||
|
if (s.players[seat].cash < cost) return s;
|
||||||
|
s.board[spaceIdx].mortgaged = false;
|
||||||
|
s.players[seat].cash -= cost;
|
||||||
|
log(s, `${s.players[seat].name} unmortgages ${sp.name} for $${cost}.`);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Jail ──────────────────────────────────────────────────────────────────────
|
||||||
|
export function payJailFine(state, seat) {
|
||||||
|
const s = clone(state);
|
||||||
|
s.players[seat].cash -= 50;
|
||||||
|
s.players[seat].jailed = false;
|
||||||
|
s.players[seat].jailTurns = 0;
|
||||||
|
log(s, `${s.players[seat].name} pays $50 to get out of jail.`);
|
||||||
|
s.phase = 'preroll';
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useJailCard(state, seat) {
|
||||||
|
const s = clone(state);
|
||||||
|
s.players[seat].getOutOfJailFree--;
|
||||||
|
s.players[seat].jailed = false;
|
||||||
|
s.players[seat].jailTurns = 0;
|
||||||
|
log(s, `${s.players[seat].name} uses a Get Out of Jail Free card.`);
|
||||||
|
s.phase = 'preroll';
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Turn ──────────────────────────────────────────────────────────────────────
|
||||||
|
export function endTurn(state) {
|
||||||
|
const s = clone(state);
|
||||||
|
// If doubles were rolled and player isn't jailed: same player rolls again
|
||||||
|
if (s.doublesCount > 0 && !s.players[s.current].jailed) {
|
||||||
|
s.phase = 'preroll';
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
s.doublesCount = 0;
|
||||||
|
// Advance to next active player
|
||||||
|
let next = (s.current + 1) % s.playerCount;
|
||||||
|
let guard = 0;
|
||||||
|
while (!s.players[next].active && guard++ < s.playerCount) {
|
||||||
|
next = (next + 1) % s.playerCount;
|
||||||
|
}
|
||||||
|
s.current = next;
|
||||||
|
s.diceRoll = null;
|
||||||
|
s.phase = 'preroll';
|
||||||
|
return checkGameOver(s);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,103 @@
|
||||||
|
# Monopoly — Spritesheet Guide
|
||||||
|
|
||||||
|
Three image files need to be created or extended. All dimensions are exact — the engine reads pixel-perfect frame boundaries.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. `assets/images/monopoly-pawns.png`
|
||||||
|
|
||||||
|
**Total sheet size:** 320 × 80 px
|
||||||
|
**Frame size:** 80 × 80 px
|
||||||
|
**Layout:** 4 frames in a single horizontal row
|
||||||
|
**Displayed at:** 32 × 32 px on the board (the image is scaled down, so keep art centered and avoid fine detail near the edges)
|
||||||
|
|
||||||
|
Each frame is a single player token on a transparent background. The token should be centered in the 80 × 80 cell with roughly 8–10 px of breathing room on all sides so it doesn't clip when scaled.
|
||||||
|
|
||||||
|
| Frame | X offset | Player | Color theme | Suggested token |
|
||||||
|
|-------|----------|--------|-------------|-----------------|
|
||||||
|
| 0 | x=0 | Player 1 | Red `#E53935` | Top hat |
|
||||||
|
| 1 | x=80 | Player 2 | Blue `#1565C0` | Racing car |
|
||||||
|
| 2 | x=160 | Player 3 | Green `#2E7D32` | Scottie dog |
|
||||||
|
| 3 | x=240 | Player 4 | Gold `#F57F17` | Battleship |
|
||||||
|
|
||||||
|
### Visual guidance per token
|
||||||
|
|
||||||
|
**Top hat (frame 0 — red):**
|
||||||
|
Iconic tall cylinder hat. Brim at bottom, oval crown at top. Deep red or black body with a red highlight/shadow. Works well as a simple 2-tone silhouette.
|
||||||
|
|
||||||
|
**Racing car (frame 1 — blue):**
|
||||||
|
Classic 1930s open-wheel racer viewed at a slight 3/4 angle. Blue body, yellow or silver wheels. Keep it chunky and readable at 32 × 32 px — avoid thin spokes.
|
||||||
|
|
||||||
|
**Scottie dog (frame 2 — green):**
|
||||||
|
Side-on silhouette of a Scottish Terrier with wiry fur. Body in dark green or black-green; eyes as a small white or yellow dot. The iconic bushy-beard chin and erect tail are the key recognition cues.
|
||||||
|
|
||||||
|
**Battleship (frame 3 — gold):**
|
||||||
|
Top-down or slight 3/4 view of a naval destroyer. Gold hull, dark gray turrets, small white wake lines. Keep it horizontal/landscape within the square cell.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. `assets/images/monopoly-cards.png`
|
||||||
|
|
||||||
|
**Total sheet size:** 400 × 300 px
|
||||||
|
**Frame size:** 200 × 300 px
|
||||||
|
**Layout:** 2 frames side by side in one row
|
||||||
|
**Displayed at:** 340 × 220 px inside the card popup (the engine scales the frame to fit the top area of a 360 × 480 popup)
|
||||||
|
|
||||||
|
The code overlays its own text labels on top — "CHANCE" or "COMMUNITY CHEST" is printed at the top by the engine, and the card's effect text is printed in the lower half. **The image only needs to fill the upper ~220 px of visible space** — think of it as providing the background art and mood, not the text.
|
||||||
|
|
||||||
|
| Frame | X offset | Card type | Background color | Icon |
|
||||||
|
|-------|----------|-----------|------------------|------|
|
||||||
|
| 0 | x=0 | Chance | Orange `#E77A2C` | Large "?" |
|
||||||
|
| 1 | x=200 | Community Chest | Blue `#1565C0` | Treasure chest |
|
||||||
|
|
||||||
|
### Frame 0 — Chance (200 × 300 px)
|
||||||
|
|
||||||
|
- **Background:** Warm orange gradient, darker toward the top and bottom edges
|
||||||
|
- **Border:** A thin ornate gold or cream inner border rect (≈ 8 px from each edge)
|
||||||
|
- **Central icon:** A large bold "?" character, roughly 100 × 140 px, in cream or white with a slight drop shadow. The "?" should be centered horizontally and sit in the lower ⅔ of the frame (the engine prints "CHANCE" at the very top, so leave the top 40–50 px relatively clean)
|
||||||
|
- **Decorative touches (optional):** Small radiating lines or sunburst behind the "?", art deco corner flourishes
|
||||||
|
|
||||||
|
### Frame 1 — Community Chest (200 × 300 px)
|
||||||
|
|
||||||
|
- **Background:** Rich blue gradient, `#1565C0` center fading to `#0D3A6E` at edges
|
||||||
|
- **Border:** Same style ornate inner border as Chance, in gold or cream
|
||||||
|
- **Central icon:** A wooden treasure chest, slightly open, with a warm interior glow or gold coins visible. Roughly 80 × 70 px, centered horizontally, sitting in the lower ⅔ of the frame. Classic Monopoly Community Chest chests are brown/tan with gold hardware
|
||||||
|
- **Decorative touches (optional):** Small star or coin scattered around the chest, "art deco" corner ornaments matching the Chance card style for consistency
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. `assets/images/game-icons.png` — extend existing sheet
|
||||||
|
|
||||||
|
**Current sheet size:** 660 × 660 px
|
||||||
|
**Grid:** 15 columns × 15 rows of 44 × 44 px cells
|
||||||
|
**Current frames:** 0 – 47 (48 icons)
|
||||||
|
**Monopoly frame index:** **48**
|
||||||
|
|
||||||
|
### Where to paint frame 48
|
||||||
|
|
||||||
|
Frame 48 falls at:
|
||||||
|
|
||||||
|
```
|
||||||
|
Column = 48 % 15 = 3 → x = 3 × 44 = 132 px
|
||||||
|
Row = 48 / 15 = 3 → y = 3 × 44 = 132 px
|
||||||
|
```
|
||||||
|
|
||||||
|
**Paint the Monopoly icon into the cell at (132, 132) — a 44 × 44 px region — in your existing game-icons PSD.**
|
||||||
|
|
||||||
|
### Monopoly icon design (44 × 44 px)
|
||||||
|
|
||||||
|
The icon is shown at 44 × 44 px in the game menu next to the game title. Suggested design:
|
||||||
|
|
||||||
|
- **Background:** Deep green `#1F3D1F` or a classic Monopoly board green
|
||||||
|
- **Foreground:** A small white/cream Monopoly board viewed from above — simplified to just the four corner squares (a white square outline with the four corner squares indicated) OR simply the red "M" monogram from the Monopoly logo
|
||||||
|
- **Alternative:** A gold top hat silhouette centered on a dark background — very legible at small size and immediately recognizable
|
||||||
|
|
||||||
|
Keep the art to 2–3 colors at most. At 44 × 44 the icon is tiny; bold shapes with strong contrast read far better than detailed illustrations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] `monopoly-pawns.png` — 4 tokens, 320 × 80 px, transparent background per token
|
||||||
|
- [ ] `monopoly-cards.png` — 2 card arts, 400 × 300 px, no text (engine adds text)
|
||||||
|
- [ ] `game-icons.png` — add Monopoly icon at pixel (132, 132) in the existing 660 × 660 sheet
|
||||||
|
|
@ -58,6 +58,7 @@ import VideoPokerGame from './games/videopoker/VideoPokerGame.js';
|
||||||
import FarkelGame from './games/farkel/FarkelGame.js';
|
import FarkelGame from './games/farkel/FarkelGame.js';
|
||||||
import StrategoGame from './games/stratego/StrategoGame.js';
|
import StrategoGame from './games/stratego/StrategoGame.js';
|
||||||
import KiitosGame from './games/kiitos/KiitosGame.js';
|
import KiitosGame from './games/kiitos/KiitosGame.js';
|
||||||
|
import MonopolyGame from './games/monopoly/MonopolyGame.js';
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
type: Phaser.AUTO,
|
type: Phaser.AUTO,
|
||||||
|
|
@ -129,6 +130,7 @@ const config = {
|
||||||
FarkelGame,
|
FarkelGame,
|
||||||
StrategoGame,
|
StrategoGame,
|
||||||
KiitosGame,
|
KiitosGame,
|
||||||
|
MonopolyGame,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ export default class GameRoomScene extends Phaser.Scene {
|
||||||
}
|
}
|
||||||
|
|
||||||
create() {
|
create() {
|
||||||
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame' };
|
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame' };
|
||||||
if (slugDispatch[this.game.slug]) {
|
if (slugDispatch[this.game.slug]) {
|
||||||
this.scene.start(slugDispatch[this.game.slug], {
|
this.scene.start(slugDispatch[this.game.slug], {
|
||||||
game: this.game,
|
game: this.game,
|
||||||
|
|
|
||||||
|
|
@ -132,6 +132,10 @@ export default class PreloadScene extends Phaser.Scene {
|
||||||
// Stratego unit art: 12 transparent frames (0=Flag, 1..10=rank, 11=Bomb),
|
// Stratego unit art: 12 transparent frames (0=Flag, 1..10=rank, 11=Bomb),
|
||||||
// 6 cols × 2 rows. Optional — the scene draws vector glyphs when absent.
|
// 6 cols × 2 rows. Optional — the scene draws vector glyphs when absent.
|
||||||
this.load.spritesheet('stratego-pieces', '/assets/images/stratego-pieces.png', { frameWidth: 140, frameHeight: 140 });
|
this.load.spritesheet('stratego-pieces', '/assets/images/stratego-pieces.png', { frameWidth: 140, frameHeight: 140 });
|
||||||
|
// Monopoly pawns: 4 frames (one per seat) at 80×80. Optional — falls back to colored circles.
|
||||||
|
this.load.spritesheet('monopoly-pawns', '/assets/images/monopoly-pawns.png', { frameWidth: 80, frameHeight: 80 });
|
||||||
|
// Monopoly card art: frame 0 = Chance, frame 1 = Community Chest, at 200×300.
|
||||||
|
this.load.spritesheet('monopoly-cards', '/assets/images/monopoly-cards.png', { frameWidth: 200, frameHeight: 300 });
|
||||||
}
|
}
|
||||||
|
|
||||||
async create() {
|
async create() {
|
||||||
|
|
|
||||||
|
|
@ -73,3 +73,4 @@ registerGame({ slug: 'videopoker', name: 'Video Poker', category: '
|
||||||
registerGame({ slug: 'farkel', name: 'Farkle', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 45 });
|
registerGame({ slug: 'farkel', name: 'Farkle', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 45 });
|
||||||
registerGame({ slug: 'stratego', name: 'Stratego', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, hasTutorial: false, iconFrame: 46 });
|
registerGame({ slug: 'stratego', name: 'Stratego', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, hasTutorial: false, iconFrame: 46 });
|
||||||
registerGame({ slug: 'kiitos', name: 'Kiitos', category: 'word', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 47 });
|
registerGame({ slug: 'kiitos', name: 'Kiitos', category: 'word', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 47 });
|
||||||
|
registerGame({ slug: 'monopoly', name: 'Monopoly', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 48 });
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue