feat(monopoly): add complete trade system with AI evaluation and drag-and-drop UI

- Add trade logic to MonopolyLogic: isTradeable, validateTrade, applyTrade
  - Properties with buildings (or in groups with buildings) cannot be traded
  - Validates ownership, cash affordability, and player activity
  - Handles two-way cash flows and property ownership transfers

- Add AI trade evaluation to MonopolyAI: evaluateTrade
  - Scores offers based on cash delta, asset value, group control changes
  - Detects set completions and control crossings (past 50%)
  - Vets breaking up completed monopolies
  - Applies skill-based thresholds and randomness
  - Returns acceptance decision with human-readable reason

- Add trade modal UI to MonopolyGame
  - Three-column layout: your properties, give/get lanes, opponent properties
  - Drag-and-drop property cards into trade lanes
  - Cash steppers for adding/requesting cash
  - Opponent tabs to switch counterparty
  - Hover cards for detailed property info
  - Visual feedback: pulsing hints when offer is empty, accept/reject messages
  - "Initiate Trade" button in end-turn menu
This commit is contained in:
Brian Fertig 2026-06-08 16:43:54 -06:00
parent ae2f3246dc
commit 684f5ed7b2
5 changed files with 794 additions and 2 deletions

Binary file not shown.

View File

@ -44,6 +44,11 @@
"file": "track09.mp3", "file": "track09.mp3",
"artist": "Jeff the Sloth", "artist": "Jeff the Sloth",
"title": "As Fast as I can Go" "title": "As Fast as I can Go"
},
{
"file": "track10.mp3",
"artist": "Back to Basics",
"title": "M83"
} }
] ]
} }

View File

