Initial I Want to Heal app

This commit is contained in:
Warren H
2026-06-17 20:04:36 -04:00
parent 3880db1b58
commit 3c90998a61
109 changed files with 32775 additions and 0 deletions
+20
View File
@@ -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()
}
+18
View File
@@ -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()
}
+63
View File
@@ -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.`)
+262
View File
@@ -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.`)
+78
View File
@@ -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()
}