From 66911dc450394babfb835a4d5a774d5182e1e31e Mon Sep 17 00:00:00 2001 From: root Date: Tue, 8 Sep 2026 18:21:10 +0100 Subject: [PATCH] First git commit --- bbs/bbs.log | 17 + bbs/bbsd.py | 241 +++++++++ bbs/bbsdb.py | 126 +++++ bbs/boards.py | 250 +++++++++ bbs/games.py | 323 +++++++++++ bbs/mud.py | 511 ++++++++++++++++++ bbs/screens.py | 305 +++++++++++ bbs/term.py | 163 ++++++ bbs/txt3-bbs.service | 43 ++ public_html/.htaccess | 16 + public_html/about.php | 96 ++++ public_html/admin/forums.php | 88 +++ public_html/admin/index.php | 40 ++ public_html/admin/topics.php | 76 +++ public_html/admin/users.php | 125 +++++ .../bbs/__pycache__/bbsdb.cpython-311.pyc | Bin 0 -> 6100 bytes .../bbs/__pycache__/boards.cpython-311.pyc | Bin 0 -> 16843 bytes .../bbs/__pycache__/games.cpython-311.pyc | Bin 0 -> 21279 bytes .../bbs/__pycache__/mud.cpython-311.pyc | Bin 0 -> 33860 bytes .../bbs/__pycache__/screens.cpython-311.pyc | Bin 0 -> 21295 bytes .../bbs/__pycache__/term.cpython-311.pyc | Bin 0 -> 8263 bytes public_html/bbs/bbsd.py | 241 +++++++++ public_html/bbs/bbsdb.py | 126 +++++ public_html/bbs/boards.py | 250 +++++++++ public_html/bbs/games.py | 323 +++++++++++ public_html/bbs/mud.py | 511 ++++++++++++++++++ public_html/bbs/screens.py | 305 +++++++++++ public_html/bbs/term.py | 163 ++++++ public_html/bbs/txt3-bbs.service | 43 ++ public_html/compose.php | 50 ++ public_html/data/wap.sqlite | Bin 0 -> 143360 bytes public_html/forum.php | 87 +++ public_html/games.php | 23 + public_html/games/guess.php | 74 +++ public_html/games/mplib.php | 82 +++ public_html/games/nim.php | 95 ++++ public_html/games/quiz.php | 82 +++ public_html/games/scores.php | 30 + public_html/games/ttt.php | 116 ++++ public_html/google-verify-PLACEHOLDER.html | 1 + public_html/inbox.php | 46 ++ public_html/index.html | 136 +++++ public_html/index.php | 41 ++ public_html/lib/auth.php | 117 ++++ public_html/lib/bootstrap.php | 14 + public_html/lib/config.php | 14 + public_html/lib/db.php | 344 ++++++++++++ public_html/lib/mud.php | 305 +++++++++++ public_html/lib/ui.php | 317 +++++++++++ public_html/login.php | 20 + public_html/logout.php | 8 + public_html/message.php | 35 ++ public_html/mud.php | 242 +++++++++ public_html/page1.php | 13 + public_html/page2.php | 17 + public_html/page3.php | 13 + public_html/page4.php | 14 + public_html/password.php | 30 + public_html/profile.php | 54 ++ public_html/robots.txt | 8 + public_html/signup.php | 29 + public_html/sitemap.php | 34 ++ public_html/style.css | 33 ++ public_html/topic.php | 83 +++ public_html/users.php | 23 + 65 files changed, 7012 insertions(+) create mode 100644 bbs/bbs.log create mode 100644 bbs/bbsd.py create mode 100644 bbs/bbsdb.py create mode 100644 bbs/boards.py create mode 100644 bbs/games.py create mode 100644 bbs/mud.py create mode 100644 bbs/screens.py create mode 100644 bbs/term.py create mode 100644 bbs/txt3-bbs.service create mode 100644 public_html/.htaccess create mode 100644 public_html/about.php create mode 100644 public_html/admin/forums.php create mode 100644 public_html/admin/index.php create mode 100644 public_html/admin/topics.php create mode 100644 public_html/admin/users.php create mode 100644 public_html/bbs/__pycache__/bbsdb.cpython-311.pyc create mode 100644 public_html/bbs/__pycache__/boards.cpython-311.pyc create mode 100644 public_html/bbs/__pycache__/games.cpython-311.pyc create mode 100644 public_html/bbs/__pycache__/mud.cpython-311.pyc create mode 100644 public_html/bbs/__pycache__/screens.cpython-311.pyc create mode 100644 public_html/bbs/__pycache__/term.cpython-311.pyc create mode 100644 public_html/bbs/bbsd.py create mode 100644 public_html/bbs/bbsdb.py create mode 100644 public_html/bbs/boards.py create mode 100644 public_html/bbs/games.py create mode 100644 public_html/bbs/mud.py create mode 100644 public_html/bbs/screens.py create mode 100644 public_html/bbs/term.py create mode 100644 public_html/bbs/txt3-bbs.service create mode 100644 public_html/compose.php create mode 100644 public_html/data/wap.sqlite create mode 100644 public_html/forum.php create mode 100644 public_html/games.php create mode 100644 public_html/games/guess.php create mode 100644 public_html/games/mplib.php create mode 100644 public_html/games/nim.php create mode 100644 public_html/games/quiz.php create mode 100644 public_html/games/scores.php create mode 100644 public_html/games/ttt.php create mode 100644 public_html/google-verify-PLACEHOLDER.html create mode 100644 public_html/inbox.php create mode 100644 public_html/index.html create mode 100644 public_html/index.php create mode 100644 public_html/lib/auth.php create mode 100644 public_html/lib/bootstrap.php create mode 100644 public_html/lib/config.php create mode 100644 public_html/lib/db.php create mode 100644 public_html/lib/mud.php create mode 100644 public_html/lib/ui.php create mode 100644 public_html/login.php create mode 100644 public_html/logout.php create mode 100644 public_html/message.php create mode 100644 public_html/mud.php create mode 100644 public_html/page1.php create mode 100644 public_html/page2.php create mode 100644 public_html/page3.php create mode 100644 public_html/page4.php create mode 100644 public_html/password.php create mode 100644 public_html/profile.php create mode 100644 public_html/robots.txt create mode 100644 public_html/signup.php create mode 100644 public_html/sitemap.php create mode 100644 public_html/style.css create mode 100644 public_html/topic.php create mode 100644 public_html/users.php diff --git a/bbs/bbs.log b/bbs/bbs.log new file mode 100644 index 0000000..d4cc633 --- /dev/null +++ b/bbs/bbs.log @@ -0,0 +1,17 @@ +[2026-08-17 21:00:54] txt3 BBS listening on 100.127.96.105:12300 (db=/home/txt3/domains/wap.txt3.net/public_html/data/wap.sqlite) +[2026-08-17 21:01:27] connect from 100.127.96.105 +[2026-08-17 21:01:29] disconnect 100.127.96.105 +[2026-08-17 21:02:36] connect from 100.127.96.105 +[2026-08-17 21:02:41] disconnect 100.127.96.105 +[2026-08-17 21:04:03] connect from 100.127.96.105 +[2026-08-17 21:04:09] disconnect 100.127.96.105 +[2026-08-17 21:04:31] connect from 100.83.66.92 +[2026-08-17 21:16:16] hangup 100.83.66.92 +[2026-08-17 21:16:16] disconnect 100.83.66.92 +[2026-08-17 22:10:28] txt3 BBS listening on 100.127.96.105:12300 (db=/home/txt3/domains/wap.txt3.net/public_html/data/wap.sqlite) +[2026-08-17 22:11:15] connect from 100.127.96.105 +[2026-08-17 22:11:25] disconnect 100.127.96.105 +[2026-08-18 14:54:13] txt3 BBS listening on 100.127.96.105:12300 (db=/home/txt3/domains/wap.txt3.net/public_html/data/wap.sqlite) +[2026-08-18 14:55:31] connect from 100.127.96.105 +[2026-08-18 14:55:46] disconnect 100.127.96.105 +[2026-08-18 15:23:26] txt3 BBS listening on 100.127.96.105:12300 (db=/home/txt3/domains/wap.txt3.net/public_html/data/wap.sqlite) diff --git a/bbs/bbsd.py b/bbs/bbsd.py new file mode 100644 index 0000000..79a24ac --- /dev/null +++ b/bbs/bbsd.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +"""txt3 BBS - telnet front-end for wap.txt3.net. + +Shares the SQLite database with the WML/XHTML site: one account works on all +three front-ends, and multiplayer matches are cross-playable between them. + +Binds to the tailscale interface only by default. +""" +import os +import socket +import socketserver +import sys +import threading +import time + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import bbsdb # noqa: E402 +from bbsdb import connect, q1, login, create_user, user_by_name, touch # noqa: E402 +from term import Term, Hangup # noqa: E402 +import boards # noqa: E402 +import games # noqa: E402 +import mud # noqa: E402 +import screens # noqa: E402 + +HOST = os.environ.get("BBS_HOST", "100.127.96.105") +PORT = int(os.environ.get("BBS_PORT", "12300")) +MAX_SESSIONS = int(os.environ.get("BBS_MAX", "20")) + +_sessions = threading.BoundedSemaphore(MAX_SESSIONS) + +BANNER = r""" + _ _ _____ ____ ____ ____ + | |___ _| |_|___ / | __ )| __ ) ___| + | __\ \/ / __| |_ \ | _ \| _ \___ \ + | |_ > <| |_ ___) | | |_) | |_) |__) | + \__/_/\_\\__|____/ |____/|____/____/ +""" + + +def log(msg): + sys.stdout.write("[%s] %s\n" % (time.strftime("%Y-%m-%d %H:%M:%S"), msg)) + sys.stdout.flush() + + +def welcome(t, con): + t.write(BANNER) + motd = q1(con, "SELECT v FROM settings WHERE k='motd'") + t.line(" " + (motd["v"] if motd else "Welcome!")) + users = q1(con, "SELECT COUNT(*) c FROM users")["c"] + posts = q1(con, "SELECT COUNT(*) c FROM posts")["c"] + t.line(" %d members, %d posts. Also on the web: https://wap.txt3.net" % (users, posts)) + t.rule("=") + + +def do_login(t, con): + """Returns a user row, or None if the caller gave up.""" + for _ in range(3): + t.line("\n [L]ogin [N]ew user [G]uest look around [Q]uit") + c = t.ask("\n> ").lower() + if c.startswith("l"): + name = t.ask(" Username: ", maxlen=16) + if not name: + continue + pw = t.secret(" Password: ") + u = login(con, name, pw) + if u: + t.line("\n Welcome back, %s." % u["username"]) + return u + t.line(" Login failed (bad details, or account banned).") + elif c.startswith("n"): + u = do_signup(t, con) + if u: + return u + elif c.startswith("g"): + return "guest" + elif c.startswith("q"): + return None + return None + + +def do_signup(t, con): + t.line("\n New account:") + name = t.ask(" Choose a username (3-16, letters/digits/_): ", maxlen=16) + if not name: + return None + if not name.replace("_", "").isalnum() or not 3 <= len(name) <= 16: + t.line(" Invalid username.") + return None + if user_by_name(con, name): + t.line(" That name is taken.") + return None + pw = t.secret(" Choose a password (min 4): ") + if len(pw) < 4: + t.line(" Too short.") + return None + if pw != t.secret(" Repeat password: "): + t.line(" Passwords did not match.") + return None + uid = create_user(con, name, pw) + if not uid: + t.line(" Could not create the account (hashing failed).") + return None + t.line("\n Account created. It works on https://wap.txt3.net too.") + return bbsdb.user_by_id(con, uid) + + +def guest_menu(t, con): + while True: + t.header("GUEST - read only") + t.line(" [F]orums (read) [U]ser list [S]cores [L]ogin/signup [Q]uit") + c = t.ask("\nguest> ").lower() + if c.startswith("f"): + boards.forum_menu(t, con, {"id": -1, "is_admin": 0, "username": "guest"}) + elif c.startswith("u"): + screens.userlist(t, con) + elif c.startswith("s"): + games.scores(t, con) + elif c.startswith("l"): + return "login" + elif c.startswith("q") or c == "": + return None + + +def main_menu(t, con, me): + while True: + me = bbsdb.user_by_id(con, me["id"]) + if not me or me["is_banned"]: + t.line("\n Your account is no longer active. Goodbye.") + return + touch(con, me["id"]) + n = bbsdb.unread(con, me["id"]) + t.header("MAIN MENU - %s%s" % (me["username"], + " [admin]" if me["is_admin"] else "")) + t.line(" [M]ail %s" % ("(%d unread)" % n if n else "")) + t.line(" [F]orums") + t.line(" [G]ames") + t.line(" [D]ungeon - explore a shared world (also on the web)") + t.line(" [P]rofile") + t.line(" [U]ser list") + if me["is_admin"]: + t.line(" [A]dmin tools") + t.line(" [Q]uit") + c = t.ask("\nmain> ").lower() + if c.startswith("m"): + boards.mail_menu(t, con, me) + elif c.startswith("f"): + boards.forum_menu(t, con, me) + elif c.startswith("g"): + games.games_menu(t, con, me) + elif c.startswith("d"): + mud.mud_menu(t, con, me) + elif c.startswith("p"): + screens.profile_menu(t, con, me) + elif c.startswith("u"): + screens.userlist(t, con) + elif c.startswith("a") and me["is_admin"]: + screens.admin_menu(t, con, me) + elif c.startswith("q"): + return + + +class Handler(socketserver.BaseRequestHandler): + def handle(self): + if not _sessions.acquire(blocking=False): + try: + self.request.sendall(b"\r\nBBS is full, try later.\r\n") + self.request.close() + except OSError: + pass + return + con = None + t = None + ip = self.client_address[0] + log("connect from %s" % ip) + try: + t = Term(self.request, self.client_address) + con = connect() + welcome(t, con) + while True: + who = do_login(t, con) + if who is None: + break + if who == "guest": + if guest_menu(t, con) == "login": + continue + break + main_menu(t, con, who) + break + if t: + t.line("\n Goodbye - 73s from txt3 BBS.\n") + except Hangup: + log("hangup %s" % ip) + except Exception as e: # keep one session's bug local + log("ERROR %s: %r" % (ip, e)) + try: + t.line("\n Internal error - disconnecting.\n") + except Exception: + pass + finally: + if con is not None: + try: + con.close() + except Exception: + pass + try: + self.request.close() + except OSError: + pass + _sessions.release() + log("disconnect %s" % ip) + + +class Server(socketserver.ThreadingTCPServer): + allow_reuse_address = True + daemon_threads = True + address_family = socket.AF_INET + + +def main(): + # Fail loudly if the shared database is not reachable. + try: + con = connect() + con.execute("SELECT 1 FROM users LIMIT 1") + con.close() + except Exception as e: + log("FATAL: cannot open database %s: %r" % (bbsdb.DB_PATH, e)) + return 1 + srv = Server((HOST, PORT), Handler) + log("txt3 BBS listening on %s:%d (db=%s)" % (HOST, PORT, bbsdb.DB_PATH)) + try: + srv.serve_forever() + except KeyboardInterrupt: + log("shutting down") + finally: + srv.server_close() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/bbs/bbsdb.py b/bbs/bbsdb.py new file mode 100644 index 0000000..bc811d0 --- /dev/null +++ b/bbs/bbsdb.py @@ -0,0 +1,126 @@ +"""Shared data layer for the txt3 BBS (telnet front-end). + +Talks to the exact same SQLite database as the WAP/XHTML site, so accounts, +mail, forums and scores are identical across all three front-ends. + +Password hashes are PHP `password_hash()` bcrypt ($2y$) values. Rather than +depend on a bcrypt wheel, we verify/create them by calling the php binary, +passing values through the environment so nothing is ever interpolated into +a shell string. +""" +import os +import json +import sqlite3 +import subprocess +import time + +DB_PATH = os.environ.get( + "BBS_DB", "/home/txt3/domains/wap.txt3.net/public_html/data/wap.sqlite" +) +PHP_BIN = os.environ.get("BBS_PHP", "/usr/bin/php") + + +def connect(): + con = sqlite3.connect(DB_PATH, timeout=10) + con.row_factory = sqlite3.Row + con.execute("PRAGMA journal_mode = WAL") + con.execute("PRAGMA foreign_keys = ON") + con.execute("PRAGMA busy_timeout = 5000") + return con + + +def q(con, sql, args=()): + return con.execute(sql, args).fetchall() + + +def q1(con, sql, args=()): + r = con.execute(sql, args).fetchone() + return r + + +def ex(con, sql, args=()): + cur = con.execute(sql, args) + con.commit() + return cur.lastrowid + + +# ---------------------------------------------------------------- passwords +def _php(code, env): + e = dict(os.environ) + e.update(env) + try: + out = subprocess.run( + [PHP_BIN, "-r", code], + capture_output=True, text=True, timeout=10, env=e, + ) + return out.stdout.strip() + except Exception: + return "" + + +def verify_password(plain, hashed): + return _php( + 'echo password_verify(getenv("BP_P"), getenv("BP_H")) ? "1" : "0";', + {"BP_P": plain, "BP_H": hashed}, + ) == "1" + + +def hash_password(plain): + h = _php( + 'echo password_hash(getenv("BP_P"), PASSWORD_DEFAULT);', + {"BP_P": plain}, + ) + return h or None + + +# ---------------------------------------------------------------- users +def user_by_name(con, name): + return q1(con, "SELECT * FROM users WHERE username=? COLLATE NOCASE", (name,)) + + +def user_by_id(con, uid): + return q1(con, "SELECT * FROM users WHERE id=?", (uid,)) + + +def login(con, name, password): + u = user_by_name(con, name) + if not u or u["is_banned"]: + return None + if not verify_password(password, u["pass_hash"]): + return None + ex(con, "UPDATE users SET last_seen=? WHERE id=?", (int(time.time()), u["id"])) + return u + + +def create_user(con, name, password): + h = hash_password(password) + if not h: + return None + now = int(time.time()) + uid = ex( + con, + "INSERT INTO users (username,pass_hash,is_admin,created_at,last_seen)" + " VALUES (?,?,0,?,?)", + (name, h, now, now), + ) + return uid + + +def touch(con, uid): + ex(con, "UPDATE users SET last_seen=? WHERE id=?", (int(time.time()), uid)) + + +def unread(con, uid): + return q1(con, "SELECT COUNT(*) c FROM messages WHERE to_id=? AND is_read=0", + (uid,))["c"] + + +def ago(ts): + d = max(0, int(time.time()) - int(ts or 0)) + if d < 60: + return "%ds" % d + if d < 3600: + return "%dm" % (d // 60) + if d < 86400: + return "%dh" % (d // 3600) + return "%dd" % (d // 86400) diff --git a/bbs/boards.py b/bbs/boards.py new file mode 100644 index 0000000..50273e5 --- /dev/null +++ b/bbs/boards.py @@ -0,0 +1,250 @@ +"""Private mail + forum screens for the BBS.""" +import time +from bbsdb import q, q1, ex, user_by_name, unread, ago + +PER_PAGE = 10 + + +# ==================================================================== mail +def mail_menu(t, con, me): + while True: + n = unread(con, me["id"]) + t.header("MAIL (%d unread)" % n) + t.line(" [I]nbox [S]ent [W]rite [Q]uit to main") + c = t.ask("\nmail> ").lower() + if c.startswith("i"): + mail_list(t, con, me, "in") + elif c.startswith("s"): + mail_list(t, con, me, "out") + elif c.startswith("w"): + mail_write(t, con, me) + elif c.startswith("q") or c == "": + return + + +def mail_list(t, con, me, box): + page = 0 + while True: + off = page * PER_PAGE + if box == "in": + rows = q(con, "SELECT m.*, u.username who FROM messages m " + "LEFT JOIN users u ON u.id=m.from_id " + "WHERE m.to_id=? ORDER BY m.id DESC LIMIT ? OFFSET ?", + (me["id"], PER_PAGE + 1, off)) + else: + rows = q(con, "SELECT m.*, u.username who FROM messages m " + "LEFT JOIN users u ON u.id=m.to_id " + "WHERE m.from_id=? ORDER BY m.id DESC LIMIT ? OFFSET ?", + (me["id"], PER_PAGE + 1, off)) + more = len(rows) > PER_PAGE + rows = rows[:PER_PAGE] + + t.header("INBOX" if box == "in" else "SENT") + if not rows: + t.line(" (empty)") + for i, r in enumerate(rows, 1): + flag = "*" if (box == "in" and not r["is_read"]) else " " + t.line(" %s%2d. %-12s %-28s %s" % ( + flag, i, (r["who"] or "[gone]")[:12], r["subject"][:28], + ago(r["created_at"]))) + t.line("\n number=read [N]ext [P]rev [Q]back") + c = t.ask("\nmail> ").lower() + if c.isdigit() and 1 <= int(c) <= len(rows): + mail_read(t, con, me, rows[int(c) - 1]["id"], box) + elif c.startswith("n") and more: + page += 1 + elif c.startswith("p") and page > 0: + page -= 1 + elif c.startswith("q") or c == "": + return + + +def mail_read(t, con, me, mid, box): + m = q1(con, "SELECT m.*, f.username fromname, o.username toname FROM messages m " + "LEFT JOIN users f ON f.id=m.from_id " + "LEFT JOIN users o ON o.id=m.to_id WHERE m.id=?", (mid,)) + if not m or me["id"] not in (m["to_id"], m["from_id"]): + t.line("Not found.") + return + if m["to_id"] == me["id"] and not m["is_read"]: + ex(con, "UPDATE messages SET is_read=1 WHERE id=?", (mid,)) + + t.header("MSG: " + m["subject"][:40]) + t.line(" From: %s" % (m["fromname"] or "[gone]")) + t.line(" To : %s" % (m["toname"] or "[gone]")) + t.line(" Date: %s" % time.strftime("%d/%m/%Y %H:%M", + time.localtime(m["created_at"]))) + t.rule() + for ln in m["body"].splitlines() or [""]: + t.line(" " + ln) + t.rule() + t.line(" [R]eply [D]elete [Q]back") + c = t.ask("\nmsg> ").lower() + if c.startswith("r") and m["fromname"]: + subj = m["subject"] + if not subj.lower().startswith("re:"): + subj = "Re: " + subj + mail_write(t, con, me, to=m["fromname"], subject=subj) + elif c.startswith("d"): + ex(con, "DELETE FROM messages WHERE id=? AND (to_id=? OR from_id=?)", + (mid, me["id"], me["id"])) + t.line("Deleted.") + + +def mail_write(t, con, me, to=None, subject=None): + t.header("WRITE MAIL") + if to is None: + to = t.ask(" To (username): ", maxlen=16) + else: + t.line(" To: " + to) + if not to: + return + dest = user_by_name(con, to) + if not dest: + t.line(" No such user.") + return + if dest["id"] == me["id"]: + t.line(" You cannot mail yourself.") + return + if subject is None: + subject = t.ask(" Subject: ", maxlen=60) + else: + t.line(" Subject: " + subject) + if not subject: + return + t.line(" Body - end with a single '.' on its own line:") + body = read_body(t) + if not body.strip(): + t.line(" Aborted (empty).") + return + ex(con, "INSERT INTO messages (from_id,to_id,subject,body,created_at) " + "VALUES (?,?,?,?,?)", + (me["id"], dest["id"], subject, body[:4000], int(time.time()))) + t.line(" Sent to %s." % dest["username"]) + + +def read_body(t, maxlines=40): + """Multi-line entry terminated by a lone '.' - the classic BBS editor.""" + lines = [] + while len(lines) < maxlines: + ln = t.read(" | ", maxlen=200) + if ln == ".": + break + lines.append(ln) + return "\n".join(lines) + + +# ==================================================================== forum +def forum_menu(t, con, me): + while True: + rows = q(con, "SELECT f.*, " + "(SELECT COUNT(*) FROM topics x WHERE x.forum_id=f.id) tc " + "FROM forums f ORDER BY f.sort_order, f.id") + t.header("FORUMS") + for i, r in enumerate(rows, 1): + t.line(" %2d. %-24s %3d topics%s" % ( + i, r["name"][:24], r["tc"], " [locked]" if r["is_locked"] else "")) + t.line("\n number=open [Q]back") + c = t.ask("\nforum> ").lower() + if c.isdigit() and 1 <= int(c) <= len(rows): + topic_list(t, con, me, rows[int(c) - 1]) + elif c.startswith("q") or c == "": + return + + +def topic_list(t, con, me, forum): + page = 0 + while True: + off = page * PER_PAGE + rows = q(con, "SELECT t.*, u.username, " + "(SELECT COUNT(*) FROM posts p WHERE p.topic_id=t.id) pc " + "FROM topics t LEFT JOIN users u ON u.id=t.user_id " + "WHERE t.forum_id=? ORDER BY t.bumped_at DESC LIMIT ? OFFSET ?", + (forum["id"], PER_PAGE + 1, off)) + more = len(rows) > PER_PAGE + rows = rows[:PER_PAGE] + + t.header("FORUM: " + forum["name"]) + if not rows: + t.line(" (no topics yet)") + for i, r in enumerate(rows, 1): + t.line(" %2d.%s %-30s %3dp %-10s %s" % ( + i, "L" if r["is_locked"] else " ", r["title"][:30], r["pc"], + (r["username"] or "?")[:10], ago(r["bumped_at"]))) + t.line("\n number=read [N]ew topic [M]ore [P]rev [Q]back") + c = t.ask("\ntopics> ").lower() + if c.isdigit() and 1 <= int(c) <= len(rows): + topic_read(t, con, me, rows[int(c) - 1]["id"]) + elif c.startswith("n"): + new_topic(t, con, me, forum) + elif c.startswith("m") and more: + page += 1 + elif c.startswith("p") and page > 0: + page -= 1 + elif c.startswith("q") or c == "": + return + + +def new_topic(t, con, me, forum): + if forum["is_locked"] and not me["is_admin"]: + t.line(" That forum is locked.") + return + title = t.ask(" Topic title: ", maxlen=80) + if not title: + return + t.line(" Body - end with '.' on its own line:") + body = read_body(t) + if not body.strip(): + t.line(" Aborted.") + return + now = int(time.time()) + tid = ex(con, "INSERT INTO topics (forum_id,user_id,title,created_at,bumped_at)" + " VALUES (?,?,?,?,?)", (forum["id"], me["id"], title, now, now)) + ex(con, "INSERT INTO posts (topic_id,user_id,body,created_at) VALUES (?,?,?,?)", + (tid, me["id"], body[:4000], now)) + t.line(" Topic posted.") + + +def topic_read(t, con, me, tid): + page = 0 + while True: + top = q1(con, "SELECT t.*, f.name fname, f.is_locked flocked FROM topics t " + "JOIN forums f ON f.id=t.forum_id WHERE t.id=?", (tid,)) + if not top: + t.line(" Gone.") + return + locked = top["is_locked"] or top["flocked"] + off = page * PER_PAGE + rows = q(con, "SELECT p.*, u.username FROM posts p " + "LEFT JOIN users u ON u.id=p.user_id " + "WHERE p.topic_id=? ORDER BY p.id LIMIT ? OFFSET ?", + (tid, PER_PAGE + 1, off)) + more = len(rows) > PER_PAGE + rows = rows[:PER_PAGE] + + t.header(top["title"][:50] + (" [LOCKED]" if locked else "")) + for r in rows: + t.line(" --- %s, %s ago ---" % ((r["username"] or "[gone]"), + ago(r["created_at"]))) + for ln in r["body"].splitlines() or [""]: + t.line(" " + ln) + t.line("\n [R]eply [M]ore [P]rev [Q]back") + c = t.ask("\ntopic> ").lower() + if c.startswith("r"): + if locked and not me["is_admin"]: + t.line(" Topic is locked.") + continue + t.line(" Reply - end with '.' on its own line:") + body = read_body(t) + if body.strip(): + now = int(time.time()) + ex(con, "INSERT INTO posts (topic_id,user_id,body,created_at)" + " VALUES (?,?,?,?)", (tid, me["id"], body[:4000], now)) + ex(con, "UPDATE topics SET bumped_at=? WHERE id=?", (now, tid)) + t.line(" Posted.") + elif c.startswith("m") and more: + page += 1 + elif c.startswith("p") and page > 0: + page -= 1 + elif c.startswith("q") or c == "": + return diff --git a/bbs/games.py b/bbs/games.py new file mode 100644 index 0000000..a8c229f --- /dev/null +++ b/bbs/games.py @@ -0,0 +1,323 @@ +"""Games for the BBS. Shares `scores`, `sp_games` and `matches` with the web +site, so a telnet user and a WAP user can play each other in the same match.""" +import json +import random +import time +from bbsdb import q, q1, ex, ago + +QUESTIONS = [ + ("What does WAP stand for?", + ["Wireless Application Protocol", "Web Access Point", + "Wide Area Paging", "Wireless Audio Player"], 0), + ("WML is based on which language?", ["HTML", "XML", "SGML", "JSON"], 1), + ("Which company made the Nokia 7110?", + ["Ericsson", "Motorola", "Nokia", "Siemens"], 2), + ("A WML file is made up of one or more...", + ["Pages", "Cards", "Frames", "Slides"], 1), + ("What year was WAP 1.1 published?", ["1996", "1999", "2002", "2005"], 1), + ("GPRS stands for General Packet Radio...?", + ["System", "Service", "Standard", "Stream"], 1), +] + + +def games_menu(t, con, me): + while True: + t.header("GAMES") + t.line(" [1] Guess the Number (single player)") + t.line(" [2] Quick Quiz (single player)") + t.line(" [3] Noughts & Crosses (multiplayer)") + t.line(" [4] Nim - 21 sticks (multiplayer)") + t.line(" [5] High scores") + t.line(" [Q] Back to main") + c = t.ask("\ngames> ").lower() + if c == "1": + guess(t, con, me) + elif c == "2": + quiz(t, con, me) + elif c == "3": + ttt(t, con, me) + elif c == "4": + nim(t, con, me) + elif c == "5": + scores(t, con) + elif c.startswith("q") or c == "": + return + + +def add_score(con, game, uid, score, detail=""): + ex(con, "INSERT INTO scores (game,user_id,score,detail,created_at)" + " VALUES (?,?,?,?,?)", (game, uid, score, detail, int(time.time()))) + + +def scores(t, con): + names = {"guess": "Guess the Number", "quiz": "Quick Quiz", + "ttt": "Noughts & Crosses", "nim": "Nim"} + t.header("HIGH SCORES") + for g, label in names.items(): + rows = q(con, "SELECT u.username, MAX(s.score) sc FROM scores s " + "JOIN users u ON u.id=s.user_id WHERE s.game=? " + "GROUP BY s.user_id ORDER BY sc DESC LIMIT 5", (g,)) + t.line("\n " + label + ":") + if not rows: + t.line(" (none yet)") + for i, r in enumerate(rows, 1): + t.line(" %d. %-14s %d" % (i, r["username"][:14], r["sc"])) + t.pause() + + +# ------------------------------------------------------------------ guess +def guess(t, con, me): + n = random.randint(1, 100) + tries = 0 + t.header("GUESS THE NUMBER") + t.line(" I picked a number from 1 to 100. Fewer guesses = more points.") + t.line(" Type Q to give up.") + while True: + s = t.ask("\n guess> ", maxlen=4) + if s.lower().startswith("q"): + t.line(" The number was %d." % n) + return + if not s.isdigit(): + t.line(" Enter a number 1-100.") + continue + g = int(s) + if not 1 <= g <= 100: + t.line(" Enter a number 1-100.") + continue + tries += 1 + if g == n: + score = max(1, 110 - 10 * tries) + add_score(con, "guess", me["id"], score, "%d tries" % tries) + t.line(" Correct! %d in %d tries. Score %d." % (n, tries, score)) + return + t.line(" Too %s. (tries: %d)" % ("LOW" if g < n else "HIGH", tries)) + + +# ------------------------------------------------------------------ quiz +def quiz(t, con, me): + qs = random.sample(QUESTIONS, 5) + right = 0 + t.header("QUICK QUIZ") + for i, (question, opts, ans) in enumerate(qs, 1): + t.line("\n Q%d/5: %s" % (i, question)) + for j, o in enumerate(opts): + t.line(" %d) %s" % (j + 1, o)) + s = t.ask(" answer> ", maxlen=2) + pick = int(s) - 1 if s.isdigit() else -1 + if pick == ans: + right += 1 + t.line(" Correct!") + else: + t.line(" Wrong - it was: %s" % opts[ans]) + score = right * 20 + add_score(con, "quiz", me["id"], score, "%d/5" % right) + t.line("\n Final: %d/5 correct, score %d." % (right, score)) + t.pause() + + +# ------------------------------------------------- multiplayer plumbing +def seat_of(m, uid): + if m["p1"] == uid: + return 1 + if m["p2"] == uid: + return 2 + return 0 + + +def lobby(t, con, me, game, label, new_state): + """Shared lobby: returns a match id to play, or None to go back.""" + while True: + mine = q(con, "SELECT m.*, a.username n1, b.username n2 FROM matches m " + "LEFT JOIN users a ON a.id=m.p1 LEFT JOIN users b ON b.id=m.p2 " + "WHERE m.game=? AND (m.p1=? OR m.p2=?) " + "AND m.status IN ('open','playing') " + "ORDER BY m.updated_at DESC LIMIT 10", + (game, me["id"], me["id"])) + open_ = q(con, "SELECT m.*, a.username n1 FROM matches m " + "LEFT JOIN users a ON a.id=m.p1 " + "WHERE m.game=? AND m.status='open' AND m.p1<>? " + "ORDER BY m.id DESC LIMIT 10", (game, me["id"])) + + t.header(label + " - LOBBY") + t.line(" Your games:") + if not mine: + t.line(" (none)") + for r in mine: + opp = (r["n2"] or "waiting...") if r["p1"] == me["id"] else (r["n1"] or "?") + yours = (r["status"] == "playing" and seat_of(r, me["id"]) == r["turn"]) + t.line(" #%d vs %-12s %s" % (r["id"], opp[:12], + "<< YOUR TURN" if yours else "")) + t.line(" Open games to join:") + if not open_: + t.line(" (none)") + for r in open_: + t.line(" #%d by %s" % (r["id"], r["n1"] or "?")) + t.line("\n [C]reate J = join # = play [Q]back") + c = t.ask("\nlobby> ").lower().strip() + + if c.startswith("c"): + now = int(time.time()) + mid = ex(con, "INSERT INTO matches (game,p1,turn,state,status," + "created_at,updated_at) VALUES (?,?,1,?,'open',?,?)", + (game, me["id"], new_state, now, now)) + t.line(" Created game #%d - waiting for an opponent." % mid) + elif c.startswith("j"): + digits = "".join(ch for ch in c if ch.isdigit()) + if digits: + mid = int(digits) + m = q1(con, "SELECT * FROM matches WHERE id=? AND game=?", (mid, game)) + if not m or m["status"] != "open" or m["p1"] == me["id"]: + t.line(" Cannot join that game.") + else: + ex(con, "UPDATE matches SET p2=?, status='playing', updated_at=?" + " WHERE id=?", (me["id"], int(time.time()), mid)) + t.line(" Joined #%d." % mid) + return mid + elif c.startswith("#") or c.isdigit(): + digits = "".join(ch for ch in c if ch.isdigit()) + if digits: + return int(digits) + elif c.startswith("q") or c == "": + return None + + +def get_match(con, game, mid): + return q1(con, "SELECT m.*, a.username n1, b.username n2 FROM matches m " + "LEFT JOIN users a ON a.id=m.p1 LEFT JOIN users b ON b.id=m.p2 " + "WHERE m.id=? AND m.game=?", (mid, game)) + + +def save_match(con, mid, state, turn, status="playing", winner=None): + ex(con, "UPDATE matches SET state=?,turn=?,status=?,winner=?,updated_at=?" + " WHERE id=?", (state, turn, status, winner, int(time.time()), mid)) + + +# ------------------------------------------------------------------ ttt +def ttt_winner(b): + for a, c, d in [(0,1,2),(3,4,5),(6,7,8),(0,3,6),(1,4,7),(2,5,8),(0,4,8),(2,4,6)]: + if b[a] and b[a] == b[c] == b[d]: + return b[a] + return None + + +def ttt(t, con, me): + mid = lobby(t, con, me, "ttt", "NOUGHTS & CROSSES", + json.dumps({"b": [""] * 9})) + if mid is None: + return + while True: + m = get_match(con, "ttt", mid) + if not m: + t.line(" No such game.") + return + st = json.loads(m["state"]) + b = st["b"] + seat = seat_of(m, me["id"]) + + t.header("#%d X=%s O=%s" % (mid, m["n1"] or "?", m["n2"] or "waiting")) + for r in range(3): + cells = [b[r*3+c] if b[r*3+c] else str(r*3+c+1) for c in range(3)] + t.line(" %s | %s | %s" % tuple(cells)) + if r < 2: + t.line(" ---+---+---") + + if m["status"] == "open": + t.line("\n Waiting for an opponent to join.") + t.line(" [R]efresh [Q]back") + elif m["status"] == "done": + if m["winner"] is None: + t.line("\n Result: draw.") + else: + t.line("\n Winner: %s" % (m["n1"] if m["winner"] == m["p1"] else m["n2"])) + t.pause() + return + elif seat == 0: + t.line("\n Spectating. Turn: player %d" % m["turn"]) + t.line(" [R]efresh [Q]back") + elif seat == m["turn"]: + t.line("\n Your turn (%s). Enter a free square 1-9." % ("X" if seat == 1 else "O")) + else: + t.line("\n Opponent to move. [R]efresh [Q]back") + + c = t.ask("\nttt> ").lower().strip() + if c.startswith("q"): + return + if c.startswith("r") or c == "": + continue + if m["status"] != "playing" or seat == 0 or seat != m["turn"]: + t.line(" Not your move.") + continue + if not c.isdigit() or not 1 <= int(c) <= 9: + t.line(" Pick 1-9.") + continue + i = int(c) - 1 + if b[i]: + t.line(" That square is taken.") + continue + b[i] = "X" if seat == 1 else "O" + st["b"] = b + w = ttt_winner(b) + if w: + save_match(con, mid, json.dumps(st), seat, "done", me["id"]) + add_score(con, "ttt", me["id"], 50, "win") + t.line(" You win!") + elif all(b): + save_match(con, mid, json.dumps(st), seat, "done", None) + t.line(" Draw.") + else: + save_match(con, mid, json.dumps(st), 2 if seat == 1 else 1) + t.line(" Move played.") + + +# ------------------------------------------------------------------ nim +def nim(t, con, me): + mid = lobby(t, con, me, "nim", "NIM - 21 STICKS", json.dumps({"sticks": 21})) + if mid is None: + return + while True: + m = get_match(con, "nim", mid) + if not m: + t.line(" No such game.") + return + st = json.loads(m["state"]) + left = st["sticks"] + seat = seat_of(m, me["id"]) + + t.header("#%d %s vs %s" % (mid, m["n1"] or "?", m["n2"] or "waiting")) + t.line(" " + "| " * left) + t.line(" %d sticks left. Take the LAST stick and you LOSE." % left) + + if m["status"] == "open": + t.line("\n Waiting for an opponent. [R]efresh [Q]back") + elif m["status"] == "done": + t.line("\n Winner: %s" % (m["n1"] if m["winner"] == m["p1"] else m["n2"])) + t.pause() + return + elif seat == 0: + t.line("\n Spectating. [R]efresh [Q]back") + elif seat == m["turn"]: + t.line("\n Your turn - take 1, 2 or 3.") + else: + t.line("\n Opponent to move. [R]efresh [Q]back") + + c = t.ask("\nnim> ").lower().strip() + if c.startswith("q"): + return + if c.startswith("r") or c == "": + continue + if m["status"] != "playing" or seat == 0 or seat != m["turn"]: + t.line(" Not your move.") + continue + if c not in ("1", "2", "3") or int(c) > left: + t.line(" Take 1-3, and no more than remain.") + continue + n = int(c) + st["sticks"] = left - n + if st["sticks"] <= 0: + winner = m["p2"] if seat == 1 else m["p1"] + save_match(con, mid, json.dumps(st), seat, "done", winner) + add_score(con, "nim", winner, 40, "win") + t.line(" You took the last stick - you LOSE!") + else: + save_match(con, mid, json.dumps(st), 2 if seat == 1 else 1) + t.line(" You took %d. %d left." % (n, st["sticks"])) diff --git a/bbs/mud.py b/bbs/mud.py new file mode 100644 index 0000000..3b01bc0 --- /dev/null +++ b/bbs/mud.py @@ -0,0 +1,511 @@ +"""Telnet MUD realm for the txt3 BBS. + +Implements the same rules as the WAP/XHTML front-end (lib/mud.php) against +the same SQLite tables, so a character created on the web can be played live +here and vice-versa. Combat/levelling formulas deliberately match the PHP +engine so both front-ends behave identically. + +This module is imported by bbsd.py; it exposes mud_menu(t, con, me). +""" +import json +import random +import time + +from bbsdb import q, q1, ex, ago + +START_HP, START_ATK, START_DEF = 30, 4, 2 +RESPAWN = 30 + +CLASSES = { + "fighter": ("Fighter", 2, 0, 6), + "mage": ("Mage", 4, 0, 0), + "thief": ("Thief", 1, 2, 2), +} + + +def class_bonus(cls): + return CLASSES.get(cls, CLASSES["fighter"]) + + +def item(con, key): + return q1(con, "SELECT * FROM mud_items WHERE key_name=?", (key,)) + + +def char_for(con, uid): + r = q1(con, "SELECT * FROM mud_chars WHERE user_id=?", (uid,)) + return dict(r) if r else None + + +def room(con, rid): + r = q1(con, "SELECT * FROM mud_rooms WHERE id=?", (rid,)) + if r: + r = dict(r) + r["exits"] = json.loads(r["exits"]) + return r + + +def ensure_char(con, me, cls="fighter"): + c = char_for(con, me["id"]) + if c: + return dict(c) + if cls not in CLASSES: + cls = "fighter" + nm, batk, bdef, bhp = class_bonus(cls) + maxhp = START_HP + bhp + ex(con, "INSERT INTO mud_chars (user_id,name,class,room_id,hp,max_hp,atk,def,xp,level,gold)" + " VALUES (?,?,?,1,?,?,?,?,0,1,0)", + (me["id"], me["username"], cls, maxhp, maxhp, START_ATK + batk, START_DEF + bdef)) + echo(con, 1, "%s the %s arrives in the world." % (me["username"], nm)) + return dict(char_for(con, me["id"])) + + +def echo(con, rid, text): + ex(con, "INSERT INTO mud_events (room_id,ts,text) VALUES (?,?,?)", + (rid, int(time.time()), text)) + + +def alive_spawns(con, rid): + return q(con, "SELECT s.*, m.key_name AS key_name, m.name AS name, m.descr AS descr " + "FROM mud_spawn s JOIN mud_mobs m ON m.id=s.mob_id " + "WHERE s.room_id=? AND s.alive=1 ORDER BY s.id", (rid,)) + + +def ground_items(con, rid): + return q(con, "SELECT g.id, i.key_name AS key_name, i.name AS name, i.descr AS descr " + "FROM mud_ground g JOIN mud_items i ON i.id=g.item_id " + "WHERE g.room_id=? ORDER BY g.id", (rid,)) + + +def respawn(con): + now = int(time.time()) + dead = q(con, "SELECT s.id, s.mob_id, m.hp AS mhp FROM mud_spawn s " + "JOIN mud_mobs m ON m.id=s.mob_id WHERE s.alive=0 AND s.next_respawn<=?", + (now,)) + for d in dead: + ex(con, "UPDATE mud_spawn SET alive=1, hp=? WHERE id=?", (d["mhp"], d["id"])) + + +def level_check(con, c): + need = c["level"] * 50 + if c["xp"] >= need: + c["xp"] -= need + c["level"] += 1 + c["max_hp"] += 8 + c["atk"] += 2 + c["def"] += 1 + c["hp"] = c["max_hp"] + ex(con, "UPDATE mud_chars SET level=?,max_hp=?,atk=?,def=?,xp=?,hp=? WHERE id=?", + (c["level"], c["max_hp"], c["atk"], c["def"], c["xp"], c["hp"], c["id"])) + return "You reached level %d! HP/ATK/DEF increased." % c["level"] + return None + + +def fight(con, spawn_id, c): + sp = q1(con, "SELECT s.*, m.key_name AS key_name, m.name AS name, m.descr AS descr, " + "m.atk AS atk, m.def AS def, m.xp AS xp, m.gold AS gold, m.loot AS loot, " + "m.respawn AS respawn FROM mud_spawn s JOIN mud_mobs m ON m.id=s.mob_id " + "WHERE s.id=?", (spawn_id,)) + if not sp or not sp["alive"]: + return ["There is nothing to fight here."] + sp = dict(sp) + msgs = [] + wep = item(con, c["weapon"]) if c["weapon"] else None + pDmg = max(1, c["atk"] + (wep["atk"] if wep else 0) - sp["def"] + random.randint(-1, 1)) + sp["hp"] -= pDmg + msgs.append("You hit %s for %d." % (sp["name"], pDmg)) + if sp["hp"] <= 0: + c["xp"] += sp["xp"] + c["gold"] += sp["gold"] + msgs.append("%s dies! +%d xp, +%d gold." % (sp["name"], sp["xp"], sp["gold"])) + inv = json.loads(c["inv"] or "[]") + for lk in json.loads(sp["loot"] or "[]"): + it = item(con, lk) + if it: + inv.append(lk) + msgs.append("You take %s." % it["name"]) + c["inv"] = json.dumps(inv) + ex(con, "UPDATE mud_spawn SET alive=0, next_respawn=? WHERE id=?", + (int(time.time()) + sp["respawn"], sp["id"])) + echo(con, c["room_id"], "%s slew %s." % (c["name"], sp["name"])) + ex(con, "UPDATE mud_chars SET xp=?,gold=?,inv=? WHERE id=?", + (c["xp"], c["gold"], c["inv"], c["id"])) + lv = level_check(con, c) + if lv: + msgs.append(lv) + return msgs + arm = item(con, c["armor"]) if c["armor"] else None + mDmg = max(1, sp["atk"] - (c["def"] + (arm["def"] if arm else 0)) + random.randint(-1, 1)) + c["hp"] -= mDmg + msgs.append("%s hits you for %d." % (sp["name"], mDmg)) + if c["hp"] <= 0: + c["hp"] = 0 + msgs.append("You have fallen! You wake in the Village Square.") + echo(con, c["room_id"], "%s was slain by %s and fades away." % (c["name"], sp["name"])) + lost = int(c["gold"] * 0.2) + c["gold"] -= lost + c["room_id"] = 1 + c["hp"] = c["max_hp"] + ex(con, "UPDATE mud_chars SET room_id=1,hp=?,gold=?,last_cmd_at=? WHERE id=?", + (c["hp"], c["gold"], int(time.time()), c["id"])) + else: + ex(con, "UPDATE mud_chars SET hp=? WHERE id=?", (c["hp"], c["id"])) + return msgs + + +# ------------------------------------------------------------------ UI +DIRS = {"n": "North", "s": "South", "e": "East", "w": "West", "u": "Up", "d": "Down"} + + +def show_room(t, con, c): + rm = room(con, c["room_id"]) + spawns = alive_spawns(con, c["room_id"]) + ground = ground_items(con, c["room_id"]) + others = players_in_room(con, c["room_id"]) + events = q(con, "SELECT text FROM mud_events WHERE room_id=? ORDER BY id DESC LIMIT 5", + (c["room_id"],)) + t.header("MUD - " + c["name"]) + t.line(" " + rm["name"]) + t.line(" " + rm["descr"]) + if spawns: + t.line(" Here:") + for s in spawns: + t.line(" %s (%d hp)" % (s["descr"], s["hp"])) + people = [p for p in others if p["id"] != c["id"]] + if people: + t.line(" Adventurers here:") + for p in people: + b = CLASSES.get(p["class"], CLASSES["fighter"])[0] + bn = ", bounty %dg" % p["bounty"] if p["bounty"] > 0 else "" + t.line(" %s (%s, L%d%s)" % (p["name"], b, p["level"], bn)) + if ground: + t.line(" On the ground:") + for g in ground: + t.line(" " + g["name"]) + if events: + t.line(" You see:") + for ev in reversed(events): + t.line(" " + ev["text"]) + t.rule("-") + t.line(" HP %d/%d Lvl %d XP %d Gold %d Bank %d" % ( + c["hp"], c["max_hp"], c["level"], c["xp"], c["gold"], c["bank"])) + ex(con, "UPDATE mud_chars SET last_cmd_at=? WHERE id=?", (int(time.time()), c["id"])) + + +def mud_menu(t, con, me): + c = ensure_char(con, me) + show_room(t, con, c) + while True: + t.line("\n [n/s/e/w/u/d] move [l]ook [k]ill [t]ake ") + t.line(" [w]ield [d]rink [i]nventory [sc]ore [Q]uit to main") + line = t.ask("\nmud> ").lower().strip() + if not line or line.startswith("q"): + t.line("Leaving the realm...") + return + parts = line.split(" ", 1) + cmd = parts[0] + arg = parts[1].strip() if len(parts) > 1 else "" + + if cmd in DIRS: + rm = room(con, c["room_id"]) + if cmd in rm["exits"]: + c["room_id"] = rm["exits"][cmd] + ex(con, "UPDATE mud_chars SET room_id=? WHERE id=?", (c["room_id"], c["id"])) + echo(con, c["room_id"], "%s heads %s." % (c["name"], DIRS[cmd])) + else: + t.line("You can't go that way.") + show_room(t, con, c) + continue + if cmd in ("l", "look"): + show_room(t, con, c) + continue + if cmd in ("k", "kill", "attack"): + if not arg: + t.line("Kill what?"); continue + spawns = alive_spawns(con, c["room_id"]) + hit = None + for s in spawns: + if s["key_name"].startswith(arg) or arg in s["name"]: + hit = s; break + if hit: + for m in fight(con, hit["id"], c): + t.line(" " + m) + else: + t.line("There is no '%s' here to fight." % arg) + show_room(t, con, c) + continue + if cmd in ("t", "take", "get"): + if not arg: + t.line("Take what?"); continue + ground = ground_items(con, c["room_id"]) + got = None + for g in ground: + if g["key_name"].startswith(arg): + got = g; break + if got: + ex(con, "DELETE FROM mud_ground WHERE id=?", (got["id"],)) + inv = json.loads(c["inv"] or "[]") + inv.append(got["key_name"]) + c["inv"] = json.dumps(inv) + ex(con, "UPDATE mud_chars SET inv=? WHERE id=?", (c["inv"], c["id"])) + echo(con, c["room_id"], "%s takes %s." % (c["name"], got["name"])) + t.line("You take %s." % got["name"]) + else: + t.line("There is no '%s' here." % arg) + show_room(t, con, c) + continue + if cmd in ("w", "wield", "wear"): + if not arg: + t.line("Wield what?"); continue + inv = json.loads(c["inv"] or "[]") + it = None + for k in inv: + if k.startswith(arg): + it = item(con, k); break + if not it: + t.line("You don't have that."); continue + if it["slot"] == "weapon": + c["weapon"] = it["key_name"] + ex(con, "UPDATE mud_chars SET weapon=? WHERE id=?", (it["key_name"], c["id"])) + t.line("You wield %s." % it["name"]) + elif it["slot"] == "armor": + c["armor"] = it["key_name"] + ex(con, "UPDATE mud_chars SET armor=? WHERE id=?", (it["key_name"], c["id"])) + t.line("You don %s." % it["name"]) + else: + t.line("You can't wield that.") + show_room(t, con, c) + continue + if cmd in ("d", "drink", "quaff"): + if not arg: + t.line("Drink what?"); continue + inv = json.loads(c["inv"] or "[]") + it = None + idx = None + for i, k in enumerate(inv): + if k.startswith(arg): + it = item(con, k); idx = i; break + if not it: + t.line("You don't have that."); continue + if it["slot"] != "potion": + t.line("That's not a potion."); continue + c["hp"] = min(c["max_hp"], c["hp"] + it["heal"]) + inv.pop(idx) + c["inv"] = json.dumps(inv) + ex(con, "UPDATE mud_chars SET hp=?,inv=? WHERE id=?", (c["hp"], c["inv"], c["id"])) + t.line("You drink %s and recover %d hp." % (it["name"], it["heal"])) + show_room(t, con, c) + continue + if cmd in ("i", "inv", "inventory"): + inv = json.loads(c["inv"] or "[]") + if inv: + t.line(" You carry: " + ", ".join(item(con, k)["name"] for k in inv)) + else: + t.line(" Your pack is empty.") + continue + if cmd in ("sc", "score", "stats"): + t.line(" Level %d | HP %d/%d | ATK %d | DEF %d | XP %d | Gold %d | Bank %d | Kills %d | Deaths %d" % ( + c["level"], c["hp"], c["max_hp"], c["atk"], c["def"], c["xp"], c["gold"], c["bank"], c["kills"], c["deaths"])) + continue + if cmd in ("a", "attack", "murder", "killp"): + if not arg: + t.line("Attack who?"); continue + v = find_player_in_room(con, c["room_id"], arg) + if v: + if v["id"] == c["id"]: + t.line("You can't attack yourself.") + else: + for m in pvp(con, c, v): + t.line(" " + m) + else: + others = [p for p in players_in_room(con, c["room_id"]) if p["id"] != c["id"]] + who = ", ".join(p["name"] for p in others) or "nobody" + t.line("There is no '%s' here to fight. Adventurers present: %s." % (arg, who)) + show_room(t, con, c) + continue + if cmd in ("b", "buy"): + if not arg: + t.line("Buy what? e.g. 'buy steel_sword'"); continue + for m in buy(con, c, arg): + t.line(" " + m) + show_room(t, con, c) + continue + if cmd in ("bank",): + if not arg: + t.line("bank to deposit, bank - to withdraw"); continue + if arg.startswith("-"): + for m in withdraw(con, c, int(arg[1:] or 0)): + t.line(" " + m) + else: + for m in deposit(con, c, int(arg or 0)): + t.line(" " + m) + show_room(t, con, c) + continue + if cmd in ("tax",): + for m in tax(con, c): + t.line(" " + m) + show_room(t, con, c) + continue + if cmd in ("bounty", "hit"): + parts = arg.split(" ", 1) + if len(parts) < 2 or not parts[1].isdigit(): + t.line("bounty "); continue + v = find_player_in_room(con, c["room_id"], parts[0]) + if v: + if v["id"] == c["id"]: + t.line("You can't bounty yourself.") + else: + for m in set_bounty(con, c, v, int(parts[1])): + t.line(" " + m) + else: + t.line("There is no '%s' here to bounty." % parts[0]) + show_room(t, con, c) + continue + if cmd in ("board", "top", "leaderboard"): + lb = leaderboard(con, 10) + t.line(" Adventurers of renown:") + for i, r in enumerate(lb, 1): + cn = CLASSES.get(r["class"], CLASSES["fighter"])[0] + t.line(" %d. %s (%s) L%d G%d K%d/D%d" % (i, r["name"], cn, r["level"], r["gold"], r["kills"], r["deaths"])) + continue + t.line(" Unknown command. Try: n/s/e/w/u/d, look, kill , attack ,") + t.line(" buy , bank , tax, bounty , board, inv, score.") + + +# ---- RPGBBS-style economy & PvP (mirror of lib/mud.php) ---- + +def players_in_room(con, rid): + return q(con, "SELECT c.*, u.username AS username FROM mud_chars c " + "JOIN users u ON u.id=c.user_id WHERE c.room_id=? ORDER BY c.level DESC", (rid,)) + + +def find_player_in_room(con, rid, frag): + frag = frag.lower() + for p in players_in_room(con, rid): + if p["id"] == 0: + continue + if frag in (p["name"] or "").lower() or frag in (p["username"] or "").lower(): + return p + return None + + +def shop_list(con): + return q(con, "SELECT * FROM mud_items WHERE price>0 ORDER BY price") + + +def buy(con, c, key): + it = item(con, key) + if not it or it["price"] <= 0: + return ["The trader doesn't sell that."] + if c["gold"] < it["price"]: + return ["You can't afford the %s (%dg)." % (it["name"], it["price"])] + c["gold"] -= it["price"] + inv = json.loads(c["inv"] or "[]") + inv.append(it["key_name"]) + c["inv"] = json.dumps(inv) + ex(con, "UPDATE mud_chars SET gold=?,inv=? WHERE id=?", (c["gold"], c["inv"], c["id"])) + return ["You buy %s for %dg." % (it["name"], it["price"])] + + +def deposit(con, c, amt): + amt = max(0, min(amt, c["gold"])) + if amt <= 0: + return ["You have no gold to bank."] + c["gold"] -= amt + c["bank"] += amt + ex(con, "UPDATE mud_chars SET gold=?,bank=? WHERE id=?", (c["gold"], c["bank"], c["id"])) + return ["You deposit %dg. Bank balance: %dg." % (amt, c["bank"])] + + +def withdraw(con, c, amt): + amt = max(0, min(amt, c["bank"])) + if amt <= 0: + return ["Nothing to withdraw."] + c["bank"] -= amt + c["gold"] += amt + ex(con, "UPDATE mud_chars SET gold=?,bank=? WHERE id=?", (c["gold"], c["bank"], c["id"])) + return ["You withdraw %dg. You carry %dg." % (amt, c["gold"])] + + +def tax(con, c): + due = int(c["gold"] * 0.10) + if due <= 0: + return ["Sir Joe squints. \"Come back when ye've coins to tithe.\""] + c["gold"] -= due + ex(con, "UPDATE mud_chars SET gold=? WHERE id=?", (c["gold"], c["id"])) + return ["Sir Joe pockets %dg. \"That's the price of civilisation, adventurer.\"" % due] + + +def leaderboard(con, limit=10): + return q(con, "SELECT name,class,level,gold,kills,deaths FROM mud_chars " + "ORDER BY level DESC, gold DESC LIMIT %d" % limit) + + +def pvp(con, att, dfn): + msgs = [] + if dfn["id"] == att["id"]: + return ["You can't attack yourself."] + if dfn["room_id"] != att["room_id"]: + return ["They aren't here."] + aWep = item(con, att["weapon"])["atk"] if att["weapon"] else 0 + dWep = item(con, dfn["weapon"])["atk"] if dfn["weapon"] else 0 + aArm = item(con, att["armor"])["def"] if att["armor"] else 0 + dArm = item(con, dfn["armor"])["def"] if dfn["armor"] else 0 + aDmg = max(1, att["atk"] + aWep - dfn["def"] - dArm + random.randint(-1, 1)) + dDmg = max(1, dfn["atk"] + dWep - att["def"] - aArm + random.randint(-1, 1)) + att["hp"] -= dDmg + dfn["hp"] -= aDmg + msgs.append("You strike %s for %d. %s strikes you for %d." % (dfn["name"], aDmg, dfn["name"], dDmg)) + if dfn["hp"] <= 0 and att["hp"] > 0: + loot = int(dfn["gold"] * 0.5) + att["gold"] += loot + att["kills"] += 1 + dfn["gold"] -= loot + dfn["deaths"] += 1 + dfn["room_id"] = 1 + dfn["hp"] = dfn["max_hp"] + bounty = int(dfn["bounty"]) + if bounty > 0: + att["gold"] += bounty + dfn["bounty"] = 0 + msgs.append("You collect the %dg bounty on %s!" % (bounty, dfn["name"])) + msgs.append("You slay %s! +%dg looted%s" % ( + dfn["name"], loot, ", +%dg bounty." % bounty if bounty else ".")) + echo(con, att["room_id"], "%s cut down %s in cold blood." % (att["name"], dfn["name"])) + ex(con, "UPDATE mud_chars SET gold=?,kills=?,hp=?,room_id=? WHERE id=?", + (att["gold"], att["kills"], att["hp"], att["room_id"], att["id"])) + ex(con, "UPDATE mud_chars SET gold=?,deaths=?,bounty=?,hp=?,room_id=? WHERE id=?", + (dfn["gold"], dfn["deaths"], dfn["bounty"], dfn["hp"], dfn["id"])) + ex(con, "DELETE FROM mud_bounties WHERE target_id=?", (dfn["id"],)) + return msgs + if att["hp"] <= 0 and dfn["hp"] > 0: + loot = int(att["gold"] * 0.5) + dfn["gold"] += loot + dfn["kills"] += 1 + att["gold"] -= loot + att["deaths"] += 1 + att["room_id"] = 1 + att["hp"] = att["max_hp"] + msgs.append("%s bests you! You lose %dg and wake in the Village Square." % (dfn["name"], loot)) + echo(con, att["room_id"], "%s cut down %s." % (dfn["name"], att["name"])) + ex(con, "UPDATE mud_chars SET gold=?,deaths=?,hp=?,room_id=? WHERE id=?", + (att["gold"], att["deaths"], att["hp"], att["room_id"], att["id"])) + ex(con, "UPDATE mud_chars SET gold=?,kills=? WHERE id=?", (dfn["gold"], dfn["kills"], dfn["id"])) + return msgs + ex(con, "UPDATE mud_chars SET hp=? WHERE id=?", (att["hp"], att["id"])) + ex(con, "UPDATE mud_chars SET hp=? WHERE id=?", (dfn["hp"], dfn["id"])) + return msgs + + +def set_bounty(con, c, target, amt): + if target["id"] == c["id"]: + return ["You can't bounty yourself."] + if amt <= 0: + return ["A bounty needs to be worth something."] + if c["gold"] < amt: + return ["You can't post a %dg bounty." % amt] + c["gold"] -= amt + ex(con, "UPDATE mud_chars SET bounty=bounty+?, gold=? WHERE id=?", (amt, c["gold"], target["id"])) + ex(con, "INSERT INTO mud_bounties (target_id,by_id,amount,ts) VALUES (?,?,?,?)", + (target["id"], c["id"], amt, int(time.time()))) + ex(con, "UPDATE mud_chars SET gold=? WHERE id=?", (c["gold"], c["id"])) + return ["You post a %dg bounty on %s. The realm will remember." % (amt, target["name"])] diff --git a/bbs/screens.py b/bbs/screens.py new file mode 100644 index 0000000..5f91d69 --- /dev/null +++ b/bbs/screens.py @@ -0,0 +1,305 @@ +"""Profile + admin screens for the BBS.""" +import time +from bbsdb import q, q1, ex, user_by_name, user_by_id, hash_password, ago + +PER_PAGE = 10 + + +# ================================================================= profile +def profile_menu(t, con, me): + while True: + u = user_by_id(con, me["id"]) + posts = q1(con, "SELECT COUNT(*) c FROM posts WHERE user_id=?", (u["id"],))["c"] + t.header("YOUR PROFILE") + t.line(" User : %s%s" % (u["username"], " [admin]" if u["is_admin"] else "")) + t.line(" Tagline : %s" % (u["tagline"] or "-")) + t.line(" Location: %s" % (u["location"] or "-")) + t.line(" Joined : %s" % time.strftime("%d/%m/%Y", + time.localtime(u["created_at"]))) + t.line(" Posts : %d" % posts) + best = q(con, "SELECT game, MAX(score) sc FROM scores WHERE user_id=?" + " GROUP BY game", (u["id"],)) + for b in best: + t.line(" Best %-6s: %d" % (b["game"], b["sc"])) + t.line("\n [T]agline [L]ocation [P]assword [W]ho's online [Q]back") + c = t.ask("\nprofile> ").lower() + if c.startswith("t"): + v = t.ask(" New tagline: ", maxlen=80) + ex(con, "UPDATE users SET tagline=? WHERE id=?", (v, u["id"])) + elif c.startswith("l"): + v = t.ask(" New location: ", maxlen=40) + ex(con, "UPDATE users SET location=? WHERE id=?", (v, u["id"])) + elif c.startswith("p"): + change_password(t, con, u) + elif c.startswith("w"): + who(t, con) + elif c.startswith("q") or c == "": + return + + +def change_password(t, con, u): + from bbsdb import verify_password + old = t.secret(" Current password: ") + if not verify_password(old, u["pass_hash"]): + t.line(" Wrong password.") + return + n1 = t.secret(" New password: ") + if len(n1) < 4: + t.line(" Too short (min 4).") + return + if n1 != t.secret(" Repeat new: "): + t.line(" Did not match.") + return + h = hash_password(n1) + if not h: + t.line(" Could not hash password - unchanged.") + return + ex(con, "UPDATE users SET pass_hash=? WHERE id=?", (h, u["id"])) + t.line(" Password changed. It works on the website too.") + + +def who(t, con): + rows = q(con, "SELECT username,last_seen,is_admin FROM users " + "ORDER BY last_seen DESC LIMIT 15") + t.header("RECENTLY SEEN") + for r in rows: + t.line(" %-16s %s ago%s" % (r["username"][:16], ago(r["last_seen"]), + " [admin]" if r["is_admin"] else "")) + t.pause() + + +def userlist(t, con): + page = 0 + while True: + rows = q(con, "SELECT username,tagline,last_seen FROM users " + "ORDER BY username LIMIT ? OFFSET ?", + (PER_PAGE + 1, page * PER_PAGE)) + more = len(rows) > PER_PAGE + rows = rows[:PER_PAGE] + t.header("MEMBER LIST") + for r in rows: + t.line(" %-16s %-24s %s" % (r["username"][:16], + (r["tagline"] or "")[:24], + ago(r["last_seen"]))) + t.line("\n [N]ext [P]rev [Q]back") + c = t.ask("\nusers> ").lower() + if c.startswith("n") and more: + page += 1 + elif c.startswith("p") and page > 0: + page -= 1 + else: + return + + +# ================================================================== admin +def admin_menu(t, con, me): + if not me["is_admin"]: + t.line(" Admin only.") + return + while True: + t.header("ADMIN TOOLS") + t.line(" [U]sers [F]orums [T]opics [M]OTD [S]tats [Q]back") + c = t.ask("\nadmin> ").lower() + if c.startswith("u"): + admin_users(t, con, me) + elif c.startswith("f"): + admin_forums(t, con) + elif c.startswith("t"): + admin_topics(t, con) + elif c.startswith("m"): + v = t.ask(" New MOTD: ", maxlen=200) + ex(con, "INSERT INTO settings (k,v) VALUES ('motd',?) " + "ON CONFLICT(k) DO UPDATE SET v=excluded.v", (v,)) + t.line(" MOTD updated (site + BBS).") + elif c.startswith("s"): + admin_stats(t, con) + elif c.startswith("q") or c == "": + return + + +def admin_stats(t, con): + t.header("STATS") + for label, sql in [ + ("Users", "SELECT COUNT(*) c FROM users"), + ("Banned", "SELECT COUNT(*) c FROM users WHERE is_banned=1"), + ("Forums", "SELECT COUNT(*) c FROM forums"), + ("Topics", "SELECT COUNT(*) c FROM topics"), + ("Posts", "SELECT COUNT(*) c FROM posts"), + ("Messages", "SELECT COUNT(*) c FROM messages"), + ("Matches", "SELECT COUNT(*) c FROM matches"), + ]: + t.line(" %-10s %d" % (label, q1(con, sql)["c"])) + t.pause() + + +def admin_users(t, con, me): + while True: + t.header("ADMIN: USERS") + t.line(" [L]ist [F]ind [A]dd [E]dit [Q]back") + c = t.ask("\nadmin/users> ").lower() + if c.startswith("l"): + rows = q(con, "SELECT id,username,is_admin,is_banned FROM users" + " ORDER BY id LIMIT 40") + for r in rows: + t.line(" #%-4d %-16s %s%s" % ( + r["id"], r["username"][:16], + "admin " if r["is_admin"] else "", + "BANNED" if r["is_banned"] else "")) + t.pause() + elif c.startswith("f"): + term = t.ask(" Username contains: ", maxlen=16) + rows = q(con, "SELECT id,username,is_admin,is_banned FROM users" + " WHERE username LIKE ? ORDER BY id LIMIT 40", + ("%" + term + "%",)) + if not rows: + t.line(" (no matches)") + for r in rows: + t.line(" #%-4d %-16s %s%s" % ( + r["id"], r["username"][:16], + "admin " if r["is_admin"] else "", + "BANNED" if r["is_banned"] else "")) + t.pause() + elif c.startswith("a"): + name = t.ask(" New username: ", maxlen=16) + if not name.replace("_", "").isalnum() or not 3 <= len(name) <= 16: + t.line(" Bad username (3-16 alnum/underscore).") + continue + if user_by_name(con, name): + t.line(" Already taken.") + continue + pw = t.secret(" Password: ") + if len(pw) < 4: + t.line(" Too short.") + continue + h = hash_password(pw) + if not h: + t.line(" Hashing failed.") + continue + now = int(time.time()) + ex(con, "INSERT INTO users (username,pass_hash,is_admin,created_at)" + " VALUES (?,?,0,?)", (name, h, now)) + t.line(" Created %s." % name) + elif c.startswith("e"): + admin_edit_user(t, con, me) + elif c.startswith("q") or c == "": + return + + +def admin_edit_user(t, con, me): + name = t.ask(" Username to edit: ", maxlen=16) + u = user_by_name(con, name) + if not u: + t.line(" No such user.") + return + while True: + u = user_by_id(con, u["id"]) + t.header("EDIT: %s (#%d)" % (u["username"], u["id"])) + t.line(" admin=%d banned=%d tagline=%s" % + (u["is_admin"], u["is_banned"], u["tagline"] or "-")) + t.line("\n [A]dmin toggle [B]an toggle [R]eset pw [D]elete [Q]back") + c = t.ask("\nedit> ").lower() + if c.startswith("a"): + if u["id"] == me["id"] and u["is_admin"]: + t.line(" You cannot remove your own admin rights.") + continue + ex(con, "UPDATE users SET is_admin=1-is_admin WHERE id=?", (u["id"],)) + elif c.startswith("b"): + if u["id"] == me["id"]: + t.line(" You cannot ban yourself.") + continue + ex(con, "UPDATE users SET is_banned=1-is_banned WHERE id=?", (u["id"],)) + elif c.startswith("r"): + pw = t.secret(" New password: ") + if len(pw) < 4: + t.line(" Too short.") + continue + h = hash_password(pw) + if h: + ex(con, "UPDATE users SET pass_hash=? WHERE id=?", (h, u["id"])) + t.line(" Reset.") + elif c.startswith("d"): + if u["id"] == me["id"]: + t.line(" You cannot delete yourself.") + continue + if t.ask(" Type DELETE to confirm: ") == "DELETE": + ex(con, "DELETE FROM users WHERE id=?", (u["id"],)) + t.line(" Deleted.") + return + elif c.startswith("q") or c == "": + return + + +def admin_forums(t, con): + while True: + rows = q(con, "SELECT f.*, (SELECT COUNT(*) FROM topics x " + "WHERE x.forum_id=f.id) tc FROM forums f " + "ORDER BY f.sort_order, f.id") + t.header("ADMIN: FORUMS") + for r in rows: + t.line(" #%-3d %-24s %3dt %s" % ( + r["id"], r["name"][:24], r["tc"], + "[locked]" if r["is_locked"] else "")) + t.line("\n [A]dd [L]ock toggle [R]ename [D]elete [Q]back") + c = t.ask("\nadmin/forums> ").lower() + if c.startswith("a"): + name = t.ask(" Forum name: ", maxlen=60) + if not name: + continue + descr = t.ask(" Description: ", maxlen=120) + order = t.ask(" Sort order (number): ", maxlen=4) + ex(con, "INSERT INTO forums (name,descr,sort_order) VALUES (?,?,?)", + (name, descr, int(order) if order.isdigit() else 0)) + t.line(" Added.") + elif c.startswith("l"): + i = t.ask(" Forum id: ", maxlen=6) + if i.isdigit(): + ex(con, "UPDATE forums SET is_locked=1-is_locked WHERE id=?", (int(i),)) + elif c.startswith("r"): + i = t.ask(" Forum id: ", maxlen=6) + if i.isdigit(): + name = t.ask(" New name: ", maxlen=60) + if name: + ex(con, "UPDATE forums SET name=? WHERE id=?", (name, int(i))) + elif c.startswith("d"): + i = t.ask(" Forum id to DELETE (with all topics): ", maxlen=6) + if i.isdigit() and t.ask(" Type DELETE to confirm: ") == "DELETE": + ex(con, "DELETE FROM forums WHERE id=?", (int(i),)) + t.line(" Deleted.") + elif c.startswith("q") or c == "": + return + + +def admin_topics(t, con): + while True: + rows = q(con, "SELECT t.*, f.name fname, u.username FROM topics t " + "JOIN forums f ON f.id=t.forum_id " + "LEFT JOIN users u ON u.id=t.user_id " + "ORDER BY t.bumped_at DESC LIMIT 20") + t.header("ADMIN: TOPICS (latest 20)") + for r in rows: + t.line(" #%-4d [%-10s] %-26s %s%s" % ( + r["id"], r["fname"][:10], r["title"][:26], + (r["username"] or "?")[:10], + " LOCKED" if r["is_locked"] else "")) + t.line("\n [L]ock toggle [M]ove [D]elete [Q]back") + c = t.ask("\nadmin/topics> ").lower() + if c.startswith("l"): + i = t.ask(" Topic id: ", maxlen=6) + if i.isdigit(): + ex(con, "UPDATE topics SET is_locked=1-is_locked WHERE id=?", (int(i),)) + elif c.startswith("m"): + i = t.ask(" Topic id: ", maxlen=6) + f = t.ask(" Move to forum id: ", maxlen=6) + if i.isdigit() and f.isdigit(): + if q1(con, "SELECT 1 x FROM forums WHERE id=?", (int(f),)): + ex(con, "UPDATE topics SET forum_id=? WHERE id=?", (int(f), int(i))) + t.line(" Moved.") + else: + t.line(" No such forum.") + elif c.startswith("d"): + i = t.ask(" Topic id to DELETE: ", maxlen=6) + if i.isdigit() and t.ask(" Type DELETE to confirm: ") == "DELETE": + ex(con, "DELETE FROM topics WHERE id=?", (int(i),)) + t.line(" Deleted.") + elif c.startswith("q") or c == "": + return diff --git a/bbs/term.py b/bbs/term.py new file mode 100644 index 0000000..8ee7bed --- /dev/null +++ b/bbs/term.py @@ -0,0 +1,163 @@ +"""Telnet terminal I/O for the txt3 BBS. + +Deliberately conservative: real telnet clients, netcat, PuTTY in raw mode and +Windows telnet all have to work, so we negotiate the bare minimum (suppress +go-ahead, refuse everything else) and strip IAC sequences from the byte stream +rather than trying to be a full RFC 854 implementation. +""" +import socket + +IAC = 255 +DONT = 254 +DO = 253 +WONT = 252 +WILL = 251 +SB = 250 +SE = 240 +ECHO = 1 +SGA = 3 + +CRLF = b"\r\n" + + +class Hangup(Exception): + """Raised when the peer disconnects or times out.""" + + +class Term: + def __init__(self, sock, addr, idle_timeout=600): + self.s = sock + self.addr = addr + self.buf = b"" + self.s.settimeout(idle_timeout) + # We will echo input ourselves only for passwords (as masking); + # otherwise let the client echo locally, which is what most do. + self._send_raw(bytes([IAC, WILL, SGA])) + self._send_raw(bytes([IAC, DO, SGA])) + + # ------------------------------------------------------------- low level + def _send_raw(self, data): + try: + self.s.sendall(data) + except (BrokenPipeError, ConnectionResetError, OSError): + raise Hangup() + + def write(self, text=""): + if isinstance(text, str): + # Telnet wants CRLF; also escape a literal IAC byte. + text = text.replace("\n", "\r\n").encode("utf-8", "replace") + self._send_raw(text.replace(bytes([IAC]), bytes([IAC, IAC]))) + + def line(self, text=""): + self.write(text + "\n") + + # ------------------------------------------------------------- input + def _fill(self): + try: + chunk = self.s.recv(1024) + except socket.timeout: + raise Hangup() + except (ConnectionResetError, OSError): + raise Hangup() + if not chunk: + raise Hangup() + self.buf += chunk + + def _pop_byte(self): + while not self.buf: + self._fill() + b = self.buf[0] + self.buf = self.buf[1:] + return b + + def _read_char(self): + """Return the next data byte, transparently consuming telnet commands.""" + while True: + b = self._pop_byte() + if b != IAC: + return b + # IAC ... + c = self._pop_byte() + if c == IAC: + return IAC # escaped literal 255 + if c in (DO, DONT, WILL, WONT): + opt = self._pop_byte() + # Refuse everything except SGA, which we already agreed. + if c == DO: + resp = WILL if opt == SGA else WONT + elif c == WILL: + resp = DO if opt == SGA else DONT + else: + resp = WONT if c == DO else DONT + self._send_raw(bytes([IAC, resp, opt])) + continue + if c == SB: + # swallow the subnegotiation up to IAC SE + prev = None + while True: + x = self._pop_byte() + if prev == IAC and x == SE: + break + prev = x + continue + # any other 2-byte command: ignore + continue + + def read(self, prompt="", mask=False, maxlen=200): + """Read one line of input. Handles backspace and bare CR or LF.""" + if prompt: + self.write(prompt) + out = bytearray() + while True: + b = self._read_char() + if b in (13, 10): # CR or LF + # A CR is often followed by LF or NUL; peek and drop it. + if self.buf[:1] in (b"\n", b"\x00"): + self.buf = self.buf[1:] + self.write("\n") + break + if b in (8, 127): # backspace / delete + if out: + out.pop() + if mask: + self.write("\b \b") + else: + self.write("\b \b") + continue + if b == 3: # ^C + raise Hangup() + if b == 4 and not out: # ^D on an empty line + raise Hangup() + if b < 32 or b > 126: + continue # ignore other control/non-ascii + if len(out) >= maxlen: + continue + out.append(b) + if mask: + self.write("*") + return out.decode("utf-8", "replace").strip() + + def ask(self, prompt, maxlen=200): + return self.read(prompt, mask=False, maxlen=maxlen) + + def secret(self, prompt="Password: "): + return self.read(prompt, mask=True, maxlen=60) + + # ------------------------------------------------------------- screen + def rule(self, ch="-", n=60): + self.line(ch * n) + + def header(self, title): + self.line() + self.rule("=") + self.line(" " + title) + self.rule("=") + + def pause(self): + self.read("\n[enter] ") + + def close(self): + try: + self.s.close() + except OSError: + pass diff --git a/bbs/txt3-bbs.service b/bbs/txt3-bbs.service new file mode 100644 index 0000000..53564f5 --- /dev/null +++ b/bbs/txt3-bbs.service @@ -0,0 +1,43 @@ +[Unit] +Description=txt3 BBS (telnet front-end for wap.txt3.net) +Documentation=https://wap.txt3.net/about.php +After=network-online.target tailscaled.service +Wants=network-online.target + +[Service] +Type=simple +User=txt3 +Group=txt3 +WorkingDirectory=/home/txt3/domains/wap.txt3.net/bbs +ExecStart=/usr/bin/python3 /home/txt3/domains/wap.txt3.net/bbs/bbsd.py +Environment=BBS_HOST=100.127.96.105 +Environment=BBS_PORT=12300 +Environment=BBS_DB=/home/txt3/domains/wap.txt3.net/public_html/data/wap.sqlite +Environment=BBS_PHP=/usr/bin/php +Environment=PYTHONUNBUFFERED=1 + +# The service only ever needs its own code, the shared sqlite db, and php for +# bcrypt hashing. Everything else is locked down. +NoNewPrivileges=yes +PrivateTmp=yes +ProtectSystem=strict +ProtectHome=read-only +ReadWritePaths=/home/txt3/domains/wap.txt3.net/public_html/data +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectControlGroups=yes +RestrictNamespaces=yes +RestrictSUIDSGID=yes +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX +LockPersonality=yes +MemoryDenyWriteExecute=no +# tailscale-only listener, so no need for extra port capabilities +CapabilityBoundingSet= + +Restart=on-failure +RestartSec=5 +StandardOutput=append:/home/txt3/domains/wap.txt3.net/bbs/bbs.log +StandardError=append:/home/txt3/domains/wap.txt3.net/bbs/bbs.log + +[Install] +WantedBy=multi-user.target diff --git a/public_html/.htaccess b/public_html/.htaccess new file mode 100644 index 0000000..f965292 --- /dev/null +++ b/public_html/.htaccess @@ -0,0 +1,16 @@ +# wap.txt3.net +DirectoryIndex index.php + +# WAP MIME types for gateways/phones that need them declared +AddType text/vnd.wap.wml .wml +AddType application/vnd.wap.wmlc .wmlc +AddType text/vnd.wap.wmlscript .wmls +AddType image/vnd.wap.wbmp .wbmp + +# The sqlite database and library code must never be served +RedirectMatch 404 ^/data(/|$) +RedirectMatch 404 ^/lib(/|$) + + + Header set X-Content-Type-Options nosniff + diff --git a/public_html/about.php b/public_html/about.php new file mode 100644 index 0000000..6be1c20 --- /dev/null +++ b/public_html/about.php @@ -0,0 +1,96 @@ + '/index.php']); + +p_para('wap.txt3.net - a WAP site, on purpose, in ' . date('Y') . '.'); +p_rule(); + +p_para('== Where this came from =='); +p_para('txt3 has been sending text messages since the early 2000s. This site ' + . 'is a deliberate throwback to how it started: a WML deck on a phone with ' + . 'a two-inch screen.'); + +p_rule(); +p_para('== Timeline =='); + +p_para('1997 - the txt3 story begins. The current site still carries the line ' + . '"Connecting people - since 1997".'); + +p_para('Aug 2002 - txt3.co.uk is first archived by the Internet Archive, making ' + . 'it the oldest of the txt3 domains on record. txt3.com is captured a few ' + . 'weeks later, in Sep 2002.'); + +p_para('Jan 2003 - wap.txt3.co.uk appears: a WML 1.1 deck titled "FREE SMS", ' + . 'with a .wbmp logo and Login / Sign Up / Statistics links. It was built ' + . 'with WAPtor, the WAP authoring tool of the day. By this point ' + . 'txt3.co.uk itself was just a 5-second meta-refresh pointing visitors ' + . 'at txt3.com.'); + +p_para('2004 - txt3.net joins the family and the WAP gateway moves with it. ' + . 'The front deck proudly announces "Registration working 100%!" and links ' + . 'to an "Access Denied?" help card.'); + +p_para('Feb 2005 - wap.txt3.net is first archived, serving the same FREE SMS ' + . 'deck under a txt3.net logo. Accounts were tied to your handset: change ' + . 'phones and you had to text UNLOCK back to the service to reactivate. ' + . 'The help card also allowed that you might simply have been "banned :)".'); + +p_para('2008-2010 - wap.txt3.com is being passed around on mobile forums as a ' + . 'free-SMS WAP site, including a 2008 thread on nairaland.com listing it ' + . 'among "WAP sites you know".'); + +p_para('2016 - the revival. txt3.net returns with the tagline "The original, ' + . 'resurrected!" offering unlimited free replyable SMS to UK numbers, plus ' + . 'SMS-to-email, email-to-SMS and bulk SMS, alongside a forum and a blog. ' + . 'The announcement is signed simply "- JP".'); + +p_para('2019 - still running, still fixing logins, still promising ' + . 'international SMS "in the very near future".'); + +p_para('2025 - txt3.com becomes a quieter landing page, pointing people at a ' + . 'self-hosted Matrix server (mtx.txt3.net) for group chats, communities ' + . 'and calls. The medium changed; the purpose did not.'); + +p_para(date('Y') . ' - wap.txt3.net is reborn as this hybrid: the same site ' + . 'rendered as WML 1.1 for period phones and XHTML for everything else, ' + . 'plus a telnet BBS on port 12300 for anyone who prefers a terminal. ' + . 'Forums, private mail, profiles and games are shared across all three ' + . 'front-ends - one account, one database.'); + +p_rule(); +p_para('== The domains =='); +p_para('txt3.co.uk - first seen Aug 2002, last archived 2017.'); +p_para('txt3.com - first seen Sep 2002, still live today.'); +p_para('txt3.net - first seen Oct 2004, home of this site.'); +p_para('wap.txt3.co.uk - the original WAP deck, archived Jan-Mar 2003.'); +p_para('wap.txt3.com - archived 2009-2019.'); +p_para('wap.txt3.net - archived from Feb 2005, and running again now.'); + +p_rule(); +p_para('== This site =='); +p_para('One PHP codebase serves two markup languages. WML browsers are ' + . 'detected from the Accept header, WAP gateway headers and user-agent, ' + . 'and get valid WML 1.1 decks with softkeys and postfields; everyone else ' + . 'gets XHTML. Data lives in SQLite. The BBS is a Python telnet daemon ' + . 'reading the very same database, so a match started on a phone can be ' + . 'finished from a terminal.'); +p_para('Use the link at the bottom of any page to switch markup by hand, or ' + . 'add ?m=auto to go back to automatic detection.'); + +p_rule(); +p_para('== A note on searching for us =='); +p_para('Looking up "txt3" today is harder than it should be. The K-pop group ' + . 'TOMORROW X TOGETHER (TXT), formed 2019, floods every search for TXT ' + . 'forums, and there are unrelated outfits using similar names. Even AI ' + . 'search tools confidently invent a history for this domain - complete ' + . 'with projects it was never part of. The account above is drawn only ' + . 'from archived copies of txt3\'s own pages.'); + +p_rule(); +p_para('Sources: Internet Archive Wayback Machine captures of txt3.co.uk, ' + . 'txt3.com, txt3.net and their wap. subdomains (2002-2026), and archived ' + . 'copies of the sites\' own pages.'); + +p_links([['/index.php', 'Home'], ['/forum.php', 'Forum']]); +page_end(); diff --git a/public_html/admin/forums.php b/public_html/admin/forums.php new file mode 100644 index 0000000..d0b36e6 --- /dev/null +++ b/public_html/admin/forums.php @@ -0,0 +1,88 @@ +prepare("INSERT INTO forums (name,descr,sort_order) VALUES (?,?,?)") + ->execute([$n, mb_substr(trim((string)($_POST['descr'] ?? '')), 0, 120), + (int)($_POST['sort_order'] ?? 0)]); + $msg = 'Forum "' . $n . '" created.'; + } + } elseif ($act === 'save' && $fid) { + $n = mb_substr(trim((string)($_POST['name'] ?? '')), 0, 60); + if ($n === '') $err = 'Forum name required.'; + else { + $d->prepare("UPDATE forums SET name=?,descr=?,sort_order=?,is_locked=? WHERE id=?") + ->execute([$n, mb_substr(trim((string)($_POST['descr'] ?? '')), 0, 120), + (int)($_POST['sort_order'] ?? 0), + (int)($_POST['is_locked'] ?? 0), $fid]); + $msg = 'Forum saved.'; + } + } elseif ($act === 'del' && $fid) { + // ON DELETE CASCADE removes its topics and their posts + $d->prepare("DELETE FROM forums WHERE id=?")->execute([$fid]); + $msg = 'Forum and all its topics deleted.'; + $edit = 0; + } +} + +page_start('Admin: forums', ['back' => '/admin/index.php']); +if ($err) p_err($err); +if ($msg) p_ok($msg); + +if ($edit) { + $s = $d->prepare("SELECT * FROM forums WHERE id=?"); + $s->execute([$edit]); + $f = $s->fetch(); + if (!$f) bail('Admin', 'No such forum.', '/admin/forums.php'); + p_para('Editing forum #' . $f['id']); + p_form('/admin/forums.php?edit=' . $edit, [ + ['name' => 'act', 'type' => 'hidden', 'value' => 'save'], + ['name' => 'fid', 'type' => 'hidden', 'value' => (string)$f['id']], + ['name' => 'name', 'label' => 'Name', 'value' => $f['name'], 'maxlength' => 60], + ['name' => 'descr', 'label' => 'Description', 'value' => $f['descr'], 'maxlength' => 120], + ['name' => 'sort_order', 'label' => 'Sort order', 'value' => (string)$f['sort_order'], + 'maxlength' => 4, 'format' => '*N'], + ['name' => 'is_locked', 'label' => 'Locked', 'type' => 'select', + 'value' => (string)$f['is_locked'], 'options' => ['0' => 'no', '1' => 'yes']], + ], 'Save forum'); + p_form('/admin/forums.php', [ + ['name' => 'act', 'type' => 'hidden', 'value' => 'del'], + ['name' => 'fid', 'type' => 'hidden', 'value' => (string)$f['id']], + ], 'DELETE forum'); + p_links([['/admin/forums.php', 'Back to forum list']]); + page_end(); + exit; +} + +$rows = $d->query("SELECT f.*, (SELECT COUNT(*) FROM topics t WHERE t.forum_id=f.id) tc + FROM forums f ORDER BY f.sort_order, f.id")->fetchAll(); +foreach ($rows as $r) { + p_link('/admin/forums.php', '#' . $r['id'] . ' ' . $r['name'] . ' (' . $r['tc'] . 't)' + . ($r['is_locked'] ? ' [locked]' : ''), ['edit' => $r['id']]); +} +if (!$rows) p_para('No forums.'); +p_rule(); +p_para('Add a forum:'); +p_form('/admin/forums.php', [ + ['name' => 'act', 'type' => 'hidden', 'value' => 'add'], + ['name' => 'name', 'label' => 'Name', 'maxlength' => 60], + ['name' => 'descr', 'label' => 'Description', 'maxlength' => 120], + ['name' => 'sort_order', 'label' => 'Sort order', 'value' => '0', + 'maxlength' => 4, 'format' => '*N'], +], 'Add forum'); +p_links([['/admin/index.php', 'Admin home']]); +page_end(); diff --git a/public_html/admin/index.php b/public_html/admin/index.php new file mode 100644 index 0000000..dd87917 --- /dev/null +++ b/public_html/admin/index.php @@ -0,0 +1,40 @@ + (int)$d->query("SELECT COUNT(*) FROM users")->fetchColumn(), + 'Banned' => (int)$d->query("SELECT COUNT(*) FROM users WHERE is_banned=1")->fetchColumn(), + 'Forums' => (int)$d->query("SELECT COUNT(*) FROM forums")->fetchColumn(), + 'Topics' => (int)$d->query("SELECT COUNT(*) FROM topics")->fetchColumn(), + 'Posts' => (int)$d->query("SELECT COUNT(*) FROM posts")->fetchColumn(), + 'Messages' => (int)$d->query("SELECT COUNT(*) FROM messages")->fetchColumn(), + 'Matches' => (int)$d->query("SELECT COUNT(*) FROM matches")->fetchColumn(), +]; + +page_start('Admin', ['back' => '/index.php']); +if ($ok) p_ok($ok); +foreach ($stats as $k => $v) p_para($k . ': ' . $v); +p_rule(); +p_links([ + ['/admin/users.php', 'Manage users'], + ['/admin/forums.php', 'Manage forums'], + ['/admin/topics.php', 'Moderate topics'], +]); +p_rule(); +p_para('Message of the day:'); +p_form('/admin/index.php', [ + ['name' => 'motd', 'label' => 'MOTD', 'value' => setting('motd'), 'maxlength' => 200], +], 'Save'); +p_links([['/index.php', 'Home']]); +page_end(); diff --git a/public_html/admin/topics.php b/public_html/admin/topics.php new file mode 100644 index 0000000..bd15e78 --- /dev/null +++ b/public_html/admin/topics.php @@ -0,0 +1,76 @@ +prepare("UPDATE topics SET is_locked = 1 - is_locked WHERE id=?")->execute([$tid]); + $msg = 'Topic lock toggled.'; + } elseif ($act === 'del') { + $d->prepare("DELETE FROM topics WHERE id=?")->execute([$tid]); + $msg = 'Topic deleted.'; + } elseif ($act === 'move') { + $to = (int)($_POST['forum_id'] ?? 0); + $chk = $d->prepare("SELECT 1 FROM forums WHERE id=?"); + $chk->execute([$to]); + if ($chk->fetchColumn()) { + $d->prepare("UPDATE topics SET forum_id=? WHERE id=?")->execute([$to, $tid]); + $msg = 'Topic moved.'; + } else $err = 'Target forum does not exist.'; + } + } +} + +$forums = []; +foreach ($d->query("SELECT id,name FROM forums ORDER BY sort_order,id") as $f) { + $forums[(string)$f['id']] = $f['name']; +} + +$page = max(1, (int)($_GET['p'] ?? 1)); +$off = ($page - 1) * PER_PAGE; +$s = $d->prepare("SELECT t.*, u.username, f.name AS fname, + (SELECT COUNT(*) FROM posts p WHERE p.topic_id=t.id) pc + FROM topics t LEFT JOIN users u ON u.id=t.user_id + JOIN forums f ON f.id=t.forum_id + ORDER BY t.bumped_at DESC LIMIT ? OFFSET ?"); +$s->execute([PER_PAGE + 1, $off]); +$rows = $s->fetchAll(); +$more = count($rows) > PER_PAGE; +if ($more) array_pop($rows); + +page_start('Admin: topics', ['back' => '/admin/index.php']); +if ($err) p_err($err); +if ($msg) p_ok($msg); +if (!$rows) p_para('No topics.'); + +foreach ($rows as $r) { + p_para('#' . $r['id'] . ' [' . $r['fname'] . '] ' . $r['title'] + . ' - ' . ($r['username'] ?? '?') . ', ' . $r['pc'] . ' posts' + . ($r['is_locked'] ? ' [LOCKED]' : '')); + p_link('/topic.php', 'view', ['t' => $r['id']]); + p_form('/admin/topics.php', [ + ['name' => 'act', 'type' => 'hidden', 'value' => 'lock'], + ['name' => 'tid', 'type' => 'hidden', 'value' => (string)$r['id']], + ], $r['is_locked'] ? 'Unlock' : 'Lock'); + p_form('/admin/topics.php', [ + ['name' => 'act', 'type' => 'hidden', 'value' => 'move'], + ['name' => 'tid', 'type' => 'hidden', 'value' => (string)$r['id']], + ['name' => 'forum_id', 'label' => 'Move to', 'type' => 'select', + 'value' => (string)$r['forum_id'], 'options' => $forums], + ], 'Move'); + p_form('/admin/topics.php', [ + ['name' => 'act', 'type' => 'hidden', 'value' => 'del'], + ['name' => 'tid', 'type' => 'hidden', 'value' => (string)$r['id']], + ], 'DELETE'); + p_rule(); +} +p_pager('/admin/topics.php', $page, $more); +p_links([['/admin/index.php', 'Admin home']]); +page_end(); diff --git a/public_html/admin/users.php b/public_html/admin/users.php new file mode 100644 index 0000000..5adbdf2 --- /dev/null +++ b/public_html/admin/users.php @@ -0,0 +1,125 @@ +prepare("UPDATE users SET tagline=?,location=?,is_admin=?,is_banned=? WHERE id=?") + ->execute([$tag, $loc, $adm, $ban, $uid]); + $msg = 'Saved ' . $u['username'] . '.'; + $newpw = (string)($_POST['newpass'] ?? ''); + if ($newpw !== '') { + if (strlen($newpw) < 4) $err = 'New password too short - not changed.'; + else { + $d->prepare("UPDATE users SET pass_hash=? WHERE id=?") + ->execute([password_hash($newpw, PASSWORD_DEFAULT), $uid]); + $msg .= ' Password reset.'; + } + } + } + } + } elseif ($act === 'del' && $uid) { + if ($uid === (int)$me['id']) $err = 'You cannot delete yourself.'; + else { + $u = user_by_id($uid); + $d->prepare("DELETE FROM users WHERE id=?")->execute([$uid]); + $msg = 'Deleted ' . ($u['username'] ?? '#' . $uid) . '.'; + $edit = 0; + } + } +} + +page_start('Admin: users', ['back' => '/admin/index.php']); +if ($err) p_err($err); +if ($msg) p_ok($msg); + +// ---------- edit one user ---------- +if ($edit && ($u = user_by_id($edit))) { + p_para('Editing: ' . $u['username'] . ' (#' . $u['id'] . ')'); + p_form('/admin/users.php?edit=' . $edit, [ + ['name' => 'act', 'type' => 'hidden', 'value' => 'save'], + ['name' => 'uid', 'type' => 'hidden', 'value' => (string)$u['id']], + ['name' => 'tagline', 'label' => 'Tagline', 'value' => $u['tagline'], 'maxlength' => 80], + ['name' => 'location', 'label' => 'Location', 'value' => $u['location'], 'maxlength' => 40], + ['name' => 'is_admin', 'label' => 'Admin', 'type' => 'select', + 'value' => (string)$u['is_admin'], 'options' => ['0' => 'no', '1' => 'yes']], + ['name' => 'is_banned', 'label' => 'Banned', 'type' => 'select', + 'value' => (string)$u['is_banned'], 'options' => ['0' => 'no', '1' => 'yes']], + ['name' => 'newpass', 'label' => 'Reset password (blank=keep)', 'type' => 'password'], + ], 'Save user'); + p_form('/admin/users.php', [ + ['name' => 'act', 'type' => 'hidden', 'value' => 'del'], + ['name' => 'uid', 'type' => 'hidden', 'value' => (string)$u['id']], + ], 'DELETE user'); + p_links([['/admin/users.php', 'Back to user list']]); + page_end(); + exit; +} + +// ---------- list ---------- +$q = trim((string)($_POST['q'] ?? $_GET['q'] ?? '')); +$page = max(1, (int)($_GET['p'] ?? 1)); +$off = ($page - 1) * PER_PAGE; +if ($q !== '') { + $s = $d->prepare("SELECT * FROM users WHERE username LIKE ? ORDER BY id LIMIT ? OFFSET ?"); + $s->execute(['%' . $q . '%', PER_PAGE + 1, $off]); +} else { + $s = $d->prepare("SELECT * FROM users ORDER BY id LIMIT ? OFFSET ?"); + $s->execute([PER_PAGE + 1, $off]); +} +$rows = $s->fetchAll(); +$more = count($rows) > PER_PAGE; +if ($more) array_pop($rows); + +foreach ($rows as $r) { + $tags = ($r['is_admin'] ? ' [admin]' : '') . ($r['is_banned'] ? ' [BANNED]' : ''); + p_link('/admin/users.php', '#' . $r['id'] . ' ' . $r['username'] . $tags, + ['edit' => $r['id']]); +} +if (!$rows) p_para('No users found.'); +p_pager('/admin/users.php', $page, $more, $q !== '' ? ['q' => $q] : []); + +p_rule(); +p_form('/admin/users.php', [ + ['name' => 'q', 'label' => 'Search username', 'value' => $q, 'maxlength' => 16], +], 'Search'); +p_rule(); +p_para('Add a user:'); +p_form('/admin/users.php', [ + ['name' => 'act', 'type' => 'hidden', 'value' => 'add'], + ['name' => 'username', 'label' => 'Username', 'maxlength' => 16], + ['name' => 'pass', 'label' => 'Password', 'type' => 'password'], + ['name' => 'is_admin', 'label' => 'Admin', 'type' => 'select', + 'options' => ['0' => 'no', '1' => 'yes']], +], 'Add user'); +p_links([['/admin/index.php', 'Admin home']]); +page_end(); diff --git a/public_html/bbs/__pycache__/bbsdb.cpython-311.pyc b/public_html/bbs/__pycache__/bbsdb.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7967542fa8b817f538823c63bcbc7b60c9824eb5 GIT binary patch literal 6100 zcmb^#OKcm*b$=w6yQJuk?D*4KW7&;FnHC+(M(oP2OMOz=mMK}3<3vEitT-!?63L}! zm)55=z_>=E7z(TaZleHgp#rHJ!#Nn}rA3oNZa(xt1_VMZY9K&Qxyg-D7&-OLF1aG5 zx=7G0Id5iX-p;)Ld%J(~`y~dh-z&eI`LmB<{z-4@mrxsg{R{^0GnkoRFpIe~JHc8# zH^EswKfznQFd_nGw%8}Yo7Y3vBx|X?r{wB0GfSGh~MHV7j|!Y%8Na>mEW^Z-3Q;k_ilLyivY-+)r4eK#RbEoy zxI|hODRu0*q^bgqb1J&7lGM~fI6+he_=JZtXmSAof$3Cs+Tt~r%%RCtRv`-^iJng9 z?PDn6T7Eidp{d#HDUfIe_@m;;YDSV`rgWsj87P%CRFc!ua0;e3nxr7$47k?~0*Kwx zSJb?srWqo;h}x~4*>5o1{hEMnsLTP-M3%~vmbNKHhqIJ z>W@G_LwB>T0uy(hD==%W`g~)L`Fn861Lq``c`l~#tub9&F~Kw3(XVLm2(qTfNM%$l zZ!GQ|9gFVghSBhkfuwUx=dz7VhN z1S=2?9MF^FQ8iB1sLc*2T)`lOWT*jzw7wu zcuDFmOWhTzyV(DD_Yd#R7W;3FR0Yr57jIrHZ97sHLKPuY5<<>YS6Mhx5ss9EBhFN) zEF7%}M@z!ds@zh%@HHI3+-DMwa^co7oiVt1pDGUetyh69Y%-WNN8OFs_u%G%GZAC% zv(X?&{4isRQ>u|jf-?qr(n2SAkg_Q#WLnoLMF=4hp!4iCLgj&=>FofD3{Zck_jd1! zzB*8r4_4%ZU&vjb%UxwTRFSC+$TmRx?`*vaE8jO;H~0b{W5`a}x|umGs|E#&IkF2D z$!-89OLQt^V6Vm>ckLyzA7&0i(_aJt42tp{`L?{$zuH%fo;ZQ882f{cdcDBP9QX(gsEScEiltO z=HSN{xQ~T6hZ!QU%zv26 z<8jNLk3{SDESUn_qVO#hkbvEth@Mt^n?NMl!7IGU1YZHTweSl(Dr$ttsFR5 z2^=hm2cJEbTh|%ZwPOQR1KXF#5?SYA_Sv&%`gS1Y;ps>h^I4Y^ImLhWG7B)#U_1;J ztYu`B^kyTY3NJu?`rA#x5%aUw4v?{g=wd`oBsJtzJ9e4UIjtHX{Lc2i(fDY4FoYWJ zhuVWdbPlz@)Q*0P+K;vO202rpcorNo*_ZCKmM~%7l}kfCVY;p23hVUPdY}o|C&lY0 zlU`Un3r!CJ0Qt*-s(7)wefOHXBzD06iHrBSL7+bOx|@;w|MKuY|2o6F{I=&cMto}3 zmQcE3uvsXWD=;Z`Q-Q%2*c^))5D6b$JlQM~s{m{efF+R8NGx`BWNaWlF!)O3%J6uw zmz;#d$SD92cTYoSvdJcn{dA)Zet!Ze1JLvi0G7Q~#dB3}U}e`*0C@FMUcK&O1dPY$ zUpl=al!Vr;LB?VAMcAd8)dEyf1$K(P2cmf31QKA8i!S!W28Rdx$59u0Wo+aU%Ihl8 z(bb{Bu|aE`g_8X2In+NgJRBJxMA4D{NNf<)N;3m+XJtr%A~iASquazM7vgk<^a1WA zXgbZ8RG3fMGUS_ZeZ#-%>E$=D#&60rd9hb=$*(l;GfMhB>^*rpU49EaCJ-H7X|ngvU;O^d3+wHB>n@^yuKDfxmZ@ULGq8v5F8Y39;uCK8`K?1}NI(kzBLHY0|A}vO)7kgH{`g z1K90(n!6!1YTowy0$&gc!j#ag#T%{yq-PoduAYy^2FJ$H`RMpaO=+D}chq-7j#@*Y zYXxUg*^vF>isOnAaugdxuSSNi48p7axzM@LF=*$4pg$Vf$S?)e|LuaxZC9RDt0YPR z4fQ6U)#k_`jC7jEXcbyd53+q;oRUOhuo$OUuLGb;-nEk4tYTZQlK)-2oO)+=dA2O= zt4RCc^+fVP0lUtz-UDBH{Y%$Y4z1`VasLyqONIywpxD3eV|;<)g{{HJQDqX&-K0tg zu8r>c-IWHRd5eo#8HkIL3veF1i6H121UUe=O##L#3Ca zWnrWujFjsCtvovk}X(U zaT*X%Bsu_JFXHfF1fM--vI#3#lP%1;^I3{QUIV(WQEq9*bMne)HLzo8>Iu*Kc0XkR zRCn)v3ODx=M>pgEY|fHaM{S)c^e z?Skr@T?BWC(c(?0&5GDXaTcM1a@O3=nulU;tYH!E-5d8oZ#H*W7Wp4xJ+%!| zI2~nD#mlTUO43^#=Boa-@IPDUJbOaTzhAsB<`V8 zCvMIc=V5AajwT<$#u5S~9U{u;{Tq8GIw2fv-OKwE7b=3C~h1)GzDB z^RmJ^LqRLUN<}aIRT;Tx{i;k$(fU=H?WOwPS^ZM-EqC4NzTI6Fe5IC)Pk2t|o-zQ6 z9;lFIzrDD+cP(0$dMZ*6op!Tedu)#rI|(5q0fxL0=fyN6Y#~re$V-7jfFwW*WuqkHfJa0 z8QU{so3eMigZXD(o_XHpd7j_z|ML8u+wC;LG0gw${r_q)nf@Iwa)((x`F;UTJ~i>C z%O;-U&2!Xc3jSK=%=4DZmU;RzJ#W2iowr@K&D$^A=b6h4W#Z|}4&M5a>9Uizfpqb9 zkSt%xGapfx-Moi)!ruzs1$QfX7Nm!FgY@zhAgg%uxXI^@`OXQ!52m64Zhk5_$8~bE zVPSEei_8dtKq!J&T=aT?JAQoJ{~T*N>a%63n_2VCo~$`=JIgLc0>b3S2Swc>TQ@+We`>VTw|@M9K1wm8kRH^>(e%)DvbwE90j_vl2{9OPr(GXp12b6k5X&ne@_ z7wh7#oV*&E4&UZD?#lSpKq!hQm#zvy7^rY|{_0{d%0Oxhxfg3`1Ee zlC^{vqgm>ffVItYDfqm~U?7I{eZtS}? zwa^`bxAsj>NA^vJrvyIYU$~=YJs$`y3LL!Zfd3IYjM`<>mYKCyZ?aX%-jq4j_kfYu zJue0_b%DJYs+NSCT*42cA0G#$*(A>1(Vh$Z>E}4g2Xn-Y?DZSMF}ZKb$U-) z_|4w*-V9rn7}?qZ?f)M7!!(m0|8T*=fBc5uPs3eU9jPVSVT#jHxRpC%@DY}*aSLxz z$5(SSKSps~;b~Y|tc8_D`z~*bK{j#gtVKVzmu#UXLoW9JOD<8(S`as^0NQ%oAZ=c< z$8Eexd;f1VE8>z3)-+Aaa_xuu7us&-nGnT0^s^6ELtA7Uyshoo{Oi17=2NDqEB_GQ zgq2IT77v-O2Ht}^OU#l(Q?fJe;9Z4Yj+l8CYT{mUmMym;?u;|=R%PL>1qx8h#Cx^v zh7?tW6!5fK`&74zI{2DGN_`*9T1%nC>TKECZPUL`5Cs>JqJyv1r%W?NN#KS7CtPt? z8fSmep-(4V=55h!g+y_~x;kUNZN5QJ5AftAV@*xd8#2SH{aSRpCP#ui@t2kYvjo7r z(6idv6t{c<<$jef8}h#bZNqt6bnx5tJtZ)4yMV}Givboc(V?Sd->fsd$--NdGHcWv zpiPj&&aye|(B}Y%L7v0i zx*q06#?GGM<^z$))U`l_o99jsk4$i<&Ym3QFj<6K1hHDSZFGTP7vi4wPk}yB2;|0aVYPr@(>wezPwRZRH zkAU2N5G0^7bleNum22Tp;OcW6B3XN6ar*thO!RppUO9nv0u|*ar=qboCl^|rpAHCb zV8(#?qgMmBfd*VTcU1^{Ksdnk)XWV5vk`XU+tdPqd!Y(0eC{lBZg_0++`wDI3JI{x z1wukKCa%H64#42~fB>ZlI9~;vWI`=Ifwu6$YeB&JU?{30HdaBvS>t1cdQ91Xg4Edp zEG0`Xz$gIHF*}>3h48INmYxp_0Rb6amIA&pJ2!Pr*acS+tS(^;st|T#PIWnflPnU2 zA-HS;#9kiSGIQ4IOhwfv@7xV1EE!itvSsCP;_zm5O>%t8WNtV|ZP`rQccg0WU;iz$ z*7Iw2ZARR-|2w;-rtc3X5Q(A0&>x+qnr%w*u^&wSN5N?H zm(1=G*8BYyRl|8B92km0LKYe}OvR<`rk$3d1Pd#xwp4;f%IKI&<|LCnvsQf^3 z?*3WH(;|CXwyfrTZQq&9H7MMB|9}FzEhlUyhf`ru2PNi^%p4M#LqEu(4y_!zdwBIQ zVNj`_-&)cGzqUQJi4DE*drIW*n%DR*eUE&~mGT6nhHm{f0^jO`haap{zmCaG``7#A zrh`v_s~wfuqayhg5hfsE={~~6GOQ;#aQ7sB+M-mzztVkcdwz2;eeh+;jO^>X0Sd;M zum?upOnxbc3)JBV{dz{|mtJAxskmj9G7u+g+$z-RRk^s8w-mqwuIR$mtoZ{<*bX2} zKnzOL9Yz%lR9;XW4EgEQMs-22hcvcADUm82I8wa5xaI`lOVTTzf!rL0+<<~OwG;#? zq#j&IUZq>)g1p#5UO;%-VBLJXM`NF88Q*My@gYk>>et1E3pb--!Ulhc3C3 z>zVPl`Z=Ks-g@4SuR6F9XkkB+G$z+pp+k8x5#~7Z#HKVD5)T1y!dtP5R(@aW{Jz$A zxz-c?t!D&3yppA-!~7jW!vvCf19xTYYG7gR4zRYNtAV)y8gamegZwRKbIwPu9p!|> zSeQi!^vCEi$lGVmnxkP-!z{(e`iFptL(}wqt?+3=r3Xfbxb_0C=G0Dl<4s?T86rjU zexF-F4VVx>0hd0)2eWiE2nHP{5*232nPV0L0LGz(xnLA+Mv($fBy>=~ zY2b4dal-i^FZ>7|WvThBc`ih#UzSF$8^L8FPXHD6cL1w`;wb!&BnkW5WSog!peo)0 z>e=APM%#B*v*Q#61P0{t0FXY`4Nt9=NprIAK9lyQUw>G?c7E;7qxVFvU-aNlH4!D^ z64NL%jUv;SVJZ@~jGakr|0tFmOVRh=d1~J&+IOa>)*KJ#;DnS4uRa-nG9od9GBYSL zgU@K|&rf`KB5?`KIy>&i(}U@;^q{=GU2?Zev`?meBJImSnvoAj5{H4KoqEtM*7vR* zlw5rheL$uUi1Y#N^Ais`#rmGLI?1(PqI+e!SEPIM&p)quuv6Uo`sUuwhd%{w$36~A zdq?EGBi~u5`V-K8l;4>smPqSKyaN(gCrGR)RN}8sd~{;@M5dxLX04&k zQ)ZXMa5BR!4?q|HJpN%kIVjodWP9C~iLo}M&ZlAzu9DMCr6;*#HBPAYz1T|ZZhSTV zZDrfj%C_{PRM{<8b|Z`4ld0a38v6K+#Ly;FmF!8*r1pcw?9{{VCpBQ~%Uk{69BqF< zWE)o+lQWW|UUt-PnJm@@^<$pSu$9TG54OZa zOk(O~re4hbw(O7#$*Pzcq8>w-VJ*l{(cBI|yCD0=Wz5eV2L$H2xj=~L z(5Jyoagku?+FXF!>)*?TLtHQ#;lj5<9D-VZ%*zc-hXpXa zVa_d?w^CJiHTdcRP^PSv1ZOv0+f$a*nB?MQ7q>i=aa9t)&O2^)D`+7yoC?dX#E>4A z^GQ1;rb}kJM5aqkTCx0BnX2kfTYu4-V%Nr!ty0x%a@A`Id%~WpSUh!2s%({*HkoM? znYN6ps!%f(+eFu4i9RCJM@0I_rmH4-{_c_0Bc!7D`d9k#LSNw~iD{OZW|3)D+DF@# zBao?k&okgEeXC5eB^kZnk-C$=?tDBmK4GA6#F%q`o1!Z%DKc1CO!3 zg**l%W)IS4vf2oFIGP$t*UODvlB-LiyJfmt%>4-d2*==a&1k12vN-}m{{|~^5hlRH%+u=PPKDaz=F)%x@B$-A>w|ukx~z}z)-qll zL+wF9vBd_@ECMs_#%T+aJ1X4az<@a)458JOo4y0{d=8AOWR`am$NkLQR3s9dK?gn; z;DbP^{V@v{=bnFq9dRDS3(Eh3a{6eY2QI*T33D!Mn_5_awIfTv9}b4#Q9V9laG639 z3{F$Rh0nm<2z~>cT?&=G2v-7%pW-tC#IorbTd_R6>2xOocRN-)lDD5acZ<&5II}LU zTuct9n-do$cbn{P13Kn%C#zQuCBO>j%`onJ>xOynun?P2ww~!9!Wb zdj}+j20+o6s{q*ST3~~xOaX4-N~7X~H{A(-HrFg&3WipXl`XXhf*~b-%c6sK>)ZS) z&`&4=uHi+89)g8&p4P&)(MPKDZ)qUiIM-DFRu}Pa`J!m%R}a-VVR>08JgxU{6-7<# z1j19&aKl&V`v$y9Hr}Q4Dp9Mi`zpa%5>hP-v*=4|S1$(7UKpKd@9-&lZ8W?PoQZI^ zRZac2pTy#zs|c+JJ}x?=q$9T>HmBe}n)OEjzbC^2L^pI1TSL}1a(3*(nQ@^RIwDrb zsqUElec*`ccwvaEWk1|d)a|@~Md-zQ1jEUK<0Mrf;TDRAr3lmy;S0zOiH$&U zqZlzgtJGnVgjNJ0?F#&lOc7|`GP@zP0(35LPMEhJ2ldhH8AJ#InV0Zm_m8G~(i0C~e=_jo#@C}_*Vx+W zwbSCcu`LVLGl6Pr4Uz5(sJ4Pc^vp>WxW!6MjE5S~(W$smzGYjUvJk1!CbMlyKu6vc z0cQU{(G{V_Xr%joXIUHg()P%NW8aff_nHp+RTU=J5XuvEXe$0yO$ZNiFMTK^8^2 zpCQP?s)rx|4n7}bQEp*&knjm-p~nCWTmS|vgiN>#ca3@yJk@v*;7X&*fIq?u>8rHx zV+gU(SnBnQ->j`91O>_hOO5`0qH$?7_T0AzE4m>V<5fHa5P0Hzr4WmEd8;nO!VnIl zgCH%~8^)RnLHh{=?fbzH3qvU|z5!q~-%R5pD}tk+LCetoQgj$YQ6MM;9o6cQgTkWx zu9VHs;JwHPY*_OF8|7+Z^i&uFHmtE+z(%7kvq5_(tO7Q7ClK|;8VH)VVqit@YXr|1Fwz1~Bf>R!Crh0smUnO& zfZaOq93v01=7kyI0-U^{-V~4!XC3((#d`D5j`Sa(9fB=xDP_XNnX3@Y0Dtw77cqwd z-4OtOjK2_0qu7T67k?!Tqe6Hse*@vZ5P=~OZh)p4jZHnz4YDdN!0HfMkadItwe})G+hW7mb?e3zb4x zwqK;S7*ll}*v~sZzrD8Sv-sM$=e#A>`r}q9~F(QWw%cdT>?pc1TR8 z%yf!Ory4>5L6Ibc0t^od;EQaVft6SsgFqlq8d%9|z)HXj|MmmFSlxr66l*i_a9a%G{BeJLScjN2c^}~;MN!`cf?qd)> z(MR}F4Uulbnm_^>D#$_*#jwPT$jpeyjO2nSx+HeD%l<~ z6y!peFg52IqWf#qy%m>%uVPeL<{8k|rg$*_K*Zg|^(piQRcC|v^>7OvF#hnH{+u3JopM- zhxQesH1@+6$tjIG3o7h}Dh}HMyPD#}E3>-k=X~CYr`Zp%VAosUz;;DP zHR>$cUtrg}5;1^Gn}#Q0F>$*w30k$drgZJf0pUER($+V< zO%PTbU4UBLmQxH%s;vwtn>R_Ee(64R1M#jXAQ7mDmA`^Gecj6E#F z8QY;zx%wCQPKfe#g`OmcSo*6N)FaB2q*gyG+s1nRHvtDPMl-8Nk%QMAcb8T$^m!JY=#yNXW<@W;#?7i)5A;@8UP!i=J? zs&Q<>B9`h^xtE5HwQ*x44o>5lFU7iqTUdY8RTWN?3+z)#7l!raSru&DD1^a5_0K81 z$Dob-JettAQYtwvc)}?Z1xr0H5yY7%l;TX4M$U8xlfMoEm{NElYf}c6Q6a}TKLI_5 z#E?&VS-1_C5!`N?V^->4MLoZXiBX7{>_4^Yc{>>PD~MsA+CD;MPQ1PG0Wgrtv&dIK zCM?e?!S8K$RIBQA@}R^v%50;^Hj-V38E;MUitODZdVAC3YdaqP7{cRnn=SGlgH!0D zuCD#$c6!gp@ieIVKG4g0io=#BpUunZcvO0O`eq(ATC8TC#Z`1m$*fx}&OV4dI zNnVZG?A?ajBASRu45*b(j6h&t?9o-Rd*sP=5b)dV+P6-vcRj9@x{k?R$3*f2F9Xrj zDWa#NME}Ad^{jS#%632aZEeTX+779OcS5(_)C=FB5?W_=?|}WL^$$YiR4r88 z-XVE9!Tcb4I{(#Er?y+HJ}j|EWcG;29@(sJOr8Ju$o(U*=b^GX7c>XkA>7TIwL3m< zdEoo3^FgOn+a}kxB`upC*hdTP+R>Hq_pI+<=N}(@GO^LR(YMk1^_6d$#qLoUCEjy5 zN;dzwT~(|;G)75=iug7XxNa>AE= z=Fw3fB^-lk;H%EsCnx#v%;Y3dW3pD*Xv|M5mN%r2N~nwt&yZUwo1DnR=kStjI68o5 z2)4>358@e`!H70PX55!1QZSi^FPJ0qVIJaPj|v|`BE-_je}I7TK~WjgKGC!xm0ld97z#Ew>qT7!W|S9A YyYMnEj#FN0=awli+B@)Zu^>J1zfsS5kpKVy literal 0 HcmV?d00001 diff --git a/public_html/bbs/__pycache__/games.cpython-311.pyc b/public_html/bbs/__pycache__/games.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ff66c75bd18928a8582a6b3f65e9e11c683b0893 GIT binary patch literal 21279 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 U<8ZcILkIQyxGpeKn6mEtzserylmGw# literal 0 HcmV?d00001 diff --git a/public_html/bbs/__pycache__/mud.cpython-311.pyc b/public_html/bbs/__pycache__/mud.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..75be60adf51b2a3e90db2832f62a8a4f68e9be2f GIT binary patch literal 33860 zcmdsgd2kz7njbELAOMo!P3ml3A}R3@t(!V%inl0J2dRscOoMFjka(#9>VQF;_IlT# z!-*jm9djm}1hb`?rFS%0&XzXIt!OLUT}_oX@m6&^T{T>!n0PChs&Q4SqA72ZiT_CQ z`(ESd20=>p%vNos0X%fS*Zq#~eeb>RdSCxRYN}O-o}@8zk9jP)Cwa`$W6|q)({-ax*OSs? z?XmGmJ$ByAC-W9QWzpD^%3I&l^`!AOgz3B;p@UCFn88=@>F?=#GI$6X%owM$K$;Bt;%*6>)qb@h+RAR0)oUggs*8SR9ZcvyQ z_f)yYd9HkPq`zitim#p=o~+=UL(Y+Lx5r93v*)`moE`C?6lXv3spQ-foRb?Eb_&h` zk4xYNP#2Gj=O)HkiFaN7+<vwUJqt5$C7#+FevJSfh7w1G-cSZ(WRd-y1+gZ&u zPK@5NL0SbKp-&DD$k{g&9ujIyD71h=+3Hv%LOdr0$tYwR!*ih|58XCEw?&R5oB*$NUt!2#WOyR+_z$Y@i^lsj_wf3HXv&XJ(H~P~ z*#W#WbPDM>uP}s?8_za$bv1W|j6*I@g+62)7e z$tpMSr3TNn=*uCz5@Z02sc?)QyjoSdw$&Ni5?Sn;FNmZ zO%0*?r}_uPtdpz8l{=EHMYgqwrWPR|X^HE87AY@N_i2OAAf$VuZPDp`h99BDKaRF~ zT+i#27M5*nAq~(-3#Z&Jp>Kpo0}ZT!Arn6`;HfYQ)D0oy)CeEa3vMcji!{|j&-YZZOVJ$aLv=x5mU_d{6WbTOn$U?VhD%=7D zzlWS&8U&SFfneI;A*2W&H>1Gv8F+&VuQgz!v=ey3M>DIj(_6F=c*4X)6g>!Tp(NM6 z5s$mV#K0#|?}tn`-N2Eg(FrH-j)GDc;ROzp2DUJwbi2?4ec4*(c}h^XvU(v(`16eWs0pn2peZ zXv>ke9Y4DM>I1Yp9?m|jmnz5ry<;PO+UPUJp`1~Ra$SI$hsH?wxT6;UP4q%!eMWpd znqJ5K)28X9Y4dclhbF%8;Y;@7Y}(?nMPl%*jyGy+h$~a9zCMG`;QM-r0SQ%}C04Ee{XXN}7-=*{oSf$b->aIXqc8=DgR3Kd0wbCGQ%n zyf<0N*3QbIiBZ0SyWDX0QgatqUSCO{S{BmTew^>G2w5m^%2yy@d6U?R>dobxf`Ano zOJjHepAbg*>WUOL@ENKLS-77~+aXI=cf-Z*zP9rM4ZBc^a@o+`5jkyYZV8!O1H%)@ zfXyxjl$S&I>IXuG@v)EzEB-BnsDH@VKRg*qLZgN!-L&3e_E8RHy0y8+-BW_Ak3b_F zMtTi??!QGqqsI}*3iK~z&kswM-LhqOc<|_x^48O{0u8fw=kEH`){?Csmx;**_^zep z`Hkz_N`nK^w!QMUy^<+!=Ct4B=L3!F_UwRrwsWR=-DI9|KiBC~YraUa`J3kQ1Fr;b z&Yub%SV~%&`je5>66tWu-=v7$SEaVAqUGAJk_<_``oG73?bXw;?bZJevo0ycpXGNh z_pfYwJid(2YCnC%jCRR%MmC)hO=s3^=^tAbvKIP-*^9%HqfB;`Nw#v?RxX;#l}S^D zZaRVs8_YLx5y(4wW!FldZN_sFVpBpydWrx9UU9>GYD zyVB#j=ZV9-icgISb{I#-Jt3241SFKQ)Cjb0BL>fCCP<+g@hmun6Zdfh=wa#%(cZk4 zV*k#-$0hR>5BAOPlhSs{X}f|Z(Ng*=qduwZ?{x^!W9ndwh3A{m4G+T$gS&b!=)md- zoxnA8MQH|#ry)ggY{=r8cew`yicn6XG>3cAd3T(1b7#)AcPez|L_Y{l?i{YFvC6os z$0qtQ(8FYjyE+W!ll5FfXA|eHb`m9hvX(n{v8nkYclsKlM)*(l0-?vJ1~%Lngf%pa zojujZ3U>?jxV4dBT%y3(X7_gib+a1uUo^I??g2*I&k#lq0W`u z$R^MniG$|IrqCP`CZ@nn48_2y&~zgN&k=$rN&>kS1L%+npivM~b%c`uhQLryP3_s0 zBKlaqLIdt;C&+OKfSnP@9sJ_(vE|DIxRV68lLWSt3e>RrMh{x|*f9QMoM?x!dH~;y zKC{)Joz|-ddScR-$j2t#jlyZ8W^LB-2A`2Ps#a8^dVL)<#7_ouMQl1q>hyY}!-0A* zf~_Hx zXhrln{M>E?=y+@Dclf!H*;{kBBA)xy>0r{&KYp7EcxiVfY8<&tcw%x#eeHjcn3efJ))D(xJuGIldQQ)(Ojvag4{J}_Sn@%`jJo7mg+U@ zz<#mOk=6-DfTS2-zJkhz4EH7(jTJI01lyN1`XMmz++w_a$VkMGVR+KpqC#th03@_# zIi0KzQ+qhUg9z~u!h1kI-SiXQ)HRn>bK5W15QNZ3|tH%%l$UL zZO#_BEm`vEbk;_(rGAUwGG_rHmr7@|mc)^nS%J2BVu?y-lf&cc>RM`=|L$DRLdL?; z#qB}oQqsfWW&LvLW6Mh6O4F0F)!NnWr$@rW&fX0&BIPocJPI*F5q^<=V&(Q~-V?8s z-Y#0&*KFzjgUTSJ2_$lmsFF?S{|Bs(c0%7|LMGNqXj8Rp2uTU1lhlg zaWl#^qaBV|O(ykxh86~3uoFLLDXL_+-bSbG!9O{!FFX>4w?#m!Ad z^-P4kRWp#!hMCHyj)}}vzv0|uM>+9xll`UUrXAYe^w~F=n~;ADMv=Km_zOOnN@n87 z&Bn{lc$j}9Y}=)+AFyqA0&G)_7t}|ETRcbPq_zgrsdP`=9GR+y7F%KR6d@~L7;AT= ztsX6Q7g4#VF!?FB=(J;O{t}ewJ>Y^S^8`vYl|B8}M@83n4qu)SQK)e)aJz}Qt zM^~Wv6iLD$UEO1R7NmYSCy^f|(LY^}PE2^{n*QlJ%)U@mBRh==;}|KbqN4Ug z$1LTmL4lHVvIEH>6Sd2`4T=1S%U#IrE9E(AIQ>&nbqLa>J6{`mZ-h+L#M!Bs%&HQIwQ%cQlQLH zDNTl52^ z+fz=!)|-Y@H|hfoEom^l4yIEbbVAg`Iqy2}R}X#fyL9|@y>OPG+?cS!5y4U|V%!?|)7X%3 zjq)>$-h!Onga13=xde!u$B;&7;`X>n^i52D>`>VKB!@tf!><`)Bj# z@@G!3*;2tFm6QgDA9je=W8W$9y8{h@;rR~9%E?wvv~tVcBt=N`-wqT8n&-=x8pU0Q z7Y2f*i#Hc;E?56!*M{4PnTY9C;UdeV{wp~XF5SV&q z+Gg6`YYUhpQ#PHx)3#=^yx;L|hv+C-GAtc_WL<6&%Z^EoW0I*(Hr0uyy0v6*Ywz6) z)V@3Y&NOjZ5!UUM06%|JvTTp>Z+rYH{*<{CG}k=e1F<_@&Zewc>>t`bu!-6GmrpO> zd3C`*`dXJv7jQ@Ev4<1)Am9x zpP4(qcj02tyx1e|tXVoJW$u?V_dnN}QjW2}e|kM5Cor(^%7dNrJN>8E(la1tOFg#c z$oj0%cFt^ygp6d8Gd%obo_WA8n>P|UzXV4Hl_ApgSty&V6OCH@5?MI14tRoJwju^zc zUsxbCMMt&IS1xBPAAVf0jL*vHl{-()tl%SZt&*ipwzP?sws0Yh#FBdEt^}L_+(r&edx$TCOc|)?jF56xwluDZ6XIf`k-)r@I z-Z@8S@3clSs&pxHspZkOznbdWo9Ytc@#}rBulGo zX%#K4>oG_^(?8quS;{kf#I8%?`WuS=Fb+0rLk`qtAk1GRz6^T!t459+1# zVmZCoZz7b9ASQ%p1!1Jj%SW*lvv!ofK}2v#(;q*~`eFCOYd`3H*eey*%7wMdwaeX) z(KE-XXCTx?KwY2&(C}bRfCBvRO2*2;CwUO!H9aj8kDie%9kQiEv~)mTFw?>01)-!) z*qRK7lDa0Q@Mmhq1RFA4aUnEZ!j#oCad*7J9MX@6^zM+}71G}g>8FGiOg|cRZdB56 z50u#ZhB#S+einvLG1^H>Uqkq-l3Dn+zSpGT|F*tYBTslizGk(|03tMlw#M-`8`H@? zT@WqZc<@F%sgi;)8ACkRsY}eCf+E!jy1((WDtSr5XHCMZsCQ!dMI6^kToX9%@@dPa zPe947;eueM7VG209L8LQ-!KS)=6e>Oxk;zxZ>NcO|IVhR*|hXM>X(B0O-)9pHs_$3xt6Hp7^B2=L+c+2s=6MD@MW&hmn<}InnP}UnI6)? zhmUwE2&+xnrg`P~($w;ZO=_bH(mb=_;Pc~avS~R57@aCC#nrYL>~W>)30$dU0=U|t zxpQaiYS5gh$J9I_W|UoO8IPJfz6r&Ja%jhcwoNF1x2nVqJRTGAbDhEsWaVuNGkT=N zj;XuCnsG%16(O(dW13oMb(@;I8o9IWueEn`hrDJo;;iC?E6DO|JqH??l*V8++Q359 zc~e22f?+5JvjdAv#xQNL`6Gn*>L`Mb^`C%d_33WpPQx64*TtHGhN-S@v8CXTvVHmo zhPgbjt$N1jd`f19hU)zx{UoET!y14YMi+l=Juy1s_6$snO`h`ZbPrG5?W5LJpBSBh zfu;KtP9n)aK~e*7h)LkKv zn%0}nom1>t6fJI@*N7XvDU8E%yIgewSs8q-uk!ANh6H)l0M^{uJESw=xT_SyajoF# z*vV<<_${0;o{I6O0x3esmSy~wMmHH_3OD1X6t!d;vj$`8c&1qnr88q$!QD49&LGT` zl_B#mbdxRtybDZT6*HV{D%c_b;RGmhLMAfqjpLmv(nETWVh*6NO5lOajC*p2%nCR{ zX7KLFA!JrCSGZ5vnkQWoFdr6vhc5K}Awxe%lOb5U-f@#ynd9C745Np_IHg39Liir; z`Iq>)e*rZ*uz*;g0T680=1)i|oSeeVoL)~YSSSW9TCnJoQY+=u%9)mgQ9Bm8f`!5M zhm}%ljhtFTccuB=Vs_7(BO_2TZ}-0q;w+5=ahAr-w0vPodcW=6HviQH{lcEb6e+b( zG8M_DBGFU?3ikb$cU$}i0-1r9`E8PIyJXrSn|6q%9qOc3Q2(|al4+-G+9{fLt|f!& zHYS|_ZTX@31GE1gbW^e=U$o?}W#kbtc!CA~hBbTYhbC1zBu_JeIwDl2B~St(MZZ$^#JYM~tZ0?8S|xj%Y;P0UHw=M2(Ry)` zrBJpMik8AK2=)*N_QXJNXrXrD^5QWmt3bMIiYm7)sf)U9@ap&)l|f zSjsGtGm9uEvf@c8i&iplXyKKG!9@VFRI=}p?R!M_jrQ3)9q)C_bc8QO+Y#jNFM+-> zBXD$n`+_r=ym%9hY zP^pjbe4jSPr(#SfFA}<|$)j04>=ra-;7TQ4PoT_-RfplDx_)?8MXW=a;83?tT^FQf zY*aLi=WN=qneqMl;*k;1&wc1K^I3pO)=S;}Bix;hyZ@~(*}XZ2<;2$n<@}YJtb9SQ zr9k0sWh~S_us1ffIxaDT7*EiLq%?FgJo;$iJh5V*cEg{ZJ5}d%!UF zzGruAG`*l7Qqd1-zErfna5~+W{>}RVFi)ROV=yoBr3rr%H?FpUu~LtuxXmZ9I4_LV z^=8!i9Aiku{6LAnL)*@2htCmI%^9&gP@YN|rF|-{v?#mib7=3Q(i6-PJGHgKmAYjc zSRysU72gYZR)x8k2X>*}+L#10!={+FTl;h(tlI)lxo?f9#<@QSo)$7Z?c5Sii?k5d z>jK8P|M+eD9ypufa{y;6rZat+FE?KZnI1BB3?Jx^%(!(VgW>RAU&a=*Uh#%jnC4FT zGWe3%a=@3~Vl#-)j(8O&qQ^@U;MATCcWsGNn_@s2@=@VWLJYVWSJU!MYg(S5rWL=X zngYTvtm)<};J)}Cc!}9GY0d#Gtz=j_xFwcW#pfPKnydazuoRe@<)KYs;Uj*g0S;GR z2fj>ae*;X;ei0`7vZu2cHrISZY)+c{VH}QBD-w|{Xw8}gFni{yinN#+2JMfH4~&Ca z)l~-nO6-cQuz*Hvw-wz@xQN+aWqzT`J1f3)O>ceE8U8?A?KGP&M+vDh!6EO_h(0^LYJE^(zl;)~lbM>m`szGyg zT65JHyBf46ns)=qbLY{HCbZ)}Y&rWhZ-7AZ+?eM;tOMeyh`1Fj2eVDCF9&l<^K_ms zPd&@N6o(30b>k6FcMG}5Sv3x{qar+8n0$HDxoie%@#P9to>%6*A`NSSLz zKXjlUPI+i6nW~Q3@%j&l3n_E6sKZ%C@f<|ozO>wDOF7SLw^&=;@p2w6(nj^ac4gRt zrtZ|@2j=~hxnF*(S+C=_IO}QTU)mae!x``_X21qdV+=OR45-0ZjoeII+K(Vx{Bcyi z_-%1!eAlKc=sB&dFVPD25?F7Y`%7Tld0_n`PqVtM`~@x6Pj4R$X&&K`L^L&O_dbz zYI>r8S5puLyqbbYC^4Fw3fSg^P0PO=54TP4yb^yW^8J3r)y{73F)PP0C3R~DQ7-xFTR(O&*#W6LWH~Xy1d52lb z4#OJ{x$dd1t`^=x2P#IgM5GGuQ$Q+ap%{U;BB9Y`Otl=_?Z`BWOq1+QhV~ThC^+GD z$|9GnN#Tp9%mb5I?DlthU_E-j8tzT>qs(gd7Bs(lY{tKZ_QNpK<8cn$@>)9x8h3Gb zeaI5o^Q^J@<;qIkWlT>SHT|u|4(|_9CN_5idIBjaz1D65B+JNK*aR0W*uNPyK~~g| zsy&;`c2nq~2TKz+J>Iewjis{C*z2sZDzoHR1*u+dQoWMyQZI%~uuT@cwkxblm8xYE zl=umP5_2C!aDpkadvwAhT%w%I6IMqta@H8idQ+MCF3TH6$U+}IU6$}^W)!S_*cPq_ zvtQ1MbseiX>pf}=%>}R)AWRG;!6maD+;Cw5Y z#A{~?_W?L?xL?P44RERiJx}x?GcV!+zG_17vA#p6r~Rx%G3zyYGwf!Ko4r(a4VQJF z==Hj}?L0Qp!NCogRCL0&VF&&Juy^q%{0j=UA&Td7K`%_ZoLLyls>7#jFy19QJP#kbSTrX;DrT0|j5yLczy{ zO%!kwjbC^8ez@iurgLT!p%~+n9e&J)L72s5D2ZkO7z1D{H0~!qS56^F3FfgWfeaAX z4U=A51Dhi-s3*vm&_APFjQvyhz1-=k`^r$~T-8I>Tp1!cx5wog?Q`Fq5co252$keL zNG2293FjEuaGav~gLlF6)(G}2u#_rw(%liyFfTao3Vu9FPNzK1dnoqzSh29CXS->= z`C)sI6N(ANsc7zAF5#R&g({CwFC_I(I0YVAPfXHwS2DL?F<+87ls8Xfnldp6oX4K= z@jBsmk*aud*$Gn}zFINYaaWKH3fGEX2TV$uN_lS!cWL|L3Cio+2aun9|M(j_3$!hz> zF2!_(hOXkbgBSs3IL|CuVut+!PYEsxXe@?O;8qRWJK)m|TLVVmUTYE{H{F~V85cgF zbmQcmN#S49nI+os#E^xO!zd#BDZ3hR^CLs3k=5<;z@-@30tx>HDKYz)itTJMQcPfK zD2Ya|(%Pg+Y8f1SjiJ+>!jKT4qz4qNP;ehXNIxcgME}MiECLG@eT{-q3TQ6D<`e#& z@P~Ak42O1baRLM0JBrnt!AR;GjyVu&{!gKkMMIm_m}v&|2;M; z{u&lEmaIVILZ)QdAzOCLG_0Fa{Z6>OaZ09q*_1Du@}C>V4M{yQ>yZx0a#psS6)k6@ zh8t~?rCqkLoy_Tu52rqu3S5)YcgyL!f1Y0Yd3vdoUM8oP{XD(u^Ykhyy+%&2fk}0G z7VPU&vI3q@-u~$Apj*n@C+F>BQBfO`3dz1#w(k|~d)Mrl{uZ*mCsX_bLA~TCk{w0< zq_s5o;h7tTb$?N5(Dj485BDzJ{{8A;wckb2AC7-8u6T11({?TQ`;A}N(mpQnANLch>~;frU@^+bX4qy33-p)-77XV8Bnd`F2jWbE2L5TYL7JEiF(b z+IHi+S-O~6A=>D>mX!-jlhhj6Fj3wCpX4Z!9VOVB@q^*@oWfvn@b*%{L!Xp$P|i6B zkY?uk8`pBS(^iLK76kNbS=oV}`IA4-+V^?ZzNJPfYrmX@eG>YN;^oGb+Lg;sj)@1G z@Lk)s9f)&j;nH)X?oczfw8IC5DfLzTdcm#*cd&8E^ss&Dwp36n7u3?zB`gRutYznX z((+LYdNJd>M;@G;KNr9URx`N4tjow$AaZo&fRuMia@5O?deKq8d9(M3wmp(<&p&9h zRSg9YSUXcvkA%_T_{zX)$&-;4Ai){gaYl5UShLrn?ocl_^7}-g3 zV8OdsFFE$fj(wtI-}?65i*><)@76EY1IkaNXg!Bs(sNj!Ow~Y_;oI?$h2? ze8ln#lH;Q6xF|X0Gr|h!1urRfFRoq#%lr3de%bC@{v5XuHv7CIuu__iY zMEm5#M<;?t*mKEQ3_q&Y3wB~tQtfwlEba)L4o(H92s5g_$jTwC#%7%Gh^dks)v}{n zbX5P;f}J{nJ#*fLtC(rT^wPCmTyWdsIDlFHz!nDP>D7zRtWRGTFI^U|^vaj6i%mBq z$Lq4=bxsWJ+Ou|Z&*FO>V4W7 zn-Cgf6G9~O>7cN*=b=~1tCbwD$c|S;_Ko&HwdAOg9W|n(W^>@v=$4`b{96E@hWdIw zrhxCJEn-iZA@wi|9+=RYS`7O5zBsJui=_)e_j1y567|R7Z`2=&`fNS70MlgK;yz4A z*$?k7XD+uq-X@hDmvT?YxhMP$etx!f&7S^Y>j$lY#@VxTXZ>f_^R~}>gT)Wto_`zZ zuGzMAJ2TgOHR^1`^;w&E`HFb;x_tSDc=~n8-Y47pM0;O!Jvq2ySUF6#h^u#=o)M3n zld{f9_Vcp+yl6kaIpFt+Hu`Rg*);DNV>9ksOQt_c`NI@7jV_+=!lhK(Eh8-Nmdm@P zylyEnpfRSSh!DeTW!@Jv_lh?9M%yqBlnRv1TNfG@_{G-Xh2Xu1y)X%Feq6rNu;O~s zhSkaRbm-Y_@xqnQ?u$LW;`M&HXFzlfibLbFYeKv&i0(J#+dlN8pnr=D6|-5G`ZgIV zBBX)#w*K$fvLj|56m9g~a>(bx;T^V`U3+vw$~`LQ9`(0krH0XF&JIGbLKY~?3bs{! zQvXrCGTwilRr7gP%`!&+K{@Lntqz6Z{Z0+5{L|LYk9CO0I@atsir=&1X%w)GsgkuI1e<&?@f zr35;T1!!(~0d_B3cyRYY`h0o-pUsdgfWf^T-*9QyB2SBXuSt$h+0iLFI;HbhL85&4 z)(3Ab3`prkaym>}lTu683)rImlj5ZTph3}c(W9ZI(UlBo?{RtWajD>hTtIfjFR}V! z*V(qhU{=uildPq#rK`&gpYD9LQz|$j7aVzE2Aj64++Xjn4@2oeFWbNR?I0v(7K^rG z$yU6Uo%c!mNA0voNZF-wcB#Law(K36^w$i*>_@qtBYmywzvZGmaG;h`s zVrH#qqwjhK4W1$?qg2i)jaovBS(TE#O14*t_9|K;ih@olzg*5Qr?ESYDS<*|p;*t^ z@k#GTy`XQ7Jlyt^E6dm9s(LBsRXOL?)eJeOF<^*-`Y=e~3)r!G?U@0dkcK`xBVN5G z_Pilqb&3PL=o*y=#>9z9@%DXr!YdwoQ_6c&a` z>Z)Y9CR?tNhs+yEj6YHAq}953Vjqq8eYFRH)=E0D`y`U>bb1@ZyKe@nRnDB1O&0@U zVd6j{H-7`wibE{ggp=6aALBt;aF4`2BsK^CMPB~I%qwEg4e?r^WP3xly&;<3P@1C_ zWSJt(DN5KJt?)`|%E53`R1Hx)7%Gz8Ndb=1PwkIwAz~4%?vSN0Sr%0w%tF#GjLAOQ ziz9>(>!T)+fk?CoZIZ2BwzZ3<_Kh~{w6Tx%#bUaxLOjz z?yRs2NxcnEbFZRj6SCQcOs(y*Do4d~gj{1$UU9BQH2JCBj;M7xH>22w98~_`pgoOq zRYaEQL2Cbo1Fck9ipT30G^G&&u)`N;KToBfKuVmKV{06YG<8Ip$|gL4gI0q7OTexo z6@KXyydh|KcS0V zmi@GaJ@>u)@c%nBfBf_0U1IXCHAl?9!I7sn@xWQhaaK%!O|-m*Z74H?Gwn0&@3kxW zVUH1AzSF*LO`F>rxDc3{zbfXG2D2sW9@)BQrg6=b{QjAD&-jJF-nq9V(=OSxOEm3* z=VC0B#j|hEy*<+~<9eqp>;tzhJXh zGC%AAwch%8yI68kvY!-fr$p1KEylZP!+7U4?u6lO8{4&MDpU^bG^V{Gf1|v9!#n^_ zlUw?lj33(uwJSEwhIGYax2iT9Mv!VmBp5Srw4{9^Hpjp-x8kFQcJ+A+2sN21XncbC znqdNCh^?!c2>WZbFVfh4zc99w5L=_yQP91Z@(hbHAufi5oXP9^oukh20aqQ1`qU^m zQNHjq3J9MOLG!%g@5Bg!uEsf|@b4&%f`3Xl{(YPP$E}bMew@VwT`h|hQbviKQ6gDO zWos#rF4^jLzI$)x-WsGE>6N0TQjNG-SauReKQGTo3( zH$>BoHGA!Hmm*?=BucV3iMD3Z)cpS!9;(+j%@_fW{pNVcR3+J+G5<;tfp|5Wq=o-P zT~8+8Ud3ZjSWlv0mNJ1u;l?h7|4jFOfk2Bre@)R8EPQk9*(0WxiI%d&c#=5w8(>fM za`E!*mE6bo#o7kR-XPjei>A{G_C(io)SK*n7}fHxa1uwgXx?$4c*NDN-fXrqMrSKi zn9_*ze5>`0t6uLxqtg2WR1`~Gzi-$vL5|+Y{)9@R`G%&Nu(PH=t7o&1_ej@>z@3@E zyKQby!BvvGnk&K^a$Hc1K@)U$*fq}Gca_0%VE`%IvX;#M0AZT^p_m&PgWoX1{II@A zQFf8W>&FDqGMt3Td{8u5C)Q3iVsCU-6Wiz7OoAhOGuz?X9CqfeCUvcNm<^&`DFg&~ znl;ig9$WIYNFLr)0Ux$(z!)?xrZo)R~}Os?@l=REz)!bJh2L@CBu`7*Z`GDbd1zzc|5b$`4)qNa412mZ0c4qU#Hou)Xn-V2R?f=y_9$hiEp##bG1+V ztne6{#OL{JG?`m**aB>>wQ3$$e*X4}S%&T9X~AQrY0Y+_KP z{?%Nm@M=Tps@ZrjNo!&bGt%@J;-j1$sGGVR;DM?fO;6%Zb*Y+J|Apo3#MA0}ZP@zi zGcwVQsszwF-9}v{YIF{p}ktnRF42H zHf`K~25gGM^g`4|g+sVf=(ilm|k@yW<>OEx{Y@0QpMlhansM+Me8k zA5jS8@H;Fb?>5s&OuVgIJ+4sJcwVvm+A^cP@m9y|8Tb_SaHM)hp`H*$k=r&ue_&Ts=~~yHjx3hw|cAR8AG7~knje&w!)$aOP$os{S*vP zz*BIH0_H%MiB0VaCxQ2mz>JR{92bU>9x^$vkl$RMh0X@@lgqQv$-L$AM2ejc_8*D4l|3;zUlOGv`px7`ZIff+DuxuI@O~a9HX?XXociw^{E`G5h z==@&QcdJNEdmwOU{y5a-aNv|)DyNr{YgL=SCH#684|xI(zn;bBZ}r>#_Bs24o*b^? z6n<7Wy|>Mu>Q9|ZUC59u+vs#Qb**5V-{^0jO@qRC!7~3EE}CXj)-%XJk2*Y~Sk5T+ zo7Zd^frbz2{B^K437GvI{*Jj0xQwzF(&=o++K!$6#(-t^wXknoG5`3=omKamx;Xe6G0izFOzC7V zh3O>L8Rv@A-{Qc?e6`A+>mqYid+~;-e2Y}BYL%Bfom)$F1ajwk0qdc~N|<^zJxW)84iC$Jl>Hy=rSoDpEQMZ|yZc0^OY9nyvhjk{A=xq{T80#t!K@!( ze1G`yo6GpDHa;^y?O4S}EIKb)F36S(qUA!^`SSUAFm^vGfU#uC<4!zgdfG19JE;rK zvo1K#y5M}I3tk1J?>s$DHbULK^7-rH>rMS`Z1^$x%=a+CVrztkPSVmmak3exVmF94RdN%b zF|3F+|f% zQmwtE4H3$Vy={xBmas{R{6yoG=3_4QMg>L6@7=-ZHnx8YTO>ejst7mjD-L36s4!Ke z{Ozk(#ETm0!P^}FTF)p|R30UWD*Nw)EUHwtX;k9P$8j&0t*;RND>dg3RdGY*I7U@s zqXTG7w%v`pOIsTRyqJCrZ*>nVk&hyS@SFG)6_;5lUidpCXy3*se1<3%{E`Tb|Ai|M z8p)}C{yksdHeLdIH=?6e(HLP>*AX%OsAxGF(dZsvZzywAFK5BtR!l!6S`KZgv1oo~ ze0Jg4ozJdeMiTo5XJ-@vT0B>4Tgtx*IG(?z%;i*sY}rK!>*rXKPvci`=jkr z=^?okg&f8llXR5a4IU-E^U<)g!7XIijabTB#I9?fb&7EFZ|@at*G1EHCd`Hb2(kV6 zdqRJuq2#_keqx}nPf?vSYOoF|*xb_Z=KGZwAJVjSx9$~h*{Y+!iY+?;iG}#S}C|l!EFluih}P^@N)`2pdds6?dnkUl}zpQ zr*z4-Y%l_nb?!S9mrp@1g0C&uj>*Hd=_%oV;u4*X*TrK1VUgDBpBpTC({mjKnUHSJ z*yyvSOPyh#H66R2v3;TA#20RJCR~n^we(_qpC_Aa2ADP4 z42VwC>&ebO5?J*5R$3}zp~aww(O)#MCh6fqG#WVU`ikc|ZLrIrhttbwU^VK=6QwG& z8}%?eiw1VHo?cO>3N!6`*j+}0UA1%>4e&0}w48+_OS#K~s|TMAipiJooiRN(bNU|S zqY7K}w+$>#^EaBL*E?Awqam8jyk*8hi%nnuTo(;?BQ!M`Ir5Vf+GnfCdj9UIsk&*ux@BH8wVM+f|Jj z+A!;#yPL~6PTV`18*_KPO5ZE*TsGqpb&uUxJ?3mpH3`;!|@M=X}V!<*z` zRWH7~0~e1hoaM5Gqd4n4b(w5G%~^!C**WXTv-jxgKPDdd8bxVpfO!xe4la!g{+UVGVweU>1cS zKXr9^Dm1+iOyni+=D39G`m}I;>ejR%EQNV4ku!ZQ{1U5SCI{812R^|2?+WZ#dZ#5q zC0|)AE7XdWvzDeFdeHU zxZv!cK@L7o<$|(+WJ-rl(gEq^rgi?Df_plB&s#ZXh~n~oPW>Y1jdkXO4koSR7R9-u z4vXcD;}<}D_a6}76LF@?cmuwPa&U#Z=a3`ifYgf8sp)g6|61Rx6y6nECi#dvnJ%^s z&IhS$T8Hs_ui97a0n23Fsw3+cEM9SNd(!!byOQ+XtTj^}9{96r+p8}>X*qN{$LTAs zR4*CV)OlL!Gr0Ubb-S9ok{c(ZNa~S#!R^y)<69tynbY0R9e7%%|1PU^q$I5oYPEcY zHq@(C-;XFuXftbGD&9CRELZvW=H=iUy5yKj)|*w^A%nKU z$bL<|fi``;LM@eL)m5$6B{)`cxxAZXJc3cfGH!`kAN<5$Gdz*7&T-L(@xH;n-U+sM z>3Vf)8MhS*zSAtJCB-|idhW68KX$90`aP%}~Hy^#xJ?C97?|G;2hG@rcy zLo^G=Nj5>FAQ`IZIbn(n^--F=LM|o~mZ*!Jn7%eY7Yc%KwUEe-D0i=@MtC(Co|%r! zg+t^OJ+I!ui*sR!!C?Z`LX@uNnyVL@tKUoH!5lIj335}@5fB`?cmI$u;YCbNztw|>eo>|%zV^M6eTMT zG}VEoXpJd;Qo^9%R)SjMaW3)~@VW5A+aO3dt_P>NAfK>d84|Y098>`gc$^Y!pTu=&QV=8IweX7keYtwv!k5^TPD zRcKc6MboY21fy2=)Iu<{$hUwBR3rQX`olfTu64iN`-0gM6V`jyBah#fm};4+UZY-k ziehcz-u`EW&)UB#d6p-6#%0gAIB`+-Tw1g5(9VbSeL7Yy(X33fBF*9x$9>0|BiEJZTUQ9^I^x+#M2q zOs0>C^fB%Gb59z@gB_b~lKYrMAD8LlB7Hpd{+GTdWgF2K&23LV+?w8Iz6eXr!*cWR zw>i|oQR?>=48OHd9uhjw;}ZyXN%)PL=O4z4C3lTP*UEIQNY}n_dtzRYD5>4?*Kf2x zE!oHeZJu_B>SljOpl242uAI@-xq6?7*=N3ubG<+0(k#{emf26GY!BD1PB~`tt zbH`Qi@csMmuUAQ~a@keBYoRrIYB#N@r%m>>rSx=e-QKR;4t!a=H7_ym$jmz;IZb-( zPG~p#*Spt+c(=sV$V`n$PLp5`3c6!dtnK4>9=x*#|4bs3(+ll@|I6BV?G974*8lA> zXqDeVE7hY}?3CC)zuOCK7qC3yc*_E{nnSQaX1r;@hO0KKWhH0D2FO=~SM_s?P?8mc zn9wWECLoLT!@*2F01q)M4)_+XG<29X;i0Twqac%hzk8U+&*7&M?mIz# zZgx2}-bTG_?;_6!LlHK~n_$j%;`J1=si=p&$cIDMQg54HDN{;X`X$Z@pl2c+W`*lv zKEl=^oon|u!4y0eyaf|78wxI+WMMK2%yDce9AOuxBQw{VqSb6~cyV5NgsG)cVH?@S zkTNxJO|NQA(^V>ey7`J{FelCIXmYwuYG4Nd|703sqhi#Iti+wzEP$!z7%CG!&R#|MWoATe^n^IXCjYDrMn1?-RNWW)pS zw(zyd04H9d0$AQX%Z{yD)So+f`48*v*NNUbDbFwG`S11aht&O`TlUgYMjwf<=7$4jxo zkM7>Pn~HcyqN`=PTBNI$h`G+S^A9iGzZ45gt{T}@^E|gk%&kd@vl3k?)0HA!NyJ~! z#j&drT`JS1_qt(3)@MpPTS2B?*c|r+kvY0WZMAGod~s}ZMY08CTR^l0c5L=P?f z%VO1AXJY z?BKxAzy#ZJH0m1b>+Kt!7<>-^X5a8DE6ef)keQbr2olzoj#m^66sgEj8w=(Z9{F25_dE*w?F0z&6>v?u6_02O5ZyH7#BWRYsf3LWF9`{X7qH;ijg&rwU5Ce@3;qlL z5`ue{7x~QEH~yO|CvBCePn^x6W_f{o)7Gi9z|qph(W0_M@9Oemx{s55%v;uWa*h zu?qm`9RyO&K?0#L1-w>5%*fe~&i-iN#{>7sKXbFj!HP`XA!zJ4b+1~D#wac)qUi{n zEecUqtlDQ$K+(qO0nAh>GYZHXrh9Gdo1}q(X)1@aXrF5vIq4o`nuNr4M+U=H4s@t- zFoR@ix}0MLI!w9)K!>8WX>m^IPIR>8D|>5z5%{GIXzrtaMBI$E4zNBaAbhMsweLvJ*HsBfqT zMwh{X@d^GdctS=Fz8kN4FzCgg5Ca?_Uhah;VQp>a18@;71tvW_8T=qZs52kD1HURw zrir;%K$0Bd%Rm^YBDT#0J=!-mHQIf)k3R*shM|T(gwLumsDVH`jVElkrmqG0et4X) zEr30MKZ{}r$V_v&V#=7G1ExC)?@>?)LV00A&u8uhcCAIg8w*Tg^c)YRpkCz`T|Kj*_ch#D0$L);`{Mn`0z=I#cjmjm< zB)USTD@3|t2N_n5^9%`63~Q}_qj2No)3Qxr%epDZ{!?P|+^IjjG4QlOtnb`9xHYrI zZ_UX59x-{o@HcFn-?;nq2a>;2_IHZ@&Rx6Jcluk4)pZ7P=z?TYfx`m80%)x0J}l7< zGTk834M~=ESYpa$ru=K>&~xUHLU=N;G+;$>->wCIb{J2r`{RMNz*@k>cYqSb+czrz z@_78XM(i6xeX5~imGM(M4&fMw_uu_bGJay4PmdY|g2PMmVTJ)n8JtL2GQ$R&zIT!F zz^oRYWEcuT?Q(~pMfu?nH|%ggv;lcr!GB#UA0AUxKR%vSp_RLqxt?dU)HU! zVKBA!X3eKz{(v%jlj*{2oTTUWafj&QXNXZ-BQFj$(0`v(VlC+cqc*}w>^5|H?Zgk z6_UJ*kUCK;l&UlsE+YaUSu{*41h5{juxs$m5C}LNL6xbPHIb+2B?iXJ@M;QWeyYwE zuC*HvPHUOd+J_QdF4N^AT~1gQnM*QE0}6H(-GdT6B-2A8J@oy?;sP{0m8?@hEUgk# zB{NkbIZeYcgMxe43)g!UOPt8CaGJ#O-b^fQV2dqm^rC(3i0nC{+SdRu7r#aA^z?4~ zz^Ldti)b1`cnsk;Nn7F(iD{9U7LlB$d=P^gDVM}l$V`PuPLr6)sFt2tv?B@96Ed@W z2^WuWW`JMw0n`(|r*`3%vjYBYdmnVIS_y8>Jfq|QhR5cj9%NYv;-nXvwWKr4qF_7Z zN;?}Sm09$&hDs0Z!wLKNMEAtFKi6+f*e~Ef5IvA(m4|c;5OefQhhUK<+VncPBV`0Ik<%j?aKBU6~XH9ci8`M1C2Z4^s+UKXQ2Imtw!tHqjnSBrC zG57%f3)dk)gOEa7FG{vz*;Xvtig&GaiDTE|w4Wrw$9Ze^HDR|%1!HR~>x;jRCHHZOJ|WX5L~^3i3=~w`Xi^P5;=qPZKoDfee3(H~R=8xKo3F7$ z8-UfDG-v2k*+7uNcFc!$Ac*1|MnJG)gU;Gd$ZF!jE5qR_)-HQqL7~c~6TB zwc;?;niqT?9B6jnd>9%b6PONcLUGo%P;49^SN{L4b1L*#S?hd{zML_NN9$eYS}(~^ z>*#5b(Rje^;<3<h{C~vcluZ` zza@?NnnJvys=<$htqX;f<+h;UPf*jUM@X`AV6l&ydWo1ITEq5CbHuHOXs&C6qO;TU zp~Z#f#SplADDE;%prCsmyll8-a5}je3^f4fYbfB>?y z)8OyPHAUaivP1Iu*t%qf8d6Jp>F<_y>PfT5GYw}NjsSU#GHkEn^8;TAIH2Nm;xE)7 zG(G)OI3fbJL@pn^H9tKQOytf9q@arN5a(e2BnC7}<;;a5JoW_DV?`l$L1>GFLi+PF za>9O$fF?rlj|uV%2^&^w!g_0oKTVW|V5I|xJ7kRNp)7X1ixDySQ}{33hfjkpDJ^zy zk`9_YOhgUtQ|TZs12TtX=Frzn{d1;XVj5(oA%ktM5%`OhgfoW$5$NVK0UjBem(kla-(gdXQNFnYu;fi=Zi zDAxM%(u1Y-!uxmE?y9k3tCFW$_Ed|WYER=^!p`Gz3izM zJ@qJBRQzY$r*l8O`RPrm=%8G5aIKg4@}P~NZl}I!liF^!ao1Rc@1hLcc z091^`_+`c~lGD_Vh@C+3(CLwwTA8U8$!QWaSqf$-=z?Y=G@>>fJ-_=k5c=1d3OMW2 z96dfVdXvLKoz4hAur+$VWX6#O_M{MfSU}X^NWf$^&XMLHCjHr#>2e{Smig$kWHv}- z&8HJ|Y1X{qPP4cNcLv|{*_=$MC98ImzY9ph=P)0f!Gs%!?`1ypm<;{7Gcz*I$g<8s zNi;PLixHYT74?+sYl7dCwNwVPFL(p|7oFjp;)?X?8F?9CP%0G5ntS7dVXt0W#@uIb zi9S7U#Izsu8S-1KPa$J|k7v!VfnV`Igqp*-*>rFv`n;?vUWx?&$aD~jh%-}aq z%J#2xyvLvuz2^BQL+SScde^lN#f(9_>k z>-c~(uqv)XZS~EDx?uHrGPi`Ry`s{XA6&Qt29m9h8u$z_5Ia|;V<6TC>%c(3ckP18 z6~G~kMPvs+wi^nifOK%i2G6#|nd<}`H%%yRW(9qLfeCa!XX~n}IX__r(Mnd?%+ys4 zghe%o;-$K(-UT!Qc>{k91G4^I1Xs~6Sn&pugKl$?@U?5ODT=+)GdZof8=DNmG75WZ z3El)IgY!XfUQ02d+&s)PP@o&A%+J0TUSwy$8Vingd~hLrC&(^`7kM_k6jHZZ@pIR% zM}($mv&pe8=_%CJ(wMwVT6n?LpKrnhO0@|+Ky_A4nbe1q_a3GP&S92xSIL&qWVtljM}*W0^>*uS(g8^ze@%@iK+_=42`gBEX?%5JVcQ1N`2Z%#d zA90B4Bd$<=lRHjEodNjFc1;nIGX3D;1s-81ISQI~dlbiPeK*Zoy;RBF) zaDC!&-E;Rr(R~nn%lAE5g^LuD?2$aZvZq({^zHzXR9wLOo>9*ZeU-Z%m6$P^855Z? zxGSF1u~2K4oig% za$&>&4rX?H5HlZCcOZ0ccW>W*=KFGKyHoOv%AQftGpgn_wyfZ6(Np=~O__1NzLZBL zPlxR35Ir3T^xd9^i}x4B;vHx7xob*@8_FsVXN7F?Lt- zRLPzy(NneK*%Nc@=2|?bcWFz3Tkmpj!q;ahXrE>$xzq~9zlNS>-mMAi5_-&>-TTwL zE`f`6J+JZpM7KD58a>0E2GfE4KUZh}*Li+|tY)nM?EZOR_s`E__wUU*<1xk=4l{H0mT~w>B^hn3nbDzX|0SUAe~`6S z4Oo&N%vwYHxc#Ow(<}5Fm9N53HwSd(HCVRI=nG0vowWo8M92sKC?z{15h?l?= zfW+CRInK{U)P0(Y^8h=WHhRxC39#*C3ikX5`34rHqb_w#p?_rT!q9lMnAr2%(4ODg z4)**u4)$=WCVhg=&cQgDutsKx5g#@%-UPn~`igQfNzD7vHU*^PN(651yqSjT0M3cY zJOea$tBVv$rjroe#rC6v1dFl2Z>kd#Pg0FQ5cVm~-BLEazC<5^=pJ?)ch!*MfjNJ1 z;cAfgtETqo39T(%t(rR2+rXiwhIGy7ZRcnFWJ?gB>~0QytCZ!Fd^KZp9ApcTZ?siK z^r~v~$O0IzRE$Crlxr1df2+pGCZgM0oi3S!?q?QwQ@^KQ>oPIy5V$r{dQ84&V@LzaIF#WFeP|8`@TG9@#x1OWu9DH=m2F7Y| zxdluf0|=kodZYON9z*}%V=4b%(OZvr{LrVl;HcH{xHR4t?}@iPE(fn**rT~qUJ=iK z91=b4vAkH`=0WiBZR&u8d>FWnp`$P8O9g31-#&@%m+5|y?oX{IfOju~^RJo1&l&Lg zZIqeD466yH05BCR{d&b_5n8U>Wp6vNT%(~DY`%M8`QTCCCw=0+`pvdjpX56x`;M*k zrJlBKwcyh(+1It!ry}i<9gi;-+&KC#y8c<$ug*L@BbIglVmw~_=a(N}UcbCeiS8b> z2M37TSb(^V1(I%Ku@efx5ScQ#Facr@5xl0Vm2`IL!yP_)C(VMK^6~mZwG!?^=$gLHDA?ipO$=MvTscEjhWHCBbBPS=gcz- zOLI>4oHLZh>N>Q3`=2iUY*DO!ORDIWE4sHYJ+BxND~42Lf9y9^Tho7C^F_^8>1(jP zIe8PHAE2Oxn*n`lGZ**A0rXe?IDj7DzS98rHAZdNqmrM25NJ%>Kml9;E(>PunatY{ z-iPTwGsHBQt;xil87LS`(tjWYFqoiU16C2v`^*#|v*FwT#w!fQ!D1bVF}QSP1grlz zD{wO!jsIO%h?Thn*#KEdhVp`G@r}y+4`B}vPOGNFP%le5vo*!-)zyo^)#bf6dZ1CypimY;GXEn? z4vj=9q^wrp8zJR!jOb;DFnxf#q_&CPXr=naT7VD{dSn8&(d4#xO@c%SDUvJ4YK_Z$84xA|`VL*W{1MXgA}L zZFm}l{Sy>I7b??MJpK~C!oUM|FX5l7n3_#t>YS2O;A!IN1WyxBCtzw?#nfV9gG4vV zbfZW&l1+6sX9xJWI6E|;x;YK1k3HTO?~RYedmmS9RBoJoTE98Id3AIAX){=LV<$f? z6AP>3RdHd%8W&{G;hnPb^}COM0CwV-Gv?eZ0y#(#^5~+h!+`IOd{+Pd&;jYBvrymU(uZlVQz6gc$)u5@Cy2nMfk4}z@~PJ z+OgD&miiq_{yp+<$1)&(|MQ!C*FAcdK0v|W4N3*4izwLklnUx|se)ZgD)9MGI29b( ngSV-ml|kWDP~M7fQ^5_&kN2tI5><`&sbG|<#Cvl<>g0a`bGuTH literal 0 HcmV?d00001 diff --git a/public_html/bbs/__pycache__/term.cpython-311.pyc b/public_html/bbs/__pycache__/term.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..317d958bc61628151ebc80efc093d39cdb847dd9 GIT binary patch literal 8263 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 M+Wwn8da2z058X>|Q~&?~ literal 0 HcmV?d00001 diff --git a/public_html/bbs/bbsd.py b/public_html/bbs/bbsd.py new file mode 100644 index 0000000..79a24ac --- /dev/null +++ b/public_html/bbs/bbsd.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +"""txt3 BBS - telnet front-end for wap.txt3.net. + +Shares the SQLite database with the WML/XHTML site: one account works on all +three front-ends, and multiplayer matches are cross-playable between them. + +Binds to the tailscale interface only by default. +""" +import os +import socket +import socketserver +import sys +import threading +import time + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import bbsdb # noqa: E402 +from bbsdb import connect, q1, login, create_user, user_by_name, touch # noqa: E402 +from term import Term, Hangup # noqa: E402 +import boards # noqa: E402 +import games # noqa: E402 +import mud # noqa: E402 +import screens # noqa: E402 + +HOST = os.environ.get("BBS_HOST", "100.127.96.105") +PORT = int(os.environ.get("BBS_PORT", "12300")) +MAX_SESSIONS = int(os.environ.get("BBS_MAX", "20")) + +_sessions = threading.BoundedSemaphore(MAX_SESSIONS) + +BANNER = r""" + _ _ _____ ____ ____ ____ + | |___ _| |_|___ / | __ )| __ ) ___| + | __\ \/ / __| |_ \ | _ \| _ \___ \ + | |_ > <| |_ ___) | | |_) | |_) |__) | + \__/_/\_\\__|____/ |____/|____/____/ +""" + + +def log(msg): + sys.stdout.write("[%s] %s\n" % (time.strftime("%Y-%m-%d %H:%M:%S"), msg)) + sys.stdout.flush() + + +def welcome(t, con): + t.write(BANNER) + motd = q1(con, "SELECT v FROM settings WHERE k='motd'") + t.line(" " + (motd["v"] if motd else "Welcome!")) + users = q1(con, "SELECT COUNT(*) c FROM users")["c"] + posts = q1(con, "SELECT COUNT(*) c FROM posts")["c"] + t.line(" %d members, %d posts. Also on the web: https://wap.txt3.net" % (users, posts)) + t.rule("=") + + +def do_login(t, con): + """Returns a user row, or None if the caller gave up.""" + for _ in range(3): + t.line("\n [L]ogin [N]ew user [G]uest look around [Q]uit") + c = t.ask("\n> ").lower() + if c.startswith("l"): + name = t.ask(" Username: ", maxlen=16) + if not name: + continue + pw = t.secret(" Password: ") + u = login(con, name, pw) + if u: + t.line("\n Welcome back, %s." % u["username"]) + return u + t.line(" Login failed (bad details, or account banned).") + elif c.startswith("n"): + u = do_signup(t, con) + if u: + return u + elif c.startswith("g"): + return "guest" + elif c.startswith("q"): + return None + return None + + +def do_signup(t, con): + t.line("\n New account:") + name = t.ask(" Choose a username (3-16, letters/digits/_): ", maxlen=16) + if not name: + return None + if not name.replace("_", "").isalnum() or not 3 <= len(name) <= 16: + t.line(" Invalid username.") + return None + if user_by_name(con, name): + t.line(" That name is taken.") + return None + pw = t.secret(" Choose a password (min 4): ") + if len(pw) < 4: + t.line(" Too short.") + return None + if pw != t.secret(" Repeat password: "): + t.line(" Passwords did not match.") + return None + uid = create_user(con, name, pw) + if not uid: + t.line(" Could not create the account (hashing failed).") + return None + t.line("\n Account created. It works on https://wap.txt3.net too.") + return bbsdb.user_by_id(con, uid) + + +def guest_menu(t, con): + while True: + t.header("GUEST - read only") + t.line(" [F]orums (read) [U]ser list [S]cores [L]ogin/signup [Q]uit") + c = t.ask("\nguest> ").lower() + if c.startswith("f"): + boards.forum_menu(t, con, {"id": -1, "is_admin": 0, "username": "guest"}) + elif c.startswith("u"): + screens.userlist(t, con) + elif c.startswith("s"): + games.scores(t, con) + elif c.startswith("l"): + return "login" + elif c.startswith("q") or c == "": + return None + + +def main_menu(t, con, me): + while True: + me = bbsdb.user_by_id(con, me["id"]) + if not me or me["is_banned"]: + t.line("\n Your account is no longer active. Goodbye.") + return + touch(con, me["id"]) + n = bbsdb.unread(con, me["id"]) + t.header("MAIN MENU - %s%s" % (me["username"], + " [admin]" if me["is_admin"] else "")) + t.line(" [M]ail %s" % ("(%d unread)" % n if n else "")) + t.line(" [F]orums") + t.line(" [G]ames") + t.line(" [D]ungeon - explore a shared world (also on the web)") + t.line(" [P]rofile") + t.line(" [U]ser list") + if me["is_admin"]: + t.line(" [A]dmin tools") + t.line(" [Q]uit") + c = t.ask("\nmain> ").lower() + if c.startswith("m"): + boards.mail_menu(t, con, me) + elif c.startswith("f"): + boards.forum_menu(t, con, me) + elif c.startswith("g"): + games.games_menu(t, con, me) + elif c.startswith("d"): + mud.mud_menu(t, con, me) + elif c.startswith("p"): + screens.profile_menu(t, con, me) + elif c.startswith("u"): + screens.userlist(t, con) + elif c.startswith("a") and me["is_admin"]: + screens.admin_menu(t, con, me) + elif c.startswith("q"): + return + + +class Handler(socketserver.BaseRequestHandler): + def handle(self): + if not _sessions.acquire(blocking=False): + try: + self.request.sendall(b"\r\nBBS is full, try later.\r\n") + self.request.close() + except OSError: + pass + return + con = None + t = None + ip = self.client_address[0] + log("connect from %s" % ip) + try: + t = Term(self.request, self.client_address) + con = connect() + welcome(t, con) + while True: + who = do_login(t, con) + if who is None: + break + if who == "guest": + if guest_menu(t, con) == "login": + continue + break + main_menu(t, con, who) + break + if t: + t.line("\n Goodbye - 73s from txt3 BBS.\n") + except Hangup: + log("hangup %s" % ip) + except Exception as e: # keep one session's bug local + log("ERROR %s: %r" % (ip, e)) + try: + t.line("\n Internal error - disconnecting.\n") + except Exception: + pass + finally: + if con is not None: + try: + con.close() + except Exception: + pass + try: + self.request.close() + except OSError: + pass + _sessions.release() + log("disconnect %s" % ip) + + +class Server(socketserver.ThreadingTCPServer): + allow_reuse_address = True + daemon_threads = True + address_family = socket.AF_INET + + +def main(): + # Fail loudly if the shared database is not reachable. + try: + con = connect() + con.execute("SELECT 1 FROM users LIMIT 1") + con.close() + except Exception as e: + log("FATAL: cannot open database %s: %r" % (bbsdb.DB_PATH, e)) + return 1 + srv = Server((HOST, PORT), Handler) + log("txt3 BBS listening on %s:%d (db=%s)" % (HOST, PORT, bbsdb.DB_PATH)) + try: + srv.serve_forever() + except KeyboardInterrupt: + log("shutting down") + finally: + srv.server_close() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/public_html/bbs/bbsdb.py b/public_html/bbs/bbsdb.py new file mode 100644 index 0000000..bc811d0 --- /dev/null +++ b/public_html/bbs/bbsdb.py @@ -0,0 +1,126 @@ +"""Shared data layer for the txt3 BBS (telnet front-end). + +Talks to the exact same SQLite database as the WAP/XHTML site, so accounts, +mail, forums and scores are identical across all three front-ends. + +Password hashes are PHP `password_hash()` bcrypt ($2y$) values. Rather than +depend on a bcrypt wheel, we verify/create them by calling the php binary, +passing values through the environment so nothing is ever interpolated into +a shell string. +""" +import os +import json +import sqlite3 +import subprocess +import time + +DB_PATH = os.environ.get( + "BBS_DB", "/home/txt3/domains/wap.txt3.net/public_html/data/wap.sqlite" +) +PHP_BIN = os.environ.get("BBS_PHP", "/usr/bin/php") + + +def connect(): + con = sqlite3.connect(DB_PATH, timeout=10) + con.row_factory = sqlite3.Row + con.execute("PRAGMA journal_mode = WAL") + con.execute("PRAGMA foreign_keys = ON") + con.execute("PRAGMA busy_timeout = 5000") + return con + + +def q(con, sql, args=()): + return con.execute(sql, args).fetchall() + + +def q1(con, sql, args=()): + r = con.execute(sql, args).fetchone() + return r + + +def ex(con, sql, args=()): + cur = con.execute(sql, args) + con.commit() + return cur.lastrowid + + +# ---------------------------------------------------------------- passwords +def _php(code, env): + e = dict(os.environ) + e.update(env) + try: + out = subprocess.run( + [PHP_BIN, "-r", code], + capture_output=True, text=True, timeout=10, env=e, + ) + return out.stdout.strip() + except Exception: + return "" + + +def verify_password(plain, hashed): + return _php( + 'echo password_verify(getenv("BP_P"), getenv("BP_H")) ? "1" : "0";', + {"BP_P": plain, "BP_H": hashed}, + ) == "1" + + +def hash_password(plain): + h = _php( + 'echo password_hash(getenv("BP_P"), PASSWORD_DEFAULT);', + {"BP_P": plain}, + ) + return h or None + + +# ---------------------------------------------------------------- users +def user_by_name(con, name): + return q1(con, "SELECT * FROM users WHERE username=? COLLATE NOCASE", (name,)) + + +def user_by_id(con, uid): + return q1(con, "SELECT * FROM users WHERE id=?", (uid,)) + + +def login(con, name, password): + u = user_by_name(con, name) + if not u or u["is_banned"]: + return None + if not verify_password(password, u["pass_hash"]): + return None + ex(con, "UPDATE users SET last_seen=? WHERE id=?", (int(time.time()), u["id"])) + return u + + +def create_user(con, name, password): + h = hash_password(password) + if not h: + return None + now = int(time.time()) + uid = ex( + con, + "INSERT INTO users (username,pass_hash,is_admin,created_at,last_seen)" + " VALUES (?,?,0,?,?)", + (name, h, now, now), + ) + return uid + + +def touch(con, uid): + ex(con, "UPDATE users SET last_seen=? WHERE id=?", (int(time.time()), uid)) + + +def unread(con, uid): + return q1(con, "SELECT COUNT(*) c FROM messages WHERE to_id=? AND is_read=0", + (uid,))["c"] + + +def ago(ts): + d = max(0, int(time.time()) - int(ts or 0)) + if d < 60: + return "%ds" % d + if d < 3600: + return "%dm" % (d // 60) + if d < 86400: + return "%dh" % (d // 3600) + return "%dd" % (d // 86400) diff --git a/public_html/bbs/boards.py b/public_html/bbs/boards.py new file mode 100644 index 0000000..50273e5 --- /dev/null +++ b/public_html/bbs/boards.py @@ -0,0 +1,250 @@ +"""Private mail + forum screens for the BBS.""" +import time +from bbsdb import q, q1, ex, user_by_name, unread, ago + +PER_PAGE = 10 + + +# ==================================================================== mail +def mail_menu(t, con, me): + while True: + n = unread(con, me["id"]) + t.header("MAIL (%d unread)" % n) + t.line(" [I]nbox [S]ent [W]rite [Q]uit to main") + c = t.ask("\nmail> ").lower() + if c.startswith("i"): + mail_list(t, con, me, "in") + elif c.startswith("s"): + mail_list(t, con, me, "out") + elif c.startswith("w"): + mail_write(t, con, me) + elif c.startswith("q") or c == "": + return + + +def mail_list(t, con, me, box): + page = 0 + while True: + off = page * PER_PAGE + if box == "in": + rows = q(con, "SELECT m.*, u.username who FROM messages m " + "LEFT JOIN users u ON u.id=m.from_id " + "WHERE m.to_id=? ORDER BY m.id DESC LIMIT ? OFFSET ?", + (me["id"], PER_PAGE + 1, off)) + else: + rows = q(con, "SELECT m.*, u.username who FROM messages m " + "LEFT JOIN users u ON u.id=m.to_id " + "WHERE m.from_id=? ORDER BY m.id DESC LIMIT ? OFFSET ?", + (me["id"], PER_PAGE + 1, off)) + more = len(rows) > PER_PAGE + rows = rows[:PER_PAGE] + + t.header("INBOX" if box == "in" else "SENT") + if not rows: + t.line(" (empty)") + for i, r in enumerate(rows, 1): + flag = "*" if (box == "in" and not r["is_read"]) else " " + t.line(" %s%2d. %-12s %-28s %s" % ( + flag, i, (r["who"] or "[gone]")[:12], r["subject"][:28], + ago(r["created_at"]))) + t.line("\n number=read [N]ext [P]rev [Q]back") + c = t.ask("\nmail> ").lower() + if c.isdigit() and 1 <= int(c) <= len(rows): + mail_read(t, con, me, rows[int(c) - 1]["id"], box) + elif c.startswith("n") and more: + page += 1 + elif c.startswith("p") and page > 0: + page -= 1 + elif c.startswith("q") or c == "": + return + + +def mail_read(t, con, me, mid, box): + m = q1(con, "SELECT m.*, f.username fromname, o.username toname FROM messages m " + "LEFT JOIN users f ON f.id=m.from_id " + "LEFT JOIN users o ON o.id=m.to_id WHERE m.id=?", (mid,)) + if not m or me["id"] not in (m["to_id"], m["from_id"]): + t.line("Not found.") + return + if m["to_id"] == me["id"] and not m["is_read"]: + ex(con, "UPDATE messages SET is_read=1 WHERE id=?", (mid,)) + + t.header("MSG: " + m["subject"][:40]) + t.line(" From: %s" % (m["fromname"] or "[gone]")) + t.line(" To : %s" % (m["toname"] or "[gone]")) + t.line(" Date: %s" % time.strftime("%d/%m/%Y %H:%M", + time.localtime(m["created_at"]))) + t.rule() + for ln in m["body"].splitlines() or [""]: + t.line(" " + ln) + t.rule() + t.line(" [R]eply [D]elete [Q]back") + c = t.ask("\nmsg> ").lower() + if c.startswith("r") and m["fromname"]: + subj = m["subject"] + if not subj.lower().startswith("re:"): + subj = "Re: " + subj + mail_write(t, con, me, to=m["fromname"], subject=subj) + elif c.startswith("d"): + ex(con, "DELETE FROM messages WHERE id=? AND (to_id=? OR from_id=?)", + (mid, me["id"], me["id"])) + t.line("Deleted.") + + +def mail_write(t, con, me, to=None, subject=None): + t.header("WRITE MAIL") + if to is None: + to = t.ask(" To (username): ", maxlen=16) + else: + t.line(" To: " + to) + if not to: + return + dest = user_by_name(con, to) + if not dest: + t.line(" No such user.") + return + if dest["id"] == me["id"]: + t.line(" You cannot mail yourself.") + return + if subject is None: + subject = t.ask(" Subject: ", maxlen=60) + else: + t.line(" Subject: " + subject) + if not subject: + return + t.line(" Body - end with a single '.' on its own line:") + body = read_body(t) + if not body.strip(): + t.line(" Aborted (empty).") + return + ex(con, "INSERT INTO messages (from_id,to_id,subject,body,created_at) " + "VALUES (?,?,?,?,?)", + (me["id"], dest["id"], subject, body[:4000], int(time.time()))) + t.line(" Sent to %s." % dest["username"]) + + +def read_body(t, maxlines=40): + """Multi-line entry terminated by a lone '.' - the classic BBS editor.""" + lines = [] + while len(lines) < maxlines: + ln = t.read(" | ", maxlen=200) + if ln == ".": + break + lines.append(ln) + return "\n".join(lines) + + +# ==================================================================== forum +def forum_menu(t, con, me): + while True: + rows = q(con, "SELECT f.*, " + "(SELECT COUNT(*) FROM topics x WHERE x.forum_id=f.id) tc " + "FROM forums f ORDER BY f.sort_order, f.id") + t.header("FORUMS") + for i, r in enumerate(rows, 1): + t.line(" %2d. %-24s %3d topics%s" % ( + i, r["name"][:24], r["tc"], " [locked]" if r["is_locked"] else "")) + t.line("\n number=open [Q]back") + c = t.ask("\nforum> ").lower() + if c.isdigit() and 1 <= int(c) <= len(rows): + topic_list(t, con, me, rows[int(c) - 1]) + elif c.startswith("q") or c == "": + return + + +def topic_list(t, con, me, forum): + page = 0 + while True: + off = page * PER_PAGE + rows = q(con, "SELECT t.*, u.username, " + "(SELECT COUNT(*) FROM posts p WHERE p.topic_id=t.id) pc " + "FROM topics t LEFT JOIN users u ON u.id=t.user_id " + "WHERE t.forum_id=? ORDER BY t.bumped_at DESC LIMIT ? OFFSET ?", + (forum["id"], PER_PAGE + 1, off)) + more = len(rows) > PER_PAGE + rows = rows[:PER_PAGE] + + t.header("FORUM: " + forum["name"]) + if not rows: + t.line(" (no topics yet)") + for i, r in enumerate(rows, 1): + t.line(" %2d.%s %-30s %3dp %-10s %s" % ( + i, "L" if r["is_locked"] else " ", r["title"][:30], r["pc"], + (r["username"] or "?")[:10], ago(r["bumped_at"]))) + t.line("\n number=read [N]ew topic [M]ore [P]rev [Q]back") + c = t.ask("\ntopics> ").lower() + if c.isdigit() and 1 <= int(c) <= len(rows): + topic_read(t, con, me, rows[int(c) - 1]["id"]) + elif c.startswith("n"): + new_topic(t, con, me, forum) + elif c.startswith("m") and more: + page += 1 + elif c.startswith("p") and page > 0: + page -= 1 + elif c.startswith("q") or c == "": + return + + +def new_topic(t, con, me, forum): + if forum["is_locked"] and not me["is_admin"]: + t.line(" That forum is locked.") + return + title = t.ask(" Topic title: ", maxlen=80) + if not title: + return + t.line(" Body - end with '.' on its own line:") + body = read_body(t) + if not body.strip(): + t.line(" Aborted.") + return + now = int(time.time()) + tid = ex(con, "INSERT INTO topics (forum_id,user_id,title,created_at,bumped_at)" + " VALUES (?,?,?,?,?)", (forum["id"], me["id"], title, now, now)) + ex(con, "INSERT INTO posts (topic_id,user_id,body,created_at) VALUES (?,?,?,?)", + (tid, me["id"], body[:4000], now)) + t.line(" Topic posted.") + + +def topic_read(t, con, me, tid): + page = 0 + while True: + top = q1(con, "SELECT t.*, f.name fname, f.is_locked flocked FROM topics t " + "JOIN forums f ON f.id=t.forum_id WHERE t.id=?", (tid,)) + if not top: + t.line(" Gone.") + return + locked = top["is_locked"] or top["flocked"] + off = page * PER_PAGE + rows = q(con, "SELECT p.*, u.username FROM posts p " + "LEFT JOIN users u ON u.id=p.user_id " + "WHERE p.topic_id=? ORDER BY p.id LIMIT ? OFFSET ?", + (tid, PER_PAGE + 1, off)) + more = len(rows) > PER_PAGE + rows = rows[:PER_PAGE] + + t.header(top["title"][:50] + (" [LOCKED]" if locked else "")) + for r in rows: + t.line(" --- %s, %s ago ---" % ((r["username"] or "[gone]"), + ago(r["created_at"]))) + for ln in r["body"].splitlines() or [""]: + t.line(" " + ln) + t.line("\n [R]eply [M]ore [P]rev [Q]back") + c = t.ask("\ntopic> ").lower() + if c.startswith("r"): + if locked and not me["is_admin"]: + t.line(" Topic is locked.") + continue + t.line(" Reply - end with '.' on its own line:") + body = read_body(t) + if body.strip(): + now = int(time.time()) + ex(con, "INSERT INTO posts (topic_id,user_id,body,created_at)" + " VALUES (?,?,?,?)", (tid, me["id"], body[:4000], now)) + ex(con, "UPDATE topics SET bumped_at=? WHERE id=?", (now, tid)) + t.line(" Posted.") + elif c.startswith("m") and more: + page += 1 + elif c.startswith("p") and page > 0: + page -= 1 + elif c.startswith("q") or c == "": + return diff --git a/public_html/bbs/games.py b/public_html/bbs/games.py new file mode 100644 index 0000000..a8c229f --- /dev/null +++ b/public_html/bbs/games.py @@ -0,0 +1,323 @@ +"""Games for the BBS. Shares `scores`, `sp_games` and `matches` with the web +site, so a telnet user and a WAP user can play each other in the same match.""" +import json +import random +import time +from bbsdb import q, q1, ex, ago + +QUESTIONS = [ + ("What does WAP stand for?", + ["Wireless Application Protocol", "Web Access Point", + "Wide Area Paging", "Wireless Audio Player"], 0), + ("WML is based on which language?", ["HTML", "XML", "SGML", "JSON"], 1), + ("Which company made the Nokia 7110?", + ["Ericsson", "Motorola", "Nokia", "Siemens"], 2), + ("A WML file is made up of one or more...", + ["Pages", "Cards", "Frames", "Slides"], 1), + ("What year was WAP 1.1 published?", ["1996", "1999", "2002", "2005"], 1), + ("GPRS stands for General Packet Radio...?", + ["System", "Service", "Standard", "Stream"], 1), +] + + +def games_menu(t, con, me): + while True: + t.header("GAMES") + t.line(" [1] Guess the Number (single player)") + t.line(" [2] Quick Quiz (single player)") + t.line(" [3] Noughts & Crosses (multiplayer)") + t.line(" [4] Nim - 21 sticks (multiplayer)") + t.line(" [5] High scores") + t.line(" [Q] Back to main") + c = t.ask("\ngames> ").lower() + if c == "1": + guess(t, con, me) + elif c == "2": + quiz(t, con, me) + elif c == "3": + ttt(t, con, me) + elif c == "4": + nim(t, con, me) + elif c == "5": + scores(t, con) + elif c.startswith("q") or c == "": + return + + +def add_score(con, game, uid, score, detail=""): + ex(con, "INSERT INTO scores (game,user_id,score,detail,created_at)" + " VALUES (?,?,?,?,?)", (game, uid, score, detail, int(time.time()))) + + +def scores(t, con): + names = {"guess": "Guess the Number", "quiz": "Quick Quiz", + "ttt": "Noughts & Crosses", "nim": "Nim"} + t.header("HIGH SCORES") + for g, label in names.items(): + rows = q(con, "SELECT u.username, MAX(s.score) sc FROM scores s " + "JOIN users u ON u.id=s.user_id WHERE s.game=? " + "GROUP BY s.user_id ORDER BY sc DESC LIMIT 5", (g,)) + t.line("\n " + label + ":") + if not rows: + t.line(" (none yet)") + for i, r in enumerate(rows, 1): + t.line(" %d. %-14s %d" % (i, r["username"][:14], r["sc"])) + t.pause() + + +# ------------------------------------------------------------------ guess +def guess(t, con, me): + n = random.randint(1, 100) + tries = 0 + t.header("GUESS THE NUMBER") + t.line(" I picked a number from 1 to 100. Fewer guesses = more points.") + t.line(" Type Q to give up.") + while True: + s = t.ask("\n guess> ", maxlen=4) + if s.lower().startswith("q"): + t.line(" The number was %d." % n) + return + if not s.isdigit(): + t.line(" Enter a number 1-100.") + continue + g = int(s) + if not 1 <= g <= 100: + t.line(" Enter a number 1-100.") + continue + tries += 1 + if g == n: + score = max(1, 110 - 10 * tries) + add_score(con, "guess", me["id"], score, "%d tries" % tries) + t.line(" Correct! %d in %d tries. Score %d." % (n, tries, score)) + return + t.line(" Too %s. (tries: %d)" % ("LOW" if g < n else "HIGH", tries)) + + +# ------------------------------------------------------------------ quiz +def quiz(t, con, me): + qs = random.sample(QUESTIONS, 5) + right = 0 + t.header("QUICK QUIZ") + for i, (question, opts, ans) in enumerate(qs, 1): + t.line("\n Q%d/5: %s" % (i, question)) + for j, o in enumerate(opts): + t.line(" %d) %s" % (j + 1, o)) + s = t.ask(" answer> ", maxlen=2) + pick = int(s) - 1 if s.isdigit() else -1 + if pick == ans: + right += 1 + t.line(" Correct!") + else: + t.line(" Wrong - it was: %s" % opts[ans]) + score = right * 20 + add_score(con, "quiz", me["id"], score, "%d/5" % right) + t.line("\n Final: %d/5 correct, score %d." % (right, score)) + t.pause() + + +# ------------------------------------------------- multiplayer plumbing +def seat_of(m, uid): + if m["p1"] == uid: + return 1 + if m["p2"] == uid: + return 2 + return 0 + + +def lobby(t, con, me, game, label, new_state): + """Shared lobby: returns a match id to play, or None to go back.""" + while True: + mine = q(con, "SELECT m.*, a.username n1, b.username n2 FROM matches m " + "LEFT JOIN users a ON a.id=m.p1 LEFT JOIN users b ON b.id=m.p2 " + "WHERE m.game=? AND (m.p1=? OR m.p2=?) " + "AND m.status IN ('open','playing') " + "ORDER BY m.updated_at DESC LIMIT 10", + (game, me["id"], me["id"])) + open_ = q(con, "SELECT m.*, a.username n1 FROM matches m " + "LEFT JOIN users a ON a.id=m.p1 " + "WHERE m.game=? AND m.status='open' AND m.p1<>? " + "ORDER BY m.id DESC LIMIT 10", (game, me["id"])) + + t.header(label + " - LOBBY") + t.line(" Your games:") + if not mine: + t.line(" (none)") + for r in mine: + opp = (r["n2"] or "waiting...") if r["p1"] == me["id"] else (r["n1"] or "?") + yours = (r["status"] == "playing" and seat_of(r, me["id"]) == r["turn"]) + t.line(" #%d vs %-12s %s" % (r["id"], opp[:12], + "<< YOUR TURN" if yours else "")) + t.line(" Open games to join:") + if not open_: + t.line(" (none)") + for r in open_: + t.line(" #%d by %s" % (r["id"], r["n1"] or "?")) + t.line("\n [C]reate J = join # = play [Q]back") + c = t.ask("\nlobby> ").lower().strip() + + if c.startswith("c"): + now = int(time.time()) + mid = ex(con, "INSERT INTO matches (game,p1,turn,state,status," + "created_at,updated_at) VALUES (?,?,1,?,'open',?,?)", + (game, me["id"], new_state, now, now)) + t.line(" Created game #%d - waiting for an opponent." % mid) + elif c.startswith("j"): + digits = "".join(ch for ch in c if ch.isdigit()) + if digits: + mid = int(digits) + m = q1(con, "SELECT * FROM matches WHERE id=? AND game=?", (mid, game)) + if not m or m["status"] != "open" or m["p1"] == me["id"]: + t.line(" Cannot join that game.") + else: + ex(con, "UPDATE matches SET p2=?, status='playing', updated_at=?" + " WHERE id=?", (me["id"], int(time.time()), mid)) + t.line(" Joined #%d." % mid) + return mid + elif c.startswith("#") or c.isdigit(): + digits = "".join(ch for ch in c if ch.isdigit()) + if digits: + return int(digits) + elif c.startswith("q") or c == "": + return None + + +def get_match(con, game, mid): + return q1(con, "SELECT m.*, a.username n1, b.username n2 FROM matches m " + "LEFT JOIN users a ON a.id=m.p1 LEFT JOIN users b ON b.id=m.p2 " + "WHERE m.id=? AND m.game=?", (mid, game)) + + +def save_match(con, mid, state, turn, status="playing", winner=None): + ex(con, "UPDATE matches SET state=?,turn=?,status=?,winner=?,updated_at=?" + " WHERE id=?", (state, turn, status, winner, int(time.time()), mid)) + + +# ------------------------------------------------------------------ ttt +def ttt_winner(b): + for a, c, d in [(0,1,2),(3,4,5),(6,7,8),(0,3,6),(1,4,7),(2,5,8),(0,4,8),(2,4,6)]: + if b[a] and b[a] == b[c] == b[d]: + return b[a] + return None + + +def ttt(t, con, me): + mid = lobby(t, con, me, "ttt", "NOUGHTS & CROSSES", + json.dumps({"b": [""] * 9})) + if mid is None: + return + while True: + m = get_match(con, "ttt", mid) + if not m: + t.line(" No such game.") + return + st = json.loads(m["state"]) + b = st["b"] + seat = seat_of(m, me["id"]) + + t.header("#%d X=%s O=%s" % (mid, m["n1"] or "?", m["n2"] or "waiting")) + for r in range(3): + cells = [b[r*3+c] if b[r*3+c] else str(r*3+c+1) for c in range(3)] + t.line(" %s | %s | %s" % tuple(cells)) + if r < 2: + t.line(" ---+---+---") + + if m["status"] == "open": + t.line("\n Waiting for an opponent to join.") + t.line(" [R]efresh [Q]back") + elif m["status"] == "done": + if m["winner"] is None: + t.line("\n Result: draw.") + else: + t.line("\n Winner: %s" % (m["n1"] if m["winner"] == m["p1"] else m["n2"])) + t.pause() + return + elif seat == 0: + t.line("\n Spectating. Turn: player %d" % m["turn"]) + t.line(" [R]efresh [Q]back") + elif seat == m["turn"]: + t.line("\n Your turn (%s). Enter a free square 1-9." % ("X" if seat == 1 else "O")) + else: + t.line("\n Opponent to move. [R]efresh [Q]back") + + c = t.ask("\nttt> ").lower().strip() + if c.startswith("q"): + return + if c.startswith("r") or c == "": + continue + if m["status"] != "playing" or seat == 0 or seat != m["turn"]: + t.line(" Not your move.") + continue + if not c.isdigit() or not 1 <= int(c) <= 9: + t.line(" Pick 1-9.") + continue + i = int(c) - 1 + if b[i]: + t.line(" That square is taken.") + continue + b[i] = "X" if seat == 1 else "O" + st["b"] = b + w = ttt_winner(b) + if w: + save_match(con, mid, json.dumps(st), seat, "done", me["id"]) + add_score(con, "ttt", me["id"], 50, "win") + t.line(" You win!") + elif all(b): + save_match(con, mid, json.dumps(st), seat, "done", None) + t.line(" Draw.") + else: + save_match(con, mid, json.dumps(st), 2 if seat == 1 else 1) + t.line(" Move played.") + + +# ------------------------------------------------------------------ nim +def nim(t, con, me): + mid = lobby(t, con, me, "nim", "NIM - 21 STICKS", json.dumps({"sticks": 21})) + if mid is None: + return + while True: + m = get_match(con, "nim", mid) + if not m: + t.line(" No such game.") + return + st = json.loads(m["state"]) + left = st["sticks"] + seat = seat_of(m, me["id"]) + + t.header("#%d %s vs %s" % (mid, m["n1"] or "?", m["n2"] or "waiting")) + t.line(" " + "| " * left) + t.line(" %d sticks left. Take the LAST stick and you LOSE." % left) + + if m["status"] == "open": + t.line("\n Waiting for an opponent. [R]efresh [Q]back") + elif m["status"] == "done": + t.line("\n Winner: %s" % (m["n1"] if m["winner"] == m["p1"] else m["n2"])) + t.pause() + return + elif seat == 0: + t.line("\n Spectating. [R]efresh [Q]back") + elif seat == m["turn"]: + t.line("\n Your turn - take 1, 2 or 3.") + else: + t.line("\n Opponent to move. [R]efresh [Q]back") + + c = t.ask("\nnim> ").lower().strip() + if c.startswith("q"): + return + if c.startswith("r") or c == "": + continue + if m["status"] != "playing" or seat == 0 or seat != m["turn"]: + t.line(" Not your move.") + continue + if c not in ("1", "2", "3") or int(c) > left: + t.line(" Take 1-3, and no more than remain.") + continue + n = int(c) + st["sticks"] = left - n + if st["sticks"] <= 0: + winner = m["p2"] if seat == 1 else m["p1"] + save_match(con, mid, json.dumps(st), seat, "done", winner) + add_score(con, "nim", winner, 40, "win") + t.line(" You took the last stick - you LOSE!") + else: + save_match(con, mid, json.dumps(st), 2 if seat == 1 else 1) + t.line(" You took %d. %d left." % (n, st["sticks"])) diff --git a/public_html/bbs/mud.py b/public_html/bbs/mud.py new file mode 100644 index 0000000..3b01bc0 --- /dev/null +++ b/public_html/bbs/mud.py @@ -0,0 +1,511 @@ +"""Telnet MUD realm for the txt3 BBS. + +Implements the same rules as the WAP/XHTML front-end (lib/mud.php) against +the same SQLite tables, so a character created on the web can be played live +here and vice-versa. Combat/levelling formulas deliberately match the PHP +engine so both front-ends behave identically. + +This module is imported by bbsd.py; it exposes mud_menu(t, con, me). +""" +import json +import random +import time + +from bbsdb import q, q1, ex, ago + +START_HP, START_ATK, START_DEF = 30, 4, 2 +RESPAWN = 30 + +CLASSES = { + "fighter": ("Fighter", 2, 0, 6), + "mage": ("Mage", 4, 0, 0), + "thief": ("Thief", 1, 2, 2), +} + + +def class_bonus(cls): + return CLASSES.get(cls, CLASSES["fighter"]) + + +def item(con, key): + return q1(con, "SELECT * FROM mud_items WHERE key_name=?", (key,)) + + +def char_for(con, uid): + r = q1(con, "SELECT * FROM mud_chars WHERE user_id=?", (uid,)) + return dict(r) if r else None + + +def room(con, rid): + r = q1(con, "SELECT * FROM mud_rooms WHERE id=?", (rid,)) + if r: + r = dict(r) + r["exits"] = json.loads(r["exits"]) + return r + + +def ensure_char(con, me, cls="fighter"): + c = char_for(con, me["id"]) + if c: + return dict(c) + if cls not in CLASSES: + cls = "fighter" + nm, batk, bdef, bhp = class_bonus(cls) + maxhp = START_HP + bhp + ex(con, "INSERT INTO mud_chars (user_id,name,class,room_id,hp,max_hp,atk,def,xp,level,gold)" + " VALUES (?,?,?,1,?,?,?,?,0,1,0)", + (me["id"], me["username"], cls, maxhp, maxhp, START_ATK + batk, START_DEF + bdef)) + echo(con, 1, "%s the %s arrives in the world." % (me["username"], nm)) + return dict(char_for(con, me["id"])) + + +def echo(con, rid, text): + ex(con, "INSERT INTO mud_events (room_id,ts,text) VALUES (?,?,?)", + (rid, int(time.time()), text)) + + +def alive_spawns(con, rid): + return q(con, "SELECT s.*, m.key_name AS key_name, m.name AS name, m.descr AS descr " + "FROM mud_spawn s JOIN mud_mobs m ON m.id=s.mob_id " + "WHERE s.room_id=? AND s.alive=1 ORDER BY s.id", (rid,)) + + +def ground_items(con, rid): + return q(con, "SELECT g.id, i.key_name AS key_name, i.name AS name, i.descr AS descr " + "FROM mud_ground g JOIN mud_items i ON i.id=g.item_id " + "WHERE g.room_id=? ORDER BY g.id", (rid,)) + + +def respawn(con): + now = int(time.time()) + dead = q(con, "SELECT s.id, s.mob_id, m.hp AS mhp FROM mud_spawn s " + "JOIN mud_mobs m ON m.id=s.mob_id WHERE s.alive=0 AND s.next_respawn<=?", + (now,)) + for d in dead: + ex(con, "UPDATE mud_spawn SET alive=1, hp=? WHERE id=?", (d["mhp"], d["id"])) + + +def level_check(con, c): + need = c["level"] * 50 + if c["xp"] >= need: + c["xp"] -= need + c["level"] += 1 + c["max_hp"] += 8 + c["atk"] += 2 + c["def"] += 1 + c["hp"] = c["max_hp"] + ex(con, "UPDATE mud_chars SET level=?,max_hp=?,atk=?,def=?,xp=?,hp=? WHERE id=?", + (c["level"], c["max_hp"], c["atk"], c["def"], c["xp"], c["hp"], c["id"])) + return "You reached level %d! HP/ATK/DEF increased." % c["level"] + return None + + +def fight(con, spawn_id, c): + sp = q1(con, "SELECT s.*, m.key_name AS key_name, m.name AS name, m.descr AS descr, " + "m.atk AS atk, m.def AS def, m.xp AS xp, m.gold AS gold, m.loot AS loot, " + "m.respawn AS respawn FROM mud_spawn s JOIN mud_mobs m ON m.id=s.mob_id " + "WHERE s.id=?", (spawn_id,)) + if not sp or not sp["alive"]: + return ["There is nothing to fight here."] + sp = dict(sp) + msgs = [] + wep = item(con, c["weapon"]) if c["weapon"] else None + pDmg = max(1, c["atk"] + (wep["atk"] if wep else 0) - sp["def"] + random.randint(-1, 1)) + sp["hp"] -= pDmg + msgs.append("You hit %s for %d." % (sp["name"], pDmg)) + if sp["hp"] <= 0: + c["xp"] += sp["xp"] + c["gold"] += sp["gold"] + msgs.append("%s dies! +%d xp, +%d gold." % (sp["name"], sp["xp"], sp["gold"])) + inv = json.loads(c["inv"] or "[]") + for lk in json.loads(sp["loot"] or "[]"): + it = item(con, lk) + if it: + inv.append(lk) + msgs.append("You take %s." % it["name"]) + c["inv"] = json.dumps(inv) + ex(con, "UPDATE mud_spawn SET alive=0, next_respawn=? WHERE id=?", + (int(time.time()) + sp["respawn"], sp["id"])) + echo(con, c["room_id"], "%s slew %s." % (c["name"], sp["name"])) + ex(con, "UPDATE mud_chars SET xp=?,gold=?,inv=? WHERE id=?", + (c["xp"], c["gold"], c["inv"], c["id"])) + lv = level_check(con, c) + if lv: + msgs.append(lv) + return msgs + arm = item(con, c["armor"]) if c["armor"] else None + mDmg = max(1, sp["atk"] - (c["def"] + (arm["def"] if arm else 0)) + random.randint(-1, 1)) + c["hp"] -= mDmg + msgs.append("%s hits you for %d." % (sp["name"], mDmg)) + if c["hp"] <= 0: + c["hp"] = 0 + msgs.append("You have fallen! You wake in the Village Square.") + echo(con, c["room_id"], "%s was slain by %s and fades away." % (c["name"], sp["name"])) + lost = int(c["gold"] * 0.2) + c["gold"] -= lost + c["room_id"] = 1 + c["hp"] = c["max_hp"] + ex(con, "UPDATE mud_chars SET room_id=1,hp=?,gold=?,last_cmd_at=? WHERE id=?", + (c["hp"], c["gold"], int(time.time()), c["id"])) + else: + ex(con, "UPDATE mud_chars SET hp=? WHERE id=?", (c["hp"], c["id"])) + return msgs + + +# ------------------------------------------------------------------ UI +DIRS = {"n": "North", "s": "South", "e": "East", "w": "West", "u": "Up", "d": "Down"} + + +def show_room(t, con, c): + rm = room(con, c["room_id"]) + spawns = alive_spawns(con, c["room_id"]) + ground = ground_items(con, c["room_id"]) + others = players_in_room(con, c["room_id"]) + events = q(con, "SELECT text FROM mud_events WHERE room_id=? ORDER BY id DESC LIMIT 5", + (c["room_id"],)) + t.header("MUD - " + c["name"]) + t.line(" " + rm["name"]) + t.line(" " + rm["descr"]) + if spawns: + t.line(" Here:") + for s in spawns: + t.line(" %s (%d hp)" % (s["descr"], s["hp"])) + people = [p for p in others if p["id"] != c["id"]] + if people: + t.line(" Adventurers here:") + for p in people: + b = CLASSES.get(p["class"], CLASSES["fighter"])[0] + bn = ", bounty %dg" % p["bounty"] if p["bounty"] > 0 else "" + t.line(" %s (%s, L%d%s)" % (p["name"], b, p["level"], bn)) + if ground: + t.line(" On the ground:") + for g in ground: + t.line(" " + g["name"]) + if events: + t.line(" You see:") + for ev in reversed(events): + t.line(" " + ev["text"]) + t.rule("-") + t.line(" HP %d/%d Lvl %d XP %d Gold %d Bank %d" % ( + c["hp"], c["max_hp"], c["level"], c["xp"], c["gold"], c["bank"])) + ex(con, "UPDATE mud_chars SET last_cmd_at=? WHERE id=?", (int(time.time()), c["id"])) + + +def mud_menu(t, con, me): + c = ensure_char(con, me) + show_room(t, con, c) + while True: + t.line("\n [n/s/e/w/u/d] move [l]ook [k]ill [t]ake ") + t.line(" [w]ield [d]rink [i]nventory [sc]ore [Q]uit to main") + line = t.ask("\nmud> ").lower().strip() + if not line or line.startswith("q"): + t.line("Leaving the realm...") + return + parts = line.split(" ", 1) + cmd = parts[0] + arg = parts[1].strip() if len(parts) > 1 else "" + + if cmd in DIRS: + rm = room(con, c["room_id"]) + if cmd in rm["exits"]: + c["room_id"] = rm["exits"][cmd] + ex(con, "UPDATE mud_chars SET room_id=? WHERE id=?", (c["room_id"], c["id"])) + echo(con, c["room_id"], "%s heads %s." % (c["name"], DIRS[cmd])) + else: + t.line("You can't go that way.") + show_room(t, con, c) + continue + if cmd in ("l", "look"): + show_room(t, con, c) + continue + if cmd in ("k", "kill", "attack"): + if not arg: + t.line("Kill what?"); continue + spawns = alive_spawns(con, c["room_id"]) + hit = None + for s in spawns: + if s["key_name"].startswith(arg) or arg in s["name"]: + hit = s; break + if hit: + for m in fight(con, hit["id"], c): + t.line(" " + m) + else: + t.line("There is no '%s' here to fight." % arg) + show_room(t, con, c) + continue + if cmd in ("t", "take", "get"): + if not arg: + t.line("Take what?"); continue + ground = ground_items(con, c["room_id"]) + got = None + for g in ground: + if g["key_name"].startswith(arg): + got = g; break + if got: + ex(con, "DELETE FROM mud_ground WHERE id=?", (got["id"],)) + inv = json.loads(c["inv"] or "[]") + inv.append(got["key_name"]) + c["inv"] = json.dumps(inv) + ex(con, "UPDATE mud_chars SET inv=? WHERE id=?", (c["inv"], c["id"])) + echo(con, c["room_id"], "%s takes %s." % (c["name"], got["name"])) + t.line("You take %s." % got["name"]) + else: + t.line("There is no '%s' here." % arg) + show_room(t, con, c) + continue + if cmd in ("w", "wield", "wear"): + if not arg: + t.line("Wield what?"); continue + inv = json.loads(c["inv"] or "[]") + it = None + for k in inv: + if k.startswith(arg): + it = item(con, k); break + if not it: + t.line("You don't have that."); continue + if it["slot"] == "weapon": + c["weapon"] = it["key_name"] + ex(con, "UPDATE mud_chars SET weapon=? WHERE id=?", (it["key_name"], c["id"])) + t.line("You wield %s." % it["name"]) + elif it["slot"] == "armor": + c["armor"] = it["key_name"] + ex(con, "UPDATE mud_chars SET armor=? WHERE id=?", (it["key_name"], c["id"])) + t.line("You don %s." % it["name"]) + else: + t.line("You can't wield that.") + show_room(t, con, c) + continue + if cmd in ("d", "drink", "quaff"): + if not arg: + t.line("Drink what?"); continue + inv = json.loads(c["inv"] or "[]") + it = None + idx = None + for i, k in enumerate(inv): + if k.startswith(arg): + it = item(con, k); idx = i; break + if not it: + t.line("You don't have that."); continue + if it["slot"] != "potion": + t.line("That's not a potion."); continue + c["hp"] = min(c["max_hp"], c["hp"] + it["heal"]) + inv.pop(idx) + c["inv"] = json.dumps(inv) + ex(con, "UPDATE mud_chars SET hp=?,inv=? WHERE id=?", (c["hp"], c["inv"], c["id"])) + t.line("You drink %s and recover %d hp." % (it["name"], it["heal"])) + show_room(t, con, c) + continue + if cmd in ("i", "inv", "inventory"): + inv = json.loads(c["inv"] or "[]") + if inv: + t.line(" You carry: " + ", ".join(item(con, k)["name"] for k in inv)) + else: + t.line(" Your pack is empty.") + continue + if cmd in ("sc", "score", "stats"): + t.line(" Level %d | HP %d/%d | ATK %d | DEF %d | XP %d | Gold %d | Bank %d | Kills %d | Deaths %d" % ( + c["level"], c["hp"], c["max_hp"], c["atk"], c["def"], c["xp"], c["gold"], c["bank"], c["kills"], c["deaths"])) + continue + if cmd in ("a", "attack", "murder", "killp"): + if not arg: + t.line("Attack who?"); continue + v = find_player_in_room(con, c["room_id"], arg) + if v: + if v["id"] == c["id"]: + t.line("You can't attack yourself.") + else: + for m in pvp(con, c, v): + t.line(" " + m) + else: + others = [p for p in players_in_room(con, c["room_id"]) if p["id"] != c["id"]] + who = ", ".join(p["name"] for p in others) or "nobody" + t.line("There is no '%s' here to fight. Adventurers present: %s." % (arg, who)) + show_room(t, con, c) + continue + if cmd in ("b", "buy"): + if not arg: + t.line("Buy what? e.g. 'buy steel_sword'"); continue + for m in buy(con, c, arg): + t.line(" " + m) + show_room(t, con, c) + continue + if cmd in ("bank",): + if not arg: + t.line("bank to deposit, bank - to withdraw"); continue + if arg.startswith("-"): + for m in withdraw(con, c, int(arg[1:] or 0)): + t.line(" " + m) + else: + for m in deposit(con, c, int(arg or 0)): + t.line(" " + m) + show_room(t, con, c) + continue + if cmd in ("tax",): + for m in tax(con, c): + t.line(" " + m) + show_room(t, con, c) + continue + if cmd in ("bounty", "hit"): + parts = arg.split(" ", 1) + if len(parts) < 2 or not parts[1].isdigit(): + t.line("bounty "); continue + v = find_player_in_room(con, c["room_id"], parts[0]) + if v: + if v["id"] == c["id"]: + t.line("You can't bounty yourself.") + else: + for m in set_bounty(con, c, v, int(parts[1])): + t.line(" " + m) + else: + t.line("There is no '%s' here to bounty." % parts[0]) + show_room(t, con, c) + continue + if cmd in ("board", "top", "leaderboard"): + lb = leaderboard(con, 10) + t.line(" Adventurers of renown:") + for i, r in enumerate(lb, 1): + cn = CLASSES.get(r["class"], CLASSES["fighter"])[0] + t.line(" %d. %s (%s) L%d G%d K%d/D%d" % (i, r["name"], cn, r["level"], r["gold"], r["kills"], r["deaths"])) + continue + t.line(" Unknown command. Try: n/s/e/w/u/d, look, kill , attack ,") + t.line(" buy , bank , tax, bounty , board, inv, score.") + + +# ---- RPGBBS-style economy & PvP (mirror of lib/mud.php) ---- + +def players_in_room(con, rid): + return q(con, "SELECT c.*, u.username AS username FROM mud_chars c " + "JOIN users u ON u.id=c.user_id WHERE c.room_id=? ORDER BY c.level DESC", (rid,)) + + +def find_player_in_room(con, rid, frag): + frag = frag.lower() + for p in players_in_room(con, rid): + if p["id"] == 0: + continue + if frag in (p["name"] or "").lower() or frag in (p["username"] or "").lower(): + return p + return None + + +def shop_list(con): + return q(con, "SELECT * FROM mud_items WHERE price>0 ORDER BY price") + + +def buy(con, c, key): + it = item(con, key) + if not it or it["price"] <= 0: + return ["The trader doesn't sell that."] + if c["gold"] < it["price"]: + return ["You can't afford the %s (%dg)." % (it["name"], it["price"])] + c["gold"] -= it["price"] + inv = json.loads(c["inv"] or "[]") + inv.append(it["key_name"]) + c["inv"] = json.dumps(inv) + ex(con, "UPDATE mud_chars SET gold=?,inv=? WHERE id=?", (c["gold"], c["inv"], c["id"])) + return ["You buy %s for %dg." % (it["name"], it["price"])] + + +def deposit(con, c, amt): + amt = max(0, min(amt, c["gold"])) + if amt <= 0: + return ["You have no gold to bank."] + c["gold"] -= amt + c["bank"] += amt + ex(con, "UPDATE mud_chars SET gold=?,bank=? WHERE id=?", (c["gold"], c["bank"], c["id"])) + return ["You deposit %dg. Bank balance: %dg." % (amt, c["bank"])] + + +def withdraw(con, c, amt): + amt = max(0, min(amt, c["bank"])) + if amt <= 0: + return ["Nothing to withdraw."] + c["bank"] -= amt + c["gold"] += amt + ex(con, "UPDATE mud_chars SET gold=?,bank=? WHERE id=?", (c["gold"], c["bank"], c["id"])) + return ["You withdraw %dg. You carry %dg." % (amt, c["gold"])] + + +def tax(con, c): + due = int(c["gold"] * 0.10) + if due <= 0: + return ["Sir Joe squints. \"Come back when ye've coins to tithe.\""] + c["gold"] -= due + ex(con, "UPDATE mud_chars SET gold=? WHERE id=?", (c["gold"], c["id"])) + return ["Sir Joe pockets %dg. \"That's the price of civilisation, adventurer.\"" % due] + + +def leaderboard(con, limit=10): + return q(con, "SELECT name,class,level,gold,kills,deaths FROM mud_chars " + "ORDER BY level DESC, gold DESC LIMIT %d" % limit) + + +def pvp(con, att, dfn): + msgs = [] + if dfn["id"] == att["id"]: + return ["You can't attack yourself."] + if dfn["room_id"] != att["room_id"]: + return ["They aren't here."] + aWep = item(con, att["weapon"])["atk"] if att["weapon"] else 0 + dWep = item(con, dfn["weapon"])["atk"] if dfn["weapon"] else 0 + aArm = item(con, att["armor"])["def"] if att["armor"] else 0 + dArm = item(con, dfn["armor"])["def"] if dfn["armor"] else 0 + aDmg = max(1, att["atk"] + aWep - dfn["def"] - dArm + random.randint(-1, 1)) + dDmg = max(1, dfn["atk"] + dWep - att["def"] - aArm + random.randint(-1, 1)) + att["hp"] -= dDmg + dfn["hp"] -= aDmg + msgs.append("You strike %s for %d. %s strikes you for %d." % (dfn["name"], aDmg, dfn["name"], dDmg)) + if dfn["hp"] <= 0 and att["hp"] > 0: + loot = int(dfn["gold"] * 0.5) + att["gold"] += loot + att["kills"] += 1 + dfn["gold"] -= loot + dfn["deaths"] += 1 + dfn["room_id"] = 1 + dfn["hp"] = dfn["max_hp"] + bounty = int(dfn["bounty"]) + if bounty > 0: + att["gold"] += bounty + dfn["bounty"] = 0 + msgs.append("You collect the %dg bounty on %s!" % (bounty, dfn["name"])) + msgs.append("You slay %s! +%dg looted%s" % ( + dfn["name"], loot, ", +%dg bounty." % bounty if bounty else ".")) + echo(con, att["room_id"], "%s cut down %s in cold blood." % (att["name"], dfn["name"])) + ex(con, "UPDATE mud_chars SET gold=?,kills=?,hp=?,room_id=? WHERE id=?", + (att["gold"], att["kills"], att["hp"], att["room_id"], att["id"])) + ex(con, "UPDATE mud_chars SET gold=?,deaths=?,bounty=?,hp=?,room_id=? WHERE id=?", + (dfn["gold"], dfn["deaths"], dfn["bounty"], dfn["hp"], dfn["id"])) + ex(con, "DELETE FROM mud_bounties WHERE target_id=?", (dfn["id"],)) + return msgs + if att["hp"] <= 0 and dfn["hp"] > 0: + loot = int(att["gold"] * 0.5) + dfn["gold"] += loot + dfn["kills"] += 1 + att["gold"] -= loot + att["deaths"] += 1 + att["room_id"] = 1 + att["hp"] = att["max_hp"] + msgs.append("%s bests you! You lose %dg and wake in the Village Square." % (dfn["name"], loot)) + echo(con, att["room_id"], "%s cut down %s." % (dfn["name"], att["name"])) + ex(con, "UPDATE mud_chars SET gold=?,deaths=?,hp=?,room_id=? WHERE id=?", + (att["gold"], att["deaths"], att["hp"], att["room_id"], att["id"])) + ex(con, "UPDATE mud_chars SET gold=?,kills=? WHERE id=?", (dfn["gold"], dfn["kills"], dfn["id"])) + return msgs + ex(con, "UPDATE mud_chars SET hp=? WHERE id=?", (att["hp"], att["id"])) + ex(con, "UPDATE mud_chars SET hp=? WHERE id=?", (dfn["hp"], dfn["id"])) + return msgs + + +def set_bounty(con, c, target, amt): + if target["id"] == c["id"]: + return ["You can't bounty yourself."] + if amt <= 0: + return ["A bounty needs to be worth something."] + if c["gold"] < amt: + return ["You can't post a %dg bounty." % amt] + c["gold"] -= amt + ex(con, "UPDATE mud_chars SET bounty=bounty+?, gold=? WHERE id=?", (amt, c["gold"], target["id"])) + ex(con, "INSERT INTO mud_bounties (target_id,by_id,amount,ts) VALUES (?,?,?,?)", + (target["id"], c["id"], amt, int(time.time()))) + ex(con, "UPDATE mud_chars SET gold=? WHERE id=?", (c["gold"], c["id"])) + return ["You post a %dg bounty on %s. The realm will remember." % (amt, target["name"])] diff --git a/public_html/bbs/screens.py b/public_html/bbs/screens.py new file mode 100644 index 0000000..5f91d69 --- /dev/null +++ b/public_html/bbs/screens.py @@ -0,0 +1,305 @@ +"""Profile + admin screens for the BBS.""" +import time +from bbsdb import q, q1, ex, user_by_name, user_by_id, hash_password, ago + +PER_PAGE = 10 + + +# ================================================================= profile +def profile_menu(t, con, me): + while True: + u = user_by_id(con, me["id"]) + posts = q1(con, "SELECT COUNT(*) c FROM posts WHERE user_id=?", (u["id"],))["c"] + t.header("YOUR PROFILE") + t.line(" User : %s%s" % (u["username"], " [admin]" if u["is_admin"] else "")) + t.line(" Tagline : %s" % (u["tagline"] or "-")) + t.line(" Location: %s" % (u["location"] or "-")) + t.line(" Joined : %s" % time.strftime("%d/%m/%Y", + time.localtime(u["created_at"]))) + t.line(" Posts : %d" % posts) + best = q(con, "SELECT game, MAX(score) sc FROM scores WHERE user_id=?" + " GROUP BY game", (u["id"],)) + for b in best: + t.line(" Best %-6s: %d" % (b["game"], b["sc"])) + t.line("\n [T]agline [L]ocation [P]assword [W]ho's online [Q]back") + c = t.ask("\nprofile> ").lower() + if c.startswith("t"): + v = t.ask(" New tagline: ", maxlen=80) + ex(con, "UPDATE users SET tagline=? WHERE id=?", (v, u["id"])) + elif c.startswith("l"): + v = t.ask(" New location: ", maxlen=40) + ex(con, "UPDATE users SET location=? WHERE id=?", (v, u["id"])) + elif c.startswith("p"): + change_password(t, con, u) + elif c.startswith("w"): + who(t, con) + elif c.startswith("q") or c == "": + return + + +def change_password(t, con, u): + from bbsdb import verify_password + old = t.secret(" Current password: ") + if not verify_password(old, u["pass_hash"]): + t.line(" Wrong password.") + return + n1 = t.secret(" New password: ") + if len(n1) < 4: + t.line(" Too short (min 4).") + return + if n1 != t.secret(" Repeat new: "): + t.line(" Did not match.") + return + h = hash_password(n1) + if not h: + t.line(" Could not hash password - unchanged.") + return + ex(con, "UPDATE users SET pass_hash=? WHERE id=?", (h, u["id"])) + t.line(" Password changed. It works on the website too.") + + +def who(t, con): + rows = q(con, "SELECT username,last_seen,is_admin FROM users " + "ORDER BY last_seen DESC LIMIT 15") + t.header("RECENTLY SEEN") + for r in rows: + t.line(" %-16s %s ago%s" % (r["username"][:16], ago(r["last_seen"]), + " [admin]" if r["is_admin"] else "")) + t.pause() + + +def userlist(t, con): + page = 0 + while True: + rows = q(con, "SELECT username,tagline,last_seen FROM users " + "ORDER BY username LIMIT ? OFFSET ?", + (PER_PAGE + 1, page * PER_PAGE)) + more = len(rows) > PER_PAGE + rows = rows[:PER_PAGE] + t.header("MEMBER LIST") + for r in rows: + t.line(" %-16s %-24s %s" % (r["username"][:16], + (r["tagline"] or "")[:24], + ago(r["last_seen"]))) + t.line("\n [N]ext [P]rev [Q]back") + c = t.ask("\nusers> ").lower() + if c.startswith("n") and more: + page += 1 + elif c.startswith("p") and page > 0: + page -= 1 + else: + return + + +# ================================================================== admin +def admin_menu(t, con, me): + if not me["is_admin"]: + t.line(" Admin only.") + return + while True: + t.header("ADMIN TOOLS") + t.line(" [U]sers [F]orums [T]opics [M]OTD [S]tats [Q]back") + c = t.ask("\nadmin> ").lower() + if c.startswith("u"): + admin_users(t, con, me) + elif c.startswith("f"): + admin_forums(t, con) + elif c.startswith("t"): + admin_topics(t, con) + elif c.startswith("m"): + v = t.ask(" New MOTD: ", maxlen=200) + ex(con, "INSERT INTO settings (k,v) VALUES ('motd',?) " + "ON CONFLICT(k) DO UPDATE SET v=excluded.v", (v,)) + t.line(" MOTD updated (site + BBS).") + elif c.startswith("s"): + admin_stats(t, con) + elif c.startswith("q") or c == "": + return + + +def admin_stats(t, con): + t.header("STATS") + for label, sql in [ + ("Users", "SELECT COUNT(*) c FROM users"), + ("Banned", "SELECT COUNT(*) c FROM users WHERE is_banned=1"), + ("Forums", "SELECT COUNT(*) c FROM forums"), + ("Topics", "SELECT COUNT(*) c FROM topics"), + ("Posts", "SELECT COUNT(*) c FROM posts"), + ("Messages", "SELECT COUNT(*) c FROM messages"), + ("Matches", "SELECT COUNT(*) c FROM matches"), + ]: + t.line(" %-10s %d" % (label, q1(con, sql)["c"])) + t.pause() + + +def admin_users(t, con, me): + while True: + t.header("ADMIN: USERS") + t.line(" [L]ist [F]ind [A]dd [E]dit [Q]back") + c = t.ask("\nadmin/users> ").lower() + if c.startswith("l"): + rows = q(con, "SELECT id,username,is_admin,is_banned FROM users" + " ORDER BY id LIMIT 40") + for r in rows: + t.line(" #%-4d %-16s %s%s" % ( + r["id"], r["username"][:16], + "admin " if r["is_admin"] else "", + "BANNED" if r["is_banned"] else "")) + t.pause() + elif c.startswith("f"): + term = t.ask(" Username contains: ", maxlen=16) + rows = q(con, "SELECT id,username,is_admin,is_banned FROM users" + " WHERE username LIKE ? ORDER BY id LIMIT 40", + ("%" + term + "%",)) + if not rows: + t.line(" (no matches)") + for r in rows: + t.line(" #%-4d %-16s %s%s" % ( + r["id"], r["username"][:16], + "admin " if r["is_admin"] else "", + "BANNED" if r["is_banned"] else "")) + t.pause() + elif c.startswith("a"): + name = t.ask(" New username: ", maxlen=16) + if not name.replace("_", "").isalnum() or not 3 <= len(name) <= 16: + t.line(" Bad username (3-16 alnum/underscore).") + continue + if user_by_name(con, name): + t.line(" Already taken.") + continue + pw = t.secret(" Password: ") + if len(pw) < 4: + t.line(" Too short.") + continue + h = hash_password(pw) + if not h: + t.line(" Hashing failed.") + continue + now = int(time.time()) + ex(con, "INSERT INTO users (username,pass_hash,is_admin,created_at)" + " VALUES (?,?,0,?)", (name, h, now)) + t.line(" Created %s." % name) + elif c.startswith("e"): + admin_edit_user(t, con, me) + elif c.startswith("q") or c == "": + return + + +def admin_edit_user(t, con, me): + name = t.ask(" Username to edit: ", maxlen=16) + u = user_by_name(con, name) + if not u: + t.line(" No such user.") + return + while True: + u = user_by_id(con, u["id"]) + t.header("EDIT: %s (#%d)" % (u["username"], u["id"])) + t.line(" admin=%d banned=%d tagline=%s" % + (u["is_admin"], u["is_banned"], u["tagline"] or "-")) + t.line("\n [A]dmin toggle [B]an toggle [R]eset pw [D]elete [Q]back") + c = t.ask("\nedit> ").lower() + if c.startswith("a"): + if u["id"] == me["id"] and u["is_admin"]: + t.line(" You cannot remove your own admin rights.") + continue + ex(con, "UPDATE users SET is_admin=1-is_admin WHERE id=?", (u["id"],)) + elif c.startswith("b"): + if u["id"] == me["id"]: + t.line(" You cannot ban yourself.") + continue + ex(con, "UPDATE users SET is_banned=1-is_banned WHERE id=?", (u["id"],)) + elif c.startswith("r"): + pw = t.secret(" New password: ") + if len(pw) < 4: + t.line(" Too short.") + continue + h = hash_password(pw) + if h: + ex(con, "UPDATE users SET pass_hash=? WHERE id=?", (h, u["id"])) + t.line(" Reset.") + elif c.startswith("d"): + if u["id"] == me["id"]: + t.line(" You cannot delete yourself.") + continue + if t.ask(" Type DELETE to confirm: ") == "DELETE": + ex(con, "DELETE FROM users WHERE id=?", (u["id"],)) + t.line(" Deleted.") + return + elif c.startswith("q") or c == "": + return + + +def admin_forums(t, con): + while True: + rows = q(con, "SELECT f.*, (SELECT COUNT(*) FROM topics x " + "WHERE x.forum_id=f.id) tc FROM forums f " + "ORDER BY f.sort_order, f.id") + t.header("ADMIN: FORUMS") + for r in rows: + t.line(" #%-3d %-24s %3dt %s" % ( + r["id"], r["name"][:24], r["tc"], + "[locked]" if r["is_locked"] else "")) + t.line("\n [A]dd [L]ock toggle [R]ename [D]elete [Q]back") + c = t.ask("\nadmin/forums> ").lower() + if c.startswith("a"): + name = t.ask(" Forum name: ", maxlen=60) + if not name: + continue + descr = t.ask(" Description: ", maxlen=120) + order = t.ask(" Sort order (number): ", maxlen=4) + ex(con, "INSERT INTO forums (name,descr,sort_order) VALUES (?,?,?)", + (name, descr, int(order) if order.isdigit() else 0)) + t.line(" Added.") + elif c.startswith("l"): + i = t.ask(" Forum id: ", maxlen=6) + if i.isdigit(): + ex(con, "UPDATE forums SET is_locked=1-is_locked WHERE id=?", (int(i),)) + elif c.startswith("r"): + i = t.ask(" Forum id: ", maxlen=6) + if i.isdigit(): + name = t.ask(" New name: ", maxlen=60) + if name: + ex(con, "UPDATE forums SET name=? WHERE id=?", (name, int(i))) + elif c.startswith("d"): + i = t.ask(" Forum id to DELETE (with all topics): ", maxlen=6) + if i.isdigit() and t.ask(" Type DELETE to confirm: ") == "DELETE": + ex(con, "DELETE FROM forums WHERE id=?", (int(i),)) + t.line(" Deleted.") + elif c.startswith("q") or c == "": + return + + +def admin_topics(t, con): + while True: + rows = q(con, "SELECT t.*, f.name fname, u.username FROM topics t " + "JOIN forums f ON f.id=t.forum_id " + "LEFT JOIN users u ON u.id=t.user_id " + "ORDER BY t.bumped_at DESC LIMIT 20") + t.header("ADMIN: TOPICS (latest 20)") + for r in rows: + t.line(" #%-4d [%-10s] %-26s %s%s" % ( + r["id"], r["fname"][:10], r["title"][:26], + (r["username"] or "?")[:10], + " LOCKED" if r["is_locked"] else "")) + t.line("\n [L]ock toggle [M]ove [D]elete [Q]back") + c = t.ask("\nadmin/topics> ").lower() + if c.startswith("l"): + i = t.ask(" Topic id: ", maxlen=6) + if i.isdigit(): + ex(con, "UPDATE topics SET is_locked=1-is_locked WHERE id=?", (int(i),)) + elif c.startswith("m"): + i = t.ask(" Topic id: ", maxlen=6) + f = t.ask(" Move to forum id: ", maxlen=6) + if i.isdigit() and f.isdigit(): + if q1(con, "SELECT 1 x FROM forums WHERE id=?", (int(f),)): + ex(con, "UPDATE topics SET forum_id=? WHERE id=?", (int(f), int(i))) + t.line(" Moved.") + else: + t.line(" No such forum.") + elif c.startswith("d"): + i = t.ask(" Topic id to DELETE: ", maxlen=6) + if i.isdigit() and t.ask(" Type DELETE to confirm: ") == "DELETE": + ex(con, "DELETE FROM topics WHERE id=?", (int(i),)) + t.line(" Deleted.") + elif c.startswith("q") or c == "": + return diff --git a/public_html/bbs/term.py b/public_html/bbs/term.py new file mode 100644 index 0000000..8ee7bed --- /dev/null +++ b/public_html/bbs/term.py @@ -0,0 +1,163 @@ +"""Telnet terminal I/O for the txt3 BBS. + +Deliberately conservative: real telnet clients, netcat, PuTTY in raw mode and +Windows telnet all have to work, so we negotiate the bare minimum (suppress +go-ahead, refuse everything else) and strip IAC sequences from the byte stream +rather than trying to be a full RFC 854 implementation. +""" +import socket + +IAC = 255 +DONT = 254 +DO = 253 +WONT = 252 +WILL = 251 +SB = 250 +SE = 240 +ECHO = 1 +SGA = 3 + +CRLF = b"\r\n" + + +class Hangup(Exception): + """Raised when the peer disconnects or times out.""" + + +class Term: + def __init__(self, sock, addr, idle_timeout=600): + self.s = sock + self.addr = addr + self.buf = b"" + self.s.settimeout(idle_timeout) + # We will echo input ourselves only for passwords (as masking); + # otherwise let the client echo locally, which is what most do. + self._send_raw(bytes([IAC, WILL, SGA])) + self._send_raw(bytes([IAC, DO, SGA])) + + # ------------------------------------------------------------- low level + def _send_raw(self, data): + try: + self.s.sendall(data) + except (BrokenPipeError, ConnectionResetError, OSError): + raise Hangup() + + def write(self, text=""): + if isinstance(text, str): + # Telnet wants CRLF; also escape a literal IAC byte. + text = text.replace("\n", "\r\n").encode("utf-8", "replace") + self._send_raw(text.replace(bytes([IAC]), bytes([IAC, IAC]))) + + def line(self, text=""): + self.write(text + "\n") + + # ------------------------------------------------------------- input + def _fill(self): + try: + chunk = self.s.recv(1024) + except socket.timeout: + raise Hangup() + except (ConnectionResetError, OSError): + raise Hangup() + if not chunk: + raise Hangup() + self.buf += chunk + + def _pop_byte(self): + while not self.buf: + self._fill() + b = self.buf[0] + self.buf = self.buf[1:] + return b + + def _read_char(self): + """Return the next data byte, transparently consuming telnet commands.""" + while True: + b = self._pop_byte() + if b != IAC: + return b + # IAC ... + c = self._pop_byte() + if c == IAC: + return IAC # escaped literal 255 + if c in (DO, DONT, WILL, WONT): + opt = self._pop_byte() + # Refuse everything except SGA, which we already agreed. + if c == DO: + resp = WILL if opt == SGA else WONT + elif c == WILL: + resp = DO if opt == SGA else DONT + else: + resp = WONT if c == DO else DONT + self._send_raw(bytes([IAC, resp, opt])) + continue + if c == SB: + # swallow the subnegotiation up to IAC SE + prev = None + while True: + x = self._pop_byte() + if prev == IAC and x == SE: + break + prev = x + continue + # any other 2-byte command: ignore + continue + + def read(self, prompt="", mask=False, maxlen=200): + """Read one line of input. Handles backspace and bare CR or LF.""" + if prompt: + self.write(prompt) + out = bytearray() + while True: + b = self._read_char() + if b in (13, 10): # CR or LF + # A CR is often followed by LF or NUL; peek and drop it. + if self.buf[:1] in (b"\n", b"\x00"): + self.buf = self.buf[1:] + self.write("\n") + break + if b in (8, 127): # backspace / delete + if out: + out.pop() + if mask: + self.write("\b \b") + else: + self.write("\b \b") + continue + if b == 3: # ^C + raise Hangup() + if b == 4 and not out: # ^D on an empty line + raise Hangup() + if b < 32 or b > 126: + continue # ignore other control/non-ascii + if len(out) >= maxlen: + continue + out.append(b) + if mask: + self.write("*") + return out.decode("utf-8", "replace").strip() + + def ask(self, prompt, maxlen=200): + return self.read(prompt, mask=False, maxlen=maxlen) + + def secret(self, prompt="Password: "): + return self.read(prompt, mask=True, maxlen=60) + + # ------------------------------------------------------------- screen + def rule(self, ch="-", n=60): + self.line(ch * n) + + def header(self, title): + self.line() + self.rule("=") + self.line(" " + title) + self.rule("=") + + def pause(self): + self.read("\n[enter] ") + + def close(self): + try: + self.s.close() + except OSError: + pass diff --git a/public_html/bbs/txt3-bbs.service b/public_html/bbs/txt3-bbs.service new file mode 100644 index 0000000..53564f5 --- /dev/null +++ b/public_html/bbs/txt3-bbs.service @@ -0,0 +1,43 @@ +[Unit] +Description=txt3 BBS (telnet front-end for wap.txt3.net) +Documentation=https://wap.txt3.net/about.php +After=network-online.target tailscaled.service +Wants=network-online.target + +[Service] +Type=simple +User=txt3 +Group=txt3 +WorkingDirectory=/home/txt3/domains/wap.txt3.net/bbs +ExecStart=/usr/bin/python3 /home/txt3/domains/wap.txt3.net/bbs/bbsd.py +Environment=BBS_HOST=100.127.96.105 +Environment=BBS_PORT=12300 +Environment=BBS_DB=/home/txt3/domains/wap.txt3.net/public_html/data/wap.sqlite +Environment=BBS_PHP=/usr/bin/php +Environment=PYTHONUNBUFFERED=1 + +# The service only ever needs its own code, the shared sqlite db, and php for +# bcrypt hashing. Everything else is locked down. +NoNewPrivileges=yes +PrivateTmp=yes +ProtectSystem=strict +ProtectHome=read-only +ReadWritePaths=/home/txt3/domains/wap.txt3.net/public_html/data +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectControlGroups=yes +RestrictNamespaces=yes +RestrictSUIDSGID=yes +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX +LockPersonality=yes +MemoryDenyWriteExecute=no +# tailscale-only listener, so no need for extra port capabilities +CapabilityBoundingSet= + +Restart=on-failure +RestartSec=5 +StandardOutput=append:/home/txt3/domains/wap.txt3.net/bbs/bbs.log +StandardError=append:/home/txt3/domains/wap.txt3.net/bbs/bbs.log + +[Install] +WantedBy=multi-user.target diff --git a/public_html/compose.php b/public_html/compose.php new file mode 100644 index 0000000..c537ec3 --- /dev/null +++ b/public_html/compose.php @@ -0,0 +1,50 @@ +prepare("SELECT subject FROM messages WHERE id=? AND to_id=?"); + $s->execute([$reId, $me['id']]); + $sub = $s->fetchColumn(); + if ($sub) $subj = (strncasecmp($sub, 'Re:', 3) === 0) ? $sub : 'Re: ' . $sub; +} + +$err = ''; +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + require_csrf(); + $toName = trim((string)($_POST['to'] ?? '')); + $subj = mb_substr(trim((string)($_POST['subject'] ?? '')), 0, 60); + $body = trim((string)($_POST['body'] ?? '')); + $dest = user_by_name($toName); + if (!$dest) $err = 'No such user: ' . $toName; + elseif ((int)$dest['id'] === (int)$me['id']) $err = 'You cannot message yourself.'; + elseif ($subj === '') $err = 'Subject required.'; + elseif ($body === '') $err = 'Message body required.'; + else { + db()->prepare("INSERT INTO messages (from_id,to_id,subject,body,created_at) + VALUES (?,?,?,?,?)") + ->execute([$me['id'], $dest['id'], $subj, mb_substr($body, 0, 4000), time()]); + page_start('Sent', ['back' => '/inbox.php']); + p_ok('Message sent to ' . $dest['username'] . '.'); + p_links([['/inbox.php', 'Inbox'], ['/index.php', 'Home']]); + page_end(); + exit; + } +} + +page_start('New message', ['back' => '/inbox.php']); +if ($err) p_err($err); +p_form('/compose.php', [ + ['name' => 'to', 'label' => 'To (username)', 'value' => $toName, 'maxlength' => 16], + ['name' => 'subject', 'label' => 'Subject', 'value' => $subj, 'maxlength' => 60], + ['name' => 'body', 'label' => 'Message', 'type' => is_wml() ? 'text' : 'textarea', + 'maxlength' => is_wml() ? 200 : 4000], +], 'Send'); +p_links([['/inbox.php', 'Inbox'], ['/users.php', 'Member list']]); +page_end(); diff --git a/public_html/data/wap.sqlite b/public_html/data/wap.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..bb5bde1bab1fb838661da2b44443a71dea857ece GIT binary patch literal 143360 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 literal 0 HcmV?d00001 diff --git a/public_html/forum.php b/public_html/forum.php new file mode 100644 index 0000000..6081454 --- /dev/null +++ b/public_html/forum.php @@ -0,0 +1,87 @@ +query("SELECT f.*, + (SELECT COUNT(*) FROM topics t WHERE t.forum_id=f.id) tc, + (SELECT COUNT(*) FROM posts p JOIN topics t ON t.id=p.topic_id + WHERE t.forum_id=f.id) pc + FROM forums f ORDER BY f.sort_order, f.id")->fetchAll(); + page_start('Forum', ['back' => '/index.php']); + if (!$rows) p_para('No forums yet.'); + foreach ($rows as $r) { + p_link('/forum.php', $r['name'] . ' (' . $r['tc'] . 't/' . $r['pc'] . 'p)' + . ($r['is_locked'] ? ' [locked]' : ''), ['f' => $r['id']]); + if (!is_wml() && $r['descr'] !== '') p_para($r['descr'], 'small'); + } + p_links([['/index.php', 'Home']]); + page_end(); + exit; +} + +// ---------- topic list for one forum ---------- +$s = db()->prepare("SELECT * FROM forums WHERE id=?"); +$s->execute([$fid]); +$f = $s->fetch(); +if (!$f) bail('Forum', 'No such forum.', '/forum.php'); + +// new topic +$err = ''; +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $me = require_login(); + require_csrf(); + if ($f['is_locked'] && !$me['is_admin']) bail('Forum', 'Forum is locked.', '/forum.php'); + $title = mb_substr(trim((string)($_POST['title'] ?? '')), 0, 80); + $body = trim((string)($_POST['body'] ?? '')); + if ($title === '' || $body === '') { + $err = 'Title and message are both required.'; + } else { + $now = time(); + $d = db(); + $d->prepare("INSERT INTO topics (forum_id,user_id,title,created_at,bumped_at) + VALUES (?,?,?,?,?)")->execute([$fid, $me['id'], $title, $now, $now]); + $tid = (int)$d->lastInsertId(); + $d->prepare("INSERT INTO posts (topic_id,user_id,body,created_at) VALUES (?,?,?,?)") + ->execute([$tid, $me['id'], mb_substr($body, 0, 4000), $now]); + redirect('/topic.php', ['t' => $tid]); + } +} + +$page = max(1, (int)($_GET['p'] ?? 1)); +$off = ($page - 1) * PER_PAGE; +$s = db()->prepare("SELECT t.*, u.username, + (SELECT COUNT(*) FROM posts p WHERE p.topic_id=t.id) pc + FROM topics t LEFT JOIN users u ON u.id=t.user_id + WHERE t.forum_id=? ORDER BY t.bumped_at DESC LIMIT ? OFFSET ?"); +$s->execute([$fid, PER_PAGE + 1, $off]); +$rows = $s->fetchAll(); +$more = count($rows) > PER_PAGE; +if ($more) array_pop($rows); + +page_start($f['name'], ['back' => '/forum.php']); +if ($err) p_err($err); +if (!$rows) p_para('No topics yet. Start one!'); +foreach ($rows as $r) { + p_link('/topic.php', ($r['is_locked'] ? '[L] ' : '') . $r['title'] + . ' (' . $r['pc'] . ') by ' . ($r['username'] ?? '?'), ['t' => $r['id']]); +} +p_pager('/forum.php', $page, $more, ['f' => $fid]); +p_rule(); +if ($me && (!$f['is_locked'] || $me['is_admin'])) { + p_para('New topic:'); + p_form('/forum.php?f=' . $fid, [ + ['name' => 'title', 'label' => 'Title', 'maxlength' => 80], + ['name' => 'body', 'label' => 'Message', 'type' => is_wml() ? 'text' : 'textarea', + 'maxlength' => is_wml() ? 200 : 4000], + ], 'Post'); +} elseif (!$me) { + p_links([['/login.php', 'Log in to post']]); +} else { + p_para('This forum is locked.'); +} +p_links([['/forum.php', 'All forums'], ['/index.php', 'Home']]); +page_end(); diff --git a/public_html/games.php b/public_html/games.php new file mode 100644 index 0000000..b70a07e --- /dev/null +++ b/public_html/games.php @@ -0,0 +1,23 @@ + '/index.php']); +p_para('Single player:'); +p_links([ + ['/games/guess.php', 'Guess the Number'], + ['/games/quiz.php', 'Quick Quiz'], +]); +p_para('Multiplayer (play a friend):'); +p_links([ + ['/games/ttt.php', 'Noughts & Crosses'], + ['/games/nim.php', 'Nim (21 sticks)'], +]); +p_para('The MUD - a shared world:'); +p_links([ + ['/mud.php', 'Enter the realm'], +]); +p_rule(); +p_links([['/games/scores.php', 'High scores'], ['/index.php', 'Home']]); +if (!$me) p_para('Log in to save scores and play others.'); +page_end(); diff --git a/public_html/games/guess.php b/public_html/games/guess.php new file mode 100644 index 0000000..49b053f --- /dev/null +++ b/public_html/games/guess.php @@ -0,0 +1,74 @@ +prepare("SELECT * FROM sp_games WHERE user_id=? AND game='guess' + AND status='active' ORDER BY id DESC LIMIT 1"); + $s->execute([$uid]); + return $s->fetch() ?: null; +} +function guess_new(int $uid): array { + $st = json_encode(['n' => random_int(1, 100), 'tries' => 0, 'log' => []]); + $now = time(); + db()->prepare("INSERT INTO sp_games (game,user_id,state,created_at,updated_at) + VALUES ('guess',?,?,?,?)")->execute([$uid, $st, $now, $now]); + return guess_load($uid); +} + +$msg = ''; $done = false; +$g = guess_load((int)$me['id']); + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + require_csrf(); + if (($_POST['act'] ?? '') === 'new') { + if ($g) db()->prepare("UPDATE sp_games SET status='abandoned' WHERE id=?")->execute([$g['id']]); + $g = guess_new((int)$me['id']); + $msg = 'New game! Guess a number between 1 and 100.'; + } else { + if (!$g) $g = guess_new((int)$me['id']); + $st = json_decode($g['state'], true); + $gv = (int)($_POST['guess'] ?? 0); + if ($gv < 1 || $gv > 100) { + $msg = 'Enter a number from 1 to 100.'; + } else { + $st['tries']++; + $st['log'][] = $gv; + if ($gv === (int)$st['n']) { + $score = max(1, 110 - 10 * (int)$st['tries']); + db()->prepare("UPDATE sp_games SET status='won', state=?, updated_at=? WHERE id=?") + ->execute([json_encode($st), time(), $g['id']]); + db()->prepare("INSERT INTO scores (game,user_id,score,detail,created_at) + VALUES ('guess',?,?,?,?)") + ->execute([$me['id'], $score, $st['tries'] . ' tries', time()]); + $msg = "Correct! $gv was the number, in {$st['tries']} tries. Score: $score"; + $done = true; $g = null; + } else { + $msg = $gv . ' is too ' . ($gv < (int)$st['n'] ? 'LOW' : 'HIGH') + . '. Tries: ' . $st['tries']; + db()->prepare("UPDATE sp_games SET state=?, updated_at=? WHERE id=?") + ->execute([json_encode($st), time(), $g['id']]); + $g['state'] = json_encode($st); + } + } + } +} + +page_start('Guess the Number', ['back' => '/games.php']); +if ($msg) p_para($msg); +if ($g) { + $st = json_decode($g['state'], true); + if ($st['log']) p_para('Previous: ' . implode(', ', array_slice($st['log'], -8))); + p_form('/games/guess.php', [ + ['name' => 'guess', 'label' => 'Your guess (1-100)', 'maxlength' => 3, + 'format' => '*N'], + ], 'Guess'); +} else { + if (!$done) p_para('Guess the secret number between 1 and 100. Fewer tries = more points.'); +} +p_form('/games/guess.php', [ + ['name' => 'act', 'type' => 'hidden', 'value' => 'new'], +], $g ? 'Restart' : 'New game'); +p_links([['/games/scores.php', 'High scores', ['g' => 'guess']], ['/games.php', 'Games']]); +page_end(); diff --git a/public_html/games/mplib.php b/public_html/games/mplib.php new file mode 100644 index 0000000..16001f4 --- /dev/null +++ b/public_html/games/mplib.php @@ -0,0 +1,82 @@ +prepare("INSERT INTO matches (game,p1,turn,state,status,created_at,updated_at) + VALUES (?,?,1,?, 'open',?,?)") + ->execute([$game, $uid, $state, $now, $now]); + return (int)db()->lastInsertId(); +} + +function mp_join(string $game, int $mid, int $uid): bool { + $s = db()->prepare("SELECT * FROM matches WHERE id=? AND game=?"); + $s->execute([$mid, $game]); + $m = $s->fetch(); + if (!$m || $m['status'] !== 'open' || (int)$m['p1'] === $uid) return false; + db()->prepare("UPDATE matches SET p2=?, status='playing', updated_at=? WHERE id=?") + ->execute([$uid, time(), $mid]); + return true; +} + +function mp_get(string $game, int $mid): ?array { + $s = db()->prepare("SELECT m.*, a.username AS n1, b.username AS n2 + FROM matches m + LEFT JOIN users a ON a.id=m.p1 + LEFT JOIN users b ON b.id=m.p2 + WHERE m.id=? AND m.game=?"); + $s->execute([$mid, $game]); + return $s->fetch() ?: null; +} + +function mp_save(int $mid, string $state, int $turn, string $status = 'playing', ?int $winner = null): void { + db()->prepare("UPDATE matches SET state=?, turn=?, status=?, winner=?, updated_at=? WHERE id=?") + ->execute([$state, $turn, $status, $winner, time(), $mid]); +} + +// Which seat is this user in? 1, 2, or 0 for spectator. +function mp_seat(array $m, int $uid): int { + if ((int)$m['p1'] === $uid) return 1; + if ((int)$m['p2'] === $uid) return 2; + return 0; +} + +// Render the lobby: open games to join, your active games, and a create button. +function mp_lobby(string $game, string $self, int $uid, string $newState): void { + $d = db(); + $s = $d->prepare("SELECT m.*, a.username AS n1 FROM matches m + LEFT JOIN users a ON a.id=m.p1 + WHERE m.game=? AND m.status='open' AND m.p1<>? + ORDER BY m.id DESC LIMIT 10"); + $s->execute([$game, $uid]); + $open = $s->fetchAll(); + + $s = $d->prepare("SELECT m.*, a.username AS n1, b.username AS n2 FROM matches m + LEFT JOIN users a ON a.id=m.p1 + LEFT JOIN users b ON b.id=m.p2 + WHERE m.game=? AND (m.p1=? OR m.p2=?) AND m.status IN ('open','playing') + ORDER BY m.updated_at DESC LIMIT 10"); + $s->execute([$game, $uid, $uid]); + $mine = $s->fetchAll(); + + if ($mine) { + p_para('Your games:'); + foreach ($mine as $m) { + $opp = ((int)$m['p1'] === $uid) ? ($m['n2'] ?? 'waiting...') : ($m['n1'] ?? '?'); + $yourTurn = ($m['status'] === 'playing' && mp_seat($m, $uid) === (int)$m['turn']); + p_link($self, '#' . $m['id'] . ' vs ' . $opp . ($yourTurn ? ' - YOUR TURN' : ''), + ['g' => $m['id']]); + } + } + if ($open) { + p_para('Open games to join:'); + foreach ($open as $m) { + p_link($self, 'Join #' . $m['id'] . ' by ' . ($m['n1'] ?? '?'), ['g' => $m['id'], 'join' => 1]); + } + } + if (!$mine && !$open) p_para('No games yet - create one and wait for an opponent.'); + p_form($self, [ + ['name' => 'act', 'type' => 'hidden', 'value' => 'create'], + ], 'Create new game'); +} diff --git a/public_html/games/nim.php b/public_html/games/nim.php new file mode 100644 index 0000000..d5801ad --- /dev/null +++ b/public_html/games/nim.php @@ -0,0 +1,95 @@ + 21])); + redirect($SELF, ['g' => $mid]); + } + if ($act === 'take') { + $mid = (int)($_POST['mid'] ?? 0); + $m = mp_get('nim', $mid); + $seat = $m ? mp_seat($m, $uid) : 0; + if (!$m || $m['status'] !== 'playing') $msg = 'Game is not in play.'; + elseif ($seat === 0) $msg = 'You are not in this game.'; + elseif ($seat !== (int)$m['turn']) $msg = 'Not your turn.'; + else { + $st = json_decode($m['state'], true); + $n = (int)($_POST['n'] ?? 0); + if ($n < 1 || $n > 3 || $n > (int)$st['sticks']) { + $msg = 'Take 1 to 3 sticks (and no more than remain).'; + } else { + $st['sticks'] -= $n; + if ($st['sticks'] <= 0) { + // taker of the last stick loses -> the other seat wins + $winnerId = ($seat === 1) ? (int)$m['p2'] : (int)$m['p1']; + mp_save($mid, json_encode($st), $seat, 'done', $winnerId); + db()->prepare("INSERT INTO scores (game,user_id,score,detail,created_at) + VALUES ('nim',?,?,?,?)") + ->execute([$winnerId, 40, 'win', time()]); + $msg = 'You took the last stick - you lose!'; + } else { + mp_save($mid, json_encode($st), $seat === 1 ? 2 : 1); + $msg = 'You took ' . $n . '. ' . $st['sticks'] . ' left.'; + } + } + } + } +} + +if ($mid && !empty($_GET['join'])) { + $msg = mp_join('nim', $mid, $uid) ? 'You joined the game.' : 'Could not join that game.'; +} + +page_start('Nim - 21 sticks', ['back' => '/games.php']); +if ($msg) p_para($msg); + +if (!$mid) { + p_para('21 sticks. Players alternate taking 1, 2 or 3. Take the LAST stick and you lose.'); + mp_lobby('nim', $SELF, $uid, json_encode(['sticks' => 21])); + p_links([['/games.php', 'Games']]); + page_end(); + exit; +} + +$m = mp_get('nim', $mid); +if (!$m) bail('Game', 'No such game.', $SELF); +$st = json_decode($m['state'], true); +$seat = mp_seat($m, $uid); +$left = (int)$st['sticks']; + +p_para('#' . $m['id'] . ': ' . ($m['n1'] ?? '?') . ' vs ' . ($m['n2'] ?? 'waiting')); +p_para('Sticks left: ' . $left); +if ($left > 0) p_para(str_repeat('|', min($left, 21))); +p_rule(); + +if ($m['status'] === 'open') { + p_para('Waiting for an opponent to join.'); +} elseif ($m['status'] === 'done') { + $wn = ((int)$m['winner'] === (int)$m['p1']) ? $m['n1'] : $m['n2']; + p_para('Winner: ' . ($wn ?? '?')); +} elseif ($seat === 0) { + p_para('Spectating. Turn: player ' . $m['turn']); +} elseif ($seat === (int)$m['turn']) { + p_para('Your turn. Take how many?'); + p_form($SELF . '?g=' . $mid, [ + ['name' => 'act', 'type' => 'hidden', 'value' => 'take'], + ['name' => 'mid', 'type' => 'hidden', 'value' => (string)$mid], + ['name' => 'n', 'label' => 'Take', 'type' => 'select', + 'options' => ['1' => '1 stick', '2' => '2 sticks', '3' => '3 sticks']], + ], 'Take'); +} else { + p_para('Opponent to move.'); +} + +p_links([[$SELF, 'Refresh', ['g' => $mid]], [$SELF, 'Lobby'], ['/games.php', 'Games']]); +page_end(); diff --git a/public_html/games/quiz.php b/public_html/games/quiz.php new file mode 100644 index 0000000..3e6f3a7 --- /dev/null +++ b/public_html/games/quiz.php @@ -0,0 +1,82 @@ +prepare("SELECT * FROM sp_games WHERE user_id=? AND game='quiz' + AND status='active' ORDER BY id DESC LIMIT 1"); + $s->execute([$uid]); + return $s->fetch() ?: null; +} + +$msg = ''; +$g = quiz_load((int)$me['id']); + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + require_csrf(); + $act = $_POST['act'] ?? ''; + if ($act === 'new' || !$g) { + if ($g) db()->prepare("UPDATE sp_games SET status='abandoned' WHERE id=?")->execute([$g['id']]); + $keys = array_keys($QUESTIONS); + shuffle($keys); + $keys = array_slice($keys, 0, 5); + $st = json_encode(['q' => $keys, 'i' => 0, 'right' => 0]); + $now = time(); + db()->prepare("INSERT INTO sp_games (game,user_id,state,created_at,updated_at) + VALUES ('quiz',?,?,?,?)")->execute([$me['id'], $st, $now, $now]); + $g = quiz_load((int)$me['id']); + $msg = 'New quiz: 5 questions.'; + } elseif ($act === 'ans') { + $st = json_decode($g['state'], true); + $qi = $QUESTIONS[$st['q'][$st['i']]]; + $pick = (int)($_POST['a'] ?? -1); + if ($pick === (int)$qi[2]) { $st['right']++; $msg = 'Correct!'; } + else $msg = 'Wrong - it was: ' . $qi[1][$qi[2]]; + $st['i']++; + if ($st['i'] >= count($st['q'])) { + $score = $st['right'] * 20; + db()->prepare("UPDATE sp_games SET status='done', state=?, updated_at=? WHERE id=?") + ->execute([json_encode($st), time(), $g['id']]); + db()->prepare("INSERT INTO scores (game,user_id,score,detail,created_at) + VALUES ('quiz',?,?,?,?)") + ->execute([$me['id'], $score, $st['right'] . '/5', time()]); + $msg .= ' Quiz over: ' . $st['right'] . '/5 correct, score ' . $score . '.'; + $g = null; + } else { + db()->prepare("UPDATE sp_games SET state=?, updated_at=? WHERE id=?") + ->execute([json_encode($st), time(), $g['id']]); + $g['state'] = json_encode($st); + } + } +} + +page_start('Quick Quiz', ['back' => '/games.php']); +if ($msg) p_para($msg); + +if ($g) { + $st = json_decode($g['state'], true); + $qi = $QUESTIONS[$st['q'][$st['i']]]; + p_para('Q' . ($st['i'] + 1) . '/' . count($st['q']) . ': ' . $qi[0]); + $opts = []; + foreach ($qi[1] as $k => $lab) $opts[(string)$k] = $lab; + p_form('/games/quiz.php', [ + ['name' => 'act', 'type' => 'hidden', 'value' => 'ans'], + ['name' => 'a', 'label' => 'Answer', 'type' => 'select', 'options' => $opts], + ], 'Answer'); +} else { + p_para('A short quiz about WAP and mobile history. 20 points per correct answer.'); + p_form('/games/quiz.php', [['name' => 'act', 'type' => 'hidden', 'value' => 'new']], 'Start quiz'); +} +p_links([['/games/scores.php', 'High scores', ['g' => 'quiz']], ['/games.php', 'Games']]); +page_end(); diff --git a/public_html/games/scores.php b/public_html/games/scores.php new file mode 100644 index 0000000..28c3e44 --- /dev/null +++ b/public_html/games/scores.php @@ -0,0 +1,30 @@ + 'Guess the Number', 'quiz' => 'Quick Quiz', + 'ttt' => 'Noughts & Crosses', 'nim' => 'Nim']; +$g = (string)($_GET['g'] ?? ''); + +page_start('High scores', ['back' => '/games.php']); + +if (!isset($games[$g])) { + p_para('Pick a game:'); + $links = []; + foreach ($games as $k => $n) $links[] = ['/games/scores.php', $n, ['g' => $k]]; + p_links($links); +} else { + p_para($games[$g] . ' - top 10:'); + $s = db()->prepare("SELECT u.username, MAX(s.score) sc, COUNT(*) n + FROM scores s JOIN users u ON u.id=s.user_id + WHERE s.game=? GROUP BY s.user_id + ORDER BY sc DESC LIMIT 10"); + $s->execute([$g]); + $rows = $s->fetchAll(); + if (!$rows) p_para('No scores yet.'); + $i = 1; + foreach ($rows as $r) { + p_para($i++ . '. ' . $r['username'] . ' - ' . $r['sc'] . ' (' . $r['n'] . ' plays)'); + } + p_links([['/games/scores.php', 'Other games']]); +} +p_links([['/games.php', 'Games'], ['/index.php', 'Home']]); +page_end(); diff --git a/public_html/games/ttt.php b/public_html/games/ttt.php new file mode 100644 index 0000000..56e405d --- /dev/null +++ b/public_html/games/ttt.php @@ -0,0 +1,116 @@ + array_fill(0, 9, '')])); + redirect($SELF, ['g' => $mid]); + } + if ($act === 'move') { + $mid = (int)($_POST['mid'] ?? 0); + $m = mp_get('ttt', $mid); + $seat = $m ? mp_seat($m, $uid) : 0; + if (!$m || $m['status'] !== 'playing') $msg = 'Game is not in play.'; + elseif ($seat === 0) $msg = 'You are not in this game.'; + elseif ($seat !== (int)$m['turn']) $msg = 'Not your turn.'; + else { + $st = json_decode($m['state'], true); + $cell = (int)($_POST['cell'] ?? 0) - 1; // players type 1-9 + if ($cell < 0 || $cell > 8 || $st['b'][$cell] !== '') { + $msg = 'That square is not free (pick 1-9).'; + } else { + $mark = $seat === 1 ? 'X' : 'O'; + $st['b'][$cell] = $mark; + $w = ttt_win($st['b']); + $full = !in_array('', $st['b'], true); + if ($w !== null) { + mp_save($mid, json_encode($st), $seat, 'done', $uid); + db()->prepare("INSERT INTO scores (game,user_id,score,detail,created_at) + VALUES ('ttt',?,?,?,?)")->execute([$uid, 50, 'win', time()]); + $msg = 'You win!'; + } elseif ($full) { + mp_save($mid, json_encode($st), $seat, 'done', null); + $msg = 'Draw!'; + } else { + mp_save($mid, json_encode($st), $seat === 1 ? 2 : 1); + $msg = 'Move played. Waiting for your opponent.'; + } + } + } + } +} + +if ($mid && !empty($_GET['join'])) { + if (mp_join('ttt', $mid, $uid)) $msg = 'You joined the game. Player 2 is O.'; + else $msg = 'Could not join that game.'; +} + +page_start('Noughts & Crosses', ['back' => '/games.php']); +if ($msg) p_para($msg); + +if (!$mid) { + mp_lobby('ttt', $SELF, $uid, json_encode(['b' => array_fill(0, 9, '')])); + p_links([['/games.php', 'Games']]); + page_end(); + exit; +} + +$m = mp_get('ttt', $mid); +if (!$m) bail('Game', 'No such game.', $SELF); +$st = json_decode($m['state'], true); +$b = $st['b']; +$seat = mp_seat($m, $uid); + +p_para('#' . $m['id'] . ': X=' . ($m['n1'] ?? '?') . ' vs O=' . ($m['n2'] ?? 'waiting')); + +// board: 3 rows, free cells show their number so WAP users know what to type +for ($r = 0; $r < 3; $r++) { + $cells = []; + for ($c = 0; $c < 3; $c++) { + $i = $r * 3 + $c; + $cells[] = $b[$i] === '' ? (string)($i + 1) : $b[$i]; + } + p_para(implode(' | ', $cells)); +} +p_rule(); + +if ($m['status'] === 'open') { + p_para('Waiting for an opponent to join. Tell a friend to open Games > Noughts & Crosses.'); +} elseif ($m['status'] === 'done') { + if ($m['winner'] === null) p_para('Result: draw.'); + else { + $wn = ((int)$m['winner'] === (int)$m['p1']) ? $m['n1'] : $m['n2']; + p_para('Winner: ' . $wn); + } +} elseif ($seat === 0) { + p_para('You are watching. Turn: player ' . $m['turn']); +} elseif ($seat === (int)$m['turn']) { + p_para('Your turn (' . ($seat === 1 ? 'X' : 'O') . '). Enter a free square number.'); + p_form($SELF . '?g=' . $mid, [ + ['name' => 'act', 'type' => 'hidden', 'value' => 'move'], + ['name' => 'mid', 'type' => 'hidden', 'value' => (string)$mid], + ['name' => 'cell', 'label' => 'Square (1-9)', 'maxlength' => 1, 'format' => 'N'], + ], 'Play'); +} else { + p_para('Opponent to move. Check back shortly.'); +} + +p_links([[$SELF, 'Refresh', ['g' => $mid]], [$SELF, 'Lobby'], ['/games.php', 'Games']]); +page_end(); diff --git a/public_html/google-verify-PLACEHOLDER.html b/public_html/google-verify-PLACEHOLDER.html new file mode 100644 index 0000000..70c5845 --- /dev/null +++ b/public_html/google-verify-PLACEHOLDER.html @@ -0,0 +1 @@ +google-site-verification: REPLACE_WITH_YOUR_GOOGLE_VERIFICATION_CODE diff --git a/public_html/inbox.php b/public_html/inbox.php new file mode 100644 index 0000000..0e13f2a --- /dev/null +++ b/public_html/inbox.php @@ -0,0 +1,46 @@ +prepare("DELETE FROM messages WHERE id=? AND (to_id=? OR from_id=?)") + ->execute([$mid, $me['id'], $me['id']]); + redirect('/inbox.php', ['box' => $box]); +} + +if ($box === 'in') { + $sql = "SELECT m.*, u.username AS who FROM messages m + LEFT JOIN users u ON u.id=m.from_id + WHERE m.to_id=? ORDER BY m.id DESC LIMIT ? OFFSET ?"; +} else { + $sql = "SELECT m.*, u.username AS who FROM messages m + LEFT JOIN users u ON u.id=m.to_id + WHERE m.from_id=? ORDER BY m.id DESC LIMIT ? OFFSET ?"; +} +$s = db()->prepare($sql); +$s->execute([$me['id'], PER_PAGE + 1, $off]); +$rows = $s->fetchAll(); +$more = count($rows) > PER_PAGE; +if ($more) array_pop($rows); + +page_start($box === 'in' ? 'Inbox' : 'Sent', ['back' => '/index.php']); +if (!$rows) p_para($box === 'in' ? 'No messages.' : 'Nothing sent yet.'); +foreach ($rows as $r) { + $flag = ($box === 'in' && !$r['is_read']) ? '* ' : ''; + $lab = $flag . ($r['who'] ?? '[deleted]') . ': ' . $r['subject'] . ' (' . ago((int)$r['created_at']) . ')'; + p_link('/message.php', $lab, ['id' => $r['id']]); +} +p_pager('/inbox.php', $page, $more, ['box' => $box]); +p_rule(); +p_links([ + ['/compose.php', 'New message'], + ['/inbox.php', $box === 'in' ? 'Sent items' : 'Inbox', ['box' => $box === 'in' ? 'out' : 'in']], + ['/index.php', 'Home'], +]); +page_end(); diff --git a/public_html/index.html b/public_html/index.html new file mode 100644 index 0000000..7633e70 --- /dev/null +++ b/public_html/index.html @@ -0,0 +1,136 @@ + + + + + wap.txt3.net — Domain welcome page for + + + + + + + + + + + + + + + +
+
+
+
+
+
+ +
+

+ Website Enabled

+

+ This page indicates a newly created website is working. This welcome page is automatically generated after a virtual server is setup by Virtualmin — a premier open-source hosting control panel, offering automation and comprehensive management for webhosting environments.

+
+ +
+
+
+
+
+
+

+ Login

+
+
+

+ + ⎆ + +

+
+ + If you are the website owner, this welcome page appears because no website has been set up on this virtual server yet. To modify this page and upload your website, log in to Virtualmin and place your files in the public_html directory. +
+ + Login to Virtualmin +
+
+
+
+ + +
+ + \ No newline at end of file diff --git a/public_html/index.php b/public_html/index.php new file mode 100644 index 0000000..8b27a73 --- /dev/null +++ b/public_html/index.php @@ -0,0 +1,41 @@ +query("SELECT COUNT(*) FROM users")->fetchColumn(); +$pc = (int)$d->query("SELECT COUNT(*) FROM posts")->fetchColumn(); +p_rule(); +p_para("$uc members, $pc posts."); +p_para('Also on telnet: wap.txt3.net port 12300 (tailscale).'); +if (!is_wml()) { + p_para('Markup mode: ' . mode() . ' (WML clients are auto-detected).', 'small'); +} +page_end(); diff --git a/public_html/lib/auth.php b/public_html/lib/auth.php new file mode 100644 index 0000000..daa06d2 --- /dev/null +++ b/public_html/lib/auth.php @@ -0,0 +1,117 @@ + accept the sid from the query string. + ini_set('session.use_only_cookies', '0'); + ini_set('session.use_trans_sid', '0'); + session_name('WAPSID'); + if (!empty($_REQUEST['WAPSID']) && preg_match('/^[A-Za-z0-9,\-]{8,64}$/', $_REQUEST['WAPSID'])) { + session_id($_REQUEST['WAPSID']); + } + session_start(); +} + +function csrf_token(): string { + if (empty($_SESSION['csrf'])) $_SESSION['csrf'] = bin2hex(random_bytes(16)); + return $_SESSION['csrf']; +} + +function csrf_ok(): bool { + $t = $_POST['csrf'] ?? $_GET['csrf'] ?? ''; + return $t !== '' && !empty($_SESSION['csrf']) && hash_equals($_SESSION['csrf'], $t); +} + +function require_csrf(): void { + if (!csrf_ok()) bail('Error', 'Session expired or bad token. Please try again.'); +} + +function current_user(): ?array { + static $cache = null; + static $done = false; + if ($done) return $cache; + $done = true; + if (empty($_SESSION['uid'])) return $cache = null; + $s = db()->prepare("SELECT * FROM users WHERE id=?"); + $s->execute([$_SESSION['uid']]); + $u = $s->fetch(); + if (!$u || $u['is_banned']) { $_SESSION['uid'] = null; return $cache = null; } + // throttle last_seen writes to once a minute + if (time() - (int)$u['last_seen'] > 60) { + db()->prepare("UPDATE users SET last_seen=? WHERE id=?")->execute([time(), $u['id']]); + } + return $cache = $u; +} + +function require_login(): array { + $u = current_user(); + if (!$u) { + page_start('Login needed', ['back' => '/index.php']); + p_para('You must log in to use this feature.'); + p_links([['/login.php', 'Login'], ['/signup.php', 'Sign up'], ['/index.php', 'Home']]); + page_end(); + exit; + } + return $u; +} + +function require_admin(): array { + $u = require_login(); + if (!$u['is_admin']) bail('Denied', 'Admin only.'); + return $u; +} + +function user_login(string $name, string $pass): ?array { + $s = db()->prepare("SELECT * FROM users WHERE username=?"); + $s->execute([$name]); + $u = $s->fetch(); + if (!$u || !password_verify($pass, $u['pass_hash'])) return null; + if ($u['is_banned']) return null; + $_SESSION['uid'] = (int)$u['id']; + return $u; +} + +function user_create(string $name, string $pass, int $admin = 0): int { + $s = db()->prepare("INSERT INTO users (username,pass_hash,is_admin,created_at) + VALUES (?,?,?,?)"); + $s->execute([$name, password_hash($pass, PASSWORD_DEFAULT), $admin, time()]); + return (int)db()->lastInsertId(); +} + +function username_taken(string $n): bool { + $s = db()->prepare("SELECT 1 FROM users WHERE username=?"); + $s->execute([$n]); + return (bool)$s->fetchColumn(); +} + +function valid_username(string $n): bool { + return (bool)preg_match('/^[A-Za-z0-9_]{3,16}$/', $n); +} + +function user_by_id(int $id): ?array { + $s = db()->prepare("SELECT * FROM users WHERE id=?"); + $s->execute([$id]); + return $s->fetch() ?: null; +} + +function user_by_name(string $n): ?array { + $s = db()->prepare("SELECT * FROM users WHERE username=?"); + $s->execute([$n]); + return $s->fetch() ?: null; +} + +function unread_count(int $uid): int { + $s = db()->prepare("SELECT COUNT(*) FROM messages WHERE to_id=? AND is_read=0"); + $s->execute([$uid]); + return (int)$s->fetchColumn(); +} + +function ago(int $ts): string { + $d = max(0, time() - $ts); + if ($d < 60) return $d . 's'; + if ($d < 3600) return floor($d / 60) . 'm'; + if ($d < 86400) return floor($d / 3600) . 'h'; + if ($d < 2592000) return floor($d / 86400) . 'd'; + return date('d/m/y', $ts); +} diff --git a/public_html/lib/bootstrap.php b/public_html/lib/bootstrap.php new file mode 100644 index 0000000..e35ccdc --- /dev/null +++ b/public_html/lib/bootstrap.php @@ -0,0 +1,14 @@ +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); + $pdo->exec('PRAGMA journal_mode = WAL'); + $pdo->exec('PRAGMA foreign_keys = ON'); + $pdo->exec('PRAGMA busy_timeout = 5000'); + + db_migrate($pdo); + if ($fresh) db_seed($pdo); + db_seed_mud($pdo); + return $pdo; +} + +function db_migrate(PDO $p): void { + $p->exec("CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE COLLATE NOCASE, + pass_hash TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + is_banned INTEGER NOT NULL DEFAULT 0, + tagline TEXT DEFAULT '', + location TEXT DEFAULT '', + created_at INTEGER NOT NULL, + last_seen INTEGER NOT NULL DEFAULT 0 + )"); + + $p->exec("CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + from_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + to_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + subject TEXT NOT NULL, + body TEXT NOT NULL, + is_read INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL + )"); + $p->exec("CREATE INDEX IF NOT EXISTS idx_msg_to ON messages(to_id, id DESC)"); + + $p->exec("CREATE TABLE IF NOT EXISTS forums ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + descr TEXT DEFAULT '', + sort_order INTEGER NOT NULL DEFAULT 0, + is_locked INTEGER NOT NULL DEFAULT 0 + )"); + + $p->exec("CREATE TABLE IF NOT EXISTS topics ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + forum_id INTEGER NOT NULL REFERENCES forums(id) ON DELETE CASCADE, + user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + title TEXT NOT NULL, + is_locked INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + bumped_at INTEGER NOT NULL + )"); + $p->exec("CREATE INDEX IF NOT EXISTS idx_topic_forum ON topics(forum_id, bumped_at DESC)"); + + $p->exec("CREATE TABLE IF NOT EXISTS posts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + topic_id INTEGER NOT NULL REFERENCES topics(id) ON DELETE CASCADE, + user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + body TEXT NOT NULL, + created_at INTEGER NOT NULL + )"); + $p->exec("CREATE INDEX IF NOT EXISTS idx_post_topic ON posts(topic_id, id)"); + + // ---- games ---- + // single player high scores + $p->exec("CREATE TABLE IF NOT EXISTS scores ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + game TEXT NOT NULL, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + score INTEGER NOT NULL, + detail TEXT DEFAULT '', + created_at INTEGER NOT NULL + )"); + $p->exec("CREATE INDEX IF NOT EXISTS idx_score_game ON scores(game, score DESC)"); + + // generic multiplayer match table (tictactoe, nim) + $p->exec("CREATE TABLE IF NOT EXISTS matches ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + game TEXT NOT NULL, + p1 INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + p2 INTEGER REFERENCES users(id) ON DELETE CASCADE, + turn INTEGER NOT NULL DEFAULT 1, + state TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'open', + winner INTEGER DEFAULT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + )"); + $p->exec("CREATE INDEX IF NOT EXISTS idx_match_status ON matches(game, status)"); + + // single-player session state (guess the number) + $p->exec("CREATE TABLE IF NOT EXISTS sp_games ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + game TEXT NOT NULL, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + state TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + )"); + + // ---- MUD world ---- + $p->exec("CREATE TABLE IF NOT EXISTS mud_rooms ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + descr TEXT NOT NULL, + exits TEXT NOT NULL DEFAULT '{}', -- JSON dir -> room_id + safe INTEGER NOT NULL DEFAULT 0 + )"); + $p->exec("CREATE TABLE IF NOT EXISTS mud_mobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + room_id INTEGER NOT NULL, + key_name TEXT NOT NULL, -- keyword for 'kill ' + name TEXT NOT NULL, + descr TEXT NOT NULL, + hp INTEGER NOT NULL, + atk INTEGER NOT NULL, + def INTEGER NOT NULL, + xp INTEGER NOT NULL, + gold INTEGER NOT NULL DEFAULT 0, + loot TEXT NOT NULL DEFAULT '[]', -- JSON [item_key,...] + respawn INTEGER NOT NULL DEFAULT 30 + )"); + $p->exec("CREATE TABLE IF NOT EXISTS mud_spawn ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + mob_id INTEGER NOT NULL, + room_id INTEGER NOT NULL, + hp INTEGER NOT NULL, + alive INTEGER NOT NULL DEFAULT 1, + next_respawn INTEGER NOT NULL DEFAULT 0 + )"); + $p->exec("CREATE INDEX IF NOT EXISTS idx_mudspawn_room ON mud_spawn(room_id, alive)"); + $p->exec("CREATE TABLE IF NOT EXISTS mud_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key_name TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + descr TEXT NOT NULL, + slot TEXT, -- weapon|armor|potion|misc + atk INTEGER NOT NULL DEFAULT 0, + def INTEGER NOT NULL DEFAULT 0, + heal INTEGER NOT NULL DEFAULT 0, + gold INTEGER NOT NULL DEFAULT 0, + price INTEGER NOT NULL DEFAULT 0 -- shop price (0 = not sold) + )"); + $p->exec("CREATE TABLE IF NOT EXISTS mud_ground ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + room_id INTEGER NOT NULL, + item_id INTEGER NOT NULL + )"); + // de-dupe any pre-existing duplicate ground rows before the unique index + $p->exec("DELETE FROM mud_ground WHERE id NOT IN ( + SELECT MIN(id) FROM mud_ground GROUP BY room_id, item_id)"); + $p->exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_mudground_room_item ON mud_ground(room_id, item_id)"); + $p->exec("CREATE TABLE IF NOT EXISTS mud_chars ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + class TEXT NOT NULL DEFAULT 'fighter', + room_id INTEGER NOT NULL DEFAULT 1, + hp INTEGER NOT NULL, + max_hp INTEGER NOT NULL, + atk INTEGER NOT NULL, + def INTEGER NOT NULL, + xp INTEGER NOT NULL DEFAULT 0, + level INTEGER NOT NULL DEFAULT 1, + gold INTEGER NOT NULL DEFAULT 0, + bank INTEGER NOT NULL DEFAULT 0, + bounty INTEGER NOT NULL DEFAULT 0, + weapon TEXT DEFAULT NULL, + armor TEXT DEFAULT NULL, + inv TEXT NOT NULL DEFAULT '[]', -- JSON [item_key,...] + kills INTEGER NOT NULL DEFAULT 0, + deaths INTEGER NOT NULL DEFAULT 0, + last_cmd_at INTEGER NOT NULL DEFAULT 0 + )"); + $p->exec("CREATE TABLE IF NOT EXISTS mud_bounties ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + target_id INTEGER NOT NULL, + by_id INTEGER NOT NULL, + amount INTEGER NOT NULL, + ts INTEGER NOT NULL + )"); + $p->exec("CREATE TABLE IF NOT EXISTS mud_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + room_id INTEGER NOT NULL, + ts INTEGER NOT NULL, + text TEXT NOT NULL + )"); + $p->exec("CREATE INDEX IF NOT EXISTS idx_mudev_room ON mud_events(room_id, id DESC)"); + + $p->exec("CREATE TABLE IF NOT EXISTS settings ( + k TEXT PRIMARY KEY, + v TEXT NOT NULL + )"); +} + +function db_seed_mud(PDO $p): void { + // --- migrate schema on an existing install (columns/tables may be new) --- + $cols = []; + foreach ($p->query("PRAGMA table_info(mud_chars)") as $row) $cols[$row['name']] = 1; + $add = [ + 'class' => "ALTER TABLE mud_chars ADD COLUMN class TEXT NOT NULL DEFAULT 'fighter'", + 'bank' => "ALTER TABLE mud_chars ADD COLUMN bank INTEGER NOT NULL DEFAULT 0", + 'bounty' => "ALTER TABLE mud_chars ADD COLUMN bounty INTEGER NOT NULL DEFAULT 0", + 'kills' => "ALTER TABLE mud_chars ADD COLUMN kills INTEGER NOT NULL DEFAULT 0", + 'deaths' => "ALTER TABLE mud_chars ADD COLUMN deaths INTEGER NOT NULL DEFAULT 0", + ]; + foreach ($add as $k => $sql) if (!isset($cols[$k])) $p->exec($sql); + $p->exec("CREATE TABLE IF NOT EXISTS mud_bounties ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + target_id INTEGER NOT NULL, by_id INTEGER NOT NULL, + amount INTEGER NOT NULL, ts INTEGER NOT NULL + )"); + // mud_items.price may be new on older installs + $icols = []; + foreach ($p->query("PRAGMA table_info(mud_items)") as $row) $icols[$row['name']] = 1; + if (!isset($icols['price'])) $p->exec("ALTER TABLE mud_items ADD COLUMN price INTEGER NOT NULL DEFAULT 0"); + + // --- build the base world only if empty --- + if ($p->query("SELECT COUNT(*) FROM mud_rooms")->fetchColumn() == 0) { + $now = time(); + $rooms = [ + 1 => ['Village Square', 'A quiet cobbled square. Travellers set out from here.', + '{"n":2,"e":5,"s":6,"w":9,"u":10,"d":11}', 1], + 2 => ['Forest Path', 'Dappled light, the smell of pine. Something rustles.', + '{"s":1,"e":3}', 0], + 3 => ['Dark Cave', 'Dripping walls and the flap of leathery wings.', + '{"w":2,"d":4}', 0], + 4 => ['Cave Depths', 'A vast chamber lit by glowing moss; something large lurks.', + '{"u":3,"e":8}', 0], + 5 => ['River Bank', 'A clear stream teeming with silver fish.', + '{"w":1,"n":7}', 0], + 6 => ['Old Ruins', 'Toppled columns and a broken altar.', + '{"n":1,"e":7}', 0], + 7 => ['Ruins Altar', 'Cold air; a pale shape drifts between the stones.', + '{"w":6,"s":5}', 0], + 8 => ['Mountain Pass', 'Thin air and a hulking figure blocks the path.', + '{"w":4}', 0], + 9 => ['Trader\'s Shop', 'Shelves of blades and brews. "Buy or begone," grunts the trader.', + '{"e":1}', 1], + 10 => ['The Vault', 'A thick-doored bank. Coins are safer here than on your person.', + '{"d":1}', 1], + 11 => ['The Inn', 'Sir Joe Mollicone, the Taxman, watches the door. "Pay up, adventurer."', + '{"u":1}', 1], + ]; + $ri = $p->prepare("INSERT INTO mud_rooms (id,name,descr,exits,safe) VALUES (?,?,?,?,?)"); + foreach ($rooms as $id => $r) $ri->execute([$id, $r[0], $r[1], $r[2], $r[3]]); + + $mobs = [ + [2,'wolf','a Wolf','Mangy but quick.',18,5,2,8,3,'[]',40], + [3,'bat','a Cave Bat','A squeaking blur.',12,4,1,5,2,'[]',30], + [3,'goblin','a Goblin','Sharp teeth, sharper knife.',26,6,3,14,6,'["rusty_sword"]',45], + [4,'orc','an Orc','Scarred and brutal.',46,9,5,40,15,'["leather_armor","gold"]',60], + [5,'fish','a River Fish','Slippery and startled.',8,2,1,3,1,'[]',20], + [6,'skeleton','a Skeleton','Rattling bones, rusted blade.',30,7,4,20,8,'[]',45], + [7,'ghost','a Ghost','Cold, wailing, half-seen.',38,8,3,30,12,'["healing_potion"]',45], + [8,'troll','a Troll','Twice your height and thick as a tree.',70,12,8,80,30,'["rusty_sword","leather_armor","gold"]',90], + ]; + $mi = $p->prepare("INSERT INTO mud_mobs (room_id,key_name,name,descr,hp,atk,def,xp,gold,loot,respawn) + VALUES (?,?,?,?,?,?,?,?,?,?,?)"); + $si = $p->prepare("INSERT INTO mud_spawn (mob_id,room_id,hp,alive,next_respawn) VALUES (?,?,?,1,0)"); + foreach ($mobs as $m) { + $mi->execute($m); + $mid = $p->lastInsertId(); + $si->execute([$mid, $m[0], $m[4]]); + } + } else { + // existing world: make sure the shop/bank/taxman rooms and the village + // links to them exist (idempotent). + $new_rooms = [ + 9 => ['Trader\'s Shop', 'Shelves of blades and brews. "Buy or begone," grunts the trader.', '{"e":1}', 1], + 10 => ['The Vault', 'A thick-doored bank. Coins are safer here than on your person.', '{"d":1}', 1], + 11 => ['The Inn', 'Sir Joe Mollicone, the Taxman, watches the door. "Pay up, adventurer."', '{"u":1}', 1], + ]; + $ri = $p->prepare("INSERT OR IGNORE INTO mud_rooms (id,name,descr,exits,safe) VALUES (?,?,?,?,?)"); + foreach ($new_rooms as $id => $r) $ri->execute([$id, $r[0], $r[1], $r[2], $r[3]]); + // extend the village (room 1) exits to include w/u/d if not present + $v = $p->query("SELECT exits FROM mud_rooms WHERE id=1")->fetchColumn(); + $vx = json_decode($v, true); + $vx['w'] = 9; $vx['u'] = 10; $vx['d'] = 11; + $p->prepare("UPDATE mud_rooms SET exits=? WHERE id=1")->execute([json_encode($vx)]); + } + + // --- shop stock (idempotent by key_name) --- + $items = [ + ['rusty_sword','Rusty Sword','A chipped but serviceable blade.','weapon',4,0,0,0,20], + ['leather_armor','Leather Armor','Cracked but protective.','armor',0,3,0,0,25], + ['healing_potion','Healing Potion','Bitter herbs that mend wounds.','potion',0,0,25,0,15], + ['steel_sword','Steel Sword','A keen edge for the serious.','weapon',8,0,0,0,60], + ['plate_armor','Plate Armor','Heavy and reassuring.','armor',0,7,0,0,70], + ['gold','Gold Coins','A small pile of coin.','misc',0,0,0,10,0], + ['torch','Torch','Flickering light against the dark.','misc',1,0,0,0,5], + ]; + $ii = $p->prepare("INSERT OR IGNORE INTO mud_items (key_name,name,descr,slot,atk,def,heal,gold,price) + VALUES (?,?,?,?,?,?,?,?,?)"); + foreach ($items as $it) $ii->execute($it); + + // a couple of ground items to find + $g1 = $p->prepare("INSERT OR IGNORE INTO mud_ground (room_id,item_id) + SELECT 1, id FROM mud_items WHERE key_name='healing_potion'"); + $g1->execute(); + $g1 = $p->prepare("INSERT OR IGNORE INTO mud_ground (room_id,item_id) + SELECT 3, id FROM mud_items WHERE key_name='torch'"); + $g1->execute(); +} + +function db_seed(PDO $p): void { + $now = time(); + $p->prepare("INSERT INTO users (username,pass_hash,is_admin,tagline,created_at) + VALUES (?,?,1,?,?)") + ->execute([BOOTSTRAP_ADMIN, password_hash(BOOTSTRAP_ADMIN_PW, PASSWORD_DEFAULT), + 'site admin', $now]); + $f = $p->prepare("INSERT INTO forums (name,descr,sort_order) VALUES (?,?,?)"); + $f->execute(['General', 'Anything goes', 1]); + $f->execute(['Mobile', 'Phones, WAP, retro kit', 2]); + $f->execute(['Games', 'Talk about the site games', 3]); + $p->prepare("INSERT INTO settings (k,v) VALUES ('motd',?)") + ->execute(['Welcome to ' . SITE_NAME . '!']); +} + +function setting(string $k, string $default = ''): string { + $r = db()->prepare("SELECT v FROM settings WHERE k=?"); + $r->execute([$k]); + $v = $r->fetchColumn(); + return $v === false ? $default : (string)$v; +} + +function setting_set(string $k, string $v): void { + db()->prepare("INSERT INTO settings (k,v) VALUES (?,?) + ON CONFLICT(k) DO UPDATE SET v=excluded.v")->execute([$k, $v]); +} diff --git a/public_html/lib/mud.php b/public_html/lib/mud.php new file mode 100644 index 0000000..3a86c30 --- /dev/null +++ b/public_html/lib/mud.php @@ -0,0 +1,305 @@ + ['name' => 'Fighter', 'bonus_atk' => 2, 'bonus_def' => 0, 'bonus_hp' => 6], + 'mage' => ['name' => 'Mage', 'bonus_atk' => 4, 'bonus_def' => 0, 'bonus_hp' => 0], + 'thief' => ['name' => 'Thief', 'bonus_atk' => 1, 'bonus_def' => 2, 'bonus_hp' => 2], +]; + +function mud_class_bonus(string $class): array { + global $MUD_CLASSES; + return $MUD_CLASSES[$class] ?? $MUD_CLASSES['fighter']; +} + +function mud_echo($msg, int $room_id, $pdo = null): void { + $pdo = $pdo ?: db(); + $pdo->prepare("INSERT INTO mud_events (room_id,ts,text) VALUES (?,?,?)") + ->execute([$room_id, time(), $msg]); +} + +function mud_item($key) { + $st = db()->prepare("SELECT * FROM mud_items WHERE key_name=?"); + $st->execute([$key]); + return $st->fetch(PDO::FETCH_ASSOC); +} + +function mud_char_for(int $uid) { + $st = db()->prepare("SELECT c.* FROM mud_chars c WHERE user_id=?"); + $st->execute([$uid]); + return $st->fetch(PDO::FETCH_ASSOC); +} + +function mud_room(int $id) { + $st = db()->prepare("SELECT * FROM mud_rooms WHERE id=?"); + $st->execute([$id]); + $r = $st->fetch(PDO::FETCH_ASSOC); + if ($r) $r['exits'] = json_decode($r['exits'], true); + return $r; +} + +// build a fresh character from a user row +function mud_create_char(array $user, string $name, string $class = 'fighter'): array { + $pdo = db(); + $class = array_key_exists($class, $GLOBALS['MUD_CLASSES']) ? $class : 'fighter'; + $b = mud_class_bonus($class); + $maxhp = MUD_START_HP + $b['bonus_hp']; + $pdo->prepare("INSERT INTO mud_chars (user_id,name,class,room_id,hp,max_hp,atk,def,xp,level,gold) + VALUES (?,?,?,1,?,?,?,?,0,1,0)") + ->execute([$user['id'], $name, $class, $maxhp, $maxhp, MUD_START_ATK + $b['bonus_atk'], MUD_START_DEF + $b['bonus_def']]); + mud_echo("{$name} the {$b['name']} arrives in the world.", 1); + return mud_char_for($user['id']); +} + +function mud_alive_spawns(int $room_id) { + $st = db()->prepare("SELECT s.*, m.key_name, m.name, m.descr FROM mud_spawn s + JOIN mud_mobs m ON m.id=s.mob_id + WHERE s.room_id=? AND s.alive=1 ORDER BY s.id"); + $st->execute([$room_id]); + return $st->fetchAll(PDO::FETCH_ASSOC); +} + +function mud_ground_items(int $room_id) { + $st = db()->prepare("SELECT g.id, i.key_name, i.name, i.descr FROM mud_ground g + JOIN mud_items i ON i.id=g.item_id WHERE g.room_id=? ORDER BY g.id"); + $st->execute([$room_id]); + return $st->fetchAll(PDO::FETCH_ASSOC); +} + +function mud_respawn(PDO $pdo = null): void { + $pdo = $pdo ?: db(); + $now = time(); + $st = $pdo->prepare("SELECT s.id, s.mob_id, m.hp FROM mud_spawn s + JOIN mud_mobs m ON m.id=s.mob_id + WHERE s.alive=0 AND s.next_respawn<=?"); + $st->execute([$now]); + $dead = $st->fetchAll(PDO::FETCH_ASSOC); + foreach ($dead as $d) { + $pdo->prepare("UPDATE mud_spawn SET alive=1, hp=? WHERE id=?") + ->execute([$d['hp'], $d['id']]); + } +} + +function mud_level_check(array &$c, PDO $pdo = null): ?string { + $pdo = $pdo ?: db(); + $need = $c['level'] * 50; + if ($c['xp'] >= $need) { + $c['xp'] -= $need; + $c['level']++; + $c['max_hp'] += 8; $c['atk'] += 2; $c['def'] += 1; + $c['hp'] = $c['max_hp']; + $pdo->prepare("UPDATE mud_chars SET level=?,max_hp=?,atk=?,def=?,xp=?,hp=? WHERE id=?") + ->execute([$c['level'], $c['max_hp'], $c['atk'], $c['def'], $c['xp'], $c['hp'], $c['id']]); + return "You reached level {$c['level']}! HP/ATK/DEF increased."; + } + return null; +} + +// Resolve a single combat round. Returns array of messages. +function mud_fight(int $spawn_id, array &$c, PDO $pdo = null): array { + $pdo = $pdo ?: db(); + // NOTE: select columns explicitly so s.hp (current spawn HP) is not + // shadowed by m.hp (the mob's base HP) which would break HP tracking. + $st = $pdo->prepare( + "SELECT s.id AS id, s.hp AS hp, s.alive, s.next_respawn, + m.name, m.descr, m.atk, m.def, m.xp, m.gold, m.loot, m.respawn + FROM mud_spawn s JOIN mud_mobs m ON m.id=s.mob_id WHERE s.id=?"); + $st->execute([$spawn_id]); + $sp = $st->fetch(PDO::FETCH_ASSOC); + if (!$sp || !$sp['alive']) return ["There is nothing to fight here."]; + + $msgs = []; + // player hits + $pDmg = max(1, ($c['atk'] + ($c['weapon'] ? (mud_item($c['weapon'])['atk'] ?? 0) : 0)) - $sp['def'] + rand(-1, 1)); + $sp['hp'] -= $pDmg; + $msgs[] = "You hit {$sp['name']} for $pDmg."; + if ($sp['hp'] <= 0) { + // victory + $c['xp'] += $sp['xp']; + $c['gold']+= $sp['gold']; + $msgs[] = "{$sp['name']} dies! +{$sp['xp']} xp, +{$sp['gold']} gold."; + // loot + $loot = json_decode($sp['loot'], true); + foreach ($loot as $lk) { + $it = mud_item($lk); + if ($it) { + $c['inv'] = json_decode($c['inv'], true); $c['inv'][] = $lk; $c['inv'] = json_encode($c['inv']); + $msgs[] = "You take {$it['name']}."; + } + } + $pdo->prepare("UPDATE mud_spawn SET alive=0, next_respawn=? WHERE id=?") + ->execute([time() + $sp['respawn'], $sp['id']]); + mud_echo("{$c['name']} slew {$sp['name']}.", $c['room_id'], $pdo); + $up = $pdo->prepare("UPDATE mud_chars SET xp=?,gold=?,inv=? WHERE id=?"); + $up->execute([$c['xp'], $c['gold'], $c['inv'], $c['id']]); + $lv = mud_level_check($c, $pdo); + if ($lv) $msgs[] = $lv; + return $msgs; + } + // mob hits back + $mDmg = max(1, ($sp['atk'] - ($c['def'] + ($c['armor'] ? (mud_item($c['armor'])['def'] ?? 0) : 0))) + rand(-1, 1)); + $c['hp'] -= $mDmg; + $msgs[] = "{$sp['name']} hits you for $mDmg."; + $pdo->prepare("UPDATE mud_spawn SET hp=? WHERE id=?")->execute([$sp['hp'], $sp['id']]); + if ($c['hp'] <= 0) { + $c['hp'] = 0; + $msgs[] = "You have fallen! You wake in the Village Square."; + mud_echo("{$c['name']} was slain by {$sp['name']} and fades away.", $c['room_id'], $pdo); + // death: drop to start, lose a little gold, heal + $lost = intval($c['gold'] * 0.2); + $c['gold'] -= $lost; + $c['room_id'] = 1; $c['hp'] = $c['max_hp']; + $pdo->prepare("UPDATE mud_chars SET room_id=1,hp=?,gold=?,last_cmd_at=? WHERE id=?") + ->execute([$c['hp'], $c['gold'], time(), $c['id']]); + } else { + $pdo->prepare("UPDATE mud_chars SET hp=? WHERE id=?")->execute([$c['hp'], $c['id']]); + } + return $msgs; +} + +// ---- RPGBBS-style economy & PvP ---- + +function mud_players_in_room(int $room_id): array { + return db()->query( + "SELECT c.*, u.username AS username FROM mud_chars c + JOIN users u ON u.id=c.user_id + WHERE c.room_id=$room_id ORDER BY c.level DESC") + ->fetchAll(PDO::FETCH_ASSOC); +} + +// Find a player in the same room by a fragment of their character name or username. +function mud_find_player_in_room(int $room_id, string $frag): ?array { + $frag = strtolower($frag); + foreach (mud_players_in_room($room_id) as $p) { + if ($p['id'] == 0) continue; + if (str_contains(strtolower($p['name']), $frag) || str_contains(strtolower($p['username']), $frag)) { + return $p; + } + } + return null; +} + +function mud_shop_list(): array { + return db()->query("SELECT * FROM mud_items WHERE price>0 ORDER BY price")->fetchAll(PDO::FETCH_ASSOC); +} + +function mud_buy(array &$c, string $key): array { + $pdo = db(); + $it = mud_item($key); + if (!$it || $it['price'] <= 0) return ["The trader doesn't sell that."]; + if ($c['gold'] < $it['price']) return ["You can't afford the {$it['name']} ({$it['price']}g)."]; + $c['gold'] -= $it['price']; + $inv = json_decode($c['inv'], true); + $inv[] = $it['key_name']; + $c['inv'] = json_encode($inv); + $pdo->prepare("UPDATE mud_chars SET gold=?,inv=? WHERE id=?")->execute([$c['gold'], $c['inv'], $c['id']]); + return ["You buy {$it['name']} for {$it['price']}g."]; +} + +function mud_deposit(array &$c, int $amt): array { + $pdo = db(); + $amt = max(0, min($amt, $c['gold'])); + if ($amt <= 0) return ["You have no gold to bank."]; + $c['gold'] -= $amt; $c['bank'] += $amt; + $pdo->prepare("UPDATE mud_chars SET gold=?,bank=? WHERE id=?")->execute([$c['gold'], $c['bank'], $c['id']]); + return ["You deposit {$amt}g. Bank balance: {$c['bank']}g."]; +} + +function mud_withdraw(array &$c, int $amt): array { + $pdo = db(); + $amt = max(0, min($amt, $c['bank'])); + if ($amt <= 0) return ["Nothing to withdraw."]; + $c['bank'] -= $amt; $c['gold'] += $amt; + $pdo->prepare("UPDATE mud_chars SET gold=?,bank=? WHERE id=?")->execute([$c['gold'], $c['bank'], $c['id']]); + return ["You withdraw {$amt}g. You carry {$c['gold']}g."]; +} + +// Sir Joe Mollicone, the Taxman: takes a 10% tithe (gold sink), safe room 11. +function mud_tax(array &$c): array { + $pdo = db(); + $due = intval($c['gold'] * 0.10); + if ($due <= 0) return ["Sir Joe squints. \"Come back when ye've coins to tithe.\""]; + $c['gold'] -= $due; + $pdo->prepare("UPDATE mud_chars SET gold=? WHERE id=?")->execute([$c['gold'], $c['id']]); + return ["Sir Joe pockets {$due}g. \"That's the price of civilisation, adventurer.\""]; +} + +function mud_leaderboard(int $limit = 10): array { + return db()->query("SELECT name,class,level,gold,kills,deaths FROM mud_chars + ORDER BY level DESC, gold DESC LIMIT $limit")->fetchAll(PDO::FETCH_ASSOC); +} + +// PvP: attack another player in the same room (RPGBBS "live the game"). +function mud_pvp(array &$att, array $def, PDO $pdo = null): array { + $pdo = $pdo ?: db(); + $msgs = []; + if ($def['id'] === $att['id']) return ["You can't attack yourself."]; + if ($def['room_id'] != $att['room_id']) return ["They aren't here."]; + $aWep = $att['weapon'] ? (mud_item($att['weapon'])['atk'] ?? 0) : 0; + $dWep = $def['weapon'] ? (mud_item($def['weapon'])['atk'] ?? 0) : 0; + $aArm = $att['armor'] ? (mud_item($att['armor'])['def'] ?? 0) : 0; + $dArm = $def['armor'] ? (mud_item($def['armor'])['def'] ?? 0) : 0; + $aDmg = max(1, $att['atk'] + $aWep - $def['def'] - $dArm + rand(-1,1)); + $dDmg = max(1, $def['atk'] + $dWep - $att['def'] - $aArm + rand(-1,1)); + $att['hp'] -= $dDmg; $def['hp'] -= $aDmg; + $msgs[] = "You strike {$def['name']} for $aDmg. {$def['name']} strikes you for $dDmg."; + // attacker wins + if ($def['hp'] <= 0 && $att['hp'] > 0) { + $loot = intval($def['gold'] * 0.5); + $att['gold'] += $loot; $att['kills']++; + $def['gold'] -= $loot; $def['deaths']++; + $def['room_id'] = 1; $def['hp'] = $def['max_hp']; + $bounty = intval($def['bounty']); + if ($bounty > 0) { $att['gold'] += $bounty; $def['bounty'] = 0; + $msgs[] = "You collect the {$bounty}g bounty on {$def['name']}!"; } + $msgs[] = "You slay {$def['name']}! +{$loot}g looted" . ($bounty? ", +{$bounty}g bounty." : "."); + mud_echo("{$att['name']} cut down {$def['name']} in cold blood.", $att['room_id'], $pdo); + $pdo->prepare("UPDATE mud_chars SET gold=?,kills=?,hp=?,room_id=? WHERE id=?") + ->execute([$att['gold'], $att['kills'], $att['hp'], $att['room_id'], $att['id']]); + $pdo->prepare("UPDATE mud_chars SET gold=?,deaths=?,bounty=?,hp=?,room_id=? WHERE id=?") + ->execute([$def['gold'], $def['deaths'], $def['bounty'], $def['hp'], $def['id']]); + $pdo->prepare("DELETE FROM mud_bounties WHERE target_id=?")->execute([$def['id']]); + return $msgs; + } + // defender wins + if ($att['hp'] <= 0 && $def['hp'] > 0) { + $loot = intval($att['gold'] * 0.5); + $def['gold'] += $loot; $def['kills']++; + $att['gold'] -= $loot; $att['deaths']++; + $att['room_id'] = 1; $att['hp'] = $att['max_hp']; + $msgs[] = "{$def['name']} bests you! You lose {$loot}g and wake in the Village Square."; + mud_echo("{$def['name']} cut down {$att['name']}.", $att['room_id'], $pdo); + $pdo->prepare("UPDATE mud_chars SET gold=?,deaths=?,hp=?,room_id=? WHERE id=?") + ->execute([$att['gold'], $att['deaths'], $att['hp'], $att['room_id'], $att['id']]); + $pdo->prepare("UPDATE mud_chars SET gold=?,kills=? WHERE id=?") + ->execute([$def['gold'], $def['kills'], $def['id']]); + return $msgs; + } + // both survive a round + $pdo->prepare("UPDATE mud_chars SET hp=? WHERE id=?")->execute([$att['hp'], $att['id']]); + $pdo->prepare("UPDATE mud_chars SET hp=? WHERE id=?")->execute([$def['hp'], $def['id']]); + return $msgs; +} + +function mud_set_bounty(array &$c, array $target, int $amt): array { + $pdo = db(); + if ($target['id'] === $c['id']) return ["You can't bounty yourself."]; + if ($amt <= 0) return ["A bounty needs to be worth something."]; + if ($c['gold'] < $amt) return ["You can't post a {$amt}g bounty."]; + $c['gold'] -= $amt; + $pdo->prepare("UPDATE mud_chars SET bounty=bounty+?, gold=? WHERE id=?") + ->execute([$amt, $c['gold'], $target['id']]); + $pdo->prepare("INSERT INTO mud_bounties (target_id,by_id,amount,ts) VALUES (?,?,?,?)") + ->execute([$target['id'], $c['id'], $amt, time()]); + $pdo->prepare("UPDATE mud_chars SET gold=? WHERE id=?")->execute([$c['gold'], $c['id']]); + return ["You post a {$amt}g bounty on {$target['name']}. The realm will remember."]; +} diff --git a/public_html/lib/ui.php b/public_html/lib/ui.php new file mode 100644 index 0000000..19c0170 --- /dev/null +++ b/public_html/lib/ui.php @@ -0,0 +1,317 @@ +' . "\n"; + echo '' . "\n"; + echo "\n\n"; + // "Back" softkey on every deck + if (!empty($opt['back'])) { + echo '' . "\n"; + } + echo "

$t

\n"; + } else { + header('Content-Type: application/xhtml+xml; charset=utf-8'); + echo '' . "\n"; + echo '' . "\n"; + echo '' . "\n"; + echo "\n$t - " . e(SITE_NAME) . "\n"; + echo '' . "\n"; + // Canonical URL drops the WAPSID session param and the markup switch, + // so search engines consolidate on the clean page. + $canon = 'https://' . ($_SERVER['HTTP_HOST'] ?? 'wap.txt3.net') + . (strtok($_SERVER['REQUEST_URI'] ?? '/index.php', '?')); + echo '' . "\n"; + echo '' . "\n"; + echo "\n\n
\n"; + echo '

' . e(SITE_NAME) . "

\n"; + nav_bar(); + echo "

$t

\n"; + } +} + +function nav_bar(): void { + if (is_wml()) return; + $me = current_user(); + echo '\n"; +} + +function page_end(): void { + if (!$GLOBALS['__page_open']) return; + // Always offer a link to the OTHER markup mode, in both modes, so a client + // that switched (or was detected wrongly) can always get back. + $other = is_wml() ? 'html' : 'wml'; + $otherLbl = is_wml() ? 'xhtml view' : 'wml view'; + // Keep the user on the page they are looking at, minus any old m= override. + $self = strtok($_SERVER['REQUEST_URI'] ?? '/index.php', '?'); + $qs = $_GET; + unset($qs['m'], $qs[session_name()]); + $qs['m'] = $other; + + if (is_wml()) { + echo '

' . e($otherLbl) . "

\n"; + echo "\n\n"; + } else { + echo '
' . e(SITE_NAME) . ' · ' + . '' . e($otherLbl) . '' + . "
\n
\n\n\n"; + } + $GLOBALS['__page_open'] = false; +} + +// ---------------------------------------------------------------- primitives +function p_para(string $text, string $class = ''): void { + $t = nl2br_mode(e($text)); + if (is_wml()) echo "

$t

\n"; + else echo '$t

\n"; +} + +function nl2br_mode(string $escaped): string { + return str_replace(["\r\n", "\n"], is_wml() ? "
" : "
", $escaped); +} + +function p_err(string $t): void { + if (is_wml()) echo '

! ' . e($t) . "

\n"; + else echo '

' . e($t) . "

\n"; +} +function p_ok(string $t): void { + if (is_wml()) echo '

' . e($t) . "

\n"; + else echo '

' . e($t) . "

\n"; +} + +// A vertical list of links -- one

per line in WML,

    in XHTML. +function p_links(array $links): void { + if (!$links) return; + if (is_wml()) { + foreach ($links as $l) { + echo '

    ' + . e($l[1]) . "

    \n"; + } + } else { + echo "\n"; + } +} + +function p_link(string $path, string $label, array $q = []): void { + $a = '' . e($label) . ''; + echo is_wml() ? "

    $a

    \n" : "

    $a

    \n"; +} + +function p_rule(): void { if (!is_wml()) echo "
    \n"; } + +// ---------------------------------------------------------------- forms +// $fields: list of ['name'=>, 'label'=>, 'type'=>text|password|textarea|select|hidden, +// 'value'=>, 'options'=>[v=>label], 'maxlength'=>, 'format'=>] +function p_form(string $action, array $fields, string $submit, array $hidden = []): void { + $hidden['csrf'] = csrf_token(); + if (is_wml()) { + // WML: inputs are declared in the card body, the issues the POST. + foreach ($fields as $f) { + $type = $f['type'] ?? 'text'; + if ($type === 'hidden') { $hidden[$f['name']] = $f['value'] ?? ''; continue; } + echo '

    ' . e($f['label']) . ':
    '; + if ($type === 'select') { + echo ''; + } else { + // WML has no textarea; a plain input is the portable choice. + echo ''; + } + echo "

    \n"; + } + echo '' . "\n"; + echo ' ' . "\n"; + foreach ($hidden as $k => $v) { + echo ' ' . "\n"; + } + foreach ($fields as $f) { + if (($f['type'] ?? 'text') === 'hidden') continue; + echo ' ' . "\n"; + } + echo " \n\n"; + } else { + echo '
    ' . "\n
    \n"; + foreach ($hidden as $k => $v) { + echo '' . "\n"; + } + foreach ($fields as $f) { + $type = $f['type'] ?? 'text'; + $nm = e($f['name']); + if ($type === 'hidden') { + echo '' . "\n"; + continue; + } + echo '


    \n"; + if ($type === 'textarea') { + echo '"; + } elseif ($type === 'select') { + echo ''; + } else { + echo ''; + } + echo "

    \n"; + } + echo '

    ' . "\n"; + echo "
    \n
    \n"; + } +} + +// Pager: prev/next links +function p_pager(string $path, int $page, bool $more, array $q = []): void { + $out = []; + if ($page > 1) $out[] = ['/'.ltrim($path,'/'), '< Prev', $q + ['p' => $page - 1]]; + if ($more) $out[] = ['/'.ltrim($path,'/'), 'Next >', $q + ['p' => $page + 1]]; + if ($out) p_links($out); +} + +function bail(string $title, string $msg, string $backPath = '/index.php'): void { + page_start($title, ['back' => $backPath]); + p_err($msg); + p_link($backPath, 'Back'); + page_end(); + exit; +} + +function redirect(string $path, array $q = []): void { + header('Location: ' . url($path, $q)); + exit; +} diff --git a/public_html/login.php b/public_html/login.php new file mode 100644 index 0000000..fa8470e --- /dev/null +++ b/public_html/login.php @@ -0,0 +1,20 @@ + '/index.php']); +if ($err) p_err($err); +p_form('/login.php', [ + ['name' => 'username', 'label' => 'Username', 'maxlength' => 16], + ['name' => 'pass', 'label' => 'Password', 'type' => 'password', 'maxlength' => 40], +], 'Log in'); +p_links([['/signup.php', 'Create an account'], ['/index.php', 'Home']]); +page_end(); diff --git a/public_html/logout.php b/public_html/logout.php new file mode 100644 index 0000000..60c4e19 --- /dev/null +++ b/public_html/logout.php @@ -0,0 +1,8 @@ + '/index.php']); +p_ok('You are logged out.'); +p_links([['/index.php', 'Home'], ['/login.php', 'Log in again']]); +page_end(); diff --git a/public_html/message.php b/public_html/message.php new file mode 100644 index 0000000..9eca9c9 --- /dev/null +++ b/public_html/message.php @@ -0,0 +1,35 @@ +prepare("SELECT m.*, f.username AS fromname, t.username AS toname + FROM messages m + LEFT JOIN users f ON f.id=m.from_id + LEFT JOIN users t ON t.id=m.to_id + WHERE m.id=?"); +$s->execute([$id]); +$m = $s->fetch(); +if (!$m || ((int)$m['to_id'] !== (int)$me['id'] && (int)$m['from_id'] !== (int)$me['id'])) { + bail('Message', 'Not found.', '/inbox.php'); +} +if ((int)$m['to_id'] === (int)$me['id'] && !$m['is_read']) { + db()->prepare("UPDATE messages SET is_read=1 WHERE id=?")->execute([$id]); +} + +page_start('Msg: ' . $m['subject'], ['back' => '/inbox.php']); +p_para('From: ' . ($m['fromname'] ?? '[deleted]')); +p_para('To: ' . ($m['toname'] ?? '[deleted]')); +p_para('Date: ' . date('d/m/Y H:i', (int)$m['created_at'])); +p_rule(); +p_para(wtrim($m['body'])); +p_rule(); +if ((int)$m['from_id'] !== (int)$me['id'] && $m['fromname'] !== null) { + p_link('/compose.php', 'Reply', ['to' => $m['from_id'], 're' => $m['id']]); +} +p_form('/inbox.php', [ + ['name' => 'act', 'type' => 'hidden', 'value' => 'del'], + ['name' => 'id', 'type' => 'hidden', 'value' => (string)$id], +], 'Delete'); +p_links([['/inbox.php', 'Inbox'], ['/index.php', 'Home']]); +page_end(); diff --git a/public_html/mud.php b/public_html/mud.php new file mode 100644 index 0000000..b20a3ba --- /dev/null +++ b/public_html/mud.php @@ -0,0 +1,242 @@ + 'cname', 'label' => 'Character name', 'maxlength' => 16], + ['name' => 'class', 'label' => 'Class', 'type' => 'select', + 'options' => ['fighter' => 'Fighter (+ATK/HP)', 'mage' => 'Mage (+ATK)', 'thief' => 'Thief (+DEF/HP)']], + ], 'Enter the world', ['csrf' => csrf_token()]); + p_link('/games.php', 'Back to games'); + page_end(); + exit; + } +} + +mud_respawn(); +$msg = []; + +// ---- command handling ---- +if (isset($_GET['cmd'])) { + $raw = strtolower(trim($_GET['cmd'])); + $parts = explode(' ', $raw, 2); + $cmd = $parts[0]; + $arg = trim($parts[1] ?? ($_GET['arg'] ?? '')); +} elseif ($_SERVER['REQUEST_METHOD'] === 'POST' && !empty($_POST['cmd'])) { + require_csrf(); + $parts = explode(' ', trim($_POST['cmd']), 2); + $cmd = strtolower($parts[0]); + $arg = $parts[1] ?? ''; +} else { + $cmd = ''; $arg = ''; +} + +switch ($cmd) { + case 'n': case 'north': case 's': case 'south': case 'e': case 'east': + case 'w': case 'west': case 'u': case 'up': case 'd': case 'down': + $dir = $cmd[0]; + $room = mud_room($c['room_id']); + if (!empty($room['exits'][$dir])) { + $c['room_id'] = $room['exits'][$dir]; + db()->prepare("UPDATE mud_chars SET room_id=?,last_cmd_at=? WHERE id=?") + ->execute([$c['room_id'], time(), $c['id']]); + mud_echo("{$c['name']} heads {$dir}.", $c['room_id']); + } else { + $msg[] = "You can't go that way."; + } + break; + case 'look': case 'l': + $msg[] = 'You take in your surroundings.'; + break; + case 'k': case 'kill': case 'attack': + if (!$arg) { $msg[] = 'Kill what?'; break; } + $spawns = mud_alive_spawns($c['room_id']); + $hit = null; + foreach ($spawns as $sp) { if (strpos($sp['key_name'], $arg) === 0 || strpos($sp['name'], $arg) !== false) { $hit = $sp; break; } } + if ($hit) $msg = array_merge($msg, mud_fight($hit['id'], $c)); + else $msg[] = "There is no '$arg' here to fight."; + break; + case 'take': case 'get': + if (!$arg) { $msg[] = 'Take what?'; break; } + $ground = mud_ground_items($c['room_id']); + $got = null; + foreach ($ground as $g) if (strpos($g['key_name'], $arg) === 0) { $got = $g; break; } + if ($got) { + db()->prepare("DELETE FROM mud_ground WHERE id=?")->execute([$got['id']]); + $inv = json_decode($c['inv'], true); $inv[] = $got['key_name']; $inv = json_encode($inv); + db()->prepare("UPDATE mud_chars SET inv=? WHERE id=?")->execute([$inv, $c['id']]); + $c['inv'] = $inv; + mud_echo("{$c['name']} takes {$got['name']}.", $c['room_id']); + $msg[] = "You take {$got['name']}."; + } else $msg[] = "There is no '$arg' here."; + break; + case 'wear': case 'wield': + if (!$arg) { $msg[] = 'Wear what?'; break; } + $inv = json_decode($c['inv'], true); + $it = null; + foreach ($inv as $k) { if (strpos($k, $arg) === 0) { $it = mud_item($k); break; } } + if (!$it) { $msg[] = "You don't have that."; break; } + if ($it['slot'] === 'weapon') { $c['weapon'] = $it['key_name']; db()->prepare("UPDATE mud_chars SET weapon=? WHERE id=?")->execute([$it['key_name'], $c['id']]); $msg[] = "You wield {$it['name']}."; } + elseif ($it['slot'] === 'armor') { $c['armor'] = $it['key_name']; db()->prepare("UPDATE mud_chars SET armor=? WHERE id=?")->execute([$it['key_name'], $c['id']]); $msg[] = "You don {$it['name']}."; } + else { $msg[] = "You can't wear that."; } + break; + case 'drink': case 'quaff': + if (!$arg) { $msg[] = 'Drink what?'; break; } + $inv = json_decode($c['inv'], true); + $it = null; $idx = null; + foreach ($inv as $i => $k) { if (strpos($k, $arg) === 0) { $it = mud_item($k); $idx = $i; break; } } + if (!$it) { $msg[] = "You don't have that."; break; } + if ($it['slot'] !== 'potion') { $msg[] = "That's not a potion."; break; } + $heal = $it['heal']; + $c['hp'] = min($c['max_hp'], $c['hp'] + $heal); + unset($inv[$idx]); $inv = json_encode(array_values($inv)); + db()->prepare("UPDATE mud_chars SET hp=?,inv=? WHERE id=?")->execute([$c['hp'], $inv, $c['id']]); + $c['inv'] = $inv; + $msg[] = "You drink {$it['name']} and recover $heal hp."; + break; + case 'inv': case 'i': case 'inventory': + $inv = json_decode($c['inv'], true); + $msg[] = $inv ? "You carry: " . implode(', ', array_map(fn($k) => mud_item($k)['name'], $inv)) + : "Your pack is empty."; + break; + case 'score': case 'stats': + $msg[] = "Level {$c['level']} | HP {$c['hp']}/{$c['max_hp']} | ATK {$c['atk']} | DEF {$c['def']} | XP {$c['xp']} | Gold {$c['gold']} | Bank {$c['bank']} | Kills {$c['kills']} | Deaths {$c['deaths']}"; + break; + case 'attack': case 'a': + if (!$arg) { $msg[] = 'Attack who?'; break; } + $v = mud_find_player_in_room($c['room_id'], $arg); + if ($v) { + if ($v['id'] == $c['id']) $msg[] = "You can't attack yourself."; + else $msg = array_merge($msg, mud_pvp($c, $v)); + } else { + $here = array_values(array_filter(mud_players_in_room($c['room_id']), fn($p) => $p['id'] != $c['id'])); + $who = $here ? implode(', ', array_map(fn($p) => $p['name'], $here)) : 'nobody'; + $msg[] = "There is no '$arg' here to fight. Adventurers present: $who."; + } + break; + case 'buy': case 'b': + if (!$arg) { $msg[] = 'Buy what? (e.g. buy steel_sword)'; break; } + $msg = array_merge($msg, mud_buy($c, $arg)); + break; + case 'bank': + if (!$arg) { $msg[] = 'bank to deposit, or bank - to withdraw.'; break; } + if ($arg[0] === '-') $msg = array_merge($msg, mud_withdraw($c, (int)substr($arg,1))); + else $msg = array_merge($msg, mud_deposit($c, (int)$arg)); + break; + case 'tax': + $msg = array_merge($msg, mud_tax($c)); + break; + case 'bounty': + $bp = explode(' ', $arg, 2); + if (count($bp) < 2 || !is_numeric($bp[1])) { $msg[] = 'bounty '; break; } + $v = mud_find_player_in_room($c['room_id'], $bp[0]); + if ($v) { + if ($v['id'] == $c['id']) $msg[] = "You can't bounty yourself."; + else $msg = array_merge($msg, mud_set_bounty($c, $v, (int)$bp[1])); + } else $msg[] = "There is no '$bp[0]' here to bounty."; + break; + case 'board': case 'top': + $lb = mud_leaderboard(10); + $msg[] = 'Adventurers of renown:'; + foreach ($lb as $i => $r) { + $cn = $GLOBALS['MUD_CLASSES'][$r['class']]['name'] ?? $r['class']; + $msg[] = sprintf(" %d. %s (%s) L%d G%d K%d/D%d", $i+1, $r['name'], $cn, $r['level'], $r['gold'], $r['kills'], $r['deaths']); + } + break; + case 'help': case 'h': case '': + $msg[] = "Commands: n/s/e/w/u/d (move), look, kill , attack , take , " + . "wear/wield , drink , buy , bank <+/->amt, tax, " + . "bounty , board (leaderboard), inv, score, help. " + . "Also play live on the telnet BBS (port 12300)."; + break; + default: + $msg[] = "Unknown command: $cmd (try 'help')."; +} + +// ---- render room ---- +$room = mud_room($c['room_id']); +$spawns = mud_alive_spawns($c['room_id']); +$ground = mud_ground_items($c['room_id']); +$others = mud_players_in_room($c['room_id']); +$ev_st = db()->prepare("SELECT text FROM mud_events WHERE room_id=? ORDER BY id DESC LIMIT 5"); +$ev_st->execute([$c['room_id']]); +$events = $ev_st->fetchAll(PDO::FETCH_COLUMN); + +page_start('MUD - ' . $c['name']); +if ($msg) foreach ($msg as $m) p_para($m); +p_rule(); +p_para($room['name']); +p_para($room['descr']); +if ($spawns) { + p_para('Here:'); + foreach ($spawns as $sp) p_para(' ' . $sp['descr'] . ' (' . $sp['hp'] . ' hp)'); +} +// other players present +$people = array_filter($others, fn($p) => $p['id'] != $c['id']); +if ($people) { + p_para('Adventurers here:'); + foreach ($people as $p) { + $cn = $GLOBALS['MUD_CLASSES'][$p['class']]['name'] ?? $p['class']; + p_para(' ' . $p['name'] . ' (' . $cn . ', L' . $p['level'] . (($p['bounty']>0)?', bounty '.$p['bounty'].'g':'') . ')'); + } +} +if ($ground) { + p_para('On the ground:'); + foreach ($ground as $g) p_para(' ' . $g['name']); +} +if ($events) { + p_para('You see:'); + foreach (array_reverse($events) as $ev) p_para(' ' . $ev); +} +p_rule(); +p_para("HP {$c['hp']}/{$c['max_hp']} Lvl {$c['level']} XP {$c['xp']} Gold {$c['gold']} Bank {$c['bank']}"); + +// movement links + command form +$links = []; +foreach ($room['exits'] as $dir => $to) { + $labels = ['n'=>'North','s'=>'South','e'=>'East','w'=>'West','u'=>'Up','d'=>'Down']; + $links[] = ['/mud.php', $labels[$dir], ['cmd' => $dir]]; +} +p_links($links); +// action quick links +$acts = []; +if ($spawns) $acts[] = ['/mud.php', 'Attack', ['cmd' => 'kill', 'arg' => substr($spawns[0]['key_name'],0,3)]]; +if ($room['id'] == 9) { + foreach (mud_shop_list() as $it) $acts[] = ['/mud.php', 'Buy '.$it['name'].' ('.$it['price'].'g)', ['cmd' => 'buy', 'arg' => $it['key_name']]]; +} +if ($room['id'] == 10) { $acts[] = ['/mud.php', 'Deposit 10g', ['cmd' => 'bank', 'arg' => '10']]; $acts[] = ['/mud.php', 'Withdraw 10g', ['cmd' => 'bank', 'arg' => '-10']]; } +if ($room['id'] == 11) { $acts[] = ['/mud.php', 'Pay the Taxman', ['cmd' => 'tax']]; } +if ($people) $acts[] = ['/mud.php', 'Attack ' . $people[array_key_first($people)]['name'], ['cmd' => 'attack', 'arg' => substr($people[array_key_first($people)]['name'],0,4)]]; +$acts[] = ['/mud.php', 'Look', ['cmd' => 'look']]; +$acts[] = ['/mud.php', 'Inventory', ['cmd' => 'inv']]; +$acts[] = ['/mud.php', 'Score', ['cmd' => 'score']]; +$acts[] = ['/mud.php', 'Leaderboard', ['cmd' => 'board']]; +$acts[] = ['/mud.php', 'Help', ['cmd' => 'help']]; +p_links($acts); +p_para('Or type a command:'); +p_form('/mud.php', [ + ['name' => 'cmd', 'label' => '', 'maxlength' => 48], +], 'Go', ['csrf' => csrf_token()]); +p_link('/games.php', 'Back to games'); +page_end(); diff --git a/public_html/page1.php b/public_html/page1.php new file mode 100644 index 0000000..f5d1af3 --- /dev/null +++ b/public_html/page1.php @@ -0,0 +1,13 @@ + + + + +

    + txt3.com was established in 1997 as a free SMS service, allowing users to send text messages to mobile phones in the UK and internationally. Accessible via web and WAP, it catered to users across various devices. +

    +

    + Next: Service Evolution +

    +
    +
    + diff --git a/public_html/page2.php b/public_html/page2.php new file mode 100644 index 0000000..1bb8a50 --- /dev/null +++ b/public_html/page2.php @@ -0,0 +1,17 @@ + + + + +

    + In the early 2010s, txt3 expanded its offerings to include: +

      +
    • Group messaging
    • +
    • Community forums
    • +
    + These enhancements aimed to foster a more interactive and engaging user experience. +

    +

    + Next: Current Status +

    +
    +
    diff --git a/public_html/page3.php b/public_html/page3.php new file mode 100644 index 0000000..9c8bef3 --- /dev/null +++ b/public_html/page3.php @@ -0,0 +1,13 @@ + + + + +

    + As of now, txt3.com hosts a Matrix chat server, providing secure and decentralized communication services, including group chats, communities, and audio/video calls. +

    +

    + Next: Domain Information +

    +
    +
    + diff --git a/public_html/page4.php b/public_html/page4.php new file mode 100644 index 0000000..f983454 --- /dev/null +++ b/public_html/page4.php @@ -0,0 +1,14 @@ + + + + +

    + - txt3.com: Established in 1997, currently hosting a Matrix chat server.
    + - txt3.net: Operational since at least 2013, also providing free SMS services.
    + - txt3.co.uk: Registered on June 25, 1999, with the registrar (AQ) Limited. +

    +

    + Back to Menu +

    +
    +
    diff --git a/public_html/password.php b/public_html/password.php new file mode 100644 index 0000000..a13beef --- /dev/null +++ b/public_html/password.php @@ -0,0 +1,30 @@ +prepare("UPDATE users SET pass_hash=? WHERE id=?") + ->execute([password_hash($n1, PASSWORD_DEFAULT), $me['id']]); + $ok = 'Password changed.'; + } +} + +page_start('Change password', ['back' => '/profile.php']); +if ($err) p_err($err); +if ($ok) p_ok($ok); +p_form('/password.php', [ + ['name' => 'old', 'label' => 'Current password', 'type' => 'password'], + ['name' => 'new1', 'label' => 'New password', 'type' => 'password'], + ['name' => 'new2', 'label' => 'Repeat new', 'type' => 'password'], +], 'Change'); +p_links([['/profile.php', 'Back to profile']]); +page_end(); diff --git a/public_html/profile.php b/public_html/profile.php new file mode 100644 index 0000000..0cdf6d8 --- /dev/null +++ b/public_html/profile.php @@ -0,0 +1,54 @@ +prepare("UPDATE users SET tagline=?, location=? WHERE id=?") + ->execute([$tag, $loc, $id]); + $u['tagline'] = $tag; $u['location'] = $loc; + $msg = 'Profile saved.'; + } +} + +$d = db(); +$s = $d->prepare("SELECT COUNT(*) FROM posts WHERE user_id=?"); $s->execute([$id]); +$posts = (int)$s->fetchColumn(); +$s = $d->prepare("SELECT game, MAX(score) sc FROM scores WHERE user_id=? GROUP BY game"); +$s->execute([$id]); +$best = $s->fetchAll(); + +page_start('Profile: ' . $u['username'], ['back' => '/index.php']); +if ($msg) p_ok($msg); +p_para('User: ' . $u['username'] . ($u['is_admin'] ? ' [admin]' : '')); +if ($u['tagline'] !== '') p_para('"' . $u['tagline'] . '"'); +if ($u['location'] !== '') p_para('From: ' . $u['location']); +p_para('Joined: ' . date('d/m/Y', (int)$u['created_at'])); +p_para('Last seen: ' . ($u['last_seen'] ? ago((int)$u['last_seen']) . ' ago' : 'never')); +p_para('Forum posts: ' . $posts); +foreach ($best as $b) p_para('Best ' . $b['game'] . ': ' . $b['sc']); + +p_rule(); +if ($mine) { + p_para('Edit your details:'); + p_form('/profile.php', [ + ['name' => 'tagline', 'label' => 'Tagline', 'value' => $u['tagline'], 'maxlength' => 80], + ['name' => 'location', 'label' => 'Location', 'value' => $u['location'], 'maxlength' => 40], + ], 'Save'); + p_links([['/password.php', 'Change password'], ['/inbox.php', 'My inbox']]); +} elseif ($me) { + p_links([['/compose.php', 'Send message', ['to' => $u['id']]]]); +} +p_links([['/users.php', 'Member list'], ['/index.php', 'Home']]); +page_end(); diff --git a/public_html/robots.txt b/public_html/robots.txt new file mode 100644 index 0000000..5c22c94 --- /dev/null +++ b/public_html/robots.txt @@ -0,0 +1,8 @@ +User-agent: * +Allow: / + +# Session / markup-switch query params are not separate pages. +Disallow: /*?*WAPSID= +Disallow: /*&WAPSID= + +Sitemap: https://wap.txt3.net/sitemap.php diff --git a/public_html/signup.php b/public_html/signup.php new file mode 100644 index 0000000..10adc3e --- /dev/null +++ b/public_html/signup.php @@ -0,0 +1,29 @@ + '/index.php']); +if ($err) p_err($err); +p_form('/signup.php', [ + ['name' => 'username', 'label' => 'Username', 'maxlength' => 16], + ['name' => 'pass', 'label' => 'Password', 'type' => 'password', 'maxlength' => 40], + ['name' => 'pass2', 'label' => 'Repeat', 'type' => 'password', 'maxlength' => 40], +], 'Create'); +p_links([['/login.php', 'Already a member? Log in'], ['/index.php', 'Home']]); +page_end(); diff --git a/public_html/sitemap.php b/public_html/sitemap.php new file mode 100644 index 0000000..f001bd4 --- /dev/null +++ b/public_html/sitemap.php @@ -0,0 +1,34 @@ + PDO::ERRMODE_EXCEPTION, +]); + +$urls = []; +$urls[] = ['loc' => $base . '/index.php', 'changefreq' => 'daily', 'priority' => '1.0']; +$urls[] = ['loc' => $base . '/about.php', 'changefreq' => 'monthly', 'priority' => '0.6']; +$urls[] = ['loc' => $base . '/forum.php', 'changefreq' => 'daily', 'priority' => '0.8']; + +foreach ($db->query("SELECT id, name FROM forums ORDER BY id") as $f) { + $urls[] = ['loc' => $base . '/forum.php?f=' . $f['id'], + 'changefreq' => 'weekly', 'priority' => '0.5']; +} +foreach ($db->query("SELECT id FROM topics ORDER BY bumped_at DESC LIMIT 500") as $t) { + $urls[] = ['loc' => $base . '/topic.php?t=' . $t['id'], + 'changefreq' => 'monthly', 'priority' => '0.4']; +} + +header('Content-Type: application/xml; charset=utf-8'); +echo '' . "\n"; +echo '' . "\n"; +foreach ($urls as $u) { + echo ' ' . "\n"; + echo ' ' . htmlspecialchars($u['loc'], ENT_XML1, 'UTF-8') . '' . "\n"; + echo ' ' . $u['changefreq'] . '' . "\n"; + echo ' ' . $u['priority'] . '' . "\n"; + echo ' ' . "\n"; +} +echo '' . "\n"; diff --git a/public_html/style.css b/public_html/style.css new file mode 100644 index 0000000..f3a9960 --- /dev/null +++ b/public_html/style.css @@ -0,0 +1,33 @@ +/* wap.txt3.net - small, high-contrast, works on old and new browsers */ +body { + background: #101418; + color: #dfe6ec; + font-family: Verdana, Geneva, sans-serif; + font-size: 14px; + line-height: 1.5; + margin: 0; + padding: 8px; +} +.wrap { max-width: 640px; margin: 0 auto; } +h1 { font-size: 18px; margin: 4px 0; } +h1 a { color: #7fd6ff; text-decoration: none; } +h2 { font-size: 15px; margin: 12px 0 6px; color: #9fb4c4; border-bottom: 1px solid #2a3440; padding-bottom: 4px; } +a { color: #7fd6ff; } +a:hover { color: #b8ecff; } +p { margin: 6px 0; } +hr { border: 0; border-top: 1px solid #2a3440; margin: 8px 0; } +.nav { background: #18202a; padding: 6px 8px; border-radius: 4px; font-size: 12px; } +.nav a { text-decoration: none; } +ul.menu { list-style: none; padding: 0; margin: 6px 0; } +ul.menu li { padding: 5px 0; border-bottom: 1px solid #1e2732; } +.err { color: #ff9c8a; background: #2b1a1a; padding: 6px; border-left: 3px solid #ff6b52; } +.ok { color: #a6e6a6; background: #16261a; padding: 6px; border-left: 3px solid #59c65e; } +.small { font-size: 11px; color: #8496a5; } +.foot { margin-top: 16px; font-size: 11px; color: #6e7f8d; border-top: 1px solid #2a3440; padding-top: 6px; } +input, textarea, select { + background: #0b0e12; color: #e6eef4; border: 1px solid #33404e; + padding: 5px; font-family: inherit; font-size: 14px; max-width: 100%; +} +input[type=submit] { background: #244258; color: #dff1ff; border: 1px solid #3d6b8c; cursor: pointer; padding: 6px 14px; } +label { font-size: 12px; color: #9fb4c4; } +form { margin: 6px 0; } diff --git a/public_html/topic.php b/public_html/topic.php new file mode 100644 index 0000000..902b474 --- /dev/null +++ b/public_html/topic.php @@ -0,0 +1,83 @@ +prepare("SELECT t.*, f.name AS fname, f.is_locked AS flocked + FROM topics t JOIN forums f ON f.id=t.forum_id WHERE t.id=?"); +$s->execute([$tid]); +$t = $s->fetch(); +if (!$t) bail('Topic', 'No such topic.', '/forum.php'); + +$locked = $t['is_locked'] || $t['flocked']; + +// reply / delete post +$err = ''; +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $me = require_login(); + require_csrf(); + $act = $_POST['act'] ?? 'reply'; + if ($act === 'delpost' && $me['is_admin']) { + db()->prepare("DELETE FROM posts WHERE id=? AND topic_id=?") + ->execute([(int)($_POST['pid'] ?? 0), $tid]); + redirect('/topic.php', ['t' => $tid]); + } + if ($locked && !$me['is_admin']) bail('Topic', 'Topic is locked.', '/forum.php'); + $body = trim((string)($_POST['body'] ?? '')); + if ($body === '') { + $err = 'Message cannot be empty.'; + } else { + $now = time(); + db()->prepare("INSERT INTO posts (topic_id,user_id,body,created_at) VALUES (?,?,?,?)") + ->execute([$tid, $me['id'], mb_substr($body, 0, 4000), $now]); + db()->prepare("UPDATE topics SET bumped_at=? WHERE id=?")->execute([$now, $tid]); + // jump to the last page + $c = db()->prepare("SELECT COUNT(*) FROM posts WHERE topic_id=?"); + $c->execute([$tid]); + $last = max(1, (int)ceil(((int)$c->fetchColumn()) / PER_PAGE)); + redirect('/topic.php', ['t' => $tid, 'p' => $last]); + } +} + +$page = max(1, (int)($_GET['p'] ?? 1)); +$off = ($page - 1) * PER_PAGE; +$s = db()->prepare("SELECT p.*, u.username FROM posts p + LEFT JOIN users u ON u.id=p.user_id + WHERE p.topic_id=? ORDER BY p.id LIMIT ? OFFSET ?"); +$s->execute([$tid, PER_PAGE + 1, $off]); +$rows = $s->fetchAll(); +$more = count($rows) > PER_PAGE; +if ($more) array_pop($rows); + +page_start($t['title'], ['back' => '/forum.php?f=' . $t['forum_id']]); +if ($err) p_err($err); +p_para('In ' . $t['fname'] . ($locked ? ' [locked]' : '')); +p_rule(); +foreach ($rows as $r) { + p_para('-- ' . ($r['username'] ?? '[deleted]') . ', ' . ago((int)$r['created_at']) . ' ago:'); + p_para(wtrim($r['body'])); + if ($me && $me['is_admin']) { + p_form('/topic.php?t=' . $tid, [ + ['name' => 'act', 'type' => 'hidden', 'value' => 'delpost'], + ['name' => 'pid', 'type' => 'hidden', 'value' => (string)$r['id']], + ], 'Del post'); + } + p_rule(); +} +p_pager('/topic.php', $page, $more, ['t' => $tid]); + +if ($me && (!$locked || $me['is_admin'])) { + p_para('Reply:'); + p_form('/topic.php?t=' . $tid, [ + ['name' => 'body', 'label' => 'Message', 'type' => is_wml() ? 'text' : 'textarea', + 'maxlength' => is_wml() ? 200 : 4000], + ], 'Reply'); +} elseif (!$me) { + p_links([['/login.php', 'Log in to reply']]); +} +p_links([ + ['/forum.php', 'Back to ' . $t['fname'], ['f' => $t['forum_id']]], + ['/forum.php', 'All forums'], + ['/index.php', 'Home'], +]); +page_end(); diff --git a/public_html/users.php b/public_html/users.php new file mode 100644 index 0000000..b0b8c57 --- /dev/null +++ b/public_html/users.php @@ -0,0 +1,23 @@ +prepare("SELECT id,username,tagline,last_seen,is_admin FROM users + ORDER BY username LIMIT ? OFFSET ?"); +$s->execute([PER_PAGE + 1, $off]); +$rows = $s->fetchAll(); +$more = count($rows) > PER_PAGE; +if ($more) array_pop($rows); + +page_start('Members', ['back' => '/index.php']); +foreach ($rows as $r) { + $lab = $r['username'] . ($r['is_admin'] ? ' [a]' : '') + . ($r['last_seen'] ? ' (' . ago((int)$r['last_seen']) . ')' : ''); + p_link('/profile.php', $lab, ['id' => $r['id']]); +} +if (!$rows) p_para('No members.'); +p_pager('/users.php', $page, $more); +p_links([['/index.php', 'Home']]); +page_end();