From b28e8a865c4d63c844dc0e937ec905bb7ab27246 Mon Sep 17 00:00:00 2001 From: Simon Lemieux <1105380+simlmx@users.noreply.github.com> Date: Mon, 24 Jun 2024 16:01:45 +0000 Subject: [PATCH] Big fefactor the way we define moves Also fix some of the linting. --- .github/workflows/ci.yaml | 25 ++- .prettierignore | 1 + Makefile | 4 +- game-template/README.md | 4 +- game-template/game/package.json | 5 +- game-template/game/rollup.config.js | 2 +- game-template/game/src/index.test-d.ts | 30 +++ game-template/game/src/index.test.ts | 17 ++ game-template/game/src/index.ts | 64 +++++-- game-template/game/tsconfig.json | 2 +- game-template/game/vitest.config.ts | 9 + game-template/ui/.eslintrc.json | 2 +- game-template/ui/package.json | 2 +- game-template/ui/rollup.config.js | 2 +- game-template/ui/src/Board.tsx | 28 ++- game-template/ui/src/index.ts | 2 +- game-template/ui/src/main.tsx | 6 +- lerna.json | 2 +- packages/core/src/index.ts | 8 - packages/dev-server/package.json | 10 +- packages/dev-server/src/App.tsx | 88 +++++---- packages/dev-server/src/match.ts | 87 ++++----- packages/game/src/gameDef.ts | 237 +++++++++++------------- packages/game/src/random.ts | 2 +- packages/game/src/testing.ts | 200 +++++++++++--------- packages/game/src/typing.ts | 1 + packages/ui-testing/src/index.tsx | 16 +- packages/ui/rollup.config.js | 2 +- packages/ui/src/{index.tsx => index.ts} | 148 ++++++++++----- packages/ui/src/lefunExtractor.ts | 4 +- pnpm-lock.yaml | 37 ++-- 31 files changed, 612 insertions(+), 435 deletions(-) create mode 100644 game-template/game/src/index.test-d.ts create mode 100644 game-template/game/src/index.test.ts create mode 100644 game-template/game/vitest.config.ts create mode 100644 packages/game/src/typing.ts rename packages/ui/src/{index.tsx => index.ts} (57%) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 0c7fc8b..733267f 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -5,22 +5,21 @@ name: Build, Check Format and Test on: pull_request: - branches: [ "main" ] + branches: ["main"] jobs: build: - runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - - name: Install Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: 'pnpm' - - run: make init - - run: make build - - run: make check-format - - run: make test + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: "pnpm" + - run: make init + - run: make build + - run: make check-format + - run: make test diff --git a/.prettierignore b/.prettierignore index 5575c7d..0c7ba5e 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1 +1,2 @@ packages/*/dist +pnpm-lock.yaml diff --git a/Makefile b/Makefile index 022d417..8c98a77 100644 --- a/Makefile +++ b/Makefile @@ -16,13 +16,13 @@ watch: .PHONY: format format: - pnpm prettier packages --write pnpm lerna run format + pnpm prettier . --write .PHONY: check-format check-format: - pnpm prettier packages --check pnpm lerna run check-format + pnpm prettier . --check .PHONY: bump-version bump-version: diff --git a/game-template/README.md b/game-template/README.md index be75cb4..eab3e91 100644 --- a/game-template/README.md +++ b/game-template/README.md @@ -1,15 +1,13 @@ # Game Template -This is a very minimalist game example. +This is a very minimalist game example. See [Dudo][dudo] for a more complex example. - ## Run the game locally pnpm install cd ui pnpm dev - [dudo]: https://github.com/lefun-fun/dudo diff --git a/game-template/game/package.json b/game-template/game/package.json index 3d29aaa..61c4a3a 100644 --- a/game-template/game/package.json +++ b/game-template/game/package.json @@ -17,10 +17,11 @@ "devDependencies": { "@lefun/core": "workspace:*", "@lefun/game": "workspace:*", - "@rollup/plugin-typescript": "^11.1.6", "rollup": "^4.18.1", + "rollup-plugin-typescript2": "^0.36.0", "tslib": "^2.6.3", - "typescript": "^5.5.3" + "typescript": "^5.5.3", + "vitest": "^1.6.0" }, "peerDependencies": { "@lefun/core": "workspace:*", diff --git a/game-template/game/rollup.config.js b/game-template/game/rollup.config.js index 152e18a..4be5e60 100644 --- a/game-template/game/rollup.config.js +++ b/game-template/game/rollup.config.js @@ -1,4 +1,4 @@ -import typescript from "@rollup/plugin-typescript"; +import typescript from "rollup-plugin-typescript2"; export default { input: "src/index.ts", diff --git a/game-template/game/src/index.test-d.ts b/game-template/game/src/index.test-d.ts new file mode 100644 index 0000000..1695c2c --- /dev/null +++ b/game-template/game/src/index.test-d.ts @@ -0,0 +1,30 @@ +import { expectTypeOf, test } from "vitest"; + +import { MatchTester, PlayerMove } from "@lefun/game"; + +import { game, RollGame, RollGameState as GS } from "."; + +test("inside PlayerMove", () => { + expectTypeOf(game).toEqualTypeOf(); + + const move: PlayerMove = { + executeNow() { + // + }, + }; + + expectTypeOf(move.executeNow).parameter(0).toMatchTypeOf<{ + board: GS["B"]; + playerboard: GS["PB"]; + payload: { x: number }; + }>(); + + expectTypeOf(move.executeNow).parameter(0).toMatchTypeOf<{ + board: { count: number }; + }>(); +}); + +test("match tester", () => { + const match = new MatchTester({ game, numPlayers: 2 }); + expectTypeOf(match.board.count).toEqualTypeOf(); +}); diff --git a/game-template/game/src/index.test.ts b/game-template/game/src/index.test.ts new file mode 100644 index 0000000..c1b7ec0 --- /dev/null +++ b/game-template/game/src/index.test.ts @@ -0,0 +1,17 @@ +import { test } from "vitest"; + +import { MatchTester as _MatchTester } from "@lefun/game"; + +import { game, RollGame as G, RollGameState as GS } from "."; + +class MatchTester extends _MatchTester {} + +test("sanity check", () => { + const match = new MatchTester({ game, numPlayers: 2 }); + const { players } = match.board; + + const userId = Object.keys(players)[0]; + + match.makeMove(userId, "roll"); + match.makeMove(userId, "moveWithArg", { someArg: "123" }); +}); diff --git a/game-template/game/src/index.ts b/game-template/game/src/index.ts index aea5723..82a7582 100644 --- a/game-template/game/src/index.ts +++ b/game-template/game/src/index.ts @@ -1,5 +1,5 @@ import { UserId } from "@lefun/core"; -import { createMove, GameDef, Moves } from "@lefun/game"; +import { BoardMove, Game, GameState, INIT_MOVE, PlayerMove } from "@lefun/game"; type Player = { isRolling: boolean; @@ -11,21 +11,54 @@ export type Board = { players: Record; }; -const [ROLL, roll] = createMove("roll"); +export type RollGameState = GameState; -const moves: Moves = { - [ROLL]: { - executeNow({ board, userId }) { - board.players[userId].isRolling = true; - }, - execute({ board, userId, random }) { - board.players[userId].diceValue = random.d6(); - board.players[userId].isRolling = false; - }, +type BMT = { + someBoardMove: never; + someBoardMoveWithArgs: { someArg: number }; +}; + +const moveWithArg: PlayerMove = { + execute() { + // + }, +}; + +const roll: PlayerMove = { + executeNow({ board, userId }) { + board.players[userId].isRolling = true; + }, + execute({ board, userId, random, delayMove }) { + board.players[userId].diceValue = random.d6(); + board.players[userId].isRolling = false; + delayMove("someBoardMove", 100); + delayMove("someBoardMoveWithArgs", { someArg: 3 }, 100); + }, +}; + +const initMove: BoardMove = { + execute() { + // + }, +}; + +const someBoardMove: BoardMove = { + execute() { + // }, }; -const game: GameDef = { +const someBoardMoveWithArgs: BoardMove< + RollGameState, + BMT["someBoardMoveWithArgs"], + BMT +> = { + execute() { + // + }, +}; + +export const game = { initialBoards: ({ players }) => ({ board: { count: 0, @@ -34,9 +67,10 @@ const game: GameDef = { ), }, }), - moves, + playerMoves: { roll, moveWithArg }, + boardMoves: { [INIT_MOVE]: initMove, someBoardMove, someBoardMoveWithArgs }, minPlayers: 1, maxPlayers: 10, -}; +} satisfies Game; -export { game, roll }; +export type RollGame = typeof game; diff --git a/game-template/game/tsconfig.json b/game-template/game/tsconfig.json index b20a21d..dec8958 100644 --- a/game-template/game/tsconfig.json +++ b/game-template/game/tsconfig.json @@ -5,7 +5,7 @@ "module": "esnext", "lib": ["dom", "esnext"], "sourceMap": true, - "moduleResolution": "node", + "moduleResolution": "bundler", "declaration": true, "allowSyntheticDefaultImports": true, "allowJs": true, diff --git a/game-template/game/vitest.config.ts b/game-template/game/vitest.config.ts new file mode 100644 index 0000000..2fe25b9 --- /dev/null +++ b/game-template/game/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + typecheck: { + enabled: true, + }, + }, +}); diff --git a/game-template/ui/.eslintrc.json b/game-template/ui/.eslintrc.json index 10d3a32..a8594e3 100644 --- a/game-template/ui/.eslintrc.json +++ b/game-template/ui/.eslintrc.json @@ -18,7 +18,7 @@ ["^node:"], ["^@?\\w"], ["^@lefun/"], - ["^roll-game$"], + ["roll-game"], ["^"], ["^\\."] ] diff --git a/game-template/ui/package.json b/game-template/ui/package.json index 3fbccad..488c988 100644 --- a/game-template/ui/package.json +++ b/game-template/ui/package.json @@ -29,7 +29,6 @@ "@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", @@ -38,6 +37,7 @@ "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.3", "vite": "^5.3.4" }, diff --git a/game-template/ui/rollup.config.js b/game-template/ui/rollup.config.js index 3ad146a..78cd00c 100644 --- a/game-template/ui/rollup.config.js +++ b/game-template/ui/rollup.config.js @@ -1,9 +1,9 @@ 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", diff --git a/game-template/ui/src/Board.tsx b/game-template/ui/src/Board.tsx index 7ba17e4..d7ec7fe 100644 --- a/game-template/ui/src/Board.tsx +++ b/game-template/ui/src/Board.tsx @@ -5,22 +5,21 @@ import classNames from "classnames"; import type { UserId } from "@lefun/core"; import { + makeUseMakeMove, makeUseSelector, makeUseSelectorShallow, - useDispatch, useIsPlayer, useUsername, } from "@lefun/ui"; -import { Board as _Board, roll } from "roll-game"; - -type B = _Board; +import type { RollGame, RollGameState } from "roll-game"; // Dice symbol characters const DICE = ["", "\u2680", "\u2681", "\u2682", "\u2683", "\u2684", "\u2685"]; -const useSelector = makeUseSelector(); -const useSelectorShallow = makeUseSelectorShallow(); +const useSelector = makeUseSelector(); +const useSelectorShallow = makeUseSelectorShallow(); +const useMakeMove = makeUseMakeMove(); function Player({ userId }: { userId: UserId }) { const itsMe = useSelector((state) => state.userId === userId); @@ -50,7 +49,7 @@ function Die({ userId }: { userId: UserId }) { } function Board() { - const dispatch = useDispatch(); + const makeMove = useMakeMove(); const players = useSelectorShallow((state) => Object.keys(state.board.players), ); @@ -66,9 +65,18 @@ function Board() { ))} {isPlayer && ( - + <> + + + )} ); diff --git a/game-template/ui/src/index.ts b/game-template/ui/src/index.ts index c2c26f8..9c8df85 100644 --- a/game-template/ui/src/index.ts +++ b/game-template/ui/src/index.ts @@ -1,3 +1,3 @@ import Board from "./Board"; -export { Board } +export { Board }; diff --git a/game-template/ui/src/main.tsx b/game-template/ui/src/main.tsx index 25f7a93..df61657 100644 --- a/game-template/ui/src/main.tsx +++ b/game-template/ui/src/main.tsx @@ -1,19 +1,19 @@ import { render } from "@lefun/dev-server"; -import { Board, game } from "roll-game"; +import { game } from "roll-game"; // @ts-expect-error abc import { messages as en } from "./locales/en/messages"; // @ts-expect-error abc import { messages as fr } from "./locales/fr/messages"; -render({ +render({ board: async () => { const { default: Board } = await import("./Board"); // @ts-expect-error the import is there even if TS does not see it! await import("./index.css"); return ; }, - gameDef: game, + game, messages: { en, fr }, }); diff --git a/lerna.json b/lerna.json index 66e76d0..57fec8a 100644 --- a/lerna.json +++ b/lerna.json @@ -2,4 +2,4 @@ "$schema": "node_modules/lerna/schemas/lerna-schema.json", "version": "1.5.0", "npmClient": "pnpm" -} \ No newline at end of file +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 98e4bb9..9b172e2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -4,14 +4,6 @@ export * from "./types"; import { UserId } from "./types"; -/* - * This is the type returned by move functions created by the game developer. - */ -export type Move

