diff --git a/IWantToHeal-Thor-v1.0.32.apk b/IWantToHeal-Thor-v1.0.32.apk new file mode 100644 index 0000000..98625c7 Binary files /dev/null and b/IWantToHeal-Thor-v1.0.32.apk differ diff --git a/android/app/build.gradle b/android/app/build.gradle index 383b9c5..3c43b6d 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -7,8 +7,8 @@ android { applicationId "com.warren.iwanttoheal" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 49 - versionName "1.0.31" + versionCode 50 + versionName "1.0.32" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/db/seed.sql b/db/seed.sql index e7a7387..d319a87 100644 --- a/db/seed.sql +++ b/db/seed.sql @@ -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 diff --git a/docs/push-updates.md b/docs/push-updates.md index 73d9eb7..789199b 100644 --- a/docs/push-updates.md +++ b/docs/push-updates.md @@ -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" \ diff --git a/server/game-api.mjs b/server/game-api.mjs index adcd8b7..522aa26 100644 --- a/server/game-api.mjs +++ b/server/game-api.mjs @@ -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 diff --git a/src/App.css b/src/App.css index 91be524..cd2b637 100644 --- a/src/App.css +++ b/src/App.css @@ -1922,6 +1922,16 @@ h2 { grid-template-columns: repeat(3, minmax(0, 1fr)); } +.part-start-row { + display: grid; + gap: 8px; + grid-template-columns: minmax(0, 1fr) minmax(88px, 0.45fr); +} + +.hard-mode-button { + border-color: #c25b4b; +} + .part-setup-panel .primary-button { min-height: 54px; } @@ -4707,6 +4717,28 @@ h2 { box-shadow: inset 0 5px #cf4b59; } +.hard-enemy-bars { + display: grid; + gap: 6px; +} + +.hard-enemy-bars .enemy-health { + position: relative; +} + +.hard-enemy-bars .enemy-health em { + color: #fff7df; + font-family: 'Press Start 2P', monospace; + font-size: 7px; + font-style: normal; + left: 8px; + position: absolute; + text-shadow: 0 1px 0 #111; + top: 50%; + transform: translateY(-50%); + z-index: 1; +} + .combat-layout { display: grid; gap: 18px; diff --git a/src/App.tsx b/src/App.tsx index cfcf6f0..e2cad8f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -88,6 +88,7 @@ function App() { const [roguelikeAbilityLabelMode, setRoguelikeAbilityLabelMode] = useState('ability') const [pvpContentType, setPvpContentType] = useState('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() { 0} profile={profile} roguelikeMode={combatContentId < 0 ? roguelikeKind : undefined} roguelikeUpgradeTiming={combatContentId < 0 ? roguelikeUpgradeTiming : undefined} @@ -294,6 +296,7 @@ function App() { setSelectedDifficultyId(baseDungeon?.difficulties[0]?.id ?? 1) } setSelectedPart(1) + setSelectedHardMode(false) setScreen('combat') } const tierOptions = activityOptions @@ -328,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 @@ -728,20 +731,36 @@ function App() {
{parts.map((p) => ( - +
+ + +
))}
diff --git a/src/components/CombatScreen.tsx b/src/components/CombatScreen.tsx index 00b450e..8ace395 100644 --- a/src/components/CombatScreen.tsx +++ b/src/components/CombatScreen.tsx @@ -113,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 } @@ -195,9 +232,14 @@ function spellResourceCost(spell: Spell, upgrades: RoguelikeUpgrade[], freeCastR function toCombatSpell(ability: Ability, key: string, healingPower: number): Spell { const kinds: Record = { 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 { @@ -210,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, } } @@ -293,6 +336,7 @@ function makeRoguelikeSegment( export function CombatScreen({ difficulty, dungeon, + hardMode = false, profile, startPart = 1, roguelikeMode, @@ -304,6 +348,7 @@ export function CombatScreen({ }: { difficulty: Difficulty dungeon: Dungeon + hardMode?: boolean profile: CharacterProfile startPart?: number roguelikeMode?: RoguelikeMode @@ -354,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(() => ({ 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(() => initialCombatState) const [selectedId, setSelectedId] = useState(partyTemplate[0].id) const [encounterIndex, setEncounterIndex] = useState(initialEncounterIndex) @@ -381,7 +427,7 @@ export function CombatScreen({ const [upgradeChoices, setUpgradeChoices] = useState([]) const rewardClaimedRef = useRef(false) const profileRefreshedRef = useRef(false) - const rolledEncounterIdsRef = useRef(new Set()) + const rolledEncounterIdsRef = useRef(new Set()) const runTokenRef = useRef(crypto.randomUUID()) const resourceSpentRef = useRef(0) const runStartedAtRef = useRef(0) @@ -397,12 +443,17 @@ export function CombatScreen({ 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') @@ -484,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 @@ -519,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, @@ -547,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) => { @@ -571,7 +624,7 @@ export function CombatScreen({ ? 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.name === 'Mend' && activeSetEffects.has('mend_extra_target')) { const extra = extraTarget([targetId]) @@ -604,16 +657,38 @@ export function CombatScreen({ 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, @@ -632,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') @@ -686,6 +762,7 @@ export function CombatScreen({ completedPart, runStartPart, [partDuration(1), partDuration(2), partDuration(3)], + hardMode, ) .then((result) => { setReward(result) @@ -698,7 +775,7 @@ export function CombatScreen({ ) }) }, - [difficulty.id, dungeon.id, onProfileUpdated], + [difficulty.id, dungeon.id, hardMode, onProfileUpdated], ) const finishRoguelikeRun = useCallback( @@ -796,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 @@ -814,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), @@ -822,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')) { @@ -914,7 +994,11 @@ export function CombatScreen({ const healerBeforeDamage = current.party.find((member) => member.id === 'mira') const tankPressure = tankPressureTargets(current.party) const tankPressureIds = new Set(tankPressure.targets.map((member) => member.id)) - const nextParty = current.party.map((member) => { + const pendingJumpHeals: Array<{ + targetId: string + heal: NonNullable[number] + }> = [] + const damagedParty = current.party.map((member) => { if (member.health <= 0) return member let damage = member.id === primaryTarget.id ? encounter.damage : 0 if (tankPressureIds.has(member.id)) { @@ -929,8 +1013,28 @@ export function CombatScreen({ ? 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 @@ -944,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, @@ -956,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 ( @@ -996,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)) { @@ -1053,6 +1173,9 @@ export function CombatScreen({ poisonStacks: undefined, maxHealthPenaltyTicks: undefined, healingReductionTicks: undefined, + hotEffects: [], + bounceHeals: [], + damageReductionTicks: undefined, })) setEncounterIndex((value) => value + 1) setCombat({ @@ -1061,13 +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') }, [ addLog, addFloatingHeal, difficulty.damageMultiplier, + enemyCount, encounter, encounterIndex, encounters, @@ -1136,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(() => ({ 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, @@ -1186,7 +1318,8 @@ export function CombatScreen({ encounter.description, encounter.enemyName, encounter.isBoss, - encounter.maxHealth, + encounterMaxHealth, + hardMode, enemyHealth, encounterIndex, encounters.length, @@ -1215,7 +1348,7 @@ export function CombatScreen({ > {!dualScreenEnabled &&
-

{difficulty.name} - Item Level {difficulty.droppedItemLevel}

+

{difficulty.name}{hardMode ? ' Hard' : ''} - Item Level {difficulty.droppedItemLevel}

{dungeon.name}

@@ -1238,10 +1371,21 @@ export function CombatScreen({
- {encounter.enemyName} - {Math.ceil(enemyHealth)} / {encounter.maxHealth} + {hardMode ? `${encounter.enemyName} x2` : encounter.enemyName} + {Math.ceil(enemyHealth)} / {encounterMaxHealth}
-
+ {hardMode ? ( +
+ {enemyHealthSegments.map((segment) => ( +
+ + {encounter.enemyName} {segment.index + 1}: {Math.ceil(segment.health)} / {encounter.maxHealth} +
+ ))} +
+ ) : ( +
+ )}

{encounter.description}

@@ -1288,7 +1432,14 @@ export function CombatScreen({ .map((entry) => +{entry.value})}
- {member.hotTicks > 0 && Renew {formatEffectTime(member.hotTicks)}} + {memberHotEffects(member).map((effect) => ( + {effect.label} {formatEffectTime(effect.ticks)} + ))} + {member.shield > 0 && Shield {Math.ceil(member.shield)}} + {(member.damageReductionTicks ?? 0) > 0 && Barkskin {formatEffectTime(member.damageReductionTicks ?? 0)}} + {(member.bounceHeals ?? []).map((effect) => ( + {effect.label} {effect.charges} + ))} {member.debuff && member.debuffTicks && {member.debuff} {formatEffectTime(member.debuffTicks)}} {member.poisonStacks && member.poisonStacks > 0 && Poison {member.poisonStacks}} {member.maxHealthPenaltyTicks && member.maxHealthPenaltyTicks > 0 && Max HP -25% {formatEffectTime(member.maxHealthPenaltyTicks)}} @@ -1518,33 +1669,41 @@ export function CombatScreen({

{sectionName} Complete

{encounter.enemyName} Defeated

-

Proceed to {sectionName} {currentPart + 1} or end the run?

- +

{canContinueAfterPart ? `Proceed to ${sectionName} ${currentPart + 1} or end the run?` : 'Hard mode for this section is complete.'}

+ {canContinueAfterPart && ( + + )} diff --git a/src/components/EquipmentScreen.tsx b/src/components/EquipmentScreen.tsx index d91790e..5d13dc7 100644 --- a/src/components/EquipmentScreen.tsx +++ b/src/components/EquipmentScreen.tsx @@ -28,6 +28,7 @@ const EQUIPMENT_LIST_PAGE_SIZE = 3 const CRAFTING_LIST_PAGE_SIZE = 3 const CRAFTING_FILTER_SLOTS = (Object.keys(SLOT_LABELS) as EquipmentSlot[]) .filter((slot) => slot !== 'component') +const DIRECT_CRAFT_ITEM_LEVELS = new Set([1, 10, 20, 25]) type Props = { profile: CharacterProfile @@ -68,18 +69,17 @@ export function EquipmentScreen({ const [message, setMessage] = useState('') const scrollRef = useRef(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( firstRecipe?.id ?? null, ) const selectedRecipe = profile.craftingRecipes.find((recipe) => recipe.id === selectedRecipeId) const selectedRecipeRequiresUpgrade = selectedRecipe - ? profile.craftingRecipes.some((recipe) => - recipe.sourceEncounterId === selectedRecipe.sourceEncounterId - && recipe.item.slot === selectedRecipe.item.slot - && recipe.item.itemLevel < selectedRecipe.item.itemLevel, - ) + ? !DIRECT_CRAFT_ITEM_LEVELS.has(selectedRecipe.item.itemLevel) : false const selectedItemRecipe = selectedItem ? profile.craftingRecipes.find((recipe) => recipe.item.id === selectedItem.id) @@ -126,12 +126,14 @@ export function EquipmentScreen({ const [slotFilter, setSlotFilter] = useState('all') const [levelFilter, setLevelFilter] = useState(null) const availableLevels = useMemo( - () => [...new Set(profile.craftingRecipes.map((r) => r.item.itemLevel))].sort((a, b) => b - a), + () => [...new Set(profile.craftingRecipes + .filter((r) => DIRECT_CRAFT_ITEM_LEVELS.has(r.item.itemLevel)) + .map((r) => r.item.itemLevel))].sort((a, b) => b - a), [profile.craftingRecipes], ) const filteredRecipes = useMemo( () => { - let result = [...profile.craftingRecipes] + let result = profile.craftingRecipes.filter((r) => DIRECT_CRAFT_ITEM_LEVELS.has(r.item.itemLevel)) if (slotFilter !== 'all') result = result.filter((r) => r.item.slot === slotFilter) if (levelFilter !== null) result = result.filter((r) => r.item.itemLevel === levelFilter) result.sort((a, b) => b.item.itemLevel - a.item.itemLevel) @@ -144,7 +146,10 @@ export function EquipmentScreen({ () => new Map( (Object.keys(SLOT_LABELS) as EquipmentSlot[]).map((slot) => [ slot, - profile.craftingRecipes.filter((recipe) => recipe.item.slot === slot).length, + profile.craftingRecipes.filter((recipe) => + recipe.item.slot === slot + && DIRECT_CRAFT_ITEM_LEVELS.has(recipe.item.itemLevel), + ).length, ]), ), [profile.craftingRecipes], @@ -589,7 +594,7 @@ export function EquipmentScreen({ type="button" > All - {profile.craftingRecipes.length} + {profile.craftingRecipes.filter((recipe) => DIRECT_CRAFT_ITEM_LEVELS.has(recipe.item.itemLevel)).length} {CRAFTING_FILTER_SLOTS.map((slot) => (