First git commit

This commit is contained in:
root
2026-09-08 18:21:10 +01:00
commit 66911dc450
65 changed files with 7012 additions and 0 deletions

17
bbs/bbs.log Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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();

View 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();

View 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();

View 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
View 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();

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

241
public_html/bbs/bbsd.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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

View 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
View 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

Binary file not shown.

87
public_html/forum.php Normal file
View 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
View 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();

View 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();

View 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
View 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();

View 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();

View 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
View 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();

View File

@ -0,0 +1 @@
google-site-verification: REPLACE_WITH_YOUR_GOOGLE_VERIFICATION_CODE

46
public_html/inbox.php Normal file
View 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

File diff suppressed because one or more lines are too long

41
public_html/index.php Normal file
View 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
View 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);
}

View 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');

View 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
View 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
View 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
View 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(' &#183; ', $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) . ' &#183; '
. '<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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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();