Fixing AI bots
This commit is contained in:
parent
602e4abece
commit
e092bd5d01
454
server/ai/app.ts
454
server/ai/app.ts
@ -1,12 +1,9 @@
|
|||||||
// @ts-nocheck
|
|
||||||
import fetch from 'node-fetch';
|
|
||||||
import WebSocket from 'ws';
|
import WebSocket from 'ws';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import calculateLongestRoad from './longest-road';
|
import calculateLongestRoad from './longest-road';
|
||||||
|
|
||||||
import { getValidRoads, getValidCorners } from '../util/validLocations';
|
import { getValidRoads, getValidCorners } from '../util/validLocations';
|
||||||
import { layout, staticData } from '../util/layout';
|
import { layout, staticData } from '../util/layout';
|
||||||
|
require("../console-line")
|
||||||
const version = '0.0.1';
|
const version = '0.0.1';
|
||||||
|
|
||||||
if (process.argv.length < 5) {
|
if (process.argv.length < 5) {
|
||||||
@ -21,106 +18,82 @@ For example:
|
|||||||
process.exit(-1);
|
process.exit(-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const server = process.argv[2];
|
const server: string = process.argv[2] as string;
|
||||||
const gameId = process.argv[3];
|
const gameId: string = process.argv[3] as string;
|
||||||
const name = process.argv[4];
|
const name: string = process.argv[4] as string;
|
||||||
|
// Optional flag: --start
|
||||||
|
const startOnFull: boolean = process.argv.includes('--start');
|
||||||
|
|
||||||
const game: any = {};
|
const game: any = {};
|
||||||
const anyValue = undefined;
|
const anyValue: any = undefined;
|
||||||
|
|
||||||
process.env["NODE_TLS_REJECT_UNAUTHORIZED"] = 0;
|
process.env["NODE_TLS_REJECT_UNAUTHORIZED"] = '0';
|
||||||
|
|
||||||
/* Do not use arrow function as this is rebound to have
|
/* Do not use arrow function as this is rebound to have
|
||||||
* this as the WebSocket */
|
* this as the WebSocket */
|
||||||
let send = function (this: WebSocket, data: any) {
|
let wsRef: WebSocket | undefined;
|
||||||
|
let send = function (data: any) {
|
||||||
if (data.type === 'get') {
|
if (data.type === 'get') {
|
||||||
console.log(`ws - send: get`, data.fields);
|
console.log(`ws - send: get`, data.fields);
|
||||||
} else {
|
} else {
|
||||||
console.log(`ws - send: ${data.type}`);
|
console.log(`ws - send: ${data.type}`);
|
||||||
}
|
}
|
||||||
this.send(JSON.stringify(data));
|
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) => {
|
const error = (e: any) => {
|
||||||
console.log(`ws - error`, e);
|
console.log(`ws - error`, e);
|
||||||
};
|
};
|
||||||
|
|
||||||
const connect = async () => {
|
const connect = async (): Promise<WebSocket> => {
|
||||||
let loc = new URL(server), new_uri;
|
const loc = new URL(server as string);
|
||||||
let player;
|
let player: string | undefined;
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(await fs.readFile(`${name}.json`, 'utf-8'));
|
const data = JSON.parse(await fs.promises.readFile(`${name}.json`, 'utf-8')) as any;
|
||||||
player = data.player;
|
player = data.player;
|
||||||
} catch (_) {
|
} catch (_err) {
|
||||||
const res = await fetch(`${server}/api/v1/games`, {
|
// Use a minimal fetch options object to avoid RequestInit mismatches
|
||||||
method: 'GET',
|
const res = await fetch(`${server}/api/v1/games`, ({ headers: { 'Content-Type': 'application/json' } } as any));
|
||||||
cache: 'no-cache',
|
|
||||||
credentials: 'same-origin', /* include cookies */
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (!res) {
|
if (!res) {
|
||||||
throw new Error(`Unable to connect to ${server}`);
|
throw new Error(`Unable to connect to ${server}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
player = JSON.parse(await res.text()).player;
|
const text = await res.text();
|
||||||
await fs.writeFile(`${name}.json`, JSON.stringify({
|
player = JSON.parse(text).player;
|
||||||
name,
|
await fs.promises.writeFile(`${name}.json`, JSON.stringify({ name, player }));
|
||||||
player
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Connecting to ${server} as ${player}`);
|
console.log(`Connecting to ${server} as ${player}`);
|
||||||
|
|
||||||
if (loc.protocol === "https:") {
|
const proto = loc.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
new_uri = "wss";
|
const new_uri = `${proto}://${loc.host}/ketr.ketran/api/v1/games/ws/${gameId}`;
|
||||||
} else {
|
const ws = new WebSocket(new_uri, [], { headers: { Cookie: `player=${player}` } });
|
||||||
new_uri = "ws";
|
|
||||||
}
|
|
||||||
new_uri = `${new_uri}://${loc.host}/ketr.ketran/api/v1/games/ws/${gameId}`;
|
|
||||||
const ws = new WebSocket(new_uri,
|
|
||||||
[],
|
|
||||||
{
|
|
||||||
'headers': {
|
|
||||||
'Cookie': `player=${player}`
|
|
||||||
}
|
|
||||||
});
|
|
||||||
send = send.bind(ws);
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise<WebSocket>((resolve) => {
|
||||||
const headers = (e) => {
|
const headers = (_e?: any): void => { console.log(`ws - headers`); };
|
||||||
console.log(`ws - headers`);
|
const open = (): void => { console.log(`ws - open`); resolve(ws); };
|
||||||
};
|
const close = (_e?: any): void => { console.log(`ws - close`); };
|
||||||
|
|
||||||
const open = (e) => {
|
|
||||||
console.log(`ws - open`);
|
|
||||||
resolve(ws);
|
|
||||||
};
|
|
||||||
|
|
||||||
const connection = (ws) => {
|
|
||||||
console.log("connection request cookie: ", ws.upgradeReq.headers.cookie);
|
|
||||||
};
|
|
||||||
|
|
||||||
const close = (e) => {
|
|
||||||
console.log(`ws - close`);
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.on('open', open);
|
ws.on('open', open);
|
||||||
ws.on('connect', () => { connect(ws); });
|
|
||||||
ws.on('headers', headers);
|
ws.on('headers', headers);
|
||||||
ws.on('close', close);
|
ws.on('close', close);
|
||||||
ws.on('error', error);
|
ws.on('error', error);
|
||||||
ws.on('message', async (data) => { await message(data); });
|
ws.on('message', async (data: WebSocket.Data) => { await message(data); });
|
||||||
|
// store reference for send()
|
||||||
|
wsRef = ws;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const createPlayer = () => {
|
// createPlayer intentionally removed (unused)
|
||||||
};
|
|
||||||
|
|
||||||
const types = [ 'wheat', 'brick', 'stone', 'sheep', 'wood' ];
|
const types = [ 'wheat', 'brick', 'stone', 'sheep', 'wood' ];
|
||||||
|
|
||||||
const tryBuild = () => {
|
const tryBuild = (_received?: any): any => {
|
||||||
let waitingFor = undefined;
|
let waitingFor: any = undefined;
|
||||||
|
|
||||||
if (!waitingFor
|
if (!waitingFor
|
||||||
&& game.private.settlements
|
&& game.private.settlements
|
||||||
@ -193,11 +166,9 @@ const tryBuild = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const tryProgress = () => {
|
const tryProgress = (_received?: any): any => {
|
||||||
let waitingFor = undefined;
|
if (!game.private || !game.private.development) {
|
||||||
|
return undefined;
|
||||||
if (!game.private.development) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let vps = 0;
|
let vps = 0;
|
||||||
@ -227,17 +198,18 @@ const tryProgress = () => {
|
|||||||
message: `I have ${vps} VP cards!`
|
message: `I have ${vps} VP cards!`
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
let sleeping = false;
|
let sleeping = false;
|
||||||
let paused = false;
|
let paused = false;
|
||||||
|
|
||||||
const sleep = async (delay) => {
|
const sleep = async (delay: number): Promise<void> => {
|
||||||
if (sleeping) {
|
if (sleeping) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
sleeping = true;
|
sleeping = true;
|
||||||
return new Promise((resolve) => {
|
return new Promise<void>((resolve: () => void) => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
sleeping = false;
|
sleeping = false;
|
||||||
resolve();
|
resolve();
|
||||||
@ -246,7 +218,7 @@ const sleep = async (delay) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const bestSettlementPlacement = (game) => {
|
const bestSettlementPlacement = (game: any): number => {
|
||||||
const best = {
|
const best = {
|
||||||
index: -1,
|
index: -1,
|
||||||
pips: 0
|
pips: 0
|
||||||
@ -255,11 +227,10 @@ const bestSettlementPlacement = (game) => {
|
|||||||
/* For each corner that is valid, find out which
|
/* For each corner that is valid, find out which
|
||||||
* tiles are on that corner, and for each of those
|
* tiles are on that corner, and for each of those
|
||||||
* tiles, find the pip placement for that tile. */
|
* tiles, find the pip placement for that tile. */
|
||||||
game.turn.limits.corners.forEach(cornerIndex => {
|
(game.turn && game.turn.limits && Array.isArray(game.turn.limits.corners) ? game.turn.limits.corners : []).forEach((cornerIndex: any) => {
|
||||||
const tiles = [];
|
const tiles: Array<{ tile: any; index: number; }> = [];
|
||||||
layout.tiles.forEach((tile, index) => {
|
(layout.tiles || []).forEach((tile: any, index: number) => {
|
||||||
if (tile.corners.indexOf(cornerIndex) !== -1
|
if ((tile.corners || []).indexOf(cornerIndex) !== -1 && tiles.findIndex(t => t.index === index) === -1) {
|
||||||
&& tiles.indexOf(index) === -1) {
|
|
||||||
tiles.push({ tile, index });
|
tiles.push({ tile, index });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -267,11 +238,10 @@ const bestSettlementPlacement = (game) => {
|
|||||||
let cornerScore = 0;
|
let cornerScore = 0;
|
||||||
|
|
||||||
/* Find the tileOrder holding this tile */
|
/* Find the tileOrder holding this tile */
|
||||||
tiles.forEach(tile => {
|
tiles.forEach((tile: { tile: any; index: number }) => {
|
||||||
const index = tile.index;
|
const index = tile.index;
|
||||||
// const tileIndex = game.tileOrder.indexOf(index);
|
const pipIndex = game.pipOrder && game.pipOrder[index];
|
||||||
const pipIndex = game.pipOrder[index];
|
const score = (staticData.pips && staticData.pips[pipIndex] && staticData.pips[pipIndex].pips) || 0;
|
||||||
const score = staticData.pips[pipIndex].pips;
|
|
||||||
cornerScore += score;
|
cornerScore += score;
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -285,17 +255,20 @@ const bestSettlementPlacement = (game) => {
|
|||||||
return best.index;
|
return best.index;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bestRoadPlacement = (game) => {
|
const bestRoadPlacement = (game: any): number => {
|
||||||
const road = calculateLongestRoad(game);
|
const road: any = calculateLongestRoad(game) || { index: -1, segments: 0 };
|
||||||
console.log(`${name} - could make road ${road.segments + 1} long on ${road.index}`);
|
console.log(`${name} - could make road ${road.segments + 1} long on ${road.index}`);
|
||||||
|
|
||||||
let attempt = -1;
|
let attempt = -1;
|
||||||
if (road.index !== -1) {
|
const layoutAny: any = layout;
|
||||||
layout.roads[road.index].corners.forEach(cornerIndex => {
|
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) {
|
if (attempt !== -1) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
layout.corners[cornerIndex].roads.forEach(roadIndex => {
|
const cornerRoads = (layoutAny.corners && layoutAny.corners[cornerIndex] && layoutAny.corners[cornerIndex].roads) || [];
|
||||||
|
cornerRoads.forEach((roadIndex: any) => {
|
||||||
if (attempt !== -1) {
|
if (attempt !== -1) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -308,17 +281,18 @@ const bestRoadPlacement = (game) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (game.turn.limits.roads.indexOf(attempt) !== -1) {
|
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`);
|
console.log(`${name} - attempting to place on end of longest road`);
|
||||||
return attempt;
|
return attempt;
|
||||||
} else {
|
} else {
|
||||||
console.log(`${name} - selecting a random road location`);
|
console.log(`${name} - selecting a random road location`);
|
||||||
return game.turn.limits.roads[Math.floor(
|
const roads = (game && game.turn && game.turn.limits && game.turn.limits.roads) || [];
|
||||||
Math.random() * game.turn.limits.roads.length)];
|
if (!roads.length) return -1;
|
||||||
|
return roads[Math.floor(Math.random() * roads.length)];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const isMatch = (input, received) => {
|
const isMatch = (input: any, received: any): boolean => {
|
||||||
for (let key in input) {
|
for (let key in input) {
|
||||||
/* received update didn't contain this field */
|
/* received update didn't contain this field */
|
||||||
if (!(key in received)) {
|
if (!(key in received)) {
|
||||||
@ -346,29 +320,81 @@ const isMatch = (input, received) => {
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const processLobby = (received) => {
|
const processLobby = (received: any): any => {
|
||||||
if (game.name === '' && !received.name) {
|
/*
|
||||||
|
* 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 });
|
send({ type: 'player-name', name });
|
||||||
/* Wait for the game.name to be set to 'name' and for unselected */
|
/* Wait for the game.name to be set to 'name' and for unselected */
|
||||||
return { name, players: anyValue, unselected: anyValue };
|
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) {
|
if (!received.unselected) {
|
||||||
return {
|
return {
|
||||||
unselected: anyValue
|
unselected: anyValue
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/* AI selected a Player. Wait for game-order */
|
/* AI selected a Player. Wait for game-order.
|
||||||
if (received.unselected.indexOf(name) === -1) {
|
* Only treat the bot as already selected if its color is not the 'unassigned' sentinel.
|
||||||
|
* Some servers 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({
|
send({
|
||||||
type: 'chat',
|
type: 'chat',
|
||||||
message: `Woohoo! Robot AI ${version} is alive and playing as ${game.color}!`
|
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' };
|
return { state: 'game-order' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const slots = [];
|
const slots: any[] = [];
|
||||||
for (let color in game.players) {
|
for (let color in game.players) {
|
||||||
if (game.players[color].status === 'Not active') {
|
if (game.players[color].status === 'Not active') {
|
||||||
slots.push(color);
|
slots.push(color);
|
||||||
@ -384,20 +410,43 @@ const processLobby = (received) => {
|
|||||||
|
|
||||||
const index = Math.floor(Math.random() * slots.length);
|
const index = Math.floor(Math.random() * slots.length);
|
||||||
console.log(`${name} - requesting to play as ${slots[index]}.`);
|
console.log(`${name} - requesting to play as ${slots[index]}.`);
|
||||||
game.unselected = game.unselected.filter(
|
game.unselected = (game.unselected || []).filter((color: any) => color === slots[index]);
|
||||||
color => color === slots[index]);
|
|
||||||
send({
|
send({
|
||||||
type: 'set',
|
type: 'set',
|
||||||
field: 'color',
|
field: 'color',
|
||||||
value: slots[index]
|
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' };
|
return { color: slots[index], state: 'game-order' };
|
||||||
};
|
};
|
||||||
|
|
||||||
const processGameOrder = async () => {
|
const processGameOrder = async (_received?: any): Promise<any> => {
|
||||||
if (!game.color) {
|
// 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`);
|
console.log(`game-order - player not active`);
|
||||||
return { color };
|
return { color: game.color };
|
||||||
}
|
}
|
||||||
console.log(`game-order - `, {
|
console.log(`game-order - `, {
|
||||||
color: game.color,
|
color: game.color,
|
||||||
@ -411,7 +460,7 @@ const processGameOrder = async () => {
|
|||||||
return { turn: { color: game.color }};
|
return { turn: { color: game.color }};
|
||||||
};
|
};
|
||||||
|
|
||||||
const processInitialPlacement = async (received) => {
|
const processInitialPlacement = async (_received?: any): Promise<any> => {
|
||||||
/* Fetch the various game order elements so we can make
|
/* Fetch the various game order elements so we can make
|
||||||
* educated location selections */
|
* educated location selections */
|
||||||
if (!game.pipOrder
|
if (!game.pipOrder
|
||||||
@ -467,17 +516,20 @@ const processInitialPlacement = async (received) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Start watching for a name entry */
|
/* Start watching for a name entry */
|
||||||
let waitingFor = { name: anyValue }, received = {};
|
let waitingFor: any = { name: anyValue };
|
||||||
|
let received: any = {};
|
||||||
|
|
||||||
const reducedGame = (game) => {
|
// Ignore TS6133 (declared but never used)
|
||||||
|
// @ts-ignore
|
||||||
|
const reducedGame = (game: any): Record<string, any> => {
|
||||||
const filters = [ 'chat', 'activities', 'placements', 'players', 'private', 'dice' ];
|
const filters = [ 'chat', 'activities', 'placements', 'players', 'private', 'dice' ];
|
||||||
const value = {};
|
const value: Record<string, any> = {};
|
||||||
for (let key in game) {
|
for (let key in game) {
|
||||||
if (filters.indexOf(key) === -1) {
|
if (filters.indexOf(key) === -1) {
|
||||||
value[key] = game[key];
|
value[key] = game[key];
|
||||||
} else {
|
} else {
|
||||||
if (Array.isArray(game[key])) {
|
if (Array.isArray((game as any)[key])) {
|
||||||
value[key] = `length(${game[key].length})`;
|
value[key] = `length(${(game as any)[key].length})`;
|
||||||
} else {
|
} else {
|
||||||
value[key] = `...filtered`;
|
value[key] = `...filtered`;
|
||||||
}
|
}
|
||||||
@ -486,11 +538,9 @@ const reducedGame = (game) => {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
const processWaitingFor = (waitingFor) => {
|
const processWaitingFor = (waitingFor: any): void => {
|
||||||
const value = {
|
const value: { type: string; fields: string[] } = { type: 'get', fields: [] };
|
||||||
type: 'get',
|
console.log(`${name} - waiting for: `, waitingFor);
|
||||||
fields: []
|
|
||||||
};
|
|
||||||
for (let key in waitingFor) {
|
for (let key in waitingFor) {
|
||||||
value.fields.push(key);
|
value.fields.push(key);
|
||||||
}
|
}
|
||||||
@ -499,17 +549,13 @@ const processWaitingFor = (waitingFor) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const selectResources = async (received) => {
|
const selectResources = async (_received: any): Promise<any> => {
|
||||||
if (!game.turn) {
|
if (!game.turn) return { turn: anyValue };
|
||||||
return { turn: anyValue };
|
if (!game.turn.actions || game.turn.actions.indexOf('select-resources') === -1) return undefined;
|
||||||
}
|
return undefined;
|
||||||
|
|
||||||
if (!game.turn.actions || game.turn.actions.indexOf('select-resources') === -1) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const processDiscard = async (received) => {
|
const processDiscard = async (_received?: any): Promise<any> => {
|
||||||
if (!game.players) {
|
if (!game.players) {
|
||||||
waitingFor = {
|
waitingFor = {
|
||||||
players: {}
|
players: {}
|
||||||
@ -524,20 +570,22 @@ const processDiscard = async (received) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cards = [],
|
const cards: string[] = [];
|
||||||
discards = {};
|
const discards: Record<string, number> = {};
|
||||||
types.forEach(type => {
|
types.forEach(type => {
|
||||||
for (let i = 0; i < game.private[type]; i++) {
|
for (let i = 0; i < game.private[type]; i++) {
|
||||||
cards.push(type);
|
cards.push(type);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
if (cards.length === 0) {
|
||||||
|
// nothing to discard (defensive)
|
||||||
|
return;
|
||||||
|
}
|
||||||
while (mustDiscard--) {
|
while (mustDiscard--) {
|
||||||
const type = cards[Math.floor(Math.random() * cards.length)];
|
const type = cards[Math.floor(Math.random() * cards.length)] as string;
|
||||||
if (!(type in discards)) {
|
if (!(type in discards)) {
|
||||||
discards[type] = 1;
|
discards[type] = (discards[type] || 0) + 1;
|
||||||
} else {
|
}
|
||||||
discards[type]++;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
console.log(`discarding - `, discards);
|
console.log(`discarding - `, discards);
|
||||||
send({
|
send({
|
||||||
@ -552,8 +600,8 @@ const processDiscard = async (received) => {
|
|||||||
return waitingFor;
|
return waitingFor;
|
||||||
};
|
};
|
||||||
|
|
||||||
const processTrade = async (received) => {
|
const processTrade = async (received?: any): Promise<any> => {
|
||||||
const enough = [];
|
const enough: string[] = [];
|
||||||
let shouldTrade = true;
|
let shouldTrade = true;
|
||||||
|
|
||||||
/* Check and see which resources we have enough of */
|
/* Check and see which resources we have enough of */
|
||||||
@ -564,7 +612,7 @@ const processTrade = async (received) => {
|
|||||||
});
|
});
|
||||||
shouldTrade = enough.length > 0;
|
shouldTrade = enough.length > 0;
|
||||||
|
|
||||||
let least = { type: undefined, count: 0 };
|
let least: { type?: string | undefined; count: number } = { type: undefined, count: 0 };
|
||||||
|
|
||||||
if (shouldTrade) {
|
if (shouldTrade) {
|
||||||
/* Find out which resource we have the least amount of */
|
/* Find out which resource we have the least amount of */
|
||||||
@ -623,7 +671,7 @@ const processTrade = async (received) => {
|
|||||||
gets: [get]
|
gets: [get]
|
||||||
};
|
};
|
||||||
|
|
||||||
if (received.turn.offer) {
|
if (received && received.turn && received.turn.offer) {
|
||||||
send({
|
send({
|
||||||
type: 'trade',
|
type: 'trade',
|
||||||
action: 'accept',
|
action: 'accept',
|
||||||
@ -642,7 +690,7 @@ const processTrade = async (received) => {
|
|||||||
|
|
||||||
/* Initiate offer... */
|
/* Initiate offer... */
|
||||||
|
|
||||||
if (!received.turn.offer) {
|
if (!received || !received.turn || !received.turn.offer) {
|
||||||
console.log(`trade - `, offer);
|
console.log(`trade - `, offer);
|
||||||
send({
|
send({
|
||||||
type: 'trade',
|
type: 'trade',
|
||||||
@ -660,7 +708,7 @@ const processTrade = async (received) => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const processVolcano = async (received) => {
|
const processVolcano = async (_received?: any): Promise<any> => {
|
||||||
if (!game.turn || !game.private) {
|
if (!game.turn || !game.private) {
|
||||||
return {
|
return {
|
||||||
turn: anyValue,
|
turn: anyValue,
|
||||||
@ -683,8 +731,9 @@ const processVolcano = async (received) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const processNormal = async (received) => {
|
const processNormal = async (received?: any): Promise<any> => {
|
||||||
let waitingFor = undefined;
|
let waitingFor: any = undefined;
|
||||||
|
let index: number | undefined;
|
||||||
|
|
||||||
if (!game.turn || !game.private) {
|
if (!game.turn || !game.private) {
|
||||||
return {
|
return {
|
||||||
@ -712,7 +761,7 @@ const processNormal = async (received) => {
|
|||||||
|
|
||||||
/* From here on it is only actions that occur on the player's turn */
|
/* From here on it is only actions that occur on the player's turn */
|
||||||
if (!received.turn || received.turn.color !== game.color) {
|
if (!received.turn || received.turn.color !== game.color) {
|
||||||
console.log(`${name} - waiting for turn... ${game.players[game.turn.color].name} is active.`);
|
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({
|
console.log({
|
||||||
wheat: game.private.wheat,
|
wheat: game.private.wheat,
|
||||||
sheep: game.private.sheep,
|
sheep: game.private.sheep,
|
||||||
@ -741,22 +790,17 @@ const processNormal = async (received) => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (received.turn.actions && received.turn.actions.indexOf('place-road') !== -1) {
|
if (received.turn && received.turn.actions && received.turn.actions.indexOf('place-road') !== -1) {
|
||||||
index = bestRoadPlacement(game);
|
index = bestRoadPlacement(game);
|
||||||
send({
|
send({ type: 'place-road', index });
|
||||||
type: 'place-road', index
|
|
||||||
});
|
|
||||||
return {
|
return {
|
||||||
turn: { color: game.color }
|
turn: { color: game.color }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (game.turn.actions && game.turn.actions.indexOf('place-city') !== -1) {
|
if (game.turn.actions && game.turn.actions.indexOf('place-city') !== -1) {
|
||||||
index = game.turn.limits.corners[Math.floor(
|
index = game.turn.limits.corners[Math.floor(Math.random() * game.turn.limits.corners.length)];
|
||||||
Math.random() * game.turn.limits.corners.length)];
|
send({ type: 'place-city', index });
|
||||||
send({
|
|
||||||
type: 'place-city', index
|
|
||||||
});
|
|
||||||
return {
|
return {
|
||||||
turn: { color: game.color }
|
turn: { color: game.color }
|
||||||
};
|
};
|
||||||
@ -836,86 +880,49 @@ const processNormal = async (received) => {
|
|||||||
return { turn: anyValue };
|
return { turn: anyValue };
|
||||||
};
|
};
|
||||||
|
|
||||||
const message = async (data) => {
|
const message = async (data: WebSocket.Data): Promise<void> => {
|
||||||
|
let parsed: any = data;
|
||||||
try {
|
try {
|
||||||
data = JSON.parse(data);
|
const text = typeof data === 'string' ? data : data.toString();
|
||||||
|
parsed = JSON.parse(text);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
console.log(data);
|
console.log(data);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (data.type) {
|
const msg: any = parsed;
|
||||||
|
switch (msg.type) {
|
||||||
case 'warning':
|
case 'warning':
|
||||||
if (game.turn.color === game.color && game.state !== 'lobby') {
|
if (game.turn && game.turn.color === game.color && game.state !== 'lobby') {
|
||||||
console.log(`WARNING: ${data.warning}. Passing.`);
|
console.log(`WARNING: ${msg.warning}. Passing.`);
|
||||||
send({
|
send({ type: 'pass' });
|
||||||
type: 'pass'
|
waitingFor = { turn: { color: game.color } };
|
||||||
});
|
|
||||||
waitingFor = {
|
|
||||||
turn: { color: game.color }
|
|
||||||
};
|
|
||||||
processWaitingFor(waitingFor);
|
processWaitingFor(waitingFor);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'game-update':
|
case 'game-update':
|
||||||
/* Keep game updated with the latest information */
|
Object.assign(game, msg.update || {});
|
||||||
Object.assign(game, data.update);
|
if (msg.update && msg.update.chat) {
|
||||||
if (data.update.chat) {
|
|
||||||
let newState = paused;
|
let newState = paused;
|
||||||
const rePause = new RegExp(`${name}: pause`, 'i');
|
const rePause = new RegExp(`${name}: pause`, 'i');
|
||||||
const reUnpause = new RegExp(`${name}: unpause`, 'i');
|
const reUnpause = new RegExp(`${name}: unpause`, 'i');
|
||||||
|
for (let i = 0; i < msg.update.chat.length; i++) {
|
||||||
for (let i = 0; i < data.update.chat.length; i++) {
|
if (msg.update.chat[i].message.match(rePause)) { newState = true; continue; }
|
||||||
if (data.update.chat[i].message.match(rePause)) {
|
if (msg.update.chat[i].message.match(reUnpause)) { newState = false; continue; }
|
||||||
newState = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.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 (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 (paused) {
|
if (sleeping) { if (waitingFor) Object.assign(received, msg.update); console.log(`${name} - sleeping`); return; }
|
||||||
if (waitingFor) {
|
|
||||||
Object.assign(received, data.update);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sleeping) {
|
|
||||||
if (waitingFor) {
|
|
||||||
Object.assign(received, data.update);
|
|
||||||
}
|
|
||||||
console.log(`${name} - sleeping`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (waitingFor) {
|
if (waitingFor) {
|
||||||
Object.assign(received, data.update);
|
Object.assign(received, msg.update);
|
||||||
if (!isMatch(waitingFor, received)) {
|
if (!isMatch(waitingFor, received)) {
|
||||||
console.log(`${name} - still waiting - waitingFor: `,
|
if (game.turn && game.turn.robberInAction) { console.log(`${name} - robber in action! Must check discards...`); } else { return; }
|
||||||
waitingFor);
|
|
||||||
if (game.turn && game.turn.robberInAction) {
|
|
||||||
console.log(`${name} - robber in action! Must check discards...`);
|
|
||||||
} else {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
console.log(`${name} - received match - received: `,
|
console.log(`${name} - received match - received: `, msg.update);
|
||||||
reducedGame(received));
|
|
||||||
console.log(`${name} - going to sleep`);
|
console.log(`${name} - going to sleep`);
|
||||||
await sleep(1000 + Math.random() * 500);
|
await sleep(1000 + Math.random() * 500);
|
||||||
console.log(`${name} - waking up`);
|
console.log(`${name} - waking up`);
|
||||||
@ -927,52 +934,33 @@ const message = async (data) => {
|
|||||||
case undefined:
|
case undefined:
|
||||||
case 'lobby':
|
case 'lobby':
|
||||||
waitingFor = await processLobby(received);
|
waitingFor = await processLobby(received);
|
||||||
if (waitingFor) {
|
if (waitingFor) processWaitingFor(waitingFor);
|
||||||
processWaitingFor(waitingFor);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
|
|
||||||
case 'game-order':
|
case 'game-order':
|
||||||
waitingFor = await processGameOrder(received);
|
waitingFor = await processGameOrder(received);
|
||||||
if (waitingFor) {
|
if (waitingFor) processWaitingFor(waitingFor);
|
||||||
processWaitingFor(waitingFor);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
|
|
||||||
case 'initial-placement':
|
case 'initial-placement':
|
||||||
waitingFor = await processInitialPlacement(received);
|
waitingFor = await processInitialPlacement(received);
|
||||||
if (waitingFor) {
|
if (waitingFor) processWaitingFor(waitingFor);
|
||||||
processWaitingFor(waitingFor);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
|
|
||||||
case 'volcano':
|
case 'volcano':
|
||||||
waitingFor = await processVolcano(received);
|
waitingFor = await processVolcano(received);
|
||||||
if (waitingFor) {
|
if (waitingFor) processWaitingFor(waitingFor);
|
||||||
processWaitingFor(waitingFor);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
|
|
||||||
case 'normal':
|
case 'normal':
|
||||||
waitingFor = await processNormal(received);
|
waitingFor = await processNormal(received);
|
||||||
if (waitingFor) {
|
if (waitingFor) processWaitingFor(waitingFor);
|
||||||
processWaitingFor(waitingFor);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'ping':
|
case 'ping':
|
||||||
if (!game.state && !received.state) {
|
if (!game.state && !received.state) { console.log(`ping received with no game. Sending update request`); send({ type: 'game-update' }); }
|
||||||
console.log(`ping received with no game. Sending update request`);
|
|
||||||
send({
|
|
||||||
type: 'game-update'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
console.log(data);
|
console.log(`Received: ${msg.type} while game state = ${game.state}`);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -988,7 +976,9 @@ connect().then(() => {
|
|||||||
ai()
|
ai()
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
ws.close();
|
if (wsRef) {
|
||||||
|
try { wsRef.close(); } catch (_) { }
|
||||||
|
}
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
@ -1,9 +1,10 @@
|
|||||||
{
|
{
|
||||||
"name": "peddlers-of-ketran-ai-bot",
|
"name": "peddlers-of-ketran-ai-bot",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"main": "app.js",
|
"main": "app.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "export $(cat ../../.env | xargs) && node app.js"
|
"start": "node start.js",
|
||||||
|
"start:ts": "export $(cat ../../.env | xargs) && node -r ts-node/register app.ts"
|
||||||
},
|
},
|
||||||
"author": "James Ketrenos <james_settlers@ketrenos.com>",
|
"author": "James Ketrenos <james_settlers@ketrenos.com>",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
28
server/ai/start.js
Normal file
28
server/ai/start.js
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// Load env from ../../.env (best-effort)
|
||||||
|
try {
|
||||||
|
const envPath = path.resolve(__dirname, '..', '..', '.env');
|
||||||
|
if (fs.existsSync(envPath)) {
|
||||||
|
const content = fs.readFileSync(envPath, 'utf8');
|
||||||
|
content.split(/\n/).forEach(line => {
|
||||||
|
const m = line.match(/^([^#=]+)=([\s\S]*)$/);
|
||||||
|
if (m) process.env[m[1].trim()] = m[2].trim();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Prefer ts-node/register to allow requiring .ts directly
|
||||||
|
require.resolve('ts-node/register');
|
||||||
|
require('ts-node/register');
|
||||||
|
require('./app.ts');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('ts-node not found. Please run this inside the project container where dev dependencies are installed.');
|
||||||
|
console.error('Original error:', err && err.message ? err.message : err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
20
server/routes/games/turnFactory.ts
Normal file
20
server/routes/games/turnFactory.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { Player, Turn } from "./types";
|
||||||
|
|
||||||
|
export const newTurn = (first: Player) : Turn => {
|
||||||
|
return {
|
||||||
|
name: first.name,
|
||||||
|
color: first.color,
|
||||||
|
actions: [],
|
||||||
|
limits: {},
|
||||||
|
roll: 0,
|
||||||
|
volcano: null,
|
||||||
|
free: false,
|
||||||
|
freeRoads: 0,
|
||||||
|
select: {},
|
||||||
|
active: null,
|
||||||
|
robberInAction: false,
|
||||||
|
placedRobber: false,
|
||||||
|
developmentPurchased: false,
|
||||||
|
offer: null,
|
||||||
|
};
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user