Fix AI at Winner
This commit is contained in:
parent
dfce3aa2f4
commit
c6bb6c0ad5
@ -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") {
|
||||
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<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]);
|
||||
|
335
server/ai/app.ts
335
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<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,22 +796,120 @@ 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) {
|
||||
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<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) {
|
||||
|
Loading…
x
Reference in New Issue
Block a user