Compare commits

...

8 Commits

Author SHA1 Message Date
Warren H 4fc15ebe9a Android build v1.0.33 2026-06-20 13:21:49 -04:00
Warren H 7313c968e6 Android build v1.0.32 2026-06-20 12:50:48 -04:00
Warren H 207fcd1a15 Android build v1.0.31 2026-06-19 23:14:56 -04:00
Warren H fd6a1ce3c7 Android build v1.0.30 2026-06-19 22:10:14 -04:00
Warren H f8b98e6b23 Android build v1.0.29 2026-06-19 21:58:23 -04:00
Warren H fc7c6488ea Android build v1.0.28 2026-06-19 21:35:17 -04:00
Warren H ba6d3b614e Android build v1.0.27 2026-06-19 21:29:44 -04:00
Warren H 88874933c3 Android build v1.0.26 2026-06-19 20:55:23 -04:00
25 changed files with 3612 additions and 1303 deletions
+3
View File
@@ -2,5 +2,8 @@
- AYN Thor main display: 6-inch AMOLED, 1920 x 1080, 120Hz.
- AYN Thor secondary display: 3.92-inch AMOLED, 1240 x 1080, 60Hz.
- AYN Thor UI sizing must be designed against Android CSS/layout viewport, not physical framebuffer pixels.
- Approximate Thor CSS viewports: main display 960 x 540, secondary display 620 x 540.
- Test top-screen UI only against the main display viewport, and bottom-screen UI only against the secondary display viewport.
- User rebuilds app; do not rebuild APK unless explicitly requested.
- Apply game changes to both web version and mobile app version.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2 -2
View File
@@ -7,8 +7,8 @@ android {
applicationId "com.warren.iwanttoheal"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 43
versionName "1.0.27"
versionCode 52
versionName "1.0.33"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+88 -13
View File
@@ -134,22 +134,22 @@ INSERT OR IGNORE INTO spells
VALUES
(1, 1, 'mend', 'Mend', 'direct_heal', 5, 0.5, 30, 1, '+', 'A fast, efficient single-target heal.'),
(2, 1, 'renew', 'Renew', 'heal_over_time', 7, 0.5, 12, 1, '~', 'Heals now and continues healing over time.'),
(3, 1, 'radiance', 'Radiance', 'party_heal', 12, 8, 18, 1, '*', 'Restores health to every living party member.'),
(3, 1, 'radiance', 'Radiance', 'party_heal', 12, 8, 18, 1, '*', 'Restores health to up to 4 injured party members.'),
(4, 1, 'sun-ward', 'Sun Ward', 'absorb', 8, 7, 36, 1, 'O', 'Places a damage-absorbing shield on your target.'),
(5, 1, 'purify', 'Purify', 'cleanse', 5, 5, 10, 1, 'x', 'Removes a harmful effect and restores health.'),
(6, 1, 'dawn-burst', 'Dawn Burst', 'party_heal', 16, 12, 28, 5, 'D', 'A brilliant wave of healing for the entire party.'),
(6, 1, 'dawn-burst', 'Dawn Burst', 'party_heal', 16, 12, 28, 5, 'D', 'A brilliant wave of healing for up to 4 injured allies.'),
(7, 1, 'guardian-light', 'Guardian Light', 'absorb', 13, 14, 55, 10, 'G', 'A powerful ward reserved for moments of extreme danger.'),
(8, 1, 'second-sun', 'Second Sun', 'direct_heal', 20, 20, 85, 15, 'S', 'Calls down a delayed surge of restorative light.'),
(9, 1, 'daybreak', 'Daybreak', 'party_heal', 23, 30, 48, 20, 'A', 'Floods the party with the full strength of dawn.'),
(9, 1, 'daybreak', 'Daybreak', 'party_heal', 23, 30, 48, 20, 'A', 'Floods up to 4 injured allies with the full strength of dawn.'),
(20, 2, 'verdant-touch', 'Verdant Touch', 'direct_heal', 5, 0.5, 28, 1, '+', 'A quick pulse of living energy.'),
(21, 2, 'seed-of-life', 'Seed of Life', 'heal_over_time', 7, 0.5, 11, 1, 's', 'Plants a restorative seed that blooms over time.'),
(22, 2, 'wild-bloom', 'Wild Bloom', 'party_heal', 12, 8, 17, 1, '*', 'Restorative growth spreads through the party.'),
(22, 2, 'wild-bloom', 'Wild Bloom', 'party_heal', 12, 8, 17, 1, '*', 'Restorative growth spreads to up to 4 injured allies.'),
(23, 2, 'barkskin', 'Barkskin', 'absorb', 8, 7, 34, 1, 'B', 'Wraps an ally in protective living bark.'),
(24, 2, 'purging-sap', 'Purging Sap', 'cleanse', 5, 5, 10, 1, 'p', 'Draws a harmful effect out through enchanted sap.'),
(25, 2, 'ancient-grove', 'Ancient Grove', 'party_heal', 17, 12, 31, 5, 'T', 'Briefly summons the shelter of an ancient grove.'),
(30, 3, 'etched-mend', 'Etched Mend', 'direct_heal', 5, 0.5, 29, 1, '+', 'Completes a simple rune of restoration.'),
(31, 3, 'echo-rune', 'Echo Rune', 'heal_over_time', 7, 0.5, 12, 1, 'e', 'Repeats a restorative rune over several moments.'),
(32, 3, 'concordance', 'Concordance', 'party_heal', 12, 8, 18, 1, '*', 'Links the party through a shared healing pattern.'),
(32, 3, 'concordance', 'Concordance', 'party_heal', 12, 8, 18, 1, '*', 'Links up to 4 injured allies through a shared healing pattern.'),
(33, 3, 'aegis-script', 'Aegis Script', 'absorb', 8, 7, 35, 1, 'O', 'Writes a temporary barrier around an ally.'),
(34, 3, 'unravel', 'Unravel', 'cleanse', 5, 5, 10, 1, 'u', 'Unravels a hostile magical pattern.'),
(35, 3, 'grand-design', 'Grand Design', 'party_heal', 16, 12, 30, 5, 'R', 'Activates a prepared network of restorative runes.');
@@ -191,6 +191,76 @@ UPDATE spells SET unlock_level = 10 WHERE slug = 'guardian-light';
UPDATE spells SET unlock_level = 15 WHERE slug = 'second-sun';
UPDATE spells SET unlock_level = 20 WHERE slug = 'daybreak';
UPDATE spells SET
name = 'Verdant Touch',
spell_type = 'direct_hot',
resource_cost = 5,
cooldown_seconds = 0.5,
power = 20,
glyph = '+',
description = 'A weaker direct heal that also plants a stacking heal over time.'
WHERE slug = 'verdant-touch';
UPDATE spells SET
name = 'Wild Growth',
spell_type = 'party_hot',
resource_cost = 12,
cooldown_seconds = 8,
power = 14,
glyph = '*',
description = 'Applies a stacking heal over time to up to 4 injured allies.'
WHERE slug = 'wild-bloom';
UPDATE spells SET
name = 'Barkskin',
spell_type = 'damage_reduction',
resource_cost = 10,
cooldown_seconds = 14,
power = 0,
glyph = 'B',
description = 'Reduces the target ally''s damage taken by 50% for 8 seconds.'
WHERE slug = 'barkskin';
UPDATE spells SET
name = 'Ancient Grove',
spell_type = 'party_hot',
resource_cost = 17,
cooldown_seconds = 12,
power = 24,
glyph = 'T',
description = 'Applies a stronger stacking heal over time to up to 4 injured allies.'
WHERE slug = 'ancient-grove';
UPDATE spells SET
name = 'Mending Rune',
spell_type = 'bounce_heal',
resource_cost = 7,
cooldown_seconds = 0.5,
power = 18,
glyph = 'e',
description = 'Places a rune that heals when the ally takes damage, then jumps 4 times.'
WHERE slug = 'echo-rune';
UPDATE spells SET
name = 'Concordance',
spell_type = 'party_absorb',
resource_cost = 12,
cooldown_seconds = 8,
power = 28,
glyph = '*',
description = 'Shields up to 4 injured allies through a shared barrier pattern.'
WHERE slug = 'concordance';
UPDATE spells SET
name = 'Grand Design',
spell_type = 'party_absorb',
resource_cost = 16,
cooldown_seconds = 12,
power = 42,
glyph = 'R',
description = 'Raises a stronger shared barrier around up to 4 injured allies.'
WHERE slug = 'grand-design';
INSERT OR IGNORE INTO items
(id, slug, name, slot, rarity, item_level, healing_power, max_resource_bonus, glyph, description)
VALUES
@@ -479,9 +549,7 @@ WHERE id BETWEEN 901 AND 1409;
UPDATE items
SET rarity = CASE item_level
WHEN 1 THEN 'common'
WHEN 5 THEN 'common'
WHEN 10 THEN 'uncommon'
WHEN 15 THEN 'rare'
WHEN 20 THEN 'epic'
WHEN 25 THEN 'legendary'
ELSE rarity
@@ -493,9 +561,7 @@ SET name = (
SELECT
CASE items.item_level
WHEN 1 THEN 'Raw '
WHEN 5 THEN 'Honed '
WHEN 10 THEN 'Green '
WHEN 15 THEN 'Blue '
WHEN 20 THEN 'Purple '
WHEN 25 THEN 'Orange '
ELSE ''
@@ -1276,12 +1342,23 @@ SET difficulty_id = CASE
END
WHERE id BETWEEN 901 AND 1409;
DELETE FROM crafting_recipe_components
WHERE recipe_id IN (
SELECT crafting_recipes.id
FROM crafting_recipes
JOIN items ON items.id = crafting_recipes.item_id
WHERE items.item_level NOT IN (1, 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
SET rarity = CASE item_level
WHEN 1 THEN 'common'
WHEN 5 THEN 'common'
WHEN 10 THEN 'uncommon'
WHEN 15 THEN 'rare'
WHEN 20 THEN 'epic'
WHEN 25 THEN 'legendary'
ELSE rarity
@@ -1293,9 +1370,7 @@ SET name = (
SELECT
CASE items.item_level
WHEN 1 THEN 'Raw '
WHEN 5 THEN 'Honed '
WHEN 10 THEN 'Green '
WHEN 15 THEN 'Blue '
WHEN 20 THEN 'Purple '
WHEN 25 THEN 'Orange '
ELSE ''
+2 -2
View File
@@ -79,9 +79,9 @@ cd /Users/warren/Documents/testgame/testgame
export GITEA_URL="https://git.whoagland.com"
export GITEA_OWNER="phenom"
export GITEA_REPO="i-want-to-heal"
export GITEA_TOKEN="PASTE_TOKEN_HERE"
export GITEA_TOKEN="ed2db3fd54546e9658377d0551b3fc3961583f1d"
VERSION="1.0.26"
VERSION="1.0.27"
APK="IWantToHeal-Thor-v$VERSION.apk"
RELEASE_JSON=$(curl -sS -X POST "$GITEA_URL/api/v1/repos/$GITEA_OWNER/$GITEA_REPO/releases" \
+56 -18
View File
@@ -22,6 +22,7 @@ const bossImageContentTypes = {
}
const equipmentSlots = ['weapon', 'helmet', 'chest', 'gloves', 'boots', 'pants', 'ring', 'necklace', 'trinket']
const componentSlot = 'component'
const directCraftItemLevels = new Set([1, 10, 20, 25])
const sessionCookieName = 'chronicle_session'
const sessionLifetimeSeconds = 60 * 60 * 24 * 30
const rateLimitBuckets = new Map()
@@ -232,6 +233,25 @@ function consumeRateLimit(key, limit, windowMs) {
}
}
function catchUpExperienceReward(database, accountId, characterId, baseReward, currentExperience, currentLevel) {
const targetLevel = database.prepare(`
SELECT COALESCE(MAX(level), 0) AS level
FROM characters
WHERE account_id = ?
AND id != ?
`).get(accountId, characterId).level
if (targetLevel <= currentLevel) return baseReward
const targetExperience = database.prepare(`
SELECT experience_required AS experienceRequired
FROM level_progression
WHERE level = ?
`).get(targetLevel)?.experienceRequired ?? currentExperience
const gap = Math.max(0, targetExperience - currentExperience)
if (gap <= 0) return baseReward
const doubledBase = Math.min(baseReward, Math.ceil(gap / 2))
return doubledBase * 2 + (baseReward - doubledBase)
}
function normalizeUsername(value) {
const username = String(value ?? '').trim()
if (!/^[A-Za-z0-9_]{3,20}$/.test(username)) {
@@ -1693,16 +1713,9 @@ function craftItem(database, characterId, recipeId) {
WHERE crafting_recipes.id = ?
`).get(recipeId)
if (!recipe) throw new Error('That crafting recipe does not exist.')
const lowerTierRecipe = database.prepare(`
SELECT crafting_recipes.id
FROM crafting_recipes
JOIN items ON items.id = crafting_recipes.item_id
WHERE crafting_recipes.source_encounter_id = ?
AND items.slot = ?
AND items.item_level < ?
LIMIT 1
`).get(recipe.sourceEncounterId, recipe.slot, recipe.itemLevel)
if (lowerTierRecipe) throw new Error('Upgrade the previous item tier instead.')
if (!directCraftItemLevels.has(recipe.itemLevel)) {
throw new Error('Upgrade the previous item tier instead.')
}
const components = database.prepare(`
SELECT
@@ -2024,12 +2037,21 @@ function completeDungeon(database, characterId, accountId, dungeonId, difficulty
const completedPart = Math.min(Math.max(Number(runMetrics?.completedPart) || 1, 1), 3)
const startPart = Math.min(Math.max(Number(runMetrics?.startPart) || 1, 1), 3)
const completedParts = completedPart - startPart + 1
const rewardMultiplier = runMetrics?.hardMode ? 2 : 1
const rawPartDurations = runMetrics?.partDurationSeconds
const partDurationSeconds = Array.isArray(rawPartDurations) && rawPartDurations.length === 3
? rawPartDurations.map(Number)
: null
const experienceReward = Math.round(
dungeon.experienceReward * dungeon.experienceMultiplier * completedPart,
const baseExperienceReward = Math.round(
dungeon.experienceReward * dungeon.experienceMultiplier * completedPart * rewardMultiplier,
)
const experienceReward = catchUpExperienceReward(
database,
accountId,
characterId,
baseExperienceReward,
character.experience,
character.level,
)
const newExperience = Math.min(character.experience + experienceReward, maxExperience)
const newLevel = database.prepare(`
@@ -2127,17 +2149,18 @@ function completeDungeon(database, characterId, accountId, dungeonId, difficulty
`).all(dungeonId, dungeon.completionItemLevel ?? dungeon.droppedItemLevel + 3)
if (bonusItems.length > 0) {
bonusItem = bonusItems[0]
const rewardQuantity = rewardMultiplier
const previousQuantity = database.prepare(`
SELECT quantity FROM character_inventory
WHERE character_id = ? AND item_id = ?
`).get(characterId, bonusItem.id)?.quantity ?? 0
database.prepare(`
INSERT INTO character_inventory (character_id, item_id, quantity, equipped)
VALUES (?, ?, 1, 0)
VALUES (?, ?, ?, 0)
ON CONFLICT(character_id, item_id)
DO UPDATE SET quantity = quantity + 1
`).run(characterId, bonusItem.id)
bonusItem = { ...bonusItem, quantity: 1, duplicate: previousQuantity > 0, quantityAfter: previousQuantity + 1 }
DO UPDATE SET quantity = quantity + ?
`).run(characterId, bonusItem.id, rewardQuantity, rewardQuantity)
bonusItem = { ...bonusItem, quantity: rewardQuantity, duplicate: previousQuantity > 0, quantityAfter: previousQuantity + rewardQuantity }
}
}
@@ -2234,6 +2257,12 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
let newExperience = character.experience
let newLevel = character.level
if (experienceMode === 'pvp-boss-quarter-level') {
const catchUpTargetLevel = database.prepare(`
SELECT COALESCE(MAX(level), 0) AS level
FROM characters
WHERE account_id = ?
AND id != ?
`).get(accountId, characterId).level
for (let bossIndex = 0; bossIndex < bossesCleared && newExperience < maxExperience; bossIndex += 1) {
const currentLevelFloor = database.prepare(`
SELECT experience_required AS experienceRequired
@@ -2248,7 +2277,8 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
WHERE level = ?
`).get(newLevel + 1).experienceRequired
const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor)
newExperience = Math.min(maxExperience, newExperience + Math.round(levelBand * 0.25))
const rewardRate = catchUpTargetLevel > newLevel ? 0.5 : 0.25
newExperience = Math.min(maxExperience, newExperience + Math.round(levelBand * rewardRate))
newLevel = database.prepare(`
SELECT MAX(level) AS level
FROM level_progression
@@ -2256,9 +2286,17 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
`).get(newExperience).level
}
} else {
const experienceReward = Math.round(
const baseExperienceReward = Math.round(
dungeon.experienceReward * dungeon.experienceMultiplier * (encountersCleared / 3),
)
const experienceReward = catchUpExperienceReward(
database,
accountId,
characterId,
baseExperienceReward,
character.experience,
character.level,
)
newExperience = Math.min(character.experience + experienceReward, maxExperience)
newLevel = database.prepare(`
SELECT MAX(level) AS level
+2131 -8
View File
File diff suppressed because it is too large Load Diff
+274 -218
View File
@@ -88,6 +88,7 @@ function App() {
const [roguelikeAbilityLabelMode, setRoguelikeAbilityLabelMode] = useState<RoguelikeAbilityLabelMode>('ability')
const [pvpContentType, setPvpContentType] = useState<PvpContentType>('dungeon')
const [selectedPart, setSelectedPart] = useState(1)
const [selectedHardMode, setSelectedHardMode] = useState(false)
const [combatContentId, setCombatContentId] = useState(1)
const [leaderboardCategory, setLeaderboardCategory] = useState<'part_1' | 'part_2' | 'part_3' | 'full_run'>('part_1')
const [showLoot, setShowLoot] = useState(false)
@@ -235,6 +236,7 @@ function App() {
<CombatScreen
difficulty={difficulty}
dungeon={dungeon}
hardMode={selectedHardMode && combatContentId > 0}
profile={profile}
roguelikeMode={combatContentId < 0 ? roguelikeKind : undefined}
roguelikeUpgradeTiming={combatContentId < 0 ? roguelikeUpgradeTiming : undefined}
@@ -283,6 +285,20 @@ function App() {
const raid = raidOptions.find((candidate) => candidate.id === selectedRaidId)
?? raidOptions[0]
const activityOptions = screen === 'raids' ? raidOptions : dungeonOptions
const startPveRoguelike = () => {
const baseDungeon = dungeonOptions[0]
const baseRaid = raidOptions[0]
if (roguelikeKind === 'raid') {
setCombatContentId(-2)
setSelectedDifficultyId(baseRaid?.difficulties[0]?.id ?? 101)
} else {
setCombatContentId(-1)
setSelectedDifficultyId(baseDungeon?.difficulties[0]?.id ?? 1)
}
setSelectedPart(1)
setSelectedHardMode(false)
setScreen('combat')
}
const tierOptions = activityOptions
.flatMap((option) => option.difficulties)
.filter((difficulty, index, all) => (
@@ -315,9 +331,9 @@ function App() {
: profile.completedDungeonParts
const sectionName = activity.contentType === 'raid' ? 'Phase' : 'Part'
const parts = [
{ part: 1, name: `${sectionName} 1`, encounterCount: 3, unlocked: true },
{ part: 2, name: `${sectionName} 2`, encounterCount: 3, unlocked: completedSections >= 1 },
{ part: 3, name: `${sectionName} 3`, encounterCount: 3, unlocked: completedSections >= 2 },
{ part: 1, name: `${sectionName} 1`, encounterCount: 3, unlocked: true, hardUnlocked: completedSections >= 1 },
{ part: 2, name: `${sectionName} 2`, encounterCount: 3, unlocked: completedSections >= 1, hardUnlocked: completedSections >= 2 },
{ part: 3, name: `${sectionName} 3`, encounterCount: 3, unlocked: completedSections >= 2, hardUnlocked: completedSections >= 3 },
]
const cloudSync = getCloudSyncStatus()
const canShowCloudSync = account.id !== -1 && cloudSync.available
@@ -328,7 +344,7 @@ function App() {
: a.sequence - b.sequence)
return (
<main className="game-shell">
<main className={`game-shell ${screen === 'dungeons' || screen === 'raids' ? 'dungeon-shell' : ''} ${screen === 'customize' ? 'workshop-shell' : ''}`}>
<header className="topbar app-header">
<button
className="brand-button"
@@ -425,84 +441,90 @@ function App() {
</div>
{roguelikeVariant === 'pve' && (
<>
<div className="roguelike-option-panel">
<div>
<p className="eyebrow">Upgrade Timing</p>
<h2>Buff Drafts</h2>
</div>
<div className="roguelike-timing-row">
<button
className={`text-button ${roguelikeUpgradeTiming === 'encounter' ? 'active' : ''}`}
onClick={() => setRoguelikeUpgradeTiming('encounter')}
type="button"
>
Every Encounter
</button>
<button
className={`text-button ${roguelikeUpgradeTiming === 'boss' ? 'active' : ''}`}
onClick={() => setRoguelikeUpgradeTiming('boss')}
type="button"
>
Boss Only
</button>
</div>
</div>
<div className="roguelike-option-panel">
<div>
<p className="eyebrow">Upgrade Labels</p>
<h2>Display Mode</h2>
</div>
<div className="roguelike-timing-row">
<button
className={`text-button ${roguelikeAbilityLabelMode === 'ability' ? 'active' : ''}`}
onClick={() => setRoguelikeAbilityLabelMode('ability')}
type="button"
>
Ability Names
</button>
<button
className={`text-button ${roguelikeAbilityLabelMode === 'slot' ? 'active' : ''}`}
onClick={() => setRoguelikeAbilityLabelMode('slot')}
type="button"
>
Slot Names
</button>
</div>
</div>
<div className="roguelike-mode-grid">
<button
className="menu-card"
onClick={() => {
const baseDungeon = dungeonOptions[0]
setRoguelikeKind('dungeon')
setCombatContentId(-1)
setSelectedDifficultyId(baseDungeon.difficulties[0]?.id ?? 1)
setSelectedPart(1)
setScreen('combat')
}}
type="button"
>
<span>D</span>
<strong>Dungeon Roguelike</strong>
<small>Five-player party. Two random trash enemies and a boss with a lighter early ramp.</small>
</button>
<button
className="menu-card"
onClick={() => {
const baseRaid = raidOptions[0]
setRoguelikeKind('raid')
setCombatContentId(-2)
setSelectedDifficultyId(baseRaid?.difficulties[0]?.id ?? 101)
setSelectedPart(1)
setScreen('combat')
}}
type="button"
>
<span>R</span>
<strong>Raid Roguelike</strong>
<small>Ten-player party. Raid pools, lighter early scaling, and the same upgrade draft.</small>
</button>
</div>
<div className="roguelike-option-panel">
<div>
<p className="eyebrow">Run Type</p>
<h2>PvE Roguelike</h2>
</div>
<div className="roguelike-timing-row">
<button
className={`text-button ${roguelikeKind === 'dungeon' ? 'active' : ''}`}
onClick={() => setRoguelikeKind('dungeon')}
type="button"
>
Dungeon
</button>
<button
className={`text-button ${roguelikeKind === 'raid' ? 'active' : ''}`}
onClick={() => setRoguelikeKind('raid')}
type="button"
>
Raid
</button>
</div>
</div>
<div className="roguelike-option-panel">
<div>
<p className="eyebrow">Upgrade Timing</p>
<h2>Buff Drafts</h2>
</div>
<div className="roguelike-timing-row">
<button
className={`text-button ${roguelikeUpgradeTiming === 'encounter' ? 'active' : ''}`}
onClick={() => setRoguelikeUpgradeTiming('encounter')}
type="button"
>
Every Encounter
</button>
<button
className={`text-button ${roguelikeUpgradeTiming === 'boss' ? 'active' : ''}`}
onClick={() => setRoguelikeUpgradeTiming('boss')}
type="button"
>
Boss Only
</button>
</div>
</div>
<div className="roguelike-option-panel">
<div>
<p className="eyebrow">Upgrade Labels</p>
<h2>Display Mode</h2>
</div>
<div className="roguelike-timing-row">
<button
className={`text-button ${roguelikeAbilityLabelMode === 'ability' ? 'active' : ''}`}
onClick={() => setRoguelikeAbilityLabelMode('ability')}
type="button"
>
Ability Names
</button>
<button
className={`text-button ${roguelikeAbilityLabelMode === 'slot' ? 'active' : ''}`}
onClick={() => setRoguelikeAbilityLabelMode('slot')}
type="button"
>
Slot Names
</button>
</div>
</div>
<div className="menu-card pvp-queue-panel">
<span>{roguelikeKind === 'raid' ? 'R' : 'D'}</span>
<div>
<strong>{roguelikeKind === 'raid' ? 'Raid Roguelike' : 'Dungeon Roguelike'}</strong>
<small>
{roguelikeKind === 'raid'
? 'Ten-player party. Raid pools, lighter early scaling, and the same upgrade draft.'
: 'Five-player party. Two random trash enemies and a boss with a lighter early ramp.'}
</small>
</div>
<button
className="text-button"
onClick={startPveRoguelike}
type="button"
>
Start Run
</button>
</div>
</>
)}
{roguelikeVariant === 'pvp' && (
@@ -586,147 +608,179 @@ function App() {
)}
{(screen === 'dungeons' || screen === 'raids') && (
<section className="content-screen">
<section className="content-screen dungeon-run-screen">
<ScreenHeading
eyebrow="Adventure"
title={activity.contentType === 'raid' ? 'Raids' : 'Dungeons'}
onBack={() => setScreen('menu')}
/>
<section className="run-setup-panel">
<div className="run-setup-heading">
<div>
<p className="eyebrow">Step 1</p>
<h2>Item Level</h2>
</div>
<small>{screen === 'raids' ? 'Raid' : 'Dungeon'} tiers unlock by character level.</small>
</div>
<div className="tier-grid">
{tierOptions.map((difficulty) => {
const locked = profile.character.level < difficulty.unlockLevel
const selected = difficulty.droppedItemLevel === selectedDifficulty.droppedItemLevel
return (
<button
className={`${selected ? 'selected' : ''} ${locked ? 'locked' : ''}`}
disabled={locked}
key={difficulty.id}
onClick={() => {
const nextActivity = activity.difficulties.some(
(candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel,
)
? activity
: activityOptions.find((option) =>
option.difficulties.some((candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel),
)
if (nextActivity) {
if (screen === 'raids') setSelectedRaidId(nextActivity.id)
else setSelectedDungeonId(nextActivity.id)
const nextDifficulty = nextActivity.difficulties.find(
(candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel,
)
if (nextDifficulty) setSelectedDifficultyId(nextDifficulty.id)
}
}}
type="button"
>
<strong>iLvl {difficulty.droppedItemLevel}</strong>
<span>{locked ? `Level ${difficulty.unlockLevel}` : difficulty.name}</span>
</button>
)
})}
</div>
</section>
<div className="dungeon-run-board">
<div className="dungeon-run-main">
<article className="run-summary-card dungeon-focus-card">
<div className={`dungeon-art ${activity.contentType === 'raid' ? 'raid-art' : ''}`}>
{activityInitials(activity.name)}
</div>
<div className="run-summary-copy">
<p className="eyebrow">Selected Run</p>
<h2>{activity.name}</h2>
<p>{activity.description}</p>
<div className="tag-row">
<span>Level {activity.recommendedLevel}</span>
<span>{activity.partySize} Players</span>
<span>{selectedDifficulty.name}</span>
<span>iLvl {selectedDifficulty.droppedItemLevel}</span>
<span>{Math.round(activity.experienceReward * selectedDifficulty.experienceMultiplier)} XP</span>
</div>
</div>
</article>
<section className="run-setup-panel">
<div className="run-setup-heading">
<div>
<p className="eyebrow">Step 2</p>
<h2>{screen === 'raids' ? 'Pick Raid' : 'Pick Dungeon'}</h2>
</div>
<small>{selectedDifficulty.name} rewards iLvl {selectedDifficulty.droppedItemLevel} components.</small>
<section className="run-setup-panel dungeon-choice-panel">
<div className="run-setup-heading">
<div>
<p className="eyebrow">Pick Run</p>
<h2>{screen === 'raids' ? 'Raid' : 'Dungeon'}</h2>
</div>
<small>{selectedDifficulty.name} rewards iLvl {selectedDifficulty.droppedItemLevel} components.</small>
</div>
<div className="activity-card-grid dungeon-choice-grid">
{tierActivityOptions.map((candidate) => {
const difficulty = candidate.difficulties.find(
(option) => option.droppedItemLevel === selectedDifficulty.droppedItemLevel,
) ?? candidate.difficulties[0]
const locked = profile.character.level < difficulty.unlockLevel
const selected = candidate.id === activity.id
return (
<button
className={`activity-card ${selected ? 'selected' : ''} ${locked ? 'locked' : ''}`}
disabled={locked}
key={candidate.id}
onClick={() => {
if (screen === 'raids') setSelectedRaidId(candidate.id)
else setSelectedDungeonId(candidate.id)
setSelectedDifficultyId(difficulty.id)
}}
type="button"
>
<span className={`dungeon-art ${candidate.contentType === 'raid' ? 'raid-art' : ''}`}>
{activityInitials(candidate.name)}
</span>
<strong>{candidate.name}</strong>
<small>{candidate.locationName}</small>
<i>
Level {candidate.recommendedLevel} | {candidate.partySize} Players
</i>
</button>
)
})}
</div>
</section>
</div>
<div className="activity-card-grid">
{tierActivityOptions.map((candidate) => {
const difficulty = candidate.difficulties.find(
(option) => option.droppedItemLevel === selectedDifficulty.droppedItemLevel,
) ?? candidate.difficulties[0]
const locked = profile.character.level < difficulty.unlockLevel
const selected = candidate.id === activity.id
return (
<button
className={`activity-card ${selected ? 'selected' : ''} ${locked ? 'locked' : ''}`}
disabled={locked}
key={candidate.id}
onClick={() => {
if (screen === 'raids') setSelectedRaidId(candidate.id)
else setSelectedDungeonId(candidate.id)
setSelectedDifficultyId(difficulty.id)
}}
type="button"
>
<span className={`dungeon-art ${candidate.contentType === 'raid' ? 'raid-art' : ''}`}>
{activityInitials(candidate.name)}
</span>
<strong>{candidate.name}</strong>
<small>{candidate.locationName}</small>
<i>
Level {candidate.recommendedLevel} | {candidate.partySize} Players
</i>
</button>
)
})}
</div>
</section>
<article className="run-summary-card">
<div className={`dungeon-art ${activity.contentType === 'raid' ? 'raid-art' : ''}`}>
{activityInitials(activity.name)}
</div>
<div className="run-summary-copy">
<p className="eyebrow">Step 3</p>
<h2>{activity.name}</h2>
<p>{activity.description}</p>
<div className="tag-row">
<span>Level {activity.recommendedLevel}</span>
<span>{activity.partySize} Players</span>
<span>{selectedDifficulty.name}</span>
<span>iLvl {selectedDifficulty.droppedItemLevel}</span>
<span>{Math.round(activity.experienceReward * selectedDifficulty.experienceMultiplier)} XP</span>
<aside className="dungeon-setup-rail">
<section className="run-setup-panel tier-setup-panel">
<div className="run-setup-heading">
<div>
<p className="eyebrow">Item Level</p>
<h2>Tier</h2>
</div>
<small>{screen === 'raids' ? 'Raid' : 'Dungeon'} tiers unlock by level.</small>
</div>
<div className="tier-grid">
{tierOptions.map((difficulty) => {
const locked = profile.character.level < difficulty.unlockLevel
const selected = difficulty.droppedItemLevel === selectedDifficulty.droppedItemLevel
return (
<button
className={`${selected ? 'selected' : ''} ${locked ? 'locked' : ''}`}
disabled={locked}
key={difficulty.id}
onClick={() => {
const nextActivity = activity.difficulties.some(
(candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel,
)
? activity
: activityOptions.find((option) =>
option.difficulties.some((candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel),
)
if (nextActivity) {
if (screen === 'raids') setSelectedRaidId(nextActivity.id)
else setSelectedDungeonId(nextActivity.id)
const nextDifficulty = nextActivity.difficulties.find(
(candidate) => candidate.droppedItemLevel === difficulty.droppedItemLevel,
)
if (nextDifficulty) setSelectedDifficultyId(nextDifficulty.id)
}
}}
type="button"
>
<strong>iLvl {difficulty.droppedItemLevel}</strong>
<span>{locked ? `Level ${difficulty.unlockLevel}` : difficulty.name}</span>
</button>
)
})}
</div>
</section>
<section className="run-setup-panel part-setup-panel">
<div className="run-setup-heading">
<div>
<p className="eyebrow">Start</p>
<h2>{sectionName}</h2>
</div>
<small>{difficultyLocked ? `Unlocks at level ${selectedDifficulty.unlockLevel}` : 'Choose a section to launch.'}</small>
</div>
<div className="part-picker">
{parts.map((p) => (
<div className="part-start-row" key={p.part}>
<button
className={`primary-button ${selectedPart === p.part && !selectedHardMode ? 'selected-part' : ''} ${!p.unlocked ? 'locked' : ''}`}
disabled={difficultyLocked || !p.unlocked}
onClick={() => {
setSelectedPart(p.part)
setSelectedHardMode(false)
setCombatContentId(activity.id)
setSelectedDifficultyId(selectedDifficulty.id)
setScreen('combat')
}}
type="button"
>
{p.name}
</button>
<button
className={`primary-button hard-mode-button ${selectedPart === p.part && selectedHardMode ? 'selected-part' : ''} ${!p.hardUnlocked ? 'locked' : ''}`}
disabled={difficultyLocked || !p.hardUnlocked}
onClick={() => {
setSelectedPart(p.part)
setSelectedHardMode(true)
setCombatContentId(activity.id)
setSelectedDifficultyId(selectedDifficulty.id)
setScreen('combat')
}}
type="button"
>
Hard
</button>
</div>
))}
</div>
</section>
<div className="difficulty-section compact-difficulty-section">
<div className={`difficulty-summary ${difficultyLocked ? 'locked' : ''}`}>
<div>
<strong>{selectedDifficulty.name}</strong>
<small>{difficultyLocked ? `Unlocks at level ${selectedDifficulty.unlockLevel}` : selectedDifficulty.description}</small>
</div>
<dl>
<div><dt>Health</dt><dd>{selectedDifficulty.healthMultiplier.toFixed(2)}x</dd></div>
<div><dt>Damage</dt><dd>{selectedDifficulty.damageMultiplier.toFixed(2)}x</dd></div>
<div><dt>XP</dt><dd>{selectedDifficulty.experienceMultiplier.toFixed(1)}x</dd></div>
<div><dt>Loot</dt><dd>iLvl {selectedDifficulty.droppedItemLevel}</dd></div>
</dl>
</div>
</div>
</div>
<div className="part-picker">
{parts.map((p) => (
<button
key={p.part}
className={`primary-button ${selectedPart === p.part ? 'selected-part' : ''} ${!p.unlocked ? 'locked' : ''}`}
disabled={difficultyLocked || !p.unlocked}
onClick={() => {
setSelectedPart(p.part)
setCombatContentId(activity.id)
setSelectedDifficultyId(selectedDifficulty.id)
setScreen('combat')
}}
type="button"
>
{p.name}
</button>
))}
</div>
</article>
<div className="difficulty-section compact-difficulty-section">
<div className={`difficulty-summary ${difficultyLocked ? 'locked' : ''}`}>
<div>
<strong>{selectedDifficulty.name}</strong>
<small>{difficultyLocked ? `Unlocks at level ${selectedDifficulty.unlockLevel}` : selectedDifficulty.description}</small>
</div>
<dl>
<div><dt>Health</dt><dd>{selectedDifficulty.healthMultiplier.toFixed(2)}x</dd></div>
<div><dt>Damage</dt><dd>{selectedDifficulty.damageMultiplier.toFixed(2)}x</dd></div>
<div><dt>XP</dt><dd>{selectedDifficulty.experienceMultiplier.toFixed(1)}x</dd></div>
<div><dt>Components</dt><dd>iLvl {selectedDifficulty.droppedItemLevel}</dd></div>
</dl>
</div>
</div>
<div className="loot-preview-section">
<div className="loot-preview-section">
<div className="equipment-heading toggle-heading">
<div>
<p className="eyebrow">Encounter Rewards</p>
@@ -793,9 +847,9 @@ function App() {
</div>
</>
)}
</div>
{SHOW_LEADERBOARDS && (
<div className="leaderboard-section">
</div>
{SHOW_LEADERBOARDS && (
<div className="leaderboard-section">
<div className="equipment-heading toggle-heading">
<div>
<p className="eyebrow">Efficiency Rankings</p>
@@ -872,8 +926,10 @@ function App() {
</div>
</>
)}
</div>
)}
</aside>
</div>
)}
</section>
)}
+312 -105
View File
@@ -10,6 +10,10 @@ import {
import {
INITIAL_PARTY,
RAID_PARTY,
DEFAULT_GROUP_HEAL_TARGETS,
groupHealTargets,
partyDamageOutput,
tankPressureTargets,
type CombatLogEntry,
type PartyMember,
type Spell,
@@ -35,7 +39,6 @@ import {
} from '../dualScreen'
const TICK_MS = 700
const TARGET_RENDER_THROTTLE_MS = 180
type RoguelikeMode = 'dungeon' | 'raid'
type RoguelikeUpgradeTiming = 'boss' | 'encounter'
@@ -110,6 +113,43 @@ function healMember(member: PartyMember, amount: number) {
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) {
return upgrades.filter((upgrade) => upgrade.id === id).length
}
@@ -192,9 +232,14 @@ function spellResourceCost(spell: Spell, upgrades: RoguelikeUpgrade[], freeCastR
function toCombatSpell(ability: Ability, key: string, healingPower: number): Spell {
const kinds: Record<string, Spell['kind']> = {
direct_heal: 'direct',
direct_hot: 'direct',
heal_over_time: 'hot',
party_heal: 'group',
party_hot: 'group',
party_absorb: 'group',
absorb: 'shield',
damage_reduction: 'damage_reduction',
bounce_heal: 'bounce_heal',
cleanse: 'cleanse',
}
return {
@@ -207,6 +252,7 @@ function toCombatSpell(ability: Ability, key: string, healingPower: number): Spe
power: ability.power + healingPower,
glyph: ability.glyph,
kind: kinds[ability.spellType] ?? 'direct',
effectType: ability.spellType,
}
}
@@ -290,6 +336,7 @@ function makeRoguelikeSegment(
export function CombatScreen({
difficulty,
dungeon,
hardMode = false,
profile,
startPart = 1,
roguelikeMode,
@@ -301,6 +348,7 @@ export function CombatScreen({
}: {
difficulty: Difficulty
dungeon: Dungeon
hardMode?: boolean
profile: CharacterProfile
startPart?: number
roguelikeMode?: RoguelikeMode
@@ -351,15 +399,16 @@ export function CombatScreen({
const sectionName = isRoguelike ? 'Stage' : dungeon.contentType === 'raid' ? 'Phase' : 'Part'
const contentName = isRoguelike ? 'Roguelike' : dungeon.contentType === 'raid' ? 'Raid' : 'Dungeon'
const initialEncounterIndex = (startPart - 1) * 3
const enemyCount = hardMode ? 2 : 1
const initialCombatState = useMemo<SinglePlayerCombatState>(() => ({
party: partyTemplate,
resource: maxResource,
enemyHealth: encounters[initialEncounterIndex].maxHealth,
enemyHealth: encounters[initialEncounterIndex].maxHealth * enemyCount,
cooldowns: {},
elapsedTicks: 0,
castsTowardFree: 0,
freeCastReady: false,
}), [encounters, initialEncounterIndex, maxResource, partyTemplate])
}), [encounters, enemyCount, initialEncounterIndex, maxResource, partyTemplate])
const [combatState, setCombatState] = useState<SinglePlayerCombatState>(() => initialCombatState)
const [selectedId, setSelectedId] = useState(partyTemplate[0].id)
const [encounterIndex, setEncounterIndex] = useState(initialEncounterIndex)
@@ -378,7 +427,7 @@ export function CombatScreen({
const [upgradeChoices, setUpgradeChoices] = useState<RoguelikeUpgrade[]>([])
const rewardClaimedRef = useRef(false)
const profileRefreshedRef = useRef(false)
const rolledEncounterIdsRef = useRef(new Set<number>())
const rolledEncounterIdsRef = useRef(new Set<string>())
const runTokenRef = useRef(crypto.randomUUID())
const resourceSpentRef = useRef(0)
const runStartedAtRef = useRef(0)
@@ -387,16 +436,24 @@ export function CombatScreen({
const nextFloatingTextId = useRef(1)
const combatRef = useRef(initialCombatState)
const selectedIdRef = useRef(partyTemplate[0].id)
const selectedRenderTimeoutRef = useRef<number | null>(null)
const lastSelectedRenderAtRef = useRef(0)
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 encounter = encounters[encounterIndex]
const encounterMaxHealth = encounter.maxHealth * enemyCount
const currentPart = getCurrentPart(encounterIndex)
const completedSections = dungeon.contentType === 'raid'
? profile.completedRaidPhases
: profile.completedDungeonParts
const canContinueAfterPart = !hardMode || completedSections >= currentPart + 1
const firstEncounterIndex = (startPart - 1) * 3
const expectedLootRolls = encounters
.slice(firstEncounterIndex, encounterIndex + 1)
.filter((candidate) => candidate.lootTables.some((entry) => entry.difficultyId === difficulty.id))
.length
.length * enemyCount
const isPartBoss = encounter.isBoss && encounterIndex % 3 === 2
const isFinalBoss = isPartBoss && encounterIndex === encounters.length - 1
const playerHealer = party.find((member) => member.id === 'mira')
@@ -418,6 +475,9 @@ export function CombatScreen({
enabled: dualScreenEnabled,
} = useDualScreen()
statusRef.current = status
pausedRef.current = paused
useEffect(() => {
const now = Date.now()
runStartedAtRef.current = now
@@ -438,32 +498,27 @@ export function CombatScreen({
? nextState(combatRef.current)
: nextState
combatRef.current = next
setSelectedId(selectedIdRef.current)
setCombatState(next)
}, [])
const syncSelectedTargetDom = useCallback((id: string) => {
document.querySelectorAll<HTMLButtonElement>('[data-party-member-id]').forEach((button) => {
const selected = button.dataset.partyMemberId === id
button.classList.toggle('selected', selected)
button.setAttribute('aria-pressed', String(selected))
})
}, [])
const setSelectedTargetId = useCallback((id: string) => {
if (selectedIdRef.current === id) return
selectedIdRef.current = id
const now = performance.now()
const elapsed = now - lastSelectedRenderAtRef.current
if (elapsed >= TARGET_RENDER_THROTTLE_MS) {
lastSelectedRenderAtRef.current = now
setSelectedId(id)
return
}
if (selectedRenderTimeoutRef.current !== null) return
selectedRenderTimeoutRef.current = window.setTimeout(() => {
selectedRenderTimeoutRef.current = null
lastSelectedRenderAtRef.current = performance.now()
setSelectedId(selectedIdRef.current)
}, TARGET_RENDER_THROTTLE_MS - elapsed)
}, [])
syncSelectedTargetDom(id)
}, [syncSelectedTargetDom])
useEffect(() => () => {
if (selectedRenderTimeoutRef.current !== null) {
window.clearTimeout(selectedRenderTimeoutRef.current)
}
}, [])
useEffect(() => {
syncSelectedTargetDom(selectedIdRef.current)
}, [combatState, syncSelectedTargetDom])
const addLog = useCallback((text: string, tone: CombatLogEntry['tone']) => {
const entry = { id: nextLogId.current++, text, tone }
@@ -480,10 +535,12 @@ export function CombatScreen({
}, [])
const requestLootRoll = useCallback(
(encounterId: number) => {
if (rolledEncounterIdsRef.current.has(encounterId)) return
rolledEncounterIdsRef.current.add(encounterId)
rollEncounterLoot(encounterId, difficulty.id, runTokenRef.current)
(encounterId: number, rollIndex = 0) => {
const rollKey = `${encounterId}:${rollIndex}`
if (rolledEncounterIdsRef.current.has(rollKey)) return
rolledEncounterIdsRef.current.add(rollKey)
const runToken = rollIndex === 0 ? runTokenRef.current : `${runTokenRef.current}-hard-${rollIndex}`
rollEncounterLoot(encounterId, difficulty.id, runToken)
.then((result) => {
setLootRolls((current) => [...current, result])
const awarded = result.items
@@ -515,7 +572,7 @@ export function CombatScreen({
setCombat({
party: freshParty,
resource: maxResource,
enemyHealth: nextEncounters[initialEncounterIndex].maxHealth,
enemyHealth: nextEncounters[initialEncounterIndex].maxHealth * enemyCount,
cooldowns: {},
elapsedTicks: 0,
castsTowardFree: 0,
@@ -543,7 +600,7 @@ export function CombatScreen({
runStartedAtRef.current = Date.now()
partStartTimesRef.current = { [startPart]: runStartedAtRef.current }
setLog([{ id: nextLogId.current++, text: 'A new run begins.', tone: 'system' }])
}, [difficulty, initialEncounterIndex, maxResource, partyTemplate, roguelikeMode, roguelikePool, setCombat, setSelectedTargetId, startPart, staticEncounters])
}, [difficulty, enemyCount, initialEncounterIndex, maxResource, partyTemplate, roguelikeMode, roguelikePool, setCombat, setSelectedTargetId, startPart, staticEncounters])
const castSpell = useCallback(
(spell: Spell) => {
@@ -561,7 +618,13 @@ export function CombatScreen({
const directTargets = new Set([targetId])
const hotTargets = new Set<string>()
const shieldTargets = new Set<string>()
if (spell.kind === 'hot') hotTargets.add(targetId)
const extraTargets = upgradeStackCount(roguelikeUpgrades, `slot${spell.key as SlotKey}-extra-target` as RoguelikeUpgradeId)
const groupTargets = new Set(
spell.kind === 'group'
? groupHealTargets(current.party, DEFAULT_GROUP_HEAL_TARGETS + extraTargets).map((member) => member.id)
: [],
)
if (spell.kind === 'hot' || spell.effectType === 'direct_hot') hotTargets.add(targetId)
if (spell.kind === 'shield') shieldTargets.add(targetId)
if (spell.name === 'Mend' && activeSetEffects.has('mend_extra_target')) {
const extra = extraTarget([targetId])
@@ -574,7 +637,6 @@ export function CombatScreen({
if (spell.name === 'Mend' && activeSetEffects.has('mend_applies_renew')) {
hotTargets.add(targetId)
}
const extraTargets = upgradeStackCount(roguelikeUpgrades, `slot${spell.key as SlotKey}-extra-target` as RoguelikeUpgradeId)
for (let index = 0; index < extraTargets; index += 1) {
if (spell.kind === 'group') break
if (spell.kind === 'hot') {
@@ -594,16 +656,39 @@ export function CombatScreen({
const nextParty = current.party.map((member) => {
if (member.health <= 0) return member
if (spell.kind === 'group') {
if (!groupTargets.has(member.id)) return member
if (spell.effectType === 'party_absorb') {
const power = Math.round(spell.power * (1.25 ** upgradeStackCount(roguelikeUpgrades, 'shield-boost')))
return { ...member, shield: Math.max(member.shield, power) }
}
if (spell.effectType === 'party_hot') {
return {
...member,
hotTicks: 0,
hotEffects: addHotEffect(member, spell),
}
}
const power = Math.round(spell.power * (1.25 ** upgradeStackCount(roguelikeUpgrades, 'group-heal-boost')))
const nextHealth = healMember(member, power)
addFloatingHeal(member.id, Math.max(0, nextHealth - member.health))
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') {
const power = Math.round(spell.power * (1.25 ** upgradeStackCount(roguelikeUpgrades, 'shield-boost')))
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') {
return {
...member,
@@ -622,7 +707,8 @@ export function CombatScreen({
return {
...member,
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')
@@ -676,6 +762,7 @@ export function CombatScreen({
completedPart,
runStartPart,
[partDuration(1), partDuration(2), partDuration(3)],
hardMode,
)
.then((result) => {
setReward(result)
@@ -688,7 +775,7 @@ export function CombatScreen({
)
})
},
[difficulty.id, dungeon.id, onProfileUpdated],
[difficulty.id, dungeon.id, hardMode, onProfileUpdated],
)
const finishRoguelikeRun = useCallback(
@@ -786,6 +873,9 @@ export function CombatScreen({
poisonStacks: undefined,
maxHealthPenaltyTicks: undefined,
healingReductionTicks: undefined,
hotEffects: [],
bounceHeals: [],
damageReductionTicks: undefined,
}))
const nextStage = clearedBoss ? roguelikeStage + 1 : roguelikeStage
const nextSegment = clearedBoss
@@ -804,7 +894,7 @@ export function CombatScreen({
setCombat({
...current,
party: recoveredParty,
enemyHealth: nextEncounter.maxHealth,
enemyHealth: nextEncounter.maxHealth * enemyCount,
elapsedTicks: 0,
cooldowns: {},
resource: clamp(current.resource + Math.round(maxResource * 0.25), 0, maxResource),
@@ -812,7 +902,7 @@ export function CombatScreen({
setUpgradeChoices([])
setStatus('playing')
addLog(`${upgrade.name} gained. ${nextEncounter.enemyName} approaches.`, 'system')
}, [addLog, difficulty, encounterIndex, encounters, maxResource, roguelikeMode, roguelikePool, roguelikeStage, setCombat])
}, [addLog, difficulty, encounterIndex, encounters, enemyCount, maxResource, roguelikeMode, roguelikePool, roguelikeStage, setCombat])
useGameAction((action, device) => {
if (action === 'pause' || (action === 'back' && device === 'pc')) {
@@ -854,9 +944,7 @@ export function CombatScreen({
if (spell) castSpell(spell)
})
useEffect(() => {
if (status !== 'playing' || paused) return
const timer = window.setInterval(() => {
const runCombatTick = useCallback(() => {
const current = combatRef.current
const nextElapsedTicks = current.elapsedTicks + 1
const nextCooldowns = Object.fromEntries(
@@ -904,19 +992,49 @@ export function CombatScreen({
}
const healerBeforeDamage = current.party.find((member) => member.id === 'mira')
const nextParty = current.party.map((member) => {
const tankPressure = tankPressureTargets(current.party)
const tankPressureIds = new Set(tankPressure.targets.map((member) => member.id))
const pendingJumpHeals: Array<{
targetId: string
heal: NonNullable<PartyMember['bounceHeals']>[number]
}> = []
const damagedParty = current.party.map((member) => {
if (member.health <= 0) return member
let damage = member.id === primaryTarget.id ? encounter.damage : 0
if (member.role === 'Tank') damage += encounter.tankDamage
if (tankBuster && member.role === 'Tank') damage += Math.round(22 * difficulty.damageMultiplier)
if (tankPressureIds.has(member.id)) {
damage += Math.round(encounter.tankDamage * tankPressure.multiplier)
}
if (tankBuster && tankPressureIds.has(member.id)) {
damage += Math.round(22 * difficulty.damageMultiplier * tankPressure.multiplier)
}
if (bossPulse) damage += Math.round(12 * difficulty.damageMultiplier)
if (member.debuff) damage += Math.round(7 * difficulty.damageMultiplier)
const nextPoisonStacks = appliesPoison && member.id === primaryTarget.id
? Math.max(1, (member.poisonStacks ?? 0) + 1)
: member.poisonStacks ?? 0
if (nextPoisonStacks > 0) damage += Math.round((4 + nextPoisonStacks * 4) * difficulty.damageMultiplier)
damage *= enemyCount
if ((member.damageReductionTicks ?? 0) > 0) {
damage = Math.round(damage * 0.5)
}
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)
const nextMaxHealthPenaltyTicks = appliesMaxHealthCut && member.id === primaryTarget.id
? 15
@@ -930,9 +1048,12 @@ export function CombatScreen({
: Math.max(0, (member.debuffTicks ?? 0) - 1)
return {
...member,
health: clamp(member.health - damage + absorbed + healing, 0, nextEffectiveMaxHealth),
health: clamp(clamp(member.health + healing, 0, nextEffectiveMaxHealth) - damage + absorbed, 0, nextEffectiveMaxHealth),
shield: Math.max(0, member.shield - damage),
hotTicks: Math.max(0, member.hotTicks - 1),
hotTicks: 0,
hotEffects: tickHotEffects(hotEffects),
bounceHeals: nextBounceHeals,
damageReductionTicks: Math.max(0, (member.damageReductionTicks ?? 0) - 1),
debuff: nextDebuffTicks > 0
? (appliesDebuff && member.id === primaryTarget.id ? 'Searing Mark' : member.debuff)
: undefined,
@@ -942,6 +1063,17 @@ export function CombatScreen({
healingReductionTicks: nextHealingReductionTicks,
}
})
const nextParty = damagedParty.map((member) => {
const jumped = pendingJumpHeals.filter((jump) => jump.targetId === member.id)
if (jumped.length === 0) return member
return {
...member,
bounceHeals: [
...(member.bounceHeals ?? []),
...jumped.map((jump) => jump.heal),
],
}
})
const healerAfterDamage = nextParty.find((member) => member.id === 'mira')
if (
@@ -968,7 +1100,7 @@ export function CombatScreen({
return
}
const nextEnemyHealth = current.enemyHealth - encounter.partyDamage
const nextEnemyHealth = current.enemyHealth - partyDamageOutput(nextParty, encounter.partyDamage)
if (nextEnemyHealth > 0) {
setCombat({
...current,
@@ -982,7 +1114,9 @@ export function CombatScreen({
}
if (!isRoguelike && encounter.lootTables.some((entry) => entry.difficultyId === difficulty.id)) {
requestLootRoll(encounter.id)
for (let rollIndex = 0; rollIndex < enemyCount; rollIndex += 1) {
requestLootRoll(encounter.id, rollIndex)
}
}
if (isRoguelike && (upgradesEveryEncounter || encounter.isBoss)) {
@@ -1039,6 +1173,9 @@ export function CombatScreen({
poisonStacks: undefined,
maxHealthPenaltyTicks: undefined,
healingReductionTicks: undefined,
hotEffects: [],
bounceHeals: [],
damageReductionTicks: undefined,
}))
setEncounterIndex((value) => value + 1)
setCombat({
@@ -1047,15 +1184,14 @@ export function CombatScreen({
resource: nextResource,
cooldowns: nextCooldowns,
elapsedTicks: 0,
enemyHealth: nextEncounter.maxHealth,
enemyHealth: nextEncounter.maxHealth * enemyCount,
})
addLog(`${encounter.enemyName} defeated. ${nextEncounter.enemyName} approaches.`, 'system')
}, TICK_MS)
return () => window.clearInterval(timer)
}, [
addLog,
addFloatingHeal,
difficulty.damageMultiplier,
enemyCount,
encounter,
encounterIndex,
encounters,
@@ -1073,11 +1209,43 @@ export function CombatScreen({
profile.character.name,
setCombat,
startPart,
status,
currentPart,
paused,
])
useEffect(() => {
runCombatTickRef.current = runCombatTick
}, [runCombatTick])
useEffect(() => {
if (status === 'playing' && !paused) {
if (!combatClockActiveRef.current) {
lastCombatTickAtRef.current = performance.now()
combatClockActiveRef.current = true
}
return
}
combatClockActiveRef.current = false
}, [paused, status])
useEffect(() => {
const timer = window.setInterval(() => {
if (
!combatClockActiveRef.current
|| statusRef.current !== 'playing'
|| pausedRef.current
) return
const now = performance.now()
const 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(() => {
if (
!reward
@@ -1092,15 +1260,23 @@ export function CombatScreen({
})
}, [expectedLootRolls, lootRolls.length, onProfileUpdated, reward])
const enemyPercent = (enemyHealth / encounter.maxHealth) * 100
const enemyPercent = (enemyHealth / encounterMaxHealth) * 100
const enemyHealthSegments = Array.from({ length: enemyCount }, (_, index) => {
const remaining = clamp(enemyHealth - encounter.maxHealth * index, 0, encounter.maxHealth)
return {
index,
health: remaining,
percent: (remaining / encounter.maxHealth) * 100,
}
}).reverse()
const dualScreenState = useMemo<DualScreenCombatState>(() => ({
difficultyName: difficulty.name,
dungeonName: dungeon.name,
dungeonName: hardMode ? `${dungeon.name} Hard` : dungeon.name,
contentName,
encounterName: encounter.enemyName,
encounterDescription: encounter.description,
encounterHealth: enemyHealth,
encounterMaxHealth: encounter.maxHealth,
encounterMaxHealth,
encounterIsBoss: encounter.isBoss,
encounterIndex,
encounterCount: encounters.length,
@@ -1142,7 +1318,8 @@ export function CombatScreen({
encounter.description,
encounter.enemyName,
encounter.isBoss,
encounter.maxHealth,
encounterMaxHealth,
hardMode,
enemyHealth,
encounterIndex,
encounters.length,
@@ -1171,7 +1348,7 @@ export function CombatScreen({
>
{!dualScreenEnabled && <header className="topbar">
<div>
<p className="eyebrow">{difficulty.name} - Item Level {difficulty.droppedItemLevel}</p>
<p className="eyebrow">{difficulty.name}{hardMode ? ' Hard' : ''} - Item Level {difficulty.droppedItemLevel}</p>
<h1>{dungeon.name}</h1>
</div>
<div className="combat-header-actions">
@@ -1194,10 +1371,21 @@ export function CombatScreen({
</div>
<div className="enemy-info">
<div className="bar-label">
<strong>{encounter.enemyName}</strong>
<span>{Math.ceil(enemyHealth)} / {encounter.maxHealth}</span>
<strong>{hardMode ? `${encounter.enemyName} x2` : encounter.enemyName}</strong>
<span>{Math.ceil(enemyHealth)} / {encounterMaxHealth}</span>
</div>
<div className="bar enemy-health"><span style={{ width: `${enemyPercent}%` }} /></div>
{hardMode ? (
<div className="hard-enemy-bars">
{enemyHealthSegments.map((segment) => (
<div className="bar enemy-health" key={segment.index}>
<span style={{ width: `${segment.percent}%` }} />
<em>{encounter.enemyName} {segment.index + 1}: {Math.ceil(segment.health)} / {encounter.maxHealth}</em>
</div>
))}
</div>
) : (
<div className="bar enemy-health"><span style={{ width: `${enemyPercent}%` }} /></div>
)}
<p>{encounter.description}</p>
</div>
</section>
@@ -1218,17 +1406,16 @@ export function CombatScreen({
{party.map((member) => (
<button
className={`party-member ${selectedId === member.id ? 'selected' : ''} ${member.health <= 0 ? 'dead' : ''}`}
data-party-member-id={member.id}
key={member.id}
onClick={() => setSelectedTargetId(member.id)}
aria-pressed={selectedId === member.id}
type="button"
>
{selectedId === member.id && (
<span className="target-marker" aria-hidden="true">
<i />
Target
</span>
)}
<span className="target-marker" aria-hidden="true">
<i />
Target
</span>
<div className="member-header">
<span className={`role role-${member.role.toLowerCase()}`}>{member.role[0]}</span>
<strong>{member.name}</strong>
@@ -1245,7 +1432,14 @@ export function CombatScreen({
.map((entry) => <span className="floating-heal" key={entry.id}>+{entry.value}</span>)}
</div>
<div className="member-effects">
{member.hotTicks > 0 && <span className="buff">Renew {formatEffectTime(member.hotTicks)}</span>}
{memberHotEffects(member).map((effect) => (
<span className="buff" key={effect.id}>{effect.label} {formatEffectTime(effect.ticks)}</span>
))}
{member.shield > 0 && <span className="buff">Shield {Math.ceil(member.shield)}</span>}
{(member.damageReductionTicks ?? 0) > 0 && <span className="buff">Barkskin {formatEffectTime(member.damageReductionTicks ?? 0)}</span>}
{(member.bounceHeals ?? []).map((effect) => (
<span className="buff" key={effect.id}>{effect.label} {effect.charges}</span>
))}
{member.debuff && member.debuffTicks && <span className="debuff">{member.debuff} {formatEffectTime(member.debuffTicks)}</span>}
{member.poisonStacks && member.poisonStacks > 0 && <span className="debuff">Poison {member.poisonStacks}</span>}
{member.maxHealthPenaltyTicks && member.maxHealthPenaltyTicks > 0 && <span className="debuff">Max HP -25% {formatEffectTime(member.maxHealthPenaltyTicks)}</span>}
@@ -1324,7 +1518,7 @@ export function CombatScreen({
{status === 'upgrade-choice' && (
<div className="result-screen">
<div>
<div className="pvp-upgrade-dialog pve-upgrade-dialog">
<p className="eyebrow">
{encounter.isBoss
? `Roguelike Stage ${roguelikeStage} Complete`
@@ -1332,13 +1526,18 @@ export function CombatScreen({
</p>
<h2>Choose Upgrade</h2>
<p>Pick one upgrade before the next fight.</p>
<div className="upgrade-choice-grid">
{upgradeChoices.map((upgrade) => (
<button key={upgrade.id} onClick={() => chooseRoguelikeUpgrade(upgrade)} type="button">
<strong>{upgrade.name}</strong>
<small>{upgrade.description}</small>
</button>
))}
<div className="pvp-choice-columns">
<div>
<strong>Run Buff</strong>
<div className="upgrade-choice-grid">
{upgradeChoices.map((upgrade) => (
<button key={upgrade.id} onClick={() => chooseRoguelikeUpgrade(upgrade)} type="button">
<strong>{upgrade.name}</strong>
<small>{upgrade.description}</small>
</button>
))}
</div>
</div>
</div>
{roguelikeUpgrades.length > 0 && (
<p className="roguelike-upgrade-list">
@@ -1470,33 +1669,41 @@ export function CombatScreen({
<div>
<p className="eyebrow">{sectionName} Complete</p>
<h2>{encounter.enemyName} Defeated</h2>
<p>Proceed to {sectionName} {currentPart + 1} or end the run?</p>
<button
onClick={() => {
const nextIndex = encounterIndex + 1
partStartTimesRef.current[currentPart + 1] = Date.now()
const nextEncounter = encounters[nextIndex]
const current = combatRef.current
const recoveredParty = current.party.map((member) => ({
...member,
health: clamp(member.health + 35, 0, member.maxHealth),
debuff: undefined,
debuffTicks: undefined,
}))
setEncounterIndex(nextIndex)
setCombat({
...current,
party: recoveredParty,
enemyHealth: nextEncounter.maxHealth,
elapsedTicks: 0,
})
setStatus('playing')
addLog(`Proceeding to ${sectionName} ${currentPart + 1}. ${nextEncounter.enemyName} approaches.`, 'system')
}}
type="button"
>
Continue to {sectionName} {currentPart + 1}
</button>
<p>{canContinueAfterPart ? `Proceed to ${sectionName} ${currentPart + 1} or end the run?` : 'Hard mode for this section is complete.'}</p>
{canContinueAfterPart && (
<button
onClick={() => {
const nextIndex = encounterIndex + 1
partStartTimesRef.current[currentPart + 1] = Date.now()
const nextEncounter = encounters[nextIndex]
const current = combatRef.current
const recoveredParty = current.party.map((member) => ({
...member,
health: clamp(member.health + 35, 0, member.maxHealth),
debuff: undefined,
debuffTicks: undefined,
poisonStacks: undefined,
maxHealthPenaltyTicks: undefined,
healingReductionTicks: undefined,
hotEffects: [],
bounceHeals: [],
damageReductionTicks: undefined,
}))
setEncounterIndex(nextIndex)
setCombat({
...current,
party: recoveredParty,
enemyHealth: nextEncounter.maxHealth * enemyCount,
elapsedTicks: 0,
})
setStatus('playing')
addLog(`Proceeding to ${sectionName} ${currentPart + 1}. ${nextEncounter.enemyName} approaches.`, 'system')
}}
type="button"
>
Continue to {sectionName} {currentPart + 1}
</button>
)}
<button className="secondary-result-button" onClick={() => finishRun(currentPart, startPart)} type="button">
End Run
</button>
+41 -2
View File
@@ -4,6 +4,7 @@ import {
type CharacterProfile,
type GameClass,
} from '../profile'
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../dualScreen'
import { EquipmentScreen } from './EquipmentScreen'
import { TalentScreen } from './TalentScreen'
@@ -14,7 +15,8 @@ type Props = {
}
export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
const [activeTab, setActiveTab] = useState<'equipment' | 'talents' | 'class'>('class')
const [activeTab, setActiveTab] = useState<'equipment' | 'crafting' | 'talents' | 'class'>('class')
const { enabled: dualScreenEnabled } = useDualScreen()
const [classId, setClassId] = useState(profile.character.classId)
const [slots, setSlots] = useState<Array<number | null>>(profile.abilitySlots)
const [selectedSlot, setSelectedSlot] = useState(0)
@@ -63,6 +65,29 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
)
}
const classWorkshopState = useMemo<DualScreenWorkshopState | null>(() => {
if (activeTab !== 'class') return null
return {
mode: 'class',
title: 'Ability Library',
subtitle: gameClass.name,
summary: `Selected slot ${selectedSlot + 1}. ${message || 'Choose an ability for the active loadout.'}`,
items: gameClass.spells.map((ability) => {
const locked = ability.unlockLevel > profile.character.level
const equipped = slots.includes(ability.id)
return {
glyph: locked ? 'L' : ability.glyph,
title: ability.name,
meta: locked ? `Level ${ability.unlockLevel}` : `${ability.cost} ${gameClass.resourceName}`,
detail: ability.description,
status: equipped ? 'Equipped' : locked ? 'Locked' : '',
}
}),
}
}, [activeTab, gameClass, message, profile.character.level, selectedSlot, slots])
useDualScreenWorkshopPublisher(classWorkshopState, dualScreenEnabled)
async function persistChanges() {
saveScroll()
setSaving(true)
@@ -80,7 +105,7 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
return (
<section className="content-screen customize-screen">
<div className="screen-heading">
<div className="screen-heading customize-heading">
<div>
<p className="eyebrow">Character Workshop</p>
<h1>Customize Character</h1>
@@ -89,8 +114,10 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
</div>
<div className="customize-tabs" role="tablist" aria-label="Customize character sections">
<button className="back-button customize-tab-back" onClick={onBack} type="button">Back</button>
{([
{ key: 'equipment', label: 'Equipment' },
{ key: 'crafting', label: 'Crafting' },
{ key: 'talents', label: 'Talents' },
{ key: 'class', label: 'Class' },
] as const).map((tab) => (
@@ -110,6 +137,18 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
{activeTab === 'equipment' && (
<EquipmentScreen
embedded
mode="equipment"
showModeTabs={false}
profile={profile}
onUpdated={onSaved}
/>
)}
{activeTab === 'crafting' && (
<EquipmentScreen
embedded
mode="crafting"
showModeTabs={false}
profile={profile}
onUpdated={onSaved}
/>
+317 -117
View File
@@ -9,6 +9,7 @@ import {
type EquipmentSlot,
type Item,
} from '../profile'
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../dualScreen'
const SLOT_LABELS: Record<EquipmentSlot, string> = {
weapon: 'Weapon',
@@ -24,16 +25,29 @@ const SLOT_LABELS: Record<EquipmentSlot, string> = {
}
const EQUIPMENT_LIST_PAGE_SIZE = 3
const CRAFTING_LIST_PAGE_SIZE = 6
const CRAFTING_LIST_PAGE_SIZE = 3
const CRAFTING_FILTER_SLOTS = (Object.keys(SLOT_LABELS) as EquipmentSlot[])
.filter((slot) => slot !== 'component')
const DIRECT_CRAFT_ITEM_LEVELS = new Set([1, 10, 20, 25])
type Props = {
profile: CharacterProfile
onBack?: () => void
onUpdated: (profile: CharacterProfile) => void
embedded?: boolean
mode?: 'equipment' | 'crafting'
showModeTabs?: boolean
}
export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }: Props) {
export function EquipmentScreen({
profile,
onBack,
onUpdated,
embedded = false,
mode,
showModeTabs = true,
}: Props) {
const { enabled: dualScreenEnabled } = useDualScreen()
const totalItemCount = profile.inventory.reduce(
(total, item) => total + item.quantity,
0,
@@ -49,24 +63,23 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
const [crafting, setCrafting] = useState(false)
const [upgrading, setUpgrading] = useState(false)
const [showSetBonuses, setShowSetBonuses] = useState(false)
const [equipmentTab, setEquipmentTab] = useState<'equipment' | 'crafting'>('equipment')
const [equipmentTab, setEquipmentTab] = useState<'equipment' | 'crafting'>(mode ?? 'equipment')
const [inventoryPage, setInventoryPage] = useState(0)
const [recipePage, setRecipePage] = useState(0)
const [message, setMessage] = useState('')
const scrollRef = useRef<number>(0)
const selectedItem = profile.inventory.find((item) => item.id === selectedItemId)
const firstRecipe = profile.craftingRecipes.find((recipe) => recipe.canCraft)
?? profile.craftingRecipes[0]
const craftableRecipes = profile.craftingRecipes.filter((recipe) =>
DIRECT_CRAFT_ITEM_LEVELS.has(recipe.item.itemLevel),
)
const firstRecipe = craftableRecipes.find((recipe) => recipe.canCraft)
?? craftableRecipes[0]
const [selectedRecipeId, setSelectedRecipeId] = useState<number | null>(
firstRecipe?.id ?? null,
)
const selectedRecipe = profile.craftingRecipes.find((recipe) => recipe.id === selectedRecipeId)
const selectedRecipeRequiresUpgrade = selectedRecipe
? profile.craftingRecipes.some((recipe) =>
recipe.sourceEncounterId === selectedRecipe.sourceEncounterId
&& recipe.item.slot === selectedRecipe.item.slot
&& recipe.item.itemLevel < selectedRecipe.item.itemLevel,
)
? !DIRECT_CRAFT_ITEM_LEVELS.has(selectedRecipe.item.itemLevel)
: false
const selectedItemRecipe = selectedItem
? profile.craftingRecipes.find((recipe) => recipe.item.id === selectedItem.id)
@@ -113,12 +126,14 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
const [slotFilter, setSlotFilter] = useState<EquipmentSlot | 'all'>('all')
const [levelFilter, setLevelFilter] = useState<number | null>(null)
const availableLevels = useMemo(
() => [...new Set(profile.craftingRecipes.map((r) => r.item.itemLevel))].sort((a, b) => b - a),
() => [...new Set(profile.craftingRecipes
.filter((r) => DIRECT_CRAFT_ITEM_LEVELS.has(r.item.itemLevel))
.map((r) => r.item.itemLevel))].sort((a, b) => b - a),
[profile.craftingRecipes],
)
const filteredRecipes = useMemo(
() => {
let result = [...profile.craftingRecipes]
let result = profile.craftingRecipes.filter((r) => DIRECT_CRAFT_ITEM_LEVELS.has(r.item.itemLevel))
if (slotFilter !== 'all') result = result.filter((r) => r.item.slot === slotFilter)
if (levelFilter !== null) result = result.filter((r) => r.item.itemLevel === levelFilter)
result.sort((a, b) => b.item.itemLevel - a.item.itemLevel)
@@ -126,6 +141,19 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
},
[profile.craftingRecipes, slotFilter, levelFilter],
)
const readyRecipeCount = filteredRecipes.filter((recipe) => recipe.canCraft).length
const slotRecipeCounts = useMemo(
() => new Map(
(Object.keys(SLOT_LABELS) as EquipmentSlot[]).map((slot) => [
slot,
profile.craftingRecipes.filter((recipe) =>
recipe.item.slot === slot
&& DIRECT_CRAFT_ITEM_LEVELS.has(recipe.item.itemLevel),
).length,
]),
),
[profile.craftingRecipes],
)
const recipePageCount = Math.max(
1,
Math.ceil(filteredRecipes.length / CRAFTING_LIST_PAGE_SIZE),
@@ -147,12 +175,26 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
setRecipePage((current) => Math.min(current, recipePageCount - 1))
}, [recipePageCount])
useEffect(() => {
if (filteredRecipes.length === 0) {
setSelectedRecipeId(null)
return
}
if (!filteredRecipes.some((recipe) => recipe.id === selectedRecipeId)) {
setSelectedRecipeId(filteredRecipes[0].id)
}
}, [filteredRecipes, selectedRecipeId])
useEffect(() => {
if (equipmentTab === 'crafting') {
loadProfile().then((fresh) => onUpdated(fresh)).catch(() => {})
}
}, [equipmentTab])
useEffect(() => {
if (mode) setEquipmentTab(mode)
}, [mode])
function saveScroll() {
scrollRef.current = window.scrollY
}
@@ -227,6 +269,143 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
}
}
function renderEquipmentActions() {
if (!selectedItem) {
return <p>Select an item to inspect it.</p>
}
if (selectedItem.slot === 'component') {
return <p className="component-note">Used in crafting.</p>
}
return (
<>
<ComparisonDelta selected={selectedItem} equipped={comparisonItem} />
<button
className="primary-button"
disabled={selectedItem.equipped || equipping || breakingDown || upgrading}
onClick={equipSelected}
type="button"
>
{selectedItem.equipped ? 'Equipped' : equipping ? 'Equipping...' : 'Equip Item'}
</button>
{upgradeRecipe && (
<button
className="primary-button"
disabled={!upgradeRecipe.canCraft || equipping || breakingDown || upgrading}
onClick={upgradeSelected}
type="button"
>
{upgrading ? 'Upgrading...' : `Upgrade to iLvl ${upgradeRecipe.item.itemLevel}`}
</button>
)}
{(!selectedItem.equipped || selectedItem.quantity > 1) && (
<button
className="breakdown-button"
disabled={equipping || breakingDown || upgrading}
onClick={breakdownSelected}
type="button"
>
{breakingDown
? 'Breaking Down...'
: selectedItem.quantity > 1
? 'Break Down Duplicate'
: 'Break Down'}
</button>
)}
</>
)
}
const workshopState = useMemo<DualScreenWorkshopState>(() => {
if (equipmentTab === 'crafting') {
if (!selectedRecipe) {
return {
mode: 'crafting',
title: 'Craft Output',
subtitle: 'No recipe selected',
items: [],
}
}
return {
mode: 'crafting',
title: selectedRecipe.item.name,
subtitle: `${SLOT_LABELS[selectedRecipe.item.slot]} - Item Level ${selectedRecipe.item.itemLevel}`,
summary: selectedRecipe.item.description,
items: [
{
glyph: selectedRecipe.item.glyph,
title: 'Craft Output',
meta: `+${selectedRecipe.item.healingPower} Healing Power / +${selectedRecipe.item.maxResourceBonus} Max Resource`,
status: selectedRecipe.canCraft ? 'Ready' : 'Missing components',
},
...selectedRecipe.components.map((component) => ({
glyph: component.item.glyph,
title: component.item.name,
meta: `Item Level ${component.item.itemLevel}`,
status: `${component.owned}/${component.quantity}`,
})),
],
}
}
if (!selectedItem) {
return {
mode: 'equipment',
title: 'Equipment Detail',
subtitle: 'No item selected',
items: [],
}
}
return {
mode: 'equipment',
title: selectedItem.slot === 'component' ? 'Crafting Component' : selectedItem.name,
subtitle: `${SLOT_LABELS[selectedItem.slot]} - Item Level ${selectedItem.itemLevel}`,
summary: selectedItem.description,
items: selectedItem.slot === 'component'
? [{
glyph: selectedItem.glyph,
title: selectedItem.name,
meta: `Owned: ${selectedItem.quantity}`,
status: 'Component',
}]
: [
{
glyph: selectedItem.glyph,
title: selectedItem.name,
meta: `+${selectedItem.healingPower} Healing Power / +${selectedItem.maxResourceBonus} Max Resource`,
status: selectedItem.equipped ? 'Equipped' : 'Inventory',
},
...(comparisonItem && comparisonItem.id !== selectedItem.id
? [{
glyph: comparisonItem.glyph,
title: comparisonItem.name,
meta: `+${comparisonItem.healingPower} Healing Power / +${comparisonItem.maxResourceBonus} Max Resource`,
status: 'Currently Equipped',
}]
: [{
title: selectedItem.equipped ? 'Already Equipped' : 'Empty Slot',
status: 'Comparison',
}]),
...(upgradeRecipe
? [
{
glyph: upgradeRecipe.item.glyph,
title: `Upgrade to ${upgradeRecipe.item.name}`,
meta: `Item Level ${upgradeRecipe.item.itemLevel}`,
status: upgradeRecipe.canCraft ? 'Ready' : 'Missing materials',
},
...upgradeRecipe.components.map((component) => ({
glyph: component.item.glyph,
title: component.item.name,
meta: `Required for upgrade`,
status: `${component.owned}/${component.quantity}`,
})),
]
: []),
],
}
}, [comparisonItem, equipmentTab, selectedItem, selectedRecipe, upgradeRecipe])
useDualScreenWorkshopPublisher(workshopState, dualScreenEnabled)
const content = (
<>
{!embedded && (
@@ -253,22 +432,24 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
<GearStat value={`+${profile.gearStats.maxResourceBonus}`} label={`Max ${profile.character.resourceName}`} />
</div>
<nav className="equipment-tabs">
<button
className={`equipment-tab ${equipmentTab === 'equipment' ? 'active' : ''}`}
onClick={() => setEquipmentTab('equipment')}
type="button"
>
Equipment
</button>
<button
className={`equipment-tab ${equipmentTab === 'crafting' ? 'active' : ''}`}
onClick={() => setEquipmentTab('crafting')}
type="button"
>
Crafting
</button>
</nav>
{showModeTabs && (
<nav className="equipment-tabs">
<button
className={`equipment-tab ${equipmentTab === 'equipment' ? 'active' : ''}`}
onClick={() => setEquipmentTab('equipment')}
type="button"
>
Equipment
</button>
<button
className={`equipment-tab ${equipmentTab === 'crafting' ? 'active' : ''}`}
onClick={() => setEquipmentTab('crafting')}
type="button"
>
Crafting
</button>
</nav>
)}
{equipmentTab === 'equipment' ? (
<>
@@ -277,9 +458,6 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
selectedItem.slot === 'component' ? (
<>
<ItemDetail title="Crafting Component" item={selectedItem} />
<div className="equip-action">
<p className="component-note">Used in crafting.</p>
</div>
</>
) : (
<>
@@ -293,41 +471,6 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
<h2>{selectedItem.equipped ? 'Already Equipped' : 'Empty Slot'}</h2>
</div>
)}
<div className="equip-action">
<ComparisonDelta selected={selectedItem} equipped={comparisonItem} />
<button
className="primary-button"
disabled={selectedItem.equipped || equipping || breakingDown || upgrading}
onClick={equipSelected}
type="button"
>
{selectedItem.equipped ? 'Equipped' : equipping ? 'Equipping...' : 'Equip Item'}
</button>
{upgradeRecipe && (
<button
className="primary-button"
disabled={!upgradeRecipe.canCraft || equipping || breakingDown || upgrading}
onClick={upgradeSelected}
type="button"
>
{upgrading ? 'Upgrading...' : `Upgrade to iLvl ${upgradeRecipe.item.itemLevel}`}
</button>
)}
{(!selectedItem.equipped || selectedItem.quantity > 1) && (
<button
className="breakdown-button"
disabled={equipping || breakingDown || upgrading}
onClick={breakdownSelected}
type="button"
>
{breakingDown
? 'Breaking Down...'
: selectedItem.quantity > 1
? 'Break Down Duplicate'
: 'Break Down'}
</button>
)}
</div>
</>
)
) : (
@@ -335,6 +478,10 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
)}
</section>
<section className="equipment-action-strip">
{renderEquipmentActions()}
</section>
<div className="equipment-layout">
<section className="equipped-panel">
<EquipmentHeading eyebrow="Currently Worn" title="Equipment Slots" />
@@ -430,43 +577,82 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
<section className="crafting-panel">
<EquipmentHeading
eyebrow="Crafting"
title="Recipes"
detail={`${filteredRecipes.filter((recipe) => recipe.canCraft).length} ready`}
title="Workbench"
detail={`${readyRecipeCount} ready / ${filteredRecipes.length} shown`}
/>
<div className="crafting-filter-bar">
<select
className="filter-select"
value={slotFilter}
onChange={(e) => {
setSlotFilter(e.target.value as EquipmentSlot | 'all')
setRecipePage(0)
}}
>
<option value="all">All Slots</option>
{(Object.entries(SLOT_LABELS) as [EquipmentSlot, string][]).map(([slot, label]) => (
<option key={slot} value={slot}>{label}</option>
))}
</select>
<select
className="filter-select"
value={levelFilter ?? ''}
onChange={(e) => {
setLevelFilter(e.target.value === '' ? null : Number(e.target.value))
setRecipePage(0)
}}
>
<option value="">All Levels</option>
{availableLevels.map((level) => (
<option key={level} value={level}>Item Level {level}</option>
))}
</select>
</div>
{filteredRecipes.length === 0 && (
<p className="inventory-empty">No crafting recipes match filters.</p>
)}
{filteredRecipes.length > 0 && (
<div className="crafting-layout">
<div className="crafting-list">
<div className="crafting-layout">
<aside className="crafting-filters">
<div>
<p className="eyebrow">Slot</p>
<div className="crafting-filter-grid">
<button
className={slotFilter === 'all' ? 'active' : ''}
onClick={() => {
setSlotFilter('all')
setRecipePage(0)
}}
type="button"
>
<strong>All</strong>
<span>{profile.craftingRecipes.filter((recipe) => DIRECT_CRAFT_ITEM_LEVELS.has(recipe.item.itemLevel)).length}</span>
</button>
{CRAFTING_FILTER_SLOTS.map((slot) => (
<button
className={slotFilter === slot ? 'active' : ''}
disabled={(slotRecipeCounts.get(slot) ?? 0) === 0}
key={slot}
onClick={() => {
setSlotFilter(slot)
setRecipePage(0)
}}
type="button"
>
<strong>{SLOT_LABELS[slot]}</strong>
<span>{slotRecipeCounts.get(slot) ?? 0}</span>
</button>
))}
</div>
</div>
<div>
<p className="eyebrow">Item Level</p>
<div className="crafting-level-row">
<button
className={levelFilter === null ? 'active' : ''}
onClick={() => {
setLevelFilter(null)
setRecipePage(0)
}}
type="button"
>
All
</button>
{availableLevels.map((level) => (
<button
className={levelFilter === level ? 'active' : ''}
key={level}
onClick={() => {
setLevelFilter(level)
setRecipePage(0)
}}
type="button"
>
{level}
</button>
))}
</div>
</div>
</aside>
<section className="crafting-list-panel">
<EquipmentHeading
eyebrow="Recipes"
title={slotFilter === 'all' ? 'Available' : SLOT_LABELS[slotFilter]}
detail={`Page ${recipePage + 1}/${recipePageCount}`}
/>
{filteredRecipes.length === 0 ? (
<p className="inventory-empty">No recipes match filters.</p>
) : (
<div className="crafting-list">
{recipePageItems.map((recipe) => (
<button
className={`${selectedRecipeId === recipe.id ? 'selected' : ''} rarity-${recipe.item.rarity}`}
@@ -482,9 +668,13 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
{recipe.item.setName ? ` - ${recipe.item.setName}` : ''}
</small>
</div>
<i>{recipe.canCraft ? 'Ready' : 'Needs materials'}</i>
<i className={recipe.canCraft ? 'ready' : 'missing'}>
{recipe.canCraft ? 'Ready' : 'Needs materials'}
</i>
</button>
))}
</div>
)}
{filteredRecipes.length > CRAFTING_LIST_PAGE_SIZE && (
<ListPager
label={`Page ${recipePage + 1} / ${recipePageCount}`}
@@ -494,10 +684,26 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
previousDisabled={recipePage <= 0}
/>
)}
<div className="crafting-action-row">
<button
className="primary-button"
disabled={selectedRecipeRequiresUpgrade || !selectedRecipe?.canCraft || crafting}
onClick={craftSelected}
type="button"
>
{crafting ? 'Crafting...' : selectedRecipeRequiresUpgrade ? 'Upgrade Existing Item' : 'Craft Item'}
</button>
</div>
{selectedRecipe && (
</section>
<section className="crafting-detail-panel">
{selectedRecipe ? (
<div className={`crafting-detail rarity-${selectedRecipe.item.rarity}`}>
<ItemDetail title="Craft Output" item={{ ...selectedRecipe.item, quantity: 1, equipped: false }} />
<div className="crafting-detail-heading">
<p className="eyebrow">Materials</p>
<span>{selectedRecipe.canCraft ? 'Ready' : 'Missing components'}</span>
</div>
<div className="crafting-components">
{selectedRecipe.components.map((component) => (
<div
@@ -510,22 +716,16 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
</div>
))}
</div>
<button
className="primary-button"
disabled={selectedRecipeRequiresUpgrade || !selectedRecipe.canCraft || crafting}
onClick={craftSelected}
type="button"
>
{crafting ? 'Crafting...' : selectedRecipeRequiresUpgrade ? 'Upgrade Existing Item' : 'Craft Item'}
</button>
</div>
) : (
<p className="inventory-empty">Select a recipe.</p>
)}
</div>
)}
</section>
</div>
</section>
)}
{profile.setBonuses.length > 0 && (
{equipmentTab === 'equipment' && profile.setBonuses.length > 0 && (
<section className="set-bonus-panel">
<div className="equipment-heading toggle-heading">
<div>
@@ -561,11 +761,11 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
)
if (embedded) {
return <div className="equipment-screen embedded-screen">{content}</div>
return <div className={`equipment-screen embedded-screen ${equipmentTab === 'crafting' ? 'crafting-active' : ''}`}>{content}</div>
}
return (
<section className="content-screen equipment-screen">
<section className={`content-screen equipment-screen ${equipmentTab === 'crafting' ? 'crafting-active' : ''}`}>
{content}
</section>
)
+158 -33
View File
@@ -1,5 +1,15 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { INITIAL_PARTY, RAID_PARTY, type CombatLogEntry, type PartyMember, type Spell } from '../game'
import {
INITIAL_PARTY,
RAID_PARTY,
DEFAULT_GROUP_HEAL_TARGETS,
groupHealTargets,
partyDamageOutput,
tankPressureTargets,
type CombatLogEntry,
type PartyMember,
type Spell,
} from '../game'
import { completeRoguelike, type DungeonReward } from '../profile'
import type { Ability, CharacterProfile, DungeonEncounter } from '../profile'
import type { GameMode } from '../gameRepository'
@@ -78,6 +88,17 @@ type FloatingCombatText = {
value: number
}
type PvpRunSummary = {
bossesKilled: number
experienceGained: number
previousLevel: number | null
newLevel: number | null
levelsGained: number
talentPointsGained: number
unlockedAbilities: DungeonReward['unlockedAbilities']
loot: Array<NonNullable<DungeonReward['bonusItem']>>
}
const BOSS_MECHANICS: BossMechanic[] = [
'party-pulse',
'searing-mark',
@@ -119,6 +140,19 @@ function formatEffectTime(ticks: number) {
return Number.isInteger(seconds) ? `${seconds}s` : `${seconds.toFixed(1)}s`
}
function createEmptyPvpRunSummary(): PvpRunSummary {
return {
bossesKilled: 0,
experienceGained: 0,
previousLevel: null,
newLevel: null,
levelsGained: 0,
talentPointsGained: 0,
unlockedAbilities: [],
loot: [],
}
}
function buffStacks<T extends string>(items: T[], id: T) {
return items.filter((item) => item === id).length
}
@@ -411,11 +445,13 @@ export function PvPRoguelikeScreen({
const [playerSide, setPlayerSide] = useState<SideState>(() => starterSide(partyTemplate, maxResource))
const [cpuSide, setCpuSide] = useState<SideState>(() => starterSide(cpuPartyTemplate, maxResource))
const [selectedId, setSelectedId] = useState(partyTemplate[0].id)
const selectedIdRef = useRef(partyTemplate[0].id)
const [elapsedTicks, setElapsedTicks] = useState(0)
const [cpuDifficulty, setCpuDifficulty] = useState<CpuDifficulty | null>(null)
const [queueMessage, setQueueMessage] = useState('')
const [log, setLog] = useState<CombatLogEntry[]>([{ id: 1, text: 'Queueing opponent...', tone: 'system' }])
const [reward, setReward] = useState<DungeonReward | null>(null)
const [runSummary, setRunSummary] = useState<PvpRunSummary>(() => createEmptyPvpRunSummary())
const [rewardError, setRewardError] = useState('')
const [showEndLog, setShowEndLog] = useState(false)
const [floatingTexts, setFloatingTexts] = useState<FloatingCombatText[]>([])
@@ -433,6 +469,8 @@ export function PvPRoguelikeScreen({
const bossRewardClaimedRef = useRef(new Set<number>())
const cpuDefeatedRef = useRef(false)
const playerClearedEncounterRef = useRef(-1)
const queuedMatchRef = useRef(false)
const encounterPoolRef = useRef(encounterPool)
const playerRef = useRef(playerSide)
const cpuRef = useRef(cpuSide)
const encounter = encounters[encounterIndex]
@@ -459,6 +497,12 @@ export function PvPRoguelikeScreen({
const {
enabled: dualScreenEnabled,
} = useDualScreen()
const setSelectedTargetId = useCallback((id: string) => {
selectedIdRef.current = id
setSelectedId(id)
}, [])
const addLog = useCallback((text: string, tone: CombatLogEntry['tone']) => {
setLog((current) => [createLogEntry(nextLogId, text, tone), ...current].slice(0, 60))
}, [])
@@ -473,11 +517,16 @@ export function PvPRoguelikeScreen({
}, [])
useEffect(() => {
if (queuedMatchRef.current) return
const loadedCheckpoint = loadPvpRoguelikeCheckpoint(profile.character.id, contentType)
setCheckpointStage(loadedCheckpoint)
setStartStage(loadedCheckpoint)
}, [contentType, profile.character.id])
useEffect(() => {
encounterPoolRef.current = encounterPool
}, [encounterPool])
const awardBossReward = useCallback((encounterIndexValue: number) => {
if (bossRewardClaimedRef.current.has(encounterIndexValue)) return
bossRewardClaimedRef.current.add(encounterIndexValue)
@@ -497,6 +546,20 @@ export function PvPRoguelikeScreen({
)
.then((result) => {
setReward(result)
setRunSummary((current) => {
const unlockedById = new Map(current.unlockedAbilities.map((ability) => [ability.id, ability]))
result.unlockedAbilities.forEach((ability) => unlockedById.set(ability.id, ability))
return {
bossesKilled: current.bossesKilled + 1,
experienceGained: current.experienceGained + result.experienceGained,
previousLevel: current.previousLevel ?? result.previousLevel,
newLevel: result.newLevel,
levelsGained: current.levelsGained + result.levelsGained,
talentPointsGained: current.talentPointsGained + result.talentPointsGained,
unlockedAbilities: Array.from(unlockedById.values()),
loot: result.bonusItem ? [...current.loot, result.bonusItem] : current.loot,
}
})
onProfileUpdated(result.profile)
if (result.bonusItem) {
addLog(
@@ -532,8 +595,9 @@ export function PvPRoguelikeScreen({
: null)
}, [opponentDebuffChoicesCatalog, selfBuffChoicesCatalog])
useEffect(() => {
const firstSegment = buildEncounterSegment(encounterPool, startStage, contentType)
const startMatch = useCallback((nextStartStage?: number) => {
const matchStartStage = nextStartStage ?? loadPvpRoguelikeCheckpoint(profile.character.id, contentType)
const firstSegment = buildEncounterSegment(encounterPoolRef.current, matchStartStage, contentType)
const firstEncounter = firstSegment[0]
const basePlayer = starterSide(partyTemplate, maxResource)
const baseCpu = starterSide(cpuPartyTemplate, maxResource)
@@ -543,15 +607,18 @@ export function PvPRoguelikeScreen({
cpuRef.current = baseCpu
nextLogId.current = 2
playerClearedEncounterRef.current = -1
queuedMatchRef.current = true
bossRewardClaimedRef.current = new Set()
setEncounters(firstSegment)
setEncounterIndex(0)
setStage(startStage)
setCheckpointStage(matchStartStage)
setStartStage(matchStartStage)
setStage(matchStartStage)
setElapsedTicks(0)
setStatus('queueing')
setPlayerSide(basePlayer)
setCpuSide(baseCpu)
setSelectedId(partyTemplate[0].id)
setSelectedTargetId(partyTemplate[0].id)
setPlayerBuffChoices([])
setPlayerDebuffChoices([])
setSelectedBuff(null)
@@ -560,6 +627,7 @@ export function PvPRoguelikeScreen({
setPaused(false)
setTargetGroup(0)
setReward(null)
setRunSummary(createEmptyPvpRunSummary())
setRewardError('')
setShowEndLog(false)
setFloatingTexts([])
@@ -569,26 +637,28 @@ export function PvPRoguelikeScreen({
cpuDefeatedRef.current = false
if (gameMode === 'offline') {
const randomCpu = randomCpuDifficulty()
setQueueMessage(`Offline mode. CPU ${randomCpu} enters at stage ${startStage}.`)
setQueueMessage(`Offline mode. CPU ${randomCpu} enters at stage ${matchStartStage}.`)
setCpuDifficulty(randomCpu)
setLog([{ id: 1, text: `Offline mode. CPU ${randomCpu} enters at stage ${startStage}.`, tone: 'system' }])
setLog([{ id: 1, text: `Offline mode. CPU ${randomCpu} enters at stage ${matchStartStage}.`, tone: 'system' }])
const timer = window.setTimeout(() => {
setStatus('playing')
addLog(`Stage ${startStage} begins against CPU ${randomCpu}.`, 'system')
addLog(`Stage ${matchStartStage} begins against CPU ${randomCpu}.`, 'system')
}, 500)
return () => window.clearTimeout(timer)
}
setQueueMessage(`Searching queue. Stage ${startStage} start ready.`)
setLog([{ id: 1, text: `Searching queue. Stage ${startStage} start ready.`, tone: 'system' }])
setQueueMessage(`Searching queue. Stage ${matchStartStage} start ready.`)
setLog([{ id: 1, text: `Searching queue. Stage ${matchStartStage} start ready.`, tone: 'system' }])
const timer = window.setTimeout(() => {
const randomCpu = randomCpuDifficulty()
setCpuDifficulty(randomCpu)
setQueueMessage(`No queued player found. CPU ${randomCpu} steps in.`)
setStatus('playing')
addLog(`No queued player found. CPU ${randomCpu} steps in at stage ${startStage}.`, 'system')
addLog(`No queued player found. CPU ${randomCpu} steps in at stage ${matchStartStage}.`, 'system')
}, 1400)
return () => window.clearTimeout(timer)
}, [addLog, contentType, cpuPartyTemplate, encounterPool, gameMode, maxResource, partyTemplate, startStage])
}, [addLog, contentType, cpuPartyTemplate, gameMode, maxResource, partyTemplate, profile.character.id, setSelectedTargetId])
useEffect(() => startMatch(), [startMatch])
const applySpell = useCallback((
current: SideState,
@@ -611,6 +681,11 @@ export function PvPRoguelikeScreen({
const hotTargets = new Set(spell.kind === 'hot' ? [targetId] : [])
const shieldTargets = new Set(spell.kind === 'shield' ? [targetId] : [])
const extraTargets = buffStacks(buffs, `slot${spell.key as SlotKey}-extra-target` as SelfBuffId)
const groupTargets = new Set(
spell.kind === 'group'
? groupHealTargets(current.party, DEFAULT_GROUP_HEAL_TARGETS + extraTargets).map((member) => member.id)
: [],
)
for (let index = 0; index < extraTargets; index += 1) {
if (spell.kind === 'group') break
if (spell.kind === 'hot') {
@@ -629,6 +704,7 @@ export function PvPRoguelikeScreen({
const nextParty = current.party.map((member) => {
if (member.health <= 0) return member
if (spell.kind === 'group') {
if (!groupTargets.has(member.id)) return member
const groupPower = Math.round(spell.power * (1.25 ** buffStacks(buffs, 'group-heal-boost')))
const nextHealth = healMember(member, groupPower, debuffs)
addFloatingHeal(sideName, member.id, Math.max(0, nextHealth - member.health))
@@ -687,29 +763,30 @@ export function PvPRoguelikeScreen({
const castPlayerSpell = useCallback((spell: Spell) => {
if (status !== 'playing' || playerDone || !playerAlive) return
const targetId = selectedIdRef.current
const succeeded = applySpell(playerRef.current, (value) => {
const next = typeof value === 'function' ? value(playerRef.current) : value
playerRef.current = next
setPlayerSide(next)
}, 'player', playerRef.current.buffs, playerRef.current.debuffs, spell, selectedId)
if (succeeded) addLog(`${spell.name} cast on ${playerRef.current.party.find((member) => member.id === selectedId)?.name ?? 'target'}.`, 'heal')
}, [addLog, applySpell, playerAlive, playerDone, selectedId, status])
}, 'player', playerRef.current.buffs, playerRef.current.debuffs, spell, targetId)
if (succeeded) addLog(`${spell.name} cast on ${playerRef.current.party.find((member) => member.id === targetId)?.name ?? 'target'}.`, 'heal')
}, [addLog, applySpell, playerAlive, playerDone, status])
const selectRelativeTarget = useCallback((direction: -1 | 1) => {
const living = playerRef.current.party.filter((member) => member.health > 0)
if (living.length === 0) return
const currentIndex = living.findIndex((member) => member.id === selectedId)
const currentIndex = living.findIndex((member) => member.id === selectedIdRef.current)
const nextIndex = currentIndex < 0
? 0
: (currentIndex + direction + living.length) % living.length
setSelectedId(living[nextIndex].id)
}, [selectedId])
setSelectedTargetId(living[nextIndex].id)
}, [setSelectedTargetId])
const selectDirectionalTarget = useCallback((action: InputAction) => {
const currentIndex = playerRef.current.party.findIndex((member) => member.id === selectedId)
const currentIndex = playerRef.current.party.findIndex((member) => member.id === selectedIdRef.current)
if (currentIndex < 0) {
const firstLiving = playerRef.current.party.find((member) => member.health > 0)
if (firstLiving) setSelectedId(firstLiving.id)
if (firstLiving) setSelectedTargetId(firstLiving.id)
return
}
const currentRow = Math.floor(currentIndex / partyColumns)
@@ -736,14 +813,14 @@ export function PvPRoguelikeScreen({
const bSecondary = horizontal ? 0 : Math.abs(b.column - currentColumn)
return aPrimary - bPrimary || aSecondary - bSecondary
})
if (candidates[0]) setSelectedId(candidates[0].member.id)
}, [partyColumns, selectedId])
if (candidates[0]) setSelectedTargetId(candidates[0].member.id)
}, [partyColumns, setSelectedTargetId])
const selectDirectTarget = useCallback((slot: number) => {
const index = slot + (contentType === 'raid' ? targetGroup * 6 : 0)
const member = playerRef.current.party[index]
if (member?.health > 0) setSelectedId(member.id)
}, [contentType, targetGroup])
if (member?.health > 0) setSelectedTargetId(member.id)
}, [contentType, setSelectedTargetId, targetGroup])
const cpuTakeTurn = useCallback(() => {
if (!cpuDifficulty || status !== 'playing' || cpuDone || !cpuAlive) return
@@ -790,10 +867,14 @@ export function PvPRoguelikeScreen({
const appliesHealingReduction = encounterValue.isBoss && elapsedTicks > 0 && elapsedTicks % 9 === 0 && mechanics.includes('healing-reduction')
const appliesPoison = encounterValue.isBoss && elapsedTicks > 0 && elapsedTicks % 12 === 0 && mechanics.includes('ramping-poison')
const damageMultiplier = incomingDamageMultiplier(side.debuffs)
const tankPressure = tankPressureTargets(side.party)
const tankPressureIds = new Set(tankPressure.targets.map((member) => member.id))
const nextParty = side.party.map((member) => {
if (member.health <= 0) return member
let damage = member.id === primaryTarget.id ? encounterValue.damage : 0
if (member.role === 'Tank') damage += encounterValue.tankDamage
if (tankPressureIds.has(member.id)) {
damage += Math.round(encounterValue.tankDamage * tankPressure.multiplier)
}
if (bossPulse) damage += 10
if (member.debuff) damage += 6
const nextPoisonStacks = appliesPoison && member.id === primaryTarget.id
@@ -842,7 +923,7 @@ export function PvPRoguelikeScreen({
cooldowns: Object.fromEntries(
Object.entries(side.cooldowns).map(([id, seconds]) => [id, Math.max(0, seconds - TICK_MS / 1000)]),
),
enemyHealth: Math.max(0, side.enemyHealth - encounterValue.partyDamage),
enemyHealth: Math.max(0, side.enemyHealth - partyDamageOutput(nextParty, encounterValue.partyDamage)),
}
}, [addFloatingHeal, elapsedTicks, maxResource])
@@ -895,6 +976,12 @@ export function PvPRoguelikeScreen({
addLog(`CPU ${cpuDifficulty ?? 1} fell. Finish the boss for XP.`, 'loot')
}
if (nextPlayer.enemyHealth <= 0) {
if (encounter.isBoss && cpuDefeatedRef.current) {
finishRoguelikeRun()
setStatus('won')
addLog('CPU defeated. Match complete.', 'loot')
return
}
addLog(`${encounter.enemyName} cleared. Choose your next edge.`, 'loot')
beginUpgradePhase()
}
@@ -955,10 +1042,17 @@ export function PvPRoguelikeScreen({
}
const clearedBoss = encounter.isBoss
if (clearedBoss && cpuDefeatedRef.current) {
finishRoguelikeRun()
setStatus('won')
addLog('CPU defeated. Match complete.', 'loot')
return
}
const nextStage = clearedBoss ? stage + 1 : stage
const nextSegment = clearedBoss ? buildEncounterSegment(encounterPool, nextStage, contentType) : []
const nextEncounter = clearedBoss ? nextSegment[0] : encounters[encounterIndex + 1]
if (!nextEncounter) {
finishRoguelikeRun()
setStatus('won')
addLog('No further encounters remain.', 'loot')
return
@@ -1007,7 +1101,7 @@ export function PvPRoguelikeScreen({
setElapsedTicks(0)
setStatus('playing')
addLog(`You chose ${selectedBuff.name} and ${selectedDebuff.name}. CPU ${cpuDifficulty} chose ${cpuBuff.name} and ${cpuDebuff.name}.`, 'system')
}, [addLog, contentType, cpuDifficulty, encounter, encounterIndex, encounterPool, encounters, maxResource, opponentDebuffChoicesCatalog, selectedBuff, selectedDebuff, selfBuffChoicesCatalog, stage, starterSpells])
}, [addLog, contentType, cpuDifficulty, encounter, encounterIndex, encounterPool, encounters, finishRoguelikeRun, maxResource, opponentDebuffChoicesCatalog, selectedBuff, selectedDebuff, selfBuffChoicesCatalog, stage, starterSpells])
useGameAction((action) => {
if (action === 'pause' || action === 'back') {
@@ -1036,9 +1130,9 @@ export function PvPRoguelikeScreen({
setTargetGroup((current) => {
const groupCount = Math.max(1, Math.ceil(playerRef.current.party.length / 6))
const next = ((current + 1) % groupCount) as 0 | 1 | 2
const selectedIndex = playerRef.current.party.findIndex((member) => member.id === selectedId)
const selectedIndex = playerRef.current.party.findIndex((member) => member.id === selectedIdRef.current)
const nextMember = playerRef.current.party[(selectedIndex < 0 ? 0 : selectedIndex % 6) + next * 6]
if (nextMember?.health > 0) setSelectedId(nextMember.id)
if (nextMember?.health > 0) setSelectedTargetId(nextMember.id)
return next
})
return
@@ -1128,7 +1222,7 @@ export function PvPRoguelikeScreen({
{dualScreenEnabled && status !== 'queueing' && (
<DualScreenTopCombat
state={dualScreenState}
onSelectTarget={setSelectedId}
onSelectTarget={setSelectedTargetId}
/>
)}
@@ -1152,7 +1246,7 @@ export function PvPRoguelikeScreen({
<button
className={`party-member ${selectedId === member.id ? 'selected' : ''} ${member.health <= 0 ? 'down' : ''}`}
key={`player-${member.id}`}
onClick={() => setSelectedId(member.id)}
onClick={() => setSelectedTargetId(member.id)}
type="button"
>
<div className="member-header">
@@ -1351,9 +1445,39 @@ export function PvPRoguelikeScreen({
<h2>{status === 'won' ? `CPU ${cpuDifficulty} Falls` : `CPU ${cpuDifficulty} Wins`}</h2>
<p>{finalEncountersCleared} encounters cleared.</p>
<div className="reward-summary">
{!reward && !rewardError && <p>Boss kills grant XP immediately.</p>}
<p>{runSummary.bossesKilled} bosses killed.</p>
<p>+{runSummary.experienceGained} XP</p>
{runSummary.bossesKilled > 0 && !reward && !rewardError && <p>Final boss rewards still recording...</p>}
{rewardError && <p className="reward-error">{rewardError}</p>}
{reward && (
{runSummary.levelsGained > 0 && runSummary.previousLevel !== null && runSummary.newLevel !== null && (
<p className="level-gain">
Level {runSummary.previousLevel} to {runSummary.newLevel}
<small>+{runSummary.talentPointsGained} talent point{runSummary.talentPointsGained === 1 ? '' : 's'}</small>
</p>
)}
{runSummary.unlockedAbilities.map((ability) => (
<p className="ability-unlock" key={ability.id}>
<span>{ability.glyph}</span>
Ability Unlocked: {ability.name}
</p>
))}
<div className="run-loot-rolls">
{runSummary.loot.length > 0 ? runSummary.loot.map((item, index) => (
<div className="dropped" key={`${item.id}-${index}`}>
<strong>Boss {index + 1}</strong>
<span>
{item.glyph} {item.name} x{item.quantity}
{item.duplicate ? ` (owned x${item.quantityAfter})` : ''}
</span>
</div>
)) : (
<div>
<strong>Loot</strong>
<span>No boss loot awarded</span>
</div>
)}
</div>
{reward && runSummary.bossesKilled === 0 && (
<>
<p>+{reward.experienceGained} XP</p>
{reward.levelsGained > 0 && (
@@ -1392,6 +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>
</div>
</div>
+100 -1
View File
@@ -56,12 +56,28 @@ export type DualScreenCombatState = {
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: 'combat-state'; state: DualScreenCombatState }
| { type: 'workshop-state'; state: DualScreenWorkshopState }
| { type: 'companion-ready' }
| { type: 'companion-heartbeat' }
| { type: 'control-action'; action: InputAction }
| { type: 'combat-ended' }
| { type: 'workshop-ended' }
type DualScreenContextValue = {
enabled: boolean
@@ -280,16 +296,64 @@ export function useDualScreenPublisher(
}, [enabled, state])
}
export function useDualScreenWorkshopPublisher(
state: DualScreenWorkshopState | null,
enabled: boolean,
) {
const stateRef = useRef(state)
useEffect(() => {
stateRef.current = state
}, [state])
useEffect(() => {
if (!enabled || !state) return
const channel = createChannel()
if (!channel) return
const publish = () => {
if (stateRef.current) {
channel.postMessage({
type: 'workshop-state',
state: stateRef.current,
} satisfies DualScreenMessage)
}
}
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
if (event.data.type === 'companion-ready') publish()
}
publish()
return () => {
channel.postMessage({ type: 'workshop-ended' } satisfies DualScreenMessage)
channel.close()
}
}, [enabled, state])
useEffect(() => {
if (!enabled || !state) return
const channel = createChannel()
channel?.postMessage({ type: 'workshop-state', state } satisfies DualScreenMessage)
channel?.close()
}, [enabled, state])
}
export function DualScreenBottomDisplay() {
const [state, setState] = useState<DualScreenCombatState | null>(loadRecentSnapshot)
const [workshopState, setWorkshopState] = useState<DualScreenWorkshopState | null>(null)
useEffect(() => {
const channel = createChannel()
if (!channel) return
const announce = () => channel.postMessage({ type: 'companion-ready' } satisfies DualScreenMessage)
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
if (event.data.type === 'combat-state') setState(event.data.state)
if (event.data.type === 'combat-state') {
setState(event.data.state)
setWorkshopState(null)
}
if (event.data.type === 'workshop-state') {
setWorkshopState(event.data.state)
setState(null)
}
if (event.data.type === 'combat-ended') setState(null)
if (event.data.type === 'workshop-ended') setWorkshopState(null)
}
announce()
const timer = window.setInterval(() => {
@@ -307,6 +371,40 @@ export function DualScreenBottomDisplay() {
channel?.close()
}
if (!state && workshopState) {
return (
<main className="dual-bottom-display workshop-bottom-display">
<header className="dual-controls-header">
<div>
<p className="eyebrow">{workshopState.mode}</p>
<h1>{workshopState.title}</h1>
</div>
<div className="dual-controls-progress">
<span>{workshopState.subtitle}</span>
</div>
</header>
{workshopState.summary && (
<section className="workshop-bottom-summary">
{workshopState.summary}
</section>
)}
<section className="workshop-bottom-grid">
{workshopState.items.map((item, index) => (
<article key={`${item.title}-${index}`}>
{item.glyph && <span>{item.glyph}</span>}
<div>
<strong>{item.title}</strong>
{item.meta && <small>{item.meta}</small>}
{item.detail && <p>{item.detail}</p>}
</div>
{item.status && <i>{item.status}</i>}
</article>
))}
</section>
</main>
)
}
if (!state) {
return (
<main className="dual-bottom-display dual-bottom-waiting">
@@ -475,6 +573,7 @@ export function DualScreenTopCombat({
return (
<button
className={`dual-top-member ${state.selectedId === member.id ? 'selected' : ''} ${member.health <= 0 ? 'dead' : ''}`}
data-party-member-id={member.id}
key={member.id}
onClick={() => onSelectTarget(member.id)}
type="button"
+44 -2
View File
@@ -8,6 +8,19 @@ export type PartyMember = {
maxHealth: number
shield: 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
debuffTicks?: number
poisonStacks?: number
@@ -24,7 +37,8 @@ export type Spell = {
cooldown: number
power: number
glyph: string
kind: 'direct' | 'hot' | 'group' | 'shield' | 'cleanse'
kind: 'direct' | 'hot' | 'group' | 'shield' | 'cleanse' | 'damage_reduction' | 'bounce_heal'
effectType?: string
}
export type Encounter = {
@@ -44,6 +58,9 @@ export type CombatLogEntry = {
tone: 'system' | 'heal' | 'danger' | 'loot'
}
export const TANKLESS_DAMAGE_MULTIPLIER = 1.35
export const DEFAULT_GROUP_HEAL_TARGETS = 4
export const INITIAL_PARTY: PartyMember[] = [
{ id: 'brann', name: 'Brann', role: 'Tank', health: 150, maxHealth: 150, shield: 0, hotTicks: 0 },
{ id: 'mira', name: 'Mira', role: 'Healer', health: 100, maxHealth: 100, shield: 0, hotTicks: 0 },
@@ -101,7 +118,7 @@ export const SPELLS: Spell[] = [
id: 'radiance',
key: '3',
name: 'Radiance',
description: 'Restores health to every living party member.',
description: 'Restores health to up to 4 injured party members.',
cost: 12,
cooldown: 8,
power: 18,
@@ -164,3 +181,28 @@ export const ENCOUNTERS: Encounter[] = [
isBoss: true,
},
]
export function partyDamageOutput(party: PartyMember[], baseDamage: number) {
const livingCount = party.filter((member) => member.health > 0).length
return Math.round(baseDamage * (livingCount / Math.max(1, party.length)))
}
export function tankPressureTargets(party: PartyMember[]) {
const living = party.filter((member) => member.health > 0)
const tanks = living.filter((member) => member.role === 'Tank')
if (tanks.length > 0) return { targets: tanks, multiplier: 1 }
const damageDealer = living
.filter((member) => member.role === 'Damage')
.sort((left, right) => right.health - left.health)[0]
return {
targets: damageDealer ? [damageDealer] : [],
multiplier: TANKLESS_DAMAGE_MULTIPLIER,
}
}
export function groupHealTargets(party: PartyMember[], targetCount = DEFAULT_GROUP_HEAL_TARGETS) {
return party
.filter((member) => member.health > 0)
.sort((left, right) => (left.health / left.maxHealth) - (right.health / right.maxHealth))
.slice(0, targetCount)
}
+54 -19
View File
@@ -26,6 +26,7 @@ export interface GameRepository {
completedPart?: number,
startPart?: number,
partDurationSeconds?: [number, number, number],
hardMode?: boolean,
): Promise<DungeonReward>
completeRoguelike(
dungeonId: number,
@@ -359,11 +360,33 @@ function experienceForLevel(level: number) {
return (level - 1) * (level - 1) * 100
}
function catchUpExperienceReward(
baseReward: number,
currentExperience: number,
currentLevel: number,
targetLevel: number,
) {
if (targetLevel <= currentLevel) return baseReward
const targetExperience = experienceForLevel(targetLevel)
const gap = Math.max(0, targetExperience - currentExperience)
if (gap <= 0) return baseReward
const doubledBase = Math.min(baseReward, Math.ceil(gap / 2))
return doubledBase * 2 + (baseReward - doubledBase)
}
function highestOtherClassLevel(save: OfflineSave) {
const activeClass = save.activeClassId
return Object.entries(save.characters)
.filter(([classId]) => Number(classId) !== activeClass)
.reduce((highest, [, character]) => Math.max(highest, character.level), 0)
}
function scaledPvpBossExperience(
startingExperience: number,
startingLevel: number,
bossesCleared: number,
maxLevel: number,
targetLevel = startingLevel,
) {
let experience = startingExperience
let level = startingLevel
@@ -374,7 +397,8 @@ function scaledPvpBossExperience(
? maxExperience
: experienceForLevel(level + 1)
const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor)
experience = Math.min(maxExperience, experience + Math.round(levelBand * 0.25))
const rewardRate = targetLevel > level ? 0.5 : 0.25
experience = Math.min(maxExperience, experience + Math.round(levelBand * rewardRate))
while (level < maxLevel && experienceForLevel(level + 1) <= experience) {
level += 1
}
@@ -385,12 +409,11 @@ function scaledPvpBossExperience(
type ComponentTemplate = { id: number; slug: string; name: string; itemLevel: number; glyph: string; description: string }
const COMPONENT_ITEMS: Record<number, ComponentTemplate> = {
1: { id: 600, slug: 'minor-component', name: 'Minor Component', itemLevel: 1, glyph: '◆', description: 'A basic crafting component.' },
5: { id: 601, slug: 'basic-component', name: 'Basic Component', itemLevel: 5, glyph: '◇', description: 'A standard crafting component.' },
10: { id: 602, slug: 'refined-component', name: 'Refined Component', itemLevel: 10, glyph: '◈', description: 'A refined crafting component.' },
15: { id: 603, slug: 'advanced-component', name: 'Advanced Component', itemLevel: 15, glyph: '◉', description: 'An advanced crafting component.' },
20: { id: 604, slug: 'superior-component', name: 'Superior Component', itemLevel: 20, glyph: '◎', description: 'A superior crafting component.' },
25: { id: 605, slug: 'primal-component', name: 'Primal Component', itemLevel: 25, glyph: '✦', description: 'A primal crafting component.' },
}
const DIRECT_CRAFT_ITEM_LEVELS = new Set([1, 10, 20, 25])
type WindowWithApiBase = Window & {
CAPACITOR_API_BASE_URL?: string
@@ -718,7 +741,7 @@ const serverRepository: GameRepository = {
),
saveProfile: (classId, abilitySlots) =>
cachedOnlineLocalRepository.saveProfile(classId, abilitySlots),
completeDungeon: (dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds) =>
completeDungeon: (dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds, hardMode) =>
cachedOnlineLocalRepository.completeDungeon(
dungeonId,
difficultyId,
@@ -727,6 +750,7 @@ const serverRepository: GameRepository = {
completedPart,
startPart,
partDurationSeconds,
hardMode,
),
completeRoguelike: (dungeonId, difficultyId, encountersCleared, resourceSpent, durationSeconds, options) =>
cachedOnlineLocalRepository.completeRoguelike(
@@ -833,7 +857,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
store.writeSave(save)
return buildProfile(save)
},
async completeDungeon(dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds) {
async completeDungeon(dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds, hardMode) {
void startPart
void partDurationSeconds
if (!Number.isInteger(resourceSpent) || resourceSpent < 0) {
@@ -859,8 +883,15 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
const previousLevel = cd.level
const previousExperience = cd.experience
const partCount = completedPart ?? 1
const experienceReward = Math.round(
dungeon.experienceReward * difficulty.experienceMultiplier * partCount,
const rewardMultiplier = hardMode ? 2 : 1
const baseExperienceReward = Math.round(
dungeon.experienceReward * difficulty.experienceMultiplier * partCount * rewardMultiplier,
)
const experienceReward = catchUpExperienceReward(
baseExperienceReward,
previousExperience,
previousLevel,
highestOtherClassLevel(save),
)
const maxExperience = experienceForLevel(profile.maxLevel)
const newExperience = Math.min(previousExperience + experienceReward, maxExperience)
@@ -908,19 +939,20 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
const selected = rewardPool[Math.floor(Math.random() * rewardPool.length)]
const existing = profile.inventory.find((item) => item.id === selected.id)
const duplicate = Boolean(existing)
let quantityAfter = 1
const rewardQuantity = rewardMultiplier
let quantityAfter = rewardQuantity
if (existing) {
existing.quantity += 1
existing.quantity += rewardQuantity
quantityAfter = existing.quantity
} else {
profile.inventory.push({
...selected,
quantity: 1,
quantity: rewardQuantity,
equipped: false,
})
}
cd.inventory = profile.inventory
bonusItem = { ...selected, quantity: 1, duplicate, quantityAfter }
bonusItem = { ...selected, quantity: rewardQuantity, duplicate, quantityAfter }
}
}
@@ -973,13 +1005,19 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
const maxExperience = experienceForLevel(profile.maxLevel)
const bossesCleared = Math.max(0, Math.floor(options?.bossesCleared ?? encountersCleared / 3))
const scaledReward = options?.experienceMode === 'pvp-boss-quarter-level'
? scaledPvpBossExperience(previousExperience, previousLevel, bossesCleared, profile.maxLevel)
? scaledPvpBossExperience(previousExperience, previousLevel, bossesCleared, profile.maxLevel, highestOtherClassLevel(save))
: null
const baseRoguelikeReward = Math.round(dungeon.experienceReward * difficulty.experienceMultiplier * (encountersCleared / 3))
const newExperience = scaledReward
? scaledReward.experience
: Math.min(
previousExperience
+ Math.round(dungeon.experienceReward * difficulty.experienceMultiplier * (encountersCleared / 3)),
+ catchUpExperienceReward(
baseRoguelikeReward,
previousExperience,
previousLevel,
highestOtherClassLevel(save),
),
maxExperience,
)
let newLevel = scaledReward?.level ?? previousLevel
@@ -1163,11 +1201,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
const profile = buildProfile(save)
const recipe = profile.craftingRecipes.find((candidate) => candidate.id === recipeId)
if (!recipe) throw new Error('That crafting recipe does not exist.')
const requiresUpgrade = profile.craftingRecipes.some((candidate) =>
candidate.sourceEncounterId === recipe.sourceEncounterId
&& candidate.item.slot === recipe.item.slot
&& candidate.item.itemLevel < recipe.item.itemLevel,
)
const requiresUpgrade = !DIRECT_CRAFT_ITEM_LEVELS.has(recipe.item.itemLevel)
if (requiresUpgrade) throw new Error('Upgrade the previous item tier instead.')
const missing = recipe.components.find((component) => component.owned < component.quantity)
if (missing) {
@@ -1363,7 +1397,7 @@ const cachedOnlineRepository: GameRepository = {
},
loadProfile: () => cachedOnlineLocalRepository.loadProfile(),
saveProfile: (classId, abilitySlots) => cachedOnlineLocalRepository.saveProfile(classId, abilitySlots),
completeDungeon: (dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds) =>
completeDungeon: (dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds, hardMode) =>
cachedOnlineLocalRepository.completeDungeon(
dungeonId,
difficultyId,
@@ -1372,6 +1406,7 @@ const cachedOnlineRepository: GameRepository = {
completedPart,
startPart,
partDurationSeconds,
hardMode,
),
completeRoguelike: (dungeonId, difficultyId, encountersCleared, resourceSpent, durationSeconds, options) =>
cachedOnlineLocalRepository.completeRoguelike(
-15
View File
@@ -121,7 +121,6 @@ const STORAGE_KEY = 'ashen-halls-input-bindings-v1'
const PREFERENCES_STORAGE_KEY = 'ashen-halls-input-preferences-v1'
const GAME_ACTION_EVENT = 'ashen-halls-game-action'
const NATIVE_CONTROLLER_EVENT = 'ashen-halls-native-controller'
const COMBAT_TARGET_NAVIGATION_THROTTLE_MS = 220
type CaptureState = {
device: InputDevice
@@ -277,14 +276,6 @@ function hasUiOverlay() {
).some(isVisible)
}
function isCombatTargetAction(action: InputAction) {
return action.startsWith('navigate')
|| action.startsWith('targetParty')
|| action === 'previousTarget'
|| action === 'nextTarget'
|| action === 'toggleTargetGroup'
}
const BUTTON_LABELS: Record<number, string> = {
0: 'A / Cross',
1: 'B / Circle',
@@ -398,7 +389,6 @@ export function InputProvider({ children }: { children: ReactNode }) {
const keyboardInputRef = useRef(keyboardInput)
const previousTokensRef = useRef(new Set<string>())
const repeatRef = useRef<Record<string, number>>({})
const lastCombatNavigationRef = useRef(0)
useEffect(() => {
bindingsRef.current = bindings
@@ -445,11 +435,6 @@ export function InputProvider({ children }: { children: ReactNode }) {
const dispatchAction = useCallback((action: InputAction, device: InputDevice) => {
const uiOverlay = hasUiOverlay()
const combatActive = Boolean(document.querySelector('[data-combat-active="true"]'))
if (combatActive && !uiOverlay && isCombatTargetAction(action)) {
const now = performance.now()
if (now - lastCombatNavigationRef.current < COMBAT_TARGET_NAVIGATION_THROTTLE_MS) return
lastCombatNavigationRef.current = now
}
setLastDevice(device)
document.documentElement.dataset.inputDevice = device
+28 -748
View File
@@ -62,7 +62,7 @@
"power": 18,
"unlockLevel": 1,
"glyph": "*",
"description": "Restores health to every living party member."
"description": "Restores health to up to 4 injured party members."
},
{
"id": 4,
@@ -101,7 +101,7 @@
"power": 28,
"unlockLevel": 5,
"glyph": "D",
"description": "A brilliant wave of healing for the entire party."
"description": "A brilliant wave of healing for up to 4 injured allies."
},
{
"id": 7,
@@ -140,7 +140,7 @@
"power": 48,
"unlockLevel": 20,
"glyph": "A",
"description": "Floods the party with the full strength of dawn."
"description": "Floods up to 4 injured allies with the full strength of dawn."
}
],
"talents": [
@@ -313,13 +313,13 @@
"classId": 2,
"slug": "verdant-touch",
"name": "Verdant Touch",
"spellType": "direct_heal",
"spellType": "direct_hot",
"cost": 5,
"cooldown": 0.5,
"power": 28,
"power": 20,
"unlockLevel": 1,
"glyph": "+",
"description": "A quick pulse of living energy."
"description": "A weaker direct heal that also plants a stacking heal over time."
},
{
"id": 21,
@@ -338,27 +338,27 @@
"id": 22,
"classId": 2,
"slug": "wild-bloom",
"name": "Wild Bloom",
"spellType": "party_heal",
"name": "Wild Growth",
"spellType": "party_hot",
"cost": 12,
"cooldown": 8,
"power": 17,
"power": 14,
"unlockLevel": 1,
"glyph": "*",
"description": "Restorative growth spreads through the party."
"description": "Applies a stacking heal over time to up to 4 injured allies."
},
{
"id": 23,
"classId": 2,
"slug": "barkskin",
"name": "Barkskin",
"spellType": "absorb",
"cost": 8,
"cooldown": 7,
"power": 34,
"spellType": "damage_reduction",
"cost": 10,
"cooldown": 14,
"power": 0,
"unlockLevel": 1,
"glyph": "B",
"description": "Wraps an ally in protective living bark."
"description": "Reduces the target ally's damage taken by 50% for 8 seconds."
},
{
"id": 24,
@@ -378,13 +378,13 @@
"classId": 2,
"slug": "ancient-grove",
"name": "Ancient Grove",
"spellType": "party_heal",
"spellType": "party_hot",
"cost": 17,
"cooldown": 12,
"power": 31,
"power": 24,
"unlockLevel": 5,
"glyph": "T",
"description": "Briefly summons the shelter of an ancient grove."
"description": "Applies a stronger stacking heal over time to up to 4 injured allies."
}
],
"talents": [
@@ -569,27 +569,27 @@
"id": 31,
"classId": 3,
"slug": "echo-rune",
"name": "Echo Rune",
"spellType": "heal_over_time",
"name": "Mending Rune",
"spellType": "bounce_heal",
"cost": 7,
"cooldown": 0.5,
"power": 12,
"power": 18,
"unlockLevel": 1,
"glyph": "e",
"description": "Repeats a restorative rune over several moments."
"description": "Places a rune that heals when the ally takes damage, then jumps 4 times."
},
{
"id": 32,
"classId": 3,
"slug": "concordance",
"name": "Concordance",
"spellType": "party_heal",
"spellType": "party_absorb",
"cost": 12,
"cooldown": 8,
"power": 18,
"power": 28,
"unlockLevel": 1,
"glyph": "*",
"description": "Links the party through a shared healing pattern."
"description": "Shields up to 4 injured allies through a shared barrier pattern."
},
{
"id": 33,
@@ -622,13 +622,13 @@
"classId": 3,
"slug": "grand-design",
"name": "Grand Design",
"spellType": "party_heal",
"spellType": "party_absorb",
"cost": 16,
"cooldown": 12,
"power": 30,
"power": 42,
"unlockLevel": 5,
"glyph": "R",
"description": "Activates a prepared network of restorative runes."
"description": "Raises a stronger shared barrier around up to 4 injured allies."
}
],
"talents": [
@@ -1211,366 +1211,6 @@
],
"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,
"difficultyId": 2,
@@ -1931,366 +1571,6 @@
],
"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,
"difficultyId": 4,
+2
View File
@@ -319,6 +319,7 @@ export async function completeDungeon(
completedPart?: number,
startPart?: number,
partDurationSeconds?: [number, number, number],
hardMode?: boolean,
): Promise<DungeonReward> {
return activeGameRepository().completeDungeon(
dungeonId,
@@ -328,6 +329,7 @@ export async function completeDungeon(
completedPart,
startPart,
partDurationSeconds,
hardMode,
)
}