diff --git a/scripts/seed.js b/scripts/seed.js new file mode 100644 index 0000000..e30e430 --- /dev/null +++ b/scripts/seed.js @@ -0,0 +1,129 @@ +import bcrypt from 'bcryptjs'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import db from '../db/index.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const dataDir = join(__dirname, '..', 'data'); + +const ghosts = JSON.parse(readFileSync(join(dataDir, 'ghosts.json'), 'utf8')); +const abilities = JSON.parse(readFileSync(join(dataDir, 'abilities.json'), 'utf8')); +const sets = JSON.parse(readFileSync(join(dataDir, 'sets.json'), 'utf8')); + +// Some source ghosts have "-" (null) health/damage. Derive a sensible value from +// the average of same type+rarity peers so AR combat always has valid numbers. +function deriveStats(list) { + const buckets = {}; + for (const g of list) { + if (g.health == null || g.damage == null) continue; + const k = `${g.type}-${g.rarity}`; + (buckets[k] ??= { h: [], d: [] }); + buckets[k].h.push(g.health); + buckets[k].d.push(g.damage); + } + const avg = (a) => (a.length ? Math.round(a.reduce((x, y) => x + y, 0) / a.length) : null); + const fallback = { h: 338, d: 192 }; // global-ish midpoint if a whole bucket is empty + for (const g of list) { + const k = `${g.type}-${g.rarity}`; + const b = buckets[k]; + if (g.health == null) g.health = (b && avg(b.h)) ?? fallback.h; + if (g.damage == null) g.damage = (b && avg(b.d)) ?? fallback.d; + } + return list; +} + +deriveStats(ghosts); + +const insertAbility = db.prepare(` + INSERT INTO abilities (name, kind, charges, cooldown, effect) + VALUES (@name, @kind, @charges, @cooldown, @effect) + ON CONFLICT(name) DO UPDATE SET + kind=excluded.kind, charges=excluded.charges, + cooldown=excluded.cooldown, effect=excluded.effect +`); + +const insertGhost = 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 + (@name, @name, @type, @rarity, @speed, @range, @chargeShot, + @health, @damage, @ability, @isBoss, @setNumber, @setName, 1) +`); + +const seed = db.transaction(() => { + // Only seed ghosts/abilities/sets if the ghosts table is empty, + // so re-running seed never clobbers admin edits. + const count = db.prepare('SELECT COUNT(*) AS n FROM ghosts').get().n; + if (count === 0) { + for (const a of abilities) { + insertAbility.run({ + name: a.name, kind: a.kind ?? 'common', + charges: a.charges ?? null, cooldown: a.cooldown ?? null, effect: a.effect ?? null, + }); + } + + const idByName = {}; + for (const g of ghosts) { + const info = insertGhost.run({ ...g, isBoss: g.isBoss ? 1 : 0 }); + idByName[g.name] = info.lastInsertRowid; + } + + // Build a set per canonical boss-linked LEGO reference. + // Roster = the set's boss + a curated sampling of type-matched ghosts, + // so each scan returns a playable mix across rarities. + const insertSet = db.prepare(` + INSERT INTO sets (code, set_number, set_name, boss_ghost_id, enabled) + VALUES (@code, @setNumber, @setName, @bossId, 1) + `); + const linkGhost = db.prepare( + 'INSERT OR IGNORE INTO set_ghosts (set_id, ghost_id) VALUES (?, ?)' + ); + + for (const s of sets) { + const bossId = s.boss ? idByName[s.boss] : null; + const bossGhost = ghosts.find((g) => g.name === s.boss); + const code = `NN-${s.setNumber}`; + const info = insertSet.run({ + code, setNumber: s.setNumber, setName: s.setName, bossId, + }); + const setId = info.lastInsertRowid; + + // roster: boss + up to 6 non-boss ghosts whose type matches the boss type + const roster = new Set(); + if (bossId) roster.add(bossId); + const pool = ghosts.filter( + (g) => !g.isBoss && (!bossGhost || g.type === bossGhost.type) + ); + // spread across rarities 1..4 + for (let r = 1; r <= 4; r++) { + const pick = pool.filter((g) => g.rarity === r).slice(0, 2); + for (const g of pick) roster.add(idByName[g.name]); + } + for (const gid of roster) linkGhost.run(setId, gid); + } + } + + // Always ensure an admin user exists (bootstrap). + const adminUser = process.env.ADMIN_USER || 'admin'; + const adminPass = process.env.ADMIN_PASS || 'changeme'; + const existing = db.prepare("SELECT COUNT(*) AS n FROM users WHERE role='admin'").get().n; + if (existing === 0) { + const hash = bcrypt.hashSync(adminPass, 10); + db.prepare( + 'INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)' + ).run(adminUser, hash, 'admin'); + console.log(`Created admin user "${adminUser}". CHANGE THE PASSWORD after first login.`); + } +}); + +seed(); + +const stats = { + ghosts: db.prepare('SELECT COUNT(*) AS n FROM ghosts').get().n, + abilities: db.prepare('SELECT COUNT(*) AS n FROM abilities').get().n, + sets: db.prepare('SELECT COUNT(*) AS n FROM sets').get().n, + rosterLinks: db.prepare('SELECT COUNT(*) AS n FROM set_ghosts').get().n, +}; +console.log('Seed complete:', stats);