Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7313c968e6 | |||
| 207fcd1a15 | |||
| fd6a1ce3c7 | |||
| f8b98e6b23 | |||
| fc7c6488ea | |||
| ba6d3b614e |
@@ -2,5 +2,7 @@
|
|||||||
|
|
||||||
- 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.
|
||||||
- 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.
@@ -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 50
|
||||||
versionName "1.0.26"
|
versionName "1.0.32"
|
||||||
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.
|
||||||
|
|||||||
+88
-13
@@ -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
|
||||||
@@ -493,9 +561,7 @@ SET name = (
|
|||||||
SELECT
|
SELECT
|
||||||
CASE items.item_level
|
CASE items.item_level
|
||||||
WHEN 1 THEN 'Raw '
|
WHEN 1 THEN 'Raw '
|
||||||
WHEN 5 THEN 'Honed '
|
|
||||||
WHEN 10 THEN 'Green '
|
WHEN 10 THEN 'Green '
|
||||||
WHEN 15 THEN 'Blue '
|
|
||||||
WHEN 20 THEN 'Purple '
|
WHEN 20 THEN 'Purple '
|
||||||
WHEN 25 THEN 'Orange '
|
WHEN 25 THEN 'Orange '
|
||||||
ELSE ''
|
ELSE ''
|
||||||
@@ -1276,12 +1342,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, 10, 20, 25)
|
||||||
|
);
|
||||||
|
|
||||||
|
DELETE FROM crafting_recipes
|
||||||
|
WHERE item_id IN (
|
||||||
|
SELECT id FROM items WHERE item_level NOT IN (1, 10, 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 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
|
||||||
@@ -1293,9 +1370,7 @@ SET name = (
|
|||||||
SELECT
|
SELECT
|
||||||
CASE items.item_level
|
CASE items.item_level
|
||||||
WHEN 1 THEN 'Raw '
|
WHEN 1 THEN 'Raw '
|
||||||
WHEN 5 THEN 'Honed '
|
|
||||||
WHEN 10 THEN 'Green '
|
WHEN 10 THEN 'Green '
|
||||||
WHEN 15 THEN 'Blue '
|
|
||||||
WHEN 20 THEN 'Purple '
|
WHEN 20 THEN 'Purple '
|
||||||
WHEN 25 THEN 'Orange '
|
WHEN 25 THEN 'Orange '
|
||||||
ELSE ''
|
ELSE ''
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+1719
-20
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>
|
||||||
|
|||||||
+260
-44
@@ -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,
|
||||||
@@ -109,6 +113,43 @@ 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 addHotEffect(member: PartyMember, spell: Spell, ticks = 5) {
|
||||||
|
return [
|
||||||
|
...memberHotEffects(member),
|
||||||
|
{
|
||||||
|
id: `${spell.id}-${crypto.randomUUID()}`,
|
||||||
|
label: spell.name,
|
||||||
|
ticks,
|
||||||
|
power: Math.max(1, Math.round(spell.power / 2)),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function addBounceHeal(member: PartyMember, spell: Spell) {
|
||||||
|
return [
|
||||||
|
...(member.bounceHeals ?? []),
|
||||||
|
{
|
||||||
|
id: `${spell.id}-${crypto.randomUUID()}`,
|
||||||
|
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
|
||||||
}
|
}
|
||||||
@@ -191,9 +232,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 +252,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 +336,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 +348,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 +399,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 +427,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 +436,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 +475,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 +535,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 +572,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 +600,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 +618,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 +637,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 +656,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 +707,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 +762,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 +775,7 @@ export function CombatScreen({
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
[difficulty.id, dungeon.id, onProfileUpdated],
|
[difficulty.id, dungeon.id, hardMode, onProfileUpdated],
|
||||||
)
|
)
|
||||||
|
|
||||||
const finishRoguelikeRun = useCallback(
|
const finishRoguelikeRun = useCallback(
|
||||||
@@ -778,6 +873,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 +894,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 +902,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 +944,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 +992,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 +1048,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 +1063,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 +1100,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 +1114,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 +1173,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 +1184,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 +1209,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 +1260,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 +1318,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 +1348,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 +1371,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 +1432,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 +1518,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 +1526,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 +1537,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 +1669,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 +1682,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 +1703,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)
|
||||||
@@ -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'
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
+99
-1
@@ -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
|
||||||
@@ -280,16 +296,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 +371,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">
|
||||||
|
|||||||
+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)
|
||||||
|
}
|
||||||
|
|||||||
+54
-19
@@ -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,
|
||||||
@@ -359,11 +360,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 +397,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 +409,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
|
||||||
@@ -718,7 +741,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 +750,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(
|
||||||
@@ -833,7 +857,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 +883,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 +939,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 +1005,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 +1201,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 +1397,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 +1406,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": [
|
||||||
@@ -1211,366 +1211,6 @@
|
|||||||
],
|
],
|
||||||
"canCraft": false
|
"canCraft": false
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"id": 1004,
|
|
||||||
"difficultyId": 1,
|
|
||||||
"sourceDungeonId": 1,
|
|
||||||
"sourceEncounterId": 12,
|
|
||||||
"item": {
|
|
||||||
"id": 4,
|
|
||||||
"slug": "cinderstep-boots",
|
|
||||||
"name": "Honed Yian Kut-Ku Boots",
|
|
||||||
"slot": "boots",
|
|
||||||
"rarity": "common",
|
|
||||||
"itemLevel": 5,
|
|
||||||
"healingPower": 3,
|
|
||||||
"maxResourceBonus": 0,
|
|
||||||
"glyph": "b",
|
|
||||||
"description": "Crafted with Yian Kut-Ku coins.",
|
|
||||||
"setId": null,
|
|
||||||
"setSlug": null,
|
|
||||||
"setName": null
|
|
||||||
},
|
|
||||||
"components": [
|
|
||||||
{
|
|
||||||
"item": {
|
|
||||||
"id": 281201,
|
|
||||||
"slug": "yian-kut-ku-coin-ilvl-1",
|
|
||||||
"name": "Raw Yian Kut-Ku Coin",
|
|
||||||
"slot": "component",
|
|
||||||
"rarity": "common",
|
|
||||||
"itemLevel": 1,
|
|
||||||
"healingPower": 0,
|
|
||||||
"maxResourceBonus": 0,
|
|
||||||
"glyph": "$",
|
|
||||||
"description": "A boss coin from Yian Kut-Ku used for item level 1 crafting."
|
|
||||||
},
|
|
||||||
"quantity": 5,
|
|
||||||
"owned": 0
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"canCraft": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 1002,
|
|
||||||
"difficultyId": 1,
|
|
||||||
"sourceDungeonId": 1,
|
|
||||||
"sourceEncounterId": 3,
|
|
||||||
"item": {
|
|
||||||
"id": 2,
|
|
||||||
"slug": "wardens-cinderwrap",
|
|
||||||
"name": "Honed Bulldrome Chest",
|
|
||||||
"slot": "chest",
|
|
||||||
"rarity": "common",
|
|
||||||
"itemLevel": 5,
|
|
||||||
"healingPower": 3,
|
|
||||||
"maxResourceBonus": 0,
|
|
||||||
"glyph": "C",
|
|
||||||
"description": "Crafted with Bulldrome coins.",
|
|
||||||
"setId": null,
|
|
||||||
"setSlug": null,
|
|
||||||
"setName": null
|
|
||||||
},
|
|
||||||
"components": [
|
|
||||||
{
|
|
||||||
"item": {
|
|
||||||
"id": 280301,
|
|
||||||
"slug": "bulldrome-coin-ilvl-1",
|
|
||||||
"name": "Raw Bulldrome Coin",
|
|
||||||
"slot": "component",
|
|
||||||
"rarity": "common",
|
|
||||||
"itemLevel": 1,
|
|
||||||
"healingPower": 0,
|
|
||||||
"maxResourceBonus": 0,
|
|
||||||
"glyph": "$",
|
|
||||||
"description": "A boss coin from Bulldrome used for item level 1 crafting."
|
|
||||||
},
|
|
||||||
"quantity": 5,
|
|
||||||
"owned": 0
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"canCraft": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 1003,
|
|
||||||
"difficultyId": 1,
|
|
||||||
"sourceDungeonId": 1,
|
|
||||||
"sourceEncounterId": 3,
|
|
||||||
"item": {
|
|
||||||
"id": 6,
|
|
||||||
"slug": "furnace-tenders-wraps",
|
|
||||||
"name": "Honed Bulldrome Gloves",
|
|
||||||
"slot": "gloves",
|
|
||||||
"rarity": "common",
|
|
||||||
"itemLevel": 5,
|
|
||||||
"healingPower": 3,
|
|
||||||
"maxResourceBonus": 2,
|
|
||||||
"glyph": "g",
|
|
||||||
"description": "Crafted with Bulldrome coins.",
|
|
||||||
"setId": null,
|
|
||||||
"setSlug": null,
|
|
||||||
"setName": null
|
|
||||||
},
|
|
||||||
"components": [
|
|
||||||
{
|
|
||||||
"item": {
|
|
||||||
"id": 280301,
|
|
||||||
"slug": "bulldrome-coin-ilvl-1",
|
|
||||||
"name": "Raw Bulldrome Coin",
|
|
||||||
"slot": "component",
|
|
||||||
"rarity": "common",
|
|
||||||
"itemLevel": 1,
|
|
||||||
"healingPower": 0,
|
|
||||||
"maxResourceBonus": 0,
|
|
||||||
"glyph": "$",
|
|
||||||
"description": "A boss coin from Bulldrome used for item level 1 crafting."
|
|
||||||
},
|
|
||||||
"quantity": 5,
|
|
||||||
"owned": 0
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"canCraft": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 1001,
|
|
||||||
"difficultyId": 1,
|
|
||||||
"sourceDungeonId": 1,
|
|
||||||
"sourceEncounterId": 3,
|
|
||||||
"item": {
|
|
||||||
"id": 5,
|
|
||||||
"slug": "adepts-hood",
|
|
||||||
"name": "Honed Bulldrome Helmet",
|
|
||||||
"slot": "helmet",
|
|
||||||
"rarity": "common",
|
|
||||||
"itemLevel": 5,
|
|
||||||
"healingPower": 3,
|
|
||||||
"maxResourceBonus": 4,
|
|
||||||
"glyph": "^",
|
|
||||||
"description": "Crafted with Bulldrome coins.",
|
|
||||||
"setId": null,
|
|
||||||
"setSlug": null,
|
|
||||||
"setName": null
|
|
||||||
},
|
|
||||||
"components": [
|
|
||||||
{
|
|
||||||
"item": {
|
|
||||||
"id": 280301,
|
|
||||||
"slug": "bulldrome-coin-ilvl-1",
|
|
||||||
"name": "Raw Bulldrome Coin",
|
|
||||||
"slot": "component",
|
|
||||||
"rarity": "common",
|
|
||||||
"itemLevel": 1,
|
|
||||||
"healingPower": 0,
|
|
||||||
"maxResourceBonus": 0,
|
|
||||||
"glyph": "$",
|
|
||||||
"description": "A boss coin from Bulldrome used for item level 1 crafting."
|
|
||||||
},
|
|
||||||
"quantity": 5,
|
|
||||||
"owned": 0
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"canCraft": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 1009,
|
|
||||||
"difficultyId": 1,
|
|
||||||
"sourceDungeonId": 1,
|
|
||||||
"sourceEncounterId": 22,
|
|
||||||
"item": {
|
|
||||||
"id": 9,
|
|
||||||
"slug": "sootglass-pendant",
|
|
||||||
"name": "Honed Rathian Necklace",
|
|
||||||
"slot": "necklace",
|
|
||||||
"rarity": "common",
|
|
||||||
"itemLevel": 5,
|
|
||||||
"healingPower": 4,
|
|
||||||
"maxResourceBonus": 4,
|
|
||||||
"glyph": "n",
|
|
||||||
"description": "Crafted with Rathian coins.",
|
|
||||||
"setId": null,
|
|
||||||
"setSlug": null,
|
|
||||||
"setName": null
|
|
||||||
},
|
|
||||||
"components": [
|
|
||||||
{
|
|
||||||
"item": {
|
|
||||||
"id": 282201,
|
|
||||||
"slug": "rathian-coin-ilvl-1",
|
|
||||||
"name": "Raw Rathian Coin",
|
|
||||||
"slot": "component",
|
|
||||||
"rarity": "common",
|
|
||||||
"itemLevel": 1,
|
|
||||||
"healingPower": 0,
|
|
||||||
"maxResourceBonus": 0,
|
|
||||||
"glyph": "$",
|
|
||||||
"description": "A boss coin from Rathian used for item level 1 crafting."
|
|
||||||
},
|
|
||||||
"quantity": 5,
|
|
||||||
"owned": 0
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"canCraft": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 1008,
|
|
||||||
"difficultyId": 1,
|
|
||||||
"sourceDungeonId": 1,
|
|
||||||
"sourceEncounterId": 22,
|
|
||||||
"item": {
|
|
||||||
"id": 8,
|
|
||||||
"slug": "ashwalker-legwraps",
|
|
||||||
"name": "Honed Rathian Pants",
|
|
||||||
"slot": "pants",
|
|
||||||
"rarity": "common",
|
|
||||||
"itemLevel": 5,
|
|
||||||
"healingPower": 3,
|
|
||||||
"maxResourceBonus": 3,
|
|
||||||
"glyph": "P",
|
|
||||||
"description": "Crafted with Rathian coins.",
|
|
||||||
"setId": null,
|
|
||||||
"setSlug": null,
|
|
||||||
"setName": null
|
|
||||||
},
|
|
||||||
"components": [
|
|
||||||
{
|
|
||||||
"item": {
|
|
||||||
"id": 282201,
|
|
||||||
"slug": "rathian-coin-ilvl-1",
|
|
||||||
"name": "Raw Rathian Coin",
|
|
||||||
"slot": "component",
|
|
||||||
"rarity": "common",
|
|
||||||
"itemLevel": 1,
|
|
||||||
"healingPower": 0,
|
|
||||||
"maxResourceBonus": 0,
|
|
||||||
"glyph": "$",
|
|
||||||
"description": "A boss coin from Rathian used for item level 1 crafting."
|
|
||||||
},
|
|
||||||
"quantity": 5,
|
|
||||||
"owned": 0
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"canCraft": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 1005,
|
|
||||||
"difficultyId": 1,
|
|
||||||
"sourceDungeonId": 1,
|
|
||||||
"sourceEncounterId": 12,
|
|
||||||
"item": {
|
|
||||||
"id": 1,
|
|
||||||
"slug": "emberglass-sigil",
|
|
||||||
"name": "Honed Yian Kut-Ku Ring",
|
|
||||||
"slot": "ring",
|
|
||||||
"rarity": "common",
|
|
||||||
"itemLevel": 5,
|
|
||||||
"healingPower": 4,
|
|
||||||
"maxResourceBonus": 5,
|
|
||||||
"glyph": "o",
|
|
||||||
"description": "Crafted with Yian Kut-Ku coins.",
|
|
||||||
"setId": null,
|
|
||||||
"setSlug": null,
|
|
||||||
"setName": null
|
|
||||||
},
|
|
||||||
"components": [
|
|
||||||
{
|
|
||||||
"item": {
|
|
||||||
"id": 281201,
|
|
||||||
"slug": "yian-kut-ku-coin-ilvl-1",
|
|
||||||
"name": "Raw Yian Kut-Ku Coin",
|
|
||||||
"slot": "component",
|
|
||||||
"rarity": "common",
|
|
||||||
"itemLevel": 1,
|
|
||||||
"healingPower": 0,
|
|
||||||
"maxResourceBonus": 0,
|
|
||||||
"glyph": "$",
|
|
||||||
"description": "A boss coin from Yian Kut-Ku used for item level 1 crafting."
|
|
||||||
},
|
|
||||||
"quantity": 5,
|
|
||||||
"owned": 0
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"canCraft": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 1006,
|
|
||||||
"difficultyId": 1,
|
|
||||||
"sourceDungeonId": 1,
|
|
||||||
"sourceEncounterId": 12,
|
|
||||||
"item": {
|
|
||||||
"id": 7,
|
|
||||||
"slug": "warden-ember",
|
|
||||||
"name": "Honed Yian Kut-Ku Trinket",
|
|
||||||
"slot": "trinket",
|
|
||||||
"rarity": "common",
|
|
||||||
"itemLevel": 5,
|
|
||||||
"healingPower": 4,
|
|
||||||
"maxResourceBonus": 4,
|
|
||||||
"glyph": "*",
|
|
||||||
"description": "Crafted with Yian Kut-Ku coins.",
|
|
||||||
"setId": null,
|
|
||||||
"setSlug": null,
|
|
||||||
"setName": null
|
|
||||||
},
|
|
||||||
"components": [
|
|
||||||
{
|
|
||||||
"item": {
|
|
||||||
"id": 281201,
|
|
||||||
"slug": "yian-kut-ku-coin-ilvl-1",
|
|
||||||
"name": "Raw Yian Kut-Ku Coin",
|
|
||||||
"slot": "component",
|
|
||||||
"rarity": "common",
|
|
||||||
"itemLevel": 1,
|
|
||||||
"healingPower": 0,
|
|
||||||
"maxResourceBonus": 0,
|
|
||||||
"glyph": "$",
|
|
||||||
"description": "A boss coin from Yian Kut-Ku used for item level 1 crafting."
|
|
||||||
},
|
|
||||||
"quantity": 5,
|
|
||||||
"owned": 0
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"canCraft": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 1007,
|
|
||||||
"difficultyId": 1,
|
|
||||||
"sourceDungeonId": 1,
|
|
||||||
"sourceEncounterId": 22,
|
|
||||||
"item": {
|
|
||||||
"id": 3,
|
|
||||||
"slug": "ashwood-crook",
|
|
||||||
"name": "Honed Rathian Weapon",
|
|
||||||
"slot": "weapon",
|
|
||||||
"rarity": "common",
|
|
||||||
"itemLevel": 5,
|
|
||||||
"healingPower": 5,
|
|
||||||
"maxResourceBonus": 0,
|
|
||||||
"glyph": "/",
|
|
||||||
"description": "Crafted with Rathian coins.",
|
|
||||||
"setId": null,
|
|
||||||
"setSlug": null,
|
|
||||||
"setName": null
|
|
||||||
},
|
|
||||||
"components": [
|
|
||||||
{
|
|
||||||
"item": {
|
|
||||||
"id": 282201,
|
|
||||||
"slug": "rathian-coin-ilvl-1",
|
|
||||||
"name": "Raw Rathian Coin",
|
|
||||||
"slot": "component",
|
|
||||||
"rarity": "common",
|
|
||||||
"itemLevel": 1,
|
|
||||||
"healingPower": 0,
|
|
||||||
"maxResourceBonus": 0,
|
|
||||||
"glyph": "$",
|
|
||||||
"description": "A boss coin from Rathian used for item level 1 crafting."
|
|
||||||
},
|
|
||||||
"quantity": 5,
|
|
||||||
"owned": 0
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"canCraft": false
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"id": 1104,
|
"id": 1104,
|
||||||
"difficultyId": 2,
|
"difficultyId": 2,
|
||||||
@@ -1931,366 +1571,6 @@
|
|||||||
],
|
],
|
||||||
"canCraft": false
|
"canCraft": false
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"id": 1204,
|
|
||||||
"difficultyId": 2,
|
|
||||||
"sourceDungeonId": 1,
|
|
||||||
"sourceEncounterId": 12,
|
|
||||||
"item": {
|
|
||||||
"id": 304,
|
|
||||||
"slug": "runed-cinderstep-boots",
|
|
||||||
"name": "Blue Yian Kut-Ku Boots",
|
|
||||||
"slot": "boots",
|
|
||||||
"rarity": "rare",
|
|
||||||
"itemLevel": 15,
|
|
||||||
"healingPower": 9,
|
|
||||||
"maxResourceBonus": 8,
|
|
||||||
"glyph": "b",
|
|
||||||
"description": "Crafted with Yian Kut-Ku coins.",
|
|
||||||
"setId": null,
|
|
||||||
"setSlug": null,
|
|
||||||
"setName": null
|
|
||||||
},
|
|
||||||
"components": [
|
|
||||||
{
|
|
||||||
"item": {
|
|
||||||
"id": 281210,
|
|
||||||
"slug": "yian-kut-ku-coin-ilvl-10",
|
|
||||||
"name": "Green Yian Kut-Ku Coin",
|
|
||||||
"slot": "component",
|
|
||||||
"rarity": "uncommon",
|
|
||||||
"itemLevel": 10,
|
|
||||||
"healingPower": 0,
|
|
||||||
"maxResourceBonus": 0,
|
|
||||||
"glyph": "$",
|
|
||||||
"description": "A boss coin from Yian Kut-Ku used for item level 10 crafting."
|
|
||||||
},
|
|
||||||
"quantity": 15,
|
|
||||||
"owned": 0
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"canCraft": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 1202,
|
|
||||||
"difficultyId": 2,
|
|
||||||
"sourceDungeonId": 1,
|
|
||||||
"sourceEncounterId": 3,
|
|
||||||
"item": {
|
|
||||||
"id": 302,
|
|
||||||
"slug": "runed-cinderwrap",
|
|
||||||
"name": "Blue Bulldrome Chest",
|
|
||||||
"slot": "chest",
|
|
||||||
"rarity": "rare",
|
|
||||||
"itemLevel": 15,
|
|
||||||
"healingPower": 11,
|
|
||||||
"maxResourceBonus": 3,
|
|
||||||
"glyph": "C",
|
|
||||||
"description": "Crafted with Bulldrome coins.",
|
|
||||||
"setId": null,
|
|
||||||
"setSlug": null,
|
|
||||||
"setName": null
|
|
||||||
},
|
|
||||||
"components": [
|
|
||||||
{
|
|
||||||
"item": {
|
|
||||||
"id": 280310,
|
|
||||||
"slug": "bulldrome-coin-ilvl-10",
|
|
||||||
"name": "Green Bulldrome Coin",
|
|
||||||
"slot": "component",
|
|
||||||
"rarity": "uncommon",
|
|
||||||
"itemLevel": 10,
|
|
||||||
"healingPower": 0,
|
|
||||||
"maxResourceBonus": 0,
|
|
||||||
"glyph": "$",
|
|
||||||
"description": "A boss coin from Bulldrome used for item level 10 crafting."
|
|
||||||
},
|
|
||||||
"quantity": 15,
|
|
||||||
"owned": 0
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"canCraft": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 1203,
|
|
||||||
"difficultyId": 2,
|
|
||||||
"sourceDungeonId": 1,
|
|
||||||
"sourceEncounterId": 3,
|
|
||||||
"item": {
|
|
||||||
"id": 306,
|
|
||||||
"slug": "runed-furnace-wraps",
|
|
||||||
"name": "Blue Bulldrome Gloves",
|
|
||||||
"slot": "gloves",
|
|
||||||
"rarity": "rare",
|
|
||||||
"itemLevel": 15,
|
|
||||||
"healingPower": 11,
|
|
||||||
"maxResourceBonus": 6,
|
|
||||||
"glyph": "g",
|
|
||||||
"description": "Crafted with Bulldrome coins.",
|
|
||||||
"setId": null,
|
|
||||||
"setSlug": null,
|
|
||||||
"setName": null
|
|
||||||
},
|
|
||||||
"components": [
|
|
||||||
{
|
|
||||||
"item": {
|
|
||||||
"id": 280310,
|
|
||||||
"slug": "bulldrome-coin-ilvl-10",
|
|
||||||
"name": "Green Bulldrome Coin",
|
|
||||||
"slot": "component",
|
|
||||||
"rarity": "uncommon",
|
|
||||||
"itemLevel": 10,
|
|
||||||
"healingPower": 0,
|
|
||||||
"maxResourceBonus": 0,
|
|
||||||
"glyph": "$",
|
|
||||||
"description": "A boss coin from Bulldrome used for item level 10 crafting."
|
|
||||||
},
|
|
||||||
"quantity": 15,
|
|
||||||
"owned": 0
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"canCraft": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 1201,
|
|
||||||
"difficultyId": 2,
|
|
||||||
"sourceDungeonId": 1,
|
|
||||||
"sourceEncounterId": 3,
|
|
||||||
"item": {
|
|
||||||
"id": 305,
|
|
||||||
"slug": "runed-adepts-hood",
|
|
||||||
"name": "Blue Bulldrome Helmet",
|
|
||||||
"slot": "helmet",
|
|
||||||
"rarity": "rare",
|
|
||||||
"itemLevel": 15,
|
|
||||||
"healingPower": 9,
|
|
||||||
"maxResourceBonus": 9,
|
|
||||||
"glyph": "^",
|
|
||||||
"description": "Crafted with Bulldrome coins.",
|
|
||||||
"setId": null,
|
|
||||||
"setSlug": null,
|
|
||||||
"setName": null
|
|
||||||
},
|
|
||||||
"components": [
|
|
||||||
{
|
|
||||||
"item": {
|
|
||||||
"id": 280310,
|
|
||||||
"slug": "bulldrome-coin-ilvl-10",
|
|
||||||
"name": "Green Bulldrome Coin",
|
|
||||||
"slot": "component",
|
|
||||||
"rarity": "uncommon",
|
|
||||||
"itemLevel": 10,
|
|
||||||
"healingPower": 0,
|
|
||||||
"maxResourceBonus": 0,
|
|
||||||
"glyph": "$",
|
|
||||||
"description": "A boss coin from Bulldrome used for item level 10 crafting."
|
|
||||||
},
|
|
||||||
"quantity": 15,
|
|
||||||
"owned": 0
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"canCraft": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 1209,
|
|
||||||
"difficultyId": 2,
|
|
||||||
"sourceDungeonId": 1,
|
|
||||||
"sourceEncounterId": 22,
|
|
||||||
"item": {
|
|
||||||
"id": 309,
|
|
||||||
"slug": "runed-sootglass-pendant",
|
|
||||||
"name": "Blue Rathian Necklace",
|
|
||||||
"slot": "necklace",
|
|
||||||
"rarity": "rare",
|
|
||||||
"itemLevel": 15,
|
|
||||||
"healingPower": 12,
|
|
||||||
"maxResourceBonus": 10,
|
|
||||||
"glyph": "n",
|
|
||||||
"description": "Crafted with Rathian coins.",
|
|
||||||
"setId": null,
|
|
||||||
"setSlug": null,
|
|
||||||
"setName": null
|
|
||||||
},
|
|
||||||
"components": [
|
|
||||||
{
|
|
||||||
"item": {
|
|
||||||
"id": 282210,
|
|
||||||
"slug": "rathian-coin-ilvl-10",
|
|
||||||
"name": "Green Rathian Coin",
|
|
||||||
"slot": "component",
|
|
||||||
"rarity": "uncommon",
|
|
||||||
"itemLevel": 10,
|
|
||||||
"healingPower": 0,
|
|
||||||
"maxResourceBonus": 0,
|
|
||||||
"glyph": "$",
|
|
||||||
"description": "A boss coin from Rathian used for item level 10 crafting."
|
|
||||||
},
|
|
||||||
"quantity": 15,
|
|
||||||
"owned": 0
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"canCraft": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 1208,
|
|
||||||
"difficultyId": 2,
|
|
||||||
"sourceDungeonId": 1,
|
|
||||||
"sourceEncounterId": 22,
|
|
||||||
"item": {
|
|
||||||
"id": 308,
|
|
||||||
"slug": "runed-ashwalker-legwraps",
|
|
||||||
"name": "Blue Rathian Pants",
|
|
||||||
"slot": "pants",
|
|
||||||
"rarity": "rare",
|
|
||||||
"itemLevel": 15,
|
|
||||||
"healingPower": 9,
|
|
||||||
"maxResourceBonus": 9,
|
|
||||||
"glyph": "P",
|
|
||||||
"description": "Crafted with Rathian coins.",
|
|
||||||
"setId": null,
|
|
||||||
"setSlug": null,
|
|
||||||
"setName": null
|
|
||||||
},
|
|
||||||
"components": [
|
|
||||||
{
|
|
||||||
"item": {
|
|
||||||
"id": 282210,
|
|
||||||
"slug": "rathian-coin-ilvl-10",
|
|
||||||
"name": "Green Rathian Coin",
|
|
||||||
"slot": "component",
|
|
||||||
"rarity": "uncommon",
|
|
||||||
"itemLevel": 10,
|
|
||||||
"healingPower": 0,
|
|
||||||
"maxResourceBonus": 0,
|
|
||||||
"glyph": "$",
|
|
||||||
"description": "A boss coin from Rathian used for item level 10 crafting."
|
|
||||||
},
|
|
||||||
"quantity": 15,
|
|
||||||
"owned": 0
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"canCraft": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 1205,
|
|
||||||
"difficultyId": 2,
|
|
||||||
"sourceDungeonId": 1,
|
|
||||||
"sourceEncounterId": 12,
|
|
||||||
"item": {
|
|
||||||
"id": 301,
|
|
||||||
"slug": "runed-emberglass-sigil",
|
|
||||||
"name": "Blue Yian Kut-Ku Ring",
|
|
||||||
"slot": "ring",
|
|
||||||
"rarity": "rare",
|
|
||||||
"itemLevel": 15,
|
|
||||||
"healingPower": 10,
|
|
||||||
"maxResourceBonus": 13,
|
|
||||||
"glyph": "o",
|
|
||||||
"description": "Crafted with Yian Kut-Ku coins.",
|
|
||||||
"setId": null,
|
|
||||||
"setSlug": null,
|
|
||||||
"setName": null
|
|
||||||
},
|
|
||||||
"components": [
|
|
||||||
{
|
|
||||||
"item": {
|
|
||||||
"id": 281210,
|
|
||||||
"slug": "yian-kut-ku-coin-ilvl-10",
|
|
||||||
"name": "Green Yian Kut-Ku Coin",
|
|
||||||
"slot": "component",
|
|
||||||
"rarity": "uncommon",
|
|
||||||
"itemLevel": 10,
|
|
||||||
"healingPower": 0,
|
|
||||||
"maxResourceBonus": 0,
|
|
||||||
"glyph": "$",
|
|
||||||
"description": "A boss coin from Yian Kut-Ku used for item level 10 crafting."
|
|
||||||
},
|
|
||||||
"quantity": 15,
|
|
||||||
"owned": 0
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"canCraft": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 1206,
|
|
||||||
"difficultyId": 2,
|
|
||||||
"sourceDungeonId": 1,
|
|
||||||
"sourceEncounterId": 12,
|
|
||||||
"item": {
|
|
||||||
"id": 307,
|
|
||||||
"slug": "runed-warden-ember",
|
|
||||||
"name": "Blue Yian Kut-Ku Trinket",
|
|
||||||
"slot": "trinket",
|
|
||||||
"rarity": "rare",
|
|
||||||
"itemLevel": 15,
|
|
||||||
"healingPower": 12,
|
|
||||||
"maxResourceBonus": 10,
|
|
||||||
"glyph": "*",
|
|
||||||
"description": "Crafted with Yian Kut-Ku coins.",
|
|
||||||
"setId": null,
|
|
||||||
"setSlug": null,
|
|
||||||
"setName": null
|
|
||||||
},
|
|
||||||
"components": [
|
|
||||||
{
|
|
||||||
"item": {
|
|
||||||
"id": 281210,
|
|
||||||
"slug": "yian-kut-ku-coin-ilvl-10",
|
|
||||||
"name": "Green Yian Kut-Ku Coin",
|
|
||||||
"slot": "component",
|
|
||||||
"rarity": "uncommon",
|
|
||||||
"itemLevel": 10,
|
|
||||||
"healingPower": 0,
|
|
||||||
"maxResourceBonus": 0,
|
|
||||||
"glyph": "$",
|
|
||||||
"description": "A boss coin from Yian Kut-Ku used for item level 10 crafting."
|
|
||||||
},
|
|
||||||
"quantity": 15,
|
|
||||||
"owned": 0
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"canCraft": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 1207,
|
|
||||||
"difficultyId": 2,
|
|
||||||
"sourceDungeonId": 1,
|
|
||||||
"sourceEncounterId": 22,
|
|
||||||
"item": {
|
|
||||||
"id": 303,
|
|
||||||
"slug": "runed-ashwood-crook",
|
|
||||||
"name": "Blue Rathian Weapon",
|
|
||||||
"slot": "weapon",
|
|
||||||
"rarity": "rare",
|
|
||||||
"itemLevel": 15,
|
|
||||||
"healingPower": 15,
|
|
||||||
"maxResourceBonus": 3,
|
|
||||||
"glyph": "/",
|
|
||||||
"description": "Crafted with Rathian coins.",
|
|
||||||
"setId": null,
|
|
||||||
"setSlug": null,
|
|
||||||
"setName": null
|
|
||||||
},
|
|
||||||
"components": [
|
|
||||||
{
|
|
||||||
"item": {
|
|
||||||
"id": 282210,
|
|
||||||
"slug": "rathian-coin-ilvl-10",
|
|
||||||
"name": "Green Rathian Coin",
|
|
||||||
"slot": "component",
|
|
||||||
"rarity": "uncommon",
|
|
||||||
"itemLevel": 10,
|
|
||||||
"healingPower": 0,
|
|
||||||
"maxResourceBonus": 0,
|
|
||||||
"glyph": "$",
|
|
||||||
"description": "A boss coin from Rathian used for item level 10 crafting."
|
|
||||||
},
|
|
||||||
"quantity": 15,
|
|
||||||
"owned": 0
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"canCraft": false
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"id": 1304,
|
"id": 1304,
|
||||||
"difficultyId": 4,
|
"difficultyId": 4,
|
||||||
|
|||||||
@@ -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