From f06074a804605a74a764b8dcd42d1e9d3d2efc17 Mon Sep 17 00:00:00 2001 From: nakomochi Date: Fri, 17 Oct 2025 16:16:30 +0900 Subject: [PATCH 1/4] =?UTF-8?q?=E3=83=AB=E3=83=BC=E3=83=A0=E3=83=9E?= =?UTF-8?q?=E3=83=83=E3=83=81=E9=83=A8=E5=88=86=E3=82=92=E5=88=87=E3=82=8A?= =?UTF-8?q?=E5=88=86=E3=81=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/backend/src/magic.ts | 636 +++++++++++--------------------------- apps/backend/src/room.ts | 199 ++++++++++++ 2 files changed, 386 insertions(+), 449 deletions(-) create mode 100644 apps/backend/src/room.ts diff --git a/apps/backend/src/magic.ts b/apps/backend/src/magic.ts index 579ee76..5d7b6eb 100644 --- a/apps/backend/src/magic.ts +++ b/apps/backend/src/magic.ts @@ -1,14 +1,13 @@ -import { DurableObject } from "cloudflare:workers"; import type { Env } from "hono/types"; import { type Mission, missions } from "./mission"; +import { RoomMatch, type RoomState } from "./room"; + +// --- Game-specific Types --- -// 定数 -const DEFAULT_BOARD_SIZE = 3; -const DEFAULT_TIME_LIMIT_MS = 10000; -// timeout let timeout: ReturnType; -// 型 +export type Operation = "add" | "sub"; + export type MoveAction = { x: number; y: number; @@ -22,47 +21,20 @@ export type Rule = | { rule: "boardSize"; state: number } | { rule: "timeLimit"; state: number }; -export type Operation = "add" | "sub"; - -export type GameState = { - status: "preparing" | "playing" | "paused"; - players: string[]; - playerStatus: { - [playerId: string]: - | "preparing" - | "ready" - | "playing" - | "finished" - | "error"; - }; - names: { - [playerId: string]: string; - }; - round: number; // 0-indexed - turn: number; // 0 ~ players.length-1 players[turn]'s turn +// GameState extends RoomState to include game-specific properties +export type GameState = RoomState & { + round: number; + turn: number; board: (number | null)[][]; winners: string[] | null; - winnersAry: { - [playerId: string]: (true | false)[][]; - }; + winnersAry: { [playerId: string]: (true | false)[][] }; gameId: string; - hands: { - [playerId: string]: number[]; - }; - missions: { - [playerId: string]: { - id: string; - mission: Mission; - }; - }; - rules: { - negativeDisabled: boolean; - boardSize: number; - timeLimit: number; - }; + hands: { [playerId: string]: number[] }; + missions: { [playerId: string]: { id: string; mission: Mission } }; timeLimitUnix: number; }; +// Combined message types for both room and game actions export type MessageType = | { type: "makeMove"; payload: MoveAction } | { type: "setReady"; payload?: undefined } @@ -72,126 +44,77 @@ export type MessageType = | { type: "backToLobby"; payload?: undefined } | { type: "removePlayer"; payload?: undefined }; -interface Session { - ws: WebSocket; - playerId: string; -} - -export class Magic extends DurableObject { - gameState: GameState | undefined = undefined; - sessions: Session[] = []; +const DEFAULT_BOARD_SIZE = 3; +const DEFAULT_TIME_LIMIT_MS = 10000; +export class Magic extends RoomMatch { constructor(ctx: DurableObjectState, env: Env) { super(ctx, env); - this.ctx.blockConcurrencyWhile(async () => { - this.gameState = await this.ctx.storage.get("gameState"); - }); + this.state = undefined; // Initialize state, will be loaded in super's constructor } - async fetch(request: Request) { - const url = new URL(request.url); - const playerId = url.searchParams.get("playerId"); - const playerName = url.searchParams.get("playerName"); - if (!playerId) { - return new Response("playerId is required", { status: 400 }); - } else if (!playerName) { - return new Response("playerName is required", { status: 400 }); - } - - if (request.headers.get("Upgrade") !== "websocket") { - return new Response("Expected websocket", { status: 400 }); - } - - const { 0: client, 1: server } = new WebSocketPair(); - - if (!this.gameState) await this.initialize(); - - await this.handleSession(server, playerId, playerName); - - return new Response(null, { - status: 101, - webSocket: client, - }); - } - - async handleSession(ws: WebSocket, playerId: string, playerName: string) { - const session: Session = { ws, playerId }; - this.sessions.push(session); - - ws.accept(); - - await this.addPlayer(playerId, playerName); - - ws.addEventListener("message", async (msg) => { - try { - // TODO: 型をつける ✅ - const { type, payload } = JSON.parse(msg.data as string) as MessageType; - switch (type) { - case "makeMove": - await this.makeMove( - playerId, - payload.x, - payload.y, - payload.num, - payload.operation, - payload.numIndex, - ); - break; - case "setReady": - await this.setReady(playerId); - break; - case "cancelReady": - await this.cancelReady(playerId); - break; - case "changeRule": - await this.changeRule(payload); - break; - case "backToLobby": - await this.backToLobby(playerId); - break; - case "removePlayer": - await this.removePlayer(playerId); - break; - case "pass": - await this.pass(); - break; - default: - throw new Error(`Unhandled message type: ${type}`); - } - } catch { - ws.send(JSON.stringify({ error: "Invalid message" })); - } - }); - - const closeOrErrorHandler = () => { - this.sessions = this.sessions.filter((s) => s !== session); - this.updateDisconnectedPlayer(playerId); - }; - ws.addEventListener("close", closeOrErrorHandler); - ws.addEventListener("error", closeOrErrorHandler); - - // Send current state to the newly connected client - ws.send(JSON.stringify({ type: "state", payload: this.gameState })); - } - - broadcast(message: unknown) { - const serialized = JSON.stringify(message); - this.sessions.forEach((session) => { - try { - session.ws.send(serialized); - } catch { - this.sessions = this.sessions.filter((s) => s !== session); + async wsMessageListener( + ws: WebSocket, + message: MessageEvent, + playerId: string, + ) { + try { + const { type, payload } = JSON.parse( + message.data as string, + ) as MessageType; + switch (type) { + // Game actions + case "makeMove": + await this.makeMove( + playerId, + payload.x, + payload.y, + payload.num, + payload.operation, + payload.numIndex, + ); + break; + case "pass": + await this.pass(); + break; + // Room actions (from base class) + case "setReady": + await this.setReady(playerId); + break; + case "cancelReady": + await this.cancelReady(playerId); + break; + case "changeRule": + await this.changeRule(payload); + break; + case "removePlayer": + await this.removePlayer(playerId); + break; + case "backToLobby": + await this.backToLobby(playerId); + break; + default: + throw new Error(`Unhandled message type: ${type}`); } - }); + } catch { + ws.send(JSON.stringify({ error: "Invalid message" })); + } } - // --- Game Logic Methods --- - + // called once when the Durable Object is first created async initialize() { - this.gameState = { + this.state = { + // RoomState properties + status: "preparing", players: [], playerStatus: {}, names: {}, + rules: { + negativeDisabled: false, + boardSize: DEFAULT_BOARD_SIZE, + timeLimit: DEFAULT_TIME_LIMIT_MS / 1000, + }, + // GameState specific properties round: 0, turn: 0, board: [], @@ -200,155 +123,78 @@ export class Magic extends DurableObject { gameId: this.ctx.id.toString(), hands: {}, missions: {}, - status: "preparing", - rules: { - negativeDisabled: false, - boardSize: DEFAULT_BOARD_SIZE, - timeLimit: DEFAULT_TIME_LIMIT_MS / 1000, - }, timeLimitUnix: Date.now() + DEFAULT_TIME_LIMIT_MS, }; - await this.ctx.storage.put("gameState", this.gameState); - this.broadcast({ type: "state", payload: this.gameState }); + await this.ctx.storage.put("gameState", this.state); + this.broadcast({ type: "state", payload: this.state }); } - async addPlayer(playerId: string, playerName: string) { - if (!this.gameState) { - console.error("Game state is not initialized"); - return; - } - console.log( - "Adding player:", - playerId, - playerName, - this.gameState.players.includes(playerId), - ); - - // New player - if (!this.gameState.players.includes(playerId)) { - switch (this.gameState.status) { - case "preparing": - if (!this.gameState.players.includes(playerId)) { - this.gameState.players.push(playerId); - this.gameState.names[playerId] = playerName; - this.gameState.playerStatus[playerId] = "preparing"; - - await this.ctx.storage.put("gameState", this.gameState); - this.broadcast({ type: "state", payload: this.gameState }); - } - break; - case "playing": - console.error("Game already started, cannot join now."); - break; - case "paused": - if (this.gameState.players.includes(playerId)) { - this.gameState.playerStatus[playerId] = "playing"; - if ( - Object.values(this.gameState.playerStatus).every( - (status) => status === "playing", - ) - ) { - console.log("All players reconnected, resuming game."); - this.gameState.status = "playing"; - } else { - console.log("Waiting for other players to reconnect."); - } - await this.ctx.storage.put("gameState", this.gameState); - this.broadcast({ type: "state", payload: this.gameState }); - } else { - console.error("Game already started, cannot join now."); - } - break; - default: - this.gameState.status satisfies never; - } - } else { - // Reconnecting player - if (this.gameState.playerStatus[playerId] !== "error") { - throw new Error( - `Player is already connected but tried to connect again: ${this.gameState.playerStatus[playerId]}`, - ); - } - switch (this.gameState.status) { - case "preparing": - this.gameState.playerStatus[playerId] = "preparing"; - break; - case "playing": - throw new Error("Game already started, but trying to reconnect."); - case "paused": - this.gameState.playerStatus[playerId] = "playing"; - if ( - Object.values(this.gameState.playerStatus).every( - (status) => status === "playing", - ) - ) { - console.log("All players reconnected, resuming game."); - this.gameState.status = "playing"; - } else { - console.log("Waiting for other players to reconnect."); - } - break; - default: - this.gameState.status satisfies never; - } - - await this.ctx.storage.put("gameState", this.gameState); - this.broadcast({ type: "state", payload: this.gameState }); - } - } - - async updateDisconnectedPlayer(playerId: string) { - if (!this.gameState) throw new Error("Game state is not initialized"); + async changeRule(payload: Rule) { + if (!this.state || this.state.status !== "preparing") return; - if (!this.gameState.players.includes(playerId)) { - console.error("Player not found in game:", playerId); - return; - } - this.gameState.playerStatus[playerId] = "error"; - if (this.gameState.status !== "preparing") { - this.gameState.status = "paused"; + if (payload.rule === "negativeDisabled") { + this.state.rules.negativeDisabled = payload.state; + } else if (payload.rule === "boardSize") { + this.state.rules.boardSize = payload.state; + this.state.board = Array(payload.state) + .fill(null) + .map(() => Array(payload.state).fill(null)); + } else if (payload.rule === "timeLimit") { + this.state.rules.timeLimit = payload.state; } - - await this.ctx.storage.put("gameState", this.gameState); - this.broadcast({ type: "state", payload: this.gameState }); + await this.ctx.storage.put("gameState", this.state); + this.broadcast({ type: "state", payload: this.state }); } - async startGame() { - if (!this.gameState || this.gameState.status !== "preparing") return; - this.clearGameState(); - for (const playerId of this.gameState.players) { - if (this.gameState.playerStatus[playerId] !== "ready") { + override async startGame(): Promise { + if (!this.state || this.state.status !== "preparing") return; + // The original implementation called a method to clear parts of the state. + // We will replicate that behavior by resetting the game-specific state here. + const size = this.state.rules.boardSize; + this.state.board = Array(size) + .fill(null) + .map(() => Array(size).fill(null)); + this.state.round = 0; + this.state.turn = 0; + this.state.winners = null; + this.state.winnersAry = {}; + this.state.hands = {}; + this.state.missions = {}; + + for (const playerId of this.state.players) { + if (this.state.playerStatus[playerId] !== "ready") { console.error("one of the players not ready:", playerId); return; } - this.gameState.playerStatus[playerId] = "playing"; + this.state.playerStatus[playerId] = "playing"; - if (this.gameState.hands[playerId]) { + if (this.state.hands[playerId]) { console.error("player already has a hand:", playerId); return; } - this.gameState.hands[playerId] = this.drawInitialHand(); + this.state.hands[playerId] = this.drawInitialHand(); - if (this.gameState.missions[playerId]) { + if (this.state.missions[playerId]) { console.error("player already has a mission:", playerId); return; } - this.gameState.missions[playerId] = this.getRandomMission(); + this.state.missions[playerId] = this.getRandomMission(); } - this.gameState.status = "playing"; - this.gameState.timeLimitUnix = - Date.now() + this.gameState.rules.timeLimit * 1000; + this.state.status = "playing"; + this.state.timeLimitUnix = Date.now() + this.state.rules.timeLimit * 1000; clearTimeout(timeout); timeout = setTimeout(() => { this.pass(); - }, this.gameState.rules.timeLimit * 1000); + }, this.state.rules.timeLimit * 1000); - await this.ctx.storage.put("gameState", this.gameState); - this.broadcast({ type: "state", payload: this.gameState }); + await this.ctx.storage.put("gameState", this.state); + this.broadcast({ type: "state", payload: this.state }); } + // --- Game-specific Methods --- + drawInitialHand() { - if (!this.gameState) return []; + if (!this.state) return []; const hand = new Array(3); // TODO: 変更可能にする for (let i = 0; i < hand.length; i++) { hand[i] = this.drawCard(); @@ -386,7 +232,7 @@ export class Magic extends DurableObject { operation: Operation, numIndex: number, ) { - if (!this.gameState || this.gameState.winners) return; + if (!this.state || this.state.winners) return; if (!this.isValidMove(player, x, y, num)) { console.error("Invalid move attempted:", player, x, y, num); @@ -395,172 +241,66 @@ export class Magic extends DurableObject { console.log("Making move:", player, x, y, num); - this.gameState.board[y][x] = this.computeCellResult(x, y, num, operation); + this.state.board[y][x] = this.computeCellResult(x, y, num, operation); - if (this.gameState.turn === this.gameState.players.length - 1) { - this.gameState.round += 1; + if (this.state.turn === this.state.players.length - 1) { + this.state.round += 1; } - this.gameState.turn = - (this.gameState.turn + 1) % this.gameState.players.length; + this.state.turn = (this.state.turn + 1) % this.state.players.length; - const prevHand = this.gameState.hands[player]; + const prevHand = this.state.hands[player]; - this.gameState.hands[player] = prevHand.toSpliced( - numIndex, - 1, - this.drawCard(), - ); + this.state.hands[player] = prevHand.toSpliced(numIndex, 1, this.drawCard()); - for (const id of this.gameState.players) { - const winary = this.isVictory(this.gameState.missions[id].mission); + for (const id of this.state.players) { + const winary = this.isVictory(this.state.missions[id].mission); if (winary.some((row) => row.includes(true))) { - if (!this.gameState) throw new Error("Game state is not initialized"); - this.gameState.winnersAry[id] = winary; - if (!this.gameState.winners) { - this.gameState.winners = [id]; - } else if (!this.gameState.winners.includes(id)) { - this.gameState.winners.push(id); + if (!this.state) throw new Error("Game state is not initialized"); + this.state.winnersAry[id] = winary; + if (!this.state.winners) { + this.state.winners = [id]; + } else if (!this.state.winners.includes(id)) { + this.state.winners.push(id); } console.log("winary", winary); - console.log("this.gameState.winnersAry", this.gameState.winnersAry); + console.log("this.state.winnersAry", this.state.winnersAry); } } - if (this.gameState.winners) { - this.gameState.status = "preparing"; - Object.keys(this.gameState.playerStatus).forEach((playerId) => { - if (!this.gameState) throw new Error("Game state is not initialized"); - this.gameState.playerStatus[playerId] = "finished"; + if (this.state.winners) { + this.state.status = "preparing"; + Object.keys(this.state.playerStatus).forEach((playerId) => { + if (!this.state) throw new Error("Game state is not initialized"); + this.state.playerStatus[playerId] = "finished"; }); } - this.gameState.timeLimitUnix = - Date.now() + this.gameState.rules.timeLimit * 1000; + this.state.timeLimitUnix = Date.now() + this.state.rules.timeLimit * 1000; clearTimeout(timeout); - if (!this.gameState.winners) + if (!this.state.winners) timeout = setTimeout(() => { this.pass(); - }, this.gameState.rules.timeLimit * 1000); - await this.ctx.storage.put("gameState", this.gameState); - this.broadcast({ type: "state", payload: this.gameState }); - } - - async setReady(player: string) { - if (!this.gameState) return; - if (this.gameState.playerStatus[player] !== "preparing") { - console.error("Player not in preparing state:", player); - return; - } - this.gameState.playerStatus[player] = "ready"; - if ( - this.gameState.players.length >= 2 && - this.gameState.players.every( - (p) => this.gameState?.playerStatus[p] === "ready", - ) - ) { - this.startGame(); - } else { - await this.ctx.storage.put("gameState", this.gameState); - this.broadcast({ type: "state", payload: this.gameState }); - } - } - async cancelReady(player: string) { - if (!this.gameState) return; - if (this.gameState.playerStatus[player] !== "ready") { - console.error("Player not in ready state:", player); - return; - } - this.gameState.playerStatus[player] = "preparing"; - await this.ctx.storage.put("gameState", this.gameState); - this.broadcast({ type: "state", payload: this.gameState }); - } - async backToLobby(playerId: string) { - if (!this.gameState) return; - this.gameState.playerStatus[playerId] = "preparing"; - - await this.ctx.storage.put("gameState", this.gameState); - this.broadcast({ type: "state", payload: this.gameState }); - } - - async removePlayer(playerId: string) { - if (!this.gameState) return; - - this.gameState.players = this.gameState.players.filter( - (p) => p !== playerId, - ); - - delete this.gameState.playerStatus[playerId]; - delete this.gameState.names[playerId]; - delete this.gameState.hands[playerId]; - delete this.gameState.missions[playerId]; - - if (this.gameState.players.length === 0) { - await this.ctx.storage.delete("gameState"); - return; - } - - await this.ctx.storage.put("gameState", this.gameState); - this.broadcast({ type: "state", payload: this.gameState }); - } - - async clearGameState() { - if (!this.gameState) return; - - const size = this.gameState.rules.boardSize; - - this.gameState = { - ...structuredClone(this.gameState), - board: Array(size) - .fill(null) - .map(() => Array(size).fill(null)), - round: 0, - turn: 0, - winners: null, - winnersAry: {}, - hands: {}, - missions: {}, - }; - - console.log(this.gameState); - await this.ctx.storage.delete("gameState"); - this.broadcast({ type: "state", payload: this.gameState }); - } - - async changeRule(payload: Rule) { - if (!this.gameState) return; - if (payload.rule === "negativeDisabled") { - this.gameState.rules.negativeDisabled = payload.state; - } else if (payload.rule === "boardSize") { - this.gameState.rules.boardSize = payload.state; - this.gameState.board = Array(payload.state) - .fill(null) - .map(() => Array(payload.state).fill(null)); - } else if (payload.rule === "timeLimit") { - this.gameState.rules.timeLimit = payload.state; - } - console.log(this.gameState.rules); - await this.ctx.storage.put("gameState", this.gameState); - this.broadcast({ type: "state", payload: this.gameState }); + }, this.state.rules.timeLimit * 1000); + await this.ctx.storage.put("gameState", this.state); + this.broadcast({ type: "state", payload: this.state }); } async pass() { - if (!this.gameState) return; - if (this.gameState.turn === this.gameState.players.length - 1) { - this.gameState.round += 1; + if (!this.state) return; + if (this.state.turn === this.state.players.length - 1) { + this.state.round += 1; } - this.gameState.turn = - (this.gameState.turn + 1) % this.gameState.players.length; - this.gameState.timeLimitUnix = - Date.now() + this.gameState.rules.timeLimit * 1000; + this.state.turn = (this.state.turn + 1) % this.state.players.length; + this.state.timeLimitUnix = Date.now() + this.state.rules.timeLimit * 1000; clearTimeout(timeout); timeout = setTimeout(() => { this.pass(); - }, this.gameState.rules.timeLimit * 1000); - await this.ctx.storage.put("gameState", this.gameState); - this.broadcast({ type: "state", payload: this.gameState }); + }, this.state.rules.timeLimit * 1000); + await this.ctx.storage.put("gameState", this.state); + this.broadcast({ type: "state", payload: this.state }); } isValidMove(player: string, x: number, y: number, num: number) { - if (!this.gameState) throw new Error("Game state is not initialized"); + if (!this.state) throw new Error("Game state is not initialized"); // TODO: 調整可能にする if (!Number.isInteger(num) || num < 1 || num > 4) { @@ -568,13 +308,13 @@ export class Magic extends DurableObject { return false; } - const currentPlayer = this.gameState.players[this.gameState.turn]; + const currentPlayer = this.state.players[this.state.turn]; if (currentPlayer !== player) { console.error("Not your turn:", player); return false; } - const currentHand = this.gameState.hands[currentPlayer]; + const currentHand = this.state.hands[currentPlayer]; if (!currentHand || currentHand.length === 0) { console.error("Invalid hand:", currentPlayer); return false; @@ -584,7 +324,7 @@ export class Magic extends DurableObject { return false; } - if (this.gameState.board[y][x] === undefined) { + if (this.state.board[y][x] === undefined) { console.error("Invalid board position:", x, y); return false; } @@ -598,15 +338,15 @@ export class Magic extends DurableObject { num: number, operation: Operation, ): number { - if (!this.gameState) throw new Error("Game state is not initialized"); + if (!this.state) throw new Error("Game state is not initialized"); - const prev = this.gameState.board[y][x] ?? 0; + const prev = this.state.board[y][x] ?? 0; switch (operation) { case "add": return prev + num; case "sub": - return num > prev && this.gameState.rules.negativeDisabled + return num > prev && this.state.rules.negativeDisabled ? num - prev : prev - num; default: @@ -615,30 +355,28 @@ export class Magic extends DurableObject { } isVictory(mission: Mission) { - if (!this.gameState) throw new Error("Game state is not initialized"); - const matrix = Array.from({ length: this.gameState.rules.boardSize }, () => - Array(this.gameState?.rules.boardSize).fill(false), + if (!this.state) throw new Error("Game state is not initialized"); + const matrix = Array.from({ length: this.state.rules.boardSize }, () => + Array(this.state?.rules.boardSize).fill(false), ); if (mission.target === "column" || mission.target === "allDirection") { - for (let i = 0; i < this.gameState.rules.boardSize; i++) { - const columnary = this.gameState.board[i].filter( - (value) => value !== null, - ); + for (let i = 0; i < this.state.rules.boardSize; i++) { + const columnary = this.state.board[i].filter((value) => value !== null); if (this.isWinner(columnary, mission)) { - matrix[i] = Array(this.gameState.rules.boardSize).fill(true); + matrix[i] = Array(this.state.rules.boardSize).fill(true); } } } if (mission.target === "row" || mission.target === "allDirection") { - for (let i = 0; i < this.gameState.rules.boardSize; i++) { + for (let i = 0; i < this.state.rules.boardSize; i++) { const nullinary = []; - for (let j = 0; j < this.gameState.rules.boardSize; j++) { - nullinary.push(this.gameState.board[j][i]); + for (let j = 0; j < this.state.rules.boardSize; j++) { + nullinary.push(this.state.board[j][i]); } const rowary = nullinary.filter((value) => value !== null); if (this.isWinner(rowary, mission)) { - for (let j = 0; j < this.gameState.rules.boardSize; j++) { + for (let j = 0; j < this.state.rules.boardSize; j++) { matrix[j][i] = true; } } @@ -648,24 +386,24 @@ export class Magic extends DurableObject { if (mission.target === "diagonal" || mission.target === "allDirection") { for (let i = 0; i < 2; i++) { const nullinary = []; - for (let j = 0; j < this.gameState.rules.boardSize; j++) { + for (let j = 0; j < this.state.rules.boardSize; j++) { if (i === 0) { nullinary.push( - this.gameState.board[j][this.gameState.rules.boardSize - j - 1], + this.state.board[j][this.state.rules.boardSize - j - 1], ); } else { nullinary.push( - this.gameState.board[this.gameState.rules.boardSize - j - 1][j], + this.state.board[this.state.rules.boardSize - j - 1][j], ); } } const diaary = nullinary.filter((value) => value !== null); if (this.isWinner(diaary, mission)) { - for (let j = 0; j < this.gameState.rules.boardSize; j++) { + for (let j = 0; j < this.state.rules.boardSize; j++) { if (i === 0) { - matrix[j][this.gameState.rules.boardSize - j - 1] = true; + matrix[j][this.state.rules.boardSize - j - 1] = true; } else { - matrix[this.gameState.rules.boardSize - j - 1][j] = true; + matrix[this.state.rules.boardSize - j - 1][j] = true; } } } @@ -674,9 +412,9 @@ export class Magic extends DurableObject { if (mission.target === "allCell") { const nullinary = []; - for (let i = 0; i < this.gameState.rules.boardSize; i++) { - for (let j = 0; j < this.gameState.rules.boardSize; j++) { - nullinary.push(this.gameState.board[i][j]); + for (let i = 0; i < this.state.rules.boardSize; i++) { + for (let j = 0; j < this.state.rules.boardSize; j++) { + nullinary.push(this.state.board[i][j]); } } const boardary = nullinary.filter((value) => value !== null); @@ -721,11 +459,11 @@ export class Magic extends DurableObject { } isWinner(obary: number[], mission: Mission) { - if (!this.gameState) throw new Error("Game state is not initialized"); - if (obary.length === this.gameState.rules.boardSize) { + if (!this.state) throw new Error("Game state is not initialized"); + if (obary.length === this.state.rules.boardSize) { if (mission.type === "sum") { let hikaku = 0; - for (let j = 0; j < this.gameState.rules.boardSize; j++) { + for (let j = 0; j < this.state.rules.boardSize; j++) { hikaku += obary[j]; } if (hikaku === mission.number) { @@ -734,7 +472,7 @@ export class Magic extends DurableObject { } if (mission.type === "multipile") { let hikaku = 0; - for (let j = 0; j < this.gameState.rules.boardSize; j++) { + for (let j = 0; j < this.state.rules.boardSize; j++) { hikaku += obary[j] % mission.number; } if (hikaku === 0) { @@ -744,35 +482,35 @@ export class Magic extends DurableObject { if (mission.type === "arithmetic") { obary.sort((first, second) => first - second); let hikaku = 0; - for (let j = 1; j < this.gameState.rules.boardSize; j++) { + for (let j = 1; j < this.state.rules.boardSize; j++) { if (obary[j] - obary[j - 1] === mission.number) { hikaku += 1; } } - if (hikaku === this.gameState.rules.boardSize - 1) { + if (hikaku === this.state.rules.boardSize - 1) { return true; } } if (mission.type === "geometic") { obary.sort((first, second) => first - second); let hikaku = 0; - for (let j = 1; j < this.gameState.rules.boardSize; j++) { + for (let j = 1; j < this.state.rules.boardSize; j++) { if (obary[j] === obary[j - 1] * mission.number) { hikaku += 1; } } - if (hikaku === this.gameState.rules.boardSize - 1) { + if (hikaku === this.state.rules.boardSize - 1) { return true; } } if (mission.type === "prime") { let hikaku = 0; - for (let j = 0; j < this.gameState.rules.boardSize; j++) { + for (let j = 0; j < this.state.rules.boardSize; j++) { if (this.prime(obary[j])) { hikaku += 1; } } - if (hikaku === this.gameState.rules.boardSize) { + if (hikaku === this.state.rules.boardSize) { return true; } } diff --git a/apps/backend/src/room.ts b/apps/backend/src/room.ts new file mode 100644 index 0000000..c5055d1 --- /dev/null +++ b/apps/backend/src/room.ts @@ -0,0 +1,199 @@ +import { DurableObject } from "cloudflare:workers"; +import type { Env } from "hono/types"; + +export type RoomStatus = "preparing" | "playing" | "paused"; +export type PlayerStatus = + | "preparing" + | "ready" + | "playing" + | "finished" + | "error"; + +export interface Session { + ws: WebSocket; + playerId: string; +} + +export type RoomState = { + status: RoomStatus; + players: string[]; + playerStatus: { [playerId: string]: PlayerStatus }; + names: { [playerId: string]: string }; + rules: { + negativeDisabled: boolean; + boardSize: number; + timeLimit: number; + }; +}; + +export abstract class RoomMatch extends DurableObject { + state: T | undefined = undefined; + sessions: Session[] = []; + + constructor(ctx: DurableObjectState, env: Env) { + super(ctx, env); + this.ctx.blockConcurrencyWhile(async () => { + this.state = await this.ctx.storage.get("gameState"); + }); + } + + // Entry point for all connections + async fetch(request: Request) { + const url = new URL(request.url); + const playerId = url.searchParams.get("playerId"); + const playerName = url.searchParams.get("playerName"); + if (!playerId || !playerName) { + return new Response("playerId and playerName are required", { + status: 400, + }); + } + + if (request.headers.get("Upgrade") !== "websocket") { + return new Response("Expected websocket", { status: 400 }); + } + + if (!this.state) { + await this.initialize(); + } + + const { 0: client, 1: server } = new WebSocketPair(); + await this.handleSession(server, playerId, playerName); + + return new Response(null, { + status: 101, + webSocket: client, + }); + } + + async handleSession(ws: WebSocket, playerId: string, playerName: string) { + const session: Session = { ws, playerId }; + this.sessions.push(session); + ws.accept(); + + await this.addPlayer(playerId, playerName); + + ws.addEventListener("message", async (msg) => { + this.wsMessageListener(ws, msg, playerId); + }); + + const closeOrErrorHandler = () => { + this.sessions = this.sessions.filter((s) => s !== session); + this.updateDisconnectedPlayer(playerId); + }; + ws.addEventListener("close", closeOrErrorHandler); + ws.addEventListener("error", closeOrErrorHandler); + + ws.send(JSON.stringify({ type: "state", payload: this.state })); + } + + broadcast(message: unknown) { + const serialized = JSON.stringify(message); + this.sessions.forEach((session) => { + try { + session.ws.send(serialized); + } catch { + this.sessions = this.sessions.filter((s) => s !== session); + } + }); + } + + // --- Room Management Methods --- + + async addPlayer(playerId: string, playerName: string) { + if (!this.state) return; + + // New player + if (!this.state.players.includes(playerId)) { + if (this.state.status === "preparing") { + this.state.players.push(playerId); + this.state.names[playerId] = playerName; + this.state.playerStatus[playerId] = "preparing"; + } + } else { + // Reconnecting player + if (this.state.playerStatus[playerId] === "error") { + this.state.playerStatus[playerId] = + this.state.status === "paused" ? "playing" : "preparing"; + if ( + this.state.status === "paused" && + this.state.players.every( + (p) => this.state?.playerStatus[p] === "playing", + ) + ) { + this.state.status = "playing"; + } + } + } + await this.ctx.storage.put("gameState", this.state); + this.broadcast({ type: "state", payload: this.state }); + } + + async removePlayer(playerId: string) { + if (!this.state) return; + + this.state.players = this.state.players.filter((p) => p !== playerId); + delete this.state.playerStatus[playerId]; + delete this.state.names[playerId]; + + if (this.state.players.length === 0) { + await this.ctx.storage.delete("gameState"); + return; + } + + await this.ctx.storage.put("gameState", this.state); + this.broadcast({ type: "state", payload: this.state }); + } + + async updateDisconnectedPlayer(playerId: string) { + if (!this.state || !this.state.players.includes(playerId)) return; + + if (!this.sessions.some((s) => s.playerId === playerId)) { + if (this.state.status === "preparing") { + await this.removePlayer(playerId); + } else { + this.state.playerStatus[playerId] = "error"; + this.state.status = "paused"; + await this.ctx.storage.put("gameState", this.state); + this.broadcast({ type: "state", payload: this.state }); + } + } + } + + async setReady(playerId: string) { + if (!this.state || this.state.status !== "preparing") return; + this.state.playerStatus[playerId] = "ready"; + + if ( + this.state.players.length >= 2 && + this.state.players.every((p) => this.state?.playerStatus[p] === "ready") + ) { + await this.startGame(); + } else { + await this.ctx.storage.put("gameState", this.state); + this.broadcast({ type: "state", payload: this.state }); + } + } + + async cancelReady(playerId: string) { + if (!this.state || this.state.playerStatus[playerId] !== "ready") return; + this.state.playerStatus[playerId] = "preparing"; + await this.ctx.storage.put("gameState", this.state); + this.broadcast({ type: "state", payload: this.state }); + } + + async backToLobby(playerId: string) { + if (!this.state) return; + this.state.playerStatus[playerId] = "preparing"; + await this.ctx.storage.put("gameState", this.state); + this.broadcast({ type: "state", payload: this.state }); + } + + // This method is intended to be overridden by subclasses + abstract startGame(): Promise; + abstract wsMessageListener( + ws: WebSocket, + message: MessageEvent, + playerId: string, + ): Promise; + abstract initialize(): Promise; +} From 9f511b407133935673373967bd16718a3c15d8c8 Mon Sep 17 00:00:00 2001 From: nakomochi Date: Fri, 17 Oct 2025 20:35:12 +0900 Subject: [PATCH 2/4] =?UTF-8?q?isWinner=E3=82=92=E6=94=B9=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/backend/src/magic.ts | 84 ++++++++++++--------------------------- 1 file changed, 26 insertions(+), 58 deletions(-) diff --git a/apps/backend/src/magic.ts b/apps/backend/src/magic.ts index 5d7b6eb..abdafe3 100644 --- a/apps/backend/src/magic.ts +++ b/apps/backend/src/magic.ts @@ -435,14 +435,14 @@ export class Magic extends RoomMatch { if (mission.type === "prime") { let hikaku = 0; for (let j = 0; j < boardary.length; j++) { - if (this.prime(boardary[j])) { + if (this.isPrime(boardary[j])) { hikaku += 1; } } if (hikaku > 3) { for (let i = 0; i < nullinary.length; i++) { matrix[Math.floor(i / mission.number)][i % mission.number] = - this.prime(nullinary[i]); + this.isPrime(nullinary[i]); } } } @@ -460,65 +460,33 @@ export class Magic extends RoomMatch { isWinner(obary: number[], mission: Mission) { if (!this.state) throw new Error("Game state is not initialized"); - if (obary.length === this.state.rules.boardSize) { - if (mission.type === "sum") { - let hikaku = 0; - for (let j = 0; j < this.state.rules.boardSize; j++) { - hikaku += obary[j]; - } - if (hikaku === mission.number) { - return true; - } - } - if (mission.type === "multipile") { - let hikaku = 0; - for (let j = 0; j < this.state.rules.boardSize; j++) { - hikaku += obary[j] % mission.number; - } - if (hikaku === 0) { - return true; - } - } - if (mission.type === "arithmetic") { - obary.sort((first, second) => first - second); - let hikaku = 0; - for (let j = 1; j < this.state.rules.boardSize; j++) { - if (obary[j] - obary[j - 1] === mission.number) { - hikaku += 1; - } - } - if (hikaku === this.state.rules.boardSize - 1) { - return true; - } - } - if (mission.type === "geometic") { - obary.sort((first, second) => first - second); - let hikaku = 0; - for (let j = 1; j < this.state.rules.boardSize; j++) { - if (obary[j] === obary[j - 1] * mission.number) { - hikaku += 1; - } - } - if (hikaku === this.state.rules.boardSize - 1) { - return true; - } - } - if (mission.type === "prime") { - let hikaku = 0; - for (let j = 0; j < this.state.rules.boardSize; j++) { - if (this.prime(obary[j])) { - hikaku += 1; - } - } - if (hikaku === this.state.rules.boardSize) { - return true; - } - } + if (obary.length !== this.state.rules.boardSize) { + return false; + } + + switch (mission.type) { + case "sum": + return obary.reduce((acc, val) => acc + val, 0) === mission.number; + case "multipile": + return obary.every((val) => val % mission.number === 0); + case "arithmetic": + return obary + .toSorted((a, b) => a - b) + .slice(1) + .every((val, i) => val - obary[i] === mission.number); + case "geometic": + return obary + .toSorted((a, b) => a - b) + .slice(1) + .every((val, i) => val === obary[i] * mission.number); + case "prime": + return obary.every((val) => this.isPrime(val)); + default: + return false; } - return false; } - prime(number: number | null) { + isPrime(number: number | null) { if (number === null) { return false; } From 60128e031904567aeccb08a3787d2267c041b060 Mon Sep 17 00:00:00 2001 From: nakomochi Date: Fri, 17 Oct 2025 20:37:37 +0900 Subject: [PATCH 3/4] fix typo --- apps/backend/src/magic.ts | 2 +- apps/backend/src/mission.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/backend/src/magic.ts b/apps/backend/src/magic.ts index abdafe3..16041f4 100644 --- a/apps/backend/src/magic.ts +++ b/apps/backend/src/magic.ts @@ -474,7 +474,7 @@ export class Magic extends RoomMatch { .toSorted((a, b) => a - b) .slice(1) .every((val, i) => val - obary[i] === mission.number); - case "geometic": + case "geometric": return obary .toSorted((a, b) => a - b) .slice(1) diff --git a/apps/backend/src/mission.ts b/apps/backend/src/mission.ts index 653edba..16adbb2 100644 --- a/apps/backend/src/mission.ts +++ b/apps/backend/src/mission.ts @@ -1,5 +1,5 @@ export type Mission = { - type: "sum" | "multipile" | "arithmetic" | "geometic" | "prime"; + type: "sum" | "multipile" | "arithmetic" | "geometric" | "prime"; target: "column" | "row" | "diagonal" | "allDirection" | "allCell"; number: number; description: string; @@ -277,13 +277,13 @@ export const missions: Record = { description: "どこかの列が公差が4の等差数列", }, "45": { - type: "geometic", + type: "geometric", target: "allDirection", number: 2, description: "行、列、対角線のうちどこかが公比が2の等比数列", }, "46": { - type: "geometic", + type: "geometric", target: "allDirection", number: 3, description: "行、列、対角線のうちどこかが公比が3の等比数列", From abd6ce2264faf39cf3881f9e0f2a05b7aa2f4bed Mon Sep 17 00:00:00 2001 From: nakomochi Date: Sun, 19 Oct 2025 10:16:15 +0900 Subject: [PATCH 4/4] add test --- apps/backend/src/test/magic.test.ts | 537 ++++++++++++++++++ .../src/test/mocks/cloudflare-workers.ts | 8 + apps/backend/tsconfig.json | 5 +- 3 files changed, 549 insertions(+), 1 deletion(-) create mode 100644 apps/backend/src/test/magic.test.ts create mode 100644 apps/backend/src/test/mocks/cloudflare-workers.ts diff --git a/apps/backend/src/test/magic.test.ts b/apps/backend/src/test/magic.test.ts new file mode 100644 index 0000000..ad12bfb --- /dev/null +++ b/apps/backend/src/test/magic.test.ts @@ -0,0 +1,537 @@ +import { beforeEach, describe, expect, it } from "bun:test"; +import { type GameState, Magic } from "../magic"; +import type { Mission } from "../mission"; + +// Mock dependencies for Magic class constructor +// biome-ignore lint/suspicious/noExplicitAny: test +const mockCtx: any = { + blockConcurrencyWhile: (callback: () => Promise) => callback(), + storage: { + get: () => Promise.resolve(undefined), + }, +}; +// biome-ignore lint/suspicious/noExplicitAny: test +const mockEnv: any = null; + +describe("Magic", () => { + let magic: Magic; + + beforeEach(() => { + magic = new Magic(mockCtx, mockEnv); + // Mock the state for each test + magic.state = { + rules: { + boardSize: 3, + negativeDisabled: false, + timeLimit: 10, + }, + // Add other necessary state properties with default values + status: "playing", + players: ["player1", "player2"], + playerStatus: { player1: "playing", player2: "playing" }, + names: { player1: "Player 1", player2: "Player 2" }, + round: 1, + turn: 0, + board: [ + [1, 2, 3], + [4, 5, 6], + [7, 8, 9], + ], + winners: null, + winnersAry: {}, + gameId: "test-game", + hands: { player1: [1, 2, 3], player2: [4, 1, 2] }, + missions: {}, + timeLimitUnix: 0, + } as GameState; + }); + + describe("isPrime", () => { + it("should return true for prime numbers", () => { + expect(magic.isPrime(2)).toBe(true); + expect(magic.isPrime(3)).toBe(true); + expect(magic.isPrime(5)).toBe(true); + expect(magic.isPrime(97)).toBe(true); + }); + + it("should return false for non-prime numbers", () => { + expect(magic.isPrime(1)).toBe(false); + expect(magic.isPrime(4)).toBe(false); + expect(magic.isPrime(9)).toBe(false); + expect(magic.isPrime(100)).toBe(false); + }); + + it("should return false for null or zero", () => { + expect(magic.isPrime(null)).toBe(false); + expect(magic.isPrime(0)).toBe(false); + }); + }); + + describe("isWinner", () => { + it('should correctly validate "sum" mission', () => { + if (!magic.state) return; + const mission: Mission = { + type: "sum", + number: 6, + target: "allDirection", + description: "", + }; + const obary = [1, 2, 3]; + magic.state.rules.boardSize = 3; + expect(magic.isWinner(obary, mission)).toBe(true); + + const obary2 = [1, 2, 4]; + expect(magic.isWinner(obary2, mission)).toBe(false); + }); + + it('should correctly validate "multipile" mission', () => { + if (!magic.state) return; + const mission: Mission = { + type: "multipile", + number: 2, + target: "allDirection", + description: "", + }; + const obary = [2, 4, 6]; + magic.state.rules.boardSize = 3; + expect(magic.isWinner(obary, mission)).toBe(true); + + const obary2 = [2, 4, 5]; + expect(magic.isWinner(obary2, mission)).toBe(false); + }); + + it('should correctly validate "arithmetic" mission', () => { + if (!magic.state) return; + const mission: Mission = { + type: "arithmetic", + number: 2, + target: "allDirection", + description: "", + }; + const obary = [3, 5, 7]; + magic.state.rules.boardSize = 3; + expect(magic.isWinner(obary, mission)).toBe(true); + + const obary2 = [3, 5, 8]; + expect(magic.isWinner(obary2, mission)).toBe(false); + + // Test with unsorted array + const obary3 = [7, 3, 5]; + expect(magic.isWinner(obary3, mission)).toBe(true); + }); + + it('should correctly validate "geometric" mission', () => { + if (!magic.state) return; + const mission: Mission = { + type: "geometric", + number: 2, + target: "allDirection", + description: "", + }; + const obary = [2, 4, 8]; + magic.state.rules.boardSize = 3; + expect(magic.isWinner(obary, mission)).toBe(true); + + const obary2 = [2, 4, 9]; + expect(magic.isWinner(obary2, mission)).toBe(false); + + // Test with unsorted array + const obary3 = [8, 2, 4]; + expect(magic.isWinner(obary3, mission)).toBe(true); + }); + + it('should correctly validate "prime" mission', () => { + if (!magic.state) return; + const mission: Mission = { + type: "prime", + target: "allDirection", + number: 0, + description: "", + }; + const obary = [2, 3, 5]; + magic.state.rules.boardSize = 3; + expect(magic.isWinner(obary, mission)).toBe(true); + + const obary2 = [2, 3, 4]; + expect(magic.isWinner(obary2, mission)).toBe(false); + }); + + it("should return false if array length does not match board size", () => { + if (!magic.state) return; + const mission: Mission = { + type: "sum", + number: 6, + target: "allDirection", + description: "", + }; + const obary = [1, 2, 3, 4]; + magic.state.rules.boardSize = 3; + expect(magic.isWinner(obary, mission)).toBe(false); + }); + + it("should handle empty array for all mission types", () => { + if (!magic.state) return; + magic.state.rules.boardSize = 0; + const missionSum: Mission = { + type: "sum", + number: 0, + target: "allDirection", + description: "", + }; + expect(magic.isWinner([], missionSum)).toBe(true); + + const missionMultipile: Mission = { + type: "multipile", + number: 2, + target: "allDirection", + description: "", + }; + expect(magic.isWinner([], missionMultipile)).toBe(true); + + const missionArithmetic: Mission = { + type: "arithmetic", + number: 2, + target: "allDirection", + description: "", + }; + expect(magic.isWinner([], missionArithmetic)).toBe(true); + + const missionGeometric: Mission = { + type: "geometric", + number: 2, + target: "allDirection", + description: "", + }; + expect(magic.isWinner([], missionGeometric)).toBe(true); + + const missionPrime: Mission = { + type: "prime", + target: "allDirection", + number: 0, + description: "", + }; + expect(magic.isWinner([], missionPrime)).toBe(true); + }); + + it('should correctly validate "sum" mission with negative numbers', () => { + if (!magic.state) return; + const mission: Mission = { + type: "sum", + number: -6, + target: "allDirection", + description: "", + }; + const obary = [-1, -2, -3]; + magic.state.rules.boardSize = 3; + expect(magic.isWinner(obary, mission)).toBe(true); + }); + + it('should correctly validate "multipile" mission with mission number 1', () => { + if (!magic.state) return; + const mission: Mission = { + type: "multipile", + number: 1, + target: "allDirection", + description: "", + }; + const obary = [1, 2, 3]; + magic.state.rules.boardSize = 3; + expect(magic.isWinner(obary, mission)).toBe(true); + }); + + it('should correctly validate "arithmetic" mission with descending sequence', () => { + if (!magic.state) return; + const mission: Mission = { + type: "arithmetic", + number: -2, + target: "allDirection", + description: "", + }; + const obary = [7, 5, 3]; + magic.state.rules.boardSize = 3; + expect(magic.isWinner(obary, mission)).toBe(true); + }); + + it('should correctly validate "geometric" mission with mission number 1', () => { + if (!magic.state) return; + const mission: Mission = { + type: "geometric", + number: 1, + target: "allDirection", + description: "", + }; + const obary = [3, 3, 3]; + magic.state.rules.boardSize = 3; + expect(magic.isWinner(obary, mission)).toBe(true); + }); + + it('should return false for "prime" mission with array containing 1', () => { + if (!magic.state) return; + const mission: Mission = { + type: "prime", + target: "allDirection", + number: 0, + description: "", + }; + const obary = [1, 2, 3]; + magic.state.rules.boardSize = 3; + expect(magic.isWinner(obary, mission)).toBe(false); + }); + }); + + describe("isVictory", () => { + it("should correctly identify a winning column", () => { + if (!magic.state) return; + const mission: Mission = { + type: "sum", + number: 15, + target: "column", + description: "", + }; + magic.state.board = [ + [8, 1, 6], + [3, 5, 7], + [4, 9, 2], + ]; + magic.state.rules.boardSize = 3; + const result = magic.isVictory(mission); + expect(result).toEqual([ + [true, true, true], + [true, true, true], + [true, true, true], + ]); + }); + + it("should correctly identify a winning row", () => { + if (!magic.state) return; + const mission: Mission = { + type: "sum", + number: 15, + target: "row", + description: "", + }; + magic.state.board = [ + [8, 3, 4], + [1, 5, 9], + [6, 7, 2], + ]; + magic.state.rules.boardSize = 3; + const result = magic.isVictory(mission); + expect(result).toEqual([ + [true, true, true], + [true, true, true], + [true, true, true], + ]); + }); + + it("should correctly identify a winning diagonal", () => { + if (!magic.state) return; + const mission: Mission = { + type: "sum", + number: 15, + target: "diagonal", + description: "", + }; + magic.state.board = [ + [8, 1, 6], + [3, 5, 7], + [4, 9, 2], + ]; + magic.state.rules.boardSize = 3; + const result = magic.isVictory(mission); + expect(result[0][2]).toBe(true); + expect(result[1][1]).toBe(true); + expect(result[2][0]).toBe(true); + }); + + it("should correctly identify winning lines with allDirection", () => { + if (!magic.state) return; + const mission: Mission = { + type: "sum", + number: 15, + target: "allDirection", + description: "", + }; + magic.state.board = [ + [8, 1, 6], + [3, 5, 7], + [4, 9, 2], + ]; + magic.state.rules.boardSize = 3; + const result = magic.isVictory(mission); + expect(result).toEqual([ + [true, true, true], + [true, true, true], + [true, true, true], + ]); + }); + + it("should return a false matrix when no winning lines are present", () => { + if (!magic.state) return; + const mission: Mission = { + type: "sum", + number: 100, + target: "allDirection", + description: "", + }; + magic.state.board = [ + [1, 2, 3], + [4, 5, 6], + [7, 8, 9], + ]; + magic.state.rules.boardSize = 3; + const result = magic.isVictory(mission); + expect(result).toEqual([ + [false, false, false], + [false, false, false], + [false, false, false], + ]); + }); + + it("should correctly identify a winning main diagonal", async () => { + if (!magic.state) return; + + magic.state.players = ["player1", "player2"]; + magic.state.missions = { + player1: { + id: "1", + mission: { + type: "sum", + target: "diagonal", + number: 17, + description: "", + }, + }, + player2: { + id: "2", + mission: { + type: "multipile", + target: "allCell", + number: 3, + description: "", + }, + }, + }; + magic.state.board = [ + [6, null, 3], + [null, 2, null], + [3, null, 9], + ]; + magic.state.hands = { player1: [9], player2: [] }; + magic.state.turn = 0; // player1's turn + + await magic.makeMove("player1", 2, 2, 9, "add", 0); + + expect(magic.state.winners).not.toBeNull(); + if (magic.state.winners) { + expect(magic.state.winners).toHaveLength(1); + expect(magic.state.winners).toContain("player1"); + } + }); + + it("should correctly identify a winning anti-diagonal", () => { + if (!magic.state) return; + const mission: Mission = { + type: "sum", + number: 8, + target: "diagonal", + description: "", + }; + magic.state.board = [ + [3, null, 3], + [null, 2, null], + [3, null, 3], + ]; + magic.state.rules.boardSize = 3; + const result = magic.isVictory(mission); + expect(result[0][2]).toBe(true); + expect(result[1][1]).toBe(true); + expect(result[2][0]).toBe(true); + }); + + describe("allCell target", () => { + it("should correctly identify winning cells for multipile mission", () => { + if (!magic.state) return; + const mission: Mission = { + type: "multipile", + number: 3, + target: "allCell", + description: "", + }; + magic.state.board = [ + [3, 5, 6], + [9, 1, 12], + [2, 4, 15], + ]; + magic.state.rules.boardSize = 3; + const result = magic.isVictory(mission); + expect(result).toEqual([ + [true, false, true], + [true, false, true], + [false, false, true], + ]); + }); + + it("should correctly identify winning cells for prime mission", () => { + if (!magic.state) return; + const mission: Mission = { + type: "prime", + number: 0, + target: "allCell", + description: "", + }; + magic.state.board = [ + [2, 4, 3], + [5, 6, 7], + [8, 9, 11], + ]; + magic.state.rules.boardSize = 3; + const result = magic.isVictory(mission); + expect(result).toEqual([ + [true, false, true], + [true, false, true], + [false, false, true], + ]); + }); + }); + }); + + describe("makeMove", () => { + it("should correctly identify multiple winners", async () => { + if (!magic.state) return; + + magic.state.players = ["player1", "player2"]; + magic.state.missions = { + player1: { + id: "1", + mission: { type: "sum", target: "row", number: 15, description: "" }, + }, + player2: { + id: "2", + mission: { + type: "sum", + target: "column", + number: 15, + description: "", + }, + }, + }; + magic.state.board = [ + [8, 1, 6], + [3, null, 7], + [4, 9, 2], + ]; + magic.state.hands = { player1: [5], player2: [] }; + magic.state.turn = 0; // player1's turn + + await magic.makeMove("player1", 1, 1, 5, "add", 0); + + expect(magic.state.winners).not.toBeNull(); + if (magic.state.winners) { + expect(magic.state.winners).toHaveLength(2); + expect(magic.state.winners).toContain("player1"); + expect(magic.state.winners).toContain("player2"); + } + }); + }); +}); diff --git a/apps/backend/src/test/mocks/cloudflare-workers.ts b/apps/backend/src/test/mocks/cloudflare-workers.ts new file mode 100644 index 0000000..9275f45 --- /dev/null +++ b/apps/backend/src/test/mocks/cloudflare-workers.ts @@ -0,0 +1,8 @@ +export class DurableObject { + // biome-ignore lint/suspicious/noExplicitAny: mock + public ctx: any; + // biome-ignore lint/suspicious/noExplicitAny: mock + constructor(ctx: any) { + this.ctx = ctx; + } +} diff --git a/apps/backend/tsconfig.json b/apps/backend/tsconfig.json index 60299e3..470b9c3 100644 --- a/apps/backend/tsconfig.json +++ b/apps/backend/tsconfig.json @@ -8,6 +8,9 @@ "lib": ["ESNext"], "jsx": "react-jsx", "jsxImportSource": "hono/jsx", - "types": ["@cloudflare/workers-types"] + "types": ["@cloudflare/workers-types", "bun-types"], + "paths": { + "cloudflare:workers": ["./src/test/mocks/cloudflare-workers.ts"] + } } }