/** 마작 [?] "지금 할 수 있는 것" (docs/07 §5). Pure: only reads the client view (my own tiles + public info). */ import type { HandResultView, MahjongView, MyView, WaitView } from '@bg/games/mahjong'; import type { PublicUser } from '@bg/shared/lite'; const WIND = ['동', '남', '서', '북']; const HONOR = ['동', '남', '서', '북', '백', '발', '중']; const SUIT: Record = { m: '만', p: '통', s: '삭' }; const ABORT: Record = { kyuushu: '구종구패', suufon: '사풍연타', suukan: '사깡유국', suucha: '사가리치', sanchaho: '삼가화' }; const LIMIT: Record = { mangan: '만관', haneman: '하네만', baiman: '배만', sanbaiman: '삼배만', kazoe: '헤아림 역만', yakuman: '역만' }; /** "5m0r" / "5m" → "5만", "1z" → "동" (same as Tile.tsx tileShort). */ const short = (code: string) => (code[1] === 'z' ? (HONOR[Number(code[0]) - 1] ?? '자패') : `${code[0]}${SUIT[code[1] ?? ''] ?? ''}`); const fmt = (n: number) => n.toLocaleString('ko-KR'); const signed = (n: number) => (n > 0 ? `+${fmt(n)}` : n < 0 ? `-${fmt(-n)}` : '0'); /** 을/를 by the last syllable. */ const eulReul = (w: string) => { const c = w.charCodeAt(w.length - 1); return c >= 0xac00 && c <= 0xd7a3 && (c - 0xac00) % 28 === 0 ? `${w}를` : `${w}을`; }; /** Same format as the screen: "3삭(2장), 6삭(4장, 역 없음)". */ const waitText = (ws: WaitView[]) => ws.map((w) => `${short(w.kind)}(${w.left}장${w.yaku ? '' : ', 역 없음'})`).join(', '); const roundLabel = (x: { roundWind: number; handNumber: number; honba: number }) => `${WIND[x.roundWind] ?? ''}${x.handNumber + 1}국${x.honba ? ` ${x.honba}본장` : ''}`; export function helpNow(v: MahjongView, me: string | null, players: Record): string { const nameOf = (seat: number | null | undefined) => { const id = seat === null || seat === undefined ? undefined : v.seats[seat]?.id; const n = id ? players[id]?.nickname : undefined; return n ? `${n}님` : '다른 사람'; }; const my = me !== null ? v.my : null; const sanma = v.mode === 'sanma'; const calls = sanma ? '퐁·깡' : '치·퐁·깡'; if (v.phase === 'finished') return finished(v, me, players); if (v.phase === 'handResult') return handResult(v, my, nameOf); // ---- playing const riichiSeats = v.seats.filter((s) => s.riichi && s.seat !== my?.seat).map((s) => nameOf(s.seat)); const riichiLine = riichiSeats.length ? `리치한 사람: ${riichiSeats.join(', ')}.` : ''; if (v.stage === 'claim' && v.claimOn) { const c = v.claimOn; const tile = `${short(c.tile)} 패`; const mine = !!my && c.from === my.seat; const who = mine ? '내가' : `${nameOf(c.from)}이`; const what = c.source === 'discard' ? `${who} ${eulReul(tile)} 버렸어요.` : c.source === 'kakan' ? `${who} ${eulReul(tile)} 가깡(퐁에 1장 더 붙이기)했어요.` : c.source === 'ankan' ? `${who} ${short(c.tile)} 4장으로 안깡했어요.` : `${who} 북을 옆에 빼 두었어요(북 뽑기).`; if (my?.claim && !my.claim.responded) return claimHelp(v, my, what, calls); if (my?.claim?.responded) return `${what}\n응답했어요. 다른 사람이 고르기를 기다리는 중이에요.`; const how = `${c.source === 'discard' ? `울기(${calls})나 ` : ''}론`; const lines = [`${what} 다른 사람이 이 패로 ${how}을 할지 확인하는 중이에요.`]; lines.push('잠깐 기다리면 다음 차례로 넘어가요.'); if (my) waitsLine(v, my, lines); return lines.join('\n'); } const turn = v.turn; if (turn === null || (v.stage !== 'afterDraw' && v.stage !== 'afterCall')) return '잠시만 기다려 주세요. 곧 다음 차례가 시작돼요.'; if (!my) { const lines = [v.stage === 'afterCall' ? `${nameOf(turn)} 차례예요. 방금 울어서(${calls}) 패를 가져오지 않고 바로 1장을 버려요.` : `${nameOf(turn)} 차례예요. 1장을 가져오고(쯔모) 1장을 버려요.`]; lines.push(`${roundLabel(v)} · 남은 패 ${v.wallCount}장.${riichiLine ? ` ${riichiLine}` : ''}`); lines.push('손패 4면자(같은 패 3장이나 이어진 3장) + 머리(같은 패 2장)를 먼저 완성하는 사람이 이겨요(화료).'); return lines.join('\n'); } if (!my.canDiscard) { const lines = [`${nameOf(turn)} 차례예요. 기다려 주세요.`]; if (my.autoPilot) lines.push('지금 자리 비움 상태라 자동으로 쳐요. 아무 패나 누르면 돌아와요.'); waitsLine(v, my, lines); if (riichiLine) lines.push(riichiLine); return lines.join('\n'); } return myTurn(v, my, riichiSeats.length > 0, calls); } /** 리치·텐파이 대기패 / 후리텐 (only what the view already has). */ function waitsLine(v: MahjongView, my: MyView, lines: string[]) { if (my.riichi) lines.push('나는 리치 중이에요. 화료패(손패를 완성하는 패)가 나오면 [론!], 내가 가져오면 [쯔모!]가 떠요.'); if (my.waits.length) { const noYaku = my.waits.some((w) => !w.yaku); lines.push(`텐파이(1장만 더 오면 완성)예요. 대기패: ${waitText(my.waits)}.${noYaku ? ' "역 없음" 패로는 론할 수 없어요.' : ''}`); } if (my.furiten && (my.waits.length || my.riichi)) lines.push('후리텐이에요: 내 대기패를 전에 버렸거나 론을 넘겨서 론은 못 하고, 내가 직접 가져오는 쯔모로만 화료할 수 있어요.'); const beginnerOn = v.options.beginnerMode && my.prefs.beginner; if (beginnerOn && my.yakuHints.length) lines.push(`💡 ${my.yakuHints.slice(0, 2).join(' ')}`); } function myTurn(v: MahjongView, my: MyView, othersRiichi: boolean, calls: string): string { const beginnerOn = v.options.beginnerMode && my.prefs.beginner; const lines: string[] = []; if (my.canTsumo) lines.push('화료할 수 있어요! [쯔모!]를 누르면 가져온 패로 손패 완성(쯔모 화료)이에요.'); if (my.riichi) { lines.push(`내 차례예요. 리치 중이라 손패를 바꿀 수 없어서 방금 가져온 패만 버릴 수 있어요(나머지는 흐리게 보여요).`); } else if (v.stage === 'afterCall') { lines.push(`내 차례예요. 방금 울었으니(${calls}) 패를 가져오지 않고 바로 1장을 버려요.`); if (my.locked.length) lines.push('🔒 표시 패는 방금 울은 패와 같은 패(또는 스지)라 지금 버릴 수 없어요.'); } else { lines.push(`내 차례예요. ${my.drawn ? `방금 가져온 패는 ${short(my.drawn)} 패예요(맨 오른쪽에 조금 떨어진 패). ` : ''}손패에서 1장을 골라 버려요.`); } lines.push(`${my.riichi ? '가져온 패를' : '버릴 패를'} 누른 뒤 한 번 더 누르거나 [○○ 버리기] 버튼을 누르면 버려요(한 번에 놓기 설정이면 바로 버려져요).`); if (my.riichiDiscards.length) lines.push('[리치]: 텐파이(1장만 더 오면 완성)라 리치할 수 있어요. [리치]를 누르고 밝은 패를 버리면 1,000점을 내고 리치를 걸어요. 리치는 역(점수가 나는 조건)이 되지만, 그다음부터는 가져온 패만 버려요.'); const tenpai = Object.keys(my.tenpaiAfter); if (beginnerOn && !my.riichi && tenpai.length) { const kinds = [...new Set(tenpai.map(short))]; lines.push(`'텐' 표시가 붙은 패(${kinds.slice(0, 4).join(', ')}${kinds.length > 4 ? ' 등' : ''})를 버리면 텐파이예요. 패를 누르면 기다리는 패와 남은 장수가 아래에 나와요.`); } if (my.ankan.length || my.kakan.length) lines.push('[깡]: 같은 패 4장을 모아 깡을 할 수 있어요. 깡을 하면 패를 1장 더 가져와요.'); if (my.canKita) lines.push('[북 뽑기]: 북을 옆에 빼 두고 패를 1장 더 가져와요. 빼 둔 북은 도라(보너스 1판)예요.'); if (my.canKyuushu) lines.push('[구종구패(유국)]: 1·9·자패가 9종류 이상이라 이번 국을 무승부로 끝낼 수 있어요(안 해도 돼요).'); if (my.furiten && !my.canTsumo) lines.push('후리텐이에요: 론은 못 하고 쯔모로만 화료할 수 있어요.'); if (othersRiichi && !my.riichi && !my.canTsumo) lines.push('리치한 사람이 이미 버린 패와 같은 패는 그 사람에게 론 당하지 않아요.'); if (beginnerOn && my.yakuHints.length) lines.push(`💡 ${my.yakuHints.slice(0, 2).join(' ')}`); else if (!beginnerOn && v.options.beginnerMode) lines.push("'내 설정'에서 초보 모드를 켜면 텐파이 표시와 대기패·역 힌트를 볼 수 있어요."); lines.push(v.stage === 'afterDraw' ? '시간이 지나면 방금 가져온 패를 저절로 버려요.' : '시간이 지나면 패 1장이 저절로 버려져요.'); return lines.join('\n'); } function claimHelp(v: MahjongView, my: MyView, what: string, calls: string): string { const c = my.claim!; const lines = [what]; if (c.ron) lines.push('이 패로 화료할 수 있어요! [론!]을 누르면 남이 낸 패로 손패 완성(론 화료)이에요.'); if (c.pon.length) lines.push('[퐁]: 같은 패 2장에 이 패를 더해 3장을 내놓아요.'); if (c.kan.length) lines.push('[깡]: 같은 패 3장에 이 패를 더해 4장을 내놓고, 패를 1장 더 가져와요.'); if (c.chi.length) lines.push(`[치${c.chi.length > 1 ? ` (${c.chi.length}가지)` : ''}]: 바로 앞 사람이 버린 패로 이어진 3장을 만들어요.${c.chi.length > 1 ? ' 누른 뒤 쓸 조합을 골라요.' : ''}`); lines.push(`[패스]: 그냥 넘겨요. 시간이 지나면 저절로 패스돼요.${c.ron ? ' 론을 넘기면 후리텐이 되어 한동안(리치 중이면 이번 국 끝까지) 론을 못 해요.' : ''}`); if ((c.pon.length || c.kan.length || c.chi.length) && !my.riichi) lines.push(`울면(${calls}) 리치를 못 해요. 역패·탕야오처럼 다른 역(점수가 나는 조건)이 있어야 화료할 수 있어요.`); if (v.mode === 'sanma' && c.chi.length === 0 && (c.pon.length || c.kan.length)) lines.push('3인 마작은 치가 없어요.'); return lines.join('\n'); } function handResult(v: MahjongView, my: MyView | null, nameOf: (seat: number | null | undefined) => string): string { const r = v.result as HandResultView | null; const lines: string[] = []; if (!r) lines.push('이번 국이 끝났어요.'); else if (r.kind === 'agari') { for (const w of r.winners) { const how = w.from === null ? '쯔모(직접 가져온 패로 완성)' : `론(${nameOf(w.from)}이 버린 패로 완성)`; const yaku = w.yaku.filter((y) => !/^(도라|적도라|우라도라|북) /.test(y.name)).map((y) => y.name); const size = w.limit === 'yakuman' ? (w.yakuman > 1 ? `${w.yakuman}배 역만` : '역만') : `${w.han}판 ${w.fu}부${LIMIT[w.limit] ? ` · ${LIMIT[w.limit]}` : ''}`; lines.push(`${roundLabel(r)}: ${nameOf(w.seat)} ${how} 화료예요. 역: ${yaku.join(', ') || '없음'} · ${size} · ${fmt(w.points)}점.`); } } else if (r.kind === 'exhaustive') { lines.push(`${roundLabel(r)}: 패가 다 떨어져 유국(무승부)이에요. 텐파이(1장만 더 오면 완성)였던 사람: ${r.tenpai.length ? r.tenpai.map(nameOf).join(', ') : '없음'}.`); if (r.nagashi.length) lines.push(`유국만관: ${r.nagashi.map(nameOf).join(', ')}.`); else if (r.tenpai.length && r.tenpai.length < v.n) lines.push('텐파이가 아닌 사람이 텐파이인 사람에게 점수를 줘요.'); } else { lines.push(`${roundLabel(r)}: 도중유국(${ABORT[r.abortiveReason ?? ''] ?? '중간에 무효'})이에요. 점수는 그대로예요.`); } if (r && my) { const d = r.deltas[my.seat] ?? 0; lines.push(`내 점수: ${signed(d)} → ${fmt(r.pointsAfter[my.seat] ?? 0)}점.`); } const ready = my ? v.seats[my.seat]?.ready : false; const next = v.gameOver ? '대국이 끝나요.' : v.nextHand ? `다음은 ${roundLabel(v.nextHand)}이에요.` : ''; if (!my) lines.push(`${next} 모두 확인하면 넘어가요.`.trim()); else if (ready) lines.push(`${next} 다른 사람을 기다리는 중이에요 (${v.seats.filter((s) => s.ready).length}/${v.n}).`.trim()); else lines.push(`${next} 표를 확인하고 [${v.gameOver ? '최종 결과 보기' : '다음 국으로'}]를 눌러 주세요. 시간이 지나면 저절로 넘어가요.`.trim()); return lines.join('\n'); } function finished(v: MahjongView, me: string | null, players: Record): string { const f = v.final; const lines = [`대국이 끝났어요.${f ? ` ${f.summary}` : ''}`]; const mine = f?.ranking.find((x) => x.player === me); if (mine) lines.push(`나는 ${mine.rank}위 · ${fmt(mine.points)}점이에요.`); else if (f?.ranking[0]) lines.push(`1위는 ${players[f.ranking[0].player]?.nickname ? `${players[f.ranking[0].player]!.nickname}님` : '다른 사람'}이에요.`); lines.push('최종 결과 표의 "결과(우마·오카)"는 순위 보너스를 더한 놀이용 점수예요. 결과 창에서 [한 판 더]를 누르면 다시 할 수 있어요.'); return lines.join('\n'); }