diff --git a/client/src/Winner.tsx b/client/src/Winner.tsx index b2957d6..82f287b 100644 --- a/client/src/Winner.tsx +++ b/client/src/Winner.tsx @@ -20,7 +20,14 @@ const Winner: React.FC = ({ winnerDismissed, setWinnerDismissed }) const { lastJsonMessage, sendJsonMessage } = useContext(GlobalContext); const [winner, setWinner] = useState(undefined); const [state, setState] = useState(undefined); - const fields = useMemo(() => ["winner", "state"], []); + // Track the client's color so we can tell if the current user is an observer + // (observers don't have a color assigned). Observers should remain on the + // winner screen even if the server advances the global state to `lobby`. + const [color, setColor] = useState(undefined); + // Include color/private so this component can determine whether the local + // session is an observer. We purposely request these minimal fields here + // to avoid coupling Winner to higher-level RoomView state handling. + const fields = useMemo(() => ["winner", "state", "color", "private"], []); useEffect(() => { if (!lastJsonMessage) { return; @@ -33,11 +40,39 @@ const Winner: React.FC = ({ winnerDismissed, setWinnerDismissed }) if ("winner" in data.update && !equal(data.update.winner, winner)) { setWinner(data.update.winner); } + // Keep track of our client color so we can special-case observer behavior + // (observers have no color). This allows an observer to stay on the + // winner screen even when the server moves the game back to the lobby. + if ("color" in data.update && data.update.color !== color) { + setColor(data.update.color); + } if ("state" in data.update && data.update.state !== state) { + // If the server leaves the winner state we normally clear the + // winner data. However, if the local user is an observer (no + // `color`) and they were viewing the winner screen, we keep the + // winner visible until they explicitly click "Go back to Lobby". + // This lets observers continue to view final game stats and the + // chat even as the server advances global state to `lobby`. + const isObserver = typeof color === "undefined" || color === null; + const leavingWinner = state === "winner" && data.update.state !== "winner"; + if (data.update.state !== "winner") { - setWinner(undefined); + if (isObserver && leavingWinner) { + // OBSERVER SPECIAL CASE: don't clear `winner` here. Still update + // the internal `state` so other parts of the UI know the server + // state changed, but keep rendering the winner panel for the + // observer until they click the button. + } else { + setWinner(undefined); + } } - setWinnerDismissed(false); + + // Only reset the dismissed flag when the server re-enters winner + // (otherwise preserve user's dismissal choice). + if (data.update.state === "winner") { + setWinnerDismissed(false); + } + setState(data.update.state); } break; @@ -58,8 +93,17 @@ const Winner: React.FC = ({ winnerDismissed, setWinnerDismissed }) const quitClicked = useCallback(() => { if (!winnerDismissed) { setWinnerDismissed(true); + // Tell server we want to go back to the lobby. Also immediately + // request the latest room/game fields so the client receives the + // updated lobby/game state and can continue observing the current + // game id. This helps observers remain connected to the same game + // record after the server transitions them. + sendJsonMessage({ type: "goto-lobby" }); sendJsonMessage({ - type: "goto-lobby", + type: "get", + // Request a conservative set of fields required to continue + // observing the current game/lobby state. + fields: ["id", "state", "color", "private", "players"], }); } }, [sendJsonMessage, winnerDismissed, setWinnerDismissed]); diff --git a/server/ai/app.ts b/server/ai/app.ts index 5c70acd..ef25ffe 100644 --- a/server/ai/app.ts +++ b/server/ai/app.ts @@ -229,6 +229,7 @@ const tryProgress = (_received?: any): any => { let sleeping = false; let paused = false; +let winnerAnnounced = false; const sleep = async (delay: number): Promise => { if (sleeping) { @@ -346,6 +347,78 @@ const isMatch = (input: any, received: any): boolean => { return true; }; +// Helper: deep clone/compare simple JSON-able values +const deepClone = (v: any) => { + try { return JSON.parse(JSON.stringify(v)); } catch (e) { return v; } +}; +// Stable stringify: recursively sort object keys so output is deterministic +const stableStringify = (v: any): string => { + if (v === null) return 'null'; + const t = typeof v; + if (t === 'number' || t === 'boolean' || t === 'string') return JSON.stringify(v); + if (Array.isArray(v)) { + return '[' + v.map((el) => stableStringify(el)).join(',') + ']'; + } + if (t === 'object') { + const keys = Object.keys(v).sort(); + return '{' + keys.map(k => JSON.stringify(k) + ':' + stableStringify((v as any)[k])).join(',') + '}'; + } + // fallback + try { return JSON.stringify(v); } catch (e) { return String(v); } +}; + +const deepEqual = (a: any, b: any) => { + try { return stableStringify(a) === stableStringify(b); } catch (e) { return a === b; } +}; + +// Helper: get value at dot-separated path (path may be array or string) +const getAtPath = (obj: any, path: string): any => { + if (!path) return undefined; + const parts = path.split('.'); + let cur: any = obj; + for (let i = 0; i < parts.length; i++) { + if (cur === undefined || cur === null) return undefined; + const p = parts[i]; + // @ts-ignore - dynamic index; we've checked cur for null/undefined + cur = (cur as any)[p]; + } + return cur; +}; + +// Helper: collect all leaf paths from waitingFor object (returns dot-separated) +const collectLeafPaths = (obj: any, prefix = ''): string[] => { + const paths: string[] = []; + if (obj === anyValue || obj === undefined) { + // leaf + if (prefix) paths.push(prefix); + return paths; + } + if (typeof obj !== 'object' || obj === null) { + if (prefix) paths.push(prefix); + return paths; + } + for (const k of Object.keys(obj)) { + const next = prefix ? `${prefix}.${k}` : k; + paths.push(...collectLeafPaths(obj[k], next)); + } + return paths; +}; + +// Helper: check if msg.update explicitly contains the full path (leaf present in the incremental update) +const msgUpdateContainsPath = (update: any, path: string): boolean => { + if (!update) return false; + const parts = path.split('.'); + let cur: any = update; + for (let i = 0; i < parts.length; i++) { + if (cur === undefined || cur === null || typeof cur !== 'object') return false; + const key: PropertyKey = parts[i] as PropertyKey; + if (!Object.prototype.hasOwnProperty.call(cur, key)) return false; + // @ts-ignore - dynamic index guarded by previous checks + cur = (cur as any)[key]; + } + return true; +}; + const processLobby = (received: any): any => { /* * Lobby flow notes: @@ -723,22 +796,120 @@ const processTrade = async (received?: any): Promise => { gives: [give], gets: [get] }; + // Helper: can our private resources satisfy a list of required give items? + const privateCanGive = (items: Array): boolean => { + if (!game.private) return false; + for (let i = 0; i < (items || []).length; i++) { + const it = items[i]; + if (!it) continue; + if (it.type === 'bank') { + // bank entries refer to a give type in our own player.gives; treat as not satisfiable here + return false; + } + const have = Number(game.private[it.type] || 0); + if (have < (it.count || 0)) return false; + } + return true; + }; + // Helper: do two offers represent matching terms (p.gives == myGets && p.gets == myGives) + const offersMatch = (p: any, myOffer: any): boolean => { + if (!p || !myOffer) return false; + // compare lengths first + if ((p.gives || []).length !== (myOffer.gets || []).length) return false; + if ((p.gets || []).length !== (myOffer.gives || []).length) return false; + // each give in p must match a get in myOffer + for (let i = 0; i < (p.gives || []).length; i++) { + const g = p.gives[i]; + let found = false; + for (let j = 0; j < (myOffer.gets || []).length; j++) { + const mg = myOffer.gets[j]; + if (g.type === mg.type && g.count === mg.count) { + found = true; + break; + } + } + if (!found) return false; + } + for (let i = 0; i < (p.gets || []).length; i++) { + const g = p.gets[i]; + let found = false; + for (let j = 0; j < (myOffer.gives || []).length; j++) { + const mg = myOffer.gives[j]; + if (g.type === mg.type && g.count === mg.count) { + found = true; + break; + } + } + if (!found) return false; + } + return true; + }; + + // If there's a current turn-level offer, decide how to respond. if (received && received.turn && received.turn.offer) { - send({ - type: 'trade', - action: 'accept', - offer: { - name: 'The bank', - gets: [{ type: get.type, count: 1 }], - gives: [{ type: give.type, count: give.count }] + const turnOffer = received.turn.offer; + + // If the offer originates from another player (not the bank), then we (as the active player) + // should accept if we can satisfy the terms, otherwise explicitly reject. + if (turnOffer.name && turnOffer.name !== 'The bank') { + // Active player accepting other player's offer must be able to give what that player requests (offer.gets) + if (privateCanGive(turnOffer.gets || [])) { + send({ type: 'trade', action: 'accept', offer: turnOffer }); + return { turn: { actions: anyValue } }; + } else { + send({ type: 'trade', action: 'reject', offer: turnOffer }); + return { turn: anyValue }; } - }); - return { - turn: { - actions: anyValue + } + + // If the current offer is a bank offer, wait to allow other players to respond + // and prefer player trades over the bank. + const TRADE_RESPONSE_DELAY_MS = 15000; + await new Promise(r => setTimeout(r, TRADE_RESPONSE_DELAY_MS)); + + // Find our own player entry in the received players (our proposed offer) + const myEntry = (received.players && received.players[game.color]) || null; + const myOffer = myEntry && (myEntry.gives || myEntry.gets) ? { gives: myEntry.gives, gets: myEntry.gets, name: myEntry.name, color: myEntry.color } : null; + + if (myOffer && myOffer.gives && myOffer.gives.length && myOffer.gets && myOffer.gets.length) { + // Scan other players to see if any submitted a matching counter-offer. If found and they can meet it, + // accept that player trade. If they can't meet it, explicitly reject their offer. + for (const color in (received.players || {})) { + if (!Object.prototype.hasOwnProperty.call(received.players, color)) continue; + const p = received.players[color]; + if (!p || p.color === game.color) continue; + if (p.status !== 'Active') continue; + if (!p.gives || !p.gets) continue; + if (!offersMatch(p, myOffer)) continue; + + // If the candidate player requests resources that we can provide, accept their offer. + if (privateCanGive(p.gets || [])) { + send({ type: 'trade', action: 'accept', offer: { name: p.name, color: p.color, gives: p.gives, gets: p.gets } }); + return { turn: { actions: anyValue } }; + } else { + // Cannot meet their terms — explicitly reject so the server will know. + send({ type: 'trade', action: 'reject', offer: { name: p.name, color: p.color, gives: p.gives, gets: p.gets } }); + } } - }; + } + + // If no player trades were accepted, fall back to the bank trade (if we can still give the required resources). + if (privateCanGive([{ type: give.type, count: give.count }])) { + send({ + type: 'trade', + action: 'accept', + offer: { + name: 'The bank', + gets: [{ type: get.type, count: 1 }], + gives: [{ type: give.type, count: give.count }] + } + }); + return { turn: { actions: anyValue } }; + } + + // If we cannot satisfy the bank trade, do nothing this cycle. + return { turn: anyValue }; } /* Initiate offer... */ @@ -984,7 +1155,96 @@ const message = async (data: WebSocket.Data): Promise => { break; case 'game-update': + // Preserve previous snapshot to detect whether waited-for fields actually changed + const oldGameSnapshot = deepClone(game); Object.assign(game, msg.update || {}); + // If the server just announced a winner, send a congratulatory chat + // and then dismiss the winner dialog by navigating back to the lobby. + if (msg.update && (msg.update.winner || msg.update.state === 'winner')) { + // If we got the full winner payload, use it. Otherwise request it from the server. + if (msg.update.winner) { + const w = msg.update.winner as any; + if (!winnerAnnounced) { + let message = ''; + try { + // If we won, be witty. Use turns/points if available. + if (w.name === name) { + const pts = w.points !== undefined ? w.points : 'many'; + const turns = w.turns !== undefined ? w.turns : 'a few'; + message = `Victory! I won with ${pts} points after ${turns} turns. Beep boop — humility.exe not found.`; + } else { + const pts = w.points !== undefined ? ` with ${w.points} points` : ''; + message = `Congratulations ${w.name}${pts}! Well played.`; + } + } catch (e) { + message = `Congratulations ${w.name || 'winner'}!`; + } + send({ type: 'chat', message }); + // After a short pause show the lobby (dismiss winner dialog) + setTimeout(() => { + try { + send({ type: 'goto-lobby' }); + // Immediately request lobby information so we can decide whether to + // auto-start (startOnFull) as soon as the server transitions back + // to the lobby. This avoids waiting for an unrelated update cycle. + setTimeout(() => { + try { + send({ type: 'get', fields: ['players', 'participants', 'unselected', 'state'] }); + } catch (err) { + console.warn('Failed to request lobby fields', err); + } + }, 250); + } catch (err) { + console.warn('Failed to send goto-lobby', err); + } + }, 1200); + winnerAnnounced = true; + } + } else { + // We were notified the state is 'winner' but didn't receive the winner object. + // Request the winner field from the server. If it arrives shortly, the + // handler above will process it. Otherwise fall back to a generic message + // and return to the lobby. + try { + send({ type: 'get', fields: ['winner'] }); + } catch (err) { + console.warn('Failed to request winner field', err); + } + + // Fallback after a short wait if full winner info doesn't arrive. + setTimeout(() => { + if (winnerAnnounced) return; + let msgText = 'Congratulations to the winner!'; + try { + // If we have some partial info in game, try to include it + if (game && game.turn && game.turn.name) { + msgText = `Congratulations ${game.turn.name}!`; + } + } catch (e) { + // ignore + } + send({ type: 'chat', message: msgText }); + try { + send({ type: 'goto-lobby' }); + // Ask for lobby state so we can react quickly (claim slot / auto-start) + setTimeout(() => { + try { + send({ type: 'get', fields: ['players', 'participants', 'unselected', 'state'] }); + } catch (err) { + console.warn('Failed to request lobby fields (fallback)', err); + } + }, 250); + } catch (err) { + console.warn('Failed to send goto-lobby (fallback)', err); + } + winnerAnnounced = true; + }, 2000); + } + } + // If the server clears the winner/state, reset our announced flag so we can handle future games + if (msg.update && typeof msg.update.state !== 'undefined' && msg.update.state !== 'winner') { + winnerAnnounced = false; + } if (msg.update && msg.update.chat) { let newState = paused; const rePause = new RegExp(`${name}: pause`, 'i'); @@ -999,16 +1259,53 @@ const message = async (data: WebSocket.Data): Promise => { if (sleeping) { if (waitingFor) Object.assign(received, msg.update); console.log(`${name} - sleeping`); return; } if (waitingFor) { + // Accumulate recent update fields for debugging Object.assign(received, msg.update); - if (!isMatch(waitingFor, received)) { + + // First ensure presence in the merged snapshot + if (!isMatch(waitingFor, game)) { if (game.turn && game.turn.robberInAction) { console.log(`${name} - robber in action! Must check discards...`); } else { return; } - } else { - console.log(`${name} - received match - received: `, msg.update); - console.log(`${name} - going to sleep`); - await sleep(1000 + Math.random() * 500); - console.log(`${name} - waking up`); - waitingFor = undefined; } + + // Now require that at least one of the waited-for leaf paths actually + // changed in this update (or was explicitly provided in msg.update). + // For leaves equal to `anyValue` we accept any change; for concrete + // values we accept only if the new value equals the concrete value. + const paths = collectLeafPaths(waitingFor); + let changeDetected = false; + for (const p of paths) { + const oldVal = getAtPath(oldGameSnapshot, p); + const newVal = getAtPath(game, p); + + // If the incremental update explicitly contained the path, treat that as a change. + if (msg.update && msgUpdateContainsPath(msg.update, p)) { + changeDetected = true; + break; + } + + // If old and new differ, that's a change + if (!deepEqual(oldVal, newVal)) { + changeDetected = true; + break; + } + + // If the waiting leaf expects a concrete value (not anyValue) and the + // value already equals that concrete value, consider that satisfied + // only if it changed to that value in this update (above checks cover it). + } + + if (!changeDetected) { + // No relevant change occurred in this update; continue waiting. + return; + } + + // We found a change and presence is satisfied + const delay = 1000 + Math.random() * 500; + console.log(`${name} - received match - received: `, msg.update); + console.log(`${name} - going to sleep for ${Math.round(delay)}ms`); + await sleep(delay); + console.log(`${name} - waking up to wait for new socket messages...`); + waitingFor = undefined; } switch (game.state) {