From 3ac35a11a9a8233abfd0fb3b765a97110c5d2715 Mon Sep 17 00:00:00 2001 From: nakomochi Date: Tue, 4 Nov 2025 18:02:23 +0900 Subject: [PATCH 1/4] =?UTF-8?q?rule=E9=96=A2=E9=80=A3=E3=81=AE=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/backend/src/magic.ts | 50 ++++++++++++++++++++++++++++++-------- apps/backend/src/memory.ts | 5 +++- apps/backend/src/room.ts | 5 ---- 3 files changed, 44 insertions(+), 16 deletions(-) diff --git a/apps/backend/src/magic.ts b/apps/backend/src/magic.ts index c2aeb89..0b7de00 100644 --- a/apps/backend/src/magic.ts +++ b/apps/backend/src/magic.ts @@ -19,10 +19,17 @@ export type MoveAction = { export type Rule = | { rule: "negativeDisabled"; state: boolean } | { rule: "boardSize"; state: number } - | { rule: "timeLimit"; state: number }; + | { rule: "timeLimit"; state: number } + | { rule: "cpu"; state: number }; // GameState extends RoomState to include game-specific properties export type GameState = RoomState & { + rules: { + negativeDisabled: boolean; + boardSize: number; + timeLimit: number; + cpu: number; + }; round: number; turn: number; board: (number | null)[][]; @@ -121,6 +128,7 @@ export class Magic extends RoomMatch { negativeDisabled: false, boardSize: DEFAULT_BOARD_SIZE, timeLimit: DEFAULT_TIME_LIMIT_MS / 1000, + cpu: 0, }, // GameState specific properties round: 0, @@ -140,16 +148,38 @@ export class Magic extends RoomMatch { async changeRule(payload: Rule) { if (!this.state || this.state.status !== "preparing") return; - 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; + switch (payload.rule) { + case "negativeDisabled": + this.state.rules.negativeDisabled = payload.state; + break; + case "boardSize": { + const size = payload.state; + if (size < 1 || size > 6) { + console.error("Invalid board size:", size); + return; + } + this.state.rules.boardSize = size; + this.state.board = Array(size) + .fill(null) + .map(() => Array(size).fill(null)); + break; + } + case "timeLimit": { + const timeLimit = payload.state; + if (timeLimit < 1) { + console.error("Invalid time limit:", timeLimit); + return; + } + this.state.rules.timeLimit = timeLimit; + break; + } + case "cpu": + this.state.rules.cpu = payload.state; + break; + default: + payload satisfies never; } + await this.ctx.storage.put("gameState", this.state); this.broadcast({ type: "state", payload: this.state }); } diff --git a/apps/backend/src/memory.ts b/apps/backend/src/memory.ts index b08c340..ccd2090 100644 --- a/apps/backend/src/memory.ts +++ b/apps/backend/src/memory.ts @@ -63,6 +63,10 @@ export type EventCard = { // GameState extends RoomState to include game-specific properties export type GameState = RoomState & { + rules: { + boardSize: number; + timeLimit: number; + }; round: number; turn: number; board: CellState[][]; @@ -163,7 +167,6 @@ export class Memory extends RoomMatch { playerStatus: {}, names: {}, rules: { - negativeDisabled: false, boardSize: DEFAULT_BOARD_SIZE, timeLimit: DEFAULT_TIME_LIMIT_MS / 1000, }, diff --git a/apps/backend/src/room.ts b/apps/backend/src/room.ts index 7924ab5..b89bf29 100644 --- a/apps/backend/src/room.ts +++ b/apps/backend/src/room.ts @@ -21,11 +21,6 @@ export type RoomState = { players: string[]; playerStatus: { [playerId: string]: PlayerStatus }; names: { [playerId: string]: string }; - rules: { - negativeDisabled: boolean; - boardSize: number; - timeLimit: number; - }; }; export abstract class RoomMatch extends DurableObject { From 5624bb82f5574e4f661a50be8bb3d817e8716e3a Mon Sep 17 00:00:00 2001 From: nakomochi Date: Tue, 2 Dec 2025 18:00:40 +0900 Subject: [PATCH 2/4] =?UTF-8?q?CPU=E5=AF=BE=E6=88=A6=E3=81=AE=E6=9E=A0?= =?UTF-8?q?=E7=B5=84=E3=81=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/backend/src/magic.ts | 269 ++++++++++++------ apps/backend/src/memory.ts | 134 +++++---- apps/backend/src/room.ts | 60 ++-- .../app/routes/magic-square/room.$roomId.tsx | 198 ++++++++----- .../memory-optimization/room.$roomId.tsx | 131 +++++---- 5 files changed, 488 insertions(+), 304 deletions(-) diff --git a/apps/backend/src/magic.ts b/apps/backend/src/magic.ts index 0b7de00..b270259 100644 --- a/apps/backend/src/magic.ts +++ b/apps/backend/src/magic.ts @@ -22,6 +22,16 @@ export type Rule = | { rule: "timeLimit"; state: number } | { rule: "cpu"; state: number }; +export type LastGameResult = { + players: { + id: string; + name: string; + }[]; + winners: string[] | null; + winnersAry: { [playerId: string]: (true | false)[][] }; + board: (number | null)[][]; +}; + // GameState extends RoomState to include game-specific properties export type GameState = RoomState & { rules: { @@ -31,10 +41,9 @@ export type GameState = RoomState & { cpu: number; }; round: number; - turn: number; + currentPlayerIndex: number; // index in players, including spectators board: (number | null)[][]; - winners: string[] | null; - winnersAry: { [playerId: string]: (true | false)[][] }; + lastGameResult: LastGameResult | null; gameId: string; hands: { [playerId: string]: number[] }; missions: { [playerId: string]: { id: string; mission: Mission } }; @@ -71,6 +80,9 @@ export class Magic extends RoomMatch { const { type, payload } = JSON.parse( message.data as string, ) as MessageType; + if (!this.state) { + throw new Error("Game state is not initialized"); + } switch (type) { // Game actions case "makeMove": @@ -88,7 +100,7 @@ export class Magic extends RoomMatch { break; // Room actions (from base class) case "setReady": - await this.setReady(playerId); + await this.setReady(playerId, this.state.rules.cpu); break; case "cancelReady": await this.cancelReady(playerId); @@ -124,18 +136,17 @@ export class Magic extends RoomMatch { players: [], playerStatus: {}, names: {}, + // GameState specific properties rules: { negativeDisabled: false, boardSize: DEFAULT_BOARD_SIZE, timeLimit: DEFAULT_TIME_LIMIT_MS / 1000, cpu: 0, }, - // GameState specific properties round: 0, - turn: 0, + currentPlayerIndex: 0, board: [], - winners: null, - winnersAry: {}, + lastGameResult: null, gameId: this.ctx.id.toString(), hands: {}, missions: {}, @@ -186,50 +197,66 @@ export class Magic extends RoomMatch { 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.winners = null; - this.state.winnersAry = {}; + this.state.lastGameResult = null; this.state.hands = {}; this.state.missions = {}; - for (const playerId of this.state.players) { + for (let i = 0; i < this.state.rules.cpu; i++) { + const cpuId = `cpu-${i + 1}-${crypto.randomUUID()}`; + this.state.players.push({ + id: cpuId, + type: "cpu", + }); + this.state.names[cpuId] = `CPU ${i + 1}`; + this.state.playerStatus[cpuId] = "ready"; + } + + for (const id of this.state.players.map((p) => p.id)) { + const player = this.state.players.find((p) => p.id === id); + if (!player) throw new Error(`Player not found: ${id}`); + if ( - this.state.playerStatus[playerId] !== "ready" && - this.state.playerStatus[playerId] !== "spectatingReady" + this.state.playerStatus[id] !== "ready" && + this.state.playerStatus[id] !== "spectatingReady" ) { - console.error("one of the players not ready:", playerId); + console.error("one of the players not ready:", id); return; } - this.state.playerStatus[playerId] = - this.state.playerStatus[playerId] === "ready" - ? "playing" - : "spectating"; - - if (this.state.playerStatus[playerId] === "playing") { - if (this.state.hands[playerId]) { - console.error("player already has a hand:", playerId); - return; - } - this.state.hands[playerId] = this.drawInitialHand(); - if (this.state.missions[playerId]) { - console.error("player already has a mission:", playerId); - return; - } - this.state.missions[playerId] = this.getRandomMission(); + switch (this.state.playerStatus[id]) { + case "ready": + this.state.playerStatus[id] = "playing"; + if (player.type !== "cpu") player.type = "player"; + if (this.state.hands[id]) { + console.error("player already has a hand:", id); + return; + } + this.state.hands[id] = this.drawInitialHand(); + + if (this.state.missions[id]) { + console.error("player already has a mission:", id); + return; + } + this.state.missions[id] = this.getRandomMission(); + break; + case "spectatingReady": + this.state.playerStatus[id] = "spectating"; + player.type = "spectator"; + break; + default: + this.state.playerStatus[id] satisfies never; } } const firstPlayingIndex = this.state.players.findIndex( - (p) => this.state?.playerStatus[p] === "playing", + (p) => this.state?.playerStatus[p.id] === "playing", ); - this.state.turn = firstPlayingIndex; + this.state.currentPlayerIndex = firstPlayingIndex; this.state.status = "playing"; this.state.timeLimitUnix = Date.now() + this.state.rules.timeLimit * 1000; clearTimeout(timeout); @@ -245,7 +272,7 @@ export class Magic extends RoomMatch { drawInitialHand() { if (!this.state) return []; - const hand = new Array(3); // TODO: 変更可能にする + const hand: number[] = new Array(3); // TODO: 変更可能にする for (let i = 0; i < hand.length; i++) { hand[i] = this.drawCard(); } @@ -274,58 +301,55 @@ export class Magic extends RoomMatch { return { id: randomKey, mission: missions[randomKey] }; } - advanceTurnAndRound() { + async advanceTurnAndRound() { if (!this.state) return; const players = this.state.players; - const playerStatuses = this.state.playerStatus; - const currentTurn = this.state.turn; + const currentPlayerIndex = this.state.currentPlayerIndex; - const activePlayerIds = players.filter( - (p) => playerStatuses[p] === "playing", + const activePlayers = players.filter( + (p) => p.type === "player" || p.type === "cpu", ); - if (activePlayerIds.length === 0) { - this.state.status = "paused"; - return; // No one to advance turn to. - } - const currentPlayerId = players[currentTurn]; + if (activePlayers.length === 0) + throw new Error("No active players to advance turn to"); - // Find the index of the current player in the list of *active* players. - // If the current player is not active (e.g., a spectator), this will be -1. - const currentPlayerActiveIndex = activePlayerIds.indexOf(currentPlayerId); + const currentActivePlayerIndex = activePlayers.findIndex( + (p) => p.id === players[currentPlayerIndex].id, + ); - let nextPlayerId: string | null = null; + if (currentActivePlayerIndex === -1) + throw new Error("Current active player not found"); - if (currentPlayerActiveIndex === -1) { - // The turn was on an inactive player. Find the first active player after the current one. - let nextTurn = currentTurn; - for (let i = 0; i < players.length; i++) { - nextTurn = (nextTurn + 1) % players.length; - if (playerStatuses[players[nextTurn]] === "playing") { - nextPlayerId = players[nextTurn]; - break; - } - } - if (!nextPlayerId) { - // Should be unreachable due to activePlayerIds.length check - this.state.status = "paused"; - return; - } - } else { - // The current player is active. Find the next one in the active list. - const nextPlayerActiveIndex = - (currentPlayerActiveIndex + 1) % activePlayerIds.length; - nextPlayerId = activePlayerIds[nextPlayerActiveIndex]; + const nextActivePlayerIndex = + (currentActivePlayerIndex + 1) % activePlayers.length; + + const nextActivePlayer = activePlayers[nextActivePlayerIndex]; + + if (nextActivePlayer.type === "spectator") + throw new Error("Next active player cannot be a spectator"); + + const nextPlayerIndex = players.findIndex( + (p) => p.id === nextActivePlayer.id, + ); + + if (nextActivePlayer.id !== players[nextPlayerIndex].id) + throw new Error("Turn did not advance correctly"); + this.state.currentPlayerIndex = nextPlayerIndex; + + if (nextActivePlayerIndex === 0) { // If we wrapped around the active players list, increment the round. - if (nextPlayerActiveIndex === 0) { - this.state.round += 1; - } + this.state.round += 1; } - if (nextPlayerId) { - this.state.turn = players.indexOf(nextPlayerId); + await this.ctx.storage.put("gameState", this.state); + this.broadcast({ type: "state", payload: this.state }); + + if (nextActivePlayer.type === "cpu") { + this.cpuMakeMove(nextActivePlayer.id).catch((err) => { + console.error("CPU Error:", err); + }); } } @@ -337,7 +361,7 @@ export class Magic extends RoomMatch { operation: Operation, numIndex: number, ) { - if (!this.state || this.state.winners) return; + if (!this.state || this.state.lastGameResult) return; if (!this.isValidMove(player, x, y, num)) { console.error("Invalid move attempted:", player, x, y, num); @@ -348,39 +372,59 @@ export class Magic extends RoomMatch { this.state.board[y][x] = this.computeCellResult(x, y, num, operation); + clearTimeout(timeout); + this.advanceTurnAndRound(); const prevHand = this.state.hands[player]; this.state.hands[player] = prevHand.toSpliced(numIndex, 1, this.drawCard()); - for (const id of this.state.players) { + for (const id of this.state.players.map((p) => p.id)) { if (this.state.missions[id]) { const winary = this.isVictory(this.state.missions[id].mission); if (winary.some((row) => row.includes(true))) { 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); + if (!this.state.lastGameResult) { + const names = this.state.names; + this.state.lastGameResult = { + players: this.state.players.map((p) => ({ + id: p.id, + name: names[p.id], + })), + winners: [id], + winnersAry: { [id]: winary }, + board: this.state.board, + }; + } else if (!this.state.lastGameResult.winners?.includes(id)) { + this.state.lastGameResult.winners?.push(id); } + this.state.lastGameResult.winnersAry[id] = winary; console.log("winary", winary); - console.log("this.state.winnersAry", this.state.winnersAry); + console.log( + "this.state.winnersAry", + this.state.lastGameResult.winnersAry, + ); } } } - if (this.state.winners) { + // someone won + if (this.state.lastGameResult) { 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.state.players = this.state.players.filter((p) => { + if (p.type !== "cpu") return true; + delete this.state?.playerStatus[p.id]; + return false; + }); } + this.state.timeLimitUnix = Date.now() + this.state.rules.timeLimit * 1000; - clearTimeout(timeout); - if (!this.state.winners) + if (!this.state.lastGameResult) timeout = setTimeout(() => { this.pass(); }, this.state.rules.timeLimit * 1000); @@ -388,6 +432,46 @@ export class Magic extends RoomMatch { this.broadcast({ type: "state", payload: this.state }); } + async cpuMakeMove(cpuId: string) { + await new Promise((resolve) => setTimeout(resolve, 1000)); // Simulate thinking time + + console.log("CPU making move:", cpuId); + if (!this.state) return; + + const hand = this.state.hands[cpuId]; + if (!hand || hand.length === 0) return; + + this.pass(); + return; + + // random + // const size = this.state.rules.boardSize; + // let targetX = -1; + // let targetY = -1; + + // for (let y = 0; y < size; y++) { + // for (let x = 0; x < size; x++) { + // if (this.state.board[y][x] === null) { + // targetX = x; + // targetY = y; + // break; + // } + // } + // if (targetX !== -1) break; + // } + + // if (targetX === -1) { + // await this.pass(); + // return; + // } + + // const numIndex = 0; + // const num = hand[numIndex]; + // const operation: Operation = "add"; + + // await this.makeMove(cpuId, targetX, targetY, num, operation, numIndex); + } + async pass() { if (!this.state) return; this.advanceTurnAndRound(); @@ -400,7 +484,7 @@ export class Magic extends RoomMatch { this.broadcast({ type: "state", payload: this.state }); } - isValidMove(player: string, x: number, y: number, num: number) { + isValidMove(playerId: string, x: number, y: number, num: number) { if (!this.state) throw new Error("Game state is not initialized"); // TODO: 調整可能にする @@ -409,13 +493,13 @@ export class Magic extends RoomMatch { return false; } - const currentPlayer = this.state.players[this.state.turn]; - if (currentPlayer !== player) { - console.error("Not your turn:", player); + const currentPlayer = this.state.players[this.state.currentPlayerIndex]; + if (currentPlayer.id !== playerId) { + console.error("Not your turn:", playerId); return false; } - const currentHand = this.state.hands[currentPlayer]; + const currentHand = this.state.hands[currentPlayer.id]; if (!currentHand || currentHand.length === 0) { console.error("Invalid hand:", currentPlayer); return false; @@ -457,8 +541,9 @@ export class Magic extends RoomMatch { isVictory(mission: Mission) { 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), + const matrix: boolean[][] = 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.state.rules.boardSize; i++) { diff --git a/apps/backend/src/memory.ts b/apps/backend/src/memory.ts index ccd2090..7543813 100644 --- a/apps/backend/src/memory.ts +++ b/apps/backend/src/memory.ts @@ -68,7 +68,7 @@ export type GameState = RoomState & { timeLimit: number; }; round: number; - turn: number; + currentPlayerIndex: number; // index in players, including spectators board: CellState[][]; winners: string[] | null; gameId: string; @@ -172,7 +172,7 @@ export class Memory extends RoomMatch { }, // GameState specific properties round: 0, - turn: 0, + currentPlayerIndex: 0, board: [], winners: null, gameId: this.ctx.id.toString(), @@ -204,32 +204,61 @@ export class Memory extends RoomMatch { 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.hands = {}; this.state.clocks = {}; this.state.points = {}; - for (const playerId of this.state.players) { - if (this.state.playerStatus[playerId] !== "ready") { - console.error("one of the players not ready:", playerId); + // for (let i = 0; i < this.state.rules.cpu; i++) { + // const cpuId = `cpu-${i + 1}-${crypto.randomUUID()}`; + // this.state.players.push({ + // id: cpuId, + // type: "cpu", + // }); + // this.state.names[cpuId] = `CPU ${i + 1}`; + // this.state.playerStatus[cpuId] = "ready"; + // } + + for (const id of this.state.players.map((p) => p.id)) { + const player = this.state.players.find((p) => p.id === id); + if (!player) throw new Error(`Player not found: ${id}`); + + if ( + this.state.playerStatus[id] !== "ready" && + this.state.playerStatus[id] !== "spectatingReady" + ) { + console.error("one of the players not ready:", id); return; } - this.state.playerStatus[playerId] = "playing"; - if (this.state.hands[playerId]) { - console.error("player already has a hand:", playerId); - return; + switch (this.state.playerStatus[id]) { + case "ready": + this.state.playerStatus[id] = "playing"; + if (player.type !== "cpu") player.type = "player"; + if (this.state.hands[id]) { + console.error("player already has a hand:", id); + return; + } + this.state.hands[id] = this.drawInitialHand(); + break; + case "spectatingReady": + this.state.playerStatus[id] = "spectating"; + player.type = "spectator"; + break; + default: + this.state.playerStatus[id] satisfies never; } - this.state.hands[playerId] = this.drawInitialHand(); } + + const firstPlayingIndex = this.state.players.findIndex( + (p) => this.state?.playerStatus[p.id] === "playing", + ); + this.state.currentPlayerIndex = firstPlayingIndex; this.state.status = "playing"; this.state.timeLimitUnix = Date.now() + this.state.rules.timeLimit * 1000; clearTimeout(timeout); @@ -281,59 +310,56 @@ export class Memory extends RoomMatch { }; } - advanceTurnAndRound() { + async advanceTurnAndRound() { if (!this.state) return; const players = this.state.players; - const playerStatuses = this.state.playerStatus; - const currentTurn = this.state.turn; + const currentPlayerIndex = this.state.currentPlayerIndex; - const activePlayerIds = players.filter( - (p) => playerStatuses[p] === "playing", + const activePlayers = players.filter( + (p) => p.type === "player" || p.type === "cpu", ); - if (activePlayerIds.length === 0) { - this.state.status = "paused"; - return; // No one to advance turn to. - } - const currentPlayerId = players[currentTurn]; + if (activePlayers.length === 0) + throw new Error("No active players to advance turn to"); - // Find the index of the current player in the list of *active* players. - // If the current player is not active (e.g., a watcher), this will be -1. - const currentPlayerActiveIndex = activePlayerIds.indexOf(currentPlayerId); + const currentActivePlayerIndex = activePlayers.findIndex( + (p) => p.id === players[currentPlayerIndex].id, + ); - let nextPlayerId: string | null = null; + if (currentActivePlayerIndex === -1) + throw new Error("Current active player not found"); - if (currentPlayerActiveIndex === -1) { - // The turn was on an inactive player. Find the first active player after the current one. - let nextTurn = currentTurn; - for (let i = 0; i < players.length; i++) { - nextTurn = (nextTurn + 1) % players.length; - if (playerStatuses[players[nextTurn]] === "playing") { - nextPlayerId = players[nextTurn]; - break; - } - } - if (!nextPlayerId) { - // Should be unreachable due to activePlayerIds.length check - this.state.status = "paused"; - return; - } - } else { - // The current player is active. Find the next one in the active list. - const nextPlayerActiveIndex = - (currentPlayerActiveIndex + 1) % activePlayerIds.length; - nextPlayerId = activePlayerIds[nextPlayerActiveIndex]; + const nextActivePlayerIndex = + (currentActivePlayerIndex + 1) % activePlayers.length; + + const nextActivePlayer = activePlayers[nextActivePlayerIndex]; + + if (nextActivePlayer.type === "spectator") + throw new Error("Next active player cannot be a spectator"); + + const nextPlayerIndex = players.findIndex( + (p) => p.id === nextActivePlayer.id, + ); + + if (nextActivePlayer.id !== players[nextPlayerIndex].id) + throw new Error("Turn did not advance correctly"); + this.state.currentPlayerIndex = nextPlayerIndex; + + if (nextActivePlayerIndex === 0) { // If we wrapped around the active players list, increment the round. - if (nextPlayerActiveIndex === 0) { - this.state.round += 1; - } + this.state.round += 1; } - if (nextPlayerId) { - this.state.turn = players.indexOf(nextPlayerId); - } + await this.ctx.storage.put("gameState", this.state); + this.broadcast({ type: "state", payload: this.state }); + + // if (nextActivePlayer.type === "cpu") { + // this.cpuMakeMove(nextActivePlayer.id).catch((err) => { + // console.error("CPU Error:", err); + // }); + // } } async reserveMemory( @@ -467,7 +493,7 @@ export class Memory extends RoomMatch { ): boolean { if (!this.state) throw new Error("Game state is not initialized"); - const currentPlayer = this.state.players[this.state.turn]; + const currentPlayer = this.state.players[this.state.currentPlayerIndex].id; if (currentPlayer !== player) { console.error("Not your turn:", player); return false; diff --git a/apps/backend/src/room.ts b/apps/backend/src/room.ts index b89bf29..305982f 100644 --- a/apps/backend/src/room.ts +++ b/apps/backend/src/room.ts @@ -18,7 +18,10 @@ export interface Session { export type RoomState = { status: RoomStatus; - players: string[]; + players: { + type: "player" | "spectator" | "cpu"; + id: string; + }[]; // playerStatus: { [playerId: string]: PlayerStatus }; names: { [playerId: string]: string }; }; @@ -100,10 +103,13 @@ export abstract class RoomMatch extends DurableObject { if (!this.state) return; // New player - if (!this.state.players.includes(playerId)) { + if (!this.state.players.some((p) => p.id === playerId)) { switch (this.state.status) { case "preparing": - this.state.players.push(playerId); + this.state.players.push({ + id: playerId, + type: "player", + }); this.state.names[playerId] = playerName; this.state.playerStatus[playerId] = "preparing"; @@ -111,34 +117,21 @@ export abstract class RoomMatch extends DurableObject { this.broadcast({ type: "state", payload: this.state }); break; case "playing": - if (!this.state.players.includes(playerId)) { - this.state.players.push(playerId); - this.state.names[playerId] = playerName; - this.state.playerStatus[playerId] = "spectating"; + this.state.players.push({ + id: playerId, + type: "spectator", + }); + this.state.names[playerId] = playerName; + this.state.playerStatus[playerId] = "spectating"; - await this.ctx.storage.put("gameState", this.state); - this.broadcast({ type: "state", payload: this.state }); - } + await this.ctx.storage.put("gameState", this.state); + this.broadcast({ type: "state", payload: this.state }); break; case "paused": - // if (this.state.players.includes(playerId)) { - // this.state.playerStatus[playerId] = "playing"; - // if ( - // Object.values(this.state.playerStatus).every( - // (status) => status === "playing", - // ) - // ) { - // console.log("All players reconnected, resuming game."); - // this.state.status = "playing"; - // } else { - // console.log("Waiting for other players to reconnect."); - // } - // await this.ctx.storage.put("gameState", this.state); - // this.broadcast({ type: "state", payload: this.state }); - // } else { - // console.error("Game already started, cannot join now."); - // } - this.state.players.push(playerId); + this.state.players.push({ + id: playerId, + type: "spectator", + }); this.state.names[playerId] = playerName; this.state.playerStatus[playerId] = "spectating"; @@ -187,7 +180,7 @@ export abstract class RoomMatch extends DurableObject { async removePlayer(playerId: string) { if (!this.state) return; - this.state.players = this.state.players.filter((p) => p !== playerId); + this.state.players = this.state.players.filter((p) => p.id !== playerId); delete this.state.playerStatus[playerId]; delete this.state.names[playerId]; @@ -201,7 +194,8 @@ export abstract class RoomMatch extends DurableObject { } async updateDisconnectedPlayer(playerId: string) { - if (!this.state || !this.state.players.includes(playerId)) return; + if (!this.state || !this.state.players.some((p) => p.id === playerId)) + return; if (!this.sessions.some((s) => s.playerId === playerId)) { if (this.state.status === "preparing") { @@ -215,13 +209,15 @@ export abstract class RoomMatch extends DurableObject { } } - async setReady(playerId: string) { + async setReady(playerId: string, cpu?: number) { if (!this.state || this.state.status !== "preparing") return; this.state.playerStatus[playerId] = "ready"; if ( Object.values(this.state.playerStatus).filter((s) => s === "ready") - .length >= 2 && + .length + + (cpu || 0) >= + 2 && Object.values(this.state.playerStatus).every( (s) => s === "ready" || s === "spectatingReady", ) diff --git a/apps/frontend/app/routes/magic-square/room.$roomId.tsx b/apps/frontend/app/routes/magic-square/room.$roomId.tsx index fcd520e..aa1d724 100644 --- a/apps/frontend/app/routes/magic-square/room.$roomId.tsx +++ b/apps/frontend/app/routes/magic-square/room.$roomId.tsx @@ -186,27 +186,47 @@ function TurnDisplay({ currentPlayerId, currentPlayerName, myId, + isCpuTurn, remainingTime, }: { round: number; currentPlayerId: string; currentPlayerName: string; myId: string; + isCpuTurn: boolean; remainingTime: number; }) { const isMyTurn = currentPlayerId === myId; + // 共通のスタイルを定数として定義 + const baseTurnDisplayClasses = + "h-12 flex items-center justify-center text-lg font-bold p-2 rounded-md transition-all duration-300"; + return (

Round

{round + 1}

+
- {isMyTurn ? "Your Turn" : `${currentPlayerName}'s Turn`} + {isMyTurn && "Your Turn"} + {isCpuTurn && ( +
+
+ {currentPlayerName} is thinking... +
+ )} + {!isMyTurn && !isCpuTurn && `${currentPlayerName}'s Turn`}
+

Time

@@ -235,13 +255,15 @@ export default function RoomPage() { const activePlayerIds = user ? (gameState?.players.filter( - (p) => gameState?.playerStatus[p] === "playing", + (p) => p.type === "player" || p.type === "cpu", ) ?? null) : null; const opponentIds = user - ? (activePlayerIds?.filter((p) => p !== user.id) ?? null) + ? (activePlayerIds?.filter((p) => p.id !== user.id) ?? null) : null; - const currentPlayerId = gameState?.players[gameState.turn] ?? null; + const currentPlayer = + gameState?.players[gameState.currentPlayerIndex] ?? null; + const isCPUTurn = currentPlayer?.type === "cpu"; const [selectedNumIndex, setSelectedNumIndex] = useState(null); const [selectedOperation, setSelectedOperation] = useState("add"); @@ -390,7 +412,7 @@ export default function RoomPage() { console.log( "Loading or waiting for game state...", gameState, - currentPlayerId, + currentPlayer?.id, ); return (

@@ -400,12 +422,12 @@ export default function RoomPage() { } if (myStatus === "spectating") { - if (!currentPlayerId) { - throw new Error("Current player ID is missing"); + if (!currentPlayer) { + throw new Error("Current player is missing"); } const playingPlayers = gameState.players.filter( - (p) => gameState.playerStatus[p] === "playing", + (p) => p.type === "player" || p.type === "cpu", ); const spectatedPlayer = spectatedPlayerId @@ -430,14 +452,14 @@ export default function RoomPage() { > Overview - {playingPlayers.map((pId) => ( + {playingPlayers.map((p) => ( ))}
@@ -447,14 +469,14 @@ export default function RoomPage() { {spectatedPlayer ? // Single player perspective playingPlayers - .filter((pId) => pId !== spectatedPlayer.id) - .map((opponentId) => - gameState.missions[opponentId] ? ( + .filter((p) => p.id !== spectatedPlayer.id) + .map((opponent) => + gameState.missions[opponent.id] ? ( ) : null, @@ -466,9 +488,10 @@ export default function RoomPage() {
{}} /> @@ -492,21 +515,21 @@ export default function RoomPage() {
{ // Overview perspective - playingPlayers.map((playerId) => - gameState.missions[playerId] ? ( -
+ playingPlayers.map((p) => + gameState.missions[p.id] ? ( +
{}} selectedNumIndex={null} /> @@ -542,14 +565,14 @@ export default function RoomPage() {
    - {gameState.players.map((playerId) => ( + {gameState.players.map((p) => (
  • - {gameState.names[playerId]} - {playerId === roomHost && ( + {gameState.names[p.id]} + {p.id === roomHost && ( Host @@ -557,20 +580,20 @@ export default function RoomPage() { - {gameState.playerStatus[playerId] === "ready" + {gameState.playerStatus[p.id] === "ready" ? "Ready!" - : gameState.playerStatus[playerId] === "spectatingReady" + : gameState.playerStatus[p.id] === "spectatingReady" ? "Spectator" - : gameState.playerStatus[playerId] === "error" + : gameState.playerStatus[p.id] === "error" ? "Error" : "Preparing..."} @@ -641,6 +664,27 @@ export default function RoomPage() {
+
+ +

GAME SET

- {gameState.winners && ( + {gameState.lastGameResult.winners && (
- {gameState.winners.map((winnersId) => ( + {gameState.lastGameResult.winners.map((winnersId) => (

{gameState.names[winnersId]}

@@ -700,21 +748,22 @@ export default function RoomPage() {
); } - if (winnerDisplay === gameState.winners.length) { + if (winnerDisplay === gameState.lastGameResult.winners.length) { return (

- Result {winnerDisplay}/{gameState.winners.length} + Result {winnerDisplay}/{gameState.lastGameResult.winners.length}

@@ -722,7 +771,9 @@ export default function RoomPage() {
@@ -749,16 +800,17 @@ export default function RoomPage() {

- Result {winnerDisplay}/{gameState.winners.length} + Result {winnerDisplay}/{gameState.lastGameResult.winners.length}

@@ -766,7 +818,9 @@ export default function RoomPage() {
@@ -791,21 +845,22 @@ export default function RoomPage() { } if (myStatus === "playing") { - if (!currentPlayerId) { - throw new Error("Current player ID is missing"); + if (!currentPlayer) { + throw new Error("Current player is missing"); } + const isCpuTurn = currentPlayer.type === "cpu"; return (
Password:{roomSecret}
{/* Opponent's Info */} {opponentIds && (
- {opponentIds.map((opponentId) => ( + {opponentIds.map((opponent) => ( ))} @@ -815,12 +870,19 @@ export default function RoomPage() {
- +
+ +
{/* Player's Info */}
@@ -830,7 +892,11 @@ export default function RoomPage() { description={gameState?.missions[user.id]?.mission.description} /> )} -
+
{gameState.hands[user.id] && ( - {playingPlayers.map((pId) => ( + {playingPlayers.map((p) => ( ))}
@@ -439,14 +440,14 @@ export default function RoomPage() { {spectatedPlayer ? // Single player perspective playingPlayers - .filter((pId) => pId !== spectatedPlayer.id) - .map((opponentId) => - gameState.missions[opponentId] ? ( + .filter((p) => p.id !== spectatedPlayer.id) + .map((opponent) => + gameState.missions[opponent.id] ? ( ) : null, @@ -458,8 +459,8 @@ export default function RoomPage() {
@@ -484,21 +485,21 @@ export default function RoomPage() {
{ // Overview perspective - playingPlayers.map((playerId) => - gameState.missions[playerId] ? ( -
+ playingPlayers.map((player) => + gameState.missions[player.id] ? ( +
{}} selectedNumIndex={null} /> @@ -530,14 +531,14 @@ export default function RoomPage() {
    - {gameState.players.map((playerId) => ( + {gameState.players.map((player) => (
  • - {gameState.names[playerId]} - {playerId === roomHost && ( + {gameState.names[player.id]} + {player.id === roomHost && ( Host @@ -545,16 +546,16 @@ export default function RoomPage() { - {gameState.playerStatus[playerId] === "ready" + {gameState.playerStatus[player.id] === "ready" ? "Ready!" - : gameState.playerStatus[playerId] === "error" + : gameState.playerStatus[player.id] === "error" ? "Error" : "Preparing..."} @@ -639,7 +640,11 @@ export default function RoomPage() { } if (myStatus === "finished") { - if (!gameState.winners || gameState.winners.length === 0) { + if ( + !gameState.lastGameResult || + !gameState.lastGameResult.winners || + gameState.lastGameResult.winners.length === 0 + ) { throw new Error("Winners data is missing"); } if (winnerDisplay === 0) { @@ -648,9 +653,9 @@ export default function RoomPage() {

    GAME SET

    - {gameState.winners && ( + {gameState.lastGameResult.winners && (
    - {gameState.winners.map((winnersId) => ( + {gameState.lastGameResult.winners.map((winnersId) => (

    {gameState.names[winnersId]}

    @@ -678,21 +683,22 @@ export default function RoomPage() {
    ); } - if (winnerDisplay === gameState.winners.length) { + if (winnerDisplay === gameState.lastGameResult.winners.length) { return (

    - Result {winnerDisplay}/{gameState.winners.length} + Result {winnerDisplay}/{gameState.lastGameResult.winners.length}

    @@ -700,7 +706,9 @@ export default function RoomPage() {
    @@ -727,16 +735,17 @@ export default function RoomPage() {

    - Result {winnerDisplay}/{gameState.winners.length} + Result {winnerDisplay}/{gameState.lastGameResult.winners.length}

    @@ -744,7 +753,9 @@ export default function RoomPage() {
    @@ -769,8 +780,8 @@ export default function RoomPage() { } if (myStatus === "playing") { - if (!currentPlayerId) { - throw new Error("Current player ID is missing"); + if (!currentPlayer) { + throw new Error("Current player is missing"); } return (
    @@ -778,12 +789,12 @@ export default function RoomPage() { {/* Opponent's Info */} {opponentIds && (
    - {opponentIds.map((opponentId) => ( + {opponentIds.map((opponent) => ( ))} @@ -793,8 +804,8 @@ export default function RoomPage() {
    @@ -823,7 +834,7 @@ export default function RoomPage() { />
    -
    + {/*
    -
    +
    */}