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:
parent
ae2f3246dc
commit
684f5ed7b2
Binary file not shown.
|
|
@ -44,6 +44,11 @@
|
|||
"file": "track09.mp3",
|
||||
"artist": "Jeff the Sloth",
|
||||
"title": "As Fast as I can Go"
|
||||
},
|
||||
{
|
||||
"file": "track10.mp3",
|
||||
"artist": "Back to Basics",
|
||||
"title": "M83"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -15,6 +15,17 @@ const PROFILES = {
|
|||
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 noise(n) { return (Math.random() - 0.5) * n; }
|
||||
|
||||
|
|
@ -56,6 +67,97 @@ export function chooseJailAction(state, seat, skill) {
|
|||
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 won’t break up a monopoly I’ve 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 = 'I’ll pass — that doesn’t move me.';
|
||||
}
|
||||
return { accept, reason };
|
||||
}
|
||||
|
||||
// ── Build decisions ────────────────────────────────────────────────────────────
|
||||
// Returns { action: 'house'|'hotel', spaceIdx } or null
|
||||
export function chooseBuild(state, seat, skill) {
|
||||
|
|
|
|||
|
|
@ -17,8 +17,9 @@ import {
|
|||
mortgageProperty, unmortgageProperty, payJailFine, useJailCard,
|
||||
applyCardEffect, applyRent, endTurn, checkGameOver, calculateRent,
|
||||
canBuildHouse, canBuildHotel, ownsGroup, netWorth,
|
||||
isTradeable, validateTrade, applyTrade,
|
||||
} from './MonopolyLogic.js';
|
||||
import { chooseBuy, chooseBid, chooseJailAction, chooseBuild, nextThinkDelay } from './MonopolyAI.js';
|
||||
import { chooseBuy, chooseBid, chooseJailAction, chooseBuild, nextThinkDelay, evaluateTrade } from './MonopolyAI.js';
|
||||
|
||||
// ── Layout ────────────────────────────────────────────────────────────────────
|
||||
const BL = 30; // board left
|
||||
|
|
@ -83,6 +84,15 @@ export default class MonopolyGame extends Phaser.Scene {
|
|||
this.modalOrigin = null;
|
||||
// Card draw animation flag — suppresses static popup until animation finishes
|
||||
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() {
|
||||
|
|
@ -483,7 +493,7 @@ export default class MonopolyGame extends Phaser.Scene {
|
|||
if (this.gs.phase === 'auction' && this.gs.pendingAuction) this.drawAuctionPanel();
|
||||
if (this.modalActive && this.gs.phase === 'buy') this.drawModalBuyButtons();
|
||||
// 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();
|
||||
}
|
||||
|
||||
|
|
@ -746,6 +756,7 @@ export default class MonopolyGame extends Phaser.Scene {
|
|||
})) {
|
||||
btnCount++;
|
||||
}
|
||||
if (phase === 'endturn' && this.canInitiateTrade()) btnCount++;
|
||||
}
|
||||
|
||||
// Second pass: draw buttons aligned to board bottom (BT + BS)
|
||||
|
|
@ -796,6 +807,10 @@ export default class MonopolyGame extends Phaser.Scene {
|
|||
if (canMortgage || canUnmortgage) {
|
||||
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
|
||||
|
|
@ -1325,6 +1340,596 @@ export default class MonopolyGame extends Phaser.Scene {
|
|||
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 ──────────────────────────────────────────────────────────────
|
||||
showGameOver() {
|
||||
const winner = this.gs.winner !== null ? this.gs.players[this.gs.winner] : null;
|
||||
|
|
|
|||
|
|
@ -678,6 +678,86 @@ export function unmortgageProperty(state, seat, spaceIdx) {
|
|||
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 ──────────────────────────────────────────────────────────────────────
|
||||
export function payJailFine(state, seat) {
|
||||
const s = clone(state);
|
||||
|
|
|
|||
Loading…
Reference in New Issue