JasonWoof Got questions, comments, patches, etc.? Contact Jason Woofenden
register coffeescript compiler
[peach-cgt.git] / server.coffee
index 48e2857..e20223d 100644 (file)
@@ -1,10 +1,67 @@
-listen_port = 8333
+# Peach CGT -- Card Game Table simulator
+# Copyright (C) 2011  Jason Woofenden
+#
+# This program is free software: you can redistribute it and/or modify it under
+# the terms of the GNU Affero General Public License as published by the Free
+# Software Foundation, either version 3 of the License, or (at your option) any
+# later version.
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE.  See the GNU Affero General Public License for more
+# details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+listen_port = process.env.PORT ? process.env.app_port ? 9988
 sys = require 'sys'
 fs = require 'fs'
 http = require 'http'
+querystring = require 'querystring'
 url = require 'url'
 less = require 'less'
 coffee = require 'coffee-script'
+model = require './common.coffee'
+
+games = {}
+max_concurrent_games = 50
+max_game_idle = 3 * 60 * 60 * 1000 # three hours (in miliseconds)
+
+# timeout function with args in convenient order
+timeout = (ms, func) -> setTimeout func, ms
+interval = (ms, func) -> setInterval func, ms
+
+now_s = ->
+       d = new Date()
+       return d.getTime()
+
+expire_old_games = ->
+       count = 0
+       for slug, g of games
+               count += 1
+               oldest_slug = slug
+               oldest_seen = g.last_seen
+
+       return unless count > 0
+
+       # check all the games
+       # track oldest
+       # delete old ones
+       too_old = now_s() - max_game_idle
+       kills = []
+       for slug, g of games
+               if g.last_seen < oldest_seen
+                       oldest_seen = g.last_seen
+                       oldest_slug = slug
+               if g.last_seen < too_old
+                       kills.push slug
+       if count > max_concurrent_games and kills.length is 0
+               console.log "hit max_concurrent_games, destroying oldest"
+               kills.push oldest_slug
+       for slug in kills
+               console.log "killing game #{slug}"
+               delete games[slug]
 
 css_handler = (args, out, request, url_parts) ->
        fs.readFile 'style.less', 'utf8', (err, data) ->
@@ -16,15 +73,39 @@ css_handler = (args, out, request, url_parts) ->
                        out.end css
 
 js_handler = (args, out, request, url_parts) ->
-       fs.readFile 'client.coffee', 'utf8', (err, data) ->
+       convert = false
+       basename = url_parts.pathname.substr 1, (url_parts.pathname.length - 4)
+       if basename is 'client'
+               filename = 'client.coffee'
+               convert = true
+       else if basename is 'common'
+               filename = 'common.coffee'
+               convert = true
+       else if basename is 'cs_cards'
+               filename = 'cs_cards.js'
+               convert = false
+       else
+               error = "Unknown js basename: #{basename}"
+               console.log error
+               out.end(error)
+               return
+
+       fs.readFile filename, 'utf8', (err, data) ->
                if err
-                       return out.end 'Server failed to read client.coffee'
-               out.end coffee.compile data
+                       return out.end "Server failed to read #{filename}"
+               if convert
+                       try
+                               converted = coffee.compile data
+                       catch e
+                               out.end "alert(\"server faild to compile #{filename}\");"
+                       out.end converted
+               else
+                       out.end data
 
 html_handler = (args, out, request, url_parts) ->
        fs.readFile 'index.html', 'utf8', (err, data) ->
                if err
-                       return out.end 'Server failed to read index.html'
+                       return out.end "Server failed to read index.html: #{err}"
                out.end data
 
 clean_pathname_regex = new RegExp('[^a-zA-Z/_.-]')
@@ -34,17 +115,165 @@ clean_pathname = (str) ->
        str = str.replace clean_pathname_regex, '_'
        str = str.replace clean_pathname_regex2, '/_'
        return str.replace clean_pathname_regex3, '_'
