diff --git a/client/src/Trade.tsx b/client/src/Trade.tsx index 4793070..13f6950 100644 --- a/client/src/Trade.tsx +++ b/client/src/Trade.tsx @@ -1,18 +1,17 @@ -import React, { useState, useCallback, useEffect, useContext, useMemo, useRef } from "react"; -import equal from "fast-deep-equal"; +import React, { useState, useCallback, useEffect, useContext, useMemo, useRef } from 'react'; +import equal from 'fast-deep-equal'; -import Paper from "@mui/material/Paper"; -import Button from "@mui/material/Button"; -import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward"; -import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward"; +import Paper from '@mui/material/Paper'; +import Button from '@mui/material/Button'; +import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; +import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; -import { Resource } from "./Resource"; -import { PlayerColor } from "./PlayerColor"; -import { GlobalContext } from "./GlobalContext"; +import { Resource } from './Resource'; +import { PlayerColor } from './PlayerColor'; +import { GlobalContext } from './GlobalContext'; +import { assetsPath } from './Common'; -import "./Trade.css"; - -/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */ +import './Trade.css'; interface Resources { wheat: number; @@ -51,7 +50,33 @@ const Trade: React.FC = () => { const [players, setPlayers] = useState(undefined); const [color, setColor] = useState(undefined); - const fields = useMemo(() => ["turn", "players", "private", "color"], []); + // Viewer context: either the player's private info or a read-only observer stub. + // Observers (no `private` data) will use a read-only view but should be able + // to see active offers. Use `observerViewMode` to adjust offer visibility rules. + const isPrivObserver = !priv || !priv.name; + const observerViewMode = isPrivObserver; + + // Default color for observers until the full flow is implemented + const defaultObserverColor = observerViewMode ? 'R' : undefined; + + const viewer: any = priv + ? priv + : { + name: '', + color: undefined, + gives: [], + gets: [], + offerRejected: {}, + banks: [], + resources: 0, + }; + const isObserver = !viewer || !viewer.name; + + // Prefer a viewer-specified color. If none, observers default to `defaultObserverColor`, otherwise fall back to server `color`. + const effectiveColor = + viewer && viewer.color ? viewer.color : defaultObserverColor ? defaultObserverColor : color; + + const fields = useMemo(() => ['turn', 'players', 'private', 'color'], []); useEffect(() => { if (!lastJsonMessage) { @@ -59,18 +84,18 @@ const Trade: React.FC = () => { } const data = lastJsonMessage; switch (data.type) { - case "game-update": + case 'game-update': console.log(`trade - game-update: `, data.update); - if ("turn" in data.update && !equal(turn, data.update.turn)) { + if ('turn' in data.update && !equal(turn, data.update.turn)) { setTurn(data.update.turn); } - if ("players" in data.update && !equal(players, data.update.players)) { + if ('players' in data.update && !equal(players, data.update.players)) { setPlayers(data.update.players); } - if ("private" in data.update && !equal(priv, data.update.private)) { + if ('private' in data.update && !equal(priv, data.update.private)) { setPriv(data.update.private); } - if ("color" in data.update && color !== data.update.color) { + if ('color' in data.update && color !== data.update.color) { setColor(data.update.color); } break; @@ -84,25 +109,30 @@ const Trade: React.FC = () => { return; } sendJsonMessage({ - type: "get", + type: 'get', fields, }); }, [sendJsonMessage, fields]); const transfer = useCallback( (type: string, direction: string) => { - if (direction === "give") { + if (direction === 'give') { /* give clicked */ if (gets[type as keyof Resources]) { gets[type as keyof Resources]--; gives[type as keyof Resources] = 0; } else { - if (gives[type as keyof Resources] < priv[type]) { + // Only allow incrementing gives if we have a priv viewer with that resource + if ( + viewer && + viewer[type] !== undefined && + gives[type as keyof Resources] < viewer[type] + ) { gives[type as keyof Resources]++; } gets[type as keyof Resources] = 0; } - } else if (direction === "get") { + } else if (direction === 'get') { /* get clicked */ if (gives[type as keyof Resources]) { gives[type as keyof Resources]--; @@ -118,7 +148,7 @@ const Trade: React.FC = () => { setGets({ ...gets }); setGives({ ...gives }); }, - [setGets, setGives, gets, gives, priv] + [setGets, setGives, gets, gives, viewer] ); const createTransfer = useCallback( @@ -126,7 +156,7 @@ const Trade: React.FC = () => { return (
transfer(resource, "get")} + onClick={() => transfer(resource, 'get')} label={true} type={resource} disabled @@ -134,7 +164,7 @@ const Trade: React.FC = () => { />
{gets[resource as keyof Resources] === gives[resource as keyof Resources] ? ( - "" + '' ) : gets[resource as keyof Resources] > gives[resource as keyof Resources] ? ( ) : ( @@ -142,24 +172,28 @@ const Trade: React.FC = () => { )}
transfer(resource, "give")} + onClick={() => transfer(resource, 'give')} label={true} type={resource} disabled - available={priv ? priv[resource] - gives[resource as keyof Resources] : undefined} + available={ + viewer && viewer[resource] !== undefined + ? viewer[resource] - gives[resource as keyof Resources] + : undefined + } count={gives[resource as keyof Resources]} />
); }, - [gives, gets, transfer, priv] + [gives, gets, transfer, viewer] ); const sendTrade = useCallback( (action: string, offer: any) => { if (sendJsonMessage) { sendJsonMessage({ - type: "trade", + type: 'trade', action, offer, }); @@ -192,7 +226,7 @@ const Trade: React.FC = () => { console.log(gives, gets); trade.gives.forEach((give: any) => (_gives[give.type as keyof Resources] = give.count)); trade.gets.forEach((get: any) => (_gets[get.type as keyof Resources] = get.count)); - sendTrade("offer", trade); + sendTrade('offer', trade); console.log(_gives, _gets); setGives(Object.assign({}, empty, _gives)); setGets(Object.assign({}, empty, _gets)); @@ -200,15 +234,16 @@ const Trade: React.FC = () => { [setGives, setGets, gives, gets, sendTrade] ); - if (!priv || !turn || !turn.actions || turn.actions.indexOf("trade") === -1) { + // Allow observers (no `private` data) to view trades in read-only mode. + if (!turn || !turn.actions || turn.actions.indexOf('trade') === -1) { return <>; } - const transfers = ["brick", "wood", "wheat", "sheep", "stone"].map((resource) => { + const transfers = ['brick', 'wood', 'wheat', 'sheep', 'stone'].map(resource => { return createTransfer(resource); }); - priv.offerRejected = priv.offerRejected ? priv.offerRejected : {}; + const viewerOfferRejected = viewer.offerRejected ? viewer.offerRejected : {}; const canMeetOffer = (player: any, offer: any) => { if (offer.gets.length === 0 || offer.gives.length === 0) { @@ -216,7 +251,7 @@ const Trade: React.FC = () => { } for (let i = 0; i < offer.gets.length; i++) { const get = offer.gets[i]; - if (offer.name === "The bank") { + if (offer.name === 'The bank') { const _gives: any[] = [], _gets: any[] = []; for (const type in gives) { @@ -238,7 +273,7 @@ const Trade: React.FC = () => { return false; } - if (get.type !== "bank") { + if (get.type !== 'bank') { if (gives[get.type as keyof Resources] < get.count) { return false; } @@ -273,8 +308,9 @@ const Trade: React.FC = () => { return; } valid = - offer.gives.find((item: any) => (item.type === get.type || item.type === "*") && item.count === get.count) !== - undefined; + offer.gives.find( + (item: any) => (item.type === get.type || item.type === '*') && item.count === get.count + ) !== undefined; }); if (valid) @@ -284,13 +320,14 @@ const Trade: React.FC = () => { } valid = offer.gets.find( - (item: any) => (item.type === give.type || item.type === "bank") && item.count === give.count + (item: any) => + (item.type === give.type || item.type === 'bank') && item.count === give.count ) !== undefined; }); return valid; }; - const isTurn = turn && turn.color === color ? true : false; + const isTurn = turn && turn.color === effectiveColor ? true : false; const offerClicked = () => { const trade = { @@ -307,20 +344,23 @@ const Trade: React.FC = () => { trade.gets.push({ type: key, count: gets[key as keyof Resources] }); } } - sendTrade("offer", trade); + sendTrade('offer', trade); }; const cancelOffer = (offer: any) => { - sendTrade("cancel", offer); + sendTrade('cancel', offer); }; const acceptClicked = (offer: any) => { - if (offer.name === "The bank") { - sendTrade("accept", Object.assign({}, { name: offer.name, gives: trade.gets, gets: trade.gives })); + if (offer.name === 'The bank') { + sendTrade( + 'accept', + Object.assign({}, { name: offer.name, gives: trade.gets, gets: trade.gives }) + ); } else if (offer.self) { - sendTrade("accept", offer); + sendTrade('accept', offer); } else { - sendTrade("accept", Object.assign({}, offer, { gives: offer.gets, gets: offer.gives })); + sendTrade('accept', Object.assign({}, offer, { gives: offer.gets, gets: offer.gives })); } }; @@ -329,16 +369,18 @@ const Trade: React.FC = () => { /* Player has rejected the active player's bid or active player rejected * the other player's bid */ const rejectClicked = (trade: any) => { - sendTrade("reject", trade); + sendTrade('reject', trade); }; /* Create list of active trades */ const activeTrades: any[] = []; + console.log('trade - building activeTrades', { turn, players, viewer, color }); for (const colorKey in players) { const item = players[colorKey], name = item.name; + console.log(`trade - processing player ${colorKey}`, { item, name, colorKey }); item.offerRejected = item.offerRejected ? item.offerRejected : {}; - if (item.status !== "Active") { + if (item.status !== 'Active') { continue; } /* Only list players with an offer, unless it is the active player (see @@ -346,8 +388,8 @@ const Trade: React.FC = () => { * or the player explicitly rejected the player's offer */ if ( turn.name !== name && - priv.name !== name && - !(colorKey in priv.offerRejected) && + viewer.name !== name && + !(colorKey in (viewer.offerRejected || {})) && (!item.gets || item.gets.length === 0 || !item.gives || item.gives.length === 0) ) { continue; @@ -355,7 +397,7 @@ const Trade: React.FC = () => { const tmp: TradeItem = { negotiator: turn.name === name, - self: priv.name === name, + self: viewer.name === name, name: name, color: colorKey, valid: false, @@ -363,6 +405,24 @@ const Trade: React.FC = () => { gives: item.gives ? item.gives : [], offerRejected: item.offerRejected, }; + console.log('trade - tmp before fallback', { tmp }); + + // If this is the active (negotiating) player but their gets/gives + // are not present in the players map (e.g. observers where private + // data isn't available), fall back to the offer on `turn` so the + // trade line shows the active player's current offer. + if (tmp.negotiator && turn && turn.offer) { + console.log('trade - negotiator detected; turn.offer present', { turnOffer: turn.offer }); + if ((!tmp.gets || tmp.gets.length === 0) && turn.offer.gets) { + console.log('trade - applying turn.offer.gets fallback', { gets: turn.offer.gets }); + tmp.gets = turn.offer.gets.slice(); + } + if ((!tmp.gives || tmp.gives.length === 0) && turn.offer.gives) { + console.log('trade - applying turn.offer.gives fallback', { gives: turn.offer.gives }); + tmp.gives = turn.offer.gives.slice(); + } + } + console.log('trade - tmp after fallback', { tmp }); tmp.canSubmit = !!(tmp.gets.length && tmp.gives.length); @@ -397,17 +457,17 @@ const Trade: React.FC = () => { } } - const isOfferSubmitted = isCompatibleOffer(priv, trade), - isNegiatorSubmitted = turn && turn.offer && isCompatibleOffer(priv, turn.offer), + const isOfferSubmitted = isCompatibleOffer(viewer, trade), + isNegiatorSubmitted = turn && turn.offer && isCompatibleOffer(viewer, turn.offer), isOfferValid = trade.gives.length && trade.gets.length ? true : false; - if (isTurn && priv && priv.banks) { - priv.banks.forEach((bank: string) => { - const count = bank === "bank" ? 3 : 2; + if (isTurn && viewer && viewer.banks) { + viewer.banks.forEach((bank: string) => { + const count = bank === 'bank' ? 3 : 2; activeTrades.push({ name: `The bank`, color: undefined, - gives: [{ count: 1, type: "*" }], + gives: [{ count: 1, type: '*' }], gets: [{ count: count, type: bank }], valid: false, offerRejected: {}, @@ -417,8 +477,8 @@ const Trade: React.FC = () => { activeTrades.push({ name: `The bank`, color: undefined, - gives: [{ count: 1, type: "*" }], - gets: [{ count: 4, type: "bank" }], + gives: [{ count: 1, type: '*' }], + gets: [{ count: 4, type: 'bank' }], valid: false, offerRejected: {}, }); @@ -426,38 +486,45 @@ const Trade: React.FC = () => { if (isTurn) { activeTrades.forEach((offer: any) => { - if (offer.name === "The bank") { + if (offer.name === 'The bank') { /* offer has to be the second parameter for the bank to match */ offer.valid = isCompatibleOffer({ gives: trade.gets, gets: trade.gives }, offer); } else { - offer.valid = !(turn.color in offer.offerRejected) && canMeetOffer(priv, offer); + // For observers, ignore the offerRejected map so the read-only viewer can + // see the active player's offer details. + offer.valid = + (observerViewMode ? true : !(turn.color in offer.offerRejected)) && + canMeetOffer(viewer, offer); } }); } else { const found = activeTrades.find((item: any) => item.name === turn.name); if (found) { - found.valid = !(color! in found.offerRejected) && canMeetOffer(priv, found); + found.valid = + (observerViewMode ? true : !(effectiveColor! in found.offerRejected)) && + canMeetOffer(viewer, found); } } const tradeElements = activeTrades.map((item: any, index: number) => { - const youRejectedOffer = color! in item.offerRejected; + console.log(`trade - rendering trade element ${index}`, { item, index }); + const youRejectedOffer = observerViewMode ? false : effectiveColor! in item.offerRejected; let youWereRejected; if (isTurn) { - youWereRejected = item.color && item.color in priv.offerRejected; + youWereRejected = item.color && item.color in (viewer.offerRejected || {}); } else { - youWereRejected = Object.getOwnPropertyNames(priv.offerRejected).length !== 0; + youWereRejected = Object.getOwnPropertyNames(viewer.offerRejected || {}).length !== 0; } const isNewOffer = item.self && !isOfferSubmitted; let isSameOffer; - const isBank = item.name === "The bank"; + const isBank = item.name === 'The bank'; if (isTurn) { isSameOffer = isCompatibleOffer(trade, { gets: item.gives, gives: item.gets }); } else { - isSameOffer = turn.offer && isCompatibleOffer(priv, turn.offer); + isSameOffer = turn.offer && isCompatibleOffer(viewer, turn.offer); } let source; @@ -472,46 +539,84 @@ const Trade: React.FC = () => { } else { source = item; } - const _gets = source.gets.length + + // Ensure arrays exist so .length checks are safe + source.gets = source.gets || []; + source.gives = source.gives || []; + + // Build display elements and also capture snapshots for logging + const computed_gets = source.gets.length ? source.gets.map((get: any, index: number) => { - if (get.type === "bank") { + if (get.type === 'bank') { return ( - {get.count} of any resource{" "} + {get.count} of any resource{' '} ); } - return ; + return ( + + ); }) - : "nothing"; - const _gives = source.gives.length + : 'nothing'; + + const computed_gives = source.gives.length ? source.gives.map((give: any, index: number) => { - if (give.type === "*") { + if (give.type === '*') { return ( - 1 of any resource{" "} + 1 of any resource{' '} ); } - return ; + return ( + + ); }) - : "nothing"; + : 'nothing'; + + // Log JSON snapshots so the console shows values at log time (not live objects) + try { + console.log(`trade - computed for render ${index}`, { + item: JSON.parse(JSON.stringify(item)), + source: JSON.parse(JSON.stringify(source)), + computed_gets: source.gets.length ? JSON.parse(JSON.stringify(source.gets)) : 'nothing', + computed_gives: source.gives.length ? JSON.parse(JSON.stringify(source.gives)) : 'nothing', + }); + } catch (e) { + // Fallback if stringify fails + console.log(`trade - computed for render ${index}`, { item, source }); + } return (
- {item.self && ( + {item.self && !isObserver && ( <> - {(_gets !== "nothing" || _gives !== "nothing") && ( + {(computed_gets !== 'nothing' || computed_gives !== 'nothing') && ( - You want {_gets} and will give {_gives}. + You want {computed_gets} and will give {computed_gives}. )} - {youWereRejected && !isNewOffer && {turn.name} rejected your offer.} + {youWereRejected && !isNewOffer && !isObserver && ( + {turn.name} rejected your offer. + )} - {!youWereRejected && _gets === "nothing" && _gives === "nothing" && ( + {!youWereRejected && computed_gets === 'nothing' && computed_gives === 'nothing' && ( You have not made a trade offer. )} @@ -519,9 +624,11 @@ const Trade: React.FC = () => { isSameOffer && !youWereRejected && isOfferValid && - _gets !== "nothing" && - _gives !== "nothing" && ( - Your submitted offer agrees with {turn.name}'s terms. + computed_gets !== 'nothing' && + computed_gives !== 'nothing' && ( + + Your submitted offer agrees with {turn.name}'s terms. + )} )} @@ -530,10 +637,10 @@ const Trade: React.FC = () => { <> {(!isTurn || !isSameOffer || isBank) && !youRejectedOffer && - _gets !== "nothing" && - _gives !== "nothing" && ( + computed_gets !== 'nothing' && + computed_gives !== 'nothing' && ( - {item.name} wants {_gets} and will give {_gives}. + {item.name} wants {computed_gets} and will give {computed_gives}. )} @@ -543,20 +650,29 @@ const Trade: React.FC = () => { !isSameOffer && isOfferValid && !youRejectedOffer && - _gets !== "nothing" && - _gives !== "nothing" && This is a counter offer.} + computed_gets !== 'nothing' && + computed_gives !== 'nothing' && ( + This is a counter offer. + )} - {isTurn && isSameOffer && !youRejectedOffer && _gets !== "nothing" && _gives !== "nothing" && ( - {item.name} will meet your terms. + {isTurn && + isSameOffer && + !youRejectedOffer && + computed_gets !== 'nothing' && + computed_gives !== 'nothing' && {item.name} will meet your terms.} + + {(!isTurn || !youWereRejected) && + (computed_gets === 'nothing' || computed_gives === 'nothing') && ( + {item.name} has not submitted a trade offer. + )} + + {youRejectedOffer && !isObserver && ( + You rejected {item.name}'s offer. )} - {(!isTurn || !youWereRejected) && (_gets === "nothing" || _gives === "nothing") && ( - {item.name} has not submitted a trade offer. + {isTurn && youWereRejected && !isObserver && ( + {item.name} rejected your offer. )} - - {youRejectedOffer && You rejected {item.name}'s offer.} - - {isTurn && youWereRejected && {item.name} rejected your offer.} )} @@ -570,27 +686,32 @@ const Trade: React.FC = () => { )} {!isTurn && item.color === turn.color && ( - )} - {item.name !== "The bank" && !item.self && (isTurn || item.name === turn.name) && ( + {item.name !== 'The bank' && !item.self && (isTurn || item.name === turn.name) && ( )} - {item.self && ( + {item.self && !isObserver && ( )} - {item.self && ( + {item.self && !isObserver && ( @@ -603,12 +724,17 @@ const Trade: React.FC = () => { return (
{tradeElements}
- {priv.resources === 0 && ( + {!priv && ( +
+ Read-only: observers can view offers but cannot participate in trades. +
+ )} + {priv && priv.resources === 0 && (
You have no resources to participate in this trade.
)} - {priv.resources !== 0 && ( + {priv && priv.resources !== 0 && (
Get
diff --git a/server/ai/app.ts b/server/ai/app.ts index ef25ffe..69780d5 100644 --- a/server/ai/app.ts +++ b/server/ai/app.ts @@ -663,8 +663,18 @@ const processDiscard = async (_received?: any): Promise => { return waitingFor; } - let mustDiscard = game.players[game.color].mustDiscard; + // The server may send the per-player `private` update with mustDiscard + // before the aggregated `players` snapshot is delivered (sendUpdateToPlayer + // is issued prior to sendUpdateToPlayers). Prefer the value found in + // `game.players[...]` when present, otherwise fall back to `game.private`. + let mustDiscard: any = undefined; + if (game.players && game.players[game.color] && typeof game.players[game.color].mustDiscard !== 'undefined') { + mustDiscard = game.players[game.color].mustDiscard; + } else if (game.private && typeof game.private.mustDiscard !== 'undefined') { + mustDiscard = game.private.mustDiscard; + } + // No discards required or information not present yet if (!mustDiscard) { return; } @@ -922,8 +932,14 @@ const processTrade = async (received?: any): Promise => { offer }); + // Wait not only for our private.offerRejected flag but also for any players + // updates (other players posting counter-offers or accept/rejects). This + // reduces a race where the proposing AI proceeds before opponents have + // had a chance to respond. return { - private: { offerRejected: anyValue } + private: { offerRejected: anyValue }, + players: anyValue, + turn: { offer: anyValue } }; } @@ -1012,9 +1028,18 @@ const processNormal = async (received?: any): Promise => { const privateTotal = types.reduce((s, t) => s + (Number(game.private && game.private[t]) || 0), 0); const totalResources = (typeof playerResources !== 'undefined') ? playerResources : privateTotal; - if (typeof game.players[game.color].mustDiscard === 'undefined') { - // No discard required (totalResources <= 7); proceed with turn actions. - console.log(`${name} - robber in action but no discard required (totalResources=${totalResources}); proceeding`); + const playerMustDiscard = game.players[game.color] && typeof game.players[game.color].mustDiscard !== 'undefined' ? game.players[game.color].mustDiscard : undefined; + if (typeof playerMustDiscard === 'undefined') { + // If our total resources indicate a discard should be required (>7) but the + // server hasn't provided the `mustDiscard` field yet, explicitly request + // the players payload so we get the information. Otherwise, if total + // resources are <=7, no discard is needed and we can proceed. + if (totalResources > 7) { + console.log(`${name} - robber in action but mustDiscard missing (totalResources=${totalResources}); requesting players`); + return { players: anyValue }; + } else { + console.log(`${name} - robber in action and no discard required (totalResources=${totalResources}); proceeding`); + } } } @@ -1088,7 +1113,7 @@ const processNormal = async (received?: any): Promise => { } if (game.turn.robberInAction) { - console.log({ turn: game.turn }); + console.log({ "turn.name": game.turn.name }); return; }