1
0
Fork 0

Compare commits

..

4 commits

Author SHA1 Message Date
24662060df v0.2.36 - progressive hand rank styling, guide ranking rework 2026-09-11 17:30:17 -04:00
076a631fe8 feat(poker): drop the center suit glyph from mini cards
Mini-sized cards show the rank and corner suit only, keeping the guide's example rows quiet at small sizes.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-11 17:30:11 -04:00
8634c17ff2 feat(poker): rework the guide ranking table into tier columns
Rankings flow down the left column then the right with muted chevrons in the gaps marking the order, the text and example cards sit in their own centered columns, and each row wears its ladder tier.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-11 17:30:11 -04:00
1b0e4ed074 feat(poker): grade hand ranks on a progressive style ladder
Every made hand maps to a presentation tier from high card (no styling) through brass, silver, and gold pairs to a sapphire straight flush and a royal flush prismatic treatment. The live hand hint carries the tier, so the you-have placard glows with your hand's rank, and royals now read as royal flush.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-11 17:30:03 -04:00
11 changed files with 308 additions and 38 deletions

View file

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

View file

@ -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;

View file

@ -105,9 +105,13 @@ export function CardView({
<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)}>
{size !== "mini" && (
<span
className={cn("poker-card-center", RED_SUITS.has(card.suit) && redSuitClass)}
>
{SUIT_SYMBOLS[card.suit]}
</span>
)}
</>
)}
</span>

View file

@ -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({
</div>
<ViewerRoleBadges view={view} />
</div>
{handLabel && <span className="poker-hand-placard">You have: {handLabel}</span>}
{handLabel && (
<span
className={`poker-hand-placard${
handTier && handTier > 0 ? ` poker-rank-tier-${handTier}` : ""
}`}
>
You have: {handLabel}
</span>
)}
</div>
</div>

View file

@ -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<string>;
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({
<PokerActionConsole
snapshot={snapshot}
handLabel={handLabel}
handTier={handTier}
hostSeatId={snapshot.hostSeatId}
starting={actionProps.starting}
leaving={actionProps.leaving}

View file

@ -54,6 +54,7 @@ export function PokerGameTable({
[view.communityCards, view.phase, view.yourHoleCards],
);
const bestCardKeys = useMemo(() => 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,

View file

@ -1,7 +1,8 @@
"use client";
import { BookOpen } from "lucide-react";
import { BookOpen, ChevronDown } from "lucide-react";
import type { Card, Rank, Suit } from "@/lib/minigames/poker";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Dialog,
@ -17,7 +18,8 @@ interface PokerRanking {
readonly name: string;
readonly example: readonly Card[];
readonly description: string;
readonly color?: string;
/** 0 (no styling) through 9 (royal prismatic); see handRankStyle.ts. */
readonly tier: number;
}
function card(rank: Rank, suit: Suit): Card {
@ -29,57 +31,61 @@ const RANKINGS: readonly PokerRanking[] = [
name: "High card",
example: [card(14, "s"), card(13, "h"), card(9, "d"), card(5, "c"), card(2, "s")],
description: "No matching ranks, the highest card wins.",
tier: 0,
},
{
name: "One pair",
example: [card(9, "s"), card(9, "h"), card(13, "d"), card(7, "c"), card(3, "s")],
description: "Two cards share the same rank.",
tier: 1,
},
{
name: "Two pair",
example: [card(11, "s"), card(11, "h"), card(4, "d"), card(4, "c"), card(14, "s")],
description: "Two different pairs plus a fifth card.",
tier: 2,
},
{
name: "Three of a kind",
example: [card(7, "s"), card(7, "h"), card(7, "d"), card(13, "c"), card(2, "s")],
description: "Three cards share the same rank.",
tier: 3,
},
{
name: "Straight",
example: [card(5, "s"), card(6, "h"), card(7, "d"), card(8, "c"), card(9, "s")],
description: "Five cards run in rank order.",
color: "#ffff00",
tier: 4,
},
{
name: "Flush",
example: [card(14, "h"), card(10, "h"), card(7, "h"), card(4, "h"), card(2, "h")],
description: "Five cards share the same suit.",
color: "#ffff00",
tier: 5,
},
{
name: "Full house",
example: [card(13, "s"), card(13, "h"), card(13, "d"), card(8, "c"), card(8, "s")],
description: "Three of one rank and two of another.",
color: "#ffff00",
tier: 6,
},
{
name: "Four of a kind",
example: [card(3, "s"), card(3, "h"), card(3, "d"), card(3, "c"), card(14, "s")],
description: "Four cards share the same rank.",
color: "#ffff00",
tier: 7,
},
{
name: "Straight flush",
example: [card(5, "s"), card(6, "s"), card(7, "s"), card(8, "s"), card(9, "s")],
description: "Five cards run in order and share a suit.",
color: "#ffff00",
tier: 8,
},
{
name: "Royal flush",
example: [card(10, "h"), card(11, "h"), card(12, "h"), card(13, "h"), card(14, "h")],
description: "The ace-high straight flush, the strongest hand.",
color: "#ffff00",
tier: 9,
},
];
@ -102,18 +108,27 @@ export function PokerGuide() {
<section className="flex flex-col gap-3">
<h2 className="text-sm font-semibold uppercase tracking-wider">Hand rankings</h2>
<ol className="grid gap-3 sm:grid-cols-2">
{RANKINGS.map((ranking, index) => (
<li key={ranking.name} className="flex items-center gap-3 border border-border p-3" style={ranking.color ? ({
borderColor: ranking.color
}) : {}}>
<ol className="grid gap-2 sm:grid-flow-col sm:grid-cols-2 sm:grid-rows-[auto_1rem_auto_1rem_auto_1rem_auto_1rem_auto]">
{RANKINGS.flatMap((ranking, index) => {
const isLast = index === RANKINGS.length - 1;
const columnBreakAfter = index === 4;
const nodes = [
<li
key={ranking.name}
className={`relative flex items-center gap-3 border border-border p-3${
ranking.tier > 0 ? ` poker-rank-tier-${ranking.tier}` : ""
}`}
>
<span className="w-5 shrink-0 font-mono text-xs text-muted-foreground">
{index + 1}
</span>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex min-w-0 flex-1 flex-col justify-center gap-0">
<span className="font-medium">{ranking.name}</span>
<div className="flex gap-0.5" aria-label={`${ranking.name} example`}>
<p className="text-xs leading-tight text-muted-foreground">
{ranking.description}
</p>
</div>
<div className="flex shrink-0 gap-0.5" aria-label={`${ranking.name} example`}>
{ranking.example.map((exampleCard, cardIndex) => (
<CardView
key={`${exampleCard.rank}-${exampleCard.suit}`}
@ -123,11 +138,24 @@ export function PokerGuide() {
/>
))}
</div>
</div>
<p className="text-xs text-muted-foreground max-w-1/2">{ranking.description}</p>
</div>
</li>
))}
</li>,
];
if (!isLast) {
nodes.push(
<div
key={`${ranking.name}-arrow`}
aria-hidden="true"
className={cn(
"flex items-center justify-center -my-3",
columnBreakAfter && "sm:hidden",
)}
>
<ChevronDown className="size-3.5 text-muted-foreground/70" />
</div>,
);
}
return nodes;
})}
</ol>
</section>

View file

@ -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<Rank, string> = {
@ -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),
};
}

View file

@ -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}`;
}

View file

@ -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,
});
});

View file

@ -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");
});
});