-       
-# serve javascript files from within /usr/share/javascript
-javascript_handler = (args, out, request, url_parts) ->
-       filename = clean_pathname "/usr/share/#{url_parts.pathname}"
+
+# serve javascript files from within external/
+external_javascript_handler = (args, out, request, url_parts) ->
+       filename = clean_pathname "external/#{url_parts.pathname.substr 10}"
        fs.readFile filename, 'utf8', (err, data) ->
                if err
                        out.writeHead 404
                        return out.end "Server failed to read #{filename}"
                out.writeHead 200, 'Content-Type': 'text/javascript'
                out.end data
-       
+
+
+get_handler = (args, out, request, url_parts) ->
+       unless args.game?.length
+               out.writeHead 404, "Content-Type": 'text/plain'
+               out.end 'Missing (or empty) "game" argument'
+               return
+
+       unless args.agent is 'p1' or args.agent is 'p2'
+               out.writeHead 404, "Content-Type": 'text/plain'
+               out.end '"agent" argument must be set to p1 or p2'
+               return
+
+       unless games[args.game]?
+               out.writeHead 404, "Content-Type": 'text/plain'
+               out.end 'Game not found'
+               return
+
+       game = games[args.game]
+       out.writeHead 200, 'Content-Type': 'text/javascript'
+
+       waiter = games["#{args.agent}_waiter"]
+       if waiter?
+               waiter.end '[]'
+
+       game["#{args.agent}_waiter"] = out
+
+       answer_soon game # in case there's something queued already
+
+set_handler = (args, out, request, url_parts) ->
+       unless args.game?.length
+               out.writeHead 404, "Content-Type": 'text/plain'
+               out.end '{"status":1,"text_status":"Missing (or empty) game argument"}'
+               return
+
+       unless args.agent is 'p1' or args.agent is 'p2'
+               out.writeHead 404, "Content-Type": 'text/plain'
+               out.end '{"status":2,"text_status":"agent argument must be set to p1 or p2"}'
+               return
+
+       unless args.messages?
+               out.writeHead 404, "Content-Type": 'text/plain'
+               out.end '{"status":3,"text_status":"messages argument must be set"}'
+               return
+
+       try
+               messages = JSON.parse args.messages
+       catch e
+               out.writeHead 400, "Content-Type": 'text/plain'
+               out.end '{"status":4,"text_status":"Invalid JSON"}'
+               return
+
+       # special handling of 'new_game' api, because for this one we don't have a
+       # game object to pass the message to
+       if messages?[0]?[0] is 'new_game'
+               message = messages.shift()
+               slug = message[1]
+               if games[slug]?
+                       out.writeHead 403, "Content-Type": 'text/plain'
+                       out.end '{"status":6,"text_status":"Game already exists"}'
+                       return
+               game = games[slug] = new_game slug, 'server'
+               game.last_seen = now_s()
+               console.log "new game: #{slug}"
+               expire_old_games()
+
+       unless games[args.game]?
+               out.writeHead 404, "Content-Type": 'text/plain'
+               out.end '{"status":5,"text_status":"Game not found"}'
+               return
+
+       game = games[args.game]
+
+       game.last_seen = now_s()
+
+       game.process_messages messages
+
+       out.writeHead 200, "Content-Type": 'text/plain'
+       out.end '{"status":0,"text_status":"Success"}'
+
+# don't call this directly, call answer_soon instead
+answer_now = (game) ->
+       if game.p1_waiter and game.p1_queue.length
+               waiter = game.p1_waiter
+               queue = game.p1_queue
+               game.p1_waiter = false
+               game.p1_queue = []
+               waiter.end JSON.stringify queue
+       if game.p2_waiter and game.p2_queue.length
+               waiter = game.p2_waiter
+               queue = game.p2_queue
+               game.p2_waiter = false
+               game.p2_queue = []
+               waiter.end JSON.stringify queue
+
+# this marks a game as "dirty" and makes sure there's exactly one timeout
+# that'll respond to any clients that are waiting, and now have messages.
+answer_soon = (game) ->
+       unless game.replier_id
+               game.replier_id = timeout 1, ->
+                       delete game.replier_id
+                       answer_now game
+
+forward_events = (message...) ->
+       unless message[1] is 'p1'
+               @p1_queue.push message
+               answer_soon this
+       unless message[1] is 'p2'
+               @p2_queue.push message
+               answer_soon this
+
+new_game = (slug) ->
+       game = games[slug] = model.new slug, 'server'
+       game.p1_waiter = false
+       game.p2_waiter = false
+       game.p1_queue = []
+       game.p2_queue = []
+
+       game.on 'move', (agent, card, x, y, z, pile) ->
+               forward_events.call this, 'move', agent, card, x, y, z, pile
+       game.on 'mark', (agent, card, state) ->
+               forward_events.call this, 'mark', agent, card, state
+       game.on 'flip', (agent, card, state) ->
+               forward_events.call this, 'flip', agent, card, state
+       game.on 'new_cards', (agent, cards) ->
+               # server assigns card numbers, and tells both clients
+               # (unlike all other api calls, sending agent expects to get this one back)
+               forward_events.call this, 'new_cards', 'server', cards
+       game.on 'set_cards', (agent, cards) ->
+               forward_events.call this, 'set_cards', agent, cards
+       game.on 'send_state', (agent) ->
+               timeout 10, =>
+                       if agent is 'p1'
+                               @p1_queue.push ['set_cards', 'server', @cards]
+                               answer_soon this
+                       if agent is 'p2'
+                               @p2_queue.push ['set_cards', 'server', @cards]
+                               answer_soon this
+
+       return game
+
+long_poll_keepalive = ->
+       for slug, g of games
+               if g.p1_waiter? and g.p1_waiter isnt false
+                       g.p1_waiter.write '\n'
+               if g.p2_waiter? and g.p2_waiter isnt false
+                       g.p2_waiter.write '\n'
+
+interval 12000, long_poll_keepalive
 
 http_server = http.createServer (req, res) ->
        url_parts = url.parse req.url, true
