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