-
-
- {t("main.title")}, {name}
-
-
-
-
-
-
-
-
+
+
+
+
+ {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"))