Compare commits
8 commits
559ee64258
...
5df186d52d
| Author | SHA1 | Date | |
|---|---|---|---|
| 5df186d52d | |||
| 19113e39c9 | |||
| 71e9f3e9e0 | |||
| 16a44d3fc5 | |||
| e9e89929f9 | |||
| 56e3d253c9 | |||
| 9b2b0db553 | |||
| f7bb8f1e9f |
51 changed files with 32131 additions and 551 deletions
52
DESIGN.md
52
DESIGN.md
|
|
@ -8,17 +8,17 @@ Polaris is a dark operations console: focused, quiet, and information-dense with
|
||||||
|
|
||||||
### Palette
|
### Palette
|
||||||
|
|
||||||
| Role | Token | Usage |
|
| Role | Token | Usage |
|
||||||
|------|-------|-------|
|
| --------------------- | --------------------------------------- | ------------------------------------ |
|
||||||
| Surface / page | `bg-background` | Frontend page background |
|
| Surface / page | `bg-background` | Frontend page background |
|
||||||
| Surface / card | `bg-card` | Elevated content sections |
|
| Surface / card | `bg-card` | Elevated content sections |
|
||||||
| Text / primary | `text-foreground` | Headings and primary labels |
|
| Text / primary | `text-foreground` | Headings and primary labels |
|
||||||
| Text / secondary | `text-muted-foreground` | Descriptions and supporting copy |
|
| Text / secondary | `text-muted-foreground` | Descriptions and supporting copy |
|
||||||
| Interactive / primary | `bg-primary`, `text-primary-foreground` | Primary actions and checked controls |
|
| Interactive / primary | `bg-primary`, `text-primary-foreground` | Primary actions and checked controls |
|
||||||
| Interactive / focus | `ring-ring` | Keyboard focus indicators |
|
| Interactive / focus | `ring-ring` | Keyboard focus indicators |
|
||||||
| Border | `border-border` | Card and control boundaries |
|
| Border | `border-border` | Card and control boundaries |
|
||||||
| Status / error | `text-destructive` | Inline validation and failure copy |
|
| Status / error | `text-destructive` | Inline validation and failure copy |
|
||||||
| Status / success | `text-emerald-500` | Existing save confirmations |
|
| Status / success | `text-emerald-500` | Existing save confirmations |
|
||||||
|
|
||||||
### Rules
|
### Rules
|
||||||
|
|
||||||
|
|
@ -29,13 +29,13 @@ Polaris is a dark operations console: focused, quiet, and information-dense with
|
||||||
|
|
||||||
### Scale
|
### Scale
|
||||||
|
|
||||||
| Level | Token | Usage |
|
| Level | Token | Usage |
|
||||||
|-------|-------|-------|
|
| ---------- | ------------------------------- | ---------------------------- |
|
||||||
| Page title | `text-lg font-semibold` | Route heading |
|
| Page title | `text-lg font-semibold` | Route heading |
|
||||||
| Card title | `font-semibold` | Section heading |
|
| Card title | `font-semibold` | Section heading |
|
||||||
| Body | inherited `text-base` | Primary content |
|
| Body | inherited `text-base` | Primary content |
|
||||||
| Supporting | `text-sm text-muted-foreground` | Descriptions and helper text |
|
| Supporting | `text-sm text-muted-foreground` | Descriptions and helper text |
|
||||||
| Compact | `text-xs` | Secondary metadata only |
|
| Compact | `text-xs` | Secondary metadata only |
|
||||||
|
|
||||||
### Font Stack
|
### Font Stack
|
||||||
|
|
||||||
|
|
@ -67,6 +67,16 @@ Spacing follows Tailwind's 4px scale. Existing frontend stacks use `gap-1`, `gap
|
||||||
- **Motion**: no section-level motion.
|
- **Motion**: no section-level motion.
|
||||||
- **Layout**: vertical stack.
|
- **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
|
### 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.
|
- **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
|
### Timing
|
||||||
|
|
||||||
| Type | Duration | Usage |
|
| Type | Duration | Usage |
|
||||||
|------|----------|-------|
|
| -------- | --------- | ---------------------------------- |
|
||||||
| Micro | 100–150ms | Switch and radio state feedback |
|
| Micro | 100–150ms | Switch and radio state feedback |
|
||||||
| Standard | 200–300ms | Button hover and focus transitions |
|
| Standard | 200–300ms | Button hover and focus transitions |
|
||||||
|
|
||||||
### Rules
|
### Rules
|
||||||
|
|
@ -182,6 +192,6 @@ The frontend uses a mixed strategy: card borders plus the existing shadcn card s
|
||||||
|
|
||||||
### Accepted Debt
|
### 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 |
|
| 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 |
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "polaris-task-force",
|
"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.",
|
"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",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,13 @@ import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||||
import {
|
import {
|
||||||
countGrantsByGrantor,
|
countGrantsByGrantor,
|
||||||
findUserProfile,
|
findUserProfile,
|
||||||
|
getAwardsByIds,
|
||||||
getGrantorAssignments,
|
getGrantorAssignments,
|
||||||
|
heldTierOfSeries,
|
||||||
isRecipientEligible,
|
isRecipientEligible,
|
||||||
} from "@/lib/awards/grant";
|
} from "@/lib/awards/grant";
|
||||||
import { cleanupDuplicateAwards, type CleanupResult } from "@/lib/awards/index";
|
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> {
|
interface ActionResult<T = undefined> {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
|
|
@ -107,12 +109,6 @@ export async function grantAward(input: {
|
||||||
}
|
}
|
||||||
const issued = await countGrantsByGrantor(payload, award.id, grantorProfile.id);
|
const issued = await countGrantsByGrantor(payload, award.id, grantorProfile.id);
|
||||||
remaining = maxGrants - issued;
|
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;
|
let processed = 0;
|
||||||
|
|
||||||
for (const recipientUserId of input.recipientUserIds) {
|
for (const recipientUserId of input.recipientUserIds) {
|
||||||
if (remaining !== null && processed >= remaining) break;
|
|
||||||
|
|
||||||
if (grantorAssignmentId !== undefined) {
|
if (grantorAssignmentId !== undefined) {
|
||||||
const eligible = await isRecipientEligible(
|
const eligible = await isRecipientEligible(
|
||||||
payload,
|
payload,
|
||||||
|
|
@ -163,20 +157,150 @@ export async function grantAward(input: {
|
||||||
const progression = recipientProfile.progression;
|
const progression = recipientProfile.progression;
|
||||||
const currentAwards = progression?.awards ?? [];
|
const currentAwards = progression?.awards ?? [];
|
||||||
|
|
||||||
const alreadyHasAward = currentAwards.some((e) => {
|
// Handle advancement series logic for ribbons with advancement
|
||||||
const id = typeof e.award === "number" ? e.award : e.award?.id;
|
if (
|
||||||
return id === award.id;
|
award.advancement?.seriesKey &&
|
||||||
});
|
award.advancement?.tier !== undefined &&
|
||||||
if (alreadyHasAward) {
|
award.advancement?.tier !== null
|
||||||
results.push({
|
) {
|
||||||
recipientUserId,
|
// Get all the awards held by this recipient
|
||||||
success: false,
|
const heldAwardIds = currentAwards
|
||||||
error: "Recipient already has this award.",
|
.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;
|
if (alreadyHasAward) {
|
||||||
continue;
|
results.push({
|
||||||
|
recipientUserId,
|
||||||
|
success: false,
|
||||||
|
error: "Recipient already has this award.",
|
||||||
|
});
|
||||||
|
processed += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Normal grant (no series advancement)
|
||||||
await payload.update({
|
await payload.update({
|
||||||
collection: "profiles",
|
collection: "profiles",
|
||||||
id: recipientProfile.id,
|
id: recipientProfile.id,
|
||||||
|
|
@ -221,9 +345,7 @@ export async function grantAward(input: {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cleanupDuplicateAwardsAction(): Promise<
|
export async function cleanupDuplicateAwardsAction(): Promise<ActionResult<CleanupResult>> {
|
||||||
ActionResult<CleanupResult>
|
|
||||||
> {
|
|
||||||
try {
|
try {
|
||||||
const { payload, user } = await authenticate();
|
const { payload, user } = await authenticate();
|
||||||
const superuser = await isSuperuser(payload, user);
|
const superuser = await isSuperuser(payload, user);
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ interface ActionResult<T = undefined> {
|
||||||
data?: T;
|
data?: T;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_PENDING_RIBBON_SUBMISSIONS = 3;
|
const MAX_PENDING_RIBBON_SUBMISSIONS = 15;
|
||||||
|
|
||||||
async function authenticate() {
|
async function authenticate() {
|
||||||
const payloadConfig = await config;
|
const payloadConfig = await config;
|
||||||
|
|
@ -90,7 +90,10 @@ export async function submitRibbonDesign(input: {
|
||||||
overrideAccess: true,
|
overrideAccess: true,
|
||||||
});
|
});
|
||||||
if (pendingCount.totalDocs >= MAX_PENDING_RIBBON_SUBMISSIONS) {
|
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({
|
const submission = await payload.create({
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,67 @@ const TYPE_LABEL: Record<"ribbon" | "medal", string> = {
|
||||||
medal: "Medals",
|
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() {
|
export default async function AwardsPage() {
|
||||||
const headers = await nextHeaders();
|
const headers = await nextHeaders();
|
||||||
const payloadConfig = await config;
|
const payloadConfig = await config;
|
||||||
|
|
@ -54,9 +115,19 @@ export default async function AwardsPage() {
|
||||||
const ribbons = awards.filter((a) => a.type === "ribbon");
|
const ribbons = awards.filter((a) => a.type === "ribbon");
|
||||||
const medals = awards.filter((a) => a.type === "medal");
|
const medals = awards.filter((a) => a.type === "medal");
|
||||||
|
|
||||||
const sections: { title: string; items: Award[] }[] = [];
|
const catalogSections: { title: string; items: AwardCatalogItem[] }[] = [];
|
||||||
if (medals.length > 0) sections.push({ title: TYPE_LABEL.medal, items: medals });
|
if (medals.length > 0) {
|
||||||
if (ribbons.length > 0) sections.push({ title: TYPE_LABEL.ribbon, items: ribbons });
|
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 (
|
return (
|
||||||
<div className="p-5 flex flex-col gap-6 max-w-7xl">
|
<div className="p-5 flex flex-col gap-6 max-w-7xl">
|
||||||
|
|
@ -96,27 +167,48 @@ export default async function AwardsPage() {
|
||||||
</ItemContent>
|
</ItemContent>
|
||||||
</Item>
|
</Item>
|
||||||
) : (
|
) : (
|
||||||
sections.map((section) => (
|
catalogSections.map((section) => (
|
||||||
<section key={section.title} className="flex flex-col gap-3">
|
<section key={section.title} className="flex flex-col gap-3">
|
||||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
{section.title}
|
{section.title}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{section.items.map((award) => {
|
{section.items.map((item) => {
|
||||||
const awardImage =
|
switch (item.kind) {
|
||||||
typeof award.image === "object" && award.image ? (award.image as Media) : null;
|
case "advancement":
|
||||||
const backgroundImage =
|
return (
|
||||||
typeof award.backgroundImage === "object" && award.backgroundImage
|
<div
|
||||||
? (award.backgroundImage as Media)
|
key={`advancement-${item.seriesKey}`}
|
||||||
: null;
|
className="col-span-full flex items-center gap-2 border-b border-border pb-1 text-xs font-medium text-muted-foreground"
|
||||||
return (
|
>
|
||||||
<AwardWithGrantButton
|
<span className="shrink-0">Advancement: {item.familyName}</span>
|
||||||
key={award.id}
|
<div className="h-px flex-1 bg-border" aria-hidden />
|
||||||
award={award}
|
</div>
|
||||||
awardImage={awardImage}
|
);
|
||||||
backgroundImage={backgroundImage}
|
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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { getPayload } from "payload";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { headers as nextHeaders } from "next/headers";
|
import { headers as nextHeaders } from "next/headers";
|
||||||
import Link from "next/link";
|
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 { ClientDate } from "@/components/frontend/ClientDate";
|
||||||
import { resolveLevel } from "@/utils/xp/resolveLevel";
|
import { resolveLevel } from "@/utils/xp/resolveLevel";
|
||||||
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
||||||
|
|
@ -53,6 +53,104 @@ interface ProfilePageProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
type Payload = Awaited<ReturnType<typeof getPayload>>;
|
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(
|
async function findEligibleEvaluationMissions(
|
||||||
payload: Payload,
|
payload: Payload,
|
||||||
|
|
@ -207,14 +305,19 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
|
||||||
const canEditIntelPromise: Promise<boolean> = viewer
|
const canEditIntelPromise: Promise<boolean> = viewer
|
||||||
? hasPermission(payload, viewer, "profiles:intel_excerpt:update")
|
? hasPermission(payload, viewer, "profiles:intel_excerpt:update")
|
||||||
: Promise.resolve(false);
|
: Promise.resolve(false);
|
||||||
const [evaluationSummaryResult, eligibleMissions, isSuperuserViewer, isDirectLeader, canEditIntel] =
|
const [
|
||||||
await Promise.all([
|
evaluationSummaryResult,
|
||||||
evaluationSummaryPromise,
|
eligibleMissions,
|
||||||
eligibleMissionsPromise,
|
isSuperuserViewer,
|
||||||
privilegedViewerPromise,
|
isDirectLeader,
|
||||||
directLeaderPromise,
|
canEditIntel,
|
||||||
canEditIntelPromise,
|
] = await Promise.all([
|
||||||
]);
|
evaluationSummaryPromise,
|
||||||
|
eligibleMissionsPromise,
|
||||||
|
privilegedViewerPromise,
|
||||||
|
directLeaderPromise,
|
||||||
|
canEditIntelPromise,
|
||||||
|
]);
|
||||||
const canViewSubordinate =
|
const canViewSubordinate =
|
||||||
isProfileOwner || isLegacyPrivileged || isSuperuserViewer || isDirectLeader;
|
isProfileOwner || isLegacyPrivileged || isSuperuserViewer || isDirectLeader;
|
||||||
const latestMissionRows =
|
const latestMissionRows =
|
||||||
|
|
@ -258,12 +361,18 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
|
||||||
: null;
|
: null;
|
||||||
const canSubmitEvaluation = viewer !== null && !isProfileOwner;
|
const canSubmitEvaluation = viewer !== null && !isProfileOwner;
|
||||||
const rank = profile.rank as Rank;
|
const rank = profile.rank as Rank;
|
||||||
const awards = (profile.progression?.awards ?? []).flatMap((entry) => {
|
const awardEntries = (profile.progression?.awards ?? []).filter(
|
||||||
if (typeof entry !== "object" || entry === null) return [];
|
(entry): entry is ProfileAwardEntry => typeof entry === "object" && entry !== null,
|
||||||
|
);
|
||||||
|
const heldAwardEntries: HeldAwardEntry[] = awardEntries.flatMap((entry) => {
|
||||||
const award = entry.award;
|
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(
|
const qualifications = (profile.progression?.qualifications ?? []).filter(
|
||||||
(q): q is Qualification => typeof q === "object" && q !== null,
|
(q): q is Qualification => typeof q === "object" && q !== null,
|
||||||
);
|
);
|
||||||
|
|
@ -378,12 +487,12 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
|
||||||
</ItemMedia>
|
</ItemMedia>
|
||||||
<ItemContent>
|
<ItemContent>
|
||||||
<ItemTitle>Enlisted</ItemTitle>
|
<ItemTitle>Enlisted</ItemTitle>
|
||||||
<ItemDescription>
|
<ItemDescription>
|
||||||
<ClientDate
|
<ClientDate
|
||||||
date={profile.dossier.enlistmentDate}
|
date={profile.dossier.enlistmentDate}
|
||||||
options={{ year: "numeric", month: "short", day: "numeric" }}
|
options={{ year: "numeric", month: "short", day: "numeric" }}
|
||||||
/>
|
/>
|
||||||
</ItemDescription>
|
</ItemDescription>
|
||||||
</ItemContent>
|
</ItemContent>
|
||||||
</Item>
|
</Item>
|
||||||
<Item variant="muted" size="sm">
|
<Item variant="muted" size="sm">
|
||||||
|
|
@ -489,7 +598,13 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{grantableAwards.length > 0 && (
|
{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 ──────────────────────────────────── */}
|
{/* ── Combat Record ──────────────────────────────────── */}
|
||||||
|
|
@ -588,28 +703,35 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
|
||||||
</div>
|
</div>
|
||||||
{awards.length > 0 ? (
|
{awards.length > 0 ? (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
{[...awards]
|
{[...heldAwardEntries]
|
||||||
.sort((a, b) => {
|
.sort(({ award: first }, { award: second }) => {
|
||||||
const typeOrder = { medal: 0, ribbon: 1, commendation: 2 } as const;
|
const typeOrder = { medal: 0, ribbon: 1, commendation: 2 } as const;
|
||||||
const categoryOrder =
|
const categoryOrder =
|
||||||
(typeOrder[a.type as keyof typeof typeOrder] ?? typeOrder.commendation) -
|
(typeOrder[first.type as keyof typeof typeOrder] ?? typeOrder.commendation) -
|
||||||
(typeOrder[b.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 =
|
const awardImage =
|
||||||
award.image && typeof award.image === "object" ? (award.image as Media) : null;
|
award.image && typeof award.image === "object" ? (award.image as Media) : null;
|
||||||
const backgroundImage =
|
const backgroundImage =
|
||||||
award.backgroundImage && typeof award.backgroundImage === "object"
|
award.backgroundImage && typeof award.backgroundImage === "object"
|
||||||
? (award.backgroundImage as Media)
|
? (award.backgroundImage as Media)
|
||||||
: null;
|
: null;
|
||||||
|
const upgradedFromId = awardIdFromReference(entry.upgradedFrom);
|
||||||
return (
|
return (
|
||||||
<AwardCard
|
<AwardCard
|
||||||
key={award.id}
|
key={entry.id ?? `${award.id}:${entry.awardedAt}`}
|
||||||
awardImage={awardImage}
|
awardImage={awardImage}
|
||||||
backgroundImage={backgroundImage}
|
backgroundImage={backgroundImage}
|
||||||
award={award}
|
award={award}
|
||||||
|
upgradedFromName={
|
||||||
|
upgradedFromId === null ? null : (upgradedFromNames[upgradedFromId] ?? null)
|
||||||
|
}
|
||||||
|
upgradedAt={entry.upgradedAt}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -94,6 +94,33 @@ export const Awards: CollectionConfig = {
|
||||||
condition: (data) => data?.type === "medal",
|
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",
|
name: "grantConfig",
|
||||||
type: "group",
|
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;
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -244,6 +244,21 @@ export const Profiles: CollectionConfig = {
|
||||||
description: "An optional reason for the award.",
|
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.",
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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 { Item, ItemContent, ItemDescription, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||||||
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
|
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
|
||||||
import { RibbonViewer } from "@/components/frontend/account/RibbonViewer";
|
import { RibbonViewer } from "@/components/frontend/account/RibbonViewer";
|
||||||
import type { RibbonDesign } from "@/components/awards/ribbonDesign/types";
|
import type { RibbonDesign } from "@/components/awards/ribbonDesign/types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { Award, Media } from "@/payload-types";
|
||||||
|
|
||||||
const getExperiencePointsClass = (experiencePoints: number) => {
|
const getExperiencePointsClass = (experiencePoints: number) => {
|
||||||
if (experiencePoints >= 5000) return "award-xp-high";
|
if (experiencePoints >= 5000) return "award-xp-high";
|
||||||
|
|
@ -17,6 +18,8 @@ export default function AwardCard(props: {
|
||||||
awardImage: Media | null;
|
awardImage: Media | null;
|
||||||
award: Award;
|
award: Award;
|
||||||
backgroundImage?: Media | null;
|
backgroundImage?: Media | null;
|
||||||
|
upgradedFromName?: string | null;
|
||||||
|
upgradedAt?: string | null;
|
||||||
}) {
|
}) {
|
||||||
const isCommendation = props.award.type === "commendation" || !props.award.type;
|
const isCommendation = props.award.type === "commendation" || !props.award.type;
|
||||||
const commendationColor =
|
const commendationColor =
|
||||||
|
|
@ -27,6 +30,10 @@ export default function AwardCard(props: {
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const bgUrl = props.backgroundImage?.url;
|
const bgUrl = props.backgroundImage?.url;
|
||||||
|
const upgradeTrail =
|
||||||
|
props.upgradedFromName && props.upgradedAt
|
||||||
|
? { name: props.upgradedFromName, at: props.upgradedAt }
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Item
|
<Item
|
||||||
|
|
@ -108,6 +115,15 @@ export default function AwardCard(props: {
|
||||||
+{props.award.experiencePoints} XP
|
+{props.award.experiencePoints} XP
|
||||||
</span>
|
</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>
|
</ItemContent>
|
||||||
</Item>
|
</Item>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { Award, Media } from "@/payload-types";
|
import { Award, Media } from "@/payload-types";
|
||||||
import { Item, ItemContent, ItemDescription, ItemHeader, ItemTitle } from "@/components/ui/item";
|
import { Item, ItemContent, ItemDescription, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
|
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
|
||||||
import { RibbonViewer } from "@/components/frontend/account/RibbonViewer";
|
import { RibbonViewer } from "@/components/frontend/account/RibbonViewer";
|
||||||
import type { RibbonDesign } from "@/components/awards/ribbonDesign/types";
|
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 && (
|
{hasArt && (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -95,6 +101,11 @@ export function AwardListingCard({
|
||||||
<span className="text-[9px] uppercase tracking-wider text-muted-foreground font-mono">
|
<span className="text-[9px] uppercase tracking-wider text-muted-foreground font-mono">
|
||||||
{type}
|
{type}
|
||||||
</span>
|
</span>
|
||||||
|
{award.advancement?.tier != null && (
|
||||||
|
<Badge variant="outline" className="w-fit text-xs text-muted-foreground">
|
||||||
|
Tier {award.advancement.tier}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</ItemHeader>
|
</ItemHeader>
|
||||||
|
|
||||||
<ItemContent className="relative z-10 flex flex-col gap-2">
|
<ItemContent className="relative z-10 flex flex-col gap-2">
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,22 @@ import { useRouter } from "next/navigation";
|
||||||
import type { Award } from "@/payload-types";
|
import type { Award } from "@/payload-types";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { AwardPickerDialog } from "./AwardPickerDialog";
|
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 {
|
interface ProfileGrantAwardButtonProps {
|
||||||
userId: number;
|
readonly userId: number;
|
||||||
username: string;
|
readonly username: string;
|
||||||
awards: Award[];
|
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
|
* Eligibility is enforced server-side (getGrantStats / grantAward inside
|
||||||
* GrantAwardDialog) — this component only provides the entry point.
|
* 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 [open, setOpen] = useState(false);
|
||||||
|
const [openUpgradeKey, setOpenUpgradeKey] = useState<string | null>(null);
|
||||||
const router = useRouter();
|
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 (
|
return (
|
||||||
<section className="flex flex-col gap-3">
|
<section className="flex flex-col gap-3">
|
||||||
|
|
@ -31,10 +53,27 @@ export function ProfileGrantAwardButton({ userId, username, awards }: ProfileGra
|
||||||
Grant Award
|
Grant Award
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" variant="outline" onClick={() => setOpen(true)}>
|
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||||
<PlusIcon data-icon="inline-start" />
|
{upgradeOptions.map(({ heldAwardId, nextTier }) => {
|
||||||
Grant Award
|
const upgradeKey = `${heldAwardId}:${nextTier.id}`;
|
||||||
</Button>
|
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>
|
</div>
|
||||||
<AwardPickerDialog
|
<AwardPickerDialog
|
||||||
awards={awards}
|
awards={awards}
|
||||||
|
|
@ -44,6 +83,26 @@ export function ProfileGrantAwardButton({ userId, username, awards }: ProfileGra
|
||||||
onOpenChange={setOpen}
|
onOpenChange={setOpen}
|
||||||
onGranted={() => router.refresh()}
|
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>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,62 @@
|
||||||
import type { RibbonDesign } from "@/components/awards/ribbonDesign/types";
|
import type { RibbonDesign } from "@/components/awards/ribbonDesign/types";
|
||||||
import { RibbonViewer } from "@/components/frontend/account/RibbonViewer";
|
import { RibbonViewer } from "@/components/frontend/account/RibbonViewer";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
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";
|
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[] }) => {
|
export const RibbonRack = ({ awards }: { awards: Award[] }) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap w-[19.625rem] max-w-full gap-x-1 gap-y-px">
|
<div className="flex flex-wrap w-[19.625rem] max-w-full gap-x-1 gap-y-px">
|
||||||
{awards
|
{dedupeAdvancementAwards(
|
||||||
.filter((award) => award.type === "ribbon" && award.ribbonDesign != null)
|
awards.filter((award) => award.type === "ribbon" && award.ribbonDesign != null),
|
||||||
|
)
|
||||||
.sort((a, b) => (b.experiencePoints ?? 0) - (a.experiencePoints ?? 0))
|
.sort((a, b) => (b.experiencePoints ?? 0) - (a.experiencePoints ?? 0))
|
||||||
.map((award, key) => {
|
.map((award, key) => {
|
||||||
const ribbon = award.ribbonDesign as RibbonDesign;
|
const ribbon = award.ribbonDesign as RibbonDesign;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import type { PokerActionKind, PokerLegalActions } from "@/lib/minigames/poker";
|
import type { PokerActionKind, PokerLegalActions } from "@/lib/minigames/poker";
|
||||||
import { pokerAction, type PokerSnapshot } from "@/app/(frontend)/minigames/poker/actions";
|
import { pokerAction, type PokerSnapshot } from "@/app/(frontend)/minigames/poker/actions";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -14,6 +15,8 @@ interface BettingControlsProps {
|
||||||
readonly potTotal: number;
|
readonly potTotal: number;
|
||||||
readonly yourTurn: boolean;
|
readonly yourTurn: boolean;
|
||||||
readonly onSnapshot: (snapshot: PokerSnapshot) => void;
|
readonly onSnapshot: (snapshot: PokerSnapshot) => void;
|
||||||
|
readonly onActionFeedback?: () => void;
|
||||||
|
readonly compact?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function clampAmount(value: number, min: number, max: number): number {
|
function clampAmount(value: number, min: number, max: number): number {
|
||||||
|
|
@ -32,10 +35,13 @@ export function BettingControls({
|
||||||
potTotal,
|
potTotal,
|
||||||
yourTurn,
|
yourTurn,
|
||||||
onSnapshot,
|
onSnapshot,
|
||||||
|
onActionFeedback,
|
||||||
|
compact = false,
|
||||||
}: BettingControlsProps) {
|
}: BettingControlsProps) {
|
||||||
const [pendingAction, setPendingAction] = useState<PokerActionKind | null>(null);
|
const [pendingAction, setPendingAction] = useState<PokerActionKind | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [amount, setAmount] = useState(legalActions.minBetTo);
|
const [amount, setAmount] = useState(legalActions.minBetTo);
|
||||||
|
const [wagerOpen, setWagerOpen] = useState(false);
|
||||||
|
|
||||||
const wagerKind: PokerActionKind | null = legalActions.canBet
|
const wagerKind: PokerActionKind | null = legalActions.canBet
|
||||||
? "bet"
|
? "bet"
|
||||||
|
|
@ -53,8 +59,13 @@ export function BettingControls({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPendingAction(null);
|
setPendingAction(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setWagerOpen(false);
|
||||||
}, [snapshotVersion]);
|
}, [snapshotVersion]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (compact && (!yourTurn || wagerKind === null)) setWagerOpen(false);
|
||||||
|
}, [compact, wagerKind, yourTurn]);
|
||||||
|
|
||||||
const submit = async (kind: PokerActionKind, wager?: number) => {
|
const submit = async (kind: PokerActionKind, wager?: number) => {
|
||||||
setError(null);
|
setError(null);
|
||||||
setPendingAction(kind);
|
setPendingAction(kind);
|
||||||
|
|
@ -65,6 +76,7 @@ export function BettingControls({
|
||||||
setPendingAction(null);
|
setPendingAction(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (compact) setWagerOpen(false);
|
||||||
if (result.data) onSnapshot(result.data);
|
if (result.data) onSnapshot(result.data);
|
||||||
} catch (caught) {
|
} catch (caught) {
|
||||||
setError(caught instanceof Error ? caught.message : "Could not send the table action.");
|
setError(caught instanceof Error ? caught.message : "Could not send the table action.");
|
||||||
|
|
@ -84,44 +96,97 @@ export function BettingControls({
|
||||||
setAmount(clampAmount(nextAmount, wagerMin, wagerMax));
|
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 (
|
return (
|
||||||
<div className="flex flex-col gap-3">
|
<div
|
||||||
<div className="flex flex-wrap gap-2">
|
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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
|
className="poker-chip poker-chip-fold"
|
||||||
disabled={!canAct || !legalActions.canFold}
|
disabled={!canAct || !legalActions.canFold}
|
||||||
onClick={() => void submit("fold")}
|
onClick={() => submitFromControl("fold")}
|
||||||
>
|
>
|
||||||
Fold
|
Fold
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
{compact ? (
|
||||||
type="button"
|
compactAction && (
|
||||||
variant="outline"
|
<Button
|
||||||
disabled={!canAct || !legalActions.canCheck}
|
type="button"
|
||||||
onClick={() => void submit("check")}
|
variant={compactAction.kind === "call" ? "secondary" : "outline"}
|
||||||
>
|
className="poker-chip poker-chip-call"
|
||||||
Check
|
disabled={!canAct}
|
||||||
</Button>
|
onClick={() => submitFromControl(compactAction.kind)}
|
||||||
<Button
|
>
|
||||||
type="button"
|
{compactAction.label}
|
||||||
variant="secondary"
|
</Button>
|
||||||
disabled={!canAct || !legalActions.canCall}
|
)
|
||||||
onClick={() => void submit("call")}
|
) : (
|
||||||
>
|
<>
|
||||||
Call {amountLabel(legalActions.callAmount)}
|
<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>
|
</div>
|
||||||
|
|
||||||
{wagerKind && wagerMax >= wagerMin && (
|
{wagerKind && wagerMax >= wagerMin && (!compact || wagerOpen) && (
|
||||||
<Card className="rounded-none">
|
<Card
|
||||||
<CardHeader className="p-4 pb-2">
|
className={cn("poker-wager-panel rounded-none", compact && "poker-wager-panel-compact")}
|
||||||
|
>
|
||||||
|
<CardHeader className="p-3 pb-2">
|
||||||
<CardTitle className="text-sm">
|
<CardTitle className="text-sm">
|
||||||
{wagerKind === "bet" ? "Bet size" : "Raise to"}
|
{wagerKind === "bet" ? "Bet size" : "Raise to"}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col gap-3 p-4 pt-0">
|
<CardContent
|
||||||
<div className="flex items-center gap-3">
|
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
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min={wagerMin}
|
min={wagerMin}
|
||||||
|
|
@ -132,7 +197,7 @@ export function BettingControls({
|
||||||
setAmount(clampAmount(Number(event.target.value), wagerMin, wagerMax))
|
setAmount(clampAmount(Number(event.target.value), wagerMin, wagerMax))
|
||||||
}
|
}
|
||||||
disabled={!canAct}
|
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`}
|
aria-label={`${wagerKind === "bet" ? "Bet" : "Raise"} amount`}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -145,19 +210,23 @@ export function BettingControls({
|
||||||
setAmount(clampAmount(Number(event.target.value), wagerMin, wagerMax))
|
setAmount(clampAmount(Number(event.target.value), wagerMin, wagerMax))
|
||||||
}
|
}
|
||||||
disabled={!canAct}
|
disabled={!canAct}
|
||||||
className="w-24 font-mono tabular-nums"
|
className="poker-wager-input w-24 font-mono tabular-nums"
|
||||||
aria-label="Wager amount"
|
aria-label="Wager amount"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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) => (
|
{(["min", "half", "pot", "all"] as const).map((choice) => (
|
||||||
<Button
|
<Button
|
||||||
key={choice}
|
key={choice}
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
className="poker-quick-chip"
|
||||||
disabled={!canAct}
|
disabled={!canAct}
|
||||||
onClick={() => chooseQuickAmount(choice)}
|
onClick={() => {
|
||||||
|
onActionFeedback?.();
|
||||||
|
chooseQuickAmount(choice);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{choice === "min"
|
{choice === "min"
|
||||||
? "Min"
|
? "Min"
|
||||||
|
|
@ -170,9 +239,9 @@ export function BettingControls({
|
||||||
))}
|
))}
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className="ml-auto"
|
className="poker-chip poker-chip-wager ml-auto"
|
||||||
disabled={!canAct}
|
disabled={!canAct}
|
||||||
onClick={() => void submit(wagerKind, amount)}
|
onClick={() => submitFromControl(wagerKind, amount)}
|
||||||
>
|
>
|
||||||
{wagerKind === "bet" ? "Bet" : "Raise"} {amountLabel(amount)}
|
{wagerKind === "bet" ? "Bet" : "Raise"} {amountLabel(amount)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -181,11 +250,12 @@ export function BettingControls({
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex min-h-5 items-center justify-between gap-3 text-xs" aria-live="polite">
|
<div
|
||||||
{!yourTurn && <span className="text-muted-foreground">Waiting for the table to act.</span>}
|
className="poker-betting-message flex min-h-5 items-center justify-between gap-3 text-xs"
|
||||||
{pendingAction && (
|
aria-live="polite"
|
||||||
<span className="text-muted-foreground">Waiting for the table update...</span>
|
>
|
||||||
)}
|
{!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>}
|
{error && <span className="text-destructive">{error}</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { Card, Rank, Suit } from "@/lib/minigames/poker";
|
import type { Card, Rank, Suit } from "@/lib/minigames/poker";
|
||||||
|
import { mapCardToSvg, useCardStyle } from "./cardStyle";
|
||||||
|
|
||||||
const RANK_LABELS: Record<Rank, string> = {
|
const RANK_LABELS: Record<Rank, string> = {
|
||||||
2: "2",
|
2: "2",
|
||||||
|
|
@ -47,7 +48,11 @@ export function CardView({
|
||||||
highlighted = false,
|
highlighted = false,
|
||||||
dimmed = false,
|
dimmed = false,
|
||||||
}: CardViewProps) {
|
}: CardViewProps) {
|
||||||
|
const { style: cardStyle } = useCardStyle();
|
||||||
|
const lightFace = cardStyle === "light";
|
||||||
|
const classicFace = cardStyle === "classic";
|
||||||
const revealed = card !== null && faceUp;
|
const revealed = card !== null && faceUp;
|
||||||
|
const redSuitClass = lightFace ? "text-rose-600" : "text-rose-400";
|
||||||
const label = card
|
const label = card
|
||||||
? `${RANK_LABELS[card.rank]} of ${
|
? `${RANK_LABELS[card.rank]} of ${
|
||||||
card.suit === "s"
|
card.suit === "s"
|
||||||
|
|
@ -76,28 +81,38 @@ export function CardView({
|
||||||
style={dealIndex > 0 ? { animationDelay: `${dealIndex * 60}ms` } : undefined}
|
style={dealIndex > 0 ? { animationDelay: `${dealIndex * 60}ms` } : undefined}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
>
|
>
|
||||||
<span className={cn("poker-card-inner", revealed && "poker-card-is-face-up")}>
|
<span className="poker-card-motion">
|
||||||
<span className="poker-card-face poker-card-front" aria-hidden={!revealed}>
|
<span className={cn("poker-card-inner", revealed && "poker-card-is-face-up")}>
|
||||||
{card && (
|
<span
|
||||||
<>
|
className={cn(
|
||||||
<span
|
"poker-card-face poker-card-front gap-1",
|
||||||
className={cn(
|
lightFace && "poker-card-face--light",
|
||||||
"flex flex-col items-center leading-none",
|
classicFace && "poker-card-front--svg",
|
||||||
RED_SUITS.has(card.suit) && "text-rose-400",
|
)}
|
||||||
)}
|
aria-hidden={!revealed}
|
||||||
>
|
>
|
||||||
<span className="font-semibold">{RANK_LABELS[card.rank]}</span>
|
{card && classicFace && (
|
||||||
<span>{SUIT_SYMBOLS[card.suit]}</span>
|
<img src={mapCardToSvg(card)} alt="" draggable={false} className="poker-card-svg" />
|
||||||
</span>
|
)}
|
||||||
<span
|
{card && !classicFace && (
|
||||||
className={cn("poker-card-center", RED_SUITS.has(card.suit) && "text-rose-400")}
|
<>
|
||||||
>
|
<span
|
||||||
{SUIT_SYMBOLS[card.suit]}
|
className={cn(
|
||||||
</span>
|
"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>
|
||||||
<span className="poker-card-face poker-card-back" aria-hidden={revealed} />
|
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
167
src/components/frontend/minigames/poker/PokerActionConsole.tsx
Normal file
167
src/components/frontend/minigames/poker/PokerActionConsole.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
src/components/frontend/minigames/poker/PokerDeckOrigin.tsx
Normal file
11
src/components/frontend/minigames/poker/PokerDeckOrigin.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
275
src/components/frontend/minigames/poker/PokerFeltCanvas.tsx
Normal file
275
src/components/frontend/minigames/poker/PokerFeltCanvas.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -3,52 +3,26 @@
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Maximize2, Minimize2 } from "lucide-react";
|
import { Maximize2, Minimize2 } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import type { PokerPhase } from "@/lib/minigames/poker";
|
||||||
import type { Card, PokerPhase, PublicPlayerView } from "@/lib/minigames/poker";
|
|
||||||
import { describeHandStrength } from "@/lib/minigames/poker/handHint";
|
import { describeHandStrength } from "@/lib/minigames/poker/handHint";
|
||||||
import type { PokerSnapshot } from "@/app/(frontend)/minigames/poker/actions";
|
import type { PokerSnapshot } from "@/app/(frontend)/minigames/poker/actions";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import { Card as UiCard, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
Card as UiCard,
|
import { PokerCardStyleToggle } from "./PokerCardStyleToggle";
|
||||||
CardContent,
|
import { PokerFeltCanvas, type SeatedSnapshot } from "./PokerFeltCanvas";
|
||||||
CardFooter,
|
import { PokerSoundToggle } from "./PokerSoundToggle";
|
||||||
CardHeader,
|
import type { PokerSoundControls } from "./usePokerSounds";
|
||||||
CardTitle,
|
import type { ActivePokerPopup } from "./usePokerPopups";
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { BettingControls } from "./BettingControls";
|
|
||||||
import { CardView } from "./CardView";
|
|
||||||
import { PokerClock } from "./PokerClock";
|
|
||||||
import { PokerSeat } from "./PokerSeat";
|
|
||||||
import { RenameDialogGuard } from "./RenameDialogGuard";
|
import { RenameDialogGuard } from "./RenameDialogGuard";
|
||||||
import { seatPosition } from "./seatPosition";
|
|
||||||
|
|
||||||
type SeatedSnapshot = PokerSnapshot & {
|
|
||||||
readonly you: NonNullable<PokerSnapshot["you"]>;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface PokerGameTableProps {
|
interface PokerGameTableProps {
|
||||||
readonly snapshot: SeatedSnapshot;
|
readonly snapshot: SeatedSnapshot;
|
||||||
readonly onSnapshot: (snapshot: PokerSnapshot) => void;
|
readonly onSnapshot: (snapshot: PokerSnapshot) => void;
|
||||||
readonly onStart: () => Promise<void>;
|
readonly onStart: () => Promise<void>;
|
||||||
readonly onLeave: () => Promise<void>;
|
readonly onLeave: () => Promise<void>;
|
||||||
}
|
readonly sound: PokerSoundControls;
|
||||||
|
readonly popups: readonly ActivePokerPopup[];
|
||||||
interface FeltCanvasProps {
|
|
||||||
readonly snapshot: SeatedSnapshot;
|
|
||||||
readonly onSnapshot: (snapshot: PokerSnapshot) => void;
|
|
||||||
readonly handLabel: string | null;
|
|
||||||
readonly bestCardKeys: ReadonlySet<string>;
|
|
||||||
readonly fullscreen?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TableActionButtonsProps {
|
|
||||||
readonly view: SeatedSnapshot["you"];
|
|
||||||
readonly hostSeatId: number | null;
|
|
||||||
readonly starting: boolean;
|
|
||||||
readonly leaving: boolean;
|
|
||||||
readonly onStart: () => void;
|
|
||||||
readonly onLeave: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const PHASE_LABELS: Record<PokerPhase, string> = {
|
const PHASE_LABELS: Record<PokerPhase, string> = {
|
||||||
|
|
@ -58,197 +32,14 @@ const PHASE_LABELS: Record<PokerPhase, string> = {
|
||||||
"game-complete": "Game complete",
|
"game-complete": "Game complete",
|
||||||
};
|
};
|
||||||
|
|
||||||
function cardKey(card: Card): string {
|
export function PokerGameTable({
|
||||||
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({
|
|
||||||
snapshot,
|
snapshot,
|
||||||
onSnapshot,
|
onSnapshot,
|
||||||
handLabel,
|
onStart,
|
||||||
bestCardKeys,
|
onLeave,
|
||||||
fullscreen = false,
|
sound,
|
||||||
}: FeltCanvasProps) {
|
popups,
|
||||||
const view = snapshot.you;
|
}: PokerGameTableProps) {
|
||||||
const winnerSeats = new Set(view.showdown?.pots.flatMap((pot) => pot.winnerSeats) ?? []);
|
|
||||||
const result = view.showdown?.results.find((item) => item.seatId === view.viewerSeat);
|
|
||||||
const showHoleCards = view.phase !== "hand-complete" && view.phase !== "game-complete";
|
|
||||||
const hasHint = handLabel !== null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"poker-felt-table relative min-h-[36rem] overflow-hidden p-4 sm:min-h-[42rem]",
|
|
||||||
fullscreen && "poker-felt-fullscreen",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{view.players.map((player: PublicPlayerView) => {
|
|
||||||
const isViewer = player.seatId === view.viewerSeat;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={player.seatId}
|
|
||||||
className={cn(
|
|
||||||
"absolute z-10",
|
|
||||||
isViewer ? "poker-seat-south-strip" : "w-36 max-w-[44%] sm:w-52",
|
|
||||||
!isViewer && seatPosition(player.seatId),
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<PokerSeat
|
|
||||||
player={player}
|
|
||||||
isCurrentUser={isViewer}
|
|
||||||
isActing={view.phase === "betting" && player.seatId === view.toActSeat}
|
|
||||||
isWinner={winnerSeats.has(player.seatId)}
|
|
||||||
showHoleCards={showHoleCards}
|
|
||||||
handLabel={isViewer ? handLabel : null}
|
|
||||||
cardKeySet={isViewer ? bestCardKeys : undefined}
|
|
||||||
compact={isViewer}
|
|
||||||
/>
|
|
||||||
<div className="pointer-events-none absolute -top-2 right-2 z-20 flex gap-1">
|
|
||||||
{player.seatId === view.buttonSeat && (
|
|
||||||
<span className="flex size-6 items-center justify-center rounded-full border border-amber-200 bg-amber-300 text-xs font-bold text-black">
|
|
||||||
D
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{player.seatId === view.smallBlindSeat && <Badge variant="secondary">SB</Badge>}
|
|
||||||
{player.seatId === view.bigBlindSeat && <Badge variant="secondary">BB</Badge>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
<div className="absolute left-1/2 top-1/2 z-10 flex w-[min(24rem,80%)] -translate-x-1/2 -translate-y-1/2 flex-col items-center gap-3 text-white">
|
|
||||||
{view.phase === "betting" && (
|
|
||||||
<PokerClock deadline={snapshot.turnDeadline} totalMs={view.config.turnTimeoutMs} />
|
|
||||||
)}
|
|
||||||
<div className="flex flex-wrap justify-center gap-1.5" aria-label="Community cards">
|
|
||||||
{view.communityCards.length > 0 ? (
|
|
||||||
view.communityCards.map((card, index) => {
|
|
||||||
const isBestCard = bestCardKeys.has(cardKey(card));
|
|
||||||
return (
|
|
||||||
<CardView
|
|
||||||
key={`${card.rank}-${card.suit}`}
|
|
||||||
card={card}
|
|
||||||
size="md"
|
|
||||||
dealIndex={index}
|
|
||||||
highlighted={hasHint && isBestCard}
|
|
||||||
dimmed={hasHint && !isBestCard}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
) : (
|
|
||||||
<span className="font-mono text-xs uppercase tracking-[0.2em] text-white/55">
|
|
||||||
Community cards
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-center gap-1">
|
|
||||||
<span className="font-mono text-xs uppercase tracking-[0.2em] text-white/60">Pot</span>
|
|
||||||
<span className="font-mono text-2xl font-semibold tabular-nums">
|
|
||||||
{view.potTotal.toLocaleString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{(view.phase === "showdown" || view.phase === "hand-complete") && (
|
|
||||||
<div
|
|
||||||
className="flex w-full flex-col items-center gap-1 border border-white/15 bg-black/35 px-3 py-2 text-center"
|
|
||||||
aria-live="polite"
|
|
||||||
>
|
|
||||||
<p className="font-semibold">
|
|
||||||
{view.phase === "showdown" ? "Showdown" : "Hand complete"}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-white/70">
|
|
||||||
{result
|
|
||||||
? `Your result: ${signedAmount(result.netChange)} chips`
|
|
||||||
: "The hand is settled."}
|
|
||||||
</p>
|
|
||||||
{snapshot.nextHandAt && (
|
|
||||||
<PokerClock deadline={snapshot.nextHandAt} mode="text" label="Next hand in" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{view.phase !== "game-complete" && (
|
|
||||||
<div className="absolute bottom-[13%] left-1/2 z-30 max-h-[45%] w-[min(44rem,calc(100%-2rem))] -translate-x-1/2 overflow-y-auto rounded-none border border-white/15 bg-black/60 p-3 backdrop-blur-sm">
|
|
||||||
<BettingControls
|
|
||||||
tableId={snapshot.tableId}
|
|
||||||
snapshotVersion={snapshot.version}
|
|
||||||
legalActions={view.legalActions}
|
|
||||||
potTotal={view.potTotal}
|
|
||||||
yourTurn={view.yourTurn}
|
|
||||||
onSnapshot={onSnapshot}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{view.phase === "game-complete" && (
|
|
||||||
<div className="absolute left-1/2 top-1/2 z-20 flex w-[min(24rem,calc(100%-2rem))] -translate-x-1/2 -translate-y-1/2 justify-center">
|
|
||||||
<PlacementBoard view={view} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PokerGameTable({ snapshot, onSnapshot, onStart, onLeave }: PokerGameTableProps) {
|
|
||||||
const [starting, setStarting] = useState(false);
|
const [starting, setStarting] = useState(false);
|
||||||
const [leaving, setLeaving] = useState(false);
|
const [leaving, setLeaving] = useState(false);
|
||||||
const [fullscreen, setFullscreen] = 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 tableName = snapshot.name ?? `Table ${snapshot.tableId}`;
|
||||||
const feltCanvas = (
|
const feltCanvas = (
|
||||||
<FeltCanvas
|
<PokerFeltCanvas
|
||||||
snapshot={snapshot}
|
snapshot={snapshot}
|
||||||
onSnapshot={onSnapshot}
|
onSnapshot={onSnapshot}
|
||||||
handLabel={handHint?.label ?? null}
|
handLabel={handHint?.label ?? null}
|
||||||
bestCardKeys={bestCardKeys}
|
bestCardKeys={bestCardKeys}
|
||||||
|
actionProps={{
|
||||||
|
starting,
|
||||||
|
leaving,
|
||||||
|
onStart: () => void start(),
|
||||||
|
onLeave: () => void leave(),
|
||||||
|
}}
|
||||||
|
onActionFeedback={sound.playUiClick}
|
||||||
|
popups={popups}
|
||||||
fullscreen={fullscreen}
|
fullscreen={fullscreen}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
@ -312,11 +111,13 @@ export function PokerGameTable({ snapshot, onSnapshot, onStart, onLeave }: Poker
|
||||||
<Badge variant="outline">{PHASE_LABELS[view.phase]}</Badge>
|
<Badge variant="outline">{PHASE_LABELS[view.phase]}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||||
{view.phase === "game-complete" && view.viewerSeat === snapshot.hostSeatId && (
|
<PokerCardStyleToggle />
|
||||||
<Button type="button" onClick={() => void start()} disabled={starting}>
|
<PokerSoundToggle
|
||||||
{starting ? "Starting..." : "Start new game"}
|
muted={sound.muted}
|
||||||
</Button>
|
volume={sound.volume}
|
||||||
)}
|
onToggleMuted={sound.toggleMuted}
|
||||||
|
onVolumeChange={sound.setVolume}
|
||||||
|
/>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
@ -327,9 +128,16 @@ export function PokerGameTable({ snapshot, onSnapshot, onStart, onLeave }: Poker
|
||||||
>
|
>
|
||||||
<Minimize2 aria-hidden="true" />
|
<Minimize2 aria-hidden="true" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" variant="outline" onClick={() => void leave()} disabled={leaving}>
|
{view.phase !== "game-complete" && (
|
||||||
{leaving ? "Leaving..." : "Leave table"}
|
<Button
|
||||||
</Button>
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => void leave()}
|
||||||
|
disabled={leaving}
|
||||||
|
>
|
||||||
|
{leaving ? "Leaving..." : "Leave table"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{feltCanvas}
|
{feltCanvas}
|
||||||
|
|
@ -360,6 +168,13 @@ export function PokerGameTable({ snapshot, onSnapshot, onStart, onLeave }: Poker
|
||||||
<span className="font-mono text-xs tabular-nums text-muted-foreground">
|
<span className="font-mono text-xs tabular-nums text-muted-foreground">
|
||||||
v{snapshot.version}
|
v{snapshot.version}
|
||||||
</span>
|
</span>
|
||||||
|
<PokerCardStyleToggle />
|
||||||
|
<PokerSoundToggle
|
||||||
|
muted={sound.muted}
|
||||||
|
volume={sound.volume}
|
||||||
|
onToggleMuted={sound.toggleMuted}
|
||||||
|
onVolumeChange={sound.setVolume}
|
||||||
|
/>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
@ -370,19 +185,14 @@ export function PokerGameTable({ snapshot, onSnapshot, onStart, onLeave }: Poker
|
||||||
>
|
>
|
||||||
<Maximize2 aria-hidden="true" />
|
<Maximize2 aria-hidden="true" />
|
||||||
</Button>
|
</Button>
|
||||||
|
{view.phase !== "game-complete" && (
|
||||||
|
<Button type="button" variant="outline" onClick={() => void leave()} disabled={leaving}>
|
||||||
|
{leaving ? "Leaving..." : "Leave table"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-4">{feltCanvas}</CardContent>
|
<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>
|
</UiCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ interface PokerRanking {
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly example: readonly Card[];
|
readonly example: readonly Card[];
|
||||||
readonly description: string;
|
readonly description: string;
|
||||||
|
readonly color?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function card(rank: Rank, suit: Suit): Card {
|
function card(rank: Rank, suit: Suit): Card {
|
||||||
|
|
@ -48,31 +49,37 @@ const RANKINGS: readonly PokerRanking[] = [
|
||||||
name: "Straight",
|
name: "Straight",
|
||||||
example: [card(5, "s"), card(6, "h"), card(7, "d"), card(8, "c"), card(9, "s")],
|
example: [card(5, "s"), card(6, "h"), card(7, "d"), card(8, "c"), card(9, "s")],
|
||||||
description: "Five cards run in rank order.",
|
description: "Five cards run in rank order.",
|
||||||
|
color: "#ffff00",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Flush",
|
name: "Flush",
|
||||||
example: [card(14, "h"), card(10, "h"), card(7, "h"), card(4, "h"), card(2, "h")],
|
example: [card(14, "h"), card(10, "h"), card(7, "h"), card(4, "h"), card(2, "h")],
|
||||||
description: "Five cards share the same suit.",
|
description: "Five cards share the same suit.",
|
||||||
|
color: "#ffff00",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Full house",
|
name: "Full house",
|
||||||
example: [card(13, "s"), card(13, "h"), card(13, "d"), card(8, "c"), card(8, "s")],
|
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.",
|
description: "Three of one rank and two of another.",
|
||||||
|
color: "#ffff00",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Four of a kind",
|
name: "Four of a kind",
|
||||||
example: [card(3, "s"), card(3, "h"), card(3, "d"), card(3, "c"), card(14, "s")],
|
example: [card(3, "s"), card(3, "h"), card(3, "d"), card(3, "c"), card(14, "s")],
|
||||||
description: "Four cards share the same rank.",
|
description: "Four cards share the same rank.",
|
||||||
|
color: "#ffff00",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Straight flush",
|
name: "Straight flush",
|
||||||
example: [card(5, "s"), card(6, "s"), card(7, "s"), card(8, "s"), card(9, "s")],
|
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.",
|
description: "Five cards run in order and share a suit.",
|
||||||
|
color: "#ffff00",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Royal flush",
|
name: "Royal flush",
|
||||||
example: [card(10, "h"), card(11, "h"), card(12, "h"), card(13, "h"), card(14, "h")],
|
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.",
|
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>
|
<h2 className="text-sm font-semibold uppercase tracking-wider">Hand rankings</h2>
|
||||||
<ol className="grid gap-3 sm:grid-cols-2">
|
<ol className="grid gap-3 sm:grid-cols-2">
|
||||||
{RANKINGS.map((ranking, index) => (
|
{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">
|
<span className="w-5 shrink-0 font-mono text-xs text-muted-foreground">
|
||||||
{index + 1}
|
{index + 1}
|
||||||
</span>
|
</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">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<span className="font-medium">{ranking.name}</span>
|
<span className="font-medium">{ranking.name}</span>
|
||||||
<div className="flex gap-0.5" aria-label={`${ranking.name} example`}>
|
<div className="flex gap-0.5" aria-label={`${ranking.name} example`}>
|
||||||
|
|
@ -115,7 +124,7 @@ export function PokerGuide() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -49,8 +49,8 @@ function createdLabel(createdAt: string): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PokerLobby({ refreshKey, onOpenTable, isSuperuser = false }: PokerLobbyProps) {
|
export function PokerLobby({ refreshKey, onOpenTable, isSuperuser = false }: PokerLobbyProps) {
|
||||||
const [tables, setTables] = useState<readonly PokerTableSummary[]>([]);
|
const [tables, setTables] = useState<readonly PokerTableSummary[] | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const [pendingTable, setPendingTable] = useState<string | null>(null);
|
const [pendingTable, setPendingTable] = useState<string | null>(null);
|
||||||
const [forceEndingTable, setForceEndingTable] = useState<string | null>(null);
|
const [forceEndingTable, setForceEndingTable] = useState<string | null>(null);
|
||||||
const [creating, setCreating] = useState(false);
|
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 [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const loadTables = useCallback(async () => {
|
const loadTables = useCallback(async () => {
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
try {
|
||||||
const result = await listPokerTables();
|
const result = await listPokerTables();
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
setError(result.error ?? "Could not load poker tables.");
|
setError(result.error ?? "Could not load poker tables.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setError(null);
|
||||||
setTables(result.data ?? []);
|
setTables(result.data ?? []);
|
||||||
} catch (caught) {
|
} catch (caught) {
|
||||||
setError(caught instanceof Error ? caught.message : "Could not load poker tables.");
|
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(() => {
|
useEffect(() => {
|
||||||
void loadTables();
|
void loadTables();
|
||||||
}, [loadTables, refreshKey]);
|
}, [loadTables, refreshKey]);
|
||||||
|
|
@ -172,12 +178,12 @@ export function PokerLobby({ refreshKey, onOpenTable, isSuperuser = false }: Pok
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => void loadTables()}
|
onClick={() => void manualRefresh()}
|
||||||
disabled={loading}
|
disabled={refreshing}
|
||||||
aria-label="Refresh poker tables"
|
aria-label="Refresh poker tables"
|
||||||
title="Refresh 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>
|
||||||
<Button type="button" onClick={() => void create()} disabled={creating}>
|
<Button type="button" onClick={() => void create()} disabled={creating}>
|
||||||
{creating ? (
|
{creating ? (
|
||||||
|
|
@ -193,7 +199,7 @@ export function PokerLobby({ refreshKey, onOpenTable, isSuperuser = false }: Pok
|
||||||
{error && (
|
{error && (
|
||||||
<p className="border-b border-border px-4 py-3 text-sm text-destructive">{error}</p>
|
<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">
|
<p className="px-4 py-8 text-center text-sm text-muted-foreground">
|
||||||
Scanning for open tables...
|
Scanning for open tables...
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
12
src/components/frontend/minigames/poker/PokerPopups.tsx
Normal file
12
src/components/frontend/minigames/poker/PokerPopups.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { Card, PublicPlayerView } from "@/lib/minigames/poker";
|
import type { PublicPlayerView } from "@/lib/minigames/poker";
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { CardView } from "./CardView";
|
import { CardView } from "./CardView";
|
||||||
|
|
||||||
interface PokerSeatProps {
|
interface PokerSeatProps {
|
||||||
|
|
@ -11,14 +11,11 @@ interface PokerSeatProps {
|
||||||
readonly isActing: boolean;
|
readonly isActing: boolean;
|
||||||
readonly isWinner: boolean;
|
readonly isWinner: boolean;
|
||||||
readonly showHoleCards: boolean;
|
readonly showHoleCards: boolean;
|
||||||
readonly handLabel?: string | null;
|
|
||||||
readonly cardKeySet?: ReadonlySet<string>;
|
|
||||||
readonly compact?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function cardKey(card: Card): string {
|
type StackFlash = "up" | "down" | null;
|
||||||
return `${card.rank}-${card.suit}`;
|
|
||||||
}
|
const STACK_FLASH_MS = 900;
|
||||||
|
|
||||||
export function PokerSeat({
|
export function PokerSeat({
|
||||||
player,
|
player,
|
||||||
|
|
@ -26,77 +23,104 @@ export function PokerSeat({
|
||||||
isActing,
|
isActing,
|
||||||
isWinner,
|
isWinner,
|
||||||
showHoleCards,
|
showHoleCards,
|
||||||
handLabel = null,
|
|
||||||
cardKeySet,
|
|
||||||
compact = false,
|
|
||||||
}: PokerSeatProps) {
|
}: PokerSeatProps) {
|
||||||
const cards = showHoleCards ? player.holeCards : null;
|
const revealed = showHoleCards ? player.holeCards : null;
|
||||||
const cardCount = player.cardCount > 0 ? player.cardCount : 2;
|
const inHand = showHoleCards && !player.folded && !player.eliminated && !player.left;
|
||||||
const faceCards = cards ?? Array.from({ length: cardCount }, () => null);
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"poker-seat-panel flex flex-col gap-2 border p-3 text-left backdrop-blur-sm",
|
"poker-seat-panel mx-auto flex w-max items-center gap-2 px-2 py-1.5 backdrop-blur-sm",
|
||||||
compact && "flex-row flex-wrap items-center gap-2 p-2 sm:flex-nowrap sm:gap-3",
|
(player.folded || player.left) && "opacity-55",
|
||||||
player.folded && "opacity-50",
|
|
||||||
player.eliminated && "opacity-40",
|
player.eliminated && "opacity-40",
|
||||||
isActing && "ring-2 ring-emerald-400/90",
|
isActing && "poker-seat-active ring-2 ring-emerald-400/90",
|
||||||
isCurrentUser && "border-ring",
|
isCurrentUser && "border-ring",
|
||||||
isWinner && "ring-2 ring-amber-300",
|
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")}>
|
<span
|
||||||
<div className="min-w-0">
|
className={cn("poker-seat-avatar", `poker-seat-avatar-tone-${player.seatId % 4}`)}
|
||||||
<p className="truncate text-sm font-semibold">
|
aria-hidden="true"
|
||||||
{player.name}
|
>
|
||||||
{isCurrentUser && <span className="ml-1 text-xs text-muted-foreground">(you)</span>}
|
{initial}
|
||||||
</p>
|
</span>
|
||||||
<p className="font-mono text-xs tabular-nums text-muted-foreground">
|
<div className="poker-seat-nameplate min-w-0">
|
||||||
Stack {player.stack.toLocaleString()}
|
<p className="truncate text-[0.65rem] font-medium leading-tight text-muted-foreground">
|
||||||
</p>
|
{player.name}
|
||||||
</div>
|
{isCurrentUser && <span className="ml-1">(you)</span>}
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
</p>
|
||||||
{player.allIn && <Badge variant="destructive">All-in</Badge>}
|
<p
|
||||||
{player.folded && <Badge variant="outline">Folded</Badge>}
|
className={cn(
|
||||||
{isWinner && <Badge variant="secondary">Winner</Badge>}
|
"poker-seat-stack",
|
||||||
</div>
|
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>
|
</div>
|
||||||
|
|
||||||
{handLabel && (
|
{revealed ? (
|
||||||
<span
|
<div className="poker-seat-reveal" aria-label="Revealed hole cards">
|
||||||
className={cn(
|
{revealed.map((card, index) => (
|
||||||
"w-fit max-w-full border border-amber-300/40 bg-amber-300/10 px-2 py-1 text-xs text-amber-200",
|
<CardView key={`${player.seatId}-hole-${index}`} card={card} faceUp size="mini" />
|
||||||
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))}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
) : (
|
||||||
<p className="font-mono text-xs tabular-nums text-muted-foreground">
|
inHand && (
|
||||||
{player.committed > 0 ? `Bet ${player.committed.toLocaleString()}` : "No bet"}
|
<div className="poker-seat-cardbacks" aria-label="Hole cards, face down">
|
||||||
</p>
|
<span aria-hidden="true" />
|
||||||
{isActing && <p className="text-xs font-semibold text-emerald-300">Acting</p>}
|
<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>
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
43
src/components/frontend/minigames/poker/PokerSoundToggle.tsx
Normal file
43
src/components/frontend/minigames/poker/PokerSoundToggle.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -3,12 +3,16 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { PokerSnapshot } from "@/app/(frontend)/minigames/poker/actions";
|
import type { PokerSnapshot } from "@/app/(frontend)/minigames/poker/actions";
|
||||||
|
import { MIN_PLAYERS } from "@/lib/minigames/poker";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { PokerCardStyleProvider } from "./cardStyle";
|
||||||
import { PokerGameTable } from "./PokerGameTable";
|
import { PokerGameTable } from "./PokerGameTable";
|
||||||
import { RenameTableDialog } from "./RenameTableDialog";
|
import { RenameTableDialog } from "./RenameTableDialog";
|
||||||
|
import { usePokerPopups } from "./usePokerPopups";
|
||||||
|
import { usePokerSounds } from "./usePokerSounds";
|
||||||
import { seatPosition } from "./seatPosition";
|
import { seatPosition } from "./seatPosition";
|
||||||
|
|
||||||
interface PokerTableProps {
|
interface PokerTableProps {
|
||||||
|
|
@ -53,7 +57,10 @@ function WaitingTable({
|
||||||
const viewerSeat = snapshot.seats.findIndex((id) => String(id) === String(currentUserId));
|
const viewerSeat = snapshot.seats.findIndex((id) => String(id) === String(currentUserId));
|
||||||
const seated = viewerSeat >= 0;
|
const seated = viewerSeat >= 0;
|
||||||
const full = snapshot.seats.length > 0 && snapshot.seats.every((id) => id !== null);
|
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 isHost = seated && snapshot.hostSeatId === viewerSeat;
|
||||||
|
const opponentCount = snapshot.seats.length - (seated ? 1 : 0);
|
||||||
|
|
||||||
const start = async () => {
|
const start = async () => {
|
||||||
setStarting(true);
|
setStarting(true);
|
||||||
|
|
@ -92,18 +99,34 @@ function WaitingTable({
|
||||||
<Badge variant="outline">Waiting room</Badge>
|
<Badge variant="outline">Waiting room</Badge>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-4">
|
<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) => (
|
{snapshot.seats.map((userId, seatId) => (
|
||||||
<div
|
<div
|
||||||
key={seatId}
|
key={seatId}
|
||||||
className={cn(
|
className={cn(
|
||||||
"poker-waiting-seat absolute flex flex-col gap-1 border px-3 py-2 text-xs",
|
"poker-waiting-seat absolute flex flex-col items-center gap-1 border px-3 py-2 text-xs",
|
||||||
seatPosition(seatId),
|
seatId === viewerSeat
|
||||||
|
? "poker-waiting-seat-south"
|
||||||
|
: seatPosition(
|
||||||
|
snapshot.seats
|
||||||
|
.slice(0, seatId)
|
||||||
|
.filter((_, previousSeatId) => previousSeatId !== viewerSeat).length,
|
||||||
|
opponentCount,
|
||||||
|
),
|
||||||
userId !== null && "poker-waiting-seat-occupied",
|
userId !== null && "poker-waiting-seat-occupied",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="font-semibold">Seat {seatId + 1}</span>
|
<span
|
||||||
<span className="text-white/70">
|
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
|
{userId === null
|
||||||
? "Open seat"
|
? "Open seat"
|
||||||
: String(userId) === String(currentUserId)
|
: String(userId) === String(currentUserId)
|
||||||
|
|
@ -126,8 +149,16 @@ function WaitingTable({
|
||||||
{seated ? (
|
{seated ? (
|
||||||
<>
|
<>
|
||||||
{isHost && (
|
{isHost && (
|
||||||
<Button type="button" onClick={() => void start()} disabled={starting}>
|
<Button
|
||||||
{starting ? "Starting..." : "Start game"}
|
type="button"
|
||||||
|
onClick={() => void start()}
|
||||||
|
disabled={starting || !enoughPlayers}
|
||||||
|
>
|
||||||
|
{starting
|
||||||
|
? "Starting..."
|
||||||
|
: enoughPlayers
|
||||||
|
? "Start game"
|
||||||
|
: "You need at least two players to start"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button type="button" variant="outline" onClick={() => void leave()} disabled={leaving}>
|
<Button type="button" variant="outline" onClick={() => void leave()} disabled={leaving}>
|
||||||
|
|
@ -149,7 +180,7 @@ function WaitingTable({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PokerTable({
|
function PokerTableInner({
|
||||||
snapshot,
|
snapshot,
|
||||||
currentUserId,
|
currentUserId,
|
||||||
loading,
|
loading,
|
||||||
|
|
@ -160,6 +191,12 @@ export function PokerTable({
|
||||||
onLeave,
|
onLeave,
|
||||||
onBack,
|
onBack,
|
||||||
}: PokerTableProps) {
|
}: 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) {
|
if (loading && !snapshot) {
|
||||||
return (
|
return (
|
||||||
<Card className="rounded-none">
|
<Card className="rounded-none">
|
||||||
|
|
@ -214,6 +251,16 @@ export function PokerTable({
|
||||||
onSnapshot={onSnapshot}
|
onSnapshot={onSnapshot}
|
||||||
onStart={onStart}
|
onStart={onStart}
|
||||||
onLeave={onLeave}
|
onLeave={onLeave}
|
||||||
|
sound={sound}
|
||||||
|
popups={popups}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function PokerTable(props: PokerTableProps) {
|
||||||
|
return (
|
||||||
|
<PokerCardStyleProvider>
|
||||||
|
<PokerTableInner {...props} />
|
||||||
|
</PokerCardStyleProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
89
src/components/frontend/minigames/poker/cardStyle.tsx
Normal file
89
src/components/frontend/minigames/poker/cardStyle.tsx
Normal 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);
|
||||||
|
}
|
||||||
175
src/components/frontend/minigames/poker/pokerPopups.ts
Normal file
175
src/components/frontend/minigames/poker/pokerPopups.ts
Normal 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;
|
||||||
|
}
|
||||||
264
src/components/frontend/minigames/poker/pokerSound.ts
Normal file
264
src/components/frontend/minigames/poker/pokerSound.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
234
src/components/frontend/minigames/poker/pokerSoundEvents.ts
Normal file
234
src/components/frontend/minigames/poker/pokerSoundEvents.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,28 @@
|
||||||
const SEAT_POSITION_CLASSES = [
|
const ARC_POSITION_CLASSES = [
|
||||||
"poker-seat-south",
|
["poker-seat-arc-center"],
|
||||||
"poker-seat-west",
|
["poker-seat-arc-left", "poker-seat-arc-right"],
|
||||||
"poker-seat-north",
|
["poker-seat-arc-left", "poker-seat-arc-center", "poker-seat-arc-right"],
|
||||||
"poker-seat-east",
|
[
|
||||||
|
"poker-seat-arc-far-left",
|
||||||
|
"poker-seat-arc-center-left",
|
||||||
|
"poker-seat-arc-center-right",
|
||||||
|
"poker-seat-arc-far-right",
|
||||||
|
],
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export function seatPosition(seatId: number): string {
|
export function seatPosition(opponentIndex: number, opponentCount: number): string {
|
||||||
return SEAT_POSITION_CLASSES[seatId % SEAT_POSITION_CLASSES.length] ?? SEAT_POSITION_CLASSES[0];
|
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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
86
src/components/frontend/minigames/poker/usePokerPopups.ts
Normal file
86
src/components/frontend/minigames/poker/usePokerPopups.ts
Normal 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;
|
||||||
|
}
|
||||||
162
src/components/frontend/minigames/poker/usePokerSounds.ts
Normal file
162
src/components/frontend/minigames/poker/usePokerSounds.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { Payload } from "payload";
|
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 AssignmentType = Assignment["type"];
|
||||||
type RecipientScope = "same_command" | "whitelist" | "anyone";
|
type RecipientScope = "same_command" | "whitelist" | "anyone";
|
||||||
|
|
@ -26,10 +26,7 @@ export async function getGrantorAssignments(
|
||||||
const res = await payload.find({
|
const res = await payload.find({
|
||||||
collection: "assignments",
|
collection: "assignments",
|
||||||
where: {
|
where: {
|
||||||
and: [
|
and: [{ leader: { equals: userId } }, { type: { in: [...allowedTypes] } }],
|
||||||
{ leader: { equals: userId } },
|
|
||||||
{ type: { in: [...allowedTypes] } },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
limit: 100,
|
limit: 100,
|
||||||
depth: 0,
|
depth: 0,
|
||||||
|
|
@ -85,9 +82,42 @@ export async function getAssignmentTreeMembers(
|
||||||
return [...collected];
|
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
|
* How many times `grantorProfileId` has already issued `awardId` across all
|
||||||
* profiles. Scans every profile (depth 0) and counts matching grant rows.
|
* 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(
|
export async function countGrantsByGrantor(
|
||||||
payload: Payload,
|
payload: Payload,
|
||||||
|
|
@ -103,6 +133,9 @@ export async function countGrantsByGrantor(
|
||||||
let count = 0;
|
let count = 0;
|
||||||
for (const profile of res.docs) {
|
for (const profile of res.docs) {
|
||||||
for (const entry of profile.progression?.awards ?? []) {
|
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) {
|
if (refId(entry.award) === awardId && refId(entry.awardedBy) === grantorProfileId) {
|
||||||
count += 1;
|
count += 1;
|
||||||
}
|
}
|
||||||
|
|
@ -163,10 +196,7 @@ export async function isRecipientEligible(
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The profile belonging to `userId`, or null if none exists. */
|
/** The profile belonging to `userId`, or null if none exists. */
|
||||||
export async function findUserProfile(
|
export async function findUserProfile(payload: Payload, userId: number): Promise<Profile | null> {
|
||||||
payload: Payload,
|
|
||||||
userId: number,
|
|
||||||
): Promise<Profile | null> {
|
|
||||||
const res = await payload.find({
|
const res = await payload.find({
|
||||||
collection: "profiles",
|
collection: "profiles",
|
||||||
where: { user: { equals: userId } },
|
where: { user: { equals: userId } },
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ import {
|
||||||
STARTING_STACK,
|
STARTING_STACK,
|
||||||
TURN_TIMEOUT_MS,
|
TURN_TIMEOUT_MS,
|
||||||
} from "./constants";
|
} from "./constants";
|
||||||
import { advanceState, createTableState } from "./engine";
|
import { advanceState, awardFoldOut, createTableState } from "./engine";
|
||||||
import { awardPokerGameResult, type PokerPlacementResult } from "./stats";
|
import { awardPokerGameResult, type PokerPlacementResult } from "./stats";
|
||||||
import type {
|
import type {
|
||||||
PokerActionKind,
|
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. */
|
/** Fire-and-forget XP/stats award on game completion; never blocks the table. */
|
||||||
async function awardGameResults(record: PokerTableRecord): Promise<void> {
|
async function awardGameResults(record: PokerTableRecord): Promise<void> {
|
||||||
const state = record.state;
|
const state = record.state;
|
||||||
|
|
@ -308,7 +348,18 @@ function buildSnapshot(record: PokerTableRecord, userId: number): PokerSnapshot
|
||||||
const seated = seatIndex >= 0 && !record.leftUsers.has(userId);
|
const seated = seatIndex >= 0 && !record.leftUsers.has(userId);
|
||||||
const state = record.state;
|
const state = record.state;
|
||||||
const inEngine = seated && state !== null && seatIndex < state.players.length;
|
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;
|
const hostSeat = record.hostUserId !== null ? record.seatMap.indexOf(record.hostUserId) : -1;
|
||||||
return {
|
return {
|
||||||
tableId: record.id,
|
tableId: record.id,
|
||||||
|
|
@ -481,6 +532,9 @@ export function leaveTable(tableId: string, userId: number): void {
|
||||||
record.version += 1;
|
record.version += 1;
|
||||||
rekeyTurnTimer(record);
|
rekeyTurnTimer(record);
|
||||||
scheduleDisposalIfEmpty(record);
|
scheduleDisposalIfEmpty(record);
|
||||||
|
if (record.inGame && seatedCountOf(record) === 1 && record.pendingJoins.length === 0) {
|
||||||
|
finalizeForLastPlayer(record);
|
||||||
|
}
|
||||||
pushTable(record);
|
pushTable(record);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -163,6 +163,8 @@ export interface PublicPlayerView {
|
||||||
readonly allIn: boolean;
|
readonly allIn: boolean;
|
||||||
readonly hasActed: boolean;
|
readonly hasActed: boolean;
|
||||||
readonly eliminated: 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. */
|
/** Populated only for the viewer or for live hands at showdown. */
|
||||||
readonly holeCards: readonly Card[] | null;
|
readonly holeCards: readonly Card[] | null;
|
||||||
readonly cardCount: number;
|
readonly cardCount: number;
|
||||||
|
|
|
||||||
|
|
@ -27,9 +27,8 @@ function projectPlayer(
|
||||||
): PublicPlayerView {
|
): PublicPlayerView {
|
||||||
const isViewer = viewerSeat !== null && player.seatId === viewerSeat;
|
const isViewer = viewerSeat !== null && player.seatId === viewerSeat;
|
||||||
const reveal = (state.phase === "showdown" && !player.folded) || isViewer;
|
const reveal = (state.phase === "showdown" && !player.folded) || isViewer;
|
||||||
const holeCards: readonly Card[] | null = reveal && player.holeCards.length > 0
|
const holeCards: readonly Card[] | null =
|
||||||
? player.holeCards
|
reveal && player.holeCards.length > 0 ? player.holeCards : null;
|
||||||
: null;
|
|
||||||
return {
|
return {
|
||||||
seatId: player.seatId,
|
seatId: player.seatId,
|
||||||
name: player.name,
|
name: player.name,
|
||||||
|
|
@ -40,6 +39,9 @@ function projectPlayer(
|
||||||
allIn: player.allIn,
|
allIn: player.allIn,
|
||||||
hasActed: player.hasActed,
|
hasActed: player.hasActed,
|
||||||
eliminated: player.eliminated,
|
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,
|
holeCards,
|
||||||
cardCount: player.holeCards.length,
|
cardCount: player.holeCards.length,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,8 @@ export function notificationLabel(type?: string | null): string {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "poker:force-ended":
|
case "poker:force-ended":
|
||||||
return "Poker game ended";
|
return "Poker game ended";
|
||||||
|
case "poker:everyone-left":
|
||||||
|
return "Everyone left the table";
|
||||||
case "market:offer":
|
case "market:offer":
|
||||||
return "New offer";
|
return "New offer";
|
||||||
case "market:counter":
|
case "market:counter":
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ export const MUTEABLE_NOTIFICATION_TYPES = [
|
||||||
{ label: "Ticket replies", value: "ticket:reply" },
|
{ label: "Ticket replies", value: "ticket:reply" },
|
||||||
{ label: "Ticket status changes", value: "ticket:status" },
|
{ label: "Ticket status changes", value: "ticket:status" },
|
||||||
{ label: "Poker", value: "poker:force-ended" },
|
{ label: "Poker", value: "poker:force-ended" },
|
||||||
|
{ label: "Poker game auto-ended", value: "poker:everyone-left" },
|
||||||
] as const;
|
] as const;
|
||||||
export type MuteableNotificationType = (typeof MUTEABLE_NOTIFICATION_TYPES)[number]["value"];
|
export type MuteableNotificationType = (typeof MUTEABLE_NOTIFICATION_TYPES)[number]["value"];
|
||||||
export const MUTEABLE_NOTIFICATION_VALUES = MUTEABLE_NOTIFICATION_TYPES.map((t) => t.value);
|
export const MUTEABLE_NOTIFICATION_VALUES = MUTEABLE_NOTIFICATION_TYPES.map((t) => t.value);
|
||||||
|
|
|
||||||
26713
src/migrations/20260911_201559_add_award_advancement_series.json
Normal file
26713
src/migrations/20260911_201559_add_award_advancement_series.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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";`)
|
||||||
|
}
|
||||||
|
|
@ -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_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_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_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 = [
|
export const migrations = [
|
||||||
{
|
{
|
||||||
|
|
@ -334,6 +335,11 @@ export const migrations = [
|
||||||
{
|
{
|
||||||
up: migration_20260911_052147_add_ribbon_submissions.up,
|
up: migration_20260911_052147_add_ribbon_submissions.up,
|
||||||
down: migration_20260911_052147_add_ribbon_submissions.down,
|
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'
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -1360,6 +1360,7 @@ export interface User {
|
||||||
| 'ticket:reply'
|
| 'ticket:reply'
|
||||||
| 'ticket:status'
|
| 'ticket:status'
|
||||||
| 'poker:force-ended'
|
| 'poker:force-ended'
|
||||||
|
| 'poker:everyone-left'
|
||||||
)[]
|
)[]
|
||||||
| null;
|
| null;
|
||||||
};
|
};
|
||||||
|
|
@ -1393,6 +1394,7 @@ export interface User {
|
||||||
| 'ticket:reply'
|
| 'ticket:reply'
|
||||||
| 'ticket:status'
|
| 'ticket:status'
|
||||||
| 'poker:force-ended'
|
| 'poker:force-ended'
|
||||||
|
| 'poker:everyone-left'
|
||||||
)[]
|
)[]
|
||||||
| null;
|
| null;
|
||||||
};
|
};
|
||||||
|
|
@ -2010,6 +2012,14 @@ export interface Profile {
|
||||||
* An optional reason for the award.
|
* An optional reason for the award.
|
||||||
*/
|
*/
|
||||||
awardReason?: string | null;
|
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;
|
id?: string | null;
|
||||||
}[]
|
}[]
|
||||||
| null;
|
| null;
|
||||||
|
|
@ -2149,6 +2159,19 @@ export interface Award {
|
||||||
| number
|
| number
|
||||||
| boolean
|
| boolean
|
||||||
| null;
|
| 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.
|
* 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;
|
awardedBy?: T;
|
||||||
awardedAt?: T;
|
awardedAt?: T;
|
||||||
awardReason?: T;
|
awardReason?: T;
|
||||||
|
upgradedFrom?: T;
|
||||||
|
upgradedAt?: T;
|
||||||
id?: T;
|
id?: T;
|
||||||
};
|
};
|
||||||
qualifications?: T;
|
qualifications?: T;
|
||||||
|
|
@ -5833,6 +5858,12 @@ export interface AwardsSelect<T extends boolean = true> {
|
||||||
ribbonDesign?: T;
|
ribbonDesign?: T;
|
||||||
backgroundImage?: T;
|
backgroundImage?: T;
|
||||||
medalDesign?: T;
|
medalDesign?: T;
|
||||||
|
advancement?:
|
||||||
|
| T
|
||||||
|
| {
|
||||||
|
seriesKey?: T;
|
||||||
|
tier?: T;
|
||||||
|
};
|
||||||
grantConfig?:
|
grantConfig?:
|
||||||
| T
|
| T
|
||||||
| {
|
| {
|
||||||
|
|
|
||||||
|
|
@ -131,6 +131,7 @@ export const EventTypes = {
|
||||||
|
|
||||||
// Awards
|
// Awards
|
||||||
AwardGrant: "award:grant",
|
AwardGrant: "award:grant",
|
||||||
|
AwardUpgrade: "award:upgrade",
|
||||||
AwardRibbonSubmitted: "award:ribbon-submitted",
|
AwardRibbonSubmitted: "award:ribbon-submitted",
|
||||||
AwardRibbonApproved: "award:ribbon-approved",
|
AwardRibbonApproved: "award:ribbon-approved",
|
||||||
AwardRibbonRejected: "award:ribbon-rejected",
|
AwardRibbonRejected: "award:ribbon-rejected",
|
||||||
|
|
|
||||||
737
tests/int/award-advancement.int.spec.ts
Normal file
737
tests/int/award-advancement.int.spec.ts
Normal 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.");
|
||||||
|
});
|
||||||
|
});
|
||||||
45
tests/int/poker-card-skins.int.spec.ts
Normal file
45
tests/int/poker-card-skins.int.spec.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
330
tests/int/poker-popups.int.spec.ts
Normal file
330
tests/int/poker-popups.int.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
242
tests/int/poker-sound.int.spec.ts
Normal file
242
tests/int/poker-sound.int.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,6 +1,12 @@
|
||||||
import { describe, expect, it } from "vitest";
|
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
|
* 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -158,8 +158,8 @@ describe("Ribbon Design Submissions", () => {
|
||||||
// Mock authentication for regular user
|
// Mock authentication for regular user
|
||||||
authSpy.mockResolvedValue({ user });
|
authSpy.mockResolvedValue({ user });
|
||||||
|
|
||||||
// Create 3 pending submissions
|
// Fill the pending cap
|
||||||
for (let i = 0; i < 3; i++) {
|
for (let i = 0; i < 15; i++) {
|
||||||
await payload.create({
|
await payload.create({
|
||||||
collection: "ribbon-submissions",
|
collection: "ribbon-submissions",
|
||||||
data: {
|
data: {
|
||||||
|
|
@ -189,7 +189,7 @@ describe("Ribbon Design Submissions", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
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.");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue