345 lines
15 KiB
PHP
345 lines
15 KiB
PHP
<?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]);
|
|
}
|