Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 140 additions & 1 deletion frontend/app/game-ui.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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 (
<section
aria-labelledby={titleId}
className={`relic-collection rounded-2xl border border-zinc-700 bg-zinc-950 p-4 ${className}`}
>
<div className="flex items-end justify-between gap-4">
<div>
<p className="text-[10px] tracking-[0.25em] text-orange-400">
RELIC INVENTORY · {ownedRelics.length}/15 UNIQUE · {totalRelicDrops}{" "}
{totalRelicDrops === 1 ? "DROP" : "DROPS"}
</p>
<h2 id={titleId} className="mt-1 text-xl font-black">
RELIC LOADOUT
</h2>
</div>
<p className="max-w-52 text-right text-[10px] text-zinc-500">
Always visible during the run. Switch or unequip between rooms.
</p>
</div>

{!dataAvailable && (
<div className="mt-3 rounded-xl border border-amber-800/70 bg-amber-950/25 px-3 py-2 text-left">
<p className="text-[10px] font-bold text-amber-200">
Collection data is temporarily unavailable. Onchain state will retry automatically on the next sync.
</p>
</div>
)}

{ownedRelics.length === 0 ? (
<div className="mt-3 rounded-xl border border-dashed border-zinc-700 bg-black/25 p-4 text-center">
<p className="text-sm font-black text-zinc-300">
{dataAvailable ? "NO RELICS COLLECTED" : "RELIC INVENTORY SYNCING"}
</p>
<p className="mt-1 text-[10px] text-zinc-500">
{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."}
</p>
</div>
) : (
<div className="mt-3 grid gap-2 lg:grid-cols-3">
{[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 (
<button
key={relicId}
type="button"
onClick={() => 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" : ""}`}
>
<div className="flex items-start gap-3">
<RelicArtwork
imageSrc={ownedRelic.imageSrc}
name={ownedRelic.name}
className="h-12 w-12"
/>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-3">
<p className={`font-black ${ownedRelic.accentClass}`}>
{ownedRelic.name}
{relicId !== 0 && (
<span className="ml-1 text-xs text-zinc-400">
×{relicCounts[relicId] ?? 1}
</span>
)}
</p>
<span
className={
activeRelic
? "text-[9px] font-black text-orange-400"
: "text-[9px] font-bold text-zinc-500"
}
>
{actionLabel}
</span>
</div>
<p className="mt-1 text-[10px] text-zinc-400">
{ownedRelic.effect}
</p>
{relicId !== 0 && (
<p className="mt-2 text-[10px] font-bold text-red-300">
Tradeoff: {ownedRelic.tradeoff}
</p>
)}
</div>
</div>
</button>
);
})}
</div>
)}

<p className="mt-3 text-[10px] text-zinc-600">
Duplicate effects do not stack. Revive use and permanent max-HP costs remain spent for the full run.
</p>
</section>
);
}

function BossRelicCard({
relic,
label,
Expand Down
143 changes: 79 additions & 64 deletions frontend/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
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";
Expand All @@ -47,6 +47,7 @@
GameHud,
GoldAmount,
RelicArtwork,
RelicCollection,
RoomProgressLine,
SmallStat,
} from "./game-ui";
Expand Down Expand Up @@ -88,6 +89,7 @@
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;
Expand Down Expand Up @@ -877,8 +879,37 @@
},
] 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
>();



Expand Down Expand Up @@ -1813,8 +1844,8 @@
*/

function playerStateFromFrontendSnapshot(
snapshot: any,

Check warning on line 1847 in frontend/app/page.tsx

View workflow job for this annotation

GitHub Actions / Next.js checks

Unexpected any. Specify a different type

Check warning on line 1847 in frontend/app/page.tsx

View workflow job for this annotation

GitHub Actions / Next.js checks

Unexpected any. Specify a different type
relicSnapshot?: any

Check warning on line 1848 in frontend/app/page.tsx

View workflow job for this annotation

GitHub Actions / Next.js checks

Unexpected any. Specify a different type

Check warning on line 1848 in frontend/app/page.tsx

View workflow job for this annotation

GitHub Actions / Next.js checks

Unexpected any. Specify a different type
) {
const ownedRelicsMask =
Number(
Expand Down Expand Up @@ -2072,10 +2103,20 @@
: 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 =
Expand Down Expand Up @@ -2104,16 +2145,25 @@
),
});

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.
}
}

Expand Down Expand Up @@ -2178,7 +2228,10 @@
}

return playerStateFromFrontendSnapshot(
snapshot
snapshot,
lastRelicSnapshotByPlayer.get(
playerCacheKey
)
);
}

Expand Down Expand Up @@ -2297,7 +2350,7 @@
return result;
}

function buildRandomNumbers(

Check warning on line 2353 in frontend/app/page.tsx

View workflow job for this annotation

GitHub Actions / Next.js checks

'buildRandomNumbers' is defined but never used

Check warning on line 2353 in frontend/app/page.tsx

View workflow job for this annotation

GitHub Actions / Next.js checks

'buildRandomNumbers' is defined but never used
requestKind: number
) {
let count = 1;
Expand Down Expand Up @@ -3416,7 +3469,7 @@
);
}

