Files
joke-app/packages/games/src/mahjong/tiles.ts
EJClaw c0fb44e395 M5: 마작 규칙·점수 계산·화면·테스트
- 규칙 모듈 packages/games/src/mahjong (tiles·agari·scoring·types·index): 4인 리치 마작(동풍전/반장전)과
  3인 산마, 옵션 전부 한국어 meta, 응답 창(론 > 퐁·깡 > 치, 조기 확정, 최소 400ms, 시간 초과 = 패스),
  리치·일발·더블리치, 후리텐(버림패·동순·리치), 구이가에, 깡 도라 시점, 사깡·사풍·사가리치·구종구패·삼가화,
  유국만관, 책임지불, 북 뽑기, 고점법 점수 계산(역·부·만관~더블 역만, 본장·공탁, 산마 쯔모손/북가절반),
  토비·아가리야메·서입 서든데스, 우마·오카 결과, 초보 모드 힌트, autoPilot·나가기 처리
- 응답 창 타이머는 아직 응답하지 않은 응답 가능자에게만 걸어 연결 끊긴 사람은 바로 패스, 확정 뒤에는 방총자
  타이머로 닫음(끊긴 방총자 때문에 타이머가 헛도는 일 방지). 공개 시계에는 응답 창 남은 시간을 싣지 않음
- 테스트: 점수 계산기 161건(1~4판 × 20~110부 표 전 칸, 만관 이상, 역·부 예시, 산마 지불),
  규칙 시나리오 77건(§10 체크리스트), 무작위 대국 1,000판(4인 반장전 240 · 동풍전 400 · 산마 360,
  옵션 무작위, 패·점수 보존, onTimeout 합법, 재현성) + 엔진 randomPlayout과 매 단계 정보 누출 검사
- 화면 apps/web/src/games/mahjong: 직접 그린 SVG 패, 4방향(3인은 3방향) 버림패 테이블, 탭 두 번 버리기,
  리치·쯔모·깡·북·구종구패, 론·퐁·치(조합 선택)·깡·패스 응답, 국 결과(역·판·부·점수 변화)와 최종 순위,
  초보 모드(텐파이 타패·대기·남은 장수·역 힌트·후리텐 배지), PC 2단 배치·휴대폰 한 줄 손패, 키보드·스크린리더
- 등록: GAMES, 카탈로그(available), 웹 registry, '곧 나와요'에서 마작 제거
- E2E: PC 방장 + 휴대폰 3명 4인 대국으로 한 국을 끝까지(타패·패스·유국 결과 확인·다음 국 시작)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 14:06:45 +09:00

98 lines
3.9 KiB
TypeScript

/**
* 마작패 (docs/games/mahjong.md §3, §8.1). Tile ids 0..135, kind = id >> 2 (0..33):
* 0-8 1m-9m, 9-17 1p-9p, 18-26 1s-9s, 27-30 동남서북, 31-33 백발중.
* Over the wire a tile is a short code "<kind><copy>[r]", e.g. "5m0r" (red five), "3p2", "7z1" (중) — unique per
* tile so hidden-information leak checks stay meaningful.
*/
export type TileId = number;
export type Kind = number;
export const EAST = 27;
export const SOUTH = 28;
export const WEST = 29;
export const NORTH = 30;
export const HAKU = 31;
export const HATSU = 32;
export const CHUN = 33;
export const KIND_CODES: string[] = [
...['m', 'p', 's'].flatMap((s) => Array.from({ length: 9 }, (_, i) => `${i + 1}${s}`)),
...Array.from({ length: 7 }, (_, i) => `${i + 1}z`),
];
const KIND_BY_CODE: Record<string, Kind> = Object.fromEntries(KIND_CODES.map((c, i) => [c, i]));
export const kindOf = (id: TileId): Kind => id >> 2;
export const isHonor = (k: Kind): boolean => k >= 27;
export const isDragon = (k: Kind): boolean => k >= 31;
export const isWind = (k: Kind): boolean => k >= 27 && k <= 30;
export const isTerminal = (k: Kind): boolean => k < 27 && (k % 9 === 0 || k % 9 === 8);
export const isYaochu = (k: Kind): boolean => k >= 27 || k % 9 === 0 || k % 9 === 8;
export const suitOf = (k: Kind): number => (k < 27 ? Math.floor(k / 9) : 3);
export const numOf = (k: Kind): number => (k < 27 ? (k % 9) + 1 : 0);
export const YAOCHU: Kind[] = [0, 8, 9, 17, 18, 26, 27, 28, 29, 30, 31, 32, 33];
export const GREEN: Kind[] = [19, 20, 21, 23, 25, HATSU];
/** Kinds missing in 3-player (산마): 2m~8m. */
export const isSanmaRemoved = (k: Kind): boolean => k >= 1 && k <= 7;
export function allTiles(sanma: boolean): TileId[] {
const out: TileId[] = [];
for (let id = 0; id < 136; id++) if (!(sanma && isSanmaRemoved(kindOf(id)))) out.push(id);
return out;
}
/** Red fives in use: 4인 3 → 5m·5p·5s, 2 → 5p·5s; 산마 → 5p·5s (or none). Copy 0 of each five is the red one. */
export function redTiles(akaDora: number, sanma: boolean): TileId[] {
if (akaDora <= 0) return [];
if (sanma || akaDora === 2) return [52, 88];
return [16, 52, 88];
}
export function tileCode(id: TileId, red: readonly TileId[]): string {
return `${KIND_CODES[kindOf(id)]}${id & 3}${red.includes(id) ? 'r' : ''}`;
}
/** Inverse of tileCode (the red marker is ignored); null when malformed. */
export function parseTileCode(code: string): TileId | null {
const m = /^([1-9][mps]|[1-7]z)([0-3])r?$/.exec(code);
if (!m) return null;
return KIND_BY_CODE[m[1]!]! * 4 + Number(m[2]);
}
export const kindCode = (k: Kind): string => KIND_CODES[k]!;
export const kindFromCode = (code: string): Kind | null => KIND_BY_CODE[code] ?? null;
/** Dora shown by an indicator (§6.2, 산마: 1m ↔ 9m). */
export function doraFromIndicator(ind: Kind, sanma: boolean): Kind {
if (ind < 27) {
if (sanma && ind === 0) return 8;
if (sanma && ind === 8) return 0;
return ind - (ind % 9) + (((ind % 9) + 1) % 9);
}
if (ind <= 30) return ind === 30 ? 27 : ind + 1;
return ind === 33 ? 31 : ind + 1;
}
const SUIT_KO = ['만수', '통수', '삭수'];
const HONOR_KO = ['동', '남', '서', '북', '백', '발', '중'];
/** Screen-reader / log name, e.g. "만수 5", "동", "중". */
export function kindNameKo(k: Kind): string {
return k < 27 ? `${SUIT_KO[suitOf(k)]} ${numOf(k)}` : HONOR_KO[k - 27]!;
}
/** Short name, e.g. "5만", "3통", "7삭", "동". */
export function kindShortKo(k: Kind): string {
return k < 27 ? `${numOf(k)}${['만', '통', '삭'][suitOf(k)]}` : HONOR_KO[k - 27]!;
}
export const WIND_KO = ['동', '남', '서', '북'];
/** Sort key: 만→통→삭→자, by number, red five before plain fives. */
export function compareTiles(a: TileId, b: TileId): number {
return a - b;
}
export function counts34(kinds: Iterable<Kind>): number[] {
const c = new Array<number>(34).fill(0);
for (const k of kinds) c[k]!++;
return c;
}