diff --git a/app/lib/game-service.ts b/app/lib/game-service.ts index ecfe973..d0d269c 100644 --- a/app/lib/game-service.ts +++ b/app/lib/game-service.ts @@ -3,20 +3,7 @@ import type { DrizzleD1Database } from "drizzle-orm/d1"; import { gameState, haiyama, kyoku } from "~/lib/db/schema"; import type { Hai } from "~/lib/hai/types"; import { sortTehai } from "~/lib/hai/types"; -import type { GameState } from "~/lib/types"; - -export interface GameStateRecord { - userId: string; - kyoku: number; - junme: number; - remainTsumo: number; - score: number; - haiyama: Hai[]; - sutehai: Hai[]; - tehai: Hai[]; - tsumohai: Hai[]; - haiyamaId: string | null; -} +import type { GameState, GameStateRecord } from "~/lib/types"; export async function getGameState( db: DrizzleD1Database, @@ -40,6 +27,7 @@ export async function initGame( const tehai = tiles.slice(0, 13); const tsumohai = tiles[13] ? [tiles[13]] : []; const remainingHai = tiles.slice(14); + await db.delete(kyoku).where(eq(kyoku.userId, userId)); await db .insert(gameState) @@ -71,35 +59,7 @@ export async function initGame( }); } -/** - * Create a shuffled haiyama with only suited tiles (no jihai) - * Each haiyama has exactly `tileCount` tiles (default 32) - */ -export function createShuffledHaiyama(tileCount = 32): Hai[] { - const tiles: Hai[] = []; - - for (const kind of ["manzu", "pinzu", "souzu"] as const) { - for (let value = 1; value <= 9; value += 1) { - for (let i = 0; i < 4; i += 1) { - tiles.push({ kind, value }); - } - } - } - - // Shuffle - for (let i = tiles.length - 1; i > 0; i -= 1) { - const j = Math.floor(Math.random() * (i + 1)); - [tiles[i], tiles[j]] = [tiles[j], tiles[i]]; - } - - return tiles.slice(0, tileCount); -} - -export async function getRandomHaiyamaOrCreate( - db: DrizzleD1Database, - userId: string, -) { - // Get haiyama IDs the user has already played +export async function getRandomHaiyama(db: DrizzleD1Database, userId: string) { const playedHaiyama = await db .select({ haiyamaId: kyoku.haiyamaId }) .from(kyoku) @@ -107,36 +67,15 @@ export async function getRandomHaiyamaOrCreate( const playedIds = playedHaiyama.map((r) => r.haiyamaId); - // Select a random haiyama that the user hasn't played yet - let randomHaiyama: (typeof haiyama.$inferSelect)[]; if (playedIds.length > 0) { - randomHaiyama = await db + return await db .select() .from(haiyama) .where(notInArray(haiyama.id, playedIds)) .orderBy(sql`RANDOM()`) .limit(1); - } else { - randomHaiyama = await db - .select() - .from(haiyama) - .orderBy(sql`RANDOM()`) - .limit(1); } - - if (randomHaiyama.length > 0) { - return randomHaiyama[0]; - } - - throw new Error("No haiyama available; seed the database first"); -} - -export async function seedHaiyama(db: DrizzleD1Database, count: number) { - const values = Array.from({ length: count }, () => ({ - tiles: createShuffledHaiyama(32), - })); - await db.insert(haiyama).values(values); - return count; + return await db.select().from(haiyama).orderBy(sql`RANDOM()`).limit(1); } export async function tedashi( @@ -238,10 +177,10 @@ export async function restartGame( return { newKyoku, isGameOver: true }; } - const randomHaiyama = await getRandomHaiyamaOrCreate(db, userId); + const randomHaiyama = await getRandomHaiyama(db, userId); - const newHaiyama = randomHaiyama.tiles; - const newHaiyamaId = randomHaiyama.id; + const newHaiyama = randomHaiyama[0].tiles; + const newHaiyamaId = randomHaiyama[0].id; const tehai = newHaiyama.slice(0, 13); const tsumohai = newHaiyama[13] ? [newHaiyama[13]] : []; diff --git a/app/lib/types.ts b/app/lib/types.ts index c369fa1..f5aee12 100644 --- a/app/lib/types.ts +++ b/app/lib/types.ts @@ -1,6 +1,6 @@ import type { Hai } from "./hai/types"; -export interface GameState { +export type GameState = { kyoku: number; junme: number; remainTsumo: number; @@ -9,4 +9,17 @@ export interface GameState { sutehai: Hai[]; tehai: Hai[]; tsumohai: Hai | null; -} +}; + +export type GameStateRecord = { + userId: string; + kyoku: number; + junme: number; + remainTsumo: number; + score: number; + haiyama: Hai[]; + sutehai: Hai[]; + tehai: Hai[]; + tsumohai: Hai[]; + haiyamaId: string | null; +}; diff --git a/app/routes/_index.tsx b/app/routes/_index.tsx index cdc4407..12cc69f 100644 --- a/app/routes/_index.tsx +++ b/app/routes/_index.tsx @@ -7,10 +7,9 @@ export default function Page() { const navigate = useNavigate(); const anonymousLoginAndStart = async () => { const user = await authClient.getSession(); - if (user.data) { - await authClient.signOut(); + if (!user.data) { + await authClient.signIn.anonymous(); } - await authClient.signIn.anonymous(); navigate("/play"); }; return ( diff --git a/app/routes/learn.tsx b/app/routes/learn.tsx index 2ae4945..8072a25 100644 --- a/app/routes/learn.tsx +++ b/app/routes/learn.tsx @@ -1,7 +1,7 @@ import { useEffect } from "react"; import { Link, useLocation } from "react-router"; -import BasicRule from "~/lib/components/BasicRule"; -import LocalRule from "~/lib/components/LocalRule"; +import BasicRule from "./learn/BasicRule"; +import LocalRule from "./learn/LocalRule"; export default function Page() { const location = useLocation(); diff --git a/app/lib/components/BasicRule.tsx b/app/routes/learn/BasicRule.tsx similarity index 100% rename from app/lib/components/BasicRule.tsx rename to app/routes/learn/BasicRule.tsx diff --git a/app/lib/components/LocalRule.tsx b/app/routes/learn/LocalRule.tsx similarity index 100% rename from app/lib/components/LocalRule.tsx rename to app/routes/learn/LocalRule.tsx diff --git a/app/routes/play.tsx b/app/routes/play.tsx index 9807a3b..a275b00 100644 --- a/app/routes/play.tsx +++ b/app/routes/play.tsx @@ -1,154 +1,30 @@ -import { useEffect, useState } from "react"; import { type ShouldRevalidateFunctionArgs, useFetcher } from "react-router"; import { getAuth } from "~/lib/auth"; import { getDB } from "~/lib/db"; import { getGameState, - getRandomHaiyamaOrCreate, + getRandomHaiyama, initGame, toGameState, } from "~/lib/game-service"; import judgeAgari from "~/lib/hai/agari"; import { calculateShanten } from "~/lib/hai/shanten"; import type { Hai } from "~/lib/hai/types"; -import { haiToIndex, indexToHai, sortTehai } from "~/lib/hai/types"; +import { sortTehai } from "~/lib/hai/types"; import { getAgariScoreDelta } from "~/lib/score"; import type { GameState } from "~/lib/types"; import type { Route } from "./+types/play"; - -const SUHAI_KINDS = ["manzu", "pinzu", "souzu"] as const; -const JIHAI_VALUES = [ - "ton", - "nan", - "sya", - "pei", - "haku", - "hatsu", - "tyun", -] as const; -const TILE_IMAGE_PATHS = [ - ...SUHAI_KINDS.flatMap((kind) => - Array.from({ length: 9 }, (_, index) => `/hai/${kind}_${index + 1}.png`), - ), - ...JIHAI_VALUES.map((value) => `/hai/jihai_${value}.png`), -]; -const TOTAL_TSUMO_PER_KYOKU = 18; - -type ShantenAdvanceDiscard = { - hai: Hai; - resultingShanten: number; -}; - -type AgariDetail = - | { - type: "standard"; - mentsu: Hai[][]; - janto: Hai[]; - } - | { - type: "chiitoitsu"; - pairs: Hai[][]; - }; - -function getHaiKey(hai: Hai): string { - return `${hai.kind}-${hai.value}`; -} - -function shantenLabel(shanten: number): string { - if (shanten === -1) return "和了"; - if (shanten === 0) return "テンパイ"; - return `${shanten}シャンテン`; -} - -function getAgariDetail(tehai: Hai[]): AgariDetail | null { - const tehaiIndex = Array(34).fill(0); - for (const hai of tehai) { - tehaiIndex[haiToIndex(hai) - 1] += 1; - } - - const pairIndexes: number[] = []; - for (let i = 0; i < tehaiIndex.length; i++) { - if (tehaiIndex[i] >= 2) { - pairIndexes.push(i); - } - } - - for (const jantoIndex of pairIndexes) { - const withoutJanto = tehaiIndex.concat(); - withoutJanto[jantoIndex] -= 2; - - const koutsuCandidates: number[] = []; - for (let i = 0; i < withoutJanto.length; i++) { - if (withoutJanto[i] >= 3) { - koutsuCandidates.push(i); - } - } - - for (let bit = 0; bit < 1 << koutsuCandidates.length; bit++) { - const remaining = withoutJanto.concat(); - const mentsu: Hai[][] = []; - let isValid = true; - - for (let i = 0; i < koutsuCandidates.length; i++) { - if (bit & (1 << i)) { - const idx = koutsuCandidates[i]; - remaining[idx] -= 3; - if (remaining[idx] < 0) { - isValid = false; - break; - } - const tile = indexToHai(idx + 1); - mentsu.push([tile, tile, tile]); - } - } - if (!isValid) continue; - - for (let kind = 0; kind < 3; kind++) { - for (let i = 0; i <= 6; i++) { - const idx = kind * 9 + i; - while ( - remaining[idx] >= 1 && - remaining[idx + 1] >= 1 && - remaining[idx + 2] >= 1 - ) { - remaining[idx] -= 1; - remaining[idx + 1] -= 1; - remaining[idx + 2] -= 1; - mentsu.push([ - indexToHai(idx + 1), - indexToHai(idx + 2), - indexToHai(idx + 3), - ]); - } - } - } - - if (mentsu.length === 4 && remaining.every((count) => count === 0)) { - const jantoTile = indexToHai(jantoIndex + 1); - return { - type: "standard", - mentsu, - janto: [jantoTile, jantoTile], - }; - } - } - } - - if ( - pairIndexes.length === 7 && - tehaiIndex.every((count) => count === 0 || count === 2) - ) { - return { - type: "chiitoitsu", - pairs: pairIndexes.map((idx) => { - const tile = indexToHai(idx + 1); - return [tile, tile]; - }), - }; - } - - return null; -} +import { AgariModal } from "./play/AgariModal"; +import { computeOptimisticGameState } from "./play/computeOptimisticGameState"; +import { TILE_IMAGE_PATHS } from "./play/constants"; +import { GameHeader } from "./play/GameHeader"; +import { HintPanel } from "./play/HintPanel"; +import { RyukyokuModal } from "./play/RyukyokuModal"; +import { SutehaiDisplay } from "./play/SutehaiDisplay"; +import { TehaiDisplay } from "./play/TehaiDisplay"; +import { useHints } from "./play/useHints"; + +export type IndexedHai = Hai & { index: number }; export const links: Route.LinksFunction = () => TILE_IMAGE_PATHS.map((href) => ({ @@ -172,22 +48,15 @@ export async function loader({ const userId = session.user.id; try { - // Check if game state already exists in D1 const existingState = await getGameState(db, userId); - if (existingState) { return toGameState(existingState); } - // No existing game state, so initialize from haiyama - const randomHaiyama = await getRandomHaiyamaOrCreate(db, userId); - const haiData = randomHaiyama.tiles; - const haiyamaId = randomHaiyama.id; - - // Initialize game state in D1 + const randomHaiyama = await getRandomHaiyama(db, userId); + const { id: haiyamaId, tiles: haiData } = randomHaiyama[0]; await initGame(db, userId, haiyamaId, haiData); - // Get the initialized game state const gameStateRecord = await getGameState(db, userId); if (!gameStateRecord) { throw new Error("Failed to get current game state"); @@ -218,574 +87,101 @@ export function shouldRevalidate({ formAction }: ShouldRevalidateFunctionArgs) { export default function Page({ loaderData }: Route.ComponentProps) { const actionFetcher = useFetcher(); const discardFetcher = useFetcher(); - const [showHints, setShowHints] = useState(false); - const [hintDiscardsByResult, setHintDiscardsByResult] = useState( - new Map(), - ); - const [isAdvanceHint, setIsAdvanceHint] = useState(true); - const fetcherGameState = - discardFetcher.data && "tehai" in discardFetcher.data - ? (discardFetcher.data as GameState) - : null; - - const currentGameState = - fetcherGameState?.kyoku === loaderData.kyoku - ? fetcherGameState - : loaderData; const { - sutehai, - tsumohai, - nextTsumohai, - junme, kyoku, - tehai, - remainTsumo, score, - } = currentGameState; - const baseSortedTehai = sortTehai(tehai); - let optimisticSutehai = sutehai; - let optimisticTehai = baseSortedTehai; - let optimisticTsumohai = tsumohai; - let optimisticJunme = junme; - let optimisticRemainTsumo = remainTsumo; - - if ( - discardFetcher.state !== "idle" && - discardFetcher.formData && - tsumohai !== null - ) { - const nextRemainTsumo = Math.max(0, remainTsumo - 1); - if (discardFetcher.formAction?.endsWith("/api/tedashi")) { - const index = Number(discardFetcher.formData.get("index")); - if ( - Number.isInteger(index) && - index >= 0 && - index < baseSortedTehai.length - ) { - const discardedHai = baseSortedTehai[index]; - const remainingTehai = baseSortedTehai.filter((_, i) => i !== index); - optimisticSutehai = [...sutehai, discardedHai]; - optimisticTehai = sortTehai([...remainingTehai, tsumohai]); - optimisticTsumohai = nextTsumohai; - optimisticJunme = junme + 1; - optimisticRemainTsumo = nextRemainTsumo; - } - } - - if (discardFetcher.formAction?.endsWith("/api/tsumogiri")) { - optimisticSutehai = [...sutehai, tsumohai]; - optimisticTsumohai = nextTsumohai; - optimisticJunme = junme + 1; - optimisticRemainTsumo = nextRemainTsumo; - } - } - const tsumoProgressValue = Math.min( - TOTAL_TSUMO_PER_KYOKU, - Math.max(0, TOTAL_TSUMO_PER_KYOKU - optimisticRemainTsumo), + optimisticTehai, + optimisticTsumohai, + optimisticSutehai, + optimisticJunme, + optimisticRemainTsumo, + tsumoProgressValue, + } = computeOptimisticGameState(loaderData, discardFetcher); + + const shantenResult = calculateShanten(optimisticTehai); + const isHintCalculating = discardFetcher.state !== "idle"; + const { + showHints, + hintDiscards, + isAdvanceHint, + hasAnyHints, + handleHintToggle, + } = useHints( + optimisticTehai, + optimisticTsumohai, + shantenResult, + optimisticJunme, + isHintCalculating, ); - // biome-ignore lint/correctness/useExhaustiveDependencies: reset hints on each junme change - useEffect(() => { - setShowHints(false); - setHintDiscardsByResult(new Map()); - setIsAdvanceHint(true); - }, [optimisticJunme]); const isAgari = optimisticTehai && optimisticTsumohai ? judgeAgari(sortTehai([...optimisticTehai, optimisticTsumohai])) : false; - const agariDetail = - isAgari && optimisticTsumohai - ? getAgariDetail(sortTehai([...optimisticTehai, optimisticTsumohai])) - : null; - const shantenResult = optimisticTehai - ? calculateShanten(optimisticTehai) - : { shanten: 8, isTenpai: false }; const agariScoreDelta = getAgariScoreDelta(optimisticJunme); const isRyukyoku = optimisticRemainTsumo <= 0; const ryukyokuShanten = shantenResult.shanten; const ryukyokuScoreDelta = ryukyokuShanten === 0 ? 3000 : ryukyokuShanten === 1 ? 1000 : 0; - const isHintCalculating = discardFetcher.state !== "idle"; - - const buildHintDiscards = () => { - const shantenAdvanceDiscardsByResult = new Map< - number, - ShantenAdvanceDiscard[] - >(); - const shantenKeepDiscardsByResult = new Map< - number, - ShantenAdvanceDiscard[] - >(); - - if (!optimisticTsumohai) { - return { - hintDiscardsByResult: shantenAdvanceDiscardsByResult, - isAdvanceHint: true, - }; - } - - const currentShanten = shantenResult.shanten; - const evaluatedDiscards: ShantenAdvanceDiscard[] = optimisticTehai - .map((hai, index) => { - const remainingTehai = optimisticTehai.filter((_, i) => i !== index); - const nextTehai = sortTehai([...remainingTehai, optimisticTsumohai]); - return { hai, resultingShanten: calculateShanten(nextTehai).shanten }; - }) - .filter((discard) => discard.resultingShanten < currentShanten); - - const dedupedDiscardMap = new Map(); - for (const discard of evaluatedDiscards) { - const key = `${getHaiKey(discard.hai)}-${discard.resultingShanten}`; - if (!dedupedDiscardMap.has(key)) { - dedupedDiscardMap.set(key, discard); - } - } - - for (const discard of dedupedDiscardMap.values()) { - const current = - shantenAdvanceDiscardsByResult.get(discard.resultingShanten) ?? []; - current.push(discard); - shantenAdvanceDiscardsByResult.set(discard.resultingShanten, current); - } - - if (shantenAdvanceDiscardsByResult.size > 0) { - return { - hintDiscardsByResult: shantenAdvanceDiscardsByResult, - isAdvanceHint: true, - }; - } - - const shantenKeepDiscards = optimisticTehai - .map((hai, index) => { - const remainingTehai = optimisticTehai.filter((_, i) => i !== index); - const nextTehai = sortTehai([...remainingTehai, optimisticTsumohai]); - return { hai, resultingShanten: calculateShanten(nextTehai).shanten }; - }) - .filter((discard) => discard.resultingShanten === currentShanten); - shantenKeepDiscards.push({ - hai: optimisticTsumohai, - resultingShanten: currentShanten, - }); - - const dedupedKeepDiscardMap = new Map(); - for (const discard of shantenKeepDiscards) { - const key = `${getHaiKey(discard.hai)}-${discard.resultingShanten}`; - if (!dedupedKeepDiscardMap.has(key)) { - dedupedKeepDiscardMap.set(key, discard); - } - } - - for (const discard of dedupedKeepDiscardMap.values()) { - const current = - shantenKeepDiscardsByResult.get(discard.resultingShanten) ?? []; - current.push(discard); - shantenKeepDiscardsByResult.set(discard.resultingShanten, current); - } - - return { - hintDiscardsByResult: shantenKeepDiscardsByResult, - isAdvanceHint: false, - }; - }; - - const handleHintToggle = () => { - if (showHints) { - setShowHints(false); - return; - } - - const { hintDiscardsByResult, isAdvanceHint } = buildHintDiscards(); - setHintDiscardsByResult(hintDiscardsByResult); - setIsAdvanceHint(isAdvanceHint); - setShowHints(true); - }; - const hasAnyHints = hintDiscardsByResult.size > 0; - - type IndexedHai = Hai & { index: number }; - - const indexedSutehai: IndexedHai[] = optimisticSutehai.map( - (hai: Hai, index: number) => ({ - ...hai, - index, - }), - ); - const indexedTehai: IndexedHai[] = optimisticTehai.map( - (hai: Hai, index: number) => ({ - ...hai, - index, - }), - ); - const firstRowTehai = indexedTehai.slice(0, 7); - const secondRowTehai = indexedTehai.slice(7); + const indexedSutehai: IndexedHai[] = optimisticSutehai.map((hai, index) => ({ + ...hai, + index, + })); + const indexedTehai: IndexedHai[] = optimisticTehai.map((hai, index) => ({ + ...hai, + index, + })); return (
- {isAgari && ( - -
-

和了

-

獲得スコア: +{agariScoreDelta}

- {optimisticTsumohai && ( -
-

ツモ牌

- {`${optimisticTsumohai.kind} -
- )} - {agariDetail?.type === "standard" && ( -
-

雀頭

-
- {(() => { - const seen = new Map(); - return agariDetail.janto.map((hai) => { - const baseKey = getHaiKey(hai); - const count = (seen.get(baseKey) ?? 0) + 1; - seen.set(baseKey, count); - return ( - {`${hai.kind} - ); - }); - })()} -
-

