const fetch = require('node-fetch'); const WebSocket = require('ws'); const fs = require('fs').promises; const calculateLongestRoad = require('./longest-road.js'); const { getValidRoads, getValidCorners } = require('../util/validLocations.js'); const { layout, staticData } = require('../util/layout.js'); 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 = process.argv[2]; const gameId = process.argv[3]; const name = process.argv[4]; const game = {}; const anyValue = undefined; process.env["NODE_TLS_REJECT_UNAUTHORIZED"] = 0; /* Do not use arrow function as this is rebound to have * this as the WebSocket */ let send = function (data) { if (data.type === 'get') { console.log(`ws - send: get`, data.fields); } else { console.log(`ws - send: ${data.type}`); } this.send(JSON.stringify(data)); }; const error = (e) => { console.log(`ws - error`, e); }; const connect = async () => { let loc = new URL(server), new_uri; let player; try { const data = JSON.parse(await fs.readFile(`${name}.json`, 'utf-8')); player = data.player; } catch (_) { const res = await fetch(`${server}/api/v1/games`, { method: 'GET', cache: 'no-cache', credentials: 'same-origin', /* include cookies */ headers: { 'Content-Type': 'application/json' } }); if (!res) { throw new Error(`Unable to connect to ${server}`); } player = JSON.parse(await res.text()).player; await fs.writeFile(`${name}.json`, JSON.stringify({ name, player })); } console.log(`Connecting to ${server} as ${player}`); if (loc.protocol === "https:") { new_uri = "wss"; } else { 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) => { const headers = (e) => { console.log(`ws - headers`); }; 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('connect', () => { connect(ws); }); ws.on('headers', headers); ws.on('close', close); ws.on('error', error); ws.on('message', async (data) => { await message(data); }); }); }; const createPlayer = () => { }; const types = [ 'wheat', 'brick', 'stone', 'sheep', 'wood' ]; const tryBuild = () => { let waitingFor = 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; }; let sleeping = false; const sleep = async (delay) => { if (sleeping) { return; } sleeping = true; return new Promise((resolve) => { setTimeout(() => { sleeping = false; resolve(); }, delay); }); }; const bestSettlementPlacement = (game) => { 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.limits.corners.forEach(cornerIndex => { const tiles = []; layout.tiles.forEach((tile, index) => { if (tile.corners.indexOf(cornerIndex) !== -1 && tiles.indexOf(index) === -1) { tiles.push({ tile, index }); } }); let cornerScore = 0; /* Find the tileOrder holding this tile */ tiles.forEach(tile => { const index = tile.index; // const tileIndex = game.tileOrder.indexOf(index); const pipIndex = game.pipOrder[index]; const score = staticData.pips[pipIndex].pips; 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) => { const road = calculateLongestRoad(game); console.log(`${name} - could make road ${road.segments + 1} long on ${road.index}`); let attempt = -1; if (road.index !== -1) { layout.roads[road.index].corners.forEach(cornerIndex => { if (attempt !== -1) { return; } layout.corners[cornerIndex].roads.forEach(roadIndex => { if (attempt !== -1) { return; } const placedRoad = game.placements.roads[roadIndex]; if (placedRoad.color) { return; } attempt = roadIndex; }); }); } if (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`); return game.turn.limits.roads[Math.floor( Math.random() * game.turn.limits.roads.length)]; } } const isMatch = (input, received) => { 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; }; const processLobby = (received) => { if (game.name === '' && !received.name) { 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 (!received.unselected) { return { unselected: anyValue }; } /* AI selected a Player. Wait for game-order */ if (received.unselected.indexOf(name) === -1) { send({ type: 'chat', message: `Woohoo! Robot AI ${version} is alive and playing as ${game.color}!` }); return { state: 'game-order' }; } const slots = []; 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 => color === slots[index]); send({ type: 'set', field: 'color', value: slots[index] }); return { color: slots[index], state: 'game-order' }; }; const processGameOrder = async () => { if (!game.color) { console.log(`game-order - player not active`); return { 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) => { /* 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 = { name: anyValue }, received = {}; const reducedGame = (game) => { const filters = [ 'chat', 'activities', 'placements', 'players', 'private', 'dice' ]; const value = {}; for (let key in game) { if (filters.indexOf(key) === -1) { value[key] = game[key]; } else { if (Array.isArray(game[key])) { value[key] = `length(${game[key].length})`; } else { value[key] = `...filtered`; } } } return value; } const processWaitingFor = (waitingFor) => { const value = { type: 'get', fields: [] }; for (let key in waitingFor) { value.fields.push(key); } send(value); received = {}; } const processDiscard = async (received) => { if (!game.players) { waitingFor = { players: {} }; waitingFor.players[game.color] = undefined; return waitingFor; } let mustDiscard = game.players[game.color].mustDiscard; if (!mustDiscard) { return; } const cards = [], discards = {}; types.forEach(type => { for (let i = 0; i < game.private[type]; i++) { cards.push(type); } }); while (mustDiscard--) { const type = cards[Math.floor(Math.random() * cards.length)]; if (!(type in discards)) { discards[type] = 1; } else { discards[type]++; } } console.log(`discarding - `, discards); send({ type: 'discard', discards }); waitingFor = { turn: anyValue, players: {} } waitingFor.players[game.color] = anyValue; return waitingFor; }; const processTrade = async (received) => { const enough = []; 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: 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] }; if (received.turn.offer) { send({ type: 'trade', action: 'accept', offer: { name: 'The bank', gets: [{ type: get.type, count: 1 }], gives: [{ type: give.type, count: give.count }] } }); return { turn: { actions: anyValue } }; } /* Initiate offer... */ if (!received.turn.offer) { console.log(`trade - `, offer); send({ type: 'trade', action: 'offer', offer }); return { private: { offerRejected: anyValue } }; } return { turn: anyValue }; } const processNormal = async (received) => { let waitingFor = undefined; if (!game.turn || !game.private) { return { turn: anyValue, private: anyValue } }; /* Process things that happen on everyone's turn */ waitingFor = await processDiscard(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.turn.color].name} 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 }; } 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.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 processTrade(received); if (waitingFor) { return waitingFor; } console.log(`${name} - passing`); send({ type: 'pass' }); return { turn: anyValue }; }; const message = async (data) => { try { data = JSON.parse(data); } catch (error) { console.error(error); console.log(data); return; } switch (data.type) { case 'warning': if (game.turn.color === game.color && game.state !== 'lobby') { console.log(`WARNING: ${data.warning}. Passing.`); send({ type: 'pass' }); waitingFor = { turn: { color: game.color } }; processWaitingFor(waitingFor); } break; case 'game-update': /* Keep game updated with the latest information */ Object.assign(game, data.update); if (sleeping) { if (waitingFor) { Object.assign(received, data.update); } console.log(`${name} - sleeping`); return; } if (waitingFor) { Object.assign(received, data.update); if (!isMatch(waitingFor, received)) { console.log(`${name} - still waiting - waitingFor: `, waitingFor); if (game.turn && game.turn.robberInAction) { console.log(`${name} - robber in action! Must check discards...`); } else { return; } } else { console.log(`${name} - received match - received: `, reducedGame(received)); console.log(`${name} - going to sleep`); await sleep(1000 + Math.random() * 500); console.log(`${name} - waking up`); 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 '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(data); break; } } const ai = async () => { send({ type: 'get', fields: [ 'dice', 'name', 'color', 'state', 'placements' ] }); } connect().then(() => { ai() .catch((error) => { console.error(error); ws.close(); }); }) .catch((error) => { console.error(error); });