From 1916ad3509a3f85e0e6a10b1055ae7c80dae699f Mon Sep 17 00:00:00 2001 From: James Ketrenos Date: Sat, 25 Jun 2022 15:27:12 -0700 Subject: [PATCH] Updated AI Signed-off-by: James Ketrenos --- client/src/Activities.js | 13 +- server/ai/app.js | 876 ++++++++++++++++++++++-------- server/ai/longest-road.js | 161 ++++++ server/routes/games.js | 497 ++++++++--------- server/{routes => util}/layout.js | 0 server/util/validLocations.js | 120 ++++ 6 files changed, 1165 insertions(+), 502 deletions(-) create mode 100644 server/ai/longest-road.js rename server/{routes => util}/layout.js (100%) create mode 100644 server/util/validLocations.js diff --git a/client/src/Activities.js b/client/src/Activities.js index 5843ff4..77aaf26 100644 --- a/client/src/Activities.js +++ b/client/src/Activities.js @@ -5,7 +5,7 @@ import { PlayerColor } from './PlayerColor.js'; import { Dice } from './Dice.js'; import { GlobalContext } from "./GlobalContext.js"; -const Activity = ({ activity }) => { +const Activity = ({ keep, activity }) => { const [animation, setAnimation] = useState('open'); const [display, setDisplay] = useState(true) @@ -16,7 +16,7 @@ const Activity = ({ activity }) => { setDisplay(false) }; - if (display) { + if (display && !keep) { setTimeout(() => { hide(10000) }, 0); } @@ -157,10 +157,11 @@ const Activities = () => { discarders.push(
{name} must discard {player.mustDiscard} cards.
); } - const list = activities - .filter(activity => timestamp - activity.date < 11000) - .map(activity => { - return ; + let list = activities + .filter((activity, index) => + activities.length - 1 === index || timestamp - activity.date < 11000); + list = list.map((activity, index) => { + return ; }); let who; diff --git a/server/ai/app.js b/server/ai/app.js index 0979bbc..67d04b1 100644 --- a/server/ai/app.js +++ b/server/ai/app.js @@ -1,6 +1,10 @@ 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 = require('../util/layout.js'); const version = '0.0.1'; @@ -18,16 +22,28 @@ For example: const server = process.argv[2]; const gameId = process.argv[3]; -let session = undefined; 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; @@ -69,6 +85,8 @@ const connect = async () => { 'Cookie': `player=${player}` } }); + send = send.bind(ws); + return new Promise((resolve, reject) => { const headers = (e) => { console.log(`ws - headers`); @@ -92,27 +110,182 @@ const connect = async () => { ws.on('headers', headers); ws.on('close', close); ws.on('error', error); - ws.on('message', async (data) => { await message(ws, data); }); + ws.on('message', async (data) => { await message(data); }); }); }; -const createPlayer = (ws) => { - const send = (data) => { - ws.send(JSON.stringify(data)); - }; +const createPlayer = () => { +}; - if (game.name === '') { - send({ type: 'player-name', name }); - return; - } +const types = [ 'wheat', 'brick', 'stone', 'sheep', 'wood' ]; - if (game.state !== 'lobby') { - return; +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 (game.unselected.indexOf(name) === -1) { + 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 bestRoadPlacement = (game) => { + const road = calculateLongestRoad(game); + console.log(`${name} - could make road ${road.segments + 1} long on ${road.index}`); + + let attempt = -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) { @@ -120,11 +293,16 @@ const createPlayer = (ws) => { slots.push(color); } } + if (slots.length === 0) { - return; + 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(`Requesting to play as ${slots[index]}.`); + console.log(`${name} - requesting to play as ${slots[index]}.`); game.unselected = game.unselected.filter( color => color === slots[index]); send({ @@ -132,248 +310,502 @@ const createPlayer = (ws) => { field: 'color', value: slots[index] }); - send({ - type: 'chat', - message: `Woohoo! Robot AI ${version} is alive!` - }); + return { color: slots[index], state: 'game-order' }; }; -const tryBuild = (ws) => { - const send = (data) => { - console.log(`ws - send`); - ws.send(JSON.stringify(data)); +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) => { + if (!game.turn || game.turn.color !== game.color) { + return { + turn: { + color: game.color, + } + } }; - let trying = false; - if (game.private.settlements - && game.private.wood - && game.private.brick - && game.private.sheep - && game.private.wheat) { - send({ - type: 'buy-settlement' + + 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 = game.turn.limits.corners[Math.floor( + Math.random() * game.turn.limits.corners.length)]; + } + 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]; + } }); - trying = true; + 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 (game.private.wood && game.private.brick && game.private.roads) { + /* 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: 'buy-road' + type: 'trade', + action: 'cancel' }); - trying = true; + return { + turn: anyValue + }; } - return trying; -}; + if (!shouldTrade) { + return; + } - -const sleep = async (delay) => { - return new Promise((resolve) => { - setTimeout(resolve, delay); - }); -}; - -const message = async (ws, data) => { - const send = (data) => { - console.log(`ws - send: ${data.type}`); - ws.send(JSON.stringify(data)); + const give = { + type: enough[Math.floor(Math.random() * enough.length)], + count: 4 + }, get = { + type: least.type, + count: 1 + }; + const offer = { + gives: [give], + gets: [get] }; - data = JSON.parse(data); - switch (data.type) { - case 'game-update': - - Object.assign(game, data.update); - delete data.update.chat; - delete data.update.activities; - console.log(`ws - receive - `, - data.update - ); + 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 + } + }; + } - console.log(`state - ${game.state}`); + /* 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) { + console.log({ corners: game.turn.limits.corners }); + index = game.turn.limits.corners[Math.floor( + Math.random() * game.turn.limits.corners.length)]; + 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.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': - createPlayer(ws); - break; + waitingFor = await processLobby(received); + if (waitingFor) { + processWaitingFor(waitingFor); + } + return; case 'game-order': - if (!game.color) { - console.log(`game-order - player not active`); - return; + waitingFor = await processGameOrder(received); + if (waitingFor) { + processWaitingFor(waitingFor); } - 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' }); - } - break; + return; - case 'initial-placement': { - await sleep(1000 + Math.random() * 500); - console.log({ color: game.color, state: game.state, turn: game.turn }); - if (game.turn.color !== game.color) { - break; + case 'initial-placement': + waitingFor = await processInitialPlacement(received); + if (waitingFor) { + processWaitingFor(waitingFor); } - - let index; - const type = game.turn.actions[0]; - if (type === 'place-road') { - console.log({ roads: game.turn.limits.roads }); - index = game.turn.limits.roads[Math.floor( - Math.random() * game.turn.limits.roads.length)]; - } else if (type === 'place-settlement') { - console.log({ corners: game.turn.limits.corners }); - index = game.turn.limits.corners[Math.floor( - Math.random() * game.turn.limits.corners.length)]; - } - console.log(`Selecting ${type} at ${index}`); - send({ - type, index - }); - } break; - + return; + case 'normal': - if (game.players[game.color].mustDiscard) { - await sleep(1000 + Math.random() * 500); - let mustDiscard = game.players[game.color].mustDiscard; - if (!mustDiscard) { - return; - } - const cards = [], - discards = {}; - const types = ['wheat', 'sheep', 'stone', 'brick', 'wood']; - 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 - }); - return; + waitingFor = await processNormal(received); + if (waitingFor) { + processWaitingFor(waitingFor); } - - if (game.turn.color !== game.color) { - console.log(`not ${name}'s turn.`) - return; - } - - await sleep(1000 + Math.random() * 500); - if (game.turn.color !== game.color) { - return; - } - - if (game.turn.actions && game.turn.actions.indexOf('place-road') !== -1) { - index = game.turn.limits.roads[Math.floor( - Math.random() * game.turn.limits.roads.length)]; - send({ - type: 'place-road', index - }); - return; - } - - if (game.turn.actions && game.turn.actions.indexOf('place-settlement') !== -1) { - console.log({ corners: game.turn.limits.corners }); - index = game.turn.limits.corners[Math.floor( - Math.random() * game.turn.limits.corners.length)]; - send({ - type: 'place-settlement', index - }); - return; - } - - if (!game.dice) { - console.log(`Rolling...`); - send({ - type: 'roll' - }); - return; - } - - 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; - } - - 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 }); - } else { - console.log({ - turn: game.turn, - wheat: game.private.wheat, - sheep: game.private.sheep, - stone: game.private.stone, - brick: game.private.brick, - wood: game.private.wood, - }); - if (!tryBuild(ws)) { - send({ - type: 'pass' - }); - } - } - break; - default: - console.log({ state: game.state, turn: game.turn }); - break; - } - break; - - case 'ping': - if (!game.state) { - console.log(`ping received with no game. Sending update request`); - ws.send(JSON.stringify({ - type: 'game-update' - })); + 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 (ws) => { +const ai = async () => { + send({ + type: 'get', + fields: [ 'dice', 'name', 'color', 'state', 'placements' ] + }); } -connect().then((ws) => { - ai(ws) +connect().then(() => { + ai() .catch((error) => { console.error(error); ws.close(); diff --git a/server/ai/longest-road.js b/server/ai/longest-road.js new file mode 100644 index 0000000..d890987 --- /dev/null +++ b/server/ai/longest-road.js @@ -0,0 +1,161 @@ +const layout = require('../util/layout.js'); + +const processCorner = (game, color, cornerIndex, placedCorner) => { + /* If this corner is allocated and isn't assigned to the walking color, skip it */ + if (placedCorner.color && placedCorner.color !== color) { + return 0; + } + /* If this corner is already being walked, skip it */ + if (placedCorner.walking) { + return 0; + } + + placedCorner.walking = true; + /* Calculate the longest road branching from both corners */ + let longest = 0; + layout.corners[cornerIndex].roads.forEach(roadIndex => { + const placedRoad = game.placements.roads[roadIndex]; + if (placedRoad.walking) { + return; + } + const tmp = processRoad(game, color, roadIndex, placedRoad); + longest = Math.max(tmp, longest); + /*if (tmp > longest) { + longest = tmp; + placedCorner.longestRoad = roadIndex; + placedCorner.longest + } + longest = Math.max( + */ + }); + + return longest; +}; + +const buildCornerGraph = (game, color, cornerIndex, placedCorner, set) => { + /* If this corner is allocated and isn't assigned to the walking color, skip it */ + if (placedCorner.color && placedCorner.color !== color) { + return; + } + /* If this corner is already being walked, skip it */ + if (placedCorner.walking) { + return; + } + + placedCorner.walking = true; + /* Calculate the longest road branching from both corners */ + layout.corners[cornerIndex].roads.forEach(roadIndex => { + const placedRoad = game.placements.roads[roadIndex]; + buildRoadGraph(game, color, roadIndex, placedRoad, set); + }); +}; + +const processRoad = (game, color, roadIndex, placedRoad) => { + /* If this road isn't assigned to the walking color, skip it */ + if (placedRoad.color !== color) { + return 0; + } + + /* If this road is already being walked, skip it */ + if (placedRoad.walking) { + return 0; + } + + placedRoad.walking = true; + /* Calculate the longest road branching from both corners */ + let roadLength = 1; + layout.roads[roadIndex].corners.forEach(cornerIndex => { + const placedCorner = game.placements.corners[cornerIndex]; + if (placedCorner.walking) { + return; + } + roadLength += processCorner(game, color, cornerIndex, placedCorner); + }); + + return roadLength; +}; + +const buildRoadGraph = (game, color, roadIndex, placedRoad, set) => { + /* If this road isn't assigned to the walking color, skip it */ + if (placedRoad.color !== color) { + return; + } + /* If this road is already being walked, skip it */ + if (placedRoad.walking) { + return; + } + + placedRoad.walking = true; + set.push(roadIndex); + /* Calculate the longest road branching from both corners */ + layout.roads[roadIndex].corners.forEach(cornerIndex => { + const placedCorner = game.placements.corners[cornerIndex]; + buildCornerGraph(game, color, cornerIndex, placedCorner, set) + }); +}; + +const clearRoadWalking = (game) => { + /* Clear out walk markers on roads */ + layout.roads.forEach((item, itemIndex) => { + delete game.placements.roads[itemIndex].walking; + }); + + /* Clear out walk markers on corners */ + layout.corners.forEach((item, itemIndex) => { + delete game.placements.corners[itemIndex].walking; + }); +} + +const calculateRoadLengths = (game) => { + const color = game.color; + clearRoadWalking(game); + + /* Build a set of connected road graphs. Once all graphs are + * constructed, walk through each graph, starting from each + * location in the graph. If the length ever equals the + * number of items in the graph, short circuit--longest path. + * Otherwise, check all paths from each segment. This is + * needed to catch loops where starting from an outside end + * point may result in not counting the length of the loop + */ + let graphs = []; + layout.roads.forEach((_, roadIndex) => { + const placedRoad = game.placements.roads[roadIndex]; + if (placedRoad.color === color) { + let set = []; + buildRoadGraph(game, color, roadIndex, placedRoad, set); + if (set.length) { + graphs.push({ color, set }); + } + } + }); + + let final = { + segments: 0, + index: -1 + }; + + clearRoadWalking(game); + graphs.forEach(graph => { + graph.longestRoad = 0; + graph.set.forEach(roadIndex => { + const placedRoad = game.placements.roads[roadIndex]; + clearRoadWalking(game); + const length = processRoad(game, color, roadIndex, placedRoad); + if (length >= graph.longestRoad) { + graph.longestStartSegment = roadIndex; + graph.longestRoad = length; + if (length > final.segments) { + final.segments = length; + final.index = roadIndex; + } + } + }); + }); + + game.placements.roads.forEach(road => delete road.walking); + + return final; +}; + +module.exports = calculateRoadLengths; \ No newline at end of file diff --git a/server/routes/games.js b/server/routes/games.js index 883472c..3280a34 100755 --- a/server/routes/games.js +++ b/server/routes/games.js @@ -8,12 +8,17 @@ const express = require("express"), accessSync = fs.accessSync, randomWords = require("random-words"), equal = require("fast-deep-equal"); -const layout = require('./layout.js'); +const layout = require('../util/layout.js'); + +const { getValidRoads, getValidCorners, isRuleEnabled } = require('../util/validLocations.js'); + const MAX_SETTLEMENTS = 5; const MAX_CITIES = 4; const MAX_ROADS = 15; +const types = [ 'wheat', 'stone', 'sheep', 'wood', 'brick' ]; + const debug = { audio: false, get: true, @@ -419,10 +424,7 @@ const pickRobber = (game) => { } } -const isRuleEnabled = (game, rule) => { - return rule in game.rules && game.rules[rule].enabled; -}; - + const processRoll = (game, session, dice) => { if (!dice[1]) { console.error(`Invalid roll sequence!`); @@ -1565,115 +1567,6 @@ const calculateRoadLengths = (game, session) => { } }; -const getValidCorners = (game, color, type) => { - const limits = []; - - /* For each corner, if the corner already has a color set, skip it if type - * isn't set. If type is set, if it is a match, and the color is a match, - * add it to the list. - * - * If we are limiting based on active player, a corner is only valid - * if it connects to a road that is owned by that player. - * - * If no color is set, walk each road that leaves that corner and - * check to see if there is a settlement placed at the end of that road - * - * If so, this location cannot have a settlement. - * - * If still valid, and we are in initial settlement placement, and if - * Volcano is enabled, verify the tile is not the Volcano. - */ - layout.corners.forEach((corner, cornerIndex) => { - const placement = game.placements.corners[cornerIndex]; - if (type) { - if (placement.color === color && placement.type === type) { - limits.push(cornerIndex); - } - return; - } - - if (placement.color) { - return; - } - - let valid; - if (!color) { - valid = true; /* Not filtering based on current player */ - } else { - valid = false; - for (let r = 0; !valid && r < corner.roads.length; r++) { - valid = game.placements.roads[corner.roads[r]].color === color; - } - } - - for (let r = 0; valid && r < corner.roads.length; r++) { - const road = layout.roads[corner.roads[r]]; - for (let c = 0; valid && c < road.corners.length; c++) { - /* This side of the road is pointing to the corner being validated. - * Skip it. */ - if (road.corners[c] === cornerIndex) { - continue; - } - /* There is a settlement within one segment from this - * corner, so it is invalid for settlement placement */ - if (game.placements.corners[road.corners[c]].color) { - valid = false; - } - } - } - if (valid) { - /* During initial placement, if volcano is enabled, do not allow - * placement on a corner connected to the volcano (robber starts - * on the volcano) */ - if (!(game.state === 'initial-placement' - && isRuleEnabled(game, 'volcano') - && layout.tiles[game.robber].corners.indexOf(cornerIndex) !== -1 - )) { - limits.push(cornerIndex); - } - } - }); - - return limits; -} - -const getValidRoads = (game, color) => { - const limits = []; - - /* For each road, if the road is set, skip it. - * If no color is set, check the two corners. If the corner - * has a matching color, add this to the set. Otherwise skip. - */ - layout.roads.forEach((road, roadIndex) => { - if (game.placements.roads[roadIndex].color) { - return; - } - let valid = false; - for (let c = 0; !valid && c < road.corners.length; c++) { - const corner = layout.corners[road.corners[c]], - cornerColor = game.placements.corners[road.corners[c]].color; - /* Roads do not pass through other player's settlements */ - if (cornerColor && cornerColor !== color) { - continue; - } - for (let r = 0; !valid && r < corner.roads.length; r++) { - /* This side of the corner is pointing to the road being validated. Skip it. */ - if (corner.roads[r] === roadIndex) { - continue; - } - if (game.placements.roads[corner.roads[r]].color === color) { - valid = true; - } - } - } - if (valid) { - limits.push(roadIndex); - } - }); - - return limits; -} - const isCompatibleOffer = (player, offer) => { const isBank = offer.name === 'The bank'; let valid = player.gets.length === offer.gives.length && @@ -1699,7 +1592,7 @@ const isCompatibleOffer = (player, offer) => { return; } valid = offer.gives.find(item => - (item.type === get.type || item.type === '*') && + (item.type === get.type || isBank) && item.count === get.count) !== undefined; }); @@ -1708,7 +1601,7 @@ const isCompatibleOffer = (player, offer) => { return; } valid = offer.gets.find(item => - (item.type === give.type || item.type === 'bank') && + (item.type === give.type || isBank) && item.count === give.count) !== undefined; }); return valid; @@ -1773,6 +1666,11 @@ const checkPlayerOffer = (game, player, offer) => { return; } + if (give.count <= 0) { + error = `${give.count} must be more than 0!` + return; + } + if (player[give.type] < give.count) { error = `${name} does do not have ${give.count} ${give.type}!`; return; @@ -1788,6 +1686,10 @@ const checkPlayerOffer = (game, player, offer) => { if (error) { return; } + if (get.count <= 0) { + error = `${get.count} must be more than 0!`; + return; + } if (offer.gives.find(give => get.type === give.type)) { error = `${name} can not give and get the same resource type!`; }; @@ -1800,10 +1702,10 @@ const canMeetOffer = (player, offer) => { for (let i = 0; i < offer.gets.length; i++) { const get = offer.gets[i]; if (get.type === 'bank') { - if (player[player.gives[0].type] < get.count) { + if (player[player.gives[0].type] < get.count || get.count <= 0) { return false; } - } else if (player[get.type] < get.count) { + } else if (player[get.type] < get.count || get.count <= 0) { return false; } } @@ -1900,183 +1802,229 @@ router.put("/:id/:action/:value?", async (req, res) => { return res.status(400).send(error); }); -const trade = (game, session, action, offer) => { - const name = session.name, player = session.player; - let warning; +const startTrade = (game, session) => { + /* Only the active player can begin trading */ + if (game.turn.name !== session.name) { + return `You cannot start trading negotiations when it is not your turn.` + } + /* Clear any free gives if the player begins trading */ + if (game.turn.free) { + delete game.turn.free; + } + game.turn.actions = ['trade']; + game.turn.limits = {}; + for (let key in game.players) { + game.players[key].gives = []; + game.players[key].gets = []; + delete game.players[key].offerRejected; + } + addActivity(game, session, + `${session.name} requested to begin trading negotiations.`); +}; +const cancelTrade = (game, session) => { + /* TODO: Perhaps 'cancel' is how a player can remove an offer... */ + if (game.turn.name !== session.name) { + return `Only the active player can cancel trading negotiations.`; + } + game.turn.actions = []; + game.turn.limits = {}; + addActivity(game, session, `${session.name} has cancelled trading negotiations.`); +}; + +const processOffer = (game, session, offer) => { + let warning = checkPlayerOffer(game, session.player, offer); + if (warning) { + return warning; + } + + if (isSameOffer(session.player, offer)) { + console.log(session.player); + return `You already have a pending offer submitted for ${offerToString(offer)}.`; + } + + session.player.gives = offer.gives; + session.player.gets = offer.gets; + session.player.offerRejected = {}; + + if (game.turn.color === session.color) { + game.turn.offer = offer; + } + + /* If this offer matches what another player wants, clear rejection + * on of that other player's offer */ + for (let color in game.players) { + if (color === session.color) { + continue; + } + const other = game.players[color]; + if (other.status !== 'Active') { + continue; + } + /* Comparison reverses give/get order */ + if (isSameOffer(other, { gives: offer.gets, gets: offer.gives })) { + if (other.offerRejected) { + delete other.offerRejected[session.color]; + } + } + } + + addActivity(game, session, `${session.name} submitted an offer to give ${offerToString(offer)}.`); +}; + +const rejectOffer = (game, session, offer) => { + /* If the active player rejected an offer, they rejected another player */ + const other = game.players[offer.color]; + if (!other.offerRejected) { + other.offerRejected = {}; + } + other.offerRejected[session.color] = true; + if (!session.player.offerRejected) { + session.player.offerRejected = {}; + } + session.player.offerRejected[offer.color] = true; + addActivity(game, session, `${session.name} rejected ${other.name}'s offer.`); +}; + +const acceptOffer = (game, session, offer) => { + const name = session.name; + + if (game.turn.name !== name) { + return `Only the active player can accept an offer.`; + } + + let target; + + console.log({ description: offerToString(offer) }); + + let warning = checkPlayerOffer(game, session.player, offer); + if (warning) { + return warning; + } + + if (!isCompatibleOffer(session.player, { + name: offer.name, + gives: offer.gets, + gets: offer.gives + })) { + return `Unfortunately, trades were re-negotiated in transit and 1 ` + + `the deal is invalid!`; + } + + /* Verify that the offer sent by the active player matches what + * the latest offer was that was received by the requesting player */ + if (!offer.name || offer.name !== 'The bank') { + target = game.players[offer.color]; + if (offer.color in target.offerRejected) { + return `${target.name} rejected this offer.`; + } + if (!isCompatibleOffer(target, offer)) { + return `Unfortunately, trades were re-negotiated in transit and ` + + `the deal is invalid!`; + } + + warning = checkPlayerOffer(game, target, { + gives: offer.gets, + gets: offer.gives + }); + if (warning) { + return warning; + } + + if (!isSameOffer(target, { gives: offer.gets, gets: offer.gives })) { + console.log({ target, offer }); + return `These terms were not agreed to by ${target.name}!`; + } + + if (!canMeetOffer(target, player)) { + return `${target.name} cannot meet the terms.`; + } + } else { + target = offer; + } + + debugChat(game, 'Before trade'); + + /* Transfer goods */ + const player = session.player; + offer.gets.forEach(item => { + if (target.name !== 'The bank') { + target[item.type] -= item.count; + target.resources -= item.count; + } + player[item.type] += item.count; + player.resources += item.count; + }); + offer.gives.forEach(item => { + if (target.name !== 'The bank') { + target[item.type] += item.count; + target.resources += item.count; + } + player[item.type] -= item.count; + player.resources -= item.count; + }); + + const from = (offer.name === 'The bank') ? 'the bank' : offer.name; + addChatMessage(game, session, `${session.name} traded ` + + ` ${offerToString(offer)} ` + + `from ${from}.`); + addActivity(game, session, `${session.name} accepted a trade from ${from}.`) + delete game.turn.offer; + if (target) { + delete target.gives; + delete target.gets; + } + delete session.player.gives; + delete session.player.gets; + delete game.turn.offer; + + debugChat(game, 'After trade'); + + /* Debug!!! */ + for (let key in game.players) { + if (!game.players[key].state === 'Active') { + continue; + } + types.forEach(type => { + if (game.players[key][type] < 0) { + throw new Error(`Player resources are below zero! BUG BUG BUG!`); + } + }); + } + game.turn.actions = []; +}; + +const trade = (game, session, action, offer) => { if (game.state !== "normal") { return `Game not in correct state to begin trading.`; } if (!game.turn.actions || game.turn.actions.indexOf('trade') === -1) { - /* Only the active player can begin trading */ - if (game.turn.name !== name) { - return `You cannot start trading negotiations when it is not your turn.` - } - /* Clear any free gives if the player begins trading */ - if (game.turn.free) { - delete game.turn.free; - } - game.turn.actions = [ 'trade' ]; - game.turn.limits = {}; - for (let key in game.players) { - game.players[key].gives = []; - game.players[key].gets = []; - delete game.players[key].offerRejected; - } - addActivity(game, session, `${name} requested to begin trading negotiations.`); - return; + return startTrade(game, session); } /* Only the active player can cancel trading */ if (action === 'cancel') { - /* TODO: Perhaps 'cancel' is how a player can remove an offer... */ - if (game.turn.name !== name) { - return `Only the active player can cancel trading negotiations.`; - } - game.turn.actions = []; - game.turn.limits = {}; - addActivity(game, session, `${name} has cancelled trading negotiations.`); - return; + return cancelTrade(game, session); } /* Any player can make an offer */ if (action === 'offer') { - warning = checkPlayerOffer(game, session.player, offer); - if (warning) { - return warning; - } - - if (isSameOffer(session.player, offer)) { - console.log(session.player); - return `You already have a pending offer submitted for ${offerToString(offer)}.`; - } - - session.player.gives = offer.gives; - session.player.gets = offer.gets; - session.player.offerRejected = {}; - - if (game.turn.color === session.color) { - game.turn.offer = offer; - } - - /* If this offer matches what another player wants, clear rejection - * on of that other player's offer */ - for (let color in game.players) { - if (color === session.color) { - continue; - } - const other = game.players[color]; - if (other.status !== 'Active') { - continue; - } - /* Comparison reverses give/get order */ - if (isSameOffer(other, { gives: offer.gets, gets: offer.gives })) { - if (other.offerRejected) { - delete other.offerRejected[session.color]; - } - } - } - - addActivity(game, session, `${session.name} submitted an offer to give ${offerToString(offer)}.`); - return; + return processOffer(game, session, offer); } /* Any player can reject an offer */ if (action === 'reject') { - /* If the active player rejected an offer, they rejected another player */ - const other = game.players[offer.color]; - if (!other.offerRejected) { - other.offerRejected = {}; - } - other.offerRejected[session.color] = true; - if (!session.player.offerRejected) { - session.player.offerRejected = {}; - } - session.player.offerRejected[offer.color] = true; - addActivity(game, session, `${session.name} rejected ${other.name}'s offer.`); - return; + return rejectOffer(game, session, offer); } /* Only the active player can accept an offer */ if (action === 'accept') { - if (game.turn.name !== name) { - return `Only the active player can accept an offer.`; + if (offer.name === 'The bank') { + session.player.gets = offer.gets; + session.player.gives = offer.gives; } - - let target; - - console.log({ offer, description: offerToString(offer) }); - - warning = checkPlayerOffer(game, session.player, offer); - if (warning) { - return warning; - } - - /* Verify that the offer sent by the active player matches what - * the latest offer was that was received by the requesting player */ - if (!offer.name || offer.name !== 'The bank') { - target = game.players[offer.color]; - if (offer.color in target.offerRejected) { - return `${target.name} rejected this offer.`; - } - if (!isCompatibleOffer(target, offer)) { - return `Unfortunately, trades were re-negotiated in transit and the deal is invalid!`; - } - - warning = checkPlayerOffer(game, target, { gives: offer.gets, gets: offer.gives }); - if (warning) { - return warning; - } - - if (!isSameOffer(target, { gives: offer.gets, gets: offer.gives })) { - console.log( { target, offer }); - return `These terms were not agreed to by ${target.name}!`; - } - - if (!canMeetOffer(target, player)) { - return `${target.name} cannot meet the terms.`; - } - } else { - target = offer; - } - - debugChat(game, 'Before trade'); - - /* Transfer goods */ - offer.gets.forEach(item => { - if (target.name !== 'The bank') { - target[item.type] -= item.count; - target.resources -= item.count; - } - player[item.type] += item.count; - player.resources += item.count; - }); - offer.gives.forEach(item => { - if (target.name !== 'The bank') { - target[item.type] += item.count; - target.resources += item.count; - } - player[item.type] -= item.count; - player.resources -= item.count; - }); - - const from = (offer.name === 'The bank') ? 'the bank' : offer.name; - addChatMessage(game, session, `${session.name} traded ` + - ` ${offerToString(offer)} ` + - `from ${from}.`); - addActivity(game, session, `${session.name} accepted a trade from ${from}.`) - delete game.turn.offer; - if (target) { - delete target.gives; - delete target.gets; - } - delete session.player.gives; - delete session.player.gets; - - debugChat(game, 'After trade'); - - game.turn.actions = []; + return acceptOffer(game, session, offer); } } @@ -3044,7 +2992,8 @@ const buyRoad = (game, session) => { addActivity(game, session, `${game.turn.name} is considering building a road.`); sendUpdateToPlayers(game, { turn: game.turn, - chat: game.chat + chat: game.chat, + activities: game.activities }); } @@ -4316,7 +4265,7 @@ router.ws("/ws/:id", async (ws, req) => { break; case 'trade': console.log(`${short}: <- trade:${getName(session)} - ` + - (data.action ? data.action : 'start') + ` - `, + (data.action ? data.action : 'start') + ` -`, data.offer ? data.offer : 'no trade yet'); warning = trade(game, session, data.action, data.offer); if (warning) { diff --git a/server/routes/layout.js b/server/util/layout.js similarity index 100% rename from server/routes/layout.js rename to server/util/layout.js diff --git a/server/util/validLocations.js b/server/util/validLocations.js new file mode 100644 index 0000000..1b65394 --- /dev/null +++ b/server/util/validLocations.js @@ -0,0 +1,120 @@ +const layout = require('./layout.js'); + +const isRuleEnabled = (game, rule) => { + return rule in game.rules && game.rules[rule].enabled; +}; + +const getValidRoads = (game, color) => { + const limits = []; + + /* For each road, if the road is set, skip it. + * If no color is set, check the two corners. If the corner + * has a matching color, add this to the set. Otherwise skip. + */ + layout.roads.forEach((road, roadIndex) => { + if (game.placements.roads[roadIndex].color) { + return; + } + let valid = false; + for (let c = 0; !valid && c < road.corners.length; c++) { + const corner = layout.corners[road.corners[c]], + cornerColor = game.placements.corners[road.corners[c]].color; + /* Roads do not pass through other player's settlements */ + if (cornerColor && cornerColor !== color) { + continue; + } + for (let r = 0; !valid && r < corner.roads.length; r++) { + /* This side of the corner is pointing to the road being validated. Skip it. */ + if (corner.roads[r] === roadIndex) { + continue; + } + if (game.placements.roads[corner.roads[r]].color === color) { + valid = true; + } + } + } + if (valid) { + limits.push(roadIndex); + } + }); + + return limits; +} + +const getValidCorners = (game, color, type) => { + const limits = []; + + /* For each corner, if the corner already has a color set, skip it if type + * isn't set. If type is set, if it is a match, and the color is a match, + * add it to the list. + * + * If we are limiting based on active player, a corner is only valid + * if it connects to a road that is owned by that player. + * + * If no color is set, walk each road that leaves that corner and + * check to see if there is a settlement placed at the end of that road + * + * If so, this location cannot have a settlement. + * + * If still valid, and we are in initial settlement placement, and if + * Volcano is enabled, verify the tile is not the Volcano. + */ + layout.corners.forEach((corner, cornerIndex) => { + const placement = game.placements.corners[cornerIndex]; + if (type) { + if (placement.color === color && placement.type === type) { + limits.push(cornerIndex); + } + return; + } + + if (placement.color) { + return; + } + + let valid; + if (!color) { + valid = true; /* Not filtering based on current player */ + } else { + valid = false; + for (let r = 0; !valid && r < corner.roads.length; r++) { + valid = game.placements.roads[corner.roads[r]].color === color; + } + } + + for (let r = 0; valid && r < corner.roads.length; r++) { + const road = layout.roads[corner.roads[r]]; + for (let c = 0; valid && c < road.corners.length; c++) { + /* This side of the road is pointing to the corner being validated. + * Skip it. */ + if (road.corners[c] === cornerIndex) { + continue; + } + /* There is a settlement within one segment from this + * corner, so it is invalid for settlement placement */ + if (game.placements.corners[road.corners[c]].color) { + valid = false; + } + } + } + if (valid) { + /* During initial placement, if volcano is enabled, do not allow + * placement on a corner connected to the volcano (robber starts + * on the volcano) */ + if (!(game.state === 'initial-placement' + && isRuleEnabled(game, 'volcano') + && layout.tiles[game.robber].corners.indexOf(cornerIndex) !== -1 + )) { + limits.push(cornerIndex); + } + } + }); + + return limits; +} + +module.exports = { + getValidCorners, + getValidRoads, + isRuleEnabled +}; \ No newline at end of file