面子

-
- {(() => { - const seenMentsu = new Map(); - return agariDetail.mentsu.map((mentsu) => { - const mentsuKeyBase = mentsu.map(getHaiKey).join("_"); - const mentsuCount = - (seenMentsu.get(mentsuKeyBase) ?? 0) + 1; - seenMentsu.set(mentsuKeyBase, mentsuCount); - const seenHai = new Map(); - return ( -
- {mentsu.map((hai) => { - const baseKey = getHaiKey(hai); - const count = (seenHai.get(baseKey) ?? 0) + 1; - seenHai.set(baseKey, count); - return ( - {`${hai.kind} - ); - })} -
- ); - }); - })()} -
-
- )} - {agariDetail?.type === "chiitoitsu" && ( -
-

七対子

-
- {(() => { - const seenPairs = new Map(); - return agariDetail.pairs.map((pair) => { - const pairKeyBase = pair.map(getHaiKey).join("_"); - const pairCount = (seenPairs.get(pairKeyBase) ?? 0) + 1; - seenPairs.set(pairKeyBase, pairCount); - const seenHai = new Map(); - return ( -
- {pair.map((hai) => { - const baseKey = getHaiKey(hai); - const count = (seenHai.get(baseKey) ?? 0) + 1; - seenHai.set(baseKey, count); - return ( - {`${hai.kind} - ); - })} -
- ); - }); - })()} -
-
- )} -
- - - - -
-
-
+ {isAgari && optimisticTsumohai && ( + )} {isRyukyoku && ( - -
-

流局

-

- シャンテン: {ryukyokuShanten} / 獲得スコア: +{ryukyokuScoreDelta} -

-
- - - -
-
-
+ )}
-
-
-

