Skip to content

Commit

Permalink
Upgrade (#2)
Browse files Browse the repository at this point in the history
* Upgrade

* Upgrade

* unsubscribe from channels, register new players joining in

* fix

* fix

* fix

* fix
  • Loading branch information
AlemTuzlak authored Dec 4, 2024
1 parent 3de2ee5 commit 5df677c
Show file tree
Hide file tree
Showing 9 changed files with 181 additions and 142 deletions.
3 changes: 1 addition & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,7 @@ jobs:
node-version-file: "package.json"
cache: "pnpm"
- run: pnpm install --prefer-offline --frozen-lockfile
#- run: pnpm run typecheck
- run: echo "Type Checks are disabled ✅"
- run: pnpm run typecheck

vitest:
needs: typecheck
Expand Down
3 changes: 3 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,5 +39,8 @@
"Readme*": "AUTHORS, Authors, BACKERS*, Backers*, CHANGELOG*, CITATION*, CODEOWNERS, CODE_OF_CONDUCT*, CONTRIBUTING*, CONTRIBUTORS, COPYING*, CREDITS, Changelog*, Citation*, Code_Of_Conduct*, Codeowners, Contributing*, Contributors, Copying*, Credits, GOVERNANCE.MD, Governance.md, HISTORY.MD, History.md, LICENSE*, License*, MAINTAINERS, Maintainers, README-*, README_*, RELEASE_NOTES*, ROADMAP.MD, Readme-*, Readme_*, Release_Notes*, Roadmap.md, SECURITY.MD, SPONSORS*, Security.md, Sponsors*, authors, backers*, changelog*, citation*, code_of_conduct*, codeowners, contributing*, contributors, copying*, credits, governance.md, history.md, license*, maintainers, readme-*, readme_*, release_notes*, roadmap.md, security.md, sponsors*",
"README*": "AUTHORS, Authors, BACKERS*, Backers*, CHANGELOG*, CITATION*, CODEOWNERS, CODE_OF_CONDUCT*, CONTRIBUTING*, CONTRIBUTORS, COPYING*, CREDITS, Changelog*, Citation*, Code_Of_Conduct*, Codeowners, Contributing*, Contributors, Copying*, Credits, GOVERNANCE.MD, Governance.md, HISTORY.MD, History.md, LICENSE*, License*, MAINTAINERS, Maintainers, README-*, README_*, RELEASE_NOTES*, ROADMAP.MD, Readme-*, Readme_*, Release_Notes*, Roadmap.md, SECURITY.MD, SPONSORS*, Security.md, Sponsors*, authors, backers*, changelog*, citation*, code_of_conduct*, codeowners, contributing*, contributors, copying*, credits, governance.md, history.md, license*, maintainers, readme-*, readme_*, release_notes*, roadmap.md, security.md, sponsors*",
"Dockerfile": "*.dockerfile, .devcontainer.*, .dockerignore, captain-definition, compose.*, docker-compose.*, dockerfile*"
},
"[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
}
}
23 changes: 0 additions & 23 deletions app/components/game/PlayerCursors.tsx

This file was deleted.

38 changes: 38 additions & 0 deletions app/realtime/player.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { RealtimeMessage } from "@supabase/supabase-js"
import { queryClient } from "~/root"
import type { RoomLoaderData } from "~/routes/rooms.$roomId"

export const handlePlayerUpdate =
(roomId: string, serverLoader: () => Promise<RoomLoaderData>) => async (payload: RealtimeMessage["payload"]) => {
const newItem = payload.new as {
player_id: string
isActive: boolean
score: number
id: string
scrollPosition: {
x: number
y: number
}
}
// biome-ignore lint/style/noNonNullAssertion: This will be there
const data = queryClient.getQueryData<RoomLoaderData>(["room", roomId])!
if (!data.players.find((p) => p.playerId === newItem.player_id)) {
const data = await serverLoader()

return queryClient.setQueryData(["room", roomId], data)
}
queryClient.setQueryData<typeof data>(["room", roomId], {
...data,
players: data.players.map((player) => {
if (player.playerId === newItem.player_id) {
return {
...player,
isActive: newItem.isActive,
score: newItem.score,
scrollPosition: newItem.scrollPosition,
}
}
return player
}),
})
}
20 changes: 20 additions & 0 deletions app/realtime/room.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { RealtimeMessage } from "@supabase/supabase-js"
import { queryClient } from "~/root"
import type { RoomLoaderData } from "~/routes/rooms.$roomId"

