1
0
Fork 0

Compare commits

..

No commits in common. "5df186d52d5a13b17f4776b82eb3a39d0ef9f67d" and "559ee64258c98e32a6ba3253227bef5d665e2ee2" have entirely different histories.

51 changed files with 549 additions and 32129 deletions

View file

@ -8,17 +8,17 @@ Polaris is a dark operations console: focused, quiet, and information-dense with
### Palette
| Role | Token | Usage |
| --------------------- | --------------------------------------- | ------------------------------------ |
| Surface / page | `bg-background` | Frontend page background |
| Surface / card | `bg-card` | Elevated content sections |
| Text / primary | `text-foreground` | Headings and primary labels |
| Text / secondary | `text-muted-foreground` | Descriptions and supporting copy |
| Role | Token | Usage |
|------|-------|-------|
| Surface / page | `bg-background` | Frontend page background |
| Surface / card | `bg-card` | Elevated content sections |
| Text / primary | `text-foreground` | Headings and primary labels |
| Text / secondary | `text-muted-foreground` | Descriptions and supporting copy |
| Interactive / primary | `bg-primary`, `text-primary-foreground` | Primary actions and checked controls |
| Interactive / focus | `ring-ring` | Keyboard focus indicators |
| Border | `border-border` | Card and control boundaries |
| Status / error | `text-destructive` | Inline validation and failure copy |
| Status / success | `text-emerald-500` | Existing save confirmations |
| Interactive / focus | `ring-ring` | Keyboard focus indicators |
| Border | `border-border` | Card and control boundaries |
| Status / error | `text-destructive` | Inline validation and failure copy |
| Status / success | `text-emerald-500` | Existing save confirmations |
### Rules
@ -29,13 +29,13 @@ Polaris is a dark operations console: focused, quiet, and information-dense with
### Scale
| Level | Token | Usage |
| ---------- | ------------------------------- | ---------------------------- |
| Page title | `text-lg font-semibold` | Route heading |
| Card title | `font-semibold` | Section heading |
| Body | inherited `text-base` | Primary content |
| Level | Token | Usage |
|-------|-------|-------|
| Page title | `text-lg font-semibold` | Route heading |
| Card title | `font-semibold` | Section heading |
| Body | inherited `text-base` | Primary content |
| Supporting | `text-sm text-muted-foreground` | Descriptions and helper text |
| Compact | `text-xs` | Secondary metadata only |
| Compact | `text-xs` | Secondary metadata only |
### Font Stack
@ -67,16 +67,6 @@ Spacing follows Tailwind's 4px scale. Existing frontend stacks use `gap-1`, `gap
- **Motion**: no section-level motion.
- **Layout**: vertical stack.
### Poker table surface
- **Purpose**: The poker table is a diegetic game surface. The velvet felt carries the board, the dealer station, opponent arc, and the player's action console without floating control overlays.
- **Material**: Preserve the existing velvet felt gradient, dark inset rim, and restrained gold trim. The action console is a darker inset panel with the same trim language, while chips use dark, clay, and gold-trim variants for action hierarchy.
- **Primitives**: `poker-felt-table`, `poker-dealer-station`, `poker-seat-arc-*`, `poker-action-console`, `poker-hand-placard`, and `poker-chip`.
- **States**: betting and waiting console states, acting-seat highlight, folded and eliminated players, showdown result, game-complete placement, open and occupied waiting seats, and reduced-motion deal presentation.
- **Accessibility**: The cosmetic dealer station is labelled as a station and is not included in player or seat data. Action controls remain native buttons and inputs with visible focus states. Card labels continue to describe face-up and face-down cards.
- **Motion**: Cards originate visually at the dealer shoe using transform and opacity with a 60ms stagger. Reduced motion removes the deal animation while retaining static card tilts and highlight states.
- **Layout**: The felt is a flat-bottom semicircle. Opponents occupy count-aware upper arc slots, the dealer is top-center, community cards sit in the middle, and the user's console is pinned to the flat bottom edge. Fullscreen increases felt depth without moving the console out of the viewport canvas.
### Locker inventory surface
- **Purpose**: The authenticated locker is a Polaris operations surface: a dense, readable inventory rather than a literal game replica. The grid is the focal region and selection context is kept adjacent to it.
@ -167,9 +157,9 @@ Spacing follows Tailwind's 4px scale. Existing frontend stacks use `gap-1`, `gap
### Timing
| Type | Duration | Usage |
| -------- | --------- | ---------------------------------- |
| Micro | 100–150ms | Switch and radio state feedback |
| Type | Duration | Usage |
|------|----------|-------|
| Micro | 100–150ms | Switch and radio state feedback |
| Standard | 200–300ms | Button hover and focus transitions |
### Rules
@ -192,6 +182,6 @@ The frontend uses a mixed strategy: card borders plus the existing shadcn card s
### Accepted Debt
| Item | Location | Why accepted | Owner / Exit |
| ---------------------------------------------- | ------------- | -------------------------------------------------------------- | --------------------------------------------------------------- |
| Item | Location | Why accepted | Owner / Exit |
|------|----------|--------------|--------------|
| Existing success color uses `text-emerald-500` | Account forms | Preserve established feedback styling in this focused redesign | Consolidate account feedback tokens in a future account UI pass |

View file

