diff --git a/.gitignore b/.gitignore index b947077..a7bdcfa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ node_modules/ dist/ +*.bak diff --git a/Makefile b/Makefile index c4706df..6ece6c0 100644 --- a/Makefile +++ b/Makefile @@ -18,7 +18,7 @@ watch: check: pnpm check -.PHONY:fix +.PHONY: fix fix: pnpm fix diff --git a/games/game1-v2.3.0/.gitignore b/games/game1-v2.3.0/.gitignore deleted file mode 100644 index ecbb671..0000000 --- a/games/game1-v2.3.0/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/ui/src/locales/*/messages.mjs diff --git a/games/game1-v2.3.0/game/package.json b/games/game1-v2.3.0/game/package.json deleted file mode 100644 index c03b9d6..0000000 --- a/games/game1-v2.3.0/game/package.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "name": "game1-v2.3.0-game", - "version": "1.0.0", - "description": "Game logic for the minimal example game 'Roll'", - "author": "Simon Lemieux", - "private": true, - "license": "MIT", - "type": "module", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "scripts": { - "build": "rm -rf ./dist && pnpm rollup --config", - "watch": "pnpm rollup --config --watch", - "test": "vitest run src" - }, - "devDependencies": { - "@lefun/core": "workspace:*", - "@lefun/game": "workspace:*", - "rollup": "^4.20.0", - "rollup-plugin-typescript2": "^0.36.0", - "tslib": "^2.6.3", - "typescript": "^5.5.4", - "vitest": "^2.0.5" - }, - "peerDependencies": { - "@lefun/core": "workspace:*", - "@lefun/game": "workspace:*" - } -} diff --git a/games/game1-v2.3.0/game/rollup.config.js b/games/game1-v2.3.0/game/rollup.config.js deleted file mode 100644 index 4be5e60..0000000 --- a/games/game1-v2.3.0/game/rollup.config.js +++ /dev/null @@ -1,14 +0,0 @@ -import typescript from "rollup-plugin-typescript2"; - -export default { - input: "src/index.ts", - output: [ - { - dir: "dist", - format: "esm", - sourcemap: true, - }, - ], - plugins: [typescript()], - external: ["@lefun/core", "@lefun/game"], -}; diff --git a/games/game1-v2.3.0/game/src/index.test.ts b/games/game1-v2.3.0/game/src/index.test.ts deleted file mode 100644 index 4ddf487..0000000 --- a/games/game1-v2.3.0/game/src/index.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { expect, test } from "vitest"; - -import { MatchTester as _MatchTester, MatchTesterOptions } from "@lefun/game"; - -import { autoMove, G, game, GS } from "."; - -class MatchTester extends _MatchTester { - constructor(options: Omit, "game" | "autoMove">) { - super({ - ...options, - game, - autoMove, - }); - } -} - -test("sanity check", () => { - const match = new MatchTester({ numPlayers: 2 }); - const { players } = match.board; - - const userId = Object.keys(players)[0]; - - match.makeMove(userId, "roll"); - match.makeMove(userId, "roll", {}, { canFail: true }); - match.makeMove(userId, "moveWithArg", { someArg: "123" }); - match.makeMove(userId, "moveWithArg", { someArg: "123" }, { canFail: true }); - - // Time has no passed yet - expect(match.board.lastSomeBoardMoveValue).toBeUndefined(); - - // Not enough time - match.fastForward(50); - expect(match.board.lastSomeBoardMoveValue).toBeUndefined(); - - // Enough time - match.fastForward(50); - expect(match.board.lastSomeBoardMoveValue).toEqual(3); -}); - -test("turns in tests", () => { - const match = new MatchTester({ numPlayers: 2 }); - - const [p0, p1] = match.board.playerOrder; - - expect(match.meta.players.byId[p0].itsYourTurn).toBe(true); - expect(match.meta.players.byId[p1].itsYourTurn).toBe(false); - - match.makeMove(p0, "roll"); - expect(match.meta.players.byId[p0].itsYourTurn).toBe(false); - expect(match.meta.players.byId[p1].itsYourTurn).toBe(true); - - match.makeMove(p0, "moveWithArg", { someArg: "123" }); - expect(match.meta.players.byId[p0].itsYourTurn).toBe(false); - expect(match.meta.players.byId[p1].itsYourTurn).toBe(true); -}); - -test("bots and turns", async () => { - const match = new MatchTester({ numPlayers: 0, numBots: 2 }); - await match.start(); - expect(match.board.sum).toBeGreaterThanOrEqual(20); - expect(match.matchHasEnded).toBe(true); -}); diff --git a/games/game1-v2.3.0/game/src/index.ts b/games/game1-v2.3.0/game/src/index.ts deleted file mode 100644 index 7d7af89..0000000 --- a/games/game1-v2.3.0/game/src/index.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { GamePlayerSettings, GameSettings, UserId } from "@lefun/core"; -import { - AutoMove, - BoardMove, - Game, - GameState, - GameStats, - INIT_MOVE, - PlayerMove, -} from "@lefun/game"; - -type Player = { - isRolling: boolean; - diceValue?: number; -}; - -export type Board = { - players: Record; - playerOrder: UserId[]; - currentPlayerIndex: number; - - sum: number; - lastSomeBoardMoveValue?: number; - - matchSettings: Record; - matchPlayersSettings: Record>; -}; - -export type GS = GameState; - -type MoveWithArgPayload = { someArg: string }; -type BoardMoveWithArgPayload = { someArg: number }; - -export type PMT = { - roll: null; - moveWithArg: MoveWithArgPayload; -}; - -export type BMT = { - someBoardMove: null; - someBoardMoveWithArgs: BoardMoveWithArgPayload; -}; - -const matchStats = [ - { key: "patate", type: "integer" }, -] as const satisfies GameStats; - -const playerStats = [ - { key: "poil", type: "rank", determinesRank: true }, -] as const satisfies GameStats; - -type PM = PlayerMove< - GS, - Payload, - PMT, - BMT, - typeof playerStats, - typeof matchStats ->; - -const moveWithArg: PM = {}; - -const roll: PM = { - executeNow({ board, userId }) { - board.players[userId].isRolling = true; - }, - execute({ - board, - userId, - random, - delayMove, - turns, - logMatchStat, - logPlayerStat, - endMatch, - }) { - const diceValue = - board.matchPlayersSettings[userId].dieNumFaces === "6" - ? random.d6() - : random.dice(20); - board.players[userId].diceValue = diceValue; - board.players[userId].isRolling = false; - board.sum += diceValue; - - delayMove("someBoardMove", 100); - delayMove("someBoardMoveWithArgs", { someArg: 3 }, 100); - - // Test those types here - logPlayerStat(userId, "poil", 1); - logMatchStat("patate", 1); - - // If it was the player's turn, we go to the next player. - if (userId === board.playerOrder[board.currentPlayerIndex]) { - turns.end(userId); - board.currentPlayerIndex = - (board.currentPlayerIndex + 1) % board.playerOrder.length; - const nextPlayer = board.playerOrder[board.currentPlayerIndex]; - - turns.begin(nextPlayer, { - expiresIn: 60000, - playerMoveOnExpire: ["moveWithArg", { someArg: "0" }], - }); - } - - if (board.sum >= 20) { - endMatch(); - } - }, -}; - -type BM

= BoardMove; - -const initMove: BM = { - execute({ board, turns }) { - turns.begin(board.playerOrder[0]); - }, -}; - -const someBoardMove: BM = { - execute() { - // - }, -}; - -const someBoardMoveWithArgs: BM = { - execute({ board, payload }) { - board.lastSomeBoardMoveValue = payload.someArg; - }, -}; - -const gameSettings: GameSettings = [ - { - key: "setting1", - options: [{ value: "a" }, { value: "b" }], - }, - { - key: "setting2", - options: [{ value: "x" }, { value: "y", isDefault: true }], - }, -]; - -const gamePlayerSettings: GamePlayerSettings = [ - { - key: "color", - type: "color", - exclusive: true, - options: [ - { value: "red", label: "red" }, - { value: "blue", label: "blue" }, - { value: "green", label: "green" }, - { value: "orange", label: "orange" }, - { value: "pink", label: "pink" }, - { value: "brown", label: "brown" }, - { value: "black", label: "black" }, - { value: "darkgreen", label: "darkgreen" }, - { value: "darkred", label: "darkred" }, - { value: "purple", label: "purple" }, - ], - }, - { - key: "dieNumFaces", - type: "string", - options: [{ value: "6", isDefault: true }, { value: "20" }], - }, -]; - -export const game = { - initialBoards({ players, matchSettings, matchPlayersSettings }) { - return { - board: { - sum: 0, - players: Object.fromEntries( - players.map((userId) => [userId, { isRolling: false }]), - ), - playerOrder: [...players], - currentPlayerIndex: 0, - matchSettings, - matchPlayersSettings, - }, - }; - }, - playerMoves: { roll, moveWithArg }, - boardMoves: { [INIT_MOVE]: initMove, someBoardMove, someBoardMoveWithArgs }, - minPlayers: 1, - maxPlayers: 10, - matchStats, - playerStats, - gameSettings, - gamePlayerSettings, -} satisfies Game; - -export type G = typeof game; - -export const autoMove: AutoMove = ({ random }) => { - if (random.d2() === 1) { - return ["moveWithArg", { someArg: "123" }]; - } - return "roll"; -}; diff --git a/games/game1-v2.3.0/game/tsconfig.json b/games/game1-v2.3.0/game/tsconfig.json deleted file mode 100644 index dec8958..0000000 --- a/games/game1-v2.3.0/game/tsconfig.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "compilerOptions": { - "strict": true, - "target": "esnext", - "module": "esnext", - "lib": ["dom", "esnext"], - "sourceMap": true, - "moduleResolution": "bundler", - "declaration": true, - "allowSyntheticDefaultImports": true, - "allowJs": true, - "esModuleInterop": true, - "outDir": "dist", - "declarationDir": "dist" - }, - "include": ["src/**/*"] -} diff --git a/games/game1-v2.3.0/game/vitest.config.ts b/games/game1-v2.3.0/game/vitest.config.ts deleted file mode 100644 index 2fe25b9..0000000 --- a/games/game1-v2.3.0/game/vitest.config.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - typecheck: { - enabled: true, - }, - }, -}); diff --git a/games/game1-v2.3.0/manifest.json b/games/game1-v2.3.0/manifest.json deleted file mode 100644 index a5dc6c3..0000000 --- a/games/game1-v2.3.0/manifest.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "id": "roll", - "packages": { - "game": "game", - "ui": "ui" - }, - "version": "1" -} diff --git a/games/game1-v2.3.0/ui/src/Board.tsx b/games/game1-v2.3.0/ui/src/Board.tsx deleted file mode 100644 index 95aa25d..0000000 --- a/games/game1-v2.3.0/ui/src/Board.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import "./index.css"; - -import { Trans } from "@lingui/macro"; -import classNames from "classnames"; -import type { G, GS } from "game1-v2.3.0-game"; - -import type { UserId } from "@lefun/core"; -import { - makeUseMakeMove, - makeUseSelector, - makeUseSelectorShallow, - useIsPlayer, - useUsername, -} from "@lefun/ui"; - -const useSelector = makeUseSelector(); -const useSelectorShallow = makeUseSelectorShallow(); -const useMakeMove = makeUseMakeMove(); - -function Player({ userId }: { userId: UserId }) { - const itsMe = useSelector((state) => state.userId === userId); - const username = useUsername(userId); - - const color = useSelector( - (state) => state.board.matchPlayersSettings[userId].color, - ); - - return ( -

