1
0
2025-10-13 17:28:22 -07:00

1365 lines
44 KiB
TypeScript

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<WebSocket> => {
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<WebSocket>((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<void> => {
if (sleeping) {
return;
}
sleeping = true;
return new Promise<void>((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<any> => {
// 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<any> => {
/* 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<string, any> => {
const filters = [ 'chat', 'activities', 'placements', 'players', 'private', 'dice' ];
const value: Record<string, any> = {};
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<any> => {
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<any> => {
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<string, number> = {};
const counts: Record<string, number> = {};
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<any> => {
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<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',
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<any> => {
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<any> => {
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<void> => {
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);
});