Skip to content

Commit 4d60966

Browse files
authored
プレイ画面を作成 (#109)
* fomrat * load haiyama * create endpoint and show sample image * iikannji
1 parent e38e34a commit 4d60966

File tree

9 files changed

+275
-13
lines changed

9 files changed

+275
-13
lines changed

app/lib/auth.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import { betterAuth } from "better-auth";
22
import { drizzleAdapter } from "better-auth/adapters/drizzle";
33
import { anonymous } from "better-auth/plugins";
4-
import { eq } from "drizzle-orm";
4+
import * as schema from "../lib/db/schema";
55
import { getDB } from "./db";
66

77
export function getAuth(env?: Env) {
88
const auth = betterAuth({
99
database: drizzleAdapter(getDB(env), {
1010
provider: "pg",
11+
schema: schema,
1112
}),
1213
emailAndPassword: {
1314
enabled: true,
@@ -27,5 +28,5 @@ export function getAuth(env?: Env) {
2728
});
2829
return auth;
2930
}
30-
31-
export const auth = getAuth();
31+
// This is for @better-auth/cli
32+
// export const auth = getAuth();

app/lib/hai.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,3 +97,17 @@ export function haiToDBHai(hai: Hai, haiyamaId: string, order: number): DBHai {
9797
index: haiToIndex(hai),
9898
};
9999
}
100+
101+
export function dbHaiToHai(dbHai: DBHai): Hai {
102+
if (dbHai.kind === "jihai") {
103+
return {
104+
kind: dbHai.kind,
105+
value: dbHai.value as JihaiValue,
106+
};
107+
} else {
108+
return {
109+
kind: dbHai.kind as SuhaiKind,
110+
value: Number(dbHai.value),
111+
};
112+
}
113+
}

app/lib/redis.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ export function getRedisClient(env: Env) {
99
return client;
1010
}
1111

12-
interface GameState {
12+
export interface GameState {
1313
kyoku: number;
1414
junme: number;
1515
haiyama: Hai[];
@@ -123,3 +123,17 @@ export const jikyoku = async (
123123
};
124124
await setGameState(client, userId, newGameState);
125125
};
126+
127+
export const getCurrentGameState = async (
128+
client: ReturnType<typeof createClient>,
129+
userId: string,
130+
): Promise<GameState | null> => {
131+
return await getGameState(client, userId);
132+
};
133+
134+
export const deleteGameState = async (
135+
client: ReturnType<typeof createClient>,
136+
userId: string,
137+
) => {
138+
await client.del(`user:${userId}:game`);
139+
};

app/routes/_index.tsx

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { drizzle as drizzleNeon } from "drizzle-orm/neon-http";
22
import { drizzle as drizzlePg } from "drizzle-orm/node-postgres";
3-
import { Link } from "react-router";
3+
import type { c } from "node_modules/better-auth/dist/shared/better-auth.C9FmyZ5W.cjs";
4+
import { Link, useNavigate } from "react-router";
5+
import { authClient } from "~/lib/auth-client";
46
import { getRedisClient } from "~/lib/redis";
57
import type { Route } from "./+types/_index";
68

@@ -19,16 +21,28 @@ export async function loader({ context }: Route.LoaderArgs) {
1921
}
2022

2123
export default function Page() {
24+
const navigate = useNavigate();
25+
const anonymousLoginAndStart = async () => {
26+
const user = await authClient.getSession();
27+
if (!user) {
28+
const _user = await authClient.signIn.anonymous();
29+
}
30+
navigate("/play");
31+
};
2232
return (
2333
<>
2434
<h1 className="text-5xl text-center pb-1">Hitori Mahjong</h1>
25-
<Link to="/start">
26-
<p className="text-center link">Get Started</p>
27-
</Link>
28-
29-
<Link to="/learn">
30-
<p className="text-center link">Learn How to Play</p>
31-
</Link>
35+
<div className="flex justify-center items-center flex-col gap-4 py-4">
36+
<button onClick={anonymousLoginAndStart} className="link" type="button">
37+
Play as Guest
38+
</button>
39+
<Link to="/login" className="link">
40+
Login
41+
</Link>
42+
<Link to="/learn" className="link">
43+
Learn How to Play
44+
</Link>
45+
</div>
3246
</>
3347
);
3448
}

app/routes/login.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export default function Page() {
2+
return (
3+
<>
4+
<p>Log in</p>
5+
</>
6+
);
7+
}

app/routes/play.tedashi.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { redirect } from "react-router";
2+
import { getAuth } from "~/lib/auth";
3+
import { type GameState, getRedisClient, tedashi } from "~/lib/redis";
4+
import type { Route } from "./+types/play.tedashi";
5+
6+
export async function action({ context, request }: Route.ActionArgs) {
7+
const { env } = context.cloudflare;
8+
const auth = getAuth(env);
9+
const session = await auth.api.getSession({ headers: request.headers });
10+
11+
if (!session?.user?.id) {
12+
throw new Response("Unauthorized", { status: 401 });
13+
}
14+
const userId = session.user.id;
15+
16+
const formData = await request.formData();
17+
const index = Number(formData.get("index"));
18+
19+
if (isNaN(index)) {
20+
throw new Response("Invalid index", { status: 400 });
21+
}
22+
23+
const redisClient = getRedisClient(env);
24+
await redisClient.connect();
25+
26+
try {
27+
await tedashi(redisClient, userId, index);
28+
const gameStateJSON = await redisClient.get(`user:${userId}:game`);
29+
const gameState = gameStateJSON ? JSON.parse(gameStateJSON) : null;
30+
31+
await redisClient.quit();
32+
return redirect("/play");
33+
} catch (error) {
34+
await redisClient.quit();
35+
const errorMessage = error instanceof Error ? error.message : String(error);
36+
throw new Response(errorMessage, { status: 400 });
37+
}
38+
}

app/routes/play.tsumogiri.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { redirect } from "react-router";
2+
import { getAuth } from "~/lib/auth";
3+
import { type GameState, getRedisClient, tsumogiri } from "~/lib/redis";
4+
import type { Route } from "./+types/play.tsumogiri";
5+
6+
export async function action({ context, request }: Route.ActionArgs) {
7+
const { env } = context.cloudflare;
8+
const auth = getAuth(env);
9+
const session = await auth.api.getSession({ headers: request.headers });
10+
11+
if (!session?.user?.id) {
12+
throw new Response("Unauthorized", { status: 401 });
13+
}
14+
const userId = session.user.id;
15+
16+
const redisClient = getRedisClient(env);
17+
await redisClient.connect();
18+
19+
try {
20+
await tsumogiri(redisClient, userId);
21+
const gameStateJSON = await redisClient.get(`user:${userId}:game`);
22+
const gameState = gameStateJSON ? JSON.parse(gameStateJSON) : null;
23+
24+
await redisClient.quit();
25+
return redirect("/play");
26+
} catch (error) {
27+
await redisClient.quit();
28+
const errorMessage = error instanceof Error ? error.message : String(error);
29+
throw new Response(errorMessage, { status: 400 });
30+
}
31+
}

app/routes/play.tsx

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import { sql } from "drizzle-orm";
2+
import { Form } from "react-router";
3+
import { getAuth } from "~/lib/auth";
4+
import { getDB } from "~/lib/db";
5+
import { hai, haiyama } from "~/lib/db/schema";
6+
import { dbHaiToHai, sortTehai } from "~/lib/hai";
7+
import { type GameState, getRedisClient, init } from "~/lib/redis";
8+
import type { Route } from "./+types/play";
9+
10+
export async function loader({
11+
context,
12+
request,
13+
}: Route.LoaderArgs): Promise<GameState> {
14+
const { env } = context.cloudflare;
15+
const db = getDB(env);
16+
const auth = getAuth(env);
17+
const session = await auth.api.getSession({ headers: request.headers });
18+
19+
if (!session?.user?.id) {
20+
throw new Response("Unauthorized", { status: 401 });
21+
}
22+
const userId = session.user.id;
23+
24+
// Check if game state already exists in Redis
25+
const redisClient = getRedisClient(env);
26+
await redisClient.connect();
27+
28+
try {
29+
const existingGameState = await redisClient.get(`user:${userId}:game`);
30+
31+
if (existingGameState) {
32+
// Return existing game state from Redis
33+
await redisClient.quit();
34+
return JSON.parse(existingGameState);
35+
}
36+
37+
// No existing game state, so initialize from PostgreSQL
38+
const randomHaiyama = await db
39+
.select()
40+
.from(haiyama)
41+
.orderBy(sql`RANDOM()`)
42+
.limit(1);
43+
44+
if (randomHaiyama.length === 0) {
45+
await redisClient.quit();
46+
throw new Response("No haiyama found", { status: 404 });
47+
}
48+
49+
const selectedHaiyama = randomHaiyama[0];
50+
const rawHaiData = await db
51+
.select()
52+
.from(hai)
53+
.where(sql`${hai.haiyamaId} = ${selectedHaiyama.id}`)
54+
.orderBy(hai.order);
55+
56+
const haiData = rawHaiData.map((hai) => dbHaiToHai(hai));
57+
58+
// Initialize game state in Redis
59+
await init(redisClient, userId, haiData);
60+
61+
// Get the initialized game state to return
62+
const gameStateJSON = await redisClient.get(`user:${userId}:game`);
63+
const gameState = gameStateJSON ? JSON.parse(gameStateJSON) : null;
64+
65+
await redisClient.quit();
66+
return gameState;
67+
} catch (error) {
68+
await redisClient.quit();
69+
throw error instanceof Error ? error : new Error(String(error));
70+
}
71+
}
72+
73+
export default function Page({ loaderData }: Route.ComponentProps) {
74+
let { haiyama, sutehai, tsumohai, junme, kyoku, tehai } = loaderData;
75+
tehai = sortTehai(tehai);
76+
const indexedSutehai = sutehai.map((hai, index) => ({
77+
...hai,
78+
index: index,
79+
}));
80+
const indexedTehai = tehai.map((hai, index) => ({
81+
...hai,
82+
index: index,
83+
}));
84+
85+
return (
86+
<div className="p-4">
87+
<p className="text-xl mb-4">
88+
Play Page - 局 {kyoku} 巡目 {junme}
89+
</p>
90+
91+
{/* Sutehai grid (3x6) */}
92+
<div className="mb-6">
93+
<h3 className="text-lg mb-2">捨て牌</h3>
94+
<div className="grid grid-cols-6 gap-0 w-max min-h-48">
95+
{indexedSutehai.map((hai) => (
96+
<img
97+
key={hai.index}
98+
src={`/hai/${hai.kind}_${hai.value}.png`}
99+
alt={`${hai.kind} ${hai.value}`}
100+
className="w-12 h-16"
101+
/>
102+
))}
103+
</div>
104+
</div>
105+
106+
{/* Tehai and Tsumohai */}
107+
<div className="flex items-center gap-4">
108+
<div>
109+
<h3 className="text-lg mb-2">手牌</h3>
110+
<div className="flex gap-0">
111+
{indexedTehai.map((hai) => (
112+
<Form key={hai.index} method="post" action="/play/tedashi">
113+
<input type="hidden" name="index" value={hai.index} />
114+
<button type="submit">
115+
<img
116+
src={`/hai/${hai.kind}_${hai.value}.png`}
117+
alt={`${hai.kind} ${hai.value}`}
118+
className="w-12 h-16 cursor-pointer hover:scale-105 transition-transform"
119+
/>
120+
</button>
121+
</Form>
122+
))}
123+
</div>
124+
</div>
125+
126+
{tsumohai && (
127+
<div>
128+
<h3 className="text-lg mb-2">ツモ牌</h3>
129+
<Form method="post" action="/play/tsumogiri">
130+
<button type="submit">
131+
<img
132+
src={`/hai/${tsumohai.kind}_${tsumohai.value}.png`}
133+
alt={`${tsumohai.kind} ${tsumohai.value}`}
134+
className="w-12 h-16 object-contain cursor-pointer hover:scale-105 transition-transform"
135+
/>
136+
</button>
137+
</Form>
138+
</div>
139+
)}
140+
</div>
141+
</div>
142+
);
143+
}

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
"postinstall": "npm run cf-typegen",
1212
"preview": "bun run build && vite preview",
1313
"typecheck": "npm run cf-typegen && react-router typegen && tsc -b",
14-
"format": "bunx @biomejs/biome format . --write"
14+
"format": "bunx @biomejs/biome check . --write"
1515
},
1616
"dependencies": {
1717
"@neondatabase/serverless": "^1.0.2",

0 commit comments

Comments
 (0)