export const handleRoomUpdate = (roomId: string) => (payload: RealtimeMessage["payload"]) => {
const newRoomInfo = payload.new
// biome-ignore lint/style/noNonNullAssertion: This will be there
const data = queryClient.getQueryData<RoomLoaderData>(["room", roomId])!
if (payload.new.id !== data.room.id) return
queryClient.setQueryData<typeof data>(["room", data.room.id], {
...data,
room: {
...data.room,
...newRoomInfo,
flippedIndices: newRoomInfo.flippedIndices ?? data.room.flippedIndices,
matchedPairs: newRoomInfo.matchedPairs ?? data.room.matchedPairs,
currentTurn: newRoomInfo.current_turn,
},
})
}
6 changes: 4 additions & 2 deletions app/root.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { useTranslation } from "react-i18next"
import {
Form,
Expand All @@ -16,7 +17,8 @@ import type { Route } from "./+types/root"
import { getUserFromRequest } from "./queries/user.server"
import { commitServerSession, getServerSession } from "./session.server"
import tailwindcss from "./tailwind.css?url"

// Create a client
export const queryClient = new QueryClient()
export async function loader({ context, request }: Route.LoaderArgs) {
const { lang, clientEnv } = context
const player = await getUserFromRequest(request)
Expand Down Expand Up @@ -75,7 +77,7 @@ export const Layout = ({ children }: { children: React.ReactNode }) => {
<Links />
</head>
<body className="w-full h-full">
{children}
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
<ScrollRestoration />
<Scripts />
</body>
Expand Down
205 changes: 93 additions & 112 deletions app/routes/rooms.$roomId.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { useQuery } from "@tanstack/react-query"
import { QRCodeSVG } from "qrcode.react"
import { useEffect, useState } from "react"
import { type LoaderFunctionArgs, redirect, useRevalidator, useSubmit } from "react-router"
import { useEffect } from "react"
import { type LoaderFunctionArgs, redirect, useSubmit } from "react-router"
import { Grid } from "~/components/game/Grid"
import { Leaderboard } from "~/components/game/Leaderboard"
import { VictoryScreen } from "~/components/game/VictoryScreen"
import { db } from "~/db.server"
import { getUserFromRequest } from "~/queries/user.server"
import { handlePlayerUpdate } from "~/realtime/player"
import { handleRoomUpdate } from "~/realtime/room"
import { queryClient } from "~/root"
import { supabase } from "~/utils/supabase"
import type { Route } from "./+types/rooms.$roomId"

Expand Down Expand Up @@ -51,6 +55,10 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
return { room, players: room.activePlayers, cards: room.cards, user, qrCode }
}

export type RoomLoaderData = Awaited<ReturnType<typeof loader>>
export type Player = Awaited<ReturnType<typeof loader>>["players"][0]
export type Room = Awaited<ReturnType<typeof loader>>["room"]

export const action = async ({ request }: Route.ActionArgs) => {
const formData = await request.formData()
const playerId = formData.get("playerId") as string | null
Expand All @@ -70,139 +78,112 @@ export const action = async ({ request }: Route.ActionArgs) => {
return redirect("/")
}

export type Player = Awaited<ReturnType<typeof loader>>["players"][0]
export type Room = Awaited<ReturnType<typeof loader>>["room"]
export const clientLoader = async ({ serverLoader, params }: Route.ClientLoaderArgs) => {
// Try to get the data from the cache
const cachedData = queryClient.getQueryData<RoomLoaderData>(["room", params.roomId])
// Either used the cached data or fetch it from the server
const data = cachedData ?? (await serverLoader())
// Don't set the data if it's already there
if (!cachedData) {
queryClient.setQueryData(["room", params.roomId], data)
}
// Subscribe to real-time updates on the room
const roomSubscription = supabase
.channel("rooms")
.on("postgres_changes", { event: "*", schema: "public", table: "rooms" }, handleRoomUpdate(params.roomId))
.subscribe()
// Subscribe to real-time updates on the active players
const playerSubscription = supabase
.channel("active_players")
.on(
"postgres_changes",
{ event: "*", schema: "public", table: "active_players" },
handlePlayerUpdate(params.roomId, serverLoader)
)
.subscribe()

const unsubscribe = () => {
roomSubscription.unsubscribe()
playerSubscription.unsubscribe()
}
return {
...data,
unsubscribe,
}
}

export default function Room({ loaderData }: Route.ComponentProps) {
const { room, players, cards, user, qrCode } = loaderData
const [activeRoom, setActiveRoom] = useState<Room>(room)
const [activePlayers, setActivePlayers] = useState<Player[]>(players)
const showVictory = activeRoom.status === "complete"
const submit = useSubmit()
useEffect(() => {
setActiveRoom(room)
}, [room])
clientLoader.hydrate = true

export default function Room({ loaderData, params }: Route.ComponentProps) {
const { room, cards, user, qrCode } = loaderData
const submit = useSubmit()
const { data } = useQuery<Route.ComponentProps["loaderData"]>({ queryKey: ["room", params.roomId] })
// Close the channels when the user leaves the page
useEffect(() => {
setActivePlayers(players)
}, [players])
return () => {
if ("unsubscribe" in loaderData) {
loaderData.unsubscribe()
}
}
}, [loaderData])
if (!data) return <div>Loading...</div>
const activeRoom = data.room
const activePlayers = data.players
const showVictory = activeRoom.status === "complete"

const { revalidate } = useRevalidator()
const markAsInactive = () => {
const leaveGame = () => {
const formData = new FormData()
const activePlayer = activePlayers.find((p) => p.playerId === user.id)
if (!activePlayer) return
formData.append("id", activePlayer.id)
formData.append("playerId", user.id)
submit(formData, { method: "POST" })
}
// Subscribe to real-time updates
useEffect(() => {
const roomSubscription = supabase
.channel("rooms")
.on("postgres_changes", { event: "*", schema: "public", table: "rooms" }, (payload) => {
const newItem = payload.new as {
id: string
created_at: Date
gridSize: number
status: string
created_by: string
current_turn: string | null
winnerId: string | null
matchedPairs: string[]
flippedIndices: number[]
cards: string[]
name: string
}
setActiveRoom({
...room,
...newItem,
flippedIndices: newItem.flippedIndices ?? room.flippedIndices,
matchedPairs: newItem.matchedPairs ?? room.matchedPairs,
currentTurn: newItem.current_turn,
})
})
.subscribe()

const playerSubscription = supabase
.channel("active_players")
.on("postgres_changes", { event: "*", schema: "public", table: "active_players" }, (payload) => {
const newItem = payload.new as {
player_id: string
isActive: boolean
score: number
id: string
scrollPosition: {
x: number
y: number
}
}

if (!activePlayers.find((p) => p.playerId === newItem.player_id)) {
revalidate()
}
setActivePlayers(
activePlayers.map((player) => {
if (player.playerId === newItem.player_id) {
return {
...player,
isActive: newItem.isActive,
score: newItem.score,
scrollPosition: newItem.scrollPosition,
}
}
return player
})
)

//revalidate()
})
.subscribe()

return () => {
roomSubscription.unsubscribe()
playerSubscription.unsubscribe()
}
}, [revalidate, activePlayers, room])

// Update room data
const updateRoom = async (data: Partial<Room & { current_turn?: string }>) => {
await supabase.from("rooms").update(data).eq("id", activeRoom.id)
}
const updatePlayer = async (data: Partial<Player>) => {
await supabase.from("active_players").update(data).eq("room_id", activeRoom.id).eq("player_id", user.id)
}
// Handle card clicks
const handleCardClick = async (index: number) => {
// Don't do anything if there are already two flipped cards
if (activeRoom.flippedIndices.length === 2) return
// Add the clicked card to the flipped indices
const newFlippedIndices = [...activeRoom.flippedIndices, index]
await supabase
.from("rooms")
.update({ flippedIndices: [...newFlippedIndices] })
.eq("id", room.id)
// Update the room with the new flipped indices so everyone can see the change
await updateRoom({ flippedIndices: [...newFlippedIndices] })
// Check if it's a match if two cards are flipped
if (activeRoom.flippedIndices.length === 1) {
// Check if the two cards are the same
const isMatch = cards[activeRoom.flippedIndices[0]] === cards[index]
const currentPlayerIndex = activePlayers.findIndex((p) => p.player?.name === activeRoom.currentTurn)
const currentPlayer = activePlayers[currentPlayerIndex]
// If it's a match, update the player's score and the room's status
if (isMatch) {
const newMatchedPairs = [...activeRoom.matchedPairs, cards[index]]
const isFinished = newMatchedPairs.length === room.gridSize * 2
await supabase
.from("active_players")
.update({ score: currentPlayer.score + 1 })
.eq("room_id", room.id)
.eq("player_id", user.id)
await supabase
.from("rooms")
.update({ matchedPairs: newMatchedPairs, status: isFinished ? "complete" : "waiting" })
.eq("id", room.id)
if (isFinished) {
return
}
// Check if the game is finished and update the room and the player
const isFinished = newMatchedPairs.length === activeRoom.gridSize * 2
// Update the player's score
await updatePlayer({ score: currentPlayer.score + 1 })
// Update the room's status and matched pairs
await updateRoom({ matchedPairs: newMatchedPairs, status: isFinished ? "complete" : "waiting" })
// Revert the flipped cards so the game can go on
setTimeout(async () => {
await updateRoom({ flippedIndices: [] })
}, 1000)
// Exit early so that we don't go into the setTimeout below
return
}

setTimeout(async () => {
await supabase.from("rooms").update({ flippedIndices: [] }).eq("id", room.id)

const nextPlayer = activePlayers[currentPlayerIndex + 1]
if (nextPlayer === undefined) {
await supabase.from("rooms").update({ current_turn: activePlayers[0].player?.name }).eq("id", room.id)
} else {
await supabase.from("rooms").update({ current_turn: nextPlayer.player?.name }).eq("id", room.id)
}
// Move to the next player
await updateRoom({
flippedIndices: [],
current_turn: !nextPlayer ? activePlayers[0].player?.name : nextPlayer.player?.name,
})
}, 1000)
}
}
Expand All @@ -215,7 +196,7 @@ export default function Room({ loaderData }: Route.ComponentProps) {
<div className="bg-gray-50 min-h-screen">
<h1 className="bg-indigo-600 text-white drop-shadow-xl flex items-center justify-between text-center text-2xl font-bold p-4 lg:px-12">
{activeRoom.name}
<button type="button" className="bg-red-500 text-base text-white px-4 py-2 rounded-md" onClick={markAsInactive}>
<button type="button" className="bg-red-500 text-base text-white px-4 py-2 rounded-md" onClick={leaveGame}>
Leave
</button>
</h1>
Expand All @@ -224,7 +205,7 @@ export default function Room({ loaderData }: Route.ComponentProps) {
<div className="flex lg:flex-row flex-col justify-between gap-4">
<Grid
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
size={activeRoom.gridSize as any}
size={room.gridSize as any}
cards={cards}
flippedIndices={activeRoom.flippedIndices}
matchedPairs={activeRoom.matchedPairs}
Expand Down
Loading

0 comments on commit 5df677c

Please sign in to comment.