947 lines
34 KiB
TypeScript
947 lines
34 KiB
TypeScript
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
|
|
}
|