M3 기반: 공용 베팅 엔진, 카드 정의, 카드·칩 화면 컴포넌트, 돈 표현 금지 검사

- packages/engine/betting: 한국식(체크·삥·콜·따당·쿼터·하프·풀·다이, 라운드당 1회·첫 행동에서만 레이즈),
  노리밋·팟리밋(최소 레이즈, 불완전 올인 레이즈는 재오픈 안 함, BB 옵션), 앤티·블라인드, 올인,
  받지 않은 베팅 반환, 사이드 팟 분할, 동점 분배(홀수 칩 순서), 시간 초과 자동 행동
- 테스트 24개: 포커·섯다 계획서 계산 예시, 행동별 상태 전이, 사이드 팟 예시, 무작위 베팅 1,500판
  (칩 총량 보존·라운드 종료·라운드 끝 금액 일치·팟 합계=판 전체)
- packages/engine/cards: 트럼프 52+조커, 화투 48(+보너스)·섯다 20장 정의와 테스트
- 웹: 직접 그린 SVG 트럼프·화투 카드, 칩 표시("1,250 칩"), 놀이용 칩 안내문
- apps/web/src/no-money-wording.test.ts: 베팅 게임 화면에 통화 기호·"원"·판돈·충전·환전 등 금지

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-10-04 05:45:13 +09:00
parent a10139b51a
commit 9519b09be8
10 changed files with 1370 additions and 3 deletions

View File

@@ -0,0 +1,35 @@
/**
* Play-chip display (docs/04 §6): chips are room-only play points.
* Never shown as money: no currency symbols, no "원/돈/충전/환전" wording.
*/
export function formatChips(n: number): string {
return `${Math.max(0, Math.round(n)).toLocaleString('ko-KR')} 칩`;
}
export function Chips({ n, strong, className }: { n: number; strong?: boolean; className?: string }) {
return (
<span className={className} style={{ fontVariantNumeric: 'tabular-nums', fontWeight: strong ? 800 : 600, whiteSpace: 'nowrap' }}>
<ChipIcon /> {formatChips(n)}
</span>
);
}
export function ChipIcon({ size = 16 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 20 20" aria-hidden style={{ verticalAlign: '-3px' }}>
<circle cx="10" cy="10" r="9" fill="#2563eb" />
<circle cx="10" cy="10" r="5.5" fill="none" stroke="#fff" strokeWidth="2" strokeDasharray="3 2.4" />
</svg>
);
}
/** Shown once per room in betting games. */
export const PLAY_CHIP_NOTICE = '칩은 이 방에서만 쓰는 놀이용 점수예요. 실제 가치가 없고 바꾸거나 주고받을 수 없으며, 방이 끝나면 사라져요.';
export function PlayChipNotice() {
return (
<p className="small" style={{ background: 'var(--surface-2)', borderRadius: 10, padding: '8px 12px', margin: 0 }}>
<ChipIcon /> {PLAY_CHIP_NOTICE}
</p>
);
}

View File