- 東{kyoku}局 - - (全4局)巡目: {optimisticJunme} / 18 - -

-
- - スコア: {score} - - - シャンテン:{" "} - {shantenResult.shanten === -1 - ? "和了" - : shantenResult.shanten} - -
-
-
- -
-
+
-
-

- 捨て牌 -

-
- {indexedSutehai.map((hai) => ( - {`${hai.kind} - ))} -
-
-
-
- - {showHints && - !isHintCalculating && - hasAnyHints && - [...hintDiscardsByResult.entries()] - .sort(([a], [b]) => a - b) - .map(([resultingShanten]) => ( - - {isAdvanceHint - ? `${shantenLabel(resultingShanten)}に進む打牌` - : `${shantenLabel(resultingShanten)}を維持する打牌`} - - ))} -
- {showHints && isHintCalculating ? ( -

- 計算中... -

- ) : showHints && !hasAnyHints ? ( -

- なし -

- ) : null} -
-
- {showHints && isHintCalculating ? ( -

- 計算中... -

- ) : showHints && !hasAnyHints ? ( -

なし

- ) : showHints ? ( - [...hintDiscardsByResult.entries()] - .sort(([a], [b]) => a - b) - .map(([resultingShanten, discards]) => ( -
- {discards.map((discard) => ( - {`${discard.hai.kind} - ))} -
- )) - ) : null} -
-
-
+ +

