Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 753bba581a | |||
| 2300973164 | |||
| 1281be69d8 | |||
| 4fc15ebe9a | |||
| 7313c968e6 | |||
| 207fcd1a15 | |||
| fd6a1ce3c7 | |||
| f8b98e6b23 | |||
| fc7c6488ea | |||
| ba6d3b614e |
@@ -2,5 +2,8 @@
|
||||
|
||||
- AYN Thor main display: 6-inch AMOLED, 1920 x 1080, 120Hz.
|
||||
- AYN Thor secondary display: 3.92-inch AMOLED, 1240 x 1080, 60Hz.
|
||||
- AYN Thor UI sizing must be designed against Android CSS/layout viewport, not physical framebuffer pixels.
|
||||
- Approximate Thor CSS viewports: main display 960 x 540, secondary display 620 x 540.
|
||||
- Test top-screen UI only against the main display viewport, and bottom-screen UI only against the secondary display viewport.
|
||||
- User rebuilds app; do not rebuild APK unless explicitly requested.
|
||||
- Apply game changes to both web version and mobile app version.
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -7,8 +7,8 @@ android {
|
||||
applicationId "com.warren.iwanttoheal"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 44
|
||||
versionName "1.0.26"
|
||||
versionCode 55
|
||||
versionName "1.0.36"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
+103
-17
@@ -134,22 +134,22 @@ INSERT OR IGNORE INTO spells
|
||||
VALUES
|
||||
(1, 1, 'mend', 'Mend', 'direct_heal', 5, 0.5, 30, 1, '+', 'A fast, efficient single-target heal.'),
|
||||
(2, 1, 'renew', 'Renew', 'heal_over_time', 7, 0.5, 12, 1, '~', 'Heals now and continues healing over time.'),
|
||||
(3, 1, 'radiance', 'Radiance', 'party_heal', 12, 8, 18, 1, '*', 'Restores health to every living party member.'),
|
||||
(3, 1, 'radiance', 'Radiance', 'party_heal', 12, 8, 18, 1, '*', 'Restores health to up to 4 injured party members.'),
|
||||
(4, 1, 'sun-ward', 'Sun Ward', 'absorb', 8, 7, 36, 1, 'O', 'Places a damage-absorbing shield on your target.'),
|
||||
(5, 1, 'purify', 'Purify', 'cleanse', 5, 5, 10, 1, 'x', 'Removes a harmful effect and restores health.'),
|
||||
(6, 1, 'dawn-burst', 'Dawn Burst', 'party_heal', 16, 12, 28, 5, 'D', 'A brilliant wave of healing for the entire party.'),
|
||||
(6, 1, 'dawn-burst', 'Dawn Burst', 'party_heal', 16, 12, 28, 5, 'D', 'A brilliant wave of healing for up to 4 injured allies.'),
|
||||
(7, 1, 'guardian-light', 'Guardian Light', 'absorb', 13, 14, 55, 10, 'G', 'A powerful ward reserved for moments of extreme danger.'),
|
||||
(8, 1, 'second-sun', 'Second Sun', 'direct_heal', 20, 20, 85, 15, 'S', 'Calls down a delayed surge of restorative light.'),
|
||||
(9, 1, 'daybreak', 'Daybreak', 'party_heal', 23, 30, 48, 20, 'A', 'Floods the party with the full strength of dawn.'),
|
||||
(9, 1, 'daybreak', 'Daybreak', 'party_heal', 23, 30, 48, 20, 'A', 'Floods up to 4 injured allies with the full strength of dawn.'),
|
||||
(20, 2, 'verdant-touch', 'Verdant Touch', 'direct_heal', 5, 0.5, 28, 1, '+', 'A quick pulse of living energy.'),
|
||||
(21, 2, 'seed-of-life', 'Seed of Life', 'heal_over_time', 7, 0.5, 11, 1, 's', 'Plants a restorative seed that blooms over time.'),
|
||||
(22, 2, 'wild-bloom', 'Wild Bloom', 'party_heal', 12, 8, 17, 1, '*', 'Restorative growth spreads through the party.'),
|
||||
(22, 2, 'wild-bloom', 'Wild Bloom', 'party_heal', 12, 8, 17, 1, '*', 'Restorative growth spreads to up to 4 injured allies.'),
|
||||
(23, 2, 'barkskin', 'Barkskin', 'absorb', 8, 7, 34, 1, 'B', 'Wraps an ally in protective living bark.'),
|
||||
(24, 2, 'purging-sap', 'Purging Sap', 'cleanse', 5, 5, 10, 1, 'p', 'Draws a harmful effect out through enchanted sap.'),
|
||||
(25, 2, 'ancient-grove', 'Ancient Grove', 'party_heal', 17, 12, 31, 5, 'T', 'Briefly summons the shelter of an ancient grove.'),
|
||||
(30, 3, 'etched-mend', 'Etched Mend', 'direct_heal', 5, 0.5, 29, 1, '+', 'Completes a simple rune of restoration.'),
|
||||
(31, 3, 'echo-rune', 'Echo Rune', 'heal_over_time', 7, 0.5, 12, 1, 'e', 'Repeats a restorative rune over several moments.'),
|
||||
(32, 3, 'concordance', 'Concordance', 'party_heal', 12, 8, 18, 1, '*', 'Links the party through a shared healing pattern.'),
|
||||
(32, 3, 'concordance', 'Concordance', 'party_heal', 12, 8, 18, 1, '*', 'Links up to 4 injured allies through a shared healing pattern.'),
|
||||
(33, 3, 'aegis-script', 'Aegis Script', 'absorb', 8, 7, 35, 1, 'O', 'Writes a temporary barrier around an ally.'),
|
||||
(34, 3, 'unravel', 'Unravel', 'cleanse', 5, 5, 10, 1, 'u', 'Unravels a hostile magical pattern.'),
|
||||
(35, 3, 'grand-design', 'Grand Design', 'party_heal', 16, 12, 30, 5, 'R', 'Activates a prepared network of restorative runes.');
|
||||
@@ -191,6 +191,76 @@ UPDATE spells SET unlock_level = 10 WHERE slug = 'guardian-light';
|
||||
UPDATE spells SET unlock_level = 15 WHERE slug = 'second-sun';
|
||||
UPDATE spells SET unlock_level = 20 WHERE slug = 'daybreak';
|
||||
|
||||
UPDATE spells SET
|
||||
name = 'Verdant Touch',
|
||||
spell_type = 'direct_hot',
|
||||
resource_cost = 5,
|
||||
cooldown_seconds = 0.5,
|
||||
power = 20,
|
||||
glyph = '+',
|
||||
description = 'A weaker direct heal that also plants a stacking heal over time.'
|
||||
WHERE slug = 'verdant-touch';
|
||||
|
||||
UPDATE spells SET
|
||||
name = 'Wild Growth',
|
||||
spell_type = 'party_hot',
|
||||
resource_cost = 12,
|
||||
cooldown_seconds = 8,
|
||||
power = 14,
|
||||
glyph = '*',
|
||||
description = 'Applies a stacking heal over time to up to 4 injured allies.'
|
||||
WHERE slug = 'wild-bloom';
|
||||
|
||||
UPDATE spells SET
|
||||
name = 'Barkskin',
|
||||
spell_type = 'damage_reduction',
|
||||
resource_cost = 10,
|
||||
cooldown_seconds = 14,
|
||||
power = 0,
|
||||
glyph = 'B',
|
||||
description = 'Reduces the target ally''s damage taken by 50% for 8 seconds.'
|
||||
WHERE slug = 'barkskin';
|
||||
|
||||
UPDATE spells SET
|
||||
name = 'Ancient Grove',
|
||||
spell_type = 'party_hot',
|
||||
resource_cost = 17,
|
||||
cooldown_seconds = 12,
|
||||
power = 24,
|
||||
glyph = 'T',
|
||||
description = 'Applies a stronger stacking heal over time to up to 4 injured allies.'
|
||||
WHERE slug = 'ancient-grove';
|
||||
|
||||
UPDATE spells SET
|
||||
name = 'Mending Rune',
|
||||
spell_type = 'bounce_heal',
|
||||
resource_cost = 7,
|
||||
cooldown_seconds = 0.5,
|
||||
power = 18,
|
||||
glyph = 'e',
|
||||
description = 'Places a rune that heals when the ally takes damage, then jumps 4 times.'
|
||||
WHERE slug = 'echo-rune';
|
||||
|
||||
UPDATE spells SET
|
||||
name = 'Concordance',
|
||||
spell_type = 'party_absorb',
|
||||
resource_cost = 12,
|
||||
cooldown_seconds = 8,
|
||||
power = 28,
|
||||
glyph = '*',
|
||||
description = 'Shields up to 4 injured allies through a shared barrier pattern.'
|
||||
WHERE slug = 'concordance';
|
||||
|
||||
UPDATE spells SET
|
||||
name = 'Grand Design',
|
||||
spell_type = 'party_absorb',
|
||||
resource_cost = 16,
|
||||
cooldown_seconds = 12,
|
||||
power = 42,
|
||||
glyph = 'R',
|
||||
description = 'Raises a stronger shared barrier around up to 4 injured allies.'
|
||||
WHERE slug = 'grand-design';
|
||||
|
||||
INSERT OR IGNORE INTO items
|
||||
(id, slug, name, slot, rarity, item_level, healing_power, max_resource_bonus, glyph, description)
|
||||
VALUES
|
||||
@@ -479,9 +549,7 @@ WHERE id BETWEEN 901 AND 1409;
|
||||
UPDATE items
|
||||
SET rarity = CASE item_level
|
||||
WHEN 1 THEN 'common'
|
||||
WHEN 5 THEN 'common'
|
||||
WHEN 10 THEN 'uncommon'
|
||||
WHEN 15 THEN 'rare'
|
||||
WHEN 20 THEN 'epic'
|
||||
WHEN 25 THEN 'legendary'
|
||||
ELSE rarity
|
||||
@@ -610,18 +678,22 @@ JOIN coin_sources
|
||||
ON coin_sources.encounter_id = crafting_recipes.source_encounter_id
|
||||
AND coin_sources.difficulty_id = crafting_recipes.difficulty_id;
|
||||
|
||||
DELETE FROM character_talents
|
||||
WHERE talent_id IN (SELECT id FROM talents WHERE class_id = 1);
|
||||
|
||||
DELETE FROM talents WHERE class_id = 1;
|
||||
|
||||
INSERT OR IGNORE INTO talents
|
||||
(id, class_id, slug, name, max_rank, tier, branch, prerequisite_talent_id, prerequisite_rank, effect_type, effect_value_per_rank, glyph, description)
|
||||
VALUES
|
||||
(1, 1, 'bright-reserves', 'Bright Reserves', 5, 1, 1, NULL, 0, 'max_resource', 2, 'M', 'Increases maximum Mana by 2 per rank.'),
|
||||
(2, 1, 'gentle-dawn', 'Gentle Dawn', 5, 1, 2, NULL, 0, 'hot_power_percent', 2, '~', 'Increases healing-over-time power by 2% per rank.'),
|
||||
(10, 1, 'steady-hands', 'Steady Hands', 5, 1, 3, NULL, 0, 'direct_heal_percent', 2, '+', 'Increases direct healing by 2% per rank.'),
|
||||
(11, 1, 'overflowing-light', 'Overflowing Light', 5, 2, 1, 1, 3, 'resource_regen_percent', 2, 'O', 'Improves Mana regeneration by 2% per rank. Requires Bright Reserves rank 3.'),
|
||||
(12, 1, 'lingering-rays', 'Lingering Rays', 5, 2, 2, 2, 3, 'hot_duration_percent', 4, 'L', 'Extends healing-over-time duration by 4% per rank. Requires Gentle Dawn rank 3.'),
|
||||
(13, 1, 'radiant-precision', 'Radiant Precision', 5, 2, 3, 10, 3, 'critical_heal_percent', 1, '!', 'Adds 1% healing critical chance per rank. Requires Steady Hands rank 3.'),
|
||||
(14, 1, 'sunlit-aegis', 'Sunlit Aegis', 3, 3, 1, 11, 5, 'absorb_power_percent', 5, 'A', 'Strengthens absorb effects by 5% per rank. Requires Overflowing Light rank 5.'),
|
||||
(15, 1, 'shared-dawn', 'Shared Dawn', 3, 3, 2, 12, 5, 'party_heal_percent', 5, '*', 'Increases party-wide healing by 5% per rank. Requires Lingering Rays rank 5.'),
|
||||
(16, 1, 'miracle-worker', 'Miracle Worker', 1, 4, 2, 15, 3, 'cooldown_reduction_percent', 10, 'S', 'Reduces major healing cooldowns by 10%. Requires Shared Dawn rank 3.'),
|
||||
(1, 1, 'shield-applies-renew', 'Shield applies Renew', 1, 1, 1, NULL, 0, 'shield_applies_renew', 0, '~', 'Sun Ward also applies Renew to the target.'),
|
||||
(2, 1, 'mend-applies-renew', 'Mend applies Renew', 1, 1, 2, NULL, 0, 'mend_applies_renew', 0, '~', 'Mend also applies Renew to the target.'),
|
||||
(10, 1, 'mend-adds-shield', 'Mend adds Shield', 1, 1, 3, NULL, 0, 'mend_applies_shield', 0, 'O', 'Mend also applies a shield at 50% strength to the target.'),
|
||||
(11, 1, 'radiance-adds-shield', 'Radiance adds Shield', 1, 1, 4, NULL, 0, 'radiance_applies_shield', 0, 'O', 'Radiance applies a shield at 30% strength to affected party members.'),
|
||||
(12, 1, 'radiance-applies-renew', 'Radiance applies Renew', 1, 1, 5, NULL, 0, 'radiance_applies_renew', 0, '~', 'Radiance applies Renew at 50% duration to affected party members.'),
|
||||
(13, 1, 'shielded-damage-reduction', 'Shielded takes less', 1, 1, 6, NULL, 0, 'shielded_damage_reduction', 0, 'D', 'While shielded, the target receives 20% less damage.'),
|
||||
(14, 1, 'shielded-healing-bonus', 'Shielded healing boost', 1, 1, 7, NULL, 0, 'shielded_healing_bonus', 0, '+', 'While shielded, the target receives 20% more healing.'),
|
||||
(15, 1, 'mend-reduces-radiance', 'Mend lowers Radiance', 1, 1, 8, NULL, 0, 'mend_reduces_radiance_cooldown', 0, '*', 'Casting Mend reduces the cooldown of Radiance by 2 seconds.'),
|
||||
|
||||
(3, 2, 'deep-roots', 'Deep Roots', 5, 1, 1, NULL, 0, 'max_resource', 2, 'R', 'Increases maximum Bloom by 2 per rank.'),
|
||||
(20, 2, 'patient-growth', 'Patient Growth', 5, 1, 2, NULL, 0, 'hot_power_percent', 2, 's', 'Increases healing-over-time power by 2% per rank.'),
|
||||
@@ -728,6 +800,7 @@ INSERT INTO generated_loot_tiers
|
||||
(item_level, dungeon_id, raid_id, dungeon_difficulty_id, raid_difficulty_id, recipe_base, craft_quantity)
|
||||
VALUES
|
||||
(10, 3, 2, 2, 101, 1100, 2),
|
||||
(15, 4, 5, 3, 103, 1200, 3),
|
||||
(20, 6, 7, 4, 104, 1300, 4),
|
||||
(25, 8, 9, 5, 105, 1400, 5);
|
||||
|
||||
@@ -1276,10 +1349,23 @@ SET difficulty_id = CASE
|
||||
END
|
||||
WHERE id BETWEEN 901 AND 1409;
|
||||
|
||||
DELETE FROM crafting_recipe_components
|
||||
WHERE recipe_id IN (
|
||||
SELECT crafting_recipes.id
|
||||
FROM crafting_recipes
|
||||
JOIN items ON items.id = crafting_recipes.item_id
|
||||
WHERE items.item_level NOT IN (1, 5, 10, 15, 20, 25)
|
||||
);
|
||||
|
||||
DELETE FROM crafting_recipes
|
||||
WHERE item_id IN (
|
||||
SELECT id FROM items WHERE item_level NOT IN (1, 5, 10, 15, 20, 25)
|
||||
);
|
||||
|
||||
UPDATE items
|
||||
SET rarity = CASE item_level
|
||||
WHEN 1 THEN 'common'
|
||||
WHEN 5 THEN 'common'
|
||||
WHEN 5 THEN 'uncommon'
|
||||
WHEN 10 THEN 'uncommon'
|
||||
WHEN 15 THEN 'rare'
|
||||
WHEN 20 THEN 'epic'
|
||||
|
||||
@@ -79,9 +79,9 @@ cd /Users/warren/Documents/testgame/testgame
|
||||
export GITEA_URL="https://git.whoagland.com"
|
||||
export GITEA_OWNER="phenom"
|
||||
export GITEA_REPO="i-want-to-heal"
|
||||
export GITEA_TOKEN="PASTE_TOKEN_HERE"
|
||||
export GITEA_TOKEN="ed2db3fd54546e9658377d0551b3fc3961583f1d"
|
||||
|
||||
VERSION="1.0.26"
|
||||
VERSION="1.0.27"
|
||||
APK="IWantToHeal-Thor-v$VERSION.apk"
|
||||
|
||||
RELEASE_JSON=$(curl -sS -X POST "$GITEA_URL/api/v1/repos/$GITEA_OWNER/$GITEA_REPO/releases" \
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="620" height="540" viewBox="0 0 620 540" role="img" aria-label="Ayn Thor secondary display spell effect quick swap mockup">
|
||||
<rect width="620" height="540" fill="#111219"/>
|
||||
<rect x="16" y="16" width="588" height="48" fill="#20222d" stroke="#0a0b0e" stroke-width="3"/>
|
||||
<text x="32" y="36" fill="#8aa0b7" font-family="monospace" font-size="11" font-weight="700">SPELL EFFECTS</text>
|
||||
<text x="32" y="56" fill="#f5e6b2" font-family="monospace" font-size="18" font-weight="900">Quick Swap</text>
|
||||
<text x="460" y="47" fill="#83d99b" font-family="monospace" font-size="13" font-weight="900">4/4 ACTIVE</text>
|
||||
|
||||
<g transform="translate(16 82)">
|
||||
<rect width="588" height="118" fill="#191b25" stroke="#3a3944" stroke-width="2"/>
|
||||
<text x="16" y="28" fill="#8aa0b7" font-family="monospace" font-size="10" font-weight="700">ACTIVE SLOTS</text>
|
||||
<g transform="translate(14 46)">
|
||||
<rect width="130" height="54" fill="#29291f" stroke="#e5b95f" stroke-width="3"/>
|
||||
<text x="10" y="20" fill="#e5b95f" font-family="monospace" font-size="11" font-weight="900">LV 5</text>
|
||||
<text x="10" y="39" fill="#f7f2d7" font-family="monospace" font-size="12" font-weight="900">Mend Renew</text>
|
||||
</g>
|
||||
<g transform="translate(154 46)">
|
||||
<rect width="130" height="54" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
|
||||
<text x="10" y="20" fill="#6da7df" font-family="monospace" font-size="11" font-weight="900">LV 10</text>
|
||||
<text x="10" y="39" fill="#f7f2d7" font-family="monospace" font-size="12" font-weight="900">Rad Shield</text>
|
||||
</g>
|
||||
<g transform="translate(294 46)">
|
||||
<rect width="130" height="54" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
|
||||
<text x="10" y="20" fill="#4fb978" font-family="monospace" font-size="11" font-weight="900">LV 15</text>
|
||||
<text x="10" y="39" fill="#f7f2d7" font-family="monospace" font-size="12" font-weight="900">Shield DR</text>
|
||||
</g>
|
||||
<g transform="translate(434 46)">
|
||||
<rect width="130" height="54" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
|
||||
<text x="10" y="20" fill="#b16dde" font-family="monospace" font-size="11" font-weight="900">LV 20</text>
|
||||
<text x="10" y="39" fill="#f7f2d7" font-family="monospace" font-size="12" font-weight="900">Mend CD</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<g transform="translate(16 218)">
|
||||
<rect width="278" height="286" fill="#191b25" stroke="#3a3944" stroke-width="2"/>
|
||||
<text x="16" y="28" fill="#8aa0b7" font-family="monospace" font-size="10" font-weight="700">POOL</text>
|
||||
<g transform="translate(14 46)">
|
||||
<rect width="250" height="42" fill="#29291f" stroke="#e5b95f" stroke-width="3"/>
|
||||
<text x="12" y="26" fill="#f5e6b2" font-family="monospace" font-size="13" font-weight="900">Mend applies Renew</text>
|
||||
</g>
|
||||
<g transform="translate(14 98)">
|
||||
<rect width="250" height="42" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
|
||||
<text x="12" y="26" fill="#f7f2d7" font-family="monospace" font-size="13" font-weight="900">Shield applies Renew</text>
|
||||
</g>
|
||||
<g transform="translate(14 150)">
|
||||
<rect width="250" height="42" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
|
||||
<text x="12" y="26" fill="#f7f2d7" font-family="monospace" font-size="13" font-weight="900">Mend adds Shield</text>
|
||||
</g>
|
||||
<g transform="translate(14 202)">
|
||||
<rect width="250" height="42" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
|
||||
<text x="12" y="26" fill="#f7f2d7" font-family="monospace" font-size="13" font-weight="900">Radiance Renew</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<g transform="translate(310 218)">
|
||||
<rect width="294" height="286" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
|
||||
<text x="16" y="28" fill="#8aa0b7" font-family="monospace" font-size="10" font-weight="700">DETAIL</text>
|
||||
<text x="16" y="58" fill="#f5e6b2" font-family="monospace" font-size="18" font-weight="900">Mend applies Renew</text>
|
||||
<text x="16" y="92" fill="#d7dbe0" font-family="monospace" font-size="13">Mend also applies Renew to</text>
|
||||
<text x="16" y="112" fill="#d7dbe0" font-family="monospace" font-size="13">the target.</text>
|
||||
<text x="16" y="150" fill="#83d99b" font-family="monospace" font-size="12" font-weight="900">Rule: same HoT refreshes.</text>
|
||||
<text x="16" y="172" fill="#83d99b" font-family="monospace" font-size="12" font-weight="900">Different HoTs coexist.</text>
|
||||
<rect x="16" y="216" width="120" height="44" rx="3" fill="#e5b95f" stroke="#0a0b0e" stroke-width="3"/>
|
||||
<text x="50" y="244" fill="#21180a" font-family="monospace" font-size="14" font-weight="900">Equip</text>
|
||||
<rect x="154" y="216" width="120" height="44" rx="3" fill="#191b25" stroke="#3a3944" stroke-width="2"/>
|
||||
<text x="184" y="244" fill="#f7f2d7" font-family="monospace" font-size="14" font-weight="900">Clear</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.8 KiB |
@@ -0,0 +1,90 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540" role="img" aria-label="Ayn Thor main display talent effect planner mockup">
|
||||
<rect width="960" height="540" fill="#111219"/>
|
||||
<rect x="20" y="18" width="920" height="50" fill="#20222d" stroke="#0a0b0e" stroke-width="3"/>
|
||||
<text x="38" y="39" fill="#8aa0b7" font-family="monospace" font-size="12" font-weight="700">CHARACTER WORKSHOP</text>
|
||||
<text x="38" y="59" fill="#f5e6b2" font-family="monospace" font-size="20" font-weight="900">Spell Effects</text>
|
||||
<rect x="764" y="28" width="154" height="30" rx="3" fill="#e5b95f" stroke="#0a0b0e" stroke-width="3"/>
|
||||
<text x="786" y="49" fill="#21180a" font-family="monospace" font-size="13" font-weight="900">Save Loadout</text>
|
||||
|
||||
<g transform="translate(20 88)">
|
||||
<rect width="294" height="420" fill="#191b25" stroke="#3a3944" stroke-width="2"/>
|
||||
<text x="18" y="28" fill="#8aa0b7" font-family="monospace" font-size="11" font-weight="700">UNLOCKED SLOTS</text>
|
||||
<text x="18" y="53" fill="#f7f2d7" font-family="monospace" font-size="19" font-weight="900">4 active effects</text>
|
||||
|
||||
<g transform="translate(16 76)">
|
||||
<rect width="262" height="58" fill="#29291f" stroke="#e5b95f" stroke-width="3"/>
|
||||
<circle cx="29" cy="29" r="15" fill="#e5b95f"/>
|
||||
<text x="22" y="34" fill="#21180a" font-family="monospace" font-size="13" font-weight="900">5</text>
|
||||
<text x="56" y="25" fill="#f5e6b2" font-family="monospace" font-size="15" font-weight="900">Mend applies Renew</text>
|
||||
<text x="56" y="45" fill="#83d99b" font-family="monospace" font-size="11">Selected</text>
|
||||
</g>
|
||||
<g transform="translate(16 148)">
|
||||
<rect width="262" height="58" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
|
||||
<circle cx="29" cy="29" r="15" fill="#6da7df"/>
|
||||
<text x="18" y="34" fill="#08111c" font-family="monospace" font-size="12" font-weight="900">10</text>
|
||||
<text x="56" y="25" fill="#f7f2d7" font-family="monospace" font-size="15" font-weight="900">Radiance adds shield</text>
|
||||
<text x="56" y="45" fill="#8aa0b7" font-family="monospace" font-size="11">30 percent strength</text>
|
||||
</g>
|
||||
<g transform="translate(16 220)">
|
||||
<rect width="262" height="58" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
|
||||
<circle cx="29" cy="29" r="15" fill="#4fb978"/>
|
||||
<text x="18" y="34" fill="#071408" font-family="monospace" font-size="12" font-weight="900">15</text>
|
||||
<text x="56" y="25" fill="#f7f2d7" font-family="monospace" font-size="15" font-weight="900">Shielded takes less</text>
|
||||
<text x="56" y="45" fill="#8aa0b7" font-family="monospace" font-size="11">20 percent damage cut</text>
|
||||
</g>
|
||||
<g transform="translate(16 292)">
|
||||
<rect width="262" height="58" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
|
||||
<circle cx="29" cy="29" r="15" fill="#b16dde"/>
|
||||
<text x="18" y="34" fill="#15071c" font-family="monospace" font-size="12" font-weight="900">20</text>
|
||||
<text x="56" y="25" fill="#f7f2d7" font-family="monospace" font-size="15" font-weight="900">Mend lowers Radiance</text>
|
||||
<text x="56" y="45" fill="#8aa0b7" font-family="monospace" font-size="11">-2 sec cooldown</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<g transform="translate(334 88)">
|
||||
<rect width="606" height="286" fill="#191b25" stroke="#3a3944" stroke-width="2"/>
|
||||
<text x="18" y="28" fill="#8aa0b7" font-family="monospace" font-size="11" font-weight="700">EFFECT POOL</text>
|
||||
<text x="18" y="53" fill="#f7f2d7" font-family="monospace" font-size="19" font-weight="900">Pick effects, swap anytime</text>
|
||||
|
||||
<g transform="translate(18 76)">
|
||||
<rect width="276" height="58" fill="#29291f" stroke="#e5b95f" stroke-width="3"/>
|
||||
<text x="14" y="24" fill="#f5e6b2" font-family="monospace" font-size="14" font-weight="900">Mend applies Renew</text>
|
||||
<text x="14" y="45" fill="#b7c3d0" font-family="monospace" font-size="11">Direct heal also applies Renew.</text>
|
||||
<text x="226" y="24" fill="#83d99b" font-family="monospace" font-size="10" font-weight="900">ON</text>
|
||||
</g>
|
||||
<g transform="translate(312 76)">
|
||||
<rect width="276" height="58" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
|
||||
<text x="14" y="24" fill="#f7f2d7" font-family="monospace" font-size="14" font-weight="900">Shield applies Renew</text>
|
||||
<text x="14" y="45" fill="#b7c3d0" font-family="monospace" font-size="11">Sun Ward adds a Renew effect.</text>
|
||||
</g>
|
||||
<g transform="translate(18 148)">
|
||||
<rect width="276" height="58" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
|
||||
<text x="14" y="24" fill="#f7f2d7" font-family="monospace" font-size="14" font-weight="900">Mend adds Shield</text>
|
||||
<text x="14" y="45" fill="#b7c3d0" font-family="monospace" font-size="11">50 percent shield strength.</text>
|
||||
</g>
|
||||
<g transform="translate(312 148)">
|
||||
<rect width="276" height="58" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
|
||||
<text x="14" y="24" fill="#f7f2d7" font-family="monospace" font-size="14" font-weight="900">Radiance adds Shield</text>
|
||||
<text x="14" y="45" fill="#b7c3d0" font-family="monospace" font-size="11">30 percent to affected allies.</text>
|
||||
</g>
|
||||
<g transform="translate(18 220)">
|
||||
<rect width="276" height="46" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
|
||||
<text x="14" y="20" fill="#f7f2d7" font-family="monospace" font-size="14" font-weight="900">Radiance applies Renew</text>
|
||||
<text x="14" y="38" fill="#b7c3d0" font-family="monospace" font-size="11">50 percent duration.</text>
|
||||
</g>
|
||||
<g transform="translate(312 220)">
|
||||
<rect width="276" height="46" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
|
||||
<text x="14" y="20" fill="#f7f2d7" font-family="monospace" font-size="14" font-weight="900">Shielded gets +healing</text>
|
||||
<text x="14" y="38" fill="#b7c3d0" font-family="monospace" font-size="11">20 percent more healing.</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<g transform="translate(334 392)">
|
||||
<rect width="606" height="116" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
|
||||
<text x="18" y="26" fill="#8aa0b7" font-family="monospace" font-size="11" font-weight="700">SELECTED EFFECT</text>
|
||||
<text x="18" y="52" fill="#f5e6b2" font-family="monospace" font-size="20" font-weight="900">Mend applies Renew</text>
|
||||
<text x="18" y="78" fill="#d7dbe0" font-family="monospace" font-size="13">Casting Mend also applies Renew to the same target. Renew refreshes itself; different HoTs coexist.</text>
|
||||
<rect x="470" y="32" width="112" height="42" rx="3" fill="#e5b95f" stroke="#0a0b0e" stroke-width="3"/>
|
||||
<text x="497" y="58" fill="#21180a" font-family="monospace" font-size="14" font-weight="900">Equip</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.6 KiB |
+131
-25
@@ -22,6 +22,7 @@ const bossImageContentTypes = {
|
||||
}
|
||||
const equipmentSlots = ['weapon', 'helmet', 'chest', 'gloves', 'boots', 'pants', 'ring', 'necklace', 'trinket']
|
||||
const componentSlot = 'component'
|
||||
const directCraftItemLevels = new Set([1, 10, 20, 25])
|
||||
const sessionCookieName = 'chronicle_session'
|
||||
const sessionLifetimeSeconds = 60 * 60 * 24 * 30
|
||||
const rateLimitBuckets = new Map()
|
||||
@@ -232,6 +233,25 @@ function consumeRateLimit(key, limit, windowMs) {
|
||||
}
|
||||
}
|
||||
|
||||
function catchUpExperienceReward(database, accountId, characterId, baseReward, currentExperience, currentLevel) {
|
||||
const targetLevel = database.prepare(`
|
||||
SELECT COALESCE(MAX(level), 0) AS level
|
||||
FROM characters
|
||||
WHERE account_id = ?
|
||||
AND id != ?
|
||||
`).get(accountId, characterId).level
|
||||
if (targetLevel <= currentLevel) return baseReward
|
||||
const targetExperience = database.prepare(`
|
||||
SELECT experience_required AS experienceRequired
|
||||
FROM level_progression
|
||||
WHERE level = ?
|
||||
`).get(targetLevel)?.experienceRequired ?? currentExperience
|
||||
const gap = Math.max(0, targetExperience - currentExperience)
|
||||
if (gap <= 0) return baseReward
|
||||
const doubledBase = Math.min(baseReward, Math.ceil(gap / 2))
|
||||
return doubledBase * 2 + (baseReward - doubledBase)
|
||||
}
|
||||
|
||||
function normalizeUsername(value) {
|
||||
const username = String(value ?? '').trim()
|
||||
if (!/^[A-Za-z0-9_]{3,20}$/.test(username)) {
|
||||
@@ -1693,16 +1713,9 @@ function craftItem(database, characterId, recipeId) {
|
||||
WHERE crafting_recipes.id = ?
|
||||
`).get(recipeId)
|
||||
if (!recipe) throw new Error('That crafting recipe does not exist.')
|
||||
const lowerTierRecipe = database.prepare(`
|
||||
SELECT crafting_recipes.id
|
||||
FROM crafting_recipes
|
||||
JOIN items ON items.id = crafting_recipes.item_id
|
||||
WHERE crafting_recipes.source_encounter_id = ?
|
||||
AND items.slot = ?
|
||||
AND items.item_level < ?
|
||||
LIMIT 1
|
||||
`).get(recipe.sourceEncounterId, recipe.slot, recipe.itemLevel)
|
||||
if (lowerTierRecipe) throw new Error('Upgrade the previous item tier instead.')
|
||||
if (!directCraftItemLevels.has(recipe.itemLevel)) {
|
||||
throw new Error('Upgrade the previous item tier instead.')
|
||||
}
|
||||
|
||||
const components = database.prepare(`
|
||||
SELECT
|
||||
@@ -1849,9 +1862,20 @@ function upgradeItem(database, characterId, itemId) {
|
||||
return getProfile(database, characterId)
|
||||
}
|
||||
|
||||
function talentEffectCapacity(level) {
|
||||
return Math.min(4, Math.max(0, Math.floor(level / 5)))
|
||||
}
|
||||
|
||||
function talentEffectSource(effectType) {
|
||||
if (effectType.startsWith('mend_')) return 'Mend'
|
||||
if (effectType.startsWith('radiance_')) return 'Radiance'
|
||||
if (effectType.startsWith('shield_') || effectType.startsWith('shielded_')) return 'Shield'
|
||||
return effectType
|
||||
}
|
||||
|
||||
function allocateTalent(database, characterId, talentId) {
|
||||
const character = database.prepare(`
|
||||
SELECT class_id AS classId, talent_points AS talentPoints
|
||||
SELECT class_id AS classId, level, talent_points AS talentPoints
|
||||
FROM characters
|
||||
WHERE id = ?
|
||||
`).get(characterId)
|
||||
@@ -1863,7 +1887,8 @@ function allocateTalent(database, characterId, talentId) {
|
||||
max_rank AS maxRank,
|
||||
tier,
|
||||
prerequisite_talent_id AS prerequisiteTalentId,
|
||||
prerequisite_rank AS prerequisiteRank
|
||||
prerequisite_rank AS prerequisiteRank,
|
||||
effect_type AS effectType
|
||||
FROM talents
|
||||
WHERE id = ?
|
||||
`).get(talentId)
|
||||
@@ -1871,6 +1896,60 @@ function allocateTalent(database, characterId, talentId) {
|
||||
if (!talent || talent.classId !== character.classId) {
|
||||
throw new Error('That talent does not belong to the active class.')
|
||||
}
|
||||
|
||||
if (character.classId === 1) {
|
||||
const currentRank = database.prepare(`
|
||||
SELECT rank
|
||||
FROM character_talents
|
||||
WHERE character_id = ? AND talent_id = ?
|
||||
`).get(characterId, talentId)?.rank ?? 0
|
||||
database.exec('BEGIN')
|
||||
try {
|
||||
if (currentRank > 0) {
|
||||
database.prepare(`
|
||||
DELETE FROM character_talents
|
||||
WHERE character_id = ? AND talent_id = ?
|
||||
`).run(characterId, talentId)
|
||||
} else {
|
||||
const capacity = talentEffectCapacity(character.level)
|
||||
if (capacity <= 0) throw new Error('Spell effects unlock at level 5.')
|
||||
const activeTalents = database.prepare(`
|
||||
SELECT
|
||||
talents.id,
|
||||
talents.name,
|
||||
talents.effect_type AS effectType
|
||||
FROM character_talents
|
||||
JOIN talents ON talents.id = character_talents.talent_id
|
||||
WHERE character_talents.character_id = ?
|
||||
AND talents.class_id = ?
|
||||
AND character_talents.rank > 0
|
||||
`).all(characterId, character.classId)
|
||||
const source = talentEffectSource(talent.effectType)
|
||||
const sourceConflict = activeTalents.find(
|
||||
(candidate) => candidate.id !== talentId && talentEffectSource(candidate.effectType) === source,
|
||||
)
|
||||
if (sourceConflict) {
|
||||
throw new Error(`Only one ${source} spell effect can be active.`)
|
||||
}
|
||||
const activeCount = activeTalents.length
|
||||
if (activeCount >= capacity) {
|
||||
throw new Error(`Level ${character.level} allows ${capacity} active spell effect${capacity === 1 ? '' : 's'}.`)
|
||||
}
|
||||
database.prepare(`
|
||||
INSERT INTO character_talents (character_id, talent_id, rank)
|
||||
VALUES (?, ?, 1)
|
||||
ON CONFLICT(character_id, talent_id)
|
||||
DO UPDATE SET rank = 1
|
||||
`).run(characterId, talentId)
|
||||
}
|
||||
database.exec('COMMIT')
|
||||
} catch (error) {
|
||||
database.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
return getProfile(database, characterId)
|
||||
}
|
||||
|
||||
if (character.talentPoints <= 0) {
|
||||
throw new Error('No talent points are available.')
|
||||
}
|
||||
@@ -1949,11 +2028,13 @@ function resetTalents(database, characterId) {
|
||||
WHERE character_id = ?
|
||||
AND talent_id IN (SELECT id FROM talents WHERE class_id = ?)
|
||||
`).run(characterId, character.classId)
|
||||
database.prepare(`
|
||||
UPDATE characters
|
||||
SET talent_points = MIN(level, talent_points + ?)
|
||||
WHERE id = ?
|
||||
`).run(refunded, characterId)
|
||||
if (character.classId !== 1) {
|
||||
database.prepare(`
|
||||
UPDATE characters
|
||||
SET talent_points = MIN(level, talent_points + ?)
|
||||
WHERE id = ?
|
||||
`).run(refunded, characterId)
|
||||
}
|
||||
database.exec('COMMIT')
|
||||
} catch (error) {
|
||||
database.exec('ROLLBACK')
|
||||
@@ -2024,12 +2105,21 @@ function completeDungeon(database, characterId, accountId, dungeonId, difficulty
|
||||
const completedPart = Math.min(Math.max(Number(runMetrics?.completedPart) || 1, 1), 3)
|
||||
const startPart = Math.min(Math.max(Number(runMetrics?.startPart) || 1, 1), 3)
|
||||
const completedParts = completedPart - startPart + 1
|
||||
const rewardMultiplier = runMetrics?.hardMode ? 2 : 1
|
||||
const rawPartDurations = runMetrics?.partDurationSeconds
|
||||
const partDurationSeconds = Array.isArray(rawPartDurations) && rawPartDurations.length === 3
|
||||
? rawPartDurations.map(Number)
|
||||
: null
|
||||
const experienceReward = Math.round(
|
||||
dungeon.experienceReward * dungeon.experienceMultiplier * completedPart,
|
||||
const baseExperienceReward = Math.round(
|
||||
dungeon.experienceReward * dungeon.experienceMultiplier * completedPart * rewardMultiplier,
|
||||
)
|
||||
const experienceReward = catchUpExperienceReward(
|
||||
database,
|
||||
accountId,
|
||||
characterId,
|
||||
baseExperienceReward,
|
||||
character.experience,
|
||||
character.level,
|
||||
)
|
||||
const newExperience = Math.min(character.experience + experienceReward, maxExperience)
|
||||
const newLevel = database.prepare(`
|
||||
@@ -2127,17 +2217,18 @@ function completeDungeon(database, characterId, accountId, dungeonId, difficulty
|
||||
`).all(dungeonId, dungeon.completionItemLevel ?? dungeon.droppedItemLevel + 3)
|
||||
if (bonusItems.length > 0) {
|
||||
bonusItem = bonusItems[0]
|
||||
const rewardQuantity = rewardMultiplier
|
||||
const previousQuantity = database.prepare(`
|
||||
SELECT quantity FROM character_inventory
|
||||
WHERE character_id = ? AND item_id = ?
|
||||
`).get(characterId, bonusItem.id)?.quantity ?? 0
|
||||
database.prepare(`
|
||||
INSERT INTO character_inventory (character_id, item_id, quantity, equipped)
|
||||
VALUES (?, ?, 1, 0)
|
||||
VALUES (?, ?, ?, 0)
|
||||
ON CONFLICT(character_id, item_id)
|
||||
DO UPDATE SET quantity = quantity + 1
|
||||
`).run(characterId, bonusItem.id)
|
||||
bonusItem = { ...bonusItem, quantity: 1, duplicate: previousQuantity > 0, quantityAfter: previousQuantity + 1 }
|
||||
DO UPDATE SET quantity = quantity + ?
|
||||
`).run(characterId, bonusItem.id, rewardQuantity, rewardQuantity)
|
||||
bonusItem = { ...bonusItem, quantity: rewardQuantity, duplicate: previousQuantity > 0, quantityAfter: previousQuantity + rewardQuantity }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2234,6 +2325,12 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
|
||||
let newExperience = character.experience
|
||||
let newLevel = character.level
|
||||
if (experienceMode === 'pvp-boss-quarter-level') {
|
||||
const catchUpTargetLevel = database.prepare(`
|
||||
SELECT COALESCE(MAX(level), 0) AS level
|
||||
FROM characters
|
||||
WHERE account_id = ?
|
||||
AND id != ?
|
||||
`).get(accountId, characterId).level
|
||||
for (let bossIndex = 0; bossIndex < bossesCleared && newExperience < maxExperience; bossIndex += 1) {
|
||||
const currentLevelFloor = database.prepare(`
|
||||
SELECT experience_required AS experienceRequired
|
||||
@@ -2248,7 +2345,8 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
|
||||
WHERE level = ?
|
||||
`).get(newLevel + 1).experienceRequired
|
||||
const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor)
|
||||
newExperience = Math.min(maxExperience, newExperience + Math.round(levelBand * 0.25))
|
||||
const rewardRate = catchUpTargetLevel > newLevel ? 0.5 : 0.25
|
||||
newExperience = Math.min(maxExperience, newExperience + Math.round(levelBand * rewardRate))
|
||||
newLevel = database.prepare(`
|
||||
SELECT MAX(level) AS level
|
||||
FROM level_progression
|
||||
@@ -2256,9 +2354,17 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
|
||||
`).get(newExperience).level
|
||||
}
|
||||
} else {
|
||||
const experienceReward = Math.round(
|
||||
const baseExperienceReward = Math.round(
|
||||
dungeon.experienceReward * dungeon.experienceMultiplier * (encountersCleared / 3),
|
||||
)
|
||||
const experienceReward = catchUpExperienceReward(
|
||||
database,
|
||||
accountId,
|
||||
characterId,
|
||||
baseExperienceReward,
|
||||
character.experience,
|
||||
character.level,
|
||||
)
|
||||
newExperience = Math.min(character.experience + experienceReward, maxExperience)
|
||||
newLevel = database.prepare(`
|
||||
SELECT MAX(level) AS level
|
||||
|
||||
+1942
-21
File diff suppressed because it is too large
Load Diff
+134
-96
@@ -88,6 +88,7 @@ function App() {
|
||||
const [roguelikeAbilityLabelMode, setRoguelikeAbilityLabelMode] = useState<RoguelikeAbilityLabelMode>('ability')
|
||||
const [pvpContentType, setPvpContentType] = useState<PvpContentType>('dungeon')
|
||||
const [selectedPart, setSelectedPart] = useState(1)
|
||||
const [selectedHardMode, setSelectedHardMode] = useState(false)
|
||||
const [combatContentId, setCombatContentId] = useState(1)
|
||||
const [leaderboardCategory, setLeaderboardCategory] = useState<'part_1' | 'part_2' | 'part_3' | 'full_run'>('part_1')
|
||||
const [showLoot, setShowLoot] = useState(false)
|
||||
@@ -235,6 +236,7 @@ function App() {
|
||||
<CombatScreen
|
||||
difficulty={difficulty}
|
||||
dungeon={dungeon}
|
||||
hardMode={selectedHardMode && combatContentId > 0}
|
||||
profile={profile}
|
||||
roguelikeMode={combatContentId < 0 ? roguelikeKind : undefined}
|
||||
roguelikeUpgradeTiming={combatContentId < 0 ? roguelikeUpgradeTiming : undefined}
|
||||
@@ -283,6 +285,20 @@ function App() {
|
||||
const raid = raidOptions.find((candidate) => candidate.id === selectedRaidId)
|
||||
?? raidOptions[0]
|
||||
const activityOptions = screen === 'raids' ? raidOptions : dungeonOptions
|
||||
const startPveRoguelike = () => {
|
||||
const baseDungeon = dungeonOptions[0]
|
||||
const baseRaid = raidOptions[0]
|
||||
if (roguelikeKind === 'raid') {
|
||||
setCombatContentId(-2)
|
||||
setSelectedDifficultyId(baseRaid?.difficulties[0]?.id ?? 101)
|
||||
} else {
|
||||
setCombatContentId(-1)
|
||||
setSelectedDifficultyId(baseDungeon?.difficulties[0]?.id ?? 1)
|
||||
}
|
||||
setSelectedPart(1)
|
||||
setSelectedHardMode(false)
|
||||
setScreen('combat')
|
||||
}
|
||||
const tierOptions = activityOptions
|
||||
.flatMap((option) => option.difficulties)
|
||||
.filter((difficulty, index, all) => (
|
||||
@@ -315,9 +331,9 @@ function App() {
|
||||
: 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 },
|
||||
{ part: 1, name: `${sectionName} 1`, encounterCount: 3, unlocked: true, hardUnlocked: completedSections >= 1 },
|
||||
{ part: 2, name: `${sectionName} 2`, encounterCount: 3, unlocked: completedSections >= 1, hardUnlocked: completedSections >= 2 },
|
||||
{ part: 3, name: `${sectionName} 3`, encounterCount: 3, unlocked: completedSections >= 2, hardUnlocked: completedSections >= 3 },
|
||||
]
|
||||
const cloudSync = getCloudSyncStatus()
|
||||
const canShowCloudSync = account.id !== -1 && cloudSync.available
|
||||
@@ -328,7 +344,7 @@ function App() {
|
||||
: a.sequence - b.sequence)
|
||||
|
||||
return (
|
||||
<main className={`game-shell ${screen === 'dungeons' || screen === 'raids' ? 'dungeon-shell' : ''}`}>
|
||||
<main className={`game-shell ${screen === 'dungeons' || screen === 'raids' ? 'dungeon-shell' : ''} ${screen === 'customize' ? 'workshop-shell' : ''}`}>
|
||||
<header className="topbar app-header">
|
||||
<button
|
||||
className="brand-button"
|
||||
@@ -425,84 +441,90 @@ function App() {
|
||||
</div>
|
||||
{roguelikeVariant === 'pve' && (
|
||||
<>
|
||||
<div className="roguelike-option-panel">
|
||||
<div>
|
||||
<p className="eyebrow">Upgrade Timing</p>
|
||||
<h2>Buff Drafts</h2>
|
||||
</div>
|
||||
<div className="roguelike-timing-row">
|
||||
<button
|
||||
className={`text-button ${roguelikeUpgradeTiming === 'encounter' ? 'active' : ''}`}
|
||||
onClick={() => setRoguelikeUpgradeTiming('encounter')}
|
||||
type="button"
|
||||
>
|
||||
Every Encounter
|
||||
</button>
|
||||
<button
|
||||
className={`text-button ${roguelikeUpgradeTiming === 'boss' ? 'active' : ''}`}
|
||||
onClick={() => setRoguelikeUpgradeTiming('boss')}
|
||||
type="button"
|
||||
>
|
||||
Boss Only
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="roguelike-option-panel">
|
||||
<div>
|
||||
<p className="eyebrow">Upgrade Labels</p>
|
||||
<h2>Display Mode</h2>
|
||||
</div>
|
||||
<div className="roguelike-timing-row">
|
||||
<button
|
||||
className={`text-button ${roguelikeAbilityLabelMode === 'ability' ? 'active' : ''}`}
|
||||
onClick={() => setRoguelikeAbilityLabelMode('ability')}
|
||||
type="button"
|
||||
>
|
||||
Ability Names
|
||||
</button>
|
||||
<button
|
||||
className={`text-button ${roguelikeAbilityLabelMode === 'slot' ? 'active' : ''}`}
|
||||
onClick={() => setRoguelikeAbilityLabelMode('slot')}
|
||||
type="button"
|
||||
>
|
||||
Slot Names
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="roguelike-mode-grid">
|
||||
<button
|
||||
className="menu-card"
|
||||
onClick={() => {
|
||||
const baseDungeon = dungeonOptions[0]
|
||||
setRoguelikeKind('dungeon')
|
||||
setCombatContentId(-1)
|
||||
setSelectedDifficultyId(baseDungeon.difficulties[0]?.id ?? 1)
|
||||
setSelectedPart(1)
|
||||
setScreen('combat')
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<span>D</span>
|
||||
<strong>Dungeon Roguelike</strong>
|
||||
<small>Five-player party. Two random trash enemies and a boss with a lighter early ramp.</small>
|
||||
</button>
|
||||
<button
|
||||
className="menu-card"
|
||||
onClick={() => {
|
||||
const baseRaid = raidOptions[0]
|
||||
setRoguelikeKind('raid')
|
||||
setCombatContentId(-2)
|
||||
setSelectedDifficultyId(baseRaid?.difficulties[0]?.id ?? 101)
|
||||
setSelectedPart(1)
|
||||
setScreen('combat')
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<span>R</span>
|
||||
<strong>Raid Roguelike</strong>
|
||||
<small>Ten-player party. Raid pools, lighter early scaling, and the same upgrade draft.</small>
|
||||
</button>
|
||||
</div>
|
||||
<div className="roguelike-option-panel">
|
||||
<div>
|
||||
<p className="eyebrow">Run Type</p>
|
||||
<h2>PvE Roguelike</h2>
|
||||
</div>
|
||||
<div className="roguelike-timing-row">
|
||||
<button
|
||||
className={`text-button ${roguelikeKind === 'dungeon' ? 'active' : ''}`}
|
||||
onClick={() => setRoguelikeKind('dungeon')}
|
||||
type="button"
|
||||
>
|
||||
Dungeon
|
||||
</button>
|
||||
<button
|
||||
className={`text-button ${roguelikeKind === 'raid' ? 'active' : ''}`}
|
||||
onClick={() => setRoguelikeKind('raid')}
|
||||
type="button"
|
||||
>
|
||||
Raid
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="roguelike-option-panel">
|
||||
<div>
|
||||
<p className="eyebrow">Upgrade Timing</p>
|
||||
<h2>Buff Drafts</h2>
|
||||
</div>
|
||||
<div className="roguelike-timing-row">
|
||||
<button
|
||||
className={`text-button ${roguelikeUpgradeTiming === 'encounter' ? 'active' : ''}`}
|
||||
onClick={() => setRoguelikeUpgradeTiming('encounter')}
|
||||
type="button"
|
||||
>
|
||||
Every Encounter
|
||||
</button>
|
||||
<button
|
||||
className={`text-button ${roguelikeUpgradeTiming === 'boss' ? 'active' : ''}`}
|
||||
onClick={() => setRoguelikeUpgradeTiming('boss')}
|
||||
type="button"
|
||||
>
|
||||
Boss Only
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="roguelike-option-panel">
|
||||
<div>
|
||||
<p className="eyebrow">Upgrade Labels</p>
|
||||
<h2>Display Mode</h2>
|
||||
</div>
|
||||
<div className="roguelike-timing-row">
|
||||
<button
|
||||
className={`text-button ${roguelikeAbilityLabelMode === 'ability' ? 'active' : ''}`}
|
||||
onClick={() => setRoguelikeAbilityLabelMode('ability')}
|
||||
type="button"
|
||||
>
|
||||
Ability Names
|
||||
</button>
|
||||
<button
|
||||
className={`text-button ${roguelikeAbilityLabelMode === 'slot' ? 'active' : ''}`}
|
||||
onClick={() => setRoguelikeAbilityLabelMode('slot')}
|
||||
type="button"
|
||||
>
|
||||
Slot Names
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="menu-card pvp-queue-panel">
|
||||
<span>{roguelikeKind === 'raid' ? 'R' : 'D'}</span>
|
||||
<div>
|
||||
<strong>{roguelikeKind === 'raid' ? 'Raid Roguelike' : 'Dungeon Roguelike'}</strong>
|
||||
<small>
|
||||
{roguelikeKind === 'raid'
|
||||
? 'Ten-player party. Raid pools, lighter early scaling, and the same upgrade draft.'
|
||||
: 'Five-player party. Two random trash enemies and a boss with a lighter early ramp.'}
|
||||
</small>
|
||||
</div>
|
||||
<button
|
||||
className="text-button"
|
||||
onClick={startPveRoguelike}
|
||||
type="button"
|
||||
>
|
||||
Start Run
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{roguelikeVariant === 'pvp' && (
|
||||
@@ -709,20 +731,36 @@ function App() {
|
||||
</div>
|
||||
<div className="part-picker">
|
||||
{parts.map((p) => (
|
||||
<button
|
||||
key={p.part}
|
||||
className={`primary-button ${selectedPart === p.part ? 'selected-part' : ''} ${!p.unlocked ? 'locked' : ''}`}
|
||||
disabled={difficultyLocked || !p.unlocked}
|
||||
onClick={() => {
|
||||
setSelectedPart(p.part)
|
||||
setCombatContentId(activity.id)
|
||||
setSelectedDifficultyId(selectedDifficulty.id)
|
||||
setScreen('combat')
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{p.name}
|
||||
</button>
|
||||
<div className="part-start-row" key={p.part}>
|
||||
<button
|
||||
className={`primary-button ${selectedPart === p.part && !selectedHardMode ? 'selected-part' : ''} ${!p.unlocked ? 'locked' : ''}`}
|
||||
disabled={difficultyLocked || !p.unlocked}
|
||||
onClick={() => {
|
||||
setSelectedPart(p.part)
|
||||
setSelectedHardMode(false)
|
||||
setCombatContentId(activity.id)
|
||||
setSelectedDifficultyId(selectedDifficulty.id)
|
||||
setScreen('combat')
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{p.name}
|
||||
</button>
|
||||
<button
|
||||
className={`primary-button hard-mode-button ${selectedPart === p.part && selectedHardMode ? 'selected-part' : ''} ${!p.hardUnlocked ? 'locked' : ''}`}
|
||||
disabled={difficultyLocked || !p.hardUnlocked}
|
||||
onClick={() => {
|
||||
setSelectedPart(p.part)
|
||||
setSelectedHardMode(true)
|
||||
setCombatContentId(activity.id)
|
||||
setSelectedDifficultyId(selectedDifficulty.id)
|
||||
setScreen('combat')
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Hard
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
+388
-101
@@ -10,6 +10,10 @@ import {
|
||||
import {
|
||||
INITIAL_PARTY,
|
||||
RAID_PARTY,
|
||||
DEFAULT_GROUP_HEAL_TARGETS,
|
||||
groupHealTargets,
|
||||
partyDamageOutput,
|
||||
tankPressureTargets,
|
||||
type CombatLogEntry,
|
||||
type PartyMember,
|
||||
type Spell,
|
||||
@@ -39,7 +43,7 @@ const TICK_MS = 700
|
||||
type RoguelikeMode = 'dungeon' | 'raid'
|
||||
type RoguelikeUpgradeTiming = 'boss' | 'encounter'
|
||||
type RoguelikeAbilityLabelMode = 'ability' | 'slot'
|
||||
type SlotKey = '1' | '2' | '3' | '4' | '5'
|
||||
type SlotKey = '1' | '2' | '3' | '4' | '5' | '6'
|
||||
type RoguelikeMechanic =
|
||||
| 'party-pulse'
|
||||
| 'searing-mark'
|
||||
@@ -101,12 +105,53 @@ function effectiveMaxHealth(member: PartyMember) {
|
||||
return Math.max(1, Math.round(member.maxHealth * (member.maxHealthPenaltyTicks && member.maxHealthPenaltyTicks > 0 ? 0.75 : 1)))
|
||||
}
|
||||
|
||||
function healAmount(member: PartyMember, amount: number) {
|
||||
return Math.round(amount * (member.healingReductionTicks && member.healingReductionTicks > 0 ? 0.75 : 1))
|
||||
function healAmount(member: PartyMember, amount: number, multiplier = 1) {
|
||||
return Math.round(amount * (member.healingReductionTicks && member.healingReductionTicks > 0 ? 0.75 : 1) * multiplier)
|
||||
}
|
||||
|
||||
function healMember(member: PartyMember, amount: number) {
|
||||
return clamp(member.health + healAmount(member, amount), 0, effectiveMaxHealth(member))
|
||||
function healMember(member: PartyMember, amount: number, multiplier = 1) {
|
||||
return clamp(member.health + healAmount(member, amount, multiplier), 0, effectiveMaxHealth(member))
|
||||
}
|
||||
|
||||
function memberHotEffects(member: PartyMember) {
|
||||
if (member.hotEffects?.length) return member.hotEffects
|
||||
return member.hotTicks > 0
|
||||
? [{ id: 'legacy-renew', spellId: 'legacy-renew', label: 'Renew', ticks: member.hotTicks, power: 6 }]
|
||||
: []
|
||||
}
|
||||
|
||||
function effectId(prefix: string) {
|
||||
return `${prefix}-${globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`}`
|
||||
}
|
||||
|
||||
function addHotEffect(member: PartyMember, spell: Spell, ticks = 5) {
|
||||
const nextEffect = {
|
||||
id: effectId(spell.id),
|
||||
spellId: spell.id,
|
||||
label: spell.name,
|
||||
ticks,
|
||||
power: Math.max(1, Math.round(spell.power / 2)),
|
||||
}
|
||||
const currentEffects = memberHotEffects(member).filter((effect) => effect.spellId !== spell.id)
|
||||
return [...currentEffects, nextEffect]
|
||||
}
|
||||
|
||||
function addBounceHeal(member: PartyMember, spell: Spell) {
|
||||
return [
|
||||
...(member.bounceHeals ?? []),
|
||||
{
|
||||
id: effectId(spell.id),
|
||||
label: spell.name,
|
||||
charges: 4,
|
||||
power: spell.power,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function tickHotEffects(effects: PartyMember['hotEffects']) {
|
||||
return (effects ?? [])
|
||||
.map((effect) => ({ ...effect, ticks: effect.ticks - 1 }))
|
||||
.filter((effect) => effect.ticks > 0)
|
||||
}
|
||||
|
||||
function upgradeStackCount(upgrades: RoguelikeUpgrade[], id: RoguelikeUpgradeId) {
|
||||
@@ -123,7 +168,7 @@ function buildRoguelikeUpgrades(
|
||||
spells: Spell[],
|
||||
labelMode: RoguelikeAbilityLabelMode,
|
||||
): RoguelikeUpgrade[] {
|
||||
const slotUpgrades = (['1', '2', '3', '4', '5'] as SlotKey[]).flatMap((slot) => {
|
||||
const slotUpgrades = (['1', '2', '3', '4', '5', '6'] as SlotKey[]).flatMap((slot) => {
|
||||
const label = slotLabel(slot, spells, labelMode)
|
||||
return [
|
||||
{
|
||||
@@ -191,9 +236,14 @@ function spellResourceCost(spell: Spell, upgrades: RoguelikeUpgrade[], freeCastR
|
||||
function toCombatSpell(ability: Ability, key: string, healingPower: number): Spell {
|
||||
const kinds: Record<string, Spell['kind']> = {
|
||||
direct_heal: 'direct',
|
||||
direct_hot: 'direct',
|
||||
heal_over_time: 'hot',
|
||||
party_heal: 'group',
|
||||
party_hot: 'group',
|
||||
party_absorb: 'group',
|
||||
absorb: 'shield',
|
||||
damage_reduction: 'damage_reduction',
|
||||
bounce_heal: 'bounce_heal',
|
||||
cleanse: 'cleanse',
|
||||
}
|
||||
return {
|
||||
@@ -206,6 +256,7 @@ function toCombatSpell(ability: Ability, key: string, healingPower: number): Spe
|
||||
power: ability.power + healingPower,
|
||||
glyph: ability.glyph,
|
||||
kind: kinds[ability.spellType] ?? 'direct',
|
||||
effectType: ability.spellType,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,6 +340,7 @@ function makeRoguelikeSegment(
|
||||
export function CombatScreen({
|
||||
difficulty,
|
||||
dungeon,
|
||||
hardMode = false,
|
||||
profile,
|
||||
startPart = 1,
|
||||
roguelikeMode,
|
||||
@@ -300,6 +352,7 @@ export function CombatScreen({
|
||||
}: {
|
||||
difficulty: Difficulty
|
||||
dungeon: Dungeon
|
||||
hardMode?: boolean
|
||||
profile: CharacterProfile
|
||||
startPart?: number
|
||||
roguelikeMode?: RoguelikeMode
|
||||
@@ -350,20 +403,22 @@ export function CombatScreen({
|
||||
const sectionName = isRoguelike ? 'Stage' : dungeon.contentType === 'raid' ? 'Phase' : 'Part'
|
||||
const contentName = isRoguelike ? 'Roguelike' : dungeon.contentType === 'raid' ? 'Raid' : 'Dungeon'
|
||||
const initialEncounterIndex = (startPart - 1) * 3
|
||||
const enemyCount = hardMode ? 2 : 1
|
||||
const initialCombatState = useMemo<SinglePlayerCombatState>(() => ({
|
||||
party: partyTemplate,
|
||||
resource: maxResource,
|
||||
enemyHealth: encounters[initialEncounterIndex].maxHealth,
|
||||
enemyHealth: encounters[initialEncounterIndex].maxHealth * enemyCount,
|
||||
cooldowns: {},
|
||||
elapsedTicks: 0,
|
||||
castsTowardFree: 0,
|
||||
freeCastReady: false,
|
||||
}), [encounters, initialEncounterIndex, maxResource, partyTemplate])
|
||||
}), [encounters, enemyCount, initialEncounterIndex, maxResource, partyTemplate])
|
||||
const [combatState, setCombatState] = useState<SinglePlayerCombatState>(() => initialCombatState)
|
||||
const [selectedId, setSelectedId] = useState(partyTemplate[0].id)
|
||||
const [encounterIndex, setEncounterIndex] = useState(initialEncounterIndex)
|
||||
const [status, setStatus] = useState<'playing' | 'won' | 'lost' | 'part-complete' | 'upgrade-choice'>('playing')
|
||||
const [paused, setPaused] = useState(false)
|
||||
const [speedMultiplier, setSpeedMultiplier] = useState<1 | 2>(1)
|
||||
const [targetGroup, setTargetGroup] = useState<0 | 1 | 2>(0)
|
||||
const [log, setLog] = useState<CombatLogEntry[]>([
|
||||
{ id: 1, text: `${dungeon.name} begins.`, tone: 'system' },
|
||||
@@ -377,7 +432,7 @@ export function CombatScreen({
|
||||
const [upgradeChoices, setUpgradeChoices] = useState<RoguelikeUpgrade[]>([])
|
||||
const rewardClaimedRef = useRef(false)
|
||||
const profileRefreshedRef = useRef(false)
|
||||
const rolledEncounterIdsRef = useRef(new Set<number>())
|
||||
const rolledEncounterIdsRef = useRef(new Set<string>())
|
||||
const runTokenRef = useRef(crypto.randomUUID())
|
||||
const resourceSpentRef = useRef(0)
|
||||
const runStartedAtRef = useRef(0)
|
||||
@@ -386,24 +441,45 @@ export function CombatScreen({
|
||||
const nextFloatingTextId = useRef(1)
|
||||
const combatRef = useRef(initialCombatState)
|
||||
const selectedIdRef = useRef(partyTemplate[0].id)
|
||||
const runCombatTickRef = useRef<() => void>(() => {})
|
||||
const combatClockActiveRef = useRef(false)
|
||||
const lastCombatTickAtRef = useRef(performance.now())
|
||||
const statusRef = useRef(status)
|
||||
const pausedRef = useRef(paused)
|
||||
const speedMultiplierRef = useRef<1 | 2>(speedMultiplier)
|
||||
const { party, resource, enemyHealth, cooldowns, freeCastReady } = combatState
|
||||
const encounter = encounters[encounterIndex]
|
||||
const encounterMaxHealth = encounter.maxHealth * enemyCount
|
||||
const currentPart = getCurrentPart(encounterIndex)
|
||||
const completedSections = dungeon.contentType === 'raid'
|
||||
? profile.completedRaidPhases
|
||||
: profile.completedDungeonParts
|
||||
const canContinueAfterPart = !hardMode || completedSections >= currentPart + 1
|
||||
const firstEncounterIndex = (startPart - 1) * 3
|
||||
const expectedLootRolls = encounters
|
||||
.slice(firstEncounterIndex, encounterIndex + 1)
|
||||
.filter((candidate) => candidate.lootTables.some((entry) => entry.difficultyId === difficulty.id))
|
||||
.length
|
||||
.length * enemyCount
|
||||
const isPartBoss = encounter.isBoss && encounterIndex % 3 === 2
|
||||
const isFinalBoss = isPartBoss && encounterIndex === encounters.length - 1
|
||||
const playerHealer = party.find((member) => member.id === 'mira')
|
||||
const playerIsAlive = Boolean(playerHealer && playerHealer.health > 0)
|
||||
const upgradesEveryEncounter = roguelikeUpgradeTiming === 'encounter'
|
||||
const activeSetEffects = useMemo(
|
||||
() => isRoguelike
|
||||
? new Set<string>()
|
||||
: new Set(profile.setBonuses.filter((bonus) => bonus.active).map((bonus) => bonus.effectType)),
|
||||
[isRoguelike, profile.setBonuses],
|
||||
const activeEffects = useMemo(
|
||||
() => {
|
||||
const effects = new Set<string>(
|
||||
gameClass.talents
|
||||
.filter((talent) => talent.rank > 0)
|
||||
.map((talent) => talent.effectType),
|
||||
)
|
||||
if (!isRoguelike) {
|
||||
profile.setBonuses
|
||||
.filter((bonus) => bonus.active)
|
||||
.forEach((bonus) => effects.add(bonus.effectType))
|
||||
}
|
||||
return effects
|
||||
},
|
||||
[gameClass.talents, isRoguelike, profile.setBonuses],
|
||||
)
|
||||
const {
|
||||
bindings,
|
||||
@@ -415,6 +491,10 @@ export function CombatScreen({
|
||||
enabled: dualScreenEnabled,
|
||||
} = useDualScreen()
|
||||
|
||||
statusRef.current = status
|
||||
pausedRef.current = paused
|
||||
speedMultiplierRef.current = speedMultiplier
|
||||
|
||||
useEffect(() => {
|
||||
const now = Date.now()
|
||||
runStartedAtRef.current = now
|
||||
@@ -472,10 +552,12 @@ export function CombatScreen({
|
||||
}, [])
|
||||
|
||||
const requestLootRoll = useCallback(
|
||||
(encounterId: number) => {
|
||||
if (rolledEncounterIdsRef.current.has(encounterId)) return
|
||||
rolledEncounterIdsRef.current.add(encounterId)
|
||||
rollEncounterLoot(encounterId, difficulty.id, runTokenRef.current)
|
||||
(encounterId: number, rollIndex = 0) => {
|
||||
const rollKey = `${encounterId}:${rollIndex}`
|
||||
if (rolledEncounterIdsRef.current.has(rollKey)) return
|
||||
rolledEncounterIdsRef.current.add(rollKey)
|
||||
const runToken = rollIndex === 0 ? runTokenRef.current : `${runTokenRef.current}-hard-${rollIndex}`
|
||||
rollEncounterLoot(encounterId, difficulty.id, runToken)
|
||||
.then((result) => {
|
||||
setLootRolls((current) => [...current, result])
|
||||
const awarded = result.items
|
||||
@@ -507,7 +589,7 @@ export function CombatScreen({
|
||||
setCombat({
|
||||
party: freshParty,
|
||||
resource: maxResource,
|
||||
enemyHealth: nextEncounters[initialEncounterIndex].maxHealth,
|
||||
enemyHealth: nextEncounters[initialEncounterIndex].maxHealth * enemyCount,
|
||||
cooldowns: {},
|
||||
elapsedTicks: 0,
|
||||
castsTowardFree: 0,
|
||||
@@ -535,7 +617,7 @@ export function CombatScreen({
|
||||
runStartedAtRef.current = Date.now()
|
||||
partStartTimesRef.current = { [startPart]: runStartedAtRef.current }
|
||||
setLog([{ id: nextLogId.current++, text: 'A new run begins.', tone: 'system' }])
|
||||
}, [difficulty, initialEncounterIndex, maxResource, partyTemplate, roguelikeMode, roguelikePool, setCombat, setSelectedTargetId, startPart, staticEncounters])
|
||||
}, [difficulty, enemyCount, initialEncounterIndex, maxResource, partyTemplate, roguelikeMode, roguelikePool, setCombat, setSelectedTargetId, startPart, staticEncounters])
|
||||
|
||||
const castSpell = useCallback(
|
||||
(spell: Spell) => {
|
||||
@@ -550,23 +632,36 @@ export function CombatScreen({
|
||||
const extraTarget = (blockedIds: string[]) => current.party
|
||||
.filter((member) => member.health > 0 && !blockedIds.includes(member.id))
|
||||
.sort((left, right) => (left.health / left.maxHealth) - (right.health / right.maxHealth))[0]
|
||||
const effectSpell = (name: string) => {
|
||||
const ability = gameClass.spells.find((candidate) => candidate.name === name)
|
||||
return ability ? toCombatSpell(ability, `effect-${ability.id}`, healingPower) : null
|
||||
}
|
||||
const renewEffect = effectSpell('Renew')
|
||||
const shieldEffect = effectSpell('Sun Ward')
|
||||
const healingMultiplier = (member: PartyMember) =>
|
||||
activeEffects.has('shielded_healing_bonus') && member.shield > 0 ? 1.2 : 1
|
||||
const directTargets = new Set([targetId])
|
||||
const hotTargets = new Set<string>()
|
||||
const shieldTargets = new Set<string>()
|
||||
if (spell.kind === 'hot') hotTargets.add(targetId)
|
||||
const extraTargets = upgradeStackCount(roguelikeUpgrades, `slot${spell.key as SlotKey}-extra-target` as RoguelikeUpgradeId)
|
||||
const groupTargets = new Set(
|
||||
spell.kind === 'group'
|
||||
? groupHealTargets(current.party, DEFAULT_GROUP_HEAL_TARGETS + extraTargets).map((member) => member.id)
|
||||
: [],
|
||||
)
|
||||
if (spell.kind === 'hot' || spell.effectType === 'direct_hot') hotTargets.add(targetId)
|
||||
if (spell.kind === 'shield') shieldTargets.add(targetId)
|
||||
if (spell.name === 'Mend' && activeSetEffects.has('mend_extra_target')) {
|
||||
if (spell.name === 'Mend' && activeEffects.has('mend_extra_target')) {
|
||||
const extra = extraTarget([targetId])
|
||||
if (extra) directTargets.add(extra.id)
|
||||
}
|
||||
if (spell.name === 'Renew' && activeSetEffects.has('renew_extra_target')) {
|
||||
if (spell.name === 'Renew' && activeEffects.has('renew_extra_target')) {
|
||||
const extra = extraTarget([targetId])
|
||||
if (extra) hotTargets.add(extra.id)
|
||||
}
|
||||
if (spell.name === 'Mend' && activeSetEffects.has('mend_applies_renew')) {
|
||||
if (spell.name === 'Mend' && activeEffects.has('mend_applies_renew')) {
|
||||
hotTargets.add(targetId)
|
||||
}
|
||||
const extraTargets = upgradeStackCount(roguelikeUpgrades, `slot${spell.key as SlotKey}-extra-target` as RoguelikeUpgradeId)
|
||||
for (let index = 0; index < extraTargets; index += 1) {
|
||||
if (spell.kind === 'group') break
|
||||
if (spell.kind === 'hot') {
|
||||
@@ -586,20 +681,61 @@ export function CombatScreen({
|
||||
const nextParty = current.party.map((member) => {
|
||||
if (member.health <= 0) return member
|
||||
if (spell.kind === 'group') {
|
||||
if (!groupTargets.has(member.id)) return member
|
||||
if (spell.effectType === 'party_absorb') {
|
||||
const power = Math.round(spell.power * (1.25 ** upgradeStackCount(roguelikeUpgrades, 'shield-boost')))
|
||||
return { ...member, shield: Math.max(member.shield, power) }
|
||||
}
|
||||
if (spell.effectType === 'party_hot') {
|
||||
return {
|
||||
...member,
|
||||
hotTicks: 0,
|
||||
hotEffects: addHotEffect(member, spell),
|
||||
}
|
||||
}
|
||||
const power = Math.round(spell.power * (1.25 ** upgradeStackCount(roguelikeUpgrades, 'group-heal-boost')))
|
||||
const nextHealth = healMember(member, power)
|
||||
const nextHealth = healMember(member, power, healingMultiplier(member))
|
||||
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') {
|
||||
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') {
|
||||
return {
|
||||
...member,
|
||||
health: healMember(member, spell.power),
|
||||
health: healMember(member, spell.power, healingMultiplier(member)),
|
||||
debuff: undefined,
|
||||
debuffTicks: undefined,
|
||||
poisonStacks: undefined,
|
||||
@@ -608,13 +744,23 @@ export function CombatScreen({
|
||||
}
|
||||
}
|
||||
const nextHealth = directTargets.has(member.id)
|
||||
? healMember(member, spell.power)
|
||||
? healMember(member, spell.power, healingMultiplier(member))
|
||||
: member.health
|
||||
if (nextHealth > member.health) addFloatingHeal(member.id, nextHealth - member.health)
|
||||
const nextShield = spell.name === 'Mend' && directTargets.has(member.id) && activeEffects.has('mend_applies_shield')
|
||||
? Math.max(member.shield, Math.round((shieldEffect?.power ?? spell.power) * 0.5))
|
||||
: member.shield
|
||||
const appliedHotSpell = spell.name === 'Mend' && activeEffects.has('mend_applies_renew') && renewEffect
|
||||
? renewEffect
|
||||
: spell
|
||||
return {
|
||||
...member,
|
||||
health: nextHealth,
|
||||
hotTicks: hotTargets.has(member.id) ? 5 : member.hotTicks,
|
||||
shield: nextShield,
|
||||
hotTicks: 0,
|
||||
hotEffects: hotTargets.has(member.id)
|
||||
? addHotEffect(member, appliedHotSpell)
|
||||
: member.hotEffects,
|
||||
}
|
||||
})
|
||||
const freeCastStacks = upgradeStackCount(roguelikeUpgrades, 'fifth-cast-free')
|
||||
@@ -632,20 +778,26 @@ export function CombatScreen({
|
||||
&& !current.freeCastReady
|
||||
&& current.castsTowardFree + 1 >= 5
|
||||
resourceSpentRef.current += effectiveCost
|
||||
const nextCooldowns = {
|
||||
...current.cooldowns,
|
||||
}
|
||||
if (spell.name === 'Mend' && activeEffects.has('mend_reduces_radiance_cooldown')) {
|
||||
const radiance = spells.find((candidate) => candidate.name === 'Radiance')
|
||||
if (radiance) nextCooldowns[radiance.id] = Math.max(0, (nextCooldowns[radiance.id] ?? 0) - 2)
|
||||
}
|
||||
nextCooldowns[spell.id] = spell.cooldown * cooldownMultiplier(spell, roguelikeUpgrades)
|
||||
|
||||
setCombat({
|
||||
...current,
|
||||
party: nextParty,
|
||||
resource: current.resource - effectiveCost,
|
||||
cooldowns: {
|
||||
...current.cooldowns,
|
||||
[spell.id]: spell.cooldown * cooldownMultiplier(spell, roguelikeUpgrades),
|
||||
},
|
||||
cooldowns: nextCooldowns,
|
||||
castsTowardFree: nextCastsTowardFree,
|
||||
freeCastReady: gainedFreeCast || nextFreeCastReady,
|
||||
})
|
||||
addLog(`${spell.name} cast on ${spell.kind === 'group' ? 'the party' : selected.name}${effectiveCost === 0 ? ' for free' : ''}.`, 'heal')
|
||||
},
|
||||
[activeSetEffects, addFloatingHeal, addLog, roguelikeUpgrades, setCombat, status],
|
||||
[activeEffects, addFloatingHeal, addLog, gameClass.spells, healingPower, roguelikeUpgrades, setCombat, spells, status],
|
||||
)
|
||||
|
||||
const finishRun = useCallback(
|
||||
@@ -668,6 +820,7 @@ export function CombatScreen({
|
||||
completedPart,
|
||||
runStartPart,
|
||||
[partDuration(1), partDuration(2), partDuration(3)],
|
||||
hardMode,
|
||||
)
|
||||
.then((result) => {
|
||||
setReward(result)
|
||||
@@ -680,7 +833,7 @@ export function CombatScreen({
|
||||
)
|
||||
})
|
||||
},
|
||||
[difficulty.id, dungeon.id, onProfileUpdated],
|
||||
[difficulty.id, dungeon.id, hardMode, onProfileUpdated],
|
||||
)
|
||||
|
||||
const finishRoguelikeRun = useCallback(
|
||||
@@ -778,6 +931,9 @@ export function CombatScreen({
|
||||
poisonStacks: undefined,
|
||||
maxHealthPenaltyTicks: undefined,
|
||||
healingReductionTicks: undefined,
|
||||
hotEffects: [],
|
||||
bounceHeals: [],
|
||||
damageReductionTicks: undefined,
|
||||
}))
|
||||
const nextStage = clearedBoss ? roguelikeStage + 1 : roguelikeStage
|
||||
const nextSegment = clearedBoss
|
||||
@@ -796,7 +952,7 @@ export function CombatScreen({
|
||||
setCombat({
|
||||
...current,
|
||||
party: recoveredParty,
|
||||
enemyHealth: nextEncounter.maxHealth,
|
||||
enemyHealth: nextEncounter.maxHealth * enemyCount,
|
||||
elapsedTicks: 0,
|
||||
cooldowns: {},
|
||||
resource: clamp(current.resource + Math.round(maxResource * 0.25), 0, maxResource),
|
||||
@@ -804,9 +960,13 @@ export function CombatScreen({
|
||||
setUpgradeChoices([])
|
||||
setStatus('playing')
|
||||
addLog(`${upgrade.name} gained. ${nextEncounter.enemyName} approaches.`, 'system')
|
||||
}, [addLog, difficulty, encounterIndex, encounters, maxResource, roguelikeMode, roguelikePool, roguelikeStage, setCombat])
|
||||
}, [addLog, difficulty, encounterIndex, encounters, enemyCount, maxResource, roguelikeMode, roguelikePool, roguelikeStage, setCombat])
|
||||
|
||||
useGameAction((action, device) => {
|
||||
if (action === 'toggleSpeed') {
|
||||
if (status === 'playing') setSpeedMultiplier((value) => (value === 1 ? 2 : 1))
|
||||
return
|
||||
}
|
||||
if (action === 'pause' || (action === 'back' && device === 'pc')) {
|
||||
if (status === 'playing') setPaused((value) => !value)
|
||||
return
|
||||
@@ -846,9 +1006,7 @@ export function CombatScreen({
|
||||
if (spell) castSpell(spell)
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== 'playing' || paused) return
|
||||
const timer = window.setInterval(() => {
|
||||
const runCombatTick = useCallback(() => {
|
||||
const current = combatRef.current
|
||||
const nextElapsedTicks = current.elapsedTicks + 1
|
||||
const nextCooldowns = Object.fromEntries(
|
||||
@@ -896,19 +1054,53 @@ export function CombatScreen({
|
||||
}
|
||||
|
||||
const healerBeforeDamage = current.party.find((member) => member.id === 'mira')
|
||||
const nextParty = current.party.map((member) => {
|
||||
const tankPressure = tankPressureTargets(current.party)
|
||||
const tankPressureIds = new Set(tankPressure.targets.map((member) => member.id))
|
||||
const pendingJumpHeals: Array<{
|
||||
targetId: string
|
||||
heal: NonNullable<PartyMember['bounceHeals']>[number]
|
||||
}> = []
|
||||
const damagedParty = current.party.map((member) => {
|
||||
if (member.health <= 0) return member
|
||||
let damage = member.id === primaryTarget.id ? encounter.damage : 0
|
||||
if (member.role === 'Tank') damage += encounter.tankDamage
|
||||
if (tankBuster && member.role === 'Tank') damage += Math.round(22 * difficulty.damageMultiplier)
|
||||
if (tankPressureIds.has(member.id)) {
|
||||
damage += Math.round(encounter.tankDamage * tankPressure.multiplier)
|
||||
}
|
||||
if (tankBuster && tankPressureIds.has(member.id)) {
|
||||
damage += Math.round(22 * difficulty.damageMultiplier * tankPressure.multiplier)
|
||||
}
|
||||
if (bossPulse) damage += Math.round(12 * difficulty.damageMultiplier)
|
||||
if (member.debuff) damage += Math.round(7 * difficulty.damageMultiplier)
|
||||
const nextPoisonStacks = appliesPoison && member.id === primaryTarget.id
|
||||
? Math.max(1, (member.poisonStacks ?? 0) + 1)
|
||||
: member.poisonStacks ?? 0
|
||||
if (nextPoisonStacks > 0) damage += Math.round((4 + nextPoisonStacks * 4) * difficulty.damageMultiplier)
|
||||
damage *= enemyCount
|
||||
if ((member.damageReductionTicks ?? 0) > 0) {
|
||||
damage = Math.round(damage * 0.5)
|
||||
}
|
||||
if (member.shield > 0 && activeEffects.has('shielded_damage_reduction')) {
|
||||
damage = Math.round(damage * 0.8)
|
||||
}
|
||||
const absorbed = Math.min(member.shield, damage)
|
||||
const healing = member.hotTicks > 0 ? healAmount(member, 6) : 0
|
||||
const hotEffects = memberHotEffects(member)
|
||||
const healingMultiplier = member.shield > 0 && activeEffects.has('shielded_healing_bonus') ? 1.2 : 1
|
||||
let healing = hotEffects.reduce((total, effect) => total + healAmount(member, effect.power, healingMultiplier), 0)
|
||||
let nextBounceHeals = [...(member.bounceHeals ?? [])]
|
||||
if (damage > 0 && nextBounceHeals.length > 0) {
|
||||
nextBounceHeals = nextBounceHeals.flatMap((effect) => {
|
||||
healing += healAmount(member, effect.power, healingMultiplier)
|
||||
const nextCharges = effect.charges - 1
|
||||
if (nextCharges <= 0) return []
|
||||
const jumpTargets = current.party.filter((candidate) => candidate.health > 0 && candidate.id !== member.id)
|
||||
const jumpTarget = jumpTargets[Math.floor(Math.random() * jumpTargets.length)] ?? member
|
||||
pendingJumpHeals.push({
|
||||
targetId: jumpTarget.id,
|
||||
heal: { ...effect, charges: nextCharges },
|
||||
})
|
||||
return []
|
||||
})
|
||||
}
|
||||
if (healing > 0) addFloatingHeal(member.id, healing)
|
||||
const nextMaxHealthPenaltyTicks = appliesMaxHealthCut && member.id === primaryTarget.id
|
||||
? 15
|
||||
@@ -922,9 +1114,12 @@ export function CombatScreen({
|
||||
: Math.max(0, (member.debuffTicks ?? 0) - 1)
|
||||
return {
|
||||
...member,
|
||||
health: clamp(member.health - damage + absorbed + healing, 0, nextEffectiveMaxHealth),
|
||||
health: clamp(clamp(member.health + healing, 0, nextEffectiveMaxHealth) - damage + absorbed, 0, nextEffectiveMaxHealth),
|
||||
shield: Math.max(0, member.shield - damage),
|
||||
hotTicks: Math.max(0, member.hotTicks - 1),
|
||||
hotTicks: 0,
|
||||
hotEffects: tickHotEffects(hotEffects),
|
||||
bounceHeals: nextBounceHeals,
|
||||
damageReductionTicks: Math.max(0, (member.damageReductionTicks ?? 0) - 1),
|
||||
debuff: nextDebuffTicks > 0
|
||||
? (appliesDebuff && member.id === primaryTarget.id ? 'Searing Mark' : member.debuff)
|
||||
: undefined,
|
||||
@@ -934,6 +1129,17 @@ export function CombatScreen({
|
||||
healingReductionTicks: nextHealingReductionTicks,
|
||||
}
|
||||
})
|
||||
const nextParty = damagedParty.map((member) => {
|
||||
const jumped = pendingJumpHeals.filter((jump) => jump.targetId === member.id)
|
||||
if (jumped.length === 0) return member
|
||||
return {
|
||||
...member,
|
||||
bounceHeals: [
|
||||
...(member.bounceHeals ?? []),
|
||||
...jumped.map((jump) => jump.heal),
|
||||
],
|
||||
}
|
||||
})
|
||||
const healerAfterDamage = nextParty.find((member) => member.id === 'mira')
|
||||
|
||||
if (
|
||||
@@ -960,7 +1166,7 @@ export function CombatScreen({
|
||||
return
|
||||
}
|
||||
|
||||
const nextEnemyHealth = current.enemyHealth - encounter.partyDamage
|
||||
const nextEnemyHealth = current.enemyHealth - partyDamageOutput(nextParty, encounter.partyDamage)
|
||||
if (nextEnemyHealth > 0) {
|
||||
setCombat({
|
||||
...current,
|
||||
@@ -974,7 +1180,9 @@ export function CombatScreen({
|
||||
}
|
||||
|
||||
if (!isRoguelike && encounter.lootTables.some((entry) => entry.difficultyId === difficulty.id)) {
|
||||
requestLootRoll(encounter.id)
|
||||
for (let rollIndex = 0; rollIndex < enemyCount; rollIndex += 1) {
|
||||
requestLootRoll(encounter.id, rollIndex)
|
||||
}
|
||||
}
|
||||
|
||||
if (isRoguelike && (upgradesEveryEncounter || encounter.isBoss)) {
|
||||
@@ -1031,6 +1239,9 @@ export function CombatScreen({
|
||||
poisonStacks: undefined,
|
||||
maxHealthPenaltyTicks: undefined,
|
||||
healingReductionTicks: undefined,
|
||||
hotEffects: [],
|
||||
bounceHeals: [],
|
||||
damageReductionTicks: undefined,
|
||||
}))
|
||||
setEncounterIndex((value) => value + 1)
|
||||
setCombat({
|
||||
@@ -1039,15 +1250,15 @@ export function CombatScreen({
|
||||
resource: nextResource,
|
||||
cooldowns: nextCooldowns,
|
||||
elapsedTicks: 0,
|
||||
enemyHealth: nextEncounter.maxHealth,
|
||||
enemyHealth: nextEncounter.maxHealth * enemyCount,
|
||||
})
|
||||
addLog(`${encounter.enemyName} defeated. ${nextEncounter.enemyName} approaches.`, 'system')
|
||||
}, TICK_MS)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [
|
||||
activeEffects,
|
||||
addLog,
|
||||
addFloatingHeal,
|
||||
difficulty.damageMultiplier,
|
||||
enemyCount,
|
||||
encounter,
|
||||
encounterIndex,
|
||||
encounters,
|
||||
@@ -1065,11 +1276,44 @@ export function CombatScreen({
|
||||
profile.character.name,
|
||||
setCombat,
|
||||
startPart,
|
||||
status,
|
||||
currentPart,
|
||||
paused,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
runCombatTickRef.current = runCombatTick
|
||||
}, [runCombatTick])
|
||||
|
||||
useEffect(() => {
|
||||
if (status === 'playing' && !paused) {
|
||||
if (!combatClockActiveRef.current) {
|
||||
lastCombatTickAtRef.current = performance.now()
|
||||
combatClockActiveRef.current = true
|
||||
}
|
||||
return
|
||||
}
|
||||
combatClockActiveRef.current = false
|
||||
}, [paused, status])
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => {
|
||||
if (
|
||||
!combatClockActiveRef.current
|
||||
|| statusRef.current !== 'playing'
|
||||
|| pausedRef.current
|
||||
) return
|
||||
const now = performance.now()
|
||||
const tickMs = TICK_MS / speedMultiplierRef.current
|
||||
const dueTicks = Math.min(8, Math.floor((now - lastCombatTickAtRef.current) / tickMs))
|
||||
if (dueTicks <= 0) return
|
||||
lastCombatTickAtRef.current += dueTicks * tickMs
|
||||
for (let index = 0; index < dueTicks; index += 1) {
|
||||
if (statusRef.current !== 'playing' || pausedRef.current) return
|
||||
runCombatTickRef.current()
|
||||
}
|
||||
}, 50)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!reward
|
||||
@@ -1084,15 +1328,23 @@ export function CombatScreen({
|
||||
})
|
||||
}, [expectedLootRolls, lootRolls.length, onProfileUpdated, reward])
|
||||
|
||||
const enemyPercent = (enemyHealth / encounter.maxHealth) * 100
|
||||
const enemyPercent = (enemyHealth / encounterMaxHealth) * 100
|
||||
const enemyHealthSegments = Array.from({ length: enemyCount }, (_, index) => {
|
||||
const remaining = clamp(enemyHealth - encounter.maxHealth * index, 0, encounter.maxHealth)
|
||||
return {
|
||||
index,
|
||||
health: remaining,
|
||||
percent: (remaining / encounter.maxHealth) * 100,
|
||||
}
|
||||
}).reverse()
|
||||
const dualScreenState = useMemo<DualScreenCombatState>(() => ({
|
||||
difficultyName: difficulty.name,
|
||||
dungeonName: dungeon.name,
|
||||
dungeonName: hardMode ? `${dungeon.name} Hard` : dungeon.name,
|
||||
contentName,
|
||||
encounterName: encounter.enemyName,
|
||||
encounterDescription: encounter.description,
|
||||
encounterHealth: enemyHealth,
|
||||
encounterMaxHealth: encounter.maxHealth,
|
||||
encounterMaxHealth,
|
||||
encounterIsBoss: encounter.isBoss,
|
||||
encounterIndex,
|
||||
encounterCount: encounters.length,
|
||||
@@ -1122,6 +1374,7 @@ export function CombatScreen({
|
||||
directPartyTargeting,
|
||||
paused,
|
||||
targetGroup,
|
||||
speedMultiplier,
|
||||
}), [
|
||||
bindings,
|
||||
controllerIconStyle,
|
||||
@@ -1134,7 +1387,8 @@ export function CombatScreen({
|
||||
encounter.description,
|
||||
encounter.enemyName,
|
||||
encounter.isBoss,
|
||||
encounter.maxHealth,
|
||||
encounterMaxHealth,
|
||||
hardMode,
|
||||
enemyHealth,
|
||||
encounterIndex,
|
||||
encounters.length,
|
||||
@@ -1151,6 +1405,7 @@ export function CombatScreen({
|
||||
spells,
|
||||
freeCastReady,
|
||||
roguelikeUpgrades,
|
||||
speedMultiplier,
|
||||
status,
|
||||
targetGroup,
|
||||
])
|
||||
@@ -1163,7 +1418,7 @@ export function CombatScreen({
|
||||
>
|
||||
{!dualScreenEnabled && <header className="topbar">
|
||||
<div>
|
||||
<p className="eyebrow">{difficulty.name} - Item Level {difficulty.droppedItemLevel}</p>
|
||||
<p className="eyebrow">{difficulty.name}{hardMode ? ' Hard' : ''} - Item Level {difficulty.droppedItemLevel}</p>
|
||||
<h1>{dungeon.name}</h1>
|
||||
</div>
|
||||
<div className="combat-header-actions">
|
||||
@@ -1186,10 +1441,21 @@ export function CombatScreen({
|
||||
</div>
|
||||
<div className="enemy-info">
|
||||
<div className="bar-label">
|
||||
<strong>{encounter.enemyName}</strong>
|
||||
<span>{Math.ceil(enemyHealth)} / {encounter.maxHealth}</span>
|
||||
<strong>{hardMode ? `${encounter.enemyName} x2` : encounter.enemyName}</strong>
|
||||
<span>{Math.ceil(enemyHealth)} / {encounterMaxHealth}</span>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1203,6 +1469,7 @@ export function CombatScreen({
|
||||
? `${gameClass.resourceName} ${Math.floor(resource)} / ${maxResource}`
|
||||
: `${profile.character.name} is defeated`}
|
||||
</span>
|
||||
{speedMultiplier === 2 && <strong className="speed-badge">2x speed</strong>}
|
||||
<div className="bar mana-bar"><span style={{ width: `${(resource / maxResource) * 100}%` }} /></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1236,7 +1503,14 @@ export function CombatScreen({
|
||||
.map((entry) => <span className="floating-heal" key={entry.id}>+{entry.value}</span>)}
|
||||
</div>
|
||||
<div className="member-effects">
|
||||
{member.hotTicks > 0 && <span className="buff">Renew {formatEffectTime(member.hotTicks)}</span>}
|
||||
{memberHotEffects(member).map((effect) => (
|
||||
<span className="buff" key={effect.id}>{effect.label} {formatEffectTime(effect.ticks)}</span>
|
||||
))}
|
||||
{member.shield > 0 && <span className="buff">Shield {Math.ceil(member.shield)}</span>}
|
||||
{(member.damageReductionTicks ?? 0) > 0 && <span className="buff">Barkskin {formatEffectTime(member.damageReductionTicks ?? 0)}</span>}
|
||||
{(member.bounceHeals ?? []).map((effect) => (
|
||||
<span className="buff" key={effect.id}>{effect.label} {effect.charges}</span>
|
||||
))}
|
||||
{member.debuff && member.debuffTicks && <span className="debuff">{member.debuff} {formatEffectTime(member.debuffTicks)}</span>}
|
||||
{member.poisonStacks && member.poisonStacks > 0 && <span className="debuff">Poison {member.poisonStacks}</span>}
|
||||
{member.maxHealthPenaltyTicks && member.maxHealthPenaltyTicks > 0 && <span className="debuff">Max HP -25% {formatEffectTime(member.maxHealthPenaltyTicks)}</span>}
|
||||
@@ -1315,7 +1589,7 @@ export function CombatScreen({
|
||||
|
||||
{status === 'upgrade-choice' && (
|
||||
<div className="result-screen">
|
||||
<div>
|
||||
<div className="pvp-upgrade-dialog pve-upgrade-dialog">
|
||||
<p className="eyebrow">
|
||||
{encounter.isBoss
|
||||
? `Roguelike Stage ${roguelikeStage} Complete`
|
||||
@@ -1323,13 +1597,18 @@ export function CombatScreen({
|
||||
</p>
|
||||
<h2>Choose Upgrade</h2>
|
||||
<p>Pick one upgrade before the next fight.</p>
|
||||
<div className="upgrade-choice-grid">
|
||||
{upgradeChoices.map((upgrade) => (
|
||||
<button key={upgrade.id} onClick={() => chooseRoguelikeUpgrade(upgrade)} type="button">
|
||||
<strong>{upgrade.name}</strong>
|
||||
<small>{upgrade.description}</small>
|
||||
</button>
|
||||
))}
|
||||
<div className="pvp-choice-columns">
|
||||
<div>
|
||||
<strong>Run Buff</strong>
|
||||
<div className="upgrade-choice-grid">
|
||||
{upgradeChoices.map((upgrade) => (
|
||||
<button key={upgrade.id} onClick={() => chooseRoguelikeUpgrade(upgrade)} type="button">
|
||||
<strong>{upgrade.name}</strong>
|
||||
<small>{upgrade.description}</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{roguelikeUpgrades.length > 0 && (
|
||||
<p className="roguelike-upgrade-list">
|
||||
@@ -1461,33 +1740,41 @@ export function CombatScreen({
|
||||
<div>
|
||||
<p className="eyebrow">{sectionName} Complete</p>
|
||||
<h2>{encounter.enemyName} Defeated</h2>
|
||||
<p>Proceed to {sectionName} {currentPart + 1} or end the run?</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
const nextIndex = encounterIndex + 1
|
||||
partStartTimesRef.current[currentPart + 1] = Date.now()
|
||||
const nextEncounter = encounters[nextIndex]
|
||||
const current = combatRef.current
|
||||
const recoveredParty = current.party.map((member) => ({
|
||||
...member,
|
||||
health: clamp(member.health + 35, 0, member.maxHealth),
|
||||
debuff: undefined,
|
||||
debuffTicks: undefined,
|
||||
}))
|
||||
setEncounterIndex(nextIndex)
|
||||
setCombat({
|
||||
...current,
|
||||
party: recoveredParty,
|
||||
enemyHealth: nextEncounter.maxHealth,
|
||||
elapsedTicks: 0,
|
||||
})
|
||||
setStatus('playing')
|
||||
addLog(`Proceeding to ${sectionName} ${currentPart + 1}. ${nextEncounter.enemyName} approaches.`, 'system')
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Continue to {sectionName} {currentPart + 1}
|
||||
</button>
|
||||
<p>{canContinueAfterPart ? `Proceed to ${sectionName} ${currentPart + 1} or end the run?` : 'Hard mode for this section is complete.'}</p>
|
||||
{canContinueAfterPart && (
|
||||
<button
|
||||
onClick={() => {
|
||||
const nextIndex = encounterIndex + 1
|
||||
partStartTimesRef.current[currentPart + 1] = Date.now()
|
||||
const nextEncounter = encounters[nextIndex]
|
||||
const current = combatRef.current
|
||||
const recoveredParty = current.party.map((member) => ({
|
||||
...member,
|
||||
health: clamp(member.health + 35, 0, member.maxHealth),
|
||||
debuff: undefined,
|
||||
debuffTicks: undefined,
|
||||
poisonStacks: undefined,
|
||||
maxHealthPenaltyTicks: undefined,
|
||||
healingReductionTicks: undefined,
|
||||
hotEffects: [],
|
||||
bounceHeals: [],
|
||||
damageReductionTicks: undefined,
|
||||
}))
|
||||
setEncounterIndex(nextIndex)
|
||||
setCombat({
|
||||
...current,
|
||||
party: recoveredParty,
|
||||
enemyHealth: nextEncounter.maxHealth * enemyCount,
|
||||
elapsedTicks: 0,
|
||||
})
|
||||
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">
|
||||
End Run
|
||||
</button>
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
type CharacterProfile,
|
||||
type GameClass,
|
||||
} from '../profile'
|
||||
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../dualScreen'
|
||||
import { EquipmentScreen } from './EquipmentScreen'
|
||||
import { TalentScreen } from './TalentScreen'
|
||||
|
||||
@@ -14,7 +15,8 @@ type Props = {
|
||||
}
|
||||
|
||||
export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
|
||||
const [activeTab, setActiveTab] = useState<'equipment' | 'talents' | 'class'>('class')
|
||||
const [activeTab, setActiveTab] = useState<'equipment' | 'crafting' | 'talents' | 'class'>('class')
|
||||
const { enabled: dualScreenEnabled } = useDualScreen()
|
||||
const [classId, setClassId] = useState(profile.character.classId)
|
||||
const [slots, setSlots] = useState<Array<number | null>>(profile.abilitySlots)
|
||||
const [selectedSlot, setSelectedSlot] = useState(0)
|
||||
@@ -38,7 +40,7 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
|
||||
function chooseClass(nextClass: GameClass) {
|
||||
const starterAbilities = nextClass.spells
|
||||
.filter((ability) => ability.unlockLevel <= profile.character.level)
|
||||
.slice(0, 5)
|
||||
.slice(0, 6)
|
||||
.map((ability) => ability.id)
|
||||
setClassId(nextClass.id)
|
||||
setSlots([...starterAbilities, ...Array(6 - starterAbilities.length).fill(null)])
|
||||
@@ -63,6 +65,29 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
|
||||
)
|
||||
}
|
||||
|
||||
const classWorkshopState = useMemo<DualScreenWorkshopState | null>(() => {
|
||||
if (activeTab !== 'class') return null
|
||||
return {
|
||||
mode: 'class',
|
||||
title: 'Ability Library',
|
||||
subtitle: gameClass.name,
|
||||
summary: `Selected slot ${selectedSlot + 1}. ${message || 'Choose an ability for the active loadout.'}`,
|
||||
items: gameClass.spells.map((ability) => {
|
||||
const locked = ability.unlockLevel > profile.character.level
|
||||
const equipped = slots.includes(ability.id)
|
||||
return {
|
||||
glyph: locked ? 'L' : ability.glyph,
|
||||
title: ability.name,
|
||||
meta: locked ? `Level ${ability.unlockLevel}` : `${ability.cost} ${gameClass.resourceName}`,
|
||||
detail: ability.description,
|
||||
status: equipped ? 'Equipped' : locked ? 'Locked' : '',
|
||||
}
|
||||
}),
|
||||
}
|
||||
}, [activeTab, gameClass, message, profile.character.level, selectedSlot, slots])
|
||||
|
||||
useDualScreenWorkshopPublisher(classWorkshopState, dualScreenEnabled)
|
||||
|
||||
async function persistChanges() {
|
||||
saveScroll()
|
||||
setSaving(true)
|
||||
@@ -80,7 +105,7 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
|
||||
|
||||
return (
|
||||
<section className="content-screen customize-screen">
|
||||
<div className="screen-heading">
|
||||
<div className="screen-heading customize-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Character Workshop</p>
|
||||
<h1>Customize Character</h1>
|
||||
@@ -89,8 +114,10 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
|
||||
</div>
|
||||
|
||||
<div className="customize-tabs" role="tablist" aria-label="Customize character sections">
|
||||
<button className="back-button customize-tab-back" onClick={onBack} type="button">Back</button>
|
||||
{([
|
||||
{ key: 'equipment', label: 'Equipment' },
|
||||
{ key: 'crafting', label: 'Crafting' },
|
||||
{ key: 'talents', label: 'Talents' },
|
||||
{ key: 'class', label: 'Class' },
|
||||
] as const).map((tab) => (
|
||||
@@ -110,6 +137,18 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
|
||||
{activeTab === 'equipment' && (
|
||||
<EquipmentScreen
|
||||
embedded
|
||||
mode="equipment"
|
||||
showModeTabs={false}
|
||||
profile={profile}
|
||||
onUpdated={onSaved}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'crafting' && (
|
||||
<EquipmentScreen
|
||||
embedded
|
||||
mode="crafting"
|
||||
showModeTabs={false}
|
||||
profile={profile}
|
||||
onUpdated={onSaved}
|
||||
/>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type EquipmentSlot,
|
||||
type Item,
|
||||
} from '../profile'
|
||||
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../dualScreen'
|
||||
|
||||
const SLOT_LABELS: Record<EquipmentSlot, string> = {
|
||||
weapon: 'Weapon',
|
||||
@@ -24,16 +25,29 @@ const SLOT_LABELS: Record<EquipmentSlot, string> = {
|
||||
}
|
||||
|
||||
const EQUIPMENT_LIST_PAGE_SIZE = 3
|
||||
const CRAFTING_LIST_PAGE_SIZE = 6
|
||||
const CRAFTING_LIST_PAGE_SIZE = 3
|
||||
const CRAFTING_FILTER_SLOTS = (Object.keys(SLOT_LABELS) as EquipmentSlot[])
|
||||
.filter((slot) => slot !== 'component')
|
||||
const DIRECT_CRAFT_ITEM_LEVELS = new Set([1, 10, 20, 25])
|
||||
|
||||
type Props = {
|
||||
profile: CharacterProfile
|
||||
onBack?: () => void
|
||||
onUpdated: (profile: CharacterProfile) => void
|
||||
embedded?: boolean
|
||||
mode?: 'equipment' | 'crafting'
|
||||
showModeTabs?: boolean
|
||||
}
|
||||
|
||||
export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }: Props) {
|
||||
export function EquipmentScreen({
|
||||
profile,
|
||||
onBack,
|
||||
onUpdated,
|
||||
embedded = false,
|
||||
mode,
|
||||
showModeTabs = true,
|
||||
}: Props) {
|
||||
const { enabled: dualScreenEnabled } = useDualScreen()
|
||||
const totalItemCount = profile.inventory.reduce(
|
||||
(total, item) => total + item.quantity,
|
||||
0,
|
||||
@@ -49,24 +63,23 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
||||
const [crafting, setCrafting] = useState(false)
|
||||
const [upgrading, setUpgrading] = useState(false)
|
||||
const [showSetBonuses, setShowSetBonuses] = useState(false)
|
||||
const [equipmentTab, setEquipmentTab] = useState<'equipment' | 'crafting'>('equipment')
|
||||
const [equipmentTab, setEquipmentTab] = useState<'equipment' | 'crafting'>(mode ?? 'equipment')
|
||||
const [inventoryPage, setInventoryPage] = useState(0)
|
||||
const [recipePage, setRecipePage] = useState(0)
|
||||
const [message, setMessage] = useState('')
|
||||
const scrollRef = useRef<number>(0)
|
||||
const selectedItem = profile.inventory.find((item) => item.id === selectedItemId)
|
||||
const firstRecipe = profile.craftingRecipes.find((recipe) => recipe.canCraft)
|
||||
?? profile.craftingRecipes[0]
|
||||
const craftableRecipes = profile.craftingRecipes.filter((recipe) =>
|
||||
DIRECT_CRAFT_ITEM_LEVELS.has(recipe.item.itemLevel),
|
||||
)
|
||||
const firstRecipe = craftableRecipes.find((recipe) => recipe.canCraft)
|
||||
?? craftableRecipes[0]
|
||||
const [selectedRecipeId, setSelectedRecipeId] = useState<number | null>(
|
||||
firstRecipe?.id ?? null,
|
||||
)
|
||||
const selectedRecipe = profile.craftingRecipes.find((recipe) => recipe.id === selectedRecipeId)
|
||||
const selectedRecipeRequiresUpgrade = selectedRecipe
|
||||
? profile.craftingRecipes.some((recipe) =>
|
||||
recipe.sourceEncounterId === selectedRecipe.sourceEncounterId
|
||||
&& recipe.item.slot === selectedRecipe.item.slot
|
||||
&& recipe.item.itemLevel < selectedRecipe.item.itemLevel,
|
||||
)
|
||||
? !DIRECT_CRAFT_ITEM_LEVELS.has(selectedRecipe.item.itemLevel)
|
||||
: false
|
||||
const selectedItemRecipe = selectedItem
|
||||
? profile.craftingRecipes.find((recipe) => recipe.item.id === selectedItem.id)
|
||||
@@ -113,12 +126,14 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
||||
const [slotFilter, setSlotFilter] = useState<EquipmentSlot | 'all'>('all')
|
||||
const [levelFilter, setLevelFilter] = useState<number | null>(null)
|
||||
const availableLevels = useMemo(
|
||||
() => [...new Set(profile.craftingRecipes.map((r) => r.item.itemLevel))].sort((a, b) => b - a),
|
||||
() => [...new Set(profile.craftingRecipes
|
||||
.filter((r) => DIRECT_CRAFT_ITEM_LEVELS.has(r.item.itemLevel))
|
||||
.map((r) => r.item.itemLevel))].sort((a, b) => b - a),
|
||||
[profile.craftingRecipes],
|
||||
)
|
||||
const filteredRecipes = useMemo(
|
||||
() => {
|
||||
let result = [...profile.craftingRecipes]
|
||||
let result = profile.craftingRecipes.filter((r) => DIRECT_CRAFT_ITEM_LEVELS.has(r.item.itemLevel))
|
||||
if (slotFilter !== 'all') result = result.filter((r) => r.item.slot === slotFilter)
|
||||
if (levelFilter !== null) result = result.filter((r) => r.item.itemLevel === levelFilter)
|
||||
result.sort((a, b) => b.item.itemLevel - a.item.itemLevel)
|
||||
@@ -131,7 +146,10 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
||||
() => new Map(
|
||||
(Object.keys(SLOT_LABELS) as EquipmentSlot[]).map((slot) => [
|
||||
slot,
|
||||
profile.craftingRecipes.filter((recipe) => recipe.item.slot === slot).length,
|
||||
profile.craftingRecipes.filter((recipe) =>
|
||||
recipe.item.slot === slot
|
||||
&& DIRECT_CRAFT_ITEM_LEVELS.has(recipe.item.itemLevel),
|
||||
).length,
|
||||
]),
|
||||
),
|
||||
[profile.craftingRecipes],
|
||||
@@ -173,6 +191,10 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
||||
}
|
||||
}, [equipmentTab])
|
||||
|
||||
useEffect(() => {
|
||||
if (mode) setEquipmentTab(mode)
|
||||
}, [mode])
|
||||
|
||||
function saveScroll() {
|
||||
scrollRef.current = window.scrollY
|
||||
}
|
||||
@@ -247,6 +269,143 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
||||
}
|
||||
}
|
||||
|
||||
function renderEquipmentActions() {
|
||||
if (!selectedItem) {
|
||||
return <p>Select an item to inspect it.</p>
|
||||
}
|
||||
if (selectedItem.slot === 'component') {
|
||||
return <p className="component-note">Used in crafting.</p>
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<ComparisonDelta selected={selectedItem} equipped={comparisonItem} />
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={selectedItem.equipped || equipping || breakingDown || upgrading}
|
||||
onClick={equipSelected}
|
||||
type="button"
|
||||
>
|
||||
{selectedItem.equipped ? 'Equipped' : equipping ? 'Equipping...' : 'Equip Item'}
|
||||
</button>
|
||||
{upgradeRecipe && (
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={!upgradeRecipe.canCraft || equipping || breakingDown || upgrading}
|
||||
onClick={upgradeSelected}
|
||||
type="button"
|
||||
>
|
||||
{upgrading ? 'Upgrading...' : `Upgrade to iLvl ${upgradeRecipe.item.itemLevel}`}
|
||||
</button>
|
||||
)}
|
||||
{(!selectedItem.equipped || selectedItem.quantity > 1) && (
|
||||
<button
|
||||
className="breakdown-button"
|
||||
disabled={equipping || breakingDown || upgrading}
|
||||
onClick={breakdownSelected}
|
||||
type="button"
|
||||
>
|
||||
{breakingDown
|
||||
? 'Breaking Down...'
|
||||
: selectedItem.quantity > 1
|
||||
? 'Break Down Duplicate'
|
||||
: 'Break Down'}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const workshopState = useMemo<DualScreenWorkshopState>(() => {
|
||||
if (equipmentTab === 'crafting') {
|
||||
if (!selectedRecipe) {
|
||||
return {
|
||||
mode: 'crafting',
|
||||
title: 'Craft Output',
|
||||
subtitle: 'No recipe selected',
|
||||
items: [],
|
||||
}
|
||||
}
|
||||
return {
|
||||
mode: 'crafting',
|
||||
title: selectedRecipe.item.name,
|
||||
subtitle: `${SLOT_LABELS[selectedRecipe.item.slot]} - Item Level ${selectedRecipe.item.itemLevel}`,
|
||||
summary: selectedRecipe.item.description,
|
||||
items: [
|
||||
{
|
||||
glyph: selectedRecipe.item.glyph,
|
||||
title: 'Craft Output',
|
||||
meta: `+${selectedRecipe.item.healingPower} Healing Power / +${selectedRecipe.item.maxResourceBonus} Max Resource`,
|
||||
status: selectedRecipe.canCraft ? 'Ready' : 'Missing components',
|
||||
},
|
||||
...selectedRecipe.components.map((component) => ({
|
||||
glyph: component.item.glyph,
|
||||
title: component.item.name,
|
||||
meta: `Item Level ${component.item.itemLevel}`,
|
||||
status: `${component.owned}/${component.quantity}`,
|
||||
})),
|
||||
],
|
||||
}
|
||||
}
|
||||
if (!selectedItem) {
|
||||
return {
|
||||
mode: 'equipment',
|
||||
title: 'Equipment Detail',
|
||||
subtitle: 'No item selected',
|
||||
items: [],
|
||||
}
|
||||
}
|
||||
return {
|
||||
mode: 'equipment',
|
||||
title: selectedItem.slot === 'component' ? 'Crafting Component' : selectedItem.name,
|
||||
subtitle: `${SLOT_LABELS[selectedItem.slot]} - Item Level ${selectedItem.itemLevel}`,
|
||||
summary: selectedItem.description,
|
||||
items: selectedItem.slot === 'component'
|
||||
? [{
|
||||
glyph: selectedItem.glyph,
|
||||
title: selectedItem.name,
|
||||
meta: `Owned: ${selectedItem.quantity}`,
|
||||
status: 'Component',
|
||||
}]
|
||||
: [
|
||||
{
|
||||
glyph: selectedItem.glyph,
|
||||
title: selectedItem.name,
|
||||
meta: `+${selectedItem.healingPower} Healing Power / +${selectedItem.maxResourceBonus} Max Resource`,
|
||||
status: selectedItem.equipped ? 'Equipped' : 'Inventory',
|
||||
},
|
||||
...(comparisonItem && comparisonItem.id !== selectedItem.id
|
||||
? [{
|
||||
glyph: comparisonItem.glyph,
|
||||
title: comparisonItem.name,
|
||||
meta: `+${comparisonItem.healingPower} Healing Power / +${comparisonItem.maxResourceBonus} Max Resource`,
|
||||
status: 'Currently Equipped',
|
||||
}]
|
||||
: [{
|
||||
title: selectedItem.equipped ? 'Already Equipped' : 'Empty Slot',
|
||||
status: 'Comparison',
|
||||
}]),
|
||||
...(upgradeRecipe
|
||||
? [
|
||||
{
|
||||
glyph: upgradeRecipe.item.glyph,
|
||||
title: `Upgrade to ${upgradeRecipe.item.name}`,
|
||||
meta: `Item Level ${upgradeRecipe.item.itemLevel}`,
|
||||
status: upgradeRecipe.canCraft ? 'Ready' : 'Missing materials',
|
||||
},
|
||||
...upgradeRecipe.components.map((component) => ({
|
||||
glyph: component.item.glyph,
|
||||
title: component.item.name,
|
||||
meta: `Required for upgrade`,
|
||||
status: `${component.owned}/${component.quantity}`,
|
||||
})),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
}
|
||||
}, [comparisonItem, equipmentTab, selectedItem, selectedRecipe, upgradeRecipe])
|
||||
|
||||
useDualScreenWorkshopPublisher(workshopState, dualScreenEnabled)
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{!embedded && (
|
||||
@@ -273,22 +432,24 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
||||
<GearStat value={`+${profile.gearStats.maxResourceBonus}`} label={`Max ${profile.character.resourceName}`} />
|
||||
</div>
|
||||
|
||||
<nav className="equipment-tabs">
|
||||
<button
|
||||
className={`equipment-tab ${equipmentTab === 'equipment' ? 'active' : ''}`}
|
||||
onClick={() => setEquipmentTab('equipment')}
|
||||
type="button"
|
||||
>
|
||||
Equipment
|
||||
</button>
|
||||
<button
|
||||
className={`equipment-tab ${equipmentTab === 'crafting' ? 'active' : ''}`}
|
||||
onClick={() => setEquipmentTab('crafting')}
|
||||
type="button"
|
||||
>
|
||||
Crafting
|
||||
</button>
|
||||
</nav>
|
||||
{showModeTabs && (
|
||||
<nav className="equipment-tabs">
|
||||
<button
|
||||
className={`equipment-tab ${equipmentTab === 'equipment' ? 'active' : ''}`}
|
||||
onClick={() => setEquipmentTab('equipment')}
|
||||
type="button"
|
||||
>
|
||||
Equipment
|
||||
</button>
|
||||
<button
|
||||
className={`equipment-tab ${equipmentTab === 'crafting' ? 'active' : ''}`}
|
||||
onClick={() => setEquipmentTab('crafting')}
|
||||
type="button"
|
||||
>
|
||||
Crafting
|
||||
</button>
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{equipmentTab === 'equipment' ? (
|
||||
<>
|
||||
@@ -297,9 +458,6 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
||||
selectedItem.slot === 'component' ? (
|
||||
<>
|
||||
<ItemDetail title="Crafting Component" item={selectedItem} />
|
||||
<div className="equip-action">
|
||||
<p className="component-note">Used in crafting.</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -313,41 +471,6 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
||||
<h2>{selectedItem.equipped ? 'Already Equipped' : 'Empty Slot'}</h2>
|
||||
</div>
|
||||
)}
|
||||
<div className="equip-action">
|
||||
<ComparisonDelta selected={selectedItem} equipped={comparisonItem} />
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={selectedItem.equipped || equipping || breakingDown || upgrading}
|
||||
onClick={equipSelected}
|
||||
type="button"
|
||||
>
|
||||
{selectedItem.equipped ? 'Equipped' : equipping ? 'Equipping...' : 'Equip Item'}
|
||||
</button>
|
||||
{upgradeRecipe && (
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={!upgradeRecipe.canCraft || equipping || breakingDown || upgrading}
|
||||
onClick={upgradeSelected}
|
||||
type="button"
|
||||
>
|
||||
{upgrading ? 'Upgrading...' : `Upgrade to iLvl ${upgradeRecipe.item.itemLevel}`}
|
||||
</button>
|
||||
)}
|
||||
{(!selectedItem.equipped || selectedItem.quantity > 1) && (
|
||||
<button
|
||||
className="breakdown-button"
|
||||
disabled={equipping || breakingDown || upgrading}
|
||||
onClick={breakdownSelected}
|
||||
type="button"
|
||||
>
|
||||
{breakingDown
|
||||
? 'Breaking Down...'
|
||||
: selectedItem.quantity > 1
|
||||
? 'Break Down Duplicate'
|
||||
: 'Break Down'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
@@ -355,6 +478,10 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="equipment-action-strip">
|
||||
{renderEquipmentActions()}
|
||||
</section>
|
||||
|
||||
<div className="equipment-layout">
|
||||
<section className="equipped-panel">
|
||||
<EquipmentHeading eyebrow="Currently Worn" title="Equipment Slots" />
|
||||
@@ -467,9 +594,9 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
||||
type="button"
|
||||
>
|
||||
<strong>All</strong>
|
||||
<span>{profile.craftingRecipes.length}</span>
|
||||
<span>{profile.craftingRecipes.filter((recipe) => DIRECT_CRAFT_ITEM_LEVELS.has(recipe.item.itemLevel)).length}</span>
|
||||
</button>
|
||||
{(Object.entries(SLOT_LABELS) as [EquipmentSlot, string][]).map(([slot, label]) => (
|
||||
{CRAFTING_FILTER_SLOTS.map((slot) => (
|
||||
<button
|
||||
className={slotFilter === slot ? 'active' : ''}
|
||||
disabled={(slotRecipeCounts.get(slot) ?? 0) === 0}
|
||||
@@ -480,7 +607,7 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<strong>{label}</strong>
|
||||
<strong>{SLOT_LABELS[slot]}</strong>
|
||||
<span>{slotRecipeCounts.get(slot) ?? 0}</span>
|
||||
</button>
|
||||
))}
|
||||
@@ -557,6 +684,16 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
||||
previousDisabled={recipePage <= 0}
|
||||
/>
|
||||
)}
|
||||
<div className="crafting-action-row">
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={selectedRecipeRequiresUpgrade || !selectedRecipe?.canCraft || crafting}
|
||||
onClick={craftSelected}
|
||||
type="button"
|
||||
>
|
||||
{crafting ? 'Crafting...' : selectedRecipeRequiresUpgrade ? 'Upgrade Existing Item' : 'Craft Item'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="crafting-detail-panel">
|
||||
@@ -579,14 +716,6 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={selectedRecipeRequiresUpgrade || !selectedRecipe.canCraft || crafting}
|
||||
onClick={craftSelected}
|
||||
type="button"
|
||||
>
|
||||
{crafting ? 'Crafting...' : selectedRecipeRequiresUpgrade ? 'Upgrade Existing Item' : 'Craft Item'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="inventory-empty">Select a recipe.</p>
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { INITIAL_PARTY, RAID_PARTY, type CombatLogEntry, type PartyMember, type Spell } from '../game'
|
||||
import {
|
||||
INITIAL_PARTY,
|
||||
RAID_PARTY,
|
||||
DEFAULT_GROUP_HEAL_TARGETS,
|
||||
groupHealTargets,
|
||||
partyDamageOutput,
|
||||
tankPressureTargets,
|
||||
type CombatLogEntry,
|
||||
type PartyMember,
|
||||
type Spell,
|
||||
} from '../game'
|
||||
import { completeRoguelike, type DungeonReward } from '../profile'
|
||||
import type { Ability, CharacterProfile, DungeonEncounter } from '../profile'
|
||||
import type { GameMode } from '../gameRepository'
|
||||
@@ -34,7 +44,7 @@ type PvpEncounter = DungeonEncounter & {
|
||||
sourceEncounterId?: number
|
||||
}
|
||||
|
||||
type SlotKey = '1' | '2' | '3' | '4' | '5'
|
||||
type SlotKey = '1' | '2' | '3' | '4' | '5' | '6'
|
||||
type AbilityLabelMode = 'ability' | 'slot'
|
||||
|
||||
type SelfBuffId =
|
||||
@@ -78,6 +88,17 @@ type FloatingCombatText = {
|
||||
value: number
|
||||
}
|
||||
|
||||
type PvpRunSummary = {
|
||||
bossesKilled: number
|
||||
experienceGained: number
|
||||
previousLevel: number | null
|
||||
newLevel: number | null
|
||||
levelsGained: number
|
||||
talentPointsGained: number
|
||||
unlockedAbilities: DungeonReward['unlockedAbilities']
|
||||
loot: Array<NonNullable<DungeonReward['bonusItem']>>
|
||||
}
|
||||
|
||||
const BOSS_MECHANICS: BossMechanic[] = [
|
||||
'party-pulse',
|
||||
'searing-mark',
|
||||
@@ -119,6 +140,19 @@ function formatEffectTime(ticks: number) {
|
||||
return Number.isInteger(seconds) ? `${seconds}s` : `${seconds.toFixed(1)}s`
|
||||
}
|
||||
|
||||
function createEmptyPvpRunSummary(): PvpRunSummary {
|
||||
return {
|
||||
bossesKilled: 0,
|
||||
experienceGained: 0,
|
||||
previousLevel: null,
|
||||
newLevel: null,
|
||||
levelsGained: 0,
|
||||
talentPointsGained: 0,
|
||||
unlockedAbilities: [],
|
||||
loot: [],
|
||||
}
|
||||
}
|
||||
|
||||
function buffStacks<T extends string>(items: T[], id: T) {
|
||||
return items.filter((item) => item === id).length
|
||||
}
|
||||
@@ -130,7 +164,7 @@ function slotLabel(slot: SlotKey, spells: Spell[], labelMode: AbilityLabelMode)
|
||||
}
|
||||
|
||||
function buildSelfBuffChoices(spells: Spell[], labelMode: AbilityLabelMode): Array<Choice<SelfBuffId>> {
|
||||
const slotChoices = (['1', '2', '3', '4', '5'] as SlotKey[]).flatMap((slot) => {
|
||||
const slotChoices = (['1', '2', '3', '4', '5', '6'] as SlotKey[]).flatMap((slot) => {
|
||||
const label = slotLabel(slot, spells, labelMode)
|
||||
return [
|
||||
{
|
||||
@@ -159,7 +193,7 @@ function buildSelfBuffChoices(spells: Spell[], labelMode: AbilityLabelMode): Arr
|
||||
}
|
||||
|
||||
function buildOpponentDebuffChoices(spells: Spell[], labelMode: AbilityLabelMode): Array<Choice<OpponentDebuffId>> {
|
||||
const slotChoices = (['1', '2', '3', '4', '5'] as SlotKey[]).flatMap((slot) => {
|
||||
const slotChoices = (['1', '2', '3', '4', '5', '6'] as SlotKey[]).flatMap((slot) => {
|
||||
const label = slotLabel(slot, spells, labelMode)
|
||||
return [
|
||||
{
|
||||
@@ -217,13 +251,13 @@ function outgoingHealMultiplier(debuffs: OpponentDebuffId[]) {
|
||||
return 0.85 ** buffStacks(debuffs, 'opp-healing-reduced')
|
||||
}
|
||||
|
||||
function healAmount(member: PartyMember, amount: number, debuffs: OpponentDebuffId[]) {
|
||||
function healAmount(member: PartyMember, amount: number, debuffs: OpponentDebuffId[], multiplier = 1) {
|
||||
const healingReduction = member.healingReductionTicks && member.healingReductionTicks > 0 ? 0.75 : 1
|
||||
return Math.round(amount * healingReduction * outgoingHealMultiplier(debuffs))
|
||||
return Math.round(amount * healingReduction * outgoingHealMultiplier(debuffs) * multiplier)
|
||||
}
|
||||
|
||||
function healMember(member: PartyMember, amount: number, debuffs: OpponentDebuffId[]) {
|
||||
return clamp(member.health + healAmount(member, amount, debuffs), 0, effectiveMaxHealth(member))
|
||||
function healMember(member: PartyMember, amount: number, debuffs: OpponentDebuffId[], multiplier = 1) {
|
||||
return clamp(member.health + healAmount(member, amount, debuffs, multiplier), 0, effectiveMaxHealth(member))
|
||||
}
|
||||
|
||||
function cooldownMultiplier(spell: Spell, buffs: SelfBuffId[], debuffs: OpponentDebuffId[]) {
|
||||
@@ -300,7 +334,7 @@ function scoreSelfBuff(buff: Choice<SelfBuffId>, spells: Spell[]) {
|
||||
if (buff.id === 'fifth-cast-free') return 8
|
||||
if (buff.id === 'group-heal-boost') return 8
|
||||
if (buff.id === 'shield-boost') return 6
|
||||
const slot = buff.id.match(/slot([1-5])/i)?.[1] as SlotKey | undefined
|
||||
const slot = buff.id.match(/slot([1-6])/i)?.[1] as SlotKey | undefined
|
||||
const spell = spells.find((candidate) => candidate.key === slot)
|
||||
if (!spell) return 5
|
||||
if (buff.id.endsWith('extra-target')) {
|
||||
@@ -374,7 +408,7 @@ export function PvPRoguelikeScreen({
|
||||
const gameClass = profile.classes.find((candidate) => candidate.id === profile.character.classId)!
|
||||
const starterSpells = useMemo(() => gameClass.spells
|
||||
.filter((spell) => spell.unlockLevel === 1)
|
||||
.slice(0, 5)
|
||||
.slice(0, 6)
|
||||
.map((spell, index) => toCombatSpell(spell, String(index + 1))), [gameClass.spells])
|
||||
const [abilityLabelMode] = useState<AbilityLabelMode>('ability')
|
||||
const selfBuffChoicesCatalog = useMemo(
|
||||
@@ -411,11 +445,14 @@ export function PvPRoguelikeScreen({
|
||||
const [playerSide, setPlayerSide] = useState<SideState>(() => starterSide(partyTemplate, maxResource))
|
||||
const [cpuSide, setCpuSide] = useState<SideState>(() => starterSide(cpuPartyTemplate, maxResource))
|
||||
const [selectedId, setSelectedId] = useState(partyTemplate[0].id)
|
||||
const selectedIdRef = useRef(partyTemplate[0].id)
|
||||
const [speedMultiplier, setSpeedMultiplier] = useState<1 | 2>(1)
|
||||
const [elapsedTicks, setElapsedTicks] = useState(0)
|
||||
const [cpuDifficulty, setCpuDifficulty] = useState<CpuDifficulty | null>(null)
|
||||
const [queueMessage, setQueueMessage] = useState('')
|
||||
const [log, setLog] = useState<CombatLogEntry[]>([{ id: 1, text: 'Queueing opponent...', tone: 'system' }])
|
||||
const [reward, setReward] = useState<DungeonReward | null>(null)
|
||||
const [runSummary, setRunSummary] = useState<PvpRunSummary>(() => createEmptyPvpRunSummary())
|
||||
const [rewardError, setRewardError] = useState('')
|
||||
const [showEndLog, setShowEndLog] = useState(false)
|
||||
const [floatingTexts, setFloatingTexts] = useState<FloatingCombatText[]>([])
|
||||
@@ -433,6 +470,8 @@ export function PvPRoguelikeScreen({
|
||||
const bossRewardClaimedRef = useRef(new Set<number>())
|
||||
const cpuDefeatedRef = useRef(false)
|
||||
const playerClearedEncounterRef = useRef(-1)
|
||||
const queuedMatchRef = useRef(false)
|
||||
const encounterPoolRef = useRef(encounterPool)
|
||||
const playerRef = useRef(playerSide)
|
||||
const cpuRef = useRef(cpuSide)
|
||||
const encounter = encounters[encounterIndex]
|
||||
@@ -445,6 +484,14 @@ export function PvPRoguelikeScreen({
|
||||
? Math.max(encountersCleared, encounterIndex + 1)
|
||||
: encountersCleared
|
||||
const cpuBehavior = cpuDifficulty ? CPU_BEHAVIOR[cpuDifficulty] : CPU_BEHAVIOR[1]
|
||||
const activeSpellEffects = useMemo(
|
||||
() => new Set(
|
||||
gameClass.talents
|
||||
.filter((talent) => talent.rank > 0)
|
||||
.map((talent) => talent.effectType),
|
||||
),
|
||||
[gameClass.talents],
|
||||
)
|
||||
const playerDone = playerSide.enemyHealth <= 0
|
||||
const cpuDone = cpuSide.enemyHealth <= 0
|
||||
const playerAlive = playerSide.party.some((member) => member.health > 0)
|
||||
@@ -459,6 +506,12 @@ export function PvPRoguelikeScreen({
|
||||
const {
|
||||
enabled: dualScreenEnabled,
|
||||
} = useDualScreen()
|
||||
|
||||
const setSelectedTargetId = useCallback((id: string) => {
|
||||
selectedIdRef.current = id
|
||||
setSelectedId(id)
|
||||
}, [])
|
||||
|
||||
const addLog = useCallback((text: string, tone: CombatLogEntry['tone']) => {
|
||||
setLog((current) => [createLogEntry(nextLogId, text, tone), ...current].slice(0, 60))
|
||||
}, [])
|
||||
@@ -473,11 +526,16 @@ export function PvPRoguelikeScreen({
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (queuedMatchRef.current) return
|
||||
const loadedCheckpoint = loadPvpRoguelikeCheckpoint(profile.character.id, contentType)
|
||||
setCheckpointStage(loadedCheckpoint)
|
||||
setStartStage(loadedCheckpoint)
|
||||
}, [contentType, profile.character.id])
|
||||
|
||||
useEffect(() => {
|
||||
encounterPoolRef.current = encounterPool
|
||||
}, [encounterPool])
|
||||
|
||||
const awardBossReward = useCallback((encounterIndexValue: number) => {
|
||||
if (bossRewardClaimedRef.current.has(encounterIndexValue)) return
|
||||
bossRewardClaimedRef.current.add(encounterIndexValue)
|
||||
@@ -497,6 +555,20 @@ export function PvPRoguelikeScreen({
|
||||
)
|
||||
.then((result) => {
|
||||
setReward(result)
|
||||
setRunSummary((current) => {
|
||||
const unlockedById = new Map(current.unlockedAbilities.map((ability) => [ability.id, ability]))
|
||||
result.unlockedAbilities.forEach((ability) => unlockedById.set(ability.id, ability))
|
||||
return {
|
||||
bossesKilled: current.bossesKilled + 1,
|
||||
experienceGained: current.experienceGained + result.experienceGained,
|
||||
previousLevel: current.previousLevel ?? result.previousLevel,
|
||||
newLevel: result.newLevel,
|
||||
levelsGained: current.levelsGained + result.levelsGained,
|
||||
talentPointsGained: current.talentPointsGained + result.talentPointsGained,
|
||||
unlockedAbilities: Array.from(unlockedById.values()),
|
||||
loot: result.bonusItem ? [...current.loot, result.bonusItem] : current.loot,
|
||||
}
|
||||
})
|
||||
onProfileUpdated(result.profile)
|
||||
if (result.bonusItem) {
|
||||
addLog(
|
||||
@@ -532,8 +604,9 @@ export function PvPRoguelikeScreen({
|
||||
: null)
|
||||
}, [opponentDebuffChoicesCatalog, selfBuffChoicesCatalog])
|
||||
|
||||
useEffect(() => {
|
||||
const firstSegment = buildEncounterSegment(encounterPool, startStage, contentType)
|
||||
const startMatch = useCallback((nextStartStage?: number) => {
|
||||
const matchStartStage = nextStartStage ?? loadPvpRoguelikeCheckpoint(profile.character.id, contentType)
|
||||
const firstSegment = buildEncounterSegment(encounterPoolRef.current, matchStartStage, contentType)
|
||||
const firstEncounter = firstSegment[0]
|
||||
const basePlayer = starterSide(partyTemplate, maxResource)
|
||||
const baseCpu = starterSide(cpuPartyTemplate, maxResource)
|
||||
@@ -543,15 +616,18 @@ export function PvPRoguelikeScreen({
|
||||
cpuRef.current = baseCpu
|
||||
nextLogId.current = 2
|
||||
playerClearedEncounterRef.current = -1
|
||||
queuedMatchRef.current = true
|
||||
bossRewardClaimedRef.current = new Set()
|
||||
setEncounters(firstSegment)
|
||||
setEncounterIndex(0)
|
||||
setStage(startStage)
|
||||
setCheckpointStage(matchStartStage)
|
||||
setStartStage(matchStartStage)
|
||||
setStage(matchStartStage)
|
||||
setElapsedTicks(0)
|
||||
setStatus('queueing')
|
||||
setPlayerSide(basePlayer)
|
||||
setCpuSide(baseCpu)
|
||||
setSelectedId(partyTemplate[0].id)
|
||||
setSelectedTargetId(partyTemplate[0].id)
|
||||
setPlayerBuffChoices([])
|
||||
setPlayerDebuffChoices([])
|
||||
setSelectedBuff(null)
|
||||
@@ -560,6 +636,7 @@ export function PvPRoguelikeScreen({
|
||||
setPaused(false)
|
||||
setTargetGroup(0)
|
||||
setReward(null)
|
||||
setRunSummary(createEmptyPvpRunSummary())
|
||||
setRewardError('')
|
||||
setShowEndLog(false)
|
||||
setFloatingTexts([])
|
||||
@@ -569,26 +646,28 @@ export function PvPRoguelikeScreen({
|
||||
cpuDefeatedRef.current = false
|
||||
if (gameMode === 'offline') {
|
||||
const randomCpu = randomCpuDifficulty()
|
||||
setQueueMessage(`Offline mode. CPU ${randomCpu} enters at stage ${startStage}.`)
|
||||
setQueueMessage(`Offline mode. CPU ${randomCpu} enters at stage ${matchStartStage}.`)
|
||||
setCpuDifficulty(randomCpu)
|
||||
setLog([{ id: 1, text: `Offline mode. CPU ${randomCpu} enters at stage ${startStage}.`, tone: 'system' }])
|
||||
setLog([{ id: 1, text: `Offline mode. CPU ${randomCpu} enters at stage ${matchStartStage}.`, tone: 'system' }])
|
||||
const timer = window.setTimeout(() => {
|
||||
setStatus('playing')
|
||||
addLog(`Stage ${startStage} begins against CPU ${randomCpu}.`, 'system')
|
||||
addLog(`Stage ${matchStartStage} begins against CPU ${randomCpu}.`, 'system')
|
||||
}, 500)
|
||||
return () => window.clearTimeout(timer)
|
||||
}
|
||||
setQueueMessage(`Searching queue. Stage ${startStage} start ready.`)
|
||||
setLog([{ id: 1, text: `Searching queue. Stage ${startStage} start ready.`, tone: 'system' }])
|
||||
setQueueMessage(`Searching queue. Stage ${matchStartStage} start ready.`)
|
||||
setLog([{ id: 1, text: `Searching queue. Stage ${matchStartStage} start ready.`, tone: 'system' }])
|
||||
const timer = window.setTimeout(() => {
|
||||
const randomCpu = randomCpuDifficulty()
|
||||
setCpuDifficulty(randomCpu)
|
||||
setQueueMessage(`No queued player found. CPU ${randomCpu} steps in.`)
|
||||
setStatus('playing')
|
||||
addLog(`No queued player found. CPU ${randomCpu} steps in at stage ${startStage}.`, 'system')
|
||||
addLog(`No queued player found. CPU ${randomCpu} steps in at stage ${matchStartStage}.`, 'system')
|
||||
}, 1400)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [addLog, contentType, cpuPartyTemplate, encounterPool, gameMode, maxResource, partyTemplate, startStage])
|
||||
}, [addLog, contentType, cpuPartyTemplate, gameMode, maxResource, partyTemplate, profile.character.id, setSelectedTargetId])
|
||||
|
||||
useEffect(() => startMatch(), [startMatch])
|
||||
|
||||
const applySpell = useCallback((
|
||||
current: SideState,
|
||||
@@ -607,10 +686,21 @@ export function PvPRoguelikeScreen({
|
||||
const extraTarget = (blockedIds: string[]) => livingTargets
|
||||
.filter((member) => !blockedIds.includes(member.id))
|
||||
.sort((left, right) => (left.health / left.maxHealth) - (right.health / right.maxHealth))[0]
|
||||
const hasSpellEffect = (effectType: string) => sideName === 'player' && activeSpellEffects.has(effectType)
|
||||
const renewEffect = starterSpells.find((candidate) => candidate.kind === 'hot')
|
||||
const shieldEffect = starterSpells.find((candidate) => candidate.kind === 'shield')
|
||||
const radianceEffect = starterSpells.find((candidate) => candidate.kind === 'group')
|
||||
const healingMultiplier = (member: PartyMember) =>
|
||||
hasSpellEffect('shielded_healing_bonus') && member.shield > 0 ? 1.2 : 1
|
||||
const directTargets = new Set([targetId])
|
||||
const hotTargets = new Set(spell.kind === 'hot' ? [targetId] : [])
|
||||
const shieldTargets = new Set(spell.kind === 'shield' ? [targetId] : [])
|
||||
const extraTargets = buffStacks(buffs, `slot${spell.key as SlotKey}-extra-target` as SelfBuffId)
|
||||
const groupTargets = new Set(
|
||||
spell.kind === 'group'
|
||||
? groupHealTargets(current.party, DEFAULT_GROUP_HEAL_TARGETS + extraTargets).map((member) => member.id)
|
||||
: [],
|
||||
)
|
||||
for (let index = 0; index < extraTargets; index += 1) {
|
||||
if (spell.kind === 'group') break
|
||||
if (spell.kind === 'hot') {
|
||||
@@ -626,21 +716,45 @@ export function PvPRoguelikeScreen({
|
||||
const extra = extraTarget([...directTargets])
|
||||
if (extra) directTargets.add(extra.id)
|
||||
}
|
||||
if (spell.kind === 'direct' && hasSpellEffect('mend_applies_renew') && renewEffect) {
|
||||
directTargets.forEach((id) => hotTargets.add(id))
|
||||
}
|
||||
if (spell.kind === 'direct' && hasSpellEffect('mend_applies_shield') && shieldEffect) {
|
||||
directTargets.forEach((id) => shieldTargets.add(id))
|
||||
}
|
||||
if (spell.kind === 'shield' && hasSpellEffect('shield_applies_renew') && renewEffect) {
|
||||
shieldTargets.forEach((id) => hotTargets.add(id))
|
||||
}
|
||||
const nextParty = current.party.map((member) => {
|
||||
if (member.health <= 0) return member
|
||||
if (spell.kind === 'group') {
|
||||
if (!groupTargets.has(member.id)) return member
|
||||
const groupPower = Math.round(spell.power * (1.25 ** buffStacks(buffs, 'group-heal-boost')))
|
||||
const nextHealth = healMember(member, groupPower, debuffs)
|
||||
const nextHealth = healMember(member, groupPower, debuffs, healingMultiplier(member))
|
||||
addFloatingHeal(sideName, member.id, Math.max(0, nextHealth - member.health))
|
||||
return { ...member, health: nextHealth }
|
||||
const nextShield = hasSpellEffect('radiance_applies_shield')
|
||||
? Math.max(member.shield, Math.round((shieldEffect?.power ?? spell.power) * 0.3))
|
||||
: member.shield
|
||||
return {
|
||||
...member,
|
||||
health: nextHealth,
|
||||
shield: nextShield,
|
||||
hotTicks: hasSpellEffect('radiance_applies_renew') && renewEffect
|
||||
? Math.max(member.hotTicks, 3)
|
||||
: member.hotTicks,
|
||||
}
|
||||
}
|
||||
if (!directTargets.has(member.id) && !hotTargets.has(member.id) && !shieldTargets.has(member.id)) return member
|
||||
if (spell.kind === 'shield') {
|
||||
const shieldPower = Math.round(spell.power * (1.25 ** buffStacks(buffs, 'shield-boost')))
|
||||
return { ...member, shield: Math.max(member.shield, shieldPower) }
|
||||
return {
|
||||
...member,
|
||||
shield: Math.max(member.shield, shieldPower),
|
||||
hotTicks: hotTargets.has(member.id) ? 5 : member.hotTicks,
|
||||
}
|
||||
}
|
||||
if (spell.kind === 'cleanse') {
|
||||
const nextHealth = healMember(member, spell.power, debuffs)
|
||||
const nextHealth = healMember(member, spell.power, debuffs, healingMultiplier(member))
|
||||
addFloatingHeal(sideName, member.id, Math.max(0, nextHealth - member.health))
|
||||
return {
|
||||
...member,
|
||||
@@ -652,11 +766,17 @@ export function PvPRoguelikeScreen({
|
||||
healingReductionTicks: undefined,
|
||||
}
|
||||
}
|
||||
const nextHealth = directTargets.has(member.id) ? healMember(member, spell.power, debuffs) : member.health
|
||||
const nextHealth = directTargets.has(member.id)
|
||||
? healMember(member, spell.power, debuffs, healingMultiplier(member))
|
||||
: member.health
|
||||
if (nextHealth > member.health) addFloatingHeal(sideName, member.id, nextHealth - member.health)
|
||||
const nextShield = shieldTargets.has(member.id) && spell.kind === 'direct' && hasSpellEffect('mend_applies_shield')
|
||||
? Math.max(member.shield, Math.round((shieldEffect?.power ?? spell.power) * 0.5))
|
||||
: member.shield
|
||||
return {
|
||||
...member,
|
||||
health: nextHealth,
|
||||
shield: nextShield,
|
||||
hotTicks: hotTargets.has(member.id) ? 5 : member.hotTicks,
|
||||
}
|
||||
})
|
||||
@@ -670,46 +790,51 @@ export function PvPRoguelikeScreen({
|
||||
: current.castsTowardFree + 1
|
||||
: current.castsTowardFree
|
||||
const gainedFreeCast = freeCastStacks > 0 && !current.freeCastReady && current.castsTowardFree + 1 >= 5
|
||||
const nextCooldowns = {
|
||||
...current.cooldowns,
|
||||
}
|
||||
if (spell.kind === 'direct' && hasSpellEffect('mend_reduces_radiance_cooldown') && radianceEffect) {
|
||||
nextCooldowns[radianceEffect.id] = Math.max(0, (nextCooldowns[radianceEffect.id] ?? 0) - 2)
|
||||
}
|
||||
nextCooldowns[spell.id] = spell.cooldown * cooldownMultiplier(spell, buffs, debuffs)
|
||||
const nextState: SideState = {
|
||||
...current,
|
||||
party: nextParty,
|
||||
resource: current.resource - effectiveCost,
|
||||
cooldowns: {
|
||||
...current.cooldowns,
|
||||
[spell.id]: spell.cooldown * cooldownMultiplier(spell, buffs, debuffs),
|
||||
},
|
||||
cooldowns: nextCooldowns,
|
||||
castsTowardFree: nextCastsTowardFree,
|
||||
freeCastReady: gainedFreeCast || nextFreeCastReady,
|
||||
}
|
||||
setCurrent(nextState)
|
||||
return true
|
||||
}, [addFloatingHeal])
|
||||
}, [activeSpellEffects, addFloatingHeal, starterSpells])
|
||||
|
||||
const castPlayerSpell = useCallback((spell: Spell) => {
|
||||
if (status !== 'playing' || playerDone || !playerAlive) return
|
||||
const targetId = selectedIdRef.current
|
||||
const succeeded = applySpell(playerRef.current, (value) => {
|
||||
const next = typeof value === 'function' ? value(playerRef.current) : value
|
||||
playerRef.current = next
|
||||
setPlayerSide(next)
|
||||
}, 'player', playerRef.current.buffs, playerRef.current.debuffs, spell, selectedId)
|
||||
if (succeeded) addLog(`${spell.name} cast on ${playerRef.current.party.find((member) => member.id === selectedId)?.name ?? 'target'}.`, 'heal')
|
||||
}, [addLog, applySpell, playerAlive, playerDone, selectedId, status])
|
||||
}, 'player', playerRef.current.buffs, playerRef.current.debuffs, spell, targetId)
|
||||
if (succeeded) addLog(`${spell.name} cast on ${playerRef.current.party.find((member) => member.id === targetId)?.name ?? 'target'}.`, 'heal')
|
||||
}, [addLog, applySpell, playerAlive, playerDone, status])
|
||||
|
||||
const selectRelativeTarget = useCallback((direction: -1 | 1) => {
|
||||
const living = playerRef.current.party.filter((member) => member.health > 0)
|
||||
if (living.length === 0) return
|
||||
const currentIndex = living.findIndex((member) => member.id === selectedId)
|
||||
const currentIndex = living.findIndex((member) => member.id === selectedIdRef.current)
|
||||
const nextIndex = currentIndex < 0
|
||||
? 0
|
||||
: (currentIndex + direction + living.length) % living.length
|
||||
setSelectedId(living[nextIndex].id)
|
||||
}, [selectedId])
|
||||
setSelectedTargetId(living[nextIndex].id)
|
||||
}, [setSelectedTargetId])
|
||||
|
||||
const selectDirectionalTarget = useCallback((action: InputAction) => {
|
||||
const currentIndex = playerRef.current.party.findIndex((member) => member.id === selectedId)
|
||||
const currentIndex = playerRef.current.party.findIndex((member) => member.id === selectedIdRef.current)
|
||||
if (currentIndex < 0) {
|
||||
const firstLiving = playerRef.current.party.find((member) => member.health > 0)
|
||||
if (firstLiving) setSelectedId(firstLiving.id)
|
||||
if (firstLiving) setSelectedTargetId(firstLiving.id)
|
||||
return
|
||||
}
|
||||
const currentRow = Math.floor(currentIndex / partyColumns)
|
||||
@@ -736,14 +861,14 @@ export function PvPRoguelikeScreen({
|
||||
const bSecondary = horizontal ? 0 : Math.abs(b.column - currentColumn)
|
||||
return aPrimary - bPrimary || aSecondary - bSecondary
|
||||
})
|
||||
if (candidates[0]) setSelectedId(candidates[0].member.id)
|
||||
}, [partyColumns, selectedId])
|
||||
if (candidates[0]) setSelectedTargetId(candidates[0].member.id)
|
||||
}, [partyColumns, setSelectedTargetId])
|
||||
|
||||
const selectDirectTarget = useCallback((slot: number) => {
|
||||
const index = slot + (contentType === 'raid' ? targetGroup * 6 : 0)
|
||||
const member = playerRef.current.party[index]
|
||||
if (member?.health > 0) setSelectedId(member.id)
|
||||
}, [contentType, targetGroup])
|
||||
if (member?.health > 0) setSelectedTargetId(member.id)
|
||||
}, [contentType, setSelectedTargetId, targetGroup])
|
||||
|
||||
const cpuTakeTurn = useCallback(() => {
|
||||
if (!cpuDifficulty || status !== 'playing' || cpuDone || !cpuAlive) return
|
||||
@@ -790,10 +915,15 @@ export function PvPRoguelikeScreen({
|
||||
const appliesHealingReduction = encounterValue.isBoss && elapsedTicks > 0 && elapsedTicks % 9 === 0 && mechanics.includes('healing-reduction')
|
||||
const appliesPoison = encounterValue.isBoss && elapsedTicks > 0 && elapsedTicks % 12 === 0 && mechanics.includes('ramping-poison')
|
||||
const damageMultiplier = incomingDamageMultiplier(side.debuffs)
|
||||
const hasSpellEffect = (effectType: string) => sideName === 'player' && activeSpellEffects.has(effectType)
|
||||
const tankPressure = tankPressureTargets(side.party)
|
||||
const tankPressureIds = new Set(tankPressure.targets.map((member) => member.id))
|
||||
const nextParty = side.party.map((member) => {
|
||||
if (member.health <= 0) return member
|
||||
let damage = member.id === primaryTarget.id ? encounterValue.damage : 0
|
||||
if (member.role === 'Tank') damage += encounterValue.tankDamage
|
||||
if (tankPressureIds.has(member.id)) {
|
||||
damage += Math.round(encounterValue.tankDamage * tankPressure.multiplier)
|
||||
}
|
||||
if (bossPulse) damage += 10
|
||||
if (member.debuff) damage += 6
|
||||
const nextPoisonStacks = appliesPoison && member.id === primaryTarget.id
|
||||
@@ -801,8 +931,12 @@ export function PvPRoguelikeScreen({
|
||||
: member.poisonStacks ?? 0
|
||||
if (nextPoisonStacks > 0) damage += 3 + nextPoisonStacks * 3
|
||||
damage = Math.round(damage * damageMultiplier)
|
||||
if (member.shield > 0 && hasSpellEffect('shielded_damage_reduction')) {
|
||||
damage = Math.round(damage * 0.8)
|
||||
}
|
||||
const absorbed = Math.min(member.shield, damage)
|
||||
const healing = member.hotTicks > 0 ? healAmount(member, 6, side.debuffs) : 0
|
||||
const healingMultiplier = member.shield > 0 && hasSpellEffect('shielded_healing_bonus') ? 1.2 : 1
|
||||
const healing = member.hotTicks > 0 ? healAmount(member, 6, side.debuffs, healingMultiplier) : 0
|
||||
if (healing > 0) addFloatingHeal(sideName, member.id, healing)
|
||||
const nextMaxHealthPenaltyTicks = appliesMaxHealthCut && member.id === primaryTarget.id
|
||||
? 14
|
||||
@@ -842,9 +976,9 @@ export function PvPRoguelikeScreen({
|
||||
cooldowns: Object.fromEntries(
|
||||
Object.entries(side.cooldowns).map(([id, seconds]) => [id, Math.max(0, seconds - TICK_MS / 1000)]),
|
||||
),
|
||||
enemyHealth: Math.max(0, side.enemyHealth - encounterValue.partyDamage),
|
||||
enemyHealth: Math.max(0, side.enemyHealth - partyDamageOutput(nextParty, encounterValue.partyDamage)),
|
||||
}
|
||||
}, [addFloatingHeal, elapsedTicks, maxResource])
|
||||
}, [activeSpellEffects, addFloatingHeal, elapsedTicks, maxResource])
|
||||
|
||||
const beginUpgradePhase = useCallback(() => {
|
||||
setPlayerBuffChoices(chooseRandom(selfBuffChoicesCatalog, 3))
|
||||
@@ -895,12 +1029,18 @@ export function PvPRoguelikeScreen({
|
||||
addLog(`CPU ${cpuDifficulty ?? 1} fell. Finish the boss for XP.`, 'loot')
|
||||
}
|
||||
if (nextPlayer.enemyHealth <= 0) {
|
||||
if (encounter.isBoss && cpuDefeatedRef.current) {
|
||||
finishRoguelikeRun()
|
||||
setStatus('won')
|
||||
addLog('CPU defeated. Match complete.', 'loot')
|
||||
return
|
||||
}
|
||||
addLog(`${encounter.enemyName} cleared. Choose your next edge.`, 'loot')
|
||||
beginUpgradePhase()
|
||||
}
|
||||
}, TICK_MS)
|
||||
}, TICK_MS / speedMultiplier)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [addLog, advanceSide, awardBossReward, beginUpgradePhase, checkpointStage, contentType, cpuDifficulty, cpuTakeTurn, encounter, encounterIndex, encountersCleared, finishRoguelikeRun, paused, profile.character.id, stage, status])
|
||||
}, [addLog, advanceSide, awardBossReward, beginUpgradePhase, checkpointStage, contentType, cpuDifficulty, cpuTakeTurn, encounter, encounterIndex, encountersCleared, finishRoguelikeRun, paused, profile.character.id, speedMultiplier, stage, status])
|
||||
|
||||
useEffect(() => {
|
||||
if ((status !== 'won' && status !== 'lost') || recordedRunRef.current || !cpuDifficulty) return
|
||||
@@ -955,10 +1095,17 @@ export function PvPRoguelikeScreen({
|
||||
}
|
||||
|
||||
const clearedBoss = encounter.isBoss
|
||||
if (clearedBoss && cpuDefeatedRef.current) {
|
||||
finishRoguelikeRun()
|
||||
setStatus('won')
|
||||
addLog('CPU defeated. Match complete.', 'loot')
|
||||
return
|
||||
}
|
||||
const nextStage = clearedBoss ? stage + 1 : stage
|
||||
const nextSegment = clearedBoss ? buildEncounterSegment(encounterPool, nextStage, contentType) : []
|
||||
const nextEncounter = clearedBoss ? nextSegment[0] : encounters[encounterIndex + 1]
|
||||
if (!nextEncounter) {
|
||||
finishRoguelikeRun()
|
||||
setStatus('won')
|
||||
addLog('No further encounters remain.', 'loot')
|
||||
return
|
||||
@@ -1007,9 +1154,13 @@ export function PvPRoguelikeScreen({
|
||||
setElapsedTicks(0)
|
||||
setStatus('playing')
|
||||
addLog(`You chose ${selectedBuff.name} and ${selectedDebuff.name}. CPU ${cpuDifficulty} chose ${cpuBuff.name} and ${cpuDebuff.name}.`, 'system')
|
||||
}, [addLog, contentType, cpuDifficulty, encounter, encounterIndex, encounterPool, encounters, maxResource, opponentDebuffChoicesCatalog, selectedBuff, selectedDebuff, selfBuffChoicesCatalog, stage, starterSpells])
|
||||
}, [addLog, contentType, cpuDifficulty, encounter, encounterIndex, encounterPool, encounters, finishRoguelikeRun, maxResource, opponentDebuffChoicesCatalog, selectedBuff, selectedDebuff, selfBuffChoicesCatalog, stage, starterSpells])
|
||||
|
||||
useGameAction((action) => {
|
||||
if (action === 'toggleSpeed') {
|
||||
if (status === 'playing') setSpeedMultiplier((value) => (value === 1 ? 2 : 1))
|
||||
return
|
||||
}
|
||||
if (action === 'pause' || action === 'back') {
|
||||
if (status === 'playing') setPaused((value) => !value)
|
||||
return
|
||||
@@ -1036,9 +1187,9 @@ export function PvPRoguelikeScreen({
|
||||
setTargetGroup((current) => {
|
||||
const groupCount = Math.max(1, Math.ceil(playerRef.current.party.length / 6))
|
||||
const next = ((current + 1) % groupCount) as 0 | 1 | 2
|
||||
const selectedIndex = playerRef.current.party.findIndex((member) => member.id === selectedId)
|
||||
const selectedIndex = playerRef.current.party.findIndex((member) => member.id === selectedIdRef.current)
|
||||
const nextMember = playerRef.current.party[(selectedIndex < 0 ? 0 : selectedIndex % 6) + next * 6]
|
||||
if (nextMember?.health > 0) setSelectedId(nextMember.id)
|
||||
if (nextMember?.health > 0) setSelectedTargetId(nextMember.id)
|
||||
return next
|
||||
})
|
||||
return
|
||||
@@ -1081,6 +1232,7 @@ export function PvPRoguelikeScreen({
|
||||
directPartyTargeting,
|
||||
paused,
|
||||
targetGroup,
|
||||
speedMultiplier,
|
||||
}), [
|
||||
bindings,
|
||||
controllerIconStyle,
|
||||
@@ -1105,6 +1257,7 @@ export function PvPRoguelikeScreen({
|
||||
playerSide.party,
|
||||
playerSide.resource,
|
||||
selectedId,
|
||||
speedMultiplier,
|
||||
stage,
|
||||
starterSpells,
|
||||
status,
|
||||
@@ -1128,7 +1281,7 @@ export function PvPRoguelikeScreen({
|
||||
{dualScreenEnabled && status !== 'queueing' && (
|
||||
<DualScreenTopCombat
|
||||
state={dualScreenState}
|
||||
onSelectTarget={setSelectedId}
|
||||
onSelectTarget={setSelectedTargetId}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1143,6 +1296,7 @@ export function PvPRoguelikeScreen({
|
||||
<div className="resource-row pvp-resource-row">
|
||||
<div className="pvp-resource-wrap">
|
||||
<span>{gameClass.resourceName} {Math.floor(playerSide.resource)} / {maxResource}</span>
|
||||
{speedMultiplier === 2 && <strong className="speed-badge">2x speed</strong>}
|
||||
<div className="bar mana-bar"><span style={{ width: `${(playerSide.resource / maxResource) * 100}%` }} /></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1152,7 +1306,7 @@ export function PvPRoguelikeScreen({
|
||||
<button
|
||||
className={`party-member ${selectedId === member.id ? 'selected' : ''} ${member.health <= 0 ? 'down' : ''}`}
|
||||
key={`player-${member.id}`}
|
||||
onClick={() => setSelectedId(member.id)}
|
||||
onClick={() => setSelectedTargetId(member.id)}
|
||||
type="button"
|
||||
>
|
||||
<div className="member-header">
|
||||
@@ -1351,9 +1505,39 @@ export function PvPRoguelikeScreen({
|
||||
<h2>{status === 'won' ? `CPU ${cpuDifficulty} Falls` : `CPU ${cpuDifficulty} Wins`}</h2>
|
||||
<p>{finalEncountersCleared} encounters cleared.</p>
|
||||
<div className="reward-summary">
|
||||
{!reward && !rewardError && <p>Boss kills grant XP immediately.</p>}
|
||||
<p>{runSummary.bossesKilled} bosses killed.</p>
|
||||
<p>+{runSummary.experienceGained} XP</p>
|
||||
{runSummary.bossesKilled > 0 && !reward && !rewardError && <p>Final boss rewards still recording...</p>}
|
||||
{rewardError && <p className="reward-error">{rewardError}</p>}
|
||||
{reward && (
|
||||
{runSummary.levelsGained > 0 && runSummary.previousLevel !== null && runSummary.newLevel !== null && (
|
||||
<p className="level-gain">
|
||||
Level {runSummary.previousLevel} to {runSummary.newLevel}
|
||||
<small>+{runSummary.talentPointsGained} talent point{runSummary.talentPointsGained === 1 ? '' : 's'}</small>
|
||||
</p>
|
||||
)}
|
||||
{runSummary.unlockedAbilities.map((ability) => (
|
||||
<p className="ability-unlock" key={ability.id}>
|
||||
<span>{ability.glyph}</span>
|
||||
Ability Unlocked: {ability.name}
|
||||
</p>
|
||||
))}
|
||||
<div className="run-loot-rolls">
|
||||
{runSummary.loot.length > 0 ? runSummary.loot.map((item, index) => (
|
||||
<div className="dropped" key={`${item.id}-${index}`}>
|
||||
<strong>Boss {index + 1}</strong>
|
||||
<span>
|
||||
{item.glyph} {item.name} x{item.quantity}
|
||||
{item.duplicate ? ` (owned x${item.quantityAfter})` : ''}
|
||||
</span>
|
||||
</div>
|
||||
)) : (
|
||||
<div>
|
||||
<strong>Loot</strong>
|
||||
<span>No boss loot awarded</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{reward && runSummary.bossesKilled === 0 && (
|
||||
<>
|
||||
<p>+{reward.experienceGained} XP</p>
|
||||
{reward.levelsGained > 0 && (
|
||||
@@ -1392,6 +1576,7 @@ export function PvPRoguelikeScreen({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<button onClick={() => startMatch()} type="button">Queue Next Match</button>
|
||||
<button className="secondary-result-button" onClick={onExit} type="button">Back to Roguelike</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+176
-118
@@ -1,10 +1,11 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
allocateTalent,
|
||||
resetTalents,
|
||||
type CharacterProfile,
|
||||
type Talent,
|
||||
} from '../profile'
|
||||
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../dualScreen'
|
||||
|
||||
type Props = {
|
||||
profile: CharacterProfile
|
||||
@@ -13,199 +14,256 @@ type Props = {
|
||||
embedded?: boolean
|
||||
}
|
||||
|
||||
const EFFECT_SLOT_LEVELS = [5, 10, 15, 20] as const
|
||||
const EFFECT_CLASS_ID = 1
|
||||
const EFFECT_SOURCE_LABELS: Record<string, string> = {
|
||||
mend: 'Mend',
|
||||
radiance: 'Radiance',
|
||||
shield: 'Shield',
|
||||
}
|
||||
|
||||
function effectSource(effectType: string) {
|
||||
if (effectType.startsWith('mend_')) return 'mend'
|
||||
if (effectType.startsWith('radiance_')) return 'radiance'
|
||||
if (effectType.startsWith('shield_') || effectType.startsWith('shielded_')) return 'shield'
|
||||
return effectType
|
||||
}
|
||||
|
||||
function effectCapacity(level: number) {
|
||||
return EFFECT_SLOT_LEVELS.filter((slotLevel) => level >= slotLevel).length
|
||||
}
|
||||
|
||||
function activeEffects(talents: Talent[]) {
|
||||
return talents.filter((talent) => talent.rank > 0)
|
||||
}
|
||||
|
||||
export function TalentScreen({ profile, onBack, onUpdated, embedded = false }: Props) {
|
||||
const { enabled: dualScreenEnabled } = useDualScreen()
|
||||
const [busyTalentId, setBusyTalentId] = useState<number | null>(null)
|
||||
const [talentPage, setTalentPage] = useState(0)
|
||||
const [resetting, setResetting] = useState(false)
|
||||
const [selectedTalentId, setSelectedTalentId] = useState<number | null>(null)
|
||||
const [message, setMessage] = useState('')
|
||||
const scrollRef = useRef<number>(0)
|
||||
const gameClass = profile.classes.find(
|
||||
(candidate) => candidate.id === profile.character.classId,
|
||||
)!
|
||||
const classPointsSpent = gameClass.talents.reduce(
|
||||
(total, talent) => total + talent.rank,
|
||||
0,
|
||||
)
|
||||
const 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] ?? []
|
||||
const isEffectClass = gameClass.id === EFFECT_CLASS_ID
|
||||
const capacity = isEffectClass ? effectCapacity(profile.character.level) : 0
|
||||
const selectedEffects = activeEffects(gameClass.talents)
|
||||
const selectedTalent = gameClass.talents.find((talent) => talent.id === selectedTalentId)
|
||||
?? selectedEffects[0]
|
||||
?? gameClass.talents[0]
|
||||
?? null
|
||||
|
||||
useEffect(() => {
|
||||
window.scrollTo(0, scrollRef.current)
|
||||
}, [profile])
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedTalentId && gameClass.talents.some((talent) => talent.id === selectedTalentId)) return
|
||||
setSelectedTalentId(selectedTalent?.id ?? null)
|
||||
}, [gameClass.talents, selectedTalent?.id, selectedTalentId])
|
||||
|
||||
function saveScroll() {
|
||||
scrollRef.current = window.scrollY
|
||||
}
|
||||
|
||||
function lowerTierPoints(talent: Talent) {
|
||||
return gameClass.talents
|
||||
.filter((candidate) => candidate.tier < talent.tier)
|
||||
.reduce((total, candidate) => total + candidate.rank, 0)
|
||||
}
|
||||
|
||||
function lockReason(talent: Talent) {
|
||||
if (talent.rank >= talent.maxRank) return 'Maximum rank'
|
||||
|
||||
const requiredTierPoints = (talent.tier - 1) * 5
|
||||
if (lowerTierPoints(talent) < requiredTierPoints) {
|
||||
return `Requires ${requiredTierPoints} earlier-tier points`
|
||||
if (!isEffectClass) return 'Coming soon'
|
||||
if (talent.rank > 0) return ''
|
||||
const source = effectSource(talent.effectType)
|
||||
const sourceConflict = selectedEffects.find((effect) => effectSource(effect.effectType) === source)
|
||||
if (sourceConflict) return `${EFFECT_SOURCE_LABELS[source] ?? source} already selected`
|
||||
if (capacity <= 0) return 'Unlocks at level 5'
|
||||
if (selectedEffects.length >= capacity) {
|
||||
return `Active slots full (${capacity}/${capacity})`
|
||||
}
|
||||
|
||||
if (talent.prerequisiteTalentId) {
|
||||
const prerequisite = gameClass.talents.find(
|
||||
(candidate) => candidate.id === talent.prerequisiteTalentId,
|
||||
)
|
||||
if ((prerequisite?.rank ?? 0) < talent.prerequisiteRank) {
|
||||
return `Requires ${talent.prerequisiteName} rank ${talent.prerequisiteRank}`
|
||||
}
|
||||
}
|
||||
|
||||
if (profile.character.talentPoints <= 0) return 'No points available'
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
async function purchaseRank(talent: Talent) {
|
||||
async function toggleEffect(talent: Talent) {
|
||||
saveScroll()
|
||||
setBusyTalentId(talent.id)
|
||||
setMessage('')
|
||||
try {
|
||||
const updated = await allocateTalent(talent.id)
|
||||
onUpdated(updated)
|
||||
setMessage(`${talent.name} increased to rank ${talent.rank + 1}.`)
|
||||
setSelectedTalentId(talent.id)
|
||||
setMessage(talent.rank > 0 ? `${talent.name} removed.` : `${talent.name} activated.`)
|
||||
} catch (reason) {
|
||||
setMessage(reason instanceof Error ? reason.message : 'Unable to allocate talent.')
|
||||
setMessage(reason instanceof Error ? reason.message : 'Unable to update spell effect.')
|
||||
} finally {
|
||||
setBusyTalentId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function refundTree() {
|
||||
async function clearEffects() {
|
||||
saveScroll()
|
||||
setResetting(true)
|
||||
setMessage('')
|
||||
try {
|
||||
const updated = await resetTalents()
|
||||
onUpdated(updated)
|
||||
setMessage('All points in this talent tree were refunded.')
|
||||
setMessage('Spell effects cleared.')
|
||||
} catch (reason) {
|
||||
setMessage(reason instanceof Error ? reason.message : 'Unable to reset talents.')
|
||||
setMessage(reason instanceof Error ? reason.message : 'Unable to clear spell effects.')
|
||||
} finally {
|
||||
setResetting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const workshopState = useMemo<DualScreenWorkshopState | null>(() => {
|
||||
if (!isEffectClass) return null
|
||||
return {
|
||||
mode: 'talents',
|
||||
title: 'Spell Effects',
|
||||
subtitle: `${selectedEffects.length}/${capacity} active`,
|
||||
summary: selectedTalent
|
||||
? `${selectedTalent.name}: ${selectedTalent.description}`
|
||||
: 'Choose effects to modify your spells.',
|
||||
items: gameClass.talents.map((talent) => ({
|
||||
glyph: talent.glyph,
|
||||
title: talent.name,
|
||||
meta: talent.rank > 0 ? 'Active' : lockReason(talent) || 'Available',
|
||||
detail: talent.description,
|
||||
status: talent.rank > 0 ? 'Selected' : '',
|
||||
})),
|
||||
}
|
||||
}, [capacity, gameClass.talents, isEffectClass, selectedEffects.length, selectedTalent])
|
||||
|
||||
useDualScreenWorkshopPublisher(workshopState, dualScreenEnabled)
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{!embedded && (
|
||||
<div className="screen-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Character Growth</p>
|
||||
<h1>Talents</h1>
|
||||
<h1>Spell Effects</h1>
|
||||
</div>
|
||||
<button className="back-button" onClick={onBack} type="button">Back</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="talent-toolbar">
|
||||
<div className="talent-toolbar spell-effect-toolbar">
|
||||
<div className="talent-class-summary">
|
||||
<span style={{ borderColor: gameClass.themeColor, color: gameClass.themeColor }}>
|
||||
{gameClass.name[0]}
|
||||
</span>
|
||||
<div>
|
||||
<p className="eyebrow">{gameClass.name} Tree</p>
|
||||
<h2>Shape Your Healing Style</h2>
|
||||
<p className="eyebrow">{gameClass.name} Effects</p>
|
||||
<h2>Modify Your Spells</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="talent-points">
|
||||
<strong>{profile.character.talentPoints}</strong>
|
||||
<span>Available</span>
|
||||
<small>{classPointsSpent} spent in this tree</small>
|
||||
<strong>{selectedEffects.length}/{capacity}</strong>
|
||||
<span>Active</span>
|
||||
<small>Slots unlock at levels 5, 10, 15, 20</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="talent-page-tabs" role="tablist" aria-label="Talent tier pages">
|
||||
{tierPages.map((pageTiers, index) => (
|
||||
<button
|
||||
aria-selected={talentPage === index}
|
||||
className={talentPage === index ? 'active' : ''}
|
||||
key={pageTiers.join('-')}
|
||||
onClick={() => setTalentPage(index)}
|
||||
role="tab"
|
||||
type="button"
|
||||
>
|
||||
Tiers {pageTiers[0]}-{pageTiers[pageTiers.length - 1]}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
{!isEffectClass ? (
|
||||
<div className="talent-empty-state">
|
||||
<h2>Spell effects coming soon for {gameClass.name}.</h2>
|
||||
<p>This replacement system starts with the first class.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="spell-effect-layout">
|
||||
<section className="effect-slots-panel">
|
||||
<p className="eyebrow">Active Slots</p>
|
||||
{EFFECT_SLOT_LEVELS.map((level, index) => {
|
||||
const effect = selectedEffects[index]
|
||||
const unlocked = profile.character.level >= level
|
||||
return (
|
||||
<button
|
||||
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">
|
||||
{visibleTiers.map((tier) => {
|
||||
const requiredPoints = (tier - 1) * 5
|
||||
return (
|
||||
<section className="talent-tier" key={tier}>
|
||||
<div className="tier-label">
|
||||
<span>Tier {tier}</span>
|
||||
<small>
|
||||
{tier === 1 ? 'Open' : `${requiredPoints} earlier-tier points`}
|
||||
</small>
|
||||
<section className="effect-pool-panel">
|
||||
<div className="effect-panel-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Effect Pool</p>
|
||||
<h2>Choose and Swap</h2>
|
||||
</div>
|
||||
<div className="tier-talents">
|
||||
{gameClass.talents
|
||||
.filter((talent) => talent.tier === tier)
|
||||
.sort((a, b) => a.branch - b.branch)
|
||||
.map((talent) => {
|
||||
const reason = lockReason(talent)
|
||||
const isBusy = busyTalentId === talent.id
|
||||
return (
|
||||
<article
|
||||
className={`talent-node ${reason ? 'locked' : 'available'} ${talent.rank > 0 ? 'invested' : ''}`}
|
||||
key={talent.id}
|
||||
style={{ gridColumn: talent.branch }}
|
||||
>
|
||||
<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>
|
||||
)
|
||||
})}
|
||||
<span>{selectedEffects.length}/{capacity} active</span>
|
||||
</div>
|
||||
<div className="selected-effect-strip">
|
||||
<div>
|
||||
<p className="eyebrow">Selected Effect</p>
|
||||
{selectedTalent ? (
|
||||
<>
|
||||
<strong>{selectedTalent.name}</strong>
|
||||
<small>{selectedTalent.description}</small>
|
||||
</>
|
||||
) : (
|
||||
<small>No effect selected.</small>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{selectedTalent && (
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={Boolean(lockReason(selectedTalent)) || busyTalentId === selectedTalent.id}
|
||||
onClick={() => toggleEffect(selectedTalent)}
|
||||
type="button"
|
||||
>
|
||||
{busyTalentId === selectedTalent.id
|
||||
? 'Saving...'
|
||||
: selectedTalent.rank > 0
|
||||
? 'Remove'
|
||||
: 'Activate'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="effect-pool">
|
||||
{gameClass.talents.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>{talent.description}</small>
|
||||
</div>
|
||||
<i>{isBusy ? 'Saving' : active ? 'Active' : reason || 'Available'}</i>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<footer className="talent-footer">
|
||||
<span>{message || 'Talent changes are saved immediately.'}</span>
|
||||
<span>{message || 'Spell effect changes are saved immediately.'}</span>
|
||||
<button
|
||||
className="text-button"
|
||||
disabled={classPointsSpent === 0 || resetting}
|
||||
onClick={refundTree}
|
||||
disabled={selectedEffects.length === 0 || resetting}
|
||||
onClick={clearEffects}
|
||||
type="button"
|
||||
>
|
||||
{resetting ? 'Refunding...' : 'Reset Tree'}
|
||||
{resetting ? 'Clearing...' : 'Clear Effects'}
|
||||
</button>
|
||||
</footer>
|
||||
</>
|
||||
|
||||
+111
-2
@@ -54,14 +54,31 @@ export type DualScreenCombatState = {
|
||||
directPartyTargeting: boolean
|
||||
paused: boolean
|
||||
targetGroup: 0 | 1 | 2
|
||||
speedMultiplier: 1 | 2
|
||||
}
|
||||
|
||||
export type DualScreenWorkshopState = {
|
||||
mode: 'class' | 'equipment' | 'crafting' | 'talents'
|
||||
title: string
|
||||
subtitle: string
|
||||
summary?: string
|
||||
items: Array<{
|
||||
glyph?: string
|
||||
title: string
|
||||
meta?: string
|
||||
detail?: string
|
||||
status?: string
|
||||
}>
|
||||
}
|
||||
|
||||
type DualScreenMessage =
|
||||
| { type: 'combat-state'; state: DualScreenCombatState }
|
||||
| { type: 'workshop-state'; state: DualScreenWorkshopState }
|
||||
| { type: 'companion-ready' }
|
||||
| { type: 'companion-heartbeat' }
|
||||
| { type: 'control-action'; action: InputAction }
|
||||
| { type: 'combat-ended' }
|
||||
| { type: 'workshop-ended' }
|
||||
|
||||
type DualScreenContextValue = {
|
||||
enabled: boolean
|
||||
@@ -102,6 +119,13 @@ function loadRecentSnapshot() {
|
||||
}
|
||||
}
|
||||
|
||||
function memberHotEffects(member: PartyMember) {
|
||||
if (member.hotEffects?.length) return member.hotEffects
|
||||
return member.hotTicks > 0
|
||||
? [{ id: 'legacy-renew', spellId: 'legacy-renew', label: 'Renew', ticks: member.hotTicks, power: 6 }]
|
||||
: []
|
||||
}
|
||||
|
||||
export function DualScreenProvider({ children }: { children: ReactNode }) {
|
||||
const [enabled, setEnabledState] = useState(
|
||||
() => localStorage.getItem(STORAGE_KEY) === 'true',
|
||||
@@ -280,16 +304,64 @@ export function useDualScreenPublisher(
|
||||
}, [enabled, state])
|
||||
}
|
||||
|
||||
export function useDualScreenWorkshopPublisher(
|
||||
state: DualScreenWorkshopState | null,
|
||||
enabled: boolean,
|
||||
) {
|
||||
const stateRef = useRef(state)
|
||||
useEffect(() => {
|
||||
stateRef.current = state
|
||||
}, [state])
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !state) return
|
||||
const channel = createChannel()
|
||||
if (!channel) return
|
||||
const publish = () => {
|
||||
if (stateRef.current) {
|
||||
channel.postMessage({
|
||||
type: 'workshop-state',
|
||||
state: stateRef.current,
|
||||
} satisfies DualScreenMessage)
|
||||
}
|
||||
}
|
||||
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
|
||||
if (event.data.type === 'companion-ready') publish()
|
||||
}
|
||||
publish()
|
||||
return () => {
|
||||
channel.postMessage({ type: 'workshop-ended' } satisfies DualScreenMessage)
|
||||
channel.close()
|
||||
}
|
||||
}, [enabled, state])
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !state) return
|
||||
const channel = createChannel()
|
||||
channel?.postMessage({ type: 'workshop-state', state } satisfies DualScreenMessage)
|
||||
channel?.close()
|
||||
}, [enabled, state])
|
||||
}
|
||||
|
||||
export function DualScreenBottomDisplay() {
|
||||
const [state, setState] = useState<DualScreenCombatState | null>(loadRecentSnapshot)
|
||||
const [workshopState, setWorkshopState] = useState<DualScreenWorkshopState | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const channel = createChannel()
|
||||
if (!channel) return
|
||||
const announce = () => channel.postMessage({ type: 'companion-ready' } satisfies DualScreenMessage)
|
||||
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
|
||||
if (event.data.type === 'combat-state') setState(event.data.state)
|
||||
if (event.data.type === 'combat-state') {
|
||||
setState(event.data.state)
|
||||
setWorkshopState(null)
|
||||
}
|
||||
if (event.data.type === 'workshop-state') {
|
||||
setWorkshopState(event.data.state)
|
||||
setState(null)
|
||||
}
|
||||
if (event.data.type === 'combat-ended') setState(null)
|
||||
if (event.data.type === 'workshop-ended') setWorkshopState(null)
|
||||
}
|
||||
announce()
|
||||
const timer = window.setInterval(() => {
|
||||
@@ -307,6 +379,40 @@ export function DualScreenBottomDisplay() {
|
||||
channel?.close()
|
||||
}
|
||||
|
||||
if (!state && workshopState) {
|
||||
return (
|
||||
<main className="dual-bottom-display workshop-bottom-display">
|
||||
<header className="dual-controls-header">
|
||||
<div>
|
||||
<p className="eyebrow">{workshopState.mode}</p>
|
||||
<h1>{workshopState.title}</h1>
|
||||
</div>
|
||||
<div className="dual-controls-progress">
|
||||
<span>{workshopState.subtitle}</span>
|
||||
</div>
|
||||
</header>
|
||||
{workshopState.summary && (
|
||||
<section className="workshop-bottom-summary">
|
||||
{workshopState.summary}
|
||||
</section>
|
||||
)}
|
||||
<section className="workshop-bottom-grid">
|
||||
{workshopState.items.map((item, index) => (
|
||||
<article key={`${item.title}-${index}`}>
|
||||
{item.glyph && <span>{item.glyph}</span>}
|
||||
<div>
|
||||
<strong>{item.title}</strong>
|
||||
{item.meta && <small>{item.meta}</small>}
|
||||
{item.detail && <p>{item.detail}</p>}
|
||||
</div>
|
||||
{item.status && <i>{item.status}</i>}
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
if (!state) {
|
||||
return (
|
||||
<main className="dual-bottom-display dual-bottom-waiting">
|
||||
@@ -340,6 +446,7 @@ export function DualScreenBottomDisplay() {
|
||||
</div>
|
||||
<div className="dual-controls-mana">
|
||||
<span>{state.resourceName} {Math.floor(state.resource)} / {state.maxResource}</span>
|
||||
{state.speedMultiplier === 2 && <strong className="speed-badge">2x speed</strong>}
|
||||
<div className="bar mana-bar">
|
||||
<span style={{ width: `${(state.resource / state.maxResource) * 100}%` }} />
|
||||
</div>
|
||||
@@ -501,7 +608,9 @@ export function DualScreenTopCombat({
|
||||
</div>
|
||||
)}
|
||||
<div className="member-effects">
|
||||
{member.hotTicks > 0 && <span className="buff">Renew</span>}
|
||||
{memberHotEffects(member).map((effect) => (
|
||||
<span className="buff" key={effect.id}>{effect.label}</span>
|
||||
))}
|
||||
{member.debuff && <span className="debuff">{member.debuff}</span>}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
+45
-2
@@ -8,6 +8,20 @@ export type PartyMember = {
|
||||
maxHealth: number
|
||||
shield: number
|
||||
hotTicks: number
|
||||
hotEffects?: Array<{
|
||||
id: string
|
||||
spellId: string
|
||||
label: string
|
||||
ticks: number
|
||||
power: number
|
||||
}>
|
||||
bounceHeals?: Array<{
|
||||
id: string
|
||||
label: string
|
||||
charges: number
|
||||
power: number
|
||||
}>
|
||||
damageReductionTicks?: number
|
||||
debuff?: string
|
||||
debuffTicks?: number
|
||||
poisonStacks?: number
|
||||
@@ -24,7 +38,8 @@ export type Spell = {
|
||||
cooldown: number
|
||||
power: number
|
||||
glyph: string
|
||||
kind: 'direct' | 'hot' | 'group' | 'shield' | 'cleanse'
|
||||
kind: 'direct' | 'hot' | 'group' | 'shield' | 'cleanse' | 'damage_reduction' | 'bounce_heal'
|
||||
effectType?: string
|
||||
}
|
||||
|
||||
export type Encounter = {
|
||||
@@ -44,6 +59,9 @@ export type CombatLogEntry = {
|
||||
tone: 'system' | 'heal' | 'danger' | 'loot'
|
||||
}
|
||||
|
||||
export const TANKLESS_DAMAGE_MULTIPLIER = 1.35
|
||||
export const DEFAULT_GROUP_HEAL_TARGETS = 4
|
||||
|
||||
export const INITIAL_PARTY: PartyMember[] = [
|
||||
{ id: 'brann', name: 'Brann', role: 'Tank', health: 150, maxHealth: 150, shield: 0, hotTicks: 0 },
|
||||
{ id: 'mira', name: 'Mira', role: 'Healer', health: 100, maxHealth: 100, shield: 0, hotTicks: 0 },
|
||||
@@ -101,7 +119,7 @@ export const SPELLS: Spell[] = [
|
||||
id: 'radiance',
|
||||
key: '3',
|
||||
name: 'Radiance',
|
||||
description: 'Restores health to every living party member.',
|
||||
description: 'Restores health to up to 4 injured party members.',
|
||||
cost: 12,
|
||||
cooldown: 8,
|
||||
power: 18,
|
||||
@@ -164,3 +182,28 @@ export const ENCOUNTERS: Encounter[] = [
|
||||
isBoss: true,
|
||||
},
|
||||
]
|
||||
|
||||
export function partyDamageOutput(party: PartyMember[], baseDamage: number) {
|
||||
const livingCount = party.filter((member) => member.health > 0).length
|
||||
return Math.round(baseDamage * (livingCount / Math.max(1, party.length)))
|
||||
}
|
||||
|
||||
export function tankPressureTargets(party: PartyMember[]) {
|
||||
const living = party.filter((member) => member.health > 0)
|
||||
const tanks = living.filter((member) => member.role === 'Tank')
|
||||
if (tanks.length > 0) return { targets: tanks, multiplier: 1 }
|
||||
const damageDealer = living
|
||||
.filter((member) => member.role === 'Damage')
|
||||
.sort((left, right) => right.health - left.health)[0]
|
||||
return {
|
||||
targets: damageDealer ? [damageDealer] : [],
|
||||
multiplier: TANKLESS_DAMAGE_MULTIPLIER,
|
||||
}
|
||||
}
|
||||
|
||||
export function groupHealTargets(party: PartyMember[], targetCount = DEFAULT_GROUP_HEAL_TARGETS) {
|
||||
return party
|
||||
.filter((member) => member.health > 0)
|
||||
.sort((left, right) => (left.health / left.maxHealth) - (right.health / right.maxHealth))
|
||||
.slice(0, targetCount)
|
||||
}
|
||||
|
||||
+150
-53
@@ -26,6 +26,7 @@ export interface GameRepository {
|
||||
completedPart?: number,
|
||||
startPart?: number,
|
||||
partDurationSeconds?: [number, number, number],
|
||||
hardMode?: boolean,
|
||||
): Promise<DungeonReward>
|
||||
completeRoguelike(
|
||||
dungeonId: number,
|
||||
@@ -102,6 +103,7 @@ const offlineSaveKey = 'chronicle.offlineSave.v1'
|
||||
const onlineCacheKey = 'chronicle.onlineCache.v1'
|
||||
const authTokenKey = 'chronicle.authToken.v1'
|
||||
const offlineAccount = { id: -1, username: 'Offline' }
|
||||
const ABILITY_SLOT_COUNT = 6
|
||||
|
||||
function clone<T>(value: T): T {
|
||||
return structuredClone(value)
|
||||
@@ -146,7 +148,7 @@ function upgradeV1Save(v1: { profile: CharacterProfile; lootRolls: Record<string
|
||||
level: cid === p.character.classId ? p.character.level : 1,
|
||||
experience: cid === p.character.classId ? p.character.experience : 0,
|
||||
talentPoints: cid === p.character.classId ? p.character.talentPoints : 1,
|
||||
abilitySlots: cid === p.character.classId ? [...p.abilitySlots] : [],
|
||||
abilitySlots: cid === p.character.classId ? normalizeAbilitySlots(p.abilitySlots) : [],
|
||||
talentRanks,
|
||||
inventory: cid === p.character.classId ? clone(p.inventory) : [],
|
||||
}
|
||||
@@ -163,11 +165,32 @@ function upgradeV1Save(v1: { profile: CharacterProfile; lootRolls: Record<string
|
||||
}
|
||||
|
||||
function upgradeV2Save(v2: Omit<OfflineSave, 'version' | 'completedRaidPhases'> & { version: 2 }): OfflineSave {
|
||||
return {
|
||||
return normalizeSaveAbilitySlots({
|
||||
...v2,
|
||||
version: 3,
|
||||
completedRaidPhases: 0,
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeAbilitySlots(abilitySlots: unknown): Array<number | null> {
|
||||
const slots = Array.isArray(abilitySlots)
|
||||
? abilitySlots
|
||||
.slice(0, ABILITY_SLOT_COUNT)
|
||||
.map((value) => {
|
||||
if (value === null || value === undefined) return null
|
||||
const id = Number(value)
|
||||
return Number.isInteger(id) ? id : null
|
||||
})
|
||||
: []
|
||||
while (slots.length < ABILITY_SLOT_COUNT) slots.push(null)
|
||||
return slots
|
||||
}
|
||||
|
||||
function normalizeSaveAbilitySlots(save: OfflineSave): OfflineSave {
|
||||
for (const character of Object.values(save.characters)) {
|
||||
character.abilitySlots = normalizeAbilitySlots(character.abilitySlots)
|
||||
}
|
||||
return save
|
||||
}
|
||||
|
||||
function normalizeOfflineSave(raw: unknown): OfflineSave | null {
|
||||
@@ -177,12 +200,12 @@ function normalizeOfflineSave(raw: unknown): OfflineSave | null {
|
||||
profile?: CharacterProfile
|
||||
lootRolls?: Record<string, LootRoll>
|
||||
}
|
||||
if (candidate.version === 3) return candidate as OfflineSave
|
||||
if (candidate.version === 3) return normalizeSaveAbilitySlots(candidate as OfflineSave)
|
||||
if (candidate.version === 2) {
|
||||
return upgradeV2Save(candidate as Omit<OfflineSave, 'version' | 'completedRaidPhases'> & { version: 2 })
|
||||
}
|
||||
if (candidate.version === 1 && candidate.profile) {
|
||||
return upgradeV1Save(candidate as { profile: CharacterProfile; lootRolls: Record<string, LootRoll> })
|
||||
return normalizeSaveAbilitySlots(upgradeV1Save(candidate as { profile: CharacterProfile; lootRolls: Record<string, LootRoll> }))
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -359,11 +382,33 @@ function experienceForLevel(level: number) {
|
||||
return (level - 1) * (level - 1) * 100
|
||||
}
|
||||
|
||||
function catchUpExperienceReward(
|
||||
baseReward: number,
|
||||
currentExperience: number,
|
||||
currentLevel: number,
|
||||
targetLevel: number,
|
||||
) {
|
||||
if (targetLevel <= currentLevel) return baseReward
|
||||
const targetExperience = experienceForLevel(targetLevel)
|
||||
const gap = Math.max(0, targetExperience - currentExperience)
|
||||
if (gap <= 0) return baseReward
|
||||
const doubledBase = Math.min(baseReward, Math.ceil(gap / 2))
|
||||
return doubledBase * 2 + (baseReward - doubledBase)
|
||||
}
|
||||
|
||||
function highestOtherClassLevel(save: OfflineSave) {
|
||||
const activeClass = save.activeClassId
|
||||
return Object.entries(save.characters)
|
||||
.filter(([classId]) => Number(classId) !== activeClass)
|
||||
.reduce((highest, [, character]) => Math.max(highest, character.level), 0)
|
||||
}
|
||||
|
||||
function scaledPvpBossExperience(
|
||||
startingExperience: number,
|
||||
startingLevel: number,
|
||||
bossesCleared: number,
|
||||
maxLevel: number,
|
||||
targetLevel = startingLevel,
|
||||
) {
|
||||
let experience = startingExperience
|
||||
let level = startingLevel
|
||||
@@ -374,7 +419,8 @@ function scaledPvpBossExperience(
|
||||
? maxExperience
|
||||
: experienceForLevel(level + 1)
|
||||
const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor)
|
||||
experience = Math.min(maxExperience, experience + Math.round(levelBand * 0.25))
|
||||
const rewardRate = targetLevel > level ? 0.5 : 0.25
|
||||
experience = Math.min(maxExperience, experience + Math.round(levelBand * rewardRate))
|
||||
while (level < maxLevel && experienceForLevel(level + 1) <= experience) {
|
||||
level += 1
|
||||
}
|
||||
@@ -382,15 +428,25 @@ function scaledPvpBossExperience(
|
||||
return { experience, level }
|
||||
}
|
||||
|
||||
function talentEffectCapacity(level: number) {
|
||||
return Math.min(4, Math.max(0, Math.floor(level / 5)))
|
||||
}
|
||||
|
||||
function talentEffectSource(effectType: string) {
|
||||
if (effectType.startsWith('mend_')) return 'Mend'
|
||||
if (effectType.startsWith('radiance_')) return 'Radiance'
|
||||
if (effectType.startsWith('shield_') || effectType.startsWith('shielded_')) return 'Shield'
|
||||
return effectType
|
||||
}
|
||||
|
||||
type ComponentTemplate = { id: number; slug: string; name: string; itemLevel: number; glyph: string; description: string }
|
||||
const COMPONENT_ITEMS: Record<number, ComponentTemplate> = {
|
||||
1: { id: 600, slug: 'minor-component', name: 'Minor Component', itemLevel: 1, glyph: '◆', description: 'A basic crafting component.' },
|
||||
5: { id: 601, slug: 'basic-component', name: 'Basic Component', itemLevel: 5, glyph: '◇', description: 'A standard crafting component.' },
|
||||
10: { id: 602, slug: 'refined-component', name: 'Refined Component', itemLevel: 10, glyph: '◈', description: 'A refined crafting component.' },
|
||||
15: { id: 603, slug: 'advanced-component', name: 'Advanced Component', itemLevel: 15, glyph: '◉', description: 'An advanced 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.' },
|
||||
}
|
||||
const DIRECT_CRAFT_ITEM_LEVELS = new Set([1, 10, 20, 25])
|
||||
|
||||
type WindowWithApiBase = Window & {
|
||||
CAPACITOR_API_BASE_URL?: string
|
||||
@@ -425,7 +481,7 @@ function mergeProfileIntoSave(profile: CharacterProfile, existingSave?: OfflineS
|
||||
level: profile.character.level,
|
||||
experience: profile.character.experience,
|
||||
talentPoints: profile.character.talentPoints,
|
||||
abilitySlots: [...profile.abilitySlots],
|
||||
abilitySlots: normalizeAbilitySlots(profile.abilitySlots),
|
||||
talentRanks,
|
||||
inventory: clone(profile.inventory),
|
||||
}
|
||||
@@ -718,7 +774,7 @@ const serverRepository: GameRepository = {
|
||||
),
|
||||
saveProfile: (classId, abilitySlots) =>
|
||||
cachedOnlineLocalRepository.saveProfile(classId, abilitySlots),
|
||||
completeDungeon: (dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds) =>
|
||||
completeDungeon: (dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds, hardMode) =>
|
||||
cachedOnlineLocalRepository.completeDungeon(
|
||||
dungeonId,
|
||||
difficultyId,
|
||||
@@ -727,6 +783,7 @@ const serverRepository: GameRepository = {
|
||||
completedPart,
|
||||
startPart,
|
||||
partDurationSeconds,
|
||||
hardMode,
|
||||
),
|
||||
completeRoguelike: (dungeonId, difficultyId, encountersCleared, resourceSpent, durationSeconds, options) =>
|
||||
cachedOnlineLocalRepository.completeRoguelike(
|
||||
@@ -763,9 +820,9 @@ function emptyCharacterData(classId: number): CharacterData {
|
||||
const inventory: Item[] = []
|
||||
const startingAbilitySlots: Array<number | null> = gc.spells
|
||||
.filter((s) => s.unlockLevel === 1)
|
||||
.slice(0, 5)
|
||||
.slice(0, ABILITY_SLOT_COUNT)
|
||||
.map((s) => s.id)
|
||||
while (startingAbilitySlots.length < 6) startingAbilitySlots.push(null)
|
||||
while (startingAbilitySlots.length < ABILITY_SLOT_COUNT) startingAbilitySlots.push(null)
|
||||
return {
|
||||
level: 1,
|
||||
experience: 0,
|
||||
@@ -805,35 +862,34 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
||||
},
|
||||
async saveProfile(classId, abilitySlots) {
|
||||
const save = requireStoredSave(store)
|
||||
const static_ = clone(starterProfile) as CharacterProfile
|
||||
const gameClass = static_.classes.find((candidate) => candidate.id === classId)
|
||||
if (!gameClass) throw new Error('Selected class does not exist.')
|
||||
const static_ = clone(starterProfile) as CharacterProfile
|
||||
const gameClass = static_.classes.find((candidate) => candidate.id === classId)
|
||||
if (!gameClass) throw new Error('Selected class does not exist.')
|
||||
|
||||
const slots = abilitySlots.slice(0, 6)
|
||||
while (slots.length < 6) slots.push(null)
|
||||
const selectedIds = slots.filter((id): id is number => id !== null)
|
||||
if (new Set(selectedIds).size !== selectedIds.length) {
|
||||
throw new Error('The same ability cannot be equipped twice.')
|
||||
}
|
||||
const activeChar = save.characters[save.activeClassId]
|
||||
const validIds = new Set(
|
||||
gameClass.spells
|
||||
.filter((spell) => spell.unlockLevel <= activeChar.level)
|
||||
.map((spell) => spell.id),
|
||||
)
|
||||
if (selectedIds.some((id) => !validIds.has(id))) {
|
||||
throw new Error('One or more abilities are locked or belong to another class.')
|
||||
}
|
||||
const slots = normalizeAbilitySlots(abilitySlots)
|
||||
const selectedIds = slots.filter((id): id is number => id !== null)
|
||||
if (new Set(selectedIds).size !== selectedIds.length) {
|
||||
throw new Error('The same ability cannot be equipped twice.')
|
||||
}
|
||||
const activeChar = save.characters[save.activeClassId]
|
||||
const validIds = new Set(
|
||||
gameClass.spells
|
||||
.filter((spell) => spell.unlockLevel <= activeChar.level)
|
||||
.map((spell) => spell.id),
|
||||
)
|
||||
if (selectedIds.some((id) => !validIds.has(id))) {
|
||||
throw new Error('One or more abilities are locked or belong to another class.')
|
||||
}
|
||||
|
||||
if (!save.characters[classId]) {
|
||||
save.characters[classId] = emptyCharacterData(classId)
|
||||
}
|
||||
save.characters[classId].abilitySlots = slots
|
||||
save.activeClassId = classId
|
||||
if (!save.characters[classId]) {
|
||||
save.characters[classId] = emptyCharacterData(classId)
|
||||
}
|
||||
save.characters[classId].abilitySlots = slots
|
||||
save.activeClassId = classId
|
||||
store.writeSave(save)
|
||||
return buildProfile(save)
|
||||
},
|
||||
async completeDungeon(dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds) {
|
||||
async completeDungeon(dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds, hardMode) {
|
||||
void startPart
|
||||
void partDurationSeconds
|
||||
if (!Number.isInteger(resourceSpent) || resourceSpent < 0) {
|
||||
@@ -859,8 +915,15 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
||||
const previousLevel = cd.level
|
||||
const previousExperience = cd.experience
|
||||
const partCount = completedPart ?? 1
|
||||
const experienceReward = Math.round(
|
||||
dungeon.experienceReward * difficulty.experienceMultiplier * partCount,
|
||||
const rewardMultiplier = hardMode ? 2 : 1
|
||||
const baseExperienceReward = Math.round(
|
||||
dungeon.experienceReward * difficulty.experienceMultiplier * partCount * rewardMultiplier,
|
||||
)
|
||||
const experienceReward = catchUpExperienceReward(
|
||||
baseExperienceReward,
|
||||
previousExperience,
|
||||
previousLevel,
|
||||
highestOtherClassLevel(save),
|
||||
)
|
||||
const maxExperience = experienceForLevel(profile.maxLevel)
|
||||
const newExperience = Math.min(previousExperience + experienceReward, maxExperience)
|
||||
@@ -908,19 +971,20 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
||||
const selected = rewardPool[Math.floor(Math.random() * rewardPool.length)]
|
||||
const existing = profile.inventory.find((item) => item.id === selected.id)
|
||||
const duplicate = Boolean(existing)
|
||||
let quantityAfter = 1
|
||||
const rewardQuantity = rewardMultiplier
|
||||
let quantityAfter = rewardQuantity
|
||||
if (existing) {
|
||||
existing.quantity += 1
|
||||
existing.quantity += rewardQuantity
|
||||
quantityAfter = existing.quantity
|
||||
} else {
|
||||
profile.inventory.push({
|
||||
...selected,
|
||||
quantity: 1,
|
||||
quantity: rewardQuantity,
|
||||
equipped: false,
|
||||
})
|
||||
}
|
||||
cd.inventory = profile.inventory
|
||||
bonusItem = { ...selected, quantity: 1, duplicate, quantityAfter }
|
||||
bonusItem = { ...selected, quantity: rewardQuantity, duplicate, quantityAfter }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -973,13 +1037,19 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
||||
const maxExperience = experienceForLevel(profile.maxLevel)
|
||||
const bossesCleared = Math.max(0, Math.floor(options?.bossesCleared ?? encountersCleared / 3))
|
||||
const scaledReward = options?.experienceMode === 'pvp-boss-quarter-level'
|
||||
? scaledPvpBossExperience(previousExperience, previousLevel, bossesCleared, profile.maxLevel)
|
||||
? scaledPvpBossExperience(previousExperience, previousLevel, bossesCleared, profile.maxLevel, highestOtherClassLevel(save))
|
||||
: null
|
||||
const baseRoguelikeReward = Math.round(dungeon.experienceReward * difficulty.experienceMultiplier * (encountersCleared / 3))
|
||||
const newExperience = scaledReward
|
||||
? scaledReward.experience
|
||||
: Math.min(
|
||||
previousExperience
|
||||
+ Math.round(dungeon.experienceReward * difficulty.experienceMultiplier * (encountersCleared / 3)),
|
||||
+ catchUpExperienceReward(
|
||||
baseRoguelikeReward,
|
||||
previousExperience,
|
||||
previousLevel,
|
||||
highestOtherClassLevel(save),
|
||||
),
|
||||
maxExperience,
|
||||
)
|
||||
let newLevel = scaledReward?.level ?? previousLevel
|
||||
@@ -1043,6 +1113,34 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
||||
)!
|
||||
const talent = gameClass.talents.find((candidate) => candidate.id === talentId)
|
||||
if (!talent) throw new Error('That talent does not belong to the active class.')
|
||||
if (save.activeClassId === 1) {
|
||||
if ((cd.talentRanks[String(talentId)] ?? 0) > 0) {
|
||||
cd.talentRanks[String(talentId)] = 0
|
||||
} else {
|
||||
const capacity = talentEffectCapacity(cd.level)
|
||||
if (capacity <= 0) throw new Error('Spell effects unlock at level 5.')
|
||||
const source = talentEffectSource(talent.effectType)
|
||||
const sourceConflict = gameClass.talents.find(
|
||||
(candidate) =>
|
||||
candidate.id !== talentId
|
||||
&& (cd.talentRanks[String(candidate.id)] ?? 0) > 0
|
||||
&& talentEffectSource(candidate.effectType) === source,
|
||||
)
|
||||
if (sourceConflict) {
|
||||
throw new Error(`Only one ${source} spell effect can be active.`)
|
||||
}
|
||||
const activeCount = gameClass.talents.reduce(
|
||||
(total, candidate) => total + ((cd.talentRanks[String(candidate.id)] ?? 0) > 0 ? 1 : 0),
|
||||
0,
|
||||
)
|
||||
if (activeCount >= capacity) {
|
||||
throw new Error(`Level ${cd.level} allows ${capacity} active spell effect${capacity === 1 ? '' : 's'}.`)
|
||||
}
|
||||
cd.talentRanks[String(talentId)] = 1
|
||||
}
|
||||
store.writeSave(save)
|
||||
return buildProfile(save)
|
||||
}
|
||||
if (cd.talentPoints <= 0) {
|
||||
throw new Error('No talent points are available.')
|
||||
}
|
||||
@@ -1085,10 +1183,12 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
||||
for (const talent of gameClass.talents) {
|
||||
cd.talentRanks[String(talent.id)] = 0
|
||||
}
|
||||
cd.talentPoints = Math.min(
|
||||
profile.maxTalentPoints,
|
||||
cd.talentPoints + refunded,
|
||||
)
|
||||
if (save.activeClassId !== 1) {
|
||||
cd.talentPoints = Math.min(
|
||||
profile.maxTalentPoints,
|
||||
cd.talentPoints + refunded,
|
||||
)
|
||||
}
|
||||
store.writeSave(save)
|
||||
return buildProfile(save)
|
||||
},
|
||||
@@ -1163,11 +1263,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
||||
const profile = buildProfile(save)
|
||||
const recipe = profile.craftingRecipes.find((candidate) => candidate.id === recipeId)
|
||||
if (!recipe) throw new Error('That crafting recipe does not exist.')
|
||||
const requiresUpgrade = profile.craftingRecipes.some((candidate) =>
|
||||
candidate.sourceEncounterId === recipe.sourceEncounterId
|
||||
&& candidate.item.slot === recipe.item.slot
|
||||
&& candidate.item.itemLevel < recipe.item.itemLevel,
|
||||
)
|
||||
const requiresUpgrade = !DIRECT_CRAFT_ITEM_LEVELS.has(recipe.item.itemLevel)
|
||||
if (requiresUpgrade) throw new Error('Upgrade the previous item tier instead.')
|
||||
const missing = recipe.components.find((component) => component.owned < component.quantity)
|
||||
if (missing) {
|
||||
@@ -1363,7 +1459,7 @@ const cachedOnlineRepository: GameRepository = {
|
||||
},
|
||||
loadProfile: () => cachedOnlineLocalRepository.loadProfile(),
|
||||
saveProfile: (classId, abilitySlots) => cachedOnlineLocalRepository.saveProfile(classId, abilitySlots),
|
||||
completeDungeon: (dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds) =>
|
||||
completeDungeon: (dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds, hardMode) =>
|
||||
cachedOnlineLocalRepository.completeDungeon(
|
||||
dungeonId,
|
||||
difficultyId,
|
||||
@@ -1372,6 +1468,7 @@ const cachedOnlineRepository: GameRepository = {
|
||||
completedPart,
|
||||
startPart,
|
||||
partDurationSeconds,
|
||||
hardMode,
|
||||
),
|
||||
completeRoguelike: (dungeonId, difficultyId, encountersCleared, resourceSpent, durationSeconds, options) =>
|
||||
cachedOnlineLocalRepository.completeRoguelike(
|
||||
|
||||
+18
-17
@@ -35,6 +35,7 @@ export const INPUT_ACTIONS = [
|
||||
'targetParty5',
|
||||
'targetParty6',
|
||||
'toggleTargetGroup',
|
||||
'toggleSpeed',
|
||||
'pause',
|
||||
] as const
|
||||
|
||||
@@ -63,6 +64,7 @@ export const ACTION_LABELS: Record<InputAction, string> = {
|
||||
targetParty5: 'Target Party Member 5',
|
||||
targetParty6: 'Target Party Member 6',
|
||||
toggleTargetGroup: 'Switch Raid Target Group',
|
||||
toggleSpeed: 'Toggle 2x Speed',
|
||||
pause: 'Pause Menu',
|
||||
}
|
||||
|
||||
@@ -89,6 +91,7 @@ export const DEFAULT_BINDINGS: Record<InputDevice, InputBindings> = {
|
||||
targetParty5: 'F5',
|
||||
targetParty6: 'F6',
|
||||
toggleTargetGroup: 'Tab',
|
||||
toggleSpeed: 'Backquote',
|
||||
pause: 'Escape',
|
||||
},
|
||||
controller: {
|
||||
@@ -111,8 +114,9 @@ export const DEFAULT_BINDINGS: Record<InputDevice, InputBindings> = {
|
||||
targetParty3: 'Button15',
|
||||
targetParty4: 'Button13',
|
||||
targetParty5: 'Button4',
|
||||
targetParty6: 'Button11',
|
||||
targetParty6: 'Button10',
|
||||
toggleTargetGroup: 'Button6',
|
||||
toggleSpeed: 'Button11',
|
||||
pause: 'Button9',
|
||||
},
|
||||
}
|
||||
@@ -121,7 +125,6 @@ const STORAGE_KEY = 'ashen-halls-input-bindings-v1'
|
||||
const PREFERENCES_STORAGE_KEY = 'ashen-halls-input-preferences-v1'
|
||||
const GAME_ACTION_EVENT = 'ashen-halls-game-action'
|
||||
const NATIVE_CONTROLLER_EVENT = 'ashen-halls-native-controller'
|
||||
const COMBAT_TARGET_NAVIGATION_THROTTLE_MS = 220
|
||||
|
||||
type CaptureState = {
|
||||
device: InputDevice
|
||||
@@ -146,7 +149,8 @@ const InputContext = createContext<InputContextValue | null>(null)
|
||||
function loadBindings(): Record<InputDevice, InputBindings> {
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') as Partial<Record<InputDevice, Partial<InputBindings>>>
|
||||
const controller = { ...DEFAULT_BINDINGS.controller, ...saved.controller }
|
||||
const savedController = saved.controller
|
||||
const controller = { ...DEFAULT_BINDINGS.controller, ...savedController }
|
||||
const usesLegacyAbilityDefaults = [
|
||||
'Button2',
|
||||
'Button3',
|
||||
@@ -167,6 +171,15 @@ function loadBindings(): Record<InputDevice, InputBindings> {
|
||||
ability6: DEFAULT_BINDINGS.controller.ability6,
|
||||
})
|
||||
}
|
||||
if (savedController?.toggleSpeed === 'Button7') {
|
||||
controller.toggleSpeed = DEFAULT_BINDINGS.controller.toggleSpeed
|
||||
}
|
||||
if (savedController?.ability6 === 'Button10') {
|
||||
controller.ability6 = DEFAULT_BINDINGS.controller.ability6
|
||||
}
|
||||
if (savedController?.targetParty6 === 'Button11') {
|
||||
controller.targetParty6 = DEFAULT_BINDINGS.controller.targetParty6
|
||||
}
|
||||
return {
|
||||
pc: { ...DEFAULT_BINDINGS.pc, ...saved.pc },
|
||||
controller,
|
||||
@@ -277,14 +290,6 @@ function hasUiOverlay() {
|
||||
).some(isVisible)
|
||||
}
|
||||
|
||||
function isCombatTargetAction(action: InputAction) {
|
||||
return action.startsWith('navigate')
|
||||
|| action.startsWith('targetParty')
|
||||
|| action === 'previousTarget'
|
||||
|| action === 'nextTarget'
|
||||
|| action === 'toggleTargetGroup'
|
||||
}
|
||||
|
||||
const BUTTON_LABELS: Record<number, string> = {
|
||||
0: 'A / Cross',
|
||||
1: 'B / Circle',
|
||||
@@ -398,7 +403,6 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
||||
const keyboardInputRef = useRef(keyboardInput)
|
||||
const previousTokensRef = useRef(new Set<string>())
|
||||
const repeatRef = useRef<Record<string, number>>({})
|
||||
const lastCombatNavigationRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
bindingsRef.current = bindings
|
||||
@@ -445,11 +449,6 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
||||
const dispatchAction = useCallback((action: InputAction, device: InputDevice) => {
|
||||
const uiOverlay = hasUiOverlay()
|
||||
const combatActive = Boolean(document.querySelector('[data-combat-active="true"]'))
|
||||
if (combatActive && !uiOverlay && isCombatTargetAction(action)) {
|
||||
const now = performance.now()
|
||||
if (now - lastCombatNavigationRef.current < COMBAT_TARGET_NAVIGATION_THROTTLE_MS) return
|
||||
lastCombatNavigationRef.current = now
|
||||
}
|
||||
|
||||
setLastDevice(device)
|
||||
document.documentElement.dataset.inputDevice = device
|
||||
@@ -519,9 +518,11 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
||||
'targetParty5',
|
||||
'targetParty6',
|
||||
'toggleTargetGroup',
|
||||
'toggleSpeed',
|
||||
] satisfies InputAction[]
|
||||
const combatPriority = [
|
||||
'pause',
|
||||
'toggleSpeed',
|
||||
'ability1',
|
||||
'ability2',
|
||||
'ability3',
|
||||
|
||||
+407
-134
@@ -62,7 +62,7 @@
|
||||
"power": 18,
|
||||
"unlockLevel": 1,
|
||||
"glyph": "*",
|
||||
"description": "Restores health to every living party member."
|
||||
"description": "Restores health to up to 4 injured party members."
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
@@ -101,7 +101,7 @@
|
||||
"power": 28,
|
||||
"unlockLevel": 5,
|
||||
"glyph": "D",
|
||||
"description": "A brilliant wave of healing for the entire party."
|
||||
"description": "A brilliant wave of healing for up to 4 injured allies."
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
@@ -140,161 +140,144 @@
|
||||
"power": 48,
|
||||
"unlockLevel": 20,
|
||||
"glyph": "A",
|
||||
"description": "Floods the party with the full strength of dawn."
|
||||
"description": "Floods up to 4 injured allies with the full strength of dawn."
|
||||
}
|
||||
],
|
||||
"talents": [
|
||||
{
|
||||
"id": 1,
|
||||
"classId": 1,
|
||||
"slug": "bright-reserves",
|
||||
"name": "Bright Reserves",
|
||||
"maxRank": 5,
|
||||
"slug": "shield-applies-renew",
|
||||
"name": "Shield applies Renew",
|
||||
"maxRank": 1,
|
||||
"tier": 1,
|
||||
"branch": 1,
|
||||
"prerequisiteTalentId": null,
|
||||
"prerequisiteRank": 0,
|
||||
"prerequisiteName": null,
|
||||
"effectType": "max_resource",
|
||||
"effectValuePerRank": 2,
|
||||
"glyph": "M",
|
||||
"description": "Increases maximum Mana by 2 per rank.",
|
||||
"effectType": "shield_applies_renew",
|
||||
"effectValuePerRank": 0,
|
||||
"glyph": "~",
|
||||
"description": "Sun Ward also applies Renew to the target.",
|
||||
"rank": 0
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"classId": 1,
|
||||
"slug": "gentle-dawn",
|
||||
"name": "Gentle Dawn",
|
||||
"maxRank": 5,
|
||||
"slug": "mend-applies-renew",
|
||||
"name": "Mend applies Renew",
|
||||
"maxRank": 1,
|
||||
"tier": 1,
|
||||
"branch": 2,
|
||||
"prerequisiteTalentId": null,
|
||||
"prerequisiteRank": 0,
|
||||
"prerequisiteName": null,
|
||||
"effectType": "hot_power_percent",
|
||||
"effectValuePerRank": 2,
|
||||
"effectType": "mend_applies_renew",
|
||||
"effectValuePerRank": 0,
|
||||
"glyph": "~",
|
||||
"description": "Increases healing-over-time power by 2% per rank.",
|
||||
"description": "Mend also applies Renew to the target.",
|
||||
"rank": 0
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"classId": 1,
|
||||
"slug": "steady-hands",
|
||||
"name": "Steady Hands",
|
||||
"maxRank": 5,
|
||||
"slug": "mend-adds-shield",
|
||||
"name": "Mend adds Shield",
|
||||
"maxRank": 1,
|
||||
"tier": 1,
|
||||
"branch": 3,
|
||||
"prerequisiteTalentId": null,
|
||||
"prerequisiteRank": 0,
|
||||
"prerequisiteName": null,
|
||||
"effectType": "direct_heal_percent",
|
||||
"effectValuePerRank": 2,
|
||||
"glyph": "+",
|
||||
"description": "Increases direct healing by 2% per rank.",
|
||||
"effectType": "mend_applies_shield",
|
||||
"effectValuePerRank": 0,
|
||||
"glyph": "O",
|
||||
"description": "Mend also applies a shield at 50% strength to the target.",
|
||||
"rank": 0
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"classId": 1,
|
||||
"slug": "overflowing-light",
|
||||
"name": "Overflowing Light",
|
||||
"maxRank": 5,
|
||||
"tier": 2,
|
||||
"branch": 1,
|
||||
"prerequisiteTalentId": 1,
|
||||
"prerequisiteRank": 3,
|
||||
"prerequisiteName": "Bright Reserves",
|
||||
"effectType": "resource_regen_percent",
|
||||
"effectValuePerRank": 2,
|
||||
"slug": "radiance-adds-shield",
|
||||
"name": "Radiance adds Shield",
|
||||
"maxRank": 1,
|
||||
"tier": 1,
|
||||
"branch": 4,
|
||||
"prerequisiteTalentId": null,
|
||||
"prerequisiteRank": 0,
|
||||
"prerequisiteName": null,
|
||||
"effectType": "radiance_applies_shield",
|
||||
"effectValuePerRank": 0,
|
||||
"glyph": "O",
|
||||
"description": "Improves Mana regeneration by 2% per rank. Requires Bright Reserves rank 3.",
|
||||
"description": "Radiance applies a shield at 30% strength to affected party members.",
|
||||
"rank": 0
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"classId": 1,
|
||||
"slug": "lingering-rays",
|
||||
"name": "Lingering Rays",
|
||||
"maxRank": 5,
|
||||
"tier": 2,
|
||||
"branch": 2,
|
||||
"prerequisiteTalentId": 2,
|
||||
"prerequisiteRank": 3,
|
||||
"prerequisiteName": "Gentle Dawn",
|
||||
"effectType": "hot_duration_percent",
|
||||
"effectValuePerRank": 4,
|
||||
"glyph": "L",
|
||||
"description": "Extends healing-over-time duration by 4% per rank. Requires Gentle Dawn rank 3.",
|
||||
"slug": "radiance-applies-renew",
|
||||
"name": "Radiance applies Renew",
|
||||
"maxRank": 1,
|
||||
"tier": 1,
|
||||
"branch": 5,
|
||||
"prerequisiteTalentId": null,
|
||||
"prerequisiteRank": 0,
|
||||
"prerequisiteName": null,
|
||||
"effectType": "radiance_applies_renew",
|
||||
"effectValuePerRank": 0,
|
||||
"glyph": "~",
|
||||
"description": "Radiance applies Renew at 50% duration to affected party members.",
|
||||
"rank": 0
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"classId": 1,
|
||||
"slug": "radiant-precision",
|
||||
"name": "Radiant Precision",
|
||||
"maxRank": 5,
|
||||
"tier": 2,
|
||||
"branch": 3,
|
||||
"prerequisiteTalentId": 10,
|
||||
"prerequisiteRank": 3,
|
||||
"prerequisiteName": "Steady Hands",
|
||||
"effectType": "critical_heal_percent",
|
||||
"effectValuePerRank": 1,
|
||||
"glyph": "!",
|
||||
"description": "Adds 1% healing critical chance per rank. Requires Steady Hands rank 3.",
|
||||
"slug": "shielded-damage-reduction",
|
||||
"name": "Shielded takes less",
|
||||
"maxRank": 1,
|
||||
"tier": 1,
|
||||
"branch": 6,
|
||||
"prerequisiteTalentId": null,
|
||||
"prerequisiteRank": 0,
|
||||
"prerequisiteName": null,
|
||||
"effectType": "shielded_damage_reduction",
|
||||
"effectValuePerRank": 0,
|
||||
"glyph": "D",
|
||||
"description": "While shielded, the target receives 20% less damage.",
|
||||
"rank": 0
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"classId": 1,
|
||||
"slug": "sunlit-aegis",
|
||||
"name": "Sunlit Aegis",
|
||||
"maxRank": 3,
|
||||
"tier": 3,
|
||||
"branch": 1,
|
||||
"prerequisiteTalentId": 11,
|
||||
"prerequisiteRank": 5,
|
||||
"prerequisiteName": "Overflowing Light",
|
||||
"effectType": "absorb_power_percent",
|
||||
"effectValuePerRank": 5,
|
||||
"glyph": "A",
|
||||
"description": "Strengthens absorb effects by 5% per rank. Requires Overflowing Light rank 5.",
|
||||
"slug": "shielded-healing-bonus",
|
||||
"name": "Shielded healing boost",
|
||||
"maxRank": 1,
|
||||
"tier": 1,
|
||||
"branch": 7,
|
||||
"prerequisiteTalentId": null,
|
||||
"prerequisiteRank": 0,
|
||||
"prerequisiteName": null,
|
||||
"effectType": "shielded_healing_bonus",
|
||||
"effectValuePerRank": 0,
|
||||
"glyph": "+",
|
||||
"description": "While shielded, the target receives 20% more healing.",
|
||||
"rank": 0
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"classId": 1,
|
||||
"slug": "shared-dawn",
|
||||
"name": "Shared Dawn",
|
||||
"maxRank": 3,
|
||||
"tier": 3,
|
||||
"branch": 2,
|
||||
"prerequisiteTalentId": 12,
|
||||
"prerequisiteRank": 5,
|
||||
"prerequisiteName": "Lingering Rays",
|
||||
"effectType": "party_heal_percent",
|
||||
"effectValuePerRank": 5,
|
||||
"glyph": "*",
|
||||
"description": "Increases party-wide healing by 5% per rank. Requires Lingering Rays rank 5.",
|
||||
"rank": 0
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
"classId": 1,
|
||||
"slug": "miracle-worker",
|
||||
"name": "Miracle Worker",
|
||||
"slug": "mend-reduces-radiance",
|
||||
"name": "Mend lowers Radiance",
|
||||
"maxRank": 1,
|
||||
"tier": 4,
|
||||
"branch": 2,
|
||||
"prerequisiteTalentId": 15,
|
||||
"prerequisiteRank": 3,
|
||||
"prerequisiteName": "Shared Dawn",
|
||||
"effectType": "cooldown_reduction_percent",
|
||||
"effectValuePerRank": 10,
|
||||
"glyph": "S",
|
||||
"description": "Reduces major healing cooldowns by 10%. Requires Shared Dawn rank 3.",
|
||||
"tier": 1,
|
||||
"branch": 8,
|
||||
"prerequisiteTalentId": null,
|
||||
"prerequisiteRank": 0,
|
||||
"prerequisiteName": null,
|
||||
"effectType": "mend_reduces_radiance_cooldown",
|
||||
"effectValuePerRank": 0,
|
||||
"glyph": "*",
|
||||
"description": "Casting Mend reduces the cooldown of Radiance by 2 seconds.",
|
||||
"rank": 0
|
||||
}
|
||||
]
|
||||
@@ -313,13 +296,13 @@
|
||||
"classId": 2,
|
||||
"slug": "verdant-touch",
|
||||
"name": "Verdant Touch",
|
||||
"spellType": "direct_heal",
|
||||
"spellType": "direct_hot",
|
||||
"cost": 5,
|
||||
"cooldown": 0.5,
|
||||
"power": 28,
|
||||
"power": 20,
|
||||
"unlockLevel": 1,
|
||||
"glyph": "+",
|
||||
"description": "A quick pulse of living energy."
|
||||
"description": "A weaker direct heal that also plants a stacking heal over time."
|
||||
},
|
||||
{
|
||||
"id": 21,
|
||||
@@ -338,27 +321,27 @@
|
||||
"id": 22,
|
||||
"classId": 2,
|
||||
"slug": "wild-bloom",
|
||||
"name": "Wild Bloom",
|
||||
"spellType": "party_heal",
|
||||
"name": "Wild Growth",
|
||||
"spellType": "party_hot",
|
||||
"cost": 12,
|
||||
"cooldown": 8,
|
||||
"power": 17,
|
||||
"power": 14,
|
||||
"unlockLevel": 1,
|
||||
"glyph": "*",
|
||||
"description": "Restorative growth spreads through the party."
|
||||
"description": "Applies a stacking heal over time to up to 4 injured allies."
|
||||
},
|
||||
{
|
||||
"id": 23,
|
||||
"classId": 2,
|
||||
"slug": "barkskin",
|
||||
"name": "Barkskin",
|
||||
"spellType": "absorb",
|
||||
"cost": 8,
|
||||
"cooldown": 7,
|
||||
"power": 34,
|
||||
"spellType": "damage_reduction",
|
||||
"cost": 10,
|
||||
"cooldown": 14,
|
||||
"power": 0,
|
||||
"unlockLevel": 1,
|
||||
"glyph": "B",
|
||||
"description": "Wraps an ally in protective living bark."
|
||||
"description": "Reduces the target ally's damage taken by 50% for 8 seconds."
|
||||
},
|
||||
{
|
||||
"id": 24,
|
||||
@@ -378,13 +361,13 @@
|
||||
"classId": 2,
|
||||
"slug": "ancient-grove",
|
||||
"name": "Ancient Grove",
|
||||
"spellType": "party_heal",
|
||||
"spellType": "party_hot",
|
||||
"cost": 17,
|
||||
"cooldown": 12,
|
||||
"power": 31,
|
||||
"power": 24,
|
||||
"unlockLevel": 5,
|
||||
"glyph": "T",
|
||||
"description": "Briefly summons the shelter of an ancient grove."
|
||||
"description": "Applies a stronger stacking heal over time to up to 4 injured allies."
|
||||
}
|
||||
],
|
||||
"talents": [
|
||||
@@ -569,27 +552,27 @@
|
||||
"id": 31,
|
||||
"classId": 3,
|
||||
"slug": "echo-rune",
|
||||
"name": "Echo Rune",
|
||||
"spellType": "heal_over_time",
|
||||
"name": "Mending Rune",
|
||||
"spellType": "bounce_heal",
|
||||
"cost": 7,
|
||||
"cooldown": 0.5,
|
||||
"power": 12,
|
||||
"power": 18,
|
||||
"unlockLevel": 1,
|
||||
"glyph": "e",
|
||||
"description": "Repeats a restorative rune over several moments."
|
||||
"description": "Places a rune that heals when the ally takes damage, then jumps 4 times."
|
||||
},
|
||||
{
|
||||
"id": 32,
|
||||
"classId": 3,
|
||||
"slug": "concordance",
|
||||
"name": "Concordance",
|
||||
"spellType": "party_heal",
|
||||
"spellType": "party_absorb",
|
||||
"cost": 12,
|
||||
"cooldown": 8,
|
||||
"power": 18,
|
||||
"power": 28,
|
||||
"unlockLevel": 1,
|
||||
"glyph": "*",
|
||||
"description": "Links the party through a shared healing pattern."
|
||||
"description": "Shields up to 4 injured allies through a shared barrier pattern."
|
||||
},
|
||||
{
|
||||
"id": 33,
|
||||
@@ -622,13 +605,13 @@
|
||||
"classId": 3,
|
||||
"slug": "grand-design",
|
||||
"name": "Grand Design",
|
||||
"spellType": "party_heal",
|
||||
"spellType": "party_absorb",
|
||||
"cost": 16,
|
||||
"cooldown": 12,
|
||||
"power": 30,
|
||||
"power": 42,
|
||||
"unlockLevel": 5,
|
||||
"glyph": "R",
|
||||
"description": "Activates a prepared network of restorative runes."
|
||||
"description": "Raises a stronger shared barrier around up to 4 injured allies."
|
||||
}
|
||||
],
|
||||
"talents": [
|
||||
@@ -1221,7 +1204,7 @@
|
||||
"slug": "cinderstep-boots",
|
||||
"name": "Honed Yian Kut-Ku Boots",
|
||||
"slot": "boots",
|
||||
"rarity": "common",
|
||||
"rarity": "uncommon",
|
||||
"itemLevel": 5,
|
||||
"healingPower": 3,
|
||||
"maxResourceBonus": 0,
|
||||
@@ -1261,7 +1244,7 @@
|
||||
"slug": "wardens-cinderwrap",
|
||||
"name": "Honed Bulldrome Chest",
|
||||
"slot": "chest",
|
||||
"rarity": "common",
|
||||
"rarity": "uncommon",
|
||||
"itemLevel": 5,
|
||||
"healingPower": 3,
|
||||
"maxResourceBonus": 0,
|
||||
@@ -1301,7 +1284,7 @@
|
||||
"slug": "furnace-tenders-wraps",
|
||||
"name": "Honed Bulldrome Gloves",
|
||||
"slot": "gloves",
|
||||
"rarity": "common",
|
||||
"rarity": "uncommon",
|
||||
"itemLevel": 5,
|
||||
"healingPower": 3,
|
||||
"maxResourceBonus": 2,
|
||||
@@ -1341,7 +1324,7 @@
|
||||
"slug": "adepts-hood",
|
||||
"name": "Honed Bulldrome Helmet",
|
||||
"slot": "helmet",
|
||||
"rarity": "common",
|
||||
"rarity": "uncommon",
|
||||
"itemLevel": 5,
|
||||
"healingPower": 3,
|
||||
"maxResourceBonus": 4,
|
||||
@@ -1381,7 +1364,7 @@
|
||||
"slug": "sootglass-pendant",
|
||||
"name": "Honed Rathian Necklace",
|
||||
"slot": "necklace",
|
||||
"rarity": "common",
|
||||
"rarity": "uncommon",
|
||||
"itemLevel": 5,
|
||||
"healingPower": 4,
|
||||
"maxResourceBonus": 4,
|
||||
@@ -1421,7 +1404,7 @@
|
||||
"slug": "ashwalker-legwraps",
|
||||
"name": "Honed Rathian Pants",
|
||||
"slot": "pants",
|
||||
"rarity": "common",
|
||||
"rarity": "uncommon",
|
||||
"itemLevel": 5,
|
||||
"healingPower": 3,
|
||||
"maxResourceBonus": 3,
|
||||
@@ -1461,7 +1444,7 @@
|
||||
"slug": "emberglass-sigil",
|
||||
"name": "Honed Yian Kut-Ku Ring",
|
||||
"slot": "ring",
|
||||
"rarity": "common",
|
||||
"rarity": "uncommon",
|
||||
"itemLevel": 5,
|
||||
"healingPower": 4,
|
||||
"maxResourceBonus": 5,
|
||||
@@ -1501,7 +1484,7 @@
|
||||
"slug": "warden-ember",
|
||||
"name": "Honed Yian Kut-Ku Trinket",
|
||||
"slot": "trinket",
|
||||
"rarity": "common",
|
||||
"rarity": "uncommon",
|
||||
"itemLevel": 5,
|
||||
"healingPower": 4,
|
||||
"maxResourceBonus": 4,
|
||||
@@ -1541,7 +1524,7 @@
|
||||
"slug": "ashwood-crook",
|
||||
"name": "Honed Rathian Weapon",
|
||||
"slot": "weapon",
|
||||
"rarity": "common",
|
||||
"rarity": "uncommon",
|
||||
"itemLevel": 5,
|
||||
"healingPower": 5,
|
||||
"maxResourceBonus": 0,
|
||||
@@ -4312,7 +4295,152 @@
|
||||
"description": "A three-boss hunt featuring Nargacuga, Azuros, and Diablos.",
|
||||
"locationName": "The Monster Frontier",
|
||||
"difficulties": [],
|
||||
"encounters": [],
|
||||
"encounters": [
|
||||
{
|
||||
"id": 401,
|
||||
"dungeonId": 4,
|
||||
"sequence": 1,
|
||||
"slug": "nargacuga-dungeon-approach",
|
||||
"enemyName": "Nargacuga Approach",
|
||||
"encounterType": "trash",
|
||||
"maxHealth": 1325,
|
||||
"damage": 18,
|
||||
"tankDamage": 10,
|
||||
"partyDamage": 28,
|
||||
"description": "Hunters clear the path before Nargacuga.",
|
||||
"imageUrl": "/boss-placeholder.svg",
|
||||
"isBoss": false,
|
||||
"lootTables": []
|
||||
},
|
||||
{
|
||||
"id": 402,
|
||||
"dungeonId": 4,
|
||||
"sequence": 2,
|
||||
"slug": "nargacuga-dungeon-guardians",
|
||||
"enemyName": "Nargacuga Guardians",
|
||||
"encounterType": "trash",
|
||||
"maxHealth": 1395,
|
||||
"damage": 19,
|
||||
"tankDamage": 11,
|
||||
"partyDamage": 30,
|
||||
"description": "Hunters clear the path before Nargacuga.",
|
||||
"imageUrl": "/boss-placeholder.svg",
|
||||
"isBoss": false,
|
||||
"lootTables": []
|
||||
},
|
||||
{
|
||||
"id": 403,
|
||||
"dungeonId": 4,
|
||||
"sequence": 3,
|
||||
"slug": "nargacuga-dungeon-boss",
|
||||
"enemyName": "Nargacuga",
|
||||
"encounterType": "boss",
|
||||
"maxHealth": 1655,
|
||||
"damage": 23,
|
||||
"tankDamage": 15,
|
||||
"partyDamage": 34,
|
||||
"description": "Nargacuga drops boss coins for item level 15 crafting.",
|
||||
"imageUrl": "/boss-placeholder.svg",
|
||||
"isBoss": true,
|
||||
"lootTables": []
|
||||
},
|
||||
{
|
||||
"id": 404,
|
||||
"dungeonId": 4,
|
||||
"sequence": 4,
|
||||
"slug": "azuros-dungeon-approach",
|
||||
"enemyName": "Azuros Approach",
|
||||
"encounterType": "trash",
|
||||
"maxHealth": 1445,
|
||||
"damage": 20,
|
||||
"tankDamage": 12,
|
||||
"partyDamage": 31,
|
||||
"description": "Hunters clear the path before Azuros.",
|
||||
"imageUrl": "/boss-placeholder.svg",
|
||||
"isBoss": false,
|
||||
"lootTables": []
|
||||
},
|
||||
{
|
||||
"id": 405,
|
||||
"dungeonId": 4,
|
||||
"sequence": 5,
|
||||
"slug": "azuros-dungeon-guardians",
|
||||
"enemyName": "Azuros Guardians",
|
||||
"encounterType": "trash",
|
||||
"maxHealth": 1515,
|
||||
"damage": 21,
|
||||
"tankDamage": 13,
|
||||
"partyDamage": 33,
|
||||
"description": "Hunters clear the path before Azuros.",
|
||||
"imageUrl": "/boss-placeholder.svg",
|
||||
"isBoss": false,
|
||||
"lootTables": []
|
||||
},
|
||||
{
|
||||
"id": 406,
|
||||
"dungeonId": 4,
|
||||
"sequence": 6,
|
||||
"slug": "azuros-dungeon-boss",
|
||||
"enemyName": "Azuros",
|
||||
"encounterType": "boss",
|
||||
"maxHealth": 1775,
|
||||
"damage": 25,
|
||||
"tankDamage": 17,
|
||||
"partyDamage": 37,
|
||||
"description": "Azuros drops boss coins for item level 15 crafting.",
|
||||
"imageUrl": "/boss-placeholder.svg",
|
||||
"isBoss": true,
|
||||
"lootTables": []
|
||||
},
|
||||
{
|
||||
"id": 407,
|
||||
"dungeonId": 4,
|
||||
"sequence": 7,
|
||||
"slug": "diablos-dungeon-approach",
|
||||
"enemyName": "Diablos Approach",
|
||||
"encounterType": "trash",
|
||||
"maxHealth": 1565,
|
||||
"damage": 22,
|
||||
"tankDamage": 14,
|
||||
"partyDamage": 34,
|
||||
"description": "Hunters clear the path before Diablos.",
|
||||
"imageUrl": "/boss-placeholder.svg",
|
||||
"isBoss": false,
|
||||
"lootTables": []
|
||||
},
|
||||
{
|
||||
"id": 408,
|
||||
"dungeonId": 4,
|
||||
"sequence": 8,
|
||||
"slug": "diablos-dungeon-guardians",
|
||||
"enemyName": "Diablos Guardians",
|
||||
"encounterType": "trash",
|
||||
"maxHealth": 1635,
|
||||
"damage": 23,
|
||||
"tankDamage": 15,
|
||||
"partyDamage": 36,
|
||||
"description": "Hunters clear the path before Diablos.",
|
||||
"imageUrl": "/boss-placeholder.svg",
|
||||
"isBoss": false,
|
||||
"lootTables": []
|
||||
},
|
||||
{
|
||||
"id": 409,
|
||||
"dungeonId": 4,
|
||||
"sequence": 9,
|
||||
"slug": "diablos-dungeon-boss",
|
||||
"enemyName": "Diablos",
|
||||
"encounterType": "boss",
|
||||
"maxHealth": 1895,
|
||||
"damage": 27,
|
||||
"tankDamage": 19,
|
||||
"partyDamage": 40,
|
||||
"description": "Diablos drops boss coins for item level 15 crafting.",
|
||||
"imageUrl": "/boss-placeholder.svg",
|
||||
"isBoss": true,
|
||||
"lootTables": []
|
||||
}
|
||||
],
|
||||
"completionLoot": [],
|
||||
"leaderboard": [],
|
||||
"leaderboards": {
|
||||
@@ -4334,7 +4462,152 @@
|
||||
"description": "A raid-scale hunt against Nargacuga, Azuros, and Diablos.",
|
||||
"locationName": "The Monster Frontier",
|
||||
"difficulties": [],
|
||||
"encounters": [],
|
||||
"encounters": [
|
||||
{
|
||||
"id": 501,
|
||||
"dungeonId": 5,
|
||||
"sequence": 1,
|
||||
"slug": "nargacuga-raid-approach",
|
||||
"enemyName": "Nargacuga Approach",
|
||||
"encounterType": "trash",
|
||||
"maxHealth": 2225,
|
||||
"damage": 18,
|
||||
"tankDamage": 10,
|
||||
"partyDamage": 52,
|
||||
"description": "Hunters clear the raid path before Nargacuga.",
|
||||
"imageUrl": "/boss-placeholder.svg",
|
||||
"isBoss": false,
|
||||
"lootTables": []
|
||||
},
|
||||
{
|
||||
"id": 502,
|
||||
"dungeonId": 5,
|
||||
"sequence": 2,
|
||||
"slug": "nargacuga-raid-guardians",
|
||||
"enemyName": "Nargacuga Guardians",
|
||||
"encounterType": "trash",
|
||||
"maxHealth": 2295,
|
||||
"damage": 19,
|
||||
"tankDamage": 11,
|
||||
"partyDamage": 54,
|
||||
"description": "Hunters clear the raid path before Nargacuga.",
|
||||
"imageUrl": "/boss-placeholder.svg",
|
||||
"isBoss": false,
|
||||
"lootTables": []
|
||||
},
|
||||
{
|
||||
"id": 503,
|
||||
"dungeonId": 5,
|
||||
"sequence": 3,
|
||||
"slug": "nargacuga-raid-boss",
|
||||
"enemyName": "Nargacuga",
|
||||
"encounterType": "boss",
|
||||
"maxHealth": 2555,
|
||||
"damage": 23,
|
||||
"tankDamage": 15,
|
||||
"partyDamage": 58,
|
||||
"description": "Nargacuga drops boss coins for item level 15 crafting.",
|
||||
"imageUrl": "/boss-placeholder.svg",
|
||||
"isBoss": true,
|
||||
"lootTables": []
|
||||
},
|
||||
{
|
||||
"id": 504,
|
||||
"dungeonId": 5,
|
||||
"sequence": 4,
|
||||
"slug": "azuros-raid-approach",
|
||||
"enemyName": "Azuros Approach",
|
||||
"encounterType": "trash",
|
||||
"maxHealth": 2345,
|
||||
"damage": 20,
|
||||
"tankDamage": 12,
|
||||
"partyDamage": 55,
|
||||
"description": "Hunters clear the raid path before Azuros.",
|
||||
"imageUrl": "/boss-placeholder.svg",
|
||||
"isBoss": false,
|
||||
"lootTables": []
|
||||
},
|
||||
{
|
||||
"id": 505,
|
||||
"dungeonId": 5,
|
||||
"sequence": 5,
|
||||
"slug": "azuros-raid-guardians",
|
||||
"enemyName": "Azuros Guardians",
|
||||
"encounterType": "trash",
|
||||
"maxHealth": 2415,
|
||||
"damage": 21,
|
||||
"tankDamage": 13,
|
||||
"partyDamage": 57,
|
||||
"description": "Hunters clear the raid path before Azuros.",
|
||||
"imageUrl": "/boss-placeholder.svg",
|
||||
"isBoss": false,
|
||||
"lootTables": []
|
||||
},
|
||||
{
|
||||
"id": 506,
|
||||
"dungeonId": 5,
|
||||
"sequence": 6,
|
||||
"slug": "azuros-raid-boss",
|
||||
"enemyName": "Azuros",
|
||||
"encounterType": "boss",
|
||||
"maxHealth": 2675,
|
||||
"damage": 25,
|
||||
"tankDamage": 17,
|
||||
"partyDamage": 61,
|
||||
"description": "Azuros drops boss coins for item level 15 crafting.",
|
||||
"imageUrl": "/boss-placeholder.svg",
|
||||
"isBoss": true,
|
||||
"lootTables": []
|
||||
},
|
||||
{
|
||||
"id": 507,
|
||||
"dungeonId": 5,
|
||||
"sequence": 7,
|
||||
"slug": "diablos-raid-approach",
|
||||
"enemyName": "Diablos Approach",
|
||||
"encounterType": "trash",
|
||||
"maxHealth": 2465,
|
||||
"damage": 22,
|
||||
"tankDamage": 14,
|
||||
"partyDamage": 58,
|
||||
"description": "Hunters clear the raid path before Diablos.",
|
||||
"imageUrl": "/boss-placeholder.svg",
|
||||
"isBoss": false,
|
||||
"lootTables": []
|
||||
},
|
||||
{
|
||||
"id": 508,
|
||||
"dungeonId": 5,
|
||||
"sequence": 8,
|
||||
"slug": "diablos-raid-guardians",
|
||||
"enemyName": "Diablos Guardians",
|
||||
"encounterType": "trash",
|
||||
"maxHealth": 2535,
|
||||
"damage": 23,
|
||||
"tankDamage": 15,
|
||||
"partyDamage": 60,
|
||||
"description": "Hunters clear the raid path before Diablos.",
|
||||
"imageUrl": "/boss-placeholder.svg",
|
||||
"isBoss": false,
|
||||
"lootTables": []
|
||||
},
|
||||
{
|
||||
"id": 509,
|
||||
"dungeonId": 5,
|
||||
"sequence": 9,
|
||||
"slug": "diablos-raid-boss",
|
||||
"enemyName": "Diablos",
|
||||
"encounterType": "boss",
|
||||
"maxHealth": 2795,
|
||||
"damage": 27,
|
||||
"tankDamage": 19,
|
||||
"partyDamage": 64,
|
||||
"description": "Diablos drops boss coins for item level 15 crafting.",
|
||||
"imageUrl": "/boss-placeholder.svg",
|
||||
"isBoss": true,
|
||||
"lootTables": []
|
||||
}
|
||||
],
|
||||
"completionLoot": [],
|
||||
"leaderboard": [],
|
||||
"leaderboards": {
|
||||
|
||||
@@ -319,6 +319,7 @@ export async function completeDungeon(
|
||||
completedPart?: number,
|
||||
startPart?: number,
|
||||
partDurationSeconds?: [number, number, number],
|
||||
hardMode?: boolean,
|
||||
): Promise<DungeonReward> {
|
||||
return activeGameRepository().completeDungeon(
|
||||
dungeonId,
|
||||
@@ -328,6 +329,7 @@ export async function completeDungeon(
|
||||
completedPart,
|
||||
startPart,
|
||||
partDurationSeconds,
|
||||
hardMode,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user