@ -15,6 +15,17 @@ const PROFILES = {
5: { reserve:400, maxBidMult:1.10, noise:0, blunder:0.00, delay:[400,800] }, 5: { reserve:400, maxBidMult:1.10, noise:0, blunder:0.00, delay:[400,800] },
}; };
// Trade evaluation tuning — all expressed in "dollars" so they compare to cash/value.
const TRADE = {
gainPast50: 250, // AI ownership of a set crosses past 50%
completeSet: 500, // AI completes a set (reaches 100%)
losePast50: -400, // AI drops from >50% to ≤50% of a set
reduceStrong: -120, // AI reduces a >50% holding but stays >50%
cashTempt: 150, // generous cash bonus when money ≫ value given up
cashTemptMult: 1.5, // cash-to-AI must exceed this × value given up
threshold: { 1:-150, 2:-50, 3:40, 4:120, 5:200 }, // lower skill = easier to tempt
};
function rnd(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min; } function rnd(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min; }
function noise(n) { return (Math.random() - 0.5) * n; } function noise(n) { return (Math.random() - 0.5) * n; }
@ -56,6 +67,97 @@ export function chooseJailAction(state, seat, skill) {
return 'roll'; return 'roll';
} }
// ── Trade evaluation ────────────────────────────────────────────────────────────
// AI is always the counterparty (offer.toSeat). It RECEIVES offer.giveProps + giveCash
// and GIVES UP offer.getProps + getCash.
function groupKey(idx) {
if (RAILROADS.includes(idx)) return 'railroad';
if (UTILITIES.includes(idx)) return 'utility';
return SPACES[idx].group;
}
function groupIndices(key) {
if (key === 'railroad') return RAILROADS;
if (key === 'utility') return UTILITIES;
return GROUPS[key] ?? [];
}
function fractionFor(board, seat, key) {
const idxs = groupIndices(key);
if (!idxs.length) return 0;
const owned = idxs.filter(i => board[i]?.owner === seat).length;
return owned / idxs.length;
}
function boardAfter(state, offer) {
const b = {};
for (const idx of PURCHASABLE) b[idx] = { ...state.board[idx] };
for (const idx of (offer.giveProps ?? [])) b[idx].owner = offer.toSeat; // AI gains
for (const idx of (offer.getProps ?? [])) b[idx].owner = offer.fromSeat; // AI loses
return b;
}
export function evaluateTrade(state, aiSeat, offer, skill) {
const prof = PROFILES[skill] ?? PROFILES[3];
const giveProps = offer.giveProps ?? []; // AI receives these
const getProps = offer.getProps ?? []; // AI gives these up
const giveCash = offer.giveCash ?? 0;
const getCash = offer.getCash ?? 0;
const before = state.board;
const after = boardAfter(state, offer);
const valueOf = idx => SPACES[idx].price ?? 0;
const cashDelta = giveCash - getCash; // +ve = AI receives money
const gainVal = giveProps.reduce((a, i) => a + valueOf(i), 0);
const loseVal = getProps.reduce((a, i) => a + valueOf(i), 0);
const assetDelta = gainVal - loseVal;
let score = cashDelta + assetDelta;
const touched = new Set();
for (const idx of [...giveProps, ...getProps]) touched.add(groupKey(idx));
let veto = false, completedSet = false, gainedControl = false;
for (const key of touched) {
const fBefore = fractionFor(before, aiSeat, key);
const fAfter = fractionFor(after, aiSeat, key);
if (fBefore <= 0.5 && fAfter > 0.5) { score += TRADE.gainPast50; gainedControl = true; }
if (fBefore < 1.0 && fAfter >= 1.0) { score += TRADE.completeSet; completedSet = true; }
if (fBefore > 0.5 && fAfter <= 0.5) score += TRADE.losePast50;
else if (fAfter < fBefore && fBefore > 0.5) score += TRADE.reduceStrong;
if (fBefore >= 1.0 && fAfter < 1.0) veto = true; // breaking up a completed monopoly
}
// Never break up a completed set unless desperate enough to need it to stay in the game.
if (veto && state.players[aiSeat].cash >= 0) {
return { accept: false, reason: 'I wont break up a monopoly Ive completed.' };
}
// A very generous cash offer can tip a borderline deal.
if (!veto && cashDelta - loseVal >= TRADE.cashTemptMult * Math.max(1, loseVal)) {
score += TRADE.cashTempt;
}
score += noise(prof.noise * 2);
const threshold = TRADE.threshold[skill] ?? 40;
const accept = score >= threshold;
let reason;
if (accept) {
if (completedSet) reason = 'That completes my set — gladly.';
else if (gainedControl) reason = 'That gives me control of the set — deal.';
else if (giveProps.length === 0 && getProps.length === 0) reason = 'The cash makes it worth it — deal.';
else if (cashDelta > 0 && cashDelta >= -assetDelta) reason = 'The money tips it in your favor — deal.';
else reason = 'That works for me — deal.';
} else {
if (loseVal > 0 && cashDelta <= 0) reason = 'Not nearly enough for what you want.';
else reason = 'Ill pass — that doesnt move me.';
}
return { accept, reason };
}
// ── Build decisions ──────────────────────────────────────────────────────────── // ── Build decisions ────────────────────────────────────────────────────────────
// Returns { action: 'house'|'hotel', spaceIdx } or null // Returns { action: 'house'|'hotel', spaceIdx } or null
export function chooseBuild(state, seat, skill) { export function chooseBuild(state, seat, skill) {

View File

@ -17,8 +17,9 @@ import {
mortgageProperty, unmortgageProperty, payJailFine, useJailCard, mortgageProperty, unmortgageProperty, payJailFine, useJailCard,
applyCardEffect, applyRent, endTurn, checkGameOver, calculateRent, applyCardEffect, applyRent, endTurn, checkGameOver, calculateRent,
canBuildHouse, canBuildHotel, ownsGroup, netWorth, canBuildHouse, canBuildHotel, ownsGroup, netWorth,
isTradeable, validateTrade, applyTrade,
} from './MonopolyLogic.js'; } from './MonopolyLogic.js';
import { chooseBuy, chooseBid, chooseJailAction, chooseBuild, nextThinkDelay } from './MonopolyAI.js'; import { chooseBuy, chooseBid, chooseJailAction, chooseBuild, nextThinkDelay, evaluateTrade } from './MonopolyAI.js';
// ── Layout ──────────────────────────────────────────────────────────────────── // ── Layout ────────────────────────────────────────────────────────────────────
const BL = 30; // board left const BL = 30; // board left
@ -83,6 +84,15 @@ export default class MonopolyGame extends Phaser.Scene {
this.modalOrigin = null; this.modalOrigin = null;
// Card draw animation flag — suppresses static popup until animation finishes // Card draw animation flag — suppresses static popup until animation finishes
this.cardAnimPlayed = false; this.cardAnimPlayed = false;
// Trade modal (self-contained overlay, like build/mortgage menus)
this.tradeMenuOpen = false;
this.tradeMenuObjs = [];
this.tradeHoverCard = null;
this.tradeOffer = null; // { giveProps, getProps, giveCash, getCash }
this.tradeCounterparty = null; // selected opponent seat
this.tradeDragGhost = null;
this._dragHintTween = null; // pulses draggable cards while offer is empty
this._dragHintCards = [];
} }
create() { create() {
@ -483,7 +493,7 @@ export default class MonopolyGame extends Phaser.Scene {
if (this.gs.phase === 'auction' && this.gs.pendingAuction) this.drawAuctionPanel(); if (this.gs.phase === 'auction' && this.gs.pendingAuction) this.drawAuctionPanel();
if (this.modalActive && this.gs.phase === 'buy') this.drawModalBuyButtons(); if (this.modalActive && this.gs.phase === 'buy') this.drawModalBuyButtons();
// DOM video portraits always render above canvas — hide them during any overlay // DOM video portraits always render above canvas — hide them during any overlay
if (this.gs.pendingCard || this.modalActive) this.hidePortraits(); if (this.gs.pendingCard || this.modalActive || this.tradeMenuOpen) this.hidePortraits();
else this.showPortraits(); else this.showPortraits();
} }
@ -746,6 +756,7 @@ export default class MonopolyGame extends Phaser.Scene {
})) { })) {
btnCount++; btnCount++;
} }
if (phase === 'endturn' && this.canInitiateTrade()) btnCount++;
} }
// Second pass: draw buttons aligned to board bottom (BT + BS) // Second pass: draw buttons aligned to board bottom (BT + BS)
@ -796,6 +807,10 @@ export default class MonopolyGame extends Phaser.Scene {
if (canMortgage || canUnmortgage) { if (canMortgage || canUnmortgage) {
mkBtn('Mortgage / Unmortgage', () => this.showMortgageMenu(), true, { variant:'ghost' }); mkBtn('Mortgage / Unmortgage', () => this.showMortgageMenu(), true, { variant:'ghost' });
} }
// Trade option (after rolling, i.e. endturn)
if (phase === 'endturn' && this.canInitiateTrade()) {
mkBtn('Initiate Trade', () => this.showTradeModal(), true, { variant:'ghost' });
}
} }
// Card OK button is drawn inside drawCardPopup(), overlaid on the card // Card OK button is drawn inside drawCardPopup(), overlaid on the card
@ -1325,6 +1340,596 @@ export default class MonopolyGame extends Phaser.Scene {
this.mortMenuObjs = []; this.mortMenuObjs = [];
} }
// ── Trade Modal ────────────────────────────────────────────────────────────
canInitiateTrade() {
const gs = this.gs;
return gs.players.some(pl => pl.seat !== this.humanSeat && pl.active && !pl.bankrupt &&
PURCHASABLE.some(i => gs.board[i]?.owner === pl.seat));
}
// Band color matching buildPropertyCardContainer / drawBoardSpace
tradeBandColor(idx) {
const sp = SPACES[idx];
return sp.group ? GROUP_COLORS[sp.group]
: sp.type === 'railroad' ? 0x1a1208
: sp.type === 'utility' && idx === 12 ? 0xFFD700
: 0x1565C0;
}
showTradeModal() {
if (this.tradeMenuOpen || this.busy) return;
if (this.gs.current !== this.humanSeat || this.gs.phase !== 'endturn') return;
this.tradeMenuOpen = true;
this.tradeMenuObjs = [];
this.tradeLaneObjs = [];
this.tradeRightObjs = [];
this.tradeMineCards = {};
this.tradeOppCards = {};
this.tradeOffer = { giveProps: [], getProps: [], giveCash: 0, getCash: 0 };
this.tradeDragGhost = null;
this._tradeDidDrag = false;
// DOM video portraits render above the canvas — hide them behind the modal
this.hidePortraits();
const gs = this.gs;
// Default counterparty: first active opponent owning a property, else first opponent
const opps = gs.players.filter(pl => pl.seat !== this.humanSeat && pl.active && !pl.bankrupt);
this.tradeCounterparty = (opps.find(pl =>
PURCHASABLE.some(i => gs.board[i]?.owner === pl.seat)) ?? opps[0])?.seat ?? null;
// Geometry
const PW = 1600, PH = 860;
const PX = GAME_WIDTH/2 - PW/2, PY = GAME_HEIGHT/2 - PH/2;
this._tradeGeo = { PW, PH, PX, PY,
LX: PX + 24, LW: 430,
CX: PX + 474, CW: 600,
RX: PX + 1094, RW: 482,
};
// Overlay (swallows background clicks)
const overlay = this.add.rectangle(GAME_WIDTH/2, GAME_HEIGHT/2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.62)
.setDepth(DEPTH.popup - 1).setInteractive();
this.tradeMenuObjs.push(overlay);
// Panel
const panel = this.add.graphics().setDepth(DEPTH.popup);
panel.fillStyle(0x1e1a12, 1);
panel.fillRoundedRect(PX, PY, PW, PH, 14);
panel.lineStyle(2, COLORS.gold, 1);
panel.strokeRoundedRect(PX, PY, PW, PH, 14);
this.tradeMenuObjs.push(panel);
this.tradeMenuObjs.push(this.add.text(GAME_WIDTH/2, PY + 24, 'Propose a Trade', {
fontFamily:'Righteous', fontSize:'26px', color:COLORS.goldHex,
}).setOrigin(0.5).setDepth(DEPTH.popup+1));
this.buildTradeLeftColumn();
this.buildTradeCenterColumn();
this.renderRightColumn();
// Drag handlers (registered once per open)
this._onTradeDragStart = (pointer, obj) => {
if (!obj || obj._spaceIdx === undefined) return;
this.tradeDragGhost = obj; this._tradeDidDrag = true; obj._dropped = false;
this.stopDragHints();
this.clearTradeHoverCard();
this.showDropHint(obj._side);
obj._homeX = obj.x; obj._homeY = obj.y;
obj.setDepth(DEPTH.popup + 12);
};
this._onTradeDrag = (pointer, obj, dragX, dragY) => {
if (obj !== this.tradeDragGhost) return;
obj.x = dragX; obj.y = dragY;
};
// Native drop — fires only when released over a lane drop zone. Route by whose
// card it is (your card → give, their card → get) so it always lands correctly.
this._onTradeDrop = (pointer, obj, zone) => {
if (obj !== this.tradeDragGhost) return;
const type = zone?.getData?.('laneType');
if (type !== 'give' && type !== 'get') return;
obj._dropped = true;
this.addTradeProp(obj._spaceIdx, obj._side === 'mine' ? 'give' : 'get');
};
this._onTradeDragEnd = (pointer, obj) => {
if (obj !== this.tradeDragGhost) return;
obj.x = obj._homeX; obj.y = obj._homeY; // snap home; drop already handled above
obj.setDepth(DEPTH.popup + 1);
this.tradeDragGhost = null;
this.clearDropHint();
this.refreshDragHints(); // resume pulsing if nothing was dropped
};
this.input.on('dragstart', this._onTradeDragStart);
this.input.on('drag', this._onTradeDrag);
this.input.on('drop', this._onTradeDrop);
this.input.on('dragend', this._onTradeDragEnd);
this.renderTradeOffer();
this.renderTradeCash();
}
buildTradeLeftColumn() {
const { LX, LW, PY } = this._tradeGeo;
const gs = this.gs;
const p = gs.players[this.humanSeat];
this.tradeMenuObjs.push(this.add.text(LX + LW/2, PY + 64, 'Your Properties', {
fontFamily:'Righteous', fontSize:'18px', color:COLORS.textHex,
}).setOrigin(0.5).setDepth(DEPTH.popup+1));
this.tradeMenuObjs.push(this.add.text(LX + LW/2, PY + 88, `Cash: $${p.cash.toLocaleString()}`, {
fontFamily:'"Julius Sans One"', fontSize:'15px', color:'#7fb87f',
}).setOrigin(0.5).setDepth(DEPTH.popup+1));
const owned = PURCHASABLE.filter(i => gs.board[i]?.owner === this.humanSeat);
owned.forEach((idx, i) => {
const col = i % 3, row = Math.floor(i / 3);
const cx = LX + 70 + col * 140;
const cy = PY + 130 + row * 78;
const card = this.buildTradeMiniCard(idx, 'mine');
card.setPosition(cx, cy);
this.tradeMineCards[idx] = card;
this.tradeMenuObjs.push(card);
});
if (owned.length === 0) {
this.tradeMenuObjs.push(this.add.text(LX + LW/2, PY + 150, 'You own no properties.\nYou can still offer cash.', {
fontFamily:'"Julius Sans One"', fontSize:'13px', color:COLORS.mutedHex, align:'center',
}).setOrigin(0.5).setDepth(DEPTH.popup+1));
}
}
buildTradeCenterColumn() {
const { CX, CW, PY } = this._tradeGeo;
const midX = CX + CW/2;
// Give lane
this.tradeMenuObjs.push(this.add.text(midX, PY + 70, 'You give →', {
fontFamily:'Righteous', fontSize:'16px', color:COLORS.goldHex,
}).setOrigin(0.5).setDepth(DEPTH.popup+1));
this.tradeGiveLane = { x: CX + 10, y: PY + 90, w: CW - 20, h: 86 };
// Get lane
this.tradeMenuObjs.push(this.add.text(midX, PY + 192, '← You get', {
fontFamily:'Righteous', fontSize:'16px', color:COLORS.goldHex,
}).setOrigin(0.5).setDepth(DEPTH.popup+1));
this.tradeGetLane = { x: CX + 10, y: PY + 212, w: CW - 20, h: 86 };
const laneG = this.add.graphics().setDepth(DEPTH.popup);
for (const lane of [this.tradeGiveLane, this.tradeGetLane]) {
laneG.fillStyle(0x14110a, 1);
laneG.fillRoundedRect(lane.x, lane.y, lane.w, lane.h, 8);
laneG.lineStyle(1, COLORS.accent, 0.6);
laneG.strokeRoundedRect(lane.x, lane.y, lane.w, lane.h, 8);
}
this.tradeMenuObjs.push(laneG);
// Real Phaser drop zones over each lane. Native drop uses Phaser's own
// render-consistent hit testing (same pipeline as normal clicks), so the
// droppable area matches exactly what's drawn. add.zone(x,y,...) takes the
// CENTER; setRectangleDropZone centers the hit area. A small pad eases aiming.
const mkZone = (lane, type) => {
const zw = lane.w + 16, zh = lane.h + 16;
const z = this.add.zone(lane.x + lane.w/2, lane.y + lane.h/2, zw, zh)
.setRectangleDropZone(zw, zh)
.setDepth(DEPTH.popup + 2);
z.setData('laneType', type);
this.tradeMenuObjs.push(z);
return z;
};
this.tradeGiveZone = mkZone(this.tradeGiveLane, 'give');
this.tradeGetZone = mkZone(this.tradeGetLane, 'get');
// Cash steppers
this.buildCashStepper('give', PY + 326);
this.buildCashStepper('get', PY + 396);
// Propose / Cancel
const proposeBtn = new Button(this, midX, PY + 476, 'Propose Trade', () => this.onProposeTrade(),
{ width: 260, height: 52, fontSize: 22 });
proposeBtn.setDepth(DEPTH.popup+2);
this.tradeMenuObjs.push(proposeBtn);
const cancelBtn = new Button(this, midX, PY + 540, 'Cancel', () => this.closeTradeModal(),
{ width: 180, height: 44, fontSize: 18, variant:'ghost' });
cancelBtn.setDepth(DEPTH.popup+2);
this.tradeMenuObjs.push(cancelBtn);
this.tradeFeedbackText = this.add.text(midX, PY + 600, 'Build your offer, then propose.', {
fontFamily:'"Julius Sans One"', fontSize:'15px', color:COLORS.mutedHex,
align:'center', wordWrap:{ width: CW - 20 },
}).setOrigin(0.5, 0).setDepth(DEPTH.popup+1);
this.tradeMenuObjs.push(this.tradeFeedbackText);
}
buildCashStepper(side, y) {
const { CX, CW } = this._tradeGeo;
const midX = CX + CW/2;
const label = side === 'give' ? 'You add cash' : 'You request cash';
this.tradeMenuObjs.push(this.add.text(CX + 10, y - 18, label, {
fontFamily:'"Julius Sans One"', fontSize:'14px', color:COLORS.textHex,
}).setOrigin(0, 0.5).setDepth(DEPTH.popup+1));
const valText = this.add.text(midX, y + 8, '$0', {
fontFamily:'Righteous', fontSize:'20px', color:COLORS.goldHex,
}).setOrigin(0.5).setDepth(DEPTH.popup+1);
this.tradeMenuObjs.push(valText);
if (side === 'give') this.tradeGiveCashText = valText; else this.tradeGetCashText = valText;
const deltas = [[-50,'50'], [-10,'10'], [+10,'+10'], [+50,'+50']];
const bw = 64, gap = 8, totalW = deltas.length * bw + (deltas.length - 1) * gap;
let bx = midX - totalW/2 + bw/2;
const rowY = y + 36;
for (const [delta, lbl] of deltas) {
const b = new Button(this, bx, rowY, lbl, () => this.adjustTradeCash(side, delta),
{ width: bw, height: 30, fontSize: 14, variant:'ghost' });
b.setDepth(DEPTH.popup+2);
this.tradeMenuObjs.push(b);
bx += bw + gap;
}
}
adjustTradeCash(side, delta) {
if (!this.tradeOffer) return;
if (side === 'give') {
const max = this.gs.players[this.humanSeat].cash;
this.tradeOffer.giveCash = Phaser.Math.Clamp(this.tradeOffer.giveCash + delta, 0, max);
} else {
const cp = this.tradeCounterparty;
const max = cp !== null ? this.gs.players[cp].cash : 0;
this.tradeOffer.getCash = Phaser.Math.Clamp(this.tradeOffer.getCash + delta, 0, max);
}
this.renderTradeCash();
}
renderTradeCash() {
if (this.tradeGiveCashText) this.tradeGiveCashText.setText(`$${this.tradeOffer.giveCash}`);
if (this.tradeGetCashText) this.tradeGetCashText.setText(`$${this.tradeOffer.getCash}`);
this.refreshDragHints();
}
renderRightColumn() {
(this.tradeRightObjs || []).forEach(o => { try { o.destroy(); } catch {} });
this.tradeRightObjs = [];
this.tradeOppCards = {};
const { RX, RW, PY } = this._tradeGeo;
const gs = this.gs;
const opps = gs.players.filter(pl => pl.seat !== this.humanSeat && pl.active && !pl.bankrupt);
// Opponent tabs
const tabW = Math.min(150, Math.floor((RW - (opps.length - 1) * 8) / Math.max(1, opps.length)));
let tx = RX + tabW/2;
for (const pl of opps) {
const selected = pl.seat === this.tradeCounterparty;
const b = new Button(this, tx, PY + 66, pl.name.length > 10 ? pl.name.slice(0,9)+'…' : pl.name,
() => this.selectTradeCounterparty(pl.seat),
{ width: tabW, height: 36, fontSize: 14, variant: selected ? 'solid' : 'ghost' });
b.setDepth(DEPTH.popup+2);
this.tradeRightObjs.push(b);
tx += tabW + 8;
}
const cp = this.tradeCounterparty;
if (cp === null) return;
this.tradeRightObjs.push(this.add.text(RX + RW/2, PY + 96, `Cash: $${gs.players[cp].cash.toLocaleString()}`, {
fontFamily:'"Julius Sans One"', fontSize:'15px', color:'#7fb87f',
}).setOrigin(0.5).setDepth(DEPTH.popup+1));
const owned = PURCHASABLE.filter(i => gs.board[i]?.owner === cp);
owned.forEach((idx, i) => {
const col = i % 3, row = Math.floor(i / 3);
const cx = RX + 70 + col * 140;
const cy = PY + 138 + row * 78;
const card = this.buildTradeMiniCard(idx, 'opp');
card.setPosition(cx, cy);
this.tradeOppCards[idx] = card;
this.tradeRightObjs.push(card);
});
if (owned.length === 0) {
this.tradeRightObjs.push(this.add.text(RX + RW/2, PY + 150, 'They own no properties.', {
fontFamily:'"Julius Sans One"', fontSize:'13px', color:COLORS.mutedHex,
}).setOrigin(0.5).setDepth(DEPTH.popup+1));
}
this.refreshMiniStates();
}
selectTradeCounterparty(seat) {
if (seat === this.tradeCounterparty) return;
this.tradeCounterparty = seat;
// getProps/getCash referenced the previous opponent — reset them
this.tradeOffer.getProps = [];
this.tradeOffer.getCash = 0;
this.renderRightColumn();
this.renderTradeOffer();
this.renderTradeCash();
this.setTradeFeedback('Build your offer, then propose.', 'muted');
}
buildTradeMiniCard(idx, side) {
const sp = SPACES[idx];
const own = this.gs.board[idx];
const MW = 128, MH = 66;
const c = this.add.container(0, 0).setDepth(DEPTH.popup + 1);
const g = this.add.graphics();
g.fillStyle(0xFFF8E7, 1);
g.fillRoundedRect(-MW/2, -MH/2, MW, MH, 6);
g.lineStyle(1, 0x2c1810, 1);
g.strokeRoundedRect(-MW/2, -MH/2, MW, MH, 6);
g.fillStyle(this.tradeBandColor(idx), 1);
g.fillRect(-MW/2, -MH/2, MW, 14);
c.add(g);
c.add(this.add.text(0, -MH/2 + 18, sp.name, {
fontFamily:'"Julius Sans One"', fontSize:'10px', color:'#1a1208',
align:'center', wordWrap:{ width: MW - 10, useAdvancedWrap:true },
}).setOrigin(0.5, 0));
const sub = (sp.type === 'property' || sp.type === 'railroad' || sp.type === 'utility') ? `$${sp.price}` : '';
if (sub) {
c.add(this.add.text(0, MH/2 - 14, sub, {
fontFamily:'"Julius Sans One"', fontSize:'9px', color:'#555544',
}).setOrigin(0.5, 0));
}
if (own.mortgaged) {
const mg = this.add.graphics();
mg.fillStyle(0x888888, 0.45);
mg.fillRoundedRect(-MW/2, -MH/2, MW, MH, 6);
c.add(mg);
c.add(this.add.text(0, 4, 'MORTGAGED', {
fontFamily:'"Julius Sans One"', fontSize:'9px', color:'#cccccc',
}).setOrigin(0.5));
}
const tradeable = isTradeable(this.gs, idx);
if (tradeable) {
const og = this.add.graphics();
og.lineStyle(2, 0x44cc66, 1);
og.strokeRoundedRect(-MW/2, -MH/2, MW, MH, 6);
c.add(og);
} else {
c.setAlpha(0.45);
}
c._spaceIdx = idx; c._side = side; c._tradeable = tradeable;
c._hw = MW/2; c._hh = MH/2; // half-extents for drop-zone overlap testing
// NB: do NOT call setSize() — on a Container it sets displayOrigin = size/2,
// which Phaser's hit test adds to the local point and shifts the hit area up/left.
// NB: the 3rd setInteractive arg is `dropZone` (boolean). Passing a config object
// there made every card a drop zone and broke drag-drop — keep it to 2 args.
c.setInteractive(new Phaser.Geom.Rectangle(-MW/2, -MH/2, MW, MH), Phaser.Geom.Rectangle.Contains);
if (c.input) c.input.cursor = tradeable ? 'grab' : 'default';
c.on('pointerover', () => { if (!this.tradeDragGhost) this.showTradeHoverCard(idx, c.x, c.y); });
c.on('pointerout', () => this.clearTradeHoverCard());
if (tradeable) {
this.input.setDraggable(c, true);
c.on('pointerdown', () => { this._tradeDidDrag = false; });
c.on('pointerup', () => { if (!this._tradeDidDrag) this.toggleTradeProp(idx, side); });
}
return c;
}
showDropHint(side) {
this.clearDropHint();
const lane = side === 'mine' ? this.tradeGiveLane
: side === 'opp' ? this.tradeGetLane : null;
if (!lane) return;
const g = this.add.graphics().setDepth(DEPTH.popup + 3);
g.fillStyle(0x44cc66, 0.22);
g.fillRoundedRect(lane.x, lane.y, lane.w, lane.h, 8);
g.lineStyle(4, 0x66ff88, 1);
g.strokeRoundedRect(lane.x, lane.y, lane.w, lane.h, 8);
const label = this.add.text(lane.x + lane.w/2, lane.y + lane.h/2,
side === 'mine' ? '⬇ DROP HERE TO GIVE' : '⬇ DROP HERE TO GET', {
fontFamily:'Righteous', fontSize:'22px', color:'#d6ffe0',
stroke:'#0a3a18', strokeThickness:4,
}).setOrigin(0.5).setDepth(DEPTH.popup + 4);
this.tradeDropHintObjs = [g, label];
this.tradeDropHintTween = this.tweens.add({
targets: [g, label],
alpha: { from: 1, to: 0.45 },
duration: 420, yoyo: true, repeat: -1, ease: 'Sine.easeInOut',
});
}
clearDropHint() {
if (this.tradeDropHintTween) { try { this.tradeDropHintTween.stop(); } catch {} this.tradeDropHintTween = null; }
(this.tradeDropHintObjs || []).forEach(o => { try { o.destroy(); } catch {} });
this.tradeDropHintObjs = [];
}
toggleTradeProp(idx, side) {
const lane = side === 'mine' ? 'give' : 'get';
const arr = lane === 'give' ? this.tradeOffer.giveProps : this.tradeOffer.getProps;
if (arr.includes(idx)) this.removeTradeProp(idx, lane);
else this.addTradeProp(idx, lane);
}
addTradeProp(idx, lane) {
const arr = lane === 'give' ? this.tradeOffer.giveProps : this.tradeOffer.getProps;
if (!arr.includes(idx)) { arr.push(idx); this.renderTradeOffer(); }
}
removeTradeProp(idx, lane) {
if (lane === 'give') this.tradeOffer.giveProps = this.tradeOffer.giveProps.filter(i => i !== idx);
else this.tradeOffer.getProps = this.tradeOffer.getProps.filter(i => i !== idx);
this.renderTradeOffer();
}
renderTradeOffer() {
(this.tradeLaneObjs || []).forEach(o => { try { o.destroy(); } catch {} });
this.tradeLaneObjs = [];
const layoutChips = (idxs, lane) => {
const CW = 178, CH = 30, gap = 8, perRow = Math.max(1, Math.floor(lane.w / (CW + gap)));
idxs.forEach((idx, i) => {
const col = i % perRow, row = Math.floor(i / perRow);
const cx = lane.x + 12 + CW/2 + col * (CW + gap);
const cy = lane.y + 20 + row * (CH + 6);
this.tradeLaneObjs.push(this.buildTradeChip(idx, cx, cy,
lane === this.tradeGiveLane ? 'give' : 'get', CW, CH));
});
};
layoutChips(this.tradeOffer.giveProps, this.tradeGiveLane);
layoutChips(this.tradeOffer.getProps, this.tradeGetLane);
this.refreshMiniStates();
this.refreshDragHints();
}
buildTradeChip(idx, cx, cy, lane, CW, CH) {
const sp = SPACES[idx];
const c = this.add.container(cx, cy).setDepth(DEPTH.popup + 2);
const g = this.add.graphics();
g.fillStyle(0x2a2418, 1);
g.fillRoundedRect(-CW/2, -CH/2, CW, CH, 6);
g.lineStyle(2, this.tradeBandColor(idx), 1);
g.strokeRoundedRect(-CW/2, -CH/2, CW, CH, 6);
c.add(g);
c.add(this.add.text(-CW/2 + 8, 0, sp.name, {
fontFamily:'"Julius Sans One"', fontSize:'11px', color:'#FFF8E7',
wordWrap:{ width: CW - 34 },
}).setOrigin(0, 0.5));
c.add(this.add.text(CW/2 - 12, 0, '✕', {
fontFamily:'Righteous', fontSize:'14px', color:'#ff8888',
}).setOrigin(0.5));
c.setInteractive(new Phaser.Geom.Rectangle(-CW/2, -CH/2, CW, CH), Phaser.Geom.Rectangle.Contains);
if (c.input) c.input.cursor = 'pointer';
c.on('pointerup', () => this.removeTradeProp(idx, lane));
return c;
}
refreshMiniStates() {
const mark = (map, arr) => {
for (const [idx, card] of Object.entries(map)) {
if (!card || !card.active) continue;
const inOffer = arr.includes(Number(idx));
if (!card._tradeable) { card.setAlpha(0.45); continue; }
card.setAlpha(inOffer ? 0.35 : 1);
}
};
mark(this.tradeMineCards, this.tradeOffer.giveProps);
mark(this.tradeOppCards, this.tradeOffer.getProps);
}
isTradeOfferEmpty() {
const o = this.tradeOffer;
return !o || (o.giveProps.length === 0 && o.getProps.length === 0 && o.giveCash === 0 && o.getCash === 0);
}
// While the offer is empty, gently pulse the draggable cards so it's obvious they
// can be picked up. Stops the moment anything is offered or requested.
startDragHints() {
this.stopDragHints();
if (!this.tradeMenuOpen) return;
const cards = [
...Object.values(this.tradeMineCards || {}),
...Object.values(this.tradeOppCards || {}),
].filter(c => c && c.active && c._tradeable);
if (!cards.length) return;
cards.forEach(c => c.setScale(1));
this._dragHintCards = cards;
this._dragHintTween = this.tweens.add({
targets: cards,
scaleX: 1.07, scaleY: 1.07,
duration: 640, yoyo: true, repeat: -1, ease: 'Sine.easeInOut',
});
}
stopDragHints() {
if (this._dragHintTween) { try { this._dragHintTween.stop(); } catch {} this._dragHintTween = null; }
(this._dragHintCards || []).forEach(c => { if (c && c.active) c.setScale(1); });
this._dragHintCards = [];
}
refreshDragHints() {
if (this.tradeMenuOpen && this.isTradeOfferEmpty()) this.startDragHints();
else this.stopDragHints();
}
showTradeHoverCard(idx, x, y) {
this.clearTradeHoverCard();
const card = this.buildPropertyCardContainer(idx);
const s = 0.82;
const hw = MODAL_W * s / 2, hh = MODAL_H * s / 2;
const hx = Phaser.Math.Clamp(x, hw + 10, GAME_WIDTH - hw - 10);
const hy = Phaser.Math.Clamp(y, hh + 10, GAME_HEIGHT - hh - 10);
card.setPosition(hx, hy).setScale(s).setDepth(DEPTH.popup + 6);
this.tradeHoverCard = card;
}
clearTradeHoverCard() {
if (this.tradeHoverCard) {
this.tradeHoverCard.each(c => { try { c.destroy(); } catch {} });
try { this.tradeHoverCard.destroy(); } catch {}
this.tradeHoverCard = null;
}
}
setTradeFeedback(msg, tone = 'muted') {
if (!this.tradeFeedbackText) return;
const color = tone === 'good' ? '#7fdd9f'
: tone === 'bad' ? COLORS.dangerHex
: tone === 'warn' ? COLORS.goldHex
: COLORS.mutedHex;
this.tradeFeedbackText.setColor(color);
this.tradeFeedbackText.setText(msg);
}
onProposeTrade() {
if (!this.tradeOffer || this.tradeCounterparty === null) return;
const offer = {
fromSeat: this.humanSeat,
toSeat: this.tradeCounterparty,
giveProps: [...this.tradeOffer.giveProps],
getProps: [...this.tradeOffer.getProps],
giveCash: this.tradeOffer.giveCash,
getCash: this.tradeOffer.getCash,
};
const v = validateTrade(this.gs, offer);
if (!v.ok) { this.setTradeFeedback(v.reason, 'warn'); return; }
const skill = this.skillBySeat[this.tradeCounterparty] ?? 3;
const verdict = evaluateTrade(this.gs, this.tradeCounterparty, offer, skill);
if (verdict.accept) {
this.gs = applyTrade(this.gs, offer);
this.setTradeFeedback('Accepted! ' + verdict.reason, 'good');
playSound(this, SFX.MONOPOLY_PURCHASE);
this.time.delayedCall(1200, () => {
this.closeTradeModal();
this.render();
});
} else {
this.setTradeFeedback('Rejected: ' + verdict.reason, 'bad');
}
}
closeTradeModal() {
this.tradeMenuOpen = false;
this.showPortraits();
this.stopDragHints();
this.clearDropHint();
if (this._onTradeDragStart) this.input.off('dragstart', this._onTradeDragStart);
if (this._onTradeDrag) this.input.off('drag', this._onTradeDrag);
if (this._onTradeDrop) this.input.off('drop', this._onTradeDrop);
if (this._onTradeDragEnd) this.input.off('dragend', this._onTradeDragEnd);
this._onTradeDragStart = this._onTradeDrag = this._onTradeDrop = this._onTradeDragEnd = null;
this.clearTradeHoverCard();
(this.tradeLaneObjs || []).forEach(o => { try { o.destroy(); } catch {} });
(this.tradeRightObjs || []).forEach(o => { try { o.destroy(); } catch {} });
(this.tradeMenuObjs || []).forEach(o => { try { o.destroy(); } catch {} });
this.tradeLaneObjs = [];
this.tradeRightObjs = [];
this.tradeMenuObjs = [];
this.tradeMineCards = {};
this.tradeOppCards = {};
this.tradeOffer = null;
this.tradeCounterparty = null;
this.tradeDragGhost = null;
this.tradeGiveCashText = null;
this.tradeGetCashText = null;
this.tradeFeedbackText = null;
}
// ── Game Over ────────────────────────────────────────────────────────────── // ── Game Over ──────────────────────────────────────────────────────────────
showGameOver() { showGameOver() {
const winner = this.gs.winner !== null ? this.gs.players[this.gs.winner] : null; const winner = this.gs.winner !== null ? this.gs.players[this.gs.winner] : null;

View File

@ -678,6 +678,86 @@ export function unmortgageProperty(state, seat, spaceIdx) {
return s; return s;
} }
// ── Trading ─────────────────────────────────────────────────────────────────────
// A property may be traded only if it (and the rest of its color group) carries no
// buildings — otherwise transferring it would strand houses / break even-building.
// Railroads & utilities never have buildings; mortgaged properties may be traded.
export function isTradeable(state, spaceIdx) {
const own = state.board[spaceIdx];
if (!own || own.owner === null || own.owner === undefined) return false;
if (own.houses > 0 || own.hotel) return false;
const sp = SPACES[spaceIdx];
if (sp.type === 'property') {
for (const i of GROUPS[sp.group]) {
const o = state.board[i];
if (o && (o.houses > 0 || o.hotel)) return false;
}
}
return true;
}
// Pure validator. Returns { ok:true } or { ok:false, reason }. Never mutates.
// offer = { fromSeat, toSeat, giveProps:[idx], getProps:[idx], giveCash, getCash }
export function validateTrade(state, offer) {
if (!offer) return { ok: false, reason: 'No offer.' };
const { fromSeat, toSeat } = offer;
const giveProps = offer.giveProps ?? [];
const getProps = offer.getProps ?? [];
const giveCash = offer.giveCash ?? 0;
const getCash = offer.getCash ?? 0;
if (fromSeat === toSeat) return { ok: false, reason: 'Cannot trade with yourself.' };
const from = state.players[fromSeat];
const to = state.players[toSeat];
if (!from || !to) return { ok: false, reason: 'Unknown player.' };
if (!from.active || from.bankrupt || !to.active || to.bankrupt) {
return { ok: false, reason: 'Both players must be active.' };
}
if (giveCash < 0 || getCash < 0) return { ok: false, reason: 'Cash cannot be negative.' };
if (giveProps.length === 0 && getProps.length === 0 && giveCash === 0 && getCash === 0) {
return { ok: false, reason: 'The offer is empty.' };
}
for (const idx of giveProps) {
if (state.board[idx]?.owner !== fromSeat) return { ok: false, reason: 'You do not own a property you are offering.' };
if (!isTradeable(state, idx)) return { ok: false, reason: `${SPACES[idx].name} has buildings and cannot be traded.` };
}
for (const idx of getProps) {
if (state.board[idx]?.owner !== toSeat) return { ok: false, reason: 'They do not own a property you requested.' };
if (!isTradeable(state, idx)) return { ok: false, reason: `${SPACES[idx].name} has buildings and cannot be traded.` };
}
if (giveCash > from.cash) return { ok: false, reason: 'You cannot afford that cash offer.' };
if (getCash > to.cash) return { ok: false, reason: 'They cannot afford that cash request.' };
return { ok: true };
}
// Apply a trade. No-op (returns original state) when the offer is invalid.
// Cash flows both ways; ownership transfers; mortgaged flag is preserved (no interest).
// Does NOT touch s.phase — trading happens within the human's endturn.
export function applyTrade(state, offer) {
const v = validateTrade(state, offer);
if (!v.ok) return state;
const s = clone(state);
const { fromSeat, toSeat } = offer;
const giveProps = offer.giveProps ?? [];
const getProps = offer.getProps ?? [];
const giveCash = offer.giveCash ?? 0;
const getCash = offer.getCash ?? 0;
s.players[fromSeat].cash -= giveCash;
s.players[toSeat].cash += giveCash;
s.players[toSeat].cash -= getCash;
s.players[fromSeat].cash += getCash;
for (const idx of giveProps) s.board[idx].owner = toSeat;
for (const idx of getProps) s.board[idx].owner = fromSeat;
const giveNames = giveProps.map(i => SPACES[i].name).join(', ') || '—';
const getNames = getProps.map(i => SPACES[i].name).join(', ') || '—';
log(s, `${s.players[fromSeat].name} traded ${giveNames}${giveCash ? ` + $${giveCash}` : ''} ` +
`to ${s.players[toSeat].name} for ${getNames}${getCash ? ` + $${getCash}` : ''}.`);
return s;
}
// ── Jail ────────────────────────────────────────────────────────────────────── // ── Jail ──────────────────────────────────────────────────────────────────────
export function payJailFine(state, seat) { export function payJailFine(state, seat) {
const s = clone(state); const s = clone(state);