@ -1,6 +1,6 @@
{
"name": "polaris-task-force",
"version": "0.2.35",
"version": "0.2.34",
"description": "Web app for the Arma 3 unit Polaris Task Force. Uses Next.js for client- and server-side rendering, with a Postgres database and some other neat libraries.",
"license": "MIT",
"type": "module",

View file

@ -9,13 +9,11 @@ import { EventTypes } from "@/utils/event-log/eventTypes";
import {
countGrantsByGrantor,
findUserProfile,
getAwardsByIds,
getGrantorAssignments,
heldTierOfSeries,
isRecipientEligible,
} from "@/lib/awards/grant";
import { cleanupDuplicateAwards, type CleanupResult } from "@/lib/awards/index";
import type { Assignment, Award, Profile } from "@/payload-types";
import type { Assignment, Profile } from "@/payload-types";
interface ActionResult<T = undefined> {
success: boolean;
@ -109,6 +107,12 @@ export async function grantAward(input: {
}
const issued = await countGrantsByGrantor(payload, award.id, grantorProfile.id);
remaining = maxGrants - issued;
if (remaining <= 0) {
return {
success: false,
error: "You have reached the maximum number of grants for this award.",
};
}
}
}
@ -124,6 +128,8 @@ export async function grantAward(input: {
let processed = 0;
for (const recipientUserId of input.recipientUserIds) {
if (remaining !== null && processed >= remaining) break;
if (grantorAssignmentId !== undefined) {
const eligible = await isRecipientEligible(
payload,
@ -157,150 +163,20 @@ export async function grantAward(input: {
const progression = recipientProfile.progression;
const currentAwards = progression?.awards ?? [];
// Handle advancement series logic for ribbons with advancement
if (
award.advancement?.seriesKey &&
award.advancement?.tier !== undefined &&
award.advancement?.tier !== null
) {
// Get all the awards held by this recipient
const heldAwardIds = currentAwards
.map((entry) => {
const awardId = typeof entry.award === "number" ? entry.award : entry.award?.id;
return awardId;
})
.filter((id): id is number => id !== undefined);
// Fetch all the held awards in one batch to check their advancement series
let heldAwards: Award[] = [];
if (heldAwardIds.length > 0) {
heldAwards = await getAwardsByIds(payload, heldAwardIds);
}
// Find the recipient's held entry in this series, if any
let heldSeries: {
entry: (typeof currentAwards)[number];
heldAward: Award;
tier: number;
} | null = null;
for (const entry of currentAwards) {
const awardId = typeof entry.award === "number" ? entry.award : entry.award?.id;
const heldAward = heldAwards.find((a) => a.id === awardId);
if (!heldAward) continue;
const tier = heldTierOfSeries(heldAward, award.advancement.seriesKey);
if (tier !== null) {
heldSeries = { entry, heldAward, tier };
break;
}
}
// If the recipient holds an entry in this series, enforce tier order
// or upgrade it in place (preserving the original award date)
if (heldSeries) {
const targetTier = award.advancement.tier;
if (heldSeries.tier === targetTier) {
results.push({
recipientUserId,
success: false,
error: "Recipient already has this award.",
});
processed += 1;
continue;
}
if (heldSeries.tier > targetTier) {
results.push({
recipientUserId,
success: false,
error: "Recipient already holds a higher tier of this ribbon.",
});
processed += 1;
continue;
}
const upgradedEntry = {
award: award.id,
awardedBy: grantorProfile.id,
awardedAt: heldSeries.entry.awardedAt,
awardReason: input.reason?.trim()
? input.reason.trim()
: (heldSeries.entry.awardReason ?? null),
upgradedFrom: heldSeries.heldAward.id,
upgradedAt: new Date().toISOString(),
};
const updatedAwards = [...currentAwards];
const heldAwardId = heldSeries.heldAward.id;
const entryIndex = updatedAwards.findIndex((e) => {
const id = typeof e.award === "number" ? e.award : e.award?.id;
return id === heldAwardId;
});
if (entryIndex === -1) {
throw new Error("Could not locate the recipient's held ribbon entry for the upgrade.");
}
updatedAwards[entryIndex] = upgradedEntry;
await payload.update({
collection: "profiles",
id: recipientProfile.id,
data: {
progression: {
...progression,
awards: updatedAwards,
},
},
overrideAccess: true,
});
const recipientUserDoc =
typeof recipientProfile.user === "object"
? recipientProfile.user
: await payload.findByID({
collection: "users",
id: recipientProfile.user,
depth: 0,
overrideAccess: true,
});
const recipientName = recipientUserDoc.displayName ?? recipientUserDoc.username;
await emitGameEvent(payload, {
type: EventTypes.AwardUpgrade,
message: `${user.displayName ?? user.username} upgraded ${recipientName}'s ribbon to ${award.name}`,
actor: user.id,
data: {
awardId: award.id,
grantorProfileId: grantorProfile.id,
recipientUserId,
fromAwardId: heldAwardId,
toAwardId: award.id,
seriesKey: award.advancement.seriesKey ?? null,
tier: award.advancement.tier ?? null,
},
});
results.push({ recipientUserId, success: true });
granted += 1;
// Upgrades are exempt from remaining-grant accounting
continue;
}
} else {
// No advancement series - check if recipient already has this award
const alreadyHasAward = currentAwards.some((e) => {
const id = typeof e.award === "number" ? e.award : e.award?.id;
return id === award.id;
const alreadyHasAward = currentAwards.some((e) => {
const id = typeof e.award === "number" ? e.award : e.award?.id;
return id === award.id;
});
if (alreadyHasAward) {
results.push({
recipientUserId,
success: false,
error: "Recipient already has this award.",
});
if (alreadyHasAward) {
results.push({
recipientUserId,
success: false,
error: "Recipient already has this award.",
});
processed += 1;
continue;
}
processed += 1;
continue;
}
// Normal grant (no series advancement)
await payload.update({
collection: "profiles",
id: recipientProfile.id,
@ -345,7 +221,9 @@ export async function grantAward(input: {
}
}
export async function cleanupDuplicateAwardsAction(): Promise<ActionResult<CleanupResult>> {
export async function cleanupDuplicateAwardsAction(): Promise<
ActionResult<CleanupResult>
> {
try {
const { payload, user } = await authenticate();
const superuser = await isSuperuser(payload, user);

View file

@ -15,7 +15,7 @@ interface ActionResult<T = undefined> {
data?: T;
}
const MAX_PENDING_RIBBON_SUBMISSIONS = 15;
const MAX_PENDING_RIBBON_SUBMISSIONS = 3;
async function authenticate() {
const payloadConfig = await config;
@ -90,10 +90,7 @@ export async function submitRibbonDesign(input: {
overrideAccess: true,
});
if (pendingCount.totalDocs >= MAX_PENDING_RIBBON_SUBMISSIONS) {
return {
success: false,
error: `You already have ${MAX_PENDING_RIBBON_SUBMISSIONS} pending ribbon submissions. Wait for them to be reviewed first.`,
};
return { success: false, error: "You already have 3 pending ribbon submissions. Wait for them to be reviewed first." };
}
const submission = await payload.create({

View file

@ -21,67 +21,6 @@ const TYPE_LABEL: Record<"ribbon" | "medal", string> = {
medal: "Medals",
};
type AwardCatalogItem =
| { readonly kind: "award"; readonly award: Award }
| {
readonly kind: "advancement";
readonly seriesKey: string;
readonly familyName: string;
};
function buildRibbonCatalogItems(ribbons: Award[]): AwardCatalogItem[] {
const awardsBySeries = new Map<string, Award[]>();
for (const award of ribbons) {
const seriesKey = award.advancement?.seriesKey;
if (seriesKey == null) continue;
const seriesAwards = awardsBySeries.get(seriesKey);
if (seriesAwards) seriesAwards.push(award);
else awardsBySeries.set(seriesKey, [award]);
}
const groupedSeriesKeys = new Set(
[...awardsBySeries.entries()]
.filter(([, seriesAwards]) => seriesAwards.length > 1)
.map(([seriesKey]) => seriesKey),
);
const emittedSeriesKeys = new Set<string>();
const items: AwardCatalogItem[] = [];
for (const award of ribbons) {
const seriesKey = award.advancement?.seriesKey;
if (seriesKey == null || !groupedSeriesKeys.has(seriesKey)) {
items.push({ kind: "award", award });
continue;
}
if (emittedSeriesKeys.has(seriesKey)) continue;
const seriesAwards = [...(awardsBySeries.get(seriesKey) ?? [])].sort((first, second) => {
const tierDifference =
(first.advancement?.tier ?? Number.POSITIVE_INFINITY) -
(second.advancement?.tier ?? Number.POSITIVE_INFINITY);
if (tierDifference !== 0) return tierDifference;
if (first.name !== second.name) return first.name < second.name ? -1 : 1;
return first.id - second.id;
});
const familyAward =
seriesAwards.find((seriesAward) => seriesAward.advancement?.tier === 1) ?? seriesAwards[0];
if (!familyAward) continue;
items.push({
kind: "advancement",
seriesKey,
familyName: familyAward.name,
});
for (const seriesAward of seriesAwards) {
items.push({ kind: "award", award: seriesAward });
}
emittedSeriesKeys.add(seriesKey);
}
return items;
}
export default async function AwardsPage() {
const headers = await nextHeaders();
const payloadConfig = await config;
@ -115,19 +54,9 @@ export default async function AwardsPage() {
const ribbons = awards.filter((a) => a.type === "ribbon");
const medals = awards.filter((a) => a.type === "medal");
const catalogSections: { title: string; items: AwardCatalogItem[] }[] = [];
if (medals.length > 0) {
catalogSections.push({
title: TYPE_LABEL.medal,
items: medals.map((award) => ({ kind: "award", award })),
});
}
if (ribbons.length > 0) {
catalogSections.push({
title: TYPE_LABEL.ribbon,
items: buildRibbonCatalogItems(ribbons),
});
}
const sections: { title: string; items: Award[] }[] = [];
if (medals.length > 0) sections.push({ title: TYPE_LABEL.medal, items: medals });
if (ribbons.length > 0) sections.push({ title: TYPE_LABEL.ribbon, items: ribbons });
return (
<div className="p-5 flex flex-col gap-6 max-w-7xl">
@ -167,48 +96,27 @@ export default async function AwardsPage() {
</ItemContent>
</Item>
) : (
catalogSections.map((section) => (
sections.map((section) => (
<section key={section.title} className="flex flex-col gap-3">
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
{section.title}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{section.items.map((item) => {
switch (item.kind) {
case "advancement":
return (
<div
key={`advancement-${item.seriesKey}`}
className="col-span-full flex items-center gap-2 border-b border-border pb-1 text-xs font-medium text-muted-foreground"
>
<span className="shrink-0">Advancement: {item.familyName}</span>
<div className="h-px flex-1 bg-border" aria-hidden />
</div>
);
case "award": {
const { award } = item;
const awardImage =
typeof award.image === "object" && award.image
? (award.image as Media)
: null;
const backgroundImage =
typeof award.backgroundImage === "object" && award.backgroundImage
? (award.backgroundImage as Media)
: null;
return (
<AwardWithGrantButton
key={award.id}
award={award}
awardImage={awardImage}
backgroundImage={backgroundImage}
/>
);
}
default: {
const exhaustiveItem: never = item;
return exhaustiveItem;
}
}
{section.items.map((award) => {
const awardImage =
typeof award.image === "object" && award.image ? (award.image as Media) : null;
const backgroundImage =
typeof award.backgroundImage === "object" && award.backgroundImage
? (award.backgroundImage as Media)
: null;
return (
<AwardWithGrantButton
key={award.id}
award={award}
awardImage={awardImage}
backgroundImage={backgroundImage}
/>
);
})}
</div>
</section>

View file

@ -3,7 +3,7 @@ import { getPayload } from "payload";
import { notFound } from "next/navigation";
import { headers as nextHeaders } from "next/headers";
import Link from "next/link";
import type { Award, Assignment, Media, Profile, Qualification, Rank, User } from "@/payload-types";
import type { Award, Assignment, Media, Qualification, Rank, User } from "@/payload-types";
import { ClientDate } from "@/components/frontend/ClientDate";
import { resolveLevel } from "@/utils/xp/resolveLevel";
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
@ -53,104 +53,6 @@ interface ProfilePageProps {
}
type Payload = Awaited<ReturnType<typeof getPayload>>;
type ProfileAwardEntry = NonNullable<NonNullable<Profile["progression"]>["awards"]>[number];
type HeldAwardEntry = {
readonly award: Award;
readonly entry: ProfileAwardEntry;
};
type NextTier = {
readonly id: number;
readonly name: string;
readonly tier: number;
};
function awardIdFromReference(reference: number | Award | null | undefined): number | null {
if (typeof reference === "number") return reference;
return reference?.id ?? null;
}
async function findNextTierByAwardId(
payload: Payload,
heldAwards: readonly Award[],
): Promise<Record<number, NextTier>> {
const heldTierBySeries = new Map<string, number>();
for (const award of heldAwards) {
if (award.type !== "ribbon") continue;
const seriesKey = award.advancement?.seriesKey;
const tier = award.advancement?.tier;
if (seriesKey == null || tier == null) continue;
heldTierBySeries.set(seriesKey, Math.max(heldTierBySeries.get(seriesKey) ?? tier, tier));
}
const nextTierResults = await Promise.all(
[...heldTierBySeries.entries()].map(async ([seriesKey, heldTier]) => {
const nextTierRes = await payload.find({
collection: "awards",
where: {
and: [
{ "advancement.seriesKey": { equals: seriesKey } },
{ "advancement.tier": { greater_than: heldTier } },
{ type: { equals: "ribbon" } },
],
},
sort: "advancement.tier",
limit: 1,
depth: 0,
overrideAccess: true,
});
const nextAward = nextTierRes.docs[0];
const tier = nextAward?.advancement?.tier;
return nextAward && tier != null
? {
seriesKey,
nextTier: { id: nextAward.id, name: nextAward.name, tier },
}
: null;
}),
);
const nextTierBySeries = new Map<string, NextTier>();
for (const result of nextTierResults) {
if (result) nextTierBySeries.set(result.seriesKey, result.nextTier);
}
const nextTierByAwardId: Record<number, NextTier> = {};
for (const award of heldAwards) {
if (award.type !== "ribbon") continue;
const seriesKey = award.advancement?.seriesKey;
const tier = award.advancement?.tier;
if (seriesKey == null || tier == null) continue;
const nextTier = nextTierBySeries.get(seriesKey);
if (nextTier && nextTier.tier > tier) nextTierByAwardId[award.id] = nextTier;
}
return nextTierByAwardId;
}
async function findUpgradedFromNames(
payload: Payload,
entries: readonly ProfileAwardEntry[],
): Promise<Record<number, string>> {
const upgradedFromIds = [
...new Set(
entries
.map((entry) => awardIdFromReference(entry.upgradedFrom))
.filter((id): id is number => id !== null),
),
];
if (upgradedFromIds.length === 0) return {};
const awardsRes = await payload.find({
collection: "awards",
where: { id: { in: upgradedFromIds } },
limit: upgradedFromIds.length,
depth: 0,
overrideAccess: true,
});
const upgradedFromNames: Record<number, string> = {};
for (const award of awardsRes.docs) upgradedFromNames[award.id] = award.name;
return upgradedFromNames;
}
async function findEligibleEvaluationMissions(
payload: Payload,
@ -305,19 +207,14 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
const canEditIntelPromise: Promise<boolean> = viewer
? hasPermission(payload, viewer, "profiles:intel_excerpt:update")
: Promise.resolve(false);
const [
evaluationSummaryResult,
eligibleMissions,
isSuperuserViewer,
isDirectLeader,
canEditIntel,
] = await Promise.all([
evaluationSummaryPromise,
eligibleMissionsPromise,
privilegedViewerPromise,
directLeaderPromise,
canEditIntelPromise,
]);
const [evaluationSummaryResult, eligibleMissions, isSuperuserViewer, isDirectLeader, canEditIntel] =
await Promise.all([
evaluationSummaryPromise,
eligibleMissionsPromise,
privilegedViewerPromise,
directLeaderPromise,
canEditIntelPromise,
]);
const canViewSubordinate =
isProfileOwner || isLegacyPrivileged || isSuperuserViewer || isDirectLeader;
const latestMissionRows =
@ -361,18 +258,12 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
: null;
const canSubmitEvaluation = viewer !== null && !isProfileOwner;
const rank = profile.rank as Rank;
const awardEntries = (profile.progression?.awards ?? []).filter(
(entry): entry is ProfileAwardEntry => typeof entry === "object" && entry !== null,
);
const heldAwardEntries: HeldAwardEntry[] = awardEntries.flatMap((entry) => {
const awards = (profile.progression?.awards ?? []).flatMap((entry) => {
if (typeof entry !== "object" || entry === null) return [];
const award = entry.award;
return typeof award === "object" && award !== null ? [{ award, entry }] : [];
return typeof award === "object" && award !== null ? [award] : [];
});
const awards = heldAwardEntries.map(({ award }) => award);
const [nextTierByAwardId, upgradedFromNames] = await Promise.all([
findNextTierByAwardId(payload, awards),
findUpgradedFromNames(payload, awardEntries),
]);
const qualifications = (profile.progression?.qualifications ?? []).filter(
(q): q is Qualification => typeof q === "object" && q !== null,
);
@ -487,12 +378,12 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
</ItemMedia>
<ItemContent>
<ItemTitle>Enlisted</ItemTitle>
<ItemDescription>
<ClientDate
date={profile.dossier.enlistmentDate}
options={{ year: "numeric", month: "short", day: "numeric" }}
/>
</ItemDescription>
<ItemDescription>
<ClientDate
date={profile.dossier.enlistmentDate}
options={{ year: "numeric", month: "short", day: "numeric" }}
/>
</ItemDescription>
</ItemContent>
</Item>
<Item variant="muted" size="sm">
@ -598,13 +489,7 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
</section>
{grantableAwards.length > 0 && (
<ProfileGrantAwardButton
userId={user.id}
username={username}
awards={grantableAwards}
heldAwards={awards}
nextTierByAwardId={nextTierByAwardId}
/>
<ProfileGrantAwardButton userId={user.id} username={username} awards={grantableAwards} />
)}
{/* ── Combat Record ──────────────────────────────────── */}
@ -703,35 +588,28 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
</div>
{awards.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{[...heldAwardEntries]
.sort(({ award: first }, { award: second }) => {
{[...awards]
.sort((a, b) => {
const typeOrder = { medal: 0, ribbon: 1, commendation: 2 } as const;
const categoryOrder =
(typeOrder[first.type as keyof typeof typeOrder] ?? typeOrder.commendation) -
(typeOrder[second.type as keyof typeof typeOrder] ?? typeOrder.commendation);
(typeOrder[a.type as keyof typeof typeOrder] ?? typeOrder.commendation) -
(typeOrder[b.type as keyof typeof typeOrder] ?? typeOrder.commendation);
return (
categoryOrder || (second.experiencePoints ?? 0) - (first.experiencePoints ?? 0)
);
return categoryOrder || (b.experiencePoints ?? 0) - (a.experiencePoints ?? 0);
})
.map(({ award, entry }) => {
.map((award) => {
const awardImage =
award.image && typeof award.image === "object" ? (award.image as Media) : null;
const backgroundImage =
award.backgroundImage && typeof award.backgroundImage === "object"
? (award.backgroundImage as Media)
: null;
const upgradedFromId = awardIdFromReference(entry.upgradedFrom);
return (
<AwardCard
key={entry.id ?? `${award.id}:${entry.awardedAt}`}
key={award.id}
awardImage={awardImage}
backgroundImage={backgroundImage}
award={award}
upgradedFromName={
upgradedFromId === null ? null : (upgradedFromNames[upgradedFromId] ?? null)
}
upgradedAt={entry.upgradedAt}
/>
);
})}

File diff suppressed because it is too large Load diff

View file

@ -94,33 +94,6 @@ export const Awards: CollectionConfig = {
condition: (data) => data?.type === "medal",
},
},
{
name: "advancement",
type: "group",
label: "Advancement Series",
admin: {
description: "Configure this award as part of an advancement lineup.",
condition: (data) => data?.type === "ribbon",
},
fields: [
{
name: "seriesKey",
type: "text",
admin: {
description:
"Lineup identifier (e.g. years-of-service). Ribbons sharing this key form one advancement family.",
},
},
{
name: "tier",
type: "number",
min: 1,
admin: {
description: "Position in the advancement lineup, starting at 1.",
},
},
],
},
{
name: "grantConfig",
type: "group",
@ -181,56 +154,4 @@ export const Awards: CollectionConfig = {
],
},
],
hooks: {
beforeChange: [
async ({ req, data, operation, originalDoc }) => {
const advancement = data.advancement;
if (
advancement &&
typeof advancement.seriesKey === "string" &&
advancement.seriesKey.trim() !== ""
) {
advancement.seriesKey = advancement.seriesKey.trim().toLowerCase().replace(/\s+/g, "-");
}
const seriesKey = advancement?.seriesKey;
const tier = advancement?.tier;
if (operation === "create" || operation === "update") {
const effectiveType = data.type ?? originalDoc?.type;
if (
(seriesKey !== undefined && tier === undefined) ||
(seriesKey === undefined && tier !== undefined)
) {
throw new Error("Advancement requires both a series key and a tier.");
}
if ((seriesKey !== undefined || tier !== undefined) && effectiveType !== "ribbon") {
throw new Error("Advancement lineups are only supported for ribbons.");
}
if (seriesKey !== undefined && tier !== undefined) {
const result = await req.payload.count({
collection: "awards",
where: {
and: [
{ "advancement.seriesKey": { equals: seriesKey } },
{ "advancement.tier": { equals: tier } },
...(operation === "update" && originalDoc?.id
? [{ id: { not_equals: originalDoc.id } }]
: []),
],
},
overrideAccess: true,
});
if (result.totalDocs > 0) {
throw new Error(`Tier ${tier} of this series already exists.`);
}
}
}
return data;
},
],
},
};

View file

@ -244,21 +244,6 @@ export const Profiles: CollectionConfig = {
description: "An optional reason for the award.",
},
},
{
name: "upgradedFrom",
type: "relationship",
relationTo: "awards",
admin: {
description: "For advanced ribbons: the previous tier this entry replaced.",
},
},
{
name: "upgradedAt",
type: "date",
admin: {
description: "When this entry was upgraded to the current tier.",
},
},
],
},
{

View file

@ -1,10 +1,9 @@
import { ClientDate } from "@/components/frontend/ClientDate";
import { Award, Media } from "@/payload-types";
import { Item, ItemContent, ItemDescription, ItemHeader, ItemTitle } from "@/components/ui/item";
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
import { RibbonViewer } from "@/components/frontend/account/RibbonViewer";
import type { RibbonDesign } from "@/components/awards/ribbonDesign/types";
import { cn } from "@/lib/utils";
import type { Award, Media } from "@/payload-types";
const getExperiencePointsClass = (experiencePoints: number) => {
if (experiencePoints >= 5000) return "award-xp-high";
@ -18,8 +17,6 @@ export default function AwardCard(props: {
awardImage: Media | null;
award: Award;
backgroundImage?: Media | null;
upgradedFromName?: string | null;
upgradedAt?: string | null;
}) {
const isCommendation = props.award.type === "commendation" || !props.award.type;
const commendationColor =
@ -30,10 +27,6 @@ export default function AwardCard(props: {
: null;
const bgUrl = props.backgroundImage?.url;
const upgradeTrail =
props.upgradedFromName && props.upgradedAt
? { name: props.upgradedFromName, at: props.upgradedAt }
: null;
return (
<Item
@ -115,15 +108,6 @@ export default function AwardCard(props: {
+{props.award.experiencePoints} XP
</span>
)}
{upgradeTrail && (
<span className="mt-1 text-xs text-muted-foreground">
Upgraded from {upgradeTrail.name} on{" "}
<ClientDate
date={upgradeTrail.at}
options={{ year: "numeric", month: "short", day: "numeric" }}
/>
</span>
)}
</ItemContent>
</Item>
);

View file

@ -1,6 +1,5 @@
import { Award, Media } from "@/payload-types";
import { Item, ItemContent, ItemDescription, ItemHeader, ItemTitle } from "@/components/ui/item";
import { Badge } from "@/components/ui/badge";
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
import { RibbonViewer } from "@/components/frontend/account/RibbonViewer";
import type { RibbonDesign } from "@/components/awards/ribbonDesign/types";
@ -69,12 +68,7 @@ export function AwardListingCard({
/>
</>
)}
<ItemHeader
className={cn(
"relative z-10 flex flex-col items-center gap-2",
isMedal && "justify-center",
)}
>
<ItemHeader className={cn("relative z-10 flex flex-col items-center gap-2", isMedal && "justify-center")}>
{hasArt && (
<div
className={cn(
@ -101,11 +95,6 @@ export function AwardListingCard({
<span className="text-[9px] uppercase tracking-wider text-muted-foreground font-mono">
{type}
</span>
{award.advancement?.tier != null && (
<Badge variant="outline" className="w-fit text-xs text-muted-foreground">
Tier {award.advancement.tier}
</Badge>
)}
</ItemHeader>
<ItemContent className="relative z-10 flex flex-col gap-2">

View file

@ -5,22 +5,12 @@ import { useRouter } from "next/navigation";
import type { Award } from "@/payload-types";
import { Button } from "@/components/ui/button";
import { AwardPickerDialog } from "./AwardPickerDialog";
import { GrantAwardDialog } from "./GrantAwardDialog";
import { cn } from "@/lib/utils";
import { ArrowUpIcon, AwardIcon, PlusIcon } from "lucide-react";
type NextTier = {
readonly id: number;
readonly name: string;
readonly tier: number;
};
import { AwardIcon, PlusIcon } from "lucide-react";
interface ProfileGrantAwardButtonProps {
readonly userId: number;
readonly username: string;
readonly awards: Award[];
readonly heldAwards?: Award[];
readonly nextTierByAwardId?: Record<number, NextTier>;
userId: number;
username: string;
awards: Award[];
}
/**
@ -28,21 +18,9 @@ interface ProfileGrantAwardButtonProps {
* Eligibility is enforced server-side (getGrantStats / grantAward inside
* GrantAwardDialog) — this component only provides the entry point.
*/
export function ProfileGrantAwardButton({
userId,
username,
awards,
heldAwards = [],
nextTierByAwardId = {},
}: ProfileGrantAwardButtonProps) {
export function ProfileGrantAwardButton({ userId, username, awards }: ProfileGrantAwardButtonProps) {
const [open, setOpen] = useState(false);
const [openUpgradeKey, setOpenUpgradeKey] = useState<string | null>(null);
const router = useRouter();
const upgradeOptions = heldAwards.flatMap((award) => {
if (award.type !== "ribbon") return [];
const nextTier = nextTierByAwardId[award.id];
return nextTier ? [{ heldAwardId: award.id, nextTier }] : [];
});
return (
<section className="flex flex-col gap-3">
@ -53,27 +31,10 @@ export function ProfileGrantAwardButton({
Grant Award
</h2>
</div>
<div className="flex flex-wrap items-center justify-end gap-2">
{upgradeOptions.map(({ heldAwardId, nextTier }) => {
const upgradeKey = `${heldAwardId}:${nextTier.id}`;
return (
<Button
key={upgradeKey}
size="sm"
variant="outline"
className={cn("max-w-full", openUpgradeKey === upgradeKey && "border-primary")}
onClick={() => setOpenUpgradeKey(upgradeKey)}
>
<ArrowUpIcon data-icon="inline-start" />
Upgrade to {nextTier.name}
</Button>
);
})}
<Button size="sm" variant="outline" onClick={() => setOpen(true)}>
<PlusIcon data-icon="inline-start" />
Grant Award
</Button>
</div>
<Button size="sm" variant="outline" onClick={() => setOpen(true)}>
<PlusIcon data-icon="inline-start" />
Grant Award
</Button>
</div>
<AwardPickerDialog
awards={awards}
@ -83,26 +44,6 @@ export function ProfileGrantAwardButton({
onOpenChange={setOpen}
onGranted={() => router.refresh()}
/>
{upgradeOptions.map(({ heldAwardId, nextTier }) => {
const upgradeKey = `${heldAwardId}:${nextTier.id}`;
return (
<GrantAwardDialog
key={upgradeKey}
awardId={nextTier.id}
awardName={nextTier.name}
recipientUserId={userId}
recipientUsername={username}
open={openUpgradeKey === upgradeKey}
onOpenChange={(nextOpen) => {
if (!nextOpen) setOpenUpgradeKey(null);
}}
onGranted={() => {
setOpenUpgradeKey(null);
router.refresh();
}}
/>
);
})}
</section>
);
}

View file

@ -1,62 +1,14 @@
import type { RibbonDesign } from "@/components/awards/ribbonDesign/types";
import { RibbonViewer } from "@/components/frontend/account/RibbonViewer";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import type { Award } from "@/payload-types";
import { Award } from "@/payload-types";
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
function compareAdvancementAwards(candidate: Award, current: Award): number {
const tierDifference =
(candidate.advancement?.tier ?? Number.NEGATIVE_INFINITY) -
(current.advancement?.tier ?? Number.NEGATIVE_INFINITY);
if (tierDifference !== 0) return tierDifference;
const experienceDifference = (candidate.experiencePoints ?? 0) - (current.experiencePoints ?? 0);
if (experienceDifference !== 0) return experienceDifference;
if (candidate.name !== current.name) return candidate.name < current.name ? 1 : -1;
return current.id - candidate.id;
}
export function dedupeAdvancementAwards(awards: Award[]): Award[] {
const seriesCounts = new Map<string, number>();
for (const award of awards) {
const seriesKey = award.advancement?.seriesKey;
if (seriesKey == null) continue;
seriesCounts.set(seriesKey, (seriesCounts.get(seriesKey) ?? 0) + 1);
}
const duplicateSeriesKeys = new Set(
[...seriesCounts.entries()].filter(([, count]) => count > 1).map(([seriesKey]) => seriesKey),
);
if (duplicateSeriesKeys.size === 0) return awards;
const winnerIndexBySeries = new Map<string, number>();
for (let index = 0; index < awards.length; index += 1) {
const award = awards[index];
if (!award) continue;
const seriesKey = award.advancement?.seriesKey;
if (seriesKey == null || !duplicateSeriesKeys.has(seriesKey)) continue;
const currentIndex = winnerIndexBySeries.get(seriesKey);
const currentAward = currentIndex == null ? undefined : awards[currentIndex];
if (currentAward == null || compareAdvancementAwards(award, currentAward) > 0) {
winnerIndexBySeries.set(seriesKey, index);
}
}
return awards.filter((award, index) => {
const seriesKey = award.advancement?.seriesKey;
if (seriesKey == null || !duplicateSeriesKeys.has(seriesKey)) return true;
return winnerIndexBySeries.get(seriesKey) === index;
});
}
export const RibbonRack = ({ awards }: { awards: Award[] }) => {
return (
<div className="flex flex-wrap w-[19.625rem] max-w-full gap-x-1 gap-y-px">
{dedupeAdvancementAwards(
awards.filter((award) => award.type === "ribbon" && award.ribbonDesign != null),
)
{awards
.filter((award) => award.type === "ribbon" && award.ribbonDesign != null)
.sort((a, b) => (b.experiencePoints ?? 0) - (a.experiencePoints ?? 0))
.map((award, key) => {
const ribbon = award.ribbonDesign as RibbonDesign;

View file

@ -1,7 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
import type { PokerActionKind, PokerLegalActions } from "@/lib/minigames/poker";
import { pokerAction, type PokerSnapshot } from "@/app/(frontend)/minigames/poker/actions";
import { Button } from "@/components/ui/button";
@ -15,8 +14,6 @@ interface BettingControlsProps {
readonly potTotal: number;
readonly yourTurn: boolean;
readonly onSnapshot: (snapshot: PokerSnapshot) => void;
readonly onActionFeedback?: () => void;
readonly compact?: boolean;
}
function clampAmount(value: number, min: number, max: number): number {
@ -35,13 +32,10 @@ export function BettingControls({
potTotal,
yourTurn,
onSnapshot,
onActionFeedback,
compact = false,
}: BettingControlsProps) {
const [pendingAction, setPendingAction] = useState<PokerActionKind | null>(null);
const [error, setError] = useState<string | null>(null);
const [amount, setAmount] = useState(legalActions.minBetTo);
const [wagerOpen, setWagerOpen] = useState(false);
const wagerKind: PokerActionKind | null = legalActions.canBet
? "bet"
@ -59,13 +53,8 @@ export function BettingControls({
useEffect(() => {
setPendingAction(null);
setError(null);
setWagerOpen(false);
}, [snapshotVersion]);
useEffect(() => {
if (compact && (!yourTurn || wagerKind === null)) setWagerOpen(false);
}, [compact, wagerKind, yourTurn]);
const submit = async (kind: PokerActionKind, wager?: number) => {
setError(null);
setPendingAction(kind);
@ -76,7 +65,6 @@ export function BettingControls({
setPendingAction(null);
return;
}
if (compact) setWagerOpen(false);
if (result.data) onSnapshot(result.data);
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Could not send the table action.");
@ -96,97 +84,44 @@ export function BettingControls({
setAmount(clampAmount(nextAmount, wagerMin, wagerMax));
};
const submitFromControl = (kind: PokerActionKind, wager?: number) => {
onActionFeedback?.();
void submit(kind, wager);
};
const compactAction = legalActions.canCall
? { kind: "call" as const, label: `Call ${amountLabel(legalActions.callAmount)}` }
: legalActions.canCheck
? { kind: "check" as const, label: "Check" }
: null;
return (
<div
className={cn(
"poker-betting-controls flex flex-col gap-3",
compact && "poker-betting-controls-compact",
)}
>
<div className={cn("flex flex-wrap gap-2", compact && "poker-compact-actions")}>
<div className="flex flex-col gap-3">
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="destructive"
className="poker-chip poker-chip-fold"
disabled={!canAct || !legalActions.canFold}
onClick={() => submitFromControl("fold")}
onClick={() => void submit("fold")}
>
Fold
</Button>
{compact ? (
compactAction && (
<Button
type="button"
variant={compactAction.kind === "call" ? "secondary" : "outline"}
className="poker-chip poker-chip-call"
disabled={!canAct}
onClick={() => submitFromControl(compactAction.kind)}
>
{compactAction.label}
</Button>
)
) : (
<>
<Button
type="button"
variant="outline"
className="poker-chip poker-chip-call"
disabled={!canAct || !legalActions.canCheck}
onClick={() => submitFromControl("check")}
>
Check
</Button>
<Button
type="button"
variant="secondary"
className="poker-chip poker-chip-call"
disabled={!canAct || !legalActions.canCall}
onClick={() => submitFromControl("call")}
>
Call {amountLabel(legalActions.callAmount)}
</Button>
</>
)}
{compact && wagerKind && wagerMax >= wagerMin && (
<Button
type="button"
className="poker-chip poker-chip-wager"
disabled={!canAct}
aria-expanded={wagerOpen}
onClick={() => {
onActionFeedback?.();
setWagerOpen((open) => !open);
}}
>
{wagerKind === "bet" ? "Bet" : "Raise"}
</Button>
)}
<Button
type="button"
variant="outline"
disabled={!canAct || !legalActions.canCheck}
onClick={() => void submit("check")}
>
Check
</Button>
<Button
type="button"
variant="secondary"
disabled={!canAct || !legalActions.canCall}
onClick={() => void submit("call")}
>
Call {amountLabel(legalActions.callAmount)}
</Button>
</div>
{wagerKind && wagerMax >= wagerMin && (!compact || wagerOpen) && (
<Card
className={cn("poker-wager-panel rounded-none", compact && "poker-wager-panel-compact")}
>
<CardHeader className="p-3 pb-2">
{wagerKind && wagerMax >= wagerMin && (
<Card className="rounded-none">
<CardHeader className="p-4 pb-2">
<CardTitle className="text-sm">
{wagerKind === "bet" ? "Bet size" : "Raise to"}
</CardTitle>
</CardHeader>
<CardContent
className={cn("flex flex-col gap-3 p-3 pt-0", compact && "poker-compact-wager-content")}
>
<div className={cn("flex items-center gap-3", compact && "poker-compact-wager-line")}>
<CardContent className="flex flex-col gap-3 p-4 pt-0">
<div className="flex items-center gap-3">
<input
type="range"
min={wagerMin}
@ -197,7 +132,7 @@ export function BettingControls({
setAmount(clampAmount(Number(event.target.value), wagerMin, wagerMax))
}
disabled={!canAct}
className="poker-wager-range h-2 min-w-0 flex-1 cursor-pointer accent-primary disabled:cursor-not-allowed"
className="h-2 min-w-0 flex-1 cursor-pointer accent-primary disabled:cursor-not-allowed"
aria-label={`${wagerKind === "bet" ? "Bet" : "Raise"} amount`}
/>
<Input
@ -210,23 +145,19 @@ export function BettingControls({
setAmount(clampAmount(Number(event.target.value), wagerMin, wagerMax))
}
disabled={!canAct}
className="poker-wager-input w-24 font-mono tabular-nums"
className="w-24 font-mono tabular-nums"
aria-label="Wager amount"
/>
</div>
<div className={cn("flex flex-wrap gap-2", compact && "poker-compact-quick-row")}>
<div className="flex flex-wrap gap-2">
{(["min", "half", "pot", "all"] as const).map((choice) => (
<Button
key={choice}
type="button"
variant="outline"
size="sm"
className="poker-quick-chip"
disabled={!canAct}
onClick={() => {
onActionFeedback?.();
chooseQuickAmount(choice);
}}
onClick={() => chooseQuickAmount(choice)}
>
{choice === "min"
? "Min"
@ -239,9 +170,9 @@ export function BettingControls({
))}
<Button
type="button"
className="poker-chip poker-chip-wager ml-auto"
className="ml-auto"
disabled={!canAct}
onClick={() => submitFromControl(wagerKind, amount)}
onClick={() => void submit(wagerKind, amount)}
>
{wagerKind === "bet" ? "Bet" : "Raise"} {amountLabel(amount)}
</Button>
@ -250,12 +181,11 @@ export function BettingControls({
</Card>
)}
<div
className="poker-betting-message flex min-h-5 items-center justify-between gap-3 text-xs"
aria-live="polite"
>
{!compact && !yourTurn && <span>Waiting for the table to act.</span>}
{pendingAction && <span>Waiting for the table update...</span>}
<div className="flex min-h-5 items-center justify-between gap-3 text-xs" aria-live="polite">
{!yourTurn && <span className="text-muted-foreground">Waiting for the table to act.</span>}
{pendingAction && (
<span className="text-muted-foreground">Waiting for the table update...</span>
)}
{error && <span className="text-destructive">{error}</span>}
</div>
</div>

View file

@ -2,7 +2,6 @@
import { cn } from "@/lib/utils";
import type { Card, Rank, Suit } from "@/lib/minigames/poker";
import { mapCardToSvg, useCardStyle } from "./cardStyle";
const RANK_LABELS: Record<Rank, string> = {
2: "2",
@ -48,11 +47,7 @@ export function CardView({
highlighted = false,
dimmed = false,
}: CardViewProps) {
const { style: cardStyle } = useCardStyle();
const lightFace = cardStyle === "light";
const classicFace = cardStyle === "classic";
const revealed = card !== null && faceUp;
const redSuitClass = lightFace ? "text-rose-600" : "text-rose-400";
const label = card
? `${RANK_LABELS[card.rank]} of ${
card.suit === "s"
@ -81,38 +76,28 @@ export function CardView({
style={dealIndex > 0 ? { animationDelay: `${dealIndex * 60}ms` } : undefined}
aria-label={label}
>
<span className="poker-card-motion">
<span className={cn("poker-card-inner", revealed && "poker-card-is-face-up")}>
<span
className={cn(
"poker-card-face poker-card-front gap-1",
lightFace && "poker-card-face--light",
classicFace && "poker-card-front--svg",
)}
aria-hidden={!revealed}
>
{card && classicFace && (
<img src={mapCardToSvg(card)} alt="" draggable={false} className="poker-card-svg" />
)}
{card && !classicFace && (
<>
<span
className={cn(
"flex flex-col items-center leading-none",
RED_SUITS.has(card.suit) && redSuitClass,
)}
>
<span className="font-semibold">{RANK_LABELS[card.rank]}</span>
<span>{SUIT_SYMBOLS[card.suit]}</span>
</span>
<span className={cn("poker-card-center", RED_SUITS.has(card.suit) && redSuitClass)}>
{SUIT_SYMBOLS[card.suit]}
</span>
</>
)}
</span>
<span className="poker-card-face poker-card-back" aria-hidden={revealed} />
<span className={cn("poker-card-inner", revealed && "poker-card-is-face-up")}>
<span className="poker-card-face poker-card-front" aria-hidden={!revealed}>
{card && (
<>
<span
className={cn(
"flex flex-col items-center leading-none",
RED_SUITS.has(card.suit) && "text-rose-400",
)}
>
<span className="font-semibold">{RANK_LABELS[card.rank]}</span>
<span>{SUIT_SYMBOLS[card.suit]}</span>
</span>
<span
className={cn("poker-card-center", RED_SUITS.has(card.suit) && "text-rose-400")}
>
{SUIT_SYMBOLS[card.suit]}
</span>
</>
)}
</span>
<span className="poker-card-face poker-card-back" aria-hidden={revealed} />
</span>
</span>
);

View file

@ -1,167 +0,0 @@
"use client";
import { cn } from "@/lib/utils";
import type { PublicPlayerView } from "@/lib/minigames/poker";
import type { PokerSnapshot } from "@/app/(frontend)/minigames/poker/actions";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { PokerClock } from "./PokerClock";
type SeatedSnapshot = PokerSnapshot & {
readonly you: NonNullable<PokerSnapshot["you"]>;
};
interface PokerActionConsoleProps {
readonly snapshot: SeatedSnapshot;
readonly handLabel: string | null;
readonly hostSeatId: number | null;
readonly starting: boolean;
readonly leaving: boolean;
readonly onStart: () => void;
readonly onLeave: () => void;
}
function TableActionButtons({
view,
hostSeatId,
starting,
leaving,
onStart,
onLeave,
}: {
readonly view: SeatedSnapshot["you"];
readonly hostSeatId: number | null;
readonly starting: boolean;
readonly leaving: boolean;
readonly onStart: () => void;
readonly onLeave: () => void;
}) {
return (
<div className="poker-complete-actions">
<div className="poker-complete-copy">
<span className="poker-console-kicker">Table complete</span>
<span className="text-sm text-white/75">The final placements are in.</span>
</div>
<div className="flex flex-wrap justify-end gap-2">
{view.viewerSeat === hostSeatId && (
<Button
type="button"
className="poker-chip poker-chip-wager"
onClick={onStart}
disabled={starting}
>
{starting ? "Starting..." : "Start new game"}
</Button>
)}
<Button
type="button"
variant="outline"
className="poker-chip poker-chip-call"
onClick={onLeave}
disabled={leaving}
>
{leaving ? "Leaving..." : "Leave table"}
</Button>
</div>
</div>
);
}
function ViewerRoleBadges({ view }: { readonly view: SeatedSnapshot["you"] }) {
return (
<div className="poker-console-role-row" aria-label="Your table roles">
{view.buttonSeat === view.viewerSeat && <Badge variant="secondary">Dealer button</Badge>}
{view.smallBlindSeat === view.viewerSeat && <Badge variant="secondary">Small blind</Badge>}
{view.bigBlindSeat === view.viewerSeat && <Badge variant="secondary">Big blind</Badge>}
</div>
);
}
export function PokerActionConsole({
snapshot,
handLabel,
hostSeatId,
starting,
leaving,
onStart,
onLeave,
}: PokerActionConsoleProps) {
const view = snapshot.you;
const viewerPlayer: PublicPlayerView | undefined = view.players.find(
(player) => player.seatId === view.viewerSeat,
);
const chipsInPlay = viewerPlayer?.committed ?? 0;
const viewerInitial = (viewerPlayer?.name ?? "You").trim().charAt(0).toUpperCase() || "?";
return (
<section
className={cn(
"poker-action-console",
view.phase === "game-complete" && "poker-action-console-complete",
view.phase === "betting" && !view.yourTurn && "poker-action-console-waiting",
)}
aria-label="Your poker action console"
>
<div className="poker-console-player">
<span
className={cn(
"poker-seat-avatar poker-console-avatar",
`poker-seat-avatar-tone-${view.viewerSeat % 4}`,
)}
aria-hidden="true"
>
{viewerInitial}
</span>
<div className="poker-console-identity">
<div className="flex min-w-0 items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate text-sm font-semibold">
{viewerPlayer?.name ?? "You"}
<span className="ml-1 text-xs text-white/55">(you)</span>
</p>
<p className="font-mono text-xs tabular-nums text-white/65">
Stack {viewerPlayer?.stack.toLocaleString() ?? "0"}
</p>
</div>
<ViewerRoleBadges view={view} />
</div>
{handLabel && <span className="poker-hand-placard">You have: {handLabel}</span>}
</div>
</div>
<div className="poker-console-economy">
<div className="poker-chip-readout">
<span className="poker-chip-stack" aria-hidden="true" />
<span className="poker-console-readout-copy">
<span className="poker-console-kicker">Chips in play</span>
<strong className="font-mono text-sm tabular-nums">
{chipsInPlay.toLocaleString()}
</strong>
</span>
</div>
{view.phase === "betting" && (
<div className="poker-console-timer">
<PokerClock
deadline={snapshot.turnDeadline}
totalMs={view.config.turnTimeoutMs}
label="Turn"
/>
</div>
)}
</div>
{view.phase === "game-complete" && (
<div className="poker-console-actions">
<TableActionButtons
view={view}
hostSeatId={hostSeatId}
starting={starting}
leaving={leaving}
onStart={onStart}
onLeave={onLeave}
/>
</div>
)}
</section>
);
}

View file

@ -1,48 +0,0 @@
"use client";
import type { PokerSnapshot } from "@/app/(frontend)/minigames/poker/actions";
import { BettingControls } from "./BettingControls";
type SeatedSnapshot = PokerSnapshot & {
readonly you: NonNullable<PokerSnapshot["you"]>;
};
interface PokerActionToolbarProps {
readonly snapshot: SeatedSnapshot;
readonly onSnapshot: (snapshot: PokerSnapshot) => void;
readonly onActionFeedback: () => void;
}
export function PokerActionToolbar({
snapshot,
onSnapshot,
onActionFeedback,
}: PokerActionToolbarProps) {
const view = snapshot.you;
if (view.phase !== "betting") return null;
if (!view.yourTurn) {
const actingPlayer = view.players.find((player) => player.seatId === view.toActSeat);
return (
<div className="poker-action-toolbar poker-action-toolbar-waiting" role="status">
{actingPlayer ? `Waiting for ${actingPlayer.name}` : "Waiting for the table"}
</div>
);
}
return (
<div className="poker-action-toolbar" aria-label="Poker actions">
<BettingControls
tableId={snapshot.tableId}
snapshotVersion={snapshot.version}
legalActions={view.legalActions}
potTotal={view.potTotal}
yourTurn={view.yourTurn}
onSnapshot={onSnapshot}
onActionFeedback={onActionFeedback}
compact
/>
</div>
);
}

View file

@ -1,58 +0,0 @@
"use client";
import { Images, Moon, Sun } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
POKER_CARD_STYLE_OPTIONS,
normalizeCardStyle,
useCardStyle,
type PokerCardStyle,
} from "./cardStyle";
const STYLE_ICONS: Record<PokerCardStyle, LucideIcon> = {
light: Sun,
dark: Moon,
classic: Images,
};
export function PokerCardStyleToggle() {
const { style, setStyle } = useCardStyle();
const Icon = STYLE_ICONS[style];
const label = POKER_CARD_STYLE_OPTIONS.find((option) => option.value === style)?.label ?? "Light";
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
size="icon"
aria-label={`Card style: ${label}`}
title={`Card style: ${label}`}
>
<Icon aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuRadioGroup
value={style}
onValueChange={(value) => setStyle(normalizeCardStyle(value))}
>
{POKER_CARD_STYLE_OPTIONS.map((option) => (
<DropdownMenuRadioItem key={option.value} value={option.value}>
{option.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}

View file

@ -1,11 +0,0 @@
"use client";
export function PokerDeckOrigin() {
return (
<div className="poker-deck-origin" aria-hidden="true">
<span className="poker-deck-card poker-deck-card-back" />
<span className="poker-deck-card poker-deck-card-middle" />
<span className="poker-deck-card poker-deck-card-top" />
</div>
);
}

View file

@ -1,275 +0,0 @@
"use client";
import { cn } from "@/lib/utils";
import type { Card, PublicPlayerView } from "@/lib/minigames/poker";
import type { PokerSnapshot } from "@/app/(frontend)/minigames/poker/actions";
import { Badge } from "@/components/ui/badge";
import { Card as UiCard, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { CardView } from "./CardView";
import { PokerActionConsole } from "./PokerActionConsole";
import { PokerActionToolbar } from "./PokerActionToolbar";
import { PokerClock } from "./PokerClock";
import { PokerDeckOrigin } from "./PokerDeckOrigin";
import { PokerPopupBubble } from "./PokerPopups";
import { PokerSeat } from "./PokerSeat";
import type { ActivePokerPopup } from "./usePokerPopups";
import { isTopSeatPosition, seatPosition } from "./seatPosition";
export type SeatedSnapshot = PokerSnapshot & {
readonly you: NonNullable<PokerSnapshot["you"]>;
};
export interface FeltCanvasActionProps {
readonly starting: boolean;
readonly leaving: boolean;
readonly onStart: () => void;
readonly onLeave: () => void;
}
interface PokerFeltCanvasProps {
readonly snapshot: SeatedSnapshot;
readonly onSnapshot: (snapshot: PokerSnapshot) => void;
readonly handLabel: string | null;
readonly bestCardKeys: ReadonlySet<string>;
readonly actionProps: FeltCanvasActionProps;
readonly onActionFeedback: () => void;
readonly popups: readonly ActivePokerPopup[];
readonly fullscreen?: boolean;
}
function cardKey(card: Card): string {
return `${card.rank}-${card.suit}`;
}
function signedAmount(value: number): string {
return `${value >= 0 ? "+" : ""}${value.toLocaleString()}`;
}
function PlacementBoard({ view }: { readonly view: SeatedSnapshot["you"] }) {
const placements = [...(view.finalPlacements ?? [])].sort((a, b) => a.placement - b.placement);
return (
<UiCard className="w-full max-w-sm rounded-none border-white/20 bg-black/55 text-white">
<CardHeader className="p-4 pb-2">
<CardTitle className="text-sm uppercase tracking-wider">Final placements</CardTitle>
</CardHeader>
<CardContent className="p-4 pt-0">
<ol className="flex flex-col gap-2">
{placements.map((placement) => {
const player = view.players.find((item) => item.seatId === placement.seatId);
return (
<li
key={placement.seatId}
className="flex items-center justify-between gap-3 text-sm"
>
<span className="font-mono text-muted-foreground">{placement.placement}.</span>
<span className="min-w-0 flex-1 truncate font-medium">
{player?.name ?? `Seat ${placement.seatId + 1}`}
</span>
<span className="font-mono tabular-nums text-muted-foreground">
{player?.stack.toLocaleString() ?? "0"}
</span>
</li>
);
})}
</ol>
</CardContent>
</UiCard>
);
}
export function PokerFeltCanvas({
snapshot,
onSnapshot,
handLabel,
bestCardKeys,
actionProps,
onActionFeedback,
popups,
fullscreen = false,
}: PokerFeltCanvasProps) {
const view = snapshot.you;
const winnerSeats = new Set(view.showdown?.pots.flatMap((pot) => pot.winnerSeats) ?? []);
const result = view.showdown?.results.find((item) => item.seatId === view.viewerSeat);
const hasHint = handLabel !== null;
const opponents = view.players.filter((player) => player.seatId !== view.viewerSeat);
const viewerPlayer = view.players.find((player) => player.seatId === view.viewerSeat);
const popupsForSeat = (seatId: number): readonly ActivePokerPopup[] =>
popups.filter((popup) => popup.seatId === seatId && popup.tone !== "leave");
const viewerPopups = popupsForSeat(view.viewerSeat);
const tableAnnouncements = popups.filter((popup) => popup.tone === "leave");
return (
<div className="poker-table-stage">
<div
className={cn(
"poker-felt-table relative min-h-[36rem] overflow-hidden p-4 sm:min-h-[42rem]",
fullscreen && "poker-felt-fullscreen",
)}
>
{opponents.map((player: PublicPlayerView, opponentIndex) => {
const positionClass = seatPosition(opponentIndex, opponents.length);
return (
<div
key={player.seatId}
className={cn(
"poker-seat-slot absolute z-10 w-36 max-w-[34%] sm:w-52",
positionClass,
)}
>
<PokerSeat
player={player}
isCurrentUser={false}
isActing={view.phase === "betting" && player.seatId === view.toActSeat}
isWinner={winnerSeats.has(player.seatId)}
showHoleCards={view.phase !== "hand-complete" && view.phase !== "game-complete"}
/>
{popupsForSeat(player.seatId).length > 0 && (
<div
className={cn(
"poker-popup-stack",
isTopSeatPosition(positionClass) && "poker-popup-stack-below",
)}
aria-hidden="true"
>
{popupsForSeat(player.seatId).map((popup) => (
<PokerPopupBubble key={popup.key} popup={popup} />
))}
</div>
)}
<div className="pointer-events-none absolute -top-2.5 left-1/2 z-20 flex -translate-x-1/2 gap-1">
{player.seatId === view.buttonSeat && (
<span className="poker-dealer-button flex size-6 items-center justify-center rounded-full border text-xs font-bold">
D
</span>
)}
{player.seatId === view.smallBlindSeat && <Badge variant="secondary">SB</Badge>}
{player.seatId === view.bigBlindSeat && <Badge variant="secondary">BB</Badge>}
</div>
</div>
);
})}
<div className="poker-table-center absolute left-1/2 top-1/2 z-10 flex w-[min(26rem,72%)] -translate-x-1/2 -translate-y-1/2 flex-col items-center gap-2 text-white">
<PokerDeckOrigin />
<div className="poker-pot-plaque">
<span className="poker-console-kicker">Pot</span>
<strong className="font-mono text-lg tabular-nums">
{view.potTotal.toLocaleString()}
</strong>
</div>
<div
className="poker-community-cards flex flex-wrap justify-center gap-1"
aria-label="Community cards"
>
{view.communityCards.length > 0 ? (
view.communityCards.map((card, index) => {
const isBestCard = bestCardKeys.has(cardKey(card));
return (
<CardView
key={`${card.rank}-${card.suit}`}
card={card}
size="md"
dealIndex={index + 1}
highlighted={hasHint && isBestCard}
dimmed={hasHint && !isBestCard}
className="poker-card-community"
/>
);
})
) : (
<span className="font-mono text-xs uppercase tracking-[0.2em] text-white/55">
Community cards
</span>
)}
</div>
{view.phase === "betting" && (
<div className="poker-table-turn-clock">
<PokerClock deadline={snapshot.turnDeadline} totalMs={view.config.turnTimeoutMs} />
</div>
)}
{(view.phase === "showdown" || view.phase === "hand-complete") && (
<div
className="flex w-full flex-col items-center gap-1 border border-white/15 bg-black/35 px-3 py-2 text-center"
aria-live="polite"
>
<p className="font-semibold">
{view.phase === "showdown" ? "Showdown" : "Hand complete"}
</p>
<p className="text-sm text-white/70">
{result
? `Your result: ${signedAmount(result.netChange)} chips`
: "The hand is settled."}
</p>
{snapshot.nextHandAt && (
<PokerClock deadline={snapshot.nextHandAt} mode="text" label="Next hand in" />
)}
</div>
)}
</div>
{view.phase !== "game-complete" && view.yourHoleCards.length > 0 && (
<div className="poker-viewer-cards" aria-label="Your hole cards">
{view.yourHoleCards.map((card, index) => (
<CardView
key={`felt-hole-${index}`}
card={card}
size="md"
dealIndex={index + 1}
highlighted={hasHint && bestCardKeys.has(cardKey(card))}
dimmed={hasHint && !bestCardKeys.has(cardKey(card))}
className={cn(
index === 0 ? "poker-card-viewer-left" : "poker-card-viewer-right",
viewerPlayer?.folded && "poker-card-folded",
)}
/>
))}
</div>
)}
<PokerActionToolbar
snapshot={snapshot}
onSnapshot={onSnapshot}
onActionFeedback={onActionFeedback}
/>
{viewerPopups.length > 0 && (
<div className="poker-popup-stack poker-popup-viewer-spot" aria-hidden="true">
{viewerPopups.map((popup) => (
<PokerPopupBubble key={popup.key} popup={popup} />
))}
</div>
)}
{tableAnnouncements.length > 0 && (
<div
className="poker-popup-stack poker-popup-announcements"
role="status"
aria-live="polite"
>
{tableAnnouncements.map((popup) => (
<PokerPopupBubble key={popup.key} popup={popup} />
))}
</div>
)}
{view.phase === "game-complete" && (
<div className="poker-placement-board absolute left-1/2 top-1/2 z-20 flex w-[min(24rem,calc(100%-2rem))] -translate-x-1/2 -translate-y-1/2 justify-center">
<PlacementBoard view={view} />
</div>
)}
</div>
<PokerActionConsole
snapshot={snapshot}
handLabel={handLabel}
hostSeatId={snapshot.hostSeatId}
starting={actionProps.starting}
leaving={actionProps.leaving}
onStart={actionProps.onStart}
onLeave={actionProps.onLeave}
/>
</div>
);
}

View file

@ -3,26 +3,52 @@
import { createPortal } from "react-dom";
import { useEffect, useMemo, useState } from "react";
import { Maximize2, Minimize2 } from "lucide-react";
import type { PokerPhase } from "@/lib/minigames/poker";
import { cn } from "@/lib/utils";
import type { Card, PokerPhase, PublicPlayerView } from "@/lib/minigames/poker";
import { describeHandStrength } from "@/lib/minigames/poker/handHint";
import type { PokerSnapshot } from "@/app/(frontend)/minigames/poker/actions";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card as UiCard, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { PokerCardStyleToggle } from "./PokerCardStyleToggle";
import { PokerFeltCanvas, type SeatedSnapshot } from "./PokerFeltCanvas";
import { PokerSoundToggle } from "./PokerSoundToggle";
import type { PokerSoundControls } from "./usePokerSounds";
import type { ActivePokerPopup } from "./usePokerPopups";
import {
Card as UiCard,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { BettingControls } from "./BettingControls";
import { CardView } from "./CardView";
import { PokerClock } from "./PokerClock";
import { PokerSeat } from "./PokerSeat";
import { RenameDialogGuard } from "./RenameDialogGuard";
import { seatPosition } from "./seatPosition";
type SeatedSnapshot = PokerSnapshot & {
readonly you: NonNullable<PokerSnapshot["you"]>;
};
interface PokerGameTableProps {
readonly snapshot: SeatedSnapshot;
readonly onSnapshot: (snapshot: PokerSnapshot) => void;
readonly onStart: () => Promise<void>;
readonly onLeave: () => Promise<void>;
readonly sound: PokerSoundControls;
readonly popups: readonly ActivePokerPopup[];
}
interface FeltCanvasProps {
readonly snapshot: SeatedSnapshot;
readonly onSnapshot: (snapshot: PokerSnapshot) => void;
readonly handLabel: string | null;
readonly bestCardKeys: ReadonlySet<string>;
readonly fullscreen?: boolean;
}
interface TableActionButtonsProps {
readonly view: SeatedSnapshot["you"];
readonly hostSeatId: number | null;
readonly starting: boolean;
readonly leaving: boolean;
readonly onStart: () => void;
readonly onLeave: () => void;
}
const PHASE_LABELS: Record<PokerPhase, string> = {
@ -32,14 +58,197 @@ const PHASE_LABELS: Record<PokerPhase, string> = {
"game-complete": "Game complete",
};
export function PokerGameTable({
snapshot,
onSnapshot,
function cardKey(card: Card): string {
return `${card.rank}-${card.suit}`;
}
function signedAmount(value: number): string {
return `${value >= 0 ? "+" : ""}${value.toLocaleString()}`;
}
function PlacementBoard({ view }: { readonly view: SeatedSnapshot["you"] }) {
const placements = [...(view.finalPlacements ?? [])].sort((a, b) => a.placement - b.placement);
return (
<UiCard className="w-full max-w-sm rounded-none border-white/20 bg-black/55 text-white">
<CardHeader className="p-4 pb-2">
<CardTitle className="text-sm uppercase tracking-wider">Final placements</CardTitle>
</CardHeader>
<CardContent className="p-4 pt-0">
<ol className="flex flex-col gap-2">
{placements.map((placement) => {
const player = view.players.find((item) => item.seatId === placement.seatId);
return (
<li
key={placement.seatId}
className="flex items-center justify-between gap-3 text-sm"
>
<span className="font-mono text-muted-foreground">{placement.placement}.</span>
<span className="min-w-0 flex-1 truncate font-medium">
{player?.name ?? `Seat ${placement.seatId + 1}`}
</span>
<span className="font-mono tabular-nums text-muted-foreground">
{player?.stack.toLocaleString() ?? "0"}
</span>
</li>
);
})}
</ol>
</CardContent>
</UiCard>
);
}
function TableActionButtons({
view,
hostSeatId,
starting,
leaving,
onStart,
onLeave,
sound,
popups,
}: PokerGameTableProps) {
}: TableActionButtonsProps) {
return (
<>
{view.phase === "game-complete" && view.viewerSeat === hostSeatId && (
<Button type="button" onClick={onStart} disabled={starting}>
{starting ? "Starting..." : "Start new game"}
</Button>
)}
<Button type="button" variant="outline" onClick={onLeave} disabled={leaving}>
{leaving ? "Leaving..." : "Leave table"}
</Button>
</>
);
}
function FeltCanvas({
snapshot,
onSnapshot,
handLabel,
bestCardKeys,
fullscreen = false,
}: FeltCanvasProps) {
const view = snapshot.you;
const winnerSeats = new Set(view.showdown?.pots.flatMap((pot) => pot.winnerSeats) ?? []);
const result = view.showdown?.results.find((item) => item.seatId === view.viewerSeat);
const showHoleCards = view.phase !== "hand-complete" && view.phase !== "game-complete";
const hasHint = handLabel !== null;
return (
<div
className={cn(
"poker-felt-table relative min-h-[36rem] overflow-hidden p-4 sm:min-h-[42rem]",
fullscreen && "poker-felt-fullscreen",
)}
>
{view.players.map((player: PublicPlayerView) => {
const isViewer = player.seatId === view.viewerSeat;
return (
<div
key={player.seatId}
className={cn(
"absolute z-10",
isViewer ? "poker-seat-south-strip" : "w-36 max-w-[44%] sm:w-52",
!isViewer && seatPosition(player.seatId),
)}
>
<PokerSeat
player={player}
isCurrentUser={isViewer}
isActing={view.phase === "betting" && player.seatId === view.toActSeat}
isWinner={winnerSeats.has(player.seatId)}
showHoleCards={showHoleCards}
handLabel={isViewer ? handLabel : null}
cardKeySet={isViewer ? bestCardKeys : undefined}
compact={isViewer}
/>
<div className="pointer-events-none absolute -top-2 right-2 z-20 flex gap-1">
{player.seatId === view.buttonSeat && (
<span className="flex size-6 items-center justify-center rounded-full border border-amber-200 bg-amber-300 text-xs font-bold text-black">
D
</span>
)}
{player.seatId === view.smallBlindSeat && <Badge variant="secondary">SB</Badge>}
{player.seatId === view.bigBlindSeat && <Badge variant="secondary">BB</Badge>}
</div>
</div>
);
})}
<div className="absolute left-1/2 top-1/2 z-10 flex w-[min(24rem,80%)] -translate-x-1/2 -translate-y-1/2 flex-col items-center gap-3 text-white">
{view.phase === "betting" && (
<PokerClock deadline={snapshot.turnDeadline} totalMs={view.config.turnTimeoutMs} />
)}
<div className="flex flex-wrap justify-center gap-1.5" aria-label="Community cards">
{view.communityCards.length > 0 ? (
view.communityCards.map((card, index) => {
const isBestCard = bestCardKeys.has(cardKey(card));
return (
<CardView
key={`${card.rank}-${card.suit}`}
card={card}
size="md"
dealIndex={index}
highlighted={hasHint && isBestCard}
dimmed={hasHint && !isBestCard}
/>
);
})
) : (
<span className="font-mono text-xs uppercase tracking-[0.2em] text-white/55">
Community cards
</span>
)}
</div>
<div className="flex flex-col items-center gap-1">
<span className="font-mono text-xs uppercase tracking-[0.2em] text-white/60">Pot</span>
<span className="font-mono text-2xl font-semibold tabular-nums">
{view.potTotal.toLocaleString()}
</span>
</div>
{(view.phase === "showdown" || view.phase === "hand-complete") && (
<div
className="flex w-full flex-col items-center gap-1 border border-white/15 bg-black/35 px-3 py-2 text-center"
aria-live="polite"
>
<p className="font-semibold">
{view.phase === "showdown" ? "Showdown" : "Hand complete"}
</p>
<p className="text-sm text-white/70">
{result
? `Your result: ${signedAmount(result.netChange)} chips`
: "The hand is settled."}
</p>
{snapshot.nextHandAt && (
<PokerClock deadline={snapshot.nextHandAt} mode="text" label="Next hand in" />
)}
</div>
)}
</div>
{view.phase !== "game-complete" && (
<div className="absolute bottom-[13%] left-1/2 z-30 max-h-[45%] w-[min(44rem,calc(100%-2rem))] -translate-x-1/2 overflow-y-auto rounded-none border border-white/15 bg-black/60 p-3 backdrop-blur-sm">
<BettingControls
tableId={snapshot.tableId}
snapshotVersion={snapshot.version}
legalActions={view.legalActions}
potTotal={view.potTotal}
yourTurn={view.yourTurn}
onSnapshot={onSnapshot}
/>
</div>
)}
{view.phase === "game-complete" && (
<div className="absolute left-1/2 top-1/2 z-20 flex w-[min(24rem,calc(100%-2rem))] -translate-x-1/2 -translate-y-1/2 justify-center">
<PlacementBoard view={view} />
</div>
)}
</div>
);
}
export function PokerGameTable({ snapshot, onSnapshot, onStart, onLeave }: PokerGameTableProps) {
const [starting, setStarting] = useState(false);
const [leaving, setLeaving] = useState(false);
const [fullscreen, setFullscreen] = useState(false);
@ -83,19 +292,11 @@ export function PokerGameTable({
const tableName = snapshot.name ?? `Table ${snapshot.tableId}`;
const feltCanvas = (
<PokerFeltCanvas
<FeltCanvas
snapshot={snapshot}
onSnapshot={onSnapshot}
handLabel={handHint?.label ?? null}
bestCardKeys={bestCardKeys}
actionProps={{
starting,
leaving,
onStart: () => void start(),
onLeave: () => void leave(),
}}
onActionFeedback={sound.playUiClick}
popups={popups}
fullscreen={fullscreen}
/>
);
@ -111,13 +312,11 @@ export function PokerGameTable({
<Badge variant="outline">{PHASE_LABELS[view.phase]}</Badge>
</div>
<div className="flex flex-wrap items-center justify-end gap-2">
<PokerCardStyleToggle />
<PokerSoundToggle
muted={sound.muted}
volume={sound.volume}
onToggleMuted={sound.toggleMuted}
onVolumeChange={sound.setVolume}
/>
{view.phase === "game-complete" && view.viewerSeat === snapshot.hostSeatId && (
<Button type="button" onClick={() => void start()} disabled={starting}>
{starting ? "Starting..." : "Start new game"}
</Button>
)}
<Button
type="button"
variant="outline"
@ -128,16 +327,9 @@ export function PokerGameTable({
>
<Minimize2 aria-hidden="true" />
</Button>
{view.phase !== "game-complete" && (
<Button
type="button"
variant="outline"
onClick={() => void leave()}
disabled={leaving}
>
{leaving ? "Leaving..." : "Leave table"}
</Button>
)}
<Button type="button" variant="outline" onClick={() => void leave()} disabled={leaving}>
{leaving ? "Leaving..." : "Leave table"}
</Button>
</div>
</div>
{feltCanvas}
@ -168,13 +360,6 @@ export function PokerGameTable({
<span className="font-mono text-xs tabular-nums text-muted-foreground">
v{snapshot.version}
</span>
<PokerCardStyleToggle />
<PokerSoundToggle
muted={sound.muted}
volume={sound.volume}
onToggleMuted={sound.toggleMuted}
onVolumeChange={sound.setVolume}
/>
<Button
type="button"
variant="outline"
@ -185,14 +370,19 @@ export function PokerGameTable({
>
<Maximize2 aria-hidden="true" />
</Button>
{view.phase !== "game-complete" && (
<Button type="button" variant="outline" onClick={() => void leave()} disabled={leaving}>
{leaving ? "Leaving..." : "Leave table"}
</Button>
)}
</div>
</CardHeader>
<CardContent className="p-4">{feltCanvas}</CardContent>
<CardFooter className="flex flex-wrap justify-end gap-2 border-t p-4">
<TableActionButtons
view={view}
hostSeatId={snapshot.hostSeatId}
starting={starting}
leaving={leaving}
onStart={() => void start()}
onLeave={() => void leave()}
/>
</CardFooter>
</UiCard>
);
}

View file

@ -17,7 +17,6 @@ interface PokerRanking {
readonly name: string;
readonly example: readonly Card[];
readonly description: string;
readonly color?: string;
}
function card(rank: Rank, suit: Suit): Card {
@ -49,37 +48,31 @@ const RANKINGS: readonly PokerRanking[] = [
name: "Straight",
example: [card(5, "s"), card(6, "h"), card(7, "d"), card(8, "c"), card(9, "s")],
description: "Five cards run in rank order.",
color: "#ffff00",
},
{
name: "Flush",
example: [card(14, "h"), card(10, "h"), card(7, "h"), card(4, "h"), card(2, "h")],
description: "Five cards share the same suit.",
color: "#ffff00",
},
{
name: "Full house",
example: [card(13, "s"), card(13, "h"), card(13, "d"), card(8, "c"), card(8, "s")],
description: "Three of one rank and two of another.",
color: "#ffff00",
},
{
name: "Four of a kind",
example: [card(3, "s"), card(3, "h"), card(3, "d"), card(3, "c"), card(14, "s")],
description: "Four cards share the same rank.",
color: "#ffff00",
},
{
name: "Straight flush",
example: [card(5, "s"), card(6, "s"), card(7, "s"), card(8, "s"), card(9, "s")],
description: "Five cards run in order and share a suit.",
color: "#ffff00",
},
{
name: "Royal flush",
example: [card(10, "h"), card(11, "h"), card(12, "h"), card(13, "h"), card(14, "h")],
description: "The ace-high straight flush, the strongest hand.",
color: "#ffff00",
},
];
@ -104,13 +97,11 @@ export function PokerGuide() {
<h2 className="text-sm font-semibold uppercase tracking-wider">Hand rankings</h2>
<ol className="grid gap-3 sm:grid-cols-2">
{RANKINGS.map((ranking, index) => (
<li key={ranking.name} className="flex items-center gap-3 border border-border p-3" style={ranking.color ? ({
borderColor: ranking.color
}) : {}}>
<li key={ranking.name} className="flex items-center gap-3 border border-border p-3">
<span className="w-5 shrink-0 font-mono text-xs text-muted-foreground">
{index + 1}
</span>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex min-w-0 flex-1 flex-col gap-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="font-medium">{ranking.name}</span>
<div className="flex gap-0.5" aria-label={`${ranking.name} example`}>
@ -124,7 +115,7 @@ export function PokerGuide() {
))}
</div>
</div>
<p className="text-xs text-muted-foreground max-w-1/2">{ranking.description}</p>
<p className="text-xs text-muted-foreground">{ranking.description}</p>
</div>
</li>
))}

View file

@ -49,8 +49,8 @@ function createdLabel(createdAt: string): string {
}
export function PokerLobby({ refreshKey, onOpenTable, isSuperuser = false }: PokerLobbyProps) {
const [tables, setTables] = useState<readonly PokerTableSummary[] | null>(null);
const [refreshing, setRefreshing] = useState(false);
const [tables, setTables] = useState<readonly PokerTableSummary[]>([]);
const [loading, setLoading] = useState(true);
const [pendingTable, setPendingTable] = useState<string | null>(null);
const [forceEndingTable, setForceEndingTable] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
@ -58,28 +58,22 @@ export function PokerLobby({ refreshKey, onOpenTable, isSuperuser = false }: Pok
const [error, setError] = useState<string | null>(null);
const loadTables = useCallback(async () => {
setLoading(true);
setError(null);
try {
const result = await listPokerTables();
if (!result.success) {
setError(result.error ?? "Could not load poker tables.");
return;
}
setError(null);
setTables(result.data ?? []);
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Could not load poker tables.");
} finally {
setLoading(false);
}
}, []);
const manualRefresh = useCallback(async () => {
setRefreshing(true);
try {
await loadTables();
} finally {
setRefreshing(false);
}
}, [loadTables]);
useEffect(() => {
void loadTables();
}, [loadTables, refreshKey]);
@ -178,12 +172,12 @@ export function PokerLobby({ refreshKey, onOpenTable, isSuperuser = false }: Pok
type="button"
variant="outline"
size="icon"
onClick={() => void manualRefresh()}
disabled={refreshing}
onClick={() => void loadTables()}
disabled={loading}
aria-label="Refresh poker tables"
title="Refresh tables"
>
<RefreshCw className={cn(refreshing && "animate-spin motion-reduce:animate-none")} />
<RefreshCw className={cn(loading && "animate-spin motion-reduce:animate-none")} />
</Button>
<Button type="button" onClick={() => void create()} disabled={creating}>
{creating ? (
@ -199,7 +193,7 @@ export function PokerLobby({ refreshKey, onOpenTable, isSuperuser = false }: Pok
{error && (
<p className="border-b border-border px-4 py-3 text-sm text-destructive">{error}</p>
)}
{tables === null ? (
{loading && tables.length === 0 ? (
<p className="px-4 py-8 text-center text-sm text-muted-foreground">
Scanning for open tables...
</p>

View file

@ -1,12 +0,0 @@
"use client";
import { cn } from "@/lib/utils";
import type { ActivePokerPopup } from "./usePokerPopups";
export function PokerPopupBubble({ popup }: { readonly popup: ActivePokerPopup }) {
return (
<span aria-hidden="true" className={cn("poker-popup", `poker-popup-${popup.tone}`)}>
{popup.text}
</span>
);
}

View file

@ -1,8 +1,8 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import type { PublicPlayerView } from "@/lib/minigames/poker";
import type { Card, PublicPlayerView } from "@/lib/minigames/poker";
import { Badge } from "@/components/ui/badge";
import { CardView } from "./CardView";
interface PokerSeatProps {
@ -11,11 +11,14 @@ interface PokerSeatProps {
readonly isActing: boolean;
readonly isWinner: boolean;
readonly showHoleCards: boolean;
readonly handLabel?: string | null;
readonly cardKeySet?: ReadonlySet<string>;
readonly compact?: boolean;
}
type StackFlash = "up" | "down" | null;
const STACK_FLASH_MS = 900;
function cardKey(card: Card): string {
return `${card.rank}-${card.suit}`;
}
export function PokerSeat({
player,
@ -23,104 +26,77 @@ export function PokerSeat({
isActing,
isWinner,
showHoleCards,
handLabel = null,
cardKeySet,
compact = false,
}: PokerSeatProps) {
const revealed = showHoleCards ? player.holeCards : null;
const inHand = showHoleCards && !player.folded && !player.eliminated && !player.left;
const initial = player.name.trim().charAt(0).toUpperCase() || "?";
const statusLabel = player.left
? "Left"
: isActing
? "Acting"
: player.folded
? "Folded"
: player.allIn
? "All-in"
: player.hasActed
? "Acted"
: null;
const [stackFlash, setStackFlash] = useState<StackFlash>(null);
const previousStackRef = useRef<number | null>(null);
useEffect(() => {
const previousStack = previousStackRef.current;
previousStackRef.current = player.stack;
if (previousStack === null || previousStack === player.stack) {
return undefined;
}
setStackFlash(player.stack > previousStack ? "up" : "down");
const timer = setTimeout(() => setStackFlash(null), STACK_FLASH_MS);
return () => clearTimeout(timer);
}, [player.stack]);
const cards = showHoleCards ? player.holeCards : null;
const cardCount = player.cardCount > 0 ? player.cardCount : 2;
const faceCards = cards ?? Array.from({ length: cardCount }, () => null);
return (
<div
className={cn(
"poker-seat-panel mx-auto flex w-max items-center gap-2 px-2 py-1.5 backdrop-blur-sm",
(player.folded || player.left) && "opacity-55",
"poker-seat-panel flex flex-col gap-2 border p-3 text-left backdrop-blur-sm",
compact && "flex-row flex-wrap items-center gap-2 p-2 sm:flex-nowrap sm:gap-3",
player.folded && "opacity-50",
player.eliminated && "opacity-40",
isActing && "poker-seat-active ring-2 ring-emerald-400/90",
isActing && "ring-2 ring-emerald-400/90",
isCurrentUser && "border-ring",
isWinner && "ring-2 ring-amber-300",
)}
aria-label={`${player.name}, seat ${player.seatId + 1}, stack ${player.stack.toLocaleString()}`}
aria-label={`${player.name}, seat ${player.seatId + 1}`}
>
<span
className={cn("poker-seat-avatar", `poker-seat-avatar-tone-${player.seatId % 4}`)}
aria-hidden="true"
>
{initial}
</span>
<div className="poker-seat-nameplate min-w-0">
<p className="truncate text-[0.65rem] font-medium leading-tight text-muted-foreground">
{player.name}
{isCurrentUser && <span className="ml-1">(you)</span>}
</p>
<p
className={cn(
"poker-seat-stack",
stackFlash === "up" && "poker-seat-stack-flash-up",
stackFlash === "down" && "poker-seat-stack-flash-down",
)}
>
<span className="poker-seat-stack-chip" aria-hidden="true" />
{player.stack.toLocaleString()}
</p>
{(statusLabel || isWinner) && (
<div className="poker-seat-status-row">
{statusLabel && <span className="poker-seat-action-badge">{statusLabel}</span>}
{isWinner && (
<span className="poker-seat-action-badge poker-seat-action-winner">Winner</span>
)}
</div>
)}
<div className={cn("flex items-start justify-between gap-2", compact && "min-w-0 flex-1")}>
<div className="min-w-0">
<p className="truncate text-sm font-semibold">
{player.name}
{isCurrentUser && <span className="ml-1 text-xs text-muted-foreground">(you)</span>}
</p>
<p className="font-mono text-xs tabular-nums text-muted-foreground">
Stack {player.stack.toLocaleString()}
</p>
</div>
<div className="flex shrink-0 items-center gap-1">
{player.allIn && <Badge variant="destructive">All-in</Badge>}
{player.folded && <Badge variant="outline">Folded</Badge>}
{isWinner && <Badge variant="secondary">Winner</Badge>}
</div>
</div>
{revealed ? (
<div className="poker-seat-reveal" aria-label="Revealed hole cards">
{revealed.map((card, index) => (
<CardView key={`${player.seatId}-hole-${index}`} card={card} faceUp size="mini" />
))}
</div>
) : (
inHand && (
<div className="poker-seat-cardbacks" aria-label="Hole cards, face down">
<span aria-hidden="true" />
<span aria-hidden="true" />
</div>
)
{handLabel && (
<span
className={cn(
"w-fit max-w-full border border-amber-300/40 bg-amber-300/10 px-2 py-1 text-xs text-amber-200",
compact && "max-w-[12rem] truncate",
)}
aria-live="polite"
>
You have: {handLabel}
</span>
)}
{player.committed > 0 && (
<div
className="poker-seat-commit"
aria-label={`Current bet ${player.committed.toLocaleString()}`}
>
<span className="poker-seat-commit-chip" aria-hidden="true" />
<span className="font-mono text-[0.65rem] tabular-nums">
{player.committed.toLocaleString()}
</span>
<div className={cn("flex items-end justify-between gap-2", compact && "shrink-0")}>
<div className="flex gap-1" aria-label="Hole cards">
{faceCards.map((card, index) => (
<CardView
key={`${player.seatId}-hole-${index}`}
card={card}
faceUp={card !== null}
size={compact ? "mini" : "sm"}
dealIndex={index}
highlighted={card !== null && cardKeySet?.has(cardKey(card))}
dimmed={card !== null && cardKeySet !== undefined && !cardKeySet.has(cardKey(card))}
/>
))}
</div>
)}
<div className="text-right">
<p className="font-mono text-xs tabular-nums text-muted-foreground">
{player.committed > 0 ? `Bet ${player.committed.toLocaleString()}` : "No bet"}
</p>
{isActing && <p className="text-xs font-semibold text-emerald-300">Acting</p>}
</div>
</div>
</div>
);
}

View file

@ -1,43 +0,0 @@
"use client";
import { Volume2, VolumeX } from "lucide-react";
import { Button } from "@/components/ui/button";
interface PokerSoundToggleProps {
readonly muted: boolean;
readonly volume: number;
readonly onToggleMuted: () => void;
readonly onVolumeChange: (volume: number) => void;
}
export function PokerSoundToggle({
muted,
volume,
onToggleMuted,
onVolumeChange,
}: PokerSoundToggleProps) {
return (
<div className="poker-sound-toggle" aria-label="Poker sound controls">
<Button
type="button"
variant="outline"
size="icon"
onClick={onToggleMuted}
aria-label={muted ? "Unmute poker sounds" : "Mute poker sounds"}
title={muted ? "Unmute poker sounds" : "Mute poker sounds"}
>
{muted ? <VolumeX aria-hidden="true" /> : <Volume2 aria-hidden="true" />}
</Button>
<input
type="range"
min={0}
max={1}
step={0.05}
value={muted ? 0 : volume}
onChange={(event) => onVolumeChange(Number(event.target.value))}
aria-label="Poker sound volume"
className="poker-sound-volume"
/>
</div>
);
}

View file

@ -3,16 +3,12 @@
import { useState } from "react";
import { cn } from "@/lib/utils";
import type { PokerSnapshot } from "@/app/(frontend)/minigames/poker/actions";
import { MIN_PLAYERS } from "@/lib/minigames/poker";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { PokerCardStyleProvider } from "./cardStyle";
import { PokerGameTable } from "./PokerGameTable";
import { RenameTableDialog } from "./RenameTableDialog";
import { usePokerPopups } from "./usePokerPopups";
import { usePokerSounds } from "./usePokerSounds";
import { seatPosition } from "./seatPosition";
interface PokerTableProps {
@ -57,10 +53,7 @@ function WaitingTable({
const viewerSeat = snapshot.seats.findIndex((id) => String(id) === String(currentUserId));
const seated = viewerSeat >= 0;
const full = snapshot.seats.length > 0 && snapshot.seats.every((id) => id !== null);
const seatedCount = snapshot.seats.filter((id) => id !== null).length;
const enoughPlayers = seatedCount >= MIN_PLAYERS;
const isHost = seated && snapshot.hostSeatId === viewerSeat;
const opponentCount = snapshot.seats.length - (seated ? 1 : 0);
const start = async () => {
setStarting(true);
@ -99,34 +92,18 @@ function WaitingTable({
<Badge variant="outline">Waiting room</Badge>
</CardHeader>
<CardContent className="p-4">
<div className="poker-felt-table relative min-h-[30rem] overflow-hidden p-4 sm:min-h-[36rem]">
<div className="poker-felt-table relative min-h-[28rem] overflow-hidden p-4 sm:min-h-[34rem]">
{snapshot.seats.map((userId, seatId) => (
<div
key={seatId}
className={cn(
"poker-waiting-seat absolute flex flex-col items-center gap-1 border px-3 py-2 text-xs",
seatId === viewerSeat
? "poker-waiting-seat-south"
: seatPosition(
snapshot.seats
.slice(0, seatId)
.filter((_, previousSeatId) => previousSeatId !== viewerSeat).length,
opponentCount,
),
"poker-waiting-seat absolute flex flex-col gap-1 border px-3 py-2 text-xs",
seatPosition(seatId),
userId !== null && "poker-waiting-seat-occupied",
)}
>
<span
className={cn(
"poker-seat-avatar",
userId === null ? "poker-seat-avatar-open" : "poker-seat-avatar-tone-0",
)}
aria-hidden="true"
>
{userId === null ? "?" : String(userId).charAt(0).toUpperCase()}
</span>
<span className="poker-waiting-name font-semibold">Seat {seatId + 1}</span>
<span className="poker-waiting-status text-white/70">
<span className="font-semibold">Seat {seatId + 1}</span>
<span className="text-white/70">
{userId === null
? "Open seat"
: String(userId) === String(currentUserId)
@ -149,16 +126,8 @@ function WaitingTable({
{seated ? (
<>
{isHost && (
<Button
type="button"
onClick={() => void start()}
disabled={starting || !enoughPlayers}
>
{starting
? "Starting..."
: enoughPlayers
? "Start game"
: "You need at least two players to start"}
<Button type="button" onClick={() => void start()} disabled={starting}>
{starting ? "Starting..." : "Start game"}
</Button>
)}
<Button type="button" variant="outline" onClick={() => void leave()} disabled={leaving}>
@ -180,7 +149,7 @@ function WaitingTable({
);
}
function PokerTableInner({
export function PokerTable({
snapshot,
currentUserId,
loading,
@ -191,12 +160,6 @@ function PokerTableInner({
onLeave,
onBack,
}: PokerTableProps) {
// Sound + popup transitions must live here, ABOVE the waiting/game split:
// the hooks have to observe the waiting-room-to-first-hand transition,
// which happens before the game table (and its seat view) mounts.
const sound = usePokerSounds(snapshot);
const popups = usePokerPopups(snapshot);
if (loading && !snapshot) {
return (
<Card className="rounded-none">
@ -251,16 +214,6 @@ function PokerTableInner({
onSnapshot={onSnapshot}
onStart={onStart}
onLeave={onLeave}
sound={sound}
popups={popups}
/>
);
}
export function PokerTable(props: PokerTableProps) {
return (
<PokerCardStyleProvider>
<PokerTableInner {...props} />
</PokerCardStyleProvider>
);
}

View file

@ -1,89 +0,0 @@
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import type { Card } from "@/lib/minigames/poker";
export type PokerCardStyle = "light" | "dark" | "classic";
const CARD_STYLE_STORAGE_KEY = "poker-card-style";
const RANK_FILE_PREFIXES: Record<number, string> = {
11: "jack",
12: "queen",
13: "king",
14: "ace",
};
const SUIT_FILE_SUFFIXES = {
s: "spades",
h: "hearts",
d: "diamonds",
c: "clubs",
} as const;
export function mapCardToSvg(card: Card): string {
const prefix = RANK_FILE_PREFIXES[card.rank] ?? String(card.rank);
const suffix = SUIT_FILE_SUFFIXES[card.suit];
return `/images/assets/playing-cards/${prefix}_of_${suffix}.svg`;
}
export function normalizeCardStyle(value: unknown): PokerCardStyle {
return value === "dark" || value === "classic" ? value : "light";
}
export const POKER_CARD_STYLE_OPTIONS: readonly {
readonly value: PokerCardStyle;
readonly label: string;
}[] = [
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
{ value: "classic", label: "Classic SVG" },
];
interface CardStyleContextValue {
readonly style: PokerCardStyle;
readonly setStyle: (style: PokerCardStyle) => void;
}
const CardStyleContext = createContext<CardStyleContextValue>({
style: "light",
setStyle: () => {},
});
export function PokerCardStyleProvider({ children }: { readonly children: ReactNode }) {
const [style, setStyleState] = useState<PokerCardStyle>("light");
useEffect(() => {
try {
const stored = window.localStorage.getItem(CARD_STYLE_STORAGE_KEY);
if (stored !== null) setStyleState(normalizeCardStyle(stored));
} catch {
// storage unavailable (private mode, disabled): keep the default
}
}, []);
const setStyle = useCallback((next: PokerCardStyle) => {
setStyleState(next);
try {
window.localStorage.setItem(CARD_STYLE_STORAGE_KEY, next);
} catch {
// non-fatal: the choice just will not persist
}
}, []);
const value = useMemo(() => ({ style, setStyle }), [style, setStyle]);
return <CardStyleContext.Provider value={value}>{children}</CardStyleContext.Provider>;
}
export function useCardStyle(): CardStyleContextValue {
return useContext(CardStyleContext);
}

View file

@ -1,175 +0,0 @@
import type { PokerActionKind, PublicPlayerView } from "@/lib/minigames/poker";
import { BLIND_STAGGER_MS, type PokerSoundSnapshot } from "./pokerSoundEvents";
export type PokerPopupTone = "action" | "wager" | "win" | "fold" | "leave";
export interface PokerPopupEvent {
readonly key: string;
readonly seatId: number;
readonly text: string;
readonly tone: PokerPopupTone;
/** ms to wait before this popup appears (staggered blind sequence), default 0. */
readonly delay?: number;
}
function playerBySeat(
players: readonly PublicPlayerView[],
seatId: number,
): PublicPlayerView | null {
return players.find((player) => player.seatId === seatId) ?? null;
}
function wagerAction(
previousBet: number,
nextBet: number,
): Extract<PokerActionKind, "bet" | "call" | "raise"> {
if (previousBet === 0 && nextBet > 0) return "bet";
if (nextBet > previousBet) return "raise";
return "call";
}
function wagerLabel(
action: Extract<PokerActionKind, "bet" | "call" | "raise">,
amount: number,
allIn: boolean,
): string {
const formatted = `+${amount.toLocaleString()}`;
if (allIn) return `All-in ${formatted}`;
if (action === "bet") return `Bet ${formatted}`;
if (action === "raise") return `Raise ${formatted}`;
return `Call ${formatted}`;
}
export function popupEventsForTransition(
previous: PokerSoundSnapshot | null,
next: PokerSoundSnapshot,
): readonly PokerPopupEvent[] {
if (!previous || previous.version === next.version) return [];
const previousView = previous.you;
const nextView = next.you;
if (!nextView) return [];
const events: PokerPopupEvent[] = [];
const transitionKey = `${next.version}:${nextView.handNumber}:${nextView.phase}:${nextView.street}`;
const sameHandAndStreet =
previousView !== null &&
previousView.handNumber === nextView.handNumber &&
previousView.street === nextView.street;
// New hand: present the blind posts as a staggered mini-sequence (small
// blind, then big blind) so the hand opens with a readable flow before the
// first real turn. The previous view may be the waiting room (no seat view
// yet) when this is the game's first dealt hand.
if (
nextView.phase === "betting" &&
(previousView === null || nextView.handNumber > previousView.handNumber)
) {
events.push({
key: `${transitionKey}:popup:blind-sb`,
seatId: nextView.smallBlindSeat,
text: `Small blind +${nextView.config.smallBlind.toLocaleString()}`,
tone: "wager",
delay: 0,
});
events.push({
key: `${transitionKey}:popup:blind-bb`,
seatId: nextView.bigBlindSeat,
text: `Big blind +${nextView.config.bigBlind.toLocaleString()}`,
tone: "wager",
delay: BLIND_STAGGER_MS,
});
return events;
}
if (!previousView) return events;
if (sameHandAndStreet && previousView.phase === "betting" && nextView.phase === "betting") {
for (const nextPlayer of nextView.players) {
const previousPlayer = playerBySeat(previousView.players, nextPlayer.seatId);
if (!previousPlayer) continue;
if (!previousPlayer.folded && nextPlayer.folded) {
events.push({
key: `${transitionKey}:popup:fold:${nextPlayer.seatId}`,
seatId: nextPlayer.seatId,
text: "Fold",
tone: "fold",
});
continue;
}
if (nextPlayer.folded) continue;
// Same committed-growth rule as the sound mapper: blinds post on a new
// hand (handNumber differs), so any mid-street increase is the player's
// own wager, even when they already acted earlier in the street.
const committedDelta = nextPlayer.totalCommitted - previousPlayer.totalCommitted;
if (committedDelta > 0) {
events.push({
key: `${transitionKey}:popup:wager:${nextPlayer.seatId}:${committedDelta}`,
seatId: nextPlayer.seatId,
text: wagerLabel(
wagerAction(previousView.currentBet, nextView.currentBet),
committedDelta,
nextPlayer.allIn,
),
tone: "wager",
});
} else if (!previousPlayer.hasActed && nextPlayer.hasActed) {
events.push({
key: `${transitionKey}:popup:check:${nextPlayer.seatId}`,
seatId: nextPlayer.seatId,
text: "Check",
tone: "action",
});
}
}
}
// Leaving is any of: a vanished seat (removed between hands), a mid-game
// leaver whose view flips the left flag immediately, or the later eliminated
// forfeit. Busted stacks are never eliminated or left, so they never read
// as a leave; the left||eliminated pair fires exactly once per player.
for (const previousPlayer of previousView.players) {
const nextPlayer = playerBySeat(nextView.players, previousPlayer.seatId);
if (!nextPlayer) {
events.push({
key: `${transitionKey}:popup:leave:${previousPlayer.seatId}`,
seatId: previousPlayer.seatId,
text: `${previousPlayer.name} left the table`,
tone: "leave",
});
continue;
}
const leftBefore = previousPlayer.left || previousPlayer.eliminated;
const leftNow = nextPlayer.left || nextPlayer.eliminated;
if (leftNow && !leftBefore) {
events.push({
key: `${transitionKey}:popup:leave:${previousPlayer.seatId}`,
seatId: previousPlayer.seatId,
text: `${previousPlayer.name} left the table`,
tone: "leave",
});
}
}
// A hand ends in exactly one of showdown or hand-complete, and both phases
// carry showdown results, so every winner gets one "+N" popup, never twice.
const handEnded =
(nextView.phase === "hand-complete" && previousView.phase !== "hand-complete") ||
(nextView.phase === "showdown" && previousView.phase !== "showdown");
if (handEnded && nextView.showdown) {
for (const result of nextView.showdown.results) {
if (result.chipsWon <= 0) continue;
events.push({
key: `${transitionKey}:popup:win:${result.seatId}:${result.chipsWon}`,
seatId: result.seatId,
text: `+${result.chipsWon.toLocaleString()}`,
tone: "win",
});
}
}
return events;
}

View file

@ -1,264 +0,0 @@
import type { PokerSoundEvent } from "./pokerSoundEvents";
export const DEFAULT_POKER_SOUND_VOLUME = 0.28;
type OscillatorShape = OscillatorType;
function assertNever(value: never): never {
throw new Error(`Unknown poker sound event: ${JSON.stringify(value)}`);
}
function clampVolume(value: number): number {
if (!Number.isFinite(value)) return DEFAULT_POKER_SOUND_VOLUME;
return Math.min(1, Math.max(0, value));
}
export class PokerSoundEngine {
private context: AudioContext | null = null;
private masterGain: GainNode | null = null;
private jazzTimer: number | null = null;
private jazzRunning = false;
private jazzNextAt = 0;
private muted = false;
private volume = DEFAULT_POKER_SOUND_VOLUME;
public setMuted(muted: boolean): void {
this.muted = muted;
this.setMasterGain();
if (muted) {
this.stopJazzSchedule();
} else if (this.context) {
this.startJazzSchedule();
}
}
public setVolume(volume: number): void {
this.volume = clampVolume(volume);
this.setMasterGain();
}
public unlock(): void {
const context = this.ensureContext();
if (!context) return;
if (context.state === "suspended") {
void context.resume().catch(() => undefined);
}
if (!this.muted) this.startJazzSchedule();
}
public playUiClick(): void {
this.unlock();
const context = this.context;
if (!context || this.muted) return;
this.scheduleTone(720, 0.045, 0.045, context.currentTime, "triangle");
}
public playEvent(event: PokerSoundEvent): void {
const context = this.context;
if (!context || this.muted) return;
switch (event.type) {
case "hand-start":
this.playStreetDeal(2, context.currentTime);
return;
case "turn-gained":
this.playTurnGained(context.currentTime);
return;
case "turn-lost":
this.scheduleTone(330, 0.12, 0.07, context.currentTime, "sine");
return;
case "street-deal":
this.playStreetDeal(event.cardCount, context.currentTime);
return;
case "wager":
this.playWager(event.action, context.currentTime);
return;
case "fold":
this.scheduleTone(180, 0.16, 0.08, context.currentTime, "triangle");
return;
case "check":
this.scheduleTone(520, 0.07, 0.045, context.currentTime, "triangle");
return;
case "showdown":
this.playShowdown(context.currentTime);
return;
case "hand-result":
this.playHandResult(event.result, context.currentTime);
return;
case "game-complete":
this.playGameComplete(context.currentTime);
return;
default:
return assertNever(event);
}
}
public dispose(): void {
this.stopJazzSchedule();
const context = this.context;
this.context = null;
this.masterGain = null;
if (context && context.state !== "closed") void context.close().catch(() => undefined);
}
private ensureContext(): AudioContext | null {
if (this.context) return this.context;
if (typeof window === "undefined" || typeof window.AudioContext !== "function") return null;
try {
const context = new window.AudioContext();
const masterGain = context.createGain();
masterGain.connect(context.destination);
this.context = context;
this.masterGain = masterGain;
this.setMasterGain();
return context;
} catch (error) {
if (error instanceof Error) return null;
return null;
}
}
private setMasterGain(): void {
if (!this.masterGain || !this.context) return;
const target = this.muted ? 0 : this.volume;
this.masterGain.gain.setTargetAtTime(target, this.context.currentTime, 0.035);
}
private scheduleTone(
frequency: number,
duration: number,
level: number,
start: number,
shape: OscillatorShape,
detune = 0,
): void {
const context = this.context;
const masterGain = this.masterGain;
if (!context || !masterGain) return;
const oscillator = context.createOscillator();
const gain = context.createGain();
oscillator.type = shape;
oscillator.frequency.setValueAtTime(frequency, start);
oscillator.detune.setValueAtTime(detune, start);
gain.gain.setValueAtTime(0.0001, start);
gain.gain.exponentialRampToValueAtTime(Math.max(0.0001, level), start + 0.012);
gain.gain.exponentialRampToValueAtTime(0.0001, start + duration);
oscillator.connect(gain);
gain.connect(masterGain);
oscillator.start(start);
oscillator.stop(start + duration + 0.04);
}
private scheduleNoise(duration: number, level: number, start: number, highPass = 900): void {
const context = this.context;
const masterGain = this.masterGain;
if (!context || !masterGain) return;
const frameCount = Math.max(1, Math.floor(context.sampleRate * duration));
const buffer = context.createBuffer(1, frameCount, context.sampleRate);
const channel = buffer.getChannelData(0);
for (let index = 0; index < channel.length; index += 1) {
channel[index] = Math.random() * 2 - 1;
}
const source = context.createBufferSource();
const filter = context.createBiquadFilter();
const gain = context.createGain();
source.buffer = buffer;
filter.type = "highpass";
filter.frequency.setValueAtTime(highPass, start);
gain.gain.setValueAtTime(level, start);
gain.gain.exponentialRampToValueAtTime(0.0001, start + duration);
source.connect(filter);
filter.connect(gain);
gain.connect(masterGain);
source.start(start);
source.stop(start + duration + 0.04);
}
private playTurnGained(start: number): void {
this.scheduleTone(660, 0.16, 0.08, start, "sine");
this.scheduleTone(880, 0.2, 0.07, start + 0.11, "sine");
}
private playStreetDeal(cardCount: number, start: number): void {
for (let index = 0; index < cardCount; index += 1) {
const offset = index * 0.075;
this.scheduleNoise(0.12, 0.035, start + offset, 1200);
this.scheduleTone(440 + index * 35, 0.08, 0.025, start + offset, "triangle");
}
}
private playWager(action: "bet" | "call" | "raise", start: number): void {
this.scheduleNoise(0.07, 0.08, start, 650);
const frequency = action === "raise" ? 620 : action === "bet" ? 540 : 460;
this.scheduleTone(frequency, 0.1, 0.055, start + 0.035, "triangle");
}
private playShowdown(start: number): void {
this.scheduleNoise(0.22, 0.045, start, 700);
this.scheduleTone(392, 0.45, 0.05, start, "sine");
this.scheduleTone(494, 0.45, 0.045, start + 0.04, "sine");
this.scheduleTone(587, 0.5, 0.04, start + 0.08, "sine");
}
private playHandResult(result: "win" | "loss", start: number): void {
if (result === "win") {
this.scheduleTone(523, 0.22, 0.06, start, "triangle");
this.scheduleTone(659, 0.25, 0.06, start + 0.12, "triangle");
this.scheduleTone(784, 0.34, 0.07, start + 0.24, "triangle");
return;
}
this.scheduleTone(330, 0.24, 0.055, start, "sine");
this.scheduleTone(247, 0.34, 0.05, start + 0.16, "sine");
}
private playGameComplete(start: number): void {
[392, 494, 587, 784].forEach((frequency, index) => {
this.scheduleTone(frequency, 0.32, 0.055, start + index * 0.13, "triangle");
});
}
private startJazzSchedule(): void {
if (this.jazzRunning || !this.context || this.muted) return;
this.jazzRunning = true;
this.jazzNextAt = Math.max(this.context.currentTime + 0.08, this.jazzNextAt);
this.scheduleJazzBar();
}
private scheduleJazzBar(): void {
const context = this.context;
if (!context || !this.jazzRunning || this.muted) return;
const beat = 60 / 92;
const start = Math.max(context.currentTime + 0.04, this.jazzNextAt);
const chordSets = [
[261.63, 329.63, 392],
[220, 277.18, 329.63],
[246.94, 311.13, 369.99],
[196, 246.94, 293.66],
];
const chord = chordSets[Math.floor(start / (beat * 4)) % chordSets.length] ?? chordSets[0];
chord.forEach((frequency, index) => {
this.scheduleTone(frequency, beat * 2.8, 0.012, start, "sine", index * 4 - 4);
});
for (let beatIndex = 0; beatIndex < 4; beatIndex += 1) {
const beatStart = start + beatIndex * beat;
this.scheduleTone(82.41 + beatIndex * 3, beat * 0.7, 0.018, beatStart, "triangle");
this.scheduleNoise(0.045, 0.012, beatStart + beat * 0.5, 3200);
this.scheduleNoise(0.035, 0.009, beatStart + beat * 0.75, 4200);
}
this.jazzNextAt = start + beat * 4;
this.jazzTimer = window.setTimeout(() => this.scheduleJazzBar(), beat * 4 * 1000 - 80);
}
private stopJazzSchedule(): void {
if (this.jazzTimer !== null) window.clearTimeout(this.jazzTimer);
this.jazzTimer = null;
this.jazzRunning = false;
}
}

View file

@ -1,234 +0,0 @@
import type { PokerSnapshot } from "@/app/(frontend)/minigames/poker/actions";
import type { PokerActionKind, PokerStreet, PublicPlayerView } from "@/lib/minigames/poker";
type PokerSoundView = NonNullable<PokerSnapshot["you"]>;
export type PokerSoundSnapshot = Pick<PokerSnapshot, "version"> & {
readonly you: Pick<
PokerSoundView,
| "communityCards"
| "currentBet"
| "config"
| "finalPlacements"
| "handNumber"
| "phase"
| "players"
| "showdown"
| "smallBlindSeat"
| "bigBlindSeat"
| "street"
| "toActSeat"
| "viewerSeat"
| "yourTurn"
> | null;
};
export type PokerSoundEvent =
| { readonly type: "hand-start"; readonly key: string; readonly handNumber: number }
| { readonly type: "turn-gained"; readonly key: string }
| { readonly type: "turn-lost"; readonly key: string }
| {
readonly type: "street-deal";
readonly key: string;
readonly street: Extract<PokerStreet, "flop" | "turn" | "river">;
readonly cardCount: number;
}
| {
readonly type: "wager";
readonly key: string;
readonly action: Extract<PokerActionKind, "bet" | "call" | "raise">;
readonly amount: number;
readonly seatId: number;
/** ms delay before playback (staggered blind sequence), default 0. */
readonly delay?: number;
}
| { readonly type: "fold"; readonly key: string; readonly seatId: number }
| { readonly type: "check"; readonly key: string; readonly seatId: number }
| { readonly type: "showdown"; readonly key: string }
| {
readonly type: "hand-result";
readonly key: string;
readonly result: "win" | "loss";
readonly netChange: number;
}
| { readonly type: "game-complete"; readonly key: string; readonly placement: number | null };
const STREET_CARD_COUNTS: Record<Extract<PokerStreet, "flop" | "turn" | "river">, number> = {
flop: 3,
turn: 1,
river: 1,
};
function playerBySeat(
players: readonly PublicPlayerView[],
seatId: number,
): PublicPlayerView | null {
return players.find((player) => player.seatId === seatId) ?? null;
}
function wagerAction(
previousBet: number,
nextBet: number,
): Extract<PokerActionKind, "bet" | "call" | "raise"> {
if (previousBet === 0 && nextBet > 0) return "bet";
if (nextBet > previousBet) return "raise";
return "call";
}
function resultForViewer(
snapshot: PokerSoundSnapshot,
): { readonly result: "win" | "loss"; readonly netChange: number } | null {
const view = snapshot.you;
if (!view) return null;
const showdown = view.showdown;
const result = showdown?.results.find((item) => item.seatId === view.viewerSeat);
if (!result) return null;
return {
result: result.netChange > 0 ? "win" : "loss",
netChange: result.netChange,
};
}
/** Gap between the small-blind and big-blind steps of the new-hand sequence. */
export const BLIND_STAGGER_MS = 700;
export function soundEventsForTransition(
previous: PokerSoundSnapshot | null,
next: PokerSoundSnapshot,
): readonly PokerSoundEvent[] {
if (!previous || previous.version === next.version) return [];
const previousView = previous.you;
const nextView = next.you;
if (!nextView) return [];
const events: PokerSoundEvent[] = [];
const transitionKey = `${next.version}:${nextView.handNumber}:${nextView.phase}:${nextView.street}`;
// The hand-start chime and staggered blind sequence also cover the very
// first dealt hand of a game: the previous view is the waiting room (no
// seat view yet), so only the blind seats and config are needed.
if (
nextView.phase === "betting" &&
(previousView === null || nextView.handNumber > previousView.handNumber)
) {
events.push({
type: "hand-start",
key: `${transitionKey}:hand-start:${nextView.handNumber}`,
handNumber: nextView.handNumber,
});
events.push({
type: "wager",
key: `${transitionKey}:blind-sb`,
action: "bet",
amount: nextView.config.smallBlind,
seatId: nextView.smallBlindSeat,
delay: 0,
});
events.push({
type: "wager",
key: `${transitionKey}:blind-bb`,
action: "bet",
amount: nextView.config.bigBlind,
seatId: nextView.bigBlindSeat,
delay: BLIND_STAGGER_MS,
});
return events;
}
if (!previousView) return events;
if (nextView.phase === "betting" && previousView.yourTurn !== nextView.yourTurn) {
events.push({
type: nextView.yourTurn ? "turn-gained" : "turn-lost",
key: `${transitionKey}:turn:${nextView.yourTurn ? "gained" : "lost"}:${nextView.toActSeat ?? "none"}`,
});
}
if (
previousView.handNumber === nextView.handNumber &&
previousView.street !== nextView.street &&
nextView.street !== "preflop"
) {
events.push({
type: "street-deal",
key: `${transitionKey}:street:${nextView.communityCards.length}`,
street: nextView.street,
cardCount: STREET_CARD_COUNTS[nextView.street],
});
}
const sameHandAndStreet =
previousView.handNumber === nextView.handNumber && previousView.street === nextView.street;
if (sameHandAndStreet && previousView.phase === "betting" && nextView.phase === "betting") {
for (const nextPlayer of nextView.players) {
const previousPlayer = playerBySeat(previousView.players, nextPlayer.seatId);
if (!previousPlayer) continue;
if (!previousPlayer.folded && nextPlayer.folded) {
events.push({
type: "fold",
key: `${transitionKey}:fold:${nextPlayer.seatId}`,
seatId: nextPlayer.seatId,
});
continue;
}
if (nextPlayer.folded) continue;
// Committed chips only grow from a player's own actions mid-street, so
// any increase is a wager even when the player already acted (e.g. bet
// the flop, then call a raise later in the same street).
const committedDelta = nextPlayer.totalCommitted - previousPlayer.totalCommitted;
if (committedDelta > 0) {
events.push({
type: "wager",
key: `${transitionKey}:wager:${nextPlayer.seatId}:${committedDelta}`,
action: wagerAction(previousView.currentBet, nextView.currentBet),
amount: committedDelta,
seatId: nextPlayer.seatId,
});
} else if (!previousPlayer.hasActed && nextPlayer.hasActed) {
events.push({
type: "check",
key: `${transitionKey}:check:${nextPlayer.seatId}`,
seatId: nextPlayer.seatId,
});
}
}
}
if (nextView.phase === "showdown" && previousView.phase !== "showdown") {
events.push({ type: "showdown", key: `${transitionKey}:showdown` });
}
// A hand ends in exactly one of showdown (cards revealed) or hand-complete
// (everyone else folded), and both phases carry showdown results, so the
// viewer win/loss jingle fires on entry to either one, never twice.
const handEnded =
(nextView.phase === "hand-complete" && previousView.phase !== "hand-complete") ||
(nextView.phase === "showdown" && previousView.phase !== "showdown");
if (handEnded) {
const result = resultForViewer(next);
if (result) {
events.push({
type: "hand-result",
key: `${transitionKey}:result:${result.result}`,
result: result.result,
netChange: result.netChange,
});
}
}
if (nextView.phase === "game-complete" && previousView.phase !== "game-complete") {
const placement =
nextView.finalPlacements?.find((item) => item.seatId === nextView.viewerSeat)?.placement ??
null;
events.push({
type: "game-complete",
key: `${transitionKey}:game-complete:${placement ?? "unknown"}`,
placement,
});
}
return events;
}

View file

@ -1,28 +1,10 @@
const ARC_POSITION_CLASSES = [
["poker-seat-arc-center"],
["poker-seat-arc-left", "poker-seat-arc-right"],
["poker-seat-arc-left", "poker-seat-arc-center", "poker-seat-arc-right"],
[
"poker-seat-arc-far-left",
"poker-seat-arc-center-left",
"poker-seat-arc-center-right",
"poker-seat-arc-far-right",
],
const SEAT_POSITION_CLASSES = [
"poker-seat-south",
"poker-seat-west",
"poker-seat-north",
"poker-seat-east",
] as const;
export function seatPosition(opponentIndex: number, opponentCount: number): string {
const normalizedCount = Math.min(ARC_POSITION_CLASSES.length, Math.max(1, opponentCount));
const positions = ARC_POSITION_CLASSES[normalizedCount - 1] ?? ARC_POSITION_CLASSES[0];
const normalizedIndex = Math.min(positions.length - 1, Math.max(0, opponentIndex));
return positions[normalizedIndex] ?? positions[0] ?? "poker-seat-arc-center";
}
const TOP_SEAT_CLASSES = new Set<string>([
"poker-seat-arc-center",
"poker-seat-arc-center-left",
"poker-seat-arc-center-right",
]);
export function isTopSeatPosition(positionClass: string): boolean {
return TOP_SEAT_CLASSES.has(positionClass);
export function seatPosition(seatId: number): string {
return SEAT_POSITION_CLASSES[seatId % SEAT_POSITION_CLASSES.length] ?? SEAT_POSITION_CLASSES[0];
}

View file

@ -1,86 +0,0 @@
"use client";
import { useEffect, useRef, useState } from "react";
import type { PokerSnapshot } from "@/app/(frontend)/minigames/poker/actions";
import { popupEventsForTransition, type PokerPopupEvent } from "./pokerPopups";
import type { PokerSoundSnapshot } from "./pokerSoundEvents";
export type ActivePokerPopup = PokerPopupEvent;
const POPUP_LIFETIME_MS = 1500;
const LEAVE_POPUP_LIFETIME_MS = 3000;
const MAX_ACTIVE_POPUPS = 12;
const MAX_SEEN_KEYS = 256;
const POPUP_LIFETIMES_MS: Record<PokerPopupEvent["tone"], number> = {
action: POPUP_LIFETIME_MS,
wager: POPUP_LIFETIME_MS,
win: POPUP_LIFETIME_MS,
fold: POPUP_LIFETIME_MS,
leave: LEAVE_POPUP_LIFETIME_MS,
};
export function usePokerPopups(snapshot: PokerSnapshot | null): readonly ActivePokerPopup[] {
const [popups, setPopups] = useState<readonly ActivePokerPopup[]>([]);
const previousSnapshotRef = useRef<PokerSoundSnapshot | null>(null);
const seenKeysRef = useRef<Set<string>>(new Set());
const timersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
useEffect(() => {
if (!snapshot) {
previousSnapshotRef.current = null;
return;
}
const previousSnapshot = previousSnapshotRef.current;
if (previousSnapshot) {
const fresh = popupEventsForTransition(previousSnapshot, snapshot).filter((event) => {
if (seenKeysRef.current.has(event.key)) return false;
seenKeysRef.current.add(event.key);
return true;
});
while (seenKeysRef.current.size > MAX_SEEN_KEYS) {
const oldest = seenKeysRef.current.values().next().value;
if (typeof oldest !== "string") break;
seenKeysRef.current.delete(oldest);
}
if (fresh.length > 0) {
const immediate: ActivePokerPopup[] = [];
for (const event of fresh) {
const delay = event.delay ?? 0;
if (delay > 0) {
timersRef.current.push(
setTimeout(() => {
setPopups((current) => [...current, event].slice(-MAX_ACTIVE_POPUPS));
timersRef.current.push(
setTimeout(() => {
setPopups((current) => current.filter((popup) => popup.key !== event.key));
}, POPUP_LIFETIMES_MS[event.tone]),
);
}, delay),
);
} else {
immediate.push(event);
timersRef.current.push(
setTimeout(() => {
setPopups((current) => current.filter((popup) => popup.key !== event.key));
}, POPUP_LIFETIMES_MS[event.tone]),
);
}
}
if (immediate.length > 0) {
setPopups((current) => [...current, ...immediate].slice(-MAX_ACTIVE_POPUPS));
}
}
}
previousSnapshotRef.current = snapshot;
}, [snapshot]);
useEffect(() => {
return () => {
for (const timer of timersRef.current) clearTimeout(timer);
timersRef.current = [];
};
}, []);
return popups;
}

View file

@ -1,162 +0,0 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import type { PokerSnapshot } from "@/app/(frontend)/minigames/poker/actions";
import { soundEventsForTransition, type PokerSoundSnapshot } from "./pokerSoundEvents";
import { DEFAULT_POKER_SOUND_VOLUME, PokerSoundEngine } from "./pokerSound";
const SOUND_SETTINGS_KEY = "polaris:poker-sound-settings";
export interface PokerSoundSettings {
readonly muted: boolean;
readonly volume: number;
}
export interface UsePokerSoundsResult extends PokerSoundSettings {
readonly toggleMuted: () => void;
readonly setVolume: (volume: number) => void;
readonly playUiClick: () => void;
}
const DEFAULT_SOUND_SETTINGS: PokerSoundSettings = {
muted: false,
volume: DEFAULT_POKER_SOUND_VOLUME,
};
export type PokerSoundControls = ReturnType<typeof usePokerSounds>;
function isStoredSettings(
value: unknown,
): value is { readonly muted: unknown; readonly volume: unknown } {
return typeof value === "object" && value !== null && "muted" in value && "volume" in value;
}
function clampVolume(value: number): number {
if (!Number.isFinite(value)) return DEFAULT_POKER_SOUND_VOLUME;
return Math.min(1, Math.max(0, value));
}
function readStoredSettings(): PokerSoundSettings {
if (typeof window === "undefined") return DEFAULT_SOUND_SETTINGS;
try {
const raw = window.localStorage.getItem(SOUND_SETTINGS_KEY);
if (!raw) return DEFAULT_SOUND_SETTINGS;
const parsed: unknown = JSON.parse(raw);
if (
!isStoredSettings(parsed) ||
typeof parsed.muted !== "boolean" ||
typeof parsed.volume !== "number"
) {
return DEFAULT_SOUND_SETTINGS;
}
return { muted: parsed.muted, volume: clampVolume(parsed.volume) };
} catch (error) {
if (error instanceof Error) return DEFAULT_SOUND_SETTINGS;
return DEFAULT_SOUND_SETTINGS;
}
}
function writeStoredSettings(settings: PokerSoundSettings): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(SOUND_SETTINGS_KEY, JSON.stringify(settings));
} catch (error) {
if (error instanceof Error) return;
}
}
export function usePokerSounds(snapshot: PokerSnapshot | null): UsePokerSoundsResult {
const [settings, setSettings] = useState<PokerSoundSettings>(DEFAULT_SOUND_SETTINGS);
const engineRef = useRef<PokerSoundEngine | null>(null);
const previousSnapshotRef = useRef<PokerSoundSnapshot | null>(null);
const playedEventKeysRef = useRef<Set<string>>(new Set());
const timersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
const [settingsLoaded, setSettingsLoaded] = useState(false);
const ensureEngine = useCallback((): PokerSoundEngine => {
const engine = engineRef.current ?? new PokerSoundEngine();
engineRef.current = engine;
engine.setVolume(settings.volume);
engine.setMuted(settings.muted);
engine.unlock();
return engine;
}, [settings.muted, settings.volume]);
const playUiClick = useCallback(() => {
ensureEngine().playUiClick();
}, [ensureEngine]);
const toggleMuted = useCallback(() => {
const nextMuted = !settings.muted;
const engine = ensureEngine();
engine.setMuted(nextMuted);
setSettings((current) => ({ ...current, muted: nextMuted }));
}, [ensureEngine, settings.muted]);
const setVolume = useCallback(
(volume: number) => {
const nextVolume = clampVolume(volume);
const engine = ensureEngine();
engine.setVolume(nextVolume);
setSettings((current) => ({ ...current, volume: nextVolume }));
},
[ensureEngine],
);
useEffect(() => {
setSettings(readStoredSettings());
setSettingsLoaded(true);
}, []);
useEffect(() => {
if (settingsLoaded) writeStoredSettings(settings);
}, [settings, settingsLoaded]);
useEffect(() => {
if (!snapshot) {
previousSnapshotRef.current = null;
return;
}
const previousSnapshot = previousSnapshotRef.current;
if (previousSnapshot) {
const events = soundEventsForTransition(previousSnapshot, snapshot);
const engine = engineRef.current;
if (engine) {
for (const event of events) {
if (playedEventKeysRef.current.has(event.key)) continue;
playedEventKeysRef.current.add(event.key);
if (event.type === "wager" && event.delay && event.delay > 0) {
timersRef.current.push(setTimeout(() => engine.playEvent(event), event.delay));
} else {
engine.playEvent(event);
}
}
}
while (playedEventKeysRef.current.size > 256) {
const oldest = playedEventKeysRef.current.values().next().value;
if (typeof oldest !== "string") break;
playedEventKeysRef.current.delete(oldest);
}
}
previousSnapshotRef.current = snapshot;
}, [snapshot]);
useEffect(() => {
return () => {
for (const timer of timersRef.current) clearTimeout(timer);
timersRef.current = [];
engineRef.current?.dispose();
engineRef.current = null;
};
}, []);
return {
muted: settings.muted,
volume: settings.volume,
toggleMuted,
setVolume,
playUiClick,
};
}

View file

@ -1,5 +1,5 @@
import type { Payload } from "payload";
import type { Assignment, Award, Profile } from "@/payload-types";
import type { Assignment, Profile } from "@/payload-types";
type AssignmentType = Assignment["type"];
type RecipientScope = "same_command" | "whitelist" | "anyone";
@ -26,7 +26,10 @@ export async function getGrantorAssignments(
const res = await payload.find({
collection: "assignments",
where: {
and: [{ leader: { equals: userId } }, { type: { in: [...allowedTypes] } }],
and: [
{ leader: { equals: userId } },
{ type: { in: [...allowedTypes] } },
],
},
limit: 100,
depth: 0,
@ -82,42 +85,9 @@ export async function getAssignmentTreeMembers(
return [...collected];
}
/**
* Get awards by their IDs in a single batched find operation.
*/
export async function getAwardsByIds(payload: Payload, ids: number[]): Promise<Award[]> {
if (ids.length === 0) return [];
const res = await payload.find({
collection: "awards",
where: {
id: {
in: ids,
},
},
limit: 1000,
depth: 0,
overrideAccess: true,
});
return res.docs;
}
/**
* Helper to get the held tier of an award in a series.
* Returns the advancement.tier if the award's advancement.seriesKey matches the given key,
* otherwise returns null.
*/
export function heldTierOfSeries(award: Award, seriesKey: string): number | null {
const normalizedSeriesKey = seriesKey.trim().toLowerCase().replace(/\s+/g, "-");
if (award.advancement?.seriesKey === normalizedSeriesKey) {
return award.advancement.tier ?? null;
}
return null;
}
/**
* How many times `grantorProfileId` has already issued `awardId` across all
* profiles. Scans every profile (depth 0) and counts matching grant rows.
* Excludes entries where `upgradedFrom` is set (upgrade entries are exempt from grant accounting).
*/
export async function countGrantsByGrantor(
payload: Payload,
@ -133,9 +103,6 @@ export async function countGrantsByGrantor(
let count = 0;
for (const profile of res.docs) {
for (const entry of profile.progression?.awards ?? []) {
// Skip entries that are upgrades (exempt from grant accounting)
if (entry.upgradedFrom) continue;
if (refId(entry.award) === awardId && refId(entry.awardedBy) === grantorProfileId) {
count += 1;
}
@ -196,7 +163,10 @@ export async function isRecipientEligible(
}
/** The profile belonging to `userId`, or null if none exists. */
export async function findUserProfile(payload: Payload, userId: number): Promise<Profile | null> {
export async function findUserProfile(
payload: Payload,
userId: number,
): Promise<Profile | null> {
const res = await payload.find({
collection: "profiles",
where: { user: { equals: userId } },

View file

@ -30,7 +30,7 @@ import {
STARTING_STACK,
TURN_TIMEOUT_MS,
} from "./constants";
import { advanceState, awardFoldOut, createTableState } from "./engine";
import { advanceState, createTableState } from "./engine";
import { awardPokerGameResult, type PokerPlacementResult } from "./stats";
import type {
PokerActionKind,
@ -256,46 +256,6 @@ function countHandWins(record: PokerTableRecord): void {
}
}
/** In-app bell notification: the game auto-ended because everyone else left. */
async function notifyLastPlayer(record: PokerTableRecord): Promise<void> {
const lastUserId = record.seatMap.find(
(id): id is number => id !== null && !record.leftUsers.has(id),
);
if (lastUserId === undefined) return;
try {
const config = (await import("@payload-config")).default;
const payload = await getPayload({ config });
await notifyUser(payload, {
userId: lastUserId,
type: "poker:everyone-left",
title: "Poker game ended",
message: "Everyone left the table, so the game ended. Check your final placement.",
link: "/minigames/poker",
});
} catch {
// Payload unavailable in this runtime; the game already ended client-side.
}
}
/**
* One seated player remains and every other seat left mid-game: award the
* remaining player the live pot (leavers forfeit their committed chips) and
* complete the game immediately instead of waiting out the next-hand timer.
*/
function finalizeForLastPlayer(record: PokerTableRecord): void {
const current = record.state;
if (!current || current.phase === "game-complete") return;
clearTurnTimer(record);
clearNextHandTimer(record);
let next = eliminateLeavers(current, record);
if (next.phase === "betting") next = awardFoldOut(next);
record.state = advanceState(next);
record.nextHandAt = null;
record.version += 1;
afterMutation(record);
void notifyLastPlayer(record);
}
/** Fire-and-forget XP/stats award on game completion; never blocks the table. */
async function awardGameResults(record: PokerTableRecord): Promise<void> {
const state = record.state;
@ -348,18 +308,7 @@ function buildSnapshot(record: PokerTableRecord, userId: number): PokerSnapshot
const seated = seatIndex >= 0 && !record.leftUsers.has(userId);
const state = record.state;
const inEngine = seated && state !== null && seatIndex < state.players.length;
const base = state && inEngine ? buildPrivateView(state, seatIndex) : null;
const you =
base === null
? null
: {
...base,
players: base.players.map((player) => {
const playerUserId = record.seatMap[player.seatId];
if (playerUserId === null || !record.leftUsers.has(playerUserId)) return player;
return { ...player, left: true };
}),
};
const you = state && inEngine ? buildPrivateView(state, seatIndex) : null;
const hostSeat = record.hostUserId !== null ? record.seatMap.indexOf(record.hostUserId) : -1;
return {
tableId: record.id,
@ -532,9 +481,6 @@ export function leaveTable(tableId: string, userId: number): void {
record.version += 1;
rekeyTurnTimer(record);
scheduleDisposalIfEmpty(record);
if (record.inGame && seatedCountOf(record) === 1 && record.pendingJoins.length === 0) {
finalizeForLastPlayer(record);
}
pushTable(record);
}

View file

@ -163,8 +163,6 @@ export interface PublicPlayerView {
readonly allIn: boolean;
readonly hasActed: boolean;
readonly eliminated: boolean;
/** True from the moment the user leaves mid-game (before the forfeit lands). */
readonly left: boolean;
/** Populated only for the viewer or for live hands at showdown. */
readonly holeCards: readonly Card[] | null;
readonly cardCount: number;

View file

@ -27,8 +27,9 @@ function projectPlayer(
): PublicPlayerView {
const isViewer = viewerSeat !== null && player.seatId === viewerSeat;
const reveal = (state.phase === "showdown" && !player.folded) || isViewer;
const holeCards: readonly Card[] | null =
reveal && player.holeCards.length > 0 ? player.holeCards : null;
const holeCards: readonly Card[] | null = reveal && player.holeCards.length > 0
? player.holeCards
: null;
return {
seatId: player.seatId,
name: player.name,
@ -39,9 +40,6 @@ function projectPlayer(
allIn: player.allIn,
hasActed: player.hasActed,
eliminated: player.eliminated,
// The engine has no leave concept; the registry layer overrides this
// for users recorded in leftUsers (see buildSnapshot in tables.ts).
left: false,
holeCards,
cardCount: player.holeCards.length,
};

View file

@ -57,8 +57,6 @@ export function notificationLabel(type?: string | null): string {
switch (type) {
case "poker:force-ended":
return "Poker game ended";
case "poker:everyone-left":
return "Everyone left the table";
case "market:offer":
return "New offer";
case "market:counter":

View file

@ -18,7 +18,6 @@ export const MUTEABLE_NOTIFICATION_TYPES = [
{ label: "Ticket replies", value: "ticket:reply" },
{ label: "Ticket status changes", value: "ticket:status" },
{ label: "Poker", value: "poker:force-ended" },
{ label: "Poker game auto-ended", value: "poker:everyone-left" },
] as const;
export type MuteableNotificationType = (typeof MUTEABLE_NOTIFICATION_TYPES)[number]["value"];
export const MUTEABLE_NOTIFICATION_VALUES = MUTEABLE_NOTIFICATION_TYPES.map((t) => t.value);

File diff suppressed because it is too large Load diff

View file

@ -1,34 +0,0 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
ALTER TYPE "public"."enum_users_preferences_notifications_muted_types" ADD VALUE 'poker:force-ended';
ALTER TYPE "public"."enum_users_preferences_notifications_muted_types" ADD VALUE 'poker:everyone-left';
ALTER TYPE "public"."enum_users_preferences_discord_muted_types" ADD VALUE 'poker:force-ended';
ALTER TYPE "public"."enum_users_preferences_discord_muted_types" ADD VALUE 'poker:everyone-left';
ALTER TABLE "profiles_progression_awards" ADD COLUMN "upgraded_from_id" integer;
ALTER TABLE "profiles_progression_awards" ADD COLUMN "upgraded_at" timestamp(3) with time zone;
ALTER TABLE "awards" ADD COLUMN "advancement_series_key" varchar;
ALTER TABLE "awards" ADD COLUMN "advancement_tier" numeric;
ALTER TABLE "profiles_progression_awards" ADD CONSTRAINT "profiles_progression_awards_upgraded_from_id_awards_id_fk" FOREIGN KEY ("upgraded_from_id") REFERENCES "public"."awards"("id") ON DELETE set null ON UPDATE no action;
CREATE INDEX "profiles_progression_awards_upgraded_from_idx" ON "profiles_progression_awards" USING btree ("upgraded_from_id");`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "profiles_progression_awards" DROP CONSTRAINT "profiles_progression_awards_upgraded_from_id_awards_id_fk";
ALTER TABLE "users_preferences_notifications_muted_types" ALTER COLUMN "value" SET DATA TYPE text;
DROP TYPE "public"."enum_users_preferences_notifications_muted_types";
CREATE TYPE "public"."enum_users_preferences_notifications_muted_types" AS ENUM('market:offer', 'market:counter', 'market:accept', 'market:reject', 'market:withdrawn', 'market:closed', 'market:sold', 'market:expired', 'award:granted', 'account:discord-request', 'finance:deposit', 'finance:withdraw', 'finance:transfer', 'shipment:completed', 'ticket:created', 'ticket:assigned', 'ticket:reply', 'ticket:status');
ALTER TABLE "users_preferences_notifications_muted_types" ALTER COLUMN "value" SET DATA TYPE "public"."enum_users_preferences_notifications_muted_types" USING "value"::"public"."enum_users_preferences_notifications_muted_types";
ALTER TABLE "users_preferences_discord_muted_types" ALTER COLUMN "value" SET DATA TYPE text;
DROP TYPE "public"."enum_users_preferences_discord_muted_types";
CREATE TYPE "public"."enum_users_preferences_discord_muted_types" AS ENUM('market:offer', 'market:counter', 'market:accept', 'market:reject', 'market:withdrawn', 'market:closed', 'market:sold', 'market:expired', 'award:granted', 'account:discord-request', 'finance:deposit', 'finance:withdraw', 'finance:transfer', 'shipment:completed', 'ticket:created', 'ticket:assigned', 'ticket:reply', 'ticket:status');
ALTER TABLE "users_preferences_discord_muted_types" ALTER COLUMN "value" SET DATA TYPE "public"."enum_users_preferences_discord_muted_types" USING "value"::"public"."enum_users_preferences_discord_muted_types";
DROP INDEX "profiles_progression_awards_upgraded_from_idx";
ALTER TABLE "profiles_progression_awards" DROP COLUMN "upgraded_from_id";
ALTER TABLE "profiles_progression_awards" DROP COLUMN "upgraded_at";
ALTER TABLE "awards" DROP COLUMN "advancement_series_key";
ALTER TABLE "awards" DROP COLUMN "advancement_tier";`)
}

View file

@ -54,7 +54,6 @@ import * as migration_20260910_221018_make_missions_codename_optional from './20
import * as migration_20260910_222518_add_dashboard_layout_preference from './20260910_222518_add_dashboard_layout_preference';
import * as migration_20260911_044754_add_poker_minigame_stats from './20260911_044754_add_poker_minigame_stats';
import * as migration_20260911_052147_add_ribbon_submissions from './20260911_052147_add_ribbon_submissions';
import * as migration_20260911_201559_add_award_advancement_series from './20260911_201559_add_award_advancement_series';
export const migrations = [
{
@ -335,11 +334,6 @@ export const migrations = [
{
up: migration_20260911_052147_add_ribbon_submissions.up,
down: migration_20260911_052147_add_ribbon_submissions.down,
name: '20260911_052147_add_ribbon_submissions',
},
{
up: migration_20260911_201559_add_award_advancement_series.up,
down: migration_20260911_201559_add_award_advancement_series.down,
name: '20260911_201559_add_award_advancement_series'
name: '20260911_052147_add_ribbon_submissions'
},
];

View file

@ -1360,7 +1360,6 @@ export interface User {
| 'ticket:reply'
| 'ticket:status'
| 'poker:force-ended'
| 'poker:everyone-left'
)[]
| null;
};
@ -1394,7 +1393,6 @@ export interface User {
| 'ticket:reply'
| 'ticket:status'
| 'poker:force-ended'
| 'poker:everyone-left'
)[]
| null;
};
@ -2012,14 +2010,6 @@ export interface Profile {
* An optional reason for the award.
*/
awardReason?: string | null;
/**
* For advanced ribbons: the previous tier this entry replaced.
*/
upgradedFrom?: (number | null) | Award;
/**
* When this entry was upgraded to the current tier.
*/
upgradedAt?: string | null;
id?: string | null;
}[]
| null;
@ -2159,19 +2149,6 @@ export interface Award {
| number
| boolean
| null;
/**
* Configure this award as part of an advancement lineup.
*/
advancement?: {
/**
* Lineup identifier (e.g. years-of-service). Ribbons sharing this key form one advancement family.
*/
seriesKey?: string | null;
/**
* Position in the advancement lineup, starting at 1.
*/
tier?: number | null;
};
/**
* Configure who can grant this award and to whom. Only applies to ribbons and commendations.
*/
@ -5734,8 +5711,6 @@ export interface ProfilesSelect<T extends boolean = true> {
awardedBy?: T;
awardedAt?: T;
awardReason?: T;
upgradedFrom?: T;
upgradedAt?: T;
id?: T;
};
qualifications?: T;
@ -5858,12 +5833,6 @@ export interface AwardsSelect<T extends boolean = true> {
ribbonDesign?: T;
backgroundImage?: T;
medalDesign?: T;
advancement?:
| T
| {
seriesKey?: T;
tier?: T;
};
grantConfig?:
| T
| {

View file

@ -131,7 +131,6 @@ export const EventTypes = {
// Awards
AwardGrant: "award:grant",
AwardUpgrade: "award:upgrade",
AwardRibbonSubmitted: "award:ribbon-submitted",
AwardRibbonApproved: "award:ribbon-approved",
AwardRibbonRejected: "award:ribbon-rejected",

View file

@ -1,737 +0,0 @@
import { getPayload, Payload } from "payload";
import config from "@/payload.config";
import { afterAll, beforeAll, describe, expect, it, vi, type MockInstance } from "vitest";
import type { User, Award } from "@/payload-types";
import { grantAward } from "@/app/(frontend)/awards/actions";
import { EventTypes } from "@/utils/event-log/eventTypes";
// Mock next/headers to avoid "headers was called outside a request scope" error
vi.mock("next/headers", () => ({
headers: async () => new Headers(),
}));
let payload: Payload;
let authSpy: MockInstance;
const RUN = `award-${Date.now().toString(36)}`;
describe("Award Advancement Series", () => {
let adminUser: User;
let adminUserId: number;
let userA: User;
let userAId: number;
let userB: User;
let userBId: number;
let userC: User;
let userCId: number;
let tier1Award: Award;
let tier2Award: Award;
let tier3Award: Award;
let superRoleId: number;
async function profileIdFor(userId: number): Promise<number> {
const res = await payload.find({
collection: "profiles",
where: { user: { equals: userId } },
limit: 1,
depth: 0,
overrideAccess: true,
});
if (!res.docs[0]) throw new Error(`No profile found for user ${userId}`);
return res.docs[0].id;
}
beforeAll(async () => {
const payloadConfig = await config;
payload = await getPayload({ config: payloadConfig });
// Create a superuser role (RBAC resolves via roleDocs, not the legacy enum)
const superRole = await payload.create({
collection: "roles",
data: {
name: `Advancement Superuser ${RUN}`,
slug: `${RUN}-superuser`,
isSuperuser: true,
isSystem: false,
permissions: [],
},
overrideAccess: true,
depth: 0,
});
superRoleId = superRole.id;
// Create an admin user
adminUser = (await payload.create({
collection: "users",
data: {
username: `${RUN}-admin`,
discordUsername: `${RUN}-admin`,
displayName: "AWARD ADMIN",
steamId: `7656119${Math.floor(Math.random() * 1e9)}`,
password: "Test123",
roles: ["admin"],
roleDocs: [superRole.id],
},
overrideAccess: true,
depth: 0,
})) as unknown as User;
adminUserId = adminUser.id;
// Create users
userA = (await payload.create({
collection: "users",
data: {
username: `${RUN}-userA`,
discordUsername: `${RUN}-userA`,
displayName: "USER A",
steamId: `7656119${Math.floor(Math.random() * 1e9)}`,
password: "Test123",
roles: ["user"],
},
overrideAccess: true,
depth: 0,
})) as unknown as User;
userAId = userA.id;
userB = (await payload.create({
collection: "users",
data: {
username: `${RUN}-userB`,
discordUsername: `${RUN}-userB`,
displayName: "USER B",
steamId: `7656119${Math.floor(Math.random() * 1e9)}`,
password: "Test123",
roles: ["user"],
},
overrideAccess: true,
depth: 0,
})) as unknown as User;
userBId = userB.id;
userC = (await payload.create({
collection: "users",
data: {
username: `${RUN}-userC`,
discordUsername: `${RUN}-userC`,
displayName: "USER C",
steamId: `7656119${Math.floor(Math.random() * 1e9)}`,
password: "Test123",
roles: ["user"],
},
overrideAccess: true,
depth: 0,
})) as unknown as User;
userCId = userC.id;
// Set up auth spy for testing authentication
authSpy = vi.spyOn(payload, "auth");
// Create awards for testing advancement
tier1Award = (await payload.create({
collection: "awards",
data: {
name: `Tier 1 Award-${RUN}`,
description: {
root: {
type: "root",
children: [
{
type: "paragraph",
children: [
{
type: "text",
text: "Tier 1 Award",
format: 0,
style: "",
mode: "normal",
detail: 0,
version: 1,
},
],
direction: "ltr",
format: "",
indent: 0,
textFormat: 0,
textStyle: "",
version: 1,
},
],
direction: "ltr",
format: "",
indent: 0,
version: 1,
},
},
type: "ribbon",
advancement: {
seriesKey: `${RUN}-years-of-service`,
tier: 1,
},
ribbonDesign: {
stripes: [
{ color: "#b91c1c", width: 30 },
{ color: "#f5f5f4", width: 5 },
{ color: "#1e3a5f", width: 30 },
],
devices: [],
mirrored: false,
},
experiencePoints: 25,
grantConfig: {
allowedGrantorAssignmentTypes: ["division"],
recipientScope: "same_command",
maxGrantsPerGrantor: null,
},
},
overrideAccess: true,
depth: 0,
})) as unknown as Award;
tier2Award = (await payload.create({
collection: "awards",
data: {
name: `Tier 2 Award-${RUN}`,
description: {
root: {
type: "root",
children: [
{
type: "paragraph",
children: [
{
type: "text",
text: "Tier 2 Award",
format: 0,
style: "",
mode: "normal",
detail: 0,
version: 1,
},
],
direction: "ltr",
format: "",
indent: 0,
textFormat: 0,
textStyle: "",
version: 1,
},
],
direction: "ltr",
format: "",
indent: 0,
version: 1,
},
},
type: "ribbon",
advancement: {
seriesKey: `${RUN}-years-of-service`,
tier: 2,
},
ribbonDesign: {
stripes: [
{ color: "#1e3a5f", width: 30 },
{ color: "#f5f5f4", width: 5 },
{ color: "#b91c1c", width: 30 },
],
devices: [],
mirrored: false,
},
experiencePoints: 50,
grantConfig: {
allowedGrantorAssignmentTypes: ["division"],
recipientScope: "same_command",
maxGrantsPerGrantor: null,
},
},
overrideAccess: true,
depth: 0,
})) as unknown as Award;
tier3Award = (await payload.create({
collection: "awards",
data: {
name: `Tier 3 Award-${RUN}`,
description: {
root: {
type: "root",
children: [
{
type: "paragraph",
children: [
{
type: "text",
text: "Tier 3 Award",
format: 0,
style: "",
mode: "normal",
detail: 0,
version: 1,
},
],
direction: "ltr",
format: "",
indent: 0,
textFormat: 0,
textStyle: "",
version: 1,
},
],
direction: "ltr",
format: "",
indent: 0,
version: 1,
},
},
type: "ribbon",
advancement: {
seriesKey: `${RUN}-years-of-service`,
tier: 3,
},
ribbonDesign: {
stripes: [
{ color: "#1e3a5f", width: 30 },
{ color: "#f5f5f4", width: 5 },
{ color: "#b91c1c", width: 30 },
],
devices: [],
mirrored: false,
},
experiencePoints: 100,
grantConfig: {
allowedGrantorAssignmentTypes: ["division"],
recipientScope: "same_command",
maxGrantsPerGrantor: 1,
},
},
overrideAccess: true,
depth: 0,
})) as unknown as Award;
});
afterAll(async () => {
// Clean up users and awards
try {
await payload.delete({ collection: "users", id: userAId, overrideAccess: true });
} catch {
// Ignore if already deleted
}
try {
await payload.delete({ collection: "users", id: userBId, overrideAccess: true });
} catch {
// Ignore if already deleted
}
try {
await payload.delete({ collection: "users", id: userCId, overrideAccess: true });
} catch {
// Ignore if already deleted
}
try {
await payload.delete({ collection: "users", id: adminUserId, overrideAccess: true });
} catch {
// Ignore if already deleted
}
try {
await payload.delete({ collection: "awards", id: tier1Award.id, overrideAccess: true });
} catch {
// Ignore if already deleted
}
try {
await payload.delete({ collection: "awards", id: tier2Award.id, overrideAccess: true });
} catch {
// Ignore if already deleted
}
try {
await payload.delete({ collection: "awards", id: tier3Award.id, overrideAccess: true });
} catch {
// Ignore if already deleted
}
try {
await payload.delete({ collection: "roles", id: superRoleId, overrideAccess: true });
} catch {
// Ignore if already deleted
}
// Restore auth spy
authSpy.mockRestore();
});
it("should grant tier 1 to recipient A", async () => {
// Mock authentication for admin user
authSpy.mockResolvedValue({ user: adminUser });
const result = await grantAward({
recipientUserIds: [userAId],
awardId: tier1Award.id,
reason: "Test grant",
});
expect(result.success).toBe(true);
expect(result.data?.results).toHaveLength(1);
expect(result.data?.results[0].success).toBe(true);
expect(result.data?.granted).toBe(1);
expect(result.data?.failed).toBe(0);
// Verify the profile has the award entry
const profile = await payload.findByID({
collection: "profiles",
id: await profileIdFor(userAId),
depth: 0,
overrideAccess: true,
});
const entries = profile.progression?.awards ?? [];
expect(entries).toHaveLength(1);
expect(entries[0]?.award).toBe(tier1Award.id);
expect(entries[0]?.upgradedFrom).toBeNull();
expect(entries[0]?.upgradedAt).toBeNull();
});
it("should upgrade recipient A from tier 1 to tier 2", async () => {
// Mock authentication for admin user
authSpy.mockResolvedValue({ user: adminUser });
const result = await grantAward({
recipientUserIds: [userAId],
awardId: tier2Award.id,
reason: "Test upgrade",
});
expect(result.success).toBe(true);
expect(result.data?.results).toHaveLength(1);
expect(result.data?.results[0].success).toBe(true);
expect(result.data?.granted).toBe(1);
expect(result.data?.failed).toBe(0);
// Verify the profile has the upgraded award entry (same array position, replaced)
const profile = await payload.findByID({
collection: "profiles",
id: await profileIdFor(userAId),
depth: 0,
overrideAccess: true,
});
const entries = profile.progression?.awards ?? [];
expect(entries).toHaveLength(1);
expect(entries[0]?.award).toBe(tier2Award.id);
expect(entries[0]?.upgradedFrom).toBe(tier1Award.id);
expect(entries[0]?.upgradedAt).toBeDefined();
expect(entries[0]?.awardedAt).toBeDefined(); // Preserved from tier 1
// Verify event was emitted (award names carry the RUN suffix, so the
// message match is idempotent across runs)
const events = await payload.find({
collection: "game-event-logs",
where: {
and: [
{ type: { equals: EventTypes.AwardUpgrade } },
{ message: { like: `Tier 2 Award-${RUN}` } },
],
},
depth: 0,
overrideAccess: true,
});
expect(events.docs).toHaveLength(1);
});
it("should upgrade recipient A from tier 2 to tier 3", async () => {
// Mock authentication for admin user
authSpy.mockResolvedValue({ user: adminUser });
const result = await grantAward({
recipientUserIds: [userAId],
awardId: tier3Award.id,
reason: "Test upgrade 3",
});
expect(result.success).toBe(true);
expect(result.data?.results).toHaveLength(1);
expect(result.data?.results[0].success).toBe(true);
expect(result.data?.granted).toBe(1);
expect(result.data?.failed).toBe(0);
// Verify the profile has the upgraded award entry (same array position, replaced)
const profile = await payload.findByID({
collection: "profiles",
id: await profileIdFor(userAId),
depth: 0,
overrideAccess: true,
});
const entries = profile.progression?.awards ?? [];
expect(entries).toHaveLength(1);
expect(entries[0]?.award).toBe(tier3Award.id);
expect(entries[0]?.upgradedFrom).toBe(tier2Award.id);
expect(entries[0]?.upgradedAt).toBeDefined();
expect(entries[0]?.awardedAt).toBeDefined(); // Preserved from tier 1
});
it("should fail when recipient A tries to get a higher tier of the same ribbon", async () => {
// Mock authentication for admin user
authSpy.mockResolvedValue({ user: adminUser });
const result = await grantAward({
recipientUserIds: [userAId],
awardId: tier1Award.id,
reason: "Test downgrade",
});
expect(result.success).toBe(true);
expect(result.data?.results).toHaveLength(1);
expect(result.data?.results[0].success).toBe(false);
expect(result.data?.results[0].error).toBe(
"Recipient already holds a higher tier of this ribbon.",
);
});
it("should grant tier 2 to fresh recipient B", async () => {
// Mock authentication for admin user
authSpy.mockResolvedValue({ user: adminUser });
const result = await grantAward({
recipientUserIds: [userBId],
awardId: tier2Award.id,
reason: "Fresh grant to B",
});
expect(result.success).toBe(true);
expect(result.data?.results).toHaveLength(1);
expect(result.data?.results[0].success).toBe(true);
expect(result.data?.granted).toBe(1);
expect(result.data?.failed).toBe(0);
// Verify the profile has the award entry (no upgradedFrom)
const profile = await payload.findByID({
collection: "profiles",
id: await profileIdFor(userBId),
depth: 0,
overrideAccess: true,
});
const entries = profile.progression?.awards ?? [];
expect(entries).toHaveLength(1);
expect(entries[0]?.award).toBe(tier2Award.id);
expect(entries[0]?.upgradedFrom).toBeNull();
});
it("should allow upgrade from tier 2 to tier 3 despite remaining 0", async () => {
// First grant tier 3 to user C (consumes the slot)
authSpy.mockResolvedValue({ user: adminUser });
const result1 = await grantAward({
recipientUserIds: [userCId],
awardId: tier3Award.id,
reason: "Grant to C",
});
expect(result1.success).toBe(true);
expect(result1.data?.results).toHaveLength(1);
expect(result1.data?.results[0].success).toBe(true);
// Now try to upgrade user B (tier 2) to tier 3 - should succeed despite remaining 0
authSpy.mockResolvedValue({ user: adminUser });
const result2 = await grantAward({
recipientUserIds: [userBId],
awardId: tier3Award.id,
reason: "Upgrade to B",
});
expect(result2.success).toBe(true);
expect(result2.data?.results).toHaveLength(1);
expect(result2.data?.results[0].success).toBe(true);
// Check that both have the tier-3 award
const profileC = await payload.findByID({
collection: "profiles",
id: await profileIdFor(userCId),
depth: 0,
overrideAccess: true,
});
const profileB = await payload.findByID({
collection: "profiles",
id: await profileIdFor(userBId),
depth: 0,
overrideAccess: true,
});
const entriesC = profileC.progression?.awards ?? [];
const entriesB = profileB.progression?.awards ?? [];
expect(entriesC).toHaveLength(1);
expect(entriesC[0]?.award).toBe(tier3Award.id);
expect(entriesB).toHaveLength(1);
expect(entriesB[0]?.award).toBe(tier3Award.id);
});
it("should fail when trying to grant same tier award to a holder", async () => {
// Mock authentication for admin user
authSpy.mockResolvedValue({ user: adminUser });
// By this point the exemption test has upgraded userB to tier 3, so
// granting tier 3 again must hit the same-tier guard
const result = await grantAward({
recipientUserIds: [userBId],
awardId: tier3Award.id,
reason: "Same tier",
});
expect(result.success).toBe(true);
expect(result.data?.results).toHaveLength(1);
expect(result.data?.results[0].success).toBe(false);
expect(result.data?.results[0].error).toBe("Recipient already has this award.");
});
it("should not affect non-series awards", async () => {
// Create a non-series award
const nonSeriesAward = await payload.create({
collection: "awards",
data: {
name: `Non-Series Award-${RUN}`,
description: {
root: {
type: "root",
children: [
{
type: "paragraph",
children: [
{
type: "text",
text: "Non-Series Award",
format: 0,
style: "",
mode: "normal",
detail: 0,
version: 1,
},
],
direction: "ltr",
format: "",
indent: 0,
textFormat: 0,
textStyle: "",
version: 1,
},
],
direction: "ltr",
format: "",
indent: 0,
version: 1,
},
},
type: "ribbon",
ribbonDesign: {
stripes: [
{ color: "#1e3a5f", width: 30 },
{ color: "#f5f5f4", width: 5 },
{ color: "#b91c1c", width: 30 },
],
devices: [],
mirrored: false,
},
experiencePoints: 25,
grantConfig: {
allowedGrantorAssignmentTypes: ["division"],
recipientScope: "same_command",
maxGrantsPerGrantor: null,
},
},
overrideAccess: true,
depth: 0,
});
// Grant it to user A
authSpy.mockResolvedValue({ user: adminUser });
const result1 = await grantAward({
recipientUserIds: [userAId],
awardId: nonSeriesAward.id,
reason: "Non-series award",
});
expect(result1.success).toBe(true);
expect(result1.data?.results).toHaveLength(1);
expect(result1.data?.results[0].success).toBe(true);
// Try to grant it again - should fail
const result2 = await grantAward({
recipientUserIds: [userAId],
awardId: nonSeriesAward.id,
reason: "Non-series award again",
});
expect(result2.success).toBe(true);
expect(result2.data?.results).toHaveLength(1);
expect(result2.data?.results[0].success).toBe(false);
expect(result2.data?.results[0].error).toBe("Recipient already has this award.");
// Cleanup
try {
await payload.delete({ collection: "awards", id: nonSeriesAward.id, overrideAccess: true });
} catch {
// Ignore if already deleted
}
});
it("should fail when creating two awards with same seriesKey + tier", async () => {
// Try to create a duplicate award with same seriesKey and tier
await expect(
payload.create({
collection: "awards",
data: {
name: `Duplicate Award-${RUN}`,
description: {
root: {
type: "root",
children: [
{
type: "paragraph",
children: [
{
type: "text",
text: "Duplicate Award",
format: 0,
style: "",
mode: "normal",
detail: 0,
version: 1,
},
],
direction: "ltr",
format: "",
indent: 0,
textFormat: 0,
textStyle: "",
version: 1,
},
],
direction: "ltr",
format: "",
indent: 0,
version: 1,
},
},
type: "ribbon",
advancement: {
seriesKey: `${RUN}-years-of-service`,
tier: 1,
},
ribbonDesign: {
stripes: [
{ color: "#1e3a5f", width: 30 },
{ color: "#f5f5f4", width: 5 },
{ color: "#b91c1c", width: 30 },
],
devices: [],
mirrored: false,
},
experiencePoints: 25,
grantConfig: {
allowedGrantorAssignmentTypes: ["division"],
recipientScope: "same_command",
maxGrantsPerGrantor: null,
},
},
overrideAccess: true,
depth: 0,
}),
).rejects.toThrow("Tier 1 of this series already exists.");
});
});

View file

@ -1,45 +0,0 @@
import { describe, expect, it } from "vitest";
import type { Card } from "@/lib/minigames/poker";
import { mapCardToSvg, normalizeCardStyle } from "@/components/frontend/minigames/poker/cardStyle";
function cardOf(rank: Card["rank"], suit: Card["suit"]): Card {
return { rank, suit };
}
describe("mapCardToSvg", () => {
it("maps number cards to their numbered asset path", () => {
expect(mapCardToSvg(cardOf(7, "h"))).toBe("/images/assets/playing-cards/7_of_hearts.svg");
expect(mapCardToSvg(cardOf(10, "s"))).toBe("/images/assets/playing-cards/10_of_spades.svg");
expect(mapCardToSvg(cardOf(2, "c"))).toBe("/images/assets/playing-cards/2_of_clubs.svg");
});
it("maps face ranks to word asset paths", () => {
expect(mapCardToSvg(cardOf(11, "d"))).toBe("/images/assets/playing-cards/jack_of_diamonds.svg");
expect(mapCardToSvg(cardOf(12, "c"))).toBe("/images/assets/playing-cards/queen_of_clubs.svg");
expect(mapCardToSvg(cardOf(13, "h"))).toBe("/images/assets/playing-cards/king_of_hearts.svg");
expect(mapCardToSvg(cardOf(14, "s"))).toBe("/images/assets/playing-cards/ace_of_spades.svg");
});
it("covers all four suits for a spot-check rank", () => {
expect(mapCardToSvg(cardOf(9, "s"))).toBe("/images/assets/playing-cards/9_of_spades.svg");
expect(mapCardToSvg(cardOf(9, "h"))).toBe("/images/assets/playing-cards/9_of_hearts.svg");
expect(mapCardToSvg(cardOf(9, "d"))).toBe("/images/assets/playing-cards/9_of_diamonds.svg");
expect(mapCardToSvg(cardOf(9, "c"))).toBe("/images/assets/playing-cards/9_of_clubs.svg");
});
});
describe("normalizeCardStyle", () => {
it("accepts the three known style values", () => {
expect(normalizeCardStyle("light")).toBe("light");
expect(normalizeCardStyle("dark")).toBe("dark");
expect(normalizeCardStyle("classic")).toBe("classic");
});
it("falls back to light for unknown or missing values", () => {
expect(normalizeCardStyle("bogus")).toBe("light");
expect(normalizeCardStyle(null)).toBe("light");
expect(normalizeCardStyle(undefined)).toBe("light");
expect(normalizeCardStyle(42)).toBe("light");
expect(normalizeCardStyle("")).toBe("light");
});
});

View file

@ -1,330 +0,0 @@
import { describe, expect, it } from "vitest";
import type { PublicPlayerView, ShowdownOutcome } from "@/lib/minigames/poker";
import {
popupEventsForTransition,
type PokerPopupEvent,
} from "@/components/frontend/minigames/poker/pokerPopups";
import {
isTopSeatPosition,
seatPosition,
} from "@/components/frontend/minigames/poker/seatPosition";
import type { PokerSoundSnapshot } from "@/components/frontend/minigames/poker/pokerSoundEvents";
function player(seatId: number, overrides: Partial<PublicPlayerView> = {}): PublicPlayerView {
return {
seatId,
name: `Player ${seatId + 1}`,
stack: 1000,
committed: 0,
totalCommitted: 0,
folded: false,
allIn: false,
hasActed: false,
eliminated: false,
left: false,
holeCards: null,
cardCount: 2,
...overrides,
};
}
function view(overrides: Partial<PokerSoundSnapshot["you"]> = {}): PokerSoundSnapshot["you"] {
return {
communityCards: [],
config: { startingStack: 1000, smallBlind: 10, bigBlind: 20, turnTimeoutMs: 30000 },
currentBet: 0,
smallBlindSeat: 0,
bigBlindSeat: 1,
finalPlacements: null,
handNumber: 1,
phase: "betting",
players: [player(0), player(1)],
showdown: null,
street: "preflop",
toActSeat: 0,
viewerSeat: 0,
yourTurn: false,
...overrides,
};
}
function snapshot(
version: number,
nextView: PokerSoundSnapshot["you"] = view(),
): PokerSoundSnapshot {
return { version, you: nextView };
}
function showdown(results: Array<{ seatId: number; chipsWon: number }>): ShowdownOutcome {
return {
pots: [],
results: results.map((item) => ({
seatId: item.seatId,
handRank: null,
bestHand: null,
chipsWon: item.chipsWon,
netChange: item.chipsWon,
placement: 1,
})),
};
}
function bySeat(events: readonly PokerPopupEvent[], seatId: number): PokerPopupEvent | undefined {
return events.find((event) => event.seatId === seatId);
}
describe("poker popup transition events", () => {
it("labels folds, checks, and wagers with amounts over the acting seat", () => {
const events = popupEventsForTransition(
snapshot(1, view({ players: [player(0), player(1)] })),
snapshot(
2,
view({
currentBet: 20,
players: [
player(0, { committed: 20, totalCommitted: 20, hasActed: true }),
player(1, { folded: true, hasActed: true }),
],
}),
),
);
const checks = popupEventsForTransition(
snapshot(1, view({ players: [player(0), player(1)] })),
snapshot(3, view({ players: [player(0), player(1, { hasActed: true })] })),
);
expect(bySeat(events, 0)).toEqual(
expect.objectContaining({ text: "Bet +20", tone: "wager", seatId: 0 }),
);
expect(bySeat(events, 1)).toEqual(
expect.objectContaining({ text: "Fold", tone: "fold", seatId: 1 }),
);
expect(checks).toEqual([expect.objectContaining({ text: "Check", tone: "action", seatId: 1 })]);
});
it("distinguishes calls from raises and calls out all-ins", () => {
const raise = popupEventsForTransition(
snapshot(
4,
view({
currentBet: 20,
players: [player(0, { committed: 20, totalCommitted: 20, hasActed: true }), player(1)],
}),
),
snapshot(
5,
view({
currentBet: 60,
players: [
player(0, { committed: 20, totalCommitted: 20, hasActed: true }),
player(1, { committed: 60, totalCommitted: 60, hasActed: true }),
],
}),
),
);
const call = popupEventsForTransition(
snapshot(
6,
view({
currentBet: 60,
players: [player(0, { committed: 20, totalCommitted: 20, hasActed: true }), player(1)],
}),
),
snapshot(
7,
view({
currentBet: 60,
players: [
player(0, { committed: 20, totalCommitted: 20, hasActed: true }),
player(1, { committed: 60, totalCommitted: 60, hasActed: true }),
],
}),
),
);
const allIn = popupEventsForTransition(
snapshot(8, view({ players: [player(0), player(1)] })),
snapshot(
9,
view({
currentBet: 1000,
players: [
player(0),
player(1, { committed: 1000, totalCommitted: 1000, hasActed: true, allIn: true }),
],
}),
),
);
expect(bySeat(raise, 1)?.text).toBe("Raise +60");
expect(bySeat(call, 1)?.text).toBe("Call +60");
expect(bySeat(allIn, 1)?.text).toBe("All-in +1,000");
});
it("pops winnings over every winning seat on showdown and hand-complete", () => {
const multiWinner = showdown([
{ seatId: 0, chipsWon: 120 },
{ seatId: 1, chipsWon: 0 },
{ seatId: 2, chipsWon: 340 },
]);
const showdownEvents = popupEventsForTransition(
snapshot(1, view({ players: [player(0), player(1), player(2)] })),
snapshot(
2,
view({
phase: "showdown",
players: [player(0), player(1), player(2)],
showdown: multiWinner,
}),
),
);
const foldOutEvents = popupEventsForTransition(
snapshot(3, view({ players: [player(0), player(1)] })),
snapshot(
4,
view({
phase: "hand-complete",
players: [player(0), player(1, { folded: true })],
showdown: showdown([{ seatId: 0, chipsWon: 45 }]),
}),
),
);
expect(showdownEvents).toEqual([
expect.objectContaining({ text: "+120", tone: "win", seatId: 0 }),
expect.objectContaining({ text: "+340", tone: "win", seatId: 2 }),
]);
expect(foldOutEvents).toEqual([
expect.objectContaining({ text: "+45", tone: "win", seatId: 0 }),
]);
});
it("stays silent on identical versions and plays the blind sequence at a new hand", () => {
const current = snapshot(7, view({ yourTurn: true }));
expect(popupEventsForTransition(current, current)).toEqual([]);
const blinds = popupEventsForTransition(
snapshot(5),
snapshot(
6,
view({
handNumber: 2,
currentBet: 20,
players: [
player(0, { committed: 10, totalCommitted: 10 }),
player(1, { committed: 20, totalCommitted: 20 }),
],
}),
),
);
expect(blinds).toEqual([
expect.objectContaining({
text: "Small blind +10",
tone: "wager",
seatId: 0,
delay: 0,
}),
expect.objectContaining({
text: "Big blind +20",
tone: "wager",
seatId: 1,
delay: 700,
}),
]);
});
it("plays the blind sequence for the game's first hand out of the waiting room", () => {
const waitingRoom: PokerSoundSnapshot = { version: 1, you: null };
const events = popupEventsForTransition(waitingRoom, snapshot(2, view({ handNumber: 1 })));
expect(events).toEqual([
expect.objectContaining({ text: "Small blind +10", seatId: 0, delay: 0 }),
expect.objectContaining({ text: "Big blind +20", seatId: 1, delay: 700 }),
]);
});
});
describe("player leave detection", () => {
it("announces players who vanish from the table between transitions", () => {
const events = popupEventsForTransition(
snapshot(1, view({ players: [player(0), player(1), player(2)] })),
snapshot(2, view({ players: [player(0), player(1)] })),
);
expect(events).toEqual([
expect.objectContaining({
text: "Player 3 left the table",
tone: "leave",
seatId: 2,
}),
]);
});
it("announces a mid-game leaver the moment the left flag flips", () => {
const events = popupEventsForTransition(
snapshot(3, view({ players: [player(0), player(1)] })),
snapshot(4, view({ players: [player(0), player(1, { left: true })] })),
);
expect(events).toEqual([
expect.objectContaining({
text: "Player 2 left the table",
tone: "leave",
seatId: 1,
}),
]);
});
it("announces the eliminated forfeit only when the player was not already marked left", () => {
const forfeitOnly = popupEventsForTransition(
snapshot(5, view({ players: [player(0), player(1)] })),
snapshot(6, view({ players: [player(0), player(1, { eliminated: true })] })),
);
const alreadyLeft = popupEventsForTransition(
snapshot(7, view({ players: [player(0), player(1, { left: true })] })),
snapshot(8, view({ players: [player(0), player(1, { left: true, eliminated: true })] })),
);
expect(forfeitOnly).toEqual([
expect.objectContaining({ text: "Player 2 left the table", tone: "leave", seatId: 1 }),
]);
expect(alreadyLeft).toEqual([]);
});
it("never mistakes a busted stack for a leaver", () => {
const events = popupEventsForTransition(
snapshot(5, view({ players: [player(0), player(1)] })),
snapshot(6, view({ players: [player(0), player(1, { stack: 0 })] })),
);
expect(events).toEqual([]);
});
});
describe("isTopSeatPosition", () => {
it("marks top-row arc classes as top seats", () => {
expect(isTopSeatPosition("poker-seat-arc-center")).toBe(true);
expect(isTopSeatPosition("poker-seat-arc-center-left")).toBe(true);
expect(isTopSeatPosition("poker-seat-arc-center-right")).toBe(true);
});
it("marks side and corner arc classes as non-top seats", () => {
expect(isTopSeatPosition("poker-seat-arc-left")).toBe(false);
expect(isTopSeatPosition("poker-seat-arc-right")).toBe(false);
expect(isTopSeatPosition("poker-seat-arc-far-left")).toBe(false);
expect(isTopSeatPosition("poker-seat-arc-far-right")).toBe(false);
});
it("matches real seat layouts: one and three opponents put a seat on top, two do not", () => {
expect(isTopSeatPosition(seatPosition(0, 1))).toBe(true);
expect(isTopSeatPosition(seatPosition(0, 2))).toBe(false);
expect(isTopSeatPosition(seatPosition(1, 2))).toBe(false);
expect(isTopSeatPosition(seatPosition(1, 3))).toBe(true);
expect(isTopSeatPosition(seatPosition(0, 3))).toBe(false);
expect(isTopSeatPosition(seatPosition(2, 3))).toBe(false);
expect(isTopSeatPosition(seatPosition(1, 4))).toBe(true);
expect(isTopSeatPosition(seatPosition(2, 4))).toBe(true);
expect(isTopSeatPosition(seatPosition(0, 4))).toBe(false);
expect(isTopSeatPosition(seatPosition(3, 4))).toBe(false);
});
});

View file

@ -1,242 +0,0 @@
import { describe, expect, it } from "vitest";
import type { Card, PublicPlayerView, ShowdownOutcome } from "@/lib/minigames/poker";
import {
soundEventsForTransition,
type PokerSoundSnapshot,
} from "@/components/frontend/minigames/poker/pokerSoundEvents";
const card = (rank: Card["rank"], suit: Card["suit"]): Card => ({ rank, suit });
function player(seatId: number, overrides: Partial<PublicPlayerView> = {}): PublicPlayerView {
return {
seatId,
name: `Player ${seatId + 1}`,
stack: 1000,
committed: 0,
totalCommitted: 0,
folded: false,
allIn: false,
hasActed: false,
eliminated: false,
left: false,
holeCards: null,
cardCount: 2,
...overrides,
};
}
function view(overrides: Partial<PokerSoundSnapshot["you"]> = {}): PokerSoundSnapshot["you"] {
return {
communityCards: [],
config: { startingStack: 1000, smallBlind: 10, bigBlind: 20, turnTimeoutMs: 30000 },
currentBet: 0,
smallBlindSeat: 0,
bigBlindSeat: 1,
finalPlacements: null,
handNumber: 1,
phase: "betting",
players: [player(0), player(1)],
showdown: null,
street: "preflop",
toActSeat: 0,
viewerSeat: 0,
yourTurn: false,
...overrides,
};
}
function snapshot(
version: number,
nextView: PokerSoundSnapshot["you"] = view(),
): PokerSoundSnapshot {
return { version, you: nextView };
}
function showdown(netChange: number): ShowdownOutcome {
return {
pots: [],
results: [
{
seatId: 0,
handRank: null,
bestHand: null,
chipsWon: 100,
netChange,
placement: netChange > 0 ? 1 : 2,
},
],
};
}
describe("poker sound transition events", () => {
it("emits a street deal descriptor for flop, turn, and river transitions", () => {
const flop = soundEventsForTransition(
snapshot(1),
snapshot(
2,
view({ street: "flop", communityCards: [card(2, "s"), card(7, "h"), card(13, "d")] }),
),
);
const turn = soundEventsForTransition(
snapshot(
2,
view({ street: "flop", communityCards: [card(2, "s"), card(7, "h"), card(13, "d")] }),
),
snapshot(
3,
view({
street: "turn",
communityCards: [card(2, "s"), card(7, "h"), card(13, "d"), card(9, "c")],
}),
),
);
const river = soundEventsForTransition(
snapshot(
3,
view({
street: "turn",
communityCards: [card(2, "s"), card(7, "h"), card(13, "d"), card(9, "c")],
}),
),
snapshot(
4,
view({
street: "river",
communityCards: [card(2, "s"), card(7, "h"), card(13, "d"), card(9, "c"), card(4, "h")],
}),
),
);
expect(flop).toEqual([
expect.objectContaining({ type: "street-deal", street: "flop", cardCount: 3 }),
]);
expect(turn).toEqual([
expect.objectContaining({ type: "street-deal", street: "turn", cardCount: 1 }),
]);
expect(river).toEqual([
expect.objectContaining({ type: "street-deal", street: "river", cardCount: 1 }),
]);
});
it("emits turn gained and turn lost descriptors", () => {
const gained = soundEventsForTransition(
snapshot(1),
snapshot(2, view({ yourTurn: true, toActSeat: 0 })),
);
const lost = soundEventsForTransition(
snapshot(2, view({ yourTurn: true, toActSeat: 0 })),
snapshot(3, view({ yourTurn: false, toActSeat: 1 })),
);
expect(gained.map((event) => event.type)).toContain("turn-gained");
expect(lost.map((event) => event.type)).toContain("turn-lost");
});
it("emits wager, check, and fold descriptors from player transitions", () => {
const previous = snapshot(1, view({ players: [player(0), player(1)] }));
const next = snapshot(
2,
view({
currentBet: 20,
players: [
player(0, { committed: 20, totalCommitted: 20, hasActed: true }),
player(1, { folded: true, hasActed: true }),
],
}),
);
const wagerEvents = soundEventsForTransition(previous, next);
const checkEvents = soundEventsForTransition(
previous,
snapshot(3, view({ players: [player(0), player(1, { hasActed: true })] })),
);
expect(wagerEvents.map((event) => event.type)).toEqual(["wager", "fold"]);
expect(checkEvents.map((event) => event.type)).toEqual(["check"]);
});
it("emits win and loss results plus game completion placement", () => {
const win = soundEventsForTransition(
snapshot(1),
snapshot(2, view({ phase: "hand-complete", showdown: showdown(40) })),
);
const loss = soundEventsForTransition(
snapshot(1),
snapshot(2, view({ phase: "hand-complete", showdown: showdown(-40) })),
);
const complete = soundEventsForTransition(
snapshot(1),
snapshot(2, view({ phase: "game-complete", finalPlacements: [{ seatId: 0, placement: 1 }] })),
);
expect(win).toEqual([expect.objectContaining({ type: "hand-result", result: "win" })]);
expect(loss).toEqual([expect.objectContaining({ type: "hand-result", result: "loss" })]);
expect(complete).toEqual([expect.objectContaining({ type: "game-complete", placement: 1 })]);
});
it("returns no events for an identical snapshot version", () => {
const current = snapshot(7, view({ yourTurn: true }));
expect(soundEventsForTransition(current, current)).toEqual([]);
});
it("announces a new hand with a two-card deal and a staggered blind sequence", () => {
const events = soundEventsForTransition(snapshot(5), snapshot(6, view({ handNumber: 2 })));
expect(events).toEqual([
expect.objectContaining({ type: "hand-start", handNumber: 2 }),
expect.objectContaining({
type: "wager",
amount: 10,
seatId: 0,
delay: 0,
}),
expect.objectContaining({
type: "wager",
amount: 20,
seatId: 1,
delay: 700,
}),
]);
});
it("plays the staggered blind sequence for the game's first hand out of the waiting room", () => {
const waitingRoom: PokerSoundSnapshot = { version: 1, you: null };
const events = soundEventsForTransition(waitingRoom, snapshot(2, view({ handNumber: 1 })));
expect(events.map((event) => event.type)).toEqual(["hand-start", "wager", "wager"]);
expect(events[1]).toEqual(expect.objectContaining({ amount: 10, delay: 0 }));
expect(events[2]).toEqual(expect.objectContaining({ amount: 20, delay: 700 }));
});
it("plays the showdown reveal plus the win jingle on showdown entry", () => {
const events = soundEventsForTransition(
snapshot(1),
snapshot(2, view({ phase: "showdown", showdown: showdown(180) })),
);
expect(events.map((event) => event.type)).toEqual(["showdown", "hand-result"]);
expect(events[1]).toEqual(expect.objectContaining({ result: "win", netChange: 180 }));
});
it("still clacks chips when the same player wagers again later in the street", () => {
const before = snapshot(
9,
view({
currentBet: 20,
players: [player(0, { committed: 20, totalCommitted: 20, hasActed: true }), player(1)],
}),
);
const after = snapshot(
10,
view({
currentBet: 60,
players: [
player(0, { committed: 60, totalCommitted: 60, hasActed: true }),
player(1, { committed: 40, totalCommitted: 40, hasActed: true }),
],
}),
);
const events = soundEventsForTransition(before, after);
expect(events.filter((event) => event.type === "wager")).toHaveLength(2);
});
});

View file

@ -1,12 +1,6 @@
import { describe, expect, it } from "vitest";
import {
createTable,
getSnapshotFor,
joinTable,
leaveTable,
startGame,
} from "@/lib/minigames/poker/tables";
import { createTable, joinTable, startGame } from "@/lib/minigames/poker/tables";
/**
* Regression tests for the registry's game-start path. Guards against the
@ -62,40 +56,3 @@ describe("poker table registry: startGame", () => {
}
});
});
describe("poker table registry: last player standing", () => {
it("ends the game immediately when everyone else leaves mid-hand", () => {
const tableId = createTable(9301, "Host");
joinTable(tableId, 9302, "Guest");
startGame(tableId, 9301);
leaveTable(tableId, 9302);
const remaining = getSnapshotFor(tableId, 9301);
expect(remaining.you?.phase).toBe("game-complete");
const players = remaining.you?.players ?? [];
const hostPlayer = players.find((player) => player.seatId === remaining.you?.viewerSeat);
const guestPlayer = players.find((player) => player.seatId !== remaining.you?.viewerSeat);
expect(hostPlayer?.eliminated).toBe(false);
expect(hostPlayer?.left).toBe(false);
// The leaver's committed blind is forfeited (leavers take nothing from
// the pot), so the host only receives their own small blind back.
expect(hostPlayer?.stack).toBe(1000);
expect(guestPlayer?.eliminated).toBe(true);
expect(guestPlayer?.left).toBe(true);
});
it("does not end the game while two or more players remain seated", () => {
const tableId = createTable(9401, "Host");
joinTable(tableId, 9402, "Second");
joinTable(tableId, 9403, "Third");
startGame(tableId, 9401);
leaveTable(tableId, 9403);
const snapshot = getSnapshotFor(tableId, 9401);
expect(snapshot.you?.phase).not.toBe("game-complete");
const third = getSnapshotFor(tableId, 9403);
expect(third.you).toBeNull();
});
});

View file

@ -158,8 +158,8 @@ describe("Ribbon Design Submissions", () => {
// Mock authentication for regular user
authSpy.mockResolvedValue({ user });
// Fill the pending cap
for (let i = 0; i < 15; i++) {
// Create 3 pending submissions
for (let i = 0; i < 3; i++) {
await payload.create({
collection: "ribbon-submissions",
data: {
@ -189,7 +189,7 @@ describe("Ribbon Design Submissions", () => {
});
expect(result.success).toBe(false);
expect(result.error).toBe("You already have 15 pending ribbon submissions. Wait for them to be reviewed first.");
expect(result.error).toBe("You already have 3 pending ribbon submissions. Wait for them to be reviewed first.");
});
});