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
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -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>
+41 -2
View File
@@ -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"
>
+55 -14
View File
@@ -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>