changes
This commit is contained in:
@@ -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.
@@ -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
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -2973,6 +2973,10 @@ h2 {
|
||||
--rarity-color: #b584e3;
|
||||
}
|
||||
|
||||
.rarity-legendary {
|
||||
--rarity-color: #f2a13a;
|
||||
}
|
||||
|
||||
.rarity-common {
|
||||
--rarity-color: #a8a3ad;
|
||||
}
|
||||
|
||||
+2
-3
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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
@@ -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),
|
||||
}
|
||||
|
||||
+1867
-5818
File diff suppressed because it is too large
Load Diff
+9
-1
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user