+
+
+
+ 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 (
+
onSelectRelic(relicId)}
+ disabled={!dataAvailable || !canChangeRelic || activeRelic}
+ aria-pressed={activeRelic}
+ className={`rounded-xl border p-3 text-left transition hover:brightness-125 disabled:cursor-not-allowed ${ownedRelic.borderClass} ${ownedRelic.backgroundClass}${activeRelic ? " ring-2 ring-orange-400" : ""}`}
+ >
+
+
+
+
+
+ {ownedRelic.name}
+ {relicId !== 0 && (
+
+ ×{relicCounts[relicId] ?? 1}
+
+ )}
+
+
+ {actionLabel}
+
+
+
+ {ownedRelic.effect}
+
+ {relicId !== 0 && (
+
+ Tradeoff: {ownedRelic.tradeoff}
+
+ )}
+
+
+
+ );
+ })}
+
+ )}
+
+
+ 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 && (
-