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