/
server.py
230 lines (209 loc) · 7.44 KB
/
server.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
import os
import re
import sys
import json
import socket
import asyncio
from aiohttp import web, WSMsgType
app = web.Application()
rooms = {}
class Room:
# Color names taken from https://en.wikipedia.org/wiki/Template:Monopoly_board_layout
# including the variant that the cheapest ones are Indigo, not SaddleBrown
property_data = """
Indigo/White: 60/60 Old Kent, Whitechapel
SkyBlue: 100/120 Angel Islington, Euston, Pentonville
DarkOrchid/White: 140/160 Pall Mall, Whitehall, Northumberland
Orange: 180/200 Bow, Marlborough, Vine
Red: 220/240 Strand, Fleet, Trafalgar
Yellow: 260/280 Leicester, Coventry, Piccadilly
Green/White: 300/320 Regent, Oxford, Bond
Blue/White: 350/400 Park Lane, Mayfair
Black/White: 200/200 King's Cross, Marylebone, Fenchurch, Liverpool
White: 150/150 Electric, Water Works
"""
def __init__(self, id):
if os.environ.get("WS_KEEPALIVE"):
asyncio.ensure_future(self.keepalive())
self.clients = []
self.proporder = []
self.funds = 1500 # Everyone's initial spendable money
self.id = id; rooms[self.id] = self # floop
print("Creating new room %s [%d rooms]" % (self.id, len(rooms)))
self.dying = None # Set to true when we run out of clients
self.all_done = False
# Preprocess the property data into a more useful form.
self.properties = {}
for group in self.property_data.splitlines():
group = group.strip()
if not group: continue
color, price1, price2, names = re.match("([A-Za-z/]+): ([0-9]+)/([0-9]+) (.*)", group).groups()
names = names.split(", ")
if "/" in color: color, fg = color.split("/")
else: fg = "Black"
for name in names:
self.proporder.append(name)
self.properties[name] = {"facevalue": int(price1), "color": color, "fg": fg}
# Alter the price of the last one (the top one of the group)
self.properties[name]["facevalue"] = int(price2)
async def send_users(self):
"""Notify all clients of updated public user data"""
users = {ws.username: self.funds for ws in self.clients if ws.username}
for prop in self.properties.values():
if "bidder" in prop:
users[prop["bidder"]] -= prop["highbid"]
info = {"type": "users",
"done_count": sum(ws.done for ws in self.clients if ws.username),
"all_done": self.all_done,
"users": sorted(users.items()),
}
for ws in self.clients:
ws.funds = info["funds"] = users.get(ws.username, self.funds)
info["done"] = ws.done
await ws.send_json(info)
async def ws_login(self, ws, name, **xtra):
if ws.username: return None
ws.username = str(name)[:32]
await ws.send_json({"type": "login", "name": ws.username})
await self.send_users()
async def ws_done(self, ws, **xtra):
ws.done = True
for cli in self.clients:
if not cli.done:
await self.send_users()
return
# Everyone's done. Mode switch!
self.all_done = True
people = {cli.username:i for i, cli in enumerate(self.clients) if cli.username}
self.proporder.sort(key=lambda prop: people.get(self.properties[prop].get("bidder"), -1))
await self.send_users()
return {"type": "properties", "data": self.properties, "order": self.proporder};
async def ws_bid(self, ws, name, value, **xtra):
if self.all_done: return None
prop = self.properties[name]
value = int(value)
minbid = prop["facevalue"] if "bidder" not in prop else prop["highbid"] + 10
if value < minbid: return None
if value > ws.funds: return None
prop["highbid"] = value
prop["bidder"] = ws.username
for cli in self.clients: cli.done = False
await self.send_users()
return {"type": "property", "name": name, "data": prop}
async def keepalive(self):
"""Keep the websockets alive
In some environments, we lose any inactive websockets. So keep telling
them about users - that's safe, at least.
"""
while True:
await asyncio.sleep(30)
await self.send_users()
async def websocket(self, ws, login_data):
ws.username = None; ws.done = False
self.dying = None # Whenever anyone joins, even if they disconnect fast, reset the death timer.
self.clients.append(ws)
await self.ws_login(ws, **login_data)
print("New socket in %s (now %d)" % (self.id, len(self.clients)))
await ws.send_json({"type": "properties", "data": self.properties, "order": self.proporder});
async for msg in ws:
# Ignore non-JSON messages
if msg.type != WSMsgType.TEXT: continue
try: msg = json.loads(msg.data)
except ValueError: continue
print("MESSAGE", msg)
if "type" not in msg or "data" not in msg: continue
f = getattr(self, "ws_" + msg["type"], None)
if not f: continue
try:
resp = await f(ws, **msg["data"])
except Exception as e:
print("Exception in ws handler:")
print(e)
continue
if resp is None: continue
for client in self.clients:
await client.send_json(resp)
self.clients.remove(ws)
await ws.close()
print("Socket gone from %s (%d left)" % (self.id, len(self.clients)))
if not self.clients:
asyncio.ensure_future(self.die())
return ws
async def die(self):
"""Destroy this room after a revive delay"""
sentinel = object()
self.dying = sentinel
print("Room %s dying" % self.id)
await asyncio.sleep(60)
if self.dying is sentinel:
# If it's not sentinel, we got revived. Maybe the
# other connection is in dying mode, maybe not;
# either way, we aren't in charge of death.
assert not self.clients
del rooms[self.id]
print("Room %s dead - %d rooms left" % (self.id, len(rooms)))
else:
if self.dying:
print("Room %s revived-but-still-dying" % self.id)
else:
print("Room %s revived" % self.id)
def route(url):
def deco(f):
app.router.add_get(url, f)
return f
return deco
@route("/")
async def home(req):
with open("client/index.html") as f:
return web.Response(text=f.read(), content_type="text/html")
@route("/ws")
async def websocket(req):
ws = web.WebSocketResponse()
await ws.prepare(req)
async for msg in ws:
if msg.type != WSMsgType.TEXT: continue
try:
msg = json.loads(msg.data)
if msg["type"] != "login": continue
room = msg["data"]["room"][:32]
if room: break
except (ValueError, KeyError, TypeError):
# Any parsing error, just wait for another message
continue
else:
# Something went wrong with the handshake. Kick
# the client and let them reconnect.
await ws.close()
return ws
if room not in rooms: Room(room)
return await rooms[room].websocket(ws, msg["data"])
# After all the custom routes, handle everything else by loading static files.
app.router.add_static("/", path="client", name="static")
# Lifted from appension
async def serve_http(loop, port, sock=None):
if sock:
srv = await loop.create_server(app.make_handler(), sock=sock)
else:
srv = await loop.create_server(app.make_handler(), "0.0.0.0", port)
sock = srv.sockets[0]
print("Listening on %s:%s" % sock.getsockname(), file=sys.stderr)
def run(port=8080, sock=None):
loop = asyncio.get_event_loop()
loop.run_until_complete(serve_http(loop, port, sock))
# TODO: Announce that we're "ready" in whatever way
try: loop.run_forever()
except KeyboardInterrupt: pass
if __name__ == '__main__':
# Look for a socket provided by systemd
sock = None
try:
pid = int(os.environ.get("LISTEN_PID", ""))
fd_count = int(os.environ.get("LISTEN_FDS", ""))
except ValueError:
pid = fd_count = 0
if pid == os.getpid() and fd_count >= 1:
# The PID matches - we've been given at least one socket.
# The sd_listen_fds docs say that they should start at FD 3.
sock = socket.socket(fileno=3)
print("Got %d socket(s)" % fd_count, file=sys.stderr)
run(port=int(os.environ.get("PORT", "8080")), sock=sock)