Initial I Want to Heal app

This commit is contained in:
Warren H
2026-06-17 20:04:36 -04:00
parent 3880db1b58
commit 3c90998a61
109 changed files with 32775 additions and 0 deletions
+946
View File
@@ -0,0 +1,946 @@
import starterProfile from './offline-starter-profile.json'
import type {
AuthSession,
CharacterProfile,
DungeonReward,
LootRoll,
Item,
EquipmentSlot,
} from './profile'
export type GameMode = 'online' | 'offline'
export interface GameRepository {
loadSession(): Promise<AuthSession>
register(username: string, password: string, characterName: string): Promise<AuthSession>
login(username: string, password: string): Promise<AuthSession>
logout(): Promise<void>
loadProfile(): Promise<CharacterProfile>
saveProfile(classId: number, abilitySlots: Array<number | null>): Promise<CharacterProfile>
completeDungeon(
dungeonId: number,
difficultyId: number,
resourceSpent: number,
durationSeconds: number,
completedPart?: number,
startPart?: number,
partDurationSeconds?: [number, number, number],
): Promise<DungeonReward>
completeRoguelike(
dungeonId: number,
difficultyId: number,
encountersCleared: number,
resourceSpent: number,
durationSeconds: number,
options?: {
bossesCleared?: number
experienceMode?: 'default' | 'pvp-boss-quarter-level'
},
): Promise<DungeonReward>
allocateTalent(talentId: number): Promise<CharacterProfile>
resetTalents(): Promise<CharacterProfile>
equipItem(itemId: number): Promise<CharacterProfile>
discardExtraItem(itemId: number): Promise<CharacterProfile>
breakdownItem(itemId: number): Promise<CharacterProfile>
craftItem(recipeId: number): Promise<CharacterProfile>
rollEncounterLoot(
encounterId: number,
difficultyId: number,
runToken: string,
): Promise<LootRoll>
}
type CharacterData = {
level: number
experience: number
talentPoints: number
abilitySlots: Array<number | null>
talentRanks: Record<string, number>
inventory: Item[]
}
type OfflineSave = {
version: 3
characterName: string
activeClassId: number
completedDungeonParts: number
completedRaidPhases: number
characters: Record<number, CharacterData>
lootRolls: Record<string, LootRoll>
}
const modeKey = 'chronicle.gameMode'
const offlineSaveKey = 'chronicle.offlineSave.v1'
const offlineAccount = { id: -1, username: 'Offline' }
function clone<T>(value: T): T {
return structuredClone(value)
}
function readMode(): GameMode {
return localStorage.getItem(modeKey) === 'offline' ? 'offline' : 'online'
}
function writeMode(mode: GameMode) {
localStorage.setItem(modeKey, mode)
}
function readOfflineSave(): OfflineSave | null {
const serialized = localStorage.getItem(offlineSaveKey)
if (!serialized) return null
try {
const raw = JSON.parse(serialized)
if (raw.version === 3) return raw as OfflineSave
if (raw.version === 2) return migrateV2ToV3(raw)
if (raw.version === 1) return migrateV1ToV2(raw)
return null
} catch {
return null
}
}
function migrateV1ToV2(v1: { profile: CharacterProfile; lootRolls: Record<string, LootRoll> }): OfflineSave {
const p = v1.profile
const classes = [1, 2, 3]
const characters: Record<number, CharacterData> = {}
for (const cid of classes) {
const gameClass = p.classes.find((c) => c.id === cid)!
const talentRanks: Record<string, number> = {}
for (const t of gameClass.talents) {
talentRanks[String(t.id)] = t.rank
}
characters[cid] = {
level: cid === p.character.classId ? p.character.level : 1,
experience: cid === p.character.classId ? p.character.experience : 0,
talentPoints: cid === p.character.classId ? p.character.talentPoints : 1,
abilitySlots: cid === p.character.classId ? [...p.abilitySlots] : [],
talentRanks,
inventory: cid === p.character.classId ? clone(p.inventory) : [],
}
}
const v2: OfflineSave = {
version: 3,
characterName: p.character.name,
activeClassId: p.character.classId,
completedDungeonParts: p.completedDungeonParts,
completedRaidPhases: p.completedRaidPhases ?? 0,
characters,
lootRolls: v1.lootRolls ?? {},
}
localStorage.setItem(offlineSaveKey, JSON.stringify(v2))
return v2
}
function migrateV2ToV3(v2: Omit<OfflineSave, 'version' | 'completedRaidPhases'> & { version: 2 }): OfflineSave {
const v3: OfflineSave = {
...v2,
version: 3,
completedRaidPhases: 0,
}
localStorage.setItem(offlineSaveKey, JSON.stringify(v3))
return v3
}
function writeOfflineSave(save: OfflineSave) {
localStorage.setItem(offlineSaveKey, JSON.stringify(save))
}
function requireOfflineSave(): OfflineSave {
const save = readOfflineSave()
if (!save) throw new Error('No offline character exists yet.')
return save
}
function buildProfile(save: OfflineSave): CharacterProfile {
const static_ = clone(starterProfile) as CharacterProfile
const cd = save.characters[save.activeClassId]
const gameClass = static_.classes.find((c) => c.id === save.activeClassId)!
static_.character.name = save.characterName
static_.character.level = cd.level
static_.character.experience = cd.experience
static_.character.talentPoints = cd.talentPoints
static_.character.classId = gameClass.id
static_.character.classSlug = gameClass.slug
static_.character.className = gameClass.name
static_.character.resourceName = gameClass.resourceName
static_.character.maxResource = gameClass.maxResource
static_.character.themeColor = gameClass.themeColor
static_.character.classDescription = gameClass.description
static_.character.currentLevelExperience = experienceForLevel(cd.level)
static_.character.nextLevelExperience = cd.level >= static_.maxLevel
? experienceForLevel(static_.maxLevel)
: experienceForLevel(cd.level + 1)
static_.abilitySlots = cd.abilitySlots
for (const c of static_.classes) {
for (const t of c.talents) {
t.rank = cd.talentRanks[String(t.id)] ?? 0
}
}
static_.allocatedTalentPoints = gameClass.talents.reduce((s, t) => s + t.rank, 0)
static_.inventory = cd.inventory
updateGearStats(static_)
updateSetBonuses(static_)
updateCraftingRecipes(static_)
static_.completedDungeonParts = save.completedDungeonParts
static_.completedRaidPhases = save.completedRaidPhases
return static_
}
function updateGearStats(profile: CharacterProfile) {
const equipped = profile.inventory.filter((item) => item.equipped)
profile.gearStats = {
averageItemLevel: profile.equipmentSlots.length === 0
? 0
: equipped.reduce((total, item) => total + item.itemLevel, 0)
/ profile.equipmentSlots.length,
healingPower: equipped.reduce((total, item) => total + item.healingPower, 0),
maxResourceBonus: equipped.reduce(
(total, item) => total + item.maxResourceBonus,
0,
),
}
}
function updateSetBonuses(profile: CharacterProfile) {
const equippedSetCounts = new Map<number, number>()
for (const item of profile.inventory) {
if (!item.equipped || !item.setId) continue
equippedSetCounts.set(item.setId, (equippedSetCounts.get(item.setId) ?? 0) + 1)
}
profile.setBonuses = (profile.setBonuses ?? []).map((bonus) => {
const equippedPieces = equippedSetCounts.get(bonus.setId) ?? 0
return {
...bonus,
equippedPieces,
active: equippedPieces >= bonus.requiredPieces,
}
})
}
function updateCraftingRecipes(profile: CharacterProfile) {
const owned = new Map(profile.inventory.map((item) => [item.id, item.quantity]))
profile.craftingRecipes = (profile.craftingRecipes ?? []).map((recipe) => {
const components = recipe.components.map((component) => ({
...component,
owned: owned.get(component.item.id) ?? 0,
}))
return {
...recipe,
components,
canCraft: components.every((component) => component.owned >= component.quantity),
}
})
}
function addInventoryItem(inventory: Item[], item: Omit<Item, 'quantity' | 'equipped'>, quantity: number) {
const existing = inventory.find((candidate) => candidate.id === item.id)
if (existing) {
existing.quantity += quantity
return { duplicate: true, quantityAfter: existing.quantity }
}
inventory.push({
...item,
quantity,
equipped: false,
})
return { duplicate: false, quantityAfter: quantity }
}
function experienceForLevel(level: number) {
return (level - 1) * (level - 1) * 100
}
function scaledPvpBossExperience(
startingExperience: number,
startingLevel: number,
bossesCleared: number,
maxLevel: number,
) {
let experience = startingExperience
let level = startingLevel
const maxExperience = experienceForLevel(maxLevel)
for (let bossIndex = 0; bossIndex < bossesCleared && experience < maxExperience; bossIndex += 1) {
const currentLevelFloor = experienceForLevel(level)
const nextLevelExperience = level >= maxLevel
? maxExperience
: experienceForLevel(level + 1)
const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor)
experience = Math.min(maxExperience, experience + Math.round(levelBand * 0.25))
while (level < maxLevel && experienceForLevel(level + 1) <= experience) {
level += 1
}
}
return { experience, level }
}
type ComponentTemplate = { id: number; slug: string; name: string; itemLevel: number; glyph: string; description: string }
const COMPONENT_ITEMS: Record<number, ComponentTemplate> = {
1: { id: 600, slug: 'minor-component', name: 'Minor Component', itemLevel: 1, glyph: '◆', description: 'A basic crafting component.' },
5: { id: 601, slug: 'basic-component', name: 'Basic Component', itemLevel: 5, glyph: '◇', description: 'A standard crafting component.' },
10: { id: 602, slug: 'refined-component', name: 'Refined Component', itemLevel: 10, glyph: '◈', description: 'A refined crafting component.' },
15: { id: 603, slug: 'advanced-component', name: 'Advanced Component', itemLevel: 15, glyph: '◉', description: 'An advanced crafting component.' },
20: { id: 604, slug: 'superior-component', name: 'Superior Component', itemLevel: 20, glyph: '◎', description: 'A superior crafting component.' },
25: { id: 605, slug: 'primal-component', name: 'Primal Component', itemLevel: 25, glyph: '✦', description: 'A primal crafting component.' },
}
type WindowWithApiBase = Window & {
CAPACITOR_API_BASE_URL?: string
}
function componentForItemLevel(itemLevel: number): ComponentTemplate | undefined {
const levels = Object.keys(COMPONENT_ITEMS).map(Number).sort((a, b) => a - b)
let best = levels[0]
for (const level of levels) {
if (level <= itemLevel) best = level
}
return COMPONENT_ITEMS[best]
}
function componentDropQuantity(itemLevel: number) {
const tier = Math.max(0, Math.floor((itemLevel - 5) / 5))
const secondChance = Math.min(0.85, 0.35 + tier * 0.12)
const thirdChance = Math.min(0.6, 0.1 + tier * 0.1)
return 1 + (Math.random() < secondChance ? 1 : 0) + (Math.random() < thirdChance ? 1 : 0)
}
function rollWeightedLootEntry<T extends { dropWeight: number }>(entries: T[]): T {
const totalWeight = entries.reduce((total, entry) => total + entry.dropWeight, 0)
let weightedRoll = Math.random() * totalWeight
for (const entry of entries) {
weightedRoll -= entry.dropWeight
if (weightedRoll < 0) return entry
}
return entries[entries.length - 1]
}
function getApiBaseUrl(): string {
const browserWindow = typeof window === 'undefined'
? undefined
: window as WindowWithApiBase
if (browserWindow?.CAPACITOR_API_BASE_URL) {
return browserWindow.CAPACITOR_API_BASE_URL
}
if (typeof import.meta !== 'undefined' && import.meta.env?.VITE_API_BASE_URL) {
return import.meta.env.VITE_API_BASE_URL
}
return ''
}
async function requestJson<T>(path: string, init?: RequestInit): Promise<T> {
const baseUrl = getApiBaseUrl()
const url = baseUrl ? `${baseUrl}${path}` : path
const response = await fetch(url, init)
const body = await response.json()
if (!response.ok) throw new Error(body.error ?? 'Unable to reach the game server.')
return body
}
const serverRepository: GameRepository = {
loadSession: () => requestJson('/api/auth/session'),
register: (username, password, characterName) =>
requestJson('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password, characterName }),
}),
login: (username, password) =>
requestJson('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
}),
logout: async () => {
await requestJson('/api/auth/logout', { method: 'POST' })
},
loadProfile: () => requestJson('/api/profile'),
saveProfile: (classId, abilitySlots) =>
requestJson('/api/profile', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ classId, abilitySlots }),
}),
completeDungeon: (dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds) =>
requestJson(`/api/dungeons/${dungeonId}/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds }),
}),
completeRoguelike: (dungeonId, difficultyId, encountersCleared, resourceSpent, durationSeconds, options) =>
requestJson('/api/roguelike/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dungeonId, difficultyId, encountersCleared, resourceSpent, durationSeconds, ...options }),
}),
allocateTalent: (talentId) =>
requestJson(`/api/talents/${talentId}/allocate`, { method: 'POST' }),
resetTalents: () =>
requestJson('/api/talents/reset', { method: 'POST' }),
equipItem: (itemId) =>
requestJson(`/api/equipment/${itemId}/equip`, { method: 'POST' }),
discardExtraItem: (itemId) =>
requestJson(`/api/equipment/${itemId}/discard-extra`, { method: 'POST' }),
breakdownItem: (itemId) =>
requestJson(`/api/equipment/${itemId}/breakdown`, { method: 'POST' }),
craftItem: (recipeId) =>
requestJson(`/api/crafting/recipes/${recipeId}/craft`, { method: 'POST' }),
rollEncounterLoot: (encounterId, difficultyId, runToken) =>
requestJson(`/api/encounters/${encounterId}/loot-roll`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ difficultyId, runToken }),
}),
}
function emptyCharacterData(classId: number): CharacterData {
const static_ = clone(starterProfile) as CharacterProfile
const gc = static_.classes.find((c) => c.id === classId)!
const talentRanks: Record<string, number> = {}
for (const t of gc.talents) talentRanks[String(t.id)] = 0
const starterItemIds = [100, 101, 102, 103, 104, 105, 106, 107, 108, 109]
const inventory: Item[] = static_.inventory.filter((i) => starterItemIds.includes(i.id)).map((i) => ({ ...i }))
const startingAbilitySlots: Array<number | null> = gc.spells
.filter((s) => s.unlockLevel === 1)
.slice(0, 5)
.map((s) => s.id)
while (startingAbilitySlots.length < 6) startingAbilitySlots.push(null)
return {
level: 1,
experience: 0,
talentPoints: 1,
abilitySlots: startingAbilitySlots,
talentRanks,
inventory,
}
}
const offlineRepository: GameRepository = {
async loadSession() {
const save = readOfflineSave()
return {
account: save ? offlineAccount : null,
profile: save ? buildProfile(save) : null,
}
},
async register() {
throw new Error('Account registration requires online mode.')
},
async login() {
throw new Error('Account login requires online mode.')
},
async logout() {
writeMode('online')
},
async loadProfile() {
return buildProfile(requireOfflineSave())
},
async saveProfile(classId, abilitySlots) {
const save = requireOfflineSave()
const static_ = clone(starterProfile) as CharacterProfile
const gameClass = static_.classes.find((candidate) => candidate.id === classId)
if (!gameClass) throw new Error('Selected class does not exist.')
const slots = abilitySlots.slice(0, 6)
while (slots.length < 6) slots.push(null)
const selectedIds = slots.filter((id): id is number => id !== null)
if (new Set(selectedIds).size !== selectedIds.length) {
throw new Error('The same ability cannot be equipped twice.')
}
const activeChar = save.characters[save.activeClassId]
const validIds = new Set(
gameClass.spells
.filter((spell) => spell.unlockLevel <= activeChar.level)
.map((spell) => spell.id),
)
if (selectedIds.some((id) => !validIds.has(id))) {
throw new Error('One or more abilities are locked or belong to another class.')
}
if (!save.characters[classId]) {
save.characters[classId] = emptyCharacterData(classId)
}
save.characters[classId].abilitySlots = slots
save.activeClassId = classId
writeOfflineSave(save)
return buildProfile(save)
},
async completeDungeon(dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds) {
void startPart
void partDurationSeconds
if (!Number.isInteger(resourceSpent) || resourceSpent < 0) {
throw new Error('The run resource total is invalid.')
}
if (!Number.isInteger(durationSeconds) || durationSeconds < 1) {
throw new Error('The run duration is invalid.')
}
const save = requireOfflineSave()
const profile = buildProfile(save)
const dungeon = profile.dungeons.find((candidate) => candidate.id === dungeonId)
const difficulty = dungeon?.difficulties.find(
(candidate) => candidate.id === difficultyId,
)
if (!dungeon || !difficulty) {
throw new Error('That difficulty is not available for this dungeon.')
}
const cd = save.characters[save.activeClassId]
if (cd.level < difficulty.unlockLevel) {
throw new Error(`${difficulty.name} unlocks at level ${difficulty.unlockLevel}.`)
}
const previousLevel = cd.level
const previousExperience = cd.experience
const partCount = completedPart ?? 1
const experienceReward = Math.round(
dungeon.experienceReward * difficulty.experienceMultiplier * partCount,
)
const maxExperience = experienceForLevel(profile.maxLevel)
const newExperience = Math.min(previousExperience + experienceReward, maxExperience)
let newLevel = previousLevel
while (
newLevel < profile.maxLevel
&& experienceForLevel(newLevel + 1) <= newExperience
) {
newLevel += 1
}
const levelsGained = newLevel - previousLevel
const gameClass = profile.classes.find(
(candidate) => candidate.id === save.activeClassId,
)!
const unlockedAbilities = gameClass.spells
.filter(
(spell) =>
spell.unlockLevel > previousLevel && spell.unlockLevel <= newLevel,
)
.map(({ id, name, unlockLevel, glyph }) => ({ id, name, unlockLevel, glyph }))
cd.experience = newExperience
cd.level = newLevel
cd.talentPoints = Math.min(
profile.maxTalentPoints,
cd.talentPoints + levelsGained,
)
if (dungeon.contentType === 'raid') {
save.completedRaidPhases = Math.max(save.completedRaidPhases, partCount)
} else {
save.completedDungeonParts = Math.max(save.completedDungeonParts, partCount)
}
let bonusItem: DungeonReward['bonusItem'] = null
if ((startPart ?? 1) === 1 && partCount >= 3 && dungeon.completionLoot.length > 0) {
const targetItemLevel = dungeon.completionItemLevel ?? difficulty.droppedItemLevel + 3
const eligibleLoot = dungeon.completionLoot.filter(
(item) => item.itemLevel >= targetItemLevel,
)
if (eligibleLoot.length > 0) {
const rewardItemLevel = Math.min(...eligibleLoot.map((item) => item.itemLevel))
const rewardPool = eligibleLoot.filter((item) => item.itemLevel === rewardItemLevel)
const selected = rewardPool[Math.floor(Math.random() * rewardPool.length)]
const existing = profile.inventory.find((item) => item.id === selected.id)
const duplicate = Boolean(existing)
let quantityAfter = 1
if (existing) {
existing.quantity += 1
quantityAfter = existing.quantity
} else {
profile.inventory.push({
...selected,
quantity: 1,
equipped: false,
})
}
cd.inventory = profile.inventory
bonusItem = { ...selected, duplicate, quantityAfter }
}
}
writeOfflineSave(save)
const updatedProfile = buildProfile(save)
return {
dungeonName: dungeon.name,
difficultyName: difficulty.name,
droppedItemLevel: difficulty.droppedItemLevel,
experienceGained: newExperience - previousExperience,
previousLevel,
newLevel,
levelsGained,
talentPointsGained: levelsGained,
resourceSpent,
durationSeconds,
averageItemLevel: updatedProfile.gearStats.averageItemLevel,
unlockedAbilities,
bonusItem,
profile: updatedProfile,
}
},
async completeRoguelike(dungeonId, difficultyId, encountersCleared, resourceSpent, durationSeconds, options) {
if (!Number.isInteger(encountersCleared) || encountersCleared < 0) {
throw new Error('The roguelike progress total is invalid.')
}
if (!Number.isInteger(resourceSpent) || resourceSpent < 0) {
throw new Error('The run resource total is invalid.')
}
if (!Number.isInteger(durationSeconds) || durationSeconds < 1) {
throw new Error('The run duration is invalid.')
}
const save = requireOfflineSave()
const profile = buildProfile(save)
const dungeon = profile.dungeons.find((candidate) => candidate.id === dungeonId)
const difficulty = dungeon?.difficulties.find(
(candidate) => candidate.id === difficultyId,
)
if (!dungeon || !difficulty) {
throw new Error('That difficulty is not available for this roguelike.')
}
const cd = save.characters[save.activeClassId]
if (cd.level < difficulty.unlockLevel) {
throw new Error(`${difficulty.name} unlocks at level ${difficulty.unlockLevel}.`)
}
const previousLevel = cd.level
const previousExperience = cd.experience
const maxExperience = experienceForLevel(profile.maxLevel)
const bossesCleared = Math.max(0, Math.floor(options?.bossesCleared ?? encountersCleared / 3))
const scaledReward = options?.experienceMode === 'pvp-boss-quarter-level'
? scaledPvpBossExperience(previousExperience, previousLevel, bossesCleared, profile.maxLevel)
: null
const newExperience = scaledReward
? scaledReward.experience
: Math.min(
previousExperience
+ Math.round(dungeon.experienceReward * difficulty.experienceMultiplier * (encountersCleared / 3)),
maxExperience,
)
let newLevel = scaledReward?.level ?? previousLevel
while (
newLevel < profile.maxLevel
&& experienceForLevel(newLevel + 1) <= newExperience
) {
newLevel += 1
}
const levelsGained = newLevel - previousLevel
const gameClass = profile.classes.find(
(candidate) => candidate.id === save.activeClassId,
)!
const unlockedAbilities = gameClass.spells
.filter(
(spell) =>
spell.unlockLevel > previousLevel && spell.unlockLevel <= newLevel,
)
.map(({ id, name, unlockLevel, glyph }) => ({ id, name, unlockLevel, glyph }))
cd.experience = newExperience
cd.level = newLevel
cd.talentPoints = Math.min(
profile.maxTalentPoints,
cd.talentPoints + levelsGained,
)
writeOfflineSave(save)
const updatedProfile = buildProfile(save)
return {
dungeonName: `${dungeon.name} Roguelike`,
difficultyName: difficulty.name,
droppedItemLevel: difficulty.droppedItemLevel,
experienceGained: newExperience - previousExperience,
previousLevel,
newLevel,
levelsGained,
talentPointsGained: levelsGained,
resourceSpent,
durationSeconds,
averageItemLevel: updatedProfile.gearStats.averageItemLevel,
unlockedAbilities,
bonusItem: null,
profile: updatedProfile,
}
},
async allocateTalent(talentId) {
const save = requireOfflineSave()
const profile = buildProfile(save)
const cd = save.characters[save.activeClassId]
const gameClass = profile.classes.find(
(candidate) => candidate.id === save.activeClassId,
)!
const talent = gameClass.talents.find((candidate) => candidate.id === talentId)
if (!talent) throw new Error('That talent does not belong to the active class.')
if (cd.talentPoints <= 0) {
throw new Error('No talent points are available.')
}
if (talent.rank >= talent.maxRank) {
throw new Error('That talent is already at maximum rank.')
}
const lowerTierPoints = gameClass.talents
.filter((candidate) => candidate.tier < talent.tier)
.reduce((total, candidate) => total + candidate.rank, 0)
const requiredTierPoints = (talent.tier - 1) * 5
if (lowerTierPoints < requiredTierPoints) {
throw new Error(`Spend ${requiredTierPoints} points in earlier tiers first.`)
}
if (talent.prerequisiteTalentId) {
const prerequisite = gameClass.talents.find(
(candidate) => candidate.id === talent.prerequisiteTalentId,
)
if ((prerequisite?.rank ?? 0) < talent.prerequisiteRank) {
throw new Error(
`The prerequisite talent requires rank ${talent.prerequisiteRank}.`,
)
}
}
cd.talentRanks[String(talentId)] = (cd.talentRanks[String(talentId)] ?? 0) + 1
cd.talentPoints -= 1
writeOfflineSave(save)
return buildProfile(save)
},
async resetTalents() {
const save = requireOfflineSave()
const profile = buildProfile(save)
const cd = save.characters[save.activeClassId]
const gameClass = profile.classes.find(
(candidate) => candidate.id === save.activeClassId,
)!
const refunded = gameClass.talents.reduce(
(total, talent) => total + talent.rank,
0,
)
for (const talent of gameClass.talents) {
cd.talentRanks[String(talent.id)] = 0
}
cd.talentPoints = Math.min(
profile.maxTalentPoints,
cd.talentPoints + refunded,
)
writeOfflineSave(save)
return buildProfile(save)
},
async equipItem(itemId) {
const save = requireOfflineSave()
const profile = buildProfile(save)
const item = profile.inventory.find((candidate) => candidate.id === itemId)
if (!item) throw new Error('That item is not in the character inventory.')
for (const candidate of profile.inventory) {
if (candidate.slot === item.slot) candidate.equipped = candidate.id === item.id
}
save.characters[save.activeClassId].inventory = profile.inventory
writeOfflineSave(save)
return buildProfile(save)
},
async discardExtraItem(itemId) {
const save = requireOfflineSave()
const profile = buildProfile(save)
const item = profile.inventory.find((candidate) => candidate.id === itemId)
if (!item) throw new Error('That item is not in the character inventory.')
if (item.quantity <= 1) throw new Error('Only extra copies can be discarded.')
item.quantity -= 1
save.characters[save.activeClassId].inventory = profile.inventory
writeOfflineSave(save)
return buildProfile(save)
},
async breakdownItem(itemId) {
const save = requireOfflineSave()
const profile = buildProfile(save)
const item = profile.inventory.find((candidate) => candidate.id === itemId)
if (!item) throw new Error('That item is not in the character inventory.')
if (item.slot === 'component') throw new Error('Components cannot be broken down.')
if (item.equipped && item.quantity <= 1) {
throw new Error('Equipped items cannot be broken down.')
}
const componentTemplate = componentForItemLevel(item.itemLevel)
if (!componentTemplate) throw new Error('No component type exists for this item level.')
if (item.quantity <= 1) {
profile.inventory.splice(profile.inventory.indexOf(item), 1)
} else {
item.quantity -= 1
}
const existing = profile.inventory.find((c) => c.id === componentTemplate.id)
const count = Math.floor(Math.random() * 3) + 1
if (existing) {
existing.quantity += count
} else {
profile.inventory.push({
id: componentTemplate.id,
slug: componentTemplate.slug,
name: componentTemplate.name,
slot: 'component' as EquipmentSlot,
rarity: 'common' as const,
itemLevel: componentTemplate.itemLevel,
healingPower: 0,
maxResourceBonus: 0,
glyph: componentTemplate.glyph,
description: componentTemplate.description,
quantity: count,
equipped: false,
})
}
save.characters[save.activeClassId].inventory = profile.inventory
writeOfflineSave(save)
return buildProfile(save)
},
async craftItem(recipeId) {
const save = requireOfflineSave()
const profile = buildProfile(save)
const recipe = profile.craftingRecipes.find((candidate) => candidate.id === recipeId)
if (!recipe) throw new Error('That crafting recipe does not exist.')
const missing = recipe.components.find((component) => component.owned < component.quantity)
if (missing) {
throw new Error(`Need ${missing.quantity} ${missing.item.name} to craft this item.`)
}
for (const component of recipe.components) {
const owned = profile.inventory.find((candidate) => candidate.id === component.item.id)
if (!owned) throw new Error(`Need ${component.quantity} ${component.item.name} to craft this item.`)
owned.quantity -= component.quantity
}
for (let index = profile.inventory.length - 1; index >= 0; index -= 1) {
if (profile.inventory[index].quantity <= 0) profile.inventory.splice(index, 1)
}
addInventoryItem(profile.inventory, recipe.item, 1)
save.characters[save.activeClassId].inventory = profile.inventory
writeOfflineSave(save)
return buildProfile(save)
},
async rollEncounterLoot(encounterId, difficultyId, runToken) {
if (runToken.length < 8 || runToken.length > 100) {
throw new Error('A valid dungeon run token is required.')
}
const save = requireOfflineSave()
const rollKey = `${runToken}:${encounterId}:${difficultyId}`
if (save.lootRolls[rollKey]) return clone(save.lootRolls[rollKey])
const profile = buildProfile(save)
const encounter = profile.dungeons
.flatMap((dungeon) => dungeon.encounters)
.find((candidate) => candidate.id === encounterId)
const difficulty = profile.dungeons
.flatMap((dungeon) => dungeon.difficulties)
.find((candidate) => candidate.id === difficultyId)
const entries = encounter?.lootTables.filter(
(entry) => entry.difficultyId === difficultyId,
) ?? []
if (!encounter || !difficulty || entries.length === 0) {
throw new Error('This encounter has no configured loot.')
}
const dropChance = entries[0].dropChance
const items: LootRoll['items'] = []
const selectedQuantities = new Map<number, { entry: typeof entries[number]; quantity: number }>()
const dungeon = profile.dungeons.find((candidate) =>
candidate.encounters.some((dungeonEncounter) => dungeonEncounter.id === encounterId),
)
const lootChanceSlots = dungeon?.contentType === 'raid' ? 8 : 5
for (let index = 0; index < lootChanceSlots; index += 1) {
if (Math.random() >= dropChance) continue
const selected = rollWeightedLootEntry(entries)
const current = selectedQuantities.get(selected.id)
selectedQuantities.set(selected.id, {
entry: selected,
quantity: (current?.quantity ?? 0) + componentDropQuantity(difficulty.droppedItemLevel),
})
}
for (const { entry, quantity } of selectedQuantities.values()) {
const {
encounterId: _encounterId,
difficultyId: _difficultyId,
dropWeight: _dropWeight,
dropChance: _dropChance,
...rolledItem
} = entry
void _encounterId
void _difficultyId
void _dropWeight
void _dropChance
const added = addInventoryItem(profile.inventory, {
...rolledItem,
slot: rolledItem.slot as EquipmentSlot,
rarity: rolledItem.rarity as Item['rarity'],
}, quantity)
items.push({
...rolledItem,
slot: rolledItem.slot as EquipmentSlot,
rarity: rolledItem.rarity as Item['rarity'],
quantity,
duplicate: added.duplicate,
quantityAfter: added.quantityAfter,
})
}
const item = items[0] ?? null
const result: LootRoll = {
encounterId,
encounterName: encounter.enemyName,
difficultyId,
difficultyName: difficulty.name,
dropChance,
dropped: items.length > 0,
item,
items,
awarded: Boolean(item),
duplicate: items.some((candidate) => candidate.duplicate),
quantityAfter: item?.quantityAfter ?? 0,
}
save.lootRolls[rollKey] = result
save.characters[save.activeClassId].inventory = profile.inventory
writeOfflineSave(save)
return clone(result)
},
}
export function getGameMode(): GameMode {
return readMode()
}
export function selectOnlineMode() {
writeMode('online')
}
export function createOfflineCharacter(characterName: string): AuthSession {
const name = characterName.trim() || 'Mira'
if (!/^[A-Za-z][A-Za-z0-9 '-]{1,19}$/.test(name)) {
throw new Error('Character name must be 2-20 characters and start with a letter.')
}
const characters: Record<number, CharacterData> = {}
for (const cid of [1, 2, 3]) {
characters[cid] = emptyCharacterData(cid)
}
const save: OfflineSave = {
version: 3,
characterName: name,
activeClassId: 1,
completedDungeonParts: 0,
completedRaidPhases: 0,
characters,
lootRolls: {},
}
writeOfflineSave(save)
writeMode('offline')
return { account: offlineAccount, profile: buildProfile(save) }
}
export function resumeOfflineCharacter(): AuthSession | null {
const save = readOfflineSave()
if (!save) return null
writeMode('offline')
return { account: offlineAccount, profile: buildProfile(save) }
}
export function hasOfflineCharacter(): boolean {
return readOfflineSave() !== null
}
export function activeGameRepository(): GameRepository {
return readMode() === 'offline' ? offlineRepository : serverRepository
}