= { - name: string; - payload: P; -}; - export type AkaType = "similar" | "aka" | "inspired" | "original"; export type User = { diff --git a/packages/dev-server/package.json b/packages/dev-server/package.json index 2ced92b..5c0e383 100644 --- a/packages/dev-server/package.json +++ b/packages/dev-server/package.json @@ -55,18 +55,20 @@ "rollup-plugin-postcss": "^4.0.2", "tailwindcss": "^3.4.6", "typescript": "^5.5.3", - "vitest": "^1.6.0" + "vitest": "^1.6.0", + "@lingui/core": "^4.11.2", + "@lingui/react": "^4.11.2" }, "peerDependencies": { "@lefun/core": "workspace:*", "@lefun/game": "workspace:*", "@lefun/ui": "workspace:*", "react": ">=17.0.2", - "react-dom": ">=17.0.2" + "react-dom": ">=17.0.2", + "@lingui/core": "^4.11.2", + "@lingui/react": "^4.11.2" }, "dependencies": { - "@lingui/core": "^4.11.2", - "@lingui/react": "^4.11.2", "classnames": "^2.5.1", "immer": "^10.1.1", "json-edit-react": "^1.13.3", diff --git a/packages/dev-server/src/App.tsx b/packages/dev-server/src/App.tsx index 695f778..450d57d 100644 --- a/packages/dev-server/src/App.tsx +++ b/packages/dev-server/src/App.tsx @@ -17,8 +17,14 @@ import { import { createRoot } from "react-dom/client"; import { createStore as _createStore, useStore as _useStore } from "zustand"; -import type { Locale, MatchSettings, UserId, UsersState } from "@lefun/core"; -import { GameDef } from "@lefun/game"; +import type { + Locale, + MatchSettings, + User, + UserId, + UsersState, +} from "@lefun/core"; +import { Game } from "@lefun/game"; import { setMakeMove, Store, storeContext } from "@lefun/ui"; import { loadMatch, Match, saveMatch } from "./match"; @@ -28,14 +34,14 @@ const LATENCY = 100; enablePatches(); -type MatchState = { - board: B; - playerboard: PB; +type MatchState = { + board: unknown; + playerboard: unknown; userId: UserId; users: UsersState; }; -const BoardForPlayer = ({ +const BoardForPlayer = ({ board, userId, messages, @@ -54,8 +60,7 @@ const BoardForPlayer = ({ const storeRef = useRef(null); useEffect(() => { - const { lefun } = (window as any).top; - const { match, players } = lefun; + const { match, players } = (window.top as any).lefun as Lefun; const { store: mainStore } = match; const { board, playerboards } = mainStore.getState(); @@ -77,7 +82,7 @@ const BoardForPlayer = ({ // Wait before we apply the updates to simulate a network latency. setTimeout(() => { - store.setState((state: MatchState) => { + store.setState((state: MatchState) => { const newState = produce(state, (draft) => { applyPatches(draft, patches); }); @@ -86,10 +91,8 @@ const BoardForPlayer = ({ }, LATENCY); }); - setMakeMove((move, store) => { - const { canDo, executeNow } = match.gameDef.moves[move.name]; - - const { payload } = move; + setMakeMove((store) => (moveName, payload) => { + const { canDo, executeNow } = match.game.playerMoves[moveName]; { const now = new Date().getTime(); @@ -100,7 +103,7 @@ const BoardForPlayer = ({ userId, board, playerboard, - payload: move.payload, + payload, ts: now, }); if (!canTheyDo) { @@ -112,13 +115,13 @@ const BoardForPlayer = ({ if (executeNow) { // Optimistic update directly on the `store` of the player making the move. - store.setState((state: MatchState) => { - const newState = produce(state, (draft: Draft>) => { + store.setState((state: MatchState) => { + const newState = produce(state, (draft: Draft) => { const { board, playerboard } = draft; executeNow({ userId, - board: board as B, - playerboard: playerboard as PB, + board, + playerboard, payload, delayMove: () => { console.warn("delayMove not implemented yet"); @@ -130,7 +133,7 @@ const BoardForPlayer = ({ }); } - match.makeMove(userId, move); + match.makeMove(userId, moveName, payload); saveMatch(match); }); @@ -190,8 +193,6 @@ function deepCopy(obj: T): T { return JSON.parse(JSON.stringify(obj)); } -type EmptyObject = Record; - function useSetDimensionCssVariablesOnResize(ref: RefObject) { const [height, setHeight] = useState(0); const [width, setWidth] = useState(0); @@ -215,10 +216,10 @@ function useSetDimensionCssVariablesOnResize(ref: RefObject) { return { height, width }; } -function MatchStateView({ +function MatchStateView({ matchRef, }: { - matchRef: RefObject>; + matchRef: RefObject>; }) { const state = _useStore(matchRef.current?.store as any, (state) => deepCopy(state), @@ -274,11 +275,11 @@ function capitalize(s: string): string { return s && s[0].toUpperCase() + s.slice(1); } -function Settings({ +function Settings({ matchRef, resetMatch, }: { - matchRef: RefObject>; + matchRef: RefObject>; resetMatch: ({ locale, numPlayers, @@ -416,7 +417,7 @@ function Settings({ )} - {view === "game" && matchRef={matchRef} />} + {view === "game" && } ); } @@ -495,13 +496,18 @@ function Dimensions({ ); } -function Main({ - gameDef, +type Lefun = { + players: Record; + match: Match; +}; + +function Main({ + game, matchSettings, matchData, gameData, }: { - gameDef: GameDef; + game: Game; matchSettings: MatchSettings; matchData?: any; gameData?: any; @@ -514,7 +520,7 @@ function Main({ const [loading, setLoading] = useState(true); - const matchRef = useRef | null>(null); + const matchRef = useRef | null>(null); const resetMatch = useCallback( ({ @@ -528,10 +534,10 @@ function Main({ }) => { const userIds = getUserIds(numPlayers); - let match: Match | null = null; + let match: Match | null = null; if (tryToLoad) { - match = loadMatch(gameDef); + match = loadMatch(game); } const players = Object.fromEntries( @@ -555,8 +561,8 @@ function Main({ userIds.map((userId, i) => [userId, { color: i.toString() }]), ); - match = new Match({ - gameDef, + match = new Match({ + game, matchSettings, matchPlayersSettings, matchData, @@ -570,7 +576,7 @@ function Main({ (window as any).lefun.match = matchRef.current; saveMatch(match); }, - [gameDef, matchData, gameData, matchSettings], + [game, matchData, gameData, matchSettings], ); const firstRender = useRef(true); @@ -600,7 +606,7 @@ function Main({ {view === "rules" ? : } {matchRef && ( - + ({ type AllMessages = Record>; -async function render({ - gameDef, +async function render({ + game, board, rules, matchSettings = {}, @@ -627,7 +633,7 @@ async function render({ idName = "home", messages = { en: {} }, }: { - gameDef: GameDef; + game: Game; board: () => Promise; rules?: () => Promise; matchSettings?: MatchSettings; @@ -684,7 +690,7 @@ async function render({ const locales = (Object.keys(messages) || ["en"]) as Locale[]; let content = (

({ ); const store = createStore({ - numPlayers: gameDef.minPlayers, + numPlayers: game.minPlayers, locales, }); diff --git a/packages/dev-server/src/match.ts b/packages/dev-server/src/match.ts index 35f9345..f17dabe 100644 --- a/packages/dev-server/src/match.ts +++ b/packages/dev-server/src/match.ts @@ -5,29 +5,31 @@ import { Locale, MatchPlayersSettings, MatchSettings, - Move, User, UserId, } from "@lefun/core"; -import { GameDef, Random } from "@lefun/game"; +import { Game, GameStateBase, Random } from "@lefun/game"; type EmptyObject = Record; -type State = { - board: B; - playerboards: Record; - secretboard: SB | EmptyObject; +type State = { + board: GS["B"]; + playerboards: Record; + secretboard: GS["SB"] | EmptyObject; }; -class Match extends EventTarget { +class Match< + GS extends GameStateBase, + G extends Game, +> extends EventTarget { userIds: UserId[]; random: Random; - gameDef: GameDef; + game: G; // Store that represents the backend. // We need to put it in a zustand Store because we want the JSON view in the right // panel to refresh with changes of state. - store: StoreApi>; + store: StoreApi>; // Note some of the constructors parameters in case we want to reset. matchData: any; @@ -35,7 +37,7 @@ class Match extends EventTarget { constructor({ players, - gameDef, + game, matchSettings, matchPlayersSettings, matchData, @@ -45,13 +47,13 @@ class Match extends EventTarget { userIds, }: { players?: Record; - gameDef: GameDef; + game: G; matchSettings?: MatchSettings; matchPlayersSettings?: MatchPlayersSettings; matchData?: any; gameData?: any; locale?: Locale; - state?: State; + state?: State; userIds?: UserId[]; }) { super(); @@ -59,12 +61,12 @@ class Match extends EventTarget { const random = new Random(); this.random = random; - this.gameDef = gameDef; + this.game = game; this.userIds = userIds || []; this.matchData = matchData; this.gameData = gameData; - this.store = createStore(() => (state || {}) as State); + this.store = createStore(() => (state || {}) as State); if (!state) { if (!players) { @@ -89,11 +91,7 @@ class Match extends EventTarget { // 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 { - board, - playerboards = {}, - secretboard = {}, - } = gameDef.initialBoards({ + const initialBoards = game.initialBoards({ players: Object.keys(players), matchSettings, matchPlayersSettings, @@ -103,6 +101,15 @@ class Match extends EventTarget { areBots, locale, }); + const { board, secretboard = {} } = initialBoards; + let { playerboards } = initialBoards; + + if (!playerboards) { + playerboards = {}; + for (const userId of this.userIds) { + playerboards[userId] = {}; + } + } this.store = createStore(() => ({ board, @@ -112,7 +119,7 @@ class Match extends EventTarget { } } - makeMove(userId: UserId, move: Move) { + makeMove(userId: UserId, moveName: string, payload: any) { const now = new Date().getTime(); // Here the `store` is the store for the player making the move, since @@ -125,8 +132,7 @@ class Match extends EventTarget { throw new Error("no store"); } - const { name, payload } = move; - const { executeNow, execute } = this.gameDef.moves[name]; + const { executeNow, execute } = this.game.playerMoves[moveName]; const patchesByUserId: Record = Object.fromEntries( this.userIds.map((userId) => [userId, []]), @@ -135,10 +141,10 @@ class Match extends EventTarget { if (executeNow) { // Also run `executeNow` on the local state. - this.store.setState((state: State) => { + this.store.setState((state: State) => { const [newState, patches] = produceWithPatches( state, - (draft: Draft>) => { + (draft: Draft>) => { const { board, playerboards } = draft; executeNow({ // We have had issues with the combination of `setState` and @@ -146,8 +152,8 @@ class Match extends EventTarget { // workaround. payload: JSON.parse(JSON.stringify(payload)), userId, - board: board as B, - playerboard: playerboards[userId] as PB, + board: board as GS["B"], + playerboard: playerboards[userId] as GS["PB"], delayMove: () => { console.warn("delayMove not implemented yet"); return { ts: 0 }; @@ -168,23 +174,23 @@ class Match extends EventTarget { if (execute) { const { store, random, matchData, gameData } = this; - store.setState((state: State) => { + store.setState((state: State) => { const [newState, patches] = produceWithPatches( state, ( draft: Draft<{ - board: B; - playerboards: Record; - secretboard: SB; + board: GS["B"]; + playerboards: Record; + secretboard: GS["SB"]; }>, ) => { const { board, playerboards, secretboard } = draft; execute({ payload, userId, - board: board as B, - playerboards: playerboards as Record, - secretboard: secretboard as SB, + board: board as GS["B"], + playerboards: playerboards as Record, + secretboard: secretboard as GS["SB"], matchData, gameData, random, @@ -232,13 +238,10 @@ class Match extends EventTarget { return JSON.stringify({ state, userIds, matchData, gameData }); } - static deserialize( - str: string, - gameDef: GameDef, - ): Match { + static deserialize(str: string, game: Game): Match { const obj = JSON.parse(str); const { state, userIds, matchData, gameData } = obj; - return new Match({ state, userIds, matchData, gameData, gameDef }); + return new Match({ state, userIds, matchData, gameData, game }); } } @@ -279,14 +282,12 @@ export function separatePatchesByUser({ } /* Save match to localStorage */ -function saveMatch(match: Match) { +function saveMatch(match: Match) { localStorage.setItem("match", match.serialize()); } /* Load match from localStorage */ -function loadMatch( - gameDef: GameDef, -): Match | null { +function loadMatch(game: Game): Match | null { const str = localStorage.getItem("match"); if (!str) { @@ -294,7 +295,7 @@ function loadMatch( } try { - return Match.deserialize(str, gameDef); + return Match.deserialize(str, game); } catch (e) { console.error("Failed to deserialize match", e); return null; diff --git a/packages/game/src/gameDef.ts b/packages/game/src/gameDef.ts index f28f7df..9a1a2fc 100644 --- a/packages/game/src/gameDef.ts +++ b/packages/game/src/gameDef.ts @@ -10,128 +10,125 @@ import { MatchPlayerSettings, MatchPlayersSettings, MatchSettings, - Move, ScoreType, UserId, } from "@lefun/core"; import { Random } from "./random"; +import { IfNever } from "./typing"; -type EmptyObject = Record; +export type GameStateBase = { B: unknown; PB: unknown; SB: unknown }; -// We provide a default payload for when we don't need one (for example for moves -// without any options). This has the side effect of allowing for a missing -// payload when one should be required. -export const createMove =

( - name: string, -): [string, (payload?: P) => Move

] => { - const f = (payload: P = {} as any) => { - return { name, payload }; - }; - return [name, f]; +type BMTBase = Record; + +export type GameState = { + B: B; + PB: PB; + SB: SB; }; -export const DELAYED_MOVE = "lefun/delayedMove"; +type NoPayload = never; -/* - * Construct a delayed move "action" - */ -export const delayedMove =

( - move: Move

, - // Timestamp (using something like `new Date().getTime()`) - ts: number, -): DelayedMove

=> { - return { - type: DELAYED_MOVE, - ts, - move, - }; +type EmptyObject = Record; + +export const INIT_MOVE = "lefun/initMove"; + +export const ADD_PLAYER = "lefun/addPlayer"; +export type AddPlayerPayload = { + userId: UserId; }; -export type DelayedMove

= { - type: typeof DELAYED_MOVE; - // Time at which we want the move to be executed. - ts: number; - // The update itself. - move: Move

; +export const KICK_PLAYER = "lefun/kickPlayer"; +export type KickPlayerPayload = { + userId: UserId; }; +export const MATCH_WAS_ABORTED = "lefun/matchWasAborted"; + export type RewardPayload = { rewards: Record; stats?: Record>; }; -export type SpecialFuncs = { - delayMove: (move: Move, delay: number) => { ts: number }; +export type DelayMove = ( + moveName: K, + ...rest: IfNever +) => { ts: number }; + +export type SpecialFuncs = { + delayMove: DelayMove; itsYourTurn: (arg0: ItsYourTurnPayload) => void; endMatch: (arg0?: EndMatchOptions) => void; reward?: (options: RewardPayload) => void; logStat: (key: string, value: number) => void; }; -type ExecuteNowOptions = { +type ExecuteNowOptions = { userId: UserId; - board: B; + board: GS["B"]; // Assume that the game developer has defined the playerboard if they're using it. - playerboard: PB; + playerboard: GS["PB"]; payload: P; - delayMove: SpecialFuncs["delayMove"]; + delayMove: SpecialFuncs["delayMove"]; }; -export type ExecuteNow = ( - options: ExecuteNowOptions, +export type ExecuteNow = ( + options: ExecuteNowOptions, // TODO: We should support returning anything and it would be passed to `execute`. ) => void | false; -export type ExecuteOptions = { +export type ExecuteOptions = { userId: UserId; - board: B; + board: G["B"]; // Even though `playerboards` and `secretboard` are optional, we'll assume that the // game developer has defined them if they use them if their execute* functions! - playerboards: Record; - secretboard: SB; + playerboards: Record; + secretboard: G["SB"]; payload: P; random: Random; ts: number; gameData: any; matchData?: any; -} & SpecialFuncs; +} & SpecialFuncs; -export type Execute = ( - options: ExecuteOptions, +export type Execute = ( + options: ExecuteOptions, ) => void; -export type PlayerMove = { +// `any` doesn't seem to work but `infer ?` does. +export type GetPayloadOfPlayerMove = + // eslint-disable-next-line @typescript-eslint/no-unused-vars + PM extends PlayerMove ? P : never; + +export type PlayerMove< + G extends GameStateBase, + P = NoPayload, + BMT extends BMTBase = EmptyObject, +> = { canDo?: (options: { userId: UserId; - board: B; - playerboard: PB; + board: G["B"]; + playerboard: G["PB"]; payload: P; // We'll pass `null` on the client, where we don't have the server time. ts: number | null; }) => boolean; - executeNow?: ExecuteNow; - execute?: Execute; + executeNow?: ExecuteNow; + execute?: Execute; }; -export type BoardExecute = ( - options: Omit, "userId">, +export type BoardExecute = ( + options: Omit, "userId">, ) => void; -export type BoardMove = { - execute?: BoardExecute; +export type BoardMove< + G extends GameStateBase, + P = NoPayload, + BMT extends BMTBase = EmptyObject, +> = { + execute?: BoardExecute; }; -export type Moves = Record< - string, - PlayerMove ->; - -export type BoardMoves = Record< - string, - BoardMove ->; - export type InitialBoardsOptions = { players: UserId[]; matchSettings: MatchSettings; @@ -147,12 +144,12 @@ export type InitialBoardsOptions = { locale: Locale; }; -export type InitialPlayerboardOptions = { +export type InitialPlayerboardOptions = { userId: UserId; - board: B; - secretboard: SB; + board: G["B"]; + secretboard: G["SB"]; // Those are the playerboards for the *other* players. - playerboards: Record; + playerboards: Record; random: Random; gameData: any; matchData?: any; @@ -192,21 +189,21 @@ export type AutoMoveInfo = { time?: number; }; -export type AutoMoveRet

= +export type AutoMoveRet = | { - move: Move

; + move: PlayerMoveObj; duration?: number; } - | Move

; + | PlayerMoveObj; -type AutoMoveType = (arg0: { +type AutoMoveType = (arg0: { userId: UserId; - board: B; - playerboard: PB; - secretboard: SB; + board: GS["B"]; + playerboard: GS["PB"]; + secretboard: GS["SB"]; random: Random; returnAutoMoveInfo: boolean; -}) => AutoMoveRet

; +}) => AutoMoveRet; type GetAgent = (arg0: { matchSettings: MatchSettings; @@ -216,7 +213,7 @@ type GetAgent = (arg0: { export type AgentGetMoveRet

= { // The `move` that should be performed. - move: Move

; + move: PlayerMoveObj

; // Some info used for training. autoMoveInfo?: AutoMoveInfo; // How much "thinking time" should be pretend this move took. @@ -247,21 +244,29 @@ export type GetMatchScoreTextOptions = { board: B; }; +type PlayerMoveObj

= { name: string; payload: P }; + // This is what the game developer must implement. -export type GameDef = { - initialBoards: (options: InitialBoardsOptions) => { - board: B; - playerboards?: Record; - secretboard?: SB; +export type Game< + GS extends GameStateBase, + BMT extends BMTBase = EmptyObject, + PM extends Record> = any, + BM extends Record> = any, +> = { + initialBoards: (options: InitialBoardsOptions) => { + board: GS["B"]; + playerboards?: Record; + secretboard?: GS["SB"]; itsYourTurnUsers?: UserId[]; }; // There is a separate function for the `playerboard` for games that support adding a // player into an ongoing match. - initialPlayerboard?: (options: InitialPlayerboardOptions) => PB; + initialPlayerboard?: (options: InitialPlayerboardOptions) => GS["PB"]; // For those the key is the `name` of the move/update - moves: Moves; - boardMoves?: BoardMoves; + playerMoves: PM; + boardMoves?: BM; + gameSettings?: GameSettings; gamePlayerSettings?: GamePlayerSettings; @@ -276,22 +281,22 @@ export type GameDef = { playerScoreType?: ScoreType; // Games can customize the match score representation using this hook. - getMatchScoreText?: (options: GetMatchScoreTextOptions) => string; + getMatchScoreText?: (options: GetMatchScoreTextOptions) => string; // Return a move for a given state of the game for a player. This is used for bots and // could be used to play for an unactive user. // Not that technically we don't need the `secretboard` in here. In practice sometimes // we put data in the secretboard to optimize calculations. - autoMove?: AutoMoveType; - getAgent?: GetAgent; + autoMove?: AutoMoveType; + getAgent?: GetAgent; // Game-level bot move duration. botMoveDuration?: number; // Log the board to the console. This is mostly used for debugging. logBoard?: (options: { - board: B; - playerboards: Record; + board: GS["B"]; + playerboards: Record; }) => string; // Min/max number of players for the game. @@ -310,29 +315,29 @@ export type GameDef = { /* * Internal representation of the game definition. * - * When developing a game, use `GameDef` instead. + * When developing a game, use `Game` instead. */ -export type GameDef_ = Omit< - GameDef, +export type Game_ = Omit< + Game, "stats" > & { stats?: { allIds: string[]; - // Note that we don't have any flags for stats yet, hence the `Record`. - byId: Record>; + // Note that we don't have any flags for stats yet, hence the `EmptyObject`. + byId: Record; }; }; /* * Parse a @lefun/game game definition into our internal game definition. */ -export function parseGameDef( - gameDef: GameDef, -): GameDef_ { +export function parseGame( + game: Game, +): Game_ { // Normalize the stats. - const { stats: stats_ } = gameDef; + const { stats: stats_ } = game; - const stats: GameDef_["stats"] = { + const stats: Game_["stats"] = { allIds: [], byId: {}, }; @@ -343,33 +348,9 @@ export function parseGameDef( stats.byId[key] = {}; } } - return { ...gameDef, stats }; -} - -// -// Special Move -// - -// This is a special move that game developers can implement rules for. If they -// do, players will be able to join in the middle of a match. -export const [ADD_PLAYER, addPlayer] = createMove<{ - userId: UserId; -}>("lefun/addPlayerMove"); -// Note that there is also a kickFromMatch "action" in the 'common' package. -export const [KICK_PLAYER, kickPlayer] = createMove<{ - userId: UserId; -}>("lefun/kickPlayer"); - -// This is a special move that will be triggered at the start of the match. -// This way games can implement some logic before any player makes a move, for instance -// triggering a delayed move. -export const [INIT_MOVE, initMove] = createMove("lefun/initMove"); - -// Move triggered by the server when we need to abruptly end a match. -export const [MATCH_WAS_ABORTED, matchWasAborted] = createMove( - "lefun/matchWasAborted", -); + return { ...game, stats }; +} // Game Manifest export type GameManifest = { diff --git a/packages/game/src/random.ts b/packages/game/src/random.ts index 9d04775..9a5ea9c 100644 --- a/packages/game/src/random.ts +++ b/packages/game/src/random.ts @@ -1,7 +1,7 @@ import { sample, sampleSize, shuffle } from "lodash-es"; export class Random { - shuffled(array: any[]): any[] { + shuffled(array: T[]): T[] { // Use loadash for this one. return shuffle(array); } diff --git a/packages/game/src/testing.ts b/packages/game/src/testing.ts index 96150ed..60a1f89 100644 --- a/packages/game/src/testing.ts +++ b/packages/game/src/testing.ts @@ -10,32 +10,34 @@ import { metaItsYourTurn, metaMatchEnded, metaRemoveUserFromMatch, - Move, UserId, } from "@lefun/core"; import { - addPlayer, Agent, AgentGetMoveRet, AutoMoveInfo, AutoMoveRet, - DelayedMove, - delayedMove, - GameDef, - GameDef_, + Game, + Game_, + GameStateBase, + GetPayloadOfPlayerMove, INIT_MOVE, - initMove, - kickPlayer, MATCH_WAS_ABORTED, - matchWasAborted, - parseGameDef, + parseGame, RewardPayload, } from "./gameDef"; import { Random } from "./random"; +import { IfNever } from "./typing"; -type MatchTesterOptions = { - gameDef: GameDef; +type DelayedBoardMove = { + name: string; + payload: any; + ts: number; +}; + +type MatchTesterOptions = { + game: Game; gameData?: any; matchData?: any; numPlayers: number; @@ -68,18 +70,34 @@ type User = { type UsersState = { byId: Record }; +type MakeMoveRest< + G extends Game, + K extends keyof G["playerMoves"], +> = IfNever< + GetPayloadOfPlayerMove, + // + [] | [EmptyObject, { canFail?: boolean }], + // + | [GetPayloadOfPlayerMove] + | [GetPayloadOfPlayerMove, { canFail?: boolean }] +>; + /* * Use this to test your game rules. * It emulates what the backend does. */ -export class MatchTester { - gameDef: GameDef_; +export class MatchTester< + GS extends GameStateBase, + G extends Game, + BMT extends Record = any, +> { + game: Game_; gameData: any; matchData?: any; meta: Meta; - board: B; - playerboards: Record; - secretboard: SB; + board: GS["B"]; + playerboards: Record; + secretboard: GS["SB"]; users: UsersState; matchHasEnded: boolean; random: Random; @@ -88,7 +106,7 @@ export class MatchTester { // Clock used for delayedMoves - in ms. time: number; // List of timers to be executed. - delayedMoves: DelayedMove[]; + delayedMoves: DelayedBoardMove[]; // To help generate the next userIds. nextUserId: number; // Variables to check for infinite loops. @@ -101,11 +119,11 @@ export class MatchTester { // Are we using the MatchTester for training. // TODO We should probably use different classes for training and for testing. _training: boolean; - _agents: Record>; + _agents: Record>; _isPlaying: boolean; constructor({ - gameDef, + game, gameData = undefined, matchData = undefined, numPlayers, @@ -117,12 +135,12 @@ export class MatchTester { training = false, logBoardToTrainingLog = false, locale = "en", - }: MatchTesterOptions) { + }: MatchTesterOptions) { if (random == null) { random = new Random(); } - this.gameDef = parseGameDef(gameDef); + this.game = parseGame(game); const meta = metaInitialState({ matchSettings, locale }); @@ -146,8 +164,8 @@ export class MatchTester { this.nextUserId = numPlayers + numBots; // Default match options. - if (gameDef.gameSettings) { - for (const { key, options } of gameDef.gameSettings) { + if (game.gameSettings) { + for (const { key, options } of game.gameSettings) { // If the option was already defined, we don't do anything if (matchSettings[key] != null) { continue; @@ -174,7 +192,7 @@ export class MatchTester { // Loop through the users. for (let nthUser = 0; nthUser < meta.players.allIds.length; ++nthUser) { const opts: MatchPlayerSettings = {}; - Object.entries(gameDef.gamePlayerSettings || {}).forEach( + Object.entries(game.gamePlayerSettings || {}).forEach( ([key, optionDef]) => { if (optionDef.exclusive) { // For each user, given them the n-th option. Loop if there are less options @@ -208,9 +226,9 @@ export class MatchTester { const { board, playerboards = {}, - secretboard = {} as SB, + secretboard = {} as GS["SB"], itsYourTurnUsers = [], - } = gameDef.initialBoards({ + } = game.initialBoards({ players: meta.players.allIds, matchSettings, matchPlayersSettings, @@ -251,7 +269,7 @@ export class MatchTester { this._sameBotCount = 0; // Make the special initial move. - this.makeBoardMove(initMove()); + this._makeBoardMove("initMove"); this._botTrainingLog = []; this._stats = []; @@ -273,7 +291,7 @@ export class MatchTester { meta, playerboards, secretboard, - gameDef, + game, gameData, matchData, random, @@ -285,8 +303,8 @@ export class MatchTester { this.nextUserId++; metaAddUserToMatch({ meta, userId, ts: new Date(), isBot }); - if (gameDef.initialPlayerboard) { - playerboards[userId] = gameDef.initialPlayerboard({ + if (game.initialPlayerboard) { + playerboards[userId] = game.initialPlayerboard({ userId, board, playerboards, @@ -298,7 +316,7 @@ export class MatchTester { } // Trigger the game's logic. - this.makeBoardMove(addPlayer({ userId })); + this._makeBoardMove("addPlayer", { userId }); this.users.byId[userId] = { username, @@ -316,7 +334,7 @@ export class MatchTester { const { meta, playerboards } = this; // Trigger the game's logic. - this.makeBoardMove(kickPlayer({ userId })); + this._makeBoardMove("kickPlayer", { userId }); metaRemoveUserFromMatch(meta, userId); @@ -329,7 +347,7 @@ export class MatchTester { */ abortMatch(): void { this._endMatch({}); - this.makeBoardMove(matchWasAborted()); + this._makeBoardMove("matchWasAborted"); } /* @@ -340,7 +358,7 @@ export class MatchTester { _endMatch(endMatchOptions: EndMatchOptions): void { this.matchHasEnded = true; - metaMatchEnded(this.meta, endMatchOptions, this.gameDef.playerScoreType); + metaMatchEnded(this.meta, endMatchOptions, this.game.playerScoreType); // It's no-one's turn anymore. this.meta.players.allIds.forEach((userId) => { @@ -349,12 +367,12 @@ export class MatchTester { this._isPlaying = false; } - async makeMoveAndContinue( + async makeMoveAndContinue( userId: UserId, - move: Move, - { canFail = false }: { canFail?: boolean } = {}, + moveName: K, + ...rest: MakeMoveRest ) { - this.makeMove(userId, move, { canFail }); + this.makeMove(userId, moveName, ...rest); await this.makeNextBotMove(); } @@ -377,8 +395,15 @@ export class MatchTester { metaItsYourTurn(this.meta, payload); }; - const delayMove = (move: Move, delay: number) => { - const dm = delayedMove(move, this.time + delay); + const delayMove = ( + name: K, + ...payloadAndDelay: IfNever + ) => { + const [payload, delay] = + payloadAndDelay.length === 1 ? [{}, 0] : payloadAndDelay; + + // const { name, payload } = move; + const dm = { name, payload, ts: this.time + delay }; // In the match tester, we only note the delayed move. We'll execute them only if // we `fastForward`. this.delayedMoves.push(dm); @@ -392,10 +417,9 @@ export class MatchTester { return { delayMove, itsYourTurn, endMatch, reward, logStat }; } - makeBoardMove(move: Move) { - const { name, payload } = move; + _makeBoardMove(moveName: string, payload: any = {}) { const { - gameDef, + game, board, playerboards, secretboard, @@ -404,21 +428,21 @@ export class MatchTester { time, random, } = this; - const { boardMoves } = gameDef; + const { boardMoves } = game; - if (!boardMoves || !boardMoves[name]) { + if (!boardMoves || !boardMoves[moveName]) { // When the move is not defined, throw only if it's not one of our optional move. - if (![INIT_MOVE, MATCH_WAS_ABORTED].includes(name)) { - throw new Error(`board move ${name} not defined`); + if (![INIT_MOVE, MATCH_WAS_ABORTED].includes(moveName)) { + throw new Error(`board move ${moveName} not defined`); } return; } - const { execute } = boardMoves[name]; + const { execute } = boardMoves[moveName]; if (!execute) { - console.warn(`board move "${name}" not defined`); + console.warn(`board move "${moveName}" not defined`); return; } @@ -430,7 +454,7 @@ export class MatchTester { playerboards, // We trust that the game developer won't use the secretboard if it's not // defined! - secretboard: secretboard!, + secretboard, payload, gameData, matchData, @@ -439,17 +463,25 @@ export class MatchTester { ...specialExecuteFuncs, }); } catch (e) { - console.warn(`board move "${name}" failed with error`); + console.warn(`board move "${moveName}" failed with error`); console.warn(e); } } - makeMove( + makeMove( userId: UserId, - move: Move, - { canFail = false }: { canFail?: boolean } = {}, + moveName: K, + ...rest: MakeMoveRest ) { - const { name, payload } = move; + let payload: GetPayloadOfPlayerMove = {} as any; + let canFail: boolean = false; + + if (rest.length === 1) { + payload = rest[0]; + } else if (rest.length === 2) { + payload = rest[0] as any; + canFail = rest[1].canFail || false; + } const { board, @@ -458,13 +490,13 @@ export class MatchTester { gameData, matchData, random, - gameDef, + game, meta, time, } = this; - if (!gameDef.moves[name]) { - throw new Error(`game does not implement ${name}`); + if (!game.playerMoves[moveName]) { + throw new Error(`game does not implement ${moveName}`); } // Make sure the userId is in our list of users. @@ -472,24 +504,24 @@ export class MatchTester { throw new Error(`unknown userId ${userId}`); } - const { moves } = gameDef; + const { playerMoves } = game; - const moveDef = moves[name]; + const moveDef = playerMoves[moveName]; if (!moveDef) { - throw new Error(`unknown move ${name}`); + throw new Error(`unknown move ${moveName}`); } const playerboard = playerboards[userId]; - const { canDo, executeNow, execute } = gameDef.moves[name]; + const { canDo, executeNow, execute } = game.playerMoves[moveName]; if ( canDo !== undefined && !canDo({ userId, board, playerboard, payload, ts: time }) ) { if (!canFail) { - throw new Error(`can not do move "${name}"`); + throw new Error(`can not do move "${moveName}"`); } return; } @@ -504,7 +536,7 @@ export class MatchTester { retValue = executeNow({ userId, board, - playerboard: playerboard!, + playerboard, payload, delayMove, }); @@ -514,7 +546,7 @@ export class MatchTester { userId, board, playerboards, - secretboard: secretboard!, + secretboard, gameData, matchData, payload, @@ -537,17 +569,16 @@ export class MatchTester { throw new Error("already playing"); } this._isPlaying = true; - const { gameDef, meta, _agents, matchSettings, matchPlayersSettings } = - this; + const { game, meta, _agents, matchSettings, matchPlayersSettings } = this; const numPlayers = meta.players.allIds.length; // Initialize agents. // TODO remove the `if` when we deprecate `autoMove`. - if (gameDef.getAgent) { + if (game.getAgent) { for (const userId of meta.players.allIds) { if (meta.players.byId[userId].isBot) { - _agents[userId] = await gameDef.getAgent({ + _agents[userId] = await game.getAgent({ matchPlayerSettings: matchPlayersSettings[userId], matchSettings, numPlayers, @@ -559,7 +590,7 @@ export class MatchTester { } async makeNextBotMove() { - const { meta, gameDef, board, playerboards, secretboard, random } = this; + const { meta, game, board, playerboards, secretboard, random } = this; // Check if we should do a bot move. for ( let userIndex = 0; @@ -572,11 +603,8 @@ export class MatchTester { if (isBot && itsYourTurn) { let boardRepr: string | undefined = undefined; - if ( - (this._verbose || this._logBoardToTrainingLog) && - gameDef.logBoard - ) { - boardRepr = gameDef.logBoard({ board, playerboards }); + if ((this._verbose || this._logBoardToTrainingLog) && game.logBoard) { + boardRepr = game.logBoard({ board, playerboards }); if (this._verbose) { console.log("----------------------"); @@ -585,12 +613,11 @@ export class MatchTester { } } - // let autoMoveRet: ReturnType['getMove']>; let autoMoveRet: AutoMoveRet | AgentGetMoveRet; const t0 = new Date().getTime(); - if (gameDef.autoMove !== undefined) { + if (game.autoMove !== undefined) { // TODO deprecate the `autoMove` function in favor of the AutoMover class? - autoMoveRet = await gameDef.autoMove({ + autoMoveRet = await game.autoMove({ board, playerboard: playerboards[userId], secretboard: secretboard!, @@ -612,7 +639,7 @@ export class MatchTester { const thinkingTime = t1 - t0; - let move: Move | undefined; + let move: { name: string; payload: any } | undefined; let autoMoveInfo: AutoMoveInfo | undefined = undefined; if ("autoMoveInfo" in autoMoveRet) { @@ -653,9 +680,14 @@ export class MatchTester { } if (move) { + const { name, payload } = move; // We only play one bot move per call. The function will be called again if it's // another bot's turn after. - return await this.makeMoveAndContinue(userId, move); + return await this.makeMoveAndContinue( + userId, + name, + ...([payload] as any), + ); } } } @@ -686,12 +718,12 @@ export class MatchTester { const delayedMoves = this.delayedMoves.filter((du) => du.ts <= this.time); // Sort by time, but keep the order in case of equality. delayedMoves - .map((u, i) => [u, i] as [DelayedMove, number]) + .map((u, i) => [u, i] as [DelayedBoardMove, number]) .sort(([u1, i1], [u2, i2]) => Math.sign(u1.ts - u2.ts) || i1 - i2); for (const delayedMove of delayedMoves) { - const { move } = delayedMove; - this.makeBoardMove(move); + const { name, payload } = delayedMove; + this._makeBoardMove(name, payload); } } diff --git a/packages/game/src/typing.ts b/packages/game/src/typing.ts new file mode 100644 index 0000000..7d1a781 --- /dev/null +++ b/packages/game/src/typing.ts @@ -0,0 +1 @@ +export type IfNever = [T] extends [never] ? TRUE : FALSE; diff --git a/packages/ui-testing/src/index.tsx b/packages/ui-testing/src/index.tsx index 8f8997c..bc9c859 100644 --- a/packages/ui-testing/src/index.tsx +++ b/packages/ui-testing/src/index.tsx @@ -1,28 +1,28 @@ import { i18n } from "@lingui/core"; import { I18nProvider } from "@lingui/react"; import { render as rtlRender, RenderResult } from "@testing-library/react"; -import { ReactNode } from "react"; +import { ElementType, ReactNode } from "react"; import { createStore } from "zustand"; import { MatchState, setMakeMove, storeContext } from "@lefun/ui"; -export const render = ( - Board: any, - state: MatchState, +export function render( + Board: ElementType, + state: MatchState, locale: string = "en", -): RenderResult => { +): RenderResult { const userId = state.userId; // Sanity check if (userId == null) { throw new Error("userId should not be null"); } - const store = createStore>()(() => ({ + const store = createStore()(() => ({ ...state, })); // Simply create a store that always use our `state. - setMakeMove(() => {}); + setMakeMove(() => () => {}); i18n.loadAndActivate({ locale, @@ -38,4 +38,4 @@ export const render = ( ); }; return rtlRender(, { wrapper }); -}; +} diff --git a/packages/ui/rollup.config.js b/packages/ui/rollup.config.js index 8f162ff..1b267b9 100644 --- a/packages/ui/rollup.config.js +++ b/packages/ui/rollup.config.js @@ -4,7 +4,7 @@ import typescript from "rollup-plugin-typescript2"; export default { input: { - index: "src/index.tsx", + index: "src/index.ts", lefunExtractor: "src/lefunExtractor.ts", }, output: [ diff --git a/packages/ui/src/index.tsx b/packages/ui/src/index.ts similarity index 57% rename from packages/ui/src/index.tsx rename to packages/ui/src/index.ts index 22dc39b..920f3b0 100644 --- a/packages/ui/src/index.tsx +++ b/packages/ui/src/index.ts @@ -2,39 +2,75 @@ import { createContext, useContext, useMemo } from "react"; import { StoreApi, useStore as _useStore } from "zustand"; import { useShallow } from "zustand/react/shallow"; -import type { MatchState as _MatchState, Move, UserId } from "@lefun/core"; - -type EmptyObject = Record; +import type { MatchState as _MatchState, UserId } from "@lefun/core"; +import type { Game, GameStateBase, GetPayloadOfPlayerMove } 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 & { +export type MatchState = _MatchState< + GS["B"], + GS["PB"] +> & { userId: UserId; - board: B; - playerboard: PB; + board: GS["B"]; + playerboard: GS["PB"]; }; -export type Selector = (state: MatchState) => T; +export type Selector = ( + state: MatchState, +) => T; -export type Store = StoreApi>; +export type Store = StoreApi< + MatchState +>; export const storeContext = createContext(null); -let _makeMove: (store: Store) => (move: Move) => void; - -export function setMakeMove(makeMove: (move: Move, store: Store) => void) { - _makeMove = (store) => (move) => { - return makeMove(move, store); - }; +type IfNever = [T] extends [never] ? TRUE : FALSE; + +type MakeMove> = < + K extends keyof G["playerMoves"] & string, +>( + moveName: K, + ...payload: IfNever< + GetPayloadOfPlayerMove, + [], + [GetPayloadOfPlayerMove] + > +) => void; + +type MakeMoveFull> = < + K extends keyof G["playerMoves"] & string, +>( + moveName: K, + ...payload: IfNever< + GetPayloadOfPlayerMove, + [GetPayloadOfPlayerMove | undefined], + [GetPayloadOfPlayerMove] + > +) => void; + +let _makeMove: ((store: Store) => MakeMoveFull) | null = null; + +export function setMakeMove( + makeMove: (store: Store) => MakeMoveFull>, +) { + _makeMove = makeMove; } -export function useMakeMove(): (move: Move) => void { - if (!_makeMove) { +type GetGameStatesFromGame> = + G extends Game ? GS : never; + +export function useMakeMove>(): MakeMove { + const makeMove = _makeMove; + + if (!makeMove) { throw new Error( '"makeMove" not defined by the host. Did you forget to call `setMakeMove`?', ); } - const store = useContext(storeContext); + const store: Store> | null = + useContext(storeContext); if (store === null) { throw new Error( @@ -44,47 +80,59 @@ export function useMakeMove(): (move: Move) => void { // `_makeMove` returns a new function every time it's called, but we don't want to // re-render. - const makeMove = useMemo(() => _makeMove(store), [store]); + return useMemo(() => { + const makeMovefull = makeMove(store); + + function newMakeMove( + moveName: K, + ...payload: IfNever< + GetPayloadOfPlayerMove, + [], + [GetPayloadOfPlayerMove] + > + ) { + return makeMovefull(moveName, payload[0] || {}); + } - return makeMove; + return newMakeMove; + }, [store, makeMove]); } -/* - * Deprecated, use `useMakeMove` directly without any hooks. - */ -export function useDispatch(): (move: Move) => void { - return useMakeMove(); +export function makeUseMakeMove>() { + return useMakeMove; } /* * Main way to get data from the match state. */ -export function useSelector(selector: Selector): T { +export function useSelector( + selector: Selector, +): T { const store = useContext(storeContext); if (store === null) { throw new Error("Store is `null`, did you forget ?"); } - return _useStore(store as Store, selector); + return _useStore(store, selector); } /* Util to "curry" the types of useSelector<...> */ export const makeUseSelector = - () => - (selector: Selector) => - useSelector(selector); + () => + (selector: Selector) => + useSelector(selector); /* Util to "curry" the types of useSelectorShallow<...> */ export const makeUseSelectorShallow = - () => - (selector: Selector) => - useSelectorShallow(selector); + () => + (selector: Selector) => + useSelectorShallow(selector); /* * Same as `useSelector` but will use a shallow equal on the output to decide if a render * is required or not. */ -export function useSelectorShallow( - selector: Selector, +export function useSelectorShallow( + selector: Selector, ): T { return useSelector(useShallow(selector)); } @@ -92,11 +140,13 @@ export function useSelectorShallow( /* * Util to check if the user is a player (if not they are a spectator). */ -export const useIsPlayer = () => { +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: _MatchState) => { + return !!state.playerboard; + }, + ); return hasPlayerboard; }; @@ -138,12 +188,12 @@ 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: _MatchState) => state.timeDelta || 0, ); const latency = useSelector( - (state: _MatchState) => state.timeLatency || 0, + (state: _MatchState) => state.timeLatency || 0, ); return toClientTime(delta, latency); @@ -184,8 +234,10 @@ export const playSound = (name: string) => { /* * Util to get a username given its userId */ -export const useUsername = (userId?: UserId): string | undefined => { - const username = useSelector((state: _MatchState) => { +export const useUsername = ( + userId?: UserId, +): string | undefined => { + const username = useSelector((state: _MatchState) => { return userId ? state.users.byId[userId]?.username : undefined; }); return username; @@ -194,9 +246,9 @@ export const useUsername = (userId?: UserId): string | undefined => { /* * Return a userId: username mapping. */ -export const useUsernames = (): Record => { +export const useUsernames = (): Record => { // Note the shallow-compared selector. - const usernames = useSelectorShallow((state: _MatchState) => { + const usernames = useSelectorShallow((state: _MatchState) => { const users = state.users.byId; const usernames: { [userId: string]: string } = {}; for (const [userId, { username }] of Object.entries(users)) { @@ -223,8 +275,8 @@ export const useMyUserId = () => { * const x = store.getState().board.x * ``` * */ -export function useStore() { - const store = useContext(storeContext) as Store; +export function useStore() { + const store = useContext(storeContext) as Store; if (store === null) { throw new Error("Store is `null`, did you forget ?"); } @@ -232,4 +284,4 @@ export function useStore() { } /* Convenience function to get a typed `useStore` hook. */ -export const makeUseStore = () => useStore; +export const makeUseStore = () => useStore; diff --git a/packages/ui/src/lefunExtractor.ts b/packages/ui/src/lefunExtractor.ts index f6a1af0..5f22830 100644 --- a/packages/ui/src/lefunExtractor.ts +++ b/packages/ui/src/lefunExtractor.ts @@ -1,9 +1,9 @@ import { extractor as defaultExtractor } from "@lingui/cli/api"; import { gameMessageKeys } from "@lefun/core"; -import { GameDef } from "@lefun/game"; +import { Game } from "@lefun/game"; -export const lefunExtractor = (game: GameDef) => ({ +export const lefunExtractor = (game: Game) => ({ first: true, match(filename: string) { const extensions = [".ts", ".tsx"]; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 40388c9..a8eb430 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,18 +24,21 @@ importers: '@lefun/game': specifier: workspace:* version: link:../../packages/game - '@rollup/plugin-typescript': - specifier: ^11.1.6 - version: 11.1.6(rollup@4.18.1)(tslib@2.6.3)(typescript@5.5.3) rollup: specifier: ^4.18.1 version: 4.18.1 + rollup-plugin-typescript2: + specifier: ^0.36.0 + version: 0.36.0(rollup@4.18.1)(typescript@5.5.3) tslib: specifier: ^2.6.3 version: 2.6.3 typescript: specifier: ^5.5.3 version: 5.5.3 + vitest: + specifier: ^1.6.0 + version: 1.6.0(@types/node@20.14.10) game-template/ui: dependencies: @@ -82,9 +85,6 @@ importers: '@rollup/plugin-node-resolve': specifier: ^15.2.3 version: 15.2.3(rollup@4.18.1) - '@rollup/plugin-typescript': - specifier: ^11.1.6 - version: 11.1.6(rollup@4.18.1)(tslib@2.6.3)(typescript@5.5.3) '@types/react': specifier: ^18.3.3 version: 18.3.3 @@ -109,6 +109,9 @@ importers: rollup-plugin-postcss: specifier: ^4.0.2 version: 4.0.2(postcss@8.4.39) + rollup-plugin-typescript2: + specifier: ^0.36.0 + version: 0.36.0(rollup@4.18.1)(typescript@5.5.3) typescript: specifier: ^5.5.3 version: 5.5.3 @@ -157,12 +160,6 @@ importers: packages/dev-server: dependencies: - '@lingui/core': - specifier: ^4.11.2 - version: 4.11.2 - '@lingui/react': - specifier: ^4.11.2 - version: 4.11.2(react@18.3.1) classnames: specifier: ^2.5.1 version: 2.5.1 @@ -185,6 +182,12 @@ importers: '@lefun/ui': specifier: workspace:* version: link:../ui + '@lingui/core': + specifier: ^4.11.2 + version: 4.11.2 + '@lingui/react': + specifier: ^4.11.2 + version: 4.11.2(react@18.3.1) '@rollup/plugin-commonjs': specifier: ^25.0.8 version: 25.0.8(rollup@4.18.1) @@ -8828,6 +8831,16 @@ snapshots: tslib: 2.6.3 typescript: 5.5.3 + rollup-plugin-typescript2@0.36.0(rollup@4.18.1)(typescript@5.5.3): + 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.3 + rollup-pluginutils@2.8.2: dependencies: estree-walker: 0.6.1