- {username} - -
- ); -} - -function Die({ userId }: { userId: UserId }) { - const diceValue = useSelector( - (state) => state.board.players[userId].diceValue, - ); - const isRolling = useSelector( - (state) => state.board.players[userId].isRolling, - ); - - return ( -
- Dice Value:{" "} - {isRolling || !diceValue ? "?" : diceValue} -
- ); -} - -function Board() { - const makeMove = useMakeMove(); - const players = useSelectorShallow((state) => - Object.keys(state.board.players), - ); - - const matchSettings = useSelector((state) => state.board.matchSettings); - - const isPlayer = useIsPlayer(); - - return ( -
-
- The template game - {Object.entries(matchSettings).map(([key, value]) => ( -
- {key}: {value} -
- ))} - {players.map((userId) => ( - - ))} -
- {isPlayer && ( - <> - - - - )} -
- ); -} - -export default Board; diff --git a/games/game1-v2.3.0/ui/src/locales/en/messages.po b/games/game1-v2.3.0/ui/src/locales/en/messages.po deleted file mode 100644 index f18baa4..0000000 --- a/games/game1-v2.3.0/ui/src/locales/en/messages.po +++ /dev/null @@ -1,47 +0,0 @@ -msgid "" -msgstr "" -"POT-Creation-Date: 2024-06-21 15:19+0000\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=utf-8\n" -"Content-Transfer-Encoding: 8bit\n" -"X-Generator: @lingui/cli\n" -"Language: en\n" -"Project-Id-Version: \n" -"Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: \n" -"Last-Translator: \n" -"Language-Team: \n" -"Plural-Forms: \n" - -#. js-lingui-explicit-id -#: lefun -msgid "lefun.name" -msgstr "Roll" - -#. js-lingui-explicit-id -#: lefun -msgid "lefun.tagline" -msgstr "The template game" - -#. js-lingui-explicit-id -#: lefun -msgid "lefun.aka" -msgstr "LeFun.fun" - -#. js-lingui-explicit-id -#: lefun -msgid "lefun.seoAka" -msgstr "" - -#. js-lingui-explicit-id -#: lefun -msgid "lefun.description" -msgstr "This game is merely a template game." - -#: src/Board.tsx -msgid "Roll" -msgstr "Roll" - -#: src/Board.tsx -msgid "The template game" -msgstr "The template game" diff --git a/games/game1-v2.3.0/ui/src/locales/fr/messages.po b/games/game1-v2.3.0/ui/src/locales/fr/messages.po deleted file mode 100644 index 71841ee..0000000 --- a/games/game1-v2.3.0/ui/src/locales/fr/messages.po +++ /dev/null @@ -1,47 +0,0 @@ -msgid "" -msgstr "" -"POT-Creation-Date: 2024-06-21 15:19+0000\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=utf-8\n" -"Content-Transfer-Encoding: 8bit\n" -"X-Generator: @lingui/cli\n" -"Language: fr\n" -"Project-Id-Version: \n" -"Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: \n" -"Last-Translator: \n" -"Language-Team: \n" -"Plural-Forms: \n" - -#. js-lingui-explicit-id -#: lefun -msgid "lefun.name" -msgstr "Roule" - -#. js-lingui-explicit-id -#: lefun -msgid "lefun.tagline" -msgstr "Le jeu template" - -#. js-lingui-explicit-id -#: lefun -msgid "lefun.aka" -msgstr "LeFun.fun" - -#. js-lingui-explicit-id -#: lefun -msgid "lefun.seoAka" -msgstr "" - -#. js-lingui-explicit-id -#: lefun -msgid "lefun.description" -msgstr "Ce jeu n'est qu'un template." - -#: src/Board.tsx -msgid "Roll" -msgstr "Lancer" - -#: src/Board.tsx -msgid "The template game" -msgstr "Le jeu template" diff --git a/games/game1-v2.5.3/src/backend.ts b/games/game1-v2.5.3/src/backend.ts index 3e7553d..230439b 100644 --- a/games/game1-v2.5.3/src/backend.ts +++ b/games/game1-v2.5.3/src/backend.ts @@ -3,7 +3,7 @@ import { AutoMove } from "@lefun/game"; import { G, GS } from "./game"; export const autoMove: AutoMove = ({ random }) => { - if (random.d2() === 1) { + if (random.bernoulli()) { return "pass"; } return "roll"; diff --git a/games/game1-v2.3.0/ui/.babelrc b/games/game1-v2.7.0/.babelrc similarity index 100% rename from games/game1-v2.3.0/ui/.babelrc rename to games/game1-v2.7.0/.babelrc diff --git a/games/game1-v2.7.0/.gitignore b/games/game1-v2.7.0/.gitignore new file mode 100644 index 0000000..61ab087 --- /dev/null +++ b/games/game1-v2.7.0/.gitignore @@ -0,0 +1 @@ +/src/locales/*/messages.mjs diff --git a/games/game1-v2.3.0/README.md b/games/game1-v2.7.0/README.md similarity index 100% rename from games/game1-v2.3.0/README.md rename to games/game1-v2.7.0/README.md diff --git a/games/game1-v2.3.0/ui/index.html b/games/game1-v2.7.0/index.html similarity index 100% rename from games/game1-v2.3.0/ui/index.html rename to games/game1-v2.7.0/index.html diff --git a/games/game1-v2.3.0/ui/lingui.config.ts b/games/game1-v2.7.0/lingui.config.ts similarity index 91% rename from games/game1-v2.3.0/ui/lingui.config.ts rename to games/game1-v2.7.0/lingui.config.ts index 079c7f9..0ff4634 100644 --- a/games/game1-v2.3.0/ui/lingui.config.ts +++ b/games/game1-v2.7.0/lingui.config.ts @@ -1,8 +1,9 @@ import type { LinguiConfig } from "@lingui/conf"; -import { game } from "game1-v2.3.0-game"; import { lefunExtractor } from "@lefun/ui/lefunExtractor"; +import { game } from "./src/game"; + const config: LinguiConfig = { locales: ["en", "fr"], sourceLocale: "en", diff --git a/games/game1-v2.3.0/ui/package.json b/games/game1-v2.7.0/package.json similarity index 63% rename from games/game1-v2.3.0/ui/package.json rename to games/game1-v2.7.0/package.json index 375d7b6..1578768 100644 --- a/games/game1-v2.3.0/ui/package.json +++ b/games/game1-v2.7.0/package.json @@ -1,24 +1,39 @@ { - "name": "game1-v2.3.0-ui", - "version": "1.0.0", - "description": "UI for the minimal example game 'Roll'", + "name": "game1-v2.7.0", + "version": "0.1.0", + "description": "Example game", "author": "Simon Lemieux", "license": "MIT", "type": "module", - "private": true, - "main": "dist/index.js", "types": "dist/types/index.d.ts", + "exports": { + "./game": { + "types": "./dist/types/game.d.ts", + "import": "./dist/game.js" + }, + "./ui": { + "types": "./dist/types/ui.d.ts", + "import": "./dist/ui.js" + }, + "./backend": { + "types": "./dist/types/backend.d.ts", + "import": "./dist/backend.js" + }, + "./index.css": "./dist/index.css" + }, "scripts": { "build": "rm -rf ./dist && pnpm rollup --config", "watch": "pnpm rollup --config --watch", "dev": "pnpm vite --host", "lingui:compile": "pnpm lingui compile", - "lingui:extract": "pnpm lingui extract" + "lingui:extract": "pnpm lingui extract", + "test": "pnpm vitest run" }, "devDependencies": { "@babel/preset-react": "^7.24.7", "@lefun/core": "workspace:*", "@lefun/dev-server": "workspace:*", + "@lefun/game": "workspace:*", "@lefun/ui": "workspace:*", "@lingui/cli": "^4.11.2", "@lingui/conf": "^4.11.2", @@ -28,6 +43,7 @@ "@rollup/plugin-babel": "^6.0.4", "@rollup/plugin-commonjs": "^26.0.1", "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-typescript": "^11.1.6", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.1", @@ -36,16 +52,16 @@ "rollup": "^4.18.1", "rollup-plugin-copy": "^3.5.0", "rollup-plugin-postcss": "^4.0.2", - "rollup-plugin-typescript2": "^0.36.0", "typescript": "^5.5.4", - "vite": "^5.3.4" + "vite": "^5.3.4", + "vitest": "^2.0.5" }, "dependencies": { - "classnames": "^2.5.1", - "game1-v2.3.0-game": "workspace:*" + "classnames": "^2.5.1" }, "peerDependencies": { "@lefun/core": "workspace:*", + "@lefun/game": "workspace:*", "@lefun/ui": "workspace:*", "react": "^18.3.1", "react-dom": "^18.3.1" diff --git a/games/game1-v2.3.0/ui/rollup.config.js b/games/game1-v2.7.0/rollup.config.js similarity index 90% rename from games/game1-v2.3.0/ui/rollup.config.js rename to games/game1-v2.7.0/rollup.config.js index 78cd00c..7f9734e 100644 --- a/games/game1-v2.3.0/ui/rollup.config.js +++ b/games/game1-v2.7.0/rollup.config.js @@ -1,12 +1,12 @@ import { babel } from "@rollup/plugin-babel"; import commonjs from "@rollup/plugin-commonjs"; import { nodeResolve } from "@rollup/plugin-node-resolve"; +import typescript from "@rollup/plugin-typescript"; import copy from "rollup-plugin-copy"; import postcss from "rollup-plugin-postcss"; -import typescript from "rollup-plugin-typescript2"; export default { - input: "src/index.ts", + input: ["src/game.ts", "src/ui.tsx", "src/backend.ts"], output: { dir: "dist", format: "esm", diff --git a/games/game1-v2.7.0/src/Board.tsx b/games/game1-v2.7.0/src/Board.tsx new file mode 100644 index 0000000..7ca9190 --- /dev/null +++ b/games/game1-v2.7.0/src/Board.tsx @@ -0,0 +1,140 @@ +import "./index.css"; + +import { Trans } from "@lingui/macro"; +import classNames from "classnames"; +import { useEffect, useState } from "react"; + +import type { UserId } from "@lefun/core"; +import { + makeUseMakeMove, + makeUseSelector, + makeUseSelectorShallow, + useUsername, + useUserTurn, +} from "@lefun/ui"; + +import { G, getCurrentPlayer, GS } from "./game"; + +const useSelector = makeUseSelector(); +const useSelectorShallow = makeUseSelectorShallow(); +const useMakeMove = makeUseMakeMove(); + +function Player({ userId }: { userId: UserId }) { + const itsMe = useSelector((state) => state.userId === userId); + const username = useUsername(userId); + + const myLastRollAt = useSelector((state) => state.playerboard?.lastRollAt); + + const color = useSelector( + (state) => state.board.matchPlayersSettings[userId].color, + ); + + const { expiresAt } = useUserTurn(userId); + + const isDead = useSelector((state) => state.board.players[userId].isDead); + + return ( +
+ + {username} {isDead ? "💀" : "😊"} + + + Expires in: {expiresAt ? : ""} + {itsMe && myLastRollAt} +
+ ); +} + +const CountDown = ({ ts }: { ts: number | undefined | null }) => { + const [delta, setDelta] = useState(null); + + useEffect(() => { + if (!ts) { + return; + } + const i = setInterval(() => { + setDelta(Math.max(ts - new Date().getTime(), 0)); + }, 100); + + return () => clearInterval(i); + }, [ts]); + + if (!ts) { + return null; + } + + return
{delta}
; +}; + +function Die({ userId }: { userId: UserId }) { + const diceValue = useSelector( + (state) => state.board.players[userId].diceValue, + ); + const isRolling = useSelector( + (state) => state.board.players[userId].isRolling, + ); + + return ( +
+ Dice Value:{" "} + {isRolling || !diceValue ? "?" : diceValue} +
+ ); +} + +const EndMatchCountDown = () => { + const endsAt = useSelector((state) => state.board.endsAt); + return ; +}; + +function Board() { + const makeMove = useMakeMove(); + const players = useSelectorShallow((state) => + Object.keys(state.board.players), + ); + + const matchSettings = useSelector((state) => state.board.matchSettings); + + const sum = useSelector((state) => state.board.sum); + + const itsMyTurn = useSelector( + (state) => getCurrentPlayer(state.board) === state.userId, + ); + + return ( +
+
+
+ The template game +
Sum: {sum}
+ + {Object.entries(matchSettings).map(([key, value]) => ( +
+ {key}: {value} +
+ ))} + {players.map((userId) => ( + + ))} +
+ + <> + + + +
+
+ ); +} + +export default Board; diff --git a/games/game1-v2.7.0/src/backend.ts b/games/game1-v2.7.0/src/backend.ts new file mode 100644 index 0000000..230439b --- /dev/null +++ b/games/game1-v2.7.0/src/backend.ts @@ -0,0 +1,10 @@ +import { AutoMove } from "@lefun/game"; + +import { G, GS } from "./game"; + +export const autoMove: AutoMove = ({ random }) => { + if (random.bernoulli()) { + return "pass"; + } + return "roll"; +}; diff --git a/games/game1-v2.7.0/src/game.test.ts b/games/game1-v2.7.0/src/game.test.ts new file mode 100644 index 0000000..a95dcf0 --- /dev/null +++ b/games/game1-v2.7.0/src/game.test.ts @@ -0,0 +1,53 @@ +import { expect, test } from "vitest"; + +import { MatchTester as _MatchTester, MatchTesterOptions } from "@lefun/game"; + +import { autoMove } from "./backend"; +import { G, game, GS, MATCH_DURATION, TURN_DURATION } from "./game"; + +class MatchTester extends _MatchTester { + constructor(options: Omit, "game" | "autoMove">) { + super({ + ...options, + game, + autoMove, + }); + } +} + +test("happy path", () => { + const match = new MatchTester({ numPlayers: 3 }); + const [p0, p1, p2] = match.board.playerOrder; + + match.makeMove(p0, "roll"); + match.makeMove(p1, "roll"); + match.makeMove(p2, "roll"); + expect(() => match.makeMove(p1, "roll")).toThrowError("not your turn"); + match.makeMove(p0, "roll"); + match.makeMove(p1, "roll"); + + match.fastForward(TURN_DURATION); + + expect(match.board.players[p2].isDead).toBe(true); + expect(match.board.currentPlayerIndex).toEqual(0); + match.makeMove(p0, "roll"); + match.makeMove(p1, "roll"); + match.fastForward(TURN_DURATION); + match.makeMove(p1, "roll"); + match.makeMove(p1, "roll"); + match.makeMove(p1, "roll"); + expect(match.matchHasEnded).toBe(false); + match.fastForward(MATCH_DURATION - 2 * TURN_DURATION); + expect(match.matchHasEnded).toBe(true); +}); + +const _sleep = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); + +test("bots and turns", async () => { + const match = new MatchTester({ numPlayers: 0, numBots: 2 }); + await match.start({ max: 50 }); + await _sleep(100); + match.fastForward(MATCH_DURATION); + expect(match.matchHasEnded).toBe(true); +}); diff --git a/games/game1-v2.7.0/src/game.ts b/games/game1-v2.7.0/src/game.ts new file mode 100644 index 0000000..6c10760 --- /dev/null +++ b/games/game1-v2.7.0/src/game.ts @@ -0,0 +1,255 @@ +import { GamePlayerSettings, GameSettings, UserId } from "@lefun/core"; +import { + BoardMove, + Game, + GameState, + GameStats, + INIT_MOVE, + PlayerMove, + Turns, +} from "@lefun/game"; + +type Player = { + isRolling: boolean; + diceValue?: number; + isDead: boolean; +}; + +export type B = { + players: Record; + playerOrder: UserId[]; + currentPlayerIndex: number; + + sum: number; + + matchSettings: Record; + matchPlayersSettings: Record>; + + endsAt: number | null; +}; + +export type PB = { + // At the moment this is only so that the playerboard is not empty. + lastRollAt: number | null; +}; + +export type GS = GameState; + +type KillPayload = { userId: UserId }; + +export type PMT = { + roll: null; + pass: null; +}; + +export type BMT = { + kill: KillPayload; + endMatch: null; +}; + +const matchStats = [ + { key: "sumOnEnd", type: "integer" }, +] as const satisfies GameStats; + +const playerStats = [ + { + key: "iMadeTheLastRoll", + type: "boolean", + determinesRank: true, + ordering: "lowerIsBetter", + }, + { key: "rollValue", type: "integer", determinesRank: true }, +] as const satisfies GameStats; + +type PM = PlayerMove< + GS, + Payload, + PMT, + BMT, + typeof playerStats, + typeof matchStats +>; + +export const getCurrentPlayer = (board: B) => { + const { currentPlayerIndex, playerOrder } = board; + return playerOrder[currentPlayerIndex]; +}; + +export const TURN_DURATION = 3000; +export const MATCH_DURATION = 10_000; + +const goToNextPlayer = ({ + board, + turns, +}: { + board: B; + turns: Turns; +}) => { + const { playerOrder, currentPlayerIndex } = board; + + const currentPlayer = getCurrentPlayer(board); + turns.end(currentPlayer); + + let nextPlayerIndex = currentPlayerIndex; + let nextPlayer = playerOrder[currentPlayerIndex]; + + for (const _ of playerOrder) { + nextPlayerIndex = (nextPlayerIndex + 1) % playerOrder.length; + nextPlayer = playerOrder[nextPlayerIndex]; + if (!board.players[nextPlayer].isDead) { + break; + } + } + + board.currentPlayerIndex = nextPlayerIndex; + + turns.begin(nextPlayer, { + expiresIn: TURN_DURATION, + boardMoveOnExpire: ["kill", { userId: nextPlayer }], + }); +}; + +const pass: PM = { + executeNow({ board, turns }) { + goToNextPlayer({ board, turns }); + }, +}; + +const roll: PM = { + executeNow({ board, userId }) { + if (getCurrentPlayer(board) !== userId) { + throw new Error("not your turn!"); + } + board.players[userId].isRolling = true; + }, + execute({ board, playerboards, userId, random, ts, turns, _ }) { + playerboards[userId].lastRollAt = ts; + board.players[userId].isRolling = false; + + const diceValue = + board.matchPlayersSettings[userId].dieNumFaces === "6" + ? random.d6() + : random.dice(20); + + board.players[userId].diceValue = diceValue; + board.sum = Object.values(board.players).reduce( + (sum, player) => sum + (player.diceValue || 0), + 0, + ); + + // Test those types here + _.logPlayerStat(userId, "rollValue", diceValue); + + // If it was the player's turn, we go to the next player. + goToNextPlayer({ board, turns }); + + if (board.sum >= 20) { + _.endMatch(); + _.logMatchStat("sumOnEnd", board.sum); + _.logPlayerStat(userId, "iMadeTheLastRoll", 1); + } + }, +}; + +const kill: BoardMove = { + execute({ board, payload, _, turns }) { + const { userId } = payload; + board.players[userId].diceValue = undefined; + board.players[userId].isDead = true; + goToNextPlayer({ board, turns }); + + if (Object.values(board.players).every((p) => p.isDead)) { + _.endMatch(); + } + }, +}; + +type BM

= BoardMove; + +const initMove: BM = { + execute({ board, _, ts }) { + _.turns.begin(board.playerOrder[0]); + _.delayMove("endMatch", MATCH_DURATION); + board.endsAt = ts + MATCH_DURATION; + }, +}; + +const endMatch: BM = { + execute({ board, _ }) { + for (const userId of board.playerOrder) { + board.players[userId].isDead = true; + } + + _.endMatch(); + }, +}; + +const gameSettings: GameSettings = [ + { + key: "setting1", + options: [{ value: "a" }, { value: "b" }], + }, + { + key: "setting2", + options: [{ value: "x" }, { value: "y", isDefault: true }], + }, +]; + +const gamePlayerSettings: GamePlayerSettings = [ + { + key: "color", + type: "color", + exclusive: true, + options: [ + { value: "red", label: "red" }, + { value: "blue", label: "blue" }, + { value: "green", label: "green" }, + { value: "orange", label: "orange" }, + { value: "pink", label: "pink" }, + { value: "brown", label: "brown" }, + { value: "black", label: "black" }, + { value: "darkgreen", label: "darkgreen" }, + { value: "darkred", label: "darkred" }, + { value: "purple", label: "purple" }, + ], + }, + { + key: "dieNumFaces", + type: "string", + options: [{ value: "6", isDefault: true }, { value: "20" }], + }, +]; + +export const game = { + initialBoards({ players, matchSettings, matchPlayersSettings }) { + return { + board: { + sum: 0, + players: Object.fromEntries( + players.map((userId) => [ + userId, + { isRolling: false, isDead: false }, + ]), + ), + playerOrder: [...players], + currentPlayerIndex: 0, + matchSettings, + matchPlayersSettings, + endsAt: null, + }, + playerboards: Object.fromEntries( + players.map((userId) => [userId, { lastRollAt: null }]), + ), + }; + }, + playerMoves: { roll, pass }, + boardMoves: { [INIT_MOVE]: initMove, kill, endMatch }, + minPlayers: 1, + maxPlayers: 10, + matchStats, + playerStats, + gameSettings, + gamePlayerSettings, +} satisfies Game; + +export type G = typeof game; diff --git a/games/game1-v2.3.0/ui/src/index.css b/games/game1-v2.7.0/src/index.css similarity index 76% rename from games/game1-v2.3.0/ui/src/index.css rename to games/game1-v2.7.0/src/index.css index 448cfde..71120b0 100644 --- a/games/game1-v2.3.0/ui/src/index.css +++ b/games/game1-v2.7.0/src/index.css @@ -4,7 +4,8 @@ html { } body, -html { +html, +#home { width: 100%; height: 100%; padding: 0; @@ -21,6 +22,10 @@ button { font-size: 1.5rem; } +.disabled { + background-color: #ccc; +} + .bold { font-weight: bold; } @@ -35,6 +40,7 @@ button { flex-direction: column; justify-content: center; height: 80px; + padding-top: 20px; } .red { @@ -76,3 +82,15 @@ button { .purple { color: purple; } + +.outer { + height: 100%; + display: flex; + flex-direction: column; +} + +.inner { + padding: 10px 0 0 0; + background-color: #f0f0f0; + margin: auto 0; +} diff --git a/games/game1-v2.7.0/src/locales/en/messages.po b/games/game1-v2.7.0/src/locales/en/messages.po new file mode 100644 index 0000000..6aef305 --- /dev/null +++ b/games/game1-v2.7.0/src/locales/en/messages.po @@ -0,0 +1,126 @@ +msgid "" +msgstr "" +"POT-Creation-Date: 2024-06-21 15:19+0000\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: @lingui/cli\n" +"Language: en\n" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: \n" +"Last-Translator: \n" +"Language-Team: \n" +"Plural-Forms: \n" + +#. js-lingui-explicit-id +#: lefun +msgid "lefun.settings.setting1.label" +msgstr "lefun.settings.setting1.label" + +#. js-lingui-explicit-id +#: lefun +msgid "lefun.settings.setting1.help" +msgstr "lefun.settings.setting1.help" + +#. js-lingui-explicit-id +#: lefun +msgid "lefun.settings.setting1.options.a.label" +msgstr "lefun.settings.setting1.options.a.label" + +#. js-lingui-explicit-id +#: lefun +msgid "lefun.settings.setting1.options.a.shortLabel" +msgstr "lefun.settings.setting1.options.a.shortLabel" + +#. js-lingui-explicit-id +#: lefun +msgid "lefun.settings.setting1.options.b.label" +msgstr "lefun.settings.setting1.options.b.label" + +#. js-lingui-explicit-id +#: lefun +msgid "lefun.settings.setting1.options.b.shortLabel" +msgstr "lefun.settings.setting1.options.b.shortLabel" + +#. js-lingui-explicit-id +#: lefun +msgid "lefun.settings.setting2.label" +msgstr "lefun.settings.setting2.label" + +#. js-lingui-explicit-id +#: lefun +msgid "lefun.settings.setting2.help" +msgstr "lefun.settings.setting2.help" + +#. js-lingui-explicit-id +#: lefun +msgid "lefun.settings.setting2.options.x.label" +msgstr "lefun.settings.setting2.options.x.label" + +#. js-lingui-explicit-id +#: lefun +msgid "lefun.settings.setting2.options.x.shortLabel" +msgstr "lefun.settings.setting2.options.x.shortLabel" + +#. js-lingui-explicit-id +#: lefun +msgid "lefun.settings.setting2.options.y.label" +msgstr "lefun.settings.setting2.options.y.label" + +#. js-lingui-explicit-id +#: lefun +msgid "lefun.settings.setting2.options.y.shortLabel" +msgstr "lefun.settings.setting2.options.y.shortLabel" + +#. js-lingui-explicit-id +#: lefun +msgid "lefun.playerSettings.dieNumFaces.label" +msgstr "lefun.playerSettings.dieNumFaces.label" + +#. js-lingui-explicit-id +#: lefun +msgid "lefun.playerSettings.dieNumFaces.options.6.label" +msgstr "lefun.playerSettings.dieNumFaces.options.6.label" + +#. js-lingui-explicit-id +#: lefun +msgid "lefun.playerSettings.dieNumFaces.options.20.label" +msgstr "lefun.playerSettings.dieNumFaces.options.20.label" + +#. js-lingui-explicit-id +#: lefun +msgid "lefun.name" +msgstr "Roll" + +#. js-lingui-explicit-id +#: lefun +msgid "lefun.tagline" +msgstr "The template game" + +#. js-lingui-explicit-id +#: lefun +msgid "lefun.aka" +msgstr "LeFun.fun" + +#. js-lingui-explicit-id +#: lefun +msgid "lefun.seoAka" +msgstr "" + +#. js-lingui-explicit-id +#: lefun +msgid "lefun.description" +msgstr "This game is merely a template game." + +#: src/Board.tsx +msgid "Pass" +msgstr "Pass" + +#: src/Board.tsx +msgid "Roll" +msgstr "Roll" + +#: src/Board.tsx +msgid "The template game" +msgstr "The template game" diff --git a/games/game1-v2.7.0/src/locales/fr/messages.po b/games/game1-v2.7.0/src/locales/fr/messages.po new file mode 100644 index 0000000..7f98ff0 --- /dev/null +++ b/games/game1-v2.7.0/src/locales/fr/messages.po @@ -0,0 +1,126 @@ +msgid "" +msgstr "" +"POT-Creation-Date: 2024-06-21 15:19+0000\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: @lingui/cli\n" +"Language: fr\n" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: \n" +"Last-Translator: \n" +"Language-Team: \n" +"Plural-Forms: \n" + +#. js-lingui-explicit-id +#: lefun +msgid "lefun.settings.setting1.label" +msgstr "" + +#. js-lingui-explicit-id +#: lefun +msgid "lefun.settings.setting1.help" +msgstr "" + +#. js-lingui-explicit-id +#: lefun +msgid "lefun.settings.setting1.options.a.label" +msgstr "" + +#. js-lingui-explicit-id +#: lefun +msgid "lefun.settings.setting1.options.a.shortLabel" +msgstr "" + +#. js-lingui-explicit-id +#: lefun +msgid "lefun.settings.setting1.options.b.label" +msgstr "" + +#. js-lingui-explicit-id +#: lefun +msgid "lefun.settings.setting1.options.b.shortLabel" +msgstr "" + +#. js-lingui-explicit-id +#: lefun +msgid "lefun.settings.setting2.label" +msgstr "" + +#. js-lingui-explicit-id +#: lefun +msgid "lefun.settings.setting2.help" +msgstr "" + +#. js-lingui-explicit-id +#: lefun +msgid "lefun.settings.setting2.options.x.label" +msgstr "" + +#. js-lingui-explicit-id +#: lefun +msgid "lefun.settings.setting2.options.x.shortLabel" +msgstr "" + +#. js-lingui-explicit-id +#: lefun +msgid "lefun.settings.setting2.options.y.label" +msgstr "" + +#. js-lingui-explicit-id +#: lefun +msgid "lefun.settings.setting2.options.y.shortLabel" +msgstr "" + +#. js-lingui-explicit-id +#: lefun +msgid "lefun.playerSettings.dieNumFaces.label" +msgstr "" + +#. js-lingui-explicit-id +#: lefun +msgid "lefun.playerSettings.dieNumFaces.options.6.label" +msgstr "" + +#. js-lingui-explicit-id +#: lefun +msgid "lefun.playerSettings.dieNumFaces.options.20.label" +msgstr "" + +#. js-lingui-explicit-id +#: lefun +msgid "lefun.name" +msgstr "Roule" + +#. js-lingui-explicit-id +#: lefun +msgid "lefun.tagline" +msgstr "Le jeu template" + +#. js-lingui-explicit-id +#: lefun +msgid "lefun.aka" +msgstr "LeFun.fun" + +#. js-lingui-explicit-id +#: lefun +msgid "lefun.seoAka" +msgstr "" + +#. js-lingui-explicit-id +#: lefun +msgid "lefun.description" +msgstr "Ce jeu n'est qu'un template." + +#: src/Board.tsx +msgid "Pass" +msgstr "" + +#: src/Board.tsx +msgid "Roll" +msgstr "Lancer" + +#: src/Board.tsx +msgid "The template game" +msgstr "Le jeu template" diff --git a/games/game1-v2.3.0/ui/src/main.tsx b/games/game1-v2.7.0/src/main.tsx similarity index 87% rename from games/game1-v2.3.0/ui/src/main.tsx rename to games/game1-v2.7.0/src/main.tsx index 16c140b..a251bfb 100644 --- a/games/game1-v2.3.0/ui/src/main.tsx +++ b/games/game1-v2.7.0/src/main.tsx @@ -1,7 +1,6 @@ -import { game } from "game1-v2.3.0-game"; - import { render } from "@lefun/dev-server"; +import { game } from "./game"; // @ts-expect-error abc import { messages as en } from "./locales/en/messages"; // @ts-expect-error abc @@ -16,5 +15,5 @@ await render({ }, game, messages: { en, fr }, - gameId: "game1-v2.3.0", + gameId: "game1-v2.5.3", }); diff --git a/games/game1-v2.3.0/ui/src/index.ts b/games/game1-v2.7.0/src/ui.tsx similarity index 100% rename from games/game1-v2.3.0/ui/src/index.ts rename to games/game1-v2.7.0/src/ui.tsx diff --git a/games/game1-v2.3.0/ui/tsconfig.json b/games/game1-v2.7.0/tsconfig.json similarity index 100% rename from games/game1-v2.3.0/ui/tsconfig.json rename to games/game1-v2.7.0/tsconfig.json diff --git a/games/game1-v2.3.0/ui/vite.config.ts b/games/game1-v2.7.0/vite.config.ts similarity index 100% rename from games/game1-v2.3.0/ui/vite.config.ts rename to games/game1-v2.7.0/vite.config.ts diff --git a/packages/core/src/gameMessages.ts b/packages/core/src/gameMessages.ts index cf0791b..4f17340 100644 --- a/packages/core/src/gameMessages.ts +++ b/packages/core/src/gameMessages.ts @@ -1,7 +1,7 @@ /* * We define here the keys used for the game messages for i18n. */ -export const gameMessageKeys: Record string> = { +export const gameMessageKeys = { name() { return "lefun.name"; }, diff --git a/packages/core/src/index.test.ts b/packages/core/src/index.test.ts index c6b9cc0..608232e 100644 --- a/packages/core/src/index.test.ts +++ b/packages/core/src/index.test.ts @@ -38,7 +38,6 @@ test("meta initialize add remove", () => { poil: { ready: true, itsYourTurn: false, - joinedAt: now, isBot: false, }, }, @@ -56,13 +55,11 @@ test("meta initialize add remove", () => { poil: { ready: true, itsYourTurn: false, - joinedAt: now, isBot: false, }, patate: { ready: true, itsYourTurn: false, - joinedAt: now, isBot: true, }, }, @@ -79,7 +76,6 @@ test("meta initialize add remove", () => { poil: { ready: true, itsYourTurn: false, - joinedAt: now, isBot: false, }, }, @@ -96,13 +92,11 @@ test("meta initialize add remove", () => { poil: { ready: true, itsYourTurn: false, - joinedAt: now, isBot: false, }, patate: { ready: true, itsYourTurn: false, - joinedAt: now, isBot: false, }, }, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9b172e2..d99d933 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2,33 +2,4 @@ export * from "./gameMessages"; export * from "./meta"; export * from "./types"; -import { UserId } from "./types"; - export type AkaType = "similar" | "aka" | "inspired" | "original"; - -export type User = { - username: string; - isGuest: boolean; - isBot?: boolean; -}; - -export type UsersState = { byId: Record }; - -/* - * State of the match as seen from a player. - * This is what the game UI has access to. - * Note that there is a version with less `undefined` values in `@lefun/ui`, for use by - * game developers. - */ -export type MatchState = { - // The player's userid - userId?: UserId; - board?: B; - // The player's own board. - playerboard?: PB; - users: UsersState; - - // Timing info with respect to the - timeDelta?: number; - timeLatency?: number; -}; diff --git a/packages/core/src/meta.ts b/packages/core/src/meta.ts index 9a41464..ac8921f 100644 --- a/packages/core/src/meta.ts +++ b/packages/core/src/meta.ts @@ -21,15 +21,6 @@ export interface Player { // When in the lobby, are we ready to start. When everyone is ready the game starts. ready: boolean; - // Score for the player at the end of the match. - // What it means depends on the type of score as defined in the Game. - // See - score?: number; - - // Rank of the player for the match. Even though this can be deduced - // from the `score`, having it here makes que user stats per rank easy to fetch. - rank?: number; - // Is it this player's turn? // This is used to: // * Trigger the "it's your turn" sounds @@ -37,13 +28,21 @@ export interface Player { // * It is NOT used to check for permission of making moves itsYourTurn: boolean; - // After coming out of the internet the date is in a string. - joinedAt: Date | string; + // `undefined` when it's not our turn and for backward compatibility + // (older matches won't have that field). + turnBeganAt?: number; + + // `undefined` when it's not our turn and for backward compatibility + turnExpiresAt?: number; // Is the player voting to end the match? votesToEndMatch?: boolean; // Is it a bot? + // TODO: deprecate this, `meta` shouldn't care if the player is a bot or human + // That info should go in the separate `User` data, along with the username. + // The logic is that we should be able to swap a human player with a bot without + // changing `meta`. isBot: boolean; } @@ -53,10 +52,6 @@ export type Meta = { matchSettings: MatchSettings; matchPlayersSettings: MatchPlayersSettings; - // Score for the match. This makes sense for collaborative/solo matches. We could put - // the best score in the "game" page. - score?: number; - // Kept for backward compatibility, those are not used anymore. settings?: { botMoveDuration?: number; @@ -91,7 +86,6 @@ export const metaInitialState = ({ export const metaAddUserToMatch = ({ meta, userId, - ts, matchPlayerSettings = {}, isBot, }: { @@ -115,7 +109,7 @@ export const metaAddUserToMatch = ({ } else { index = 0; for (const otherUserId of meta.players.allIds) { - if (meta.players.byId[otherUserId].isBot) { + if (meta.players.byId[otherUserId]?.isBot) { break; } index++; @@ -125,7 +119,6 @@ export const metaAddUserToMatch = ({ meta.players.byId[userId] = { ready: true, itsYourTurn: false, - joinedAt: ts, isBot, }; meta.players.allIds.splice(index, 0, userId); @@ -146,29 +139,42 @@ export const metaRemoveUserFromMatch = (meta: Meta, userId: UserId): void => { meta.players.allIds.splice(idx, 1); }; -export const metaSetTurns = ({ +export const metaBeginTurn = ({ meta, - userIds, - value, + userId, + beginsAt, + expiresAt, }: { meta: Meta; - userIds: UserId | UserId[] | "all"; - value: boolean; + userId: UserId; + beginsAt: number; + expiresAt?: number; }): void => { - if (userIds === "all") { - userIds = meta.players.allIds; - } else if (!Array.isArray(userIds)) { - userIds = [userIds]; + const player = meta.players.byId[userId]; + if (!player) { + console.warn( + `Trying to start turn for user ${userId} who is not in "meta"`, + ); + return; } + player.itsYourTurn = true; + player.turnBeganAt = beginsAt; + player.turnExpiresAt = expiresAt; +}; - for (const userId of userIds) { - const player = meta.players.byId[userId]; - if (!player) { - console.warn( - `Trying to set itsYourTurn for user ${userId} who is not in "meta"`, - ); - continue; - } - player.itsYourTurn = value; +export const metaEndTurn = ({ + meta, + userId, +}: { + meta: Meta; + userId: UserId; +}): void => { + const player = meta.players.byId[userId]; + if (!player) { + console.warn(`Trying to end turn for user ${userId} who is not in "meta"`); + return; } + player.itsYourTurn = false; + player.turnBeganAt = undefined; + player.turnExpiresAt = undefined; }; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 3f833cf..f7090be 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -27,6 +27,7 @@ export type GameSetting = { key: string; options: GameSettingOption[]; // Don't hide the game setting even when the default value is selected. + // Defaults to `false`. alwaysShow?: boolean; // Keeping as optional for backward compatiblity @@ -161,8 +162,22 @@ export type Credits = { // Some conditional type utilities. export type IfNull = [T] extends [null] ? Y : N; export type IfAny = 0 extends 1 & T ? Y : N; + export type IfAnyNull = IfAny< T, ANY, IfNull >; + +export type IsExactly = [T] extends [U] + ? [U] extends [T] + ? true + : false + : false; + +export type If_ObjectOrNull_Null = + IsExactly extends true + ? ObjOrNull + : IsExactly extends true + ? Null + : ELSE; diff --git a/packages/dev-server/src/App.tsx b/packages/dev-server/src/App.tsx index 92b1b24..c1cd7de 100644 --- a/packages/dev-server/src/App.tsx +++ b/packages/dev-server/src/App.tsx @@ -15,6 +15,7 @@ import { GameSetting, GameSettings_, Locale, + Meta, UserId, } from "@lefun/core"; import { @@ -31,7 +32,12 @@ import { UseSelector, } from "@lefun/ui"; -import { Match } from "./match"; +import { + Backend, + MOVE_EVENT, + patchesForUserEvent, + REVERT_MOVE_EVENT, +} from "./backend"; import { OptimisticBoards } from "./moves"; import { useStore } from "./store"; import { generateId } from "./utils"; @@ -56,14 +62,14 @@ const reformatPlayerboardPatch = (patch: Patch) => { const BoardForPlayer = ({ BoardComponent, - match, + backend, userId, messages, locale, gameId, }: { BoardComponent: () => ReactNode; - match: Match; + backend: Backend; userId: UserId | "spectator"; messages: Record; locale: Locale; @@ -80,17 +86,17 @@ const BoardForPlayer = ({ const optimisticBoards = useRef( proxy( new OptimisticBoards({ - board: match.store.board, - playerboard: match.store.playerboards[userId], + board: backend.store.board, + playerboard: backend.store.playerboards[userId] || null, + meta: backend.store.meta, }), ), ); useEffect(() => { - const { store: mainStore } = match; - const { users } = mainStore; + const { store: mainStore } = backend; - match.addEventListener(`patches:${userId}`, (event: any) => { + backend.addEventListener(patchesForUserEvent(userId), (event: any) => { if (!event) { return; } @@ -103,17 +109,18 @@ const BoardForPlayer = ({ }, LATENCY); }); - match.addEventListener("revertMove", (event: any) => { + backend.addEventListener(REVERT_MOVE_EVENT, (event: any) => { const { moveId } = event.detail; optimisticBoards.current.revertMove(moveId); }); setMakeMove((name, payload) => { if (userId === "spectator") { - throw new Error("spectator cannot make moves"); + console.warn("spectator cannot make moves"); + return; } - if (match.store.matchStatus === "over") { + if (backend.store.matchStatus === "over") { console.warn("match is over"); return; } @@ -124,25 +131,26 @@ const BoardForPlayer = ({ result = executePlayerMove({ name, payload, - game: match.game, + game: backend.game, userId, board: optimisticBoards.current.board, playerboards: { [userId]: optimisticBoards.current.playerboard }, secretboard: null, now: new Date().getTime(), - random: match.random, + random: backend.random, skipCanDo: false, onlyExecuteNow: true, // Note that technically we should not use anything from // `match.store` as this represents the DB. - matchData: match.store.matchData, - gameData: match.store.gameData, - meta: match.store.meta, + matchData: backend.store.matchData, + gameData: backend.store.gameData, + meta: backend.store.meta, }); - } catch { + } catch (e) { console.warn( `Ignoring move "${name}" for user "${userId}" because of error`, ); + console.warn(e); return; } @@ -153,10 +161,12 @@ const BoardForPlayer = ({ optimisticBoards.current.makeMove(moveId, patches); // Run the move in the backend also. - match.makeMove({ userId, name, payload, moveId }); + backend.makeMove({ userId, name, payload, moveId }); }); - const _useSelector = (): UseSelector => { + const { users } = mainStore; + + const _useSelector = (): UseSelector => { // We wrap it to respect the rules of hooks. const useSelector = ( selector: Selector, @@ -164,7 +174,16 @@ const BoardForPlayer = ({ const snapshot = useSnapshot(optimisticBoards.current); const board = snapshot.board; const playerboard = snapshot.playerboard; - return selector({ board, playerboard, users, userId }); + const meta = snapshot.meta as Meta; + return selector({ + board, + playerboard, + meta, + userId, + users, + timeDelta: 0, + timeLatency: 0, + }); }; return useSelector; }; @@ -172,11 +191,15 @@ const BoardForPlayer = ({ setUseSelector(_useSelector); setUseStore(() => { + const playerboard = optimisticBoards.current.playerboard; return { board: snapshot(optimisticBoards.current.board), - playerboard: snapshot(optimisticBoards.current.playerboard), + playerboard: playerboard === null ? null : snapshot(playerboard), + meta: snapshot(optimisticBoards.current.meta) as Meta, userId, - users: mainStore.users, + users, + timeDelta: 0, + timeLatency: 0, }; }); @@ -184,7 +207,7 @@ const BoardForPlayer = ({ setUseSelectorShallow(_useSelector); setLoading(false); - }, [userId, match, gameId]); + }, [userId, backend, gameId]); if (loading) { return

Loading player...
; @@ -224,7 +247,7 @@ const PlayerStats = ({ userId }: { userId: UserId }) => { (state) => state.match?.store.playerStats[userId] || [], ); const username = useStore( - (state) => state.match?.store.users.byId[userId]?.username, + (state) => state.match?.store.users[userId]?.username, ); return ( @@ -283,10 +306,10 @@ function MatchStateView() { setRefreshCounter((prev) => prev + 1); }; - match.addEventListener("move", handler); + match.addEventListener(MOVE_EVENT, handler); return () => { - match.removeEventListener("move", handler); + match.removeEventListener(MOVE_EVENT, handler); }; }, [match]); } @@ -300,17 +323,19 @@ function MatchStateView() { return (
{store.matchStatus || "no status"} - {(["board", "playerboards", "secretboard"] as const).map((key) => ( - - ))} + {(["board", "playerboards", "secretboard", "meta"] as const).map( + (key) => ( + + ), + )} {store.meta.players.allIds.map((userId) => ( ))} @@ -404,8 +429,8 @@ function MatchSettingsView({ gameSettings }: { gameSettings: GameSettings_ }) { {gameSettings.allIds.map((key) => ( ))} @@ -470,7 +495,7 @@ function MatchPlayerSettings({ ); const username = useStore( - (state) => state.match?.store.users.byId[userId]?.username, + (state) => state.match?.store.users[userId]?.username, ); if (!gamePlayerSettings || !matchPlayersSettings) { @@ -479,15 +504,20 @@ function MatchPlayerSettings({ return (
- + { + console.warn("NOT IMPLEMENTED YET change username"); + }} + /> {gamePlayerSettings.allIds.map((key) => ( ))}
@@ -505,7 +535,7 @@ function PlayerSettingsView({ } const { users } = match.store; - const userIds = Object.keys(users.byId); + const userIds = Object.keys(users); return ( @@ -558,7 +588,10 @@ function ButtonRow({ children }: { children: ReactNode }) { } function capitalize(s: string): string { - return s && s[0].toUpperCase() + s.slice(1); + if (s === "") { + return ""; + } + return s && s[0]!.toUpperCase() + s.slice(1); } function SettingsButtons() { @@ -724,31 +757,74 @@ function Settings() { const ItsMyTurn = ({ userId }: { userId: UserId }) => { const match = useStore((state) => state.match); + const ref = useRef(null); + const [itsMyTurn, setItsMyTurn] = useState( () => match?.store.meta.players.byId[userId]?.itsYourTurn || false, ); + const [width, setWidth] = useState(0); + useEffect(() => { if (!match || userId === "spectator") { return; } + let interval: NodeJS.Timeout; + const handler = () => { - setItsMyTurn(match?.store.meta.players.byId[userId].itsYourTurn || false); + if (interval) { + clearInterval(interval); + } + const myTurn = match.store.meta.players.byId[userId]!.itsYourTurn; + setItsMyTurn(myTurn); + + if (!myTurn) { + setWidth(0); + return; + } + + const beganAt = match?.store.meta.players.byId[userId]!.turnBeganAt; + const expiresAt = match?.store.meta.players.byId[userId]!.turnExpiresAt; + + if (expiresAt) { + interval = setInterval(() => { + const totalWidth = ref.current?.clientWidth || 0; + const now = new Date().getTime(); + if (!beganAt) { + console.warn("beganAt is undefined"); + return; + } + const width = ((now - beganAt) / (expiresAt - beganAt)) * totalWidth; + setWidth(Math.min(width, totalWidth)); + }, 50); + } }; - match.addEventListener("metaChanged", handler); + match.addEventListener(patchesForUserEvent(userId), handler); - return () => match.removeEventListener("metaChanged", handler); + return () => { + match.removeEventListener(patchesForUserEvent(userId), handler); + if (interval) { + clearInterval(interval); + } + }; }, [match, userId]); return (
+ {itsMyTurn && ( +
+ )} {userId}
); @@ -854,7 +930,7 @@ function Dimensions({ } type Lefun = { - match: Match; + backend: Backend; }; function Main() { @@ -886,6 +962,6 @@ function Main() { ); } -type AllMessages = Record>; +type AllMessages = Record>; export { AllMessages, BoardForPlayer, Lefun, Main, RulesWrapper }; diff --git a/packages/dev-server/src/match.test.ts b/packages/dev-server/src/backend.test.ts similarity index 69% rename from packages/dev-server/src/match.test.ts rename to packages/dev-server/src/backend.test.ts index 876c550..e51116b 100644 --- a/packages/dev-server/src/match.test.ts +++ b/packages/dev-server/src/backend.test.ts @@ -1,7 +1,7 @@ import { enablePatches, produceWithPatches } from "immer"; import { expect, test } from "vitest"; -import { separatePatchesByUser } from "./match"; +import { separatePatchesByUser } from "./backend"; enablePatches(); @@ -46,7 +46,6 @@ test("separatePatchesByUser", () => { }, ]); - // Without ignoreUserId { const patchesByUser = { user1: [], @@ -57,7 +56,6 @@ test("separatePatchesByUser", () => { separatePatchesByUser({ patches, userIds: ["user1", "user2"], - ignoreUserId: null, patchesOut: patchesByUser, }); @@ -95,43 +93,4 @@ test("separatePatchesByUser", () => { }, ]); } - - // With ignoreUserId - { - const patchesByUser = { - user1: [], - user2: [], - spectator: [], - }; - - separatePatchesByUser({ - patches, - userIds: ["user1", "user2"], - ignoreUserId: "user1", - patchesOut: patchesByUser, - }); - - expectArraysEqual(patchesByUser["user1"], []); - - expectArraysEqual(patchesByUser["user2"], [ - { - op: "replace", - path: ["board"], - value: { x: 456 }, - }, - { - op: "replace", - path: ["playerboard"], - value: { x: 456 }, - }, - ]); - - expectArraysEqual(patchesByUser["spectator"], [ - { - op: "replace", - path: ["board"], - value: { x: 456 }, - }, - ]); - } }); diff --git a/packages/dev-server/src/match.ts b/packages/dev-server/src/backend.ts similarity index 80% rename from packages/dev-server/src/match.ts rename to packages/dev-server/src/backend.ts index a61915f..d7a82ed 100644 --- a/packages/dev-server/src/match.ts +++ b/packages/dev-server/src/backend.ts @@ -8,10 +8,7 @@ import { Meta, metaAddUserToMatch, metaInitialState, - metaSetTurns, - User, UserId, - UsersState, } from "@lefun/core"; import { DelayedMove, @@ -20,40 +17,61 @@ import { Game_, MoveExecutionOutput, Random, + updateMetaWithTurnInfo, } from "@lefun/game"; +import { User } from "@lefun/ui"; import { generateId } from "./utils"; +type Player = { + userId: UserId; + isBot: boolean; + username: string; +}; + +type DelayedMoveId = string; + // This is what would normally go in a database. type MatchStore = { - board: unknown; - playerboards: Record; - secretboard: unknown; - // + board: object; + playerboards: Record; + secretboard: object | null; meta: Meta; matchData: unknown; gameData: unknown; matchSettings: MatchSettings; matchPlayersSettings: MatchPlayersSettings; - // - users: UsersState; - // + + // This represents the Users in the database. + users: Record; + // Simplified match statuses. matchStatus: "started" | "over"; + matchStats: { key: string; value: number }[]; playerStats: Record; - // => delayedMove + // We need an id to be able to remove them on execution. - delayedMoves: Record; + delayedMoves: Record; }; // We increment this every time we make backward incompatible changes in the match // saved to local storage. We save this version with the match to later detect that // a saved match is too old. -const VERSION = 5; - -/* This class replaces our backend */ -class Match extends EventTarget { +const VERSION = 6; + +// Events +export const patchesForUserEvent = (userId: UserId) => `PATCHES/${userId}`; +export const REVERT_MOVE_EVENT = "REVERT_MOVE"; +export const MOVE_EVENT = "MOVE"; + +/* + * Simulates the backend. + * + * It holds the ground truth about the match state. + * Views can listen to events on it to update themselves. + * */ +class Backend extends EventTarget { random: Random; game: Game_; gameId: GameId; @@ -78,7 +96,7 @@ class Match extends EventTarget { }: { game: Game_; gameId: GameId; - players: Record; + players: Player[]; matchSettings: MatchSettings; matchPlayersSettings: MatchPlayersSettings; matchData: unknown; @@ -108,7 +126,7 @@ class Match extends EventTarget { }: { game: Game_; gameId: GameId; - players?: Record; + players?: Player[]; matchSettings?: MatchSettings; matchPlayersSettings?: MatchPlayersSettings; matchData?: unknown; @@ -153,10 +171,10 @@ class Match extends EventTarget { } const areBots = Object.fromEntries( - Object.entries(players).map(([userId, { isBot }]) => [userId, !!isBot]), + players.map(({ userId, isBot }) => [userId, !!isBot]), ); - const userIds = Object.keys(players); + const userIds = players.map(({ userId }) => userId); const meta = metaInitialState({ matchSettings, locale }); const ts = new Date(); @@ -164,10 +182,6 @@ class Match extends EventTarget { metaAddUserToMatch({ meta, userId, ts, isBot: false }); } - const users: UsersState = { - byId: players, - }; - // We do this once to make sure we have the same data for everyplayer. // Then we'll deep copy the boards to make sure they are not linked. const initialBoards = game.initialBoards({ @@ -183,17 +197,24 @@ class Match extends EventTarget { }); const { board, secretboard = {} } = initialBoards; - const playerboards = + const playerboards: Record = initialBoards.playerboards || - Object.fromEntries(userIds.map((userId) => [userId, {}])); + Object.fromEntries(userIds.map((userId) => [userId, null])); + + const users = Object.fromEntries( + players.map(({ userId, username, isBot }) => [ + userId, + { username, isBot }, + ]), + ); this.store = { - meta, users, // matchSettings, matchPlayersSettings, // + meta, board, playerboards, secretboard, @@ -217,7 +238,7 @@ class Match extends EventTarget { ); this._removeDelayedMove(delayedMoveId); // Force refresh of the list of delayed moves. - this.dispatchEvent(new CustomEvent("move")); + this.dispatchEvent(new CustomEvent(MOVE_EVENT)); return; } @@ -308,6 +329,17 @@ class Match extends EventTarget { store.secretboard = secretboard; } + // Update meta + const { beginTurn, endTurn } = result; + const { meta: newMeta, patches: metaPatches } = updateMetaWithTurnInfo({ + meta, + beginTurn, + endTurn, + now, + }); + + store.meta = newMeta; + // Send out patches to users. { const userIds = store.meta.players.allIds; @@ -319,21 +351,25 @@ class Match extends EventTarget { const { patches } = result; separatePatchesByUser({ - patches, + patches: [...patches, ...metaPatches], userIds, patchesOut: patchesByUserId, }); + // Add the `meta` patches to everyone. + for (const [userId, patches] of Object.entries(patchesByUserId)) { if (patches.length === 0) { continue; } this.dispatchEvent( - new CustomEvent(`patches:${userId}`, { detail: { moveId, patches } }), + new CustomEvent(patchesForUserEvent(userId), { + detail: { moveId, patches }, + }), ); } // This is for the dev server state view so that it knows it needs to update. - this.dispatchEvent(new CustomEvent("move")); + this.dispatchEvent(new CustomEvent(MOVE_EVENT)); } // Has the match ended? @@ -345,35 +381,18 @@ class Match extends EventTarget { for (const stat of result.stats) { const { key, value, userId } = stat; if (userId) { - this.store.playerStats[userId].push({ key, value }); + this.store.playerStats[userId]!.push({ key, value }); } else { this.store.matchStats.push({ key, value }); } } - // Turns - metaSetTurns({ - meta, - userIds: Array.from(result.beginTurnUsers), - value: true, - }); - - metaSetTurns({ - meta, - userIds: Array.from(result.endTurnUsers), - value: false, - }); - - if (result.beginTurnUsers.size > 0 || result.endTurnUsers.size > 0) { - this.dispatchEvent(new CustomEvent("metaChanged")); - } - // Also cancel move expiry timeouts for users whose turn has ended. - for (const userId of result.endTurnUsers) { + for (const userId of Object.keys(result.endTurn)) { this._removeDelayedMoveForUser(userId); } - for (const userId of result.beginTurnUsers) { + for (const userId of Object.keys(result.beginTurn)) { this._removeDelayedMoveForUser(userId); } @@ -382,7 +401,7 @@ class Match extends EventTarget { this._addDelayedMove(delayedMove); } - saveMatchToLocalStorage(this, this.gameId); + saveBackendToLocalStorage(this, this.gameId); } makeBoardMove(name: string, payload: any) { @@ -406,7 +425,9 @@ class Match extends EventTarget { this._makeMove(name, payload, userId, moveId); } catch (e) { console.error("There was an error executing move", name, e); - this.dispatchEvent(new CustomEvent("revertMove", { detail: { moveId } })); + this.dispatchEvent( + new CustomEvent(REVERT_MOVE_EVENT, { detail: { moveId } }), + ); } } @@ -455,7 +476,7 @@ class Match extends EventTarget { return JSON.stringify({ store, version: VERSION }); } - static _deserialize(str: string, game: Game_, gameId: GameId): Match { + static _deserialize(str: string, game: Game_, gameId: GameId): Backend { const obj = JSON.parse(str); const { store, version } = obj; @@ -464,7 +485,7 @@ class Match extends EventTarget { throw new Error(`unsupported version ${version}`); } - return new Match({ + return new Backend({ game, gameId, store, @@ -475,30 +496,26 @@ class Match extends EventTarget { export function separatePatchesByUser({ patches, userIds, - ignoreUserId = null, patchesOut, }: { patches: Patch[]; userIds: UserId[]; - ignoreUserId?: UserId | null; patchesOut: Record; }) { for (const patch of patches) { const { path } = patch; const [p0, p1, ...rest] = path; - // "board" patches, we send those to everyone but the user making the move. - if (p0 === "board") { + // "board" and "meta" patches are sent to everyone. + if (p0 === "board" || p0 === "meta") { for (const userId of userIds) { - if (userId !== ignoreUserId) { - patchesOut[userId].push(patch); - } + patchesOut[userId]!.push(patch); } - patchesOut["spectator"].push(patch); + patchesOut["spectator"]!.push(patch); } // Send 'playerboards' patches to concerned players. else if (p0 === "playerboards") { - if (p1 !== ignoreUserId) { - patchesOut[p1].push({ ...patch, path: ["playerboard", ...rest] }); + if (p1) { + patchesOut[p1]!.push({ ...patch, path: ["playerboard", ...rest] }); } } else if (p0 === "secretboard") { // @@ -512,11 +529,14 @@ function _matchKey(gameId?: string) { return `match${gameId ? `.${gameId}` : ""}`; } -function saveMatchToLocalStorage(match: Match, gameId?: string) { +function saveBackendToLocalStorage(match: Backend, gameId?: string) { localStorage.setItem(_matchKey(gameId), match._serialize()); } -function loadMatchFromLocalStorage(game: Game_, gameId: GameId): Match | null { +function loadBackendFromLocalStorage( + game: Game_, + gameId: GameId, +): Backend | null { const str = localStorage.getItem(_matchKey(gameId)); if (!str) { @@ -524,11 +544,11 @@ function loadMatchFromLocalStorage(game: Game_, gameId: GameId): Match | null { } try { - return Match._deserialize(str, game, gameId); + return Backend._deserialize(str, game, gameId); } catch (e) { console.warn("Failed to deserialize match", e); return null; } } -export { loadMatchFromLocalStorage, Match, saveMatchToLocalStorage }; +export { Backend, loadBackendFromLocalStorage, saveBackendToLocalStorage }; diff --git a/packages/dev-server/src/moves.ts b/packages/dev-server/src/moves.ts index ba29135..e62d3cc 100644 --- a/packages/dev-server/src/moves.ts +++ b/packages/dev-server/src/moves.ts @@ -1,38 +1,48 @@ import { applyPatches, Patch } from "immer"; +import { Meta } from "@lefun/core"; import { GameStateBase } from "@lefun/game"; import { deepCopy } from "./utils"; -export class OptimisticBoards { +export class OptimisticBoards { _confirmedBoard: GS["B"]; - _confirmedPlayerboard: GS["PB"]; + _confirmedPlayerboard: GS["PB"] | null; + _confirmedMeta: Meta; // The player's moves that have not been confirmed yet _pendingMoves: { moveId: string; patches: Patch[] }[]; // ConfirmedBoard + pending moves updates: this is what we will display. board: GS["B"]; - playerboard: GS["PB"]; + // `null` for spectators. Note that GS["PB"] can itself be `null` for games without playerboards. + playerboard: GS["PB"] | null; + + meta: Meta; constructor({ board, playerboard, + meta, }: { board: GS["B"]; - playerboard: GS["PB"]; + playerboard: GS["PB"] | null; + meta: Meta; }) { board = deepCopy(board); playerboard = deepCopy(playerboard); + meta = deepCopy(meta); this._confirmedBoard = board; this._confirmedPlayerboard = playerboard; + this._confirmedMeta = meta; this._pendingMoves = []; this.board = board; this.playerboard = playerboard; + this.meta = meta; } _getMoveIndex(moveId: string): number { for (let i = 0; i < this._pendingMoves.length; i++) { - if (this._pendingMoves[i].moveId === moveId) { + if (this._pendingMoves[i]!.moveId === moveId) { return i; } } @@ -43,11 +53,16 @@ export class OptimisticBoards { _replay() { let board = this._confirmedBoard; let playerboard = this._confirmedPlayerboard; + let meta = this._confirmedMeta; for (const { patches } of this._pendingMoves) { - ({ board, playerboard } = applyPatches({ board, playerboard }, patches)); + ({ board, playerboard, meta } = applyPatches( + { board, playerboard, meta }, + patches, + )); } this.board = board; this.playerboard = playerboard; + this.meta = meta; } makeMove(moveId: string, patches: Patch[]) { @@ -72,11 +87,16 @@ export class OptimisticBoards { */ confirmMove({ moveId, patches }: { moveId?: string; patches: Patch[] }) { // Start from the confirmed boards - const { _confirmedBoard: board, _confirmedPlayerboard: playerboard } = this; + const { + _confirmedBoard: board, + _confirmedPlayerboard: playerboard, + _confirmedMeta: meta, + } = this; { - const state = applyPatches({ board, playerboard }, patches); + const state = applyPatches({ board, playerboard, meta }, patches); this._confirmedBoard = state.board; this._confirmedPlayerboard = state.playerboard; + this._confirmedMeta = state.meta; } // Remove the `moveId` if it's one of our moves. diff --git a/packages/dev-server/src/render.tsx b/packages/dev-server/src/render.tsx index 8afc1dc..51d92c1 100644 --- a/packages/dev-server/src/render.tsx +++ b/packages/dev-server/src/render.tsx @@ -10,14 +10,14 @@ import type { MatchPlayersSettings, MatchSettings, } from "@lefun/core"; -import { Game, Game_, INIT_MOVE, parseGame } from "@lefun/game"; +import { Game, Game_, GameStateAny, INIT_MOVE, parseGame } from "@lefun/game"; import { AllMessages, BoardForPlayer, Lefun, Main, RulesWrapper } from "./App"; import { - loadMatchFromLocalStorage, - Match, - saveMatchToLocalStorage, -} from "./match"; + Backend, + loadBackendFromLocalStorage, + saveBackendToLocalStorage, +} from "./backend"; import { createStore, MainStoreContext } from "./store"; function getUserIds(numPlayers: number) { @@ -26,7 +26,7 @@ function getUserIds(numPlayers: number) { .map((_, i) => i.toString()); } -const initMatch = ({ +const initBackend = ({ game, gameId, matchData, @@ -44,8 +44,7 @@ const initMatch = ({ matchSettings?: MatchSettings; matchPlayersSettings?: MatchPlayersSettings; numPlayers?: number; -}) => { - console.log("init", numPlayers); +}): Backend => { numPlayers ??= game.minPlayers; locale ??= "en"; @@ -57,7 +56,7 @@ const initMatch = ({ if (gameSettings) { matchSettings = Object.fromEntries( gameSettings.allIds.map((key) => { - const defaultValue = gameSettings.byId[key].defaultValue; + const defaultValue = gameSettings.byId[key]!.defaultValue; return [key, defaultValue]; }), ); @@ -76,20 +75,20 @@ const initMatch = ({ for (const key of gamePlayerSettings.allIds) { const taken = new Set(); - const { exclusive, options } = gamePlayerSettings.byId[key]; + const { exclusive, options } = gamePlayerSettings.byId[key]!; for (const userId of userIds) { let found = false; for (const { value, isDefault } of options) { if (exclusive && !taken.has(value)) { - matchPlayersSettings[userId][key] = value; + matchPlayersSettings[userId]![key] = value; taken.add(value); found = true; break; } if (isDefault && !exclusive) { - matchPlayersSettings[userId][key] = value; + matchPlayersSettings[userId]![key] = value; found = true; break; } @@ -101,7 +100,7 @@ const initMatch = ({ ); } else { // Fallback on the first option. - matchPlayersSettings[userId][key] = options[0].value; + matchPlayersSettings[userId]![key] = options[0]!.value; } } } @@ -109,22 +108,13 @@ const initMatch = ({ } } - const players = Object.fromEntries( - userIds.map((userId) => [ - userId, - { - username: `Player ${userId}`, - isBot: false, - // TODO We don't need to know if they are guests in here. - isGuest: false, - }, - ]), - ); - - console.log("players before ctr"); - console.log(players); + const players = userIds.map((userId) => ({ + userId, + username: `Player ${userId}`, + isBot: false, + })); - const match = new Match({ + const backend = new Backend({ game, gameId, matchSettings, @@ -135,7 +125,7 @@ const initMatch = ({ locale, }); - return match; + return backend; }; async function render({ @@ -146,9 +136,9 @@ async function render({ matchData, gameData, idName = "home", - messages = { en: {} }, + messages = { en: {}, fr: {} }, }: { - game: Game; + game: Game; gameId: GameId; board: () => Promise; rules?: () => Promise; @@ -187,14 +177,14 @@ async function render({ // Is it the player's board? if (userId !== null) { - const match = ((window.top as any).lefun as Lefun).match; + const backend = ((window.top as any).lefun as Lefun).backend; const component = await board(); const content = ( component} - match={match} + backend={backend} userId={userId} messages={messages[locale]} locale={locale} @@ -233,49 +223,49 @@ async function render({ matchSettings?: MatchSettings; matchPlayersSettings?: MatchPlayersSettings; } = {}) => { - let match = store.getState().match; + let backend = store.getState().match; - match = initMatch({ + backend = initBackend({ game: game_, gameId, matchData, gameData, - locale: locale || match?.store.meta.locale, - numPlayers: numPlayers || match?.store.meta.players.allIds.length, - matchSettings: matchSettings || match?.store.matchSettings, + locale: locale || backend?.store.meta.locale, + numPlayers: numPlayers || backend?.store.meta.players.allIds.length, + matchSettings: matchSettings || backend?.store.matchSettings, matchPlayersSettings: matchPlayersSettings || (numPlayers === undefined - ? match?.store.matchPlayersSettings + ? backend?.store.matchPlayersSettings : undefined), }); // We use `window.lefun` to communicate between the host and the player boards. - (window as any).lefun = { match }; + (window as any).lefun = { backend }; - saveMatchToLocalStorage(match, gameId); + saveBackendToLocalStorage(backend, gameId); - store.setState(() => ({ match })); + store.setState(() => ({ match: backend })); // Start the match with the first "INIT_MOVE" board move. - match.makeBoardMove(INIT_MOVE, {}); + backend.makeBoardMove(INIT_MOVE, {}); }; store.setState(() => ({ game: game_, resetMatch })); // Try to load the match from local storage, or create a new one. { - let match = loadMatchFromLocalStorage(game_, gameId); - if (!match) { - match = initMatch({ + let backend = loadBackendFromLocalStorage(game_, gameId); + if (!backend) { + backend = initBackend({ game: game_, gameId, matchData, gameData, }); } - store.setState(() => ({ match })); - (window as any).lefun = { match }; + store.setState(() => ({ match: backend })); + (window as any).lefun = { backend }; } // We import the CSS using the package name because this is what will be needed by packages importing this. diff --git a/packages/dev-server/src/store.ts b/packages/dev-server/src/store.ts index 4e9e1c7..b230841 100644 --- a/packages/dev-server/src/store.ts +++ b/packages/dev-server/src/store.ts @@ -8,7 +8,7 @@ import { createStore as _createStore, useStore as _useStore } from "zustand"; import type { Locale, UserId } from "@lefun/core"; import { Game_ } from "@lefun/game"; -import type { Match } from "./match"; +import type { Backend } from "./backend"; const KEYS_TO_LOCAL_STORAGE: (keyof State)[] = [ "visibleUserId", @@ -32,7 +32,7 @@ type State = { locales: Locale[]; view: View; game: Game_; - match: Match | undefined; + match: Backend | undefined; // toggleShowDimensions: () => void; toggleCollapsed: () => void; @@ -68,14 +68,12 @@ function createStore({ locales, game }: { locales: Locale[]; game: Game_ }) { layout: "row", visibleUserId: "all", showDimensions: false, - locale: locales[0], + locale: locales[0] || "en", locales, view: "game", match: undefined, game, // - meta: undefined, - // resetMatch: () => { // This is set in App.tsx }, diff --git a/packages/game/package.json b/packages/game/package.json index 7cc7a1e..6a384f1 100644 --- a/packages/game/package.json +++ b/packages/game/package.json @@ -31,13 +31,14 @@ "rollup-plugin-typescript2": "^0.31.2", "tslib": "^2.6.3", "typescript": "^5.5.4", - "vitest": "^2.0.5" + "vitest": "^2.0.5", + "immer": "^10.1.1" }, "peerDependencies": { - "@lefun/core": "workspace:*" + "@lefun/core": "workspace:*", + "immer": "^10.1.1" }, "dependencies": { - "immer": "^10.1.1", "lodash-es": "^4.17.21" } } diff --git a/packages/game/rollup.config.js b/packages/game/rollup.config.js index ae8092e..b354cba 100644 --- a/packages/game/rollup.config.js +++ b/packages/game/rollup.config.js @@ -17,5 +17,5 @@ export default { typescript({ useTsconfigDeclarationDir: true }), ], - external: ["@lefun/core"], + external: ["@lefun/core", "immer"], }; diff --git a/packages/game/src/execution.ts b/packages/game/src/execution.ts index 22615e5..6dd2522 100644 --- a/packages/game/src/execution.ts +++ b/packages/game/src/execution.ts @@ -2,7 +2,7 @@ import { enablePatches, Patch, produceWithPatches, setAutoFreeze } from "immer"; -import type { Meta, UserId } from "@lefun/core"; +import { Meta, metaBeginTurn, metaEndTurn, UserId } from "@lefun/core"; import { BoardExecute, @@ -14,7 +14,7 @@ import { Turns, } from "./gameDef"; import { Random } from "./random"; -import { parseMove, parseTurnUserIds } from "./utils"; +import { parseMove } from "./utils"; enablePatches(); setAutoFreeze(false); @@ -82,9 +82,9 @@ function tryProduceWithPatches( } export type MoveExecutionOutput = { - board: unknown; - playerboards: Record; - secretboard: unknown; + board: object; + playerboards: Record; + secretboard: object | null; patches: Patch[]; } & SideEffectResults; @@ -144,7 +144,7 @@ export function executePlayerMove({ !canDo({ userId, board, - playerboard: userId ? playerboards[userId] : undefined!, + playerboard: playerboards[userId]!, payload, ts: now, }) @@ -172,7 +172,7 @@ export function executePlayerMove({ ({ board, playerboards }) => { executeNow({ board, - playerboard: playerboards[userId], + playerboard: playerboards[userId]!, userId, payload, _: moveSideEffects, @@ -226,14 +226,24 @@ export function executePlayerMove({ }; } +type BeginTurn = Record; +type EndTurn = Record; + type SideEffectResults = { matchHasEnded: boolean; - beginTurnUsers: Set; - endTurnUsers: Set; + beginTurn: BeginTurn; + endTurn: EndTurn; delayedMoves: DelayedMove[]; stats: Stat[]; }; +const ensureArray = (x: T | T[]): T[] => { + if (!Array.isArray(x)) { + x = [x]; + } + return x; +}; + function defineMoveSideEffects({ game, meta, @@ -245,8 +255,8 @@ function defineMoveSideEffects({ }): { sideEffectResults: SideEffectResults; moveSideEffects: MoveSideEffects } { const sideEffectResults: SideEffectResults = { matchHasEnded: false, - beginTurnUsers: new Set(), - endTurnUsers: new Set(), + beginTurn: {}, + endTurn: {}, delayedMoves: [], stats: [], }; @@ -260,13 +270,8 @@ function defineMoveSideEffects({ { expiresIn, playerMoveOnExpire, boardMoveOnExpire } = {}, ) => { let expiresAt: number | undefined = undefined; - userIds = parseTurnUserIds(userIds, { - allUserIds: meta.players.allIds, - }); - for (const userId of userIds) { - sideEffectResults.beginTurnUsers.add(userId); - sideEffectResults.endTurnUsers.delete(userId); - } + + userIds = ensureArray(userIds); if (expiresIn !== undefined) { const ts = now + expiresIn; @@ -305,16 +310,20 @@ function defineMoveSideEffects({ } expiresAt = ts; } + + for (const userId of userIds) { + sideEffectResults.beginTurn[userId] = { expiresAt }; + delete sideEffectResults.endTurn[userId]; + } + return { expiresAt }; }; const turnsEnd: Turns["end"] = (userIds) => { - userIds = parseTurnUserIds(userIds, { - allUserIds: meta.players.allIds, - }); + userIds = ensureArray(userIds); for (const userId of userIds) { - sideEffectResults.endTurnUsers.add(userId); - sideEffectResults.beginTurnUsers.delete(userId); + sideEffectResults.endTurn[userId] = null; + delete sideEffectResults.beginTurn[userId]; } }; @@ -354,7 +363,9 @@ function defineMoveSideEffects({ delayMove, turns: { begin: turnsBegin, + beginAll: (options) => turnsBegin(meta.players.allIds, options), end: turnsEnd, + endAll: () => turnsEnd(meta.players.allIds), }, endMatch, logPlayerStat, @@ -445,3 +456,41 @@ export function executeBoardMove({ ...sideEffectResults, }; } + +export function updateMetaWithTurnInfo({ + meta, + beginTurn, + endTurn, + now, +}: { + meta: Meta; + beginTurn: BeginTurn; + endTurn: EndTurn; + now: number; +}): { meta: Meta; patches: Patch[] } { + const [newMeta, metaPatches] = produceWithPatches<{ meta: Meta }>( + // Wrap `meta` in an object to to be able to merge with the board patches. + { meta }, + (draft: { meta: Meta }) => { + const { meta } = draft; + + for (const [userId, { expiresAt }] of Object.entries(beginTurn)) { + metaBeginTurn({ + meta, + beginsAt: now, + userId, + expiresAt, + }); + } + + for (const userId of Object.keys(endTurn)) { + metaEndTurn({ + meta, + userId, + }); + } + }, + ); + + return { meta: newMeta.meta, patches: metaPatches }; +} diff --git a/packages/game/src/gameDef.ts b/packages/game/src/gameDef.ts index 5cbc882..39134a3 100644 --- a/packages/game/src/gameDef.ts +++ b/packages/game/src/gameDef.ts @@ -8,8 +8,8 @@ import { GameSettings_, GameStat, GameStats_, + If_ObjectOrNull_Null, IfAny, - IfAnyNull, IfNull, Locale, MatchPlayerSettings, @@ -20,7 +20,11 @@ import { import { Random } from "./random"; -export type GameStateBase = { +export type GameStateBase< + B extends object = object, + PB extends object | null = object | null, + SB extends object | null = object | null, +> = { B: B; PB: PB; SB: SB; @@ -28,7 +32,13 @@ export type GameStateBase = { export type MoveTypesBase = Record; -export type GameState = GameStateBase; +export type GameState< + B extends object = object, + PB extends object | null = null, + SB extends object | null = null, +> = GameStateBase; + +export type GameStateAny = GameStateBase; export type GameStats = GameStat[]; @@ -67,27 +77,36 @@ export type DelayMove = < type ValueOf = T[keyof T]; +type BeginTurnOptions< + PMT extends MoveTypesBase = MoveTypesBase, + BMT extends MoveTypesBase = MoveTypesBase, +> = { + expiresIn?: number; + boardMoveOnExpire?: IfAny< + ValueOf, + string | [string, any], + MoveObjFromMT + >; + playerMoveOnExpire?: IfAny< + ValueOf, + string | [string, any], + MoveObjFromMT + >; +}; + export type Turns< PMT extends MoveTypesBase = MoveTypesBase, BMT extends MoveTypesBase = MoveTypesBase, > = { - end: (userIds: UserId | UserId[] | "all") => void; + end: (userIds: UserId | UserId[]) => void; + endAll: () => void; begin: ( - userIds: UserId | UserId[] | "all", - options?: { - expiresIn?: number; - boardMoveOnExpire?: IfAny< - ValueOf, - string | [string, any], - MoveObjFromMT - >; - playerMoveOnExpire?: IfAny< - ValueOf, - string | [string, any], - MoveObjFromMT - >; - }, + userIds: UserId | UserId[], + options?: BeginTurnOptions, ) => { expiresAt: number | undefined }; + beginAll: (options?: BeginTurnOptions) => { + expiresAt: number | undefined; + }; }; type StatsKeys = S extends { key: infer K }[] ? K : never; @@ -284,7 +303,7 @@ export type AutoMoveInfo = { }; export type GetPayload< - G extends Game, + G extends Game, K extends keyof G["playerMoves"] & string, > = IfAny< ValueOf, @@ -368,22 +387,27 @@ export type GetMatchScoreTextOptions = { export type InitialBoardsOutput = { board: GS["B"]; -} & IfAnyNull< +} & If_ObjectOrNull_Null< GS["PB"], + // object | null: we don't know { playerboards?: Record }, - unknown, + // null: `object` because we are doing the intersection of types here. + object, + // other we know it's defined we need the playerboards { playerboards: Record } > & - IfAnyNull< + // Same logic as with G["PB"] above + If_ObjectOrNull_Null< GS["SB"], { secretboard?: GS["SB"] }, - unknown, + object, { secretboard: GS["SB"] } >; export type InitialBoard = ( options: InitialBoardsOptions, ) => InitialBoardsOutput; + // This is what the game developer must implement. export type Game< GS extends GameStateBase = GameStateBase, @@ -471,7 +495,11 @@ function normalizeStats(stats: GameStats | undefined): GameStats_ { // Remove redondant keys. byId: Object.fromEntries( allIds.map((key) => { - const { type, ordering = "higherIsBetter" } = byId[key]; + const value = byId[key]; + if (!value) { + throw new Error("value should be defined"); + } + const { type, ordering = "higherIsBetter" } = value; return [key, { key, type, ordering }]; }), ), @@ -499,6 +527,9 @@ function normalizeSettings(settings: GameSettings | GamePlayerSettings) { // Find the default values. for (const key of newSettings.allIds) { const gameSetting = newSettings.byId[key]; + if (gameSetting === undefined) { + throw new Error("game setting should be defined"); + } let defaultValue: string | null = null; let numDefault = 0; @@ -515,7 +546,7 @@ function normalizeSettings(settings: GameSettings | GamePlayerSettings) { } (gameSetting as GameSetting_).defaultValue = - defaultValue ?? gameSetting.options[0].value; + defaultValue ?? gameSetting.options[0]?.value ?? ""; } return newSettings; diff --git a/packages/game/src/random.test.ts b/packages/game/src/random.test.ts index 7115c3c..4292dc0 100644 --- a/packages/game/src/random.test.ts +++ b/packages/game/src/random.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "vitest"; -import { Random } from "."; +import { Random, RandomMock } from "."; describe("Random", () => { const random = new Random(); @@ -14,7 +14,7 @@ describe("Random", () => { }); test("bernoulli n", () => { - const n = random.bernoulli(0.3, 1000); + const n = random.bernoulli(0.3, { size: 1000 }); // Count the number of `true`. It should be around 300. expect(n.filter((x) => x).length).toBeGreaterThan(200); expect(n.filter((x) => x).length).toBeLessThan(400); @@ -25,7 +25,7 @@ describe("Random", () => { }); test("dice with n", () => { - const value = random.dice(6, 3); + const value = random.dice(6, { size: 3 }); expect(Array.isArray(value)).toBe(true); expect(value.length).toBe(3); }); @@ -35,8 +35,60 @@ describe("Random", () => { }); test("d6 no args", () => { - const value = random.d6(2); + const value = random.d6({ size: 2 }); expect(Array.isArray(value)).toBe(true); expect(value.length).toBe(2); }); + + describe("randomInt", () => { + test("randomIn(min)", () => { + const values = random.randomInt(3, { size: 100 }); + expect(values.length).toBe(100); + for (const value of values) { + expect(value).toBeGreaterThanOrEqual(0); + expect(value).toBeLessThan(3); + } + }); + test("randomIn(min, max)", () => { + const values = random.randomInt(2, 4, { size: 100 }); + expect(values.length).toBe(100); + for (const value of values) { + expect(value).toBeGreaterThanOrEqual(2); + expect(value).toBeLessThan(4); + } + }); + }); +}); + +describe("RandomMock", () => { + test("fallback on Random", () => { + const random = new RandomMock(); + const v1 = random.randomInt(100000); + const v2 = random.randomInt(100000); + expect(v1).not.toEqual(v2); + }); + + test("setNextValues happy path", () => { + const random = new RandomMock(); + random.setNextValues([42, 43, [1, 2]]); + const v1 = random.randomInt(100000); + const v2 = random.randomInt(100000); + const v3 = random.shuffled([1, 2, 3]); + expect(v1).toBe(42); + expect(v2).toBe(43); + expect(v3).toEqual([1, 2]); + }); + + test("overriden methods by children classes ignore `setNextValues`", () => { + class MyRandom extends RandomMock { + shuffled(array: readonly T[]): T[] { + return [...array]; + } + } + + const random = new MyRandom(); + random.setNextValues([0, 0]); + const myArray = [1, 2, 3, 4, 5, 6, 7]; + expect(random.shuffled(myArray)).toEqual(myArray); + }); }); diff --git a/packages/game/src/random.ts b/packages/game/src/random.ts index 9a5ea9c..74641cf 100644 --- a/packages/game/src/random.ts +++ b/packages/game/src/random.ts @@ -1,71 +1,113 @@ import { sample, sampleSize, shuffle } from "lodash-es"; export class Random { - shuffled(array: T[]): T[] { + shuffled(array: readonly T[]): T[] { // Use loadash for this one. return shuffle(array); } - sample(array: T[]): T; - sample(array: T[], n: number): T[]; - sample(array: T[], n?: number) { + sample(array: readonly T[]): T; + sample(array: readonly T[], { size }: { size: number }): T[]; + sample(array: readonly T[], { size }: { size?: number } = {}): T[] | T { if (array.length === 0) { throw new Error("can not sample from empty array"); } - if (n == null) { + if (size === undefined) { const item = sample(array); if (item === undefined) { throw new Error("lodash sample returned undefined"); } return item; } - return sampleSize(array, n); + return sampleSize(array, size); } - dice(faces: number): number; - dice(faces: number, n: number): number[]; - dice(faces: number, n?: number) { - const onlyOne = typeof n === "undefined"; - const dice = Array(onlyOne ? 1 : n) - .fill(undefined) - .map(() => 1 + Math.floor(Math.random() * faces)); - - if (onlyOne) { - return dice[0]; + dice(n: number): number; + dice(n: number, arg1: { size: number }): number[]; + dice(n: number, { size }: { size?: number } = {}): number | number[] { + if (size === undefined) { + return this.randomInt(1, n + 1); } - return dice; + return this.randomInt(1, n + 1, { size }); } d6(): number; - d6(n: number): number[]; - d6(n?: number): number | number[] { - return n === undefined ? this.dice(6) : this.dice(6, n); + d6(arg0: { size: number }): number[]; + d6({ size }: { size?: number } = {}): number | number[] { + if (size === undefined) { + return this.dice(6); + } + return this.dice(6, { size }); } - d2(): number; - d2(n: number): number[]; - d2(n?: number): number | number[] { - return n === undefined ? this.dice(2) : this.dice(2, n); - } + randomInt(max: number): number; + randomInt(min: number, max: number): number; + randomInt(min: number, max: number, { size }: { size: number }): number[]; + randomInt(max: number, { size }: { size: number }): number[]; + randomInt( + arg0: number, + arg1?: number | { size: number }, + arg2?: { size: number }, + ): number | number[] { + let min: number; + let max: number; + + if (typeof arg1 !== "number") { + min = 0; + max = arg0; + } else { + min = arg0; + max = arg1; + } - bernoulli(p?: number): boolean; - bernoulli(p: number, n: number): boolean[]; - bernoulli(p = 0.5, n?: number): boolean | boolean[] { - const onlyOne = n === undefined; - const results = Array(onlyOne ? 1 : n) + let size = 1; + let many = false; + if (typeof arg1 === "object" && typeof arg1.size === "number") { + size = arg1.size; + many = true; + } else if (typeof arg2 === "object" && typeof arg2.size === "number") { + size = arg2.size; + many = true; + } + + const values = Array(size) .fill(undefined) - .map(() => Math.random() < p); + .map(() => Math.floor(Math.random() * (max - min)) + min); - if (onlyOne) { - return results[0]; + if (!many) { + const first = values[0]; + if (first === undefined) { + throw new Error("randomInt returned undefined"); + } + return first; } - return results; + + return values; } - coin(): boolean; - coin(n: number): boolean[]; - coin(n?: number): boolean | boolean[] { - return n === undefined ? this.bernoulli() : this.bernoulli(0.5, n); + uniform(): number; + uniform(arg0: { size: number }): number[]; + uniform({ size }: { size?: number } = {}): number | number[] { + if (size === undefined) { + return Math.random(); + } + return Array(size) + .fill(undefined) + .map(() => Math.random()); + } + + bernoulli(p?: number): boolean; + bernoulli(p: number, arg1: { size: number }): boolean[]; + bernoulli( + p: number = 0.5, + { size }: { size?: number } = {}, + ): boolean | boolean[] { + if (size === undefined) { + return this.uniform() < p; + } + return Array(size) + .fill(undefined) + .map(() => this.uniform() < p); } } @@ -73,58 +115,42 @@ export class Random { * Testing utils */ -// Same interface as Random but you can decide what it's going to return. -// TODO Find a cleaner way to write this. There is *a lot* of copy pasting happening. export class RandomMock extends Random { nextValues: any[]; nextIndex: number; - // We'll default on that `Random` when no values are set. constructor() { super(); this.nextValues = []; this.nextIndex = 0; - } - shuffled(array: any[]): any[] { - if (this.nextIndex < this.nextValues.length) { - return this.nextValues[this.nextIndex++]; + for (const key of Object.getOwnPropertyNames(Random.prototype)) { + const proto = Object.getPrototypeOf(this); + // Ignore properties potentially overriden by subclasses. + if (Object.prototype.hasOwnProperty.call(proto, key)) { + continue; + } + const original = (this as any)[key]; + if (typeof original === "function" && key !== "constructor") { + (this as any)[key] = (...args: any[]) => { + if (this.nextValues.length) { + return this.nextValues.shift(); + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + return original.apply(this, args); + }; + } } - return Random.prototype.shuffled.call(this, array); } + // Backward compatibility next(value: any): void { - this.nextValues.push(value); + this.setNextValues([value]); } - d6(): number; - d6(n: number): number[]; - d6(n?: number): number | number[] { - if (this.nextIndex < this.nextValues.length) { - return this.nextValues[this.nextIndex++]; - } - return n === undefined ? super.d6() : super.d6(n); - } - - sample(array: T[]): T; - sample(array: T[], n: number): T[]; - sample(array: T[], n?: number) { - if (n !== undefined) { - throw new Error("not implemented yet"); - } - if (this.nextIndex < this.nextValues.length) { - return this.nextValues[this.nextIndex++]; - } - return n === undefined ? super.sample(array) : super.sample(array, n); - } - - bernoulli(p?: number): boolean; - bernoulli(p: number, n: number): boolean[]; - bernoulli(p = 0.5, n?: number): boolean | boolean[] { - if (this.nextIndex < this.nextValues.length) { - return this.nextValues[this.nextIndex++]; - } - return n === undefined ? super.bernoulli(p) : super.bernoulli(p, n); + setNextValues(values: readonly any[]) { + this.nextValues = [...this.nextValues, ...values]; } } diff --git a/packages/game/src/testing.test.ts b/packages/game/src/testing.test.ts index 1825441..9250abf 100644 --- a/packages/game/src/testing.test.ts +++ b/packages/game/src/testing.test.ts @@ -81,7 +81,7 @@ describe("turns", () => { test("playerMoveOnExpire", () => { const match = new MatchTester({ game, numPlayers: 1 }); - const userId = match.meta.players.allIds[0]; + const userId = match.meta.players.allIds[0]!; expect(match.board.expiresAt).toBe(undefined); // I make a move @@ -139,7 +139,7 @@ describe("turns", () => { test("executes moves with delay 0 right away", () => { const match = new MatchTester({ game, numPlayers: 1 }); - const userId = match.meta.players.allIds[0]; + const userId = match.meta.players.allIds[0]!; match.makeMove(userId, "beginWithDelay", { delay: 0 }); expect(match.board.numZeroDelay).toBe(10); expect(match.matchHasEnded).toBe(true); @@ -147,7 +147,7 @@ describe("turns", () => { test("executes moves with delay 1", () => { const match = new MatchTester({ game, numPlayers: 1 }); - const userId = match.meta.players.allIds[0]; + const userId = match.meta.players.allIds[0]!; match.makeMove(userId, "beginWithDelay", { delay: 1 }); match.fastForward(1); expect(match.board.numZeroDelay).toBe(10); @@ -157,7 +157,7 @@ describe("turns", () => { test("expiresAt", () => { const match = new MatchTester({ game, numPlayers: 1 }); - const userId = match.meta.players.allIds[0]; + const userId = match.meta.players.allIds[0]!; expect(match.board.expiresAt).toBe(undefined); match.makeMove(userId, "go", {}); @@ -190,7 +190,7 @@ describe("turns", () => { } satisfies Game; const match = new MatchTester({ game, numPlayers: 1 }); - const userId = match.meta.players.allIds[0]; + const userId = match.meta.players.allIds[0]!; match.makeMove(userId, "begin", { onExpire: "a" }); expect(match.board.x).toBe(""); match.fastForward(10); @@ -337,7 +337,7 @@ test("error in move does not get applied", () => { const match = new MatchTester({ game, numPlayers: 1 }); - const userId = match.meta.players.allIds[0]; + const userId = match.meta.players.allIds[0]!; expect(() => { match.makeMove(userId, "incrementWithError"); @@ -366,7 +366,7 @@ test("canFail", () => { const match = new MatchTester({ game, numPlayers: 1 }); - const userId = match.meta.players.allIds[0]; + const userId = match.meta.players.allIds[0]!; match.makeMove(userId, "go", null, { canFail: true }); @@ -392,8 +392,56 @@ test("end match ends turns", () => { } satisfies Game; const match = new MatchTester({ game, numPlayers: 1 }); - const userId = match.meta.players.allIds[0]; + const userId = match.meta.players.allIds[0]!; match.makeMove(userId, "go"); expect(match.matchHasEnded).toBe(true); - expect(match.meta.players.byId[userId].itsYourTurn).toBe(false); + expect(match.meta.players.byId[userId]?.itsYourTurn).toBe(false); +}); + +test("match with timeouts for all moves ends at some point", () => { + type B = { + firstPlayer: string; + n: number; + }; + + type GS = GameState; + + const game = { + ...gameBase, + initialBoards: ({ players }) => ({ + board: { n: 0, firstPlayer: players[0]! }, + }), + playerMoves: { + inc: { + execute({ board, userId, _ }) { + _.turns.begin(userId, { expiresIn: 1, playerMoveOnExpire: "inc" }); + if (board.n >= 100) { + _.endMatch(); + } + board.n += 1; + }, + }, + }, + boardMoves: { + [INIT_MOVE]: { + execute({ board, turns }) { + turns.begin(board.firstPlayer, { + expiresIn: 1, + playerMoveOnExpire: "inc", + }); + }, + }, + }, + } satisfies Game; + + type G = typeof game; + + const match = new MatchTester({ + game, + numPlayers: 1, + }); + + expect(match.matchHasEnded).toBe(false); + match.fastForward(9999999999); + expect(match.matchHasEnded).toBe(true); }); diff --git a/packages/game/src/testing.ts b/packages/game/src/testing.ts index f66c447..b79ac4f 100644 --- a/packages/game/src/testing.ts +++ b/packages/game/src/testing.ts @@ -1,9 +1,12 @@ import { Locale, MatchPlayerSettings, + MatchPlayersSettings, MatchSettings, Meta, metaAddUserToMatch, + metaBeginTurn, + metaEndTurn, metaInitialState, metaRemoveUserFromMatch, UserId, @@ -48,10 +51,10 @@ export type MatchTesterOptions> = { matchSettings?: MatchSettings; matchPlayersSettingsArr?: MatchPlayerSettings[]; random?: Random; - verbose?: boolean; training?: boolean; logBoardToTrainingLog?: boolean; locale?: Locale; + logger?: Logger; }; export type BotTrainingLogItem = @@ -65,14 +68,6 @@ export type BotTrainingLogItem = type EmptyObject = Record; -type User = { - username: string; - isBot: boolean; - isGuest: boolean; -}; - -type UsersState = { byId: Record }; - type MakeMoveOptions = { canFail?: boolean; isDelayed?: boolean }; type MakeMoveRest< @@ -88,6 +83,13 @@ type MakeMoveRest< [GetPayload] | [GetPayload, MakeMoveOptions] >; +export interface Logger { + debug?(obj: unknown, msg?: string, ...args: unknown[]): void; + info?(obj: unknown, msg?: string, ...args: unknown[]): void; + warn?(obj: unknown, msg?: string, ...args: unknown[]): void; + error?(obj: unknown, msg?: string, ...args: unknown[]): void; +} + /* * Use this to test your game rules. * It emulates what the backend does. @@ -102,11 +104,10 @@ export class MatchTester> { board: GS["B"]; playerboards: Record; secretboard: GS["SB"]; - users: UsersState; matchHasEnded: boolean; random: Random; - matchSettings; - matchPlayersSettings; + matchSettings: MatchSettings; + matchPlayersSettings: MatchPlayersSettings; // Clock used for delayedMoves - in ms. time: number; // List of timers to be executed. @@ -118,7 +119,6 @@ export class MatchTester> { _sameBotCount: number; _lastBotUserId?: UserId; _botTrainingLog: BotTrainingLogItem[]; - _verbose: boolean; _logBoardToTrainingLog: boolean; // Are we using the MatchTester for training. // TODO We should probably use different classes for training and for testing. @@ -129,6 +129,8 @@ export class MatchTester> { playerStats: Record; matchStats: { key: string; value: number }[]; + logger?: Logger; + constructor({ game, autoMove, @@ -140,10 +142,10 @@ export class MatchTester> { matchSettings = {}, matchPlayersSettingsArr, random, - verbose = false, training = false, logBoardToTrainingLog = false, locale = "en", + logger = undefined, }: MatchTesterOptions) { if (random == null) { random = new Random(); @@ -190,7 +192,7 @@ export class MatchTester> { } // If there was no option marked as `default`, we use the first one. if (!thereWasADefault) { - matchSettings[key] = options[0].value; + matchSettings[key] = options[0]!.value; } } } @@ -204,14 +206,17 @@ export class MatchTester> { const opts: MatchPlayerSettings = {}; gamePlayerSettings.allIds.forEach((key) => { const optionDef = gamePlayerSettings.byId[key]; + if (!optionDef) { + throw new Error("option def is falsy"); + } if (optionDef.exclusive) { // For each user, given them the n-th option. Loop if there are less options // than players. opts[key] = - optionDef.options[nthUser % optionDef.options.length].value; + optionDef.options[nthUser % optionDef.options.length]!.value; } else { // TODO We probably need a default option here! - opts[key] = optionDef.options[0].value; + opts[key] = optionDef.options[0]!.value; } }); matchPlayersSettingsArr.push(opts); @@ -221,14 +226,14 @@ export class MatchTester> { const matchPlayersSettings = Object.fromEntries( meta.players.allIds.map((userId, i) => [ userId, - matchPlayersSettingsArr ? matchPlayersSettingsArr[i] : {}, + matchPlayersSettingsArr ? matchPlayersSettingsArr[i]! : {}, ]), ); const areBots = Object.fromEntries( meta.players.allIds.map((userId) => [ userId, - meta.players.byId[userId].isBot, + meta.players.byId[userId]!.isBot, ]), ); @@ -253,15 +258,6 @@ export class MatchTester> { secretboard = {}, } = init as InitialBoardsOutput; - const users: UsersState = { byId: {} }; - meta.players.allIds.forEach((userId) => { - users.byId[userId] = { - username: `User ${userId}`, - isGuest: false, - isBot: false, - }; - }); - this.autoMove = autoMove; this.getAgent = getAgent; this.gameData = gameData; @@ -274,13 +270,11 @@ export class MatchTester> { this.meta = meta; this.time = time; this.delayedMoves = []; - this.users = users; this.matchSettings = matchSettings; this.matchPlayersSettings = matchPlayersSettings; this._sameBotCount = 0; this._botTrainingLog = []; - this._verbose = verbose; this._training = training; this._logBoardToTrainingLog = logBoardToTrainingLog; this._agents = {}; @@ -290,6 +284,8 @@ export class MatchTester> { this.playerStats = {}; this.matchStats = []; + this.logger = logger; + // Make the special initial move. if (game.boardMoves?.[INIT_MOVE]) { this._makeBoardMove(INIT_MOVE); @@ -312,9 +308,8 @@ export class MatchTester> { random, } = this; const userId = `userId-${this.nextUserId}`; - const username = `Player ${this.nextUserId}`; const isBot = false; - const isGuest = false; + this.nextUserId++; metaAddUserToMatch({ meta, userId, ts: new Date(), isBot }); @@ -333,12 +328,6 @@ export class MatchTester> { // Trigger the game's logic. this._makeBoardMove(ADD_PLAYER, { userId }); - this.users.byId[userId] = { - username, - isBot, - isGuest, - }; - return userId; } @@ -375,7 +364,7 @@ export class MatchTester> { // It's no-one's turn anymore. this.meta.players.allIds.forEach((userId) => { - this.meta.players.byId[userId].itsYourTurn = false; + this.meta.players.byId[userId]!.itsYourTurn = false; }); this._isPlaying = false; } @@ -391,19 +380,27 @@ export class MatchTester> { _doMoveSideEffects({ matchHasEnded, - beginTurnUsers, - endTurnUsers, + beginTurn, + endTurn, delayedMoves, stats, }: { matchHasEnded: boolean; - beginTurnUsers: Set; - endTurnUsers: Set; + beginTurn: Record; + endTurn: Record; delayedMoves: DelayedMove[]; stats: Stat[]; }) { - for (const userId of beginTurnUsers) { - this.meta.players.byId[userId].itsYourTurn = true; + const { meta, time } = this; + + for (const [userId, { expiresAt }] of Object.entries(beginTurn)) { + metaBeginTurn({ + meta, + userId, + beginsAt: time, + expiresAt, + }); + // Clear previous turn player moves for that player: in other words only the // lastest `turns.begin` counts for a given player. this.delayedMoves = this.delayedMoves.filter( @@ -411,8 +408,8 @@ export class MatchTester> { ); } - for (const userId of endTurnUsers) { - this.meta.players.byId[userId].itsYourTurn = false; + for (const userId of Object.keys(endTurn)) { + metaEndTurn({ meta, userId }); // Clear previous turn player moves for that player. this.delayedMoves = this.delayedMoves.filter( ({ userId: otherUserId }) => otherUserId !== userId, @@ -469,19 +466,13 @@ export class MatchTester> { }); { - const { - matchHasEnded, - delayedMoves, - beginTurnUsers, - endTurnUsers, - stats, - } = output; + const { matchHasEnded, delayedMoves, beginTurn, endTurn, stats } = output; this._doMoveSideEffects({ matchHasEnded, delayedMoves, - beginTurnUsers, - endTurnUsers, + beginTurn, + endTurn, stats, }); } @@ -509,6 +500,8 @@ export class MatchTester> { ? [rest[0], {}] : rest; + this.logger?.info?.({ userId, moveName, payload }, "makeMove"); + const { board, playerboards, @@ -543,7 +536,7 @@ export class MatchTester> { } catch (e) { error = true; if (canFail) { - console.warn("Error but canFail=true"); + this.logger?.warn?.("Error but canFail=true"); console.warn(e); } else { throw e; @@ -551,19 +544,13 @@ export class MatchTester> { } if (!error && output) { - const { - matchHasEnded, - delayedMoves, - beginTurnUsers, - endTurnUsers, - stats, - } = output; + const { matchHasEnded, delayedMoves, beginTurn, endTurn, stats } = output; this._doMoveSideEffects({ matchHasEnded, delayedMoves, - beginTurnUsers, - endTurnUsers, + beginTurn, + endTurn, stats, }); @@ -593,9 +580,9 @@ export class MatchTester> { // Initialize agents. if (getAgent) { for (const userId of meta.players.allIds) { - if (meta.players.byId[userId].isBot) { + if (meta.players.byId[userId]!.isBot) { _agents[userId] = await getAgent({ - matchPlayerSettings: matchPlayersSettings[userId], + matchPlayerSettings: matchPlayersSettings[userId]!, matchSettings, numPlayers, }); @@ -624,19 +611,15 @@ export class MatchTester> { userIndex < meta.players.allIds.length; ++userIndex ) { - const userId = meta.players.allIds[userIndex]; - const { isBot, itsYourTurn } = meta.players.byId[userId]; + const userId = meta.players.allIds[userIndex]!; + const { isBot, itsYourTurn } = meta.players.byId[userId]!; if (isBot && itsYourTurn) { let boardRepr: string | undefined = undefined; - if ((this._verbose || this._logBoardToTrainingLog) && game.logBoard) { + if (this._logBoardToTrainingLog && game.logBoard) { boardRepr = game.logBoard({ board, playerboards }); - if (this._verbose) { - console.log("----------------------"); - console.log(userId, "'s turn"); - console.log(boardRepr); - } + this.logger?.debug?.({ userId, board: boardRepr }, "turn"); } const t0 = new Date().getTime(); @@ -646,7 +629,7 @@ export class MatchTester> { const args = { board, - playerboard: playerboards[userId], + playerboard: playerboards[userId]!, secretboard, userId, random, @@ -713,33 +696,24 @@ export class MatchTester> { this._sameBotCount = 0; } - // State as `client` or `@lefun/ui-testing` expect it. - getState(userId: UserId) { - const { board, playerboards, users } = this; - return { - board, - userId, - playerboard: playerboards[userId], - users, - }; - } - /* * With this function we simulate passing time and execute delayedUpdates * * delta: time that passed in milli-seconds */ fastForward(delta: number): void { + this.logger?.debug?.({ delta }, "fastForward"); const { delayedMoves } = this; if (delayedMoves.length === 0) { this.time += delta; + this.logger?.debug?.({ time: this.time }, "fastForward done"); return; } // Fast forward in increments, according to the delay moves that we have in store. // Otherwise they might happen at the wrong timestamp. - const nextDelayedMove = this.delayedMoves[0]; + const nextDelayedMove = this.delayedMoves[0]!; const { ts } = nextDelayedMove; const timeToNextDelayedMove = ts - this.time; @@ -767,19 +741,45 @@ export class MatchTester> { } } - // Keep fast-forwarding + // Keep fast-forwarding, unless the match has ended. const newDelta = delta - timeToNextDelayedMove; - if (newDelta > 0) { + if (newDelta > 0 && !this.matchHasEnded) { this.fastForward(delta - timeToNextDelayedMove); } } else { this.time += delta; } + + this.logger?.debug?.( + { time: this.time, numDelayedMovesLeft: this.delayedMoves.length }, + "fastForward done", + ); } get botTrainingLog() { return this._botTrainingLog; } + + /* Get the list of players in the match. */ + players(): UserId[] { + return this.meta.players.allIds.slice(); + } + + /* Get the set of players whose turn it is. */ + playersInTurn(): Set { + const result = new Set(); + for (const userId of this.meta.players.allIds) { + if (this.meta.players.byId[userId]?.itsYourTurn) { + result.add(userId); + } + } + return result; + } + + /* Is it the given user's turn? */ + hasTurn(userId: UserId): boolean { + return !!this.meta.players.byId[userId]?.itsYourTurn; + } } function sortDelayedMoves(delayedMoves: DelayedMove[]): void { diff --git a/packages/game/src/utils.ts b/packages/game/src/utils.ts index 3f1b12b..576c1f9 100644 --- a/packages/game/src/utils.ts +++ b/packages/game/src/utils.ts @@ -1,18 +1,3 @@ -import type { UserId } from "@lefun/core"; - -export const parseTurnUserIds = ( - userIds: UserId | UserId[] | "all", - { allUserIds }: { allUserIds: UserId[] }, -): UserId[] => { - if (userIds === "all") { - userIds = allUserIds; - } - if (!Array.isArray(userIds)) { - userIds = [userIds]; - } - return userIds; -}; - /* * 'move' | ['move', payload] => {name: 'move', payload} */ diff --git a/packages/ui-testing/package.json b/packages/ui-testing/package.json index 19f78f3..b182159 100644 --- a/packages/ui-testing/package.json +++ b/packages/ui-testing/package.json @@ -26,6 +26,8 @@ "zustand": "^4.5.4" }, "devDependencies": { + "@lefun/core": "workspace:*", + "@lefun/game": "workspace:*", "@lefun/ui": "workspace:*", "@lingui/core": "^4.11.2", "@lingui/react": "^4.11.2", @@ -39,6 +41,8 @@ "typescript": "^5.5.4" }, "peerDependencies": { + "@lefun/core": "workspace:*", + "@lefun/game": "workspace:*", "@lefun/ui": "workspace:*", "@lingui/core": "^4.7.1", "@lingui/react": "^4.7.1", diff --git a/packages/ui-testing/src/index.tsx b/packages/ui-testing/src/index.tsx index 945eeba..7c56f5d 100644 --- a/packages/ui-testing/src/index.tsx +++ b/packages/ui-testing/src/index.tsx @@ -3,17 +3,45 @@ import { I18nProvider } from "@lingui/react"; import { render as rtlRender, RenderResult } from "@testing-library/react"; import { ElementType, ReactNode } from "react"; +import { UserId } from "@lefun/core"; +import { GameStateBase, MatchTester } from "@lefun/game"; import { - MatchState, setMakeMove, setUseSelector, setUseSelectorShallow, setUseStore, + UIState, + Users, } from "@lefun/ui"; +export function getUIStateFromMatchTester({ + matchTester, + userId, +}: { + matchTester: MatchTester; + userId: UserId; +}): UIState { + const { board, playerboards, meta } = matchTester; + const users: Users = {}; + + for (const userId of meta.players.allIds) { + users[userId] = { username: `User ${userId}` }; + } + + return { + userId, + board, + playerboard: playerboards[userId], + meta, + users, + timeLatency: 0, + timeDelta: 0, + }; +} + export function render( Board: ElementType, - state: MatchState, + state: UIState, locale: string = "en", ): RenderResult { const userId = state.userId; diff --git a/packages/ui/src/index.test-d.ts b/packages/ui/src/index.test-d.ts index c321f84..d660a8e 100644 --- a/packages/ui/src/index.test-d.ts +++ b/packages/ui/src/index.test-d.ts @@ -5,6 +5,8 @@ import type { BoardMove, Game, GameState, PlayerMove } from "@lefun/game"; import { makeUseMakeMove, useMakeMove } from "./index"; test("makeMove - payload-typed", () => { + type GS = GameState<{ x: number }>; + const move1: PlayerMove = { executeNow: ({ payload }) => { console.log(payload); @@ -17,8 +19,6 @@ test("makeMove - payload-typed", () => { }, }; - type GS = GameState<{ x: number }>; - // eslint-disable-next-line @typescript-eslint/no-unused-vars const game = { initialBoards: () => ({ board: { x: 2 } }), diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index c6a6492..637d8ab 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -1,23 +1,14 @@ import { useMemo } from "react"; -import type { IfAnyNull, MatchState as _MatchState, UserId } from "@lefun/core"; -import type { Game, GameStateBase, GetPayload } from "@lefun/game"; - -// In the selectors, assume that the boards are defined. We will add a check in the -// client code to make sure this is true. -export type MatchState = _MatchState< - GS["B"], - GS["PB"] -> & { - userId: UserId; - board: GS["B"]; - // We need this to be optional because of spectators. - playerboard?: GS["PB"]; -}; +import type { IfAnyNull, Meta, UserId } from "@lefun/core"; +import type { + Game, + GameStateAny, + GameStateBase, + GetPayload, +} from "@lefun/game"; -export type Selector = ( - state: MatchState, -) => T; +export type Selector = (state: UIState) => T; export type MakeMove = < K extends keyof G["playerMoves"] & string, @@ -45,18 +36,18 @@ export function setMakeMove(makeMove: MakeMoveFull) { _makeMove = makeMove; } -export type UseSelector = ( +export type UseSelector = ( selector: Selector, ) => T; -let _useSelector: UseSelector | null = null; -let _useSelectorShallow: UseSelector | null = null; +let _useSelector: UseSelector | null = null; +let _useSelectorShallow: UseSelector | null = null; -export function setUseSelector(arg0: () => UseSelector) { +export function setUseSelector(arg0: () => UseSelector) { _useSelector = arg0(); } -export function setUseSelectorShallow(arg0: () => UseSelector) { +export function setUseSelectorShallow(arg0: () => UseSelector) { _useSelectorShallow = arg0(); } @@ -71,7 +62,7 @@ export function useSelector( `"useSelector" not defined by the host. Did you forget to call \`setUseSelector\`?`, ); } - return _useSelector(selector); + return _useSelector(selector as Selector); } export function useSelectorShallow( @@ -82,7 +73,7 @@ export function useSelectorShallow( `"useShallowSelector" not defined by the host. Did you forget to call \`setUseShallowSelector\`?`, ); } - return _useSelectorShallow(selector); + return _useSelectorShallow(selector as Selector); } /* Util to "curry" the types of useSelector<...> */ @@ -96,7 +87,7 @@ export const makeUseSelectorShallow = (selector: Selector) => useSelectorShallow(selector); -export function useMakeMove(): MakeMove { +export function useMakeMove>(): MakeMove { if (!_makeMove) { throw new Error( '"makeMove" not defined by the host. Did you forget to call `setMakeMove`?', @@ -126,7 +117,7 @@ export function useMakeMove(): MakeMove { }, []); } -export function makeUseMakeMove() { +export function makeUseMakeMove>() { return useMakeMove; } @@ -135,11 +126,9 @@ export function makeUseMakeMove() { */ export const useIsPlayer = () => { // Currently, the user is a player iif its playerboard is defined. - const hasPlayerboard = useSelector( - (state: _MatchState) => { - return !!state.playerboard; - }, - ); + const hasPlayerboard = useSelector((state: UIState) => { + return !!state.playerboard; + }); return hasPlayerboard; }; @@ -181,36 +170,17 @@ const toClientTime = * has happened. This can be useful if you want some action from the server to happen * exactly when a countdown gets to 0. */ -export const useToClientTime = () => { +export const useToClientTime = () => { const delta = useSelector( - (state: _MatchState) => state.timeDelta || 0, + (state: UIState) => state.timeDelta || 0, ); const latency = useSelector( - (state: _MatchState) => state.timeLatency || 0, + (state: UIState) => state.timeLatency || 0, ); return toClientTime(delta, latency); }; -/* - * Similar to `useToClientTime` but calls the returned function once without any state - * watching. This is useful when we are already updating some state at regular intervals. - */ -/* - - If we want to be able to use this, we'll need a way to set the store outside of the - Context, which require using a hook. - -export function getClientTime( - tsNumOrDate: Date | number, - adjust: TimeAdjust, -): number { - const delta = _store.getState().timeDelta || 0; - const latency = _store.getState().timeLatency || 0; - return toClientTime(delta, latency)(tsNumOrDate, adjust); -} -*/ - /* * Util to play a sound */ @@ -230,8 +200,8 @@ export const playSound = (name: string) => { export const useUsername = ( userId?: UserId, ): string | undefined => { - const username = useSelector((state: _MatchState) => { - return userId ? state.users.byId[userId]?.username : undefined; + const username = useSelector((state: UIState) => { + return userId ? state.users[userId]?.username : undefined; }); return username; }; @@ -241,8 +211,8 @@ export const useUsername = ( */ export const useUsernames = (): Record => { // Note the shallow-compared selector. - const usernames = useSelectorShallow((state: _MatchState) => { - const users = state.users.byId; + const usernames = useSelectorShallow((state: UIState) => { + const { users } = state; const usernames: { [userId: string]: string } = {}; for (const [userId, { username }] of Object.entries(users)) { usernames[userId] = username; @@ -262,14 +232,14 @@ export const useMyUserId = () => { // This has an awkward API for backward compatibility reasons. export type _Store = { - getState(): MatchState; + getState(): UIState; }; export type UseStore = _Store; -let _useStore: UseStore | null = null; +let _useStore: UseStore | null = null; -export const setUseStore = (arg0: () => MatchState) => { +export const setUseStore = (arg0: () => UIState) => { _useStore = { getState() { return arg0(); @@ -298,3 +268,66 @@ export function useStore< /* Convenience function to get a typed `useStore` hook. */ export const makeUseStore = () => useStore; + +export const useUserTurn = ( + userId?: UserId, + adjust: TimeAdjust = "before", +): + | { itsTheirTurn: boolean; beganAt: number; expiresAt: number | undefined } + // If we have no `beganAt` then there cannot be an `expiresAt`. + | { itsTheirTurn: boolean; beganAt: undefined; expiresAt: undefined } => { + let expiresAt = useSelector((state: UIState) => { + return userId ? state.meta.players.byId[userId]?.turnExpiresAt : undefined; + }); + let beganAt = useSelector((state: UIState) => { + return userId ? state.meta.players.byId[userId]?.turnBeganAt : undefined; + }); + + const toClientTime = useToClientTime(); + + if (beganAt === undefined) { + return { + itsTheirTurn: !!beganAt, + beganAt: undefined, + expiresAt: undefined, + }; + } + + if (beganAt) { + beganAt = toClientTime(beganAt, adjust); + } + + if (expiresAt) { + expiresAt = toClientTime(expiresAt, adjust); + } + + return { itsTheirTurn: !!beganAt, beganAt, expiresAt }; +}; + +export type User = { username: string }; + +export type Users = Record; + +/* + * State of the match as seen from a player. + * This is what the game UI has access to. + * Note that there is a version with less `undefined` values in `@lefun/ui`, for use by + * game developers. + */ +export type UIState = { + // The player's userid + userId: UserId; + + // The non-match related info required about the human users in the match. + users: Users; + + board: GS["B"]; + // `playerboard is `null` for spectators. + // Note that GS['PB'] can itself be `null` for games without playerboards. + playerboard: GS["PB"] | null; + meta: Meta; + + // Timing info with respect to the + timeDelta: number; + timeLatency: number; +}; diff --git a/packages/ui/src/lefunExtractor.ts b/packages/ui/src/lefunExtractor.ts index 8b44c3c..407ee34 100644 --- a/packages/ui/src/lefunExtractor.ts +++ b/packages/ui/src/lefunExtractor.ts @@ -33,7 +33,13 @@ export const lefunExtractor = (game: Game) => ({ // }; // The fields that don't need any arguments. - const fields = ["name", "tagline", "aka", "seoAka", "description"]; + const fields = [ + "name", + "tagline", + "aka", + "seoAka", + "description", + ] as const; for (const field of fields) { onMessageExtracted({ id: gameMessageKeys[field](), @@ -46,7 +52,7 @@ export const lefunExtractor = (game: Game) => ({ // Game settings if (game.gameSettings) { for (const gameSetting of game.gameSettings) { - const fields = ["gameSettingLabel", "gameSettingHelp"]; + const fields = ["gameSettingLabel", "gameSettingHelp"] as const; const { key } = gameSetting; for (const field of fields) { onMessageExtracted({ @@ -60,7 +66,7 @@ export const lefunExtractor = (game: Game) => ({ const fields = [ "gameSettingOptionLabel", "gameSettingOptionShortLabel", - ]; + ] as const; for (const field of fields) { onMessageExtracted({ id: gameMessageKeys[field](key, value), @@ -81,7 +87,7 @@ export const lefunExtractor = (game: Game) => ({ continue; } - const fields = ["gamePlayerSettingLabel"]; + const fields = ["gamePlayerSettingLabel"] as const; for (const field of fields) { onMessageExtracted({ id: gameMessageKeys[field](key), @@ -90,7 +96,7 @@ export const lefunExtractor = (game: Game) => ({ } for (const { value } of options) { - const fields = ["gamePlayerSettingOptionLabel"]; + const fields = ["gamePlayerSettingOptionLabel"] as const; for (const field of fields) { onMessageExtracted({ id: gameMessageKeys[field](key, value), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 37dc37e..61cf62f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -37,51 +37,27 @@ importers: specifier: ^8.37.0 version: 8.37.0(eslint@9.31.0(jiti@1.21.6))(typescript@5.5.4) - games/game1-v2.3.0/game: - devDependencies: - '@lefun/core': - specifier: workspace:* - version: link:../../../packages/core - '@lefun/game': - specifier: workspace:* - version: link:../../../packages/game - rollup: - specifier: ^4.20.0 - version: 4.20.0 - rollup-plugin-typescript2: - specifier: ^0.36.0 - version: 0.36.0(rollup@4.20.0)(typescript@5.5.4) - tslib: - specifier: ^2.6.3 - version: 2.6.3 - typescript: - specifier: ^5.5.4 - version: 5.5.4 - vitest: - specifier: ^2.0.5 - version: 2.0.5(@types/node@20.14.10) - - games/game1-v2.3.0/ui: + games/game1-v2.5.3: dependencies: classnames: specifier: ^2.5.1 version: 2.5.1 - game1-v2.3.0-game: - specifier: workspace:* - version: link:../game devDependencies: '@babel/preset-react': specifier: ^7.24.7 version: 7.24.7(@babel/core@7.24.9) '@lefun/core': specifier: workspace:* - version: link:../../../packages/core + version: link:../../packages/core '@lefun/dev-server': specifier: workspace:* - version: link:../../../packages/dev-server + version: link:../../packages/dev-server + '@lefun/game': + specifier: workspace:* + version: link:../../packages/game '@lefun/ui': specifier: workspace:* - version: link:../../../packages/ui + version: link:../../packages/ui '@lingui/cli': specifier: ^4.11.2 version: 4.11.2(typescript@5.5.4) @@ -96,16 +72,19 @@ importers: version: 4.11.2(react@18.3.1) '@lingui/vite-plugin': specifier: ^4.11.2 - version: 4.11.2(typescript@5.5.4)(vite@5.3.4(@types/node@20.14.10)) + version: 4.11.2(typescript@5.5.4)(vite@5.3.5(@types/node@20.14.10)) '@rollup/plugin-babel': specifier: ^6.0.4 - version: 6.0.4(@babel/core@7.24.9)(@types/babel__core@7.20.5)(rollup@4.18.1) + version: 6.0.4(@babel/core@7.24.9)(@types/babel__core@7.20.5)(rollup@4.20.0) '@rollup/plugin-commonjs': specifier: ^26.0.1 - version: 26.0.1(rollup@4.18.1) + version: 26.0.1(rollup@4.20.0) '@rollup/plugin-node-resolve': specifier: ^15.2.3 - version: 15.2.3(rollup@4.18.1) + version: 15.2.3(rollup@4.20.0) + '@rollup/plugin-typescript': + specifier: ^11.1.6 + version: 11.1.6(rollup@4.20.0)(tslib@2.6.3)(typescript@5.5.4) '@types/react': specifier: ^18.3.3 version: 18.3.3 @@ -114,7 +93,7 @@ importers: version: 18.3.0 '@vitejs/plugin-react': specifier: ^4.3.1 - version: 4.3.1(vite@5.3.4(@types/node@20.14.10)) + version: 4.3.1(vite@5.3.5(@types/node@20.14.10)) react: specifier: ^18.3.1 version: 18.3.1 @@ -123,24 +102,24 @@ importers: version: 18.3.1(react@18.3.1) rollup: specifier: ^4.18.1 - version: 4.18.1 + version: 4.20.0 rollup-plugin-copy: specifier: ^3.5.0 version: 3.5.0 rollup-plugin-postcss: specifier: ^4.0.2 version: 4.0.2(postcss@8.4.40) - rollup-plugin-typescript2: - specifier: ^0.36.0 - version: 0.36.0(rollup@4.18.1)(typescript@5.5.4) typescript: specifier: ^5.5.4 version: 5.5.4 vite: specifier: ^5.3.4 - version: 5.3.4(@types/node@20.14.10) + version: 5.3.5(@types/node@20.14.10) + vitest: + specifier: ^2.0.5 + version: 2.0.5(@types/node@20.14.10) - games/game1-v2.5.3: + games/game1-v2.7.0: dependencies: classnames: specifier: ^2.5.1 @@ -327,9 +306,6 @@ importers: packages/game: dependencies: - immer: - specifier: ^10.1.1 - version: 10.1.1 lodash-es: specifier: ^4.17.21 version: 4.17.21 @@ -346,6 +322,9 @@ importers: '@types/lodash-es': specifier: ^4.17.12 version: 4.17.12 + immer: + specifier: ^10.1.1 + version: 10.1.1 rollup: specifier: ^2.79.1 version: 2.79.1 @@ -429,6 +408,12 @@ importers: specifier: ^4.5.4 version: 4.5.4(@types/react@18.3.3)(immer@10.1.1)(react@18.3.1) devDependencies: + '@lefun/core': + specifier: workspace:* + version: link:../core + '@lefun/game': + specifier: workspace:* + version: link:../game '@lefun/ui': specifier: workspace:* version: link:../ui @@ -4711,34 +4696,6 @@ packages: engines: {node: ^18.0.0 || >=20.0.0} hasBin: true - vite@5.3.4: - resolution: {integrity: sha512-Cw+7zL3ZG9/NZBB8C+8QbQZmR54GwqIz+WMI4b3JgdYJvX+ny9AjJXqkGQlDXSXRP9rP0B4tbciRMOVEKulVOA==} - engines: {node: ^18.0.0 || >=20.0.0} - hasBin: true - peerDependencies: - '@types/node': ^18.0.0 || >=20.0.0 - less: '*' - lightningcss: ^1.21.0 - sass: '*' - stylus: '*' - sugarss: '*' - terser: ^5.4.0 - peerDependenciesMeta: - '@types/node': - optional: true - less: - optional: true - lightningcss: - optional: true - sass: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - vite@5.3.5: resolution: {integrity: sha512-MdjglKR6AQXQb9JGiS7Rc2wC6uMjcm7Go/NHNO63EwiJXfuk9PgqiP/n5IDJCziMkfw9n4Ubp7lttNwz+8ZVKA==} engines: {node: ^18.0.0 || >=20.0.0} @@ -5540,15 +5497,6 @@ snapshots: '@lingui/core': 4.11.2 react: 18.3.1 - '@lingui/vite-plugin@4.11.2(typescript@5.5.4)(vite@5.3.4(@types/node@20.14.10))': - dependencies: - '@lingui/cli': 4.11.2(typescript@5.5.4) - '@lingui/conf': 4.11.2(typescript@5.5.4) - vite: 5.3.4(@types/node@20.14.10) - transitivePeerDependencies: - - supports-color - - typescript - '@lingui/vite-plugin@4.11.2(typescript@5.5.4)(vite@5.3.5(@types/node@20.14.10))': dependencies: '@lingui/cli': 4.11.2(typescript@5.5.4) @@ -5848,17 +5796,6 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true - '@rollup/plugin-babel@6.0.4(@babel/core@7.24.9)(@types/babel__core@7.20.5)(rollup@4.18.1)': - dependencies: - '@babel/core': 7.24.9 - '@babel/helper-module-imports': 7.24.7 - '@rollup/pluginutils': 5.1.0(rollup@4.18.1) - optionalDependencies: - '@types/babel__core': 7.20.5 - rollup: 4.18.1 - transitivePeerDependencies: - - supports-color - '@rollup/plugin-babel@6.0.4(@babel/core@7.24.9)(@types/babel__core@7.20.5)(rollup@4.20.0)': dependencies: '@babel/core': 7.24.9 @@ -5903,17 +5840,6 @@ snapshots: optionalDependencies: rollup: 4.18.1 - '@rollup/plugin-commonjs@26.0.1(rollup@4.18.1)': - dependencies: - '@rollup/pluginutils': 5.1.0(rollup@4.18.1) - commondir: 1.0.1 - estree-walker: 2.0.2 - glob: 10.4.5 - is-reference: 1.2.1 - magic-string: 0.30.10 - optionalDependencies: - rollup: 4.18.1 - '@rollup/plugin-commonjs@26.0.1(rollup@4.20.0)': dependencies: '@rollup/pluginutils': 5.1.0(rollup@4.20.0) @@ -6375,17 +6301,6 @@ snapshots: '@typescript-eslint/types': 8.37.0 eslint-visitor-keys: 4.2.1 - '@vitejs/plugin-react@4.3.1(vite@5.3.4(@types/node@20.14.10))': - dependencies: - '@babel/core': 7.24.9 - '@babel/plugin-transform-react-jsx-self': 7.24.7(@babel/core@7.24.9) - '@babel/plugin-transform-react-jsx-source': 7.24.7(@babel/core@7.24.9) - '@types/babel__core': 7.20.5 - react-refresh: 0.14.2 - vite: 5.3.4(@types/node@20.14.10) - transitivePeerDependencies: - - supports-color - '@vitejs/plugin-react@4.3.1(vite@5.3.5(@types/node@20.14.10))': dependencies: '@babel/core': 7.24.9 @@ -9409,26 +9324,6 @@ snapshots: tslib: 2.6.3 typescript: 5.5.4 - rollup-plugin-typescript2@0.36.0(rollup@4.18.1)(typescript@5.5.4): - dependencies: - '@rollup/pluginutils': 4.2.1 - find-cache-dir: 3.3.2 - fs-extra: 10.1.0 - rollup: 4.18.1 - semver: 7.6.2 - tslib: 2.6.3 - typescript: 5.5.4 - - rollup-plugin-typescript2@0.36.0(rollup@4.20.0)(typescript@5.5.4): - dependencies: - '@rollup/pluginutils': 4.2.1 - find-cache-dir: 3.3.2 - fs-extra: 10.1.0 - rollup: 4.20.0 - semver: 7.6.2 - tslib: 2.6.3 - typescript: 5.5.4 - rollup-pluginutils@2.8.2: dependencies: estree-walker: 0.6.1 @@ -9934,15 +9829,6 @@ snapshots: - supports-color - terser - vite@5.3.4(@types/node@20.14.10): - dependencies: - esbuild: 0.21.5 - postcss: 8.4.39 - rollup: 4.18.1 - optionalDependencies: - '@types/node': 20.14.10 - fsevents: 2.3.3 - vite@5.3.5(@types/node@20.14.10): dependencies: esbuild: 0.21.5 diff --git a/tsconfig.base.json b/tsconfig.base.json index cc6ea7b..805417b 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -7,8 +7,11 @@ "sourceMap": true, "moduleResolution": "node", "declaration": true, + "declarationMap": true, "allowSyntheticDefaultImports": true, "allowJs": true, - "esModuleInterop": true + "esModuleInterop": true, + "noEmitOnError": true, + "noUncheckedIndexedAccess": true } }