diff --git a/frontend/app/game-ui.tsx b/frontend/app/game-ui.tsx index 10c9ddc..2e07441 100644 --- a/frontend/app/game-ui.tsx +++ b/frontend/app/game-ui.tsx @@ -2,7 +2,7 @@ import Image from "next/image"; import type { ReactNode, Ref } from "react"; -import type { RelicDefinition } from "./relics"; +import { getRelicDefinition, type RelicDefinition } from "./relics"; export type DelvewornMode = "practice" | "onchain"; @@ -109,6 +109,145 @@ export function RelicArtwork({ ); } +export function RelicCollection({ + idPrefix, + ownedRelics, + relicCounts, + equippedRelic, + canChangeRelic, + dataAvailable = true, + lockedLabel = "BETWEEN ROOMS", + onSelectRelic, + className = "", +}: { + idPrefix: string; + ownedRelics: readonly number[]; + relicCounts: readonly number[]; + equippedRelic: number; + canChangeRelic: boolean; + dataAvailable?: boolean; + lockedLabel?: string; + onSelectRelic: (relicId: number) => void; + className?: string; +}) { + const titleId = `${idPrefix}-relic-collection-title`; + const totalRelicDrops = relicCounts.reduce( + (total, count) => total + count, + 0 + ); + + return ( +
+
+
+

+ RELIC INVENTORY · {ownedRelics.length}/15 UNIQUE · {totalRelicDrops}{" "} + {totalRelicDrops === 1 ? "DROP" : "DROPS"} +

+

+ RELIC LOADOUT +

+
+

+ Always visible during the run. Switch or unequip between rooms. +

+
+ + {!dataAvailable && ( +
+

+ Collection data is temporarily unavailable. Onchain state will retry automatically on the next sync. +

+
+ )} + + {ownedRelics.length === 0 ? ( +
+

+ {dataAvailable ? "NO RELICS COLLECTED" : "RELIC INVENTORY SYNCING"} +

+

+ {dataAvailable + ? "Defeat the boss in Room 10 to add the first relic to this run." + : "No collection data is shown until the next successful V3 snapshot."} +

+
+ ) : ( +
+ {[0, ...ownedRelics].map((relicId) => { + const ownedRelic = getRelicDefinition(relicId); + const activeRelic = relicId === equippedRelic; + const actionLabel = activeRelic + ? "ACTIVE" + : canChangeRelic + ? relicId === 0 + ? "UNEQUIP" + : "EQUIP" + : dataAvailable + ? lockedLabel + : "SYNCING"; + + return ( + + ); + })} +
+ )} + +

+ Duplicate effects do not stack. Revive use and permanent max-HP costs remain spent for the full run. +

+
+ ); +} + function BossRelicCard({ relic, label, diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index e80150d..a7fd0d0 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -26,7 +26,7 @@ import { useDisconnect, useConnectors, } from "wagmi"; -import { metaMask } from "wagmi/connectors"; +import { metaMask } from "wagmi/connectors/metaMask"; import { Chains, RiseWallet } from "rise-wallet"; import { Hooks, riseWallet } from "rise-wallet/wagmi"; import { P256, PublicKey, Signature } from "ox"; @@ -47,6 +47,7 @@ import { GameHud, GoldAmount, RelicArtwork, + RelicCollection, RoomProgressLine, SmallStat, } from "./game-ui"; @@ -88,6 +89,7 @@ const VRF_DELAYED_NOTICE_MS = 5_000; const VRF_CANONICAL_FALLBACK_MS = 20_000; const ACTION_READY_TIMEOUT_MS = 45_000; const ACTION_READY_POLL_MS = 250; +const FRONTEND_SNAPSHOT_V3_RETRY_MS = 5_000; const SESSION_DURATION_SECONDS = 8 * 60 * 60; const SESSION_STATUS_TIMEOUT_MS = 12_000; @@ -877,8 +879,37 @@ const dungeonAbi = [ }, ] as const; -let supportsFrontendSnapshotV3: - boolean | null = null; +type FrontendSnapshotV3Availability = { + supported: boolean | null; + retryAfter: number; +}; + +const frontendSnapshotV3Availability: Record< + "realtime" | "canonical", + FrontendSnapshotV3Availability +> = { + realtime: { + supported: null, + retryAfter: 0, + }, + canonical: { + supported: null, + retryAfter: 0, + }, +}; + +type CachedRelicSnapshot = + NonNullable< + Parameters< + typeof playerStateFromFrontendSnapshot + >[1] + >; + +const lastRelicSnapshotByPlayer = + new Map< + string, + CachedRelicSnapshot + >(); @@ -2072,10 +2103,20 @@ async function fetchPlayerState( : publicClient; let snapshot: unknown; + const v3Availability = + frontendSnapshotV3Availability[ + source + ]; + const playerCacheKey = + playerAddress.toLowerCase(); + const shouldTryV3 = + v3Availability.supported !== + false || + runtimeNowMs() >= + v3Availability.retryAfter; if ( - supportsFrontendSnapshotV3 !== - false + shouldTryV3 ) { try { const relicSnapshot = @@ -2104,16 +2145,25 @@ async function fetchPlayerState( ), }); - supportsFrontendSnapshotV3 = + v3Availability.supported = true; + v3Availability.retryAfter = + 0; + lastRelicSnapshotByPlayer.set( + playerCacheKey, + relicSnapshot + ); return playerStateFromFrontendSnapshot( relicSnapshot.base, relicSnapshot ); } catch { - supportsFrontendSnapshotV3 = + v3Availability.supported = false; - // The currently deployed contract remains readable during the V3 rollout. + v3Availability.retryAfter = + runtimeNowMs() + + FRONTEND_SNAPSHOT_V3_RETRY_MS; + // Fall back for this read, then retry V3 after a short cooldown. } } @@ -2178,7 +2228,10 @@ async function fetchPlayerState( } return playerStateFromFrontendSnapshot( - snapshot + snapshot, + lastRelicSnapshotByPlayer.get( + playerCacheKey + ) ); } @@ -8456,62 +8509,24 @@ function DelvewornGame() { )} - {player.supportsRelicCollection && - player.ownedRelics.length > 0 && - player.monsterHp === 0 && + {player.hasStarted && !player.relicOfferAvailable && ( -
-
-
-

- RELIC COLLECTION · {player.ownedRelics.length}/15 UNIQUE · {totalRelicDrops} {totalRelicDrops === 1 ? "DROP" : "DROPS"} -

-

CHOOSE ACTIVE RELIC

-
-

- Switch or unequip between rooms. Duplicate effects do not stack. -

-
-
- {[0, ...player.ownedRelics].map((relicId) => { - const ownedRelic = getRelicDefinition(relicId); - const activeRelic = relicId === player.equippedRelic; - return ( - - ); - })} -
-

- Revive use and Blood Price max-HP costs remain spent for the full run after switching. -

-
+ )} {/* =================================================== diff --git a/frontend/app/practice/page.tsx b/frontend/app/practice/page.tsx index 52cfad9..fa558b0 100644 --- a/frontend/app/practice/page.tsx +++ b/frontend/app/practice/page.tsx @@ -11,6 +11,7 @@ import { GameHud, GoldAmount, RelicArtwork, + RelicCollection, RoomProgressLine, SmallStat, } from "../game-ui"; @@ -42,6 +43,7 @@ import { describeRelicEquipImpact, getRelicDefinition, } from "../relics"; +import { loadPracticeRun, savePracticeRun } from "./storage"; const MAX_POTIONS = 5; const SHOP_POTION_STOCK = 2; @@ -151,6 +153,7 @@ function ShopButton({ export default function PracticePage() { const [game, setGame] = useState(EMPTY_GAME); + const [practiceStorageReady, setPracticeStorageReady] = useState(false); const [rolling, setRolling] = useState(null); const [mobileLogOpen, setMobileLogOpen] = useState(false); const timerRef = useRef(null); @@ -162,6 +165,21 @@ export default function PracticePage() { }; }, []); + useEffect(() => { + const restoreTimer = window.setTimeout(() => { + const restored = loadPracticeRun(window.localStorage); + if (restored) setGame(restored); + setPracticeStorageReady(true); + }, 0); + + return () => window.clearTimeout(restoreTimer); + }, []); + + useEffect(() => { + if (!practiceStorageReady) return; + savePracticeRun(window.localStorage, game); + }, [game, practiceStorageReady]); + const busy = rolling !== null; const room = game.roomsCleared + 1; const roomCleared = game.hasStarted && game.monsterHp === 0; @@ -600,53 +618,19 @@ export default function PracticePage() { )} - {game.ownedRelics.length > 0 && !bossRewardActive && ( -
-
-
-

- RELIC COLLECTION · {game.ownedRelics.length}/15 UNIQUE · {totalRelicDrops} {totalRelicDrops === 1 ? "DROP" : "DROPS"} -

-

CHOOSE ACTIVE RELIC

-
-

Switch or unequip between rooms. One relic can be active at a time.

-
-
- {[0, ...game.ownedRelics].map((relicId) => { - const ownedRelic = getRelicDefinition(relicId); - const activeRelic = relicId === game.equippedRelic; - return ( - - ); - })} -
-

Revive use and permanent max-HP costs remain spent for the full run, even after switching relics.

-
+ {game.hasStarted && !bossRewardActive && ( + + setGame((current) => equipOwnedRelic(current, relicId)) + } + className="mt-4" + /> )} {game.hasStarted && ( diff --git a/frontend/app/practice/storage.ts b/frontend/app/practice/storage.ts new file mode 100644 index 0000000..a294475 --- /dev/null +++ b/frontend/app/practice/storage.ts @@ -0,0 +1,150 @@ +import type { PracticeGame } from "./engine"; + +const PRACTICE_RUN_STORAGE_KEY = "delveworn_practice_run_v1"; +const PRACTICE_RUN_STORAGE_VERSION = 1; + +type PracticeRunStorage = Pick; + +type StoredPracticeRun = { + version: typeof PRACTICE_RUN_STORAGE_VERSION; + game: PracticeGame; +}; + +const NUMBER_FIELDS = [ + "hp", + "maxHp", + "baseMaxHp", + "monsterHp", + "monsterMaxHp", + "roomsCleared", + "gold", + "potions", + "weaponLevel", + "armorLevel", + "monsterType", + "lastLootType", + "lastLootAmount", + "equippedRelic", + "relicOfferRarity", + "relicOfferId", + "lastPlayerDamage", + "lastMonsterDamage", + "combatPotionsUsed", + "campPotionsBought", + "supplyPotionsBought", +] as const satisfies readonly (keyof PracticeGame)[]; + +const BOOLEAN_FIELDS = [ + "hasStarted", + "active", + "relicOfferAvailable", + "relicReviveUsed", + "lastCritical", + "campRestUsed", + "supplyBandageUsed", +] as const satisfies readonly (keyof PracticeGame)[]; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isIntegerInRange( + value: unknown, + minimum: number, + maximum: number +): value is number { + return ( + typeof value === "number" && + Number.isInteger(value) && + value >= minimum && + value <= maximum + ); +} + +function isStoredPracticeGame(value: unknown): value is PracticeGame { + if (!isRecord(value)) return false; + + if (NUMBER_FIELDS.some((field) => !Number.isFinite(value[field]))) { + return false; + } + + if (BOOLEAN_FIELDS.some((field) => typeof value[field] !== "boolean")) { + return false; + } + + if ( + !Array.isArray(value.ownedRelics) || + value.ownedRelics.some( + (relicId) => + !Number.isInteger(relicId) || + relicId < 1 || + relicId > 15 + ) || + new Set(value.ownedRelics).size !== value.ownedRelics.length + ) { + return false; + } + + if ( + !Array.isArray(value.relicCounts) || + value.relicCounts.length !== 16 || + value.relicCounts.some( + (count) => !Number.isInteger(count) || count < 0 + ) + ) { + return false; + } + + if ( + !Array.isArray(value.log) || + value.log.some((entry) => typeof entry !== "string") + ) { + return false; + } + + return ( + isIntegerInRange(value.monsterType, 0, 3) && + isIntegerInRange(value.lastLootType, 0, 4) && + isIntegerInRange(value.relicOfferRarity, 0, 5) && + isIntegerInRange(value.equippedRelic, 0, 15) && + isIntegerInRange(value.relicOfferId, 0, 15) + ); +} + +export function loadPracticeRun( + storage: PracticeRunStorage +): PracticeGame | null { + try { + const serialized = storage.getItem(PRACTICE_RUN_STORAGE_KEY); + if (!serialized) return null; + + const stored: unknown = JSON.parse(serialized); + if ( + !isRecord(stored) || + stored.version !== PRACTICE_RUN_STORAGE_VERSION || + !isStoredPracticeGame(stored.game) + ) { + return null; + } + + return stored.game; + } catch { + return null; + } +} + +export function savePracticeRun( + storage: PracticeRunStorage, + game: PracticeGame +): void { + try { + const stored: StoredPracticeRun = { + version: PRACTICE_RUN_STORAGE_VERSION, + game, + }; + + storage.setItem(PRACTICE_RUN_STORAGE_KEY, JSON.stringify(stored)); + } catch { + // Practice Mode remains playable when browser storage is unavailable. + } +} diff --git a/frontend/app/wallet-connectors.ts b/frontend/app/wallet-connectors.ts index d3f6f0e..3d00f42 100644 --- a/frontend/app/wallet-connectors.ts +++ b/frontend/app/wallet-connectors.ts @@ -1,4 +1,4 @@ -import { metaMask } from "wagmi/connectors"; +import { metaMask } from "wagmi/connectors/metaMask"; import { RiseWallet } from "rise-wallet"; import { riseWallet } from "rise-wallet/wagmi"; import { supportsWallet } from "./chain-runtime";