Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 66f5af4484 | |||
| 8f5a957963 | |||
| f8a1fbc5e2 | |||
| bab2dce6c3 | |||
| cb38042eca | |||
| 753bba581a | |||
| 2300973164 | |||
| 1281be69d8 | |||
| 4fc15ebe9a | |||
| 7313c968e6 |
@@ -4,5 +4,6 @@
|
|||||||
- AYN Thor secondary display: 3.92-inch AMOLED, 1240 x 1080, 60Hz.
|
- 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.
|
- 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.
|
- 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.
|
- User rebuilds app; do not rebuild APK unless explicitly requested.
|
||||||
- Apply game changes to both web version and mobile app version.
|
- 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.
Binary file not shown.
@@ -7,8 +7,8 @@ android {
|
|||||||
applicationId "com.warren.iwanttoheal"
|
applicationId "com.warren.iwanttoheal"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 49
|
versionCode 60
|
||||||
versionName "1.0.31"
|
versionName "1.0.40"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
|
|||||||
+553
-49
@@ -106,9 +106,8 @@ SET slug = 'rathian',
|
|||||||
image_url = COALESCE(NULLIF(image_url, ''), '/boss-placeholder.svg')
|
image_url = COALESCE(NULLIF(image_url, ''), '/boss-placeholder.svg')
|
||||||
WHERE id = 22;
|
WHERE id = 22;
|
||||||
|
|
||||||
INSERT OR IGNORE INTO mechanics
|
WITH mechanics_seed(id, encounter_id, name, mechanic_type, interval_seconds, power, description) AS (
|
||||||
(id, encounter_id, name, mechanic_type, interval_seconds, power, description)
|
VALUES
|
||||||
VALUES
|
|
||||||
(1, 3, 'Cinder Pulse', 'party_damage', 4.9, 12, 'Deals damage to the full party.'),
|
(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.'),
|
(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.'),
|
(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.'),
|
(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.'),
|
(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.'),
|
(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
|
INSERT OR IGNORE INTO classes
|
||||||
(id, slug, name, resource_name, max_resource, theme_color, description)
|
(id, slug, name, resource_name, max_resource, theme_color, description)
|
||||||
@@ -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 = 15 WHERE slug = 'second-sun';
|
||||||
UPDATE spells SET unlock_level = 20 WHERE slug = 'daybreak';
|
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
|
INSERT OR IGNORE INTO items
|
||||||
(id, slug, name, slot, rarity, item_level, healing_power, max_resource_bonus, glyph, description)
|
(id, slug, name, slot, rarity, item_level, healing_power, max_resource_bonus, glyph, description)
|
||||||
VALUES
|
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 = 'b' WHERE slug = 'cinderstep-boots';
|
||||||
UPDATE items SET glyph = '^' WHERE slug = 'adepts-hood';
|
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;
|
DELETE FROM encounter_loot;
|
||||||
|
|
||||||
INSERT INTO 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
|
-- Coin gearing override: every boss/difficulty drops one boss coin, and each
|
||||||
-- craft costs the target item level in that source boss coin.
|
-- craft costs the target item level in that source boss coin.
|
||||||
UPDATE crafting_recipes
|
UPDATE crafting_recipes
|
||||||
SET source_dungeon_id = 1,
|
SET source_dungeon_id = CASE WHEN EXISTS (SELECT 1 FROM encounters WHERE id = 3) THEN 1 ELSE NULL END,
|
||||||
source_encounter_id = 3
|
source_encounter_id = (SELECT id FROM encounters WHERE id = 3)
|
||||||
WHERE id BETWEEN 1001 AND 1409
|
WHERE id BETWEEN 1001 AND 1409
|
||||||
AND item_id IN (SELECT id FROM items WHERE slot IN ('helmet', 'chest', 'gloves'));
|
AND item_id IN (SELECT id FROM items WHERE slot IN ('helmet', 'chest', 'gloves'));
|
||||||
|
|
||||||
UPDATE crafting_recipes
|
UPDATE crafting_recipes
|
||||||
SET source_dungeon_id = 1,
|
SET source_dungeon_id = CASE WHEN EXISTS (SELECT 1 FROM encounters WHERE id = 12) THEN 1 ELSE NULL END,
|
||||||
source_encounter_id = 12
|
source_encounter_id = (SELECT id FROM encounters WHERE id = 12)
|
||||||
WHERE id BETWEEN 1001 AND 1409
|
WHERE id BETWEEN 1001 AND 1409
|
||||||
AND item_id IN (SELECT id FROM items WHERE slot IN ('boots', 'ring', 'trinket'));
|
AND item_id IN (SELECT id FROM items WHERE slot IN ('boots', 'ring', 'trinket'));
|
||||||
|
|
||||||
UPDATE crafting_recipes
|
UPDATE crafting_recipes
|
||||||
SET source_dungeon_id = 1,
|
SET source_dungeon_id = CASE WHEN EXISTS (SELECT 1 FROM encounters WHERE id = 22) THEN 1 ELSE NULL END,
|
||||||
source_encounter_id = 22
|
source_encounter_id = (SELECT id FROM encounters WHERE id = 22)
|
||||||
WHERE id BETWEEN 1001 AND 1409
|
WHERE id BETWEEN 1001 AND 1409
|
||||||
AND item_id IN (SELECT id FROM items WHERE slot IN ('weapon', 'pants', 'necklace'));
|
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);
|
WHERE id IN (SELECT item_id FROM crafting_recipes);
|
||||||
|
|
||||||
UPDATE items
|
UPDATE items
|
||||||
SET name = (
|
SET name = COALESCE((
|
||||||
SELECT
|
SELECT
|
||||||
CASE items.item_level
|
CASE items.item_level
|
||||||
WHEN 1 THEN 'Raw '
|
WHEN 1 THEN 'Raw '
|
||||||
|
WHEN 5 THEN 'Honed '
|
||||||
WHEN 10 THEN 'Green '
|
WHEN 10 THEN 'Green '
|
||||||
|
WHEN 15 THEN 'Blue '
|
||||||
WHEN 20 THEN 'Purple '
|
WHEN 20 THEN 'Purple '
|
||||||
WHEN 25 THEN 'Orange '
|
WHEN 25 THEN 'Orange '
|
||||||
ELSE ''
|
ELSE ''
|
||||||
@@ -513,14 +604,14 @@ SET name = (
|
|||||||
JOIN encounters ON encounters.id = crafting_recipes.source_encounter_id
|
JOIN encounters ON encounters.id = crafting_recipes.source_encounter_id
|
||||||
WHERE crafting_recipes.item_id = items.id
|
WHERE crafting_recipes.item_id = items.id
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
),
|
), name),
|
||||||
description = (
|
description = COALESCE((
|
||||||
SELECT 'Crafted with ' || encounters.name || ' coins.'
|
SELECT 'Crafted with ' || encounters.name || ' coins.'
|
||||||
FROM crafting_recipes
|
FROM crafting_recipes
|
||||||
JOIN encounters ON encounters.id = crafting_recipes.source_encounter_id
|
JOIN encounters ON encounters.id = crafting_recipes.source_encounter_id
|
||||||
WHERE crafting_recipes.item_id = items.id
|
WHERE crafting_recipes.item_id = items.id
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
)
|
), description)
|
||||||
WHERE id IN (SELECT item_id FROM crafting_recipes);
|
WHERE id IN (SELECT item_id FROM crafting_recipes);
|
||||||
|
|
||||||
CREATE TEMP TABLE IF NOT EXISTS coin_sources (
|
CREATE TEMP TABLE IF NOT EXISTS coin_sources (
|
||||||
@@ -539,11 +630,11 @@ DELETE FROM coin_sources;
|
|||||||
|
|
||||||
INSERT INTO coin_sources
|
INSERT INTO coin_sources
|
||||||
SELECT
|
SELECT
|
||||||
280000 + encounters.id * 100 + difficulties.dropped_item_level,
|
280000 + encounters.id * 1000 + difficulties.id,
|
||||||
encounters.id,
|
encounters.id,
|
||||||
difficulties.id,
|
difficulties.id,
|
||||||
difficulties.dropped_item_level,
|
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
|
CASE difficulties.dropped_item_level
|
||||||
WHEN 1 THEN 'Raw '
|
WHEN 1 THEN 'Raw '
|
||||||
WHEN 5 THEN 'Honed '
|
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
|
SELECT item_id, slug, name, 'component', rarity, item_level, 0, 0, glyph, description
|
||||||
FROM coin_sources;
|
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
|
UPDATE items
|
||||||
SET slug = (SELECT slug FROM coin_sources WHERE coin_sources.item_id = items.id),
|
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),
|
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
|
ON coin_sources.encounter_id = crafting_recipes.source_encounter_id
|
||||||
AND coin_sources.difficulty_id = crafting_recipes.difficulty_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
|
INSERT OR IGNORE INTO talents
|
||||||
(id, class_id, slug, name, max_rank, tier, branch, prerequisite_talent_id, prerequisite_rank, effect_type, effect_value_per_rank, glyph, description)
|
(id, class_id, slug, name, max_rank, tier, branch, prerequisite_talent_id, prerequisite_rank, effect_type, effect_value_per_rank, glyph, description)
|
||||||
VALUES
|
VALUES
|
||||||
(1, 1, 'bright-reserves', 'Bright Reserves', 5, 1, 1, NULL, 0, 'max_resource', 2, 'M', 'Increases maximum Mana by 2 per rank.'),
|
(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, 'gentle-dawn', 'Gentle Dawn', 5, 1, 2, NULL, 0, 'hot_power_percent', 2, '~', 'Increases healing-over-time power by 2% per rank.'),
|
(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, 'steady-hands', 'Steady Hands', 5, 1, 3, NULL, 0, 'direct_heal_percent', 2, '+', 'Increases direct healing by 2% per rank.'),
|
(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, '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.'),
|
(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, '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.'),
|
(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, '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.'),
|
(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, '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.'),
|
(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, '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.'),
|
(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.'),
|
||||||
(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.'),
|
|
||||||
|
|
||||||
(3, 2, 'deep-roots', 'Deep Roots', 5, 1, 1, NULL, 0, 'max_resource', 2, 'R', 'Increases maximum Bloom by 2 per rank.'),
|
(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.'),
|
(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)
|
(item_level, dungeon_id, raid_id, dungeon_difficulty_id, raid_difficulty_id, recipe_base, craft_quantity)
|
||||||
VALUES
|
VALUES
|
||||||
(10, 3, 2, 2, 101, 1100, 2),
|
(10, 3, 2, 2, 101, 1100, 2),
|
||||||
|
(15, 4, 5, 3, 103, 1200, 3),
|
||||||
(20, 6, 7, 4, 104, 1300, 4),
|
(20, 6, 7, 4, 104, 1300, 4),
|
||||||
(25, 8, 9, 5, 105, 1400, 5);
|
(25, 8, 9, 5, 105, 1400, 5);
|
||||||
|
|
||||||
@@ -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.');
|
(3, 'monster-frontier', 'The Monster Frontier', 'Hunting grounds used for tiered monster-part progression.');
|
||||||
|
|
||||||
UPDATE dungeons
|
UPDATE dungeons
|
||||||
SET slug = 'tigrex-raid',
|
SET slug = 'legacy-generated-raid-2',
|
||||||
name = 'Tigrex Raid',
|
name = 'Legacy Generated Raid 2',
|
||||||
location_id = 3,
|
location_id = 3,
|
||||||
recommended_level = 5,
|
recommended_level = 5,
|
||||||
content_type = 'raid',
|
content_type = 'raid',
|
||||||
@@ -866,6 +974,68 @@ SELECT dungeon_id, dungeon_difficulty_id FROM generated_loot_tiers
|
|||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT raid_id, raid_difficulty_id FROM generated_loot_tiers;
|
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
|
UPDATE encounters
|
||||||
SET slug = CASE id
|
SET slug = CASE id
|
||||||
WHEN 100 THEN 'tigrex-raid-approach'
|
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.'
|
WHEN 108 THEN 'Gypceros drops boss coins for item level 10 crafting.'
|
||||||
ELSE 'Hunters clear the raid path.'
|
ELSE 'Hunters clear the raid path.'
|
||||||
END
|
END
|
||||||
WHERE id BETWEEN 100 AND 108;
|
WHERE id BETWEEN 100 AND 108
|
||||||
|
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
|
INSERT OR IGNORE INTO encounters
|
||||||
(id, dungeon_id, sequence, slug, name, encounter_type, max_health, base_damage, tank_damage, party_damage, description)
|
(id, dungeon_id, sequence, slug, name, encounter_type, max_health, base_damage, tank_damage, party_damage, description)
|
||||||
@@ -986,7 +1172,12 @@ SELECT
|
|||||||
1.0
|
1.0
|
||||||
FROM generated_loot_tiers
|
FROM generated_loot_tiers
|
||||||
JOIN generated_bosses ON generated_bosses.item_level = generated_loot_tiers.item_level
|
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)
|
INSERT OR IGNORE INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance)
|
||||||
SELECT
|
SELECT
|
||||||
@@ -1000,7 +1191,15 @@ SELECT
|
|||||||
1.0
|
1.0
|
||||||
FROM generated_loot_tiers
|
FROM generated_loot_tiers
|
||||||
JOIN generated_bosses ON generated_bosses.item_level = generated_loot_tiers.item_level
|
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 (
|
CREATE TEMP TABLE IF NOT EXISTS generated_recipe_offsets (
|
||||||
recipe_offset INTEGER PRIMARY KEY,
|
recipe_offset INTEGER PRIMARY KEY,
|
||||||
@@ -1028,12 +1227,8 @@ SET source_dungeon_id = (
|
|||||||
WHERE id BETWEEN 1101 AND 1409;
|
WHERE id BETWEEN 1101 AND 1409;
|
||||||
|
|
||||||
UPDATE crafting_recipes
|
UPDATE crafting_recipes
|
||||||
SET source_dungeon_id = 2,
|
SET source_dungeon_id = NULL,
|
||||||
source_encounter_id = 102 + (
|
source_encounter_id = NULL
|
||||||
SELECT generated_recipe_offsets.boss_index * 3
|
|
||||||
FROM generated_recipe_offsets
|
|
||||||
WHERE crafting_recipes.id = 2000 + generated_recipe_offsets.recipe_offset
|
|
||||||
)
|
|
||||||
WHERE id BETWEEN 2001 AND 2009;
|
WHERE id BETWEEN 2001 AND 2009;
|
||||||
|
|
||||||
DELETE FROM crafting_recipe_components
|
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.
|
-- Final coin gearing override. Keep this after legacy loot edits.
|
||||||
UPDATE crafting_recipes
|
UPDATE crafting_recipes
|
||||||
SET source_dungeon_id = 1,
|
SET source_dungeon_id = CASE WHEN EXISTS (SELECT 1 FROM encounters WHERE id = 3) THEN 1 ELSE NULL END,
|
||||||
source_encounter_id = 3
|
source_encounter_id = (SELECT id FROM encounters WHERE id = 3)
|
||||||
WHERE id BETWEEN 1001 AND 1409
|
WHERE id BETWEEN 1001 AND 1409
|
||||||
AND item_id IN (SELECT id FROM items WHERE slot IN ('helmet', 'chest', 'gloves'));
|
AND item_id IN (SELECT id FROM items WHERE slot IN ('helmet', 'chest', 'gloves'));
|
||||||
|
|
||||||
UPDATE crafting_recipes
|
UPDATE crafting_recipes
|
||||||
SET source_dungeon_id = 1,
|
SET source_dungeon_id = CASE WHEN EXISTS (SELECT 1 FROM encounters WHERE id = 12) THEN 1 ELSE NULL END,
|
||||||
source_encounter_id = 12
|
source_encounter_id = (SELECT id FROM encounters WHERE id = 12)
|
||||||
WHERE id BETWEEN 1001 AND 1409
|
WHERE id BETWEEN 1001 AND 1409
|
||||||
AND item_id IN (SELECT id FROM items WHERE slot IN ('boots', 'ring', 'trinket'));
|
AND item_id IN (SELECT id FROM items WHERE slot IN ('boots', 'ring', 'trinket'));
|
||||||
|
|
||||||
UPDATE crafting_recipes
|
UPDATE crafting_recipes
|
||||||
SET source_dungeon_id = 1,
|
SET source_dungeon_id = CASE WHEN EXISTS (SELECT 1 FROM encounters WHERE id = 22) THEN 1 ELSE NULL END,
|
||||||
source_encounter_id = 22
|
source_encounter_id = (SELECT id FROM encounters WHERE id = 22)
|
||||||
WHERE id BETWEEN 1001 AND 1409
|
WHERE id BETWEEN 1001 AND 1409
|
||||||
AND item_id IN (SELECT id FROM items WHERE slot IN ('weapon', 'pants', 'necklace'));
|
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
|
SELECT crafting_recipes.id
|
||||||
FROM crafting_recipes
|
FROM crafting_recipes
|
||||||
JOIN items ON items.id = crafting_recipes.item_id
|
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
|
DELETE FROM crafting_recipes
|
||||||
WHERE item_id IN (
|
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
|
UPDATE items
|
||||||
SET rarity = CASE item_level
|
SET rarity = CASE item_level
|
||||||
WHEN 1 THEN 'common'
|
WHEN 1 THEN 'common'
|
||||||
|
WHEN 5 THEN 'uncommon'
|
||||||
WHEN 10 THEN 'uncommon'
|
WHEN 10 THEN 'uncommon'
|
||||||
|
WHEN 15 THEN 'rare'
|
||||||
WHEN 20 THEN 'epic'
|
WHEN 20 THEN 'epic'
|
||||||
WHEN 25 THEN 'legendary'
|
WHEN 25 THEN 'legendary'
|
||||||
ELSE rarity
|
ELSE rarity
|
||||||
@@ -1296,11 +1493,13 @@ SET rarity = CASE item_level
|
|||||||
WHERE id IN (SELECT item_id FROM crafting_recipes);
|
WHERE id IN (SELECT item_id FROM crafting_recipes);
|
||||||
|
|
||||||
UPDATE items
|
UPDATE items
|
||||||
SET name = (
|
SET name = COALESCE((
|
||||||
SELECT
|
SELECT
|
||||||
CASE items.item_level
|
CASE items.item_level
|
||||||
WHEN 1 THEN 'Raw '
|
WHEN 1 THEN 'Raw '
|
||||||
|
WHEN 5 THEN 'Honed '
|
||||||
WHEN 10 THEN 'Green '
|
WHEN 10 THEN 'Green '
|
||||||
|
WHEN 15 THEN 'Blue '
|
||||||
WHEN 20 THEN 'Purple '
|
WHEN 20 THEN 'Purple '
|
||||||
WHEN 25 THEN 'Orange '
|
WHEN 25 THEN 'Orange '
|
||||||
ELSE ''
|
ELSE ''
|
||||||
@@ -1322,25 +1521,318 @@ SET name = (
|
|||||||
JOIN encounters ON encounters.id = crafting_recipes.source_encounter_id
|
JOIN encounters ON encounters.id = crafting_recipes.source_encounter_id
|
||||||
WHERE crafting_recipes.item_id = items.id
|
WHERE crafting_recipes.item_id = items.id
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
),
|
), name),
|
||||||
description = (
|
description = COALESCE((
|
||||||
SELECT 'Crafted with ' || encounters.name || ' coins.'
|
SELECT 'Crafted with ' || encounters.name || ' coins.'
|
||||||
FROM crafting_recipes
|
FROM crafting_recipes
|
||||||
JOIN encounters ON encounters.id = crafting_recipes.source_encounter_id
|
JOIN encounters ON encounters.id = crafting_recipes.source_encounter_id
|
||||||
WHERE crafting_recipes.item_id = items.id
|
WHERE crafting_recipes.item_id = items.id
|
||||||
LIMIT 1
|
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);
|
WHERE id IN (SELECT item_id FROM crafting_recipes);
|
||||||
|
|
||||||
DELETE FROM coin_sources;
|
DELETE FROM coin_sources;
|
||||||
|
|
||||||
INSERT INTO coin_sources
|
INSERT INTO coin_sources
|
||||||
SELECT
|
SELECT
|
||||||
280000 + encounters.id * 100 + difficulties.dropped_item_level,
|
280000 + encounters.id * 1000 + difficulties.id,
|
||||||
encounters.id,
|
encounters.id,
|
||||||
difficulties.id,
|
difficulties.id,
|
||||||
difficulties.dropped_item_level,
|
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
|
CASE difficulties.dropped_item_level
|
||||||
WHEN 1 THEN 'Raw '
|
WHEN 1 THEN 'Raw '
|
||||||
WHEN 5 THEN 'Honed '
|
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
|
SELECT item_id, slug, name, 'component', rarity, item_level, 0, 0, glyph, description
|
||||||
FROM coin_sources;
|
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
|
UPDATE items
|
||||||
SET slug = (SELECT slug FROM coin_sources WHERE coin_sources.item_id = items.id),
|
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),
|
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_URL="https://git.whoagland.com"
|
||||||
export GITEA_OWNER="phenom"
|
export GITEA_OWNER="phenom"
|
||||||
export GITEA_REPO="i-want-to-heal"
|
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"
|
APK="IWantToHeal-Thor-v$VERSION.apk"
|
||||||
|
|
||||||
RELEASE_JSON=$(curl -sS -X POST "$GITEA_URL/api/v1/repos/$GITEA_OWNER/$GITEA_REPO/releases" \
|
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 |
+141
-25
@@ -22,6 +22,7 @@ const bossImageContentTypes = {
|
|||||||
}
|
}
|
||||||
const equipmentSlots = ['weapon', 'helmet', 'chest', 'gloves', 'boots', 'pants', 'ring', 'necklace', 'trinket']
|
const equipmentSlots = ['weapon', 'helmet', 'chest', 'gloves', 'boots', 'pants', 'ring', 'necklace', 'trinket']
|
||||||
const componentSlot = 'component'
|
const componentSlot = 'component'
|
||||||
|
const directCraftItemLevels = new Set([1, 10, 20, 25])
|
||||||
const sessionCookieName = 'chronicle_session'
|
const sessionCookieName = 'chronicle_session'
|
||||||
const sessionLifetimeSeconds = 60 * 60 * 24 * 30
|
const sessionLifetimeSeconds = 60 * 60 * 24 * 30
|
||||||
const rateLimitBuckets = new Map()
|
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) {
|
function normalizeUsername(value) {
|
||||||
const username = String(value ?? '').trim()
|
const username = String(value ?? '').trim()
|
||||||
if (!/^[A-Za-z0-9_]{3,20}$/.test(username)) {
|
if (!/^[A-Za-z0-9_]{3,20}$/.test(username)) {
|
||||||
@@ -770,6 +790,15 @@ export function getProfile(database, characterId, accountId) {
|
|||||||
WHERE rank <= 10
|
WHERE rank <= 10
|
||||||
ORDER BY dungeonId, difficultyId, startPart, completedParts, rank
|
ORDER BY dungeonId, difficultyId, startPart, completedParts, rank
|
||||||
`).all()
|
`).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(
|
const settings = Object.fromEntries(
|
||||||
database.prepare('SELECT key, value FROM game_settings').all()
|
database.prepare('SELECT key, value FROM game_settings').all()
|
||||||
@@ -849,6 +878,7 @@ export function getProfile(database, characterId, accountId) {
|
|||||||
}),
|
}),
|
||||||
dungeons: dungeons.map((dungeon) => ({
|
dungeons: dungeons.map((dungeon) => ({
|
||||||
...dungeon,
|
...dungeon,
|
||||||
|
completionCount: dungeonCompletionCounts.get(dungeon.id) ?? 0,
|
||||||
difficulties: dungeonDifficulties.filter(
|
difficulties: dungeonDifficulties.filter(
|
||||||
(difficulty) => difficulty.dungeonId === dungeon.id,
|
(difficulty) => difficulty.dungeonId === dungeon.id,
|
||||||
),
|
),
|
||||||
@@ -1693,16 +1723,9 @@ function craftItem(database, characterId, recipeId) {
|
|||||||
WHERE crafting_recipes.id = ?
|
WHERE crafting_recipes.id = ?
|
||||||
`).get(recipeId)
|
`).get(recipeId)
|
||||||
if (!recipe) throw new Error('That crafting recipe does not exist.')
|
if (!recipe) throw new Error('That crafting recipe does not exist.')
|
||||||
const lowerTierRecipe = database.prepare(`
|
if (!directCraftItemLevels.has(recipe.itemLevel)) {
|
||||||
SELECT crafting_recipes.id
|
throw new Error('Upgrade the previous item tier instead.')
|
||||||
FROM crafting_recipes
|
}
|
||||||
JOIN items ON items.id = crafting_recipes.item_id
|
|
||||||
WHERE crafting_recipes.source_encounter_id = ?
|
|
||||||
AND items.slot = ?
|
|
||||||
AND items.item_level < ?
|
|
||||||
LIMIT 1
|
|
||||||
`).get(recipe.sourceEncounterId, recipe.slot, recipe.itemLevel)
|
|
||||||
if (lowerTierRecipe) throw new Error('Upgrade the previous item tier instead.')
|
|
||||||
|
|
||||||
const components = database.prepare(`
|
const components = database.prepare(`
|
||||||
SELECT
|
SELECT
|
||||||
@@ -1849,9 +1872,20 @@ function upgradeItem(database, characterId, itemId) {
|
|||||||
return getProfile(database, characterId)
|
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) {
|
function allocateTalent(database, characterId, talentId) {
|
||||||
const character = database.prepare(`
|
const character = database.prepare(`
|
||||||
SELECT class_id AS classId, talent_points AS talentPoints
|
SELECT class_id AS classId, level, talent_points AS talentPoints
|
||||||
FROM characters
|
FROM characters
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`).get(characterId)
|
`).get(characterId)
|
||||||
@@ -1863,7 +1897,8 @@ function allocateTalent(database, characterId, talentId) {
|
|||||||
max_rank AS maxRank,
|
max_rank AS maxRank,
|
||||||
tier,
|
tier,
|
||||||
prerequisite_talent_id AS prerequisiteTalentId,
|
prerequisite_talent_id AS prerequisiteTalentId,
|
||||||
prerequisite_rank AS prerequisiteRank
|
prerequisite_rank AS prerequisiteRank,
|
||||||
|
effect_type AS effectType
|
||||||
FROM talents
|
FROM talents
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`).get(talentId)
|
`).get(talentId)
|
||||||
@@ -1871,6 +1906,60 @@ function allocateTalent(database, characterId, talentId) {
|
|||||||
if (!talent || talent.classId !== character.classId) {
|
if (!talent || talent.classId !== character.classId) {
|
||||||
throw new Error('That talent does not belong to the active class.')
|
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) {
|
if (character.talentPoints <= 0) {
|
||||||
throw new Error('No talent points are available.')
|
throw new Error('No talent points are available.')
|
||||||
}
|
}
|
||||||
@@ -1949,11 +2038,13 @@ function resetTalents(database, characterId) {
|
|||||||
WHERE character_id = ?
|
WHERE character_id = ?
|
||||||
AND talent_id IN (SELECT id FROM talents WHERE class_id = ?)
|
AND talent_id IN (SELECT id FROM talents WHERE class_id = ?)
|
||||||
`).run(characterId, character.classId)
|
`).run(characterId, character.classId)
|
||||||
database.prepare(`
|
if (character.classId !== 1) {
|
||||||
UPDATE characters
|
database.prepare(`
|
||||||
SET talent_points = MIN(level, talent_points + ?)
|
UPDATE characters
|
||||||
WHERE id = ?
|
SET talent_points = MIN(level, talent_points + ?)
|
||||||
`).run(refunded, characterId)
|
WHERE id = ?
|
||||||
|
`).run(refunded, characterId)
|
||||||
|
}
|
||||||
database.exec('COMMIT')
|
database.exec('COMMIT')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
database.exec('ROLLBACK')
|
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 completedPart = Math.min(Math.max(Number(runMetrics?.completedPart) || 1, 1), 3)
|
||||||
const startPart = Math.min(Math.max(Number(runMetrics?.startPart) || 1, 1), 3)
|
const startPart = Math.min(Math.max(Number(runMetrics?.startPart) || 1, 1), 3)
|
||||||
const completedParts = completedPart - startPart + 1
|
const completedParts = completedPart - startPart + 1
|
||||||
|
const rewardMultiplier = runMetrics?.hardMode ? 2 : 1
|
||||||
const rawPartDurations = runMetrics?.partDurationSeconds
|
const rawPartDurations = runMetrics?.partDurationSeconds
|
||||||
const partDurationSeconds = Array.isArray(rawPartDurations) && rawPartDurations.length === 3
|
const partDurationSeconds = Array.isArray(rawPartDurations) && rawPartDurations.length === 3
|
||||||
? rawPartDurations.map(Number)
|
? rawPartDurations.map(Number)
|
||||||
: null
|
: null
|
||||||
const experienceReward = Math.round(
|
const baseExperienceReward = Math.round(
|
||||||
dungeon.experienceReward * dungeon.experienceMultiplier * completedPart,
|
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 newExperience = Math.min(character.experience + experienceReward, maxExperience)
|
||||||
const newLevel = database.prepare(`
|
const newLevel = database.prepare(`
|
||||||
@@ -2127,17 +2227,18 @@ function completeDungeon(database, characterId, accountId, dungeonId, difficulty
|
|||||||
`).all(dungeonId, dungeon.completionItemLevel ?? dungeon.droppedItemLevel + 3)
|
`).all(dungeonId, dungeon.completionItemLevel ?? dungeon.droppedItemLevel + 3)
|
||||||
if (bonusItems.length > 0) {
|
if (bonusItems.length > 0) {
|
||||||
bonusItem = bonusItems[0]
|
bonusItem = bonusItems[0]
|
||||||
|
const rewardQuantity = rewardMultiplier
|
||||||
const previousQuantity = database.prepare(`
|
const previousQuantity = database.prepare(`
|
||||||
SELECT quantity FROM character_inventory
|
SELECT quantity FROM character_inventory
|
||||||
WHERE character_id = ? AND item_id = ?
|
WHERE character_id = ? AND item_id = ?
|
||||||
`).get(characterId, bonusItem.id)?.quantity ?? 0
|
`).get(characterId, bonusItem.id)?.quantity ?? 0
|
||||||
database.prepare(`
|
database.prepare(`
|
||||||
INSERT INTO character_inventory (character_id, item_id, quantity, equipped)
|
INSERT INTO character_inventory (character_id, item_id, quantity, equipped)
|
||||||
VALUES (?, ?, 1, 0)
|
VALUES (?, ?, ?, 0)
|
||||||
ON CONFLICT(character_id, item_id)
|
ON CONFLICT(character_id, item_id)
|
||||||
DO UPDATE SET quantity = quantity + 1
|
DO UPDATE SET quantity = quantity + ?
|
||||||
`).run(characterId, bonusItem.id)
|
`).run(characterId, bonusItem.id, rewardQuantity, rewardQuantity)
|
||||||
bonusItem = { ...bonusItem, quantity: 1, duplicate: previousQuantity > 0, quantityAfter: previousQuantity + 1 }
|
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 newExperience = character.experience
|
||||||
let newLevel = character.level
|
let newLevel = character.level
|
||||||
if (experienceMode === 'pvp-boss-quarter-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) {
|
for (let bossIndex = 0; bossIndex < bossesCleared && newExperience < maxExperience; bossIndex += 1) {
|
||||||
const currentLevelFloor = database.prepare(`
|
const currentLevelFloor = database.prepare(`
|
||||||
SELECT experience_required AS experienceRequired
|
SELECT experience_required AS experienceRequired
|
||||||
@@ -2248,7 +2355,8 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
|
|||||||
WHERE level = ?
|
WHERE level = ?
|
||||||
`).get(newLevel + 1).experienceRequired
|
`).get(newLevel + 1).experienceRequired
|
||||||
const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor)
|
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(`
|
newLevel = database.prepare(`
|
||||||
SELECT MAX(level) AS level
|
SELECT MAX(level) AS level
|
||||||
FROM level_progression
|
FROM level_progression
|
||||||
@@ -2256,9 +2364,17 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
|
|||||||
`).get(newExperience).level
|
`).get(newExperience).level
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const experienceReward = Math.round(
|
const baseExperienceReward = Math.round(
|
||||||
dungeon.experienceReward * dungeon.experienceMultiplier * (encountersCleared / 3),
|
dungeon.experienceReward * dungeon.experienceMultiplier * (encountersCleared / 3),
|
||||||
)
|
)
|
||||||
|
const experienceReward = catchUpExperienceReward(
|
||||||
|
database,
|
||||||
|
accountId,
|
||||||
|
characterId,
|
||||||
|
baseExperienceReward,
|
||||||
|
character.experience,
|
||||||
|
character.level,
|
||||||
|
)
|
||||||
newExperience = Math.min(character.experience + experienceReward, maxExperience)
|
newExperience = Math.min(character.experience + experienceReward, maxExperience)
|
||||||
newLevel = database.prepare(`
|
newLevel = database.prepare(`
|
||||||
SELECT MAX(level) AS level
|
SELECT MAX(level) AS level
|
||||||
|
|||||||
+464
-2
@@ -1683,7 +1683,8 @@ h2 {
|
|||||||
|
|
||||||
.equipment-screen .equipment-layout,
|
.equipment-screen .equipment-layout,
|
||||||
.equipment-screen .crafting-panel,
|
.equipment-screen .crafting-panel,
|
||||||
.talent-screen .talent-tree {
|
.talent-screen .talent-tree,
|
||||||
|
.talent-screen .spell-effect-layout {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
@@ -1800,6 +1801,35 @@ h2 {
|
|||||||
text-align: right;
|
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 {
|
.tier-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -1919,7 +1949,7 @@ h2 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.part-setup-panel .part-picker {
|
.part-setup-panel .part-picker {
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.part-setup-panel .primary-button {
|
.part-setup-panel .primary-button {
|
||||||
@@ -2058,6 +2088,16 @@ h2 {
|
|||||||
font-size: 12px;
|
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 {
|
.dungeon-run-screen .eyebrow {
|
||||||
font-size: 7px;
|
font-size: 7px;
|
||||||
margin-bottom: 5px;
|
margin-bottom: 5px;
|
||||||
@@ -2262,6 +2302,20 @@ h2 {
|
|||||||
margin-top: 5px;
|
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 {
|
.dungeon-choice-grid {
|
||||||
grid-auto-rows: minmax(52px, max-content);
|
grid-auto-rows: minmax(52px, max-content);
|
||||||
}
|
}
|
||||||
@@ -3444,6 +3498,260 @@ h2 {
|
|||||||
margin-top: 17px;
|
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 {
|
.talent-tier {
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
border-bottom: 1px solid #393943;
|
border-bottom: 1px solid #393943;
|
||||||
@@ -4707,6 +5015,28 @@ h2 {
|
|||||||
box-shadow: inset 0 5px #cf4b59;
|
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 {
|
.combat-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
@@ -5077,6 +5407,19 @@ h2 {
|
|||||||
margin-bottom: 4px;
|
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 {
|
.action-panel .resource-row {
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
@@ -7322,6 +7665,125 @@ h2 {
|
|||||||
margin-top: 4px;
|
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 {
|
.workshop-shell .talent-tier {
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
grid-template-columns: 66px minmax(0, 1fr);
|
grid-template-columns: 66px minmax(0, 1fr);
|
||||||
|
|||||||
+82
-42
@@ -55,6 +55,7 @@ const MENU_ITEMS: Array<{
|
|||||||
|
|
||||||
const LAST_DIFFICULTY_KEY = 'i-want-to-heal:last-difficulty'
|
const LAST_DIFFICULTY_KEY = 'i-want-to-heal:last-difficulty'
|
||||||
const SHOW_LEADERBOARDS = false
|
const SHOW_LEADERBOARDS = false
|
||||||
|
const ACTIVITY_PAGE_SIZE = 6
|
||||||
|
|
||||||
function activityInitials(name: string) {
|
function activityInitials(name: string) {
|
||||||
return name
|
return name
|
||||||
@@ -81,13 +82,14 @@ function App() {
|
|||||||
return Number.isFinite(saved) && saved > 0 ? saved : 1
|
return Number.isFinite(saved) && saved > 0 ? saved : 1
|
||||||
})
|
})
|
||||||
const [selectedDungeonId, setSelectedDungeonId] = useState(1)
|
const [selectedDungeonId, setSelectedDungeonId] = useState(1)
|
||||||
const [selectedRaidId, setSelectedRaidId] = useState(2)
|
const [selectedRaidId, setSelectedRaidId] = useState(20)
|
||||||
const [roguelikeKind, setRoguelikeKind] = useState<'dungeon' | 'raid'>('dungeon')
|
const [roguelikeKind, setRoguelikeKind] = useState<'dungeon' | 'raid'>('dungeon')
|
||||||
const [roguelikeVariant, setRoguelikeVariant] = useState<RoguelikeVariant>('pve')
|
const [roguelikeVariant, setRoguelikeVariant] = useState<RoguelikeVariant>('pve')
|
||||||
const [roguelikeUpgradeTiming, setRoguelikeUpgradeTiming] = useState<RoguelikeUpgradeTiming>('encounter')
|
const [roguelikeUpgradeTiming, setRoguelikeUpgradeTiming] = useState<RoguelikeUpgradeTiming>('encounter')
|
||||||
const [roguelikeAbilityLabelMode, setRoguelikeAbilityLabelMode] = useState<RoguelikeAbilityLabelMode>('ability')
|
const [roguelikeAbilityLabelMode, setRoguelikeAbilityLabelMode] = useState<RoguelikeAbilityLabelMode>('ability')
|
||||||
const [pvpContentType, setPvpContentType] = useState<PvpContentType>('dungeon')
|
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 [combatContentId, setCombatContentId] = useState(1)
|
||||||
const [leaderboardCategory, setLeaderboardCategory] = useState<'part_1' | 'part_2' | 'part_3' | 'full_run'>('part_1')
|
const [leaderboardCategory, setLeaderboardCategory] = useState<'part_1' | 'part_2' | 'part_3' | 'full_run'>('part_1')
|
||||||
const [showLoot, setShowLoot] = useState(false)
|
const [showLoot, setShowLoot] = useState(false)
|
||||||
@@ -230,17 +232,18 @@ function App() {
|
|||||||
const roguelikePool = profile.dungeons
|
const roguelikePool = profile.dungeons
|
||||||
.filter((candidate) => candidate.contentType === roguelikeKind)
|
.filter((candidate) => candidate.contentType === roguelikeKind)
|
||||||
.flatMap((candidate) => candidate.encounters)
|
.flatMap((candidate) => candidate.encounters)
|
||||||
const startPart = selectedPart
|
|
||||||
return (
|
return (
|
||||||
<CombatScreen
|
<CombatScreen
|
||||||
difficulty={difficulty}
|
difficulty={difficulty}
|
||||||
dungeon={dungeon}
|
dungeon={dungeon}
|
||||||
|
hardMode={false}
|
||||||
|
marathonMode={selectedMarathonMode && combatContentId > 0}
|
||||||
profile={profile}
|
profile={profile}
|
||||||
roguelikeMode={combatContentId < 0 ? roguelikeKind : undefined}
|
roguelikeMode={combatContentId < 0 ? roguelikeKind : undefined}
|
||||||
roguelikeUpgradeTiming={combatContentId < 0 ? roguelikeUpgradeTiming : undefined}
|
roguelikeUpgradeTiming={combatContentId < 0 ? roguelikeUpgradeTiming : undefined}
|
||||||
roguelikeAbilityLabelMode={combatContentId < 0 ? roguelikeAbilityLabelMode : undefined}
|
roguelikeAbilityLabelMode={combatContentId < 0 ? roguelikeAbilityLabelMode : undefined}
|
||||||
roguelikeEncounterPool={combatContentId < 0 ? roguelikePool : undefined}
|
roguelikeEncounterPool={combatContentId < 0 ? roguelikePool : undefined}
|
||||||
startPart={startPart}
|
startPart={1}
|
||||||
onExit={() => {
|
onExit={() => {
|
||||||
setScreen(combatContentId < 0 ? 'roguelike' : dungeon.contentType === 'raid' ? 'raids' : 'dungeons')
|
setScreen(combatContentId < 0 ? 'roguelike' : dungeon.contentType === 'raid' ? 'raids' : 'dungeons')
|
||||||
}}
|
}}
|
||||||
@@ -293,7 +296,7 @@ function App() {
|
|||||||
setCombatContentId(-1)
|
setCombatContentId(-1)
|
||||||
setSelectedDifficultyId(baseDungeon?.difficulties[0]?.id ?? 1)
|
setSelectedDifficultyId(baseDungeon?.difficulties[0]?.id ?? 1)
|
||||||
}
|
}
|
||||||
setSelectedPart(1)
|
setSelectedMarathonMode(false)
|
||||||
setScreen('combat')
|
setScreen('combat')
|
||||||
}
|
}
|
||||||
const tierOptions = activityOptions
|
const tierOptions = activityOptions
|
||||||
@@ -312,26 +315,26 @@ function App() {
|
|||||||
?? tierOptions.slice().reverse().find((candidate) => profile.character.level >= candidate.unlockLevel)
|
?? tierOptions.slice().reverse().find((candidate) => profile.character.level >= candidate.unlockLevel)
|
||||||
?? tierOptions[0]
|
?? tierOptions[0]
|
||||||
const selectedTierItemLevel = selectedTier?.droppedItemLevel ?? 0
|
const selectedTierItemLevel = selectedTier?.droppedItemLevel ?? 0
|
||||||
const tierActivityOptions = activityOptions.filter((option) =>
|
const activityPageCount = Math.max(1, Math.ceil(activityOptions.length / ACTIVITY_PAGE_SIZE))
|
||||||
option.difficulties.some((difficulty) => difficulty.droppedItemLevel === selectedTierItemLevel),
|
const currentActivityPage = Math.min(activityPage, activityPageCount - 1)
|
||||||
|
const pagedActivityOptions = activityOptions.slice(
|
||||||
|
currentActivityPage * ACTIVITY_PAGE_SIZE,
|
||||||
|
currentActivityPage * ACTIVITY_PAGE_SIZE + ACTIVITY_PAGE_SIZE,
|
||||||
)
|
)
|
||||||
|
const activityPageStart = activityOptions.length === 0
|
||||||
|
? 0
|
||||||
|
: currentActivityPage * ACTIVITY_PAGE_SIZE + 1
|
||||||
|
const activityPageEnd = Math.min(activityOptions.length, (currentActivityPage + 1) * ACTIVITY_PAGE_SIZE)
|
||||||
const selectedActivityId = screen === 'raids' && raid ? raid.id : dungeon.id
|
const selectedActivityId = screen === 'raids' && raid ? raid.id : dungeon.id
|
||||||
const activity = tierActivityOptions.find((candidate) => candidate.id === selectedActivityId)
|
const activity = activityOptions.find((candidate) => candidate.id === selectedActivityId)
|
||||||
?? tierActivityOptions[0]
|
?? activityOptions[0]
|
||||||
?? (screen === 'raids' && raid ? raid : dungeon)
|
?? (screen === 'raids' && raid ? raid : dungeon)
|
||||||
const selectedDifficulty = activity.difficulties.find(
|
const selectedDifficulty = activity.difficulties.find(
|
||||||
(candidate) => candidate.droppedItemLevel === selectedTierItemLevel,
|
(candidate) => candidate.droppedItemLevel === selectedTierItemLevel,
|
||||||
) ?? activity.difficulties[0]
|
) ?? activity.difficulties[0]
|
||||||
const difficultyLocked = profile.character.level < selectedDifficulty.unlockLevel
|
const difficultyLocked = profile.character.level < selectedDifficulty.unlockLevel
|
||||||
const completedSections = activity.contentType === 'raid'
|
const activityCompletionCount = activity.completionCount ?? 0
|
||||||
? profile.completedRaidPhases
|
const marathonUnlocked = activityCompletionCount >= 10
|
||||||
: 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 cloudSync = getCloudSyncStatus()
|
const cloudSync = getCloudSyncStatus()
|
||||||
const canShowCloudSync = account.id !== -1 && cloudSync.available
|
const canShowCloudSync = account.id !== -1 && cloudSync.available
|
||||||
const lootPreviewEncounters = [...activity.encounters]
|
const lootPreviewEncounters = [...activity.encounters]
|
||||||
@@ -637,10 +640,30 @@ function App() {
|
|||||||
<p className="eyebrow">Pick Run</p>
|
<p className="eyebrow">Pick Run</p>
|
||||||
<h2>{screen === 'raids' ? 'Raid' : 'Dungeon'}</h2>
|
<h2>{screen === 'raids' ? 'Raid' : 'Dungeon'}</h2>
|
||||||
</div>
|
</div>
|
||||||
<small>{selectedDifficulty.name} rewards iLvl {selectedDifficulty.droppedItemLevel} components.</small>
|
{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>{activityPageStart}-{activityPageEnd} of {activityOptions.length}</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>
|
||||||
<div className="activity-card-grid dungeon-choice-grid">
|
<div className="activity-card-grid dungeon-choice-grid">
|
||||||
{tierActivityOptions.map((candidate) => {
|
{pagedActivityOptions.map((candidate) => {
|
||||||
const difficulty = candidate.difficulties.find(
|
const difficulty = candidate.difficulties.find(
|
||||||
(option) => option.droppedItemLevel === selectedDifficulty.droppedItemLevel,
|
(option) => option.droppedItemLevel === selectedDifficulty.droppedItemLevel,
|
||||||
) ?? candidate.difficulties[0]
|
) ?? candidate.difficulties[0]
|
||||||
@@ -692,6 +715,7 @@ function App() {
|
|||||||
disabled={locked}
|
disabled={locked}
|
||||||
key={difficulty.id}
|
key={difficulty.id}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
setActivityPage(0)
|
||||||
const nextActivity = activity.difficulties.some(
|
const nextActivity = activity.difficulties.some(
|
||||||
(candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel,
|
(candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel,
|
||||||
)
|
)
|
||||||
@@ -722,27 +746,43 @@ function App() {
|
|||||||
<div className="run-setup-heading">
|
<div className="run-setup-heading">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Start</p>
|
<p className="eyebrow">Start</p>
|
||||||
<h2>{sectionName}</h2>
|
<h2>Run</h2>
|
||||||
</div>
|
</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>
|
||||||
<div className="part-picker">
|
<div className="part-picker">
|
||||||
{parts.map((p) => (
|
<button
|
||||||
<button
|
className="primary-button selected-part"
|
||||||
key={p.part}
|
disabled={difficultyLocked}
|
||||||
className={`primary-button ${selectedPart === p.part ? 'selected-part' : ''} ${!p.unlocked ? 'locked' : ''}`}
|
onClick={() => {
|
||||||
disabled={difficultyLocked || !p.unlocked}
|
setSelectedMarathonMode(false)
|
||||||
onClick={() => {
|
setCombatContentId(activity.id)
|
||||||
setSelectedPart(p.part)
|
setSelectedDifficultyId(selectedDifficulty.id)
|
||||||
setCombatContentId(activity.id)
|
setScreen('combat')
|
||||||
setSelectedDifficultyId(selectedDifficulty.id)
|
}}
|
||||||
setScreen('combat')
|
type="button"
|
||||||
}}
|
>
|
||||||
type="button"
|
Start Hunt
|
||||||
>
|
</button>
|
||||||
{p.name}
|
<button
|
||||||
</button>
|
className={`primary-button ${selectedMarathonMode ? 'selected-part' : ''} ${!marathonUnlocked ? 'locked' : ''}`}
|
||||||
))}
|
disabled={difficultyLocked || !marathonUnlocked}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedMarathonMode(true)
|
||||||
|
setCombatContentId(activity.id)
|
||||||
|
setSelectedDifficultyId(selectedDifficulty.id)
|
||||||
|
setScreen('combat')
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Marathon
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -855,10 +895,10 @@ function App() {
|
|||||||
</p>
|
</p>
|
||||||
<div className="leaderboard-tabs">
|
<div className="leaderboard-tabs">
|
||||||
{([
|
{([
|
||||||
{ key: 'part_1', label: `${sectionName} 1` },
|
{ key: 'part_1', label: 'Run' },
|
||||||
{ key: 'part_2', label: `${sectionName} 2` },
|
{ key: 'part_2', label: 'Legacy 2' },
|
||||||
{ key: 'part_3', label: `${sectionName} 3` },
|
{ key: 'part_3', label: 'Legacy 3' },
|
||||||
{ key: 'full_run', label: 'Full Run' },
|
{ key: 'full_run', label: 'Legacy Full' },
|
||||||
] as const).map((tab) => (
|
] as const).map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab.key}
|
key={tab.key}
|
||||||
|
|||||||
+371
-85
@@ -43,7 +43,7 @@ const TICK_MS = 700
|
|||||||
type RoguelikeMode = 'dungeon' | 'raid'
|
type RoguelikeMode = 'dungeon' | 'raid'
|
||||||
type RoguelikeUpgradeTiming = 'boss' | 'encounter'
|
type RoguelikeUpgradeTiming = 'boss' | 'encounter'
|
||||||
type RoguelikeAbilityLabelMode = 'ability' | 'slot'
|
type RoguelikeAbilityLabelMode = 'ability' | 'slot'
|
||||||
type SlotKey = '1' | '2' | '3' | '4' | '5'
|
type SlotKey = '1' | '2' | '3' | '4' | '5' | '6'
|
||||||
type RoguelikeMechanic =
|
type RoguelikeMechanic =
|
||||||
| 'party-pulse'
|
| 'party-pulse'
|
||||||
| 'searing-mark'
|
| 'searing-mark'
|
||||||
@@ -105,12 +105,53 @@ function effectiveMaxHealth(member: PartyMember) {
|
|||||||
return Math.max(1, Math.round(member.maxHealth * (member.maxHealthPenaltyTicks && member.maxHealthPenaltyTicks > 0 ? 0.75 : 1)))
|
return Math.max(1, Math.round(member.maxHealth * (member.maxHealthPenaltyTicks && member.maxHealthPenaltyTicks > 0 ? 0.75 : 1)))
|
||||||
}
|
}
|
||||||
|
|
||||||
function healAmount(member: PartyMember, amount: number) {
|
function healAmount(member: PartyMember, amount: number, multiplier = 1) {
|
||||||
return Math.round(amount * (member.healingReductionTicks && member.healingReductionTicks > 0 ? 0.75 : 1))
|
return Math.round(amount * (member.healingReductionTicks && member.healingReductionTicks > 0 ? 0.75 : 1) * multiplier)
|
||||||
}
|
}
|
||||||
|
|
||||||
function healMember(member: PartyMember, amount: number) {
|
function healMember(member: PartyMember, amount: number, multiplier = 1) {
|
||||||
return clamp(member.health + healAmount(member, amount), 0, effectiveMaxHealth(member))
|
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) {
|
function upgradeStackCount(upgrades: RoguelikeUpgrade[], id: RoguelikeUpgradeId) {
|
||||||
@@ -127,7 +168,7 @@ function buildRoguelikeUpgrades(
|
|||||||
spells: Spell[],
|
spells: Spell[],
|
||||||
labelMode: RoguelikeAbilityLabelMode,
|
labelMode: RoguelikeAbilityLabelMode,
|
||||||
): RoguelikeUpgrade[] {
|
): 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)
|
const label = slotLabel(slot, spells, labelMode)
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -195,9 +236,14 @@ function spellResourceCost(spell: Spell, upgrades: RoguelikeUpgrade[], freeCastR
|
|||||||
function toCombatSpell(ability: Ability, key: string, healingPower: number): Spell {
|
function toCombatSpell(ability: Ability, key: string, healingPower: number): Spell {
|
||||||
const kinds: Record<string, Spell['kind']> = {
|
const kinds: Record<string, Spell['kind']> = {
|
||||||
direct_heal: 'direct',
|
direct_heal: 'direct',
|
||||||
|
direct_hot: 'direct',
|
||||||
heal_over_time: 'hot',
|
heal_over_time: 'hot',
|
||||||
party_heal: 'group',
|
party_heal: 'group',
|
||||||
|
party_hot: 'group',
|
||||||
|
party_absorb: 'group',
|
||||||
absorb: 'shield',
|
absorb: 'shield',
|
||||||
|
damage_reduction: 'damage_reduction',
|
||||||
|
bounce_heal: 'bounce_heal',
|
||||||
cleanse: 'cleanse',
|
cleanse: 'cleanse',
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -210,6 +256,7 @@ function toCombatSpell(ability: Ability, key: string, healingPower: number): Spe
|
|||||||
power: ability.power + healingPower,
|
power: ability.power + healingPower,
|
||||||
glyph: ability.glyph,
|
glyph: ability.glyph,
|
||||||
kind: kinds[ability.spellType] ?? 'direct',
|
kind: kinds[ability.spellType] ?? 'direct',
|
||||||
|
effectType: ability.spellType,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,6 +340,8 @@ function makeRoguelikeSegment(
|
|||||||
export function CombatScreen({
|
export function CombatScreen({
|
||||||
difficulty,
|
difficulty,
|
||||||
dungeon,
|
dungeon,
|
||||||
|
hardMode = false,
|
||||||
|
marathonMode = false,
|
||||||
profile,
|
profile,
|
||||||
startPart = 1,
|
startPart = 1,
|
||||||
roguelikeMode,
|
roguelikeMode,
|
||||||
@@ -304,6 +353,8 @@ export function CombatScreen({
|
|||||||
}: {
|
}: {
|
||||||
difficulty: Difficulty
|
difficulty: Difficulty
|
||||||
dungeon: Dungeon
|
dungeon: Dungeon
|
||||||
|
hardMode?: boolean
|
||||||
|
marathonMode?: boolean
|
||||||
profile: CharacterProfile
|
profile: CharacterProfile
|
||||||
startPart?: number
|
startPart?: number
|
||||||
roguelikeMode?: RoguelikeMode
|
roguelikeMode?: RoguelikeMode
|
||||||
@@ -351,23 +402,25 @@ export function CombatScreen({
|
|||||||
})),
|
})),
|
||||||
[dungeon.partySize, profile.character.name],
|
[dungeon.partySize, profile.character.name],
|
||||||
)
|
)
|
||||||
const sectionName = isRoguelike ? 'Stage' : dungeon.contentType === 'raid' ? 'Phase' : 'Part'
|
const sectionName = isRoguelike ? 'Stage' : 'Run'
|
||||||
const contentName = isRoguelike ? 'Roguelike' : dungeon.contentType === 'raid' ? 'Raid' : 'Dungeon'
|
const contentName = isRoguelike ? 'Roguelike' : dungeon.contentType === 'raid' ? 'Raid' : 'Dungeon'
|
||||||
const initialEncounterIndex = (startPart - 1) * 3
|
const initialEncounterIndex = (startPart - 1) * 3
|
||||||
|
const enemyCount = hardMode ? 2 : 1
|
||||||
const initialCombatState = useMemo<SinglePlayerCombatState>(() => ({
|
const initialCombatState = useMemo<SinglePlayerCombatState>(() => ({
|
||||||
party: partyTemplate,
|
party: partyTemplate,
|
||||||
resource: maxResource,
|
resource: maxResource,
|
||||||
enemyHealth: encounters[initialEncounterIndex].maxHealth,
|
enemyHealth: encounters[initialEncounterIndex].maxHealth * enemyCount,
|
||||||
cooldowns: {},
|
cooldowns: {},
|
||||||
elapsedTicks: 0,
|
elapsedTicks: 0,
|
||||||
castsTowardFree: 0,
|
castsTowardFree: 0,
|
||||||
freeCastReady: false,
|
freeCastReady: false,
|
||||||
}), [encounters, initialEncounterIndex, maxResource, partyTemplate])
|
}), [encounters, enemyCount, initialEncounterIndex, maxResource, partyTemplate])
|
||||||
const [combatState, setCombatState] = useState<SinglePlayerCombatState>(() => initialCombatState)
|
const [combatState, setCombatState] = useState<SinglePlayerCombatState>(() => initialCombatState)
|
||||||
const [selectedId, setSelectedId] = useState(partyTemplate[0].id)
|
const [selectedId, setSelectedId] = useState(partyTemplate[0].id)
|
||||||
const [encounterIndex, setEncounterIndex] = useState(initialEncounterIndex)
|
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 [paused, setPaused] = useState(false)
|
||||||
|
const [speedMultiplier, setSpeedMultiplier] = useState<1 | 2>(1)
|
||||||
const [targetGroup, setTargetGroup] = useState<0 | 1 | 2>(0)
|
const [targetGroup, setTargetGroup] = useState<0 | 1 | 2>(0)
|
||||||
const [log, setLog] = useState<CombatLogEntry[]>([
|
const [log, setLog] = useState<CombatLogEntry[]>([
|
||||||
{ id: 1, text: `${dungeon.name} begins.`, tone: 'system' },
|
{ id: 1, text: `${dungeon.name} begins.`, tone: 'system' },
|
||||||
@@ -379,15 +432,17 @@ export function CombatScreen({
|
|||||||
const [floatingTexts, setFloatingTexts] = useState<FloatingCombatText[]>([])
|
const [floatingTexts, setFloatingTexts] = useState<FloatingCombatText[]>([])
|
||||||
const [roguelikeUpgrades, setRoguelikeUpgrades] = useState<RoguelikeUpgrade[]>([])
|
const [roguelikeUpgrades, setRoguelikeUpgrades] = useState<RoguelikeUpgrade[]>([])
|
||||||
const [upgradeChoices, setUpgradeChoices] = useState<RoguelikeUpgrade[]>([])
|
const [upgradeChoices, setUpgradeChoices] = useState<RoguelikeUpgrade[]>([])
|
||||||
|
const [marathonBossesDefeated, setMarathonBossesDefeated] = useState(0)
|
||||||
const rewardClaimedRef = useRef(false)
|
const rewardClaimedRef = useRef(false)
|
||||||
const profileRefreshedRef = useRef(false)
|
const profileRefreshedRef = useRef(false)
|
||||||
const rolledEncounterIdsRef = useRef(new Set<number>())
|
const rolledEncounterIdsRef = useRef(new Set<string>())
|
||||||
const runTokenRef = useRef(crypto.randomUUID())
|
const runTokenRef = useRef(crypto.randomUUID())
|
||||||
const resourceSpentRef = useRef(0)
|
const resourceSpentRef = useRef(0)
|
||||||
const runStartedAtRef = useRef(0)
|
const runStartedAtRef = useRef(0)
|
||||||
const partStartTimesRef = useRef<Record<number, number>>({})
|
const partStartTimesRef = useRef<Record<number, number>>({})
|
||||||
const nextLogId = useRef(2)
|
const nextLogId = useRef(2)
|
||||||
const nextFloatingTextId = useRef(1)
|
const nextFloatingTextId = useRef(1)
|
||||||
|
const marathonBossesDefeatedRef = useRef(0)
|
||||||
const combatRef = useRef(initialCombatState)
|
const combatRef = useRef(initialCombatState)
|
||||||
const selectedIdRef = useRef(partyTemplate[0].id)
|
const selectedIdRef = useRef(partyTemplate[0].id)
|
||||||
const runCombatTickRef = useRef<() => void>(() => {})
|
const runCombatTickRef = useRef<() => void>(() => {})
|
||||||
@@ -395,24 +450,40 @@ export function CombatScreen({
|
|||||||
const lastCombatTickAtRef = useRef(performance.now())
|
const lastCombatTickAtRef = useRef(performance.now())
|
||||||
const statusRef = useRef(status)
|
const statusRef = useRef(status)
|
||||||
const pausedRef = useRef(paused)
|
const pausedRef = useRef(paused)
|
||||||
|
const speedMultiplierRef = useRef<1 | 2>(speedMultiplier)
|
||||||
const { party, resource, enemyHealth, cooldowns, freeCastReady } = combatState
|
const { party, resource, enemyHealth, cooldowns, freeCastReady } = combatState
|
||||||
const encounter = encounters[encounterIndex]
|
const encounter = encounters[encounterIndex]
|
||||||
|
const encounterMaxHealth = encounter.maxHealth * enemyCount
|
||||||
const currentPart = getCurrentPart(encounterIndex)
|
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 firstEncounterIndex = (startPart - 1) * 3
|
||||||
const expectedLootRolls = encounters
|
const expectedLootRolls = encounters
|
||||||
.slice(firstEncounterIndex, encounterIndex + 1)
|
.slice(firstEncounterIndex, encounterIndex + 1)
|
||||||
.filter((candidate) => candidate.lootTables.some((entry) => entry.difficultyId === difficulty.id))
|
.filter((candidate) => candidate.lootTables.some((entry) => entry.difficultyId === difficulty.id))
|
||||||
.length
|
.length * enemyCount
|
||||||
const isPartBoss = encounter.isBoss && encounterIndex % 3 === 2
|
const isPartBoss = encounter.isBoss && encounterIndex % 3 === 2
|
||||||
const isFinalBoss = isPartBoss && encounterIndex === encounters.length - 1
|
const isFinalBoss = isPartBoss && encounterIndex === encounters.length - 1
|
||||||
const playerHealer = party.find((member) => member.id === 'mira')
|
const playerHealer = party.find((member) => member.id === 'mira')
|
||||||
const playerIsAlive = Boolean(playerHealer && playerHealer.health > 0)
|
const playerIsAlive = Boolean(playerHealer && playerHealer.health > 0)
|
||||||
const upgradesEveryEncounter = roguelikeUpgradeTiming === 'encounter'
|
const upgradesEveryEncounter = roguelikeUpgradeTiming === 'encounter'
|
||||||
const activeSetEffects = useMemo(
|
const activeEffects = useMemo(
|
||||||
() => isRoguelike
|
() => {
|
||||||
? new Set<string>()
|
const effects = new Set<string>(
|
||||||
: new Set(profile.setBonuses.filter((bonus) => bonus.active).map((bonus) => bonus.effectType)),
|
gameClass.talents
|
||||||
[isRoguelike, profile.setBonuses],
|
.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 {
|
const {
|
||||||
bindings,
|
bindings,
|
||||||
@@ -426,6 +497,7 @@ export function CombatScreen({
|
|||||||
|
|
||||||
statusRef.current = status
|
statusRef.current = status
|
||||||
pausedRef.current = paused
|
pausedRef.current = paused
|
||||||
|
speedMultiplierRef.current = speedMultiplier
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
@@ -484,10 +556,12 @@ export function CombatScreen({
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const requestLootRoll = useCallback(
|
const requestLootRoll = useCallback(
|
||||||
(encounterId: number) => {
|
(encounterId: number, rollIndex = 0) => {
|
||||||
if (rolledEncounterIdsRef.current.has(encounterId)) return
|
const rollKey = `${encounterId}:${rollIndex}:${marathonBossesDefeatedRef.current}`
|
||||||
rolledEncounterIdsRef.current.add(encounterId)
|
if (rolledEncounterIdsRef.current.has(rollKey)) return
|
||||||
rollEncounterLoot(encounterId, difficulty.id, runTokenRef.current)
|
rolledEncounterIdsRef.current.add(rollKey)
|
||||||
|
const runToken = `${runTokenRef.current}-${marathonBossesDefeatedRef.current}-${rollIndex}`
|
||||||
|
rollEncounterLoot(encounterId, difficulty.id, runToken)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
setLootRolls((current) => [...current, result])
|
setLootRolls((current) => [...current, result])
|
||||||
const awarded = result.items
|
const awarded = result.items
|
||||||
@@ -519,7 +593,7 @@ export function CombatScreen({
|
|||||||
setCombat({
|
setCombat({
|
||||||
party: freshParty,
|
party: freshParty,
|
||||||
resource: maxResource,
|
resource: maxResource,
|
||||||
enemyHealth: nextEncounters[initialEncounterIndex].maxHealth,
|
enemyHealth: nextEncounters[initialEncounterIndex].maxHealth * enemyCount,
|
||||||
cooldowns: {},
|
cooldowns: {},
|
||||||
elapsedTicks: 0,
|
elapsedTicks: 0,
|
||||||
castsTowardFree: 0,
|
castsTowardFree: 0,
|
||||||
@@ -539,15 +613,17 @@ export function CombatScreen({
|
|||||||
setFloatingTexts([])
|
setFloatingTexts([])
|
||||||
setRoguelikeUpgrades([])
|
setRoguelikeUpgrades([])
|
||||||
setUpgradeChoices([])
|
setUpgradeChoices([])
|
||||||
|
setMarathonBossesDefeated(0)
|
||||||
rewardClaimedRef.current = false
|
rewardClaimedRef.current = false
|
||||||
profileRefreshedRef.current = false
|
profileRefreshedRef.current = false
|
||||||
rolledEncounterIdsRef.current = new Set()
|
rolledEncounterIdsRef.current = new Set()
|
||||||
runTokenRef.current = crypto.randomUUID()
|
runTokenRef.current = crypto.randomUUID()
|
||||||
|
marathonBossesDefeatedRef.current = 0
|
||||||
resourceSpentRef.current = 0
|
resourceSpentRef.current = 0
|
||||||
runStartedAtRef.current = Date.now()
|
runStartedAtRef.current = Date.now()
|
||||||
partStartTimesRef.current = { [startPart]: runStartedAtRef.current }
|
partStartTimesRef.current = { [startPart]: runStartedAtRef.current }
|
||||||
setLog([{ id: nextLogId.current++, text: 'A new run begins.', tone: 'system' }])
|
setLog([{ id: nextLogId.current++, text: 'A new run begins.', tone: 'system' }])
|
||||||
}, [difficulty, initialEncounterIndex, maxResource, partyTemplate, roguelikeMode, roguelikePool, setCombat, setSelectedTargetId, startPart, staticEncounters])
|
}, [difficulty, enemyCount, initialEncounterIndex, maxResource, partyTemplate, roguelikeMode, roguelikePool, setCombat, setSelectedTargetId, startPart, staticEncounters])
|
||||||
|
|
||||||
const castSpell = useCallback(
|
const castSpell = useCallback(
|
||||||
(spell: Spell) => {
|
(spell: Spell) => {
|
||||||
@@ -562,6 +638,14 @@ export function CombatScreen({
|
|||||||
const extraTarget = (blockedIds: string[]) => current.party
|
const extraTarget = (blockedIds: string[]) => current.party
|
||||||
.filter((member) => member.health > 0 && !blockedIds.includes(member.id))
|
.filter((member) => member.health > 0 && !blockedIds.includes(member.id))
|
||||||
.sort((left, right) => (left.health / left.maxHealth) - (right.health / right.maxHealth))[0]
|
.sort((left, right) => (left.health / left.maxHealth) - (right.health / right.maxHealth))[0]
|
||||||
|
const 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 directTargets = new Set([targetId])
|
||||||
const hotTargets = new Set<string>()
|
const hotTargets = new Set<string>()
|
||||||
const shieldTargets = new Set<string>()
|
const shieldTargets = new Set<string>()
|
||||||
@@ -571,17 +655,17 @@ export function CombatScreen({
|
|||||||
? groupHealTargets(current.party, DEFAULT_GROUP_HEAL_TARGETS + extraTargets).map((member) => member.id)
|
? groupHealTargets(current.party, DEFAULT_GROUP_HEAL_TARGETS + extraTargets).map((member) => member.id)
|
||||||
: [],
|
: [],
|
||||||
)
|
)
|
||||||
if (spell.kind === 'hot') hotTargets.add(targetId)
|
if (spell.kind === 'hot' || spell.effectType === 'direct_hot') hotTargets.add(targetId)
|
||||||
if (spell.kind === 'shield') shieldTargets.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])
|
const extra = extraTarget([targetId])
|
||||||
if (extra) directTargets.add(extra.id)
|
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])
|
const extra = extraTarget([targetId])
|
||||||
if (extra) hotTargets.add(extra.id)
|
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)
|
hotTargets.add(targetId)
|
||||||
}
|
}
|
||||||
for (let index = 0; index < extraTargets; index += 1) {
|
for (let index = 0; index < extraTargets; index += 1) {
|
||||||
@@ -604,20 +688,60 @@ export function CombatScreen({
|
|||||||
if (member.health <= 0) return member
|
if (member.health <= 0) return member
|
||||||
if (spell.kind === 'group') {
|
if (spell.kind === 'group') {
|
||||||
if (!groupTargets.has(member.id)) return member
|
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 power = Math.round(spell.power * (1.25 ** upgradeStackCount(roguelikeUpgrades, 'group-heal-boost')))
|
||||||
const nextHealth = healMember(member, power)
|
const nextHealth = healMember(member, power, healingMultiplier(member))
|
||||||
addFloatingHeal(member.id, Math.max(0, nextHealth - member.health))
|
addFloatingHeal(member.id, Math.max(0, nextHealth - member.health))
|
||||||
return { ...member, health: nextHealth }
|
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)) return member
|
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') {
|
if (spell.kind === 'shield') {
|
||||||
const power = Math.round(spell.power * (1.25 ** upgradeStackCount(roguelikeUpgrades, 'shield-boost')))
|
const power = Math.round(spell.power * (1.25 ** upgradeStackCount(roguelikeUpgrades, 'shield-boost')))
|
||||||
return { ...member, shield: Math.max(member.shield, power) }
|
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') {
|
if (spell.kind === 'cleanse') {
|
||||||
return {
|
return {
|
||||||
...member,
|
...member,
|
||||||
health: healMember(member, spell.power),
|
health: healMember(member, spell.power, healingMultiplier(member)),
|
||||||
debuff: undefined,
|
debuff: undefined,
|
||||||
debuffTicks: undefined,
|
debuffTicks: undefined,
|
||||||
poisonStacks: undefined,
|
poisonStacks: undefined,
|
||||||
@@ -626,13 +750,23 @@ export function CombatScreen({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const nextHealth = directTargets.has(member.id)
|
const nextHealth = directTargets.has(member.id)
|
||||||
? healMember(member, spell.power)
|
? healMember(member, spell.power, healingMultiplier(member))
|
||||||
: member.health
|
: member.health
|
||||||
if (nextHealth > member.health) addFloatingHeal(member.id, nextHealth - 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 {
|
return {
|
||||||
...member,
|
...member,
|
||||||
health: nextHealth,
|
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')
|
const freeCastStacks = upgradeStackCount(roguelikeUpgrades, 'fifth-cast-free')
|
||||||
@@ -650,20 +784,26 @@ export function CombatScreen({
|
|||||||
&& !current.freeCastReady
|
&& !current.freeCastReady
|
||||||
&& current.castsTowardFree + 1 >= 5
|
&& current.castsTowardFree + 1 >= 5
|
||||||
resourceSpentRef.current += effectiveCost
|
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({
|
setCombat({
|
||||||
...current,
|
...current,
|
||||||
party: nextParty,
|
party: nextParty,
|
||||||
resource: current.resource - effectiveCost,
|
resource: current.resource - effectiveCost,
|
||||||
cooldowns: {
|
cooldowns: nextCooldowns,
|
||||||
...current.cooldowns,
|
|
||||||
[spell.id]: spell.cooldown * cooldownMultiplier(spell, roguelikeUpgrades),
|
|
||||||
},
|
|
||||||
castsTowardFree: nextCastsTowardFree,
|
castsTowardFree: nextCastsTowardFree,
|
||||||
freeCastReady: gainedFreeCast || nextFreeCastReady,
|
freeCastReady: gainedFreeCast || nextFreeCastReady,
|
||||||
})
|
})
|
||||||
addLog(`${spell.name} cast on ${spell.kind === 'group' ? 'the party' : selected.name}${effectiveCost === 0 ? ' for free' : ''}.`, 'heal')
|
addLog(`${spell.name} cast on ${spell.kind === 'group' ? 'the party' : selected.name}${effectiveCost === 0 ? ' for free' : ''}.`, 'heal')
|
||||||
},
|
},
|
||||||
[activeSetEffects, addFloatingHeal, addLog, roguelikeUpgrades, setCombat, status],
|
[activeEffects, addFloatingHeal, addLog, gameClass.spells, healingPower, roguelikeUpgrades, setCombat, spells, status],
|
||||||
)
|
)
|
||||||
|
|
||||||
const finishRun = useCallback(
|
const finishRun = useCallback(
|
||||||
@@ -686,6 +826,7 @@ export function CombatScreen({
|
|||||||
completedPart,
|
completedPart,
|
||||||
runStartPart,
|
runStartPart,
|
||||||
[partDuration(1), partDuration(2), partDuration(3)],
|
[partDuration(1), partDuration(2), partDuration(3)],
|
||||||
|
hardMode,
|
||||||
)
|
)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
setReward(result)
|
setReward(result)
|
||||||
@@ -698,7 +839,7 @@ export function CombatScreen({
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
[difficulty.id, dungeon.id, onProfileUpdated],
|
[difficulty.id, dungeon.id, hardMode, onProfileUpdated],
|
||||||
)
|
)
|
||||||
|
|
||||||
const finishRoguelikeRun = useCallback(
|
const finishRoguelikeRun = useCallback(
|
||||||
@@ -796,6 +937,9 @@ export function CombatScreen({
|
|||||||
poisonStacks: undefined,
|
poisonStacks: undefined,
|
||||||
maxHealthPenaltyTicks: undefined,
|
maxHealthPenaltyTicks: undefined,
|
||||||
healingReductionTicks: undefined,
|
healingReductionTicks: undefined,
|
||||||
|
hotEffects: [],
|
||||||
|
bounceHeals: [],
|
||||||
|
damageReductionTicks: undefined,
|
||||||
}))
|
}))
|
||||||
const nextStage = clearedBoss ? roguelikeStage + 1 : roguelikeStage
|
const nextStage = clearedBoss ? roguelikeStage + 1 : roguelikeStage
|
||||||
const nextSegment = clearedBoss
|
const nextSegment = clearedBoss
|
||||||
@@ -814,7 +958,7 @@ export function CombatScreen({
|
|||||||
setCombat({
|
setCombat({
|
||||||
...current,
|
...current,
|
||||||
party: recoveredParty,
|
party: recoveredParty,
|
||||||
enemyHealth: nextEncounter.maxHealth,
|
enemyHealth: nextEncounter.maxHealth * enemyCount,
|
||||||
elapsedTicks: 0,
|
elapsedTicks: 0,
|
||||||
cooldowns: {},
|
cooldowns: {},
|
||||||
resource: clamp(current.resource + Math.round(maxResource * 0.25), 0, maxResource),
|
resource: clamp(current.resource + Math.round(maxResource * 0.25), 0, maxResource),
|
||||||
@@ -822,9 +966,13 @@ export function CombatScreen({
|
|||||||
setUpgradeChoices([])
|
setUpgradeChoices([])
|
||||||
setStatus('playing')
|
setStatus('playing')
|
||||||
addLog(`${upgrade.name} gained. ${nextEncounter.enemyName} approaches.`, 'system')
|
addLog(`${upgrade.name} gained. ${nextEncounter.enemyName} approaches.`, 'system')
|
||||||
}, [addLog, difficulty, encounterIndex, encounters, maxResource, roguelikeMode, roguelikePool, roguelikeStage, setCombat])
|
}, [addLog, difficulty, encounterIndex, encounters, enemyCount, maxResource, roguelikeMode, roguelikePool, roguelikeStage, setCombat])
|
||||||
|
|
||||||
useGameAction((action, device) => {
|
useGameAction((action, device) => {
|
||||||
|
if (action === 'toggleSpeed') {
|
||||||
|
if (status === 'playing') setSpeedMultiplier((value) => (value === 1 ? 2 : 1))
|
||||||
|
return
|
||||||
|
}
|
||||||
if (action === 'pause' || (action === 'back' && device === 'pc')) {
|
if (action === 'pause' || (action === 'back' && device === 'pc')) {
|
||||||
if (status === 'playing') setPaused((value) => !value)
|
if (status === 'playing') setPaused((value) => !value)
|
||||||
return
|
return
|
||||||
@@ -914,7 +1062,11 @@ export function CombatScreen({
|
|||||||
const healerBeforeDamage = current.party.find((member) => member.id === 'mira')
|
const healerBeforeDamage = current.party.find((member) => member.id === 'mira')
|
||||||
const tankPressure = tankPressureTargets(current.party)
|
const tankPressure = tankPressureTargets(current.party)
|
||||||
const tankPressureIds = new Set(tankPressure.targets.map((member) => member.id))
|
const tankPressureIds = new Set(tankPressure.targets.map((member) => member.id))
|
||||||
const nextParty = current.party.map((member) => {
|
const pendingJumpHeals: Array<{
|
||||||
|
targetId: string
|
||||||
|
heal: NonNullable<PartyMember['bounceHeals']>[number]
|
||||||
|
}> = []
|
||||||
|
const damagedParty = current.party.map((member) => {
|
||||||
if (member.health <= 0) return member
|
if (member.health <= 0) return member
|
||||||
let damage = member.id === primaryTarget.id ? encounter.damage : 0
|
let damage = member.id === primaryTarget.id ? encounter.damage : 0
|
||||||
if (tankPressureIds.has(member.id)) {
|
if (tankPressureIds.has(member.id)) {
|
||||||
@@ -929,8 +1081,32 @@ export function CombatScreen({
|
|||||||
? Math.max(1, (member.poisonStacks ?? 0) + 1)
|
? Math.max(1, (member.poisonStacks ?? 0) + 1)
|
||||||
: member.poisonStacks ?? 0
|
: member.poisonStacks ?? 0
|
||||||
if (nextPoisonStacks > 0) damage += Math.round((4 + nextPoisonStacks * 4) * difficulty.damageMultiplier)
|
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 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)
|
if (healing > 0) addFloatingHeal(member.id, healing)
|
||||||
const nextMaxHealthPenaltyTicks = appliesMaxHealthCut && member.id === primaryTarget.id
|
const nextMaxHealthPenaltyTicks = appliesMaxHealthCut && member.id === primaryTarget.id
|
||||||
? 15
|
? 15
|
||||||
@@ -944,9 +1120,12 @@ export function CombatScreen({
|
|||||||
: Math.max(0, (member.debuffTicks ?? 0) - 1)
|
: Math.max(0, (member.debuffTicks ?? 0) - 1)
|
||||||
return {
|
return {
|
||||||
...member,
|
...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),
|
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
|
debuff: nextDebuffTicks > 0
|
||||||
? (appliesDebuff && member.id === primaryTarget.id ? 'Searing Mark' : member.debuff)
|
? (appliesDebuff && member.id === primaryTarget.id ? 'Searing Mark' : member.debuff)
|
||||||
: undefined,
|
: undefined,
|
||||||
@@ -956,6 +1135,17 @@ export function CombatScreen({
|
|||||||
healingReductionTicks: nextHealingReductionTicks,
|
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')
|
const healerAfterDamage = nextParty.find((member) => member.id === 'mira')
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -996,7 +1186,9 @@ export function CombatScreen({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!isRoguelike && encounter.lootTables.some((entry) => entry.difficultyId === difficulty.id)) {
|
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)) {
|
if (isRoguelike && (upgradesEveryEncounter || encounter.isBoss)) {
|
||||||
@@ -1015,6 +1207,9 @@ export function CombatScreen({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isPartBoss && !isFinalBoss) {
|
if (isPartBoss && !isFinalBoss) {
|
||||||
|
const nextMarathonKills = marathonBossesDefeatedRef.current + 1
|
||||||
|
marathonBossesDefeatedRef.current = nextMarathonKills
|
||||||
|
setMarathonBossesDefeated(nextMarathonKills)
|
||||||
setCombat({
|
setCombat({
|
||||||
...current,
|
...current,
|
||||||
party: nextParty,
|
party: nextParty,
|
||||||
@@ -1023,12 +1218,28 @@ export function CombatScreen({
|
|||||||
elapsedTicks: nextElapsedTicks,
|
elapsedTicks: nextElapsedTicks,
|
||||||
enemyHealth: 0,
|
enemyHealth: 0,
|
||||||
})
|
})
|
||||||
setStatus('part-complete')
|
setStatus(marathonMode && encounter.isBoss ? 'marathon-choice' : 'part-complete')
|
||||||
addLog(`${encounter.enemyName} is defeated.`, 'loot')
|
addLog(`${encounter.enemyName} is defeated.`, 'loot')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (encounterIndex === encounters.length - 1) {
|
if (encounterIndex === encounters.length - 1) {
|
||||||
|
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({
|
setCombat({
|
||||||
...current,
|
...current,
|
||||||
party: nextParty,
|
party: nextParty,
|
||||||
@@ -1053,6 +1264,9 @@ export function CombatScreen({
|
|||||||
poisonStacks: undefined,
|
poisonStacks: undefined,
|
||||||
maxHealthPenaltyTicks: undefined,
|
maxHealthPenaltyTicks: undefined,
|
||||||
healingReductionTicks: undefined,
|
healingReductionTicks: undefined,
|
||||||
|
hotEffects: [],
|
||||||
|
bounceHeals: [],
|
||||||
|
damageReductionTicks: undefined,
|
||||||
}))
|
}))
|
||||||
setEncounterIndex((value) => value + 1)
|
setEncounterIndex((value) => value + 1)
|
||||||
setCombat({
|
setCombat({
|
||||||
@@ -1061,13 +1275,15 @@ export function CombatScreen({
|
|||||||
resource: nextResource,
|
resource: nextResource,
|
||||||
cooldowns: nextCooldowns,
|
cooldowns: nextCooldowns,
|
||||||
elapsedTicks: 0,
|
elapsedTicks: 0,
|
||||||
enemyHealth: nextEncounter.maxHealth,
|
enemyHealth: nextEncounter.maxHealth * enemyCount,
|
||||||
})
|
})
|
||||||
addLog(`${encounter.enemyName} defeated. ${nextEncounter.enemyName} approaches.`, 'system')
|
addLog(`${encounter.enemyName} defeated. ${nextEncounter.enemyName} approaches.`, 'system')
|
||||||
}, [
|
}, [
|
||||||
|
activeEffects,
|
||||||
addLog,
|
addLog,
|
||||||
addFloatingHeal,
|
addFloatingHeal,
|
||||||
difficulty.damageMultiplier,
|
difficulty.damageMultiplier,
|
||||||
|
enemyCount,
|
||||||
encounter,
|
encounter,
|
||||||
encounterIndex,
|
encounterIndex,
|
||||||
encounters,
|
encounters,
|
||||||
@@ -1076,6 +1292,7 @@ export function CombatScreen({
|
|||||||
isPartBoss,
|
isPartBoss,
|
||||||
isFinalBoss,
|
isFinalBoss,
|
||||||
isRoguelike,
|
isRoguelike,
|
||||||
|
marathonMode,
|
||||||
upgradesEveryEncounter,
|
upgradesEveryEncounter,
|
||||||
roguelikeUpgradeCatalog,
|
roguelikeUpgradeCatalog,
|
||||||
roguelikeUpgrades,
|
roguelikeUpgrades,
|
||||||
@@ -1111,9 +1328,10 @@ export function CombatScreen({
|
|||||||
|| pausedRef.current
|
|| pausedRef.current
|
||||||
) return
|
) return
|
||||||
const now = performance.now()
|
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
|
if (dueTicks <= 0) return
|
||||||
lastCombatTickAtRef.current += dueTicks * TICK_MS
|
lastCombatTickAtRef.current += dueTicks * tickMs
|
||||||
for (let index = 0; index < dueTicks; index += 1) {
|
for (let index = 0; index < dueTicks; index += 1) {
|
||||||
if (statusRef.current !== 'playing' || pausedRef.current) return
|
if (statusRef.current !== 'playing' || pausedRef.current) return
|
||||||
runCombatTickRef.current()
|
runCombatTickRef.current()
|
||||||
@@ -1136,7 +1354,15 @@ export function CombatScreen({
|
|||||||
})
|
})
|
||||||
}, [expectedLootRolls, lootRolls.length, onProfileUpdated, reward])
|
}, [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>(() => ({
|
const dualScreenState = useMemo<DualScreenCombatState>(() => ({
|
||||||
difficultyName: difficulty.name,
|
difficultyName: difficulty.name,
|
||||||
dungeonName: dungeon.name,
|
dungeonName: dungeon.name,
|
||||||
@@ -1144,7 +1370,7 @@ export function CombatScreen({
|
|||||||
encounterName: encounter.enemyName,
|
encounterName: encounter.enemyName,
|
||||||
encounterDescription: encounter.description,
|
encounterDescription: encounter.description,
|
||||||
encounterHealth: enemyHealth,
|
encounterHealth: enemyHealth,
|
||||||
encounterMaxHealth: encounter.maxHealth,
|
encounterMaxHealth,
|
||||||
encounterIsBoss: encounter.isBoss,
|
encounterIsBoss: encounter.isBoss,
|
||||||
encounterIndex,
|
encounterIndex,
|
||||||
encounterCount: encounters.length,
|
encounterCount: encounters.length,
|
||||||
@@ -1174,6 +1400,7 @@ export function CombatScreen({
|
|||||||
directPartyTargeting,
|
directPartyTargeting,
|
||||||
paused,
|
paused,
|
||||||
targetGroup,
|
targetGroup,
|
||||||
|
speedMultiplier,
|
||||||
}), [
|
}), [
|
||||||
bindings,
|
bindings,
|
||||||
controllerIconStyle,
|
controllerIconStyle,
|
||||||
@@ -1186,7 +1413,8 @@ export function CombatScreen({
|
|||||||
encounter.description,
|
encounter.description,
|
||||||
encounter.enemyName,
|
encounter.enemyName,
|
||||||
encounter.isBoss,
|
encounter.isBoss,
|
||||||
encounter.maxHealth,
|
encounterMaxHealth,
|
||||||
|
hardMode,
|
||||||
enemyHealth,
|
enemyHealth,
|
||||||
encounterIndex,
|
encounterIndex,
|
||||||
encounters.length,
|
encounters.length,
|
||||||
@@ -1203,6 +1431,7 @@ export function CombatScreen({
|
|||||||
spells,
|
spells,
|
||||||
freeCastReady,
|
freeCastReady,
|
||||||
roguelikeUpgrades,
|
roguelikeUpgrades,
|
||||||
|
speedMultiplier,
|
||||||
status,
|
status,
|
||||||
targetGroup,
|
targetGroup,
|
||||||
])
|
])
|
||||||
@@ -1239,9 +1468,20 @@ export function CombatScreen({
|
|||||||
<div className="enemy-info">
|
<div className="enemy-info">
|
||||||
<div className="bar-label">
|
<div className="bar-label">
|
||||||
<strong>{encounter.enemyName}</strong>
|
<strong>{encounter.enemyName}</strong>
|
||||||
<span>{Math.ceil(enemyHealth)} / {encounter.maxHealth}</span>
|
<span>{Math.ceil(enemyHealth)} / {encounterMaxHealth}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="bar enemy-health"><span style={{ width: `${enemyPercent}%` }} /></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>
|
<p>{encounter.description}</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -1255,6 +1495,7 @@ export function CombatScreen({
|
|||||||
? `${gameClass.resourceName} ${Math.floor(resource)} / ${maxResource}`
|
? `${gameClass.resourceName} ${Math.floor(resource)} / ${maxResource}`
|
||||||
: `${profile.character.name} is defeated`}
|
: `${profile.character.name} is defeated`}
|
||||||
</span>
|
</span>
|
||||||
|
{speedMultiplier === 2 && <strong className="speed-badge">2x speed</strong>}
|
||||||
<div className="bar mana-bar"><span style={{ width: `${(resource / maxResource) * 100}%` }} /></div>
|
<div className="bar mana-bar"><span style={{ width: `${(resource / maxResource) * 100}%` }} /></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1288,7 +1529,14 @@ export function CombatScreen({
|
|||||||
.map((entry) => <span className="floating-heal" key={entry.id}>+{entry.value}</span>)}
|
.map((entry) => <span className="floating-heal" key={entry.id}>+{entry.value}</span>)}
|
||||||
</div>
|
</div>
|
||||||
<div className="member-effects">
|
<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.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.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>}
|
{member.maxHealthPenaltyTicks && member.maxHealthPenaltyTicks > 0 && <span className="debuff">Max HP -25% {formatEffectTime(member.maxHealthPenaltyTicks)}</span>}
|
||||||
@@ -1398,7 +1646,7 @@ export function CombatScreen({
|
|||||||
</div>
|
</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 className="result-screen">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">{status === 'won' ? `${contentName} Complete` : 'Party Defeated'}</p>
|
<p className="eyebrow">{status === 'won' ? `${contentName} Complete` : 'Party Defeated'}</p>
|
||||||
@@ -1513,38 +1761,76 @@ export function CombatScreen({
|
|||||||
</div>
|
</div>
|
||||||
</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' && (
|
{status === 'part-complete' && (
|
||||||
<div className="result-screen">
|
<div className="result-screen">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">{sectionName} Complete</p>
|
<p className="eyebrow">{sectionName} Complete</p>
|
||||||
<h2>{encounter.enemyName} Defeated</h2>
|
<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?` : 'Run checkpoint complete.'}</p>
|
||||||
<button
|
{canContinueAfterPart && (
|
||||||
onClick={() => {
|
<button
|
||||||
const nextIndex = encounterIndex + 1
|
onClick={() => {
|
||||||
partStartTimesRef.current[currentPart + 1] = Date.now()
|
const nextIndex = encounterIndex + 1
|
||||||
const nextEncounter = encounters[nextIndex]
|
partStartTimesRef.current[currentPart + 1] = Date.now()
|
||||||
const current = combatRef.current
|
const nextEncounter = encounters[nextIndex]
|
||||||
const recoveredParty = current.party.map((member) => ({
|
const current = combatRef.current
|
||||||
...member,
|
const recoveredParty = current.party.map((member) => ({
|
||||||
health: clamp(member.health + 35, 0, member.maxHealth),
|
...member,
|
||||||
debuff: undefined,
|
health: clamp(member.health + 35, 0, member.maxHealth),
|
||||||
debuffTicks: undefined,
|
debuff: undefined,
|
||||||
}))
|
debuffTicks: undefined,
|
||||||
setEncounterIndex(nextIndex)
|
poisonStacks: undefined,
|
||||||
setCombat({
|
maxHealthPenaltyTicks: undefined,
|
||||||
...current,
|
healingReductionTicks: undefined,
|
||||||
party: recoveredParty,
|
hotEffects: [],
|
||||||
enemyHealth: nextEncounter.maxHealth,
|
bounceHeals: [],
|
||||||
elapsedTicks: 0,
|
damageReductionTicks: undefined,
|
||||||
})
|
}))
|
||||||
setStatus('playing')
|
setEncounterIndex(nextIndex)
|
||||||
addLog(`Proceeding to ${sectionName} ${currentPart + 1}. ${nextEncounter.enemyName} approaches.`, 'system')
|
setCombat({
|
||||||
}}
|
...current,
|
||||||
type="button"
|
party: recoveredParty,
|
||||||
>
|
enemyHealth: nextEncounter.maxHealth * enemyCount,
|
||||||
Continue to {sectionName} {currentPart + 1}
|
elapsedTicks: 0,
|
||||||
</button>
|
})
|
||||||
|
setStatus('playing')
|
||||||
|
addLog(`Proceeding to ${sectionName} ${currentPart + 1}. ${nextEncounter.enemyName} approaches.`, 'system')
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Continue to {sectionName} {currentPart + 1}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button className="secondary-result-button" onClick={() => finishRun(currentPart, startPart)} type="button">
|
<button className="secondary-result-button" onClick={() => finishRun(currentPart, startPart)} type="button">
|
||||||
End Run
|
End Run
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
|
|||||||
function chooseClass(nextClass: GameClass) {
|
function chooseClass(nextClass: GameClass) {
|
||||||
const starterAbilities = nextClass.spells
|
const starterAbilities = nextClass.spells
|
||||||
.filter((ability) => ability.unlockLevel <= profile.character.level)
|
.filter((ability) => ability.unlockLevel <= profile.character.level)
|
||||||
.slice(0, 5)
|
.slice(0, 6)
|
||||||
.map((ability) => ability.id)
|
.map((ability) => ability.id)
|
||||||
setClassId(nextClass.id)
|
setClassId(nextClass.id)
|
||||||
setSlots([...starterAbilities, ...Array(6 - starterAbilities.length).fill(null)])
|
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_LIST_PAGE_SIZE = 3
|
||||||
const CRAFTING_FILTER_SLOTS = (Object.keys(SLOT_LABELS) as EquipmentSlot[])
|
const CRAFTING_FILTER_SLOTS = (Object.keys(SLOT_LABELS) as EquipmentSlot[])
|
||||||
.filter((slot) => slot !== 'component')
|
.filter((slot) => slot !== 'component')
|
||||||
|
const DIRECT_CRAFT_ITEM_LEVELS = new Set([1, 10, 20, 25])
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
profile: CharacterProfile
|
profile: CharacterProfile
|
||||||
@@ -68,18 +69,17 @@ export function EquipmentScreen({
|
|||||||
const [message, setMessage] = useState('')
|
const [message, setMessage] = useState('')
|
||||||
const scrollRef = useRef<number>(0)
|
const scrollRef = useRef<number>(0)
|
||||||
const selectedItem = profile.inventory.find((item) => item.id === selectedItemId)
|
const selectedItem = profile.inventory.find((item) => item.id === selectedItemId)
|
||||||
const firstRecipe = profile.craftingRecipes.find((recipe) => recipe.canCraft)
|
const craftableRecipes = profile.craftingRecipes.filter((recipe) =>
|
||||||
?? profile.craftingRecipes[0]
|
DIRECT_CRAFT_ITEM_LEVELS.has(recipe.item.itemLevel),
|
||||||
|
)
|
||||||
|
const firstRecipe = craftableRecipes.find((recipe) => recipe.canCraft)
|
||||||
|
?? craftableRecipes[0]
|
||||||
const [selectedRecipeId, setSelectedRecipeId] = useState<number | null>(
|
const [selectedRecipeId, setSelectedRecipeId] = useState<number | null>(
|
||||||
firstRecipe?.id ?? null,
|
firstRecipe?.id ?? null,
|
||||||
)
|
)
|
||||||
const selectedRecipe = profile.craftingRecipes.find((recipe) => recipe.id === selectedRecipeId)
|
const selectedRecipe = profile.craftingRecipes.find((recipe) => recipe.id === selectedRecipeId)
|
||||||
const selectedRecipeRequiresUpgrade = selectedRecipe
|
const selectedRecipeRequiresUpgrade = selectedRecipe
|
||||||
? profile.craftingRecipes.some((recipe) =>
|
? !DIRECT_CRAFT_ITEM_LEVELS.has(selectedRecipe.item.itemLevel)
|
||||||
recipe.sourceEncounterId === selectedRecipe.sourceEncounterId
|
|
||||||
&& recipe.item.slot === selectedRecipe.item.slot
|
|
||||||
&& recipe.item.itemLevel < selectedRecipe.item.itemLevel,
|
|
||||||
)
|
|
||||||
: false
|
: false
|
||||||
const selectedItemRecipe = selectedItem
|
const selectedItemRecipe = selectedItem
|
||||||
? profile.craftingRecipes.find((recipe) => recipe.item.id === selectedItem.id)
|
? profile.craftingRecipes.find((recipe) => recipe.item.id === selectedItem.id)
|
||||||
@@ -126,12 +126,14 @@ export function EquipmentScreen({
|
|||||||
const [slotFilter, setSlotFilter] = useState<EquipmentSlot | 'all'>('all')
|
const [slotFilter, setSlotFilter] = useState<EquipmentSlot | 'all'>('all')
|
||||||
const [levelFilter, setLevelFilter] = useState<number | null>(null)
|
const [levelFilter, setLevelFilter] = useState<number | null>(null)
|
||||||
const availableLevels = useMemo(
|
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],
|
[profile.craftingRecipes],
|
||||||
)
|
)
|
||||||
const filteredRecipes = useMemo(
|
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 (slotFilter !== 'all') result = result.filter((r) => r.item.slot === slotFilter)
|
||||||
if (levelFilter !== null) result = result.filter((r) => r.item.itemLevel === levelFilter)
|
if (levelFilter !== null) result = result.filter((r) => r.item.itemLevel === levelFilter)
|
||||||
result.sort((a, b) => b.item.itemLevel - a.item.itemLevel)
|
result.sort((a, b) => b.item.itemLevel - a.item.itemLevel)
|
||||||
@@ -144,7 +146,10 @@ export function EquipmentScreen({
|
|||||||
() => new Map(
|
() => new Map(
|
||||||
(Object.keys(SLOT_LABELS) as EquipmentSlot[]).map((slot) => [
|
(Object.keys(SLOT_LABELS) as EquipmentSlot[]).map((slot) => [
|
||||||
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],
|
[profile.craftingRecipes],
|
||||||
@@ -589,7 +594,7 @@ export function EquipmentScreen({
|
|||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<strong>All</strong>
|
<strong>All</strong>
|
||||||
<span>{profile.craftingRecipes.length}</span>
|
<span>{profile.craftingRecipes.filter((recipe) => DIRECT_CRAFT_ITEM_LEVELS.has(recipe.item.itemLevel)).length}</span>
|
||||||
</button>
|
</button>
|
||||||
{CRAFTING_FILTER_SLOTS.map((slot) => (
|
{CRAFTING_FILTER_SLOTS.map((slot) => (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ type PvpEncounter = DungeonEncounter & {
|
|||||||
sourceEncounterId?: number
|
sourceEncounterId?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
type SlotKey = '1' | '2' | '3' | '4' | '5'
|
type SlotKey = '1' | '2' | '3' | '4' | '5' | '6'
|
||||||
type AbilityLabelMode = 'ability' | 'slot'
|
type AbilityLabelMode = 'ability' | 'slot'
|
||||||
|
|
||||||
type SelfBuffId =
|
type SelfBuffId =
|
||||||
@@ -164,7 +164,7 @@ function slotLabel(slot: SlotKey, spells: Spell[], labelMode: AbilityLabelMode)
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildSelfBuffChoices(spells: Spell[], labelMode: AbilityLabelMode): Array<Choice<SelfBuffId>> {
|
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)
|
const label = slotLabel(slot, spells, labelMode)
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -193,7 +193,7 @@ function buildSelfBuffChoices(spells: Spell[], labelMode: AbilityLabelMode): Arr
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildOpponentDebuffChoices(spells: Spell[], labelMode: AbilityLabelMode): Array<Choice<OpponentDebuffId>> {
|
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)
|
const label = slotLabel(slot, spells, labelMode)
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -251,13 +251,13 @@ function outgoingHealMultiplier(debuffs: OpponentDebuffId[]) {
|
|||||||
return 0.85 ** buffStacks(debuffs, 'opp-healing-reduced')
|
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
|
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[]) {
|
function healMember(member: PartyMember, amount: number, debuffs: OpponentDebuffId[], multiplier = 1) {
|
||||||
return clamp(member.health + healAmount(member, amount, debuffs), 0, effectiveMaxHealth(member))
|
return clamp(member.health + healAmount(member, amount, debuffs, multiplier), 0, effectiveMaxHealth(member))
|
||||||
}
|
}
|
||||||
|
|
||||||
function cooldownMultiplier(spell: Spell, buffs: SelfBuffId[], debuffs: OpponentDebuffId[]) {
|
function cooldownMultiplier(spell: Spell, buffs: SelfBuffId[], debuffs: OpponentDebuffId[]) {
|
||||||
@@ -334,7 +334,7 @@ function scoreSelfBuff(buff: Choice<SelfBuffId>, spells: Spell[]) {
|
|||||||
if (buff.id === 'fifth-cast-free') return 8
|
if (buff.id === 'fifth-cast-free') return 8
|
||||||
if (buff.id === 'group-heal-boost') return 8
|
if (buff.id === 'group-heal-boost') return 8
|
||||||
if (buff.id === 'shield-boost') return 6
|
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)
|
const spell = spells.find((candidate) => candidate.key === slot)
|
||||||
if (!spell) return 5
|
if (!spell) return 5
|
||||||
if (buff.id.endsWith('extra-target')) {
|
if (buff.id.endsWith('extra-target')) {
|
||||||
@@ -408,7 +408,7 @@ export function PvPRoguelikeScreen({
|
|||||||
const gameClass = profile.classes.find((candidate) => candidate.id === profile.character.classId)!
|
const gameClass = profile.classes.find((candidate) => candidate.id === profile.character.classId)!
|
||||||
const starterSpells = useMemo(() => gameClass.spells
|
const starterSpells = useMemo(() => gameClass.spells
|
||||||
.filter((spell) => spell.unlockLevel === 1)
|
.filter((spell) => spell.unlockLevel === 1)
|
||||||
.slice(0, 5)
|
.slice(0, 6)
|
||||||
.map((spell, index) => toCombatSpell(spell, String(index + 1))), [gameClass.spells])
|
.map((spell, index) => toCombatSpell(spell, String(index + 1))), [gameClass.spells])
|
||||||
const [abilityLabelMode] = useState<AbilityLabelMode>('ability')
|
const [abilityLabelMode] = useState<AbilityLabelMode>('ability')
|
||||||
const selfBuffChoicesCatalog = useMemo(
|
const selfBuffChoicesCatalog = useMemo(
|
||||||
@@ -446,6 +446,7 @@ export function PvPRoguelikeScreen({
|
|||||||
const [cpuSide, setCpuSide] = useState<SideState>(() => starterSide(cpuPartyTemplate, maxResource))
|
const [cpuSide, setCpuSide] = useState<SideState>(() => starterSide(cpuPartyTemplate, maxResource))
|
||||||
const [selectedId, setSelectedId] = useState(partyTemplate[0].id)
|
const [selectedId, setSelectedId] = useState(partyTemplate[0].id)
|
||||||
const selectedIdRef = useRef(partyTemplate[0].id)
|
const selectedIdRef = useRef(partyTemplate[0].id)
|
||||||
|
const [speedMultiplier, setSpeedMultiplier] = useState<1 | 2>(1)
|
||||||
const [elapsedTicks, setElapsedTicks] = useState(0)
|
const [elapsedTicks, setElapsedTicks] = useState(0)
|
||||||
const [cpuDifficulty, setCpuDifficulty] = useState<CpuDifficulty | null>(null)
|
const [cpuDifficulty, setCpuDifficulty] = useState<CpuDifficulty | null>(null)
|
||||||
const [queueMessage, setQueueMessage] = useState('')
|
const [queueMessage, setQueueMessage] = useState('')
|
||||||
@@ -483,6 +484,14 @@ export function PvPRoguelikeScreen({
|
|||||||
? Math.max(encountersCleared, encounterIndex + 1)
|
? Math.max(encountersCleared, encounterIndex + 1)
|
||||||
: encountersCleared
|
: encountersCleared
|
||||||
const cpuBehavior = cpuDifficulty ? CPU_BEHAVIOR[cpuDifficulty] : CPU_BEHAVIOR[1]
|
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 playerDone = playerSide.enemyHealth <= 0
|
||||||
const cpuDone = cpuSide.enemyHealth <= 0
|
const cpuDone = cpuSide.enemyHealth <= 0
|
||||||
const playerAlive = playerSide.party.some((member) => member.health > 0)
|
const playerAlive = playerSide.party.some((member) => member.health > 0)
|
||||||
@@ -677,6 +686,12 @@ export function PvPRoguelikeScreen({
|
|||||||
const extraTarget = (blockedIds: string[]) => livingTargets
|
const extraTarget = (blockedIds: string[]) => livingTargets
|
||||||
.filter((member) => !blockedIds.includes(member.id))
|
.filter((member) => !blockedIds.includes(member.id))
|
||||||
.sort((left, right) => (left.health / left.maxHealth) - (right.health / right.maxHealth))[0]
|
.sort((left, right) => (left.health / left.maxHealth) - (right.health / right.maxHealth))[0]
|
||||||
|
const 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 directTargets = new Set([targetId])
|
||||||
const hotTargets = new Set(spell.kind === 'hot' ? [targetId] : [])
|
const hotTargets = new Set(spell.kind === 'hot' ? [targetId] : [])
|
||||||
const shieldTargets = new Set(spell.kind === 'shield' ? [targetId] : [])
|
const shieldTargets = new Set(spell.kind === 'shield' ? [targetId] : [])
|
||||||
@@ -701,22 +716,45 @@ export function PvPRoguelikeScreen({
|
|||||||
const extra = extraTarget([...directTargets])
|
const extra = extraTarget([...directTargets])
|
||||||
if (extra) directTargets.add(extra.id)
|
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) => {
|
const nextParty = current.party.map((member) => {
|
||||||
if (member.health <= 0) return member
|
if (member.health <= 0) return member
|
||||||
if (spell.kind === 'group') {
|
if (spell.kind === 'group') {
|
||||||
if (!groupTargets.has(member.id)) return member
|
if (!groupTargets.has(member.id)) return member
|
||||||
const groupPower = Math.round(spell.power * (1.25 ** buffStacks(buffs, 'group-heal-boost')))
|
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))
|
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 (!directTargets.has(member.id) && !hotTargets.has(member.id) && !shieldTargets.has(member.id)) return member
|
||||||
if (spell.kind === 'shield') {
|
if (spell.kind === 'shield') {
|
||||||
const shieldPower = Math.round(spell.power * (1.25 ** buffStacks(buffs, 'shield-boost')))
|
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') {
|
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))
|
addFloatingHeal(sideName, member.id, Math.max(0, nextHealth - member.health))
|
||||||
return {
|
return {
|
||||||
...member,
|
...member,
|
||||||
@@ -728,11 +766,17 @@ export function PvPRoguelikeScreen({
|
|||||||
healingReductionTicks: undefined,
|
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)
|
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 {
|
return {
|
||||||
...member,
|
...member,
|
||||||
health: nextHealth,
|
health: nextHealth,
|
||||||
|
shield: nextShield,
|
||||||
hotTicks: hotTargets.has(member.id) ? 5 : member.hotTicks,
|
hotTicks: hotTargets.has(member.id) ? 5 : member.hotTicks,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -746,20 +790,24 @@ export function PvPRoguelikeScreen({
|
|||||||
: current.castsTowardFree + 1
|
: current.castsTowardFree + 1
|
||||||
: current.castsTowardFree
|
: current.castsTowardFree
|
||||||
const gainedFreeCast = freeCastStacks > 0 && !current.freeCastReady && current.castsTowardFree + 1 >= 5
|
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 = {
|
const nextState: SideState = {
|
||||||
...current,
|
...current,
|
||||||
party: nextParty,
|
party: nextParty,
|
||||||
resource: current.resource - effectiveCost,
|
resource: current.resource - effectiveCost,
|
||||||
cooldowns: {
|
cooldowns: nextCooldowns,
|
||||||
...current.cooldowns,
|
|
||||||
[spell.id]: spell.cooldown * cooldownMultiplier(spell, buffs, debuffs),
|
|
||||||
},
|
|
||||||
castsTowardFree: nextCastsTowardFree,
|
castsTowardFree: nextCastsTowardFree,
|
||||||
freeCastReady: gainedFreeCast || nextFreeCastReady,
|
freeCastReady: gainedFreeCast || nextFreeCastReady,
|
||||||
}
|
}
|
||||||
setCurrent(nextState)
|
setCurrent(nextState)
|
||||||
return true
|
return true
|
||||||
}, [addFloatingHeal])
|
}, [activeSpellEffects, addFloatingHeal, starterSpells])
|
||||||
|
|
||||||
const castPlayerSpell = useCallback((spell: Spell) => {
|
const castPlayerSpell = useCallback((spell: Spell) => {
|
||||||
if (status !== 'playing' || playerDone || !playerAlive) return
|
if (status !== 'playing' || playerDone || !playerAlive) return
|
||||||
@@ -867,6 +915,7 @@ export function PvPRoguelikeScreen({
|
|||||||
const appliesHealingReduction = encounterValue.isBoss && elapsedTicks > 0 && elapsedTicks % 9 === 0 && mechanics.includes('healing-reduction')
|
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 appliesPoison = encounterValue.isBoss && elapsedTicks > 0 && elapsedTicks % 12 === 0 && mechanics.includes('ramping-poison')
|
||||||
const damageMultiplier = incomingDamageMultiplier(side.debuffs)
|
const damageMultiplier = incomingDamageMultiplier(side.debuffs)
|
||||||
|
const hasSpellEffect = (effectType: string) => sideName === 'player' && activeSpellEffects.has(effectType)
|
||||||
const tankPressure = tankPressureTargets(side.party)
|
const tankPressure = tankPressureTargets(side.party)
|
||||||
const tankPressureIds = new Set(tankPressure.targets.map((member) => member.id))
|
const tankPressureIds = new Set(tankPressure.targets.map((member) => member.id))
|
||||||
const nextParty = side.party.map((member) => {
|
const nextParty = side.party.map((member) => {
|
||||||
@@ -882,8 +931,12 @@ export function PvPRoguelikeScreen({
|
|||||||
: member.poisonStacks ?? 0
|
: member.poisonStacks ?? 0
|
||||||
if (nextPoisonStacks > 0) damage += 3 + nextPoisonStacks * 3
|
if (nextPoisonStacks > 0) damage += 3 + nextPoisonStacks * 3
|
||||||
damage = Math.round(damage * damageMultiplier)
|
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 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)
|
if (healing > 0) addFloatingHeal(sideName, member.id, healing)
|
||||||
const nextMaxHealthPenaltyTicks = appliesMaxHealthCut && member.id === primaryTarget.id
|
const nextMaxHealthPenaltyTicks = appliesMaxHealthCut && member.id === primaryTarget.id
|
||||||
? 14
|
? 14
|
||||||
@@ -925,7 +978,7 @@ export function PvPRoguelikeScreen({
|
|||||||
),
|
),
|
||||||
enemyHealth: Math.max(0, side.enemyHealth - partyDamageOutput(nextParty, encounterValue.partyDamage)),
|
enemyHealth: Math.max(0, side.enemyHealth - partyDamageOutput(nextParty, encounterValue.partyDamage)),
|
||||||
}
|
}
|
||||||
}, [addFloatingHeal, elapsedTicks, maxResource])
|
}, [activeSpellEffects, addFloatingHeal, elapsedTicks, maxResource])
|
||||||
|
|
||||||
const beginUpgradePhase = useCallback(() => {
|
const beginUpgradePhase = useCallback(() => {
|
||||||
setPlayerBuffChoices(chooseRandom(selfBuffChoicesCatalog, 3))
|
setPlayerBuffChoices(chooseRandom(selfBuffChoicesCatalog, 3))
|
||||||
@@ -985,9 +1038,9 @@ export function PvPRoguelikeScreen({
|
|||||||
addLog(`${encounter.enemyName} cleared. Choose your next edge.`, 'loot')
|
addLog(`${encounter.enemyName} cleared. Choose your next edge.`, 'loot')
|
||||||
beginUpgradePhase()
|
beginUpgradePhase()
|
||||||
}
|
}
|
||||||
}, TICK_MS)
|
}, TICK_MS / speedMultiplier)
|
||||||
return () => window.clearInterval(timer)
|
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(() => {
|
useEffect(() => {
|
||||||
if ((status !== 'won' && status !== 'lost') || recordedRunRef.current || !cpuDifficulty) return
|
if ((status !== 'won' && status !== 'lost') || recordedRunRef.current || !cpuDifficulty) return
|
||||||
@@ -1104,6 +1157,10 @@ export function PvPRoguelikeScreen({
|
|||||||
}, [addLog, contentType, cpuDifficulty, encounter, encounterIndex, encounterPool, encounters, finishRoguelikeRun, maxResource, opponentDebuffChoicesCatalog, selectedBuff, selectedDebuff, selfBuffChoicesCatalog, stage, starterSpells])
|
}, [addLog, contentType, cpuDifficulty, encounter, encounterIndex, encounterPool, encounters, finishRoguelikeRun, maxResource, opponentDebuffChoicesCatalog, selectedBuff, selectedDebuff, selfBuffChoicesCatalog, stage, starterSpells])
|
||||||
|
|
||||||
useGameAction((action) => {
|
useGameAction((action) => {
|
||||||
|
if (action === 'toggleSpeed') {
|
||||||
|
if (status === 'playing') setSpeedMultiplier((value) => (value === 1 ? 2 : 1))
|
||||||
|
return
|
||||||
|
}
|
||||||
if (action === 'pause' || action === 'back') {
|
if (action === 'pause' || action === 'back') {
|
||||||
if (status === 'playing') setPaused((value) => !value)
|
if (status === 'playing') setPaused((value) => !value)
|
||||||
return
|
return
|
||||||
@@ -1175,6 +1232,7 @@ export function PvPRoguelikeScreen({
|
|||||||
directPartyTargeting,
|
directPartyTargeting,
|
||||||
paused,
|
paused,
|
||||||
targetGroup,
|
targetGroup,
|
||||||
|
speedMultiplier,
|
||||||
}), [
|
}), [
|
||||||
bindings,
|
bindings,
|
||||||
controllerIconStyle,
|
controllerIconStyle,
|
||||||
@@ -1199,6 +1257,7 @@ export function PvPRoguelikeScreen({
|
|||||||
playerSide.party,
|
playerSide.party,
|
||||||
playerSide.resource,
|
playerSide.resource,
|
||||||
selectedId,
|
selectedId,
|
||||||
|
speedMultiplier,
|
||||||
stage,
|
stage,
|
||||||
starterSpells,
|
starterSpells,
|
||||||
status,
|
status,
|
||||||
@@ -1237,6 +1296,7 @@ export function PvPRoguelikeScreen({
|
|||||||
<div className="resource-row pvp-resource-row">
|
<div className="resource-row pvp-resource-row">
|
||||||
<div className="pvp-resource-wrap">
|
<div className="pvp-resource-wrap">
|
||||||
<span>{gameClass.resourceName} {Math.floor(playerSide.resource)} / {maxResource}</span>
|
<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 className="bar mana-bar"><span style={{ width: `${(playerSide.resource / maxResource) * 100}%` }} /></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+205
-117
@@ -1,10 +1,11 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
allocateTalent,
|
allocateTalent,
|
||||||
resetTalents,
|
resetTalents,
|
||||||
type CharacterProfile,
|
type CharacterProfile,
|
||||||
type Talent,
|
type Talent,
|
||||||
} from '../profile'
|
} from '../profile'
|
||||||
|
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../dualScreen'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
profile: CharacterProfile
|
profile: CharacterProfile
|
||||||
@@ -13,199 +14,286 @@ type Props = {
|
|||||||
embedded?: boolean
|
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) {
|
export function TalentScreen({ profile, onBack, onUpdated, embedded = false }: Props) {
|
||||||
|
const { enabled: dualScreenEnabled } = useDualScreen()
|
||||||
const [busyTalentId, setBusyTalentId] = useState<number | null>(null)
|
const [busyTalentId, setBusyTalentId] = useState<number | null>(null)
|
||||||
const [talentPage, setTalentPage] = useState(0)
|
|
||||||
const [resetting, setResetting] = useState(false)
|
const [resetting, setResetting] = useState(false)
|
||||||
|
const [selectedTalentId, setSelectedTalentId] = useState<number | null>(null)
|
||||||
|
const [effectPage, setEffectPage] = useState(0)
|
||||||
const [message, setMessage] = useState('')
|
const [message, setMessage] = useState('')
|
||||||
const scrollRef = useRef<number>(0)
|
const scrollRef = useRef<number>(0)
|
||||||
const gameClass = profile.classes.find(
|
const gameClass = profile.classes.find(
|
||||||
(candidate) => candidate.id === profile.character.classId,
|
(candidate) => candidate.id === profile.character.classId,
|
||||||
)!
|
)!
|
||||||
const classPointsSpent = gameClass.talents.reduce(
|
const isEffectClass = gameClass.id === EFFECT_CLASS_ID
|
||||||
(total, talent) => total + talent.rank,
|
const capacity = isEffectClass ? effectCapacity(profile.character.level) : 0
|
||||||
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(() => {
|
useEffect(() => {
|
||||||
window.scrollTo(0, scrollRef.current)
|
window.scrollTo(0, scrollRef.current)
|
||||||
}, [profile])
|
}, [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() {
|
function saveScroll() {
|
||||||
scrollRef.current = window.scrollY
|
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) {
|
function lockReason(talent: Talent) {
|
||||||
if (talent.rank >= talent.maxRank) return 'Maximum rank'
|
if (!isEffectClass) return 'Coming soon'
|
||||||
|
if (talent.rank > 0) return ''
|
||||||
const requiredTierPoints = (talent.tier - 1) * 5
|
const source = effectSource(talent.effectType)
|
||||||
if (lowerTierPoints(talent) < requiredTierPoints) {
|
const sourceConflict = selectedEffects.find((effect) => effectSource(effect.effectType) === source)
|
||||||
return `Requires ${requiredTierPoints} earlier-tier points`
|
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 ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
async function purchaseRank(talent: Talent) {
|
async function toggleEffect(talent: Talent) {
|
||||||
saveScroll()
|
saveScroll()
|
||||||
setBusyTalentId(talent.id)
|
setBusyTalentId(talent.id)
|
||||||
setMessage('')
|
setMessage('')
|
||||||
try {
|
try {
|
||||||
const updated = await allocateTalent(talent.id)
|
const updated = await allocateTalent(talent.id)
|
||||||
onUpdated(updated)
|
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) {
|
} catch (reason) {
|
||||||
setMessage(reason instanceof Error ? reason.message : 'Unable to allocate talent.')
|
setMessage(reason instanceof Error ? reason.message : 'Unable to update spell effect.')
|
||||||
} finally {
|
} finally {
|
||||||
setBusyTalentId(null)
|
setBusyTalentId(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refundTree() {
|
async function clearEffects() {
|
||||||
saveScroll()
|
saveScroll()
|
||||||
setResetting(true)
|
setResetting(true)
|
||||||
setMessage('')
|
setMessage('')
|
||||||
try {
|
try {
|
||||||
const updated = await resetTalents()
|
const updated = await resetTalents()
|
||||||
onUpdated(updated)
|
onUpdated(updated)
|
||||||
setMessage('All points in this talent tree were refunded.')
|
setMessage('Spell effects cleared.')
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
setMessage(reason instanceof Error ? reason.message : 'Unable to reset talents.')
|
setMessage(reason instanceof Error ? reason.message : 'Unable to clear spell effects.')
|
||||||
} finally {
|
} finally {
|
||||||
setResetting(false)
|
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 = (
|
const content = (
|
||||||
<>
|
<>
|
||||||
{!embedded && (
|
{!embedded && (
|
||||||
<div className="screen-heading">
|
<div className="screen-heading">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Character Growth</p>
|
<p className="eyebrow">Character Growth</p>
|
||||||
<h1>Talents</h1>
|
<h1>Spell Effects</h1>
|
||||||
</div>
|
</div>
|
||||||
<button className="back-button" onClick={onBack} type="button">Back</button>
|
<button className="back-button" onClick={onBack} type="button">Back</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="talent-toolbar">
|
<div className="talent-toolbar spell-effect-toolbar">
|
||||||
<div className="talent-class-summary">
|
<div className="talent-class-summary">
|
||||||
<span style={{ borderColor: gameClass.themeColor, color: gameClass.themeColor }}>
|
<span style={{ borderColor: gameClass.themeColor, color: gameClass.themeColor }}>
|
||||||
{gameClass.name[0]}
|
{gameClass.name[0]}
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">{gameClass.name} Tree</p>
|
<p className="eyebrow">{gameClass.name} Effects</p>
|
||||||
<h2>Shape Your Healing Style</h2>
|
<h2>Modify Your Spells</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="talent-points">
|
<div className="talent-points">
|
||||||
<strong>{profile.character.talentPoints}</strong>
|
<strong>{selectedEffects.length}/{capacity}</strong>
|
||||||
<span>Available</span>
|
<span>Active</span>
|
||||||
<small>{classPointsSpent} spent in this tree</small>
|
<small>Slots unlock at levels 5, 10, 15, 20</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="talent-page-tabs" role="tablist" aria-label="Talent tier pages">
|
{!isEffectClass ? (
|
||||||
{tierPages.map((pageTiers, index) => (
|
<div className="talent-empty-state">
|
||||||
<button
|
<h2>Spell effects coming soon for {gameClass.name}.</h2>
|
||||||
aria-selected={talentPage === index}
|
<p>This replacement system starts with the first class.</p>
|
||||||
className={talentPage === index ? 'active' : ''}
|
</div>
|
||||||
key={pageTiers.join('-')}
|
) : (
|
||||||
onClick={() => setTalentPage(index)}
|
<div className="spell-effect-layout">
|
||||||
role="tab"
|
<section className="effect-slots-panel">
|
||||||
type="button"
|
<p className="eyebrow">Active Slots</p>
|
||||||
>
|
{EFFECT_SLOT_LEVELS.map((level, index) => {
|
||||||
Tiers {pageTiers[0]}-{pageTiers[pageTiers.length - 1]}
|
const effect = selectedEffects[index]
|
||||||
</button>
|
const unlocked = profile.character.level >= level
|
||||||
))}
|
return (
|
||||||
</nav>
|
<button
|
||||||
|
className={`effect-slot ${effect ? 'filled' : ''} ${unlocked ? '' : 'locked'}`}
|
||||||
|
disabled={!effect}
|
||||||
|
key={level}
|
||||||
|
onClick={() => effect && setSelectedTalentId(effect.id)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</section>
|
||||||
|
|
||||||
<div className="talent-tree">
|
<section className="effect-pool-panel">
|
||||||
{visibleTiers.map((tier) => {
|
<div className="effect-panel-heading">
|
||||||
const requiredPoints = (tier - 1) * 5
|
<div>
|
||||||
return (
|
<p className="eyebrow">Effect Pool</p>
|
||||||
<section className="talent-tier" key={tier}>
|
<h2>Choose and Swap</h2>
|
||||||
<div className="tier-label">
|
|
||||||
<span>Tier {tier}</span>
|
|
||||||
<small>
|
|
||||||
{tier === 1 ? 'Open' : `${requiredPoints} earlier-tier points`}
|
|
||||||
</small>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="tier-talents">
|
<span>{selectedEffects.length}/{capacity} active</span>
|
||||||
{gameClass.talents
|
</div>
|
||||||
.filter((talent) => talent.tier === tier)
|
<div className="selected-effect-strip">
|
||||||
.sort((a, b) => a.branch - b.branch)
|
<div>
|
||||||
.map((talent) => {
|
<p className="eyebrow">Selected Effect</p>
|
||||||
const reason = lockReason(talent)
|
{selectedTalent ? (
|
||||||
const isBusy = busyTalentId === talent.id
|
<>
|
||||||
return (
|
<strong>{selectedTalent.name}</strong>
|
||||||
<article
|
<small>{selectedTalent.description}</small>
|
||||||
className={`talent-node ${reason ? 'locked' : 'available'} ${talent.rank > 0 ? 'invested' : ''}`}
|
</>
|
||||||
key={talent.id}
|
) : (
|
||||||
style={{ gridColumn: talent.branch }}
|
<small>No effect selected.</small>
|
||||||
>
|
)}
|
||||||
<div className="talent-node-header">
|
|
||||||
<span>{talent.glyph}</span>
|
|
||||||
<div>
|
|
||||||
<strong>{talent.name}</strong>
|
|
||||||
<small>Rank {talent.rank}/{talent.maxRank}</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p>{talent.description}</p>
|
|
||||||
<div className="rank-pips">
|
|
||||||
{Array.from({ length: talent.maxRank }, (_, index) => (
|
|
||||||
<i className={index < talent.rank ? 'filled' : ''} key={index} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
disabled={Boolean(reason) || isBusy}
|
|
||||||
onClick={() => purchaseRank(talent)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{isBusy ? 'Saving...' : reason || 'Add Rank'}
|
|
||||||
</button>
|
|
||||||
</article>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
{selectedTalent && (
|
||||||
)
|
<button
|
||||||
})}
|
className="primary-button"
|
||||||
</div>
|
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 (
|
||||||
|
<button
|
||||||
|
className={`${active ? 'active' : ''} ${selected ? 'selected' : ''}`}
|
||||||
|
disabled={Boolean(reason) || isBusy}
|
||||||
|
key={talent.id}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedTalentId(talent.id)
|
||||||
|
void toggleEffect(talent)
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span>{talent.glyph}</span>
|
||||||
|
<div>
|
||||||
|
<strong>{talent.name}</strong>
|
||||||
|
<small>{EFFECT_SOURCE_LABELS[effectSource(talent.effectType)] ?? 'Spell'}</small>
|
||||||
|
</div>
|
||||||
|
<i>{isBusy ? 'Saving' : active ? 'Active' : reason || 'Available'}</i>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{effectPageCount > 1 && (
|
||||||
|
<div className="effect-pager">
|
||||||
|
<button
|
||||||
|
disabled={effectPage === 0}
|
||||||
|
onClick={() => setEffectPage((page) => Math.max(0, page - 1))}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<footer className="talent-footer">
|
<footer className="talent-footer">
|
||||||
<span>{message || 'Talent changes are saved immediately.'}</span>
|
<span>{message || 'Spell effect changes are saved immediately.'}</span>
|
||||||
<button
|
<button
|
||||||
className="text-button"
|
className="text-button"
|
||||||
disabled={classPointsSpent === 0 || resetting}
|
disabled={selectedEffects.length === 0 || resetting}
|
||||||
onClick={refundTree}
|
onClick={clearEffects}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{resetting ? 'Refunding...' : 'Reset Tree'}
|
{resetting ? 'Clearing...' : 'Clear Effects'}
|
||||||
</button>
|
</button>
|
||||||
</footer>
|
</footer>
|
||||||
</>
|
</>
|
||||||
|
|||||||
+13
-2
@@ -42,7 +42,7 @@ export type DualScreenCombatState = {
|
|||||||
partySize: number
|
partySize: number
|
||||||
selectedId: string
|
selectedId: string
|
||||||
log: CombatLogEntry[]
|
log: CombatLogEntry[]
|
||||||
status: 'playing' | 'won' | 'lost' | 'part-complete' | 'upgrade-choice'
|
status: 'playing' | 'won' | 'lost' | 'part-complete' | 'marathon-choice' | 'upgrade-choice'
|
||||||
resource: number
|
resource: number
|
||||||
maxResource: number
|
maxResource: number
|
||||||
resourceName: string
|
resourceName: string
|
||||||
@@ -54,6 +54,7 @@ export type DualScreenCombatState = {
|
|||||||
directPartyTargeting: boolean
|
directPartyTargeting: boolean
|
||||||
paused: boolean
|
paused: boolean
|
||||||
targetGroup: 0 | 1 | 2
|
targetGroup: 0 | 1 | 2
|
||||||
|
speedMultiplier: 1 | 2
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DualScreenWorkshopState = {
|
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 }) {
|
export function DualScreenProvider({ children }: { children: ReactNode }) {
|
||||||
const [enabled, setEnabledState] = useState(
|
const [enabled, setEnabledState] = useState(
|
||||||
() => localStorage.getItem(STORAGE_KEY) === 'true',
|
() => localStorage.getItem(STORAGE_KEY) === 'true',
|
||||||
@@ -438,6 +446,7 @@ export function DualScreenBottomDisplay() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="dual-controls-mana">
|
<div className="dual-controls-mana">
|
||||||
<span>{state.resourceName} {Math.floor(state.resource)} / {state.maxResource}</span>
|
<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">
|
<div className="bar mana-bar">
|
||||||
<span style={{ width: `${(state.resource / state.maxResource) * 100}%` }} />
|
<span style={{ width: `${(state.resource / state.maxResource) * 100}%` }} />
|
||||||
</div>
|
</div>
|
||||||
@@ -599,7 +608,9 @@ export function DualScreenTopCombat({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="member-effects">
|
<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>}
|
{member.debuff && <span className="debuff">{member.debuff}</span>}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
+16
-1
@@ -8,6 +8,20 @@ export type PartyMember = {
|
|||||||
maxHealth: number
|
maxHealth: number
|
||||||
shield: number
|
shield: number
|
||||||
hotTicks: 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
|
debuff?: string
|
||||||
debuffTicks?: number
|
debuffTicks?: number
|
||||||
poisonStacks?: number
|
poisonStacks?: number
|
||||||
@@ -24,7 +38,8 @@ export type Spell = {
|
|||||||
cooldown: number
|
cooldown: number
|
||||||
power: number
|
power: number
|
||||||
glyph: string
|
glyph: string
|
||||||
kind: 'direct' | 'hot' | 'group' | 'shield' | 'cleanse'
|
kind: 'direct' | 'hot' | 'group' | 'shield' | 'cleanse' | 'damage_reduction' | 'bounce_heal'
|
||||||
|
effectType?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Encounter = {
|
export type Encounter = {
|
||||||
|
|||||||
+165
-51
@@ -26,6 +26,7 @@ export interface GameRepository {
|
|||||||
completedPart?: number,
|
completedPart?: number,
|
||||||
startPart?: number,
|
startPart?: number,
|
||||||
partDurationSeconds?: [number, number, number],
|
partDurationSeconds?: [number, number, number],
|
||||||
|
hardMode?: boolean,
|
||||||
): Promise<DungeonReward>
|
): Promise<DungeonReward>
|
||||||
completeRoguelike(
|
completeRoguelike(
|
||||||
dungeonId: number,
|
dungeonId: number,
|
||||||
@@ -69,6 +70,7 @@ type OfflineSave = {
|
|||||||
activeClassId: number
|
activeClassId: number
|
||||||
completedDungeonParts: number
|
completedDungeonParts: number
|
||||||
completedRaidPhases: number
|
completedRaidPhases: number
|
||||||
|
dungeonCompletions?: Record<string, number>
|
||||||
characters: Record<number, CharacterData>
|
characters: Record<number, CharacterData>
|
||||||
lootRolls: Record<string, LootRoll>
|
lootRolls: Record<string, LootRoll>
|
||||||
}
|
}
|
||||||
@@ -102,6 +104,7 @@ const offlineSaveKey = 'chronicle.offlineSave.v1'
|
|||||||
const onlineCacheKey = 'chronicle.onlineCache.v1'
|
const onlineCacheKey = 'chronicle.onlineCache.v1'
|
||||||
const authTokenKey = 'chronicle.authToken.v1'
|
const authTokenKey = 'chronicle.authToken.v1'
|
||||||
const offlineAccount = { id: -1, username: 'Offline' }
|
const offlineAccount = { id: -1, username: 'Offline' }
|
||||||
|
const ABILITY_SLOT_COUNT = 6
|
||||||
|
|
||||||
function clone<T>(value: T): T {
|
function clone<T>(value: T): T {
|
||||||
return structuredClone(value)
|
return structuredClone(value)
|
||||||
@@ -146,7 +149,7 @@ function upgradeV1Save(v1: { profile: CharacterProfile; lootRolls: Record<string
|
|||||||
level: cid === p.character.classId ? p.character.level : 1,
|
level: cid === p.character.classId ? p.character.level : 1,
|
||||||
experience: cid === p.character.classId ? p.character.experience : 0,
|
experience: cid === p.character.classId ? p.character.experience : 0,
|
||||||
talentPoints: cid === p.character.classId ? p.character.talentPoints : 1,
|
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,
|
talentRanks,
|
||||||
inventory: cid === p.character.classId ? clone(p.inventory) : [],
|
inventory: cid === p.character.classId ? clone(p.inventory) : [],
|
||||||
}
|
}
|
||||||
@@ -157,17 +160,41 @@ function upgradeV1Save(v1: { profile: CharacterProfile; lootRolls: Record<string
|
|||||||
activeClassId: p.character.classId,
|
activeClassId: p.character.classId,
|
||||||
completedDungeonParts: p.completedDungeonParts,
|
completedDungeonParts: p.completedDungeonParts,
|
||||||
completedRaidPhases: p.completedRaidPhases ?? 0,
|
completedRaidPhases: p.completedRaidPhases ?? 0,
|
||||||
|
dungeonCompletions: Object.fromEntries(
|
||||||
|
p.dungeons.map((dungeon) => [String(dungeon.id), dungeon.completionCount ?? 0]),
|
||||||
|
),
|
||||||
characters,
|
characters,
|
||||||
lootRolls: v1.lootRolls ?? {},
|
lootRolls: v1.lootRolls ?? {},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function upgradeV2Save(v2: Omit<OfflineSave, 'version' | 'completedRaidPhases'> & { version: 2 }): OfflineSave {
|
function upgradeV2Save(v2: Omit<OfflineSave, 'version' | 'completedRaidPhases'> & { version: 2 }): OfflineSave {
|
||||||
return {
|
return normalizeSaveAbilitySlots({
|
||||||
...v2,
|
...v2,
|
||||||
version: 3,
|
version: 3,
|
||||||
completedRaidPhases: 0,
|
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 {
|
function normalizeOfflineSave(raw: unknown): OfflineSave | null {
|
||||||
@@ -177,12 +204,12 @@ function normalizeOfflineSave(raw: unknown): OfflineSave | null {
|
|||||||
profile?: CharacterProfile
|
profile?: CharacterProfile
|
||||||
lootRolls?: Record<string, LootRoll>
|
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) {
|
if (candidate.version === 2) {
|
||||||
return upgradeV2Save(candidate as Omit<OfflineSave, 'version' | 'completedRaidPhases'> & { version: 2 })
|
return upgradeV2Save(candidate as Omit<OfflineSave, 'version' | 'completedRaidPhases'> & { version: 2 })
|
||||||
}
|
}
|
||||||
if (candidate.version === 1 && candidate.profile) {
|
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
|
return null
|
||||||
}
|
}
|
||||||
@@ -291,6 +318,10 @@ function buildProfile(save: OfflineSave): CharacterProfile {
|
|||||||
updateCraftingRecipes(static_)
|
updateCraftingRecipes(static_)
|
||||||
static_.completedDungeonParts = save.completedDungeonParts
|
static_.completedDungeonParts = save.completedDungeonParts
|
||||||
static_.completedRaidPhases = save.completedRaidPhases
|
static_.completedRaidPhases = save.completedRaidPhases
|
||||||
|
static_.dungeons = static_.dungeons.map((dungeon) => ({
|
||||||
|
...dungeon,
|
||||||
|
completionCount: save.dungeonCompletions?.[String(dungeon.id)] ?? dungeon.completionCount ?? 0,
|
||||||
|
}))
|
||||||
|
|
||||||
return static_
|
return static_
|
||||||
}
|
}
|
||||||
@@ -359,11 +390,33 @@ function experienceForLevel(level: number) {
|
|||||||
return (level - 1) * (level - 1) * 100
|
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(
|
function scaledPvpBossExperience(
|
||||||
startingExperience: number,
|
startingExperience: number,
|
||||||
startingLevel: number,
|
startingLevel: number,
|
||||||
bossesCleared: number,
|
bossesCleared: number,
|
||||||
maxLevel: number,
|
maxLevel: number,
|
||||||
|
targetLevel = startingLevel,
|
||||||
) {
|
) {
|
||||||
let experience = startingExperience
|
let experience = startingExperience
|
||||||
let level = startingLevel
|
let level = startingLevel
|
||||||
@@ -374,7 +427,8 @@ function scaledPvpBossExperience(
|
|||||||
? maxExperience
|
? maxExperience
|
||||||
: experienceForLevel(level + 1)
|
: experienceForLevel(level + 1)
|
||||||
const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor)
|
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) {
|
while (level < maxLevel && experienceForLevel(level + 1) <= experience) {
|
||||||
level += 1
|
level += 1
|
||||||
}
|
}
|
||||||
@@ -382,6 +436,17 @@ function scaledPvpBossExperience(
|
|||||||
return { experience, level }
|
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 }
|
type ComponentTemplate = { id: number; slug: string; name: string; itemLevel: number; glyph: string; description: string }
|
||||||
const COMPONENT_ITEMS: Record<number, ComponentTemplate> = {
|
const COMPONENT_ITEMS: Record<number, ComponentTemplate> = {
|
||||||
1: { id: 600, slug: 'minor-component', name: 'Minor Component', itemLevel: 1, glyph: '◆', description: 'A basic crafting component.' },
|
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.' },
|
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.' },
|
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 & {
|
type WindowWithApiBase = Window & {
|
||||||
CAPACITOR_API_BASE_URL?: string
|
CAPACITOR_API_BASE_URL?: string
|
||||||
@@ -423,7 +489,7 @@ function mergeProfileIntoSave(profile: CharacterProfile, existingSave?: OfflineS
|
|||||||
level: profile.character.level,
|
level: profile.character.level,
|
||||||
experience: profile.character.experience,
|
experience: profile.character.experience,
|
||||||
talentPoints: profile.character.talentPoints,
|
talentPoints: profile.character.talentPoints,
|
||||||
abilitySlots: [...profile.abilitySlots],
|
abilitySlots: normalizeAbilitySlots(profile.abilitySlots),
|
||||||
talentRanks,
|
talentRanks,
|
||||||
inventory: clone(profile.inventory),
|
inventory: clone(profile.inventory),
|
||||||
}
|
}
|
||||||
@@ -433,6 +499,9 @@ function mergeProfileIntoSave(profile: CharacterProfile, existingSave?: OfflineS
|
|||||||
activeClassId: profile.character.classId,
|
activeClassId: profile.character.classId,
|
||||||
completedDungeonParts: profile.completedDungeonParts,
|
completedDungeonParts: profile.completedDungeonParts,
|
||||||
completedRaidPhases: profile.completedRaidPhases,
|
completedRaidPhases: profile.completedRaidPhases,
|
||||||
|
dungeonCompletions: Object.fromEntries(
|
||||||
|
profile.dungeons.map((dungeon) => [String(dungeon.id), dungeon.completionCount ?? 0]),
|
||||||
|
),
|
||||||
characters,
|
characters,
|
||||||
lootRolls: clone(existingSave?.lootRolls ?? {}),
|
lootRolls: clone(existingSave?.lootRolls ?? {}),
|
||||||
}
|
}
|
||||||
@@ -716,7 +785,7 @@ const serverRepository: GameRepository = {
|
|||||||
),
|
),
|
||||||
saveProfile: (classId, abilitySlots) =>
|
saveProfile: (classId, abilitySlots) =>
|
||||||
cachedOnlineLocalRepository.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(
|
cachedOnlineLocalRepository.completeDungeon(
|
||||||
dungeonId,
|
dungeonId,
|
||||||
difficultyId,
|
difficultyId,
|
||||||
@@ -725,6 +794,7 @@ const serverRepository: GameRepository = {
|
|||||||
completedPart,
|
completedPart,
|
||||||
startPart,
|
startPart,
|
||||||
partDurationSeconds,
|
partDurationSeconds,
|
||||||
|
hardMode,
|
||||||
),
|
),
|
||||||
completeRoguelike: (dungeonId, difficultyId, encountersCleared, resourceSpent, durationSeconds, options) =>
|
completeRoguelike: (dungeonId, difficultyId, encountersCleared, resourceSpent, durationSeconds, options) =>
|
||||||
cachedOnlineLocalRepository.completeRoguelike(
|
cachedOnlineLocalRepository.completeRoguelike(
|
||||||
@@ -761,9 +831,9 @@ function emptyCharacterData(classId: number): CharacterData {
|
|||||||
const inventory: Item[] = []
|
const inventory: Item[] = []
|
||||||
const startingAbilitySlots: Array<number | null> = gc.spells
|
const startingAbilitySlots: Array<number | null> = gc.spells
|
||||||
.filter((s) => s.unlockLevel === 1)
|
.filter((s) => s.unlockLevel === 1)
|
||||||
.slice(0, 5)
|
.slice(0, ABILITY_SLOT_COUNT)
|
||||||
.map((s) => s.id)
|
.map((s) => s.id)
|
||||||
while (startingAbilitySlots.length < 6) startingAbilitySlots.push(null)
|
while (startingAbilitySlots.length < ABILITY_SLOT_COUNT) startingAbilitySlots.push(null)
|
||||||
return {
|
return {
|
||||||
level: 1,
|
level: 1,
|
||||||
experience: 0,
|
experience: 0,
|
||||||
@@ -803,35 +873,34 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
|||||||
},
|
},
|
||||||
async saveProfile(classId, abilitySlots) {
|
async saveProfile(classId, abilitySlots) {
|
||||||
const save = requireStoredSave(store)
|
const save = requireStoredSave(store)
|
||||||
const static_ = clone(starterProfile) as CharacterProfile
|
const static_ = clone(starterProfile) as CharacterProfile
|
||||||
const gameClass = static_.classes.find((candidate) => candidate.id === classId)
|
const gameClass = static_.classes.find((candidate) => candidate.id === classId)
|
||||||
if (!gameClass) throw new Error('Selected class does not exist.')
|
if (!gameClass) throw new Error('Selected class does not exist.')
|
||||||
|
|
||||||
const slots = abilitySlots.slice(0, 6)
|
const slots = normalizeAbilitySlots(abilitySlots)
|
||||||
while (slots.length < 6) slots.push(null)
|
const selectedIds = slots.filter((id): id is number => id !== null)
|
||||||
const selectedIds = slots.filter((id): id is number => id !== null)
|
if (new Set(selectedIds).size !== selectedIds.length) {
|
||||||
if (new Set(selectedIds).size !== selectedIds.length) {
|
throw new Error('The same ability cannot be equipped twice.')
|
||||||
throw new Error('The same ability cannot be equipped twice.')
|
}
|
||||||
}
|
const activeChar = save.characters[save.activeClassId]
|
||||||
const activeChar = save.characters[save.activeClassId]
|
const validIds = new Set(
|
||||||
const validIds = new Set(
|
gameClass.spells
|
||||||
gameClass.spells
|
.filter((spell) => spell.unlockLevel <= activeChar.level)
|
||||||
.filter((spell) => spell.unlockLevel <= activeChar.level)
|
.map((spell) => spell.id),
|
||||||
.map((spell) => spell.id),
|
)
|
||||||
)
|
if (selectedIds.some((id) => !validIds.has(id))) {
|
||||||
if (selectedIds.some((id) => !validIds.has(id))) {
|
throw new Error('One or more abilities are locked or belong to another class.')
|
||||||
throw new Error('One or more abilities are locked or belong to another class.')
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (!save.characters[classId]) {
|
if (!save.characters[classId]) {
|
||||||
save.characters[classId] = emptyCharacterData(classId)
|
save.characters[classId] = emptyCharacterData(classId)
|
||||||
}
|
}
|
||||||
save.characters[classId].abilitySlots = slots
|
save.characters[classId].abilitySlots = slots
|
||||||
save.activeClassId = classId
|
save.activeClassId = classId
|
||||||
store.writeSave(save)
|
store.writeSave(save)
|
||||||
return buildProfile(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 startPart
|
||||||
void partDurationSeconds
|
void partDurationSeconds
|
||||||
if (!Number.isInteger(resourceSpent) || resourceSpent < 0) {
|
if (!Number.isInteger(resourceSpent) || resourceSpent < 0) {
|
||||||
@@ -857,8 +926,15 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
|||||||
const previousLevel = cd.level
|
const previousLevel = cd.level
|
||||||
const previousExperience = cd.experience
|
const previousExperience = cd.experience
|
||||||
const partCount = completedPart ?? 1
|
const partCount = completedPart ?? 1
|
||||||
const experienceReward = Math.round(
|
const rewardMultiplier = hardMode ? 2 : 1
|
||||||
dungeon.experienceReward * difficulty.experienceMultiplier * partCount,
|
const baseExperienceReward = Math.round(
|
||||||
|
dungeon.experienceReward * difficulty.experienceMultiplier * partCount * rewardMultiplier,
|
||||||
|
)
|
||||||
|
const experienceReward = catchUpExperienceReward(
|
||||||
|
baseExperienceReward,
|
||||||
|
previousExperience,
|
||||||
|
previousLevel,
|
||||||
|
highestOtherClassLevel(save),
|
||||||
)
|
)
|
||||||
const maxExperience = experienceForLevel(profile.maxLevel)
|
const maxExperience = experienceForLevel(profile.maxLevel)
|
||||||
const newExperience = Math.min(previousExperience + experienceReward, maxExperience)
|
const newExperience = Math.min(previousExperience + experienceReward, maxExperience)
|
||||||
@@ -893,6 +969,10 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
|||||||
} else {
|
} else {
|
||||||
save.completedDungeonParts = Math.max(save.completedDungeonParts, partCount)
|
save.completedDungeonParts = Math.max(save.completedDungeonParts, partCount)
|
||||||
}
|
}
|
||||||
|
save.dungeonCompletions = {
|
||||||
|
...(save.dungeonCompletions ?? {}),
|
||||||
|
[String(dungeonId)]: (save.dungeonCompletions?.[String(dungeonId)] ?? 0) + 1,
|
||||||
|
}
|
||||||
|
|
||||||
let bonusItem: DungeonReward['bonusItem'] = null
|
let bonusItem: DungeonReward['bonusItem'] = null
|
||||||
if ((startPart ?? 1) === 1 && partCount >= 3 && dungeon.completionLoot.length > 0) {
|
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 selected = rewardPool[Math.floor(Math.random() * rewardPool.length)]
|
||||||
const existing = profile.inventory.find((item) => item.id === selected.id)
|
const existing = profile.inventory.find((item) => item.id === selected.id)
|
||||||
const duplicate = Boolean(existing)
|
const duplicate = Boolean(existing)
|
||||||
let quantityAfter = 1
|
const rewardQuantity = rewardMultiplier
|
||||||
|
let quantityAfter = rewardQuantity
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.quantity += 1
|
existing.quantity += rewardQuantity
|
||||||
quantityAfter = existing.quantity
|
quantityAfter = existing.quantity
|
||||||
} else {
|
} else {
|
||||||
profile.inventory.push({
|
profile.inventory.push({
|
||||||
...selected,
|
...selected,
|
||||||
quantity: 1,
|
quantity: rewardQuantity,
|
||||||
equipped: false,
|
equipped: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
cd.inventory = profile.inventory
|
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 maxExperience = experienceForLevel(profile.maxLevel)
|
||||||
const bossesCleared = Math.max(0, Math.floor(options?.bossesCleared ?? encountersCleared / 3))
|
const bossesCleared = Math.max(0, Math.floor(options?.bossesCleared ?? encountersCleared / 3))
|
||||||
const scaledReward = options?.experienceMode === 'pvp-boss-quarter-level'
|
const scaledReward = options?.experienceMode === 'pvp-boss-quarter-level'
|
||||||
? scaledPvpBossExperience(previousExperience, previousLevel, bossesCleared, profile.maxLevel)
|
? scaledPvpBossExperience(previousExperience, previousLevel, bossesCleared, profile.maxLevel, highestOtherClassLevel(save))
|
||||||
: null
|
: null
|
||||||
|
const baseRoguelikeReward = Math.round(dungeon.experienceReward * difficulty.experienceMultiplier * (encountersCleared / 3))
|
||||||
const newExperience = scaledReward
|
const newExperience = scaledReward
|
||||||
? scaledReward.experience
|
? scaledReward.experience
|
||||||
: Math.min(
|
: Math.min(
|
||||||
previousExperience
|
previousExperience
|
||||||
+ Math.round(dungeon.experienceReward * difficulty.experienceMultiplier * (encountersCleared / 3)),
|
+ catchUpExperienceReward(
|
||||||
|
baseRoguelikeReward,
|
||||||
|
previousExperience,
|
||||||
|
previousLevel,
|
||||||
|
highestOtherClassLevel(save),
|
||||||
|
),
|
||||||
maxExperience,
|
maxExperience,
|
||||||
)
|
)
|
||||||
let newLevel = scaledReward?.level ?? previousLevel
|
let newLevel = scaledReward?.level ?? previousLevel
|
||||||
@@ -1041,6 +1128,34 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
|||||||
)!
|
)!
|
||||||
const talent = gameClass.talents.find((candidate) => candidate.id === talentId)
|
const talent = gameClass.talents.find((candidate) => candidate.id === talentId)
|
||||||
if (!talent) throw new Error('That talent does not belong to the active class.')
|
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) {
|
if (cd.talentPoints <= 0) {
|
||||||
throw new Error('No talent points are available.')
|
throw new Error('No talent points are available.')
|
||||||
}
|
}
|
||||||
@@ -1083,10 +1198,12 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
|||||||
for (const talent of gameClass.talents) {
|
for (const talent of gameClass.talents) {
|
||||||
cd.talentRanks[String(talent.id)] = 0
|
cd.talentRanks[String(talent.id)] = 0
|
||||||
}
|
}
|
||||||
cd.talentPoints = Math.min(
|
if (save.activeClassId !== 1) {
|
||||||
profile.maxTalentPoints,
|
cd.talentPoints = Math.min(
|
||||||
cd.talentPoints + refunded,
|
profile.maxTalentPoints,
|
||||||
)
|
cd.talentPoints + refunded,
|
||||||
|
)
|
||||||
|
}
|
||||||
store.writeSave(save)
|
store.writeSave(save)
|
||||||
return buildProfile(save)
|
return buildProfile(save)
|
||||||
},
|
},
|
||||||
@@ -1161,11 +1278,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
|||||||
const profile = buildProfile(save)
|
const profile = buildProfile(save)
|
||||||
const recipe = profile.craftingRecipes.find((candidate) => candidate.id === recipeId)
|
const recipe = profile.craftingRecipes.find((candidate) => candidate.id === recipeId)
|
||||||
if (!recipe) throw new Error('That crafting recipe does not exist.')
|
if (!recipe) throw new Error('That crafting recipe does not exist.')
|
||||||
const requiresUpgrade = profile.craftingRecipes.some((candidate) =>
|
const requiresUpgrade = !DIRECT_CRAFT_ITEM_LEVELS.has(recipe.item.itemLevel)
|
||||||
candidate.sourceEncounterId === recipe.sourceEncounterId
|
|
||||||
&& candidate.item.slot === recipe.item.slot
|
|
||||||
&& candidate.item.itemLevel < recipe.item.itemLevel,
|
|
||||||
)
|
|
||||||
if (requiresUpgrade) throw new Error('Upgrade the previous item tier instead.')
|
if (requiresUpgrade) throw new Error('Upgrade the previous item tier instead.')
|
||||||
const missing = recipe.components.find((component) => component.owned < component.quantity)
|
const missing = recipe.components.find((component) => component.owned < component.quantity)
|
||||||
if (missing) {
|
if (missing) {
|
||||||
@@ -1361,7 +1474,7 @@ const cachedOnlineRepository: GameRepository = {
|
|||||||
},
|
},
|
||||||
loadProfile: () => cachedOnlineLocalRepository.loadProfile(),
|
loadProfile: () => cachedOnlineLocalRepository.loadProfile(),
|
||||||
saveProfile: (classId, abilitySlots) => cachedOnlineLocalRepository.saveProfile(classId, abilitySlots),
|
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(
|
cachedOnlineLocalRepository.completeDungeon(
|
||||||
dungeonId,
|
dungeonId,
|
||||||
difficultyId,
|
difficultyId,
|
||||||
@@ -1370,6 +1483,7 @@ const cachedOnlineRepository: GameRepository = {
|
|||||||
completedPart,
|
completedPart,
|
||||||
startPart,
|
startPart,
|
||||||
partDurationSeconds,
|
partDurationSeconds,
|
||||||
|
hardMode,
|
||||||
),
|
),
|
||||||
completeRoguelike: (dungeonId, difficultyId, encountersCleared, resourceSpent, durationSeconds, options) =>
|
completeRoguelike: (dungeonId, difficultyId, encountersCleared, resourceSpent, durationSeconds, options) =>
|
||||||
cachedOnlineLocalRepository.completeRoguelike(
|
cachedOnlineLocalRepository.completeRoguelike(
|
||||||
|
|||||||
+18
-2
@@ -35,6 +35,7 @@ export const INPUT_ACTIONS = [
|
|||||||
'targetParty5',
|
'targetParty5',
|
||||||
'targetParty6',
|
'targetParty6',
|
||||||
'toggleTargetGroup',
|
'toggleTargetGroup',
|
||||||
|
'toggleSpeed',
|
||||||
'pause',
|
'pause',
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
@@ -63,6 +64,7 @@ export const ACTION_LABELS: Record<InputAction, string> = {
|
|||||||
targetParty5: 'Target Party Member 5',
|
targetParty5: 'Target Party Member 5',
|
||||||
targetParty6: 'Target Party Member 6',
|
targetParty6: 'Target Party Member 6',
|
||||||
toggleTargetGroup: 'Switch Raid Target Group',
|
toggleTargetGroup: 'Switch Raid Target Group',
|
||||||
|
toggleSpeed: 'Toggle 2x Speed',
|
||||||
pause: 'Pause Menu',
|
pause: 'Pause Menu',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,6 +91,7 @@ export const DEFAULT_BINDINGS: Record<InputDevice, InputBindings> = {
|
|||||||
targetParty5: 'F5',
|
targetParty5: 'F5',
|
||||||
targetParty6: 'F6',
|
targetParty6: 'F6',
|
||||||
toggleTargetGroup: 'Tab',
|
toggleTargetGroup: 'Tab',
|
||||||
|
toggleSpeed: 'Backquote',
|
||||||
pause: 'Escape',
|
pause: 'Escape',
|
||||||
},
|
},
|
||||||
controller: {
|
controller: {
|
||||||
@@ -111,8 +114,9 @@ export const DEFAULT_BINDINGS: Record<InputDevice, InputBindings> = {
|
|||||||
targetParty3: 'Button15',
|
targetParty3: 'Button15',
|
||||||
targetParty4: 'Button13',
|
targetParty4: 'Button13',
|
||||||
targetParty5: 'Button4',
|
targetParty5: 'Button4',
|
||||||
targetParty6: 'Button11',
|
targetParty6: 'Button10',
|
||||||
toggleTargetGroup: 'Button6',
|
toggleTargetGroup: 'Button6',
|
||||||
|
toggleSpeed: 'Button11',
|
||||||
pause: 'Button9',
|
pause: 'Button9',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -145,7 +149,8 @@ const InputContext = createContext<InputContextValue | null>(null)
|
|||||||
function loadBindings(): Record<InputDevice, InputBindings> {
|
function loadBindings(): Record<InputDevice, InputBindings> {
|
||||||
try {
|
try {
|
||||||
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') as Partial<Record<InputDevice, Partial<InputBindings>>>
|
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 = [
|
const usesLegacyAbilityDefaults = [
|
||||||
'Button2',
|
'Button2',
|
||||||
'Button3',
|
'Button3',
|
||||||
@@ -166,6 +171,15 @@ function loadBindings(): Record<InputDevice, InputBindings> {
|
|||||||
ability6: DEFAULT_BINDINGS.controller.ability6,
|
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 {
|
return {
|
||||||
pc: { ...DEFAULT_BINDINGS.pc, ...saved.pc },
|
pc: { ...DEFAULT_BINDINGS.pc, ...saved.pc },
|
||||||
controller,
|
controller,
|
||||||
@@ -504,9 +518,11 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
|||||||
'targetParty5',
|
'targetParty5',
|
||||||
'targetParty6',
|
'targetParty6',
|
||||||
'toggleTargetGroup',
|
'toggleTargetGroup',
|
||||||
|
'toggleSpeed',
|
||||||
] satisfies InputAction[]
|
] satisfies InputAction[]
|
||||||
const combatPriority = [
|
const combatPriority = [
|
||||||
'pause',
|
'pause',
|
||||||
|
'toggleSpeed',
|
||||||
'ability1',
|
'ability1',
|
||||||
'ability2',
|
'ability2',
|
||||||
'ability3',
|
'ability3',
|
||||||
|
|||||||
+3918
-1894
File diff suppressed because it is too large
Load Diff
@@ -168,6 +168,7 @@ export type Dungeon = {
|
|||||||
difficulties: Difficulty[]
|
difficulties: Difficulty[]
|
||||||
encounters: DungeonEncounter[]
|
encounters: DungeonEncounter[]
|
||||||
completionLoot: Array<Omit<Item, 'quantity' | 'equipped'>>
|
completionLoot: Array<Omit<Item, 'quantity' | 'equipped'>>
|
||||||
|
completionCount?: number
|
||||||
leaderboard: LeaderboardEntry[]
|
leaderboard: LeaderboardEntry[]
|
||||||
leaderboards: {
|
leaderboards: {
|
||||||
part_1: LeaderboardEntry[]
|
part_1: LeaderboardEntry[]
|
||||||
@@ -319,6 +320,7 @@ export async function completeDungeon(
|
|||||||
completedPart?: number,
|
completedPart?: number,
|
||||||
startPart?: number,
|
startPart?: number,
|
||||||
partDurationSeconds?: [number, number, number],
|
partDurationSeconds?: [number, number, number],
|
||||||
|
hardMode?: boolean,
|
||||||
): Promise<DungeonReward> {
|
): Promise<DungeonReward> {
|
||||||
return activeGameRepository().completeDungeon(
|
return activeGameRepository().completeDungeon(
|
||||||
dungeonId,
|
dungeonId,
|
||||||
@@ -328,6 +330,7 @@ export async function completeDungeon(
|
|||||||
completedPart,
|
completedPart,
|
||||||
startPart,
|
startPart,
|
||||||
partDurationSeconds,
|
partDurationSeconds,
|
||||||
|
hardMode,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user