changes
This commit is contained in:
+164
-23
@@ -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),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user