@@ -0,0 +1,178 @@
/**
* Hwatu card drawn in SVG (our own simple artwork, no commercial hwatu images — docs/games/seotda.md §12).
* Large month number + kind badge so the card is readable without knowing the pictures.
*/
import type { CSSProperties, ReactNode } from 'react';
export interface HwatuInfo {
id: number;
month: number; // 0 for bonus
kind: 'gwang' | 'yeol' | 'tti' | 'pi' | 'ssangpi' | 'bonus';
tti?: 'hong' | 'cheong' | 'cho' | 'plain';
name: string;
piValue?: number;
}
const MONTH_COLOR = ['#6b7280', '#15803d', '#be185d', '#db2777', '#1f2937', '#7c3aed', '#dc2626', '#b91c1c', '#111827', '#ca8a04', '#c2410c', '#7c2d12', '#1e40af'];
const MONTH_NAME = ['보너스', '송학', '매조', '벚꽃', '흑싸리', '난초', '모란', '홍싸리', '공산', '국화', '단풍', '오동', '비'];
const KIND_BADGE: Record<HwatuInfo['kind'], string> = { gwang: '光', yeol: '열', tti: '띠', pi: '피', ssangpi: '쌍피', bonus: '+' };
const TTI_COLOR = { hong: '#dc2626', cheong: '#2563eb', cho: '#15803d', plain: '#dc2626' };
const TTI_TEXT = { hong: '홍단', cheong: '청단', cho: '', plain: '' };
/** A small motif per month (flowers/plants), drawn with basic shapes. */
function Motif({ month, color }: { month: number; color: string }): ReactNode {
const c = color;
switch (month) {
case 1: // 송학: pine
return <path d="M50 52 L28 92 H72 Z M50 72 L22 116 H78 Z" fill={c} opacity="0.85" />;
case 2: // 매조: plum blossoms
case 3: // 벚꽃
return (
<g fill={c} opacity="0.85">
{[[34, 70], [62, 62], [50, 92], [70, 100], [30, 108]].map(([x, y], i) => (
<circle key={i} cx={x} cy={y} r="10" />
))}
</g>
);
case 4: // 흑싸리
case 7: // 홍싸리
return (
<g stroke={c} strokeWidth="5" strokeLinecap="round" opacity="0.85">
{[30, 45, 60, 75].map((x, i) => (
<line key={i} x1={x} y1="52" x2={x - 6} y2={100 + (i % 2) * 14} />
))}
</g>
);
case 5: // 난초: iris leaves
return (
<g fill="none" stroke={c} strokeWidth="5" opacity="0.85">
<path d="M40 120 Q34 80 44 52" />
<path d="M55 120 Q62 84 52 54" />
<path d="M70 120 Q76 92 68 64" />
</g>
);
case 6: // 모란: peony
return (
<g fill={c} opacity="0.85">
<circle cx="50" cy="86" r="22" />
<circle cx="50" cy="86" r="10" fill="#fde68a" />
</g>
);
case 8: // 공산: hill
return <path d="M8 120 Q50 52 92 120 Z" fill={c} opacity="0.85" />;
case 9: // 국화
return (
<g fill={c} opacity="0.9">
{Array.from({ length: 10 }, (_, i) => {
const a = (i / 10) * Math.PI * 2;
return <ellipse key={i} cx={50 + Math.cos(a) * 14} cy={88 + Math.sin(a) * 14} rx="8" ry="4" transform={`rotate(${(a * 180) / Math.PI} ${50 + Math.cos(a) * 14} ${88 + Math.sin(a) * 14})`} />;
})}
<circle cx="50" cy="88" r="7" fill="#78350f" />
</g>
);
case 10: // 단풍: maple
return <path d="M50 58 L58 78 L78 74 L64 90 L74 108 L50 98 L26 108 L36 90 L22 74 L42 78 Z" fill={c} opacity="0.9" />;
case 11: // 오동
return (
<g fill={c} opacity="0.85">
<ellipse cx="38" cy="82" rx="16" ry="22" />
<ellipse cx="64" cy="94" rx="16" ry="22" />
</g>
);
case 12: // 비: rain lines + umbrella
return (
<g stroke={c} strokeWidth="3" opacity="0.85">
{[20, 35, 50, 65, 80].map((x) => (
<line key={x} x1={x} y1="54" x2={x - 8} y2="118" />
))}
</g>
);
default:
return <text x="50" y="98" fontSize="36" textAnchor="middle" fill={c}>★</text>;
}
}
export type HwatuSize = 'sm' | 'md' | 'lg';
const WIDTH: Record<HwatuSize, number> = { sm: 40, md: 60, lg: 84 };
export function HwatuCard({
card,
size = 'md',
selected,
dimmed,
highlight,
onClick,
style,
}: {
card: HwatuInfo | null;
size?: HwatuSize;
selected?: boolean;
dimmed?: boolean;
highlight?: boolean;
onClick?: () => void;
style?: CSSProperties;
}) {
const w = WIDTH[size];
const h = Math.round(w * 1.6);
const Tag = onClick ? 'button' : 'span';
const s: CSSProperties = {
width: w,
height: h,
flex: 'none',
display: 'inline-block',
padding: 0,
border: 0,
background: 'none',
cursor: onClick ? 'pointer' : 'default',
opacity: dimmed ? 0.45 : 1,
transform: selected ? `translateY(${-Math.round(w * 0.2)}px)` : undefined,
transition: 'transform 0.12s ease',
filter: highlight ? 'drop-shadow(0 0 6px #f59e0b)' : 'drop-shadow(0 1px 2px rgba(0,0,0,0.3))',
...style,
};
if (!card) {
return (
<Tag style={s} onClick={onClick} aria-label="뒤집힌 화투">
<svg viewBox="0 0 100 160" width={w} height={h} aria-hidden>
<rect x="1" y="1" width="98" height="158" rx="8" fill="#991b1b" stroke="#450a0a" strokeWidth="3" />
<rect x="10" y="10" width="80" height="140" rx="5" fill="#b91c1c" />
</svg>
</Tag>
);
}
const color = MONTH_COLOR[card.month] ?? '#374151';
return (
<Tag style={s} onClick={onClick} aria-label={card.name} aria-pressed={onClick ? !!selected : undefined} title={card.name}>
<svg viewBox="0 0 100 160" width={w} height={h} aria-hidden>
<rect x="1" y="1" width="98" height="158" rx="8" fill="#fffaf0" stroke={selected ? '#2563eb' : '#1f2937'} strokeWidth={selected ? 6 : 3} />
<Motif month={card.month} color={color} />
{card.kind === 'tti' && (
<g>
<rect x="58" y="30" width="18" height="90" rx="3" fill={TTI_COLOR[card.tti ?? 'plain']} />
{TTI_TEXT[card.tti ?? 'plain'] && (
<text x="67" y="62" fontSize="13" fontWeight="800" fill="#fff" textAnchor="middle" style={{ writingMode: 'vertical-rl' } as CSSProperties}>
{TTI_TEXT[card.tti ?? 'plain']}
</text>
)}
</g>
)}
{(card.kind === 'gwang' || card.kind === 'yeol') && (
<g>
<circle cx="78" cy="120" r="15" fill={card.kind === 'gwang' ? '#dc2626' : '#a16207'} />
<text x="78" y="127" fontSize={card.kind === 'gwang' ? 20 : 16} fontWeight="800" fill="#fff" textAnchor="middle">
{card.kind === 'gwang' ? '光' : '열'}
</text>
</g>
)}
{/* Month number: large and always readable */}
<rect x="6" y="6" width="38" height="38" rx="8" fill={color} />
<text x="25" y="35" fontSize="28" fontWeight="900" fill="#fff" textAnchor="middle" fontFamily="Pretendard Variable, sans-serif">
{card.month || '+'}
</text>
<text x="50" y="154" fontSize="13" fontWeight="700" fill="#374151" textAnchor="middle" fontFamily="Pretendard Variable, sans-serif">
{MONTH_NAME[card.month]} · {card.kind === 'tti' ? (TTI_TEXT[card.tti ?? 'plain'] || '띠') : KIND_BADGE[card.kind] === '光' ? '광' : KIND_BADGE[card.kind] === '열' ? '열끗' : KIND_BADGE[card.kind]}
</text>
</svg>
</Tag>
);
}

