Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 88874933c3 | |||
| bf12aefeeb |
+21
-3
@@ -138,9 +138,12 @@ services:
|
|||||||
|
|
||||||
The app listens inside Docker on port `4173`. The database lives at
|
The app listens inside Docker on port `4173`. The database lives at
|
||||||
`/mnt/usbssds/apps/iwanttoheal/data/game.db` because that host directory is
|
`/mnt/usbssds/apps/iwanttoheal/data/game.db` because that host directory is
|
||||||
mounted into the container as `/app/data`. The startup command installs
|
mounted into the container as `/app/data`. This is persistent runtime data, not
|
||||||
dependencies, applies schema migrations, builds the web app, and starts the
|
code. Do not commit it and do not copy the Mac `data/game.db` over it during
|
||||||
production server.
|
deploys.
|
||||||
|
|
||||||
|
The startup command installs dependencies, applies schema/static-content
|
||||||
|
updates, builds the web app, and starts the production server.
|
||||||
|
|
||||||
Test the local TrueNAS service:
|
Test the local TrueNAS service:
|
||||||
|
|
||||||
@@ -220,11 +223,22 @@ cd /mnt/usbssds/apps/iwanttoheal/app
|
|||||||
git pull
|
git pull
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Before restarting, back up the persistent database:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cp /mnt/usbssds/apps/iwanttoheal/data/game.db \
|
||||||
|
"/mnt/usbssds/apps/iwanttoheal/data/game-before-update-$(date +%Y%m%d-%H%M%S).db"
|
||||||
|
```
|
||||||
|
|
||||||
Restart the `iwanttoheal` app in the TrueNAS Apps UI after pulling. The app
|
Restart the `iwanttoheal` app in the TrueNAS Apps UI after pulling. The app
|
||||||
command runs `npm ci`, `npm run db:init`, `npm run build`, and `npm start` on
|
command runs `npm ci`, `npm run db:init`, `npm run build`, and `npm start` on
|
||||||
startup, so dependency, schema, and browser bundle changes are applied each time
|
startup, so dependency, schema, and browser bundle changes are applied each time
|
||||||
the container restarts.
|
the container restarts.
|
||||||
|
|
||||||
|
`npm run db:init` updates schema and seeded static game content. It should not
|
||||||
|
erase accounts, characters, inventory, or save progress. Character resets are
|
||||||
|
separate manual operations and should only be run intentionally.
|
||||||
|
|
||||||
Normal update workflow:
|
Normal update workflow:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -236,10 +250,14 @@ git push origin main
|
|||||||
# TrueNAS shell
|
# TrueNAS shell
|
||||||
cd /mnt/usbssds/apps/iwanttoheal/app
|
cd /mnt/usbssds/apps/iwanttoheal/app
|
||||||
git pull
|
git pull
|
||||||
|
cp /mnt/usbssds/apps/iwanttoheal/data/game.db \
|
||||||
|
"/mnt/usbssds/apps/iwanttoheal/data/game-before-update-$(date +%Y%m%d-%H%M%S).db"
|
||||||
```
|
```
|
||||||
|
|
||||||
Then restart the TrueNAS app.
|
Then restart the TrueNAS app.
|
||||||
|
|
||||||
|
For the shorter checklist, see [docs/push-updates.md](docs/push-updates.md).
|
||||||
|
|
||||||
### Existing auth-only app
|
### Existing auth-only app
|
||||||
|
|
||||||
If `iwanttoheal-auth` was already created during earlier testing, the simplest
|
If `iwanttoheal-auth` was already created during earlier testing, the simplest
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -57,13 +57,13 @@ For an online production build, see [DEPLOYMENT.md](DEPLOYMENT.md).
|
|||||||
- Dungeon XP rewards, level-up handling, talent-point awards, and ability unlocks
|
- Dungeon XP rewards, level-up handling, talent-point awards, and ability unlocks
|
||||||
- SQLite-backed class talent trees with ranks, tiers, prerequisites, refunds,
|
- SQLite-backed class talent trees with ranks, tiers, prerequisites, refunds,
|
||||||
and immediate persistence
|
and immediate persistence
|
||||||
- Seven equipment slots, starter item-level 1 gear, inventory comparison, and
|
- Nine equipment slots, empty starter inventory, craftable item-level 1 gear,
|
||||||
persistent equipping
|
inventory comparison, and persistent equipping
|
||||||
- Aggregate item level, healing power, and resource bonuses that affect combat
|
- Aggregate item level, healing power, and resource bonuses that affect combat
|
||||||
- Five SQLite-authored dungeon difficulty tiers with level gates, combat
|
- Four playable SQLite-authored content tiers at item levels 1, 10, 20, and 25
|
||||||
scaling, XP multipliers, and item-level reward bands
|
with level gates, combat scaling, XP multipliers, and reward bands
|
||||||
- Encounter-specific weighted loot tables for every difficulty, with authored
|
- Gear progression through item levels 1, 5, 10, 15, 20, and 25 with
|
||||||
drop chances, slot pools, and item-level 5 through 25 reward variants
|
boss-coin crafting and upgrade steps
|
||||||
- One live loot roll per defeated encounter, shown in the combat log and
|
- One live loot roll per defeated encounter, shown in the combat log and
|
||||||
dungeon-complete summary
|
dungeon-complete summary
|
||||||
- Atomic inventory awards with retry-safe roll records and stacked duplicate
|
- Atomic inventory awards with retry-safe roll records and stacked duplicate
|
||||||
|
|||||||
@@ -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 42
|
versionCode 44
|
||||||
versionName "1.0.25"
|
versionName "1.0.26"
|
||||||
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.
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import java.io.File;
|
|||||||
public abstract class ControllerBridgeActivity extends BridgeActivity {
|
public abstract class ControllerBridgeActivity extends BridgeActivity {
|
||||||
|
|
||||||
public static final String EXTRA_INITIAL_URL = "com.warren.iwanttoheal.INITIAL_URL";
|
public static final String EXTRA_INITIAL_URL = "com.warren.iwanttoheal.INITIAL_URL";
|
||||||
private static final long DPAD_THROTTLE_MS = 125;
|
private static final long DPAD_THROTTLE_MS = 220;
|
||||||
private long lastDpadDispatchAt = 0;
|
private long lastDpadDispatchAt = 0;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
+105
-30
@@ -25,28 +25,35 @@ WHERE slug = 'citadel-of-the-ember-crown';
|
|||||||
INSERT OR IGNORE INTO difficulties
|
INSERT OR IGNORE INTO difficulties
|
||||||
(id, slug, name, dropped_item_level, unlock_level, health_multiplier, damage_multiplier, experience_multiplier, description)
|
(id, slug, name, dropped_item_level, unlock_level, health_multiplier, damage_multiplier, experience_multiplier, description)
|
||||||
VALUES
|
VALUES
|
||||||
(1, 'initiate', 'Initiate', 5, 1, 1.0, 1.0, 1.0, 'Entry-level dungeon difficulty.'),
|
(1, 'initiate', 'Initiate', 1, 1, 0.8, 0.8, 1.0, 'Entry-level dungeon difficulty for crafting the first real set.'),
|
||||||
(2, 'veteran', 'Veteran', 10, 5, 1.35, 1.2, 1.5, 'Enemies deal more damage and drop stronger gear.'),
|
(2, 'veteran', 'Veteran', 10, 10, 1.45, 1.25, 2.0, 'A major step up that rewards refined gear components.'),
|
||||||
(3, 'champion', 'Champion', 15, 10, 1.7, 1.45, 2.2, 'Demanding encounters for developed characters.'),
|
(3, 'champion', 'Champion', 15, 15, 1.7, 1.45, 2.2, 'Gear-only upgrade tier between Veteran and Mythic.'),
|
||||||
(4, 'mythic', 'Mythic', 20, 15, 2.1, 1.75, 3.0, 'Endgame dungeon difficulty.'),
|
(4, 'mythic', 'Mythic', 20, 20, 2.25, 1.85, 3.5, 'Endgame dungeon difficulty.'),
|
||||||
(5, 'ascendant', 'Ascendant', 25, 20, 2.6, 2.1, 4.0, 'The current pinnacle difficulty.'),
|
(5, 'ascendant', 'Ascendant', 25, 25, 2.8, 2.25, 4.5, 'The current pinnacle difficulty.'),
|
||||||
(101, 'raid-normal', 'Normal', 7, 1, 1.0, 1.0, 1.25, 'The opening raid difficulty, tuned for an eighteen-player party.');
|
(101, 'raid-normal', 'Normal', 7, 1, 1.0, 1.0, 1.25, 'The opening raid difficulty, tuned for an eighteen-player party.');
|
||||||
|
|
||||||
UPDATE difficulties SET
|
UPDATE difficulties SET
|
||||||
dropped_item_level = CASE slug
|
dropped_item_level = CASE slug
|
||||||
WHEN 'raid-normal' THEN 10 ELSE dropped_item_level END,
|
WHEN 'initiate' THEN 1 WHEN 'raid-normal' THEN 10 ELSE dropped_item_level END,
|
||||||
unlock_level = CASE slug
|
unlock_level = CASE slug
|
||||||
WHEN 'initiate' THEN 1 WHEN 'veteran' THEN 5 WHEN 'champion' THEN 10
|
WHEN 'initiate' THEN 1 WHEN 'veteran' THEN 10 WHEN 'champion' THEN 15
|
||||||
WHEN 'mythic' THEN 15 WHEN 'ascendant' THEN 20 ELSE unlock_level END,
|
WHEN 'mythic' THEN 20 WHEN 'ascendant' THEN 25 ELSE unlock_level END,
|
||||||
health_multiplier = CASE slug
|
health_multiplier = CASE slug
|
||||||
WHEN 'initiate' THEN 1.0 WHEN 'veteran' THEN 1.35 WHEN 'champion' THEN 1.7
|
WHEN 'initiate' THEN 0.8 WHEN 'veteran' THEN 1.45 WHEN 'champion' THEN 1.7
|
||||||
WHEN 'mythic' THEN 2.1 WHEN 'ascendant' THEN 2.6 ELSE health_multiplier END,
|
WHEN 'mythic' THEN 2.25 WHEN 'ascendant' THEN 2.8 ELSE health_multiplier END,
|
||||||
damage_multiplier = CASE slug
|
damage_multiplier = CASE slug
|
||||||
WHEN 'initiate' THEN 1.0 WHEN 'veteran' THEN 1.2 WHEN 'champion' THEN 1.45
|
WHEN 'initiate' THEN 0.8 WHEN 'veteran' THEN 1.25 WHEN 'champion' THEN 1.45
|
||||||
WHEN 'mythic' THEN 1.75 WHEN 'ascendant' THEN 2.1 ELSE damage_multiplier END,
|
WHEN 'mythic' THEN 1.85 WHEN 'ascendant' THEN 2.25 ELSE damage_multiplier END,
|
||||||
experience_multiplier = CASE slug
|
experience_multiplier = CASE slug
|
||||||
WHEN 'initiate' THEN 1.0 WHEN 'veteran' THEN 1.5 WHEN 'champion' THEN 2.2
|
WHEN 'initiate' THEN 1.0 WHEN 'veteran' THEN 2.0 WHEN 'champion' THEN 2.2
|
||||||
WHEN 'mythic' THEN 3.0 WHEN 'ascendant' THEN 4.0 ELSE experience_multiplier END;
|
WHEN 'mythic' THEN 3.5 WHEN 'ascendant' THEN 4.5 ELSE experience_multiplier END,
|
||||||
|
description = CASE slug
|
||||||
|
WHEN 'initiate' THEN 'Entry-level dungeon difficulty for crafting the first real set.'
|
||||||
|
WHEN 'veteran' THEN 'A major step up that rewards refined gear components.'
|
||||||
|
WHEN 'champion' THEN 'Gear-only upgrade tier between Veteran and Mythic.'
|
||||||
|
WHEN 'mythic' THEN 'Endgame dungeon difficulty.'
|
||||||
|
WHEN 'ascendant' THEN 'The current pinnacle difficulty.'
|
||||||
|
ELSE description END;
|
||||||
|
|
||||||
INSERT OR IGNORE INTO dungeon_difficulties (dungeon_id, difficulty_id) VALUES
|
INSERT OR IGNORE INTO dungeon_difficulties (dungeon_id, difficulty_id) VALUES
|
||||||
(1, 1),
|
(1, 1),
|
||||||
@@ -345,6 +352,9 @@ DELETE FROM crafting_recipes;
|
|||||||
INSERT INTO crafting_recipes
|
INSERT INTO crafting_recipes
|
||||||
(id, item_id, difficulty_id, source_dungeon_id, source_encounter_id)
|
(id, item_id, difficulty_id, source_dungeon_id, source_encounter_id)
|
||||||
VALUES
|
VALUES
|
||||||
|
(901, 101, 1, 1, 3), (902, 102, 1, 1, 3), (903, 103, 1, 1, 3),
|
||||||
|
(904, 104, 1, 1, 12), (905, 105, 1, 1, 12), (906, 106, 1, 1, 12),
|
||||||
|
(907, 100, 1, 1, 22), (908, 108, 1, 1, 22), (909, 109, 1, 1, 22),
|
||||||
(1001, 5, 1, 1, 3), (1002, 2, 1, 1, 3), (1003, 6, 1, 1, 3),
|
(1001, 5, 1, 1, 3), (1002, 2, 1, 1, 3), (1003, 6, 1, 1, 3),
|
||||||
(1004, 4, 1, 1, 12), (1005, 1, 1, 1, 12), (1006, 7, 1, 1, 12),
|
(1004, 4, 1, 1, 12), (1005, 1, 1, 1, 12), (1006, 7, 1, 1, 12),
|
||||||
(1007, 3, 1, 1, 22), (1008, 8, 1, 1, 22), (1009, 9, 1, 1, 22),
|
(1007, 3, 1, 1, 22), (1008, 8, 1, 1, 22), (1009, 9, 1, 1, 22),
|
||||||
@@ -429,6 +439,10 @@ 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);
|
||||||
|
|
||||||
|
DELETE FROM character_inventory
|
||||||
|
WHERE character_id IN (1, 2, 3)
|
||||||
|
AND item_id BETWEEN 100 AND 109;
|
||||||
|
|
||||||
-- Coin gearing override: every boss/difficulty drops one boss coin, and each
|
-- Coin gearing override: every boss/difficulty drops one boss coin, and each
|
||||||
-- craft costs the target item level in that source boss coin.
|
-- craft costs the target item level in that source boss coin.
|
||||||
UPDATE crafting_recipes
|
UPDATE crafting_recipes
|
||||||
@@ -452,17 +466,19 @@ WHERE id BETWEEN 1001 AND 1409
|
|||||||
UPDATE crafting_recipes
|
UPDATE crafting_recipes
|
||||||
SET difficulty_id = CASE
|
SET difficulty_id = CASE
|
||||||
(SELECT item_level FROM items WHERE items.id = crafting_recipes.item_id)
|
(SELECT item_level FROM items WHERE items.id = crafting_recipes.item_id)
|
||||||
|
WHEN 1 THEN 1
|
||||||
WHEN 5 THEN 1
|
WHEN 5 THEN 1
|
||||||
WHEN 10 THEN 2
|
WHEN 10 THEN 2
|
||||||
WHEN 15 THEN 3
|
WHEN 15 THEN 2
|
||||||
WHEN 20 THEN 4
|
WHEN 20 THEN 4
|
||||||
WHEN 25 THEN 5
|
WHEN 25 THEN 5
|
||||||
ELSE difficulty_id
|
ELSE difficulty_id
|
||||||
END
|
END
|
||||||
WHERE id BETWEEN 1001 AND 1409;
|
WHERE id BETWEEN 901 AND 1409;
|
||||||
|
|
||||||
UPDATE items
|
UPDATE items
|
||||||
SET rarity = CASE item_level
|
SET rarity = CASE item_level
|
||||||
|
WHEN 1 THEN 'common'
|
||||||
WHEN 5 THEN 'common'
|
WHEN 5 THEN 'common'
|
||||||
WHEN 10 THEN 'uncommon'
|
WHEN 10 THEN 'uncommon'
|
||||||
WHEN 15 THEN 'rare'
|
WHEN 15 THEN 'rare'
|
||||||
@@ -476,7 +492,8 @@ UPDATE items
|
|||||||
SET name = (
|
SET name = (
|
||||||
SELECT
|
SELECT
|
||||||
CASE items.item_level
|
CASE items.item_level
|
||||||
WHEN 5 THEN ''
|
WHEN 1 THEN 'Raw '
|
||||||
|
WHEN 5 THEN 'Honed '
|
||||||
WHEN 10 THEN 'Green '
|
WHEN 10 THEN 'Green '
|
||||||
WHEN 15 THEN 'Blue '
|
WHEN 15 THEN 'Blue '
|
||||||
WHEN 20 THEN 'Purple '
|
WHEN 20 THEN 'Purple '
|
||||||
@@ -532,7 +549,8 @@ SELECT
|
|||||||
difficulties.dropped_item_level,
|
difficulties.dropped_item_level,
|
||||||
encounters.slug || '-coin-ilvl-' || difficulties.dropped_item_level,
|
encounters.slug || '-coin-ilvl-' || difficulties.dropped_item_level,
|
||||||
CASE difficulties.dropped_item_level
|
CASE difficulties.dropped_item_level
|
||||||
WHEN 5 THEN ''
|
WHEN 1 THEN 'Raw '
|
||||||
|
WHEN 5 THEN 'Honed '
|
||||||
WHEN 10 THEN 'Green '
|
WHEN 10 THEN 'Green '
|
||||||
WHEN 15 THEN 'Blue '
|
WHEN 15 THEN 'Blue '
|
||||||
WHEN 20 THEN 'Purple '
|
WHEN 20 THEN 'Purple '
|
||||||
@@ -540,6 +558,7 @@ SELECT
|
|||||||
ELSE ''
|
ELSE ''
|
||||||
END || encounters.name || ' Coin',
|
END || encounters.name || ' Coin',
|
||||||
CASE difficulties.dropped_item_level
|
CASE difficulties.dropped_item_level
|
||||||
|
WHEN 1 THEN 'common'
|
||||||
WHEN 5 THEN 'common'
|
WHEN 5 THEN 'common'
|
||||||
WHEN 10 THEN 'uncommon'
|
WHEN 10 THEN 'uncommon'
|
||||||
WHEN 15 THEN 'rare'
|
WHEN 15 THEN 'rare'
|
||||||
@@ -709,7 +728,6 @@ INSERT INTO generated_loot_tiers
|
|||||||
(item_level, dungeon_id, raid_id, dungeon_difficulty_id, raid_difficulty_id, recipe_base, craft_quantity)
|
(item_level, dungeon_id, raid_id, dungeon_difficulty_id, raid_difficulty_id, recipe_base, craft_quantity)
|
||||||
VALUES
|
VALUES
|
||||||
(10, 3, 2, 2, 101, 1100, 2),
|
(10, 3, 2, 2, 101, 1100, 2),
|
||||||
(15, 4, 5, 3, 103, 1200, 3),
|
|
||||||
(20, 6, 7, 4, 104, 1300, 4),
|
(20, 6, 7, 4, 104, 1300, 4),
|
||||||
(25, 8, 9, 5, 105, 1400, 5);
|
(25, 8, 9, 5, 105, 1400, 5);
|
||||||
|
|
||||||
@@ -792,19 +810,58 @@ VALUES
|
|||||||
|
|
||||||
UPDATE difficulties
|
UPDATE difficulties
|
||||||
SET dropped_item_level = 10,
|
SET dropped_item_level = 10,
|
||||||
unlock_level = 5,
|
unlock_level = 10,
|
||||||
health_multiplier = 1.35,
|
health_multiplier = 1.45,
|
||||||
damage_multiplier = 1.2,
|
damage_multiplier = 1.25,
|
||||||
experience_multiplier = 1.75,
|
experience_multiplier = 2.0,
|
||||||
description = 'Veteran raid difficulty with extra monster-part drops.'
|
description = 'Veteran raid difficulty with extra monster-part drops.'
|
||||||
WHERE id = 101;
|
WHERE id = 101;
|
||||||
|
|
||||||
INSERT OR IGNORE INTO difficulties
|
INSERT OR IGNORE INTO difficulties
|
||||||
(id, slug, name, dropped_item_level, unlock_level, health_multiplier, damage_multiplier, experience_multiplier, description)
|
(id, slug, name, dropped_item_level, unlock_level, health_multiplier, damage_multiplier, experience_multiplier, description)
|
||||||
VALUES
|
VALUES
|
||||||
(103, 'raid-champion', 'Champion Raid', 15, 10, 1.7, 1.45, 2.4, 'Champion raid difficulty with extra monster-part drops.'),
|
(103, 'raid-champion', 'Champion Raid', 15, 15, 1.7, 1.45, 2.4, 'Gear-only raid upgrade tier between Veteran and Mythic.'),
|
||||||
(104, 'raid-mythic', 'Mythic Raid', 20, 15, 2.1, 1.75, 3.2, 'Mythic raid difficulty with extra monster-part drops.'),
|
(104, 'raid-mythic', 'Mythic Raid', 20, 20, 2.25, 1.85, 3.5, 'Mythic raid difficulty with extra monster-part drops.'),
|
||||||
(105, 'raid-ascendant', 'Ascendant Raid', 25, 20, 2.6, 2.1, 4.2, 'Ascendant raid difficulty with extra monster-part drops.');
|
(105, 'raid-ascendant', 'Ascendant Raid', 25, 25, 2.8, 2.25, 4.5, 'Ascendant raid difficulty with extra monster-part drops.');
|
||||||
|
|
||||||
|
UPDATE difficulties
|
||||||
|
SET dropped_item_level = CASE id
|
||||||
|
WHEN 103 THEN 15
|
||||||
|
WHEN 104 THEN 20
|
||||||
|
WHEN 105 THEN 25
|
||||||
|
ELSE dropped_item_level
|
||||||
|
END,
|
||||||
|
unlock_level = CASE id
|
||||||
|
WHEN 103 THEN 15
|
||||||
|
WHEN 104 THEN 20
|
||||||
|
WHEN 105 THEN 25
|
||||||
|
ELSE unlock_level
|
||||||
|
END,
|
||||||
|
health_multiplier = CASE id
|
||||||
|
WHEN 103 THEN 1.7
|
||||||
|
WHEN 104 THEN 2.25
|
||||||
|
WHEN 105 THEN 2.8
|
||||||
|
ELSE health_multiplier
|
||||||
|
END,
|
||||||
|
damage_multiplier = CASE id
|
||||||
|
WHEN 103 THEN 1.45
|
||||||
|
WHEN 104 THEN 1.85
|
||||||
|
WHEN 105 THEN 2.25
|
||||||
|
ELSE damage_multiplier
|
||||||
|
END,
|
||||||
|
experience_multiplier = CASE id
|
||||||
|
WHEN 103 THEN 2.4
|
||||||
|
WHEN 104 THEN 3.5
|
||||||
|
WHEN 105 THEN 4.5
|
||||||
|
ELSE experience_multiplier
|
||||||
|
END,
|
||||||
|
description = CASE id
|
||||||
|
WHEN 103 THEN 'Gear-only raid upgrade tier between Veteran and Mythic.'
|
||||||
|
WHEN 104 THEN 'Mythic raid difficulty with extra monster-part drops.'
|
||||||
|
WHEN 105 THEN 'Ascendant raid difficulty with extra monster-part drops.'
|
||||||
|
ELSE description
|
||||||
|
END
|
||||||
|
WHERE id IN (103, 104, 105);
|
||||||
|
|
||||||
DELETE FROM dungeon_difficulties WHERE dungeon_id = 2 AND difficulty_id <> 101;
|
DELETE FROM dungeon_difficulties WHERE dungeon_id = 2 AND difficulty_id <> 101;
|
||||||
|
|
||||||
@@ -1161,6 +1218,19 @@ SET slug = CASE id
|
|||||||
END
|
END
|
||||||
WHERE id BETWEEN 860 AND 871;
|
WHERE id BETWEEN 860 AND 871;
|
||||||
|
|
||||||
|
DELETE FROM dungeon_difficulties;
|
||||||
|
INSERT OR IGNORE INTO dungeon_difficulties (dungeon_id, difficulty_id) VALUES
|
||||||
|
(1, 1),
|
||||||
|
(1, 2),
|
||||||
|
(1, 4),
|
||||||
|
(1, 5),
|
||||||
|
(3, 2),
|
||||||
|
(6, 4),
|
||||||
|
(8, 5),
|
||||||
|
(2, 101),
|
||||||
|
(7, 104),
|
||||||
|
(9, 105);
|
||||||
|
|
||||||
DELETE FROM crafting_recipe_components WHERE recipe_id BETWEEN 1001 AND 1009;
|
DELETE FROM crafting_recipe_components WHERE recipe_id BETWEEN 1001 AND 1009;
|
||||||
|
|
||||||
INSERT OR IGNORE INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES
|
INSERT OR IGNORE INTO crafting_recipe_components (recipe_id, item_id, quantity) VALUES
|
||||||
@@ -1196,17 +1266,19 @@ WHERE id BETWEEN 1001 AND 1409
|
|||||||
UPDATE crafting_recipes
|
UPDATE crafting_recipes
|
||||||
SET difficulty_id = CASE
|
SET difficulty_id = CASE
|
||||||
(SELECT item_level FROM items WHERE items.id = crafting_recipes.item_id)
|
(SELECT item_level FROM items WHERE items.id = crafting_recipes.item_id)
|
||||||
|
WHEN 1 THEN 1
|
||||||
WHEN 5 THEN 1
|
WHEN 5 THEN 1
|
||||||
WHEN 10 THEN 2
|
WHEN 10 THEN 2
|
||||||
WHEN 15 THEN 3
|
WHEN 15 THEN 2
|
||||||
WHEN 20 THEN 4
|
WHEN 20 THEN 4
|
||||||
WHEN 25 THEN 5
|
WHEN 25 THEN 5
|
||||||
ELSE difficulty_id
|
ELSE difficulty_id
|
||||||
END
|
END
|
||||||
WHERE id BETWEEN 1001 AND 1409;
|
WHERE id BETWEEN 901 AND 1409;
|
||||||
|
|
||||||
UPDATE items
|
UPDATE items
|
||||||
SET rarity = CASE item_level
|
SET rarity = CASE item_level
|
||||||
|
WHEN 1 THEN 'common'
|
||||||
WHEN 5 THEN 'common'
|
WHEN 5 THEN 'common'
|
||||||
WHEN 10 THEN 'uncommon'
|
WHEN 10 THEN 'uncommon'
|
||||||
WHEN 15 THEN 'rare'
|
WHEN 15 THEN 'rare'
|
||||||
@@ -1220,7 +1292,8 @@ UPDATE items
|
|||||||
SET name = (
|
SET name = (
|
||||||
SELECT
|
SELECT
|
||||||
CASE items.item_level
|
CASE items.item_level
|
||||||
WHEN 5 THEN ''
|
WHEN 1 THEN 'Raw '
|
||||||
|
WHEN 5 THEN 'Honed '
|
||||||
WHEN 10 THEN 'Green '
|
WHEN 10 THEN 'Green '
|
||||||
WHEN 15 THEN 'Blue '
|
WHEN 15 THEN 'Blue '
|
||||||
WHEN 20 THEN 'Purple '
|
WHEN 20 THEN 'Purple '
|
||||||
@@ -1264,7 +1337,8 @@ SELECT
|
|||||||
difficulties.dropped_item_level,
|
difficulties.dropped_item_level,
|
||||||
encounters.slug || '-coin-ilvl-' || difficulties.dropped_item_level,
|
encounters.slug || '-coin-ilvl-' || difficulties.dropped_item_level,
|
||||||
CASE difficulties.dropped_item_level
|
CASE difficulties.dropped_item_level
|
||||||
WHEN 5 THEN ''
|
WHEN 1 THEN 'Raw '
|
||||||
|
WHEN 5 THEN 'Honed '
|
||||||
WHEN 10 THEN 'Green '
|
WHEN 10 THEN 'Green '
|
||||||
WHEN 15 THEN 'Blue '
|
WHEN 15 THEN 'Blue '
|
||||||
WHEN 20 THEN 'Purple '
|
WHEN 20 THEN 'Purple '
|
||||||
@@ -1272,6 +1346,7 @@ SELECT
|
|||||||
ELSE ''
|
ELSE ''
|
||||||
END || encounters.name || ' Coin',
|
END || encounters.name || ' Coin',
|
||||||
CASE difficulties.dropped_item_level
|
CASE difficulties.dropped_item_level
|
||||||
|
WHEN 1 THEN 'common'
|
||||||
WHEN 5 THEN 'common'
|
WHEN 5 THEN 'common'
|
||||||
WHEN 10 THEN 'uncommon'
|
WHEN 10 THEN 'uncommon'
|
||||||
WHEN 15 THEN 'rare'
|
WHEN 15 THEN 'rare'
|
||||||
|
|||||||
+105
-138
@@ -1,172 +1,139 @@
|
|||||||
# Gearing System
|
# Gearing System
|
||||||
|
|
||||||
## Goal
|
## Current Rule
|
||||||
|
|
||||||
Gearing should move from boss-specific multi-item drop tables to one clear currency loop:
|
The game uses fewer playable content tiers and more gear upgrade steps.
|
||||||
|
|
||||||
1. Kill bosses.
|
Content tiers:
|
||||||
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.
|
| Content Tier | Unlock Level | Purpose |
|
||||||
|
| --- | ---: | --- |
|
||||||
|
| iLvl 1 | 1 | First real gear set |
|
||||||
|
| iLvl 10 | 10 | Midgame jump |
|
||||||
|
| iLvl 20 | 20 | Endgame jump |
|
||||||
|
| iLvl 25 | 25 | Hard endgame |
|
||||||
|
|
||||||
|
Gear tiers:
|
||||||
|
|
||||||
|
| Gear Tier | How It Is Used |
|
||||||
|
| ---: | --- |
|
||||||
|
| 1 | Crafted from iLvl 1 content |
|
||||||
|
| 5 | Upgrade tier from iLvl 1 content coins |
|
||||||
|
| 10 | Crafted/upgraded from iLvl 10 content coins |
|
||||||
|
| 15 | Gear-only upgrade tier from iLvl 10 content coins |
|
||||||
|
| 20 | Crafted/upgraded from iLvl 20 content coins |
|
||||||
|
| 25 | Crafted/upgraded from iLvl 25 content coins |
|
||||||
|
|
||||||
|
This keeps the dungeon/raid picker simple while still giving players steady gear goals.
|
||||||
|
|
||||||
|
## New Characters
|
||||||
|
|
||||||
|
New characters start with no gear equipped.
|
||||||
|
|
||||||
|
The first goal is to clear iLvl 1 content, earn raw boss coins, and craft the first iLvl 1 set. Starter gear exists as craftable gear, not automatic inventory.
|
||||||
|
|
||||||
## Coin Tiers
|
## Coin Tiers
|
||||||
|
|
||||||
Coins are component items. Each coin is tied to a boss source and an item-level tier.
|
Coins are component items. Each coin is tied to a boss and a content tier.
|
||||||
|
|
||||||
| Item Level | Display Color | Rarity Key | Example |
|
| Content Tier | Coin Prefix | Rarity Key | Example |
|
||||||
| --- | --- | --- | --- |
|
| ---: | --- | --- | --- |
|
||||||
| 5 | White | common | Bulldrome Coin |
|
| 1 | Raw | common | Raw Bulldrome Coin |
|
||||||
| 10 | Green | uncommon | Green Bulldrome Coin |
|
| 10 | Green | uncommon | Green Bulldrome Coin |
|
||||||
| 15 | Blue | rare | Blue Bulldrome Coin |
|
|
||||||
| 20 | Purple | epic | Purple Bulldrome Coin |
|
| 20 | Purple | epic | Purple Bulldrome Coin |
|
||||||
| 25 | Orange | legendary | Orange 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.
|
iLvl 5 and iLvl 15 gear do not have their own playable content tier. They use coins from the previous playable tier:
|
||||||
|
|
||||||
|
| Gear Tier | Coin Tier Used |
|
||||||
|
| ---: | ---: |
|
||||||
|
| 1 | 1 |
|
||||||
|
| 5 | 1 |
|
||||||
|
| 10 | 10 |
|
||||||
|
| 15 | 10 |
|
||||||
|
| 20 | 20 |
|
||||||
|
| 25 | 25 |
|
||||||
|
|
||||||
## Boss Loot
|
## Boss Loot
|
||||||
|
|
||||||
Each boss has one loot roll.
|
Each boss drops one boss coin for the selected content tier.
|
||||||
|
|
||||||
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:
|
Examples:
|
||||||
|
|
||||||
| Upgrade | Cost |
|
- Bulldrome at iLvl 1 drops Raw Bulldrome Coins.
|
||||||
| --- | --- |
|
- Tigrex at iLvl 10 drops Green Tigrex Coins.
|
||||||
| Bulldrome item level 5 gear -> Bulldrome item level 10 gear | 10 green Bulldrome coins |
|
- Barroth at iLvl 20 drops Purple Barroth Coins.
|
||||||
| Bulldrome item level 10 gear -> Bulldrome item level 15 gear | 15 blue Bulldrome coins |
|
- Anjanath at iLvl 25 drops Orange Anjanath 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.
|
## Crafting And Upgrades
|
||||||
|
|
||||||
|
The first gear item in a boss/item line can be crafted directly. Higher versions should be reached through Upgrade.
|
||||||
|
|
||||||
|
Current rule:
|
||||||
|
|
||||||
|
- Craft iLvl 1 boss gear directly.
|
||||||
|
- Upgrade iLvl 1 -> 5 with iLvl 1 coins.
|
||||||
|
- Craft or upgrade iLvl 10 gear from iLvl 10 content.
|
||||||
|
- Upgrade iLvl 10 -> 15 with iLvl 10 coins.
|
||||||
|
- Craft or upgrade iLvl 20 gear from iLvl 20 content.
|
||||||
|
- Craft or upgrade iLvl 25 gear from iLvl 25 content.
|
||||||
|
|
||||||
|
Upgrade consumes the old item and awards the upgraded item. This avoids duplicate clutter and keeps item identity clear.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
| Upgrade | Cost Source |
|
||||||
|
| --- | --- |
|
||||||
|
| Raw Bulldrome Helmet iLvl 1 -> Honed Bulldrome Helmet iLvl 5 | Raw Bulldrome Coins |
|
||||||
|
| Green Tigrex Helmet iLvl 10 -> Blue Tigrex Helmet iLvl 15 | Green Tigrex Coins |
|
||||||
|
| Purple Bulldrome Helmet iLvl 20 -> Orange Bulldrome Helmet iLvl 25 | Orange Bulldrome Coins |
|
||||||
|
|
||||||
|
## UI Behavior
|
||||||
|
|
||||||
|
The dungeon and raid picker only shows playable content tiers:
|
||||||
|
|
||||||
|
```text
|
||||||
|
iLvl 1 -> iLvl 10 -> iLvl 20 -> iLvl 25
|
||||||
|
```
|
||||||
|
|
||||||
|
The equipment screen still shows gear recipes for:
|
||||||
|
|
||||||
|
```text
|
||||||
|
iLvl 1 -> iLvl 5 -> iLvl 10 -> iLvl 15 -> iLvl 20 -> iLvl 25
|
||||||
|
```
|
||||||
|
|
||||||
|
Direct crafting is blocked for recipes that have a lower item-level version in the same boss/item line. Use the selected item's Upgrade button for those.
|
||||||
|
|
||||||
## Roguelike Loot
|
## Roguelike Loot
|
||||||
|
|
||||||
Roguelike bosses should award coins when defeated, using the same 1 to 3 coin roll.
|
Roguelike gear should follow the same tier brackets.
|
||||||
|
|
||||||
Roguelike coin tier should scale by wave band:
|
Recommended mapping:
|
||||||
|
|
||||||
| Waves | Coin Tier |
|
| Stage Band | Coin Tier |
|
||||||
| --- | --- |
|
| --- | ---: |
|
||||||
| 1-4 | Level 5 white coins |
|
| 1-4 | 1 |
|
||||||
| 5-9 | Level 10 green coins |
|
| 5-9 | 10 |
|
||||||
| 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 |
|
| 10-14 | 10 |
|
||||||
| 15-19 | 15 |
|
| 15-19 | 20 |
|
||||||
| 20+ | Highest unlocked 5-wave checkpoint |
|
| 20+ | 25 |
|
||||||
|
|
||||||
Checkpoint rule:
|
Boss-based coins are still preferred. A roguelike boss should award coins for its actual boss template when possible.
|
||||||
|
|
||||||
- Unlock a checkpoint after clearing its boss band.
|
## Data Notes
|
||||||
- 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.
|
Authoritative gearing data lives in SQLite seed data:
|
||||||
|
|
||||||
## Current Code Fit
|
- `db/seed.sql`
|
||||||
|
- `src/offline-starter-profile.json`
|
||||||
|
|
||||||
The existing system already has most of the required foundation:
|
Run this after changing seed data:
|
||||||
|
|
||||||
- `items.slot = 'component'` can represent coins.
|
```sh
|
||||||
- `character_inventory.quantity` already stacks components.
|
npm run db:init
|
||||||
- `crafting_recipes` and `crafting_recipe_components` already support coin costs.
|
npm run offline:export
|
||||||
- `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:
|
`npm run build` already runs `offline:export`, so a production build also refreshes the offline starter profile.
|
||||||
|
|
||||||
- Replace current 4-component boss drop tables with one boss coin per boss per tier.
|
TrueNAS keeps its own persistent `data/game.db`. Pushing code does not merge or replace that database. The TrueNAS app applies seed/schema changes when the container starts and runs `npm run db:init`.
|
||||||
- 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.
|
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
# Push Updates
|
||||||
|
|
||||||
|
Use this when pushing code from the Mac to `git.whoagland.com`, then updating the TrueNAS-hosted server.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- Git deploys code only.
|
||||||
|
- TrueNAS save data lives in `/mnt/usbssds/apps/iwanttoheal/data/game.db`.
|
||||||
|
- Do not commit, copy, or replace `data/game.db`.
|
||||||
|
- Do not run character reset commands unless you intentionally want a wipe.
|
||||||
|
- Restarting the TrueNAS app runs `npm ci`, `npm run db:init`, `npm run build`, and `npm start`.
|
||||||
|
|
||||||
|
## Step 1: Build Web Locally
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd /Users/warren/Documents/testgame/testgame
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
This validates TypeScript, builds the browser app, and refreshes `src/offline-starter-profile.json`.
|
||||||
|
|
||||||
|
## Step 2: Optional Android APK
|
||||||
|
|
||||||
|
Only run this when building a new APK.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
cd /Users/warren/Documents/testgame/testgame
|
||||||
|
|
||||||
|
export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"
|
||||||
|
export PATH="$JAVA_HOME/bin:$PATH"
|
||||||
|
|
||||||
|
VERSION="1.0.27"
|
||||||
|
|
||||||
|
CURRENT_CODE=$(grep -E 'versionCode [0-9]+' android/app/build.gradle | awk '{print $2}')
|
||||||
|
NEXT_CODE=$((CURRENT_CODE + 1))
|
||||||
|
|
||||||
|
perl -0pi -e "s/versionCode\s+\d+/versionCode $NEXT_CODE/" android/app/build.gradle
|
||||||
|
perl -0pi -e "s/versionName\s+\"[^\"]+\"/versionName \"$VERSION\"/" android/app/build.gradle
|
||||||
|
|
||||||
|
VITE_API_BASE_URL="https://iwanttoheal.phenomrom.com" npm run android:sync
|
||||||
|
|
||||||
|
cd android
|
||||||
|
./gradlew clean assembleDebug
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
cp android/app/build/outputs/apk/debug/app-debug.apk "IWantToHeal-Thor-v$VERSION.apk"
|
||||||
|
ls -lh "IWantToHeal-Thor-v$VERSION.apk"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 3: Commit And Push Code
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd /Users/warren/Documents/testgame/testgame
|
||||||
|
|
||||||
|
git add .
|
||||||
|
git commit -m "Update game 1.0.27"
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
Check before committing:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git status --short
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: source/doc/build-output changes only. Do not stage `data/game.db`.
|
||||||
|
|
||||||
|
## Step 4: Optional Gitea Release For APK
|
||||||
|
|
||||||
|
Only run this when Step 2 created a new APK.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
cd /Users/warren/Documents/testgame/testgame
|
||||||
|
|
||||||
|
export GITEA_URL="https://git.whoagland.com"
|
||||||
|
export GITEA_OWNER="phenom"
|
||||||
|
export GITEA_REPO="i-want-to-heal"
|
||||||
|
export GITEA_TOKEN="PASTE_TOKEN_HERE"
|
||||||
|
|
||||||
|
VERSION="1.0.26"
|
||||||
|
APK="IWantToHeal-Thor-v$VERSION.apk"
|
||||||
|
|
||||||
|
RELEASE_JSON=$(curl -sS -X POST "$GITEA_URL/api/v1/repos/$GITEA_OWNER/$GITEA_REPO/releases" \
|
||||||
|
-H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"tag_name\":\"v$VERSION\",\"target_commitish\":\"main\",\"name\":\"v$VERSION\",\"body\":\"I Want to Heal Android build v$VERSION\",\"draft\":false,\"prerelease\":false}")
|
||||||
|
|
||||||
|
RELEASE_ID=$(python3 -c 'import sys,json; data=json.load(sys.stdin); print(data.get("id") or data)' <<< "$RELEASE_JSON")
|
||||||
|
|
||||||
|
curl -sS -X POST "$GITEA_URL/api/v1/repos/$GITEA_OWNER/$GITEA_REPO/releases/$RELEASE_ID/assets?name=$APK" \
|
||||||
|
-H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
-F "attachment=@$APK"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 5: Update TrueNAS
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd /mnt/usbssds/apps/iwanttoheal/app
|
||||||
|
git pull
|
||||||
|
```
|
||||||
|
|
||||||
|
Before restarting, make a DB backup:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cp /mnt/usbssds/apps/iwanttoheal/data/game.db \
|
||||||
|
"/mnt/usbssds/apps/iwanttoheal/data/game-before-update-$(date +%Y%m%d-%H%M%S).db"
|
||||||
|
```
|
||||||
|
|
||||||
|
Then restart the `iwanttoheal` app in the TrueNAS Apps UI.
|
||||||
|
|
||||||
|
## What Happens On Restart
|
||||||
|
|
||||||
|
The app command runs:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm ci && npm run db:init && npm run build && npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
That means:
|
||||||
|
|
||||||
|
- dependency changes apply
|
||||||
|
- schema changes apply
|
||||||
|
- seed/static-content updates apply
|
||||||
|
- browser files rebuild
|
||||||
|
- existing accounts and characters stay in `data/game.db`
|
||||||
|
|
||||||
|
`npm run db:init` should not wipe saves. Character wipes are separate manual reset operations.
|
||||||
|
|
||||||
|
## Resetting TrueNAS Characters
|
||||||
|
|
||||||
|
Only run a reset when intentionally starting everyone over.
|
||||||
|
|
||||||
|
Resetting the Mac database does not reset TrueNAS. TrueNAS has its own persistent DB at:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/mnt/usbssds/apps/iwanttoheal/data/game.db
|
||||||
|
```
|
||||||
|
|
||||||
|
Back it up first, then run the reset command or reset SQL on TrueNAS.
|
||||||
|
|
||||||
|
## If Something Looks Wrong
|
||||||
|
|
||||||
|
Check the mounted DB path:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ls -lh /mnt/usbssds/apps/iwanttoheal/data/game.db
|
||||||
|
```
|
||||||
|
|
||||||
|
Check the latest code:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd /mnt/usbssds/apps/iwanttoheal/app
|
||||||
|
git log --oneline -5
|
||||||
|
```
|
||||||
|
|
||||||
|
Check the app API:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl http://127.0.0.1:4173/api/auth/session
|
||||||
|
```
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720">
|
||||||
|
<rect width="1280" height="720" fill="#111218"/>
|
||||||
|
<rect x="48" y="36" width="1184" height="648" fill="#1b1c24" stroke="#090a0d" stroke-width="4"/>
|
||||||
|
<text x="78" y="78" fill="#8f90a0" font-family="monospace" font-size="18">ADVENTURE</text>
|
||||||
|
<text x="78" y="114" fill="#f2f0dc" font-family="monospace" font-size="34" font-weight="700">Dungeons</text>
|
||||||
|
<text x="1075" y="88" fill="#e5b95f" font-family="monospace" font-size="18">B Back</text>
|
||||||
|
|
||||||
|
<text x="78" y="158" fill="#e5b95f" font-family="monospace" font-size="18">Pick Dungeon</text>
|
||||||
|
<g>
|
||||||
|
<rect x="78" y="178" width="335" height="128" fill="#24262f" stroke="#e5b95f" stroke-width="5"/>
|
||||||
|
<rect x="94" y="194" width="72" height="72" fill="#481e29" stroke="#090a0d" stroke-width="3"/>
|
||||||
|
<text x="116" y="241" fill="#ef6574" font-family="monospace" font-size="28" font-weight="700">AH</text>
|
||||||
|
<text x="184" y="216" fill="#f2f0dc" font-family="monospace" font-size="20" font-weight="700">Ashen Halls</text>
|
||||||
|
<text x="184" y="246" fill="#aaa9b7" font-family="monospace" font-size="16">Level 1 • 6 Players</text>
|
||||||
|
<text x="184" y="276" fill="#e5b95f" font-family="monospace" font-size="16">Selected</text>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="436" y="178" width="335" height="128" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<rect x="452" y="194" width="72" height="72" fill="#2a263f" stroke="#090a0d" stroke-width="3"/>
|
||||||
|
<text x="474" y="241" fill="#8ca9ff" font-family="monospace" font-size="28" font-weight="700">SC</text>
|
||||||
|
<text x="542" y="216" fill="#f2f0dc" font-family="monospace" font-size="20" font-weight="700">Sunken Crypt</text>
|
||||||
|
<text x="542" y="246" fill="#aaa9b7" font-family="monospace" font-size="16">Level 5 • 6 Players</text>
|
||||||
|
</g>
|
||||||
|
<g opacity="0.65">
|
||||||
|
<rect x="794" y="178" width="335" height="128" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<rect x="810" y="194" width="72" height="72" fill="#223226" stroke="#090a0d" stroke-width="3"/>
|
||||||
|
<text x="832" y="241" fill="#70d990" font-family="monospace" font-size="28" font-weight="700">GM</text>
|
||||||
|
<text x="900" y="216" fill="#f2f0dc" font-family="monospace" font-size="20" font-weight="700">Grove Maw</text>
|
||||||
|
<text x="900" y="246" fill="#aaa9b7" font-family="monospace" font-size="16">Level 10 • 6 Players</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<text x="78" y="356" fill="#e5b95f" font-family="monospace" font-size="18">Pick Part</text>
|
||||||
|
<g>
|
||||||
|
<rect x="78" y="376" width="282" height="82" fill="#29291f" stroke="#e5b95f" stroke-width="5"/>
|
||||||
|
<text x="112" y="426" fill="#f2f0dc" font-family="monospace" font-size="24" font-weight="700">Part 1</text>
|
||||||
|
<text x="250" y="426" fill="#8f90a0" font-family="monospace" font-size="16">3 fights</text>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="382" y="376" width="282" height="82" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<text x="416" y="426" fill="#f2f0dc" font-family="monospace" font-size="24" font-weight="700">Part 2</text>
|
||||||
|
<text x="554" y="426" fill="#8f90a0" font-family="monospace" font-size="16">Locked</text>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="686" y="376" width="282" height="82" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<text x="720" y="426" fill="#f2f0dc" font-family="monospace" font-size="24" font-weight="700">Part 3</text>
|
||||||
|
<text x="858" y="426" fill="#8f90a0" font-family="monospace" font-size="16">Locked</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<text x="78" y="508" fill="#e5b95f" font-family="monospace" font-size="18">Pick Difficulty</text>
|
||||||
|
<rect x="78" y="528" width="240" height="64" fill="#29291f" stroke="#e5b95f" stroke-width="5"/>
|
||||||
|
<text x="116" y="568" fill="#f2f0dc" font-family="monospace" font-size="20" font-weight="700">Normal</text>
|
||||||
|
<rect x="342" y="528" width="240" height="64" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<text x="380" y="568" fill="#f2f0dc" font-family="monospace" font-size="20" font-weight="700">Heroic</text>
|
||||||
|
<rect x="606" y="528" width="240" height="64" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<text x="644" y="568" fill="#777988" font-family="monospace" font-size="20" font-weight="700">Mythic L10</text>
|
||||||
|
|
||||||
|
<rect x="914" y="508" width="238" height="86" fill="#3a2618" stroke="#e5b95f" stroke-width="5"/>
|
||||||
|
<text x="982" y="559" fill="#f2f0dc" font-family="monospace" font-size="24" font-weight="700">Start</text>
|
||||||
|
<text x="78" y="638" fill="#aaa9b7" font-family="monospace" font-size="17">Idea A: All choices are button grids. D-pad works everywhere. No native dropdown.</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.5 KiB |
@@ -0,0 +1,44 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720">
|
||||||
|
<rect width="1280" height="720" fill="#111218"/>
|
||||||
|
<rect x="48" y="36" width="1184" height="648" fill="#1b1c24" stroke="#090a0d" stroke-width="4"/>
|
||||||
|
<text x="78" y="78" fill="#8f90a0" font-family="monospace" font-size="18">ADVENTURE</text>
|
||||||
|
<text x="78" y="114" fill="#f2f0dc" font-family="monospace" font-size="34" font-weight="700">Dungeons</text>
|
||||||
|
<text x="1014" y="88" fill="#e5b95f" font-family="monospace" font-size="18">LB/RB Change</text>
|
||||||
|
|
||||||
|
<rect x="78" y="150" width="760" height="398" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<rect x="112" y="184" width="164" height="164" fill="#481e29" stroke="#090a0d" stroke-width="4"/>
|
||||||
|
<text x="151" y="280" fill="#ef6574" font-family="monospace" font-size="48" font-weight="700">AH</text>
|
||||||
|
<text x="306" y="202" fill="#8f90a0" font-family="monospace" font-size="18">CURRENT DUNGEON</text>
|
||||||
|
<text x="306" y="248" fill="#f2f0dc" font-family="monospace" font-size="34" font-weight="700">Ashen Halls</text>
|
||||||
|
<text x="306" y="286" fill="#aaa9b7" font-family="monospace" font-size="18">Guide a six-player party through burning halls.</text>
|
||||||
|
<text x="306" y="324" fill="#e5b95f" font-family="monospace" font-size="18">Level 1 • 6 Players • 100 XP</text>
|
||||||
|
<rect x="112" y="384" width="690" height="104" fill="#15161c" stroke="#33343d" stroke-width="3"/>
|
||||||
|
<text x="138" y="425" fill="#f2f0dc" font-family="monospace" font-size="19">Ashen Halls</text>
|
||||||
|
<text x="356" y="425" fill="#8f90a0" font-family="monospace" font-size="19">Sunken Crypt</text>
|
||||||
|
<text x="608" y="425" fill="#8f90a0" font-family="monospace" font-size="19">Grove Maw</text>
|
||||||
|
<rect x="128" y="448" width="146" height="8" fill="#e5b95f"/>
|
||||||
|
|
||||||
|
<rect x="874" y="150" width="278" height="398" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<text x="906" y="194" fill="#e5b95f" font-family="monospace" font-size="18">Setup</text>
|
||||||
|
<text x="906" y="232" fill="#f2f0dc" font-family="monospace" font-size="22" font-weight="700">Part</text>
|
||||||
|
<rect x="906" y="252" width="68" height="54" fill="#29291f" stroke="#e5b95f" stroke-width="4"/>
|
||||||
|
<text x="932" y="286" fill="#f2f0dc" font-family="monospace" font-size="20">1</text>
|
||||||
|
<rect x="990" y="252" width="68" height="54" fill="#20222a" stroke="#41404a" stroke-width="3"/>
|
||||||
|
<text x="1016" y="286" fill="#8f90a0" font-family="monospace" font-size="20">2</text>
|
||||||
|
<rect x="1074" y="252" width="68" height="54" fill="#20222a" stroke="#41404a" stroke-width="3"/>
|
||||||
|
<text x="1100" y="286" fill="#8f90a0" font-family="monospace" font-size="20">3</text>
|
||||||
|
|
||||||
|
<text x="906" y="350" fill="#f2f0dc" font-family="monospace" font-size="22" font-weight="700">Difficulty</text>
|
||||||
|
<rect x="906" y="372" width="236" height="52" fill="#29291f" stroke="#e5b95f" stroke-width="4"/>
|
||||||
|
<text x="938" y="405" fill="#f2f0dc" font-family="monospace" font-size="18">Normal</text>
|
||||||
|
<rect x="906" y="436" width="236" height="52" fill="#20222a" stroke="#41404a" stroke-width="3"/>
|
||||||
|
<text x="938" y="469" fill="#aaa9b7" font-family="monospace" font-size="18">Heroic</text>
|
||||||
|
|
||||||
|
<rect x="78" y="580" width="238" height="70" fill="#15161c" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<text x="120" y="624" fill="#e5b95f" font-family="monospace" font-size="22">Prev</text>
|
||||||
|
<rect x="342" y="580" width="238" height="70" fill="#15161c" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<text x="392" y="624" fill="#e5b95f" font-family="monospace" font-size="22">Next</text>
|
||||||
|
<rect x="874" y="580" width="278" height="70" fill="#3a2618" stroke="#e5b95f" stroke-width="5"/>
|
||||||
|
<text x="963" y="624" fill="#f2f0dc" font-family="monospace" font-size="24" font-weight="700">Start</text>
|
||||||
|
<text x="78" y="684" fill="#aaa9b7" font-family="monospace" font-size="17">Idea B: One big focused dungeon. Shoulder buttons or side buttons cycle dungeon.</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,48 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720">
|
||||||
|
<rect width="1280" height="720" fill="#111218"/>
|
||||||
|
<rect x="48" y="36" width="1184" height="648" fill="#1b1c24" stroke="#090a0d" stroke-width="4"/>
|
||||||
|
<text x="78" y="78" fill="#8f90a0" font-family="monospace" font-size="18">ADVENTURE</text>
|
||||||
|
<text x="78" y="114" fill="#f2f0dc" font-family="monospace" font-size="34" font-weight="700">Mission Board</text>
|
||||||
|
<text x="1046" y="88" fill="#e5b95f" font-family="monospace" font-size="18">A Start</text>
|
||||||
|
|
||||||
|
<rect x="78" y="150" width="500" height="474" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<text x="112" y="194" fill="#e5b95f" font-family="monospace" font-size="18">Available Runs</text>
|
||||||
|
<g>
|
||||||
|
<rect x="112" y="222" width="432" height="82" fill="#29291f" stroke="#e5b95f" stroke-width="5"/>
|
||||||
|
<text x="136" y="255" fill="#f2f0dc" font-family="monospace" font-size="20" font-weight="700">Ashen Halls • Part 1</text>
|
||||||
|
<text x="136" y="283" fill="#aaa9b7" font-family="monospace" font-size="16">Normal • iLvl 1 • 100 XP</text>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="112" y="318" width="432" height="82" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<text x="136" y="351" fill="#f2f0dc" font-family="monospace" font-size="20" font-weight="700">Ashen Halls • Part 1</text>
|
||||||
|
<text x="136" y="379" fill="#aaa9b7" font-family="monospace" font-size="16">Heroic • iLvl 5 • 140 XP</text>
|
||||||
|
</g>
|
||||||
|
<g opacity="0.65">
|
||||||
|
<rect x="112" y="414" width="432" height="82" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<text x="136" y="447" fill="#f2f0dc" font-family="monospace" font-size="20" font-weight="700">Sunken Crypt • Part 1</text>
|
||||||
|
<text x="136" y="475" fill="#aaa9b7" font-family="monospace" font-size="16">Normal • Locked Level 5</text>
|
||||||
|
</g>
|
||||||
|
<g opacity="0.65">
|
||||||
|
<rect x="112" y="510" width="432" height="82" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<text x="136" y="543" fill="#f2f0dc" font-family="monospace" font-size="20" font-weight="700">Ashen Halls • Part 2</text>
|
||||||
|
<text x="136" y="571" fill="#aaa9b7" font-family="monospace" font-size="16">Normal • Locked until Part 1 clear</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<rect x="616" y="150" width="536" height="474" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<rect x="650" y="184" width="130" height="130" fill="#481e29" stroke="#090a0d" stroke-width="4"/>
|
||||||
|
<text x="684" y="262" fill="#ef6574" font-family="monospace" font-size="42" font-weight="700">AH</text>
|
||||||
|
<text x="812" y="194" fill="#8f90a0" font-family="monospace" font-size="18">SELECTED RUN</text>
|
||||||
|
<text x="812" y="238" fill="#f2f0dc" font-family="monospace" font-size="30" font-weight="700">Ashen Halls</text>
|
||||||
|
<text x="812" y="278" fill="#e5b95f" font-family="monospace" font-size="20">Part 1 • Normal</text>
|
||||||
|
<text x="650" y="358" fill="#aaa9b7" font-family="monospace" font-size="17">Fastest path for controller play. One list item is the exact run.</text>
|
||||||
|
<text x="650" y="394" fill="#aaa9b7" font-family="monospace" font-size="17">No separate dungeon, phase, or difficulty controls.</text>
|
||||||
|
|
||||||
|
<rect x="650" y="440" width="470" height="64" fill="#15161c" stroke="#33343d" stroke-width="3"/>
|
||||||
|
<text x="680" y="480" fill="#f2f0dc" font-family="monospace" font-size="18">Health 1.00x Damage 1.00x XP 1.0x</text>
|
||||||
|
<rect x="650" y="532" width="220" height="62" fill="#3a2618" stroke="#e5b95f" stroke-width="5"/>
|
||||||
|
<text x="716" y="570" fill="#f2f0dc" font-family="monospace" font-size="22" font-weight="700">Start</text>
|
||||||
|
<rect x="900" y="532" width="220" height="62" fill="#15161c" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<text x="955" y="570" fill="#e5b95f" font-family="monospace" font-size="22">Loot</text>
|
||||||
|
|
||||||
|
<text x="78" y="684" fill="#aaa9b7" font-family="monospace" font-size="17">Idea C: Flat mission list. Most controller-friendly, least setup flexibility on one screen.</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,72 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720">
|
||||||
|
<rect width="1280" height="720" fill="#111218"/>
|
||||||
|
<rect x="48" y="36" width="1184" height="648" fill="#1b1c24" stroke="#090a0d" stroke-width="4"/>
|
||||||
|
<text x="78" y="78" fill="#8f90a0" font-family="monospace" font-size="18">ADVENTURE</text>
|
||||||
|
<text x="78" y="114" fill="#f2f0dc" font-family="monospace" font-size="34" font-weight="700">Dungeons</text>
|
||||||
|
<text x="958" y="88" fill="#e5b95f" font-family="monospace" font-size="18">B Back • A Select</text>
|
||||||
|
|
||||||
|
<text x="78" y="158" fill="#e5b95f" font-family="monospace" font-size="18">1. Pick Item Level</text>
|
||||||
|
<g>
|
||||||
|
<rect x="78" y="178" width="170" height="72" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<text x="125" y="222" fill="#f2f0dc" font-family="monospace" font-size="22">5</text>
|
||||||
|
<text x="108" y="240" fill="#8f90a0" font-family="monospace" font-size="12">Initiate</text>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="268" y="178" width="170" height="72" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<text x="310" y="222" fill="#f2f0dc" font-family="monospace" font-size="22">10</text>
|
||||||
|
<text x="300" y="240" fill="#8f90a0" font-family="monospace" font-size="12">Veteran</text>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="458" y="178" width="170" height="72" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<text x="500" y="222" fill="#f2f0dc" font-family="monospace" font-size="22">15</text>
|
||||||
|
<text x="488" y="240" fill="#8f90a0" font-family="monospace" font-size="12">Champion</text>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="648" y="178" width="170" height="72" fill="#29291f" stroke="#e5b95f" stroke-width="5"/>
|
||||||
|
<text x="690" y="222" fill="#f2f0dc" font-family="monospace" font-size="22" font-weight="700">20</text>
|
||||||
|
<text x="690" y="240" fill="#e5b95f" font-family="monospace" font-size="12">Mythic</text>
|
||||||
|
</g>
|
||||||
|
<g opacity="0.65">
|
||||||
|
<rect x="838" y="178" width="170" height="72" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<text x="880" y="222" fill="#777988" font-family="monospace" font-size="22">25</text>
|
||||||
|
<text x="864" y="240" fill="#777988" font-family="monospace" font-size="12">Level 20</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<text x="78" y="304" fill="#e5b95f" font-family="monospace" font-size="18">2. Pick Run</text>
|
||||||
|
<g>
|
||||||
|
<rect x="78" y="324" width="335" height="154" fill="#29291f" stroke="#e5b95f" stroke-width="5"/>
|
||||||
|
<rect x="96" y="346" width="64" height="64" fill="#481e29" stroke="#090a0d" stroke-width="3"/>
|
||||||
|
<text x="114" y="387" fill="#ef6574" font-family="monospace" font-size="24" font-weight="700">AH</text>
|
||||||
|
<text x="178" y="360" fill="#f2f0dc" font-family="monospace" font-size="20" font-weight="700">Ashen Halls</text>
|
||||||
|
<text x="178" y="389" fill="#aaa9b7" font-family="monospace" font-size="15">Mythic • iLvl 20</text>
|
||||||
|
<text x="96" y="446" fill="#e5b95f" font-family="monospace" font-size="16">Part 1 unlocked</text>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="436" y="324" width="335" height="154" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<rect x="454" y="346" width="64" height="64" fill="#2a263f" stroke="#090a0d" stroke-width="3"/>
|
||||||
|
<text x="472" y="387" fill="#8ca9ff" font-family="monospace" font-size="24" font-weight="700">SC</text>
|
||||||
|
<text x="536" y="360" fill="#f2f0dc" font-family="monospace" font-size="20" font-weight="700">Sunken Crypt</text>
|
||||||
|
<text x="536" y="389" fill="#aaa9b7" font-family="monospace" font-size="15">Mythic • iLvl 20</text>
|
||||||
|
<text x="454" y="446" fill="#aaa9b7" font-family="monospace" font-size="16">Part 1 unlocked</text>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="794" y="324" width="335" height="154" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<rect x="812" y="346" width="64" height="64" fill="#223226" stroke="#090a0d" stroke-width="3"/>
|
||||||
|
<text x="830" y="387" fill="#70d990" font-family="monospace" font-size="24" font-weight="700">GM</text>
|
||||||
|
<text x="894" y="360" fill="#f2f0dc" font-family="monospace" font-size="20" font-weight="700">Grove Maw</text>
|
||||||
|
<text x="894" y="389" fill="#aaa9b7" font-family="monospace" font-size="15">Mythic • iLvl 20</text>
|
||||||
|
<text x="812" y="446" fill="#777988" font-family="monospace" font-size="16">Locked dungeon</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<text x="78" y="532" fill="#e5b95f" font-family="monospace" font-size="18">3. Pick Part</text>
|
||||||
|
<rect x="78" y="552" width="250" height="64" fill="#29291f" stroke="#e5b95f" stroke-width="5"/>
|
||||||
|
<text x="124" y="592" fill="#f2f0dc" font-family="monospace" font-size="20">Part 1</text>
|
||||||
|
<rect x="352" y="552" width="250" height="64" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<text x="398" y="592" fill="#aaa9b7" font-family="monospace" font-size="20">Part 2</text>
|
||||||
|
<rect x="626" y="552" width="250" height="64" fill="#20222a" stroke="#41404a" stroke-width="4"/>
|
||||||
|
<text x="672" y="592" fill="#aaa9b7" font-family="monospace" font-size="20">Part 3</text>
|
||||||
|
<rect x="926" y="552" width="226" height="64" fill="#3a2618" stroke="#e5b95f" stroke-width="5"/>
|
||||||
|
<text x="991" y="592" fill="#f2f0dc" font-family="monospace" font-size="22" font-weight="700">Start</text>
|
||||||
|
|
||||||
|
<text x="78" y="662" fill="#aaa9b7" font-family="monospace" font-size="17">Idea D: submenu flow. Pick item level first, then only compatible dungeon cards appear.</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 5.2 KiB |
+18
-10
@@ -363,13 +363,6 @@ function initializeCharacter(database, accountId, characterName, classId) {
|
|||||||
;[...starterSpells, null].slice(0, 6).forEach((spellId, index) => {
|
;[...starterSpells, null].slice(0, 6).forEach((spellId, index) => {
|
||||||
insertSlot.run(characterId, index + 1, spellId)
|
insertSlot.run(characterId, index + 1, spellId)
|
||||||
})
|
})
|
||||||
const insertItem = database.prepare(`
|
|
||||||
INSERT INTO character_inventory (character_id, item_id, quantity, equipped)
|
|
||||||
VALUES (?, ?, 1, ?)
|
|
||||||
`)
|
|
||||||
for (let itemId = 100; itemId <= 107; itemId += 1) {
|
|
||||||
insertItem.run(characterId, itemId, itemId === 107 ? 0 : 1)
|
|
||||||
}
|
|
||||||
return characterId
|
return characterId
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1692,11 +1685,24 @@ function craftItem(database, characterId, recipeId) {
|
|||||||
crafting_recipes.item_id AS itemId,
|
crafting_recipes.item_id AS itemId,
|
||||||
crafting_recipes.difficulty_id AS difficultyId,
|
crafting_recipes.difficulty_id AS difficultyId,
|
||||||
crafting_recipes.source_dungeon_id AS sourceDungeonId,
|
crafting_recipes.source_dungeon_id AS sourceDungeonId,
|
||||||
crafting_recipes.source_encounter_id AS sourceEncounterId
|
crafting_recipes.source_encounter_id AS sourceEncounterId,
|
||||||
|
items.slot,
|
||||||
|
items.item_level AS itemLevel
|
||||||
FROM crafting_recipes
|
FROM crafting_recipes
|
||||||
|
JOIN items ON items.id = crafting_recipes.item_id
|
||||||
WHERE crafting_recipes.id = ?
|
WHERE crafting_recipes.id = ?
|
||||||
`).get(recipeId)
|
`).get(recipeId)
|
||||||
if (!recipe) throw new Error('That crafting recipe does not exist.')
|
if (!recipe) throw new Error('That crafting recipe does not exist.')
|
||||||
|
const lowerTierRecipe = database.prepare(`
|
||||||
|
SELECT crafting_recipes.id
|
||||||
|
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 < ?
|
||||||
|
LIMIT 1
|
||||||
|
`).get(recipe.sourceEncounterId, recipe.slot, recipe.itemLevel)
|
||||||
|
if (lowerTierRecipe) throw new Error('Upgrade the previous item tier instead.')
|
||||||
|
|
||||||
const components = database.prepare(`
|
const components = database.prepare(`
|
||||||
SELECT
|
SELECT
|
||||||
@@ -1777,8 +1783,10 @@ function upgradeItem(database, characterId, itemId) {
|
|||||||
JOIN items ON items.id = crafting_recipes.item_id
|
JOIN items ON items.id = crafting_recipes.item_id
|
||||||
WHERE crafting_recipes.source_encounter_id = ?
|
WHERE crafting_recipes.source_encounter_id = ?
|
||||||
AND items.slot = ?
|
AND items.slot = ?
|
||||||
AND items.item_level = ?
|
AND items.item_level > ?
|
||||||
`).get(currentRecipe.sourceEncounterId, item.slot, item.itemLevel + 5)
|
ORDER BY items.item_level
|
||||||
|
LIMIT 1
|
||||||
|
`).get(currentRecipe.sourceEncounterId, item.slot, item.itemLevel)
|
||||||
if (!targetRecipe) throw new Error('No upgrade is available for this item.')
|
if (!targetRecipe) throw new Error('No upgrade is available for this item.')
|
||||||
|
|
||||||
const components = database.prepare(`
|
const components = database.prepare(`
|
||||||
|
|||||||
+642
-8
@@ -1052,6 +1052,10 @@ textarea:focus-visible,
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.game-shell.dungeon-shell {
|
||||||
|
width: min(1400px, calc(100% - 28px));
|
||||||
|
}
|
||||||
|
|
||||||
.auth-shell {
|
.auth-shell {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1368,6 +1372,10 @@ h2 {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dungeon-run-screen {
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.menu-screen {
|
.menu-screen {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1377,6 +1385,31 @@ h2 {
|
|||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dungeon-run-board {
|
||||||
|
display: grid;
|
||||||
|
flex: 1;
|
||||||
|
gap: 14px;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(340px, 0.48fr);
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dungeon-run-main,
|
||||||
|
.dungeon-setup-rail {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dungeon-run-main {
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dungeon-setup-rail {
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.message-panel {
|
.message-panel {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1734,6 +1767,396 @@ h2 {
|
|||||||
outline-color: #a85d20;
|
outline-color: #a85d20;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.run-setup-panel,
|
||||||
|
.run-summary-card {
|
||||||
|
background: var(--panel-light);
|
||||||
|
border: 2px solid #090a0d;
|
||||||
|
margin-top: 14px;
|
||||||
|
outline: 2px solid #3e3d47;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-setup-heading {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-setup-heading h2,
|
||||||
|
.run-summary-copy h2 {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-setup-heading small {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 16px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(145px, 1fr));
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-grid button,
|
||||||
|
.activity-card {
|
||||||
|
background: #15161c;
|
||||||
|
border: 2px solid #090a0d;
|
||||||
|
color: var(--ink);
|
||||||
|
cursor: pointer;
|
||||||
|
outline: 2px solid #41404a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-grid button {
|
||||||
|
display: grid;
|
||||||
|
gap: 7px;
|
||||||
|
min-height: 70px;
|
||||||
|
padding: 11px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-grid button:hover:not(:disabled),
|
||||||
|
.tier-grid button.selected,
|
||||||
|
.activity-card:hover:not(:disabled),
|
||||||
|
.activity-card.selected {
|
||||||
|
outline-color: var(--gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-grid button.selected,
|
||||||
|
.activity-card.selected {
|
||||||
|
background: #29291f;
|
||||||
|
box-shadow: inset 0 0 0 2px #6e5727;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-grid strong,
|
||||||
|
.tier-grid span,
|
||||||
|
.activity-card strong,
|
||||||
|
.activity-card small,
|
||||||
|
.activity-card i {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-grid strong,
|
||||||
|
.activity-card strong {
|
||||||
|
font-family: 'Press Start 2P', monospace;
|
||||||
|
font-size: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-grid span,
|
||||||
|
.activity-card small,
|
||||||
|
.activity-card i {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 15px;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-grid button.locked,
|
||||||
|
.activity-card.locked {
|
||||||
|
cursor: not-allowed;
|
||||||
|
filter: grayscale(0.75);
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-card-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(245px, 1fr));
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-card {
|
||||||
|
align-items: center;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px 12px;
|
||||||
|
grid-template-columns: 72px minmax(0, 1fr);
|
||||||
|
min-height: 112px;
|
||||||
|
padding: 12px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-card .dungeon-art {
|
||||||
|
grid-row: span 3;
|
||||||
|
height: 68px;
|
||||||
|
width: 68px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-summary-card {
|
||||||
|
align-items: center;
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
grid-template-columns: 92px minmax(0, 1fr) minmax(190px, 260px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dungeon-focus-card {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
grid-template-columns: 108px minmax(0, 1fr);
|
||||||
|
margin-top: 0;
|
||||||
|
min-height: 178px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dungeon-focus-card > .dungeon-art {
|
||||||
|
height: 108px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-summary-copy p:not(.eyebrow) {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 18px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.part-picker {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.part-setup-panel .part-picker {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.part-setup-panel .primary-button {
|
||||||
|
min-height: 54px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dungeon-choice-panel {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
margin-top: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dungeon-choice-grid {
|
||||||
|
align-content: start;
|
||||||
|
flex: 1;
|
||||||
|
grid-auto-rows: minmax(86px, max-content);
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dungeon-choice-grid .activity-card {
|
||||||
|
min-height: 86px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-setup-panel,
|
||||||
|
.part-setup-panel,
|
||||||
|
.dungeon-setup-rail .difficulty-section,
|
||||||
|
.dungeon-setup-rail .loot-preview-section,
|
||||||
|
.dungeon-setup-rail .leaderboard-section {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-setup-panel {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-setup-panel .tier-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-setup-panel .tier-grid button {
|
||||||
|
min-height: 56px;
|
||||||
|
padding: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dungeon-setup-rail .run-setup-heading {
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dungeon-setup-rail .run-setup-heading small {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.05;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dungeon-setup-rail .difficulty-summary {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dungeon-setup-rail .difficulty-summary small {
|
||||||
|
line-height: 1.05;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dungeon-setup-rail .difficulty-summary dl {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.dungeon-setup-rail .loot-preview-section {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dungeon-setup-rail .loot-preview-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
max-height: 260px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-height: 1100px) {
|
||||||
|
.game-shell.dungeon-shell {
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dungeon-shell .app-header {
|
||||||
|
min-height: 58px;
|
||||||
|
padding: 9px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dungeon-run-screen {
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dungeon-run-screen .screen-heading {
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dungeon-run-screen .screen-heading h1 {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dungeon-run-board,
|
||||||
|
.dungeon-run-main {
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dungeon-setup-rail {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dungeon-run-screen .run-setup-panel,
|
||||||
|
.dungeon-run-screen .run-summary-card,
|
||||||
|
.dungeon-run-screen .difficulty-section,
|
||||||
|
.dungeon-run-screen .loot-preview-section,
|
||||||
|
.dungeon-run-screen .leaderboard-section {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dungeon-focus-card {
|
||||||
|
grid-template-columns: 84px minmax(0, 1fr);
|
||||||
|
min-height: 132px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dungeon-focus-card > .dungeon-art {
|
||||||
|
height: 84px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dungeon-focus-card .run-summary-copy p:not(.eyebrow) {
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.05;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dungeon-run-screen .run-setup-heading h2,
|
||||||
|
.dungeon-focus-card .run-summary-copy h2 {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dungeon-run-screen .eyebrow {
|
||||||
|
font-size: 7px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dungeon-choice-grid,
|
||||||
|
.dungeon-run-screen .tier-grid {
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dungeon-choice-grid .activity-card {
|
||||||
|
gap: 5px 9px;
|
||||||
|
grid-template-columns: 54px minmax(0, 1fr);
|
||||||
|
min-height: 82px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dungeon-choice-grid .activity-card .dungeon-art {
|
||||||
|
height: 50px;
|
||||||
|
width: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dungeon-choice-grid .activity-card strong,
|
||||||
|
.dungeon-run-screen .tier-grid strong {
|
||||||
|
font-size: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dungeon-choice-grid .activity-card small,
|
||||||
|
.dungeon-choice-grid .activity-card i,
|
||||||
|
.dungeon-run-screen .tier-grid span {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-setup-panel .tier-grid button {
|
||||||
|
min-height: 48px;
|
||||||
|
padding: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.part-setup-panel .part-picker {
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.part-setup-panel .primary-button {
|
||||||
|
font-size: 8px;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 7px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dungeon-setup-rail .difficulty-summary {
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dungeon-setup-rail .difficulty-summary small {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dungeon-setup-rail .difficulty-summary dl {
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dungeon-setup-rail .difficulty-summary dl > div {
|
||||||
|
padding: 5px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dungeon-setup-rail .difficulty-summary dt,
|
||||||
|
.dungeon-setup-rail .difficulty-summary dd {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dungeon-setup-rail .equipment-heading h2 {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dungeon-setup-rail .loot-preview-grid {
|
||||||
|
max-height: 210px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.dungeon-run-board {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dungeon-run-main,
|
||||||
|
.dungeon-setup-rail {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.part-picker .primary-button.selected-part {
|
||||||
|
background: #f0cb79;
|
||||||
|
outline-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.part-picker .primary-button.locked {
|
||||||
|
cursor: not-allowed;
|
||||||
|
filter: grayscale(0.65);
|
||||||
|
opacity: 0.62;
|
||||||
|
}
|
||||||
|
|
||||||
.dungeon-card h2 {
|
.dungeon-card h2 {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
@@ -2585,6 +3008,29 @@ h2 {
|
|||||||
margin-top: 18px;
|
margin-top: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.equipment-screen.crafting-active .gear-summary {
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 10px 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.equipment-screen.crafting-active .gear-character > span {
|
||||||
|
flex-basis: 40px;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.equipment-screen.crafting-active .gear-stat strong {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.equipment-screen.crafting-active .gear-stat span {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.equipment-screen.crafting-active .equipment-tabs {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.equipment-tab {
|
.equipment-tab {
|
||||||
background: var(--panel-light);
|
background: var(--panel-light);
|
||||||
border: 2px solid #090a0d;
|
border: 2px solid #090a0d;
|
||||||
@@ -2691,6 +3137,15 @@ h2 {
|
|||||||
margin-top: 18px;
|
margin-top: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.equipment-screen.crafting-active .crafting-panel {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
margin-top: 12px;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.crafting-filter-bar {
|
.crafting-filter-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -2716,14 +3171,100 @@ h2 {
|
|||||||
.crafting-layout {
|
.crafting-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(280px, 0.75fr);
|
flex: 1;
|
||||||
|
grid-template-columns: minmax(210px, 0.55fr) minmax(360px, 1fr) minmax(320px, 0.85fr);
|
||||||
margin-top: 13px;
|
margin-top: 13px;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crafting-filters,
|
||||||
|
.crafting-list-panel,
|
||||||
|
.crafting-detail-panel {
|
||||||
|
background: var(--panel-light);
|
||||||
|
border: 2px solid #090a0d;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
outline: 2px solid #41404a;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crafting-filters {
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crafting-filter-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 7px;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.crafting-filter-grid button,
|
||||||
|
.crafting-level-row button {
|
||||||
|
background: #15161c;
|
||||||
|
border: 2px solid #090a0d;
|
||||||
|
color: var(--ink);
|
||||||
|
cursor: pointer;
|
||||||
|
outline: 2px solid #41404a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crafting-filter-grid button {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
min-height: 48px;
|
||||||
|
padding: 7px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crafting-filter-grid button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crafting-filter-grid button.active,
|
||||||
|
.crafting-level-row button.active {
|
||||||
|
background: #29291f;
|
||||||
|
outline-color: var(--gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.crafting-filter-grid strong,
|
||||||
|
.crafting-filter-grid span {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crafting-filter-grid strong {
|
||||||
|
font-family: 'Press Start 2P', monospace;
|
||||||
|
font-size: 6px;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crafting-filter-grid span {
|
||||||
|
color: var(--gold);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crafting-level-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crafting-level-row button {
|
||||||
|
font-family: 'Press Start 2P', monospace;
|
||||||
|
font-size: 7px;
|
||||||
|
min-height: 34px;
|
||||||
|
min-width: 48px;
|
||||||
|
padding: 6px 9px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.crafting-list {
|
.crafting-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
flex: 1;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
max-height: 360px;
|
margin-top: 10px;
|
||||||
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding: 2px;
|
padding: 2px;
|
||||||
}
|
}
|
||||||
@@ -2808,23 +3349,47 @@ h2 {
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.crafting-list i.ready {
|
||||||
|
color: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.crafting-list i.missing {
|
||||||
|
color: #e36c79;
|
||||||
|
}
|
||||||
|
|
||||||
.crafting-detail {
|
.crafting-detail {
|
||||||
background: var(--panel-light);
|
background: transparent;
|
||||||
border: 2px solid #090a0d;
|
border: 0;
|
||||||
border-top-color: var(--rarity-color, #a8a3ad);
|
border-top-color: var(--rarity-color, #a8a3ad);
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 10px;
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.crafting-detail .item-detail {
|
.crafting-detail .item-detail {
|
||||||
padding: 0;
|
flex: 0 0 auto;
|
||||||
border: 0;
|
}
|
||||||
|
|
||||||
|
.crafting-detail-heading {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crafting-detail-heading span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-family: 'Press Start 2P', monospace;
|
||||||
|
font-size: 7px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.crafting-components {
|
.crafting-components {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
flex: 1;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.crafting-components > div {
|
.crafting-components > div {
|
||||||
@@ -3564,7 +4129,7 @@ h2 {
|
|||||||
background: var(--gold);
|
background: var(--gold);
|
||||||
border: 2px solid #0a0b0e;
|
border: 2px solid #0a0b0e;
|
||||||
color: #21180a;
|
color: #21180a;
|
||||||
display: flex;
|
display: none;
|
||||||
font-family: 'Press Start 2P', monospace;
|
font-family: 'Press Start 2P', monospace;
|
||||||
font-size: 7px;
|
font-size: 7px;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
@@ -3576,6 +4141,10 @@ h2 {
|
|||||||
z-index: 2;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.party-member.selected .target-marker {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
.target-marker i {
|
.target-marker i {
|
||||||
border-bottom: 4px solid transparent;
|
border-bottom: 4px solid transparent;
|
||||||
border-left: 6px solid #21180a;
|
border-left: 6px solid #21180a;
|
||||||
@@ -4724,6 +5293,71 @@ h2 {
|
|||||||
padding: 9px 10px;
|
padding: 9px 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.run-setup-panel,
|
||||||
|
.run-summary-card {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-setup-heading {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-setup-heading small {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.1;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-grid {
|
||||||
|
gap: 6px;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-grid button {
|
||||||
|
min-height: 52px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-card-grid {
|
||||||
|
gap: 8px;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-card {
|
||||||
|
gap: 5px 10px;
|
||||||
|
grid-template-columns: 56px minmax(0, 1fr);
|
||||||
|
min-height: 78px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-card .dungeon-art {
|
||||||
|
height: 52px;
|
||||||
|
width: 52px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-summary-card {
|
||||||
|
gap: 10px;
|
||||||
|
grid-template-columns: 64px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-summary-card > .dungeon-art {
|
||||||
|
height: 58px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-summary-copy p:not(.eyebrow) {
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.part-picker {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
.activity-select,
|
.activity-select,
|
||||||
.part-buttons {
|
.part-buttons {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
|
|||||||
+179
-100
@@ -282,10 +282,32 @@ function App() {
|
|||||||
?? dungeonOptions[0]!
|
?? dungeonOptions[0]!
|
||||||
const raid = raidOptions.find((candidate) => candidate.id === selectedRaidId)
|
const raid = raidOptions.find((candidate) => candidate.id === selectedRaidId)
|
||||||
?? raidOptions[0]
|
?? raidOptions[0]
|
||||||
const activity = screen === 'raids' && raid ? raid : dungeon
|
|
||||||
const activityOptions = screen === 'raids' ? raidOptions : dungeonOptions
|
const activityOptions = screen === 'raids' ? raidOptions : dungeonOptions
|
||||||
|
const tierOptions = activityOptions
|
||||||
|
.flatMap((option) => option.difficulties)
|
||||||
|
.filter((difficulty, index, all) => (
|
||||||
|
all.findIndex((candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel) === index
|
||||||
|
))
|
||||||
|
.sort((a, b) => a.droppedItemLevel - b.droppedItemLevel)
|
||||||
|
const savedDifficulty = profile.dungeons
|
||||||
|
.flatMap((option) => option.difficulties)
|
||||||
|
.find((candidate) => candidate.id === selectedDifficultyId)
|
||||||
|
const selectedTier = tierOptions.find((candidate) => (
|
||||||
|
candidate.droppedItemLevel === savedDifficulty?.droppedItemLevel
|
||||||
|
&& profile.character.level >= candidate.unlockLevel
|
||||||
|
))
|
||||||
|
?? tierOptions.slice().reverse().find((candidate) => profile.character.level >= candidate.unlockLevel)
|
||||||
|
?? tierOptions[0]
|
||||||
|
const selectedTierItemLevel = selectedTier?.droppedItemLevel ?? 0
|
||||||
|
const tierActivityOptions = activityOptions.filter((option) =>
|
||||||
|
option.difficulties.some((difficulty) => difficulty.droppedItemLevel === selectedTierItemLevel),
|
||||||
|
)
|
||||||
|
const selectedActivityId = screen === 'raids' && raid ? raid.id : dungeon.id
|
||||||
|
const activity = tierActivityOptions.find((candidate) => candidate.id === selectedActivityId)
|
||||||
|
?? tierActivityOptions[0]
|
||||||
|
?? (screen === 'raids' && raid ? raid : dungeon)
|
||||||
const selectedDifficulty = activity.difficulties.find(
|
const selectedDifficulty = activity.difficulties.find(
|
||||||
(candidate) => candidate.id === selectedDifficultyId,
|
(candidate) => candidate.droppedItemLevel === selectedTierItemLevel,
|
||||||
) ?? activity.difficulties[0]
|
) ?? activity.difficulties[0]
|
||||||
const difficultyLocked = profile.character.level < selectedDifficulty.unlockLevel
|
const difficultyLocked = profile.character.level < selectedDifficulty.unlockLevel
|
||||||
const completedSections = activity.contentType === 'raid'
|
const completedSections = activity.contentType === 'raid'
|
||||||
@@ -306,7 +328,7 @@ function App() {
|
|||||||
: a.sequence - b.sequence)
|
: a.sequence - b.sequence)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="game-shell">
|
<main className={`game-shell ${screen === 'dungeons' || screen === 'raids' ? 'dungeon-shell' : ''}`}>
|
||||||
<header className="topbar app-header">
|
<header className="topbar app-header">
|
||||||
<button
|
<button
|
||||||
className="brand-button"
|
className="brand-button"
|
||||||
@@ -564,108 +586,163 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{(screen === 'dungeons' || screen === 'raids') && (
|
{(screen === 'dungeons' || screen === 'raids') && (
|
||||||
<section className="content-screen">
|
<section className="content-screen dungeon-run-screen">
|
||||||
<ScreenHeading
|
<ScreenHeading
|
||||||
eyebrow="Adventure"
|
eyebrow="Adventure"
|
||||||
title={activity.contentType === 'raid' ? 'Raids' : 'Dungeons'}
|
title={activity.contentType === 'raid' ? 'Raids' : 'Dungeons'}
|
||||||
onBack={() => setScreen('menu')}
|
onBack={() => setScreen('menu')}
|
||||||
/>
|
/>
|
||||||
<article className="dungeon-card">
|
<div className="dungeon-run-board">
|
||||||
<div className={`dungeon-art ${activity.contentType === 'raid' ? 'raid-art' : ''}`}>
|
<div className="dungeon-run-main">
|
||||||
{activityInitials(activity.name)}
|
<article className="run-summary-card dungeon-focus-card">
|
||||||
|
<div className={`dungeon-art ${activity.contentType === 'raid' ? 'raid-art' : ''}`}>
|
||||||
|
{activityInitials(activity.name)}
|
||||||
|
</div>
|
||||||
|
<div className="run-summary-copy">
|
||||||
|
<p className="eyebrow">Selected Run</p>
|
||||||
|
<h2>{activity.name}</h2>
|
||||||
|
<p>{activity.description}</p>
|
||||||
|
<div className="tag-row">
|
||||||
|
<span>Level {activity.recommendedLevel}</span>
|
||||||
|
<span>{activity.partySize} Players</span>
|
||||||
|
<span>{selectedDifficulty.name}</span>
|
||||||
|
<span>iLvl {selectedDifficulty.droppedItemLevel}</span>
|
||||||
|
<span>{Math.round(activity.experienceReward * selectedDifficulty.experienceMultiplier)} XP</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<section className="run-setup-panel dungeon-choice-panel">
|
||||||
|
<div className="run-setup-heading">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Pick Run</p>
|
||||||
|
<h2>{screen === 'raids' ? 'Raid' : 'Dungeon'}</h2>
|
||||||
|
</div>
|
||||||
|
<small>{selectedDifficulty.name} rewards iLvl {selectedDifficulty.droppedItemLevel} components.</small>
|
||||||
|
</div>
|
||||||
|
<div className="activity-card-grid dungeon-choice-grid">
|
||||||
|
{tierActivityOptions.map((candidate) => {
|
||||||
|
const difficulty = candidate.difficulties.find(
|
||||||
|
(option) => option.droppedItemLevel === selectedDifficulty.droppedItemLevel,
|
||||||
|
) ?? candidate.difficulties[0]
|
||||||
|
const locked = profile.character.level < difficulty.unlockLevel
|
||||||
|
const selected = candidate.id === activity.id
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`activity-card ${selected ? 'selected' : ''} ${locked ? 'locked' : ''}`}
|
||||||
|
disabled={locked}
|
||||||
|
key={candidate.id}
|
||||||
|
onClick={() => {
|
||||||
|
if (screen === 'raids') setSelectedRaidId(candidate.id)
|
||||||
|
else setSelectedDungeonId(candidate.id)
|
||||||
|
setSelectedDifficultyId(difficulty.id)
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className={`dungeon-art ${candidate.contentType === 'raid' ? 'raid-art' : ''}`}>
|
||||||
|
{activityInitials(candidate.name)}
|
||||||
|
</span>
|
||||||
|
<strong>{candidate.name}</strong>
|
||||||
|
<small>{candidate.locationName}</small>
|
||||||
|
<i>
|
||||||
|
Level {candidate.recommendedLevel} | {candidate.partySize} Players
|
||||||
|
</i>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<p className="eyebrow">{activity.locationName}</p>
|
<aside className="dungeon-setup-rail">
|
||||||
<h2>{activity.name}</h2>
|
<section className="run-setup-panel tier-setup-panel">
|
||||||
<p>{activity.description}</p>
|
<div className="run-setup-heading">
|
||||||
<div className="tag-row">
|
<div>
|
||||||
<span>Level {activity.recommendedLevel}</span>
|
<p className="eyebrow">Item Level</p>
|
||||||
<span>{activity.partySize} Players</span>
|
<h2>Tier</h2>
|
||||||
<span>{selectedDifficulty.name}</span>
|
</div>
|
||||||
<span>Component Level {selectedDifficulty.droppedItemLevel}</span>
|
<small>{screen === 'raids' ? 'Raid' : 'Dungeon'} tiers unlock by level.</small>
|
||||||
<span>{Math.round(activity.experienceReward * selectedDifficulty.experienceMultiplier)} XP</span>
|
</div>
|
||||||
</div>
|
<div className="tier-grid">
|
||||||
</div>
|
{tierOptions.map((difficulty) => {
|
||||||
{activityOptions.length > 1 && (
|
const locked = profile.character.level < difficulty.unlockLevel
|
||||||
<label className="activity-select">
|
const selected = difficulty.droppedItemLevel === selectedDifficulty.droppedItemLevel
|
||||||
<span>{activity.contentType === 'raid' ? 'Raid' : 'Dungeon'}</span>
|
return (
|
||||||
<select
|
<button
|
||||||
value={activity.id}
|
className={`${selected ? 'selected' : ''} ${locked ? 'locked' : ''}`}
|
||||||
onChange={(event) => {
|
disabled={locked}
|
||||||
const nextActivityId = Number(event.target.value)
|
key={difficulty.id}
|
||||||
const nextActivity = activityOptions.find((candidate) => candidate.id === nextActivityId)
|
onClick={() => {
|
||||||
if (screen === 'raids') setSelectedRaidId(nextActivityId)
|
const nextActivity = activity.difficulties.some(
|
||||||
else setSelectedDungeonId(nextActivityId)
|
(candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel,
|
||||||
if (nextActivity?.difficulties[0]) {
|
)
|
||||||
setSelectedDifficultyId(nextActivity.difficulties[0].id)
|
? activity
|
||||||
}
|
: activityOptions.find((option) =>
|
||||||
}}
|
option.difficulties.some((candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel),
|
||||||
>
|
)
|
||||||
{activityOptions.map((candidate) => (
|
if (nextActivity) {
|
||||||
<option key={candidate.id} value={candidate.id}>{candidate.name}</option>
|
if (screen === 'raids') setSelectedRaidId(nextActivity.id)
|
||||||
))}
|
else setSelectedDungeonId(nextActivity.id)
|
||||||
</select>
|
const nextDifficulty = nextActivity.difficulties.find(
|
||||||
</label>
|
(candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel,
|
||||||
)}
|
)
|
||||||
<div className="part-buttons">
|
if (nextDifficulty) setSelectedDifficultyId(nextDifficulty.id)
|
||||||
{parts.map((p) => (
|
}
|
||||||
<button
|
}}
|
||||||
key={p.part}
|
type="button"
|
||||||
className={`primary-button ${selectedPart === p.part ? 'selected-part' : ''} ${!p.unlocked ? 'locked' : ''}`}
|
>
|
||||||
disabled={difficultyLocked || !p.unlocked}
|
<strong>iLvl {difficulty.droppedItemLevel}</strong>
|
||||||
onClick={() => {
|
<span>{locked ? `Level ${difficulty.unlockLevel}` : difficulty.name}</span>
|
||||||
setSelectedPart(p.part)
|
</button>
|
||||||
setCombatContentId(activity.id)
|
)
|
||||||
setScreen('combat')
|
})}
|
||||||
}}
|
</div>
|
||||||
type="button"
|
</section>
|
||||||
>
|
|
||||||
{p.name}
|
<section className="run-setup-panel part-setup-panel">
|
||||||
</button>
|
<div className="run-setup-heading">
|
||||||
))}
|
<div>
|
||||||
</div>
|
<p className="eyebrow">Start</p>
|
||||||
</article>
|
<h2>{sectionName}</h2>
|
||||||
<div className="difficulty-section compact-difficulty-section">
|
</div>
|
||||||
<div className="difficulty-select-row">
|
<small>{difficultyLocked ? `Unlocks at level ${selectedDifficulty.unlockLevel}` : 'Choose a section to launch.'}</small>
|
||||||
<div>
|
</div>
|
||||||
<p className="eyebrow">Challenge Tier</p>
|
<div className="part-picker">
|
||||||
<h2>Difficulty</h2>
|
{parts.map((p) => (
|
||||||
</div>
|
<button
|
||||||
<label>
|
key={p.part}
|
||||||
<span>Select</span>
|
className={`primary-button ${selectedPart === p.part ? 'selected-part' : ''} ${!p.unlocked ? 'locked' : ''}`}
|
||||||
<select
|
disabled={difficultyLocked || !p.unlocked}
|
||||||
value={selectedDifficulty.id}
|
onClick={() => {
|
||||||
onChange={(event) => setSelectedDifficultyId(Number(event.target.value))}
|
setSelectedPart(p.part)
|
||||||
>
|
setCombatContentId(activity.id)
|
||||||
{activity.difficulties.map((difficulty, index) => (
|
setSelectedDifficultyId(selectedDifficulty.id)
|
||||||
<option
|
setScreen('combat')
|
||||||
disabled={profile.character.level < difficulty.unlockLevel}
|
}}
|
||||||
key={difficulty.id}
|
type="button"
|
||||||
value={difficulty.id}
|
|
||||||
>
|
>
|
||||||
{index + 1}. {difficulty.name}
|
{p.name}
|
||||||
{profile.character.level < difficulty.unlockLevel
|
</button>
|
||||||
? ` - Level ${difficulty.unlockLevel}`
|
|
||||||
: ''}
|
|
||||||
</option>
|
|
||||||
))}
|
))}
|
||||||
</select>
|
</div>
|
||||||
</label>
|
</section>
|
||||||
</div>
|
|
||||||
<div className={`difficulty-summary ${difficultyLocked ? 'locked' : ''}`}>
|
<div className="difficulty-section compact-difficulty-section">
|
||||||
<div>
|
<div className={`difficulty-summary ${difficultyLocked ? 'locked' : ''}`}>
|
||||||
<strong>{selectedDifficulty.name}</strong>
|
<div>
|
||||||
<small>{difficultyLocked ? `Unlocks at level ${selectedDifficulty.unlockLevel}` : selectedDifficulty.description}</small>
|
<strong>{selectedDifficulty.name}</strong>
|
||||||
|
<small>{difficultyLocked ? `Unlocks at level ${selectedDifficulty.unlockLevel}` : selectedDifficulty.description}</small>
|
||||||
|
</div>
|
||||||
|
<dl>
|
||||||
|
<div><dt>Health</dt><dd>{selectedDifficulty.healthMultiplier.toFixed(2)}x</dd></div>
|
||||||
|
<div><dt>Damage</dt><dd>{selectedDifficulty.damageMultiplier.toFixed(2)}x</dd></div>
|
||||||
|
<div><dt>XP</dt><dd>{selectedDifficulty.experienceMultiplier.toFixed(1)}x</dd></div>
|
||||||
|
<div><dt>Loot</dt><dd>iLvl {selectedDifficulty.droppedItemLevel}</dd></div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<dl>
|
|
||||||
<div><dt>Health</dt><dd>{selectedDifficulty.healthMultiplier.toFixed(2)}x</dd></div>
|
<div className="loot-preview-section">
|
||||||
<div><dt>Damage</dt><dd>{selectedDifficulty.damageMultiplier.toFixed(2)}x</dd></div>
|
|
||||||
<div><dt>XP</dt><dd>{selectedDifficulty.experienceMultiplier.toFixed(1)}x</dd></div>
|
|
||||||
<div><dt>Components</dt><dd>iLvl {selectedDifficulty.droppedItemLevel}</dd></div>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="loot-preview-section">
|
|
||||||
<div className="equipment-heading toggle-heading">
|
<div className="equipment-heading toggle-heading">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Encounter Rewards</p>
|
<p className="eyebrow">Encounter Rewards</p>
|
||||||
@@ -732,9 +809,9 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{SHOW_LEADERBOARDS && (
|
{SHOW_LEADERBOARDS && (
|
||||||
<div className="leaderboard-section">
|
<div className="leaderboard-section">
|
||||||
<div className="equipment-heading toggle-heading">
|
<div className="equipment-heading toggle-heading">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Efficiency Rankings</p>
|
<p className="eyebrow">Efficiency Rankings</p>
|
||||||
@@ -811,8 +888,10 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
+187
-117
@@ -73,6 +73,16 @@ type FloatingCombatText = {
|
|||||||
value: number
|
value: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SinglePlayerCombatState = {
|
||||||
|
party: PartyMember[]
|
||||||
|
resource: number
|
||||||
|
enemyHealth: number
|
||||||
|
cooldowns: Record<string, number>
|
||||||
|
elapsedTicks: number
|
||||||
|
castsTowardFree: number
|
||||||
|
freeCastReady: boolean
|
||||||
|
}
|
||||||
|
|
||||||
const ROGUELIKE_MECHANICS: RoguelikeMechanic[] = [
|
const ROGUELIKE_MECHANICS: RoguelikeMechanic[] = [
|
||||||
'party-pulse',
|
'party-pulse',
|
||||||
'searing-mark',
|
'searing-mark',
|
||||||
@@ -340,13 +350,18 @@ export function CombatScreen({
|
|||||||
const sectionName = isRoguelike ? 'Stage' : dungeon.contentType === 'raid' ? 'Phase' : 'Part'
|
const sectionName = isRoguelike ? 'Stage' : dungeon.contentType === 'raid' ? 'Phase' : 'Part'
|
||||||
const contentName = isRoguelike ? 'Roguelike' : dungeon.contentType === 'raid' ? 'Raid' : 'Dungeon'
|
const contentName = isRoguelike ? 'Roguelike' : dungeon.contentType === 'raid' ? 'Raid' : 'Dungeon'
|
||||||
const initialEncounterIndex = (startPart - 1) * 3
|
const initialEncounterIndex = (startPart - 1) * 3
|
||||||
const [party, setParty] = useState<PartyMember[]>(partyTemplate)
|
const initialCombatState = useMemo<SinglePlayerCombatState>(() => ({
|
||||||
|
party: partyTemplate,
|
||||||
|
resource: maxResource,
|
||||||
|
enemyHealth: encounters[initialEncounterIndex].maxHealth,
|
||||||
|
cooldowns: {},
|
||||||
|
elapsedTicks: 0,
|
||||||
|
castsTowardFree: 0,
|
||||||
|
freeCastReady: false,
|
||||||
|
}), [encounters, initialEncounterIndex, maxResource, partyTemplate])
|
||||||
|
const [combatState, setCombatState] = useState<SinglePlayerCombatState>(() => initialCombatState)
|
||||||
const [selectedId, setSelectedId] = useState(partyTemplate[0].id)
|
const [selectedId, setSelectedId] = useState(partyTemplate[0].id)
|
||||||
const [resource, setResource] = useState(maxResource)
|
|
||||||
const [encounterIndex, setEncounterIndex] = useState(initialEncounterIndex)
|
const [encounterIndex, setEncounterIndex] = useState(initialEncounterIndex)
|
||||||
const [enemyHealth, setEnemyHealth] = useState(encounters[initialEncounterIndex].maxHealth)
|
|
||||||
const [cooldowns, setCooldowns] = useState<Record<string, number>>({})
|
|
||||||
const [, setElapsedTicks] = useState(0)
|
|
||||||
const [status, setStatus] = useState<'playing' | 'won' | 'lost' | 'part-complete' | 'upgrade-choice'>('playing')
|
const [status, setStatus] = useState<'playing' | 'won' | 'lost' | 'part-complete' | 'upgrade-choice'>('playing')
|
||||||
const [paused, setPaused] = useState(false)
|
const [paused, setPaused] = useState(false)
|
||||||
const [targetGroup, setTargetGroup] = useState<0 | 1 | 2>(0)
|
const [targetGroup, setTargetGroup] = useState<0 | 1 | 2>(0)
|
||||||
@@ -360,8 +375,6 @@ export function CombatScreen({
|
|||||||
const [floatingTexts, setFloatingTexts] = useState<FloatingCombatText[]>([])
|
const [floatingTexts, setFloatingTexts] = useState<FloatingCombatText[]>([])
|
||||||
const [roguelikeUpgrades, setRoguelikeUpgrades] = useState<RoguelikeUpgrade[]>([])
|
const [roguelikeUpgrades, setRoguelikeUpgrades] = useState<RoguelikeUpgrade[]>([])
|
||||||
const [upgradeChoices, setUpgradeChoices] = useState<RoguelikeUpgrade[]>([])
|
const [upgradeChoices, setUpgradeChoices] = useState<RoguelikeUpgrade[]>([])
|
||||||
const [, setCastsTowardFree] = useState(0)
|
|
||||||
const [freeCastReady, setFreeCastReady] = useState(false)
|
|
||||||
const rewardClaimedRef = useRef(false)
|
const rewardClaimedRef = useRef(false)
|
||||||
const profileRefreshedRef = useRef(false)
|
const profileRefreshedRef = useRef(false)
|
||||||
const rolledEncounterIdsRef = useRef(new Set<number>())
|
const rolledEncounterIdsRef = useRef(new Set<number>())
|
||||||
@@ -371,10 +384,9 @@ export function CombatScreen({
|
|||||||
const partStartTimesRef = useRef<Record<number, number>>({})
|
const partStartTimesRef = useRef<Record<number, number>>({})
|
||||||
const nextLogId = useRef(2)
|
const nextLogId = useRef(2)
|
||||||
const nextFloatingTextId = useRef(1)
|
const nextFloatingTextId = useRef(1)
|
||||||
const partyRef = useRef(partyTemplate)
|
const combatRef = useRef(initialCombatState)
|
||||||
const enemyHealthRef = useRef(encounters[initialEncounterIndex].maxHealth)
|
|
||||||
const elapsedTicksRef = useRef(0)
|
|
||||||
const selectedIdRef = useRef(partyTemplate[0].id)
|
const selectedIdRef = useRef(partyTemplate[0].id)
|
||||||
|
const { party, resource, enemyHealth, cooldowns, freeCastReady } = combatState
|
||||||
const encounter = encounters[encounterIndex]
|
const encounter = encounters[encounterIndex]
|
||||||
const currentPart = getCurrentPart(encounterIndex)
|
const currentPart = getCurrentPart(encounterIndex)
|
||||||
const firstEncounterIndex = (startPart - 1) * 3
|
const firstEncounterIndex = (startPart - 1) * 3
|
||||||
@@ -416,11 +428,35 @@ export function CombatScreen({
|
|||||||
})
|
})
|
||||||
}, [paused])
|
}, [paused])
|
||||||
|
|
||||||
const setSelectedTargetId = useCallback((id: string) => {
|
const setCombat = useCallback((
|
||||||
selectedIdRef.current = id
|
nextState: SinglePlayerCombatState | ((current: SinglePlayerCombatState) => SinglePlayerCombatState),
|
||||||
setSelectedId(id)
|
) => {
|
||||||
|
const next = typeof nextState === 'function'
|
||||||
|
? nextState(combatRef.current)
|
||||||
|
: nextState
|
||||||
|
combatRef.current = next
|
||||||
|
setSelectedId(selectedIdRef.current)
|
||||||
|
setCombatState(next)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const syncSelectedTargetDom = useCallback((id: string) => {
|
||||||
|
document.querySelectorAll<HTMLButtonElement>('[data-party-member-id]').forEach((button) => {
|
||||||
|
const selected = button.dataset.partyMemberId === id
|
||||||
|
button.classList.toggle('selected', selected)
|
||||||
|
button.setAttribute('aria-pressed', String(selected))
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const setSelectedTargetId = useCallback((id: string) => {
|
||||||
|
if (selectedIdRef.current === id) return
|
||||||
|
selectedIdRef.current = id
|
||||||
|
syncSelectedTargetDom(id)
|
||||||
|
}, [syncSelectedTargetDom])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
syncSelectedTargetDom(selectedIdRef.current)
|
||||||
|
}, [combatState, syncSelectedTargetDom])
|
||||||
|
|
||||||
const addLog = useCallback((text: string, tone: CombatLogEntry['tone']) => {
|
const addLog = useCallback((text: string, tone: CombatLogEntry['tone']) => {
|
||||||
const entry = { id: nextLogId.current++, text, tone }
|
const entry = { id: nextLogId.current++, text, tone }
|
||||||
setLog((current) => [entry, ...current].slice(0, 60))
|
setLog((current) => [entry, ...current].slice(0, 60))
|
||||||
@@ -468,18 +504,19 @@ export function CombatScreen({
|
|||||||
: []
|
: []
|
||||||
const nextEncounters = roguelikeMode ? nextRoguelikeEncounters : staticEncounters
|
const nextEncounters = roguelikeMode ? nextRoguelikeEncounters : staticEncounters
|
||||||
const freshParty = partyTemplate.map((member) => ({ ...member }))
|
const freshParty = partyTemplate.map((member) => ({ ...member }))
|
||||||
partyRef.current = freshParty
|
setCombat({
|
||||||
enemyHealthRef.current = nextEncounters[initialEncounterIndex].maxHealth
|
party: freshParty,
|
||||||
|
resource: maxResource,
|
||||||
|
enemyHealth: nextEncounters[initialEncounterIndex].maxHealth,
|
||||||
|
cooldowns: {},
|
||||||
|
elapsedTicks: 0,
|
||||||
|
castsTowardFree: 0,
|
||||||
|
freeCastReady: false,
|
||||||
|
})
|
||||||
if (roguelikeMode) setRoguelikeEncounters(nextRoguelikeEncounters)
|
if (roguelikeMode) setRoguelikeEncounters(nextRoguelikeEncounters)
|
||||||
setRoguelikeStage(1)
|
setRoguelikeStage(1)
|
||||||
setParty(freshParty)
|
|
||||||
setSelectedTargetId(partyTemplate[0].id)
|
setSelectedTargetId(partyTemplate[0].id)
|
||||||
setResource(maxResource)
|
|
||||||
setEncounterIndex(initialEncounterIndex)
|
setEncounterIndex(initialEncounterIndex)
|
||||||
setEnemyHealth(nextEncounters[initialEncounterIndex].maxHealth)
|
|
||||||
setCooldowns({})
|
|
||||||
elapsedTicksRef.current = 0
|
|
||||||
setElapsedTicks(0)
|
|
||||||
setStatus('playing')
|
setStatus('playing')
|
||||||
setPaused(false)
|
setPaused(false)
|
||||||
setTargetGroup(0)
|
setTargetGroup(0)
|
||||||
@@ -490,8 +527,6 @@ export function CombatScreen({
|
|||||||
setFloatingTexts([])
|
setFloatingTexts([])
|
||||||
setRoguelikeUpgrades([])
|
setRoguelikeUpgrades([])
|
||||||
setUpgradeChoices([])
|
setUpgradeChoices([])
|
||||||
setCastsTowardFree(0)
|
|
||||||
setFreeCastReady(false)
|
|
||||||
rewardClaimedRef.current = false
|
rewardClaimedRef.current = false
|
||||||
profileRefreshedRef.current = false
|
profileRefreshedRef.current = false
|
||||||
rolledEncounterIdsRef.current = new Set()
|
rolledEncounterIdsRef.current = new Set()
|
||||||
@@ -500,18 +535,19 @@ export function CombatScreen({
|
|||||||
runStartedAtRef.current = Date.now()
|
runStartedAtRef.current = Date.now()
|
||||||
partStartTimesRef.current = { [startPart]: runStartedAtRef.current }
|
partStartTimesRef.current = { [startPart]: runStartedAtRef.current }
|
||||||
setLog([{ id: nextLogId.current++, text: 'A new run begins.', tone: 'system' }])
|
setLog([{ id: nextLogId.current++, text: 'A new run begins.', tone: 'system' }])
|
||||||
}, [difficulty, initialEncounterIndex, maxResource, partyTemplate, roguelikeMode, roguelikePool, setSelectedTargetId, startPart, staticEncounters])
|
}, [difficulty, initialEncounterIndex, maxResource, partyTemplate, roguelikeMode, roguelikePool, setCombat, setSelectedTargetId, startPart, staticEncounters])
|
||||||
|
|
||||||
const castSpell = useCallback(
|
const castSpell = useCallback(
|
||||||
(spell: Spell) => {
|
(spell: Spell) => {
|
||||||
const effectiveCost = spellResourceCost(spell, roguelikeUpgrades, freeCastReady)
|
const current = combatRef.current
|
||||||
if (status !== 'playing' || cooldowns[spell.id] > 0 || resource < effectiveCost) return
|
const effectiveCost = spellResourceCost(spell, roguelikeUpgrades, current.freeCastReady)
|
||||||
const healer = partyRef.current.find((member) => member.id === 'mira')
|
if (status !== 'playing' || current.cooldowns[spell.id] > 0 || current.resource < effectiveCost) return
|
||||||
|
const healer = current.party.find((member) => member.id === 'mira')
|
||||||
if (!healer || healer.health <= 0) return
|
if (!healer || healer.health <= 0) return
|
||||||
const targetId = selectedIdRef.current
|
const targetId = selectedIdRef.current
|
||||||
const selected = partyRef.current.find((member) => member.id === targetId)
|
const selected = current.party.find((member) => member.id === targetId)
|
||||||
if (!selected || selected.health <= 0) return
|
if (!selected || selected.health <= 0) return
|
||||||
const extraTarget = (blockedIds: string[]) => partyRef.current
|
const extraTarget = (blockedIds: string[]) => current.party
|
||||||
.filter((member) => member.health > 0 && !blockedIds.includes(member.id))
|
.filter((member) => member.health > 0 && !blockedIds.includes(member.id))
|
||||||
.sort((left, right) => (left.health / left.maxHealth) - (right.health / right.maxHealth))[0]
|
.sort((left, right) => (left.health / left.maxHealth) - (right.health / right.maxHealth))[0]
|
||||||
const directTargets = new Set([targetId])
|
const directTargets = new Set([targetId])
|
||||||
@@ -547,28 +583,7 @@ export function CombatScreen({
|
|||||||
if (extra) directTargets.add(extra.id)
|
if (extra) directTargets.add(extra.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
setResource((value) => value - effectiveCost)
|
const nextParty = current.party.map((member) => {
|
||||||
resourceSpentRef.current += effectiveCost
|
|
||||||
setCooldowns((current) => ({
|
|
||||||
...current,
|
|
||||||
[spell.id]: spell.cooldown * cooldownMultiplier(spell, roguelikeUpgrades),
|
|
||||||
}))
|
|
||||||
if (upgradeStackCount(roguelikeUpgrades, 'fifth-cast-free') > 0) {
|
|
||||||
if (freeCastReady) {
|
|
||||||
setFreeCastReady(false)
|
|
||||||
setCastsTowardFree(0)
|
|
||||||
} else {
|
|
||||||
setCastsTowardFree((current) => {
|
|
||||||
const next = current + 1
|
|
||||||
if (next >= 5) {
|
|
||||||
setFreeCastReady(true)
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const nextParty = partyRef.current.map((member) => {
|
|
||||||
if (member.health <= 0) return member
|
if (member.health <= 0) return member
|
||||||
if (spell.kind === 'group') {
|
if (spell.kind === 'group') {
|
||||||
const power = Math.round(spell.power * (1.25 ** upgradeStackCount(roguelikeUpgrades, 'group-heal-boost')))
|
const power = Math.round(spell.power * (1.25 ** upgradeStackCount(roguelikeUpgrades, 'group-heal-boost')))
|
||||||
@@ -602,11 +617,35 @@ export function CombatScreen({
|
|||||||
hotTicks: hotTargets.has(member.id) ? 5 : member.hotTicks,
|
hotTicks: hotTargets.has(member.id) ? 5 : member.hotTicks,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
partyRef.current = nextParty
|
const freeCastStacks = upgradeStackCount(roguelikeUpgrades, 'fifth-cast-free')
|
||||||
setParty(nextParty)
|
const nextFreeCastReady = freeCastStacks > 0 && current.freeCastReady
|
||||||
|
? false
|
||||||
|
: current.freeCastReady
|
||||||
|
const nextCastsTowardFree = freeCastStacks > 0
|
||||||
|
? current.freeCastReady
|
||||||
|
? 0
|
||||||
|
: current.castsTowardFree + 1 >= 5
|
||||||
|
? 0
|
||||||
|
: current.castsTowardFree + 1
|
||||||
|
: current.castsTowardFree
|
||||||
|
const gainedFreeCast = freeCastStacks > 0
|
||||||
|
&& !current.freeCastReady
|
||||||
|
&& current.castsTowardFree + 1 >= 5
|
||||||
|
resourceSpentRef.current += effectiveCost
|
||||||
|
setCombat({
|
||||||
|
...current,
|
||||||
|
party: nextParty,
|
||||||
|
resource: current.resource - effectiveCost,
|
||||||
|
cooldowns: {
|
||||||
|
...current.cooldowns,
|
||||||
|
[spell.id]: spell.cooldown * cooldownMultiplier(spell, roguelikeUpgrades),
|
||||||
|
},
|
||||||
|
castsTowardFree: nextCastsTowardFree,
|
||||||
|
freeCastReady: gainedFreeCast || nextFreeCastReady,
|
||||||
|
})
|
||||||
addLog(`${spell.name} cast on ${spell.kind === 'group' ? 'the party' : selected.name}${effectiveCost === 0 ? ' for free' : ''}.`, 'heal')
|
addLog(`${spell.name} cast on ${spell.kind === 'group' ? 'the party' : selected.name}${effectiveCost === 0 ? ' for free' : ''}.`, 'heal')
|
||||||
},
|
},
|
||||||
[activeSetEffects, addFloatingHeal, addLog, cooldowns, freeCastReady, resource, roguelikeUpgrades, status],
|
[activeSetEffects, addFloatingHeal, addLog, roguelikeUpgrades, setCombat, status],
|
||||||
)
|
)
|
||||||
|
|
||||||
const finishRun = useCallback(
|
const finishRun = useCallback(
|
||||||
@@ -669,7 +708,7 @@ export function CombatScreen({
|
|||||||
)
|
)
|
||||||
|
|
||||||
const selectRelativeTarget = useCallback((direction: -1 | 1) => {
|
const selectRelativeTarget = useCallback((direction: -1 | 1) => {
|
||||||
const living = partyRef.current.filter((member) => member.health > 0)
|
const living = combatRef.current.party.filter((member) => member.health > 0)
|
||||||
if (living.length === 0) return
|
if (living.length === 0) return
|
||||||
const currentIndex = living.findIndex((member) => member.id === selectedIdRef.current)
|
const currentIndex = living.findIndex((member) => member.id === selectedIdRef.current)
|
||||||
const nextIndex = currentIndex < 0
|
const nextIndex = currentIndex < 0
|
||||||
@@ -680,14 +719,14 @@ export function CombatScreen({
|
|||||||
|
|
||||||
const selectDirectionalTarget = useCallback((action: InputAction) => {
|
const selectDirectionalTarget = useCallback((action: InputAction) => {
|
||||||
const columns = dungeon.partySize >= 10 ? 6 : 3
|
const columns = dungeon.partySize >= 10 ? 6 : 3
|
||||||
const currentIndex = partyRef.current.findIndex((member) => member.id === selectedIdRef.current)
|
const currentIndex = combatRef.current.party.findIndex((member) => member.id === selectedIdRef.current)
|
||||||
if (currentIndex < 0) {
|
if (currentIndex < 0) {
|
||||||
setSelectedTargetId(partyRef.current[0].id)
|
setSelectedTargetId(combatRef.current.party[0].id)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const currentRow = Math.floor(currentIndex / columns)
|
const currentRow = Math.floor(currentIndex / columns)
|
||||||
const currentColumn = currentIndex % columns
|
const currentColumn = currentIndex % columns
|
||||||
const candidates = partyRef.current
|
const candidates = combatRef.current.party
|
||||||
.map((member, index) => ({
|
.map((member, index) => ({
|
||||||
member,
|
member,
|
||||||
index,
|
index,
|
||||||
@@ -721,14 +760,15 @@ export function CombatScreen({
|
|||||||
|
|
||||||
const selectDirectTarget = useCallback((slot: number) => {
|
const selectDirectTarget = useCallback((slot: number) => {
|
||||||
const index = slot + (dungeon.partySize > 6 ? targetGroup * 6 : 0)
|
const index = slot + (dungeon.partySize > 6 ? targetGroup * 6 : 0)
|
||||||
const member = partyRef.current[index]
|
const member = combatRef.current.party[index]
|
||||||
if (member) setSelectedTargetId(member.id)
|
if (member) setSelectedTargetId(member.id)
|
||||||
}, [dungeon.partySize, setSelectedTargetId, targetGroup])
|
}, [dungeon.partySize, setSelectedTargetId, targetGroup])
|
||||||
|
|
||||||
const chooseRoguelikeUpgrade = useCallback((upgrade: RoguelikeUpgrade) => {
|
const chooseRoguelikeUpgrade = useCallback((upgrade: RoguelikeUpgrade) => {
|
||||||
if (!roguelikeMode) return
|
if (!roguelikeMode) return
|
||||||
|
const current = combatRef.current
|
||||||
const clearedBoss = encounters[encounterIndex]?.isBoss ?? false
|
const clearedBoss = encounters[encounterIndex]?.isBoss ?? false
|
||||||
const recoveredParty = partyRef.current.map((member) => ({
|
const recoveredParty = current.party.map((member) => ({
|
||||||
...member,
|
...member,
|
||||||
health: member.health <= 0
|
health: member.health <= 0
|
||||||
? 0
|
? 0
|
||||||
@@ -747,24 +787,24 @@ export function CombatScreen({
|
|||||||
? nextSegment[0]
|
? nextSegment[0]
|
||||||
: encounters[encounterIndex + 1]
|
: encounters[encounterIndex + 1]
|
||||||
if (!nextEncounter) return
|
if (!nextEncounter) return
|
||||||
partyRef.current = recoveredParty
|
|
||||||
enemyHealthRef.current = nextEncounter.maxHealth
|
|
||||||
setRoguelikeUpgrades((current) => [...current, upgrade])
|
setRoguelikeUpgrades((current) => [...current, upgrade])
|
||||||
if (clearedBoss) {
|
if (clearedBoss) {
|
||||||
setRoguelikeStage(nextStage)
|
setRoguelikeStage(nextStage)
|
||||||
setRoguelikeEncounters((current) => [...current, ...nextSegment])
|
setRoguelikeEncounters((current) => [...current, ...nextSegment])
|
||||||
}
|
}
|
||||||
setParty(recoveredParty)
|
|
||||||
setEncounterIndex((current) => current + 1)
|
setEncounterIndex((current) => current + 1)
|
||||||
setEnemyHealth(nextEncounter.maxHealth)
|
setCombat({
|
||||||
elapsedTicksRef.current = 0
|
...current,
|
||||||
setElapsedTicks(0)
|
party: recoveredParty,
|
||||||
setCooldowns({})
|
enemyHealth: nextEncounter.maxHealth,
|
||||||
setResource((value) => clamp(value + Math.round(maxResource * 0.25), 0, maxResource))
|
elapsedTicks: 0,
|
||||||
|
cooldowns: {},
|
||||||
|
resource: clamp(current.resource + Math.round(maxResource * 0.25), 0, maxResource),
|
||||||
|
})
|
||||||
setUpgradeChoices([])
|
setUpgradeChoices([])
|
||||||
setStatus('playing')
|
setStatus('playing')
|
||||||
addLog(`${upgrade.name} gained. ${nextEncounter.enemyName} approaches.`, 'system')
|
addLog(`${upgrade.name} gained. ${nextEncounter.enemyName} approaches.`, 'system')
|
||||||
}, [addLog, difficulty, encounterIndex, encounters, maxResource, roguelikeMode, roguelikePool, roguelikeStage])
|
}, [addLog, difficulty, encounterIndex, encounters, maxResource, roguelikeMode, roguelikePool, roguelikeStage, setCombat])
|
||||||
|
|
||||||
useGameAction((action, device) => {
|
useGameAction((action, device) => {
|
||||||
if (action === 'pause' || (action === 'back' && device === 'pc')) {
|
if (action === 'pause' || (action === 'back' && device === 'pc')) {
|
||||||
@@ -783,10 +823,10 @@ export function CombatScreen({
|
|||||||
if (action === 'toggleTargetGroup') {
|
if (action === 'toggleTargetGroup') {
|
||||||
if (dungeon.partySize <= 6) return
|
if (dungeon.partySize <= 6) return
|
||||||
setTargetGroup((current) => {
|
setTargetGroup((current) => {
|
||||||
const groupCount = Math.max(1, Math.ceil(partyRef.current.length / 6))
|
const groupCount = Math.max(1, Math.ceil(combatRef.current.party.length / 6))
|
||||||
const next = ((current + 1) % groupCount) as 0 | 1 | 2
|
const next = ((current + 1) % groupCount) as 0 | 1 | 2
|
||||||
const selectedIndex = partyRef.current.findIndex((member) => member.id === selectedIdRef.current)
|
const selectedIndex = combatRef.current.party.findIndex((member) => member.id === selectedIdRef.current)
|
||||||
const nextMember = partyRef.current[(selectedIndex < 0 ? 0 : selectedIndex % 6) + next * 6]
|
const nextMember = combatRef.current.party[(selectedIndex < 0 ? 0 : selectedIndex % 6) + next * 6]
|
||||||
if (nextMember) setSelectedTargetId(nextMember.id)
|
if (nextMember) setSelectedTargetId(nextMember.id)
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
@@ -809,20 +849,17 @@ export function CombatScreen({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status !== 'playing' || paused) return
|
if (status !== 'playing' || paused) return
|
||||||
const timer = window.setInterval(() => {
|
const timer = window.setInterval(() => {
|
||||||
const nextElapsedTicks = elapsedTicksRef.current + 1
|
const current = combatRef.current
|
||||||
elapsedTicksRef.current = nextElapsedTicks
|
const nextElapsedTicks = current.elapsedTicks + 1
|
||||||
setElapsedTicks(nextElapsedTicks)
|
const nextCooldowns = Object.fromEntries(
|
||||||
setResource((value) => clamp(value + 2.4, 0, maxResource))
|
Object.entries(current.cooldowns).map(([id, seconds]) => [
|
||||||
setCooldowns((current) =>
|
id,
|
||||||
Object.fromEntries(
|
Math.max(0, seconds - TICK_MS / 1000),
|
||||||
Object.entries(current).map(([id, seconds]) => [
|
]),
|
||||||
id,
|
|
||||||
Math.max(0, seconds - TICK_MS / 1000),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
let nextResource = clamp(current.resource + 2.4, 0, maxResource)
|
||||||
|
|
||||||
const living = partyRef.current.filter((member) => member.health > 0)
|
const living = current.party.filter((member) => member.health > 0)
|
||||||
if (living.length === 0) {
|
if (living.length === 0) {
|
||||||
if (isRoguelike) finishRoguelikeRun(encounterIndex)
|
if (isRoguelike) finishRoguelikeRun(encounterIndex)
|
||||||
setStatus('lost')
|
setStatus('lost')
|
||||||
@@ -854,12 +891,12 @@ export function CombatScreen({
|
|||||||
if (appliesHealingReduction) addLog(`${primaryTarget.name} receives reduced healing.`, 'danger')
|
if (appliesHealingReduction) addLog(`${primaryTarget.name} receives reduced healing.`, 'danger')
|
||||||
if (tankBuster) addLog(`${encounter.enemyName} crushes the tanks.`, 'danger')
|
if (tankBuster) addLog(`${encounter.enemyName} crushes the tanks.`, 'danger')
|
||||||
if (resourceDrain) {
|
if (resourceDrain) {
|
||||||
setResource((value) => clamp(value - 8, 0, maxResource))
|
nextResource = clamp(nextResource - 8, 0, maxResource)
|
||||||
addLog(`${encounter.enemyName} drains ${gameClass.resourceName}.`, 'danger')
|
addLog(`${encounter.enemyName} drains ${gameClass.resourceName}.`, 'danger')
|
||||||
}
|
}
|
||||||
|
|
||||||
const healerBeforeDamage = partyRef.current.find((member) => member.id === 'mira')
|
const healerBeforeDamage = current.party.find((member) => member.id === 'mira')
|
||||||
const nextParty = partyRef.current.map((member) => {
|
const nextParty = current.party.map((member) => {
|
||||||
if (member.health <= 0) return member
|
if (member.health <= 0) return member
|
||||||
let damage = member.id === primaryTarget.id ? encounter.damage : 0
|
let damage = member.id === primaryTarget.id ? encounter.damage : 0
|
||||||
if (member.role === 'Tank') damage += encounter.tankDamage
|
if (member.role === 'Tank') damage += encounter.tankDamage
|
||||||
@@ -898,8 +935,6 @@ export function CombatScreen({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
const healerAfterDamage = nextParty.find((member) => member.id === 'mira')
|
const healerAfterDamage = nextParty.find((member) => member.id === 'mira')
|
||||||
partyRef.current = nextParty
|
|
||||||
setParty(nextParty)
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
healerBeforeDamage
|
healerBeforeDamage
|
||||||
@@ -911,16 +946,30 @@ export function CombatScreen({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (nextParty.every((member) => member.health <= 0)) {
|
if (nextParty.every((member) => member.health <= 0)) {
|
||||||
|
setCombat({
|
||||||
|
...current,
|
||||||
|
party: nextParty,
|
||||||
|
resource: nextResource,
|
||||||
|
cooldowns: nextCooldowns,
|
||||||
|
elapsedTicks: nextElapsedTicks,
|
||||||
|
enemyHealth: current.enemyHealth,
|
||||||
|
})
|
||||||
if (isRoguelike) finishRoguelikeRun(encounterIndex)
|
if (isRoguelike) finishRoguelikeRun(encounterIndex)
|
||||||
setStatus('lost')
|
setStatus('lost')
|
||||||
addLog('The party has fallen.', 'danger')
|
addLog('The party has fallen.', 'danger')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextEnemyHealth = enemyHealthRef.current - encounter.partyDamage
|
const nextEnemyHealth = current.enemyHealth - encounter.partyDamage
|
||||||
if (nextEnemyHealth > 0) {
|
if (nextEnemyHealth > 0) {
|
||||||
enemyHealthRef.current = nextEnemyHealth
|
setCombat({
|
||||||
setEnemyHealth(nextEnemyHealth)
|
...current,
|
||||||
|
party: nextParty,
|
||||||
|
resource: nextResource,
|
||||||
|
cooldowns: nextCooldowns,
|
||||||
|
elapsedTicks: nextElapsedTicks,
|
||||||
|
enemyHealth: nextEnemyHealth,
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -929,8 +978,14 @@ export function CombatScreen({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isRoguelike && (upgradesEveryEncounter || encounter.isBoss)) {
|
if (isRoguelike && (upgradesEveryEncounter || encounter.isBoss)) {
|
||||||
enemyHealthRef.current = 0
|
setCombat({
|
||||||
setEnemyHealth(0)
|
...current,
|
||||||
|
party: nextParty,
|
||||||
|
resource: nextResource,
|
||||||
|
cooldowns: nextCooldowns,
|
||||||
|
elapsedTicks: nextElapsedTicks,
|
||||||
|
enemyHealth: 0,
|
||||||
|
})
|
||||||
setUpgradeChoices(chooseRandom(roguelikeUpgradeCatalog, 3))
|
setUpgradeChoices(chooseRandom(roguelikeUpgradeCatalog, 3))
|
||||||
setStatus('upgrade-choice')
|
setStatus('upgrade-choice')
|
||||||
addLog(`${encounter.enemyName} defeated. Choose an upgrade.`, 'loot')
|
addLog(`${encounter.enemyName} defeated. Choose an upgrade.`, 'loot')
|
||||||
@@ -938,16 +993,28 @@ export function CombatScreen({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isPartBoss && !isFinalBoss) {
|
if (isPartBoss && !isFinalBoss) {
|
||||||
enemyHealthRef.current = 0
|
setCombat({
|
||||||
setEnemyHealth(0)
|
...current,
|
||||||
|
party: nextParty,
|
||||||
|
resource: nextResource,
|
||||||
|
cooldowns: nextCooldowns,
|
||||||
|
elapsedTicks: nextElapsedTicks,
|
||||||
|
enemyHealth: 0,
|
||||||
|
})
|
||||||
setStatus('part-complete')
|
setStatus('part-complete')
|
||||||
addLog(`${encounter.enemyName} is defeated.`, 'loot')
|
addLog(`${encounter.enemyName} is defeated.`, 'loot')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (encounterIndex === encounters.length - 1) {
|
if (encounterIndex === encounters.length - 1) {
|
||||||
enemyHealthRef.current = 0
|
setCombat({
|
||||||
setEnemyHealth(0)
|
...current,
|
||||||
|
party: nextParty,
|
||||||
|
resource: nextResource,
|
||||||
|
cooldowns: nextCooldowns,
|
||||||
|
elapsedTicks: nextElapsedTicks,
|
||||||
|
enemyHealth: 0,
|
||||||
|
})
|
||||||
finishRun(currentPart, startPart)
|
finishRun(currentPart, startPart)
|
||||||
addLog(`${encounter.enemyName} is defeated. Rolling its loot table.`, 'loot')
|
addLog(`${encounter.enemyName} is defeated. Rolling its loot table.`, 'loot')
|
||||||
return
|
return
|
||||||
@@ -965,13 +1032,15 @@ export function CombatScreen({
|
|||||||
maxHealthPenaltyTicks: undefined,
|
maxHealthPenaltyTicks: undefined,
|
||||||
healingReductionTicks: undefined,
|
healingReductionTicks: undefined,
|
||||||
}))
|
}))
|
||||||
partyRef.current = recoveredParty
|
|
||||||
enemyHealthRef.current = nextEncounter.maxHealth
|
|
||||||
setParty(recoveredParty)
|
|
||||||
setEncounterIndex((value) => value + 1)
|
setEncounterIndex((value) => value + 1)
|
||||||
setEnemyHealth(nextEncounter.maxHealth)
|
setCombat({
|
||||||
elapsedTicksRef.current = 0
|
...current,
|
||||||
setElapsedTicks(0)
|
party: recoveredParty,
|
||||||
|
resource: nextResource,
|
||||||
|
cooldowns: nextCooldowns,
|
||||||
|
elapsedTicks: 0,
|
||||||
|
enemyHealth: nextEncounter.maxHealth,
|
||||||
|
})
|
||||||
addLog(`${encounter.enemyName} defeated. ${nextEncounter.enemyName} approaches.`, 'system')
|
addLog(`${encounter.enemyName} defeated. ${nextEncounter.enemyName} approaches.`, 'system')
|
||||||
}, TICK_MS)
|
}, TICK_MS)
|
||||||
return () => window.clearInterval(timer)
|
return () => window.clearInterval(timer)
|
||||||
@@ -994,6 +1063,7 @@ export function CombatScreen({
|
|||||||
gameClass.resourceName,
|
gameClass.resourceName,
|
||||||
requestLootRoll,
|
requestLootRoll,
|
||||||
profile.character.name,
|
profile.character.name,
|
||||||
|
setCombat,
|
||||||
startPart,
|
startPart,
|
||||||
status,
|
status,
|
||||||
currentPart,
|
currentPart,
|
||||||
@@ -1140,17 +1210,16 @@ export function CombatScreen({
|
|||||||
{party.map((member) => (
|
{party.map((member) => (
|
||||||
<button
|
<button
|
||||||
className={`party-member ${selectedId === member.id ? 'selected' : ''} ${member.health <= 0 ? 'dead' : ''}`}
|
className={`party-member ${selectedId === member.id ? 'selected' : ''} ${member.health <= 0 ? 'dead' : ''}`}
|
||||||
|
data-party-member-id={member.id}
|
||||||
key={member.id}
|
key={member.id}
|
||||||
onClick={() => setSelectedTargetId(member.id)}
|
onClick={() => setSelectedTargetId(member.id)}
|
||||||
aria-pressed={selectedId === member.id}
|
aria-pressed={selectedId === member.id}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{selectedId === member.id && (
|
<span className="target-marker" aria-hidden="true">
|
||||||
<span className="target-marker" aria-hidden="true">
|
<i />
|
||||||
<i />
|
Target
|
||||||
Target
|
</span>
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<div className="member-header">
|
<div className="member-header">
|
||||||
<span className={`role role-${member.role.toLowerCase()}`}>{member.role[0]}</span>
|
<span className={`role role-${member.role.toLowerCase()}`}>{member.role[0]}</span>
|
||||||
<strong>{member.name}</strong>
|
<strong>{member.name}</strong>
|
||||||
@@ -1398,19 +1467,20 @@ export function CombatScreen({
|
|||||||
const nextIndex = encounterIndex + 1
|
const nextIndex = encounterIndex + 1
|
||||||
partStartTimesRef.current[currentPart + 1] = Date.now()
|
partStartTimesRef.current[currentPart + 1] = Date.now()
|
||||||
const nextEncounter = encounters[nextIndex]
|
const nextEncounter = encounters[nextIndex]
|
||||||
const recoveredParty = partyRef.current.map((member) => ({
|
const current = combatRef.current
|
||||||
|
const recoveredParty = current.party.map((member) => ({
|
||||||
...member,
|
...member,
|
||||||
health: clamp(member.health + 35, 0, member.maxHealth),
|
health: clamp(member.health + 35, 0, member.maxHealth),
|
||||||
debuff: undefined,
|
debuff: undefined,
|
||||||
debuffTicks: undefined,
|
debuffTicks: undefined,
|
||||||
}))
|
}))
|
||||||
partyRef.current = recoveredParty
|
|
||||||
enemyHealthRef.current = nextEncounter.maxHealth
|
|
||||||
setParty(recoveredParty)
|
|
||||||
setEncounterIndex(nextIndex)
|
setEncounterIndex(nextIndex)
|
||||||
setEnemyHealth(nextEncounter.maxHealth)
|
setCombat({
|
||||||
elapsedTicksRef.current = 0
|
...current,
|
||||||
setElapsedTicks(0)
|
party: recoveredParty,
|
||||||
|
enemyHealth: nextEncounter.maxHealth,
|
||||||
|
elapsedTicks: 0,
|
||||||
|
})
|
||||||
setStatus('playing')
|
setStatus('playing')
|
||||||
addLog(`Proceeding to ${sectionName} ${currentPart + 1}. ${nextEncounter.enemyName} approaches.`, 'system')
|
addLog(`Proceeding to ${sectionName} ${currentPart + 1}. ${nextEncounter.enemyName} approaches.`, 'system')
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -61,15 +61,24 @@ 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 selectedRecipeRequiresUpgrade = selectedRecipe
|
||||||
|
? profile.craftingRecipes.some((recipe) =>
|
||||||
|
recipe.sourceEncounterId === selectedRecipe.sourceEncounterId
|
||||||
|
&& recipe.item.slot === selectedRecipe.item.slot
|
||||||
|
&& recipe.item.itemLevel < selectedRecipe.item.itemLevel,
|
||||||
|
)
|
||||||
|
: false
|
||||||
const selectedItemRecipe = selectedItem
|
const selectedItemRecipe = selectedItem
|
||||||
? profile.craftingRecipes.find((recipe) => recipe.item.id === selectedItem.id)
|
? profile.craftingRecipes.find((recipe) => recipe.item.id === selectedItem.id)
|
||||||
: undefined
|
: undefined
|
||||||
const upgradeRecipe = selectedItem && selectedItemRecipe
|
const upgradeRecipe = selectedItem && selectedItemRecipe
|
||||||
? profile.craftingRecipes.find((recipe) =>
|
? profile.craftingRecipes
|
||||||
recipe.sourceEncounterId === selectedItemRecipe.sourceEncounterId
|
.filter((recipe) =>
|
||||||
&& recipe.item.slot === selectedItem.slot
|
recipe.sourceEncounterId === selectedItemRecipe.sourceEncounterId
|
||||||
&& recipe.item.itemLevel === selectedItem.itemLevel + 5,
|
&& recipe.item.slot === selectedItem.slot
|
||||||
)
|
&& recipe.item.itemLevel > selectedItem.itemLevel,
|
||||||
|
)
|
||||||
|
.sort((left, right) => left.item.itemLevel - right.item.itemLevel)[0]
|
||||||
: undefined
|
: undefined
|
||||||
const equippedBySlot = useMemo(
|
const equippedBySlot = useMemo(
|
||||||
() => new Map(
|
() => new Map(
|
||||||
@@ -117,6 +126,16 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
|||||||
},
|
},
|
||||||
[profile.craftingRecipes, slotFilter, levelFilter],
|
[profile.craftingRecipes, slotFilter, levelFilter],
|
||||||
)
|
)
|
||||||
|
const readyRecipeCount = filteredRecipes.filter((recipe) => recipe.canCraft).length
|
||||||
|
const slotRecipeCounts = useMemo(
|
||||||
|
() => new Map(
|
||||||
|
(Object.keys(SLOT_LABELS) as EquipmentSlot[]).map((slot) => [
|
||||||
|
slot,
|
||||||
|
profile.craftingRecipes.filter((recipe) => recipe.item.slot === slot).length,
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
[profile.craftingRecipes],
|
||||||
|
)
|
||||||
const recipePageCount = Math.max(
|
const recipePageCount = Math.max(
|
||||||
1,
|
1,
|
||||||
Math.ceil(filteredRecipes.length / CRAFTING_LIST_PAGE_SIZE),
|
Math.ceil(filteredRecipes.length / CRAFTING_LIST_PAGE_SIZE),
|
||||||
@@ -138,6 +157,16 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
|||||||
setRecipePage((current) => Math.min(current, recipePageCount - 1))
|
setRecipePage((current) => Math.min(current, recipePageCount - 1))
|
||||||
}, [recipePageCount])
|
}, [recipePageCount])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (filteredRecipes.length === 0) {
|
||||||
|
setSelectedRecipeId(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!filteredRecipes.some((recipe) => recipe.id === selectedRecipeId)) {
|
||||||
|
setSelectedRecipeId(filteredRecipes[0].id)
|
||||||
|
}
|
||||||
|
}, [filteredRecipes, selectedRecipeId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (equipmentTab === 'crafting') {
|
if (equipmentTab === 'crafting') {
|
||||||
loadProfile().then((fresh) => onUpdated(fresh)).catch(() => {})
|
loadProfile().then((fresh) => onUpdated(fresh)).catch(() => {})
|
||||||
@@ -421,43 +450,82 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
|||||||
<section className="crafting-panel">
|
<section className="crafting-panel">
|
||||||
<EquipmentHeading
|
<EquipmentHeading
|
||||||
eyebrow="Crafting"
|
eyebrow="Crafting"
|
||||||
title="Recipes"
|
title="Workbench"
|
||||||
detail={`${filteredRecipes.filter((recipe) => recipe.canCraft).length} ready`}
|
detail={`${readyRecipeCount} ready / ${filteredRecipes.length} shown`}
|
||||||
/>
|
/>
|
||||||
<div className="crafting-filter-bar">
|
<div className="crafting-layout">
|
||||||
<select
|
<aside className="crafting-filters">
|
||||||
className="filter-select"
|
<div>
|
||||||
value={slotFilter}
|
<p className="eyebrow">Slot</p>
|
||||||
onChange={(e) => {
|
<div className="crafting-filter-grid">
|
||||||
setSlotFilter(e.target.value as EquipmentSlot | 'all')
|
<button
|
||||||
setRecipePage(0)
|
className={slotFilter === 'all' ? 'active' : ''}
|
||||||
}}
|
onClick={() => {
|
||||||
>
|
setSlotFilter('all')
|
||||||
<option value="all">All Slots</option>
|
setRecipePage(0)
|
||||||
{(Object.entries(SLOT_LABELS) as [EquipmentSlot, string][]).map(([slot, label]) => (
|
}}
|
||||||
<option key={slot} value={slot}>{label}</option>
|
type="button"
|
||||||
))}
|
>
|
||||||
</select>
|
<strong>All</strong>
|
||||||
<select
|
<span>{profile.craftingRecipes.length}</span>
|
||||||
className="filter-select"
|
</button>
|
||||||
value={levelFilter ?? ''}
|
{(Object.entries(SLOT_LABELS) as [EquipmentSlot, string][]).map(([slot, label]) => (
|
||||||
onChange={(e) => {
|
<button
|
||||||
setLevelFilter(e.target.value === '' ? null : Number(e.target.value))
|
className={slotFilter === slot ? 'active' : ''}
|
||||||
setRecipePage(0)
|
disabled={(slotRecipeCounts.get(slot) ?? 0) === 0}
|
||||||
}}
|
key={slot}
|
||||||
>
|
onClick={() => {
|
||||||
<option value="">All Levels</option>
|
setSlotFilter(slot)
|
||||||
{availableLevels.map((level) => (
|
setRecipePage(0)
|
||||||
<option key={level} value={level}>Item Level {level}</option>
|
}}
|
||||||
))}
|
type="button"
|
||||||
</select>
|
>
|
||||||
</div>
|
<strong>{label}</strong>
|
||||||
{filteredRecipes.length === 0 && (
|
<span>{slotRecipeCounts.get(slot) ?? 0}</span>
|
||||||
<p className="inventory-empty">No crafting recipes match filters.</p>
|
</button>
|
||||||
)}
|
))}
|
||||||
{filteredRecipes.length > 0 && (
|
</div>
|
||||||
<div className="crafting-layout">
|
</div>
|
||||||
<div className="crafting-list">
|
<div>
|
||||||
|
<p className="eyebrow">Item Level</p>
|
||||||
|
<div className="crafting-level-row">
|
||||||
|
<button
|
||||||
|
className={levelFilter === null ? 'active' : ''}
|
||||||
|
onClick={() => {
|
||||||
|
setLevelFilter(null)
|
||||||
|
setRecipePage(0)
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
{availableLevels.map((level) => (
|
||||||
|
<button
|
||||||
|
className={levelFilter === level ? 'active' : ''}
|
||||||
|
key={level}
|
||||||
|
onClick={() => {
|
||||||
|
setLevelFilter(level)
|
||||||
|
setRecipePage(0)
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{level}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section className="crafting-list-panel">
|
||||||
|
<EquipmentHeading
|
||||||
|
eyebrow="Recipes"
|
||||||
|
title={slotFilter === 'all' ? 'Available' : SLOT_LABELS[slotFilter]}
|
||||||
|
detail={`Page ${recipePage + 1}/${recipePageCount}`}
|
||||||
|
/>
|
||||||
|
{filteredRecipes.length === 0 ? (
|
||||||
|
<p className="inventory-empty">No recipes match filters.</p>
|
||||||
|
) : (
|
||||||
|
<div className="crafting-list">
|
||||||
{recipePageItems.map((recipe) => (
|
{recipePageItems.map((recipe) => (
|
||||||
<button
|
<button
|
||||||
className={`${selectedRecipeId === recipe.id ? 'selected' : ''} rarity-${recipe.item.rarity}`}
|
className={`${selectedRecipeId === recipe.id ? 'selected' : ''} rarity-${recipe.item.rarity}`}
|
||||||
@@ -473,9 +541,13 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
|||||||
{recipe.item.setName ? ` - ${recipe.item.setName}` : ''}
|
{recipe.item.setName ? ` - ${recipe.item.setName}` : ''}
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
<i>{recipe.canCraft ? 'Ready' : 'Needs materials'}</i>
|
<i className={recipe.canCraft ? 'ready' : 'missing'}>
|
||||||
|
{recipe.canCraft ? 'Ready' : 'Needs materials'}
|
||||||
|
</i>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{filteredRecipes.length > CRAFTING_LIST_PAGE_SIZE && (
|
{filteredRecipes.length > CRAFTING_LIST_PAGE_SIZE && (
|
||||||
<ListPager
|
<ListPager
|
||||||
label={`Page ${recipePage + 1} / ${recipePageCount}`}
|
label={`Page ${recipePage + 1} / ${recipePageCount}`}
|
||||||
@@ -485,10 +557,16 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
|||||||
previousDisabled={recipePage <= 0}
|
previousDisabled={recipePage <= 0}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</section>
|
||||||
{selectedRecipe && (
|
|
||||||
|
<section className="crafting-detail-panel">
|
||||||
|
{selectedRecipe ? (
|
||||||
<div className={`crafting-detail rarity-${selectedRecipe.item.rarity}`}>
|
<div className={`crafting-detail rarity-${selectedRecipe.item.rarity}`}>
|
||||||
<ItemDetail title="Craft Output" item={{ ...selectedRecipe.item, quantity: 1, equipped: false }} />
|
<ItemDetail title="Craft Output" item={{ ...selectedRecipe.item, quantity: 1, equipped: false }} />
|
||||||
|
<div className="crafting-detail-heading">
|
||||||
|
<p className="eyebrow">Materials</p>
|
||||||
|
<span>{selectedRecipe.canCraft ? 'Ready' : 'Missing components'}</span>
|
||||||
|
</div>
|
||||||
<div className="crafting-components">
|
<div className="crafting-components">
|
||||||
{selectedRecipe.components.map((component) => (
|
{selectedRecipe.components.map((component) => (
|
||||||
<div
|
<div
|
||||||
@@ -503,20 +581,22 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="primary-button"
|
className="primary-button"
|
||||||
disabled={!selectedRecipe.canCraft || crafting}
|
disabled={selectedRecipeRequiresUpgrade || !selectedRecipe.canCraft || crafting}
|
||||||
onClick={craftSelected}
|
onClick={craftSelected}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{crafting ? 'Crafting...' : 'Craft Item'}
|
{crafting ? 'Crafting...' : selectedRecipeRequiresUpgrade ? 'Upgrade Existing Item' : 'Craft Item'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="inventory-empty">Select a recipe.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</section>
|
||||||
)}
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{profile.setBonuses.length > 0 && (
|
{equipmentTab === 'equipment' && profile.setBonuses.length > 0 && (
|
||||||
<section className="set-bonus-panel">
|
<section className="set-bonus-panel">
|
||||||
<div className="equipment-heading toggle-heading">
|
<div className="equipment-heading toggle-heading">
|
||||||
<div>
|
<div>
|
||||||
@@ -552,11 +632,11 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (embedded) {
|
if (embedded) {
|
||||||
return <div className="equipment-screen embedded-screen">{content}</div>
|
return <div className={`equipment-screen embedded-screen ${equipmentTab === 'crafting' ? 'crafting-active' : ''}`}>{content}</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="content-screen equipment-screen">
|
<section className={`content-screen equipment-screen ${equipmentTab === 'crafting' ? 'crafting-active' : ''}`}>
|
||||||
{content}
|
{content}
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -475,6 +475,7 @@ export function DualScreenTopCombat({
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={`dual-top-member ${state.selectedId === member.id ? 'selected' : ''} ${member.health <= 0 ? 'dead' : ''}`}
|
className={`dual-top-member ${state.selectedId === member.id ? 'selected' : ''} ${member.health <= 0 ? 'dead' : ''}`}
|
||||||
|
data-party-member-id={member.id}
|
||||||
key={member.id}
|
key={member.id}
|
||||||
onClick={() => onSelectTarget(member.id)}
|
onClick={() => onSelectTarget(member.id)}
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
+14
-7
@@ -760,8 +760,7 @@ function emptyCharacterData(classId: number): CharacterData {
|
|||||||
const gc = static_.classes.find((c) => c.id === classId)!
|
const gc = static_.classes.find((c) => c.id === classId)!
|
||||||
const talentRanks: Record<string, number> = {}
|
const talentRanks: Record<string, number> = {}
|
||||||
for (const t of gc.talents) talentRanks[String(t.id)] = 0
|
for (const t of gc.talents) talentRanks[String(t.id)] = 0
|
||||||
const starterItemIds = [100, 101, 102, 103, 104, 105, 106, 107, 108, 109]
|
const inventory: Item[] = []
|
||||||
const inventory: Item[] = static_.inventory.filter((i) => starterItemIds.includes(i.id)).map((i) => ({ ...i }))
|
|
||||||
const startingAbilitySlots: Array<number | null> = gc.spells
|
const startingAbilitySlots: Array<number | null> = gc.spells
|
||||||
.filter((s) => s.unlockLevel === 1)
|
.filter((s) => s.unlockLevel === 1)
|
||||||
.slice(0, 5)
|
.slice(0, 5)
|
||||||
@@ -1164,6 +1163,12 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
|||||||
const profile = buildProfile(save)
|
const profile = buildProfile(save)
|
||||||
const recipe = profile.craftingRecipes.find((candidate) => candidate.id === recipeId)
|
const recipe = profile.craftingRecipes.find((candidate) => candidate.id === recipeId)
|
||||||
if (!recipe) throw new Error('That crafting recipe does not exist.')
|
if (!recipe) throw new Error('That crafting recipe does not exist.')
|
||||||
|
const requiresUpgrade = profile.craftingRecipes.some((candidate) =>
|
||||||
|
candidate.sourceEncounterId === recipe.sourceEncounterId
|
||||||
|
&& candidate.item.slot === recipe.item.slot
|
||||||
|
&& candidate.item.itemLevel < recipe.item.itemLevel,
|
||||||
|
)
|
||||||
|
if (requiresUpgrade) throw new Error('Upgrade the previous item tier instead.')
|
||||||
const missing = recipe.components.find((component) => component.owned < component.quantity)
|
const missing = recipe.components.find((component) => component.owned < component.quantity)
|
||||||
if (missing) {
|
if (missing) {
|
||||||
throw new Error(`Need ${missing.quantity} ${missing.item.name} to craft this item.`)
|
throw new Error(`Need ${missing.quantity} ${missing.item.name} to craft this item.`)
|
||||||
@@ -1191,11 +1196,13 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
|||||||
if (item.slot === 'component') throw new Error('Components cannot be upgraded.')
|
if (item.slot === 'component') throw new Error('Components cannot be upgraded.')
|
||||||
const currentRecipe = profile.craftingRecipes.find((recipe) => recipe.item.id === item.id)
|
const currentRecipe = profile.craftingRecipes.find((recipe) => recipe.item.id === item.id)
|
||||||
const targetRecipe = currentRecipe
|
const targetRecipe = currentRecipe
|
||||||
? profile.craftingRecipes.find((recipe) =>
|
? profile.craftingRecipes
|
||||||
recipe.sourceEncounterId === currentRecipe.sourceEncounterId
|
.filter((recipe) =>
|
||||||
&& recipe.item.slot === item.slot
|
recipe.sourceEncounterId === currentRecipe.sourceEncounterId
|
||||||
&& recipe.item.itemLevel === item.itemLevel + 5,
|
&& recipe.item.slot === item.slot
|
||||||
)
|
&& recipe.item.itemLevel > item.itemLevel,
|
||||||
|
)
|
||||||
|
.sort((left, right) => left.item.itemLevel - right.item.itemLevel)[0]
|
||||||
: null
|
: null
|
||||||
if (!targetRecipe) throw new Error('No upgrade is available for this item.')
|
if (!targetRecipe) throw new Error('No upgrade is available for this item.')
|
||||||
const missing = targetRecipe.components.find((component) => component.owned < component.quantity)
|
const missing = targetRecipe.components.find((component) => component.owned < component.quantity)
|
||||||
|
|||||||
+2
-1
@@ -121,6 +121,7 @@ const STORAGE_KEY = 'ashen-halls-input-bindings-v1'
|
|||||||
const PREFERENCES_STORAGE_KEY = 'ashen-halls-input-preferences-v1'
|
const PREFERENCES_STORAGE_KEY = 'ashen-halls-input-preferences-v1'
|
||||||
const GAME_ACTION_EVENT = 'ashen-halls-game-action'
|
const GAME_ACTION_EVENT = 'ashen-halls-game-action'
|
||||||
const NATIVE_CONTROLLER_EVENT = 'ashen-halls-native-controller'
|
const NATIVE_CONTROLLER_EVENT = 'ashen-halls-native-controller'
|
||||||
|
const COMBAT_TARGET_NAVIGATION_THROTTLE_MS = 220
|
||||||
|
|
||||||
type CaptureState = {
|
type CaptureState = {
|
||||||
device: InputDevice
|
device: InputDevice
|
||||||
@@ -446,7 +447,7 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
|||||||
const combatActive = Boolean(document.querySelector('[data-combat-active="true"]'))
|
const combatActive = Boolean(document.querySelector('[data-combat-active="true"]'))
|
||||||
if (combatActive && !uiOverlay && isCombatTargetAction(action)) {
|
if (combatActive && !uiOverlay && isCombatTargetAction(action)) {
|
||||||
const now = performance.now()
|
const now = performance.now()
|
||||||
if (now - lastCombatNavigationRef.current < 125) return
|
if (now - lastCombatNavigationRef.current < COMBAT_TARGET_NAVIGATION_THROTTLE_MS) return
|
||||||
lastCombatNavigationRef.current = now
|
lastCombatNavigationRef.current = now
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+541
-857
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user