diff --git a/Makefile b/Makefile index 828d951..9061b02 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,11 @@ +.PHONY: all install clean cards score js all: install clean cards score js node := ${CURDIR}/node_modules +all_sets := ${CURDIR}/data/AllSets.json +traceur := ${node}/.bin/traceur + +${traceur}: install install: npm install @@ -14,22 +19,22 @@ install: ln -sf ${node}/utils/utils.js public/lib clean: - rm -f data/AllSets.json + rm -f ${all_sets} -cards: data/AllSets.json +cards: ${all_sets} node src/make cards custom: node src/make custom -data/AllSets.json: - curl -so data/AllSets.json https://mtgjson.com/json/AllSets.json +${all_sets}: + curl -so ${all_sets} https://mtgjson.com/json/AllSets.json score: -node src/make score #ignore errors -js: - node_modules/.bin/traceur --out public/lib/app.js public/src/init.js +js: ${traceur} ${all_sets} + ${traceur} --out public/lib/app.js public/src/init.js run: js node run diff --git a/README.md b/README.md index d56f388..c826cb0 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,49 @@ -# draft +# drafts.ninja -unaffiliated with wizards of the coast +[drafts.ninja](http://drafts.ninja) is a fork of aeosynth's `draft` project. It +supports all of the features of `draft` and more. Here are some of the +highlights: -# run + * **Pick confirmation**: In order to prevent misclicks, `draft` requires you to + click a card twice in order to select it. However, the selected card is +indistinguishable from the other cards in the pack. The UI in drafts.ninja +indicates which card is currently selected. -- [node.js](http://nodejs.org/) + * **Autopick**: If your time expires, `draft` will select a card for you at + random. This rarely turns out well. If you have preliminarily selected a +card but not confirmed it, drafts.ninja will automatically pick it for you. -- `make` + * **Connection indicators**: Are your draftmates disconnected or just slow? + drafts.ninja displays a connection indicator next to each player in the +draft, letting you know if a player is no longer with us. -- `node app.js` + * **Kick players**: If one of your players has disconnected and is holding up + the draft, you can kick them and the rest of their picks will be made +automatically for them. No more abandoning the draft halfway through! -- + * **Ready confirmation**: Each player must mark themself as ready before the + game can start. If you have unresponsive players, you can kick them before +the draft has started and get a new person. -# updating + * **Suggest lands**: After agonizing over your maindeck, you don't want to + spend a lot of time constructing your manabase. With the click of a button, +drafts.ninja will add lands to your deck using an algorithm designed to +conservatively choose your color ratio. It'll even add some basic lands to your +sideboard as well, just in case. -generally you can update with `git pull`; if that doesn't work, -rerun `make`; if that still doesn't work, please file an issue +Like `draft` before it, drafts.ninja is unaffiliated with Wizards of the Coast, +and is licensed under the MIT license. -# etc +Bugs or feature requests? Feel free to open an issue. -written in [es6], transpiled with [traceur], using [react] on the client +# Installation -for the editor component, see +drafts.ninja is a NodeJS application. Install NodeJS, then just run `make run` +in your terminal and visit [http://localhost:1337](http://localhost:1337). -[es6]: https://github.com/lukehoban/es6features -[traceur]: https://github.com/google/traceur-compiler -[react]: https://github.com/facebook/react +drafts.ninja is written in [ES6] and transpiled with [Traceur], and uses [React] +on the client-side. + + [ES6]: https://github.com/lukehoban/es6features + [Traceur]: https://github.com/google/traceur-compiler + [React]: https://github.com/facebook/react diff --git a/app.js b/app.js index 0fbe217..5ad9e08 100644 --- a/app.js +++ b/app.js @@ -14,4 +14,5 @@ var server = http.createServer(function(req, res) { }).listen(PORT) var eioServer = eio(server).on('connection', router) -console.log(new Date) +require('log-timestamp') +console.log('Started up') diff --git a/package.json b/package.json index 0d23e29..c530ee9 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "ee": "git://github.com/aeosynth/ee", "engine.io": "^1.4.1", "engine.io-client": "^1.4.1", + "log-timestamp": "^0.1.2", "node-fetch": "^1.0.3", "send": "^0.11.1", "traceur": "0.0.65", diff --git a/public/index.html b/public/index.html index 3de76f1..f922e42 100644 --- a/public/index.html +++ b/public/index.html @@ -2,7 +2,7 @@ - draft + drafts.ninja diff --git a/public/src/app.js b/public/src/app.js index 12935e6..75300b7 100644 --- a/public/src/app.js +++ b/public/src/app.js @@ -11,28 +11,32 @@ let App = { state: { id: null, - name: 'newfriend', + name: 'ninja', + + numPlayers: 0, + numActiveGames: 0, seats: 8, type: 'draft', sets: [ - 'BFZ', - 'BFZ', - 'BFZ', - 'BFZ', - 'BFZ', - 'BFZ' + 'SOI', + 'SOI', + 'SOI', + 'SOI', + 'SOI', + 'SOI' ], list: '', cards: 15, packs: 3, bots: true, - timer: true, + timer: 40, beep: false, chat: true, cols: false, + deckSize: 40, filename: 'filename', filetype: 'txt', side: false, diff --git a/public/src/cards.js b/public/src/cards.js index f92c01a..8e5e31d 100644 --- a/public/src/cards.js +++ b/public/src/cards.js @@ -10,6 +10,13 @@ let Cards = { } export let BASICS = Object.keys(Cards) +let COLORS_TO_LANDS = { + 'W': 'Plains', + 'U': 'Island', + 'B': 'Swamp', + 'R': 'Mountain', + 'G': 'Forest', +} for (let name in Cards) Cards[name] = {name, @@ -82,6 +89,9 @@ let events = { _.download(data, filename + '.' + filetype) hash() }, + readyToStart(e) { + App.send('readyToStart', e.target.checked) + }, start() { let {bots, timer} = App.state let options = [bots, timer] @@ -140,6 +150,109 @@ let events = { delete Zones[zoneName][cardName] App.update() }, + deckSize(e) { + let n = Number(e.target.value) + if (n && n > 0) + App.state.deckSize = n + App.update() + }, + suggestLands() { + // Algorithm: count the number of mana symbols appearing in the costs of + // the cards in the pool, then assign lands roughly commensurately. + let colors = ['W', 'U', 'B', 'R', 'G'] + let colorRegex = /\{[^}]+\}/g + let manaSymbols = {} + colors.forEach(x => manaSymbols[x] = 0) + + // Count the number of mana symbols of each type. + for (let card of Object.keys(Zones['main'])) { + let quantity = Zones['main'][card] + card = Cards[card] + + if (!card.manaCost) + continue + let cardManaSymbols = card.manaCost.match(colorRegex) + + for (let color of colors) + for (let symbol of cardManaSymbols) + // Test to see if '{U}' contains 'U'. This also handles things like + // '{G/U}' triggering both 'G' and 'U'. + if (symbol.indexOf(color) !== -1) + manaSymbols[color] += quantity + } + + _resetLands() + // NB: We could set only the sideboard lands of the colors we are using to + // 5, but this reveals information to the opponent on Cockatrice (and + // possibly other clients) since it tells the opponent the sideboard size. + colors.forEach(color => Zones['side'][COLORS_TO_LANDS[color]] = 5) + + colors = colors.filter(x => manaSymbols[x] > 0) + colors.forEach(x => manaSymbols[x] = Math.max(3, manaSymbols[x])) + colors.sort((a, b) => manaSymbols[b] - manaSymbols[a]) + + // Round-robin choose the lands to go into the deck. For example, if the + // mana symbol counts are W: 2, U: 2, B: 1, cycle through the sequence + // [Plains, Island, Swamp, Plains, Island] infinitely until the deck is + // finished. + // + // This has a few nice effects: + // + // * Colors with greater mana symbol counts get more lands. + // + // * When in a typical two color deck adding 17 lands, the 9/8 split will + // be in favor of the color with slightly more mana symbols of that + // color. + // + // * Every color in the deck is represented, if it is possible to do so + // in the remaining number of cards. + // + // * Because of the minimum mana symbol count for each represented color, + // splashing cards doesn't add exactly one land of the given type + // (although the land count may still be low for that color). + // + // * The problem of deciding how to round land counts is now easy to + // solve. + let manaSymbolsToAdd = colors.map(color => manaSymbols[color]) + let colorsToAdd = [] + for (let i = 0; true; i = (i + 1) % colors.length) { + if (manaSymbolsToAdd.every(x => x === 0)) + break + if (manaSymbolsToAdd[i] === 0) + continue + colorsToAdd.push(colors[i]) + manaSymbolsToAdd[i]-- + } + + let mainDeckSize = Object.keys(Zones['main']) + .map(x => Zones['main'][x]) + .reduce((a, b) => a + b) + let landsToAdd = App.state.deckSize - mainDeckSize + + let j = 0 + for (let i = 0; i < landsToAdd; i++) { + let color = colorsToAdd[j] + let land = COLORS_TO_LANDS[color] + if (!Zones['main'].hasOwnProperty(land)) + Zones['main'][land] = 0 + Zones['main'][land]++ + + j = (j + 1) % colorsToAdd.length + } + + App.update() + }, + resetLands() { + _resetLands() + App.update() + }, +} + +function _resetLands() { + Object.keys(COLORS_TO_LANDS).forEach((key) => { + let land = COLORS_TO_LANDS[key] + Zones['main'][land] = Zones['side'][land] = 0 + }) } for (let event in events) @@ -218,10 +331,20 @@ function cube() { } function clickPack(cardName) { - if (clicked !== cardName) - return clicked = cardName - let index = rawPack.findIndex(x => x.name === cardName) + let card = rawPack[index] + + if (clicked !== cardName) { + clicked = cardName + // There may be duplicate cards in a pack, but only one copy of a card is + // shown in the pick view. We must be sure to mark them all since we don't + // know which one is being displayed. + rawPack.forEach(card => card.isAutopick = card.name === cardName) + App.update() + App.send('autopick', index) + return clicked + } + clicked = null Zones.pack = {} App.update() diff --git a/public/src/components/game.js b/public/src/components/game.js index b9615e1..c17031e 100644 --- a/public/src/components/game.js +++ b/public/src/components/game.js @@ -8,6 +8,8 @@ import Settings from './settings' import {LBox} from './checkbox' let d = React.DOM +const READY_TITLE_TEXT = 'The host may start the game once all users have clicked the "ready" checkbox.' + export default React.createClass({ componentWillMount() { App.state.players = [] @@ -51,22 +53,41 @@ export default React.createClass({ if (App.state.round || !App.state.isHost) return + let readyToStart = App.state.players.every(x => x.isReadyToStart) + let startButton + = readyToStart + ? d.button({ onClick: App._emit('start') }, 'start') + : d.button({ disabled: true, title: READY_TITLE_TEXT }, 'start') return d.div({}, - d.div({}, - d.button({ onClick: App._emit('start') }, 'start')), + d.div({}, startButton), LBox('bots', 'bots'), - LBox('timer', 'timer')) + d.div({}, d.input({ + min: 0, + max: 60, + type: 'number', + valueLink: App.link('timer'), + }), ' second timer')) }, Players() { let rows = App.state.players.map(row) + let columns = [ + d.th({}, '#'), + d.th({}, ''), // connection status + d.th({}, 'name'), + d.th({}, 'packs'), + d.th({}, 'time'), + d.th({}, 'cock'), + d.th({}, 'mws'), + ] + + if (!App.state.round) + columns.push(d.th({ title: READY_TITLE_TEXT }, 'ready')) + + if (App.state.isHost) + columns.push(d.th({})) // kick + return d.table({ id: 'players' }, - d.tr({}, - d.th({}, '#'), - d.th({}, 'name'), - d.th({}, 'packs'), - d.th({}, 'time'), - d.th({}, 'cock'), - d.th({}, 'mws')), + d.tr({}, ...columns), rows) } }) @@ -83,13 +104,57 @@ function row(p, i) { : i === opp ? 'opp' : null - return d.tr({ className }, + let connectionStatusIndicator + = p.isBot ? d.span({ + className: 'icon-bot', + title: 'This player is a bot.', + }) + : p.isConnected ? d.span({ + className: 'icon-connected', + title: 'This player is currently connected to the server.', + }) + : d.span({ + className: 'icon-disconnected', + title: 'This player is currently disconnected from the server.', + }) + + let readyCheckbox + = i === self ? d.input({ + checked: p.isReadyToStart, + onChange: App._emit('readyToStart'), + type: 'checkbox', + }) + : d.input({ + checked: p.isReadyToStart, + disabled: true, + type: 'checkbox', + }) + + let columns = [ d.td({}, i + 1), + d.td({}, connectionStatusIndicator), d.td({}, p.name), d.td({}, p.packs), d.td({}, p.time), d.td({}, p.hash && p.hash.cock), - d.td({}, p.hash && p.hash.mws)) + d.td({}, p.hash && p.hash.mws), + ] + + if (!App.state.round) + columns.push(d.td({ + className: 'ready', + title: READY_TITLE_TEXT + }, readyCheckbox)) + + if (App.state.isHost) + if (i !== self && !p.isBot) + columns.push(d.td({}, d.button({ + onClick: ()=> App.send('kick', i), + }, 'kick'))) + else + columns.push(d.td({})) + + return d.tr({ className }, ...columns) } function decrement() { diff --git a/public/src/components/grid.js b/public/src/components/grid.js index 4a47ceb..c4e4d6d 100644 --- a/public/src/components/grid.js +++ b/public/src/components/grid.js @@ -15,12 +15,19 @@ function zone(zoneName) { let values = _.values(zone) let cards = _.flat(values) + let isAutopickable = card => zoneName === 'pack' && card.isAutopick + let items = cards.map(card => - d.img({ - onClick: App._emit('click', zoneName, card.name), - src: card.url, - alt: card.name - })) + d.span( + { + className: `card ${isAutopickable(card) ? 'autopick-card' : ''}`, + title: isAutopickable(card) ? 'This card will be automatically picked if your time expires.' : '', + onClick: App._emit('click', zoneName, card.name), + }, + d.img({ + src: card.url, + alt: card.name, + }))) return d.div({ className: 'zone' }, d.h1({}, `${zoneName} ${cards.length}`), diff --git a/public/src/components/lobby.js b/public/src/components/lobby.js index 1aededb..be01427 100644 --- a/public/src/components/lobby.js +++ b/public/src/components/lobby.js @@ -11,14 +11,14 @@ export default React.createClass({ render() { return d.div({}, Chat(), - d.h1({}, 'drafts.in'), + d.h1({}, 'drafts.ninja'), + d.p({}, `${App.state.numPlayers} ${App.state.numPlayers === 1 ? 'player' : 'players'} + playing ${App.state.numActiveGames} ${App.state.numActiveGames === 1 ? 'game' : 'games'}`), d.p({ className: 'error' }, App.err), Create(), d.footer({}, d.div({}, - d.a({ className: 'icon ion-social-github', href: 'https://github.com/aeosynth/draft' }), - d.a({ className: 'icon ion-social-twitter', href: 'https://twitter.com/aeosynth' }), - d.a({ className: 'icon ion-android-mail', href: 'mailto:james.r.campos@gmail.com' })), + d.a({ className: 'icon ion-social-github', href: 'https://github.com/arxanas/draft' })), d.div({}, d.small({}, 'unaffiliated with wizards of the coast')))) } @@ -42,6 +42,7 @@ function Sets(selectedSet, index) { function content() { let sets = App.state.sets.map(Sets) + let setsTop = d.div({}, sets.slice(0, 3)) let setsBot = d.div({}, sets.slice(3)) @@ -54,27 +55,28 @@ function content() { ] let cards = _.seq(15, 8).map(x => d.option({}, x)) - let packs = _.seq( 7, 3).map(x => d.option({}, x)) + let packs = _.seq(12, 3).map(x => d.option({}, x)) let cubeDraft = d.div({}, d.select({ valueLink: App.link('cards') }, cards), ' cards ', d.select({ valueLink: App.link('packs') }, packs), ' packs') + let chaos = d.div({}) switch(App.state.type) { case 'draft' : return setsTop case 'sealed': return [setsTop, setsBot] case 'cube draft' : return [cube, cubeDraft] case 'cube sealed': return cube - case 'editor': return d.a({ href: 'http://editor.draft.wtf' }, 'editor') + case 'chaos': return chaos } } function Create() { - let seats = _.seq(8, 2).map(x => + let seats = _.seq(100, 2).map(x => d.option({}, x)) - let types = ['draft', 'sealed', 'cube draft', 'cube sealed', 'editor'] + let types = ['draft', 'sealed', 'cube draft', 'cube sealed', 'chaos'] .map(type => d.button({ disabled: type === App.state.type, diff --git a/public/src/components/settings.js b/public/src/components/settings.js index 4f8f1dd..7995c10 100644 --- a/public/src/components/settings.js +++ b/public/src/components/settings.js @@ -24,12 +24,28 @@ function Lands() { inputs) }) + let suggest = d.tr({}, + d.td({}, 'deck size'), + d.td({}, d.input({ + min: 0, + onChange: App._emit('deckSize'), + type: 'number', + value: App.state.deckSize, + })), + d.td({ colSpan: 2 }, d.button({ + onClick: App._emit('resetLands') + }, 'reset lands')), + d.td({colSpan: 2 }, d.button({ + onClick: App._emit('suggestLands') + }, 'suggest lands'))) + return d.table({}, d.tr({}, d.td(), symbols), main, - side) + side, + suggest) } function Sort() { diff --git a/public/src/data.js b/public/src/data.js index 7031dbc..20fc214 100644 --- a/public/src/data.js +++ b/public/src/data.js @@ -1,5 +1,9 @@ export default { + random: { + "Random Set": "RNG" + }, expansion: { + "Shadows Over Innistrad": "SOI", "Oath of the Gatewatch": "OGW", "Battle for Zendikar": "BFZ", "Dragons of Tarkir": "DTK", @@ -71,6 +75,7 @@ export default { "Arabian Nights": "ARN" }, core: { + "Magic Origins": "ORI", "Magic 2015 Core Set": "M15", "Magic 2014 Core Set": "M14", "Magic 2013": "M13", @@ -90,7 +95,7 @@ export default { "Limited Edition Alpha": "LEA" }, other: { - "Magic Origins": "ORI", + "Eternal Masters": "EMA", "Modern Masters 2015": "MM2", "Tempest Remastered": "TPR", "Conspiracy": "CNS", diff --git a/public/style.css b/public/style.css index 2eb5c41..7077dbb 100644 --- a/public/style.css +++ b/public/style.css @@ -39,6 +39,50 @@ time { color: white; } +.card { + display: inline-block; + position: relative; + margin: 0; + cursor: pointer; +} + +.card:hover:before, .autopick-card:before { + content: ''; + + display: block; + position: absolute; + + top: 0; + bottom: 0; + left: 0; + right: 0; + + margin-bottom: 5px; + border-radius: 12px; + + background: rgba(200, 200, 200, 0.25); +} + +.autopick-card:after { + content: 'Autopick'; + + display: inline-block; + position: absolute; + + /* Center in the middle of the bottom border on the card. */ + line-height: 25px; + bottom: 5px; + left: 0; + right: 0; + + color: #fff; + font-family: Verdana, Arial, sans-serif; + font-size: 13px; + font-weight: 700; + text-align: center; + text-shadow: 0px 0px 2px #000; +} + .error { color: red; } @@ -79,6 +123,30 @@ time { background-color: rgba(255, 0, 0, .1); } +.icon-connected, .icon-disconnected, .icon-bot { + display: inline-block; + height: 10px; + width: 10px; + border-radius: 10px; + border-style: solid 1px #000; +} + +.icon-connected { + background-color: #3c3; +} + +.icon-disconnected { + background-color: #333; +} + +.icon-bot { + background-color: rgba(0, 0, 0, 0.25); +} + +.ready { + text-align: center; +} + #img { position: fixed; bottom: 0; diff --git a/src/bot.js b/src/bot.js index 42da18f..e8c00f3 100644 --- a/src/bot.js +++ b/src/bot.js @@ -1,23 +1,40 @@ +var _ = require('./_') var {EventEmitter} = require('events') +var _ = require('./_') module.exports = class extends EventEmitter { constructor() { Object.assign(this, { isBot: true, + isConnected: true, name: 'bot', packs: [], - time: 0 + time: 0, }) } getPack(pack) { var score = 99 var index = 0 + var cardcount = 0 + var scoredcards = 0 pack.forEach((card, i) => { - if (card.score < score) { - score = card.score - index = i - }}) - pack.splice(index, 1) + if (card.score) { + if (card.score < score) { + score = card.score + index = i + } + scoredcards = scoredcards + 1 + } + cardcount = i + }) + //if 50% of cards doesn't have a score, we're going to pick randomly + if (scoredcards / cardcount < .5) { + var randpick = _.rand(cardcount) + pack.splice(randpick, 1) + } + else { + pack.splice(index, 1) + } this.emit('pass', pack) } send(){} diff --git a/src/game.js b/src/game.js index ef002cb..5f6f5d2 100644 --- a/src/game.js +++ b/src/game.js @@ -1,14 +1,15 @@ -var _ = require('./_') -var Bot = require('./bot') -var Human = require('./human') -var Pool = require('./pool') -var Room = require('./room') +let _ = require('./_') +let Bot = require('./bot') +let Human = require('./human') +let Pool = require('./pool') +let Room = require('./room') +let Sock = require('./sock') -var SECOND = 1000 -var MINUTE = 1000 * 60 -var HOUR = 1000 * 60 * 60 +let SECOND = 1000 +let MINUTE = 1000 * 60 +let HOUR = 1000 * 60 * 60 -var games = {} +let games = {} ;(function playerTimer() { for (var id in games) { @@ -17,7 +18,7 @@ var games = {} continue for (var p of game.players) if (p.time && !--p.time) - p.pickRand() + p.pickOnTimeout() } setTimeout(playerTimer, SECOND) })() @@ -35,9 +36,17 @@ module.exports = class Game extends Room { constructor({id, seats, type, sets, cube}) { super() - if (sets) - Object.assign(this, { sets, - title: sets.join(' / ')}) + if (sets) { + if (type != 'chaos') { + Object.assign(this, { + sets, + title: sets.join(' / ') + }) + } + else { + Object.assign(this, { title: 'CHAOS!'}) + } + } else { var title = type if (type === 'cube draft') @@ -56,12 +65,43 @@ module.exports = class Game extends Room { }) this.renew() games[gameID] = this + + console.log(`game ${id} created`) + Game.broadcastGameInfo() } renew() { this.expires = Date.now() + HOUR } + get isActive() { + return this.players.some(x => x.isConnected && !x.isBot) + } + + // The number of total games. This includes ones that have been long since + // abandoned but not yet garbage-collected by the `renew` mechanism. + static numGames() { + return Object.keys(games).length + } + + // The number of games which have a player still in them. + static numActiveGames() { + let count = 0 + for (let id of Object.keys(games)) { + if (games[id].isActive) + count++ + } + return count + } + + static broadcastGameInfo() { + Sock.broadcast('set', { + numGames: Game.numGames(), + numActiveGames: Game.numActiveGames(), + }) + console.log(`there are now ${Game.numGames()} games, ${Game.numActiveGames()} active`) + } + name(name, sock) { super(name, sock) sock.h.name = sock.name @@ -69,6 +109,7 @@ module.exports = class Game extends Room { } join(sock) { + sock.on('exit', this.farewell.bind(this)) for (var i = 0; i < this.players.length; i++) { var p = this.players[i] if (p.id === sock.id) { @@ -98,7 +139,7 @@ module.exports = class Game extends Room { } kick(i) { - var h = this.players[i] + let h = this.players[i] if (!h || h.isBot) return @@ -108,9 +149,11 @@ module.exports = class Game extends Room { h.exit() h.err('you were kicked') + h.kick() } greet(h) { + h.isConnected = true h.send('set', { isHost: h.isHost, round: this.round, @@ -119,6 +162,11 @@ module.exports = class Game extends Room { }) } + farewell(sock) { + sock.h.isConnected = false + this.meta() + } + exit(sock) { if (this.round) return @@ -137,10 +185,14 @@ module.exports = class Game extends Room { hash: p.hash, name: p.name, time: p.time, - packs: p.packs.length + packs: p.packs.length, + isBot: p.isBot, + isConnected: p.isConnected, + isReadyToStart: p.isReadyToStart, })) for (var p of this.players) p.send('set', state) + Game.broadcastGameInfo() } kill(msg) { @@ -148,6 +200,9 @@ module.exports = class Game extends Room { this.players.forEach(p => p.err(msg)) delete games[this.id] + console.log(`game ${this.id} destroyed`) + Game.broadcastGameInfo() + this.emit('kill') } @@ -209,6 +264,9 @@ module.exports = class Game extends Room { var {players} = this var p + if (!players.every(x => x.isReadyToStart)) + return + this.renew() if (/sealed/.test(this.type)) { @@ -225,12 +283,18 @@ module.exports = class Game extends Room { for (p of players) p.useTimer = useTimer + console.log(`game ${this.id} started with ${this.players.length} players and ${this.seats} seats`) + Game.broadcastGameInfo() if (addBots) while (players.length < this.seats) players.push(new Bot) _.shuffle(players) - this.pool = Pool(src, players.length) + if (/chaos/.test(this.type)) + this.pool = Pool(src, players.length, true, true) + else + this.pool = Pool(src, players.length) + players.forEach((p, i) => { p.on('pass', this.pass.bind(this, p)) p.send('set', { self: i }) diff --git a/src/human.js b/src/human.js index 510d1f6..53dbddc 100644 --- a/src/human.js +++ b/src/human.js @@ -6,11 +6,15 @@ var hash = require('./hash') module.exports = class extends EventEmitter { constructor(sock) { Object.assign(this, { + isBot: false, + isConnected: false, + isReadyToStart: false, id: sock.id, name: sock.name, time: 0, packs: [], - pool: [] + autopick_index: -1, + pool: [], }) this.attach(sock) } @@ -19,6 +23,8 @@ module.exports = class extends EventEmitter { this.sock.ws.close() sock.mixin(this) + sock.on('readyToStart', this._readyToStart.bind(this)) + sock.on('autopick', this._autopick.bind(this)) sock.on('pick', this._pick.bind(this)) sock.on('hash', this._hash.bind(this)) @@ -27,6 +33,9 @@ module.exports = class extends EventEmitter { this.send('pack', pack) this.send('pool', this.pool) } + err(message) { + this.send('error', message) + } _hash(deck) { if (!util.deck(deck, this.pool)) return @@ -34,6 +43,15 @@ module.exports = class extends EventEmitter { this.hash = hash(deck) this.emit('meta') } + _readyToStart(value) { + this.isReadyToStart = value + this.emit('meta') + } + _autopick(index) { + var [pack] = this.packs + if (pack && index < pack.length) + this.autopick_index = index + } _pick(index) { var [pack] = this.packs if (pack && index < pack.length) @@ -47,8 +65,8 @@ module.exports = class extends EventEmitter { if (pack.length === 1) return this.pick(0) - if (this.useTimer) - this.time = 20 + 5 * pack.length + if (this.useTimer > 0) + this.time = parseInt(this.useTimer) + parseInt(pack.length) this.send('pack', pack) } @@ -65,17 +83,20 @@ module.exports = class extends EventEmitter { else this.sendPack(next) + this.autopick_index = -1 this.emit('pass', pack) } - pickRand() { - var index = _.rand(this.packs[0].length) + pickOnTimeout() { + let index = this.autopick_index + if (index === -1) + index = _.rand(this.packs[0].length) this.pick(index) } kick() { this.send = ()=>{} while(this.packs.length) - this.pickRand() - this.sendPack = this.pickRand + this.pickOnTimeout() + this.sendPack = this.pickOnTimeout this.isBot = true } } diff --git a/src/make/cards.js b/src/make/cards.js index b7d9fd3..029d681 100644 --- a/src/make/cards.js +++ b/src/make/cards.js @@ -49,6 +49,10 @@ function before() { }) var card + for (card of raw.SOI.cards) + if (card.layout === 'double-faced') + card.rarity = 'special' + for (card of raw.ISD.cards) if (card.layout === 'double-faced') card.rarity = 'special' @@ -76,35 +80,80 @@ function before() { } function after() { + var {SOI} = Sets + SOI.special = { + "mythic": [ + "archangel avacyn", + "startled awake", + "arlinn kord" + ], + "rare": [ + "hanweir militia captain", + "elusive tormentor", + "thing in the ice", + "geier reach bandit", + "sage of ancient lore", + "westvale abbey" + ], + "uncommon": [ + "avacynian missionaries", + "pious evangel", + "town gossipmonger", + "aberrant researcher", + "daring sleuth", + "uninvited geist", + "accursed witch", + "heir of falkenrath", + "kindly stranger", + "breakneck rider", + "convicted killer", + "skin invasion", + "village messenger", + "autumnal gloom", + "duskwatch recruiter", + "hermit of the natterknolls", + "lambholt pacifist", + "harvest hand", + "neglected heirloom", + "thraben gargoyle" + ], + "common": [ + "convicted killer", + "gatstaf arsonists", + "hinterland logger", + "solitary hunter" + ] + } + SOI.size = 8 var {ISD} = Sets ISD.special = { mythic: [ - 'garruk relentless' + 'garruk relentless' ], rare: [ - 'bloodline keeper', - 'daybreak ranger', - 'instigator gang', - 'kruin outlaw', - 'ludevic\'s test subject', - 'mayor of avabruck' + 'bloodline keeper', + 'daybreak ranger', + 'instigator gang', + 'kruin outlaw', + 'ludevic\'s test subject', + 'mayor of avabruck' ], uncommon: [ - 'civilized scholar', - 'cloistered youth', - 'gatstaf shepherd', - 'hanweir watchkeep', - 'reckless waif', - 'screeching bat', - 'ulvenwald mystics' + 'civilized scholar', + 'cloistered youth', + 'gatstaf shepherd', + 'hanweir watchkeep', + 'reckless waif', + 'screeching bat', + 'ulvenwald mystics' ], common: [ - 'delver of secrets', - 'grizzled outcasts', - 'thraben sentry', - 'tormented pariah', - 'village ironsmith', - 'villagers of estwald' + 'delver of secrets', + 'grizzled outcasts', + 'thraben sentry', + 'tormented pariah', + 'village ironsmith', + 'villagers of estwald' ] } var {DKA} = Sets @@ -227,12 +276,15 @@ function doCard(rawCard, cards, code, set) { if (rawCard.layout === 'split') name = rawCard.names.join(' // ') + //separate landsfrom 0cmc cards by setting 0cmc to .2 + var cmcadjusted = rawCard.cmc || 0.2 + name = _.ascii(name) if (name in cards) { if (rawCard.layout === 'split') { var card = cards[name] - card.cmc += rawCard.cmc + cmcadjusted = card.cmc + rawCard.cmc if (card.color !== rawCard.color) card.color = 'multicolor' } @@ -243,10 +295,16 @@ function doCard(rawCard, cards, code, set) { var color = !colors ? 'colorless' : colors.length > 1 ? 'multicolor' : colors[0].toLowerCase() + + //set lands to .1 to sort them before nonland 0cmc + if ('Land'.indexOf(rawCard.types) > -1) + cmcadjusted = 0.1 cards[name] = { color, name, type: rawCard.types[rawCard.types.length - 1], - cmc: rawCard.cmc || 0, + cmc: cmcadjusted, + text: rawCard.text || '', + manaCost: rawCard.manaCost || '', sets: { [code]: { rarity, url: `http://gatherer.wizards.com/Handlers/Image.ashx?multiverseid=${rawCard.multiverseid}&type=card` diff --git a/src/pool.js b/src/pool.js index 569a41f..898ab37 100644 --- a/src/pool.js +++ b/src/pool.js @@ -25,19 +25,41 @@ function toPack(code) { var {common, uncommon, rare, mythic, special, size} = set if (mythic && !_.rand(8)) rare = mythic - + //make small sets draftable. SOI has 9 commons, none have less except early sets + if (size < 10 && code != 'SOI') + size = 10 var pack = [].concat( _.choose(size, common), _.choose(3, uncommon), _.choose(1, rare) ) + if (code == 'SOI') + //http://markrosewater.tumblr.com/post/141794840953/if-the-as-fan-of-double-face-cards-is-1125-that + if (_.rand(8) == 0) + if (_.rand(15) < 3) + pack.push(_.choose(1, special.mythic)) + else + pack.push(_.choose(1, special.rare)) + else + pack.push(_.choose(1, common)) + + let specialrnd switch (code) { + case 'SOI': + if (_.rand(106) < 38) + special = special.uncommon + else + special = special.common + break case 'DGM': special = _.rand(20) ? special.gate : special.shock break + case 'EMA': + special = selectRarity(set) + break case 'MMA': special = selectRarity(set) break @@ -57,7 +79,7 @@ function toPack(code) { case 'ISD': //http://www.mtgsalvation.com/forums/magic-fundamentals/magic-general/327956-innistrad-block-transforming-card-pack-odds?comment=4 //121 card sheet, 1 mythic, 12 rare (13), 42 uncommon (55), 66 common - let specialrnd = _.rand(121) + specialrnd = _.rand(121) if (specialrnd == 0) special = special.mythic else if (specialrnd < 13) @@ -70,8 +92,8 @@ function toPack(code) { case 'DKA': //http://www.mtgsalvation.com/forums/magic-fundamentals/magic-general/327956-innistrad-block-transforming-card-pack-odds?comment=4 //80 card sheet, 2 mythic, 6 rare (8), 24 uncommon (32), 48 common - let specialrnd = _.rand(80) - if (specialrnd <= 1) + specialrnd = _.rand(80) + if (specialrnd < 2) special = special.mythic else if (specialrnd < 8) special = special.rare @@ -79,7 +101,7 @@ function toPack(code) { special = special.uncommon else special = special.common - break + break } if (special) @@ -97,17 +119,31 @@ function toCards(pool, code) { if (isCube) [code] = Object.keys(sets) card.code = mws[code] || code - var set = sets[code] delete card.sets return Object.assign(card, set) }) } -module.exports = function (src, playerCount, isSealed) { +module.exports = function (src, playerCount, isSealed, isChaos) { if (!(src instanceof Array)) { - var isCube = true - _.shuffle(src.list) + if (!(isChaos)) { + var isCube = true + _.shuffle(src.list) + } + } + else { + for (i = 0; i < src.length; i++) { + if (src[i] == 'RNG') { + var rnglist = [] + for (var rngcode in Sets) + //TODO check this against public/src/data.js + if (rngcode != 'UNH' && rngcode != 'UGL') + rnglist.push(rngcode) + var rngindex = _.rand(rnglist.length) + src[i] = rnglist[rngindex] + } + } } if (isSealed) { var count = playerCount @@ -118,15 +154,30 @@ module.exports = function (src, playerCount, isSealed) { } var pools = [] - if (isCube || isSealed) - while (count--) - pools.push(isCube - ? toCards(src.list.splice(-size)) - : [].concat(...src.map(toPack))) - else + if (isCube || isSealed) { + if (!(isChaos)) { + while (count--) + pools.push(isCube + ? toCards(src.list.splice(-size)) + : [].concat(...src.map(toPack))) + } else { + var setlist = [] + for (var code in Sets) + if (code != 'UNH' && code != 'UGL') + setlist.push(code) + for (var i = 0; i < 3; i++) { + for (var j = 0; j < playerCount; j++) { + var setindex = _.rand(setlist.length) + var code = setlist[setindex] + setlist.splice(setindex, 1) + pools.push(toPack(code)) + } + } + } + } else { for (var code of src.reverse()) for (var i = 0; i < playerCount; i++) pools.push(toPack(code)) - + } return pools } diff --git a/src/router.js b/src/router.js index bf680d2..6e4851f 100644 --- a/src/router.js +++ b/src/router.js @@ -37,4 +37,6 @@ module.exports = function (ws) { var sock = new Sock(ws) sock.on('join', join) sock.on('create', create) + + Game.broadcastGameInfo() } diff --git a/src/sock.js b/src/sock.js index 30afa07..a163cff 100644 --- a/src/sock.js +++ b/src/sock.js @@ -1,5 +1,13 @@ var {EventEmitter} = require('events') +// All sockets currently connected to the server. +let allSocks = [] + +function broadcastNumPlayers() { + console.log(`there are now ${allSocks.length} connected users`) + Sock.broadcast('set', { numPlayers: allSocks.length }) +} + function message(msg) { var [type, data] = JSON.parse(msg) this.emit(type, data, this) @@ -17,18 +25,30 @@ var mixins = { } } -module.exports = class extends EventEmitter { +class Sock extends EventEmitter { constructor(ws) { this.ws = ws - var {id='', name='newfriend'} = ws.request._query + var {id='', name='ninja'} = ws.request._query this.id = id.slice(0, 25) this.name = name.slice(0, 15) for (var key in mixins) this[key] = mixins[key].bind(this) + allSocks.push(this) + broadcastNumPlayers() ws.on('message', message.bind(this)) ws.on('close', this.exit) + + // `this.exit` may be called for other reasons than the socket closing. + let sock = this + ws.on('close', ()=> { + let index = allSocks.indexOf(sock) + if (index !== -1) { + allSocks.splice(index, 1) + broadcastNumPlayers() + } + }) } mixin(h) { h.sock = this @@ -36,4 +56,9 @@ module.exports = class extends EventEmitter { for (var key in mixins) h[key] = this[key] } + static broadcast(...args) { + for (let sock of allSocks) + sock.send(...args) + } } +module.exports = Sock diff --git a/src/util.js b/src/util.js index 7cfbd20..7e3f7e0 100644 --- a/src/util.js +++ b/src/util.js @@ -14,17 +14,17 @@ function transform(cube, seats, type) { assert(typeof list === 'string', 'typeof list') assert(typeof cards === 'number', 'typeof cards') - assert(8 <= cards && cards <= 15, 'cards range') + assert(5 <= cards && cards <= 30, 'cards range') assert(typeof packs === 'number', 'typeof packs') - assert(3 <= packs && packs <= 7, 'packs range') + assert(3 <= packs && packs <= 12, 'packs range') list = list.split('\n').map(_.ascii) var min = type === 'cube draft' ? seats * cards * packs : seats * 90 - assert(min <= list.length && list.length <= 1e3, - `this cube needs between ${min} and 1000 cards; it has ${list.length}`) + assert(min <= list.length && list.length <= 1e5, + `this cube needs between ${min} and 100,000 cards; it has ${list.length}`) var bad = [] for (var cardName of list) @@ -64,13 +64,15 @@ var util = module.exports = { }, game({seats, type, sets, cube}) { assert(typeof seats === 'number', 'typeof seats') - assert(2 <= seats && seats <= 8, 'seats range') - assert(['draft', 'sealed', 'cube draft', 'cube sealed'].indexOf(type) > -1, + assert(2 <= seats && seats <= 100, 'seats range') + assert(['draft', 'sealed', 'cube draft', 'cube sealed', 'chaos'].indexOf(type) > -1, 'indexOf type') if (/cube/.test(type)) transform(cube, seats, type) - else - sets.forEach(set => assert(set in Sets, `${set} in Sets`)) + //remove the below check for now to allow Random sets + //TODO add if check for Random set + //else + // sets.forEach(set => assert(set in Sets, `${set} in Sets`)) } }