View File

@@ -0,0 +1,102 @@
/** Trump card drawn in SVG (no image assets). Big corner index for readability on PC and phones. */
import type { CSSProperties } from 'react';
const SUIT_SYMBOL: Record<string, string> = { S: '♠', H: '♥', D: '◆', C: '♣' };
const SUIT_KO: Record<string, string> = { S: '스페이드', H: '하트', D: '다이아', C: '클로버' };
const RANK_LABEL: Record<string, string> = { T: '10', J: 'J', Q: 'Q', K: 'K', A: 'A' };
const FACE_KO: Record<string, string> = { J: '잭', Q: '퀸', K: '킹', A: '에이스' };
export type CardSize = 'sm' | 'md' | 'lg';
const WIDTH: Record<CardSize, number> = { sm: 44, md: 64, lg: 88 };
export interface PlayingCardProps {
/** e.g. "AS", "TD", "X1" (joker). null = face down. */
card: string | null;
size?: CardSize;
selected?: boolean;
dimmed?: boolean;
highlight?: boolean;
onClick?: () => void;
title?: string;
style?: CSSProperties;
/** Colour-blind friendly: 4-colour suits. */
fourColor?: boolean;
}
export function cardAria(card: string | null): string {
if (!card) return '뒤집힌 카드';
if (card.startsWith('X')) return '조커';
const r = card[0]!;
return `${SUIT_KO[card[1]!]} ${FACE_KO[r] ?? RANK_LABEL[r] ?? r}`;
}
export function PlayingCard({ card, size = 'md', selected, dimmed, highlight, onClick, title, style, fourColor }: PlayingCardProps) {
const w = WIDTH[size];
const h = Math.round(w * 1.4);
const lift = selected ? -Math.round(w * 0.22) : 0;
const common: CSSProperties = {
width: w,
height: h,
flex: 'none',
transform: `translateY(${lift}px)`,
transition: 'transform 0.12s ease',
cursor: onClick ? 'pointer' : 'default',
opacity: dimmed ? 0.45 : 1,
filter: highlight ? 'drop-shadow(0 0 6px #f59e0b)' : 'drop-shadow(0 1px 2px rgba(0,0,0,0.25))',
...style,
};
const Tag = onClick ? 'button' : 'span';
const btnReset: CSSProperties = onClick ? { padding: 0, border: 0, background: 'none' } : {};
if (!card) {
return (
<Tag style={{ ...common, ...btnReset }} onClick={onClick} aria-label={title ?? '뒤집힌 카드'} title={title}>
<svg viewBox="0 0 100 140" width={w} height={h} aria-hidden>
<rect x="1" y="1" width="98" height="138" rx="9" fill="#1e3a8a" stroke="#fff" strokeWidth="3" />
<rect x="9" y="9" width="82" height="122" rx="6" fill="none" stroke="#93c5fd" strokeWidth="2" />
<path d="M50 30 L70 70 L50 110 L30 70 Z" fill="#3b82f6" opacity="0.7" />
</svg>
</Tag>
);
}
const joker = card.startsWith('X');
const suit = card[1]!;
const rank = card[0]!;
const color = joker
? card === 'X1' ? '#111827' : '#dc2626'
: fourColor
? { S: '#111827', H: '#dc2626', D: '#2563eb', C: '#15803d' }[suit]!
: suit === 'H' || suit === 'D'
? '#dc2626'
: '#111827';
const label = joker ? '★' : (RANK_LABEL[rank] ?? rank);
const sym = joker ? '' : SUIT_SYMBOL[suit];
return (
<Tag style={{ ...common, ...btnReset }} onClick={onClick} aria-label={title ?? cardAria(card)} aria-pressed={onClick ? !!selected : undefined} title={title}>
<svg viewBox="0 0 100 140" width={w} height={h} aria-hidden>
<rect x="1" y="1" width="98" height="138" rx="9" fill="#fffef9" stroke={selected ? '#2563eb' : '#9ca3af'} strokeWidth={selected ? 5 : 2} />
<text x="9" y="33" fontSize="30" fontWeight="800" fill={color} fontFamily="Pretendard Variable, sans-serif">
{label}
</text>
<text x="11" y="60" fontSize="26" fill={color}>
{sym}
</text>
{joker ? (
<text x="50" y="98" fontSize="22" fontWeight="800" textAnchor="middle" fill={color}>
JOKER
</text>
) : 'JQK'.includes(rank) ? (
<>
<rect x="30" y="44" width="56" height="78" rx="6" fill={color} opacity="0.1" />
<text x="58" y="100" fontSize="44" fontWeight="800" textAnchor="middle" fill={color}>
{rank}
</text>
</>
) : (
<text x="60" y="108" fontSize="54" textAnchor="middle" fill={color}>
{sym}
</text>
)}
</svg>
</Tag>
);
}

