/** 고스톱 [?] "지금 할 수 있는 것" (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 = { 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 { 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'); } }