This commit is contained in:
Warren H
2026-06-18 22:28:04 -04:00
parent a604569a2f
commit 3a8d5ad8c5
19 changed files with 3047 additions and 5930 deletions
+164 -23
View File
@@ -36,6 +36,8 @@ export interface GameRepository {
options?: {
bossesCleared?: number
experienceMode?: 'default' | 'pvp-boss-quarter-level'
lootSourceEncounterId?: number
roguelikeStage?: number
},
): Promise<DungeonReward>
allocateTalent(talentId: number): Promise<CharacterProfile>
@@ -44,6 +46,7 @@ export interface GameRepository {
discardExtraItem(itemId: number): Promise<CharacterProfile>
breakdownItem(itemId: number): Promise<CharacterProfile>
craftItem(recipeId: number): Promise<CharacterProfile>
upgradeItem(itemId: number): Promise<CharacterProfile>
rollEncounterLoot(
encounterId: number,
difficultyId: number,
@@ -97,6 +100,7 @@ type LocalSaveStore = {
const modeKey = 'chronicle.repositoryMode'
const offlineSaveKey = 'chronicle.offlineSave.v1'
const onlineCacheKey = 'chronicle.onlineCache.v1'
const authTokenKey = 'chronicle.authToken.v1'
const offlineAccount = { id: -1, username: 'Offline' }
function clone<T>(value: T): T {
@@ -390,6 +394,7 @@ const COMPONENT_ITEMS: Record<number, ComponentTemplate> = {
type WindowWithApiBase = Window & {
CAPACITOR_API_BASE_URL?: string
CAPACITOR_AUTH_API_BASE_URL?: string
}
function componentForItemLevel(itemLevel: number): ComponentTemplate | undefined {
@@ -401,13 +406,6 @@ function componentForItemLevel(itemLevel: number): ComponentTemplate | undefined
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 mergeProfileIntoSave(profile: CharacterProfile, existingSave?: OfflineSave): OfflineSave {
const characters = clone(existingSave?.characters ?? {})
for (const gameClass of profile.classes) {
@@ -452,25 +450,107 @@ function rollWeightedLootEntry<T extends { dropWeight: number }>(entries: T[]):
return entries[entries.length - 1]
}
function getApiBaseUrl(): string {
function coinDropQuantity() {
const roll = Math.random()
if (roll < 0.15) return 3
if (roll < 0.5) return 2
return 1
}
function roguelikeCoinItemLevel(stage: number) {
return Math.min(25, 5 + Math.max(0, Math.floor(stage / 5)) * 5)
}
function awardRoguelikeCoin(
profile: CharacterProfile,
sourceEncounterId: number | undefined,
stage: number | undefined,
): DungeonReward['bonusItem'] {
if (!sourceEncounterId || !stage) return null
const targetItemLevel = roguelikeCoinItemLevel(stage)
const sourceEncounter = profile.dungeons
.flatMap((dungeon) => dungeon.encounters)
.find((encounter) => encounter.id === sourceEncounterId)
const coin = sourceEncounter?.lootTables
.filter((entry) => entry.itemLevel === targetItemLevel)
.sort((left, right) => left.difficultyId - right.difficultyId)[0]
if (!coin) return null
const {
encounterId: _encounterId,
difficultyId: _difficultyId,
dropWeight: _dropWeight,
dropChance: _dropChance,
...coinItem
} = coin
void _encounterId
void _difficultyId
void _dropWeight
void _dropChance
const quantity = coinDropQuantity()
const added = addInventoryItem(profile.inventory, {
...coinItem,
slot: coinItem.slot as EquipmentSlot,
rarity: coinItem.rarity as Item['rarity'],
}, quantity)
return {
...coinItem,
quantity,
duplicate: added.duplicate,
quantityAfter: added.quantityAfter,
}
}
function readAuthToken(): string {
return localStorage.getItem(authTokenKey) ?? ''
}
function writeAuthToken(token: string) {
localStorage.setItem(authTokenKey, token)
}
function clearAuthToken() {
localStorage.removeItem(authTokenKey)
}
function configuredBaseUrl(value: string | undefined): string {
return value ? value.replace(/\/+$/, '') : ''
}
function getApiBaseUrl(path: string): string {
const browserWindow = typeof window === 'undefined'
? undefined
: window as WindowWithApiBase
if (path.startsWith('/api/auth/')) {
if (browserWindow?.CAPACITOR_AUTH_API_BASE_URL) {
return configuredBaseUrl(browserWindow.CAPACITOR_AUTH_API_BASE_URL)
}
if (typeof import.meta !== 'undefined' && import.meta.env?.VITE_AUTH_API_BASE_URL) {
return configuredBaseUrl(import.meta.env.VITE_AUTH_API_BASE_URL)
}
}
if (browserWindow?.CAPACITOR_API_BASE_URL) {
return browserWindow.CAPACITOR_API_BASE_URL
return configuredBaseUrl(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 configuredBaseUrl(import.meta.env.VITE_API_BASE_URL)
}
return ''
}
async function requestJson<T>(path: string, init?: RequestInit): Promise<T> {
const baseUrl = getApiBaseUrl()
const baseUrl = getApiBaseUrl(path)
const url = baseUrl ? `${baseUrl}${path}` : path
const headers = new Headers(init?.headers)
const token = readAuthToken()
if (token && !headers.has('Authorization')) {
headers.set('Authorization', `Bearer ${token}`)
}
let response: Response
try {
response = await fetch(url, init)
response = await fetch(url, {
...init,
headers,
})
} catch (reason) {
const networkError = new Error('Unable to reach the game server.') as NetworkError
networkError.network = true
@@ -525,8 +605,18 @@ async function pushServerSyncSave(save: OfflineSave): Promise<{ profile: Charact
}
async function finalizeOnlineSession(session: AuthSession): Promise<AuthSession> {
if (!session.account || !session.profile) return session
const cache = readOnlineCache()
if (session.token) writeAuthToken(session.token)
if (!session.account || !session.profile) {
if (session.account && cache?.account.id === session.account.id) {
return {
account: session.account,
profile: buildProfile(cache.save),
token: session.token,
}
}
return session
}
if (cache?.account.id === session.account.id && cache.dirty) {
writeOnlineCache({
...cache,
@@ -535,6 +625,7 @@ async function finalizeOnlineSession(session: AuthSession): Promise<AuthSession>
return {
account: session.account,
profile: buildProfile(cache.save),
token: session.token,
}
}
try {
@@ -611,6 +702,7 @@ const serverRepository: GameRepository = {
} catch (reason) {
if (!isNetworkError(reason)) throw reason
}
clearAuthToken()
clearOnlineCache()
writeMode('online')
},
@@ -657,6 +749,8 @@ const serverRepository: GameRepository = {
cachedOnlineLocalRepository.breakdownItem(itemId),
craftItem: (recipeId) =>
cachedOnlineLocalRepository.craftItem(recipeId),
upgradeItem: (itemId) =>
cachedOnlineLocalRepository.upgradeItem(itemId),
rollEncounterLoot: (encounterId, difficultyId, runToken) =>
cachedOnlineLocalRepository.rollEncounterLoot(encounterId, difficultyId, runToken),
}
@@ -827,7 +921,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
})
}
cd.inventory = profile.inventory
bonusItem = { ...selected, duplicate, quantityAfter }
bonusItem = { ...selected, quantity: 1, duplicate, quantityAfter }
}
}
@@ -914,6 +1008,12 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
profile.maxTalentPoints,
cd.talentPoints + levelsGained,
)
const bonusItem = awardRoguelikeCoin(
profile,
options?.lootSourceEncounterId,
options?.roguelikeStage,
)
cd.inventory = profile.inventory
store.writeSave(save)
const updatedProfile = buildProfile(save)
@@ -931,7 +1031,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
durationSeconds,
averageItemLevel: updatedProfile.gearStats.averageItemLevel,
unlockedAbilities,
bonusItem: null,
bonusItem,
profile: updatedProfile,
}
},
@@ -1083,6 +1183,51 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
store.writeSave(save)
return buildProfile(save)
},
async upgradeItem(itemId) {
const save = requireStoredSave(store)
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 upgraded.')
const currentRecipe = profile.craftingRecipes.find((recipe) => recipe.item.id === item.id)
const targetRecipe = currentRecipe
? profile.craftingRecipes.find((recipe) =>
recipe.sourceEncounterId === currentRecipe.sourceEncounterId
&& recipe.item.slot === item.slot
&& recipe.item.itemLevel === item.itemLevel + 5,
)
: null
if (!targetRecipe) throw new Error('No upgrade is available for this item.')
const missing = targetRecipe.components.find((component) => component.owned < component.quantity)
if (missing) {
throw new Error(`Need ${missing.quantity} ${missing.item.name} to upgrade this item.`)
}
for (const component of targetRecipe.components) {
const owned = profile.inventory.find((candidate) => candidate.id === component.item.id)
if (!owned) throw new Error(`Need ${component.quantity} ${component.item.name} to upgrade this item.`)
owned.quantity -= component.quantity
}
const wasEquipped = item.equipped
item.quantity -= 1
item.equipped = false
for (let index = profile.inventory.length - 1; index >= 0; index -= 1) {
if (profile.inventory[index].quantity <= 0) profile.inventory.splice(index, 1)
}
if (wasEquipped) {
for (const candidate of profile.inventory) {
if (candidate.slot === targetRecipe.item.slot) candidate.equipped = false
}
}
addInventoryItem(profile.inventory, targetRecipe.item, 1)
if (wasEquipped) {
const upgraded = profile.inventory.find((candidate) => candidate.id === targetRecipe.item.id)
if (upgraded) upgraded.equipped = true
}
save.characters[save.activeClassId].inventory = profile.inventory
store.writeSave(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.')
@@ -1108,17 +1253,11 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
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
if (Math.random() < dropChance) {
const selected = rollWeightedLootEntry(entries)
const current = selectedQuantities.get(selected.id)
selectedQuantities.set(selected.id, {
entry: selected,
quantity: (current?.quantity ?? 0) + componentDropQuantity(difficulty.droppedItemLevel),
quantity: coinDropQuantity(),
})
}
@@ -1211,6 +1350,7 @@ const cachedOnlineRepository: GameRepository = {
throw new Error('Account login requires online mode.')
},
async logout() {
clearAuthToken()
clearOnlineCache()
writeMode('online')
},
@@ -1241,6 +1381,7 @@ const cachedOnlineRepository: GameRepository = {
discardExtraItem: (itemId) => cachedOnlineLocalRepository.discardExtraItem(itemId),
breakdownItem: (itemId) => cachedOnlineLocalRepository.breakdownItem(itemId),
craftItem: (recipeId) => cachedOnlineLocalRepository.craftItem(recipeId),
upgradeItem: (itemId) => cachedOnlineLocalRepository.upgradeItem(itemId),
rollEncounterLoot: (encounterId, difficultyId, runToken) =>
cachedOnlineLocalRepository.rollEncounterLoot(encounterId, difficultyId, runToken),
}