First git commit
This commit is contained in:
17
bbs/bbs.log
Normal file
17
bbs/bbs.log
Normal file
@ -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)
|
||||||
241
bbs/bbsd.py
Normal file
241
bbs/bbsd.py
Normal file
@ -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())
|
||||||
126
bbs/bbsdb.py
Normal file
126
bbs/bbsdb.py
Normal file
@ -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)
|
||||||
250
bbs/boards.py
Normal file
250
bbs/boards.py
Normal file
@ -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
|
||||||
323
bbs/games.py
Normal file
323
bbs/games.py
Normal file
@ -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 <id> = join #<id> = 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"]))
|
||||||
511
bbs/mud.py
Normal file
511
bbs/mud.py
Normal file
@ -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 <mob> [t]ake <item>")
|
||||||
|
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 <amount> to deposit, bank -<amount> 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 <player> <amount>"); 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 <mob>, attack <player>,")
|
||||||
|
t.line(" buy <item>, bank <amt>, tax, bounty <player> <amt>, 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"])]
|
||||||
305
bbs/screens.py
Normal file
305
bbs/screens.py
Normal file
@ -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
|
||||||
163
bbs/term.py
Normal file
163
bbs/term.py
Normal file
@ -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
|
||||||
43
bbs/txt3-bbs.service
Normal file
43
bbs/txt3-bbs.service
Normal file
@ -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
|
||||||
16
public_html/.htaccess
Normal file
16
public_html/.htaccess
Normal file
@ -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(/|$)
|
||||||
|
|
||||||
|
<IfModule mod_headers.c>
|
||||||
|
Header set X-Content-Type-Options nosniff
|
||||||
|
</IfModule>
|
||||||
96
public_html/about.php
Normal file
96
public_html/about.php
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/lib/bootstrap.php';
|
||||||
|
|
||||||
|
page_start('About', ['back' => '/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();
|
||||||
88
public_html/admin/forums.php
Normal file
88
public_html/admin/forums.php
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
// Admin: add / rename / reorder / lock / delete forums.
|
||||||
|
require_once __DIR__ . '/../lib/bootstrap.php';
|
||||||
|
$me = require_admin();
|
||||||
|
$d = db();
|
||||||
|
|
||||||
|
$msg = ''; $err = '';
|
||||||
|
$edit = (int)($_GET['edit'] ?? 0);
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
require_csrf();
|
||||||
|
$act = $_POST['act'] ?? '';
|
||||||
|
$fid = (int)($_POST['fid'] ?? 0);
|
||||||
|
|
||||||
|
if ($act === 'add') {
|
||||||
|
$n = mb_substr(trim((string)($_POST['name'] ?? '')), 0, 60);
|
||||||
|
if ($n === '') $err = 'Forum name required.';
|
||||||
|
else {
|
||||||
|
$d->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();
|
||||||
40
public_html/admin/index.php
Normal file
40
public_html/admin/index.php
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/../lib/bootstrap.php';
|
||||||
|
$me = require_admin();
|
||||||
|
$d = db();
|
||||||
|
|
||||||
|
$ok = '';
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
require_csrf();
|
||||||
|
if (isset($_POST['motd'])) {
|
||||||
|
setting_set('motd', mb_substr(trim((string)$_POST['motd']), 0, 200));
|
||||||
|
$ok = 'MOTD updated.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$stats = [
|
||||||
|
'Users' => (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();
|
||||||
76
public_html/admin/topics.php
Normal file
76
public_html/admin/topics.php
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
// Admin: moderate topics -- lock/unlock, move between forums, delete.
|
||||||
|
require_once __DIR__ . '/../lib/bootstrap.php';
|
||||||
|
$me = require_admin();
|
||||||
|
$d = db();
|
||||||
|
|
||||||
|
$msg = ''; $err = '';
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
require_csrf();
|
||||||
|
$act = $_POST['act'] ?? '';
|
||||||
|
$tid = (int)($_POST['tid'] ?? 0);
|
||||||
|
if ($tid) {
|
||||||
|
if ($act === 'lock') {
|
||||||
|
$d->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();
|
||||||
125
public_html/admin/users.php
Normal file
125
public_html/admin/users.php
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
<?php
|
||||||
|
// Admin: list / add / edit / ban / delete users, reset passwords.
|
||||||
|
require_once __DIR__ . '/../lib/bootstrap.php';
|
||||||
|
$me = require_admin();
|
||||||
|
$d = db();
|
||||||
|
|
||||||
|
$msg = ''; $err = '';
|
||||||
|
$edit = (int)($_GET['edit'] ?? 0);
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
require_csrf();
|
||||||
|
$act = $_POST['act'] ?? '';
|
||||||
|
$uid = (int)($_POST['uid'] ?? 0);
|
||||||
|
|
||||||
|
if ($act === 'add') {
|
||||||
|
$n = trim((string)($_POST['username'] ?? ''));
|
||||||
|
$p = (string)($_POST['pass'] ?? '');
|
||||||
|
$a = (int)($_POST['is_admin'] ?? 0);
|
||||||
|
if (!valid_username($n)) $err = 'Bad username (3-16 alnum/underscore).';
|
||||||
|
elseif (strlen($p) < 4) $err = 'Password too short.';
|
||||||
|
elseif (username_taken($n)) $err = 'Username taken.';
|
||||||
|
else { user_create($n, $p, $a); $msg = 'User ' . $n . ' created.'; }
|
||||||
|
|
||||||
|
} elseif ($act === 'save' && $uid) {
|
||||||
|
$u = user_by_id($uid);
|
||||||
|
if (!$u) $err = 'No such user.';
|
||||||
|
else {
|
||||||
|
$tag = mb_substr(trim((string)($_POST['tagline'] ?? '')), 0, 80);
|
||||||
|
$loc = mb_substr(trim((string)($_POST['location'] ?? '')), 0, 40);
|
||||||
|
$adm = (int)($_POST['is_admin'] ?? 0);
|
||||||
|
$ban = (int)($_POST['is_banned'] ?? 0);
|
||||||
|
// never let an admin strip their own last-admin rights into a lockout
|
||||||
|
if ($uid === (int)$me['id'] && !$adm) {
|
||||||
|
$err = 'You cannot remove your own admin rights.';
|
||||||
|
} else {
|
||||||
|
$d->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();
|
||||||
BIN
public_html/bbs/__pycache__/bbsdb.cpython-311.pyc
Normal file
BIN
public_html/bbs/__pycache__/bbsdb.cpython-311.pyc
Normal file
Binary file not shown.
BIN
public_html/bbs/__pycache__/boards.cpython-311.pyc
Normal file
BIN
public_html/bbs/__pycache__/boards.cpython-311.pyc
Normal file
Binary file not shown.
BIN
public_html/bbs/__pycache__/games.cpython-311.pyc
Normal file
BIN
public_html/bbs/__pycache__/games.cpython-311.pyc
Normal file
Binary file not shown.
BIN
public_html/bbs/__pycache__/mud.cpython-311.pyc
Normal file
BIN
public_html/bbs/__pycache__/mud.cpython-311.pyc
Normal file
Binary file not shown.
BIN
public_html/bbs/__pycache__/screens.cpython-311.pyc
Normal file
BIN
public_html/bbs/__pycache__/screens.cpython-311.pyc
Normal file
Binary file not shown.
BIN
public_html/bbs/__pycache__/term.cpython-311.pyc
Normal file
BIN
public_html/bbs/__pycache__/term.cpython-311.pyc
Normal file
Binary file not shown.
241
public_html/bbs/bbsd.py
Normal file
241
public_html/bbs/bbsd.py
Normal file
@ -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())
|
||||||
126
public_html/bbs/bbsdb.py
Normal file
126
public_html/bbs/bbsdb.py
Normal file
@ -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)
|
||||||
250
public_html/bbs/boards.py
Normal file
250
public_html/bbs/boards.py
Normal file
@ -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
|
||||||
323
public_html/bbs/games.py
Normal file
323
public_html/bbs/games.py
Normal file
@ -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 <id> = join #<id> = 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"]))
|
||||||
511
public_html/bbs/mud.py
Normal file
511
public_html/bbs/mud.py
Normal file
@ -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 <mob> [t]ake <item>")
|
||||||
|
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 <amount> to deposit, bank -<amount> 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 <player> <amount>"); 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 <mob>, attack <player>,")
|
||||||
|
t.line(" buy <item>, bank <amt>, tax, bounty <player> <amt>, 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"])]
|
||||||
305
public_html/bbs/screens.py
Normal file
305
public_html/bbs/screens.py
Normal file
@ -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
|
||||||
163
public_html/bbs/term.py
Normal file
163
public_html/bbs/term.py
Normal file
@ -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
|
||||||
43
public_html/bbs/txt3-bbs.service
Normal file
43
public_html/bbs/txt3-bbs.service
Normal file
@ -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
|
||||||
50
public_html/compose.php
Normal file
50
public_html/compose.php
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/lib/bootstrap.php';
|
||||||
|
$me = require_login();
|
||||||
|
|
||||||
|
$toId = (int)($_GET['to'] ?? 0);
|
||||||
|
$reId = (int)($_GET['re'] ?? 0);
|
||||||
|
$toName = '';
|
||||||
|
$subj = '';
|
||||||
|
|
||||||
|
if ($toId) { $t = user_by_id($toId); if ($t) $toName = $t['username']; }
|
||||||
|
if ($reId) {
|
||||||
|
$s = db()->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();
|
||||||
BIN
public_html/data/wap.sqlite
Normal file
BIN
public_html/data/wap.sqlite
Normal file
Binary file not shown.
87
public_html/forum.php
Normal file
87
public_html/forum.php
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/lib/bootstrap.php';
|
||||||
|
$me = current_user();
|
||||||
|
|
||||||
|
$fid = (int)($_GET['f'] ?? 0);
|
||||||
|
|
||||||
|
// ---------- forum index ----------
|
||||||
|
if (!$fid) {
|
||||||
|
$rows = db()->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();
|
||||||
23
public_html/games.php
Normal file
23
public_html/games.php
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/lib/bootstrap.php';
|
||||||
|
$me = current_user();
|
||||||
|
|
||||||
|
page_start('Games', ['back' => '/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();
|
||||||
74
public_html/games/guess.php
Normal file
74
public_html/games/guess.php
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
// Single player: guess the number 1-100, state persisted in sp_games.
|
||||||
|
require_once __DIR__ . '/../lib/bootstrap.php';
|
||||||
|
$me = require_login();
|
||||||
|
|
||||||
|
function guess_load(int $uid): ?array {
|
||||||
|
$s = db()->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();
|
||||||
82
public_html/games/mplib.php
Normal file
82
public_html/games/mplib.php
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
<?php
|
||||||
|
// Shared helpers for turn-based multiplayer games stored in `matches`.
|
||||||
|
require_once __DIR__ . '/../lib/bootstrap.php';
|
||||||
|
|
||||||
|
function mp_create(string $game, int $uid, string $state): int {
|
||||||
|
$now = time();
|
||||||
|
db()->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');
|
||||||
|
}
|
||||||
95
public_html/games/nim.php
Normal file
95
public_html/games/nim.php
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
<?php
|
||||||
|
// Multiplayer Nim: 21 sticks, take 1-3, whoever takes the last stick loses.
|
||||||
|
require_once __DIR__ . '/mplib.php';
|
||||||
|
$me = require_login();
|
||||||
|
$uid = (int)$me['id'];
|
||||||
|
$SELF = '/games/nim.php';
|
||||||
|
|
||||||
|
$msg = '';
|
||||||
|
$mid = (int)($_GET['g'] ?? 0);
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
require_csrf();
|
||||||
|
$act = $_POST['act'] ?? '';
|
||||||
|
if ($act === 'create') {
|
||||||
|
$mid = mp_create('nim', $uid, json_encode(['sticks' => 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();
|
||||||
82
public_html/games/quiz.php
Normal file
82
public_html/games/quiz.php
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
<?php
|
||||||
|
// Single player: 5-question multiple choice quiz, one question per deck.
|
||||||
|
require_once __DIR__ . '/../lib/bootstrap.php';
|
||||||
|
$me = require_login();
|
||||||
|
|
||||||
|
$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, the first big WAP phone?', ['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],
|
||||||
|
['Default MIME type for WML is text/vnd.wap...?', ['wap','wml','xml','wsp'], 1],
|
||||||
|
['GPRS stands for General Packet Radio...?', ['System','Service','Standard','Stream'], 1],
|
||||||
|
];
|
||||||
|
|
||||||
|
function quiz_load(int $uid): ?array {
|
||||||
|
$s = db()->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();
|
||||||
30
public_html/games/scores.php
Normal file
30
public_html/games/scores.php
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/../lib/bootstrap.php';
|
||||||
|
$games = ['guess' => '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();
|
||||||
116
public_html/games/ttt.php
Normal file
116
public_html/games/ttt.php
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
<?php
|
||||||
|
// Multiplayer noughts & crosses.
|
||||||
|
require_once __DIR__ . '/mplib.php';
|
||||||
|
$me = require_login();
|
||||||
|
$uid = (int)$me['id'];
|
||||||
|
$SELF = '/games/ttt.php';
|
||||||
|
|
||||||
|
function ttt_win(array $b): ?string {
|
||||||
|
$lines = [[0,1,2],[3,4,5],[6,7,8],[0,3,6],[1,4,7],[2,5,8],[0,4,8],[2,4,6]];
|
||||||
|
foreach ($lines as $l) {
|
||||||
|
if ($b[$l[0]] !== '' && $b[$l[0]] === $b[$l[1]] && $b[$l[1]] === $b[$l[2]]) return $b[$l[0]];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$msg = '';
|
||||||
|
$mid = (int)($_GET['g'] ?? 0);
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
require_csrf();
|
||||||
|
$act = $_POST['act'] ?? '';
|
||||||
|
if ($act === 'create') {
|
||||||
|
$mid = mp_create('ttt', $uid, json_encode(['b' => 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();
|
||||||
1
public_html/google-verify-PLACEHOLDER.html
Normal file
1
public_html/google-verify-PLACEHOLDER.html
Normal file
@ -0,0 +1 @@
|
|||||||
|
google-site-verification: REPLACE_WITH_YOUR_GOOGLE_VERIFICATION_CODE
|
||||||
46
public_html/inbox.php
Normal file
46
public_html/inbox.php
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/lib/bootstrap.php';
|
||||||
|
$me = require_login();
|
||||||
|
$box = ($_GET['box'] ?? 'in') === 'out' ? 'out' : 'in';
|
||||||
|
$page = max(1, (int)($_GET['p'] ?? 1));
|
||||||
|
$off = ($page - 1) * PER_PAGE;
|
||||||
|
|
||||||
|
// delete
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['act'] ?? '') === 'del') {
|
||||||
|
require_csrf();
|
||||||
|
$mid = (int)($_POST['id'] ?? 0);
|
||||||
|
db()->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();
|
||||||
136
public_html/index.html
Normal file
136
public_html/index.html
Normal file
File diff suppressed because one or more lines are too long
41
public_html/index.php
Normal file
41
public_html/index.php
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/lib/bootstrap.php';
|
||||||
|
$me = current_user();
|
||||||
|
|
||||||
|
page_start('Home');
|
||||||
|
p_para(setting('motd', 'Welcome!'));
|
||||||
|
|
||||||
|
if ($me) {
|
||||||
|
$n = unread_count((int)$me['id']);
|
||||||
|
p_para('Hello ' . $me['username'] . ($n ? " - you have $n new message(s)." : '.'));
|
||||||
|
$links = [
|
||||||
|
['/inbox.php', 'Inbox' . ($n ? " ($n new)" : '')],
|
||||||
|
['/forum.php', 'Forum'],
|
||||||
|
['/games.php', 'Games'],
|
||||||
|
['/profile.php', 'My profile'],
|
||||||
|
['/users.php', 'Member list'],
|
||||||
|
['/about.php', 'About this site'],
|
||||||
|
];
|
||||||
|
if ($me['is_admin']) $links[] = ['/admin/index.php', 'Admin tools'];
|
||||||
|
$links[] = ['/logout.php', 'Log out'];
|
||||||
|
p_links($links);
|
||||||
|
} else {
|
||||||
|
p_links([
|
||||||
|
['/login.php', 'Log in'],
|
||||||
|
['/signup.php', 'Sign up'],
|
||||||
|
['/forum.php', 'Browse forum'],
|
||||||
|
['/about.php', 'About this site'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// stats
|
||||||
|
$d = db();
|
||||||
|
$uc = (int)$d->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();
|
||||||
117
public_html/lib/auth.php
Normal file
117
public_html/lib/auth.php
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
<?php
|
||||||
|
// Sessions, auth, CSRF. Included by every page via bootstrap.php.
|
||||||
|
|
||||||
|
function session_boot(): void {
|
||||||
|
if (session_status() === PHP_SESSION_ACTIVE) return;
|
||||||
|
// WAP gateways often strip cookies -> 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);
|
||||||
|
}
|
||||||
14
public_html/lib/bootstrap.php
Normal file
14
public_html/lib/bootstrap.php
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
// Single include for every page.
|
||||||
|
require_once __DIR__ . '/config.php';
|
||||||
|
require_once __DIR__ . '/db.php';
|
||||||
|
require_once __DIR__ . '/auth.php';
|
||||||
|
require_once __DIR__ . '/ui.php';
|
||||||
|
require_once __DIR__ . '/mud.php';
|
||||||
|
|
||||||
|
session_boot();
|
||||||
|
db(); // ensure schema exists
|
||||||
|
|
||||||
|
// WAP gateways cache aggressively; force revalidation.
|
||||||
|
header('Cache-Control: no-cache, no-store, must-revalidate');
|
||||||
|
header('Pragma: no-cache');
|
||||||
14
public_html/lib/config.php
Normal file
14
public_html/lib/config.php
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
// wap.txt3.net -- global configuration
|
||||||
|
define('SITE_NAME', 'txt3 WAP');
|
||||||
|
define('SITE_ROOT', dirname(__DIR__));
|
||||||
|
define('DATA_DIR', SITE_ROOT . '/data');
|
||||||
|
define('DB_FILE', DATA_DIR . '/wap.sqlite');
|
||||||
|
define('PER_PAGE', 10); // small pages: WAP decks are size limited
|
||||||
|
define('WML_MAX_TEXT', 900); // truncate long bodies on WML decks
|
||||||
|
|
||||||
|
// Set to a username that should always be admin on first boot.
|
||||||
|
define('BOOTSTRAP_ADMIN', 'admin');
|
||||||
|
define('BOOTSTRAP_ADMIN_PW', 'wapadmin');
|
||||||
|
|
||||||
|
date_default_timezone_set('Europe/London');
|
||||||
344
public_html/lib/db.php
Normal file
344
public_html/lib/db.php
Normal file
@ -0,0 +1,344 @@
|
|||||||
|
<?php
|
||||||
|
// SQLite connection + schema bootstrap
|
||||||
|
require_once __DIR__ . '/config.php';
|
||||||
|
|
||||||
|
function db(): PDO {
|
||||||
|
static $pdo = null;
|
||||||
|
if ($pdo !== null) return $pdo;
|
||||||
|
|
||||||
|
if (!is_dir(DATA_DIR)) @mkdir(DATA_DIR, 0775, true);
|
||||||
|
$fresh = !file_exists(DB_FILE);
|
||||||
|
|
||||||
|
$pdo = new PDO('sqlite:' . DB_FILE);
|
||||||
|
$pdo->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 <key>'
|
||||||
|
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]);
|
||||||
|
}
|
||||||
305
public_html/lib/mud.php
Normal file
305
public_html/lib/mud.php
Normal file
@ -0,0 +1,305 @@
|
|||||||
|
<?php
|
||||||
|
// MUD engine shared by the WAP/XHTML front-end (mud.php).
|
||||||
|
// The telnet BBS (bbs/mud.py) implements the same rules against the same
|
||||||
|
// tables, so a character, room and mob exist once and can be played from
|
||||||
|
// either front-end. All mutations go through these functions.
|
||||||
|
|
||||||
|
const MUD_START_HP = 30;
|
||||||
|
const MUD_START_ATK = 4;
|
||||||
|
const MUD_START_DEF = 2;
|
||||||
|
const MUD_RESPAWN_SEC = 30;
|
||||||
|
|
||||||
|
// RPGBBS-style classes: small combat identity + a flavour bonus.
|
||||||
|
$MUD_CLASSES = [
|
||||||
|
'fighter' => ['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."];
|
||||||
|
}
|
||||||
317
public_html/lib/ui.php
Normal file
317
public_html/lib/ui.php
Normal file
@ -0,0 +1,317 @@
|
|||||||
|
<?php
|
||||||
|
// Dual-markup rendering layer: emits WML 1.1 or XHTML depending on the client.
|
||||||
|
require_once __DIR__ . '/config.php';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- detection
|
||||||
|
function detect_mode(): string {
|
||||||
|
// Explicit override wins, and is remembered in the session.
|
||||||
|
// ?m=auto clears the override and returns to capability detection.
|
||||||
|
$force = $_GET['m'] ?? '';
|
||||||
|
if ($force === 'auto') {
|
||||||
|
unset($_SESSION['mode']);
|
||||||
|
} elseif ($force === 'wml' || $force === 'html') {
|
||||||
|
$_SESSION['mode'] = $force;
|
||||||
|
return $force;
|
||||||
|
}
|
||||||
|
if (!empty($_SESSION['mode'])) return $_SESSION['mode'];
|
||||||
|
|
||||||
|
$accept = strtolower($_SERVER['HTTP_ACCEPT'] ?? '');
|
||||||
|
$ua = strtolower($_SERVER['HTTP_USER_AGENT'] ?? '');
|
||||||
|
|
||||||
|
// Strongest signal: the client advertises WML in Accept.
|
||||||
|
if (strpos($accept, 'vnd.wap.wml') !== false) return 'wml';
|
||||||
|
|
||||||
|
// Some gateways only send x-wap-profile / wap headers.
|
||||||
|
foreach (['HTTP_X_WAP_PROFILE','HTTP_PROFILE','HTTP_WAP_CONNECTION'] as $h) {
|
||||||
|
if (!empty($_SERVER[$h])) return 'wml';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Classic WAP browser UA fingerprints.
|
||||||
|
$wapUa = ['wap','wml','openwave','up.browser','up.link','midp','j2me','symbian',
|
||||||
|
'nokia','ericsson','sonyerics','siemens','sagem','alcatel','panasonic',
|
||||||
|
'philips','sanyo','sharp','lg-','lge-','samsung-','motorola','mot-',
|
||||||
|
'blazer','avantgo','elaine','palmos','netfront','xiino','portalmmm',
|
||||||
|
'digital paths','klondike','dolfin'];
|
||||||
|
foreach ($wapUa as $frag) {
|
||||||
|
if (strpos($ua, $frag) !== false) {
|
||||||
|
// Modern smartphones match 'nokia'/'samsung' too but they always
|
||||||
|
// advertise text/html and never vnd.wap.wml, so require the absence
|
||||||
|
// of a real HTML accept when the UA looks modern.
|
||||||
|
if (strpos($ua, 'android') !== false || strpos($ua, 'iphone') !== false
|
||||||
|
|| strpos($ua, 'webkit') !== false) return 'html';
|
||||||
|
return 'wml';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'html';
|
||||||
|
}
|
||||||
|
|
||||||
|
function mode(): string {
|
||||||
|
static $m = null;
|
||||||
|
if ($m === null) $m = detect_mode();
|
||||||
|
return $m;
|
||||||
|
}
|
||||||
|
function is_wml(): bool { return mode() === 'wml'; }
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- escaping
|
||||||
|
// WML needs $ escaped as $$ because $ introduces a variable reference.
|
||||||
|
function e(string $s): string {
|
||||||
|
$s = htmlspecialchars($s, ENT_QUOTES, 'UTF-8');
|
||||||
|
if (is_wml()) $s = str_replace('$', '$$', $s);
|
||||||
|
return $s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wtrim(string $s, int $max = WML_MAX_TEXT): string {
|
||||||
|
if (!is_wml() || strlen($s) <= $max) return $s;
|
||||||
|
return substr($s, 0, $max) . "\n[...truncated, view on a larger screen]";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- urls
|
||||||
|
// WAP browsers frequently drop cookies, so the session id rides in the URL
|
||||||
|
// whenever the client did not give us a cookie back.
|
||||||
|
function url(string $path, array $q = []): string {
|
||||||
|
// Sticky mode override, but never clobber an explicit m= passed by the caller
|
||||||
|
// (the markup-toggle link depends on winning here).
|
||||||
|
if (!empty($_GET['m']) && $_GET['m'] !== 'auto' && !isset($q['m'])) $q['m'] = $_GET['m'];
|
||||||
|
// Only WML clients (cookies stripped by the gateway) need the session id
|
||||||
|
// in the URL. XHTML clients and crawlers use cookies, or need no session
|
||||||
|
// for public pages, so they get clean canonical URLs - this stops search
|
||||||
|
// engines indexing an infinite ?WAPSID=... URL space.
|
||||||
|
if (is_wml() && empty($_COOKIE[session_name()]) && session_id() !== '') {
|
||||||
|
$q[session_name()] = session_id();
|
||||||
|
}
|
||||||
|
$u = $path;
|
||||||
|
if ($q) $u .= (strpos($path, '?') === false ? '?' : '&') . http_build_query($q);
|
||||||
|
return $u;
|
||||||
|
}
|
||||||
|
function u(string $path, array $q = []): string { return e(url($path, $q)); }
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- page shell
|
||||||
|
$GLOBALS['__page_open'] = false;
|
||||||
|
|
||||||
|
function page_start(string $title, array $opt = []): void {
|
||||||
|
if ($GLOBALS['__page_open']) return;
|
||||||
|
$GLOBALS['__page_open'] = true;
|
||||||
|
$t = e($title);
|
||||||
|
|
||||||
|
if (is_wml()) {
|
||||||
|
header('Content-Type: text/vnd.wap.wml; charset=utf-8');
|
||||||
|
echo '<?xml version="1.0" encoding="utf-8"?>' . "\n";
|
||||||
|
echo '<!DOCTYPE wml PUBLIC "-//WAPFORUM//DTD WML 1.1//EN"'
|
||||||
|
. ' "http://www.wapforum.org/DTD/wml_1.1.xml">' . "\n";
|
||||||
|
echo "<wml>\n<card id=\"main\" title=\"$t\">\n";
|
||||||
|
// "Back" softkey on every deck
|
||||||
|
if (!empty($opt['back'])) {
|
||||||
|
echo '<do type="prev" label="Back"><go href="' . e(url($opt['back']))
|
||||||
|
. '"/></do>' . "\n";
|
||||||
|
}
|
||||||
|
echo "<p><b>$t</b></p>\n";
|
||||||
|
} else {
|
||||||
|
header('Content-Type: application/xhtml+xml; charset=utf-8');
|
||||||
|
echo '<?xml version="1.0" encoding="utf-8"?>' . "\n";
|
||||||
|
echo '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"'
|
||||||
|
. ' "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">' . "\n";
|
||||||
|
echo '<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en">' . "\n";
|
||||||
|
echo "<head>\n<title>$t - " . e(SITE_NAME) . "</title>\n";
|
||||||
|
echo '<meta name="viewport" content="width=device-width, initial-scale=1"/>' . "\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 '<link rel="canonical" href="' . e($canon) . '"/>' . "\n";
|
||||||
|
echo '<link rel="stylesheet" href="/style.css" type="text/css"/>' . "\n";
|
||||||
|
echo "</head>\n<body>\n<div class=\"wrap\">\n";
|
||||||
|
echo '<h1><a href="' . u('/index.php') . '">' . e(SITE_NAME) . "</a></h1>\n";
|
||||||
|
nav_bar();
|
||||||
|
echo "<h2>$t</h2>\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function nav_bar(): void {
|
||||||
|
if (is_wml()) return;
|
||||||
|
$me = current_user();
|
||||||
|
echo '<div class="nav">';
|
||||||
|
$links = [['/index.php','Home']];
|
||||||
|
if ($me) {
|
||||||
|
$n = unread_count($me['id']);
|
||||||
|
$links[] = ['/inbox.php', 'Inbox' . ($n ? " ($n)" : '')];
|
||||||
|
$links[] = ['/forum.php', 'Forum'];
|
||||||
|
$links[] = ['/games.php', 'Games'];
|
||||||
|
$links[] = ['/profile.php', 'Profile'];
|
||||||
|
$links[] = ['/about.php', 'About'];
|
||||||
|
if ($me['is_admin']) $links[] = ['/admin/index.php', 'Admin'];
|
||||||
|
$links[] = ['/logout.php', 'Logout'];
|
||||||
|
} else {
|
||||||
|
$links[] = ['/forum.php','Forum'];
|
||||||
|
$links[] = ['/login.php','Login'];
|
||||||
|
$links[] = ['/signup.php','Signup'];
|
||||||
|
$links[] = ['/about.php','About'];
|
||||||
|
}
|
||||||
|
$out = [];
|
||||||
|
foreach ($links as $l) $out[] = '<a href="' . u($l[0]) . '">' . e($l[1]) . '</a>';
|
||||||
|
echo implode(' · ', $out);
|
||||||
|
echo "</div>\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 '<p><a href="' . e(url($self, $qs)) . '">' . e($otherLbl) . "</a></p>\n";
|
||||||
|
echo "</card>\n</wml>\n";
|
||||||
|
} else {
|
||||||
|
echo '<div class="foot">' . e(SITE_NAME) . ' · '
|
||||||
|
. '<a href="' . e(url($self, $qs)) . '">' . e($otherLbl) . '</a>'
|
||||||
|
. "</div>\n</div>\n</body>\n</html>\n";
|
||||||
|
}
|
||||||
|
$GLOBALS['__page_open'] = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- primitives
|
||||||
|
function p_para(string $text, string $class = ''): void {
|
||||||
|
$t = nl2br_mode(e($text));
|
||||||
|
if (is_wml()) echo "<p>$t</p>\n";
|
||||||
|
else echo '<p' . ($class ? ' class="' . e($class) . '"' : '') . ">$t</p>\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
function nl2br_mode(string $escaped): string {
|
||||||
|
return str_replace(["\r\n", "\n"], is_wml() ? "<br/>" : "<br />", $escaped);
|
||||||
|
}
|
||||||
|
|
||||||
|
function p_err(string $t): void {
|
||||||
|
if (is_wml()) echo '<p><b>! ' . e($t) . "</b></p>\n";
|
||||||
|
else echo '<p class="err">' . e($t) . "</p>\n";
|
||||||
|
}
|
||||||
|
function p_ok(string $t): void {
|
||||||
|
if (is_wml()) echo '<p>' . e($t) . "</p>\n";
|
||||||
|
else echo '<p class="ok">' . e($t) . "</p>\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
// A vertical list of links -- one <p> per line in WML, <ul> in XHTML.
|
||||||
|
function p_links(array $links): void {
|
||||||
|
if (!$links) return;
|
||||||
|
if (is_wml()) {
|
||||||
|
foreach ($links as $l) {
|
||||||
|
echo '<p><a href="' . e(url($l[0], $l[2] ?? [])) . '">'
|
||||||
|
. e($l[1]) . "</a></p>\n";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
echo "<ul class=\"menu\">\n";
|
||||||
|
foreach ($links as $l) {
|
||||||
|
echo '<li><a href="' . e(url($l[0], $l[2] ?? [])) . '">'
|
||||||
|
. e($l[1]) . "</a></li>\n";
|
||||||
|
}
|
||||||
|
echo "</ul>\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function p_link(string $path, string $label, array $q = []): void {
|
||||||
|
$a = '<a href="' . e(url($path, $q)) . '">' . e($label) . '</a>';
|
||||||
|
echo is_wml() ? "<p>$a</p>\n" : "<p>$a</p>\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
function p_rule(): void { if (!is_wml()) echo "<hr />\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 <do> issues the POST.
|
||||||
|
foreach ($fields as $f) {
|
||||||
|
$type = $f['type'] ?? 'text';
|
||||||
|
if ($type === 'hidden') { $hidden[$f['name']] = $f['value'] ?? ''; continue; }
|
||||||
|
echo '<p>' . e($f['label']) . ':<br/>';
|
||||||
|
if ($type === 'select') {
|
||||||
|
echo '<select name="' . e($f['name']) . '">';
|
||||||
|
foreach (($f['options'] ?? []) as $v => $lab) {
|
||||||
|
echo '<option value="' . e((string)$v) . '">' . e((string)$lab) . '</option>';
|
||||||
|
}
|
||||||
|
echo '</select>';
|
||||||
|
} else {
|
||||||
|
// WML has no textarea; a plain input is the portable choice.
|
||||||
|
echo '<input name="' . e($f['name']) . '"';
|
||||||
|
if ($type === 'password') echo ' type="password"';
|
||||||
|
if (!empty($f['maxlength'])) echo ' maxlength="' . (int)$f['maxlength'] . '"';
|
||||||
|
if (!empty($f['format'])) echo ' format="' . e($f['format']) . '"';
|
||||||
|
if (isset($f['value']) && $f['value'] !== '')
|
||||||
|
echo ' value="' . e((string)$f['value']) . '"';
|
||||||
|
echo '/>';
|
||||||
|
}
|
||||||
|
echo "</p>\n";
|
||||||
|
}
|
||||||
|
echo '<do type="accept" label="' . e($submit) . '">' . "\n";
|
||||||
|
echo ' <go href="' . e(url($action)) . '" method="post">' . "\n";
|
||||||
|
foreach ($hidden as $k => $v) {
|
||||||
|
echo ' <postfield name="' . e((string)$k) . '" value="' . e((string)$v) . '"/>' . "\n";
|
||||||
|
}
|
||||||
|
foreach ($fields as $f) {
|
||||||
|
if (($f['type'] ?? 'text') === 'hidden') continue;
|
||||||
|
echo ' <postfield name="' . e($f['name']) . '" value="$(' . e($f['name']) . ')"/>' . "\n";
|
||||||
|
}
|
||||||
|
echo " </go>\n</do>\n";
|
||||||
|
} else {
|
||||||
|
echo '<form method="post" action="' . e(url($action)) . '">' . "\n<div>\n";
|
||||||
|
foreach ($hidden as $k => $v) {
|
||||||
|
echo '<input type="hidden" name="' . e((string)$k) . '" value="' . e((string)$v) . '"/>' . "\n";
|
||||||
|
}
|
||||||
|
foreach ($fields as $f) {
|
||||||
|
$type = $f['type'] ?? 'text';
|
||||||
|
$nm = e($f['name']);
|
||||||
|
if ($type === 'hidden') {
|
||||||
|
echo '<input type="hidden" name="' . $nm . '" value="' . e((string)($f['value'] ?? '')) . '"/>' . "\n";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
echo '<p><label for="f_' . $nm . '">' . e($f['label']) . "</label><br />\n";
|
||||||
|
if ($type === 'textarea') {
|
||||||
|
echo '<textarea id="f_' . $nm . '" name="' . $nm . '" rows="6" cols="40">'
|
||||||
|
. e((string)($f['value'] ?? '')) . "</textarea>";
|
||||||
|
} elseif ($type === 'select') {
|
||||||
|
echo '<select id="f_' . $nm . '" name="' . $nm . '">';
|
||||||
|
foreach (($f['options'] ?? []) as $v => $lab) {
|
||||||
|
$sel = ((string)($f['value'] ?? '') === (string)$v) ? ' selected="selected"' : '';
|
||||||
|
echo '<option value="' . e((string)$v) . '"' . $sel . '>' . e((string)$lab) . '</option>';
|
||||||
|
}
|
||||||
|
echo '</select>';
|
||||||
|
} else {
|
||||||
|
echo '<input id="f_' . $nm . '" type="' . ($type === 'password' ? 'password' : 'text')
|
||||||
|
. '" name="' . $nm . '" value="' . e((string)($f['value'] ?? '')) . '"';
|
||||||
|
if (!empty($f['maxlength'])) echo ' maxlength="' . (int)$f['maxlength'] . '"';
|
||||||
|
echo '/>';
|
||||||
|
}
|
||||||
|
echo "</p>\n";
|
||||||
|
}
|
||||||
|
echo '<p><input type="submit" value="' . e($submit) . '"/></p>' . "\n";
|
||||||
|
echo "</div>\n</form>\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;
|
||||||
|
}
|
||||||
20
public_html/login.php
Normal file
20
public_html/login.php
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/lib/bootstrap.php';
|
||||||
|
|
||||||
|
$err = '';
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
require_csrf();
|
||||||
|
$n = trim((string)($_POST['username'] ?? ''));
|
||||||
|
$p = (string)($_POST['pass'] ?? '');
|
||||||
|
if (user_login($n, $p)) redirect('/index.php');
|
||||||
|
$err = 'Bad username or password (or account banned).';
|
||||||
|
}
|
||||||
|
|
||||||
|
page_start('Log in', ['back' => '/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();
|
||||||
8
public_html/logout.php
Normal file
8
public_html/logout.php
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/lib/bootstrap.php';
|
||||||
|
$_SESSION['uid'] = null;
|
||||||
|
unset($_SESSION['uid']);
|
||||||
|
page_start('Logged out', ['back' => '/index.php']);
|
||||||
|
p_ok('You are logged out.');
|
||||||
|
p_links([['/index.php', 'Home'], ['/login.php', 'Log in again']]);
|
||||||
|
page_end();
|
||||||
35
public_html/message.php
Normal file
35
public_html/message.php
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/lib/bootstrap.php';
|
||||||
|
$me = require_login();
|
||||||
|
$id = (int)($_GET['id'] ?? 0);
|
||||||
|
|
||||||
|
$s = db()->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();
|
||||||
242
public_html/mud.php
Normal file
242
public_html/mud.php
Normal file
@ -0,0 +1,242 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/lib/bootstrap.php';
|
||||||
|
$me = require_login();
|
||||||
|
|
||||||
|
// ensure the MUD tables + world exist even on a long-running install
|
||||||
|
db(); // triggers migrate + seed_mud
|
||||||
|
|
||||||
|
$c = mud_char_for($me['id']);
|
||||||
|
if (!$c) {
|
||||||
|
// first time: pick a name + class
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && !empty($_POST['cname'])) {
|
||||||
|
require_csrf();
|
||||||
|
$nm = trim(substr($_POST['cname'], 0, 16));
|
||||||
|
$cls = $_POST['class'] ?? 'fighter';
|
||||||
|
if (preg_match('/^[\w ]{2,16}$/', $nm)) {
|
||||||
|
$c = mud_create_char($me, $nm, $cls);
|
||||||
|
mud_echo("$nm enters the village.", 1);
|
||||||
|
} else {
|
||||||
|
$err = "Name must be 2-16 letters/digits/space.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!$c) {
|
||||||
|
page_start('MUD - New Character');
|
||||||
|
if (!empty($err)) p_err($err);
|
||||||
|
p_para('Create your adventurer. You can play here on WAP or live on the telnet BBS.');
|
||||||
|
p_form('/mud.php', [
|
||||||
|
['name' => '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 <amount> to deposit, or bank -<amount> 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 <player> <amount>'; 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 <mob>, attack <player>, take <item>, "
|
||||||
|
. "wear/wield <item>, drink <potion>, buy <item>, bank <+/->amt, tax, "
|
||||||
|
. "bounty <player> <amt>, 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();
|
||||||
13
public_html/page1.php
Normal file
13
public_html/page1.php
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE wml PUBLIC "-//WAPFORUM//DTD WML 1.1//EN" "http://www.wapforum.org/DTD/wml_1.1.xml">
|
||||||
|
<wml>
|
||||||
|
<card id="intro" title="Introduction">
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<a href="page2.php">Next: Service Evolution</a>
|
||||||
|
</p>
|
||||||
|
</card>
|
||||||
|
</wml>
|
||||||
|
|
||||||
17
public_html/page2.php
Normal file
17
public_html/page2.php
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE wml PUBLIC "-//WAPFORUM//DTD WML 1.1//EN" "http://www.wapforum.org/DTD/wml_1.1.xml">
|
||||||
|
<wml>
|
||||||
|
<card id="evolution" title="Service Evolution">
|
||||||
|
<p>
|
||||||
|
In the early 2010s, txt3 expanded its offerings to include:
|
||||||
|
<ul>
|
||||||
|
<li>Group messaging</li>
|
||||||
|
<li>Community forums</li>
|
||||||
|
</ul>
|
||||||
|
These enhancements aimed to foster a more interactive and engaging user experience.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<a href="page3.php">Next: Current Status</a>
|
||||||
|
</p>
|
||||||
|
</card>
|
||||||
|
</wml>
|
||||||
13
public_html/page3.php
Normal file
13
public_html/page3.php
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE wml PUBLIC "-//WAPFORUM//DTD WML 1.1//EN" "http://www.wapforum.org/DTD/wml_1.1.xml">
|
||||||
|
<wml>
|
||||||
|
<card id="current_status" title="Current Status">
|
||||||
|
<p>
|
||||||
|
As of now, txt3.com hosts a Matrix chat server, providing secure and decentralized communication services, including group chats, communities, and audio/video calls.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<a href="page4.php">Next: Domain Information</a>
|
||||||
|
</p>
|
||||||
|
</card>
|
||||||
|
</wml>
|
||||||
|
|
||||||
14
public_html/page4.php
Normal file
14
public_html/page4.php
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE wml PUBLIC "-//WAPFORUM//DTD WML 1.1//EN" "http://www.wapforum.org/DTD/wml_1.1.xml">
|
||||||
|
<wml>
|
||||||
|
<card id="domain_info" title="Domain Information">
|
||||||
|
<p>
|
||||||
|
- txt3.com: Established in 1997, currently hosting a Matrix chat server.<br/>
|
||||||
|
- txt3.net: Operational since at least 2013, also providing free SMS services.<br/>
|
||||||
|
- txt3.co.uk: Registered on June 25, 1999, with the registrar (AQ) Limited.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<a href="index.php">Back to Menu</a>
|
||||||
|
</p>
|
||||||
|
</card>
|
||||||
|
</wml>
|
||||||
30
public_html/password.php
Normal file
30
public_html/password.php
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/lib/bootstrap.php';
|
||||||
|
$me = require_login();
|
||||||
|
$err = ''; $ok = '';
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
require_csrf();
|
||||||
|
$old = (string)($_POST['old'] ?? '');
|
||||||
|
$n1 = (string)($_POST['new1'] ?? '');
|
||||||
|
$n2 = (string)($_POST['new2'] ?? '');
|
||||||
|
if (!password_verify($old, $me['pass_hash'])) $err = 'Current password wrong.';
|
||||||
|
elseif (strlen($n1) < 4) $err = 'New password too short.';
|
||||||
|
elseif ($n1 !== $n2) $err = 'New passwords do not match.';
|
||||||
|
else {
|
||||||
|
db()->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();
|
||||||
54
public_html/profile.php
Normal file
54
public_html/profile.php
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/lib/bootstrap.php';
|
||||||
|
$me = current_user();
|
||||||
|
|
||||||
|
$id = (int)($_GET['id'] ?? 0);
|
||||||
|
if ($id === 0) { $me = require_login(); $id = (int)$me['id']; }
|
||||||
|
$u = user_by_id($id);
|
||||||
|
if (!$u) bail('Profile', 'No such user.');
|
||||||
|
$mine = $me && (int)$me['id'] === $id;
|
||||||
|
|
||||||
|
// ---- edit handling ----
|
||||||
|
$msg = '';
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $mine) {
|
||||||
|
require_csrf();
|
||||||
|
if (isset($_POST['tagline'])) {
|
||||||
|
$tag = mb_substr(trim((string)$_POST['tagline']), 0, 80);
|
||||||
|
$loc = mb_substr(trim((string)($_POST['location'] ?? '')), 0, 40);
|
||||||
|
db()->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();
|
||||||
8
public_html/robots.txt
Normal file
8
public_html/robots.txt
Normal file
@ -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
|
||||||
29
public_html/signup.php
Normal file
29
public_html/signup.php
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/lib/bootstrap.php';
|
||||||
|
|
||||||
|
$err = '';
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
require_csrf();
|
||||||
|
$n = trim((string)($_POST['username'] ?? ''));
|
||||||
|
$p1 = (string)($_POST['pass'] ?? '');
|
||||||
|
$p2 = (string)($_POST['pass2'] ?? '');
|
||||||
|
if (!valid_username($n)) $err = 'Username: 3-16 letters, digits or underscore.';
|
||||||
|
elseif (strlen($p1) < 4) $err = 'Password must be at least 4 characters.';
|
||||||
|
elseif ($p1 !== $p2) $err = 'Passwords do not match.';
|
||||||
|
elseif (username_taken($n)) $err = 'That username is taken.';
|
||||||
|
else {
|
||||||
|
$id = user_create($n, $p1);
|
||||||
|
$_SESSION['uid'] = $id;
|
||||||
|
redirect('/index.php');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
page_start('Sign up', ['back' => '/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();
|
||||||
34
public_html/sitemap.php
Normal file
34
public_html/sitemap.php
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
// Dynamic XML sitemap: public pages, plus one entry per forum and per topic.
|
||||||
|
require_once __DIR__ . '/lib/config.php';
|
||||||
|
|
||||||
|
$base = 'https://wap.txt3.net';
|
||||||
|
$db = new PDO('sqlite:' . DB_FILE, null, null, [
|
||||||
|
PDO::ATTR_ERRMODE => 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 '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
|
||||||
|
echo '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";
|
||||||
|
foreach ($urls as $u) {
|
||||||
|
echo ' <url>' . "\n";
|
||||||
|
echo ' <loc>' . htmlspecialchars($u['loc'], ENT_XML1, 'UTF-8') . '</loc>' . "\n";
|
||||||
|
echo ' <changefreq>' . $u['changefreq'] . '</changefreq>' . "\n";
|
||||||
|
echo ' <priority>' . $u['priority'] . '</priority>' . "\n";
|
||||||
|
echo ' </url>' . "\n";
|
||||||
|
}
|
||||||
|
echo '</urlset>' . "\n";
|
||||||
33
public_html/style.css
Normal file
33
public_html/style.css
Normal file
@ -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; }
|
||||||
83
public_html/topic.php
Normal file
83
public_html/topic.php
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/lib/bootstrap.php';
|
||||||
|
$me = current_user();
|
||||||
|
$tid = (int)($_GET['t'] ?? 0);
|
||||||
|
|
||||||
|
$s = db()->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();
|
||||||
23
public_html/users.php
Normal file
23
public_html/users.php
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/lib/bootstrap.php';
|
||||||
|
$me = current_user();
|
||||||
|
$page = max(1, (int)($_GET['p'] ?? 1));
|
||||||
|
$off = ($page - 1) * PER_PAGE;
|
||||||
|
|
||||||
|
$s = db()->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();
|
||||||
Reference in New Issue
Block a user