@@ -53,16 +282,38 @@ http_server = http.createServer (req, res) ->
 
        rel_path = url_parts.pathname.substr 1
 
-       if rel_path.substr(0, 11) is 'javascript/'
-               return javascript_handler url_parts.query, res, req, url_parts
+       if rel_path.substr(0, 9) is 'external/'
+               return external_javascript_handler url_parts.query, res, req, url_parts
        else if rel_path.substr(rel_path.length - 4) is '.css'
                res.writeHead 200, 'Content-Type': 'text/css'
                return css_handler url_parts.query, res, req, url_parts
-       else if rel_path.substr rel_path.length - 3 is '.js'
+       else if rel_path.substr(rel_path.length - 3) is '.js'
                res.writeHead 200, 'Content-Type': 'text/javascript'
                return js_handler url_parts.query, res, req, url_parts
-               
+       else if rel_path is 'set'
+               data = ''
+               req.on 'data', (chunk) ->
+                       data += chunk
+               req.on 'end', ->
+                       query = url_parts.query
+                       post_args = querystring.parse data
+                       for key, parg of post_args
+                               query[key] = parg
+                       return set_handler query, res, req, url_parts
+               return
+       else if rel_path is 'get'
+               return get_handler url_parts.query, res, req, url_parts
+       else if rel_path.substr(rel_path.length - 4) is '.ico'
+               res.writeHead 404
+               return res.end()
+
        return html_handler url_parts.query, res, req, url_parts
 
-http_server.listen listen_port, "127.0.0.1"
+################## INIT ####################
+# make sure the current working directory is correct
+process.chdir __dirname
+
+setInterval expire_old_games, 2 * 60 * 1000 # check every 2 minutes for expired games
+
+http_server.listen listen_port
 console.log "Server running at http://127.0.0.1:#{listen_port}/"