Files
joke-app/apps/web/src/games/gostop/help.ts
2026-10-06 13:35:21 +09:00

166 lines
11 KiB
TypeScript

/** 고스톱 [?] "지금 할 수 있는 것" (docs/07 §5). Pure: only reads the client view. */
import type { BakKind, GostopView, SeatView } from '@bg/games/gostop';
import type { PublicUser } from '@bg/shared/lite';
const BAK: Record<BakKind, string> = { pi: '피박', gwang: '광박', meong: '멍박', go: '고박' };
function myScoreLine(v: GostopView, seat: SeatView): string {
const s = seat.score;
const items = s.items.length ? `(${s.items.map((x) => `${x.name} ${x.points}점`).join(' · ')})` : '';
const go = seat.goCount > 0 ? ` 나는 지금 ${seat.goCount}고예요.` : '';
return `지금 내 점수 ${s.total}점${items}, ${v.rules.winScore}점부터 고·스톱을 골라요.${go}`;
}
export function helpNow(v: GostopView, me: string | null, players: Record<string, PublicUser>): string {
const nm = (id: string | null | undefined) => {
const n = id ? players[id]?.nickname : undefined;
return n ? `${n}님` : '다른 사람';
};
const seat = me ? v.seats.find((s) => s.id === me) : undefined;
const seatOf = (id: string | null) => v.seats.find((s) => s.id === id);
if (v.phase === 'finished') {
const lines = [`게임이 끝났어요. ${v.result?.summary ?? ''}`.trim()];
lines.push(seat ? `내 방 점수 합계는 ${seat.total > 0 ? '+' : ''}${seat.total}점이에요. '최종 순위'에서 순위를 볼 수 있어요.` : "'최종 순위'에서 순위를 볼 수 있어요.");
if (seat) lines.push('[한 판 더]를 누르면 다시 할 수 있어요.');
return lines.join('\n');
}
if (v.phase === 'roundResult') {
const o = v.outcome;
const lines: string[] = [];
if (!o || o.type === 'nagari') {
const next = v.rules.nagariMultiplier === 'x2' ? Math.min(2 ** v.nagariStreak, v.rules.nagariMaxMultiplier) : 1;
lines.push(`${v.roundNo}판은 나가리예요. 아무도 나지 못해서 점수가 오가지 않았어요.${next > 1 ? ` 다음 판은 점수 x${next}이에요.` : ''}`);
} else {
const who = o.winner && o.winner === me ? '내가' : `${nm(o.winner)}이`;
const how = o.type === 'chongtong' ? '총통으로' : o.type === 'samppeok' ? '삼뻑으로' : '스톱해서';
const d = seat ? (o.delta[seat.id] ?? 0) : 0;
lines.push(`${v.roundNo}판은 ${who} ${how} 이겼어요.${seat ? ` 이번 판 내 점수 변화는 ${d > 0 ? '+' : ''}${d}점이에요.` : ''} 결과 표에서 족보별 점수와 배수를 볼 수 있어요.`);
}
if (seat && !seat.left) {
lines.push(seat.ready ? '다른 사람이 [다음 판으로]를 누르기를 기다리는 중이에요.' : '[다음 판으로]를 눌러 주세요. 10초가 지나면 저절로 넘어가요.');
} else lines.push('모두 준비하면 다음 판이 시작돼요.');
return lines.join('\n');
}
const lines: string[] = [];
if (seat?.autoPilot && !seat.left) lines.push('시간을 두 번 넘겨서 지금은 자동으로 진행돼요. 직접 고르면 자동이 풀려요.');
const mult = v.nagariMult > 1 ? ` 이번 판은 나가리 다음 판이라 점수 x${v.nagariMult}이에요.` : '';
switch (v.step) {
case 'sell': {
const s = v.sell;
const cur = s?.current ?? null;
if (seat && cur === me && s) {
const n = s.mySellable?.length ?? 0;
const gain = n * v.rules.gwangPrice * (v.rules.gwangPayer === 'nonDealerPlayers' ? 2 : 3);
lines.push('광팔기 차례예요. 4~5명이면 이번 판은 3명만 쳐요(선은 꼭 쳐요).');
lines.push(
`[치기]를 누르면 이번 판에 참가해요. ${n > 0 ? `[죽기 · 광 ${n}장 팔기]를 누르면 ${gain}점을 받고 이번 판은 쉬어요.` : '[죽기]를 누르면 팔 패가 없어 그냥 쉬어요.'} 시간이 지나면 죽기로 정해져요.`,
);
} else if (seat) {
const mine = s?.decisions.find((d) => d.player === me);
const self = seat.isDealer ? '나는 선이라 이번 판에 꼭 쳐요.' : mine ? `나는 ${mine.sell ? '죽기(광 팔기)' : '치기'}로 정했어요.` : '내 차례가 오면 칠지 죽을지 골라요.';
lines.push(`${cur ? `${nm(cur)}이 이번 판에 칠지 죽을지 고르는 중이에요.` : '광팔기를 정리하는 중이에요.'} ${self}`);
} else {
lines.push(`${cur ? `${nm(cur)}이 이번 판에 칠지(치기), 광을 팔고 쉴지(죽기) 고르는 중이에요.` : '광팔기를 정리하는 중이에요.'} 4~5명이면 3명만 쳐요.`);
}
return lines.join('\n');
}
case 'chongtong': {
const who = v.chongtongAsk?.player ?? v.turn;
if (seat && who === me) {
const months = v.chongtongAsk?.months ?? [];
const others = v.seats.filter((x) => x.participant && x.id !== me).length;
const each = v.rules.chongtongPoints * v.nagariMult;
lines.push(`총통이에요! 손에 같은 월 4장${months.length ? `(${months.map((m) => `${m}월`).join(', ')})` : ''}이 있어요.`);
lines.push(`[총통 선언]을 누르면 이번 판을 바로 이기고 상대마다 ${each}점, 모두 ${each * others}점을 받아요. [그냥 치기]를 누르면 판을 계속하고, 그 월로 흔들기나 폭탄을 할 수 있어요.`);
lines.push('시간이 지나면 총통 선언으로 정해져요.');
} else {
lines.push(`${nm(who)}이 총통(손에 같은 월 4장)을 선언할지 고르는 중이에요. 선언하면 ${nm(who)}이 이번 판을 바로 이겨요.`);
}
return lines.join('\n');
}
case 'play': {
const who = v.turn;
if (seat && who === me && seat.participant) {
lines.push(`내 차례예요. 손패에서 낼 패를 눌러요(패가 살짝 올라오면 한 번 더 눌러요). 낸 패와 더미에서 뒤집은 패가 바닥의 같은 월 패와 맞으면 함께 가져와요.${mult}`);
const hand = v.myHand ?? [];
const extra: string[] = [];
if (v.rules.hints) {
const canEat = hand.filter((h) => h.matches.length > 0).length;
extra.push(canEat > 0 ? '초록 테두리 패는 바닥에 같은 월이 있어 먹을 수 있어요.' : '지금은 바닥과 맞는 패가 없어요. 덜 아까운 패를 내요.');
const tips = hand.filter((h) => h.hint && h.card.kind !== 'bonus').slice(0, 2);
if (tips.length) extra.push(`힌트: ${tips.map((h) => `${h.card.name} → ${h.hint}`).join(', ')}.`);
}
if (hand.some((h) => h.card.kind === 'bonus')) extra.push('보너스패는 내면 바로 내 피가 되고, 더미에서 1장을 받아 한 번 더 내요.');
if (hand.some((h) => h.canBomb)) extra.push("'폭탄' 표시 패를 누르면 [폭탄!]과 [1장만 내기] 중에서 골라요. 폭탄은 같은 월 3장을 한꺼번에 내서 바닥 1장까지 가져오고, 상대마다 피 1장을 받아요(이기면 x2).");
if (hand.some((h) => h.canShake)) extra.push("'흔들기' 표시 패를 누르면 [흔들고 내기]와 [그냥 내기] 중에서 골라요. 흔들면 같은 월 3장을 보여 주고, 이번 판을 이기면 점수가 2배예요.");
if (hand.some((h) => h.needTarget)) extra.push("바닥에 같은 월이 2장인 패를 내면 '어느 패를 가져올까요?' 창에서 가져올 패를 눌러 골라요.");
if (v.myDummies > 0) extra.push(`[빈패 내기 (${v.myDummies}장 남음)]를 누르면 손패를 쓰지 않고 더미만 뒤집어요.`);
lines.push(...extra);
lines.push(myScoreLine(v, seat));
return lines.join('\n');
}
if (seat && !seat.participant) {
lines.push(`이번 판은 광을 팔고 쉬어요. 지금은 ${nm(who)} 차례예요.`);
return lines.join('\n');
}
if (seat) {
lines.push(`${nm(who)} 차례예요. 패를 낼 때까지 기다려 주세요.${mult}`);
lines.push(myScoreLine(v, seat));
return lines.join('\n');
}
const ws = seatOf(who);
lines.push(`${nm(who)} 차례예요. 손패 1장을 내고 더미에서 1장을 뒤집어, 바닥의 같은 월 패를 가져가요.${mult}`);
if (ws) lines.push(`${nm(who)}은 지금 ${ws.score.total}점이에요${ws.goCount > 0 ? `(${ws.goCount}고)` : ''}. ${v.rules.winScore}점부터 고·스톱을 골라요.`);
return lines.join('\n');
}
case 'chooseFlip': {
const pf = v.pendingFlip;
const who = pf?.player ?? v.turn;
if (seat && who === me && pf) {
lines.push(`뒤집은 ${pf.flipped.name}과(와) 같은 월 패가 바닥에 2장(${pf.options.map((c) => c.name).join(', ')}) 있어요. 창에서 가져올 패를 눌러 골라요.`);
lines.push('시간이 지나면 더 좋은 패를 저절로 가져와요.');
} else {
lines.push(`${nm(who)}이 뒤집은 패${pf ? `(${pf.flipped.name})` : ''}로 가져올 바닥 패를 고르는 중이에요.`);
}
if (seat?.participant && who !== me) lines.push(myScoreLine(v, seat));
return lines.join('\n');
}
case 'goStop': {
const g = v.goStop;
const who = g?.player ?? v.turn;
if (seat && who === me && g) {
const notes = g.stopNow.notes.length ? `(${g.stopNow.notes.join(', ')})` : '';
const baks = g.stopNow.perLoser.filter((x) => x.baks.length).map((x) => `${nm(x.player)} ${x.baks.map((b) => BAK[b]).join('·')}`);
const risk = v.rules.goBak ? (v.mode === 'matgo' ? ' 고를 하고 상대가 먼저 나면 고박으로 2배를 내요.' : ' 고를 하고 다른 사람이 먼저 나면 독박으로 진 사람 몫을 혼자 다 낼 수 있어요.') : ' 고를 하고 다른 사람이 먼저 나면 내가 지게 돼요.';
lines.push(`${g.score}점이 났어요! [고] 또는 [스톱]을 골라요.${g.goCount > 0 ? ` 지금까지 ${g.goCount}고 했어요.` : ''}`);
lines.push(`[스톱]: 판을 끝내고 ${g.stopNow.total}점을 받아요${notes}.${baks.length ? ` ${baks.join(', ')}이 붙어요.` : ''}`);
lines.push(`[고]: ${g.goNext.goCount}고로 판을 계속해요. +${g.goNext.plus}점${g.goNext.factor > 1 ? `, 점수 x${g.goNext.factor}` : ''}.${risk}`);
lines.push('시간이 지나면 스톱으로 정해져요.');
} else {
lines.push(`${nm(who)}이 ${g ? `${g.score}점을 내서 ` : ''}고 또는 스톱을 고르는 중이에요. 스톱하면 이번 판이 끝나요.`);
const mine = g?.stopNow.perLoser.find((x) => x.player === me);
if (seat && mine) lines.push(`${nm(who)}이 지금 스톱하면 나는 ${mine.amount}점을 내요${mine.baks.length ? `(${mine.baks.map((b) => BAK[b]).join('·')})` : ''}.`);
}
return lines.join('\n');
}
case 'gukjin': {
const g = v.gukjinAsk;
const who = g?.player ?? v.turn;
if (seat && who === me && g) {
lines.push('판이 끝나기 전에 내가 가진 9월 열끗(국진)을 어떻게 셀지 골라요.');
lines.push(`[열끗으로]: 내 점수 ${g.yeolScore}점. [쌍피로](피 2장 값): 내 점수 ${g.piScore}점. 시간이 지나면 나에게 유리한 쪽으로 정해져요.`);
} else {
lines.push(`스톱이 나왔어요. ${nm(who)}이 9월 열끗(국진)을 열끗으로 셀지 쌍피로 셀지 정하는 중이에요. 정하면 이번 판 점수를 계산해요.`);
}
return lines.join('\n');
}
default:
lines.push('잠시만 기다려 주세요.');
return lines.join('\n');
}
}