diff --git a/src/app/(frontend)/styles.css b/src/app/(frontend)/styles.css index d6e4c2f..303943e 100644 --- a/src/app/(frontend)/styles.css +++ b/src/app/(frontend)/styles.css @@ -1819,6 +1819,133 @@ overflow-wrap: anywhere; } +/* Progressive hand-rank ladder: each tier a step brighter and glowier, from + brass hints through silver (straight, flush) and gold (full house, quads) + up to the royal flush prismatic treatment. */ +.poker-rank-tier-1 { + border-color: rgb(138 109 59 / 80%); +} + +.poker-rank-tier-2 { + border-color: rgb(163 130 63 / 85%); + box-shadow: inset 0 0 0.4rem rgb(213 179 106 / 16%); +} + +.poker-rank-tier-3 { + border-color: rgb(185 138 62 / 90%); + box-shadow: + inset 0 0 0.5rem rgb(213 179 106 / 24%), + 0 0 0.35rem rgb(213 179 106 / 18%); +} + +.poker-rank-tier-4 { + border-color: rgb(201 201 212 / 85%); + box-shadow: + inset 0 0 0.55rem rgb(226 232 240 / 22%), + 0 0 0.45rem rgb(226 232 240 / 20%); +} + +.poker-rank-tier-5 { + border-color: rgb(214 214 228 / 95%); + box-shadow: + inset 0 0 0.7rem rgb(226 232 240 / 32%), + 0 0 0.7rem rgb(226 232 240 / 30%); +} + +.poker-rank-tier-6 { + border-color: #cfa95c; + box-shadow: + inset 0 0 0.7rem rgb(214 179 106 / 30%), + 0 0 0.6rem rgb(214 179 106 / 28%); +} + +.poker-rank-tier-7 { + border-color: #e0bd6f; + box-shadow: + inset 0 0 0.8rem rgb(224 189 111 / 44%), + 0 0 0.85rem rgb(224 189 111 / 42%); +} + +.poker-rank-tier-8 { + border-color: #79a7f5; + box-shadow: + inset 0 0 0.9rem rgb(121 167 245 / 46%), + 0 0 0.95rem rgb(121 167 245 / 48%); +} + +.poker-rank-tier-9 { + position: relative; + border-color: #ffd700; + background-image: linear-gradient( + 115deg, + rgb(255 77 77 / 7%), + rgb(255 184 77 / 7%) 18%, + rgb(255 249 77 / 7%) 36%, + rgb(77 255 136 / 7%) 54%, + rgb(77 195 255 / 7%) 72%, + rgb(184 77 255 / 7%) 88%, + rgb(255 77 77 / 7%) + ); + background-size: 300% 100%; + box-shadow: + inset 0 0 1rem rgb(255 215 0 / 46%), + 0 0 1.1rem rgb(255 215 0 / 55%); + animation: poker-royal-wash 6s ease-in-out infinite alternate; +} + +/* The rainbow lives only on the border ring (masked pseudo-element) and the + near-transparent background wash, never on the element's content: the hue + filter animates the pseudo itself, so cards and text keep their true colors. */ +.poker-rank-tier-9::before { + content: ""; + position: absolute; + inset: 0; + z-index: 1; + border-radius: inherit; + padding: 1px; + background: linear-gradient( + 115deg, + #ff4d4d, + #ffb84d, + #fff94d, + #4dff88, + #4dc3ff, + #b84dff, + #ff4d4d + ); + background-size: 300% 100%; + -webkit-mask: + linear-gradient(#fff 0 0) content-box, + linear-gradient(#fff 0 0); + -webkit-mask-composite: xor; + mask: + linear-gradient(#fff 0 0) content-box, + linear-gradient(#fff 0 0); + mask-composite: exclude; + animation: poker-royal-border-hue 3.2s linear infinite; + pointer-events: none; +} + +@keyframes poker-royal-wash { + 0% { + background-position: 0% 0; + } + 100% { + background-position: 100% 0; + } +} + +@keyframes poker-royal-border-hue { + 0% { + filter: hue-rotate(0deg); + background-position: 0% 0; + } + 100% { + filter: hue-rotate(360deg); + background-position: 300% 0; + } +} + .poker-console-economy { display: flex; flex-direction: column; @@ -2415,6 +2542,11 @@ animation: none; } + .poker-rank-tier-9, + .poker-rank-tier-9::before { + animation: none; + } + .poker-card-inner, .poker-card-deal { animation: none; diff --git a/src/components/frontend/minigames/poker/PokerActionConsole.tsx b/src/components/frontend/minigames/poker/PokerActionConsole.tsx index 3208f56..27f7597 100644 --- a/src/components/frontend/minigames/poker/PokerActionConsole.tsx +++ b/src/components/frontend/minigames/poker/PokerActionConsole.tsx @@ -14,6 +14,7 @@ type SeatedSnapshot = PokerSnapshot & { interface PokerActionConsoleProps { readonly snapshot: SeatedSnapshot; readonly handLabel: string | null; + readonly handTier: number | null; readonly hostSeatId: number | null; readonly starting: boolean; readonly leaving: boolean; @@ -80,6 +81,7 @@ function ViewerRoleBadges({ view }: { readonly view: SeatedSnapshot["you"] }) { export function PokerActionConsole({ snapshot, handLabel, + handTier, hostSeatId, starting, leaving, @@ -125,7 +127,15 @@ export function PokerActionConsole({ - {handLabel && You have: {handLabel}} + {handLabel && ( + 0 ? ` poker-rank-tier-${handTier}` : "" + }`} + > + You have: {handLabel} + + )} diff --git a/src/components/frontend/minigames/poker/PokerFeltCanvas.tsx b/src/components/frontend/minigames/poker/PokerFeltCanvas.tsx index 6732e74..4149b02 100644 --- a/src/components/frontend/minigames/poker/PokerFeltCanvas.tsx +++ b/src/components/frontend/minigames/poker/PokerFeltCanvas.tsx @@ -30,6 +30,7 @@ interface PokerFeltCanvasProps { readonly snapshot: SeatedSnapshot; readonly onSnapshot: (snapshot: PokerSnapshot) => void; readonly handLabel: string | null; + readonly handTier: number | null; readonly bestCardKeys: ReadonlySet; readonly actionProps: FeltCanvasActionProps; readonly onActionFeedback: () => void; @@ -82,6 +83,7 @@ export function PokerFeltCanvas({ snapshot, onSnapshot, handLabel, + handTier, bestCardKeys, actionProps, onActionFeedback, @@ -264,6 +266,7 @@ export function PokerFeltCanvas({ new Set(handHint?.bestCardKeys ?? []), [handHint]); + const handTier = handHint?.tier ?? null; useEffect(() => { setMounted(true); @@ -87,6 +88,7 @@ export function PokerGameTable({ snapshot={snapshot} onSnapshot={onSnapshot} handLabel={handHint?.label ?? null} + handTier={handTier} bestCardKeys={bestCardKeys} actionProps={{ starting, diff --git a/src/lib/minigames/poker/handHint.ts b/src/lib/minigames/poker/handHint.ts index cf55198..77d25d0 100644 --- a/src/lib/minigames/poker/handHint.ts +++ b/src/lib/minigames/poker/handHint.ts @@ -1,9 +1,12 @@ import { HAND_CATEGORIES, HAND_CATEGORY_NAMES, evaluateHand } from "./evaluator"; +import { handRankTier } from "./handRankStyle"; import type { Card, Rank } from "./types"; export interface HandStrengthHint { readonly label: string; readonly bestCardKeys: string[]; + /** Presentation tier 0-9 driving the progressive rank styling. */ + readonly tier: number; } const RANK_NAMES: Record = { @@ -63,7 +66,7 @@ function postflopLabel(category: number, tiebreakers: readonly number[]): string case HAND_CATEGORIES.QUADS: return `Four of a kind, ${pluralRankName(first)}`; case HAND_CATEGORIES.STRAIGHT_FLUSH: - return `Straight flush, ${highCardLabel(first)}`; + return first === 14 ? "Royal flush" : `Straight flush, ${highCardLabel(first)}`; default: return capitalize(HAND_CATEGORY_NAMES[category] ?? "unknown hand"); } @@ -83,6 +86,7 @@ export function describeHandStrength( ? `Pair of ${pluralRankName(highCard.rank)}` : `High card ${rankName(highCard.rank)}`, bestCardKeys: pair ? holeCards.map(cardKey) : [cardKey(highCard)], + tier: pair ? HAND_CATEGORIES.PAIR : HAND_CATEGORIES.HIGH_CARD, }; } @@ -93,5 +97,6 @@ export function describeHandStrength( return { label: postflopLabel(evaluation.rank.category, evaluation.rank.tiebreakers), bestCardKeys: evaluation.best.map(cardKey), + tier: handRankTier(evaluation.rank), }; } diff --git a/src/lib/minigames/poker/handRankStyle.ts b/src/lib/minigames/poker/handRankStyle.ts new file mode 100644 index 0000000..603c0f2 --- /dev/null +++ b/src/lib/minigames/poker/handRankStyle.ts @@ -0,0 +1,18 @@ +import { HAND_CATEGORIES } from "./evaluator"; +import type { HandRank } from "./types"; + +/** + * Presentation tier for a made hand: 0 (high card, no styling) through + * 9 (royal flush, full prismatic treatment). Each step up the ladder gets a + * more intense border and glow; see the poker-rank-tier-* rules in styles.css. + */ +export const ROYAL_RANK_TIER = 9; + +export function handRankTier(hand: HandRank): number { + const royal = hand.category === HAND_CATEGORIES.STRAIGHT_FLUSH && hand.tiebreakers[0] === 14; + return royal ? ROYAL_RANK_TIER : hand.category; +} + +export function handRankTierClass(tier: number): string { + return `poker-rank-tier-${tier}`; +} diff --git a/tests/int/poker-hand-hint.int.spec.ts b/tests/int/poker-hand-hint.int.spec.ts index 50eea4d..eddbccc 100644 --- a/tests/int/poker-hand-hint.int.spec.ts +++ b/tests/int/poker-hand-hint.int.spec.ts @@ -9,6 +9,7 @@ describe("poker hand strength hints", () => { expect(describeHandStrength([c(13, "s"), c(13, "h")], [])).toEqual({ label: "Pair of Kings", bestCardKeys: ["13-s", "13-h"], + tier: 1, }); }); @@ -16,6 +17,7 @@ describe("poker hand strength hints", () => { expect(describeHandStrength([c(14, "s"), c(9, "h")], [])).toEqual({ label: "High card Ace", bestCardKeys: ["14-s"], + tier: 0, }); }); diff --git a/tests/int/poker-rank-style.int.spec.ts b/tests/int/poker-rank-style.int.spec.ts new file mode 100644 index 0000000..c716214 --- /dev/null +++ b/tests/int/poker-rank-style.int.spec.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import type { Card, HandRank } from "@/lib/minigames/poker"; +import { handRankTier, ROYAL_RANK_TIER } from "@/lib/minigames/poker/handRankStyle"; +import { describeHandStrength } from "@/lib/minigames/poker/handHint"; + +function rank(category: number, tiebreakers: readonly number[]): HandRank { + return { category, tiebreakers }; +} + +describe("handRankTier", () => { + it("maps every category to its own tier", () => { + expect(handRankTier(rank(0, [14]))).toBe(0); + expect(handRankTier(rank(1, [9]))).toBe(1); + expect(handRankTier(rank(2, [11, 4]))).toBe(2); + expect(handRankTier(rank(3, [7]))).toBe(3); + expect(handRankTier(rank(4, [9]))).toBe(4); + expect(handRankTier(rank(5, [14]))).toBe(5); + expect(handRankTier(rank(6, [13, 8]))).toBe(6); + expect(handRankTier(rank(7, [3]))).toBe(7); + expect(handRankTier(rank(8, [9]))).toBe(8); + }); + + it("promotes only the ace-high straight flush to the royal tier", () => { + expect(handRankTier(rank(8, [14]))).toBe(ROYAL_RANK_TIER); + expect(handRankTier(rank(8, [13]))).toBe(8); + expect(handRankTier(rank(4, [14]))).not.toBe(ROYAL_RANK_TIER); + }); +}); + +describe("describeHandStrength tiers", () => { + it("keeps high card at tier zero and a pocket pair at tier one", () => { + const high = describeHandStrength( + [ + { rank: 14, suit: "s" }, + { rank: 13, suit: "h" }, + ], + [], + ); + const pair = describeHandStrength( + [ + { rank: 9, suit: "s" }, + { rank: 9, suit: "h" }, + ], + [], + ); + expect(high?.tier).toBe(0); + expect(pair?.tier).toBe(1); + }); + + it("grades a royal flush at the top tier with the royal label", () => { + const hole: Card[] = [ + { rank: 10, suit: "h" }, + { rank: 11, suit: "h" }, + ]; + const board: Card[] = [ + { rank: 12, suit: "h" }, + { rank: 13, suit: "h" }, + { rank: 14, suit: "h" }, + { rank: 2, suit: "s" }, + { rank: 3, suit: "d" }, + ]; + const hint = describeHandStrength(hole, board); + expect(hint?.tier).toBe(ROYAL_RANK_TIER); + expect(hint?.label).toBe("Royal flush"); + }); +});