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:
35
apps/web/src/components/cards/Chips.tsx
Normal file
35
apps/web/src/components/cards/Chips.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
178
apps/web/src/components/cards/HwatuCard.tsx
Normal file
178
apps/web/src/components/cards/HwatuCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
102
apps/web/src/components/cards/PlayingCard.tsx
Normal file
102
apps/web/src/components/cards/PlayingCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
apps/web/src/no-money-wording.test.ts
Normal file
39
apps/web/src/no-money-wording.test.ts
Normal 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([]);
|
||||
});
|
||||
Reference in New Issue
Block a user