1
0

Fix AI at Winner

This commit is contained in:
James Ketr 2025-10-13 17:28:22 -07:00
parent dfce3aa2f4
commit c6bb6c0ad5
2 changed files with 364 additions and 23 deletions

View File

@ -20,7 +20,14 @@ const Winner: React.FC<WinnerProps> = ({ winnerDismissed, setWinnerDismissed })
const { lastJsonMessage, sendJsonMessage } = useContext(GlobalContext);
const [winner, setWinner] = useState<any>(undefined);
const [state, setState] = useState<string | undefined>(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<string | undefined>(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<WinnerProps> = ({ 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") {
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);
}
}
// 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<WinnerProps> = ({ 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]);

View File

@ -229,6 +229,7 @@ const tryProgress = (_received?: any): any => {
let sleeping = false;
let paused = false;
let winnerAnnounced = false;
const sleep = async (delay: number): Promise<void> => {
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,8 +796,106 @@ const processTrade = async (received?: any): Promise<any> => {
gives: [give],
gets: [get]
};
// Helper: can our private resources satisfy a list of required give items?
const privateCanGive = (items: Array<any>): 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) {
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 };
}
}
// 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',
@ -734,11 +905,11 @@ const processTrade = async (received?: any): Promise<any> => {
gives: [{ type: give.type, count: give.count }]
}
});
return {
turn: {
actions: anyValue
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<void> => {
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<void> => {
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) {