This commit is contained in:
Warren H
2026-06-18 22:28:04 -04:00
parent a604569a2f
commit 3a8d5ad8c5
19 changed files with 3047 additions and 5930 deletions
+35
View File
@@ -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
Binary file not shown.
+2 -2
View File
@@ -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.
+316 -5
View File
@@ -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;
+172
View File
@@ -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.
+1
View File
@@ -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"
+18
View File
@@ -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}`)
})
+317 -60
View File
@@ -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)
+4
View File
@@ -2973,6 +2973,10 @@ h2 {
--rarity-color: #b584e3;
}
.rarity-legendary {
--rarity-color: #f2a13a;
}
.rarity-common {
--rarity-color: #a8a3ad;
}
+2 -3
View File
@@ -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() {
</label>
</div>
<p className="section-note">
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() {
)}
<div>
<strong>{encounter.enemyName}</strong>
<small>{loot.length > 0 ? `${lootChanceSlots} weighted chances, 1-3 each` : 'No component table'}</small>
<small>{loot.length > 0 ? '1 loot roll, 1-3 coins' : 'No coin table'}</small>
</div>
</div>
<div className="loot-items">
+1 -1
View File
@@ -183,7 +183,7 @@ function ItemsTab({ data, setData, setSaving, saving }: {
</label>
<label>Rarity
<select value={form.rarity ?? item.rarity} onChange={(e) => setForm({ ...form, rarity: e.target.value })}>
{['common', 'uncommon', 'rare', 'epic'].map((r) => (
{['common', 'uncommon', 'rare', 'epic', 'legendary'].map((r) => (
<option key={r} value={r}>{r}</option>
))}
</select>
+1 -1
View File
@@ -1321,7 +1321,7 @@ export function CombatScreen({
<div className="bonus-item-detail">
<span>{reward.bonusItem.glyph}</span>
<strong className={`rarity-${reward.bonusItem.rarity}`}>{reward.bonusItem.name}</strong>
<small>Item Level {reward.bonusItem.itemLevel}</small>
<small>Item Level {reward.bonusItem.itemLevel} x{reward.bonusItem.quantity}</small>
{reward.bonusItem.duplicate && <small> (owned x{reward.bonusItem.quantityAfter})</small>}
</div>
</div>
+41 -2
View File
@@ -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 }
<ComparisonDelta selected={selectedItem} equipped={comparisonItem} />
<button
className="primary-button"
disabled={selectedItem.equipped || equipping || breakingDown}
disabled={selectedItem.equipped || equipping || breakingDown || upgrading}
onClick={equipSelected}
type="button"
>
{selectedItem.equipped ? 'Equipped' : equipping ? 'Equipping...' : 'Equip Item'}
</button>
{upgradeRecipe && (
<button
className="primary-button"
disabled={!upgradeRecipe.canCraft || equipping || breakingDown || upgrading}
onClick={upgradeSelected}
type="button"
>
{upgrading ? 'Upgrading...' : `Upgrade to iLvl ${upgradeRecipe.item.itemLevel}`}
</button>
)}
{(!selectedItem.equipped || selectedItem.quantity > 1) && (
<button
className="breakdown-button"
disabled={equipping || breakingDown}
disabled={equipping || breakingDown || upgrading}
onClick={breakdownSelected}
type="button"
>
+55 -14
View File
@@ -12,8 +12,10 @@ import {
type DualScreenCombatState,
} from '../dualScreen'
import {
loadPvpRoguelikeCheckpoint,
randomCpuDifficulty,
recordCpuPvpLeaderboard,
recordPvpRoguelikeCheckpoint,
type CpuDifficulty,
type PvpContentType,
} from '../pvpRoguelike'
@@ -29,6 +31,7 @@ type BossMechanic =
type PvpEncounter = DungeonEncounter & {
bossMechanics?: BossMechanic[]
sourceEncounterId?: number
}
type SlotKey = '1' | '2' | '3' | '4' | '5'
@@ -261,6 +264,7 @@ function buildEncounterSegment(pool: DungeonEncounter[], stage: number, kind: Pv
const isBoss = index === 2
return {
...encounter,
sourceEncounterId: encounter.id,
id: 910000 + stage * 10 + index,
sequence: (stage - 1) * 3 + index + 1,
isBoss,
@@ -381,6 +385,10 @@ export function PvPRoguelikeScreen({
() => buildOpponentDebuffChoices(starterSpells, abilityLabelMode),
[abilityLabelMode, starterSpells],
)
const [checkpointStage, setCheckpointStage] = useState(() =>
loadPvpRoguelikeCheckpoint(profile.character.id, contentType),
)
const [startStage, setStartStage] = useState(checkpointStage)
const maxResource = gameClass.maxResource
const partyTemplate = useMemo(
() => (contentType === 'raid' ? RAID_PARTY : INITIAL_PARTY).map((member) => ({
@@ -397,8 +405,8 @@ export function PvPRoguelikeScreen({
[contentType],
)
const [status, setStatus] = useState<'queueing' | 'playing' | 'upgrade-choice' | 'won' | 'lost'>('queueing')
const [stage, setStage] = useState(1)
const [encounters, setEncounters] = useState<PvpEncounter[]>(() => buildEncounterSegment(encounterPool, 1, contentType))
const [stage, setStage] = useState(startStage)
const [encounters, setEncounters] = useState<PvpEncounter[]>(() => buildEncounterSegment(encounterPool, startStage, contentType))
const [encounterIndex, setEncounterIndex] = useState(0)
const [playerSide, setPlayerSide] = useState<SideState>(() => starterSide(partyTemplate, maxResource))
const [cpuSide, setCpuSide] = useState<SideState>(() => starterSide(cpuPartyTemplate, maxResource))
@@ -464,9 +472,16 @@ export function PvPRoguelikeScreen({
}, 900)
}, [])
useEffect(() => {
const loadedCheckpoint = loadPvpRoguelikeCheckpoint(profile.character.id, contentType)
setCheckpointStage(loadedCheckpoint)
setStartStage(loadedCheckpoint)
}, [contentType, profile.character.id])
const awardBossReward = useCallback((encounterIndexValue: number) => {
if (bossRewardClaimedRef.current.has(encounterIndexValue)) return
bossRewardClaimedRef.current.add(encounterIndexValue)
const rewardEncounter = encounters[encounterIndexValue]
completeRoguelike(
rewardDungeon.id,
rewardDifficulty.id,
@@ -476,18 +491,26 @@ export function PvPRoguelikeScreen({
{
bossesCleared: 1,
experienceMode: 'pvp-boss-quarter-level',
lootSourceEncounterId: rewardEncounter?.sourceEncounterId,
roguelikeStage: stage,
},
)
.then((result) => {
setReward(result)
onProfileUpdated(result.profile)
if (result.bonusItem) {
addLog(
`${result.bonusItem.name} x${result.bonusItem.quantity} awarded.`,
'loot',
)
}
})
.catch((reason: unknown) => {
setRewardError(
reason instanceof Error ? reason.message : 'Unable to award roguelike experience.',
)
})
}, [elapsedTicks, onProfileUpdated, rewardDifficulty.id, rewardDungeon.id])
}, [addLog, elapsedTicks, encounters, onProfileUpdated, rewardDifficulty.id, rewardDungeon.id, stage])
const finishRoguelikeRun = useCallback(() => {
if (rewardClaimedRef.current) return
@@ -510,7 +533,7 @@ export function PvPRoguelikeScreen({
}, [opponentDebuffChoicesCatalog, selfBuffChoicesCatalog])
useEffect(() => {
const firstSegment = buildEncounterSegment(encounterPool, 1, contentType)
const firstSegment = buildEncounterSegment(encounterPool, startStage, contentType)
const firstEncounter = firstSegment[0]
const basePlayer = starterSide(partyTemplate, maxResource)
const baseCpu = starterSide(cpuPartyTemplate, maxResource)
@@ -523,7 +546,7 @@ export function PvPRoguelikeScreen({
bossRewardClaimedRef.current = new Set()
setEncounters(firstSegment)
setEncounterIndex(0)
setStage(1)
setStage(startStage)
setElapsedTicks(0)
setStatus('queueing')
setPlayerSide(basePlayer)
@@ -546,26 +569,26 @@ export function PvPRoguelikeScreen({
cpuDefeatedRef.current = false
if (gameMode === 'offline') {
const randomCpu = randomCpuDifficulty()
setQueueMessage(`Offline mode. CPU ${randomCpu} enters immediately.`)
setQueueMessage(`Offline mode. CPU ${randomCpu} enters at stage ${startStage}.`)
setCpuDifficulty(randomCpu)
setLog([{ id: 1, text: `Offline mode. CPU ${randomCpu} enters immediately.`, tone: 'system' }])
setLog([{ id: 1, text: `Offline mode. CPU ${randomCpu} enters at stage ${startStage}.`, tone: 'system' }])
const timer = window.setTimeout(() => {
setStatus('playing')
addLog(`Round 1 begins against CPU ${randomCpu}.`, 'system')
addLog(`Stage ${startStage} begins against CPU ${randomCpu}.`, 'system')
}, 500)
return () => window.clearTimeout(timer)
}
setQueueMessage('Searching queue. No player found yet.')
setLog([{ id: 1, text: 'Searching queue. No player found yet.', tone: 'system' }])
setQueueMessage(`Searching queue. Stage ${startStage} start ready.`)
setLog([{ id: 1, text: `Searching queue. Stage ${startStage} start ready.`, tone: 'system' }])
const timer = window.setTimeout(() => {
const randomCpu = randomCpuDifficulty()
setCpuDifficulty(randomCpu)
setQueueMessage(`No queued player found. CPU ${randomCpu} steps in.`)
setStatus('playing')
addLog(`No queued player found. CPU ${randomCpu} steps in.`, 'system')
addLog(`No queued player found. CPU ${randomCpu} steps in at stage ${startStage}.`, 'system')
}, 1400)
return () => window.clearTimeout(timer)
}, [addLog, contentType, cpuPartyTemplate, encounterPool, gameMode, maxResource, partyTemplate])
}, [addLog, contentType, cpuPartyTemplate, encounterPool, gameMode, maxResource, partyTemplate, startStage])
const applySpell = useCallback((
current: SideState,
@@ -841,7 +864,18 @@ export function PvPRoguelikeScreen({
if (nextPlayer.enemyHealth <= 0 && playerClearedEncounterRef.current !== encounterIndex) {
playerClearedEncounterRef.current = encounterIndex
setEncountersCleared((value) => value + 1)
if (encounter.isBoss) awardBossReward(encounterIndex)
if (encounter.isBoss) {
awardBossReward(encounterIndex)
const nextCheckpoint = recordPvpRoguelikeCheckpoint(
profile.character.id,
contentType,
stage,
)
if (nextCheckpoint > checkpointStage) {
setCheckpointStage(nextCheckpoint)
addLog(`Stage ${nextCheckpoint} checkpoint unlocked.`, 'loot')
}
}
}
playerRef.current = nextPlayer
cpuRef.current = nextCpu
@@ -866,7 +900,7 @@ export function PvPRoguelikeScreen({
}
}, TICK_MS)
return () => window.clearInterval(timer)
}, [addLog, advanceSide, awardBossReward, beginUpgradePhase, cpuDifficulty, cpuTakeTurn, encounter, encounterIndex, encountersCleared, finishRoguelikeRun, paused, status])
}, [addLog, advanceSide, awardBossReward, beginUpgradePhase, checkpointStage, contentType, cpuDifficulty, cpuTakeTurn, encounter, encounterIndex, encountersCleared, finishRoguelikeRun, paused, profile.character.id, stage, status])
useEffect(() => {
if ((status !== 'won' && status !== 'lost') || recordedRunRef.current || !cpuDifficulty) return
@@ -1334,6 +1368,13 @@ export function PvPRoguelikeScreen({
Ability Unlocked: {ability.name}
</p>
))}
{reward.bonusItem && (
<p className="ability-unlock">
<span>{reward.bonusItem.glyph}</span>
{reward.bonusItem.name} x{reward.bonusItem.quantity}
{reward.bonusItem.duplicate ? ` (owned x${reward.bonusItem.quantityAfter})` : ''}
</p>
)}
</>
)}
</div>
+164 -23
View File
@@ -36,6 +36,8 @@ export interface GameRepository {
options?: {
bossesCleared?: number
experienceMode?: 'default' | 'pvp-boss-quarter-level'
lootSourceEncounterId?: number
roguelikeStage?: number
},
): Promise<DungeonReward>
allocateTalent(talentId: number): Promise<CharacterProfile>
@@ -44,6 +46,7 @@ export interface GameRepository {
discardExtraItem(itemId: number): Promise<CharacterProfile>
breakdownItem(itemId: number): Promise<CharacterProfile>
craftItem(recipeId: number): Promise<CharacterProfile>
upgradeItem(itemId: number): Promise<CharacterProfile>
rollEncounterLoot(
encounterId: number,
difficultyId: number,
@@ -97,6 +100,7 @@ type LocalSaveStore = {
const modeKey = 'chronicle.repositoryMode'
const offlineSaveKey = 'chronicle.offlineSave.v1'
const onlineCacheKey = 'chronicle.onlineCache.v1'
const authTokenKey = 'chronicle.authToken.v1'
const offlineAccount = { id: -1, username: 'Offline' }
function clone<T>(value: T): T {
@@ -390,6 +394,7 @@ const COMPONENT_ITEMS: Record<number, ComponentTemplate> = {
type WindowWithApiBase = Window & {
CAPACITOR_API_BASE_URL?: string
CAPACITOR_AUTH_API_BASE_URL?: string
}
function componentForItemLevel(itemLevel: number): ComponentTemplate | undefined {
@@ -401,13 +406,6 @@ function componentForItemLevel(itemLevel: number): ComponentTemplate | undefined
return COMPONENT_ITEMS[best]
}
function componentDropQuantity(itemLevel: number) {
const tier = Math.max(0, Math.floor((itemLevel - 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 mergeProfileIntoSave(profile: CharacterProfile, existingSave?: OfflineSave): OfflineSave {
const characters = clone(existingSave?.characters ?? {})
for (const gameClass of profile.classes) {
@@ -452,25 +450,107 @@ function rollWeightedLootEntry<T extends { dropWeight: number }>(entries: T[]):
return entries[entries.length - 1]
}
function getApiBaseUrl(): string {
function coinDropQuantity() {
const roll = Math.random()
if (roll < 0.15) return 3
if (roll < 0.5) return 2
return 1
}
function roguelikeCoinItemLevel(stage: number) {
return Math.min(25, 5 + Math.max(0, Math.floor(stage / 5)) * 5)
}
function awardRoguelikeCoin(
profile: CharacterProfile,
sourceEncounterId: number | undefined,
stage: number | undefined,
): DungeonReward['bonusItem'] {
if (!sourceEncounterId || !stage) return null
const targetItemLevel = roguelikeCoinItemLevel(stage)
const sourceEncounter = profile.dungeons
.flatMap((dungeon) => dungeon.encounters)
.find((encounter) => encounter.id === sourceEncounterId)
const coin = sourceEncounter?.lootTables
.filter((entry) => entry.itemLevel === targetItemLevel)
.sort((left, right) => left.difficultyId - right.difficultyId)[0]
if (!coin) return null
const {
encounterId: _encounterId,
difficultyId: _difficultyId,
dropWeight: _dropWeight,
dropChance: _dropChance,
...coinItem
} = coin
void _encounterId
void _difficultyId
void _dropWeight
void _dropChance
const quantity = coinDropQuantity()
const added = addInventoryItem(profile.inventory, {
...coinItem,
slot: coinItem.slot as EquipmentSlot,
rarity: coinItem.rarity as Item['rarity'],
}, quantity)
return {
...coinItem,
quantity,
duplicate: added.duplicate,
quantityAfter: added.quantityAfter,
}
}
function readAuthToken(): string {
return localStorage.getItem(authTokenKey) ?? ''
}
function writeAuthToken(token: string) {
localStorage.setItem(authTokenKey, token)
}
function clearAuthToken() {
localStorage.removeItem(authTokenKey)
}
function configuredBaseUrl(value: string | undefined): string {
return value ? value.replace(/\/+$/, '') : ''
}
function getApiBaseUrl(path: string): string {
const browserWindow = typeof window === 'undefined'
? undefined
: window as WindowWithApiBase
if (path.startsWith('/api/auth/')) {
if (browserWindow?.CAPACITOR_AUTH_API_BASE_URL) {
return configuredBaseUrl(browserWindow.CAPACITOR_AUTH_API_BASE_URL)
}
if (typeof import.meta !== 'undefined' && import.meta.env?.VITE_AUTH_API_BASE_URL) {
return configuredBaseUrl(import.meta.env.VITE_AUTH_API_BASE_URL)
}
}
if (browserWindow?.CAPACITOR_API_BASE_URL) {
return browserWindow.CAPACITOR_API_BASE_URL
return configuredBaseUrl(browserWindow.CAPACITOR_API_BASE_URL)
}
if (typeof import.meta !== 'undefined' && import.meta.env?.VITE_API_BASE_URL) {
return import.meta.env.VITE_API_BASE_URL
return configuredBaseUrl(import.meta.env.VITE_API_BASE_URL)
}
return ''
}
async function requestJson<T>(path: string, init?: RequestInit): Promise<T> {
const baseUrl = getApiBaseUrl()
const baseUrl = getApiBaseUrl(path)
const url = baseUrl ? `${baseUrl}${path}` : path
const headers = new Headers(init?.headers)
const token = readAuthToken()
if (token && !headers.has('Authorization')) {
headers.set('Authorization', `Bearer ${token}`)
}
let response: Response
try {
response = await fetch(url, init)
response = await fetch(url, {
...init,
headers,
})
} catch (reason) {
const networkError = new Error('Unable to reach the game server.') as NetworkError
networkError.network = true
@@ -525,8 +605,18 @@ async function pushServerSyncSave(save: OfflineSave): Promise<{ profile: Charact
}
async function finalizeOnlineSession(session: AuthSession): Promise<AuthSession> {
if (!session.account || !session.profile) return session
const cache = readOnlineCache()
if (session.token) writeAuthToken(session.token)
if (!session.account || !session.profile) {
if (session.account && cache?.account.id === session.account.id) {
return {
account: session.account,
profile: buildProfile(cache.save),
token: session.token,
}
}
return session
}
if (cache?.account.id === session.account.id && cache.dirty) {
writeOnlineCache({
...cache,
@@ -535,6 +625,7 @@ async function finalizeOnlineSession(session: AuthSession): Promise<AuthSession>
return {
account: session.account,
profile: buildProfile(cache.save),
token: session.token,
}
}
try {
@@ -611,6 +702,7 @@ const serverRepository: GameRepository = {
} catch (reason) {
if (!isNetworkError(reason)) throw reason
}
clearAuthToken()
clearOnlineCache()
writeMode('online')
},
@@ -657,6 +749,8 @@ const serverRepository: GameRepository = {
cachedOnlineLocalRepository.breakdownItem(itemId),
craftItem: (recipeId) =>
cachedOnlineLocalRepository.craftItem(recipeId),
upgradeItem: (itemId) =>
cachedOnlineLocalRepository.upgradeItem(itemId),
rollEncounterLoot: (encounterId, difficultyId, runToken) =>
cachedOnlineLocalRepository.rollEncounterLoot(encounterId, difficultyId, runToken),
}
@@ -827,7 +921,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
})
}
cd.inventory = profile.inventory
bonusItem = { ...selected, duplicate, quantityAfter }
bonusItem = { ...selected, quantity: 1, duplicate, quantityAfter }
}
}
@@ -914,6 +1008,12 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
profile.maxTalentPoints,
cd.talentPoints + levelsGained,
)
const bonusItem = awardRoguelikeCoin(
profile,
options?.lootSourceEncounterId,
options?.roguelikeStage,
)
cd.inventory = profile.inventory
store.writeSave(save)
const updatedProfile = buildProfile(save)
@@ -931,7 +1031,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
durationSeconds,
averageItemLevel: updatedProfile.gearStats.averageItemLevel,
unlockedAbilities,
bonusItem: null,
bonusItem,
profile: updatedProfile,
}
},
@@ -1083,6 +1183,51 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
store.writeSave(save)
return buildProfile(save)
},
async upgradeItem(itemId) {
const save = requireStoredSave(store)
const profile = buildProfile(save)
const item = profile.inventory.find((candidate) => candidate.id === itemId)
if (!item) throw new Error('That item is not in the character inventory.')
if (item.slot === 'component') throw new Error('Components cannot be upgraded.')
const currentRecipe = profile.craftingRecipes.find((recipe) => recipe.item.id === item.id)
const targetRecipe = currentRecipe
? profile.craftingRecipes.find((recipe) =>
recipe.sourceEncounterId === currentRecipe.sourceEncounterId
&& recipe.item.slot === item.slot
&& recipe.item.itemLevel === item.itemLevel + 5,
)
: null
if (!targetRecipe) throw new Error('No upgrade is available for this item.')
const missing = targetRecipe.components.find((component) => component.owned < component.quantity)
if (missing) {
throw new Error(`Need ${missing.quantity} ${missing.item.name} to upgrade this item.`)
}
for (const component of targetRecipe.components) {
const owned = profile.inventory.find((candidate) => candidate.id === component.item.id)
if (!owned) throw new Error(`Need ${component.quantity} ${component.item.name} to upgrade this item.`)
owned.quantity -= component.quantity
}
const wasEquipped = item.equipped
item.quantity -= 1
item.equipped = false
for (let index = profile.inventory.length - 1; index >= 0; index -= 1) {
if (profile.inventory[index].quantity <= 0) profile.inventory.splice(index, 1)
}
if (wasEquipped) {
for (const candidate of profile.inventory) {
if (candidate.slot === targetRecipe.item.slot) candidate.equipped = false
}
}
addInventoryItem(profile.inventory, targetRecipe.item, 1)
if (wasEquipped) {
const upgraded = profile.inventory.find((candidate) => candidate.id === targetRecipe.item.id)
if (upgraded) upgraded.equipped = true
}
save.characters[save.activeClassId].inventory = profile.inventory
store.writeSave(save)
return buildProfile(save)
},
async rollEncounterLoot(encounterId, difficultyId, runToken) {
if (runToken.length < 8 || runToken.length > 100) {
throw new Error('A valid dungeon run token is required.')
@@ -1108,17 +1253,11 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
const items: LootRoll['items'] = []
const selectedQuantities = new Map<number, { entry: typeof entries[number]; quantity: number }>()
const dungeon = profile.dungeons.find((candidate) =>
candidate.encounters.some((dungeonEncounter) => dungeonEncounter.id === encounterId),
)
const lootChanceSlots = dungeon?.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)
const current = selectedQuantities.get(selected.id)
selectedQuantities.set(selected.id, {
entry: selected,
quantity: (current?.quantity ?? 0) + componentDropQuantity(difficulty.droppedItemLevel),
quantity: coinDropQuantity(),
})
}
@@ -1211,6 +1350,7 @@ const cachedOnlineRepository: GameRepository = {
throw new Error('Account login requires online mode.')
},
async logout() {
clearAuthToken()
clearOnlineCache()
writeMode('online')
},
@@ -1241,6 +1381,7 @@ const cachedOnlineRepository: GameRepository = {
discardExtraItem: (itemId) => cachedOnlineLocalRepository.discardExtraItem(itemId),
breakdownItem: (itemId) => cachedOnlineLocalRepository.breakdownItem(itemId),
craftItem: (recipeId) => cachedOnlineLocalRepository.craftItem(recipeId),
upgradeItem: (itemId) => cachedOnlineLocalRepository.upgradeItem(itemId),
rollEncounterLoot: (encounterId, difficultyId, runToken) =>
cachedOnlineLocalRepository.rollEncounterLoot(encounterId, difficultyId, runToken),
}
File diff suppressed because it is too large Load Diff
+9 -1
View File
@@ -59,7 +59,7 @@ export type Item = {
slug: string
name: string
slot: EquipmentSlot
rarity: 'common' | 'uncommon' | 'rare' | 'epic'
rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'
itemLevel: number
healingPower: number
maxResourceBonus: number
@@ -234,6 +234,7 @@ export type Account = {
export type AuthSession = {
account: Account | null
profile: CharacterProfile | null
token?: string
}
export type BonusItem = {
@@ -247,6 +248,7 @@ export type BonusItem = {
maxResourceBonus: number
glyph: string
description: string
quantity: number
duplicate: boolean
quantityAfter: number
}
@@ -338,6 +340,8 @@ export async function completeRoguelike(
options?: {
bossesCleared?: number
experienceMode?: 'default' | 'pvp-boss-quarter-level'
lootSourceEncounterId?: number
roguelikeStage?: number
},
): Promise<DungeonReward> {
return activeGameRepository().completeRoguelike(
@@ -374,6 +378,10 @@ export async function craftItem(recipeId: number): Promise<CharacterProfile> {
return activeGameRepository().craftItem(recipeId)
}
export async function upgradeItem(itemId: number): Promise<CharacterProfile> {
return activeGameRepository().upgradeItem(itemId)
}
export async function rollEncounterLoot(
encounterId: number,
difficultyId: number,
+22
View File
@@ -12,6 +12,7 @@ export type CpuPvpLeaderboardEntry = {
}
const cpuLeaderboardKey = 'chronicle.pvpCpuLeaderboard.v1'
const checkpointKey = 'chronicle.pvpRoguelikeCheckpoint.v1'
export function randomCpuDifficulty(): CpuDifficulty {
return (Math.floor(Math.random() * 5) + 1) as CpuDifficulty
@@ -44,3 +45,24 @@ export function recordCpuPvpLeaderboard(entry: CpuPvpLeaderboardEntry) {
.slice(0, 30)
localStorage.setItem(cpuLeaderboardKey, JSON.stringify(next))
}
function checkpointStorageKey(characterId: number, contentType: PvpContentType) {
return `${checkpointKey}:${characterId}:${contentType}`
}
export function loadPvpRoguelikeCheckpoint(characterId: number, contentType: PvpContentType) {
const value = Number(localStorage.getItem(checkpointStorageKey(characterId, contentType)) ?? 1)
return Number.isInteger(value) && value >= 5 ? value : 1
}
export function recordPvpRoguelikeCheckpoint(
characterId: number,
contentType: PvpContentType,
stage: number,
) {
if (stage < 5 || stage % 5 !== 0) return loadPvpRoguelikeCheckpoint(characterId, contentType)
const current = loadPvpRoguelikeCheckpoint(characterId, contentType)
const next = Math.max(current, stage)
localStorage.setItem(checkpointStorageKey(characterId, contentType), String(next))
return next
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
"types": ["node"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"files": ["vite.config.ts"]
}