2694 lines
89 KiB
JavaScript
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()
|
|
}
|
|
}
|