├── .gitignore ├── README.md ├── assets └── roles.sketch ├── config.js ├── data ├── .gitignore └── snapshots │ └── .gitignore ├── lib ├── clone.js ├── colours.js ├── db.js ├── log.js └── tmpl.js ├── package.json ├── server ├── account │ ├── ddos.js │ ├── file-handler.js │ ├── registration.js │ ├── routes.js │ ├── server.js │ └── types.js ├── admin │ ├── commands.js │ └── server.js └── game │ ├── commands.js │ ├── engine.js │ ├── grid.js │ ├── requests.js │ ├── roles.js │ ├── server.js │ ├── statuses.js │ └── team.js ├── spec ├── game.spec.js ├── registration.spec.js └── requests.spec.js ├── start.js └── web ├── css ├── account.css ├── overview.css └── register.css ├── images ├── roles │ ├── cloaker.png │ ├── cloaker@2x.png │ ├── minelayer.png │ ├── minelayer@2x.png │ ├── spy.png │ └── spy@2x.png └── tiles │ ├── tile-lines.png │ ├── tile.png │ └── tile@2x.png ├── scripts ├── account.js ├── http.js ├── overview.js ├── overview.webgl.js ├── registration.js └── three.js └── templates ├── account.html ├── overview.html └── register.html /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | data/**/*.json 4 | npm-debug.log -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Code & Conquer 2 | 3 | Code & Conquer is a programming game in which teams compete to capture squares from a shared grid. 4 | 5 | ## Objective 6 | 7 | The game starts with a square grid. The winning team is the team who, by the end of the game, has captured the most number of squares. Once a team owns a square, they must protect it from other teams attempting to steal it. 8 | 9 | Some squares are worth x2 and x3 points (denoted as the taller squares on the grid). Once a square is owned by a player, it'll change to the team's respective colour. 10 | 11 | ## Gameplay 12 | 13 | 1. Open the game server's address in your browser, port 9000, and register your team: 14 | 15 | 1. Select your team's special role (see Special Roles below for more info). 16 | 17 | 2. Enter your email (used for gravatar image) and team name. 18 | 19 | 3. Click `Register` 20 | 21 | 4. Make a note of your account `Key`. 22 | 23 | 2. Download the [game client](#) and update the `serverUrl` and `clientKey` variables to the address of the game server, and your account key respectively. 24 | 25 | 3. Your team will be allocated 30 requests every minute. Requests don't roll-over – you lose what you don't use in each minute period. 26 | 27 | 4. All squares are initially owned by the CPU, each with a health of 60. 28 | 29 | 5. Using the client, you must send commands to the game server in real time to play your particular strategy. You can send `attack`, `defend` and `query` commands: 30 | 31 | 1. `attack` takes 1 health point off the specified square. If this causes the square's health to drop to 0, the attacker becomes the new owner and the square's health is set to 120. 32 | 33 | Each `attack` command uses 1 request. 34 | 35 | 2. `defend` adds 1 health point to the specified square. A square's health is capped at 120. Any square on the grid can be defended, including squares not owned by the defender. 36 | 37 | Each `defend` command uses 1 request. 38 | 39 | 3. `query` retrieves the state of the entire grid, or a specific square. Each square holds a state which shows: 40 | 41 | * The square's current owner 42 | * The square's current health 43 | * The square's bonux (x1, x2 or x3) 44 | * The entire attack and defend history since its last acquisition. 45 | 46 | Each `query` command is free, but rate limited, so avoid heavy polling. 47 | 48 | ## Special Roles 49 | 50 | Each team can select a special role during registration. There are 3 special roles to choose from. A special role can be played at any point during the game, but only once. 51 | 52 | ### Minelayer 53 | A minelayer can place one mine on a square of their choice. The team that triggers it loses all their current requests. 54 | 55 | ### Cloaker 56 | A cloaker can mask the health of 3 squares for 5 minutes of gameplay, making them appear to have maximum health to other players. 57 | 58 | ### Spy 59 | A spy can place a redirect on a player, causing their next 15 requests to be sent to a different grid location. 60 | 61 | ## Rules 62 | 63 | 1. All squares are initially owned by the game (a `query` command will show the owner as `cpu`). 64 | 65 | 2. All `cpu` owned squares start with a health of 60. 66 | 67 | 3. The team behind the `attack` command that causes the health of a square to drop to 0 will become the new owner of the square, and the square's health will be set at 120. 68 | 69 | 4. A `defend` restores 1 health point to a square. Health cannot be restored above 120. 70 | 71 | 5. A team may `attack` their own squares, and `defend` the squares of other teams. 72 | 73 | 6. A special role may be played only once during the game. 74 | 75 | 7. A team that sends an `attack` or `defend` command to a square that has a mine layed will cause the mine to be triggered. A triggered mine wipes the remaining requests of the team that triggers it. Triggering a mine effectively causes a team to be blocked from playing until the next batch of requests is given out (every minute). A mine can only be triggered once. 76 | 77 | 8. Laying a mine on top of another mine will cause the first mine to trigger as normal, and the second mine to be ineffective. The second mine is wasted. 78 | 79 | 9. A team that has been spied on will have their subsequent 15 requests (`attack` or `defend`) sent to the grid location selected by the team that spied upon them. 80 | 81 | 10. A cloak may be enabled on up to 3 squares, but no more. A cloak lasts for 5 minutes of gameplay, causing `query` commands to report the health of the specified squares as 120, regardless of their true health. 82 | 83 | ## Replays 84 | 85 | After each MancJS session we run this game at, we take a snapshot of the way the game played out on the evening. Here are our previous replays: 86 | 87 | [October 2015](http://mancjs.com/code-and-conquer/october-2015/replay.html) -------------------------------------------------------------------------------- /assets/roles.sketch: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mancjs/code-and-conquer/84ef29528802521e018751e484549abe0c9f59e2/assets/roles.sketch -------------------------------------------------------------------------------- /config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | server: { 3 | admin: { bind: '127.0.0.1', port: 9000 }, 4 | account: { bind: '0.0.0.0', port: 9001 }, 5 | game: { bind: '0.0.0.0', port: 9002, maxBuffer: 1024, minQueryGap: 1000 } 6 | }, 7 | game: { 8 | bonus: { x2: 0.1, x3: 0.05 }, 9 | health: { cpu: 60, player: 120 }, 10 | requests: { refresh: 60, amount: 30 }, 11 | roles: { 12 | cloak: { minutes: 5 }, 13 | spy: { redirects: 15 } 14 | } 15 | } 16 | }; -------------------------------------------------------------------------------- /data/.gitignore: -------------------------------------------------------------------------------- 1 | !.gitignore -------------------------------------------------------------------------------- /data/snapshots/.gitignore: -------------------------------------------------------------------------------- 1 | !.gitignore -------------------------------------------------------------------------------- /lib/clone.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = obj => { 4 | return JSON.parse(JSON.stringify(obj)); 5 | }; 6 | -------------------------------------------------------------------------------- /lib/colours.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const all = [ 4 | '#556C2F', 5 | '#CD54D7', 6 | '#CE4D30', 7 | '#678EC1', 8 | '#6ECF4A', 9 | '#C88E81', 10 | '#66D097', 11 | '#603E66', 12 | '#CB913A', 13 | '#496966', 14 | '#CB4565', 15 | '#91CCCF', 16 | '#796ACB', 17 | '#703D29', 18 | '#C9CF45', 19 | '#C04996', 20 | '#BDC889', 21 | '#CC9DC9' 22 | ]; // Don't forget to update overview.webgl.js 23 | 24 | const get = totalTeams => { 25 | return all[totalTeams]; 26 | }; 27 | 28 | module.exports = { 29 | get, 30 | all 31 | }; 32 | -------------------------------------------------------------------------------- /lib/db.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('graceful-fs'); 4 | 5 | const log = require('../lib/log'); 6 | 7 | let game; 8 | 9 | const config = { 10 | database: `${process.cwd()}/data/game.json`, 11 | snapshotPath: `${process.cwd()}/data/snapshots`, 12 | saveInterval: 10000, 13 | lastSnapshot: 0 14 | }; 15 | 16 | const get = () => { 17 | return game; 18 | }; 19 | 20 | const load = () => { 21 | if (fs.existsSync(config.database)) { 22 | game = JSON.parse(fs.readFileSync(config.database, 'utf8')); 23 | } 24 | 25 | setTimeout(save, config.saveInterval); 26 | 27 | return get(); 28 | }; 29 | 30 | const init = () => { 31 | game = { 32 | registrationOpen: false, 33 | gameStarted: false, 34 | date: new Date, 35 | roleData: { 36 | mines: {}, 37 | cloaks: [], 38 | redirects: {} 39 | }, 40 | teams: [], 41 | grid: null 42 | }; 43 | 44 | return get(); 45 | }; 46 | 47 | const save = oneOff => { 48 | const rescheduleNextSave = !oneOff; 49 | 50 | try { 51 | const json = JSON.stringify(game); 52 | 53 | return fs.writeFile(config.database, json, err => { 54 | if (err) { 55 | return log('db', `error saving file: ${err}`); 56 | } 57 | 58 | return takeSnapshot(!!oneOff, () => { 59 | if (rescheduleNextSave) { 60 | setTimeout(save, config.saveInterval); 61 | } 62 | }); 63 | }); 64 | } catch (err) { 65 | log('db', `error stringifying data: ${err}`); 66 | } 67 | }; 68 | 69 | const takeSnapshot = (force, callback) => { 70 | const getSnapshotName = () => { 71 | const time = new Date; 72 | 73 | const timestamp = [time.getHours(), time.getMinutes(), time.getSeconds()].map(part => { 74 | return part < 10 ? `0${part}` : part; 75 | }).join(''); 76 | 77 | return `game-${timestamp}.json`; 78 | }; 79 | 80 | if (!force && new Date() - config.lastSnapshot < (1000 * 60)) { 81 | return callback(); 82 | } 83 | 84 | config.lastSnapshot = new Date; 85 | return copy(config.database, `${config.snapshotPath}/${getSnapshotName()}`, callback); 86 | }; 87 | 88 | const takeFinalSnapshot = () => { 89 | save(true); 90 | }; 91 | 92 | const copy = (input, output, callback) => { 93 | const writeStream = fs.createWriteStream(output); 94 | let complete = false; 95 | 96 | writeStream.on('close', () => { 97 | if (complete) { 98 | return; 99 | } 100 | 101 | complete = true; 102 | return callback(); 103 | }); 104 | 105 | fs.createReadStream(input).pipe(writeStream); 106 | }; 107 | 108 | init(); 109 | 110 | module.exports = { 111 | get, 112 | load, 113 | init, 114 | takeFinalSnapshot 115 | }; 116 | -------------------------------------------------------------------------------- /lib/log.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const getTimestamp = () => { 4 | const time = new Date; 5 | 6 | return [time.getHours(), time.getMinutes(), time.getSeconds()].map(part => { 7 | return part < 10 ? `0${part}` : part; 8 | }).join(':'); 9 | }; 10 | 11 | const log = (context, message) => { 12 | if (process.env.NODE_ENV !== 'test') { 13 | console.log(`${getTimestamp()} ${context}: ${message}`); 14 | } 15 | }; 16 | 17 | module.exports = log; 18 | -------------------------------------------------------------------------------- /lib/tmpl.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const cache = {}; 4 | 5 | const render = (str, data) => { 6 | const fn = !/\W/.test(str) ? 7 | cache[str] = cache[str] || 8 | render(document.getElementById(str).innerHTML) : 9 | 10 | // Generate a reusable function that will serve as a template 11 | // generator (and which will be cached). 12 | new Function('obj', 13 | 'var p=[],print=function(){p.push.apply(p,arguments);};' + 14 | // Introduce the data as local variables using with(){} 15 | 'with(obj){p.push(\'' + 16 | // Convert the template into pure JavaScript 17 | str 18 | .replace(/[\r\t\n]/g, ' ') 19 | .split('{{').join('\t') 20 | .replace(/((^|\}\})[^\t]*)'/g, '$1\r') 21 | .replace(/\t=(.*?)\}\}/g, '\',$1,\'') 22 | .split('\t').join('\');') 23 | .split('}}').join('p.push(\'') 24 | .split('\r').join('\\\'') 25 | 26 | + '\');}return p.join(\'\');'); 27 | 28 | return data ? fn(data) : fn; 29 | }; 30 | 31 | module.exports = { 32 | render 33 | }; 34 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "code-and-conquer", 3 | "version": "0.1.0", 4 | "description": "Code & Conquer", 5 | "main": "app.js", 6 | "scripts": { 7 | "start": "node start.js", 8 | "test": "NODE_ENV=test mocha --grep @slow --invert spec", 9 | "test-all": "NODE_ENV=test mocha --timeout 10000 --slow 10000 spec" 10 | }, 11 | "author": "", 12 | "license": "MIT", 13 | "dependencies": { 14 | "graceful-fs": "^4.1.2" 15 | }, 16 | "devDependencies": { 17 | "expect.js": "^0.3.1", 18 | "mocha": "^2.3.2" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /server/account/ddos.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const log = require('../../lib/log'); 4 | 5 | let clients = {}; 6 | let enabled = false; 7 | let unbanInterval; 8 | 9 | const enable = on => { 10 | enabled = on; 11 | 12 | if (enabled) { 13 | unbanInterval = setInterval(clearBans, 5000); 14 | log('ddos', 'enabled'); 15 | } else { 16 | clearInterval(unbanInterval); 17 | clients = {}; 18 | log('ddos', 'disabled'); 19 | } 20 | }; 21 | 22 | const isEnabled = () => { 23 | return enabled; 24 | }; 25 | 26 | const handler = socket => { 27 | if (!enabled) { 28 | return; 29 | } 30 | 31 | const address = socket.remoteAddress; 32 | 33 | if (!clients[address]) { 34 | clients[address] = { 35 | requests: 1, 36 | banned: false, 37 | added: new Date().getTime() 38 | }; 39 | return; 40 | } 41 | 42 | if (clients[address].banned) { 43 | return socket.destroy(); 44 | } 45 | 46 | clients[address].requests += 1; 47 | 48 | if (clients[address].requests >= 1000) { 49 | clients[address].banned = true; 50 | log('ddos', `ban added ${address}`); 51 | } 52 | }; 53 | 54 | const clearBans = () => { 55 | Object.keys(clients).forEach(address => { 56 | const duration = new Date().getTime() - clients[address].added; 57 | 58 | if (duration >= 1 * 60 * 1000) { 59 | if (clients[address].banned) { 60 | log('ddos', `ban cleared ${address}`); 61 | } 62 | 63 | delete clients[address]; 64 | } 65 | }); 66 | }; 67 | 68 | const getData = () => { 69 | return clients; 70 | }; 71 | 72 | const clear = () => { 73 | clients = {}; 74 | log('ddos', 'all cleared'); 75 | }; 76 | 77 | module.exports = { 78 | handler, 79 | enable, 80 | isEnabled, 81 | getData, 82 | clear 83 | }; 84 | -------------------------------------------------------------------------------- /server/account/file-handler.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('graceful-fs'); 4 | const path = require('path'); 5 | 6 | const types = require('./types'); 7 | 8 | const supportedTypes = { 9 | '.js': 'application/javascript', 10 | '.css': 'text/css', 11 | '.png': 'image/png' 12 | }; 13 | 14 | const validExtension = url => { 15 | return Object.keys(supportedTypes).indexOf(path.extname(url)) !== -1; 16 | }; 17 | 18 | const getMimeType = url => { 19 | return supportedTypes[path.extname(url)]; 20 | }; 21 | 22 | const staticFileHandler = (request, response) => { 23 | if (!validExtension(request.url)) { 24 | return response(types.error(404)); 25 | } 26 | 27 | const filePath = path.join(process.cwd(), '/web', request.url); 28 | 29 | return fs.stat(filePath, (err, stats) => { 30 | if (err || !stats.isFile()) { 31 | return response(types.error(404)); 32 | } 33 | 34 | return fs.readFile(filePath, (err, data) => { 35 | if (err) { 36 | return response(types.error(500)); 37 | } 38 | 39 | const mime = getMimeType(filePath); 40 | return response(types.file(data, mime)); 41 | }); 42 | }); 43 | }; 44 | 45 | module.exports = staticFileHandler; 46 | -------------------------------------------------------------------------------- /server/account/registration.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const crypto = require('crypto'); 4 | 5 | const colours = require('../../lib/colours'); 6 | const config = require('../../config'); 7 | const db = require('../../lib/db'); 8 | const log = require('../../lib/log'); 9 | 10 | const roles = ['minelayer', 'cloaker', 'spy']; 11 | 12 | const createKey = () => { 13 | return (Math.round(Math.random() * 100000000000)).toString(36); 14 | }; 15 | 16 | const getGravatarUrl = email => { 17 | const hash = crypto.createHash('md5').update(email).digest('hex'); 18 | return `http://www.gravatar.com/avatar/${hash}?s=130&d=wavatar`; 19 | }; 20 | 21 | const open = () => { 22 | db.get().registrationOpen = true; 23 | log('registration', 'open'); 24 | }; 25 | 26 | const close = () => { 27 | db.get().registrationOpen = false; 28 | log('registration', 'closed'); 29 | }; 30 | 31 | const getTeamByKey = key => { 32 | const teams = db.get().teams; 33 | 34 | const matches = teams.filter(team => { 35 | return team.key === key; 36 | }); 37 | 38 | return matches[0]; 39 | }; 40 | 41 | const getTeamNames = () => { 42 | return db.get().teams.map(team => { 43 | return { 44 | name: team.name, 45 | colour: team.colour, 46 | gravatar: team.gravatar 47 | }; 48 | }); 49 | }; 50 | 51 | const getAllTeams = () => { 52 | return db.get().teams.map(team => { 53 | return { 54 | key: team.key, 55 | name: team.name, 56 | role: team.role, 57 | requests: team.requests 58 | }; 59 | }); 60 | }; 61 | 62 | const createTeam = (name, email, role) => { 63 | name = name && name.trim(); 64 | email = email && email.trim(); 65 | 66 | const validationError = validate(name, email, role); 67 | 68 | if (validationError) { 69 | log('registration', `ignored: ${validationError}`); 70 | return { err: validationError }; 71 | } 72 | 73 | const team = { 74 | key: createKey(), 75 | gravatar: getGravatarUrl(email), 76 | colour: colours.get(db.get().teams.length), 77 | roleUsed: false, 78 | role: role, 79 | name: name, 80 | email: email, 81 | requests: config.game.requests.amount 82 | }; 83 | 84 | db.get().teams.push(team); 85 | log('registration', `${team.name} (${team.email}) registered`); 86 | 87 | return { team: team }; 88 | }; 89 | 90 | const deleteTeam = key => { 91 | const teams = db.get().teams.filter(team => { 92 | return team.key === key; 93 | }); 94 | 95 | if (teams.length === 0) { 96 | return 'Team not found'; 97 | } 98 | 99 | db.get().teams = db.get().teams.filter(team => { 100 | return team.key !== key; 101 | }); 102 | }; 103 | 104 | const getStatus = () => { 105 | return { 106 | open: db.get().registrationOpen, 107 | teamCount: db.get().teams.length 108 | }; 109 | }; 110 | 111 | const validate = (name, email, role) => { 112 | if (!db.get().registrationOpen) { 113 | return 'Registration is currently closed'; 114 | } 115 | 116 | if (colours.all.length <= db.get().teams.length) { 117 | return `Server is full (${colours.all.length})`; 118 | } 119 | 120 | if (!email || email.length > 50) { 121 | return 'Please enter an email address (50 chars or less)'; 122 | } 123 | 124 | if (!name || name.length > 25) { 125 | return 'Please enter a team name (25 chars or less)'; 126 | } 127 | 128 | if (name === 'cpu') { 129 | return 'Please enter a valid team name (25 chars or less)'; 130 | } 131 | 132 | if (!name.match(/^\w+$/)) { 133 | return 'Team name must match /^\w+$/'; 134 | } 135 | 136 | const duplicates = db.get().teams.filter(team => { 137 | return team.name === name || team.email === email; 138 | }); 139 | 140 | if (duplicates.length !== 0) { 141 | return 'A team with the same name or email already exists'; 142 | } 143 | 144 | if (roles.indexOf(role) === -1) { 145 | return `Valid roles: ${roles.join(', ')}`; 146 | } 147 | }; 148 | 149 | module.exports = { 150 | open, 151 | close, 152 | getTeamByKey, 153 | getTeamNames, 154 | getAllTeams, 155 | createTeam, 156 | deleteTeam, 157 | getStatus 158 | }; 159 | -------------------------------------------------------------------------------- /server/account/routes.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const config = require('../../config'); 4 | const engine = require('../game/engine'); 5 | const registration = require('./registration'); 6 | const requests = require('../game/requests'); 7 | const types = require('./types'); 8 | 9 | const root = (request, response) => { 10 | return response(types.redirect('/register')); 11 | }; 12 | 13 | const register = (request, response) => { 14 | return response(types.template('register.html')); 15 | }; 16 | 17 | const registerTeam = (request, response) => { 18 | const team = request.body; 19 | const status = registration.createTeam(team.name, team.email, team.role); 20 | 21 | if (status.err) { 22 | return response(types.json({ err: status.err })); 23 | } 24 | 25 | const url = `/account?key=${status.team.key}`; 26 | 27 | return response(types.json({ url: url })); 28 | }; 29 | 30 | const account = (request, response) => { 31 | const team = registration.getTeamByKey(request.query.key); 32 | 33 | if (!team) { 34 | return response(types.redirect('/')); 35 | } 36 | 37 | const model = { 38 | team: team, 39 | gameStatus: engine.getStatus() 40 | }; 41 | 42 | return response(types.template('account.html', model)); 43 | }; 44 | 45 | const accountData = (request, response) => { 46 | const team = registration.getTeamByKey(request.query.key); 47 | 48 | if (!team) { 49 | return response(types.redirect('/')); 50 | } 51 | 52 | const teams = registration.getTeamNames(); 53 | 54 | return response(types.json({ 55 | teams: teams, 56 | requests: team.requests, 57 | grid: engine.query().result.grid 58 | })); 59 | }; 60 | 61 | const overview = (request, response) => { 62 | return response(types.template('overview.html', getOverviewData())); 63 | }; 64 | 65 | const overviewData = (request, response) => { 66 | return response(types.json(getOverviewData())); 67 | }; 68 | 69 | const getOverviewData = () => { 70 | const refreshSeconds = requests.getSecondsUntilNextRefresh({}); 71 | const response = engine.query(); 72 | 73 | return { 74 | grid: response.result.grid, 75 | gameStarted: response.result.gameStarted, 76 | refreshSeconds: (refreshSeconds < 10) ? ('0' + refreshSeconds) : refreshSeconds, 77 | secondsPerRound: config.game.requests.refresh 78 | }; 79 | }; 80 | 81 | module.exports = { 82 | 'GET /': root, 83 | 'GET /register': register, 84 | 'POST /register': registerTeam, 85 | 86 | 'GET /account': account, 87 | 'GET /api/account-data': accountData, 88 | 89 | 'GET /overview': overview, 90 | 'GET /api/overview-data': overviewData 91 | }; 92 | -------------------------------------------------------------------------------- /server/account/server.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const http = require('http'); 4 | const querystring = require('querystring'); 5 | 6 | const config = require('../../config'); 7 | const ddos = require('./ddos'); 8 | const log = require('../../lib/log'); 9 | const routes = require('./routes'); 10 | const staticFileHandler = require('./file-handler'); 11 | 12 | const startServer = () => { 13 | const handler = (req, res) => { 14 | return buildRequestObject(req, requestData => { 15 | const routeHandler = routes[requestData.key] || staticFileHandler; 16 | 17 | return routeHandler(requestData, responseData => { 18 | return serve(res, responseData); 19 | }); 20 | }); 21 | }; 22 | 23 | const server = http.createServer(handler); 24 | const { bind, port } = config.server.account; 25 | 26 | server.on('connection', ddos.handler); 27 | server.listen(port, bind); 28 | 29 | log('account', `listening on ${bind}:${port}`); 30 | }; 31 | 32 | const buildRequestObject = (req, callback) => { 33 | const parts = req.url.split('?'); 34 | 35 | const requestData = { 36 | url: parts[0], 37 | key: req.method + ' ' + parts[0], 38 | query: querystring.parse(parts[1]), 39 | body: {} 40 | }; 41 | 42 | if (!requestData.query) { 43 | requestData.query = {}; 44 | } 45 | 46 | if (req.method !== 'POST') { 47 | return callback(requestData); 48 | } 49 | 50 | let jsonString = ''; 51 | 52 | req.on('data', data => { 53 | jsonString += data; 54 | 55 | if (jsonString.length > 1e6) { 56 | jsonString = '{}'; 57 | req.connection.destroy(); 58 | } 59 | }); 60 | 61 | req.on('end', () => { 62 | try { 63 | requestData.body = JSON.parse(jsonString); 64 | } catch (err) { 65 | requestData.body = {}; 66 | } 67 | 68 | return callback(requestData); 69 | }); 70 | }; 71 | 72 | const serve = (res, response) => { 73 | if (response.err) { 74 | res.statusCode = response.err; 75 | return res.end(); 76 | } 77 | 78 | if (response.redirect) { 79 | res.writeHead(302, { location: response.redirect }); 80 | return res.end(); 81 | } 82 | 83 | res.setHeader('Content-Length', response.data.length); 84 | res.setHeader('Content-Type', response.mime); 85 | res.statusCode = 200; 86 | res.end(response.data); 87 | }; 88 | 89 | module.exports = { 90 | startServer 91 | }; 92 | -------------------------------------------------------------------------------- /server/account/types.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('graceful-fs'); 4 | 5 | const tmpl = require('../../lib/tmpl'); 6 | 7 | const templates = { 8 | 'register.html': fs.readFileSync(process.cwd() + '/web/templates/register.html').toString(), 9 | 'account.html': fs.readFileSync(process.cwd() + '/web/templates/account.html').toString(), 10 | 'overview.html': fs.readFileSync(process.cwd() + '/web/templates/overview.html').toString() 11 | }; 12 | 13 | const error = code => { 14 | return { 15 | err: code 16 | }; 17 | }; 18 | 19 | const text = data => { 20 | return { 21 | mime: 'text/plain', 22 | data: data 23 | }; 24 | }; 25 | 26 | const json = data => { 27 | return { 28 | mime: 'application/json', 29 | data: JSON.stringify(data) 30 | }; 31 | }; 32 | 33 | const file = (data, mime) => { 34 | return { 35 | mime: mime, 36 | data: data 37 | }; 38 | }; 39 | 40 | const template = (name, model) => { 41 | const rendered = tmpl.render(templates[name], model || {}); 42 | 43 | return { 44 | mime: 'text/html', 45 | data: rendered 46 | }; 47 | }; 48 | 49 | const redirect = url => { 50 | return { 51 | redirect: url 52 | }; 53 | }; 54 | 55 | module.exports = { 56 | error, 57 | text, 58 | json, 59 | file, 60 | template, 61 | redirect 62 | }; 63 | -------------------------------------------------------------------------------- /server/admin/commands.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const db = require('../../lib/db'); 4 | const ddos = require('../account/ddos'); 5 | const engine = require('../game/engine'); 6 | const registration = require('../account/registration'); 7 | 8 | const initGame = args => { 9 | if (!args || args.indexOf('x') === -1) { 10 | return 'missing grid size (e.g. 10x10)'; 11 | } 12 | 13 | const gridSize = { 14 | width: parseInt(args.split('x')[0].trim()), 15 | height: parseInt(args.split('x')[1].trim()) 16 | }; 17 | 18 | engine.init(gridSize); 19 | 20 | return getStatus(); 21 | }; 22 | 23 | const startGame = () => { 24 | const state = db.get(); 25 | 26 | if (!state.grid) { 27 | return 'no game initialised'; 28 | } 29 | 30 | registration.close(); 31 | engine.start(); 32 | 33 | return getStatus(); 34 | }; 35 | 36 | const stopGame = () => { 37 | const state = db.get(); 38 | 39 | if (!state.grid) { 40 | return 'no game initialised'; 41 | } 42 | 43 | registration.close(); 44 | engine.stop(); 45 | 46 | return getStatus(); 47 | }; 48 | 49 | const openRegistration = () => { 50 | registration.open(); 51 | return getStatus(); 52 | }; 53 | 54 | const closeRegistration = () => { 55 | registration.close(); 56 | return getStatus(); 57 | }; 58 | 59 | const getTeams = () => { 60 | const teams = registration.getAllTeams(); 61 | 62 | const lines = teams.map(team => { 63 | return team.key + ': ' + team.name + ' [' + team.role + '] [' + team.requests + ']'; 64 | }).join('\n'); 65 | 66 | let response = `${teams.length} total`; 67 | 68 | if (teams.length) { 69 | response += '\n' + lines; 70 | } 71 | 72 | return response; 73 | }; 74 | 75 | const deleteTeam = args => { 76 | if (!args) { 77 | return 'missing team key'; 78 | } 79 | 80 | return registration.deleteTeam(args); 81 | }; 82 | 83 | const getStatus = () => { 84 | const registrationStatus = registration.getStatus(); 85 | const gameStatus = engine.getStatus(); 86 | 87 | if (!gameStatus) { 88 | return 'init game first'; 89 | } 90 | 91 | const values = [ 92 | `ddos: ${ddos.isEnabled() ? 'on' : 'off' }`, 93 | `teams: ${registrationStatus.teamCount}`, 94 | `registration: ${registrationStatus.open ? 'open' : 'closed' }`, 95 | `game: ${gameStatus.started ? 'started' : 'stopped' }`, 96 | `grid: ${gameStatus.width}x${gameStatus.height}`, 97 | `x2: ${gameStatus.doubleSquares}`, 98 | `x3: ${gameStatus.tripleSquares}` 99 | ]; 100 | 101 | return values.join('\n'); 102 | }; 103 | 104 | const simulate = () => { 105 | db.init(); 106 | engine.init({ width: 8, height: 8 }); 107 | registration.open(); 108 | 109 | const result1 = registration.createTeam('Team 1', 'a@b.c1', 'minelayer'); 110 | const result2 = registration.createTeam('Team 2', 'a@b.c2', 'minelayer'); 111 | const result3 = registration.createTeam('Team 3', 'a@b.c3', 'spy'); 112 | const result4 = registration.createTeam('Team 4', 'a@b.c4', 'spy'); 113 | const result5 = registration.createTeam('Team 5', 'a@b.c5', 'cloaker'); 114 | const result6 = registration.createTeam('Team 6', 'a@b.c6', 'cloaker'); 115 | 116 | const teams = [result1.team, result2.team, result3.team, result4.team, result5.team, result6.team]; 117 | 118 | startGame(); 119 | 120 | const state = db.get(); 121 | 122 | for (let i = 0; i < 40; i++) { 123 | const x = Math.floor(Math.random() * 8); 124 | const y = Math.floor(Math.random() * 8); 125 | 126 | state.grid.cells[y][x].health = 1; 127 | engine.attack(teams[Math.floor(Math.random() * teams.length)].key, x, y); 128 | 129 | if (i % 2 === 0) { 130 | engine.attack(teams[Math.floor(Math.random() * teams.length)].key, x, y); 131 | } 132 | } 133 | 134 | return getStatus(); 135 | }; 136 | 137 | const getScores = () => { 138 | const state = db.get(); 139 | 140 | if (!state.gameStarted) { 141 | return; 142 | } 143 | 144 | let teams = {}; 145 | 146 | state.grid.cells.forEach(row => { 147 | row.forEach(cell => { 148 | const key = cell.owner.name; 149 | 150 | if (key === 'cpu') { 151 | return; 152 | } 153 | 154 | if (!teams[key]) { 155 | teams[key] = { name: key, score: 0 }; 156 | } 157 | 158 | teams[key].score += (1 * cell.bonus); 159 | }); 160 | }); 161 | 162 | teams = Object.keys(teams).map(key => { 163 | return teams[key]; 164 | }); 165 | 166 | teams.sort((left, right) => { 167 | return right.score - left.score; 168 | }); 169 | 170 | let position = 1; 171 | let lastScore; 172 | 173 | return teams.map(team => { 174 | if (lastScore && (team.score < lastScore)) { 175 | position += 1; 176 | } 177 | 178 | lastScore = team.score; 179 | return `${position}: ${team.name}, score: ${team.score}`; 180 | }).join('\n'); 181 | }; 182 | 183 | const ddosEnable = () => { 184 | ddos.enable(true); 185 | return getStatus(); 186 | }; 187 | 188 | const ddosDisable = () => { 189 | ddos.enable(false); 190 | return getStatus(); 191 | }; 192 | 193 | const ddosClear = () => { 194 | ddos.clear(); 195 | return getStatus(); 196 | }; 197 | 198 | const ddosList = () => { 199 | const clients = ddos.getData(); 200 | const keys = Object.keys(clients); 201 | 202 | let response = `clients: ${keys.length}`; 203 | 204 | if (keys.length > 0) { 205 | response += '\n' + keys.map(address => { 206 | const client = clients[address]; 207 | const timeLeft = 60 - Math.round((new Date().getTime() - client.added) / 1000); 208 | const bannedState = (client.banned ? ` | BANNED (${timeLeft}s)` : ''); 209 | 210 | return `${address} | requests = ${client.requests}${bannedState}`; 211 | }).join('\n'); 212 | } 213 | 214 | return response; 215 | }; 216 | 217 | module.exports = { 218 | 'init-game': initGame, 219 | 'start-game': startGame, 220 | 'stop-game': stopGame, 221 | 'open-reg': openRegistration, 222 | 'close-reg': closeRegistration, 223 | 'get-teams': getTeams, 224 | 'del-team': deleteTeam, 225 | 'status': getStatus, 226 | 'scores': getScores, 227 | 'simulate': simulate, 228 | 'enable-ddos': ddosEnable, 229 | 'disable-ddos': ddosDisable, 230 | 'clear-ddos': ddosClear, 231 | 'ddos-list': ddosList 232 | }; 233 | -------------------------------------------------------------------------------- /server/admin/server.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const net = require('net'); 4 | 5 | const commands = require('./commands'); 6 | const config = require('../../config'); 7 | const log = require('../../lib/log'); 8 | 9 | const startServer = () => { 10 | const sendHelp = socket => { 11 | socket.write(`commands: ${Object.keys(commands).join(', ')}\n`); 12 | sendPrompt(socket); 13 | }; 14 | 15 | const sendPrompt = socket => { 16 | socket.write('> '); 17 | }; 18 | 19 | const sendWelcome = socket => { 20 | socket.write('\n'); 21 | socket.write('Code & Conquer Admin Server\n'); 22 | socket.write(`Hello ${socket.remoteAddress}\n\n`); 23 | sendPrompt(socket); 24 | }; 25 | 26 | const parseCommand = data => { 27 | const parts = data.trim().split(' '); 28 | 29 | return { 30 | name: parts[0], 31 | args: parts.slice(1).join(' ') 32 | }; 33 | }; 34 | 35 | const server = net.createServer(socket => { 36 | log('admin', `${socket.remoteAddress} connected`); 37 | 38 | sendWelcome(socket); 39 | 40 | socket.on('data', data => { 41 | const cmd = parseCommand(data.toString()); 42 | 43 | if (cmd.name === 'exit') { 44 | return socket.end('adiós\n\n'); 45 | } 46 | 47 | if (!commands[cmd.name]) { 48 | return sendHelp(socket); 49 | } 50 | 51 | const result = commands[cmd.name](cmd.args); 52 | 53 | if (result) { 54 | socket.write(`${result}\n`); 55 | } 56 | 57 | sendPrompt(socket); 58 | }); 59 | }); 60 | 61 | const { bind, port } = config.server.admin; 62 | 63 | server.listen(port, bind); 64 | log('admin', `listening on ${bind}:${port}`); 65 | }; 66 | 67 | module.exports = { 68 | startServer 69 | }; 70 | -------------------------------------------------------------------------------- /server/game/commands.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const config = require('../../config'); 4 | const engine = require('./engine'); 5 | const statuses = require('./statuses'); 6 | 7 | let teamQueryHistory = {}; 8 | 9 | const queryLimitHit = team => { 10 | if (!teamQueryHistory[team]) { 11 | teamQueryHistory[team] = new Date; 12 | return false; 13 | } 14 | 15 | const gap = new Date - teamQueryHistory[team]; 16 | teamQueryHistory[team] = new Date; 17 | 18 | return gap < config.server.game.minQueryGap; 19 | }; 20 | 21 | const verifyProtocol = (team, args, argCount) => { 22 | if (!team) { 23 | return statuses.protocolMissingTeam; 24 | } 25 | 26 | if (!args || args.length !== argCount) { 27 | return statuses.protocolBadArgs; 28 | } 29 | }; 30 | 31 | const verifySingleCellRequest = request => { 32 | if (!request.team) { 33 | return statuses.missingTeamKey; 34 | } 35 | 36 | const isEmpty = str => { 37 | return str === undefined || str === ''; 38 | }; 39 | 40 | if (isEmpty(request.x)) { 41 | return statuses.missingXCoord; 42 | } 43 | 44 | if (isEmpty(request.y)) { 45 | return statuses.missingYCoord; 46 | } 47 | }; 48 | 49 | const verifyMultipleCellRequest = request => { 50 | if (!request.team) { 51 | return statuses.missingTeamKey; 52 | } 53 | 54 | if (!request.cells || request.cells.length === 0) { 55 | return statuses.missingCells; 56 | } 57 | 58 | const isEmpty = str => { 59 | return str === undefined || str === ''; 60 | }; 61 | 62 | for (let i = 0; i < request.cells.length; i++) { 63 | const cell = request.cells[i]; 64 | 65 | if (isEmpty(cell.x)) { 66 | return statuses.missingXCoord; 67 | } 68 | 69 | if (isEmpty(cell.y)) { 70 | return statuses.missingYCoord; 71 | } 72 | } 73 | }; 74 | 75 | const verifySpyRequest = request => { 76 | if (!request.team) { 77 | return statuses.missingTeamKey; 78 | } 79 | 80 | if (!request.target) { 81 | return statuses.missingTargetTeam; 82 | } 83 | 84 | const isEmpty = str => { 85 | return str === undefined || str === ''; 86 | }; 87 | 88 | if (isEmpty(request.x)) { 89 | return statuses.missingXCoord; 90 | } 91 | 92 | if (isEmpty(request.y)) { 93 | return statuses.missingYCoord; 94 | } 95 | }; 96 | 97 | const attack = (team, args) => { 98 | const protocolError = verifyProtocol(team, args, 1); 99 | 100 | if (protocolError) { 101 | return { status: protocolError }; 102 | } 103 | 104 | const request = { 105 | team: team, 106 | x: args[0].split(',')[0], 107 | y: args[0].split(',')[1] 108 | }; 109 | 110 | const error = verifySingleCellRequest(request); 111 | 112 | if (error) { 113 | return { status: error }; 114 | } 115 | 116 | return engine.attack(request.team, request.x, request.y); 117 | }; 118 | 119 | const defend = (team, args) => { 120 | const protocolError = verifyProtocol(team, args, 1); 121 | 122 | if (protocolError) { 123 | return { status: protocolError }; 124 | } 125 | 126 | const request = { 127 | team: team, 128 | x: args[0].split(',')[0], 129 | y: args[0].split(',')[1] 130 | }; 131 | 132 | const error = verifySingleCellRequest(request); 133 | 134 | if (error) { 135 | return { status: error }; 136 | } 137 | 138 | return engine.defend(request.team, request.x, request.y); 139 | }; 140 | 141 | const query = team => { 142 | if (!team) { 143 | return { status: statuses.protocolMissingTeam }; 144 | } 145 | 146 | const limitError = queryLimitHit(team); 147 | 148 | if (limitError) { 149 | return { status: statuses.protocolRateLimit }; 150 | } 151 | 152 | return engine.query(team); 153 | }; 154 | 155 | const mine = (team, args) => { 156 | const protocolError = verifyProtocol(team, args, 1); 157 | 158 | if (protocolError) { 159 | return { status: protocolError }; 160 | } 161 | 162 | const request = { 163 | team: team, 164 | x: args[0].split(',')[0], 165 | y: args[0].split(',')[1] 166 | }; 167 | 168 | const error = verifySingleCellRequest(request); 169 | 170 | if (error) { 171 | return { status: error }; 172 | } 173 | 174 | return engine.mine(request.team, request.x, request.y); 175 | }; 176 | 177 | const cloak = (team, args) => { 178 | const protocolError = verifyProtocol(team, args, 3); 179 | 180 | if (protocolError) { 181 | return { status: protocolError }; 182 | } 183 | 184 | const request = { 185 | team: team, 186 | cells: args.map(arg => { 187 | return { 188 | x: arg.split(',')[0], 189 | y: arg.split(',')[1] 190 | }; 191 | }) 192 | }; 193 | 194 | const error = verifyMultipleCellRequest(request); 195 | 196 | if (error) { 197 | return { status: error }; 198 | } 199 | 200 | return engine.cloak(request.team, request.cells); 201 | }; 202 | 203 | const spy = (team, args) => { 204 | const protocolError = verifyProtocol(team, args, 2); 205 | 206 | if (protocolError) { 207 | return { status: protocolError }; 208 | } 209 | 210 | const request = { 211 | team: team, 212 | target: args[0], 213 | x: args[1].split(',')[0], 214 | y: args[1].split(',')[1] 215 | }; 216 | 217 | const error = verifySpyRequest(request); 218 | 219 | if (error) { 220 | return { status: error }; 221 | } 222 | 223 | return engine.spy(request.team, request.target, request.x, request.y); 224 | }; 225 | 226 | module.exports = { 227 | attack, 228 | defend, 229 | query, 230 | mine, 231 | cloak, 232 | spy 233 | }; -------------------------------------------------------------------------------- /server/game/engine.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const clone = require('../../lib/clone'); 4 | const config = require('../../config'); 5 | const db = require('../../lib/db'); 6 | const grid = require('./grid'); 7 | const log = require('../../lib/log'); 8 | const requests = require('./requests'); 9 | const roles = require('./roles'); 10 | const statuses = require('./statuses'); 11 | const team = require('./team'); 12 | 13 | const verifyTeam = key => { 14 | if (!db.get().gameStarted) { 15 | return statuses.gameNotStarted; 16 | } 17 | 18 | if (!team.hasRequests(key)) { 19 | return statuses.noRequestsLeft; 20 | } 21 | }; 22 | 23 | const roleVerify = (key, role) => { 24 | if (!roles.verify(key, role)) { 25 | return statuses.roleNotAssigned; 26 | } 27 | 28 | if (roles.roleUsed(key)) { 29 | return statuses.roleAlreadyUsed; 30 | } 31 | }; 32 | 33 | const init = gridSize => { 34 | const state = db.init(); 35 | 36 | state.grid = grid.generate(gridSize.width, gridSize.height); 37 | requests.stopRefreshTimer(); 38 | 39 | log('game', `initialised with ${gridSize.width}x${gridSize.height} grid`); 40 | }; 41 | 42 | const loadExistingGame = () => { 43 | const state = db.load(); 44 | 45 | if (state.gameStarted) { 46 | requests.startRefreshTimer(); 47 | log('game', `loaded game from ${new Date(state.date)}`); 48 | } 49 | }; 50 | 51 | const getStatus = () => { 52 | const state = db.get(); 53 | 54 | if (!state.grid) { 55 | return null; 56 | } 57 | 58 | return { 59 | started: state.gameStarted, 60 | width: state.grid.width || 0, 61 | height: state.grid.height || 0, 62 | doubleSquares: state.grid.doubleSquares || 0, 63 | tripleSquares: state.grid.tripleSquares || 0 64 | }; 65 | }; 66 | 67 | const start = () => { 68 | const state = db.get(); 69 | 70 | state.gameStarted = true; 71 | 72 | if (process.env.NODE_ENV !== 'test') { 73 | requests.startRefreshTimer(); 74 | } 75 | 76 | log('game', 'started'); 77 | }; 78 | 79 | const stop = () => { 80 | const state = db.get(); 81 | 82 | state.gameStarted = false; 83 | 84 | if (process.env.NODE_ENV !== 'test') { 85 | requests.stopRefreshTimer(); 86 | } 87 | 88 | db.takeFinalSnapshot(); 89 | log('game', 'stopped'); 90 | }; 91 | 92 | const attack = (key, x, y) => { 93 | const verificationError = verifyTeam(key); 94 | 95 | if (verificationError) { 96 | return { status: verificationError }; 97 | } 98 | 99 | const redirection = roles.isTeamRedirected(key); 100 | 101 | if (redirection) { 102 | x = redirection.x; 103 | y = redirection.y; 104 | } 105 | 106 | const state = db.get(); 107 | let cell = grid.getCell(state.grid, x, y); 108 | 109 | if (!cell) { 110 | return { 111 | status: statuses.invalidCell, 112 | result: { x, y } 113 | }; 114 | } 115 | 116 | const mineResult = roles.checkMineTrigger(key, x, y); 117 | 118 | if (mineResult.triggered) { 119 | team.useAllRequests(key); 120 | 121 | return { 122 | status: statuses.okMineTriggered, 123 | result: { 124 | requestsRemaining: team.getRequestsRemaining(key), 125 | owner: mineResult.owner 126 | } 127 | }; 128 | } 129 | 130 | cell.health -= 1; 131 | 132 | const teamData = team.getPublicData(key); 133 | 134 | if (cell.health <= 0) { 135 | grid.setCellOwner(cell, teamData); 136 | log('game', `${teamData.name} conquered cell ${x},${y} from ${cell.owner.name}`); 137 | } else { 138 | grid.addCellAttackHistory(cell, teamData.name, teamData.colour); 139 | } 140 | 141 | team.useRequest(key); 142 | 143 | return { 144 | status: statuses.ok, 145 | result: { 146 | requestsRemaining: team.getRequestsRemaining(key) 147 | } 148 | }; 149 | }; 150 | 151 | const defend = (key, x, y) => { 152 | const verificationError = verifyTeam(key); 153 | 154 | if (verificationError) { 155 | return { status: verificationError }; 156 | } 157 | 158 | const redirection = roles.isTeamRedirected(key); 159 | 160 | if (redirection) { 161 | x = redirection.x; 162 | y = redirection.y; 163 | } 164 | 165 | const state = db.get(); 166 | const cell = grid.getCell(state.grid, x, y); 167 | 168 | if (!cell) { 169 | return { 170 | status: statuses.invalidCell, 171 | result: { x, y } 172 | }; 173 | } 174 | 175 | const mineResult = roles.checkMineTrigger(key, x, y); 176 | 177 | if (mineResult.triggered) { 178 | team.useAllRequests(key); 179 | 180 | return { 181 | status: statuses.okMineTriggered, 182 | result: { 183 | requestsRemaining: team.getRequestsRemaining(key), 184 | owner: mineResult.owner 185 | } 186 | }; 187 | } 188 | 189 | cell.health += 1; 190 | 191 | const { cpu, player } = config.game.health; 192 | const maxHealth = (cell.owner.name === 'cpu') ? cpu : player; 193 | 194 | if (cell.health > maxHealth) { 195 | cell.health = maxHealth; 196 | } 197 | 198 | grid.addCellDefendHistory(cell, team.getPublicData(key).name); 199 | 200 | team.useRequest(key); 201 | 202 | return { 203 | status: statuses.ok, 204 | result: { 205 | requestsRemaining: team.getRequestsRemaining(key) 206 | } 207 | }; 208 | }; 209 | 210 | const query = key => { 211 | if (key) { 212 | if (!team.exists(key)) { 213 | return { status: statuses.invalidTeam }; 214 | } 215 | } 216 | 217 | const state = db.get(); 218 | 219 | if (!state.grid) { 220 | return { 221 | status: statuses.ok, 222 | result: { 223 | grid: [], 224 | gameStarted: false 225 | } 226 | }; 227 | } 228 | 229 | const grid = clone(state.grid); 230 | 231 | roles.updateGridWithCloaks(grid); 232 | 233 | const data = { 234 | status: statuses.ok, 235 | result: { 236 | grid: grid.cells, 237 | gameStarted: state.gameStarted 238 | } 239 | }; 240 | 241 | if (key) { 242 | data.result.requestsRemaining = team.getRequestsRemaining(key); 243 | } 244 | 245 | return data; 246 | }; 247 | 248 | const mine = (key, x, y) => { 249 | const verificationError = verifyTeam(key); 250 | 251 | if (verificationError) { 252 | return { status: verificationError }; 253 | } 254 | 255 | const roleError = roleVerify(key, 'minelayer'); 256 | 257 | if (roleError) { 258 | return { status: roleError }; 259 | } 260 | 261 | const state = db.get(); 262 | const cell = grid.getCell(state.grid, x, y); 263 | 264 | if (!cell) { 265 | return { 266 | status: statuses.invalidCell, 267 | result: { x, y } 268 | }; 269 | } 270 | 271 | team.useRequest(key); 272 | roles.useRole(key); 273 | 274 | const mineResult = roles.checkMineTrigger(key, x, y); 275 | 276 | if (mineResult.triggered) { 277 | team.useAllRequests(key); 278 | 279 | return { 280 | status: statuses.okMineTriggered, 281 | result: { 282 | requestsRemaining: team.getRequestsRemaining(key), 283 | owner: mineResult.owner 284 | } 285 | }; 286 | } 287 | 288 | roles.setMine(key, x, y); 289 | 290 | return { 291 | status: statuses.ok, 292 | result: { 293 | requestsRemaining: team.getRequestsRemaining(key) 294 | } 295 | }; 296 | }; 297 | 298 | const cloak = (key, cells) => { 299 | const roleError = roleVerify(key, 'cloaker'); 300 | 301 | if (roleError) { 302 | return { status: roleError }; 303 | } 304 | 305 | if (cells.length > 3) { 306 | return { 307 | status: statuses.roleTooManyCells, 308 | result: { maxCells: 3 } 309 | }; 310 | } 311 | 312 | const state = db.get(); 313 | 314 | for (let i = 0; i < cells.length; i++) { 315 | const cell = grid.getCell(state.grid, cells[i].x, cells[i].y); 316 | 317 | if (!cell) { 318 | return { 319 | status: statuses.invalidCell, 320 | result: { x: cells[i].x, y: cells[i].y } 321 | }; 322 | } 323 | } 324 | 325 | roles.setCloak(key, cells); 326 | team.useRequest(key); 327 | roles.useRole(key); 328 | 329 | return { 330 | status: statuses.ok, 331 | result: { 332 | requestsRemaining: team.getRequestsRemaining(key) 333 | } 334 | }; 335 | }; 336 | 337 | const spy = (key, teamName, x, y) => { 338 | const roleError = roleVerify(key, 'spy'); 339 | 340 | if (roleError) { 341 | return { status: roleError }; 342 | } 343 | 344 | if (!team.existsByName(teamName)) { 345 | return { 346 | status: statuses.roleTeamNotFound, 347 | result: { 348 | team: teamName 349 | } 350 | }; 351 | } 352 | 353 | const state = db.get(); 354 | const cell = grid.getCell(state.grid, x, y); 355 | 356 | if (!cell) { 357 | return { 358 | status: statuses.invalidCell, 359 | result: { x, y } 360 | }; 361 | } 362 | 363 | roles.setSpy(key, teamName, x, y); 364 | team.useRequest(key); 365 | roles.useRole(key); 366 | 367 | return { 368 | status: statuses.ok, 369 | result: { 370 | requestsRemaining: team.getRequestsRemaining(key) 371 | } 372 | }; 373 | }; 374 | 375 | module.exports = { 376 | init, 377 | loadExistingGame, 378 | getStatus, 379 | start, 380 | stop, 381 | attack, 382 | defend, 383 | query, 384 | mine, 385 | cloak, 386 | spy 387 | }; 388 | -------------------------------------------------------------------------------- /server/game/grid.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const config = require('../../config'); 4 | 5 | let generated = {}; 6 | 7 | const generateRandomCoords = (width, height, count) => { 8 | let coords = []; 9 | 10 | for (let i = 0; i < count; i++) { 11 | let generating = true; 12 | 13 | while (generating) { 14 | const x = Math.floor(Math.random() * width); 15 | const y = Math.floor(Math.random() * height); 16 | 17 | const key = `${x},${y}`; 18 | 19 | if (generated[key]) { 20 | continue; 21 | } 22 | 23 | generated[key] = true; 24 | coords.push({ x, y }); 25 | break; 26 | } 27 | } 28 | 29 | return { coords }; 30 | }; 31 | 32 | const applyBonusSquares = (cells, coords, bonus) => { 33 | coords.forEach(coord => { 34 | cells[coord.y][coord.x].bonus = bonus; 35 | }); 36 | }; 37 | 38 | const generate = (width, height) => { 39 | generated = {}; 40 | 41 | let cells = []; 42 | 43 | const preownedState = () => { 44 | return { 45 | bonus: 1, 46 | health: config.game.health.cpu, 47 | history: { 48 | attacks: {}, 49 | defends: {} 50 | }, 51 | owner: { 52 | name: 'cpu', 53 | colour: '#333333' 54 | } 55 | }; 56 | }; 57 | 58 | for (let y = 0; y < height; y++) { 59 | if (!cells[y]) { 60 | cells[y] = []; 61 | } 62 | 63 | for (let x = 0; x < width; x++) { 64 | cells[y].push(preownedState()); 65 | } 66 | } 67 | 68 | const { x2, x3 } = config.game.bonus; 69 | 70 | const x2Count = Math.ceil(width * height * x2); 71 | const x3Count = Math.ceil(width * height * x3); 72 | 73 | const double = generateRandomCoords(width, height, x2Count); 74 | applyBonusSquares(cells, double.coords, 2); 75 | 76 | const triple = generateRandomCoords(width, height, x3Count); 77 | applyBonusSquares(cells, triple.coords, 3); 78 | 79 | return { 80 | cells, 81 | width, 82 | height, 83 | doubleSquares: x2Count, 84 | tripleSquares: x3Count 85 | }; 86 | }; 87 | 88 | const getCell = (grid, x, y) => { 89 | const cells = grid.cells; 90 | 91 | try { 92 | if (cells[y][x]) { 93 | return cells[y][x]; 94 | } 95 | } catch (err) {} 96 | }; 97 | 98 | const setCellOwner = (cell, ownerData) => { 99 | cell.owner = ownerData; 100 | cell.health = config.game.health.player; 101 | cell.history = { attacks: {}, defends: {} }; 102 | 103 | delete cell.lastAttack; 104 | }; 105 | 106 | const addCellAttackHistory = (cell, name, colour) => { 107 | if (cell.history.attacks[name] === undefined) { 108 | cell.history.attacks[name] = 0; 109 | } 110 | 111 | cell.history.attacks[name] += 1; 112 | 113 | cell.lastAttack = { 114 | time: new Date().getTime(), 115 | team: { name, colour } 116 | }; 117 | }; 118 | 119 | const addCellDefendHistory = (cell, name) => { 120 | if (cell.history.defends[name] === undefined) { 121 | cell.history.defends[name] = 0; 122 | } 123 | 124 | cell.history.defends[name] += 1; 125 | }; 126 | 127 | module.exports = { 128 | generate, 129 | getCell, 130 | setCellOwner, 131 | addCellAttackHistory, 132 | addCellDefendHistory 133 | }; 134 | -------------------------------------------------------------------------------- /server/game/requests.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const config = require('../../config'); 4 | const log = require('../../lib/log'); 5 | const team = require('./team'); 6 | 7 | let interval; 8 | let lastRefresh; 9 | 10 | const startRefreshTimer = refreshRateSecs => { 11 | team.resetRequests(); 12 | lastRefresh = new Date; 13 | 14 | const refresh = () => { 15 | if (new Date - lastRefresh >= (refreshRateSecs || config.game.requests.refresh) * 1000) { 16 | team.resetRequests(); 17 | lastRefresh = new Date; 18 | log('requests', 'replenished all'); 19 | } 20 | }; 21 | 22 | stopRefreshTimer(); 23 | interval = setInterval(refresh, 900); 24 | log('requests', 'refresh timer started'); 25 | }; 26 | 27 | const stopRefreshTimer = () => { 28 | clearInterval(interval); 29 | }; 30 | 31 | const getSecondsUntilNextRefresh = args => { 32 | const refreshRateSecs = args.refreshRateSecs || config.game.requests.refresh; 33 | const currentTime = args.currentTime || new Date; 34 | const lastRefreshTime = args.lastRefresh || lastRefresh; 35 | 36 | return Math.round(refreshRateSecs - ((currentTime - lastRefreshTime) / 1000)); 37 | }; 38 | 39 | module.exports = { 40 | startRefreshTimer, 41 | stopRefreshTimer, 42 | getSecondsUntilNextRefresh 43 | }; 44 | -------------------------------------------------------------------------------- /server/game/roles.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const config = require('../../config'); 4 | const db = require('../../lib/db'); 5 | const log = require('../../lib/log'); 6 | 7 | let test = { 8 | timeOverride: null 9 | }; 10 | 11 | const getTeamByKey = key => { 12 | return db.get().teams.filter(team => { 13 | return team.key === key; 14 | })[0]; 15 | }; 16 | 17 | const getTeamByName = name => { 18 | return db.get().teams.filter(team => { 19 | return team.name === name; 20 | })[0]; 21 | }; 22 | 23 | const verify = (key, role) => { 24 | return getTeamByKey(key).role === role; 25 | }; 26 | 27 | const useRole = key => { 28 | getTeamByKey(key).roleUsed = true; 29 | }; 30 | 31 | const roleUsed = key => { 32 | return getTeamByKey(key).roleUsed; 33 | }; 34 | 35 | const setMine = (key, x, y) => { 36 | const state = db.get(); 37 | const team = getTeamByKey(key); 38 | 39 | state.roleData.mines[`${x},${y}`] = { triggered: false, owner: team.name }; 40 | team.mineSetAt = `${x},${y}`; 41 | 42 | log('roles', `${team.name} set mine at ${x},${y}`); 43 | }; 44 | 45 | const checkMineTrigger = (key, x, y) => { 46 | const state = db.get(); 47 | 48 | const mineData = state.roleData.mines[`${x},${y}`]; 49 | 50 | if (mineData && !mineData.triggered) { 51 | mineData.triggered = true; 52 | mineData.triggeredBy = getTeamByKey(key).name; 53 | 54 | const owner = getTeamByName(mineData.owner); 55 | owner.mineTriggeredBy = mineData.triggeredBy; 56 | 57 | log('roles', `${mineData.owner}'s mine triggered by ${mineData.triggeredBy}`); 58 | 59 | return { 60 | triggered: true, 61 | owner: mineData.owner 62 | }; 63 | } 64 | 65 | return { 66 | triggered: false 67 | }; 68 | }; 69 | 70 | const setCloak = (key, cells) => { 71 | if (!cells.length) { 72 | return; 73 | } 74 | 75 | const getTimestamp = () => { 76 | const time = new Date; 77 | 78 | return [time.getHours(), time.getMinutes(), time.getSeconds()].map(part => { 79 | return part < 10 ? `0${part}` : part; 80 | }).join(':'); 81 | }; 82 | 83 | const state = db.get(); 84 | const team = getTeamByKey(key); 85 | 86 | team.cloakTime = getTimestamp(); 87 | 88 | team.cloakedCells = cells.map(cell => { 89 | return `${cell.x},${cell.y}`; 90 | }).join(' '); 91 | 92 | cells.forEach(cell => { 93 | state.roleData.cloaks.push({ 94 | cloakTime: new Date().getTime(), 95 | owner: team.name, 96 | x: cell.x, 97 | y: cell.y 98 | }); 99 | }); 100 | 101 | log('roles', `${team.name} deployed cloak`); 102 | }; 103 | 104 | const updateGridWithCloaks = grid => { 105 | const state = db.get(); 106 | 107 | const cloakValidityMs = config.game.roles.cloak.minutes * 60 * 1000; 108 | 109 | state.roleData.cloaks.filter(cloak => { 110 | const age = (test.timeOverride || new Date().getTime()) - cloak.cloakTime; 111 | return age <= cloakValidityMs; 112 | }).forEach(cloak => { 113 | grid.cells[cloak.y][cloak.x].health = config.game.health.player; 114 | grid.cells[cloak.y][cloak.x].history = { attacks: {}, defends: {} }; 115 | }); 116 | }; 117 | 118 | const setSpy = (key, teamName, x, y) => { 119 | const state = db.get(); 120 | const team = getTeamByKey(key); 121 | 122 | team.redirectedTeam = getTeamByName(teamName).name; 123 | team.redirectedTo = `${x},${y}`; 124 | 125 | state.roleData.redirects[teamName] = { 126 | remaining: config.game.roles.spy.redirects, 127 | owner: team.name, 128 | x, 129 | y 130 | }; 131 | 132 | log('roles', `${team.name} set redirect on ${team.redirectedTeam} to ${x},${y}`); 133 | }; 134 | 135 | const isTeamRedirected = key => { 136 | const state = db.get(); 137 | const team = getTeamByKey(key); 138 | 139 | let redirect = state.roleData.redirects[team.name]; 140 | 141 | if (redirect && redirect.remaining > 0) { 142 | redirect.remaining -= 1; 143 | 144 | log('roles', `${team.name}'s request redirected to ${redirect.x},${redirect.y}`); 145 | 146 | return { 147 | x: redirect.x, 148 | y: redirect.y 149 | }; 150 | } 151 | 152 | return false; 153 | }; 154 | 155 | const setCurrentTime = time => { 156 | test.timeOverride = time; 157 | }; 158 | 159 | module.exports = { 160 | getTeamByName, 161 | verify, 162 | useRole, 163 | roleUsed, 164 | setMine, 165 | checkMineTrigger, 166 | setCloak, 167 | updateGridWithCloaks, 168 | setSpy, 169 | isTeamRedirected, 170 | test: { setCurrentTime } 171 | }; 172 | -------------------------------------------------------------------------------- /server/game/server.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const net = require('net'); 4 | 5 | const commands = require('./commands'); 6 | const config = require('../../config'); 7 | const log = require('../../lib/log'); 8 | const statuses = require('./statuses'); 9 | 10 | const startServer = () => { 11 | const parseRequest = data => { 12 | const parts = data.trim().split(' '); 13 | 14 | return { 15 | name: parts[0], 16 | team: parts[1], 17 | args: parts.slice(2) 18 | }; 19 | }; 20 | 21 | const sendStatus = (socket, result) => { 22 | if (socket.connected) { 23 | socket.write(`${JSON.stringify(result)}\n`); 24 | } 25 | }; 26 | 27 | const runCommand = (socket, request) => { 28 | const req = parseRequest(request); 29 | 30 | if (!commands[req.name]) { 31 | return sendStatus(socket, { status: statuses.missingCommand }); 32 | } 33 | 34 | log('game', `command: ${request}`); 35 | 36 | const result = commands[req.name](req.team, req.args); 37 | 38 | if (result.status === statuses.protocolRateLimit) { 39 | log('game', `${socket.remoteAddress} killed after hitting rate limit`); 40 | socket.destroy(); 41 | return false; 42 | } 43 | 44 | sendStatus(socket, result); 45 | return true; 46 | }; 47 | 48 | const runCommands = (socket, buffer) => { 49 | const requests = buffer.split('\n'); 50 | 51 | for (let i = 0; i < requests.length - 1; i++) { 52 | const successful = runCommand(socket, requests[i]); 53 | 54 | if (!successful) { 55 | break; 56 | } 57 | } 58 | 59 | return requests[requests.length - 1]; 60 | }; 61 | 62 | const server = net.createServer(socket => { 63 | let buffer = ''; 64 | 65 | log('game', `${socket.remoteAddress} connected`); 66 | 67 | socket.connected = true; 68 | 69 | socket.on('data', chunk => { 70 | buffer += chunk.toString(); 71 | 72 | if (buffer.length > config.server.game.maxBuffer) { 73 | log('game', `${socket.remoteAddress} killed after sending too much data`); 74 | return socket.destroy(); 75 | } 76 | 77 | buffer = runCommands(socket, buffer); 78 | }); 79 | 80 | socket.on('end', () => { 81 | socket.connected = false; 82 | }); 83 | 84 | socket.on('close', () => { 85 | socket.connected = false; 86 | }); 87 | }); 88 | 89 | const { bind, port } = config.server.game; 90 | 91 | server.listen(port, bind); 92 | log('game', `listening on ${bind}:${port}`); 93 | }; 94 | 95 | module.exports = { 96 | startServer 97 | }; 98 | -------------------------------------------------------------------------------- /server/game/statuses.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | ok: 'ok', 5 | okMineTriggered: 'ok.mine_triggered', 6 | protocolRateLimit: 'err.proto_rate_limit', 7 | protocolMissingTeam: 'err.proto_missing_team', 8 | protocolBadArgs: 'err.proto_bad_args', 9 | gameNotStarted: 'err.game_not_started', 10 | noRequestsLeft: 'err.no_requests_left', 11 | missingCommand: 'err.missing_command', 12 | missingTeamKey: 'err.missing_team_key', 13 | missingXCoord: 'err.missing_x_coord', 14 | missingYCoord: 'err.missing_y_coord', 15 | missingCells: 'err.missing_cells', 16 | missingTargetTeam: 'err.missing_target_team', 17 | invalidCell: 'err.invalid_cell', 18 | invalidTeam: 'err.invalid_team', 19 | roleNotAssigned: 'err.role_not_assigned', 20 | roleAlreadyUsed: 'err.role_already_used', 21 | roleTooManyCells: 'err.too_many_cells', 22 | roleTeamNotFound: 'err.role_team_not_found' 23 | }; -------------------------------------------------------------------------------- /server/game/team.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const config = require('../../config'); 4 | const db = require('../../lib/db'); 5 | 6 | const getByKey = key => { 7 | return db.get().teams.filter(team => { 8 | return team.key === key; 9 | })[0]; 10 | }; 11 | 12 | const existsByName = name => { 13 | const team = db.get().teams.filter(team => { 14 | return team.name === name; 15 | })[0]; 16 | 17 | return !!team; 18 | }; 19 | 20 | const hasRequests = key => { 21 | const team = getByKey(key); 22 | return team && team.requests > 0; 23 | }; 24 | 25 | const exists = key => { 26 | return !!getByKey(key); 27 | }; 28 | 29 | const useRequest = key => { 30 | getByKey(key).requests -= 1; 31 | }; 32 | 33 | const useAllRequests = key => { 34 | getByKey(key).requests = 0; 35 | }; 36 | 37 | const resetRequests = () => { 38 | db.get().teams.forEach(team => { 39 | team.requests = config.game.requests.amount; 40 | }); 41 | }; 42 | 43 | const getRequestsRemaining = key => { 44 | return getByKey(key).requests; 45 | }; 46 | 47 | const getPublicData = key => { 48 | const team = getByKey(key); 49 | 50 | return { 51 | name: team.name, 52 | colour: team.colour 53 | }; 54 | }; 55 | 56 | module.exports = { 57 | existsByName, 58 | hasRequests, 59 | exists, 60 | useRequest, 61 | useAllRequests, 62 | resetRequests, 63 | getRequestsRemaining, 64 | getPublicData 65 | }; 66 | -------------------------------------------------------------------------------- /spec/game.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | 'use strict'; 3 | 4 | const expect = require('expect.js'); 5 | 6 | const config = require('../config'); 7 | const db = require('../lib/db'); 8 | const engine = require('../server/game/engine'); 9 | const grid = require('../server/game/grid'); 10 | const roles = require('../server/game/roles'); 11 | const statuses = require('../server/game/statuses'); 12 | 13 | beforeEach(() => { 14 | db.init(); 15 | }); 16 | 17 | describe('game', () => { 18 | describe('init', () => { 19 | it('creates game with correct state', () => { 20 | engine.init({ width: 10, height: 10 }); 21 | 22 | const state = db.get(); 23 | 24 | // registration should be closed by default on new games 25 | expect(state.registrationOpen).to.be(false); 26 | 27 | // the game should not yet be playable 28 | expect(state.gameStarted).to.be(false); 29 | 30 | // game time should be within the last 5 seconds 31 | expect(state.date.getTime()).to.be.within((new Date).getTime() - 5000, (new Date).getTime()); 32 | 33 | // game grid should be initialised with correct fields 34 | expect(state.grid).to.have.keys('cells', 'width', 'height', 'doubleSquares', 'tripleSquares'); 35 | 36 | // grid dimensions should be 10x10 37 | expect(state.grid.width).to.be(10); 38 | expect(state.grid.height).to.be(10); 39 | 40 | // grid should have a non-zero number of x2 and x3 squares 41 | expect(state.grid.doubleSquares).to.be.above(0); 42 | expect(state.grid.tripleSquares).to.be.above(0); 43 | 44 | // grid should contain the correct number of y,x cells 45 | expect(state.grid.cells.length).to.be(10); 46 | expect(state.grid.cells[0].length).to.be(10); 47 | 48 | state.grid.cells.forEach(row => { 49 | row.forEach(cell => { 50 | // all cells should be initialised with correct fields 51 | expect(cell).to.have.keys('bonus', 'health', 'history', 'owner'); 52 | 53 | // all cells should have at least a bonus of 1 54 | expect(cell.bonus).to.be.above(0); 55 | 56 | // all cells should have health of 60 57 | expect(cell.health).to.be(config.game.health.cpu); 58 | 59 | // all cells should be owned by CPU 60 | expect(cell.owner.name).to.be('cpu'); 61 | expect(cell.owner.colour).not.be(undefined); 62 | 63 | // cells should have empty attack and defend history 64 | expect(Object.keys(cell.history.attacks).length).to.be(0); 65 | expect(Object.keys(cell.history.defends).length).to.be(0); 66 | }); 67 | }); 68 | }); 69 | }); 70 | 71 | describe('requests', () => { 72 | beforeEach(() => { 73 | engine.init({ width: 5, height: 5 }); 74 | }); 75 | 76 | it('returns not started error if requests sent before start', () => { 77 | const result = engine.attack('team-key', 0, 0); 78 | 79 | expect(result.status).to.be(statuses.gameNotStarted); 80 | }); 81 | 82 | it('returns no requests error if team key is invalid', () => { 83 | engine.start(); 84 | 85 | const result = engine.attack('invalid', 0, 0); 86 | 87 | expect(result.status).to.be(statuses.noRequestsLeft); 88 | }); 89 | 90 | it('returns no requests error if requests run out', () => { 91 | const state = db.get(); 92 | 93 | const team1 = { key: 'team-1', requests: 3 }; 94 | const team2 = { key: 'team-2', requests: 3 }; 95 | 96 | state.teams.push(team1, team2); 97 | 98 | engine.start(); 99 | engine.attack('team-1', 1, 1); 100 | engine.attack('team-1', 1, 1); 101 | 102 | engine.attack('team-2', 1, 2); 103 | engine.attack('team-2', 1, 2); 104 | engine.attack('team-2', 1, 2); 105 | 106 | expect(team1.requests).to.be(1); 107 | expect(team2.requests).to.be(0); 108 | 109 | const result = engine.attack('team-2', 1, 2); 110 | expect(result.status).to.be(statuses.noRequestsLeft); 111 | }); 112 | 113 | it('returns remaining requests if successful', () => { 114 | db.get().teams.push({ key: 'team-1', requests: 30 }); 115 | 116 | engine.start(); 117 | const result = engine.attack('team-1', 1, 1); 118 | 119 | expect(result.result.requestsRemaining).to.be(29); 120 | }); 121 | }); 122 | 123 | describe('commands', () => { 124 | beforeEach(() => { 125 | engine.init({ width: 5, height: 5 }); 126 | engine.start(); 127 | }); 128 | 129 | it('returns invalid cell error if coords are invalid', () => { 130 | db.get().teams.push({ key: 'team-1', requests: 1 }); 131 | db.get().teams.push({ key: 'team-2', requests: 1 }); 132 | 133 | const result1 = engine.attack('team-1', 5, 0); 134 | const result2 = engine.attack('team-2', 0, 5); 135 | 136 | expect(result1.status).to.be(statuses.invalidCell); 137 | expect(result1.result.x).to.be(5); 138 | expect(result1.result.y).to.be(0); 139 | 140 | expect(result2.status).to.be(statuses.invalidCell); 141 | expect(result2.result.x).to.be(0); 142 | expect(result2.result.y).to.be(5); 143 | }); 144 | 145 | it('updates correct cell state for an attack command', () => { 146 | const state = db.get(); 147 | 148 | state.teams.push({ key: 'team-1', name: 'Team 1', requests: 30 }); 149 | state.teams.push({ key: 'team-2', name: 'Team 2', requests: 30 }); 150 | 151 | const cell1 = grid.getCell(state.grid, 4, 1); 152 | const cell2 = grid.getCell(state.grid, 2, 3); 153 | 154 | engine.attack('team-1', 4, 1); 155 | expect(cell1.history.attacks['Team 1']).to.be(1); 156 | expect(cell1.health).to.be(59); 157 | 158 | engine.attack('team-1', 4, 1); 159 | expect(cell1.history.attacks['Team 1']).to.be(2); 160 | expect(cell1.health).to.be(58); 161 | 162 | engine.attack('team-2', 2, 3); 163 | expect(cell2.history.attacks['Team 2']).to.be(1); 164 | expect(cell2.health).to.be(59); 165 | 166 | engine.attack('team-2', 4, 1); 167 | expect(cell1.history.attacks['Team 1']).to.be(2); 168 | expect(cell1.history.attacks['Team 2']).to.be(1); 169 | expect(cell1.health).to.be(57); 170 | }); 171 | 172 | it('updates correct cell state for a defend command', () => { 173 | const state = db.get(); 174 | 175 | state.teams.push({ key: 'team-1', name: 'Team 1', requests: 30 }); 176 | state.teams.push({ key: 'team-2', name: 'Team 2', requests: 30 }); 177 | 178 | const cell1 = grid.getCell(state.grid, 1, 1); 179 | const cell2 = grid.getCell(state.grid, 1, 2); 180 | const cell3 = grid.getCell(state.grid, 1, 3); 181 | 182 | engine.defend('team-1', 1, 1); 183 | expect(cell1.history.defends['Team 1']).to.be(1); 184 | expect(cell1.health).to.be(60); 185 | 186 | engine.attack('team-2', 1, 2); 187 | engine.defend('team-1', 1, 2); 188 | expect(cell2.history.attacks['Team 2']).to.be(1); 189 | expect(cell2.history.defends['Team 1']).to.be(1); 190 | expect(cell2.health).to.be(60); 191 | 192 | engine.attack('team-2', 1, 3); 193 | engine.defend('team-1', 1, 3); 194 | engine.attack('team-2', 1, 3); 195 | expect(cell3.history.attacks['Team 2']).to.be(2); 196 | expect(cell3.history.defends['Team 1']).to.be(1); 197 | expect(cell3.health).to.be(59); 198 | }); 199 | 200 | it('updates correct cell state for change of owner', () => { 201 | const state = db.get(); 202 | 203 | const cell = grid.getCell(state.grid, 1, 1); 204 | 205 | state.teams.push({ key: 'team-1', name: 'Team 1', requests: 60 }); 206 | 207 | for (let i = 0; i < 59; i++) { 208 | engine.attack('team-1', 1, 1); 209 | } 210 | 211 | expect(cell.health).to.be(1); 212 | 213 | engine.attack('team-1', 1, 1); 214 | 215 | expect(cell.health).to.be(config.game.health.player); 216 | expect(cell.owner.name).to.be('Team 1'); 217 | expect(Object.keys(cell.history.attacks).length).to.be(0); 218 | expect(Object.keys(cell.history.defends).length).to.be(0); 219 | expect(cell.lastAttack).to.be(undefined); 220 | }); 221 | 222 | it('updates lastAttack data on cell for each attack command', () => { 223 | const state = db.get(); 224 | 225 | const cell = grid.getCell(state.grid, 1, 1); 226 | 227 | state.teams.push({ key: 'team-1', name: 'Team 1', colour: 'colour-one', requests: 30 }); 228 | state.teams.push({ key: 'team-2', name: 'Team 2', colour: 'colour-two', requests: 30 }); 229 | 230 | engine.attack('team-1', 1, 1); 231 | 232 | expect(cell.health).to.be(59); 233 | expect(cell.lastAttack.team.name).to.be('Team 1'); 234 | expect(cell.lastAttack.team.colour).to.be('colour-one'); 235 | expect(cell.lastAttack.time).to.be.within((new Date).getTime() - 5000, (new Date).getTime()); 236 | 237 | engine.attack('team-2', 1, 1); 238 | 239 | expect(cell.health).to.be(58); 240 | expect(cell.lastAttack.team.name).to.be('Team 2'); 241 | expect(cell.lastAttack.team.colour).to.be('colour-two'); 242 | expect(cell.lastAttack.time).to.be.within((new Date).getTime() - 5000, (new Date).getTime()); 243 | }); 244 | 245 | it('adds message to event log when cell is conquered', () => { 246 | const state = db.get(); 247 | 248 | const team1 = { key: 'team-1', name: 'Team 1', colour: 'colour-one', requests: 60 }; 249 | const team2 = { key: 'team-2', name: 'Team 2', colour: 'colour-two', requests: 60 }; 250 | const team3 = { key: 'team-3', name: 'Team 3', colour: 'colour-three', requests: 60 }; 251 | 252 | state.teams.push(team1, team2, team3); 253 | 254 | // team-1 attacks cell 0,0 once 255 | engine.attack('team-1', 0, 0); 256 | 257 | // team-2 attacks cell 0,1 enough times to own it 258 | for (let i = 0; i < config.game.health.cpu; i++) { 259 | engine.attack('team-2', 0, 1); 260 | } 261 | 262 | // team-3 attacks 0,2 3 times and defends once 263 | engine.attack('team-3', 0, 2); 264 | engine.attack('team-3', 0, 2); 265 | engine.attack('team-3', 0, 2); 266 | engine.defend('team-3', 0, 2); 267 | 268 | const result = engine.query(); 269 | 270 | // cell 0,0 has one attack from team-1 271 | const cell1 = result.result.grid[0][0]; 272 | 273 | expect(cell1.health).to.be(config.game.health.cpu - 1); 274 | expect(cell1.owner.name).to.be('cpu'); 275 | expect(cell1.owner.colour).not.be(undefined); 276 | expect(Object.keys(cell1.history.attacks).length).to.be(1); 277 | expect(Object.keys(cell1.history.defends).length).to.be(0); 278 | expect(cell1.history.attacks[team1.name]).to.be(1); 279 | 280 | // cell 0,1 gets conquered by team-2 281 | const cell2 = result.result.grid[1][0]; 282 | 283 | expect(cell2.health).to.be(config.game.health.player); 284 | expect(cell2.owner.name).to.be('Team 2'); 285 | expect(cell2.owner.colour).to.be(team2.colour); 286 | expect(Object.keys(cell2.history.attacks).length).to.be(0); 287 | expect(Object.keys(cell2.history.defends).length).to.be(0); 288 | 289 | // cell 0,2 has three attacks and one defend from team-3 290 | const cell3 = result.result.grid[2][0]; 291 | 292 | expect(cell3.health).to.be(config.game.health.cpu - 2); 293 | expect(cell3.owner.name).to.be('cpu'); 294 | expect(cell3.owner.colour).not.be(undefined); 295 | expect(Object.keys(cell3.history.attacks).length).to.be(1); 296 | expect(Object.keys(cell3.history.defends).length).to.be(1); 297 | expect(cell3.history.attacks[team3.name]).to.be(3); 298 | expect(cell3.history.defends[team3.name]).to.be(1); 299 | }); 300 | 301 | it('allows anonymous query and team-based query', () => { 302 | const state = db.get(); 303 | 304 | const team1 = { key: 'team-1', name: 'Team 1', colour: 'colour-one', requests: 60 }; 305 | 306 | state.teams.push(team1); 307 | 308 | // team-1 attacks cell 0,0 once 309 | engine.attack('team-1', 0, 0); 310 | 311 | // anonymous query 312 | const result1 = engine.query(); 313 | const cell1 = result1.result.grid[0][0]; 314 | expect(cell1.health).to.be(config.game.health.cpu - 1); 315 | 316 | // team-based query 317 | const result2 = engine.query(team1.key); 318 | const cell2 = result2.result.grid[0][0]; 319 | expect(cell2.health).to.be(config.game.health.cpu - 1); 320 | 321 | // invalid team-based query 322 | const result3 = engine.query('invalid'); 323 | expect(result3.status).to.be(statuses.invalidTeam); 324 | }); 325 | }); 326 | 327 | describe('roles', () => { 328 | beforeEach(() => { 329 | engine.init({ width: 10, height: 10 }); 330 | engine.start(); 331 | }); 332 | 333 | it('fails if team plays a role they do not own', () => { 334 | const state = db.get(); 335 | 336 | const team1 = { key: 'team-1', role: 'minelayer', requests: 1 }; 337 | const team2 = { key: 'team-2', role: 'cloaker', requests: 1 }; 338 | const team3 = { key: 'team-3', role: 'spy', requests: 1 }; 339 | 340 | state.teams.push(team1, team2, team3); 341 | 342 | const result1 = engine.mine(team3.key, 0, 0); 343 | expect(result1.status).to.be(statuses.roleNotAssigned); 344 | 345 | const result2 = engine.cloak(team1.key, [{ x: 0, y: 0 }, { x: 1, y: 0 }, { x: 2, y: 0 }]); 346 | expect(result2.status).to.be(statuses.roleNotAssigned); 347 | 348 | const result3 = engine.spy(team2.key, team1.name); 349 | expect(result3.status).to.be(statuses.roleNotAssigned); 350 | }); 351 | 352 | it('only allows a role to be played once', () => { 353 | const state = db.get(); 354 | 355 | const team1 = { key: 'team-1', role: 'minelayer', requests: 2 }; 356 | const team2 = { key: 'team-2', role: 'cloaker', requests: 2 }; 357 | const team3 = { key: 'team-3', role: 'spy', requests: 2 }; 358 | 359 | state.teams.push(team1, team2, team3); 360 | 361 | const result1 = engine.mine(team1.key, 0, 0); 362 | expect(result1.status).to.be(statuses.ok); 363 | expect(result1.result.requestsRemaining).to.be(1); 364 | 365 | const error1 = engine.mine(team1.key, 0, 0); 366 | expect(error1.status).to.be(statuses.roleAlreadyUsed); 367 | 368 | const result2 = engine.cloak(team2.key, [{ x: 0, y: 0 }, { x: 1, y: 0 }, { x: 2, y: 0 }]); 369 | expect(result2.status).to.be(statuses.ok); 370 | expect(result2.result.requestsRemaining).to.be(1); 371 | 372 | const error2 = engine.cloak(team2.key, [{ x: 0, y: 0 }, { x: 1, y: 0 }, { x: 2, y: 0 }]); 373 | expect(error2.status).to.be(statuses.roleAlreadyUsed); 374 | 375 | const result3 = engine.spy(team3.key, team1.name, 0, 0); 376 | expect(result3.status).to.be(statuses.ok); 377 | expect(result3.result.requestsRemaining).to.be(1); 378 | 379 | const error3 = engine.spy(team3.key, team1.name); 380 | expect(error3.status).to.be(statuses.roleAlreadyUsed); 381 | }); 382 | 383 | it('rejects mine lays at invalid cells', () => { 384 | const state = db.get(); 385 | 386 | state.teams.push({ key: 'team-1', role: 'minelayer', requests: 1 }); 387 | 388 | const result = engine.mine('team-1', 42, 42); 389 | expect(result.status).to.be(statuses.invalidCell); 390 | expect(result.result.x).to.be(42); 391 | expect(result.result.y).to.be(42); 392 | }); 393 | 394 | it('stores successful mine lay in state', () => { 395 | const state = db.get(); 396 | 397 | state.teams.push({ key: 'team-1', name: 'Team 1', role: 'minelayer', requests: 1 }); 398 | 399 | const result = engine.mine('team-1', 4, 5); 400 | 401 | expect(result.status).to.be(statuses.ok); 402 | expect(state.roleData.mines['4,5'].owner).to.be('Team 1'); 403 | expect(state.roleData.mines['4,5'].triggered).to.be(false); 404 | }); 405 | 406 | it('wipes player requests when mine is triggered and disables mine', () => { 407 | const state = db.get(); 408 | 409 | const team1 = { key: 'team-1', name: 'Team 1', role: 'minelayer', requests: 30 }; 410 | const team2 = { key: 'team-2', name: 'Team 2', role: 'cloaker', requests: 30 }; 411 | const team3 = { key: 'team-3', name: 'Team 3', role: 'spy', requests: 30 }; 412 | 413 | state.teams.push(team1, team2, team3); 414 | 415 | const result1 = engine.mine('team-1', 2, 2); 416 | expect(result1.status).to.be(statuses.ok); 417 | expect(result1.result.requestsRemaining).to.be(29); 418 | expect(state.grid.cells[2][2].health).to.be(60); 419 | 420 | expect(state.roleData.mines['2,2'].triggered).to.be(false); 421 | expect(state.roleData.mines['2,2'].owner).to.be('Team 1'); 422 | expect(state.roleData.mines['2,2'].triggeredBy).to.be(undefined); 423 | expect(team1.mineSetAt).to.be('2,2'); 424 | 425 | const result2 = engine.defend('team-2', 2, 2); 426 | expect(result2.status).to.be(statuses.okMineTriggered); 427 | expect(result2.result.requestsRemaining).to.be(0); 428 | expect(result2.result.owner).to.be('Team 1'); 429 | expect(state.grid.cells[2][2].health).to.be(60); 430 | expect(team2.requests).to.be(0); 431 | 432 | expect(state.roleData.mines['2,2'].triggered).to.be(true); 433 | expect(state.roleData.mines['2,2'].owner).to.be('Team 1'); 434 | expect(state.roleData.mines['2,2'].triggeredBy).to.be('Team 2'); 435 | 436 | expect(team1.mineTriggeredBy).to.be('Team 2'); 437 | 438 | const result3 = engine.attack('team-3', 2, 2); 439 | expect(result3.status).to.be(statuses.ok); 440 | expect(result3.result.requestsRemaining).to.be(29); 441 | }); 442 | 443 | it('laying mine on top of another mine triggers both', () => { 444 | const state = db.get(); 445 | 446 | const team1 = { key: 'team-1', name: 'Team 1', role: 'minelayer', requests: 30 }; 447 | const team2 = { key: 'team-2', name: 'Team 2', role: 'minelayer', requests: 30 }; 448 | 449 | state.teams.push(team1, team2); 450 | 451 | const result1 = engine.mine('team-1', 5, 5); 452 | expect(result1.status).to.be(statuses.ok); 453 | expect(result1.result.requestsRemaining).to.be(29); 454 | expect(state.grid.cells[5][5].health).to.be(60); 455 | expect(team1.requests).to.be(29); 456 | 457 | const result2 = engine.mine('team-2', 5, 5); 458 | expect(result2.status).to.be(statuses.okMineTriggered); 459 | expect(result2.result.requestsRemaining).to.be(0); 460 | expect(result2.result.owner).to.be('Team 1'); 461 | expect(state.grid.cells[5][5].health).to.be(60); 462 | expect(team2.requests).to.be(0); 463 | 464 | expect(state.roleData.mines['5,5'].triggered).to.be(true); 465 | expect(state.roleData.mines['5,5'].owner).to.be('Team 1'); 466 | expect(state.roleData.mines['5,5'].triggeredBy).to.be('Team 2'); 467 | 468 | expect(team1.mineTriggeredBy).to.be('Team 2'); 469 | 470 | team2.requests = 30; 471 | 472 | const result3 = engine.mine('team-2', 5, 5); 473 | expect(result3.status).to.be(statuses.roleAlreadyUsed); 474 | expect(team2.requests).to.be(30); 475 | }); 476 | 477 | it('applying cloak to more than 3 cells fails', () => { 478 | const state = db.get(); 479 | 480 | const team = { key: 'team-1', name: 'Team 1', role: 'cloaker', requests: 30 }; 481 | 482 | state.teams.push(team); 483 | 484 | const cells = [ 485 | { x: 0, y: 0 }, 486 | { x: 1, y: 1 }, 487 | { x: 2, y: 2 }, 488 | { x: 3, y: 3 } 489 | ]; 490 | 491 | const result = engine.cloak('team-1', cells); 492 | expect(result.status).to.be(statuses.roleTooManyCells); 493 | expect(result.result.maxCells).to.be(3); 494 | expect(team.requests).to.be(30); 495 | }); 496 | 497 | it('applying cloak to invalid cells fails', () => { 498 | const state = db.get(); 499 | 500 | const team1 = { key: 'team-1', name: 'Team 1', role: 'cloaker', requests: 30 }; 501 | const team2 = { key: 'team-2', name: 'Team 2', role: 'cloaker', requests: 30 }; 502 | 503 | state.teams.push(team1, team2); 504 | 505 | const cells1 = [ 506 | { x: 0, y: 0 }, 507 | { x: 1, y: 1 }, 508 | { x: 2, y: 42 } 509 | ]; 510 | 511 | const result1 = engine.cloak('team-1', cells1); 512 | expect(result1.status).to.be(statuses.invalidCell); 513 | expect(result1.result.x).to.be(2); 514 | expect(result1.result.y).to.be(42); 515 | expect(team1.requests).to.be(30); 516 | 517 | const cells2 = [ 518 | { x: 12, y: 0 }, 519 | { x: 1, y: 1 }, 520 | { x: 2, y: 42 } 521 | ]; 522 | 523 | const result2 = engine.cloak('team-2', cells2); 524 | expect(result2.status).to.be(statuses.invalidCell); 525 | expect(result2.result.x).to.be(12); 526 | expect(result2.result.y).to.be(0); 527 | expect(team2.requests).to.be(30); 528 | }); 529 | 530 | it('applying cloak to cells stores correct role state', () => { 531 | const state = db.get(); 532 | 533 | const team = { key: 'team-1', name: 'Team 1', role: 'cloaker', roleUsed: false, requests: 30 }; 534 | 535 | state.teams.push(team); 536 | 537 | const cells = [ 538 | { x: 0, y: 0 }, 539 | { x: 1, y: 1 }, 540 | { x: 2, y: 2 } 541 | ]; 542 | 543 | const result = engine.cloak('team-1', cells); 544 | expect(result.status).to.be(statuses.ok); 545 | expect(team.requests).to.be(29); 546 | 547 | expect(state.roleData.cloaks[0].cloakTime).to.be.within((new Date).getTime() - 5000, (new Date).getTime()); 548 | expect(state.roleData.cloaks[0].owner).to.be('Team 1'); 549 | expect(state.roleData.cloaks[0].x).to.be(0); 550 | expect(state.roleData.cloaks[0].y).to.be(0); 551 | 552 | expect(state.roleData.cloaks[1].cloakTime).to.be.within((new Date).getTime() - 5000, (new Date).getTime()); 553 | expect(state.roleData.cloaks[1].owner).to.be('Team 1'); 554 | expect(state.roleData.cloaks[1].x).to.be(1); 555 | expect(state.roleData.cloaks[1].y).to.be(1); 556 | 557 | expect(state.roleData.cloaks[2].cloakTime).to.be.within((new Date).getTime() - 5000, (new Date).getTime()); 558 | expect(state.roleData.cloaks[2].owner).to.be('Team 1'); 559 | expect(state.roleData.cloaks[2].x).to.be(2); 560 | expect(state.roleData.cloaks[2].y).to.be(2); 561 | 562 | expect(team.roleUsed).to.be(true); 563 | expect(team.cloakTime).to.not.be(undefined); 564 | expect(team.cloakedCells).to.be('0,0 1,1 2,2'); 565 | }); 566 | 567 | it('applying cloak to cells causes their health to be reported as 100% in queries for 5 mins', () => { 568 | const state = db.get(); 569 | 570 | const team = { key: 'team-1', name: 'Team 1', role: 'cloaker', requests: 30 }; 571 | 572 | state.teams.push(team); 573 | 574 | state.grid.cells[3][2].health = 10; 575 | state.grid.cells[3][2].history.attacks['Some Team'] = 5; 576 | state.grid.cells[5][4].health = 1; 577 | state.grid.cells[5][4].history.defends['Team 1'] = 10; 578 | state.grid.cells[5][4].history.defends['Team 2'] = 5; 579 | 580 | const result = engine.cloak('team-1', [ { x: 2, y: 3 }, { x: 4, y: 5 } ]); 581 | expect(result.status).to.be(statuses.ok); 582 | expect(team.requests).to.be(29); 583 | 584 | const queryState1 = engine.query(); 585 | 586 | expect(queryState1.result.grid[3][2].health).to.be(120); 587 | expect(Object.keys(queryState1.result.grid[3][2].history.attacks).length).to.be(0); 588 | expect(Object.keys(queryState1.result.grid[3][2].history.defends).length).to.be(0); 589 | 590 | expect(queryState1.result.grid[5][4].health).to.be(120); 591 | expect(Object.keys(queryState1.result.grid[5][4].history.attacks).length).to.be(0); 592 | expect(Object.keys(queryState1.result.grid[5][4].history.defends).length).to.be(0); 593 | 594 | roles.test.setCurrentTime(new Date().getTime() + (2.5 * 60 * 1000)); 595 | 596 | const queryState2 = engine.query(); 597 | 598 | expect(queryState2.result.grid[3][2].health).to.be(120); 599 | expect(queryState2.result.grid[5][4].health).to.be(120); 600 | 601 | roles.test.setCurrentTime(new Date().getTime() + (6 * 60 * 1000)); 602 | 603 | const queryState3 = engine.query(); 604 | 605 | expect(queryState3.result.grid[3][2].health).to.be(10); 606 | expect(queryState3.result.grid[5][4].health).to.be(1); 607 | }); 608 | 609 | it('applying spy to unknown team or invalid square fails', () => { 610 | const state = db.get(); 611 | 612 | const team1 = { key: 'team-1', name: 'Team 1', role: 'spy', requests: 30 }; 613 | const team2 = { key: 'team-2', name: 'Team 2', role: 'minelayer', requests: 30 }; 614 | 615 | state.teams.push(team1, team2); 616 | 617 | const result1 = engine.spy('team-1', 'Unknown Team', 4, 4); 618 | expect(result1.status).to.be(statuses.roleTeamNotFound); 619 | expect(result1.result.team).to.be('Unknown Team'); 620 | expect(team1.requests).to.be(30); 621 | 622 | const result2 = engine.spy('team-1', 'Team 2', 42, 4); 623 | expect(result2.status).to.be(statuses.invalidCell); 624 | expect(result2.result.x).to.be(42); 625 | expect(result2.result.y).to.be(4); 626 | expect(team1.requests).to.be(30); 627 | }); 628 | 629 | it('applying spy to valid team and square stores correct role state', () => { 630 | const state = db.get(); 631 | 632 | const team1 = { key: 'team-1', name: 'Team 1', role: 'spy', requests: 30 }; 633 | const team2 = { key: 'team-2', name: 'Team 2', role: 'cloaker', requests: 30 }; 634 | 635 | state.teams.push(team1, team2); 636 | 637 | const result = engine.spy('team-1', 'Team 2', 1, 3); 638 | expect(result.status).to.be(statuses.ok); 639 | expect(result.result.requestsRemaining).to.be(29); 640 | expect(team1.requests).to.be(29); 641 | 642 | expect(state.roleData.redirects['Team 2'].remaining).to.be(15); 643 | expect(state.roleData.redirects['Team 2'].owner).to.be('Team 1'); 644 | expect(state.roleData.redirects['Team 2'].x).to.be(1); 645 | expect(state.roleData.redirects['Team 2'].y).to.be(3); 646 | 647 | expect(team1.roleUsed).to.be(true); 648 | expect(team1.redirectedTeam).to.be('Team 2'); 649 | expect(team1.redirectedTo).to.be('1,3'); 650 | }); 651 | 652 | it('attack commands from a redirected team go to the redirected square', () => { 653 | const state = db.get(); 654 | 655 | const team1 = { key: 'team-1', name: 'Team 1', role: 'spy', requests: 30 }; 656 | const team2 = { key: 'team-2', name: 'Team 2', role: 'cloaker', requests: 30 }; 657 | 658 | state.teams.push(team1, team2); 659 | 660 | const result1 = engine.spy('team-1', 'Team 2', 1, 1); 661 | expect(result1.status).to.be(statuses.ok); 662 | expect(result1.result.requestsRemaining).to.be(29); 663 | expect(team1.requests).to.be(29); 664 | 665 | for (let i = 0; i < 14; i++) { 666 | expect(engine.attack('team-2', 5, 5).status).to.be(statuses.ok); 667 | } 668 | 669 | expect(team2.requests).to.be(16); 670 | expect(state.grid.cells[5][5].health).to.be(60); 671 | expect(state.grid.cells[1][1].health).to.be(46); 672 | expect(state.roleData.redirects['Team 2'].remaining).to.be(1); 673 | 674 | expect(engine.attack('team-2', 5, 5).status).to.be(statuses.ok); 675 | 676 | expect(team2.requests).to.be(15); 677 | expect(state.grid.cells[5][5].health).to.be(60); 678 | expect(state.grid.cells[1][1].health).to.be(45); 679 | expect(state.roleData.redirects['Team 2'].remaining).to.be(0); 680 | 681 | expect(engine.attack('team-2', 5, 5).status).to.be(statuses.ok); 682 | 683 | expect(team2.requests).to.be(14); 684 | expect(state.grid.cells[5][5].health).to.be(59); 685 | expect(state.grid.cells[1][1].health).to.be(45); 686 | expect(state.roleData.redirects['Team 2'].remaining).to.be(0); 687 | }); 688 | 689 | it('defend commands from a redirected team go to the redirected square', () => { 690 | const state = db.get(); 691 | 692 | const team1 = { key: 'team-1', name: 'Team 1', role: 'spy', requests: 30 }; 693 | const team2 = { key: 'team-2', name: 'Team 2', role: 'cloaker', requests: 30 }; 694 | 695 | state.teams.push(team1, team2); 696 | 697 | const result1 = engine.spy('team-1', 'Team 2', 1, 1); 698 | expect(result1.status).to.be(statuses.ok); 699 | expect(result1.result.requestsRemaining).to.be(29); 700 | expect(team1.requests).to.be(29); 701 | 702 | for (let i = 0; i < 7; i++) { 703 | expect(engine.attack('team-2', 5, 5).status).to.be(statuses.ok); 704 | } 705 | 706 | expect(team2.requests).to.be(23); 707 | expect(state.grid.cells[5][5].health).to.be(60); 708 | expect(state.grid.cells[1][1].health).to.be(53); 709 | expect(state.roleData.redirects['Team 2'].remaining).to.be(8); 710 | 711 | for (let j = 0; j < 8; j++) { 712 | expect(engine.defend('team-2', 5, 5).status).to.be(statuses.ok); 713 | } 714 | 715 | expect(team2.requests).to.be(15); 716 | expect(state.grid.cells[5][5].health).to.be(60); 717 | expect(state.grid.cells[1][1].health).to.be(60); 718 | expect(state.roleData.redirects['Team 2'].remaining).to.be(0); 719 | }); 720 | }); 721 | 722 | describe('query', () => { 723 | beforeEach(() => { 724 | engine.init({ width: 5, height: 5 }); 725 | engine.start(); 726 | }); 727 | 728 | it('returns remaining requests if team key is used', () => { 729 | const state = db.get(); 730 | 731 | const team = { key: 'team-1', requests: 10 }; 732 | 733 | state.teams.push(team); 734 | 735 | engine.start(); 736 | 737 | const response1 = engine.query(); 738 | const response2 = engine.query('team-1'); 739 | 740 | expect(response1.result.requestsRemaining).to.be(undefined); 741 | expect(response2.result.requestsRemaining).to.be(10); 742 | }); 743 | }); 744 | }); 745 | -------------------------------------------------------------------------------- /spec/registration.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | 'use strict'; 3 | 4 | const expect = require('expect.js'); 5 | 6 | const colours = require('../lib/colours'); 7 | const db = require('../lib/db'); 8 | const registration = require('../server/account/registration'); 9 | 10 | const createString = length => { 11 | return new Array(length + 1).join('.'); 12 | }; 13 | 14 | beforeEach(() => { 15 | db.init(); 16 | }); 17 | 18 | describe('registration', () => { 19 | it('rejects when registration is closed', () => { 20 | registration.close(); 21 | 22 | const response = registration.createTeam('Team_Name', 'user@host.com', 'minelayer'); 23 | 24 | expect(response.err).to.match(/closed/); 25 | expect(db.get().teams.length).to.be(0); 26 | }); 27 | 28 | it('rejects when name is missing', () => { 29 | registration.open(); 30 | 31 | const response = registration.createTeam(null, 'user@host.com', 'spy'); 32 | 33 | expect(response.err).to.match(/team name/); 34 | expect(db.get().teams.length).to.be(0); 35 | }); 36 | 37 | it('rejects when name is more than 25 chars', () => { 38 | registration.open(); 39 | 40 | const response = registration.createTeam(createString(26), 'user@host.com', 'cloaker'); 41 | 42 | expect(response.err).to.match(/team name/); 43 | expect(db.get().teams.length).to.be(0); 44 | }); 45 | 46 | it('rejects when name is "cpu"', () => { 47 | registration.open(); 48 | 49 | const response = registration.createTeam('cpu', 'user@host.com', 'cloaker'); 50 | 51 | expect(response.err).to.match(/valid team name/); 52 | expect(db.get().teams.length).to.be(0); 53 | }); 54 | 55 | it('rejects when email is missing', () => { 56 | registration.open(); 57 | 58 | const response = registration.createTeam('Team_Name', null, 'minelayer'); 59 | 60 | expect(response.err).to.match(/email address/); 61 | expect(db.get().teams.length).to.be(0); 62 | }); 63 | 64 | it('rejects when email is more than 50 chars', () => { 65 | registration.open(); 66 | 67 | const response = registration.createTeam('Team_Name', createString(51), 'cloaker'); 68 | 69 | expect(response.err).to.match(/email address/); 70 | expect(db.get().teams.length).to.be(0); 71 | }); 72 | 73 | it('rejects when role is missing', () => { 74 | registration.open(); 75 | 76 | const response = registration.createTeam('Existing_Team_Name', 'user@host.com', null); 77 | 78 | expect(response.err).to.match(/Valid roles/); 79 | expect(db.get().teams.length).to.be(0); 80 | }); 81 | 82 | it('rejects when role is invalid', () => { 83 | registration.open(); 84 | 85 | const response = registration.createTeam('Existing_Team_Name', 'user@host.com', 'invalid'); 86 | 87 | expect(response.err).to.match(/Valid roles/); 88 | expect(db.get().teams.length).to.be(0); 89 | }); 90 | 91 | it('rejects when email is already in use', () => { 92 | registration.open(); 93 | 94 | db.get().teams.push({ email: 'existing@host.com' }); 95 | const response = registration.createTeam('Team_Name', 'existing@host.com', 'minelayer'); 96 | 97 | expect(response.err).to.match(/same name or email/); 98 | expect(db.get().teams.length).to.be(1); 99 | }); 100 | 101 | it('rejects when name is already in use', () => { 102 | registration.open(); 103 | 104 | db.get().teams.push({ name: 'Existing_Team_Name' }); 105 | const response = registration.createTeam('Existing_Team_Name', 'user@host.com', 'cloaker'); 106 | 107 | expect(response.err).to.match(/same name or email/); 108 | expect(db.get().teams.length).to.be(1); 109 | }); 110 | 111 | it('accepts when name, email and role are valid', () => { 112 | registration.open(); 113 | 114 | const response = registration.createTeam('Team_Name', 'user@host.com', 'spy'); 115 | const validKeys = ['key', 'gravatar', 'colour', 'roleUsed', 'role', 'name', 'email', 'requests']; 116 | 117 | expect(response.err).to.be(undefined); 118 | expect(response.team).to.only.have.keys(validKeys); 119 | expect(db.get().teams.length).to.be(1); 120 | }); 121 | 122 | it('assigns new colours to successive registrations', () => { 123 | registration.open(); 124 | 125 | const response1 = registration.createTeam('Team_Name_1', 'user1@host.com', 'spy'); 126 | const response2 = registration.createTeam('Team_Name_2', 'user2@host.com', 'cloaker'); 127 | 128 | expect(response1.team.colour).to.not.be(response2.team.colour); 129 | }); 130 | 131 | it('rejects registration when there are no more colours to use', () => { 132 | registration.open(); 133 | 134 | colours.all.forEach(() => { 135 | db.get().teams.push({ name: 'some-team' }); 136 | }); 137 | 138 | const response = registration.createTeam('Team_Name', 'user@host.com', 'spy'); 139 | 140 | expect(response.err).to.match(/is full/); 141 | expect(db.get().teams.length).to.be(colours.all.length); 142 | }); 143 | 144 | it('returns correct team when queried', () => { 145 | registration.open(); 146 | 147 | const response = registration.createTeam('Team_Name', 'user@host.com', 'minelayer'); 148 | const team = registration.getTeamByKey(response.team.key); 149 | 150 | expect(team).not.to.be(undefined); 151 | expect(team.key).to.be.ok(); 152 | expect(team.gravatar).to.be.ok(); 153 | expect(team.colour).to.be(colours.all[0]); 154 | expect(team.role).to.be('minelayer'); 155 | expect(team.name).to.be('Team_Name'); 156 | expect(team.email).to.be('user@host.com'); 157 | expect(team.requests).to.be(30); 158 | }); 159 | }); 160 | -------------------------------------------------------------------------------- /spec/requests.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | 'use strict'; 3 | 4 | const expect = require('expect.js'); 5 | 6 | const db = require('../lib/db'); 7 | const requests = require('../server/game/requests'); 8 | 9 | describe('requests', () => { 10 | beforeEach(() => { 11 | const state = db.init(); 12 | state.teams.push({ key: 'team-1', requests: 30 }); 13 | state.teams.push({ key: 'team-2', requests: 30 }); 14 | state.teams.push({ key: 'team-3', requests: 30 }); 15 | }); 16 | 17 | it('refreshes all team requests after refresh period @slow', done => { 18 | const state = db.get(); 19 | 20 | setTimeout(() => { 21 | expect(state.teams[0].requests).to.be(30); 22 | expect(state.teams[1].requests).to.be(30); 23 | expect(state.teams[2].requests).to.be(30); 24 | return done(); 25 | }, 2000); 26 | 27 | requests.startRefreshTimer(1); 28 | 29 | state.teams[0].requests = 0; 30 | state.teams[1].requests = 15; 31 | state.teams[2].requests = 30; 32 | }); 33 | 34 | it('calculates the correct number of seconds until next refresh', () => { 35 | const date = new Date; 36 | 37 | expect(requests.getSecondsUntilNextRefresh({ 38 | refreshRateSecs: 60, 39 | currentTime: new Date(date.getTime() + (35 * 1000)), 40 | lastRefresh: date 41 | })).to.be(25); 42 | 43 | expect(requests.getSecondsUntilNextRefresh({ 44 | refreshRateSecs: 60, 45 | currentTime: new Date(date.getTime() + (60 * 1000)), 46 | lastRefresh: date 47 | })).to.be(0); 48 | 49 | expect(requests.getSecondsUntilNextRefresh({ 50 | refreshRateSecs: 60, 51 | currentTime: new Date(date.getTime() + 0), 52 | lastRefresh: date 53 | })).to.be(60); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /start.js: -------------------------------------------------------------------------------- 1 | const engine = require('./server/game/engine'); 2 | engine.loadExistingGame(); 3 | 4 | const admin = require('./server/admin/server'); 5 | admin.startServer(); 6 | 7 | const account = require('./server/account/server'); 8 | account.startServer(); 9 | 10 | const game = require('./server/game/server'); 11 | game.startServer(); -------------------------------------------------------------------------------- /web/css/account.css: -------------------------------------------------------------------------------- 1 | body { 2 | padding: 0px; 3 | margin: 0px; 4 | width: 100%; 5 | height: 100%; 6 | color: #f9eed1; 7 | font-family: 'Inconsolata'; 8 | background: rgba(54, 53, 60, 1) url('/images/tiles/tile.png') repeat; 9 | background-size: 798px 798px; 10 | } 11 | 12 | input:focus, textarea:focus { 13 | outline: none; 14 | } 15 | 16 | /* account */ 17 | 18 | .account-container { 19 | width: 100%; 20 | overflow: hidden; 21 | } 22 | 23 | .account-container h1 { 24 | margin: 0; 25 | padding: 12px; 26 | font-size: 32px; 27 | font-weight: 300; 28 | text-align: center; 29 | background: rgba(54, 53, 60, 1); 30 | } 31 | 32 | .account-container .panes { 33 | overflow: hidden; 34 | width: 900px; 35 | padding: 40px 0 0 40px; 36 | margin: 30px auto; 37 | box-sizing: border-box; 38 | } 39 | 40 | .account-container .left { 41 | float: left; 42 | } 43 | 44 | .account-container .left .role-details { 45 | margin-top: 50px; 46 | background: rgba(54, 53, 60, 1); 47 | position: relative; 48 | border-radius: 20px; 49 | padding: 12px; 50 | width: 300px; 51 | box-sizing: border-box; 52 | } 53 | 54 | .role-details .icon { 55 | position: absolute; 56 | top: -30px; 57 | left: -30px; 58 | width: 100px; 59 | height: 100px; 60 | } 61 | 62 | .role-details .icon-minelayer { 63 | content:url('/images/roles/minelayer.png'); 64 | } 65 | 66 | .role-details .icon-cloaker { 67 | content:url('/images/roles/cloaker.png'); 68 | } 69 | 70 | .role-details .icon-spy { 71 | content:url('/images/roles/spy.png'); 72 | } 73 | 74 | .role-details .role-data { 75 | margin-top: 60px; 76 | } 77 | 78 | .role-details .role-activation-info { 79 | border-top: solid 1px rgba(44, 43, 50, 1);; 80 | margin-top: 12px; 81 | padding: 12px 0 0 0; 82 | line-height: 28px; 83 | text-align: center; 84 | min-height: 15px; 85 | } 86 | 87 | .role-activation-info b { 88 | color: #e05050; 89 | } 90 | 91 | .account-container .left .account-details { 92 | background: rgba(54, 53, 60, 1); 93 | position: relative; 94 | border-radius: 20px; 95 | padding: 12px; 96 | width: 300px; 97 | box-sizing: border-box; 98 | } 99 | 100 | .account-container .left .account-details .account-data { 101 | margin-top: 40px; 102 | } 103 | 104 | .account-container .left .account-details .avatar { 105 | position: absolute; 106 | background: #36353c; 107 | border: solid 10px #36353c; 108 | top: -40px; 109 | left: -40px; 110 | width: 80px; 111 | height: 80px; 112 | border-radius: 50%; 113 | } 114 | 115 | .account-container h2 { 116 | color: #f9eed1; 117 | font-size: 24px; 118 | font-weight: 300; 119 | font-family: 'Inconsolata'; 120 | padding: 0; 121 | margin: 0; 122 | text-align: center; 123 | } 124 | 125 | .account-container h2.team-name { 126 | padding: 4px 12px 25px 58px; 127 | } 128 | 129 | .data-row { 130 | overflow: hidden; 131 | padding: 5px 0; 132 | font-size: 16px; 133 | } 134 | 135 | .data-row .key { 136 | float: left; 137 | color: #f9eed1; 138 | padding: 10px; 139 | } 140 | 141 | .data-row .value { 142 | float: right; 143 | padding: 10px 20px; 144 | min-width: 35px; 145 | color: #f9eed1; 146 | background: #2a2930; 147 | text-align: center; 148 | font-family: 'Inconsolata'; 149 | } 150 | 151 | .data-row .value #team-colour { 152 | width: 10px; 153 | height: 10px; 154 | border-radius: 50%; 155 | margin: 4px auto; 156 | } 157 | 158 | .grid-info { 159 | float: left; 160 | background: rgba(54, 53, 60, 1); 161 | position: relative; 162 | border-radius: 20px; 163 | padding: 15px; 164 | width: 400px; 165 | margin-left: 20px; 166 | box-sizing: border-box; 167 | } 168 | 169 | .grid-info #cell-info { 170 | display: inline-block; 171 | background: #2a2930; 172 | margin: 15px auto 0 auto; 173 | display: none; 174 | padding: 10px; 175 | overflow: hidden; 176 | box-sizing: border-box; 177 | font-family: 'Inconsolata'; 178 | font-size: 14px; 179 | } 180 | 181 | .grid-info #cell-info .cell-coords { 182 | line-height: 20px; 183 | } 184 | 185 | .grid-info #cell-info .cell-owner { 186 | margin-top: 5px; 187 | line-height: 20px; 188 | } 189 | 190 | .grid-info #cell-info .cell-owner .owner-dot { 191 | display: inline-block; 192 | background: red; 193 | border-radius: 50%; 194 | width: 10px; 195 | height: 10px; 196 | margin-bottom: 1px; 197 | vertical-align: middle; 198 | } 199 | 200 | .grid-info .grid { 201 | margin: 15px 0 0 0; 202 | } 203 | 204 | .grid-info .grid .row-container { 205 | display: block; 206 | text-align: center; 207 | height: 24px; 208 | margin-top: -1px; 209 | } 210 | 211 | .grid-info .grid .row-container .row { 212 | display: inline-block; 213 | height: 24px; 214 | } 215 | 216 | .grid-info .grid .row-container .row .cell { 217 | display: inline-block; 218 | position: relative; 219 | width: 24px; 220 | height: 24px; 221 | background: #2a2930; 222 | border: solid 1px rgba(54, 53, 60, 1); 223 | box-sizing: border-box; 224 | margin-left: -1px; 225 | cursor: pointer; 226 | } 227 | 228 | .grid-info .grid .row-container .row .cell:hover, .grid-info .grid .row-container .row .cell.selected { 229 | background: #10101f; 230 | } 231 | 232 | .grid-info .grid .row-container .row .cell .dot { 233 | width: 10px; 234 | height: 10px; 235 | border-radius: 50%; 236 | margin: 6px 0 0 6px; 237 | padding: 0; 238 | } 239 | 240 | .grid-info .grid-data { 241 | margin-top: 10px; 242 | } 243 | 244 | .account-container .players { 245 | float: left; 246 | margin-left: 20px; 247 | box-sizing: border-box; 248 | } 249 | 250 | .account-container .players .player { 251 | position: relative; 252 | margin-bottom: 35px 253 | } 254 | 255 | .account-container .players .player .score { 256 | width: 44px; 257 | padding: 10px 0 5px 0; 258 | text-align: center; 259 | border-radius: 2px; 260 | background: rgba(54, 53, 60, 1); 261 | position: absolute; 262 | bottom: -20px; 263 | left: 28px; 264 | } 265 | 266 | .account-container .players .player img { 267 | background: #36353c; 268 | border: solid 10px #36353c; 269 | width: 80px; 270 | height: 80px; 271 | border-radius: 50%; 272 | } 273 | 274 | /* @2x */ 275 | 276 | @media 277 | only screen and (-webkit-min-device-pixel-ratio: 2), 278 | only screen and ( min--moz-device-pixel-ratio: 2), 279 | only screen and ( -o-min-device-pixel-ratio: 2/1), 280 | only screen and ( min-device-pixel-ratio: 2), 281 | only screen and ( min-resolution: 192dpi), 282 | only screen and ( min-resolution: 2dppx) { 283 | body { 284 | background: #222 url('/images/tiles/tile.png') repeat; 285 | background-size: 798px 798px; 286 | } 287 | 288 | .role-details .icon-minelayer { 289 | content:url('/images/roles/minelayer@2x.png'); 290 | } 291 | 292 | .role-details .icon-cloaker { 293 | content:url('/images/roles/cloaker@2x.png'); 294 | } 295 | 296 | .role-details .icon-spy { 297 | content:url('/images/roles/spy@2x.png'); 298 | } 299 | } -------------------------------------------------------------------------------- /web/css/overview.css: -------------------------------------------------------------------------------- 1 | body { 2 | padding: 0px; 3 | margin: 0px; 4 | width: 100%; 5 | height: 100%; 6 | color: #f9eed1; 7 | font-family: 'Inconsolata'; 8 | background: rgba(54, 53, 60, 1) url('/images/tiles/tile.png') repeat; 9 | background-size: 798px 798px; 10 | } 11 | 12 | input:focus, textarea:focus { 13 | outline: none; 14 | } 15 | 16 | /* account */ 17 | 18 | .overview-container { 19 | width: 100%; 20 | overflow: hidden; 21 | } 22 | 23 | .overview-container h1 { 24 | margin: 0; 25 | padding: 12px; 26 | font-size: 32px; 27 | font-weight: 300; 28 | text-align: center; 29 | background: rgba(54, 53, 60, 1); 30 | font-family: 'Inconsolata'; 31 | } -------------------------------------------------------------------------------- /web/css/register.css: -------------------------------------------------------------------------------- 1 | body { 2 | padding: 0px; 3 | margin: 0px; 4 | width: 100%; 5 | height: 100%; 6 | color: #f9eed1; 7 | font-family: 'Inconsolata'; 8 | background: rgba(54, 53, 60, 1) url('/images/tiles/tile.png') repeat; 9 | background-size: 798px 798px; 10 | -webkit-touch-callout: none; 11 | -webkit-user-select: none; 12 | -khtml-user-select: none; 13 | -moz-user-select: none; 14 | -ms-user-select: none; 15 | user-select: none; 16 | } 17 | 18 | input:focus, textarea:focus { 19 | outline: none; 20 | } 21 | 22 | /* registration */ 23 | 24 | .reg-container { 25 | width: 100%; 26 | overflow: hidden; 27 | } 28 | 29 | .reg-container h1 { 30 | margin: 0; 31 | padding: 12px; 32 | font-size: 32px; 33 | font-weight: 300; 34 | text-align: center; 35 | background: rgba(54, 53, 60, 1); 36 | } 37 | 38 | .reg-container .dots { 39 | text-align: center; 40 | } 41 | 42 | .reg-container .dots div { 43 | display: inline-block; 44 | width: 12px; 45 | height: 12px; 46 | margin: 3px; 47 | background: #36353C; 48 | border-radius: 50%; 49 | } 50 | 51 | .reg-container .dots div.current { 52 | background: #f9eed1; 53 | } 54 | 55 | .reg-container .roles-container { 56 | width: 300%; 57 | margin: 25px auto 15px auto; 58 | position: relative; 59 | overflow: hidden; 60 | margin-left: 0%; 61 | -webkit-transition: all 0.3s ease-in-out; 62 | -moz-transition: all 0.3s ease-in-out; 63 | -o-transition: all 0.3s ease-in-out; 64 | transition: all 0.3s ease-in-out; 65 | } 66 | 67 | .reg-container .roles-container .role-container { 68 | width: 33.333%; 69 | padding-top: 60px; 70 | float: left; 71 | overflow: hidden; 72 | } 73 | 74 | .reg-container .roles-container .role-container .role { 75 | width: 650px; 76 | margin: 0px auto; 77 | box-sizing: border-box; 78 | background: #36353C; 79 | border-radius: 20px; 80 | position: relative; 81 | } 82 | 83 | .role .left-arrow { 84 | position: absolute; 85 | background: none; 86 | color: #36353c; 87 | opacity: 0.7; 88 | left: -75px; 89 | height: 100%; 90 | line-height: 350px; 91 | font-size: 200px; 92 | font-family: 'Arial'; 93 | -webkit-transition: all 0.1s ease-in-out; 94 | -moz-transition: all 0.1s ease-in-out; 95 | -o-transition: all 0.1s ease-in-out; 96 | transition: all 0.1s ease-in-out; 97 | } 98 | 99 | .role .right-arrow { 100 | position: absolute; 101 | background: none; 102 | color: #36353c; 103 | opacity: 0.7; 104 | right: -75px; 105 | height: 100%; 106 | line-height: 350px; 107 | font-size: 200px; 108 | font-family: 'Arial'; 109 | -webkit-transition: all 0.1s ease-in-out; 110 | -moz-transition: all 0.1s ease-in-out; 111 | -o-transition: all 0.1s ease-in-out; 112 | transition: all 0.1s ease-in-out; 113 | } 114 | 115 | .role .left-arrow:hover { 116 | opacity: 1; 117 | cursor: pointer; 118 | left: -84px; 119 | } 120 | 121 | .role .right-arrow:hover { 122 | opacity: 1; 123 | cursor: pointer; 124 | right: -84px; 125 | } 126 | 127 | .role .icon { 128 | position: absolute; 129 | top: -40px; 130 | left: -40px; 131 | width: 150px; 132 | height: 150px; 133 | } 134 | 135 | .role .icon-minelayer { 136 | content:url('/images/roles/minelayer.png'); 137 | } 138 | 139 | .role .icon-cloaker { 140 | content:url('/images/roles/cloaker.png'); 141 | } 142 | 143 | .role .icon-spy { 144 | content:url('/images/roles/spy.png'); 145 | } 146 | 147 | .role .header { 148 | padding: 20px 20px 20px 120px; 149 | } 150 | 151 | .role .header .title { 152 | color: #f9eed1; 153 | font-size: 26px; 154 | font-weight: 700; 155 | padding-bottom: 5px; 156 | } 157 | 158 | .role .header .description { 159 | color: #f9eed1; 160 | line-height: 22px; 161 | font-size: 17px; 162 | } 163 | 164 | .role .form { 165 | padding: 20px 40px; 166 | } 167 | 168 | .role .form .error { 169 | box-sizing: border-box; 170 | padding: 6px 0 6px 10px; 171 | width: 427px; 172 | margin: 15px auto; 173 | display: none; 174 | color: #e05050; 175 | font-size: 18px; 176 | line-height: 22px; 177 | font-family: 'Inconsolata'; 178 | border-left: solid 3px #e05050; 179 | } 180 | 181 | .role .form input[type='text'] { 182 | font-family: 'Inconsolata'; 183 | display: block; 184 | padding: 15px; 185 | margin: 15px auto; 186 | border: 0; 187 | color: #f9eed1; 188 | line-height: 22px; 189 | font-size: 18px; 190 | background: #323138; 191 | width: 400px; 192 | } 193 | 194 | .role .form input[type='submit'] { 195 | border: 0; 196 | display: block; 197 | padding: 15px; 198 | margin: 15px auto; 199 | line-height: 22px; 200 | font-size: 17px; 201 | font-family: 'Inconsolata'; 202 | font-weight: 900; 203 | background: #f9eed1; 204 | color: #323138; 205 | width: 430px; 206 | } 207 | 208 | .role .form input[type='submit']:hover { 209 | background: #dbd0b3; 210 | cursor: pointer; 211 | } 212 | 213 | /* @2x */ 214 | 215 | @media 216 | only screen and (-webkit-min-device-pixel-ratio: 2), 217 | only screen and ( min--moz-device-pixel-ratio: 2), 218 | only screen and ( -o-min-device-pixel-ratio: 2/1), 219 | only screen and ( min-device-pixel-ratio: 2), 220 | only screen and ( min-resolution: 192dpi), 221 | only screen and ( min-resolution: 2dppx) { 222 | body { 223 | background: #222 url('/images/tiles/tile.png') repeat; 224 | background-size: 798px 798px; 225 | } 226 | 227 | .role .icon-minelayer { 228 | content:url('/images/roles/minelayer@2x.png'); 229 | } 230 | 231 | .role .icon-cloaker { 232 | content:url('/images/roles/cloaker@2x.png'); 233 | } 234 | 235 | .role .icon-spy { 236 | content:url('/images/roles/spy@2x.png'); 237 | } 238 | } -------------------------------------------------------------------------------- /web/images/roles/cloaker.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mancjs/code-and-conquer/84ef29528802521e018751e484549abe0c9f59e2/web/images/roles/cloaker.png -------------------------------------------------------------------------------- /web/images/roles/cloaker@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mancjs/code-and-conquer/84ef29528802521e018751e484549abe0c9f59e2/web/images/roles/cloaker@2x.png -------------------------------------------------------------------------------- /web/images/roles/minelayer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mancjs/code-and-conquer/84ef29528802521e018751e484549abe0c9f59e2/web/images/roles/minelayer.png -------------------------------------------------------------------------------- /web/images/roles/minelayer@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mancjs/code-and-conquer/84ef29528802521e018751e484549abe0c9f59e2/web/images/roles/minelayer@2x.png -------------------------------------------------------------------------------- /web/images/roles/spy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mancjs/code-and-conquer/84ef29528802521e018751e484549abe0c9f59e2/web/images/roles/spy.png -------------------------------------------------------------------------------- /web/images/roles/spy@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mancjs/code-and-conquer/84ef29528802521e018751e484549abe0c9f59e2/web/images/roles/spy@2x.png -------------------------------------------------------------------------------- /web/images/tiles/tile-lines.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mancjs/code-and-conquer/84ef29528802521e018751e484549abe0c9f59e2/web/images/tiles/tile-lines.png -------------------------------------------------------------------------------- /web/images/tiles/tile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mancjs/code-and-conquer/84ef29528802521e018751e484549abe0c9f59e2/web/images/tiles/tile.png -------------------------------------------------------------------------------- /web/images/tiles/tile@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mancjs/code-and-conquer/84ef29528802521e018751e484549abe0c9f59e2/web/images/tiles/tile@2x.png -------------------------------------------------------------------------------- /web/scripts/account.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | var $selectedCell; 3 | var $playerList; 4 | var $ownedSquares; 5 | var $freeSquares; 6 | var $grid; 7 | 8 | var teams; 9 | var ownedSquares = 0; 10 | var freeSquares = 0; 11 | 12 | var init = function() { 13 | $playerList = document.getElementById('player-list'); 14 | $grid = document.getElementById('user-grid'); 15 | $ownedSquares = document.getElementById('owned-squares'); 16 | $freeSquares = document.getElementById('free-squares'); 17 | 18 | initialiseGridClickHandler($grid); 19 | setTimeout(fetchAccountData, 1000); 20 | }; 21 | 22 | var fetchAccountData = function() { 23 | http.get('/api/account-data?key=' + window.myKey, function(err, accountData) { 24 | if (err) { 25 | return; 26 | } 27 | 28 | buildTeamsList(accountData.teams, accountData.grid); 29 | drawGrid($grid, accountData.grid); 30 | drawPlayers(); 31 | 32 | $ownedSquares.innerText = ownedSquares; 33 | $freeSquares.innerText = freeSquares; 34 | }); 35 | }; 36 | 37 | var createElement = function(tag, className) { 38 | var $element = document.createElement(tag); 39 | $element.className = className; 40 | return $element; 41 | }; 42 | 43 | var buildTeamsList = function(teamData, grid) { 44 | teams = {}; 45 | ownedSquares = 0; 46 | freeSquares = 0; 47 | 48 | teamData.forEach(function(team) { 49 | teams[team.name] = { 50 | gravatar: team.gravatar, 51 | colour: team.colour, 52 | name: team.name, 53 | score: 0 54 | }; 55 | }); 56 | 57 | grid.forEach(function(row) { 58 | row.forEach(function(cell) { 59 | if (cell.owner.name === window.myName) { 60 | ownedSquares += 1; 61 | } 62 | 63 | if (cell.owner.name === 'cpu') { 64 | freeSquares += 1; 65 | } 66 | 67 | if (teams[cell.owner.name]) { 68 | teams[cell.owner.name].score += (1 * cell.bonus); 69 | } 70 | }); 71 | }); 72 | }; 73 | 74 | var drawGrid = function($grid, state) { 75 | state.forEach(function(row, y) { 76 | var $row = createElement('div', 'row'); 77 | 78 | row.forEach(function(cell, x) { 79 | var cellData = { 80 | coords: x + ',' + y, 81 | owner: { 82 | name: cell.owner.name, 83 | colour: cell.owner.colour 84 | } 85 | }; 86 | 87 | var $cell = createElement('div', 'cell'); 88 | 89 | $cell.setAttribute('cell-data', JSON.stringify(cellData)); 90 | 91 | if (cell.owner.name !== 'cpu') { 92 | var $dot = createElement('div', 'dot'); 93 | $dot.style.background = cell.owner.colour; 94 | $cell.appendChild($dot); 95 | } 96 | 97 | $row.appendChild($cell); 98 | }); 99 | 100 | var $rowContainer = createElement('div', 'row-container'); 101 | $rowContainer.appendChild($row); 102 | $grid.appendChild($rowContainer); 103 | }); 104 | }; 105 | 106 | var drawPlayers = function() { 107 | var allTeams = []; 108 | 109 | Object.keys(teams).forEach(function(teamName) { 110 | allTeams.push(teams[teamName]); 111 | }); 112 | 113 | allTeams.sort(function(left, right) { 114 | return right.score - left.score; 115 | }); 116 | 117 | allTeams.forEach(function(player) { 118 | var $player = createElement('div', 'player'); 119 | 120 | var $avatar = createElement('img'); 121 | $avatar.src = player.gravatar; 122 | 123 | var $score = createElement('div', 'score'); 124 | $score.innerText = player.score; 125 | 126 | $player.appendChild($avatar); 127 | $player.appendChild($score); 128 | 129 | $playerList.appendChild($player); 130 | }); 131 | }; 132 | 133 | var initialiseGridClickHandler = function($grid) { 134 | var showCellInfo = function($cell) { 135 | if ($selectedCell) { 136 | $selectedCell.className = 'cell'; 137 | } 138 | 139 | $selectedCell = $cell; 140 | $selectedCell.className = 'cell selected'; 141 | 142 | var cellData = JSON.parse($selectedCell.getAttribute('cell-data')); 143 | 144 | var $cellInfo = document.getElementById('cell-info'); 145 | var $cellCoords = document.getElementById('cell-coords'); 146 | var $cellOwner = document.getElementById('cell-owner'); 147 | var $cellOwnerDot = document.getElementById('cell-owner-dot'); 148 | 149 | $cellCoords.innerText = cellData.coords; 150 | $cellOwner.innerText = cellData.owner.name; 151 | $cellOwnerDot.style.background = (cellData.owner.name === 'cpu') ? '#f9eed1' : cellData.owner.colour; 152 | 153 | $cellInfo.style.display = 'block'; 154 | $cellInfo.style.width = $cell.parentNode.clientWidth + 'px'; 155 | }; 156 | 157 | $grid.addEventListener('click', function(event) { 158 | if (event.srcElement.className === 'cell') { 159 | return showCellInfo(event.srcElement); 160 | } 161 | 162 | if (event.srcElement.className === 'dot') { 163 | return showCellInfo(event.srcElement.parentNode); 164 | } 165 | }); 166 | }; 167 | 168 | init(); 169 | })(); -------------------------------------------------------------------------------- /web/scripts/http.js: -------------------------------------------------------------------------------- 1 | var http = (function() { 2 | var get = function(url, callback) { 3 | var xhr = new XMLHttpRequest(); 4 | xhr.open('GET', url); 5 | 6 | xhr.onload = function() { 7 | if (xhr.status === 200) { 8 | try { 9 | return callback(null, JSON.parse(xhr.responseText)); 10 | } catch (err) { 11 | return callback(err); 12 | } 13 | } else { 14 | return callback('Request failed with ' + xhr.status); 15 | } 16 | }; 17 | 18 | xhr.onerror = function() { 19 | return callback('Request failed with ' + xhr.status); 20 | }; 21 | 22 | xhr.send(); 23 | }; 24 | 25 | var post = function(url, data, callback) { 26 | var xhr = new XMLHttpRequest(); 27 | xhr.open('POST', url); 28 | xhr.setRequestHeader('Content-Type', 'application/json'); 29 | 30 | xhr.onload = function() { 31 | if (xhr.status === 200) { 32 | try { 33 | return callback(null, JSON.parse(xhr.responseText)); 34 | } catch (err) { 35 | return callback(err); 36 | } 37 | } else { 38 | return callback('Request failed with ' + xhr.status); 39 | } 40 | }; 41 | 42 | xhr.send(JSON.stringify(data)); 43 | }; 44 | 45 | return { 46 | get: get, 47 | post: post 48 | }; 49 | })(); -------------------------------------------------------------------------------- /web/scripts/overview.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | var timer; 3 | var gameState; 4 | var ticks = 0; 5 | var $titlePrefix; 6 | var $refreshSeconds; 7 | 8 | var fetchStateUpdate = function(callback) { 9 | return http.get('/api/overview-data', function(err, state) { 10 | if (!err) { 11 | gameState = state; 12 | 13 | if (!gameState.gameStarted) { 14 | $titlePrefix.innerText = 'Game Not Started'; 15 | $refreshSeconds.innerText = ''; 16 | } else { 17 | $titlePrefix.innerText = 'Next Requests Refresh:'; 18 | $refreshSeconds.innerText = gameState.refreshSeconds; 19 | } 20 | } 21 | 22 | return callback(); 23 | }); 24 | }; 25 | 26 | var refresh = function() { 27 | ticks += 1; 28 | 29 | if (ticks >= 10) { 30 | return fetchStateUpdate(function() { 31 | ticks = 0; 32 | startRefreshTimer(); 33 | window.buildGrid(gameState.grid); 34 | }); 35 | } 36 | 37 | var seconds = parseInt($refreshSeconds.innerText) - 1; 38 | 39 | if (!isNaN(seconds)) { 40 | if (seconds <= 0) { 41 | seconds = $refreshSeconds.getAttribute('data-refresh'); 42 | } 43 | 44 | $refreshSeconds.innerText = (seconds < 10) ? ('0' + seconds) : seconds; 45 | } 46 | 47 | return startRefreshTimer(); 48 | }; 49 | 50 | var startRefreshTimer = function() { 51 | clearTimeout(timer); 52 | timer = setTimeout(refresh, 1000); 53 | }; 54 | 55 | var init = function() { 56 | $titlePrefix = document.getElementById('title-prefix'); 57 | $refreshSeconds = document.getElementById('refresh-seconds'); 58 | 59 | fetchStateUpdate(function() { 60 | window.buildGrid(gameState.grid); 61 | return startRefreshTimer(); 62 | }); 63 | }; 64 | 65 | init(); 66 | })(); -------------------------------------------------------------------------------- /web/scripts/overview.webgl.js: -------------------------------------------------------------------------------- 1 | var ATTACK_DISPLAY_TIMEOUT = 20000; 2 | 3 | var colours = [ 4 | '#556C2F', 5 | '#CD54D7', 6 | '#CE4D30', 7 | '#678EC1', 8 | '#6ECF4A', 9 | '#C88E81', 10 | '#66D097', 11 | '#603E66', 12 | '#CB913A', 13 | '#496966', 14 | '#CB4565', 15 | '#91CCCF', 16 | '#796ACB', 17 | '#703D29', 18 | '#C9CF45', 19 | '#C04996', 20 | '#BDC889', 21 | '#CC9DC9' 22 | ]; 23 | 24 | var width = window.innerWidth; 25 | var height = window.innerHeight; 26 | 27 | var scene = new THREE.Scene(); 28 | 29 | var camera = new THREE.PerspectiveCamera(40, width / height, 1, 1000); 30 | scene.add(camera); 31 | 32 | var light = new THREE.PointLight(0xffffff, 1.5); 33 | light.position.set(1, 1, -1).normalize(); 34 | scene.add(light); 35 | 36 | var ambLight = new THREE.AmbientLight(0x777777); 37 | scene.add(ambLight); 38 | 39 | var renderer = new THREE.WebGLRenderer(); 40 | renderer.setSize(width, height); 41 | document.querySelector('.overview-container').appendChild(renderer.domElement); 42 | 43 | var size = 3; 44 | var gap = 2; 45 | var spacing = size + gap; 46 | 47 | var xLength = 10; 48 | var zLength = 10; 49 | 50 | var baseMaterial = new THREE.MeshLambertMaterial({ 51 | color: 0x333333 52 | }); 53 | 54 | var coloursMaterials = colours.map(function(colour) { 55 | return new THREE.MeshLambertMaterial({ 56 | color: parseInt(colour.substring(1), 16) 57 | }); 58 | }); 59 | 60 | var materials = []; 61 | 62 | for (var i = 0; i < 6; i++) { 63 | materials.push(baseMaterial); 64 | } 65 | 66 | coloursMaterials.forEach(function(coloursMaterial) { 67 | for (var i = 0; i < 6; i++) { 68 | materials.push(coloursMaterial); 69 | } 70 | }); 71 | 72 | var colourMatOffset = 1; 73 | 74 | var multiMaterial = new THREE.MeshFaceMaterial(materials); 75 | 76 | var mesh = null; 77 | 78 | var getHeight = function(bonus) { 79 | var heights = [1, 5, 10]; 80 | 81 | return heights[bonus - 1]; 82 | }; 83 | 84 | var attacks = []; 85 | 86 | var newAttack = function(x, z, cell) { 87 | // this was +1 before, check with CD 88 | var attackColourIndex = colours.indexOf(cell.lastAttack.team.colour); 89 | var attackMaterial = coloursMaterials[attackColourIndex]; 90 | 91 | var attackSphere = new THREE.SphereGeometry(size * 0.4, 16, 16); 92 | var attackMesh = new THREE.Mesh(attackSphere, attackMaterial); 93 | 94 | scene.add(attackMesh); 95 | 96 | var plane = new THREE.CircleGeometry(1.2, 32); 97 | 98 | var height = getHeight(cell.bonus); 99 | 100 | var colourIndex = colours.indexOf(cell.owner.colour); 101 | var planeMaterial = coloursMaterials[colourIndex]; 102 | 103 | var planeMesh = new THREE.Mesh(plane, planeMaterial); 104 | planeMesh.position.set(x * spacing, height + 0.1, z * spacing); 105 | planeMesh.rotation.x = Math.PI / -2; 106 | scene.add(planeMesh); 107 | 108 | return { 109 | remove: function() { 110 | scene.remove(attackMesh); 111 | scene.remove(planeMesh); 112 | attackMesh = null; 113 | planeMesh = null; 114 | }, 115 | animate: function() { 116 | var scale = Math.sin(getTime() * 0.01); 117 | 118 | attackMesh.position.set(x * spacing, scale * 2.0 + height + 4.0, z * spacing); 119 | planeMesh.scale.set(scale, scale, scale); 120 | } 121 | }; 122 | }; 123 | 124 | var clearAttacks = function() { 125 | attacks.forEach(function(attack) { 126 | attack.remove(); 127 | }); 128 | 129 | attacks = []; 130 | }; 131 | 132 | var buildGrid = window.buildGrid = function(grid) { 133 | // console.time('buildGrid'); 134 | 135 | xLength = grid.length; 136 | zLength = grid[0].length; 137 | 138 | clearAttacks(); 139 | 140 | var generateBar = function(x, z, cell) { 141 | var height = getHeight(cell.bonus); 142 | 143 | var box = new THREE.BoxGeometry(size, height, size); 144 | 145 | var mat = new THREE.Matrix4(); 146 | mat.makeTranslation(x * spacing, height * 0.5, z * spacing); 147 | 148 | var colourIndex = 0; 149 | 150 | if (cell.owner) { 151 | colourIndex = colours.indexOf(cell.owner.colour) + 1; 152 | } 153 | 154 | matIndexOffset = colourIndex * 6; 155 | 156 | geom.merge(box, mat, matIndexOffset); 157 | 158 | // console.log('generateBar', x, z, cell); 159 | }; 160 | 161 | var geom = new THREE.Geometry(); 162 | 163 | for (var x = 0; x < xLength; x++) { 164 | for (var z = 0; z < zLength; z++) { 165 | var cell = grid[z][x]; 166 | 167 | generateBar(x, z, cell); 168 | 169 | if ( 170 | cell.owner && 171 | cell.lastAttack && 172 | cell.owner.name !== 'cpu' && 173 | (new Date().getTime() - cell.lastAttack.time < ATTACK_DISPLAY_TIMEOUT) 174 | ) { 175 | var attack = newAttack(x, z, cell); 176 | attacks.push(attack); 177 | } 178 | } 179 | } 180 | 181 | if (mesh) scene.remove(mesh); 182 | 183 | mesh = new THREE.Mesh(geom, multiMaterial); 184 | scene.add(mesh); 185 | 186 | // console.timeEnd('buildGrid'); 187 | }; 188 | 189 | var theta = 0; 190 | var startTime = new Date().getTime(); 191 | 192 | var getTime = function() { 193 | return new Date().getTime() - startTime; 194 | }; 195 | 196 | var render = function() { 197 | renderer.render(scene, camera); 198 | 199 | attacks.forEach(function(attack) { 200 | attack.animate(); 201 | }); 202 | 203 | if (light) { 204 | light.position.x = 50 * Math.sin(-theta * 2 * Math.PI / 360); 205 | light.position.y = 50; 206 | light.position.z = 50 * Math.cos(-theta * 2 * Math.PI / 360); 207 | } 208 | 209 | camera.position.y = 50; 210 | 211 | var xHalf = (xLength * spacing - gap) * 0.5; 212 | var zHalf = (zLength * spacing - gap) * 0.5; 213 | 214 | // console.log(xLength * spacing, xHalf, zHalf); 215 | 216 | camera.position.x = xHalf + xHalf * Math.sin(theta * Math.PI / 360) * 2; 217 | camera.position.z = zHalf + zHalf * Math.cos(theta * Math.PI / 360) * 2; 218 | 219 | camera.lookAt(new THREE.Vector3(xHalf, 0, zHalf)); 220 | 221 | theta++; 222 | requestAnimationFrame(render); 223 | }; 224 | 225 | setTimeout(render, 1000); 226 | -------------------------------------------------------------------------------- /web/scripts/registration.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | var margin = 0; 3 | 4 | var init = function() { 5 | initialiseSliderControls(); 6 | initialiseRegistrationForm(); 7 | }; 8 | 9 | var initialiseSliderControls = function() { 10 | var updateUi = function() { 11 | document.querySelector('.roles-container').style.marginLeft = margin + '%'; 12 | document.getElementById('dot1').className = (margin === 0) ? 'current': ''; 13 | document.getElementById('dot2').className = (margin === -100) ? 'current': ''; 14 | document.getElementById('dot3').className = (margin === -200) ? 'current': ''; 15 | }; 16 | 17 | var moveLeft = function() { 18 | margin += 100; 19 | 20 | if (margin > 0) { 21 | margin = 0; 22 | } 23 | 24 | return updateUi(); 25 | }; 26 | 27 | var moveRight = function() { 28 | margin -= 100; 29 | 30 | if (margin < -200) { 31 | margin = -200; 32 | } 33 | 34 | return updateUi(); 35 | }; 36 | 37 | var leftArrows = document.querySelectorAll('.left-arrow'); 38 | var rightArrows = document.querySelectorAll('.right-arrow'); 39 | 40 | for (var i = 0; i < leftArrows.length; i++) { 41 | leftArrows[i].addEventListener('click', moveLeft); 42 | rightArrows[i].addEventListener('click', moveRight); 43 | } 44 | 45 | updateUi(); 46 | }; 47 | 48 | var initialiseRegistrationForm = function() { 49 | var registerButtonClicked = function() { 50 | var name = this.parentNode.querySelector('input[name="team"]').value; 51 | var email = this.parentNode.querySelector('input[name="email"]').value; 52 | var role = this.parentNode.querySelector('input[name="role"]').value; 53 | var errorContainer = this.parentNode.querySelector('.error'); 54 | 55 | return register(name, email, role, function(err, response) { 56 | if (err) { 57 | errorContainer.style.display = 'block'; 58 | errorContainer.innerText = err; 59 | return; 60 | } 61 | 62 | window.location = response.url; 63 | }); 64 | }; 65 | 66 | var buttons = document.querySelectorAll('input[type="submit"]'); 67 | 68 | for (var i = 0; i < buttons.length; i++) { 69 | buttons[i].addEventListener('click', registerButtonClicked.bind(buttons[i])); 70 | } 71 | }; 72 | 73 | var register = function(name, email, role, callback) { 74 | var request = { 75 | name: name, 76 | email: email, 77 | role: role 78 | }; 79 | 80 | http.post('/register', request, function(err, response) { 81 | return callback(err || response.err, response); 82 | }); 83 | }; 84 | 85 | init(); 86 | })(); -------------------------------------------------------------------------------- /web/templates/account.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 | 6 | 7 |