263 lines
11 KiB
JavaScript
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.`)
|