Compare commits

...

3 Commits

Author SHA1 Message Date
Warren H 1281be69d8 Android build v1.0.34 2026-06-20 15:08:51 -04:00
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
19 changed files with 1623 additions and 195 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.
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 53
versionName "1.0.31" versionName "1.0.34"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions { aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+79 -2
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
@@ -491,7 +561,9 @@ SET name = (
SELECT SELECT
CASE items.item_level CASE items.item_level
WHEN 1 THEN 'Raw ' WHEN 1 THEN 'Raw '
WHEN 5 THEN 'Honed '
WHEN 10 THEN 'Green ' WHEN 10 THEN 'Green '
WHEN 15 THEN 'Blue '
WHEN 20 THEN 'Purple ' WHEN 20 THEN 'Purple '
WHEN 25 THEN 'Orange ' WHEN 25 THEN 'Orange '
ELSE '' ELSE ''
@@ -724,6 +796,7 @@ INSERT INTO generated_loot_tiers
(item_level, dungeon_id, raid_id, dungeon_difficulty_id, raid_difficulty_id, recipe_base, craft_quantity) (item_level, dungeon_id, raid_id, dungeon_difficulty_id, raid_difficulty_id, recipe_base, craft_quantity)
VALUES VALUES
(10, 3, 2, 2, 101, 1100, 2), (10, 3, 2, 2, 101, 1100, 2),
(15, 4, 5, 3, 103, 1200, 3),
(20, 6, 7, 4, 104, 1300, 4), (20, 6, 7, 4, 104, 1300, 4),
(25, 8, 9, 5, 105, 1400, 5); (25, 8, 9, 5, 105, 1400, 5);
@@ -1277,18 +1350,20 @@ WHERE recipe_id IN (
SELECT crafting_recipes.id SELECT crafting_recipes.id
FROM crafting_recipes FROM crafting_recipes
JOIN items ON items.id = crafting_recipes.item_id JOIN items ON items.id = crafting_recipes.item_id
WHERE items.item_level NOT IN (1, 10, 20, 25) WHERE items.item_level NOT IN (1, 5, 10, 15, 20, 25)
); );
DELETE FROM crafting_recipes DELETE FROM crafting_recipes
WHERE item_id IN ( WHERE item_id IN (
SELECT id FROM items WHERE item_level NOT IN (1, 10, 20, 25) SELECT id FROM items WHERE item_level NOT IN (1, 5, 10, 15, 20, 25)
); );
UPDATE items UPDATE items
SET rarity = CASE item_level SET rarity = CASE item_level
WHEN 1 THEN 'common' WHEN 1 THEN 'common'
WHEN 5 THEN 'uncommon'
WHEN 10 THEN 'uncommon' WHEN 10 THEN 'uncommon'
WHEN 15 THEN 'rare'
WHEN 20 THEN 'epic' WHEN 20 THEN 'epic'
WHEN 25 THEN 'legendary' WHEN 25 THEN 'legendary'
ELSE rarity ELSE rarity
@@ -1300,7 +1375,9 @@ SET name = (
SELECT SELECT
CASE items.item_level CASE items.item_level
WHEN 1 THEN 'Raw ' WHEN 1 THEN 'Raw '
WHEN 5 THEN 'Honed '
WHEN 10 THEN 'Green ' WHEN 10 THEN 'Green '
WHEN 15 THEN 'Blue '
WHEN 20 THEN 'Purple ' WHEN 20 THEN 'Purple '
WHEN 25 THEN 'Orange ' WHEN 25 THEN 'Orange '
ELSE '' ELSE ''
+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>
+223 -60
View File
@@ -43,7 +43,7 @@ const TICK_MS = 700
type RoguelikeMode = 'dungeon' | 'raid' type RoguelikeMode = 'dungeon' | 'raid'
type RoguelikeUpgradeTiming = 'boss' | 'encounter' type RoguelikeUpgradeTiming = 'boss' | 'encounter'
type RoguelikeAbilityLabelMode = 'ability' | 'slot' type RoguelikeAbilityLabelMode = 'ability' | 'slot'
type SlotKey = '1' | '2' | '3' | '4' | '5' type SlotKey = '1' | '2' | '3' | '4' | '5' | '6'
type RoguelikeMechanic = type RoguelikeMechanic =
| 'party-pulse' | 'party-pulse'
| 'searing-mark' | 'searing-mark'
@@ -113,6 +113,47 @@ function healMember(member: PartyMember, amount: number) {
return clamp(member.health + healAmount(member, amount), 0, effectiveMaxHealth(member)) return clamp(member.health + healAmount(member, amount), 0, effectiveMaxHealth(member))
} }
function memberHotEffects(member: PartyMember) {
if (member.hotEffects?.length) return member.hotEffects
return member.hotTicks > 0
? [{ id: 'legacy-renew', label: 'Renew', ticks: member.hotTicks, power: 6 }]
: []
}
function effectId(prefix: string) {
return `${prefix}-${globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`}`
}
function addHotEffect(member: PartyMember, spell: Spell, ticks = 5) {
return [
...memberHotEffects(member),
{
id: effectId(spell.id),
label: spell.name,
ticks,
power: Math.max(1, Math.round(spell.power / 2)),
},
]
}
function addBounceHeal(member: PartyMember, spell: Spell) {
return [
...(member.bounceHeals ?? []),
{
id: effectId(spell.id),
label: spell.name,
charges: 4,
power: spell.power,
},
]
}
function tickHotEffects(effects: PartyMember['hotEffects']) {
return (effects ?? [])
.map((effect) => ({ ...effect, ticks: effect.ticks - 1 }))
.filter((effect) => effect.ticks > 0)
}
function upgradeStackCount(upgrades: RoguelikeUpgrade[], id: RoguelikeUpgradeId) { function upgradeStackCount(upgrades: RoguelikeUpgrade[], id: RoguelikeUpgradeId) {
return upgrades.filter((upgrade) => upgrade.id === id).length return upgrades.filter((upgrade) => upgrade.id === id).length
} }
@@ -127,7 +168,7 @@ function buildRoguelikeUpgrades(
spells: Spell[], spells: Spell[],
labelMode: RoguelikeAbilityLabelMode, labelMode: RoguelikeAbilityLabelMode,
): RoguelikeUpgrade[] { ): RoguelikeUpgrade[] {
const slotUpgrades = (['1', '2', '3', '4', '5'] as SlotKey[]).flatMap((slot) => { const slotUpgrades = (['1', '2', '3', '4', '5', '6'] as SlotKey[]).flatMap((slot) => {
const label = slotLabel(slot, spells, labelMode) const label = slotLabel(slot, spells, labelMode)
return [ return [
{ {
@@ -195,9 +236,14 @@ function spellResourceCost(spell: Spell, upgrades: RoguelikeUpgrade[], freeCastR
function toCombatSpell(ability: Ability, key: string, healingPower: number): Spell { function toCombatSpell(ability: Ability, key: string, healingPower: number): Spell {
const kinds: Record<string, Spell['kind']> = { const kinds: Record<string, Spell['kind']> = {
direct_heal: 'direct', direct_heal: 'direct',
direct_hot: 'direct',
heal_over_time: 'hot', heal_over_time: 'hot',
party_heal: 'group', party_heal: 'group',
party_hot: 'group',
party_absorb: 'group',
absorb: 'shield', absorb: 'shield',
damage_reduction: 'damage_reduction',
bounce_heal: 'bounce_heal',
cleanse: 'cleanse', cleanse: 'cleanse',
} }
return { return {
@@ -210,6 +256,7 @@ function toCombatSpell(ability: Ability, key: string, healingPower: number): Spe
power: ability.power + healingPower, power: ability.power + healingPower,
glyph: ability.glyph, glyph: ability.glyph,
kind: kinds[ability.spellType] ?? 'direct', kind: kinds[ability.spellType] ?? 'direct',
effectType: ability.spellType,
} }
} }
@@ -293,6 +340,7 @@ function makeRoguelikeSegment(
export function CombatScreen({ export function CombatScreen({
difficulty, difficulty,
dungeon, dungeon,
hardMode = false,
profile, profile,
startPart = 1, startPart = 1,
roguelikeMode, roguelikeMode,
@@ -304,6 +352,7 @@ export function CombatScreen({
}: { }: {
difficulty: Difficulty difficulty: Difficulty
dungeon: Dungeon dungeon: Dungeon
hardMode?: boolean
profile: CharacterProfile profile: CharacterProfile
startPart?: number startPart?: number
roguelikeMode?: RoguelikeMode roguelikeMode?: RoguelikeMode
@@ -354,15 +403,16 @@ export function CombatScreen({
const sectionName = isRoguelike ? 'Stage' : dungeon.contentType === 'raid' ? 'Phase' : 'Part' const sectionName = isRoguelike ? 'Stage' : dungeon.contentType === 'raid' ? 'Phase' : 'Part'
const contentName = isRoguelike ? 'Roguelike' : dungeon.contentType === 'raid' ? 'Raid' : 'Dungeon' const contentName = isRoguelike ? 'Roguelike' : dungeon.contentType === 'raid' ? 'Raid' : 'Dungeon'
const initialEncounterIndex = (startPart - 1) * 3 const initialEncounterIndex = (startPart - 1) * 3
const enemyCount = hardMode ? 2 : 1
const initialCombatState = useMemo<SinglePlayerCombatState>(() => ({ const initialCombatState = useMemo<SinglePlayerCombatState>(() => ({
party: partyTemplate, party: partyTemplate,
resource: maxResource, resource: maxResource,
enemyHealth: encounters[initialEncounterIndex].maxHealth, enemyHealth: encounters[initialEncounterIndex].maxHealth * enemyCount,
cooldowns: {}, cooldowns: {},
elapsedTicks: 0, elapsedTicks: 0,
castsTowardFree: 0, castsTowardFree: 0,
freeCastReady: false, freeCastReady: false,
}), [encounters, initialEncounterIndex, maxResource, partyTemplate]) }), [encounters, enemyCount, initialEncounterIndex, maxResource, partyTemplate])
const [combatState, setCombatState] = useState<SinglePlayerCombatState>(() => initialCombatState) const [combatState, setCombatState] = useState<SinglePlayerCombatState>(() => initialCombatState)
const [selectedId, setSelectedId] = useState(partyTemplate[0].id) const [selectedId, setSelectedId] = useState(partyTemplate[0].id)
const [encounterIndex, setEncounterIndex] = useState(initialEncounterIndex) const [encounterIndex, setEncounterIndex] = useState(initialEncounterIndex)
@@ -381,7 +431,7 @@ export function CombatScreen({
const [upgradeChoices, setUpgradeChoices] = useState<RoguelikeUpgrade[]>([]) const [upgradeChoices, setUpgradeChoices] = useState<RoguelikeUpgrade[]>([])
const rewardClaimedRef = useRef(false) const rewardClaimedRef = useRef(false)
const profileRefreshedRef = useRef(false) const profileRefreshedRef = useRef(false)
const rolledEncounterIdsRef = useRef(new Set<number>()) const rolledEncounterIdsRef = useRef(new Set<string>())
const runTokenRef = useRef(crypto.randomUUID()) const runTokenRef = useRef(crypto.randomUUID())
const resourceSpentRef = useRef(0) const resourceSpentRef = useRef(0)
const runStartedAtRef = useRef(0) const runStartedAtRef = useRef(0)
@@ -397,12 +447,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 +539,12 @@ export function CombatScreen({
}, []) }, [])
const requestLootRoll = useCallback( const requestLootRoll = useCallback(
(encounterId: number) => { (encounterId: number, rollIndex = 0) => {
if (rolledEncounterIdsRef.current.has(encounterId)) return const rollKey = `${encounterId}:${rollIndex}`
rolledEncounterIdsRef.current.add(encounterId) if (rolledEncounterIdsRef.current.has(rollKey)) return
rollEncounterLoot(encounterId, difficulty.id, runTokenRef.current) rolledEncounterIdsRef.current.add(rollKey)
const runToken = rollIndex === 0 ? runTokenRef.current : `${runTokenRef.current}-hard-${rollIndex}`
rollEncounterLoot(encounterId, difficulty.id, runToken)
.then((result) => { .then((result) => {
setLootRolls((current) => [...current, result]) setLootRolls((current) => [...current, result])
const awarded = result.items const awarded = result.items
@@ -519,7 +576,7 @@ export function CombatScreen({
setCombat({ setCombat({
party: freshParty, party: freshParty,
resource: maxResource, resource: maxResource,
enemyHealth: nextEncounters[initialEncounterIndex].maxHealth, enemyHealth: nextEncounters[initialEncounterIndex].maxHealth * enemyCount,
cooldowns: {}, cooldowns: {},
elapsedTicks: 0, elapsedTicks: 0,
castsTowardFree: 0, castsTowardFree: 0,
@@ -547,7 +604,7 @@ export function CombatScreen({
runStartedAtRef.current = Date.now() runStartedAtRef.current = Date.now()
partStartTimesRef.current = { [startPart]: runStartedAtRef.current } partStartTimesRef.current = { [startPart]: runStartedAtRef.current }
setLog([{ id: nextLogId.current++, text: 'A new run begins.', tone: 'system' }]) setLog([{ id: nextLogId.current++, text: 'A new run begins.', tone: 'system' }])
}, [difficulty, initialEncounterIndex, maxResource, partyTemplate, roguelikeMode, roguelikePool, setCombat, setSelectedTargetId, startPart, staticEncounters]) }, [difficulty, enemyCount, initialEncounterIndex, maxResource, partyTemplate, roguelikeMode, roguelikePool, setCombat, setSelectedTargetId, startPart, staticEncounters])
const castSpell = useCallback( const castSpell = useCallback(
(spell: Spell) => { (spell: Spell) => {
@@ -571,7 +628,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 +661,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 +711,8 @@ export function CombatScreen({
return { return {
...member, ...member,
health: nextHealth, health: nextHealth,
hotTicks: hotTargets.has(member.id) ? 5 : member.hotTicks, hotTicks: 0,
hotEffects: hotTargets.has(member.id) ? addHotEffect(member, spell) : member.hotEffects,
} }
}) })
const freeCastStacks = upgradeStackCount(roguelikeUpgrades, 'fifth-cast-free') const freeCastStacks = upgradeStackCount(roguelikeUpgrades, 'fifth-cast-free')
@@ -686,6 +766,7 @@ export function CombatScreen({
completedPart, completedPart,
runStartPart, runStartPart,
[partDuration(1), partDuration(2), partDuration(3)], [partDuration(1), partDuration(2), partDuration(3)],
hardMode,
) )
.then((result) => { .then((result) => {
setReward(result) setReward(result)
@@ -698,7 +779,7 @@ export function CombatScreen({
) )
}) })
}, },
[difficulty.id, dungeon.id, onProfileUpdated], [difficulty.id, dungeon.id, hardMode, onProfileUpdated],
) )
const finishRoguelikeRun = useCallback( const finishRoguelikeRun = useCallback(
@@ -796,6 +877,9 @@ export function CombatScreen({
poisonStacks: undefined, poisonStacks: undefined,
maxHealthPenaltyTicks: undefined, maxHealthPenaltyTicks: undefined,
healingReductionTicks: undefined, healingReductionTicks: undefined,
hotEffects: [],
bounceHeals: [],
damageReductionTicks: undefined,
})) }))
const nextStage = clearedBoss ? roguelikeStage + 1 : roguelikeStage const nextStage = clearedBoss ? roguelikeStage + 1 : roguelikeStage
const nextSegment = clearedBoss const nextSegment = clearedBoss
@@ -814,7 +898,7 @@ export function CombatScreen({
setCombat({ setCombat({
...current, ...current,
party: recoveredParty, party: recoveredParty,
enemyHealth: nextEncounter.maxHealth, enemyHealth: nextEncounter.maxHealth * enemyCount,
elapsedTicks: 0, elapsedTicks: 0,
cooldowns: {}, cooldowns: {},
resource: clamp(current.resource + Math.round(maxResource * 0.25), 0, maxResource), resource: clamp(current.resource + Math.round(maxResource * 0.25), 0, maxResource),
@@ -822,7 +906,7 @@ export function CombatScreen({
setUpgradeChoices([]) setUpgradeChoices([])
setStatus('playing') setStatus('playing')
addLog(`${upgrade.name} gained. ${nextEncounter.enemyName} approaches.`, 'system') addLog(`${upgrade.name} gained. ${nextEncounter.enemyName} approaches.`, 'system')
}, [addLog, difficulty, encounterIndex, encounters, maxResource, roguelikeMode, roguelikePool, roguelikeStage, setCombat]) }, [addLog, difficulty, encounterIndex, encounters, enemyCount, maxResource, roguelikeMode, roguelikePool, roguelikeStage, setCombat])
useGameAction((action, device) => { useGameAction((action, device) => {
if (action === 'pause' || (action === 'back' && device === 'pc')) { if (action === 'pause' || (action === 'back' && device === 'pc')) {
@@ -914,7 +998,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 +1017,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 +1052,12 @@ export function CombatScreen({
: Math.max(0, (member.debuffTicks ?? 0) - 1) : Math.max(0, (member.debuffTicks ?? 0) - 1)
return { return {
...member, ...member,
health: clamp(member.health - damage + absorbed + healing, 0, nextEffectiveMaxHealth), health: clamp(clamp(member.health + healing, 0, nextEffectiveMaxHealth) - damage + absorbed, 0, nextEffectiveMaxHealth),
shield: Math.max(0, member.shield - damage), shield: Math.max(0, member.shield - damage),
hotTicks: Math.max(0, member.hotTicks - 1), hotTicks: 0,
hotEffects: tickHotEffects(hotEffects),
bounceHeals: nextBounceHeals,
damageReductionTicks: Math.max(0, (member.damageReductionTicks ?? 0) - 1),
debuff: nextDebuffTicks > 0 debuff: nextDebuffTicks > 0
? (appliesDebuff && member.id === primaryTarget.id ? 'Searing Mark' : member.debuff) ? (appliesDebuff && member.id === primaryTarget.id ? 'Searing Mark' : member.debuff)
: undefined, : undefined,
@@ -956,6 +1067,17 @@ export function CombatScreen({
healingReductionTicks: nextHealingReductionTicks, healingReductionTicks: nextHealingReductionTicks,
} }
}) })
const nextParty = damagedParty.map((member) => {
const jumped = pendingJumpHeals.filter((jump) => jump.targetId === member.id)
if (jumped.length === 0) return member
return {
...member,
bounceHeals: [
...(member.bounceHeals ?? []),
...jumped.map((jump) => jump.heal),
],
}
})
const healerAfterDamage = nextParty.find((member) => member.id === 'mira') const healerAfterDamage = nextParty.find((member) => member.id === 'mira')
if ( if (
@@ -996,7 +1118,9 @@ export function CombatScreen({
} }
if (!isRoguelike && encounter.lootTables.some((entry) => entry.difficultyId === difficulty.id)) { if (!isRoguelike && encounter.lootTables.some((entry) => entry.difficultyId === difficulty.id)) {
requestLootRoll(encounter.id) for (let rollIndex = 0; rollIndex < enemyCount; rollIndex += 1) {
requestLootRoll(encounter.id, rollIndex)
}
} }
if (isRoguelike && (upgradesEveryEncounter || encounter.isBoss)) { if (isRoguelike && (upgradesEveryEncounter || encounter.isBoss)) {
@@ -1053,6 +1177,9 @@ export function CombatScreen({
poisonStacks: undefined, poisonStacks: undefined,
maxHealthPenaltyTicks: undefined, maxHealthPenaltyTicks: undefined,
healingReductionTicks: undefined, healingReductionTicks: undefined,
hotEffects: [],
bounceHeals: [],
damageReductionTicks: undefined,
})) }))
setEncounterIndex((value) => value + 1) setEncounterIndex((value) => value + 1)
setCombat({ setCombat({
@@ -1061,13 +1188,14 @@ export function CombatScreen({
resource: nextResource, resource: nextResource,
cooldowns: nextCooldowns, cooldowns: nextCooldowns,
elapsedTicks: 0, elapsedTicks: 0,
enemyHealth: nextEncounter.maxHealth, enemyHealth: nextEncounter.maxHealth * enemyCount,
}) })
addLog(`${encounter.enemyName} defeated. ${nextEncounter.enemyName} approaches.`, 'system') addLog(`${encounter.enemyName} defeated. ${nextEncounter.enemyName} approaches.`, 'system')
}, [ }, [
addLog, addLog,
addFloatingHeal, addFloatingHeal,
difficulty.damageMultiplier, difficulty.damageMultiplier,
enemyCount,
encounter, encounter,
encounterIndex, encounterIndex,
encounters, encounters,
@@ -1136,15 +1264,23 @@ export function CombatScreen({
}) })
}, [expectedLootRolls, lootRolls.length, onProfileUpdated, reward]) }, [expectedLootRolls, lootRolls.length, onProfileUpdated, reward])
const enemyPercent = (enemyHealth / encounter.maxHealth) * 100 const enemyPercent = (enemyHealth / encounterMaxHealth) * 100
const enemyHealthSegments = Array.from({ length: enemyCount }, (_, index) => {
const remaining = clamp(enemyHealth - encounter.maxHealth * index, 0, encounter.maxHealth)
return {
index,
health: remaining,
percent: (remaining / encounter.maxHealth) * 100,
}
}).reverse()
const dualScreenState = useMemo<DualScreenCombatState>(() => ({ const dualScreenState = useMemo<DualScreenCombatState>(() => ({
difficultyName: difficulty.name, difficultyName: difficulty.name,
dungeonName: dungeon.name, dungeonName: hardMode ? `${dungeon.name} Hard` : dungeon.name,
contentName, contentName,
encounterName: encounter.enemyName, encounterName: encounter.enemyName,
encounterDescription: encounter.description, encounterDescription: encounter.description,
encounterHealth: enemyHealth, encounterHealth: enemyHealth,
encounterMaxHealth: encounter.maxHealth, encounterMaxHealth,
encounterIsBoss: encounter.isBoss, encounterIsBoss: encounter.isBoss,
encounterIndex, encounterIndex,
encounterCount: encounters.length, encounterCount: encounters.length,
@@ -1186,7 +1322,8 @@ export function CombatScreen({
encounter.description, encounter.description,
encounter.enemyName, encounter.enemyName,
encounter.isBoss, encounter.isBoss,
encounter.maxHealth, encounterMaxHealth,
hardMode,
enemyHealth, enemyHealth,
encounterIndex, encounterIndex,
encounters.length, encounters.length,
@@ -1215,7 +1352,7 @@ export function CombatScreen({
> >
{!dualScreenEnabled && <header className="topbar"> {!dualScreenEnabled && <header className="topbar">
<div> <div>
<p className="eyebrow">{difficulty.name} - Item Level {difficulty.droppedItemLevel}</p> <p className="eyebrow">{difficulty.name}{hardMode ? ' Hard' : ''} - Item Level {difficulty.droppedItemLevel}</p>
<h1>{dungeon.name}</h1> <h1>{dungeon.name}</h1>
</div> </div>
<div className="combat-header-actions"> <div className="combat-header-actions">
@@ -1238,10 +1375,21 @@ export function CombatScreen({
</div> </div>
<div className="enemy-info"> <div className="enemy-info">
<div className="bar-label"> <div className="bar-label">
<strong>{encounter.enemyName}</strong> <strong>{hardMode ? `${encounter.enemyName} x2` : encounter.enemyName}</strong>
<span>{Math.ceil(enemyHealth)} / {encounter.maxHealth}</span> <span>{Math.ceil(enemyHealth)} / {encounterMaxHealth}</span>
</div> </div>
<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 +1436,14 @@ export function CombatScreen({
.map((entry) => <span className="floating-heal" key={entry.id}>+{entry.value}</span>)} .map((entry) => <span className="floating-heal" key={entry.id}>+{entry.value}</span>)}
</div> </div>
<div className="member-effects"> <div className="member-effects">
{member.hotTicks > 0 && <span className="buff">Renew {formatEffectTime(member.hotTicks)}</span>} {memberHotEffects(member).map((effect) => (
<span className="buff" key={effect.id}>{effect.label} {formatEffectTime(effect.ticks)}</span>
))}
{member.shield > 0 && <span className="buff">Shield {Math.ceil(member.shield)}</span>}
{(member.damageReductionTicks ?? 0) > 0 && <span className="buff">Barkskin {formatEffectTime(member.damageReductionTicks ?? 0)}</span>}
{(member.bounceHeals ?? []).map((effect) => (
<span className="buff" key={effect.id}>{effect.label} {effect.charges}</span>
))}
{member.debuff && member.debuffTicks && <span className="debuff">{member.debuff} {formatEffectTime(member.debuffTicks)}</span>} {member.debuff && member.debuffTicks && <span className="debuff">{member.debuff} {formatEffectTime(member.debuffTicks)}</span>}
{member.poisonStacks && member.poisonStacks > 0 && <span className="debuff">Poison {member.poisonStacks}</span>} {member.poisonStacks && member.poisonStacks > 0 && <span className="debuff">Poison {member.poisonStacks}</span>}
{member.maxHealthPenaltyTicks && member.maxHealthPenaltyTicks > 0 && <span className="debuff">Max HP -25% {formatEffectTime(member.maxHealthPenaltyTicks)}</span>} {member.maxHealthPenaltyTicks && member.maxHealthPenaltyTicks > 0 && <span className="debuff">Max HP -25% {formatEffectTime(member.maxHealthPenaltyTicks)}</span>}
@@ -1518,33 +1673,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>
+1 -1
View File
@@ -40,7 +40,7 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
function chooseClass(nextClass: GameClass) { function chooseClass(nextClass: GameClass) {
const starterAbilities = nextClass.spells const starterAbilities = nextClass.spells
.filter((ability) => ability.unlockLevel <= profile.character.level) .filter((ability) => ability.unlockLevel <= profile.character.level)
.slice(0, 5) .slice(0, 6)
.map((ability) => ability.id) .map((ability) => ability.id)
setClassId(nextClass.id) setClassId(nextClass.id)
setSlots([...starterAbilities, ...Array(6 - starterAbilities.length).fill(null)]) setSlots([...starterAbilities, ...Array(6 - starterAbilities.length).fill(null)])
+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
+5 -5
View File
@@ -44,7 +44,7 @@ type PvpEncounter = DungeonEncounter & {
sourceEncounterId?: number sourceEncounterId?: number
} }
type SlotKey = '1' | '2' | '3' | '4' | '5' type SlotKey = '1' | '2' | '3' | '4' | '5' | '6'
type AbilityLabelMode = 'ability' | 'slot' type AbilityLabelMode = 'ability' | 'slot'
type SelfBuffId = type SelfBuffId =
@@ -164,7 +164,7 @@ function slotLabel(slot: SlotKey, spells: Spell[], labelMode: AbilityLabelMode)
} }
function buildSelfBuffChoices(spells: Spell[], labelMode: AbilityLabelMode): Array<Choice<SelfBuffId>> { function buildSelfBuffChoices(spells: Spell[], labelMode: AbilityLabelMode): Array<Choice<SelfBuffId>> {
const slotChoices = (['1', '2', '3', '4', '5'] as SlotKey[]).flatMap((slot) => { const slotChoices = (['1', '2', '3', '4', '5', '6'] as SlotKey[]).flatMap((slot) => {
const label = slotLabel(slot, spells, labelMode) const label = slotLabel(slot, spells, labelMode)
return [ return [
{ {
@@ -193,7 +193,7 @@ function buildSelfBuffChoices(spells: Spell[], labelMode: AbilityLabelMode): Arr
} }
function buildOpponentDebuffChoices(spells: Spell[], labelMode: AbilityLabelMode): Array<Choice<OpponentDebuffId>> { function buildOpponentDebuffChoices(spells: Spell[], labelMode: AbilityLabelMode): Array<Choice<OpponentDebuffId>> {
const slotChoices = (['1', '2', '3', '4', '5'] as SlotKey[]).flatMap((slot) => { const slotChoices = (['1', '2', '3', '4', '5', '6'] as SlotKey[]).flatMap((slot) => {
const label = slotLabel(slot, spells, labelMode) const label = slotLabel(slot, spells, labelMode)
return [ return [
{ {
@@ -334,7 +334,7 @@ function scoreSelfBuff(buff: Choice<SelfBuffId>, spells: Spell[]) {
if (buff.id === 'fifth-cast-free') return 8 if (buff.id === 'fifth-cast-free') return 8
if (buff.id === 'group-heal-boost') return 8 if (buff.id === 'group-heal-boost') return 8
if (buff.id === 'shield-boost') return 6 if (buff.id === 'shield-boost') return 6
const slot = buff.id.match(/slot([1-5])/i)?.[1] as SlotKey | undefined const slot = buff.id.match(/slot([1-6])/i)?.[1] as SlotKey | undefined
const spell = spells.find((candidate) => candidate.key === slot) const spell = spells.find((candidate) => candidate.key === slot)
if (!spell) return 5 if (!spell) return 5
if (buff.id.endsWith('extra-target')) { if (buff.id.endsWith('extra-target')) {
@@ -408,7 +408,7 @@ export function PvPRoguelikeScreen({
const gameClass = profile.classes.find((candidate) => candidate.id === profile.character.classId)! const gameClass = profile.classes.find((candidate) => candidate.id === profile.character.classId)!
const starterSpells = useMemo(() => gameClass.spells const starterSpells = useMemo(() => gameClass.spells
.filter((spell) => spell.unlockLevel === 1) .filter((spell) => spell.unlockLevel === 1)
.slice(0, 5) .slice(0, 6)
.map((spell, index) => toCombatSpell(spell, String(index + 1))), [gameClass.spells]) .map((spell, index) => toCombatSpell(spell, String(index + 1))), [gameClass.spells])
const [abilityLabelMode] = useState<AbilityLabelMode>('ability') const [abilityLabelMode] = useState<AbilityLabelMode>('ability')
const selfBuffChoicesCatalog = useMemo( const selfBuffChoicesCatalog = useMemo(
+10 -1
View File
@@ -118,6 +118,13 @@ function loadRecentSnapshot() {
} }
} }
function memberHotEffects(member: PartyMember) {
if (member.hotEffects?.length) return member.hotEffects
return member.hotTicks > 0
? [{ id: 'legacy-renew', label: 'Renew', ticks: member.hotTicks }]
: []
}
export function DualScreenProvider({ children }: { children: ReactNode }) { export function DualScreenProvider({ children }: { children: ReactNode }) {
const [enabled, setEnabledState] = useState( const [enabled, setEnabledState] = useState(
() => localStorage.getItem(STORAGE_KEY) === 'true', () => localStorage.getItem(STORAGE_KEY) === 'true',
@@ -599,7 +606,9 @@ export function DualScreenTopCombat({
</div> </div>
)} )}
<div className="member-effects"> <div className="member-effects">
{member.hotTicks > 0 && <span className="buff">Renew</span>} {memberHotEffects(member).map((effect) => (
<span className="buff" key={effect.id}>{effect.label}</span>
))}
{member.debuff && <span className="debuff">{member.debuff}</span>} {member.debuff && <span className="debuff">{member.debuff}</span>}
</div> </div>
</button> </button>
+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 = {
+105 -47
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,
@@ -102,6 +103,7 @@ const offlineSaveKey = 'chronicle.offlineSave.v1'
const onlineCacheKey = 'chronicle.onlineCache.v1' const onlineCacheKey = 'chronicle.onlineCache.v1'
const authTokenKey = 'chronicle.authToken.v1' const authTokenKey = 'chronicle.authToken.v1'
const offlineAccount = { id: -1, username: 'Offline' } const offlineAccount = { id: -1, username: 'Offline' }
const ABILITY_SLOT_COUNT = 6
function clone<T>(value: T): T { function clone<T>(value: T): T {
return structuredClone(value) return structuredClone(value)
@@ -146,7 +148,7 @@ function upgradeV1Save(v1: { profile: CharacterProfile; lootRolls: Record<string
level: cid === p.character.classId ? p.character.level : 1, level: cid === p.character.classId ? p.character.level : 1,
experience: cid === p.character.classId ? p.character.experience : 0, experience: cid === p.character.classId ? p.character.experience : 0,
talentPoints: cid === p.character.classId ? p.character.talentPoints : 1, talentPoints: cid === p.character.classId ? p.character.talentPoints : 1,
abilitySlots: cid === p.character.classId ? [...p.abilitySlots] : [], abilitySlots: cid === p.character.classId ? normalizeAbilitySlots(p.abilitySlots) : [],
talentRanks, talentRanks,
inventory: cid === p.character.classId ? clone(p.inventory) : [], inventory: cid === p.character.classId ? clone(p.inventory) : [],
} }
@@ -163,11 +165,32 @@ function upgradeV1Save(v1: { profile: CharacterProfile; lootRolls: Record<string
} }
function upgradeV2Save(v2: Omit<OfflineSave, 'version' | 'completedRaidPhases'> & { version: 2 }): OfflineSave { function upgradeV2Save(v2: Omit<OfflineSave, 'version' | 'completedRaidPhases'> & { version: 2 }): OfflineSave {
return { return normalizeSaveAbilitySlots({
...v2, ...v2,
version: 3, version: 3,
completedRaidPhases: 0, completedRaidPhases: 0,
})
}
function normalizeAbilitySlots(abilitySlots: unknown): Array<number | null> {
const slots = Array.isArray(abilitySlots)
? abilitySlots
.slice(0, ABILITY_SLOT_COUNT)
.map((value) => {
if (value === null || value === undefined) return null
const id = Number(value)
return Number.isInteger(id) ? id : null
})
: []
while (slots.length < ABILITY_SLOT_COUNT) slots.push(null)
return slots
}
function normalizeSaveAbilitySlots(save: OfflineSave): OfflineSave {
for (const character of Object.values(save.characters)) {
character.abilitySlots = normalizeAbilitySlots(character.abilitySlots)
} }
return save
} }
function normalizeOfflineSave(raw: unknown): OfflineSave | null { function normalizeOfflineSave(raw: unknown): OfflineSave | null {
@@ -177,12 +200,12 @@ function normalizeOfflineSave(raw: unknown): OfflineSave | null {
profile?: CharacterProfile profile?: CharacterProfile
lootRolls?: Record<string, LootRoll> lootRolls?: Record<string, LootRoll>
} }
if (candidate.version === 3) return candidate as OfflineSave if (candidate.version === 3) return normalizeSaveAbilitySlots(candidate as OfflineSave)
if (candidate.version === 2) { if (candidate.version === 2) {
return upgradeV2Save(candidate as Omit<OfflineSave, 'version' | 'completedRaidPhases'> & { version: 2 }) return upgradeV2Save(candidate as Omit<OfflineSave, 'version' | 'completedRaidPhases'> & { version: 2 })
} }
if (candidate.version === 1 && candidate.profile) { if (candidate.version === 1 && candidate.profile) {
return upgradeV1Save(candidate as { profile: CharacterProfile; lootRolls: Record<string, LootRoll> }) return normalizeSaveAbilitySlots(upgradeV1Save(candidate as { profile: CharacterProfile; lootRolls: Record<string, LootRoll> }))
} }
return null return null
} }
@@ -359,11 +382,33 @@ function experienceForLevel(level: number) {
return (level - 1) * (level - 1) * 100 return (level - 1) * (level - 1) * 100
} }
function catchUpExperienceReward(
baseReward: number,
currentExperience: number,
currentLevel: number,
targetLevel: number,
) {
if (targetLevel <= currentLevel) return baseReward
const targetExperience = experienceForLevel(targetLevel)
const gap = Math.max(0, targetExperience - currentExperience)
if (gap <= 0) return baseReward
const doubledBase = Math.min(baseReward, Math.ceil(gap / 2))
return doubledBase * 2 + (baseReward - doubledBase)
}
function highestOtherClassLevel(save: OfflineSave) {
const activeClass = save.activeClassId
return Object.entries(save.characters)
.filter(([classId]) => Number(classId) !== activeClass)
.reduce((highest, [, character]) => Math.max(highest, character.level), 0)
}
function scaledPvpBossExperience( function scaledPvpBossExperience(
startingExperience: number, startingExperience: number,
startingLevel: number, startingLevel: number,
bossesCleared: number, bossesCleared: number,
maxLevel: number, maxLevel: number,
targetLevel = startingLevel,
) { ) {
let experience = startingExperience let experience = startingExperience
let level = startingLevel let level = startingLevel
@@ -374,7 +419,8 @@ function scaledPvpBossExperience(
? maxExperience ? maxExperience
: experienceForLevel(level + 1) : experienceForLevel(level + 1)
const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor) const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor)
experience = Math.min(maxExperience, experience + Math.round(levelBand * 0.25)) const rewardRate = targetLevel > level ? 0.5 : 0.25
experience = Math.min(maxExperience, experience + Math.round(levelBand * rewardRate))
while (level < maxLevel && experienceForLevel(level + 1) <= experience) { while (level < maxLevel && experienceForLevel(level + 1) <= experience) {
level += 1 level += 1
} }
@@ -389,6 +435,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
@@ -423,7 +470,7 @@ function mergeProfileIntoSave(profile: CharacterProfile, existingSave?: OfflineS
level: profile.character.level, level: profile.character.level,
experience: profile.character.experience, experience: profile.character.experience,
talentPoints: profile.character.talentPoints, talentPoints: profile.character.talentPoints,
abilitySlots: [...profile.abilitySlots], abilitySlots: normalizeAbilitySlots(profile.abilitySlots),
talentRanks, talentRanks,
inventory: clone(profile.inventory), inventory: clone(profile.inventory),
} }
@@ -716,7 +763,7 @@ const serverRepository: GameRepository = {
), ),
saveProfile: (classId, abilitySlots) => saveProfile: (classId, abilitySlots) =>
cachedOnlineLocalRepository.saveProfile(classId, abilitySlots), cachedOnlineLocalRepository.saveProfile(classId, abilitySlots),
completeDungeon: (dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds) => completeDungeon: (dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds, hardMode) =>
cachedOnlineLocalRepository.completeDungeon( cachedOnlineLocalRepository.completeDungeon(
dungeonId, dungeonId,
difficultyId, difficultyId,
@@ -725,6 +772,7 @@ const serverRepository: GameRepository = {
completedPart, completedPart,
startPart, startPart,
partDurationSeconds, partDurationSeconds,
hardMode,
), ),
completeRoguelike: (dungeonId, difficultyId, encountersCleared, resourceSpent, durationSeconds, options) => completeRoguelike: (dungeonId, difficultyId, encountersCleared, resourceSpent, durationSeconds, options) =>
cachedOnlineLocalRepository.completeRoguelike( cachedOnlineLocalRepository.completeRoguelike(
@@ -761,9 +809,9 @@ function emptyCharacterData(classId: number): CharacterData {
const inventory: Item[] = [] const inventory: Item[] = []
const startingAbilitySlots: Array<number | null> = gc.spells const startingAbilitySlots: Array<number | null> = gc.spells
.filter((s) => s.unlockLevel === 1) .filter((s) => s.unlockLevel === 1)
.slice(0, 5) .slice(0, ABILITY_SLOT_COUNT)
.map((s) => s.id) .map((s) => s.id)
while (startingAbilitySlots.length < 6) startingAbilitySlots.push(null) while (startingAbilitySlots.length < ABILITY_SLOT_COUNT) startingAbilitySlots.push(null)
return { return {
level: 1, level: 1,
experience: 0, experience: 0,
@@ -803,35 +851,34 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
}, },
async saveProfile(classId, abilitySlots) { async saveProfile(classId, abilitySlots) {
const save = requireStoredSave(store) const save = requireStoredSave(store)
const static_ = clone(starterProfile) as CharacterProfile const static_ = clone(starterProfile) as CharacterProfile
const gameClass = static_.classes.find((candidate) => candidate.id === classId) const gameClass = static_.classes.find((candidate) => candidate.id === classId)
if (!gameClass) throw new Error('Selected class does not exist.') if (!gameClass) throw new Error('Selected class does not exist.')
const slots = abilitySlots.slice(0, 6) const slots = normalizeAbilitySlots(abilitySlots)
while (slots.length < 6) slots.push(null) const selectedIds = slots.filter((id): id is number => id !== null)
const selectedIds = slots.filter((id): id is number => id !== null) if (new Set(selectedIds).size !== selectedIds.length) {
if (new Set(selectedIds).size !== selectedIds.length) { throw new Error('The same ability cannot be equipped twice.')
throw new Error('The same ability cannot be equipped twice.') }
} const activeChar = save.characters[save.activeClassId]
const activeChar = save.characters[save.activeClassId] const validIds = new Set(
const validIds = new Set( gameClass.spells
gameClass.spells .filter((spell) => spell.unlockLevel <= activeChar.level)
.filter((spell) => spell.unlockLevel <= activeChar.level) .map((spell) => spell.id),
.map((spell) => spell.id), )
) if (selectedIds.some((id) => !validIds.has(id))) {
if (selectedIds.some((id) => !validIds.has(id))) { throw new Error('One or more abilities are locked or belong to another class.')
throw new Error('One or more abilities are locked or belong to another class.') }
}
if (!save.characters[classId]) { if (!save.characters[classId]) {
save.characters[classId] = emptyCharacterData(classId) save.characters[classId] = emptyCharacterData(classId)
} }
save.characters[classId].abilitySlots = slots save.characters[classId].abilitySlots = slots
save.activeClassId = classId save.activeClassId = classId
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 +904,15 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
const previousLevel = cd.level const previousLevel = cd.level
const previousExperience = cd.experience const previousExperience = cd.experience
const partCount = completedPart ?? 1 const partCount = completedPart ?? 1
const experienceReward = Math.round( const rewardMultiplier = hardMode ? 2 : 1
dungeon.experienceReward * difficulty.experienceMultiplier * partCount, const baseExperienceReward = Math.round(
dungeon.experienceReward * difficulty.experienceMultiplier * partCount * rewardMultiplier,
)
const experienceReward = catchUpExperienceReward(
baseExperienceReward,
previousExperience,
previousLevel,
highestOtherClassLevel(save),
) )
const maxExperience = experienceForLevel(profile.maxLevel) const maxExperience = experienceForLevel(profile.maxLevel)
const newExperience = Math.min(previousExperience + experienceReward, maxExperience) const newExperience = Math.min(previousExperience + experienceReward, maxExperience)
@@ -906,19 +960,20 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
const selected = rewardPool[Math.floor(Math.random() * rewardPool.length)] const selected = rewardPool[Math.floor(Math.random() * rewardPool.length)]
const existing = profile.inventory.find((item) => item.id === selected.id) const existing = profile.inventory.find((item) => item.id === selected.id)
const duplicate = Boolean(existing) const duplicate = Boolean(existing)
let quantityAfter = 1 const rewardQuantity = rewardMultiplier
let quantityAfter = rewardQuantity
if (existing) { if (existing) {
existing.quantity += 1 existing.quantity += rewardQuantity
quantityAfter = existing.quantity quantityAfter = existing.quantity
} else { } else {
profile.inventory.push({ profile.inventory.push({
...selected, ...selected,
quantity: 1, quantity: rewardQuantity,
equipped: false, equipped: false,
}) })
} }
cd.inventory = profile.inventory cd.inventory = profile.inventory
bonusItem = { ...selected, quantity: 1, duplicate, quantityAfter } bonusItem = { ...selected, quantity: rewardQuantity, duplicate, quantityAfter }
} }
} }
@@ -971,13 +1026,19 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
const maxExperience = experienceForLevel(profile.maxLevel) const maxExperience = experienceForLevel(profile.maxLevel)
const bossesCleared = Math.max(0, Math.floor(options?.bossesCleared ?? encountersCleared / 3)) const bossesCleared = Math.max(0, Math.floor(options?.bossesCleared ?? encountersCleared / 3))
const scaledReward = options?.experienceMode === 'pvp-boss-quarter-level' const scaledReward = options?.experienceMode === 'pvp-boss-quarter-level'
? scaledPvpBossExperience(previousExperience, previousLevel, bossesCleared, profile.maxLevel) ? scaledPvpBossExperience(previousExperience, previousLevel, bossesCleared, profile.maxLevel, highestOtherClassLevel(save))
: null : null
const baseRoguelikeReward = Math.round(dungeon.experienceReward * difficulty.experienceMultiplier * (encountersCleared / 3))
const newExperience = scaledReward const newExperience = scaledReward
? scaledReward.experience ? scaledReward.experience
: Math.min( : Math.min(
previousExperience previousExperience
+ Math.round(dungeon.experienceReward * difficulty.experienceMultiplier * (encountersCleared / 3)), + catchUpExperienceReward(
baseRoguelikeReward,
previousExperience,
previousLevel,
highestOtherClassLevel(save),
),
maxExperience, maxExperience,
) )
let newLevel = scaledReward?.level ?? previousLevel let newLevel = scaledReward?.level ?? previousLevel
@@ -1161,11 +1222,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
const profile = buildProfile(save) const profile = buildProfile(save)
const recipe = profile.craftingRecipes.find((candidate) => candidate.id === recipeId) const recipe = profile.craftingRecipes.find((candidate) => candidate.id === recipeId)
if (!recipe) throw new Error('That crafting recipe does not exist.') if (!recipe) throw new Error('That crafting recipe does not exist.')
const requiresUpgrade = profile.craftingRecipes.some((candidate) => const requiresUpgrade = !DIRECT_CRAFT_ITEM_LEVELS.has(recipe.item.itemLevel)
candidate.sourceEncounterId === recipe.sourceEncounterId
&& candidate.item.slot === recipe.item.slot
&& candidate.item.itemLevel < recipe.item.itemLevel,
)
if (requiresUpgrade) throw new Error('Upgrade the previous item tier instead.') if (requiresUpgrade) throw new Error('Upgrade the previous item tier instead.')
const missing = recipe.components.find((component) => component.owned < component.quantity) const missing = recipe.components.find((component) => component.owned < component.quantity)
if (missing) { if (missing) {
@@ -1361,7 +1418,7 @@ const cachedOnlineRepository: GameRepository = {
}, },
loadProfile: () => cachedOnlineLocalRepository.loadProfile(), loadProfile: () => cachedOnlineLocalRepository.loadProfile(),
saveProfile: (classId, abilitySlots) => cachedOnlineLocalRepository.saveProfile(classId, abilitySlots), saveProfile: (classId, abilitySlots) => cachedOnlineLocalRepository.saveProfile(classId, abilitySlots),
completeDungeon: (dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds) => completeDungeon: (dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds, hardMode) =>
cachedOnlineLocalRepository.completeDungeon( cachedOnlineLocalRepository.completeDungeon(
dungeonId, dungeonId,
difficultyId, difficultyId,
@@ -1370,6 +1427,7 @@ const cachedOnlineRepository: GameRepository = {
completedPart, completedPart,
startPart, startPart,
partDurationSeconds, partDurationSeconds,
hardMode,
), ),
completeRoguelike: (dungeonId, difficultyId, encountersCleared, resourceSpent, durationSeconds, options) => completeRoguelike: (dungeonId, difficultyId, encountersCleared, resourceSpent, durationSeconds, options) =>
cachedOnlineLocalRepository.completeRoguelike( cachedOnlineLocalRepository.completeRoguelike(
File diff suppressed because it is too large Load Diff
+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,
) )
} }