1
0
Fork 0

Compare commits

...

8 commits

Author SHA1 Message Date
5df186d52d v0.2.35 - poker experience pass: sounds, popups, card skins, leave handling; award advancement series 2026-09-11 17:07:52 -04:00
19113e39c9 feat(awards): add award advancement series
Awards can now upgrade through a series: collection fields and migration, grant flow support, profile and account UI updates, and regression coverage.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-11 17:07:37 -04:00
71e9f3e9e0 fix(poker): keep the lobby list stable across background polls
Background polls no longer flip the lobby into its scanning state, so the empty window and table rows stop blinking every three seconds; the refresh spinner is reserved for manual refreshes and the error line only clears on a successful load.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-11 17:07:37 -04:00
16a44d3fc5 feat(poker): redesign felt as diegetic oval with toolbar and viewer cards
The table becomes a full oval racetrack with a center deck and pot plaque, perimeter seats as compact pills with stack-first readouts that flash on change, betting actions mounted on the felt near the viewer, your own cards dealt onto the bottom rail at community-card size, directional popups, and gated start and waiting-room polish.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-11 17:07:28 -04:00
e9e89929f9 feat(poker): add floating action popups with staggered blind intro
Snapshot diffs surface fold, check, bet, call, raise, all-in, and pot-win text over each seat, leavers announce centrally, and new hands open with a small-blind then big-blind sequence before the first real turn. Popups fire exactly once per event with reduced-motion fallbacks.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-11 17:07:20 -04:00
56e3d253c9 feat(poker): add synthesized sfx and generative background jazz
Web audio synth with zero audio assets: deal whooshes, chip clacks, turn chimes, showdown and win stingers, plus a lazy 92bpm jazz loop. Transitions drive playback from pure snapshot mappers with staggered delays for the blind sequence, a header mute and volume toggle persists, and everything no-ops safely without audio support.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-11 17:07:12 -04:00
9b2b0db553 feat(poker): add card skin toggle with light, dark, and classic svg decks
Faces render white with dark ink by default, the previous dark theme cards remain one toggle away, and the classic deck draws the svg assets from public/images/assets/playing-cards. The choice persists per browser.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-11 17:07:05 -04:00
f7bb8f1e9f feat(poker): mark leavers immediately and auto-end when one player remains
Leavers now flip a left flag on the very next snapshot, the felt announces who left, and a game left with a single seated player resolves at once with a bell notification instead of waiting out the hand timer.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-11 17:06:57 -04:00
51 changed files with 32131 additions and 551 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,6 +67,16 @@ 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.
@ -157,9 +167,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
@ -182,6 +192,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.34",
"version": "0.2.35",
"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,11 +9,13 @@ 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, Profile } from "@/payload-types";
import type { Assignment, Award, Profile } from "@/payload-types";
interface ActionResult<T = undefined> {
success: boolean;
@ -107,12 +109,6 @@ 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.",
};
}
}
}
@ -128,8 +124,6 @@ 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,
@ -163,20 +157,150 @@ export async function grantAward(input: {
const progression = recipientProfile.progression;
const currentAwards = progression?.awards ?? [];
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.",
// 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;
});
processed += 1;
continue;
if (alreadyHasAward) {
results.push({
recipientUserId,
success: false,
error: "Recipient already has this award.",
});
processed += 1;
continue;
}
}
// Normal grant (no series advancement)
await payload.update({
collection: "profiles",
id: recipientProfile.id,
@ -221,9 +345,7 @@ 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 = 3;
const MAX_PENDING_RIBBON_SUBMISSIONS = 15;
async function authenticate() {
const payloadConfig = await config;
@ -90,7 +90,10 @@ export async function submitRibbonDesign(input: {
overrideAccess: true,
});
if (pendingCount.totalDocs >= MAX_PENDING_RIBBON_SUBMISSIONS) {
return { success: false, error: "You already have 3 pending ribbon submissions. Wait for them to be reviewed first." };
return {
success: false,
error: `You already have ${MAX_PENDING_RIBBON_SUBMISSIONS} pending ribbon submissions. Wait for them to be reviewed first.`,
};
}
const submission = await payload.create({

View file

@ -21,6 +21,67 @@ 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;
@ -54,9 +115,19 @@ export default async function AwardsPage() {
const ribbons = awards.filter((a) => a.type === "ribbon");
const medals = awards.filter((a) => a.type === "medal");
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 });
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),
});
}
return (
<div className="p-5 flex flex-col gap-6 max-w-7xl">
@ -96,27 +167,48 @@ export default async function AwardsPage() {
</ItemContent>
</Item>
) : (
sections.map((section) => (
catalogSections.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((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}
/>
);
{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;
}
}
})}
</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, Qualification, Rank, User } from "@/payload-types";
import type { Award, Assignment, Media, Profile, 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,6 +53,104 @@ 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,
@ -207,14 +305,19 @@ 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 =
@ -258,12 +361,18 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
: null;
const canSubmitEvaluation = viewer !== null && !isProfileOwner;
const rank = profile.rank as Rank;
const awards = (profile.progression?.awards ?? []).flatMap((entry) => {
if (typeof entry !== "object" || entry === null) return [];
const awardEntries = (profile.progression?.awards ?? []).filter(
(entry): entry is ProfileAwardEntry => typeof entry === "object" && entry !== null,
);
const heldAwardEntries: HeldAwardEntry[] = awardEntries.flatMap((entry) => {
const award = entry.award;
return typeof award === "object" && award !== null ? [award] : [];
return typeof award === "object" && award !== null ? [{ award, entry }] : [];
});
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,
);
@ -378,12 +487,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">
@ -489,7 +598,13 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
</section>
{grantableAwards.length > 0 && (
<ProfileGrantAwardButton userId={user.id} username={username} awards={grantableAwards} />
<ProfileGrantAwardButton
userId={user.id}
username={username}
awards={grantableAwards}
heldAwards={awards}
nextTierByAwardId={nextTierByAwardId}
/>
)}
{/* ── Combat Record ──────────────────────────────────── */}
@ -588,28 +703,35 @@ 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">
{[...awards]
.sort((a, b) => {
{[...heldAwardEntries]
.sort(({ award: first }, { award: second }) => {
const typeOrder = { medal: 0, ribbon: 1, commendation: 2 } as const;
const categoryOrder =
(typeOrder[a.type as keyof typeof typeOrder] ?? typeOrder.commendation) -
(typeOrder[b.type as keyof typeof typeOrder] ?? typeOrder.commendation);
(typeOrder[first.type as keyof typeof typeOrder] ?? typeOrder.commendation) -
(typeOrder[second.type as keyof typeof typeOrder] ?? typeOrder.commendation);
return categoryOrder || (b.experiencePoints ?? 0) - (a.experiencePoints ?? 0);
return (
categoryOrder || (second.experiencePoints ?? 0) - (first.experiencePoints ?? 0)
);
})
.map((award) => {
.map(({ award, entry }) => {
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={award.id}
key={entry.id ?? `${award.id}:${entry.awardedAt}`}
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,6 +94,33 @@ 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",
@ -154,4 +181,56 @@ 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,6 +244,21 @@ 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,9 +1,10 @@
import { Award, Media } from "@/payload-types";
import { ClientDate } from "@/components/frontend/ClientDate";
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";
@ -17,6 +18,8 @@ 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 =
@ -27,6 +30,10 @@ 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
@ -108,6 +115,15 @@ 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,5 +1,6 @@
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";
@ -68,7 +69,12 @@ 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(
@ -95,6 +101,11 @@ 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,12 +5,22 @@ import { useRouter } from "next/navigation";
import type { Award } from "@/payload-types";
import { Button } from "@/components/ui/button";
import { AwardPickerDialog } from "./AwardPickerDialog";
import { AwardIcon, PlusIcon } from "lucide-react";
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;
};
interface ProfileGrantAwardButtonProps {
userId: number;
username: string;
awards: Award[];
readonly userId: number;
readonly username: string;
readonly awards: Award[];
readonly heldAwards?: Award[];
readonly nextTierByAwardId?: Record<number, NextTier>;
}
/**
@ -18,9 +28,21 @@ interface ProfileGrantAwardButtonProps {
* Eligibility is enforced server-side (getGrantStats / grantAward inside
* GrantAwardDialog) — this component only provides the entry point.
*/
export function ProfileGrantAwardButton({ userId, username, awards }: ProfileGrantAwardButtonProps) {
export function ProfileGrantAwardButton({
userId,
username,
awards,
heldAwards = [],
nextTierByAwardId = {},
}: 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">
@ -31,10 +53,27 @@ export function ProfileGrantAwardButton({ userId, username, awards }: ProfileGra
Grant Award
</h2>
</div>
<Button size="sm" variant="outline" onClick={() => setOpen(true)}>
<PlusIcon data-icon="inline-start" />
Grant Award
</Button>
<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>
</div>
<AwardPickerDialog
awards={awards}
@ -44,6 +83,26 @@ export function ProfileGrantAwardButton({ userId, username, awards }: ProfileGra
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,14 +1,62 @@
import type { RibbonDesign } from "@/components/awards/ribbonDesign/types";
import { RibbonViewer } from "@/components/frontend/account/RibbonViewer";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Award } from "@/payload-types";
import type { 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">
{awards
.filter((award) => award.type === "ribbon" && award.ribbonDesign != null)
{dedupeAdvancementAwards(
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,6 +1,7 @@
"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";
@ -14,6 +15,8 @@ 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 {
@ -32,10 +35,13 @@ 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"
@ -53,8 +59,13 @@ 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);
@ -65,6 +76,7 @@ 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.");
@ -84,44 +96,97 @@ 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="flex flex-col gap-3">
<div className="flex flex-wrap gap-2">
<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")}>
<Button
type="button"
variant="destructive"
className="poker-chip poker-chip-fold"
disabled={!canAct || !legalActions.canFold}
onClick={() => void submit("fold")}
onClick={() => submitFromControl("fold")}
>
Fold
</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>
{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>
)}
</div>
{wagerKind && wagerMax >= wagerMin && (
<Card className="rounded-none">
<CardHeader className="p-4 pb-2">
{wagerKind && wagerMax >= wagerMin && (!compact || wagerOpen) && (
<Card
className={cn("poker-wager-panel rounded-none", compact && "poker-wager-panel-compact")}
>
<CardHeader className="p-3 pb-2">
<CardTitle className="text-sm">
{wagerKind === "bet" ? "Bet size" : "Raise to"}
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3 p-4 pt-0">
<div className="flex items-center gap-3">
<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")}>
<input
type="range"
min={wagerMin}
@ -132,7 +197,7 @@ export function BettingControls({
setAmount(clampAmount(Number(event.target.value), wagerMin, wagerMax))
}
disabled={!canAct}
className="h-2 min-w-0 flex-1 cursor-pointer accent-primary disabled:cursor-not-allowed"
className="poker-wager-range h-2 min-w-0 flex-1 cursor-pointer accent-primary disabled:cursor-not-allowed"
aria-label={`${wagerKind === "bet" ? "Bet" : "Raise"} amount`}
/>
<Input
@ -145,19 +210,23 @@ export function BettingControls({
setAmount(clampAmount(Number(event.target.value), wagerMin, wagerMax))
}
disabled={!canAct}
className="w-24 font-mono tabular-nums"
className="poker-wager-input w-24 font-mono tabular-nums"
aria-label="Wager amount"
/>
</div>
<div className="flex flex-wrap gap-2">
<div className={cn("flex flex-wrap gap-2", compact && "poker-compact-quick-row")}>
{(["min", "half", "pot", "all"] as const).map((choice) => (
<Button
key={choice}
type="button"
variant="outline"
size="sm"
className="poker-quick-chip"
disabled={!canAct}
onClick={() => chooseQuickAmount(choice)}
onClick={() => {
onActionFeedback?.();
chooseQuickAmount(choice);
}}
>
{choice === "min"
? "Min"
@ -170,9 +239,9 @@ export function BettingControls({
))}
<Button
type="button"
className="ml-auto"
className="poker-chip poker-chip-wager ml-auto"
disabled={!canAct}
onClick={() => void submit(wagerKind, amount)}
onClick={() => submitFromControl(wagerKind, amount)}
>
{wagerKind === "bet" ? "Bet" : "Raise"} {amountLabel(amount)}
</Button>
@ -181,11 +250,12 @@ export function BettingControls({
</Card>
)}
<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>
)}
<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>}
{error && <span className="text-destructive">{error}</span>}
</div>
</div>

View file

@ -2,6 +2,7 @@
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",
@ -47,7 +48,11 @@ 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"
@ -76,28 +81,38 @@ export function CardView({
style={dealIndex > 0 ? { animationDelay: `${dealIndex * 60}ms` } : undefined}
aria-label={label}
>
<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 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>
<span className="poker-card-face poker-card-back" aria-hidden={revealed} />
</span>
</span>
);

View file

@ -0,0 +1,167 @@
"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

@ -0,0 +1,48 @@
"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

@ -0,0 +1,58 @@
"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

@ -0,0 +1,11 @@
"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

@ -0,0 +1,275 @@
"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,52 +3,26 @@
import { createPortal } from "react-dom";
import { useEffect, useMemo, useState } from "react";
import { Maximize2, Minimize2 } from "lucide-react";
import { cn } from "@/lib/utils";
import type { Card, PokerPhase, PublicPlayerView } from "@/lib/minigames/poker";
import type { PokerPhase } 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,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { BettingControls } from "./BettingControls";
import { CardView } from "./CardView";
import { PokerClock } from "./PokerClock";
import { PokerSeat } from "./PokerSeat";
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 { 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>;
}
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;
readonly sound: PokerSoundControls;
readonly popups: readonly ActivePokerPopup[];
}
const PHASE_LABELS: Record<PokerPhase, string> = {
@ -58,197 +32,14 @@ const PHASE_LABELS: Record<PokerPhase, string> = {
"game-complete": "Game complete",
};
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,
}: 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({
export function PokerGameTable({
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) {
onStart,
onLeave,
sound,
popups,
}: PokerGameTableProps) {
const [starting, setStarting] = useState(false);
const [leaving, setLeaving] = useState(false);
const [fullscreen, setFullscreen] = useState(false);
@ -292,11 +83,19 @@ export function PokerGameTable({ snapshot, onSnapshot, onStart, onLeave }: Poker
const tableName = snapshot.name ?? `Table ${snapshot.tableId}`;
const feltCanvas = (
<FeltCanvas
<PokerFeltCanvas
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}
/>
);
@ -312,11 +111,13 @@ export function PokerGameTable({ snapshot, onSnapshot, onStart, onLeave }: Poker
<Badge variant="outline">{PHASE_LABELS[view.phase]}</Badge>
</div>
<div className="flex flex-wrap items-center justify-end gap-2">
{view.phase === "game-complete" && view.viewerSeat === snapshot.hostSeatId && (
<Button type="button" onClick={() => void start()} disabled={starting}>
{starting ? "Starting..." : "Start new game"}
</Button>
)}
<PokerCardStyleToggle />
<PokerSoundToggle
muted={sound.muted}
volume={sound.volume}
onToggleMuted={sound.toggleMuted}
onVolumeChange={sound.setVolume}
/>
<Button
type="button"
variant="outline"
@ -327,9 +128,16 @@ export function PokerGameTable({ snapshot, onSnapshot, onStart, onLeave }: Poker
>
<Minimize2 aria-hidden="true" />
</Button>
<Button type="button" variant="outline" onClick={() => void leave()} disabled={leaving}>
{leaving ? "Leaving..." : "Leave table"}
</Button>
{view.phase !== "game-complete" && (
<Button
type="button"
variant="outline"
onClick={() => void leave()}
disabled={leaving}
>
{leaving ? "Leaving..." : "Leave table"}
</Button>
)}
</div>
</div>
{feltCanvas}
@ -360,6 +168,13 @@ export function PokerGameTable({ snapshot, onSnapshot, onStart, onLeave }: Poker
<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"
@ -370,19 +185,14 @@ export function PokerGameTable({ snapshot, onSnapshot, onStart, onLeave }: Poker
>
<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,6 +17,7 @@ interface PokerRanking {
readonly name: string;
readonly example: readonly Card[];
readonly description: string;
readonly color?: string;
}
function card(rank: Rank, suit: Suit): Card {
@ -48,31 +49,37 @@ 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",
},
];
@ -97,11 +104,13 @@ 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">
<li key={ranking.name} className="flex items-center gap-3 border border-border p-3" style={ranking.color ? ({
borderColor: ranking.color
}) : {}}>
<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-2">
<div className="flex min-w-0 flex-1 flex-col gap-1">
<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`}>
@ -115,7 +124,7 @@ export function PokerGuide() {
))}
</div>
</div>
<p className="text-xs text-muted-foreground">{ranking.description}</p>
<p className="text-xs text-muted-foreground max-w-1/2">{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[]>([]);
const [loading, setLoading] = useState(true);
const [tables, setTables] = useState<readonly PokerTableSummary[] | null>(null);
const [refreshing, setRefreshing] = useState(false);
const [pendingTable, setPendingTable] = useState<string | null>(null);
const [forceEndingTable, setForceEndingTable] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
@ -58,22 +58,28 @@ 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]);
@ -172,12 +178,12 @@ export function PokerLobby({ refreshKey, onOpenTable, isSuperuser = false }: Pok
type="button"
variant="outline"
size="icon"
onClick={() => void loadTables()}
disabled={loading}
onClick={() => void manualRefresh()}
disabled={refreshing}
aria-label="Refresh poker tables"
title="Refresh tables"
>
<RefreshCw className={cn(loading && "animate-spin motion-reduce:animate-none")} />
<RefreshCw className={cn(refreshing && "animate-spin motion-reduce:animate-none")} />
</Button>
<Button type="button" onClick={() => void create()} disabled={creating}>
{creating ? (
@ -193,7 +199,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>
)}
{loading && tables.length === 0 ? (
{tables === null ? (
<p className="px-4 py-8 text-center text-sm text-muted-foreground">
Scanning for open tables...
</p>

View file

@ -0,0 +1,12 @@
"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 { Card, PublicPlayerView } from "@/lib/minigames/poker";
import { Badge } from "@/components/ui/badge";
import type { PublicPlayerView } from "@/lib/minigames/poker";
import { CardView } from "./CardView";
interface PokerSeatProps {
@ -11,14 +11,11 @@ interface PokerSeatProps {
readonly isActing: boolean;
readonly isWinner: boolean;
readonly showHoleCards: boolean;
readonly handLabel?: string | null;
readonly cardKeySet?: ReadonlySet<string>;
readonly compact?: boolean;
}
function cardKey(card: Card): string {
return `${card.rank}-${card.suit}`;
}
type StackFlash = "up" | "down" | null;
const STACK_FLASH_MS = 900;
export function PokerSeat({
player,
@ -26,77 +23,104 @@ export function PokerSeat({
isActing,
isWinner,
showHoleCards,
handLabel = null,
cardKeySet,
compact = false,
}: PokerSeatProps) {
const cards = showHoleCards ? player.holeCards : null;
const cardCount = player.cardCount > 0 ? player.cardCount : 2;
const faceCards = cards ?? Array.from({ length: cardCount }, () => null);
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]);
return (
<div
className={cn(
"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",
"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",
player.eliminated && "opacity-40",
isActing && "ring-2 ring-emerald-400/90",
isActing && "poker-seat-active ring-2 ring-emerald-400/90",
isCurrentUser && "border-ring",
isWinner && "ring-2 ring-amber-300",
)}
aria-label={`${player.name}, seat ${player.seatId + 1}`}
aria-label={`${player.name}, seat ${player.seatId + 1}, stack ${player.stack.toLocaleString()}`}
>
<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>
<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>
{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>
)}
<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))}
/>
{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>
<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>}
) : (
inHand && (
<div className="poker-seat-cardbacks" aria-label="Hole cards, face down">
<span aria-hidden="true" />
<span aria-hidden="true" />
</div>
)
)}
{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>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,43 @@
"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,12 +3,16 @@
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 {
@ -53,7 +57,10 @@ 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);
@ -92,18 +99,34 @@ function WaitingTable({
<Badge variant="outline">Waiting room</Badge>
</CardHeader>
<CardContent className="p-4">
<div className="poker-felt-table relative min-h-[28rem] overflow-hidden p-4 sm:min-h-[34rem]">
<div className="poker-felt-table relative min-h-[30rem] overflow-hidden p-4 sm:min-h-[36rem]">
{snapshot.seats.map((userId, seatId) => (
<div
key={seatId}
className={cn(
"poker-waiting-seat absolute flex flex-col gap-1 border px-3 py-2 text-xs",
seatPosition(seatId),
"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,
),
userId !== null && "poker-waiting-seat-occupied",
)}
>
<span className="font-semibold">Seat {seatId + 1}</span>
<span className="text-white/70">
<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">
{userId === null
? "Open seat"
: String(userId) === String(currentUserId)
@ -126,8 +149,16 @@ function WaitingTable({
{seated ? (
<>
{isHost && (
<Button type="button" onClick={() => void start()} disabled={starting}>
{starting ? "Starting..." : "Start game"}
<Button
type="button"
onClick={() => void start()}
disabled={starting || !enoughPlayers}
>
{starting
? "Starting..."
: enoughPlayers
? "Start game"
: "You need at least two players to start"}
</Button>
)}
<Button type="button" variant="outline" onClick={() => void leave()} disabled={leaving}>
@ -149,7 +180,7 @@ function WaitingTable({
);
}
export function PokerTable({
function PokerTableInner({
snapshot,
currentUserId,
loading,
@ -160,6 +191,12 @@ export function PokerTable({
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">
@ -214,6 +251,16 @@ export function PokerTable({
onSnapshot={onSnapshot}
onStart={onStart}
onLeave={onLeave}
sound={sound}
popups={popups}
/>
);
}
export function PokerTable(props: PokerTableProps) {
return (
<PokerCardStyleProvider>
<PokerTableInner {...props} />
</PokerCardStyleProvider>
);
}

View file

@ -0,0 +1,89 @@
"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

@ -0,0 +1,175 @@
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

@ -0,0 +1,264 @@
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

@ -0,0 +1,234 @@
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,10 +1,28 @@
const SEAT_POSITION_CLASSES = [
"poker-seat-south",
"poker-seat-west",
"poker-seat-north",
"poker-seat-east",
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",
],
] as const;
export function seatPosition(seatId: number): string {
return SEAT_POSITION_CLASSES[seatId % SEAT_POSITION_CLASSES.length] ?? SEAT_POSITION_CLASSES[0];
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);
}

View file

@ -0,0 +1,86 @@
"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

@ -0,0 +1,162 @@
"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, Profile } from "@/payload-types";
import type { Assignment, Award, Profile } from "@/payload-types";
type AssignmentType = Assignment["type"];
type RecipientScope = "same_command" | "whitelist" | "anyone";
@ -26,10 +26,7 @@ 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,
@ -85,9 +82,42 @@ 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,
@ -103,6 +133,9 @@ 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;
}
@ -163,10 +196,7 @@ 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, createTableState } from "./engine";
import { advanceState, awardFoldOut, createTableState } from "./engine";
import { awardPokerGameResult, type PokerPlacementResult } from "./stats";
import type {
PokerActionKind,
@ -256,6 +256,46 @@ 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;
@ -308,7 +348,18 @@ 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 you = state && inEngine ? buildPrivateView(state, seatIndex) : null;
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 hostSeat = record.hostUserId !== null ? record.seatMap.indexOf(record.hostUserId) : -1;
return {
tableId: record.id,
@ -481,6 +532,9 @@ 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,6 +163,8 @@ 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,9 +27,8 @@ 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,
@ -40,6 +39,9 @@ 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,6 +57,8 @@ 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,6 +18,7 @@ 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

@ -0,0 +1,34 @@
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,6 +54,7 @@ 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 = [
{
@ -334,6 +335,11 @@ 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'
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'
},
];

View file

@ -1360,6 +1360,7 @@ export interface User {
| 'ticket:reply'
| 'ticket:status'
| 'poker:force-ended'
| 'poker:everyone-left'
)[]
| null;
};
@ -1393,6 +1394,7 @@ export interface User {
| 'ticket:reply'
| 'ticket:status'
| 'poker:force-ended'
| 'poker:everyone-left'
)[]
| null;
};
@ -2010,6 +2012,14 @@ 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;
@ -2149,6 +2159,19 @@ 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.
*/
@ -5711,6 +5734,8 @@ export interface ProfilesSelect<T extends boolean = true> {
awardedBy?: T;
awardedAt?: T;
awardReason?: T;
upgradedFrom?: T;
upgradedAt?: T;
id?: T;
};
qualifications?: T;
@ -5833,6 +5858,12 @@ export interface AwardsSelect<T extends boolean = true> {
ribbonDesign?: T;
backgroundImage?: T;
medalDesign?: T;
advancement?:
| T
| {
seriesKey?: T;
tier?: T;
};
grantConfig?:
| T
| {

View file

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

View file

@ -0,0 +1,737 @@
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

@ -0,0 +1,45 @@
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

@ -0,0 +1,330 @@
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

@ -0,0 +1,242 @@
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,6 +1,12 @@
import { describe, expect, it } from "vitest";
import { createTable, joinTable, startGame } from "@/lib/minigames/poker/tables";
import {
createTable,
getSnapshotFor,
joinTable,
leaveTable,
startGame,
} from "@/lib/minigames/poker/tables";
/**
* Regression tests for the registry's game-start path. Guards against the
@ -56,3 +62,40 @@ 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 });
// Create 3 pending submissions
for (let i = 0; i < 3; i++) {
// Fill the pending cap
for (let i = 0; i < 15; 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 3 pending ribbon submissions. Wait for them to be reviewed first.");
expect(result.error).toBe("You already have 15 pending ribbon submissions. Wait for them to be reviewed first.");
});
});