Files
i-want-to-heal/scripts/init-db.mjs
T
2026-06-17 20:04:36 -04:00

263 lines
11 KiB
JavaScript

import { mkdirSync } from 'node:fs'
import { readFile } from 'node:fs/promises'
import { DatabaseSync } from 'node:sqlite'
mkdirSync('data', { recursive: true })
const database = new DatabaseSync('data/game.db')
const schema = await readFile(new URL('../db/schema.sql', import.meta.url), 'utf8')
const seed = await readFile(new URL('../db/seed.sql', import.meta.url), 'utf8')
database.exec(schema)
database.exec(`
CREATE TABLE IF NOT EXISTS crafting_recipes (
id INTEGER PRIMARY KEY,
item_id INTEGER NOT NULL UNIQUE REFERENCES items(id) ON DELETE CASCADE,
difficulty_id INTEGER REFERENCES difficulties(id),
source_dungeon_id INTEGER REFERENCES dungeons(id) ON DELETE CASCADE,
source_encounter_id INTEGER REFERENCES encounters(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS crafting_recipe_components (
recipe_id INTEGER NOT NULL REFERENCES crafting_recipes(id) ON DELETE CASCADE,
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
quantity INTEGER NOT NULL CHECK (quantity > 0),
PRIMARY KEY (recipe_id, item_id)
);
CREATE TABLE IF NOT EXISTS item_sets (
id INTEGER PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
description TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS item_set_items (
set_id INTEGER NOT NULL REFERENCES item_sets(id) ON DELETE CASCADE,
item_id INTEGER NOT NULL UNIQUE REFERENCES items(id) ON DELETE CASCADE,
PRIMARY KEY (set_id, item_id)
);
CREATE TABLE IF NOT EXISTS item_set_bonuses (
id INTEGER PRIMARY KEY,
set_id INTEGER NOT NULL REFERENCES item_sets(id) ON DELETE CASCADE,
required_pieces INTEGER NOT NULL CHECK (required_pieces > 0),
effect_type TEXT NOT NULL,
description TEXT NOT NULL,
UNIQUE (set_id, required_pieces)
);
CREATE TABLE IF NOT EXISTS encounter_loot_roll_items (
roll_id INTEGER NOT NULL REFERENCES encounter_loot_rolls(id) ON DELETE CASCADE,
item_id INTEGER NOT NULL REFERENCES items(id),
quantity INTEGER NOT NULL DEFAULT 1 CHECK (quantity > 0),
was_duplicate INTEGER NOT NULL DEFAULT 0 CHECK (was_duplicate IN (0, 1)),
quantity_after INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (roll_id, item_id)
);
`)
function addColumnIfMissing(table, column, definition) {
const columns = database.prepare(`PRAGMA table_info(${table})`).all()
if (!columns.some((candidate) => candidate.name === column)) {
database.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`)
}
}
function migrateCharacterAccountConstraint() {
const tableSql = database.prepare(`
SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'characters'
`).get()?.sql ?? ''
const hasLegacyAccountUnique = /account_id\s+INTEGER\s+UNIQUE/i.test(tableSql)
if (!hasLegacyAccountUnique) return
const columns = database.prepare('PRAGMA table_info(characters)').all()
const hasCompletedDungeonParts = columns.some(
(candidate) => candidate.name === 'completed_dungeon_parts',
)
database.exec('PRAGMA foreign_keys = OFF')
database.exec('BEGIN')
try {
database.exec(`
CREATE TABLE characters_migrated (
id INTEGER PRIMARY KEY,
account_id INTEGER REFERENCES accounts(id) ON DELETE CASCADE,
class_id INTEGER NOT NULL REFERENCES classes(id),
name TEXT NOT NULL,
level INTEGER NOT NULL DEFAULT 1,
experience INTEGER NOT NULL DEFAULT 0,
talent_points INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (account_id, class_id)
)
`)
database.exec(`
INSERT INTO characters_migrated
(id, account_id, class_id, name, level, experience, talent_points, created_at)
SELECT
id, account_id, class_id, name, level, experience, talent_points, created_at
FROM characters
`)
if (hasCompletedDungeonParts) {
database.exec(`
UPDATE accounts
SET completed_dungeon_parts = MAX(
completed_dungeon_parts,
COALESCE((
SELECT MAX(completed_dungeon_parts)
FROM characters
WHERE characters.account_id = accounts.id
), 0)
)
`)
}
database.exec('DROP TABLE characters')
database.exec('ALTER TABLE characters_migrated RENAME TO characters')
database.exec('COMMIT')
} catch (error) {
database.exec('ROLLBACK')
throw error
} finally {
database.exec('PRAGMA foreign_keys = ON')
}
}
function migrateItemSlotConstraint() {
const tableSql = database.prepare(`
SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'items'
`).get()?.sql ?? ''
if (tableSql.includes("'pants'") && tableSql.includes("'necklace'")) return
database.exec('PRAGMA foreign_keys = OFF')
database.exec('BEGIN')
try {
database.exec(`
CREATE TABLE items_migrated (
id INTEGER PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
slot TEXT NOT NULL CHECK (slot IN ('weapon', 'helmet', 'chest', 'gloves', 'boots', 'pants', 'ring', 'necklace', 'trinket', 'component')),
rarity TEXT NOT NULL,
item_level INTEGER NOT NULL,
healing_power INTEGER NOT NULL DEFAULT 0,
max_resource_bonus INTEGER NOT NULL DEFAULT 0,
glyph TEXT NOT NULL DEFAULT '?',
image_url TEXT NOT NULL DEFAULT '/equipment-placeholder.svg',
description TEXT NOT NULL
)
`)
database.exec(`
INSERT INTO items_migrated
(id, slug, name, slot, rarity, item_level, healing_power, max_resource_bonus, glyph, image_url, description)
SELECT id, slug, name, slot, rarity, item_level, healing_power, max_resource_bonus, glyph, image_url, description
FROM items
`)
database.exec('DROP TABLE items')
database.exec('ALTER TABLE items_migrated RENAME TO items')
database.exec('COMMIT')
} catch (error) {
database.exec('ROLLBACK')
throw error
} finally {
database.exec('PRAGMA foreign_keys = ON')
}
}
function migrateEncounterLootPrimaryKey() {
const tableSql = database.prepare(`
SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'encounter_loot'
`).get()?.sql ?? ''
if (/PRIMARY KEY\s*\(\s*encounter_id\s*,\s*difficulty_id\s*,\s*item_id\s*\)/i.test(tableSql)) return
database.exec('PRAGMA foreign_keys = OFF')
database.exec('BEGIN')
try {
database.exec(`
CREATE TABLE encounter_loot_migrated (
encounter_id INTEGER NOT NULL REFERENCES encounters(id) ON DELETE CASCADE,
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
difficulty_id INTEGER REFERENCES difficulties(id),
drop_weight INTEGER NOT NULL DEFAULT 100,
drop_chance REAL NOT NULL DEFAULT 0.65 CHECK (drop_chance BETWEEN 0 AND 1),
PRIMARY KEY (encounter_id, difficulty_id, item_id)
)
`)
database.exec(`
INSERT OR IGNORE INTO encounter_loot_migrated
(encounter_id, item_id, difficulty_id, drop_weight, drop_chance)
SELECT encounter_id, item_id, difficulty_id, drop_weight, drop_chance
FROM encounter_loot
`)
database.exec('DROP TABLE encounter_loot')
database.exec('ALTER TABLE encounter_loot_migrated RENAME TO encounter_loot')
database.exec('COMMIT')
} catch (error) {
database.exec('ROLLBACK')
throw error
} finally {
database.exec('PRAGMA foreign_keys = ON')
}
}
addColumnIfMissing('dungeons', 'content_type', "TEXT NOT NULL DEFAULT 'dungeon'")
addColumnIfMissing('dungeons', 'party_size', 'INTEGER NOT NULL DEFAULT 5')
addColumnIfMissing('dungeons', 'completion_item_level', 'INTEGER')
addColumnIfMissing('dungeons', 'experience_reward', 'INTEGER NOT NULL DEFAULT 100')
addColumnIfMissing('classes', 'theme_color', "TEXT NOT NULL DEFAULT '#e5b95f'")
addColumnIfMissing('spells', 'unlock_level', 'INTEGER NOT NULL DEFAULT 1')
addColumnIfMissing('spells', 'glyph', "TEXT NOT NULL DEFAULT '+'")
addColumnIfMissing('encounter_loot', 'difficulty_id', 'INTEGER REFERENCES difficulties(id)')
addColumnIfMissing('characters', 'talent_points', 'INTEGER NOT NULL DEFAULT 1')
addColumnIfMissing('characters', 'account_id', 'INTEGER REFERENCES accounts(id)')
addColumnIfMissing('characters', 'completed_dungeon_parts', 'INTEGER NOT NULL DEFAULT 0')
addColumnIfMissing('talents', 'branch', 'INTEGER NOT NULL DEFAULT 1')
addColumnIfMissing('talents', 'prerequisite_talent_id', 'INTEGER REFERENCES talents(id)')
addColumnIfMissing('talents', 'prerequisite_rank', 'INTEGER NOT NULL DEFAULT 0')
addColumnIfMissing('talents', 'effect_type', "TEXT NOT NULL DEFAULT 'placeholder'")
addColumnIfMissing('talents', 'effect_value_per_rank', 'REAL NOT NULL DEFAULT 0')
addColumnIfMissing('talents', 'glyph', "TEXT NOT NULL DEFAULT '+'")
addColumnIfMissing('items', 'glyph', "TEXT NOT NULL DEFAULT '?'")
addColumnIfMissing('items', 'image_url', "TEXT NOT NULL DEFAULT '/equipment-placeholder.svg'")
addColumnIfMissing('difficulties', 'unlock_level', 'INTEGER NOT NULL DEFAULT 1')
addColumnIfMissing('difficulties', 'health_multiplier', 'REAL NOT NULL DEFAULT 1')
addColumnIfMissing('difficulties', 'damage_multiplier', 'REAL NOT NULL DEFAULT 1')
addColumnIfMissing('difficulties', 'experience_multiplier', 'REAL NOT NULL DEFAULT 1')
addColumnIfMissing('dungeon_runs', 'difficulty_id', 'INTEGER REFERENCES difficulties(id)')
addColumnIfMissing('dungeon_runs', 'character_name', "TEXT NOT NULL DEFAULT ''")
addColumnIfMissing('dungeon_runs', 'class_name', "TEXT NOT NULL DEFAULT ''")
addColumnIfMissing('dungeon_runs', 'character_level', 'INTEGER NOT NULL DEFAULT 1')
addColumnIfMissing('dungeon_runs', 'average_item_level', 'REAL NOT NULL DEFAULT 1')
addColumnIfMissing('dungeon_runs', 'resource_spent', 'INTEGER NOT NULL DEFAULT 0')
addColumnIfMissing('dungeon_runs', 'duration_seconds', 'INTEGER NOT NULL DEFAULT 0')
addColumnIfMissing('dungeon_runs', 'leaderboard_eligible', 'INTEGER NOT NULL DEFAULT 0')
addColumnIfMissing('dungeon_runs', 'start_part', 'INTEGER NOT NULL DEFAULT 1')
addColumnIfMissing('dungeon_runs', 'completed_parts', 'INTEGER NOT NULL DEFAULT 1')
addColumnIfMissing('encounter_loot', 'drop_chance', 'REAL NOT NULL DEFAULT 0.65')
addColumnIfMissing('encounters', 'image_url', "TEXT NOT NULL DEFAULT '/boss-placeholder.svg'")
addColumnIfMissing('accounts', 'completed_dungeon_parts', 'INTEGER NOT NULL DEFAULT 0')
addColumnIfMissing('accounts', 'completed_raid_phases', 'INTEGER NOT NULL DEFAULT 0')
addColumnIfMissing('sessions', 'active_character_id', 'INTEGER REFERENCES characters(id)')
migrateCharacterAccountConstraint()
migrateItemSlotConstraint()
migrateEncounterLootPrimaryKey()
addColumnIfMissing('items', 'image_url', "TEXT NOT NULL DEFAULT '/equipment-placeholder.svg'")
database.exec(seed)
const counts = database
.prepare(`
SELECT
(SELECT COUNT(*) FROM classes) AS classes,
(SELECT COUNT(*) FROM encounters) AS encounters,
(SELECT COUNT(*) FROM items) AS items
`)
.get()
database.close()
console.log(`Database ready: ${counts.classes} class, ${counts.encounters} encounters, ${counts.items} items.`)