手牌

- -
- {indexedTehai.map((hai) => ( - - - - - ))} - {optimisticTsumohai && ( -
- - - -
- )} -
- -
-
- {firstRowTehai.map((hai) => ( - - - - - ))} -
- -
- {secondRowTehai.map((hai) => ( - - - - - ))} - {optimisticTsumohai && ( - - - - )} -
-
+
diff --git a/app/routes/play/AgariModal.tsx b/app/routes/play/AgariModal.tsx new file mode 100644 index 0000000..2bf6228 --- /dev/null +++ b/app/routes/play/AgariModal.tsx @@ -0,0 +1,243 @@ +import type { FetcherWithComponents } from "react-router"; +import type { Hai } from "~/lib/hai/types"; +import { haiToIndex, indexToHai } from "~/lib/hai/types"; +import { getHaiKey } from "./utils"; + +type AgariDetail = + | { + type: "standard"; + mentsu: Hai[][]; + janto: Hai[]; + } + | { + type: "chiitoitsu"; + pairs: Hai[][]; + }; + +function getAgariDetail(tehai: Hai[]): AgariDetail | null { + const tehaiIndex = Array(34).fill(0); + for (const hai of tehai) { + tehaiIndex[haiToIndex(hai) - 1] += 1; + } + + const pairIndexes: number[] = []; + for (let i = 0; i < tehaiIndex.length; i++) { + if (tehaiIndex[i] >= 2) { + pairIndexes.push(i); + } + } + + for (const jantoIndex of pairIndexes) { + const withoutJanto = tehaiIndex.concat(); + withoutJanto[jantoIndex] -= 2; + + const koutsuCandidates: number[] = []; + for (let i = 0; i < withoutJanto.length; i++) { + if (withoutJanto[i] >= 3) { + koutsuCandidates.push(i); + } + } + + for (let bit = 0; bit < 1 << koutsuCandidates.length; bit++) { + const remaining = withoutJanto.concat(); + const mentsu: Hai[][] = []; + let isValid = true; + + for (let i = 0; i < koutsuCandidates.length; i++) { + if (bit & (1 << i)) { + const idx = koutsuCandidates[i]; + remaining[idx] -= 3; + if (remaining[idx] < 0) { + isValid = false; + break; + } + const tile = indexToHai(idx + 1); + mentsu.push([tile, tile, tile]); + } + } + if (!isValid) continue; + + for (let kind = 0; kind < 3; kind++) { + for (let i = 0; i <= 6; i++) { + const idx = kind * 9 + i; + while ( + remaining[idx] >= 1 && + remaining[idx + 1] >= 1 && + remaining[idx + 2] >= 1 + ) { + remaining[idx] -= 1; + remaining[idx + 1] -= 1; + remaining[idx + 2] -= 1; + mentsu.push([ + indexToHai(idx + 1), + indexToHai(idx + 2), + indexToHai(idx + 3), + ]); + } + } + } + + if (mentsu.length === 4 && remaining.every((count) => count === 0)) { + const jantoTile = indexToHai(jantoIndex + 1); + return { + type: "standard", + mentsu, + janto: [jantoTile, jantoTile], + }; + } + } + } + + if ( + pairIndexes.length === 7 && + tehaiIndex.every((count) => count === 0 || count === 2) + ) { + return { + type: "chiitoitsu", + pairs: pairIndexes.map((idx) => { + const tile = indexToHai(idx + 1); + return [tile, tile]; + }), + }; + } + + return null; +} + +type AgariModalProps = { + optimisticTehai: Hai[]; + optimisticTsumohai: Hai; + agariScoreDelta: number; + optimisticJunme: number; + actionFetcher: FetcherWithComponents; +}; + +export function AgariModal({ + optimisticTehai, + optimisticTsumohai, + agariScoreDelta, + optimisticJunme, + actionFetcher, +}: AgariModalProps) { + const agariDetail = getAgariDetail([...optimisticTehai, optimisticTsumohai]); + + return ( + +
+

和了

+

獲得スコア: +{agariScoreDelta}

+
+

ツモ牌

+ {`${optimisticTsumohai.kind} +
+ {agariDetail?.type === "standard" && ( +
+

雀頭

+
+ {(() => { + const seen = new Map(); + return agariDetail.janto.map((hai) => { + const baseKey = getHaiKey(hai); + const count = (seen.get(baseKey) ?? 0) + 1; + seen.set(baseKey, count); + return ( + {`${hai.kind} + ); + }); + })()} +
+

