From 6884ddde9e645db126001061816bd963873e9f60 Mon Sep 17 00:00:00 2001 From: Simon Lemieux <1105380+simlmx@users.noreply.github.com> Date: Mon, 19 Aug 2024 13:25:14 +0000 Subject: [PATCH] Add match settings in dev-server Also: * Rename default -> isDefault in game setting (`default` being a JS reserved keyword it was annoying in some places) * Normalize the game settings in `parseGame` --- games/game1-v2.3.0/game/src/index.test.ts | 2 +- games/game1-v2.3.0/game/src/index.ts | 61 ++- games/game1-v2.3.0/ui/src/Board.tsx | 31 +- games/game1-v2.3.0/ui/src/index.css | 49 +- games/game1-v2.3.0/ui/src/main.tsx | 2 +- packages/core/src/types.ts | 25 +- packages/dev-server/src/App.tsx | 594 +++++++++++++++------- packages/dev-server/src/match.ts | 105 ++-- packages/dev-server/src/store.ts | 50 +- packages/game/src/gameDef.ts | 26 +- packages/game/src/testing.ts | 2 +- 11 files changed, 688 insertions(+), 259 deletions(-) diff --git a/games/game1-v2.3.0/game/src/index.test.ts b/games/game1-v2.3.0/game/src/index.test.ts index dc4c8fd..4ddf487 100644 --- a/games/game1-v2.3.0/game/src/index.test.ts +++ b/games/game1-v2.3.0/game/src/index.test.ts @@ -2,7 +2,7 @@ import { expect, test } from "vitest"; import { MatchTester as _MatchTester, MatchTesterOptions } from "@lefun/game"; -import { autoMove, game, RollGame as G, RollGameState as GS } from "."; +import { autoMove, G, game, GS } from "."; class MatchTester extends _MatchTester { constructor(options: Omit, "game" | "autoMove">) { diff --git a/games/game1-v2.3.0/game/src/index.ts b/games/game1-v2.3.0/game/src/index.ts index 5496997..b796744 100644 --- a/games/game1-v2.3.0/game/src/index.ts +++ b/games/game1-v2.3.0/game/src/index.ts @@ -1,4 +1,4 @@ -import { UserId } from "@lefun/core"; +import { GamePlayerSettings, GameSettings, UserId } from "@lefun/core"; import { AutoMove, BoardMove, @@ -21,9 +21,12 @@ export type Board = { sum: number; lastSomeBoardMoveValue?: number; + + matchSettings: Record; + matchPlayersSettings: Record>; }; -export type RollGameState = GameState; +export type GS = GameState; type MoveWithArgPayload = { someArg: string }; type BoardMoveWithArgPayload = { someArg: number }; @@ -47,7 +50,7 @@ const playerStats = [ ] as const satisfies GameStats; type PM = PlayerMove< - RollGameState, + GS, Payload, PMT, BMT, @@ -71,7 +74,10 @@ const roll: PM = { logPlayerStat, endMatch, }) { - const diceValue = random.d6(); + 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; @@ -102,7 +108,7 @@ const roll: PM = { }, }; -type BM

= BoardMove; +type BM

= BoardMove; const initMove: BM = { execute({ board, turns }) { @@ -122,8 +128,41 @@ const someBoardMoveWithArgs: BM = { }, }; +const gameSettings: GameSettings = [ + { + key: "setting1", + options: [{ value: "a" }, { value: "b" }], + }, + { key: "setting2", options: [{ value: "x" }, { value: "y" }] }, +]; + +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" }, { value: "20" }], + }, +]; + export const game = { - initialBoards({ players }) { + initialBoards({ players, matchSettings, matchPlayersSettings }) { return { board: { sum: 0, @@ -132,6 +171,8 @@ export const game = { ), playerOrder: [...players], currentPlayerIndex: 0, + matchSettings, + matchPlayersSettings, }, }; }, @@ -141,11 +182,13 @@ export const game = { maxPlayers: 10, matchStats, playerStats, -} satisfies Game; + gameSettings, + gamePlayerSettings, +} satisfies Game; -export type RollGame = typeof game; +export type G = typeof game; -export const autoMove: AutoMove = ({ random }) => { +export const autoMove: AutoMove = ({ random }) => { if (random.d2() === 1) { return ["moveWithArg", { someArg: "123" }]; } diff --git a/games/game1-v2.3.0/ui/src/Board.tsx b/games/game1-v2.3.0/ui/src/Board.tsx index 92e8f85..c5003f5 100644 --- a/games/game1-v2.3.0/ui/src/Board.tsx +++ b/games/game1-v2.3.0/ui/src/Board.tsx @@ -12,22 +12,23 @@ import { useUsername, } from "@lefun/ui"; -import type { RollGame, RollGameState } from "game1-v2.3.0-game"; +import type { G, GS } from "game1-v2.3.0-game"; -// Dice symbol characters -const DICE = ["", "\u2680", "\u2681", "\u2682", "\u2683", "\u2684", "\u2685"]; - -const useSelector = makeUseSelector(); -const useSelectorShallow = makeUseSelectorShallow(); -const useMakeMove = makeUseMakeMove(); +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} + {username}
); @@ -42,9 +43,10 @@ function Die({ userId }: { userId: UserId }) { ); return ( - - {isRolling || !diceValue ? "?" : DICE[diceValue]} - +
+ Dice Value:{" "} + {isRolling || !diceValue ? "?" : diceValue} +
); } @@ -54,12 +56,19 @@ function Board() { 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) => ( ))} diff --git a/games/game1-v2.3.0/ui/src/index.css b/games/game1-v2.3.0/ui/src/index.css index fd7cd36..448cfde 100644 --- a/games/game1-v2.3.0/ui/src/index.css +++ b/games/game1-v2.3.0/ui/src/index.css @@ -26,10 +26,53 @@ button { } .dice { - margin: 0 0 0 10px; - font-size: 3rem; + font-weight: bold; + font-size: 1.2rem; } .player { - height: 100px; + display: flex; + flex-direction: column; + justify-content: center; + height: 80px; +} + +.red { + color: red; +} + +.blue { + color: blue; +} + +.green { + color: green; +} + +.orange { + color: orange; +} + +.pink { + color: pink; +} + +.brown { + color: brown; +} + +.black { + color: black; +} + +.darkgreen { + color: darkgreen; +} + +.darkred { + color: darkred; +} + +.purple { + color: purple; } diff --git a/games/game1-v2.3.0/ui/src/main.tsx b/games/game1-v2.3.0/ui/src/main.tsx index 6504df9..7a0ab0d 100644 --- a/games/game1-v2.3.0/ui/src/main.tsx +++ b/games/game1-v2.3.0/ui/src/main.tsx @@ -16,5 +16,5 @@ render({ }, game, messages: { en, fr }, - gameId: "roll", + gameId: "game1-v2.3.0", }); diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 868cf16..fc1b2a2 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -16,7 +16,7 @@ export const LOCALES: Locale[] = ["fr", "en"]; export type GameSettingOption = { value: string; // Is it the default option. If it's missing we'll take the first one. - default?: boolean; + isDefault?: boolean; // Keeping as optional for backward compatibility. label?: string; @@ -51,6 +51,11 @@ export type GameSetting = { export type GameSettings = GameSetting[]; +export type GameSettings_ = { + allIds: string[]; + byId: Record; +}; + /* * Fields common to all the player setting options. */ @@ -58,7 +63,7 @@ export type CommonPlayerSettingOption = { value: string; // Is it the default option? If none is the default, we will fallback on the first // player option as the default. - default?: boolean; + isDefault?: boolean; }; type ColorPlayerSettingOption = { @@ -75,6 +80,7 @@ type StringPlayerSettingOption = { // Note that some fields are common to all types of game player setting, and some // depend on the type. export type GamePlayerSetting = { + key: string; // Can different players have the same selected option? // By default we assume *not* exclusive. exclusive?: boolean; @@ -94,15 +100,12 @@ export type GamePlayerSetting = { | { type: "string"; options: StringPlayerSettingOption[] } ); -// FIXME This should be a list, to get an order, like the game options. This will be a problem when we have -// more than one option (which is not the case yet!). -// But at the same time being able to query the option using a string is useful, and -// it's missing in the Game Options. Ideally find a way to have the best of both worlds, -// for both the (regular) options and the player options... without making it to -// cumbersome for the game developer! We probably want to internally build a allIds/byId -// scheme from the list of options, and split the "game player options DEF" with the -// "gamePlayerSettings" that we store. -export type GamePlayerSettings = Record; +export type GamePlayerSettings = GamePlayerSetting[]; + +export type GamePlayerSettings_ = { + allIds: string[]; + byId: Record; +}; export type GameStatType = | "integer" diff --git a/packages/dev-server/src/App.tsx b/packages/dev-server/src/App.tsx index 23f1fcd..55352e9 100644 --- a/packages/dev-server/src/App.tsx +++ b/packages/dev-server/src/App.tsx @@ -10,8 +10,16 @@ import { ReactNode, RefObject, useEffect, useRef, useState } from "react"; import { createRoot } from "react-dom/client"; import { createStore as _createStore } from "zustand"; -import type { Locale, MatchSettings, UserId, UsersState } from "@lefun/core"; -import { Game, MoveSideEffects } from "@lefun/game"; +import type { + GameId, + GameSetting, + Locale, + MatchPlayersSettings, + MatchSettings, + UserId, + UsersState, +} from "@lefun/core"; +import { Game, Game_, MoveSideEffects, parseGame } from "@lefun/game"; import { setMakeMove, Store, storeContext } from "@lefun/ui"; import { @@ -38,12 +46,14 @@ const BoardForPlayer = ({ userId, messages, locale, + gameId, }: { board: any; match: Match; userId: UserId | "spectator"; messages: Record; locale: Locale; + gameId: GameId; }) => { useEffect(() => { i18n.loadAndActivate({ locale, messages }); @@ -155,12 +165,12 @@ const BoardForPlayer = ({ } match.makeMove(userId, moveName, payload); - saveMatchToLocalStorage(match, match.gameId); + saveMatchToLocalStorage(match, gameId); }); storeRef.current = store; setLoading(false); - }, [userId, match]); + }, [userId, match, gameId]); if (loading) { return
Loading player...
; @@ -256,6 +266,172 @@ function MatchStateView() { ); } +function MatchSetting({ + matchValue, + gameSetting, +}: { + matchValue: string; + gameSetting: GameSetting; +}) { + const resetMatch = useStore((state) => state.resetMatch); + const matchSettings = useStore((state) => state.match?.matchSettings); + + if (!matchSettings) { + return null; + } + + const { key, options } = gameSetting; + return ( +
+ + +
+ ); +} + +function SettingsSection({ children }: { children: ReactNode }) { + return ( +
+
+ {children} +
+
+ ); +} + +function MatchSettingsView() { + const game = useStore((state) => state.game); + const matchSettings = useStore((state) => state.match?.matchSettings); + + const { gameSettings } = game; + + if (!gameSettings || !matchSettings) { + return null; + } + + return ( + + {gameSettings.allIds.map((key) => ( + + ))} + + ); +} + +function MatchPlayerSetting({ + userId, + gameSetting, + matchValue, +}: { + userId: UserId; + gameSetting: GameSetting; + matchValue: string; +}) { + const resetMatch = useStore((state) => state.resetMatch); + const matchPlayersSettings = useStore( + (state) => state.match?.matchPlayersSettings, + ); + + if (!matchPlayersSettings) { + return null; + } + + const { key, options } = gameSetting; + return ( +
+ + +
+ ); +} +function MatchPlayerSettings({ userId }: { userId: UserId }) { + const game = useStore((state) => state.game); + const { gamePlayerSettings } = game; + const matchPlayersSettings = useStore( + (state) => state.match?.matchPlayersSettings, + ); + + const username = useStore((state) => state.match?.players[userId]?.username); + + if (!gamePlayerSettings || !matchPlayersSettings) { + return null; + } + + return ( +
+ + {gamePlayerSettings.allIds.map((key) => ( + + ))} +
+ ); +} + +function PlayerSettingsView() { + const match = useStore((state) => state.match); + if (!match) { + return null; + } + const { players } = match; + + const userIds = Object.keys(players); + + return ( + +
+ {userIds.map((userId) => ( + + ))} +
+
+ ); +} + const Button = ({ onClick, children, @@ -295,12 +471,11 @@ function capitalize(s: string): string { return s && s[0].toUpperCase() + s.slice(1); } -function Settings() { +function SettingsButtons() { const setLayout = useStore((state) => state.setLayout); const toggleCollapsed = useStore((state) => state.toggleCollapsed); const toggleShowDimensions = useStore((state) => state.toggleShowDimensions); const layout = useStore((state) => state.layout); - const collapsed = useStore((state) => state.collapsed); const locales = useStore((state) => state.locales); const locale = useStore((state) => state.locale); const setLocale = useStore((state) => state.setLocale); @@ -320,6 +495,106 @@ function Settings() { const userIds = Object.keys(players); const numPlayers = userIds.length; + return ( +
+ + + + + {(["game", "rules"] as const).map((v) => ( + + ))} + + + + {locales.map((otherLocale) => ( + + ))} + + {view === "game" && ( + <> + + + {userIds.map((userId) => ( + + ))} + + + + + + + + + + + + )} +
+ ); +} + +function Settings() { + const toggleCollapsed = useStore((state) => state.toggleCollapsed); + const collapsed = useStore((state) => state.collapsed); + const view = useStore((state) => state.view); if (collapsed) { return ( @@ -332,102 +607,24 @@ function Settings() { return (
-
- - - - - {(["game", "rules"] as const).map((v) => ( - - ))} - - - - {locales.map((otherLocale) => ( - - ))} - - {view === "game" && ( - <> - - - {userIds.map((userId) => ( - - ))} - - - - - - - - - - - - )} -
- {view === "game" && } + + {view === "game" && ( + <> +
+ +
+
+ +
+
+ +
+ + )}
); } @@ -563,60 +760,108 @@ const initMatch = ({ game, matchData, gameData, - matchSettings, locale, + matchSettings, + matchPlayersSettings, numPlayers, - tryToLoadFromLocaleStorage = false, - gameId, }: { - game: Game; + game: Game_; matchData: unknown; gameData: unknown; - matchSettings: MatchSettings; - locale: Locale; - numPlayers: number; - tryToLoadFromLocaleStorage?: boolean; - gameId?: string; + locale?: Locale; + matchSettings?: MatchSettings; + matchPlayersSettings?: MatchPlayersSettings; + numPlayers?: number; }) => { - let match: Match | null = null; + numPlayers ??= game.minPlayers; + locale ??= "en"; - if (tryToLoadFromLocaleStorage) { - match = loadMatchFromLocalStorage(game, gameId); - } + const { gameSettings, gamePlayerSettings } = game; - if (match === null) { - const userIds = getUserIds(numPlayers); - - 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, - }, - ]), - ); + if (!matchSettings) { + matchSettings = {}; - // A different color per player. - // TODO This is game specific! - const matchPlayersSettings = Object.fromEntries( - userIds.map((userId, i) => [userId, { color: i.toString() }]), - ); + if (gameSettings) { + matchSettings = Object.fromEntries( + gameSettings.allIds.map((key) => { + for (const { value, isDefault } of gameSettings.byId[key].options) { + if (isDefault) { + return [key, value]; + } + } + return [key, gameSettings.byId[key].options[0].value]; + }), + ); + } + } - match = new Match({ - game, - matchSettings, - matchPlayersSettings, - matchData, - gameData, - players, - locale, - gameId, - }); + const userIds = getUserIds(numPlayers); + + if (!matchPlayersSettings) { + matchPlayersSettings = {}; + if (gamePlayerSettings) { + matchPlayersSettings = Object.fromEntries( + userIds.map((userId) => [userId, {}]), + ); + + for (const key of gamePlayerSettings.allIds) { + const taken = new Set(); + + 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; + taken.add(value); + found = true; + break; + } + + if (isDefault && !exclusive) { + matchPlayersSettings[userId][key] = value; + found = true; + break; + } + } + if (!found) { + if (exclusive) { + throw new Error( + `Not enough options for exclusive player setting "${key}"`, + ); + } else { + // Fallback on the first option. + matchPlayersSettings[userId][key] = options[0].value; + } + } + } + } + } } + 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, + }, + ]), + ); + + const match = new Match({ + game, + matchSettings, + matchPlayersSettings, + matchData, + gameData, + players, + locale, + }); + return match; }; @@ -624,22 +869,20 @@ async function render({ game, board, rules, - matchSettings = {}, matchData, gameData, idName = "home", messages = { en: {} }, - gameId = undefined, + gameId = "unknown-game-id", }: { - game: Game; + game: Game; board: () => Promise; rules?: () => Promise; - matchSettings?: MatchSettings; matchData?: any; gameData?: any; idName?: string; messages?: AllMessages; - gameId?: string; + gameId: string; }) { function renderComponent(content: ReactNode) { const container = document.getElementById(idName); @@ -647,6 +890,8 @@ async function render({ return root.render(content); } + const game_ = parseGame(game); + const urlParams = new URLSearchParams(window.location.search); let locale = urlParams.get("l") as Locale; const isRules = urlParams.get("v") === "rules"; @@ -674,6 +919,7 @@ async function render({ userId={userId} messages={messages[locale]} locale={locale} + gameId={gameId} /> ); @@ -687,6 +933,7 @@ async function render({ const locales = (Object.keys(messages) || ["en"]) as Locale[]; const store = createStore({ locales, + game: game_, }); // sanity check @@ -697,30 +944,29 @@ async function render({ locale = store.getState().locale; const resetMatch = ({ - tryToLoadFromLocaleStorage = false, numPlayers, locale, + matchSettings, + matchPlayersSettings, }: { - tryToLoadFromLocaleStorage?: boolean; numPlayers?: number; locale?: Locale; - }) => { - { - const match = store.getState().match; - numPlayers ??= - Object.keys(match?.players || {}).length || game.minPlayers; - } + matchSettings?: MatchSettings; + matchPlayersSettings?: MatchPlayersSettings; + } = {}) => { + + let match = store.getState().match; - locale ??= store.getState().locale; - const match = initMatch({ - game, + match = initMatch({ + game: game_, matchData, gameData, - matchSettings, - locale, - numPlayers, - tryToLoadFromLocaleStorage, - gameId, + locale: locale || match?.locale, + numPlayers: numPlayers || match?.numPlayers, + matchSettings: matchSettings || match?.matchSettings, + matchPlayersSettings: + matchPlayersSettings || + (numPlayers === undefined ? match?.matchPlayersSettings : undefined), }); // We use `window.lefun` to communicate between the host and the player boards. @@ -731,13 +977,21 @@ async function render({ store.setState(() => ({ match })); }; - store.setState(() => ({ resetMatch })); - - resetMatch({ - tryToLoadFromLocaleStorage: true, - locale, - numPlayers: game.minPlayers, - }); + 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({ + game: game_, + matchData, + gameData, + }); + } + store.setState(() => ({ match })); + (window as any).lefun = { match }; + } // We import the CSS using the package name because this is what will be needed by packages importing this. // @ts-expect-error Make typescript happy. diff --git a/packages/dev-server/src/match.ts b/packages/dev-server/src/match.ts index 8244a03..890521f 100644 --- a/packages/dev-server/src/match.ts +++ b/packages/dev-server/src/match.ts @@ -8,7 +8,7 @@ import { User, UserId, } from "@lefun/core"; -import { Game, GameStateBase, MoveSideEffects, Random } from "@lefun/game"; +import { Game_, MoveSideEffects, Random } from "@lefun/game"; type State = { board: unknown; @@ -16,20 +16,29 @@ type State = { secretboard: unknown; }; -type G = Game; - // 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. +// FIXME Don't forget const VERSION = 1; class Match extends EventTarget { random: Random; - game: G; + game: Game_; + // gameId: string; + // These will be serialized with the Match. players: Record; matchData: unknown; gameData: unknown; - gameId?: string; + matchSettings: MatchSettings; + matchPlayersSettings: MatchPlayersSettings; + // Locale at the time of creating the match. This is not necessarily the same as the + // current selected locale. + locale: Locale; + + get numPlayers() { + return Object.keys(this.players).length; + } // Store that represents the backend. // We need to put it in a zustand Store because we want the JSON view in the right @@ -43,19 +52,19 @@ class Match extends EventTarget { matchPlayersSettings, matchData, gameData, - gameId, + // gameId, locale, // state, }: { - game: G; + game: Game_; players: Record; - matchSettings?: MatchSettings; - matchPlayersSettings?: MatchPlayersSettings; - matchData?: unknown; - gameData?: unknown; - gameId?: string; - locale?: Locale; + matchSettings: MatchSettings; + matchPlayersSettings: MatchPlayersSettings; + matchData: unknown; + gameData: unknown; + // gameId: string; + locale: Locale; // state?: State; }) { @@ -63,33 +72,20 @@ class Match extends EventTarget { const random = new Random(); this.random = random; - this.game = game; this.players = players; this.matchData = matchData; this.gameData = gameData; - this.gameId = gameId; + // this.gameId = gameId; + this.matchSettings = matchSettings; + this.matchPlayersSettings = matchPlayersSettings; + this.locale = locale; + // + // this.locale = locale; if (state) { this.store = createStore(() => state as State); } else { - // If no saved `state was provided, we need to create everything from scratch. - if (!matchSettings) { - throw new Error("match settings required"); - } - - if (!matchPlayersSettings) { - throw new Error("match players settings required"); - } - - if (!locale) { - throw new Error("locale required"); - } - - if (!players) { - throw new Error("players required"); - } - const areBots = Object.fromEntries( Object.entries(players).map(([userId, { isBot }]) => [userId, !!isBot]), ); @@ -126,6 +122,8 @@ class Match extends EventTarget { secretboard, })); } + + // tis.gameId = gameId; } makeMove(userId: UserId, moveName: string, payload: any) { @@ -212,7 +210,7 @@ class Match extends EventTarget { } if (execute) { - const { store, random, matchData, gameData } = this; + const { matchData, gameData, random, store } = this; store.setState((state: State) => { const [newState, patches] = produceWithPatches( state, @@ -262,27 +260,53 @@ class Match extends EventTarget { serialize(): string { const state = this.store.getState(); - const { players, matchData, gameData, gameId } = this; + const { + players, + matchData, + gameData, + matchSettings, + matchPlayersSettings, + } = this; return JSON.stringify({ state, players, matchData, gameData, - gameId, + // gameId, + matchSettings, + matchPlayersSettings, version: VERSION, }); } - static deserialize(str: string, game: Game): Match { + static deserialize(str: string, game: Game_): Match { const obj = JSON.parse(str); - const { state, players, matchData, gameData, gameId, version } = obj; + const { + state, + players, + matchData, + gameData, + matchSettings, + matchPlayersSettings, + locale, + version, + } = obj; // Currently we don't even try to maintain backward compatiblity here. if (version !== VERSION) { throw new Error(`unsupported version ${version}`); } - return new Match({ state, players, matchData, gameData, game, gameId }); + return new Match({ + state, + players, + matchData, + gameData, + game, + matchSettings, + matchPlayersSettings, + locale, + }); } } @@ -330,10 +354,7 @@ function saveMatchToLocalStorage(match: Match, gameId?: string) { localStorage.setItem(matchKey(gameId), match.serialize()); } -function loadMatchFromLocalStorage( - game: Game, - gameId?: string, -): Match | null { +function loadMatchFromLocalStorage(game: Game_, gameId?: string): Match | null { const str = localStorage.getItem(matchKey(gameId)); if (!str) { diff --git a/packages/dev-server/src/store.ts b/packages/dev-server/src/store.ts index 26b8e00..9d4d482 100644 --- a/packages/dev-server/src/store.ts +++ b/packages/dev-server/src/store.ts @@ -5,7 +5,13 @@ import { createContext, useContext } from "react"; import { createStore as _createStore, useStore as _useStore } from "zustand"; -import type { Locale, UserId } from "@lefun/core"; +import type { + Locale, + // MatchPlayersSettings, + // MatchSettings, + UserId, +} from "@lefun/core"; +import { Game_ } from "@lefun/game"; import type { Match } from "./match"; @@ -16,6 +22,8 @@ const KEYS_TO_LOCAL_STORAGE: (keyof State)[] = [ "layout", "collapsed", "view", + // "matchSettings", + // "matchPlayersSettings", ]; type Layout = "row" | "column"; @@ -30,14 +38,25 @@ type State = { locale: Locale; locales: Locale[]; view: View; + // matchSettings?: MatchSettings; + // matchPlayersSettings?: MatchPlayersSettings; + game: Game_; + match: Match | undefined; + // toggleShowDimensions: () => void; toggleCollapsed: () => void; setLayout: (layout: Layout) => void; setLocale: (locale: Locale) => void; setVisibleUserId: (userId: UserId | "all" | "spectator") => void; setView: (view: View) => void; - resetMatch: (arg0: { locale: Locale; numPlayers?: number }) => void; - match: Match | undefined; + resetMatch: (arg0?: { + locale?: Locale; + numPlayers?: number; + matchSettings?: any; + matchPlayersSettings?: any; + }) => void; + // setMatchSettingValue: (key: string, value: any) => void; + // setMatchPlayerSettingValue(userId: UserId, key: string, value: any): void; }; function saveToLocalStorage(state: Partial): void { @@ -54,7 +73,7 @@ function loadFromLocalStorage(store: ReturnType) { store.setState(obj); } -function createStore({ locales }: { locales: Locale[] }) { +function createStore({ locales, game }: { locales: Locale[]; game: Game_ }) { const store = _createStore()((set) => ({ collapsed: false, layout: "row", @@ -64,6 +83,9 @@ function createStore({ locales }: { locales: Locale[] }) { locales, view: "game", match: undefined, + // matchSettings: undefined, + // matchPlayersSettings: undefined, + game, resetMatch: () => { // }, @@ -91,6 +113,26 @@ function createStore({ locales }: { locales: Locale[] }) { set({ view }); saveToLocalStorage(store.getState()); }, + /* + setMatchSettingValue: (key: string, value: any) => { + set((state) => ({ + matchSettings: { ...state.matchSettings, [key]: value }, + })); + saveToLocalStorage(store.getState()); + }, + setMatchPlayerSettingValue: (userId: UserId, key: string, value: any) => { + set((state) => ({ + matchPlayersSettings: { + ...state.matchPlayersSettings, + [userId]: { + ...(state.matchPlayersSettings || {})[userId], + [key]: value, + }, + }, + })); + saveToLocalStorage(store.getState()); + }, + */ })); loadFromLocalStorage(store); diff --git a/packages/game/src/gameDef.ts b/packages/game/src/gameDef.ts index fbabaad..0f01ff5 100644 --- a/packages/game/src/gameDef.ts +++ b/packages/game/src/gameDef.ts @@ -2,7 +2,9 @@ import { AkaType, Credits, GamePlayerSettings, + GamePlayerSettings_, GameSettings, + GameSettings_, GameStat, GameStats_, IfAny, @@ -92,17 +94,19 @@ type StatsKeys = S extends { key: infer K }[] ? K : never; export type EndMatch = () => void; -export type LogPlayerStat = ( +export type LogPlayerStat = ( userId: UserId, key: StatsKeys, value: number, ) => void; -export type LogMatchStat = ( +export type LogMatchStat = ( key: StatsKeys, value: number, ) => void; +export type Reward = (options: RewardPayload) => void; + export type MoveSideEffects< PMT extends MoveTypesBase = MoveTypesBase, BMT extends MoveTypesBase = MoveTypesBase, @@ -112,7 +116,7 @@ export type MoveSideEffects< delayMove: DelayMove; turns: Turns; endMatch: EndMatch; - reward?: (options: RewardPayload) => void; + reward?: Reward; logPlayerStat: LogPlayerStat; logMatchStat: LogMatchStat; }; @@ -421,15 +425,23 @@ export type Game_< GS extends GameStateBase = GameStateBase, PMT extends MoveTypesBase = MoveTypesBase, BMT extends MoveTypesBase = MoveTypesBase, -> = Omit, "playerStats" | "matchStats"> & { +> = Omit< + Game, + "playerStats" | "matchStats" | "gameSettings" | "gamePlayerSettings" +> & { playerStats?: GameStats_; matchStats?: GameStats_; + gameSettings?: GameSettings_; + gamePlayerSettings?: GamePlayerSettings_; }; function normalizeArray, K extends keyof T>( - arr: T[], + arr: T[] | undefined, key: K, ): { allIds: T[K][]; byId: Record } { + if (arr === undefined) { + return { allIds: [], byId: {} as Record }; + } const allIds = arr.map((item) => item[key]); const byId = Object.fromEntries(arr.map((item) => [item[key], item])); return { allIds, byId }; @@ -477,8 +489,10 @@ export function parseGame< >(game: Game): Game_ { const playerStats = normalizeStats(game.playerStats); const matchStats = normalizeStats(game.matchStats); + const gameSettings = normalizeArray(game.gameSettings, "key"); + const gamePlayerSettings = normalizeArray(game.gamePlayerSettings, "key"); - return { ...game, playerStats, matchStats }; + return { ...game, playerStats, matchStats, gameSettings, gamePlayerSettings }; } // Game Manifest diff --git a/packages/game/src/testing.ts b/packages/game/src/testing.ts index 43baa7d..e8ffd09 100644 --- a/packages/game/src/testing.ts +++ b/packages/game/src/testing.ts @@ -219,7 +219,7 @@ export class MatchTester< let thereWasADefault = false; for (const option of options) { // If we find one, we use that one. - if (option.default) { + if (option.isDefault) { matchSettings[key] = option.value; thereWasADefault = true; }