Compare commits

..

2 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
15 changed files with 529 additions and 152 deletions
+1
View File
@@ -4,5 +4,6 @@
- AYN Thor secondary display: 3.92-inch AMOLED, 1240 x 1080, 60Hz. - AYN Thor secondary display: 3.92-inch AMOLED, 1240 x 1080, 60Hz.
- AYN Thor UI sizing must be designed against Android CSS/layout viewport, not physical framebuffer pixels. - 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. - Approximate Thor CSS viewports: main display 960 x 540, secondary display 620 x 540.
- Test top-screen UI only against the main display viewport, and bottom-screen UI only against the secondary display viewport.
- User rebuilds app; do not rebuild APK unless explicitly requested. - User rebuilds app; do not rebuild APK unless explicitly requested.
- Apply game changes to both web version and mobile app version. - Apply game changes to both web version and mobile app version.
Binary file not shown.
Binary file not shown.
+2 -2
View File
@@ -7,8 +7,8 @@ android {
applicationId "com.warren.iwanttoheal" applicationId "com.warren.iwanttoheal"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 49 versionCode 52
versionName "1.0.31" versionName "1.0.33"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions { aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+70
View File
@@ -191,6 +191,76 @@ UPDATE spells SET unlock_level = 10 WHERE slug = 'guardian-light';
UPDATE spells SET unlock_level = 15 WHERE slug = 'second-sun'; UPDATE spells SET unlock_level = 15 WHERE slug = 'second-sun';
UPDATE spells SET unlock_level = 20 WHERE slug = 'daybreak'; UPDATE spells SET unlock_level = 20 WHERE slug = 'daybreak';
UPDATE spells SET
name = 'Verdant Touch',
spell_type = 'direct_hot',
resource_cost = 5,
cooldown_seconds = 0.5,
power = 20,
glyph = '+',
description = 'A weaker direct heal that also plants a stacking heal over time.'
WHERE slug = 'verdant-touch';
UPDATE spells SET
name = 'Wild Growth',
spell_type = 'party_hot',
resource_cost = 12,
cooldown_seconds = 8,
power = 14,
glyph = '*',
description = 'Applies a stacking heal over time to up to 4 injured allies.'
WHERE slug = 'wild-bloom';
UPDATE spells SET
name = 'Barkskin',
spell_type = 'damage_reduction',
resource_cost = 10,
cooldown_seconds = 14,
power = 0,
glyph = 'B',
description = 'Reduces the target ally''s damage taken by 50% for 8 seconds.'
WHERE slug = 'barkskin';
UPDATE spells SET
name = 'Ancient Grove',
spell_type = 'party_hot',
resource_cost = 17,
cooldown_seconds = 12,
power = 24,
glyph = 'T',
description = 'Applies a stronger stacking heal over time to up to 4 injured allies.'
WHERE slug = 'ancient-grove';
UPDATE spells SET
name = 'Mending Rune',
spell_type = 'bounce_heal',
resource_cost = 7,
cooldown_seconds = 0.5,
power = 18,
glyph = 'e',
description = 'Places a rune that heals when the ally takes damage, then jumps 4 times.'
WHERE slug = 'echo-rune';
UPDATE spells SET
name = 'Concordance',
spell_type = 'party_absorb',
resource_cost = 12,
cooldown_seconds = 8,
power = 28,
glyph = '*',
description = 'Shields up to 4 injured allies through a shared barrier pattern.'
WHERE slug = 'concordance';
UPDATE spells SET
name = 'Grand Design',
spell_type = 'party_absorb',
resource_cost = 16,
cooldown_seconds = 12,
power = 42,
glyph = 'R',
description = 'Raises a stronger shared barrier around up to 4 injured allies.'
WHERE slug = 'grand-design';
INSERT OR IGNORE INTO items INSERT OR IGNORE INTO items
(id, slug, name, slot, rarity, item_level, healing_power, max_resource_bonus, glyph, description) (id, slug, name, slot, rarity, item_level, healing_power, max_resource_bonus, glyph, description)
VALUES VALUES
+2 -2
View File
@@ -79,9 +79,9 @@ cd /Users/warren/Documents/testgame/testgame
export GITEA_URL="https://git.whoagland.com" export GITEA_URL="https://git.whoagland.com"
export GITEA_OWNER="phenom" export GITEA_OWNER="phenom"
export GITEA_REPO="i-want-to-heal" export GITEA_REPO="i-want-to-heal"
export GITEA_TOKEN="PASTE_TOKEN_HERE" export GITEA_TOKEN="ed2db3fd54546e9658377d0551b3fc3961583f1d"
VERSION="1.0.26" VERSION="1.0.27"
APK="IWantToHeal-Thor-v$VERSION.apk" APK="IWantToHeal-Thor-v$VERSION.apk"
RELEASE_JSON=$(curl -sS -X POST "$GITEA_URL/api/v1/repos/$GITEA_OWNER/$GITEA_REPO/releases" \ RELEASE_JSON=$(curl -sS -X POST "$GITEA_URL/api/v1/repos/$GITEA_OWNER/$GITEA_REPO/releases" \
+56 -18
View File
@@ -22,6 +22,7 @@ const bossImageContentTypes = {
} }
const equipmentSlots = ['weapon', 'helmet', 'chest', 'gloves', 'boots', 'pants', 'ring', 'necklace', 'trinket'] const equipmentSlots = ['weapon', 'helmet', 'chest', 'gloves', 'boots', 'pants', 'ring', 'necklace', 'trinket']
const componentSlot = 'component' const componentSlot = 'component'
const directCraftItemLevels = new Set([1, 10, 20, 25])
const sessionCookieName = 'chronicle_session' const sessionCookieName = 'chronicle_session'
const sessionLifetimeSeconds = 60 * 60 * 24 * 30 const sessionLifetimeSeconds = 60 * 60 * 24 * 30
const rateLimitBuckets = new Map() const rateLimitBuckets = new Map()
@@ -232,6 +233,25 @@ function consumeRateLimit(key, limit, windowMs) {
} }
} }
function catchUpExperienceReward(database, accountId, characterId, baseReward, currentExperience, currentLevel) {
const targetLevel = database.prepare(`
SELECT COALESCE(MAX(level), 0) AS level
FROM characters
WHERE account_id = ?
AND id != ?
`).get(accountId, characterId).level
if (targetLevel <= currentLevel) return baseReward
const targetExperience = database.prepare(`
SELECT experience_required AS experienceRequired
FROM level_progression
WHERE level = ?
`).get(targetLevel)?.experienceRequired ?? currentExperience
const gap = Math.max(0, targetExperience - currentExperience)
if (gap <= 0) return baseReward
const doubledBase = Math.min(baseReward, Math.ceil(gap / 2))
return doubledBase * 2 + (baseReward - doubledBase)
}
function normalizeUsername(value) { function normalizeUsername(value) {
const username = String(value ?? '').trim() const username = String(value ?? '').trim()
if (!/^[A-Za-z0-9_]{3,20}$/.test(username)) { if (!/^[A-Za-z0-9_]{3,20}$/.test(username)) {
@@ -1693,16 +1713,9 @@ function craftItem(database, characterId, recipeId) {
WHERE crafting_recipes.id = ? WHERE crafting_recipes.id = ?
`).get(recipeId) `).get(recipeId)
if (!recipe) throw new Error('That crafting recipe does not exist.') if (!recipe) throw new Error('That crafting recipe does not exist.')
const lowerTierRecipe = database.prepare(` if (!directCraftItemLevels.has(recipe.itemLevel)) {
SELECT crafting_recipes.id throw new Error('Upgrade the previous item tier instead.')
FROM crafting_recipes }
JOIN items ON items.id = crafting_recipes.item_id
WHERE crafting_recipes.source_encounter_id = ?
AND items.slot = ?
AND items.item_level < ?
LIMIT 1
`).get(recipe.sourceEncounterId, recipe.slot, recipe.itemLevel)
if (lowerTierRecipe) throw new Error('Upgrade the previous item tier instead.')
const components = database.prepare(` const components = database.prepare(`
SELECT SELECT
@@ -2024,12 +2037,21 @@ function completeDungeon(database, characterId, accountId, dungeonId, difficulty
const completedPart = Math.min(Math.max(Number(runMetrics?.completedPart) || 1, 1), 3) const completedPart = Math.min(Math.max(Number(runMetrics?.completedPart) || 1, 1), 3)
const startPart = Math.min(Math.max(Number(runMetrics?.startPart) || 1, 1), 3) const startPart = Math.min(Math.max(Number(runMetrics?.startPart) || 1, 1), 3)
const completedParts = completedPart - startPart + 1 const completedParts = completedPart - startPart + 1
const rewardMultiplier = runMetrics?.hardMode ? 2 : 1
const rawPartDurations = runMetrics?.partDurationSeconds const rawPartDurations = runMetrics?.partDurationSeconds
const partDurationSeconds = Array.isArray(rawPartDurations) && rawPartDurations.length === 3 const partDurationSeconds = Array.isArray(rawPartDurations) && rawPartDurations.length === 3
? rawPartDurations.map(Number) ? rawPartDurations.map(Number)
: null : null
const experienceReward = Math.round( const baseExperienceReward = Math.round(
dungeon.experienceReward * dungeon.experienceMultiplier * completedPart, dungeon.experienceReward * dungeon.experienceMultiplier * completedPart * rewardMultiplier,
)
const experienceReward = catchUpExperienceReward(
database,
accountId,
characterId,
baseExperienceReward,
character.experience,
character.level,
) )
const newExperience = Math.min(character.experience + experienceReward, maxExperience) const newExperience = Math.min(character.experience + experienceReward, maxExperience)
const newLevel = database.prepare(` const newLevel = database.prepare(`
@@ -2127,17 +2149,18 @@ function completeDungeon(database, characterId, accountId, dungeonId, difficulty
`).all(dungeonId, dungeon.completionItemLevel ?? dungeon.droppedItemLevel + 3) `).all(dungeonId, dungeon.completionItemLevel ?? dungeon.droppedItemLevel + 3)
if (bonusItems.length > 0) { if (bonusItems.length > 0) {
bonusItem = bonusItems[0] bonusItem = bonusItems[0]
const rewardQuantity = rewardMultiplier
const previousQuantity = database.prepare(` const previousQuantity = database.prepare(`
SELECT quantity FROM character_inventory SELECT quantity FROM character_inventory
WHERE character_id = ? AND item_id = ? WHERE character_id = ? AND item_id = ?
`).get(characterId, bonusItem.id)?.quantity ?? 0 `).get(characterId, bonusItem.id)?.quantity ?? 0
database.prepare(` database.prepare(`
INSERT INTO character_inventory (character_id, item_id, quantity, equipped) INSERT INTO character_inventory (character_id, item_id, quantity, equipped)
VALUES (?, ?, 1, 0) VALUES (?, ?, ?, 0)
ON CONFLICT(character_id, item_id) ON CONFLICT(character_id, item_id)
DO UPDATE SET quantity = quantity + 1 DO UPDATE SET quantity = quantity + ?
`).run(characterId, bonusItem.id) `).run(characterId, bonusItem.id, rewardQuantity, rewardQuantity)
bonusItem = { ...bonusItem, quantity: 1, duplicate: previousQuantity > 0, quantityAfter: previousQuantity + 1 } bonusItem = { ...bonusItem, quantity: rewardQuantity, duplicate: previousQuantity > 0, quantityAfter: previousQuantity + rewardQuantity }
} }
} }
@@ -2234,6 +2257,12 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
let newExperience = character.experience let newExperience = character.experience
let newLevel = character.level let newLevel = character.level
if (experienceMode === 'pvp-boss-quarter-level') { if (experienceMode === 'pvp-boss-quarter-level') {
const catchUpTargetLevel = database.prepare(`
SELECT COALESCE(MAX(level), 0) AS level
FROM characters
WHERE account_id = ?
AND id != ?
`).get(accountId, characterId).level
for (let bossIndex = 0; bossIndex < bossesCleared && newExperience < maxExperience; bossIndex += 1) { for (let bossIndex = 0; bossIndex < bossesCleared && newExperience < maxExperience; bossIndex += 1) {
const currentLevelFloor = database.prepare(` const currentLevelFloor = database.prepare(`
SELECT experience_required AS experienceRequired SELECT experience_required AS experienceRequired
@@ -2248,7 +2277,8 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
WHERE level = ? WHERE level = ?
`).get(newLevel + 1).experienceRequired `).get(newLevel + 1).experienceRequired
const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor) const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor)
newExperience = Math.min(maxExperience, newExperience + Math.round(levelBand * 0.25)) const rewardRate = catchUpTargetLevel > newLevel ? 0.5 : 0.25
newExperience = Math.min(maxExperience, newExperience + Math.round(levelBand * rewardRate))
newLevel = database.prepare(` newLevel = database.prepare(`
SELECT MAX(level) AS level SELECT MAX(level) AS level
FROM level_progression FROM level_progression
@@ -2256,9 +2286,17 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
`).get(newExperience).level `).get(newExperience).level
} }
} else { } else {
const experienceReward = Math.round( const baseExperienceReward = Math.round(
dungeon.experienceReward * dungeon.experienceMultiplier * (encountersCleared / 3), dungeon.experienceReward * dungeon.experienceMultiplier * (encountersCleared / 3),
) )
const experienceReward = catchUpExperienceReward(
database,
accountId,
characterId,
baseExperienceReward,
character.experience,
character.level,
)
newExperience = Math.min(character.experience + experienceReward, maxExperience) newExperience = Math.min(character.experience + experienceReward, maxExperience)
newLevel = database.prepare(` newLevel = database.prepare(`
SELECT MAX(level) AS level SELECT MAX(level) AS level
+33 -1
View File
@@ -1919,7 +1919,17 @@ h2 {
} }
.part-setup-panel .part-picker { .part-setup-panel .part-picker {
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: 1fr;
}
.part-start-row {
display: grid;
gap: 8px;
grid-template-columns: minmax(0, 1fr) minmax(82px, 0.38fr);
}
.hard-mode-button {
border-color: #c25b4b;
} }
.part-setup-panel .primary-button { .part-setup-panel .primary-button {
@@ -4707,6 +4717,28 @@ h2 {
box-shadow: inset 0 5px #cf4b59; box-shadow: inset 0 5px #cf4b59;
} }
.hard-enemy-bars {
display: grid;
gap: 6px;
}
.hard-enemy-bars .enemy-health {
position: relative;
}
.hard-enemy-bars .enemy-health em {
color: #fff7df;
font-family: 'Press Start 2P', monospace;
font-size: 7px;
font-style: normal;
left: 8px;
position: absolute;
text-shadow: 0 1px 0 #111;
top: 50%;
transform: translateY(-50%);
z-index: 1;
}
.combat-layout { .combat-layout {
display: grid; display: grid;
gap: 18px; gap: 18px;
+36 -17
View File
@@ -88,6 +88,7 @@ function App() {
const [roguelikeAbilityLabelMode, setRoguelikeAbilityLabelMode] = useState<RoguelikeAbilityLabelMode>('ability') const [roguelikeAbilityLabelMode, setRoguelikeAbilityLabelMode] = useState<RoguelikeAbilityLabelMode>('ability')
const [pvpContentType, setPvpContentType] = useState<PvpContentType>('dungeon') const [pvpContentType, setPvpContentType] = useState<PvpContentType>('dungeon')
const [selectedPart, setSelectedPart] = useState(1) const [selectedPart, setSelectedPart] = useState(1)
const [selectedHardMode, setSelectedHardMode] = useState(false)
const [combatContentId, setCombatContentId] = useState(1) const [combatContentId, setCombatContentId] = useState(1)
const [leaderboardCategory, setLeaderboardCategory] = useState<'part_1' | 'part_2' | 'part_3' | 'full_run'>('part_1') const [leaderboardCategory, setLeaderboardCategory] = useState<'part_1' | 'part_2' | 'part_3' | 'full_run'>('part_1')
const [showLoot, setShowLoot] = useState(false) const [showLoot, setShowLoot] = useState(false)
@@ -235,6 +236,7 @@ function App() {
<CombatScreen <CombatScreen
difficulty={difficulty} difficulty={difficulty}
dungeon={dungeon} dungeon={dungeon}
hardMode={selectedHardMode && combatContentId > 0}
profile={profile} profile={profile}
roguelikeMode={combatContentId < 0 ? roguelikeKind : undefined} roguelikeMode={combatContentId < 0 ? roguelikeKind : undefined}
roguelikeUpgradeTiming={combatContentId < 0 ? roguelikeUpgradeTiming : undefined} roguelikeUpgradeTiming={combatContentId < 0 ? roguelikeUpgradeTiming : undefined}
@@ -294,6 +296,7 @@ function App() {
setSelectedDifficultyId(baseDungeon?.difficulties[0]?.id ?? 1) setSelectedDifficultyId(baseDungeon?.difficulties[0]?.id ?? 1)
} }
setSelectedPart(1) setSelectedPart(1)
setSelectedHardMode(false)
setScreen('combat') setScreen('combat')
} }
const tierOptions = activityOptions const tierOptions = activityOptions
@@ -328,9 +331,9 @@ function App() {
: profile.completedDungeonParts : profile.completedDungeonParts
const sectionName = activity.contentType === 'raid' ? 'Phase' : 'Part' const sectionName = activity.contentType === 'raid' ? 'Phase' : 'Part'
const parts = [ const parts = [
{ part: 1, name: `${sectionName} 1`, encounterCount: 3, unlocked: true }, { part: 1, name: `${sectionName} 1`, encounterCount: 3, unlocked: true, hardUnlocked: completedSections >= 1 },
{ part: 2, name: `${sectionName} 2`, encounterCount: 3, unlocked: completedSections >= 1 }, { part: 2, name: `${sectionName} 2`, encounterCount: 3, unlocked: completedSections >= 1, hardUnlocked: completedSections >= 2 },
{ part: 3, name: `${sectionName} 3`, encounterCount: 3, unlocked: completedSections >= 2 }, { part: 3, name: `${sectionName} 3`, encounterCount: 3, unlocked: completedSections >= 2, hardUnlocked: completedSections >= 3 },
] ]
const cloudSync = getCloudSyncStatus() const cloudSync = getCloudSyncStatus()
const canShowCloudSync = account.id !== -1 && cloudSync.available const canShowCloudSync = account.id !== -1 && cloudSync.available
@@ -728,20 +731,36 @@ function App() {
</div> </div>
<div className="part-picker"> <div className="part-picker">
{parts.map((p) => ( {parts.map((p) => (
<button <div className="part-start-row" key={p.part}>
key={p.part} <button
className={`primary-button ${selectedPart === p.part ? 'selected-part' : ''} ${!p.unlocked ? 'locked' : ''}`} className={`primary-button ${selectedPart === p.part && !selectedHardMode ? 'selected-part' : ''} ${!p.unlocked ? 'locked' : ''}`}
disabled={difficultyLocked || !p.unlocked} disabled={difficultyLocked || !p.unlocked}
onClick={() => { onClick={() => {
setSelectedPart(p.part) setSelectedPart(p.part)
setCombatContentId(activity.id) setSelectedHardMode(false)
setSelectedDifficultyId(selectedDifficulty.id) setCombatContentId(activity.id)
setScreen('combat') setSelectedDifficultyId(selectedDifficulty.id)
}} setScreen('combat')
type="button" }}
> type="button"
{p.name} >
</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> </div>
</section> </section>
+217 -58
View File
@@ -113,6 +113,43 @@ function healMember(member: PartyMember, amount: number) {
return clamp(member.health + healAmount(member, amount), 0, effectiveMaxHealth(member)) return clamp(member.health + healAmount(member, amount), 0, effectiveMaxHealth(member))
} }
function memberHotEffects(member: PartyMember) {
if (member.hotEffects?.length) return member.hotEffects
return member.hotTicks > 0
? [{ id: 'legacy-renew', label: 'Renew', ticks: member.hotTicks, power: 6 }]
: []
}
function addHotEffect(member: PartyMember, spell: Spell, ticks = 5) {
return [
...memberHotEffects(member),
{
id: `${spell.id}-${crypto.randomUUID()}`,
label: spell.name,
ticks,
power: Math.max(1, Math.round(spell.power / 2)),
},
]
}
function addBounceHeal(member: PartyMember, spell: Spell) {
return [
...(member.bounceHeals ?? []),
{
id: `${spell.id}-${crypto.randomUUID()}`,
label: spell.name,
charges: 4,
power: spell.power,
},
]
}
function tickHotEffects(effects: PartyMember['hotEffects']) {
return (effects ?? [])
.map((effect) => ({ ...effect, ticks: effect.ticks - 1 }))
.filter((effect) => effect.ticks > 0)
}
function upgradeStackCount(upgrades: RoguelikeUpgrade[], id: RoguelikeUpgradeId) { function upgradeStackCount(upgrades: RoguelikeUpgrade[], id: RoguelikeUpgradeId) {
return upgrades.filter((upgrade) => upgrade.id === id).length return upgrades.filter((upgrade) => upgrade.id === id).length
} }
@@ -195,9 +232,14 @@ function spellResourceCost(spell: Spell, upgrades: RoguelikeUpgrade[], freeCastR
function toCombatSpell(ability: Ability, key: string, healingPower: number): Spell { function toCombatSpell(ability: Ability, key: string, healingPower: number): Spell {
const kinds: Record<string, Spell['kind']> = { const kinds: Record<string, Spell['kind']> = {
direct_heal: 'direct', direct_heal: 'direct',
direct_hot: 'direct',
heal_over_time: 'hot', heal_over_time: 'hot',
party_heal: 'group', party_heal: 'group',
party_hot: 'group',
party_absorb: 'group',
absorb: 'shield', absorb: 'shield',
damage_reduction: 'damage_reduction',
bounce_heal: 'bounce_heal',
cleanse: 'cleanse', cleanse: 'cleanse',
} }
return { return {
@@ -210,6 +252,7 @@ function toCombatSpell(ability: Ability, key: string, healingPower: number): Spe
power: ability.power + healingPower, power: ability.power + healingPower,
glyph: ability.glyph, glyph: ability.glyph,
kind: kinds[ability.spellType] ?? 'direct', kind: kinds[ability.spellType] ?? 'direct',
effectType: ability.spellType,
} }
} }
@@ -293,6 +336,7 @@ function makeRoguelikeSegment(
export function CombatScreen({ export function CombatScreen({
difficulty, difficulty,
dungeon, dungeon,
hardMode = false,
profile, profile,
startPart = 1, startPart = 1,
roguelikeMode, roguelikeMode,
@@ -304,6 +348,7 @@ export function CombatScreen({
}: { }: {
difficulty: Difficulty difficulty: Difficulty
dungeon: Dungeon dungeon: Dungeon
hardMode?: boolean
profile: CharacterProfile profile: CharacterProfile
startPart?: number startPart?: number
roguelikeMode?: RoguelikeMode roguelikeMode?: RoguelikeMode
@@ -354,15 +399,16 @@ export function CombatScreen({
const sectionName = isRoguelike ? 'Stage' : dungeon.contentType === 'raid' ? 'Phase' : 'Part' const sectionName = isRoguelike ? 'Stage' : dungeon.contentType === 'raid' ? 'Phase' : 'Part'
const contentName = isRoguelike ? 'Roguelike' : dungeon.contentType === 'raid' ? 'Raid' : 'Dungeon' const contentName = isRoguelike ? 'Roguelike' : dungeon.contentType === 'raid' ? 'Raid' : 'Dungeon'
const initialEncounterIndex = (startPart - 1) * 3 const initialEncounterIndex = (startPart - 1) * 3
const enemyCount = hardMode ? 2 : 1
const initialCombatState = useMemo<SinglePlayerCombatState>(() => ({ const initialCombatState = useMemo<SinglePlayerCombatState>(() => ({
party: partyTemplate, party: partyTemplate,
resource: maxResource, resource: maxResource,
enemyHealth: encounters[initialEncounterIndex].maxHealth, enemyHealth: encounters[initialEncounterIndex].maxHealth * enemyCount,
cooldowns: {}, cooldowns: {},
elapsedTicks: 0, elapsedTicks: 0,
castsTowardFree: 0, castsTowardFree: 0,
freeCastReady: false, freeCastReady: false,
}), [encounters, initialEncounterIndex, maxResource, partyTemplate]) }), [encounters, enemyCount, initialEncounterIndex, maxResource, partyTemplate])
const [combatState, setCombatState] = useState<SinglePlayerCombatState>(() => initialCombatState) const [combatState, setCombatState] = useState<SinglePlayerCombatState>(() => initialCombatState)
const [selectedId, setSelectedId] = useState(partyTemplate[0].id) const [selectedId, setSelectedId] = useState(partyTemplate[0].id)
const [encounterIndex, setEncounterIndex] = useState(initialEncounterIndex) const [encounterIndex, setEncounterIndex] = useState(initialEncounterIndex)
@@ -381,7 +427,7 @@ export function CombatScreen({
const [upgradeChoices, setUpgradeChoices] = useState<RoguelikeUpgrade[]>([]) const [upgradeChoices, setUpgradeChoices] = useState<RoguelikeUpgrade[]>([])
const rewardClaimedRef = useRef(false) const rewardClaimedRef = useRef(false)
const profileRefreshedRef = useRef(false) const profileRefreshedRef = useRef(false)
const rolledEncounterIdsRef = useRef(new Set<number>()) const rolledEncounterIdsRef = useRef(new Set<string>())
const runTokenRef = useRef(crypto.randomUUID()) const runTokenRef = useRef(crypto.randomUUID())
const resourceSpentRef = useRef(0) const resourceSpentRef = useRef(0)
const runStartedAtRef = useRef(0) const runStartedAtRef = useRef(0)
@@ -397,12 +443,17 @@ export function CombatScreen({
const pausedRef = useRef(paused) const pausedRef = useRef(paused)
const { party, resource, enemyHealth, cooldowns, freeCastReady } = combatState const { party, resource, enemyHealth, cooldowns, freeCastReady } = combatState
const encounter = encounters[encounterIndex] const encounter = encounters[encounterIndex]
const encounterMaxHealth = encounter.maxHealth * enemyCount
const currentPart = getCurrentPart(encounterIndex) const currentPart = getCurrentPart(encounterIndex)
const completedSections = dungeon.contentType === 'raid'
? profile.completedRaidPhases
: profile.completedDungeonParts
const canContinueAfterPart = !hardMode || completedSections >= currentPart + 1
const firstEncounterIndex = (startPart - 1) * 3 const firstEncounterIndex = (startPart - 1) * 3
const expectedLootRolls = encounters const expectedLootRolls = encounters
.slice(firstEncounterIndex, encounterIndex + 1) .slice(firstEncounterIndex, encounterIndex + 1)
.filter((candidate) => candidate.lootTables.some((entry) => entry.difficultyId === difficulty.id)) .filter((candidate) => candidate.lootTables.some((entry) => entry.difficultyId === difficulty.id))
.length .length * enemyCount
const isPartBoss = encounter.isBoss && encounterIndex % 3 === 2 const isPartBoss = encounter.isBoss && encounterIndex % 3 === 2
const isFinalBoss = isPartBoss && encounterIndex === encounters.length - 1 const isFinalBoss = isPartBoss && encounterIndex === encounters.length - 1
const playerHealer = party.find((member) => member.id === 'mira') const playerHealer = party.find((member) => member.id === 'mira')
@@ -484,10 +535,12 @@ export function CombatScreen({
}, []) }, [])
const requestLootRoll = useCallback( const requestLootRoll = useCallback(
(encounterId: number) => { (encounterId: number, rollIndex = 0) => {
if (rolledEncounterIdsRef.current.has(encounterId)) return const rollKey = `${encounterId}:${rollIndex}`
rolledEncounterIdsRef.current.add(encounterId) if (rolledEncounterIdsRef.current.has(rollKey)) return
rollEncounterLoot(encounterId, difficulty.id, runTokenRef.current) rolledEncounterIdsRef.current.add(rollKey)
const runToken = rollIndex === 0 ? runTokenRef.current : `${runTokenRef.current}-hard-${rollIndex}`
rollEncounterLoot(encounterId, difficulty.id, runToken)
.then((result) => { .then((result) => {
setLootRolls((current) => [...current, result]) setLootRolls((current) => [...current, result])
const awarded = result.items const awarded = result.items
@@ -519,7 +572,7 @@ export function CombatScreen({
setCombat({ setCombat({
party: freshParty, party: freshParty,
resource: maxResource, resource: maxResource,
enemyHealth: nextEncounters[initialEncounterIndex].maxHealth, enemyHealth: nextEncounters[initialEncounterIndex].maxHealth * enemyCount,
cooldowns: {}, cooldowns: {},
elapsedTicks: 0, elapsedTicks: 0,
castsTowardFree: 0, castsTowardFree: 0,
@@ -547,7 +600,7 @@ export function CombatScreen({
runStartedAtRef.current = Date.now() runStartedAtRef.current = Date.now()
partStartTimesRef.current = { [startPart]: runStartedAtRef.current } partStartTimesRef.current = { [startPart]: runStartedAtRef.current }
setLog([{ id: nextLogId.current++, text: 'A new run begins.', tone: 'system' }]) setLog([{ id: nextLogId.current++, text: 'A new run begins.', tone: 'system' }])
}, [difficulty, initialEncounterIndex, maxResource, partyTemplate, roguelikeMode, roguelikePool, setCombat, setSelectedTargetId, startPart, staticEncounters]) }, [difficulty, enemyCount, initialEncounterIndex, maxResource, partyTemplate, roguelikeMode, roguelikePool, setCombat, setSelectedTargetId, startPart, staticEncounters])
const castSpell = useCallback( const castSpell = useCallback(
(spell: Spell) => { (spell: Spell) => {
@@ -571,7 +624,7 @@ export function CombatScreen({
? groupHealTargets(current.party, DEFAULT_GROUP_HEAL_TARGETS + extraTargets).map((member) => member.id) ? groupHealTargets(current.party, DEFAULT_GROUP_HEAL_TARGETS + extraTargets).map((member) => member.id)
: [], : [],
) )
if (spell.kind === 'hot') hotTargets.add(targetId) if (spell.kind === 'hot' || spell.effectType === 'direct_hot') hotTargets.add(targetId)
if (spell.kind === 'shield') shieldTargets.add(targetId) if (spell.kind === 'shield') shieldTargets.add(targetId)
if (spell.name === 'Mend' && activeSetEffects.has('mend_extra_target')) { if (spell.name === 'Mend' && activeSetEffects.has('mend_extra_target')) {
const extra = extraTarget([targetId]) const extra = extraTarget([targetId])
@@ -604,16 +657,38 @@ export function CombatScreen({
if (member.health <= 0) return member if (member.health <= 0) return member
if (spell.kind === 'group') { if (spell.kind === 'group') {
if (!groupTargets.has(member.id)) return member if (!groupTargets.has(member.id)) return member
if (spell.effectType === 'party_absorb') {
const power = Math.round(spell.power * (1.25 ** upgradeStackCount(roguelikeUpgrades, 'shield-boost')))
return { ...member, shield: Math.max(member.shield, power) }
}
if (spell.effectType === 'party_hot') {
return {
...member,
hotTicks: 0,
hotEffects: addHotEffect(member, spell),
}
}
const power = Math.round(spell.power * (1.25 ** upgradeStackCount(roguelikeUpgrades, 'group-heal-boost'))) const power = Math.round(spell.power * (1.25 ** upgradeStackCount(roguelikeUpgrades, 'group-heal-boost')))
const nextHealth = healMember(member, power) const nextHealth = healMember(member, power)
addFloatingHeal(member.id, Math.max(0, nextHealth - member.health)) addFloatingHeal(member.id, Math.max(0, nextHealth - member.health))
return { ...member, health: nextHealth } return { ...member, health: nextHealth }
} }
if (!directTargets.has(member.id) && !hotTargets.has(member.id) && !shieldTargets.has(member.id)) return member if (
!directTargets.has(member.id)
&& !hotTargets.has(member.id)
&& !shieldTargets.has(member.id)
&& !(member.id === targetId && (spell.kind === 'damage_reduction' || spell.kind === 'bounce_heal'))
) return member
if (spell.kind === 'shield') { if (spell.kind === 'shield') {
const power = Math.round(spell.power * (1.25 ** upgradeStackCount(roguelikeUpgrades, 'shield-boost'))) const power = Math.round(spell.power * (1.25 ** upgradeStackCount(roguelikeUpgrades, 'shield-boost')))
return { ...member, shield: Math.max(member.shield, power) } return { ...member, shield: Math.max(member.shield, power) }
} }
if (spell.kind === 'damage_reduction') {
return { ...member, damageReductionTicks: 12 }
}
if (spell.kind === 'bounce_heal') {
return { ...member, bounceHeals: addBounceHeal(member, spell) }
}
if (spell.kind === 'cleanse') { if (spell.kind === 'cleanse') {
return { return {
...member, ...member,
@@ -632,7 +707,8 @@ export function CombatScreen({
return { return {
...member, ...member,
health: nextHealth, health: nextHealth,
hotTicks: hotTargets.has(member.id) ? 5 : member.hotTicks, hotTicks: 0,
hotEffects: hotTargets.has(member.id) ? addHotEffect(member, spell) : member.hotEffects,
} }
}) })
const freeCastStacks = upgradeStackCount(roguelikeUpgrades, 'fifth-cast-free') const freeCastStacks = upgradeStackCount(roguelikeUpgrades, 'fifth-cast-free')
@@ -686,6 +762,7 @@ export function CombatScreen({
completedPart, completedPart,
runStartPart, runStartPart,
[partDuration(1), partDuration(2), partDuration(3)], [partDuration(1), partDuration(2), partDuration(3)],
hardMode,
) )
.then((result) => { .then((result) => {
setReward(result) setReward(result)
@@ -698,7 +775,7 @@ export function CombatScreen({
) )
}) })
}, },
[difficulty.id, dungeon.id, onProfileUpdated], [difficulty.id, dungeon.id, hardMode, onProfileUpdated],
) )
const finishRoguelikeRun = useCallback( const finishRoguelikeRun = useCallback(
@@ -796,6 +873,9 @@ export function CombatScreen({
poisonStacks: undefined, poisonStacks: undefined,
maxHealthPenaltyTicks: undefined, maxHealthPenaltyTicks: undefined,
healingReductionTicks: undefined, healingReductionTicks: undefined,
hotEffects: [],
bounceHeals: [],
damageReductionTicks: undefined,
})) }))
const nextStage = clearedBoss ? roguelikeStage + 1 : roguelikeStage const nextStage = clearedBoss ? roguelikeStage + 1 : roguelikeStage
const nextSegment = clearedBoss const nextSegment = clearedBoss
@@ -814,7 +894,7 @@ export function CombatScreen({
setCombat({ setCombat({
...current, ...current,
party: recoveredParty, party: recoveredParty,
enemyHealth: nextEncounter.maxHealth, enemyHealth: nextEncounter.maxHealth * enemyCount,
elapsedTicks: 0, elapsedTicks: 0,
cooldowns: {}, cooldowns: {},
resource: clamp(current.resource + Math.round(maxResource * 0.25), 0, maxResource), resource: clamp(current.resource + Math.round(maxResource * 0.25), 0, maxResource),
@@ -822,7 +902,7 @@ export function CombatScreen({
setUpgradeChoices([]) setUpgradeChoices([])
setStatus('playing') setStatus('playing')
addLog(`${upgrade.name} gained. ${nextEncounter.enemyName} approaches.`, 'system') addLog(`${upgrade.name} gained. ${nextEncounter.enemyName} approaches.`, 'system')
}, [addLog, difficulty, encounterIndex, encounters, maxResource, roguelikeMode, roguelikePool, roguelikeStage, setCombat]) }, [addLog, difficulty, encounterIndex, encounters, enemyCount, maxResource, roguelikeMode, roguelikePool, roguelikeStage, setCombat])
useGameAction((action, device) => { useGameAction((action, device) => {
if (action === 'pause' || (action === 'back' && device === 'pc')) { if (action === 'pause' || (action === 'back' && device === 'pc')) {
@@ -914,7 +994,11 @@ export function CombatScreen({
const healerBeforeDamage = current.party.find((member) => member.id === 'mira') const healerBeforeDamage = current.party.find((member) => member.id === 'mira')
const tankPressure = tankPressureTargets(current.party) const tankPressure = tankPressureTargets(current.party)
const tankPressureIds = new Set(tankPressure.targets.map((member) => member.id)) const tankPressureIds = new Set(tankPressure.targets.map((member) => member.id))
const nextParty = current.party.map((member) => { const pendingJumpHeals: Array<{
targetId: string
heal: NonNullable<PartyMember['bounceHeals']>[number]
}> = []
const damagedParty = current.party.map((member) => {
if (member.health <= 0) return member if (member.health <= 0) return member
let damage = member.id === primaryTarget.id ? encounter.damage : 0 let damage = member.id === primaryTarget.id ? encounter.damage : 0
if (tankPressureIds.has(member.id)) { if (tankPressureIds.has(member.id)) {
@@ -929,8 +1013,28 @@ export function CombatScreen({
? Math.max(1, (member.poisonStacks ?? 0) + 1) ? Math.max(1, (member.poisonStacks ?? 0) + 1)
: member.poisonStacks ?? 0 : member.poisonStacks ?? 0
if (nextPoisonStacks > 0) damage += Math.round((4 + nextPoisonStacks * 4) * difficulty.damageMultiplier) if (nextPoisonStacks > 0) damage += Math.round((4 + nextPoisonStacks * 4) * difficulty.damageMultiplier)
damage *= enemyCount
if ((member.damageReductionTicks ?? 0) > 0) {
damage = Math.round(damage * 0.5)
}
const absorbed = Math.min(member.shield, damage) const absorbed = Math.min(member.shield, damage)
const healing = member.hotTicks > 0 ? healAmount(member, 6) : 0 const hotEffects = memberHotEffects(member)
let healing = hotEffects.reduce((total, effect) => total + healAmount(member, effect.power), 0)
let nextBounceHeals = [...(member.bounceHeals ?? [])]
if (damage > 0 && nextBounceHeals.length > 0) {
nextBounceHeals = nextBounceHeals.flatMap((effect) => {
healing += healAmount(member, effect.power)
const nextCharges = effect.charges - 1
if (nextCharges <= 0) return []
const jumpTargets = current.party.filter((candidate) => candidate.health > 0 && candidate.id !== member.id)
const jumpTarget = jumpTargets[Math.floor(Math.random() * jumpTargets.length)] ?? member
pendingJumpHeals.push({
targetId: jumpTarget.id,
heal: { ...effect, charges: nextCharges },
})
return []
})
}
if (healing > 0) addFloatingHeal(member.id, healing) if (healing > 0) addFloatingHeal(member.id, healing)
const nextMaxHealthPenaltyTicks = appliesMaxHealthCut && member.id === primaryTarget.id const nextMaxHealthPenaltyTicks = appliesMaxHealthCut && member.id === primaryTarget.id
? 15 ? 15
@@ -944,9 +1048,12 @@ export function CombatScreen({
: Math.max(0, (member.debuffTicks ?? 0) - 1) : Math.max(0, (member.debuffTicks ?? 0) - 1)
return { return {
...member, ...member,
health: clamp(member.health - damage + absorbed + healing, 0, nextEffectiveMaxHealth), health: clamp(clamp(member.health + healing, 0, nextEffectiveMaxHealth) - damage + absorbed, 0, nextEffectiveMaxHealth),
shield: Math.max(0, member.shield - damage), shield: Math.max(0, member.shield - damage),
hotTicks: Math.max(0, member.hotTicks - 1), hotTicks: 0,
hotEffects: tickHotEffects(hotEffects),
bounceHeals: nextBounceHeals,
damageReductionTicks: Math.max(0, (member.damageReductionTicks ?? 0) - 1),
debuff: nextDebuffTicks > 0 debuff: nextDebuffTicks > 0
? (appliesDebuff && member.id === primaryTarget.id ? 'Searing Mark' : member.debuff) ? (appliesDebuff && member.id === primaryTarget.id ? 'Searing Mark' : member.debuff)
: undefined, : undefined,
@@ -956,6 +1063,17 @@ export function CombatScreen({
healingReductionTicks: nextHealingReductionTicks, healingReductionTicks: nextHealingReductionTicks,
} }
}) })
const nextParty = damagedParty.map((member) => {
const jumped = pendingJumpHeals.filter((jump) => jump.targetId === member.id)
if (jumped.length === 0) return member
return {
...member,
bounceHeals: [
...(member.bounceHeals ?? []),
...jumped.map((jump) => jump.heal),
],
}
})
const healerAfterDamage = nextParty.find((member) => member.id === 'mira') const healerAfterDamage = nextParty.find((member) => member.id === 'mira')
if ( if (
@@ -996,7 +1114,9 @@ export function CombatScreen({
} }
if (!isRoguelike && encounter.lootTables.some((entry) => entry.difficultyId === difficulty.id)) { if (!isRoguelike && encounter.lootTables.some((entry) => entry.difficultyId === difficulty.id)) {
requestLootRoll(encounter.id) for (let rollIndex = 0; rollIndex < enemyCount; rollIndex += 1) {
requestLootRoll(encounter.id, rollIndex)
}
} }
if (isRoguelike && (upgradesEveryEncounter || encounter.isBoss)) { if (isRoguelike && (upgradesEveryEncounter || encounter.isBoss)) {
@@ -1053,6 +1173,9 @@ export function CombatScreen({
poisonStacks: undefined, poisonStacks: undefined,
maxHealthPenaltyTicks: undefined, maxHealthPenaltyTicks: undefined,
healingReductionTicks: undefined, healingReductionTicks: undefined,
hotEffects: [],
bounceHeals: [],
damageReductionTicks: undefined,
})) }))
setEncounterIndex((value) => value + 1) setEncounterIndex((value) => value + 1)
setCombat({ setCombat({
@@ -1061,13 +1184,14 @@ export function CombatScreen({
resource: nextResource, resource: nextResource,
cooldowns: nextCooldowns, cooldowns: nextCooldowns,
elapsedTicks: 0, elapsedTicks: 0,
enemyHealth: nextEncounter.maxHealth, enemyHealth: nextEncounter.maxHealth * enemyCount,
}) })
addLog(`${encounter.enemyName} defeated. ${nextEncounter.enemyName} approaches.`, 'system') addLog(`${encounter.enemyName} defeated. ${nextEncounter.enemyName} approaches.`, 'system')
}, [ }, [
addLog, addLog,
addFloatingHeal, addFloatingHeal,
difficulty.damageMultiplier, difficulty.damageMultiplier,
enemyCount,
encounter, encounter,
encounterIndex, encounterIndex,
encounters, encounters,
@@ -1136,15 +1260,23 @@ export function CombatScreen({
}) })
}, [expectedLootRolls, lootRolls.length, onProfileUpdated, reward]) }, [expectedLootRolls, lootRolls.length, onProfileUpdated, reward])
const enemyPercent = (enemyHealth / encounter.maxHealth) * 100 const enemyPercent = (enemyHealth / encounterMaxHealth) * 100
const enemyHealthSegments = Array.from({ length: enemyCount }, (_, index) => {
const remaining = clamp(enemyHealth - encounter.maxHealth * index, 0, encounter.maxHealth)
return {
index,
health: remaining,
percent: (remaining / encounter.maxHealth) * 100,
}
}).reverse()
const dualScreenState = useMemo<DualScreenCombatState>(() => ({ const dualScreenState = useMemo<DualScreenCombatState>(() => ({
difficultyName: difficulty.name, difficultyName: difficulty.name,
dungeonName: dungeon.name, dungeonName: hardMode ? `${dungeon.name} Hard` : dungeon.name,
contentName, contentName,
encounterName: encounter.enemyName, encounterName: encounter.enemyName,
encounterDescription: encounter.description, encounterDescription: encounter.description,
encounterHealth: enemyHealth, encounterHealth: enemyHealth,
encounterMaxHealth: encounter.maxHealth, encounterMaxHealth,
encounterIsBoss: encounter.isBoss, encounterIsBoss: encounter.isBoss,
encounterIndex, encounterIndex,
encounterCount: encounters.length, encounterCount: encounters.length,
@@ -1186,7 +1318,8 @@ export function CombatScreen({
encounter.description, encounter.description,
encounter.enemyName, encounter.enemyName,
encounter.isBoss, encounter.isBoss,
encounter.maxHealth, encounterMaxHealth,
hardMode,
enemyHealth, enemyHealth,
encounterIndex, encounterIndex,
encounters.length, encounters.length,
@@ -1215,7 +1348,7 @@ export function CombatScreen({
> >
{!dualScreenEnabled && <header className="topbar"> {!dualScreenEnabled && <header className="topbar">
<div> <div>
<p className="eyebrow">{difficulty.name} - Item Level {difficulty.droppedItemLevel}</p> <p className="eyebrow">{difficulty.name}{hardMode ? ' Hard' : ''} - Item Level {difficulty.droppedItemLevel}</p>
<h1>{dungeon.name}</h1> <h1>{dungeon.name}</h1>
</div> </div>
<div className="combat-header-actions"> <div className="combat-header-actions">
@@ -1238,10 +1371,21 @@ export function CombatScreen({
</div> </div>
<div className="enemy-info"> <div className="enemy-info">
<div className="bar-label"> <div className="bar-label">
<strong>{encounter.enemyName}</strong> <strong>{hardMode ? `${encounter.enemyName} x2` : encounter.enemyName}</strong>
<span>{Math.ceil(enemyHealth)} / {encounter.maxHealth}</span> <span>{Math.ceil(enemyHealth)} / {encounterMaxHealth}</span>
</div> </div>
<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> <p>{encounter.description}</p>
</div> </div>
</section> </section>
@@ -1288,7 +1432,14 @@ export function CombatScreen({
.map((entry) => <span className="floating-heal" key={entry.id}>+{entry.value}</span>)} .map((entry) => <span className="floating-heal" key={entry.id}>+{entry.value}</span>)}
</div> </div>
<div className="member-effects"> <div className="member-effects">
{member.hotTicks > 0 && <span className="buff">Renew {formatEffectTime(member.hotTicks)}</span>} {memberHotEffects(member).map((effect) => (
<span className="buff" key={effect.id}>{effect.label} {formatEffectTime(effect.ticks)}</span>
))}
{member.shield > 0 && <span className="buff">Shield {Math.ceil(member.shield)}</span>}
{(member.damageReductionTicks ?? 0) > 0 && <span className="buff">Barkskin {formatEffectTime(member.damageReductionTicks ?? 0)}</span>}
{(member.bounceHeals ?? []).map((effect) => (
<span className="buff" key={effect.id}>{effect.label} {effect.charges}</span>
))}
{member.debuff && member.debuffTicks && <span className="debuff">{member.debuff} {formatEffectTime(member.debuffTicks)}</span>} {member.debuff && member.debuffTicks && <span className="debuff">{member.debuff} {formatEffectTime(member.debuffTicks)}</span>}
{member.poisonStacks && member.poisonStacks > 0 && <span className="debuff">Poison {member.poisonStacks}</span>} {member.poisonStacks && member.poisonStacks > 0 && <span className="debuff">Poison {member.poisonStacks}</span>}
{member.maxHealthPenaltyTicks && member.maxHealthPenaltyTicks > 0 && <span className="debuff">Max HP -25% {formatEffectTime(member.maxHealthPenaltyTicks)}</span>} {member.maxHealthPenaltyTicks && member.maxHealthPenaltyTicks > 0 && <span className="debuff">Max HP -25% {formatEffectTime(member.maxHealthPenaltyTicks)}</span>}
@@ -1518,33 +1669,41 @@ export function CombatScreen({
<div> <div>
<p className="eyebrow">{sectionName} Complete</p> <p className="eyebrow">{sectionName} Complete</p>
<h2>{encounter.enemyName} Defeated</h2> <h2>{encounter.enemyName} Defeated</h2>
<p>Proceed to {sectionName} {currentPart + 1} or end the run?</p> <p>{canContinueAfterPart ? `Proceed to ${sectionName} ${currentPart + 1} or end the run?` : 'Hard mode for this section is complete.'}</p>
<button {canContinueAfterPart && (
onClick={() => { <button
const nextIndex = encounterIndex + 1 onClick={() => {
partStartTimesRef.current[currentPart + 1] = Date.now() const nextIndex = encounterIndex + 1
const nextEncounter = encounters[nextIndex] partStartTimesRef.current[currentPart + 1] = Date.now()
const current = combatRef.current const nextEncounter = encounters[nextIndex]
const recoveredParty = current.party.map((member) => ({ const current = combatRef.current
...member, const recoveredParty = current.party.map((member) => ({
health: clamp(member.health + 35, 0, member.maxHealth), ...member,
debuff: undefined, health: clamp(member.health + 35, 0, member.maxHealth),
debuffTicks: undefined, debuff: undefined,
})) debuffTicks: undefined,
setEncounterIndex(nextIndex) poisonStacks: undefined,
setCombat({ maxHealthPenaltyTicks: undefined,
...current, healingReductionTicks: undefined,
party: recoveredParty, hotEffects: [],
enemyHealth: nextEncounter.maxHealth, bounceHeals: [],
elapsedTicks: 0, damageReductionTicks: undefined,
}) }))
setStatus('playing') setEncounterIndex(nextIndex)
addLog(`Proceeding to ${sectionName} ${currentPart + 1}. ${nextEncounter.enemyName} approaches.`, 'system') setCombat({
}} ...current,
type="button" party: recoveredParty,
> enemyHealth: nextEncounter.maxHealth * enemyCount,
Continue to {sectionName} {currentPart + 1} elapsedTicks: 0,
</button> })
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"> <button className="secondary-result-button" onClick={() => finishRun(currentPart, startPart)} type="button">
End Run End Run
</button> </button>
+16 -11
View File
@@ -28,6 +28,7 @@ const EQUIPMENT_LIST_PAGE_SIZE = 3
const CRAFTING_LIST_PAGE_SIZE = 3 const CRAFTING_LIST_PAGE_SIZE = 3
const CRAFTING_FILTER_SLOTS = (Object.keys(SLOT_LABELS) as EquipmentSlot[]) const CRAFTING_FILTER_SLOTS = (Object.keys(SLOT_LABELS) as EquipmentSlot[])
.filter((slot) => slot !== 'component') .filter((slot) => slot !== 'component')
const DIRECT_CRAFT_ITEM_LEVELS = new Set([1, 10, 20, 25])
type Props = { type Props = {
profile: CharacterProfile profile: CharacterProfile
@@ -68,18 +69,17 @@ export function EquipmentScreen({
const [message, setMessage] = useState('') const [message, setMessage] = useState('')
const scrollRef = useRef<number>(0) const scrollRef = useRef<number>(0)
const selectedItem = profile.inventory.find((item) => item.id === selectedItemId) const selectedItem = profile.inventory.find((item) => item.id === selectedItemId)
const firstRecipe = profile.craftingRecipes.find((recipe) => recipe.canCraft) const craftableRecipes = profile.craftingRecipes.filter((recipe) =>
?? profile.craftingRecipes[0] DIRECT_CRAFT_ITEM_LEVELS.has(recipe.item.itemLevel),
)
const firstRecipe = craftableRecipes.find((recipe) => recipe.canCraft)
?? craftableRecipes[0]
const [selectedRecipeId, setSelectedRecipeId] = useState<number | null>( const [selectedRecipeId, setSelectedRecipeId] = useState<number | null>(
firstRecipe?.id ?? null, firstRecipe?.id ?? null,
) )
const selectedRecipe = profile.craftingRecipes.find((recipe) => recipe.id === selectedRecipeId) const selectedRecipe = profile.craftingRecipes.find((recipe) => recipe.id === selectedRecipeId)
const selectedRecipeRequiresUpgrade = selectedRecipe const selectedRecipeRequiresUpgrade = selectedRecipe
? profile.craftingRecipes.some((recipe) => ? !DIRECT_CRAFT_ITEM_LEVELS.has(selectedRecipe.item.itemLevel)
recipe.sourceEncounterId === selectedRecipe.sourceEncounterId
&& recipe.item.slot === selectedRecipe.item.slot
&& recipe.item.itemLevel < selectedRecipe.item.itemLevel,
)
: false : false
const selectedItemRecipe = selectedItem const selectedItemRecipe = selectedItem
? profile.craftingRecipes.find((recipe) => recipe.item.id === selectedItem.id) ? profile.craftingRecipes.find((recipe) => recipe.item.id === selectedItem.id)
@@ -126,12 +126,14 @@ export function EquipmentScreen({
const [slotFilter, setSlotFilter] = useState<EquipmentSlot | 'all'>('all') const [slotFilter, setSlotFilter] = useState<EquipmentSlot | 'all'>('all')
const [levelFilter, setLevelFilter] = useState<number | null>(null) const [levelFilter, setLevelFilter] = useState<number | null>(null)
const availableLevels = useMemo( const availableLevels = useMemo(
() => [...new Set(profile.craftingRecipes.map((r) => r.item.itemLevel))].sort((a, b) => b - a), () => [...new Set(profile.craftingRecipes
.filter((r) => DIRECT_CRAFT_ITEM_LEVELS.has(r.item.itemLevel))
.map((r) => r.item.itemLevel))].sort((a, b) => b - a),
[profile.craftingRecipes], [profile.craftingRecipes],
) )
const filteredRecipes = useMemo( const filteredRecipes = useMemo(
() => { () => {
let result = [...profile.craftingRecipes] let result = profile.craftingRecipes.filter((r) => DIRECT_CRAFT_ITEM_LEVELS.has(r.item.itemLevel))
if (slotFilter !== 'all') result = result.filter((r) => r.item.slot === slotFilter) if (slotFilter !== 'all') result = result.filter((r) => r.item.slot === slotFilter)
if (levelFilter !== null) result = result.filter((r) => r.item.itemLevel === levelFilter) if (levelFilter !== null) result = result.filter((r) => r.item.itemLevel === levelFilter)
result.sort((a, b) => b.item.itemLevel - a.item.itemLevel) result.sort((a, b) => b.item.itemLevel - a.item.itemLevel)
@@ -144,7 +146,10 @@ export function EquipmentScreen({
() => new Map( () => new Map(
(Object.keys(SLOT_LABELS) as EquipmentSlot[]).map((slot) => [ (Object.keys(SLOT_LABELS) as EquipmentSlot[]).map((slot) => [
slot, slot,
profile.craftingRecipes.filter((recipe) => recipe.item.slot === slot).length, profile.craftingRecipes.filter((recipe) =>
recipe.item.slot === slot
&& DIRECT_CRAFT_ITEM_LEVELS.has(recipe.item.itemLevel),
).length,
]), ]),
), ),
[profile.craftingRecipes], [profile.craftingRecipes],
@@ -589,7 +594,7 @@ export function EquipmentScreen({
type="button" type="button"
> >
<strong>All</strong> <strong>All</strong>
<span>{profile.craftingRecipes.length}</span> <span>{profile.craftingRecipes.filter((recipe) => DIRECT_CRAFT_ITEM_LEVELS.has(recipe.item.itemLevel)).length}</span>
</button> </button>
{CRAFTING_FILTER_SLOTS.map((slot) => ( {CRAFTING_FILTER_SLOTS.map((slot) => (
<button <button
+15 -1
View File
@@ -8,6 +8,19 @@ export type PartyMember = {
maxHealth: number maxHealth: number
shield: number shield: number
hotTicks: number hotTicks: number
hotEffects?: Array<{
id: string
label: string
ticks: number
power: number
}>
bounceHeals?: Array<{
id: string
label: string
charges: number
power: number
}>
damageReductionTicks?: number
debuff?: string debuff?: string
debuffTicks?: number debuffTicks?: number
poisonStacks?: number poisonStacks?: number
@@ -24,7 +37,8 @@ export type Spell = {
cooldown: number cooldown: number
power: number power: number
glyph: string glyph: string
kind: 'direct' | 'hot' | 'group' | 'shield' | 'cleanse' kind: 'direct' | 'hot' | 'group' | 'shield' | 'cleanse' | 'damage_reduction' | 'bounce_heal'
effectType?: string
} }
export type Encounter = { export type Encounter = {
+54 -17
View File
@@ -26,6 +26,7 @@ export interface GameRepository {
completedPart?: number, completedPart?: number,
startPart?: number, startPart?: number,
partDurationSeconds?: [number, number, number], partDurationSeconds?: [number, number, number],
hardMode?: boolean,
): Promise<DungeonReward> ): Promise<DungeonReward>
completeRoguelike( completeRoguelike(
dungeonId: number, dungeonId: number,
@@ -359,11 +360,33 @@ function experienceForLevel(level: number) {
return (level - 1) * (level - 1) * 100 return (level - 1) * (level - 1) * 100
} }
function catchUpExperienceReward(
baseReward: number,
currentExperience: number,
currentLevel: number,
targetLevel: number,
) {
if (targetLevel <= currentLevel) return baseReward
const targetExperience = experienceForLevel(targetLevel)
const gap = Math.max(0, targetExperience - currentExperience)
if (gap <= 0) return baseReward
const doubledBase = Math.min(baseReward, Math.ceil(gap / 2))
return doubledBase * 2 + (baseReward - doubledBase)
}
function highestOtherClassLevel(save: OfflineSave) {
const activeClass = save.activeClassId
return Object.entries(save.characters)
.filter(([classId]) => Number(classId) !== activeClass)
.reduce((highest, [, character]) => Math.max(highest, character.level), 0)
}
function scaledPvpBossExperience( function scaledPvpBossExperience(
startingExperience: number, startingExperience: number,
startingLevel: number, startingLevel: number,
bossesCleared: number, bossesCleared: number,
maxLevel: number, maxLevel: number,
targetLevel = startingLevel,
) { ) {
let experience = startingExperience let experience = startingExperience
let level = startingLevel let level = startingLevel
@@ -374,7 +397,8 @@ function scaledPvpBossExperience(
? maxExperience ? maxExperience
: experienceForLevel(level + 1) : experienceForLevel(level + 1)
const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor) const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor)
experience = Math.min(maxExperience, experience + Math.round(levelBand * 0.25)) const rewardRate = targetLevel > level ? 0.5 : 0.25
experience = Math.min(maxExperience, experience + Math.round(levelBand * rewardRate))
while (level < maxLevel && experienceForLevel(level + 1) <= experience) { while (level < maxLevel && experienceForLevel(level + 1) <= experience) {
level += 1 level += 1
} }
@@ -389,6 +413,7 @@ const COMPONENT_ITEMS: Record<number, ComponentTemplate> = {
20: { id: 604, slug: 'superior-component', name: 'Superior Component', itemLevel: 20, glyph: '◎', description: 'A superior crafting component.' }, 20: { id: 604, slug: 'superior-component', name: 'Superior Component', itemLevel: 20, glyph: '◎', description: 'A superior crafting component.' },
25: { id: 605, slug: 'primal-component', name: 'Primal Component', itemLevel: 25, glyph: '✦', description: 'A primal crafting component.' }, 25: { id: 605, slug: 'primal-component', name: 'Primal Component', itemLevel: 25, glyph: '✦', description: 'A primal crafting component.' },
} }
const DIRECT_CRAFT_ITEM_LEVELS = new Set([1, 10, 20, 25])
type WindowWithApiBase = Window & { type WindowWithApiBase = Window & {
CAPACITOR_API_BASE_URL?: string CAPACITOR_API_BASE_URL?: string
@@ -716,7 +741,7 @@ const serverRepository: GameRepository = {
), ),
saveProfile: (classId, abilitySlots) => saveProfile: (classId, abilitySlots) =>
cachedOnlineLocalRepository.saveProfile(classId, abilitySlots), cachedOnlineLocalRepository.saveProfile(classId, abilitySlots),
completeDungeon: (dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds) => completeDungeon: (dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds, hardMode) =>
cachedOnlineLocalRepository.completeDungeon( cachedOnlineLocalRepository.completeDungeon(
dungeonId, dungeonId,
difficultyId, difficultyId,
@@ -725,6 +750,7 @@ const serverRepository: GameRepository = {
completedPart, completedPart,
startPart, startPart,
partDurationSeconds, partDurationSeconds,
hardMode,
), ),
completeRoguelike: (dungeonId, difficultyId, encountersCleared, resourceSpent, durationSeconds, options) => completeRoguelike: (dungeonId, difficultyId, encountersCleared, resourceSpent, durationSeconds, options) =>
cachedOnlineLocalRepository.completeRoguelike( cachedOnlineLocalRepository.completeRoguelike(
@@ -831,7 +857,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
store.writeSave(save) store.writeSave(save)
return buildProfile(save) return buildProfile(save)
}, },
async completeDungeon(dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds) { async completeDungeon(dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds, hardMode) {
void startPart void startPart
void partDurationSeconds void partDurationSeconds
if (!Number.isInteger(resourceSpent) || resourceSpent < 0) { if (!Number.isInteger(resourceSpent) || resourceSpent < 0) {
@@ -857,8 +883,15 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
const previousLevel = cd.level const previousLevel = cd.level
const previousExperience = cd.experience const previousExperience = cd.experience
const partCount = completedPart ?? 1 const partCount = completedPart ?? 1
const experienceReward = Math.round( const rewardMultiplier = hardMode ? 2 : 1
dungeon.experienceReward * difficulty.experienceMultiplier * partCount, const baseExperienceReward = Math.round(
dungeon.experienceReward * difficulty.experienceMultiplier * partCount * rewardMultiplier,
)
const experienceReward = catchUpExperienceReward(
baseExperienceReward,
previousExperience,
previousLevel,
highestOtherClassLevel(save),
) )
const maxExperience = experienceForLevel(profile.maxLevel) const maxExperience = experienceForLevel(profile.maxLevel)
const newExperience = Math.min(previousExperience + experienceReward, maxExperience) const newExperience = Math.min(previousExperience + experienceReward, maxExperience)
@@ -906,19 +939,20 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
const selected = rewardPool[Math.floor(Math.random() * rewardPool.length)] const selected = rewardPool[Math.floor(Math.random() * rewardPool.length)]
const existing = profile.inventory.find((item) => item.id === selected.id) const existing = profile.inventory.find((item) => item.id === selected.id)
const duplicate = Boolean(existing) const duplicate = Boolean(existing)
let quantityAfter = 1 const rewardQuantity = rewardMultiplier
let quantityAfter = rewardQuantity
if (existing) { if (existing) {
existing.quantity += 1 existing.quantity += rewardQuantity
quantityAfter = existing.quantity quantityAfter = existing.quantity
} else { } else {
profile.inventory.push({ profile.inventory.push({
...selected, ...selected,
quantity: 1, quantity: rewardQuantity,
equipped: false, equipped: false,
}) })
} }
cd.inventory = profile.inventory cd.inventory = profile.inventory
bonusItem = { ...selected, quantity: 1, duplicate, quantityAfter } bonusItem = { ...selected, quantity: rewardQuantity, duplicate, quantityAfter }
} }
} }
@@ -971,13 +1005,19 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
const maxExperience = experienceForLevel(profile.maxLevel) const maxExperience = experienceForLevel(profile.maxLevel)
const bossesCleared = Math.max(0, Math.floor(options?.bossesCleared ?? encountersCleared / 3)) const bossesCleared = Math.max(0, Math.floor(options?.bossesCleared ?? encountersCleared / 3))
const scaledReward = options?.experienceMode === 'pvp-boss-quarter-level' const scaledReward = options?.experienceMode === 'pvp-boss-quarter-level'
? scaledPvpBossExperience(previousExperience, previousLevel, bossesCleared, profile.maxLevel) ? scaledPvpBossExperience(previousExperience, previousLevel, bossesCleared, profile.maxLevel, highestOtherClassLevel(save))
: null : null
const baseRoguelikeReward = Math.round(dungeon.experienceReward * difficulty.experienceMultiplier * (encountersCleared / 3))
const newExperience = scaledReward const newExperience = scaledReward
? scaledReward.experience ? scaledReward.experience
: Math.min( : Math.min(
previousExperience previousExperience
+ Math.round(dungeon.experienceReward * difficulty.experienceMultiplier * (encountersCleared / 3)), + catchUpExperienceReward(
baseRoguelikeReward,
previousExperience,
previousLevel,
highestOtherClassLevel(save),
),
maxExperience, maxExperience,
) )
let newLevel = scaledReward?.level ?? previousLevel let newLevel = scaledReward?.level ?? previousLevel
@@ -1161,11 +1201,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
const profile = buildProfile(save) const profile = buildProfile(save)
const recipe = profile.craftingRecipes.find((candidate) => candidate.id === recipeId) const recipe = profile.craftingRecipes.find((candidate) => candidate.id === recipeId)
if (!recipe) throw new Error('That crafting recipe does not exist.') if (!recipe) throw new Error('That crafting recipe does not exist.')
const requiresUpgrade = profile.craftingRecipes.some((candidate) => const requiresUpgrade = !DIRECT_CRAFT_ITEM_LEVELS.has(recipe.item.itemLevel)
candidate.sourceEncounterId === recipe.sourceEncounterId
&& candidate.item.slot === recipe.item.slot
&& candidate.item.itemLevel < recipe.item.itemLevel,
)
if (requiresUpgrade) throw new Error('Upgrade the previous item tier instead.') if (requiresUpgrade) throw new Error('Upgrade the previous item tier instead.')
const missing = recipe.components.find((component) => component.owned < component.quantity) const missing = recipe.components.find((component) => component.owned < component.quantity)
if (missing) { if (missing) {
@@ -1361,7 +1397,7 @@ const cachedOnlineRepository: GameRepository = {
}, },
loadProfile: () => cachedOnlineLocalRepository.loadProfile(), loadProfile: () => cachedOnlineLocalRepository.loadProfile(),
saveProfile: (classId, abilitySlots) => cachedOnlineLocalRepository.saveProfile(classId, abilitySlots), saveProfile: (classId, abilitySlots) => cachedOnlineLocalRepository.saveProfile(classId, abilitySlots),
completeDungeon: (dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds) => completeDungeon: (dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds, hardMode) =>
cachedOnlineLocalRepository.completeDungeon( cachedOnlineLocalRepository.completeDungeon(
dungeonId, dungeonId,
difficultyId, difficultyId,
@@ -1370,6 +1406,7 @@ const cachedOnlineRepository: GameRepository = {
completedPart, completedPart,
startPart, startPart,
partDurationSeconds, partDurationSeconds,
hardMode,
), ),
completeRoguelike: (dungeonId, difficultyId, encountersCleared, resourceSpent, durationSeconds, options) => completeRoguelike: (dungeonId, difficultyId, encountersCleared, resourceSpent, durationSeconds, options) =>
cachedOnlineLocalRepository.completeRoguelike( cachedOnlineLocalRepository.completeRoguelike(
+25 -25
View File
@@ -313,13 +313,13 @@
"classId": 2, "classId": 2,
"slug": "verdant-touch", "slug": "verdant-touch",
"name": "Verdant Touch", "name": "Verdant Touch",
"spellType": "direct_heal", "spellType": "direct_hot",
"cost": 5, "cost": 5,
"cooldown": 0.5, "cooldown": 0.5,
"power": 28, "power": 20,
"unlockLevel": 1, "unlockLevel": 1,
"glyph": "+", "glyph": "+",
"description": "A quick pulse of living energy." "description": "A weaker direct heal that also plants a stacking heal over time."
}, },
{ {
"id": 21, "id": 21,
@@ -338,27 +338,27 @@
"id": 22, "id": 22,
"classId": 2, "classId": 2,
"slug": "wild-bloom", "slug": "wild-bloom",
"name": "Wild Bloom", "name": "Wild Growth",
"spellType": "party_heal", "spellType": "party_hot",
"cost": 12, "cost": 12,
"cooldown": 8, "cooldown": 8,
"power": 17, "power": 14,
"unlockLevel": 1, "unlockLevel": 1,
"glyph": "*", "glyph": "*",
"description": "Restorative growth spreads to up to 4 injured allies." "description": "Applies a stacking heal over time to up to 4 injured allies."
}, },
{ {
"id": 23, "id": 23,
"classId": 2, "classId": 2,
"slug": "barkskin", "slug": "barkskin",
"name": "Barkskin", "name": "Barkskin",
"spellType": "absorb", "spellType": "damage_reduction",
"cost": 8, "cost": 10,
"cooldown": 7, "cooldown": 14,
"power": 34, "power": 0,
"unlockLevel": 1, "unlockLevel": 1,
"glyph": "B", "glyph": "B",
"description": "Wraps an ally in protective living bark." "description": "Reduces the target ally's damage taken by 50% for 8 seconds."
}, },
{ {
"id": 24, "id": 24,
@@ -378,13 +378,13 @@
"classId": 2, "classId": 2,
"slug": "ancient-grove", "slug": "ancient-grove",
"name": "Ancient Grove", "name": "Ancient Grove",
"spellType": "party_heal", "spellType": "party_hot",
"cost": 17, "cost": 17,
"cooldown": 12, "cooldown": 12,
"power": 31, "power": 24,
"unlockLevel": 5, "unlockLevel": 5,
"glyph": "T", "glyph": "T",
"description": "Briefly summons the shelter of an ancient grove." "description": "Applies a stronger stacking heal over time to up to 4 injured allies."
} }
], ],
"talents": [ "talents": [
@@ -569,27 +569,27 @@
"id": 31, "id": 31,
"classId": 3, "classId": 3,
"slug": "echo-rune", "slug": "echo-rune",
"name": "Echo Rune", "name": "Mending Rune",
"spellType": "heal_over_time", "spellType": "bounce_heal",
"cost": 7, "cost": 7,
"cooldown": 0.5, "cooldown": 0.5,
"power": 12, "power": 18,
"unlockLevel": 1, "unlockLevel": 1,
"glyph": "e", "glyph": "e",
"description": "Repeats a restorative rune over several moments." "description": "Places a rune that heals when the ally takes damage, then jumps 4 times."
}, },
{ {
"id": 32, "id": 32,
"classId": 3, "classId": 3,
"slug": "concordance", "slug": "concordance",
"name": "Concordance", "name": "Concordance",
"spellType": "party_heal", "spellType": "party_absorb",
"cost": 12, "cost": 12,
"cooldown": 8, "cooldown": 8,
"power": 18, "power": 28,
"unlockLevel": 1, "unlockLevel": 1,
"glyph": "*", "glyph": "*",
"description": "Links up to 4 injured allies through a shared healing pattern." "description": "Shields up to 4 injured allies through a shared barrier pattern."
}, },
{ {
"id": 33, "id": 33,
@@ -622,13 +622,13 @@
"classId": 3, "classId": 3,
"slug": "grand-design", "slug": "grand-design",
"name": "Grand Design", "name": "Grand Design",
"spellType": "party_heal", "spellType": "party_absorb",
"cost": 16, "cost": 16,
"cooldown": 12, "cooldown": 12,
"power": 30, "power": 42,
"unlockLevel": 5, "unlockLevel": 5,
"glyph": "R", "glyph": "R",
"description": "Activates a prepared network of restorative runes." "description": "Raises a stronger shared barrier around up to 4 injured allies."
} }
], ],
"talents": [ "talents": [
+2
View File
@@ -319,6 +319,7 @@ export async function completeDungeon(
completedPart?: number, completedPart?: number,
startPart?: number, startPart?: number,
partDurationSeconds?: [number, number, number], partDurationSeconds?: [number, number, number],
hardMode?: boolean,
): Promise<DungeonReward> { ): Promise<DungeonReward> {
return activeGameRepository().completeDungeon( return activeGameRepository().completeDungeon(
dungeonId, dungeonId,
@@ -328,6 +329,7 @@ export async function completeDungeon(
completedPart, completedPart,
startPart, startPart,
partDurationSeconds, partDurationSeconds,
hardMode,
) )
} }