changes
This commit is contained in:
+317
-60
@@ -33,6 +33,31 @@ function sendJson(response, status, body, headers = {}) {
|
||||
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
|
||||
@@ -260,6 +285,17 @@ function parseCookies(request) {
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -284,7 +320,7 @@ function createSession(database, accountId, ip, activeCharacterId) {
|
||||
}
|
||||
|
||||
function currentSession(database, request) {
|
||||
const token = parseCookies(request)[sessionCookieName]
|
||||
const token = requestSessionToken(request)
|
||||
if (!token) return null
|
||||
return database.prepare(`
|
||||
SELECT
|
||||
@@ -1268,11 +1304,57 @@ function formatLootRoll(database, context, record, dropChance) {
|
||||
}
|
||||
}
|
||||
|
||||
function componentDropQuantity(droppedItemLevel) {
|
||||
const tier = Math.max(0, Math.floor((droppedItemLevel - 5) / 5))
|
||||
const secondChance = Math.min(0.85, 0.35 + tier * 0.12)
|
||||
const thirdChance = Math.min(0.6, 0.1 + tier * 0.1)
|
||||
return 1 + (Math.random() < secondChance ? 1 : 0) + (Math.random() < thirdChance ? 1 : 0)
|
||||
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) {
|
||||
@@ -1375,13 +1457,11 @@ function rollEncounterLoot(database, characterId, encounterId, difficultyId, run
|
||||
}
|
||||
|
||||
const selectedQuantities = new Map()
|
||||
const lootChanceSlots = context.contentType === 'raid' ? 8 : 5
|
||||
for (let index = 0; index < lootChanceSlots; index += 1) {
|
||||
if (Math.random() >= dropChance) continue
|
||||
if (Math.random() < dropChance) {
|
||||
const selected = rollWeightedLootEntry(entries)
|
||||
selectedQuantities.set(
|
||||
selected.id,
|
||||
(selectedQuantities.get(selected.id) ?? 0) + componentDropQuantity(context.droppedItemLevel),
|
||||
coinDropQuantity(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1665,6 +1745,102 @@ function craftItem(database, characterId, recipeId) {
|
||||
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
|
||||
@@ -1953,7 +2129,7 @@ function completeDungeon(database, characterId, accountId, dungeonId, difficulty
|
||||
ON CONFLICT(character_id, item_id)
|
||||
DO UPDATE SET quantity = quantity + 1
|
||||
`).run(characterId, bonusItem.id)
|
||||
bonusItem = { ...bonusItem, duplicate: previousQuantity > 0, quantityAfter: previousQuantity + 1 }
|
||||
bonusItem = { ...bonusItem, quantity: 1, duplicate: previousQuantity > 0, quantityAfter: previousQuantity + 1 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2108,6 +2284,12 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
|
||||
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`,
|
||||
@@ -2122,7 +2304,7 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
|
||||
durationSeconds,
|
||||
averageItemLevel,
|
||||
unlockedAbilities,
|
||||
bonusItem: null,
|
||||
bonusItem,
|
||||
profile: getProfile(database, characterId, accountId),
|
||||
}
|
||||
}
|
||||
@@ -2211,12 +2393,124 @@ export function gameApiPlugin() {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -2242,54 +2536,7 @@ export async function handleApiRequest(request, response, next) {
|
||||
DELETE FROM sessions WHERE expires_at <= CURRENT_TIMESTAMP
|
||||
`).run()
|
||||
|
||||
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 },
|
||||
{ 'Set-Cookie': sessionCookie(result.token, request) },
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
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 },
|
||||
{ 'Set-Cookie': sessionCookie(result.token, request) },
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (request.url === '/api/auth/session' && request.method === 'GET') {
|
||||
const session = currentSession(database, request)
|
||||
if (!session) {
|
||||
sendJson(response, 200, { account: null, profile: null })
|
||||
return
|
||||
}
|
||||
sendJson(response, 200, {
|
||||
account: { id: session.accountId, username: session.username },
|
||||
profile: getProfile(database, session.characterId, session.accountId),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (request.url === '/api/auth/logout' && request.method === 'POST') {
|
||||
const token = parseCookies(request)[sessionCookieName]
|
||||
if (token) {
|
||||
database.prepare('DELETE FROM sessions WHERE token_hash = ?').run(tokenHash(token))
|
||||
}
|
||||
sendJson(
|
||||
response,
|
||||
200,
|
||||
{ ok: true },
|
||||
{ 'Set-Cookie': sessionCookie('', request, 0) },
|
||||
)
|
||||
if (await handleAuthApiRoute(database, request, response)) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2401,6 +2648,16 @@ export async function handleApiRequest(request, response, next) {
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user