Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1281be69d8 | |||
| 4fc15ebe9a | |||
| 7313c968e6 | |||
| 207fcd1a15 | |||
| fd6a1ce3c7 | |||
| f8b98e6b23 | |||
| fc7c6488ea | |||
| ba6d3b614e |
@@ -2,5 +2,8 @@
|
|||||||
|
|
||||||
- AYN Thor main display: 6-inch AMOLED, 1920 x 1080, 120Hz.
|
- AYN Thor main display: 6-inch AMOLED, 1920 x 1080, 120Hz.
|
||||||
- AYN Thor secondary display: 3.92-inch AMOLED, 1240 x 1080, 60Hz.
|
- AYN Thor secondary display: 3.92-inch AMOLED, 1240 x 1080, 60Hz.
|
||||||
|
- AYN Thor UI sizing must be designed against Android CSS/layout viewport, not physical framebuffer pixels.
|
||||||
|
- Approximate Thor CSS viewports: main display 960 x 540, secondary display 620 x 540.
|
||||||
|
- Test top-screen UI only against the main display viewport, and bottom-screen UI only against the secondary display viewport.
|
||||||
- User rebuilds app; do not rebuild APK unless explicitly requested.
|
- User rebuilds app; do not rebuild APK unless explicitly requested.
|
||||||
- Apply game changes to both web version and mobile app version.
|
- Apply game changes to both web version and mobile app version.
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -7,8 +7,8 @@ android {
|
|||||||
applicationId "com.warren.iwanttoheal"
|
applicationId "com.warren.iwanttoheal"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 44
|
versionCode 53
|
||||||
versionName "1.0.26"
|
versionName "1.0.34"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
|
|||||||
+90
-8
@@ -134,22 +134,22 @@ INSERT OR IGNORE INTO spells
|
|||||||
VALUES
|
VALUES
|
||||||
(1, 1, 'mend', 'Mend', 'direct_heal', 5, 0.5, 30, 1, '+', 'A fast, efficient single-target heal.'),
|
(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.'),
|
(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.'),
|
(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.'),
|
(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.'),
|
(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.'),
|
(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.'),
|
(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.'),
|
(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.'),
|
(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.'),
|
(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.'),
|
(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.'),
|
(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.'),
|
(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.'),
|
(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.'),
|
(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.');
|
(35, 3, 'grand-design', 'Grand Design', 'party_heal', 16, 12, 30, 5, 'R', 'Activates a prepared network of restorative runes.');
|
||||||
@@ -191,6 +191,76 @@ UPDATE spells SET unlock_level = 10 WHERE slug = 'guardian-light';
|
|||||||
UPDATE spells SET unlock_level = 15 WHERE slug = 'second-sun';
|
UPDATE spells SET unlock_level = 15 WHERE slug = 'second-sun';
|
||||||
UPDATE spells SET unlock_level = 20 WHERE slug = 'daybreak';
|
UPDATE spells SET unlock_level = 20 WHERE slug = 'daybreak';
|
||||||
|
|
||||||
|
UPDATE spells SET
|
||||||
|
name = 'Verdant Touch',
|
||||||
|
spell_type = 'direct_hot',
|
||||||
|
resource_cost = 5,
|
||||||
|
cooldown_seconds = 0.5,
|
||||||
|
power = 20,
|
||||||
|
glyph = '+',
|
||||||
|
description = 'A weaker direct heal that also plants a stacking heal over time.'
|
||||||
|
WHERE slug = 'verdant-touch';
|
||||||
|
|
||||||
|
UPDATE spells SET
|
||||||
|
name = 'Wild Growth',
|
||||||
|
spell_type = 'party_hot',
|
||||||
|
resource_cost = 12,
|
||||||
|
cooldown_seconds = 8,
|
||||||
|
power = 14,
|
||||||
|
glyph = '*',
|
||||||
|
description = 'Applies a stacking heal over time to up to 4 injured allies.'
|
||||||
|
WHERE slug = 'wild-bloom';
|
||||||
|
|
||||||
|
UPDATE spells SET
|
||||||
|
name = 'Barkskin',
|
||||||
|
spell_type = 'damage_reduction',
|
||||||
|
resource_cost = 10,
|
||||||
|
cooldown_seconds = 14,
|
||||||
|
power = 0,
|
||||||
|
glyph = 'B',
|
||||||
|
description = 'Reduces the target ally''s damage taken by 50% for 8 seconds.'
|
||||||
|
WHERE slug = 'barkskin';
|
||||||
|
|
||||||
|
UPDATE spells SET
|
||||||
|
name = 'Ancient Grove',
|
||||||
|
spell_type = 'party_hot',
|
||||||
|
resource_cost = 17,
|
||||||
|
cooldown_seconds = 12,
|
||||||
|
power = 24,
|
||||||
|
glyph = 'T',
|
||||||
|
description = 'Applies a stronger stacking heal over time to up to 4 injured allies.'
|
||||||
|
WHERE slug = 'ancient-grove';
|
||||||
|
|
||||||
|
UPDATE spells SET
|
||||||
|
name = 'Mending Rune',
|
||||||
|
spell_type = 'bounce_heal',
|
||||||
|
resource_cost = 7,
|
||||||
|
cooldown_seconds = 0.5,
|
||||||
|
power = 18,
|
||||||
|
glyph = 'e',
|
||||||
|
description = 'Places a rune that heals when the ally takes damage, then jumps 4 times.'
|
||||||
|
WHERE slug = 'echo-rune';
|
||||||
|
|
||||||
|
UPDATE spells SET
|
||||||
|
name = 'Concordance',
|
||||||
|
spell_type = 'party_absorb',
|
||||||
|
resource_cost = 12,
|
||||||
|
cooldown_seconds = 8,
|
||||||
|
power = 28,
|
||||||
|
glyph = '*',
|
||||||
|
description = 'Shields up to 4 injured allies through a shared barrier pattern.'
|
||||||
|
WHERE slug = 'concordance';
|
||||||
|
|
||||||
|
UPDATE spells SET
|
||||||
|
name = 'Grand Design',
|
||||||
|
spell_type = 'party_absorb',
|
||||||
|
resource_cost = 16,
|
||||||
|
cooldown_seconds = 12,
|
||||||
|
power = 42,
|
||||||
|
glyph = 'R',
|
||||||
|
description = 'Raises a stronger shared barrier around up to 4 injured allies.'
|
||||||
|
WHERE slug = 'grand-design';
|
||||||
|
|
||||||
INSERT OR IGNORE INTO items
|
INSERT OR IGNORE INTO items
|
||||||
(id, slug, name, slot, rarity, item_level, healing_power, max_resource_bonus, glyph, description)
|
(id, slug, name, slot, rarity, item_level, healing_power, max_resource_bonus, glyph, description)
|
||||||
VALUES
|
VALUES
|
||||||
@@ -479,9 +549,7 @@ WHERE id BETWEEN 901 AND 1409;
|
|||||||
UPDATE items
|
UPDATE items
|
||||||
SET rarity = CASE item_level
|
SET rarity = CASE item_level
|
||||||
WHEN 1 THEN 'common'
|
WHEN 1 THEN 'common'
|
||||||
WHEN 5 THEN 'common'
|
|
||||||
WHEN 10 THEN 'uncommon'
|
WHEN 10 THEN 'uncommon'
|
||||||
WHEN 15 THEN 'rare'
|
|
||||||
WHEN 20 THEN 'epic'
|
WHEN 20 THEN 'epic'
|
||||||
WHEN 25 THEN 'legendary'
|
WHEN 25 THEN 'legendary'
|
||||||
ELSE rarity
|
ELSE rarity
|
||||||
@@ -728,6 +796,7 @@ INSERT INTO generated_loot_tiers
|
|||||||
(item_level, dungeon_id, raid_id, dungeon_difficulty_id, raid_difficulty_id, recipe_base, craft_quantity)
|
(item_level, dungeon_id, raid_id, dungeon_difficulty_id, raid_difficulty_id, recipe_base, craft_quantity)
|
||||||
VALUES
|
VALUES
|
||||||
(10, 3, 2, 2, 101, 1100, 2),
|
(10, 3, 2, 2, 101, 1100, 2),
|
||||||
|
(15, 4, 5, 3, 103, 1200, 3),
|
||||||
(20, 6, 7, 4, 104, 1300, 4),
|
(20, 6, 7, 4, 104, 1300, 4),
|
||||||
(25, 8, 9, 5, 105, 1400, 5);
|
(25, 8, 9, 5, 105, 1400, 5);
|
||||||
|
|
||||||
@@ -1276,10 +1345,23 @@ SET difficulty_id = CASE
|
|||||||
END
|
END
|
||||||
WHERE id BETWEEN 901 AND 1409;
|
WHERE id BETWEEN 901 AND 1409;
|
||||||
|
|
||||||
|
DELETE FROM crafting_recipe_components
|
||||||
|
WHERE recipe_id IN (
|
||||||
|
SELECT crafting_recipes.id
|
||||||
|
FROM crafting_recipes
|
||||||
|
JOIN items ON items.id = crafting_recipes.item_id
|
||||||
|
WHERE items.item_level NOT IN (1, 5, 10, 15, 20, 25)
|
||||||
|
);
|
||||||
|
|
||||||
|
DELETE FROM crafting_recipes
|
||||||
|
WHERE item_id IN (
|
||||||
|
SELECT id FROM items WHERE item_level NOT IN (1, 5, 10, 15, 20, 25)
|
||||||
|
);
|
||||||
|
|
||||||
UPDATE items
|
UPDATE items
|
||||||
SET rarity = CASE item_level
|
SET rarity = CASE item_level
|
||||||
WHEN 1 THEN 'common'
|
WHEN 1 THEN 'common'
|
||||||
WHEN 5 THEN 'common'
|
WHEN 5 THEN 'uncommon'
|
||||||
WHEN 10 THEN 'uncommon'
|
WHEN 10 THEN 'uncommon'
|
||||||
WHEN 15 THEN 'rare'
|
WHEN 15 THEN 'rare'
|
||||||
WHEN 20 THEN 'epic'
|
WHEN 20 THEN 'epic'
|
||||||
|
|||||||
@@ -79,9 +79,9 @@ cd /Users/warren/Documents/testgame/testgame
|
|||||||
export GITEA_URL="https://git.whoagland.com"
|
export GITEA_URL="https://git.whoagland.com"
|
||||||
export GITEA_OWNER="phenom"
|
export GITEA_OWNER="phenom"
|
||||||
export GITEA_REPO="i-want-to-heal"
|
export GITEA_REPO="i-want-to-heal"
|
||||||
export GITEA_TOKEN="PASTE_TOKEN_HERE"
|
export GITEA_TOKEN="ed2db3fd54546e9658377d0551b3fc3961583f1d"
|
||||||
|
|
||||||
VERSION="1.0.26"
|
VERSION="1.0.27"
|
||||||
APK="IWantToHeal-Thor-v$VERSION.apk"
|
APK="IWantToHeal-Thor-v$VERSION.apk"
|
||||||
|
|
||||||
RELEASE_JSON=$(curl -sS -X POST "$GITEA_URL/api/v1/repos/$GITEA_OWNER/$GITEA_REPO/releases" \
|
RELEASE_JSON=$(curl -sS -X POST "$GITEA_URL/api/v1/repos/$GITEA_OWNER/$GITEA_REPO/releases" \
|
||||||
|
|||||||
+56
-18
@@ -22,6 +22,7 @@ const bossImageContentTypes = {
|
|||||||
}
|
}
|
||||||
const equipmentSlots = ['weapon', 'helmet', 'chest', 'gloves', 'boots', 'pants', 'ring', 'necklace', 'trinket']
|
const equipmentSlots = ['weapon', 'helmet', 'chest', 'gloves', 'boots', 'pants', 'ring', 'necklace', 'trinket']
|
||||||
const componentSlot = 'component'
|
const componentSlot = 'component'
|
||||||
|
const directCraftItemLevels = new Set([1, 10, 20, 25])
|
||||||
const sessionCookieName = 'chronicle_session'
|
const sessionCookieName = 'chronicle_session'
|
||||||
const sessionLifetimeSeconds = 60 * 60 * 24 * 30
|
const sessionLifetimeSeconds = 60 * 60 * 24 * 30
|
||||||
const rateLimitBuckets = new Map()
|
const rateLimitBuckets = new Map()
|
||||||
@@ -232,6 +233,25 @@ function consumeRateLimit(key, limit, windowMs) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function catchUpExperienceReward(database, accountId, characterId, baseReward, currentExperience, currentLevel) {
|
||||||
|
const targetLevel = database.prepare(`
|
||||||
|
SELECT COALESCE(MAX(level), 0) AS level
|
||||||
|
FROM characters
|
||||||
|
WHERE account_id = ?
|
||||||
|
AND id != ?
|
||||||
|
`).get(accountId, characterId).level
|
||||||
|
if (targetLevel <= currentLevel) return baseReward
|
||||||
|
const targetExperience = database.prepare(`
|
||||||
|
SELECT experience_required AS experienceRequired
|
||||||
|
FROM level_progression
|
||||||
|
WHERE level = ?
|
||||||
|
`).get(targetLevel)?.experienceRequired ?? currentExperience
|
||||||
|
const gap = Math.max(0, targetExperience - currentExperience)
|
||||||
|
if (gap <= 0) return baseReward
|
||||||
|
const doubledBase = Math.min(baseReward, Math.ceil(gap / 2))
|
||||||
|
return doubledBase * 2 + (baseReward - doubledBase)
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeUsername(value) {
|
function normalizeUsername(value) {
|
||||||
const username = String(value ?? '').trim()
|
const username = String(value ?? '').trim()
|
||||||
if (!/^[A-Za-z0-9_]{3,20}$/.test(username)) {
|
if (!/^[A-Za-z0-9_]{3,20}$/.test(username)) {
|
||||||
@@ -1693,16 +1713,9 @@ function craftItem(database, characterId, recipeId) {
|
|||||||
WHERE crafting_recipes.id = ?
|
WHERE crafting_recipes.id = ?
|
||||||
`).get(recipeId)
|
`).get(recipeId)
|
||||||
if (!recipe) throw new Error('That crafting recipe does not exist.')
|
if (!recipe) throw new Error('That crafting recipe does not exist.')
|
||||||
const lowerTierRecipe = database.prepare(`
|
if (!directCraftItemLevels.has(recipe.itemLevel)) {
|
||||||
SELECT crafting_recipes.id
|
throw new Error('Upgrade the previous item tier instead.')
|
||||||
FROM crafting_recipes
|
}
|
||||||
JOIN items ON items.id = crafting_recipes.item_id
|
|
||||||
WHERE crafting_recipes.source_encounter_id = ?
|
|
||||||
AND items.slot = ?
|
|
||||||
AND items.item_level < ?
|
|
||||||
LIMIT 1
|
|
||||||
`).get(recipe.sourceEncounterId, recipe.slot, recipe.itemLevel)
|
|
||||||
if (lowerTierRecipe) throw new Error('Upgrade the previous item tier instead.')
|
|
||||||
|
|
||||||
const components = database.prepare(`
|
const components = database.prepare(`
|
||||||
SELECT
|
SELECT
|
||||||
@@ -2024,12 +2037,21 @@ function completeDungeon(database, characterId, accountId, dungeonId, difficulty
|
|||||||
const completedPart = Math.min(Math.max(Number(runMetrics?.completedPart) || 1, 1), 3)
|
const completedPart = Math.min(Math.max(Number(runMetrics?.completedPart) || 1, 1), 3)
|
||||||
const startPart = Math.min(Math.max(Number(runMetrics?.startPart) || 1, 1), 3)
|
const startPart = Math.min(Math.max(Number(runMetrics?.startPart) || 1, 1), 3)
|
||||||
const completedParts = completedPart - startPart + 1
|
const completedParts = completedPart - startPart + 1
|
||||||
|
const rewardMultiplier = runMetrics?.hardMode ? 2 : 1
|
||||||
const rawPartDurations = runMetrics?.partDurationSeconds
|
const rawPartDurations = runMetrics?.partDurationSeconds
|
||||||
const partDurationSeconds = Array.isArray(rawPartDurations) && rawPartDurations.length === 3
|
const partDurationSeconds = Array.isArray(rawPartDurations) && rawPartDurations.length === 3
|
||||||
? rawPartDurations.map(Number)
|
? rawPartDurations.map(Number)
|
||||||
: null
|
: null
|
||||||
const experienceReward = Math.round(
|
const baseExperienceReward = Math.round(
|
||||||
dungeon.experienceReward * dungeon.experienceMultiplier * completedPart,
|
dungeon.experienceReward * dungeon.experienceMultiplier * completedPart * rewardMultiplier,
|
||||||
|
)
|
||||||
|
const experienceReward = catchUpExperienceReward(
|
||||||
|
database,
|
||||||
|
accountId,
|
||||||
|
characterId,
|
||||||
|
baseExperienceReward,
|
||||||
|
character.experience,
|
||||||
|
character.level,
|
||||||
)
|
)
|
||||||
const newExperience = Math.min(character.experience + experienceReward, maxExperience)
|
const newExperience = Math.min(character.experience + experienceReward, maxExperience)
|
||||||
const newLevel = database.prepare(`
|
const newLevel = database.prepare(`
|
||||||
@@ -2127,17 +2149,18 @@ function completeDungeon(database, characterId, accountId, dungeonId, difficulty
|
|||||||
`).all(dungeonId, dungeon.completionItemLevel ?? dungeon.droppedItemLevel + 3)
|
`).all(dungeonId, dungeon.completionItemLevel ?? dungeon.droppedItemLevel + 3)
|
||||||
if (bonusItems.length > 0) {
|
if (bonusItems.length > 0) {
|
||||||
bonusItem = bonusItems[0]
|
bonusItem = bonusItems[0]
|
||||||
|
const rewardQuantity = rewardMultiplier
|
||||||
const previousQuantity = database.prepare(`
|
const previousQuantity = database.prepare(`
|
||||||
SELECT quantity FROM character_inventory
|
SELECT quantity FROM character_inventory
|
||||||
WHERE character_id = ? AND item_id = ?
|
WHERE character_id = ? AND item_id = ?
|
||||||
`).get(characterId, bonusItem.id)?.quantity ?? 0
|
`).get(characterId, bonusItem.id)?.quantity ?? 0
|
||||||
database.prepare(`
|
database.prepare(`
|
||||||
INSERT INTO character_inventory (character_id, item_id, quantity, equipped)
|
INSERT INTO character_inventory (character_id, item_id, quantity, equipped)
|
||||||
VALUES (?, ?, 1, 0)
|
VALUES (?, ?, ?, 0)
|
||||||
ON CONFLICT(character_id, item_id)
|
ON CONFLICT(character_id, item_id)
|
||||||
DO UPDATE SET quantity = quantity + 1
|
DO UPDATE SET quantity = quantity + ?
|
||||||
`).run(characterId, bonusItem.id)
|
`).run(characterId, bonusItem.id, rewardQuantity, rewardQuantity)
|
||||||
bonusItem = { ...bonusItem, quantity: 1, duplicate: previousQuantity > 0, quantityAfter: previousQuantity + 1 }
|
bonusItem = { ...bonusItem, quantity: rewardQuantity, duplicate: previousQuantity > 0, quantityAfter: previousQuantity + rewardQuantity }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2234,6 +2257,12 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
|
|||||||
let newExperience = character.experience
|
let newExperience = character.experience
|
||||||
let newLevel = character.level
|
let newLevel = character.level
|
||||||
if (experienceMode === 'pvp-boss-quarter-level') {
|
if (experienceMode === 'pvp-boss-quarter-level') {
|
||||||
|
const catchUpTargetLevel = database.prepare(`
|
||||||
|
SELECT COALESCE(MAX(level), 0) AS level
|
||||||
|
FROM characters
|
||||||
|
WHERE account_id = ?
|
||||||
|
AND id != ?
|
||||||
|
`).get(accountId, characterId).level
|
||||||
for (let bossIndex = 0; bossIndex < bossesCleared && newExperience < maxExperience; bossIndex += 1) {
|
for (let bossIndex = 0; bossIndex < bossesCleared && newExperience < maxExperience; bossIndex += 1) {
|
||||||
const currentLevelFloor = database.prepare(`
|
const currentLevelFloor = database.prepare(`
|
||||||
SELECT experience_required AS experienceRequired
|
SELECT experience_required AS experienceRequired
|
||||||
@@ -2248,7 +2277,8 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
|
|||||||
WHERE level = ?
|
WHERE level = ?
|
||||||
`).get(newLevel + 1).experienceRequired
|
`).get(newLevel + 1).experienceRequired
|
||||||
const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor)
|
const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor)
|
||||||
newExperience = Math.min(maxExperience, newExperience + Math.round(levelBand * 0.25))
|
const rewardRate = catchUpTargetLevel > newLevel ? 0.5 : 0.25
|
||||||
|
newExperience = Math.min(maxExperience, newExperience + Math.round(levelBand * rewardRate))
|
||||||
newLevel = database.prepare(`
|
newLevel = database.prepare(`
|
||||||
SELECT MAX(level) AS level
|
SELECT MAX(level) AS level
|
||||||
FROM level_progression
|
FROM level_progression
|
||||||
@@ -2256,9 +2286,17 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
|
|||||||
`).get(newExperience).level
|
`).get(newExperience).level
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const experienceReward = Math.round(
|
const baseExperienceReward = Math.round(
|
||||||
dungeon.experienceReward * dungeon.experienceMultiplier * (encountersCleared / 3),
|
dungeon.experienceReward * dungeon.experienceMultiplier * (encountersCleared / 3),
|
||||||
)
|
)
|
||||||
|
const experienceReward = catchUpExperienceReward(
|
||||||
|
database,
|
||||||
|
accountId,
|
||||||
|
characterId,
|
||||||
|
baseExperienceReward,
|
||||||
|
character.experience,
|
||||||
|
character.level,
|
||||||
|
)
|
||||||
newExperience = Math.min(character.experience + experienceReward, maxExperience)
|
newExperience = Math.min(character.experience + experienceReward, maxExperience)
|
||||||
newLevel = database.prepare(`
|
newLevel = database.prepare(`
|
||||||
SELECT MAX(level) AS level
|
SELECT MAX(level) AS level
|
||||||
|
|||||||
+1720
-21
File diff suppressed because it is too large
Load Diff
+73
-35
@@ -88,6 +88,7 @@ function App() {
|
|||||||
const [roguelikeAbilityLabelMode, setRoguelikeAbilityLabelMode] = useState<RoguelikeAbilityLabelMode>('ability')
|
const [roguelikeAbilityLabelMode, setRoguelikeAbilityLabelMode] = useState<RoguelikeAbilityLabelMode>('ability')
|
||||||
const [pvpContentType, setPvpContentType] = useState<PvpContentType>('dungeon')
|
const [pvpContentType, setPvpContentType] = useState<PvpContentType>('dungeon')
|
||||||
const [selectedPart, setSelectedPart] = useState(1)
|
const [selectedPart, setSelectedPart] = useState(1)
|
||||||
|
const [selectedHardMode, setSelectedHardMode] = useState(false)
|
||||||
const [combatContentId, setCombatContentId] = useState(1)
|
const [combatContentId, setCombatContentId] = useState(1)
|
||||||
const [leaderboardCategory, setLeaderboardCategory] = useState<'part_1' | 'part_2' | 'part_3' | 'full_run'>('part_1')
|
const [leaderboardCategory, setLeaderboardCategory] = useState<'part_1' | 'part_2' | 'part_3' | 'full_run'>('part_1')
|
||||||
const [showLoot, setShowLoot] = useState(false)
|
const [showLoot, setShowLoot] = useState(false)
|
||||||
@@ -235,6 +236,7 @@ function App() {
|
|||||||
<CombatScreen
|
<CombatScreen
|
||||||
difficulty={difficulty}
|
difficulty={difficulty}
|
||||||
dungeon={dungeon}
|
dungeon={dungeon}
|
||||||
|
hardMode={selectedHardMode && combatContentId > 0}
|
||||||
profile={profile}
|
profile={profile}
|
||||||
roguelikeMode={combatContentId < 0 ? roguelikeKind : undefined}
|
roguelikeMode={combatContentId < 0 ? roguelikeKind : undefined}
|
||||||
roguelikeUpgradeTiming={combatContentId < 0 ? roguelikeUpgradeTiming : undefined}
|
roguelikeUpgradeTiming={combatContentId < 0 ? roguelikeUpgradeTiming : undefined}
|
||||||
@@ -283,6 +285,20 @@ function App() {
|
|||||||
const raid = raidOptions.find((candidate) => candidate.id === selectedRaidId)
|
const raid = raidOptions.find((candidate) => candidate.id === selectedRaidId)
|
||||||
?? raidOptions[0]
|
?? raidOptions[0]
|
||||||
const activityOptions = screen === 'raids' ? raidOptions : dungeonOptions
|
const activityOptions = screen === 'raids' ? raidOptions : dungeonOptions
|
||||||
|
const startPveRoguelike = () => {
|
||||||
|
const baseDungeon = dungeonOptions[0]
|
||||||
|
const baseRaid = raidOptions[0]
|
||||||
|
if (roguelikeKind === 'raid') {
|
||||||
|
setCombatContentId(-2)
|
||||||
|
setSelectedDifficultyId(baseRaid?.difficulties[0]?.id ?? 101)
|
||||||
|
} else {
|
||||||
|
setCombatContentId(-1)
|
||||||
|
setSelectedDifficultyId(baseDungeon?.difficulties[0]?.id ?? 1)
|
||||||
|
}
|
||||||
|
setSelectedPart(1)
|
||||||
|
setSelectedHardMode(false)
|
||||||
|
setScreen('combat')
|
||||||
|
}
|
||||||
const tierOptions = activityOptions
|
const tierOptions = activityOptions
|
||||||
.flatMap((option) => option.difficulties)
|
.flatMap((option) => option.difficulties)
|
||||||
.filter((difficulty, index, all) => (
|
.filter((difficulty, index, all) => (
|
||||||
@@ -315,9 +331,9 @@ function App() {
|
|||||||
: profile.completedDungeonParts
|
: profile.completedDungeonParts
|
||||||
const sectionName = activity.contentType === 'raid' ? 'Phase' : 'Part'
|
const sectionName = activity.contentType === 'raid' ? 'Phase' : 'Part'
|
||||||
const parts = [
|
const parts = [
|
||||||
{ part: 1, name: `${sectionName} 1`, encounterCount: 3, unlocked: true },
|
{ part: 1, name: `${sectionName} 1`, encounterCount: 3, unlocked: true, hardUnlocked: completedSections >= 1 },
|
||||||
{ part: 2, name: `${sectionName} 2`, encounterCount: 3, unlocked: completedSections >= 1 },
|
{ part: 2, name: `${sectionName} 2`, encounterCount: 3, unlocked: completedSections >= 1, hardUnlocked: completedSections >= 2 },
|
||||||
{ part: 3, name: `${sectionName} 3`, encounterCount: 3, unlocked: completedSections >= 2 },
|
{ part: 3, name: `${sectionName} 3`, encounterCount: 3, unlocked: completedSections >= 2, hardUnlocked: completedSections >= 3 },
|
||||||
]
|
]
|
||||||
const cloudSync = getCloudSyncStatus()
|
const cloudSync = getCloudSyncStatus()
|
||||||
const canShowCloudSync = account.id !== -1 && cloudSync.available
|
const canShowCloudSync = account.id !== -1 && cloudSync.available
|
||||||
@@ -328,7 +344,7 @@ function App() {
|
|||||||
: a.sequence - b.sequence)
|
: a.sequence - b.sequence)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className={`game-shell ${screen === 'dungeons' || screen === 'raids' ? 'dungeon-shell' : ''}`}>
|
<main className={`game-shell ${screen === 'dungeons' || screen === 'raids' ? 'dungeon-shell' : ''} ${screen === 'customize' ? 'workshop-shell' : ''}`}>
|
||||||
<header className="topbar app-header">
|
<header className="topbar app-header">
|
||||||
<button
|
<button
|
||||||
className="brand-button"
|
className="brand-button"
|
||||||
@@ -425,6 +441,28 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
{roguelikeVariant === 'pve' && (
|
{roguelikeVariant === 'pve' && (
|
||||||
<>
|
<>
|
||||||
|
<div className="roguelike-option-panel">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Run Type</p>
|
||||||
|
<h2>PvE Roguelike</h2>
|
||||||
|
</div>
|
||||||
|
<div className="roguelike-timing-row">
|
||||||
|
<button
|
||||||
|
className={`text-button ${roguelikeKind === 'dungeon' ? 'active' : ''}`}
|
||||||
|
onClick={() => setRoguelikeKind('dungeon')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Dungeon
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`text-button ${roguelikeKind === 'raid' ? 'active' : ''}`}
|
||||||
|
onClick={() => setRoguelikeKind('raid')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Raid
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="roguelike-option-panel">
|
<div className="roguelike-option-panel">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Upgrade Timing</p>
|
<p className="eyebrow">Upgrade Timing</p>
|
||||||
@@ -469,38 +507,22 @@ function App() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="roguelike-mode-grid">
|
<div className="menu-card pvp-queue-panel">
|
||||||
|
<span>{roguelikeKind === 'raid' ? 'R' : 'D'}</span>
|
||||||
|
<div>
|
||||||
|
<strong>{roguelikeKind === 'raid' ? 'Raid Roguelike' : 'Dungeon Roguelike'}</strong>
|
||||||
|
<small>
|
||||||
|
{roguelikeKind === 'raid'
|
||||||
|
? 'Ten-player party. Raid pools, lighter early scaling, and the same upgrade draft.'
|
||||||
|
: 'Five-player party. Two random trash enemies and a boss with a lighter early ramp.'}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
className="menu-card"
|
className="text-button"
|
||||||
onClick={() => {
|
onClick={startPveRoguelike}
|
||||||
const baseDungeon = dungeonOptions[0]
|
|
||||||
setRoguelikeKind('dungeon')
|
|
||||||
setCombatContentId(-1)
|
|
||||||
setSelectedDifficultyId(baseDungeon.difficulties[0]?.id ?? 1)
|
|
||||||
setSelectedPart(1)
|
|
||||||
setScreen('combat')
|
|
||||||
}}
|
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<span>D</span>
|
Start Run
|
||||||
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -709,12 +731,13 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="part-picker">
|
<div className="part-picker">
|
||||||
{parts.map((p) => (
|
{parts.map((p) => (
|
||||||
|
<div className="part-start-row" key={p.part}>
|
||||||
<button
|
<button
|
||||||
key={p.part}
|
className={`primary-button ${selectedPart === p.part && !selectedHardMode ? 'selected-part' : ''} ${!p.unlocked ? 'locked' : ''}`}
|
||||||
className={`primary-button ${selectedPart === p.part ? 'selected-part' : ''} ${!p.unlocked ? 'locked' : ''}`}
|
|
||||||
disabled={difficultyLocked || !p.unlocked}
|
disabled={difficultyLocked || !p.unlocked}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedPart(p.part)
|
setSelectedPart(p.part)
|
||||||
|
setSelectedHardMode(false)
|
||||||
setCombatContentId(activity.id)
|
setCombatContentId(activity.id)
|
||||||
setSelectedDifficultyId(selectedDifficulty.id)
|
setSelectedDifficultyId(selectedDifficulty.id)
|
||||||
setScreen('combat')
|
setScreen('combat')
|
||||||
@@ -723,6 +746,21 @@ function App() {
|
|||||||
>
|
>
|
||||||
{p.name}
|
{p.name}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className={`primary-button hard-mode-button ${selectedPart === p.part && selectedHardMode ? 'selected-part' : ''} ${!p.hardUnlocked ? 'locked' : ''}`}
|
||||||
|
disabled={difficultyLocked || !p.hardUnlocked}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedPart(p.part)
|
||||||
|
setSelectedHardMode(true)
|
||||||
|
setCombatContentId(activity.id)
|
||||||
|
setSelectedDifficultyId(selectedDifficulty.id)
|
||||||
|
setScreen('combat')
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Hard
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
+266
-46
@@ -10,6 +10,10 @@ import {
|
|||||||
import {
|
import {
|
||||||
INITIAL_PARTY,
|
INITIAL_PARTY,
|
||||||
RAID_PARTY,
|
RAID_PARTY,
|
||||||
|
DEFAULT_GROUP_HEAL_TARGETS,
|
||||||
|
groupHealTargets,
|
||||||
|
partyDamageOutput,
|
||||||
|
tankPressureTargets,
|
||||||
type CombatLogEntry,
|
type CombatLogEntry,
|
||||||
type PartyMember,
|
type PartyMember,
|
||||||
type Spell,
|
type Spell,
|
||||||
@@ -39,7 +43,7 @@ const TICK_MS = 700
|
|||||||
type RoguelikeMode = 'dungeon' | 'raid'
|
type RoguelikeMode = 'dungeon' | 'raid'
|
||||||
type RoguelikeUpgradeTiming = 'boss' | 'encounter'
|
type RoguelikeUpgradeTiming = 'boss' | 'encounter'
|
||||||
type RoguelikeAbilityLabelMode = 'ability' | 'slot'
|
type RoguelikeAbilityLabelMode = 'ability' | 'slot'
|
||||||
type SlotKey = '1' | '2' | '3' | '4' | '5'
|
type SlotKey = '1' | '2' | '3' | '4' | '5' | '6'
|
||||||
type RoguelikeMechanic =
|
type RoguelikeMechanic =
|
||||||
| 'party-pulse'
|
| 'party-pulse'
|
||||||
| 'searing-mark'
|
| 'searing-mark'
|
||||||
@@ -109,6 +113,47 @@ function healMember(member: PartyMember, amount: number) {
|
|||||||
return clamp(member.health + healAmount(member, amount), 0, effectiveMaxHealth(member))
|
return clamp(member.health + healAmount(member, amount), 0, effectiveMaxHealth(member))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function memberHotEffects(member: PartyMember) {
|
||||||
|
if (member.hotEffects?.length) return member.hotEffects
|
||||||
|
return member.hotTicks > 0
|
||||||
|
? [{ id: '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) {
|
||||||
|
return [
|
||||||
|
...memberHotEffects(member),
|
||||||
|
{
|
||||||
|
id: effectId(spell.id),
|
||||||
|
label: spell.name,
|
||||||
|
ticks,
|
||||||
|
power: Math.max(1, Math.round(spell.power / 2)),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function addBounceHeal(member: PartyMember, spell: Spell) {
|
||||||
|
return [
|
||||||
|
...(member.bounceHeals ?? []),
|
||||||
|
{
|
||||||
|
id: effectId(spell.id),
|
||||||
|
label: spell.name,
|
||||||
|
charges: 4,
|
||||||
|
power: spell.power,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function tickHotEffects(effects: PartyMember['hotEffects']) {
|
||||||
|
return (effects ?? [])
|
||||||
|
.map((effect) => ({ ...effect, ticks: effect.ticks - 1 }))
|
||||||
|
.filter((effect) => effect.ticks > 0)
|
||||||
|
}
|
||||||
|
|
||||||
function upgradeStackCount(upgrades: RoguelikeUpgrade[], id: RoguelikeUpgradeId) {
|
function upgradeStackCount(upgrades: RoguelikeUpgrade[], id: RoguelikeUpgradeId) {
|
||||||
return upgrades.filter((upgrade) => upgrade.id === id).length
|
return upgrades.filter((upgrade) => upgrade.id === id).length
|
||||||
}
|
}
|
||||||
@@ -123,7 +168,7 @@ function buildRoguelikeUpgrades(
|
|||||||
spells: Spell[],
|
spells: Spell[],
|
||||||
labelMode: RoguelikeAbilityLabelMode,
|
labelMode: RoguelikeAbilityLabelMode,
|
||||||
): RoguelikeUpgrade[] {
|
): RoguelikeUpgrade[] {
|
||||||
const slotUpgrades = (['1', '2', '3', '4', '5'] as SlotKey[]).flatMap((slot) => {
|
const slotUpgrades = (['1', '2', '3', '4', '5', '6'] as SlotKey[]).flatMap((slot) => {
|
||||||
const label = slotLabel(slot, spells, labelMode)
|
const label = slotLabel(slot, spells, labelMode)
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -191,9 +236,14 @@ function spellResourceCost(spell: Spell, upgrades: RoguelikeUpgrade[], freeCastR
|
|||||||
function toCombatSpell(ability: Ability, key: string, healingPower: number): Spell {
|
function toCombatSpell(ability: Ability, key: string, healingPower: number): Spell {
|
||||||
const kinds: Record<string, Spell['kind']> = {
|
const kinds: Record<string, Spell['kind']> = {
|
||||||
direct_heal: 'direct',
|
direct_heal: 'direct',
|
||||||
|
direct_hot: 'direct',
|
||||||
heal_over_time: 'hot',
|
heal_over_time: 'hot',
|
||||||
party_heal: 'group',
|
party_heal: 'group',
|
||||||
|
party_hot: 'group',
|
||||||
|
party_absorb: 'group',
|
||||||
absorb: 'shield',
|
absorb: 'shield',
|
||||||
|
damage_reduction: 'damage_reduction',
|
||||||
|
bounce_heal: 'bounce_heal',
|
||||||
cleanse: 'cleanse',
|
cleanse: 'cleanse',
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -206,6 +256,7 @@ function toCombatSpell(ability: Ability, key: string, healingPower: number): Spe
|
|||||||
power: ability.power + healingPower,
|
power: ability.power + healingPower,
|
||||||
glyph: ability.glyph,
|
glyph: ability.glyph,
|
||||||
kind: kinds[ability.spellType] ?? 'direct',
|
kind: kinds[ability.spellType] ?? 'direct',
|
||||||
|
effectType: ability.spellType,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,6 +340,7 @@ function makeRoguelikeSegment(
|
|||||||
export function CombatScreen({
|
export function CombatScreen({
|
||||||
difficulty,
|
difficulty,
|
||||||
dungeon,
|
dungeon,
|
||||||
|
hardMode = false,
|
||||||
profile,
|
profile,
|
||||||
startPart = 1,
|
startPart = 1,
|
||||||
roguelikeMode,
|
roguelikeMode,
|
||||||
@@ -300,6 +352,7 @@ export function CombatScreen({
|
|||||||
}: {
|
}: {
|
||||||
difficulty: Difficulty
|
difficulty: Difficulty
|
||||||
dungeon: Dungeon
|
dungeon: Dungeon
|
||||||
|
hardMode?: boolean
|
||||||
profile: CharacterProfile
|
profile: CharacterProfile
|
||||||
startPart?: number
|
startPart?: number
|
||||||
roguelikeMode?: RoguelikeMode
|
roguelikeMode?: RoguelikeMode
|
||||||
@@ -350,15 +403,16 @@ export function CombatScreen({
|
|||||||
const sectionName = isRoguelike ? 'Stage' : dungeon.contentType === 'raid' ? 'Phase' : 'Part'
|
const sectionName = isRoguelike ? 'Stage' : dungeon.contentType === 'raid' ? 'Phase' : 'Part'
|
||||||
const contentName = isRoguelike ? 'Roguelike' : dungeon.contentType === 'raid' ? 'Raid' : 'Dungeon'
|
const contentName = isRoguelike ? 'Roguelike' : dungeon.contentType === 'raid' ? 'Raid' : 'Dungeon'
|
||||||
const initialEncounterIndex = (startPart - 1) * 3
|
const initialEncounterIndex = (startPart - 1) * 3
|
||||||
|
const enemyCount = hardMode ? 2 : 1
|
||||||
const initialCombatState = useMemo<SinglePlayerCombatState>(() => ({
|
const initialCombatState = useMemo<SinglePlayerCombatState>(() => ({
|
||||||
party: partyTemplate,
|
party: partyTemplate,
|
||||||
resource: maxResource,
|
resource: maxResource,
|
||||||
enemyHealth: encounters[initialEncounterIndex].maxHealth,
|
enemyHealth: encounters[initialEncounterIndex].maxHealth * enemyCount,
|
||||||
cooldowns: {},
|
cooldowns: {},
|
||||||
elapsedTicks: 0,
|
elapsedTicks: 0,
|
||||||
castsTowardFree: 0,
|
castsTowardFree: 0,
|
||||||
freeCastReady: false,
|
freeCastReady: false,
|
||||||
}), [encounters, initialEncounterIndex, maxResource, partyTemplate])
|
}), [encounters, enemyCount, initialEncounterIndex, maxResource, partyTemplate])
|
||||||
const [combatState, setCombatState] = useState<SinglePlayerCombatState>(() => initialCombatState)
|
const [combatState, setCombatState] = useState<SinglePlayerCombatState>(() => initialCombatState)
|
||||||
const [selectedId, setSelectedId] = useState(partyTemplate[0].id)
|
const [selectedId, setSelectedId] = useState(partyTemplate[0].id)
|
||||||
const [encounterIndex, setEncounterIndex] = useState(initialEncounterIndex)
|
const [encounterIndex, setEncounterIndex] = useState(initialEncounterIndex)
|
||||||
@@ -377,7 +431,7 @@ export function CombatScreen({
|
|||||||
const [upgradeChoices, setUpgradeChoices] = useState<RoguelikeUpgrade[]>([])
|
const [upgradeChoices, setUpgradeChoices] = useState<RoguelikeUpgrade[]>([])
|
||||||
const rewardClaimedRef = useRef(false)
|
const rewardClaimedRef = useRef(false)
|
||||||
const profileRefreshedRef = useRef(false)
|
const profileRefreshedRef = useRef(false)
|
||||||
const rolledEncounterIdsRef = useRef(new Set<number>())
|
const rolledEncounterIdsRef = useRef(new Set<string>())
|
||||||
const runTokenRef = useRef(crypto.randomUUID())
|
const runTokenRef = useRef(crypto.randomUUID())
|
||||||
const resourceSpentRef = useRef(0)
|
const resourceSpentRef = useRef(0)
|
||||||
const runStartedAtRef = useRef(0)
|
const runStartedAtRef = useRef(0)
|
||||||
@@ -386,14 +440,24 @@ export function CombatScreen({
|
|||||||
const nextFloatingTextId = useRef(1)
|
const nextFloatingTextId = useRef(1)
|
||||||
const combatRef = useRef(initialCombatState)
|
const combatRef = useRef(initialCombatState)
|
||||||
const selectedIdRef = useRef(partyTemplate[0].id)
|
const selectedIdRef = useRef(partyTemplate[0].id)
|
||||||
|
const runCombatTickRef = useRef<() => void>(() => {})
|
||||||
|
const combatClockActiveRef = useRef(false)
|
||||||
|
const lastCombatTickAtRef = useRef(performance.now())
|
||||||
|
const statusRef = useRef(status)
|
||||||
|
const pausedRef = useRef(paused)
|
||||||
const { party, resource, enemyHealth, cooldowns, freeCastReady } = combatState
|
const { party, resource, enemyHealth, cooldowns, freeCastReady } = combatState
|
||||||
const encounter = encounters[encounterIndex]
|
const encounter = encounters[encounterIndex]
|
||||||
|
const encounterMaxHealth = encounter.maxHealth * enemyCount
|
||||||
const currentPart = getCurrentPart(encounterIndex)
|
const currentPart = getCurrentPart(encounterIndex)
|
||||||
|
const completedSections = dungeon.contentType === 'raid'
|
||||||
|
? profile.completedRaidPhases
|
||||||
|
: profile.completedDungeonParts
|
||||||
|
const canContinueAfterPart = !hardMode || completedSections >= currentPart + 1
|
||||||
const firstEncounterIndex = (startPart - 1) * 3
|
const firstEncounterIndex = (startPart - 1) * 3
|
||||||
const expectedLootRolls = encounters
|
const expectedLootRolls = encounters
|
||||||
.slice(firstEncounterIndex, encounterIndex + 1)
|
.slice(firstEncounterIndex, encounterIndex + 1)
|
||||||
.filter((candidate) => candidate.lootTables.some((entry) => entry.difficultyId === difficulty.id))
|
.filter((candidate) => candidate.lootTables.some((entry) => entry.difficultyId === difficulty.id))
|
||||||
.length
|
.length * enemyCount
|
||||||
const isPartBoss = encounter.isBoss && encounterIndex % 3 === 2
|
const isPartBoss = encounter.isBoss && encounterIndex % 3 === 2
|
||||||
const isFinalBoss = isPartBoss && encounterIndex === encounters.length - 1
|
const isFinalBoss = isPartBoss && encounterIndex === encounters.length - 1
|
||||||
const playerHealer = party.find((member) => member.id === 'mira')
|
const playerHealer = party.find((member) => member.id === 'mira')
|
||||||
@@ -415,6 +479,9 @@ export function CombatScreen({
|
|||||||
enabled: dualScreenEnabled,
|
enabled: dualScreenEnabled,
|
||||||
} = useDualScreen()
|
} = useDualScreen()
|
||||||
|
|
||||||
|
statusRef.current = status
|
||||||
|
pausedRef.current = paused
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
runStartedAtRef.current = now
|
runStartedAtRef.current = now
|
||||||
@@ -472,10 +539,12 @@ export function CombatScreen({
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const requestLootRoll = useCallback(
|
const requestLootRoll = useCallback(
|
||||||
(encounterId: number) => {
|
(encounterId: number, rollIndex = 0) => {
|
||||||
if (rolledEncounterIdsRef.current.has(encounterId)) return
|
const rollKey = `${encounterId}:${rollIndex}`
|
||||||
rolledEncounterIdsRef.current.add(encounterId)
|
if (rolledEncounterIdsRef.current.has(rollKey)) return
|
||||||
rollEncounterLoot(encounterId, difficulty.id, runTokenRef.current)
|
rolledEncounterIdsRef.current.add(rollKey)
|
||||||
|
const runToken = rollIndex === 0 ? runTokenRef.current : `${runTokenRef.current}-hard-${rollIndex}`
|
||||||
|
rollEncounterLoot(encounterId, difficulty.id, runToken)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
setLootRolls((current) => [...current, result])
|
setLootRolls((current) => [...current, result])
|
||||||
const awarded = result.items
|
const awarded = result.items
|
||||||
@@ -507,7 +576,7 @@ export function CombatScreen({
|
|||||||
setCombat({
|
setCombat({
|
||||||
party: freshParty,
|
party: freshParty,
|
||||||
resource: maxResource,
|
resource: maxResource,
|
||||||
enemyHealth: nextEncounters[initialEncounterIndex].maxHealth,
|
enemyHealth: nextEncounters[initialEncounterIndex].maxHealth * enemyCount,
|
||||||
cooldowns: {},
|
cooldowns: {},
|
||||||
elapsedTicks: 0,
|
elapsedTicks: 0,
|
||||||
castsTowardFree: 0,
|
castsTowardFree: 0,
|
||||||
@@ -535,7 +604,7 @@ export function CombatScreen({
|
|||||||
runStartedAtRef.current = Date.now()
|
runStartedAtRef.current = Date.now()
|
||||||
partStartTimesRef.current = { [startPart]: runStartedAtRef.current }
|
partStartTimesRef.current = { [startPart]: runStartedAtRef.current }
|
||||||
setLog([{ id: nextLogId.current++, text: 'A new run begins.', tone: 'system' }])
|
setLog([{ id: nextLogId.current++, text: 'A new run begins.', tone: 'system' }])
|
||||||
}, [difficulty, initialEncounterIndex, maxResource, partyTemplate, roguelikeMode, roguelikePool, setCombat, setSelectedTargetId, startPart, staticEncounters])
|
}, [difficulty, enemyCount, initialEncounterIndex, maxResource, partyTemplate, roguelikeMode, roguelikePool, setCombat, setSelectedTargetId, startPart, staticEncounters])
|
||||||
|
|
||||||
const castSpell = useCallback(
|
const castSpell = useCallback(
|
||||||
(spell: Spell) => {
|
(spell: Spell) => {
|
||||||
@@ -553,7 +622,13 @@ export function CombatScreen({
|
|||||||
const directTargets = new Set([targetId])
|
const directTargets = new Set([targetId])
|
||||||
const hotTargets = new Set<string>()
|
const hotTargets = new Set<string>()
|
||||||
const shieldTargets = new Set<string>()
|
const shieldTargets = new Set<string>()
|
||||||
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.kind === 'shield') shieldTargets.add(targetId)
|
||||||
if (spell.name === 'Mend' && activeSetEffects.has('mend_extra_target')) {
|
if (spell.name === 'Mend' && activeSetEffects.has('mend_extra_target')) {
|
||||||
const extra = extraTarget([targetId])
|
const extra = extraTarget([targetId])
|
||||||
@@ -566,7 +641,6 @@ export function CombatScreen({
|
|||||||
if (spell.name === 'Mend' && activeSetEffects.has('mend_applies_renew')) {
|
if (spell.name === 'Mend' && activeSetEffects.has('mend_applies_renew')) {
|
||||||
hotTargets.add(targetId)
|
hotTargets.add(targetId)
|
||||||
}
|
}
|
||||||
const extraTargets = upgradeStackCount(roguelikeUpgrades, `slot${spell.key as SlotKey}-extra-target` as RoguelikeUpgradeId)
|
|
||||||
for (let index = 0; index < extraTargets; index += 1) {
|
for (let index = 0; index < extraTargets; index += 1) {
|
||||||
if (spell.kind === 'group') break
|
if (spell.kind === 'group') break
|
||||||
if (spell.kind === 'hot') {
|
if (spell.kind === 'hot') {
|
||||||
@@ -586,16 +660,39 @@ export function CombatScreen({
|
|||||||
const nextParty = current.party.map((member) => {
|
const nextParty = current.party.map((member) => {
|
||||||
if (member.health <= 0) return member
|
if (member.health <= 0) return member
|
||||||
if (spell.kind === 'group') {
|
if (spell.kind === 'group') {
|
||||||
|
if (!groupTargets.has(member.id)) return member
|
||||||
|
if (spell.effectType === 'party_absorb') {
|
||||||
|
const power = Math.round(spell.power * (1.25 ** upgradeStackCount(roguelikeUpgrades, 'shield-boost')))
|
||||||
|
return { ...member, shield: Math.max(member.shield, power) }
|
||||||
|
}
|
||||||
|
if (spell.effectType === 'party_hot') {
|
||||||
|
return {
|
||||||
|
...member,
|
||||||
|
hotTicks: 0,
|
||||||
|
hotEffects: addHotEffect(member, spell),
|
||||||
|
}
|
||||||
|
}
|
||||||
const power = Math.round(spell.power * (1.25 ** upgradeStackCount(roguelikeUpgrades, 'group-heal-boost')))
|
const power = Math.round(spell.power * (1.25 ** upgradeStackCount(roguelikeUpgrades, 'group-heal-boost')))
|
||||||
const nextHealth = healMember(member, power)
|
const nextHealth = healMember(member, power)
|
||||||
addFloatingHeal(member.id, Math.max(0, nextHealth - member.health))
|
addFloatingHeal(member.id, Math.max(0, nextHealth - member.health))
|
||||||
return { ...member, health: nextHealth }
|
return { ...member, health: nextHealth }
|
||||||
}
|
}
|
||||||
if (!directTargets.has(member.id) && !hotTargets.has(member.id) && !shieldTargets.has(member.id)) return member
|
if (
|
||||||
|
!directTargets.has(member.id)
|
||||||
|
&& !hotTargets.has(member.id)
|
||||||
|
&& !shieldTargets.has(member.id)
|
||||||
|
&& !(member.id === targetId && (spell.kind === 'damage_reduction' || spell.kind === 'bounce_heal'))
|
||||||
|
) return member
|
||||||
if (spell.kind === 'shield') {
|
if (spell.kind === 'shield') {
|
||||||
const power = Math.round(spell.power * (1.25 ** upgradeStackCount(roguelikeUpgrades, 'shield-boost')))
|
const power = Math.round(spell.power * (1.25 ** upgradeStackCount(roguelikeUpgrades, 'shield-boost')))
|
||||||
return { ...member, shield: Math.max(member.shield, power) }
|
return { ...member, shield: Math.max(member.shield, power) }
|
||||||
}
|
}
|
||||||
|
if (spell.kind === 'damage_reduction') {
|
||||||
|
return { ...member, damageReductionTicks: 12 }
|
||||||
|
}
|
||||||
|
if (spell.kind === 'bounce_heal') {
|
||||||
|
return { ...member, bounceHeals: addBounceHeal(member, spell) }
|
||||||
|
}
|
||||||
if (spell.kind === 'cleanse') {
|
if (spell.kind === 'cleanse') {
|
||||||
return {
|
return {
|
||||||
...member,
|
...member,
|
||||||
@@ -614,7 +711,8 @@ export function CombatScreen({
|
|||||||
return {
|
return {
|
||||||
...member,
|
...member,
|
||||||
health: nextHealth,
|
health: nextHealth,
|
||||||
hotTicks: hotTargets.has(member.id) ? 5 : member.hotTicks,
|
hotTicks: 0,
|
||||||
|
hotEffects: hotTargets.has(member.id) ? addHotEffect(member, spell) : member.hotEffects,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const freeCastStacks = upgradeStackCount(roguelikeUpgrades, 'fifth-cast-free')
|
const freeCastStacks = upgradeStackCount(roguelikeUpgrades, 'fifth-cast-free')
|
||||||
@@ -668,6 +766,7 @@ export function CombatScreen({
|
|||||||
completedPart,
|
completedPart,
|
||||||
runStartPart,
|
runStartPart,
|
||||||
[partDuration(1), partDuration(2), partDuration(3)],
|
[partDuration(1), partDuration(2), partDuration(3)],
|
||||||
|
hardMode,
|
||||||
)
|
)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
setReward(result)
|
setReward(result)
|
||||||
@@ -680,7 +779,7 @@ export function CombatScreen({
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
[difficulty.id, dungeon.id, onProfileUpdated],
|
[difficulty.id, dungeon.id, hardMode, onProfileUpdated],
|
||||||
)
|
)
|
||||||
|
|
||||||
const finishRoguelikeRun = useCallback(
|
const finishRoguelikeRun = useCallback(
|
||||||
@@ -778,6 +877,9 @@ export function CombatScreen({
|
|||||||
poisonStacks: undefined,
|
poisonStacks: undefined,
|
||||||
maxHealthPenaltyTicks: undefined,
|
maxHealthPenaltyTicks: undefined,
|
||||||
healingReductionTicks: undefined,
|
healingReductionTicks: undefined,
|
||||||
|
hotEffects: [],
|
||||||
|
bounceHeals: [],
|
||||||
|
damageReductionTicks: undefined,
|
||||||
}))
|
}))
|
||||||
const nextStage = clearedBoss ? roguelikeStage + 1 : roguelikeStage
|
const nextStage = clearedBoss ? roguelikeStage + 1 : roguelikeStage
|
||||||
const nextSegment = clearedBoss
|
const nextSegment = clearedBoss
|
||||||
@@ -796,7 +898,7 @@ export function CombatScreen({
|
|||||||
setCombat({
|
setCombat({
|
||||||
...current,
|
...current,
|
||||||
party: recoveredParty,
|
party: recoveredParty,
|
||||||
enemyHealth: nextEncounter.maxHealth,
|
enemyHealth: nextEncounter.maxHealth * enemyCount,
|
||||||
elapsedTicks: 0,
|
elapsedTicks: 0,
|
||||||
cooldowns: {},
|
cooldowns: {},
|
||||||
resource: clamp(current.resource + Math.round(maxResource * 0.25), 0, maxResource),
|
resource: clamp(current.resource + Math.round(maxResource * 0.25), 0, maxResource),
|
||||||
@@ -804,7 +906,7 @@ export function CombatScreen({
|
|||||||
setUpgradeChoices([])
|
setUpgradeChoices([])
|
||||||
setStatus('playing')
|
setStatus('playing')
|
||||||
addLog(`${upgrade.name} gained. ${nextEncounter.enemyName} approaches.`, 'system')
|
addLog(`${upgrade.name} gained. ${nextEncounter.enemyName} approaches.`, 'system')
|
||||||
}, [addLog, difficulty, encounterIndex, encounters, maxResource, roguelikeMode, roguelikePool, roguelikeStage, setCombat])
|
}, [addLog, difficulty, encounterIndex, encounters, enemyCount, maxResource, roguelikeMode, roguelikePool, roguelikeStage, setCombat])
|
||||||
|
|
||||||
useGameAction((action, device) => {
|
useGameAction((action, device) => {
|
||||||
if (action === 'pause' || (action === 'back' && device === 'pc')) {
|
if (action === 'pause' || (action === 'back' && device === 'pc')) {
|
||||||
@@ -846,9 +948,7 @@ export function CombatScreen({
|
|||||||
if (spell) castSpell(spell)
|
if (spell) castSpell(spell)
|
||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
const runCombatTick = useCallback(() => {
|
||||||
if (status !== 'playing' || paused) return
|
|
||||||
const timer = window.setInterval(() => {
|
|
||||||
const current = combatRef.current
|
const current = combatRef.current
|
||||||
const nextElapsedTicks = current.elapsedTicks + 1
|
const nextElapsedTicks = current.elapsedTicks + 1
|
||||||
const nextCooldowns = Object.fromEntries(
|
const nextCooldowns = Object.fromEntries(
|
||||||
@@ -896,19 +996,49 @@ export function CombatScreen({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const healerBeforeDamage = current.party.find((member) => member.id === 'mira')
|
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
|
if (member.health <= 0) return member
|
||||||
let damage = member.id === primaryTarget.id ? encounter.damage : 0
|
let damage = member.id === primaryTarget.id ? encounter.damage : 0
|
||||||
if (member.role === 'Tank') damage += encounter.tankDamage
|
if (tankPressureIds.has(member.id)) {
|
||||||
if (tankBuster && member.role === 'Tank') damage += Math.round(22 * difficulty.damageMultiplier)
|
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 (bossPulse) damage += Math.round(12 * difficulty.damageMultiplier)
|
||||||
if (member.debuff) damage += Math.round(7 * difficulty.damageMultiplier)
|
if (member.debuff) damage += Math.round(7 * difficulty.damageMultiplier)
|
||||||
const nextPoisonStacks = appliesPoison && member.id === primaryTarget.id
|
const nextPoisonStacks = appliesPoison && member.id === primaryTarget.id
|
||||||
? Math.max(1, (member.poisonStacks ?? 0) + 1)
|
? Math.max(1, (member.poisonStacks ?? 0) + 1)
|
||||||
: member.poisonStacks ?? 0
|
: member.poisonStacks ?? 0
|
||||||
if (nextPoisonStacks > 0) damage += Math.round((4 + nextPoisonStacks * 4) * difficulty.damageMultiplier)
|
if (nextPoisonStacks > 0) damage += Math.round((4 + nextPoisonStacks * 4) * difficulty.damageMultiplier)
|
||||||
|
damage *= enemyCount
|
||||||
|
if ((member.damageReductionTicks ?? 0) > 0) {
|
||||||
|
damage = Math.round(damage * 0.5)
|
||||||
|
}
|
||||||
const absorbed = Math.min(member.shield, damage)
|
const absorbed = Math.min(member.shield, damage)
|
||||||
const healing = member.hotTicks > 0 ? healAmount(member, 6) : 0
|
const hotEffects = memberHotEffects(member)
|
||||||
|
let healing = hotEffects.reduce((total, effect) => total + healAmount(member, effect.power), 0)
|
||||||
|
let nextBounceHeals = [...(member.bounceHeals ?? [])]
|
||||||
|
if (damage > 0 && nextBounceHeals.length > 0) {
|
||||||
|
nextBounceHeals = nextBounceHeals.flatMap((effect) => {
|
||||||
|
healing += healAmount(member, effect.power)
|
||||||
|
const nextCharges = effect.charges - 1
|
||||||
|
if (nextCharges <= 0) return []
|
||||||
|
const jumpTargets = current.party.filter((candidate) => candidate.health > 0 && candidate.id !== member.id)
|
||||||
|
const jumpTarget = jumpTargets[Math.floor(Math.random() * jumpTargets.length)] ?? member
|
||||||
|
pendingJumpHeals.push({
|
||||||
|
targetId: jumpTarget.id,
|
||||||
|
heal: { ...effect, charges: nextCharges },
|
||||||
|
})
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
}
|
||||||
if (healing > 0) addFloatingHeal(member.id, healing)
|
if (healing > 0) addFloatingHeal(member.id, healing)
|
||||||
const nextMaxHealthPenaltyTicks = appliesMaxHealthCut && member.id === primaryTarget.id
|
const nextMaxHealthPenaltyTicks = appliesMaxHealthCut && member.id === primaryTarget.id
|
||||||
? 15
|
? 15
|
||||||
@@ -922,9 +1052,12 @@ export function CombatScreen({
|
|||||||
: Math.max(0, (member.debuffTicks ?? 0) - 1)
|
: Math.max(0, (member.debuffTicks ?? 0) - 1)
|
||||||
return {
|
return {
|
||||||
...member,
|
...member,
|
||||||
health: clamp(member.health - damage + absorbed + healing, 0, nextEffectiveMaxHealth),
|
health: clamp(clamp(member.health + healing, 0, nextEffectiveMaxHealth) - damage + absorbed, 0, nextEffectiveMaxHealth),
|
||||||
shield: Math.max(0, member.shield - damage),
|
shield: Math.max(0, member.shield - damage),
|
||||||
hotTicks: Math.max(0, member.hotTicks - 1),
|
hotTicks: 0,
|
||||||
|
hotEffects: tickHotEffects(hotEffects),
|
||||||
|
bounceHeals: nextBounceHeals,
|
||||||
|
damageReductionTicks: Math.max(0, (member.damageReductionTicks ?? 0) - 1),
|
||||||
debuff: nextDebuffTicks > 0
|
debuff: nextDebuffTicks > 0
|
||||||
? (appliesDebuff && member.id === primaryTarget.id ? 'Searing Mark' : member.debuff)
|
? (appliesDebuff && member.id === primaryTarget.id ? 'Searing Mark' : member.debuff)
|
||||||
: undefined,
|
: undefined,
|
||||||
@@ -934,6 +1067,17 @@ export function CombatScreen({
|
|||||||
healingReductionTicks: nextHealingReductionTicks,
|
healingReductionTicks: nextHealingReductionTicks,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
const nextParty = damagedParty.map((member) => {
|
||||||
|
const jumped = pendingJumpHeals.filter((jump) => jump.targetId === member.id)
|
||||||
|
if (jumped.length === 0) return member
|
||||||
|
return {
|
||||||
|
...member,
|
||||||
|
bounceHeals: [
|
||||||
|
...(member.bounceHeals ?? []),
|
||||||
|
...jumped.map((jump) => jump.heal),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
})
|
||||||
const healerAfterDamage = nextParty.find((member) => member.id === 'mira')
|
const healerAfterDamage = nextParty.find((member) => member.id === 'mira')
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -960,7 +1104,7 @@ export function CombatScreen({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextEnemyHealth = current.enemyHealth - encounter.partyDamage
|
const nextEnemyHealth = current.enemyHealth - partyDamageOutput(nextParty, encounter.partyDamage)
|
||||||
if (nextEnemyHealth > 0) {
|
if (nextEnemyHealth > 0) {
|
||||||
setCombat({
|
setCombat({
|
||||||
...current,
|
...current,
|
||||||
@@ -974,7 +1118,9 @@ export function CombatScreen({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!isRoguelike && encounter.lootTables.some((entry) => entry.difficultyId === difficulty.id)) {
|
if (!isRoguelike && encounter.lootTables.some((entry) => entry.difficultyId === difficulty.id)) {
|
||||||
requestLootRoll(encounter.id)
|
for (let rollIndex = 0; rollIndex < enemyCount; rollIndex += 1) {
|
||||||
|
requestLootRoll(encounter.id, rollIndex)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isRoguelike && (upgradesEveryEncounter || encounter.isBoss)) {
|
if (isRoguelike && (upgradesEveryEncounter || encounter.isBoss)) {
|
||||||
@@ -1031,6 +1177,9 @@ export function CombatScreen({
|
|||||||
poisonStacks: undefined,
|
poisonStacks: undefined,
|
||||||
maxHealthPenaltyTicks: undefined,
|
maxHealthPenaltyTicks: undefined,
|
||||||
healingReductionTicks: undefined,
|
healingReductionTicks: undefined,
|
||||||
|
hotEffects: [],
|
||||||
|
bounceHeals: [],
|
||||||
|
damageReductionTicks: undefined,
|
||||||
}))
|
}))
|
||||||
setEncounterIndex((value) => value + 1)
|
setEncounterIndex((value) => value + 1)
|
||||||
setCombat({
|
setCombat({
|
||||||
@@ -1039,15 +1188,14 @@ export function CombatScreen({
|
|||||||
resource: nextResource,
|
resource: nextResource,
|
||||||
cooldowns: nextCooldowns,
|
cooldowns: nextCooldowns,
|
||||||
elapsedTicks: 0,
|
elapsedTicks: 0,
|
||||||
enemyHealth: nextEncounter.maxHealth,
|
enemyHealth: nextEncounter.maxHealth * enemyCount,
|
||||||
})
|
})
|
||||||
addLog(`${encounter.enemyName} defeated. ${nextEncounter.enemyName} approaches.`, 'system')
|
addLog(`${encounter.enemyName} defeated. ${nextEncounter.enemyName} approaches.`, 'system')
|
||||||
}, TICK_MS)
|
|
||||||
return () => window.clearInterval(timer)
|
|
||||||
}, [
|
}, [
|
||||||
addLog,
|
addLog,
|
||||||
addFloatingHeal,
|
addFloatingHeal,
|
||||||
difficulty.damageMultiplier,
|
difficulty.damageMultiplier,
|
||||||
|
enemyCount,
|
||||||
encounter,
|
encounter,
|
||||||
encounterIndex,
|
encounterIndex,
|
||||||
encounters,
|
encounters,
|
||||||
@@ -1065,11 +1213,43 @@ export function CombatScreen({
|
|||||||
profile.character.name,
|
profile.character.name,
|
||||||
setCombat,
|
setCombat,
|
||||||
startPart,
|
startPart,
|
||||||
status,
|
|
||||||
currentPart,
|
currentPart,
|
||||||
paused,
|
|
||||||
])
|
])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
runCombatTickRef.current = runCombatTick
|
||||||
|
}, [runCombatTick])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (status === 'playing' && !paused) {
|
||||||
|
if (!combatClockActiveRef.current) {
|
||||||
|
lastCombatTickAtRef.current = performance.now()
|
||||||
|
combatClockActiveRef.current = true
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
combatClockActiveRef.current = false
|
||||||
|
}, [paused, status])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
if (
|
||||||
|
!combatClockActiveRef.current
|
||||||
|
|| statusRef.current !== 'playing'
|
||||||
|
|| pausedRef.current
|
||||||
|
) return
|
||||||
|
const now = performance.now()
|
||||||
|
const dueTicks = Math.min(4, Math.floor((now - lastCombatTickAtRef.current) / TICK_MS))
|
||||||
|
if (dueTicks <= 0) return
|
||||||
|
lastCombatTickAtRef.current += dueTicks * TICK_MS
|
||||||
|
for (let index = 0; index < dueTicks; index += 1) {
|
||||||
|
if (statusRef.current !== 'playing' || pausedRef.current) return
|
||||||
|
runCombatTickRef.current()
|
||||||
|
}
|
||||||
|
}, 50)
|
||||||
|
return () => window.clearInterval(timer)
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
!reward
|
!reward
|
||||||
@@ -1084,15 +1264,23 @@ export function CombatScreen({
|
|||||||
})
|
})
|
||||||
}, [expectedLootRolls, lootRolls.length, onProfileUpdated, reward])
|
}, [expectedLootRolls, lootRolls.length, onProfileUpdated, reward])
|
||||||
|
|
||||||
const enemyPercent = (enemyHealth / encounter.maxHealth) * 100
|
const enemyPercent = (enemyHealth / encounterMaxHealth) * 100
|
||||||
|
const enemyHealthSegments = Array.from({ length: enemyCount }, (_, index) => {
|
||||||
|
const remaining = clamp(enemyHealth - encounter.maxHealth * index, 0, encounter.maxHealth)
|
||||||
|
return {
|
||||||
|
index,
|
||||||
|
health: remaining,
|
||||||
|
percent: (remaining / encounter.maxHealth) * 100,
|
||||||
|
}
|
||||||
|
}).reverse()
|
||||||
const dualScreenState = useMemo<DualScreenCombatState>(() => ({
|
const dualScreenState = useMemo<DualScreenCombatState>(() => ({
|
||||||
difficultyName: difficulty.name,
|
difficultyName: difficulty.name,
|
||||||
dungeonName: dungeon.name,
|
dungeonName: hardMode ? `${dungeon.name} Hard` : dungeon.name,
|
||||||
contentName,
|
contentName,
|
||||||
encounterName: encounter.enemyName,
|
encounterName: encounter.enemyName,
|
||||||
encounterDescription: encounter.description,
|
encounterDescription: encounter.description,
|
||||||
encounterHealth: enemyHealth,
|
encounterHealth: enemyHealth,
|
||||||
encounterMaxHealth: encounter.maxHealth,
|
encounterMaxHealth,
|
||||||
encounterIsBoss: encounter.isBoss,
|
encounterIsBoss: encounter.isBoss,
|
||||||
encounterIndex,
|
encounterIndex,
|
||||||
encounterCount: encounters.length,
|
encounterCount: encounters.length,
|
||||||
@@ -1134,7 +1322,8 @@ export function CombatScreen({
|
|||||||
encounter.description,
|
encounter.description,
|
||||||
encounter.enemyName,
|
encounter.enemyName,
|
||||||
encounter.isBoss,
|
encounter.isBoss,
|
||||||
encounter.maxHealth,
|
encounterMaxHealth,
|
||||||
|
hardMode,
|
||||||
enemyHealth,
|
enemyHealth,
|
||||||
encounterIndex,
|
encounterIndex,
|
||||||
encounters.length,
|
encounters.length,
|
||||||
@@ -1163,7 +1352,7 @@ export function CombatScreen({
|
|||||||
>
|
>
|
||||||
{!dualScreenEnabled && <header className="topbar">
|
{!dualScreenEnabled && <header className="topbar">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">{difficulty.name} - Item Level {difficulty.droppedItemLevel}</p>
|
<p className="eyebrow">{difficulty.name}{hardMode ? ' Hard' : ''} - Item Level {difficulty.droppedItemLevel}</p>
|
||||||
<h1>{dungeon.name}</h1>
|
<h1>{dungeon.name}</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="combat-header-actions">
|
<div className="combat-header-actions">
|
||||||
@@ -1186,10 +1375,21 @@ export function CombatScreen({
|
|||||||
</div>
|
</div>
|
||||||
<div className="enemy-info">
|
<div className="enemy-info">
|
||||||
<div className="bar-label">
|
<div className="bar-label">
|
||||||
<strong>{encounter.enemyName}</strong>
|
<strong>{hardMode ? `${encounter.enemyName} x2` : encounter.enemyName}</strong>
|
||||||
<span>{Math.ceil(enemyHealth)} / {encounter.maxHealth}</span>
|
<span>{Math.ceil(enemyHealth)} / {encounterMaxHealth}</span>
|
||||||
</div>
|
</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>
|
<div className="bar enemy-health"><span style={{ width: `${enemyPercent}%` }} /></div>
|
||||||
|
)}
|
||||||
<p>{encounter.description}</p>
|
<p>{encounter.description}</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -1236,7 +1436,14 @@ export function CombatScreen({
|
|||||||
.map((entry) => <span className="floating-heal" key={entry.id}>+{entry.value}</span>)}
|
.map((entry) => <span className="floating-heal" key={entry.id}>+{entry.value}</span>)}
|
||||||
</div>
|
</div>
|
||||||
<div className="member-effects">
|
<div className="member-effects">
|
||||||
{member.hotTicks > 0 && <span className="buff">Renew {formatEffectTime(member.hotTicks)}</span>}
|
{memberHotEffects(member).map((effect) => (
|
||||||
|
<span className="buff" key={effect.id}>{effect.label} {formatEffectTime(effect.ticks)}</span>
|
||||||
|
))}
|
||||||
|
{member.shield > 0 && <span className="buff">Shield {Math.ceil(member.shield)}</span>}
|
||||||
|
{(member.damageReductionTicks ?? 0) > 0 && <span className="buff">Barkskin {formatEffectTime(member.damageReductionTicks ?? 0)}</span>}
|
||||||
|
{(member.bounceHeals ?? []).map((effect) => (
|
||||||
|
<span className="buff" key={effect.id}>{effect.label} {effect.charges}</span>
|
||||||
|
))}
|
||||||
{member.debuff && member.debuffTicks && <span className="debuff">{member.debuff} {formatEffectTime(member.debuffTicks)}</span>}
|
{member.debuff && member.debuffTicks && <span className="debuff">{member.debuff} {formatEffectTime(member.debuffTicks)}</span>}
|
||||||
{member.poisonStacks && member.poisonStacks > 0 && <span className="debuff">Poison {member.poisonStacks}</span>}
|
{member.poisonStacks && member.poisonStacks > 0 && <span className="debuff">Poison {member.poisonStacks}</span>}
|
||||||
{member.maxHealthPenaltyTicks && member.maxHealthPenaltyTicks > 0 && <span className="debuff">Max HP -25% {formatEffectTime(member.maxHealthPenaltyTicks)}</span>}
|
{member.maxHealthPenaltyTicks && member.maxHealthPenaltyTicks > 0 && <span className="debuff">Max HP -25% {formatEffectTime(member.maxHealthPenaltyTicks)}</span>}
|
||||||
@@ -1315,7 +1522,7 @@ export function CombatScreen({
|
|||||||
|
|
||||||
{status === 'upgrade-choice' && (
|
{status === 'upgrade-choice' && (
|
||||||
<div className="result-screen">
|
<div className="result-screen">
|
||||||
<div>
|
<div className="pvp-upgrade-dialog pve-upgrade-dialog">
|
||||||
<p className="eyebrow">
|
<p className="eyebrow">
|
||||||
{encounter.isBoss
|
{encounter.isBoss
|
||||||
? `Roguelike Stage ${roguelikeStage} Complete`
|
? `Roguelike Stage ${roguelikeStage} Complete`
|
||||||
@@ -1323,6 +1530,9 @@ export function CombatScreen({
|
|||||||
</p>
|
</p>
|
||||||
<h2>Choose Upgrade</h2>
|
<h2>Choose Upgrade</h2>
|
||||||
<p>Pick one upgrade before the next fight.</p>
|
<p>Pick one upgrade before the next fight.</p>
|
||||||
|
<div className="pvp-choice-columns">
|
||||||
|
<div>
|
||||||
|
<strong>Run Buff</strong>
|
||||||
<div className="upgrade-choice-grid">
|
<div className="upgrade-choice-grid">
|
||||||
{upgradeChoices.map((upgrade) => (
|
{upgradeChoices.map((upgrade) => (
|
||||||
<button key={upgrade.id} onClick={() => chooseRoguelikeUpgrade(upgrade)} type="button">
|
<button key={upgrade.id} onClick={() => chooseRoguelikeUpgrade(upgrade)} type="button">
|
||||||
@@ -1331,6 +1541,8 @@ export function CombatScreen({
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{roguelikeUpgrades.length > 0 && (
|
{roguelikeUpgrades.length > 0 && (
|
||||||
<p className="roguelike-upgrade-list">
|
<p className="roguelike-upgrade-list">
|
||||||
Active: {summarizeUpgradeStacks(roguelikeUpgrades, roguelikeUpgradeCatalog)}
|
Active: {summarizeUpgradeStacks(roguelikeUpgrades, roguelikeUpgradeCatalog)}
|
||||||
@@ -1461,7 +1673,8 @@ export function CombatScreen({
|
|||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">{sectionName} Complete</p>
|
<p className="eyebrow">{sectionName} Complete</p>
|
||||||
<h2>{encounter.enemyName} Defeated</h2>
|
<h2>{encounter.enemyName} Defeated</h2>
|
||||||
<p>Proceed to {sectionName} {currentPart + 1} or end the run?</p>
|
<p>{canContinueAfterPart ? `Proceed to ${sectionName} ${currentPart + 1} or end the run?` : 'Hard mode for this section is complete.'}</p>
|
||||||
|
{canContinueAfterPart && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const nextIndex = encounterIndex + 1
|
const nextIndex = encounterIndex + 1
|
||||||
@@ -1473,12 +1686,18 @@ export function CombatScreen({
|
|||||||
health: clamp(member.health + 35, 0, member.maxHealth),
|
health: clamp(member.health + 35, 0, member.maxHealth),
|
||||||
debuff: undefined,
|
debuff: undefined,
|
||||||
debuffTicks: undefined,
|
debuffTicks: undefined,
|
||||||
|
poisonStacks: undefined,
|
||||||
|
maxHealthPenaltyTicks: undefined,
|
||||||
|
healingReductionTicks: undefined,
|
||||||
|
hotEffects: [],
|
||||||
|
bounceHeals: [],
|
||||||
|
damageReductionTicks: undefined,
|
||||||
}))
|
}))
|
||||||
setEncounterIndex(nextIndex)
|
setEncounterIndex(nextIndex)
|
||||||
setCombat({
|
setCombat({
|
||||||
...current,
|
...current,
|
||||||
party: recoveredParty,
|
party: recoveredParty,
|
||||||
enemyHealth: nextEncounter.maxHealth,
|
enemyHealth: nextEncounter.maxHealth * enemyCount,
|
||||||
elapsedTicks: 0,
|
elapsedTicks: 0,
|
||||||
})
|
})
|
||||||
setStatus('playing')
|
setStatus('playing')
|
||||||
@@ -1488,6 +1707,7 @@ export function CombatScreen({
|
|||||||
>
|
>
|
||||||
Continue to {sectionName} {currentPart + 1}
|
Continue to {sectionName} {currentPart + 1}
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
<button className="secondary-result-button" onClick={() => finishRun(currentPart, startPart)} type="button">
|
<button className="secondary-result-button" onClick={() => finishRun(currentPart, startPart)} type="button">
|
||||||
End Run
|
End Run
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
type CharacterProfile,
|
type CharacterProfile,
|
||||||
type GameClass,
|
type GameClass,
|
||||||
} from '../profile'
|
} from '../profile'
|
||||||
|
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../dualScreen'
|
||||||
import { EquipmentScreen } from './EquipmentScreen'
|
import { EquipmentScreen } from './EquipmentScreen'
|
||||||
import { TalentScreen } from './TalentScreen'
|
import { TalentScreen } from './TalentScreen'
|
||||||
|
|
||||||
@@ -14,7 +15,8 @@ type Props = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
|
export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
|
||||||
const [activeTab, setActiveTab] = useState<'equipment' | 'talents' | 'class'>('class')
|
const [activeTab, setActiveTab] = useState<'equipment' | 'crafting' | 'talents' | 'class'>('class')
|
||||||
|
const { enabled: dualScreenEnabled } = useDualScreen()
|
||||||
const [classId, setClassId] = useState(profile.character.classId)
|
const [classId, setClassId] = useState(profile.character.classId)
|
||||||
const [slots, setSlots] = useState<Array<number | null>>(profile.abilitySlots)
|
const [slots, setSlots] = useState<Array<number | null>>(profile.abilitySlots)
|
||||||
const [selectedSlot, setSelectedSlot] = useState(0)
|
const [selectedSlot, setSelectedSlot] = useState(0)
|
||||||
@@ -38,7 +40,7 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
|
|||||||
function chooseClass(nextClass: GameClass) {
|
function chooseClass(nextClass: GameClass) {
|
||||||
const starterAbilities = nextClass.spells
|
const starterAbilities = nextClass.spells
|
||||||
.filter((ability) => ability.unlockLevel <= profile.character.level)
|
.filter((ability) => ability.unlockLevel <= profile.character.level)
|
||||||
.slice(0, 5)
|
.slice(0, 6)
|
||||||
.map((ability) => ability.id)
|
.map((ability) => ability.id)
|
||||||
setClassId(nextClass.id)
|
setClassId(nextClass.id)
|
||||||
setSlots([...starterAbilities, ...Array(6 - starterAbilities.length).fill(null)])
|
setSlots([...starterAbilities, ...Array(6 - starterAbilities.length).fill(null)])
|
||||||
@@ -63,6 +65,29 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const classWorkshopState = useMemo<DualScreenWorkshopState | null>(() => {
|
||||||
|
if (activeTab !== 'class') return null
|
||||||
|
return {
|
||||||
|
mode: 'class',
|
||||||
|
title: 'Ability Library',
|
||||||
|
subtitle: gameClass.name,
|
||||||
|
summary: `Selected slot ${selectedSlot + 1}. ${message || 'Choose an ability for the active loadout.'}`,
|
||||||
|
items: gameClass.spells.map((ability) => {
|
||||||
|
const locked = ability.unlockLevel > profile.character.level
|
||||||
|
const equipped = slots.includes(ability.id)
|
||||||
|
return {
|
||||||
|
glyph: locked ? 'L' : ability.glyph,
|
||||||
|
title: ability.name,
|
||||||
|
meta: locked ? `Level ${ability.unlockLevel}` : `${ability.cost} ${gameClass.resourceName}`,
|
||||||
|
detail: ability.description,
|
||||||
|
status: equipped ? 'Equipped' : locked ? 'Locked' : '',
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}, [activeTab, gameClass, message, profile.character.level, selectedSlot, slots])
|
||||||
|
|
||||||
|
useDualScreenWorkshopPublisher(classWorkshopState, dualScreenEnabled)
|
||||||
|
|
||||||
async function persistChanges() {
|
async function persistChanges() {
|
||||||
saveScroll()
|
saveScroll()
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
@@ -80,7 +105,7 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="content-screen customize-screen">
|
<section className="content-screen customize-screen">
|
||||||
<div className="screen-heading">
|
<div className="screen-heading customize-heading">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Character Workshop</p>
|
<p className="eyebrow">Character Workshop</p>
|
||||||
<h1>Customize Character</h1>
|
<h1>Customize Character</h1>
|
||||||
@@ -89,8 +114,10 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="customize-tabs" role="tablist" aria-label="Customize character sections">
|
<div className="customize-tabs" role="tablist" aria-label="Customize character sections">
|
||||||
|
<button className="back-button customize-tab-back" onClick={onBack} type="button">Back</button>
|
||||||
{([
|
{([
|
||||||
{ key: 'equipment', label: 'Equipment' },
|
{ key: 'equipment', label: 'Equipment' },
|
||||||
|
{ key: 'crafting', label: 'Crafting' },
|
||||||
{ key: 'talents', label: 'Talents' },
|
{ key: 'talents', label: 'Talents' },
|
||||||
{ key: 'class', label: 'Class' },
|
{ key: 'class', label: 'Class' },
|
||||||
] as const).map((tab) => (
|
] as const).map((tab) => (
|
||||||
@@ -110,6 +137,18 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
|
|||||||
{activeTab === 'equipment' && (
|
{activeTab === 'equipment' && (
|
||||||
<EquipmentScreen
|
<EquipmentScreen
|
||||||
embedded
|
embedded
|
||||||
|
mode="equipment"
|
||||||
|
showModeTabs={false}
|
||||||
|
profile={profile}
|
||||||
|
onUpdated={onSaved}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'crafting' && (
|
||||||
|
<EquipmentScreen
|
||||||
|
embedded
|
||||||
|
mode="crafting"
|
||||||
|
showModeTabs={false}
|
||||||
profile={profile}
|
profile={profile}
|
||||||
onUpdated={onSaved}
|
onUpdated={onSaved}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
type EquipmentSlot,
|
type EquipmentSlot,
|
||||||
type Item,
|
type Item,
|
||||||
} from '../profile'
|
} from '../profile'
|
||||||
|
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../dualScreen'
|
||||||
|
|
||||||
const SLOT_LABELS: Record<EquipmentSlot, string> = {
|
const SLOT_LABELS: Record<EquipmentSlot, string> = {
|
||||||
weapon: 'Weapon',
|
weapon: 'Weapon',
|
||||||
@@ -24,16 +25,29 @@ const SLOT_LABELS: Record<EquipmentSlot, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const EQUIPMENT_LIST_PAGE_SIZE = 3
|
const EQUIPMENT_LIST_PAGE_SIZE = 3
|
||||||
const CRAFTING_LIST_PAGE_SIZE = 6
|
const CRAFTING_LIST_PAGE_SIZE = 3
|
||||||
|
const CRAFTING_FILTER_SLOTS = (Object.keys(SLOT_LABELS) as EquipmentSlot[])
|
||||||
|
.filter((slot) => slot !== 'component')
|
||||||
|
const DIRECT_CRAFT_ITEM_LEVELS = new Set([1, 10, 20, 25])
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
profile: CharacterProfile
|
profile: CharacterProfile
|
||||||
onBack?: () => void
|
onBack?: () => void
|
||||||
onUpdated: (profile: CharacterProfile) => void
|
onUpdated: (profile: CharacterProfile) => void
|
||||||
embedded?: boolean
|
embedded?: boolean
|
||||||
|
mode?: 'equipment' | 'crafting'
|
||||||
|
showModeTabs?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }: Props) {
|
export function EquipmentScreen({
|
||||||
|
profile,
|
||||||
|
onBack,
|
||||||
|
onUpdated,
|
||||||
|
embedded = false,
|
||||||
|
mode,
|
||||||
|
showModeTabs = true,
|
||||||
|
}: Props) {
|
||||||
|
const { enabled: dualScreenEnabled } = useDualScreen()
|
||||||
const totalItemCount = profile.inventory.reduce(
|
const totalItemCount = profile.inventory.reduce(
|
||||||
(total, item) => total + item.quantity,
|
(total, item) => total + item.quantity,
|
||||||
0,
|
0,
|
||||||
@@ -49,24 +63,23 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
|||||||
const [crafting, setCrafting] = useState(false)
|
const [crafting, setCrafting] = useState(false)
|
||||||
const [upgrading, setUpgrading] = useState(false)
|
const [upgrading, setUpgrading] = useState(false)
|
||||||
const [showSetBonuses, setShowSetBonuses] = useState(false)
|
const [showSetBonuses, setShowSetBonuses] = useState(false)
|
||||||
const [equipmentTab, setEquipmentTab] = useState<'equipment' | 'crafting'>('equipment')
|
const [equipmentTab, setEquipmentTab] = useState<'equipment' | 'crafting'>(mode ?? 'equipment')
|
||||||
const [inventoryPage, setInventoryPage] = useState(0)
|
const [inventoryPage, setInventoryPage] = useState(0)
|
||||||
const [recipePage, setRecipePage] = useState(0)
|
const [recipePage, setRecipePage] = useState(0)
|
||||||
const [message, setMessage] = useState('')
|
const [message, setMessage] = useState('')
|
||||||
const scrollRef = useRef<number>(0)
|
const scrollRef = useRef<number>(0)
|
||||||
const selectedItem = profile.inventory.find((item) => item.id === selectedItemId)
|
const selectedItem = profile.inventory.find((item) => item.id === selectedItemId)
|
||||||
const firstRecipe = profile.craftingRecipes.find((recipe) => recipe.canCraft)
|
const craftableRecipes = profile.craftingRecipes.filter((recipe) =>
|
||||||
?? profile.craftingRecipes[0]
|
DIRECT_CRAFT_ITEM_LEVELS.has(recipe.item.itemLevel),
|
||||||
|
)
|
||||||
|
const firstRecipe = craftableRecipes.find((recipe) => recipe.canCraft)
|
||||||
|
?? craftableRecipes[0]
|
||||||
const [selectedRecipeId, setSelectedRecipeId] = useState<number | null>(
|
const [selectedRecipeId, setSelectedRecipeId] = useState<number | null>(
|
||||||
firstRecipe?.id ?? null,
|
firstRecipe?.id ?? null,
|
||||||
)
|
)
|
||||||
const selectedRecipe = profile.craftingRecipes.find((recipe) => recipe.id === selectedRecipeId)
|
const selectedRecipe = profile.craftingRecipes.find((recipe) => recipe.id === selectedRecipeId)
|
||||||
const selectedRecipeRequiresUpgrade = selectedRecipe
|
const selectedRecipeRequiresUpgrade = selectedRecipe
|
||||||
? profile.craftingRecipes.some((recipe) =>
|
? !DIRECT_CRAFT_ITEM_LEVELS.has(selectedRecipe.item.itemLevel)
|
||||||
recipe.sourceEncounterId === selectedRecipe.sourceEncounterId
|
|
||||||
&& recipe.item.slot === selectedRecipe.item.slot
|
|
||||||
&& recipe.item.itemLevel < selectedRecipe.item.itemLevel,
|
|
||||||
)
|
|
||||||
: false
|
: false
|
||||||
const selectedItemRecipe = selectedItem
|
const selectedItemRecipe = selectedItem
|
||||||
? profile.craftingRecipes.find((recipe) => recipe.item.id === selectedItem.id)
|
? profile.craftingRecipes.find((recipe) => recipe.item.id === selectedItem.id)
|
||||||
@@ -113,12 +126,14 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
|||||||
const [slotFilter, setSlotFilter] = useState<EquipmentSlot | 'all'>('all')
|
const [slotFilter, setSlotFilter] = useState<EquipmentSlot | 'all'>('all')
|
||||||
const [levelFilter, setLevelFilter] = useState<number | null>(null)
|
const [levelFilter, setLevelFilter] = useState<number | null>(null)
|
||||||
const availableLevels = useMemo(
|
const availableLevels = useMemo(
|
||||||
() => [...new Set(profile.craftingRecipes.map((r) => r.item.itemLevel))].sort((a, b) => b - a),
|
() => [...new Set(profile.craftingRecipes
|
||||||
|
.filter((r) => DIRECT_CRAFT_ITEM_LEVELS.has(r.item.itemLevel))
|
||||||
|
.map((r) => r.item.itemLevel))].sort((a, b) => b - a),
|
||||||
[profile.craftingRecipes],
|
[profile.craftingRecipes],
|
||||||
)
|
)
|
||||||
const filteredRecipes = useMemo(
|
const filteredRecipes = useMemo(
|
||||||
() => {
|
() => {
|
||||||
let result = [...profile.craftingRecipes]
|
let result = profile.craftingRecipes.filter((r) => DIRECT_CRAFT_ITEM_LEVELS.has(r.item.itemLevel))
|
||||||
if (slotFilter !== 'all') result = result.filter((r) => r.item.slot === slotFilter)
|
if (slotFilter !== 'all') result = result.filter((r) => r.item.slot === slotFilter)
|
||||||
if (levelFilter !== null) result = result.filter((r) => r.item.itemLevel === levelFilter)
|
if (levelFilter !== null) result = result.filter((r) => r.item.itemLevel === levelFilter)
|
||||||
result.sort((a, b) => b.item.itemLevel - a.item.itemLevel)
|
result.sort((a, b) => b.item.itemLevel - a.item.itemLevel)
|
||||||
@@ -131,7 +146,10 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
|||||||
() => new Map(
|
() => new Map(
|
||||||
(Object.keys(SLOT_LABELS) as EquipmentSlot[]).map((slot) => [
|
(Object.keys(SLOT_LABELS) as EquipmentSlot[]).map((slot) => [
|
||||||
slot,
|
slot,
|
||||||
profile.craftingRecipes.filter((recipe) => recipe.item.slot === slot).length,
|
profile.craftingRecipes.filter((recipe) =>
|
||||||
|
recipe.item.slot === slot
|
||||||
|
&& DIRECT_CRAFT_ITEM_LEVELS.has(recipe.item.itemLevel),
|
||||||
|
).length,
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
[profile.craftingRecipes],
|
[profile.craftingRecipes],
|
||||||
@@ -173,6 +191,10 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
|||||||
}
|
}
|
||||||
}, [equipmentTab])
|
}, [equipmentTab])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode) setEquipmentTab(mode)
|
||||||
|
}, [mode])
|
||||||
|
|
||||||
function saveScroll() {
|
function saveScroll() {
|
||||||
scrollRef.current = window.scrollY
|
scrollRef.current = window.scrollY
|
||||||
}
|
}
|
||||||
@@ -247,73 +269,15 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const content = (
|
function renderEquipmentActions() {
|
||||||
|
if (!selectedItem) {
|
||||||
|
return <p>Select an item to inspect it.</p>
|
||||||
|
}
|
||||||
|
if (selectedItem.slot === 'component') {
|
||||||
|
return <p className="component-note">Used in crafting.</p>
|
||||||
|
}
|
||||||
|
return (
|
||||||
<>
|
<>
|
||||||
{!embedded && (
|
|
||||||
<div className="screen-heading">
|
|
||||||
<div>
|
|
||||||
<p className="eyebrow">Character Loadout</p>
|
|
||||||
<h1>Equipment</h1>
|
|
||||||
</div>
|
|
||||||
<button className="back-button" onClick={onBack} type="button">Back</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="gear-summary">
|
|
||||||
<div className="gear-character">
|
|
||||||
<span style={{ borderColor: profile.character.themeColor, color: profile.character.themeColor }}>
|
|
||||||
{profile.character.className[0]}
|
|
||||||
</span>
|
|
||||||
<div>
|
|
||||||
<p className="eyebrow">{profile.character.className}</p>
|
|
||||||
<h2>{profile.character.name}</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<GearStat value={profile.gearStats.averageItemLevel.toFixed(1)} label="Average Item Level" />
|
|
||||||
<GearStat value={`+${profile.gearStats.healingPower}`} label="Healing Power" />
|
|
||||||
<GearStat value={`+${profile.gearStats.maxResourceBonus}`} label={`Max ${profile.character.resourceName}`} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav className="equipment-tabs">
|
|
||||||
<button
|
|
||||||
className={`equipment-tab ${equipmentTab === 'equipment' ? 'active' : ''}`}
|
|
||||||
onClick={() => setEquipmentTab('equipment')}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Equipment
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`equipment-tab ${equipmentTab === 'crafting' ? 'active' : ''}`}
|
|
||||||
onClick={() => setEquipmentTab('crafting')}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Crafting
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{equipmentTab === 'equipment' ? (
|
|
||||||
<>
|
|
||||||
<section className="item-comparison">
|
|
||||||
{selectedItem ? (
|
|
||||||
selectedItem.slot === 'component' ? (
|
|
||||||
<>
|
|
||||||
<ItemDetail title="Crafting Component" item={selectedItem} />
|
|
||||||
<div className="equip-action">
|
|
||||||
<p className="component-note">Used in crafting.</p>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<ItemDetail title={selectedItem.equipped ? 'Selected Equipment' : 'Inventory Item'} item={selectedItem} />
|
|
||||||
<div className="comparison-arrow">vs</div>
|
|
||||||
{comparisonItem && comparisonItem.id !== selectedItem.id ? (
|
|
||||||
<ItemDetail title="Currently Equipped" item={comparisonItem} />
|
|
||||||
) : (
|
|
||||||
<div className="item-detail empty-comparison">
|
|
||||||
<p className="eyebrow">Comparison</p>
|
|
||||||
<h2>{selectedItem.equipped ? 'Already Equipped' : 'Empty Slot'}</h2>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="equip-action">
|
|
||||||
<ComparisonDelta selected={selectedItem} equipped={comparisonItem} />
|
<ComparisonDelta selected={selectedItem} equipped={comparisonItem} />
|
||||||
<button
|
<button
|
||||||
className="primary-button"
|
className="primary-button"
|
||||||
@@ -347,7 +311,166 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
|||||||
: 'Break Down'}
|
: 'Break Down'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const workshopState = useMemo<DualScreenWorkshopState>(() => {
|
||||||
|
if (equipmentTab === 'crafting') {
|
||||||
|
if (!selectedRecipe) {
|
||||||
|
return {
|
||||||
|
mode: 'crafting',
|
||||||
|
title: 'Craft Output',
|
||||||
|
subtitle: 'No recipe selected',
|
||||||
|
items: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
mode: 'crafting',
|
||||||
|
title: selectedRecipe.item.name,
|
||||||
|
subtitle: `${SLOT_LABELS[selectedRecipe.item.slot]} - Item Level ${selectedRecipe.item.itemLevel}`,
|
||||||
|
summary: selectedRecipe.item.description,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
glyph: selectedRecipe.item.glyph,
|
||||||
|
title: 'Craft Output',
|
||||||
|
meta: `+${selectedRecipe.item.healingPower} Healing Power / +${selectedRecipe.item.maxResourceBonus} Max Resource`,
|
||||||
|
status: selectedRecipe.canCraft ? 'Ready' : 'Missing components',
|
||||||
|
},
|
||||||
|
...selectedRecipe.components.map((component) => ({
|
||||||
|
glyph: component.item.glyph,
|
||||||
|
title: component.item.name,
|
||||||
|
meta: `Item Level ${component.item.itemLevel}`,
|
||||||
|
status: `${component.owned}/${component.quantity}`,
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!selectedItem) {
|
||||||
|
return {
|
||||||
|
mode: 'equipment',
|
||||||
|
title: 'Equipment Detail',
|
||||||
|
subtitle: 'No item selected',
|
||||||
|
items: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
mode: 'equipment',
|
||||||
|
title: selectedItem.slot === 'component' ? 'Crafting Component' : selectedItem.name,
|
||||||
|
subtitle: `${SLOT_LABELS[selectedItem.slot]} - Item Level ${selectedItem.itemLevel}`,
|
||||||
|
summary: selectedItem.description,
|
||||||
|
items: selectedItem.slot === 'component'
|
||||||
|
? [{
|
||||||
|
glyph: selectedItem.glyph,
|
||||||
|
title: selectedItem.name,
|
||||||
|
meta: `Owned: ${selectedItem.quantity}`,
|
||||||
|
status: 'Component',
|
||||||
|
}]
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
glyph: selectedItem.glyph,
|
||||||
|
title: selectedItem.name,
|
||||||
|
meta: `+${selectedItem.healingPower} Healing Power / +${selectedItem.maxResourceBonus} Max Resource`,
|
||||||
|
status: selectedItem.equipped ? 'Equipped' : 'Inventory',
|
||||||
|
},
|
||||||
|
...(comparisonItem && comparisonItem.id !== selectedItem.id
|
||||||
|
? [{
|
||||||
|
glyph: comparisonItem.glyph,
|
||||||
|
title: comparisonItem.name,
|
||||||
|
meta: `+${comparisonItem.healingPower} Healing Power / +${comparisonItem.maxResourceBonus} Max Resource`,
|
||||||
|
status: 'Currently Equipped',
|
||||||
|
}]
|
||||||
|
: [{
|
||||||
|
title: selectedItem.equipped ? 'Already Equipped' : 'Empty Slot',
|
||||||
|
status: 'Comparison',
|
||||||
|
}]),
|
||||||
|
...(upgradeRecipe
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
glyph: upgradeRecipe.item.glyph,
|
||||||
|
title: `Upgrade to ${upgradeRecipe.item.name}`,
|
||||||
|
meta: `Item Level ${upgradeRecipe.item.itemLevel}`,
|
||||||
|
status: upgradeRecipe.canCraft ? 'Ready' : 'Missing materials',
|
||||||
|
},
|
||||||
|
...upgradeRecipe.components.map((component) => ({
|
||||||
|
glyph: component.item.glyph,
|
||||||
|
title: component.item.name,
|
||||||
|
meta: `Required for upgrade`,
|
||||||
|
status: `${component.owned}/${component.quantity}`,
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}, [comparisonItem, equipmentTab, selectedItem, selectedRecipe, upgradeRecipe])
|
||||||
|
|
||||||
|
useDualScreenWorkshopPublisher(workshopState, dualScreenEnabled)
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
{!embedded && (
|
||||||
|
<div className="screen-heading">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Character Loadout</p>
|
||||||
|
<h1>Equipment</h1>
|
||||||
</div>
|
</div>
|
||||||
|
<button className="back-button" onClick={onBack} type="button">Back</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="gear-summary">
|
||||||
|
<div className="gear-character">
|
||||||
|
<span style={{ borderColor: profile.character.themeColor, color: profile.character.themeColor }}>
|
||||||
|
{profile.character.className[0]}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">{profile.character.className}</p>
|
||||||
|
<h2>{profile.character.name}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<GearStat value={profile.gearStats.averageItemLevel.toFixed(1)} label="Average Item Level" />
|
||||||
|
<GearStat value={`+${profile.gearStats.healingPower}`} label="Healing Power" />
|
||||||
|
<GearStat value={`+${profile.gearStats.maxResourceBonus}`} label={`Max ${profile.character.resourceName}`} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showModeTabs && (
|
||||||
|
<nav className="equipment-tabs">
|
||||||
|
<button
|
||||||
|
className={`equipment-tab ${equipmentTab === 'equipment' ? 'active' : ''}`}
|
||||||
|
onClick={() => setEquipmentTab('equipment')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Equipment
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`equipment-tab ${equipmentTab === 'crafting' ? 'active' : ''}`}
|
||||||
|
onClick={() => setEquipmentTab('crafting')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Crafting
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{equipmentTab === 'equipment' ? (
|
||||||
|
<>
|
||||||
|
<section className="item-comparison">
|
||||||
|
{selectedItem ? (
|
||||||
|
selectedItem.slot === 'component' ? (
|
||||||
|
<>
|
||||||
|
<ItemDetail title="Crafting Component" item={selectedItem} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ItemDetail title={selectedItem.equipped ? 'Selected Equipment' : 'Inventory Item'} item={selectedItem} />
|
||||||
|
<div className="comparison-arrow">vs</div>
|
||||||
|
{comparisonItem && comparisonItem.id !== selectedItem.id ? (
|
||||||
|
<ItemDetail title="Currently Equipped" item={comparisonItem} />
|
||||||
|
) : (
|
||||||
|
<div className="item-detail empty-comparison">
|
||||||
|
<p className="eyebrow">Comparison</p>
|
||||||
|
<h2>{selectedItem.equipped ? 'Already Equipped' : 'Empty Slot'}</h2>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
@@ -355,6 +478,10 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="equipment-action-strip">
|
||||||
|
{renderEquipmentActions()}
|
||||||
|
</section>
|
||||||
|
|
||||||
<div className="equipment-layout">
|
<div className="equipment-layout">
|
||||||
<section className="equipped-panel">
|
<section className="equipped-panel">
|
||||||
<EquipmentHeading eyebrow="Currently Worn" title="Equipment Slots" />
|
<EquipmentHeading eyebrow="Currently Worn" title="Equipment Slots" />
|
||||||
@@ -467,9 +594,9 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
|||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<strong>All</strong>
|
<strong>All</strong>
|
||||||
<span>{profile.craftingRecipes.length}</span>
|
<span>{profile.craftingRecipes.filter((recipe) => DIRECT_CRAFT_ITEM_LEVELS.has(recipe.item.itemLevel)).length}</span>
|
||||||
</button>
|
</button>
|
||||||
{(Object.entries(SLOT_LABELS) as [EquipmentSlot, string][]).map(([slot, label]) => (
|
{CRAFTING_FILTER_SLOTS.map((slot) => (
|
||||||
<button
|
<button
|
||||||
className={slotFilter === slot ? 'active' : ''}
|
className={slotFilter === slot ? 'active' : ''}
|
||||||
disabled={(slotRecipeCounts.get(slot) ?? 0) === 0}
|
disabled={(slotRecipeCounts.get(slot) ?? 0) === 0}
|
||||||
@@ -480,7 +607,7 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
|||||||
}}
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<strong>{label}</strong>
|
<strong>{SLOT_LABELS[slot]}</strong>
|
||||||
<span>{slotRecipeCounts.get(slot) ?? 0}</span>
|
<span>{slotRecipeCounts.get(slot) ?? 0}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -557,6 +684,16 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
|||||||
previousDisabled={recipePage <= 0}
|
previousDisabled={recipePage <= 0}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<div className="crafting-action-row">
|
||||||
|
<button
|
||||||
|
className="primary-button"
|
||||||
|
disabled={selectedRecipeRequiresUpgrade || !selectedRecipe?.canCraft || crafting}
|
||||||
|
onClick={craftSelected}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{crafting ? 'Crafting...' : selectedRecipeRequiresUpgrade ? 'Upgrade Existing Item' : 'Craft Item'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="crafting-detail-panel">
|
<section className="crafting-detail-panel">
|
||||||
@@ -579,14 +716,6 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
className="primary-button"
|
|
||||||
disabled={selectedRecipeRequiresUpgrade || !selectedRecipe.canCraft || crafting}
|
|
||||||
onClick={craftSelected}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{crafting ? 'Crafting...' : selectedRecipeRequiresUpgrade ? 'Upgrade Existing Item' : 'Craft Item'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="inventory-empty">Select a recipe.</p>
|
<p className="inventory-empty">Select a recipe.</p>
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
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 { completeRoguelike, type DungeonReward } from '../profile'
|
||||||
import type { Ability, CharacterProfile, DungeonEncounter } from '../profile'
|
import type { Ability, CharacterProfile, DungeonEncounter } from '../profile'
|
||||||
import type { GameMode } from '../gameRepository'
|
import type { GameMode } from '../gameRepository'
|
||||||
@@ -34,7 +44,7 @@ type PvpEncounter = DungeonEncounter & {
|
|||||||
sourceEncounterId?: number
|
sourceEncounterId?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
type SlotKey = '1' | '2' | '3' | '4' | '5'
|
type SlotKey = '1' | '2' | '3' | '4' | '5' | '6'
|
||||||
type AbilityLabelMode = 'ability' | 'slot'
|
type AbilityLabelMode = 'ability' | 'slot'
|
||||||
|
|
||||||
type SelfBuffId =
|
type SelfBuffId =
|
||||||
@@ -78,6 +88,17 @@ type FloatingCombatText = {
|
|||||||
value: number
|
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[] = [
|
const BOSS_MECHANICS: BossMechanic[] = [
|
||||||
'party-pulse',
|
'party-pulse',
|
||||||
'searing-mark',
|
'searing-mark',
|
||||||
@@ -119,6 +140,19 @@ function formatEffectTime(ticks: number) {
|
|||||||
return Number.isInteger(seconds) ? `${seconds}s` : `${seconds.toFixed(1)}s`
|
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) {
|
function buffStacks<T extends string>(items: T[], id: T) {
|
||||||
return items.filter((item) => item === id).length
|
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>> {
|
function buildSelfBuffChoices(spells: Spell[], labelMode: AbilityLabelMode): Array<Choice<SelfBuffId>> {
|
||||||
const slotChoices = (['1', '2', '3', '4', '5'] as SlotKey[]).flatMap((slot) => {
|
const slotChoices = (['1', '2', '3', '4', '5', '6'] as SlotKey[]).flatMap((slot) => {
|
||||||
const label = slotLabel(slot, spells, labelMode)
|
const label = slotLabel(slot, spells, labelMode)
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -159,7 +193,7 @@ function buildSelfBuffChoices(spells: Spell[], labelMode: AbilityLabelMode): Arr
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildOpponentDebuffChoices(spells: Spell[], labelMode: AbilityLabelMode): Array<Choice<OpponentDebuffId>> {
|
function buildOpponentDebuffChoices(spells: Spell[], labelMode: AbilityLabelMode): Array<Choice<OpponentDebuffId>> {
|
||||||
const slotChoices = (['1', '2', '3', '4', '5'] as SlotKey[]).flatMap((slot) => {
|
const slotChoices = (['1', '2', '3', '4', '5', '6'] as SlotKey[]).flatMap((slot) => {
|
||||||
const label = slotLabel(slot, spells, labelMode)
|
const label = slotLabel(slot, spells, labelMode)
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -300,7 +334,7 @@ function scoreSelfBuff(buff: Choice<SelfBuffId>, spells: Spell[]) {
|
|||||||
if (buff.id === 'fifth-cast-free') return 8
|
if (buff.id === 'fifth-cast-free') return 8
|
||||||
if (buff.id === 'group-heal-boost') return 8
|
if (buff.id === 'group-heal-boost') return 8
|
||||||
if (buff.id === 'shield-boost') return 6
|
if (buff.id === 'shield-boost') return 6
|
||||||
const slot = buff.id.match(/slot([1-5])/i)?.[1] as SlotKey | undefined
|
const slot = buff.id.match(/slot([1-6])/i)?.[1] as SlotKey | undefined
|
||||||
const spell = spells.find((candidate) => candidate.key === slot)
|
const spell = spells.find((candidate) => candidate.key === slot)
|
||||||
if (!spell) return 5
|
if (!spell) return 5
|
||||||
if (buff.id.endsWith('extra-target')) {
|
if (buff.id.endsWith('extra-target')) {
|
||||||
@@ -374,7 +408,7 @@ export function PvPRoguelikeScreen({
|
|||||||
const gameClass = profile.classes.find((candidate) => candidate.id === profile.character.classId)!
|
const gameClass = profile.classes.find((candidate) => candidate.id === profile.character.classId)!
|
||||||
const starterSpells = useMemo(() => gameClass.spells
|
const starterSpells = useMemo(() => gameClass.spells
|
||||||
.filter((spell) => spell.unlockLevel === 1)
|
.filter((spell) => spell.unlockLevel === 1)
|
||||||
.slice(0, 5)
|
.slice(0, 6)
|
||||||
.map((spell, index) => toCombatSpell(spell, String(index + 1))), [gameClass.spells])
|
.map((spell, index) => toCombatSpell(spell, String(index + 1))), [gameClass.spells])
|
||||||
const [abilityLabelMode] = useState<AbilityLabelMode>('ability')
|
const [abilityLabelMode] = useState<AbilityLabelMode>('ability')
|
||||||
const selfBuffChoicesCatalog = useMemo(
|
const selfBuffChoicesCatalog = useMemo(
|
||||||
@@ -411,11 +445,13 @@ export function PvPRoguelikeScreen({
|
|||||||
const [playerSide, setPlayerSide] = useState<SideState>(() => starterSide(partyTemplate, maxResource))
|
const [playerSide, setPlayerSide] = useState<SideState>(() => starterSide(partyTemplate, maxResource))
|
||||||
const [cpuSide, setCpuSide] = useState<SideState>(() => starterSide(cpuPartyTemplate, maxResource))
|
const [cpuSide, setCpuSide] = useState<SideState>(() => starterSide(cpuPartyTemplate, maxResource))
|
||||||
const [selectedId, setSelectedId] = useState(partyTemplate[0].id)
|
const [selectedId, setSelectedId] = useState(partyTemplate[0].id)
|
||||||
|
const selectedIdRef = useRef(partyTemplate[0].id)
|
||||||
const [elapsedTicks, setElapsedTicks] = useState(0)
|
const [elapsedTicks, setElapsedTicks] = useState(0)
|
||||||
const [cpuDifficulty, setCpuDifficulty] = useState<CpuDifficulty | null>(null)
|
const [cpuDifficulty, setCpuDifficulty] = useState<CpuDifficulty | null>(null)
|
||||||
const [queueMessage, setQueueMessage] = useState('')
|
const [queueMessage, setQueueMessage] = useState('')
|
||||||
const [log, setLog] = useState<CombatLogEntry[]>([{ id: 1, text: 'Queueing opponent...', tone: 'system' }])
|
const [log, setLog] = useState<CombatLogEntry[]>([{ id: 1, text: 'Queueing opponent...', tone: 'system' }])
|
||||||
const [reward, setReward] = useState<DungeonReward | null>(null)
|
const [reward, setReward] = useState<DungeonReward | null>(null)
|
||||||
|
const [runSummary, setRunSummary] = useState<PvpRunSummary>(() => createEmptyPvpRunSummary())
|
||||||
const [rewardError, setRewardError] = useState('')
|
const [rewardError, setRewardError] = useState('')
|
||||||
const [showEndLog, setShowEndLog] = useState(false)
|
const [showEndLog, setShowEndLog] = useState(false)
|
||||||
const [floatingTexts, setFloatingTexts] = useState<FloatingCombatText[]>([])
|
const [floatingTexts, setFloatingTexts] = useState<FloatingCombatText[]>([])
|
||||||
@@ -433,6 +469,8 @@ export function PvPRoguelikeScreen({
|
|||||||
const bossRewardClaimedRef = useRef(new Set<number>())
|
const bossRewardClaimedRef = useRef(new Set<number>())
|
||||||
const cpuDefeatedRef = useRef(false)
|
const cpuDefeatedRef = useRef(false)
|
||||||
const playerClearedEncounterRef = useRef(-1)
|
const playerClearedEncounterRef = useRef(-1)
|
||||||
|
const queuedMatchRef = useRef(false)
|
||||||
|
const encounterPoolRef = useRef(encounterPool)
|
||||||
const playerRef = useRef(playerSide)
|
const playerRef = useRef(playerSide)
|
||||||
const cpuRef = useRef(cpuSide)
|
const cpuRef = useRef(cpuSide)
|
||||||
const encounter = encounters[encounterIndex]
|
const encounter = encounters[encounterIndex]
|
||||||
@@ -459,6 +497,12 @@ export function PvPRoguelikeScreen({
|
|||||||
const {
|
const {
|
||||||
enabled: dualScreenEnabled,
|
enabled: dualScreenEnabled,
|
||||||
} = useDualScreen()
|
} = useDualScreen()
|
||||||
|
|
||||||
|
const setSelectedTargetId = useCallback((id: string) => {
|
||||||
|
selectedIdRef.current = id
|
||||||
|
setSelectedId(id)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const addLog = useCallback((text: string, tone: CombatLogEntry['tone']) => {
|
const addLog = useCallback((text: string, tone: CombatLogEntry['tone']) => {
|
||||||
setLog((current) => [createLogEntry(nextLogId, text, tone), ...current].slice(0, 60))
|
setLog((current) => [createLogEntry(nextLogId, text, tone), ...current].slice(0, 60))
|
||||||
}, [])
|
}, [])
|
||||||
@@ -473,11 +517,16 @@ export function PvPRoguelikeScreen({
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (queuedMatchRef.current) return
|
||||||
const loadedCheckpoint = loadPvpRoguelikeCheckpoint(profile.character.id, contentType)
|
const loadedCheckpoint = loadPvpRoguelikeCheckpoint(profile.character.id, contentType)
|
||||||
setCheckpointStage(loadedCheckpoint)
|
setCheckpointStage(loadedCheckpoint)
|
||||||
setStartStage(loadedCheckpoint)
|
setStartStage(loadedCheckpoint)
|
||||||
}, [contentType, profile.character.id])
|
}, [contentType, profile.character.id])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
encounterPoolRef.current = encounterPool
|
||||||
|
}, [encounterPool])
|
||||||
|
|
||||||
const awardBossReward = useCallback((encounterIndexValue: number) => {
|
const awardBossReward = useCallback((encounterIndexValue: number) => {
|
||||||
if (bossRewardClaimedRef.current.has(encounterIndexValue)) return
|
if (bossRewardClaimedRef.current.has(encounterIndexValue)) return
|
||||||
bossRewardClaimedRef.current.add(encounterIndexValue)
|
bossRewardClaimedRef.current.add(encounterIndexValue)
|
||||||
@@ -497,6 +546,20 @@ export function PvPRoguelikeScreen({
|
|||||||
)
|
)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
setReward(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)
|
onProfileUpdated(result.profile)
|
||||||
if (result.bonusItem) {
|
if (result.bonusItem) {
|
||||||
addLog(
|
addLog(
|
||||||
@@ -532,8 +595,9 @@ export function PvPRoguelikeScreen({
|
|||||||
: null)
|
: null)
|
||||||
}, [opponentDebuffChoicesCatalog, selfBuffChoicesCatalog])
|
}, [opponentDebuffChoicesCatalog, selfBuffChoicesCatalog])
|
||||||
|
|
||||||
useEffect(() => {
|
const startMatch = useCallback((nextStartStage?: number) => {
|
||||||
const firstSegment = buildEncounterSegment(encounterPool, startStage, contentType)
|
const matchStartStage = nextStartStage ?? loadPvpRoguelikeCheckpoint(profile.character.id, contentType)
|
||||||
|
const firstSegment = buildEncounterSegment(encounterPoolRef.current, matchStartStage, contentType)
|
||||||
const firstEncounter = firstSegment[0]
|
const firstEncounter = firstSegment[0]
|
||||||
const basePlayer = starterSide(partyTemplate, maxResource)
|
const basePlayer = starterSide(partyTemplate, maxResource)
|
||||||
const baseCpu = starterSide(cpuPartyTemplate, maxResource)
|
const baseCpu = starterSide(cpuPartyTemplate, maxResource)
|
||||||
@@ -543,15 +607,18 @@ export function PvPRoguelikeScreen({
|
|||||||
cpuRef.current = baseCpu
|
cpuRef.current = baseCpu
|
||||||
nextLogId.current = 2
|
nextLogId.current = 2
|
||||||
playerClearedEncounterRef.current = -1
|
playerClearedEncounterRef.current = -1
|
||||||
|
queuedMatchRef.current = true
|
||||||
bossRewardClaimedRef.current = new Set()
|
bossRewardClaimedRef.current = new Set()
|
||||||
setEncounters(firstSegment)
|
setEncounters(firstSegment)
|
||||||
setEncounterIndex(0)
|
setEncounterIndex(0)
|
||||||
setStage(startStage)
|
setCheckpointStage(matchStartStage)
|
||||||
|
setStartStage(matchStartStage)
|
||||||
|
setStage(matchStartStage)
|
||||||
setElapsedTicks(0)
|
setElapsedTicks(0)
|
||||||
setStatus('queueing')
|
setStatus('queueing')
|
||||||
setPlayerSide(basePlayer)
|
setPlayerSide(basePlayer)
|
||||||
setCpuSide(baseCpu)
|
setCpuSide(baseCpu)
|
||||||
setSelectedId(partyTemplate[0].id)
|
setSelectedTargetId(partyTemplate[0].id)
|
||||||
setPlayerBuffChoices([])
|
setPlayerBuffChoices([])
|
||||||
setPlayerDebuffChoices([])
|
setPlayerDebuffChoices([])
|
||||||
setSelectedBuff(null)
|
setSelectedBuff(null)
|
||||||
@@ -560,6 +627,7 @@ export function PvPRoguelikeScreen({
|
|||||||
setPaused(false)
|
setPaused(false)
|
||||||
setTargetGroup(0)
|
setTargetGroup(0)
|
||||||
setReward(null)
|
setReward(null)
|
||||||
|
setRunSummary(createEmptyPvpRunSummary())
|
||||||
setRewardError('')
|
setRewardError('')
|
||||||
setShowEndLog(false)
|
setShowEndLog(false)
|
||||||
setFloatingTexts([])
|
setFloatingTexts([])
|
||||||
@@ -569,26 +637,28 @@ export function PvPRoguelikeScreen({
|
|||||||
cpuDefeatedRef.current = false
|
cpuDefeatedRef.current = false
|
||||||
if (gameMode === 'offline') {
|
if (gameMode === 'offline') {
|
||||||
const randomCpu = randomCpuDifficulty()
|
const randomCpu = randomCpuDifficulty()
|
||||||
setQueueMessage(`Offline mode. CPU ${randomCpu} enters at stage ${startStage}.`)
|
setQueueMessage(`Offline mode. CPU ${randomCpu} enters at stage ${matchStartStage}.`)
|
||||||
setCpuDifficulty(randomCpu)
|
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(() => {
|
const timer = window.setTimeout(() => {
|
||||||
setStatus('playing')
|
setStatus('playing')
|
||||||
addLog(`Stage ${startStage} begins against CPU ${randomCpu}.`, 'system')
|
addLog(`Stage ${matchStartStage} begins against CPU ${randomCpu}.`, 'system')
|
||||||
}, 500)
|
}, 500)
|
||||||
return () => window.clearTimeout(timer)
|
return () => window.clearTimeout(timer)
|
||||||
}
|
}
|
||||||
setQueueMessage(`Searching queue. Stage ${startStage} start ready.`)
|
setQueueMessage(`Searching queue. Stage ${matchStartStage} start ready.`)
|
||||||
setLog([{ id: 1, text: `Searching queue. Stage ${startStage} start ready.`, tone: 'system' }])
|
setLog([{ id: 1, text: `Searching queue. Stage ${matchStartStage} start ready.`, tone: 'system' }])
|
||||||
const timer = window.setTimeout(() => {
|
const timer = window.setTimeout(() => {
|
||||||
const randomCpu = randomCpuDifficulty()
|
const randomCpu = randomCpuDifficulty()
|
||||||
setCpuDifficulty(randomCpu)
|
setCpuDifficulty(randomCpu)
|
||||||
setQueueMessage(`No queued player found. CPU ${randomCpu} steps in.`)
|
setQueueMessage(`No queued player found. CPU ${randomCpu} steps in.`)
|
||||||
setStatus('playing')
|
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)
|
}, 1400)
|
||||||
return () => window.clearTimeout(timer)
|
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((
|
const applySpell = useCallback((
|
||||||
current: SideState,
|
current: SideState,
|
||||||
@@ -611,6 +681,11 @@ export function PvPRoguelikeScreen({
|
|||||||
const hotTargets = new Set(spell.kind === 'hot' ? [targetId] : [])
|
const hotTargets = new Set(spell.kind === 'hot' ? [targetId] : [])
|
||||||
const shieldTargets = new Set(spell.kind === 'shield' ? [targetId] : [])
|
const shieldTargets = new Set(spell.kind === 'shield' ? [targetId] : [])
|
||||||
const extraTargets = buffStacks(buffs, `slot${spell.key as SlotKey}-extra-target` as SelfBuffId)
|
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) {
|
for (let index = 0; index < extraTargets; index += 1) {
|
||||||
if (spell.kind === 'group') break
|
if (spell.kind === 'group') break
|
||||||
if (spell.kind === 'hot') {
|
if (spell.kind === 'hot') {
|
||||||
@@ -629,6 +704,7 @@ export function PvPRoguelikeScreen({
|
|||||||
const nextParty = current.party.map((member) => {
|
const nextParty = current.party.map((member) => {
|
||||||
if (member.health <= 0) return member
|
if (member.health <= 0) return member
|
||||||
if (spell.kind === 'group') {
|
if (spell.kind === 'group') {
|
||||||
|
if (!groupTargets.has(member.id)) return member
|
||||||
const groupPower = Math.round(spell.power * (1.25 ** buffStacks(buffs, 'group-heal-boost')))
|
const groupPower = Math.round(spell.power * (1.25 ** buffStacks(buffs, 'group-heal-boost')))
|
||||||
const nextHealth = healMember(member, groupPower, debuffs)
|
const nextHealth = healMember(member, groupPower, debuffs)
|
||||||
addFloatingHeal(sideName, member.id, Math.max(0, nextHealth - member.health))
|
addFloatingHeal(sideName, member.id, Math.max(0, nextHealth - member.health))
|
||||||
@@ -687,29 +763,30 @@ export function PvPRoguelikeScreen({
|
|||||||
|
|
||||||
const castPlayerSpell = useCallback((spell: Spell) => {
|
const castPlayerSpell = useCallback((spell: Spell) => {
|
||||||
if (status !== 'playing' || playerDone || !playerAlive) return
|
if (status !== 'playing' || playerDone || !playerAlive) return
|
||||||
|
const targetId = selectedIdRef.current
|
||||||
const succeeded = applySpell(playerRef.current, (value) => {
|
const succeeded = applySpell(playerRef.current, (value) => {
|
||||||
const next = typeof value === 'function' ? value(playerRef.current) : value
|
const next = typeof value === 'function' ? value(playerRef.current) : value
|
||||||
playerRef.current = next
|
playerRef.current = next
|
||||||
setPlayerSide(next)
|
setPlayerSide(next)
|
||||||
}, 'player', playerRef.current.buffs, playerRef.current.debuffs, spell, selectedId)
|
}, 'player', playerRef.current.buffs, playerRef.current.debuffs, spell, targetId)
|
||||||
if (succeeded) addLog(`${spell.name} cast on ${playerRef.current.party.find((member) => member.id === selectedId)?.name ?? 'target'}.`, 'heal')
|
if (succeeded) addLog(`${spell.name} cast on ${playerRef.current.party.find((member) => member.id === targetId)?.name ?? 'target'}.`, 'heal')
|
||||||
}, [addLog, applySpell, playerAlive, playerDone, selectedId, status])
|
}, [addLog, applySpell, playerAlive, playerDone, status])
|
||||||
|
|
||||||
const selectRelativeTarget = useCallback((direction: -1 | 1) => {
|
const selectRelativeTarget = useCallback((direction: -1 | 1) => {
|
||||||
const living = playerRef.current.party.filter((member) => member.health > 0)
|
const living = playerRef.current.party.filter((member) => member.health > 0)
|
||||||
if (living.length === 0) return
|
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
|
const nextIndex = currentIndex < 0
|
||||||
? 0
|
? 0
|
||||||
: (currentIndex + direction + living.length) % living.length
|
: (currentIndex + direction + living.length) % living.length
|
||||||
setSelectedId(living[nextIndex].id)
|
setSelectedTargetId(living[nextIndex].id)
|
||||||
}, [selectedId])
|
}, [setSelectedTargetId])
|
||||||
|
|
||||||
const selectDirectionalTarget = useCallback((action: InputAction) => {
|
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) {
|
if (currentIndex < 0) {
|
||||||
const firstLiving = playerRef.current.party.find((member) => member.health > 0)
|
const firstLiving = playerRef.current.party.find((member) => member.health > 0)
|
||||||
if (firstLiving) setSelectedId(firstLiving.id)
|
if (firstLiving) setSelectedTargetId(firstLiving.id)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const currentRow = Math.floor(currentIndex / partyColumns)
|
const currentRow = Math.floor(currentIndex / partyColumns)
|
||||||
@@ -736,14 +813,14 @@ export function PvPRoguelikeScreen({
|
|||||||
const bSecondary = horizontal ? 0 : Math.abs(b.column - currentColumn)
|
const bSecondary = horizontal ? 0 : Math.abs(b.column - currentColumn)
|
||||||
return aPrimary - bPrimary || aSecondary - bSecondary
|
return aPrimary - bPrimary || aSecondary - bSecondary
|
||||||
})
|
})
|
||||||
if (candidates[0]) setSelectedId(candidates[0].member.id)
|
if (candidates[0]) setSelectedTargetId(candidates[0].member.id)
|
||||||
}, [partyColumns, selectedId])
|
}, [partyColumns, setSelectedTargetId])
|
||||||
|
|
||||||
const selectDirectTarget = useCallback((slot: number) => {
|
const selectDirectTarget = useCallback((slot: number) => {
|
||||||
const index = slot + (contentType === 'raid' ? targetGroup * 6 : 0)
|
const index = slot + (contentType === 'raid' ? targetGroup * 6 : 0)
|
||||||
const member = playerRef.current.party[index]
|
const member = playerRef.current.party[index]
|
||||||
if (member?.health > 0) setSelectedId(member.id)
|
if (member?.health > 0) setSelectedTargetId(member.id)
|
||||||
}, [contentType, targetGroup])
|
}, [contentType, setSelectedTargetId, targetGroup])
|
||||||
|
|
||||||
const cpuTakeTurn = useCallback(() => {
|
const cpuTakeTurn = useCallback(() => {
|
||||||
if (!cpuDifficulty || status !== 'playing' || cpuDone || !cpuAlive) return
|
if (!cpuDifficulty || status !== 'playing' || cpuDone || !cpuAlive) return
|
||||||
@@ -790,10 +867,14 @@ export function PvPRoguelikeScreen({
|
|||||||
const appliesHealingReduction = encounterValue.isBoss && elapsedTicks > 0 && elapsedTicks % 9 === 0 && mechanics.includes('healing-reduction')
|
const appliesHealingReduction = encounterValue.isBoss && elapsedTicks > 0 && elapsedTicks % 9 === 0 && mechanics.includes('healing-reduction')
|
||||||
const appliesPoison = encounterValue.isBoss && elapsedTicks > 0 && elapsedTicks % 12 === 0 && mechanics.includes('ramping-poison')
|
const appliesPoison = encounterValue.isBoss && elapsedTicks > 0 && elapsedTicks % 12 === 0 && mechanics.includes('ramping-poison')
|
||||||
const damageMultiplier = incomingDamageMultiplier(side.debuffs)
|
const damageMultiplier = incomingDamageMultiplier(side.debuffs)
|
||||||
|
const tankPressure = tankPressureTargets(side.party)
|
||||||
|
const tankPressureIds = new Set(tankPressure.targets.map((member) => member.id))
|
||||||
const nextParty = side.party.map((member) => {
|
const nextParty = side.party.map((member) => {
|
||||||
if (member.health <= 0) return member
|
if (member.health <= 0) return member
|
||||||
let damage = member.id === primaryTarget.id ? encounterValue.damage : 0
|
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 (bossPulse) damage += 10
|
||||||
if (member.debuff) damage += 6
|
if (member.debuff) damage += 6
|
||||||
const nextPoisonStacks = appliesPoison && member.id === primaryTarget.id
|
const nextPoisonStacks = appliesPoison && member.id === primaryTarget.id
|
||||||
@@ -842,7 +923,7 @@ export function PvPRoguelikeScreen({
|
|||||||
cooldowns: Object.fromEntries(
|
cooldowns: Object.fromEntries(
|
||||||
Object.entries(side.cooldowns).map(([id, seconds]) => [id, Math.max(0, seconds - TICK_MS / 1000)]),
|
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])
|
}, [addFloatingHeal, elapsedTicks, maxResource])
|
||||||
|
|
||||||
@@ -895,6 +976,12 @@ export function PvPRoguelikeScreen({
|
|||||||
addLog(`CPU ${cpuDifficulty ?? 1} fell. Finish the boss for XP.`, 'loot')
|
addLog(`CPU ${cpuDifficulty ?? 1} fell. Finish the boss for XP.`, 'loot')
|
||||||
}
|
}
|
||||||
if (nextPlayer.enemyHealth <= 0) {
|
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')
|
addLog(`${encounter.enemyName} cleared. Choose your next edge.`, 'loot')
|
||||||
beginUpgradePhase()
|
beginUpgradePhase()
|
||||||
}
|
}
|
||||||
@@ -955,10 +1042,17 @@ export function PvPRoguelikeScreen({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const clearedBoss = encounter.isBoss
|
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 nextStage = clearedBoss ? stage + 1 : stage
|
||||||
const nextSegment = clearedBoss ? buildEncounterSegment(encounterPool, nextStage, contentType) : []
|
const nextSegment = clearedBoss ? buildEncounterSegment(encounterPool, nextStage, contentType) : []
|
||||||
const nextEncounter = clearedBoss ? nextSegment[0] : encounters[encounterIndex + 1]
|
const nextEncounter = clearedBoss ? nextSegment[0] : encounters[encounterIndex + 1]
|
||||||
if (!nextEncounter) {
|
if (!nextEncounter) {
|
||||||
|
finishRoguelikeRun()
|
||||||
setStatus('won')
|
setStatus('won')
|
||||||
addLog('No further encounters remain.', 'loot')
|
addLog('No further encounters remain.', 'loot')
|
||||||
return
|
return
|
||||||
@@ -1007,7 +1101,7 @@ export function PvPRoguelikeScreen({
|
|||||||
setElapsedTicks(0)
|
setElapsedTicks(0)
|
||||||
setStatus('playing')
|
setStatus('playing')
|
||||||
addLog(`You chose ${selectedBuff.name} and ${selectedDebuff.name}. CPU ${cpuDifficulty} chose ${cpuBuff.name} and ${cpuDebuff.name}.`, 'system')
|
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) => {
|
useGameAction((action) => {
|
||||||
if (action === 'pause' || action === 'back') {
|
if (action === 'pause' || action === 'back') {
|
||||||
@@ -1036,9 +1130,9 @@ export function PvPRoguelikeScreen({
|
|||||||
setTargetGroup((current) => {
|
setTargetGroup((current) => {
|
||||||
const groupCount = Math.max(1, Math.ceil(playerRef.current.party.length / 6))
|
const groupCount = Math.max(1, Math.ceil(playerRef.current.party.length / 6))
|
||||||
const next = ((current + 1) % groupCount) as 0 | 1 | 2
|
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]
|
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 next
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
@@ -1128,7 +1222,7 @@ export function PvPRoguelikeScreen({
|
|||||||
{dualScreenEnabled && status !== 'queueing' && (
|
{dualScreenEnabled && status !== 'queueing' && (
|
||||||
<DualScreenTopCombat
|
<DualScreenTopCombat
|
||||||
state={dualScreenState}
|
state={dualScreenState}
|
||||||
onSelectTarget={setSelectedId}
|
onSelectTarget={setSelectedTargetId}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1152,7 +1246,7 @@ export function PvPRoguelikeScreen({
|
|||||||
<button
|
<button
|
||||||
className={`party-member ${selectedId === member.id ? 'selected' : ''} ${member.health <= 0 ? 'down' : ''}`}
|
className={`party-member ${selectedId === member.id ? 'selected' : ''} ${member.health <= 0 ? 'down' : ''}`}
|
||||||
key={`player-${member.id}`}
|
key={`player-${member.id}`}
|
||||||
onClick={() => setSelectedId(member.id)}
|
onClick={() => setSelectedTargetId(member.id)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<div className="member-header">
|
<div className="member-header">
|
||||||
@@ -1351,9 +1445,39 @@ export function PvPRoguelikeScreen({
|
|||||||
<h2>{status === 'won' ? `CPU ${cpuDifficulty} Falls` : `CPU ${cpuDifficulty} Wins`}</h2>
|
<h2>{status === 'won' ? `CPU ${cpuDifficulty} Falls` : `CPU ${cpuDifficulty} Wins`}</h2>
|
||||||
<p>{finalEncountersCleared} encounters cleared.</p>
|
<p>{finalEncountersCleared} encounters cleared.</p>
|
||||||
<div className="reward-summary">
|
<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>}
|
{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>
|
<p>+{reward.experienceGained} XP</p>
|
||||||
{reward.levelsGained > 0 && (
|
{reward.levelsGained > 0 && (
|
||||||
@@ -1392,6 +1516,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>
|
<button className="secondary-result-button" onClick={onExit} type="button">Back to Roguelike</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+109
-2
@@ -56,12 +56,28 @@ export type DualScreenCombatState = {
|
|||||||
targetGroup: 0 | 1 | 2
|
targetGroup: 0 | 1 | 2
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DualScreenWorkshopState = {
|
||||||
|
mode: 'class' | 'equipment' | 'crafting' | 'talents'
|
||||||
|
title: string
|
||||||
|
subtitle: string
|
||||||
|
summary?: string
|
||||||
|
items: Array<{
|
||||||
|
glyph?: string
|
||||||
|
title: string
|
||||||
|
meta?: string
|
||||||
|
detail?: string
|
||||||
|
status?: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
type DualScreenMessage =
|
type DualScreenMessage =
|
||||||
| { type: 'combat-state'; state: DualScreenCombatState }
|
| { type: 'combat-state'; state: DualScreenCombatState }
|
||||||
|
| { type: 'workshop-state'; state: DualScreenWorkshopState }
|
||||||
| { type: 'companion-ready' }
|
| { type: 'companion-ready' }
|
||||||
| { type: 'companion-heartbeat' }
|
| { type: 'companion-heartbeat' }
|
||||||
| { type: 'control-action'; action: InputAction }
|
| { type: 'control-action'; action: InputAction }
|
||||||
| { type: 'combat-ended' }
|
| { type: 'combat-ended' }
|
||||||
|
| { type: 'workshop-ended' }
|
||||||
|
|
||||||
type DualScreenContextValue = {
|
type DualScreenContextValue = {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
@@ -102,6 +118,13 @@ function loadRecentSnapshot() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function memberHotEffects(member: PartyMember) {
|
||||||
|
if (member.hotEffects?.length) return member.hotEffects
|
||||||
|
return member.hotTicks > 0
|
||||||
|
? [{ id: 'legacy-renew', label: 'Renew', ticks: member.hotTicks }]
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
|
||||||
export function DualScreenProvider({ children }: { children: ReactNode }) {
|
export function DualScreenProvider({ children }: { children: ReactNode }) {
|
||||||
const [enabled, setEnabledState] = useState(
|
const [enabled, setEnabledState] = useState(
|
||||||
() => localStorage.getItem(STORAGE_KEY) === 'true',
|
() => localStorage.getItem(STORAGE_KEY) === 'true',
|
||||||
@@ -280,16 +303,64 @@ export function useDualScreenPublisher(
|
|||||||
}, [enabled, state])
|
}, [enabled, state])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useDualScreenWorkshopPublisher(
|
||||||
|
state: DualScreenWorkshopState | null,
|
||||||
|
enabled: boolean,
|
||||||
|
) {
|
||||||
|
const stateRef = useRef(state)
|
||||||
|
useEffect(() => {
|
||||||
|
stateRef.current = state
|
||||||
|
}, [state])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled || !state) return
|
||||||
|
const channel = createChannel()
|
||||||
|
if (!channel) return
|
||||||
|
const publish = () => {
|
||||||
|
if (stateRef.current) {
|
||||||
|
channel.postMessage({
|
||||||
|
type: 'workshop-state',
|
||||||
|
state: stateRef.current,
|
||||||
|
} satisfies DualScreenMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
|
||||||
|
if (event.data.type === 'companion-ready') publish()
|
||||||
|
}
|
||||||
|
publish()
|
||||||
|
return () => {
|
||||||
|
channel.postMessage({ type: 'workshop-ended' } satisfies DualScreenMessage)
|
||||||
|
channel.close()
|
||||||
|
}
|
||||||
|
}, [enabled, state])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled || !state) return
|
||||||
|
const channel = createChannel()
|
||||||
|
channel?.postMessage({ type: 'workshop-state', state } satisfies DualScreenMessage)
|
||||||
|
channel?.close()
|
||||||
|
}, [enabled, state])
|
||||||
|
}
|
||||||
|
|
||||||
export function DualScreenBottomDisplay() {
|
export function DualScreenBottomDisplay() {
|
||||||
const [state, setState] = useState<DualScreenCombatState | null>(loadRecentSnapshot)
|
const [state, setState] = useState<DualScreenCombatState | null>(loadRecentSnapshot)
|
||||||
|
const [workshopState, setWorkshopState] = useState<DualScreenWorkshopState | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const channel = createChannel()
|
const channel = createChannel()
|
||||||
if (!channel) return
|
if (!channel) return
|
||||||
const announce = () => channel.postMessage({ type: 'companion-ready' } satisfies DualScreenMessage)
|
const announce = () => channel.postMessage({ type: 'companion-ready' } satisfies DualScreenMessage)
|
||||||
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
|
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
|
||||||
if (event.data.type === 'combat-state') setState(event.data.state)
|
if (event.data.type === 'combat-state') {
|
||||||
|
setState(event.data.state)
|
||||||
|
setWorkshopState(null)
|
||||||
|
}
|
||||||
|
if (event.data.type === 'workshop-state') {
|
||||||
|
setWorkshopState(event.data.state)
|
||||||
|
setState(null)
|
||||||
|
}
|
||||||
if (event.data.type === 'combat-ended') setState(null)
|
if (event.data.type === 'combat-ended') setState(null)
|
||||||
|
if (event.data.type === 'workshop-ended') setWorkshopState(null)
|
||||||
}
|
}
|
||||||
announce()
|
announce()
|
||||||
const timer = window.setInterval(() => {
|
const timer = window.setInterval(() => {
|
||||||
@@ -307,6 +378,40 @@ export function DualScreenBottomDisplay() {
|
|||||||
channel?.close()
|
channel?.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!state && workshopState) {
|
||||||
|
return (
|
||||||
|
<main className="dual-bottom-display workshop-bottom-display">
|
||||||
|
<header className="dual-controls-header">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">{workshopState.mode}</p>
|
||||||
|
<h1>{workshopState.title}</h1>
|
||||||
|
</div>
|
||||||
|
<div className="dual-controls-progress">
|
||||||
|
<span>{workshopState.subtitle}</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{workshopState.summary && (
|
||||||
|
<section className="workshop-bottom-summary">
|
||||||
|
{workshopState.summary}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
<section className="workshop-bottom-grid">
|
||||||
|
{workshopState.items.map((item, index) => (
|
||||||
|
<article key={`${item.title}-${index}`}>
|
||||||
|
{item.glyph && <span>{item.glyph}</span>}
|
||||||
|
<div>
|
||||||
|
<strong>{item.title}</strong>
|
||||||
|
{item.meta && <small>{item.meta}</small>}
|
||||||
|
{item.detail && <p>{item.detail}</p>}
|
||||||
|
</div>
|
||||||
|
{item.status && <i>{item.status}</i>}
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (!state) {
|
if (!state) {
|
||||||
return (
|
return (
|
||||||
<main className="dual-bottom-display dual-bottom-waiting">
|
<main className="dual-bottom-display dual-bottom-waiting">
|
||||||
@@ -501,7 +606,9 @@ export function DualScreenTopCombat({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="member-effects">
|
<div className="member-effects">
|
||||||
{member.hotTicks > 0 && <span className="buff">Renew</span>}
|
{memberHotEffects(member).map((effect) => (
|
||||||
|
<span className="buff" key={effect.id}>{effect.label}</span>
|
||||||
|
))}
|
||||||
{member.debuff && <span className="debuff">{member.debuff}</span>}
|
{member.debuff && <span className="debuff">{member.debuff}</span>}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
+44
-2
@@ -8,6 +8,19 @@ export type PartyMember = {
|
|||||||
maxHealth: number
|
maxHealth: number
|
||||||
shield: number
|
shield: number
|
||||||
hotTicks: number
|
hotTicks: number
|
||||||
|
hotEffects?: Array<{
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
ticks: number
|
||||||
|
power: number
|
||||||
|
}>
|
||||||
|
bounceHeals?: Array<{
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
charges: number
|
||||||
|
power: number
|
||||||
|
}>
|
||||||
|
damageReductionTicks?: number
|
||||||
debuff?: string
|
debuff?: string
|
||||||
debuffTicks?: number
|
debuffTicks?: number
|
||||||
poisonStacks?: number
|
poisonStacks?: number
|
||||||
@@ -24,7 +37,8 @@ export type Spell = {
|
|||||||
cooldown: number
|
cooldown: number
|
||||||
power: number
|
power: number
|
||||||
glyph: string
|
glyph: string
|
||||||
kind: 'direct' | 'hot' | 'group' | 'shield' | 'cleanse'
|
kind: 'direct' | 'hot' | 'group' | 'shield' | 'cleanse' | 'damage_reduction' | 'bounce_heal'
|
||||||
|
effectType?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Encounter = {
|
export type Encounter = {
|
||||||
@@ -44,6 +58,9 @@ export type CombatLogEntry = {
|
|||||||
tone: 'system' | 'heal' | 'danger' | 'loot'
|
tone: 'system' | 'heal' | 'danger' | 'loot'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const TANKLESS_DAMAGE_MULTIPLIER = 1.35
|
||||||
|
export const DEFAULT_GROUP_HEAL_TARGETS = 4
|
||||||
|
|
||||||
export const INITIAL_PARTY: PartyMember[] = [
|
export const INITIAL_PARTY: PartyMember[] = [
|
||||||
{ id: 'brann', name: 'Brann', role: 'Tank', health: 150, maxHealth: 150, shield: 0, hotTicks: 0 },
|
{ 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 },
|
{ id: 'mira', name: 'Mira', role: 'Healer', health: 100, maxHealth: 100, shield: 0, hotTicks: 0 },
|
||||||
@@ -101,7 +118,7 @@ export const SPELLS: Spell[] = [
|
|||||||
id: 'radiance',
|
id: 'radiance',
|
||||||
key: '3',
|
key: '3',
|
||||||
name: 'Radiance',
|
name: 'Radiance',
|
||||||
description: 'Restores health to every living party member.',
|
description: 'Restores health to up to 4 injured party members.',
|
||||||
cost: 12,
|
cost: 12,
|
||||||
cooldown: 8,
|
cooldown: 8,
|
||||||
power: 18,
|
power: 18,
|
||||||
@@ -164,3 +181,28 @@ export const ENCOUNTERS: Encounter[] = [
|
|||||||
isBoss: true,
|
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)
|
||||||
|
}
|
||||||
|
|||||||
+84
-28
@@ -26,6 +26,7 @@ export interface GameRepository {
|
|||||||
completedPart?: number,
|
completedPart?: number,
|
||||||
startPart?: number,
|
startPart?: number,
|
||||||
partDurationSeconds?: [number, number, number],
|
partDurationSeconds?: [number, number, number],
|
||||||
|
hardMode?: boolean,
|
||||||
): Promise<DungeonReward>
|
): Promise<DungeonReward>
|
||||||
completeRoguelike(
|
completeRoguelike(
|
||||||
dungeonId: number,
|
dungeonId: number,
|
||||||
@@ -102,6 +103,7 @@ const offlineSaveKey = 'chronicle.offlineSave.v1'
|
|||||||
const onlineCacheKey = 'chronicle.onlineCache.v1'
|
const onlineCacheKey = 'chronicle.onlineCache.v1'
|
||||||
const authTokenKey = 'chronicle.authToken.v1'
|
const authTokenKey = 'chronicle.authToken.v1'
|
||||||
const offlineAccount = { id: -1, username: 'Offline' }
|
const offlineAccount = { id: -1, username: 'Offline' }
|
||||||
|
const ABILITY_SLOT_COUNT = 6
|
||||||
|
|
||||||
function clone<T>(value: T): T {
|
function clone<T>(value: T): T {
|
||||||
return structuredClone(value)
|
return structuredClone(value)
|
||||||
@@ -146,7 +148,7 @@ function upgradeV1Save(v1: { profile: CharacterProfile; lootRolls: Record<string
|
|||||||
level: cid === p.character.classId ? p.character.level : 1,
|
level: cid === p.character.classId ? p.character.level : 1,
|
||||||
experience: cid === p.character.classId ? p.character.experience : 0,
|
experience: cid === p.character.classId ? p.character.experience : 0,
|
||||||
talentPoints: cid === p.character.classId ? p.character.talentPoints : 1,
|
talentPoints: cid === p.character.classId ? p.character.talentPoints : 1,
|
||||||
abilitySlots: cid === p.character.classId ? [...p.abilitySlots] : [],
|
abilitySlots: cid === p.character.classId ? normalizeAbilitySlots(p.abilitySlots) : [],
|
||||||
talentRanks,
|
talentRanks,
|
||||||
inventory: cid === p.character.classId ? clone(p.inventory) : [],
|
inventory: cid === p.character.classId ? clone(p.inventory) : [],
|
||||||
}
|
}
|
||||||
@@ -163,11 +165,32 @@ function upgradeV1Save(v1: { profile: CharacterProfile; lootRolls: Record<string
|
|||||||
}
|
}
|
||||||
|
|
||||||
function upgradeV2Save(v2: Omit<OfflineSave, 'version' | 'completedRaidPhases'> & { version: 2 }): OfflineSave {
|
function upgradeV2Save(v2: Omit<OfflineSave, 'version' | 'completedRaidPhases'> & { version: 2 }): OfflineSave {
|
||||||
return {
|
return normalizeSaveAbilitySlots({
|
||||||
...v2,
|
...v2,
|
||||||
version: 3,
|
version: 3,
|
||||||
completedRaidPhases: 0,
|
completedRaidPhases: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAbilitySlots(abilitySlots: unknown): Array<number | null> {
|
||||||
|
const slots = Array.isArray(abilitySlots)
|
||||||
|
? abilitySlots
|
||||||
|
.slice(0, ABILITY_SLOT_COUNT)
|
||||||
|
.map((value) => {
|
||||||
|
if (value === null || value === undefined) return null
|
||||||
|
const id = Number(value)
|
||||||
|
return Number.isInteger(id) ? id : null
|
||||||
|
})
|
||||||
|
: []
|
||||||
|
while (slots.length < ABILITY_SLOT_COUNT) slots.push(null)
|
||||||
|
return slots
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSaveAbilitySlots(save: OfflineSave): OfflineSave {
|
||||||
|
for (const character of Object.values(save.characters)) {
|
||||||
|
character.abilitySlots = normalizeAbilitySlots(character.abilitySlots)
|
||||||
}
|
}
|
||||||
|
return save
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeOfflineSave(raw: unknown): OfflineSave | null {
|
function normalizeOfflineSave(raw: unknown): OfflineSave | null {
|
||||||
@@ -177,12 +200,12 @@ function normalizeOfflineSave(raw: unknown): OfflineSave | null {
|
|||||||
profile?: CharacterProfile
|
profile?: CharacterProfile
|
||||||
lootRolls?: Record<string, LootRoll>
|
lootRolls?: Record<string, LootRoll>
|
||||||
}
|
}
|
||||||
if (candidate.version === 3) return candidate as OfflineSave
|
if (candidate.version === 3) return normalizeSaveAbilitySlots(candidate as OfflineSave)
|
||||||
if (candidate.version === 2) {
|
if (candidate.version === 2) {
|
||||||
return upgradeV2Save(candidate as Omit<OfflineSave, 'version' | 'completedRaidPhases'> & { version: 2 })
|
return upgradeV2Save(candidate as Omit<OfflineSave, 'version' | 'completedRaidPhases'> & { version: 2 })
|
||||||
}
|
}
|
||||||
if (candidate.version === 1 && candidate.profile) {
|
if (candidate.version === 1 && candidate.profile) {
|
||||||
return upgradeV1Save(candidate as { profile: CharacterProfile; lootRolls: Record<string, LootRoll> })
|
return normalizeSaveAbilitySlots(upgradeV1Save(candidate as { profile: CharacterProfile; lootRolls: Record<string, LootRoll> }))
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -359,11 +382,33 @@ function experienceForLevel(level: number) {
|
|||||||
return (level - 1) * (level - 1) * 100
|
return (level - 1) * (level - 1) * 100
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function catchUpExperienceReward(
|
||||||
|
baseReward: number,
|
||||||
|
currentExperience: number,
|
||||||
|
currentLevel: number,
|
||||||
|
targetLevel: number,
|
||||||
|
) {
|
||||||
|
if (targetLevel <= currentLevel) return baseReward
|
||||||
|
const targetExperience = experienceForLevel(targetLevel)
|
||||||
|
const gap = Math.max(0, targetExperience - currentExperience)
|
||||||
|
if (gap <= 0) return baseReward
|
||||||
|
const doubledBase = Math.min(baseReward, Math.ceil(gap / 2))
|
||||||
|
return doubledBase * 2 + (baseReward - doubledBase)
|
||||||
|
}
|
||||||
|
|
||||||
|
function highestOtherClassLevel(save: OfflineSave) {
|
||||||
|
const activeClass = save.activeClassId
|
||||||
|
return Object.entries(save.characters)
|
||||||
|
.filter(([classId]) => Number(classId) !== activeClass)
|
||||||
|
.reduce((highest, [, character]) => Math.max(highest, character.level), 0)
|
||||||
|
}
|
||||||
|
|
||||||
function scaledPvpBossExperience(
|
function scaledPvpBossExperience(
|
||||||
startingExperience: number,
|
startingExperience: number,
|
||||||
startingLevel: number,
|
startingLevel: number,
|
||||||
bossesCleared: number,
|
bossesCleared: number,
|
||||||
maxLevel: number,
|
maxLevel: number,
|
||||||
|
targetLevel = startingLevel,
|
||||||
) {
|
) {
|
||||||
let experience = startingExperience
|
let experience = startingExperience
|
||||||
let level = startingLevel
|
let level = startingLevel
|
||||||
@@ -374,7 +419,8 @@ function scaledPvpBossExperience(
|
|||||||
? maxExperience
|
? maxExperience
|
||||||
: experienceForLevel(level + 1)
|
: experienceForLevel(level + 1)
|
||||||
const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor)
|
const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor)
|
||||||
experience = Math.min(maxExperience, experience + Math.round(levelBand * 0.25))
|
const rewardRate = targetLevel > level ? 0.5 : 0.25
|
||||||
|
experience = Math.min(maxExperience, experience + Math.round(levelBand * rewardRate))
|
||||||
while (level < maxLevel && experienceForLevel(level + 1) <= experience) {
|
while (level < maxLevel && experienceForLevel(level + 1) <= experience) {
|
||||||
level += 1
|
level += 1
|
||||||
}
|
}
|
||||||
@@ -385,12 +431,11 @@ function scaledPvpBossExperience(
|
|||||||
type ComponentTemplate = { id: number; slug: string; name: string; itemLevel: number; glyph: string; description: string }
|
type ComponentTemplate = { id: number; slug: string; name: string; itemLevel: number; glyph: string; description: string }
|
||||||
const COMPONENT_ITEMS: Record<number, ComponentTemplate> = {
|
const COMPONENT_ITEMS: Record<number, ComponentTemplate> = {
|
||||||
1: { id: 600, slug: 'minor-component', name: 'Minor Component', itemLevel: 1, glyph: '◆', description: 'A basic crafting component.' },
|
1: { id: 600, slug: 'minor-component', name: 'Minor Component', itemLevel: 1, glyph: '◆', description: 'A basic crafting component.' },
|
||||||
5: { id: 601, slug: 'basic-component', name: 'Basic Component', itemLevel: 5, glyph: '◇', description: 'A standard crafting component.' },
|
|
||||||
10: { id: 602, slug: 'refined-component', name: 'Refined Component', itemLevel: 10, glyph: '◈', description: 'A refined crafting component.' },
|
10: { id: 602, slug: 'refined-component', name: 'Refined Component', itemLevel: 10, glyph: '◈', description: 'A refined crafting component.' },
|
||||||
15: { id: 603, slug: 'advanced-component', name: 'Advanced Component', itemLevel: 15, glyph: '◉', description: 'An advanced crafting component.' },
|
|
||||||
20: { id: 604, slug: 'superior-component', name: 'Superior Component', itemLevel: 20, glyph: '◎', description: 'A superior crafting component.' },
|
20: { id: 604, slug: 'superior-component', name: 'Superior Component', itemLevel: 20, glyph: '◎', description: 'A superior crafting component.' },
|
||||||
25: { id: 605, slug: 'primal-component', name: 'Primal Component', itemLevel: 25, glyph: '✦', description: 'A primal crafting component.' },
|
25: { id: 605, slug: 'primal-component', name: 'Primal Component', itemLevel: 25, glyph: '✦', description: 'A primal crafting component.' },
|
||||||
}
|
}
|
||||||
|
const DIRECT_CRAFT_ITEM_LEVELS = new Set([1, 10, 20, 25])
|
||||||
|
|
||||||
type WindowWithApiBase = Window & {
|
type WindowWithApiBase = Window & {
|
||||||
CAPACITOR_API_BASE_URL?: string
|
CAPACITOR_API_BASE_URL?: string
|
||||||
@@ -425,7 +470,7 @@ function mergeProfileIntoSave(profile: CharacterProfile, existingSave?: OfflineS
|
|||||||
level: profile.character.level,
|
level: profile.character.level,
|
||||||
experience: profile.character.experience,
|
experience: profile.character.experience,
|
||||||
talentPoints: profile.character.talentPoints,
|
talentPoints: profile.character.talentPoints,
|
||||||
abilitySlots: [...profile.abilitySlots],
|
abilitySlots: normalizeAbilitySlots(profile.abilitySlots),
|
||||||
talentRanks,
|
talentRanks,
|
||||||
inventory: clone(profile.inventory),
|
inventory: clone(profile.inventory),
|
||||||
}
|
}
|
||||||
@@ -718,7 +763,7 @@ const serverRepository: GameRepository = {
|
|||||||
),
|
),
|
||||||
saveProfile: (classId, abilitySlots) =>
|
saveProfile: (classId, abilitySlots) =>
|
||||||
cachedOnlineLocalRepository.saveProfile(classId, abilitySlots),
|
cachedOnlineLocalRepository.saveProfile(classId, abilitySlots),
|
||||||
completeDungeon: (dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds) =>
|
completeDungeon: (dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds, hardMode) =>
|
||||||
cachedOnlineLocalRepository.completeDungeon(
|
cachedOnlineLocalRepository.completeDungeon(
|
||||||
dungeonId,
|
dungeonId,
|
||||||
difficultyId,
|
difficultyId,
|
||||||
@@ -727,6 +772,7 @@ const serverRepository: GameRepository = {
|
|||||||
completedPart,
|
completedPart,
|
||||||
startPart,
|
startPart,
|
||||||
partDurationSeconds,
|
partDurationSeconds,
|
||||||
|
hardMode,
|
||||||
),
|
),
|
||||||
completeRoguelike: (dungeonId, difficultyId, encountersCleared, resourceSpent, durationSeconds, options) =>
|
completeRoguelike: (dungeonId, difficultyId, encountersCleared, resourceSpent, durationSeconds, options) =>
|
||||||
cachedOnlineLocalRepository.completeRoguelike(
|
cachedOnlineLocalRepository.completeRoguelike(
|
||||||
@@ -763,9 +809,9 @@ function emptyCharacterData(classId: number): CharacterData {
|
|||||||
const inventory: Item[] = []
|
const inventory: Item[] = []
|
||||||
const startingAbilitySlots: Array<number | null> = gc.spells
|
const startingAbilitySlots: Array<number | null> = gc.spells
|
||||||
.filter((s) => s.unlockLevel === 1)
|
.filter((s) => s.unlockLevel === 1)
|
||||||
.slice(0, 5)
|
.slice(0, ABILITY_SLOT_COUNT)
|
||||||
.map((s) => s.id)
|
.map((s) => s.id)
|
||||||
while (startingAbilitySlots.length < 6) startingAbilitySlots.push(null)
|
while (startingAbilitySlots.length < ABILITY_SLOT_COUNT) startingAbilitySlots.push(null)
|
||||||
return {
|
return {
|
||||||
level: 1,
|
level: 1,
|
||||||
experience: 0,
|
experience: 0,
|
||||||
@@ -809,8 +855,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
|||||||
const gameClass = static_.classes.find((candidate) => candidate.id === classId)
|
const gameClass = static_.classes.find((candidate) => candidate.id === classId)
|
||||||
if (!gameClass) throw new Error('Selected class does not exist.')
|
if (!gameClass) throw new Error('Selected class does not exist.')
|
||||||
|
|
||||||
const slots = abilitySlots.slice(0, 6)
|
const slots = normalizeAbilitySlots(abilitySlots)
|
||||||
while (slots.length < 6) slots.push(null)
|
|
||||||
const selectedIds = slots.filter((id): id is number => id !== null)
|
const selectedIds = slots.filter((id): id is number => id !== null)
|
||||||
if (new Set(selectedIds).size !== selectedIds.length) {
|
if (new Set(selectedIds).size !== selectedIds.length) {
|
||||||
throw new Error('The same ability cannot be equipped twice.')
|
throw new Error('The same ability cannot be equipped twice.')
|
||||||
@@ -833,7 +878,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
|||||||
store.writeSave(save)
|
store.writeSave(save)
|
||||||
return buildProfile(save)
|
return buildProfile(save)
|
||||||
},
|
},
|
||||||
async completeDungeon(dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds) {
|
async completeDungeon(dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds, hardMode) {
|
||||||
void startPart
|
void startPart
|
||||||
void partDurationSeconds
|
void partDurationSeconds
|
||||||
if (!Number.isInteger(resourceSpent) || resourceSpent < 0) {
|
if (!Number.isInteger(resourceSpent) || resourceSpent < 0) {
|
||||||
@@ -859,8 +904,15 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
|||||||
const previousLevel = cd.level
|
const previousLevel = cd.level
|
||||||
const previousExperience = cd.experience
|
const previousExperience = cd.experience
|
||||||
const partCount = completedPart ?? 1
|
const partCount = completedPart ?? 1
|
||||||
const experienceReward = Math.round(
|
const rewardMultiplier = hardMode ? 2 : 1
|
||||||
dungeon.experienceReward * difficulty.experienceMultiplier * partCount,
|
const baseExperienceReward = Math.round(
|
||||||
|
dungeon.experienceReward * difficulty.experienceMultiplier * partCount * rewardMultiplier,
|
||||||
|
)
|
||||||
|
const experienceReward = catchUpExperienceReward(
|
||||||
|
baseExperienceReward,
|
||||||
|
previousExperience,
|
||||||
|
previousLevel,
|
||||||
|
highestOtherClassLevel(save),
|
||||||
)
|
)
|
||||||
const maxExperience = experienceForLevel(profile.maxLevel)
|
const maxExperience = experienceForLevel(profile.maxLevel)
|
||||||
const newExperience = Math.min(previousExperience + experienceReward, maxExperience)
|
const newExperience = Math.min(previousExperience + experienceReward, maxExperience)
|
||||||
@@ -908,19 +960,20 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
|||||||
const selected = rewardPool[Math.floor(Math.random() * rewardPool.length)]
|
const selected = rewardPool[Math.floor(Math.random() * rewardPool.length)]
|
||||||
const existing = profile.inventory.find((item) => item.id === selected.id)
|
const existing = profile.inventory.find((item) => item.id === selected.id)
|
||||||
const duplicate = Boolean(existing)
|
const duplicate = Boolean(existing)
|
||||||
let quantityAfter = 1
|
const rewardQuantity = rewardMultiplier
|
||||||
|
let quantityAfter = rewardQuantity
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.quantity += 1
|
existing.quantity += rewardQuantity
|
||||||
quantityAfter = existing.quantity
|
quantityAfter = existing.quantity
|
||||||
} else {
|
} else {
|
||||||
profile.inventory.push({
|
profile.inventory.push({
|
||||||
...selected,
|
...selected,
|
||||||
quantity: 1,
|
quantity: rewardQuantity,
|
||||||
equipped: false,
|
equipped: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
cd.inventory = profile.inventory
|
cd.inventory = profile.inventory
|
||||||
bonusItem = { ...selected, quantity: 1, duplicate, quantityAfter }
|
bonusItem = { ...selected, quantity: rewardQuantity, duplicate, quantityAfter }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -973,13 +1026,19 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
|||||||
const maxExperience = experienceForLevel(profile.maxLevel)
|
const maxExperience = experienceForLevel(profile.maxLevel)
|
||||||
const bossesCleared = Math.max(0, Math.floor(options?.bossesCleared ?? encountersCleared / 3))
|
const bossesCleared = Math.max(0, Math.floor(options?.bossesCleared ?? encountersCleared / 3))
|
||||||
const scaledReward = options?.experienceMode === 'pvp-boss-quarter-level'
|
const scaledReward = options?.experienceMode === 'pvp-boss-quarter-level'
|
||||||
? scaledPvpBossExperience(previousExperience, previousLevel, bossesCleared, profile.maxLevel)
|
? scaledPvpBossExperience(previousExperience, previousLevel, bossesCleared, profile.maxLevel, highestOtherClassLevel(save))
|
||||||
: null
|
: null
|
||||||
|
const baseRoguelikeReward = Math.round(dungeon.experienceReward * difficulty.experienceMultiplier * (encountersCleared / 3))
|
||||||
const newExperience = scaledReward
|
const newExperience = scaledReward
|
||||||
? scaledReward.experience
|
? scaledReward.experience
|
||||||
: Math.min(
|
: Math.min(
|
||||||
previousExperience
|
previousExperience
|
||||||
+ Math.round(dungeon.experienceReward * difficulty.experienceMultiplier * (encountersCleared / 3)),
|
+ catchUpExperienceReward(
|
||||||
|
baseRoguelikeReward,
|
||||||
|
previousExperience,
|
||||||
|
previousLevel,
|
||||||
|
highestOtherClassLevel(save),
|
||||||
|
),
|
||||||
maxExperience,
|
maxExperience,
|
||||||
)
|
)
|
||||||
let newLevel = scaledReward?.level ?? previousLevel
|
let newLevel = scaledReward?.level ?? previousLevel
|
||||||
@@ -1163,11 +1222,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
|||||||
const profile = buildProfile(save)
|
const profile = buildProfile(save)
|
||||||
const recipe = profile.craftingRecipes.find((candidate) => candidate.id === recipeId)
|
const recipe = profile.craftingRecipes.find((candidate) => candidate.id === recipeId)
|
||||||
if (!recipe) throw new Error('That crafting recipe does not exist.')
|
if (!recipe) throw new Error('That crafting recipe does not exist.')
|
||||||
const requiresUpgrade = profile.craftingRecipes.some((candidate) =>
|
const requiresUpgrade = !DIRECT_CRAFT_ITEM_LEVELS.has(recipe.item.itemLevel)
|
||||||
candidate.sourceEncounterId === recipe.sourceEncounterId
|
|
||||||
&& candidate.item.slot === recipe.item.slot
|
|
||||||
&& candidate.item.itemLevel < recipe.item.itemLevel,
|
|
||||||
)
|
|
||||||
if (requiresUpgrade) throw new Error('Upgrade the previous item tier instead.')
|
if (requiresUpgrade) throw new Error('Upgrade the previous item tier instead.')
|
||||||
const missing = recipe.components.find((component) => component.owned < component.quantity)
|
const missing = recipe.components.find((component) => component.owned < component.quantity)
|
||||||
if (missing) {
|
if (missing) {
|
||||||
@@ -1363,7 +1418,7 @@ const cachedOnlineRepository: GameRepository = {
|
|||||||
},
|
},
|
||||||
loadProfile: () => cachedOnlineLocalRepository.loadProfile(),
|
loadProfile: () => cachedOnlineLocalRepository.loadProfile(),
|
||||||
saveProfile: (classId, abilitySlots) => cachedOnlineLocalRepository.saveProfile(classId, abilitySlots),
|
saveProfile: (classId, abilitySlots) => cachedOnlineLocalRepository.saveProfile(classId, abilitySlots),
|
||||||
completeDungeon: (dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds) =>
|
completeDungeon: (dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds, hardMode) =>
|
||||||
cachedOnlineLocalRepository.completeDungeon(
|
cachedOnlineLocalRepository.completeDungeon(
|
||||||
dungeonId,
|
dungeonId,
|
||||||
difficultyId,
|
difficultyId,
|
||||||
@@ -1372,6 +1427,7 @@ const cachedOnlineRepository: GameRepository = {
|
|||||||
completedPart,
|
completedPart,
|
||||||
startPart,
|
startPart,
|
||||||
partDurationSeconds,
|
partDurationSeconds,
|
||||||
|
hardMode,
|
||||||
),
|
),
|
||||||
completeRoguelike: (dungeonId, difficultyId, encountersCleared, resourceSpent, durationSeconds, options) =>
|
completeRoguelike: (dungeonId, difficultyId, encountersCleared, resourceSpent, durationSeconds, options) =>
|
||||||
cachedOnlineLocalRepository.completeRoguelike(
|
cachedOnlineLocalRepository.completeRoguelike(
|
||||||
|
|||||||
@@ -121,7 +121,6 @@ const STORAGE_KEY = 'ashen-halls-input-bindings-v1'
|
|||||||
const PREFERENCES_STORAGE_KEY = 'ashen-halls-input-preferences-v1'
|
const PREFERENCES_STORAGE_KEY = 'ashen-halls-input-preferences-v1'
|
||||||
const GAME_ACTION_EVENT = 'ashen-halls-game-action'
|
const GAME_ACTION_EVENT = 'ashen-halls-game-action'
|
||||||
const NATIVE_CONTROLLER_EVENT = 'ashen-halls-native-controller'
|
const NATIVE_CONTROLLER_EVENT = 'ashen-halls-native-controller'
|
||||||
const COMBAT_TARGET_NAVIGATION_THROTTLE_MS = 220
|
|
||||||
|
|
||||||
type CaptureState = {
|
type CaptureState = {
|
||||||
device: InputDevice
|
device: InputDevice
|
||||||
@@ -277,14 +276,6 @@ function hasUiOverlay() {
|
|||||||
).some(isVisible)
|
).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> = {
|
const BUTTON_LABELS: Record<number, string> = {
|
||||||
0: 'A / Cross',
|
0: 'A / Cross',
|
||||||
1: 'B / Circle',
|
1: 'B / Circle',
|
||||||
@@ -398,7 +389,6 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
|||||||
const keyboardInputRef = useRef(keyboardInput)
|
const keyboardInputRef = useRef(keyboardInput)
|
||||||
const previousTokensRef = useRef(new Set<string>())
|
const previousTokensRef = useRef(new Set<string>())
|
||||||
const repeatRef = useRef<Record<string, number>>({})
|
const repeatRef = useRef<Record<string, number>>({})
|
||||||
const lastCombatNavigationRef = useRef(0)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
bindingsRef.current = bindings
|
bindingsRef.current = bindings
|
||||||
@@ -445,11 +435,6 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
|||||||
const dispatchAction = useCallback((action: InputAction, device: InputDevice) => {
|
const dispatchAction = useCallback((action: InputAction, device: InputDevice) => {
|
||||||
const uiOverlay = hasUiOverlay()
|
const uiOverlay = hasUiOverlay()
|
||||||
const combatActive = Boolean(document.querySelector('[data-combat-active="true"]'))
|
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)
|
setLastDevice(device)
|
||||||
document.documentElement.dataset.inputDevice = device
|
document.documentElement.dataset.inputDevice = device
|
||||||
|
|||||||
@@ -62,7 +62,7 @@
|
|||||||
"power": 18,
|
"power": 18,
|
||||||
"unlockLevel": 1,
|
"unlockLevel": 1,
|
||||||
"glyph": "*",
|
"glyph": "*",
|
||||||
"description": "Restores health to every living party member."
|
"description": "Restores health to up to 4 injured party members."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 4,
|
"id": 4,
|
||||||
@@ -101,7 +101,7 @@
|
|||||||
"power": 28,
|
"power": 28,
|
||||||
"unlockLevel": 5,
|
"unlockLevel": 5,
|
||||||
"glyph": "D",
|
"glyph": "D",
|
||||||
"description": "A brilliant wave of healing for the entire party."
|
"description": "A brilliant wave of healing for up to 4 injured allies."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 7,
|
"id": 7,
|
||||||
@@ -140,7 +140,7 @@
|
|||||||
"power": 48,
|
"power": 48,
|
||||||
"unlockLevel": 20,
|
"unlockLevel": 20,
|
||||||
"glyph": "A",
|
"glyph": "A",
|
||||||
"description": "Floods the party with the full strength of dawn."
|
"description": "Floods up to 4 injured allies with the full strength of dawn."
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"talents": [
|
"talents": [
|
||||||
@@ -313,13 +313,13 @@
|
|||||||
"classId": 2,
|
"classId": 2,
|
||||||
"slug": "verdant-touch",
|
"slug": "verdant-touch",
|
||||||
"name": "Verdant Touch",
|
"name": "Verdant Touch",
|
||||||
"spellType": "direct_heal",
|
"spellType": "direct_hot",
|
||||||
"cost": 5,
|
"cost": 5,
|
||||||
"cooldown": 0.5,
|
"cooldown": 0.5,
|
||||||
"power": 28,
|
"power": 20,
|
||||||
"unlockLevel": 1,
|
"unlockLevel": 1,
|
||||||
"glyph": "+",
|
"glyph": "+",
|
||||||
"description": "A quick pulse of living energy."
|
"description": "A weaker direct heal that also plants a stacking heal over time."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 21,
|
"id": 21,
|
||||||
@@ -338,27 +338,27 @@
|
|||||||
"id": 22,
|
"id": 22,
|
||||||
"classId": 2,
|
"classId": 2,
|
||||||
"slug": "wild-bloom",
|
"slug": "wild-bloom",
|
||||||
"name": "Wild Bloom",
|
"name": "Wild Growth",
|
||||||
"spellType": "party_heal",
|
"spellType": "party_hot",
|
||||||
"cost": 12,
|
"cost": 12,
|
||||||
"cooldown": 8,
|
"cooldown": 8,
|
||||||
"power": 17,
|
"power": 14,
|
||||||
"unlockLevel": 1,
|
"unlockLevel": 1,
|
||||||
"glyph": "*",
|
"glyph": "*",
|
||||||
"description": "Restorative growth spreads through the party."
|
"description": "Applies a stacking heal over time to up to 4 injured allies."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 23,
|
"id": 23,
|
||||||
"classId": 2,
|
"classId": 2,
|
||||||
"slug": "barkskin",
|
"slug": "barkskin",
|
||||||
"name": "Barkskin",
|
"name": "Barkskin",
|
||||||
"spellType": "absorb",
|
"spellType": "damage_reduction",
|
||||||
"cost": 8,
|
"cost": 10,
|
||||||
"cooldown": 7,
|
"cooldown": 14,
|
||||||
"power": 34,
|
"power": 0,
|
||||||
"unlockLevel": 1,
|
"unlockLevel": 1,
|
||||||
"glyph": "B",
|
"glyph": "B",
|
||||||
"description": "Wraps an ally in protective living bark."
|
"description": "Reduces the target ally's damage taken by 50% for 8 seconds."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 24,
|
"id": 24,
|
||||||
@@ -378,13 +378,13 @@
|
|||||||
"classId": 2,
|
"classId": 2,
|
||||||
"slug": "ancient-grove",
|
"slug": "ancient-grove",
|
||||||
"name": "Ancient Grove",
|
"name": "Ancient Grove",
|
||||||
"spellType": "party_heal",
|
"spellType": "party_hot",
|
||||||
"cost": 17,
|
"cost": 17,
|
||||||
"cooldown": 12,
|
"cooldown": 12,
|
||||||
"power": 31,
|
"power": 24,
|
||||||
"unlockLevel": 5,
|
"unlockLevel": 5,
|
||||||
"glyph": "T",
|
"glyph": "T",
|
||||||
"description": "Briefly summons the shelter of an ancient grove."
|
"description": "Applies a stronger stacking heal over time to up to 4 injured allies."
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"talents": [
|
"talents": [
|
||||||
@@ -569,27 +569,27 @@
|
|||||||
"id": 31,
|
"id": 31,
|
||||||
"classId": 3,
|
"classId": 3,
|
||||||
"slug": "echo-rune",
|
"slug": "echo-rune",
|
||||||
"name": "Echo Rune",
|
"name": "Mending Rune",
|
||||||
"spellType": "heal_over_time",
|
"spellType": "bounce_heal",
|
||||||
"cost": 7,
|
"cost": 7,
|
||||||
"cooldown": 0.5,
|
"cooldown": 0.5,
|
||||||
"power": 12,
|
"power": 18,
|
||||||
"unlockLevel": 1,
|
"unlockLevel": 1,
|
||||||
"glyph": "e",
|
"glyph": "e",
|
||||||
"description": "Repeats a restorative rune over several moments."
|
"description": "Places a rune that heals when the ally takes damage, then jumps 4 times."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 32,
|
"id": 32,
|
||||||
"classId": 3,
|
"classId": 3,
|
||||||
"slug": "concordance",
|
"slug": "concordance",
|
||||||
"name": "Concordance",
|
"name": "Concordance",
|
||||||
"spellType": "party_heal",
|
"spellType": "party_absorb",
|
||||||
"cost": 12,
|
"cost": 12,
|
||||||
"cooldown": 8,
|
"cooldown": 8,
|
||||||
"power": 18,
|
"power": 28,
|
||||||
"unlockLevel": 1,
|
"unlockLevel": 1,
|
||||||
"glyph": "*",
|
"glyph": "*",
|
||||||
"description": "Links the party through a shared healing pattern."
|
"description": "Shields up to 4 injured allies through a shared barrier pattern."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 33,
|
"id": 33,
|
||||||
@@ -622,13 +622,13 @@
|
|||||||
"classId": 3,
|
"classId": 3,
|
||||||
"slug": "grand-design",
|
"slug": "grand-design",
|
||||||
"name": "Grand Design",
|
"name": "Grand Design",
|
||||||
"spellType": "party_heal",
|
"spellType": "party_absorb",
|
||||||
"cost": 16,
|
"cost": 16,
|
||||||
"cooldown": 12,
|
"cooldown": 12,
|
||||||
"power": 30,
|
"power": 42,
|
||||||
"unlockLevel": 5,
|
"unlockLevel": 5,
|
||||||
"glyph": "R",
|
"glyph": "R",
|
||||||
"description": "Activates a prepared network of restorative runes."
|
"description": "Raises a stronger shared barrier around up to 4 injured allies."
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"talents": [
|
"talents": [
|
||||||
@@ -1221,7 +1221,7 @@
|
|||||||
"slug": "cinderstep-boots",
|
"slug": "cinderstep-boots",
|
||||||
"name": "Honed Yian Kut-Ku Boots",
|
"name": "Honed Yian Kut-Ku Boots",
|
||||||
"slot": "boots",
|
"slot": "boots",
|
||||||
"rarity": "common",
|
"rarity": "uncommon",
|
||||||
"itemLevel": 5,
|
"itemLevel": 5,
|
||||||
"healingPower": 3,
|
"healingPower": 3,
|
||||||
"maxResourceBonus": 0,
|
"maxResourceBonus": 0,
|
||||||
@@ -1261,7 +1261,7 @@
|
|||||||
"slug": "wardens-cinderwrap",
|
"slug": "wardens-cinderwrap",
|
||||||
"name": "Honed Bulldrome Chest",
|
"name": "Honed Bulldrome Chest",
|
||||||
"slot": "chest",
|
"slot": "chest",
|
||||||
"rarity": "common",
|
"rarity": "uncommon",
|
||||||
"itemLevel": 5,
|
"itemLevel": 5,
|
||||||
"healingPower": 3,
|
"healingPower": 3,
|
||||||
"maxResourceBonus": 0,
|
"maxResourceBonus": 0,
|
||||||
@@ -1301,7 +1301,7 @@
|
|||||||
"slug": "furnace-tenders-wraps",
|
"slug": "furnace-tenders-wraps",
|
||||||
"name": "Honed Bulldrome Gloves",
|
"name": "Honed Bulldrome Gloves",
|
||||||
"slot": "gloves",
|
"slot": "gloves",
|
||||||
"rarity": "common",
|
"rarity": "uncommon",
|
||||||
"itemLevel": 5,
|
"itemLevel": 5,
|
||||||
"healingPower": 3,
|
"healingPower": 3,
|
||||||
"maxResourceBonus": 2,
|
"maxResourceBonus": 2,
|
||||||
@@ -1341,7 +1341,7 @@
|
|||||||
"slug": "adepts-hood",
|
"slug": "adepts-hood",
|
||||||
"name": "Honed Bulldrome Helmet",
|
"name": "Honed Bulldrome Helmet",
|
||||||
"slot": "helmet",
|
"slot": "helmet",
|
||||||
"rarity": "common",
|
"rarity": "uncommon",
|
||||||
"itemLevel": 5,
|
"itemLevel": 5,
|
||||||
"healingPower": 3,
|
"healingPower": 3,
|
||||||
"maxResourceBonus": 4,
|
"maxResourceBonus": 4,
|
||||||
@@ -1381,7 +1381,7 @@
|
|||||||
"slug": "sootglass-pendant",
|
"slug": "sootglass-pendant",
|
||||||
"name": "Honed Rathian Necklace",
|
"name": "Honed Rathian Necklace",
|
||||||
"slot": "necklace",
|
"slot": "necklace",
|
||||||
"rarity": "common",
|
"rarity": "uncommon",
|
||||||
"itemLevel": 5,
|
"itemLevel": 5,
|
||||||
"healingPower": 4,
|
"healingPower": 4,
|
||||||
"maxResourceBonus": 4,
|
"maxResourceBonus": 4,
|
||||||
@@ -1421,7 +1421,7 @@
|
|||||||
"slug": "ashwalker-legwraps",
|
"slug": "ashwalker-legwraps",
|
||||||
"name": "Honed Rathian Pants",
|
"name": "Honed Rathian Pants",
|
||||||
"slot": "pants",
|
"slot": "pants",
|
||||||
"rarity": "common",
|
"rarity": "uncommon",
|
||||||
"itemLevel": 5,
|
"itemLevel": 5,
|
||||||
"healingPower": 3,
|
"healingPower": 3,
|
||||||
"maxResourceBonus": 3,
|
"maxResourceBonus": 3,
|
||||||
@@ -1461,7 +1461,7 @@
|
|||||||
"slug": "emberglass-sigil",
|
"slug": "emberglass-sigil",
|
||||||
"name": "Honed Yian Kut-Ku Ring",
|
"name": "Honed Yian Kut-Ku Ring",
|
||||||
"slot": "ring",
|
"slot": "ring",
|
||||||
"rarity": "common",
|
"rarity": "uncommon",
|
||||||
"itemLevel": 5,
|
"itemLevel": 5,
|
||||||
"healingPower": 4,
|
"healingPower": 4,
|
||||||
"maxResourceBonus": 5,
|
"maxResourceBonus": 5,
|
||||||
@@ -1501,7 +1501,7 @@
|
|||||||
"slug": "warden-ember",
|
"slug": "warden-ember",
|
||||||
"name": "Honed Yian Kut-Ku Trinket",
|
"name": "Honed Yian Kut-Ku Trinket",
|
||||||
"slot": "trinket",
|
"slot": "trinket",
|
||||||
"rarity": "common",
|
"rarity": "uncommon",
|
||||||
"itemLevel": 5,
|
"itemLevel": 5,
|
||||||
"healingPower": 4,
|
"healingPower": 4,
|
||||||
"maxResourceBonus": 4,
|
"maxResourceBonus": 4,
|
||||||
@@ -1541,7 +1541,7 @@
|
|||||||
"slug": "ashwood-crook",
|
"slug": "ashwood-crook",
|
||||||
"name": "Honed Rathian Weapon",
|
"name": "Honed Rathian Weapon",
|
||||||
"slot": "weapon",
|
"slot": "weapon",
|
||||||
"rarity": "common",
|
"rarity": "uncommon",
|
||||||
"itemLevel": 5,
|
"itemLevel": 5,
|
||||||
"healingPower": 5,
|
"healingPower": 5,
|
||||||
"maxResourceBonus": 0,
|
"maxResourceBonus": 0,
|
||||||
@@ -4312,7 +4312,152 @@
|
|||||||
"description": "A three-boss hunt featuring Nargacuga, Azuros, and Diablos.",
|
"description": "A three-boss hunt featuring Nargacuga, Azuros, and Diablos.",
|
||||||
"locationName": "The Monster Frontier",
|
"locationName": "The Monster Frontier",
|
||||||
"difficulties": [],
|
"difficulties": [],
|
||||||
"encounters": [],
|
"encounters": [
|
||||||
|
{
|
||||||
|
"id": 401,
|
||||||
|
"dungeonId": 4,
|
||||||
|
"sequence": 1,
|
||||||
|
"slug": "nargacuga-dungeon-approach",
|
||||||
|
"enemyName": "Nargacuga Approach",
|
||||||
|
"encounterType": "trash",
|
||||||
|
"maxHealth": 1325,
|
||||||
|
"damage": 18,
|
||||||
|
"tankDamage": 10,
|
||||||
|
"partyDamage": 28,
|
||||||
|
"description": "Hunters clear the path before Nargacuga.",
|
||||||
|
"imageUrl": "/boss-placeholder.svg",
|
||||||
|
"isBoss": false,
|
||||||
|
"lootTables": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 402,
|
||||||
|
"dungeonId": 4,
|
||||||
|
"sequence": 2,
|
||||||
|
"slug": "nargacuga-dungeon-guardians",
|
||||||
|
"enemyName": "Nargacuga Guardians",
|
||||||
|
"encounterType": "trash",
|
||||||
|
"maxHealth": 1395,
|
||||||
|
"damage": 19,
|
||||||
|
"tankDamage": 11,
|
||||||
|
"partyDamage": 30,
|
||||||
|
"description": "Hunters clear the path before Nargacuga.",
|
||||||
|
"imageUrl": "/boss-placeholder.svg",
|
||||||
|
"isBoss": false,
|
||||||
|
"lootTables": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 403,
|
||||||
|
"dungeonId": 4,
|
||||||
|
"sequence": 3,
|
||||||
|
"slug": "nargacuga-dungeon-boss",
|
||||||
|
"enemyName": "Nargacuga",
|
||||||
|
"encounterType": "boss",
|
||||||
|
"maxHealth": 1655,
|
||||||
|
"damage": 23,
|
||||||
|
"tankDamage": 15,
|
||||||
|
"partyDamage": 34,
|
||||||
|
"description": "Nargacuga drops boss coins for item level 15 crafting.",
|
||||||
|
"imageUrl": "/boss-placeholder.svg",
|
||||||
|
"isBoss": true,
|
||||||
|
"lootTables": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 404,
|
||||||
|
"dungeonId": 4,
|
||||||
|
"sequence": 4,
|
||||||
|
"slug": "azuros-dungeon-approach",
|
||||||
|
"enemyName": "Azuros Approach",
|
||||||
|
"encounterType": "trash",
|
||||||
|
"maxHealth": 1445,
|
||||||
|
"damage": 20,
|
||||||
|
"tankDamage": 12,
|
||||||
|
"partyDamage": 31,
|
||||||
|
"description": "Hunters clear the path before Azuros.",
|
||||||
|
"imageUrl": "/boss-placeholder.svg",
|
||||||
|
"isBoss": false,
|
||||||
|
"lootTables": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 405,
|
||||||
|
"dungeonId": 4,
|
||||||
|
"sequence": 5,
|
||||||
|
"slug": "azuros-dungeon-guardians",
|
||||||
|
"enemyName": "Azuros Guardians",
|
||||||
|
"encounterType": "trash",
|
||||||
|
"maxHealth": 1515,
|
||||||
|
"damage": 21,
|
||||||
|
"tankDamage": 13,
|
||||||
|
"partyDamage": 33,
|
||||||
|
"description": "Hunters clear the path before Azuros.",
|
||||||
|
"imageUrl": "/boss-placeholder.svg",
|
||||||
|
"isBoss": false,
|
||||||
|
"lootTables": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 406,
|
||||||
|
"dungeonId": 4,
|
||||||
|
"sequence": 6,
|
||||||
|
"slug": "azuros-dungeon-boss",
|
||||||
|
"enemyName": "Azuros",
|
||||||
|
"encounterType": "boss",
|
||||||
|
"maxHealth": 1775,
|
||||||
|
"damage": 25,
|
||||||
|
"tankDamage": 17,
|
||||||
|
"partyDamage": 37,
|
||||||
|
"description": "Azuros drops boss coins for item level 15 crafting.",
|
||||||
|
"imageUrl": "/boss-placeholder.svg",
|
||||||
|
"isBoss": true,
|
||||||
|
"lootTables": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 407,
|
||||||
|
"dungeonId": 4,
|
||||||
|
"sequence": 7,
|
||||||
|
"slug": "diablos-dungeon-approach",
|
||||||
|
"enemyName": "Diablos Approach",
|
||||||
|
"encounterType": "trash",
|
||||||
|
"maxHealth": 1565,
|
||||||
|
"damage": 22,
|
||||||
|
"tankDamage": 14,
|
||||||
|
"partyDamage": 34,
|
||||||
|
"description": "Hunters clear the path before Diablos.",
|
||||||
|
"imageUrl": "/boss-placeholder.svg",
|
||||||
|
"isBoss": false,
|
||||||
|
"lootTables": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 408,
|
||||||
|
"dungeonId": 4,
|
||||||
|
"sequence": 8,
|
||||||
|
"slug": "diablos-dungeon-guardians",
|
||||||
|
"enemyName": "Diablos Guardians",
|
||||||
|
"encounterType": "trash",
|
||||||
|
"maxHealth": 1635,
|
||||||
|
"damage": 23,
|
||||||
|
"tankDamage": 15,
|
||||||
|
"partyDamage": 36,
|
||||||
|
"description": "Hunters clear the path before Diablos.",
|
||||||
|
"imageUrl": "/boss-placeholder.svg",
|
||||||
|
"isBoss": false,
|
||||||
|
"lootTables": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 409,
|
||||||
|
"dungeonId": 4,
|
||||||
|
"sequence": 9,
|
||||||
|
"slug": "diablos-dungeon-boss",
|
||||||
|
"enemyName": "Diablos",
|
||||||
|
"encounterType": "boss",
|
||||||
|
"maxHealth": 1895,
|
||||||
|
"damage": 27,
|
||||||
|
"tankDamage": 19,
|
||||||
|
"partyDamage": 40,
|
||||||
|
"description": "Diablos drops boss coins for item level 15 crafting.",
|
||||||
|
"imageUrl": "/boss-placeholder.svg",
|
||||||
|
"isBoss": true,
|
||||||
|
"lootTables": []
|
||||||
|
}
|
||||||
|
],
|
||||||
"completionLoot": [],
|
"completionLoot": [],
|
||||||
"leaderboard": [],
|
"leaderboard": [],
|
||||||
"leaderboards": {
|
"leaderboards": {
|
||||||
@@ -4334,7 +4479,152 @@
|
|||||||
"description": "A raid-scale hunt against Nargacuga, Azuros, and Diablos.",
|
"description": "A raid-scale hunt against Nargacuga, Azuros, and Diablos.",
|
||||||
"locationName": "The Monster Frontier",
|
"locationName": "The Monster Frontier",
|
||||||
"difficulties": [],
|
"difficulties": [],
|
||||||
"encounters": [],
|
"encounters": [
|
||||||
|
{
|
||||||
|
"id": 501,
|
||||||
|
"dungeonId": 5,
|
||||||
|
"sequence": 1,
|
||||||
|
"slug": "nargacuga-raid-approach",
|
||||||
|
"enemyName": "Nargacuga Approach",
|
||||||
|
"encounterType": "trash",
|
||||||
|
"maxHealth": 2225,
|
||||||
|
"damage": 18,
|
||||||
|
"tankDamage": 10,
|
||||||
|
"partyDamage": 52,
|
||||||
|
"description": "Hunters clear the raid path before Nargacuga.",
|
||||||
|
"imageUrl": "/boss-placeholder.svg",
|
||||||
|
"isBoss": false,
|
||||||
|
"lootTables": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 502,
|
||||||
|
"dungeonId": 5,
|
||||||
|
"sequence": 2,
|
||||||
|
"slug": "nargacuga-raid-guardians",
|
||||||
|
"enemyName": "Nargacuga Guardians",
|
||||||
|
"encounterType": "trash",
|
||||||
|
"maxHealth": 2295,
|
||||||
|
"damage": 19,
|
||||||
|
"tankDamage": 11,
|
||||||
|
"partyDamage": 54,
|
||||||
|
"description": "Hunters clear the raid path before Nargacuga.",
|
||||||
|
"imageUrl": "/boss-placeholder.svg",
|
||||||
|
"isBoss": false,
|
||||||
|
"lootTables": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 503,
|
||||||
|
"dungeonId": 5,
|
||||||
|
"sequence": 3,
|
||||||
|
"slug": "nargacuga-raid-boss",
|
||||||
|
"enemyName": "Nargacuga",
|
||||||
|
"encounterType": "boss",
|
||||||
|
"maxHealth": 2555,
|
||||||
|
"damage": 23,
|
||||||
|
"tankDamage": 15,
|
||||||
|
"partyDamage": 58,
|
||||||
|
"description": "Nargacuga drops boss coins for item level 15 crafting.",
|
||||||
|
"imageUrl": "/boss-placeholder.svg",
|
||||||
|
"isBoss": true,
|
||||||
|
"lootTables": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 504,
|
||||||
|
"dungeonId": 5,
|
||||||
|
"sequence": 4,
|
||||||
|
"slug": "azuros-raid-approach",
|
||||||
|
"enemyName": "Azuros Approach",
|
||||||
|
"encounterType": "trash",
|
||||||
|
"maxHealth": 2345,
|
||||||
|
"damage": 20,
|
||||||
|
"tankDamage": 12,
|
||||||
|
"partyDamage": 55,
|
||||||
|
"description": "Hunters clear the raid path before Azuros.",
|
||||||
|
"imageUrl": "/boss-placeholder.svg",
|
||||||
|
"isBoss": false,
|
||||||
|
"lootTables": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 505,
|
||||||
|
"dungeonId": 5,
|
||||||
|
"sequence": 5,
|
||||||
|
"slug": "azuros-raid-guardians",
|
||||||
|
"enemyName": "Azuros Guardians",
|
||||||
|
"encounterType": "trash",
|
||||||
|
"maxHealth": 2415,
|
||||||
|
"damage": 21,
|
||||||
|
"tankDamage": 13,
|
||||||
|
"partyDamage": 57,
|
||||||
|
"description": "Hunters clear the raid path before Azuros.",
|
||||||
|
"imageUrl": "/boss-placeholder.svg",
|
||||||
|
"isBoss": false,
|
||||||
|
"lootTables": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 506,
|
||||||
|
"dungeonId": 5,
|
||||||
|
"sequence": 6,
|
||||||
|
"slug": "azuros-raid-boss",
|
||||||
|
"enemyName": "Azuros",
|
||||||
|
"encounterType": "boss",
|
||||||
|
"maxHealth": 2675,
|
||||||
|
"damage": 25,
|
||||||
|
"tankDamage": 17,
|
||||||
|
"partyDamage": 61,
|
||||||
|
"description": "Azuros drops boss coins for item level 15 crafting.",
|
||||||
|
"imageUrl": "/boss-placeholder.svg",
|
||||||
|
"isBoss": true,
|
||||||
|
"lootTables": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 507,
|
||||||
|
"dungeonId": 5,
|
||||||
|
"sequence": 7,
|
||||||
|
"slug": "diablos-raid-approach",
|
||||||
|
"enemyName": "Diablos Approach",
|
||||||
|
"encounterType": "trash",
|
||||||
|
"maxHealth": 2465,
|
||||||
|
"damage": 22,
|
||||||
|
"tankDamage": 14,
|
||||||
|
"partyDamage": 58,
|
||||||
|
"description": "Hunters clear the raid path before Diablos.",
|
||||||
|
"imageUrl": "/boss-placeholder.svg",
|
||||||
|
"isBoss": false,
|
||||||
|
"lootTables": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 508,
|
||||||
|
"dungeonId": 5,
|
||||||
|
"sequence": 8,
|
||||||
|
"slug": "diablos-raid-guardians",
|
||||||
|
"enemyName": "Diablos Guardians",
|
||||||
|
"encounterType": "trash",
|
||||||
|
"maxHealth": 2535,
|
||||||
|
"damage": 23,
|
||||||
|
"tankDamage": 15,
|
||||||
|
"partyDamage": 60,
|
||||||
|
"description": "Hunters clear the raid path before Diablos.",
|
||||||
|
"imageUrl": "/boss-placeholder.svg",
|
||||||
|
"isBoss": false,
|
||||||
|
"lootTables": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 509,
|
||||||
|
"dungeonId": 5,
|
||||||
|
"sequence": 9,
|
||||||
|
"slug": "diablos-raid-boss",
|
||||||
|
"enemyName": "Diablos",
|
||||||
|
"encounterType": "boss",
|
||||||
|
"maxHealth": 2795,
|
||||||
|
"damage": 27,
|
||||||
|
"tankDamage": 19,
|
||||||
|
"partyDamage": 64,
|
||||||
|
"description": "Diablos drops boss coins for item level 15 crafting.",
|
||||||
|
"imageUrl": "/boss-placeholder.svg",
|
||||||
|
"isBoss": true,
|
||||||
|
"lootTables": []
|
||||||
|
}
|
||||||
|
],
|
||||||
"completionLoot": [],
|
"completionLoot": [],
|
||||||
"leaderboard": [],
|
"leaderboard": [],
|
||||||
"leaderboards": {
|
"leaderboards": {
|
||||||
|
|||||||
@@ -319,6 +319,7 @@ export async function completeDungeon(
|
|||||||
completedPart?: number,
|
completedPart?: number,
|
||||||
startPart?: number,
|
startPart?: number,
|
||||||
partDurationSeconds?: [number, number, number],
|
partDurationSeconds?: [number, number, number],
|
||||||
|
hardMode?: boolean,
|
||||||
): Promise<DungeonReward> {
|
): Promise<DungeonReward> {
|
||||||
return activeGameRepository().completeDungeon(
|
return activeGameRepository().completeDungeon(
|
||||||
dungeonId,
|
dungeonId,
|
||||||
@@ -328,6 +329,7 @@ export async function completeDungeon(
|
|||||||
completedPart,
|
completedPart,
|
||||||
startPart,
|
startPart,
|
||||||
partDurationSeconds,
|
partDurationSeconds,
|
||||||
|
hardMode,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user