import WebSocket from 'ws'; import fs from 'fs'; import calculateLongestRoad from './longest-road'; import { getValidRoads, getValidCorners } from '../util/validLocations'; import { layout, staticData } from '../util/layout'; require("../console-line") const version = '0.0.1'; if (process.argv.length < 5) { console.error(` usage: npm start SERVER GAME-ID USER For example: npm start https://nuc.ketrenos.com:3000/ketr.ketran robot-wars ai-1 `); process.exit(-1); } const server: string = process.argv[2] as string; const gameId: string = process.argv[3] as string; const name: string = process.argv[4] as string; // Optional flag: --start const startOnFull: boolean = process.argv.includes('--start'); const game: any = {}; const anyValue: any = undefined; process.env["NODE_TLS_REJECT_UNAUTHORIZED"] = '0'; /* Do not use arrow function as this is rebound to have * this as the WebSocket */ let wsRef: WebSocket | undefined; let send = function (data: any) { if (data.type === 'get') { console.log(`ws - send: get`, data.fields); } else { console.log(`ws - send: ${data.type}`); } if (wsRef && wsRef.readyState === WebSocket.OPEN) { wsRef.send(JSON.stringify(data)); } else { console.warn('ws - send called but socket not open'); } }; const error = (e: any) => { console.log(`ws - error`, e); }; const connect = async (): Promise => { const loc = new URL(server as string); let player: string | undefined; try { const data = JSON.parse(await fs.promises.readFile(`${name}.json`, 'utf-8')) as any; player = data.player; } catch (_err) { // Use a minimal fetch options object to avoid RequestInit mismatches const res = await fetch(`${server}/api/v1/games`, ({ headers: { 'Content-Type': 'application/json' } } as any)); if (!res) { throw new Error(`Unable to connect to ${server}`); } const text = await res.text(); player = JSON.parse(text).player; await fs.promises.writeFile(`${name}.json`, JSON.stringify({ name, player })); } console.log(`Connecting to ${server} as ${player}`); const proto = loc.protocol === 'https:' ? 'wss' : 'ws'; const new_uri = `${proto}://${loc.host}/ketr.ketran/api/v1/games/ws/${gameId}`; const ws = new WebSocket(new_uri, [], { headers: { Cookie: `player=${player}` } }); return new Promise((resolve) => { const headers = (_e?: any): void => { console.log(`ws - headers`); }; const open = (): void => { console.log(`ws - open`); resolve(ws); }; const close = (_e?: any): void => { console.log(`ws - close`); }; ws.on('open', open); ws.on('headers', headers); ws.on('close', close); ws.on('error', error); ws.on('message', async (data: WebSocket.Data) => { await message(data); }); // store reference for send() wsRef = ws; }); }; // createPlayer intentionally removed (unused) const types = [ 'wheat', 'brick', 'stone', 'sheep', 'wood' ]; const tryBuild = (_received?: any): any => { let waitingFor: any = undefined; if (!waitingFor && game.private.settlements && game.private.wood && game.private.brick && game.private.sheep && game.private.wheat) { const corners = getValidCorners(game, game.color); if (corners.length) { send({ type: 'buy-settlement' }); waitingFor = { turn: { actions: anyValue } }; } } if (!waitingFor && game.private.cities && game.private.stone >= 3 && game.private.wheat >= 2) { const corners = getValidCorners(game, game.color, 'settlement'); if (corners.length) { send({ type: 'buy-city' }); waitingFor = { turn: { actions: anyValue } }; } } if (!waitingFor && game.private.roads && game.private.wood && game.private.brick) { const roads = getValidRoads(game, game.color); if (roads.length) { send({ type: 'buy-road' }); waitingFor = { turn: { actions: anyValue } }; } } if (!waitingFor && game.private.wheat && game.private.stone && game.private.sheep) { send({ type: 'buy-development' }); waitingFor = { private: { development: anyValue } }; } return waitingFor; }; const tryProgress = (_received?: any): any => { if (!game.private || !game.private.development) { return undefined; } let vps = 0; let firstPlayableProgress: any = undefined; // Count VP cards and remember the first non-VP progress card we can play for (let i = 0; i < game.private.development.length; i++) { const card = game.private.development[i]; // card.turn >= game.turns => newly drawn this turn and can't be played if (card.turn >= game.turns || card.played) { if (card.type === 'vp') vps++; continue; } if (card.type === 'vp') { vps++; continue; } // remember the first playable non-VP card if (!firstPlayableProgress) firstPlayableProgress = card; } // If we have a non-VP progress card to play, play it (existing behavior) if (firstPlayableProgress) { console.log(`${name} - Playing -`, firstPlayableProgress); send({ type: 'play-card', card: firstPlayableProgress }); return { turn: { actions: anyValue } }; } // If we have VP cards, consider playing them to win. Determine victory threshold. if (vps > 0) { const currentPoints = (game.players && game.players[game.color] && game.players[game.color].points) || 0; let victoryThreshold = 10; try { if (game.rules && game.rules['victory-points'] && ('points' in game.rules['victory-points'])) { victoryThreshold = Number(game.rules['victory-points'].points) || victoryThreshold; } } catch (e) { // ignore and use default } // If playing our VP cards would reach or exceed the threshold, play one now if (currentPoints + vps >= victoryThreshold) { // find first unplayed VP card object for (let i = 0; i < game.private.development.length; i++) { const card = game.private.development[i]; if (card.type === 'vp' && !(card.played) && !(card.turn >= game.turns)) { console.log(`${name} - Playing VP to attempt win -`, card); send({ type: 'play-card', card }); return { turn: { actions: anyValue } }; } } } // Otherwise just announce how many VP cards we have send({ type: 'chat', message: `I have ${vps} VP cards!` }); } return undefined; }; let sleeping = false; let paused = false; let winnerAnnounced = false; const sleep = async (delay: number): Promise => { if (sleeping) { return; } sleeping = true; return new Promise((resolve: () => void) => { setTimeout(() => { sleeping = false; resolve(); }, delay); }); }; const bestSettlementPlacement = (game: any): number => { const best = { index: -1, pips: 0 }; /* For each corner that is valid, find out which * tiles are on that corner, and for each of those * tiles, find the pip placement for that tile. */ (game.turn && game.turn.limits && Array.isArray(game.turn.limits.corners) ? game.turn.limits.corners : []).forEach((cornerIndex: any) => { const tiles: Array<{ tile: any; index: number; }> = []; (layout.tiles || []).forEach((tile: any, index: number) => { if ((tile.corners || []).indexOf(cornerIndex) !== -1 && tiles.findIndex(t => t.index === index) === -1) { tiles.push({ tile, index }); } }); let cornerScore = 0; /* Find the tileOrder holding this tile */ tiles.forEach((tile: { tile: any; index: number }) => { const index = tile.index; const pipIndex = game.pipOrder && game.pipOrder[index]; const score = (staticData.pips && staticData.pips[pipIndex] && staticData.pips[pipIndex].pips) || 0; cornerScore += score; }); if (cornerScore > best.pips) { best.index = cornerIndex; best.pips = cornerScore; } }); console.log(`${name} - Corner ${best.index} gives ${best.pips} pips.`); return best.index; } const bestRoadPlacement = (game: any): number => { const road: any = calculateLongestRoad(game) || { index: -1, segments: 0 }; console.log(`${name} - could make road ${road.segments + 1} long on ${road.index}`); let attempt = -1; const layoutAny: any = layout; if (road && typeof road.index === 'number' && road.index !== -1 && layoutAny && layoutAny.roads && layoutAny.roads[road.index] && layoutAny.roads[road.index].corners) { const roadCorners = layoutAny.roads[road.index].corners || []; roadCorners.forEach((cornerIndex: any) => { if (attempt !== -1) { return; } const cornerRoads = (layoutAny.corners && layoutAny.corners[cornerIndex] && layoutAny.corners[cornerIndex].roads) || []; cornerRoads.forEach((roadIndex: any) => { if (attempt !== -1) { return; } const placedRoad = game.placements.roads[roadIndex]; if (!placedRoad || placedRoad.color) { return; } attempt = roadIndex; }); }); } if (game && game.turn && game.turn.limits && Array.isArray(game.turn.limits.roads) && game.turn.limits.roads.indexOf(attempt) !== -1) { console.log(`${name} - attempting to place on end of longest road`); return attempt; } else { console.log(`${name} - selecting a random road location`); const roads = (game && game.turn && game.turn.limits && game.turn.limits.roads) || []; if (!roads.length) return -1; return roads[Math.floor(Math.random() * roads.length)]; } } const isMatch = (input: any, received: any): boolean => { for (let key in input) { /* received update didn't contain this field */ if (!(key in received)) { return false; } /* Received object had a value we were waiting to have set */ if (input[key] === anyValue && (key in received)) { continue; } /* waitingFor field is an object, so recurse */ if (typeof input[key] === 'object') { if (!isMatch(input[key], received[key])) { return false } /* object matched; go to next field */ continue; } /* No match in requested key... */ if (input[key] !== received[key]) { return false; } /* Value matches */ } /* All fields set or matched */ 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: * - `game` is the local snapshot built from previous updates (Object.assign(game, update)). * - `received` is the most recent partial update we got from the server which we use * to check for fields the server just sent in this update cycle. * - `waitingFor` describes which fields we want the server to include in a future * update. A value of `anyValue` means "I don't care what value, but the field must * be present in the update"; a concrete value means "the field must equal this". * * Reconnection edge-case: on reconnect the server may provide `name` only in the * per-player response and/or the `game` snapshot may not yet have `game.name` set * (it might be undefined rather than an empty string). Be tolerant of both cases. */ console.log(`${name} - processLobby start: game.name=${typeof game.name === 'undefined' ? 'undefined' : JSON.stringify(game.name)}, received.name=${typeof received === 'object' && 'name' in received ? JSON.stringify(received.name) : 'undefined'}`); // Prefer whatever the server just sent in `received` (if present), otherwise fall // back to the `game` snapshot. This avoids a strict === '' check which failed for // undefined on reconnect. const effectiveName = (received && typeof received.name !== 'undefined') ? received.name : game.name; if ((!effectiveName || effectiveName === '') && !(received && received.name)) { // If neither the current snapshot nor the latest update provide a game.name, // announce our player-name to the server and wait for confirmation plus // the players/unselected lists. send({ type: 'player-name', name }); /* Wait for the game.name to be set to 'name' and for unselected */ return { name, players: anyValue, unselected: anyValue }; } // If the server included `name` in this update but our `game` snapshot is still // missing it, copy it across so later logic that reads `game.name` will see it. if (received && typeof received.name !== 'undefined' && typeof game.name === 'undefined') { game.name = received.name; } if (!received.unselected) { return { unselected: anyValue }; } /* AI selected a Player. Wait for game-order. * Only treat the bot as already selected if its color is not the 'unassigned' sentinel. * Server may return an empty 'unselected' array even while the player's color * remains 'unassigned', so guard that case explicitly. */ if (received.unselected.indexOf(name) === -1 && game.color !== 'unassigned') { send({ type: 'chat', message: `Woohoo! Robot AI ${version} is alive and playing as ${game.color}!` }); // We are already selected. If there are enough active participants in the // lobby, actively request to start the game by setting the state to // 'game-order'. Otherwise return a waitingFor that asks for the state so // we'll proceed once the server flips it. const participants = (game && game.participants) || []; const activeCount = participants.filter((p: any) => p && p.color && p.color !== 'unassigned').length; if (startOnFull) { if (activeCount >= 2) { console.log(`${name} - ${activeCount} active players detected; requesting start (startOnFull enabled)`); send({ type: 'set', field: 'state', value: 'game-order' }); } else { console.log(`${name} - startOnFull enabled but only ${activeCount} active players; not starting yet`); } } else { console.log(`${name} - auto-start disabled (startOnFull not set); not requesting start even with ${activeCount} active players`); } // Still wait for the server to report state=game-order (either because we // requested it or because another player started it). return { state: 'game-order' }; } const slots: any[] = []; for (let color in game.players) { if (game.players[color].status === 'Not active') { slots.push(color); } } if (slots.length === 0) { send({ chat: `There are no slots for me to play :(. Waiting for one to open up.` }); return { unselected: anyValue }; } const index = Math.floor(Math.random() * slots.length); console.log(`${name} - requesting to play as ${slots[index]}.`); game.unselected = (game.unselected || []).filter((color: any) => color === slots[index]); send({ type: 'set', field: 'color', value: slots[index] }); // If requested, attempt to start the game immediately after claiming a slot // (only when startOnFull is set). This mirrors the reconnect flow and gives // the server a short moment to update player lists before we check counts. if (startOnFull) { setTimeout(() => { try { const participants = (game && game.participants) || []; const activeCount = participants.filter((p: any) => p && p.color && p.color !== 'unassigned').length; console.log(`${name} - (post-claim) startOnFull check: ${activeCount} active players, ${slots.length} unselected slots`); if (activeCount >= 2) { console.log(`${name} - attempting to start game (post-claim)`); send({ type: 'set', field: 'state', value: 'game-order' }); } else { send({ type: 'get', fields: [ 'players', 'participants', 'unselected', 'state' ] }); } } catch (err) { console.warn('post-claim start attempt failed', err); } }, 500); } return { color: slots[index], state: 'game-order' }; }; const processGameOrder = async (_received?: any): Promise => { // The server uses the string 'unassigned' to indicate an open slot. // Previously the code used a falsy-check (!game.color), which fails // because 'unassigned' is truthy. Check explicitly for the sentinel. if (game.color === 'unassigned') { console.log(`game-order - player not active`); return { color: game.color }; } console.log(`game-order - `, { color: game.color, players: game.players }); if (!game.players[game.color].orderRoll || game.players[game.color].tied) { console.log(`Time to roll as ${game.color}`); send({ type: 'roll' }); } return { turn: { color: game.color }}; }; const processInitialPlacement = async (_received?: any): Promise => { /* Fetch the various game order elements so we can make * educated location selections */ if (!game.pipOrder || !game.tileOrder || !game.borderOrder) { return { pipOrder: anyValue, tileOrder: anyValue, borderOrder: anyValue } } if (!game.turn || game.turn.color !== game.color) { return { turn: { color: game.color, } } }; if (!game.placements) { return { turn: { color: game.color, }, placements: anyValue }; } if (!game.turn.actions) { return { turn: { color: game.color, actions: anyValue }, placements: anyValue }; } let index; const type = game.turn.actions[0]; if (type === 'place-road') { index = bestRoadPlacement(game); } else if (type === 'place-settlement') { index = bestSettlementPlacement(game); } console.log(`Selecting ${type} at ${index}`); send({ type, index }); /* Wait for this player's turn again */ return { turn: { color: game.color } }; } /* Start watching for a name entry */ let waitingFor: any = { name: anyValue }; let received: any = {}; // Ignore TS6133 (declared but never used) // @ts-ignore const reducedGame = (game: any): Record => { const filters = [ 'chat', 'activities', 'placements', 'players', 'private', 'dice' ]; const value: Record = {}; for (let key in game) { if (filters.indexOf(key) === -1) { value[key] = game[key]; } else { if (Array.isArray((game as any)[key])) { value[key] = `length(${(game as any)[key].length})`; } else { value[key] = `...filtered`; } } } return value; } const processWaitingFor = (waitingFor: any): void => { const value: { type: string; fields: string[] } = { type: 'get', fields: [] }; console.log(`${name} - waiting for: `, waitingFor); for (let key in waitingFor) { value.fields.push(key); } send(value); received = {}; } const selectResources = async (_received: any): Promise => { if (!game.turn) return { turn: anyValue }; if (!game.turn.actions || game.turn.actions.indexOf('select-resources') === -1) return undefined; return undefined; } const processDiscard = async (_received?: any): Promise => { if (!game.players) { waitingFor = { players: {} }; waitingFor.players[game.color] = undefined; return waitingFor; } let mustDiscard = game.players[game.color].mustDiscard; if (!mustDiscard) { return; } // Intelligent discard heuristic: // - Compute counts for each resource type from our private hand. // - Repeatedly discard from the type we have the most of (most-abundant first). // - Tie-breaker: prefer to discard sheep first, then brick/wood, then wheat/stone // (sheep is typically least critical; wheat/stone useful for cities/devs). const discards: Record = {}; const counts: Record = {}; types.forEach(type => { counts[type] = Math.max(0, Number(game.private[type]) || 0); }); let totalCards = types.reduce((s, t) => s + (counts[t] || 0), 0); if (totalCards === 0) { // nothing to discard (defensive) return; } const tieOrder = [ 'sheep', 'brick', 'wood', 'wheat', 'stone' ]; while (mustDiscard-- > 0 && totalCards > 0) { // pick the resource with the highest count; tie-break using tieOrder let bestType: string | null = null; let bestCount = -1; for (const t of types) { const c = counts[t] || 0; if (c > bestCount) { bestCount = c; bestType = t; } else if (c === bestCount && c > 0 && bestType) { // tie-breaker by tieOrder index (lower index == prefer to discard) const curTie = tieOrder.indexOf(t); const bestTie = tieOrder.indexOf(bestType); if (curTie !== -1 && bestTie !== -1 && curTie < bestTie) { bestType = t; } } } if (!bestType || (counts[bestType] || 0) <= 0) { // no selectable resource left break; } discards[bestType] = (discards[bestType] || 0) + 1; counts[bestType] = (counts[bestType] || 0) - 1; totalCards--; } console.log(`discarding - `, discards); send({ type: 'discard', discards }); waitingFor = { turn: anyValue, players: {} } waitingFor.players[game.color] = anyValue; return waitingFor; }; const processTrade = async (received?: any): Promise => { const enough: string[] = []; let shouldTrade = true; /* Check and see which resources we have enough of */ types.forEach(type => { if (game.private[type] >= 4) { enough.push(type); } }); shouldTrade = enough.length > 0; let least: { type?: string | undefined; count: number } = { type: undefined, count: 0 }; if (shouldTrade) { /* Find out which resource we have the least amount of */ types.forEach(type => { if (game.private[type] <= least.count) { least.type = type; least.count = game.private[type]; } }); if (least.count >= 4) { shouldTrade = false; } } /* If trade not active, see if it should be... */ if (shouldTrade && (!received.turn.actions || received.turn.actions.indexOf('trade') === -1)) { /* Request trade mode, and wait for it... */ console.log(`${name} - starting trade negotiations`); send({ type: 'trade' }); return { turn: { actions: anyValue } } } /* If we do not have enough resources, and trade is active, cancel */ if (!shouldTrade && received.turn.actions && received.turn.actions.indexOf('trade') !== -1) { console.log(`${name} - cancelling trade negotiations`); send({ type: 'trade', action: 'cancel' }); return { turn: anyValue }; } if (!shouldTrade) { return; } const give = { type: enough[Math.floor(Math.random() * enough.length)], count: 4 }, get = { type: least.type, count: 1 }; const offer = { 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) { 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', 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... */ if (!received || !received.turn || !received.turn.offer) { console.log(`trade - `, offer); send({ type: 'trade', action: 'offer', offer }); return { private: { offerRejected: anyValue } }; } return { turn: anyValue }; } const processVolcano = async (_received?: any): Promise => { if (!game.turn || !game.private) { return { turn: anyValue, private: anyValue } }; if (game.turn.actions && game.turn.actions.indexOf('select-resources') !== -1) { console.log(`${name} - TODO - select resources -`, game.turn.select); return; } send({ type: 'roll' }); return { turn: anyValue }; }; const processNormal = async (received?: any): Promise => { let waitingFor: any = undefined; let index: number | undefined; if (!game.turn || !game.private) { return { turn: anyValue, private: anyValue } }; if (!game.pipOrder) { return { pipOrder: anyValue }; } /* Process things that happen on everyone's turn */ waitingFor = await processDiscard(received); if (waitingFor) { return waitingFor; } waitingFor = await selectResources(received); if (waitingFor) { return waitingFor; } /* From here on it is only actions that occur on the player's turn */ if (!received.turn || received.turn.color !== game.color) { console.log(`${name} - waiting for turn... ${game.players && game.players[game.turn && game.turn.color] ? game.players[game.turn.color].name : 'unknown'} is active.`); console.log({ wheat: game.private.wheat, sheep: game.private.sheep, stone: game.private.stone, brick: game.private.brick, wood: game.private.wood, }); return { turn: { color: game.color }, dice: anyValue }; } // If the robber is active on our turn, ensure we have player information // only when a discard might be required. if (game.turn && game.turn.robberInAction) { if (!game.players) { console.log(`${name} - robber in action and players missing; requesting players`); return { players: anyValue }; } const playerObj = game.players[game.color]; const playerResources = (playerObj && typeof playerObj.resources !== 'undefined') ? Number(playerObj.resources) : undefined; 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`); } } console.log(`${name}'s turn. Processing...`); if (!game.dice) { console.log(`${name} - rolling...`); send({ type: 'roll' }); return { turn: { color: game.color }, dice: anyValue }; } if (received.turn && received.turn.actions && received.turn.actions.indexOf('place-road') !== -1) { index = bestRoadPlacement(game); send({ type: 'place-road', index }); return { turn: { color: game.color } }; } if (game.turn.actions && game.turn.actions.indexOf('place-city') !== -1) { index = game.turn.limits.corners[Math.floor(Math.random() * game.turn.limits.corners.length)]; send({ type: 'place-city', index }); return { turn: { color: game.color } }; } if (game.turn.actions && game.turn.actions.indexOf('place-settlement') !== -1) { const index = bestSettlementPlacement(game); send({ type: 'place-settlement', index }); return { turn: { color: game.color } }; } if (game.turn.actions && game.turn.actions.indexOf('place-robber') !== -1) { console.log({ pips: game.turn.limits.pips }); const index = game.turn.limits.pips[Math.floor(Math.random() * game.turn.limits.pips.length)]; console.log(`placing robber - ${index}`) send({ type: 'place-robber', index }); return { turn: { color: game.color } }; } if (game.turn.actions && game.turn.actions.indexOf('steal-resource') !== -1) { if (!game.turn.limits.players) { console.warn(`No players in limits with steal-resource`); return; } const { color } = game.turn.limits.players[Math.floor(Math.random() * game.turn.limits.players.length)]; console.log(`stealing resouce from ${game.players[color].name}`); send({ type: 'steal-resource', color }); return; } if (game.turn.robberInAction) { console.log({ turn: game.turn }); return; } console.log({ wheat: game.private.wheat, sheep: game.private.sheep, stone: game.private.stone, brick: game.private.brick, wood: game.private.wood, }); waitingFor = await tryBuild(received); if (waitingFor) { return waitingFor; } waitingFor = await tryProgress(received); if (waitingFor) { return waitingFor; } waitingFor = await processTrade(received); if (waitingFor) { return waitingFor; } console.log(`${name} - passing`); send({ type: 'pass' }); return { turn: anyValue }; }; const message = async (data: WebSocket.Data): Promise => { let parsed: any = data; try { const text = typeof data === 'string' ? data : data.toString(); parsed = JSON.parse(text); } catch (error) { console.error(error); console.log(data); return; } const msg: any = parsed; switch (msg.type) { case 'warning': if (game.turn && game.turn.color === game.color && game.state !== 'lobby') { console.log(`WARNING: ${msg.warning}.`); // If robber is in action or we may need to discard, do not blindly pass. if (game.turn.robberInAction) { console.log(`${name} - WARNING received while robber in action; ensuring players data`); // Request players so we learn mustDiscard fields and can act accordingly. waitingFor = { players: anyValue }; processWaitingFor(waitingFor); } else { console.log(`${name} - not in robber action; passing.`); send({ type: 'pass' }); waitingFor = { turn: { color: game.color } }; processWaitingFor(waitingFor); } } 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'); const reUnpause = new RegExp(`${name}: unpause`, 'i'); for (let i = 0; i < msg.update.chat.length; i++) { if (msg.update.chat[i].message.match(rePause)) { newState = true; continue; } if (msg.update.chat[i].message.match(reUnpause)) { newState = false; continue; } } if (newState !== paused) { paused = newState; send({ type: 'chat', message: `Robot AI is now ${paused ? '' : 'un'}paused.` }); } } if (paused) { if (waitingFor) Object.assign(received, msg.update); return; } 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); // 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; } } // 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) { case undefined: case 'lobby': waitingFor = await processLobby(received); if (waitingFor) processWaitingFor(waitingFor); return; case 'game-order': waitingFor = await processGameOrder(received); if (waitingFor) processWaitingFor(waitingFor); return; case 'initial-placement': waitingFor = await processInitialPlacement(received); if (waitingFor) processWaitingFor(waitingFor); return; case 'volcano': waitingFor = await processVolcano(received); if (waitingFor) processWaitingFor(waitingFor); return; case 'normal': waitingFor = await processNormal(received); if (waitingFor) processWaitingFor(waitingFor); return; } break; case 'ping': if (!game.state && !received.state) { console.log(`ping received with no game. Sending update request`); send({ type: 'game-update' }); } return; default: console.log(`Received: ${msg.type} while game state = ${game.state}`); break; } } const ai = async () => { send({ type: 'get', fields: [ 'dice', 'name', 'color', 'state', 'placements' ] }); } connect().then(() => { ai() .catch((error) => { console.error(error); if (wsRef) { try { wsRef.close(); } catch (_) { } } }); }) .catch((error) => { console.error(error); });