Files
Warren H 3a8d5ad8c5 changes
2026-06-18 22:28:04 -04:00

2694 lines
89 KiB
JavaScript

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 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 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)
})
const insertItem = database.prepare(`
INSERT INTO character_inventory (character_id, item_id, quantity, equipped)
VALUES (?, ?, 1, ?)
`)
for (let itemId = 100; itemId <= 107; itemId += 1) {
insertItem.run(characterId, itemId, itemId === 107 ? 0 : 1)
}
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.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 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 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,
craftingRecipes: craftingRecipeRows.map((recipe) => {
const components = craftingComponentRows
.filter((component) => component.recipeId === recipe.id)
.map(({ recipeId, owned, quantity, ...item }) => ({
item,
quantity,
owned,
}))
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: components.every((component) => component.owned >= component.quantity),
}
}),
dungeons: dungeons.map((dungeon) => ({
...dungeon,
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
FROM crafting_recipes
WHERE crafting_recipes.id = ?
`).get(recipeId)
if (!recipe) throw new Error('That crafting recipe does not exist.')
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.')
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 source_encounter_id AS sourceEncounterId
FROM crafting_recipes
WHERE item_id = ?
`).get(itemId)
if (!currentRecipe) throw new Error('No upgrade is available for this item.')
const targetRecipe = database.prepare(`
SELECT
crafting_recipes.id,
crafting_recipes.item_id AS itemId
FROM crafting_recipes
JOIN items ON items.id = crafting_recipes.item_id
WHERE crafting_recipes.source_encounter_id = ?
AND items.slot = ?
AND items.item_level = ?
`).get(currentRecipe.sourceEncounterId, item.slot, item.itemLevel + 5)
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)
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 allocateTalent(database, characterId, talentId) {
const character = database.prepare(`
SELECT class_id AS classId, 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
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.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)
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 rawPartDurations = runMetrics?.partDurationSeconds
const partDurationSeconds = Array.isArray(rawPartDurations) && rawPartDurations.length === 3
? rawPartDurations.map(Number)
: null
const experienceReward = Math.round(
dungeon.experienceReward * dungeon.experienceMultiplier * completedPart,
)
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 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 (?, ?, 1, 0)
ON CONFLICT(character_id, item_id)
DO UPDATE SET quantity = quantity + 1
`).run(characterId, bonusItem.id)
bonusItem = { ...bonusItem, quantity: 1, duplicate: previousQuantity > 0, quantityAfter: previousQuantity + 1 }
}
}
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') {
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)
newExperience = Math.min(maxExperience, newExperience + Math.round(levelBand * 0.25))
newLevel = database.prepare(`
SELECT MAX(level) AS level
FROM level_progression
WHERE experience_required <= ?
`).get(newExperience).level
}
} else {
const experienceReward = Math.round(
dungeon.experienceReward * dungeon.experienceMultiplier * (encountersCleared / 3),
)
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()
}
}