Initial I Want to Heal app
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
|
||||
const sourcePath = resolve('data/game.db')
|
||||
const backupDirectory = resolve('backups')
|
||||
const timestamp = new Date().toISOString().replaceAll(':', '-').replaceAll('.', '-')
|
||||
const backupPath = resolve(backupDirectory, `game-${timestamp}.db`)
|
||||
|
||||
mkdirSync(backupDirectory, { recursive: true })
|
||||
|
||||
const database = new DatabaseSync(sourcePath)
|
||||
|
||||
try {
|
||||
const escapedPath = backupPath.replaceAll("'", "''")
|
||||
database.exec(`VACUUM INTO '${escapedPath}'`)
|
||||
console.log(`SQLite backup created: ${backupPath}`)
|
||||
} finally {
|
||||
database.close()
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { getProfile } from '../server/game-api.mjs'
|
||||
|
||||
const database = new DatabaseSync(':memory:')
|
||||
|
||||
try {
|
||||
database.exec(readFileSync('db/schema.sql', 'utf8'))
|
||||
database.exec(readFileSync('db/seed.sql', 'utf8'))
|
||||
const profile = getProfile(database, 1)
|
||||
writeFileSync(
|
||||
'src/offline-starter-profile.json',
|
||||
`${JSON.stringify(profile, null, 2)}\n`,
|
||||
)
|
||||
console.log('Offline starter profile exported from SQLite.')
|
||||
} finally {
|
||||
database.close()
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { readdirSync, writeFileSync } from 'node:fs'
|
||||
import { relative, resolve } from 'node:path'
|
||||
|
||||
const distDirectory = resolve('dist')
|
||||
|
||||
function listFiles(directory) {
|
||||
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const path = resolve(directory, entry.name)
|
||||
return entry.isDirectory() ? listFiles(path) : [path]
|
||||
})
|
||||
}
|
||||
|
||||
const assets = listFiles(distDirectory)
|
||||
.map((path) => `/${relative(distDirectory, path).replaceAll('\\', '/')}`)
|
||||
.filter((path) => path !== '/service-worker.js')
|
||||
.sort()
|
||||
const cacheName = `chronicle-${Date.now()}`
|
||||
const source = `const CACHE_NAME = ${JSON.stringify(cacheName)}
|
||||
const APP_ASSETS = ${JSON.stringify(assets, null, 2)}
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME)
|
||||
.then((cache) => cache.addAll(APP_ASSETS))
|
||||
.then(() => self.skipWaiting()),
|
||||
)
|
||||
})
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys()
|
||||
.then((keys) => Promise.all(
|
||||
keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)),
|
||||
))
|
||||
.then(() => self.clients.claim()),
|
||||
)
|
||||
})
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const requestUrl = new URL(event.request.url)
|
||||
if (
|
||||
event.request.method !== 'GET'
|
||||
|| requestUrl.origin !== self.location.origin
|
||||
|| requestUrl.pathname.startsWith('/api/')
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
event.respondWith(
|
||||
caches.match(event.request).then((cached) => {
|
||||
if (cached) return cached
|
||||
return fetch(event.request).catch(() => (
|
||||
event.request.mode === 'navigate'
|
||||
? caches.match('/index.html')
|
||||
: Response.error()
|
||||
))
|
||||
}),
|
||||
)
|
||||
})
|
||||
`
|
||||
|
||||
writeFileSync(resolve(distDirectory, 'service-worker.js'), source)
|
||||
console.log(`Offline app shell generated with ${assets.length} files.`)
|
||||
@@ -0,0 +1,262 @@
|
||||
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.`)
|
||||
@@ -0,0 +1,78 @@
|
||||
import { isIP } from 'node:net'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
|
||||
const [command, rawIp, rawLimit, ...noteParts] = process.argv.slice(2)
|
||||
const database = new DatabaseSync('data/game.db')
|
||||
|
||||
function normalizeIp(value) {
|
||||
const address = String(value ?? '').trim()
|
||||
if (address.startsWith('::ffff:') && isIP(address.slice(7)) === 4) {
|
||||
return address.slice(7)
|
||||
}
|
||||
return address
|
||||
}
|
||||
|
||||
function requireIp(value) {
|
||||
const address = normalizeIp(value)
|
||||
if (!isIP(address)) {
|
||||
throw new Error('Provide a valid IPv4 or IPv6 address.')
|
||||
}
|
||||
return address
|
||||
}
|
||||
|
||||
try {
|
||||
if (command === 'set') {
|
||||
const ip = requireIp(rawIp)
|
||||
const maxAccounts = Number(rawLimit)
|
||||
if (!Number.isInteger(maxAccounts) || maxAccounts < 2 || maxAccounts > 100) {
|
||||
throw new Error('The allowed account count must be an integer from 2 to 100.')
|
||||
}
|
||||
const note = noteParts.join(' ').slice(0, 200)
|
||||
database.prepare(`
|
||||
INSERT INTO account_ip_allowances
|
||||
(ip_address, max_accounts, note, updated_at)
|
||||
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(ip_address) DO UPDATE SET
|
||||
max_accounts = excluded.max_accounts,
|
||||
note = excluded.note,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
`).run(ip, maxAccounts, note)
|
||||
console.log(`${ip} may create up to ${maxAccounts} accounts${note ? ` (${note})` : ''}.`)
|
||||
} else if (command === 'remove') {
|
||||
const ip = requireIp(rawIp)
|
||||
const result = database.prepare(`
|
||||
DELETE FROM account_ip_allowances WHERE ip_address = ?
|
||||
`).run(ip)
|
||||
console.log(
|
||||
result.changes
|
||||
? `${ip} returned to the default one-account limit.`
|
||||
: `${ip} had no custom allowance.`,
|
||||
)
|
||||
} else if (command === 'list') {
|
||||
const rows = database.prepare(`
|
||||
SELECT
|
||||
account_ip_allowances.ip_address AS ip,
|
||||
account_ip_allowances.max_accounts AS maxAccounts,
|
||||
account_ip_allowances.note,
|
||||
account_ip_allowances.updated_at AS updatedAt,
|
||||
COUNT(accounts.id) AS existingAccounts
|
||||
FROM account_ip_allowances
|
||||
LEFT JOIN accounts
|
||||
ON accounts.created_ip = account_ip_allowances.ip_address
|
||||
GROUP BY account_ip_allowances.ip_address
|
||||
ORDER BY account_ip_allowances.updated_at DESC
|
||||
`).all()
|
||||
console.table(rows)
|
||||
} else {
|
||||
console.log('Usage:')
|
||||
console.log(' npm run accounts:ip -- set <ip> <max-accounts> [note]')
|
||||
console.log(' npm run accounts:ip -- remove <ip>')
|
||||
console.log(' npm run accounts:ip -- list')
|
||||
process.exitCode = command ? 1 : 0
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : error)
|
||||
process.exitCode = 1
|
||||
} finally {
|
||||
database.close()
|
||||
}
|
||||
Reference in New Issue
Block a user