setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); $pdo->exec('PRAGMA journal_mode = WAL'); $pdo->exec('PRAGMA foreign_keys = ON'); $pdo->exec('PRAGMA busy_timeout = 5000'); db_migrate($pdo); if ($fresh) db_seed($pdo); db_seed_mud($pdo); return $pdo; } function db_migrate(PDO $p): void { $p->exec("CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE COLLATE NOCASE, pass_hash TEXT NOT NULL, is_admin INTEGER NOT NULL DEFAULT 0, is_banned INTEGER NOT NULL DEFAULT 0, tagline TEXT DEFAULT '', location TEXT DEFAULT '', created_at INTEGER NOT NULL, last_seen INTEGER NOT NULL DEFAULT 0 )"); $p->exec("CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, from_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, to_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, subject TEXT NOT NULL, body TEXT NOT NULL, is_read INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL )"); $p->exec("CREATE INDEX IF NOT EXISTS idx_msg_to ON messages(to_id, id DESC)"); $p->exec("CREATE TABLE IF NOT EXISTS forums ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, descr TEXT DEFAULT '', sort_order INTEGER NOT NULL DEFAULT 0, is_locked INTEGER NOT NULL DEFAULT 0 )"); $p->exec("CREATE TABLE IF NOT EXISTS topics ( id INTEGER PRIMARY KEY AUTOINCREMENT, forum_id INTEGER NOT NULL REFERENCES forums(id) ON DELETE CASCADE, user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, title TEXT NOT NULL, is_locked INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL, bumped_at INTEGER NOT NULL )"); $p->exec("CREATE INDEX IF NOT EXISTS idx_topic_forum ON topics(forum_id, bumped_at DESC)"); $p->exec("CREATE TABLE IF NOT EXISTS posts ( id INTEGER PRIMARY KEY AUTOINCREMENT, topic_id INTEGER NOT NULL REFERENCES topics(id) ON DELETE CASCADE, user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, body TEXT NOT NULL, created_at INTEGER NOT NULL )"); $p->exec("CREATE INDEX IF NOT EXISTS idx_post_topic ON posts(topic_id, id)"); // ---- games ---- // single player high scores $p->exec("CREATE TABLE IF NOT EXISTS scores ( id INTEGER PRIMARY KEY AUTOINCREMENT, game TEXT NOT NULL, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, score INTEGER NOT NULL, detail TEXT DEFAULT '', created_at INTEGER NOT NULL )"); $p->exec("CREATE INDEX IF NOT EXISTS idx_score_game ON scores(game, score DESC)"); // generic multiplayer match table (tictactoe, nim) $p->exec("CREATE TABLE IF NOT EXISTS matches ( id INTEGER PRIMARY KEY AUTOINCREMENT, game TEXT NOT NULL, p1 INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, p2 INTEGER REFERENCES users(id) ON DELETE CASCADE, turn INTEGER NOT NULL DEFAULT 1, state TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'open', winner INTEGER DEFAULT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL )"); $p->exec("CREATE INDEX IF NOT EXISTS idx_match_status ON matches(game, status)"); // single-player session state (guess the number) $p->exec("CREATE TABLE IF NOT EXISTS sp_games ( id INTEGER PRIMARY KEY AUTOINCREMENT, game TEXT NOT NULL, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, state TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active', created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL )"); // ---- MUD world ---- $p->exec("CREATE TABLE IF NOT EXISTS mud_rooms ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, descr TEXT NOT NULL, exits TEXT NOT NULL DEFAULT '{}', -- JSON dir -> room_id safe INTEGER NOT NULL DEFAULT 0 )"); $p->exec("CREATE TABLE IF NOT EXISTS mud_mobs ( id INTEGER PRIMARY KEY AUTOINCREMENT, room_id INTEGER NOT NULL, key_name TEXT NOT NULL, -- keyword for 'kill ' name TEXT NOT NULL, descr TEXT NOT NULL, hp INTEGER NOT NULL, atk INTEGER NOT NULL, def INTEGER NOT NULL, xp INTEGER NOT NULL, gold INTEGER NOT NULL DEFAULT 0, loot TEXT NOT NULL DEFAULT '[]', -- JSON [item_key,...] respawn INTEGER NOT NULL DEFAULT 30 )"); $p->exec("CREATE TABLE IF NOT EXISTS mud_spawn ( id INTEGER PRIMARY KEY AUTOINCREMENT, mob_id INTEGER NOT NULL, room_id INTEGER NOT NULL, hp INTEGER NOT NULL, alive INTEGER NOT NULL DEFAULT 1, next_respawn INTEGER NOT NULL DEFAULT 0 )"); $p->exec("CREATE INDEX IF NOT EXISTS idx_mudspawn_room ON mud_spawn(room_id, alive)"); $p->exec("CREATE TABLE IF NOT EXISTS mud_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, key_name TEXT NOT NULL UNIQUE, name TEXT NOT NULL, descr TEXT NOT NULL, slot TEXT, -- weapon|armor|potion|misc atk INTEGER NOT NULL DEFAULT 0, def INTEGER NOT NULL DEFAULT 0, heal INTEGER NOT NULL DEFAULT 0, gold INTEGER NOT NULL DEFAULT 0, price INTEGER NOT NULL DEFAULT 0 -- shop price (0 = not sold) )"); $p->exec("CREATE TABLE IF NOT EXISTS mud_ground ( id INTEGER PRIMARY KEY AUTOINCREMENT, room_id INTEGER NOT NULL, item_id INTEGER NOT NULL )"); // de-dupe any pre-existing duplicate ground rows before the unique index $p->exec("DELETE FROM mud_ground WHERE id NOT IN ( SELECT MIN(id) FROM mud_ground GROUP BY room_id, item_id)"); $p->exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_mudground_room_item ON mud_ground(room_id, item_id)"); $p->exec("CREATE TABLE IF NOT EXISTS mud_chars ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, name TEXT NOT NULL, class TEXT NOT NULL DEFAULT 'fighter', room_id INTEGER NOT NULL DEFAULT 1, hp INTEGER NOT NULL, max_hp INTEGER NOT NULL, atk INTEGER NOT NULL, def INTEGER NOT NULL, xp INTEGER NOT NULL DEFAULT 0, level INTEGER NOT NULL DEFAULT 1, gold INTEGER NOT NULL DEFAULT 0, bank INTEGER NOT NULL DEFAULT 0, bounty INTEGER NOT NULL DEFAULT 0, weapon TEXT DEFAULT NULL, armor TEXT DEFAULT NULL, inv TEXT NOT NULL DEFAULT '[]', -- JSON [item_key,...] kills INTEGER NOT NULL DEFAULT 0, deaths INTEGER NOT NULL DEFAULT 0, last_cmd_at INTEGER NOT NULL DEFAULT 0 )"); $p->exec("CREATE TABLE IF NOT EXISTS mud_bounties ( id INTEGER PRIMARY KEY AUTOINCREMENT, target_id INTEGER NOT NULL, by_id INTEGER NOT NULL, amount INTEGER NOT NULL, ts INTEGER NOT NULL )"); $p->exec("CREATE TABLE IF NOT EXISTS mud_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, room_id INTEGER NOT NULL, ts INTEGER NOT NULL, text TEXT NOT NULL )"); $p->exec("CREATE INDEX IF NOT EXISTS idx_mudev_room ON mud_events(room_id, id DESC)"); $p->exec("CREATE TABLE IF NOT EXISTS settings ( k TEXT PRIMARY KEY, v TEXT NOT NULL )"); } function db_seed_mud(PDO $p): void { // --- migrate schema on an existing install (columns/tables may be new) --- $cols = []; foreach ($p->query("PRAGMA table_info(mud_chars)") as $row) $cols[$row['name']] = 1; $add = [ 'class' => "ALTER TABLE mud_chars ADD COLUMN class TEXT NOT NULL DEFAULT 'fighter'", 'bank' => "ALTER TABLE mud_chars ADD COLUMN bank INTEGER NOT NULL DEFAULT 0", 'bounty' => "ALTER TABLE mud_chars ADD COLUMN bounty INTEGER NOT NULL DEFAULT 0", 'kills' => "ALTER TABLE mud_chars ADD COLUMN kills INTEGER NOT NULL DEFAULT 0", 'deaths' => "ALTER TABLE mud_chars ADD COLUMN deaths INTEGER NOT NULL DEFAULT 0", ]; foreach ($add as $k => $sql) if (!isset($cols[$k])) $p->exec($sql); $p->exec("CREATE TABLE IF NOT EXISTS mud_bounties ( id INTEGER PRIMARY KEY AUTOINCREMENT, target_id INTEGER NOT NULL, by_id INTEGER NOT NULL, amount INTEGER NOT NULL, ts INTEGER NOT NULL )"); // mud_items.price may be new on older installs $icols = []; foreach ($p->query("PRAGMA table_info(mud_items)") as $row) $icols[$row['name']] = 1; if (!isset($icols['price'])) $p->exec("ALTER TABLE mud_items ADD COLUMN price INTEGER NOT NULL DEFAULT 0"); // --- build the base world only if empty --- if ($p->query("SELECT COUNT(*) FROM mud_rooms")->fetchColumn() == 0) { $now = time(); $rooms = [ 1 => ['Village Square', 'A quiet cobbled square. Travellers set out from here.', '{"n":2,"e":5,"s":6,"w":9,"u":10,"d":11}', 1], 2 => ['Forest Path', 'Dappled light, the smell of pine. Something rustles.', '{"s":1,"e":3}', 0], 3 => ['Dark Cave', 'Dripping walls and the flap of leathery wings.', '{"w":2,"d":4}', 0], 4 => ['Cave Depths', 'A vast chamber lit by glowing moss; something large lurks.', '{"u":3,"e":8}', 0], 5 => ['River Bank', 'A clear stream teeming with silver fish.', '{"w":1,"n":7}', 0], 6 => ['Old Ruins', 'Toppled columns and a broken altar.', '{"n":1,"e":7}', 0], 7 => ['Ruins Altar', 'Cold air; a pale shape drifts between the stones.', '{"w":6,"s":5}', 0], 8 => ['Mountain Pass', 'Thin air and a hulking figure blocks the path.', '{"w":4}', 0], 9 => ['Trader\'s Shop', 'Shelves of blades and brews. "Buy or begone," grunts the trader.', '{"e":1}', 1], 10 => ['The Vault', 'A thick-doored bank. Coins are safer here than on your person.', '{"d":1}', 1], 11 => ['The Inn', 'Sir Joe Mollicone, the Taxman, watches the door. "Pay up, adventurer."', '{"u":1}', 1], ]; $ri = $p->prepare("INSERT INTO mud_rooms (id,name,descr,exits,safe) VALUES (?,?,?,?,?)"); foreach ($rooms as $id => $r) $ri->execute([$id, $r[0], $r[1], $r[2], $r[3]]); $mobs = [ [2,'wolf','a Wolf','Mangy but quick.',18,5,2,8,3,'[]',40], [3,'bat','a Cave Bat','A squeaking blur.',12,4,1,5,2,'[]',30], [3,'goblin','a Goblin','Sharp teeth, sharper knife.',26,6,3,14,6,'["rusty_sword"]',45], [4,'orc','an Orc','Scarred and brutal.',46,9,5,40,15,'["leather_armor","gold"]',60], [5,'fish','a River Fish','Slippery and startled.',8,2,1,3,1,'[]',20], [6,'skeleton','a Skeleton','Rattling bones, rusted blade.',30,7,4,20,8,'[]',45], [7,'ghost','a Ghost','Cold, wailing, half-seen.',38,8,3,30,12,'["healing_potion"]',45], [8,'troll','a Troll','Twice your height and thick as a tree.',70,12,8,80,30,'["rusty_sword","leather_armor","gold"]',90], ]; $mi = $p->prepare("INSERT INTO mud_mobs (room_id,key_name,name,descr,hp,atk,def,xp,gold,loot,respawn) VALUES (?,?,?,?,?,?,?,?,?,?,?)"); $si = $p->prepare("INSERT INTO mud_spawn (mob_id,room_id,hp,alive,next_respawn) VALUES (?,?,?,1,0)"); foreach ($mobs as $m) { $mi->execute($m); $mid = $p->lastInsertId(); $si->execute([$mid, $m[0], $m[4]]); } } else { // existing world: make sure the shop/bank/taxman rooms and the village // links to them exist (idempotent). $new_rooms = [ 9 => ['Trader\'s Shop', 'Shelves of blades and brews. "Buy or begone," grunts the trader.', '{"e":1}', 1], 10 => ['The Vault', 'A thick-doored bank. Coins are safer here than on your person.', '{"d":1}', 1], 11 => ['The Inn', 'Sir Joe Mollicone, the Taxman, watches the door. "Pay up, adventurer."', '{"u":1}', 1], ]; $ri = $p->prepare("INSERT OR IGNORE INTO mud_rooms (id,name,descr,exits,safe) VALUES (?,?,?,?,?)"); foreach ($new_rooms as $id => $r) $ri->execute([$id, $r[0], $r[1], $r[2], $r[3]]); // extend the village (room 1) exits to include w/u/d if not present $v = $p->query("SELECT exits FROM mud_rooms WHERE id=1")->fetchColumn(); $vx = json_decode($v, true); $vx['w'] = 9; $vx['u'] = 10; $vx['d'] = 11; $p->prepare("UPDATE mud_rooms SET exits=? WHERE id=1")->execute([json_encode($vx)]); } // --- shop stock (idempotent by key_name) --- $items = [ ['rusty_sword','Rusty Sword','A chipped but serviceable blade.','weapon',4,0,0,0,20], ['leather_armor','Leather Armor','Cracked but protective.','armor',0,3,0,0,25], ['healing_potion','Healing Potion','Bitter herbs that mend wounds.','potion',0,0,25,0,15], ['steel_sword','Steel Sword','A keen edge for the serious.','weapon',8,0,0,0,60], ['plate_armor','Plate Armor','Heavy and reassuring.','armor',0,7,0,0,70], ['gold','Gold Coins','A small pile of coin.','misc',0,0,0,10,0], ['torch','Torch','Flickering light against the dark.','misc',1,0,0,0,5], ]; $ii = $p->prepare("INSERT OR IGNORE INTO mud_items (key_name,name,descr,slot,atk,def,heal,gold,price) VALUES (?,?,?,?,?,?,?,?,?)"); foreach ($items as $it) $ii->execute($it); // a couple of ground items to find $g1 = $p->prepare("INSERT OR IGNORE INTO mud_ground (room_id,item_id) SELECT 1, id FROM mud_items WHERE key_name='healing_potion'"); $g1->execute(); $g1 = $p->prepare("INSERT OR IGNORE INTO mud_ground (room_id,item_id) SELECT 3, id FROM mud_items WHERE key_name='torch'"); $g1->execute(); } function db_seed(PDO $p): void { $now = time(); $p->prepare("INSERT INTO users (username,pass_hash,is_admin,tagline,created_at) VALUES (?,?,1,?,?)") ->execute([BOOTSTRAP_ADMIN, password_hash(BOOTSTRAP_ADMIN_PW, PASSWORD_DEFAULT), 'site admin', $now]); $f = $p->prepare("INSERT INTO forums (name,descr,sort_order) VALUES (?,?,?)"); $f->execute(['General', 'Anything goes', 1]); $f->execute(['Mobile', 'Phones, WAP, retro kit', 2]); $f->execute(['Games', 'Talk about the site games', 3]); $p->prepare("INSERT INTO settings (k,v) VALUES ('motd',?)") ->execute(['Welcome to ' . SITE_NAME . '!']); } function setting(string $k, string $default = ''): string { $r = db()->prepare("SELECT v FROM settings WHERE k=?"); $r->execute([$k]); $v = $r->fetchColumn(); return $v === false ? $default : (string)$v; } function setting_set(string $k, string $v): void { db()->prepare("INSERT INTO settings (k,v) VALUES (?,?) ON CONFLICT(k) DO UPDATE SET v=excluded.v")->execute([$k, $v]); }