Fix: decode base64-corrupted source files (html/css/js + backend)

This commit is contained in:
jessikitty committed 2026-06-19 05:06:43 +00:00
1 parent 327b37babb
commit 458c66a2c0
6 files changed
+2699 -5

No files matched your search

+88 -1
View File
@@ -1 +1,88 @@
aW1wb3J0IERhdGFiYXNlIGZyb20gJ2JldHRlci1zcWxpdGUzJzsKaW1wb3J0IHsgbWtkaXJTeW5jIH0gZnJvbSAnbm9kZTpmcyc7CmltcG9ydCB7IGRpcm5hbWUsIGpvaW4gfSBmcm9tICdub2RlOnBhdGgnOwppbXBvcnQgeyBmaWxlVVJMVG9QYXRoIH0gZnJvbSAnbm9kZTp1cmwnOwoKY29uc3QgX19kaXJuYW1lID0gZGlybmFtZShmaWxlVVJMVG9QYXRoKGltcG9ydC5tZXRhLnVybCkpOwpjb25zdCBEQl9QQVRIID0gam9pbihfX2Rpcm5hbWUsICduZXdidXJ5LnNxbGl0ZScpOwoKbWtkaXJTeW5jKF9fZGlybmFtZSwgeyByZWN1cnNpdmU6IHRydWUgfSk7Cgpjb25zdCBkYiA9IG5ldyBEYXRhYmFzZShEQl9QQVRIKTsKZGIucHJhZ21hKCdqb3VybmFsX21vZGUgPSBXQUwnKTsKZGIucHJhZ21hKCdmb3JlaWduX2tleXMgPSBPTicpOwoKZGIuZXhlYyhgCkNSRUFURSBUQUJMRSBJRiBOT1QgRVhJU1RTIHVzZXJzICgKICBpZCAgICAgICAgICAgIElOVEVHRVIgUFJJTUFSWSBLRVkgQVVUT0lOQ1JFTUVOVCwKICB1c2VybmFtZSAgICAgIFRFWFQgVU5JUVVFIE5PVCBOVUxMLAogIHBhc3N3b3JkX2hhc2ggVEVYVCBOT1QgTlVMTCwKICByb2xlICAgICAgICAgIFRFWFQgTk9UIE5VTEwgREVGQVVMVCAnYWRtaW4nLAogIGNyZWF0ZWRfYXQgICAgVEVYVCBOT1QgTlVMTCBERUZBVUxUIChkYXRldGltZSgnbm93JykpCik7CgpDUkVBVEUgVEFCTEUgSUYgTk9UIEVYSVNUUyBhYmlsaXRpZXMgKAogIGlkICAgICAgICBJTlRFR0VSIFBSSU1BUlkgS0VZIEFVVE9JTkNSRU1FTlQsCiAgbmFtZSAgICAgIFRFWFQgVU5JUVVFIE5PVCBOVUxMLAogIGtpbmQgICAgICBURVhUIE5PVCBOVUxMIERFRkFVTFQgJ2NvbW1vbicsICAgLS0gY29tbW9uIHwgYm9zcwogIGNoYXJnZXMgICBJTlRFR0VSLAogIGNvb2xkb3duICBURVhULAogIGVmZmVjdCAgICBURVhUCik7CgpDUkVBVEUgVEFCTEUgSUYgTk9UIEVYSVNUUyBnaG9zdHMgKAogIGlkICAgICAgICAgICBJTlRFR0VSIFBSSU1BUlkgS0VZIEFVVE9JTkNSRU1FTlQsCiAgbmFtZSAgICAgICAgIFRFWFQgTk9UIE5VTEwsCiAgZGlzcGxheV9uYW1lIFRFWFQsICAgICAgICAgICAgICAgICAgICAgICAgICAgLS0gc2hvd24gb24gdGhlIGxvY2stb24gbGFiZWw7IGZhbGxzIGJhY2sgdG8gbmFtZQogIHR5cGUgICAgICAgICBURVhUIE5PVCBOVUxMLCAgICAgICAgICAgICAgICAgIC0tIHJlZCB8IHllbGxvdyB8IGJsdWUKICByYXJpdHkgICAgICAgSU5URUdFUiBOT1QgTlVMTCwgICAgICAgICAgICAgICAtLSAxLi40IChzdGFycykKICBzcGVlZCAgICAgICAgSU5URUdFUiBOT1QgTlVMTCBERUZBVUxUIDAsCiAgcmFuZ2UgICAgICAgIElOVEVHRVIgTk9UIE5VTEwgREVGQVVMVCAwLAogIGNoYXJnZV9zaG90ICBJTlRFR0VSIE5PVCBOVUxMIERFRkFVTFQgMCwKICBoZWFsdGggICAgICAgSU5URUdFUiBOT1QgTlVMTCwKICBkYW1hZ2UgICAgICAgSU5URUdFUiBOT1QgTlVMTCwKICBhYmlsaXR5ICAgICAgVEVYVCwKICBpc19ib3NzICAgICAgSU5URUdFUiBOT1QgTlVMTCBERUZBVUxUIDAsCiAgc2V0X251bWJlciAgIFRFWFQsCiAgc2V0X25hbWUgICAgIFRFWFQsCiAgaW1hZ2VfcGF0aCAgIFRFWFQsICAgICAgICAgICAgICAgICAgICAgICAgICAgLS0gdXBsb2FkZWQgR0lGL1BORyBiaWxsYm9hcmQgKG51bGxhYmxlKQogIGVuYWJsZWQgICAgICBJTlRFR0VSIE5PVCBOVUxMIERFRkFVTFQgMSwKICBjcmVhdGVkX2F0ICAgVEVYVCBOT1QgTlVMTCBERUZBVUxUIChkYXRldGltZSgnbm93JykpCik7CkNSRUFURSBJTkRFWCBJRiBOT1QgRVhJU1RTIGlkeF9naG9zdHNfdHlwZSAgIE9OIGdob3N0cyh0eXBlKTsKQ1JFQVRFIElOREVYIElGIE5PVCBFWElTVFMgaWR4X2dob3N0c19yYXJpdHkgT04gZ2hvc3RzKHJhcml0eSk7CkNSRUFURSBJTkRFWCBJRiBOT1QgRVhJU1RTIGlkeF9naG9zdHNfYm9zcyAgIE9OIGdob3N0cyhpc19ib3NzKTsKCkNSRUFURSBUQUJMRSBJRiBOT1QgRVhJU1RTIHNldHMgKAogIGlkICAgICAgICAgIElOVEVHRVIgUFJJTUFSWSBLRVkgQVVUT0lOQ1JFTUVOVCwKICBjb2RlICAgICAgICBURVhUIFVOSVFVRSBOT1QgTlVMTCwgICAgICAgICAgICAtLSBRUiBwYXlsb2FkIC8gc2NhbiBjb2RlCiAgc2V0X251bWJlciAgVEVYVCwgICAgICAgICAgICAgICAgICAgICAgICAgICAgLS0gZS5nLiA3MDQxOSAocmVmZXJlbmNlIG9ubHkpCiAgc2V0X25hbWUgICAgVEVYVCBOT1QgTlVMTCwKICBib3NzX2dob3N0X2lkIElOVEVHRVIgUkVGRVJFTkNFUyBnaG9zdHMoaWQpIE9OIERFTEVURSBTRVQgTlVMTCwKICBlbmFibGVkICAgICBJTlRFR0VSIE5PVCBOVUxMIERFRkFVTFQgMSwKICBjcmVhdGVkX2F0ICBURVhUIE5PVCBOVUxMIERFRkFVTFQgKGRhdGV0aW1lKCdub3cnKSkKKTsKCkNSRUFURSBUQUJMRSBJRiBOT1QgRVhJU1RTIHNldF9naG9zdHMgKAogIHNldF9pZCAgIElOVEVHRVIgTk9UIE5VTEwgUkVGRVJFTkNFUyBzZXRzKGlkKSBPTiBERUxFVEUgQ0FTQ0FERSwKICBnaG9zdF9pZCBJTlRFR0VSIE5PVCBOVUxMIFJFRkVSRU5DRVMgZ2hvc3RzKGlkKSBPTiBERUxFVEUgQ0FTQ0FERSwKICBQUklNQVJZIEtFWSAoc2V0X2lkLCBnaG9zdF9pZCkKKTsKYCk7CgovKiAtLS0tIE1pZ3JhdGlvbnMgKGlkZW1wb3RlbnQpIC0tLS0KICogQWRkIHRyYW5zcGFyZW50LXZpZGVvIHNwcml0ZSBjb2x1bW5zIHRvIHRoZSBleGlzdGluZyBnaG9zdHMgdGFibGUgd2l0aG91dAogKiBkcm9wcGluZyBkYXRhLiB3ZWJtX3BhdGggaXMgdGhlIFZQOSthbHBoYSBiaWxsYm9hcmQ7IHdlYnBfcGF0aCBpcyB0aGUKICogYW5pbWF0ZWQtV2ViUCBmYWxsYmFjayB1c2VkIHdoZXJlIFZQOSBhbHBoYSBpc24ndCBzdXBwb3J0ZWQgKGUuZy4gaU9TKS4KICovCmZ1bmN0aW9uIGFkZENvbHVtbklmTWlzc2luZyh0YWJsZSwgY29sdW1uLCBkZWNsKSB7CiAgY29uc3QgY29scyA9IGRiLnByZXBhcmUoYFBSQUdNQSB0YWJsZV9pbmZvKCR7dGFibGV9KWApLmFsbCgpOwogIGlmICghY29scy5zb21lKChjKSA9PiBjLm5hbWUgPT09IGNvbHVtbikpIHsKICAgIGRiLmV4ZWMoYEFMVEVSIFRBQkxFICR7dGFibGV9IEFERCBDT0xVTU4gJHtjb2x1bW59ICR7ZGVjbH1gKTsKICB9Cn0KYWRkQ29sdW1uSWZNaXNzaW5nKCdnaG9zdHMnLCAnd2VibV9wYXRoJywgJ1RFWFQnKTsgLy8gVlA5K2FscGhhIGJpbGxib2FyZCAobnVsbGFibGUpCmFkZENvbHVtbklmTWlzc2luZygnZ2hvc3RzJywgJ3dlYnBfcGF0aCcsICdURVhUJyk7IC8vIGFuaW1hdGVkLVdlYlAgZmFsbGJhY2sgKG51bGxhYmxlKQoKZXhwb3J0IGRlZmF1bHQgZGI7CmV4cG9ydCB7IERCX1BBVEggfTsK import Database from 'better-sqlite3';
import { mkdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const DB_PATH = join(__dirname, 'newbury.sqlite');
mkdirSync(__dirname, { recursive: true });
const db = new Database(DB_PATH);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'admin',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS abilities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
kind TEXT NOT NULL DEFAULT 'common', -- common | boss
charges INTEGER,
cooldown TEXT,
effect TEXT
);
CREATE TABLE IF NOT EXISTS ghosts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
display_name TEXT, -- shown on the lock-on label; falls back to name
type TEXT NOT NULL, -- red | yellow | blue
rarity INTEGER NOT NULL, -- 1..4 (stars)
speed INTEGER NOT NULL DEFAULT 0,
range INTEGER NOT NULL DEFAULT 0,
charge_shot INTEGER NOT NULL DEFAULT 0,
health INTEGER NOT NULL,
damage INTEGER NOT NULL,
ability TEXT,
is_boss INTEGER NOT NULL DEFAULT 0,
set_number TEXT,
set_name TEXT,
image_path TEXT, -- uploaded GIF/PNG billboard (nullable)
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_ghosts_type ON ghosts(type);
CREATE INDEX IF NOT EXISTS idx_ghosts_rarity ON ghosts(rarity);
CREATE INDEX IF NOT EXISTS idx_ghosts_boss ON ghosts(is_boss);
CREATE TABLE IF NOT EXISTS sets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT UNIQUE NOT NULL, -- QR payload / scan code
set_number TEXT, -- e.g. 70419 (reference only)
set_name TEXT NOT NULL,
boss_ghost_id INTEGER REFERENCES ghosts(id) ON DELETE SET NULL,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS set_ghosts (
set_id INTEGER NOT NULL REFERENCES sets(id) ON DELETE CASCADE,
ghost_id INTEGER NOT NULL REFERENCES ghosts(id) ON DELETE CASCADE,
PRIMARY KEY (set_id, ghost_id)
);
`);
/* ---- Migrations (idempotent) ----
* Add transparent-video sprite columns to the existing ghosts table without
* dropping data. webm_path is the VP9+alpha billboard; webp_path is the
* animated-WebP fallback used where VP9 alpha isn't supported (e.g. iOS).
*/
function addColumnIfMissing(table, column, decl) {
const cols = db.prepare(`PRAGMA table_info(${table})`).all();
if (!cols.some((c) => c.name === column)) {
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${decl}`);
}
}
addColumnIfMissing('ghosts', 'webm_path', 'TEXT'); // VP9+alpha billboard (nullable)
addColumnIfMissing('ghosts', 'webp_path', 'TEXT'); // animated-WebP fallback (nullable)
export default db;
export { DB_PATH };
+111 -1
View File
@@ -1 +1,111 @@
aW1wb3J0IHsgc3Bhd24gfSBmcm9tICdub2RlOmNoaWxkX3Byb2Nlc3MnOwppbXBvcnQgeyBleGlzdHNTeW5jLCBzdGF0U3luYyB9IGZyb20gJ25vZGU6ZnMnOwppbXBvcnQgeyBiYXNlbmFtZSwgZXh0bmFtZSwgam9pbiB9IGZyb20gJ25vZGU6cGF0aCc7CgovKgogKiBnaG9zdC1tZWRpYS5qcyDigJQgc2VydmVyLXNpZGUgZ2hvc3Qgc3ByaXRlIGNvbnZlcnNpb24uCiAqCiAqIFR1cm5zIGFuIHVwbG9hZGVkIGJsYWNrLWJhY2tncm91bmQgZ2hvc3QgTVA0IGludG8gYSB0cmFuc3BhcmVudCBhbmltYXRlZAogKiBXZWJNIChWUDkgKyBhbHBoYSkgcGx1cyBhbiBhbmltYXRlZCBXZWJQIGZhbGxiYWNrLCB1c2luZyBsdW1hIGtleWluZyBzbyB0aGUKICogYmxhY2sgYmVjb21lcyB0cmFuc3BhcmVudCBhbmQgdGhlIGdsb3cga2VlcHMgc29mdCBzZW1pLXRyYW5zcGFyZW50IGVkZ2VzLgogKgogKiBTaGVsbHMgb3V0IHRvIHRoZSBzeXN0ZW0gYGZmbXBlZ2AgYmluYXJ5IChubyBucG0gZGVwZW5kZW5jeSkuIElmIGZmbXBlZyBpcwogKiBub3QgaW5zdGFsbGVkLCBjb252ZXJzaW9uIGZhaWxzIGdyYWNlZnVsbHkgYW5kIHRoZSBjYWxsZXIga2VlcHMgdGhlIG9yaWdpbmFsLgogKgogKiBUaGlzIG1pcnJvcnMgdGhlIHN0YW5kYWxvbmUgZ2hvc3RpZnkuc2ggcGlwZWxpbmUgc28gbG9jYWwgYW5kIHNlcnZlciBvdXRwdXQKICogbWF0Y2guCiAqLwoKY29uc3QgRkZNUEVHID0gcHJvY2Vzcy5lbnYuRkZNUEVHX1BBVEggfHwgJ2ZmbXBlZyc7CgovLyBUdW5hYmxlcyAoZW52LW92ZXJyaWRhYmxlKSDigJQga2VwdCBpbiBzeW5jIHdpdGggZ2hvc3RpZnkuc2ggZGVmYXVsdHMuCmNvbnN0IFRIUkVTSCA9IHByb2Nlc3MuZW52LkdIT1NUX0tFWV9USFJFU0hPTEQgfHwgJzAuMDYnOwpjb25zdCBUT0wgPSBwcm9jZXNzLmVudi5HSE9TVF9LRVlfVE9MRVJBTkNFIHx8ICcwLjEwJzsKY29uc3QgU09GVCA9IHByb2Nlc3MuZW52LkdIT1NUX0tFWV9TT0ZUTkVTUyB8fCAnMC4xNSc7CmNvbnN0IEZQUyA9IHByb2Nlc3MuZW52LkdIT1NUX0ZQUyB8fCAnMTUnOwpjb25zdCBNQVhXID0gcHJvY2Vzcy5lbnYuR0hPU1RfTUFYVyB8fCAnNTEyJzsKY29uc3QgV0VCUF9RID0gcHJvY2Vzcy5lbnYuR0hPU1RfV0VCUF9RVUFMSVRZIHx8ICc4MCc7CmNvbnN0IFZQOV9DUkYgPSBwcm9jZXNzLmVudi5HSE9TVF9WUDlfQ1JGIHx8ICczMCc7CgovLyBUaGUgc2hhcmVkIGx1bWEta2V5ICsgZG93bnNjYWxlIGZpbHRlciBjaGFpbi4KZnVuY3Rpb24ga2V5Q2hhaW4oKSB7CiAgY29uc3Qga2V5ID0gYGZvcm1hdD1yZ2JhLGx1bWFrZXk9dGhyZXNob2xkPSR7VEhSRVNIfTp0b2xlcmFuY2U9JHtUT0x9OnNvZnRuZXNzPSR7U09GVH1gOwogIGNvbnN0IHNjYWxlID0gYHNjYWxlPSdtaW4oaXcsJHtNQVhXfSknOi0xOmZsYWdzPWxhbmN6b3NgOwogIHJldHVybiBgJHtrZXl9LGZwcz0ke0ZQU30sJHtzY2FsZX1gOwp9CgpmdW5jdGlvbiBydW4oYXJncykgewogIHJldHVybiBuZXcgUHJvbWlzZSgocmVzb2x2ZSwgcmVqZWN0KSA9PiB7CiAgICBjb25zdCBwcm9jID0gc3Bhd24oRkZNUEVHLCBhcmdzLCB7IHN0ZGlvOiBbJ2lnbm9yZScsICdpZ25vcmUnLCAncGlwZSddIH0pOwogICAgbGV0IHN0ZGVyciA9ICcnOwogICAgcHJvYy5zdGRlcnIub24oJ2RhdGEnLCAoZCkgPT4geyBzdGRlcnIgKz0gZC50b1N0cmluZygpOyB9KTsKICAgIHByb2Mub24oJ2Vycm9yJywgcmVqZWN0KTsgLy8gZS5nLiBmZm1wZWcgbm90IGZvdW5kIChFTk9FTlQpCiAgICBwcm9jLm9uKCdjbG9zZScsIChjb2RlKSA9PiB7CiAgICAgIGlmIChjb2RlID09PSAwKSByZXNvbHZlKCk7CiAgICAgIGVsc2UgcmVqZWN0KG5ldyBFcnJvcihgZmZtcGVnIGV4aXRlZCAke2NvZGV9OiAke3N0ZGVyci5zbGljZSgtNTAwKX1gKSk7CiAgICB9KTsKICB9KTsKfQoKLyoqIFRydWUgaWYgYSB1c2FibGUgZmZtcGVnIGJpbmFyeSBpcyBvbiBQQVRILiAqLwpleHBvcnQgZnVuY3Rpb24gZmZtcGVnQXZhaWxhYmxlKCkgewogIHJldHVybiBuZXcgUHJvbWlzZSgocmVzb2x2ZSkgPT4gewogICAgY29uc3QgcHJvYyA9IHNwYXduKEZGTVBFRywgWyctdmVyc2lvbiddLCB7IHN0ZGlvOiAnaWdub3JlJyB9KTsKICAgIHByb2Mub24oJ2Vycm9yJywgKCkgPT4gcmVzb2x2ZShmYWxzZSkpOwogICAgcHJvYy5vbignY2xvc2UnLCAoY29kZSkgPT4gcmVzb2x2ZShjb2RlID09PSAwKSk7CiAgfSk7Cn0KCmZ1bmN0aW9uIG91dE5hbWUoc3JjRmlsZW5hbWUsIGV4dCkgewogIC8vIHdyYWl0aC0xNzE4Lm1wNCAtPiB3cmFpdGgtMTcxOC53ZWJtIC8gLndlYnAsIHdyaXR0ZW4gYWxvbmdzaWRlIHRoZSBzb3VyY2UuCiAgY29uc3Qgc3RlbSA9IGJhc2VuYW1lKHNyY0ZpbGVuYW1lLCBleHRuYW1lKHNyY0ZpbGVuYW1lKSk7CiAgcmV0dXJuIGAke3N0ZW19LiR7ZXh0fWA7Cn0KCi8qKgogKiBDb252ZXJ0IGEgc291cmNlIE1QNCAoYWxyZWFkeSBzYXZlZCBpbiB1cGxvYWREaXIpIGludG8gdHJhbnNwYXJlbnQgd2VibSt3ZWJwLgogKgogKiBAcGFyYW0ge3N0cmluZ30gdXBsb2FkRGlyICBhYnNvbHV0ZSB1cGxvYWRzIGRpcmVjdG9yeQogKiBAcGFyYW0ge3N0cmluZ30gc3JjRmlsZW5hbWUgdGhlIG11bHRlciBmaWxlbmFtZSBvZiB0aGUgdXBsb2FkZWQgbXA0CiAqIEByZXR1cm5zIHtQcm9taXNlPHsgd2VibTogc3RyaW5nfG51bGwsIHdlYnA6IHN0cmluZ3xudWxsIH0+fSBuZXcgZmlsZW5hbWVzCiAqICAgICAgICAgIChub3QgZnVsbCBwYXRocykuIEVpdGhlciBtYXkgYmUgbnVsbCBpZiB0aGF0IGVuY29kZSBmYWlsZWQuCiAqLwpleHBvcnQgYXN5bmMgZnVuY3Rpb24gY29udmVydEdob3N0TXA0KHVwbG9hZERpciwgc3JjRmlsZW5hbWUpIHsKICBjb25zdCBzcmNQYXRoID0gam9pbih1cGxvYWREaXIsIHNyY0ZpbGVuYW1lKTsKICBpZiAoIWV4aXN0c1N5bmMoc3JjUGF0aCkpIHRocm93IG5ldyBFcnJvcihgc291cmNlIG5vdCBmb3VuZDogJHtzcmNQYXRofWApOwoKICBjb25zdCB2ZiA9IGtleUNoYWluKCk7CiAgY29uc3QgcmVzdWx0ID0geyB3ZWJtOiBudWxsLCB3ZWJwOiBudWxsIH07CgogIC8vIC0tLSBXZWJNIChWUDkgKyBhbHBoYSk6IHRoZSBwcmltYXJ5LCBicm93c2VyLWRlY29kZWQgc3ByaXRlIC0tLQogIGNvbnN0IHdlYm1OYW1lID0gb3V0TmFtZShzcmNGaWxlbmFtZSwgJ3dlYm0nKTsKICB0cnkgewogICAgYXdhaXQgcnVuKFsKICAgICAgJy1oaWRlX2Jhbm5lcicsICctbG9nbGV2ZWwnLCAnZXJyb3InLCAnLXknLCAnLWknLCBzcmNQYXRoLAogICAgICAnLXZmJywgdmYsICctYW4nLAogICAgICAnLWM6dicsICdsaWJ2cHgtdnA5JywgJy1waXhfZm10JywgJ3l1dmE0MjBwJywKICAgICAgJy1iOnYnLCAnMCcsICctY3JmJywgVlA5X0NSRiwgJy1hdXRvLWFsdC1yZWYnLCAnMCcsCiAgICAgIGpvaW4odXBsb2FkRGlyLCB3ZWJtTmFtZSksCiAgICBdKTsKICAgIGlmIChzdGF0U3luYyhqb2luKHVwbG9hZERpciwgd2VibU5hbWUpKS5zaXplID4gMCkgcmVzdWx0LndlYm0gPSB3ZWJtTmFtZTsKICB9IGNhdGNoIChlKSB7CiAgICBjb25zb2xlLmVycm9yKCdbZ2hvc3QtbWVkaWFdIHdlYm0gZW5jb2RlIGZhaWxlZDonLCBlLm1lc3NhZ2UpOwogIH0KCiAgLy8gLS0tIFdlYlAgZmFsbGJhY2s6IGZvciBpT1MgU2FmYXJpIC8gbm8tVlA5LWFscGhhIGJyb3dzZXJzIC0tLQogIGNvbnN0IHdlYnBOYW1lID0gb3V0TmFtZShzcmNGaWxlbmFtZSwgJ3dlYnAnKTsKICB0cnkgewogICAgYXdhaXQgcnVuKFsKICAgICAgJy1oaWRlX2Jhbm5Line truncated import { spawn } from 'node:child_process';
import { existsSync, statSync } from 'node:fs';
import { basename, extname, join } from 'node:path';
/*
* ghost-media.js — server-side ghost sprite conversion.
*
* Turns an uploaded black-background ghost MP4 into a transparent animated
* WebM (VP9 + alpha) plus an animated WebP fallback, using luma keying so the
* black becomes transparent and the glow keeps soft semi-transparent edges.
*
* Shells out to the system `ffmpeg` binary (no npm dependency). If ffmpeg is
* not installed, conversion fails gracefully and the caller keeps the original.
*
* This mirrors the standalone ghostify.sh pipeline so local and server output
* match.
*/
const FFMPEG = process.env.FFMPEG_PATH || 'ffmpeg';
// Tunables (env-overridable) — kept in sync with ghostify.sh defaults.
const THRESH = process.env.GHOST_KEY_THRESHOLD || '0.06';
const TOL = process.env.GHOST_KEY_TOLERANCE || '0.10';
const SOFT = process.env.GHOST_KEY_SOFTNESS || '0.15';
const FPS = process.env.GHOST_FPS || '15';
const MAXW = process.env.GHOST_MAXW || '512';
const WEBP_Q = process.env.GHOST_WEBP_QUALITY || '80';
const VP9_CRF = process.env.GHOST_VP9_CRF || '30';
// The shared luma-key + downscale filter chain.
function keyChain() {
const key = `format=rgba,lumakey=threshold=${THRESH}:tolerance=${TOL}:softness=${SOFT}`;
const scale = `scale='min(iw,${MAXW})':-1:flags=lanczos`;
return `${key},fps=${FPS},${scale}`;
}
function run(args) {
return new Promise((resolve, reject) => {
const proc = spawn(FFMPEG, args, { stdio: ['ignore', 'ignore', 'pipe'] });
let stderr = '';
proc.stderr.on('data', (d) => { stderr += d.toString(); });
proc.on('error', reject); // e.g. ffmpeg not found (ENOENT)
proc.on('close', (code) => {
if (code === 0) resolve();
else reject(new Error(`ffmpeg exited ${code}: ${stderr.slice(-500)}`));
});
});
}
/** True if a usable ffmpeg binary is on PATH. */
export function ffmpegAvailable() {
return new Promise((resolve) => {
const proc = spawn(FFMPEG, ['-version'], { stdio: 'ignore' });
proc.on('error', () => resolve(false));
proc.on('close', (code) => resolve(code === 0));
});
}
function outName(srcFilename, ext) {
// wraith-1718.mp4 -> wraith-1718.webm / .webp, written alongside the source.
const stem = basename(srcFilename, extname(srcFilename));
return `${stem}.${ext}`;
}
/**
* Convert a source MP4 (already saved in uploadDir) into transparent webm+webp.
*
* @param {string} uploadDir absolute uploads directory
* @param {string} srcFilename the multer filename of the uploaded mp4
* @returns {Promise<{ webm: string|null, webp: string|null }>} new filenames
* (not full paths). Either may be null if that encode failed.
*/
export async function convertGhostMp4(uploadDir, srcFilename) {
const srcPath = join(uploadDir, srcFilename);
if (!existsSync(srcPath)) throw new Error(`source not found: ${srcPath}`);
const vf = keyChain();
const result = { webm: null, webp: null };
// --- WebM (VP9 + alpha): the primary, browser-decoded sprite ---
const webmName = outName(srcFilename, 'webm');
try {
await run([
'-hide_banner', '-loglevel', 'error', '-y', '-i', srcPath,
'-vf', vf, '-an',
'-c:v', 'libvpx-vp9', '-pix_fmt', 'yuva420p',
'-b:v', '0', '-crf', VP9_CRF, '-auto-alt-ref', '0',
join(uploadDir, webmName),
]);
if (statSync(join(uploadDir, webmName)).size > 0) result.webm = webmName;
} catch (e) {
console.error('[ghost-media] webm encode failed:', e.message);
}
// --- WebP fallback: for iOS Safari / no-VP9-alpha browsers ---
const webpName = outName(srcFilename, 'webp');
try {
await run([
'-hide_banner', '-loglevel', 'error', '-y', '-i', srcPath,
'-vf', vf, '-loop', '0', '-an',
'-c:v', 'libwebp_anim', '-lossless', '0', '-q:v', WEBP_Q,
'-preset', 'drawing', '-compression_level', '6',
join(uploadDir, webpName),
]);
if (statSync(join(uploadDir, webpName)).size > 0) result.webp = webpName;
} catch (e) {
console.error('[ghost-media] webp encode failed:', e.message);
}
return result;
}
+1496
View File
File diff suppressed because it is too large. Load diff
+654 -1
View File
@@ -1 +1,654 @@
aW1wb3J0ICogYXMgVEhSRUUgZnJvbSAndGhyZWUnOwoKLyogPT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09CiAgIE5ld2J1cnkgTmlnaHRzIOKAlCBjbGllbnQgZ2FtZSBsb2dpYwogICAtIFNjcmVlbiByb3V0aW5nICh0aXRsZSAvIHNjYW4gLyBodW50IC8gcm9zdGVyKQogICAtIFFSIHNjYW4gdmlhIEJhcmNvZGVEZXRlY3RvciAobWFudWFsIGNvZGUgZmFsbGJhY2spCiAgIC0gQVIgaHVudDogY2FtZXJhIHBhc3N0aHJvdWdoICsgRGV2aWNlT3JpZW50YXRpb24gZ3lybyBsb29rLAogICAgIGNvbG9yLXdoZWVsIGdsb29tIGRldGVjdGlvbiwgYmxhc3RlciB3aXRoIG92ZXJoZWF0LAogICAgIHByb2NlZHVyYWwgd2lzcCBtZXNoZXMgKyBhbmltYXRlZC1HSUYgYmlsbGJvYXJkcy4KICAgPT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09ICovCgpjb25zdCAkID0gKHMsIHIgPSBkb2N1bWVudCkgPT4gci5xdWVyeVNlbGVjdG9yKHMpOwpjb25zdCAkJCA9IChzLCByID0gZG9jdW1lbnQpID0+IFsuLi5yLnF1ZXJ5U2VsZWN0b3JBbGwocyldOwoKY29uc3Qgc2NyZWVucyA9IHsKICB0aXRsZTogJCgnI3NjcmVlbi10aXRsZScpLAogIHNjYW46ICQoJyNzY3JlZW4tc2NhbicpLAogIGh1bnQ6ICQoJyNzY3JlZW4taHVudCcpLAogIHJvc3RlcjogJCgnI3NjcmVlbi1yb3N0ZXInKSwKICBhYm91dDogJCgnI3NjcmVlbi1hYm91dCcpLAp9OwpmdW5jdGlvbiBzaG93KG5hbWUpIHsKICBPYmplY3QudmFsdWVzKHNjcmVlbnMpLmZvckVhY2goKHMpID0+IHMuY2xhc3NMaXN0LmFkZCgnaGlkZGVuJykpOwogIHNjcmVlbnNbbmFtZV0uY2xhc3NMaXN0LnJlbW92ZSgnaGlkZGVuJyk7Cn0KCi8qIC0tLS0tLS0tLS0gVGl0bGUgbmF2IC0tLS0tLS0tLS0gKi8KJCgnI2J0bi1zY2FuLXNldCcpLmFkZEV2ZW50TGlzdGVuZXIoJ2NsaWNrJywgKCkgPT4gc3RhcnRTY2FuKCkpOwokKCcjYnRuLWZyZWVodW50JykuYWRkRXZlbnRMaXN0ZW5lcignY2xpY2snLCAoKSA9PiBzdGFydEh1bnQoeyBmcmVlSHVudDogdHJ1ZSB9KSk7CiQoJyNidG4tcm9zdGVyJykuYWRkRXZlbnRMaXN0ZW5lcignY2xpY2snLCAoKSA9PiBvcGVuUm9zdGVyKCkpOwokKCcjYnRuLWFib3V0JykuYWRkRXZlbnRMaXN0ZW5lcignY2xpY2snLCAoKSA9PiBzaG93KCdhYm91dCcpKTsKJCQoJ1tkYXRhLWJhY2tdJykuZm9yRWFjaCgoYikgPT4gYi5hZGRFdmVudExpc3RlbmVyKCdjbGljaycsICgpID0+IHJldHVybkhvbWUoKSkpOwoKZnVuY3Rpb24gcmV0dXJuSG9tZSgpIHsKICBzdG9wU2NhbigpOwogIGh1bnQuc3RvcCgpOwogIHNob3coJ3RpdGxlJyk7Cn0KCi8qID09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PQogICBTQ0FOCiAgID09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PSAqLwpsZXQgc2NhblN0cmVhbSA9IG51bGw7CmxldCBzY2FuTG9vcCA9IG51bGw7CmxldCBkZXRlY3RvciA9IG51bGw7Cgphc3luYyBmdW5jdGlvbiBzdGFydFNjYW4oKSB7CiAgc2hvdygnc2NhbicpOwogICQoJyNzY2FuLWVycm9yJykuY2xhc3NMaXN0LmFkZCgnaGlkZGVuJyk7CiAgY29uc3Qgc3RhdHVzID0gJCgnI3NjYW4tc3RhdHVzJyk7CiAgY29uc3QgdmlkZW8gPSAkKCcjc2Nhbi12aWRlbycpOwoKICBpZiAoJ0JhcmNvZGVEZXRlY3RvcicgaW4gd2luZG93KSB7CiAgICB0cnkgewogICAgICBjb25zdCBmb3JtYXRzID0gYXdhaXQgd2luZG93LkJhcmNvZGVEZXRlY3Rvci5nZXRTdXBwb3J0ZWRGb3JtYXRzKCk7CiAgICAgIGlmIChmb3JtYXRzLmluY2x1ZGVzKCdxcl9jb2RlJykpIHsKICAgICAgICBkZXRlY3RvciA9IG5ldyB3aW5kb3cuQmFyY29kZURldGVjdG9yKHsgZm9ybWF0czogWydxcl9jb2RlJ10gfSk7CiAgICAgIH0KICAgIH0gY2F0Y2ggeyBkZXRlY3RvciA9IG51bGw7IH0KICB9CgogIHRyeSB7CiAgICBzY2FuU3RyZWFtID0gYXdhaXQgbmF2aWdhdG9yLm1lZGlhRGV2aWNlcy5nZXRVc2VyTWVkaWEoewogICAgICB2aWRlbzogeyBmYWNpbmdNb2RlOiAnZW52aXJvbm1lbnQnIH0sIGF1ZGlvOiBmYWxzZSwKICAgIH0pOwogICAgdmlkZW8uc3JjT2JqZWN0ID0gc2NhblN0cmVhbTsKICAgIGF3YWl0IHZpZGVvLnBsYXkoKTsKICAgIHN0YXR1cy50ZXh0Q29udGVudCA9IGRldGVjdG9yID8gJ0xvb2tpbmcgZm9yIGEgc2V0IGNvZGXigKYnIDogJ0NhbWVyYSBvbiDigJQgZW50ZXIgY29kZSBiZWxvdyc7CiAgICBpZiAoZGV0ZWN0b3IpIHRpY2tTY2FuKHZpZGVvKTsKICB9IGNhdGNoIChlKSB7CiAgICBzdGF0dXMudGV4dENvbnRlbnQgPSAnQ2FtZXJhIHVuYXZhaWxhYmxlIOKAlCBlbnRlciB0aGUgY29kZSBiZWxvdyc7CiAgfQp9Cgphc3luYyBmdW5jdGlvbiB0aWNrU2Nhbih2aWRlbykgewogIGlmICghZGV0ZWN0b3IgfHwgIXNjYW5TdHJlYW0pIHJldHVybjsKICB0cnkgewogICAgY29uc3QgY29kZXMgPSBhd2FpdCBkZXRlY3Rvci5kZXRlY3QodmlkZW8pOwogICAgaWYgKGNvZGVzLmxlbmd0aCkgewogICAgICBjb25zdCByYXcgPSBjb2Rlc1swXS5yYXdWYWx1ZT8udHJpbSgpOwogICAgICBpZiAocmF3KSB7IHJlc29sdmVDb2RlKHJhdyk7IHJldHVybjsgfQogICAgfQogIH0gY2F0Y2ggeyAvKiBrZWVwIHRyeWluZyAqLyB9CiAgc2Nhbkxvb3AgPSByZXF1ZXN0QW5pbWF0aW9uRnJhbWUoKCkgPT4gdGlja1NjYW4odmlkZW8pKTsKfQoKZnVuY3Rpb24gc3RvcFNjYW4oKSB7CiAgaWYgKHNjYW5Mb29wKSBjYW5jZWxBbmltYXRpb25GcmFtZShzY2FuTG9vcCksIChzY2FuTG9vcCA9IG51bGwpOwogIGlmIChzY2FuU3RyZWFtKSB7IHNjYW5TdHJlYW0uZ2V0VHJhY2tzKCkuZm9yRWFjaCgodCkgPT4gdC5zdG9wKCkpOyBzY2FuU3RyZWFtID0gbnVsbDsgfQp9CgokKCcjYnRuLW1hbnVhbC1nbycpLmFkZEV2ZW50TGlzdGVuZXIoJ2NsaWNrJywgKCkgPT4gewogIGNvbnN0IGNvZGUgPSAkKCcjbWFudWFsLWNvZGUnKS52YWx1ZS50cmltKCk7CiAgaWYgKGNvZGUpIHJlc29sdmVDb2RlKGNvZGUpOwp9KTsKJCgnI21hbnVhbC1jb2RlJykuYWRkRXZlbnRMaXN0ZW5lcigna2V5ZG93bicsIChlKSA9PiB7CiAgaWYgKGUua2V5ID09PSAnRW50ZXInKSAkKCcjYnRuLW1hbnVhbC1nbycpLmNsaWNrKCk7Cn0pOwoKYXN5bmMgZnVuY3Rpb24gcmVzb2x2ZUNvZGUoY29kZSkgewogIC8vIEFjY2VwdCBlaXRoZXIgYSBiYXJlIGNvZGUgb3IgYSBVUkwgd2hvc2UgbGFzdCBwYXRoL3F1ZXJ5IHNlZ21lbnQgaXMgdGhlIGNvZGUuCiAgY29uc3QgY2xlYW5lZCA9IGNvZGUuaW5jbHVkZXMoJy8nKSA/IGNvZGUuc3BsaXQoL1tcLz89XS8pLmZpbHRlcihCb29sZWFuKS5wb3AoKSA6IGNvZGU7CiAgdHJ5IHsKICAgIGNvbnN0IHJlcyA9IGF3YWl0IGZldGNoKGAvYXBpL3NjYW4vJHtlbmNvZGVVUklDb21wb25lbnQoY2xlYW5lZCl9YCk7CiAgICBpZiAoIXJlcy5vaykgdGhyb3cgbmV3IEVycm9yKCdVbmtub3duIHNldCBjb2RlJyk7CiAgICBjb25zdCBkYXRhID0gYXdhaXQgcmVzLmpzb24oKTsKICAgIHN0b3BTY2FuKCk7CiAgICBzdGFydEh1bnQoeyBzZXREYXRhOiBkYXRhIH0pOwogIH0gY2F0Y2ggKGUpIHsKICAgIGNvbnN0IGVyciA9ICQoJyNzY2FuLWVycm9yJyk7CiAgICBlcnIudGV4dENvbnRlbnQgPSBgQ291bGRuJ3QgZmluZCBhIHNldCBmb3IgIiR7Y2xlYW5Line truncated import * as THREE from 'three';
/* ============================================================
Newbury Nights — client game logic
- Screen routing (title / scan / hunt / roster)
- QR scan via BarcodeDetector (manual code fallback)
- AR hunt: camera passthrough + DeviceOrientation gyro look,
color-wheel gloom detection, blaster with overheat,
procedural wisp meshes + animated-GIF billboards.
============================================================ */
const $ = (s, r = document) => r.querySelector(s);
const $$ = (s, r = document) => [...r.querySelectorAll(s)];
const screens = {
title: $('#screen-title'),
scan: $('#screen-scan'),
hunt: $('#screen-hunt'),
roster: $('#screen-roster'),
about: $('#screen-about'),
};
function show(name) {
Object.values(screens).forEach((s) => s.classList.add('hidden'));
screens[name].classList.remove('hidden');
}
/* ---------- Title nav ---------- */
$('#btn-scan-set').addEventListener('click', () => startScan());
$('#btn-freehunt').addEventListener('click', () => startHunt({ freeHunt: true }));
$('#btn-roster').addEventListener('click', () => openRoster());
$('#btn-about').addEventListener('click', () => show('about'));
$$('[data-back]').forEach((b) => b.addEventListener('click', () => returnHome()));
function returnHome() {
stopScan();
hunt.stop();
show('title');
}
/* ============================================================
SCAN
============================================================ */
let scanStream = null;
let scanLoop = null;
let detector = null;
async function startScan() {
show('scan');
$('#scan-error').classList.add('hidden');
const status = $('#scan-status');
const video = $('#scan-video');
if ('BarcodeDetector' in window) {
try {
const formats = await window.BarcodeDetector.getSupportedFormats();
if (formats.includes('qr_code')) {
detector = new window.BarcodeDetector({ formats: ['qr_code'] });
}
} catch { detector = null; }
}
try {
scanStream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment' }, audio: false,
});
video.srcObject = scanStream;
await video.play();
status.textContent = detector ? 'Looking for a set code…' : 'Camera on — enter code below';
if (detector) tickScan(video);
} catch (e) {
status.textContent = 'Camera unavailable — enter the code below';
}
}
async function tickScan(video) {
if (!detector || !scanStream) return;
try {
const codes = await detector.detect(video);
if (codes.length) {
const raw = codes[0].rawValue?.trim();
if (raw) { resolveCode(raw); return; }
}
} catch { /* keep trying */ }
scanLoop = requestAnimationFrame(() => tickScan(video));
}
function stopScan() {
if (scanLoop) cancelAnimationFrame(scanLoop), (scanLoop = null);
if (scanStream) { scanStream.getTracks().forEach((t) => t.stop()); scanStream = null; }
}
$('#btn-manual-go').addEventListener('click', () => {
const code = $('#manual-code').value.trim();
if (code) resolveCode(code);
});
$('#manual-code').addEventListener('keydown', (e) => {
if (e.key === 'Enter') $('#btn-manual-go').click();
});
async function resolveCode(code) {
// Accept either a bare code or a URL whose last path/query segment is the code.
const cleaned = code.includes('/') ? code.split(/[\/?=]/).filter(Boolean).pop() : code;
try {
const res = await fetch(`/api/scan/${encodeURIComponent(cleaned)}`);
if (!res.ok) throw new Error('Unknown set code');
const data = await res.json();
stopScan();
startHunt({ setData: data });
} catch (e) {
const err = $('#scan-error');
err.textContent = `Couldn't find a set for "${cleaned}". Check the code and try again.`;
err.classList.remove('hidden');
}
}
/* ============================================================
HUNT (AR)
============================================================ */
const TYPE_COLORS = { red: 0xff3b5c, yellow: 0xffc23b, blue: 0x3bb6ff };
// Detect VP9-with-alpha WebM support once. iOS Safari historically lacks
// reliable VP9-alpha, so those devices fall back to the GIF/WebP <img> path.
const SUPPORTS_WEBM_ALPHA = (() => {
try {
const v = document.createElement('video');
return !!v.canPlayType && v.canPlayType('video/webm; codecs="vp9"') !== '';
} catch {
return false;
}
})();
const hunt = {
running: false,
scene: null, camera: null, renderer: null, raf: null,
stream: null, video: null,
ghosts: [], // active ghost objects in the scene
target: null, // currently locked ghost
armedType: null, // color-wheel armed type
battery: 100,
gloom: 0,
overheat: 0,
blasting: false,
orientation: { alpha: 0, beta: 0, gamma: 0, active: false },
pool: [], // ghosts available to spawn (from set roster or freehunt)
boss: null,
remaining: 0, // spots left to clear (set mode)
freeHunt: false,
async start({ setData, freeHunt }) {
show('hunt');
this.running = true;
this.freeHunt = !!freeHunt;
this.battery = 100; this.gloom = 0; this.overheat = 0; this.target = null; this.armedType = null;
this.ghosts = [];
$('#result').classList.add('hidden');
$('#lockon').classList.add('hidden');
this.updateBattery(); this.updateGloom();
if (freeHunt) {
const res = await fetch('/api/freehunt?n=6');
this.pool = (await res.json()).spawns;
this.boss = null;
this.remaining = Infinity;
} else {
this.pool = setData.roster.filter((g) => !g.isBoss);
this.boss = setData.boss;
this.remaining = Math.min(5, this.pool.length); // spots to clear before boss
}
await this.initCamera();
this.initScene();
this.bindControls();
this.maybePromptMotion();
this.spawnNext();
this.loop();
},
// On iOS, surface an explicit "Enable Motion" button. Tapping it is the clean
// user gesture iOS needs to show its Motion & Orientation Access prompt.
maybePromptMotion() {
const overlay = $('#motion-gate');
if (!this.gyroNeedsPermission()) { overlay.classList.add('hidden'); return; }
overlay.classList.remove('hidden');
const btn = $('#motion-enable');
const onTap = async () => {
const ok = await this.requestGyro();
overlay.classList.add('hidden');
if (!ok) this.toast('Motion blocked — enable it in Settings › Safari', 2200);
};
btn.onclick = onTap;
this._motionEls = { overlay, btn };
},
async initCamera() {
this.video = $('#ar-video');
try {
this.stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment' }, audio: false,
});
this.video.srcObject = this.stream;
await this.video.play();
} catch { /* no camera → dark backdrop, gyro still works */ }
},
initScene() {
const canvas = $('#ar-canvas');
this.renderer = new THREE.WebGLRenderer({ canvas, alpha: true, antialias: true });
this.renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
this.renderer.setSize(innerWidth, innerHeight);
this.scene = new THREE.Scene();
this.camera = new THREE.PerspectiveCamera(70, innerWidth / innerHeight, 0.1, 100);
this.camera.position.set(0, 0, 0);
this.scene.add(new THREE.AmbientLight(0xffffff, 0.8));
const p = new THREE.PointLight(0x88aaff, 1.2, 50);
p.position.set(0, 2, 2);
this.scene.add(p);
this._onResize = () => {
this.camera.aspect = innerWidth / innerHeight;
this.camera.updateProjectionMatrix();
this.renderer.setSize(innerWidth, innerHeight);
};
addEventListener('resize', this._onResize);
// Gyro look. iOS Safari requires DeviceOrientationEvent.requestPermission()
// from an explicit user gesture (handled by the "Enable Motion" button in
// start()). Other browsers can attach the listener immediately.
this._onOrient = (e) => {
if (e.alpha == null) return;
this.orientation = { alpha: e.alpha, beta: e.beta, gamma: e.gamma, active: true };
};
if (!this.gyroNeedsPermission()) {
addEventListener('deviceorientation', this._onOrient);
this._gyroAttached = true;
}
},
gyroNeedsPermission() {
const DOE = window.DeviceOrientationEvent;
return !!(DOE && typeof DOE.requestPermission === 'function');
},
// Called from a clean tap (the Enable Motion overlay). On iOS this triggers
// the system "Motion & Orientation Access" prompt; once granted we attach the
// listener. Safe to call more than once.
async requestGyro() {
if (this._gyroAttached) return true;
const DOE = window.DeviceOrientationEvent;
try {
if (DOE && typeof DOE.requestPermission === 'function') {
const res = await DOE.requestPermission();
if (res !== 'granted') return false;
}
addEventListener('deviceorientation', this._onOrient);
this._gyroAttached = true;
return true;
} catch {
return false;
}
},
bindControls() {
// color wheel
$$('.wheel-seg').forEach((seg) => {
seg.onclick = () => {
const type = seg.dataset.type;
this.armedType = type;
$$('.wheel-seg').forEach((s) => s.classList.remove('armed'));
seg.classList.add('armed');
$('#wheel-core').textContent = type.toUpperCase();
this.scanGloom(type);
};
});
const blast = $('#btn-blast');
const startBlast = (e) => {
e.preventDefault();
this.blasting = true;
};
const endBlast = () => { this.blasting = false; };
blast.addEventListener('touchstart', startBlast, { passive: false });
blast.addEventListener('mousedown', startBlast);
addEventListener('touchend', endBlast);
addEventListener('mouseup', endBlast);
this._blastEls = { blast, startBlast, endBlast };
},
/* spawn a wisp/billboard ghost at a random spot around the player */
spawnNext() {
if (!this.pool.length) return;
const data = this.pool[Math.floor(Math.random() * this.pool.length)];
this.addGhost(data);
},
addGhost(data, isBoss = false) {
const group = new THREE.Group();
const color = TYPE_COLORS[data.type] ?? 0xffffff;
let mesh;
let texture = null;
if (data.webm && SUPPORTS_WEBM_ALPHA) {
// WebM (VP9+alpha) billboard via VideoTexture. The browser decodes and
// updates the texture itself — no per-frame needsUpdate pumping needed.
const vid = document.createElement('video');
vid.crossOrigin = 'anonymous';
vid.muted = true; // required for autoplay
vid.loop = true;
vid.playsInline = true; // iOS: stay inline, don't fullscreen
vid.autoplay = true;
vid.preload = 'auto';
vid.src = data.webm;
texture = new THREE.VideoTexture(vid);
texture.colorSpace = THREE.SRGBColorSpace;
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.generateMipmaps = false;
const mat = new THREE.MeshBasicMaterial({ map: texture, transparent: true, side: THREE.DoubleSide, depthWrite: false });
mesh = new THREE.Mesh(new THREE.PlaneGeometry(1.2, 1.2), mat);
// If the video errors (e.g. alpha unsupported despite canPlayType), swap
// to the GIF/image billboard if we have one.
vid.onerror = () => {
if (group.userData.videoFellBack) return;
group.userData.videoFellBack = true;
if (data.image) {
const img = document.createElement('img');
img.crossOrigin = 'anonymous';
img.src = data.image;
const t2 = new THREE.Texture(img);
img.onload = () => { t2.needsUpdate = true; };
mesh.material.map = t2;
mesh.material.needsUpdate = true;
group.userData.gifImg = img;
group.userData.gifTex = t2;
group.userData.vidEl = null;
}
};
const pr = vid.play();
if (pr && pr.catch) pr.catch(() => {});
group.userData.vidEl = vid;
} else if (data.image) {
// animated GIF billboard — texture.needsUpdate pumped each frame
const img = document.createElement('img');
img.crossOrigin = 'anonymous';
img.src = data.image;
texture = new THREE.Texture(img);
img.onload = () => { texture.needsUpdate = true; };
const mat = new THREE.MeshBasicMaterial({ map: texture, transparent: true, side: THREE.DoubleSide });
mesh = new THREE.Mesh(new THREE.PlaneGeometry(1.2, 1.2), mat);
group.userData.gifImg = img;
group.userData.gifTex = texture;
} else {
// procedural wisp: glowing sphere + halo
const geo = new THREE.SphereGeometry(0.45, 24, 24);
const mat = new THREE.MeshStandardMaterial({
color, emissive: color, emissiveIntensity: 1.4, roughness: 0.4,
transparent: true, opacity: 0.92,
});
mesh = new THREE.Mesh(geo, mat);
const halo = new THREE.Mesh(
new THREE.SphereGeometry(0.62, 24, 24),
new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.16, side: THREE.BackSide })
);
group.add(halo);
}
group.add(mesh);
// Place in a forward-facing arc so ghosts are findable without perfect
// aiming: yaw within ±60° of straight ahead, modest pitch, closer in.
const yaw = (Math.random() - 0.5) * (Math.PI * 2 / 3);
const pitch = (Math.random() - 0.5) * 0.5;
const dist = 3 + Math.random() * 1.5;
group.position.set(
Math.sin(yaw) * dist,
Math.sin(pitch) * dist,
-Math.cos(yaw) * dist
);
group.userData = {
...group.userData,
data, isBoss,
hp: data.health, maxHp: data.health,
bobPhase: Math.random() * Math.PI * 2,
revealed: true, // visible on spawn; the wheel now aggros/tints rather than uncloaks
};
this.scene.add(group);
this.ghosts.push(group);
},
scanGloom(type) {
// Ghosts are visible on spawn now, so the wheel acts as a "lure": scanning a
// colour pulls the nearest matching ghost toward your aim and charges gloom.
// Mismatched colour risks a jumpscare.
const matches = this.ghosts.filter((g) => g.userData.data.type === type);
if (matches.length) {
// pull the nearest match into a forward, easy-to-aim position
const g = matches[0];
const dist = 3;
g.position.set((Math.random() - 0.5) * 1.2, (Math.random() - 0.3) * 0.8, -dist);
g.userData.lured = true;
this.gloom += 5; this.updateGloom();
this.toast(`${type.toUpperCase()} ghost lured in`, 800);
} else {
// no ghost of that colour present — small chance of a jumpscare
if (Math.random() < 0.12) this.jumpscare();
else { this.gloom += 2; this.updateGloom(); this.toast('+2 gloom', 600); }
}
},
loop() {
if (!this.running) return;
this.raf = requestAnimationFrame(() => this.loop());
const t = performance.now() / 1000;
// camera yaw/pitch from gyro
if (this.orientation.active) {
const { alpha, beta, gamma } = this.orientation;
// simple mapping; good enough for look-around aiming
this.camera.rotation.set(
THREE.MathUtils.degToRad((beta ?? 0) - 90),
THREE.MathUtils.degToRad(alpha ?? 0),
THREE.MathUtils.degToRad(-(gamma ?? 0)),
'YXZ'
);
}
// animate ghosts: bob + face camera + pump GIF textures
for (const g of this.ghosts) {
g.position.y += Math.sin(t * 1.5 + g.userData.bobPhase) * 0.0025;
g.lookAt(this.camera.position);
if (g.userData.gifTex && g.userData.gifImg?.complete) g.userData.gifTex.needsUpdate = true;
}
// targeting: ghost nearest screen-center (and revealed) becomes the lock-on
this.updateTarget();
// blaster
if (this.blasting && this.overheat < 100 && this.target) {
const dmg = 0.9; // per-frame chip damage
this.target.userData.hp -= dmg;
this.overheat = Math.min(100, this.overheat + 0.8);
this.updateGhostHp();
if (this.target.userData.hp <= 0) this.captureTarget();
} else if (!this.blasting && this.overheat > 0) {
this.overheat = Math.max(0, this.overheat - 1.2); // cool down
}
if (this.overheat >= 100) this.blasting = false;
this.updateOverheat();
// ghosts attack: drain battery if a revealed ghost is in front of you
this.maybeTakeDamage();
this.renderer.render(this.scene, this.camera);
},
updateTarget() {
const center = new THREE.Vector2(0, 0);
let best = null, bestDist = 0.5; // within ~half-screen of center
const v = new THREE.Vector3();
for (const g of this.ghosts) {
if (!g.userData.revealed) continue;
v.copy(g.position).project(this.camera);
if (v.z > 1) continue; // behind camera
const d = Math.hypot(v.x - center.x, v.y - center.y);
if (d < bestDist) { bestDist = d; best = g; }
}
if (best !== this.target) {
this.target = best;
this.renderLockon();
}
},
renderLockon() {
const lock = $('#lockon');
if (!this.target) { lock.classList.add('hidden'); return; }
const d = this.target.userData.data;
lock.classList.remove('hidden');
// IMPORTANT: label reads displayName, not a hardcoded tint name
$('#lockon-name').textContent = d.displayName || d.name;
$('#lockon-name').className = `lockon-name display type-${d.type}`;
$('#lockon-type').textContent = d.type;
$('#lockon-rarity').textContent = '★'.repeat(d.rarity);
$('#lockon-ability').textContent = d.ability || '—';
this.updateGhostHp();
},
updateGhostHp() {
if (!this.target) return;
const pct = Math.max(0, (this.target.userData.hp / this.target.userData.maxHp) * 100);
$('#ghost-hp-fill').style.width = `${pct}%`;
},
captureTarget() {
const g = this.target;
const d = g.userData.data;
if (g.userData.vidEl) { try { g.userData.vidEl.pause(); g.userData.vidEl.src = ''; } catch {} g.userData.vidEl = null; }
this.scene.remove(g);
this.ghosts = this.ghosts.filter((x) => x !== g);
this.target = null;
$('#lockon').classList.add('hidden');
this.gloom += 10 + d.rarity * 5;
this.updateGloom();
this.toast(`Captured ${d.displayName || d.name}!`, 900);
if (g.userData.isBoss) { this.win(d); return; }
if (!this.freeHunt) {
this.remaining -= 1;
if (this.remaining <= 0 && this.boss) {
// soul artifact → boss appears
this.toast('A Soul Artifact pulses…', 1100);
setTimeout(() => this.addGhost(this.boss, true), 1100);
return;
}
}
// respawn another ghost to keep the hunt going
setTimeout(() => this.spawnNext(), 600);
},
maybeTakeDamage() {
// a revealed ghost roughly centered + we're not blasting → it glooms us
if (this.blasting) return;
if (!this.target) return;
if (Math.random() < 0.004) {
const dmg = this.target.userData.data.rarity * 1.5; // 4★ hurts most
this.battery = Math.max(0, this.battery - dmg);
this.updateBattery();
this.flashDamage();
if (this.battery <= 0) this.lose();
}
},
/* ---- HUD updates ---- */
updateBattery() {
const fill = $('#battery-fill');
fill.style.width = `${this.battery}%`;
fill.classList.toggle('low', this.battery <= 30);
$('#battery-pct').textContent = `${Math.round(this.battery)}%`;
},
updateGloom() { $('#gloom-count').textContent = this.gloom; },
updateOverheat() {
$('#overheat-fill').style.width = `${this.overheat}%`;
$('#btn-blast').classList.toggle('overheated', this.overheat >= 100);
$('#btn-blast').textContent = this.overheat >= 100 ? 'OVERHEATED' : 'HOLD TO BLAST';
},
toast(msg, ms = 800, jump = false) {
const t = $('#toast');
t.textContent = msg;
t.className = `toast${jump ? ' jumpscare' : ''}`;
clearTimeout(this._toastT);
this._toastT = setTimeout(() => t.classList.add('hidden'), ms);
},
jumpscare() {
this.toast('BOO!', 700, true);
this.battery = Math.max(0, this.battery - 4);
this.updateBattery();
if (navigator.vibrate) navigator.vibrate(120);
},
flashDamage() {
if (navigator.vibrate) navigator.vibrate(40);
document.body.animate(
[{ boxShadow: 'inset 0 0 0 9999px rgba(255,59,92,.25)' }, { boxShadow: 'inset 0 0 0 9999px rgba(255,59,92,0)' }],
{ duration: 250 }
);
},
win(boss) {
$('#result-title').textContent = 'Set Cleared';
$('#result-body').textContent = `You captured ${boss.displayName || boss.name} and drove the gloom out. Gloom banked: ${this.gloom}.`;
$('#result').classList.remove('hidden');
this.running = false;
},
lose() {
$('#result-title').textContent = 'Battery Dead';
$('#result-body').textContent = 'The detector went dark and the ghosts slipped away. Recharge and try again.';
$('#result').classList.remove('hidden');
this.running = false;
},
stop() {
this.running = false;
if (this.raf) cancelAnimationFrame(this.raf);
if (this.stream) { this.stream.getTracks().forEach((t) => t.stop()); this.stream = null; }
if (this._onResize) removeEventListener('resize', this._onResize);
if (this._onOrient && this._gyroAttached) {
removeEventListener('deviceorientation', this._onOrient);
this._gyroAttached = false;
}
if (this._motionEls) { this._motionEls.overlay.classList.add('hidden'); }
if (this._blastEls) {
removeEventListener('touchend', this._blastEls.endBlast);
removeEventListener('mouseup', this._blastEls.endBlast);
}
if (this.renderer) { this.renderer.dispose?.(); }
for (const g of this.ghosts) {
if (g.userData.vidEl) { try { g.userData.vidEl.pause(); g.userData.vidEl.src = ''; } catch {} g.userData.vidEl = null; }
}
this.ghosts = []; this.target = null;
},
};
function startHunt(opts) { hunt.start(opts); }
/* ============================================================
ROSTER / GHOST INDEX
============================================================ */
async function openRoster() {
show('roster');
await loadRoster();
}
['#filter-type', '#filter-rarity', '#filter-boss'].forEach((sel) =>
$(sel).addEventListener('change', loadRoster)
);
async function loadRoster() {
const type = $('#filter-type').value;
const rarity = $('#filter-rarity').value;
const boss = $('#filter-boss').checked ? '1' : '';
const params = new URLSearchParams();
if (type) params.set('type', type);
if (rarity) params.set('rarity', rarity);
if (boss) params.set('boss', boss);
const res = await fetch(`/api/ghosts?${params}`);
const { ghosts } = await res.json();
const grid = $('#roster-grid');
grid.innerHTML = ghosts.map(ghostCard).join('') ||
'<p class="muted">No ghosts match those filters.</p>';
}
function ghostCard(g) {
const colorVar = `var(--${g.type})`;
const thumb = g.image
? `<img class="ghost-thumb" src="${g.image}" alt="${escapeHtml(g.displayName)}" loading="lazy">`
: '';
const setRef = g.setNumber ? `<div class="set-ref">Set ${g.setNumber} · ${escapeHtml(g.setName || '')}</div>` : '';
return `
<div class="ghost-card ${g.isBoss ? 'boss' : ''}">
<div class="accent" style="background:${colorVar}"></div>
${thumb}
<div class="gname">${escapeHtml(g.displayName)} ${g.isBoss ? '<span class="boss-badge">BOSS</span>' : ''}</div>
<div class="meta"><span class="dot ${g.type}"></span> <span class="stars">${'★'.repeat(g.rarity)}</span></div>
<div class="statline"><span>HP ${g.health}</span><span>DMG ${g.damage}</span></div>
<div class="statline"><span>SPD ${g.speed}</span><span>RNG ${g.range}</span></div>
${g.ability ? `<div class="set-ref" style="margin-top:6px">⚡ ${escapeHtml(g.ability)}</div>` : ''}
${setRef}
</div>`;
}
function escapeHtml(s) {
return String(s ?? '').replace(/[&<>"']/g, (c) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
+239 -1
View File
@@ -1 +1,239 @@
aW1wb3J0IHsgUm91dGVyIH0gZnJvbSAnZXhwcmVzcyc7CmltcG9ydCBtdWx0ZXIgZnJvbSAnbXVsdGVyJzsKaW1wb3J0IHsgcmFuZG9tQnl0ZXMgfSBmcm9tICdub2RlOmNyeXB0byc7CmltcG9ydCB7IG1rZGlyU3luYywgZXhpc3RzU3luYywgdW5saW5rU3luYyB9IGZyb20gJ25vZGU6ZnMnOwppbXBvcnQgeyBkaXJuYW1lLCBleHRuYW1lLCBqb2luIH0gZnJvbSAnbm9kZTpwYXRoJzsKaW1wb3J0IHsgZmlsZVVSTFRvUGF0aCB9IGZyb20gJ25vZGU6dXJsJzsKaW1wb3J0IGRiIGZyb20gJy4uL2RiL2luZGV4LmpzJzsKaW1wb3J0IHsgcmVxdWlyZUF1dGggfSBmcm9tICcuL2F1dGgtbWlkZGxld2FyZS5qcyc7CmltcG9ydCB7IGNvbnZlcnRHaG9zdE1wNCB9IGZyb20gJy4uL2xpYi9naG9zdC1tZWRpYS5qcyc7Cgpjb25zdCBfX2Rpcm5hbWUgPSBkaXJuYW1lKGZpbGVVUkxUb1BhdGgoaW1wb3J0Lm1ldGEudXJsKSk7CmNvbnN0IFVQTE9BRF9ESVIgPSBqb2luKF9fZGlybmFtZSwgJy4uJywgcHJvY2Vzcy5lbnYuVVBMT0FEX0RJUiB8fCAndXBsb2FkcycpOwpta2RpclN5bmMoVVBMT0FEX0RJUiwgeyByZWN1cnNpdmU6IHRydWUgfSk7Cgpjb25zdCByb3V0ZXIgPSBSb3V0ZXIoKTsKcm91dGVyLnVzZShyZXF1aXJlQXV0aCk7IC8vIGV2ZXJ5dGhpbmcgaGVyZSByZXF1aXJlcyBhIHZhbGlkIEpXVAoKY29uc3QgQUxMT1dFRCA9IG5ldyBTZXQoWycuZ2lmJywgJy5wbmcnLCAnLmpwZycsICcuanBlZycsICcud2VicCcsICcud2VibScsICcubXA0J10pOwpjb25zdCBWSURFT19FWFRTID0gbmV3IFNldChbJy5tcDQnLCAnLndlYm0nXSk7CmNvbnN0IHN0b3JhZ2UgPSBtdWx0ZXIuZGlza1N0b3JhZ2UoewogIGRlc3RpbmF0aW9uOiAoX3JlcSwgX2ZpbGUsIGNiKSA9PiBjYihudWxsLCBVUExPQURfRElSKSwKICBmaWxlbmFtZTogKF9yZXEsIGZpbGUsIGNiKSA9PiB7CiAgICBjb25zdCBleHQgPSBleHRuYW1lKGZpbGUub3JpZ2luYWxuYW1lKS50b0xvd2VyQ2FzZSgpOwogICAgY2IobnVsbCwgYCR7RGF0ZS5ub3coKX0tJHtyYW5kb21CeXRlcyg2KS50b1N0cmluZygnaGV4Jyl9JHtleHR9YCk7CiAgfSwKfSk7CmNvbnN0IHVwbG9hZCA9IG11bHRlcih7CiAgc3RvcmFnZSwKICBsaW1pdHM6IHsgZmlsZVNpemU6IDY0ICogMTAyNCAqIDEwMjQgfSwgLy8gNjRNQiDigJQgc291cmNlIE1QNHMgYXJlIGxhcmdlciB0aGFuIEdJRnMKICBmaWxlRmlsdGVyOiAoX3JlcSwgZmlsZSwgY2IpID0+IHsKICAgIGNvbnN0IGV4dCA9IGV4dG5hbWUoZmlsZS5vcmlnaW5hbG5hbWUpLnRvTG93ZXJDYXNlKCk7CiAgICBjYihBTExPV0VELmhhcyhleHQpID8gbnVsbCA6IG5ldyBFcnJvcigndW5zdXBwb3J0ZWQgZmlsZSB0eXBlJyksIEFMTE9XRUQuaGFzKGV4dCkpOwogIH0sCn0pOwoKLy8gUmVtb3ZlIGFuIHVwbG9hZGVkIGZpbGUgYnkgYmFyZSBmaWxlbmFtZSwgaWdub3JpbmcgZXJyb3JzLgpmdW5jdGlvbiByZW1vdmVVcGxvYWQoZmlsZW5hbWUpIHsKICBpZiAoIWZpbGVuYW1lKSByZXR1cm47CiAgY29uc3QgcCA9IGpvaW4oVVBMT0FEX0RJUiwgZmlsZW5hbWUpOwogIGlmIChleGlzdHNTeW5jKHApKSB0cnkgeyB1bmxpbmtTeW5jKHApOyB9IGNhdGNoIHsgLyogaWdub3JlICovIH0KfQoKY29uc3QgdG9JbnQgPSAodiwgZCA9IDApID0+IChOdW1iZXIuaXNGaW5pdGUoK3YpID8gcGFyc2VJbnQodiwgMTApIDogZCk7CgovKiAtLS0tLS0tLS0tLS0tLS0tIEdob3N0cyAtLS0tLS0tLS0tLS0tLS0tICovCgpyb3V0ZXIuZ2V0KCcvZ2hvc3RzJywgKHJlcSwgcmVzKSA9PiB7CiAgcmVzLmpzb24oeyBnaG9zdHM6IGRiLnByZXBhcmUoJ1NFTEVDVCAqIEZST00gZ2hvc3RzIE9SREVSIEJZIHR5cGUsIHJhcml0eSwgbmFtZScpLmFsbCgpIH0pOwp9KTsKCnJvdXRlci5wb3N0KCcvZ2hvc3RzJywgKHJlcSwgcmVzKSA9PiB7CiAgY29uc3QgYiA9IHJlcS5ib2R5IHx8IHt9OwogIGlmICghYi5uYW1lIHx8ICFiLnR5cGUgfHwgIWIucmFyaXR5KSB7CiAgICByZXR1cm4gcmVzLnN0YXR1cyg0MDApLmpzb24oeyBlcnJvcjogJ25hbWUsIHR5cGUsIHJhcml0eSByZXF1aXJlZCcgfSk7CiAgfQogIGNvbnN0IGluZm8gPSBkYgogICAgLnByZXBhcmUoCiAgICAgIGBJTlNFUlQgSU5UTyBnaG9zdHMKICAgICAgICAobmFtZSwgZGlzcGxheV9uYW1lLCB0eXBlLCByYXJpdHksIHNwZWVkLCByYW5nZSwgY2hhcmdlX3Nob3QsCiAgICAgICAgIGhlYWx0aCwgZGFtYWdlLCBhYmlsaXR5LCBpc19ib3NzLCBzZXRfbnVtYmVyLCBzZXRfbmFtZSwgZW5hYmxlZCkKICAgICAgIFZBTFVFUyAoPyw/LD8sPyw/LD8sPyw/LD8sPyw/LD8sPyw/KWAKICAgICkKICAgIC5ydW4oCiAgICAgIGIubmFtZSwgYi5kaXNwbGF5TmFtZSB8fCBiLm5hbWUsIGIudHlwZSwgdG9JbnQoYi5yYXJpdHksIDEpLAogICAgICB0b0ludChiLnNwZWVkKSwgdG9JbnQoYi5yYW5nZSksIHRvSW50KGIuY2hhcmdlU2hvdCksCiAgICAgIHRvSW50KGIuaGVhbHRoLCAzMDApLCB0b0ludChiLmRhbWFnZSwgMTUwKSwgYi5hYmlsaXR5IHx8IG51bGwsCiAgICAgIGIuaXNCb3NzID8gMSA6IDAsIGIuc2V0TnVtYmVyIHx8IG51bGwsIGIuc2V0TmFtZSB8fCBudWxsLAogICAgICBiLmVuYWJsZWQgPT09IGZhbHNlID8gMCA6IDEKICAgICk7CiAgcmVzLnN0YXR1cygyMDEpLmpzb24oeyBpZDogaW5mby5sYXN0SW5zZXJ0Um93aWQgfSk7Cn0pOwoKcm91dGVyLnBhdGNoKCcvZ2hvc3RzLzppZCcsIChyZXEsIHJlcykgPT4gewogIGNvbnN0IGdob3N0ID0gZGIucHJlcGFyZSgnU0VMRUNUICogRlJPTSBnaG9zdHMgV0hFUkUgaWQgPSA/JykuZ2V0KHJlcS5wYXJhbXMuaWQpOwogIGlmICghZ2hvc3QpIHJldHVybiByZXMuc3RhdHVzKDQwNCkuanNvbih7IGVycm9yOiAnbm90IGZvdW5kJyB9KTsKICBjb25zdCBiID0gcmVxLmJvZHkgfHwge307CiAgY29uc3QgbWFwID0gewogICAgbmFtZTogJ25hbWUnLCBkaXNwbGF5TmFtZTogJ2Rpc3BsYXlfbmFtZScsIHR5cGU6ICd0eXBlJywgcmFyaXR5OiAncmFyaXR5JywKICAgIHNwZWVkOiAnc3BlZWQnLCByYW5nZTogJ3JhbmdlJywgY2hhcmdlU2hvdDogJ2NoYXJnZV9zaG90JywKICAgIGhlYWx0aDogJ2hlYWx0aCcsIGRhbWFnZTogJ2RhbWFnZScsIGFiaWxpdHk6ICdhYmlsaXR5JywKICAgIGlzQm9zczogJ2lzX2Jvc3MnLCBzZXROdW1iZXI6ICdzZXRfbnVtYmVyJywgc2V0TmFtZTogJ3NldF9uYW1lJywgZW5hYmxlZDogJ2VuYWJsZWQnLAogIH07CiAgY29uc3Qgc2V0cyA9IFtdOwogIGNvbnN0IHZhbHMgPSBbXTsKICBmb3IgKGNvbnN0IFtrLCBjb2xdIG9mIE9iamVjdC5lbnRyaWVzKG1hcCkpIHsKICAgIGlmIChrIGluIGIpIHsKICAgICAgc2V0cy5wdXNoKGAke2NvbH0gPSA/YCk7CiAgICAgIGxldCB2ID0gYltrXTsKICAgICAgaWYgKGsgPT09ICdpc0Jvc3MnIHx8IGsgPT09ICdlbmFibGVkJykgdiA9IHYgPyAxIDogMDsKICAgICAgdmFscy5wdXNoKHYpOwogICAgfQogIH0KICBpZiAoIXNldHMubGVuZ3RoKSByZXR1cm4gcmVzLmpzb24oeyBvazogdHJ1ZSwgdW5jaGFuZ2VkOiB0cnVlIH0pOwogIHZhbHMucHVzaChyZXEucGFyYW1zLmlkKTsKICBkYi5wcmVwYXJlKGBVUERBVEUgZ2hvc3RzIFNFVCAke3NldHMuam9pbignLCAnKX0gV0hFUkUgaWQgPSA/YCkucnVuKC4uLnZhbHMpOwogIHJlcy5qc29uKHsgb2s6IHRydWUgfSk7Cn0pOwoKcm91dGVyLnBvc3QoJy9naG9zdHMvOmlkL2ltYWdlJywgdXBsb2FkLnNpbmdsZSgnaW1hZ2UnKSwgYXN5bmMgKHJlcSwgcmVLine truncated import { Router } from 'express';
import multer from 'multer';
import { randomBytes } from 'node:crypto';
import { mkdirSync, existsSync, unlinkSync } from 'node:fs';
import { dirname, extname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import db from '../db/index.js';
import { requireAuth } from './auth-middleware.js';
import { convertGhostMp4 } from '../lib/ghost-media.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const UPLOAD_DIR = join(__dirname, '..', process.env.UPLOAD_DIR || 'uploads');
mkdirSync(UPLOAD_DIR, { recursive: true });
const router = Router();
router.use(requireAuth); // everything here requires a valid JWT
const ALLOWED = new Set(['.gif', '.png', '.jpg', '.jpeg', '.webp', '.webm', '.mp4']);
const VIDEO_EXTS = new Set(['.mp4', '.webm']);
const storage = multer.diskStorage({
destination: (_req, _file, cb) => cb(null, UPLOAD_DIR),
filename: (_req, file, cb) => {
const ext = extname(file.originalname).toLowerCase();
cb(null, `${Date.now()}-${randomBytes(6).toString('hex')}${ext}`);
},
});
const upload = multer({
storage,
limits: { fileSize: 64 * 1024 * 1024 }, // 64MB — source MP4s are larger than GIFs
fileFilter: (_req, file, cb) => {
const ext = extname(file.originalname).toLowerCase();
cb(ALLOWED.has(ext) ? null : new Error('unsupported file type'), ALLOWED.has(ext));
},
});
// Remove an uploaded file by bare filename, ignoring errors.
function removeUpload(filename) {
if (!filename) return;
const p = join(UPLOAD_DIR, filename);
if (existsSync(p)) try { unlinkSync(p); } catch { /* ignore */ }
}
const toInt = (v, d = 0) => (Number.isFinite(+v) ? parseInt(v, 10) : d);
/* ---------------- Ghosts ---------------- */
router.get('/ghosts', (req, res) => {
res.json({ ghosts: db.prepare('SELECT * FROM ghosts ORDER BY type, rarity, name').all() });
});
router.post('/ghosts', (req, res) => {
const b = req.body || {};
if (!b.name || !b.type || !b.rarity) {
return res.status(400).json({ error: 'name, type, rarity required' });
}
const info = db
.prepare(
`INSERT INTO ghosts
(name, display_name, type, rarity, speed, range, charge_shot,
health, damage, ability, is_boss, set_number, set_name, enabled)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`
)
.run(
b.name, b.displayName || b.name, b.type, toInt(b.rarity, 1),
toInt(b.speed), toInt(b.range), toInt(b.chargeShot),
toInt(b.health, 300), toInt(b.damage, 150), b.ability || null,
b.isBoss ? 1 : 0, b.setNumber || null, b.setName || null,
b.enabled === false ? 0 : 1
);
res.status(201).json({ id: info.lastInsertRowid });
});
router.patch('/ghosts/:id', (req, res) => {
const ghost = db.prepare('SELECT * FROM ghosts WHERE id = ?').get(req.params.id);
if (!ghost) return res.status(404).json({ error: 'not found' });
const b = req.body || {};
const map = {
name: 'name', displayName: 'display_name', type: 'type', rarity: 'rarity',
speed: 'speed', range: 'range', chargeShot: 'charge_shot',
health: 'health', damage: 'damage', ability: 'ability',
isBoss: 'is_boss', setNumber: 'set_number', setName: 'set_name', enabled: 'enabled',
};
const sets = [];
const vals = [];
for (const [k, col] of Object.entries(map)) {
if (k in b) {
sets.push(`${col} = ?`);
let v = b[k];
if (k === 'isBoss' || k === 'enabled') v = v ? 1 : 0;
vals.push(v);
}
}
if (!sets.length) return res.json({ ok: true, unchanged: true });
vals.push(req.params.id);
db.prepare(`UPDATE ghosts SET ${sets.join(', ')} WHERE id = ?`).run(...vals);
res.json({ ok: true });
});
router.post('/ghosts/:id/image', upload.single('image'), async (req, res) => {
const ghost = db.prepare('SELECT * FROM ghosts WHERE id = ?').get(req.params.id);
if (!ghost) {
if (req.file) removeUpload(req.file.filename);
return res.status(404).json({ error: 'not found' });
}
if (!req.file) return res.status(400).json({ error: 'no file' });
const ext = extname(req.file.filename).toLowerCase();
// Clear any previous media (image + video sprites) before recording the new set.
const cleanupOld = () => {
removeUpload(ghost.image_path);
removeUpload(ghost.webm_path);
removeUpload(ghost.webp_path);
};
if (ext === '.mp4') {
// Convert the source MP4 to a transparent WebM (VP9+alpha) plus a WebP
// fallback via luma keying. The original MP4 is removed afterwards.
let out;
try {
out = await convertGhostMp4(UPLOAD_DIR, req.file.filename);
} catch (e) {
removeUpload(req.file.filename);
return res.status(500).json({ error: 'conversion failed', detail: e.message });
}
removeUpload(req.file.filename); // discard the raw mp4
if (!out.webm && !out.webp) {
return res.status(500).json({ error: 'conversion produced no output (is ffmpeg installed?)' });
}
cleanupOld();
// webp doubles as the still/thumbnail image where present.
db.prepare('UPDATE ghosts SET webm_path = ?, webp_path = ?, image_path = ? WHERE id = ?')
.run(out.webm, out.webp, out.webp, ghost.id);
return res.json({
ok: true,
webm: out.webm ? `/uploads/${out.webm}` : null,
webp: out.webp ? `/uploads/${out.webp}` : null,
image: out.webp ? `/uploads/${out.webp}` : null,
});
}
if (ext === '.webm') {
// Pre-made transparent WebM uploaded directly — store as-is.
cleanupOld();
db.prepare('UPDATE ghosts SET webm_path = ?, webp_path = NULL, image_path = NULL WHERE id = ?')
.run(req.file.filename, ghost.id);
return res.json({ ok: true, webm: `/uploads/${req.file.filename}` });
}
// Plain image (gif/png/jpg/webp) — the original billboard path. Clear any
// previous video sprites so the ghost falls back cleanly to the image.
cleanupOld();
db.prepare('UPDATE ghosts SET image_path = ?, webm_path = NULL, webp_path = NULL WHERE id = ?')
.run(req.file.filename, ghost.id);
res.json({ ok: true, image: `/uploads/${req.file.filename}` });
});
router.delete('/ghosts/:id', (req, res) => {
const ghost = db.prepare('SELECT * FROM ghosts WHERE id = ?').get(req.params.id);
if (!ghost) return res.status(404).json({ error: 'not found' });
removeUpload(ghost.image_path);
removeUpload(ghost.webm_path);
removeUpload(ghost.webp_path);
db.prepare('DELETE FROM ghosts WHERE id = ?').run(ghost.id);
res.json({ ok: true });
});
/* ---------------- Sets ---------------- */
router.get('/sets', (req, res) => {
const sets = db.prepare('SELECT * FROM sets ORDER BY set_number, set_name').all();
const getRoster = db.prepare(
`SELECT g.id, g.name, g.type, g.rarity, g.is_boss
FROM ghosts g JOIN set_ghosts sg ON sg.ghost_id = g.id
WHERE sg.set_id = ? ORDER BY g.is_boss DESC, g.rarity DESC, g.name`
);
res.json({
sets: sets.map((s) => ({ ...s, roster: getRoster.all(s.id) })),
});
});
router.post('/sets', (req, res) => {
const b = req.body || {};
if (!b.code || !b.setName) return res.status(400).json({ error: 'code and setName required' });
try {
const info = db
.prepare(
'INSERT INTO sets (code, set_number, set_name, boss_ghost_id, enabled) VALUES (?,?,?,?,?)'
)
.run(b.code, b.setNumber || null, b.setName, b.bossGhostId || null, b.enabled === false ? 0 : 1);
res.status(201).json({ id: info.lastInsertRowid });
} catch (e) {
if (String(e).includes('UNIQUE')) return res.status(409).json({ error: 'code already exists' });
throw e;
}
});
router.patch('/sets/:id', (req, res) => {
const set = db.prepare('SELECT * FROM sets WHERE id = ?').get(req.params.id);
if (!set) return res.status(404).json({ error: 'not found' });
const b = req.body || {};
const map = {
code: 'code', setNumber: 'set_number', setName: 'set_name',
bossGhostId: 'boss_ghost_id', enabled: 'enabled',
};
const sets = []; const vals = [];
for (const [k, col] of Object.entries(map)) {
if (k in b) {
sets.push(`${col} = ?`);
vals.push(k === 'enabled' ? (b[k] ? 1 : 0) : b[k]);
}
}
if (!sets.length) return res.json({ ok: true, unchanged: true });
vals.push(req.params.id);
db.prepare(`UPDATE sets SET ${sets.join(', ')} WHERE id = ?`).run(...vals);
res.json({ ok: true });
});
router.put('/sets/:id/roster', (req, res) => {
const set = db.prepare('SELECT * FROM sets WHERE id = ?').get(req.params.id);
if (!set) return res.status(404).json({ error: 'not found' });
const ids = Array.isArray(req.body?.ghostIds) ? req.body.ghostIds : [];
const tx = db.transaction(() => {
db.prepare('DELETE FROM set_ghosts WHERE set_id = ?').run(set.id);
const link = db.prepare('INSERT OR IGNORE INTO set_ghosts (set_id, ghost_id) VALUES (?, ?)');
for (const gid of ids) link.run(set.id, gid);
});
tx();
res.json({ ok: true, count: ids.length });
});
router.delete('/sets/:id', (req, res) => {
const set = db.prepare('SELECT * FROM sets WHERE id = ?').get(req.params.id);
if (!set) return res.status(404).json({ error: 'not found' });
db.prepare('DELETE FROM sets WHERE id = ?').run(set.id);
res.json({ ok: true });
});
export default router;
+111 -1
View File
@@ -1 +1,111 @@
aW1wb3J0IHsgUm91dGVyIH0gZnJvbSAnZXhwcmVzcyc7CmltcG9ydCBkYiBmcm9tICcuLi9kYi9pbmRleC5qcyc7Cgpjb25zdCByb3V0ZXIgPSBSb3V0ZXIoKTsKCi8vIFJhcml0eSBzcGF3biB3ZWlnaHRzIChjb21tb24gLT4gbGVnZW5kYXJ5KS4gTWlycm9ycyB0aGUgcHJvamVjdCdzCi8vIGNvbW1vbig3KS9yYXJlKDMpL2xlZ2VuZGFyeSgxKSB3ZWlnaHRpbmcsIG1hcHBlZCBvbnRvIHRoZSA0IHN0YXIgdGllcnMuCmNvbnN0IFJBUklUWV9XRUlHSFRTID0geyAxOiA3LCAyOiA0LCAzOiAzLCA0OiAxIH07CgpmdW5jdGlvbiByb3dUb0dob3N0KGcpIHsKICByZXR1cm4gewogICAgaWQ6IGcuaWQsCiAgICBuYW1lOiBnLm5hbWUsCiAgICBkaXNwbGF5TmFtZTogZy5kaXNwbGF5X25hbWUgfHwgZy5uYW1lLAogICAgdHlwZTogZy50eXBlLAogICAgcmFyaXR5OiBnLnJhcml0eSwKICAgIHNwZWVkOiBnLnNwZWVkLAogICAgcmFuZ2U6IGcucmFuZ2UsCiAgICBjaGFyZ2VTaG90OiBnLmNoYXJnZV9zaG90LAogICAgaGVhbHRoOiBnLmhlYWx0aCwKICAgIGRhbWFnZTogZy5kYW1hZ2UsCiAgICBhYmlsaXR5OiBnLmFiaWxpdHksCiAgICBpc0Jvc3M6ICEhZy5pc19ib3NzLAogICAgc2V0TnVtYmVyOiBnLnNldF9udW1iZXIsCiAgICBzZXROYW1lOiBnLnNldF9uYW1lLAogICAgaW1hZ2U6IGcuaW1hZ2VfcGF0aCA/IGAvdXBsb2Fkcy8ke2cuaW1hZ2VfcGF0aH1gIDogbnVsbCwKICAgIHdlYm06IGcud2VibV9wYXRoID8gYC91cGxvYWRzLyR7Zy53ZWJtX3BhdGh9YCA6IG51bGwsCiAgICB3ZWJwOiBnLndlYnBfcGF0aCA/IGAvdXBsb2Fkcy8ke2cud2VicF9wYXRofWAgOiBudWxsLAogIH07Cn0KCmZ1bmN0aW9uIHdlaWdodGVkUGljayhnaG9zdHMpIHsKICBjb25zdCB3ZWlnaHRlZCA9IFtdOwogIGZvciAoY29uc3QgZyBvZiBnaG9zdHMpIHsKICAgIGNvbnN0IHcgPSBSQVJJVFlfV0VJR0hUU1tnLnJhcml0eV0gPz8gMTsKICAgIGZvciAobGV0IGkgPSAwOyBpIDwgdzsgaSsrKSB3ZWlnaHRlZC5wdXNoKGcpOwogIH0KICBpZiAoIXdlaWdodGVkLmxlbmd0aCkgcmV0dXJuIG51bGw7CiAgcmV0dXJuIHdlaWdodGVkW01hdGguZmxvb3IoTWF0aC5yYW5kb20oKSAqIHdlaWdodGVkLmxlbmd0aCldOwp9CgovLyBHRVQgL2FwaS9zY2FuLzpjb2RlICDigJQgbm8gYXV0aC4gUmV0dXJucyB0aGUgc2V0J3MgZ2hvc3Qgcm9zdGVyICsgYm9zcy4Kcm91dGVyLmdldCgnL3NjYW4vOmNvZGUnLCAocmVxLCByZXMpID0+IHsKICBjb25zdCBzZXQgPSBkYgogICAgLnByZXBhcmUoJ1NFTEVDVCAqIEZST00gc2V0cyBXSEVSRSBjb2RlID0gPyBBTkQgZW5hYmxlZCA9IDEnKQogICAgLmdldChyZXEucGFyYW1zLmNvZGUpOwogIGlmICghc2V0KSByZXR1cm4gcmVzLnN0YXR1cyg0MDQpLmpzb24oeyBlcnJvcjogJ3Vua25vd24gb3IgZGlzYWJsZWQgc2V0IGNvZGUnIH0pOwoKICBjb25zdCByb3N0ZXIgPSBkYgogICAgLnByZXBhcmUoCiAgICAgIGBTRUxFQ1QgZy4qIEZST00gZ2hvc3RzIGcKICAgICAgIEpPSU4gc2V0X2dob3N0cyBzZyBPTiBzZy5naG9zdF9pZCA9IGcuaWQKICAgICAgIFdIRVJFIHNnLnNldF9pZCA9ID8gQU5EIGcuZW5hYmxlZCA9IDEKICAgICAgIE9SREVSIEJZIGcuaXNfYm9zcyBERVNDLCBnLnJhcml0eSBERVNDLCBnLm5hbWVgCiAgICApCiAgICAuYWxsKHNldC5pZCk7CgogIGNvbnN0IGJvc3MgPSBzZXQuYm9zc19naG9zdF9pZAogICAgPyBkYi5wcmVwYXJlKCdTRUxFQ1QgKiBGUk9NIGdob3N0cyBXSEVSRSBpZCA9ID8nKS5nZXQoc2V0LmJvc3NfZ2hvc3RfaWQpCiAgICA6IG51bGw7CgogIHJlcy5qc29uKHsKICAgIHNldDogewogICAgICBjb2RlOiBzZXQuY29kZSwKICAgICAgc2V0TnVtYmVyOiBzZXQuc2V0X251bWJlciwKICAgICAgc2V0TmFtZTogc2V0LnNldF9uYW1lLAogICAgfSwKICAgIGJvc3M6IGJvc3MgPyByb3dUb0dob3N0KGJvc3MpIDogbnVsbCwKICAgIHJvc3Rlcjogcm9zdGVyLm1hcChyb3dUb0dob3N0KSwKICB9KTsKfSk7CgovLyBHRVQgL2FwaS9mcmVlaHVudCAg4oCUIG5vIGF1dGguIFNwYXducyBOIHdlaWdodGVkIHJhbmRvbSBlbmFibGVkIG5vbi1ib3NzIGdob3N0cwovLyBmb3IgZnJlZS1odW50IG1vZGUgKHByb2NlZHVyYWwgd2lzcHMgY2xpZW50LXNpZGUgaWYgbm8gaW1hZ2UpLgpyb3V0ZXIuZ2V0KCcvZnJlZWh1bnQnLCAocmVxLCByZXMpID0+IHsKICBjb25zdCBuID0gTWF0aC5taW4ocGFyc2VJbnQocmVxLnF1ZXJ5Lm4sIDEwKSB8fCAzLCAxMCk7CiAgY29uc3QgdHlwZSA9IHJlcS5xdWVyeS50eXBlOyAvLyBvcHRpb25hbCByZWR8eWVsbG93fGJsdWUgZmlsdGVyCiAgbGV0IHEgPSAnU0VMRUNUICogRlJPTSBnaG9zdHMgV0hFUkUgZW5hYmxlZCA9IDEgQU5EIGlzX2Jvc3MgPSAwJzsKICBjb25zdCBwYXJhbXMgPSBbXTsKICBpZiAodHlwZSAmJiBbJ3JlZCcsICd5ZWxsb3cnLCAnYmx1ZSddLmluY2x1ZGVzKHR5cGUpKSB7CiAgICBxICs9ICcgQU5EIHR5cGUgPSA/JzsKICAgIHBhcmFtcy5wdXNoKHR5cGUpOwogIH0KICBjb25zdCBwb29sID0gZGIucHJlcGFyZShxKS5hbGwoLi4ucGFyYW1zKTsKICBjb25zdCBzcGF3bnMgPSBbXTsKICBmb3IgKGxldCBpID0gMDsgaSA8IG4gJiYgcG9vbC5sZW5ndGg7IGkrKykgewogICAgY29uc3QgcGljayA9IHdlaWdodGVkUGljayhwb29sKTsKICAgIGlmIChwaWNrKSBzcGF3bnMucHVzaChyb3dUb0dob3N0KHBpY2spKTsKICB9CiAgcmVzLmpzb24oeyBzcGF3bnMgfSk7Cn0pOwoKLy8gR0VUIC9hcGkvZ2hvc3RzICDigJQgbm8gYXV0aC4gUHVibGljIHJvc3RlciBicm93c2VyIChlbmFibGVkIG9ubHkpLgpyb3V0ZXIuZ2V0KCcvZ2hvc3RzJywgKHJlcSwgcmVzKSA9PiB7CiAgY29uc3QgeyB0eXBlLCByYXJpdHksIGJvc3MgfSA9IHJlcS5xdWVyeTsKICBsZXQgcSA9ICdTRUxFQ1QgKiBGUk9NIGdob3N0cyBXSEVSRSBlbmFibGVkID0gMSc7CiAgY29uc3QgcGFyYW1zID0gW107CiAgaWYgKHR5cGUpIHsgcSArPSAnIEFORCB0eXBlID0gPyc7IHBhcmFtcy5wdXNoKHR5cGUpOyB9CiAgaWYgKHJhcml0eSkgeyBxICs9ICcgQU5EIHJhcml0eSA9ID8nOyBwYXJhbXMucHVzaChwYXJzZUludChyYXJpdHksIDEwKSk7IH0KICBpZiAoYm9zcyA9PT0gJzEnKSBxICs9ICcgQU5EIGlzX2Jvc3MgPSAxJzsKICBxICs9ICcgT1JERVIgQlkgdHlwZSwgcmFyaXR5LCBuYW1lJzsKICByZXMuanNvbih7IGdob3N0czogZGIucHJlcGFyZShxKS5hbGwoLi4ucGFyYW1zKS5tYXAocm93VG9HaG9zdCkgfSk7Cn0pOwoKLy8gR0VUIC9hcGkvYWJpbGl0aWVzIOKAlCBubyBhdXRoLiBSZWZlcmVuY2UgZGF0YS4Kcm91dGVyLmdldCgnL2FiaWxpdGllcycsIChyZXEsIHJlcykgPT4gewogIGNvbnN0IHJvd3MgPSBkYi5wcmVwYXJlKCdTRUxFQ1QgKiBGUk9NIGFiaWxpdGllcyBPUkRFUiBCWSBraW5kLCBuYW1lJykuYWxsKCk7CiAgcmVzLmpzb24oeyBhYmlsaXRpZXM6IHJvd3MgfSk7Cn0pOwoKZXhwb3J0IGRlZmF1bHQgcm91dGVyOwo= import { Router } from 'express';
import db from '../db/index.js';
const router = Router();
// Rarity spawn weights (common -> legendary). Mirrors the project's
// common(7)/rare(3)/legendary(1) weighting, mapped onto the 4 star tiers.
const RARITY_WEIGHTS = { 1: 7, 2: 4, 3: 3, 4: 1 };
function rowToGhost(g) {
return {
id: g.id,
name: g.name,
displayName: g.display_name || g.name,
type: g.type,
rarity: g.rarity,
speed: g.speed,
range: g.range,
chargeShot: g.charge_shot,
health: g.health,
damage: g.damage,
ability: g.ability,
isBoss: !!g.is_boss,
setNumber: g.set_number,
setName: g.set_name,
image: g.image_path ? `/uploads/${g.image_path}` : null,
webm: g.webm_path ? `/uploads/${g.webm_path}` : null,
webp: g.webp_path ? `/uploads/${g.webp_path}` : null,
};
}
function weightedPick(ghosts) {
const weighted = [];
for (const g of ghosts) {
const w = RARITY_WEIGHTS[g.rarity] ?? 1;
for (let i = 0; i < w; i++) weighted.push(g);
}
if (!weighted.length) return null;
return weighted[Math.floor(Math.random() * weighted.length)];
}
// GET /api/scan/:code — no auth. Returns the set's ghost roster + boss.
router.get('/scan/:code', (req, res) => {
const set = db
.prepare('SELECT * FROM sets WHERE code = ? AND enabled = 1')
.get(req.params.code);
if (!set) return res.status(404).json({ error: 'unknown or disabled set code' });
const roster = db
.prepare(
`SELECT g.* FROM ghosts g
JOIN set_ghosts sg ON sg.ghost_id = g.id
WHERE sg.set_id = ? AND g.enabled = 1
ORDER BY g.is_boss DESC, g.rarity DESC, g.name`
)
.all(set.id);
const boss = set.boss_ghost_id
? db.prepare('SELECT * FROM ghosts WHERE id = ?').get(set.boss_ghost_id)
: null;
res.json({
set: {
code: set.code,
setNumber: set.set_number,
setName: set.set_name,
},
boss: boss ? rowToGhost(boss) : null,
roster: roster.map(rowToGhost),
});
});
// GET /api/freehunt — no auth. Spawns N weighted random enabled non-boss ghosts
// for free-hunt mode (procedural wisps client-side if no image).
router.get('/freehunt', (req, res) => {
const n = Math.min(parseInt(req.query.n, 10) || 3, 10);
const type = req.query.type; // optional red|yellow|blue filter
let q = 'SELECT * FROM ghosts WHERE enabled = 1 AND is_boss = 0';
const params = [];
if (type && ['red', 'yellow', 'blue'].includes(type)) {
q += ' AND type = ?';
params.push(type);
}
const pool = db.prepare(q).all(...params);
const spawns = [];
for (let i = 0; i < n && pool.length; i++) {
const pick = weightedPick(pool);
if (pick) spawns.push(rowToGhost(pick));
}
res.json({ spawns });
});
// GET /api/ghosts — no auth. Public roster browser (enabled only).
router.get('/ghosts', (req, res) => {
const { type, rarity, boss } = req.query;
let q = 'SELECT * FROM ghosts WHERE enabled = 1';
const params = [];
if (type) { q += ' AND type = ?'; params.push(type); }
if (rarity) { q += ' AND rarity = ?'; params.push(parseInt(rarity, 10)); }
if (boss === '1') q += ' AND is_boss = 1';
q += ' ORDER BY type, rarity, name';
res.json({ ghosts: db.prepare(q).all(...params).map(rowToGhost) });
});
// GET /api/abilities — no auth. Reference data.
router.get('/abilities', (req, res) => {
const rows = db.prepare('SELECT * FROM abilities ORDER BY kind, name').all();
res.json({ abilities: rows });
});
export default router;