Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Using test #6

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .babelrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"presets": ["@babel/preset-typescript"],
"plugins": ["@babel/plugin-proposal-explicit-resource-management", "babel-plugin-react-compiler"]
}
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,14 @@
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 { 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 +54,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 +77,113 @@ 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"]
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
const realtime = (roomId: string, serverLoader: any) => {
// Subscribe to real-time updates on the room
const roomSubscription = supabase
.channel("rooms")
.on("postgres_changes", { event: "*", schema: "public", table: "rooms" }, handleRoomUpdate(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(roomId, serverLoader)
)
.subscribe()
return {
test: "",
[Symbol.dispose]() {
playerSubscription.unsubscribe()
roomSubscription.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"
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)
}

using _a = realtime(params.roomId, serverLoader)

return {
...data,
}
}

clientLoader.hydrate = true

export default function Room({ loaderData, params }: Route.ComponentProps) {
const { room, cards, user, qrCode } = loaderData
const submit = useSubmit()
useEffect(() => {
setActiveRoom(room)
}, [room])
const { data } = useQuery<Route.ComponentProps["loaderData"]>({ queryKey: ["room", params.roomId] })

useEffect(() => {
setActivePlayers(players)
}, [players])
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