diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index d9c4e71..90e0de3 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -43,6 +43,41 @@ Keep the game server bound to `127.0.0.1`. Set `TRUST_PROXY=1` only when the server can be reached solely through your local reverse proxy. This lets account limits use the visitor's public IP instead of the proxy's address. +## Separate auth server + +The auth routes can run as their own Node process. This is useful when you want +`auth.phenomrom.com` to stay available while the game server is being rebuilt or +changed. + +On the TrueNAS host, run the auth process against the same project data folder: + +```sh +npm ci +npm run db:init +AUTH_HOST=127.0.0.1 AUTH_PORT=4174 TRUST_PROXY=1 COOKIE_SECURE=1 AUTH_CORS_ORIGINS=https://phenomrom.com npm run auth:start +``` + +Point `auth.phenomrom.com` at that process through HTTPS: + +```caddyfile +auth.phenomrom.com { + reverse_proxy 127.0.0.1:4174 +} +``` + +Build the web or mobile app with the auth base URL set separately from the game +API: + +```sh +VITE_AUTH_API_BASE_URL=https://auth.phenomrom.com npm run build +``` + +For a Capacitor wrapper, set `window.CAPACITOR_AUTH_API_BASE_URL` to +`https://auth.phenomrom.com` the same way `window.CAPACITOR_API_BASE_URL` is set. +The app stores the returned bearer token locally and sends it with later API +requests, so auth works across subdomains and inside the mobile WebView. Existing +same-origin cookie sessions still work when auth is served by the game server. + ## Account limits Registration permits one account per public IP by default. Login and API rate diff --git a/IWantToHeal-Thor-v1.0.22.apk b/IWantToHeal-Thor-v1.0.22.apk new file mode 100644 index 0000000..28cb367 Binary files /dev/null and b/IWantToHeal-Thor-v1.0.22.apk differ diff --git a/android/app/build.gradle b/android/app/build.gradle index 91958b6..dc5f52e 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -7,8 +7,8 @@ android { applicationId "com.warren.iwanttoheal" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 32 - versionName "1.0.21" + versionCode 39 + versionName "1.0.23" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/db/seed.sql b/db/seed.sql index b45122c..38dba07 100644 --- a/db/seed.sql +++ b/db/seed.sql @@ -429,6 +429,168 @@ INSERT OR IGNORE INTO character_inventory (character_id, item_id, quantity, equi (3, 100, 1, 1), (3, 101, 1, 1), (3, 102, 1, 1), (3, 103, 1, 1), (3, 104, 1, 1), (3, 108, 1, 1), (3, 105, 1, 1), (3, 109, 1, 1), (3, 106, 1, 1), (3, 107, 1, 0); +-- Coin gearing override: every boss/difficulty drops one boss coin, and each +-- craft costs the target item level in that source boss coin. +UPDATE crafting_recipes +SET source_dungeon_id = 1, + source_encounter_id = 3 +WHERE id BETWEEN 1001 AND 1409 + AND item_id IN (SELECT id FROM items WHERE slot IN ('helmet', 'chest', 'gloves')); + +UPDATE crafting_recipes +SET source_dungeon_id = 1, + source_encounter_id = 12 +WHERE id BETWEEN 1001 AND 1409 + AND item_id IN (SELECT id FROM items WHERE slot IN ('boots', 'ring', 'trinket')); + +UPDATE crafting_recipes +SET source_dungeon_id = 1, + source_encounter_id = 22 +WHERE id BETWEEN 1001 AND 1409 + AND item_id IN (SELECT id FROM items WHERE slot IN ('weapon', 'pants', 'necklace')); + +UPDATE crafting_recipes +SET difficulty_id = CASE + (SELECT item_level FROM items WHERE items.id = crafting_recipes.item_id) + WHEN 5 THEN 1 + WHEN 10 THEN 2 + WHEN 15 THEN 3 + WHEN 20 THEN 4 + WHEN 25 THEN 5 + ELSE difficulty_id + END +WHERE id BETWEEN 1001 AND 1409; + +UPDATE items +SET rarity = CASE item_level + WHEN 5 THEN 'common' + WHEN 10 THEN 'uncommon' + WHEN 15 THEN 'rare' + WHEN 20 THEN 'epic' + WHEN 25 THEN 'legendary' + ELSE rarity + END +WHERE id IN (SELECT item_id FROM crafting_recipes); + +UPDATE items +SET name = ( + SELECT + CASE items.item_level + WHEN 5 THEN '' + WHEN 10 THEN 'Green ' + WHEN 15 THEN 'Blue ' + WHEN 20 THEN 'Purple ' + WHEN 25 THEN 'Orange ' + ELSE '' + END + || encounters.name || ' ' + || CASE items.slot + WHEN 'weapon' THEN 'Weapon' + WHEN 'helmet' THEN 'Helmet' + WHEN 'chest' THEN 'Chest' + WHEN 'gloves' THEN 'Gloves' + WHEN 'boots' THEN 'Boots' + WHEN 'pants' THEN 'Pants' + WHEN 'ring' THEN 'Ring' + WHEN 'necklace' THEN 'Necklace' + WHEN 'trinket' THEN 'Trinket' + ELSE items.name + END + FROM crafting_recipes + JOIN encounters ON encounters.id = crafting_recipes.source_encounter_id + WHERE crafting_recipes.item_id = items.id + LIMIT 1 + ), + description = ( + SELECT 'Crafted with ' || encounters.name || ' coins.' + FROM crafting_recipes + JOIN encounters ON encounters.id = crafting_recipes.source_encounter_id + WHERE crafting_recipes.item_id = items.id + LIMIT 1 + ) +WHERE id IN (SELECT item_id FROM crafting_recipes); + +CREATE TEMP TABLE IF NOT EXISTS coin_sources ( + item_id INTEGER PRIMARY KEY, + encounter_id INTEGER NOT NULL, + difficulty_id INTEGER NOT NULL, + item_level INTEGER NOT NULL, + slug TEXT NOT NULL, + name TEXT NOT NULL, + rarity TEXT NOT NULL, + glyph TEXT NOT NULL, + description TEXT NOT NULL +); + +DELETE FROM coin_sources; + +INSERT INTO coin_sources +SELECT + 280000 + encounters.id * 100 + difficulties.dropped_item_level, + encounters.id, + difficulties.id, + difficulties.dropped_item_level, + encounters.slug || '-coin-ilvl-' || difficulties.dropped_item_level, + CASE difficulties.dropped_item_level + WHEN 5 THEN '' + WHEN 10 THEN 'Green ' + WHEN 15 THEN 'Blue ' + WHEN 20 THEN 'Purple ' + WHEN 25 THEN 'Orange ' + ELSE '' + END || encounters.name || ' Coin', + CASE difficulties.dropped_item_level + WHEN 5 THEN 'common' + WHEN 10 THEN 'uncommon' + WHEN 15 THEN 'rare' + WHEN 20 THEN 'epic' + WHEN 25 THEN 'legendary' + ELSE 'common' + END, + '$', + 'A boss coin from ' || encounters.name || ' used for item level ' + || difficulties.dropped_item_level || ' crafting.' +FROM encounters +JOIN dungeon_difficulties ON dungeon_difficulties.dungeon_id = encounters.dungeon_id +JOIN difficulties ON difficulties.id = dungeon_difficulties.difficulty_id +WHERE encounters.encounter_type = 'boss'; + +INSERT OR IGNORE INTO items + (id, slug, name, slot, rarity, item_level, healing_power, max_resource_bonus, glyph, description) +SELECT item_id, slug, name, 'component', rarity, item_level, 0, 0, glyph, description +FROM coin_sources; + +UPDATE items +SET slug = (SELECT slug FROM coin_sources WHERE coin_sources.item_id = items.id), + name = (SELECT name FROM coin_sources WHERE coin_sources.item_id = items.id), + slot = 'component', + rarity = (SELECT rarity FROM coin_sources WHERE coin_sources.item_id = items.id), + item_level = (SELECT item_level FROM coin_sources WHERE coin_sources.item_id = items.id), + healing_power = 0, + max_resource_bonus = 0, + glyph = (SELECT glyph FROM coin_sources WHERE coin_sources.item_id = items.id), + description = (SELECT description FROM coin_sources WHERE coin_sources.item_id = items.id) +WHERE id IN (SELECT item_id FROM coin_sources); + +DELETE FROM encounter_loot; + +INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) +SELECT encounter_id, item_id, difficulty_id, 100, 1.0 +FROM coin_sources; + +DELETE FROM crafting_recipe_components; + +INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) +SELECT + crafting_recipes.id, + coin_sources.item_id, + items.item_level +FROM crafting_recipes +JOIN items ON items.id = crafting_recipes.item_id +JOIN coin_sources + ON coin_sources.encounter_id = crafting_recipes.source_encounter_id + AND coin_sources.difficulty_id = crafting_recipes.difficulty_id; + INSERT OR IGNORE INTO talents (id, class_id, slug, name, max_rank, tier, branch, prerequisite_talent_id, prerequisite_rank, effect_type, effect_value_per_rank, glyph, description) VALUES @@ -678,9 +840,9 @@ SET slug = CASE id END, encounter_type = CASE WHEN id IN (102, 105, 108) THEN 'boss' ELSE 'trash' END, description = CASE id - WHEN 102 THEN 'Tigrex drops monster parts for item level 10 crafting.' - WHEN 105 THEN 'Rathalos drops monster parts for item level 10 crafting.' - WHEN 108 THEN 'Gypceros drops monster parts for item level 10 crafting.' + WHEN 102 THEN 'Tigrex drops boss coins for item level 10 crafting.' + WHEN 105 THEN 'Rathalos drops boss coins for item level 10 crafting.' + WHEN 108 THEN 'Gypceros drops boss coins for item level 10 crafting.' ELSE 'Hunters clear the raid path.' END WHERE id BETWEEN 100 AND 108; @@ -702,7 +864,7 @@ SELECT offset.tank_damage + generated_bosses.boss_index * 2 + generated_loot_tiers.item_level / 8, offset.party_damage + generated_bosses.boss_index * 3, CASE offset.encounter_type - WHEN 'boss' THEN generated_bosses.boss_name || ' drops monster parts for item level ' || generated_loot_tiers.item_level || ' crafting.' + WHEN 'boss' THEN generated_bosses.boss_name || ' drops boss coins for item level ' || generated_loot_tiers.item_level || ' crafting.' ELSE 'Hunters clear the path before ' || generated_bosses.boss_name || '.' END FROM generated_loot_tiers @@ -730,7 +892,7 @@ SELECT offset.tank_damage + generated_bosses.boss_index * 2 + generated_loot_tiers.item_level / 8, offset.party_damage + generated_bosses.boss_index * 3 + 24, CASE offset.encounter_type - WHEN 'boss' THEN generated_bosses.boss_name || ' drops monster parts for item level ' || generated_loot_tiers.item_level || ' crafting.' + WHEN 'boss' THEN generated_bosses.boss_name || ' drops boss coins for item level ' || generated_loot_tiers.item_level || ' crafting.' ELSE 'Hunters clear the raid path before ' || generated_bosses.boss_name || '.' END FROM generated_loot_tiers @@ -1011,3 +1173,152 @@ INSERT OR IGNORE INTO crafting_recipe_components (recipe_id, item_id, quantity) (1007, 868, 5), (1007, 869, 3), (1007, 871, 1), (1008, 868, 5), (1008, 869, 3), (1008, 871, 1), (1009, 868, 5), (1009, 869, 3), (1009, 871, 1); + +-- Final coin gearing override. Keep this after legacy loot edits. +UPDATE crafting_recipes +SET source_dungeon_id = 1, + source_encounter_id = 3 +WHERE id BETWEEN 1001 AND 1409 + AND item_id IN (SELECT id FROM items WHERE slot IN ('helmet', 'chest', 'gloves')); + +UPDATE crafting_recipes +SET source_dungeon_id = 1, + source_encounter_id = 12 +WHERE id BETWEEN 1001 AND 1409 + AND item_id IN (SELECT id FROM items WHERE slot IN ('boots', 'ring', 'trinket')); + +UPDATE crafting_recipes +SET source_dungeon_id = 1, + source_encounter_id = 22 +WHERE id BETWEEN 1001 AND 1409 + AND item_id IN (SELECT id FROM items WHERE slot IN ('weapon', 'pants', 'necklace')); + +UPDATE crafting_recipes +SET difficulty_id = CASE + (SELECT item_level FROM items WHERE items.id = crafting_recipes.item_id) + WHEN 5 THEN 1 + WHEN 10 THEN 2 + WHEN 15 THEN 3 + WHEN 20 THEN 4 + WHEN 25 THEN 5 + ELSE difficulty_id + END +WHERE id BETWEEN 1001 AND 1409; + +UPDATE items +SET rarity = CASE item_level + WHEN 5 THEN 'common' + WHEN 10 THEN 'uncommon' + WHEN 15 THEN 'rare' + WHEN 20 THEN 'epic' + WHEN 25 THEN 'legendary' + ELSE rarity + END +WHERE id IN (SELECT item_id FROM crafting_recipes); + +UPDATE items +SET name = ( + SELECT + CASE items.item_level + WHEN 5 THEN '' + WHEN 10 THEN 'Green ' + WHEN 15 THEN 'Blue ' + WHEN 20 THEN 'Purple ' + WHEN 25 THEN 'Orange ' + ELSE '' + END + || encounters.name || ' ' + || CASE items.slot + WHEN 'weapon' THEN 'Weapon' + WHEN 'helmet' THEN 'Helmet' + WHEN 'chest' THEN 'Chest' + WHEN 'gloves' THEN 'Gloves' + WHEN 'boots' THEN 'Boots' + WHEN 'pants' THEN 'Pants' + WHEN 'ring' THEN 'Ring' + WHEN 'necklace' THEN 'Necklace' + WHEN 'trinket' THEN 'Trinket' + ELSE items.name + END + FROM crafting_recipes + JOIN encounters ON encounters.id = crafting_recipes.source_encounter_id + WHERE crafting_recipes.item_id = items.id + LIMIT 1 + ), + description = ( + SELECT 'Crafted with ' || encounters.name || ' coins.' + FROM crafting_recipes + JOIN encounters ON encounters.id = crafting_recipes.source_encounter_id + WHERE crafting_recipes.item_id = items.id + LIMIT 1 + ) +WHERE id IN (SELECT item_id FROM crafting_recipes); + +DELETE FROM coin_sources; + +INSERT INTO coin_sources +SELECT + 280000 + encounters.id * 100 + difficulties.dropped_item_level, + encounters.id, + difficulties.id, + difficulties.dropped_item_level, + encounters.slug || '-coin-ilvl-' || difficulties.dropped_item_level, + CASE difficulties.dropped_item_level + WHEN 5 THEN '' + WHEN 10 THEN 'Green ' + WHEN 15 THEN 'Blue ' + WHEN 20 THEN 'Purple ' + WHEN 25 THEN 'Orange ' + ELSE '' + END || encounters.name || ' Coin', + CASE difficulties.dropped_item_level + WHEN 5 THEN 'common' + WHEN 10 THEN 'uncommon' + WHEN 15 THEN 'rare' + WHEN 20 THEN 'epic' + WHEN 25 THEN 'legendary' + ELSE 'common' + END, + '$', + 'A boss coin from ' || encounters.name || ' used for item level ' + || difficulties.dropped_item_level || ' crafting.' +FROM encounters +JOIN dungeon_difficulties ON dungeon_difficulties.dungeon_id = encounters.dungeon_id +JOIN difficulties ON difficulties.id = dungeon_difficulties.difficulty_id +WHERE encounters.encounter_type = 'boss'; + +INSERT OR IGNORE INTO items + (id, slug, name, slot, rarity, item_level, healing_power, max_resource_bonus, glyph, description) +SELECT item_id, slug, name, 'component', rarity, item_level, 0, 0, glyph, description +FROM coin_sources; + +UPDATE items +SET slug = (SELECT slug FROM coin_sources WHERE coin_sources.item_id = items.id), + name = (SELECT name FROM coin_sources WHERE coin_sources.item_id = items.id), + slot = 'component', + rarity = (SELECT rarity FROM coin_sources WHERE coin_sources.item_id = items.id), + item_level = (SELECT item_level FROM coin_sources WHERE coin_sources.item_id = items.id), + healing_power = 0, + max_resource_bonus = 0, + glyph = (SELECT glyph FROM coin_sources WHERE coin_sources.item_id = items.id), + description = (SELECT description FROM coin_sources WHERE coin_sources.item_id = items.id) +WHERE id IN (SELECT item_id FROM coin_sources); + +DELETE FROM encounter_loot; + +INSERT INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance) +SELECT encounter_id, item_id, difficulty_id, 100, 1.0 +FROM coin_sources; + +DELETE FROM crafting_recipe_components; + +INSERT INTO crafting_recipe_components (recipe_id, item_id, quantity) +SELECT + crafting_recipes.id, + coin_sources.item_id, + items.item_level +FROM crafting_recipes +JOIN items ON items.id = crafting_recipes.item_id +JOIN coin_sources + ON coin_sources.encounter_id = crafting_recipes.source_encounter_id + AND coin_sources.difficulty_id = crafting_recipes.difficulty_id; diff --git a/docs/gearing-system.md b/docs/gearing-system.md new file mode 100644 index 0000000..7d41c47 --- /dev/null +++ b/docs/gearing-system.md @@ -0,0 +1,172 @@ +# Gearing System + +## Goal + +Gearing should move from boss-specific multi-item drop tables to one clear currency loop: + +1. Kill bosses. +2. Earn boss coins. +3. Craft gear with those coins. +4. Upgrade that boss gear into the next item-level tier with higher-rarity coins. + +This keeps boss loot readable, removes low-percentage frustration, and makes every boss kill progress a targeted gear goal. + +## Coin Tiers + +Coins are component items. Each coin is tied to a boss source and an item-level tier. + +| Item Level | Display Color | Rarity Key | Example | +| --- | --- | --- | --- | +| 5 | White | common | Bulldrome Coin | +| 10 | Green | uncommon | Green Bulldrome Coin | +| 15 | Blue | rare | Blue Bulldrome Coin | +| 20 | Purple | epic | Purple Bulldrome Coin | +| 25 | Orange | legendary | Orange Bulldrome Coin | + +Implementation note: the current TypeScript rarity union supports `common`, `uncommon`, `rare`, and `epic`. Orange needs a new rarity key, recommended as `legendary`, plus UI color styling. + +## Boss Loot + +Each boss has one loot roll. + +For now, each successful boss loot roll awards 1 to 3 coins: + +| Roll Result | Coins Awarded | +| --- | --- | +| Low roll | 1 coin | +| Normal roll | 2 coins | +| High roll | 3 coins | + +Recommended weighting: + +| Coins | Chance | +| --- | --- | +| 1 | 50% | +| 2 | 35% | +| 3 | 15% | + +The coin source comes from the defeated boss. Bulldrome drops Bulldrome coins, Rathian drops Rathian coins, and so on. + +The coin tier comes from content difficulty or roguelike depth: + +| Source | Coin Tier | +| --- | --- | +| Item level 5 content | White level 5 coins | +| Item level 10 content | Green level 10 coins | +| Item level 15 content | Blue level 15 coins | +| Item level 20 content | Purple level 20 coins | +| Item level 25 content | Orange level 25 coins | + +## Crafting Costs + +Gear is crafted with boss coins from the same boss and item-level tier. + +| Gear Item Level | Cost | +| --- | --- | +| 5 | 5 white boss coins | +| 10 | 10 green boss coins | +| 15 | 15 blue boss coins | +| 20 | 20 purple boss coins | +| 25 | 25 orange boss coins | + +Example: + +- Bulldrome item-level 5 helmet costs 5 white Bulldrome coins. +- Bulldrome item-level 10 helmet costs 10 green Bulldrome coins. +- Rathian item-level 20 gloves cost 20 purple Rathian coins. + +## Gear Upgrades + +Crafting can create gear directly, but upgrades should become the preferred long-term path. + +Upgrade rule: + +- Existing boss gear upgrades into the next item-level version of the same boss gear. +- Upgrade cost uses coins from the next tier. +- Required coin quantity equals the target item level. + +Examples: + +| Upgrade | Cost | +| --- | --- | +| Bulldrome item level 5 gear -> Bulldrome item level 10 gear | 10 green Bulldrome coins | +| Bulldrome item level 10 gear -> Bulldrome item level 15 gear | 15 blue Bulldrome coins | +| Bulldrome item level 15 gear -> Bulldrome item level 20 gear | 20 purple Bulldrome coins | +| Bulldrome item level 20 gear -> Bulldrome item level 25 gear | 25 orange Bulldrome coins | + +Upgrade should consume the old item and award the upgraded item. This avoids duplicate clutter and keeps equipment identity clear. + +## Roguelike Loot + +Roguelike bosses should award coins when defeated, using the same 1 to 3 coin roll. + +Roguelike coin tier should scale by wave band: + +| Waves | Coin Tier | +| --- | --- | +| 1-4 | Level 5 white coins | +| 5-9 | Level 10 green coins | +| 10-14 | Level 15 blue coins | +| 15-19 | Level 20 purple coins | +| 20+ | Level 25 orange coins | + +Boss identity can be handled two ways: + +1. Boss-based coins: use the actual boss template selected for that roguelike boss. +2. Roguelike coins: use a generic roguelike coin per tier. + +Recommended first pass: boss-based coins. It reuses the same crafting economy as dungeons and makes roguelike runs feel connected to the main gear chase. + +## Roguelike Checkpoints + +Checkpoints should unlock every 5 waves. + +| Highest Cleared Wave | Future Start Wave | +| --- | --- | +| 0-4 | 1 | +| 5-9 | 5 | +| 10-14 | 10 | +| 15-19 | 15 | +| 20+ | Highest unlocked 5-wave checkpoint | + +Checkpoint rule: + +- Unlock a checkpoint after clearing its boss band. +- Starting from a checkpoint begins at that wave band with matching coin tier. +- Runs should still record leaderboard progress from the selected start wave so full runs and checkpoint runs can be ranked separately later. + +Current implementation note: the roguelike screen always starts at stage 1 and only awards XP per boss. Checkpoints need saved character progress and a start-wave selector. + +## Current Code Fit + +The existing system already has most of the required foundation: + +- `items.slot = 'component'` can represent coins. +- `character_inventory.quantity` already stacks components. +- `crafting_recipes` and `crafting_recipe_components` already support coin costs. +- `encounter_loot_rolls` and `encounter_loot_roll_items` already persist retry-safe loot awards. +- `completeRoguelike` is already called after each roguelike boss kill for XP, so coin awards can attach to that same flow. + +Needed changes: + +- Replace current 4-component boss drop tables with one boss coin per boss per tier. +- Change boss loot roll count from multiple chance slots to one 1-3 coin roll. +- Add orange/legendary rarity support. +- Add upgrade recipes or a dedicated upgrade endpoint. +- Add roguelike boss coin awards. +- Add roguelike checkpoint persistence and start-wave selection. +- Export updated offline starter data after seed changes. + +## Suggestions + +Use guaranteed coin drops for now. One to three coins per boss gives steady progress and makes craft timing easy to understand. + +Keep coins boss-specific, not slot-specific. Slot-specific components add complexity without much decision value. + +Use upgrade-first UI. Show the next upgrade for equipped gear before showing the full crafting catalog. + +Keep direct crafting and upgrading at the same coin cost for the target tier. Direct crafting helps new slots catch up; upgrading preserves boss gear identity. + +Add a pity floor only if needed later. If boss kills always award coins, the system already has deterministic progress. + +Use one orange rarity key: `legendary`. Avoid storing display color names as rarity values; colors can change without data migration. diff --git a/package.json b/package.json index f7372ce..da501ea 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "offline:export": "node scripts/export-offline-profile.mjs", "lint": "eslint .", "admin:start": "node server/admin.mjs", + "auth:start": "node server/auth.mjs", "start": "node server/production.mjs", "prepreview": "npm run db:init", "preview": "vite preview" diff --git a/server/auth.mjs b/server/auth.mjs new file mode 100644 index 0000000..16ae23d --- /dev/null +++ b/server/auth.mjs @@ -0,0 +1,18 @@ +import { createServer } from 'node:http' +import { handleAuthApiRequest } from './game-api.mjs' + +process.env.NODE_ENV = process.env.NODE_ENV ?? 'production' +process.env.CORS_ORIGINS = process.env.CORS_ORIGINS + ?? process.env.AUTH_CORS_ORIGINS + ?? '*' + +const host = process.env.AUTH_HOST ?? process.env.HOST ?? '127.0.0.1' +const port = Number(process.env.AUTH_PORT ?? process.env.PORT ?? 4174) + +const server = createServer((request, response) => { + handleAuthApiRequest(request, response) +}) + +server.listen(port, host, () => { + console.log(`I want to Heal auth listening on http://${host}:${port}`) +}) diff --git a/server/game-api.mjs b/server/game-api.mjs index 0e982f1..ff4aab8 100644 --- a/server/game-api.mjs +++ b/server/game-api.mjs @@ -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) diff --git a/src/App.css b/src/App.css index e6dfabb..ce7fef9 100644 --- a/src/App.css +++ b/src/App.css @@ -2973,6 +2973,10 @@ h2 { --rarity-color: #b584e3; } +.rarity-legendary { + --rarity-color: #f2a13a; +} + .rarity-common { --rarity-color: #a8a3ad; } diff --git a/src/App.tsx b/src/App.tsx index fac5965..aba9ce6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -287,7 +287,6 @@ function App() { { part: 2, name: `${sectionName} 2`, encounterCount: 3, unlocked: completedSections >= 1 }, { part: 3, name: `${sectionName} 3`, encounterCount: 3, unlocked: completedSections >= 2 }, ] - const lootChanceSlots = activity.contentType === 'raid' ? 8 : 5 const cloudSync = getCloudSyncStatus() const canShowCloudSync = account.id !== -1 && cloudSync.available const lootPreviewEncounters = [...activity.encounters] @@ -682,7 +681,7 @@ function App() {

