import { createReadStream, existsSync, mkdirSync, statSync, writeFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { createHash, randomBytes, scryptSync, timingSafeEqual, } from 'node:crypto' import { isIP } from 'node:net' import { extname, resolve, sep } from 'node:path' import { DatabaseSync } from 'node:sqlite' const databasePath = fileURLToPath(new URL('../data/game.db', import.meta.url)) const bossImageDirectory = fileURLToPath(new URL('../data/uploads/bosses/', import.meta.url)) const itemImageDirectory = fileURLToPath(new URL('../data/uploads/items/', import.meta.url)) const bossImageContentTypes = { '.gif': 'image/gif', '.jpeg': 'image/jpeg', '.jpg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp', } const equipmentSlots = ['weapon', 'helmet', 'chest', 'gloves', 'boots', 'pants', 'ring', 'necklace', 'trinket'] const componentSlot = 'component' const directCraftItemLevels = new Set([1, 10, 20, 25]) const sessionCookieName = 'chronicle_session' const sessionLifetimeSeconds = 60 * 60 * 24 * 30 const rateLimitBuckets = new Map() function sendJson(response, status, body, headers = {}) { response.statusCode = status response.setHeader('Content-Type', 'application/json') for (const [name, value] of Object.entries(headers)) response.setHeader(name, value) response.end(JSON.stringify(body)) } function configuredCorsOrigins() { return String(process.env.CORS_ORIGINS ?? process.env.AUTH_CORS_ORIGINS ?? '') .split(',') .map((origin) => origin.trim()) .filter(Boolean) } function setCorsHeaders(response, request) { const origin = request.headers.origin if (typeof origin !== 'string') return const allowedOrigins = configuredCorsOrigins() if (!allowedOrigins.includes('*') && !allowedOrigins.includes(origin)) return response.setHeader('Access-Control-Allow-Origin', origin) response.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,OPTIONS') response.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization') response.setHeader('Access-Control-Max-Age', '86400') response.setHeader('Vary', 'Origin') } function sendCorsPreflight(request, response) { setCorsHeaders(response, request) response.statusCode = 204 response.end() } async function readJson(request, maxSize = 16 * 1024) { const chunks = [] let size = 0 for await (const chunk of request) { size += chunk.length if (size > maxSize) { const error = new Error('Request body is too large.') error.status = 413 throw error } chunks.push(chunk) } return JSON.parse(Buffer.concat(chunks).toString('utf8')) } function sendBossImage(request, response) { const pathname = decodeURIComponent(new URL(request.url, 'http://localhost').pathname) const filename = pathname.replace('/api/boss-images/', '') if (!/^[A-Za-z0-9._-]+$/.test(filename)) { sendJson(response, 404, { error: 'Image not found.' }) return } const imagePath = resolve(bossImageDirectory, filename) const insideImageDirectory = imagePath.startsWith(resolve(bossImageDirectory) + sep) const extension = extname(imagePath).toLowerCase() if ( !insideImageDirectory || !bossImageContentTypes[extension] || !existsSync(imagePath) || !statSync(imagePath).isFile() ) { sendJson(response, 404, { error: 'Image not found.' }) return } response.statusCode = 200 response.setHeader('Content-Type', bossImageContentTypes[extension]) response.setHeader('Cache-Control', 'public, max-age=31536000, immutable') response.setHeader('X-Content-Type-Options', 'nosniff') createReadStream(imagePath).pipe(response) } function sendItemImage(request, response) { const pathname = decodeURIComponent(new URL(request.url, 'http://localhost').pathname) const filename = pathname.replace('/api/item-images/', '') if (!/^[A-Za-z0-9._-]+$/.test(filename)) { sendJson(response, 404, { error: 'Image not found.' }) return } const imagePath = resolve(itemImageDirectory, filename) const insideImageDirectory = imagePath.startsWith(resolve(itemImageDirectory) + sep) const extension = extname(imagePath).toLowerCase() if ( !insideImageDirectory || !bossImageContentTypes[extension] || !existsSync(imagePath) || !statSync(imagePath).isFile() ) { sendJson(response, 404, { error: 'Image not found.' }) return } response.statusCode = 200 response.setHeader('Content-Type', bossImageContentTypes[extension]) response.setHeader('Cache-Control', 'public, max-age=31536000, immutable') response.setHeader('X-Content-Type-Options', 'nosniff') createReadStream(imagePath).pipe(response) } function saveBossImage(database, encounterId, payload) { const encounter = database.prepare(` SELECT id, slug, encounter_type AS encounterType FROM encounters WHERE id = ? `).get(encounterId) if (!encounter || encounter.encounterType !== 'boss') { throw new Error('Boss encounter not found.') } const dataUrl = String(payload.imageData ?? '') const match = dataUrl.match(/^data:(image\/(?:png|jpeg|webp|gif));base64,([A-Za-z0-9+/=]+)$/) if (!match) { throw new Error('Upload a PNG, JPG, WebP, or GIF image.') } const extensionByType = { 'image/gif': 'gif', 'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp', } const bytes = Buffer.from(match[2], 'base64') if (bytes.length === 0 || bytes.length > 4 * 1024 * 1024) { throw new Error('Boss image must be 1 byte to 4 MB.') } mkdirSync(bossImageDirectory, { recursive: true }) const filename = `${encounter.slug}-${Date.now()}-${randomBytes(4).toString('hex')}.${extensionByType[match[1]]}` writeFileSync(resolve(bossImageDirectory, filename), bytes, { mode: 0o644 }) const imageUrl = `/api/boss-images/${filename}` database.prepare(` UPDATE encounters SET image_url = ? WHERE id = ? `).run(imageUrl, encounterId) return imageUrl } function saveItemImage(database, itemId, payload) { const item = database.prepare(` SELECT id, slug, slot FROM items WHERE id = ? `).get(itemId) if (!item || item.slot === componentSlot) { throw new Error('Equipment item not found.') } const dataUrl = String(payload.imageData ?? '') const match = dataUrl.match(/^data:(image\/(?:png|jpeg|webp|gif));base64,([A-Za-z0-9+/=]+)$/) if (!match) { throw new Error('Upload a PNG, JPG, WebP, or GIF image.') } const extensionByType = { 'image/gif': 'gif', 'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp', } const bytes = Buffer.from(match[2], 'base64') if (bytes.length === 0 || bytes.length > 4 * 1024 * 1024) { throw new Error('Equipment image must be 1 byte to 4 MB.') } mkdirSync(itemImageDirectory, { recursive: true }) const filename = `${item.slug}-${Date.now()}-${randomBytes(4).toString('hex')}.${extensionByType[match[1]]}` writeFileSync(resolve(itemImageDirectory, filename), bytes, { mode: 0o644 }) const imageUrl = `/api/item-images/${filename}` database.prepare(` UPDATE items SET image_url = ? WHERE id = ? `).run(imageUrl, itemId) return imageUrl } function normalizeIp(value) { const address = String(value ?? '').trim() if (address.startsWith('::ffff:') && isIP(address.slice(7)) === 4) { return address.slice(7) } return address } function requestIp(request) { if (process.env.TRUST_PROXY === '1') { const forwarded = request.headers['x-forwarded-for'] if (typeof forwarded === 'string') { return normalizeIp(forwarded.split(',')[0]) } } return normalizeIp(request.socket.remoteAddress ?? 'unknown') } function consumeRateLimit(key, limit, windowMs) { const now = Date.now() const bucket = rateLimitBuckets.get(key) if (!bucket || now >= bucket.resetAt) { rateLimitBuckets.set(key, { count: 1, resetAt: now + windowMs }) return } bucket.count += 1 if (bucket.count > limit) { const retryAfter = Math.max(1, Math.ceil((bucket.resetAt - now) / 1000)) const error = new Error(`Too many requests. Try again in ${retryAfter} seconds.`) error.status = 429 error.retryAfter = retryAfter throw error } } function catchUpExperienceReward(database, accountId, characterId, baseReward, currentExperience, currentLevel) { const targetLevel = database.prepare(` SELECT COALESCE(MAX(level), 0) AS level FROM characters WHERE account_id = ? AND id != ? `).get(accountId, characterId).level if (targetLevel <= currentLevel) return baseReward const targetExperience = database.prepare(` SELECT experience_required AS experienceRequired FROM level_progression WHERE level = ? `).get(targetLevel)?.experienceRequired ?? currentExperience const gap = Math.max(0, targetExperience - currentExperience) if (gap <= 0) return baseReward const doubledBase = Math.min(baseReward, Math.ceil(gap / 2)) return doubledBase * 2 + (baseReward - doubledBase) } function normalizeUsername(value) { const username = String(value ?? '').trim() if (!/^[A-Za-z0-9_]{3,20}$/.test(username)) { throw new Error('Username must be 3-20 letters, numbers, or underscores.') } return username } function normalizeCharacterName(value, fallback) { const name = String(value ?? fallback).trim() if (!/^[A-Za-z][A-Za-z0-9 '-]{1,19}$/.test(name)) { throw new Error('Character name must be 2-20 characters and start with a letter.') } return name } function validatePassword(value) { const password = String(value ?? '') if (password.length < 10 || password.length > 128) { throw new Error('Password must be 10-128 characters.') } return password } function passwordDigest(password, salt) { return scryptSync(password, salt, 64).toString('hex') } function verifyPassword(password, account) { const actual = Buffer.from(passwordDigest(password, account.passwordSalt), 'hex') const expected = Buffer.from(account.passwordHash, 'hex') return actual.length === expected.length && timingSafeEqual(actual, expected) } function tokenHash(token) { return createHash('sha256').update(token).digest('hex') } function parseCookies(request) { return Object.fromEntries( String(request.headers.cookie ?? '') .split(';') .map((part) => part.trim()) .filter(Boolean) .map((part) => { const separator = part.indexOf('=') return separator < 0 ? [part, ''] : [part.slice(0, separator), decodeURIComponent(part.slice(separator + 1))] }), ) } function bearerToken(request) { const authorization = request.headers.authorization if (typeof authorization !== 'string') return '' const match = authorization.match(/^Bearer\s+(.+)$/i) return match ? match[1].trim() : '' } function requestSessionToken(request) { return bearerToken(request) || parseCookies(request)[sessionCookieName] || '' } function sessionCookie(token, request, maxAge = sessionLifetimeSeconds) { const secure = request.headers['x-forwarded-proto'] === 'https' || Boolean(request.socket.encrypted) || process.env.COOKIE_SECURE === '1' return [ `${sessionCookieName}=${encodeURIComponent(token)}`, 'HttpOnly', 'Path=/', 'SameSite=Lax', `Max-Age=${maxAge}`, secure ? 'Secure' : '', ].filter(Boolean).join('; ') } function createSession(database, accountId, ip, activeCharacterId) { const token = randomBytes(32).toString('base64url') database.prepare(` INSERT INTO sessions (account_id, token_hash, active_character_id, expires_at, created_ip) VALUES (?, ?, ?, datetime('now', '+30 days'), ?) `).run(accountId, tokenHash(token), activeCharacterId ?? null, ip) return token } function currentSession(database, request) { const token = requestSessionToken(request) if (!token) return null return database.prepare(` SELECT sessions.id AS sessionId, accounts.id AS accountId, accounts.username, characters.id AS characterId, characters.class_id AS classId FROM sessions JOIN accounts ON accounts.id = sessions.account_id JOIN characters ON characters.id = sessions.active_character_id WHERE sessions.token_hash = ? AND sessions.expires_at > CURRENT_TIMESTAMP `).get(tokenHash(token)) ?? null } function requireSession(database, request) { const session = currentSession(database, request) if (!session) { const error = new Error('Sign in to continue.') error.status = 401 throw error } return session } function initializeCharacter(database, accountId, characterName, classId) { const result = database.prepare(` INSERT INTO characters (account_id, class_id, name, level, experience, talent_points) VALUES (?, ?, ?, 1, 0, 1) `).run(accountId, classId, characterName) const characterId = Number(result.lastInsertRowid) const insertSlot = database.prepare(` INSERT INTO character_ability_slots (character_id, slot_number, spell_id) VALUES (?, ?, ?) `) const starterSpells = database.prepare(` SELECT id FROM spells WHERE class_id = ? AND unlock_level = 1 ORDER BY id `).all(classId).map((s) => s.id) ;[...starterSpells, null].slice(0, 6).forEach((spellId, index) => { insertSlot.run(characterId, index + 1, spellId) }) return characterId } function registerAccount(database, request, payload) { const ip = requestIp(request) consumeRateLimit(`register:${ip}`, 3, 60 * 60 * 1000) const username = normalizeUsername(payload.username) const password = validatePassword(payload.password) const characterName = normalizeCharacterName(payload.characterName, username) const existing = database.prepare(` SELECT id FROM accounts WHERE username = ? COLLATE NOCASE `).get(username) if (existing) throw new Error('That username is already taken.') const accountLimit = database.prepare(` SELECT max_accounts AS maxAccounts FROM account_ip_allowances WHERE ip_address = ? `).get(ip)?.maxAccounts ?? 1 const existingIpAccounts = database.prepare(` SELECT COUNT(*) AS count FROM accounts WHERE created_ip = ? `).get(ip).count if (existingIpAccounts >= accountLimit) { throw new Error( 'This IP address has reached its account limit. Contact the server administrator for an exception.', ) } const salt = randomBytes(16).toString('hex') database.exec('BEGIN') try { const accountResult = database.prepare(` INSERT INTO accounts (username, password_hash, password_salt, created_ip) VALUES (?, ?, ?, ?) `).run(username, passwordDigest(password, salt), salt, ip) const accountId = Number(accountResult.lastInsertRowid) const activeCharacterId = initializeCharacter(database, accountId, characterName, 1) initializeCharacter(database, accountId, characterName, 2) initializeCharacter(database, accountId, characterName, 3) const token = createSession(database, accountId, ip, activeCharacterId) database.exec('COMMIT') return { token, account: { id: accountId, username }, profile: getProfile(database, activeCharacterId, accountId), } } catch (error) { database.exec('ROLLBACK') throw error } } function loginAccount(database, request, payload) { const ip = requestIp(request) consumeRateLimit(`login:${ip}`, 8, 15 * 60 * 1000) const username = normalizeUsername(payload.username) const password = String(payload.password ?? '') const account = database.prepare(` SELECT accounts.id, accounts.username, accounts.password_hash AS passwordHash, accounts.password_salt AS passwordSalt FROM accounts WHERE accounts.username = ? COLLATE NOCASE `).get(username) if (!account || !verifyPassword(password, account)) { throw new Error('Invalid username or password.') } const activeCharacterId = database.prepare(` SELECT id FROM characters WHERE account_id = ? ORDER BY class_id LIMIT 1 `).get(account.id)?.id if (!activeCharacterId) throw new Error('No character found for this account.') const token = createSession(database, account.id, ip, activeCharacterId) return { token, account: { id: account.id, username: account.username }, profile: getProfile(database, activeCharacterId, account.id), } } export function getProfile(database, characterId, accountId) { if (!accountId) { const row = database.prepare('SELECT account_id FROM characters WHERE id = ?').get(characterId) accountId = row?.account_id ?? null } const character = database.prepare(` SELECT characters.id, characters.name, characters.level, characters.experience, characters.talent_points AS talentPoints, classes.id AS classId, classes.slug AS classSlug, classes.name AS className, classes.resource_name AS resourceName, classes.max_resource AS maxResource, classes.theme_color AS themeColor, classes.description AS classDescription FROM characters JOIN classes ON classes.id = characters.class_id WHERE characters.id = ? `).get(characterId) const classes = database.prepare(` SELECT id, slug, name, resource_name AS resourceName, max_resource AS maxResource, theme_color AS themeColor, description FROM classes ORDER BY id `).all() const spells = database.prepare(` SELECT id, class_id AS classId, slug, name, spell_type AS spellType, resource_cost AS cost, cooldown_seconds AS cooldown, power, unlock_level AS unlockLevel, glyph, description FROM spells ORDER BY class_id, unlock_level, id `).all() const slots = database.prepare(` SELECT slot_number AS slotNumber, spell_id AS spellId FROM character_ability_slots WHERE character_id = ? ORDER BY slot_number `).all(characterId) const talents = database.prepare(` SELECT talents.id, talents.class_id AS classId, talents.slug, talents.name, talents.max_rank AS maxRank, talents.tier, talents.branch, talents.prerequisite_talent_id AS prerequisiteTalentId, talents.prerequisite_rank AS prerequisiteRank, prerequisite.name AS prerequisiteName, talents.effect_type AS effectType, talents.effect_value_per_rank AS effectValuePerRank, talents.glyph, talents.description, COALESCE(character_talents.rank, 0) AS rank FROM talents LEFT JOIN talents AS prerequisite ON prerequisite.id = talents.prerequisite_talent_id LEFT JOIN character_talents ON character_talents.talent_id = talents.id AND character_talents.character_id = ? ORDER BY talents.class_id, talents.tier, talents.branch `).all(characterId) const inventory = database.prepare(` SELECT items.id, items.slug, items.name, items.slot, items.rarity, items.item_level AS itemLevel, items.healing_power AS healingPower, items.max_resource_bonus AS maxResourceBonus, items.glyph, items.description, item_sets.id AS setId, item_sets.slug AS setSlug, item_sets.name AS setName, character_inventory.quantity, character_inventory.equipped FROM character_inventory JOIN items ON items.id = character_inventory.item_id LEFT JOIN item_set_items ON item_set_items.item_id = items.id LEFT JOIN item_sets ON item_sets.id = item_set_items.set_id WHERE character_inventory.character_id = ? ORDER BY items.slot, items.item_level DESC, items.id `).all(characterId).map((item) => ({ ...item, equipped: Boolean(item.equipped) })) const equippedItems = inventory.filter((item) => item.equipped) const gearStats = { averageItemLevel: equipmentSlots.length === 0 ? 0 : equippedItems.reduce((total, item) => total + item.itemLevel, 0) / equipmentSlots.length, healingPower: equippedItems.reduce((total, item) => total + item.healingPower, 0), maxResourceBonus: equippedItems.reduce((total, item) => total + item.maxResourceBonus, 0), } const dungeons = database.prepare(` SELECT dungeons.id, dungeons.slug, dungeons.name, dungeons.recommended_level AS recommendedLevel, dungeons.content_type AS contentType, dungeons.party_size AS partySize, dungeons.completion_item_level AS completionItemLevel, dungeons.experience_reward AS experienceReward, dungeons.image_url AS imageUrl, dungeons.description, locations.name AS locationName FROM dungeons JOIN locations ON locations.id = dungeons.location_id ORDER BY dungeons.id `).all() const dungeonDifficulties = database.prepare(` SELECT dungeon_difficulties.dungeon_id AS dungeonId, difficulties.id, difficulties.slug, difficulties.name, difficulties.dropped_item_level AS droppedItemLevel, difficulties.unlock_level AS unlockLevel, difficulties.health_multiplier AS healthMultiplier, difficulties.damage_multiplier AS damageMultiplier, difficulties.experience_multiplier AS experienceMultiplier, difficulties.description FROM dungeon_difficulties JOIN difficulties ON difficulties.id = dungeon_difficulties.difficulty_id ORDER BY dungeon_difficulties.dungeon_id, difficulties.id `).all() const encounters = database.prepare(` SELECT id, dungeon_id AS dungeonId, sequence, slug, name AS enemyName, encounter_type AS encounterType, max_health AS maxHealth, base_damage AS damage, tank_damage AS tankDamage, party_damage AS partyDamage, description, image_url AS imageUrl FROM encounters ORDER BY dungeon_id, sequence `).all() const encounterLoot = database.prepare(` SELECT encounter_loot.encounter_id AS encounterId, encounter_loot.difficulty_id AS difficultyId, encounter_loot.drop_weight AS dropWeight, encounter_loot.drop_chance AS dropChance, items.id, items.slug, items.name, items.slot, items.rarity, items.item_level AS itemLevel, items.healing_power AS healingPower, items.max_resource_bonus AS maxResourceBonus, items.glyph, items.description, item_sets.id AS setId, item_sets.slug AS setSlug, item_sets.name AS setName FROM encounter_loot JOIN items ON items.id = encounter_loot.item_id LEFT JOIN item_set_items ON item_set_items.item_id = items.id LEFT JOIN item_sets ON item_sets.id = item_set_items.set_id ORDER BY encounter_loot.encounter_id, encounter_loot.difficulty_id, items.slot `).all() const completionLoot = database.prepare(` SELECT dungeon_completion_loot.dungeon_id AS dungeonId, items.id, items.slug, items.name, items.slot, items.rarity, items.item_level AS itemLevel, items.healing_power AS healingPower, items.max_resource_bonus AS maxResourceBonus, items.glyph, items.description, item_sets.id AS setId, item_sets.slug AS setSlug, item_sets.name AS setName FROM dungeon_completion_loot JOIN items ON items.id = dungeon_completion_loot.item_id LEFT JOIN item_set_items ON item_set_items.item_id = items.id LEFT JOIN item_sets ON item_sets.id = item_set_items.set_id ORDER BY dungeon_completion_loot.dungeon_id, items.slot `).all() const craftingRecipeRows = database.prepare(` SELECT crafting_recipes.id, crafting_recipes.difficulty_id AS difficultyId, crafting_recipes.source_dungeon_id AS sourceDungeonId, crafting_recipes.source_encounter_id AS sourceEncounterId, output.id AS itemId, output.slug, output.name, output.slot, output.rarity, output.item_level AS itemLevel, output.healing_power AS healingPower, output.max_resource_bonus AS maxResourceBonus, output.glyph, output.description, item_sets.id AS setId, item_sets.slug AS setSlug, item_sets.name AS setName FROM crafting_recipes JOIN items AS output ON output.id = crafting_recipes.item_id LEFT JOIN item_set_items ON item_set_items.item_id = output.id LEFT JOIN item_sets ON item_sets.id = item_set_items.set_id ORDER BY crafting_recipes.source_dungeon_id, crafting_recipes.difficulty_id, output.item_level, output.slot `).all() const craftingComponentRows = database.prepare(` SELECT crafting_recipe_components.recipe_id AS recipeId, crafting_recipe_components.quantity, components.id, components.slug, components.name, components.slot, components.rarity, components.item_level AS itemLevel, components.healing_power AS healingPower, components.max_resource_bonus AS maxResourceBonus, components.glyph, components.description, COALESCE(character_inventory.quantity, 0) AS owned FROM crafting_recipe_components JOIN items AS components ON components.id = crafting_recipe_components.item_id LEFT JOIN character_inventory ON character_inventory.item_id = components.id AND character_inventory.character_id = ? ORDER BY crafting_recipe_components.recipe_id, components.id `).all(characterId) const setBonuses = database.prepare(` SELECT item_sets.id AS setId, item_sets.slug AS setSlug, item_sets.name AS setName, item_set_bonuses.required_pieces AS requiredPieces, item_set_bonuses.effect_type AS effectType, item_set_bonuses.description, COALESCE(equipped_sets.equippedPieces, 0) AS equippedPieces FROM item_set_bonuses JOIN item_sets ON item_sets.id = item_set_bonuses.set_id LEFT JOIN ( SELECT item_set_items.set_id AS setId, COUNT(*) AS equippedPieces FROM item_set_items JOIN character_inventory ON character_inventory.item_id = item_set_items.item_id AND character_inventory.character_id = ? AND character_inventory.equipped = 1 GROUP BY item_set_items.set_id ) AS equipped_sets ON equipped_sets.setId = item_sets.id ORDER BY item_sets.id, item_set_bonuses.required_pieces `).all(characterId).map((bonus) => ({ ...bonus, active: bonus.equippedPieces >= bonus.requiredPieces, })) const gearUpgradePaths = database.prepare(` SELECT from_item_id AS fromItemId, to_item_id AS toItemId FROM gear_upgrade_paths ORDER BY from_item_id `).all() const leaderboardRuns = database.prepare(` SELECT rank, dungeonId, difficultyId, startPart, completedParts, characterName, className, characterLevel, averageItemLevel, resourceSpent, durationSeconds, completedAt FROM ( SELECT ROW_NUMBER() OVER ( PARTITION BY dungeon_id, difficulty_id, start_part, completed_parts ORDER BY resource_spent, average_item_level, duration_seconds, completed_at ) AS rank, dungeon_id AS dungeonId, difficulty_id AS difficultyId, start_part AS startPart, completed_parts AS completedParts, character_name AS characterName, class_name AS className, character_level AS characterLevel, average_item_level AS averageItemLevel, resource_spent AS resourceSpent, duration_seconds AS durationSeconds, completed_at AS completedAt FROM dungeon_runs WHERE result = 'victory' AND leaderboard_eligible = 1 ) WHERE rank <= 10 ORDER BY dungeonId, difficultyId, startPart, completedParts, rank `).all() const dungeonCompletionCounts = new Map(database.prepare(` SELECT dungeon_id AS dungeonId, COUNT(*) AS count FROM dungeon_runs WHERE character_id = ? AND result = 'victory' AND start_part = 1 AND completed_parts >= 1 GROUP BY dungeon_id `).all(characterId).map((row) => [row.dungeonId, row.count])) const settings = Object.fromEntries( database.prepare('SELECT key, value FROM game_settings').all() .map((setting) => [setting.key, Number(setting.value)]), ) const currentLevel = database.prepare(` SELECT experience_required AS experienceRequired FROM level_progression WHERE level = ? `).get(character.level) const nextLevel = database.prepare(` SELECT experience_required AS experienceRequired FROM level_progression WHERE level = ? `).get(character.level + 1) return { character: { ...character, currentLevelExperience: currentLevel?.experienceRequired ?? character.experience, nextLevelExperience: nextLevel?.experienceRequired ?? character.experience, }, classes: classes.map((gameClass) => ({ ...gameClass, spells: spells.filter((spell) => spell.classId === gameClass.id), talents: talents.filter((talent) => talent.classId === gameClass.id), })), abilitySlots: Array.from({ length: 6 }, (_, index) => { const slot = slots.find((candidate) => candidate.slotNumber === index + 1) return slot?.spellId ?? null }), maxLevel: settings.max_level ?? 25, maxTalentPoints: settings.max_talent_points ?? 25, allocatedTalentPoints: talents.reduce((total, talent) => total + talent.rank, 0), completedDungeonParts: accountId ? (database.prepare('SELECT completed_dungeon_parts AS value FROM accounts WHERE id = ?').get(accountId)?.value ?? 0) : 0, completedRaidPhases: accountId ? (database.prepare('SELECT completed_raid_phases AS value FROM accounts WHERE id = ?').get(accountId)?.value ?? 0) : 0, equipmentSlots, inventory, gearStats, setBonuses, gearUpgradePaths, craftingRecipes: craftingRecipeRows.map((recipe) => { const components = craftingComponentRows .filter((component) => component.recipeId === recipe.id) .map(({ recipeId, owned, quantity, ...item }) => ({ item, quantity, owned, })) const hasRequiredComponents = components.length > 0 && components.every((component) => component.quantity > 0) const { itemId, slug, name, slot, rarity, itemLevel, healingPower, maxResourceBonus, glyph, description, setId, setSlug, setName } = recipe return { id: recipe.id, difficultyId: recipe.difficultyId, sourceDungeonId: recipe.sourceDungeonId, sourceEncounterId: recipe.sourceEncounterId, item: { id: itemId, slug, name, slot, rarity, itemLevel, healingPower, maxResourceBonus, glyph, description, setId, setSlug, setName, }, components, canCraft: hasRequiredComponents && components.every((component) => component.owned >= component.quantity), } }), dungeons: dungeons.map((dungeon) => ({ ...dungeon, completionCount: dungeonCompletionCounts.get(dungeon.id) ?? 0, difficulties: dungeonDifficulties.filter( (difficulty) => difficulty.dungeonId === dungeon.id, ), encounters: encounters .filter((encounter) => encounter.dungeonId === dungeon.id) .map((encounter) => ({ ...encounter, isBoss: encounter.encounterType === 'boss', lootTables: encounterLoot.filter( (entry) => entry.encounterId === encounter.id, ), })), completionLoot: completionLoot.filter((item) => item.dungeonId === dungeon.id), leaderboard: leaderboardRuns .filter((run) => run.dungeonId === dungeon.id) .map((run) => ({ ...run, rank: Number(run.rank) })), leaderboards: { part_1: leaderboardRuns .filter((run) => run.dungeonId === dungeon.id && run.startPart === 1 && run.completedParts === 1) .map((run) => ({ ...run, rank: Number(run.rank) })), part_2: leaderboardRuns .filter((run) => run.dungeonId === dungeon.id && run.startPart === 2 && run.completedParts === 1) .map((run) => ({ ...run, rank: Number(run.rank) })), part_3: leaderboardRuns .filter((run) => run.dungeonId === dungeon.id && run.startPart === 3 && run.completedParts === 1) .map((run) => ({ ...run, rank: Number(run.rank) })), full_run: leaderboardRuns .filter((run) => run.dungeonId === dungeon.id && run.startPart === 1 && run.completedParts === 3) .map((run) => ({ ...run, rank: Number(run.rank) })), }, })), } } function exportCharacterData(database, characterId, classId) { const character = database.prepare(` SELECT level, experience, talent_points AS talentPoints FROM characters WHERE id = ? `).get(characterId) const slots = database.prepare(` SELECT slot_number AS slotNumber, spell_id AS spellId FROM character_ability_slots WHERE character_id = ? ORDER BY slot_number `).all(characterId) const talents = database.prepare(` SELECT talents.id, COALESCE(character_talents.rank, 0) AS rank FROM talents LEFT JOIN character_talents ON character_talents.talent_id = talents.id AND character_talents.character_id = ? WHERE talents.class_id = ? ORDER BY talents.id `).all(characterId, classId) const inventory = database.prepare(` SELECT items.id, items.slug, items.name, items.slot, items.rarity, items.item_level AS itemLevel, items.healing_power AS healingPower, items.max_resource_bonus AS maxResourceBonus, items.glyph, items.description, item_sets.id AS setId, item_sets.slug AS setSlug, item_sets.name AS setName, character_inventory.quantity, character_inventory.equipped FROM character_inventory JOIN items ON items.id = character_inventory.item_id LEFT JOIN item_set_items ON item_set_items.item_id = items.id LEFT JOIN item_sets ON item_sets.id = item_set_items.set_id WHERE character_inventory.character_id = ? ORDER BY items.slot, items.item_level DESC, items.id `).all(characterId).map((item) => ({ ...item, equipped: Boolean(item.equipped) })) const talentRanks = {} for (const talent of talents) { if (talent.rank > 0) { talentRanks[String(talent.id)] = talent.rank } } return { level: character.level, experience: character.experience, talentPoints: character.talentPoints, abilitySlots: Array.from({ length: 6 }, (_, index) => { const slot = slots.find((candidate) => candidate.slotNumber === index + 1) return slot?.spellId ?? null }), talentRanks, inventory, } } function buildSyncSave(database, accountId, activeCharacterId) { const account = database.prepare(` SELECT completed_dungeon_parts AS completedDungeonParts, completed_raid_phases AS completedRaidPhases FROM accounts WHERE id = ? `).get(accountId) const characters = database.prepare(` SELECT id, class_id AS classId, name FROM characters WHERE account_id = ? ORDER BY class_id `).all(accountId) const activeClassId = characters.find((candidate) => candidate.id === activeCharacterId)?.classId ?? characters[0]?.classId ?? 1 const characterName = characters.find((candidate) => candidate.id === activeCharacterId)?.name ?? characters[0]?.name ?? 'Mira' return { version: 3, characterName, activeClassId, completedDungeonParts: account?.completedDungeonParts ?? 0, completedRaidPhases: account?.completedRaidPhases ?? 0, characters: Object.fromEntries( characters.map((character) => [ character.classId, exportCharacterData(database, character.id, character.classId), ]), ), lootRolls: {}, } } function clampInteger(value, fallback, min, max) { const numeric = Number(value) if (!Number.isInteger(numeric)) return fallback return Math.min(max, Math.max(min, numeric)) } function importSyncSave(database, accountId, activeCharacterId, payload) { const save = payload?.save if ( !save || typeof save !== 'object' || Number(save.version) !== 3 || typeof save.characterName !== 'string' || !save.characters || typeof save.characters !== 'object' ) { throw new Error('The local save snapshot is invalid.') } const maxLevel = Number( database.prepare("SELECT value FROM game_settings WHERE key = 'max_level'").get()?.value ?? 25, ) const maxTalentPoints = Number( database.prepare("SELECT value FROM game_settings WHERE key = 'max_talent_points'").get()?.value ?? 25, ) const maxExperience = database.prepare(` SELECT experience_required AS experienceRequired FROM level_progression WHERE level = ? `).get(maxLevel).experienceRequired const classIds = database.prepare('SELECT id FROM classes ORDER BY id').all().map((row) => row.id) const existingCharacters = database.prepare(` SELECT id, class_id AS classId, name FROM characters WHERE account_id = ? ORDER BY class_id `).all(accountId) if (existingCharacters.length === 0) { throw new Error('No character found for this account.') } const baseCharacterName = existingCharacters.find((candidate) => candidate.id === activeCharacterId)?.name ?? existingCharacters[0].name const characterName = normalizeCharacterName(save.characterName, baseCharacterName) const itemRows = database.prepare(` SELECT id, slot FROM items `).all() const itemSlots = new Map(itemRows.map((item) => [item.id, item.slot])) const spellIdsByClass = new Map( classIds.map((classId) => [ classId, new Set( database.prepare(` SELECT id FROM spells WHERE class_id = ? `).all(classId).map((spell) => spell.id), ), ]), ) const talentRowsByClass = new Map( classIds.map((classId) => [ classId, database.prepare(` SELECT id, max_rank AS maxRank FROM talents WHERE class_id = ? `).all(classId), ]), ) const charactersByClass = new Map(existingCharacters.map((character) => [character.classId, character])) database.exec('BEGIN') try { for (const classId of classIds) { if (!charactersByClass.has(classId)) { const characterId = initializeCharacter(database, accountId, characterName, classId) charactersByClass.set(classId, { id: characterId, classId, name: characterName }) } } database.prepare(` UPDATE accounts SET completed_dungeon_parts = ?, completed_raid_phases = ? WHERE id = ? `).run( clampInteger(save.completedDungeonParts, 0, 0, 3), clampInteger(save.completedRaidPhases, 0, 0, 3), accountId, ) const replaceSlot = database.prepare(` INSERT INTO character_ability_slots (character_id, slot_number, spell_id) VALUES (?, ?, ?) `) const insertTalent = database.prepare(` INSERT INTO character_talents (character_id, talent_id, rank) VALUES (?, ?, ?) `) const insertInventory = database.prepare(` INSERT INTO character_inventory (character_id, item_id, quantity, equipped) VALUES (?, ?, ?, ?) `) for (const classId of classIds) { const local = save.characters[classId] if (!local || typeof local !== 'object') continue const characterId = charactersByClass.get(classId).id database.prepare(` UPDATE characters SET name = ?, level = ?, experience = ?, talent_points = ? WHERE id = ? `).run( characterName, clampInteger(local.level, 1, 1, maxLevel), clampInteger(local.experience, 0, 0, maxExperience), clampInteger(local.talentPoints, 1, 0, maxTalentPoints), characterId, ) const rawSlots = Array.isArray(local.abilitySlots) ? local.abilitySlots.slice(0, 6) : [] while (rawSlots.length < 6) rawSlots.push(null) const validSpellIds = spellIdsByClass.get(classId) ?? new Set() const seenSpellIds = new Set() const normalizedSlots = rawSlots.map((value) => { if (value === null) return null const spellId = Number(value) if ( !Number.isInteger(spellId) || !validSpellIds.has(spellId) || seenSpellIds.has(spellId) ) { return null } seenSpellIds.add(spellId) return spellId }) database.prepare(` DELETE FROM character_ability_slots WHERE character_id = ? `).run(characterId) normalizedSlots.forEach((spellId, index) => { replaceSlot.run(characterId, index + 1, spellId) }) database.prepare(` DELETE FROM character_talents WHERE character_id = ? AND talent_id IN (SELECT id FROM talents WHERE class_id = ?) `).run(characterId, classId) const localTalentRanks = local.talentRanks && typeof local.talentRanks === 'object' ? local.talentRanks : {} for (const talent of talentRowsByClass.get(classId) ?? []) { const rank = clampInteger(localTalentRanks[String(talent.id)], 0, 0, talent.maxRank) if (rank > 0) { insertTalent.run(characterId, talent.id, rank) } } database.prepare(` DELETE FROM character_inventory WHERE character_id = ? `).run(characterId) const inventoryByItemId = new Map() const equippedSlots = new Set() for (const item of Array.isArray(local.inventory) ? local.inventory : []) { const itemId = Number(item?.id) const slot = itemSlots.get(itemId) const quantity = clampInteger(item?.quantity, 0, 0, 9999) if (!slot || quantity <= 0) continue const current = inventoryByItemId.get(itemId) ?? { quantity: 0, equipped: false } current.quantity = Math.min(9999, current.quantity + quantity) if ( Boolean(item?.equipped) && slot !== 'component' && !equippedSlots.has(slot) ) { current.equipped = true equippedSlots.add(slot) } inventoryByItemId.set(itemId, current) } for (const [itemId, itemState] of inventoryByItemId) { insertInventory.run(characterId, itemId, itemState.quantity, itemState.equipped ? 1 : 0) } } let syncedClassId = clampInteger( save.activeClassId, existingCharacters[0]?.classId ?? 1, classIds[0] ?? 1, classIds[classIds.length - 1] ?? 1, ) if (!charactersByClass.has(syncedClassId)) { syncedClassId = existingCharacters[0]?.classId ?? 1 } const syncedCharacterId = charactersByClass.get(syncedClassId)?.id ?? activeCharacterId database.prepare(` UPDATE sessions SET active_character_id = ? WHERE account_id = ? `).run(syncedCharacterId, accountId) database.exec('COMMIT') return { profile: getProfile(database, syncedCharacterId, accountId), save: buildSyncSave(database, accountId, syncedCharacterId), } } catch (error) { database.exec('ROLLBACK') throw error } } function itemById(database, itemId) { return database.prepare(` SELECT items.id, items.slug, items.name, items.slot, items.rarity, items.item_level AS itemLevel, items.healing_power AS healingPower, items.max_resource_bonus AS maxResourceBonus, items.glyph, items.description, item_sets.id AS setId, item_sets.slug AS setSlug, item_sets.name AS setName FROM items LEFT JOIN item_set_items ON item_set_items.item_id = items.id LEFT JOIN item_sets ON item_sets.id = item_set_items.set_id WHERE items.id = ? `).get(itemId) } function formatLootRoll(database, context, record, dropChance) { let items = [] if (record.id) { items = database.prepare(` SELECT items.id, items.slug, items.name, items.slot, items.rarity, items.item_level AS itemLevel, items.healing_power AS healingPower, items.max_resource_bonus AS maxResourceBonus, items.glyph, items.description, item_sets.id AS setId, item_sets.slug AS setSlug, item_sets.name AS setName, encounter_loot_roll_items.quantity, encounter_loot_roll_items.was_duplicate AS wasDuplicate, encounter_loot_roll_items.quantity_after AS quantityAfter FROM encounter_loot_roll_items JOIN items ON items.id = encounter_loot_roll_items.item_id LEFT JOIN item_set_items ON item_set_items.item_id = items.id LEFT JOIN item_sets ON item_sets.id = item_set_items.set_id WHERE encounter_loot_roll_items.roll_id = ? ORDER BY items.id `).all(record.id).map((item) => ({ ...item, duplicate: Boolean(item.wasDuplicate), })) } if (items.length === 0 && record.itemId) { const item = itemById(database, record.itemId) if (item) { items = [{ ...item, quantity: 1, duplicate: Boolean(record.wasDuplicate), quantityAfter: record.quantityAfter, }] } } const item = items[0] ?? null return { encounterId: context.id, encounterName: context.encounterName, difficultyId: context.difficultyId, difficultyName: context.difficultyName, dropChance, dropped: Boolean(record.dropped), item, items, awarded: Boolean(record.dropped), duplicate: items.some((candidate) => candidate.duplicate) || Boolean(record.wasDuplicate), quantityAfter: item?.quantityAfter ?? record.quantityAfter, } } function coinDropQuantity() { const roll = Math.random() if (roll < 0.15) return 3 if (roll < 0.5) return 2 return 1 } function roguelikeCoinItemLevel(stage) { return Math.min(25, 5 + Math.max(0, Math.floor(stage / 5)) * 5) } function awardRoguelikeCoin(database, characterId, sourceEncounterId, stage) { if (!sourceEncounterId || !stage) return null const coin = database.prepare(` SELECT items.id, items.slug, items.name, items.slot, items.rarity, items.item_level AS itemLevel, items.healing_power AS healingPower, items.max_resource_bonus AS maxResourceBonus, items.glyph, items.description FROM encounter_loot JOIN items ON items.id = encounter_loot.item_id WHERE encounter_loot.encounter_id = ? AND items.item_level = ? ORDER BY encounter_loot.difficulty_id LIMIT 1 `).get(sourceEncounterId, roguelikeCoinItemLevel(stage)) if (!coin) return null const quantity = coinDropQuantity() const previousQuantity = database.prepare(` SELECT quantity FROM character_inventory WHERE character_id = ? AND item_id = ? `).get(characterId, coin.id)?.quantity ?? 0 database.prepare(` INSERT INTO character_inventory (character_id, item_id, quantity, equipped) VALUES (?, ?, ?, 0) ON CONFLICT(character_id, item_id) DO UPDATE SET quantity = quantity + ? `).run(characterId, coin.id, quantity, quantity) return { ...coin, quantity, duplicate: previousQuantity > 0, quantityAfter: previousQuantity + quantity, } } function rollWeightedLootEntry(entries) { const totalWeight = entries.reduce((total, entry) => total + entry.dropWeight, 0) let weightedRoll = Math.random() * totalWeight for (const entry of entries) { weightedRoll -= entry.dropWeight if (weightedRoll < 0) return entry } return entries[entries.length - 1] } function rollEncounterLoot(database, characterId, encounterId, difficultyId, runToken) { if (typeof runToken !== 'string' || runToken.length < 8 || runToken.length > 100) { throw new Error('A valid dungeon run token is required.') } const context = database.prepare(` SELECT encounters.id, encounters.name AS encounterName, encounters.dungeon_id AS dungeonId, dungeons.content_type AS contentType, difficulties.id AS difficultyId, difficulties.name AS difficultyName, difficulties.dropped_item_level AS droppedItemLevel, difficulties.unlock_level AS unlockLevel FROM encounters JOIN dungeons ON dungeons.id = encounters.dungeon_id JOIN dungeon_difficulties ON dungeon_difficulties.dungeon_id = encounters.dungeon_id JOIN difficulties ON difficulties.id = dungeon_difficulties.difficulty_id WHERE encounters.id = ? AND difficulties.id = ? `).get(encounterId, difficultyId) if (!context) throw new Error('That loot table is not available.') const character = database.prepare('SELECT level FROM characters WHERE id = ?').get(characterId) if (character.level < context.unlockLevel) { throw new Error(`${context.difficultyName} unlocks at level ${context.unlockLevel}.`) } const entries = database.prepare(` SELECT encounter_loot.drop_weight AS dropWeight, encounter_loot.drop_chance AS dropChance, items.id, items.slug, items.name, items.slot, items.rarity, items.item_level AS itemLevel, items.healing_power AS healingPower, items.max_resource_bonus AS maxResourceBonus, items.glyph, items.description, item_sets.id AS setId, item_sets.slug AS setSlug, item_sets.name AS setName FROM encounter_loot JOIN items ON items.id = encounter_loot.item_id LEFT JOIN item_set_items ON item_set_items.item_id = items.id LEFT JOIN item_sets ON item_sets.id = item_set_items.set_id WHERE encounter_loot.encounter_id = ? AND encounter_loot.difficulty_id = ? ORDER BY items.id `).all(encounterId, difficultyId) if (entries.length === 0) throw new Error('This encounter has no configured loot.') const dropChance = entries[0].dropChance const findExistingRoll = database.prepare(` SELECT id, item_id AS itemId, dropped, was_duplicate AS wasDuplicate, quantity_after AS quantityAfter FROM encounter_loot_rolls WHERE character_id = ? AND run_token = ? AND encounter_id = ? AND difficulty_id = ? `) const existing = findExistingRoll.get(characterId, runToken, encounterId, difficultyId) if (existing) { return formatLootRoll(database, context, existing, dropChance) } database.exec('BEGIN IMMEDIATE') try { const concurrentRoll = findExistingRoll.get( characterId, runToken, encounterId, difficultyId, ) if (concurrentRoll) { database.exec('COMMIT') return formatLootRoll(database, context, concurrentRoll, dropChance) } const selectedQuantities = new Map() if (Math.random() < dropChance) { const selected = rollWeightedLootEntry(entries) selectedQuantities.set( selected.id, coinDropQuantity(), ) } if (selectedQuantities.size === 0) { database.prepare(` INSERT INTO encounter_loot_rolls (character_id, run_token, encounter_id, difficulty_id, dropped) VALUES (?, ?, ?, ?, 0) `).run(characterId, runToken, encounterId, difficultyId) database.exec('COMMIT') return formatLootRoll(database, context, { id: null, itemId: null, dropped: 0, wasDuplicate: 0, quantityAfter: 0, }, dropChance) } const primaryItemId = selectedQuantities.keys().next().value const primaryPreviousQuantity = database.prepare(` SELECT quantity FROM character_inventory WHERE character_id = ? AND item_id = ? `).get(characterId, primaryItemId)?.quantity ?? 0 const rollResult = database.prepare(` INSERT INTO encounter_loot_rolls ( character_id, run_token, encounter_id, difficulty_id, item_id, dropped, was_duplicate, quantity_after ) VALUES (?, ?, ?, ?, ?, 1, ?, ?) `).run( characterId, runToken, encounterId, difficultyId, primaryItemId, primaryPreviousQuantity > 0 ? 1 : 0, primaryPreviousQuantity + selectedQuantities.get(primaryItemId), ) const rollId = Number(rollResult.lastInsertRowid) for (const [itemId, quantity] of selectedQuantities) { const previousQuantity = database.prepare(` SELECT quantity FROM character_inventory WHERE character_id = ? AND item_id = ? `).get(characterId, itemId)?.quantity ?? 0 database.prepare(` INSERT INTO character_inventory (character_id, item_id, quantity, equipped) VALUES (?, ?, ?, 0) ON CONFLICT(character_id, item_id) DO UPDATE SET quantity = quantity + ? `).run(characterId, itemId, quantity, quantity) database.prepare(` INSERT INTO encounter_loot_roll_items (roll_id, item_id, quantity, was_duplicate, quantity_after) VALUES (?, ?, ?, ?, ?) `).run( rollId, itemId, quantity, previousQuantity > 0 ? 1 : 0, previousQuantity + quantity, ) } database.exec('COMMIT') return formatLootRoll(database, context, { id: rollId, itemId: primaryItemId, dropped: 1, wasDuplicate: primaryPreviousQuantity > 0 ? 1 : 0, quantityAfter: primaryPreviousQuantity + selectedQuantities.get(primaryItemId), }, dropChance) } catch (error) { database.exec('ROLLBACK') throw error } } function equipItem(database, characterId, itemId) { const item = database.prepare(` SELECT items.id, items.slot, character_inventory.equipped FROM character_inventory JOIN items ON items.id = character_inventory.item_id WHERE character_inventory.character_id = ? AND items.id = ? `).get(characterId, itemId) if (!item) throw new Error('That item is not in the character inventory.') if (!equipmentSlots.includes(item.slot)) throw new Error('That item cannot be equipped.') if (item.equipped) return getProfile(database, characterId) database.exec('BEGIN') try { database.prepare(` UPDATE character_inventory SET equipped = 0 WHERE character_id = ? AND item_id IN (SELECT id FROM items WHERE slot = ?) `).run(characterId, item.slot) database.prepare(` UPDATE character_inventory SET equipped = 1 WHERE character_id = ? AND item_id = ? `).run(characterId, itemId) database.exec('COMMIT') } catch (error) { database.exec('ROLLBACK') throw error } return getProfile(database, characterId) } function discardExtraItem(database, characterId, itemId) { const item = database.prepare(` SELECT items.name, character_inventory.quantity FROM character_inventory JOIN items ON items.id = character_inventory.item_id WHERE character_inventory.character_id = ? AND items.id = ? `).get(characterId, itemId) if (!item) throw new Error('That item is not in the character inventory.') if (item.quantity <= 1) { throw new Error('Only extra copies can be discarded.') } database.exec('BEGIN') try { const result = database.prepare(` UPDATE character_inventory SET quantity = quantity - 1 WHERE character_id = ? AND item_id = ? AND quantity > 1 `).run(characterId, itemId) if (result.changes !== 1) { throw new Error('The extra copy is no longer available.') } database.exec('COMMIT') } catch (error) { database.exec('ROLLBACK') throw error } return getProfile(database, characterId) } function breakdownItem(database, characterId, itemId) { const item = database.prepare(` SELECT items.id, items.name, items.slot, items.item_level AS itemLevel, character_inventory.quantity AS qty, character_inventory.equipped FROM character_inventory JOIN items ON items.id = character_inventory.item_id WHERE character_inventory.character_id = ? AND items.id = ? `).get(characterId, itemId) if (!item) throw new Error('That item is not in the character inventory.') if (item.slot === componentSlot) throw new Error('Components cannot be broken down.') if (item.equipped && item.qty <= 1) { throw new Error('Equipped items cannot be broken down.') } const component = database.prepare(` SELECT id FROM items WHERE slot = ? AND item_level <= ? ORDER BY item_level DESC, id LIMIT 1 `).get(componentSlot, item.itemLevel) if (!component) throw new Error('No component type exists for this item level.') const count = Math.floor(Math.random() * 3) + 1 database.exec('BEGIN') try { if (item.qty <= 1) { database.prepare(` DELETE FROM character_inventory WHERE character_id = ? AND item_id = ? `).run(characterId, itemId) } else { database.prepare(` UPDATE character_inventory SET quantity = quantity - 1 WHERE character_id = ? AND item_id = ? AND quantity > 1 `).run(characterId, itemId) } database.prepare(` INSERT INTO character_inventory (character_id, item_id, quantity, equipped) VALUES (?, ?, ?, 0) ON CONFLICT(character_id, item_id) DO UPDATE SET quantity = quantity + ? `).run(characterId, component.id, count, count) database.exec('COMMIT') } catch (error) { database.exec('ROLLBACK') throw error } return getProfile(database, characterId) } function craftItem(database, characterId, recipeId) { const recipe = database.prepare(` SELECT crafting_recipes.id, crafting_recipes.item_id AS itemId, crafting_recipes.difficulty_id AS difficultyId, crafting_recipes.source_dungeon_id AS sourceDungeonId, crafting_recipes.source_encounter_id AS sourceEncounterId, items.slot, items.item_level AS itemLevel FROM crafting_recipes JOIN items ON items.id = crafting_recipes.item_id WHERE crafting_recipes.id = ? `).get(recipeId) if (!recipe) throw new Error('That crafting recipe does not exist.') if (!directCraftItemLevels.has(recipe.itemLevel)) { throw new Error('Upgrade the previous item tier instead.') } const components = database.prepare(` SELECT crafting_recipe_components.item_id AS itemId, crafting_recipe_components.quantity, COALESCE(character_inventory.quantity, 0) AS owned FROM crafting_recipe_components LEFT JOIN character_inventory ON character_inventory.item_id = crafting_recipe_components.item_id AND character_inventory.character_id = ? WHERE crafting_recipe_components.recipe_id = ? `).all(characterId, recipeId) if (components.length === 0) throw new Error('That recipe has no component requirements.') if (components.some((component) => component.quantity <= 0)) { throw new Error('Recipe components must require at least one item.') } const missing = components.find((component) => component.owned < component.quantity) if (missing) { const item = itemById(database, missing.itemId) throw new Error(`Need ${missing.quantity} ${item?.name ?? 'component'} to craft this item.`) } database.exec('BEGIN') try { for (const component of components) { database.prepare(` UPDATE character_inventory SET quantity = quantity - ? WHERE character_id = ? AND item_id = ? `).run(component.quantity, characterId, component.itemId) database.prepare(` DELETE FROM character_inventory WHERE character_id = ? AND item_id = ? AND quantity <= 0 `).run(characterId, component.itemId) } database.prepare(` INSERT INTO character_inventory (character_id, item_id, quantity, equipped) VALUES (?, ?, 1, 0) ON CONFLICT(character_id, item_id) DO UPDATE SET quantity = quantity + 1 `).run(characterId, recipe.itemId) database.exec('COMMIT') } catch (error) { database.exec('ROLLBACK') throw error } return getProfile(database, characterId) } function upgradeItem(database, characterId, itemId) { const item = database.prepare(` SELECT items.id, items.name, items.slot, items.item_level AS itemLevel, character_inventory.quantity, character_inventory.equipped FROM character_inventory JOIN items ON items.id = character_inventory.item_id WHERE character_inventory.character_id = ? AND items.id = ? `).get(characterId, itemId) if (!item) throw new Error('That item is not in the character inventory.') if (item.slot === componentSlot) throw new Error('Components cannot be upgraded.') const currentRecipe = database.prepare(` SELECT id FROM crafting_recipes WHERE item_id = ? `).get(itemId) if (!currentRecipe) throw new Error('No upgrade is available for this item.') const pathRecipe = database.prepare(` SELECT crafting_recipes.id, crafting_recipes.item_id AS itemId FROM gear_upgrade_paths JOIN crafting_recipes ON crafting_recipes.item_id = gear_upgrade_paths.to_item_id WHERE gear_upgrade_paths.from_item_id = ? `).get(itemId) const targetRecipe = pathRecipe ?? database.prepare(` WITH next_tier AS ( SELECT MIN(items.item_level) AS item_level FROM crafting_recipes JOIN items ON items.id = crafting_recipes.item_id WHERE items.slot = ? AND items.item_level > ? ) SELECT crafting_recipes.id, crafting_recipes.item_id AS itemId FROM crafting_recipes JOIN items ON items.id = crafting_recipes.item_id JOIN next_tier ON next_tier.item_level = items.item_level WHERE items.slot = ? ORDER BY crafting_recipes.id LIMIT 1 `).get(item.slot, item.itemLevel, item.slot) if (!targetRecipe) throw new Error('No upgrade is available for this item.') const components = database.prepare(` SELECT crafting_recipe_components.item_id AS itemId, crafting_recipe_components.quantity, COALESCE(character_inventory.quantity, 0) AS owned FROM crafting_recipe_components LEFT JOIN character_inventory ON character_inventory.item_id = crafting_recipe_components.item_id AND character_inventory.character_id = ? WHERE crafting_recipe_components.recipe_id = ? `).all(characterId, targetRecipe.id) if (components.length === 0) throw new Error('That upgrade has no component requirements.') if (components.some((component) => component.quantity <= 0)) { throw new Error('Upgrade components must require at least one item.') } const missing = components.find((component) => component.owned < component.quantity) if (missing) { const componentItem = itemById(database, missing.itemId) throw new Error(`Need ${missing.quantity} ${componentItem?.name ?? 'component'} to upgrade this item.`) } database.exec('BEGIN') try { for (const component of components) { database.prepare(` UPDATE character_inventory SET quantity = quantity - ? WHERE character_id = ? AND item_id = ? `).run(component.quantity, characterId, component.itemId) } database.prepare(` UPDATE character_inventory SET quantity = quantity - 1, equipped = 0 WHERE character_id = ? AND item_id = ? `).run(characterId, itemId) database.prepare(` DELETE FROM character_inventory WHERE character_id = ? AND quantity <= 0 `).run(characterId) if (item.equipped) { database.prepare(` UPDATE character_inventory SET equipped = 0 WHERE character_id = ? AND item_id IN (SELECT id FROM items WHERE slot = ?) `).run(characterId, item.slot) } database.prepare(` INSERT INTO character_inventory (character_id, item_id, quantity, equipped) VALUES (?, ?, 1, ?) ON CONFLICT(character_id, item_id) DO UPDATE SET quantity = quantity + 1, equipped = CASE WHEN excluded.equipped = 1 THEN 1 ELSE equipped END `).run(characterId, targetRecipe.itemId, item.equipped ? 1 : 0) database.exec('COMMIT') } catch (error) { database.exec('ROLLBACK') throw error } return getProfile(database, characterId) } function talentEffectCapacity(level) { return Math.min(4, Math.max(0, Math.floor(level / 5))) } function talentEffectSource(effectType) { if (effectType.startsWith('mend_')) return 'Mend' if (effectType.startsWith('radiance_')) return 'Radiance' if (effectType.startsWith('shield_') || effectType.startsWith('shielded_')) return 'Shield' return effectType } function allocateTalent(database, characterId, talentId) { const character = database.prepare(` SELECT class_id AS classId, level, talent_points AS talentPoints FROM characters WHERE id = ? `).get(characterId) const talent = database.prepare(` SELECT id, class_id AS classId, name, max_rank AS maxRank, tier, prerequisite_talent_id AS prerequisiteTalentId, prerequisite_rank AS prerequisiteRank, effect_type AS effectType FROM talents WHERE id = ? `).get(talentId) if (!talent || talent.classId !== character.classId) { throw new Error('That talent does not belong to the active class.') } if (character.classId === 1) { const currentRank = database.prepare(` SELECT rank FROM character_talents WHERE character_id = ? AND talent_id = ? `).get(characterId, talentId)?.rank ?? 0 database.exec('BEGIN') try { if (currentRank > 0) { database.prepare(` DELETE FROM character_talents WHERE character_id = ? AND talent_id = ? `).run(characterId, talentId) } else { const capacity = talentEffectCapacity(character.level) if (capacity <= 0) throw new Error('Spell effects unlock at level 5.') const activeTalents = database.prepare(` SELECT talents.id, talents.name, talents.effect_type AS effectType FROM character_talents JOIN talents ON talents.id = character_talents.talent_id WHERE character_talents.character_id = ? AND talents.class_id = ? AND character_talents.rank > 0 `).all(characterId, character.classId) const source = talentEffectSource(talent.effectType) const sourceConflict = activeTalents.find( (candidate) => candidate.id !== talentId && talentEffectSource(candidate.effectType) === source, ) if (sourceConflict) { throw new Error(`Only one ${source} spell effect can be active.`) } const activeCount = activeTalents.length if (activeCount >= capacity) { throw new Error(`Level ${character.level} allows ${capacity} active spell effect${capacity === 1 ? '' : 's'}.`) } database.prepare(` INSERT INTO character_talents (character_id, talent_id, rank) VALUES (?, ?, 1) ON CONFLICT(character_id, talent_id) DO UPDATE SET rank = 1 `).run(characterId, talentId) } database.exec('COMMIT') } catch (error) { database.exec('ROLLBACK') throw error } return getProfile(database, characterId) } if (character.talentPoints <= 0) { throw new Error('No talent points are available.') } const currentRank = database.prepare(` SELECT rank FROM character_talents WHERE character_id = ? AND talent_id = ? `).get(characterId, talentId)?.rank ?? 0 if (currentRank >= talent.maxRank) { throw new Error('That talent is already at maximum rank.') } const lowerTierPoints = database.prepare(` SELECT COALESCE(SUM(character_talents.rank), 0) AS points FROM character_talents JOIN talents ON talents.id = character_talents.talent_id WHERE character_talents.character_id = ? AND talents.class_id = ? AND talents.tier < ? `).get(characterId, character.classId, talent.tier).points const requiredTierPoints = (talent.tier - 1) * 5 if (lowerTierPoints < requiredTierPoints) { throw new Error(`Spend ${requiredTierPoints} points in earlier tiers first.`) } if (talent.prerequisiteTalentId) { const prerequisiteRank = database.prepare(` SELECT rank FROM character_talents WHERE character_id = ? AND talent_id = ? `).get(characterId, talent.prerequisiteTalentId)?.rank ?? 0 if (prerequisiteRank < talent.prerequisiteRank) { throw new Error(`The prerequisite talent requires rank ${talent.prerequisiteRank}.`) } } database.exec('BEGIN') try { database.prepare(` INSERT INTO character_talents (character_id, talent_id, rank) VALUES (?, ?, 1) ON CONFLICT(character_id, talent_id) DO UPDATE SET rank = rank + 1 `).run(characterId, talentId) database.prepare(` UPDATE characters SET talent_points = talent_points - 1 WHERE id = ? `).run(characterId) database.exec('COMMIT') } catch (error) { database.exec('ROLLBACK') throw error } return getProfile(database, characterId) } function resetTalents(database, characterId) { const character = database.prepare(` SELECT class_id AS classId, level, talent_points AS talentPoints FROM characters WHERE id = ? `).get(characterId) const refunded = database.prepare(` SELECT COALESCE(SUM(character_talents.rank), 0) AS points FROM character_talents JOIN talents ON talents.id = character_talents.talent_id WHERE character_talents.character_id = ? AND talents.class_id = ? `).get(characterId, character.classId).points database.exec('BEGIN') try { database.prepare(` DELETE FROM character_talents WHERE character_id = ? AND talent_id IN (SELECT id FROM talents WHERE class_id = ?) `).run(characterId, character.classId) if (character.classId !== 1) { database.prepare(` UPDATE characters SET talent_points = MIN(level, talent_points + ?) WHERE id = ? `).run(refunded, characterId) } database.exec('COMMIT') } catch (error) { database.exec('ROLLBACK') throw error } return getProfile(database, characterId) } function completeDungeon(database, characterId, accountId, dungeonId, difficultyId, runMetrics) { const resourceSpent = Number(runMetrics?.resourceSpent) const durationSeconds = Number(runMetrics?.durationSeconds) if (!Number.isInteger(resourceSpent) || resourceSpent < 0 || resourceSpent > 100000) { throw new Error('The run resource total is invalid.') } if (!Number.isInteger(durationSeconds) || durationSeconds < 1 || durationSeconds > 86400) { throw new Error('The run duration is invalid.') } const dungeon = database.prepare(` SELECT dungeons.id, dungeons.name, dungeons.content_type AS contentType, dungeons.completion_item_level AS completionItemLevel, dungeons.experience_reward AS experienceReward, difficulties.id AS difficultyId, difficulties.name AS difficultyName, difficulties.unlock_level AS unlockLevel, difficulties.experience_multiplier AS experienceMultiplier, difficulties.dropped_item_level AS droppedItemLevel FROM dungeons JOIN dungeon_difficulties ON dungeon_difficulties.dungeon_id = dungeons.id JOIN difficulties ON difficulties.id = dungeon_difficulties.difficulty_id WHERE dungeons.id = ? AND difficulties.id = ? `).get(dungeonId, difficultyId) if (!dungeon) throw new Error('That difficulty is not available for this dungeon.') const character = database.prepare(` SELECT characters.id, characters.class_id AS classId, characters.name, characters.level, characters.experience, characters.talent_points AS talentPoints, classes.name AS className FROM characters JOIN classes ON classes.id = characters.class_id WHERE characters.id = ? `).get(characterId) if (character.level < dungeon.unlockLevel) { throw new Error(`${dungeon.difficultyName} unlocks at level ${dungeon.unlockLevel}.`) } const maxLevel = Number( database.prepare("SELECT value FROM game_settings WHERE key = 'max_level'").get()?.value ?? 25, ) const maxTalentPoints = Number( database.prepare("SELECT value FROM game_settings WHERE key = 'max_talent_points'").get()?.value ?? 25, ) const maxExperience = database.prepare(` SELECT experience_required AS experienceRequired FROM level_progression WHERE level = ? `).get(maxLevel).experienceRequired const completedPart = Math.min(Math.max(Number(runMetrics?.completedPart) || 1, 1), 3) const startPart = Math.min(Math.max(Number(runMetrics?.startPart) || 1, 1), 3) const completedParts = completedPart - startPart + 1 const rewardMultiplier = runMetrics?.hardMode ? 2 : 1 const rawPartDurations = runMetrics?.partDurationSeconds const partDurationSeconds = Array.isArray(rawPartDurations) && rawPartDurations.length === 3 ? rawPartDurations.map(Number) : null const baseExperienceReward = Math.round( dungeon.experienceReward * dungeon.experienceMultiplier * completedPart * rewardMultiplier, ) const experienceReward = catchUpExperienceReward( database, accountId, characterId, baseExperienceReward, character.experience, character.level, ) const newExperience = Math.min(character.experience + experienceReward, maxExperience) const newLevel = database.prepare(` SELECT MAX(level) AS level FROM level_progression WHERE experience_required <= ? `).get(newExperience).level const levelsGained = Math.max(0, newLevel - character.level) const newTalentPoints = Math.min( maxTalentPoints, character.talentPoints + levelsGained, ) const unlockedAbilities = database.prepare(` SELECT id, name, unlock_level AS unlockLevel, glyph FROM spells WHERE class_id = ? AND unlock_level > ? AND unlock_level <= ? ORDER BY unlock_level, id `).all(character.classId, character.level, newLevel) const averageItemLevel = database.prepare(` SELECT COALESCE(SUM(items.item_level), 0) * 1.0 / ? AS value FROM character_inventory JOIN items ON items.id = character_inventory.item_id WHERE character_inventory.character_id = ? AND character_inventory.equipped = 1 `).get(equipmentSlots.length, characterId).value const isFullRun = startPart === 1 && completedPart >= 3 const runRows = isFullRun ? [ { startPart: 1, completedParts: 1, duration: Math.max(1, Math.round(partDurationSeconds[0] || 1)) }, { startPart: 2, completedParts: 1, duration: Math.max(1, Math.round(partDurationSeconds[1] || 1)) }, { startPart: 3, completedParts: 1, duration: Math.max(1, Math.round(partDurationSeconds[2] || 1)) }, { startPart: 1, completedParts: 3, duration: durationSeconds }, ] : [{ startPart, completedParts: completedParts, duration: durationSeconds }] database.exec('BEGIN') try { database.prepare(` UPDATE characters SET experience = ?, level = ?, talent_points = ? WHERE id = ? `).run(newExperience, newLevel, newTalentPoints, characterId) if (dungeon.contentType === 'raid') { database.prepare(` UPDATE accounts SET completed_raid_phases = MAX(completed_raid_phases, ?) WHERE id = ? `).run(completedPart, accountId) } else { database.prepare(` UPDATE accounts SET completed_dungeon_parts = MAX(completed_dungeon_parts, ?) WHERE id = ? `).run(completedPart, accountId) } const insertStmt = database.prepare(` INSERT INTO dungeon_runs ( character_id, dungeon_id, difficulty_id, result, character_name, class_name, character_level, average_item_level, resource_spent, duration_seconds, leaderboard_eligible, start_part, completed_parts, completed_at ) VALUES (?, ?, ?, 'victory', ?, ?, ?, ?, ?, ?, 1, ?, ?, CURRENT_TIMESTAMP) `) for (const row of runRows) { insertStmt.run( characterId, dungeonId, difficultyId, character.name, character.className, character.level, averageItemLevel, resourceSpent, row.duration, row.startPart, row.completedParts, ) } database.exec('COMMIT') } catch (error) { database.exec('ROLLBACK') throw error } let bonusItem = null if (startPart === 1 && completedPart >= 3) { const bonusItems = database.prepare(` SELECT items.id, items.slug, items.name, items.slot, items.rarity, items.item_level AS itemLevel, items.healing_power AS healingPower, items.max_resource_bonus AS maxResourceBonus, items.glyph, items.description FROM dungeon_completion_loot JOIN items ON items.id = dungeon_completion_loot.item_id WHERE dungeon_completion_loot.dungeon_id = ? AND items.item_level >= ? ORDER BY items.item_level, RANDOM() LIMIT 1 `).all(dungeonId, dungeon.completionItemLevel ?? dungeon.droppedItemLevel + 3) if (bonusItems.length > 0) { bonusItem = bonusItems[0] const rewardQuantity = rewardMultiplier const previousQuantity = database.prepare(` SELECT quantity FROM character_inventory WHERE character_id = ? AND item_id = ? `).get(characterId, bonusItem.id)?.quantity ?? 0 database.prepare(` INSERT INTO character_inventory (character_id, item_id, quantity, equipped) VALUES (?, ?, ?, 0) ON CONFLICT(character_id, item_id) DO UPDATE SET quantity = quantity + ? `).run(characterId, bonusItem.id, rewardQuantity, rewardQuantity) bonusItem = { ...bonusItem, quantity: rewardQuantity, duplicate: previousQuantity > 0, quantityAfter: previousQuantity + rewardQuantity } } } return { dungeonName: dungeon.name, difficultyName: dungeon.difficultyName, droppedItemLevel: dungeon.droppedItemLevel, experienceGained: newExperience - character.experience, previousLevel: character.level, newLevel, levelsGained, talentPointsGained: levelsGained, resourceSpent, durationSeconds, averageItemLevel, unlockedAbilities, bonusItem, profile: getProfile(database, characterId, accountId), } } function completeRoguelike(database, characterId, accountId, runMetrics) { const dungeonId = Number(runMetrics?.dungeonId) const difficultyId = Number(runMetrics?.difficultyId) const encountersCleared = Number(runMetrics?.encountersCleared) const bossesCleared = Number(runMetrics?.bossesCleared ?? Math.floor(encountersCleared / 3)) const experienceMode = runMetrics?.experienceMode === 'pvp-boss-quarter-level' ? 'pvp-boss-quarter-level' : 'default' const resourceSpent = Number(runMetrics?.resourceSpent) const durationSeconds = Number(runMetrics?.durationSeconds) if (!Number.isInteger(dungeonId) || dungeonId < 1) { throw new Error('The roguelike dungeon is invalid.') } if (!Number.isInteger(difficultyId) || difficultyId < 1) { throw new Error('The roguelike difficulty is invalid.') } if (!Number.isInteger(encountersCleared) || encountersCleared < 0 || encountersCleared > 100000) { throw new Error('The roguelike progress total is invalid.') } if (!Number.isInteger(bossesCleared) || bossesCleared < 0 || bossesCleared > 100000) { throw new Error('The roguelike boss total is invalid.') } if (!Number.isInteger(resourceSpent) || resourceSpent < 0 || resourceSpent > 100000) { throw new Error('The run resource total is invalid.') } if (!Number.isInteger(durationSeconds) || durationSeconds < 1 || durationSeconds > 86400) { throw new Error('The run duration is invalid.') } const dungeon = database.prepare(` SELECT dungeons.id, dungeons.name, difficulties.id AS difficultyId, difficulties.name AS difficultyName, difficulties.unlock_level AS unlockLevel, difficulties.experience_multiplier AS experienceMultiplier, difficulties.dropped_item_level AS droppedItemLevel, dungeons.experience_reward AS experienceReward FROM dungeons JOIN dungeon_difficulties ON dungeon_difficulties.dungeon_id = dungeons.id JOIN difficulties ON difficulties.id = dungeon_difficulties.difficulty_id WHERE dungeons.id = ? AND difficulties.id = ? `).get(dungeonId, difficultyId) if (!dungeon) throw new Error('That difficulty is not available for this roguelike.') const character = database.prepare(` SELECT characters.id, characters.class_id AS classId, characters.level, characters.experience, characters.talent_points AS talentPoints FROM characters WHERE characters.id = ? `).get(characterId) if (character.level < dungeon.unlockLevel) { throw new Error(`${dungeon.difficultyName} unlocks at level ${dungeon.unlockLevel}.`) } const maxLevel = Number( database.prepare("SELECT value FROM game_settings WHERE key = 'max_level'").get()?.value ?? 25, ) const maxTalentPoints = Number( database.prepare("SELECT value FROM game_settings WHERE key = 'max_talent_points'").get()?.value ?? 25, ) const maxExperience = database.prepare(` SELECT experience_required AS experienceRequired FROM level_progression WHERE level = ? `).get(maxLevel).experienceRequired let newExperience = character.experience let newLevel = character.level if (experienceMode === 'pvp-boss-quarter-level') { const catchUpTargetLevel = database.prepare(` SELECT COALESCE(MAX(level), 0) AS level FROM characters WHERE account_id = ? AND id != ? `).get(accountId, characterId).level for (let bossIndex = 0; bossIndex < bossesCleared && newExperience < maxExperience; bossIndex += 1) { const currentLevelFloor = database.prepare(` SELECT experience_required AS experienceRequired FROM level_progression WHERE level = ? `).get(newLevel).experienceRequired const nextLevelExperience = newLevel >= maxLevel ? maxExperience : database.prepare(` SELECT experience_required AS experienceRequired FROM level_progression WHERE level = ? `).get(newLevel + 1).experienceRequired const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor) const rewardRate = catchUpTargetLevel > newLevel ? 0.5 : 0.25 newExperience = Math.min(maxExperience, newExperience + Math.round(levelBand * rewardRate)) newLevel = database.prepare(` SELECT MAX(level) AS level FROM level_progression WHERE experience_required <= ? `).get(newExperience).level } } else { const baseExperienceReward = Math.round( dungeon.experienceReward * dungeon.experienceMultiplier * (encountersCleared / 3), ) const experienceReward = catchUpExperienceReward( database, accountId, characterId, baseExperienceReward, character.experience, character.level, ) newExperience = Math.min(character.experience + experienceReward, maxExperience) newLevel = database.prepare(` SELECT MAX(level) AS level FROM level_progression WHERE experience_required <= ? `).get(newExperience).level } const levelsGained = Math.max(0, newLevel - character.level) const newTalentPoints = Math.min( maxTalentPoints, character.talentPoints + levelsGained, ) const unlockedAbilities = database.prepare(` SELECT id, name, unlock_level AS unlockLevel, glyph FROM spells WHERE class_id = ? AND unlock_level > ? AND unlock_level <= ? ORDER BY unlock_level, id `).all(character.classId, character.level, newLevel) const averageItemLevel = database.prepare(` SELECT COALESCE(SUM(items.item_level), 0) * 1.0 / ? AS value FROM character_inventory JOIN items ON items.id = character_inventory.item_id WHERE character_inventory.character_id = ? AND character_inventory.equipped = 1 `).get(equipmentSlots.length, characterId).value database.prepare(` UPDATE characters SET experience = ?, level = ?, talent_points = ? WHERE id = ? `).run(newExperience, newLevel, newTalentPoints, characterId) const bonusItem = awardRoguelikeCoin( database, characterId, Number(runMetrics?.lootSourceEncounterId), Number(runMetrics?.roguelikeStage), ) return { dungeonName: `${dungeon.name} Roguelike`, difficultyName: dungeon.difficultyName, droppedItemLevel: dungeon.droppedItemLevel, experienceGained: newExperience - character.experience, previousLevel: character.level, newLevel, levelsGained, talentPointsGained: levelsGained, resourceSpent, durationSeconds, averageItemLevel, unlockedAbilities, bonusItem, profile: getProfile(database, characterId, accountId), } } function saveProfile(database, characterId, accountId, payload) { const classId = Number(payload.classId) const spellIds = Array.isArray(payload.abilitySlots) ? payload.abilitySlots.slice(0, 6).map((value) => value === null ? null : Number(value)) : [] while (spellIds.length < 6) spellIds.push(null) const gameClass = database.prepare('SELECT id FROM classes WHERE id = ?').get(classId) if (!gameClass) throw new Error('Selected class does not exist.') const getCharacterLevel = database.prepare(` SELECT level, class_id AS classId FROM characters WHERE id = ? `) const character = getCharacterLevel.get(characterId) if (!character) throw new Error('Character not found.') const selectedIds = spellIds.filter((id) => id !== null) if (new Set(selectedIds).size !== selectedIds.length) { throw new Error('The same ability cannot be equipped twice.') } if (selectedIds.length > 0) { const placeholders = selectedIds.map(() => '?').join(', ') const validSpells = database.prepare(` SELECT id FROM spells WHERE class_id = ? AND unlock_level <= ? AND id IN (${placeholders}) `).all(classId, character.level, ...selectedIds) if (validSpells.length !== selectedIds.length) { throw new Error('One or more abilities are locked or belong to another class.') } } database.exec('BEGIN') try { let targetCharacterId = characterId if (character.classId !== classId) { const existing = database.prepare(` SELECT id FROM characters WHERE account_id = ? AND class_id = ? `).get(accountId, classId) if (existing) { targetCharacterId = existing.id } else { const char = database.prepare('SELECT name FROM characters WHERE id = ?').get(characterId) targetCharacterId = initializeCharacter(database, accountId, char.name, classId) } database.prepare(` UPDATE sessions SET active_character_id = ? WHERE account_id = ? `).run(targetCharacterId, accountId) } database.prepare('DELETE FROM character_ability_slots WHERE character_id = ?').run(targetCharacterId) const insertSlot = database.prepare(` INSERT INTO character_ability_slots (character_id, slot_number, spell_id) VALUES (?, ?, ?) `) spellIds.forEach((spellId, index) => { insertSlot.run(targetCharacterId, index + 1, spellId) }) database.exec('COMMIT') return targetCharacterId } catch (error) { database.exec('ROLLBACK') throw error } } export function gameApiPlugin() { return { name: 'ashen-halls-game-api', configureServer(server) { server.middlewares.use(handleApiRequest) }, configurePreviewServer(server) { server.middlewares.use(handleApiRequest) }, } } async function handleAuthApiRoute(database, request, response) { if (request.url === '/api/auth/register' && request.method === 'POST') { const payload = await readJson(request) const result = registerAccount(database, request, payload) sendJson( response, 201, { account: result.account, profile: result.profile, token: result.token }, { 'Set-Cookie': sessionCookie(result.token, request) }, ) return true } if (request.url === '/api/auth/login' && request.method === 'POST') { const payload = await readJson(request) const result = loginAccount(database, request, payload) sendJson( response, 200, { account: result.account, profile: result.profile, token: result.token }, { 'Set-Cookie': sessionCookie(result.token, request) }, ) return true } if (request.url === '/api/auth/session' && request.method === 'GET') { const session = currentSession(database, request) if (!session) { sendJson(response, 200, { account: null, profile: null }) return true } sendJson(response, 200, { account: { id: session.accountId, username: session.username }, profile: getProfile(database, session.characterId, session.accountId), }) return true } if (request.url === '/api/auth/logout' && request.method === 'POST') { const token = requestSessionToken(request) if (token) { database.prepare('DELETE FROM sessions WHERE token_hash = ?').run(tokenHash(token)) } sendJson( response, 200, { ok: true }, { 'Set-Cookie': sessionCookie('', request, 0) }, ) return true } return false } export async function handleAuthApiRequest(request, response, next = null) { if (!request.url?.startsWith('/api/auth/')) { if (next) { next() return } sendJson(response, 404, { error: 'API route not found.' }) return } if (request.method === 'OPTIONS') { sendCorsPreflight(request, response) return } setCorsHeaders(response, request) if (!existsSync(databasePath)) { sendJson(response, 503, { error: 'Database missing. Run npm run db:init.' }) return } const database = new DatabaseSync(databasePath) database.exec('PRAGMA foreign_keys = ON') try { const ip = requestIp(request) consumeRateLimit(`auth:${ip}`, 120, 60 * 1000) database.prepare(` DELETE FROM sessions WHERE expires_at <= CURRENT_TIMESTAMP `).run() if (!(await handleAuthApiRoute(database, request, response))) { sendJson(response, 404, { error: 'API route not found.' }) } } catch (error) { const status = Number(error?.status) || 400 const headers = error?.retryAfter ? { 'Retry-After': String(error.retryAfter) } : {} sendJson( response, status, { error: error instanceof Error ? error.message : 'Unable to process request.' }, headers, ) } finally { database.close() } } export async function handleApiRequest(request, response, next) { if (!request.url?.startsWith('/api/')) { next() return } if (request.method === 'OPTIONS') { sendCorsPreflight(request, response) return } setCorsHeaders(response, request) if (request.url.startsWith('/api/boss-images/') && request.method === 'GET') { sendBossImage(request, response) return } if (request.url.startsWith('/api/item-images/') && request.method === 'GET') { sendItemImage(request, response) return } if (!existsSync(databasePath)) { sendJson(response, 503, { error: 'Database missing. Run npm run db:init.' }) return } const database = new DatabaseSync(databasePath) database.exec('PRAGMA foreign_keys = ON') try { const ip = requestIp(request) consumeRateLimit(`api:${ip}`, 240, 60 * 1000) database.prepare(` DELETE FROM sessions WHERE expires_at <= CURRENT_TIMESTAMP `).run() if (await handleAuthApiRoute(database, request, response)) { return } const session = requireSession(database, request) if (request.url === '/api/profile/sync-save' && request.method === 'GET') { sendJson(response, 200, buildSyncSave(database, session.accountId, session.characterId)) return } if (request.url === '/api/profile/sync-save' && request.method === 'PUT') { const payload = await readJson(request, 512 * 1024) sendJson(response, 200, importSyncSave(database, session.accountId, session.characterId, payload)) return } if (request.url === '/api/profile' && request.method === 'GET') { sendJson(response, 200, getProfile(database, session.characterId, session.accountId)) return } if (request.url === '/api/profile' && request.method === 'PUT') { const payload = await readJson(request) const newCharacterId = saveProfile(database, session.characterId, session.accountId, payload) sendJson(response, 200, getProfile(database, newCharacterId, session.accountId)) return } const dungeonCompletion = request.url.match(/^\/api\/dungeons\/(\d+)\/complete$/) if (dungeonCompletion && request.method === 'POST') { const payload = await readJson(request) sendJson( response, 200, completeDungeon( database, session.characterId, session.accountId, Number(dungeonCompletion[1]), Number(payload.difficultyId), payload, ), ) return } if (request.url === '/api/roguelike/complete' && request.method === 'POST') { const payload = await readJson(request) sendJson( response, 200, completeRoguelike(database, session.characterId, session.accountId, payload), ) return } const talentAllocation = request.url.match(/^\/api\/talents\/(\d+)\/allocate$/) if (talentAllocation && request.method === 'POST') { sendJson( response, 200, allocateTalent(database, session.characterId, Number(talentAllocation[1])), ) return } if (request.url === '/api/talents/reset' && request.method === 'POST') { sendJson(response, 200, resetTalents(database, session.characterId)) return } const itemEquip = request.url.match(/^\/api\/equipment\/(\d+)\/equip$/) if (itemEquip && request.method === 'POST') { sendJson( response, 200, equipItem(database, session.characterId, Number(itemEquip[1])), ) return } const itemDiscard = request.url.match(/^\/api\/equipment\/(\d+)\/discard-extra$/) if (itemDiscard && request.method === 'POST') { sendJson( response, 200, discardExtraItem(database, session.characterId, Number(itemDiscard[1])), ) return } const itemBreakdown = request.url.match(/^\/api\/equipment\/(\d+)\/breakdown$/) if (itemBreakdown && request.method === 'POST') { sendJson( response, 200, breakdownItem(database, session.characterId, Number(itemBreakdown[1])), ) return } const recipeCraft = request.url.match(/^\/api\/crafting\/recipes\/(\d+)\/craft$/) if (recipeCraft && request.method === 'POST') { sendJson( response, 200, craftItem(database, session.characterId, Number(recipeCraft[1])), ) return } const itemUpgrade = request.url.match(/^\/api\/items\/(\d+)\/upgrade$/) if (itemUpgrade && request.method === 'POST') { sendJson( response, 200, upgradeItem(database, session.characterId, Number(itemUpgrade[1])), ) return } const encounterLootRoll = request.url.match(/^\/api\/encounters\/(\d+)\/loot-roll$/) if (encounterLootRoll && request.method === 'POST') { const payload = await readJson(request) sendJson( response, 200, rollEncounterLoot( database, session.characterId, Number(encounterLootRoll[1]), Number(payload.difficultyId), payload.runToken, ), ) return } sendJson(response, 404, { error: 'API route not found.' }) } catch (error) { const status = Number(error?.status) || 400 const headers = error?.retryAfter ? { 'Retry-After': String(error.retryAfter) } : {} sendJson( response, status, { error: error instanceof Error ? error.message : 'Unable to process request.' }, headers, ) } finally { database.close() } }