diff --git a/.gitignore b/.gitignore
index a222bd3..52b8fa8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,7 @@
node_modules/
colony.json
colony.json.tmp
+decks.json
+decks.json.tmp
+data/
+assets/deck-images/
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..e6b65b3
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,13 @@
+FROM node:20-alpine
+WORKDIR /app
+
+COPY package*.json ./
+RUN npm ci --only=production
+
+COPY . .
+RUN mkdir -p assets/deck-images
+
+EXPOSE 3000
+ENV NODE_ENV=production
+
+CMD ["node", "server.js"]
diff --git a/deck-editor.html b/deck-editor.html
new file mode 100644
index 0000000..b166d81
--- /dev/null
+++ b/deck-editor.html
@@ -0,0 +1,585 @@
+
+
+
+
@@ -967,6 +1002,9 @@
+
+
+
+
@@ -1361,6 +1413,11 @@
saving: false,
tlItems: [],
_tlRows: { top: 1, bot: 1 },
+ // Image decks
+ deckImages: [],
+ deckCache: {},
+ customDecks: [], // full deck objects with cards
+ activeDeckId: '', // '' = default cards.json
// Live updates
liveUpdate: false,
// Card draw
@@ -1398,6 +1455,7 @@
return
}
this.loadCards()
+ this.loadDecks()
this.connectLive()
const ok = await this.loadGame()
if (!ok) { this.loading = false; return }
@@ -1416,6 +1474,7 @@
if (!r.ok) { this.error = 'Session not found'; return false }
this.game = await r.json()
document.title = this.game.title + ' — Orison Reach'
+ if (this.game.deck_id) this.activeDeckId = this.game.deck_id
return true
} catch {
this.error = 'Network error'
@@ -1429,6 +1488,54 @@
this.items = r.ok ? await r.json() : []
} catch { this.items = [] }
this.tlItems = this.computeLayout()
+ this.buildDeckCache()
+ },
+
+ async loadDecks() {
+ try {
+ const r = await fetch('/api/decks')
+ if (!r.ok) return
+ const decks = await r.json()
+ this.customDecks = decks
+ this.deckImages = decks.flatMap(d => (d.images || []).map(img => ({
+ filename: img.filename, keywords: img.keywords || []
+ })))
+ this.buildDeckCache()
+ } catch {}
+ },
+
+ buildDeckCache() {
+ if (!this.deckImages.length) return
+ const cache = {}
+ for (const item of this.items) {
+ if (!item.env) cache[item.id] = this.pickDeckImage(item)
+ }
+ this.deckCache = cache
+ },
+
+ // Deterministic seed from item ID so image never changes on re-render
+ idSeed(id) {
+ return id.split('').reduce((a, c) => (a * 31 + c.charCodeAt(0)) >>> 0, 0)
+ },
+
+ pickDeckImage(item) {
+ if (!this.deckImages.length) return null
+ const tags = new Set()
+ if (item.mood) tags.add(item.mood.toLowerCase())
+ if (item.discipline) tags.add(item.discipline.toLowerCase())
+ for (const m of (item.description || '').matchAll(/\*\*([^*\n]+)\*\*/g))
+ tags.add(m[1].trim().toLowerCase())
+ const scored = this.deckImages.map(img => ({
+ img,
+ score: img.keywords.filter(k => tags.has(k.toLowerCase())).length + 1
+ }))
+ const total = scored.reduce((s, x) => s + x.score, 0)
+ let pick = (this.idSeed(item.id) % 10000) / 10000 * total
+ for (const { img, score } of scored) {
+ pick -= score
+ if (pick <= 0) return img.filename
+ }
+ return scored[0].img.filename
},
connectLive() {
@@ -1456,6 +1563,35 @@
},
drawCard() {
+ // Custom deck selected?
+ if (this.activeDeckId) {
+ const deck = this.customDecks.find(d => d.id === this.activeDeckId)
+ const pool = deck?.cards || []
+ if (!pool.length) return
+ const hasCast = this.cast().length > 0
+ const hasKeyword = this.allKeywords().length > 0
+ const hasProject = this.projects().length > 0
+ const meets = c => {
+ if (!c.requires?.length) return true
+ return c.requires.every(r =>
+ r==='cast'?hasCast:r==='keyword'?hasKeyword:r==='project'?hasProject:true)
+ }
+ const eligible = pool.filter(meets)
+ const avail = eligible.filter(c => !this.recentCardIds.includes(c.id))
+ const src = avail.length ? avail : eligible
+ if (!src.length) return
+ const picked = src[Math.floor(Math.random() * src.length)]
+ const resolved = this.resolveDynamic(picked)
+ // Pick random card image if the card has images
+ if (picked.images?.length) {
+ const img = picked.images[Math.floor(Math.random() * picked.images.length)]
+ resolved.cardImage = img.filename
+ }
+ this.drawnCard = resolved
+ this.recentCardIds = [...this.recentCardIds.slice(-9), picked.id]
+ this.showCard = true
+ return
+ }
if (!this.cards.length) return
const hasCast = this.cast().length > 0
const hasKeyword = this.allKeywords().length > 0
@@ -1992,8 +2128,17 @@
this.showModal = true
},
+ async saveDeckToGame() {
+ if (!this.game || !this.editMode) return
+ await fetch('/api/games/' + this.slug, {
+ method: 'PUT',
+ headers: this.authHeaders(),
+ body: JSON.stringify({ ...this.game, deck_id: this.activeDeckId })
+ })
+ },
+
openEditGame() {
- this.activeForm = Object.assign({}, this.game)
+ this.activeForm = Object.assign({ deck_id: '' }, this.game)
this.formError = null
this.modalType = 'editGame'
this.showModal = true
@@ -2074,6 +2219,7 @@
if (!r.ok) { this.formError = data.error || 'Failed to save'; return }
this.game = data
document.title = data.title + ' — Orison Reach'
+ if (data.deck_id !== undefined) this.activeDeckId = data.deck_id
this.tlItems = this.computeLayout()
this.closeModal()
} catch { this.formError = 'Network error' }
diff --git a/games.html b/games.html
index 19620e7..1d9cd10 100644
--- a/games.html
+++ b/games.html
@@ -344,6 +344,19 @@
+
+
Card Deck
+
+
+
+
+
+
@@ -360,6 +373,7 @@
function gamesApp() {
return {
games: [],
+ decks: [],
loading: true,
showModal: false,
saving: false,
@@ -367,7 +381,11 @@
form: {},
async init() {
- await this.loadGames()
+ await Promise.all([this.loadGames(), this.loadDecks()])
+ },
+
+ async loadDecks() {
+ try { const r = await fetch('/api/decks'); this.decks = r.ok ? await r.json() : [] } catch {}
},
async loadGames() {
@@ -386,7 +404,8 @@
title: '', description: '', mood: 'light',
timeline_start: '', timeline_end: '', password: '',
start_title: '', start_description: '', start_mood: '',
- end_title: '', end_description: '', end_mood: ''
+ end_title: '', end_description: '', end_mood: '',
+ deck_id: ''
}
this.formError = null
this.showModal = true
diff --git a/index.html b/index.html
index 756628b..ae8294f 100644
--- a/index.html
+++ b/index.html
@@ -237,7 +237,8 @@
Engineering Project
Labour Project
Survival Ledger
-
Game Sessions
+
Game Sessions
+
Image Decks
diff --git a/server.js b/server.js
index 15f1679..031814b 100644
--- a/server.js
+++ b/server.js
@@ -6,6 +6,26 @@ const path = require('path')
const app = express()
const PORT = process.env.PORT || 3000
+const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || ''
+
+// Stateless admin token: hmac(timestamp, secret). Valid 24h.
+function makeAdminToken() {
+ const ts = Date.now().toString()
+ const sig = crypto.createHmac('sha256', 'colony-admin-' + ADMIN_PASSWORD).update(ts).digest('hex')
+ return ts + '.' + sig
+}
+function validAdminToken(token) {
+ if (!ADMIN_PASSWORD || !token) return !ADMIN_PASSWORD // if no password set, allow all
+ const [ts, sig] = (token || '').split('.')
+ if (!ts || !sig) return false
+ if (Date.now() - Number(ts) > 86_400_000) return false // expired
+ const expected = crypto.createHmac('sha256', 'colony-admin-' + ADMIN_PASSWORD).update(ts).digest('hex')
+ return sig === expected
+}
+function requireAdmin(req, res, next) {
+ if (validAdminToken(req.headers['x-admin-token'])) return next()
+ res.status(401).json({ error: 'Admin authentication required' })
+}
// SSE clients: Map
>
const sseClients = new Map()
@@ -67,10 +87,18 @@ function publicGame(g) {
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 || '',
+ deck_id: g.deck_id || '',
created_at: g.created_at
}
}
+// --- Admin auth ---
+app.post('/api/admin/login', (req, res) => {
+ if (!ADMIN_PASSWORD) return res.json({ token: 'no-auth-required' })
+ if (req.body.password !== ADMIN_PASSWORD) return res.status(401).json({ error: 'Wrong password' })
+ res.json({ token: makeAdminToken() })
+})
+
// --- Live updates (SSE) ---
app.get('/api/games/:slug/live', (req, res) => {
res.writeHead(200, {
@@ -150,6 +178,7 @@ app.put('/api/games/:slug', requirePassword, (req, res) => {
game.end_title = end_title || ''
game.end_description = end_description || ''
game.end_mood = end_mood || ''
+ game.deck_id = req.body.deck_id || ''
saveDB(req.db); broadcast(req.params.slug)
res.json(publicGame(game))
})
@@ -258,4 +287,167 @@ app.delete('/api/games/:slug/keywords/:id', requirePassword, (req, res) => {
res.json({ ok: true })
})
+// --- Decks (image libraries) ---
+function loadDecks() {
+ try { return JSON.parse(fs.readFileSync(path.join(__dirname, 'decks.json'), 'utf8')) }
+ catch { return { decks: [] } }
+}
+function saveDecks(data) {
+ const tmp = path.join(__dirname, 'decks.json') + '.tmp'
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2))
+ fs.renameSync(tmp, path.join(__dirname, 'decks.json'))
+}
+
+app.get('/api/decks', (req, res) => {
+ res.json(loadDecks().decks)
+})
+
+app.post('/api/decks', requireAdmin, (req, res) => {
+ const { name, description } = req.body
+ if (!name) return res.status(400).json({ error: 'name required' })
+ const db = loadDecks()
+ const deck = { id: newId(), name, description: description || '', images: [], created_at: now() }
+ db.decks.push(deck)
+ saveDecks(db)
+ res.status(201).json(deck)
+})
+
+app.put('/api/decks/:id', requireAdmin, (req, res) => {
+ const db = loadDecks()
+ const deck = db.decks.find(d => d.id === req.params.id)
+ if (!deck) return res.status(404).json({ error: 'Deck not found' })
+ deck.name = req.body.name || deck.name
+ deck.description = req.body.description ?? deck.description
+ saveDecks(db)
+ res.json(deck)
+})
+
+app.delete('/api/decks/:id', requireAdmin, (req, res) => {
+ const db = loadDecks()
+ const idx = db.decks.findIndex(d => d.id === req.params.id)
+ if (idx === -1) return res.status(404).json({ error: 'Deck not found' })
+ // Delete image files
+ for (const img of db.decks[idx].images || []) {
+ try { fs.unlinkSync(path.join(__dirname, 'assets', 'deck-images', img.filename)) } catch {}
+ }
+ db.decks.splice(idx, 1)
+ saveDecks(db)
+ res.json({ ok: true })
+})
+
+// --- Cards within a deck ---
+app.post('/api/decks/:id/cards', requireAdmin, (req, res) => {
+ const db = loadDecks()
+ const deck = db.decks.find(d => d.id === req.params.id)
+ if (!deck) return res.status(404).json({ error: 'Deck not found' })
+ const { title, type, question, a, b, requires } = req.body
+ if (!title || !a || !b) return res.status(400).json({ error: 'title, a, b required' })
+ const card = { id: newId(), title, type: type || 'action', question: question || '', a, b, requires: requires || [], images: [], created_at: now() }
+ deck.cards = deck.cards || []
+ deck.cards.push(card)
+ saveDecks(db)
+ res.status(201).json(card)
+})
+
+app.put('/api/decks/:deckId/cards/:cardId', requireAdmin, (req, res) => {
+ const db = loadDecks()
+ const deck = db.decks.find(d => d.id === req.params.deckId)
+ if (!deck) return res.status(404).json({ error: 'Deck not found' })
+ const card = (deck.cards || []).find(c => c.id === req.params.cardId)
+ if (!card) return res.status(404).json({ error: 'Card not found' })
+ const { title, type, question, a, b, requires } = req.body
+ Object.assign(card, { title: title ?? card.title, type: type ?? card.type, question: question ?? card.question, a: a ?? card.a, b: b ?? card.b, requires: requires ?? card.requires })
+ saveDecks(db)
+ res.json(card)
+})
+
+app.delete('/api/decks/:deckId/cards/:cardId', requireAdmin, (req, res) => {
+ const db = loadDecks()
+ const deck = db.decks.find(d => d.id === req.params.deckId)
+ if (!deck) return res.status(404).json({ error: 'Deck not found' })
+ const idx = (deck.cards || []).findIndex(c => c.id === req.params.cardId)
+ if (idx === -1) return res.status(404).json({ error: 'Card not found' })
+ for (const img of deck.cards[idx].images || []) {
+ try { fs.unlinkSync(path.join(__dirname, 'assets', 'deck-images', img.filename)) } catch {}
+ }
+ deck.cards.splice(idx, 1)
+ saveDecks(db)
+ res.json({ ok: true })
+})
+
+app.post('/api/decks/:deckId/cards/:cardId/images', requireAdmin, (req, res) => {
+ const db = loadDecks()
+ const deck = db.decks.find(d => d.id === req.params.deckId)
+ if (!deck) return res.status(404).json({ error: 'Deck not found' })
+ const card = (deck.cards || []).find(c => c.id === req.params.cardId)
+ if (!card) return res.status(404).json({ error: 'Card not found' })
+ const { filename, data, keywords } = req.body
+ if (!data) return res.status(400).json({ error: 'data required' })
+ const ext = path.extname(filename || '.jpg') || '.jpg'
+ const savedName = `deck-${newId()}${ext}`
+ fs.mkdirSync(path.join(__dirname, 'assets', 'deck-images'), { recursive: true })
+ fs.writeFileSync(path.join(__dirname, 'assets', 'deck-images', savedName), Buffer.from(data, 'base64'))
+ const img = { id: newId(), filename: savedName, keywords: keywords || [], created_at: now() }
+ card.images = card.images || []
+ card.images.push(img)
+ saveDecks(db)
+ res.status(201).json(img)
+})
+
+app.delete('/api/decks/:deckId/cards/:cardId/images/:imgId', requireAdmin, (req, res) => {
+ const db = loadDecks()
+ const deck = db.decks.find(d => d.id === req.params.deckId)
+ if (!deck) return res.status(404).json({ error: 'Deck not found' })
+ const card = (deck.cards || []).find(c => c.id === req.params.cardId)
+ if (!card) return res.status(404).json({ error: 'Card not found' })
+ const idx = (card.images || []).findIndex(i => i.id === req.params.imgId)
+ if (idx === -1) return res.status(404).json({ error: 'Image not found' })
+ try { fs.unlinkSync(path.join(__dirname, 'assets', 'deck-images', card.images[idx].filename)) } catch {}
+ card.images.splice(idx, 1)
+ saveDecks(db)
+ res.json({ ok: true })
+})
+
+// --- Pool images (for event auto-matching) ---
+app.post('/api/decks/:id/images', requireAdmin, (req, res) => {
+ const db = loadDecks()
+ const deck = db.decks.find(d => d.id === req.params.id)
+ if (!deck) return res.status(404).json({ error: 'Deck not found' })
+ const { filename, data, keywords } = req.body
+ if (!data) return res.status(400).json({ error: 'data required' })
+ const ext = path.extname(filename || '.jpg') || '.jpg'
+ const savedName = `deck-${newId()}${ext}`
+ const imgPath = path.join(__dirname, 'assets', 'deck-images', savedName)
+ fs.mkdirSync(path.dirname(imgPath), { recursive: true })
+ fs.writeFileSync(imgPath, Buffer.from(data, 'base64'))
+ const img = { id: newId(), filename: savedName, keywords: keywords || [], created_at: now() }
+ deck.images = deck.images || []
+ deck.images.push(img)
+ saveDecks(db)
+ res.status(201).json(img)
+})
+
+app.put('/api/decks/:deckId/images/:imgId', requireAdmin, (req, res) => {
+ const db = loadDecks()
+ const deck = db.decks.find(d => d.id === req.params.deckId)
+ if (!deck) return res.status(404).json({ error: 'Deck not found' })
+ const img = deck.images.find(i => i.id === req.params.imgId)
+ if (!img) return res.status(404).json({ error: 'Image not found' })
+ img.keywords = req.body.keywords ?? img.keywords
+ saveDecks(db)
+ res.json(img)
+})
+
+app.delete('/api/decks/:deckId/images/:imgId', requireAdmin, (req, res) => {
+ const db = loadDecks()
+ const deck = db.decks.find(d => d.id === req.params.deckId)
+ if (!deck) return res.status(404).json({ error: 'Deck not found' })
+ const idx = deck.images.findIndex(i => i.id === req.params.imgId)
+ if (idx === -1) return res.status(404).json({ error: 'Image not found' })
+ try { fs.unlinkSync(path.join(__dirname, 'assets', 'deck-images', deck.images[idx].filename)) } catch {}
+ deck.images.splice(idx, 1)
+ saveDecks(db)
+ res.json({ ok: true })
+})
+
app.listen(PORT, () => console.log(`Colony Archive running on http://localhost:${PORT}`))