Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f5a957963 | |||
| f8a1fbc5e2 | |||
| bab2dce6c3 | |||
| cb38042eca | |||
| 753bba581a | |||
| 2300973164 | |||
| 1281be69d8 | |||
| 4fc15ebe9a | |||
| 7313c968e6 | |||
| 207fcd1a15 |
@@ -4,5 +4,6 @@
|
||||
- AYN Thor secondary display: 3.92-inch AMOLED, 1240 x 1080, 60Hz.
|
||||
- AYN Thor UI sizing must be designed against Android CSS/layout viewport, not physical framebuffer pixels.
|
||||
- Approximate Thor CSS viewports: main display 960 x 540, secondary display 620 x 540.
|
||||
- Test top-screen UI only against the main display viewport, and bottom-screen UI only against the secondary display viewport.
|
||||
- User rebuilds app; do not rebuild APK unless explicitly requested.
|
||||
- Apply game changes to both web version and mobile app version.
|
||||
|
||||
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.
@@ -7,8 +7,8 @@ android {
|
||||
applicationId "com.warren.iwanttoheal"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 48
|
||||
versionName "1.0.30"
|
||||
versionCode 59
|
||||
versionName "1.0.39"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
+558
-54
@@ -106,9 +106,8 @@ SET slug = 'rathian',
|
||||
image_url = COALESCE(NULLIF(image_url, ''), '/boss-placeholder.svg')
|
||||
WHERE id = 22;
|
||||
|
||||
INSERT OR IGNORE INTO mechanics
|
||||
(id, encounter_id, name, mechanic_type, interval_seconds, power, description)
|
||||
VALUES
|
||||
WITH mechanics_seed(id, encounter_id, name, mechanic_type, interval_seconds, power, description) AS (
|
||||
VALUES
|
||||
(1, 3, 'Cinder Pulse', 'party_damage', 4.9, 12, 'Deals damage to the full party.'),
|
||||
(2, 3, 'Searing Mark', 'dispellable_dot', 7.7, 7, 'Marks one target with recurring damage until cleansed.'),
|
||||
(10, 12, 'Ember Wave', 'party_damage', 5.6, 14, 'A wave of ember energy hits the entire party.'),
|
||||
@@ -120,7 +119,17 @@ VALUES
|
||||
(102, 105, 'Pillar of Judgment', 'party_damage', 5.2, 16, 'Columns of flame erupt beneath the raid.'),
|
||||
(103, 105, 'Inquisitor''s Brand', 'dispellable_dot', 7.8, 10, 'A burning brand persists until cleansed.'),
|
||||
(104, 108, 'Crownflare', 'party_damage', 4.9, 18, 'The Ember Crown releases a raid-wide flare.'),
|
||||
(105, 108, 'Royal Decree', 'dispellable_dot', 7.1, 12, 'A lethal decree marks one raider for cleansing.');
|
||||
(105, 108, 'Royal Decree', 'dispellable_dot', 7.1, 12, 'A lethal decree marks one raider for cleansing.')
|
||||
)
|
||||
INSERT OR IGNORE INTO mechanics
|
||||
(id, encounter_id, name, mechanic_type, interval_seconds, power, description)
|
||||
SELECT id, encounter_id, name, mechanic_type, interval_seconds, power, description
|
||||
FROM mechanics_seed
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM encounters
|
||||
WHERE encounters.id = mechanics_seed.encounter_id
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO classes
|
||||
(id, slug, name, resource_name, max_resource, theme_color, description)
|
||||
@@ -134,22 +143,22 @@ INSERT OR IGNORE INTO spells
|
||||
VALUES
|
||||
(1, 1, 'mend', 'Mend', 'direct_heal', 5, 0.5, 30, 1, '+', 'A fast, efficient single-target heal.'),
|
||||
(2, 1, 'renew', 'Renew', 'heal_over_time', 7, 0.5, 12, 1, '~', 'Heals now and continues healing over time.'),
|
||||
(3, 1, 'radiance', 'Radiance', 'party_heal', 12, 8, 18, 1, '*', 'Restores health to every living party member.'),
|
||||
(3, 1, 'radiance', 'Radiance', 'party_heal', 12, 8, 18, 1, '*', 'Restores health to up to 4 injured party members.'),
|
||||
(4, 1, 'sun-ward', 'Sun Ward', 'absorb', 8, 7, 36, 1, 'O', 'Places a damage-absorbing shield on your target.'),
|
||||
(5, 1, 'purify', 'Purify', 'cleanse', 5, 5, 10, 1, 'x', 'Removes a harmful effect and restores health.'),
|
||||
(6, 1, 'dawn-burst', 'Dawn Burst', 'party_heal', 16, 12, 28, 5, 'D', 'A brilliant wave of healing for the entire party.'),
|
||||
(6, 1, 'dawn-burst', 'Dawn Burst', 'party_heal', 16, 12, 28, 5, 'D', 'A brilliant wave of healing for up to 4 injured allies.'),
|
||||
(7, 1, 'guardian-light', 'Guardian Light', 'absorb', 13, 14, 55, 10, 'G', 'A powerful ward reserved for moments of extreme danger.'),
|
||||
(8, 1, 'second-sun', 'Second Sun', 'direct_heal', 20, 20, 85, 15, 'S', 'Calls down a delayed surge of restorative light.'),
|
||||
(9, 1, 'daybreak', 'Daybreak', 'party_heal', 23, 30, 48, 20, 'A', 'Floods the party with the full strength of dawn.'),
|
||||
(9, 1, 'daybreak', 'Daybreak', 'party_heal', 23, 30, 48, 20, 'A', 'Floods up to 4 injured allies with the full strength of dawn.'),
|
||||
(20, 2, 'verdant-touch', 'Verdant Touch', 'direct_heal', 5, 0.5, 28, 1, '+', 'A quick pulse of living energy.'),
|
||||
(21, 2, 'seed-of-life', 'Seed of Life', 'heal_over_time', 7, 0.5, 11, 1, 's', 'Plants a restorative seed that blooms over time.'),
|
||||
(22, 2, 'wild-bloom', 'Wild Bloom', 'party_heal', 12, 8, 17, 1, '*', 'Restorative growth spreads through the party.'),
|
||||
(22, 2, 'wild-bloom', 'Wild Bloom', 'party_heal', 12, 8, 17, 1, '*', 'Restorative growth spreads to up to 4 injured allies.'),
|
||||
(23, 2, 'barkskin', 'Barkskin', 'absorb', 8, 7, 34, 1, 'B', 'Wraps an ally in protective living bark.'),
|
||||
(24, 2, 'purging-sap', 'Purging Sap', 'cleanse', 5, 5, 10, 1, 'p', 'Draws a harmful effect out through enchanted sap.'),
|
||||
(25, 2, 'ancient-grove', 'Ancient Grove', 'party_heal', 17, 12, 31, 5, 'T', 'Briefly summons the shelter of an ancient grove.'),
|
||||
(30, 3, 'etched-mend', 'Etched Mend', 'direct_heal', 5, 0.5, 29, 1, '+', 'Completes a simple rune of restoration.'),
|
||||
(31, 3, 'echo-rune', 'Echo Rune', 'heal_over_time', 7, 0.5, 12, 1, 'e', 'Repeats a restorative rune over several moments.'),
|
||||
(32, 3, 'concordance', 'Concordance', 'party_heal', 12, 8, 18, 1, '*', 'Links the party through a shared healing pattern.'),
|
||||
(32, 3, 'concordance', 'Concordance', 'party_heal', 12, 8, 18, 1, '*', 'Links up to 4 injured allies through a shared healing pattern.'),
|
||||
(33, 3, 'aegis-script', 'Aegis Script', 'absorb', 8, 7, 35, 1, 'O', 'Writes a temporary barrier around an ally.'),
|
||||
(34, 3, 'unravel', 'Unravel', 'cleanse', 5, 5, 10, 1, 'u', 'Unravels a hostile magical pattern.'),
|
||||
(35, 3, 'grand-design', 'Grand Design', 'party_heal', 16, 12, 30, 5, 'R', 'Activates a prepared network of restorative runes.');
|
||||
@@ -191,6 +200,76 @@ UPDATE spells SET unlock_level = 10 WHERE slug = 'guardian-light';
|
||||
UPDATE spells SET unlock_level = 15 WHERE slug = 'second-sun';
|
||||
UPDATE spells SET unlock_level = 20 WHERE slug = 'daybreak';
|
||||
|
||||
UPDATE spells SET
|
||||
name = 'Verdant Touch',
|
||||
spell_type = 'direct_hot',
|
||||
resource_cost = 5,
|
||||
cooldown_seconds = 0.5,
|
||||
power = 20,
|
||||
glyph = '+',
|
||||
description = 'A weaker direct heal that also plants a stacking heal over time.'
|
||||
WHERE slug = 'verdant-touch';
|
||||
|
||||
UPDATE spells SET
|
||||
name = 'Wild Growth',
|
||||
spell_type = 'party_hot',
|
||||
resource_cost = 12,
|
||||
cooldown_seconds = 8,
|
||||
power = 14,
|
||||
glyph = '*',
|
||||
description = 'Applies a stacking heal over time to up to 4 injured allies.'
|
||||
WHERE slug = 'wild-bloom';
|
||||
|
||||
UPDATE spells SET
|
||||
name = 'Barkskin',
|
||||
spell_type = 'damage_reduction',
|
||||
resource_cost = 10,
|
||||
cooldown_seconds = 14,
|
||||
power = 0,
|
||||
glyph = 'B',
|
||||
description = 'Reduces the target ally''s damage taken by 50% for 8 seconds.'
|
||||
WHERE slug = 'barkskin';
|
||||
|
||||
UPDATE spells SET
|
||||
name = 'Ancient Grove',
|
||||
spell_type = 'party_hot',
|
||||
resource_cost = 17,
|
||||
cooldown_seconds = 12,
|
||||
power = 24,
|
||||
glyph = 'T',
|
||||
description = 'Applies a stronger stacking heal over time to up to 4 injured allies.'
|
||||
WHERE slug = 'ancient-grove';
|
||||
|
||||
UPDATE spells SET
|
||||
name = 'Mending Rune',
|
||||
spell_type = 'bounce_heal',
|
||||
resource_cost = 7,
|
||||
cooldown_seconds = 0.5,
|
||||
power = 18,
|
||||
glyph = 'e',
|
||||
description = 'Places a rune that heals when the ally takes damage, then jumps 4 times.'
|
||||
WHERE slug = 'echo-rune';
|
||||
|
||||
UPDATE spells SET
|
||||
name = 'Concordance',
|
||||
spell_type = 'party_absorb',
|
||||
resource_cost = 12,
|
||||
cooldown_seconds = 8,
|
||||
power = 28,
|
||||
glyph = '*',
|
||||
description = 'Shields up to 4 injured allies through a shared barrier pattern.'
|
||||
WHERE slug = 'concordance';
|
||||
|
||||
UPDATE spells SET
|
||||
name = 'Grand Design',
|
||||
spell_type = 'party_absorb',
|
||||
resource_cost = 16,
|
||||
cooldown_seconds = 12,
|
||||
power = 42,
|
||||
glyph = 'R',
|
||||
description = 'Raises a stronger shared barrier around up to 4 injured allies.'
|
||||
WHERE slug = 'grand-design';
|
||||
|
||||
INSERT OR IGNORE INTO items
|
||||
(id, slug, name, slot, rarity, item_level, healing_power, max_resource_bonus, glyph, description)
|
||||
VALUES
|
||||
@@ -322,6 +401,16 @@ UPDATE items SET glyph = '/' WHERE slug = 'ashwood-crook';
|
||||
UPDATE items SET glyph = 'b' WHERE slug = 'cinderstep-boots';
|
||||
UPDATE items SET glyph = '^' WHERE slug = 'adepts-hood';
|
||||
|
||||
INSERT OR IGNORE INTO encounters
|
||||
(id, dungeon_id, sequence, slug, name, encounter_type, max_health, base_damage, tank_damage, party_damage, description)
|
||||
VALUES
|
||||
(3, 1, 9003, 'legacy-loot-encounter-3', 'Legacy Loot Encounter 3', 'boss', 1, 1, 1, 1, 'Temporary legacy seed row.'),
|
||||
(12, 1, 9012, 'legacy-loot-encounter-12', 'Legacy Loot Encounter 12', 'boss', 1, 1, 1, 1, 'Temporary legacy seed row.'),
|
||||
(22, 1, 9022, 'legacy-loot-encounter-22', 'Legacy Loot Encounter 22', 'boss', 1, 1, 1, 1, 'Temporary legacy seed row.'),
|
||||
(102, 2, 9102, 'legacy-loot-encounter-102', 'Legacy Loot Encounter 102', 'boss', 1, 1, 1, 1, 'Temporary legacy seed row.'),
|
||||
(105, 2, 9105, 'legacy-loot-encounter-105', 'Legacy Loot Encounter 105', 'boss', 1, 1, 1, 1, 'Temporary legacy seed row.'),
|
||||
(108, 2, 9108, 'legacy-loot-encounter-108', 'Legacy Loot Encounter 108', 'boss', 1, 1, 1, 1, 'Temporary legacy seed row.');
|
||||
|
||||
DELETE FROM encounter_loot;
|
||||
|
||||
INSERT INTO encounter_loot
|
||||
@@ -446,20 +535,20 @@ WHERE character_id IN (1, 2, 3)
|
||||
-- Coin gearing override: every boss/difficulty drops one boss coin, and each
|
||||
-- craft costs the target item level in that source boss coin.
|
||||
UPDATE crafting_recipes
|
||||
SET source_dungeon_id = 1,
|
||||
source_encounter_id = 3
|
||||
SET source_dungeon_id = CASE WHEN EXISTS (SELECT 1 FROM encounters WHERE id = 3) THEN 1 ELSE NULL END,
|
||||
source_encounter_id = (SELECT id FROM encounters WHERE id = 3)
|
||||
WHERE id BETWEEN 1001 AND 1409
|
||||
AND item_id IN (SELECT id FROM items WHERE slot IN ('helmet', 'chest', 'gloves'));
|
||||
|
||||
UPDATE crafting_recipes
|
||||
SET source_dungeon_id = 1,
|
||||
source_encounter_id = 12
|
||||
SET source_dungeon_id = CASE WHEN EXISTS (SELECT 1 FROM encounters WHERE id = 12) THEN 1 ELSE NULL END,
|
||||
source_encounter_id = (SELECT id FROM encounters WHERE id = 12)
|
||||
WHERE id BETWEEN 1001 AND 1409
|
||||
AND item_id IN (SELECT id FROM items WHERE slot IN ('boots', 'ring', 'trinket'));
|
||||
|
||||
UPDATE crafting_recipes
|
||||
SET source_dungeon_id = 1,
|
||||
source_encounter_id = 22
|
||||
SET source_dungeon_id = CASE WHEN EXISTS (SELECT 1 FROM encounters WHERE id = 22) THEN 1 ELSE NULL END,
|
||||
source_encounter_id = (SELECT id FROM encounters WHERE id = 22)
|
||||
WHERE id BETWEEN 1001 AND 1409
|
||||
AND item_id IN (SELECT id FROM items WHERE slot IN ('weapon', 'pants', 'necklace'));
|
||||
|
||||
@@ -487,11 +576,13 @@ SET rarity = CASE item_level
|
||||
WHERE id IN (SELECT item_id FROM crafting_recipes);
|
||||
|
||||
UPDATE items
|
||||
SET name = (
|
||||
SET name = COALESCE((
|
||||
SELECT
|
||||
CASE items.item_level
|
||||
WHEN 1 THEN 'Raw '
|
||||
WHEN 5 THEN 'Honed '
|
||||
WHEN 10 THEN 'Green '
|
||||
WHEN 15 THEN 'Blue '
|
||||
WHEN 20 THEN 'Purple '
|
||||
WHEN 25 THEN 'Orange '
|
||||
ELSE ''
|
||||
@@ -513,14 +604,14 @@ SET name = (
|
||||
JOIN encounters ON encounters.id = crafting_recipes.source_encounter_id
|
||||
WHERE crafting_recipes.item_id = items.id
|
||||
LIMIT 1
|
||||
),
|
||||
description = (
|
||||
), name),
|
||||
description = COALESCE((
|
||||
SELECT 'Crafted with ' || encounters.name || ' coins.'
|
||||
FROM crafting_recipes
|
||||
JOIN encounters ON encounters.id = crafting_recipes.source_encounter_id
|
||||
WHERE crafting_recipes.item_id = items.id
|
||||
LIMIT 1
|
||||
)
|
||||
), description)
|
||||
WHERE id IN (SELECT item_id FROM crafting_recipes);
|
||||
|
||||
CREATE TEMP TABLE IF NOT EXISTS coin_sources (
|
||||
@@ -539,11 +630,11 @@ DELETE FROM coin_sources;
|
||||
|
||||
INSERT INTO coin_sources
|
||||
SELECT
|
||||
280000 + encounters.id * 100 + difficulties.dropped_item_level,
|
||||
280000 + encounters.id * 1000 + difficulties.id,
|
||||
encounters.id,
|
||||
difficulties.id,
|
||||
difficulties.dropped_item_level,
|
||||
encounters.slug || '-coin-ilvl-' || difficulties.dropped_item_level,
|
||||
encounters.slug || '-coin-diff-' || difficulties.id || '-ilvl-' || difficulties.dropped_item_level,
|
||||
CASE difficulties.dropped_item_level
|
||||
WHEN 1 THEN 'Raw '
|
||||
WHEN 5 THEN 'Honed '
|
||||
@@ -575,6 +666,18 @@ INSERT OR IGNORE INTO items
|
||||
SELECT item_id, slug, name, 'component', rarity, item_level, 0, 0, glyph, description
|
||||
FROM coin_sources;
|
||||
|
||||
UPDATE coin_sources
|
||||
SET item_id = (
|
||||
SELECT items.id
|
||||
FROM items
|
||||
WHERE items.slug = coin_sources.slug
|
||||
)
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM items
|
||||
WHERE items.slug = coin_sources.slug
|
||||
);
|
||||
|
||||
UPDATE items
|
||||
SET slug = (SELECT slug FROM coin_sources WHERE coin_sources.item_id = items.id),
|
||||
name = (SELECT name FROM coin_sources WHERE coin_sources.item_id = items.id),
|
||||
@@ -606,18 +709,22 @@ JOIN coin_sources
|
||||
ON coin_sources.encounter_id = crafting_recipes.source_encounter_id
|
||||
AND coin_sources.difficulty_id = crafting_recipes.difficulty_id;
|
||||
|
||||
DELETE FROM character_talents
|
||||
WHERE talent_id IN (SELECT id FROM talents WHERE class_id = 1);
|
||||
|
||||
DELETE FROM talents WHERE class_id = 1;
|
||||
|
||||
INSERT OR IGNORE INTO talents
|
||||
(id, class_id, slug, name, max_rank, tier, branch, prerequisite_talent_id, prerequisite_rank, effect_type, effect_value_per_rank, glyph, description)
|
||||
VALUES
|
||||
(1, 1, 'bright-reserves', 'Bright Reserves', 5, 1, 1, NULL, 0, 'max_resource', 2, 'M', 'Increases maximum Mana by 2 per rank.'),
|
||||
(2, 1, 'gentle-dawn', 'Gentle Dawn', 5, 1, 2, NULL, 0, 'hot_power_percent', 2, '~', 'Increases healing-over-time power by 2% per rank.'),
|
||||
(10, 1, 'steady-hands', 'Steady Hands', 5, 1, 3, NULL, 0, 'direct_heal_percent', 2, '+', 'Increases direct healing by 2% per rank.'),
|
||||
(11, 1, 'overflowing-light', 'Overflowing Light', 5, 2, 1, 1, 3, 'resource_regen_percent', 2, 'O', 'Improves Mana regeneration by 2% per rank. Requires Bright Reserves rank 3.'),
|
||||
(12, 1, 'lingering-rays', 'Lingering Rays', 5, 2, 2, 2, 3, 'hot_duration_percent', 4, 'L', 'Extends healing-over-time duration by 4% per rank. Requires Gentle Dawn rank 3.'),
|
||||
(13, 1, 'radiant-precision', 'Radiant Precision', 5, 2, 3, 10, 3, 'critical_heal_percent', 1, '!', 'Adds 1% healing critical chance per rank. Requires Steady Hands rank 3.'),
|
||||
(14, 1, 'sunlit-aegis', 'Sunlit Aegis', 3, 3, 1, 11, 5, 'absorb_power_percent', 5, 'A', 'Strengthens absorb effects by 5% per rank. Requires Overflowing Light rank 5.'),
|
||||
(15, 1, 'shared-dawn', 'Shared Dawn', 3, 3, 2, 12, 5, 'party_heal_percent', 5, '*', 'Increases party-wide healing by 5% per rank. Requires Lingering Rays rank 5.'),
|
||||
(16, 1, 'miracle-worker', 'Miracle Worker', 1, 4, 2, 15, 3, 'cooldown_reduction_percent', 10, 'S', 'Reduces major healing cooldowns by 10%. Requires Shared Dawn rank 3.'),
|
||||
(1, 1, 'shield-applies-renew', 'Shield applies Renew', 1, 1, 1, NULL, 0, 'shield_applies_renew', 0, '~', 'Sun Ward also applies Renew to the target.'),
|
||||
(2, 1, 'mend-applies-renew', 'Mend applies Renew', 1, 1, 2, NULL, 0, 'mend_applies_renew', 0, '~', 'Mend also applies Renew to the target.'),
|
||||
(10, 1, 'mend-adds-shield', 'Mend adds Shield', 1, 1, 3, NULL, 0, 'mend_applies_shield', 0, 'O', 'Mend also applies a shield at 50% strength to the target.'),
|
||||
(11, 1, 'radiance-adds-shield', 'Radiance adds Shield', 1, 1, 4, NULL, 0, 'radiance_applies_shield', 0, 'O', 'Radiance applies a shield at 30% strength to affected party members.'),
|
||||
(12, 1, 'radiance-applies-renew', 'Radiance applies Renew', 1, 1, 5, NULL, 0, 'radiance_applies_renew', 0, '~', 'Radiance applies Renew at 50% duration to affected party members.'),
|
||||
(13, 1, 'shielded-damage-reduction', 'Shielded takes less', 1, 1, 6, NULL, 0, 'shielded_damage_reduction', 0, 'D', 'While shielded, the target receives 20% less damage.'),
|
||||
(14, 1, 'shielded-healing-bonus', 'Shielded healing boost', 1, 1, 7, NULL, 0, 'shielded_healing_bonus', 0, '+', 'While shielded, the target receives 20% more healing.'),
|
||||
(15, 1, 'mend-reduces-radiance', 'Mend lowers Radiance', 1, 1, 8, NULL, 0, 'mend_reduces_radiance_cooldown', 0, '*', 'Casting Mend reduces the cooldown of Radiance by 2 seconds.'),
|
||||
|
||||
(3, 2, 'deep-roots', 'Deep Roots', 5, 1, 1, NULL, 0, 'max_resource', 2, 'R', 'Increases maximum Bloom by 2 per rank.'),
|
||||
(20, 2, 'patient-growth', 'Patient Growth', 5, 1, 2, NULL, 0, 'hot_power_percent', 2, 's', 'Increases healing-over-time power by 2% per rank.'),
|
||||
@@ -724,6 +831,7 @@ INSERT INTO generated_loot_tiers
|
||||
(item_level, dungeon_id, raid_id, dungeon_difficulty_id, raid_difficulty_id, recipe_base, craft_quantity)
|
||||
VALUES
|
||||
(10, 3, 2, 2, 101, 1100, 2),
|
||||
(15, 4, 5, 3, 103, 1200, 3),
|
||||
(20, 6, 7, 4, 104, 1300, 4),
|
||||
(25, 8, 9, 5, 105, 1400, 5);
|
||||
|
||||
@@ -782,8 +890,8 @@ INSERT OR IGNORE INTO locations (id, slug, name, description) VALUES
|
||||
(3, 'monster-frontier', 'The Monster Frontier', 'Hunting grounds used for tiered monster-part progression.');
|
||||
|
||||
UPDATE dungeons
|
||||
SET slug = 'tigrex-raid',
|
||||
name = 'Tigrex Raid',
|
||||
SET slug = 'legacy-generated-raid-2',
|
||||
name = 'Legacy Generated Raid 2',
|
||||
location_id = 3,
|
||||
recommended_level = 5,
|
||||
content_type = 'raid',
|
||||
@@ -866,6 +974,68 @@ SELECT dungeon_id, dungeon_difficulty_id FROM generated_loot_tiers
|
||||
UNION ALL
|
||||
SELECT raid_id, raid_difficulty_id FROM generated_loot_tiers;
|
||||
|
||||
UPDATE crafting_recipes
|
||||
SET source_dungeon_id = NULL,
|
||||
source_encounter_id = NULL
|
||||
WHERE source_dungeon_id IN (
|
||||
SELECT dungeon_id FROM generated_loot_tiers
|
||||
UNION
|
||||
SELECT raid_id FROM generated_loot_tiers
|
||||
)
|
||||
OR source_encounter_id IN (
|
||||
SELECT id
|
||||
FROM encounters
|
||||
WHERE dungeon_id IN (
|
||||
SELECT dungeon_id FROM generated_loot_tiers
|
||||
UNION
|
||||
SELECT raid_id FROM generated_loot_tiers
|
||||
)
|
||||
);
|
||||
|
||||
DELETE FROM encounter_loot_roll_items
|
||||
WHERE roll_id IN (
|
||||
SELECT id
|
||||
FROM encounter_loot_rolls
|
||||
WHERE encounter_id IN (
|
||||
SELECT id
|
||||
FROM encounters
|
||||
WHERE dungeon_id IN (
|
||||
SELECT dungeon_id FROM generated_loot_tiers
|
||||
UNION
|
||||
SELECT raid_id FROM generated_loot_tiers
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
DELETE FROM encounter_loot_rolls
|
||||
WHERE encounter_id IN (
|
||||
SELECT id
|
||||
FROM encounters
|
||||
WHERE dungeon_id IN (
|
||||
SELECT dungeon_id FROM generated_loot_tiers
|
||||
UNION
|
||||
SELECT raid_id FROM generated_loot_tiers
|
||||
)
|
||||
);
|
||||
|
||||
DELETE FROM encounter_loot
|
||||
WHERE encounter_id IN (
|
||||
SELECT id
|
||||
FROM encounters
|
||||
WHERE dungeon_id IN (
|
||||
SELECT dungeon_id FROM generated_loot_tiers
|
||||
UNION
|
||||
SELECT raid_id FROM generated_loot_tiers
|
||||
)
|
||||
);
|
||||
|
||||
DELETE FROM encounters
|
||||
WHERE dungeon_id IN (
|
||||
SELECT dungeon_id FROM generated_loot_tiers
|
||||
UNION
|
||||
SELECT raid_id FROM generated_loot_tiers
|
||||
);
|
||||
|
||||
UPDATE encounters
|
||||
SET slug = CASE id
|
||||
WHEN 100 THEN 'tigrex-raid-approach'
|
||||
@@ -898,7 +1068,23 @@ SET slug = CASE id
|
||||
WHEN 108 THEN 'Gypceros drops boss coins for item level 10 crafting.'
|
||||
ELSE 'Hunters clear the raid path.'
|
||||
END
|
||||
WHERE id BETWEEN 100 AND 108;
|
||||
WHERE id BETWEEN 100 AND 108
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM encounters AS conflict
|
||||
WHERE conflict.id NOT BETWEEN 100 AND 108
|
||||
AND conflict.slug IN (
|
||||
'tigrex-raid-approach',
|
||||
'tigrex-raid-guardians',
|
||||
'tigrex-raid',
|
||||
'rathalos-raid-approach',
|
||||
'rathalos-raid-guardians',
|
||||
'rathalos-raid',
|
||||
'gypceros-raid-approach',
|
||||
'gypceros-raid-guardians',
|
||||
'gypceros-raid'
|
||||
)
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO encounters
|
||||
(id, dungeon_id, sequence, slug, name, encounter_type, max_health, base_damage, tank_damage, party_damage, description)
|
||||
@@ -986,7 +1172,12 @@ SELECT
|
||||
1.0
|
||||
FROM generated_loot_tiers
|
||||
JOIN generated_bosses ON generated_bosses.item_level = generated_loot_tiers.item_level
|
||||
JOIN generated_drop_patterns ON generated_drop_patterns.boss_index = generated_bosses.boss_index;
|
||||
JOIN generated_drop_patterns ON generated_drop_patterns.boss_index = generated_bosses.boss_index
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM encounters
|
||||
WHERE encounters.id = generated_loot_tiers.dungeon_id * 100 + generated_bosses.boss_index * 3 + 3
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance)
|
||||
SELECT
|
||||
@@ -1000,7 +1191,15 @@ SELECT
|
||||
1.0
|
||||
FROM generated_loot_tiers
|
||||
JOIN generated_bosses ON generated_bosses.item_level = generated_loot_tiers.item_level
|
||||
JOIN generated_drop_patterns ON generated_drop_patterns.boss_index = generated_bosses.boss_index;
|
||||
JOIN generated_drop_patterns ON generated_drop_patterns.boss_index = generated_bosses.boss_index
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM encounters
|
||||
WHERE encounters.id = CASE generated_loot_tiers.raid_id
|
||||
WHEN 2 THEN 102 + generated_bosses.boss_index * 3
|
||||
ELSE generated_loot_tiers.raid_id * 100 + generated_bosses.boss_index * 3 + 3
|
||||
END
|
||||
);
|
||||
|
||||
CREATE TEMP TABLE IF NOT EXISTS generated_recipe_offsets (
|
||||
recipe_offset INTEGER PRIMARY KEY,
|
||||
@@ -1028,12 +1227,8 @@ SET source_dungeon_id = (
|
||||
WHERE id BETWEEN 1101 AND 1409;
|
||||
|
||||
UPDATE crafting_recipes
|
||||
SET source_dungeon_id = 2,
|
||||
source_encounter_id = 102 + (
|
||||
SELECT generated_recipe_offsets.boss_index * 3
|
||||
FROM generated_recipe_offsets
|
||||
WHERE crafting_recipes.id = 2000 + generated_recipe_offsets.recipe_offset
|
||||
)
|
||||
SET source_dungeon_id = NULL,
|
||||
source_encounter_id = NULL
|
||||
WHERE id BETWEEN 2001 AND 2009;
|
||||
|
||||
DELETE FROM crafting_recipe_components
|
||||
@@ -1242,20 +1437,20 @@ INSERT OR IGNORE INTO crafting_recipe_components (recipe_id, item_id, quantity)
|
||||
|
||||
-- Final coin gearing override. Keep this after legacy loot edits.
|
||||
UPDATE crafting_recipes
|
||||
SET source_dungeon_id = 1,
|
||||
source_encounter_id = 3
|
||||
SET source_dungeon_id = CASE WHEN EXISTS (SELECT 1 FROM encounters WHERE id = 3) THEN 1 ELSE NULL END,
|
||||
source_encounter_id = (SELECT id FROM encounters WHERE id = 3)
|
||||
WHERE id BETWEEN 1001 AND 1409
|
||||
AND item_id IN (SELECT id FROM items WHERE slot IN ('helmet', 'chest', 'gloves'));
|
||||
|
||||
UPDATE crafting_recipes
|
||||
SET source_dungeon_id = 1,
|
||||
source_encounter_id = 12
|
||||
SET source_dungeon_id = CASE WHEN EXISTS (SELECT 1 FROM encounters WHERE id = 12) THEN 1 ELSE NULL END,
|
||||
source_encounter_id = (SELECT id FROM encounters WHERE id = 12)
|
||||
WHERE id BETWEEN 1001 AND 1409
|
||||
AND item_id IN (SELECT id FROM items WHERE slot IN ('boots', 'ring', 'trinket'));
|
||||
|
||||
UPDATE crafting_recipes
|
||||
SET source_dungeon_id = 1,
|
||||
source_encounter_id = 22
|
||||
SET source_dungeon_id = CASE WHEN EXISTS (SELECT 1 FROM encounters WHERE id = 22) THEN 1 ELSE NULL END,
|
||||
source_encounter_id = (SELECT id FROM encounters WHERE id = 22)
|
||||
WHERE id BETWEEN 1001 AND 1409
|
||||
AND item_id IN (SELECT id FROM items WHERE slot IN ('weapon', 'pants', 'necklace'));
|
||||
|
||||
@@ -1277,18 +1472,20 @@ WHERE recipe_id IN (
|
||||
SELECT crafting_recipes.id
|
||||
FROM crafting_recipes
|
||||
JOIN items ON items.id = crafting_recipes.item_id
|
||||
WHERE items.item_level NOT IN (1, 10, 20, 25)
|
||||
WHERE items.item_level NOT IN (1, 5, 10, 15, 20, 25)
|
||||
);
|
||||
|
||||
DELETE FROM crafting_recipes
|
||||
WHERE item_id IN (
|
||||
SELECT id FROM items WHERE item_level NOT IN (1, 10, 20, 25)
|
||||
SELECT id FROM items WHERE item_level NOT IN (1, 5, 10, 15, 20, 25)
|
||||
);
|
||||
|
||||
UPDATE items
|
||||
SET rarity = CASE item_level
|
||||
WHEN 1 THEN 'common'
|
||||
WHEN 5 THEN 'uncommon'
|
||||
WHEN 10 THEN 'uncommon'
|
||||
WHEN 15 THEN 'rare'
|
||||
WHEN 20 THEN 'epic'
|
||||
WHEN 25 THEN 'legendary'
|
||||
ELSE rarity
|
||||
@@ -1296,11 +1493,13 @@ SET rarity = CASE item_level
|
||||
WHERE id IN (SELECT item_id FROM crafting_recipes);
|
||||
|
||||
UPDATE items
|
||||
SET name = (
|
||||
SET name = COALESCE((
|
||||
SELECT
|
||||
CASE items.item_level
|
||||
WHEN 1 THEN 'Raw '
|
||||
WHEN 5 THEN 'Honed '
|
||||
WHEN 10 THEN 'Green '
|
||||
WHEN 15 THEN 'Blue '
|
||||
WHEN 20 THEN 'Purple '
|
||||
WHEN 25 THEN 'Orange '
|
||||
ELSE ''
|
||||
@@ -1322,25 +1521,318 @@ SET name = (
|
||||
JOIN encounters ON encounters.id = crafting_recipes.source_encounter_id
|
||||
WHERE crafting_recipes.item_id = items.id
|
||||
LIMIT 1
|
||||
),
|
||||
description = (
|
||||
), name),
|
||||
description = COALESCE((
|
||||
SELECT 'Crafted with ' || encounters.name || ' coins.'
|
||||
FROM crafting_recipes
|
||||
JOIN encounters ON encounters.id = crafting_recipes.source_encounter_id
|
||||
WHERE crafting_recipes.item_id = items.id
|
||||
LIMIT 1
|
||||
), description)
|
||||
WHERE id IN (SELECT item_id FROM crafting_recipes);
|
||||
|
||||
-- Single-boss hunting grounds. Keep this late so legacy three-boss hunt
|
||||
-- seed data and generated monster tiers are normalized into separate runs.
|
||||
CREATE TEMP TABLE IF NOT EXISTS single_boss_dungeons (
|
||||
dungeon_id INTEGER PRIMARY KEY,
|
||||
boss_name TEXT NOT NULL,
|
||||
boss_slug TEXT NOT NULL,
|
||||
item_level INTEGER NOT NULL,
|
||||
difficulty_floor INTEGER NOT NULL,
|
||||
location_id INTEGER NOT NULL,
|
||||
experience_reward INTEGER NOT NULL
|
||||
);
|
||||
|
||||
DELETE FROM single_boss_dungeons;
|
||||
INSERT INTO single_boss_dungeons
|
||||
(dungeon_id, boss_name, boss_slug, item_level, difficulty_floor, location_id, experience_reward)
|
||||
VALUES
|
||||
(1, 'Bulldrome', 'bulldrome', 1, 1, 1, 125),
|
||||
(2, 'Yian Kut-Ku', 'yian-kut-ku', 1, 1, 1, 125),
|
||||
(3, 'Rathian', 'rathian', 1, 1, 1, 125),
|
||||
(4, 'Tigrex', 'tigrex', 10, 2, 3, 205),
|
||||
(5, 'Rathalos', 'rathalos', 10, 2, 3, 205),
|
||||
(6, 'Gypceros', 'gypceros', 10, 2, 3, 205),
|
||||
(7, 'Nargacuga', 'nargacuga', 15, 3, 3, 245),
|
||||
(8, 'Azuros', 'azuros', 15, 3, 3, 245),
|
||||
(9, 'Diablos', 'diablos', 15, 3, 3, 245),
|
||||
(10, 'Barroth', 'barroth', 20, 4, 3, 285),
|
||||
(11, 'Tobi Kadachi', 'tobi-kadachi', 20, 4, 3, 285),
|
||||
(12, 'Monoblos', 'monoblos', 20, 4, 3, 285),
|
||||
(13, 'Anjanath', 'anjanath', 25, 5, 3, 325),
|
||||
(14, 'Bazelgeuse', 'bazelgeuse', 25, 5, 3, 325),
|
||||
(15, 'Odogaron', 'odogaron', 25, 5, 3, 325);
|
||||
|
||||
CREATE TEMP TABLE IF NOT EXISTS single_boss_raids (
|
||||
raid_id INTEGER PRIMARY KEY,
|
||||
dungeon_id INTEGER NOT NULL,
|
||||
difficulty_id INTEGER NOT NULL,
|
||||
experience_reward INTEGER NOT NULL
|
||||
);
|
||||
|
||||
DELETE FROM single_boss_raids;
|
||||
INSERT INTO single_boss_raids (raid_id, dungeon_id, difficulty_id, experience_reward)
|
||||
VALUES
|
||||
(20, 4, 101, 275),
|
||||
(21, 5, 101, 275),
|
||||
(22, 6, 101, 275),
|
||||
(23, 7, 103, 325),
|
||||
(24, 8, 103, 325),
|
||||
(25, 9, 103, 325),
|
||||
(26, 10, 104, 375),
|
||||
(27, 11, 104, 375),
|
||||
(28, 12, 104, 375),
|
||||
(29, 13, 105, 425),
|
||||
(30, 14, 105, 425),
|
||||
(31, 15, 105, 425);
|
||||
|
||||
UPDATE dungeons
|
||||
SET slug = 'retired-dungeon-' || id
|
||||
WHERE id BETWEEN 1 AND 31;
|
||||
|
||||
INSERT OR IGNORE INTO dungeons
|
||||
(id, location_id, slug, name, recommended_level, content_type, party_size, completion_item_level, experience_reward, description)
|
||||
SELECT
|
||||
dungeon_id,
|
||||
location_id,
|
||||
boss_slug || '-hunting-ground',
|
||||
boss_name || ' Hunting Ground',
|
||||
CASE difficulty_floor WHEN 1 THEN 1 ELSE item_level END,
|
||||
'dungeon',
|
||||
6,
|
||||
NULL,
|
||||
experience_reward,
|
||||
'A focused hunt through ' || boss_name || ' territory.'
|
||||
FROM single_boss_dungeons;
|
||||
|
||||
UPDATE dungeons
|
||||
SET location_id = (SELECT location_id FROM single_boss_dungeons WHERE dungeon_id = dungeons.id),
|
||||
slug = (SELECT boss_slug || '-hunting-ground' FROM single_boss_dungeons WHERE dungeon_id = dungeons.id),
|
||||
name = (SELECT boss_name || ' Hunting Ground' FROM single_boss_dungeons WHERE dungeon_id = dungeons.id),
|
||||
recommended_level = (SELECT CASE difficulty_floor WHEN 1 THEN 1 ELSE item_level END FROM single_boss_dungeons WHERE dungeon_id = dungeons.id),
|
||||
content_type = 'dungeon',
|
||||
party_size = 6,
|
||||
completion_item_level = NULL,
|
||||
experience_reward = (SELECT experience_reward FROM single_boss_dungeons WHERE dungeon_id = dungeons.id),
|
||||
description = (SELECT 'A focused hunt through ' || boss_name || ' territory.' FROM single_boss_dungeons WHERE dungeon_id = dungeons.id)
|
||||
WHERE id IN (SELECT dungeon_id FROM single_boss_dungeons);
|
||||
|
||||
INSERT OR IGNORE INTO dungeons
|
||||
(id, location_id, slug, name, recommended_level, content_type, party_size, completion_item_level, experience_reward, description)
|
||||
SELECT
|
||||
single_boss_raids.raid_id,
|
||||
single_boss_dungeons.location_id,
|
||||
'apex-' || single_boss_dungeons.boss_slug || '-raid',
|
||||
'Apex ' || single_boss_dungeons.boss_name || ' Raid',
|
||||
single_boss_dungeons.item_level,
|
||||
'raid',
|
||||
18,
|
||||
NULL,
|
||||
single_boss_raids.experience_reward,
|
||||
'A raid-scale hunt against Apex ' || single_boss_dungeons.boss_name || '.'
|
||||
FROM single_boss_raids
|
||||
JOIN single_boss_dungeons ON single_boss_dungeons.dungeon_id = single_boss_raids.dungeon_id;
|
||||
|
||||
UPDATE dungeons
|
||||
SET slug = (SELECT 'apex-' || single_boss_dungeons.boss_slug || '-raid' FROM single_boss_raids JOIN single_boss_dungeons ON single_boss_dungeons.dungeon_id = single_boss_raids.dungeon_id WHERE single_boss_raids.raid_id = dungeons.id),
|
||||
name = (SELECT 'Apex ' || single_boss_dungeons.boss_name || ' Raid' FROM single_boss_raids JOIN single_boss_dungeons ON single_boss_dungeons.dungeon_id = single_boss_raids.dungeon_id WHERE single_boss_raids.raid_id = dungeons.id),
|
||||
location_id = (SELECT single_boss_dungeons.location_id FROM single_boss_raids JOIN single_boss_dungeons ON single_boss_dungeons.dungeon_id = single_boss_raids.dungeon_id WHERE single_boss_raids.raid_id = dungeons.id),
|
||||
recommended_level = (SELECT single_boss_dungeons.item_level FROM single_boss_raids JOIN single_boss_dungeons ON single_boss_dungeons.dungeon_id = single_boss_raids.dungeon_id WHERE single_boss_raids.raid_id = dungeons.id),
|
||||
content_type = 'raid',
|
||||
party_size = 18,
|
||||
completion_item_level = NULL,
|
||||
experience_reward = (SELECT experience_reward FROM single_boss_raids WHERE single_boss_raids.raid_id = dungeons.id),
|
||||
description = (SELECT 'A raid-scale hunt against Apex ' || single_boss_dungeons.boss_name || '.' FROM single_boss_raids JOIN single_boss_dungeons ON single_boss_dungeons.dungeon_id = single_boss_raids.dungeon_id WHERE single_boss_raids.raid_id = dungeons.id)
|
||||
WHERE id IN (SELECT raid_id FROM single_boss_raids);
|
||||
|
||||
UPDATE crafting_recipes
|
||||
SET source_dungeon_id = NULL,
|
||||
source_encounter_id = NULL
|
||||
WHERE id BETWEEN 1001 AND 1409;
|
||||
|
||||
DELETE FROM encounter_loot_roll_items
|
||||
WHERE roll_id IN (
|
||||
SELECT id
|
||||
FROM encounter_loot_rolls
|
||||
WHERE encounter_id IN (
|
||||
SELECT id FROM encounters
|
||||
WHERE dungeon_id IN (SELECT dungeon_id FROM single_boss_dungeons)
|
||||
OR dungeon_id IN (SELECT raid_id FROM single_boss_raids)
|
||||
)
|
||||
);
|
||||
|
||||
DELETE FROM encounter_loot_rolls
|
||||
WHERE encounter_id IN (
|
||||
SELECT id FROM encounters
|
||||
WHERE dungeon_id IN (SELECT dungeon_id FROM single_boss_dungeons)
|
||||
OR dungeon_id IN (SELECT raid_id FROM single_boss_raids)
|
||||
);
|
||||
|
||||
DELETE FROM encounters WHERE dungeon_id IN (SELECT dungeon_id FROM single_boss_dungeons);
|
||||
DELETE FROM encounters WHERE dungeon_id IN (SELECT raid_id FROM single_boss_raids);
|
||||
|
||||
INSERT INTO encounters
|
||||
(id, dungeon_id, sequence, slug, name, encounter_type, max_health, base_damage, tank_damage, party_damage, description)
|
||||
SELECT
|
||||
single_boss_dungeons.dungeon_id * 100 + offset.value,
|
||||
single_boss_dungeons.dungeon_id,
|
||||
offset.value,
|
||||
single_boss_dungeons.boss_slug || '-' || offset.slug,
|
||||
CASE offset.encounter_type
|
||||
WHEN 'boss' THEN single_boss_dungeons.boss_name
|
||||
ELSE single_boss_dungeons.boss_name || ' ' || offset.name
|
||||
END,
|
||||
offset.encounter_type,
|
||||
offset.health + single_boss_dungeons.item_level * 35,
|
||||
offset.damage + single_boss_dungeons.difficulty_floor,
|
||||
offset.tank_damage + single_boss_dungeons.difficulty_floor,
|
||||
offset.party_damage + single_boss_dungeons.difficulty_floor * 2,
|
||||
CASE offset.encounter_type
|
||||
WHEN 'boss' THEN single_boss_dungeons.boss_name || ' drops boss coins for crafting.'
|
||||
ELSE 'Hunters clear the path before ' || single_boss_dungeons.boss_name || '.'
|
||||
END
|
||||
FROM single_boss_dungeons
|
||||
JOIN (
|
||||
SELECT 1 AS value, 'approach' AS slug, 'Approach' AS name, 'trash' AS encounter_type, 430 AS health, 13 AS damage, 8 AS tank_damage, 24 AS party_damage
|
||||
UNION ALL SELECT 2, 'guardians', 'Guardians', 'trash', 520, 15, 9, 26
|
||||
UNION ALL SELECT 3, 'boss', '', 'boss', 860, 19, 14, 29
|
||||
) AS offset;
|
||||
|
||||
INSERT INTO encounters
|
||||
(id, dungeon_id, sequence, slug, name, encounter_type, max_health, base_damage, tank_damage, party_damage, description)
|
||||
SELECT
|
||||
single_boss_raids.raid_id * 100 + offset.value,
|
||||
single_boss_raids.raid_id,
|
||||
offset.value,
|
||||
single_boss_dungeons.boss_slug || '-raid-' || offset.slug,
|
||||
CASE offset.encounter_type
|
||||
WHEN 'boss' THEN 'Apex ' || single_boss_dungeons.boss_name
|
||||
ELSE 'Apex ' || single_boss_dungeons.boss_name || ' ' || offset.name
|
||||
END,
|
||||
offset.encounter_type,
|
||||
offset.health + single_boss_dungeons.item_level * 35 + 900,
|
||||
offset.damage + single_boss_dungeons.difficulty_floor,
|
||||
offset.tank_damage + single_boss_dungeons.difficulty_floor,
|
||||
offset.party_damage + single_boss_dungeons.difficulty_floor * 2 + 22,
|
||||
CASE offset.encounter_type
|
||||
WHEN 'boss' THEN 'Apex ' || single_boss_dungeons.boss_name || ' drops raid coins for crafting.'
|
||||
ELSE 'Hunters clear the raid path before Apex ' || single_boss_dungeons.boss_name || '.'
|
||||
END
|
||||
FROM single_boss_raids
|
||||
JOIN single_boss_dungeons
|
||||
ON single_boss_dungeons.dungeon_id = single_boss_raids.dungeon_id
|
||||
JOIN (
|
||||
SELECT 1 AS value, 'approach' AS slug, 'Approach' AS name, 'trash' AS encounter_type, 650 AS health, 15 AS damage, 9 AS tank_damage, 28 AS party_damage
|
||||
UNION ALL SELECT 2, 'guardians', 'Guardians', 'trash', 720, 16, 10, 30
|
||||
UNION ALL SELECT 3, 'boss', '', 'boss', 980, 20, 14, 34
|
||||
) AS offset;
|
||||
|
||||
DELETE FROM dungeon_difficulties;
|
||||
INSERT OR IGNORE INTO dungeon_difficulties (dungeon_id, difficulty_id)
|
||||
SELECT dungeon_id, difficulties.id
|
||||
FROM single_boss_dungeons
|
||||
JOIN difficulties ON difficulties.id BETWEEN single_boss_dungeons.difficulty_floor AND 5
|
||||
WHERE difficulties.id BETWEEN 1 AND 5;
|
||||
|
||||
INSERT OR IGNORE INTO dungeon_difficulties (dungeon_id, difficulty_id)
|
||||
SELECT raid_id, difficulty_id
|
||||
FROM single_boss_raids;
|
||||
|
||||
CREATE TEMP TABLE IF NOT EXISTS recipe_source_override (
|
||||
recipe_id INTEGER PRIMARY KEY,
|
||||
dungeon_id INTEGER NOT NULL,
|
||||
encounter_id INTEGER NOT NULL
|
||||
);
|
||||
|
||||
DELETE FROM recipe_source_override;
|
||||
INSERT INTO recipe_source_override (recipe_id, dungeon_id, encounter_id) VALUES
|
||||
(1001, 1, 103), (1002, 1, 103), (1003, 1, 103),
|
||||
(1004, 2, 203), (1005, 2, 203), (1006, 2, 203),
|
||||
(1007, 3, 303), (1008, 3, 303), (1009, 3, 303),
|
||||
(1101, 4, 403), (1102, 4, 403), (1103, 4, 403),
|
||||
(1104, 5, 503), (1105, 5, 503), (1106, 5, 503),
|
||||
(1107, 6, 603), (1108, 6, 603), (1109, 6, 603),
|
||||
(1201, 7, 703), (1202, 7, 703), (1203, 7, 703),
|
||||
(1204, 8, 803), (1205, 8, 803), (1206, 8, 803),
|
||||
(1207, 9, 903), (1208, 9, 903), (1209, 9, 903),
|
||||
(1301, 10, 1003), (1302, 10, 1003), (1303, 10, 1003),
|
||||
(1304, 11, 1103), (1305, 11, 1103), (1306, 11, 1103),
|
||||
(1307, 12, 1203), (1308, 12, 1203), (1309, 12, 1203),
|
||||
(1401, 13, 1303), (1402, 13, 1303), (1403, 13, 1303),
|
||||
(1404, 14, 1403), (1405, 14, 1403), (1406, 14, 1403),
|
||||
(1407, 15, 1503), (1408, 15, 1503), (1409, 15, 1503);
|
||||
|
||||
UPDATE crafting_recipes
|
||||
SET source_dungeon_id = (SELECT dungeon_id FROM recipe_source_override WHERE recipe_id = crafting_recipes.id),
|
||||
source_encounter_id = (SELECT encounter_id FROM recipe_source_override WHERE recipe_id = crafting_recipes.id)
|
||||
WHERE id IN (SELECT recipe_id FROM recipe_source_override);
|
||||
|
||||
UPDATE crafting_recipes
|
||||
SET source_dungeon_id = (
|
||||
SELECT single_boss_raids.raid_id
|
||||
FROM generated_recipe_offsets
|
||||
JOIN single_boss_raids
|
||||
ON single_boss_raids.dungeon_id = 4 + generated_recipe_offsets.boss_index
|
||||
WHERE crafting_recipes.id = 2000 + generated_recipe_offsets.recipe_offset
|
||||
),
|
||||
source_encounter_id = (
|
||||
SELECT single_boss_raids.raid_id * 100 + 3
|
||||
FROM generated_recipe_offsets
|
||||
JOIN single_boss_raids
|
||||
ON single_boss_raids.dungeon_id = 4 + generated_recipe_offsets.boss_index
|
||||
WHERE crafting_recipes.id = 2000 + generated_recipe_offsets.recipe_offset
|
||||
),
|
||||
difficulty_id = 101
|
||||
WHERE id BETWEEN 2001 AND 2009;
|
||||
|
||||
UPDATE items
|
||||
SET name = COALESCE((
|
||||
SELECT
|
||||
CASE items.item_level
|
||||
WHEN 1 THEN 'Raw '
|
||||
WHEN 5 THEN 'Honed '
|
||||
WHEN 10 THEN 'Green '
|
||||
WHEN 15 THEN 'Blue '
|
||||
WHEN 20 THEN 'Purple '
|
||||
WHEN 25 THEN 'Orange '
|
||||
ELSE ''
|
||||
END
|
||||
|| encounters.name || ' '
|
||||
|| CASE items.slot
|
||||
WHEN 'weapon' THEN 'Weapon'
|
||||
WHEN 'helmet' THEN 'Helmet'
|
||||
WHEN 'chest' THEN 'Chest'
|
||||
WHEN 'gloves' THEN 'Gloves'
|
||||
WHEN 'boots' THEN 'Boots'
|
||||
WHEN 'pants' THEN 'Pants'
|
||||
WHEN 'ring' THEN 'Ring'
|
||||
WHEN 'necklace' THEN 'Necklace'
|
||||
WHEN 'trinket' THEN 'Trinket'
|
||||
ELSE items.name
|
||||
END
|
||||
FROM crafting_recipes
|
||||
JOIN encounters ON encounters.id = crafting_recipes.source_encounter_id
|
||||
WHERE crafting_recipes.item_id = items.id
|
||||
LIMIT 1
|
||||
), name),
|
||||
description = COALESCE((
|
||||
SELECT 'Crafted with ' || encounters.name || ' coins.'
|
||||
FROM crafting_recipes
|
||||
JOIN encounters ON encounters.id = crafting_recipes.source_encounter_id
|
||||
WHERE crafting_recipes.item_id = items.id
|
||||
LIMIT 1
|
||||
), description)
|
||||
WHERE id IN (SELECT item_id FROM crafting_recipes);
|
||||
|
||||
DELETE FROM coin_sources;
|
||||
|
||||
INSERT INTO coin_sources
|
||||
SELECT
|
||||
280000 + encounters.id * 100 + difficulties.dropped_item_level,
|
||||
280000 + encounters.id * 1000 + difficulties.id,
|
||||
encounters.id,
|
||||
difficulties.id,
|
||||
difficulties.dropped_item_level,
|
||||
encounters.slug || '-coin-ilvl-' || difficulties.dropped_item_level,
|
||||
encounters.slug || '-coin-diff-' || difficulties.id || '-ilvl-' || difficulties.dropped_item_level,
|
||||
CASE difficulties.dropped_item_level
|
||||
WHEN 1 THEN 'Raw '
|
||||
WHEN 5 THEN 'Honed '
|
||||
@@ -1372,6 +1864,18 @@ INSERT OR IGNORE INTO items
|
||||
SELECT item_id, slug, name, 'component', rarity, item_level, 0, 0, glyph, description
|
||||
FROM coin_sources;
|
||||
|
||||
UPDATE coin_sources
|
||||
SET item_id = (
|
||||
SELECT items.id
|
||||
FROM items
|
||||
WHERE items.slug = coin_sources.slug
|
||||
)
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM items
|
||||
WHERE items.slug = coin_sources.slug
|
||||
);
|
||||
|
||||
UPDATE items
|
||||
SET slug = (SELECT slug FROM coin_sources WHERE coin_sources.item_id = items.id),
|
||||
name = (SELECT name FROM coin_sources WHERE coin_sources.item_id = items.id),
|
||||
|
||||
@@ -79,9 +79,9 @@ 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"
|
||||
export GITEA_TOKEN="ed2db3fd54546e9658377d0551b3fc3961583f1d"
|
||||
|
||||
VERSION="1.0.26"
|
||||
VERSION="1.0.27"
|
||||
APK="IWantToHeal-Thor-v$VERSION.apk"
|
||||
|
||||
RELEASE_JSON=$(curl -sS -X POST "$GITEA_URL/api/v1/repos/$GITEA_OWNER/$GITEA_REPO/releases" \
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="620" height="540" viewBox="0 0 620 540" role="img" aria-label="Ayn Thor secondary display spell effect quick swap mockup">
|
||||
<rect width="620" height="540" fill="#111219"/>
|
||||
<rect x="16" y="16" width="588" height="48" fill="#20222d" stroke="#0a0b0e" stroke-width="3"/>
|
||||
<text x="32" y="36" fill="#8aa0b7" font-family="monospace" font-size="11" font-weight="700">SPELL EFFECTS</text>
|
||||
<text x="32" y="56" fill="#f5e6b2" font-family="monospace" font-size="18" font-weight="900">Quick Swap</text>
|
||||
<text x="460" y="47" fill="#83d99b" font-family="monospace" font-size="13" font-weight="900">4/4 ACTIVE</text>
|
||||
|
||||
<g transform="translate(16 82)">
|
||||
<rect width="588" height="118" fill="#191b25" stroke="#3a3944" stroke-width="2"/>
|
||||
<text x="16" y="28" fill="#8aa0b7" font-family="monospace" font-size="10" font-weight="700">ACTIVE SLOTS</text>
|
||||
<g transform="translate(14 46)">
|
||||
<rect width="130" height="54" fill="#29291f" stroke="#e5b95f" stroke-width="3"/>
|
||||
<text x="10" y="20" fill="#e5b95f" font-family="monospace" font-size="11" font-weight="900">LV 5</text>
|
||||
<text x="10" y="39" fill="#f7f2d7" font-family="monospace" font-size="12" font-weight="900">Mend Renew</text>
|
||||
</g>
|
||||
<g transform="translate(154 46)">
|
||||
<rect width="130" height="54" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
|
||||
<text x="10" y="20" fill="#6da7df" font-family="monospace" font-size="11" font-weight="900">LV 10</text>
|
||||
<text x="10" y="39" fill="#f7f2d7" font-family="monospace" font-size="12" font-weight="900">Rad Shield</text>
|
||||
</g>
|
||||
<g transform="translate(294 46)">
|
||||
<rect width="130" height="54" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
|
||||
<text x="10" y="20" fill="#4fb978" font-family="monospace" font-size="11" font-weight="900">LV 15</text>
|
||||
<text x="10" y="39" fill="#f7f2d7" font-family="monospace" font-size="12" font-weight="900">Shield DR</text>
|
||||
</g>
|
||||
<g transform="translate(434 46)">
|
||||
<rect width="130" height="54" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
|
||||
<text x="10" y="20" fill="#b16dde" font-family="monospace" font-size="11" font-weight="900">LV 20</text>
|
||||
<text x="10" y="39" fill="#f7f2d7" font-family="monospace" font-size="12" font-weight="900">Mend CD</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<g transform="translate(16 218)">
|
||||
<rect width="278" height="286" fill="#191b25" stroke="#3a3944" stroke-width="2"/>
|
||||
<text x="16" y="28" fill="#8aa0b7" font-family="monospace" font-size="10" font-weight="700">POOL</text>
|
||||
<g transform="translate(14 46)">
|
||||
<rect width="250" height="42" fill="#29291f" stroke="#e5b95f" stroke-width="3"/>
|
||||
<text x="12" y="26" fill="#f5e6b2" font-family="monospace" font-size="13" font-weight="900">Mend applies Renew</text>
|
||||
</g>
|
||||
<g transform="translate(14 98)">
|
||||
<rect width="250" height="42" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
|
||||
<text x="12" y="26" fill="#f7f2d7" font-family="monospace" font-size="13" font-weight="900">Shield applies Renew</text>
|
||||
</g>
|
||||
<g transform="translate(14 150)">
|
||||
<rect width="250" height="42" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
|
||||
<text x="12" y="26" fill="#f7f2d7" font-family="monospace" font-size="13" font-weight="900">Mend adds Shield</text>
|
||||
</g>
|
||||
<g transform="translate(14 202)">
|
||||
<rect width="250" height="42" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
|
||||
<text x="12" y="26" fill="#f7f2d7" font-family="monospace" font-size="13" font-weight="900">Radiance Renew</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<g transform="translate(310 218)">
|
||||
<rect width="294" height="286" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
|
||||
<text x="16" y="28" fill="#8aa0b7" font-family="monospace" font-size="10" font-weight="700">DETAIL</text>
|
||||
<text x="16" y="58" fill="#f5e6b2" font-family="monospace" font-size="18" font-weight="900">Mend applies Renew</text>
|
||||
<text x="16" y="92" fill="#d7dbe0" font-family="monospace" font-size="13">Mend also applies Renew to</text>
|
||||
<text x="16" y="112" fill="#d7dbe0" font-family="monospace" font-size="13">the target.</text>
|
||||
<text x="16" y="150" fill="#83d99b" font-family="monospace" font-size="12" font-weight="900">Rule: same HoT refreshes.</text>
|
||||
<text x="16" y="172" fill="#83d99b" font-family="monospace" font-size="12" font-weight="900">Different HoTs coexist.</text>
|
||||
<rect x="16" y="216" width="120" height="44" rx="3" fill="#e5b95f" stroke="#0a0b0e" stroke-width="3"/>
|
||||
<text x="50" y="244" fill="#21180a" font-family="monospace" font-size="14" font-weight="900">Equip</text>
|
||||
<rect x="154" y="216" width="120" height="44" rx="3" fill="#191b25" stroke="#3a3944" stroke-width="2"/>
|
||||
<text x="184" y="244" fill="#f7f2d7" font-family="monospace" font-size="14" font-weight="900">Clear</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.8 KiB |
@@ -0,0 +1,90 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540" role="img" aria-label="Ayn Thor main display talent effect planner mockup">
|
||||
<rect width="960" height="540" fill="#111219"/>
|
||||
<rect x="20" y="18" width="920" height="50" fill="#20222d" stroke="#0a0b0e" stroke-width="3"/>
|
||||
<text x="38" y="39" fill="#8aa0b7" font-family="monospace" font-size="12" font-weight="700">CHARACTER WORKSHOP</text>
|
||||
<text x="38" y="59" fill="#f5e6b2" font-family="monospace" font-size="20" font-weight="900">Spell Effects</text>
|
||||
<rect x="764" y="28" width="154" height="30" rx="3" fill="#e5b95f" stroke="#0a0b0e" stroke-width="3"/>
|
||||
<text x="786" y="49" fill="#21180a" font-family="monospace" font-size="13" font-weight="900">Save Loadout</text>
|
||||
|
||||
<g transform="translate(20 88)">
|
||||
<rect width="294" height="420" fill="#191b25" stroke="#3a3944" stroke-width="2"/>
|
||||
<text x="18" y="28" fill="#8aa0b7" font-family="monospace" font-size="11" font-weight="700">UNLOCKED SLOTS</text>
|
||||
<text x="18" y="53" fill="#f7f2d7" font-family="monospace" font-size="19" font-weight="900">4 active effects</text>
|
||||
|
||||
<g transform="translate(16 76)">
|
||||
<rect width="262" height="58" fill="#29291f" stroke="#e5b95f" stroke-width="3"/>
|
||||
<circle cx="29" cy="29" r="15" fill="#e5b95f"/>
|
||||
<text x="22" y="34" fill="#21180a" font-family="monospace" font-size="13" font-weight="900">5</text>
|
||||
<text x="56" y="25" fill="#f5e6b2" font-family="monospace" font-size="15" font-weight="900">Mend applies Renew</text>
|
||||
<text x="56" y="45" fill="#83d99b" font-family="monospace" font-size="11">Selected</text>
|
||||
</g>
|
||||
<g transform="translate(16 148)">
|
||||
<rect width="262" height="58" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
|
||||
<circle cx="29" cy="29" r="15" fill="#6da7df"/>
|
||||
<text x="18" y="34" fill="#08111c" font-family="monospace" font-size="12" font-weight="900">10</text>
|
||||
<text x="56" y="25" fill="#f7f2d7" font-family="monospace" font-size="15" font-weight="900">Radiance adds shield</text>
|
||||
<text x="56" y="45" fill="#8aa0b7" font-family="monospace" font-size="11">30 percent strength</text>
|
||||
</g>
|
||||
<g transform="translate(16 220)">
|
||||
<rect width="262" height="58" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
|
||||
<circle cx="29" cy="29" r="15" fill="#4fb978"/>
|
||||
<text x="18" y="34" fill="#071408" font-family="monospace" font-size="12" font-weight="900">15</text>
|
||||
<text x="56" y="25" fill="#f7f2d7" font-family="monospace" font-size="15" font-weight="900">Shielded takes less</text>
|
||||
<text x="56" y="45" fill="#8aa0b7" font-family="monospace" font-size="11">20 percent damage cut</text>
|
||||
</g>
|
||||
<g transform="translate(16 292)">
|
||||
<rect width="262" height="58" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
|
||||
<circle cx="29" cy="29" r="15" fill="#b16dde"/>
|
||||
<text x="18" y="34" fill="#15071c" font-family="monospace" font-size="12" font-weight="900">20</text>
|
||||
<text x="56" y="25" fill="#f7f2d7" font-family="monospace" font-size="15" font-weight="900">Mend lowers Radiance</text>
|
||||
<text x="56" y="45" fill="#8aa0b7" font-family="monospace" font-size="11">-2 sec cooldown</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<g transform="translate(334 88)">
|
||||
<rect width="606" height="286" fill="#191b25" stroke="#3a3944" stroke-width="2"/>
|
||||
<text x="18" y="28" fill="#8aa0b7" font-family="monospace" font-size="11" font-weight="700">EFFECT POOL</text>
|
||||
<text x="18" y="53" fill="#f7f2d7" font-family="monospace" font-size="19" font-weight="900">Pick effects, swap anytime</text>
|
||||
|
||||
<g transform="translate(18 76)">
|
||||
<rect width="276" height="58" fill="#29291f" stroke="#e5b95f" stroke-width="3"/>
|
||||
<text x="14" y="24" fill="#f5e6b2" font-family="monospace" font-size="14" font-weight="900">Mend applies Renew</text>
|
||||
<text x="14" y="45" fill="#b7c3d0" font-family="monospace" font-size="11">Direct heal also applies Renew.</text>
|
||||
<text x="226" y="24" fill="#83d99b" font-family="monospace" font-size="10" font-weight="900">ON</text>
|
||||
</g>
|
||||
<g transform="translate(312 76)">
|
||||
<rect width="276" height="58" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
|
||||
<text x="14" y="24" fill="#f7f2d7" font-family="monospace" font-size="14" font-weight="900">Shield applies Renew</text>
|
||||
<text x="14" y="45" fill="#b7c3d0" font-family="monospace" font-size="11">Sun Ward adds a Renew effect.</text>
|
||||
</g>
|
||||
<g transform="translate(18 148)">
|
||||
<rect width="276" height="58" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
|
||||
<text x="14" y="24" fill="#f7f2d7" font-family="monospace" font-size="14" font-weight="900">Mend adds Shield</text>
|
||||
<text x="14" y="45" fill="#b7c3d0" font-family="monospace" font-size="11">50 percent shield strength.</text>
|
||||
</g>
|
||||
<g transform="translate(312 148)">
|
||||
<rect width="276" height="58" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
|
||||
<text x="14" y="24" fill="#f7f2d7" font-family="monospace" font-size="14" font-weight="900">Radiance adds Shield</text>
|
||||
<text x="14" y="45" fill="#b7c3d0" font-family="monospace" font-size="11">30 percent to affected allies.</text>
|
||||
</g>
|
||||
<g transform="translate(18 220)">
|
||||
<rect width="276" height="46" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
|
||||
<text x="14" y="20" fill="#f7f2d7" font-family="monospace" font-size="14" font-weight="900">Radiance applies Renew</text>
|
||||
<text x="14" y="38" fill="#b7c3d0" font-family="monospace" font-size="11">50 percent duration.</text>
|
||||
</g>
|
||||
<g transform="translate(312 220)">
|
||||
<rect width="276" height="46" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
|
||||
<text x="14" y="20" fill="#f7f2d7" font-family="monospace" font-size="14" font-weight="900">Shielded gets +healing</text>
|
||||
<text x="14" y="38" fill="#b7c3d0" font-family="monospace" font-size="11">20 percent more healing.</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<g transform="translate(334 392)">
|
||||
<rect width="606" height="116" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
|
||||
<text x="18" y="26" fill="#8aa0b7" font-family="monospace" font-size="11" font-weight="700">SELECTED EFFECT</text>
|
||||
<text x="18" y="52" fill="#f5e6b2" font-family="monospace" font-size="20" font-weight="900">Mend applies Renew</text>
|
||||
<text x="18" y="78" fill="#d7dbe0" font-family="monospace" font-size="13">Casting Mend also applies Renew to the same target. Renew refreshes itself; different HoTs coexist.</text>
|
||||
<rect x="470" y="32" width="112" height="42" rx="3" fill="#e5b95f" stroke="#0a0b0e" stroke-width="3"/>
|
||||
<text x="497" y="58" fill="#21180a" font-family="monospace" font-size="14" font-weight="900">Equip</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.6 KiB |
+136
-20
@@ -22,6 +22,7 @@ const bossImageContentTypes = {
|
||||
}
|
||||
const equipmentSlots = ['weapon', 'helmet', 'chest', 'gloves', 'boots', 'pants', 'ring', 'necklace', 'trinket']
|
||||
const componentSlot = 'component'
|
||||
const directCraftItemLevels = new Set([1, 10, 20, 25])
|
||||
const sessionCookieName = 'chronicle_session'
|
||||
const sessionLifetimeSeconds = 60 * 60 * 24 * 30
|
||||
const rateLimitBuckets = new Map()
|
||||
@@ -232,6 +233,25 @@ function consumeRateLimit(key, limit, windowMs) {
|
||||
}
|
||||
}
|
||||
|
||||
function catchUpExperienceReward(database, accountId, characterId, baseReward, currentExperience, currentLevel) {
|
||||
const targetLevel = database.prepare(`
|
||||
SELECT COALESCE(MAX(level), 0) AS level
|
||||
FROM characters
|
||||
WHERE account_id = ?
|
||||
AND id != ?
|
||||
`).get(accountId, characterId).level
|
||||
if (targetLevel <= currentLevel) return baseReward
|
||||
const targetExperience = database.prepare(`
|
||||
SELECT experience_required AS experienceRequired
|
||||
FROM level_progression
|
||||
WHERE level = ?
|
||||
`).get(targetLevel)?.experienceRequired ?? currentExperience
|
||||
const gap = Math.max(0, targetExperience - currentExperience)
|
||||
if (gap <= 0) return baseReward
|
||||
const doubledBase = Math.min(baseReward, Math.ceil(gap / 2))
|
||||
return doubledBase * 2 + (baseReward - doubledBase)
|
||||
}
|
||||
|
||||
function normalizeUsername(value) {
|
||||
const username = String(value ?? '').trim()
|
||||
if (!/^[A-Za-z0-9_]{3,20}$/.test(username)) {
|
||||
@@ -770,6 +790,15 @@ export function getProfile(database, characterId, accountId) {
|
||||
WHERE rank <= 10
|
||||
ORDER BY dungeonId, difficultyId, startPart, completedParts, rank
|
||||
`).all()
|
||||
const dungeonCompletionCounts = new Map(database.prepare(`
|
||||
SELECT dungeon_id AS dungeonId, COUNT(*) AS count
|
||||
FROM dungeon_runs
|
||||
WHERE character_id = ?
|
||||
AND result = 'victory'
|
||||
AND start_part = 1
|
||||
AND completed_parts >= 1
|
||||
GROUP BY dungeon_id
|
||||
`).all(characterId).map((row) => [row.dungeonId, row.count]))
|
||||
|
||||
const settings = Object.fromEntries(
|
||||
database.prepare('SELECT key, value FROM game_settings').all()
|
||||
@@ -849,6 +878,7 @@ export function getProfile(database, characterId, accountId) {
|
||||
}),
|
||||
dungeons: dungeons.map((dungeon) => ({
|
||||
...dungeon,
|
||||
completionCount: dungeonCompletionCounts.get(dungeon.id) ?? 0,
|
||||
difficulties: dungeonDifficulties.filter(
|
||||
(difficulty) => difficulty.dungeonId === dungeon.id,
|
||||
),
|
||||
@@ -1693,16 +1723,9 @@ function craftItem(database, characterId, recipeId) {
|
||||
WHERE crafting_recipes.id = ?
|
||||
`).get(recipeId)
|
||||
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.')
|
||||
if (!directCraftItemLevels.has(recipe.itemLevel)) {
|
||||
throw new Error('Upgrade the previous item tier instead.')
|
||||
}
|
||||
|
||||
const components = database.prepare(`
|
||||
SELECT
|
||||
@@ -1849,9 +1872,20 @@ function upgradeItem(database, characterId, itemId) {
|
||||
return getProfile(database, characterId)
|
||||
}
|
||||
|
||||
function talentEffectCapacity(level) {
|
||||
return Math.min(4, Math.max(0, Math.floor(level / 5)))
|
||||
}
|
||||
|
||||
function talentEffectSource(effectType) {
|
||||
if (effectType.startsWith('mend_')) return 'Mend'
|
||||
if (effectType.startsWith('radiance_')) return 'Radiance'
|
||||
if (effectType.startsWith('shield_') || effectType.startsWith('shielded_')) return 'Shield'
|
||||
return effectType
|
||||
}
|
||||
|
||||
function allocateTalent(database, characterId, talentId) {
|
||||
const character = database.prepare(`
|
||||
SELECT class_id AS classId, talent_points AS talentPoints
|
||||
SELECT class_id AS classId, level, talent_points AS talentPoints
|
||||
FROM characters
|
||||
WHERE id = ?
|
||||
`).get(characterId)
|
||||
@@ -1863,7 +1897,8 @@ function allocateTalent(database, characterId, talentId) {
|
||||
max_rank AS maxRank,
|
||||
tier,
|
||||
prerequisite_talent_id AS prerequisiteTalentId,
|
||||
prerequisite_rank AS prerequisiteRank
|
||||
prerequisite_rank AS prerequisiteRank,
|
||||
effect_type AS effectType
|
||||
FROM talents
|
||||
WHERE id = ?
|
||||
`).get(talentId)
|
||||
@@ -1871,6 +1906,60 @@ function allocateTalent(database, characterId, talentId) {
|
||||
if (!talent || talent.classId !== character.classId) {
|
||||
throw new Error('That talent does not belong to the active class.')
|
||||
}
|
||||
|
||||
if (character.classId === 1) {
|
||||
const currentRank = database.prepare(`
|
||||
SELECT rank
|
||||
FROM character_talents
|
||||
WHERE character_id = ? AND talent_id = ?
|
||||
`).get(characterId, talentId)?.rank ?? 0
|
||||
database.exec('BEGIN')
|
||||
try {
|
||||
if (currentRank > 0) {
|
||||
database.prepare(`
|
||||
DELETE FROM character_talents
|
||||
WHERE character_id = ? AND talent_id = ?
|
||||
`).run(characterId, talentId)
|
||||
} else {
|
||||
const capacity = talentEffectCapacity(character.level)
|
||||
if (capacity <= 0) throw new Error('Spell effects unlock at level 5.')
|
||||
const activeTalents = database.prepare(`
|
||||
SELECT
|
||||
talents.id,
|
||||
talents.name,
|
||||
talents.effect_type AS effectType
|
||||
FROM character_talents
|
||||
JOIN talents ON talents.id = character_talents.talent_id
|
||||
WHERE character_talents.character_id = ?
|
||||
AND talents.class_id = ?
|
||||
AND character_talents.rank > 0
|
||||
`).all(characterId, character.classId)
|
||||
const source = talentEffectSource(talent.effectType)
|
||||
const sourceConflict = activeTalents.find(
|
||||
(candidate) => candidate.id !== talentId && talentEffectSource(candidate.effectType) === source,
|
||||
)
|
||||
if (sourceConflict) {
|
||||
throw new Error(`Only one ${source} spell effect can be active.`)
|
||||
}
|
||||
const activeCount = activeTalents.length
|
||||
if (activeCount >= capacity) {
|
||||
throw new Error(`Level ${character.level} allows ${capacity} active spell effect${capacity === 1 ? '' : 's'}.`)
|
||||
}
|
||||
database.prepare(`
|
||||
INSERT INTO character_talents (character_id, talent_id, rank)
|
||||
VALUES (?, ?, 1)
|
||||
ON CONFLICT(character_id, talent_id)
|
||||
DO UPDATE SET rank = 1
|
||||
`).run(characterId, talentId)
|
||||
}
|
||||
database.exec('COMMIT')
|
||||
} catch (error) {
|
||||
database.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
return getProfile(database, characterId)
|
||||
}
|
||||
|
||||
if (character.talentPoints <= 0) {
|
||||
throw new Error('No talent points are available.')
|
||||
}
|
||||
@@ -1949,11 +2038,13 @@ function resetTalents(database, characterId) {
|
||||
WHERE character_id = ?
|
||||
AND talent_id IN (SELECT id FROM talents WHERE class_id = ?)
|
||||
`).run(characterId, character.classId)
|
||||
if (character.classId !== 1) {
|
||||
database.prepare(`
|
||||
UPDATE characters
|
||||
SET talent_points = MIN(level, talent_points + ?)
|
||||
WHERE id = ?
|
||||
`).run(refunded, characterId)
|
||||
}
|
||||
database.exec('COMMIT')
|
||||
} catch (error) {
|
||||
database.exec('ROLLBACK')
|
||||
@@ -2024,12 +2115,21 @@ function completeDungeon(database, characterId, accountId, dungeonId, difficulty
|
||||
const completedPart = Math.min(Math.max(Number(runMetrics?.completedPart) || 1, 1), 3)
|
||||
const startPart = Math.min(Math.max(Number(runMetrics?.startPart) || 1, 1), 3)
|
||||
const completedParts = completedPart - startPart + 1
|
||||
const rewardMultiplier = runMetrics?.hardMode ? 2 : 1
|
||||
const rawPartDurations = runMetrics?.partDurationSeconds
|
||||
const partDurationSeconds = Array.isArray(rawPartDurations) && rawPartDurations.length === 3
|
||||
? rawPartDurations.map(Number)
|
||||
: null
|
||||
const experienceReward = Math.round(
|
||||
dungeon.experienceReward * dungeon.experienceMultiplier * completedPart,
|
||||
const baseExperienceReward = Math.round(
|
||||
dungeon.experienceReward * dungeon.experienceMultiplier * completedPart * rewardMultiplier,
|
||||
)
|
||||
const experienceReward = catchUpExperienceReward(
|
||||
database,
|
||||
accountId,
|
||||
characterId,
|
||||
baseExperienceReward,
|
||||
character.experience,
|
||||
character.level,
|
||||
)
|
||||
const newExperience = Math.min(character.experience + experienceReward, maxExperience)
|
||||
const newLevel = database.prepare(`
|
||||
@@ -2127,17 +2227,18 @@ function completeDungeon(database, characterId, accountId, dungeonId, difficulty
|
||||
`).all(dungeonId, dungeon.completionItemLevel ?? dungeon.droppedItemLevel + 3)
|
||||
if (bonusItems.length > 0) {
|
||||
bonusItem = bonusItems[0]
|
||||
const rewardQuantity = rewardMultiplier
|
||||
const previousQuantity = database.prepare(`
|
||||
SELECT quantity FROM character_inventory
|
||||
WHERE character_id = ? AND item_id = ?
|
||||
`).get(characterId, bonusItem.id)?.quantity ?? 0
|
||||
database.prepare(`
|
||||
INSERT INTO character_inventory (character_id, item_id, quantity, equipped)
|
||||
VALUES (?, ?, 1, 0)
|
||||
VALUES (?, ?, ?, 0)
|
||||
ON CONFLICT(character_id, item_id)
|
||||
DO UPDATE SET quantity = quantity + 1
|
||||
`).run(characterId, bonusItem.id)
|
||||
bonusItem = { ...bonusItem, quantity: 1, duplicate: previousQuantity > 0, quantityAfter: previousQuantity + 1 }
|
||||
DO UPDATE SET quantity = quantity + ?
|
||||
`).run(characterId, bonusItem.id, rewardQuantity, rewardQuantity)
|
||||
bonusItem = { ...bonusItem, quantity: rewardQuantity, duplicate: previousQuantity > 0, quantityAfter: previousQuantity + rewardQuantity }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2234,6 +2335,12 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
|
||||
let newExperience = character.experience
|
||||
let newLevel = character.level
|
||||
if (experienceMode === 'pvp-boss-quarter-level') {
|
||||
const catchUpTargetLevel = database.prepare(`
|
||||
SELECT COALESCE(MAX(level), 0) AS level
|
||||
FROM characters
|
||||
WHERE account_id = ?
|
||||
AND id != ?
|
||||
`).get(accountId, characterId).level
|
||||
for (let bossIndex = 0; bossIndex < bossesCleared && newExperience < maxExperience; bossIndex += 1) {
|
||||
const currentLevelFloor = database.prepare(`
|
||||
SELECT experience_required AS experienceRequired
|
||||
@@ -2248,7 +2355,8 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
|
||||
WHERE level = ?
|
||||
`).get(newLevel + 1).experienceRequired
|
||||
const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor)
|
||||
newExperience = Math.min(maxExperience, newExperience + Math.round(levelBand * 0.25))
|
||||
const rewardRate = catchUpTargetLevel > newLevel ? 0.5 : 0.25
|
||||
newExperience = Math.min(maxExperience, newExperience + Math.round(levelBand * rewardRate))
|
||||
newLevel = database.prepare(`
|
||||
SELECT MAX(level) AS level
|
||||
FROM level_progression
|
||||
@@ -2256,9 +2364,17 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
|
||||
`).get(newExperience).level
|
||||
}
|
||||
} else {
|
||||
const experienceReward = Math.round(
|
||||
const baseExperienceReward = Math.round(
|
||||
dungeon.experienceReward * dungeon.experienceMultiplier * (encountersCleared / 3),
|
||||
)
|
||||
const experienceReward = catchUpExperienceReward(
|
||||
database,
|
||||
accountId,
|
||||
characterId,
|
||||
baseExperienceReward,
|
||||
character.experience,
|
||||
character.level,
|
||||
)
|
||||
newExperience = Math.min(character.experience + experienceReward, maxExperience)
|
||||
newLevel = database.prepare(`
|
||||
SELECT MAX(level) AS level
|
||||
|
||||
+478
-14
@@ -1683,7 +1683,8 @@ h2 {
|
||||
|
||||
.equipment-screen .equipment-layout,
|
||||
.equipment-screen .crafting-panel,
|
||||
.talent-screen .talent-tree {
|
||||
.talent-screen .talent-tree,
|
||||
.talent-screen .spell-effect-layout {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
@@ -1800,6 +1801,35 @@ h2 {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.activity-pager {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.activity-pager button {
|
||||
background: #15161c;
|
||||
border: 2px solid #090a0d;
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
min-height: 34px;
|
||||
outline: 2px solid #41404a;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.activity-pager button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.activity-pager span {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tier-grid {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
@@ -1919,7 +1949,17 @@ h2 {
|
||||
}
|
||||
|
||||
.part-setup-panel .part-picker {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.part-start-row {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(82px, 0.38fr);
|
||||
}
|
||||
|
||||
.hard-mode-button {
|
||||
border-color: #c25b4b;
|
||||
}
|
||||
|
||||
.part-setup-panel .primary-button {
|
||||
@@ -2058,6 +2098,16 @@ h2 {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.activity-pager button {
|
||||
font-size: 9px;
|
||||
min-height: 28px;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
.activity-pager span {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.dungeon-run-screen .eyebrow {
|
||||
font-size: 7px;
|
||||
margin-bottom: 5px;
|
||||
@@ -2262,6 +2312,20 @@ h2 {
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.activity-pager {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.activity-pager button {
|
||||
font-size: 8px;
|
||||
min-height: 24px;
|
||||
padding: 3px 5px;
|
||||
}
|
||||
|
||||
.activity-pager span {
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.dungeon-choice-grid {
|
||||
grid-auto-rows: minmax(52px, max-content);
|
||||
}
|
||||
@@ -3444,6 +3508,260 @@ h2 {
|
||||
margin-top: 17px;
|
||||
}
|
||||
|
||||
.talent-empty-state {
|
||||
background: var(--panel-light);
|
||||
border: 2px solid #090a0d;
|
||||
margin-top: 17px;
|
||||
outline: 2px solid #41404a;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.spell-effect-layout {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
grid-template-columns: 220px minmax(0, 1fr);
|
||||
margin-top: 17px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.effect-slots-panel,
|
||||
.effect-pool-panel {
|
||||
background: #191b25;
|
||||
border: 2px solid #090a0d;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
outline: 2px solid #3a3944;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.effect-slots-panel {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-auto-rows: minmax(76px, auto);
|
||||
}
|
||||
|
||||
.effect-slot {
|
||||
background: #20222d;
|
||||
border: 2px solid #090a0d;
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
outline: 2px solid #3a3944;
|
||||
padding: 10px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.effect-slot.filled {
|
||||
background: #29291f;
|
||||
outline-color: var(--gold);
|
||||
}
|
||||
|
||||
.effect-slot.locked {
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
.effect-slot span,
|
||||
.effect-pool > button i {
|
||||
color: var(--gold);
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
font-size: 8px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.effect-slot strong,
|
||||
.effect-slot small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.effect-slot strong {
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
font-size: 8px;
|
||||
line-height: 1.35;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.effect-slot small {
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.effect-panel-heading {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.effect-panel-heading > span {
|
||||
color: var(--gold);
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.selected-effect-strip {
|
||||
align-items: center;
|
||||
background: #20222d;
|
||||
border: 2px solid #090a0d;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
margin-top: 12px;
|
||||
outline: 2px solid #3a3944;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.selected-effect-strip strong,
|
||||
.selected-effect-strip small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.selected-effect-strip strong {
|
||||
color: var(--gold);
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
font-size: 9px;
|
||||
line-height: 1.35;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.selected-effect-strip small {
|
||||
color: var(--muted);
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.selected-effect-strip .primary-button {
|
||||
min-width: 120px;
|
||||
padding: 9px 12px;
|
||||
}
|
||||
|
||||
.effect-pool {
|
||||
align-content: start;
|
||||
display: grid;
|
||||
flex: 1;
|
||||
gap: 10px;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
grid-template-rows: repeat(2, 62px);
|
||||
margin-top: 12px;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.effect-pool > button {
|
||||
align-content: center;
|
||||
background: #20222d;
|
||||
border: 2px solid #090a0d;
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
grid-template-columns: 26px minmax(0, 1fr);
|
||||
min-height: 0;
|
||||
outline: 2px solid #3a3944;
|
||||
overflow: hidden;
|
||||
padding: 7px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.effect-pool > button.active {
|
||||
background: #29291f;
|
||||
outline-color: var(--gold);
|
||||
}
|
||||
|
||||
.effect-pool > button.selected {
|
||||
border-color: var(--gold);
|
||||
}
|
||||
|
||||
.effect-pool > button:disabled:not(.active) {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.effect-pool > button > span {
|
||||
align-items: center;
|
||||
background: #15161c;
|
||||
border: 1px solid #55515f;
|
||||
color: var(--gold);
|
||||
display: flex;
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
height: 26px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.effect-pool strong,
|
||||
.effect-pool small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.effect-pool strong {
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
font-size: 7px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.effect-pool small {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.effect-pool > button i {
|
||||
grid-column: 1 / -1;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.effect-pager {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.effect-pager button {
|
||||
background: #15161c;
|
||||
border: 2px solid #090a0d;
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
font-size: 7px;
|
||||
min-height: 28px;
|
||||
outline: 2px solid #41404a;
|
||||
padding: 4px 8px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.effect-pager button:disabled {
|
||||
color: #676773;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.effect-pager span {
|
||||
color: var(--gold);
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.spell-effect-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.effect-slots-panel {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.effect-pool {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.selected-effect-strip {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.talent-tier {
|
||||
align-items: stretch;
|
||||
border-bottom: 1px solid #393943;
|
||||
@@ -4707,6 +5025,28 @@ h2 {
|
||||
box-shadow: inset 0 5px #cf4b59;
|
||||
}
|
||||
|
||||
.hard-enemy-bars {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.hard-enemy-bars .enemy-health {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.hard-enemy-bars .enemy-health em {
|
||||
color: #fff7df;
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
font-size: 7px;
|
||||
font-style: normal;
|
||||
left: 8px;
|
||||
position: absolute;
|
||||
text-shadow: 0 1px 0 #111;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.combat-layout {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
@@ -5077,6 +5417,19 @@ h2 {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.speed-badge {
|
||||
background: var(--gold);
|
||||
border: 2px solid #0a0b0e;
|
||||
color: #21180a;
|
||||
display: inline-block;
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
font-size: 8px;
|
||||
line-height: 1;
|
||||
margin: 0 0 5px;
|
||||
padding: 5px 7px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.action-panel .resource-row {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
@@ -7322,6 +7675,125 @@ h2 {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.workshop-shell .spell-effect-layout {
|
||||
gap: 6px;
|
||||
grid-template-columns: 172px minmax(0, 1fr);
|
||||
margin-top: 5px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.workshop-shell .effect-slots-panel,
|
||||
.workshop-shell .effect-pool-panel {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.workshop-shell .effect-slots-panel {
|
||||
gap: 5px;
|
||||
grid-auto-rows: minmax(43px, 1fr);
|
||||
}
|
||||
|
||||
.workshop-shell .effect-slot {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.workshop-shell .effect-slot span,
|
||||
.workshop-shell .effect-pool > button i,
|
||||
.workshop-shell .effect-panel-heading > span,
|
||||
.workshop-shell .effect-pager span {
|
||||
font-size: 6px;
|
||||
}
|
||||
|
||||
.workshop-shell .effect-slot strong {
|
||||
font-size: 6px;
|
||||
line-height: 1.15;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.workshop-shell .effect-slot small {
|
||||
font-size: 9px;
|
||||
line-height: 1;
|
||||
margin-top: 2px;
|
||||
max-height: 18px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.workshop-shell .effect-panel-heading h2 {
|
||||
font-size: 9px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.workshop-shell .selected-effect-strip {
|
||||
gap: 6px;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
margin-top: 5px;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.workshop-shell .selected-effect-strip .eyebrow {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.workshop-shell .selected-effect-strip strong {
|
||||
font-size: 7px;
|
||||
line-height: 1.1;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.workshop-shell .selected-effect-strip small {
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
margin-top: 3px;
|
||||
max-height: 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.workshop-shell .selected-effect-strip .primary-button {
|
||||
font-size: 7px;
|
||||
min-height: 25px;
|
||||
min-width: 72px;
|
||||
padding: 3px 6px;
|
||||
}
|
||||
|
||||
.workshop-shell .effect-pool {
|
||||
gap: 5px;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
grid-template-rows: repeat(2, 52px);
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.workshop-shell .effect-pool > button {
|
||||
gap: 4px;
|
||||
grid-template-columns: 22px minmax(0, 1fr);
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.workshop-shell .effect-pool > button > span {
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.workshop-shell .effect-pool strong {
|
||||
font-size: 6px;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.workshop-shell .effect-pool small {
|
||||
font-size: 9px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.workshop-shell .effect-pager {
|
||||
gap: 5px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.workshop-shell .effect-pager button {
|
||||
font-size: 6px;
|
||||
min-height: 22px;
|
||||
padding: 2px 5px;
|
||||
}
|
||||
|
||||
.workshop-shell .talent-tier {
|
||||
gap: 6px;
|
||||
grid-template-columns: 66px minmax(0, 1fr);
|
||||
@@ -7567,6 +8039,7 @@ h2 {
|
||||
flex: 1;
|
||||
gap: 5px;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
grid-template-rows: repeat(2, minmax(52px, 1fr));
|
||||
margin-top: 5px;
|
||||
max-height: none;
|
||||
min-height: 0;
|
||||
@@ -7605,10 +8078,6 @@ h2 {
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
.workshop-shell .ability-library > button:nth-child(n+6) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.workshop-shell .save-row .primary-button {
|
||||
font-size: 8px;
|
||||
min-height: 28px;
|
||||
@@ -7682,11 +8151,8 @@ h2 {
|
||||
}
|
||||
|
||||
.workshop-shell .ability-library {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.workshop-shell .ability-library > button:nth-child(n+6) {
|
||||
display: none;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
grid-template-rows: repeat(2, minmax(52px, 1fr));
|
||||
}
|
||||
|
||||
.workshop-bottom-grid {
|
||||
@@ -7814,6 +8280,7 @@ h2 {
|
||||
flex: 1;
|
||||
gap: 5px;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
grid-template-rows: repeat(2, minmax(52px, 1fr));
|
||||
margin-top: 5px;
|
||||
max-height: none;
|
||||
min-height: 0;
|
||||
@@ -7843,9 +8310,6 @@ h2 {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.workshop-shell .ability-library > button:nth-child(n+6) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 700px) and (max-height: 620px) {
|
||||
|
||||
+112
-48
@@ -55,6 +55,7 @@ const MENU_ITEMS: Array<{
|
||||
|
||||
const LAST_DIFFICULTY_KEY = 'i-want-to-heal:last-difficulty'
|
||||
const SHOW_LEADERBOARDS = false
|
||||
const ACTIVITY_PAGE_SIZE = 6
|
||||
|
||||
function activityInitials(name: string) {
|
||||
return name
|
||||
@@ -81,13 +82,15 @@ function App() {
|
||||
return Number.isFinite(saved) && saved > 0 ? saved : 1
|
||||
})
|
||||
const [selectedDungeonId, setSelectedDungeonId] = useState(1)
|
||||
const [selectedRaidId, setSelectedRaidId] = useState(2)
|
||||
const [selectedRaidId, setSelectedRaidId] = useState(20)
|
||||
const [roguelikeKind, setRoguelikeKind] = useState<'dungeon' | 'raid'>('dungeon')
|
||||
const [roguelikeVariant, setRoguelikeVariant] = useState<RoguelikeVariant>('pve')
|
||||
const [roguelikeUpgradeTiming, setRoguelikeUpgradeTiming] = useState<RoguelikeUpgradeTiming>('encounter')
|
||||
const [roguelikeAbilityLabelMode, setRoguelikeAbilityLabelMode] = useState<RoguelikeAbilityLabelMode>('ability')
|
||||
const [pvpContentType, setPvpContentType] = useState<PvpContentType>('dungeon')
|
||||
const [selectedPart, setSelectedPart] = useState(1)
|
||||
const [selectedMarathonMode, setSelectedMarathonMode] = useState(false)
|
||||
const [activityPage, setActivityPage] = useState(0)
|
||||
const [combatContentId, setCombatContentId] = useState(1)
|
||||
const [leaderboardCategory, setLeaderboardCategory] = useState<'part_1' | 'part_2' | 'part_3' | 'full_run'>('part_1')
|
||||
const [showLoot, setShowLoot] = useState(false)
|
||||
@@ -235,6 +238,8 @@ function App() {
|
||||
<CombatScreen
|
||||
difficulty={difficulty}
|
||||
dungeon={dungeon}
|
||||
hardMode={false}
|
||||
marathonMode={selectedMarathonMode && combatContentId > 0}
|
||||
profile={profile}
|
||||
roguelikeMode={combatContentId < 0 ? roguelikeKind : undefined}
|
||||
roguelikeUpgradeTiming={combatContentId < 0 ? roguelikeUpgradeTiming : undefined}
|
||||
@@ -283,6 +288,20 @@ function App() {
|
||||
const raid = raidOptions.find((candidate) => candidate.id === selectedRaidId)
|
||||
?? raidOptions[0]
|
||||
const activityOptions = screen === 'raids' ? raidOptions : dungeonOptions
|
||||
const startPveRoguelike = () => {
|
||||
const baseDungeon = dungeonOptions[0]
|
||||
const baseRaid = raidOptions[0]
|
||||
if (roguelikeKind === 'raid') {
|
||||
setCombatContentId(-2)
|
||||
setSelectedDifficultyId(baseRaid?.difficulties[0]?.id ?? 101)
|
||||
} else {
|
||||
setCombatContentId(-1)
|
||||
setSelectedDifficultyId(baseDungeon?.difficulties[0]?.id ?? 1)
|
||||
}
|
||||
setSelectedPart(1)
|
||||
setSelectedMarathonMode(false)
|
||||
setScreen('combat')
|
||||
}
|
||||
const tierOptions = activityOptions
|
||||
.flatMap((option) => option.difficulties)
|
||||
.filter((difficulty, index, all) => (
|
||||
@@ -302,6 +321,12 @@ function App() {
|
||||
const tierActivityOptions = activityOptions.filter((option) =>
|
||||
option.difficulties.some((difficulty) => difficulty.droppedItemLevel === selectedTierItemLevel),
|
||||
)
|
||||
const activityPageCount = Math.max(1, Math.ceil(tierActivityOptions.length / ACTIVITY_PAGE_SIZE))
|
||||
const currentActivityPage = Math.min(activityPage, activityPageCount - 1)
|
||||
const pagedActivityOptions = tierActivityOptions.slice(
|
||||
currentActivityPage * ACTIVITY_PAGE_SIZE,
|
||||
currentActivityPage * ACTIVITY_PAGE_SIZE + ACTIVITY_PAGE_SIZE,
|
||||
)
|
||||
const selectedActivityId = screen === 'raids' && raid ? raid.id : dungeon.id
|
||||
const activity = tierActivityOptions.find((candidate) => candidate.id === selectedActivityId)
|
||||
?? tierActivityOptions[0]
|
||||
@@ -310,15 +335,9 @@ function App() {
|
||||
(candidate) => candidate.droppedItemLevel === selectedTierItemLevel,
|
||||
) ?? activity.difficulties[0]
|
||||
const difficultyLocked = profile.character.level < selectedDifficulty.unlockLevel
|
||||
const completedSections = activity.contentType === 'raid'
|
||||
? profile.completedRaidPhases
|
||||
: profile.completedDungeonParts
|
||||
const sectionName = activity.contentType === 'raid' ? 'Phase' : 'Part'
|
||||
const parts = [
|
||||
{ part: 1, name: `${sectionName} 1`, encounterCount: 3, unlocked: true },
|
||||
{ part: 2, name: `${sectionName} 2`, encounterCount: 3, unlocked: completedSections >= 1 },
|
||||
{ part: 3, name: `${sectionName} 3`, encounterCount: 3, unlocked: completedSections >= 2 },
|
||||
]
|
||||
const activityCompletionCount = activity.completionCount ?? 0
|
||||
const marathonUnlocked = activityCompletionCount >= 10
|
||||
const cloudSync = getCloudSyncStatus()
|
||||
const canShowCloudSync = account.id !== -1 && cloudSync.available
|
||||
const lootPreviewEncounters = [...activity.encounters]
|
||||
@@ -425,6 +444,28 @@ function App() {
|
||||
</div>
|
||||
{roguelikeVariant === 'pve' && (
|
||||
<>
|
||||
<div className="roguelike-option-panel">
|
||||
<div>
|
||||
<p className="eyebrow">Run Type</p>
|
||||
<h2>PvE Roguelike</h2>
|
||||
</div>
|
||||
<div className="roguelike-timing-row">
|
||||
<button
|
||||
className={`text-button ${roguelikeKind === 'dungeon' ? 'active' : ''}`}
|
||||
onClick={() => setRoguelikeKind('dungeon')}
|
||||
type="button"
|
||||
>
|
||||
Dungeon
|
||||
</button>
|
||||
<button
|
||||
className={`text-button ${roguelikeKind === 'raid' ? 'active' : ''}`}
|
||||
onClick={() => setRoguelikeKind('raid')}
|
||||
type="button"
|
||||
>
|
||||
Raid
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="roguelike-option-panel">
|
||||
<div>
|
||||
<p className="eyebrow">Upgrade Timing</p>
|
||||
@@ -469,38 +510,22 @@ function App() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="roguelike-mode-grid">
|
||||
<div className="menu-card pvp-queue-panel">
|
||||
<span>{roguelikeKind === 'raid' ? 'R' : 'D'}</span>
|
||||
<div>
|
||||
<strong>{roguelikeKind === 'raid' ? 'Raid Roguelike' : 'Dungeon Roguelike'}</strong>
|
||||
<small>
|
||||
{roguelikeKind === 'raid'
|
||||
? 'Ten-player party. Raid pools, lighter early scaling, and the same upgrade draft.'
|
||||
: 'Five-player party. Two random trash enemies and a boss with a lighter early ramp.'}
|
||||
</small>
|
||||
</div>
|
||||
<button
|
||||
className="menu-card"
|
||||
onClick={() => {
|
||||
const baseDungeon = dungeonOptions[0]
|
||||
setRoguelikeKind('dungeon')
|
||||
setCombatContentId(-1)
|
||||
setSelectedDifficultyId(baseDungeon.difficulties[0]?.id ?? 1)
|
||||
setSelectedPart(1)
|
||||
setScreen('combat')
|
||||
}}
|
||||
className="text-button"
|
||||
onClick={startPveRoguelike}
|
||||
type="button"
|
||||
>
|
||||
<span>D</span>
|
||||
<strong>Dungeon Roguelike</strong>
|
||||
<small>Five-player party. Two random trash enemies and a boss with a lighter early ramp.</small>
|
||||
</button>
|
||||
<button
|
||||
className="menu-card"
|
||||
onClick={() => {
|
||||
const baseRaid = raidOptions[0]
|
||||
setRoguelikeKind('raid')
|
||||
setCombatContentId(-2)
|
||||
setSelectedDifficultyId(baseRaid?.difficulties[0]?.id ?? 101)
|
||||
setSelectedPart(1)
|
||||
setScreen('combat')
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<span>R</span>
|
||||
<strong>Raid Roguelike</strong>
|
||||
<small>Ten-player party. Raid pools, lighter early scaling, and the same upgrade draft.</small>
|
||||
Start Run
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
@@ -618,10 +643,30 @@ function App() {
|
||||
<p className="eyebrow">Pick Run</p>
|
||||
<h2>{screen === 'raids' ? 'Raid' : 'Dungeon'}</h2>
|
||||
</div>
|
||||
{activityPageCount > 1 ? (
|
||||
<div className="activity-pager" aria-label={`${screen === 'raids' ? 'Raid' : 'Dungeon'} pages`}>
|
||||
<button
|
||||
disabled={currentActivityPage === 0}
|
||||
onClick={() => setActivityPage((page) => Math.max(0, page - 1))}
|
||||
type="button"
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
<span>{currentActivityPage + 1}/{activityPageCount}</span>
|
||||
<button
|
||||
disabled={currentActivityPage >= activityPageCount - 1}
|
||||
onClick={() => setActivityPage((page) => Math.min(activityPageCount - 1, page + 1))}
|
||||
type="button"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<small>{selectedDifficulty.name} rewards iLvl {selectedDifficulty.droppedItemLevel} components.</small>
|
||||
)}
|
||||
</div>
|
||||
<div className="activity-card-grid dungeon-choice-grid">
|
||||
{tierActivityOptions.map((candidate) => {
|
||||
{pagedActivityOptions.map((candidate) => {
|
||||
const difficulty = candidate.difficulties.find(
|
||||
(option) => option.droppedItemLevel === selectedDifficulty.droppedItemLevel,
|
||||
) ?? candidate.difficulties[0]
|
||||
@@ -673,6 +718,7 @@ function App() {
|
||||
disabled={locked}
|
||||
key={difficulty.id}
|
||||
onClick={() => {
|
||||
setActivityPage(0)
|
||||
const nextActivity = activity.difficulties.some(
|
||||
(candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel,
|
||||
)
|
||||
@@ -703,27 +749,45 @@ function App() {
|
||||
<div className="run-setup-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Start</p>
|
||||
<h2>{sectionName}</h2>
|
||||
<h2>Run</h2>
|
||||
</div>
|
||||
<small>{difficultyLocked ? `Unlocks at level ${selectedDifficulty.unlockLevel}` : 'Choose a section to launch.'}</small>
|
||||
<small>
|
||||
{difficultyLocked
|
||||
? `Unlocks at level ${selectedDifficulty.unlockLevel}`
|
||||
: marathonUnlocked
|
||||
? 'Marathon keeps health and mana between boss kills.'
|
||||
: `Marathon unlocks after 10 clears (${activityCompletionCount}/10).`}
|
||||
</small>
|
||||
</div>
|
||||
<div className="part-picker">
|
||||
{parts.map((p) => (
|
||||
<button
|
||||
key={p.part}
|
||||
className={`primary-button ${selectedPart === p.part ? 'selected-part' : ''} ${!p.unlocked ? 'locked' : ''}`}
|
||||
disabled={difficultyLocked || !p.unlocked}
|
||||
className="primary-button selected-part"
|
||||
disabled={difficultyLocked}
|
||||
onClick={() => {
|
||||
setSelectedPart(p.part)
|
||||
setSelectedPart(1)
|
||||
setSelectedMarathonMode(false)
|
||||
setCombatContentId(activity.id)
|
||||
setSelectedDifficultyId(selectedDifficulty.id)
|
||||
setScreen('combat')
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{p.name}
|
||||
Start Hunt
|
||||
</button>
|
||||
<button
|
||||
className={`primary-button ${selectedMarathonMode ? 'selected-part' : ''} ${!marathonUnlocked ? 'locked' : ''}`}
|
||||
disabled={difficultyLocked || !marathonUnlocked}
|
||||
onClick={() => {
|
||||
setSelectedPart(1)
|
||||
setSelectedMarathonMode(true)
|
||||
setCombatContentId(activity.id)
|
||||
setSelectedDifficultyId(selectedDifficulty.id)
|
||||
setScreen('combat')
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Marathon
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
+376
-69
@@ -10,6 +10,10 @@ import {
|
||||
import {
|
||||
INITIAL_PARTY,
|
||||
RAID_PARTY,
|
||||
DEFAULT_GROUP_HEAL_TARGETS,
|
||||
groupHealTargets,
|
||||
partyDamageOutput,
|
||||
tankPressureTargets,
|
||||
type CombatLogEntry,
|
||||
type PartyMember,
|
||||
type Spell,
|
||||
@@ -39,7 +43,7 @@ const TICK_MS = 700
|
||||
type RoguelikeMode = 'dungeon' | 'raid'
|
||||
type RoguelikeUpgradeTiming = 'boss' | 'encounter'
|
||||
type RoguelikeAbilityLabelMode = 'ability' | 'slot'
|
||||
type SlotKey = '1' | '2' | '3' | '4' | '5'
|
||||
type SlotKey = '1' | '2' | '3' | '4' | '5' | '6'
|
||||
type RoguelikeMechanic =
|
||||
| 'party-pulse'
|
||||
| 'searing-mark'
|
||||
@@ -101,12 +105,53 @@ function effectiveMaxHealth(member: PartyMember) {
|
||||
return Math.max(1, Math.round(member.maxHealth * (member.maxHealthPenaltyTicks && member.maxHealthPenaltyTicks > 0 ? 0.75 : 1)))
|
||||
}
|
||||
|
||||
function healAmount(member: PartyMember, amount: number) {
|
||||
return Math.round(amount * (member.healingReductionTicks && member.healingReductionTicks > 0 ? 0.75 : 1))
|
||||
function healAmount(member: PartyMember, amount: number, multiplier = 1) {
|
||||
return Math.round(amount * (member.healingReductionTicks && member.healingReductionTicks > 0 ? 0.75 : 1) * multiplier)
|
||||
}
|
||||
|
||||
function healMember(member: PartyMember, amount: number) {
|
||||
return clamp(member.health + healAmount(member, amount), 0, effectiveMaxHealth(member))
|
||||
function healMember(member: PartyMember, amount: number, multiplier = 1) {
|
||||
return clamp(member.health + healAmount(member, amount, multiplier), 0, effectiveMaxHealth(member))
|
||||
}
|
||||
|
||||
function memberHotEffects(member: PartyMember) {
|
||||
if (member.hotEffects?.length) return member.hotEffects
|
||||
return member.hotTicks > 0
|
||||
? [{ id: 'legacy-renew', spellId: 'legacy-renew', label: 'Renew', ticks: member.hotTicks, power: 6 }]
|
||||
: []
|
||||
}
|
||||
|
||||
function effectId(prefix: string) {
|
||||
return `${prefix}-${globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`}`
|
||||
}
|
||||
|
||||
function addHotEffect(member: PartyMember, spell: Spell, ticks = 5) {
|
||||
const nextEffect = {
|
||||
id: effectId(spell.id),
|
||||
spellId: spell.id,
|
||||
label: spell.name,
|
||||
ticks,
|
||||
power: Math.max(1, Math.round(spell.power / 2)),
|
||||
}
|
||||
const currentEffects = memberHotEffects(member).filter((effect) => effect.spellId !== spell.id)
|
||||
return [...currentEffects, nextEffect]
|
||||
}
|
||||
|
||||
function addBounceHeal(member: PartyMember, spell: Spell) {
|
||||
return [
|
||||
...(member.bounceHeals ?? []),
|
||||
{
|
||||
id: effectId(spell.id),
|
||||
label: spell.name,
|
||||
charges: 4,
|
||||
power: spell.power,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function tickHotEffects(effects: PartyMember['hotEffects']) {
|
||||
return (effects ?? [])
|
||||
.map((effect) => ({ ...effect, ticks: effect.ticks - 1 }))
|
||||
.filter((effect) => effect.ticks > 0)
|
||||
}
|
||||
|
||||
function upgradeStackCount(upgrades: RoguelikeUpgrade[], id: RoguelikeUpgradeId) {
|
||||
@@ -123,7 +168,7 @@ function buildRoguelikeUpgrades(
|
||||
spells: Spell[],
|
||||
labelMode: RoguelikeAbilityLabelMode,
|
||||
): RoguelikeUpgrade[] {
|
||||
const slotUpgrades = (['1', '2', '3', '4', '5'] as SlotKey[]).flatMap((slot) => {
|
||||
const slotUpgrades = (['1', '2', '3', '4', '5', '6'] as SlotKey[]).flatMap((slot) => {
|
||||
const label = slotLabel(slot, spells, labelMode)
|
||||
return [
|
||||
{
|
||||
@@ -191,9 +236,14 @@ function spellResourceCost(spell: Spell, upgrades: RoguelikeUpgrade[], freeCastR
|
||||
function toCombatSpell(ability: Ability, key: string, healingPower: number): Spell {
|
||||
const kinds: Record<string, Spell['kind']> = {
|
||||
direct_heal: 'direct',
|
||||
direct_hot: 'direct',
|
||||
heal_over_time: 'hot',
|
||||
party_heal: 'group',
|
||||
party_hot: 'group',
|
||||
party_absorb: 'group',
|
||||
absorb: 'shield',
|
||||
damage_reduction: 'damage_reduction',
|
||||
bounce_heal: 'bounce_heal',
|
||||
cleanse: 'cleanse',
|
||||
}
|
||||
return {
|
||||
@@ -206,6 +256,7 @@ function toCombatSpell(ability: Ability, key: string, healingPower: number): Spe
|
||||
power: ability.power + healingPower,
|
||||
glyph: ability.glyph,
|
||||
kind: kinds[ability.spellType] ?? 'direct',
|
||||
effectType: ability.spellType,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,6 +340,8 @@ function makeRoguelikeSegment(
|
||||
export function CombatScreen({
|
||||
difficulty,
|
||||
dungeon,
|
||||
hardMode = false,
|
||||
marathonMode = false,
|
||||
profile,
|
||||
startPart = 1,
|
||||
roguelikeMode,
|
||||
@@ -300,6 +353,8 @@ export function CombatScreen({
|
||||
}: {
|
||||
difficulty: Difficulty
|
||||
dungeon: Dungeon
|
||||
hardMode?: boolean
|
||||
marathonMode?: boolean
|
||||
profile: CharacterProfile
|
||||
startPart?: number
|
||||
roguelikeMode?: RoguelikeMode
|
||||
@@ -350,20 +405,22 @@ export function CombatScreen({
|
||||
const sectionName = isRoguelike ? 'Stage' : dungeon.contentType === 'raid' ? 'Phase' : 'Part'
|
||||
const contentName = isRoguelike ? 'Roguelike' : dungeon.contentType === 'raid' ? 'Raid' : 'Dungeon'
|
||||
const initialEncounterIndex = (startPart - 1) * 3
|
||||
const enemyCount = hardMode ? 2 : 1
|
||||
const initialCombatState = useMemo<SinglePlayerCombatState>(() => ({
|
||||
party: partyTemplate,
|
||||
resource: maxResource,
|
||||
enemyHealth: encounters[initialEncounterIndex].maxHealth,
|
||||
enemyHealth: encounters[initialEncounterIndex].maxHealth * enemyCount,
|
||||
cooldowns: {},
|
||||
elapsedTicks: 0,
|
||||
castsTowardFree: 0,
|
||||
freeCastReady: false,
|
||||
}), [encounters, initialEncounterIndex, maxResource, partyTemplate])
|
||||
}), [encounters, enemyCount, initialEncounterIndex, maxResource, partyTemplate])
|
||||
const [combatState, setCombatState] = useState<SinglePlayerCombatState>(() => initialCombatState)
|
||||
const [selectedId, setSelectedId] = useState(partyTemplate[0].id)
|
||||
const [encounterIndex, setEncounterIndex] = useState(initialEncounterIndex)
|
||||
const [status, setStatus] = useState<'playing' | 'won' | 'lost' | 'part-complete' | 'upgrade-choice'>('playing')
|
||||
const [status, setStatus] = useState<'playing' | 'won' | 'lost' | 'part-complete' | 'marathon-choice' | 'upgrade-choice'>('playing')
|
||||
const [paused, setPaused] = useState(false)
|
||||
const [speedMultiplier, setSpeedMultiplier] = useState<1 | 2>(1)
|
||||
const [targetGroup, setTargetGroup] = useState<0 | 1 | 2>(0)
|
||||
const [log, setLog] = useState<CombatLogEntry[]>([
|
||||
{ id: 1, text: `${dungeon.name} begins.`, tone: 'system' },
|
||||
@@ -375,15 +432,17 @@ export function CombatScreen({
|
||||
const [floatingTexts, setFloatingTexts] = useState<FloatingCombatText[]>([])
|
||||
const [roguelikeUpgrades, setRoguelikeUpgrades] = useState<RoguelikeUpgrade[]>([])
|
||||
const [upgradeChoices, setUpgradeChoices] = useState<RoguelikeUpgrade[]>([])
|
||||
const [marathonBossesDefeated, setMarathonBossesDefeated] = useState(0)
|
||||
const rewardClaimedRef = useRef(false)
|
||||
const profileRefreshedRef = useRef(false)
|
||||
const rolledEncounterIdsRef = useRef(new Set<number>())
|
||||
const rolledEncounterIdsRef = useRef(new Set<string>())
|
||||
const runTokenRef = useRef(crypto.randomUUID())
|
||||
const resourceSpentRef = useRef(0)
|
||||
const runStartedAtRef = useRef(0)
|
||||
const partStartTimesRef = useRef<Record<number, number>>({})
|
||||
const nextLogId = useRef(2)
|
||||
const nextFloatingTextId = useRef(1)
|
||||
const marathonBossesDefeatedRef = useRef(0)
|
||||
const combatRef = useRef(initialCombatState)
|
||||
const selectedIdRef = useRef(partyTemplate[0].id)
|
||||
const runCombatTickRef = useRef<() => void>(() => {})
|
||||
@@ -391,24 +450,40 @@ export function CombatScreen({
|
||||
const lastCombatTickAtRef = useRef(performance.now())
|
||||
const statusRef = useRef(status)
|
||||
const pausedRef = useRef(paused)
|
||||
const speedMultiplierRef = useRef<1 | 2>(speedMultiplier)
|
||||
const { party, resource, enemyHealth, cooldowns, freeCastReady } = combatState
|
||||
const encounter = encounters[encounterIndex]
|
||||
const encounterMaxHealth = encounter.maxHealth * enemyCount
|
||||
const currentPart = getCurrentPart(encounterIndex)
|
||||
const completedSections = dungeon.contentType === 'raid'
|
||||
? profile.completedRaidPhases
|
||||
: profile.completedDungeonParts
|
||||
const canContinueAfterPart = !hardMode || completedSections >= currentPart + 1
|
||||
const firstEncounterIndex = (startPart - 1) * 3
|
||||
const expectedLootRolls = encounters
|
||||
.slice(firstEncounterIndex, encounterIndex + 1)
|
||||
.filter((candidate) => candidate.lootTables.some((entry) => entry.difficultyId === difficulty.id))
|
||||
.length
|
||||
.length * enemyCount
|
||||
const isPartBoss = encounter.isBoss && encounterIndex % 3 === 2
|
||||
const isFinalBoss = isPartBoss && encounterIndex === encounters.length - 1
|
||||
const playerHealer = party.find((member) => member.id === 'mira')
|
||||
const playerIsAlive = Boolean(playerHealer && playerHealer.health > 0)
|
||||
const upgradesEveryEncounter = roguelikeUpgradeTiming === 'encounter'
|
||||
const activeSetEffects = useMemo(
|
||||
() => isRoguelike
|
||||
? new Set<string>()
|
||||
: new Set(profile.setBonuses.filter((bonus) => bonus.active).map((bonus) => bonus.effectType)),
|
||||
[isRoguelike, profile.setBonuses],
|
||||
const activeEffects = useMemo(
|
||||
() => {
|
||||
const effects = new Set<string>(
|
||||
gameClass.talents
|
||||
.filter((talent) => talent.rank > 0)
|
||||
.map((talent) => talent.effectType),
|
||||
)
|
||||
if (!isRoguelike) {
|
||||
profile.setBonuses
|
||||
.filter((bonus) => bonus.active)
|
||||
.forEach((bonus) => effects.add(bonus.effectType))
|
||||
}
|
||||
return effects
|
||||
},
|
||||
[gameClass.talents, isRoguelike, profile.setBonuses],
|
||||
)
|
||||
const {
|
||||
bindings,
|
||||
@@ -422,6 +497,7 @@ export function CombatScreen({
|
||||
|
||||
statusRef.current = status
|
||||
pausedRef.current = paused
|
||||
speedMultiplierRef.current = speedMultiplier
|
||||
|
||||
useEffect(() => {
|
||||
const now = Date.now()
|
||||
@@ -480,10 +556,12 @@ export function CombatScreen({
|
||||
}, [])
|
||||
|
||||
const requestLootRoll = useCallback(
|
||||
(encounterId: number) => {
|
||||
if (rolledEncounterIdsRef.current.has(encounterId)) return
|
||||
rolledEncounterIdsRef.current.add(encounterId)
|
||||
rollEncounterLoot(encounterId, difficulty.id, runTokenRef.current)
|
||||
(encounterId: number, rollIndex = 0) => {
|
||||
const rollKey = `${encounterId}:${rollIndex}:${marathonBossesDefeatedRef.current}`
|
||||
if (rolledEncounterIdsRef.current.has(rollKey)) return
|
||||
rolledEncounterIdsRef.current.add(rollKey)
|
||||
const runToken = `${runTokenRef.current}-${marathonBossesDefeatedRef.current}-${rollIndex}`
|
||||
rollEncounterLoot(encounterId, difficulty.id, runToken)
|
||||
.then((result) => {
|
||||
setLootRolls((current) => [...current, result])
|
||||
const awarded = result.items
|
||||
@@ -515,7 +593,7 @@ export function CombatScreen({
|
||||
setCombat({
|
||||
party: freshParty,
|
||||
resource: maxResource,
|
||||
enemyHealth: nextEncounters[initialEncounterIndex].maxHealth,
|
||||
enemyHealth: nextEncounters[initialEncounterIndex].maxHealth * enemyCount,
|
||||
cooldowns: {},
|
||||
elapsedTicks: 0,
|
||||
castsTowardFree: 0,
|
||||
@@ -535,15 +613,17 @@ export function CombatScreen({
|
||||
setFloatingTexts([])
|
||||
setRoguelikeUpgrades([])
|
||||
setUpgradeChoices([])
|
||||
setMarathonBossesDefeated(0)
|
||||
rewardClaimedRef.current = false
|
||||
profileRefreshedRef.current = false
|
||||
rolledEncounterIdsRef.current = new Set()
|
||||
runTokenRef.current = crypto.randomUUID()
|
||||
marathonBossesDefeatedRef.current = 0
|
||||
resourceSpentRef.current = 0
|
||||
runStartedAtRef.current = Date.now()
|
||||
partStartTimesRef.current = { [startPart]: runStartedAtRef.current }
|
||||
setLog([{ id: nextLogId.current++, text: 'A new run begins.', tone: 'system' }])
|
||||
}, [difficulty, initialEncounterIndex, maxResource, partyTemplate, roguelikeMode, roguelikePool, setCombat, setSelectedTargetId, startPart, staticEncounters])
|
||||
}, [difficulty, enemyCount, initialEncounterIndex, maxResource, partyTemplate, roguelikeMode, roguelikePool, setCombat, setSelectedTargetId, startPart, staticEncounters])
|
||||
|
||||
const castSpell = useCallback(
|
||||
(spell: Spell) => {
|
||||
@@ -558,23 +638,36 @@ export function CombatScreen({
|
||||
const extraTarget = (blockedIds: string[]) => current.party
|
||||
.filter((member) => member.health > 0 && !blockedIds.includes(member.id))
|
||||
.sort((left, right) => (left.health / left.maxHealth) - (right.health / right.maxHealth))[0]
|
||||
const effectSpell = (name: string) => {
|
||||
const ability = gameClass.spells.find((candidate) => candidate.name === name)
|
||||
return ability ? toCombatSpell(ability, `effect-${ability.id}`, healingPower) : null
|
||||
}
|
||||
const renewEffect = effectSpell('Renew')
|
||||
const shieldEffect = effectSpell('Sun Ward')
|
||||
const healingMultiplier = (member: PartyMember) =>
|
||||
activeEffects.has('shielded_healing_bonus') && member.shield > 0 ? 1.2 : 1
|
||||
const directTargets = new Set([targetId])
|
||||
const hotTargets = new Set<string>()
|
||||
const shieldTargets = new Set<string>()
|
||||
if (spell.kind === 'hot') hotTargets.add(targetId)
|
||||
const extraTargets = upgradeStackCount(roguelikeUpgrades, `slot${spell.key as SlotKey}-extra-target` as RoguelikeUpgradeId)
|
||||
const groupTargets = new Set(
|
||||
spell.kind === 'group'
|
||||
? groupHealTargets(current.party, DEFAULT_GROUP_HEAL_TARGETS + extraTargets).map((member) => member.id)
|
||||
: [],
|
||||
)
|
||||
if (spell.kind === 'hot' || spell.effectType === 'direct_hot') hotTargets.add(targetId)
|
||||
if (spell.kind === 'shield') shieldTargets.add(targetId)
|
||||
if (spell.name === 'Mend' && activeSetEffects.has('mend_extra_target')) {
|
||||
if (spell.name === 'Mend' && activeEffects.has('mend_extra_target')) {
|
||||
const extra = extraTarget([targetId])
|
||||
if (extra) directTargets.add(extra.id)
|
||||
}
|
||||
if (spell.name === 'Renew' && activeSetEffects.has('renew_extra_target')) {
|
||||
if (spell.name === 'Renew' && activeEffects.has('renew_extra_target')) {
|
||||
const extra = extraTarget([targetId])
|
||||
if (extra) hotTargets.add(extra.id)
|
||||
}
|
||||
if (spell.name === 'Mend' && activeSetEffects.has('mend_applies_renew')) {
|
||||
if (spell.name === 'Mend' && activeEffects.has('mend_applies_renew')) {
|
||||
hotTargets.add(targetId)
|
||||
}
|
||||
const extraTargets = upgradeStackCount(roguelikeUpgrades, `slot${spell.key as SlotKey}-extra-target` as RoguelikeUpgradeId)
|
||||
for (let index = 0; index < extraTargets; index += 1) {
|
||||
if (spell.kind === 'group') break
|
||||
if (spell.kind === 'hot') {
|
||||
@@ -594,20 +687,61 @@ export function CombatScreen({
|
||||
const nextParty = current.party.map((member) => {
|
||||
if (member.health <= 0) return member
|
||||
if (spell.kind === 'group') {
|
||||
const power = Math.round(spell.power * (1.25 ** upgradeStackCount(roguelikeUpgrades, 'group-heal-boost')))
|
||||
const nextHealth = healMember(member, power)
|
||||
addFloatingHeal(member.id, Math.max(0, nextHealth - member.health))
|
||||
return { ...member, health: nextHealth }
|
||||
}
|
||||
if (!directTargets.has(member.id) && !hotTargets.has(member.id) && !shieldTargets.has(member.id)) return member
|
||||
if (spell.kind === 'shield') {
|
||||
if (!groupTargets.has(member.id)) return member
|
||||
if (spell.effectType === 'party_absorb') {
|
||||
const power = Math.round(spell.power * (1.25 ** upgradeStackCount(roguelikeUpgrades, 'shield-boost')))
|
||||
return { ...member, shield: Math.max(member.shield, power) }
|
||||
}
|
||||
if (spell.effectType === 'party_hot') {
|
||||
return {
|
||||
...member,
|
||||
hotTicks: 0,
|
||||
hotEffects: addHotEffect(member, spell),
|
||||
}
|
||||
}
|
||||
const power = Math.round(spell.power * (1.25 ** upgradeStackCount(roguelikeUpgrades, 'group-heal-boost')))
|
||||
const nextHealth = healMember(member, power, healingMultiplier(member))
|
||||
addFloatingHeal(member.id, Math.max(0, nextHealth - member.health))
|
||||
const nextShield = spell.name === 'Radiance' && activeEffects.has('radiance_applies_shield')
|
||||
? Math.max(member.shield, Math.round((shieldEffect?.power ?? spell.power) * 0.3))
|
||||
: member.shield
|
||||
return {
|
||||
...member,
|
||||
health: nextHealth,
|
||||
shield: nextShield,
|
||||
hotTicks: spell.name === 'Radiance' && activeEffects.has('radiance_applies_renew') ? 0 : member.hotTicks,
|
||||
hotEffects: spell.name === 'Radiance' && activeEffects.has('radiance_applies_renew') && renewEffect
|
||||
? addHotEffect(member, renewEffect, 3)
|
||||
: member.hotEffects,
|
||||
}
|
||||
}
|
||||
if (
|
||||
!directTargets.has(member.id)
|
||||
&& !hotTargets.has(member.id)
|
||||
&& !shieldTargets.has(member.id)
|
||||
&& !(member.id === targetId && (spell.kind === 'damage_reduction' || spell.kind === 'bounce_heal'))
|
||||
) return member
|
||||
if (spell.kind === 'shield') {
|
||||
const power = Math.round(spell.power * (1.25 ** upgradeStackCount(roguelikeUpgrades, 'shield-boost')))
|
||||
return {
|
||||
...member,
|
||||
hotTicks: activeEffects.has('shield_applies_renew') && renewEffect ? 0 : member.hotTicks,
|
||||
hotEffects: activeEffects.has('shield_applies_renew') && renewEffect
|
||||
? addHotEffect(member, renewEffect)
|
||||
: member.hotEffects,
|
||||
shield: Math.max(member.shield, power),
|
||||
}
|
||||
}
|
||||
if (spell.kind === 'damage_reduction') {
|
||||
return { ...member, damageReductionTicks: 12 }
|
||||
}
|
||||
if (spell.kind === 'bounce_heal') {
|
||||
return { ...member, bounceHeals: addBounceHeal(member, spell) }
|
||||
}
|
||||
if (spell.kind === 'cleanse') {
|
||||
return {
|
||||
...member,
|
||||
health: healMember(member, spell.power),
|
||||
health: healMember(member, spell.power, healingMultiplier(member)),
|
||||
debuff: undefined,
|
||||
debuffTicks: undefined,
|
||||
poisonStacks: undefined,
|
||||
@@ -616,13 +750,23 @@ export function CombatScreen({
|
||||
}
|
||||
}
|
||||
const nextHealth = directTargets.has(member.id)
|
||||
? healMember(member, spell.power)
|
||||
? healMember(member, spell.power, healingMultiplier(member))
|
||||
: member.health
|
||||
if (nextHealth > member.health) addFloatingHeal(member.id, nextHealth - member.health)
|
||||
const nextShield = spell.name === 'Mend' && directTargets.has(member.id) && activeEffects.has('mend_applies_shield')
|
||||
? Math.max(member.shield, Math.round((shieldEffect?.power ?? spell.power) * 0.5))
|
||||
: member.shield
|
||||
const appliedHotSpell = spell.name === 'Mend' && activeEffects.has('mend_applies_renew') && renewEffect
|
||||
? renewEffect
|
||||
: spell
|
||||
return {
|
||||
...member,
|
||||
health: nextHealth,
|
||||
hotTicks: hotTargets.has(member.id) ? 5 : member.hotTicks,
|
||||
shield: nextShield,
|
||||
hotTicks: 0,
|
||||
hotEffects: hotTargets.has(member.id)
|
||||
? addHotEffect(member, appliedHotSpell)
|
||||
: member.hotEffects,
|
||||
}
|
||||
})
|
||||
const freeCastStacks = upgradeStackCount(roguelikeUpgrades, 'fifth-cast-free')
|
||||
@@ -640,20 +784,26 @@ export function CombatScreen({
|
||||
&& !current.freeCastReady
|
||||
&& current.castsTowardFree + 1 >= 5
|
||||
resourceSpentRef.current += effectiveCost
|
||||
const nextCooldowns = {
|
||||
...current.cooldowns,
|
||||
}
|
||||
if (spell.name === 'Mend' && activeEffects.has('mend_reduces_radiance_cooldown')) {
|
||||
const radiance = spells.find((candidate) => candidate.name === 'Radiance')
|
||||
if (radiance) nextCooldowns[radiance.id] = Math.max(0, (nextCooldowns[radiance.id] ?? 0) - 2)
|
||||
}
|
||||
nextCooldowns[spell.id] = spell.cooldown * cooldownMultiplier(spell, roguelikeUpgrades)
|
||||
|
||||
setCombat({
|
||||
...current,
|
||||
party: nextParty,
|
||||
resource: current.resource - effectiveCost,
|
||||
cooldowns: {
|
||||
...current.cooldowns,
|
||||
[spell.id]: spell.cooldown * cooldownMultiplier(spell, roguelikeUpgrades),
|
||||
},
|
||||
cooldowns: nextCooldowns,
|
||||
castsTowardFree: nextCastsTowardFree,
|
||||
freeCastReady: gainedFreeCast || nextFreeCastReady,
|
||||
})
|
||||
addLog(`${spell.name} cast on ${spell.kind === 'group' ? 'the party' : selected.name}${effectiveCost === 0 ? ' for free' : ''}.`, 'heal')
|
||||
},
|
||||
[activeSetEffects, addFloatingHeal, addLog, roguelikeUpgrades, setCombat, status],
|
||||
[activeEffects, addFloatingHeal, addLog, gameClass.spells, healingPower, roguelikeUpgrades, setCombat, spells, status],
|
||||
)
|
||||
|
||||
const finishRun = useCallback(
|
||||
@@ -676,6 +826,7 @@ export function CombatScreen({
|
||||
completedPart,
|
||||
runStartPart,
|
||||
[partDuration(1), partDuration(2), partDuration(3)],
|
||||
hardMode,
|
||||
)
|
||||
.then((result) => {
|
||||
setReward(result)
|
||||
@@ -688,7 +839,7 @@ export function CombatScreen({
|
||||
)
|
||||
})
|
||||
},
|
||||
[difficulty.id, dungeon.id, onProfileUpdated],
|
||||
[difficulty.id, dungeon.id, hardMode, onProfileUpdated],
|
||||
)
|
||||
|
||||
const finishRoguelikeRun = useCallback(
|
||||
@@ -786,6 +937,9 @@ export function CombatScreen({
|
||||
poisonStacks: undefined,
|
||||
maxHealthPenaltyTicks: undefined,
|
||||
healingReductionTicks: undefined,
|
||||
hotEffects: [],
|
||||
bounceHeals: [],
|
||||
damageReductionTicks: undefined,
|
||||
}))
|
||||
const nextStage = clearedBoss ? roguelikeStage + 1 : roguelikeStage
|
||||
const nextSegment = clearedBoss
|
||||
@@ -804,7 +958,7 @@ export function CombatScreen({
|
||||
setCombat({
|
||||
...current,
|
||||
party: recoveredParty,
|
||||
enemyHealth: nextEncounter.maxHealth,
|
||||
enemyHealth: nextEncounter.maxHealth * enemyCount,
|
||||
elapsedTicks: 0,
|
||||
cooldowns: {},
|
||||
resource: clamp(current.resource + Math.round(maxResource * 0.25), 0, maxResource),
|
||||
@@ -812,9 +966,13 @@ export function CombatScreen({
|
||||
setUpgradeChoices([])
|
||||
setStatus('playing')
|
||||
addLog(`${upgrade.name} gained. ${nextEncounter.enemyName} approaches.`, 'system')
|
||||
}, [addLog, difficulty, encounterIndex, encounters, maxResource, roguelikeMode, roguelikePool, roguelikeStage, setCombat])
|
||||
}, [addLog, difficulty, encounterIndex, encounters, enemyCount, maxResource, roguelikeMode, roguelikePool, roguelikeStage, setCombat])
|
||||
|
||||
useGameAction((action, device) => {
|
||||
if (action === 'toggleSpeed') {
|
||||
if (status === 'playing') setSpeedMultiplier((value) => (value === 1 ? 2 : 1))
|
||||
return
|
||||
}
|
||||
if (action === 'pause' || (action === 'back' && device === 'pc')) {
|
||||
if (status === 'playing') setPaused((value) => !value)
|
||||
return
|
||||
@@ -902,19 +1060,53 @@ export function CombatScreen({
|
||||
}
|
||||
|
||||
const healerBeforeDamage = current.party.find((member) => member.id === 'mira')
|
||||
const nextParty = current.party.map((member) => {
|
||||
const tankPressure = tankPressureTargets(current.party)
|
||||
const tankPressureIds = new Set(tankPressure.targets.map((member) => member.id))
|
||||
const pendingJumpHeals: Array<{
|
||||
targetId: string
|
||||
heal: NonNullable<PartyMember['bounceHeals']>[number]
|
||||
}> = []
|
||||
const damagedParty = current.party.map((member) => {
|
||||
if (member.health <= 0) return member
|
||||
let damage = member.id === primaryTarget.id ? encounter.damage : 0
|
||||
if (member.role === 'Tank') damage += encounter.tankDamage
|
||||
if (tankBuster && member.role === 'Tank') damage += Math.round(22 * difficulty.damageMultiplier)
|
||||
if (tankPressureIds.has(member.id)) {
|
||||
damage += Math.round(encounter.tankDamage * tankPressure.multiplier)
|
||||
}
|
||||
if (tankBuster && tankPressureIds.has(member.id)) {
|
||||
damage += Math.round(22 * difficulty.damageMultiplier * tankPressure.multiplier)
|
||||
}
|
||||
if (bossPulse) damage += Math.round(12 * difficulty.damageMultiplier)
|
||||
if (member.debuff) damage += Math.round(7 * difficulty.damageMultiplier)
|
||||
const nextPoisonStacks = appliesPoison && member.id === primaryTarget.id
|
||||
? Math.max(1, (member.poisonStacks ?? 0) + 1)
|
||||
: member.poisonStacks ?? 0
|
||||
if (nextPoisonStacks > 0) damage += Math.round((4 + nextPoisonStacks * 4) * difficulty.damageMultiplier)
|
||||
damage *= enemyCount
|
||||
if ((member.damageReductionTicks ?? 0) > 0) {
|
||||
damage = Math.round(damage * 0.5)
|
||||
}
|
||||
if (member.shield > 0 && activeEffects.has('shielded_damage_reduction')) {
|
||||
damage = Math.round(damage * 0.8)
|
||||
}
|
||||
const absorbed = Math.min(member.shield, damage)
|
||||
const healing = member.hotTicks > 0 ? healAmount(member, 6) : 0
|
||||
const hotEffects = memberHotEffects(member)
|
||||
const healingMultiplier = member.shield > 0 && activeEffects.has('shielded_healing_bonus') ? 1.2 : 1
|
||||
let healing = hotEffects.reduce((total, effect) => total + healAmount(member, effect.power, healingMultiplier), 0)
|
||||
let nextBounceHeals = [...(member.bounceHeals ?? [])]
|
||||
if (damage > 0 && nextBounceHeals.length > 0) {
|
||||
nextBounceHeals = nextBounceHeals.flatMap((effect) => {
|
||||
healing += healAmount(member, effect.power, healingMultiplier)
|
||||
const nextCharges = effect.charges - 1
|
||||
if (nextCharges <= 0) return []
|
||||
const jumpTargets = current.party.filter((candidate) => candidate.health > 0 && candidate.id !== member.id)
|
||||
const jumpTarget = jumpTargets[Math.floor(Math.random() * jumpTargets.length)] ?? member
|
||||
pendingJumpHeals.push({
|
||||
targetId: jumpTarget.id,
|
||||
heal: { ...effect, charges: nextCharges },
|
||||
})
|
||||
return []
|
||||
})
|
||||
}
|
||||
if (healing > 0) addFloatingHeal(member.id, healing)
|
||||
const nextMaxHealthPenaltyTicks = appliesMaxHealthCut && member.id === primaryTarget.id
|
||||
? 15
|
||||
@@ -928,9 +1120,12 @@ export function CombatScreen({
|
||||
: Math.max(0, (member.debuffTicks ?? 0) - 1)
|
||||
return {
|
||||
...member,
|
||||
health: clamp(member.health - damage + absorbed + healing, 0, nextEffectiveMaxHealth),
|
||||
health: clamp(clamp(member.health + healing, 0, nextEffectiveMaxHealth) - damage + absorbed, 0, nextEffectiveMaxHealth),
|
||||
shield: Math.max(0, member.shield - damage),
|
||||
hotTicks: Math.max(0, member.hotTicks - 1),
|
||||
hotTicks: 0,
|
||||
hotEffects: tickHotEffects(hotEffects),
|
||||
bounceHeals: nextBounceHeals,
|
||||
damageReductionTicks: Math.max(0, (member.damageReductionTicks ?? 0) - 1),
|
||||
debuff: nextDebuffTicks > 0
|
||||
? (appliesDebuff && member.id === primaryTarget.id ? 'Searing Mark' : member.debuff)
|
||||
: undefined,
|
||||
@@ -940,6 +1135,17 @@ export function CombatScreen({
|
||||
healingReductionTicks: nextHealingReductionTicks,
|
||||
}
|
||||
})
|
||||
const nextParty = damagedParty.map((member) => {
|
||||
const jumped = pendingJumpHeals.filter((jump) => jump.targetId === member.id)
|
||||
if (jumped.length === 0) return member
|
||||
return {
|
||||
...member,
|
||||
bounceHeals: [
|
||||
...(member.bounceHeals ?? []),
|
||||
...jumped.map((jump) => jump.heal),
|
||||
],
|
||||
}
|
||||
})
|
||||
const healerAfterDamage = nextParty.find((member) => member.id === 'mira')
|
||||
|
||||
if (
|
||||
@@ -966,7 +1172,7 @@ export function CombatScreen({
|
||||
return
|
||||
}
|
||||
|
||||
const nextEnemyHealth = current.enemyHealth - encounter.partyDamage
|
||||
const nextEnemyHealth = current.enemyHealth - partyDamageOutput(nextParty, encounter.partyDamage)
|
||||
if (nextEnemyHealth > 0) {
|
||||
setCombat({
|
||||
...current,
|
||||
@@ -980,7 +1186,9 @@ export function CombatScreen({
|
||||
}
|
||||
|
||||
if (!isRoguelike && encounter.lootTables.some((entry) => entry.difficultyId === difficulty.id)) {
|
||||
requestLootRoll(encounter.id)
|
||||
for (let rollIndex = 0; rollIndex < enemyCount; rollIndex += 1) {
|
||||
requestLootRoll(encounter.id, rollIndex)
|
||||
}
|
||||
}
|
||||
|
||||
if (isRoguelike && (upgradesEveryEncounter || encounter.isBoss)) {
|
||||
@@ -999,6 +1207,9 @@ export function CombatScreen({
|
||||
}
|
||||
|
||||
if (isPartBoss && !isFinalBoss) {
|
||||
const nextMarathonKills = marathonBossesDefeatedRef.current + 1
|
||||
marathonBossesDefeatedRef.current = nextMarathonKills
|
||||
setMarathonBossesDefeated(nextMarathonKills)
|
||||
setCombat({
|
||||
...current,
|
||||
party: nextParty,
|
||||
@@ -1007,12 +1218,28 @@ export function CombatScreen({
|
||||
elapsedTicks: nextElapsedTicks,
|
||||
enemyHealth: 0,
|
||||
})
|
||||
setStatus('part-complete')
|
||||
setStatus(marathonMode && encounter.isBoss ? 'marathon-choice' : 'part-complete')
|
||||
addLog(`${encounter.enemyName} is defeated.`, 'loot')
|
||||
return
|
||||
}
|
||||
|
||||
if (encounterIndex === encounters.length - 1) {
|
||||
if (marathonMode && encounter.isBoss) {
|
||||
const nextMarathonKills = marathonBossesDefeatedRef.current + 1
|
||||
marathonBossesDefeatedRef.current = nextMarathonKills
|
||||
setMarathonBossesDefeated(nextMarathonKills)
|
||||
setCombat({
|
||||
...current,
|
||||
party: nextParty,
|
||||
resource: nextResource,
|
||||
cooldowns: nextCooldowns,
|
||||
elapsedTicks: nextElapsedTicks,
|
||||
enemyHealth: 0,
|
||||
})
|
||||
setStatus('marathon-choice')
|
||||
addLog(`${encounter.enemyName} is defeated. Continue marathon or end the hunt.`, 'loot')
|
||||
return
|
||||
}
|
||||
setCombat({
|
||||
...current,
|
||||
party: nextParty,
|
||||
@@ -1037,6 +1264,9 @@ export function CombatScreen({
|
||||
poisonStacks: undefined,
|
||||
maxHealthPenaltyTicks: undefined,
|
||||
healingReductionTicks: undefined,
|
||||
hotEffects: [],
|
||||
bounceHeals: [],
|
||||
damageReductionTicks: undefined,
|
||||
}))
|
||||
setEncounterIndex((value) => value + 1)
|
||||
setCombat({
|
||||
@@ -1045,13 +1275,15 @@ export function CombatScreen({
|
||||
resource: nextResource,
|
||||
cooldowns: nextCooldowns,
|
||||
elapsedTicks: 0,
|
||||
enemyHealth: nextEncounter.maxHealth,
|
||||
enemyHealth: nextEncounter.maxHealth * enemyCount,
|
||||
})
|
||||
addLog(`${encounter.enemyName} defeated. ${nextEncounter.enemyName} approaches.`, 'system')
|
||||
}, [
|
||||
activeEffects,
|
||||
addLog,
|
||||
addFloatingHeal,
|
||||
difficulty.damageMultiplier,
|
||||
enemyCount,
|
||||
encounter,
|
||||
encounterIndex,
|
||||
encounters,
|
||||
@@ -1060,6 +1292,7 @@ export function CombatScreen({
|
||||
isPartBoss,
|
||||
isFinalBoss,
|
||||
isRoguelike,
|
||||
marathonMode,
|
||||
upgradesEveryEncounter,
|
||||
roguelikeUpgradeCatalog,
|
||||
roguelikeUpgrades,
|
||||
@@ -1095,9 +1328,10 @@ export function CombatScreen({
|
||||
|| pausedRef.current
|
||||
) return
|
||||
const now = performance.now()
|
||||
const dueTicks = Math.min(4, Math.floor((now - lastCombatTickAtRef.current) / TICK_MS))
|
||||
const tickMs = TICK_MS / speedMultiplierRef.current
|
||||
const dueTicks = Math.min(8, Math.floor((now - lastCombatTickAtRef.current) / tickMs))
|
||||
if (dueTicks <= 0) return
|
||||
lastCombatTickAtRef.current += dueTicks * TICK_MS
|
||||
lastCombatTickAtRef.current += dueTicks * tickMs
|
||||
for (let index = 0; index < dueTicks; index += 1) {
|
||||
if (statusRef.current !== 'playing' || pausedRef.current) return
|
||||
runCombatTickRef.current()
|
||||
@@ -1120,15 +1354,23 @@ export function CombatScreen({
|
||||
})
|
||||
}, [expectedLootRolls, lootRolls.length, onProfileUpdated, reward])
|
||||
|
||||
const enemyPercent = (enemyHealth / encounter.maxHealth) * 100
|
||||
const enemyPercent = (enemyHealth / encounterMaxHealth) * 100
|
||||
const enemyHealthSegments = Array.from({ length: enemyCount }, (_, index) => {
|
||||
const remaining = clamp(enemyHealth - encounter.maxHealth * index, 0, encounter.maxHealth)
|
||||
return {
|
||||
index,
|
||||
health: remaining,
|
||||
percent: (remaining / encounter.maxHealth) * 100,
|
||||
}
|
||||
}).reverse()
|
||||
const dualScreenState = useMemo<DualScreenCombatState>(() => ({
|
||||
difficultyName: difficulty.name,
|
||||
dungeonName: dungeon.name,
|
||||
dungeonName: hardMode ? `${dungeon.name} Hard` : dungeon.name,
|
||||
contentName,
|
||||
encounterName: encounter.enemyName,
|
||||
encounterDescription: encounter.description,
|
||||
encounterHealth: enemyHealth,
|
||||
encounterMaxHealth: encounter.maxHealth,
|
||||
encounterMaxHealth,
|
||||
encounterIsBoss: encounter.isBoss,
|
||||
encounterIndex,
|
||||
encounterCount: encounters.length,
|
||||
@@ -1158,6 +1400,7 @@ export function CombatScreen({
|
||||
directPartyTargeting,
|
||||
paused,
|
||||
targetGroup,
|
||||
speedMultiplier,
|
||||
}), [
|
||||
bindings,
|
||||
controllerIconStyle,
|
||||
@@ -1170,7 +1413,8 @@ export function CombatScreen({
|
||||
encounter.description,
|
||||
encounter.enemyName,
|
||||
encounter.isBoss,
|
||||
encounter.maxHealth,
|
||||
encounterMaxHealth,
|
||||
hardMode,
|
||||
enemyHealth,
|
||||
encounterIndex,
|
||||
encounters.length,
|
||||
@@ -1187,6 +1431,7 @@ export function CombatScreen({
|
||||
spells,
|
||||
freeCastReady,
|
||||
roguelikeUpgrades,
|
||||
speedMultiplier,
|
||||
status,
|
||||
targetGroup,
|
||||
])
|
||||
@@ -1199,7 +1444,7 @@ export function CombatScreen({
|
||||
>
|
||||
{!dualScreenEnabled && <header className="topbar">
|
||||
<div>
|
||||
<p className="eyebrow">{difficulty.name} - Item Level {difficulty.droppedItemLevel}</p>
|
||||
<p className="eyebrow">{difficulty.name}{hardMode ? ' Hard' : ''} - Item Level {difficulty.droppedItemLevel}</p>
|
||||
<h1>{dungeon.name}</h1>
|
||||
</div>
|
||||
<div className="combat-header-actions">
|
||||
@@ -1222,10 +1467,21 @@ export function CombatScreen({
|
||||
</div>
|
||||
<div className="enemy-info">
|
||||
<div className="bar-label">
|
||||
<strong>{encounter.enemyName}</strong>
|
||||
<span>{Math.ceil(enemyHealth)} / {encounter.maxHealth}</span>
|
||||
<strong>{hardMode ? `${encounter.enemyName} x2` : encounter.enemyName}</strong>
|
||||
<span>{Math.ceil(enemyHealth)} / {encounterMaxHealth}</span>
|
||||
</div>
|
||||
{hardMode ? (
|
||||
<div className="hard-enemy-bars">
|
||||
{enemyHealthSegments.map((segment) => (
|
||||
<div className="bar enemy-health" key={segment.index}>
|
||||
<span style={{ width: `${segment.percent}%` }} />
|
||||
<em>{encounter.enemyName} {segment.index + 1}: {Math.ceil(segment.health)} / {encounter.maxHealth}</em>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bar enemy-health"><span style={{ width: `${enemyPercent}%` }} /></div>
|
||||
)}
|
||||
<p>{encounter.description}</p>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1239,6 +1495,7 @@ export function CombatScreen({
|
||||
? `${gameClass.resourceName} ${Math.floor(resource)} / ${maxResource}`
|
||||
: `${profile.character.name} is defeated`}
|
||||
</span>
|
||||
{speedMultiplier === 2 && <strong className="speed-badge">2x speed</strong>}
|
||||
<div className="bar mana-bar"><span style={{ width: `${(resource / maxResource) * 100}%` }} /></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1272,7 +1529,14 @@ export function CombatScreen({
|
||||
.map((entry) => <span className="floating-heal" key={entry.id}>+{entry.value}</span>)}
|
||||
</div>
|
||||
<div className="member-effects">
|
||||
{member.hotTicks > 0 && <span className="buff">Renew {formatEffectTime(member.hotTicks)}</span>}
|
||||
{memberHotEffects(member).map((effect) => (
|
||||
<span className="buff" key={effect.id}>{effect.label} {formatEffectTime(effect.ticks)}</span>
|
||||
))}
|
||||
{member.shield > 0 && <span className="buff">Shield {Math.ceil(member.shield)}</span>}
|
||||
{(member.damageReductionTicks ?? 0) > 0 && <span className="buff">Barkskin {formatEffectTime(member.damageReductionTicks ?? 0)}</span>}
|
||||
{(member.bounceHeals ?? []).map((effect) => (
|
||||
<span className="buff" key={effect.id}>{effect.label} {effect.charges}</span>
|
||||
))}
|
||||
{member.debuff && member.debuffTicks && <span className="debuff">{member.debuff} {formatEffectTime(member.debuffTicks)}</span>}
|
||||
{member.poisonStacks && member.poisonStacks > 0 && <span className="debuff">Poison {member.poisonStacks}</span>}
|
||||
{member.maxHealthPenaltyTicks && member.maxHealthPenaltyTicks > 0 && <span className="debuff">Max HP -25% {formatEffectTime(member.maxHealthPenaltyTicks)}</span>}
|
||||
@@ -1351,7 +1615,7 @@ export function CombatScreen({
|
||||
|
||||
{status === 'upgrade-choice' && (
|
||||
<div className="result-screen">
|
||||
<div>
|
||||
<div className="pvp-upgrade-dialog pve-upgrade-dialog">
|
||||
<p className="eyebrow">
|
||||
{encounter.isBoss
|
||||
? `Roguelike Stage ${roguelikeStage} Complete`
|
||||
@@ -1359,6 +1623,9 @@ export function CombatScreen({
|
||||
</p>
|
||||
<h2>Choose Upgrade</h2>
|
||||
<p>Pick one upgrade before the next fight.</p>
|
||||
<div className="pvp-choice-columns">
|
||||
<div>
|
||||
<strong>Run Buff</strong>
|
||||
<div className="upgrade-choice-grid">
|
||||
{upgradeChoices.map((upgrade) => (
|
||||
<button key={upgrade.id} onClick={() => chooseRoguelikeUpgrade(upgrade)} type="button">
|
||||
@@ -1367,6 +1634,8 @@ export function CombatScreen({
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{roguelikeUpgrades.length > 0 && (
|
||||
<p className="roguelike-upgrade-list">
|
||||
Active: {summarizeUpgradeStacks(roguelikeUpgrades, roguelikeUpgradeCatalog)}
|
||||
@@ -1377,7 +1646,7 @@ export function CombatScreen({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status !== 'playing' && status !== 'part-complete' && status !== 'upgrade-choice' && (
|
||||
{status !== 'playing' && status !== 'part-complete' && status !== 'marathon-choice' && status !== 'upgrade-choice' && (
|
||||
<div className="result-screen">
|
||||
<div>
|
||||
<p className="eyebrow">{status === 'won' ? `${contentName} Complete` : 'Party Defeated'}</p>
|
||||
@@ -1492,12 +1761,43 @@ export function CombatScreen({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{status === 'marathon-choice' && (
|
||||
<div className="result-screen">
|
||||
<div>
|
||||
<p className="eyebrow">Marathon</p>
|
||||
<h2>{encounter.enemyName} Defeated</h2>
|
||||
<p>
|
||||
{marathonBossesDefeated} boss{marathonBossesDefeated === 1 ? '' : 'es'} defeated.
|
||||
Continue with current health and {gameClass.resourceName}, or end the hunt.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
const current = combatRef.current
|
||||
setCombat({
|
||||
...current,
|
||||
enemyHealth: encounter.maxHealth * enemyCount,
|
||||
elapsedTicks: 0,
|
||||
})
|
||||
setStatus('playing')
|
||||
addLog(`Marathon continues. Another ${encounter.enemyName} appears.`, 'danger')
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Continue Marathon
|
||||
</button>
|
||||
<button className="secondary-result-button" onClick={() => finishRun(currentPart, startPart)} type="button">
|
||||
End
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{status === 'part-complete' && (
|
||||
<div className="result-screen">
|
||||
<div>
|
||||
<p className="eyebrow">{sectionName} Complete</p>
|
||||
<h2>{encounter.enemyName} Defeated</h2>
|
||||
<p>Proceed to {sectionName} {currentPart + 1} or end the run?</p>
|
||||
<p>{canContinueAfterPart ? `Proceed to ${sectionName} ${currentPart + 1} or end the run?` : 'Hard mode for this section is complete.'}</p>
|
||||
{canContinueAfterPart && (
|
||||
<button
|
||||
onClick={() => {
|
||||
const nextIndex = encounterIndex + 1
|
||||
@@ -1509,12 +1809,18 @@ export function CombatScreen({
|
||||
health: clamp(member.health + 35, 0, member.maxHealth),
|
||||
debuff: undefined,
|
||||
debuffTicks: undefined,
|
||||
poisonStacks: undefined,
|
||||
maxHealthPenaltyTicks: undefined,
|
||||
healingReductionTicks: undefined,
|
||||
hotEffects: [],
|
||||
bounceHeals: [],
|
||||
damageReductionTicks: undefined,
|
||||
}))
|
||||
setEncounterIndex(nextIndex)
|
||||
setCombat({
|
||||
...current,
|
||||
party: recoveredParty,
|
||||
enemyHealth: nextEncounter.maxHealth,
|
||||
enemyHealth: nextEncounter.maxHealth * enemyCount,
|
||||
elapsedTicks: 0,
|
||||
})
|
||||
setStatus('playing')
|
||||
@@ -1524,6 +1830,7 @@ export function CombatScreen({
|
||||
>
|
||||
Continue to {sectionName} {currentPart + 1}
|
||||
</button>
|
||||
)}
|
||||
<button className="secondary-result-button" onClick={() => finishRun(currentPart, startPart)} type="button">
|
||||
End Run
|
||||
</button>
|
||||
|
||||
@@ -40,7 +40,7 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
|
||||
function chooseClass(nextClass: GameClass) {
|
||||
const starterAbilities = nextClass.spells
|
||||
.filter((ability) => ability.unlockLevel <= profile.character.level)
|
||||
.slice(0, 5)
|
||||
.slice(0, 6)
|
||||
.map((ability) => ability.id)
|
||||
setClassId(nextClass.id)
|
||||
setSlots([...starterAbilities, ...Array(6 - starterAbilities.length).fill(null)])
|
||||
|
||||
@@ -28,6 +28,7 @@ const EQUIPMENT_LIST_PAGE_SIZE = 3
|
||||
const CRAFTING_LIST_PAGE_SIZE = 3
|
||||
const CRAFTING_FILTER_SLOTS = (Object.keys(SLOT_LABELS) as EquipmentSlot[])
|
||||
.filter((slot) => slot !== 'component')
|
||||
const DIRECT_CRAFT_ITEM_LEVELS = new Set([1, 10, 20, 25])
|
||||
|
||||
type Props = {
|
||||
profile: CharacterProfile
|
||||
@@ -68,18 +69,17 @@ export function EquipmentScreen({
|
||||
const [message, setMessage] = useState('')
|
||||
const scrollRef = useRef<number>(0)
|
||||
const selectedItem = profile.inventory.find((item) => item.id === selectedItemId)
|
||||
const firstRecipe = profile.craftingRecipes.find((recipe) => recipe.canCraft)
|
||||
?? profile.craftingRecipes[0]
|
||||
const craftableRecipes = profile.craftingRecipes.filter((recipe) =>
|
||||
DIRECT_CRAFT_ITEM_LEVELS.has(recipe.item.itemLevel),
|
||||
)
|
||||
const firstRecipe = craftableRecipes.find((recipe) => recipe.canCraft)
|
||||
?? craftableRecipes[0]
|
||||
const [selectedRecipeId, setSelectedRecipeId] = useState<number | null>(
|
||||
firstRecipe?.id ?? null,
|
||||
)
|
||||
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,
|
||||
)
|
||||
? !DIRECT_CRAFT_ITEM_LEVELS.has(selectedRecipe.item.itemLevel)
|
||||
: false
|
||||
const selectedItemRecipe = selectedItem
|
||||
? profile.craftingRecipes.find((recipe) => recipe.item.id === selectedItem.id)
|
||||
@@ -126,12 +126,14 @@ export function EquipmentScreen({
|
||||
const [slotFilter, setSlotFilter] = useState<EquipmentSlot | 'all'>('all')
|
||||
const [levelFilter, setLevelFilter] = useState<number | null>(null)
|
||||
const availableLevels = useMemo(
|
||||
() => [...new Set(profile.craftingRecipes.map((r) => r.item.itemLevel))].sort((a, b) => b - a),
|
||||
() => [...new Set(profile.craftingRecipes
|
||||
.filter((r) => DIRECT_CRAFT_ITEM_LEVELS.has(r.item.itemLevel))
|
||||
.map((r) => r.item.itemLevel))].sort((a, b) => b - a),
|
||||
[profile.craftingRecipes],
|
||||
)
|
||||
const filteredRecipes = useMemo(
|
||||
() => {
|
||||
let result = [...profile.craftingRecipes]
|
||||
let result = profile.craftingRecipes.filter((r) => DIRECT_CRAFT_ITEM_LEVELS.has(r.item.itemLevel))
|
||||
if (slotFilter !== 'all') result = result.filter((r) => r.item.slot === slotFilter)
|
||||
if (levelFilter !== null) result = result.filter((r) => r.item.itemLevel === levelFilter)
|
||||
result.sort((a, b) => b.item.itemLevel - a.item.itemLevel)
|
||||
@@ -144,7 +146,10 @@ export function EquipmentScreen({
|
||||
() => new Map(
|
||||
(Object.keys(SLOT_LABELS) as EquipmentSlot[]).map((slot) => [
|
||||
slot,
|
||||
profile.craftingRecipes.filter((recipe) => recipe.item.slot === slot).length,
|
||||
profile.craftingRecipes.filter((recipe) =>
|
||||
recipe.item.slot === slot
|
||||
&& DIRECT_CRAFT_ITEM_LEVELS.has(recipe.item.itemLevel),
|
||||
).length,
|
||||
]),
|
||||
),
|
||||
[profile.craftingRecipes],
|
||||
@@ -589,7 +594,7 @@ export function EquipmentScreen({
|
||||
type="button"
|
||||
>
|
||||
<strong>All</strong>
|
||||
<span>{profile.craftingRecipes.length}</span>
|
||||
<span>{profile.craftingRecipes.filter((recipe) => DIRECT_CRAFT_ITEM_LEVELS.has(recipe.item.itemLevel)).length}</span>
|
||||
</button>
|
||||
{CRAFTING_FILTER_SLOTS.map((slot) => (
|
||||
<button
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { INITIAL_PARTY, RAID_PARTY, type CombatLogEntry, type PartyMember, type Spell } from '../game'
|
||||
import {
|
||||
INITIAL_PARTY,
|
||||
RAID_PARTY,
|
||||
DEFAULT_GROUP_HEAL_TARGETS,
|
||||
groupHealTargets,
|
||||
partyDamageOutput,
|
||||
tankPressureTargets,
|
||||
type CombatLogEntry,
|
||||
type PartyMember,
|
||||
type Spell,
|
||||
} from '../game'
|
||||
import { completeRoguelike, type DungeonReward } from '../profile'
|
||||
import type { Ability, CharacterProfile, DungeonEncounter } from '../profile'
|
||||
import type { GameMode } from '../gameRepository'
|
||||
@@ -34,7 +44,7 @@ type PvpEncounter = DungeonEncounter & {
|
||||
sourceEncounterId?: number
|
||||
}
|
||||
|
||||
type SlotKey = '1' | '2' | '3' | '4' | '5'
|
||||
type SlotKey = '1' | '2' | '3' | '4' | '5' | '6'
|
||||
type AbilityLabelMode = 'ability' | 'slot'
|
||||
|
||||
type SelfBuffId =
|
||||
@@ -78,6 +88,17 @@ type FloatingCombatText = {
|
||||
value: number
|
||||
}
|
||||
|
||||
type PvpRunSummary = {
|
||||
bossesKilled: number
|
||||
experienceGained: number
|
||||
previousLevel: number | null
|
||||
newLevel: number | null
|
||||
levelsGained: number
|
||||
talentPointsGained: number
|
||||
unlockedAbilities: DungeonReward['unlockedAbilities']
|
||||
loot: Array<NonNullable<DungeonReward['bonusItem']>>
|
||||
}
|
||||
|
||||
const BOSS_MECHANICS: BossMechanic[] = [
|
||||
'party-pulse',
|
||||
'searing-mark',
|
||||
@@ -119,6 +140,19 @@ function formatEffectTime(ticks: number) {
|
||||
return Number.isInteger(seconds) ? `${seconds}s` : `${seconds.toFixed(1)}s`
|
||||
}
|
||||
|
||||
function createEmptyPvpRunSummary(): PvpRunSummary {
|
||||
return {
|
||||
bossesKilled: 0,
|
||||
experienceGained: 0,
|
||||
previousLevel: null,
|
||||
newLevel: null,
|
||||
levelsGained: 0,
|
||||
talentPointsGained: 0,
|
||||
unlockedAbilities: [],
|
||||
loot: [],
|
||||
}
|
||||
}
|
||||
|
||||
function buffStacks<T extends string>(items: T[], id: T) {
|
||||
return items.filter((item) => item === id).length
|
||||
}
|
||||
@@ -130,7 +164,7 @@ function slotLabel(slot: SlotKey, spells: Spell[], labelMode: AbilityLabelMode)
|
||||
}
|
||||
|
||||
function buildSelfBuffChoices(spells: Spell[], labelMode: AbilityLabelMode): Array<Choice<SelfBuffId>> {
|
||||
const slotChoices = (['1', '2', '3', '4', '5'] as SlotKey[]).flatMap((slot) => {
|
||||
const slotChoices = (['1', '2', '3', '4', '5', '6'] as SlotKey[]).flatMap((slot) => {
|
||||
const label = slotLabel(slot, spells, labelMode)
|
||||
return [
|
||||
{
|
||||
@@ -159,7 +193,7 @@ function buildSelfBuffChoices(spells: Spell[], labelMode: AbilityLabelMode): Arr
|
||||
}
|
||||
|
||||
function buildOpponentDebuffChoices(spells: Spell[], labelMode: AbilityLabelMode): Array<Choice<OpponentDebuffId>> {
|
||||
const slotChoices = (['1', '2', '3', '4', '5'] as SlotKey[]).flatMap((slot) => {
|
||||
const slotChoices = (['1', '2', '3', '4', '5', '6'] as SlotKey[]).flatMap((slot) => {
|
||||
const label = slotLabel(slot, spells, labelMode)
|
||||
return [
|
||||
{
|
||||
@@ -217,13 +251,13 @@ function outgoingHealMultiplier(debuffs: OpponentDebuffId[]) {
|
||||
return 0.85 ** buffStacks(debuffs, 'opp-healing-reduced')
|
||||
}
|
||||
|
||||
function healAmount(member: PartyMember, amount: number, debuffs: OpponentDebuffId[]) {
|
||||
function healAmount(member: PartyMember, amount: number, debuffs: OpponentDebuffId[], multiplier = 1) {
|
||||
const healingReduction = member.healingReductionTicks && member.healingReductionTicks > 0 ? 0.75 : 1
|
||||
return Math.round(amount * healingReduction * outgoingHealMultiplier(debuffs))
|
||||
return Math.round(amount * healingReduction * outgoingHealMultiplier(debuffs) * multiplier)
|
||||
}
|
||||
|
||||
function healMember(member: PartyMember, amount: number, debuffs: OpponentDebuffId[]) {
|
||||
return clamp(member.health + healAmount(member, amount, debuffs), 0, effectiveMaxHealth(member))
|
||||
function healMember(member: PartyMember, amount: number, debuffs: OpponentDebuffId[], multiplier = 1) {
|
||||
return clamp(member.health + healAmount(member, amount, debuffs, multiplier), 0, effectiveMaxHealth(member))
|
||||
}
|
||||
|
||||
function cooldownMultiplier(spell: Spell, buffs: SelfBuffId[], debuffs: OpponentDebuffId[]) {
|
||||
@@ -300,7 +334,7 @@ function scoreSelfBuff(buff: Choice<SelfBuffId>, spells: Spell[]) {
|
||||
if (buff.id === 'fifth-cast-free') return 8
|
||||
if (buff.id === 'group-heal-boost') return 8
|
||||
if (buff.id === 'shield-boost') return 6
|
||||
const slot = buff.id.match(/slot([1-5])/i)?.[1] as SlotKey | undefined
|
||||
const slot = buff.id.match(/slot([1-6])/i)?.[1] as SlotKey | undefined
|
||||
const spell = spells.find((candidate) => candidate.key === slot)
|
||||
if (!spell) return 5
|
||||
if (buff.id.endsWith('extra-target')) {
|
||||
@@ -374,7 +408,7 @@ export function PvPRoguelikeScreen({
|
||||
const gameClass = profile.classes.find((candidate) => candidate.id === profile.character.classId)!
|
||||
const starterSpells = useMemo(() => gameClass.spells
|
||||
.filter((spell) => spell.unlockLevel === 1)
|
||||
.slice(0, 5)
|
||||
.slice(0, 6)
|
||||
.map((spell, index) => toCombatSpell(spell, String(index + 1))), [gameClass.spells])
|
||||
const [abilityLabelMode] = useState<AbilityLabelMode>('ability')
|
||||
const selfBuffChoicesCatalog = useMemo(
|
||||
@@ -411,11 +445,14 @@ export function PvPRoguelikeScreen({
|
||||
const [playerSide, setPlayerSide] = useState<SideState>(() => starterSide(partyTemplate, maxResource))
|
||||
const [cpuSide, setCpuSide] = useState<SideState>(() => starterSide(cpuPartyTemplate, maxResource))
|
||||
const [selectedId, setSelectedId] = useState(partyTemplate[0].id)
|
||||
const selectedIdRef = useRef(partyTemplate[0].id)
|
||||
const [speedMultiplier, setSpeedMultiplier] = useState<1 | 2>(1)
|
||||
const [elapsedTicks, setElapsedTicks] = useState(0)
|
||||
const [cpuDifficulty, setCpuDifficulty] = useState<CpuDifficulty | null>(null)
|
||||
const [queueMessage, setQueueMessage] = useState('')
|
||||
const [log, setLog] = useState<CombatLogEntry[]>([{ id: 1, text: 'Queueing opponent...', tone: 'system' }])
|
||||
const [reward, setReward] = useState<DungeonReward | null>(null)
|
||||
const [runSummary, setRunSummary] = useState<PvpRunSummary>(() => createEmptyPvpRunSummary())
|
||||
const [rewardError, setRewardError] = useState('')
|
||||
const [showEndLog, setShowEndLog] = useState(false)
|
||||
const [floatingTexts, setFloatingTexts] = useState<FloatingCombatText[]>([])
|
||||
@@ -433,6 +470,8 @@ export function PvPRoguelikeScreen({
|
||||
const bossRewardClaimedRef = useRef(new Set<number>())
|
||||
const cpuDefeatedRef = useRef(false)
|
||||
const playerClearedEncounterRef = useRef(-1)
|
||||
const queuedMatchRef = useRef(false)
|
||||
const encounterPoolRef = useRef(encounterPool)
|
||||
const playerRef = useRef(playerSide)
|
||||
const cpuRef = useRef(cpuSide)
|
||||
const encounter = encounters[encounterIndex]
|
||||
@@ -445,6 +484,14 @@ export function PvPRoguelikeScreen({
|
||||
? Math.max(encountersCleared, encounterIndex + 1)
|
||||
: encountersCleared
|
||||
const cpuBehavior = cpuDifficulty ? CPU_BEHAVIOR[cpuDifficulty] : CPU_BEHAVIOR[1]
|
||||
const activeSpellEffects = useMemo(
|
||||
() => new Set(
|
||||
gameClass.talents
|
||||
.filter((talent) => talent.rank > 0)
|
||||
.map((talent) => talent.effectType),
|
||||
),
|
||||
[gameClass.talents],
|
||||
)
|
||||
const playerDone = playerSide.enemyHealth <= 0
|
||||
const cpuDone = cpuSide.enemyHealth <= 0
|
||||
const playerAlive = playerSide.party.some((member) => member.health > 0)
|
||||
@@ -459,6 +506,12 @@ export function PvPRoguelikeScreen({
|
||||
const {
|
||||
enabled: dualScreenEnabled,
|
||||
} = useDualScreen()
|
||||
|
||||
const setSelectedTargetId = useCallback((id: string) => {
|
||||
selectedIdRef.current = id
|
||||
setSelectedId(id)
|
||||
}, [])
|
||||
|
||||
const addLog = useCallback((text: string, tone: CombatLogEntry['tone']) => {
|
||||
setLog((current) => [createLogEntry(nextLogId, text, tone), ...current].slice(0, 60))
|
||||
}, [])
|
||||
@@ -473,11 +526,16 @@ export function PvPRoguelikeScreen({
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (queuedMatchRef.current) return
|
||||
const loadedCheckpoint = loadPvpRoguelikeCheckpoint(profile.character.id, contentType)
|
||||
setCheckpointStage(loadedCheckpoint)
|
||||
setStartStage(loadedCheckpoint)
|
||||
}, [contentType, profile.character.id])
|
||||
|
||||
useEffect(() => {
|
||||
encounterPoolRef.current = encounterPool
|
||||
}, [encounterPool])
|
||||
|
||||
const awardBossReward = useCallback((encounterIndexValue: number) => {
|
||||
if (bossRewardClaimedRef.current.has(encounterIndexValue)) return
|
||||
bossRewardClaimedRef.current.add(encounterIndexValue)
|
||||
@@ -497,6 +555,20 @@ export function PvPRoguelikeScreen({
|
||||
)
|
||||
.then((result) => {
|
||||
setReward(result)
|
||||
setRunSummary((current) => {
|
||||
const unlockedById = new Map(current.unlockedAbilities.map((ability) => [ability.id, ability]))
|
||||
result.unlockedAbilities.forEach((ability) => unlockedById.set(ability.id, ability))
|
||||
return {
|
||||
bossesKilled: current.bossesKilled + 1,
|
||||
experienceGained: current.experienceGained + result.experienceGained,
|
||||
previousLevel: current.previousLevel ?? result.previousLevel,
|
||||
newLevel: result.newLevel,
|
||||
levelsGained: current.levelsGained + result.levelsGained,
|
||||
talentPointsGained: current.talentPointsGained + result.talentPointsGained,
|
||||
unlockedAbilities: Array.from(unlockedById.values()),
|
||||
loot: result.bonusItem ? [...current.loot, result.bonusItem] : current.loot,
|
||||
}
|
||||
})
|
||||
onProfileUpdated(result.profile)
|
||||
if (result.bonusItem) {
|
||||
addLog(
|
||||
@@ -532,8 +604,9 @@ export function PvPRoguelikeScreen({
|
||||
: null)
|
||||
}, [opponentDebuffChoicesCatalog, selfBuffChoicesCatalog])
|
||||
|
||||
useEffect(() => {
|
||||
const firstSegment = buildEncounterSegment(encounterPool, startStage, contentType)
|
||||
const startMatch = useCallback((nextStartStage?: number) => {
|
||||
const matchStartStage = nextStartStage ?? loadPvpRoguelikeCheckpoint(profile.character.id, contentType)
|
||||
const firstSegment = buildEncounterSegment(encounterPoolRef.current, matchStartStage, contentType)
|
||||
const firstEncounter = firstSegment[0]
|
||||
const basePlayer = starterSide(partyTemplate, maxResource)
|
||||
const baseCpu = starterSide(cpuPartyTemplate, maxResource)
|
||||
@@ -543,15 +616,18 @@ export function PvPRoguelikeScreen({
|
||||
cpuRef.current = baseCpu
|
||||
nextLogId.current = 2
|
||||
playerClearedEncounterRef.current = -1
|
||||
queuedMatchRef.current = true
|
||||
bossRewardClaimedRef.current = new Set()
|
||||
setEncounters(firstSegment)
|
||||
setEncounterIndex(0)
|
||||
setStage(startStage)
|
||||
setCheckpointStage(matchStartStage)
|
||||
setStartStage(matchStartStage)
|
||||
setStage(matchStartStage)
|
||||
setElapsedTicks(0)
|
||||
setStatus('queueing')
|
||||
setPlayerSide(basePlayer)
|
||||
setCpuSide(baseCpu)
|
||||
setSelectedId(partyTemplate[0].id)
|
||||
setSelectedTargetId(partyTemplate[0].id)
|
||||
setPlayerBuffChoices([])
|
||||
setPlayerDebuffChoices([])
|
||||
setSelectedBuff(null)
|
||||
@@ -560,6 +636,7 @@ export function PvPRoguelikeScreen({
|
||||
setPaused(false)
|
||||
setTargetGroup(0)
|
||||
setReward(null)
|
||||
setRunSummary(createEmptyPvpRunSummary())
|
||||
setRewardError('')
|
||||
setShowEndLog(false)
|
||||
setFloatingTexts([])
|
||||
@@ -569,26 +646,28 @@ export function PvPRoguelikeScreen({
|
||||
cpuDefeatedRef.current = false
|
||||
if (gameMode === 'offline') {
|
||||
const randomCpu = randomCpuDifficulty()
|
||||
setQueueMessage(`Offline mode. CPU ${randomCpu} enters at stage ${startStage}.`)
|
||||
setQueueMessage(`Offline mode. CPU ${randomCpu} enters at stage ${matchStartStage}.`)
|
||||
setCpuDifficulty(randomCpu)
|
||||
setLog([{ id: 1, text: `Offline mode. CPU ${randomCpu} enters at stage ${startStage}.`, tone: 'system' }])
|
||||
setLog([{ id: 1, text: `Offline mode. CPU ${randomCpu} enters at stage ${matchStartStage}.`, tone: 'system' }])
|
||||
const timer = window.setTimeout(() => {
|
||||
setStatus('playing')
|
||||
addLog(`Stage ${startStage} begins against CPU ${randomCpu}.`, 'system')
|
||||
addLog(`Stage ${matchStartStage} begins against CPU ${randomCpu}.`, 'system')
|
||||
}, 500)
|
||||
return () => window.clearTimeout(timer)
|
||||
}
|
||||
setQueueMessage(`Searching queue. Stage ${startStage} start ready.`)
|
||||
setLog([{ id: 1, text: `Searching queue. Stage ${startStage} start ready.`, tone: 'system' }])
|
||||
setQueueMessage(`Searching queue. Stage ${matchStartStage} start ready.`)
|
||||
setLog([{ id: 1, text: `Searching queue. Stage ${matchStartStage} start ready.`, tone: 'system' }])
|
||||
const timer = window.setTimeout(() => {
|
||||
const randomCpu = randomCpuDifficulty()
|
||||
setCpuDifficulty(randomCpu)
|
||||
setQueueMessage(`No queued player found. CPU ${randomCpu} steps in.`)
|
||||
setStatus('playing')
|
||||
addLog(`No queued player found. CPU ${randomCpu} steps in at stage ${startStage}.`, 'system')
|
||||
addLog(`No queued player found. CPU ${randomCpu} steps in at stage ${matchStartStage}.`, 'system')
|
||||
}, 1400)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [addLog, contentType, cpuPartyTemplate, encounterPool, gameMode, maxResource, partyTemplate, startStage])
|
||||
}, [addLog, contentType, cpuPartyTemplate, gameMode, maxResource, partyTemplate, profile.character.id, setSelectedTargetId])
|
||||
|
||||
useEffect(() => startMatch(), [startMatch])
|
||||
|
||||
const applySpell = useCallback((
|
||||
current: SideState,
|
||||
@@ -607,10 +686,21 @@ export function PvPRoguelikeScreen({
|
||||
const extraTarget = (blockedIds: string[]) => livingTargets
|
||||
.filter((member) => !blockedIds.includes(member.id))
|
||||
.sort((left, right) => (left.health / left.maxHealth) - (right.health / right.maxHealth))[0]
|
||||
const hasSpellEffect = (effectType: string) => sideName === 'player' && activeSpellEffects.has(effectType)
|
||||
const renewEffect = starterSpells.find((candidate) => candidate.kind === 'hot')
|
||||
const shieldEffect = starterSpells.find((candidate) => candidate.kind === 'shield')
|
||||
const radianceEffect = starterSpells.find((candidate) => candidate.kind === 'group')
|
||||
const healingMultiplier = (member: PartyMember) =>
|
||||
hasSpellEffect('shielded_healing_bonus') && member.shield > 0 ? 1.2 : 1
|
||||
const directTargets = new Set([targetId])
|
||||
const hotTargets = new Set(spell.kind === 'hot' ? [targetId] : [])
|
||||
const shieldTargets = new Set(spell.kind === 'shield' ? [targetId] : [])
|
||||
const extraTargets = buffStacks(buffs, `slot${spell.key as SlotKey}-extra-target` as SelfBuffId)
|
||||
const groupTargets = new Set(
|
||||
spell.kind === 'group'
|
||||
? groupHealTargets(current.party, DEFAULT_GROUP_HEAL_TARGETS + extraTargets).map((member) => member.id)
|
||||
: [],
|
||||
)
|
||||
for (let index = 0; index < extraTargets; index += 1) {
|
||||
if (spell.kind === 'group') break
|
||||
if (spell.kind === 'hot') {
|
||||
@@ -626,21 +716,45 @@ export function PvPRoguelikeScreen({
|
||||
const extra = extraTarget([...directTargets])
|
||||
if (extra) directTargets.add(extra.id)
|
||||
}
|
||||
if (spell.kind === 'direct' && hasSpellEffect('mend_applies_renew') && renewEffect) {
|
||||
directTargets.forEach((id) => hotTargets.add(id))
|
||||
}
|
||||
if (spell.kind === 'direct' && hasSpellEffect('mend_applies_shield') && shieldEffect) {
|
||||
directTargets.forEach((id) => shieldTargets.add(id))
|
||||
}
|
||||
if (spell.kind === 'shield' && hasSpellEffect('shield_applies_renew') && renewEffect) {
|
||||
shieldTargets.forEach((id) => hotTargets.add(id))
|
||||
}
|
||||
const nextParty = current.party.map((member) => {
|
||||
if (member.health <= 0) return member
|
||||
if (spell.kind === 'group') {
|
||||
if (!groupTargets.has(member.id)) return member
|
||||
const groupPower = Math.round(spell.power * (1.25 ** buffStacks(buffs, 'group-heal-boost')))
|
||||
const nextHealth = healMember(member, groupPower, debuffs)
|
||||
const nextHealth = healMember(member, groupPower, debuffs, healingMultiplier(member))
|
||||
addFloatingHeal(sideName, member.id, Math.max(0, nextHealth - member.health))
|
||||
return { ...member, health: nextHealth }
|
||||
const nextShield = hasSpellEffect('radiance_applies_shield')
|
||||
? Math.max(member.shield, Math.round((shieldEffect?.power ?? spell.power) * 0.3))
|
||||
: member.shield
|
||||
return {
|
||||
...member,
|
||||
health: nextHealth,
|
||||
shield: nextShield,
|
||||
hotTicks: hasSpellEffect('radiance_applies_renew') && renewEffect
|
||||
? Math.max(member.hotTicks, 3)
|
||||
: member.hotTicks,
|
||||
}
|
||||
}
|
||||
if (!directTargets.has(member.id) && !hotTargets.has(member.id) && !shieldTargets.has(member.id)) return member
|
||||
if (spell.kind === 'shield') {
|
||||
const shieldPower = Math.round(spell.power * (1.25 ** buffStacks(buffs, 'shield-boost')))
|
||||
return { ...member, shield: Math.max(member.shield, shieldPower) }
|
||||
return {
|
||||
...member,
|
||||
shield: Math.max(member.shield, shieldPower),
|
||||
hotTicks: hotTargets.has(member.id) ? 5 : member.hotTicks,
|
||||
}
|
||||
}
|
||||
if (spell.kind === 'cleanse') {
|
||||
const nextHealth = healMember(member, spell.power, debuffs)
|
||||
const nextHealth = healMember(member, spell.power, debuffs, healingMultiplier(member))
|
||||
addFloatingHeal(sideName, member.id, Math.max(0, nextHealth - member.health))
|
||||
return {
|
||||
...member,
|
||||
@@ -652,11 +766,17 @@ export function PvPRoguelikeScreen({
|
||||
healingReductionTicks: undefined,
|
||||
}
|
||||
}
|
||||
const nextHealth = directTargets.has(member.id) ? healMember(member, spell.power, debuffs) : member.health
|
||||
const nextHealth = directTargets.has(member.id)
|
||||
? healMember(member, spell.power, debuffs, healingMultiplier(member))
|
||||
: member.health
|
||||
if (nextHealth > member.health) addFloatingHeal(sideName, member.id, nextHealth - member.health)
|
||||
const nextShield = shieldTargets.has(member.id) && spell.kind === 'direct' && hasSpellEffect('mend_applies_shield')
|
||||
? Math.max(member.shield, Math.round((shieldEffect?.power ?? spell.power) * 0.5))
|
||||
: member.shield
|
||||
return {
|
||||
...member,
|
||||
health: nextHealth,
|
||||
shield: nextShield,
|
||||
hotTicks: hotTargets.has(member.id) ? 5 : member.hotTicks,
|
||||
}
|
||||
})
|
||||
@@ -670,46 +790,51 @@ export function PvPRoguelikeScreen({
|
||||
: current.castsTowardFree + 1
|
||||
: current.castsTowardFree
|
||||
const gainedFreeCast = freeCastStacks > 0 && !current.freeCastReady && current.castsTowardFree + 1 >= 5
|
||||
const nextCooldowns = {
|
||||
...current.cooldowns,
|
||||
}
|
||||
if (spell.kind === 'direct' && hasSpellEffect('mend_reduces_radiance_cooldown') && radianceEffect) {
|
||||
nextCooldowns[radianceEffect.id] = Math.max(0, (nextCooldowns[radianceEffect.id] ?? 0) - 2)
|
||||
}
|
||||
nextCooldowns[spell.id] = spell.cooldown * cooldownMultiplier(spell, buffs, debuffs)
|
||||
const nextState: SideState = {
|
||||
...current,
|
||||
party: nextParty,
|
||||
resource: current.resource - effectiveCost,
|
||||
cooldowns: {
|
||||
...current.cooldowns,
|
||||
[spell.id]: spell.cooldown * cooldownMultiplier(spell, buffs, debuffs),
|
||||
},
|
||||
cooldowns: nextCooldowns,
|
||||
castsTowardFree: nextCastsTowardFree,
|
||||
freeCastReady: gainedFreeCast || nextFreeCastReady,
|
||||
}
|
||||
setCurrent(nextState)
|
||||
return true
|
||||
}, [addFloatingHeal])
|
||||
}, [activeSpellEffects, addFloatingHeal, starterSpells])
|
||||
|
||||
const castPlayerSpell = useCallback((spell: Spell) => {
|
||||
if (status !== 'playing' || playerDone || !playerAlive) return
|
||||
const targetId = selectedIdRef.current
|
||||
const succeeded = applySpell(playerRef.current, (value) => {
|
||||
const next = typeof value === 'function' ? value(playerRef.current) : value
|
||||
playerRef.current = next
|
||||
setPlayerSide(next)
|
||||
}, 'player', playerRef.current.buffs, playerRef.current.debuffs, spell, selectedId)
|
||||
if (succeeded) addLog(`${spell.name} cast on ${playerRef.current.party.find((member) => member.id === selectedId)?.name ?? 'target'}.`, 'heal')
|
||||
}, [addLog, applySpell, playerAlive, playerDone, selectedId, status])
|
||||
}, 'player', playerRef.current.buffs, playerRef.current.debuffs, spell, targetId)
|
||||
if (succeeded) addLog(`${spell.name} cast on ${playerRef.current.party.find((member) => member.id === targetId)?.name ?? 'target'}.`, 'heal')
|
||||
}, [addLog, applySpell, playerAlive, playerDone, status])
|
||||
|
||||
const selectRelativeTarget = useCallback((direction: -1 | 1) => {
|
||||
const living = playerRef.current.party.filter((member) => member.health > 0)
|
||||
if (living.length === 0) return
|
||||
const currentIndex = living.findIndex((member) => member.id === selectedId)
|
||||
const currentIndex = living.findIndex((member) => member.id === selectedIdRef.current)
|
||||
const nextIndex = currentIndex < 0
|
||||
? 0
|
||||
: (currentIndex + direction + living.length) % living.length
|
||||
setSelectedId(living[nextIndex].id)
|
||||
}, [selectedId])
|
||||
setSelectedTargetId(living[nextIndex].id)
|
||||
}, [setSelectedTargetId])
|
||||
|
||||
const selectDirectionalTarget = useCallback((action: InputAction) => {
|
||||
const currentIndex = playerRef.current.party.findIndex((member) => member.id === selectedId)
|
||||
const currentIndex = playerRef.current.party.findIndex((member) => member.id === selectedIdRef.current)
|
||||
if (currentIndex < 0) {
|
||||
const firstLiving = playerRef.current.party.find((member) => member.health > 0)
|
||||
if (firstLiving) setSelectedId(firstLiving.id)
|
||||
if (firstLiving) setSelectedTargetId(firstLiving.id)
|
||||
return
|
||||
}
|
||||
const currentRow = Math.floor(currentIndex / partyColumns)
|
||||
@@ -736,14 +861,14 @@ export function PvPRoguelikeScreen({
|
||||
const bSecondary = horizontal ? 0 : Math.abs(b.column - currentColumn)
|
||||
return aPrimary - bPrimary || aSecondary - bSecondary
|
||||
})
|
||||
if (candidates[0]) setSelectedId(candidates[0].member.id)
|
||||
}, [partyColumns, selectedId])
|
||||
if (candidates[0]) setSelectedTargetId(candidates[0].member.id)
|
||||
}, [partyColumns, setSelectedTargetId])
|
||||
|
||||
const selectDirectTarget = useCallback((slot: number) => {
|
||||
const index = slot + (contentType === 'raid' ? targetGroup * 6 : 0)
|
||||
const member = playerRef.current.party[index]
|
||||
if (member?.health > 0) setSelectedId(member.id)
|
||||
}, [contentType, targetGroup])
|
||||
if (member?.health > 0) setSelectedTargetId(member.id)
|
||||
}, [contentType, setSelectedTargetId, targetGroup])
|
||||
|
||||
const cpuTakeTurn = useCallback(() => {
|
||||
if (!cpuDifficulty || status !== 'playing' || cpuDone || !cpuAlive) return
|
||||
@@ -790,10 +915,15 @@ export function PvPRoguelikeScreen({
|
||||
const appliesHealingReduction = encounterValue.isBoss && elapsedTicks > 0 && elapsedTicks % 9 === 0 && mechanics.includes('healing-reduction')
|
||||
const appliesPoison = encounterValue.isBoss && elapsedTicks > 0 && elapsedTicks % 12 === 0 && mechanics.includes('ramping-poison')
|
||||
const damageMultiplier = incomingDamageMultiplier(side.debuffs)
|
||||
const hasSpellEffect = (effectType: string) => sideName === 'player' && activeSpellEffects.has(effectType)
|
||||
const tankPressure = tankPressureTargets(side.party)
|
||||
const tankPressureIds = new Set(tankPressure.targets.map((member) => member.id))
|
||||
const nextParty = side.party.map((member) => {
|
||||
if (member.health <= 0) return member
|
||||
let damage = member.id === primaryTarget.id ? encounterValue.damage : 0
|
||||
if (member.role === 'Tank') damage += encounterValue.tankDamage
|
||||
if (tankPressureIds.has(member.id)) {
|
||||
damage += Math.round(encounterValue.tankDamage * tankPressure.multiplier)
|
||||
}
|
||||
if (bossPulse) damage += 10
|
||||
if (member.debuff) damage += 6
|
||||
const nextPoisonStacks = appliesPoison && member.id === primaryTarget.id
|
||||
@@ -801,8 +931,12 @@ export function PvPRoguelikeScreen({
|
||||
: member.poisonStacks ?? 0
|
||||
if (nextPoisonStacks > 0) damage += 3 + nextPoisonStacks * 3
|
||||
damage = Math.round(damage * damageMultiplier)
|
||||
if (member.shield > 0 && hasSpellEffect('shielded_damage_reduction')) {
|
||||
damage = Math.round(damage * 0.8)
|
||||
}
|
||||
const absorbed = Math.min(member.shield, damage)
|
||||
const healing = member.hotTicks > 0 ? healAmount(member, 6, side.debuffs) : 0
|
||||
const healingMultiplier = member.shield > 0 && hasSpellEffect('shielded_healing_bonus') ? 1.2 : 1
|
||||
const healing = member.hotTicks > 0 ? healAmount(member, 6, side.debuffs, healingMultiplier) : 0
|
||||
if (healing > 0) addFloatingHeal(sideName, member.id, healing)
|
||||
const nextMaxHealthPenaltyTicks = appliesMaxHealthCut && member.id === primaryTarget.id
|
||||
? 14
|
||||
@@ -842,9 +976,9 @@ export function PvPRoguelikeScreen({
|
||||
cooldowns: Object.fromEntries(
|
||||
Object.entries(side.cooldowns).map(([id, seconds]) => [id, Math.max(0, seconds - TICK_MS / 1000)]),
|
||||
),
|
||||
enemyHealth: Math.max(0, side.enemyHealth - encounterValue.partyDamage),
|
||||
enemyHealth: Math.max(0, side.enemyHealth - partyDamageOutput(nextParty, encounterValue.partyDamage)),
|
||||
}
|
||||
}, [addFloatingHeal, elapsedTicks, maxResource])
|
||||
}, [activeSpellEffects, addFloatingHeal, elapsedTicks, maxResource])
|
||||
|
||||
const beginUpgradePhase = useCallback(() => {
|
||||
setPlayerBuffChoices(chooseRandom(selfBuffChoicesCatalog, 3))
|
||||
@@ -895,12 +1029,18 @@ export function PvPRoguelikeScreen({
|
||||
addLog(`CPU ${cpuDifficulty ?? 1} fell. Finish the boss for XP.`, 'loot')
|
||||
}
|
||||
if (nextPlayer.enemyHealth <= 0) {
|
||||
if (encounter.isBoss && cpuDefeatedRef.current) {
|
||||
finishRoguelikeRun()
|
||||
setStatus('won')
|
||||
addLog('CPU defeated. Match complete.', 'loot')
|
||||
return
|
||||
}
|
||||
addLog(`${encounter.enemyName} cleared. Choose your next edge.`, 'loot')
|
||||
beginUpgradePhase()
|
||||
}
|
||||
}, TICK_MS)
|
||||
}, TICK_MS / speedMultiplier)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [addLog, advanceSide, awardBossReward, beginUpgradePhase, checkpointStage, contentType, cpuDifficulty, cpuTakeTurn, encounter, encounterIndex, encountersCleared, finishRoguelikeRun, paused, profile.character.id, stage, status])
|
||||
}, [addLog, advanceSide, awardBossReward, beginUpgradePhase, checkpointStage, contentType, cpuDifficulty, cpuTakeTurn, encounter, encounterIndex, encountersCleared, finishRoguelikeRun, paused, profile.character.id, speedMultiplier, stage, status])
|
||||
|
||||
useEffect(() => {
|
||||
if ((status !== 'won' && status !== 'lost') || recordedRunRef.current || !cpuDifficulty) return
|
||||
@@ -955,10 +1095,17 @@ export function PvPRoguelikeScreen({
|
||||
}
|
||||
|
||||
const clearedBoss = encounter.isBoss
|
||||
if (clearedBoss && cpuDefeatedRef.current) {
|
||||
finishRoguelikeRun()
|
||||
setStatus('won')
|
||||
addLog('CPU defeated. Match complete.', 'loot')
|
||||
return
|
||||
}
|
||||
const nextStage = clearedBoss ? stage + 1 : stage
|
||||
const nextSegment = clearedBoss ? buildEncounterSegment(encounterPool, nextStage, contentType) : []
|
||||
const nextEncounter = clearedBoss ? nextSegment[0] : encounters[encounterIndex + 1]
|
||||
if (!nextEncounter) {
|
||||
finishRoguelikeRun()
|
||||
setStatus('won')
|
||||
addLog('No further encounters remain.', 'loot')
|
||||
return
|
||||
@@ -1007,9 +1154,13 @@ export function PvPRoguelikeScreen({
|
||||
setElapsedTicks(0)
|
||||
setStatus('playing')
|
||||
addLog(`You chose ${selectedBuff.name} and ${selectedDebuff.name}. CPU ${cpuDifficulty} chose ${cpuBuff.name} and ${cpuDebuff.name}.`, 'system')
|
||||
}, [addLog, contentType, cpuDifficulty, encounter, encounterIndex, encounterPool, encounters, maxResource, opponentDebuffChoicesCatalog, selectedBuff, selectedDebuff, selfBuffChoicesCatalog, stage, starterSpells])
|
||||
}, [addLog, contentType, cpuDifficulty, encounter, encounterIndex, encounterPool, encounters, finishRoguelikeRun, maxResource, opponentDebuffChoicesCatalog, selectedBuff, selectedDebuff, selfBuffChoicesCatalog, stage, starterSpells])
|
||||
|
||||
useGameAction((action) => {
|
||||
if (action === 'toggleSpeed') {
|
||||
if (status === 'playing') setSpeedMultiplier((value) => (value === 1 ? 2 : 1))
|
||||
return
|
||||
}
|
||||
if (action === 'pause' || action === 'back') {
|
||||
if (status === 'playing') setPaused((value) => !value)
|
||||
return
|
||||
@@ -1036,9 +1187,9 @@ export function PvPRoguelikeScreen({
|
||||
setTargetGroup((current) => {
|
||||
const groupCount = Math.max(1, Math.ceil(playerRef.current.party.length / 6))
|
||||
const next = ((current + 1) % groupCount) as 0 | 1 | 2
|
||||
const selectedIndex = playerRef.current.party.findIndex((member) => member.id === selectedId)
|
||||
const selectedIndex = playerRef.current.party.findIndex((member) => member.id === selectedIdRef.current)
|
||||
const nextMember = playerRef.current.party[(selectedIndex < 0 ? 0 : selectedIndex % 6) + next * 6]
|
||||
if (nextMember?.health > 0) setSelectedId(nextMember.id)
|
||||
if (nextMember?.health > 0) setSelectedTargetId(nextMember.id)
|
||||
return next
|
||||
})
|
||||
return
|
||||
@@ -1081,6 +1232,7 @@ export function PvPRoguelikeScreen({
|
||||
directPartyTargeting,
|
||||
paused,
|
||||
targetGroup,
|
||||
speedMultiplier,
|
||||
}), [
|
||||
bindings,
|
||||
controllerIconStyle,
|
||||
@@ -1105,6 +1257,7 @@ export function PvPRoguelikeScreen({
|
||||
playerSide.party,
|
||||
playerSide.resource,
|
||||
selectedId,
|
||||
speedMultiplier,
|
||||
stage,
|
||||
starterSpells,
|
||||
status,
|
||||
@@ -1128,7 +1281,7 @@ export function PvPRoguelikeScreen({
|
||||
{dualScreenEnabled && status !== 'queueing' && (
|
||||
<DualScreenTopCombat
|
||||
state={dualScreenState}
|
||||
onSelectTarget={setSelectedId}
|
||||
onSelectTarget={setSelectedTargetId}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1143,6 +1296,7 @@ export function PvPRoguelikeScreen({
|
||||
<div className="resource-row pvp-resource-row">
|
||||
<div className="pvp-resource-wrap">
|
||||
<span>{gameClass.resourceName} {Math.floor(playerSide.resource)} / {maxResource}</span>
|
||||
{speedMultiplier === 2 && <strong className="speed-badge">2x speed</strong>}
|
||||
<div className="bar mana-bar"><span style={{ width: `${(playerSide.resource / maxResource) * 100}%` }} /></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1152,7 +1306,7 @@ export function PvPRoguelikeScreen({
|
||||
<button
|
||||
className={`party-member ${selectedId === member.id ? 'selected' : ''} ${member.health <= 0 ? 'down' : ''}`}
|
||||
key={`player-${member.id}`}
|
||||
onClick={() => setSelectedId(member.id)}
|
||||
onClick={() => setSelectedTargetId(member.id)}
|
||||
type="button"
|
||||
>
|
||||
<div className="member-header">
|
||||
@@ -1351,9 +1505,39 @@ export function PvPRoguelikeScreen({
|
||||
<h2>{status === 'won' ? `CPU ${cpuDifficulty} Falls` : `CPU ${cpuDifficulty} Wins`}</h2>
|
||||
<p>{finalEncountersCleared} encounters cleared.</p>
|
||||
<div className="reward-summary">
|
||||
{!reward && !rewardError && <p>Boss kills grant XP immediately.</p>}
|
||||
<p>{runSummary.bossesKilled} bosses killed.</p>
|
||||
<p>+{runSummary.experienceGained} XP</p>
|
||||
{runSummary.bossesKilled > 0 && !reward && !rewardError && <p>Final boss rewards still recording...</p>}
|
||||
{rewardError && <p className="reward-error">{rewardError}</p>}
|
||||
{reward && (
|
||||
{runSummary.levelsGained > 0 && runSummary.previousLevel !== null && runSummary.newLevel !== null && (
|
||||
<p className="level-gain">
|
||||
Level {runSummary.previousLevel} to {runSummary.newLevel}
|
||||
<small>+{runSummary.talentPointsGained} talent point{runSummary.talentPointsGained === 1 ? '' : 's'}</small>
|
||||
</p>
|
||||
)}
|
||||
{runSummary.unlockedAbilities.map((ability) => (
|
||||
<p className="ability-unlock" key={ability.id}>
|
||||
<span>{ability.glyph}</span>
|
||||
Ability Unlocked: {ability.name}
|
||||
</p>
|
||||
))}
|
||||
<div className="run-loot-rolls">
|
||||
{runSummary.loot.length > 0 ? runSummary.loot.map((item, index) => (
|
||||
<div className="dropped" key={`${item.id}-${index}`}>
|
||||
<strong>Boss {index + 1}</strong>
|
||||
<span>
|
||||
{item.glyph} {item.name} x{item.quantity}
|
||||
{item.duplicate ? ` (owned x${item.quantityAfter})` : ''}
|
||||
</span>
|
||||
</div>
|
||||
)) : (
|
||||
<div>
|
||||
<strong>Loot</strong>
|
||||
<span>No boss loot awarded</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{reward && runSummary.bossesKilled === 0 && (
|
||||
<>
|
||||
<p>+{reward.experienceGained} XP</p>
|
||||
{reward.levelsGained > 0 && (
|
||||
@@ -1392,6 +1576,7 @@ export function PvPRoguelikeScreen({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<button onClick={() => startMatch()} type="button">Queue Next Match</button>
|
||||
<button className="secondary-result-button" onClick={onExit} type="button">Back to Roguelike</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+185
-97
@@ -1,10 +1,11 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
allocateTalent,
|
||||
resetTalents,
|
||||
type CharacterProfile,
|
||||
type Talent,
|
||||
} from '../profile'
|
||||
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../dualScreen'
|
||||
|
||||
type Props = {
|
||||
profile: CharacterProfile
|
||||
@@ -13,199 +14,286 @@ type Props = {
|
||||
embedded?: boolean
|
||||
}
|
||||
|
||||
const EFFECT_SLOT_LEVELS = [5, 10, 15, 20] as const
|
||||
const EFFECT_CLASS_ID = 1
|
||||
const EFFECTS_PER_PAGE = 8
|
||||
const EFFECT_SOURCE_LABELS: Record<string, string> = {
|
||||
mend: 'Mend',
|
||||
radiance: 'Radiance',
|
||||
shield: 'Shield',
|
||||
}
|
||||
|
||||
function effectSource(effectType: string) {
|
||||
if (effectType.startsWith('mend_')) return 'mend'
|
||||
if (effectType.startsWith('radiance_')) return 'radiance'
|
||||
if (effectType.startsWith('shield_') || effectType.startsWith('shielded_')) return 'shield'
|
||||
return effectType
|
||||
}
|
||||
|
||||
function effectCapacity(level: number) {
|
||||
return EFFECT_SLOT_LEVELS.filter((slotLevel) => level >= slotLevel).length
|
||||
}
|
||||
|
||||
function activeEffects(talents: Talent[]) {
|
||||
return talents.filter((talent) => talent.rank > 0)
|
||||
}
|
||||
|
||||
export function TalentScreen({ profile, onBack, onUpdated, embedded = false }: Props) {
|
||||
const { enabled: dualScreenEnabled } = useDualScreen()
|
||||
const [busyTalentId, setBusyTalentId] = useState<number | null>(null)
|
||||
const [talentPage, setTalentPage] = useState(0)
|
||||
const [resetting, setResetting] = useState(false)
|
||||
const [selectedTalentId, setSelectedTalentId] = useState<number | null>(null)
|
||||
const [effectPage, setEffectPage] = useState(0)
|
||||
const [message, setMessage] = useState('')
|
||||
const scrollRef = useRef<number>(0)
|
||||
const gameClass = profile.classes.find(
|
||||
(candidate) => candidate.id === profile.character.classId,
|
||||
)!
|
||||
const classPointsSpent = gameClass.talents.reduce(
|
||||
(total, talent) => total + talent.rank,
|
||||
0,
|
||||
const isEffectClass = gameClass.id === EFFECT_CLASS_ID
|
||||
const capacity = isEffectClass ? effectCapacity(profile.character.level) : 0
|
||||
const selectedEffects = activeEffects(gameClass.talents)
|
||||
const selectedTalent = gameClass.talents.find((talent) => talent.id === selectedTalentId)
|
||||
?? selectedEffects[0]
|
||||
?? gameClass.talents[0]
|
||||
?? null
|
||||
const effectPageCount = Math.max(1, Math.ceil(gameClass.talents.length / EFFECTS_PER_PAGE))
|
||||
const visibleTalents = gameClass.talents.slice(
|
||||
effectPage * EFFECTS_PER_PAGE,
|
||||
effectPage * EFFECTS_PER_PAGE + EFFECTS_PER_PAGE,
|
||||
)
|
||||
const tiers = Array.from(
|
||||
new Set(gameClass.talents.map((talent) => talent.tier)),
|
||||
).sort((a, b) => a - b)
|
||||
const tierPages = Array.from(
|
||||
{ length: Math.ceil(tiers.length / 2) },
|
||||
(_, index) => tiers.slice(index * 2, index * 2 + 2),
|
||||
)
|
||||
const visibleTiers = tierPages[talentPage] ?? tierPages[0] ?? []
|
||||
|
||||
useEffect(() => {
|
||||
window.scrollTo(0, scrollRef.current)
|
||||
}, [profile])
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedTalentId && gameClass.talents.some((talent) => talent.id === selectedTalentId)) return
|
||||
setSelectedTalentId(selectedTalent?.id ?? null)
|
||||
}, [gameClass.talents, selectedTalent?.id, selectedTalentId])
|
||||
|
||||
useEffect(() => {
|
||||
setEffectPage((page) => Math.min(page, effectPageCount - 1))
|
||||
}, [effectPageCount])
|
||||
|
||||
function saveScroll() {
|
||||
scrollRef.current = window.scrollY
|
||||
}
|
||||
|
||||
function lowerTierPoints(talent: Talent) {
|
||||
return gameClass.talents
|
||||
.filter((candidate) => candidate.tier < talent.tier)
|
||||
.reduce((total, candidate) => total + candidate.rank, 0)
|
||||
}
|
||||
|
||||
function lockReason(talent: Talent) {
|
||||
if (talent.rank >= talent.maxRank) return 'Maximum rank'
|
||||
|
||||
const requiredTierPoints = (talent.tier - 1) * 5
|
||||
if (lowerTierPoints(talent) < requiredTierPoints) {
|
||||
return `Requires ${requiredTierPoints} earlier-tier points`
|
||||
if (!isEffectClass) return 'Coming soon'
|
||||
if (talent.rank > 0) return ''
|
||||
const source = effectSource(talent.effectType)
|
||||
const sourceConflict = selectedEffects.find((effect) => effectSource(effect.effectType) === source)
|
||||
if (sourceConflict) return `${EFFECT_SOURCE_LABELS[source] ?? source} already selected`
|
||||
if (capacity <= 0) return 'Unlocks at level 5'
|
||||
if (selectedEffects.length >= capacity) {
|
||||
return `Active slots full (${capacity}/${capacity})`
|
||||
}
|
||||
|
||||
if (talent.prerequisiteTalentId) {
|
||||
const prerequisite = gameClass.talents.find(
|
||||
(candidate) => candidate.id === talent.prerequisiteTalentId,
|
||||
)
|
||||
if ((prerequisite?.rank ?? 0) < talent.prerequisiteRank) {
|
||||
return `Requires ${talent.prerequisiteName} rank ${talent.prerequisiteRank}`
|
||||
}
|
||||
}
|
||||
|
||||
if (profile.character.talentPoints <= 0) return 'No points available'
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
async function purchaseRank(talent: Talent) {
|
||||
async function toggleEffect(talent: Talent) {
|
||||
saveScroll()
|
||||
setBusyTalentId(talent.id)
|
||||
setMessage('')
|
||||
try {
|
||||
const updated = await allocateTalent(talent.id)
|
||||
onUpdated(updated)
|
||||
setMessage(`${talent.name} increased to rank ${talent.rank + 1}.`)
|
||||
setSelectedTalentId(talent.id)
|
||||
setMessage(talent.rank > 0 ? `${talent.name} removed.` : `${talent.name} activated.`)
|
||||
} catch (reason) {
|
||||
setMessage(reason instanceof Error ? reason.message : 'Unable to allocate talent.')
|
||||
setMessage(reason instanceof Error ? reason.message : 'Unable to update spell effect.')
|
||||
} finally {
|
||||
setBusyTalentId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function refundTree() {
|
||||
async function clearEffects() {
|
||||
saveScroll()
|
||||
setResetting(true)
|
||||
setMessage('')
|
||||
try {
|
||||
const updated = await resetTalents()
|
||||
onUpdated(updated)
|
||||
setMessage('All points in this talent tree were refunded.')
|
||||
setMessage('Spell effects cleared.')
|
||||
} catch (reason) {
|
||||
setMessage(reason instanceof Error ? reason.message : 'Unable to reset talents.')
|
||||
setMessage(reason instanceof Error ? reason.message : 'Unable to clear spell effects.')
|
||||
} finally {
|
||||
setResetting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const workshopState = useMemo<DualScreenWorkshopState | null>(() => {
|
||||
if (!isEffectClass) return null
|
||||
return {
|
||||
mode: 'talents',
|
||||
title: 'Spell Effects',
|
||||
subtitle: `${selectedEffects.length}/${capacity} active`,
|
||||
summary: selectedTalent
|
||||
? `${selectedTalent.name}: ${selectedTalent.description}`
|
||||
: 'Choose effects to modify your spells.',
|
||||
items: gameClass.talents.map((talent) => ({
|
||||
glyph: talent.glyph,
|
||||
title: talent.name,
|
||||
meta: talent.rank > 0 ? 'Active' : lockReason(talent) || 'Available',
|
||||
detail: talent.description,
|
||||
status: talent.rank > 0 ? 'Selected' : '',
|
||||
})),
|
||||
}
|
||||
}, [capacity, gameClass.talents, isEffectClass, selectedEffects.length, selectedTalent])
|
||||
|
||||
useDualScreenWorkshopPublisher(workshopState, dualScreenEnabled)
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{!embedded && (
|
||||
<div className="screen-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Character Growth</p>
|
||||
<h1>Talents</h1>
|
||||
<h1>Spell Effects</h1>
|
||||
</div>
|
||||
<button className="back-button" onClick={onBack} type="button">Back</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="talent-toolbar">
|
||||
<div className="talent-toolbar spell-effect-toolbar">
|
||||
<div className="talent-class-summary">
|
||||
<span style={{ borderColor: gameClass.themeColor, color: gameClass.themeColor }}>
|
||||
{gameClass.name[0]}
|
||||
</span>
|
||||
<div>
|
||||
<p className="eyebrow">{gameClass.name} Tree</p>
|
||||
<h2>Shape Your Healing Style</h2>
|
||||
<p className="eyebrow">{gameClass.name} Effects</p>
|
||||
<h2>Modify Your Spells</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="talent-points">
|
||||
<strong>{profile.character.talentPoints}</strong>
|
||||
<span>Available</span>
|
||||
<small>{classPointsSpent} spent in this tree</small>
|
||||
<strong>{selectedEffects.length}/{capacity}</strong>
|
||||
<span>Active</span>
|
||||
<small>Slots unlock at levels 5, 10, 15, 20</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="talent-page-tabs" role="tablist" aria-label="Talent tier pages">
|
||||
{tierPages.map((pageTiers, index) => (
|
||||
{!isEffectClass ? (
|
||||
<div className="talent-empty-state">
|
||||
<h2>Spell effects coming soon for {gameClass.name}.</h2>
|
||||
<p>This replacement system starts with the first class.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="spell-effect-layout">
|
||||
<section className="effect-slots-panel">
|
||||
<p className="eyebrow">Active Slots</p>
|
||||
{EFFECT_SLOT_LEVELS.map((level, index) => {
|
||||
const effect = selectedEffects[index]
|
||||
const unlocked = profile.character.level >= level
|
||||
return (
|
||||
<button
|
||||
aria-selected={talentPage === index}
|
||||
className={talentPage === index ? 'active' : ''}
|
||||
key={pageTiers.join('-')}
|
||||
onClick={() => setTalentPage(index)}
|
||||
role="tab"
|
||||
className={`effect-slot ${effect ? 'filled' : ''} ${unlocked ? '' : 'locked'}`}
|
||||
disabled={!effect}
|
||||
key={level}
|
||||
onClick={() => effect && setSelectedTalentId(effect.id)}
|
||||
type="button"
|
||||
>
|
||||
Tiers {pageTiers[0]}-{pageTiers[pageTiers.length - 1]}
|
||||
<span>Lv {level}</span>
|
||||
<strong>{effect?.name ?? (unlocked ? 'Empty Slot' : 'Locked')}</strong>
|
||||
<small>{effect?.description ?? (unlocked ? 'Choose an effect from the pool.' : `Reach level ${level}.`)}</small>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
)
|
||||
})}
|
||||
</section>
|
||||
|
||||
<div className="talent-tree">
|
||||
{visibleTiers.map((tier) => {
|
||||
const requiredPoints = (tier - 1) * 5
|
||||
return (
|
||||
<section className="talent-tier" key={tier}>
|
||||
<div className="tier-label">
|
||||
<span>Tier {tier}</span>
|
||||
<small>
|
||||
{tier === 1 ? 'Open' : `${requiredPoints} earlier-tier points`}
|
||||
</small>
|
||||
<section className="effect-pool-panel">
|
||||
<div className="effect-panel-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Effect Pool</p>
|
||||
<h2>Choose and Swap</h2>
|
||||
</div>
|
||||
<div className="tier-talents">
|
||||
{gameClass.talents
|
||||
.filter((talent) => talent.tier === tier)
|
||||
.sort((a, b) => a.branch - b.branch)
|
||||
.map((talent) => {
|
||||
<span>{selectedEffects.length}/{capacity} active</span>
|
||||
</div>
|
||||
<div className="selected-effect-strip">
|
||||
<div>
|
||||
<p className="eyebrow">Selected Effect</p>
|
||||
{selectedTalent ? (
|
||||
<>
|
||||
<strong>{selectedTalent.name}</strong>
|
||||
<small>{selectedTalent.description}</small>
|
||||
</>
|
||||
) : (
|
||||
<small>No effect selected.</small>
|
||||
)}
|
||||
</div>
|
||||
{selectedTalent && (
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={Boolean(lockReason(selectedTalent)) || busyTalentId === selectedTalent.id}
|
||||
onClick={() => toggleEffect(selectedTalent)}
|
||||
type="button"
|
||||
>
|
||||
{busyTalentId === selectedTalent.id
|
||||
? 'Saving...'
|
||||
: selectedTalent.rank > 0
|
||||
? 'Remove'
|
||||
: 'Activate'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="effect-pool">
|
||||
{visibleTalents.map((talent) => {
|
||||
const reason = lockReason(talent)
|
||||
const active = talent.rank > 0
|
||||
const selected = selectedTalent?.id === talent.id
|
||||
const isBusy = busyTalentId === talent.id
|
||||
return (
|
||||
<article
|
||||
className={`talent-node ${reason ? 'locked' : 'available'} ${talent.rank > 0 ? 'invested' : ''}`}
|
||||
<button
|
||||
className={`${active ? 'active' : ''} ${selected ? 'selected' : ''}`}
|
||||
disabled={Boolean(reason) || isBusy}
|
||||
key={talent.id}
|
||||
style={{ gridColumn: talent.branch }}
|
||||
onClick={() => {
|
||||
setSelectedTalentId(talent.id)
|
||||
void toggleEffect(talent)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<div className="talent-node-header">
|
||||
<span>{talent.glyph}</span>
|
||||
<div>
|
||||
<strong>{talent.name}</strong>
|
||||
<small>Rank {talent.rank}/{talent.maxRank}</small>
|
||||
<small>{EFFECT_SOURCE_LABELS[effectSource(talent.effectType)] ?? 'Spell'}</small>
|
||||
</div>
|
||||
<i>{isBusy ? 'Saving' : active ? 'Active' : reason || 'Available'}</i>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<p>{talent.description}</p>
|
||||
<div className="rank-pips">
|
||||
{Array.from({ length: talent.maxRank }, (_, index) => (
|
||||
<i className={index < talent.rank ? 'filled' : ''} key={index} />
|
||||
))}
|
||||
</div>
|
||||
{effectPageCount > 1 && (
|
||||
<div className="effect-pager">
|
||||
<button
|
||||
disabled={Boolean(reason) || isBusy}
|
||||
onClick={() => purchaseRank(talent)}
|
||||
disabled={effectPage === 0}
|
||||
onClick={() => setEffectPage((page) => Math.max(0, page - 1))}
|
||||
type="button"
|
||||
>
|
||||
{isBusy ? 'Saving...' : reason || 'Add Rank'}
|
||||
Prev
|
||||
</button>
|
||||
<span>{effectPage + 1}/{effectPageCount}</span>
|
||||
<button
|
||||
disabled={effectPage >= effectPageCount - 1}
|
||||
onClick={() => setEffectPage((page) => Math.min(effectPageCount - 1, page + 1))}
|
||||
type="button"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<footer className="talent-footer">
|
||||
<span>{message || 'Talent changes are saved immediately.'}</span>
|
||||
<span>{message || 'Spell effect changes are saved immediately.'}</span>
|
||||
<button
|
||||
className="text-button"
|
||||
disabled={classPointsSpent === 0 || resetting}
|
||||
onClick={refundTree}
|
||||
disabled={selectedEffects.length === 0 || resetting}
|
||||
onClick={clearEffects}
|
||||
type="button"
|
||||
>
|
||||
{resetting ? 'Refunding...' : 'Reset Tree'}
|
||||
{resetting ? 'Clearing...' : 'Clear Effects'}
|
||||
</button>
|
||||
</footer>
|
||||
</>
|
||||
|
||||
+13
-2
@@ -42,7 +42,7 @@ export type DualScreenCombatState = {
|
||||
partySize: number
|
||||
selectedId: string
|
||||
log: CombatLogEntry[]
|
||||
status: 'playing' | 'won' | 'lost' | 'part-complete' | 'upgrade-choice'
|
||||
status: 'playing' | 'won' | 'lost' | 'part-complete' | 'marathon-choice' | 'upgrade-choice'
|
||||
resource: number
|
||||
maxResource: number
|
||||
resourceName: string
|
||||
@@ -54,6 +54,7 @@ export type DualScreenCombatState = {
|
||||
directPartyTargeting: boolean
|
||||
paused: boolean
|
||||
targetGroup: 0 | 1 | 2
|
||||
speedMultiplier: 1 | 2
|
||||
}
|
||||
|
||||
export type DualScreenWorkshopState = {
|
||||
@@ -118,6 +119,13 @@ function loadRecentSnapshot() {
|
||||
}
|
||||
}
|
||||
|
||||
function memberHotEffects(member: PartyMember) {
|
||||
if (member.hotEffects?.length) return member.hotEffects
|
||||
return member.hotTicks > 0
|
||||
? [{ id: 'legacy-renew', spellId: 'legacy-renew', label: 'Renew', ticks: member.hotTicks, power: 6 }]
|
||||
: []
|
||||
}
|
||||
|
||||
export function DualScreenProvider({ children }: { children: ReactNode }) {
|
||||
const [enabled, setEnabledState] = useState(
|
||||
() => localStorage.getItem(STORAGE_KEY) === 'true',
|
||||
@@ -438,6 +446,7 @@ export function DualScreenBottomDisplay() {
|
||||
</div>
|
||||
<div className="dual-controls-mana">
|
||||
<span>{state.resourceName} {Math.floor(state.resource)} / {state.maxResource}</span>
|
||||
{state.speedMultiplier === 2 && <strong className="speed-badge">2x speed</strong>}
|
||||
<div className="bar mana-bar">
|
||||
<span style={{ width: `${(state.resource / state.maxResource) * 100}%` }} />
|
||||
</div>
|
||||
@@ -599,7 +608,9 @@ export function DualScreenTopCombat({
|
||||
</div>
|
||||
)}
|
||||
<div className="member-effects">
|
||||
{member.hotTicks > 0 && <span className="buff">Renew</span>}
|
||||
{memberHotEffects(member).map((effect) => (
|
||||
<span className="buff" key={effect.id}>{effect.label}</span>
|
||||
))}
|
||||
{member.debuff && <span className="debuff">{member.debuff}</span>}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
+45
-2
@@ -8,6 +8,20 @@ export type PartyMember = {
|
||||
maxHealth: number
|
||||
shield: number
|
||||
hotTicks: number
|
||||
hotEffects?: Array<{
|
||||
id: string
|
||||
spellId: string
|
||||
label: string
|
||||
ticks: number
|
||||
power: number
|
||||
}>
|
||||
bounceHeals?: Array<{
|
||||
id: string
|
||||
label: string
|
||||
charges: number
|
||||
power: number
|
||||
}>
|
||||
damageReductionTicks?: number
|
||||
debuff?: string
|
||||
debuffTicks?: number
|
||||
poisonStacks?: number
|
||||
@@ -24,7 +38,8 @@ export type Spell = {
|
||||
cooldown: number
|
||||
power: number
|
||||
glyph: string
|
||||
kind: 'direct' | 'hot' | 'group' | 'shield' | 'cleanse'
|
||||
kind: 'direct' | 'hot' | 'group' | 'shield' | 'cleanse' | 'damage_reduction' | 'bounce_heal'
|
||||
effectType?: string
|
||||
}
|
||||
|
||||
export type Encounter = {
|
||||
@@ -44,6 +59,9 @@ export type CombatLogEntry = {
|
||||
tone: 'system' | 'heal' | 'danger' | 'loot'
|
||||
}
|
||||
|
||||
export const TANKLESS_DAMAGE_MULTIPLIER = 1.35
|
||||
export const DEFAULT_GROUP_HEAL_TARGETS = 4
|
||||
|
||||
export const INITIAL_PARTY: PartyMember[] = [
|
||||
{ id: 'brann', name: 'Brann', role: 'Tank', health: 150, maxHealth: 150, shield: 0, hotTicks: 0 },
|
||||
{ id: 'mira', name: 'Mira', role: 'Healer', health: 100, maxHealth: 100, shield: 0, hotTicks: 0 },
|
||||
@@ -101,7 +119,7 @@ export const SPELLS: Spell[] = [
|
||||
id: 'radiance',
|
||||
key: '3',
|
||||
name: 'Radiance',
|
||||
description: 'Restores health to every living party member.',
|
||||
description: 'Restores health to up to 4 injured party members.',
|
||||
cost: 12,
|
||||
cooldown: 8,
|
||||
power: 18,
|
||||
@@ -164,3 +182,28 @@ export const ENCOUNTERS: Encounter[] = [
|
||||
isBoss: true,
|
||||
},
|
||||
]
|
||||
|
||||
export function partyDamageOutput(party: PartyMember[], baseDamage: number) {
|
||||
const livingCount = party.filter((member) => member.health > 0).length
|
||||
return Math.round(baseDamage * (livingCount / Math.max(1, party.length)))
|
||||
}
|
||||
|
||||
export function tankPressureTargets(party: PartyMember[]) {
|
||||
const living = party.filter((member) => member.health > 0)
|
||||
const tanks = living.filter((member) => member.role === 'Tank')
|
||||
if (tanks.length > 0) return { targets: tanks, multiplier: 1 }
|
||||
const damageDealer = living
|
||||
.filter((member) => member.role === 'Damage')
|
||||
.sort((left, right) => right.health - left.health)[0]
|
||||
return {
|
||||
targets: damageDealer ? [damageDealer] : [],
|
||||
multiplier: TANKLESS_DAMAGE_MULTIPLIER,
|
||||
}
|
||||
}
|
||||
|
||||
export function groupHealTargets(party: PartyMember[], targetCount = DEFAULT_GROUP_HEAL_TARGETS) {
|
||||
return party
|
||||
.filter((member) => member.health > 0)
|
||||
.sort((left, right) => (left.health / left.maxHealth) - (right.health / right.maxHealth))
|
||||
.slice(0, targetCount)
|
||||
}
|
||||
|
||||
+140
-26
@@ -26,6 +26,7 @@ export interface GameRepository {
|
||||
completedPart?: number,
|
||||
startPart?: number,
|
||||
partDurationSeconds?: [number, number, number],
|
||||
hardMode?: boolean,
|
||||
): Promise<DungeonReward>
|
||||
completeRoguelike(
|
||||
dungeonId: number,
|
||||
@@ -69,6 +70,7 @@ type OfflineSave = {
|
||||
activeClassId: number
|
||||
completedDungeonParts: number
|
||||
completedRaidPhases: number
|
||||
dungeonCompletions?: Record<string, number>
|
||||
characters: Record<number, CharacterData>
|
||||
lootRolls: Record<string, LootRoll>
|
||||
}
|
||||
@@ -102,6 +104,7 @@ const offlineSaveKey = 'chronicle.offlineSave.v1'
|
||||
const onlineCacheKey = 'chronicle.onlineCache.v1'
|
||||
const authTokenKey = 'chronicle.authToken.v1'
|
||||
const offlineAccount = { id: -1, username: 'Offline' }
|
||||
const ABILITY_SLOT_COUNT = 6
|
||||
|
||||
function clone<T>(value: T): T {
|
||||
return structuredClone(value)
|
||||
@@ -146,7 +149,7 @@ function upgradeV1Save(v1: { profile: CharacterProfile; lootRolls: Record<string
|
||||
level: cid === p.character.classId ? p.character.level : 1,
|
||||
experience: cid === p.character.classId ? p.character.experience : 0,
|
||||
talentPoints: cid === p.character.classId ? p.character.talentPoints : 1,
|
||||
abilitySlots: cid === p.character.classId ? [...p.abilitySlots] : [],
|
||||
abilitySlots: cid === p.character.classId ? normalizeAbilitySlots(p.abilitySlots) : [],
|
||||
talentRanks,
|
||||
inventory: cid === p.character.classId ? clone(p.inventory) : [],
|
||||
}
|
||||
@@ -157,17 +160,41 @@ function upgradeV1Save(v1: { profile: CharacterProfile; lootRolls: Record<string
|
||||
activeClassId: p.character.classId,
|
||||
completedDungeonParts: p.completedDungeonParts,
|
||||
completedRaidPhases: p.completedRaidPhases ?? 0,
|
||||
dungeonCompletions: Object.fromEntries(
|
||||
p.dungeons.map((dungeon) => [String(dungeon.id), dungeon.completionCount ?? 0]),
|
||||
),
|
||||
characters,
|
||||
lootRolls: v1.lootRolls ?? {},
|
||||
}
|
||||
}
|
||||
|
||||
function upgradeV2Save(v2: Omit<OfflineSave, 'version' | 'completedRaidPhases'> & { version: 2 }): OfflineSave {
|
||||
return {
|
||||
return normalizeSaveAbilitySlots({
|
||||
...v2,
|
||||
version: 3,
|
||||
completedRaidPhases: 0,
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeAbilitySlots(abilitySlots: unknown): Array<number | null> {
|
||||
const slots = Array.isArray(abilitySlots)
|
||||
? abilitySlots
|
||||
.slice(0, ABILITY_SLOT_COUNT)
|
||||
.map((value) => {
|
||||
if (value === null || value === undefined) return null
|
||||
const id = Number(value)
|
||||
return Number.isInteger(id) ? id : null
|
||||
})
|
||||
: []
|
||||
while (slots.length < ABILITY_SLOT_COUNT) slots.push(null)
|
||||
return slots
|
||||
}
|
||||
|
||||
function normalizeSaveAbilitySlots(save: OfflineSave): OfflineSave {
|
||||
for (const character of Object.values(save.characters)) {
|
||||
character.abilitySlots = normalizeAbilitySlots(character.abilitySlots)
|
||||
}
|
||||
return save
|
||||
}
|
||||
|
||||
function normalizeOfflineSave(raw: unknown): OfflineSave | null {
|
||||
@@ -177,12 +204,12 @@ function normalizeOfflineSave(raw: unknown): OfflineSave | null {
|
||||
profile?: CharacterProfile
|
||||
lootRolls?: Record<string, LootRoll>
|
||||
}
|
||||
if (candidate.version === 3) return candidate as OfflineSave
|
||||
if (candidate.version === 3) return normalizeSaveAbilitySlots(candidate as OfflineSave)
|
||||
if (candidate.version === 2) {
|
||||
return upgradeV2Save(candidate as Omit<OfflineSave, 'version' | 'completedRaidPhases'> & { version: 2 })
|
||||
}
|
||||
if (candidate.version === 1 && candidate.profile) {
|
||||
return upgradeV1Save(candidate as { profile: CharacterProfile; lootRolls: Record<string, LootRoll> })
|
||||
return normalizeSaveAbilitySlots(upgradeV1Save(candidate as { profile: CharacterProfile; lootRolls: Record<string, LootRoll> }))
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -291,6 +318,10 @@ function buildProfile(save: OfflineSave): CharacterProfile {
|
||||
updateCraftingRecipes(static_)
|
||||
static_.completedDungeonParts = save.completedDungeonParts
|
||||
static_.completedRaidPhases = save.completedRaidPhases
|
||||
static_.dungeons = static_.dungeons.map((dungeon) => ({
|
||||
...dungeon,
|
||||
completionCount: save.dungeonCompletions?.[String(dungeon.id)] ?? dungeon.completionCount ?? 0,
|
||||
}))
|
||||
|
||||
return static_
|
||||
}
|
||||
@@ -359,11 +390,33 @@ function experienceForLevel(level: number) {
|
||||
return (level - 1) * (level - 1) * 100
|
||||
}
|
||||
|
||||
function catchUpExperienceReward(
|
||||
baseReward: number,
|
||||
currentExperience: number,
|
||||
currentLevel: number,
|
||||
targetLevel: number,
|
||||
) {
|
||||
if (targetLevel <= currentLevel) return baseReward
|
||||
const targetExperience = experienceForLevel(targetLevel)
|
||||
const gap = Math.max(0, targetExperience - currentExperience)
|
||||
if (gap <= 0) return baseReward
|
||||
const doubledBase = Math.min(baseReward, Math.ceil(gap / 2))
|
||||
return doubledBase * 2 + (baseReward - doubledBase)
|
||||
}
|
||||
|
||||
function highestOtherClassLevel(save: OfflineSave) {
|
||||
const activeClass = save.activeClassId
|
||||
return Object.entries(save.characters)
|
||||
.filter(([classId]) => Number(classId) !== activeClass)
|
||||
.reduce((highest, [, character]) => Math.max(highest, character.level), 0)
|
||||
}
|
||||
|
||||
function scaledPvpBossExperience(
|
||||
startingExperience: number,
|
||||
startingLevel: number,
|
||||
bossesCleared: number,
|
||||
maxLevel: number,
|
||||
targetLevel = startingLevel,
|
||||
) {
|
||||
let experience = startingExperience
|
||||
let level = startingLevel
|
||||
@@ -374,7 +427,8 @@ function scaledPvpBossExperience(
|
||||
? maxExperience
|
||||
: experienceForLevel(level + 1)
|
||||
const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor)
|
||||
experience = Math.min(maxExperience, experience + Math.round(levelBand * 0.25))
|
||||
const rewardRate = targetLevel > level ? 0.5 : 0.25
|
||||
experience = Math.min(maxExperience, experience + Math.round(levelBand * rewardRate))
|
||||
while (level < maxLevel && experienceForLevel(level + 1) <= experience) {
|
||||
level += 1
|
||||
}
|
||||
@@ -382,6 +436,17 @@ function scaledPvpBossExperience(
|
||||
return { experience, level }
|
||||
}
|
||||
|
||||
function talentEffectCapacity(level: number) {
|
||||
return Math.min(4, Math.max(0, Math.floor(level / 5)))
|
||||
}
|
||||
|
||||
function talentEffectSource(effectType: string) {
|
||||
if (effectType.startsWith('mend_')) return 'Mend'
|
||||
if (effectType.startsWith('radiance_')) return 'Radiance'
|
||||
if (effectType.startsWith('shield_') || effectType.startsWith('shielded_')) return 'Shield'
|
||||
return effectType
|
||||
}
|
||||
|
||||
type ComponentTemplate = { id: number; slug: string; name: string; itemLevel: number; glyph: string; description: string }
|
||||
const COMPONENT_ITEMS: Record<number, ComponentTemplate> = {
|
||||
1: { id: 600, slug: 'minor-component', name: 'Minor Component', itemLevel: 1, glyph: '◆', description: 'A basic crafting component.' },
|
||||
@@ -389,6 +454,7 @@ const COMPONENT_ITEMS: Record<number, ComponentTemplate> = {
|
||||
20: { id: 604, slug: 'superior-component', name: 'Superior Component', itemLevel: 20, glyph: '◎', description: 'A superior crafting component.' },
|
||||
25: { id: 605, slug: 'primal-component', name: 'Primal Component', itemLevel: 25, glyph: '✦', description: 'A primal crafting component.' },
|
||||
}
|
||||
const DIRECT_CRAFT_ITEM_LEVELS = new Set([1, 10, 20, 25])
|
||||
|
||||
type WindowWithApiBase = Window & {
|
||||
CAPACITOR_API_BASE_URL?: string
|
||||
@@ -423,7 +489,7 @@ function mergeProfileIntoSave(profile: CharacterProfile, existingSave?: OfflineS
|
||||
level: profile.character.level,
|
||||
experience: profile.character.experience,
|
||||
talentPoints: profile.character.talentPoints,
|
||||
abilitySlots: [...profile.abilitySlots],
|
||||
abilitySlots: normalizeAbilitySlots(profile.abilitySlots),
|
||||
talentRanks,
|
||||
inventory: clone(profile.inventory),
|
||||
}
|
||||
@@ -433,6 +499,9 @@ function mergeProfileIntoSave(profile: CharacterProfile, existingSave?: OfflineS
|
||||
activeClassId: profile.character.classId,
|
||||
completedDungeonParts: profile.completedDungeonParts,
|
||||
completedRaidPhases: profile.completedRaidPhases,
|
||||
dungeonCompletions: Object.fromEntries(
|
||||
profile.dungeons.map((dungeon) => [String(dungeon.id), dungeon.completionCount ?? 0]),
|
||||
),
|
||||
characters,
|
||||
lootRolls: clone(existingSave?.lootRolls ?? {}),
|
||||
}
|
||||
@@ -716,7 +785,7 @@ const serverRepository: GameRepository = {
|
||||
),
|
||||
saveProfile: (classId, abilitySlots) =>
|
||||
cachedOnlineLocalRepository.saveProfile(classId, abilitySlots),
|
||||
completeDungeon: (dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds) =>
|
||||
completeDungeon: (dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds, hardMode) =>
|
||||
cachedOnlineLocalRepository.completeDungeon(
|
||||
dungeonId,
|
||||
difficultyId,
|
||||
@@ -725,6 +794,7 @@ const serverRepository: GameRepository = {
|
||||
completedPart,
|
||||
startPart,
|
||||
partDurationSeconds,
|
||||
hardMode,
|
||||
),
|
||||
completeRoguelike: (dungeonId, difficultyId, encountersCleared, resourceSpent, durationSeconds, options) =>
|
||||
cachedOnlineLocalRepository.completeRoguelike(
|
||||
@@ -761,9 +831,9 @@ function emptyCharacterData(classId: number): CharacterData {
|
||||
const inventory: Item[] = []
|
||||
const startingAbilitySlots: Array<number | null> = gc.spells
|
||||
.filter((s) => s.unlockLevel === 1)
|
||||
.slice(0, 5)
|
||||
.slice(0, ABILITY_SLOT_COUNT)
|
||||
.map((s) => s.id)
|
||||
while (startingAbilitySlots.length < 6) startingAbilitySlots.push(null)
|
||||
while (startingAbilitySlots.length < ABILITY_SLOT_COUNT) startingAbilitySlots.push(null)
|
||||
return {
|
||||
level: 1,
|
||||
experience: 0,
|
||||
@@ -807,8 +877,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
||||
const gameClass = static_.classes.find((candidate) => candidate.id === classId)
|
||||
if (!gameClass) throw new Error('Selected class does not exist.')
|
||||
|
||||
const slots = abilitySlots.slice(0, 6)
|
||||
while (slots.length < 6) slots.push(null)
|
||||
const slots = normalizeAbilitySlots(abilitySlots)
|
||||
const selectedIds = slots.filter((id): id is number => id !== null)
|
||||
if (new Set(selectedIds).size !== selectedIds.length) {
|
||||
throw new Error('The same ability cannot be equipped twice.')
|
||||
@@ -831,7 +900,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
||||
store.writeSave(save)
|
||||
return buildProfile(save)
|
||||
},
|
||||
async completeDungeon(dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds) {
|
||||
async completeDungeon(dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds, hardMode) {
|
||||
void startPart
|
||||
void partDurationSeconds
|
||||
if (!Number.isInteger(resourceSpent) || resourceSpent < 0) {
|
||||
@@ -857,8 +926,15 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
||||
const previousLevel = cd.level
|
||||
const previousExperience = cd.experience
|
||||
const partCount = completedPart ?? 1
|
||||
const experienceReward = Math.round(
|
||||
dungeon.experienceReward * difficulty.experienceMultiplier * partCount,
|
||||
const rewardMultiplier = hardMode ? 2 : 1
|
||||
const baseExperienceReward = Math.round(
|
||||
dungeon.experienceReward * difficulty.experienceMultiplier * partCount * rewardMultiplier,
|
||||
)
|
||||
const experienceReward = catchUpExperienceReward(
|
||||
baseExperienceReward,
|
||||
previousExperience,
|
||||
previousLevel,
|
||||
highestOtherClassLevel(save),
|
||||
)
|
||||
const maxExperience = experienceForLevel(profile.maxLevel)
|
||||
const newExperience = Math.min(previousExperience + experienceReward, maxExperience)
|
||||
@@ -893,6 +969,10 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
||||
} else {
|
||||
save.completedDungeonParts = Math.max(save.completedDungeonParts, partCount)
|
||||
}
|
||||
save.dungeonCompletions = {
|
||||
...(save.dungeonCompletions ?? {}),
|
||||
[String(dungeonId)]: (save.dungeonCompletions?.[String(dungeonId)] ?? 0) + 1,
|
||||
}
|
||||
|
||||
let bonusItem: DungeonReward['bonusItem'] = null
|
||||
if ((startPart ?? 1) === 1 && partCount >= 3 && dungeon.completionLoot.length > 0) {
|
||||
@@ -906,19 +986,20 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
||||
const selected = rewardPool[Math.floor(Math.random() * rewardPool.length)]
|
||||
const existing = profile.inventory.find((item) => item.id === selected.id)
|
||||
const duplicate = Boolean(existing)
|
||||
let quantityAfter = 1
|
||||
const rewardQuantity = rewardMultiplier
|
||||
let quantityAfter = rewardQuantity
|
||||
if (existing) {
|
||||
existing.quantity += 1
|
||||
existing.quantity += rewardQuantity
|
||||
quantityAfter = existing.quantity
|
||||
} else {
|
||||
profile.inventory.push({
|
||||
...selected,
|
||||
quantity: 1,
|
||||
quantity: rewardQuantity,
|
||||
equipped: false,
|
||||
})
|
||||
}
|
||||
cd.inventory = profile.inventory
|
||||
bonusItem = { ...selected, quantity: 1, duplicate, quantityAfter }
|
||||
bonusItem = { ...selected, quantity: rewardQuantity, duplicate, quantityAfter }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -971,13 +1052,19 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
||||
const maxExperience = experienceForLevel(profile.maxLevel)
|
||||
const bossesCleared = Math.max(0, Math.floor(options?.bossesCleared ?? encountersCleared / 3))
|
||||
const scaledReward = options?.experienceMode === 'pvp-boss-quarter-level'
|
||||
? scaledPvpBossExperience(previousExperience, previousLevel, bossesCleared, profile.maxLevel)
|
||||
? scaledPvpBossExperience(previousExperience, previousLevel, bossesCleared, profile.maxLevel, highestOtherClassLevel(save))
|
||||
: null
|
||||
const baseRoguelikeReward = Math.round(dungeon.experienceReward * difficulty.experienceMultiplier * (encountersCleared / 3))
|
||||
const newExperience = scaledReward
|
||||
? scaledReward.experience
|
||||
: Math.min(
|
||||
previousExperience
|
||||
+ Math.round(dungeon.experienceReward * difficulty.experienceMultiplier * (encountersCleared / 3)),
|
||||
+ catchUpExperienceReward(
|
||||
baseRoguelikeReward,
|
||||
previousExperience,
|
||||
previousLevel,
|
||||
highestOtherClassLevel(save),
|
||||
),
|
||||
maxExperience,
|
||||
)
|
||||
let newLevel = scaledReward?.level ?? previousLevel
|
||||
@@ -1041,6 +1128,34 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
||||
)!
|
||||
const talent = gameClass.talents.find((candidate) => candidate.id === talentId)
|
||||
if (!talent) throw new Error('That talent does not belong to the active class.')
|
||||
if (save.activeClassId === 1) {
|
||||
if ((cd.talentRanks[String(talentId)] ?? 0) > 0) {
|
||||
cd.talentRanks[String(talentId)] = 0
|
||||
} else {
|
||||
const capacity = talentEffectCapacity(cd.level)
|
||||
if (capacity <= 0) throw new Error('Spell effects unlock at level 5.')
|
||||
const source = talentEffectSource(talent.effectType)
|
||||
const sourceConflict = gameClass.talents.find(
|
||||
(candidate) =>
|
||||
candidate.id !== talentId
|
||||
&& (cd.talentRanks[String(candidate.id)] ?? 0) > 0
|
||||
&& talentEffectSource(candidate.effectType) === source,
|
||||
)
|
||||
if (sourceConflict) {
|
||||
throw new Error(`Only one ${source} spell effect can be active.`)
|
||||
}
|
||||
const activeCount = gameClass.talents.reduce(
|
||||
(total, candidate) => total + ((cd.talentRanks[String(candidate.id)] ?? 0) > 0 ? 1 : 0),
|
||||
0,
|
||||
)
|
||||
if (activeCount >= capacity) {
|
||||
throw new Error(`Level ${cd.level} allows ${capacity} active spell effect${capacity === 1 ? '' : 's'}.`)
|
||||
}
|
||||
cd.talentRanks[String(talentId)] = 1
|
||||
}
|
||||
store.writeSave(save)
|
||||
return buildProfile(save)
|
||||
}
|
||||
if (cd.talentPoints <= 0) {
|
||||
throw new Error('No talent points are available.')
|
||||
}
|
||||
@@ -1083,10 +1198,12 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
||||
for (const talent of gameClass.talents) {
|
||||
cd.talentRanks[String(talent.id)] = 0
|
||||
}
|
||||
if (save.activeClassId !== 1) {
|
||||
cd.talentPoints = Math.min(
|
||||
profile.maxTalentPoints,
|
||||
cd.talentPoints + refunded,
|
||||
)
|
||||
}
|
||||
store.writeSave(save)
|
||||
return buildProfile(save)
|
||||
},
|
||||
@@ -1161,11 +1278,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
||||
const profile = buildProfile(save)
|
||||
const recipe = profile.craftingRecipes.find((candidate) => candidate.id === recipeId)
|
||||
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,
|
||||
)
|
||||
const requiresUpgrade = !DIRECT_CRAFT_ITEM_LEVELS.has(recipe.item.itemLevel)
|
||||
if (requiresUpgrade) throw new Error('Upgrade the previous item tier instead.')
|
||||
const missing = recipe.components.find((component) => component.owned < component.quantity)
|
||||
if (missing) {
|
||||
@@ -1361,7 +1474,7 @@ const cachedOnlineRepository: GameRepository = {
|
||||
},
|
||||
loadProfile: () => cachedOnlineLocalRepository.loadProfile(),
|
||||
saveProfile: (classId, abilitySlots) => cachedOnlineLocalRepository.saveProfile(classId, abilitySlots),
|
||||
completeDungeon: (dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds) =>
|
||||
completeDungeon: (dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds, hardMode) =>
|
||||
cachedOnlineLocalRepository.completeDungeon(
|
||||
dungeonId,
|
||||
difficultyId,
|
||||
@@ -1370,6 +1483,7 @@ const cachedOnlineRepository: GameRepository = {
|
||||
completedPart,
|
||||
startPart,
|
||||
partDurationSeconds,
|
||||
hardMode,
|
||||
),
|
||||
completeRoguelike: (dungeonId, difficultyId, encountersCleared, resourceSpent, durationSeconds, options) =>
|
||||
cachedOnlineLocalRepository.completeRoguelike(
|
||||
|
||||
+18
-17
@@ -35,6 +35,7 @@ export const INPUT_ACTIONS = [
|
||||
'targetParty5',
|
||||
'targetParty6',
|
||||
'toggleTargetGroup',
|
||||
'toggleSpeed',
|
||||
'pause',
|
||||
] as const
|
||||
|
||||
@@ -63,6 +64,7 @@ export const ACTION_LABELS: Record<InputAction, string> = {
|
||||
targetParty5: 'Target Party Member 5',
|
||||
targetParty6: 'Target Party Member 6',
|
||||
toggleTargetGroup: 'Switch Raid Target Group',
|
||||
toggleSpeed: 'Toggle 2x Speed',
|
||||
pause: 'Pause Menu',
|
||||
}
|
||||
|
||||
@@ -89,6 +91,7 @@ export const DEFAULT_BINDINGS: Record<InputDevice, InputBindings> = {
|
||||
targetParty5: 'F5',
|
||||
targetParty6: 'F6',
|
||||
toggleTargetGroup: 'Tab',
|
||||
toggleSpeed: 'Backquote',
|
||||
pause: 'Escape',
|
||||
},
|
||||
controller: {
|
||||
@@ -111,8 +114,9 @@ export const DEFAULT_BINDINGS: Record<InputDevice, InputBindings> = {
|
||||
targetParty3: 'Button15',
|
||||
targetParty4: 'Button13',
|
||||
targetParty5: 'Button4',
|
||||
targetParty6: 'Button11',
|
||||
targetParty6: 'Button10',
|
||||
toggleTargetGroup: 'Button6',
|
||||
toggleSpeed: 'Button11',
|
||||
pause: 'Button9',
|
||||
},
|
||||
}
|
||||
@@ -121,7 +125,6 @@ const STORAGE_KEY = 'ashen-halls-input-bindings-v1'
|
||||
const PREFERENCES_STORAGE_KEY = 'ashen-halls-input-preferences-v1'
|
||||
const GAME_ACTION_EVENT = 'ashen-halls-game-action'
|
||||
const NATIVE_CONTROLLER_EVENT = 'ashen-halls-native-controller'
|
||||
const COMBAT_TARGET_NAVIGATION_THROTTLE_MS = 220
|
||||
|
||||
type CaptureState = {
|
||||
device: InputDevice
|
||||
@@ -146,7 +149,8 @@ const InputContext = createContext<InputContextValue | null>(null)
|
||||
function loadBindings(): Record<InputDevice, InputBindings> {
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') as Partial<Record<InputDevice, Partial<InputBindings>>>
|
||||
const controller = { ...DEFAULT_BINDINGS.controller, ...saved.controller }
|
||||
const savedController = saved.controller
|
||||
const controller = { ...DEFAULT_BINDINGS.controller, ...savedController }
|
||||
const usesLegacyAbilityDefaults = [
|
||||
'Button2',
|
||||
'Button3',
|
||||
@@ -167,6 +171,15 @@ function loadBindings(): Record<InputDevice, InputBindings> {
|
||||
ability6: DEFAULT_BINDINGS.controller.ability6,
|
||||
})
|
||||
}
|
||||
if (savedController?.toggleSpeed === 'Button7') {
|
||||
controller.toggleSpeed = DEFAULT_BINDINGS.controller.toggleSpeed
|
||||
}
|
||||
if (savedController?.ability6 === 'Button10') {
|
||||
controller.ability6 = DEFAULT_BINDINGS.controller.ability6
|
||||
}
|
||||
if (savedController?.targetParty6 === 'Button11') {
|
||||
controller.targetParty6 = DEFAULT_BINDINGS.controller.targetParty6
|
||||
}
|
||||
return {
|
||||
pc: { ...DEFAULT_BINDINGS.pc, ...saved.pc },
|
||||
controller,
|
||||
@@ -277,14 +290,6 @@ function hasUiOverlay() {
|
||||
).some(isVisible)
|
||||
}
|
||||
|
||||
function isCombatTargetAction(action: InputAction) {
|
||||
return action.startsWith('navigate')
|
||||
|| action.startsWith('targetParty')
|
||||
|| action === 'previousTarget'
|
||||
|| action === 'nextTarget'
|
||||
|| action === 'toggleTargetGroup'
|
||||
}
|
||||
|
||||
const BUTTON_LABELS: Record<number, string> = {
|
||||
0: 'A / Cross',
|
||||
1: 'B / Circle',
|
||||
@@ -398,7 +403,6 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
||||
const keyboardInputRef = useRef(keyboardInput)
|
||||
const previousTokensRef = useRef(new Set<string>())
|
||||
const repeatRef = useRef<Record<string, number>>({})
|
||||
const lastCombatNavigationRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
bindingsRef.current = bindings
|
||||
@@ -445,11 +449,6 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
||||
const dispatchAction = useCallback((action: InputAction, device: InputDevice) => {
|
||||
const uiOverlay = hasUiOverlay()
|
||||
const combatActive = Boolean(document.querySelector('[data-combat-active="true"]'))
|
||||
if (combatActive && !uiOverlay && isCombatTargetAction(action)) {
|
||||
const now = performance.now()
|
||||
if (now - lastCombatNavigationRef.current < COMBAT_TARGET_NAVIGATION_THROTTLE_MS) return
|
||||
lastCombatNavigationRef.current = now
|
||||
}
|
||||
|
||||
setLastDevice(device)
|
||||
document.documentElement.dataset.inputDevice = device
|
||||
@@ -519,9 +518,11 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
||||
'targetParty5',
|
||||
'targetParty6',
|
||||
'toggleTargetGroup',
|
||||
'toggleSpeed',
|
||||
] satisfies InputAction[]
|
||||
const combatPriority = [
|
||||
'pause',
|
||||
'toggleSpeed',
|
||||
'ability1',
|
||||
'ability2',
|
||||
'ability3',
|
||||
|
||||
+3930
-1906
File diff suppressed because it is too large
Load Diff
@@ -168,6 +168,7 @@ export type Dungeon = {
|
||||
difficulties: Difficulty[]
|
||||
encounters: DungeonEncounter[]
|
||||
completionLoot: Array<Omit<Item, 'quantity' | 'equipped'>>
|
||||
completionCount?: number
|
||||
leaderboard: LeaderboardEntry[]
|
||||
leaderboards: {
|
||||
part_1: LeaderboardEntry[]
|
||||
@@ -319,6 +320,7 @@ export async function completeDungeon(
|
||||
completedPart?: number,
|
||||
startPart?: number,
|
||||
partDurationSeconds?: [number, number, number],
|
||||
hardMode?: boolean,
|
||||
): Promise<DungeonReward> {
|
||||
return activeGameRepository().completeDungeon(
|
||||
dungeonId,
|
||||
@@ -328,6 +330,7 @@ export async function completeDungeon(
|
||||
completedPart,
|
||||
startPart,
|
||||
partDurationSeconds,
|
||||
hardMode,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user