changes
This commit is contained in:
@@ -2973,6 +2973,10 @@ h2 {
|
||||
--rarity-color: #b584e3;
|
||||
}
|
||||
|
||||
.rarity-legendary {
|
||||
--rarity-color: #f2a13a;
|
||||
}
|
||||
|
||||
.rarity-common {
|
||||
--rarity-color: #a8a3ad;
|
||||
}
|
||||
|
||||
+2
-3
@@ -287,7 +287,6 @@ function App() {
|
||||
{ part: 2, name: `${sectionName} 2`, encounterCount: 3, unlocked: completedSections >= 1 },
|
||||
{ part: 3, name: `${sectionName} 3`, encounterCount: 3, unlocked: completedSections >= 2 },
|
||||
]
|
||||
const lootChanceSlots = activity.contentType === 'raid' ? 8 : 5
|
||||
const cloudSync = getCloudSyncStatus()
|
||||
const canShowCloudSync = account.id !== -1 && cloudSync.available
|
||||
const lootPreviewEncounters = [...activity.encounters]
|
||||
@@ -682,7 +681,7 @@ function App() {
|
||||
</label>
|
||||
</div>
|
||||
<p className="section-note">
|
||||
Bosses roll {lootChanceSlots} loot chances against the listed table, 1-3 materials each
|
||||
Bosses drop 1-3 boss coins from one loot roll
|
||||
{activity.completionItemLevel
|
||||
? ` - full clear guarantees iLvl ${activity.completionItemLevel}`
|
||||
: ''}
|
||||
@@ -702,7 +701,7 @@ function App() {
|
||||
)}
|
||||
<div>
|
||||
<strong>{encounter.enemyName}</strong>
|
||||
<small>{loot.length > 0 ? `${lootChanceSlots} weighted chances, 1-3 each` : 'No component table'}</small>
|
||||
<small>{loot.length > 0 ? '1 loot roll, 1-3 coins' : 'No coin table'}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="loot-items">
|
||||
|
||||
@@ -183,7 +183,7 @@ function ItemsTab({ data, setData, setSaving, saving }: {
|
||||
</label>
|
||||
<label>Rarity
|
||||
<select value={form.rarity ?? item.rarity} onChange={(e) => setForm({ ...form, rarity: e.target.value })}>
|
||||
{['common', 'uncommon', 'rare', 'epic'].map((r) => (
|
||||
{['common', 'uncommon', 'rare', 'epic', 'legendary'].map((r) => (
|
||||
<option key={r} value={r}>{r}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -1321,7 +1321,7 @@ export function CombatScreen({
|
||||
<div className="bonus-item-detail">
|
||||
<span>{reward.bonusItem.glyph}</span>
|
||||
<strong className={`rarity-${reward.bonusItem.rarity}`}>{reward.bonusItem.name}</strong>
|
||||
<small>Item Level {reward.bonusItem.itemLevel}</small>
|
||||
<small>Item Level {reward.bonusItem.itemLevel} x{reward.bonusItem.quantity}</small>
|
||||
{reward.bonusItem.duplicate && <small> (owned x{reward.bonusItem.quantityAfter})</small>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
craftItem,
|
||||
equipItem,
|
||||
loadProfile,
|
||||
upgradeItem,
|
||||
type CharacterProfile,
|
||||
type EquipmentSlot,
|
||||
type Item,
|
||||
@@ -46,6 +47,7 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
||||
const [equipping, setEquipping] = useState(false)
|
||||
const [breakingDown, setBreakingDown] = useState(false)
|
||||
const [crafting, setCrafting] = useState(false)
|
||||
const [upgrading, setUpgrading] = useState(false)
|
||||
const [showSetBonuses, setShowSetBonuses] = useState(false)
|
||||
const [equipmentTab, setEquipmentTab] = useState<'equipment' | 'crafting'>('equipment')
|
||||
const [inventoryPage, setInventoryPage] = useState(0)
|
||||
@@ -59,6 +61,16 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
||||
firstRecipe?.id ?? null,
|
||||
)
|
||||
const selectedRecipe = profile.craftingRecipes.find((recipe) => recipe.id === selectedRecipeId)
|
||||
const selectedItemRecipe = selectedItem
|
||||
? profile.craftingRecipes.find((recipe) => recipe.item.id === selectedItem.id)
|
||||
: undefined
|
||||
const upgradeRecipe = selectedItem && selectedItemRecipe
|
||||
? profile.craftingRecipes.find((recipe) =>
|
||||
recipe.sourceEncounterId === selectedItemRecipe.sourceEncounterId
|
||||
&& recipe.item.slot === selectedItem.slot
|
||||
&& recipe.item.itemLevel === selectedItem.itemLevel + 5,
|
||||
)
|
||||
: undefined
|
||||
const equippedBySlot = useMemo(
|
||||
() => new Map(
|
||||
profile.inventory
|
||||
@@ -189,6 +201,23 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
||||
}
|
||||
}
|
||||
|
||||
async function upgradeSelected() {
|
||||
if (!selectedItem || !upgradeRecipe) return
|
||||
saveScroll()
|
||||
setUpgrading(true)
|
||||
setMessage('')
|
||||
try {
|
||||
const updated = await upgradeItem(selectedItem.id)
|
||||
onUpdated(updated)
|
||||
setSelectedItemId(upgradeRecipe.item.id)
|
||||
setMessage(`${selectedItem.name} upgraded to ${upgradeRecipe.item.name}.`)
|
||||
} catch (reason) {
|
||||
setMessage(reason instanceof Error ? reason.message : 'Unable to upgrade item.')
|
||||
} finally {
|
||||
setUpgrading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{!embedded && (
|
||||
@@ -259,16 +288,26 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
|
||||
<ComparisonDelta selected={selectedItem} equipped={comparisonItem} />
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={selectedItem.equipped || equipping || breakingDown}
|
||||
disabled={selectedItem.equipped || equipping || breakingDown || upgrading}
|
||||
onClick={equipSelected}
|
||||
type="button"
|
||||
>
|
||||
{selectedItem.equipped ? 'Equipped' : equipping ? 'Equipping...' : 'Equip Item'}
|
||||
</button>
|
||||
{upgradeRecipe && (
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={!upgradeRecipe.canCraft || equipping || breakingDown || upgrading}
|
||||
onClick={upgradeSelected}
|
||||
type="button"
|
||||
>
|
||||
{upgrading ? 'Upgrading...' : `Upgrade to iLvl ${upgradeRecipe.item.itemLevel}`}
|
||||
</button>
|
||||
)}
|
||||
{(!selectedItem.equipped || selectedItem.quantity > 1) && (
|
||||
<button
|
||||
className="breakdown-button"
|
||||
disabled={equipping || breakingDown}
|
||||
disabled={equipping || breakingDown || upgrading}
|
||||
onClick={breakdownSelected}
|
||||
type="button"
|
||||
>
|
||||
|
||||
@@ -12,8 +12,10 @@ import {
|
||||
type DualScreenCombatState,
|
||||
} from '../dualScreen'
|
||||
import {
|
||||
loadPvpRoguelikeCheckpoint,
|
||||
randomCpuDifficulty,
|
||||
recordCpuPvpLeaderboard,
|
||||
recordPvpRoguelikeCheckpoint,
|
||||
type CpuDifficulty,
|
||||
type PvpContentType,
|
||||
} from '../pvpRoguelike'
|
||||
@@ -29,6 +31,7 @@ type BossMechanic =
|
||||
|
||||
type PvpEncounter = DungeonEncounter & {
|
||||
bossMechanics?: BossMechanic[]
|
||||
sourceEncounterId?: number
|
||||
}
|
||||
|
||||
type SlotKey = '1' | '2' | '3' | '4' | '5'
|
||||
@@ -261,6 +264,7 @@ function buildEncounterSegment(pool: DungeonEncounter[], stage: number, kind: Pv
|
||||
const isBoss = index === 2
|
||||
return {
|
||||
...encounter,
|
||||
sourceEncounterId: encounter.id,
|
||||
id: 910000 + stage * 10 + index,
|
||||
sequence: (stage - 1) * 3 + index + 1,
|
||||
isBoss,
|
||||
@@ -381,6 +385,10 @@ export function PvPRoguelikeScreen({
|
||||
() => buildOpponentDebuffChoices(starterSpells, abilityLabelMode),
|
||||
[abilityLabelMode, starterSpells],
|
||||
)
|
||||
const [checkpointStage, setCheckpointStage] = useState(() =>
|
||||
loadPvpRoguelikeCheckpoint(profile.character.id, contentType),
|
||||
)
|
||||
const [startStage, setStartStage] = useState(checkpointStage)
|
||||
const maxResource = gameClass.maxResource
|
||||
const partyTemplate = useMemo(
|
||||
() => (contentType === 'raid' ? RAID_PARTY : INITIAL_PARTY).map((member) => ({
|
||||
@@ -397,8 +405,8 @@ export function PvPRoguelikeScreen({
|
||||
[contentType],
|
||||
)
|
||||
const [status, setStatus] = useState<'queueing' | 'playing' | 'upgrade-choice' | 'won' | 'lost'>('queueing')
|
||||
const [stage, setStage] = useState(1)
|
||||
const [encounters, setEncounters] = useState<PvpEncounter[]>(() => buildEncounterSegment(encounterPool, 1, contentType))
|
||||
const [stage, setStage] = useState(startStage)
|
||||
const [encounters, setEncounters] = useState<PvpEncounter[]>(() => buildEncounterSegment(encounterPool, startStage, contentType))
|
||||
const [encounterIndex, setEncounterIndex] = useState(0)
|
||||
const [playerSide, setPlayerSide] = useState<SideState>(() => starterSide(partyTemplate, maxResource))
|
||||
const [cpuSide, setCpuSide] = useState<SideState>(() => starterSide(cpuPartyTemplate, maxResource))
|
||||
@@ -464,9 +472,16 @@ export function PvPRoguelikeScreen({
|
||||
}, 900)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const loadedCheckpoint = loadPvpRoguelikeCheckpoint(profile.character.id, contentType)
|
||||
setCheckpointStage(loadedCheckpoint)
|
||||
setStartStage(loadedCheckpoint)
|
||||
}, [contentType, profile.character.id])
|
||||
|
||||
const awardBossReward = useCallback((encounterIndexValue: number) => {
|
||||
if (bossRewardClaimedRef.current.has(encounterIndexValue)) return
|
||||
bossRewardClaimedRef.current.add(encounterIndexValue)
|
||||
const rewardEncounter = encounters[encounterIndexValue]
|
||||
completeRoguelike(
|
||||
rewardDungeon.id,
|
||||
rewardDifficulty.id,
|
||||
@@ -476,18 +491,26 @@ export function PvPRoguelikeScreen({
|
||||
{
|
||||
bossesCleared: 1,
|
||||
experienceMode: 'pvp-boss-quarter-level',
|
||||
lootSourceEncounterId: rewardEncounter?.sourceEncounterId,
|
||||
roguelikeStage: stage,
|
||||
},
|
||||
)
|
||||
.then((result) => {
|
||||
setReward(result)
|
||||
onProfileUpdated(result.profile)
|
||||
if (result.bonusItem) {
|
||||
addLog(
|
||||
`${result.bonusItem.name} x${result.bonusItem.quantity} awarded.`,
|
||||
'loot',
|
||||
)
|
||||
}
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
setRewardError(
|
||||
reason instanceof Error ? reason.message : 'Unable to award roguelike experience.',
|
||||
)
|
||||
})
|
||||
}, [elapsedTicks, onProfileUpdated, rewardDifficulty.id, rewardDungeon.id])
|
||||
}, [addLog, elapsedTicks, encounters, onProfileUpdated, rewardDifficulty.id, rewardDungeon.id, stage])
|
||||
|
||||
const finishRoguelikeRun = useCallback(() => {
|
||||
if (rewardClaimedRef.current) return
|
||||
@@ -510,7 +533,7 @@ export function PvPRoguelikeScreen({
|
||||
}, [opponentDebuffChoicesCatalog, selfBuffChoicesCatalog])
|
||||
|
||||
useEffect(() => {
|
||||
const firstSegment = buildEncounterSegment(encounterPool, 1, contentType)
|
||||
const firstSegment = buildEncounterSegment(encounterPool, startStage, contentType)
|
||||
const firstEncounter = firstSegment[0]
|
||||
const basePlayer = starterSide(partyTemplate, maxResource)
|
||||
const baseCpu = starterSide(cpuPartyTemplate, maxResource)
|
||||
@@ -523,7 +546,7 @@ export function PvPRoguelikeScreen({
|
||||
bossRewardClaimedRef.current = new Set()
|
||||
setEncounters(firstSegment)
|
||||
setEncounterIndex(0)
|
||||
setStage(1)
|
||||
setStage(startStage)
|
||||
setElapsedTicks(0)
|
||||
setStatus('queueing')
|
||||
setPlayerSide(basePlayer)
|
||||
@@ -546,26 +569,26 @@ export function PvPRoguelikeScreen({
|
||||
cpuDefeatedRef.current = false
|
||||
if (gameMode === 'offline') {
|
||||
const randomCpu = randomCpuDifficulty()
|
||||
setQueueMessage(`Offline mode. CPU ${randomCpu} enters immediately.`)
|
||||
setQueueMessage(`Offline mode. CPU ${randomCpu} enters at stage ${startStage}.`)
|
||||
setCpuDifficulty(randomCpu)
|
||||
setLog([{ id: 1, text: `Offline mode. CPU ${randomCpu} enters immediately.`, tone: 'system' }])
|
||||
setLog([{ id: 1, text: `Offline mode. CPU ${randomCpu} enters at stage ${startStage}.`, tone: 'system' }])
|
||||
const timer = window.setTimeout(() => {
|
||||
setStatus('playing')
|
||||
addLog(`Round 1 begins against CPU ${randomCpu}.`, 'system')
|
||||
addLog(`Stage ${startStage} begins against CPU ${randomCpu}.`, 'system')
|
||||
}, 500)
|
||||
return () => window.clearTimeout(timer)
|
||||
}
|
||||
setQueueMessage('Searching queue. No player found yet.')
|
||||
setLog([{ id: 1, text: 'Searching queue. No player found yet.', tone: 'system' }])
|
||||
setQueueMessage(`Searching queue. Stage ${startStage} start ready.`)
|
||||
setLog([{ id: 1, text: `Searching queue. Stage ${startStage} start ready.`, tone: 'system' }])
|
||||
const timer = window.setTimeout(() => {
|
||||
const randomCpu = randomCpuDifficulty()
|
||||
setCpuDifficulty(randomCpu)
|
||||
setQueueMessage(`No queued player found. CPU ${randomCpu} steps in.`)
|
||||
setStatus('playing')
|
||||
addLog(`No queued player found. CPU ${randomCpu} steps in.`, 'system')
|
||||
addLog(`No queued player found. CPU ${randomCpu} steps in at stage ${startStage}.`, 'system')
|
||||
}, 1400)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [addLog, contentType, cpuPartyTemplate, encounterPool, gameMode, maxResource, partyTemplate])
|
||||
}, [addLog, contentType, cpuPartyTemplate, encounterPool, gameMode, maxResource, partyTemplate, startStage])
|
||||
|
||||
const applySpell = useCallback((
|
||||
current: SideState,
|
||||
@@ -841,7 +864,18 @@ export function PvPRoguelikeScreen({
|
||||
if (nextPlayer.enemyHealth <= 0 && playerClearedEncounterRef.current !== encounterIndex) {
|
||||
playerClearedEncounterRef.current = encounterIndex
|
||||
setEncountersCleared((value) => value + 1)
|
||||
if (encounter.isBoss) awardBossReward(encounterIndex)
|
||||
if (encounter.isBoss) {
|
||||
awardBossReward(encounterIndex)
|
||||
const nextCheckpoint = recordPvpRoguelikeCheckpoint(
|
||||
profile.character.id,
|
||||
contentType,
|
||||
stage,
|
||||
)
|
||||
if (nextCheckpoint > checkpointStage) {
|
||||
setCheckpointStage(nextCheckpoint)
|
||||
addLog(`Stage ${nextCheckpoint} checkpoint unlocked.`, 'loot')
|
||||
}
|
||||
}
|
||||
}
|
||||
playerRef.current = nextPlayer
|
||||
cpuRef.current = nextCpu
|
||||
@@ -866,7 +900,7 @@ export function PvPRoguelikeScreen({
|
||||
}
|
||||
}, TICK_MS)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [addLog, advanceSide, awardBossReward, beginUpgradePhase, cpuDifficulty, cpuTakeTurn, encounter, encounterIndex, encountersCleared, finishRoguelikeRun, paused, status])
|
||||
}, [addLog, advanceSide, awardBossReward, beginUpgradePhase, checkpointStage, contentType, cpuDifficulty, cpuTakeTurn, encounter, encounterIndex, encountersCleared, finishRoguelikeRun, paused, profile.character.id, stage, status])
|
||||
|
||||
useEffect(() => {
|
||||
if ((status !== 'won' && status !== 'lost') || recordedRunRef.current || !cpuDifficulty) return
|
||||
@@ -1334,6 +1368,13 @@ export function PvPRoguelikeScreen({
|
||||
Ability Unlocked: {ability.name}
|
||||
</p>
|
||||
))}
|
||||
{reward.bonusItem && (
|
||||
<p className="ability-unlock">
|
||||
<span>{reward.bonusItem.glyph}</span>
|
||||
{reward.bonusItem.name} x{reward.bonusItem.quantity}
|
||||
{reward.bonusItem.duplicate ? ` (owned x${reward.bonusItem.quantityAfter})` : ''}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+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),
|
||||
}
|
||||
|
||||
+1867
-5818
File diff suppressed because it is too large
Load Diff
+9
-1
@@ -59,7 +59,7 @@ export type Item = {
|
||||
slug: string
|
||||
name: string
|
||||
slot: EquipmentSlot
|
||||
rarity: 'common' | 'uncommon' | 'rare' | 'epic'
|
||||
rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'
|
||||
itemLevel: number
|
||||
healingPower: number
|
||||
maxResourceBonus: number
|
||||
@@ -234,6 +234,7 @@ export type Account = {
|
||||
export type AuthSession = {
|
||||
account: Account | null
|
||||
profile: CharacterProfile | null
|
||||
token?: string
|
||||
}
|
||||
|
||||
export type BonusItem = {
|
||||
@@ -247,6 +248,7 @@ export type BonusItem = {
|
||||
maxResourceBonus: number
|
||||
glyph: string
|
||||
description: string
|
||||
quantity: number
|
||||
duplicate: boolean
|
||||
quantityAfter: number
|
||||
}
|
||||
@@ -338,6 +340,8 @@ export async function completeRoguelike(
|
||||
options?: {
|
||||
bossesCleared?: number
|
||||
experienceMode?: 'default' | 'pvp-boss-quarter-level'
|
||||
lootSourceEncounterId?: number
|
||||
roguelikeStage?: number
|
||||
},
|
||||
): Promise<DungeonReward> {
|
||||
return activeGameRepository().completeRoguelike(
|
||||
@@ -374,6 +378,10 @@ export async function craftItem(recipeId: number): Promise<CharacterProfile> {
|
||||
return activeGameRepository().craftItem(recipeId)
|
||||
}
|
||||
|
||||
export async function upgradeItem(itemId: number): Promise<CharacterProfile> {
|
||||
return activeGameRepository().upgradeItem(itemId)
|
||||
}
|
||||
|
||||
export async function rollEncounterLoot(
|
||||
encounterId: number,
|
||||
difficultyId: number,
|
||||
|
||||
@@ -12,6 +12,7 @@ export type CpuPvpLeaderboardEntry = {
|
||||
}
|
||||
|
||||
const cpuLeaderboardKey = 'chronicle.pvpCpuLeaderboard.v1'
|
||||
const checkpointKey = 'chronicle.pvpRoguelikeCheckpoint.v1'
|
||||
|
||||
export function randomCpuDifficulty(): CpuDifficulty {
|
||||
return (Math.floor(Math.random() * 5) + 1) as CpuDifficulty
|
||||
@@ -44,3 +45,24 @@ export function recordCpuPvpLeaderboard(entry: CpuPvpLeaderboardEntry) {
|
||||
.slice(0, 30)
|
||||
localStorage.setItem(cpuLeaderboardKey, JSON.stringify(next))
|
||||
}
|
||||
|
||||
function checkpointStorageKey(characterId: number, contentType: PvpContentType) {
|
||||
return `${checkpointKey}:${characterId}:${contentType}`
|
||||
}
|
||||
|
||||
export function loadPvpRoguelikeCheckpoint(characterId: number, contentType: PvpContentType) {
|
||||
const value = Number(localStorage.getItem(checkpointStorageKey(characterId, contentType)) ?? 1)
|
||||
return Number.isInteger(value) && value >= 5 ? value : 1
|
||||
}
|
||||
|
||||
export function recordPvpRoguelikeCheckpoint(
|
||||
characterId: number,
|
||||
contentType: PvpContentType,
|
||||
stage: number,
|
||||
) {
|
||||
if (stage < 5 || stage % 5 !== 0) return loadPvpRoguelikeCheckpoint(characterId, contentType)
|
||||
const current = loadPvpRoguelikeCheckpoint(characterId, contentType)
|
||||
const next = Math.max(current, stage)
|
||||
localStorage.setItem(checkpointStorageKey(characterId, contentType), String(next))
|
||||
return next
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user