First git commit
This commit is contained in:
17
bbs/bbs.log
Normal file
17
bbs/bbs.log
Normal file
@ -0,0 +1,17 @@
|
||||
[2026-08-17 21:00:54] txt3 BBS listening on 100.127.96.105:12300 (db=/home/txt3/domains/wap.txt3.net/public_html/data/wap.sqlite)
|
||||
[2026-08-17 21:01:27] connect from 100.127.96.105
|
||||
[2026-08-17 21:01:29] disconnect 100.127.96.105
|
||||
[2026-08-17 21:02:36] connect from 100.127.96.105
|
||||
[2026-08-17 21:02:41] disconnect 100.127.96.105
|
||||
[2026-08-17 21:04:03] connect from 100.127.96.105
|
||||
[2026-08-17 21:04:09] disconnect 100.127.96.105
|
||||
[2026-08-17 21:04:31] connect from 100.83.66.92
|
||||
[2026-08-17 21:16:16] hangup 100.83.66.92
|
||||
[2026-08-17 21:16:16] disconnect 100.83.66.92
|
||||
[2026-08-17 22:10:28] txt3 BBS listening on 100.127.96.105:12300 (db=/home/txt3/domains/wap.txt3.net/public_html/data/wap.sqlite)
|
||||
[2026-08-17 22:11:15] connect from 100.127.96.105
|
||||
[2026-08-17 22:11:25] disconnect 100.127.96.105
|
||||
[2026-08-18 14:54:13] txt3 BBS listening on 100.127.96.105:12300 (db=/home/txt3/domains/wap.txt3.net/public_html/data/wap.sqlite)
|
||||
[2026-08-18 14:55:31] connect from 100.127.96.105
|
||||
[2026-08-18 14:55:46] disconnect 100.127.96.105
|
||||
[2026-08-18 15:23:26] txt3 BBS listening on 100.127.96.105:12300 (db=/home/txt3/domains/wap.txt3.net/public_html/data/wap.sqlite)
|
||||
241
bbs/bbsd.py
Normal file
241
bbs/bbsd.py
Normal file
@ -0,0 +1,241 @@
|
||||
#!/usr/bin/env python3
|
||||
"""txt3 BBS - telnet front-end for wap.txt3.net.
|
||||
|
||||
Shares the SQLite database with the WML/XHTML site: one account works on all
|
||||
three front-ends, and multiplayer matches are cross-playable between them.
|
||||
|
||||
Binds to the tailscale interface only by default.
|
||||
"""
|
||||
import os
|
||||
import socket
|
||||
import socketserver
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
import bbsdb # noqa: E402
|
||||
from bbsdb import connect, q1, login, create_user, user_by_name, touch # noqa: E402
|
||||
from term import Term, Hangup # noqa: E402
|
||||
import boards # noqa: E402
|
||||
import games # noqa: E402
|
||||
import mud # noqa: E402
|
||||
import screens # noqa: E402
|
||||
|
||||
HOST = os.environ.get("BBS_HOST", "100.127.96.105")
|
||||
PORT = int(os.environ.get("BBS_PORT", "12300"))
|
||||
MAX_SESSIONS = int(os.environ.get("BBS_MAX", "20"))
|
||||
|
||||
_sessions = threading.BoundedSemaphore(MAX_SESSIONS)
|
||||
|
||||
BANNER = r"""
|
||||
_ _ _____ ____ ____ ____
|
||||
| |___ _| |_|___ / | __ )| __ ) ___|
|
||||
| __\ \/ / __| |_ \ | _ \| _ \___ \
|
||||
| |_ > <| |_ ___) | | |_) | |_) |__) |
|
||||
\__/_/\_\\__|____/ |____/|____/____/
|
||||
"""
|
||||
|
||||
|
||||
def log(msg):
|
||||
sys.stdout.write("[%s] %s\n" % (time.strftime("%Y-%m-%d %H:%M:%S"), msg))
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def welcome(t, con):
|
||||
t.write(BANNER)
|
||||
motd = q1(con, "SELECT v FROM settings WHERE k='motd'")
|
||||
t.line(" " + (motd["v"] if motd else "Welcome!"))
|
||||
users = q1(con, "SELECT COUNT(*) c FROM users")["c"]
|
||||
posts = q1(con, "SELECT COUNT(*) c FROM posts")["c"]
|
||||
t.line(" %d members, %d posts. Also on the web: https://wap.txt3.net" % (users, posts))
|
||||
t.rule("=")
|
||||
|
||||
|
||||
def do_login(t, con):
|
||||
"""Returns a user row, or None if the caller gave up."""
|
||||
for _ in range(3):
|
||||
t.line("\n [L]ogin [N]ew user [G]uest look around [Q]uit")
|
||||
c = t.ask("\n> ").lower()
|
||||
if c.startswith("l"):
|
||||
name = t.ask(" Username: ", maxlen=16)
|
||||
if not name:
|
||||
continue
|
||||
pw = t.secret(" Password: ")
|
||||
u = login(con, name, pw)
|
||||
if u:
|
||||
t.line("\n Welcome back, %s." % u["username"])
|
||||
return u
|
||||
t.line(" Login failed (bad details, or account banned).")
|
||||
elif c.startswith("n"):
|
||||
u = do_signup(t, con)
|
||||
if u:
|
||||
return u
|
||||
elif c.startswith("g"):
|
||||
return "guest"
|
||||
elif c.startswith("q"):
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def do_signup(t, con):
|
||||
t.line("\n New account:")
|
||||
name = t.ask(" Choose a username (3-16, letters/digits/_): ", maxlen=16)
|
||||
if not name:
|
||||
return None
|
||||
if not name.replace("_", "").isalnum() or not 3 <= len(name) <= 16:
|
||||
t.line(" Invalid username.")
|
||||
return None
|
||||
if user_by_name(con, name):
|
||||
t.line(" That name is taken.")
|
||||
return None
|
||||
pw = t.secret(" Choose a password (min 4): ")
|
||||
if len(pw) < 4:
|
||||
t.line(" Too short.")
|
||||
return None
|
||||
if pw != t.secret(" Repeat password: "):
|
||||
t.line(" Passwords did not match.")
|
||||
return None
|
||||
uid = create_user(con, name, pw)
|
||||
if not uid:
|
||||
t.line(" Could not create the account (hashing failed).")
|
||||
return None
|
||||
t.line("\n Account created. It works on https://wap.txt3.net too.")
|
||||
return bbsdb.user_by_id(con, uid)
|
||||
|
||||
|
||||
def guest_menu(t, con):
|
||||
while True:
|
||||
t.header("GUEST - read only")
|
||||
t.line(" [F]orums (read) [U]ser list [S]cores [L]ogin/signup [Q]uit")
|
||||
c = t.ask("\nguest> ").lower()
|
||||
if c.startswith("f"):
|
||||
boards.forum_menu(t, con, {"id": -1, "is_admin": 0, "username": "guest"})
|
||||
elif c.startswith("u"):
|
||||
screens.userlist(t, con)
|
||||
elif c.startswith("s"):
|
||||
games.scores(t, con)
|
||||
elif c.startswith("l"):
|
||||
return "login"
|
||||
elif c.startswith("q") or c == "":
|
||||
return None
|
||||
|
||||
|
||||
def main_menu(t, con, me):
|
||||
while True:
|
||||
me = bbsdb.user_by_id(con, me["id"])
|
||||
if not me or me["is_banned"]:
|
||||
t.line("\n Your account is no longer active. Goodbye.")
|
||||
return
|
||||
touch(con, me["id"])
|
||||
n = bbsdb.unread(con, me["id"])
|
||||
t.header("MAIN MENU - %s%s" % (me["username"],
|
||||
" [admin]" if me["is_admin"] else ""))
|
||||
t.line(" [M]ail %s" % ("(%d unread)" % n if n else ""))
|
||||
t.line(" [F]orums")
|
||||
t.line(" [G]ames")
|
||||
t.line(" [D]ungeon - explore a shared world (also on the web)")
|
||||
t.line(" [P]rofile")
|
||||
t.line(" [U]ser list")
|
||||
if me["is_admin"]:
|
||||
t.line(" [A]dmin tools")
|
||||
t.line(" [Q]uit")
|
||||
c = t.ask("\nmain> ").lower()
|
||||
if c.startswith("m"):
|
||||
boards.mail_menu(t, con, me)
|
||||
elif c.startswith("f"):
|
||||
boards.forum_menu(t, con, me)
|
||||
elif c.startswith("g"):
|
||||
games.games_menu(t, con, me)
|
||||
elif c.startswith("d"):
|
||||
mud.mud_menu(t, con, me)
|
||||
elif c.startswith("p"):
|
||||
screens.profile_menu(t, con, me)
|
||||
elif c.startswith("u"):
|
||||
screens.userlist(t, con)
|
||||
elif c.startswith("a") and me["is_admin"]:
|
||||
screens.admin_menu(t, con, me)
|
||||
elif c.startswith("q"):
|
||||
return
|
||||
|
||||
|
||||
class Handler(socketserver.BaseRequestHandler):
|
||||
def handle(self):
|
||||
if not _sessions.acquire(blocking=False):
|
||||
try:
|
||||
self.request.sendall(b"\r\nBBS is full, try later.\r\n")
|
||||
self.request.close()
|
||||
except OSError:
|
||||
pass
|
||||
return
|
||||
con = None
|
||||
t = None
|
||||
ip = self.client_address[0]
|
||||
log("connect from %s" % ip)
|
||||
try:
|
||||
t = Term(self.request, self.client_address)
|
||||
con = connect()
|
||||
welcome(t, con)
|
||||
while True:
|
||||
who = do_login(t, con)
|
||||
if who is None:
|
||||
break
|
||||
if who == "guest":
|
||||
if guest_menu(t, con) == "login":
|
||||
continue
|
||||
break
|
||||
main_menu(t, con, who)
|
||||
break
|
||||
if t:
|
||||
t.line("\n Goodbye - 73s from txt3 BBS.\n")
|
||||
except Hangup:
|
||||
log("hangup %s" % ip)
|
||||
except Exception as e: # keep one session's bug local
|
||||
log("ERROR %s: %r" % (ip, e))
|
||||
try:
|
||||
t.line("\n Internal error - disconnecting.\n")
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
if con is not None:
|
||||
try:
|
||||
con.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self.request.close()
|
||||
except OSError:
|
||||
pass
|
||||
_sessions.release()
|
||||
log("disconnect %s" % ip)
|
||||
|
||||
|
||||
class Server(socketserver.ThreadingTCPServer):
|
||||
allow_reuse_address = True
|
||||
daemon_threads = True
|
||||
address_family = socket.AF_INET
|
||||
|
||||
|
||||
def main():
|
||||
# Fail loudly if the shared database is not reachable.
|
||||
try:
|
||||
con = connect()
|
||||
con.execute("SELECT 1 FROM users LIMIT 1")
|
||||
con.close()
|
||||
except Exception as e:
|
||||
log("FATAL: cannot open database %s: %r" % (bbsdb.DB_PATH, e))
|
||||
return 1
|
||||
srv = Server((HOST, PORT), Handler)
|
||||
log("txt3 BBS listening on %s:%d (db=%s)" % (HOST, PORT, bbsdb.DB_PATH))
|
||||
try:
|
||||
srv.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
log("shutting down")
|
||||
finally:
|
||||
srv.server_close()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
126
bbs/bbsdb.py
Normal file
126
bbs/bbsdb.py
Normal file
@ -0,0 +1,126 @@
|
||||
"""Shared data layer for the txt3 BBS (telnet front-end).
|
||||
|
||||
Talks to the exact same SQLite database as the WAP/XHTML site, so accounts,
|
||||
mail, forums and scores are identical across all three front-ends.
|
||||
|
||||
Password hashes are PHP `password_hash()` bcrypt ($2y$) values. Rather than
|
||||
depend on a bcrypt wheel, we verify/create them by calling the php binary,
|
||||
passing values through the environment so nothing is ever interpolated into
|
||||
a shell string.
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
DB_PATH = os.environ.get(
|
||||
"BBS_DB", "/home/txt3/domains/wap.txt3.net/public_html/data/wap.sqlite"
|
||||
)
|
||||
PHP_BIN = os.environ.get("BBS_PHP", "/usr/bin/php")
|
||||
|
||||
|
||||
def connect():
|
||||
con = sqlite3.connect(DB_PATH, timeout=10)
|
||||
con.row_factory = sqlite3.Row
|
||||
con.execute("PRAGMA journal_mode = WAL")
|
||||
con.execute("PRAGMA foreign_keys = ON")
|
||||
con.execute("PRAGMA busy_timeout = 5000")
|
||||
return con
|
||||
|
||||
|
||||
def q(con, sql, args=()):
|
||||
return con.execute(sql, args).fetchall()
|
||||
|
||||
|
||||
def q1(con, sql, args=()):
|
||||
r = con.execute(sql, args).fetchone()
|
||||
return r
|
||||
|
||||
|
||||
def ex(con, sql, args=()):
|
||||
cur = con.execute(sql, args)
|
||||
con.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- passwords
|
||||
def _php(code, env):
|
||||
e = dict(os.environ)
|
||||
e.update(env)
|
||||
try:
|
||||
out = subprocess.run(
|
||||
[PHP_BIN, "-r", code],
|
||||
capture_output=True, text=True, timeout=10, env=e,
|
||||
)
|
||||
return out.stdout.strip()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def verify_password(plain, hashed):
|
||||
return _php(
|
||||
'echo password_verify(getenv("BP_P"), getenv("BP_H")) ? "1" : "0";',
|
||||
{"BP_P": plain, "BP_H": hashed},
|
||||
) == "1"
|
||||
|
||||
|
||||
def hash_password(plain):
|
||||
h = _php(
|
||||
'echo password_hash(getenv("BP_P"), PASSWORD_DEFAULT);',
|
||||
{"BP_P": plain},
|
||||
)
|
||||
return h or None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- users
|
||||
def user_by_name(con, name):
|
||||
return q1(con, "SELECT * FROM users WHERE username=? COLLATE NOCASE", (name,))
|
||||
|
||||
|
||||
def user_by_id(con, uid):
|
||||
return q1(con, "SELECT * FROM users WHERE id=?", (uid,))
|
||||
|
||||
|
||||
def login(con, name, password):
|
||||
u = user_by_name(con, name)
|
||||
if not u or u["is_banned"]:
|
||||
return None
|
||||
if not verify_password(password, u["pass_hash"]):
|
||||
return None
|
||||
ex(con, "UPDATE users SET last_seen=? WHERE id=?", (int(time.time()), u["id"]))
|
||||
return u
|
||||
|
||||
|
||||
def create_user(con, name, password):
|
||||
h = hash_password(password)
|
||||
if not h:
|
||||
return None
|
||||
now = int(time.time())
|
||||
uid = ex(
|
||||
con,
|
||||
"INSERT INTO users (username,pass_hash,is_admin,created_at,last_seen)"
|
||||
" VALUES (?,?,0,?,?)",
|
||||
(name, h, now, now),
|
||||
)
|
||||
return uid
|
||||
|
||||
|
||||
def touch(con, uid):
|
||||
ex(con, "UPDATE users SET last_seen=? WHERE id=?", (int(time.time()), uid))
|
||||
|
||||
|
||||
def unread(con, uid):
|
||||
return q1(con, "SELECT COUNT(*) c FROM messages WHERE to_id=? AND is_read=0",
|
||||
(uid,))["c"]
|
||||
|
||||
|
||||
def ago(ts):
|
||||
d = max(0, int(time.time()) - int(ts or 0))
|
||||
if d < 60:
|
||||
return "%ds" % d
|
||||
if d < 3600:
|
||||
return "%dm" % (d // 60)
|
||||
if d < 86400:
|
||||
return "%dh" % (d // 3600)
|
||||
return "%dd" % (d // 86400)
|
||||
250
bbs/boards.py
Normal file
250
bbs/boards.py
Normal file
@ -0,0 +1,250 @@
|
||||
"""Private mail + forum screens for the BBS."""
|
||||
import time
|
||||
from bbsdb import q, q1, ex, user_by_name, unread, ago
|
||||
|
||||
PER_PAGE = 10
|
||||
|
||||
|
||||
# ==================================================================== mail
|
||||
def mail_menu(t, con, me):
|
||||
while True:
|
||||
n = unread(con, me["id"])
|
||||
t.header("MAIL (%d unread)" % n)
|
||||
t.line(" [I]nbox [S]ent [W]rite [Q]uit to main")
|
||||
c = t.ask("\nmail> ").lower()
|
||||
if c.startswith("i"):
|
||||
mail_list(t, con, me, "in")
|
||||
elif c.startswith("s"):
|
||||
mail_list(t, con, me, "out")
|
||||
elif c.startswith("w"):
|
||||
mail_write(t, con, me)
|
||||
elif c.startswith("q") or c == "":
|
||||
return
|
||||
|
||||
|
||||
def mail_list(t, con, me, box):
|
||||
page = 0
|
||||
while True:
|
||||
off = page * PER_PAGE
|
||||
if box == "in":
|
||||
rows = q(con, "SELECT m.*, u.username who FROM messages m "
|
||||
"LEFT JOIN users u ON u.id=m.from_id "
|
||||
"WHERE m.to_id=? ORDER BY m.id DESC LIMIT ? OFFSET ?",
|
||||
(me["id"], PER_PAGE + 1, off))
|
||||
else:
|
||||
rows = q(con, "SELECT m.*, u.username who FROM messages m "
|
||||
"LEFT JOIN users u ON u.id=m.to_id "
|
||||
"WHERE m.from_id=? ORDER BY m.id DESC LIMIT ? OFFSET ?",
|
||||
(me["id"], PER_PAGE + 1, off))
|
||||
more = len(rows) > PER_PAGE
|
||||
rows = rows[:PER_PAGE]
|
||||
|
||||
t.header("INBOX" if box == "in" else "SENT")
|
||||
if not rows:
|
||||
t.line(" (empty)")
|
||||
for i, r in enumerate(rows, 1):
|
||||
flag = "*" if (box == "in" and not r["is_read"]) else " "
|
||||
t.line(" %s%2d. %-12s %-28s %s" % (
|
||||
flag, i, (r["who"] or "[gone]")[:12], r["subject"][:28],
|
||||
ago(r["created_at"])))
|
||||
t.line("\n number=read [N]ext [P]rev [Q]back")
|
||||
c = t.ask("\nmail> ").lower()
|
||||
if c.isdigit() and 1 <= int(c) <= len(rows):
|
||||
mail_read(t, con, me, rows[int(c) - 1]["id"], box)
|
||||
elif c.startswith("n") and more:
|
||||
page += 1
|
||||
elif c.startswith("p") and page > 0:
|
||||
page -= 1
|
||||
elif c.startswith("q") or c == "":
|
||||
return
|
||||
|
||||
|
||||
def mail_read(t, con, me, mid, box):
|
||||
m = q1(con, "SELECT m.*, f.username fromname, o.username toname FROM messages m "
|
||||
"LEFT JOIN users f ON f.id=m.from_id "
|
||||
"LEFT JOIN users o ON o.id=m.to_id WHERE m.id=?", (mid,))
|
||||
if not m or me["id"] not in (m["to_id"], m["from_id"]):
|
||||
t.line("Not found.")
|
||||
return
|
||||
if m["to_id"] == me["id"] and not m["is_read"]:
|
||||
ex(con, "UPDATE messages SET is_read=1 WHERE id=?", (mid,))
|
||||
|
||||
t.header("MSG: " + m["subject"][:40])
|
||||
t.line(" From: %s" % (m["fromname"] or "[gone]"))
|
||||
t.line(" To : %s" % (m["toname"] or "[gone]"))
|
||||
t.line(" Date: %s" % time.strftime("%d/%m/%Y %H:%M",
|
||||
time.localtime(m["created_at"])))
|
||||
t.rule()
|
||||
for ln in m["body"].splitlines() or [""]:
|
||||
t.line(" " + ln)
|
||||
t.rule()
|
||||
t.line(" [R]eply [D]elete [Q]back")
|
||||
c = t.ask("\nmsg> ").lower()
|
||||
if c.startswith("r") and m["fromname"]:
|
||||
subj = m["subject"]
|
||||
if not subj.lower().startswith("re:"):
|
||||
subj = "Re: " + subj
|
||||
mail_write(t, con, me, to=m["fromname"], subject=subj)
|
||||
elif c.startswith("d"):
|
||||
ex(con, "DELETE FROM messages WHERE id=? AND (to_id=? OR from_id=?)",
|
||||
(mid, me["id"], me["id"]))
|
||||
t.line("Deleted.")
|
||||
|
||||
|
||||
def mail_write(t, con, me, to=None, subject=None):
|
||||
t.header("WRITE MAIL")
|
||||
if to is None:
|
||||
to = t.ask(" To (username): ", maxlen=16)
|
||||
else:
|
||||
t.line(" To: " + to)
|
||||
if not to:
|
||||
return
|
||||
dest = user_by_name(con, to)
|
||||
if not dest:
|
||||
t.line(" No such user.")
|
||||
return
|
||||
if dest["id"] == me["id"]:
|
||||
t.line(" You cannot mail yourself.")
|
||||
return
|
||||
if subject is None:
|
||||
subject = t.ask(" Subject: ", maxlen=60)
|
||||
else:
|
||||
t.line(" Subject: " + subject)
|
||||
if not subject:
|
||||
return
|
||||
t.line(" Body - end with a single '.' on its own line:")
|
||||
body = read_body(t)
|
||||
if not body.strip():
|
||||
t.line(" Aborted (empty).")
|
||||
return
|
||||
ex(con, "INSERT INTO messages (from_id,to_id,subject,body,created_at) "
|
||||
"VALUES (?,?,?,?,?)",
|
||||
(me["id"], dest["id"], subject, body[:4000], int(time.time())))
|
||||
t.line(" Sent to %s." % dest["username"])
|
||||
|
||||
|
||||
def read_body(t, maxlines=40):
|
||||
"""Multi-line entry terminated by a lone '.' - the classic BBS editor."""
|
||||
lines = []
|
||||
while len(lines) < maxlines:
|
||||
ln = t.read(" | ", maxlen=200)
|
||||
if ln == ".":
|
||||
break
|
||||
lines.append(ln)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ==================================================================== forum
|
||||
def forum_menu(t, con, me):
|
||||
while True:
|
||||
rows = q(con, "SELECT f.*, "
|
||||
"(SELECT COUNT(*) FROM topics x WHERE x.forum_id=f.id) tc "
|
||||
"FROM forums f ORDER BY f.sort_order, f.id")
|
||||
t.header("FORUMS")
|
||||
for i, r in enumerate(rows, 1):
|
||||
t.line(" %2d. %-24s %3d topics%s" % (
|
||||
i, r["name"][:24], r["tc"], " [locked]" if r["is_locked"] else ""))
|
||||
t.line("\n number=open [Q]back")
|
||||
c = t.ask("\nforum> ").lower()
|
||||
if c.isdigit() and 1 <= int(c) <= len(rows):
|
||||
topic_list(t, con, me, rows[int(c) - 1])
|
||||
elif c.startswith("q") or c == "":
|
||||
return
|
||||
|
||||
|
||||
def topic_list(t, con, me, forum):
|
||||
page = 0
|
||||
while True:
|
||||
off = page * PER_PAGE
|
||||
rows = q(con, "SELECT t.*, u.username, "
|
||||
"(SELECT COUNT(*) FROM posts p WHERE p.topic_id=t.id) pc "
|
||||
"FROM topics t LEFT JOIN users u ON u.id=t.user_id "
|
||||
"WHERE t.forum_id=? ORDER BY t.bumped_at DESC LIMIT ? OFFSET ?",
|
||||
(forum["id"], PER_PAGE + 1, off))
|
||||
more = len(rows) > PER_PAGE
|
||||
rows = rows[:PER_PAGE]
|
||||
|
||||
t.header("FORUM: " + forum["name"])
|
||||
if not rows:
|
||||
t.line(" (no topics yet)")
|
||||
for i, r in enumerate(rows, 1):
|
||||
t.line(" %2d.%s %-30s %3dp %-10s %s" % (
|
||||
i, "L" if r["is_locked"] else " ", r["title"][:30], r["pc"],
|
||||
(r["username"] or "?")[:10], ago(r["bumped_at"])))
|
||||
t.line("\n number=read [N]ew topic [M]ore [P]rev [Q]back")
|
||||
c = t.ask("\ntopics> ").lower()
|
||||
if c.isdigit() and 1 <= int(c) <= len(rows):
|
||||
topic_read(t, con, me, rows[int(c) - 1]["id"])
|
||||
elif c.startswith("n"):
|
||||
new_topic(t, con, me, forum)
|
||||
elif c.startswith("m") and more:
|
||||
page += 1
|
||||
elif c.startswith("p") and page > 0:
|
||||
page -= 1
|
||||
elif c.startswith("q") or c == "":
|
||||
return
|
||||
|
||||
|
||||
def new_topic(t, con, me, forum):
|
||||
if forum["is_locked"] and not me["is_admin"]:
|
||||
t.line(" That forum is locked.")
|
||||
return
|
||||
title = t.ask(" Topic title: ", maxlen=80)
|
||||
if not title:
|
||||
return
|
||||
t.line(" Body - end with '.' on its own line:")
|
||||
body = read_body(t)
|
||||
if not body.strip():
|
||||
t.line(" Aborted.")
|
||||
return
|
||||
now = int(time.time())
|
||||
tid = ex(con, "INSERT INTO topics (forum_id,user_id,title,created_at,bumped_at)"
|
||||
" VALUES (?,?,?,?,?)", (forum["id"], me["id"], title, now, now))
|
||||
ex(con, "INSERT INTO posts (topic_id,user_id,body,created_at) VALUES (?,?,?,?)",
|
||||
(tid, me["id"], body[:4000], now))
|
||||
t.line(" Topic posted.")
|
||||
|
||||
|
||||
def topic_read(t, con, me, tid):
|
||||
page = 0
|
||||
while True:
|
||||
top = q1(con, "SELECT t.*, f.name fname, f.is_locked flocked FROM topics t "
|
||||
"JOIN forums f ON f.id=t.forum_id WHERE t.id=?", (tid,))
|
||||
if not top:
|
||||
t.line(" Gone.")
|
||||
return
|
||||
locked = top["is_locked"] or top["flocked"]
|
||||
off = page * PER_PAGE
|
||||
rows = q(con, "SELECT p.*, u.username FROM posts p "
|
||||
"LEFT JOIN users u ON u.id=p.user_id "
|
||||
"WHERE p.topic_id=? ORDER BY p.id LIMIT ? OFFSET ?",
|
||||
(tid, PER_PAGE + 1, off))
|
||||
more = len(rows) > PER_PAGE
|
||||
rows = rows[:PER_PAGE]
|
||||
|
||||
t.header(top["title"][:50] + (" [LOCKED]" if locked else ""))
|
||||
for r in rows:
|
||||
t.line(" --- %s, %s ago ---" % ((r["username"] or "[gone]"),
|
||||
ago(r["created_at"])))
|
||||
for ln in r["body"].splitlines() or [""]:
|
||||
t.line(" " + ln)
|
||||
t.line("\n [R]eply [M]ore [P]rev [Q]back")
|
||||
c = t.ask("\ntopic> ").lower()
|
||||
if c.startswith("r"):
|
||||
if locked and not me["is_admin"]:
|
||||
t.line(" Topic is locked.")
|
||||
continue
|
||||
t.line(" Reply - end with '.' on its own line:")
|
||||
body = read_body(t)
|
||||
if body.strip():
|
||||
now = int(time.time())
|
||||
ex(con, "INSERT INTO posts (topic_id,user_id,body,created_at)"
|
||||
" VALUES (?,?,?,?)", (tid, me["id"], body[:4000], now))
|
||||
ex(con, "UPDATE topics SET bumped_at=? WHERE id=?", (now, tid))
|
||||
t.line(" Posted.")
|
||||
elif c.startswith("m") and more:
|
||||
page += 1
|
||||
elif c.startswith("p") and page > 0:
|
||||
page -= 1
|
||||
elif c.startswith("q") or c == "":
|
||||
return
|
||||
323
bbs/games.py
Normal file
323
bbs/games.py
Normal file
@ -0,0 +1,323 @@
|
||||
"""Games for the BBS. Shares `scores`, `sp_games` and `matches` with the web
|
||||
site, so a telnet user and a WAP user can play each other in the same match."""
|
||||
import json
|
||||
import random
|
||||
import time
|
||||
from bbsdb import q, q1, ex, ago
|
||||
|
||||
QUESTIONS = [
|
||||
("What does WAP stand for?",
|
||||
["Wireless Application Protocol", "Web Access Point",
|
||||
"Wide Area Paging", "Wireless Audio Player"], 0),
|
||||
("WML is based on which language?", ["HTML", "XML", "SGML", "JSON"], 1),
|
||||
("Which company made the Nokia 7110?",
|
||||
["Ericsson", "Motorola", "Nokia", "Siemens"], 2),
|
||||
("A WML file is made up of one or more...",
|
||||
["Pages", "Cards", "Frames", "Slides"], 1),
|
||||
("What year was WAP 1.1 published?", ["1996", "1999", "2002", "2005"], 1),
|
||||
("GPRS stands for General Packet Radio...?",
|
||||
["System", "Service", "Standard", "Stream"], 1),
|
||||
]
|
||||
|
||||
|
||||
def games_menu(t, con, me):
|
||||
while True:
|
||||
t.header("GAMES")
|
||||
t.line(" [1] Guess the Number (single player)")
|
||||
t.line(" [2] Quick Quiz (single player)")
|
||||
t.line(" [3] Noughts & Crosses (multiplayer)")
|
||||
t.line(" [4] Nim - 21 sticks (multiplayer)")
|
||||
t.line(" [5] High scores")
|
||||
t.line(" [Q] Back to main")
|
||||
c = t.ask("\ngames> ").lower()
|
||||
if c == "1":
|
||||
guess(t, con, me)
|
||||
elif c == "2":
|
||||
quiz(t, con, me)
|
||||
elif c == "3":
|
||||
ttt(t, con, me)
|
||||
elif c == "4":
|
||||
nim(t, con, me)
|
||||
elif c == "5":
|
||||
scores(t, con)
|
||||
elif c.startswith("q") or c == "":
|
||||
return
|
||||
|
||||
|
||||
def add_score(con, game, uid, score, detail=""):
|
||||
ex(con, "INSERT INTO scores (game,user_id,score,detail,created_at)"
|
||||
" VALUES (?,?,?,?,?)", (game, uid, score, detail, int(time.time())))
|
||||
|
||||
|
||||
def scores(t, con):
|
||||
names = {"guess": "Guess the Number", "quiz": "Quick Quiz",
|
||||
"ttt": "Noughts & Crosses", "nim": "Nim"}
|
||||
t.header("HIGH SCORES")
|
||||
for g, label in names.items():
|
||||
rows = q(con, "SELECT u.username, MAX(s.score) sc FROM scores s "
|
||||
"JOIN users u ON u.id=s.user_id WHERE s.game=? "
|
||||
"GROUP BY s.user_id ORDER BY sc DESC LIMIT 5", (g,))
|
||||
t.line("\n " + label + ":")
|
||||
if not rows:
|
||||
t.line(" (none yet)")
|
||||
for i, r in enumerate(rows, 1):
|
||||
t.line(" %d. %-14s %d" % (i, r["username"][:14], r["sc"]))
|
||||
t.pause()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ guess
|
||||
def guess(t, con, me):
|
||||
n = random.randint(1, 100)
|
||||
tries = 0
|
||||
t.header("GUESS THE NUMBER")
|
||||
t.line(" I picked a number from 1 to 100. Fewer guesses = more points.")
|
||||
t.line(" Type Q to give up.")
|
||||
while True:
|
||||
s = t.ask("\n guess> ", maxlen=4)
|
||||
if s.lower().startswith("q"):
|
||||
t.line(" The number was %d." % n)
|
||||
return
|
||||
if not s.isdigit():
|
||||
t.line(" Enter a number 1-100.")
|
||||
continue
|
||||
g = int(s)
|
||||
if not 1 <= g <= 100:
|
||||
t.line(" Enter a number 1-100.")
|
||||
continue
|
||||
tries += 1
|
||||
if g == n:
|
||||
score = max(1, 110 - 10 * tries)
|
||||
add_score(con, "guess", me["id"], score, "%d tries" % tries)
|
||||
t.line(" Correct! %d in %d tries. Score %d." % (n, tries, score))
|
||||
return
|
||||
t.line(" Too %s. (tries: %d)" % ("LOW" if g < n else "HIGH", tries))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ quiz
|
||||
def quiz(t, con, me):
|
||||
qs = random.sample(QUESTIONS, 5)
|
||||
right = 0
|
||||
t.header("QUICK QUIZ")
|
||||
for i, (question, opts, ans) in enumerate(qs, 1):
|
||||
t.line("\n Q%d/5: %s" % (i, question))
|
||||
for j, o in enumerate(opts):
|
||||
t.line(" %d) %s" % (j + 1, o))
|
||||
s = t.ask(" answer> ", maxlen=2)
|
||||
pick = int(s) - 1 if s.isdigit() else -1
|
||||
if pick == ans:
|
||||
right += 1
|
||||
t.line(" Correct!")
|
||||
else:
|
||||
t.line(" Wrong - it was: %s" % opts[ans])
|
||||
score = right * 20
|
||||
add_score(con, "quiz", me["id"], score, "%d/5" % right)
|
||||
t.line("\n Final: %d/5 correct, score %d." % (right, score))
|
||||
t.pause()
|
||||
|
||||
|
||||
# ------------------------------------------------- multiplayer plumbing
|
||||
def seat_of(m, uid):
|
||||
if m["p1"] == uid:
|
||||
return 1
|
||||
if m["p2"] == uid:
|
||||
return 2
|
||||
return 0
|
||||
|
||||
|
||||
def lobby(t, con, me, game, label, new_state):
|
||||
"""Shared lobby: returns a match id to play, or None to go back."""
|
||||
while True:
|
||||
mine = q(con, "SELECT m.*, a.username n1, b.username n2 FROM matches m "
|
||||
"LEFT JOIN users a ON a.id=m.p1 LEFT JOIN users b ON b.id=m.p2 "
|
||||
"WHERE m.game=? AND (m.p1=? OR m.p2=?) "
|
||||
"AND m.status IN ('open','playing') "
|
||||
"ORDER BY m.updated_at DESC LIMIT 10",
|
||||
(game, me["id"], me["id"]))
|
||||
open_ = q(con, "SELECT m.*, a.username n1 FROM matches m "
|
||||
"LEFT JOIN users a ON a.id=m.p1 "
|
||||
"WHERE m.game=? AND m.status='open' AND m.p1<>? "
|
||||
"ORDER BY m.id DESC LIMIT 10", (game, me["id"]))
|
||||
|
||||
t.header(label + " - LOBBY")
|
||||
t.line(" Your games:")
|
||||
if not mine:
|
||||
t.line(" (none)")
|
||||
for r in mine:
|
||||
opp = (r["n2"] or "waiting...") if r["p1"] == me["id"] else (r["n1"] or "?")
|
||||
yours = (r["status"] == "playing" and seat_of(r, me["id"]) == r["turn"])
|
||||
t.line(" #%d vs %-12s %s" % (r["id"], opp[:12],
|
||||
"<< YOUR TURN" if yours else ""))
|
||||
t.line(" Open games to join:")
|
||||
if not open_:
|
||||
t.line(" (none)")
|
||||
for r in open_:
|
||||
t.line(" #%d by %s" % (r["id"], r["n1"] or "?"))
|
||||
t.line("\n [C]reate J <id> = join #<id> = play [Q]back")
|
||||
c = t.ask("\nlobby> ").lower().strip()
|
||||
|
||||
if c.startswith("c"):
|
||||
now = int(time.time())
|
||||
mid = ex(con, "INSERT INTO matches (game,p1,turn,state,status,"
|
||||
"created_at,updated_at) VALUES (?,?,1,?,'open',?,?)",
|
||||
(game, me["id"], new_state, now, now))
|
||||
t.line(" Created game #%d - waiting for an opponent." % mid)
|
||||
elif c.startswith("j"):
|
||||
digits = "".join(ch for ch in c if ch.isdigit())
|
||||
if digits:
|
||||
mid = int(digits)
|
||||
m = q1(con, "SELECT * FROM matches WHERE id=? AND game=?", (mid, game))
|
||||
if not m or m["status"] != "open" or m["p1"] == me["id"]:
|
||||
t.line(" Cannot join that game.")
|
||||
else:
|
||||
ex(con, "UPDATE matches SET p2=?, status='playing', updated_at=?"
|
||||
" WHERE id=?", (me["id"], int(time.time()), mid))
|
||||
t.line(" Joined #%d." % mid)
|
||||
return mid
|
||||
elif c.startswith("#") or c.isdigit():
|
||||
digits = "".join(ch for ch in c if ch.isdigit())
|
||||
if digits:
|
||||
return int(digits)
|
||||
elif c.startswith("q") or c == "":
|
||||
return None
|
||||
|
||||
|
||||
def get_match(con, game, mid):
|
||||
return q1(con, "SELECT m.*, a.username n1, b.username n2 FROM matches m "
|
||||
"LEFT JOIN users a ON a.id=m.p1 LEFT JOIN users b ON b.id=m.p2 "
|
||||
"WHERE m.id=? AND m.game=?", (mid, game))
|
||||
|
||||
|
||||
def save_match(con, mid, state, turn, status="playing", winner=None):
|
||||
ex(con, "UPDATE matches SET state=?,turn=?,status=?,winner=?,updated_at=?"
|
||||
" WHERE id=?", (state, turn, status, winner, int(time.time()), mid))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ ttt
|
||||
def ttt_winner(b):
|
||||
for a, c, d in [(0,1,2),(3,4,5),(6,7,8),(0,3,6),(1,4,7),(2,5,8),(0,4,8),(2,4,6)]:
|
||||
if b[a] and b[a] == b[c] == b[d]:
|
||||
return b[a]
|
||||
return None
|
||||
|
||||
|
||||
def ttt(t, con, me):
|
||||
mid = lobby(t, con, me, "ttt", "NOUGHTS & CROSSES",
|
||||
json.dumps({"b": [""] * 9}))
|
||||
if mid is None:
|
||||
return
|
||||
while True:
|
||||
m = get_match(con, "ttt", mid)
|
||||
if not m:
|
||||
t.line(" No such game.")
|
||||
return
|
||||
st = json.loads(m["state"])
|
||||
b = st["b"]
|
||||
seat = seat_of(m, me["id"])
|
||||
|
||||
t.header("#%d X=%s O=%s" % (mid, m["n1"] or "?", m["n2"] or "waiting"))
|
||||
for r in range(3):
|
||||
cells = [b[r*3+c] if b[r*3+c] else str(r*3+c+1) for c in range(3)]
|
||||
t.line(" %s | %s | %s" % tuple(cells))
|
||||
if r < 2:
|
||||
t.line(" ---+---+---")
|
||||
|
||||
if m["status"] == "open":
|
||||
t.line("\n Waiting for an opponent to join.")
|
||||
t.line(" [R]efresh [Q]back")
|
||||
elif m["status"] == "done":
|
||||
if m["winner"] is None:
|
||||
t.line("\n Result: draw.")
|
||||
else:
|
||||
t.line("\n Winner: %s" % (m["n1"] if m["winner"] == m["p1"] else m["n2"]))
|
||||
t.pause()
|
||||
return
|
||||
elif seat == 0:
|
||||
t.line("\n Spectating. Turn: player %d" % m["turn"])
|
||||
t.line(" [R]efresh [Q]back")
|
||||
elif seat == m["turn"]:
|
||||
t.line("\n Your turn (%s). Enter a free square 1-9." % ("X" if seat == 1 else "O"))
|
||||
else:
|
||||
t.line("\n Opponent to move. [R]efresh [Q]back")
|
||||
|
||||
c = t.ask("\nttt> ").lower().strip()
|
||||
if c.startswith("q"):
|
||||
return
|
||||
if c.startswith("r") or c == "":
|
||||
continue
|
||||
if m["status"] != "playing" or seat == 0 or seat != m["turn"]:
|
||||
t.line(" Not your move.")
|
||||
continue
|
||||
if not c.isdigit() or not 1 <= int(c) <= 9:
|
||||
t.line(" Pick 1-9.")
|
||||
continue
|
||||
i = int(c) - 1
|
||||
if b[i]:
|
||||
t.line(" That square is taken.")
|
||||
continue
|
||||
b[i] = "X" if seat == 1 else "O"
|
||||
st["b"] = b
|
||||
w = ttt_winner(b)
|
||||
if w:
|
||||
save_match(con, mid, json.dumps(st), seat, "done", me["id"])
|
||||
add_score(con, "ttt", me["id"], 50, "win")
|
||||
t.line(" You win!")
|
||||
elif all(b):
|
||||
save_match(con, mid, json.dumps(st), seat, "done", None)
|
||||
t.line(" Draw.")
|
||||
else:
|
||||
save_match(con, mid, json.dumps(st), 2 if seat == 1 else 1)
|
||||
t.line(" Move played.")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ nim
|
||||
def nim(t, con, me):
|
||||
mid = lobby(t, con, me, "nim", "NIM - 21 STICKS", json.dumps({"sticks": 21}))
|
||||
if mid is None:
|
||||
return
|
||||
while True:
|
||||
m = get_match(con, "nim", mid)
|
||||
if not m:
|
||||
t.line(" No such game.")
|
||||
return
|
||||
st = json.loads(m["state"])
|
||||
left = st["sticks"]
|
||||
seat = seat_of(m, me["id"])
|
||||
|
||||
t.header("#%d %s vs %s" % (mid, m["n1"] or "?", m["n2"] or "waiting"))
|
||||
t.line(" " + "| " * left)
|
||||
t.line(" %d sticks left. Take the LAST stick and you LOSE." % left)
|
||||
|
||||
if m["status"] == "open":
|
||||
t.line("\n Waiting for an opponent. [R]efresh [Q]back")
|
||||
elif m["status"] == "done":
|
||||
t.line("\n Winner: %s" % (m["n1"] if m["winner"] == m["p1"] else m["n2"]))
|
||||
t.pause()
|
||||
return
|
||||
elif seat == 0:
|
||||
t.line("\n Spectating. [R]efresh [Q]back")
|
||||
elif seat == m["turn"]:
|
||||
t.line("\n Your turn - take 1, 2 or 3.")
|
||||
else:
|
||||
t.line("\n Opponent to move. [R]efresh [Q]back")
|
||||
|
||||
c = t.ask("\nnim> ").lower().strip()
|
||||
if c.startswith("q"):
|
||||
return
|
||||
if c.startswith("r") or c == "":
|
||||
continue
|
||||
if m["status"] != "playing" or seat == 0 or seat != m["turn"]:
|
||||
t.line(" Not your move.")
|
||||
continue
|
||||
if c not in ("1", "2", "3") or int(c) > left:
|
||||
t.line(" Take 1-3, and no more than remain.")
|
||||
continue
|
||||
n = int(c)
|
||||
st["sticks"] = left - n
|
||||
if st["sticks"] <= 0:
|
||||
winner = m["p2"] if seat == 1 else m["p1"]
|
||||
save_match(con, mid, json.dumps(st), seat, "done", winner)
|
||||
add_score(con, "nim", winner, 40, "win")
|
||||
t.line(" You took the last stick - you LOSE!")
|
||||
else:
|
||||
save_match(con, mid, json.dumps(st), 2 if seat == 1 else 1)
|
||||
t.line(" You took %d. %d left." % (n, st["sticks"]))
|
||||
511
bbs/mud.py
Normal file
511
bbs/mud.py
Normal file
@ -0,0 +1,511 @@
|
||||
"""Telnet MUD realm for the txt3 BBS.
|
||||
|
||||
Implements the same rules as the WAP/XHTML front-end (lib/mud.php) against
|
||||
the same SQLite tables, so a character created on the web can be played live
|
||||
here and vice-versa. Combat/levelling formulas deliberately match the PHP
|
||||
engine so both front-ends behave identically.
|
||||
|
||||
This module is imported by bbsd.py; it exposes mud_menu(t, con, me).
|
||||
"""
|
||||
import json
|
||||
import random
|
||||
import time
|
||||
|
||||
from bbsdb import q, q1, ex, ago
|
||||
|
||||
START_HP, START_ATK, START_DEF = 30, 4, 2
|
||||
RESPAWN = 30
|
||||
|
||||
CLASSES = {
|
||||
"fighter": ("Fighter", 2, 0, 6),
|
||||
"mage": ("Mage", 4, 0, 0),
|
||||
"thief": ("Thief", 1, 2, 2),
|
||||
}
|
||||
|
||||
|
||||
def class_bonus(cls):
|
||||
return CLASSES.get(cls, CLASSES["fighter"])
|
||||
|
||||
|
||||
def item(con, key):
|
||||
return q1(con, "SELECT * FROM mud_items WHERE key_name=?", (key,))
|
||||
|
||||
|
||||
def char_for(con, uid):
|
||||
r = q1(con, "SELECT * FROM mud_chars WHERE user_id=?", (uid,))
|
||||
return dict(r) if r else None
|
||||
|
||||
|
||||
def room(con, rid):
|
||||
r = q1(con, "SELECT * FROM mud_rooms WHERE id=?", (rid,))
|
||||
if r:
|
||||
r = dict(r)
|
||||
r["exits"] = json.loads(r["exits"])
|
||||
return r
|
||||
|
||||
|
||||
def ensure_char(con, me, cls="fighter"):
|
||||
c = char_for(con, me["id"])
|
||||
if c:
|
||||
return dict(c)
|
||||
if cls not in CLASSES:
|
||||
cls = "fighter"
|
||||
nm, batk, bdef, bhp = class_bonus(cls)
|
||||
maxhp = START_HP + bhp
|
||||
ex(con, "INSERT INTO mud_chars (user_id,name,class,room_id,hp,max_hp,atk,def,xp,level,gold)"
|
||||
" VALUES (?,?,?,1,?,?,?,?,0,1,0)",
|
||||
(me["id"], me["username"], cls, maxhp, maxhp, START_ATK + batk, START_DEF + bdef))
|
||||
echo(con, 1, "%s the %s arrives in the world." % (me["username"], nm))
|
||||
return dict(char_for(con, me["id"]))
|
||||
|
||||
|
||||
def echo(con, rid, text):
|
||||
ex(con, "INSERT INTO mud_events (room_id,ts,text) VALUES (?,?,?)",
|
||||
(rid, int(time.time()), text))
|
||||
|
||||
|
||||
def alive_spawns(con, rid):
|
||||
return q(con, "SELECT s.*, m.key_name AS key_name, m.name AS name, m.descr AS descr "
|
||||
"FROM mud_spawn s JOIN mud_mobs m ON m.id=s.mob_id "
|
||||
"WHERE s.room_id=? AND s.alive=1 ORDER BY s.id", (rid,))
|
||||
|
||||
|
||||
def ground_items(con, rid):
|
||||
return q(con, "SELECT g.id, i.key_name AS key_name, i.name AS name, i.descr AS descr "
|
||||
"FROM mud_ground g JOIN mud_items i ON i.id=g.item_id "
|
||||
"WHERE g.room_id=? ORDER BY g.id", (rid,))
|
||||
|
||||
|
||||
def respawn(con):
|
||||
now = int(time.time())
|
||||
dead = q(con, "SELECT s.id, s.mob_id, m.hp AS mhp FROM mud_spawn s "
|
||||
"JOIN mud_mobs m ON m.id=s.mob_id WHERE s.alive=0 AND s.next_respawn<=?",
|
||||
(now,))
|
||||
for d in dead:
|
||||
ex(con, "UPDATE mud_spawn SET alive=1, hp=? WHERE id=?", (d["mhp"], d["id"]))
|
||||
|
||||
|
||||
def level_check(con, c):
|
||||
need = c["level"] * 50
|
||||
if c["xp"] >= need:
|
||||
c["xp"] -= need
|
||||
c["level"] += 1
|
||||
c["max_hp"] += 8
|
||||
c["atk"] += 2
|
||||
c["def"] += 1
|
||||
c["hp"] = c["max_hp"]
|
||||
ex(con, "UPDATE mud_chars SET level=?,max_hp=?,atk=?,def=?,xp=?,hp=? WHERE id=?",
|
||||
(c["level"], c["max_hp"], c["atk"], c["def"], c["xp"], c["hp"], c["id"]))
|
||||
return "You reached level %d! HP/ATK/DEF increased." % c["level"]
|
||||
return None
|
||||
|
||||
|
||||
def fight(con, spawn_id, c):
|
||||
sp = q1(con, "SELECT s.*, m.key_name AS key_name, m.name AS name, m.descr AS descr, "
|
||||
"m.atk AS atk, m.def AS def, m.xp AS xp, m.gold AS gold, m.loot AS loot, "
|
||||
"m.respawn AS respawn FROM mud_spawn s JOIN mud_mobs m ON m.id=s.mob_id "
|
||||
"WHERE s.id=?", (spawn_id,))
|
||||
if not sp or not sp["alive"]:
|
||||
return ["There is nothing to fight here."]
|
||||
sp = dict(sp)
|
||||
msgs = []
|
||||
wep = item(con, c["weapon"]) if c["weapon"] else None
|
||||
pDmg = max(1, c["atk"] + (wep["atk"] if wep else 0) - sp["def"] + random.randint(-1, 1))
|
||||
sp["hp"] -= pDmg
|
||||
msgs.append("You hit %s for %d." % (sp["name"], pDmg))
|
||||
if sp["hp"] <= 0:
|
||||
c["xp"] += sp["xp"]
|
||||
c["gold"] += sp["gold"]
|
||||
msgs.append("%s dies! +%d xp, +%d gold." % (sp["name"], sp["xp"], sp["gold"]))
|
||||
inv = json.loads(c["inv"] or "[]")
|
||||
for lk in json.loads(sp["loot"] or "[]"):
|
||||
it = item(con, lk)
|
||||
if it:
|
||||
inv.append(lk)
|
||||
msgs.append("You take %s." % it["name"])
|
||||
c["inv"] = json.dumps(inv)
|
||||
ex(con, "UPDATE mud_spawn SET alive=0, next_respawn=? WHERE id=?",
|
||||
(int(time.time()) + sp["respawn"], sp["id"]))
|
||||
echo(con, c["room_id"], "%s slew %s." % (c["name"], sp["name"]))
|
||||
ex(con, "UPDATE mud_chars SET xp=?,gold=?,inv=? WHERE id=?",
|
||||
(c["xp"], c["gold"], c["inv"], c["id"]))
|
||||
lv = level_check(con, c)
|
||||
if lv:
|
||||
msgs.append(lv)
|
||||
return msgs
|
||||
arm = item(con, c["armor"]) if c["armor"] else None
|
||||
mDmg = max(1, sp["atk"] - (c["def"] + (arm["def"] if arm else 0)) + random.randint(-1, 1))
|
||||
c["hp"] -= mDmg
|
||||
msgs.append("%s hits you for %d." % (sp["name"], mDmg))
|
||||
if c["hp"] <= 0:
|
||||
c["hp"] = 0
|
||||
msgs.append("You have fallen! You wake in the Village Square.")
|
||||
echo(con, c["room_id"], "%s was slain by %s and fades away." % (c["name"], sp["name"]))
|
||||
lost = int(c["gold"] * 0.2)
|
||||
c["gold"] -= lost
|
||||
c["room_id"] = 1
|
||||
c["hp"] = c["max_hp"]
|
||||
ex(con, "UPDATE mud_chars SET room_id=1,hp=?,gold=?,last_cmd_at=? WHERE id=?",
|
||||
(c["hp"], c["gold"], int(time.time()), c["id"]))
|
||||
else:
|
||||
ex(con, "UPDATE mud_chars SET hp=? WHERE id=?", (c["hp"], c["id"]))
|
||||
return msgs
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ UI
|
||||
DIRS = {"n": "North", "s": "South", "e": "East", "w": "West", "u": "Up", "d": "Down"}
|
||||
|
||||
|
||||
def show_room(t, con, c):
|
||||
rm = room(con, c["room_id"])
|
||||
spawns = alive_spawns(con, c["room_id"])
|
||||
ground = ground_items(con, c["room_id"])
|
||||
others = players_in_room(con, c["room_id"])
|
||||
events = q(con, "SELECT text FROM mud_events WHERE room_id=? ORDER BY id DESC LIMIT 5",
|
||||
(c["room_id"],))
|
||||
t.header("MUD - " + c["name"])
|
||||
t.line(" " + rm["name"])
|
||||
t.line(" " + rm["descr"])
|
||||
if spawns:
|
||||
t.line(" Here:")
|
||||
for s in spawns:
|
||||
t.line(" %s (%d hp)" % (s["descr"], s["hp"]))
|
||||
people = [p for p in others if p["id"] != c["id"]]
|
||||
if people:
|
||||
t.line(" Adventurers here:")
|
||||
for p in people:
|
||||
b = CLASSES.get(p["class"], CLASSES["fighter"])[0]
|
||||
bn = ", bounty %dg" % p["bounty"] if p["bounty"] > 0 else ""
|
||||
t.line(" %s (%s, L%d%s)" % (p["name"], b, p["level"], bn))
|
||||
if ground:
|
||||
t.line(" On the ground:")
|
||||
for g in ground:
|
||||
t.line(" " + g["name"])
|
||||
if events:
|
||||
t.line(" You see:")
|
||||
for ev in reversed(events):
|
||||
t.line(" " + ev["text"])
|
||||
t.rule("-")
|
||||
t.line(" HP %d/%d Lvl %d XP %d Gold %d Bank %d" % (
|
||||
c["hp"], c["max_hp"], c["level"], c["xp"], c["gold"], c["bank"]))
|
||||
ex(con, "UPDATE mud_chars SET last_cmd_at=? WHERE id=?", (int(time.time()), c["id"]))
|
||||
|
||||
|
||||
def mud_menu(t, con, me):
|
||||
c = ensure_char(con, me)
|
||||
show_room(t, con, c)
|
||||
while True:
|
||||
t.line("\n [n/s/e/w/u/d] move [l]ook [k]ill <mob> [t]ake <item>")
|
||||
t.line(" [w]ield [d]rink [i]nventory [sc]ore [Q]uit to main")
|
||||
line = t.ask("\nmud> ").lower().strip()
|
||||
if not line or line.startswith("q"):
|
||||
t.line("Leaving the realm...")
|
||||
return
|
||||
parts = line.split(" ", 1)
|
||||
cmd = parts[0]
|
||||
arg = parts[1].strip() if len(parts) > 1 else ""
|
||||
|
||||
if cmd in DIRS:
|
||||
rm = room(con, c["room_id"])
|
||||
if cmd in rm["exits"]:
|
||||
c["room_id"] = rm["exits"][cmd]
|
||||
ex(con, "UPDATE mud_chars SET room_id=? WHERE id=?", (c["room_id"], c["id"]))
|
||||
echo(con, c["room_id"], "%s heads %s." % (c["name"], DIRS[cmd]))
|
||||
else:
|
||||
t.line("You can't go that way.")
|
||||
show_room(t, con, c)
|
||||
continue
|
||||
if cmd in ("l", "look"):
|
||||
show_room(t, con, c)
|
||||
continue
|
||||
if cmd in ("k", "kill", "attack"):
|
||||
if not arg:
|
||||
t.line("Kill what?"); continue
|
||||
spawns = alive_spawns(con, c["room_id"])
|
||||
hit = None
|
||||
for s in spawns:
|
||||
if s["key_name"].startswith(arg) or arg in s["name"]:
|
||||
hit = s; break
|
||||
if hit:
|
||||
for m in fight(con, hit["id"], c):
|
||||
t.line(" " + m)
|
||||
else:
|
||||
t.line("There is no '%s' here to fight." % arg)
|
||||
show_room(t, con, c)
|
||||
continue
|
||||
if cmd in ("t", "take", "get"):
|
||||
if not arg:
|
||||
t.line("Take what?"); continue
|
||||
ground = ground_items(con, c["room_id"])
|
||||
got = None
|
||||
for g in ground:
|
||||
if g["key_name"].startswith(arg):
|
||||
got = g; break
|
||||
if got:
|
||||
ex(con, "DELETE FROM mud_ground WHERE id=?", (got["id"],))
|
||||
inv = json.loads(c["inv"] or "[]")
|
||||
inv.append(got["key_name"])
|
||||
c["inv"] = json.dumps(inv)
|
||||
ex(con, "UPDATE mud_chars SET inv=? WHERE id=?", (c["inv"], c["id"]))
|
||||
echo(con, c["room_id"], "%s takes %s." % (c["name"], got["name"]))
|
||||
t.line("You take %s." % got["name"])
|
||||
else:
|
||||
t.line("There is no '%s' here." % arg)
|
||||
show_room(t, con, c)
|
||||
continue
|
||||
if cmd in ("w", "wield", "wear"):
|
||||
if not arg:
|
||||
t.line("Wield what?"); continue
|
||||
inv = json.loads(c["inv"] or "[]")
|
||||
it = None
|
||||
for k in inv:
|
||||
if k.startswith(arg):
|
||||
it = item(con, k); break
|
||||
if not it:
|
||||
t.line("You don't have that."); continue
|
||||
if it["slot"] == "weapon":
|
||||
c["weapon"] = it["key_name"]
|
||||
ex(con, "UPDATE mud_chars SET weapon=? WHERE id=?", (it["key_name"], c["id"]))
|
||||
t.line("You wield %s." % it["name"])
|
||||
elif it["slot"] == "armor":
|
||||
c["armor"] = it["key_name"]
|
||||
ex(con, "UPDATE mud_chars SET armor=? WHERE id=?", (it["key_name"], c["id"]))
|
||||
t.line("You don %s." % it["name"])
|
||||
else:
|
||||
t.line("You can't wield that.")
|
||||
show_room(t, con, c)
|
||||
continue
|
||||
if cmd in ("d", "drink", "quaff"):
|
||||
if not arg:
|
||||
t.line("Drink what?"); continue
|
||||
inv = json.loads(c["inv"] or "[]")
|
||||
it = None
|
||||
idx = None
|
||||
for i, k in enumerate(inv):
|
||||
if k.startswith(arg):
|
||||
it = item(con, k); idx = i; break
|
||||
if not it:
|
||||
t.line("You don't have that."); continue
|
||||
if it["slot"] != "potion":
|
||||
t.line("That's not a potion."); continue
|
||||
c["hp"] = min(c["max_hp"], c["hp"] + it["heal"])
|
||||
inv.pop(idx)
|
||||
c["inv"] = json.dumps(inv)
|
||||
ex(con, "UPDATE mud_chars SET hp=?,inv=? WHERE id=?", (c["hp"], c["inv"], c["id"]))
|
||||
t.line("You drink %s and recover %d hp." % (it["name"], it["heal"]))
|
||||
show_room(t, con, c)
|
||||
continue
|
||||
if cmd in ("i", "inv", "inventory"):
|
||||
inv = json.loads(c["inv"] or "[]")
|
||||
if inv:
|
||||
t.line(" You carry: " + ", ".join(item(con, k)["name"] for k in inv))
|
||||
else:
|
||||
t.line(" Your pack is empty.")
|
||||
continue
|
||||
if cmd in ("sc", "score", "stats"):
|
||||
t.line(" Level %d | HP %d/%d | ATK %d | DEF %d | XP %d | Gold %d | Bank %d | Kills %d | Deaths %d" % (
|
||||
c["level"], c["hp"], c["max_hp"], c["atk"], c["def"], c["xp"], c["gold"], c["bank"], c["kills"], c["deaths"]))
|
||||
continue
|
||||
if cmd in ("a", "attack", "murder", "killp"):
|
||||
if not arg:
|
||||
t.line("Attack who?"); continue
|
||||
v = find_player_in_room(con, c["room_id"], arg)
|
||||
if v:
|
||||
if v["id"] == c["id"]:
|
||||
t.line("You can't attack yourself.")
|
||||
else:
|
||||
for m in pvp(con, c, v):
|
||||
t.line(" " + m)
|
||||
else:
|
||||
others = [p for p in players_in_room(con, c["room_id"]) if p["id"] != c["id"]]
|
||||
who = ", ".join(p["name"] for p in others) or "nobody"
|
||||
t.line("There is no '%s' here to fight. Adventurers present: %s." % (arg, who))
|
||||
show_room(t, con, c)
|
||||
continue
|
||||
if cmd in ("b", "buy"):
|
||||
if not arg:
|
||||
t.line("Buy what? e.g. 'buy steel_sword'"); continue
|
||||
for m in buy(con, c, arg):
|
||||
t.line(" " + m)
|
||||
show_room(t, con, c)
|
||||
continue
|
||||
if cmd in ("bank",):
|
||||
if not arg:
|
||||
t.line("bank <amount> to deposit, bank -<amount> to withdraw"); continue
|
||||
if arg.startswith("-"):
|
||||
for m in withdraw(con, c, int(arg[1:] or 0)):
|
||||
t.line(" " + m)
|
||||
else:
|
||||
for m in deposit(con, c, int(arg or 0)):
|
||||
t.line(" " + m)
|
||||
show_room(t, con, c)
|
||||
continue
|
||||
if cmd in ("tax",):
|
||||
for m in tax(con, c):
|
||||
t.line(" " + m)
|
||||
show_room(t, con, c)
|
||||
continue
|
||||
if cmd in ("bounty", "hit"):
|
||||
parts = arg.split(" ", 1)
|
||||
if len(parts) < 2 or not parts[1].isdigit():
|
||||
t.line("bounty <player> <amount>"); continue
|
||||
v = find_player_in_room(con, c["room_id"], parts[0])
|
||||
if v:
|
||||
if v["id"] == c["id"]:
|
||||
t.line("You can't bounty yourself.")
|
||||
else:
|
||||
for m in set_bounty(con, c, v, int(parts[1])):
|
||||
t.line(" " + m)
|
||||
else:
|
||||
t.line("There is no '%s' here to bounty." % parts[0])
|
||||
show_room(t, con, c)
|
||||
continue
|
||||
if cmd in ("board", "top", "leaderboard"):
|
||||
lb = leaderboard(con, 10)
|
||||
t.line(" Adventurers of renown:")
|
||||
for i, r in enumerate(lb, 1):
|
||||
cn = CLASSES.get(r["class"], CLASSES["fighter"])[0]
|
||||
t.line(" %d. %s (%s) L%d G%d K%d/D%d" % (i, r["name"], cn, r["level"], r["gold"], r["kills"], r["deaths"]))
|
||||
continue
|
||||
t.line(" Unknown command. Try: n/s/e/w/u/d, look, kill <mob>, attack <player>,")
|
||||
t.line(" buy <item>, bank <amt>, tax, bounty <player> <amt>, board, inv, score.")
|
||||
|
||||
|
||||
# ---- RPGBBS-style economy & PvP (mirror of lib/mud.php) ----
|
||||
|
||||
def players_in_room(con, rid):
|
||||
return q(con, "SELECT c.*, u.username AS username FROM mud_chars c "
|
||||
"JOIN users u ON u.id=c.user_id WHERE c.room_id=? ORDER BY c.level DESC", (rid,))
|
||||
|
||||
|
||||
def find_player_in_room(con, rid, frag):
|
||||
frag = frag.lower()
|
||||
for p in players_in_room(con, rid):
|
||||
if p["id"] == 0:
|
||||
continue
|
||||
if frag in (p["name"] or "").lower() or frag in (p["username"] or "").lower():
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def shop_list(con):
|
||||
return q(con, "SELECT * FROM mud_items WHERE price>0 ORDER BY price")
|
||||
|
||||
|
||||
def buy(con, c, key):
|
||||
it = item(con, key)
|
||||
if not it or it["price"] <= 0:
|
||||
return ["The trader doesn't sell that."]
|
||||
if c["gold"] < it["price"]:
|
||||
return ["You can't afford the %s (%dg)." % (it["name"], it["price"])]
|
||||
c["gold"] -= it["price"]
|
||||
inv = json.loads(c["inv"] or "[]")
|
||||
inv.append(it["key_name"])
|
||||
c["inv"] = json.dumps(inv)
|
||||
ex(con, "UPDATE mud_chars SET gold=?,inv=? WHERE id=?", (c["gold"], c["inv"], c["id"]))
|
||||
return ["You buy %s for %dg." % (it["name"], it["price"])]
|
||||
|
||||
|
||||
def deposit(con, c, amt):
|
||||
amt = max(0, min(amt, c["gold"]))
|
||||
if amt <= 0:
|
||||
return ["You have no gold to bank."]
|
||||
c["gold"] -= amt
|
||||
c["bank"] += amt
|
||||
ex(con, "UPDATE mud_chars SET gold=?,bank=? WHERE id=?", (c["gold"], c["bank"], c["id"]))
|
||||
return ["You deposit %dg. Bank balance: %dg." % (amt, c["bank"])]
|
||||
|
||||
|
||||
def withdraw(con, c, amt):
|
||||
amt = max(0, min(amt, c["bank"]))
|
||||
if amt <= 0:
|
||||
return ["Nothing to withdraw."]
|
||||
c["bank"] -= amt
|
||||
c["gold"] += amt
|
||||
ex(con, "UPDATE mud_chars SET gold=?,bank=? WHERE id=?", (c["gold"], c["bank"], c["id"]))
|
||||
return ["You withdraw %dg. You carry %dg." % (amt, c["gold"])]
|
||||
|
||||
|
||||
def tax(con, c):
|
||||
due = int(c["gold"] * 0.10)
|
||||
if due <= 0:
|
||||
return ["Sir Joe squints. \"Come back when ye've coins to tithe.\""]
|
||||
c["gold"] -= due
|
||||
ex(con, "UPDATE mud_chars SET gold=? WHERE id=?", (c["gold"], c["id"]))
|
||||
return ["Sir Joe pockets %dg. \"That's the price of civilisation, adventurer.\"" % due]
|
||||
|
||||
|
||||
def leaderboard(con, limit=10):
|
||||
return q(con, "SELECT name,class,level,gold,kills,deaths FROM mud_chars "
|
||||
"ORDER BY level DESC, gold DESC LIMIT %d" % limit)
|
||||
|
||||
|
||||
def pvp(con, att, dfn):
|
||||
msgs = []
|
||||
if dfn["id"] == att["id"]:
|
||||
return ["You can't attack yourself."]
|
||||
if dfn["room_id"] != att["room_id"]:
|
||||
return ["They aren't here."]
|
||||
aWep = item(con, att["weapon"])["atk"] if att["weapon"] else 0
|
||||
dWep = item(con, dfn["weapon"])["atk"] if dfn["weapon"] else 0
|
||||
aArm = item(con, att["armor"])["def"] if att["armor"] else 0
|
||||
dArm = item(con, dfn["armor"])["def"] if dfn["armor"] else 0
|
||||
aDmg = max(1, att["atk"] + aWep - dfn["def"] - dArm + random.randint(-1, 1))
|
||||
dDmg = max(1, dfn["atk"] + dWep - att["def"] - aArm + random.randint(-1, 1))
|
||||
att["hp"] -= dDmg
|
||||
dfn["hp"] -= aDmg
|
||||
msgs.append("You strike %s for %d. %s strikes you for %d." % (dfn["name"], aDmg, dfn["name"], dDmg))
|
||||
if dfn["hp"] <= 0 and att["hp"] > 0:
|
||||
loot = int(dfn["gold"] * 0.5)
|
||||
att["gold"] += loot
|
||||
att["kills"] += 1
|
||||
dfn["gold"] -= loot
|
||||
dfn["deaths"] += 1
|
||||
dfn["room_id"] = 1
|
||||
dfn["hp"] = dfn["max_hp"]
|
||||
bounty = int(dfn["bounty"])
|
||||
if bounty > 0:
|
||||
att["gold"] += bounty
|
||||
dfn["bounty"] = 0
|
||||
msgs.append("You collect the %dg bounty on %s!" % (bounty, dfn["name"]))
|
||||
msgs.append("You slay %s! +%dg looted%s" % (
|
||||
dfn["name"], loot, ", +%dg bounty." % bounty if bounty else "."))
|
||||
echo(con, att["room_id"], "%s cut down %s in cold blood." % (att["name"], dfn["name"]))
|
||||
ex(con, "UPDATE mud_chars SET gold=?,kills=?,hp=?,room_id=? WHERE id=?",
|
||||
(att["gold"], att["kills"], att["hp"], att["room_id"], att["id"]))
|
||||
ex(con, "UPDATE mud_chars SET gold=?,deaths=?,bounty=?,hp=?,room_id=? WHERE id=?",
|
||||
(dfn["gold"], dfn["deaths"], dfn["bounty"], dfn["hp"], dfn["id"]))
|
||||
ex(con, "DELETE FROM mud_bounties WHERE target_id=?", (dfn["id"],))
|
||||
return msgs
|
||||
if att["hp"] <= 0 and dfn["hp"] > 0:
|
||||
loot = int(att["gold"] * 0.5)
|
||||
dfn["gold"] += loot
|
||||
dfn["kills"] += 1
|
||||
att["gold"] -= loot
|
||||
att["deaths"] += 1
|
||||
att["room_id"] = 1
|
||||
att["hp"] = att["max_hp"]
|
||||
msgs.append("%s bests you! You lose %dg and wake in the Village Square." % (dfn["name"], loot))
|
||||
echo(con, att["room_id"], "%s cut down %s." % (dfn["name"], att["name"]))
|
||||
ex(con, "UPDATE mud_chars SET gold=?,deaths=?,hp=?,room_id=? WHERE id=?",
|
||||
(att["gold"], att["deaths"], att["hp"], att["room_id"], att["id"]))
|
||||
ex(con, "UPDATE mud_chars SET gold=?,kills=? WHERE id=?", (dfn["gold"], dfn["kills"], dfn["id"]))
|
||||
return msgs
|
||||
ex(con, "UPDATE mud_chars SET hp=? WHERE id=?", (att["hp"], att["id"]))
|
||||
ex(con, "UPDATE mud_chars SET hp=? WHERE id=?", (dfn["hp"], dfn["id"]))
|
||||
return msgs
|
||||
|
||||
|
||||
def set_bounty(con, c, target, amt):
|
||||
if target["id"] == c["id"]:
|
||||
return ["You can't bounty yourself."]
|
||||
if amt <= 0:
|
||||
return ["A bounty needs to be worth something."]
|
||||
if c["gold"] < amt:
|
||||
return ["You can't post a %dg bounty." % amt]
|
||||
c["gold"] -= amt
|
||||
ex(con, "UPDATE mud_chars SET bounty=bounty+?, gold=? WHERE id=?", (amt, c["gold"], target["id"]))
|
||||
ex(con, "INSERT INTO mud_bounties (target_id,by_id,amount,ts) VALUES (?,?,?,?)",
|
||||
(target["id"], c["id"], amt, int(time.time())))
|
||||
ex(con, "UPDATE mud_chars SET gold=? WHERE id=?", (c["gold"], c["id"]))
|
||||
return ["You post a %dg bounty on %s. The realm will remember." % (amt, target["name"])]
|
||||
305
bbs/screens.py
Normal file
305
bbs/screens.py
Normal file
@ -0,0 +1,305 @@
|
||||
"""Profile + admin screens for the BBS."""
|
||||
import time
|
||||
from bbsdb import q, q1, ex, user_by_name, user_by_id, hash_password, ago
|
||||
|
||||
PER_PAGE = 10
|
||||
|
||||
|
||||
# ================================================================= profile
|
||||
def profile_menu(t, con, me):
|
||||
while True:
|
||||
u = user_by_id(con, me["id"])
|
||||
posts = q1(con, "SELECT COUNT(*) c FROM posts WHERE user_id=?", (u["id"],))["c"]
|
||||
t.header("YOUR PROFILE")
|
||||
t.line(" User : %s%s" % (u["username"], " [admin]" if u["is_admin"] else ""))
|
||||
t.line(" Tagline : %s" % (u["tagline"] or "-"))
|
||||
t.line(" Location: %s" % (u["location"] or "-"))
|
||||
t.line(" Joined : %s" % time.strftime("%d/%m/%Y",
|
||||
time.localtime(u["created_at"])))
|
||||
t.line(" Posts : %d" % posts)
|
||||
best = q(con, "SELECT game, MAX(score) sc FROM scores WHERE user_id=?"
|
||||
" GROUP BY game", (u["id"],))
|
||||
for b in best:
|
||||
t.line(" Best %-6s: %d" % (b["game"], b["sc"]))
|
||||
t.line("\n [T]agline [L]ocation [P]assword [W]ho's online [Q]back")
|
||||
c = t.ask("\nprofile> ").lower()
|
||||
if c.startswith("t"):
|
||||
v = t.ask(" New tagline: ", maxlen=80)
|
||||
ex(con, "UPDATE users SET tagline=? WHERE id=?", (v, u["id"]))
|
||||
elif c.startswith("l"):
|
||||
v = t.ask(" New location: ", maxlen=40)
|
||||
ex(con, "UPDATE users SET location=? WHERE id=?", (v, u["id"]))
|
||||
elif c.startswith("p"):
|
||||
change_password(t, con, u)
|
||||
elif c.startswith("w"):
|
||||
who(t, con)
|
||||
elif c.startswith("q") or c == "":
|
||||
return
|
||||
|
||||
|
||||
def change_password(t, con, u):
|
||||
from bbsdb import verify_password
|
||||
old = t.secret(" Current password: ")
|
||||
if not verify_password(old, u["pass_hash"]):
|
||||
t.line(" Wrong password.")
|
||||
return
|
||||
n1 = t.secret(" New password: ")
|
||||
if len(n1) < 4:
|
||||
t.line(" Too short (min 4).")
|
||||
return
|
||||
if n1 != t.secret(" Repeat new: "):
|
||||
t.line(" Did not match.")
|
||||
return
|
||||
h = hash_password(n1)
|
||||
if not h:
|
||||
t.line(" Could not hash password - unchanged.")
|
||||
return
|
||||
ex(con, "UPDATE users SET pass_hash=? WHERE id=?", (h, u["id"]))
|
||||
t.line(" Password changed. It works on the website too.")
|
||||
|
||||
|
||||
def who(t, con):
|
||||
rows = q(con, "SELECT username,last_seen,is_admin FROM users "
|
||||
"ORDER BY last_seen DESC LIMIT 15")
|
||||
t.header("RECENTLY SEEN")
|
||||
for r in rows:
|
||||
t.line(" %-16s %s ago%s" % (r["username"][:16], ago(r["last_seen"]),
|
||||
" [admin]" if r["is_admin"] else ""))
|
||||
t.pause()
|
||||
|
||||
|
||||
def userlist(t, con):
|
||||
page = 0
|
||||
while True:
|
||||
rows = q(con, "SELECT username,tagline,last_seen FROM users "
|
||||
"ORDER BY username LIMIT ? OFFSET ?",
|
||||
(PER_PAGE + 1, page * PER_PAGE))
|
||||
more = len(rows) > PER_PAGE
|
||||
rows = rows[:PER_PAGE]
|
||||
t.header("MEMBER LIST")
|
||||
for r in rows:
|
||||
t.line(" %-16s %-24s %s" % (r["username"][:16],
|
||||
(r["tagline"] or "")[:24],
|
||||
ago(r["last_seen"])))
|
||||
t.line("\n [N]ext [P]rev [Q]back")
|
||||
c = t.ask("\nusers> ").lower()
|
||||
if c.startswith("n") and more:
|
||||
page += 1
|
||||
elif c.startswith("p") and page > 0:
|
||||
page -= 1
|
||||
else:
|
||||
return
|
||||
|
||||
|
||||
# ================================================================== admin
|
||||
def admin_menu(t, con, me):
|
||||
if not me["is_admin"]:
|
||||
t.line(" Admin only.")
|
||||
return
|
||||
while True:
|
||||
t.header("ADMIN TOOLS")
|
||||
t.line(" [U]sers [F]orums [T]opics [M]OTD [S]tats [Q]back")
|
||||
c = t.ask("\nadmin> ").lower()
|
||||
if c.startswith("u"):
|
||||
admin_users(t, con, me)
|
||||
elif c.startswith("f"):
|
||||
admin_forums(t, con)
|
||||
elif c.startswith("t"):
|
||||
admin_topics(t, con)
|
||||
elif c.startswith("m"):
|
||||
v = t.ask(" New MOTD: ", maxlen=200)
|
||||
ex(con, "INSERT INTO settings (k,v) VALUES ('motd',?) "
|
||||
"ON CONFLICT(k) DO UPDATE SET v=excluded.v", (v,))
|
||||
t.line(" MOTD updated (site + BBS).")
|
||||
elif c.startswith("s"):
|
||||
admin_stats(t, con)
|
||||
elif c.startswith("q") or c == "":
|
||||
return
|
||||
|
||||
|
||||
def admin_stats(t, con):
|
||||
t.header("STATS")
|
||||
for label, sql in [
|
||||
("Users", "SELECT COUNT(*) c FROM users"),
|
||||
("Banned", "SELECT COUNT(*) c FROM users WHERE is_banned=1"),
|
||||
("Forums", "SELECT COUNT(*) c FROM forums"),
|
||||
("Topics", "SELECT COUNT(*) c FROM topics"),
|
||||
("Posts", "SELECT COUNT(*) c FROM posts"),
|
||||
("Messages", "SELECT COUNT(*) c FROM messages"),
|
||||
("Matches", "SELECT COUNT(*) c FROM matches"),
|
||||
]:
|
||||
t.line(" %-10s %d" % (label, q1(con, sql)["c"]))
|
||||
t.pause()
|
||||
|
||||
|
||||
def admin_users(t, con, me):
|
||||
while True:
|
||||
t.header("ADMIN: USERS")
|
||||
t.line(" [L]ist [F]ind [A]dd [E]dit [Q]back")
|
||||
c = t.ask("\nadmin/users> ").lower()
|
||||
if c.startswith("l"):
|
||||
rows = q(con, "SELECT id,username,is_admin,is_banned FROM users"
|
||||
" ORDER BY id LIMIT 40")
|
||||
for r in rows:
|
||||
t.line(" #%-4d %-16s %s%s" % (
|
||||
r["id"], r["username"][:16],
|
||||
"admin " if r["is_admin"] else "",
|
||||
"BANNED" if r["is_banned"] else ""))
|
||||
t.pause()
|
||||
elif c.startswith("f"):
|
||||
term = t.ask(" Username contains: ", maxlen=16)
|
||||
rows = q(con, "SELECT id,username,is_admin,is_banned FROM users"
|
||||
" WHERE username LIKE ? ORDER BY id LIMIT 40",
|
||||
("%" + term + "%",))
|
||||
if not rows:
|
||||
t.line(" (no matches)")
|
||||
for r in rows:
|
||||
t.line(" #%-4d %-16s %s%s" % (
|
||||
r["id"], r["username"][:16],
|
||||
"admin " if r["is_admin"] else "",
|
||||
"BANNED" if r["is_banned"] else ""))
|
||||
t.pause()
|
||||
elif c.startswith("a"):
|
||||
name = t.ask(" New username: ", maxlen=16)
|
||||
if not name.replace("_", "").isalnum() or not 3 <= len(name) <= 16:
|
||||
t.line(" Bad username (3-16 alnum/underscore).")
|
||||
continue
|
||||
if user_by_name(con, name):
|
||||
t.line(" Already taken.")
|
||||
continue
|
||||
pw = t.secret(" Password: ")
|
||||
if len(pw) < 4:
|
||||
t.line(" Too short.")
|
||||
continue
|
||||
h = hash_password(pw)
|
||||
if not h:
|
||||
t.line(" Hashing failed.")
|
||||
continue
|
||||
now = int(time.time())
|
||||
ex(con, "INSERT INTO users (username,pass_hash,is_admin,created_at)"
|
||||
" VALUES (?,?,0,?)", (name, h, now))
|
||||
t.line(" Created %s." % name)
|
||||
elif c.startswith("e"):
|
||||
admin_edit_user(t, con, me)
|
||||
elif c.startswith("q") or c == "":
|
||||
return
|
||||
|
||||
|
||||
def admin_edit_user(t, con, me):
|
||||
name = t.ask(" Username to edit: ", maxlen=16)
|
||||
u = user_by_name(con, name)
|
||||
if not u:
|
||||
t.line(" No such user.")
|
||||
return
|
||||
while True:
|
||||
u = user_by_id(con, u["id"])
|
||||
t.header("EDIT: %s (#%d)" % (u["username"], u["id"]))
|
||||
t.line(" admin=%d banned=%d tagline=%s" %
|
||||
(u["is_admin"], u["is_banned"], u["tagline"] or "-"))
|
||||
t.line("\n [A]dmin toggle [B]an toggle [R]eset pw [D]elete [Q]back")
|
||||
c = t.ask("\nedit> ").lower()
|
||||
if c.startswith("a"):
|
||||
if u["id"] == me["id"] and u["is_admin"]:
|
||||
t.line(" You cannot remove your own admin rights.")
|
||||
continue
|
||||
ex(con, "UPDATE users SET is_admin=1-is_admin WHERE id=?", (u["id"],))
|
||||
elif c.startswith("b"):
|
||||
if u["id"] == me["id"]:
|
||||
t.line(" You cannot ban yourself.")
|
||||
continue
|
||||
ex(con, "UPDATE users SET is_banned=1-is_banned WHERE id=?", (u["id"],))
|
||||
elif c.startswith("r"):
|
||||
pw = t.secret(" New password: ")
|
||||
if len(pw) < 4:
|
||||
t.line(" Too short.")
|
||||
continue
|
||||
h = hash_password(pw)
|
||||
if h:
|
||||
ex(con, "UPDATE users SET pass_hash=? WHERE id=?", (h, u["id"]))
|
||||
t.line(" Reset.")
|
||||
elif c.startswith("d"):
|
||||
if u["id"] == me["id"]:
|
||||
t.line(" You cannot delete yourself.")
|
||||
continue
|
||||
if t.ask(" Type DELETE to confirm: ") == "DELETE":
|
||||
ex(con, "DELETE FROM users WHERE id=?", (u["id"],))
|
||||
t.line(" Deleted.")
|
||||
return
|
||||
elif c.startswith("q") or c == "":
|
||||
return
|
||||
|
||||
|
||||
def admin_forums(t, con):
|
||||
while True:
|
||||
rows = q(con, "SELECT f.*, (SELECT COUNT(*) FROM topics x "
|
||||
"WHERE x.forum_id=f.id) tc FROM forums f "
|
||||
"ORDER BY f.sort_order, f.id")
|
||||
t.header("ADMIN: FORUMS")
|
||||
for r in rows:
|
||||
t.line(" #%-3d %-24s %3dt %s" % (
|
||||
r["id"], r["name"][:24], r["tc"],
|
||||
"[locked]" if r["is_locked"] else ""))
|
||||
t.line("\n [A]dd [L]ock toggle [R]ename [D]elete [Q]back")
|
||||
c = t.ask("\nadmin/forums> ").lower()
|
||||
if c.startswith("a"):
|
||||
name = t.ask(" Forum name: ", maxlen=60)
|
||||
if not name:
|
||||
continue
|
||||
descr = t.ask(" Description: ", maxlen=120)
|
||||
order = t.ask(" Sort order (number): ", maxlen=4)
|
||||
ex(con, "INSERT INTO forums (name,descr,sort_order) VALUES (?,?,?)",
|
||||
(name, descr, int(order) if order.isdigit() else 0))
|
||||
t.line(" Added.")
|
||||
elif c.startswith("l"):
|
||||
i = t.ask(" Forum id: ", maxlen=6)
|
||||
if i.isdigit():
|
||||
ex(con, "UPDATE forums SET is_locked=1-is_locked WHERE id=?", (int(i),))
|
||||
elif c.startswith("r"):
|
||||
i = t.ask(" Forum id: ", maxlen=6)
|
||||
if i.isdigit():
|
||||
name = t.ask(" New name: ", maxlen=60)
|
||||
if name:
|
||||
ex(con, "UPDATE forums SET name=? WHERE id=?", (name, int(i)))
|
||||
elif c.startswith("d"):
|
||||
i = t.ask(" Forum id to DELETE (with all topics): ", maxlen=6)
|
||||
if i.isdigit() and t.ask(" Type DELETE to confirm: ") == "DELETE":
|
||||
ex(con, "DELETE FROM forums WHERE id=?", (int(i),))
|
||||
t.line(" Deleted.")
|
||||
elif c.startswith("q") or c == "":
|
||||
return
|
||||
|
||||
|
||||
def admin_topics(t, con):
|
||||
while True:
|
||||
rows = q(con, "SELECT t.*, f.name fname, u.username FROM topics t "
|
||||
"JOIN forums f ON f.id=t.forum_id "
|
||||
"LEFT JOIN users u ON u.id=t.user_id "
|
||||
"ORDER BY t.bumped_at DESC LIMIT 20")
|
||||
t.header("ADMIN: TOPICS (latest 20)")
|
||||
for r in rows:
|
||||
t.line(" #%-4d [%-10s] %-26s %s%s" % (
|
||||
r["id"], r["fname"][:10], r["title"][:26],
|
||||
(r["username"] or "?")[:10],
|
||||
" LOCKED" if r["is_locked"] else ""))
|
||||
t.line("\n [L]ock toggle [M]ove [D]elete [Q]back")
|
||||
c = t.ask("\nadmin/topics> ").lower()
|
||||
if c.startswith("l"):
|
||||
i = t.ask(" Topic id: ", maxlen=6)
|
||||
if i.isdigit():
|
||||
ex(con, "UPDATE topics SET is_locked=1-is_locked WHERE id=?", (int(i),))
|
||||
elif c.startswith("m"):
|
||||
i = t.ask(" Topic id: ", maxlen=6)
|
||||
f = t.ask(" Move to forum id: ", maxlen=6)
|
||||
if i.isdigit() and f.isdigit():
|
||||
if q1(con, "SELECT 1 x FROM forums WHERE id=?", (int(f),)):
|
||||
ex(con, "UPDATE topics SET forum_id=? WHERE id=?", (int(f), int(i)))
|
||||
t.line(" Moved.")
|
||||
else:
|
||||
t.line(" No such forum.")
|
||||
elif c.startswith("d"):
|
||||
i = t.ask(" Topic id to DELETE: ", maxlen=6)
|
||||
if i.isdigit() and t.ask(" Type DELETE to confirm: ") == "DELETE":
|
||||
ex(con, "DELETE FROM topics WHERE id=?", (int(i),))
|
||||
t.line(" Deleted.")
|
||||
elif c.startswith("q") or c == "":
|
||||
return
|
||||
163
bbs/term.py
Normal file
163
bbs/term.py
Normal file
@ -0,0 +1,163 @@
|
||||
"""Telnet terminal I/O for the txt3 BBS.
|
||||
|
||||
Deliberately conservative: real telnet clients, netcat, PuTTY in raw mode and
|
||||
Windows telnet all have to work, so we negotiate the bare minimum (suppress
|
||||
go-ahead, refuse everything else) and strip IAC sequences from the byte stream
|
||||
rather than trying to be a full RFC 854 implementation.
|
||||
"""
|
||||
import socket
|
||||
|
||||
IAC = 255
|
||||
DONT = 254
|
||||
DO = 253
|
||||
WONT = 252
|
||||
WILL = 251
|
||||
SB = 250
|
||||
SE = 240
|
||||
ECHO = 1
|
||||
SGA = 3
|
||||
|
||||
CRLF = b"\r\n"
|
||||
|
||||
|
||||
class Hangup(Exception):
|
||||
"""Raised when the peer disconnects or times out."""
|
||||
|
||||
|
||||
class Term:
|
||||
def __init__(self, sock, addr, idle_timeout=600):
|
||||
self.s = sock
|
||||
self.addr = addr
|
||||
self.buf = b""
|
||||
self.s.settimeout(idle_timeout)
|
||||
# We will echo input ourselves only for passwords (as masking);
|
||||
# otherwise let the client echo locally, which is what most do.
|
||||
self._send_raw(bytes([IAC, WILL, SGA]))
|
||||
self._send_raw(bytes([IAC, DO, SGA]))
|
||||
|
||||
# ------------------------------------------------------------- low level
|
||||
def _send_raw(self, data):
|
||||
try:
|
||||
self.s.sendall(data)
|
||||
except (BrokenPipeError, ConnectionResetError, OSError):
|
||||
raise Hangup()
|
||||
|
||||
def write(self, text=""):
|
||||
if isinstance(text, str):
|
||||
# Telnet wants CRLF; also escape a literal IAC byte.
|
||||
text = text.replace("\n", "\r\n").encode("utf-8", "replace")
|
||||
self._send_raw(text.replace(bytes([IAC]), bytes([IAC, IAC])))
|
||||
|
||||
def line(self, text=""):
|
||||
self.write(text + "\n")
|
||||
|
||||
# ------------------------------------------------------------- input
|
||||
def _fill(self):
|
||||
try:
|
||||
chunk = self.s.recv(1024)
|
||||
except socket.timeout:
|
||||
raise Hangup()
|
||||
except (ConnectionResetError, OSError):
|
||||
raise Hangup()
|
||||
if not chunk:
|
||||
raise Hangup()
|
||||
self.buf += chunk
|
||||
|
||||
def _pop_byte(self):
|
||||
while not self.buf:
|
||||
self._fill()
|
||||
b = self.buf[0]
|
||||
self.buf = self.buf[1:]
|
||||
return b
|
||||
|
||||
def _read_char(self):
|
||||
"""Return the next data byte, transparently consuming telnet commands."""
|
||||
while True:
|
||||
b = self._pop_byte()
|
||||
if b != IAC:
|
||||
return b
|
||||
# IAC ...
|
||||
c = self._pop_byte()
|
||||
if c == IAC:
|
||||
return IAC # escaped literal 255
|
||||
if c in (DO, DONT, WILL, WONT):
|
||||
opt = self._pop_byte()
|
||||
# Refuse everything except SGA, which we already agreed.
|
||||
if c == DO:
|
||||
resp = WILL if opt == SGA else WONT
|
||||
elif c == WILL:
|
||||
resp = DO if opt == SGA else DONT
|
||||
else:
|
||||
resp = WONT if c == DO else DONT
|
||||
self._send_raw(bytes([IAC, resp, opt]))
|
||||
continue
|
||||
if c == SB:
|
||||
# swallow the subnegotiation up to IAC SE
|
||||
prev = None
|
||||
while True:
|
||||
x = self._pop_byte()
|
||||
if prev == IAC and x == SE:
|
||||
break
|
||||
prev = x
|
||||
continue
|
||||
# any other 2-byte command: ignore
|
||||
continue
|
||||
|
||||
def read(self, prompt="", mask=False, maxlen=200):
|
||||
"""Read one line of input. Handles backspace and bare CR or LF."""
|
||||
if prompt:
|
||||
self.write(prompt)
|
||||
out = bytearray()
|
||||
while True:
|
||||
b = self._read_char()
|
||||
if b in (13, 10): # CR or LF
|
||||
# A CR is often followed by LF or NUL; peek and drop it.
|
||||
if self.buf[:1] in (b"\n", b"\x00"):
|
||||
self.buf = self.buf[1:]
|
||||
self.write("\n")
|
||||
break
|
||||
if b in (8, 127): # backspace / delete
|
||||
if out:
|
||||
out.pop()
|
||||
if mask:
|
||||
self.write("\b \b")
|
||||
else:
|
||||
self.write("\b \b")
|
||||
continue
|
||||
if b == 3: # ^C
|
||||
raise Hangup()
|
||||
if b == 4 and not out: # ^D on an empty line
|
||||
raise Hangup()
|
||||
if b < 32 or b > 126:
|
||||
continue # ignore other control/non-ascii
|
||||
if len(out) >= maxlen:
|
||||
continue
|
||||
out.append(b)
|
||||
if mask:
|
||||
self.write("*")
|
||||
return out.decode("utf-8", "replace").strip()
|
||||
|
||||
def ask(self, prompt, maxlen=200):
|
||||
return self.read(prompt, mask=False, maxlen=maxlen)
|
||||
|
||||
def secret(self, prompt="Password: "):
|
||||
return self.read(prompt, mask=True, maxlen=60)
|
||||
|
||||
# ------------------------------------------------------------- screen
|
||||
def rule(self, ch="-", n=60):
|
||||
self.line(ch * n)
|
||||
|
||||
def header(self, title):
|
||||
self.line()
|
||||
self.rule("=")
|
||||
self.line(" " + title)
|
||||
self.rule("=")
|
||||
|
||||
def pause(self):
|
||||
self.read("\n[enter] ")
|
||||
|
||||
def close(self):
|
||||
try:
|
||||
self.s.close()
|
||||
except OSError:
|
||||
pass
|
||||
43
bbs/txt3-bbs.service
Normal file
43
bbs/txt3-bbs.service
Normal file
@ -0,0 +1,43 @@
|
||||
[Unit]
|
||||
Description=txt3 BBS (telnet front-end for wap.txt3.net)
|
||||
Documentation=https://wap.txt3.net/about.php
|
||||
After=network-online.target tailscaled.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=txt3
|
||||
Group=txt3
|
||||
WorkingDirectory=/home/txt3/domains/wap.txt3.net/bbs
|
||||
ExecStart=/usr/bin/python3 /home/txt3/domains/wap.txt3.net/bbs/bbsd.py
|
||||
Environment=BBS_HOST=100.127.96.105
|
||||
Environment=BBS_PORT=12300
|
||||
Environment=BBS_DB=/home/txt3/domains/wap.txt3.net/public_html/data/wap.sqlite
|
||||
Environment=BBS_PHP=/usr/bin/php
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
|
||||
# The service only ever needs its own code, the shared sqlite db, and php for
|
||||
# bcrypt hashing. Everything else is locked down.
|
||||
NoNewPrivileges=yes
|
||||
PrivateTmp=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=read-only
|
||||
ReadWritePaths=/home/txt3/domains/wap.txt3.net/public_html/data
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectControlGroups=yes
|
||||
RestrictNamespaces=yes
|
||||
RestrictSUIDSGID=yes
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
|
||||
LockPersonality=yes
|
||||
MemoryDenyWriteExecute=no
|
||||
# tailscale-only listener, so no need for extra port capabilities
|
||||
CapabilityBoundingSet=
|
||||
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
StandardOutput=append:/home/txt3/domains/wap.txt3.net/bbs/bbs.log
|
||||
StandardError=append:/home/txt3/domains/wap.txt3.net/bbs/bbs.log
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
Reference in New Issue
Block a user