- 공식 상품 페이지 규칙 설명 이미지 기준: 5장씩, 시작 카드(2명 40·3명 20·4명 이상 0), 0 카드는 방향 유지/반대 선택, 칩(목숨)은 공식 규칙이 아니라 하우스 룰 - handSize 'auto'(전용 카드 5, 트럼프 3), startTotal 옵션, 카드 종류 'zero'와 dir 액션 - stateVersion 2, 진행 중인 v1 게임은 옛 규칙 그대로 이어서 하도록 migrate - 특수 카드 장수(±9·±10·0)는 공개 자료가 없어 각 4장 유지 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
62 lines
4.2 KiB
TypeScript
62 lines
4.2 KiB
TypeScript
/** 구십구 [?] "지금 할 수 있는 것" (docs/07 §5). Pure: only reads the client view. */
|
||
import type { NNOptions, NinetyNineView } from '@bg/games/ninety-nine';
|
||
import type { PublicUser } from '@bg/shared/lite';
|
||
|
||
/** Same as the engine's: `auto` deals 5 with the dedicated cards, 3 with trump cards. */
|
||
export const resolvedHandSize = (o: Pick<NNOptions, 'mode' | 'handSize'>): number => (o.handSize === 'auto' ? (o.mode === 'classic' ? 3 : 5) : o.handSize);
|
||
/** Same as the engine's: the start card shows 40 for 2 players, 20 for 3, 0 for 4 or more. */
|
||
export const resolvedStartTotal = (o: Pick<NNOptions, 'mode' | 'startTotal'>, players: number): number =>
|
||
o.mode === 'kr-product' && o.startTotal === 'auto' ? (players <= 2 ? 40 : players === 3 ? 20 : 0) : 0;
|
||
|
||
const nm = (players: Record<string, PublicUser>, id: string | null | undefined) => `${(id && players[id]?.nickname) || '다른 사람'}님`;
|
||
|
||
/** What happens to someone who has no card to play (option dependent). */
|
||
function loseText(v: NinetyNineView): string {
|
||
if (v.livesStart === 0) return '바로 탈락해요';
|
||
return `목숨 1개를 잃고 합계 ${resolvedStartTotal(v.options, v.seats.filter((s) => s.alive).length)}부터 새 판을 해요`;
|
||
}
|
||
|
||
export function helpNow(v: NinetyNineView, me: string | null, players: Record<string, PublicUser>): string {
|
||
const goal = '카드를 1장씩 내며 합계를 더해요. 합계가 99를 넘게 될 카드밖에 없으면 져요.';
|
||
if (v.phase === 'finished') return `게임이 끝났어요. ${v.result?.summary ?? ''}`.trim() + '\n결과 창에서 [한 판 더]를 누르면 다시 할 수 있어요.';
|
||
|
||
const seat = me ? v.seats.find((s) => s.id === me) : undefined;
|
||
const my = seat ? v.my : null;
|
||
const cur = v.current;
|
||
const curNick = nm(players, cur);
|
||
const turnLine = cur ? `${curNick} 차례예요(지금 합계 ${v.total}).` : `지금 합계는 ${v.total}까지 왔어요.`;
|
||
|
||
if (!seat || !my) return `${turnLine}\n${goal}`;
|
||
if (!seat.alive) return `${seat.left ? '게임에서 나갔어요.' : '탈락했어요. 남은 사람의 게임을 지켜봐요.'}\n${turnLine}`;
|
||
|
||
const lifeLine =
|
||
v.livesStart > 0
|
||
? seat.onBoard
|
||
? '목숨을 다 잃어서 마지막 기회예요. 한 번 더 지면 탈락해요.'
|
||
: `내 목숨은 ${seat.lives}개예요.`
|
||
: '';
|
||
|
||
if (!my.myTurn) {
|
||
const lines = [`${turnLine} 기다려 주세요.`];
|
||
lines.push(`내 차례에 낼 수 있는 카드가 없으면 ${loseText(v)}. 카드를 누르면 낼 때 합계를 미리 볼 수 있어요.`);
|
||
if (lifeLine) lines.push(lifeLine);
|
||
return lines.join('\n');
|
||
}
|
||
|
||
const legal = my.hand.filter((c) => c.legal);
|
||
const lines = [`내 차례예요(지금 합계 ${v.total}). 합계가 99를 넘지 않게 카드 1장을 내요.`];
|
||
if (legal.length < my.hand.length) lines.push(`초록 테두리로 밝게 보이는 카드만 낼 수 있어요(${legal.length}장). 어두운 카드는 99를 넘거나 0보다 작아져요.`);
|
||
lines.push('카드를 누르면 낸 뒤 합계가 보여요. 한 번 더 누르면 내요. 패스는 없어요.');
|
||
if (legal.some((c) => c.kind === 'plusMinus')) lines.push('±카드는 누른 뒤 더하기(+)와 빼기(−) 중 하나를 골라요. 합계가 클 때는 빼기가 좋아요.');
|
||
if (legal.some((c) => c.kind === 'ace')) lines.push('A는 누른 뒤 더할 값을 골라요.');
|
||
if (legal.some((c) => c.kind === 'joker')) lines.push(`조커는 합계를 ${v.jokerRange.min}~${v.jokerRange.max} 중 원하는 수로 만들어요. 수를 고르고 [조커 내기]를 눌러요.`);
|
||
const face = (k: string) => legal.find((c) => c.kind === k)?.face.label;
|
||
if (face('zero')) lines.push(`${face('zero')} 카드는 합계를 그대로 둬요. 누른 뒤 [방향 그대로]와 [방향 바꾸기] 중 하나를 골라요.`);
|
||
if (face('reverse')) lines.push(`${face('reverse')} 카드는 합계를 그대로 두고 진행 방향만 바꿔요.`);
|
||
if (face('hold')) lines.push(`${face('hold')} 카드는 합계를 그대로 두고 다음 사람에게 넘겨요.`);
|
||
if (face('to99')) lines.push(`${face('to99')} 카드는 합계를 바로 99로 만들어요.`);
|
||
if (face('minus')) lines.push(`${face('minus')} 카드는 합계에서 10을 빼요.`);
|
||
if (lifeLine) lines.push(lifeLine);
|
||
return lines.join('\n');
|
||
}
|