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