awacke1 commited on
Commit
39abf33
·
verified ·
1 Parent(s): 12176b4

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +449 -0
app.py ADDED
@@ -0,0 +1,449 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app.py 🎭
2
+ # One file, two personalities:
3
+ # - RUN_MODE=api -> FastAPI JSON multiplayer relay
4
+ # - RUN_MODE=admin -> Streamlit admin console
5
+ #
6
+ # Shared state uses SQLite so both processes agree on reality.
7
+ # (In-memory is where bugs go to reproduce. 🐛💞)
8
+
9
+ import os, time, json, secrets, string, sqlite3
10
+ from typing import Any, Dict, Optional, List
11
+
12
+ DB_PATH = os.getenv("GAME_DB_PATH", "/data/game.sqlite") # HF persistent storage uses /data 💾
13
+ CORS_ORIGINS = os.getenv("CORS_ORIGINS", "https://allaiinc.org").split(",")
14
+
15
+ def now_i() -> int:
16
+ return int(time.time())
17
+
18
+ def rid(n=6) -> str:
19
+ alphabet = string.ascii_uppercase + string.digits
20
+ return "".join(secrets.choice(alphabet) for _ in range(n))
21
+
22
+ def tok() -> str:
23
+ return secrets.token_urlsafe(24)
24
+
25
+ def db() -> sqlite3.Connection:
26
+ # check_same_thread=False because Streamlit/FastAPI like to multitask 🤹
27
+ conn = sqlite3.connect(DB_PATH, check_same_thread=False)
28
+ conn.row_factory = sqlite3.Row
29
+ return conn
30
+
31
+ def init_db():
32
+ os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
33
+ con = db()
34
+ cur = con.cursor()
35
+ cur.executescript("""
36
+ CREATE TABLE IF NOT EXISTS rooms(
37
+ room TEXT PRIMARY KEY,
38
+ created INTEGER NOT NULL,
39
+ seq INTEGER NOT NULL,
40
+ public_json TEXT NOT NULL
41
+ );
42
+ CREATE TABLE IF NOT EXISTS players(
43
+ token TEXT PRIMARY KEY,
44
+ room TEXT NOT NULL,
45
+ seat INTEGER NOT NULL,
46
+ name TEXT NOT NULL,
47
+ last_seen INTEGER NOT NULL
48
+ );
49
+ CREATE TABLE IF NOT EXISTS private_state(
50
+ room TEXT NOT NULL,
51
+ seat INTEGER NOT NULL,
52
+ private_json TEXT NOT NULL,
53
+ PRIMARY KEY(room, seat)
54
+ );
55
+ CREATE TABLE IF NOT EXISTS events(
56
+ room TEXT NOT NULL,
57
+ seq INTEGER NOT NULL,
58
+ t INTEGER NOT NULL,
59
+ kind TEXT NOT NULL,
60
+ data_json TEXT NOT NULL,
61
+ PRIMARY KEY(room, seq)
62
+ );
63
+ """)
64
+ con.commit()
65
+ con.close()
66
+
67
+ def room_get(con, room: str):
68
+ r = con.execute("SELECT * FROM rooms WHERE room=?", (room,)).fetchone()
69
+ return r
70
+
71
+ def event_append(con, room: str, kind: str, data: Dict[str, Any]) -> int:
72
+ r = room_get(con, room)
73
+ if not r:
74
+ raise ValueError("room not found")
75
+ seq = int(r["seq"]) + 1
76
+ con.execute("UPDATE rooms SET seq=? WHERE room=?", (seq, room))
77
+ con.execute(
78
+ "INSERT INTO events(room, seq, t, kind, data_json) VALUES(?,?,?,?,?)",
79
+ (room, seq, now_i(), kind, json.dumps(data, ensure_ascii=False))
80
+ )
81
+ return seq
82
+
83
+ def players_roster(con, room: str) -> List[Dict[str, Any]]:
84
+ rows = con.execute("SELECT seat, name, last_seen FROM players WHERE room=? ORDER BY seat", (room,)).fetchall()
85
+ return [dict(r) for r in rows]
86
+
87
+ def player_by_token(con, token: str):
88
+ return con.execute("SELECT * FROM players WHERE token=?", (token,)).fetchone()
89
+
90
+ def public_get(con, room: str) -> Dict[str, Any]:
91
+ r = room_get(con, room)
92
+ if not r:
93
+ raise KeyError("room")
94
+ return json.loads(r["public_json"])
95
+
96
+ def public_set(con, room: str, obj: Dict[str, Any]):
97
+ con.execute("UPDATE rooms SET public_json=? WHERE room=?", (json.dumps(obj, ensure_ascii=False), room))
98
+
99
+ def private_get(con, room: str, seat: int) -> Dict[str, Any]:
100
+ r = con.execute("SELECT private_json FROM private_state WHERE room=? AND seat=?", (room, seat)).fetchone()
101
+ return json.loads(r["private_json"]) if r else {}
102
+
103
+ def private_set(con, room: str, seat: int, obj: Dict[str, Any]):
104
+ con.execute(
105
+ "INSERT INTO private_state(room, seat, private_json) VALUES(?,?,?) "
106
+ "ON CONFLICT(room, seat) DO UPDATE SET private_json=excluded.private_json",
107
+ (room, seat, json.dumps(obj, ensure_ascii=False))
108
+ )
109
+
110
+ def cleanup_inactive(con, room: str, ttl: int = 180):
111
+ # “If you haven’t pinged me in 3 minutes, I assume you went to get snacks.” 🍪
112
+ cutoff = now_i() - ttl
113
+ gone = con.execute("SELECT seat, name FROM players WHERE room=? AND last_seen < ?", (room, cutoff)).fetchall()
114
+ if gone:
115
+ con.execute("DELETE FROM players WHERE room=? AND last_seen < ?", (room, cutoff))
116
+ for g in gone:
117
+ event_append(con, room, "leave", {"seat": int(g["seat"]), "name": g["name"], "why": "timeout"})
118
+
119
+ # -----------------------------
120
+ # FastAPI mode 🧠⚡
121
+ # -----------------------------
122
+ if os.getenv("RUN_MODE", "api") == "api":
123
+ from fastapi import FastAPI, HTTPException
124
+ from fastapi.middleware.cors import CORSMiddleware
125
+ from pydantic import BaseModel
126
+
127
+ init_db()
128
+ api = FastAPI(title="AllAIINC Multiplayer Relay", version="0.1")
129
+
130
+ api.add_middleware(
131
+ CORSMiddleware,
132
+ allow_origins=[o.strip() for o in CORS_ORIGINS if o.strip()],
133
+ allow_credentials=True,
134
+ allow_methods=["*"],
135
+ allow_headers=["*"],
136
+ )
137
+
138
+ class CreateRoomIn(BaseModel):
139
+ name: str = "Host"
140
+
141
+ class JoinRoomIn(BaseModel):
142
+ room: str
143
+ name: str = "Player"
144
+
145
+ class CmdIn(BaseModel):
146
+ room: str
147
+ token: str
148
+ cmd: str
149
+
150
+ class ActionIn(BaseModel):
151
+ room: str
152
+ token: str
153
+ kind: str
154
+ payload: Dict[str, Any] = {}
155
+
156
+ # --- “Easy words” multiplayer language ---
157
+ # verbs: say, put, get, add, del, who, list, ping, clear
158
+ # all stored as events + public kv (plus private kv per player if needed)
159
+
160
+ def parse_cmd(cmd: str):
161
+ cmd = (cmd or "").strip()
162
+ parts = cmd.split()
163
+ verb = parts[0].lower() if parts else ""
164
+ rest = parts[1:]
165
+ return verb, rest
166
+
167
+ @api.get("/api/health")
168
+ def health():
169
+ return {"ok": True, "t": now_i(), "db": DB_PATH}
170
+
171
+ @api.post("/api/room/create")
172
+ def room_create(body: CreateRoomIn):
173
+ con = db()
174
+ try:
175
+ room = rid()
176
+ token_ = tok()
177
+ public = {"turnSeat": 0, "kv": {}, "joke": "Why did the packet cross the road? To get ACK’d. 🐔📨"}
178
+ con.execute(
179
+ "INSERT INTO rooms(room, created, seq, public_json) VALUES(?,?,?,?)",
180
+ (room, now_i(), 0, json.dumps(public, ensure_ascii=False))
181
+ )
182
+ con.execute(
183
+ "INSERT INTO players(token, room, seat, name, last_seen) VALUES(?,?,?,?,?)",
184
+ (token_, room, 0, body.name[:24], now_i())
185
+ )
186
+ private_set(con, room, 0, {"kv": {}, "note": "host private pocket 🧥"})
187
+ event_append(con, room, "join", {"seat": 0, "name": body.name[:24]})
188
+ con.commit()
189
+ return {"room": room, "token": token_, "seat": 0}
190
+ finally:
191
+ con.close()
192
+
193
+ @api.post("/api/room/join")
194
+ def room_join(body: JoinRoomIn):
195
+ con = db()
196
+ try:
197
+ if not room_get(con, body.room):
198
+ raise HTTPException(404, "Room not found")
199
+ cleanup_inactive(con, body.room)
200
+
201
+ # seat = smallest unused
202
+ used = {int(r["seat"]) for r in con.execute("SELECT seat FROM players WHERE room=?", (body.room,)).fetchall()}
203
+ seat = 0
204
+ while seat in used:
205
+ seat += 1
206
+
207
+ token_ = tok()
208
+ con.execute(
209
+ "INSERT INTO players(token, room, seat, name, last_seen) VALUES(?,?,?,?,?)",
210
+ (token_, body.room, seat, body.name[:24], now_i())
211
+ )
212
+ private_set(con, body.room, seat, {"kv": {}, "hand": []})
213
+ seq = event_append(con, body.room, "join", {"seat": seat, "name": body.name[:24]})
214
+ con.commit()
215
+ return {"room": body.room, "token": token_, "seat": seat, "seq": seq}
216
+ finally:
217
+ con.close()
218
+
219
+ @api.get("/api/state")
220
+ def state(room: str, token: str, since: int = 0):
221
+ con = db()
222
+ try:
223
+ if not room_get(con, room):
224
+ raise HTTPException(404, "Room not found")
225
+ p = player_by_token(con, token)
226
+ if not p or p["room"] != room:
227
+ raise HTTPException(401, "Bad token")
228
+ con.execute("UPDATE players SET last_seen=? WHERE token=?", (now_i(), token))
229
+ cleanup_inactive(con, room)
230
+
231
+ pub = public_get(con, room)
232
+ seat = int(p["seat"])
233
+ priv = private_get(con, room, seat)
234
+
235
+ evs = con.execute(
236
+ "SELECT seq, t, kind, data_json FROM events WHERE room=? AND seq > ? ORDER BY seq",
237
+ (room, since)
238
+ ).fetchall()
239
+
240
+ return {
241
+ "seq": int(room_get(con, room)["seq"]),
242
+ "you": {"seat": seat, "name": p["name"]},
243
+ "roster": players_roster(con, room),
244
+ "public": pub,
245
+ "private": priv,
246
+ "events": [{"seq": int(e["seq"]), "t": int(e["t"]), "kind": e["kind"], "data": json.loads(e["data_json"])} for e in evs],
247
+ }
248
+ finally:
249
+ con.close()
250
+
251
+ @api.post("/api/cmd")
252
+ def cmd_run(body: CmdIn):
253
+ con = db()
254
+ try:
255
+ if not room_get(con, body.room):
256
+ raise HTTPException(404, "Room not found")
257
+ p = player_by_token(con, body.token)
258
+ if not p or p["room"] != body.room:
259
+ raise HTTPException(401, "Bad token")
260
+
261
+ seat = int(p["seat"])
262
+ name = p["name"]
263
+ con.execute("UPDATE players SET last_seen=? WHERE token=?", (now_i(), body.token))
264
+ cleanup_inactive(con, body.room)
265
+
266
+ pub = public_get(con, body.room)
267
+ pub.setdefault("kv", {})
268
+ priv = private_get(con, body.room, seat)
269
+ priv.setdefault("kv", {})
270
+
271
+ verb, rest = parse_cmd(body.cmd)
272
+
273
+ if verb in ("help", ""):
274
+ return {"ok": True, "msg": "verbs: join, say, put, get, add, del, who, list, ping, clear"}
275
+
276
+ if verb == "ping":
277
+ seq = event_append(con, body.room, "ping", {"seat": seat, "name": name})
278
+ con.commit()
279
+ return {"ok": True, "msg": "pong 🏓", "seq": seq}
280
+
281
+ if verb == "who":
282
+ return {"ok": True, "players": players_roster(con, body.room)}
283
+
284
+ if verb == "list":
285
+ return {"ok": True, "keys": sorted(pub["kv"].keys())}
286
+
287
+ if verb == "say":
288
+ msg = " ".join(rest).strip()[:240]
289
+ if not msg:
290
+ raise HTTPException(400, "Usage: say <message>")
291
+ seq = event_append(con, body.room, "say", {"seat": seat, "name": name, "text": msg})
292
+ con.commit()
293
+ return {"ok": True, "msg": "sent 🗣️", "seq": seq}
294
+
295
+ if verb == "put":
296
+ if len(rest) < 2:
297
+ raise HTTPException(400, "Usage: put <key> <value>")
298
+ k = rest[0]
299
+ v = " ".join(rest[1:])[:240]
300
+ pub["kv"][k] = v
301
+ public_set(con, body.room, pub)
302
+ seq = event_append(con, body.room, "put", {"seat": seat, "key": k, "value": v})
303
+ con.commit()
304
+ return {"ok": True, "msg": f"ok ✅ stored {k}", "seq": seq}
305
+
306
+ if verb == "get":
307
+ if len(rest) != 1:
308
+ raise HTTPException(400, "Usage: get <key>")
309
+ k = rest[0]
310
+ return {"ok": True, "key": k, "value": pub["kv"].get(k)}
311
+
312
+ if verb == "add":
313
+ if len(rest) < 2:
314
+ raise HTTPException(400, "Usage: add <key> <number>")
315
+ k = rest[0]
316
+ try:
317
+ amt = float(rest[1])
318
+ except:
319
+ raise HTTPException(400, "add needs a number, e.g. add score 5")
320
+ cur = pub["kv"].get(k, 0)
321
+ try:
322
+ cur = float(cur)
323
+ except:
324
+ cur = 0.0
325
+ pub["kv"][k] = cur + amt
326
+ public_set(con, body.room, pub)
327
+ seq = event_append(con, body.room, "add", {"seat": seat, "key": k, "amt": amt, "new": pub["kv"][k]})
328
+ con.commit()
329
+ return {"ok": True, "msg": f"{k} -> {pub['kv'][k]} 📈", "seq": seq}
330
+
331
+ if verb == "del":
332
+ if len(rest) != 1:
333
+ raise HTTPException(400, "Usage: del <key>")
334
+ k = rest[0]
335
+ pub["kv"].pop(k, None)
336
+ public_set(con, body.room, pub)
337
+ seq = event_append(con, body.room, "del", {"seat": seat, "key": k})
338
+ con.commit()
339
+ return {"ok": True, "msg": f"deleted {k} 🧹", "seq": seq}
340
+
341
+ if verb == "clear":
342
+ if seat != 0:
343
+ raise HTTPException(403, "Only seat 0 can clear")
344
+ pub["kv"] = {}
345
+ public_set(con, body.room, pub)
346
+ seq = event_append(con, body.room, "clear", {"by": name})
347
+ con.commit()
348
+ return {"ok": True, "msg": "cleared 🧼", "seq": seq}
349
+
350
+ raise HTTPException(400, f"Unknown verb: {verb}")
351
+ finally:
352
+ con.close()
353
+
354
+ @api.post("/api/action")
355
+ def action(body: ActionIn):
356
+ # “action” is for structured game moves (turn-based etc.)
357
+ con = db()
358
+ try:
359
+ if not room_get(con, body.room):
360
+ raise HTTPException(404, "Room not found")
361
+ p = player_by_token(con, body.token)
362
+ if not p or p["room"] != body.room:
363
+ raise HTTPException(401, "Bad token")
364
+
365
+ seat = int(p["seat"])
366
+ name = p["name"]
367
+ con.execute("UPDATE players SET last_seen=? WHERE token=?", (now_i(), body.token))
368
+ cleanup_inactive(con, body.room)
369
+
370
+ pub = public_get(con, body.room)
371
+
372
+ # ultra-minimal example: a shared “pot” with turn enforcement
373
+ pub.setdefault("turnSeat", 0)
374
+ pub.setdefault("pot", 0)
375
+
376
+ if seat != int(pub["turnSeat"]):
377
+ raise HTTPException(409, "Not your turn")
378
+
379
+ if body.kind == "add_to_pot":
380
+ amt = int(body.payload.get("amt", 1))
381
+ pub["pot"] += max(0, amt)
382
+ else:
383
+ raise HTTPException(400, "Unknown kind")
384
+
385
+ # advance turn
386
+ seats = [r["seat"] for r in players_roster(con, body.room)]
387
+ seats = sorted(int(s) for s in seats)
388
+ i = seats.index(seat)
389
+ pub["turnSeat"] = seats[(i+1) % len(seats)]
390
+
391
+ public_set(con, body.room, pub)
392
+ seq = event_append(con, body.room, "action", {"seat": seat, "name": name, "kind": body.kind, "payload": body.payload})
393
+ con.commit()
394
+ return {"ok": True, "seq": seq, "public": pub}
395
+ finally:
396
+ con.close()
397
+
398
+ # -----------------------------
399
+ # Streamlit admin mode 🧑‍🔧🧠
400
+ # -----------------------------
401
+ else:
402
+ import streamlit as st
403
+ import pandas as pd
404
+ import requests
405
+
406
+ init_db()
407
+
408
+ st.set_page_config(page_title="Admin — Multiplayer Relay", layout="wide")
409
+ st.title("🧑‍✈️ Admin Console — Multiplayer Relay")
410
+ st.caption("If you can read this, nginx routing worked. If you can’t, blame gremlins. 👹")
411
+
412
+ con = db()
413
+ rooms = con.execute("SELECT room, created, seq FROM rooms ORDER BY created DESC").fetchall()
414
+ con.close()
415
+
416
+ if not rooms:
417
+ st.info("No rooms yet. Create one via /api/room/create from your web client.")
418
+ st.stop()
419
+
420
+ df_rooms = pd.DataFrame([dict(r) for r in rooms])
421
+ st.dataframe(df_rooms, use_container_width=True, hide_index=True)
422
+
423
+ room_id = st.selectbox("Pick a room", df_rooms["room"].tolist())
424
+ col1, col2, col3 = st.columns([1,1,1])
425
+
426
+ if col1.button("🔄 Refresh"):
427
+ st.rerun()
428
+
429
+ # Show room details
430
+ con = db()
431
+ room = con.execute("SELECT * FROM rooms WHERE room=?", (room_id,)).fetchone()
432
+ roster = con.execute("SELECT seat, name, last_seen FROM players WHERE room=? ORDER BY seat", (room_id,)).fetchall()
433
+ events = con.execute("SELECT seq, t, kind, data_json FROM events WHERE room=? ORDER BY seq DESC LIMIT 80", (room_id,)).fetchall()
434
+ con.close()
435
+
436
+ st.subheader(f"🧩 Room `{room_id}`")
437
+ st.write("**Public**")
438
+ st.json(json.loads(room["public_json"]))
439
+
440
+ st.write("**Players**")
441
+ st.dataframe(pd.DataFrame([dict(r) for r in roster]), use_container_width=True, hide_index=True)
442
+
443
+ st.write("**Recent events**")
444
+ ev_df = pd.DataFrame([{
445
+ "seq": int(e["seq"]), "t": int(e["t"]), "kind": e["kind"], "data": json.loads(e["data_json"])
446
+ } for e in events])
447
+ st.dataframe(ev_df, use_container_width=True, hide_index=True)
448
+
449
+ st.caption("Joke: A SQL query walks into a bar and says: ‘Can I join you?’ 🍻🧾")