JasonWoof Got questions, comments, patches, etc.? Contact Jason Woofenden
more debug messages
[peach-cgt.git] / server.coffee
1 # Peach CGT -- Card Game Table simulator
2 # Copyright (C) 2011  Jason Woofenden
3 #
4 # This program is free software: you can redistribute it and/or modify it under
5 # the terms of the GNU Affero General Public License as published by the Free
6 # Software Foundation, either version 3 of the License, or (at your option) any
7 # later version.
8 #
9 # This program is distributed in the hope that it will be useful, but WITHOUT
10 # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
11 # FOR A PARTICULAR PURPOSE.  See the GNU Affero General Public License for more
12 # details.
13 #
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
16
17 listen_port = process.env.app_port ? 9988
18 sys = require 'sys'
19 fs = require 'fs'
20 http = require 'http'
21 querystring = require 'querystring'
22 url = require 'url'
23 console.log "required builtins"
24 less = require 'less'
25 console.log "required less"
26 coffee = require 'coffee-script'
27 console.log "required coffee-script"
28 model = require './common.coffee'
29 console.log "required common.coffee"
30
31 games = {}
32 max_concurrent_games = 50
33 max_game_idle = 3 * 60 * 60 * 1000 # three hours (in miliseconds)
34
35 # timeout function with args in convenient order
36 timeout = (ms, func) -> setTimeout func, ms
37
38 now_s = ->
39         d = new Date()
40         return d.getTime()
41
42 expire_old_games = ->
43         count = 0
44         for slug, g of games
45                 count += 1
46                 oldest_slug = slug
47                 oldest_seen = g.last_seen
48
49         return unless count > 0
50
51         # check all the games
52         # track oldest
53         # delete old ones
54         too_old = now_s() - max_game_idle
55         kills = []
56         for slug, g of games
57                 if g.last_seen < oldest_seen
58                         oldest_seen = g.last_seen
59                         oldest_slug = slug
60                 if g.last_seen < too_old
61                         kills.push slug
62         if count > max_concurrent_games and kills.length is 0
63                 console.log "hit max_concurrent_games, destroying oldest"
64                 kills.push oldest_slug
65         for slug in kills
66                 console.log "killing game #{slug}"
67                 delete games[slug]
68
69 css_handler = (args, out, request, url_parts) ->
70         fs.readFile 'style.less', 'utf8', (err, data) ->
71                 if err
72                         return out.end 'Server failed to read style.less'
73                 less.render data, (err, css) ->
74                         if err
75                                 return out.end "Server failed to make css: #{err}"
76                         out.end css
77
78 js_handler = (args, out, request, url_parts) ->
79         convert = false
80         basename = url_parts.pathname.substr 1, (url_parts.pathname.length - 4)
81         if basename is 'client'
82                 filename = 'client.coffee'
83                 convert = true
84         else if basename is 'common'
85                 filename = 'common.coffee'
86                 convert = true
87         else if basename is 'cs_cards'
88                 filename = 'cs_cards.js'
89                 convert = false
90         else
91                 error = "Unknown js basename: #{basename}"
92                 console.log error
93                 out.end(error)
94                 return
95
96         fs.readFile filename, 'utf8', (err, data) ->
97                 if err
98                         return out.end "Server failed to read #{filename}"
99                 if convert
100                         try
101                                 converted = coffee.compile data
102                         catch e
103                                 out.end "alert(\"server faild to compile #{filename}\");"
104                         out.end converted
105                 else
106                         out.end data
107
108 html_handler = (args, out, request, url_parts) ->
109         fs.readFile 'index.html', 'utf8', (err, data) ->
110                 if err
111                         return out.end "Server failed to read index.html: #{err}"
112                 out.end data
113
114 clean_pathname_regex = new RegExp('[^a-zA-Z/_.-]')
115 clean_pathname_regex2 = new RegExp('/[.]')
116 clean_pathname_regex3 = new RegExp('^[.-]')
117 clean_pathname = (str) ->
118         str = str.replace clean_pathname_regex, '_'
119         str = str.replace clean_pathname_regex2, '/_'
120         return str.replace clean_pathname_regex3, '_'
121
122 # serve javascript files from within external/
123 external_javascript_handler = (args, out, request, url_parts) ->
124         filename = clean_pathname "external/#{url_parts.pathname.substr 10}"
125         fs.readFile filename, 'utf8', (err, data) ->
126                 if err
127                         out.writeHead 404
128                         return out.end "Server failed to read #{filename}"
129                 out.writeHead 200, 'Content-Type': 'text/javascript'
130                 out.end data
131
132
133 get_handler = (args, out, request, url_parts) ->
134         unless args.game?.length
135                 out.writeHead 404, "Content-Type": 'text/plain'
136                 out.end 'Missing (or empty) "game" argument'
137                 return
138
139         unless args.agent is 'p1' or args.agent is 'p2'
140                 out.writeHead 404, "Content-Type": 'text/plain'
141                 out.end '"agent" argument must be set to p1 or p2'
142                 return
143
144         unless games[args.game]?
145                 out.writeHead 404, "Content-Type": 'text/plain'
146                 out.end 'Game not found'
147                 return
148
149         game = games[args.game]
150
151         waiter = games["#{args.agent}_waiter"]
152         if waiter?
153                 waiter.writeHead 200, 'Content-Type': 'text/javascript'
154                 waiter.end '[]'
155
156         game["#{args.agent}_waiter"] = out
157
158         answer_soon game # in case there's something queued already
159
160 set_handler = (args, out, request, url_parts) ->
161         unless args.game?.length
162                 out.writeHead 404, "Content-Type": 'text/plain'
163                 out.end '{"status":1,"text_status":"Missing (or empty) game argument"}'
164                 return
165
166         unless args.agent is 'p1' or args.agent is 'p2'
167                 out.writeHead 404, "Content-Type": 'text/plain'
168                 out.end '{"status":2,"text_status":"agent argument must be set to p1 or p2"}'
169                 return
170
171         unless args.messages?
172                 out.writeHead 404, "Content-Type": 'text/plain'
173                 out.end '{"status":3,"text_status":"messages argument must be set"}'
174                 return
175
176         try
177                 messages = JSON.parse args.messages
178         catch e
179                 out.writeHead 400, "Content-Type": 'text/plain'
180                 out.end '{"status":4,"text_status":"Invalid JSON"}'
181                 return
182
183         # special handling of 'new_game' api, because for this one we don't have a
184         # game object to pass the message to
185         if messages?[0]?[0] is 'new_game'
186                 message = messages.shift()
187                 slug = message[1]
188                 if games[slug]?
189                         out.writeHead 403, "Content-Type": 'text/plain'
190                         out.end '{"status":6,"text_status":"Game already exists"}'
191                         return
192                 game = games[slug] = new_game slug, 'server'
193                 game.last_seen = now_s()
194                 console.log "new game: #{slug}"
195                 expire_old_games()
196
197         unless games[args.game]?
198                 out.writeHead 404, "Content-Type": 'text/plain'
199                 out.end '{"status":5,"text_status":"Game not found"}'
200                 return
201
202         game = games[args.game]
203
204         game.last_seen = now_s()
205
206         game.process_messages messages
207
208         out.writeHead 200, "Content-Type": 'text/plain'
209         out.end '{"status":0,"text_status":"Success"}'
210
211 # don't call this directly, call answer_soon instead
212 answer_now = (game) ->
213         if game.p1_waiter and game.p1_queue.length
214                 waiter = game.p1_waiter
215                 queue = game.p1_queue
216                 game.p1_waiter = false
217                 game.p1_queue = []
218                 waiter.writeHead 200, 'Content-Type': 'text/javascript'
219                 waiter.end JSON.stringify queue
220         if game.p2_waiter and game.p2_queue.length
221                 waiter = game.p2_waiter
222                 queue = game.p2_queue
223                 game.p2_waiter = false
224                 game.p2_queue = []
225                 waiter.writeHead 200, 'Content-Type': 'text/javascript'
226                 waiter.end JSON.stringify queue
227
228 # this marks a game as "dirty" and makes sure there's exactly one timeout
229 # that'll respond to any clients that are waiting, and now have messages.
230 answer_soon = (game) ->
231         unless game.replier_id
232                 game.replier_id = timeout 1, ->
233                         delete game.replier_id
234                         answer_now game
235
236 forward_events = (message...) ->
237         unless message[1] is 'p1'
238                 @p1_queue.push message
239                 answer_soon this
240         unless message[1] is 'p2'
241                 @p2_queue.push message
242                 answer_soon this
243
244 new_game = (slug) ->
245         game = games[slug] = model.new slug, 'server'
246         game.p1_waiter = false
247         game.p2_waiter = false
248         game.p1_queue = []
249         game.p2_queue = []
250
251         game.on 'move', (agent, card, x, y, z, pile) ->
252                 forward_events.call this, 'move', agent, card, x, y, z, pile
253         game.on 'mark', (agent, card, state) ->
254                 forward_events.call this, 'mark', agent, card, state
255         game.on 'flip', (agent, card, state) ->
256                 forward_events.call this, 'flip', agent, card, state
257         game.on 'new_cards', (agent, cards) ->
258                 # server assigns card numbers, and tells both clients
259                 # (unlike all other api calls, sending agent expects to get this one back)
260                 forward_events.call this, 'new_cards', 'server', cards
261         game.on 'set_cards', (agent, cards) ->
262                 forward_events.call this, 'set_cards', agent, cards
263         game.on 'send_state', (agent) ->
264                 timeout 10, =>
265                         if agent is 'p1'
266                                 @p1_queue.push ['set_cards', 'server', @cards]
267                                 answer_soon this
268                         if agent is 'p2'
269                                 @p2_queue.push ['set_cards', 'server', @cards]
270                                 answer_soon this
271
272         return game
273
274 http_server = http.createServer (req, res) ->
275         url_parts = url.parse req.url, true
276         if url_parts.query is undefined
277                 url_parts.query = {}
278
279         rel_path = url_parts.pathname.substr 1
280
281         if rel_path.substr(0, 9) is 'external/'
282                 return external_javascript_handler url_parts.query, res, req, url_parts
283         else if rel_path.substr(rel_path.length - 4) is '.css'
284                 res.writeHead 200, 'Content-Type': 'text/css'
285                 return css_handler url_parts.query, res, req, url_parts
286         else if rel_path.substr(rel_path.length - 3) is '.js'
287                 res.writeHead 200, 'Content-Type': 'text/javascript'
288                 return js_handler url_parts.query, res, req, url_parts
289         else if rel_path.substr(rel_path.length - 4) is '/set'
290                 data = ''
291                 req.on 'data', (chunk) ->
292                         data += chunk
293                 req.on 'end', ->
294                         query = url_parts.query
295                         post_args = querystring.parse data
296                         for key, parg of post_args
297                                 query[key] = parg
298                         return set_handler query, res, req, url_parts
299                 return
300         else if rel_path.substr(rel_path.length - 4) is '/get'
301                 return get_handler url_parts.query, res, req, url_parts
302         else if rel_path.substr(rel_path.length - 4) is '.ico'
303                 res.writeHead 404
304                 return res.end()
305
306         return html_handler url_parts.query, res, req, url_parts
307
308 ################## INIT ####################
309 # make sure the current working directory is correct
310 process.chdir __dirname
311
312 setInterval expire_old_games, 2 * 60 * 1000 # check every 2 minutes for expired games
313
314 http_server.listen listen_port, "127.0.0.1"
315 console.log "Server running at http://127.0.0.1:#{listen_port}/"