diff --git a/public/assets/music/track10.mp3 b/public/assets/music/track10.mp3 new file mode 100644 index 0000000..e45bc86 Binary files /dev/null and b/public/assets/music/track10.mp3 differ diff --git a/public/data/music.json b/public/data/music.json index 6365b83..13e695e 100644 --- a/public/data/music.json +++ b/public/data/music.json @@ -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" } ] } \ No newline at end of file diff --git a/public/src/games/monopoly/MonopolyAI.js b/public/src/games/monopoly/MonopolyAI.js index 031dbdb..26a43fa 100644 --- a/public/src/games/monopoly/MonopolyAI.js +++ b/public/src/games/monopoly/MonopolyAI.js @@ -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) { diff --git a/public/src/games/monopoly/MonopolyGame.js b/public/src/games/monopoly/MonopolyGame.js index 55b584e..09ce829 100644 --- a/public/src/games/monopoly/MonopolyGame.js +++ b/public/src/games/monopoly/MonopolyGame.js @@ -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; diff --git a/public/src/games/monopoly/MonopolyLogic.js b/public/src/games/monopoly/MonopolyLogic.js index fade16e..837be6c 100644 --- a/public/src/games/monopoly/MonopolyLogic.js +++ b/public/src/games/monopoly/MonopolyLogic.js @@ -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);