- Bosses roll {lootChanceSlots} loot chances against the listed table, 1-3 materials each + Bosses drop 1-3 boss coins from one loot roll {activity.completionItemLevel ? ` - full clear guarantees iLvl ${activity.completionItemLevel}` : ''} @@ -702,7 +701,7 @@ function App() { )}

{encounter.enemyName} - {loot.length > 0 ? `${lootChanceSlots} weighted chances, 1-3 each` : 'No component table'} + {loot.length > 0 ? '1 loot roll, 1-3 coins' : 'No coin table'}
diff --git a/src/components/AdminScreen.tsx b/src/components/AdminScreen.tsx index da30de1..f6f6463 100644 --- a/src/components/AdminScreen.tsx +++ b/src/components/AdminScreen.tsx @@ -183,7 +183,7 @@ function ItemsTab({ data, setData, setSaving, saving }: {
diff --git a/src/components/EquipmentScreen.tsx b/src/components/EquipmentScreen.tsx index bf84443..60f8913 100644 --- a/src/components/EquipmentScreen.tsx +++ b/src/components/EquipmentScreen.tsx @@ -4,6 +4,7 @@ import { craftItem, equipItem, loadProfile, + upgradeItem, type CharacterProfile, type EquipmentSlot, type Item, @@ -46,6 +47,7 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false } const [equipping, setEquipping] = useState(false) const [breakingDown, setBreakingDown] = useState(false) const [crafting, setCrafting] = useState(false) + const [upgrading, setUpgrading] = useState(false) const [showSetBonuses, setShowSetBonuses] = useState(false) const [equipmentTab, setEquipmentTab] = useState<'equipment' | 'crafting'>('equipment') const [inventoryPage, setInventoryPage] = useState(0) @@ -59,6 +61,16 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false } firstRecipe?.id ?? null, ) const selectedRecipe = profile.craftingRecipes.find((recipe) => recipe.id === selectedRecipeId) + const selectedItemRecipe = selectedItem + ? profile.craftingRecipes.find((recipe) => recipe.item.id === selectedItem.id) + : undefined + const upgradeRecipe = selectedItem && selectedItemRecipe + ? profile.craftingRecipes.find((recipe) => + recipe.sourceEncounterId === selectedItemRecipe.sourceEncounterId + && recipe.item.slot === selectedItem.slot + && recipe.item.itemLevel === selectedItem.itemLevel + 5, + ) + : undefined const equippedBySlot = useMemo( () => new Map( profile.inventory @@ -189,6 +201,23 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false } } } + async function upgradeSelected() { + if (!selectedItem || !upgradeRecipe) return + saveScroll() + setUpgrading(true) + setMessage('') + try { + const updated = await upgradeItem(selectedItem.id) + onUpdated(updated) + setSelectedItemId(upgradeRecipe.item.id) + setMessage(`${selectedItem.name} upgraded to ${upgradeRecipe.item.name}.`) + } catch (reason) { + setMessage(reason instanceof Error ? reason.message : 'Unable to upgrade item.') + } finally { + setUpgrading(false) + } + } + const content = ( <> {!embedded && ( @@ -259,16 +288,26 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false } + {upgradeRecipe && ( + + )} {(!selectedItem.equipped || selectedItem.quantity > 1) && (