面子

+
+ {(() => { + const seenMentsu = new Map(); + return agariDetail.mentsu.map((mentsu) => { + const mentsuKeyBase = mentsu.map(getHaiKey).join("_"); + const mentsuCount = (seenMentsu.get(mentsuKeyBase) ?? 0) + 1; + seenMentsu.set(mentsuKeyBase, mentsuCount); + const seenHai = new Map(); + return ( +
+ {mentsu.map((hai) => { + const baseKey = getHaiKey(hai); + const count = (seenHai.get(baseKey) ?? 0) + 1; + seenHai.set(baseKey, count); + return ( + {`${hai.kind} + ); + })} +
+ ); + }); + })()} +
+
+ )} + {agariDetail?.type === "chiitoitsu" && ( +
+

七対子

+
+ {(() => { + const seenPairs = new Map(); + return agariDetail.pairs.map((pair) => { + const pairKeyBase = pair.map(getHaiKey).join("_"); + const pairCount = (seenPairs.get(pairKeyBase) ?? 0) + 1; + seenPairs.set(pairKeyBase, pairCount); + const seenHai = new Map(); + return ( +
+ {pair.map((hai) => { + const baseKey = getHaiKey(hai); + const count = (seenHai.get(baseKey) ?? 0) + 1; + seenHai.set(baseKey, count); + return ( + {`${hai.kind} + ); + })} +
+ ); + }); + })()} +
+
+ )} +
+ + + + +
+
+
+ ); +} diff --git a/app/routes/play/GameHeader.tsx b/app/routes/play/GameHeader.tsx new file mode 100644 index 0000000..6783540 --- /dev/null +++ b/app/routes/play/GameHeader.tsx @@ -0,0 +1,46 @@ +import { TOTAL_TSUMO_PER_KYOKU } from "./constants"; + +type GameHeaderProps = { + kyoku: number; + optimisticJunme: number; + score: number; + shantenResult: { shanten: number }; + tsumoProgressValue: number; +}; + +export function GameHeader({ + kyoku, + optimisticJunme, + score, + shantenResult, + tsumoProgressValue, +}: GameHeaderProps) { + return ( +
+
+

+ 東{kyoku}局 + + (全4局)巡目: {optimisticJunme} / 18 + +

+
+ + スコア: {score} + + + シャンテン:{" "} + {shantenResult.shanten === -1 ? "和了" : shantenResult.shanten} + +
+
+
+ +
+
+ ); +} diff --git a/app/routes/play/HintPanel.tsx b/app/routes/play/HintPanel.tsx new file mode 100644 index 0000000..7eaf720 --- /dev/null +++ b/app/routes/play/HintPanel.tsx @@ -0,0 +1,67 @@ +import type { ShantenAdvanceDiscard } from "./useHints"; +import { getHaiKey, shantenLabel } from "./utils"; + +type HintPanelProps = { + showHints: boolean; + hintDiscards: ShantenAdvanceDiscard[]; + isAdvanceHint: boolean; + hasAnyHints: boolean; + isHintCalculating: boolean; + handleHintToggle: () => void; +}; + +export function HintPanel({ + showHints, + hintDiscards, + isAdvanceHint, + hasAnyHints, + isHintCalculating, + handleHintToggle, +}: HintPanelProps) { + return ( +
+
+ + {showHints && !isHintCalculating && hasAnyHints && ( + + {isAdvanceHint + ? `${shantenLabel(hintDiscards[0].resultingShanten)}に進む打牌` + : `${shantenLabel(hintDiscards[0].resultingShanten)}を維持する打牌`} + + )} +
+ {showHints && isHintCalculating ? ( +

計算中...

+ ) : showHints && !hasAnyHints ? ( +

なし

+ ) : null} +
+
+ {showHints && isHintCalculating ? ( +

計算中...

+ ) : showHints && !hasAnyHints ? ( +

なし

+ ) : showHints ? ( +
+ {hintDiscards.map((discard) => ( + {`${discard.hai.kind} + ))} +
+ ) : null} +
+
+
+ ); +} diff --git a/app/routes/play/RyukyokuModal.tsx b/app/routes/play/RyukyokuModal.tsx new file mode 100644 index 0000000..0fe08c0 --- /dev/null +++ b/app/routes/play/RyukyokuModal.tsx @@ -0,0 +1,35 @@ +import type { FetcherWithComponents } from "react-router"; + +type RyukyokuModalProps = { + ryukyokuShanten: number; + ryukyokuScoreDelta: number; + actionFetcher: FetcherWithComponents; +}; + +export function RyukyokuModal({ + ryukyokuShanten, + ryukyokuScoreDelta, + actionFetcher, +}: RyukyokuModalProps) { + return ( + +
+

流局

+

+ シャンテン: {ryukyokuShanten} / 獲得スコア: +{ryukyokuScoreDelta} +

+
+ + + +
+
+
+ ); +} diff --git a/app/routes/play/SutehaiDisplay.tsx b/app/routes/play/SutehaiDisplay.tsx new file mode 100644 index 0000000..57d9674 --- /dev/null +++ b/app/routes/play/SutehaiDisplay.tsx @@ -0,0 +1,23 @@ +import type { IndexedHai } from "../play"; + +type SutehaiDisplayProps = { + indexedSutehai: IndexedHai[]; +}; + +export function SutehaiDisplay({ indexedSutehai }: SutehaiDisplayProps) { + return ( +
+

捨て牌

+
+ {indexedSutehai.map((hai) => ( + {`${hai.kind} + ))} +
+
+ ); +} diff --git a/app/routes/play/TehaiDisplay.tsx b/app/routes/play/TehaiDisplay.tsx new file mode 100644 index 0000000..927a59f --- /dev/null +++ b/app/routes/play/TehaiDisplay.tsx @@ -0,0 +1,125 @@ +// app/routes/play/TehaiDisplay.tsx +import type { FetcherWithComponents } from "react-router"; +import type { Hai } from "~/lib/hai/types"; +import type { GameState } from "~/lib/types"; +import type { IndexedHai } from "../play"; + +type TehaiButtonProps = { + hai: IndexedHai; + discardFetcher: FetcherWithComponents; +}; + +function TehaiButton({ hai, discardFetcher }: TehaiButtonProps) { + return ( + + + + + ); +} + +type TsumoButtonProps = { + optimisticTsumohai: Hai; + discardFetcher: FetcherWithComponents; + className: string; +}; + +function TsumoButton({ + optimisticTsumohai, + discardFetcher, + className, +}: TsumoButtonProps) { + return ( + + + + ); +} + +type TehaiDisplayProps = { + indexedTehai: IndexedHai[]; + optimisticTsumohai: Hai | null; + discardFetcher: FetcherWithComponents; +}; + +export function TehaiDisplay({ + indexedTehai, + optimisticTsumohai, + discardFetcher, +}: TehaiDisplayProps) { + const firstRowTehai = indexedTehai.slice(0, 7); + const secondRowTehai = indexedTehai.slice(7); + + return ( + <> + {/* デスクトップ */} +
+ {indexedTehai.map((hai) => ( + + ))} + {optimisticTsumohai && ( +
+ +
+ )} +
+ + {/* モバイル */} +
+
+ {firstRowTehai.map((hai) => ( + + ))} +
+
+ {secondRowTehai.map((hai) => ( + + ))} + {optimisticTsumohai && ( + + )} +
+
+ + ); +} diff --git a/app/routes/play/computeOptimisticGameState.ts b/app/routes/play/computeOptimisticGameState.ts new file mode 100644 index 0000000..2ae71c9 --- /dev/null +++ b/app/routes/play/computeOptimisticGameState.ts @@ -0,0 +1,83 @@ +import type { Fetcher } from "react-router"; +import { sortTehai } from "~/lib/hai/types"; +import type { GameState } from "~/lib/types"; +import { TOTAL_TSUMO_PER_KYOKU } from "./constants"; + +export function computeOptimisticGameState( + loaderData: GameState, + discardFetcher: Fetcher, +) { + const fetcherGameState = + discardFetcher.data && "tehai" in discardFetcher.data + ? discardFetcher.data + : null; + + const currentGameState = + fetcherGameState?.kyoku === loaderData.kyoku + ? fetcherGameState + : loaderData; + + const { + sutehai, + tsumohai, + nextTsumohai, + junme, + kyoku, + tehai, + remainTsumo, + score, + } = currentGameState; + + const baseSortedTehai = sortTehai(tehai); + + let optimisticSutehai = sutehai; + let optimisticTehai = baseSortedTehai; + let optimisticTsumohai = tsumohai; + let optimisticJunme = junme; + let optimisticRemainTsumo = remainTsumo; + + if ( + discardFetcher.state !== "idle" && + discardFetcher.formData && + tsumohai !== null + ) { + const nextRemainTsumo = Math.max(0, remainTsumo - 1); + + if (discardFetcher.formAction?.endsWith("/api/tedashi")) { + const index = Number(discardFetcher.formData.get("index")); + if ( + Number.isInteger(index) && + index >= 0 && + index < baseSortedTehai.length + ) { + const discardHai = baseSortedTehai[index]; + const remainingTehai = baseSortedTehai.filter((_, i) => i !== index); + optimisticSutehai = [...sutehai, discardHai]; + optimisticTehai = sortTehai([...remainingTehai, tsumohai]); + optimisticTsumohai = nextTsumohai; + optimisticJunme = junme + 1; + optimisticRemainTsumo = nextRemainTsumo; + } + } else if (discardFetcher.formAction?.endsWith("/api/tsumogii")) { + optimisticSutehai = [...sutehai, tsumohai]; + optimisticTsumohai = nextTsumohai; + optimisticJunme = junme + 1; + optimisticRemainTsumo = nextRemainTsumo; + } + } + const tsumoProgressValue = Math.max( + 0, + TOTAL_TSUMO_PER_KYOKU - optimisticRemainTsumo, + ); + return { + currentGameState, + kyoku, + score, + optimisticTehai, + optimisticTsumohai, + optimisticSutehai, + optimisticJunme, + optimisticRemainTsumo, + tsumoProgressValue, + }; +} diff --git a/app/routes/play/constants.ts b/app/routes/play/constants.ts new file mode 100644 index 0000000..09a3dd1 --- /dev/null +++ b/app/routes/play/constants.ts @@ -0,0 +1,20 @@ +export const TOTAL_TSUMO_PER_KYOKU = 18; + +const SUHAI_KINDS = ["manzu", "pinzu", "souzu"] as const; + +const JIHAI_VALUES = [ + "ton", + "nan", + "sya", + "pei", + "haku", + "hatsu", + "tyun", +] as const; + +export const TILE_IMAGE_PATHS = [ + ...SUHAI_KINDS.flatMap((kind) => + Array.from({ length: 9 }, (_, index) => `/hai/${kind}_${index + 1}.png`), + ), + ...JIHAI_VALUES.map((value) => `/hai/jihai_${value}.png`), +]; diff --git a/app/routes/play/useHints.ts b/app/routes/play/useHints.ts new file mode 100644 index 0000000..4d6c7a7 --- /dev/null +++ b/app/routes/play/useHints.ts @@ -0,0 +1,97 @@ +import { useEffect, useState } from "react"; +import { calculateShanten } from "~/lib/hai/shanten"; +import type { Hai } from "~/lib/hai/types"; +import { sortTehai } from "~/lib/hai/types"; +import { getHaiKey } from "./utils"; + +export type ShantenAdvanceDiscard = { + hai: Hai; + resultingShanten: number; +}; + +function dedupe(discards: ShantenAdvanceDiscard[]): ShantenAdvanceDiscard[] { + const seen = new Map(); + for (const discard of discards) { + const key = `${getHaiKey(discard.hai)}-${discard.resultingShanten}`; + if (!seen.has(key)) seen.set(key, discard); + } + return [...seen.values()]; +} + +function buildHintDiscards( + optimisticTehai: Hai[], + optimisticTsumohai: Hai, + currentShanten: number, +): { + discards: ShantenAdvanceDiscard[]; + isAdvanceHint: boolean; +} { + const allDiscards = optimisticTehai.map((hai, index) => { + const remainingTehai = optimisticTehai.filter((_, i) => i !== index); + const nextTehai = sortTehai([...remainingTehai, optimisticTsumohai]); + return { hai, resultingShanten: calculateShanten(nextTehai).shanten }; + }); + + const minShanten = Math.min(...allDiscards.map((d) => d.resultingShanten)); + + if (minShanten < currentShanten) { + return { + discards: dedupe( + allDiscards.filter((d) => d.resultingShanten === minShanten), + ), + isAdvanceHint: true, + }; + } + + return { + discards: dedupe([ + ...allDiscards.filter((d) => d.resultingShanten === currentShanten), + { hai: optimisticTsumohai, resultingShanten: currentShanten }, + ]), + isAdvanceHint: false, + }; +} + +export function useHints( + optimisticTehai: Hai[], + optimisticTsumohai: Hai | null, + shantenResult: { shanten: number }, + optimisticJunme: number, + isHintCalculating: boolean, +) { + const [showHints, setShowHints] = useState(false); + const [hintDiscards, setHintDiscards] = useState([]); + const [isAdvanceHint, setIsAdvanceHint] = useState(true); + + // biome-ignore lint/correctness/useExhaustiveDependencies: reset hints on each junme change + useEffect(() => { + setShowHints(false); + setHintDiscards([]); + setIsAdvanceHint(true); + }, [optimisticJunme]); + + const handleHintToggle = () => { + if (showHints) { + setShowHints(false); + return; + } + if (!optimisticTsumohai || isHintCalculating) return; + + const result = buildHintDiscards( + optimisticTehai, + optimisticTsumohai, + shantenResult.shanten, + ); + setHintDiscards(result.discards); + setIsAdvanceHint(result.isAdvanceHint); + setShowHints(true); + }; + + return { + showHints, + hintDiscards, + isAdvanceHint, + hasAnyHints: hintDiscards.length > 0, + handleHintToggle, + }; +} diff --git a/app/routes/play/utils.ts b/app/routes/play/utils.ts new file mode 100644 index 0000000..24e3d45 --- /dev/null +++ b/app/routes/play/utils.ts @@ -0,0 +1,11 @@ +import type { Hai } from "~/lib/hai/types"; + +export function getHaiKey(hai: Hai): string { + return `${hai.kind}-${hai.value}`; +} + +export function shantenLabel(shanten: number): string { + if (shanten === -1) return "和了"; + if (shanten === 0) return "テンパイ"; + return `${shanten}シャンテン`; +}