Skip to content
Merged
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
1 change: 1 addition & 0 deletions client/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export const api = {
createGame: (data: CreateCustomGameRequest) => generatedApi.postGameCreate({client: customClient, body: data}),
dailyChallenge: () => generatedApi.postGameDaily({client: customClient}),
dailyLeaderboard: () => generatedApi.getGameDailyLeaderboard({client: customClient}),
gameInfo: () => generatedApi.getGameInfo({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}),
Expand Down
4 changes: 2 additions & 2 deletions client/src/api/generated/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// This file is auto-generated by @hey-api/openapi-ts

export { anonLink, anonLogout, anonSignup, connect, getAdminAnalyzeGameByCode, getGameDailyLeaderboard, 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, DailyLeaderboardEntry, DailyLeaderboardResponse, GameFinished, GetAdminAnalyzeGameByCodeData, GetAdminAnalyzeGameByCodeResponse, GetAdminAnalyzeGameByCodeResponses, GetGameDailyLeaderboardData, GetGameDailyLeaderboardResponse, GetGameDailyLeaderboardResponses, 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';
export { anonLink, anonLogout, anonSignup, connect, getAdminAnalyzeGameByCode, getGameDailyLeaderboard, getGameInfo, 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, DailyLeaderboardEntry, DailyLeaderboardResponse, GameFinished, GameInfoResponse, GameKind, GetAdminAnalyzeGameByCodeData, GetAdminAnalyzeGameByCodeResponse, GetAdminAnalyzeGameByCodeResponses, GetGameDailyLeaderboardData, GetGameDailyLeaderboardResponse, GetGameDailyLeaderboardResponses, GetGameInfoData, GetGameInfoResponse, GetGameInfoResponses, 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, RunningGame, 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';
14 changes: 13 additions & 1 deletion client/src/api/generated/sdk.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, GetGameDailyLeaderboardData, GetGameDailyLeaderboardResponses, 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';
import type { AnonLinkData, AnonLinkErrors, AnonLinkResponses, AnonLogoutData, AnonLogoutResponses, AnonSignupData, AnonSignupErrors, AnonSignupResponses, ConnectData, ConnectErrors, ConnectResponses, GetAdminAnalyzeGameByCodeData, GetAdminAnalyzeGameByCodeResponses, GetGameDailyLeaderboardData, GetGameDailyLeaderboardResponses, GetGameInfoData, GetGameInfoResponses, 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<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean, TResponse = unknown> = Options2<TData, ThrowOnError, TResponse> & {
/**
Expand Down Expand Up @@ -155,6 +155,18 @@ export const getGameDailyLeaderboard = <ThrowOnError extends boolean = false>(op
...options
});

/**
* Game info for the current player
*
* Everything the lobby needs about the current player: the games they are seated at and could rejoin, and whether they already played today's daily challenge to the end.
*
*/
export const getGameInfo = <ThrowOnError extends boolean = false>(options?: Options<GetGameInfoData, ThrowOnError>) => (options?.client ?? client).get<GetGameInfoResponses, unknown, ThrowOnError>({
responseType: 'json',
url: '/game/info',
...options
});

/**
* Get current user information
*/
Expand Down
30 changes: 30 additions & 0 deletions client/src/api/generated/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,20 @@ export type DailyLeaderboardEntry = {
points: number;
};

export type GameInfoResponse = {
runningGames: Array<RunningGame>;
dailyChallengePlayed: boolean;
};

export type RunningGame = {
uuid: string;
code: string;
kind: GameKind;
startedAt: string;
};

export type GameKind = 'QUEUE' | 'CUSTOM' | 'DAILY';

export type JoinGameRequest = {
code: string;
};
Expand Down Expand Up @@ -446,6 +460,22 @@ export type GetGameDailyLeaderboardResponses = {

export type GetGameDailyLeaderboardResponse = GetGameDailyLeaderboardResponses[keyof GetGameDailyLeaderboardResponses];

export type GetGameInfoData = {
body?: never;
path?: never;
query?: never;
url: '/game/info';
};

export type GetGameInfoResponses = {
/**
* Info retrieved successfully
*/
200: GameInfoResponse;
};

export type GetGameInfoResponse = GetGameInfoResponses[keyof GetGameInfoResponses];

export type WhoamiData = {
body?: never;
path?: never;
Expand Down
26 changes: 17 additions & 9 deletions client/src/components/lobby/daily-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ 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"
import {useGameInfoStore} from "@/store/game-info.ts"

const rules = ["sameCards", "oncePerDay", "opponents"]

Expand All @@ -21,6 +22,8 @@ export function DailySection() {
const handleAxiosError = useAxiosErrorHandler()
const isAnon = usePlayerStore(state => state.isAnon)
const remaining = useMidnightCountdown()
const played = useGameInfoStore(state => state.dailyChallengePlayed)
const runningDaily = useGameInfoStore(state => state.runningGames.find(game => game.kind === "DAILY"))
const [error, setError] = useState<string | null>(null)

const {execute: executeDaily, isLoading, hasError, reset} = useAsyncAction(async () => {
Expand Down Expand Up @@ -73,15 +76,20 @@ export function DailySection() {
</Button>
</div>
) : (
<Button
className="w-full"
onClick={handlePlay}
disabled={isLoading}
variant={hasError ? "destructive" : "default"}
>
{isLoading && <Loader2 className="h-4 w-4 animate-spin"/>}
{t("lobby.daily.play")}
</Button>
<div className="flex flex-col gap-2">
<Button
className="w-full"
onClick={handlePlay}
disabled={isLoading || (played && !runningDaily)}
variant={hasError ? "destructive" : "default"}
>
{isLoading && <Loader2 className="h-4 w-4 animate-spin"/>}
{t(runningDaily ? "lobby.daily.finish" : "lobby.daily.play")}
</Button>
{played && !runningDaily && (
<p className="text-center text-sm text-muted-foreground">{t("lobby.daily.alreadyPlayed")}</p>
)}
</div>
)}

<Accordion type="single" collapsible>
Expand Down
11 changes: 11 additions & 0 deletions client/src/components/lobby/join-tab.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import {RunningGames} from "@/components/lobby/running-games.tsx"
import {JoinSection} from "@/components/lobby/join-section.tsx"

export function JoinTab() {
return (
<div className="flex flex-col gap-4">
<RunningGames/>
<JoinSection/>
</div>
)
}
56 changes: 56 additions & 0 deletions client/src/components/lobby/running-games.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
import {Button} from "@/components/ui/button"
import {History, Play} from "lucide-react"
import {useTranslation} from "react-i18next"
import {useNavigate} from "react-router-dom"
import {useGameInfoStore} from "@/store/game-info.ts"

const units: [Intl.RelativeTimeFormatUnit, number][] = [["day", 86400], ["hour", 3600], ["minute", 60]]

const startedAgo = (startedAt: string, language: string) => {
const seconds = (Date.now() - new Date(startedAt).getTime()) / 1000
const [unit, size] = units.find(([, size]) => seconds >= size) ?? units[units.length - 1]
return new Intl.RelativeTimeFormat(language, {numeric: "always"})
.format(-Math.max(1, Math.floor(seconds / size)), unit)
}

export function RunningGames() {
const {t, i18n} = useTranslation()
const navigate = useNavigate()
const runningGames = useGameInfoStore(state => state.runningGames)

if (runningGames.length === 0) return null

return (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<History className="h-5 w-5 text-primary"/>
<CardTitle className="text-lg">{t("lobby.running.title")}</CardTitle>
</div>
<CardDescription>{t("lobby.running.description")}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-2">
{runningGames.map(game => (
<Button
key={game.uuid}
variant="outline"
className="h-auto w-full justify-between py-3"
onClick={() => navigate(`/game/${game.code}`)}
>
<span className="flex flex-col items-start gap-0.5">
<span className="font-medium">{t(`lobby.running.kind.${game.kind}`)}</span>
<span className="text-xs font-normal text-muted-foreground">
{startedAgo(game.startedAt, i18n.language)}
</span>
</span>
<span className="flex items-center gap-3">
<span className="font-mono tracking-[0.2em]">{game.code}</span>
<Play className="h-4 w-4 text-primary"/>
</span>
</Button>
))}
</CardContent>
</Card>
)
}
11 changes: 9 additions & 2 deletions client/src/components/main-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@ import {motion} from "motion/react";
import {CalendarDays, LogIn, Plus} from "lucide-react";
import {cn} from "@/lib/utils";
import {DailyTab} from "@/components/lobby/daily-tab.tsx";
import {JoinSection} from "@/components/lobby/join-section.tsx";
import {JoinTab} from "@/components/lobby/join-tab.tsx";
import {CreateSection} from "@/components/lobby/create-section.tsx";
import {useGameInfoStore} from "@/store/game-info.ts";

const sections = [
{id: "daily" as const, icon: CalendarDays, Section: DailyTab},
{id: "join" as const, icon: LogIn, Section: JoinSection},
{id: "join" as const, icon: LogIn, Section: JoinTab},
{id: "create" as const, icon: Plus, Section: CreateSection},
]

Expand All @@ -22,9 +23,15 @@ export function MainScreen() {
const alignedRef = useRef(false)
const fromScrollRef = useRef(false)
const targetRef = useRef<number | null>(null)
const refreshGameInfo = useGameInfoStore(state => state.refresh)

const active = sections.find(s => s.id === section)?.id

// Fetched once per lobby mount, so coming back from a game picks up the game that just finished.
useEffect(() => {
refreshGameInfo()
}, [refreshGameInfo])

// 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(() => {
Expand Down
11 changes: 11 additions & 0 deletions client/src/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,8 @@
"title": "Tagestrumpf",
"countdown": "bis zum nächsten Tagestrumpf",
"play": "Jetzt spielen",
"finish": "Spiel abschliessen",
"alreadyPlayed": "Du hast den Tagestrumpf von heute schon gespielt.",
"explanation": "Was ist das?",
"rules": {
"sameCards": "Alle spielen mit den exakt gleich gemischten Karten",
Expand All @@ -152,6 +154,15 @@
"join": {
"title": "Spiel beitreten",
"description": "Falls du einen Code erhalten hast kannst du ihn hier eingeben."
},
"running": {
"title": "Laufende Spiele",
"description": "Spiele, an denen du noch am Tisch sitzt. Tippe drauf, um weiterzuspielen.",
"kind": {
"DAILY": "Tagestrumpf",
"CUSTOM": "Eigenes Spiel",
"QUEUE": "Schnelles Spiel"
}
}
},
"logout": {
Expand Down
11 changes: 11 additions & 0 deletions client/src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,8 @@
"title": "Daily challenge",
"countdown": "until the next challenge",
"play": "Play challenge",
"finish": "Finish challenge",
"alreadyPlayed": "You already played today's challenge.",
"explanation": "What is this?",
"rules": {
"sameCards": "Everyone plays the exact same shuffle",
Expand All @@ -125,6 +127,15 @@
"join": {
"title": "Join a game",
"description": "Got a code from a friend? Enter it here to take a seat."
},
"running": {
"title": "Running games",
"description": "Games you still have a seat at. Tap one to keep playing.",
"kind": {
"DAILY": "Daily challenge",
"CUSTOM": "Custom game",
"QUEUE": "Quick game"
}
}
},
"create": {
Expand Down
31 changes: 31 additions & 0 deletions client/src/store/game-info.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import {create} from 'zustand'
import {api} from '@/api/client'
import type {RunningGame} from '@/api/generated'

interface GameInfoState {
runningGames: RunningGame[]
dailyChallengePlayed: boolean
isLoading: boolean
refresh: () => Promise<void>
}

export const useGameInfoStore = create<GameInfoState>((set) => ({
runningGames: [],
dailyChallengePlayed: false,
isLoading: true,
// The lobby stays usable if this fails, the sections just fall back to their default state.
refresh: async () => {
set({isLoading: true})
try {
const {data} = await api.gameInfo()
set({
runningGames: data?.runningGames ?? [],
dailyChallengePlayed: data?.dailyChallengePlayed ?? false
})
} catch {
set({runningGames: [], dailyChallengePlayed: false})
} finally {
set({isLoading: false})
}
}
}))
8 changes: 8 additions & 0 deletions server/main/kotlin/ch/yass/game/GameController.kt
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ class GameController(private val service: GameService, private val repo: GameRep
post("/ping", ::ping)
post("/daily", ::daily)
get("/daily-leaderboard", ::dailyLeaderboard)
get("/info", ::info)
}

private fun ping(ctx: Context) = either {
Expand Down Expand Up @@ -175,4 +176,11 @@ class GameController(private val service: GameService, private val repo: GameRep
{ errorResponse(ctx, it) },
{ successResponse(ctx, it) }
)

private fun info(ctx: Context) = either<DomainError, _> {
service.info(player(ctx), LocalDateTime.now(ZoneOffset.UTC))
}.fold(
{ errorResponse(ctx, it) },
{ successResponse(ctx, it) }
)
}
13 changes: 13 additions & 0 deletions server/main/kotlin/ch/yass/game/GameRepository.kt
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,19 @@ class GameRepository(private val db: DSLContext) {
return game ?: r.raise(GameNotFound(uuid))
}

/**
* Games the player is seated at and could still rejoin. STALE ones count as well, they are only
* waiting for someone to come back (see [rejoinSeat]).
*/
fun getRunningGamesForPlayer(player: InternalPlayer): List<Game> =
db.select(GAME)
.from(SEAT)
.join(GAME).on(SEAT.GAME_ID.eq(GAME.ID))
.where(SEAT.PLAYER_ID.eq(player.id))
.and(GAME.STATUS.`in`(GameStatus.RUNNING.name, GameStatus.STALE.name))
.orderBy(GAME.CREATED_AT.desc())
.fetch(mapping(Game::fromRecord))

fun getDailyGameForPlayer(player: InternalPlayer, start: LocalDateTime, end: LocalDateTime): Game? =
db.select(GAME)
.from(SEAT)
Expand Down
14 changes: 14 additions & 0 deletions server/main/kotlin/ch/yass/game/GameService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,20 @@ class GameService(
return DailyLeaderboardResponse(day, entries)
}

/**
* What the lobby needs to know about the player: the games they can rejoin and whether today's
* daily challenge is already behind them.
*/
fun info(player: InternalPlayer, now: LocalDateTime): GameInfoResponse {
val (start, end) = swissDayWindowUTC(swissDay(now))
val daily = repo.getDailyGameForPlayer(player, start, end)

return GameInfoResponse(
repo.getRunningGamesForPlayer(player).map(RunningGame::from),
daily?.status == GameStatus.FINISHED
)
}

context(_: Raise<GameWithCodeNotFound>)
fun getStateByCode(code: String): GameState {
val game = repo.getByCode(code)
Expand Down
8 changes: 8 additions & 0 deletions server/main/kotlin/ch/yass/game/api/GameInfoResponse.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package ch.yass.game.api

import ch.yass.game.dto.RunningGame

data class GameInfoResponse(
val runningGames: List<RunningGame>,
val dailyChallengePlayed: Boolean
)
Loading
Loading