M7: 보석 상인 규칙·화면·테스트

- 규칙 모듈 packages/games/src/splendor: 카드 90장·귀족 10장 데이터 파일 분리(cards.ts),
  보석 가져오기(3색/같은 색 2개, allowFewerTokens), 예약(공개/더미, 황금), 구매(자동·수동 지불),
  10개 한도 반납, 귀족 방문·선택, 라운드 끝까지 진행 후 종료, 동점은 카드 적은 쪽,
  넘기기와 모두 넘기면 종료, 기권, 시간 초과 자동 행동(3번 연속이면 자리 비움),
  view는 더미·블라인드 예약·제외 귀족을 숨기고 살 수 있는 카드·지불 미리보기를 계산
- 테스트: 문서 10절 시나리오 42개 + 무작위 대국 1,000판(보존·정보 유출·onTimeout 합법·결정성)
  + 엔진 randomPlayout
- 화면 apps/web/src/games/splendor: 자체 카드·귀족·보석 그림(색+모양+글자), 3단계 시장과 더미,
  보석 은행, 내 보너스·보석·예약, 상대 요약, 살 수 있는 카드 강조, 반납·귀족 선택, PC/휴대폰·다크 모드
- 등록: GAMES, 카탈로그, 화면 레지스트리, 아이콘
- E2E: PC 방장 + 휴대폰 친구가 10점 판을 결과까지 진행

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-10-05 14:53:35 +09:00
parent 559fb8507c
commit 11ad2be147
10 changed files with 3322 additions and 0 deletions

View File

@@ -0,0 +1,76 @@
/**
* 보석 상인 component data (docs/games/splendor.md §3.1, §3.2). Kept in a separate data file so the card
* list can be swapped for an own list with the same structure (§12). Encoding: `<id>:<points>:<W U G R K cost digits>`.
*/
export const COLORS = ['W', 'U', 'G', 'R', 'K'] as const;
export type Color = (typeof COLORS)[number];
export type TokenColor = Color | 'gold';
export const TOKEN_COLORS: readonly TokenColor[] = [...COLORS, 'gold'];
export type Gems = Record<Color, number>;
export type Tokens = Record<TokenColor, number>;
export type Tier = 1 | 2 | 3;
export const TIERS: readonly Tier[] = [1, 2, 3];
export interface DevCard {
id: string;
tier: Tier;
bonus: Color;
points: number;
cost: Gems;
}
export interface Noble {
id: string;
points: 3;
req: Gems;
}
const RAW_CARDS = [
// tier 1
'1W01:0:01111', '1W02:0:01211', '1W03:0:02201', '1W04:0:31001', '1W05:0:00021', '1W06:0:02002', '1W07:0:03000', '1W08:1:00400',
'1U01:0:10111', '1U02:0:10121', '1U03:0:10220', '1U04:0:01310', '1U05:0:10002', '1U06:0:00202', '1U07:0:00003', '1U08:1:00040',
'1G01:0:11011', '1G02:0:11012', '1G03:0:01022', '1G04:0:13100', '1G05:0:21000', '1G06:0:02020', '1G07:0:00030', '1G08:1:00004',
'1R01:0:11101', '1R02:0:21101', '1R03:0:20102', '1R04:0:10013', '1R05:0:02100', '1R06:0:20020', '1R07:0:30000', '1R08:1:40000',
'1K01:0:11110', '1K02:0:12110', '1K03:0:22010', '1K04:0:00131', '1K05:0:00210', '1K06:0:20200', '1K07:0:00300', '1K08:1:04000',
// tier 2
'2W01:1:00322', '2W02:1:23030', '2W03:2:00142', '2W04:2:00053', '2W05:2:00050', '2W06:3:60000', '2U01:1:02230', '2U02:1:02303',
'2U03:2:53000', '2U04:2:20014', '2U05:2:05000', '2U06:3:06000', '2G01:1:30230', '2G02:1:23002', '2G03:2:42001', '2G04:2:05300',
'2G05:2:00500', '2G06:3:00600', '2R01:1:20023', '2R02:1:03023', '2R03:2:14200', '2R04:2:30005', '2R05:2:00005', '2R06:3:00060',
'2K01:1:32200', '2K02:1:30302', '2K03:2:01420', '2K04:2:00530', '2K05:2:50000', '2K06:3:00006',
// tier 3
'3W01:3:03353', '3W02:4:00007', '3W03:4:30036', '3W04:5:30007', '3U01:3:30335', '3U02:4:70000', '3U03:4:63003', '3U04:5:73000',
'3G01:3:53033', '3G02:4:07000', '3G03:4:36300', '3G04:5:07300', '3R01:3:35303', '3R02:4:00700', '3R03:4:03630', '3R04:5:00730',
'3K01:3:33530', '3K02:4:00070', '3K03:4:00363', '3K04:5:00073',
];
const RAW_NOBLES = [
'N01:00440', 'N02:04400', 'N03:44000', 'N04:40004', 'N05:00044', 'N06:03330', 'N07:33300', 'N08:33003', 'N09:30033', 'N10:00333',
];
const gems = (digits: string): Gems => ({ W: +digits[0]!, U: +digits[1]!, G: +digits[2]!, R: +digits[3]!, K: +digits[4]! });
export const CARDS: readonly DevCard[] = RAW_CARDS.map((raw) => {
const [id, points, cost] = raw.split(':') as [string, string, string];
return { id, tier: Number(id[0]) as Tier, bonus: id[1] as Color, points: Number(points), cost: gems(cost) };
});
export const NOBLES: readonly Noble[] = RAW_NOBLES.map((raw) => {
const [id, req] = raw.split(':') as [string, string];
return { id, points: 3, req: gems(req) };
});
const CARD_BY_ID = new Map(CARDS.map((c) => [c.id, c]));
const NOBLE_BY_ID = new Map(NOBLES.map((n) => [n.id, n]));
export function cardOf(id: string): DevCard {
const c = CARD_BY_ID.get(id);
if (!c) throw new Error(`unknown card ${id}`);
return c;
}
export function nobleOf(id: string): Noble {
const n = NOBLE_BY_ID.get(id);
if (!n) throw new Error(`unknown noble ${id}`);
return n;
}
export const isCardId = (id: string): boolean => CARD_BY_ID.has(id);
export const emptyGems = (): Gems => ({ W: 0, U: 0, G: 0, R: 0, K: 0 });
export const emptyTokens = (): Tokens => ({ W: 0, U: 0, G: 0, R: 0, K: 0, gold: 0 });
export const sumTokens = (t: Partial<Record<TokenColor, number>>): number => TOKEN_COLORS.reduce((a, c) => a + (t[c] ?? 0), 0);