View File

@@ -0,0 +1,39 @@
/**
* Guard (docs/04 §6, reviewer request): betting-game UI text must never look like real money.
* Scans the betting games' screens and shared chip/card components for currency-like wording.
*/
import { expect, test } from 'bun:test';
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
const ROOTS = ['games/poker', 'games/seotda', 'games/gostop', 'components/cards'].map((p) => join(import.meta.dir, p));
const FORBIDDEN: [RegExp, string][] = [
[/₩|\$\s?\d|\d\s?\$/, '통화 기호'],
[/\d[\d,]*\s*(만\s*)?원(?![가-힣])/, '"원" 단위'],
[/판돈|돈을|돈이|돈은|현금|머니|골드/, '돈을 떠올리는 낱말'],
[/충전|환전|입금|출금|송금|결제|구매/, '거래 낱말'],
];
function files(dir: string): string[] {
if (!existsSync(dir)) return [];
return readdirSync(dir).flatMap((f) => {
const p = join(dir, f);
return statSync(p).isDirectory() ? files(p) : /\.(tsx?|css)$/.test(f) ? [p] : [];
});
}
/** Only string literals / JSX text matter; comments are ignored. */
function visibleText(src: string): string {
return src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1');
}
test('no currency-like wording in betting game UI', () => {
const problems: string[] = [];
for (const f of ROOTS.flatMap(files)) {
const text = visibleText(readFileSync(f, 'utf8'));
text.split('\n').forEach((line, i) => {
for (const [re, why] of FORBIDDEN) if (re.test(line)) problems.push(`${f.split('/src/')[1]}:${i + 1} ${why}: ${line.trim().slice(0, 80)}`);
});
}
expect(problems).toEqual([]);
});

View File

@@ -1,9 +1,20 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"lib": [
"ES2023",
"DOM",
"DOM.Iterable"
],
"jsx": "react-jsx",
"types": ["vite/client"]
"types": [
"vite/client"
]
},
"include": ["src"]
"include": [
"src"
],
"exclude": [
"src/**/*.test.ts"
]
}