const express = require('express') const bcrypt = require('bcryptjs') const crypto = require('crypto') const fs = require('fs') const path = require('path') const app = express() const PORT = process.env.PORT || 3000 // SSE clients: Map> const sseClients = new Map() function broadcast(slug) { const bucket = sseClients.get(slug) if (!bucket) return const msg = `data: update\n\n` for (const res of bucket) { try { res.write(msg) } catch {} } } const DB_PATH = path.join(__dirname, 'colony.json') function loadDB() { try { return JSON.parse(fs.readFileSync(DB_PATH, 'utf8')) } catch { return { games: [] } } } function saveDB(data) { const tmp = DB_PATH + '.tmp' fs.writeFileSync(tmp, JSON.stringify(data, null, 2)) fs.renameSync(tmp, DB_PATH) } function toSlug(title) { return title.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') } function newId() { return crypto.randomUUID() } function now() { return new Date().toISOString() } app.use(express.json()) app.use(express.static(path.join(__dirname))) function requirePassword(req, res, next) { const password = req.headers['x-game-password'] if (!password) return res.status(401).json({ error: 'Password required' }) const db = loadDB() const game = db.games.find(g => g.slug === req.params.slug) if (!game) return res.status(404).json({ error: 'Game not found' }) if (!bcrypt.compareSync(password, game.password_hash)) { return res.status(401).json({ error: 'Invalid password' }) } req.game = game req.db = db next() } function publicGame(g) { return { id: g.id, slug: g.slug, title: g.title, description: g.description || '', mood: g.mood || 'light', timeline_start: g.timeline_start, timeline_end: g.timeline_end, start_title: g.start_title || '', start_description: g.start_description || '', start_mood: g.start_mood || '', end_title: g.end_title || '', end_description: g.end_description || '', end_mood: g.end_mood || '', created_at: g.created_at } } // --- Live updates (SSE) --- app.get('/api/games/:slug/live', (req, res) => { res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'X-Accel-Buffering': 'no', }) res.write('data: connected\n\n') const slug = req.params.slug if (!sseClients.has(slug)) sseClients.set(slug, new Set()) sseClients.get(slug).add(res) req.on('close', () => { sseClients.get(slug)?.delete(res) if (sseClients.get(slug)?.size === 0) sseClients.delete(slug) }) }) // --- Games --- app.get('/api/games', (req, res) => { const db = loadDB() res.json(db.games.map(g => ({ id: g.id, slug: g.slug, title: g.title, description: g.description, mood: g.mood, timeline_start: g.timeline_start, timeline_end: g.timeline_end, created_at: g.created_at }))) }) app.post('/api/games', (req, res) => { const { title, description, mood, timeline_start, timeline_end, password, start_title, start_description, start_mood, end_title, end_description, end_mood } = req.body if (!title || !timeline_start || !timeline_end || !password) { return res.status(400).json({ error: 'title, timeline_start, timeline_end, password required' }) } const base = toSlug(title) if (!base) return res.status(400).json({ error: 'Title must contain letters or numbers' }) const db = loadDB() let slug = base, n = 1 while (db.games.find(g => g.slug === slug)) slug = `${base}-${n++}` const game = { id: newId(), slug, title, description: description || '', mood: mood || 'light', timeline_start, timeline_end, start_title: start_title || '', start_description: start_description || '', start_mood: start_mood || '', end_title: end_title || '', end_description: end_description || '', end_mood: end_mood || '', password_hash: bcrypt.hashSync(password, 10), created_at: now(), items: [] } db.games.push(game) saveDB(db) res.status(201).json(publicGame(game)) }) app.get('/api/games/:slug', (req, res) => { const db = loadDB() const game = db.games.find(g => g.slug === req.params.slug) if (!game) return res.status(404).json({ error: 'Game not found' }) res.json(publicGame(game)) }) app.put('/api/games/:slug', requirePassword, (req, res) => { const { title, description, mood, timeline_start, timeline_end, start_title, start_description, start_mood, end_title, end_description, end_mood } = req.body if (!title || !timeline_start || !timeline_end) { return res.status(400).json({ error: 'title, timeline_start, timeline_end required' }) } const game = req.game game.title = title game.description = description || '' game.mood = mood || 'light' game.timeline_start = timeline_start game.timeline_end = timeline_end game.start_title = start_title || '' game.start_description = start_description || '' game.start_mood = start_mood || '' game.end_title = end_title || '' game.end_description = end_description || '' game.end_mood = end_mood || '' saveDB(req.db); broadcast(req.params.slug) res.json(publicGame(game)) }) app.post('/api/games/:slug/verify', (req, res) => { const { password } = req.body if (!password) return res.status(400).json({ error: 'Password required' }) const db = loadDB() const game = db.games.find(g => g.slug === req.params.slug) if (!game) return res.status(404).json({ error: 'Game not found' }) if (!bcrypt.compareSync(password, game.password_hash)) { return res.status(401).json({ error: 'Invalid password' }) } res.json({ ok: true }) }) // --- Items --- app.get('/api/games/:slug/items', (req, res) => { const db = loadDB() const game = db.games.find(g => g.slug === req.params.slug) if (!game) return res.status(404).json({ error: 'Game not found' }) const sorted = [...(game.items || [])].sort((a, b) => a.date.localeCompare(b.date) || a.created_at.localeCompare(b.created_at)) res.json(sorted) }) app.post('/api/games/:slug/items', requirePassword, (req, res) => { const { type, title, description, mood, date, end_date, status, discipline, env } = req.body if (!type || !title || !date) return res.status(400).json({ error: 'type, title, date required' }) if (!['event', 'project', 'person'].includes(type)) { return res.status(400).json({ error: 'type must be event, project, or person' }) } const item = { id: newId(), type, title, description: description || '', mood: mood || null, date, end_date: end_date || null, status: status || null, discipline: discipline || null, env: env || null, env_layout: req.body.env_layout || null, created_at: now() } req.game.items = req.game.items || [] req.game.items.push(item) saveDB(req.db); broadcast(req.params.slug) res.status(201).json(item) }) app.put('/api/games/:slug/items/:id', requirePassword, (req, res) => { const items = req.game.items || [] const idx = items.findIndex(i => i.id === req.params.id) if (idx === -1) return res.status(404).json({ error: 'Item not found' }) const { title, description, mood, date, end_date, status, discipline } = req.body if (!title || !date) return res.status(400).json({ error: 'title, date required' }) const { env, env_layout } = req.body items[idx] = { ...items[idx], title, description: description || '', mood: mood || null, date, end_date: end_date || null, status: status || null, discipline: discipline || null, env: env || null, env_layout: env_layout || null } saveDB(req.db); broadcast(req.params.slug) res.json(items[idx]) }) app.delete('/api/games/:slug/items/:id', requirePassword, (req, res) => { const items = req.game.items || [] const idx = items.findIndex(i => i.id === req.params.id) if (idx === -1) return res.status(404).json({ error: 'Item not found' }) items.splice(idx, 1) saveDB(req.db); broadcast(req.params.slug) res.json({ ok: true }) }) // --- Keywords --- app.get('/api/games/:slug/keywords', (req, res) => { const db = loadDB() const game = db.games.find(g => g.slug === req.params.slug) if (!game) return res.status(404).json({ error: 'Game not found' }) res.json([...(game.keywords || [])].sort((a, b) => a.term.localeCompare(b.term))) }) app.post('/api/games/:slug/keywords', requirePassword, (req, res) => { const { term, definition } = req.body if (!term) return res.status(400).json({ error: 'term required' }) req.game.keywords = req.game.keywords || [] const existing = req.game.keywords.find(k => k.term.toLowerCase() === term.toLowerCase()) if (existing) { existing.definition = definition || '' saveDB(req.db); broadcast(req.params.slug) return res.json(existing) } const kw = { id: newId(), term, definition: definition || '', created_at: now() } req.game.keywords.push(kw) saveDB(req.db); broadcast(req.params.slug) res.status(201).json(kw) }) app.put('/api/games/:slug/keywords/:id', requirePassword, (req, res) => { const kws = req.game.keywords || [] const idx = kws.findIndex(k => k.id === req.params.id) if (idx === -1) return res.status(404).json({ error: 'Keyword not found' }) const { term, definition } = req.body kws[idx] = { ...kws[idx], term: term || kws[idx].term, definition: definition || '' } saveDB(req.db); broadcast(req.params.slug) res.json(kws[idx]) }) app.delete('/api/games/:slug/keywords/:id', requirePassword, (req, res) => { const kws = req.game.keywords || [] const idx = kws.findIndex(k => k.id === req.params.id) if (idx === -1) return res.status(404).json({ error: 'Keyword not found' }) kws.splice(idx, 1) saveDB(req.db); broadcast(req.params.slug) res.json({ ok: true }) }) app.listen(PORT, () => console.log(`Colony Archive running on http://localhost:${PORT}`))