diff --git a/client/src/App.tsx b/client/src/App.tsx index fde1755e..2dec7c54 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -2,7 +2,7 @@ import '@/App.css' import Landing from "@/routes/landing.tsx"; import Lobby from "@/routes/lobby.tsx"; import Login from "@/routes/login.tsx"; -import {createBrowserRouter, RouterProvider} from "react-router-dom"; +import {createBrowserRouter, Navigate, RouterProvider} from "react-router-dom"; import {AuthGuard} from "@/auth/auth-guard.tsx"; import '@/i18n'; import {MainWrapper} from "@/components/main-wrapper.tsx"; @@ -24,6 +24,10 @@ const router = createBrowserRouter([ }, { path: "/lobby", + element: , + }, + { + path: "/lobby/:section", element: , }, { diff --git a/client/src/api/client.ts b/client/src/api/client.ts index aa03091c..729b58fb 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -42,6 +42,7 @@ export const api = { // Game createGame: (data: CreateCustomGameRequest) => generatedApi.postGameCreate({client: customClient, body: data}), + dailyChallenge: () => generatedApi.postGameDaily({client: customClient}), joinGame: (data: JoinGameRequest) => generatedApi.postGameJoin({client: customClient, body: data}), playCard: (data: PlayCardRequest) => generatedApi.postGamePlay({client: customClient, body: data}), chooseTrump: (data: ChooseTrumpRequest) => generatedApi.postGameTrump({client: customClient, body: data}), diff --git a/client/src/api/generated/index.ts b/client/src/api/generated/index.ts index a930ec3e..3dba512c 100644 --- a/client/src/api/generated/index.ts +++ b/client/src/api/generated/index.ts @@ -1,4 +1,4 @@ // This file is auto-generated by @hey-api/openapi-ts -export { anonLink, anonLogout, anonSignup, connect, getAdminAnalyzeGameByCode, type Options, postAdminMessage, postGameCreate, postGameJoin, postGamePing, postGamePlay, postGameSchiebe, postGameTrump, postGameWeisen, subscribe, whoami } from './sdk.gen'; -export type { AnalyzeGameStateResponse, AnalyzeHand, AnonLinkData, AnonLinkErrors, AnonLinkResponse, AnonLinkResponses, AnonLogoutData, AnonLogoutResponse, AnonLogoutResponses, AnonSignupData, AnonSignupErrors, AnonSignupRequest, AnonSignupResponse, AnonSignupResponse2, AnonSignupResponses, Card, CardInHand, CardOnTable, ChooseTrumpRequest, ClientOptions, ConnectData, ConnectErrors, ConnectResponse, ConnectResponses, CreateCustomGameRequest, CreateCustomGameResponse, GameFinished, GetAdminAnalyzeGameByCodeData, GetAdminAnalyzeGameByCodeResponse, GetAdminAnalyzeGameByCodeResponses, JoinGameRequest, JoinGameResponse, MessageRequest, PingSeatRequest, PlayCardRequest, PlayedCard, PlayedCardWithPlayer, Player, PlayerAtTable, PlayerWithCards, PlayerWithWeise, Position, PostAdminMessageData, PostAdminMessageResponse, PostAdminMessageResponses, PostGameCreateData, PostGameCreateResponse, PostGameCreateResponses, PostGameJoinData, PostGameJoinResponse, PostGameJoinResponses, PostGamePingData, PostGamePingResponse, PostGamePingResponses, PostGamePlayData, PostGamePlayResponse, PostGamePlayResponses, PostGameSchiebeData, PostGameSchiebeResponse, PostGameSchiebeResponses, PostGameTrumpData, PostGameTrumpResponse, PostGameTrumpResponses, PostGameWeisenData, PostGameWeisenResponse, PostGameWeisenResponses, Rank, SchiebeRequest, SeatState, State, SubscribeData, SubscribeErrors, SubscribeRequest, SubscribeResponse, SubscribeResponses, SuccessfulActionResponse, Suit, TeamWithPoints, TotalPoints, TrickWithCards, Trump, Weis, WeisenRequest, WeisType, WeisWithPoints, WhoamiData, WhoamiErrors, WhoamiResponse, WhoAmIResponse, WhoamiResponses } from './types.gen'; +export { anonLink, anonLogout, anonSignup, connect, getAdminAnalyzeGameByCode, type Options, postAdminMessage, postGameCreate, postGameDaily, postGameJoin, postGamePing, postGamePlay, postGameSchiebe, postGameTrump, postGameWeisen, subscribe, whoami } from './sdk.gen'; +export type { AnalyzeGameStateResponse, AnalyzeHand, AnonLinkData, AnonLinkErrors, AnonLinkResponse, AnonLinkResponses, AnonLogoutData, AnonLogoutResponse, AnonLogoutResponses, AnonSignupData, AnonSignupErrors, AnonSignupRequest, AnonSignupResponse, AnonSignupResponse2, AnonSignupResponses, Card, CardInHand, CardOnTable, ChooseTrumpRequest, ClientOptions, ConnectData, ConnectErrors, ConnectResponse, ConnectResponses, CreateCustomGameRequest, CreateCustomGameResponse, CreateDailyChallengeResponse, GameFinished, GetAdminAnalyzeGameByCodeData, GetAdminAnalyzeGameByCodeResponse, GetAdminAnalyzeGameByCodeResponses, JoinGameRequest, JoinGameResponse, MessageRequest, PingSeatRequest, PlayCardRequest, PlayedCard, PlayedCardWithPlayer, Player, PlayerAtTable, PlayerWithCards, PlayerWithWeise, Position, PostAdminMessageData, PostAdminMessageResponse, PostAdminMessageResponses, PostGameCreateData, PostGameCreateResponse, PostGameCreateResponses, PostGameDailyData, PostGameDailyResponse, PostGameDailyResponses, PostGameJoinData, PostGameJoinResponse, PostGameJoinResponses, PostGamePingData, PostGamePingResponse, PostGamePingResponses, PostGamePlayData, PostGamePlayResponse, PostGamePlayResponses, PostGameSchiebeData, PostGameSchiebeResponse, PostGameSchiebeResponses, PostGameTrumpData, PostGameTrumpResponse, PostGameTrumpResponses, PostGameWeisenData, PostGameWeisenResponse, PostGameWeisenResponses, Rank, SchiebeRequest, SeatState, State, SubscribeData, SubscribeErrors, SubscribeRequest, SubscribeResponse, SubscribeResponses, SuccessfulActionResponse, Suit, TeamWithPoints, TotalPoints, TrickWithCards, Trump, Weis, WeisenRequest, WeisType, WeisWithPoints, WhoamiData, WhoamiErrors, WhoamiResponse, WhoAmIResponse, WhoamiResponses } from './types.gen'; diff --git a/client/src/api/generated/sdk.gen.ts b/client/src/api/generated/sdk.gen.ts index 2410d48f..a6e8945b 100644 --- a/client/src/api/generated/sdk.gen.ts +++ b/client/src/api/generated/sdk.gen.ts @@ -2,7 +2,7 @@ import type { Client, Options as Options2, TDataShape } from './client'; import { client } from './client.gen'; -import type { AnonLinkData, AnonLinkErrors, AnonLinkResponses, AnonLogoutData, AnonLogoutResponses, AnonSignupData, AnonSignupErrors, AnonSignupResponses, ConnectData, ConnectErrors, ConnectResponses, GetAdminAnalyzeGameByCodeData, GetAdminAnalyzeGameByCodeResponses, PostAdminMessageData, PostAdminMessageResponses, PostGameCreateData, PostGameCreateResponses, PostGameJoinData, PostGameJoinResponses, PostGamePingData, PostGamePingResponses, PostGamePlayData, PostGamePlayResponses, PostGameSchiebeData, PostGameSchiebeResponses, PostGameTrumpData, PostGameTrumpResponses, PostGameWeisenData, PostGameWeisenResponses, SubscribeData, SubscribeErrors, SubscribeResponses, WhoamiData, WhoamiErrors, WhoamiResponses } from './types.gen'; +import type { AnonLinkData, AnonLinkErrors, AnonLinkResponses, AnonLogoutData, AnonLogoutResponses, AnonSignupData, AnonSignupErrors, AnonSignupResponses, ConnectData, ConnectErrors, ConnectResponses, GetAdminAnalyzeGameByCodeData, GetAdminAnalyzeGameByCodeResponses, PostAdminMessageData, PostAdminMessageResponses, PostGameCreateData, PostGameCreateResponses, PostGameDailyData, PostGameDailyResponses, PostGameJoinData, PostGameJoinResponses, PostGamePingData, PostGamePingResponses, PostGamePlayData, PostGamePlayResponses, PostGameSchiebeData, PostGameSchiebeResponses, PostGameTrumpData, PostGameTrumpResponses, PostGameWeisenData, PostGameWeisenResponses, SubscribeData, SubscribeErrors, SubscribeResponses, WhoamiData, WhoamiErrors, WhoamiResponses } from './types.gen'; export type Options = Options2 & { /** @@ -131,6 +131,18 @@ export const postGamePing = (options: Opti } }); +/** + * Join or create today's daily challenge + * + * Returns the code of today's daily challenge game for the current player. Creates the game on the first call of the day, returns the already running (or finished) game on every later call. Only available for signed up players. + * + */ +export const postGameDaily = (options?: Options) => (options?.client ?? client).post({ + responseType: 'json', + url: '/game/daily', + ...options +}); + /** * Get current user information */ diff --git a/client/src/api/generated/types.gen.ts b/client/src/api/generated/types.gen.ts index c3042433..4c84f4e7 100644 --- a/client/src/api/generated/types.gen.ts +++ b/client/src/api/generated/types.gen.ts @@ -59,6 +59,10 @@ export type CreateCustomGameResponse = { code?: string; }; +export type CreateDailyChallengeResponse = { + code: string; +}; + export type JoinGameRequest = { code: string; }; @@ -400,6 +404,22 @@ export type PostGamePingResponses = { export type PostGamePingResponse = PostGamePingResponses[keyof PostGamePingResponses]; +export type PostGameDailyData = { + body?: never; + path?: never; + query?: never; + url: '/game/daily'; +}; + +export type PostGameDailyResponses = { + /** + * Daily challenge joined successfully + */ + 200: CreateDailyChallengeResponse; +}; + +export type PostGameDailyResponse = PostGameDailyResponses[keyof PostGameDailyResponses]; + export type WhoamiData = { body?: never; path?: never; diff --git a/client/src/components/create-game-overlay.tsx b/client/src/components/lobby/create-section.tsx similarity index 59% rename from client/src/components/create-game-overlay.tsx rename to client/src/components/lobby/create-section.tsx index 120a5815..fa34e98c 100644 --- a/client/src/components/create-game-overlay.tsx +++ b/client/src/components/lobby/create-section.tsx @@ -1,4 +1,4 @@ -import {Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger} from "@/components/ui/dialog" +import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card" import {Button} from "@/components/ui/button" import {AlertCircle, Bot, Loader2, Plus, User} from "lucide-react" import {useTranslation} from "react-i18next" @@ -9,7 +9,6 @@ import {api} from "@/api/client" import {useAxiosErrorHandler} from "@/hooks/use-axios-error-handler" import {useAsyncAction} from "@/hooks/use-async-action" import {CreateCustomGameRequest} from "@/api/generated"; -import {DialogDescription} from "@radix-ui/react-dialog"; import {useState} from "react"; import {isAxiosError} from "axios"; import {useNavigate} from "react-router-dom"; @@ -52,10 +51,9 @@ const TeamButton = ({team, selected, onClick}: { team: TeamComposition, selected ) } -export function CreateGameOverlay() { +export function CreateSection() { const {t} = useTranslation() const handleAxiosError = useAxiosErrorHandler() - const [open, setOpen] = useState(false) const [error, setError] = useState(null) const navigate = useNavigate() const [nsTeam, setNsTeam] = useState('player-bot') @@ -80,7 +78,6 @@ export function CreateGameOverlay() { reset() try { const response = await executeCreateGame(settings) - setOpen(false) navigate(`/game/${response.data?.code}`) } catch (error: unknown) { if (isAxiosError(error) && error.response?.status === 422) { @@ -146,115 +143,111 @@ export function CreateGameOverlay() { } return ( - - - - - - - {t("create.title")} - {t("create.description")} - -
- {error && ( - - - {t("errors.title")} - - {error} - - - )} -
-
- -

- {t(`create.configDescription.${getConfigDescriptionKey()}`)} -

+ + +
+ + {t("create.title")} +
+ {t("create.description")} +
+ + {error && ( + + + {t("errors.title")} + + {error} + + + )} +
+
+ +

+ {t(`create.configDescription.${getConfigDescriptionKey()}`)} +

+
+
+
+ handleTeamSelection('NS', 'player-player')} + /> + handleTeamSelection('NS', 'player-bot')} + /> + handleTeamSelection('NS', 'bot-bot')} + />
-
-
- handleTeamSelection('NS', 'player-player')} - /> - handleTeamSelection('NS', 'player-bot')} - /> - handleTeamSelection('NS', 'bot-bot')} - /> -
-
- handleTeamSelection('EW', 'player-player')} - /> - handleTeamSelection('EW', 'player-bot')} - /> - handleTeamSelection('EW', 'bot-bot')} - /> -
+
+ handleTeamSelection('EW', 'player-player')} + /> + handleTeamSelection('EW', 'player-bot')} + /> + handleTeamSelection('EW', 'bot-bot')} + />
+
-
- - handleConditionTypeChange(value as 'POINTS' | 'HANDS')} - className="flex gap-4" - > -
- - -
-
- - -
-
-
+
+ + handleConditionTypeChange(value as 'POINTS' | 'HANDS')} + className="flex gap-4" + > +
+ + +
+
+ + +
+
+
-
- - setSettings(s => ({ - ...s, - winningConditionValue: parseInt(e.target.value) || 0 - }))} - /> -
+
+ + setSettings(s => ({ + ...s, + winningConditionValue: parseInt(e.target.value) || 0 + }))} + />
+ - -
+ + ) -} \ No newline at end of file +} diff --git a/client/src/components/lobby/daily-section.tsx b/client/src/components/lobby/daily-section.tsx new file mode 100644 index 00000000..20f7cdd6 --- /dev/null +++ b/client/src/components/lobby/daily-section.tsx @@ -0,0 +1,102 @@ +import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card" +import {Button} from "@/components/ui/button" +import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert" +import {Accordion, AccordionContent, AccordionItem, AccordionTrigger} from "@/components/ui/accordion" +import {AlertCircle, CalendarDays, Loader2} from "lucide-react" +import {useTranslation} from "react-i18next" +import {useState} from "react" +import {isAxiosError} from "axios" +import {useNavigate} from "react-router-dom" +import {api} from "@/api/client" +import {useAsyncAction} from "@/hooks/use-async-action" +import {useAxiosErrorHandler} from "@/hooks/use-axios-error-handler" +import {useMidnightCountdown} from "@/hooks/use-midnight-countdown" +import {usePlayerStore} from "@/store/player.ts" + +const rules = ["sameCards", "oncePerDay", "opponents"] + +export function DailySection() { + const {t} = useTranslation() + const navigate = useNavigate() + const handleAxiosError = useAxiosErrorHandler() + const isAnon = usePlayerStore(state => state.isAnon) + const remaining = useMidnightCountdown() + const [error, setError] = useState(null) + + const {execute: executeDaily, isLoading, hasError, reset} = useAsyncAction(async () => { + return api.dailyChallenge() + }) + + const handlePlay = async () => { + setError(null) + reset() + try { + const response = await executeDaily() + navigate(`/game/${response.data?.code}`) + } catch (error: unknown) { + if (!isAxiosError(error)) return + const domainError = (error.response?.data as { payload?: { domainError?: string } })?.payload?.domainError + if (domainError === "SignedUpPlayersOnly") { + return setError(t("lobby.daily.signedUpOnly")) + } + handleAxiosError(error) + } + } + + return ( + + +
+ + {t("lobby.daily.title")} +
+
+ + {error && ( + + + {t("errors.title")} + {error} + + )} + +
+ {remaining} + {t("lobby.daily.countdown")} +
+ + {isAnon ? ( +
+

{t("lobby.daily.signedUpOnly")}

+ +
+ ) : ( + + )} + + + + + {t("lobby.daily.explanation")} + + +
    + {rules.map(key =>
  • {t(`lobby.daily.rules.${key}`)}
  • )} +
+
+
+
+
+
+ ) +} diff --git a/client/src/components/lobby/join-section.tsx b/client/src/components/lobby/join-section.tsx new file mode 100644 index 00000000..d8703016 --- /dev/null +++ b/client/src/components/lobby/join-section.tsx @@ -0,0 +1,44 @@ +import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card" +import {Button} from "@/components/ui/button" +import {Input} from "@/components/ui/input" +import {LogIn} from "lucide-react" +import {useTranslation} from "react-i18next" +import {useState} from "react" +import {useNavigate} from "react-router-dom" + +export function JoinSection() { + const {t} = useTranslation() + const navigate = useNavigate() + const [gameCode, setGameCode] = useState("") + + const handleJoinGame = (e: React.FormEvent) => { + e.preventDefault() + navigate(`/game/${gameCode}`) + } + + return ( + + +
+ + {t("lobby.join.title")} +
+ {t("lobby.join.description")} +
+ +
+ setGameCode(e.target.value.toUpperCase())} + maxLength={5} + placeholder={t("main.game.codePlaceholder")} + className="col-span-2 text-center text-lg font-medium tracking-[0.25em] h-10" + /> + +
+
+
+ ) +} diff --git a/client/src/components/lobby/lobby-header.tsx b/client/src/components/lobby/lobby-header.tsx new file mode 100644 index 00000000..609f6c25 --- /dev/null +++ b/client/src/components/lobby/lobby-header.tsx @@ -0,0 +1,9 @@ +import {Logo} from "@/components/logo.tsx"; + +export function LobbyHeader() { + return ( +
+ +
+ ) +} diff --git a/client/src/components/main-screen.tsx b/client/src/components/main-screen.tsx index 0ea15bbf..1efc7c29 100644 --- a/client/src/components/main-screen.tsx +++ b/client/src/components/main-screen.tsx @@ -1,49 +1,123 @@ -import {Button} from "@/components/ui/button" -import {Card, CardContent, CardHeader, CardTitle,} from "@/components/ui/card" -import {Input} from "@/components/ui/input" -import {useState} from "react"; +import {useEffect, useRef} from "react"; import {useTranslation} from "react-i18next"; -import {usePlayerStore} from "@/store/player.ts"; -import {CreateGameOverlay} from "@/components/create-game-overlay.tsx"; -import {useNavigate} from "react-router-dom"; +import {Navigate, useNavigate, useParams} from "react-router-dom"; +import {motion} from "motion/react"; +import {CalendarDays, LogIn, Plus} from "lucide-react"; +import {cn} from "@/lib/utils"; +import {DailySection} from "@/components/lobby/daily-section.tsx"; +import {JoinSection} from "@/components/lobby/join-section.tsx"; +import {CreateSection} from "@/components/lobby/create-section.tsx"; + +const sections = [ + {id: "daily" as const, icon: CalendarDays, Section: DailySection}, + {id: "join" as const, icon: LogIn, Section: JoinSection}, + {id: "create" as const, icon: Plus, Section: CreateSection}, +] export function MainScreen() { - const [gameCode, setGameCode] = useState("") - const name = usePlayerStore(state => state.name) const {t} = useTranslation() + const {section} = useParams() const navigate = useNavigate() + const trackRef = useRef(null) + const alignedRef = useRef(false) + const fromScrollRef = useRef(false) + const targetRef = useRef(null) + + const active = sections.find(s => s.id === section)?.id + + // Aligns the carousel whenever the section changes through the URL, be it a tab click, a browser + // back or a deep link. The first alignment jumps so a deep link does not animate on load. + useEffect(() => { + const track = trackRef.current + if (!track || !active) return + + // A section change that came from swiping is already on screen. Scrolling the track again would + // fight the momentum of the ongoing gesture and carry it past the section the swipe aimed at. + if (fromScrollRef.current) { + fromScrollRef.current = false + alignedRef.current = true + return + } + + const left = sections.findIndex(s => s.id === active) * track.clientWidth + targetRef.current = left + track.scrollTo({left, behavior: alignedRef.current ? "smooth" : "auto"}) + alignedRef.current = true + + // The target is normally cleared once the track arrives. This releases it in case the animation + // never reports the exact position, so a swipe afterwards is not ignored forever. + const timeout = setTimeout(() => (targetRef.current = null), 700) + return () => clearTimeout(timeout) + }, [active]) - const handleJoinGame = (e: React.FormEvent) => { - e.preventDefault() - navigate(`/game/${gameCode}`) + // Keeps the navigation in sync while swiping through the carousel on mobile. On desktop only the + // active section is rendered, so the track never overflows and this is a no-op. + const handleScroll = () => { + const track = trackRef.current + if (!track || track.scrollWidth <= track.clientWidth) return + + // While the track animates towards a tapped section it travels over the ones in between. + // Syncing the navigation to those would drag the indicator back and forth before it settles. + if (targetRef.current !== null) { + if (Math.abs(track.scrollLeft - targetRef.current) <= 2) targetRef.current = null + return + } + + const id = sections[Math.round(track.scrollLeft / track.clientWidth)]?.id + if (!id || id === active) return + + fromScrollRef.current = true + navigate(`/lobby/${id}`, {replace: true}) } + if (!active) return + return ( - <> -
- - - {t("main.title")}, {name} - - -
-
- setGameCode(e.target.value.toUpperCase())} - maxLength={5} - placeholder={t("main.game.codePlaceholder")} - className="flex-1 text-center text-lg tracking-wider" - /> - -
- -
-
-
+
+ + +
+ {sections.map(({id, Section}) => ( +
+
+
+ ))}
- +
) } diff --git a/client/src/hooks/use-midnight-countdown.ts b/client/src/hooks/use-midnight-countdown.ts new file mode 100644 index 00000000..a8a53b82 --- /dev/null +++ b/client/src/hooks/use-midnight-countdown.ts @@ -0,0 +1,24 @@ +import {useEffect, useState} from "react" + +const format = () => { + const now = new Date() + const midnight = new Date(now) + midnight.setHours(24, 0, 0, 0) + + const seconds = Math.max(0, Math.floor((midnight.getTime() - now.getTime()) / 1000)) + const parts = [Math.floor(seconds / 3600), Math.floor(seconds / 60) % 60, seconds % 60] + return parts.map(part => String(part).padStart(2, "0")).join(":") +} + +// Time left until the next local midnight as HH:MM:SS. Players are assumed to be in Swiss time, +// which is what the server resets on. +export function useMidnightCountdown() { + const [remaining, setRemaining] = useState(format) + + useEffect(() => { + const interval = setInterval(() => setRemaining(format()), 1000) + return () => clearInterval(interval) + }, []) + + return remaining +} diff --git a/client/src/i18n/de.json b/client/src/i18n/de.json index 76a82276..9728fec1 100644 --- a/client/src/i18n/de.json +++ b/client/src/i18n/de.json @@ -121,11 +121,32 @@ "submit": "Erstellen" }, "main": { - "title": "Hallo", "game": { "codePlaceholder": "ABCDE", + "join": "Beitreten" + } + }, + "lobby": { + "tabs": { + "daily": "Tagestrumpf", "join": "Beitreten", - "create": "Spiel erstellen" + "create": "Erstellen" + }, + "daily": { + "title": "Tagestrumpf", + "countdown": "bis zum nächsten Tagestrumpf", + "play": "Jetzt spielen", + "explanation": "Was ist das?", + "rules": { + "sameCards": "Alle spielen mit den exakt gleich gemischten Karten", + "oncePerDay": "Jeden Tag eine Chance", + "opponents": "Du und ein Bot-Partner gegen zwei Bots auf 1000 Punkte" + }, + "signedUpOnly": "Mit einem Gast-Profil kannst du den Tagestrumpf nicht spielen." + }, + "join": { + "title": "Spiel beitreten", + "description": "Falls du einen Code erhalten hast kannst du ihn hier eingeben." } }, "logout": { diff --git a/client/src/i18n/en.json b/client/src/i18n/en.json index 3ada0f9f..16ea2be6 100644 --- a/client/src/i18n/en.json +++ b/client/src/i18n/en.json @@ -55,7 +55,6 @@ "4000034": "The password has been found in data breaches and must no longer be used.", "4010001": "The login flow expired {expired_at_unix_since_minutes} minutes ago, please try again." }, - "gameFull": { "title": "Game full", "description": "The game is already full" @@ -95,11 +94,32 @@ } }, "main": { - "title": "Hello", "game": { "codePlaceholder": "ABCDE", + "join": "Join" + } + }, + "lobby": { + "tabs": { + "daily": "Daily", "join": "Join", - "create": "Create game" + "create": "Create" + }, + "daily": { + "title": "Daily challenge", + "countdown": "until the next challenge", + "play": "Play challenge", + "explanation": "What is this?", + "rules": { + "sameCards": "Everyone plays the exact same shuffle", + "oncePerDay": "One shot every day", + "opponents": "You and a bot partner against two bots, first to 1000 points" + }, + "signedUpOnly": "Guest profiles can't play the daily challenge." + }, + "join": { + "title": "Join a game", + "description": "Got a code from a friend? Enter it here to take a seat." } }, "create": { diff --git a/client/src/routes/lobby.tsx b/client/src/routes/lobby.tsx index 9550cd87..3b607b33 100644 --- a/client/src/routes/lobby.tsx +++ b/client/src/routes/lobby.tsx @@ -1,15 +1,13 @@ import {MainScreen} from "@/components/main-screen.tsx"; -import {Logo} from "@/components/logo.tsx"; +import {LobbyHeader} from "@/components/lobby/lobby-header.tsx"; export default function Lobby() { return ( - <> -
-
- - -
+
+
+ +
- +
); } diff --git a/http/yass/daily.http b/http/yass/daily.http new file mode 100644 index 00000000..746324d3 --- /dev/null +++ b/http/yass/daily.http @@ -0,0 +1,5 @@ +// @name Daily Challenge +// Creates today's daily challenge game (or returns the one already played today) +POST /game/daily HTTP/1.1 +Host: {{yassBaseUrl}} +Cookie: ory_kratos_session={{orySessionCookie}} \ No newline at end of file diff --git a/pom.xml b/pom.xml index b4bc3a9f..9174cb04 100644 --- a/pom.xml +++ b/pom.xml @@ -353,6 +353,7 @@ | seat | trick | message + | daily_challenge public diff --git a/server/main/kotlin/ch/yass/admin/dsl/game.kt b/server/main/kotlin/ch/yass/admin/dsl/game.kt index 1c877a21..47e2c076 100644 --- a/server/main/kotlin/ch/yass/admin/dsl/game.kt +++ b/server/main/kotlin/ch/yass/admin/dsl/game.kt @@ -36,7 +36,7 @@ fun game(lambda: GameStateBuilder.() -> Unit): GameState { createdAt = LocalDateTime.now(ZoneOffset.UTC) updatedAt = LocalDateTime.now(ZoneOffset.UTC) code = (1..5).map { ('A'..'Z').random() }.joinToString("") - seed = Random.nextInt(100_000, 1_000_000) + seed = Random.nextLong() settings = toDbJson( GameSettings(false, false, false, false, state.settings.wcType, state.settings.wcValue, state.settings.forcedDecks) ) diff --git a/server/main/kotlin/ch/yass/admin/dsl/inMemoryGame.kt b/server/main/kotlin/ch/yass/admin/dsl/inMemoryGame.kt index fef47e20..5202e442 100644 --- a/server/main/kotlin/ch/yass/admin/dsl/inMemoryGame.kt +++ b/server/main/kotlin/ch/yass/admin/dsl/inMemoryGame.kt @@ -33,7 +33,7 @@ fun inMemoryGame(lambda: GameStateBuilder.() -> Unit): GameState { createdAt = now, updatedAt = now, code = (1..5).map { ('A'..'Z').random() }.joinToString(""), - seed = Random.nextInt(100_000, 1_000_000), + seed = Random.nextLong(), settings = GameSettings(false, false, false, false, state.settings.wcType, state.settings.wcValue, state.settings.forcedDecks), status = GameStatus.RUNNING, kind = GameKind.CUSTOM diff --git a/server/main/kotlin/ch/yass/core/error/DomainError.kt b/server/main/kotlin/ch/yass/core/error/DomainError.kt index 76489fde..00da8dbb 100644 --- a/server/main/kotlin/ch/yass/core/error/DomainError.kt +++ b/server/main/kotlin/ch/yass/core/error/DomainError.kt @@ -28,6 +28,7 @@ data class UnauthorizedSubscription(val error: DomainError) : AuthError data class InvalidAnonToken(val token: String) : AuthError data class CanNotImpersonate(val player: InternalPlayer, val impersonateUuid: UUID) : AuthError data class CanNotLinkAnonAccount(val playerUuid: UUID) : AuthError +data class SignedUpPlayersOnly(val player: InternalPlayer) : AuthError // Game or Game-State related Errors sealed interface GameError : DomainError diff --git a/server/main/kotlin/ch/yass/db/Public.kt b/server/main/kotlin/ch/yass/db/Public.kt index b933fb3d..90e74cb7 100644 --- a/server/main/kotlin/ch/yass/db/Public.kt +++ b/server/main/kotlin/ch/yass/db/Public.kt @@ -4,6 +4,7 @@ package ch.yass.db +import ch.yass.db.tables.DailyChallenge import ch.yass.db.tables.Game import ch.yass.db.tables.Hand import ch.yass.db.tables.Message @@ -32,6 +33,11 @@ open class Public : SchemaImpl(DSL.name("public"), DefaultCatalog.DEFAULT_CATALO val PUBLIC: Public = Public() } + /** + * The table public.daily_challenge. + */ + val DAILY_CHALLENGE: DailyChallenge get() = DailyChallenge.DAILY_CHALLENGE + /** * The table public.game. */ @@ -65,6 +71,7 @@ open class Public : SchemaImpl(DSL.name("public"), DefaultCatalog.DEFAULT_CATALO override fun getCatalog(): Catalog = DefaultCatalog.DEFAULT_CATALOG override fun getTables(): List> = listOf( + DailyChallenge.DAILY_CHALLENGE, Game.GAME, Hand.HAND, Message.MESSAGE, diff --git a/server/main/kotlin/ch/yass/db/keys/Keys.kt b/server/main/kotlin/ch/yass/db/keys/Keys.kt index ed4db7c2..4e54a6b6 100644 --- a/server/main/kotlin/ch/yass/db/keys/Keys.kt +++ b/server/main/kotlin/ch/yass/db/keys/Keys.kt @@ -5,12 +5,14 @@ package ch.yass.db.keys +import ch.yass.db.tables.DailyChallenge import ch.yass.db.tables.Game import ch.yass.db.tables.Hand import ch.yass.db.tables.Message import ch.yass.db.tables.Player import ch.yass.db.tables.Seat import ch.yass.db.tables.Trick +import ch.yass.db.tables.records.DailyChallengeRecord import ch.yass.db.tables.records.GameRecord import ch.yass.db.tables.records.HandRecord import ch.yass.db.tables.records.MessageRecord @@ -30,6 +32,8 @@ import org.jooq.impl.QOM.ForeignKeyRule // UNIQUE and PRIMARY KEY definitions // ------------------------------------------------------------------------- +val DAILY_CHALLENGE_DAY_KEY: UniqueKey = Internal.createUniqueKey(DailyChallenge.DAILY_CHALLENGE, DSL.name("daily_challenge_day_key"), arrayOf(DailyChallenge.DAILY_CHALLENGE.DAY), true) +val DAILY_CHALLENGE_PKEY: UniqueKey = Internal.createUniqueKey(DailyChallenge.DAILY_CHALLENGE, DSL.name("daily_challenge_pkey"), arrayOf(DailyChallenge.DAILY_CHALLENGE.ID), true) val GAME_PKEY: UniqueKey = Internal.createUniqueKey(Game.GAME, DSL.name("game_pkey"), arrayOf(Game.GAME.ID), true) val HAND_PKEY: UniqueKey = Internal.createUniqueKey(Hand.HAND, DSL.name("hand_pkey"), arrayOf(Hand.HAND.ID), true) val MESSAGE_PKEY: UniqueKey = Internal.createUniqueKey(Message.MESSAGE, DSL.name("message_pkey"), arrayOf(Message.MESSAGE.ID), true) diff --git a/server/main/kotlin/ch/yass/db/tables/DailyChallenge.kt b/server/main/kotlin/ch/yass/db/tables/DailyChallenge.kt new file mode 100644 index 00000000..68ba3df2 --- /dev/null +++ b/server/main/kotlin/ch/yass/db/tables/DailyChallenge.kt @@ -0,0 +1,202 @@ +/* + * This file is generated by jOOQ. + */ +package ch.yass.db.tables + + +import ch.yass.db.Public +import ch.yass.db.keys.DAILY_CHALLENGE_DAY_KEY +import ch.yass.db.keys.DAILY_CHALLENGE_PKEY +import ch.yass.db.tables.records.DailyChallengeRecord + +import java.time.LocalDate +import java.time.LocalDateTime + +import kotlin.collections.Collection +import kotlin.collections.List + +import org.jooq.Condition +import org.jooq.Field +import org.jooq.ForeignKey +import org.jooq.InverseForeignKey +import org.jooq.JSON +import org.jooq.Name +import org.jooq.PlainSQL +import org.jooq.QueryPart +import org.jooq.Record +import org.jooq.SQL +import org.jooq.Schema +import org.jooq.Stringly +import org.jooq.Table +import org.jooq.TableField +import org.jooq.TableLike +import org.jooq.TableOptions +import org.jooq.UniqueKey +import org.jooq.impl.DSL +import org.jooq.impl.Internal +import org.jooq.impl.SQLDataType +import org.jooq.impl.TableImpl + + +/** + * This class is generated by jOOQ. + */ +@Suppress("warnings") +open class DailyChallenge( + alias: Name, + path: Table?, + childPath: ForeignKey?, + parentPath: InverseForeignKey?, + aliased: Table?, + parameters: Array?>?, + where: Condition? +): TableImpl( + alias, + Public.PUBLIC, + path, + childPath, + parentPath, + aliased, + parameters, + DSL.comment(""), + TableOptions.table(), + where, +) { + companion object { + + /** + * The reference instance of public.daily_challenge + */ + val DAILY_CHALLENGE: DailyChallenge = DailyChallenge() + } + + /** + * The class holding records for this type + */ + override fun getRecordType(): Class = DailyChallengeRecord::class.java + + /** + * The column public.daily_challenge.id. + */ + val ID: TableField = createField(DSL.name("id"), SQLDataType.INTEGER.nullable(false), this, "") + + /** + * The column public.daily_challenge.uuid. + */ + val UUID: TableField = createField(DSL.name("uuid"), SQLDataType.VARCHAR(37).nullable(false), this, "") + + /** + * The column public.daily_challenge.created_at. + */ + val CREATED_AT: TableField = createField(DSL.name("created_at"), SQLDataType.LOCALDATETIME(6).nullable(false), this, "") + + /** + * The column public.daily_challenge.updated_at. + */ + val UPDATED_AT: TableField = createField(DSL.name("updated_at"), SQLDataType.LOCALDATETIME(6).nullable(false), this, "") + + /** + * The column public.daily_challenge.day. + */ + val DAY: TableField = createField(DSL.name("day"), SQLDataType.LOCALDATE.nullable(false), this, "") + + /** + * The column public.daily_challenge.seed. + */ + val SEED: TableField = createField(DSL.name("seed"), SQLDataType.BIGINT.nullable(false), this, "") + + /** + * The column public.daily_challenge.forced_decks. + */ + val FORCED_DECKS: TableField = createField(DSL.name("forced_decks"), SQLDataType.JSON.nullable(false).defaultValue(DSL.field(DSL.raw("'[]'::json"), SQLDataType.JSON)), this, "") + + private constructor(alias: Name, aliased: Table?): this(alias, null, null, null, aliased, null, null) + private constructor(alias: Name, aliased: Table?, parameters: Array?>?): this(alias, null, null, null, aliased, parameters, null) + private constructor(alias: Name, aliased: Table?, where: Condition?): this(alias, null, null, null, aliased, null, where) + + /** + * Create an aliased public.daily_challenge table reference + */ + constructor(alias: String): this(DSL.name(alias)) + + /** + * Create an aliased public.daily_challenge table reference + */ + constructor(alias: Name): this(alias, null) + + /** + * Create a public.daily_challenge table reference + */ + constructor(): this(DSL.name("daily_challenge"), null) + override fun getSchema(): Schema? = if (aliased()) null else Public.PUBLIC + override fun getPrimaryKey(): UniqueKey = DAILY_CHALLENGE_PKEY + override fun getUniqueKeys(): List> = listOf(DAILY_CHALLENGE_DAY_KEY) + override fun `as`(alias: String): DailyChallenge = DailyChallenge(DSL.name(alias), this) + override fun `as`(alias: Name): DailyChallenge = DailyChallenge(alias, this) + override fun `as`(alias: Table<*>): DailyChallenge = DailyChallenge(alias.qualifiedName, this) + + /** + * Rename this table + */ + override fun rename(name: String): DailyChallenge = DailyChallenge(DSL.name(name), null) + + /** + * Rename this table + */ + override fun rename(name: Name): DailyChallenge = DailyChallenge(name, null) + + /** + * Rename this table + */ + override fun rename(name: Table<*>): DailyChallenge = DailyChallenge(name.qualifiedName, null) + + /** + * Create an inline derived table from this table + */ + override fun where(condition: Condition?): DailyChallenge = DailyChallenge(qualifiedName, if (aliased()) this else null, Internal.condition(this, condition)) + + /** + * Create an inline derived table from this table + */ + override fun where(conditions: Collection): DailyChallenge = where(DSL.and(conditions)) + + /** + * Create an inline derived table from this table + */ + override fun where(vararg conditions: Condition?): DailyChallenge = where(DSL.and(*conditions)) + + /** + * Create an inline derived table from this table + */ + override fun where(condition: Field?): DailyChallenge = where(DSL.condition(condition)) + + /** + * Create an inline derived table from this table + */ + @PlainSQL override fun where(condition: SQL): DailyChallenge = where(DSL.condition(condition)) + + /** + * Create an inline derived table from this table + */ + @PlainSQL override fun where(@Stringly.SQL condition: String): DailyChallenge = where(DSL.condition(condition)) + + /** + * Create an inline derived table from this table + */ + @PlainSQL override fun where(@Stringly.SQL condition: String, vararg binds: Any?): DailyChallenge = where(DSL.condition(condition, *binds)) + + /** + * Create an inline derived table from this table + */ + @PlainSQL override fun where(@Stringly.SQL condition: String, vararg parts: QueryPart): DailyChallenge = where(DSL.condition(condition, *parts)) + + /** + * Create an inline derived table from this table + */ + override fun whereExists(select: TableLike<*>): DailyChallenge = where(DSL.exists(select)) + + /** + * Create an inline derived table from this table + */ + override fun whereNotExists(select: TableLike<*>): DailyChallenge = where(DSL.notExists(select)) +} diff --git a/server/main/kotlin/ch/yass/db/tables/Game.kt b/server/main/kotlin/ch/yass/db/tables/Game.kt index 7b983d5e..2f961413 100644 --- a/server/main/kotlin/ch/yass/db/tables/Game.kt +++ b/server/main/kotlin/ch/yass/db/tables/Game.kt @@ -115,13 +115,18 @@ open class Game( /** * The column public.game.seed. */ - val SEED: TableField = createField(DSL.name("seed"), SQLDataType.INTEGER.nullable(false), this, "") + val SEED: TableField = createField(DSL.name("seed"), SQLDataType.BIGINT.nullable(false), this, "") /** * The column public.game.kind. */ val KIND: TableField = createField(DSL.name("kind"), SQLDataType.VARCHAR(255).nullable(false), this, "") + /** + * The column public.game.finished_state. + */ + val FINISHED_STATE: TableField = createField(DSL.name("finished_state"), SQLDataType.JSON, this, "") + private constructor(alias: Name, aliased: Table?): this(alias, null, null, null, aliased, null, null) private constructor(alias: Name, aliased: Table?, parameters: Array?>?): this(alias, null, null, null, aliased, parameters, null) private constructor(alias: Name, aliased: Table?, where: Condition?): this(alias, null, null, null, aliased, null, where) diff --git a/server/main/kotlin/ch/yass/db/tables/pojos/DailyChallenge.kt b/server/main/kotlin/ch/yass/db/tables/pojos/DailyChallenge.kt new file mode 100644 index 00000000..77003208 --- /dev/null +++ b/server/main/kotlin/ch/yass/db/tables/pojos/DailyChallenge.kt @@ -0,0 +1,85 @@ +/* + * This file is generated by jOOQ. + */ +package ch.yass.db.tables.pojos + + +import java.io.Serializable +import java.time.LocalDate +import java.time.LocalDateTime + +import org.jooq.JSON + + +/** + * This class is generated by jOOQ. + */ +@Suppress("warnings") +data class DailyChallenge( + var id: Int, + var uuid: String, + var createdAt: LocalDateTime, + var updatedAt: LocalDateTime, + var day: LocalDate, + var seed: Long, + var forcedDecks: JSON? = null +): Serializable { + + + override fun equals(other: Any?): Boolean { + if (this === other) + return true + if (other == null) + return false + if (this::class != other::class) + return false + val o: DailyChallenge = other as DailyChallenge + if (this.id != o.id) + return false + if (this.uuid != o.uuid) + return false + if (this.createdAt != o.createdAt) + return false + if (this.updatedAt != o.updatedAt) + return false + if (this.day != o.day) + return false + if (this.seed != o.seed) + return false + if (this.forcedDecks == null) { + if (o.forcedDecks != null) + return false + } + else if (this.forcedDecks != o.forcedDecks) + return false + return true + } + + override fun hashCode(): Int { + val prime = 31 + var result = 1 + result = prime * result + this.id.hashCode() + result = prime * result + this.uuid.hashCode() + result = prime * result + this.createdAt.hashCode() + result = prime * result + this.updatedAt.hashCode() + result = prime * result + this.day.hashCode() + result = prime * result + this.seed.hashCode() + result = prime * result + (if (this.forcedDecks == null) 0 else this.forcedDecks.hashCode()) + return result + } + + override fun toString(): String { + val sb = StringBuilder("DailyChallenge (") + + sb.append(id) + sb.append(", ").append(uuid) + sb.append(", ").append(createdAt) + sb.append(", ").append(updatedAt) + sb.append(", ").append(day) + sb.append(", ").append(seed) + sb.append(", ").append(forcedDecks) + + sb.append(")") + return sb.toString() + } +} diff --git a/server/main/kotlin/ch/yass/db/tables/pojos/Game.kt b/server/main/kotlin/ch/yass/db/tables/pojos/Game.kt index cb5f586a..629654de 100644 --- a/server/main/kotlin/ch/yass/db/tables/pojos/Game.kt +++ b/server/main/kotlin/ch/yass/db/tables/pojos/Game.kt @@ -22,8 +22,9 @@ data class Game( var code: String, var settings: JSON? = null, var status: String? = null, - var seed: Int, - var kind: String + var seed: Long, + var kind: String, + var finishedState: JSON? = null ): Serializable { @@ -61,6 +62,12 @@ data class Game( return false if (this.kind != o.kind) return false + if (this.finishedState == null) { + if (o.finishedState != null) + return false + } + else if (this.finishedState != o.finishedState) + return false return true } @@ -76,6 +83,7 @@ data class Game( result = prime * result + (if (this.status == null) 0 else this.status.hashCode()) result = prime * result + this.seed.hashCode() result = prime * result + this.kind.hashCode() + result = prime * result + (if (this.finishedState == null) 0 else this.finishedState.hashCode()) return result } @@ -91,6 +99,7 @@ data class Game( sb.append(", ").append(status) sb.append(", ").append(seed) sb.append(", ").append(kind) + sb.append(", ").append(finishedState) sb.append(")") return sb.toString() diff --git a/server/main/kotlin/ch/yass/db/tables/records/DailyChallengeRecord.kt b/server/main/kotlin/ch/yass/db/tables/records/DailyChallengeRecord.kt new file mode 100644 index 00000000..ca34871c --- /dev/null +++ b/server/main/kotlin/ch/yass/db/tables/records/DailyChallengeRecord.kt @@ -0,0 +1,86 @@ +/* + * This file is generated by jOOQ. + */ +package ch.yass.db.tables.records + + +import ch.yass.db.tables.DailyChallenge + +import java.time.LocalDate +import java.time.LocalDateTime + +import org.jooq.JSON +import org.jooq.Record1 +import org.jooq.impl.UpdatableRecordImpl + + +/** + * This class is generated by jOOQ. + */ +@Suppress("warnings") +open class DailyChallengeRecord private constructor() : UpdatableRecordImpl(DailyChallenge.DAILY_CHALLENGE) { + + open var id: Int + set(value): Unit = set(0, value) + get(): Int = get(0) as Int + + open var uuid: String + set(value): Unit = set(1, value) + get(): String = get(1) as String + + open var createdAt: LocalDateTime + set(value): Unit = set(2, value) + get(): LocalDateTime = get(2) as LocalDateTime + + open var updatedAt: LocalDateTime + set(value): Unit = set(3, value) + get(): LocalDateTime = get(3) as LocalDateTime + + open var day: LocalDate + set(value): Unit = set(4, value) + get(): LocalDate = get(4) as LocalDate + + open var seed: Long + set(value): Unit = set(5, value) + get(): Long = get(5) as Long + + open var forcedDecks: JSON? + set(value): Unit = set(6, value) + get(): JSON? = get(6) as JSON? + + // ------------------------------------------------------------------------- + // Primary key information + // ------------------------------------------------------------------------- + + override fun key(): Record1 = super.key() as Record1 + + /** + * Create a detached, initialised DailyChallengeRecord + */ + constructor(id: Int, uuid: String, createdAt: LocalDateTime, updatedAt: LocalDateTime, day: LocalDate, seed: Long, forcedDecks: JSON? = null): this() { + this.id = id + this.uuid = uuid + this.createdAt = createdAt + this.updatedAt = updatedAt + this.day = day + this.seed = seed + this.forcedDecks = forcedDecks + resetTouchedOnNotNull() + } + + /** + * Create a detached, initialised DailyChallengeRecord + */ + constructor(value: ch.yass.db.tables.pojos.DailyChallenge?): this() { + if (value != null) { + this.id = value.id + this.uuid = value.uuid + this.createdAt = value.createdAt + this.updatedAt = value.updatedAt + this.day = value.day + this.seed = value.seed + this.forcedDecks = value.forcedDecks + resetTouchedOnNotNull() + } + } +} diff --git a/server/main/kotlin/ch/yass/db/tables/records/GameRecord.kt b/server/main/kotlin/ch/yass/db/tables/records/GameRecord.kt index 4f8a46b3..7e80cb1b 100644 --- a/server/main/kotlin/ch/yass/db/tables/records/GameRecord.kt +++ b/server/main/kotlin/ch/yass/db/tables/records/GameRecord.kt @@ -47,14 +47,18 @@ open class GameRecord private constructor() : UpdatableRecordImpl(Ga set(value): Unit = set(6, value) get(): String? = get(6) as String? - open var seed: Int + open var seed: Long set(value): Unit = set(7, value) - get(): Int = get(7) as Int + get(): Long = get(7) as Long open var kind: String set(value): Unit = set(8, value) get(): String = get(8) as String + open var finishedState: JSON? + set(value): Unit = set(9, value) + get(): JSON? = get(9) as JSON? + // ------------------------------------------------------------------------- // Primary key information // ------------------------------------------------------------------------- @@ -64,7 +68,7 @@ open class GameRecord private constructor() : UpdatableRecordImpl(Ga /** * Create a detached, initialised GameRecord */ - constructor(id: Int, uuid: String, createdAt: LocalDateTime, updatedAt: LocalDateTime, code: String, settings: JSON? = null, status: String? = null, seed: Int, kind: String): this() { + constructor(id: Int, uuid: String, createdAt: LocalDateTime, updatedAt: LocalDateTime, code: String, settings: JSON? = null, status: String? = null, seed: Long, kind: String, finishedState: JSON? = null): this() { this.id = id this.uuid = uuid this.createdAt = createdAt @@ -74,6 +78,7 @@ open class GameRecord private constructor() : UpdatableRecordImpl(Ga this.status = status this.seed = seed this.kind = kind + this.finishedState = finishedState resetTouchedOnNotNull() } @@ -91,6 +96,7 @@ open class GameRecord private constructor() : UpdatableRecordImpl(Ga this.status = value.status this.seed = value.seed this.kind = value.kind + this.finishedState = value.finishedState resetTouchedOnNotNull() } } diff --git a/server/main/kotlin/ch/yass/db/tables/references/Tables.kt b/server/main/kotlin/ch/yass/db/tables/references/Tables.kt index 77beef8d..4f9783e3 100644 --- a/server/main/kotlin/ch/yass/db/tables/references/Tables.kt +++ b/server/main/kotlin/ch/yass/db/tables/references/Tables.kt @@ -5,6 +5,7 @@ package ch.yass.db.tables.references +import ch.yass.db.tables.DailyChallenge import ch.yass.db.tables.Game import ch.yass.db.tables.Hand import ch.yass.db.tables.Message @@ -14,6 +15,11 @@ import ch.yass.db.tables.Trick +/** + * The table public.daily_challenge. + */ +val DAILY_CHALLENGE: DailyChallenge = DailyChallenge.DAILY_CHALLENGE + /** * The table public.game. */ diff --git a/server/main/kotlin/ch/yass/game/GameController.kt b/server/main/kotlin/ch/yass/game/GameController.kt index 85d83007..c4b824fb 100644 --- a/server/main/kotlin/ch/yass/game/GameController.kt +++ b/server/main/kotlin/ch/yass/game/GameController.kt @@ -17,6 +17,7 @@ import ch.yass.game.engine.* import ch.yass.game.pubsub.GameFinished import ch.yass.game.pubsub.gameFinishedActions import ch.yass.identity.helper.player +import io.javalin.apibuilder.ApiBuilder.get import io.javalin.apibuilder.ApiBuilder.post import io.javalin.apibuilder.EndpointGroup import io.javalin.http.Context @@ -34,6 +35,8 @@ class GameController(private val service: GameService, private val repo: GameRep post("/weisen", ::weisen) post("/schiebe", ::schiebe) post("/ping", ::ping) + post("/daily", ::daily) +// get("/daily-leaderboard", ::dailyLeaderboard) } private fun ping(ctx: Context) = either { @@ -157,4 +160,16 @@ class GameController(private val service: GameService, private val repo: GameRep { errorResponse(ctx, it) }, { successResponse(ctx, it) } ) -} \ No newline at end of file + + private fun daily(ctx: Context) = either { + val code = service.createDaily(player(ctx)) + logger().info("trigger_alert: New daily challenge just started $code") + + CreateDailyChallengeResponse(code) + }.fold({ errorResponse(ctx, it) }, { successResponse(ctx, it) }) + +// private fun dailyLeaderboard(ctx: Context) = either { +// TODO() +// service.dailyLeaderboard(LocalDateTime.now(ZoneOffset.UTC)) +// }.fold({ errorResponse(ctx, it) }, { successResponse(ctx, it) }) +} diff --git a/server/main/kotlin/ch/yass/game/GameRepository.kt b/server/main/kotlin/ch/yass/game/GameRepository.kt index 8825452f..3106ef01 100644 --- a/server/main/kotlin/ch/yass/game/GameRepository.kt +++ b/server/main/kotlin/ch/yass/game/GameRepository.kt @@ -18,6 +18,7 @@ import ch.yass.game.engine.botName import ch.yass.game.engine.randomFreePosition import org.jooq.DSLContext import org.jooq.Records.mapping +import java.time.LocalDate import java.time.LocalDateTime import java.time.ZoneOffset import java.util.* @@ -25,14 +26,24 @@ import kotlin.random.Random class GameRepository(private val db: DSLContext) { - fun createGame(settings: GameSettings, kind: GameKind): Game { - return db.insertInto(GAME, GAME.UUID, GAME.CODE, GAME.CREATED_AT, GAME.UPDATED_AT, GAME.SEED, GAME.SETTINGS, GAME.STATUS, GAME.KIND) + fun createGame(settings: GameSettings, kind: GameKind, seed: Long = Random.nextLong()): Game { + return db.insertInto( + GAME, + GAME.UUID, + GAME.CODE, + GAME.CREATED_AT, + GAME.UPDATED_AT, + GAME.SEED, + GAME.SETTINGS, + GAME.STATUS, + GAME.KIND + ) .values( UUID.randomUUID().toString(), (1..5).map { ('A'..'Z').random() }.joinToString(""), // TODO: Handle collisions LocalDateTime.now(ZoneOffset.UTC), LocalDateTime.now(ZoneOffset.UTC), - Random.nextInt(100_000, 1_000_000), + seed, toDbJson(settings), GameStatus.RUNNING.name, kind.name @@ -108,6 +119,16 @@ class GameRepository(private val db: DSLContext) { return game ?: r.raise(GameNotFound(uuid)) } + fun getDailyGameForPlayer(player: InternalPlayer, start: LocalDateTime, end: LocalDateTime): Game? = + db.select(GAME) + .from(SEAT) + .join(GAME).on(SEAT.GAME_ID.eq(GAME.ID)) + .where(GAME.KIND.eq(GameKind.DAILY.name)) + .and(GAME.CREATED_AT.ge(start)) + .and(GAME.CREATED_AT.lt(end)) + .and(SEAT.PLAYER_ID.eq(player.id)) + .fetchOne(mapping(Game::fromRecord)) + fun refresh(game: Game): Game { return db.selectFrom(GAME) .where(GAME.UUID.eq(game.uuid.toString())) @@ -215,6 +236,33 @@ class GameRepository(private val db: DSLContext) { .fetchOneInto(Game::class.java)!! } + fun getOrCreateDailyChallengeForDay(day: LocalDate): DailyChallenge { + getDailyChallenge(day)?.let { return it } + + return db.insertInto( + DAILY_CHALLENGE, + DAILY_CHALLENGE.UUID, + DAILY_CHALLENGE.CREATED_AT, + DAILY_CHALLENGE.UPDATED_AT, + DAILY_CHALLENGE.DAY, + DAILY_CHALLENGE.SEED + ) + .values( + UUID.randomUUID().toString(), + LocalDateTime.now(ZoneOffset.UTC), + LocalDateTime.now(ZoneOffset.UTC), + day, + Random.nextLong() + ) + .returningResult(DAILY_CHALLENGE) + .fetchOne(mapping(DailyChallenge::fromRecord))!! + } + + private fun getDailyChallenge(day: LocalDate): DailyChallenge? = + db.selectFrom(DAILY_CHALLENGE) + .where(DAILY_CHALLENGE.DAY.eq(day)) + .fetchOne(DailyChallenge::fromRecord) + private fun createSeat(seat: NewSeat): Seat { return db .insertInto( diff --git a/server/main/kotlin/ch/yass/game/GameService.kt b/server/main/kotlin/ch/yass/game/GameService.kt index 96fe1af9..50bd72ff 100644 --- a/server/main/kotlin/ch/yass/game/GameService.kt +++ b/server/main/kotlin/ch/yass/game/GameService.kt @@ -23,9 +23,13 @@ import ch.yass.game.dto.db.InternalPlayer import ch.yass.game.dto.db.Seat import ch.yass.game.engine.* import ch.yass.game.pubsub.* +import ch.yass.identity.helper.isAnon import kotlinx.coroutines.* import org.slf4j.MDC +import java.time.LocalDateTime +import java.time.ZoneOffset import java.util.* +import kotlin.random.Random import kotlinx.coroutines.channels.Channel as EventChannel class GameService( @@ -79,19 +83,53 @@ class GameService( r.ensure(settings.botPositions().size < 4) { GameSettingsMaxBots(settings) } r.ensure(validWcValue) { GameSettingsInvalidValue(settings) } - val game = repo.createGame(settings, GameKind.CUSTOM) + return startGame(settings, GameKind.CUSTOM, player).code + } + + context(r: Raise) + fun createDaily(player: InternalPlayer): String { + r.ensure(!isAnon(player)) { SignedUpPlayersOnly(player) } + + val day = swissDay(LocalDateTime.now(ZoneOffset.UTC)) + val (start, end) = swissDayWindowUTC(day) + + // Already has a game of type DAILY today, meaning they already joined the daily challenge. We'll return + // the code of that game which either results in an automatic rejoin if not finished or the analysis view. + repo.getDailyGameForPlayer(player, start, end)?.let { return it.code } + + val challenge = repo.getOrCreateDailyChallengeForDay(day) + val settings = GameSettings( + botNorth = true, botEast = true, botSouth = false, botWest = true, // player will sit south + winningConditionType = WinningConditionType.POINTS, // daily challenge is always to 1000 points for now + winningConditionValue = 1000, + forcedDecks = challenge.forcedDecks + ) + return startGame(settings, GameKind.DAILY, player, Position.SOUTH, challenge.seed).code + } - settings.botPositions().map { position -> - val botPlayer = playerService.create("Bot", position) - repo.takeASeat(game, botPlayer, position) + /** + * Creates the game, seats all configured bots and the player (at [position] or a random free + * one), then deals the first hand and starts its first trick. + */ + context(_: Raise) + private fun startGame( + settings: GameSettings, + kind: GameKind, + player: InternalPlayer, + position: Position? = null, + seed: Long = Random.nextLong(), + ): Game { + val game = repo.createGame(settings, kind, seed) + + settings.botPositions().forEach { botPosition -> + repo.takeASeat(game, playerService.createBot(botPosition), botPosition) } - val newSeat = repo.takeASeat(game, player) + val seat = repo.takeASeat(game, player, position) - // Creating player always starts game - val hand = repo.createHand(NewHand(game, newSeat.position, dealHand(game, handNumber = 0))) + val hand = repo.createHand(NewHand(game, seat.position, dealHand(game, handNumber = 0))) repo.createTrick(hand) - return game.code + return game } context(_: Raise, _: Raise) @@ -108,6 +146,10 @@ class GameService( return repo.getState(game) } + context(_: Raise) + fun dailyLeaderboard(now: LocalDateTime) { + } + context(_: Raise) fun getStateByCode(code: String): GameState { val game = repo.getByCode(code) diff --git a/server/main/kotlin/ch/yass/game/PlayerService.kt b/server/main/kotlin/ch/yass/game/PlayerService.kt index baace70e..324d4bd6 100644 --- a/server/main/kotlin/ch/yass/game/PlayerService.kt +++ b/server/main/kotlin/ch/yass/game/PlayerService.kt @@ -65,12 +65,12 @@ class PlayerService(private val db: DSLContext) { * Special handling for bots: we don't create an actual entry in the db and just map the id to the * position. When loading the game state again in GameRepository.getState we again fake the player object. */ - fun create(name: String, position: Position): InternalPlayer { + fun createBot(position: Position): InternalPlayer { return InternalPlayer( id = botId(position), uuid = UUID.randomUUID(), oryUuid = null, - name = name, + name = "Bot", bot = true, anonToken = null, createdAt = LocalDateTime.now(), diff --git a/server/main/kotlin/ch/yass/game/api/CreateDailyChallengeResponse.kt b/server/main/kotlin/ch/yass/game/api/CreateDailyChallengeResponse.kt new file mode 100644 index 00000000..64b4baf9 --- /dev/null +++ b/server/main/kotlin/ch/yass/game/api/CreateDailyChallengeResponse.kt @@ -0,0 +1,5 @@ +package ch.yass.game.api + +data class CreateDailyChallengeResponse( + val code: String +) diff --git a/server/main/kotlin/ch/yass/game/dto/db/DailyChallenge.kt b/server/main/kotlin/ch/yass/game/dto/db/DailyChallenge.kt new file mode 100644 index 00000000..95148f34 --- /dev/null +++ b/server/main/kotlin/ch/yass/game/dto/db/DailyChallenge.kt @@ -0,0 +1,33 @@ +package ch.yass.game.dto.db + +import ch.yass.core.helper.fromDbJson +import ch.yass.db.tables.records.DailyChallengeRecord +import ch.yass.game.dto.Card +import java.time.LocalDate +import java.time.LocalDateTime +import java.util.* + +data class DailyChallenge( + val id: Int, + val uuid: UUID, + val createdAt: LocalDateTime, + val updatedAt: LocalDateTime, + val day: LocalDate, + val seed: Long, + val forcedDecks: List> +) { + companion object { + fun fromRecord(challenge: DailyChallengeRecord): DailyChallenge { + return DailyChallenge( + challenge.id, + UUID.fromString(challenge.uuid), + challenge.createdAt, + challenge.updatedAt, + challenge.day, + challenge.seed, + // TODO: Arrays instead of lists, they keep their component type at runtime + fromDbJson>>(challenge.forcedDecks).map { it.toList() } + ) + } + } +} diff --git a/server/main/kotlin/ch/yass/game/dto/db/Game.kt b/server/main/kotlin/ch/yass/game/dto/db/Game.kt index b5d9c969..8e2c7de0 100644 --- a/server/main/kotlin/ch/yass/game/dto/db/Game.kt +++ b/server/main/kotlin/ch/yass/game/dto/db/Game.kt @@ -14,7 +14,7 @@ data class Game( val createdAt: LocalDateTime, val updatedAt: LocalDateTime, val code: String, - val seed: Int, + val seed: Long, val settings: GameSettings, val status: GameStatus, val kind: GameKind diff --git a/server/main/kotlin/ch/yass/game/engine/daily.kt b/server/main/kotlin/ch/yass/game/engine/daily.kt new file mode 100644 index 00000000..b5c57744 --- /dev/null +++ b/server/main/kotlin/ch/yass/game/engine/daily.kt @@ -0,0 +1,30 @@ +package ch.yass.game.engine + +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.ZoneOffset +import java.time.ZonedDateTime + +private val zurich = ZoneId.of("Europe/Zurich") + +/** + * Since this is the SWISS national game, daily challenges are aligned with the time in Switzerland. If you're two hours + * behind Swiss time, the daily challenge will start already at 22:00 in your country. + */ +fun swissDay(utcNow: LocalDateTime): LocalDate = utcNow.toInstant(ZoneOffset.UTC).atZone(zurich).toLocalDate() + +data class UtcWindow(val start: LocalDateTime, val end: LocalDateTime) + +/** + * The UTC timestamps a Swiss day starts and ends at, e.g. 2026-07-27 in Switzerland runs from 2026-07-26T22:00 until + * 2026-07-27T22:00 UTC. [UtcWindow.end] is exclusive, so compare with `>= start` and `< end`. + */ +fun swissDayWindowUTC(day: LocalDate): UtcWindow = + UtcWindow( + start = day.atStartOfDay(zurich).toLocalDateTimeUTC(), + end = day.plusDays(1).atStartOfDay(zurich).toLocalDateTimeUTC() + ) + +private fun ZonedDateTime.toLocalDateTimeUTC(): LocalDateTime = + withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime() diff --git a/server/main/kotlin/ch/yass/game/engine/helpers.kt b/server/main/kotlin/ch/yass/game/engine/helpers.kt index ea6f10c2..af2ecf47 100644 --- a/server/main/kotlin/ch/yass/game/engine/helpers.kt +++ b/server/main/kotlin/ch/yass/game/engine/helpers.kt @@ -39,7 +39,7 @@ fun allOfSuit(suit: Suit): List> = cartesianProduct(Rank.regula * always deal the exact same cards), unless [forcedDeck] is given to replicate errors or for * testing. */ -fun generateHand(seed: Int, handNumber: Int, forcedDeck: List? = null): EnumMap> { +fun generateHand(seed: Long, handNumber: Int, forcedDeck: List? = null): EnumMap> { val deck = forcedDeck?.map { Pair(it.rank, it.suit) } ?: seededDeck(seed, handNumber) return mapOf( @@ -50,7 +50,7 @@ fun generateHand(seed: Int, handNumber: Int, forcedDeck: List? = null): En ).toEnumMap() } -private fun seededDeck(seed: Int, handNumber: Int): List> { +private fun seededDeck(seed: Long, handNumber: Int): List> { val random = Random(seed) repeat(handNumber) { random.nextLong() } diff --git a/server/main/kotlin/ch/yass/identity/helper/player.kt b/server/main/kotlin/ch/yass/identity/helper/player.kt index 69fda057..bc628bcc 100644 --- a/server/main/kotlin/ch/yass/identity/helper/player.kt +++ b/server/main/kotlin/ch/yass/identity/helper/player.kt @@ -8,3 +8,5 @@ import io.javalin.http.Context fun player(ctx: Context): InternalPlayer = ctx.attribute(CtxAttributes.PLAYER.name)!! fun isAdmin(player: InternalPlayer): Boolean = config().getStringList("admins").contains(player.uuid.toString()) + +fun isAnon(player: InternalPlayer): Boolean = player.oryUuid == null diff --git a/server/main/resources/db/migration/V21__Add_daily_challenge.sql b/server/main/resources/db/migration/V21__Add_daily_challenge.sql new file mode 100644 index 00000000..925d6caf --- /dev/null +++ b/server/main/resources/db/migration/V21__Add_daily_challenge.sql @@ -0,0 +1,16 @@ +ALTER TABLE game + ADD COLUMN finished_state json; + +ALTER TABLE game + ALTER COLUMN seed TYPE BIGINT; + +CREATE TABLE daily_challenge +( + id SERIAL PRIMARY KEY, + uuid VARCHAR(37) NOT NULL, + created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL, + updated_at TIMESTAMP WITHOUT TIME ZONE NOT NULL, + day DATE NOT NULL UNIQUE, + seed BIGINT NOT NULL, + forced_decks json NOT NULL DEFAULT '[]'::json +); diff --git a/server/main/resources/openapi-spec.yaml b/server/main/resources/openapi-spec.yaml index 215dd095..1cae0895 100644 --- a/server/main/resources/openapi-spec.yaml +++ b/server/main/resources/openapi-spec.yaml @@ -155,6 +155,21 @@ paths: application/json: schema: $ref: '#/components/schemas/SuccessfulActionResponse' + /game/daily: + post: + summary: Join or create today's daily challenge + description: > + Returns the code of today's daily challenge game for the current player. Creates the game on + the first call of the day, returns the already running (or finished) game on every later call. + Only available for signed up players. + responses: + '200': + description: Daily challenge joined successfully + content: + application/json: + schema: + $ref: '#/components/schemas/CreateDailyChallengeResponse' + /auth/whoami: get: summary: Get current user information @@ -448,6 +463,14 @@ components: code: type: string + CreateDailyChallengeResponse: + type: object + required: + - code + properties: + code: + type: string + JoinGameRequest: type: object required: diff --git a/server/test/kotlin/ch/yass/unit/EngineHelpersTest.kt b/server/test/kotlin/ch/yass/unit/EngineHelpersTest.kt index 3c5e4491..f03f8b0a 100644 --- a/server/test/kotlin/ch/yass/unit/EngineHelpersTest.kt +++ b/server/test/kotlin/ch/yass/unit/EngineHelpersTest.kt @@ -59,7 +59,7 @@ class EngineHelpersTest { */ @Test fun testGenerateHandIsStableForAFixedSeed() { - val hand = generateHand(seed = 123456, handNumber = 0) + val hand = generateHand(seed = 123456L, handNumber = 0) assertTrue(hand.getValue(Position.NORTH) == interpretCards("C10,CK,D6,DJ,DA,HJ,HA,S8,SJ")) assertTrue(hand.getValue(Position.WEST) == interpretCards("C7,CQ,H6,H7,H9,H10,HQ,S10,SK"))