async function waitForCanonicalReceipt(

Check warning on line 3472 in frontend/app/page.tsx

View workflow job for this annotation

GitHub Actions / Next.js checks

'waitForCanonicalReceipt' is defined but never used

Check warning on line 3472 in frontend/app/page.tsx

View workflow job for this annotation

GitHub Actions / Next.js checks

'waitForCanonicalReceipt' is defined but never used
hash:
`0x${string}`
) {
Expand Down Expand Up @@ -3709,7 +3762,7 @@
return () => {
unwatchShreds();
unwatchHttpEvents();
vrfResultsRef.current.clear();

Check warning on line 3765 in frontend/app/page.tsx

View workflow job for this annotation

GitHub Actions / Next.js checks

The ref value 'vrfResultsRef.current' will likely have changed by the time this effect cleanup function runs. If this ref points to a node rendered by React, copy 'vrfResultsRef.current' to a variable inside the effect, and use that variable in the cleanup function

Check warning on line 3765 in frontend/app/page.tsx

View workflow job for this annotation

GitHub Actions / Next.js checks

The ref value 'vrfResultsRef.current' will likely have changed by the time this effect cleanup function runs. If this ref points to a node rendered by React, copy 'vrfResultsRef.current' to a variable inside the effect, and use that variable in the cleanup function
};
}, [
connectedAddress,
Expand Down Expand Up @@ -4375,7 +4428,7 @@
}
}

function extractRandomnessRequestId(

Check warning on line 4431 in frontend/app/page.tsx

View workflow job for this annotation

GitHub Actions / Next.js checks

'extractRandomnessRequestId' is defined but never used

Check warning on line 4431 in frontend/app/page.tsx

View workflow job for this annotation

GitHub Actions / Next.js checks

'extractRandomnessRequestId' is defined but never used
receipt:
Awaited<
ReturnType<
Expand Down Expand Up @@ -4986,7 +5039,7 @@
request: (args: {
method: string;
params?: unknown[];
}) => Promise<any>;

Check warning on line 5042 in frontend/app/page.tsx

View workflow job for this annotation

GitHub Actions / Next.js checks

Unexpected any. Specify a different type

Check warning on line 5042 in frontend/app/page.tsx

View workflow job for this annotation

GitHub Actions / Next.js checks

Unexpected any. Specify a different type
},
bundleId: string
) {
Expand Down Expand Up @@ -5128,7 +5181,7 @@
unknown[];
}
) =>
Promise<any>;

Check warning on line 5184 in frontend/app/page.tsx

View workflow job for this annotation

GitHub Actions / Next.js checks

Unexpected any. Specify a different type

Check warning on line 5184 in frontend/app/page.tsx

View workflow job for this annotation

GitHub Actions / Next.js checks

Unexpected any. Specify a different type
};

timingLog(
Expand All @@ -5143,7 +5196,7 @@
functionName,

args,
} as any);

Check warning on line 5199 in frontend/app/page.tsx

View workflow job for this annotation

GitHub Actions / Next.js checks

Unexpected any. Specify a different type

Check warning on line 5199 in frontend/app/page.tsx

View workflow job for this annotation

GitHub Actions / Next.js checks

Unexpected any. Specify a different type

let lastError:
unknown = null;
Expand Down Expand Up @@ -8456,62 +8509,24 @@
</div>
)}

{player.supportsRelicCollection &&
player.ownedRelics.length > 0 &&
player.monsterHp === 0 &&
{player.hasStarted &&
!player.relicOfferAvailable && (
<section className="mb-4 rounded-2xl border border-zinc-700 bg-zinc-950 p-4">
<div className="flex items-end justify-between gap-4">
<div>
<p className="text-[10px] tracking-[0.25em] text-orange-400">
RELIC COLLECTION · {player.ownedRelics.length}/15 UNIQUE · {totalRelicDrops} {totalRelicDrops === 1 ? "DROP" : "DROPS"}
</p>
<h2 className="mt-1 text-xl font-black">CHOOSE ACTIVE RELIC</h2>
</div>
<p className="max-w-48 text-right text-[10px] text-zinc-500">
Switch or unequip between rooms. Duplicate effects do not stack.
</p>
</div>
<div className="mt-3 grid gap-2 lg:grid-cols-3">
{[0, ...player.ownedRelics].map((relicId) => {
const ownedRelic = getRelicDefinition(relicId);
const activeRelic = relicId === player.equippedRelic;
return (
<button
key={relicId}
onClick={() => runEquipRelicTransaction(relicId)}
disabled={busy || 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" : ""}`}
>
<div className="flex items-start gap-3">
<RelicArtwork imageSrc={ownedRelic.imageSrc} name={ownedRelic.name} className="h-12 w-12" />
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-3">
<p className={`font-black ${ownedRelic.accentClass}`}>
{ownedRelic.name}
{relicId !== 0 && (
<span className="ml-1 text-xs text-zinc-400">×{player.relicCounts[relicId] ?? 1}</span>
)}
</p>
<span className={activeRelic ? "text-[9px] font-black text-orange-400" : "text-[9px] font-bold text-zinc-500"}>
{activeRelic ? "ACTIVE" : relicId === 0 ? "UNEQUIP" : "EQUIP"}
</span>
</div>
<p className="mt-1 text-[10px] text-zinc-400">{ownedRelic.effect}</p>
{relicId !== 0 && (
<p className="mt-2 text-[10px] font-bold text-red-300">Tradeoff: {ownedRelic.tradeoff}</p>
)}
</div>
</div>
</button>
);
})}
</div>
<p className="mt-3 text-[10px] text-zinc-600">
Revive use and Blood Price max-HP costs remain spent for the full run after switching.
</p>
</section>
<RelicCollection
idPrefix="onchain"
ownedRelics={player.ownedRelics}
relicCounts={player.relicCounts}
equippedRelic={player.equippedRelic}
canChangeRelic={
player.supportsRelicCollection &&
player.active &&
player.monsterHp === 0 &&
!busy
}
dataAvailable={player.supportsRelicCollection}
lockedLabel={player.active ? "BETWEEN ROOMS" : "RUN ENDED"}
onSelectRelic={runEquipRelicTransaction}
className="mb-4"
/>
)}

{/* ===================================================
Expand Down
Loading
Loading