Compare commits

..

13 Commits

Author SHA1 Message Date
Warren H 5aac39c6c9 Android build v1.0.42 2026-06-20 20:49:46 -04:00
Warren H 224249e372 Android build v1.0.41 2026-06-20 20:42:10 -04:00
Warren H 66f5af4484 Android build v1.0.40 2026-06-20 20:16:20 -04:00
Warren H 8f5a957963 Android build v1.0.39 2026-06-20 20:09:57 -04:00
Warren H f8a1fbc5e2 Android build v1.0.38 2026-06-20 18:06:39 -04:00
Warren H bab2dce6c3 Android build v1.0.37 2026-06-20 17:52:12 -04:00
Warren H cb38042eca Android build v1.0.37 2026-06-20 17:50:57 -04:00
Warren H 753bba581a Android build v1.0.36 2026-06-20 16:57:03 -04:00
Warren H 2300973164 Android build v1.0.35 2026-06-20 16:10:35 -04:00
Warren H 1281be69d8 Android build v1.0.34 2026-06-20 15:08:51 -04:00
Warren H 4fc15ebe9a Android build v1.0.33 2026-06-20 13:21:49 -04:00
Warren H 7313c968e6 Android build v1.0.32 2026-06-20 12:50:48 -04:00
Warren H 207fcd1a15 Android build v1.0.31 2026-06-19 23:14:56 -04:00
28 changed files with 6568 additions and 2496 deletions
+1
View File
@@ -4,5 +4,6 @@
- AYN Thor secondary display: 3.92-inch AMOLED, 1240 x 1080, 60Hz.
- AYN Thor UI sizing must be designed against Android CSS/layout viewport, not physical framebuffer pixels.
- Approximate Thor CSS viewports: main display 960 x 540, secondary display 620 x 540.
- Test top-screen UI only against the main display viewport, and bottom-screen UI only against the secondary display viewport.
- User rebuilds app; do not rebuild APK unless explicitly requested.
- Apply game changes to both web version and mobile app version.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2 -2
View File
@@ -7,8 +7,8 @@ android {
applicationId "com.warren.iwanttoheal"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 48
versionName "1.0.30"
versionCode 62
versionName "1.0.42"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+558 -54
View File
@@ -106,9 +106,8 @@ SET slug = 'rathian',
image_url = COALESCE(NULLIF(image_url, ''), '/boss-placeholder.svg')
WHERE id = 22;
INSERT OR IGNORE INTO mechanics
(id, encounter_id, name, mechanic_type, interval_seconds, power, description)
VALUES
WITH mechanics_seed(id, encounter_id, name, mechanic_type, interval_seconds, power, description) AS (
VALUES
(1, 3, 'Cinder Pulse', 'party_damage', 4.9, 12, 'Deals damage to the full party.'),
(2, 3, 'Searing Mark', 'dispellable_dot', 7.7, 7, 'Marks one target with recurring damage until cleansed.'),
(10, 12, 'Ember Wave', 'party_damage', 5.6, 14, 'A wave of ember energy hits the entire party.'),
@@ -120,7 +119,17 @@ VALUES
(102, 105, 'Pillar of Judgment', 'party_damage', 5.2, 16, 'Columns of flame erupt beneath the raid.'),
(103, 105, 'Inquisitor''s Brand', 'dispellable_dot', 7.8, 10, 'A burning brand persists until cleansed.'),
(104, 108, 'Crownflare', 'party_damage', 4.9, 18, 'The Ember Crown releases a raid-wide flare.'),
(105, 108, 'Royal Decree', 'dispellable_dot', 7.1, 12, 'A lethal decree marks one raider for cleansing.');
(105, 108, 'Royal Decree', 'dispellable_dot', 7.1, 12, 'A lethal decree marks one raider for cleansing.')
)
INSERT OR IGNORE INTO mechanics
(id, encounter_id, name, mechanic_type, interval_seconds, power, description)
SELECT id, encounter_id, name, mechanic_type, interval_seconds, power, description
FROM mechanics_seed
WHERE EXISTS (
SELECT 1
FROM encounters
WHERE encounters.id = mechanics_seed.encounter_id
);
INSERT OR IGNORE INTO classes
(id, slug, name, resource_name, max_resource, theme_color, description)
@@ -134,22 +143,22 @@ INSERT OR IGNORE INTO spells
VALUES
(1, 1, 'mend', 'Mend', 'direct_heal', 5, 0.5, 30, 1, '+', 'A fast, efficient single-target heal.'),
(2, 1, 'renew', 'Renew', 'heal_over_time', 7, 0.5, 12, 1, '~', 'Heals now and continues healing over time.'),
(3, 1, 'radiance', 'Radiance', 'party_heal', 12, 8, 18, 1, '*', 'Restores health to every living party member.'),
(3, 1, 'radiance', 'Radiance', 'party_heal', 12, 8, 18, 1, '*', 'Restores health to up to 4 injured party members.'),
(4, 1, 'sun-ward', 'Sun Ward', 'absorb', 8, 7, 36, 1, 'O', 'Places a damage-absorbing shield on your target.'),
(5, 1, 'purify', 'Purify', 'cleanse', 5, 5, 10, 1, 'x', 'Removes a harmful effect and restores health.'),
(6, 1, 'dawn-burst', 'Dawn Burst', 'party_heal', 16, 12, 28, 5, 'D', 'A brilliant wave of healing for the entire party.'),
(6, 1, 'dawn-burst', 'Dawn Burst', 'party_heal', 16, 12, 28, 5, 'D', 'A brilliant wave of healing for up to 4 injured allies.'),
(7, 1, 'guardian-light', 'Guardian Light', 'absorb', 13, 14, 55, 10, 'G', 'A powerful ward reserved for moments of extreme danger.'),
(8, 1, 'second-sun', 'Second Sun', 'direct_heal', 20, 20, 85, 15, 'S', 'Calls down a delayed surge of restorative light.'),
(9, 1, 'daybreak', 'Daybreak', 'party_heal', 23, 30, 48, 20, 'A', 'Floods the party with the full strength of dawn.'),
(9, 1, 'daybreak', 'Daybreak', 'party_heal', 23, 30, 48, 20, 'A', 'Floods up to 4 injured allies with the full strength of dawn.'),
(20, 2, 'verdant-touch', 'Verdant Touch', 'direct_heal', 5, 0.5, 28, 1, '+', 'A quick pulse of living energy.'),
(21, 2, 'seed-of-life', 'Seed of Life', 'heal_over_time', 7, 0.5, 11, 1, 's', 'Plants a restorative seed that blooms over time.'),
(22, 2, 'wild-bloom', 'Wild Bloom', 'party_heal', 12, 8, 17, 1, '*', 'Restorative growth spreads through the party.'),
(22, 2, 'wild-bloom', 'Wild Bloom', 'party_heal', 12, 8, 17, 1, '*', 'Restorative growth spreads to up to 4 injured allies.'),
(23, 2, 'barkskin', 'Barkskin', 'absorb', 8, 7, 34, 1, 'B', 'Wraps an ally in protective living bark.'),
(24, 2, 'purging-sap', 'Purging Sap', 'cleanse', 5, 5, 10, 1, 'p', 'Draws a harmful effect out through enchanted sap.'),
(25, 2, 'ancient-grove', 'Ancient Grove', 'party_heal', 17, 12, 31, 5, 'T', 'Briefly summons the shelter of an ancient grove.'),
(30, 3, 'etched-mend', 'Etched Mend', 'direct_heal', 5, 0.5, 29, 1, '+', 'Completes a simple rune of restoration.'),
(31, 3, 'echo-rune', 'Echo Rune', 'heal_over_time', 7, 0.5, 12, 1, 'e', 'Repeats a restorative rune over several moments.'),
(32, 3, 'concordance', 'Concordance', 'party_heal', 12, 8, 18, 1, '*', 'Links the party through a shared healing pattern.'),
(32, 3, 'concordance', 'Concordance', 'party_heal', 12, 8, 18, 1, '*', 'Links up to 4 injured allies through a shared healing pattern.'),
(33, 3, 'aegis-script', 'Aegis Script', 'absorb', 8, 7, 35, 1, 'O', 'Writes a temporary barrier around an ally.'),
(34, 3, 'unravel', 'Unravel', 'cleanse', 5, 5, 10, 1, 'u', 'Unravels a hostile magical pattern.'),
(35, 3, 'grand-design', 'Grand Design', 'party_heal', 16, 12, 30, 5, 'R', 'Activates a prepared network of restorative runes.');
@@ -191,6 +200,76 @@ UPDATE spells SET unlock_level = 10 WHERE slug = 'guardian-light';
UPDATE spells SET unlock_level = 15 WHERE slug = 'second-sun';
UPDATE spells SET unlock_level = 20 WHERE slug = 'daybreak';
UPDATE spells SET
name = 'Verdant Touch',
spell_type = 'direct_hot',
resource_cost = 5,
cooldown_seconds = 0.5,
power = 20,
glyph = '+',
description = 'A weaker direct heal that also plants a stacking heal over time.'
WHERE slug = 'verdant-touch';
UPDATE spells SET
name = 'Wild Growth',
spell_type = 'party_hot',
resource_cost = 12,
cooldown_seconds = 8,
power = 14,
glyph = '*',
description = 'Applies a stacking heal over time to up to 4 injured allies.'
WHERE slug = 'wild-bloom';
UPDATE spells SET
name = 'Barkskin',
spell_type = 'damage_reduction',
resource_cost = 10,
cooldown_seconds = 14,
power = 0,
glyph = 'B',
description = 'Reduces the target ally''s damage taken by 50% for 8 seconds.'
WHERE slug = 'barkskin';
UPDATE spells SET
name = 'Ancient Grove',
spell_type = 'party_hot',
resource_cost = 17,
cooldown_seconds = 12,
power = 24,
glyph = 'T',
description = 'Applies a stronger stacking heal over time to up to 4 injured allies.'
WHERE slug = 'ancient-grove';
UPDATE spells SET
name = 'Mending Rune',
spell_type = 'bounce_heal',
resource_cost = 7,
cooldown_seconds = 0.5,
power = 18,
glyph = 'e',
description = 'Places a rune that heals when the ally takes damage, then jumps 4 times.'
WHERE slug = 'echo-rune';
UPDATE spells SET
name = 'Concordance',
spell_type = 'party_absorb',
resource_cost = 12,
cooldown_seconds = 8,
power = 28,
glyph = '*',
description = 'Shields up to 4 injured allies through a shared barrier pattern.'
WHERE slug = 'concordance';
UPDATE spells SET
name = 'Grand Design',
spell_type = 'party_absorb',
resource_cost = 16,
cooldown_seconds = 12,
power = 42,
glyph = 'R',
description = 'Raises a stronger shared barrier around up to 4 injured allies.'
WHERE slug = 'grand-design';
INSERT OR IGNORE INTO items
(id, slug, name, slot, rarity, item_level, healing_power, max_resource_bonus, glyph, description)
VALUES
@@ -322,6 +401,16 @@ UPDATE items SET glyph = '/' WHERE slug = 'ashwood-crook';
UPDATE items SET glyph = 'b' WHERE slug = 'cinderstep-boots';
UPDATE items SET glyph = '^' WHERE slug = 'adepts-hood';
INSERT OR IGNORE INTO encounters
(id, dungeon_id, sequence, slug, name, encounter_type, max_health, base_damage, tank_damage, party_damage, description)
VALUES
(3, 1, 9003, 'legacy-loot-encounter-3', 'Legacy Loot Encounter 3', 'boss', 1, 1, 1, 1, 'Temporary legacy seed row.'),
(12, 1, 9012, 'legacy-loot-encounter-12', 'Legacy Loot Encounter 12', 'boss', 1, 1, 1, 1, 'Temporary legacy seed row.'),
(22, 1, 9022, 'legacy-loot-encounter-22', 'Legacy Loot Encounter 22', 'boss', 1, 1, 1, 1, 'Temporary legacy seed row.'),
(102, 2, 9102, 'legacy-loot-encounter-102', 'Legacy Loot Encounter 102', 'boss', 1, 1, 1, 1, 'Temporary legacy seed row.'),
(105, 2, 9105, 'legacy-loot-encounter-105', 'Legacy Loot Encounter 105', 'boss', 1, 1, 1, 1, 'Temporary legacy seed row.'),
(108, 2, 9108, 'legacy-loot-encounter-108', 'Legacy Loot Encounter 108', 'boss', 1, 1, 1, 1, 'Temporary legacy seed row.');
DELETE FROM encounter_loot;
INSERT INTO encounter_loot
@@ -446,20 +535,20 @@ WHERE character_id IN (1, 2, 3)
-- Coin gearing override: every boss/difficulty drops one boss coin, and each
-- craft costs the target item level in that source boss coin.
UPDATE crafting_recipes
SET source_dungeon_id = 1,
source_encounter_id = 3
SET source_dungeon_id = CASE WHEN EXISTS (SELECT 1 FROM encounters WHERE id = 3) THEN 1 ELSE NULL END,
source_encounter_id = (SELECT id FROM encounters WHERE id = 3)
WHERE id BETWEEN 1001 AND 1409
AND item_id IN (SELECT id FROM items WHERE slot IN ('helmet', 'chest', 'gloves'));
UPDATE crafting_recipes
SET source_dungeon_id = 1,
source_encounter_id = 12
SET source_dungeon_id = CASE WHEN EXISTS (SELECT 1 FROM encounters WHERE id = 12) THEN 1 ELSE NULL END,
source_encounter_id = (SELECT id FROM encounters WHERE id = 12)
WHERE id BETWEEN 1001 AND 1409
AND item_id IN (SELECT id FROM items WHERE slot IN ('boots', 'ring', 'trinket'));
UPDATE crafting_recipes
SET source_dungeon_id = 1,
source_encounter_id = 22
SET source_dungeon_id = CASE WHEN EXISTS (SELECT 1 FROM encounters WHERE id = 22) THEN 1 ELSE NULL END,
source_encounter_id = (SELECT id FROM encounters WHERE id = 22)
WHERE id BETWEEN 1001 AND 1409
AND item_id IN (SELECT id FROM items WHERE slot IN ('weapon', 'pants', 'necklace'));
@@ -487,11 +576,13 @@ SET rarity = CASE item_level
WHERE id IN (SELECT item_id FROM crafting_recipes);
UPDATE items
SET name = (
SET name = COALESCE((
SELECT
CASE items.item_level
WHEN 1 THEN 'Raw '
WHEN 5 THEN 'Honed '
WHEN 10 THEN 'Green '
WHEN 15 THEN 'Blue '
WHEN 20 THEN 'Purple '
WHEN 25 THEN 'Orange '
ELSE ''
@@ -513,14 +604,14 @@ SET name = (
JOIN encounters ON encounters.id = crafting_recipes.source_encounter_id
WHERE crafting_recipes.item_id = items.id
LIMIT 1
),
description = (
), name),
description = COALESCE((
SELECT 'Crafted with ' || encounters.name || ' coins.'
FROM crafting_recipes
JOIN encounters ON encounters.id = crafting_recipes.source_encounter_id
WHERE crafting_recipes.item_id = items.id
LIMIT 1
)
), description)
WHERE id IN (SELECT item_id FROM crafting_recipes);
CREATE TEMP TABLE IF NOT EXISTS coin_sources (
@@ -539,11 +630,11 @@ DELETE FROM coin_sources;
INSERT INTO coin_sources
SELECT
280000 + encounters.id * 100 + difficulties.dropped_item_level,
280000 + encounters.id * 1000 + difficulties.id,
encounters.id,
difficulties.id,
difficulties.dropped_item_level,
encounters.slug || '-coin-ilvl-' || difficulties.dropped_item_level,
encounters.slug || '-coin-diff-' || difficulties.id || '-ilvl-' || difficulties.dropped_item_level,
CASE difficulties.dropped_item_level
WHEN 1 THEN 'Raw '
WHEN 5 THEN 'Honed '
@@ -575,6 +666,18 @@ INSERT OR IGNORE INTO items
SELECT item_id, slug, name, 'component', rarity, item_level, 0, 0, glyph, description
FROM coin_sources;
UPDATE coin_sources
SET item_id = (
SELECT items.id
FROM items
WHERE items.slug = coin_sources.slug
)
WHERE EXISTS (
SELECT 1
FROM items
WHERE items.slug = coin_sources.slug
);
UPDATE items
SET slug = (SELECT slug FROM coin_sources WHERE coin_sources.item_id = items.id),
name = (SELECT name FROM coin_sources WHERE coin_sources.item_id = items.id),
@@ -606,18 +709,22 @@ JOIN coin_sources
ON coin_sources.encounter_id = crafting_recipes.source_encounter_id
AND coin_sources.difficulty_id = crafting_recipes.difficulty_id;
DELETE FROM character_talents
WHERE talent_id IN (SELECT id FROM talents WHERE class_id = 1);
DELETE FROM talents WHERE class_id = 1;
INSERT OR IGNORE INTO talents
(id, class_id, slug, name, max_rank, tier, branch, prerequisite_talent_id, prerequisite_rank, effect_type, effect_value_per_rank, glyph, description)
VALUES
(1, 1, 'bright-reserves', 'Bright Reserves', 5, 1, 1, NULL, 0, 'max_resource', 2, 'M', 'Increases maximum Mana by 2 per rank.'),
(2, 1, 'gentle-dawn', 'Gentle Dawn', 5, 1, 2, NULL, 0, 'hot_power_percent', 2, '~', 'Increases healing-over-time power by 2% per rank.'),
(10, 1, 'steady-hands', 'Steady Hands', 5, 1, 3, NULL, 0, 'direct_heal_percent', 2, '+', 'Increases direct healing by 2% per rank.'),
(11, 1, 'overflowing-light', 'Overflowing Light', 5, 2, 1, 1, 3, 'resource_regen_percent', 2, 'O', 'Improves Mana regeneration by 2% per rank. Requires Bright Reserves rank 3.'),
(12, 1, 'lingering-rays', 'Lingering Rays', 5, 2, 2, 2, 3, 'hot_duration_percent', 4, 'L', 'Extends healing-over-time duration by 4% per rank. Requires Gentle Dawn rank 3.'),
(13, 1, 'radiant-precision', 'Radiant Precision', 5, 2, 3, 10, 3, 'critical_heal_percent', 1, '!', 'Adds 1% healing critical chance per rank. Requires Steady Hands rank 3.'),
(14, 1, 'sunlit-aegis', 'Sunlit Aegis', 3, 3, 1, 11, 5, 'absorb_power_percent', 5, 'A', 'Strengthens absorb effects by 5% per rank. Requires Overflowing Light rank 5.'),
(15, 1, 'shared-dawn', 'Shared Dawn', 3, 3, 2, 12, 5, 'party_heal_percent', 5, '*', 'Increases party-wide healing by 5% per rank. Requires Lingering Rays rank 5.'),
(16, 1, 'miracle-worker', 'Miracle Worker', 1, 4, 2, 15, 3, 'cooldown_reduction_percent', 10, 'S', 'Reduces major healing cooldowns by 10%. Requires Shared Dawn rank 3.'),
(1, 1, 'shield-applies-renew', 'Shield applies Renew', 1, 1, 1, NULL, 0, 'shield_applies_renew', 0, '~', 'Sun Ward also applies Renew to the target.'),
(2, 1, 'mend-applies-renew', 'Mend applies Renew', 1, 1, 2, NULL, 0, 'mend_applies_renew', 0, '~', 'Mend also applies Renew to the target.'),
(10, 1, 'mend-adds-shield', 'Mend adds Shield', 1, 1, 3, NULL, 0, 'mend_applies_shield', 0, 'O', 'Mend also applies a shield at 50% strength to the target.'),
(11, 1, 'radiance-adds-shield', 'Radiance adds Shield', 1, 1, 4, NULL, 0, 'radiance_applies_shield', 0, 'O', 'Radiance applies a shield at 30% strength to affected party members.'),
(12, 1, 'radiance-applies-renew', 'Radiance applies Renew', 1, 1, 5, NULL, 0, 'radiance_applies_renew', 0, '~', 'Radiance applies Renew at 50% duration to affected party members.'),
(13, 1, 'shielded-damage-reduction', 'Shielded takes less', 1, 1, 6, NULL, 0, 'shielded_damage_reduction', 0, 'D', 'While shielded, the target receives 20% less damage.'),
(14, 1, 'shielded-healing-bonus', 'Shielded healing boost', 1, 1, 7, NULL, 0, 'shielded_healing_bonus', 0, '+', 'While shielded, the target receives 20% more healing.'),
(15, 1, 'mend-reduces-radiance', 'Mend lowers Radiance', 1, 1, 8, NULL, 0, 'mend_reduces_radiance_cooldown', 0, '*', 'Casting Mend reduces the cooldown of Radiance by 2 seconds.'),
(3, 2, 'deep-roots', 'Deep Roots', 5, 1, 1, NULL, 0, 'max_resource', 2, 'R', 'Increases maximum Bloom by 2 per rank.'),
(20, 2, 'patient-growth', 'Patient Growth', 5, 1, 2, NULL, 0, 'hot_power_percent', 2, 's', 'Increases healing-over-time power by 2% per rank.'),
@@ -724,6 +831,7 @@ INSERT INTO generated_loot_tiers
(item_level, dungeon_id, raid_id, dungeon_difficulty_id, raid_difficulty_id, recipe_base, craft_quantity)
VALUES
(10, 3, 2, 2, 101, 1100, 2),
(15, 4, 5, 3, 103, 1200, 3),
(20, 6, 7, 4, 104, 1300, 4),
(25, 8, 9, 5, 105, 1400, 5);
@@ -782,8 +890,8 @@ INSERT OR IGNORE INTO locations (id, slug, name, description) VALUES
(3, 'monster-frontier', 'The Monster Frontier', 'Hunting grounds used for tiered monster-part progression.');
UPDATE dungeons
SET slug = 'tigrex-raid',
name = 'Tigrex Raid',
SET slug = 'legacy-generated-raid-2',
name = 'Legacy Generated Raid 2',
location_id = 3,
recommended_level = 5,
content_type = 'raid',
@@ -866,6 +974,68 @@ SELECT dungeon_id, dungeon_difficulty_id FROM generated_loot_tiers
UNION ALL
SELECT raid_id, raid_difficulty_id FROM generated_loot_tiers;
UPDATE crafting_recipes
SET source_dungeon_id = NULL,
source_encounter_id = NULL
WHERE source_dungeon_id IN (
SELECT dungeon_id FROM generated_loot_tiers
UNION
SELECT raid_id FROM generated_loot_tiers
)
OR source_encounter_id IN (
SELECT id
FROM encounters
WHERE dungeon_id IN (
SELECT dungeon_id FROM generated_loot_tiers
UNION
SELECT raid_id FROM generated_loot_tiers
)
);
DELETE FROM encounter_loot_roll_items
WHERE roll_id IN (
SELECT id
FROM encounter_loot_rolls
WHERE encounter_id IN (
SELECT id
FROM encounters
WHERE dungeon_id IN (
SELECT dungeon_id FROM generated_loot_tiers
UNION
SELECT raid_id FROM generated_loot_tiers
)
)
);
DELETE FROM encounter_loot_rolls
WHERE encounter_id IN (
SELECT id
FROM encounters
WHERE dungeon_id IN (
SELECT dungeon_id FROM generated_loot_tiers
UNION
SELECT raid_id FROM generated_loot_tiers
)
);
DELETE FROM encounter_loot
WHERE encounter_id IN (
SELECT id
FROM encounters
WHERE dungeon_id IN (
SELECT dungeon_id FROM generated_loot_tiers
UNION
SELECT raid_id FROM generated_loot_tiers
)
);
DELETE FROM encounters
WHERE dungeon_id IN (
SELECT dungeon_id FROM generated_loot_tiers
UNION
SELECT raid_id FROM generated_loot_tiers
);
UPDATE encounters
SET slug = CASE id
WHEN 100 THEN 'tigrex-raid-approach'
@@ -898,7 +1068,23 @@ SET slug = CASE id
WHEN 108 THEN 'Gypceros drops boss coins for item level 10 crafting.'
ELSE 'Hunters clear the raid path.'
END
WHERE id BETWEEN 100 AND 108;
WHERE id BETWEEN 100 AND 108
AND NOT EXISTS (
SELECT 1
FROM encounters AS conflict
WHERE conflict.id NOT BETWEEN 100 AND 108
AND conflict.slug IN (
'tigrex-raid-approach',
'tigrex-raid-guardians',
'tigrex-raid',
'rathalos-raid-approach',
'rathalos-raid-guardians',
'rathalos-raid',
'gypceros-raid-approach',
'gypceros-raid-guardians',
'gypceros-raid'
)
);
INSERT OR IGNORE INTO encounters
(id, dungeon_id, sequence, slug, name, encounter_type, max_health, base_damage, tank_damage, party_damage, description)
@@ -986,7 +1172,12 @@ SELECT
1.0
FROM generated_loot_tiers
JOIN generated_bosses ON generated_bosses.item_level = generated_loot_tiers.item_level
JOIN generated_drop_patterns ON generated_drop_patterns.boss_index = generated_bosses.boss_index;
JOIN generated_drop_patterns ON generated_drop_patterns.boss_index = generated_bosses.boss_index
WHERE EXISTS (
SELECT 1
FROM encounters
WHERE encounters.id = generated_loot_tiers.dungeon_id * 100 + generated_bosses.boss_index * 3 + 3
);
INSERT OR IGNORE INTO encounter_loot (encounter_id, item_id, difficulty_id, drop_weight, drop_chance)
SELECT
@@ -1000,7 +1191,15 @@ SELECT
1.0
FROM generated_loot_tiers
JOIN generated_bosses ON generated_bosses.item_level = generated_loot_tiers.item_level
JOIN generated_drop_patterns ON generated_drop_patterns.boss_index = generated_bosses.boss_index;
JOIN generated_drop_patterns ON generated_drop_patterns.boss_index = generated_bosses.boss_index
WHERE EXISTS (
SELECT 1
FROM encounters
WHERE encounters.id = CASE generated_loot_tiers.raid_id
WHEN 2 THEN 102 + generated_bosses.boss_index * 3
ELSE generated_loot_tiers.raid_id * 100 + generated_bosses.boss_index * 3 + 3
END
);
CREATE TEMP TABLE IF NOT EXISTS generated_recipe_offsets (
recipe_offset INTEGER PRIMARY KEY,
@@ -1028,12 +1227,8 @@ SET source_dungeon_id = (
WHERE id BETWEEN 1101 AND 1409;
UPDATE crafting_recipes
SET source_dungeon_id = 2,
source_encounter_id = 102 + (
SELECT generated_recipe_offsets.boss_index * 3
FROM generated_recipe_offsets
WHERE crafting_recipes.id = 2000 + generated_recipe_offsets.recipe_offset
)
SET source_dungeon_id = NULL,
source_encounter_id = NULL
WHERE id BETWEEN 2001 AND 2009;
DELETE FROM crafting_recipe_components
@@ -1242,20 +1437,20 @@ INSERT OR IGNORE INTO crafting_recipe_components (recipe_id, item_id, quantity)
-- Final coin gearing override. Keep this after legacy loot edits.
UPDATE crafting_recipes
SET source_dungeon_id = 1,
source_encounter_id = 3
SET source_dungeon_id = CASE WHEN EXISTS (SELECT 1 FROM encounters WHERE id = 3) THEN 1 ELSE NULL END,
source_encounter_id = (SELECT id FROM encounters WHERE id = 3)
WHERE id BETWEEN 1001 AND 1409
AND item_id IN (SELECT id FROM items WHERE slot IN ('helmet', 'chest', 'gloves'));
UPDATE crafting_recipes
SET source_dungeon_id = 1,
source_encounter_id = 12
SET source_dungeon_id = CASE WHEN EXISTS (SELECT 1 FROM encounters WHERE id = 12) THEN 1 ELSE NULL END,
source_encounter_id = (SELECT id FROM encounters WHERE id = 12)
WHERE id BETWEEN 1001 AND 1409
AND item_id IN (SELECT id FROM items WHERE slot IN ('boots', 'ring', 'trinket'));
UPDATE crafting_recipes
SET source_dungeon_id = 1,
source_encounter_id = 22
SET source_dungeon_id = CASE WHEN EXISTS (SELECT 1 FROM encounters WHERE id = 22) THEN 1 ELSE NULL END,
source_encounter_id = (SELECT id FROM encounters WHERE id = 22)
WHERE id BETWEEN 1001 AND 1409
AND item_id IN (SELECT id FROM items WHERE slot IN ('weapon', 'pants', 'necklace'));
@@ -1277,18 +1472,20 @@ WHERE recipe_id IN (
SELECT crafting_recipes.id
FROM crafting_recipes
JOIN items ON items.id = crafting_recipes.item_id
WHERE items.item_level NOT IN (1, 10, 20, 25)
WHERE items.item_level NOT IN (1, 5, 10, 15, 20, 25)
);
DELETE FROM crafting_recipes
WHERE item_id IN (
SELECT id FROM items WHERE item_level NOT IN (1, 10, 20, 25)
SELECT id FROM items WHERE item_level NOT IN (1, 5, 10, 15, 20, 25)
);
UPDATE items
SET rarity = CASE item_level
WHEN 1 THEN 'common'
WHEN 5 THEN 'uncommon'
WHEN 10 THEN 'uncommon'
WHEN 15 THEN 'rare'
WHEN 20 THEN 'epic'
WHEN 25 THEN 'legendary'
ELSE rarity
@@ -1296,11 +1493,13 @@ SET rarity = CASE item_level
WHERE id IN (SELECT item_id FROM crafting_recipes);
UPDATE items
SET name = (
SET name = COALESCE((
SELECT
CASE items.item_level
WHEN 1 THEN 'Raw '
WHEN 5 THEN 'Honed '
WHEN 10 THEN 'Green '
WHEN 15 THEN 'Blue '
WHEN 20 THEN 'Purple '
WHEN 25 THEN 'Orange '
ELSE ''
@@ -1322,25 +1521,318 @@ SET name = (
JOIN encounters ON encounters.id = crafting_recipes.source_encounter_id
WHERE crafting_recipes.item_id = items.id
LIMIT 1
),
description = (
), name),
description = COALESCE((
SELECT 'Crafted with ' || encounters.name || ' coins.'
FROM crafting_recipes
JOIN encounters ON encounters.id = crafting_recipes.source_encounter_id
WHERE crafting_recipes.item_id = items.id
LIMIT 1
), description)
WHERE id IN (SELECT item_id FROM crafting_recipes);
-- Single-boss hunting grounds. Keep this late so legacy three-boss hunt
-- seed data and generated monster tiers are normalized into separate runs.
CREATE TEMP TABLE IF NOT EXISTS single_boss_dungeons (
dungeon_id INTEGER PRIMARY KEY,
boss_name TEXT NOT NULL,
boss_slug TEXT NOT NULL,
item_level INTEGER NOT NULL,
difficulty_floor INTEGER NOT NULL,
location_id INTEGER NOT NULL,
experience_reward INTEGER NOT NULL
);
DELETE FROM single_boss_dungeons;
INSERT INTO single_boss_dungeons
(dungeon_id, boss_name, boss_slug, item_level, difficulty_floor, location_id, experience_reward)
VALUES
(1, 'Bulldrome', 'bulldrome', 1, 1, 1, 125),
(2, 'Yian Kut-Ku', 'yian-kut-ku', 1, 1, 1, 125),
(3, 'Rathian', 'rathian', 1, 1, 1, 125),
(4, 'Tigrex', 'tigrex', 10, 2, 3, 205),
(5, 'Rathalos', 'rathalos', 10, 2, 3, 205),
(6, 'Gypceros', 'gypceros', 10, 2, 3, 205),
(7, 'Nargacuga', 'nargacuga', 15, 3, 3, 245),
(8, 'Azuros', 'azuros', 15, 3, 3, 245),
(9, 'Diablos', 'diablos', 15, 3, 3, 245),
(10, 'Barroth', 'barroth', 20, 4, 3, 285),
(11, 'Tobi Kadachi', 'tobi-kadachi', 20, 4, 3, 285),
(12, 'Monoblos', 'monoblos', 20, 4, 3, 285),
(13, 'Anjanath', 'anjanath', 25, 5, 3, 325),
(14, 'Bazelgeuse', 'bazelgeuse', 25, 5, 3, 325),
(15, 'Odogaron', 'odogaron', 25, 5, 3, 325);
CREATE TEMP TABLE IF NOT EXISTS single_boss_raids (
raid_id INTEGER PRIMARY KEY,
dungeon_id INTEGER NOT NULL,
difficulty_id INTEGER NOT NULL,
experience_reward INTEGER NOT NULL
);
DELETE FROM single_boss_raids;
INSERT INTO single_boss_raids (raid_id, dungeon_id, difficulty_id, experience_reward)
VALUES
(20, 4, 101, 275),
(21, 5, 101, 275),
(22, 6, 101, 275),
(23, 7, 103, 325),
(24, 8, 103, 325),
(25, 9, 103, 325),
(26, 10, 104, 375),
(27, 11, 104, 375),
(28, 12, 104, 375),
(29, 13, 105, 425),
(30, 14, 105, 425),
(31, 15, 105, 425);
UPDATE dungeons
SET slug = 'retired-dungeon-' || id
WHERE id BETWEEN 1 AND 31;
INSERT OR IGNORE INTO dungeons
(id, location_id, slug, name, recommended_level, content_type, party_size, completion_item_level, experience_reward, description)
SELECT
dungeon_id,
location_id,
boss_slug || '-hunting-ground',
boss_name || ' Hunting Ground',
CASE difficulty_floor WHEN 1 THEN 1 ELSE item_level END,
'dungeon',
6,
NULL,
experience_reward,
'A focused hunt through ' || boss_name || ' territory.'
FROM single_boss_dungeons;
UPDATE dungeons
SET location_id = (SELECT location_id FROM single_boss_dungeons WHERE dungeon_id = dungeons.id),
slug = (SELECT boss_slug || '-hunting-ground' FROM single_boss_dungeons WHERE dungeon_id = dungeons.id),
name = (SELECT boss_name || ' Hunting Ground' FROM single_boss_dungeons WHERE dungeon_id = dungeons.id),
recommended_level = (SELECT CASE difficulty_floor WHEN 1 THEN 1 ELSE item_level END FROM single_boss_dungeons WHERE dungeon_id = dungeons.id),
content_type = 'dungeon',
party_size = 6,
completion_item_level = NULL,
experience_reward = (SELECT experience_reward FROM single_boss_dungeons WHERE dungeon_id = dungeons.id),
description = (SELECT 'A focused hunt through ' || boss_name || ' territory.' FROM single_boss_dungeons WHERE dungeon_id = dungeons.id)
WHERE id IN (SELECT dungeon_id FROM single_boss_dungeons);
INSERT OR IGNORE INTO dungeons
(id, location_id, slug, name, recommended_level, content_type, party_size, completion_item_level, experience_reward, description)
SELECT
single_boss_raids.raid_id,
single_boss_dungeons.location_id,
'apex-' || single_boss_dungeons.boss_slug || '-raid',
'Apex ' || single_boss_dungeons.boss_name || ' Raid',
single_boss_dungeons.item_level,
'raid',
18,
NULL,
single_boss_raids.experience_reward,
'A raid-scale hunt against Apex ' || single_boss_dungeons.boss_name || '.'
FROM single_boss_raids
JOIN single_boss_dungeons ON single_boss_dungeons.dungeon_id = single_boss_raids.dungeon_id;
UPDATE dungeons
SET slug = (SELECT 'apex-' || single_boss_dungeons.boss_slug || '-raid' FROM single_boss_raids JOIN single_boss_dungeons ON single_boss_dungeons.dungeon_id = single_boss_raids.dungeon_id WHERE single_boss_raids.raid_id = dungeons.id),
name = (SELECT 'Apex ' || single_boss_dungeons.boss_name || ' Raid' FROM single_boss_raids JOIN single_boss_dungeons ON single_boss_dungeons.dungeon_id = single_boss_raids.dungeon_id WHERE single_boss_raids.raid_id = dungeons.id),
location_id = (SELECT single_boss_dungeons.location_id FROM single_boss_raids JOIN single_boss_dungeons ON single_boss_dungeons.dungeon_id = single_boss_raids.dungeon_id WHERE single_boss_raids.raid_id = dungeons.id),
recommended_level = (SELECT single_boss_dungeons.item_level FROM single_boss_raids JOIN single_boss_dungeons ON single_boss_dungeons.dungeon_id = single_boss_raids.dungeon_id WHERE single_boss_raids.raid_id = dungeons.id),
content_type = 'raid',
party_size = 18,
completion_item_level = NULL,
experience_reward = (SELECT experience_reward FROM single_boss_raids WHERE single_boss_raids.raid_id = dungeons.id),
description = (SELECT 'A raid-scale hunt against Apex ' || single_boss_dungeons.boss_name || '.' FROM single_boss_raids JOIN single_boss_dungeons ON single_boss_dungeons.dungeon_id = single_boss_raids.dungeon_id WHERE single_boss_raids.raid_id = dungeons.id)
WHERE id IN (SELECT raid_id FROM single_boss_raids);
UPDATE crafting_recipes
SET source_dungeon_id = NULL,
source_encounter_id = NULL
WHERE id BETWEEN 1001 AND 1409;
DELETE FROM encounter_loot_roll_items
WHERE roll_id IN (
SELECT id
FROM encounter_loot_rolls
WHERE encounter_id IN (
SELECT id FROM encounters
WHERE dungeon_id IN (SELECT dungeon_id FROM single_boss_dungeons)
OR dungeon_id IN (SELECT raid_id FROM single_boss_raids)
)
);
DELETE FROM encounter_loot_rolls
WHERE encounter_id IN (
SELECT id FROM encounters
WHERE dungeon_id IN (SELECT dungeon_id FROM single_boss_dungeons)
OR dungeon_id IN (SELECT raid_id FROM single_boss_raids)
);
DELETE FROM encounters WHERE dungeon_id IN (SELECT dungeon_id FROM single_boss_dungeons);
DELETE FROM encounters WHERE dungeon_id IN (SELECT raid_id FROM single_boss_raids);
INSERT INTO encounters
(id, dungeon_id, sequence, slug, name, encounter_type, max_health, base_damage, tank_damage, party_damage, description)
SELECT
single_boss_dungeons.dungeon_id * 100 + offset.value,
single_boss_dungeons.dungeon_id,
offset.value,
single_boss_dungeons.boss_slug || '-' || offset.slug,
CASE offset.encounter_type
WHEN 'boss' THEN single_boss_dungeons.boss_name
ELSE single_boss_dungeons.boss_name || ' ' || offset.name
END,
offset.encounter_type,
offset.health + single_boss_dungeons.item_level * 35,
offset.damage + single_boss_dungeons.difficulty_floor,
offset.tank_damage + single_boss_dungeons.difficulty_floor,
offset.party_damage + single_boss_dungeons.difficulty_floor * 2,
CASE offset.encounter_type
WHEN 'boss' THEN single_boss_dungeons.boss_name || ' drops boss coins for crafting.'
ELSE 'Hunters clear the path before ' || single_boss_dungeons.boss_name || '.'
END
FROM single_boss_dungeons
JOIN (
SELECT 1 AS value, 'approach' AS slug, 'Approach' AS name, 'trash' AS encounter_type, 430 AS health, 13 AS damage, 8 AS tank_damage, 24 AS party_damage
UNION ALL SELECT 2, 'guardians', 'Guardians', 'trash', 520, 15, 9, 26
UNION ALL SELECT 3, 'boss', '', 'boss', 860, 19, 14, 29
) AS offset;
INSERT INTO encounters
(id, dungeon_id, sequence, slug, name, encounter_type, max_health, base_damage, tank_damage, party_damage, description)
SELECT
single_boss_raids.raid_id * 100 + offset.value,
single_boss_raids.raid_id,
offset.value,
single_boss_dungeons.boss_slug || '-raid-' || offset.slug,
CASE offset.encounter_type
WHEN 'boss' THEN 'Apex ' || single_boss_dungeons.boss_name
ELSE 'Apex ' || single_boss_dungeons.boss_name || ' ' || offset.name
END,
offset.encounter_type,
offset.health + single_boss_dungeons.item_level * 35 + 900,
offset.damage + single_boss_dungeons.difficulty_floor,
offset.tank_damage + single_boss_dungeons.difficulty_floor,
offset.party_damage + single_boss_dungeons.difficulty_floor * 2 + 22,
CASE offset.encounter_type
WHEN 'boss' THEN 'Apex ' || single_boss_dungeons.boss_name || ' drops raid coins for crafting.'
ELSE 'Hunters clear the raid path before Apex ' || single_boss_dungeons.boss_name || '.'
END
FROM single_boss_raids
JOIN single_boss_dungeons
ON single_boss_dungeons.dungeon_id = single_boss_raids.dungeon_id
JOIN (
SELECT 1 AS value, 'approach' AS slug, 'Approach' AS name, 'trash' AS encounter_type, 650 AS health, 15 AS damage, 9 AS tank_damage, 28 AS party_damage
UNION ALL SELECT 2, 'guardians', 'Guardians', 'trash', 720, 16, 10, 30
UNION ALL SELECT 3, 'boss', '', 'boss', 980, 20, 14, 34
) AS offset;
DELETE FROM dungeon_difficulties;
INSERT OR IGNORE INTO dungeon_difficulties (dungeon_id, difficulty_id)
SELECT dungeon_id, difficulties.id
FROM single_boss_dungeons
JOIN difficulties ON difficulties.id BETWEEN single_boss_dungeons.difficulty_floor AND 5
WHERE difficulties.id BETWEEN 1 AND 5;
INSERT OR IGNORE INTO dungeon_difficulties (dungeon_id, difficulty_id)
SELECT raid_id, difficulty_id
FROM single_boss_raids;
CREATE TEMP TABLE IF NOT EXISTS recipe_source_override (
recipe_id INTEGER PRIMARY KEY,
dungeon_id INTEGER NOT NULL,
encounter_id INTEGER NOT NULL
);
DELETE FROM recipe_source_override;
INSERT INTO recipe_source_override (recipe_id, dungeon_id, encounter_id) VALUES
(1001, 1, 103), (1002, 1, 103), (1003, 1, 103),
(1004, 2, 203), (1005, 2, 203), (1006, 2, 203),
(1007, 3, 303), (1008, 3, 303), (1009, 3, 303),
(1101, 4, 403), (1102, 4, 403), (1103, 4, 403),
(1104, 5, 503), (1105, 5, 503), (1106, 5, 503),
(1107, 6, 603), (1108, 6, 603), (1109, 6, 603),
(1201, 7, 703), (1202, 7, 703), (1203, 7, 703),
(1204, 8, 803), (1205, 8, 803), (1206, 8, 803),
(1207, 9, 903), (1208, 9, 903), (1209, 9, 903),
(1301, 10, 1003), (1302, 10, 1003), (1303, 10, 1003),
(1304, 11, 1103), (1305, 11, 1103), (1306, 11, 1103),
(1307, 12, 1203), (1308, 12, 1203), (1309, 12, 1203),
(1401, 13, 1303), (1402, 13, 1303), (1403, 13, 1303),
(1404, 14, 1403), (1405, 14, 1403), (1406, 14, 1403),
(1407, 15, 1503), (1408, 15, 1503), (1409, 15, 1503);
UPDATE crafting_recipes
SET source_dungeon_id = (SELECT dungeon_id FROM recipe_source_override WHERE recipe_id = crafting_recipes.id),
source_encounter_id = (SELECT encounter_id FROM recipe_source_override WHERE recipe_id = crafting_recipes.id)
WHERE id IN (SELECT recipe_id FROM recipe_source_override);
UPDATE crafting_recipes
SET source_dungeon_id = (
SELECT single_boss_raids.raid_id
FROM generated_recipe_offsets
JOIN single_boss_raids
ON single_boss_raids.dungeon_id = 4 + generated_recipe_offsets.boss_index
WHERE crafting_recipes.id = 2000 + generated_recipe_offsets.recipe_offset
),
source_encounter_id = (
SELECT single_boss_raids.raid_id * 100 + 3
FROM generated_recipe_offsets
JOIN single_boss_raids
ON single_boss_raids.dungeon_id = 4 + generated_recipe_offsets.boss_index
WHERE crafting_recipes.id = 2000 + generated_recipe_offsets.recipe_offset
),
difficulty_id = 101
WHERE id BETWEEN 2001 AND 2009;
UPDATE items
SET name = COALESCE((
SELECT
CASE items.item_level
WHEN 1 THEN 'Raw '
WHEN 5 THEN 'Honed '
WHEN 10 THEN 'Green '
WHEN 15 THEN 'Blue '
WHEN 20 THEN 'Purple '
WHEN 25 THEN 'Orange '
ELSE ''
END
|| encounters.name || ' '
|| CASE items.slot
WHEN 'weapon' THEN 'Weapon'
WHEN 'helmet' THEN 'Helmet'
WHEN 'chest' THEN 'Chest'
WHEN 'gloves' THEN 'Gloves'
WHEN 'boots' THEN 'Boots'
WHEN 'pants' THEN 'Pants'
WHEN 'ring' THEN 'Ring'
WHEN 'necklace' THEN 'Necklace'
WHEN 'trinket' THEN 'Trinket'
ELSE items.name
END
FROM crafting_recipes
JOIN encounters ON encounters.id = crafting_recipes.source_encounter_id
WHERE crafting_recipes.item_id = items.id
LIMIT 1
), name),
description = COALESCE((
SELECT 'Crafted with ' || encounters.name || ' coins.'
FROM crafting_recipes
JOIN encounters ON encounters.id = crafting_recipes.source_encounter_id
WHERE crafting_recipes.item_id = items.id
LIMIT 1
), description)
WHERE id IN (SELECT item_id FROM crafting_recipes);
DELETE FROM coin_sources;
INSERT INTO coin_sources
SELECT
280000 + encounters.id * 100 + difficulties.dropped_item_level,
280000 + encounters.id * 1000 + difficulties.id,
encounters.id,
difficulties.id,
difficulties.dropped_item_level,
encounters.slug || '-coin-ilvl-' || difficulties.dropped_item_level,
encounters.slug || '-coin-diff-' || difficulties.id || '-ilvl-' || difficulties.dropped_item_level,
CASE difficulties.dropped_item_level
WHEN 1 THEN 'Raw '
WHEN 5 THEN 'Honed '
@@ -1372,6 +1864,18 @@ INSERT OR IGNORE INTO items
SELECT item_id, slug, name, 'component', rarity, item_level, 0, 0, glyph, description
FROM coin_sources;
UPDATE coin_sources
SET item_id = (
SELECT items.id
FROM items
WHERE items.slug = coin_sources.slug
)
WHERE EXISTS (
SELECT 1
FROM items
WHERE items.slug = coin_sources.slug
);
UPDATE items
SET slug = (SELECT slug FROM coin_sources WHERE coin_sources.item_id = items.id),
name = (SELECT name FROM coin_sources WHERE coin_sources.item_id = items.id),
+2 -2
View File
@@ -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

+141 -25
View File
@@ -22,6 +22,7 @@ const bossImageContentTypes = {
}
const equipmentSlots = ['weapon', 'helmet', 'chest', 'gloves', 'boots', 'pants', 'ring', 'necklace', 'trinket']
const componentSlot = 'component'
const directCraftItemLevels = new Set([1, 10, 20, 25])
const sessionCookieName = 'chronicle_session'
const sessionLifetimeSeconds = 60 * 60 * 24 * 30
const rateLimitBuckets = new Map()
@@ -232,6 +233,25 @@ function consumeRateLimit(key, limit, windowMs) {
}
}
function catchUpExperienceReward(database, accountId, characterId, baseReward, currentExperience, currentLevel) {
const targetLevel = database.prepare(`
SELECT COALESCE(MAX(level), 0) AS level
FROM characters
WHERE account_id = ?
AND id != ?
`).get(accountId, characterId).level
if (targetLevel <= currentLevel) return baseReward
const targetExperience = database.prepare(`
SELECT experience_required AS experienceRequired
FROM level_progression
WHERE level = ?
`).get(targetLevel)?.experienceRequired ?? currentExperience
const gap = Math.max(0, targetExperience - currentExperience)
if (gap <= 0) return baseReward
const doubledBase = Math.min(baseReward, Math.ceil(gap / 2))
return doubledBase * 2 + (baseReward - doubledBase)
}
function normalizeUsername(value) {
const username = String(value ?? '').trim()
if (!/^[A-Za-z0-9_]{3,20}$/.test(username)) {
@@ -770,6 +790,15 @@ export function getProfile(database, characterId, accountId) {
WHERE rank <= 10
ORDER BY dungeonId, difficultyId, startPart, completedParts, rank
`).all()
const dungeonCompletionCounts = new Map(database.prepare(`
SELECT dungeon_id AS dungeonId, COUNT(*) AS count
FROM dungeon_runs
WHERE character_id = ?
AND result = 'victory'
AND start_part = 1
AND completed_parts >= 1
GROUP BY dungeon_id
`).all(characterId).map((row) => [row.dungeonId, row.count]))
const settings = Object.fromEntries(
database.prepare('SELECT key, value FROM game_settings').all()
@@ -849,6 +878,7 @@ export function getProfile(database, characterId, accountId) {
}),
dungeons: dungeons.map((dungeon) => ({
...dungeon,
completionCount: dungeonCompletionCounts.get(dungeon.id) ?? 0,
difficulties: dungeonDifficulties.filter(
(difficulty) => difficulty.dungeonId === dungeon.id,
),
@@ -1693,16 +1723,9 @@ function craftItem(database, characterId, recipeId) {
WHERE crafting_recipes.id = ?
`).get(recipeId)
if (!recipe) throw new Error('That crafting recipe does not exist.')
const lowerTierRecipe = database.prepare(`
SELECT crafting_recipes.id
FROM crafting_recipes
JOIN items ON items.id = crafting_recipes.item_id
WHERE crafting_recipes.source_encounter_id = ?
AND items.slot = ?
AND items.item_level < ?
LIMIT 1
`).get(recipe.sourceEncounterId, recipe.slot, recipe.itemLevel)
if (lowerTierRecipe) throw new Error('Upgrade the previous item tier instead.')
if (!directCraftItemLevels.has(recipe.itemLevel)) {
throw new Error('Upgrade the previous item tier instead.')
}
const components = database.prepare(`
SELECT
@@ -1849,9 +1872,20 @@ function upgradeItem(database, characterId, itemId) {
return getProfile(database, characterId)
}
function talentEffectCapacity(level) {
return Math.min(4, Math.max(0, Math.floor(level / 5)))
}
function talentEffectSource(effectType) {
if (effectType.startsWith('mend_')) return 'Mend'
if (effectType.startsWith('radiance_')) return 'Radiance'
if (effectType.startsWith('shield_') || effectType.startsWith('shielded_')) return 'Shield'
return effectType
}
function allocateTalent(database, characterId, talentId) {
const character = database.prepare(`
SELECT class_id AS classId, talent_points AS talentPoints
SELECT class_id AS classId, level, talent_points AS talentPoints
FROM characters
WHERE id = ?
`).get(characterId)
@@ -1863,7 +1897,8 @@ function allocateTalent(database, characterId, talentId) {
max_rank AS maxRank,
tier,
prerequisite_talent_id AS prerequisiteTalentId,
prerequisite_rank AS prerequisiteRank
prerequisite_rank AS prerequisiteRank,
effect_type AS effectType
FROM talents
WHERE id = ?
`).get(talentId)
@@ -1871,6 +1906,60 @@ function allocateTalent(database, characterId, talentId) {
if (!talent || talent.classId !== character.classId) {
throw new Error('That talent does not belong to the active class.')
}
if (character.classId === 1) {
const currentRank = database.prepare(`
SELECT rank
FROM character_talents
WHERE character_id = ? AND talent_id = ?
`).get(characterId, talentId)?.rank ?? 0
database.exec('BEGIN')
try {
if (currentRank > 0) {
database.prepare(`
DELETE FROM character_talents
WHERE character_id = ? AND talent_id = ?
`).run(characterId, talentId)
} else {
const capacity = talentEffectCapacity(character.level)
if (capacity <= 0) throw new Error('Spell effects unlock at level 5.')
const activeTalents = database.prepare(`
SELECT
talents.id,
talents.name,
talents.effect_type AS effectType
FROM character_talents
JOIN talents ON talents.id = character_talents.talent_id
WHERE character_talents.character_id = ?
AND talents.class_id = ?
AND character_talents.rank > 0
`).all(characterId, character.classId)
const source = talentEffectSource(talent.effectType)
const sourceConflict = activeTalents.find(
(candidate) => candidate.id !== talentId && talentEffectSource(candidate.effectType) === source,
)
if (sourceConflict) {
throw new Error(`Only one ${source} spell effect can be active.`)
}
const activeCount = activeTalents.length
if (activeCount >= capacity) {
throw new Error(`Level ${character.level} allows ${capacity} active spell effect${capacity === 1 ? '' : 's'}.`)
}
database.prepare(`
INSERT INTO character_talents (character_id, talent_id, rank)
VALUES (?, ?, 1)
ON CONFLICT(character_id, talent_id)
DO UPDATE SET rank = 1
`).run(characterId, talentId)
}
database.exec('COMMIT')
} catch (error) {
database.exec('ROLLBACK')
throw error
}
return getProfile(database, characterId)
}
if (character.talentPoints <= 0) {
throw new Error('No talent points are available.')
}
@@ -1949,11 +2038,13 @@ function resetTalents(database, characterId) {
WHERE character_id = ?
AND talent_id IN (SELECT id FROM talents WHERE class_id = ?)
`).run(characterId, character.classId)
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 +2115,21 @@ function completeDungeon(database, characterId, accountId, dungeonId, difficulty
const completedPart = Math.min(Math.max(Number(runMetrics?.completedPart) || 1, 1), 3)
const startPart = Math.min(Math.max(Number(runMetrics?.startPart) || 1, 1), 3)
const completedParts = completedPart - startPart + 1
const rewardMultiplier = runMetrics?.hardMode ? 2 : 1
const rawPartDurations = runMetrics?.partDurationSeconds
const partDurationSeconds = Array.isArray(rawPartDurations) && rawPartDurations.length === 3
? rawPartDurations.map(Number)
: null
const experienceReward = Math.round(
dungeon.experienceReward * dungeon.experienceMultiplier * completedPart,
const baseExperienceReward = Math.round(
dungeon.experienceReward * dungeon.experienceMultiplier * completedPart * rewardMultiplier,
)
const experienceReward = catchUpExperienceReward(
database,
accountId,
characterId,
baseExperienceReward,
character.experience,
character.level,
)
const newExperience = Math.min(character.experience + experienceReward, maxExperience)
const newLevel = database.prepare(`
@@ -2127,17 +2227,18 @@ function completeDungeon(database, characterId, accountId, dungeonId, difficulty
`).all(dungeonId, dungeon.completionItemLevel ?? dungeon.droppedItemLevel + 3)
if (bonusItems.length > 0) {
bonusItem = bonusItems[0]
const rewardQuantity = rewardMultiplier
const previousQuantity = database.prepare(`
SELECT quantity FROM character_inventory
WHERE character_id = ? AND item_id = ?
`).get(characterId, bonusItem.id)?.quantity ?? 0
database.prepare(`
INSERT INTO character_inventory (character_id, item_id, quantity, equipped)
VALUES (?, ?, 1, 0)
VALUES (?, ?, ?, 0)
ON CONFLICT(character_id, item_id)
DO UPDATE SET quantity = quantity + 1
`).run(characterId, bonusItem.id)
bonusItem = { ...bonusItem, quantity: 1, duplicate: previousQuantity > 0, quantityAfter: previousQuantity + 1 }
DO UPDATE SET quantity = quantity + ?
`).run(characterId, bonusItem.id, rewardQuantity, rewardQuantity)
bonusItem = { ...bonusItem, quantity: rewardQuantity, duplicate: previousQuantity > 0, quantityAfter: previousQuantity + rewardQuantity }
}
}
@@ -2234,6 +2335,12 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
let newExperience = character.experience
let newLevel = character.level
if (experienceMode === 'pvp-boss-quarter-level') {
const catchUpTargetLevel = database.prepare(`
SELECT COALESCE(MAX(level), 0) AS level
FROM characters
WHERE account_id = ?
AND id != ?
`).get(accountId, characterId).level
for (let bossIndex = 0; bossIndex < bossesCleared && newExperience < maxExperience; bossIndex += 1) {
const currentLevelFloor = database.prepare(`
SELECT experience_required AS experienceRequired
@@ -2248,7 +2355,8 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
WHERE level = ?
`).get(newLevel + 1).experienceRequired
const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor)
newExperience = Math.min(maxExperience, newExperience + Math.round(levelBand * 0.25))
const rewardRate = catchUpTargetLevel > newLevel ? 0.5 : 0.25
newExperience = Math.min(maxExperience, newExperience + Math.round(levelBand * rewardRate))
newLevel = database.prepare(`
SELECT MAX(level) AS level
FROM level_progression
@@ -2256,9 +2364,17 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
`).get(newExperience).level
}
} else {
const experienceReward = Math.round(
const baseExperienceReward = Math.round(
dungeon.experienceReward * dungeon.experienceMultiplier * (encountersCleared / 3),
)
const experienceReward = catchUpExperienceReward(
database,
accountId,
characterId,
baseExperienceReward,
character.experience,
character.level,
)
newExperience = Math.min(character.experience + experienceReward, maxExperience)
newLevel = database.prepare(`
SELECT MAX(level) AS level
+497 -37
View File
@@ -1683,7 +1683,8 @@ h2 {
.equipment-screen .equipment-layout,
.equipment-screen .crafting-panel,
.talent-screen .talent-tree {
.talent-screen .talent-tree,
.talent-screen .spell-effect-layout {
flex: 1;
min-height: 0;
}
@@ -1794,12 +1795,58 @@ h2 {
font-size: 14px;
}
.run-title-row {
align-items: center;
display: flex;
gap: 10px;
justify-content: space-between;
}
.run-title-row h2 {
min-width: 0;
}
.inline-back-button {
flex: 0 0 auto;
min-height: 34px;
padding: 6px 10px;
}
.run-setup-heading small {
color: var(--muted);
font-size: 16px;
text-align: right;
}
.activity-pager {
align-items: center;
display: flex;
gap: 8px;
}
.activity-pager button {
background: #15161c;
border: 2px solid #090a0d;
color: var(--ink);
cursor: pointer;
font: inherit;
font-size: 11px;
min-height: 34px;
outline: 2px solid #41404a;
padding: 6px 8px;
}
.activity-pager button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.activity-pager span {
color: var(--muted);
font-size: 13px;
white-space: nowrap;
}
.tier-grid {
display: grid;
gap: 10px;
@@ -1919,7 +1966,7 @@ h2 {
}
.part-setup-panel .part-picker {
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: 1fr;
}
.part-setup-panel .primary-button {
@@ -2014,14 +2061,6 @@ h2 {
padding: 10px;
}
.dungeon-run-screen .screen-heading {
padding-bottom: 10px;
}
.dungeon-run-screen .screen-heading h1 {
font-size: 18px;
}
.dungeon-run-board,
.dungeon-run-main {
gap: 10px;
@@ -2058,6 +2097,22 @@ h2 {
font-size: 12px;
}
.inline-back-button {
font-size: 12px;
min-height: 28px;
padding: 4px 7px;
}
.activity-pager button {
font-size: 9px;
min-height: 28px;
padding: 4px 6px;
}
.activity-pager span {
font-size: 11px;
}
.dungeon-run-screen .eyebrow {
font-size: 7px;
margin-bottom: 5px;
@@ -2196,21 +2251,6 @@ h2 {
padding: 6px;
}
.dungeon-run-screen .screen-heading {
padding-bottom: 4px;
}
.dungeon-run-screen .screen-heading h1 {
font-size: 13px;
line-height: 1.15;
}
.dungeon-run-screen .back-button {
font-size: 14px;
min-height: 34px;
padding: 5px 8px;
}
.dungeon-run-board,
.dungeon-run-main,
.dungeon-setup-rail {
@@ -2247,6 +2287,12 @@ h2 {
line-height: 1.2;
}
.inline-back-button {
font-size: 10px;
min-height: 24px;
padding: 3px 6px;
}
.dungeon-run-screen .eyebrow,
.dungeon-run-screen .tier-grid strong,
.dungeon-choice-grid .activity-card strong,
@@ -2262,6 +2308,20 @@ h2 {
margin-top: 5px;
}
.activity-pager {
gap: 4px;
}
.activity-pager button {
font-size: 8px;
min-height: 24px;
padding: 3px 5px;
}
.activity-pager span {
font-size: 9px;
}
.dungeon-choice-grid {
grid-auto-rows: minmax(52px, max-content);
}
@@ -3444,6 +3504,260 @@ h2 {
margin-top: 17px;
}
.talent-empty-state {
background: var(--panel-light);
border: 2px solid #090a0d;
margin-top: 17px;
outline: 2px solid #41404a;
padding: 18px;
}
.spell-effect-layout {
display: grid;
gap: 14px;
grid-template-columns: 220px minmax(0, 1fr);
margin-top: 17px;
min-height: 0;
}
.effect-slots-panel,
.effect-pool-panel {
background: #191b25;
border: 2px solid #090a0d;
display: flex;
flex-direction: column;
min-height: 0;
outline: 2px solid #3a3944;
padding: 12px;
}
.effect-slots-panel {
display: grid;
gap: 10px;
grid-auto-rows: minmax(76px, auto);
}
.effect-slot {
background: #20222d;
border: 2px solid #090a0d;
color: var(--ink);
cursor: pointer;
outline: 2px solid #3a3944;
padding: 10px;
text-align: left;
}
.effect-slot.filled {
background: #29291f;
outline-color: var(--gold);
}
.effect-slot.locked {
opacity: 0.58;
}
.effect-slot span,
.effect-pool > button i {
color: var(--gold);
font-family: 'Press Start 2P', monospace;
font-size: 8px;
text-transform: uppercase;
}
.effect-slot strong,
.effect-slot small {
display: block;
}
.effect-slot strong {
font-family: 'Press Start 2P', monospace;
font-size: 8px;
line-height: 1.35;
margin-top: 8px;
}
.effect-slot small {
color: var(--muted);
font-size: 14px;
line-height: 1;
margin-top: 6px;
}
.effect-panel-heading {
align-items: center;
display: flex;
justify-content: space-between;
}
.effect-panel-heading > span {
color: var(--gold);
font-family: 'Press Start 2P', monospace;
font-size: 9px;
}
.selected-effect-strip {
align-items: center;
background: #20222d;
border: 2px solid #090a0d;
display: grid;
gap: 12px;
grid-template-columns: minmax(0, 1fr) auto;
margin-top: 12px;
outline: 2px solid #3a3944;
padding: 10px;
}
.selected-effect-strip strong,
.selected-effect-strip small {
display: block;
}
.selected-effect-strip strong {
color: var(--gold);
font-family: 'Press Start 2P', monospace;
font-size: 9px;
line-height: 1.35;
margin-top: 5px;
}
.selected-effect-strip small {
color: var(--muted);
font-size: 15px;
line-height: 1;
margin-top: 5px;
}
.selected-effect-strip .primary-button {
min-width: 120px;
padding: 9px 12px;
}
.effect-pool {
align-content: start;
display: grid;
flex: 1;
gap: 10px;
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-rows: repeat(2, 62px);
margin-top: 12px;
min-height: 0;
overflow: hidden;
}
.effect-pool > button {
align-content: center;
background: #20222d;
border: 2px solid #090a0d;
color: var(--ink);
cursor: pointer;
display: grid;
gap: 6px;
grid-template-columns: 26px minmax(0, 1fr);
min-height: 0;
outline: 2px solid #3a3944;
overflow: hidden;
padding: 7px;
text-align: left;
}
.effect-pool > button.active {
background: #29291f;
outline-color: var(--gold);
}
.effect-pool > button.selected {
border-color: var(--gold);
}
.effect-pool > button:disabled:not(.active) {
cursor: not-allowed;
opacity: 0.55;
}
.effect-pool > button > span {
align-items: center;
background: #15161c;
border: 1px solid #55515f;
color: var(--gold);
display: flex;
font-family: 'Press Start 2P', monospace;
height: 26px;
justify-content: center;
}
.effect-pool strong,
.effect-pool small {
display: block;
}
.effect-pool strong {
font-family: 'Press Start 2P', monospace;
font-size: 7px;
line-height: 1.35;
}
.effect-pool small {
color: var(--muted);
font-size: 11px;
line-height: 1;
margin-top: 5px;
}
.effect-pool > button i {
grid-column: 1 / -1;
line-height: 1;
}
.effect-pager {
align-items: center;
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 8px;
}
.effect-pager button {
background: #15161c;
border: 2px solid #090a0d;
color: var(--ink);
cursor: pointer;
font-family: 'Press Start 2P', monospace;
font-size: 7px;
min-height: 28px;
outline: 2px solid #41404a;
padding: 4px 8px;
text-transform: uppercase;
}
.effect-pager button:disabled {
color: #676773;
cursor: not-allowed;
}
.effect-pager span {
color: var(--gold);
font-family: 'Press Start 2P', monospace;
font-size: 8px;
}
@media (max-width: 800px) {
.spell-effect-layout {
grid-template-columns: 1fr;
}
.effect-slots-panel {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.effect-pool {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.selected-effect-strip {
grid-template-columns: 1fr;
}
}
.talent-tier {
align-items: stretch;
border-bottom: 1px solid #393943;
@@ -4707,6 +5021,28 @@ h2 {
box-shadow: inset 0 5px #cf4b59;
}
.hard-enemy-bars {
display: grid;
gap: 6px;
}
.hard-enemy-bars .enemy-health {
position: relative;
}
.hard-enemy-bars .enemy-health em {
color: #fff7df;
font-family: 'Press Start 2P', monospace;
font-size: 7px;
font-style: normal;
left: 8px;
position: absolute;
text-shadow: 0 1px 0 #111;
top: 50%;
transform: translateY(-50%);
z-index: 1;
}
.combat-layout {
display: grid;
gap: 18px;
@@ -5077,6 +5413,19 @@ h2 {
margin-bottom: 4px;
}
.speed-badge {
background: var(--gold);
border: 2px solid #0a0b0e;
color: #21180a;
display: inline-block;
font-family: 'Press Start 2P', monospace;
font-size: 8px;
line-height: 1;
margin: 0 0 5px;
padding: 5px 7px;
text-transform: uppercase;
}
.action-panel .resource-row {
justify-content: flex-start;
}
@@ -7322,6 +7671,125 @@ h2 {
margin-top: 4px;
}
.workshop-shell .spell-effect-layout {
gap: 6px;
grid-template-columns: 172px minmax(0, 1fr);
margin-top: 5px;
overflow: hidden;
}
.workshop-shell .effect-slots-panel,
.workshop-shell .effect-pool-panel {
padding: 5px;
}
.workshop-shell .effect-slots-panel {
gap: 5px;
grid-auto-rows: minmax(43px, 1fr);
}
.workshop-shell .effect-slot {
min-height: 0;
overflow: hidden;
padding: 5px;
}
.workshop-shell .effect-slot span,
.workshop-shell .effect-pool > button i,
.workshop-shell .effect-panel-heading > span,
.workshop-shell .effect-pager span {
font-size: 6px;
}
.workshop-shell .effect-slot strong {
font-size: 6px;
line-height: 1.15;
margin-top: 3px;
}
.workshop-shell .effect-slot small {
font-size: 9px;
line-height: 1;
margin-top: 2px;
max-height: 18px;
overflow: hidden;
}
.workshop-shell .effect-panel-heading h2 {
font-size: 9px;
line-height: 1.1;
}
.workshop-shell .selected-effect-strip {
gap: 6px;
grid-template-columns: minmax(0, 1fr) auto;
margin-top: 5px;
padding: 5px;
}
.workshop-shell .selected-effect-strip .eyebrow {
display: none;
}
.workshop-shell .selected-effect-strip strong {
font-size: 7px;
line-height: 1.1;
margin-top: 0;
}
.workshop-shell .selected-effect-strip small {
font-size: 10px;
line-height: 1;
margin-top: 3px;
max-height: 20px;
overflow: hidden;
}
.workshop-shell .selected-effect-strip .primary-button {
font-size: 7px;
min-height: 25px;
min-width: 72px;
padding: 3px 6px;
}
.workshop-shell .effect-pool {
gap: 5px;
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-rows: repeat(2, 52px);
margin-top: 5px;
}
.workshop-shell .effect-pool > button {
gap: 4px;
grid-template-columns: 22px minmax(0, 1fr);
padding: 4px;
}
.workshop-shell .effect-pool > button > span {
height: 22px;
}
.workshop-shell .effect-pool strong {
font-size: 6px;
line-height: 1.15;
}
.workshop-shell .effect-pool small {
font-size: 9px;
margin-top: 2px;
}
.workshop-shell .effect-pager {
gap: 5px;
margin-top: 4px;
}
.workshop-shell .effect-pager button {
font-size: 6px;
min-height: 22px;
padding: 2px 5px;
}
.workshop-shell .talent-tier {
gap: 6px;
grid-template-columns: 66px minmax(0, 1fr);
@@ -7567,6 +8035,7 @@ h2 {
flex: 1;
gap: 5px;
grid-template-columns: repeat(5, minmax(0, 1fr));
grid-template-rows: repeat(2, minmax(52px, 1fr));
margin-top: 5px;
max-height: none;
min-height: 0;
@@ -7605,10 +8074,6 @@ h2 {
font-size: 8px;
}
.workshop-shell .ability-library > button:nth-child(n+6) {
display: none;
}
.workshop-shell .save-row .primary-button {
font-size: 8px;
min-height: 28px;
@@ -7682,11 +8147,8 @@ h2 {
}
.workshop-shell .ability-library {
grid-template-columns: 1fr;
}
.workshop-shell .ability-library > button:nth-child(n+6) {
display: none;
grid-template-columns: repeat(5, minmax(0, 1fr));
grid-template-rows: repeat(2, minmax(52px, 1fr));
}
.workshop-bottom-grid {
@@ -7814,6 +8276,7 @@ h2 {
flex: 1;
gap: 5px;
grid-template-columns: repeat(5, minmax(0, 1fr));
grid-template-rows: repeat(2, minmax(52px, 1fr));
margin-top: 5px;
max-height: none;
min-height: 0;
@@ -7843,9 +8306,6 @@ h2 {
line-height: 1;
}
.workshop-shell .ability-library > button:nth-child(n+6) {
display: none;
}
}
@media (max-width: 700px) and (max-height: 620px) {
+178 -125
View File
@@ -55,6 +55,7 @@ const MENU_ITEMS: Array<{
const LAST_DIFFICULTY_KEY = 'i-want-to-heal:last-difficulty'
const SHOW_LEADERBOARDS = false
const ACTIVITY_PAGE_SIZE = 4
function activityInitials(name: string) {
return name
@@ -81,13 +82,14 @@ function App() {
return Number.isFinite(saved) && saved > 0 ? saved : 1
})
const [selectedDungeonId, setSelectedDungeonId] = useState(1)
const [selectedRaidId, setSelectedRaidId] = useState(2)
const [selectedRaidId, setSelectedRaidId] = useState(20)
const [roguelikeKind, setRoguelikeKind] = useState<'dungeon' | 'raid'>('dungeon')
const [roguelikeVariant, setRoguelikeVariant] = useState<RoguelikeVariant>('pve')
const [roguelikeUpgradeTiming, setRoguelikeUpgradeTiming] = useState<RoguelikeUpgradeTiming>('encounter')
const [roguelikeAbilityLabelMode, setRoguelikeAbilityLabelMode] = useState<RoguelikeAbilityLabelMode>('ability')
const [pvpContentType, setPvpContentType] = useState<PvpContentType>('dungeon')
const [selectedPart, setSelectedPart] = useState(1)
const [selectedMarathonMode, setSelectedMarathonMode] = useState(false)
const [activityPage, setActivityPage] = useState(0)
const [combatContentId, setCombatContentId] = useState(1)
const [leaderboardCategory, setLeaderboardCategory] = useState<'part_1' | 'part_2' | 'part_3' | 'full_run'>('part_1')
const [showLoot, setShowLoot] = useState(false)
@@ -230,17 +232,18 @@ function App() {
const roguelikePool = profile.dungeons
.filter((candidate) => candidate.contentType === roguelikeKind)
.flatMap((candidate) => candidate.encounters)
const startPart = selectedPart
return (
<CombatScreen
difficulty={difficulty}
dungeon={dungeon}
hardMode={false}
marathonMode={selectedMarathonMode && combatContentId > 0}
profile={profile}
roguelikeMode={combatContentId < 0 ? roguelikeKind : undefined}
roguelikeUpgradeTiming={combatContentId < 0 ? roguelikeUpgradeTiming : undefined}
roguelikeAbilityLabelMode={combatContentId < 0 ? roguelikeAbilityLabelMode : undefined}
roguelikeEncounterPool={combatContentId < 0 ? roguelikePool : undefined}
startPart={startPart}
startPart={1}
onExit={() => {
setScreen(combatContentId < 0 ? 'roguelike' : dungeon.contentType === 'raid' ? 'raids' : 'dungeons')
}}
@@ -283,6 +286,19 @@ 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)
}
setSelectedMarathonMode(false)
setScreen('combat')
}
const tierOptions = activityOptions
.flatMap((option) => option.difficulties)
.filter((difficulty, index, all) => (
@@ -299,26 +315,24 @@ function App() {
?? tierOptions.slice().reverse().find((candidate) => profile.character.level >= candidate.unlockLevel)
?? tierOptions[0]
const selectedTierItemLevel = selectedTier?.droppedItemLevel ?? 0
const tierActivityOptions = activityOptions.filter((option) =>
option.difficulties.some((difficulty) => difficulty.droppedItemLevel === selectedTierItemLevel),
const activityPageCount = Math.max(1, Math.ceil(activityOptions.length / ACTIVITY_PAGE_SIZE))
const currentActivityPage = Math.min(activityPage, activityPageCount - 1)
const pagedActivityOptions = activityOptions.slice(
currentActivityPage * ACTIVITY_PAGE_SIZE,
currentActivityPage * ACTIVITY_PAGE_SIZE + ACTIVITY_PAGE_SIZE,
)
const activityPageStart = activityOptions.length === 0
? 0
: currentActivityPage * ACTIVITY_PAGE_SIZE + 1
const activityPageEnd = Math.min(activityOptions.length, (currentActivityPage + 1) * ACTIVITY_PAGE_SIZE)
const selectedActivityId = screen === 'raids' && raid ? raid.id : dungeon.id
const activity = tierActivityOptions.find((candidate) => candidate.id === selectedActivityId)
?? tierActivityOptions[0]
const activity = activityOptions.find((candidate) => candidate.id === selectedActivityId)
?? activityOptions[0]
?? (screen === 'raids' && raid ? raid : dungeon)
const selectedDifficulty = activity.difficulties.find(
(candidate) => candidate.droppedItemLevel === selectedTierItemLevel,
) ?? activity.difficulties[0]
const difficultyLocked = profile.character.level < selectedDifficulty.unlockLevel
const completedSections = activity.contentType === 'raid'
? profile.completedRaidPhases
: profile.completedDungeonParts
const sectionName = activity.contentType === 'raid' ? 'Phase' : 'Part'
const parts = [
{ part: 1, name: `${sectionName} 1`, encounterCount: 3, unlocked: true },
{ part: 2, name: `${sectionName} 2`, encounterCount: 3, unlocked: completedSections >= 1 },
{ part: 3, name: `${sectionName} 3`, encounterCount: 3, unlocked: completedSections >= 2 },
]
const cloudSync = getCloudSyncStatus()
const canShowCloudSync = account.id !== -1 && cloudSync.available
const lootPreviewEncounters = [...activity.encounters]
@@ -425,84 +439,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' && (
@@ -587,11 +607,6 @@ function App() {
{(screen === 'dungeons' || screen === 'raids') && (
<section className="content-screen dungeon-run-screen">
<ScreenHeading
eyebrow="Adventure"
title={activity.contentType === 'raid' ? 'Raids' : 'Dungeons'}
onBack={() => setScreen('menu')}
/>
<div className="dungeon-run-board">
<div className="dungeon-run-main">
<article className="run-summary-card dungeon-focus-card">
@@ -600,7 +615,10 @@ function App() {
</div>
<div className="run-summary-copy">
<p className="eyebrow">Selected Run</p>
<h2>{activity.name}</h2>
<div className="run-title-row">
<h2>{activity.name}</h2>
<button className="back-button inline-back-button" onClick={() => setScreen('menu')} type="button">Back</button>
</div>
<p>{activity.description}</p>
<div className="tag-row">
<span>Level {activity.recommendedLevel}</span>
@@ -618,10 +636,30 @@ function App() {
<p className="eyebrow">Pick Run</p>
<h2>{screen === 'raids' ? 'Raid' : 'Dungeon'}</h2>
</div>
<small>{selectedDifficulty.name} rewards iLvl {selectedDifficulty.droppedItemLevel} components.</small>
{activityPageCount > 1 ? (
<div className="activity-pager" aria-label={`${screen === 'raids' ? 'Raid' : 'Dungeon'} pages`}>
<button
disabled={currentActivityPage === 0}
onClick={() => setActivityPage((page) => Math.max(0, page - 1))}
type="button"
>
Prev
</button>
<span>{activityPageStart}-{activityPageEnd} of {activityOptions.length}</span>
<button
disabled={currentActivityPage >= activityPageCount - 1}
onClick={() => setActivityPage((page) => Math.min(activityPageCount - 1, page + 1))}
type="button"
>
Next
</button>
</div>
) : (
<small>{selectedDifficulty.name} rewards iLvl {selectedDifficulty.droppedItemLevel} components.</small>
)}
</div>
<div className="activity-card-grid dungeon-choice-grid">
{tierActivityOptions.map((candidate) => {
{pagedActivityOptions.map((candidate) => {
const difficulty = candidate.difficulties.find(
(option) => option.droppedItemLevel === selectedDifficulty.droppedItemLevel,
) ?? candidate.difficulties[0]
@@ -673,6 +711,7 @@ function App() {
disabled={locked}
key={difficulty.id}
onClick={() => {
setActivityPage(0)
const nextActivity = activity.difficulties.some(
(candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel,
)
@@ -703,27 +742,41 @@ function App() {
<div className="run-setup-heading">
<div>
<p className="eyebrow">Start</p>
<h2>{sectionName}</h2>
<h2>Run</h2>
</div>
<small>{difficultyLocked ? `Unlocks at level ${selectedDifficulty.unlockLevel}` : 'Choose a section to launch.'}</small>
<small>
{difficultyLocked
? `Unlocks at level ${selectedDifficulty.unlockLevel}`
: 'Marathon keeps health and mana between boss kills.'}
</small>
</div>
<div className="part-picker">
{parts.map((p) => (
<button
key={p.part}
className={`primary-button ${selectedPart === p.part ? 'selected-part' : ''} ${!p.unlocked ? 'locked' : ''}`}
disabled={difficultyLocked || !p.unlocked}
onClick={() => {
setSelectedPart(p.part)
setCombatContentId(activity.id)
setSelectedDifficultyId(selectedDifficulty.id)
setScreen('combat')
}}
type="button"
>
{p.name}
</button>
))}
<button
className="primary-button selected-part"
disabled={difficultyLocked}
onClick={() => {
setSelectedMarathonMode(false)
setCombatContentId(activity.id)
setSelectedDifficultyId(selectedDifficulty.id)
setScreen('combat')
}}
type="button"
>
Start Hunt
</button>
<button
className={`primary-button ${selectedMarathonMode ? 'selected-part' : ''}`}
disabled={difficultyLocked}
onClick={() => {
setSelectedMarathonMode(true)
setCombatContentId(activity.id)
setSelectedDifficultyId(selectedDifficulty.id)
setScreen('combat')
}}
type="button"
>
Marathon
</button>
</div>
</section>
@@ -836,10 +889,10 @@ function App() {
</p>
<div className="leaderboard-tabs">
{([
{ key: 'part_1', label: `${sectionName} 1` },
{ key: 'part_2', label: `${sectionName} 2` },
{ key: 'part_3', label: `${sectionName} 3` },
{ key: 'full_run', label: 'Full Run' },
{ key: 'part_1', label: 'Run' },
{ key: 'part_2', label: 'Legacy 2' },
{ key: 'part_3', label: 'Legacy 3' },
{ key: 'full_run', label: 'Legacy Full' },
] as const).map((tab) => (
<button
key={tab.key}
+404 -97
View File
@@ -10,6 +10,10 @@ import {
import {
INITIAL_PARTY,
RAID_PARTY,
DEFAULT_GROUP_HEAL_TARGETS,
groupHealTargets,
partyDamageOutput,
tankPressureTargets,
type CombatLogEntry,
type PartyMember,
type Spell,
@@ -39,7 +43,7 @@ const TICK_MS = 700
type RoguelikeMode = 'dungeon' | 'raid'
type RoguelikeUpgradeTiming = 'boss' | 'encounter'
type RoguelikeAbilityLabelMode = 'ability' | 'slot'
type SlotKey = '1' | '2' | '3' | '4' | '5'
type SlotKey = '1' | '2' | '3' | '4' | '5' | '6'
type RoguelikeMechanic =
| 'party-pulse'
| 'searing-mark'
@@ -101,12 +105,53 @@ function effectiveMaxHealth(member: PartyMember) {
return Math.max(1, Math.round(member.maxHealth * (member.maxHealthPenaltyTicks && member.maxHealthPenaltyTicks > 0 ? 0.75 : 1)))
}
function healAmount(member: PartyMember, amount: number) {
return Math.round(amount * (member.healingReductionTicks && member.healingReductionTicks > 0 ? 0.75 : 1))
function healAmount(member: PartyMember, amount: number, multiplier = 1) {
return Math.round(amount * (member.healingReductionTicks && member.healingReductionTicks > 0 ? 0.75 : 1) * multiplier)
}
function healMember(member: PartyMember, amount: number) {
return clamp(member.health + healAmount(member, amount), 0, effectiveMaxHealth(member))
function healMember(member: PartyMember, amount: number, multiplier = 1) {
return clamp(member.health + healAmount(member, amount, multiplier), 0, effectiveMaxHealth(member))
}
function memberHotEffects(member: PartyMember) {
if (member.hotEffects?.length) return member.hotEffects
return member.hotTicks > 0
? [{ id: 'legacy-renew', spellId: 'legacy-renew', label: 'Renew', ticks: member.hotTicks, power: 6 }]
: []
}
function effectId(prefix: string) {
return `${prefix}-${globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`}`
}
function addHotEffect(member: PartyMember, spell: Spell, ticks = 5) {
const nextEffect = {
id: effectId(spell.id),
spellId: spell.id,
label: spell.name,
ticks,
power: Math.max(1, Math.round(spell.power / 2)),
}
const currentEffects = memberHotEffects(member).filter((effect) => effect.spellId !== spell.id)
return [...currentEffects, nextEffect]
}
function addBounceHeal(member: PartyMember, spell: Spell) {
return [
...(member.bounceHeals ?? []),
{
id: effectId(spell.id),
label: spell.name,
charges: 4,
power: spell.power,
},
]
}
function tickHotEffects(effects: PartyMember['hotEffects']) {
return (effects ?? [])
.map((effect) => ({ ...effect, ticks: effect.ticks - 1 }))
.filter((effect) => effect.ticks > 0)
}
function upgradeStackCount(upgrades: RoguelikeUpgrade[], id: RoguelikeUpgradeId) {
@@ -123,7 +168,7 @@ function buildRoguelikeUpgrades(
spells: Spell[],
labelMode: RoguelikeAbilityLabelMode,
): RoguelikeUpgrade[] {
const slotUpgrades = (['1', '2', '3', '4', '5'] as SlotKey[]).flatMap((slot) => {
const slotUpgrades = (['1', '2', '3', '4', '5', '6'] as SlotKey[]).flatMap((slot) => {
const label = slotLabel(slot, spells, labelMode)
return [
{
@@ -191,9 +236,14 @@ function spellResourceCost(spell: Spell, upgrades: RoguelikeUpgrade[], freeCastR
function toCombatSpell(ability: Ability, key: string, healingPower: number): Spell {
const kinds: Record<string, Spell['kind']> = {
direct_heal: 'direct',
direct_hot: 'direct',
heal_over_time: 'hot',
party_heal: 'group',
party_hot: 'group',
party_absorb: 'group',
absorb: 'shield',
damage_reduction: 'damage_reduction',
bounce_heal: 'bounce_heal',
cleanse: 'cleanse',
}
return {
@@ -206,6 +256,7 @@ function toCombatSpell(ability: Ability, key: string, healingPower: number): Spe
power: ability.power + healingPower,
glyph: ability.glyph,
kind: kinds[ability.spellType] ?? 'direct',
effectType: ability.spellType,
}
}
@@ -289,6 +340,8 @@ function makeRoguelikeSegment(
export function CombatScreen({
difficulty,
dungeon,
hardMode = false,
marathonMode = false,
profile,
startPart = 1,
roguelikeMode,
@@ -300,6 +353,8 @@ export function CombatScreen({
}: {
difficulty: Difficulty
dungeon: Dungeon
hardMode?: boolean
marathonMode?: boolean
profile: CharacterProfile
startPart?: number
roguelikeMode?: RoguelikeMode
@@ -347,23 +402,25 @@ export function CombatScreen({
})),
[dungeon.partySize, profile.character.name],
)
const sectionName = isRoguelike ? 'Stage' : dungeon.contentType === 'raid' ? 'Phase' : 'Part'
const sectionName = isRoguelike ? 'Stage' : 'Run'
const contentName = isRoguelike ? 'Roguelike' : dungeon.contentType === 'raid' ? 'Raid' : 'Dungeon'
const initialEncounterIndex = (startPart - 1) * 3
const enemyCount = hardMode ? 2 : 1
const initialCombatState = useMemo<SinglePlayerCombatState>(() => ({
party: partyTemplate,
resource: maxResource,
enemyHealth: encounters[initialEncounterIndex].maxHealth,
enemyHealth: encounters[initialEncounterIndex].maxHealth * enemyCount,
cooldowns: {},
elapsedTicks: 0,
castsTowardFree: 0,
freeCastReady: false,
}), [encounters, initialEncounterIndex, maxResource, partyTemplate])
}), [encounters, enemyCount, initialEncounterIndex, maxResource, partyTemplate])
const [combatState, setCombatState] = useState<SinglePlayerCombatState>(() => initialCombatState)
const [selectedId, setSelectedId] = useState(partyTemplate[0].id)
const [encounterIndex, setEncounterIndex] = useState(initialEncounterIndex)
const [status, setStatus] = useState<'playing' | 'won' | 'lost' | 'part-complete' | 'upgrade-choice'>('playing')
const [status, setStatus] = useState<'playing' | 'won' | 'lost' | 'part-complete' | 'marathon-choice' | 'upgrade-choice'>('playing')
const [paused, setPaused] = useState(false)
const [speedMultiplier, setSpeedMultiplier] = useState<1 | 2>(1)
const [targetGroup, setTargetGroup] = useState<0 | 1 | 2>(0)
const [log, setLog] = useState<CombatLogEntry[]>([
{ id: 1, text: `${dungeon.name} begins.`, tone: 'system' },
@@ -375,15 +432,17 @@ export function CombatScreen({
const [floatingTexts, setFloatingTexts] = useState<FloatingCombatText[]>([])
const [roguelikeUpgrades, setRoguelikeUpgrades] = useState<RoguelikeUpgrade[]>([])
const [upgradeChoices, setUpgradeChoices] = useState<RoguelikeUpgrade[]>([])
const [marathonBossesDefeated, setMarathonBossesDefeated] = useState(0)
const rewardClaimedRef = useRef(false)
const profileRefreshedRef = useRef(false)
const rolledEncounterIdsRef = useRef(new Set<number>())
const rolledEncounterIdsRef = useRef(new Set<string>())
const runTokenRef = useRef(crypto.randomUUID())
const resourceSpentRef = useRef(0)
const runStartedAtRef = useRef(0)
const partStartTimesRef = useRef<Record<number, number>>({})
const nextLogId = useRef(2)
const nextFloatingTextId = useRef(1)
const marathonBossesDefeatedRef = useRef(0)
const combatRef = useRef(initialCombatState)
const selectedIdRef = useRef(partyTemplate[0].id)
const runCombatTickRef = useRef<() => void>(() => {})
@@ -391,24 +450,40 @@ export function CombatScreen({
const lastCombatTickAtRef = useRef(performance.now())
const statusRef = useRef(status)
const pausedRef = useRef(paused)
const speedMultiplierRef = useRef<1 | 2>(speedMultiplier)
const { party, resource, enemyHealth, cooldowns, freeCastReady } = combatState
const encounter = encounters[encounterIndex]
const encounterMaxHealth = encounter.maxHealth * enemyCount
const currentPart = getCurrentPart(encounterIndex)
const completedSections = dungeon.contentType === 'raid'
? profile.completedRaidPhases
: profile.completedDungeonParts
const canContinueAfterPart = !hardMode || completedSections >= currentPart + 1
const firstEncounterIndex = (startPart - 1) * 3
const expectedLootRolls = encounters
.slice(firstEncounterIndex, encounterIndex + 1)
.filter((candidate) => candidate.lootTables.some((entry) => entry.difficultyId === difficulty.id))
.length
.length * enemyCount
const isPartBoss = encounter.isBoss && encounterIndex % 3 === 2
const isFinalBoss = isPartBoss && encounterIndex === encounters.length - 1
const playerHealer = party.find((member) => member.id === 'mira')
const playerIsAlive = Boolean(playerHealer && playerHealer.health > 0)
const upgradesEveryEncounter = roguelikeUpgradeTiming === 'encounter'
const activeSetEffects = useMemo(
() => isRoguelike
? new Set<string>()
: new Set(profile.setBonuses.filter((bonus) => bonus.active).map((bonus) => bonus.effectType)),
[isRoguelike, profile.setBonuses],
const activeEffects = useMemo(
() => {
const effects = new Set<string>(
gameClass.talents
.filter((talent) => talent.rank > 0)
.map((talent) => talent.effectType),
)
if (!isRoguelike) {
profile.setBonuses
.filter((bonus) => bonus.active)
.forEach((bonus) => effects.add(bonus.effectType))
}
return effects
},
[gameClass.talents, isRoguelike, profile.setBonuses],
)
const {
bindings,
@@ -422,6 +497,7 @@ export function CombatScreen({
statusRef.current = status
pausedRef.current = paused
speedMultiplierRef.current = speedMultiplier
useEffect(() => {
const now = Date.now()
@@ -480,10 +556,12 @@ export function CombatScreen({
}, [])
const requestLootRoll = useCallback(
(encounterId: number) => {
if (rolledEncounterIdsRef.current.has(encounterId)) return
rolledEncounterIdsRef.current.add(encounterId)
rollEncounterLoot(encounterId, difficulty.id, runTokenRef.current)
(encounterId: number, rollIndex = 0) => {
const rollKey = `${encounterId}:${rollIndex}:${marathonBossesDefeatedRef.current}`
if (rolledEncounterIdsRef.current.has(rollKey)) return
rolledEncounterIdsRef.current.add(rollKey)
const runToken = `${runTokenRef.current}-${marathonBossesDefeatedRef.current}-${rollIndex}`
rollEncounterLoot(encounterId, difficulty.id, runToken)
.then((result) => {
setLootRolls((current) => [...current, result])
const awarded = result.items
@@ -515,7 +593,7 @@ export function CombatScreen({
setCombat({
party: freshParty,
resource: maxResource,
enemyHealth: nextEncounters[initialEncounterIndex].maxHealth,
enemyHealth: nextEncounters[initialEncounterIndex].maxHealth * enemyCount,
cooldowns: {},
elapsedTicks: 0,
castsTowardFree: 0,
@@ -535,15 +613,17 @@ export function CombatScreen({
setFloatingTexts([])
setRoguelikeUpgrades([])
setUpgradeChoices([])
setMarathonBossesDefeated(0)
rewardClaimedRef.current = false
profileRefreshedRef.current = false
rolledEncounterIdsRef.current = new Set()
runTokenRef.current = crypto.randomUUID()
marathonBossesDefeatedRef.current = 0
resourceSpentRef.current = 0
runStartedAtRef.current = Date.now()
partStartTimesRef.current = { [startPart]: runStartedAtRef.current }
setLog([{ id: nextLogId.current++, text: 'A new run begins.', tone: 'system' }])
}, [difficulty, initialEncounterIndex, maxResource, partyTemplate, roguelikeMode, roguelikePool, setCombat, setSelectedTargetId, startPart, staticEncounters])
}, [difficulty, enemyCount, initialEncounterIndex, maxResource, partyTemplate, roguelikeMode, roguelikePool, setCombat, setSelectedTargetId, startPart, staticEncounters])
const castSpell = useCallback(
(spell: Spell) => {
@@ -558,23 +638,36 @@ export function CombatScreen({
const extraTarget = (blockedIds: string[]) => current.party
.filter((member) => member.health > 0 && !blockedIds.includes(member.id))
.sort((left, right) => (left.health / left.maxHealth) - (right.health / right.maxHealth))[0]
const effectSpell = (name: string) => {
const ability = gameClass.spells.find((candidate) => candidate.name === name)
return ability ? toCombatSpell(ability, `effect-${ability.id}`, healingPower) : null
}
const renewEffect = effectSpell('Renew')
const shieldEffect = effectSpell('Sun Ward')
const healingMultiplier = (member: PartyMember) =>
activeEffects.has('shielded_healing_bonus') && member.shield > 0 ? 1.2 : 1
const directTargets = new Set([targetId])
const hotTargets = new Set<string>()
const shieldTargets = new Set<string>()
if (spell.kind === 'hot') hotTargets.add(targetId)
const extraTargets = upgradeStackCount(roguelikeUpgrades, `slot${spell.key as SlotKey}-extra-target` as RoguelikeUpgradeId)
const groupTargets = new Set(
spell.kind === 'group'
? groupHealTargets(current.party, DEFAULT_GROUP_HEAL_TARGETS + extraTargets).map((member) => member.id)
: [],
)
if (spell.kind === 'hot' || spell.effectType === 'direct_hot') hotTargets.add(targetId)
if (spell.kind === 'shield') shieldTargets.add(targetId)
if (spell.name === 'Mend' && activeSetEffects.has('mend_extra_target')) {
if (spell.name === 'Mend' && activeEffects.has('mend_extra_target')) {
const extra = extraTarget([targetId])
if (extra) directTargets.add(extra.id)
}
if (spell.name === 'Renew' && activeSetEffects.has('renew_extra_target')) {
if (spell.name === 'Renew' && activeEffects.has('renew_extra_target')) {
const extra = extraTarget([targetId])
if (extra) hotTargets.add(extra.id)
}
if (spell.name === 'Mend' && activeSetEffects.has('mend_applies_renew')) {
if (spell.name === 'Mend' && activeEffects.has('mend_applies_renew')) {
hotTargets.add(targetId)
}
const extraTargets = upgradeStackCount(roguelikeUpgrades, `slot${spell.key as SlotKey}-extra-target` as RoguelikeUpgradeId)
for (let index = 0; index < extraTargets; index += 1) {
if (spell.kind === 'group') break
if (spell.kind === 'hot') {
@@ -594,20 +687,61 @@ export function CombatScreen({
const nextParty = current.party.map((member) => {
if (member.health <= 0) return member
if (spell.kind === 'group') {
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,
@@ -616,13 +750,23 @@ export function CombatScreen({
}
}
const nextHealth = directTargets.has(member.id)
? healMember(member, spell.power)
? healMember(member, spell.power, healingMultiplier(member))
: member.health
if (nextHealth > member.health) addFloatingHeal(member.id, nextHealth - member.health)
const nextShield = spell.name === 'Mend' && directTargets.has(member.id) && activeEffects.has('mend_applies_shield')
? Math.max(member.shield, Math.round((shieldEffect?.power ?? spell.power) * 0.5))
: member.shield
const appliedHotSpell = spell.name === 'Mend' && activeEffects.has('mend_applies_renew') && renewEffect
? renewEffect
: spell
return {
...member,
health: nextHealth,
hotTicks: hotTargets.has(member.id) ? 5 : member.hotTicks,
shield: nextShield,
hotTicks: 0,
hotEffects: hotTargets.has(member.id)
? addHotEffect(member, appliedHotSpell)
: member.hotEffects,
}
})
const freeCastStacks = upgradeStackCount(roguelikeUpgrades, 'fifth-cast-free')
@@ -640,20 +784,26 @@ export function CombatScreen({
&& !current.freeCastReady
&& current.castsTowardFree + 1 >= 5
resourceSpentRef.current += effectiveCost
const nextCooldowns = {
...current.cooldowns,
}
if (spell.name === 'Mend' && activeEffects.has('mend_reduces_radiance_cooldown')) {
const radiance = spells.find((candidate) => candidate.name === 'Radiance')
if (radiance) nextCooldowns[radiance.id] = Math.max(0, (nextCooldowns[radiance.id] ?? 0) - 2)
}
nextCooldowns[spell.id] = spell.cooldown * cooldownMultiplier(spell, roguelikeUpgrades)
setCombat({
...current,
party: nextParty,
resource: current.resource - effectiveCost,
cooldowns: {
...current.cooldowns,
[spell.id]: spell.cooldown * cooldownMultiplier(spell, roguelikeUpgrades),
},
cooldowns: nextCooldowns,
castsTowardFree: nextCastsTowardFree,
freeCastReady: gainedFreeCast || nextFreeCastReady,
})
addLog(`${spell.name} cast on ${spell.kind === 'group' ? 'the party' : selected.name}${effectiveCost === 0 ? ' for free' : ''}.`, 'heal')
},
[activeSetEffects, addFloatingHeal, addLog, roguelikeUpgrades, setCombat, status],
[activeEffects, addFloatingHeal, addLog, gameClass.spells, healingPower, roguelikeUpgrades, setCombat, spells, status],
)
const finishRun = useCallback(
@@ -676,6 +826,7 @@ export function CombatScreen({
completedPart,
runStartPart,
[partDuration(1), partDuration(2), partDuration(3)],
hardMode,
)
.then((result) => {
setReward(result)
@@ -688,7 +839,7 @@ export function CombatScreen({
)
})
},
[difficulty.id, dungeon.id, onProfileUpdated],
[difficulty.id, dungeon.id, hardMode, onProfileUpdated],
)
const finishRoguelikeRun = useCallback(
@@ -786,6 +937,9 @@ export function CombatScreen({
poisonStacks: undefined,
maxHealthPenaltyTicks: undefined,
healingReductionTicks: undefined,
hotEffects: [],
bounceHeals: [],
damageReductionTicks: undefined,
}))
const nextStage = clearedBoss ? roguelikeStage + 1 : roguelikeStage
const nextSegment = clearedBoss
@@ -804,7 +958,7 @@ export function CombatScreen({
setCombat({
...current,
party: recoveredParty,
enemyHealth: nextEncounter.maxHealth,
enemyHealth: nextEncounter.maxHealth * enemyCount,
elapsedTicks: 0,
cooldowns: {},
resource: clamp(current.resource + Math.round(maxResource * 0.25), 0, maxResource),
@@ -812,9 +966,13 @@ export function CombatScreen({
setUpgradeChoices([])
setStatus('playing')
addLog(`${upgrade.name} gained. ${nextEncounter.enemyName} approaches.`, 'system')
}, [addLog, difficulty, encounterIndex, encounters, maxResource, roguelikeMode, roguelikePool, roguelikeStage, setCombat])
}, [addLog, difficulty, encounterIndex, encounters, enemyCount, maxResource, roguelikeMode, roguelikePool, roguelikeStage, setCombat])
useGameAction((action, device) => {
if (action === 'toggleSpeed') {
if (status === 'playing') setSpeedMultiplier((value) => (value === 1 ? 2 : 1))
return
}
if (action === 'pause' || (action === 'back' && device === 'pc')) {
if (status === 'playing') setPaused((value) => !value)
return
@@ -902,19 +1060,53 @@ export function CombatScreen({
}
const healerBeforeDamage = current.party.find((member) => member.id === 'mira')
const nextParty = current.party.map((member) => {
const tankPressure = tankPressureTargets(current.party)
const tankPressureIds = new Set(tankPressure.targets.map((member) => member.id))
const pendingJumpHeals: Array<{
targetId: string
heal: NonNullable<PartyMember['bounceHeals']>[number]
}> = []
const damagedParty = current.party.map((member) => {
if (member.health <= 0) return member
let damage = member.id === primaryTarget.id ? encounter.damage : 0
if (member.role === 'Tank') damage += encounter.tankDamage
if (tankBuster && member.role === 'Tank') damage += Math.round(22 * difficulty.damageMultiplier)
if (tankPressureIds.has(member.id)) {
damage += Math.round(encounter.tankDamage * tankPressure.multiplier)
}
if (tankBuster && tankPressureIds.has(member.id)) {
damage += Math.round(22 * difficulty.damageMultiplier * tankPressure.multiplier)
}
if (bossPulse) damage += Math.round(12 * difficulty.damageMultiplier)
if (member.debuff) damage += Math.round(7 * difficulty.damageMultiplier)
const nextPoisonStacks = appliesPoison && member.id === primaryTarget.id
? Math.max(1, (member.poisonStacks ?? 0) + 1)
: member.poisonStacks ?? 0
if (nextPoisonStacks > 0) damage += Math.round((4 + nextPoisonStacks * 4) * difficulty.damageMultiplier)
damage *= enemyCount
if ((member.damageReductionTicks ?? 0) > 0) {
damage = Math.round(damage * 0.5)
}
if (member.shield > 0 && activeEffects.has('shielded_damage_reduction')) {
damage = Math.round(damage * 0.8)
}
const absorbed = Math.min(member.shield, damage)
const healing = member.hotTicks > 0 ? healAmount(member, 6) : 0
const hotEffects = memberHotEffects(member)
const healingMultiplier = member.shield > 0 && activeEffects.has('shielded_healing_bonus') ? 1.2 : 1
let healing = hotEffects.reduce((total, effect) => total + healAmount(member, effect.power, healingMultiplier), 0)
let nextBounceHeals = [...(member.bounceHeals ?? [])]
if (damage > 0 && nextBounceHeals.length > 0) {
nextBounceHeals = nextBounceHeals.flatMap((effect) => {
healing += healAmount(member, effect.power, healingMultiplier)
const nextCharges = effect.charges - 1
if (nextCharges <= 0) return []
const jumpTargets = current.party.filter((candidate) => candidate.health > 0 && candidate.id !== member.id)
const jumpTarget = jumpTargets[Math.floor(Math.random() * jumpTargets.length)] ?? member
pendingJumpHeals.push({
targetId: jumpTarget.id,
heal: { ...effect, charges: nextCharges },
})
return []
})
}
if (healing > 0) addFloatingHeal(member.id, healing)
const nextMaxHealthPenaltyTicks = appliesMaxHealthCut && member.id === primaryTarget.id
? 15
@@ -928,9 +1120,12 @@ export function CombatScreen({
: Math.max(0, (member.debuffTicks ?? 0) - 1)
return {
...member,
health: clamp(member.health - damage + absorbed + healing, 0, nextEffectiveMaxHealth),
health: clamp(clamp(member.health + healing, 0, nextEffectiveMaxHealth) - damage + absorbed, 0, nextEffectiveMaxHealth),
shield: Math.max(0, member.shield - damage),
hotTicks: Math.max(0, member.hotTicks - 1),
hotTicks: 0,
hotEffects: tickHotEffects(hotEffects),
bounceHeals: nextBounceHeals,
damageReductionTicks: Math.max(0, (member.damageReductionTicks ?? 0) - 1),
debuff: nextDebuffTicks > 0
? (appliesDebuff && member.id === primaryTarget.id ? 'Searing Mark' : member.debuff)
: undefined,
@@ -940,6 +1135,17 @@ export function CombatScreen({
healingReductionTicks: nextHealingReductionTicks,
}
})
const nextParty = damagedParty.map((member) => {
const jumped = pendingJumpHeals.filter((jump) => jump.targetId === member.id)
if (jumped.length === 0) return member
return {
...member,
bounceHeals: [
...(member.bounceHeals ?? []),
...jumped.map((jump) => jump.heal),
],
}
})
const healerAfterDamage = nextParty.find((member) => member.id === 'mira')
if (
@@ -966,7 +1172,7 @@ export function CombatScreen({
return
}
const nextEnemyHealth = current.enemyHealth - encounter.partyDamage
const nextEnemyHealth = current.enemyHealth - partyDamageOutput(nextParty, encounter.partyDamage)
if (nextEnemyHealth > 0) {
setCombat({
...current,
@@ -980,7 +1186,9 @@ export function CombatScreen({
}
if (!isRoguelike && encounter.lootTables.some((entry) => entry.difficultyId === difficulty.id)) {
requestLootRoll(encounter.id)
for (let rollIndex = 0; rollIndex < enemyCount; rollIndex += 1) {
requestLootRoll(encounter.id, rollIndex)
}
}
if (isRoguelike && (upgradesEveryEncounter || encounter.isBoss)) {
@@ -999,6 +1207,9 @@ export function CombatScreen({
}
if (isPartBoss && !isFinalBoss) {
const nextMarathonKills = marathonBossesDefeatedRef.current + 1
marathonBossesDefeatedRef.current = nextMarathonKills
setMarathonBossesDefeated(nextMarathonKills)
setCombat({
...current,
party: nextParty,
@@ -1007,12 +1218,28 @@ export function CombatScreen({
elapsedTicks: nextElapsedTicks,
enemyHealth: 0,
})
setStatus('part-complete')
setStatus(marathonMode && encounter.isBoss ? 'marathon-choice' : 'part-complete')
addLog(`${encounter.enemyName} is defeated.`, 'loot')
return
}
if (encounterIndex === encounters.length - 1) {
if (marathonMode && encounter.isBoss) {
const nextMarathonKills = marathonBossesDefeatedRef.current + 1
marathonBossesDefeatedRef.current = nextMarathonKills
setMarathonBossesDefeated(nextMarathonKills)
setCombat({
...current,
party: nextParty,
resource: nextResource,
cooldowns: nextCooldowns,
elapsedTicks: nextElapsedTicks,
enemyHealth: 0,
})
setStatus('marathon-choice')
addLog(`${encounter.enemyName} is defeated. Continue marathon or end the hunt.`, 'loot')
return
}
setCombat({
...current,
party: nextParty,
@@ -1037,6 +1264,9 @@ export function CombatScreen({
poisonStacks: undefined,
maxHealthPenaltyTicks: undefined,
healingReductionTicks: undefined,
hotEffects: [],
bounceHeals: [],
damageReductionTicks: undefined,
}))
setEncounterIndex((value) => value + 1)
setCombat({
@@ -1045,13 +1275,15 @@ export function CombatScreen({
resource: nextResource,
cooldowns: nextCooldowns,
elapsedTicks: 0,
enemyHealth: nextEncounter.maxHealth,
enemyHealth: nextEncounter.maxHealth * enemyCount,
})
addLog(`${encounter.enemyName} defeated. ${nextEncounter.enemyName} approaches.`, 'system')
}, [
activeEffects,
addLog,
addFloatingHeal,
difficulty.damageMultiplier,
enemyCount,
encounter,
encounterIndex,
encounters,
@@ -1060,6 +1292,7 @@ export function CombatScreen({
isPartBoss,
isFinalBoss,
isRoguelike,
marathonMode,
upgradesEveryEncounter,
roguelikeUpgradeCatalog,
roguelikeUpgrades,
@@ -1095,9 +1328,10 @@ export function CombatScreen({
|| pausedRef.current
) return
const now = performance.now()
const dueTicks = Math.min(4, Math.floor((now - lastCombatTickAtRef.current) / TICK_MS))
const tickMs = TICK_MS / speedMultiplierRef.current
const dueTicks = Math.min(8, Math.floor((now - lastCombatTickAtRef.current) / tickMs))
if (dueTicks <= 0) return
lastCombatTickAtRef.current += dueTicks * TICK_MS
lastCombatTickAtRef.current += dueTicks * tickMs
for (let index = 0; index < dueTicks; index += 1) {
if (statusRef.current !== 'playing' || pausedRef.current) return
runCombatTickRef.current()
@@ -1120,7 +1354,15 @@ 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,
@@ -1128,7 +1370,7 @@ export function CombatScreen({
encounterName: encounter.enemyName,
encounterDescription: encounter.description,
encounterHealth: enemyHealth,
encounterMaxHealth: encounter.maxHealth,
encounterMaxHealth,
encounterIsBoss: encounter.isBoss,
encounterIndex,
encounterCount: encounters.length,
@@ -1158,6 +1400,7 @@ export function CombatScreen({
directPartyTargeting,
paused,
targetGroup,
speedMultiplier,
}), [
bindings,
controllerIconStyle,
@@ -1170,7 +1413,8 @@ export function CombatScreen({
encounter.description,
encounter.enemyName,
encounter.isBoss,
encounter.maxHealth,
encounterMaxHealth,
hardMode,
enemyHealth,
encounterIndex,
encounters.length,
@@ -1187,6 +1431,7 @@ export function CombatScreen({
spells,
freeCastReady,
roguelikeUpgrades,
speedMultiplier,
status,
targetGroup,
])
@@ -1223,9 +1468,20 @@ export function CombatScreen({
<div className="enemy-info">
<div className="bar-label">
<strong>{encounter.enemyName}</strong>
<span>{Math.ceil(enemyHealth)} / {encounter.maxHealth}</span>
<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>
@@ -1239,6 +1495,7 @@ export function CombatScreen({
? `${gameClass.resourceName} ${Math.floor(resource)} / ${maxResource}`
: `${profile.character.name} is defeated`}
</span>
{speedMultiplier === 2 && <strong className="speed-badge">2x speed</strong>}
<div className="bar mana-bar"><span style={{ width: `${(resource / maxResource) * 100}%` }} /></div>
</div>
</div>
@@ -1272,7 +1529,14 @@ export function CombatScreen({
.map((entry) => <span className="floating-heal" key={entry.id}>+{entry.value}</span>)}
</div>
<div className="member-effects">
{member.hotTicks > 0 && <span className="buff">Renew {formatEffectTime(member.hotTicks)}</span>}
{memberHotEffects(member).map((effect) => (
<span className="buff" key={effect.id}>{effect.label} {formatEffectTime(effect.ticks)}</span>
))}
{member.shield > 0 && <span className="buff">Shield {Math.ceil(member.shield)}</span>}
{(member.damageReductionTicks ?? 0) > 0 && <span className="buff">Barkskin {formatEffectTime(member.damageReductionTicks ?? 0)}</span>}
{(member.bounceHeals ?? []).map((effect) => (
<span className="buff" key={effect.id}>{effect.label} {effect.charges}</span>
))}
{member.debuff && member.debuffTicks && <span className="debuff">{member.debuff} {formatEffectTime(member.debuffTicks)}</span>}
{member.poisonStacks && member.poisonStacks > 0 && <span className="debuff">Poison {member.poisonStacks}</span>}
{member.maxHealthPenaltyTicks && member.maxHealthPenaltyTicks > 0 && <span className="debuff">Max HP -25% {formatEffectTime(member.maxHealthPenaltyTicks)}</span>}
@@ -1351,7 +1615,7 @@ export function CombatScreen({
{status === 'upgrade-choice' && (
<div className="result-screen">
<div>
<div className="pvp-upgrade-dialog pve-upgrade-dialog">
<p className="eyebrow">
{encounter.isBoss
? `Roguelike Stage ${roguelikeStage} Complete`
@@ -1359,13 +1623,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">
@@ -1377,7 +1646,7 @@ export function CombatScreen({
</div>
)}
{status !== 'playing' && status !== 'part-complete' && status !== 'upgrade-choice' && (
{status !== 'playing' && status !== 'part-complete' && status !== 'marathon-choice' && status !== 'upgrade-choice' && (
<div className="result-screen">
<div>
<p className="eyebrow">{status === 'won' ? `${contentName} Complete` : 'Party Defeated'}</p>
@@ -1492,38 +1761,76 @@ export function CombatScreen({
</div>
</div>
)}
{status === 'marathon-choice' && (
<div className="result-screen">
<div>
<p className="eyebrow">Marathon</p>
<h2>{encounter.enemyName} Defeated</h2>
<p>
{marathonBossesDefeated} boss{marathonBossesDefeated === 1 ? '' : 'es'} defeated.
Continue with current health and {gameClass.resourceName}, or end the hunt.
</p>
<button
onClick={() => {
const current = combatRef.current
setCombat({
...current,
enemyHealth: encounter.maxHealth * enemyCount,
elapsedTicks: 0,
})
setStatus('playing')
addLog(`Marathon continues. Another ${encounter.enemyName} appears.`, 'danger')
}}
type="button"
>
Continue Marathon
</button>
<button className="secondary-result-button" onClick={() => finishRun(currentPart, startPart)} type="button">
End
</button>
</div>
</div>
)}
{status === 'part-complete' && (
<div className="result-screen">
<div>
<p className="eyebrow">{sectionName} Complete</p>
<h2>{encounter.enemyName} Defeated</h2>
<p>Proceed to {sectionName} {currentPart + 1} or end the run?</p>
<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?` : 'Run checkpoint 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>
+1 -1
View File
@@ -40,7 +40,7 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
function chooseClass(nextClass: GameClass) {
const starterAbilities = nextClass.spells
.filter((ability) => ability.unlockLevel <= profile.character.level)
.slice(0, 5)
.slice(0, 6)
.map((ability) => ability.id)
setClassId(nextClass.id)
setSlots([...starterAbilities, ...Array(6 - starterAbilities.length).fill(null)])
+16 -11
View File
@@ -28,6 +28,7 @@ const EQUIPMENT_LIST_PAGE_SIZE = 3
const CRAFTING_LIST_PAGE_SIZE = 3
const CRAFTING_FILTER_SLOTS = (Object.keys(SLOT_LABELS) as EquipmentSlot[])
.filter((slot) => slot !== 'component')
const DIRECT_CRAFT_ITEM_LEVELS = new Set([1, 10, 20, 25])
type Props = {
profile: CharacterProfile
@@ -68,18 +69,17 @@ export function EquipmentScreen({
const [message, setMessage] = useState('')
const scrollRef = useRef<number>(0)
const selectedItem = profile.inventory.find((item) => item.id === selectedItemId)
const firstRecipe = profile.craftingRecipes.find((recipe) => recipe.canCraft)
?? profile.craftingRecipes[0]
const craftableRecipes = profile.craftingRecipes.filter((recipe) =>
DIRECT_CRAFT_ITEM_LEVELS.has(recipe.item.itemLevel),
)
const firstRecipe = craftableRecipes.find((recipe) => recipe.canCraft)
?? craftableRecipes[0]
const [selectedRecipeId, setSelectedRecipeId] = useState<number | null>(
firstRecipe?.id ?? null,
)
const selectedRecipe = profile.craftingRecipes.find((recipe) => recipe.id === selectedRecipeId)
const selectedRecipeRequiresUpgrade = selectedRecipe
? profile.craftingRecipes.some((recipe) =>
recipe.sourceEncounterId === selectedRecipe.sourceEncounterId
&& recipe.item.slot === selectedRecipe.item.slot
&& recipe.item.itemLevel < selectedRecipe.item.itemLevel,
)
? !DIRECT_CRAFT_ITEM_LEVELS.has(selectedRecipe.item.itemLevel)
: false
const selectedItemRecipe = selectedItem
? profile.craftingRecipes.find((recipe) => recipe.item.id === selectedItem.id)
@@ -126,12 +126,14 @@ export function EquipmentScreen({
const [slotFilter, setSlotFilter] = useState<EquipmentSlot | 'all'>('all')
const [levelFilter, setLevelFilter] = useState<number | null>(null)
const availableLevels = useMemo(
() => [...new Set(profile.craftingRecipes.map((r) => r.item.itemLevel))].sort((a, b) => b - a),
() => [...new Set(profile.craftingRecipes
.filter((r) => DIRECT_CRAFT_ITEM_LEVELS.has(r.item.itemLevel))
.map((r) => r.item.itemLevel))].sort((a, b) => b - a),
[profile.craftingRecipes],
)
const filteredRecipes = useMemo(
() => {
let result = [...profile.craftingRecipes]
let result = profile.craftingRecipes.filter((r) => DIRECT_CRAFT_ITEM_LEVELS.has(r.item.itemLevel))
if (slotFilter !== 'all') result = result.filter((r) => r.item.slot === slotFilter)
if (levelFilter !== null) result = result.filter((r) => r.item.itemLevel === levelFilter)
result.sort((a, b) => b.item.itemLevel - a.item.itemLevel)
@@ -144,7 +146,10 @@ export function EquipmentScreen({
() => new Map(
(Object.keys(SLOT_LABELS) as EquipmentSlot[]).map((slot) => [
slot,
profile.craftingRecipes.filter((recipe) => recipe.item.slot === slot).length,
profile.craftingRecipes.filter((recipe) =>
recipe.item.slot === slot
&& DIRECT_CRAFT_ITEM_LEVELS.has(recipe.item.itemLevel),
).length,
]),
),
[profile.craftingRecipes],
@@ -589,7 +594,7 @@ export function EquipmentScreen({
type="button"
>
<strong>All</strong>
<span>{profile.craftingRecipes.length}</span>
<span>{profile.craftingRecipes.filter((recipe) => DIRECT_CRAFT_ITEM_LEVELS.has(recipe.item.itemLevel)).length}</span>
</button>
{CRAFTING_FILTER_SLOTS.map((slot) => (
<button
+241 -56
View File
@@ -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>
+205 -117
View File
@@ -1,10 +1,11 @@
import { useEffect, useRef, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import {
allocateTalent,
resetTalents,
type CharacterProfile,
type Talent,
} from '../profile'
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../dualScreen'
type Props = {
profile: CharacterProfile
@@ -13,199 +14,286 @@ type Props = {
embedded?: boolean
}
const EFFECT_SLOT_LEVELS = [5, 10, 15, 20] as const
const EFFECT_CLASS_ID = 1
const EFFECTS_PER_PAGE = 8
const EFFECT_SOURCE_LABELS: Record<string, string> = {
mend: 'Mend',
radiance: 'Radiance',
shield: 'Shield',
}
function effectSource(effectType: string) {
if (effectType.startsWith('mend_')) return 'mend'
if (effectType.startsWith('radiance_')) return 'radiance'
if (effectType.startsWith('shield_') || effectType.startsWith('shielded_')) return 'shield'
return effectType
}
function effectCapacity(level: number) {
return EFFECT_SLOT_LEVELS.filter((slotLevel) => level >= slotLevel).length
}
function activeEffects(talents: Talent[]) {
return talents.filter((talent) => talent.rank > 0)
}
export function TalentScreen({ profile, onBack, onUpdated, embedded = false }: Props) {
const { enabled: dualScreenEnabled } = useDualScreen()
const [busyTalentId, setBusyTalentId] = useState<number | null>(null)
const [talentPage, setTalentPage] = useState(0)
const [resetting, setResetting] = useState(false)
const [selectedTalentId, setSelectedTalentId] = useState<number | null>(null)
const [effectPage, setEffectPage] = useState(0)
const [message, setMessage] = useState('')
const scrollRef = useRef<number>(0)
const gameClass = profile.classes.find(
(candidate) => candidate.id === profile.character.classId,
)!
const classPointsSpent = gameClass.talents.reduce(
(total, talent) => total + talent.rank,
0,
const isEffectClass = gameClass.id === EFFECT_CLASS_ID
const capacity = isEffectClass ? effectCapacity(profile.character.level) : 0
const selectedEffects = activeEffects(gameClass.talents)
const selectedTalent = gameClass.talents.find((talent) => talent.id === selectedTalentId)
?? selectedEffects[0]
?? gameClass.talents[0]
?? null
const effectPageCount = Math.max(1, Math.ceil(gameClass.talents.length / EFFECTS_PER_PAGE))
const visibleTalents = gameClass.talents.slice(
effectPage * EFFECTS_PER_PAGE,
effectPage * EFFECTS_PER_PAGE + EFFECTS_PER_PAGE,
)
const tiers = Array.from(
new Set(gameClass.talents.map((talent) => talent.tier)),
).sort((a, b) => a - b)
const tierPages = Array.from(
{ length: Math.ceil(tiers.length / 2) },
(_, index) => tiers.slice(index * 2, index * 2 + 2),
)
const visibleTiers = tierPages[talentPage] ?? tierPages[0] ?? []
useEffect(() => {
window.scrollTo(0, scrollRef.current)
}, [profile])
useEffect(() => {
if (selectedTalentId && gameClass.talents.some((talent) => talent.id === selectedTalentId)) return
setSelectedTalentId(selectedTalent?.id ?? null)
}, [gameClass.talents, selectedTalent?.id, selectedTalentId])
useEffect(() => {
setEffectPage((page) => Math.min(page, effectPageCount - 1))
}, [effectPageCount])
function saveScroll() {
scrollRef.current = window.scrollY
}
function lowerTierPoints(talent: Talent) {
return gameClass.talents
.filter((candidate) => candidate.tier < talent.tier)
.reduce((total, candidate) => total + candidate.rank, 0)
}
function lockReason(talent: Talent) {
if (talent.rank >= talent.maxRank) return 'Maximum rank'
const requiredTierPoints = (talent.tier - 1) * 5
if (lowerTierPoints(talent) < requiredTierPoints) {
return `Requires ${requiredTierPoints} earlier-tier points`
if (!isEffectClass) return 'Coming soon'
if (talent.rank > 0) return ''
const source = effectSource(talent.effectType)
const sourceConflict = selectedEffects.find((effect) => effectSource(effect.effectType) === source)
if (sourceConflict) return `${EFFECT_SOURCE_LABELS[source] ?? source} already selected`
if (capacity <= 0) return 'Unlocks at level 5'
if (selectedEffects.length >= capacity) {
return `Active slots full (${capacity}/${capacity})`
}
if (talent.prerequisiteTalentId) {
const prerequisite = gameClass.talents.find(
(candidate) => candidate.id === talent.prerequisiteTalentId,
)
if ((prerequisite?.rank ?? 0) < talent.prerequisiteRank) {
return `Requires ${talent.prerequisiteName} rank ${talent.prerequisiteRank}`
}
}
if (profile.character.talentPoints <= 0) return 'No points available'
return ''
}
async function purchaseRank(talent: Talent) {
async function toggleEffect(talent: Talent) {
saveScroll()
setBusyTalentId(talent.id)
setMessage('')
try {
const updated = await allocateTalent(talent.id)
onUpdated(updated)
setMessage(`${talent.name} increased to rank ${talent.rank + 1}.`)
setSelectedTalentId(talent.id)
setMessage(talent.rank > 0 ? `${talent.name} removed.` : `${talent.name} activated.`)
} catch (reason) {
setMessage(reason instanceof Error ? reason.message : 'Unable to allocate talent.')
setMessage(reason instanceof Error ? reason.message : 'Unable to update spell effect.')
} finally {
setBusyTalentId(null)
}
}
async function refundTree() {
async function clearEffects() {
saveScroll()
setResetting(true)
setMessage('')
try {
const updated = await resetTalents()
onUpdated(updated)
setMessage('All points in this talent tree were refunded.')
setMessage('Spell effects cleared.')
} catch (reason) {
setMessage(reason instanceof Error ? reason.message : 'Unable to reset talents.')
setMessage(reason instanceof Error ? reason.message : 'Unable to clear spell effects.')
} finally {
setResetting(false)
}
}
const workshopState = useMemo<DualScreenWorkshopState | null>(() => {
if (!isEffectClass) return null
return {
mode: 'talents',
title: 'Spell Effects',
subtitle: `${selectedEffects.length}/${capacity} active`,
summary: selectedTalent
? `${selectedTalent.name}: ${selectedTalent.description}`
: 'Choose effects to modify your spells.',
items: gameClass.talents.map((talent) => ({
glyph: talent.glyph,
title: talent.name,
meta: talent.rank > 0 ? 'Active' : lockReason(talent) || 'Available',
detail: talent.description,
status: talent.rank > 0 ? 'Selected' : '',
})),
}
}, [capacity, gameClass.talents, isEffectClass, selectedEffects.length, selectedTalent])
useDualScreenWorkshopPublisher(workshopState, dualScreenEnabled)
const content = (
<>
{!embedded && (
<div className="screen-heading">
<div>
<p className="eyebrow">Character Growth</p>
<h1>Talents</h1>
<h1>Spell Effects</h1>
</div>
<button className="back-button" onClick={onBack} type="button">Back</button>
</div>
)}
<div className="talent-toolbar">
<div className="talent-toolbar spell-effect-toolbar">
<div className="talent-class-summary">
<span style={{ borderColor: gameClass.themeColor, color: gameClass.themeColor }}>
{gameClass.name[0]}
</span>
<div>
<p className="eyebrow">{gameClass.name} Tree</p>
<h2>Shape Your Healing Style</h2>
<p className="eyebrow">{gameClass.name} Effects</p>
<h2>Modify Your Spells</h2>
</div>
</div>
<div className="talent-points">
<strong>{profile.character.talentPoints}</strong>
<span>Available</span>
<small>{classPointsSpent} spent in this tree</small>
<strong>{selectedEffects.length}/{capacity}</strong>
<span>Active</span>
<small>Slots unlock at levels 5, 10, 15, 20</small>
</div>
</div>
<nav className="talent-page-tabs" role="tablist" aria-label="Talent tier pages">
{tierPages.map((pageTiers, index) => (
<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">
{visibleTalents.map((talent) => {
const reason = lockReason(talent)
const active = talent.rank > 0
const selected = selectedTalent?.id === talent.id
const isBusy = busyTalentId === talent.id
return (
<button
className={`${active ? 'active' : ''} ${selected ? 'selected' : ''}`}
disabled={Boolean(reason) || isBusy}
key={talent.id}
onClick={() => {
setSelectedTalentId(talent.id)
void toggleEffect(talent)
}}
type="button"
>
<span>{talent.glyph}</span>
<div>
<strong>{talent.name}</strong>
<small>{EFFECT_SOURCE_LABELS[effectSource(talent.effectType)] ?? 'Spell'}</small>
</div>
<i>{isBusy ? 'Saving' : active ? 'Active' : reason || 'Available'}</i>
</button>
)
})}
</div>
{effectPageCount > 1 && (
<div className="effect-pager">
<button
disabled={effectPage === 0}
onClick={() => setEffectPage((page) => Math.max(0, page - 1))}
type="button"
>
Prev
</button>
<span>{effectPage + 1}/{effectPageCount}</span>
<button
disabled={effectPage >= effectPageCount - 1}
onClick={() => setEffectPage((page) => Math.min(effectPageCount - 1, page + 1))}
type="button"
>
Next
</button>
</div>
)}
</section>
</div>
)}
<footer className="talent-footer">
<span>{message || 'Talent changes are saved immediately.'}</span>
<span>{message || 'Spell effect changes are saved immediately.'}</span>
<button
className="text-button"
disabled={classPointsSpent === 0 || resetting}
onClick={refundTree}
disabled={selectedEffects.length === 0 || resetting}
onClick={clearEffects}
type="button"
>
{resetting ? 'Refunding...' : 'Reset Tree'}
{resetting ? 'Clearing...' : 'Clear Effects'}
</button>
</footer>
</>
+13 -2
View File
@@ -42,7 +42,7 @@ export type DualScreenCombatState = {
partySize: number
selectedId: string
log: CombatLogEntry[]
status: 'playing' | 'won' | 'lost' | 'part-complete' | 'upgrade-choice'
status: 'playing' | 'won' | 'lost' | 'part-complete' | 'marathon-choice' | 'upgrade-choice'
resource: number
maxResource: number
resourceName: string
@@ -54,6 +54,7 @@ export type DualScreenCombatState = {
directPartyTargeting: boolean
paused: boolean
targetGroup: 0 | 1 | 2
speedMultiplier: 1 | 2
}
export type DualScreenWorkshopState = {
@@ -118,6 +119,13 @@ function loadRecentSnapshot() {
}
}
function memberHotEffects(member: PartyMember) {
if (member.hotEffects?.length) return member.hotEffects
return member.hotTicks > 0
? [{ id: 'legacy-renew', spellId: 'legacy-renew', label: 'Renew', ticks: member.hotTicks, power: 6 }]
: []
}
export function DualScreenProvider({ children }: { children: ReactNode }) {
const [enabled, setEnabledState] = useState(
() => localStorage.getItem(STORAGE_KEY) === 'true',
@@ -438,6 +446,7 @@ export function DualScreenBottomDisplay() {
</div>
<div className="dual-controls-mana">
<span>{state.resourceName} {Math.floor(state.resource)} / {state.maxResource}</span>
{state.speedMultiplier === 2 && <strong className="speed-badge">2x speed</strong>}
<div className="bar mana-bar">
<span style={{ width: `${(state.resource / state.maxResource) * 100}%` }} />
</div>
@@ -599,7 +608,9 @@ export function DualScreenTopCombat({
</div>
)}
<div className="member-effects">
{member.hotTicks > 0 && <span className="buff">Renew</span>}
{memberHotEffects(member).map((effect) => (
<span className="buff" key={effect.id}>{effect.label}</span>
))}
{member.debuff && <span className="debuff">{member.debuff}</span>}
</div>
</button>
+45 -2
View File
@@ -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)
}
+165 -51
View File
@@ -26,6 +26,7 @@ export interface GameRepository {
completedPart?: number,
startPart?: number,
partDurationSeconds?: [number, number, number],
hardMode?: boolean,
): Promise<DungeonReward>
completeRoguelike(
dungeonId: number,
@@ -69,6 +70,7 @@ type OfflineSave = {
activeClassId: number
completedDungeonParts: number
completedRaidPhases: number
dungeonCompletions?: Record<string, number>
characters: Record<number, CharacterData>
lootRolls: Record<string, LootRoll>
}
@@ -102,6 +104,7 @@ const offlineSaveKey = 'chronicle.offlineSave.v1'
const onlineCacheKey = 'chronicle.onlineCache.v1'
const authTokenKey = 'chronicle.authToken.v1'
const offlineAccount = { id: -1, username: 'Offline' }
const ABILITY_SLOT_COUNT = 6
function clone<T>(value: T): T {
return structuredClone(value)
@@ -146,7 +149,7 @@ function upgradeV1Save(v1: { profile: CharacterProfile; lootRolls: Record<string
level: cid === p.character.classId ? p.character.level : 1,
experience: cid === p.character.classId ? p.character.experience : 0,
talentPoints: cid === p.character.classId ? p.character.talentPoints : 1,
abilitySlots: cid === p.character.classId ? [...p.abilitySlots] : [],
abilitySlots: cid === p.character.classId ? normalizeAbilitySlots(p.abilitySlots) : [],
talentRanks,
inventory: cid === p.character.classId ? clone(p.inventory) : [],
}
@@ -157,17 +160,41 @@ function upgradeV1Save(v1: { profile: CharacterProfile; lootRolls: Record<string
activeClassId: p.character.classId,
completedDungeonParts: p.completedDungeonParts,
completedRaidPhases: p.completedRaidPhases ?? 0,
dungeonCompletions: Object.fromEntries(
p.dungeons.map((dungeon) => [String(dungeon.id), dungeon.completionCount ?? 0]),
),
characters,
lootRolls: v1.lootRolls ?? {},
}
}
function upgradeV2Save(v2: Omit<OfflineSave, 'version' | 'completedRaidPhases'> & { version: 2 }): OfflineSave {
return {
return normalizeSaveAbilitySlots({
...v2,
version: 3,
completedRaidPhases: 0,
})
}
function normalizeAbilitySlots(abilitySlots: unknown): Array<number | null> {
const slots = Array.isArray(abilitySlots)
? abilitySlots
.slice(0, ABILITY_SLOT_COUNT)
.map((value) => {
if (value === null || value === undefined) return null
const id = Number(value)
return Number.isInteger(id) ? id : null
})
: []
while (slots.length < ABILITY_SLOT_COUNT) slots.push(null)
return slots
}
function normalizeSaveAbilitySlots(save: OfflineSave): OfflineSave {
for (const character of Object.values(save.characters)) {
character.abilitySlots = normalizeAbilitySlots(character.abilitySlots)
}
return save
}
function normalizeOfflineSave(raw: unknown): OfflineSave | null {
@@ -177,12 +204,12 @@ function normalizeOfflineSave(raw: unknown): OfflineSave | null {
profile?: CharacterProfile
lootRolls?: Record<string, LootRoll>
}
if (candidate.version === 3) return candidate as OfflineSave
if (candidate.version === 3) return normalizeSaveAbilitySlots(candidate as OfflineSave)
if (candidate.version === 2) {
return upgradeV2Save(candidate as Omit<OfflineSave, 'version' | 'completedRaidPhases'> & { version: 2 })
}
if (candidate.version === 1 && candidate.profile) {
return upgradeV1Save(candidate as { profile: CharacterProfile; lootRolls: Record<string, LootRoll> })
return normalizeSaveAbilitySlots(upgradeV1Save(candidate as { profile: CharacterProfile; lootRolls: Record<string, LootRoll> }))
}
return null
}
@@ -291,6 +318,10 @@ function buildProfile(save: OfflineSave): CharacterProfile {
updateCraftingRecipes(static_)
static_.completedDungeonParts = save.completedDungeonParts
static_.completedRaidPhases = save.completedRaidPhases
static_.dungeons = static_.dungeons.map((dungeon) => ({
...dungeon,
completionCount: save.dungeonCompletions?.[String(dungeon.id)] ?? dungeon.completionCount ?? 0,
}))
return static_
}
@@ -359,11 +390,33 @@ function experienceForLevel(level: number) {
return (level - 1) * (level - 1) * 100
}
function catchUpExperienceReward(
baseReward: number,
currentExperience: number,
currentLevel: number,
targetLevel: number,
) {
if (targetLevel <= currentLevel) return baseReward
const targetExperience = experienceForLevel(targetLevel)
const gap = Math.max(0, targetExperience - currentExperience)
if (gap <= 0) return baseReward
const doubledBase = Math.min(baseReward, Math.ceil(gap / 2))
return doubledBase * 2 + (baseReward - doubledBase)
}
function highestOtherClassLevel(save: OfflineSave) {
const activeClass = save.activeClassId
return Object.entries(save.characters)
.filter(([classId]) => Number(classId) !== activeClass)
.reduce((highest, [, character]) => Math.max(highest, character.level), 0)
}
function scaledPvpBossExperience(
startingExperience: number,
startingLevel: number,
bossesCleared: number,
maxLevel: number,
targetLevel = startingLevel,
) {
let experience = startingExperience
let level = startingLevel
@@ -374,7 +427,8 @@ function scaledPvpBossExperience(
? maxExperience
: experienceForLevel(level + 1)
const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor)
experience = Math.min(maxExperience, experience + Math.round(levelBand * 0.25))
const rewardRate = targetLevel > level ? 0.5 : 0.25
experience = Math.min(maxExperience, experience + Math.round(levelBand * rewardRate))
while (level < maxLevel && experienceForLevel(level + 1) <= experience) {
level += 1
}
@@ -382,6 +436,17 @@ function scaledPvpBossExperience(
return { experience, level }
}
function talentEffectCapacity(level: number) {
return Math.min(4, Math.max(0, Math.floor(level / 5)))
}
function talentEffectSource(effectType: string) {
if (effectType.startsWith('mend_')) return 'Mend'
if (effectType.startsWith('radiance_')) return 'Radiance'
if (effectType.startsWith('shield_') || effectType.startsWith('shielded_')) return 'Shield'
return effectType
}
type ComponentTemplate = { id: number; slug: string; name: string; itemLevel: number; glyph: string; description: string }
const COMPONENT_ITEMS: Record<number, ComponentTemplate> = {
1: { id: 600, slug: 'minor-component', name: 'Minor Component', itemLevel: 1, glyph: '◆', description: 'A basic crafting component.' },
@@ -389,6 +454,7 @@ const COMPONENT_ITEMS: Record<number, ComponentTemplate> = {
20: { id: 604, slug: 'superior-component', name: 'Superior Component', itemLevel: 20, glyph: '◎', description: 'A superior crafting component.' },
25: { id: 605, slug: 'primal-component', name: 'Primal Component', itemLevel: 25, glyph: '✦', description: 'A primal crafting component.' },
}
const DIRECT_CRAFT_ITEM_LEVELS = new Set([1, 10, 20, 25])
type WindowWithApiBase = Window & {
CAPACITOR_API_BASE_URL?: string
@@ -423,7 +489,7 @@ function mergeProfileIntoSave(profile: CharacterProfile, existingSave?: OfflineS
level: profile.character.level,
experience: profile.character.experience,
talentPoints: profile.character.talentPoints,
abilitySlots: [...profile.abilitySlots],
abilitySlots: normalizeAbilitySlots(profile.abilitySlots),
talentRanks,
inventory: clone(profile.inventory),
}
@@ -433,6 +499,9 @@ function mergeProfileIntoSave(profile: CharacterProfile, existingSave?: OfflineS
activeClassId: profile.character.classId,
completedDungeonParts: profile.completedDungeonParts,
completedRaidPhases: profile.completedRaidPhases,
dungeonCompletions: Object.fromEntries(
profile.dungeons.map((dungeon) => [String(dungeon.id), dungeon.completionCount ?? 0]),
),
characters,
lootRolls: clone(existingSave?.lootRolls ?? {}),
}
@@ -716,7 +785,7 @@ const serverRepository: GameRepository = {
),
saveProfile: (classId, abilitySlots) =>
cachedOnlineLocalRepository.saveProfile(classId, abilitySlots),
completeDungeon: (dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds) =>
completeDungeon: (dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds, hardMode) =>
cachedOnlineLocalRepository.completeDungeon(
dungeonId,
difficultyId,
@@ -725,6 +794,7 @@ const serverRepository: GameRepository = {
completedPart,
startPart,
partDurationSeconds,
hardMode,
),
completeRoguelike: (dungeonId, difficultyId, encountersCleared, resourceSpent, durationSeconds, options) =>
cachedOnlineLocalRepository.completeRoguelike(
@@ -761,9 +831,9 @@ function emptyCharacterData(classId: number): CharacterData {
const inventory: Item[] = []
const startingAbilitySlots: Array<number | null> = gc.spells
.filter((s) => s.unlockLevel === 1)
.slice(0, 5)
.slice(0, ABILITY_SLOT_COUNT)
.map((s) => s.id)
while (startingAbilitySlots.length < 6) startingAbilitySlots.push(null)
while (startingAbilitySlots.length < ABILITY_SLOT_COUNT) startingAbilitySlots.push(null)
return {
level: 1,
experience: 0,
@@ -803,35 +873,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) {
@@ -857,8 +926,15 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
const previousLevel = cd.level
const previousExperience = cd.experience
const partCount = completedPart ?? 1
const experienceReward = Math.round(
dungeon.experienceReward * difficulty.experienceMultiplier * partCount,
const rewardMultiplier = hardMode ? 2 : 1
const baseExperienceReward = Math.round(
dungeon.experienceReward * difficulty.experienceMultiplier * partCount * rewardMultiplier,
)
const experienceReward = catchUpExperienceReward(
baseExperienceReward,
previousExperience,
previousLevel,
highestOtherClassLevel(save),
)
const maxExperience = experienceForLevel(profile.maxLevel)
const newExperience = Math.min(previousExperience + experienceReward, maxExperience)
@@ -893,6 +969,10 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
} else {
save.completedDungeonParts = Math.max(save.completedDungeonParts, partCount)
}
save.dungeonCompletions = {
...(save.dungeonCompletions ?? {}),
[String(dungeonId)]: (save.dungeonCompletions?.[String(dungeonId)] ?? 0) + 1,
}
let bonusItem: DungeonReward['bonusItem'] = null
if ((startPart ?? 1) === 1 && partCount >= 3 && dungeon.completionLoot.length > 0) {
@@ -906,19 +986,20 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
const selected = rewardPool[Math.floor(Math.random() * rewardPool.length)]
const existing = profile.inventory.find((item) => item.id === selected.id)
const duplicate = Boolean(existing)
let quantityAfter = 1
const rewardQuantity = rewardMultiplier
let quantityAfter = rewardQuantity
if (existing) {
existing.quantity += 1
existing.quantity += rewardQuantity
quantityAfter = existing.quantity
} else {
profile.inventory.push({
...selected,
quantity: 1,
quantity: rewardQuantity,
equipped: false,
})
}
cd.inventory = profile.inventory
bonusItem = { ...selected, quantity: 1, duplicate, quantityAfter }
bonusItem = { ...selected, quantity: rewardQuantity, duplicate, quantityAfter }
}
}
@@ -971,13 +1052,19 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
const maxExperience = experienceForLevel(profile.maxLevel)
const bossesCleared = Math.max(0, Math.floor(options?.bossesCleared ?? encountersCleared / 3))
const scaledReward = options?.experienceMode === 'pvp-boss-quarter-level'
? scaledPvpBossExperience(previousExperience, previousLevel, bossesCleared, profile.maxLevel)
? scaledPvpBossExperience(previousExperience, previousLevel, bossesCleared, profile.maxLevel, highestOtherClassLevel(save))
: null
const baseRoguelikeReward = Math.round(dungeon.experienceReward * difficulty.experienceMultiplier * (encountersCleared / 3))
const newExperience = scaledReward
? scaledReward.experience
: Math.min(
previousExperience
+ Math.round(dungeon.experienceReward * difficulty.experienceMultiplier * (encountersCleared / 3)),
+ catchUpExperienceReward(
baseRoguelikeReward,
previousExperience,
previousLevel,
highestOtherClassLevel(save),
),
maxExperience,
)
let newLevel = scaledReward?.level ?? previousLevel
@@ -1041,6 +1128,34 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
)!
const talent = gameClass.talents.find((candidate) => candidate.id === talentId)
if (!talent) throw new Error('That talent does not belong to the active class.')
if (save.activeClassId === 1) {
if ((cd.talentRanks[String(talentId)] ?? 0) > 0) {
cd.talentRanks[String(talentId)] = 0
} else {
const capacity = talentEffectCapacity(cd.level)
if (capacity <= 0) throw new Error('Spell effects unlock at level 5.')
const source = talentEffectSource(talent.effectType)
const sourceConflict = gameClass.talents.find(
(candidate) =>
candidate.id !== talentId
&& (cd.talentRanks[String(candidate.id)] ?? 0) > 0
&& talentEffectSource(candidate.effectType) === source,
)
if (sourceConflict) {
throw new Error(`Only one ${source} spell effect can be active.`)
}
const activeCount = gameClass.talents.reduce(
(total, candidate) => total + ((cd.talentRanks[String(candidate.id)] ?? 0) > 0 ? 1 : 0),
0,
)
if (activeCount >= capacity) {
throw new Error(`Level ${cd.level} allows ${capacity} active spell effect${capacity === 1 ? '' : 's'}.`)
}
cd.talentRanks[String(talentId)] = 1
}
store.writeSave(save)
return buildProfile(save)
}
if (cd.talentPoints <= 0) {
throw new Error('No talent points are available.')
}
@@ -1083,10 +1198,12 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
for (const talent of gameClass.talents) {
cd.talentRanks[String(talent.id)] = 0
}
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)
},
@@ -1161,11 +1278,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
const profile = buildProfile(save)
const recipe = profile.craftingRecipes.find((candidate) => candidate.id === recipeId)
if (!recipe) throw new Error('That crafting recipe does not exist.')
const requiresUpgrade = profile.craftingRecipes.some((candidate) =>
candidate.sourceEncounterId === recipe.sourceEncounterId
&& candidate.item.slot === recipe.item.slot
&& candidate.item.itemLevel < recipe.item.itemLevel,
)
const requiresUpgrade = !DIRECT_CRAFT_ITEM_LEVELS.has(recipe.item.itemLevel)
if (requiresUpgrade) throw new Error('Upgrade the previous item tier instead.')
const missing = recipe.components.find((component) => component.owned < component.quantity)
if (missing) {
@@ -1361,7 +1474,7 @@ const cachedOnlineRepository: GameRepository = {
},
loadProfile: () => cachedOnlineLocalRepository.loadProfile(),
saveProfile: (classId, abilitySlots) => cachedOnlineLocalRepository.saveProfile(classId, abilitySlots),
completeDungeon: (dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds) =>
completeDungeon: (dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds, hardMode) =>
cachedOnlineLocalRepository.completeDungeon(
dungeonId,
difficultyId,
@@ -1370,6 +1483,7 @@ const cachedOnlineRepository: GameRepository = {
completedPart,
startPart,
partDurationSeconds,
hardMode,
),
completeRoguelike: (dungeonId, difficultyId, encountersCleared, resourceSpent, durationSeconds, options) =>
cachedOnlineLocalRepository.completeRoguelike(
+18 -17
View File
@@ -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',
File diff suppressed because it is too large Load Diff
+3
View File
@@ -168,6 +168,7 @@ export type Dungeon = {
difficulties: Difficulty[]
encounters: DungeonEncounter[]
completionLoot: Array<Omit<Item, 'quantity' | 'equipped'>>
completionCount?: number
leaderboard: LeaderboardEntry[]
leaderboards: {
part_1: LeaderboardEntry[]
@@ -319,6 +320,7 @@ export async function completeDungeon(
completedPart?: number,
startPart?: number,
partDurationSeconds?: [number, number, number],
hardMode?: boolean,
): Promise<DungeonReward> {
return activeGameRepository().completeDungeon(
dungeonId,
@@ -328,6 +330,7 @@ export async function completeDungeon(
completedPart,
startPart,
partDurationSeconds,
hardMode,
)
}