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 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. 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 ## Account limits
Registration permits one account per public IP by default. Login and API rate 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" applicationId "com.warren.iwanttoheal"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 32 versionCode 39
versionName "1.0.21" versionName "1.0.23"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions { aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // 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, 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); (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 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) (id, class_id, slug, name, max_rank, tier, branch, prerequisite_talent_id, prerequisite_rank, effect_type, effect_value_per_rank, glyph, description)
VALUES VALUES
@@ -678,9 +840,9 @@ SET slug = CASE id
END, END,
encounter_type = CASE WHEN id IN (102, 105, 108) THEN 'boss' ELSE 'trash' END, encounter_type = CASE WHEN id IN (102, 105, 108) THEN 'boss' ELSE 'trash' END,
description = CASE id description = CASE id
WHEN 102 THEN 'Tigrex 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 monster parts for item level 10 crafting.' WHEN 105 THEN 'Rathalos drops boss coins for item level 10 crafting.'
WHEN 108 THEN 'Gypceros drops monster parts for item level 10 crafting.' WHEN 108 THEN 'Gypceros drops boss coins for item level 10 crafting.'
ELSE 'Hunters clear the raid path.' ELSE 'Hunters clear the raid path.'
END END
WHERE id BETWEEN 100 AND 108; 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.tank_damage + generated_bosses.boss_index * 2 + generated_loot_tiers.item_level / 8,
offset.party_damage + generated_bosses.boss_index * 3, offset.party_damage + generated_bosses.boss_index * 3,
CASE offset.encounter_type 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 || '.' ELSE 'Hunters clear the path before ' || generated_bosses.boss_name || '.'
END END
FROM generated_loot_tiers FROM generated_loot_tiers
@@ -730,7 +892,7 @@ SELECT
offset.tank_damage + generated_bosses.boss_index * 2 + generated_loot_tiers.item_level / 8, offset.tank_damage + generated_bosses.boss_index * 2 + generated_loot_tiers.item_level / 8,
offset.party_damage + generated_bosses.boss_index * 3 + 24, offset.party_damage + generated_bosses.boss_index * 3 + 24,
CASE offset.encounter_type 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 || '.' ELSE 'Hunters clear the raid path before ' || generated_bosses.boss_name || '.'
END END
FROM generated_loot_tiers 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), (1007, 868, 5), (1007, 869, 3), (1007, 871, 1),
(1008, 868, 5), (1008, 869, 3), (1008, 871, 1), (1008, 868, 5), (1008, 869, 3), (1008, 871, 1),
(1009, 868, 5), (1009, 869, 3), (1009, 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", "offline:export": "node scripts/export-offline-profile.mjs",
"lint": "eslint .", "lint": "eslint .",
"admin:start": "node server/admin.mjs", "admin:start": "node server/admin.mjs",
"auth:start": "node server/auth.mjs",
"start": "node server/production.mjs", "start": "node server/production.mjs",
"prepreview": "npm run db:init", "prepreview": "npm run db:init",
"preview": "vite preview" "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)) 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) { async function readJson(request, maxSize = 16 * 1024) {
const chunks = [] const chunks = []
let size = 0 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) { function sessionCookie(token, request, maxAge = sessionLifetimeSeconds) {
const secure = request.headers['x-forwarded-proto'] === 'https' const secure = request.headers['x-forwarded-proto'] === 'https'
|| Boolean(request.socket.encrypted) || Boolean(request.socket.encrypted)
@@ -284,7 +320,7 @@ function createSession(database, accountId, ip, activeCharacterId) {
} }
function currentSession(database, request) { function currentSession(database, request) {
const token = parseCookies(request)[sessionCookieName] const token = requestSessionToken(request)
if (!token) return null if (!token) return null
return database.prepare(` return database.prepare(`
SELECT SELECT
@@ -1268,11 +1304,57 @@ function formatLootRoll(database, context, record, dropChance) {
} }
} }
function componentDropQuantity(droppedItemLevel) { function coinDropQuantity() {
const tier = Math.max(0, Math.floor((droppedItemLevel - 5) / 5)) const roll = Math.random()
const secondChance = Math.min(0.85, 0.35 + tier * 0.12) if (roll < 0.15) return 3
const thirdChance = Math.min(0.6, 0.1 + tier * 0.1) if (roll < 0.5) return 2
return 1 + (Math.random() < secondChance ? 1 : 0) + (Math.random() < thirdChance ? 1 : 0) 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) { function rollWeightedLootEntry(entries) {
@@ -1375,13 +1457,11 @@ function rollEncounterLoot(database, characterId, encounterId, difficultyId, run
} }
const selectedQuantities = new Map() const selectedQuantities = new Map()
const lootChanceSlots = context.contentType === 'raid' ? 8 : 5 if (Math.random() < dropChance) {
for (let index = 0; index < lootChanceSlots; index += 1) {
if (Math.random() >= dropChance) continue
const selected = rollWeightedLootEntry(entries) const selected = rollWeightedLootEntry(entries)
selectedQuantities.set( selectedQuantities.set(
selected.id, selected.id,
(selectedQuantities.get(selected.id) ?? 0) + componentDropQuantity(context.droppedItemLevel), coinDropQuantity(),
) )
} }
@@ -1665,6 +1745,102 @@ function craftItem(database, characterId, recipeId) {
return getProfile(database, characterId) 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) { function allocateTalent(database, characterId, talentId) {
const character = database.prepare(` const character = database.prepare(`
SELECT class_id AS classId, talent_points AS talentPoints 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) ON CONFLICT(character_id, item_id)
DO UPDATE SET quantity = quantity + 1 DO UPDATE SET quantity = quantity + 1
`).run(characterId, bonusItem.id) `).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 = ? SET experience = ?, level = ?, talent_points = ?
WHERE id = ? WHERE id = ?
`).run(newExperience, newLevel, newTalentPoints, characterId) `).run(newExperience, newLevel, newTalentPoints, characterId)
const bonusItem = awardRoguelikeCoin(
database,
characterId,
Number(runMetrics?.lootSourceEncounterId),
Number(runMetrics?.roguelikeStage),
)
return { return {
dungeonName: `${dungeon.name} Roguelike`, dungeonName: `${dungeon.name} Roguelike`,
@@ -2122,7 +2304,7 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
durationSeconds, durationSeconds,
averageItemLevel, averageItemLevel,
unlockedAbilities, unlockedAbilities,
bonusItem: null, bonusItem,
profile: getProfile(database, characterId, accountId), 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) { export async function handleApiRequest(request, response, next) {
if (!request.url?.startsWith('/api/')) { if (!request.url?.startsWith('/api/')) {
next() next()
return return
} }
if (request.method === 'OPTIONS') {
sendCorsPreflight(request, response)
return
}
setCorsHeaders(response, request)
if (request.url.startsWith('/api/boss-images/') && request.method === 'GET') { if (request.url.startsWith('/api/boss-images/') && request.method === 'GET') {
sendBossImage(request, response) sendBossImage(request, response)
return return
@@ -2242,54 +2536,7 @@ export async function handleApiRequest(request, response, next) {
DELETE FROM sessions WHERE expires_at <= CURRENT_TIMESTAMP DELETE FROM sessions WHERE expires_at <= CURRENT_TIMESTAMP
`).run() `).run()
if (request.url === '/api/auth/register' && request.method === 'POST') { if (await handleAuthApiRoute(database, request, response)) {
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) },
)
return return
} }
@@ -2401,6 +2648,16 @@ export async function handleApiRequest(request, response, next) {
return 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$/) const encounterLootRoll = request.url.match(/^\/api\/encounters\/(\d+)\/loot-roll$/)
if (encounterLootRoll && request.method === 'POST') { if (encounterLootRoll && request.method === 'POST') {
const payload = await readJson(request) const payload = await readJson(request)
+4
View File
@@ -2973,6 +2973,10 @@ h2 {
--rarity-color: #b584e3; --rarity-color: #b584e3;
} }
.rarity-legendary {
--rarity-color: #f2a13a;
}
.rarity-common { .rarity-common {
--rarity-color: #a8a3ad; --rarity-color: #a8a3ad;
} }
+2 -3
View File
@@ -287,7 +287,6 @@ function App() {
{ part: 2, name: `${sectionName} 2`, encounterCount: 3, unlocked: completedSections >= 1 }, { part: 2, name: `${sectionName} 2`, encounterCount: 3, unlocked: completedSections >= 1 },
{ part: 3, name: `${sectionName} 3`, encounterCount: 3, unlocked: completedSections >= 2 }, { part: 3, name: `${sectionName} 3`, encounterCount: 3, unlocked: completedSections >= 2 },
] ]
const lootChanceSlots = activity.contentType === 'raid' ? 8 : 5
const cloudSync = getCloudSyncStatus() const cloudSync = getCloudSyncStatus()
const canShowCloudSync = account.id !== -1 && cloudSync.available const canShowCloudSync = account.id !== -1 && cloudSync.available
const lootPreviewEncounters = [...activity.encounters] const lootPreviewEncounters = [...activity.encounters]
@@ -682,7 +681,7 @@ function App() {
</label> </label>
</div> </div>
<p className="section-note"> <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 {activity.completionItemLevel
? ` - full clear guarantees iLvl ${activity.completionItemLevel}` ? ` - full clear guarantees iLvl ${activity.completionItemLevel}`
: ''} : ''}
@@ -702,7 +701,7 @@ function App() {
)} )}
<div> <div>
<strong>{encounter.enemyName}</strong> <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> </div>
<div className="loot-items"> <div className="loot-items">
+1 -1
View File
@@ -183,7 +183,7 @@ function ItemsTab({ data, setData, setSaving, saving }: {
</label> </label>
<label>Rarity <label>Rarity
<select value={form.rarity ?? item.rarity} onChange={(e) => setForm({ ...form, rarity: e.target.value })}> <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> <option key={r} value={r}>{r}</option>
))} ))}
</select> </select>
+1 -1
View File
@@ -1321,7 +1321,7 @@ export function CombatScreen({
<div className="bonus-item-detail"> <div className="bonus-item-detail">
<span>{reward.bonusItem.glyph}</span> <span>{reward.bonusItem.glyph}</span>
<strong className={`rarity-${reward.bonusItem.rarity}`}>{reward.bonusItem.name}</strong> <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>} {reward.bonusItem.duplicate && <small> (owned x{reward.bonusItem.quantityAfter})</small>}
</div> </div>
</div> </div>
+41 -2
View File
@@ -4,6 +4,7 @@ import {
craftItem, craftItem,
equipItem, equipItem,
loadProfile, loadProfile,
upgradeItem,
type CharacterProfile, type CharacterProfile,
type EquipmentSlot, type EquipmentSlot,
type Item, type Item,
@@ -46,6 +47,7 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
const [equipping, setEquipping] = useState(false) const [equipping, setEquipping] = useState(false)
const [breakingDown, setBreakingDown] = useState(false) const [breakingDown, setBreakingDown] = useState(false)
const [crafting, setCrafting] = useState(false) const [crafting, setCrafting] = useState(false)
const [upgrading, setUpgrading] = useState(false)
const [showSetBonuses, setShowSetBonuses] = useState(false) const [showSetBonuses, setShowSetBonuses] = useState(false)
const [equipmentTab, setEquipmentTab] = useState<'equipment' | 'crafting'>('equipment') const [equipmentTab, setEquipmentTab] = useState<'equipment' | 'crafting'>('equipment')
const [inventoryPage, setInventoryPage] = useState(0) const [inventoryPage, setInventoryPage] = useState(0)
@@ -59,6 +61,16 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
firstRecipe?.id ?? null, firstRecipe?.id ?? null,
) )
const selectedRecipe = profile.craftingRecipes.find((recipe) => recipe.id === selectedRecipeId) 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( const equippedBySlot = useMemo(
() => new Map( () => new Map(
profile.inventory 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 = ( const content = (
<> <>
{!embedded && ( {!embedded && (
@@ -259,16 +288,26 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
<ComparisonDelta selected={selectedItem} equipped={comparisonItem} /> <ComparisonDelta selected={selectedItem} equipped={comparisonItem} />
<button <button
className="primary-button" className="primary-button"
disabled={selectedItem.equipped || equipping || breakingDown} disabled={selectedItem.equipped || equipping || breakingDown || upgrading}
onClick={equipSelected} onClick={equipSelected}
type="button" type="button"
> >
{selectedItem.equipped ? 'Equipped' : equipping ? 'Equipping...' : 'Equip Item'} {selectedItem.equipped ? 'Equipped' : equipping ? 'Equipping...' : 'Equip Item'}
</button> </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) && ( {(!selectedItem.equipped || selectedItem.quantity > 1) && (
<button <button
className="breakdown-button" className="breakdown-button"
disabled={equipping || breakingDown} disabled={equipping || breakingDown || upgrading}
onClick={breakdownSelected} onClick={breakdownSelected}
type="button" type="button"
> >
+55 -14
View File
@@ -12,8 +12,10 @@ import {
type DualScreenCombatState, type DualScreenCombatState,
} from '../dualScreen' } from '../dualScreen'
import { import {
loadPvpRoguelikeCheckpoint,
randomCpuDifficulty, randomCpuDifficulty,
recordCpuPvpLeaderboard, recordCpuPvpLeaderboard,
recordPvpRoguelikeCheckpoint,
type CpuDifficulty, type CpuDifficulty,
type PvpContentType, type PvpContentType,
} from '../pvpRoguelike' } from '../pvpRoguelike'
@@ -29,6 +31,7 @@ type BossMechanic =
type PvpEncounter = DungeonEncounter & { type PvpEncounter = DungeonEncounter & {
bossMechanics?: BossMechanic[] bossMechanics?: BossMechanic[]
sourceEncounterId?: number
} }
type SlotKey = '1' | '2' | '3' | '4' | '5' type SlotKey = '1' | '2' | '3' | '4' | '5'
@@ -261,6 +264,7 @@ function buildEncounterSegment(pool: DungeonEncounter[], stage: number, kind: Pv
const isBoss = index === 2 const isBoss = index === 2
return { return {
...encounter, ...encounter,
sourceEncounterId: encounter.id,
id: 910000 + stage * 10 + index, id: 910000 + stage * 10 + index,
sequence: (stage - 1) * 3 + index + 1, sequence: (stage - 1) * 3 + index + 1,
isBoss, isBoss,
@@ -381,6 +385,10 @@ export function PvPRoguelikeScreen({
() => buildOpponentDebuffChoices(starterSpells, abilityLabelMode), () => buildOpponentDebuffChoices(starterSpells, abilityLabelMode),
[abilityLabelMode, starterSpells], [abilityLabelMode, starterSpells],
) )
const [checkpointStage, setCheckpointStage] = useState(() =>
loadPvpRoguelikeCheckpoint(profile.character.id, contentType),
)
const [startStage, setStartStage] = useState(checkpointStage)
const maxResource = gameClass.maxResource const maxResource = gameClass.maxResource
const partyTemplate = useMemo( const partyTemplate = useMemo(
() => (contentType === 'raid' ? RAID_PARTY : INITIAL_PARTY).map((member) => ({ () => (contentType === 'raid' ? RAID_PARTY : INITIAL_PARTY).map((member) => ({
@@ -397,8 +405,8 @@ export function PvPRoguelikeScreen({
[contentType], [contentType],
) )
const [status, setStatus] = useState<'queueing' | 'playing' | 'upgrade-choice' | 'won' | 'lost'>('queueing') const [status, setStatus] = useState<'queueing' | 'playing' | 'upgrade-choice' | 'won' | 'lost'>('queueing')
const [stage, setStage] = useState(1) const [stage, setStage] = useState(startStage)
const [encounters, setEncounters] = useState<PvpEncounter[]>(() => buildEncounterSegment(encounterPool, 1, contentType)) const [encounters, setEncounters] = useState<PvpEncounter[]>(() => buildEncounterSegment(encounterPool, startStage, contentType))
const [encounterIndex, setEncounterIndex] = useState(0) const [encounterIndex, setEncounterIndex] = useState(0)
const [playerSide, setPlayerSide] = useState<SideState>(() => starterSide(partyTemplate, maxResource)) const [playerSide, setPlayerSide] = useState<SideState>(() => starterSide(partyTemplate, maxResource))
const [cpuSide, setCpuSide] = useState<SideState>(() => starterSide(cpuPartyTemplate, maxResource)) const [cpuSide, setCpuSide] = useState<SideState>(() => starterSide(cpuPartyTemplate, maxResource))
@@ -464,9 +472,16 @@ export function PvPRoguelikeScreen({
}, 900) }, 900)
}, []) }, [])
useEffect(() => {
const loadedCheckpoint = loadPvpRoguelikeCheckpoint(profile.character.id, contentType)
setCheckpointStage(loadedCheckpoint)
setStartStage(loadedCheckpoint)
}, [contentType, profile.character.id])
const awardBossReward = useCallback((encounterIndexValue: number) => { const awardBossReward = useCallback((encounterIndexValue: number) => {
if (bossRewardClaimedRef.current.has(encounterIndexValue)) return if (bossRewardClaimedRef.current.has(encounterIndexValue)) return
bossRewardClaimedRef.current.add(encounterIndexValue) bossRewardClaimedRef.current.add(encounterIndexValue)
const rewardEncounter = encounters[encounterIndexValue]
completeRoguelike( completeRoguelike(
rewardDungeon.id, rewardDungeon.id,
rewardDifficulty.id, rewardDifficulty.id,
@@ -476,18 +491,26 @@ export function PvPRoguelikeScreen({
{ {
bossesCleared: 1, bossesCleared: 1,
experienceMode: 'pvp-boss-quarter-level', experienceMode: 'pvp-boss-quarter-level',
lootSourceEncounterId: rewardEncounter?.sourceEncounterId,
roguelikeStage: stage,
}, },
) )
.then((result) => { .then((result) => {
setReward(result) setReward(result)
onProfileUpdated(result.profile) onProfileUpdated(result.profile)
if (result.bonusItem) {
addLog(
`${result.bonusItem.name} x${result.bonusItem.quantity} awarded.`,
'loot',
)
}
}) })
.catch((reason: unknown) => { .catch((reason: unknown) => {
setRewardError( setRewardError(
reason instanceof Error ? reason.message : 'Unable to award roguelike experience.', 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(() => { const finishRoguelikeRun = useCallback(() => {
if (rewardClaimedRef.current) return if (rewardClaimedRef.current) return
@@ -510,7 +533,7 @@ export function PvPRoguelikeScreen({
}, [opponentDebuffChoicesCatalog, selfBuffChoicesCatalog]) }, [opponentDebuffChoicesCatalog, selfBuffChoicesCatalog])
useEffect(() => { useEffect(() => {
const firstSegment = buildEncounterSegment(encounterPool, 1, contentType) const firstSegment = buildEncounterSegment(encounterPool, startStage, contentType)
const firstEncounter = firstSegment[0] const firstEncounter = firstSegment[0]
const basePlayer = starterSide(partyTemplate, maxResource) const basePlayer = starterSide(partyTemplate, maxResource)
const baseCpu = starterSide(cpuPartyTemplate, maxResource) const baseCpu = starterSide(cpuPartyTemplate, maxResource)
@@ -523,7 +546,7 @@ export function PvPRoguelikeScreen({
bossRewardClaimedRef.current = new Set() bossRewardClaimedRef.current = new Set()
setEncounters(firstSegment) setEncounters(firstSegment)
setEncounterIndex(0) setEncounterIndex(0)
setStage(1) setStage(startStage)
setElapsedTicks(0) setElapsedTicks(0)
setStatus('queueing') setStatus('queueing')
setPlayerSide(basePlayer) setPlayerSide(basePlayer)
@@ -546,26 +569,26 @@ export function PvPRoguelikeScreen({
cpuDefeatedRef.current = false cpuDefeatedRef.current = false
if (gameMode === 'offline') { if (gameMode === 'offline') {
const randomCpu = randomCpuDifficulty() const randomCpu = randomCpuDifficulty()
setQueueMessage(`Offline mode. CPU ${randomCpu} enters immediately.`) setQueueMessage(`Offline mode. CPU ${randomCpu} enters at stage ${startStage}.`)
setCpuDifficulty(randomCpu) 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(() => { const timer = window.setTimeout(() => {
setStatus('playing') setStatus('playing')
addLog(`Round 1 begins against CPU ${randomCpu}.`, 'system') addLog(`Stage ${startStage} begins against CPU ${randomCpu}.`, 'system')
}, 500) }, 500)
return () => window.clearTimeout(timer) return () => window.clearTimeout(timer)
} }
setQueueMessage('Searching queue. No player found yet.') setQueueMessage(`Searching queue. Stage ${startStage} start ready.`)
setLog([{ id: 1, text: 'Searching queue. No player found yet.', tone: 'system' }]) setLog([{ id: 1, text: `Searching queue. Stage ${startStage} start ready.`, tone: 'system' }])
const timer = window.setTimeout(() => { const timer = window.setTimeout(() => {
const randomCpu = randomCpuDifficulty() const randomCpu = randomCpuDifficulty()
setCpuDifficulty(randomCpu) setCpuDifficulty(randomCpu)
setQueueMessage(`No queued player found. CPU ${randomCpu} steps in.`) setQueueMessage(`No queued player found. CPU ${randomCpu} steps in.`)
setStatus('playing') 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) }, 1400)
return () => window.clearTimeout(timer) return () => window.clearTimeout(timer)
}, [addLog, contentType, cpuPartyTemplate, encounterPool, gameMode, maxResource, partyTemplate]) }, [addLog, contentType, cpuPartyTemplate, encounterPool, gameMode, maxResource, partyTemplate, startStage])
const applySpell = useCallback(( const applySpell = useCallback((
current: SideState, current: SideState,
@@ -841,7 +864,18 @@ export function PvPRoguelikeScreen({
if (nextPlayer.enemyHealth <= 0 && playerClearedEncounterRef.current !== encounterIndex) { if (nextPlayer.enemyHealth <= 0 && playerClearedEncounterRef.current !== encounterIndex) {
playerClearedEncounterRef.current = encounterIndex playerClearedEncounterRef.current = encounterIndex
setEncountersCleared((value) => value + 1) 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 playerRef.current = nextPlayer
cpuRef.current = nextCpu cpuRef.current = nextCpu
@@ -866,7 +900,7 @@ export function PvPRoguelikeScreen({
} }
}, TICK_MS) }, TICK_MS)
return () => window.clearInterval(timer) 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(() => { useEffect(() => {
if ((status !== 'won' && status !== 'lost') || recordedRunRef.current || !cpuDifficulty) return if ((status !== 'won' && status !== 'lost') || recordedRunRef.current || !cpuDifficulty) return
@@ -1334,6 +1368,13 @@ export function PvPRoguelikeScreen({
Ability Unlocked: {ability.name} Ability Unlocked: {ability.name}
</p> </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> </div>
+164 -23
View File
@@ -36,6 +36,8 @@ export interface GameRepository {
options?: { options?: {
bossesCleared?: number bossesCleared?: number
experienceMode?: 'default' | 'pvp-boss-quarter-level' experienceMode?: 'default' | 'pvp-boss-quarter-level'
lootSourceEncounterId?: number
roguelikeStage?: number
}, },
): Promise<DungeonReward> ): Promise<DungeonReward>
allocateTalent(talentId: number): Promise<CharacterProfile> allocateTalent(talentId: number): Promise<CharacterProfile>
@@ -44,6 +46,7 @@ export interface GameRepository {
discardExtraItem(itemId: number): Promise<CharacterProfile> discardExtraItem(itemId: number): Promise<CharacterProfile>
breakdownItem(itemId: number): Promise<CharacterProfile> breakdownItem(itemId: number): Promise<CharacterProfile>
craftItem(recipeId: number): Promise<CharacterProfile> craftItem(recipeId: number): Promise<CharacterProfile>
upgradeItem(itemId: number): Promise<CharacterProfile>
rollEncounterLoot( rollEncounterLoot(
encounterId: number, encounterId: number,
difficultyId: number, difficultyId: number,
@@ -97,6 +100,7 @@ type LocalSaveStore = {
const modeKey = 'chronicle.repositoryMode' const modeKey = 'chronicle.repositoryMode'
const offlineSaveKey = 'chronicle.offlineSave.v1' const offlineSaveKey = 'chronicle.offlineSave.v1'
const onlineCacheKey = 'chronicle.onlineCache.v1' const onlineCacheKey = 'chronicle.onlineCache.v1'
const authTokenKey = 'chronicle.authToken.v1'
const offlineAccount = { id: -1, username: 'Offline' } const offlineAccount = { id: -1, username: 'Offline' }
function clone<T>(value: T): T { function clone<T>(value: T): T {
@@ -390,6 +394,7 @@ const COMPONENT_ITEMS: Record<number, ComponentTemplate> = {
type WindowWithApiBase = Window & { type WindowWithApiBase = Window & {
CAPACITOR_API_BASE_URL?: string CAPACITOR_API_BASE_URL?: string
CAPACITOR_AUTH_API_BASE_URL?: string
} }
function componentForItemLevel(itemLevel: number): ComponentTemplate | undefined { function componentForItemLevel(itemLevel: number): ComponentTemplate | undefined {
@@ -401,13 +406,6 @@ function componentForItemLevel(itemLevel: number): ComponentTemplate | undefined
return COMPONENT_ITEMS[best] 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 { function mergeProfileIntoSave(profile: CharacterProfile, existingSave?: OfflineSave): OfflineSave {
const characters = clone(existingSave?.characters ?? {}) const characters = clone(existingSave?.characters ?? {})
for (const gameClass of profile.classes) { for (const gameClass of profile.classes) {
@@ -452,25 +450,107 @@ function rollWeightedLootEntry<T extends { dropWeight: number }>(entries: T[]):
return entries[entries.length - 1] 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' const browserWindow = typeof window === 'undefined'
? undefined ? undefined
: window as WindowWithApiBase : 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) { 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) { 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 '' return ''
} }
async function requestJson<T>(path: string, init?: RequestInit): Promise<T> { async function requestJson<T>(path: string, init?: RequestInit): Promise<T> {
const baseUrl = getApiBaseUrl() const baseUrl = getApiBaseUrl(path)
const url = baseUrl ? `${baseUrl}${path}` : 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 let response: Response
try { try {
response = await fetch(url, init) response = await fetch(url, {
...init,
headers,
})
} catch (reason) { } catch (reason) {
const networkError = new Error('Unable to reach the game server.') as NetworkError const networkError = new Error('Unable to reach the game server.') as NetworkError
networkError.network = true networkError.network = true
@@ -525,8 +605,18 @@ async function pushServerSyncSave(save: OfflineSave): Promise<{ profile: Charact
} }
async function finalizeOnlineSession(session: AuthSession): Promise<AuthSession> { async function finalizeOnlineSession(session: AuthSession): Promise<AuthSession> {
if (!session.account || !session.profile) return session
const cache = readOnlineCache() 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) { if (cache?.account.id === session.account.id && cache.dirty) {
writeOnlineCache({ writeOnlineCache({
...cache, ...cache,
@@ -535,6 +625,7 @@ async function finalizeOnlineSession(session: AuthSession): Promise<AuthSession>
return { return {
account: session.account, account: session.account,
profile: buildProfile(cache.save), profile: buildProfile(cache.save),
token: session.token,
} }
} }
try { try {
@@ -611,6 +702,7 @@ const serverRepository: GameRepository = {
} catch (reason) { } catch (reason) {
if (!isNetworkError(reason)) throw reason if (!isNetworkError(reason)) throw reason
} }
clearAuthToken()
clearOnlineCache() clearOnlineCache()
writeMode('online') writeMode('online')
}, },
@@ -657,6 +749,8 @@ const serverRepository: GameRepository = {
cachedOnlineLocalRepository.breakdownItem(itemId), cachedOnlineLocalRepository.breakdownItem(itemId),
craftItem: (recipeId) => craftItem: (recipeId) =>
cachedOnlineLocalRepository.craftItem(recipeId), cachedOnlineLocalRepository.craftItem(recipeId),
upgradeItem: (itemId) =>
cachedOnlineLocalRepository.upgradeItem(itemId),
rollEncounterLoot: (encounterId, difficultyId, runToken) => rollEncounterLoot: (encounterId, difficultyId, runToken) =>
cachedOnlineLocalRepository.rollEncounterLoot(encounterId, difficultyId, runToken), cachedOnlineLocalRepository.rollEncounterLoot(encounterId, difficultyId, runToken),
} }
@@ -827,7 +921,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
}) })
} }
cd.inventory = profile.inventory 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, profile.maxTalentPoints,
cd.talentPoints + levelsGained, cd.talentPoints + levelsGained,
) )
const bonusItem = awardRoguelikeCoin(
profile,
options?.lootSourceEncounterId,
options?.roguelikeStage,
)
cd.inventory = profile.inventory
store.writeSave(save) store.writeSave(save)
const updatedProfile = buildProfile(save) const updatedProfile = buildProfile(save)
@@ -931,7 +1031,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
durationSeconds, durationSeconds,
averageItemLevel: updatedProfile.gearStats.averageItemLevel, averageItemLevel: updatedProfile.gearStats.averageItemLevel,
unlockedAbilities, unlockedAbilities,
bonusItem: null, bonusItem,
profile: updatedProfile, profile: updatedProfile,
} }
}, },
@@ -1083,6 +1183,51 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
store.writeSave(save) store.writeSave(save)
return buildProfile(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) { async rollEncounterLoot(encounterId, difficultyId, runToken) {
if (runToken.length < 8 || runToken.length > 100) { if (runToken.length < 8 || runToken.length > 100) {
throw new Error('A valid dungeon run token is required.') throw new Error('A valid dungeon run token is required.')
@@ -1108,17 +1253,11 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
const items: LootRoll['items'] = [] const items: LootRoll['items'] = []
const selectedQuantities = new Map<number, { entry: typeof entries[number]; quantity: number }>() const selectedQuantities = new Map<number, { entry: typeof entries[number]; quantity: number }>()
const dungeon = profile.dungeons.find((candidate) => if (Math.random() < dropChance) {
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
const selected = rollWeightedLootEntry(entries) const selected = rollWeightedLootEntry(entries)
const current = selectedQuantities.get(selected.id)
selectedQuantities.set(selected.id, { selectedQuantities.set(selected.id, {
entry: selected, 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.') throw new Error('Account login requires online mode.')
}, },
async logout() { async logout() {
clearAuthToken()
clearOnlineCache() clearOnlineCache()
writeMode('online') writeMode('online')
}, },
@@ -1241,6 +1381,7 @@ const cachedOnlineRepository: GameRepository = {
discardExtraItem: (itemId) => cachedOnlineLocalRepository.discardExtraItem(itemId), discardExtraItem: (itemId) => cachedOnlineLocalRepository.discardExtraItem(itemId),
breakdownItem: (itemId) => cachedOnlineLocalRepository.breakdownItem(itemId), breakdownItem: (itemId) => cachedOnlineLocalRepository.breakdownItem(itemId),
craftItem: (recipeId) => cachedOnlineLocalRepository.craftItem(recipeId), craftItem: (recipeId) => cachedOnlineLocalRepository.craftItem(recipeId),
upgradeItem: (itemId) => cachedOnlineLocalRepository.upgradeItem(itemId),
rollEncounterLoot: (encounterId, difficultyId, runToken) => rollEncounterLoot: (encounterId, difficultyId, runToken) =>
cachedOnlineLocalRepository.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 slug: string
name: string name: string
slot: EquipmentSlot slot: EquipmentSlot
rarity: 'common' | 'uncommon' | 'rare' | 'epic' rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'
itemLevel: number itemLevel: number
healingPower: number healingPower: number
maxResourceBonus: number maxResourceBonus: number
@@ -234,6 +234,7 @@ export type Account = {
export type AuthSession = { export type AuthSession = {
account: Account | null account: Account | null
profile: CharacterProfile | null profile: CharacterProfile | null
token?: string
} }
export type BonusItem = { export type BonusItem = {
@@ -247,6 +248,7 @@ export type BonusItem = {
maxResourceBonus: number maxResourceBonus: number
glyph: string glyph: string
description: string description: string
quantity: number
duplicate: boolean duplicate: boolean
quantityAfter: number quantityAfter: number
} }
@@ -338,6 +340,8 @@ export async function completeRoguelike(
options?: { options?: {
bossesCleared?: number bossesCleared?: number
experienceMode?: 'default' | 'pvp-boss-quarter-level' experienceMode?: 'default' | 'pvp-boss-quarter-level'
lootSourceEncounterId?: number
roguelikeStage?: number
}, },
): Promise<DungeonReward> { ): Promise<DungeonReward> {
return activeGameRepository().completeRoguelike( return activeGameRepository().completeRoguelike(
@@ -374,6 +378,10 @@ export async function craftItem(recipeId: number): Promise<CharacterProfile> {
return activeGameRepository().craftItem(recipeId) return activeGameRepository().craftItem(recipeId)
} }
export async function upgradeItem(itemId: number): Promise<CharacterProfile> {
return activeGameRepository().upgradeItem(itemId)
}
export async function rollEncounterLoot( export async function rollEncounterLoot(
encounterId: number, encounterId: number,
difficultyId: number, difficultyId: number,
+22
View File
@@ -12,6 +12,7 @@ export type CpuPvpLeaderboardEntry = {
} }
const cpuLeaderboardKey = 'chronicle.pvpCpuLeaderboard.v1' const cpuLeaderboardKey = 'chronicle.pvpCpuLeaderboard.v1'
const checkpointKey = 'chronicle.pvpRoguelikeCheckpoint.v1'
export function randomCpuDifficulty(): CpuDifficulty { export function randomCpuDifficulty(): CpuDifficulty {
return (Math.floor(Math.random() * 5) + 1) as CpuDifficulty return (Math.floor(Math.random() * 5) + 1) as CpuDifficulty
@@ -44,3 +45,24 @@ export function recordCpuPvpLeaderboard(entry: CpuPvpLeaderboardEntry) {
.slice(0, 30) .slice(0, 30)
localStorage.setItem(cpuLeaderboardKey, JSON.stringify(next)) 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"]
}