diff --git a/TODO.md b/TODO.md index 47543678..7aa65dda 100644 --- a/TODO.md +++ b/TODO.md @@ -14,7 +14,6 @@ ## UI -- bug: die do not display the same face in multiplayer (states are correct) - when hovering target, highlight should have the dragged mesh's shape, not the target shape (what about parts?) - hand count on peer pointers/player tab? - score (Mah-jong, Belote) diff --git a/apps/cli/jsconfig.json b/apps/cli/jsconfig.json index ad241158..6c43461f 100644 --- a/apps/cli/jsconfig.json +++ b/apps/cli/jsconfig.json @@ -1,4 +1,11 @@ { "extends": "../../jsconfig.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@src/*": ["src/*"], + "@tabulous/server/*": ["../server/src/*"] + } + }, "include": ["src/**/*.js", "tests/**/*.js"] } diff --git a/apps/cli/package.json b/apps/cli/package.json index 065cc028..14ce0f45 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -13,7 +13,6 @@ "typecheck": "tsc -p jsconfig.json --noEmit" }, "dependencies": { - "@tabulous/cli": "workspace:^", "@urql/core": "^4.1.2", "add": "^2.0.6", "ajv": "^8.12.0", @@ -29,6 +28,7 @@ "undici": "^5.24.0" }, "devDependencies": { + "@tabulous/types": "workspace:*", "strip-ansi": "^7.1.0" } } diff --git a/apps/cli/src/commands/add-player.js b/apps/cli/src/commands/add-player.js index 1491507e..abdaf40b 100644 --- a/apps/cli/src/commands/add-player.js +++ b/apps/cli/src/commands/add-player.js @@ -1,6 +1,4 @@ // @ts-check -/** @typedef {import('@tabulous/server/src/graphql').Player} Player */ - import { gql } from '@urql/core' import chalkTemplate from 'chalk-template' import kebabCase from 'lodash.kebabcase' @@ -18,7 +16,7 @@ import { commonOptions } from './help.js' /** * @typedef {object} AddPlayerResult player addition command result - * @property {Player} player - added player. + * @property {import('@tabulous/types').Player} player - added player. */ const addPlayerMutation = gql` @@ -33,7 +31,7 @@ const addPlayerMutation = gql` /** * Triggers player addition command * @param {string[]} argv - array of parsed arguments (without executable and current file). - * @returns {Promise} the added player (or help message). + * @returns the added player (or help message). */ export default async function addPlayerCommand(argv) { const args = parseArgv(argv, { @@ -57,7 +55,7 @@ export default async function addPlayerCommand(argv) { /** * Adds a new player account. * @param {AddPlayerArgs} args - creation arguments. - * @returns {Promise} the added player. + * @returns the added player. */ export async function addPlayer({ username, password }) { const { addPlayer: player } = await getGraphQLClient().mutation( @@ -72,12 +70,7 @@ export async function addPlayer({ username, password }) { return attachFormater({ player }, formatPlayer) } -/** - * - * @param {AddPlayerResult} result - * @returns {string} - */ -function formatPlayer({ player }) { +function formatPlayer(/** @type {AddPlayerResult} */ { player }) { return chalkTemplate`player {bold ${player.username}} added with id {bold ${player.id}}` } diff --git a/apps/cli/src/commands/catalog.js b/apps/cli/src/commands/catalog.js index 0b492c13..3937f474 100644 --- a/apps/cli/src/commands/catalog.js +++ b/apps/cli/src/commands/catalog.js @@ -1,6 +1,4 @@ // @ts-check -/** @typedef {import('@tabulous/server/src/graphql').CatalogItem} CatalogItem */ - import { gql } from '@urql/core' import chalkTemplate from 'chalk-template' @@ -49,7 +47,7 @@ const listCatalogQuery = gql` /** * Triggers catalog command * @param {string[]} argv - array of parsed arguments (without executable and current file). - * @returns {Promise} this user catalog of accessible games (or help message). + * @returns this user catalog of accessible games (or help message). */ export default async function catalogCommand(argv) { const args = parseArgv(argv, { @@ -71,11 +69,11 @@ export default async function catalogCommand(argv) { /** * List all available games of a given user. * @param {CatalogArgs} args - catalog arguments. - * @returns {Promise} this user catalog of accessible games. + * @returns this user catalog of accessible games. */ export async function catalog({ username }) { const { id } = await findUser(username) - /** @type {{ listCatalog: CatalogItem[] }} */ + /** @type {{ listCatalog: import('@tabulous/server/graphql').CatalogItem[] }} */ const { listCatalog: catalog } = await getGraphQLClient().query( listCatalogQuery, signToken(id) @@ -95,18 +93,14 @@ export async function catalog({ username }) { } /** - * @param {CatalogItem} descriptor - * @returns {string} the descriptor localized name. + * @param {import('@tabulous/server/graphql').CatalogItem} descriptor + * @returns the descriptor localized name. */ function getLocaleName({ name, locales }) { return locales?.fr?.title ?? name } -/** - * @param {CatalogResult} result - * @returns {string} formatted result - */ -function formatCatalog({ games }) { +function formatCatalog(/** @type {CatalogResult} */ { games }) { const output = [] for (const { name, title, copyright } of games) { output.push( diff --git a/apps/cli/src/commands/configure-loggers.js b/apps/cli/src/commands/configure-loggers.js index e34b2883..5d2162bc 100644 --- a/apps/cli/src/commands/configure-loggers.js +++ b/apps/cli/src/commands/configure-loggers.js @@ -1,6 +1,4 @@ // @ts-check -/** @typedef {import('@tabulous/server/src/graphql').LoggerLevel} LoggerLevel */ - import { gql } from '@urql/core' import chalkTemplate from 'chalk-template' @@ -25,7 +23,7 @@ const configureLoggersMutation = gql` /** * Triggers logger configuration command * @param {string[]} argv - array of parsed arguments (without executable and current file). - * @returns {Promise} the configured loggers (or help message). + * @returns the configured loggers (or help message). */ export default async function configureLoggerCommand(argv) { const args = parseArgv(argv, { @@ -44,7 +42,7 @@ export default async function configureLoggerCommand(argv) { /** * @param {string} input - input string containing levels. - * @returns {LoggerLevel[]} parsed logger levels. + * @returns parsed logger levels. * @throws {Error} when the input string is not compliant. */ function parseLevels(input) { @@ -53,7 +51,7 @@ function parseLevels(input) { if (!name || !level) { throw new Error(`misformated levels: "${input}"`) } - return { name, level: /** @type {LoggerLevel['level']} */ (level) } + return { name, level } }) } @@ -65,8 +63,8 @@ function parseLevels(input) { /** * Configures logger levels. - * @param {{ levels: LoggerLevel[] }} args - configuration arguments. - * @returns {Promise} the configured loggers. + * @param {{ levels: { name: string, level: string}[] }} args - configuration arguments. + * @returns the configured loggers. */ export async function configureLevels({ levels }) { const { configureLoggerLevels: results } = await getGraphQLClient().mutation( @@ -77,11 +75,7 @@ export async function configureLevels({ levels }) { return attachFormater(results, formatLogger) } -/** - * @param {LoggerLevel[]} levels - * @returns {string} formatted results - */ -function formatLogger(levels) { +function formatLogger(/** @type {{ name: string, level: string }[]} */ levels) { return levels.map(({ name, level }) => `- ${name}: ${level}`).join('\n') } diff --git a/apps/cli/src/commands/delete-game.js b/apps/cli/src/commands/delete-game.js index 3732c70a..2dbc6f64 100644 --- a/apps/cli/src/commands/delete-game.js +++ b/apps/cli/src/commands/delete-game.js @@ -1,6 +1,4 @@ // @ts-check -/** @typedef {import('@tabulous/server/src/graphql').Game} Game */ - import { gql } from '@urql/core' import chalkTemplate from 'chalk-template' @@ -17,7 +15,7 @@ import { commonOptions } from './help.js' /** * @typedef {object} DeleteGameResult game deletion command result - * @property {Game} game - deleted game. + * @property {import('@tabulous/types').Game} game - deleted game. */ const deleteGameMutation = gql` @@ -33,7 +31,7 @@ const deleteGameMutation = gql` /** * Triggers game deletion command * @param {string[]} argv - array of parsed arguments (without executable and current file). - * @returns {Promise} the deleted game (or help message). + * @returns the deleted game (or help message). */ export default async function deleteGameCommand(argv) { const args = parseArgv(argv, { @@ -57,7 +55,7 @@ export default async function deleteGameCommand(argv) { /** * Deletes an existing game. * @param {DeleteGameArgs} args - deletion arguments. - * @returns {Promise} game deletion results. + * @returns game deletion results. */ export async function deleteGame({ gameId }) { const { deleteGame: game } = await getGraphQLClient().mutation( @@ -65,7 +63,7 @@ export async function deleteGame({ gameId }) { { gameId }, signToken() ) - return attachFormater({ game }, ({ game }) => + return attachFormater({ game }, (/** @type {DeleteGameResult} */ { game }) => game ? chalkTemplate`${formatGame(game)} {underline deleted}` : chalkTemplate`{underline no game} deleted` diff --git a/apps/cli/src/commands/delete-player.js b/apps/cli/src/commands/delete-player.js index 67750289..6a9c87cc 100644 --- a/apps/cli/src/commands/delete-player.js +++ b/apps/cli/src/commands/delete-player.js @@ -1,6 +1,4 @@ // @ts-check -/** @typedef {import('@tabulous/server/src/graphql').Player} Player */ - import { gql } from '@urql/core' import chalkTemplate from 'chalk-template' @@ -17,7 +15,7 @@ import { commonOptions } from './help.js' /** * @typedef {object} DeletePlayerResult player deletion command result - * @property {Player} player - deleted player. + * @property {import('@tabulous/types').Player} player - deleted player. */ const deletePlayerMutation = gql` @@ -33,7 +31,7 @@ const deletePlayerMutation = gql` /** * Triggers player deletion command * @param {string[]} argv - array of parsed arguments (without executable and current file). - * @returns {Promise} the deleted player (or help message). + * @returns the deleted player (or help message). */ export default async function deletePlayerCommand(argv) { const args = parseArgv(argv, { @@ -57,7 +55,7 @@ export default async function deletePlayerCommand(argv) { /** * Deletes an existing player account. * @param {DeletePlayerArgs} args - deletion arguments. - * @returns {Promise} player deletion results. + * @returns player deletion results. */ export async function deletePlayer({ id }) { const { deletePlayer: player } = await getGraphQLClient().mutation( @@ -65,10 +63,12 @@ export async function deletePlayer({ id }) { { id }, signToken() ) - return attachFormater({ player }, ({ player }) => - player - ? chalkTemplate`${formatPlayer(player)} {underline deleted}` - : chalkTemplate`{underline no player} deleted` + return attachFormater( + { player }, + (/** @type {DeletePlayerResult} */ { player }) => + player + ? chalkTemplate`${formatPlayer(player)} {underline deleted}` + : chalkTemplate`{underline no player} deleted` ) } diff --git a/apps/cli/src/commands/grant.js b/apps/cli/src/commands/grant.js index 0ee831ff..ef9d21e5 100644 --- a/apps/cli/src/commands/grant.js +++ b/apps/cli/src/commands/grant.js @@ -29,7 +29,7 @@ const grantAccessMutation = gql` /** * Triggers grant command. * @param {string[]} argv - array of parsed arguments (without executable and current file). - * @returns {Promise} whether the operation succeeded. + * @returns whether the operation succeeded. */ export default async function grantCommand(argv) { const args = parseArgv(argv, { @@ -56,7 +56,7 @@ export default async function grantCommand(argv) { /** * Grant a player access to a copyrighted game. * @param {GrantArgs} args - username and game name. - * @returns {Promise} whether the operation succeeded. + * @returns whether the operation succeeded. */ export async function grant({ username, gameName }) { const client = getGraphQLClient() @@ -76,11 +76,7 @@ export async function grant({ username, gameName }) { ) } -/** - * @param {GrantAccessResult} result - * @returns {string} formatted result - */ -function formatGrant({ grantAccess }) { +function formatGrant(/** @type {GrantAccessResult} */ { grantAccess }) { return grantAccess ? chalkTemplate`🛣 access {green granted}\n` : chalkTemplate`🔶 {yellow no changes}\n` diff --git a/apps/cli/src/commands/list-players.js b/apps/cli/src/commands/list-players.js index bdb0f6af..54ccb4b6 100644 --- a/apps/cli/src/commands/list-players.js +++ b/apps/cli/src/commands/list-players.js @@ -1,6 +1,4 @@ // @ts-check -/** @typedef {import('@tabulous/server/src/graphql').Player} Player */ - import { gql } from '@urql/core' import chalkTemplate from 'chalk-template' @@ -18,7 +16,7 @@ import { commonOptions } from './help.js' /** * Triggers player list command. * @param {string[]} argv - array of parsed arguments (without executable and current file). - * @returns {Promise} list of players or help message. + * @returns list of players or help message. */ export default async function listPlayersCommand(argv) { const args = parseArgv(argv, commonArgSpec) @@ -30,7 +28,7 @@ export default async function listPlayersCommand(argv) { /** * Fetches all pages of the player list - * @returns {Promise} list of players. + * @returns list of players. */ export async function listPlayers() { let from = 0 @@ -50,11 +48,10 @@ export async function listPlayers() { return attachFormater(players, formatPlayers) } -/** - * @param {number} from - * @param {number} size - */ -function makeListPlayersQuery(from, size) { +function makeListPlayersQuery( + /** @type {number} */ from, + /** @type {number} */ size +) { return gql` query listGamesQuery { listPlayers(from: ${from.toString()}, size: ${size.toString()}) { @@ -71,11 +68,9 @@ function makeListPlayersQuery(from, size) { ` } -/** - * @param {Player[]} players - * @returns {string} - formatted result - */ -function formatPlayers(players) { +function formatPlayers( + /** @type {import('@tabulous/types').Player[]} */ players +) { return players.map(player => `- ${formatPlayer(player)}`).join('\n') } diff --git a/apps/cli/src/commands/revoke.js b/apps/cli/src/commands/revoke.js index 833c70fe..b430e039 100644 --- a/apps/cli/src/commands/revoke.js +++ b/apps/cli/src/commands/revoke.js @@ -28,7 +28,7 @@ const revokeAccessMutation = gql` /** * Triggers the revoke command. * @param {string[]} argv - array of parsed arguments (without executable and current file). - * @returns {Promise} whether the operation succeeded. + * @returns whether the operation succeeded. */ export default async function revokeCommand(argv) { const args = parseArgv(argv, { @@ -55,7 +55,7 @@ export default async function revokeCommand(argv) { /** * Revoke game access to a player. * @param {RevokeArgs} args - username and game Name. - * @returns {Promise} whether the operation succeeded. + * @returns whether the operation succeeded. */ export async function revoke({ username, gameName }) { const client = getGraphQLClient() @@ -75,11 +75,7 @@ export async function revoke({ username, gameName }) { ) } -/** - * @param {RevokeAccessResult} result - * @returns {string} formatted result - */ -function formatRevokation({ revokeAccess }) { +function formatRevokation(/** @type {RevokeAccessResult} */ { revokeAccess }) { return revokeAccess ? chalkTemplate`🚷 access {green revoked}\n` : chalkTemplate`🔶 {yellow no changes}\n` diff --git a/apps/cli/src/commands/show-player.js b/apps/cli/src/commands/show-player.js index 045aef25..0e76752b 100644 --- a/apps/cli/src/commands/show-player.js +++ b/apps/cli/src/commands/show-player.js @@ -1,9 +1,4 @@ // @ts-check -/** - * @typedef {import('@tabulous/server/src/graphql').Game} Game - * @typedef {import('@tabulous/server/src/graphql').Player} Player - */ - import { gql } from '@urql/core' import chalkTemplate from 'chalk-template' @@ -37,7 +32,7 @@ const listGamesQuery = gql` /** * Triggers show player command. * @param {string[]} argv - array of parsed arguments (without executable and current file). - * @returns {Promise} whether the operation succeeded. + * @returns whether the operation succeeded. */ export default async function showPlayerCommand(argv) { const args = parseArgv(argv, { @@ -59,7 +54,7 @@ export default async function showPlayerCommand(argv) { /** * Show a player's details. * @param {ShowPlayerArgs} args - username. - * @returns {Promise} found player details. + * @returns found player details. */ export async function showPlayer({ username }) { const player = attachFormater(await findUser(username), formatPlayer) @@ -76,19 +71,17 @@ export async function showPlayer({ username }) { ) } -/** - * @param {Player} result - * @returns {string} formatted results - */ -function formatPlayer({ - id, - isAdmin, - username, - email, - provider, - currentGameId, - termsAccepted -}) { +function formatPlayer( + /** @type {import('@tabulous/types').Player} */ { + id, + isAdmin, + username, + email, + provider, + currentGameId, + termsAccepted + } +) { return chalkTemplate`{dim id:} ${id} ${isAdmin ? '🥷' : ''} {dim username:} ${username} {dim email:} ${email || 'none'} @@ -99,11 +92,12 @@ function formatPlayer({ const spacing = '\n ' -/** - * @param {Player & {games: Game[]}} result - * @returns {string} formatted results - */ -function formatGames({ id: playerId, games }) { +function formatGames( + /** @type {import('@tabulous/types').Player & { games: import('@tabulous/server/graphql').Game[] }} */ { + id: playerId, + games + } +) { const { owned, invited } = games.reduce( (counts, game) => { if (game.players?.find(({ id, isOwner }) => isOwner && id === playerId)) { @@ -113,7 +107,10 @@ function formatGames({ id: playerId, games }) { } return counts }, - { owned: /** @type {Game[]} */ ([]), invited: /** @type {Game[]} */ ([]) } + { + owned: /** @type {import('@tabulous/server/graphql').Game[]} */ ([]), + invited: /** @type {import('@tabulous/server/graphql').Game[]} */ ([]) + } ) owned.sort(byCreatedDate) invited.sort(byCreatedDate) @@ -136,11 +133,9 @@ showPlayerCommand.help = function help() { ${commonOptions}` } -/** - * @param {Game} gameA - * @param {Game} gameB - * @returns {number} - */ -function byCreatedDate({ created: a }, { created: b }) { +function byCreatedDate( + /** @type {import('@tabulous/server/graphql').Game} */ { created: a }, + /** @type {import('@tabulous/server/graphql').Game} */ { created: b } +) { return b - a } diff --git a/apps/cli/src/util/args.js b/apps/cli/src/util/args.js index 0ac8cc42..c73e41ed 100644 --- a/apps/cli/src/util/args.js +++ b/apps/cli/src/util/args.js @@ -12,7 +12,7 @@ RequiredString.required = requiredSymbol * @template {arg.Spec} T * @param {string[]} argv - array of parsed arguments (without executable and current file). * @param {T} spec - parser specification. - * @returns {any} parsed command line arguments. + * @returns parsed command line arguments. */ export function parseArgv(argv = [], spec) { const parsed = arg(spec, { permissive: true, argv }) @@ -38,7 +38,7 @@ export function parseArgv(argv = [], spec) { * Removes the first command from parsed arguments. * @param {string[]} argv - array of parsed arguments. * @param {string} command - removed command - * @returns {string[]} the parsed arguments without stripped command. + * @returns the parsed arguments without stripped command. */ export function shiftCommand(argv, command) { return argv.filter(arg => camelCase(arg) !== command) diff --git a/apps/cli/src/util/configuration.js b/apps/cli/src/util/configuration.js index ed10d637..ee0e2616 100644 --- a/apps/cli/src/util/configuration.js +++ b/apps/cli/src/util/configuration.js @@ -27,7 +27,7 @@ const validate = new Ajv({ allErrors: true }).compile({ * - JWT_KEY: key used to sign JWT sent to the client. * - ADMIN_USER_ID: user id used to run elevated graphQL queries. * - * @returns {Configuration} the loaded configuration. + * @returns the loaded configuration. * @throws {Error} when the provided environment variables do not match expected values. */ export function loadConfiguration() { diff --git a/apps/cli/src/util/find-user.js b/apps/cli/src/util/find-user.js index e7155bdd..9b6ad1db 100644 --- a/apps/cli/src/util/find-user.js +++ b/apps/cli/src/util/find-user.js @@ -1,6 +1,4 @@ // @ts-check -/** @typedef {import('@tabulous/server/src/graphql').Player} Player */ - import { gql } from '@urql/core' import { getGraphQLClient } from './graphql-client.js' @@ -22,21 +20,33 @@ const findUserQuery = gql` ` /** + * @overload * Finds user details from their username * @param {string} username - desired username. - * @param {boolean} [failOnNull = true] - whether to throw an error when no player could be found. - * @returns {Promise} corresponding player, or null. + * @param {boolean} [throwOnNull=true] - whether to throw an error when no player could be found. + * @returns {Promise} corresponding player. * @throws {Error} when failOnNull is true, and no player could be found + * + * @overload + * Finds user details from their username + * @param {string} username - desired username. + * @param {false} throwOnNull - whether to throw an error when no player could be found. + * @returns {Promise} corresponding player, or null. */ -export async function findUser(username, failOnNull = true) { +export async function findUser( + /** @type {string} */ username, + throwOnNull = true +) { const client = getGraphQLClient() const { searchPlayers } = await client.query( findUserQuery, { username }, signToken() ) - const user = searchPlayers?.[0] ?? null - if (!user && failOnNull) { + const user = /** @type {?import('@tabulous/types').Player} */ ( + searchPlayers?.[0] ?? null + ) + if (!user && throwOnNull) { throw new Error(`no user found`) } return user diff --git a/apps/cli/src/util/formaters.js b/apps/cli/src/util/formaters.js index a5d45b2a..45bad93e 100644 --- a/apps/cli/src/util/formaters.js +++ b/apps/cli/src/util/formaters.js @@ -1,9 +1,4 @@ // @ts-check -/** - * @typedef {import('@tabulous/server/src/graphql').Game} Game - * @typedef {import('@tabulous/server/src/graphql').Player} Player - */ - import chalkTemplate from 'chalk-template' const symbol = Symbol('formaters') @@ -23,7 +18,7 @@ export function printWithFormaters(object) { * Invokes formaters of an object. * @template {Record} T * @param {T} object - printed object. - * @returns {(T|string)[]} list of outputs, either strings or raw objects. + * @returns list of outputs, either strings or raw objects. */ export function applyFormaters(object) { /** @type {(T|string)[]} */ @@ -44,7 +39,7 @@ export function applyFormaters(object) { * @param {T} object - object to add this formater to. * @param {(obj: T) => string} formater - added formater function. * @param {boolean} [first=false] - whether to add this formater first or last. - * @returns {T} the mutated object. + * @returns the mutated object. */ export function attachFormater(object, formater, first = false) { let formaters = object[symbol] @@ -59,8 +54,8 @@ export function attachFormater(object, formater, first = false) { /** * Formater for game objects. - * @param {Game} game - game to format. - * @returns {string} formatted game. + * @param {import('@tabulous/server/graphql').Game} game - game to format. + * @returns formatted game. */ export function formatGame({ id, created, kind }) { return chalkTemplate`${kind ? `${kind} game` : `🛋️ lobby`} {dim ${formatDate( @@ -70,8 +65,8 @@ export function formatGame({ id, created, kind }) { /** * Formater for player objects. - * @param {Player} player - player to format. - * @return {string} formatted player. + * @param {import('@tabulous/types').Player} player - player to format. + * @return formatted player. */ export function formatPlayer({ id, username, email }) { return chalkTemplate`{bold ${username}} {dim ${email || 'no email'}} (${id})` @@ -84,8 +79,8 @@ const timeAndDate = new Intl.DateTimeFormat('en-gb', { /** * Formater for timestamps. - * @param {number} timestamp - timestamp to format. - * @returns {string} localized date and time. + * @param {number} [timestamp] - timestamp to format. + * @returns localized date and time. */ export function formatDate(timestamp) { return !timestamp ? 'unknown' : timeAndDate.format(timestamp) diff --git a/apps/cli/src/util/graphql-client.js b/apps/cli/src/util/graphql-client.js index 81eef7cb..bf3a7d50 100644 --- a/apps/cli/src/util/graphql-client.js +++ b/apps/cli/src/util/graphql-client.js @@ -1,17 +1,12 @@ // @ts-check -/** - * @typedef {import('@urql/core').Client} UrqlClient - * @typedef {import('@urql/core').ClientOptions} UrqlClientOptions - */ - import { cacheExchange, createClient, fetchExchange } from '@urql/core' import { Agent, fetch, setGlobalDispatcher } from 'undici' import { loadConfiguration } from './configuration.js' -/** @typedef {Parameters[0]} Query */ +/** @typedef {Parameters[0]} Query */ -/** @typedef {Parameters[1]} Variables */ +/** @typedef {Parameters[1]} Variables */ /** @typedef {(query: Query, variablesOrJwt: Variables|string, jwt?: string) => Promise} RequestSignature */ @@ -21,7 +16,7 @@ import { loadConfiguration } from './configuration.js' * @property {RequestSignature} mutation - runs a GraphQL query with pre-defined JWT, throwing received errors. */ -/** @typedef {Omit & CustomClient} Client */ +/** @typedef {Omit & CustomClient} Client */ /** @type {?Client} */ let client @@ -30,7 +25,7 @@ let fetchOptions /** * Builds or returns the existing graphQL client. - * @returns {Client} graphql client. + * @returns graphql client. */ export function getGraphQLClient() { if (!client) { @@ -47,7 +42,7 @@ export function getGraphQLClient() { } /** - * @param {UrqlClientOptions} options - GraphQL client options. + * @param {import('@urql/core').ClientOptions} options - GraphQL client options. * @returns {Client} initialized client. */ function initClient(options) { diff --git a/apps/cli/src/util/index.js b/apps/cli/src/util/index.js index b5be1e53..ca1ed3eb 100644 --- a/apps/cli/src/util/index.js +++ b/apps/cli/src/util/index.js @@ -1,3 +1,4 @@ +// @ts-check export * from './args.js' export * from './configuration.js' export * from './find-user.js' diff --git a/apps/cli/src/util/jwt.js b/apps/cli/src/util/jwt.js index 44fde576..d555aa27 100644 --- a/apps/cli/src/util/jwt.js +++ b/apps/cli/src/util/jwt.js @@ -6,7 +6,7 @@ import { loadConfiguration } from './configuration.js' /** * Creates a signed JWT that can be used as authentication token. * @param {string} [userId] - id of the impersonated user. Default to the one from configuration. - * @returns {string} the corresponding signed JWT. + * @returns the corresponding signed JWT. */ export function signToken(userId) { const { jwt, adminUserId } = loadConfiguration() diff --git a/apps/cli/tests/commands/add-player.test.js b/apps/cli/tests/commands/add-player.test.js index 4df616b9..e0b5e8f2 100644 --- a/apps/cli/tests/commands/add-player.test.js +++ b/apps/cli/tests/commands/add-player.test.js @@ -1,8 +1,4 @@ // @ts-check -/** - * @typedef {import('../../src').Command} Command - */ - import { faker } from '@faker-js/faker' import kebabCase from 'lodash.kebabcase' import stripAnsi from 'strip-ansi' @@ -18,7 +14,7 @@ vi.mock('../../src/util/graphql-client.js', () => ({ })) describe('Player addition command', () => { - /** @type {Command} */ + /** @type {import('@src/index').Command} */ let addPlayer const adminPlayerId = faker.string.uuid() const jwtKey = faker.string.uuid() diff --git a/apps/cli/tests/commands/catalog.test.js b/apps/cli/tests/commands/catalog.test.js index 5700ba9d..a5099e30 100644 --- a/apps/cli/tests/commands/catalog.test.js +++ b/apps/cli/tests/commands/catalog.test.js @@ -1,8 +1,4 @@ // @ts-check -/** - * @typedef {import('../../src').Command} Command - */ - import { faker } from '@faker-js/faker' import stripAnsi from 'strip-ansi' import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' @@ -17,7 +13,7 @@ vi.mock('../../src/util/graphql-client.js', () => ({ })) describe('Player catalog command', () => { - /** @type {Command} */ + /** @type {import('@src/index').Command} */ let catalog const adminUserId = faker.string.uuid() const jwtKey = faker.string.uuid() diff --git a/apps/cli/tests/commands/configure-loggers.test.js b/apps/cli/tests/commands/configure-loggers.test.js index e33eccd4..047fdb97 100644 --- a/apps/cli/tests/commands/configure-loggers.test.js +++ b/apps/cli/tests/commands/configure-loggers.test.js @@ -1,8 +1,4 @@ // @ts-check -/** - * @typedef {import('../../src').Command} Command - */ - import { faker } from '@faker-js/faker' import stripAnsi from 'strip-ansi' import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' @@ -17,7 +13,7 @@ vi.mock('../../src/util/graphql-client.js', () => ({ })) describe('Logger configuration command', () => { - /** @type {Command} */ + /** @type {import('@src/index').Command} */ let configureLogger const adminPlayerId = faker.string.uuid() const jwtKey = faker.string.uuid() diff --git a/apps/cli/tests/commands/delete-game.test.js b/apps/cli/tests/commands/delete-game.test.js index 06b042c9..0595771f 100644 --- a/apps/cli/tests/commands/delete-game.test.js +++ b/apps/cli/tests/commands/delete-game.test.js @@ -1,9 +1,4 @@ // @ts-check -/** - * @typedef {import('../../src').Command} Command - * @typedef {import('../../src/commands/show-player').Game} Game - */ - import { faker } from '@faker-js/faker' import stripAnsi from 'strip-ansi' import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' @@ -18,7 +13,7 @@ vi.mock('../../src/util/graphql-client.js', () => ({ })) describe('Game deletion command', () => { - /** @type {Command} */ + /** @type {import('@src/index').Command} */ let deleteGame const adminPlayerId = faker.string.uuid() const jwtKey = faker.string.uuid() @@ -52,7 +47,11 @@ describe('Game deletion command', () => { const id = faker.string.uuid() const kind = faker.lorem.word() const created = Date.now() - const game = /** @type {Game} */ ({ id, kind, created }) + const game = /** @type {import('@tabulous/server/graphql').Game} */ ({ + id, + kind, + created + }) mockQuery.mockResolvedValueOnce({ deleteGame: game }) const result = await deleteGame([id]) expect(result).toMatchObject({ game }) @@ -70,7 +69,10 @@ describe('Game deletion command', () => { it('handles deleted lobby', async () => { const id = faker.string.uuid() const created = Date.now() - const lobby = /** @type {Game} */ ({ id, created }) + const lobby = /** @type {import('@tabulous/server/graphql').Game} */ ({ + id, + created + }) mockQuery.mockResolvedValueOnce({ deleteGame: lobby }) const result = await deleteGame([id]) expect(result).toMatchObject({ game: lobby }) diff --git a/apps/cli/tests/commands/delete-player.test.js b/apps/cli/tests/commands/delete-player.test.js index bd2686a9..8f25eae7 100644 --- a/apps/cli/tests/commands/delete-player.test.js +++ b/apps/cli/tests/commands/delete-player.test.js @@ -1,8 +1,4 @@ // @ts-check -/** - * @typedef {import('../../src').Command} Command - */ - import { faker } from '@faker-js/faker' import stripAnsi from 'strip-ansi' import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' @@ -17,7 +13,7 @@ vi.mock('../../src/util/graphql-client.js', () => ({ })) describe('Player deletion command', () => { - /** @type {Command} */ + /** @type {import('@src/index').Command} */ let deletePlayer const adminPlayerId = faker.string.uuid() const jwtKey = faker.string.uuid() diff --git a/apps/cli/tests/commands/grant.test.js b/apps/cli/tests/commands/grant.test.js index eb509fcd..3fc1024f 100644 --- a/apps/cli/tests/commands/grant.test.js +++ b/apps/cli/tests/commands/grant.test.js @@ -1,8 +1,4 @@ // @ts-check -/** - * @typedef {import('../../src').Command} Command - */ - import { faker } from '@faker-js/faker' import stripAnsi from 'strip-ansi' import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' @@ -20,7 +16,7 @@ vi.mock('../../src/util/graphql-client.js', () => ({ })) describe('Player game granting command', () => { - /** @type {Command} */ + /** @type {import('@src/index').Command} */ let grant const adminUserId = faker.string.uuid() const jwtKey = faker.string.uuid() diff --git a/apps/cli/tests/commands/list-players.test.js b/apps/cli/tests/commands/list-players.test.js index 68a91fa4..465de052 100644 --- a/apps/cli/tests/commands/list-players.test.js +++ b/apps/cli/tests/commands/list-players.test.js @@ -1,9 +1,4 @@ // @ts-check -/** - * @typedef {import('../../src').Command} Command - * @typedef {import('@tabulous/server/src/graphql').Player} Player - */ - import { faker } from '@faker-js/faker' import stripAnsi from 'strip-ansi' import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' @@ -20,7 +15,7 @@ vi.mock('../../src/util/graphql-client.js', () => ({ })) describe('List players command', () => { - /** @type {Command} */ + /** @type {import('@src/index').Command} */ let listPlayers const adminUserId = faker.string.uuid() const jwtKey = faker.string.uuid() @@ -47,7 +42,7 @@ describe('List players command', () => { }) describe('given some players', () => { - /** @type {Player[]} */ + /** @type {import('@tabulous/types').Player[]} */ const players = Array.from({ length: 34 }, (_, i) => ({ id: `id-${i + 1}`, username: faker.person.firstName(), diff --git a/apps/cli/tests/commands/revoke.test.js b/apps/cli/tests/commands/revoke.test.js index efa098c6..9f9357c4 100644 --- a/apps/cli/tests/commands/revoke.test.js +++ b/apps/cli/tests/commands/revoke.test.js @@ -1,8 +1,4 @@ // @ts-check -/** - * @typedef {import('../../src').Command} Command - */ - import { faker } from '@faker-js/faker' import stripAnsi from 'strip-ansi' import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' @@ -20,7 +16,7 @@ vi.mock('../../src/util/graphql-client.js', () => ({ })) describe('Player game revokation command', () => { - /** @type {Command} */ + /** @type {import('@src/index').Command} */ let revoke const adminUserId = faker.string.uuid() const jwtKey = faker.string.uuid() diff --git a/apps/cli/tests/commands/show-player.test.js b/apps/cli/tests/commands/show-player.test.js index 03fa999d..7ce0e82c 100644 --- a/apps/cli/tests/commands/show-player.test.js +++ b/apps/cli/tests/commands/show-player.test.js @@ -1,10 +1,4 @@ // @ts-check -/** - * @typedef {import('../../src').Command} Command - * @typedef {import('../../src/commands/show-player').Game} Game - * @typedef {import('@tabulous/server/src/graphql').Player} Player - */ - import { faker } from '@faker-js/faker' import stripAnsi from 'strip-ansi' import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' @@ -22,18 +16,18 @@ vi.mock('../../src/util/graphql-client.js', () => ({ })) describe('Show player command', () => { - /** @type {Command} */ + /** @type {import('@src/index').Command} */ let showPlayer const adminUserId = faker.string.uuid() const jwtKey = faker.string.uuid() - /** @type {Player} */ + /** @type {import('@tabulous/types').Player} */ const player = { id: faker.string.uuid(), username: faker.person.fullName(), email: faker.internet.email(), currentGameId: null } - /** @type {Player} */ + /** @type {import('@tabulous/types').Player} */ const player2 = { id: faker.string.uuid(), username: faker.person.fullName(), @@ -85,7 +79,7 @@ describe('Show player command', () => { }) describe('given some games', () => { - const games = /** @type {Game[]} */ ([ + const games = /** @type {import('@tabulous/server/graphql').Game[]} */ ([ { id: 'game-1', kind: 'klondike', @@ -100,7 +94,7 @@ describe('Show player command', () => { } ]) games.push( - /** @type {Game} */ ({ + /** @type {import('@tabulous/server/graphql').Game} */ ({ id: 'game-3', kind: 'klondike', created: faker.date @@ -110,7 +104,7 @@ describe('Show player command', () => { }) ) games.push( - /** @type {Game} */ ({ + /** @type {import('@tabulous/server/graphql').Game} */ ({ id: 'game-4', kind: 'klondike', created: faker.date diff --git a/apps/cli/tests/index.test.js b/apps/cli/tests/index.test.js index 8263bf5a..bab37624 100644 --- a/apps/cli/tests/index.test.js +++ b/apps/cli/tests/index.test.js @@ -1,3 +1,4 @@ +// @ts-check import { inspect } from 'util' import { beforeAll, describe, expect, it, vi } from 'vitest' @@ -23,6 +24,7 @@ vi.mock('../src/commands/grant.js', () => ({ describe('Tabulous CLI', () => { const output = mockConsole() + /** @type {import('@src/index').default} */ let cli beforeAll(async () => { @@ -49,6 +51,7 @@ describe('Tabulous CLI', () => { const error = new Error('no username provided') const commandHelpMessage = 'command custom help message' mockCatalog.mockRejectedValue(error) + // @ts-expect-error -- TS does not like attaching properties to mocks mockCatalog.help = vi.fn().mockReturnValueOnce(commandHelpMessage) await cli(['catalog']) expect(output.stdout).toContain(`error: ${error.message}`) diff --git a/apps/cli/tests/test-util.js b/apps/cli/tests/test-util.js index 9f0b0040..69d060e2 100644 --- a/apps/cli/tests/test-util.js +++ b/apps/cli/tests/test-util.js @@ -1,3 +1,4 @@ +// @ts-check import stripAnsi from 'strip-ansi' import { inspect } from 'util' import { afterEach, beforeEach, vi } from 'vitest' @@ -11,19 +12,23 @@ export function mockConsole() { beforeEach(() => { output.stdout = '' - vi.spyOn(console, 'log').mockImplementation((...args) => { - output.stdout += args - .map(obj => { - const content = - typeof obj === 'string' ? stripAnsi(obj) : inspect(obj) - debug && process.stdout.write(`${content}\n`) - return content - }) - .join(' ') - }) + vi.spyOn(console, 'log').mockImplementation( + (/** @type {any} */ ...args) => { + output.stdout += args + .map((/** @type {any} */ obj) => { + const content = + typeof obj === 'string' ? stripAnsi(obj) : inspect(obj) + debug && process.stdout.write(`${content}\n`) + return content + }) + .join(' ') + } + ) }) - afterEach(vi.resetAllMocks) + afterEach(() => { + vi.resetAllMocks() + }) return output } diff --git a/apps/cli/tests/util/configuration.test.js b/apps/cli/tests/util/configuration.test.js index 1b3d7198..32ab7a87 100644 --- a/apps/cli/tests/util/configuration.test.js +++ b/apps/cli/tests/util/configuration.test.js @@ -1,3 +1,4 @@ +// @ts-check import { faker } from '@faker-js/faker' import { beforeEach, describe, expect, it, vi } from 'vitest' diff --git a/apps/cli/tests/util/find-user.test.js b/apps/cli/tests/util/find-user.test.js index e20ae905..82b7c2d2 100644 --- a/apps/cli/tests/util/find-user.test.js +++ b/apps/cli/tests/util/find-user.test.js @@ -1,3 +1,4 @@ +// @ts-check import { faker } from '@faker-js/faker' import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' @@ -11,6 +12,7 @@ vi.mock('../../src/util/graphql-client.js', () => ({ })) describe('getGraphQLClient()', () => { + /** @type {import('@src/util/find-user').findUser} */ let findUser const adminUserId = faker.string.uuid() const jwtKey = faker.string.uuid() diff --git a/apps/cli/tests/util/formaters.test.js b/apps/cli/tests/util/formaters.test.js index a1af009b..3b29d0e1 100644 --- a/apps/cli/tests/util/formaters.test.js +++ b/apps/cli/tests/util/formaters.test.js @@ -1,3 +1,4 @@ +// @ts-check import { faker } from '@faker-js/faker' import stripAnsi from 'strip-ansi' import { describe, expect, it } from 'vitest' @@ -28,7 +29,7 @@ describe('formatGame()', () => { }) describe('formatDate()', () => { - function pad(number) { + function pad(/** @type {number} */ number) { return number < 10 ? `0${number}` : number.toString() } it('handles no date', () => { @@ -52,16 +53,16 @@ describe('formatPlayer()', () => { const id = faker.string.uuid() const email = faker.internet.email() const username = faker.person.fullName() - expect(stripAnsi(formatPlayer({ username, email, id }))).toEqual( - `${username} ${email} (${id})` - ) + expect( + stripAnsi(formatPlayer({ username, email, id, currentGameId: null })) + ).toEqual(`${username} ${email} (${id})`) }) it('handles no email', () => { const id = faker.string.uuid() const username = faker.person.fullName() - expect(stripAnsi(formatPlayer({ username, id }))).toEqual( - `${username} no email (${id})` - ) + expect( + stripAnsi(formatPlayer({ username, id, currentGameId: null })) + ).toEqual(`${username} no email (${id})`) }) }) diff --git a/apps/cli/tests/util/graphql-client.test.js b/apps/cli/tests/util/graphql-client.test.js index 2739f5a3..5cb78d5c 100644 --- a/apps/cli/tests/util/graphql-client.test.js +++ b/apps/cli/tests/util/graphql-client.test.js @@ -1,3 +1,4 @@ +// @ts-check import { faker } from '@faker-js/faker' import { MockAgent, setGlobalDispatcher } from 'undici' import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' @@ -7,13 +8,17 @@ vi.mock('../../src/util/configuration.js', () => ({ })) describe('getGraphQLClient()', () => { + /** @type {import('vitest').MockedFunction} */ let loadConfiguration + /** @type {import('@src/util/graphql-client').getGraphQLClient} */ let getGraphQLClient const url = faker.internet.url({ appendSlash: false }) const jwtKey = faker.string.uuid() beforeAll(async () => { - ;({ loadConfiguration } = await import('../../src/util/configuration.js')) + loadConfiguration = vi.mocked( + (await import('../../src/util/configuration.js')).loadConfiguration + ) ;({ getGraphQLClient } = await import('../../src/util/graphql-client.js')) }) @@ -22,7 +27,11 @@ describe('getGraphQLClient()', () => { }) it('builds client from configuration', () => { - loadConfiguration.mockReturnValue({ url, jwt: { key: jwtKey } }) + loadConfiguration.mockReturnValue({ + url, + jwt: { key: jwtKey }, + adminUserId: '' + }) const client = getGraphQLClient() expect(client).toBeDefined() @@ -36,8 +45,11 @@ describe('getGraphQLClient()', () => { }) describe('given a client', () => { + /** @type {import('@src/util').Client} */ let client + /** @type {MockAgent} */ let mockAgent + /** @type {ReturnType} */ let networkMock const graphQLRequest = vi.fn() @@ -61,7 +73,10 @@ describe('getGraphQLClient()', () => { networkMock .intercept({ method: 'POST', path: '/' }) .reply(200, ({ headers, body }) => { - graphQLRequest({ body: JSON.parse(body), headers }) + graphQLRequest({ + body: JSON.parse(/** @type {string} */ (body)), + headers + }) return { errors: [{ message: error.message }], data: null } }) @@ -88,7 +103,10 @@ describe('getGraphQLClient()', () => { networkMock .intercept({ method: 'POST', path: '/' }) .reply(200, ({ headers, body }) => { - graphQLRequest({ body: JSON.parse(body), headers }) + graphQLRequest({ + body: JSON.parse(/** @type {string} */ (body)), + headers + }) return { data } }) @@ -129,7 +147,10 @@ describe('getGraphQLClient()', () => { networkMock .intercept({ method: 'POST', path: '/' }) .reply(200, ({ headers, body }) => { - graphQLRequest({ body: JSON.parse(body), headers }) + graphQLRequest({ + body: JSON.parse(/** @type {string} */ (body)), + headers + }) return { data } }) diff --git a/apps/game-utils/jsconfig.json b/apps/game-utils/jsconfig.json new file mode 100644 index 00000000..ad241158 --- /dev/null +++ b/apps/game-utils/jsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../jsconfig.json", + "include": ["src/**/*.js", "tests/**/*.js"] +} diff --git a/apps/game-utils/package.json b/apps/game-utils/package.json new file mode 100644 index 00000000..176c3d80 --- /dev/null +++ b/apps/game-utils/package.json @@ -0,0 +1,19 @@ +{ + "name": "@tabulous/game-utils", + "version": "0.0.1", + "description": "Utilities for dealing with game states", + "type": "module", + "main": "src/index.js", + "scripts": { + "test": "vitest run --coverage", + "test:dev": "vitest dev --reporter=verbose", + "typecheck": "tsc -p jsconfig.json --noEmit" + }, + "dependencies": { + "@tabulous/types": "workspace:*", + "deepmerge": "^4.3.1" + }, + "devDependencies": { + "ajv": "^8.12.0" + } +} diff --git a/apps/game-utils/src/camera.js b/apps/game-utils/src/camera.js new file mode 100644 index 00000000..a7bc9f4a --- /dev/null +++ b/apps/game-utils/src/camera.js @@ -0,0 +1,43 @@ +// @ts-check + +/** + * Builds a camera save for a given player, with default values: + * - alpha = PI * 3/2 (south) + * - beta = PI / 8 (slightly elevated from ground) + * - elevation = 35 + * - target = [0,0,0] (the origin) + * - index = 0 (default camera position) + * It adds the hash. + * @param {Partial} cameraPosition - a partial camera position without hash. + * @returns the built camera position. + */ +export function buildCameraPosition({ + playerId, + index = 0, + target = [0, 0, 0], + alpha = (3 * Math.PI) / 2, + beta = Math.PI / 8, + elevation = 35 +} = {}) { + if (!playerId) { + throw new Error('camera position requires playerId') + } + return addHash({ + hash: '', + playerId, + index, + target, + alpha, + beta, + elevation + }) +} + +/** + * @param {import('@tabulous/types').CameraPosition} position - camera to extend with hash. + * @returns the augmented camera. + */ +function addHash(position) { + position.hash = `${position.target[0]}-${position.target[1]}-${position.target[2]}-${position.alpha}-${position.beta}-${position.elevation}` + return position +} diff --git a/apps/server/src/utils/collections.js b/apps/game-utils/src/collection.js similarity index 100% rename from apps/server/src/utils/collections.js rename to apps/game-utils/src/collection.js diff --git a/apps/game-utils/src/descriptor.js b/apps/game-utils/src/descriptor.js new file mode 100644 index 00000000..abc3aed3 --- /dev/null +++ b/apps/game-utils/src/descriptor.js @@ -0,0 +1,209 @@ +// @ts-check +import merge from 'deepmerge' + +import { shuffle } from './collection.js' +import { findAnchor, findMesh, stackMeshes } from './mesh.js' +import { mergeProps } from './utils.js' + +/** + * Creates a unique game from a game descriptor. + * @param {string} kind - created game's kind. + * @param {Partial} descriptor - to create game from. + * @returns a list of serialized 3D meshes. + */ +export async function createMeshes(kind, descriptor) { + if (!descriptor || !descriptor.build) { + throw new Error(`Game ${kind} does not export a build() function`) + } + const { slots, bags, meshes } = await descriptor.build() + const meshById = cloneAll(meshes ?? []) + const allMeshes = [...meshById.values()] + const meshesByBagId = randomizeBags(bags, meshById) + for (const slot of slots ?? []) { + fillSlot(slot, meshesByBagId, allMeshes) + } + removeDandlingMeshes(meshesByBagId, allMeshes) + return allMeshes +} + +/** + * @param {import('@tabulous/types').Mesh[]} meshes - meshes to clone. + * @returns cloned meshes. + */ +function cloneAll(meshes) { + /** @type {Map} */ + const all = new Map() + for (const mesh of meshes) { + all.set(mesh.id, merge(mesh, {})) + } + return all +} + +/** + * Walk through all game meshes (main scene and player hands) + * to enrich their assets (textures, images, models) with absoluyte paths. + * @param {Partial} game - altered game data. + * @returns the altered game data. + */ +export function enrichAssets(game) { + const allMeshes = [ + ...(game.meshes ?? []), + ...(game.hands ?? []).flatMap(({ meshes }) => meshes) + ] + if (game.kind) { + for (const mesh of allMeshes) { + if (isRelativeAsset(mesh.texture)) { + mesh.texture = addAbsoluteAsset(mesh.texture, game.kind, 'texture') + } + if (mesh.file && isRelativeAsset(mesh.file)) { + mesh.file = addAbsoluteAsset(mesh.file, game.kind, 'model') + } + if (mesh.detailable && isRelativeAsset(mesh.detailable.frontImage)) { + mesh.detailable.frontImage = addAbsoluteAsset( + mesh.detailable.frontImage, + game.kind, + 'image' + ) + } + if ( + mesh.detailable?.backImage && + isRelativeAsset(mesh.detailable.backImage) + ) { + mesh.detailable.backImage = addAbsoluteAsset( + mesh.detailable.backImage, + game.kind, + 'image' + ) + } + } + } + return game +} + +/** + * @param {string} [path] - tested path. + * @returns whether this path is a relative assets. + */ +export function isRelativeAsset(path) { + return path && !path.startsWith('#') && !path.startsWith('/') ? true : false +} + +/** + * @param {string} path - relative path. + * @param {string} kind - game kind. + * @param {string} assetType - asset type. + * @returns the absolute asset path + */ +export function addAbsoluteAsset(path, kind, assetType) { + return `/${kind}/${assetType}s/${path}` +} + +/** + * @param {Map|undefined} bags - a map of bags. + * @param {Map} meshById - map of meshes. + * @returns a list of randomized meshes per bags. + */ +function randomizeBags(bags, meshById) { + /** @type {Map} */ + const meshesByBagId = new Map() + if (bags instanceof Map) { + for (const [bagId, meshIds] of bags) { + meshesByBagId.set( + bagId, + /** @type {import('@tabulous/types').Mesh[]} */ ( + shuffle(meshIds) + .map(id => meshById.get(id)) + .filter(Boolean) + ) + ) + } + } + return meshesByBagId +} + +/** + * @param {import('@tabulous/types').Slot} slot - slot to fill. + * @param {Map} meshesByBagId - randomized meshes per bags. + * @param {import('@tabulous/types').Mesh[]} allMeshes - all meshes + */ +function fillSlot( + { bagId, anchorId, count, ...props }, + meshesByBagId, + allMeshes +) { + const candidates = meshesByBagId.get(bagId) + if (candidates?.length) { + const meshes = candidates.splice(0, count ?? candidates.length) + for (const mesh of meshes) { + mergeProps(mesh, props) + } + if (anchorId) { + const anchor = findAnchor(anchorId, allMeshes) + if (anchor) { + if (anchor.snappedId) { + const mesh = findMesh(anchor.snappedId, allMeshes) + if (mesh) { + meshes.splice(0, 0, mesh) + } + } else { + anchor.snappedId = meshes[0].id + } + } + } + stackMeshes(meshes) + } +} + +/** + * @param {Map} meshesByBagId - list of meshes by bag. + * @param {import('@tabulous/types').Mesh[]} allMeshes - list of all meshes. + */ +function removeDandlingMeshes(meshesByBagId, allMeshes) { + const removedIds = [] + for (const [, meshes] of meshesByBagId) { + removedIds.push(...meshes.map(({ id }) => id)) + } + for (const id of removedIds) { + allMeshes.splice( + allMeshes.findIndex(mesh => mesh.id === id), + 1 + ) + } +} + +/** + * Crawls game data to find mesh and anchor ids that are not unique. + * Reports them on console. + * @param {import('@tabulous/types').GameData} game - checked game data. + * @param {boolean} throwViolations - whether to throw instead of reporting + */ +export function reportReusedIds(game, throwViolations = false) { + const meshes = [...game.meshes, ...game.hands.flatMap(({ meshes }) => meshes)] + const uniqueIds = new Set() + const reusedIds = new Set() + + function check( + /** @type {import('@tabulous/types').Mesh | import('@tabulous/types').Anchor} */ { + id + } + ) { + if (uniqueIds.has(id)) { + reusedIds.add(id) + } else { + uniqueIds.add(id) + } + } + for (const mesh of meshes) { + check(mesh) + mesh.anchorable?.anchors?.forEach(check) + } + if (reusedIds.size) { + const message = `game ${game.kind} (${game.id}) has reused ids: ${[ + ...reusedIds + ].join(', ')}` + if (throwViolations) { + throw new Error(message) + } + console.warn(message) + } +} diff --git a/apps/game-utils/src/hand.js b/apps/game-utils/src/hand.js new file mode 100644 index 00000000..8d0be945 --- /dev/null +++ b/apps/game-utils/src/hand.js @@ -0,0 +1,57 @@ +// @ts-check +import { findAnchor, findMesh } from './mesh.js' +import { mergeProps, popMesh } from './utils.js' + +/** + * Alter game data to draw some meshes from a given anchor into a player's hand. + * Automatically creates player hands if needed. + * If provided anchor has fewer meshes as requested, depletes it. + * @param {import('@tabulous/types').GameData} game - altered game data. + * @param {object} params - operation parameters: + * @param {string} params.playerId - player id for which meshes are drawn. + * @param {string} params.fromAnchor - id of the anchor to draw from. + * @param {number} [params.count = 1] - number of drawn mesh + * @param {any} [params.props = {}] - other props merged into draw meshes. + * @throws {Error} when no anchor or stack could be found + */ +export function drawInHand( + game, + { playerId, count = 1, fromAnchor, props = {} } +) { + const hand = findOrCreateHand(game, playerId) + const meshes = game.meshes + const anchor = findAnchor(fromAnchor, meshes) + const stack = findMesh(anchor.snappedId, meshes, false) + if (!stack) { + throw new Error(`Anchor ${fromAnchor} has no snapped mesh`) + } + for (let i = 0; i < count; i++) { + /** @type {import('@tabulous/types').Mesh} */ + const drawn = + stack.stackable?.stackIds?.length === 0 ? stack : popMesh(stack, meshes) + mergeProps(drawn, props) + hand.meshes.push(drawn) + meshes.splice(meshes.indexOf(drawn), 1) + if (drawn === stack) { + break + } + } + if (stack.stackable?.stackIds?.length === 0) { + anchor.snappedId = null + } +} + +/** + * Finds the hand of a given player, optionally creating it. + * @param {import('@tabulous/types').GameData} game - altered game data. + * @param {string} playerId - player id for which hand is created. + * @returns existing hand, or created one. + */ +export function findOrCreateHand(game, playerId) { + let hand = game.hands.find(hand => hand.playerId === playerId) + if (!hand) { + hand = { playerId, meshes: [] } + game.hands.push(hand) + } + return hand +} diff --git a/apps/game-utils/src/index.js b/apps/game-utils/src/index.js new file mode 100644 index 00000000..e02baf88 --- /dev/null +++ b/apps/game-utils/src/index.js @@ -0,0 +1,7 @@ +// @ts-check +export * from './camera.js' +export * from './collection.js' +export * from './descriptor.js' +export * from './hand.js' +export * from './mesh.js' +export * from './preference.js' diff --git a/apps/game-utils/src/mesh.js b/apps/game-utils/src/mesh.js new file mode 100644 index 00000000..de2fbe08 --- /dev/null +++ b/apps/game-utils/src/mesh.js @@ -0,0 +1,281 @@ +// @ts-check +import merge from 'deepmerge' + +import { mergeProps, popMesh } from './utils.js' + +/** + * @overload + * Finds a mesh by id. + * @param {?string|undefined} id - desired mesh id. + * @param {import('@tabulous/types').Mesh[]} meshes - mesh list to search in. + * @param {boolean} [throwOnMiss=true] - throws if mesh can't be found. + * @returns {import('@tabulous/types').Mesh} corresponding mesh. + * + * @overload + * Finds a mesh by id. + * @param {?string|undefined} id - desired mesh id. + * @param {import('@tabulous/types').Mesh[]} meshes - mesh list to search in. + * @param {false} throwOnMiss - does not throw can't be found. + * @returns {?import('@tabulous/types').Mesh} corresponding mesh, if any. + */ +export function findMesh( + /** @type {?string|undefined} */ id, + /** @type {import('@tabulous/types').Mesh[]} */ meshes, + throwOnMiss = true +) { + const mesh = meshes?.find(mesh => mesh.id === id) ?? null + if (throwOnMiss && !mesh) { + throw new Error(`No mesh with id ${id}`) + } + return mesh +} + +/** + * @overload + * Finds an anchor from a path. + * @param {string} path - path to the desired anchor id, its steps separated with '.'. + * @param {import('@tabulous/types').Mesh[]} meshes - list of mesh to search into. + * @param {boolean} [throwOnMiss=true] + * @returns {import('@tabulous/types').Anchor} the desired anchor. + * @throws {Error} when the anchor could not be found. + * + * @overload + * Finds an anchor from a path. Can return null if no anchor could be found. + * @param {string} path - path to the desired anchor id, its steps separated with '.'. + * @param {import('@tabulous/types').Mesh[]} meshes - list of mesh to search into. + * @param {false} throwOnMiss + * @returns {?import('@tabulous/types').Anchor} the desired anchor, or null if it can't be found. + */ +export function findAnchor( + /** @type {string} */ path, + /** @type {import('@tabulous/types').Mesh[]} */ meshes, + throwOnMiss = true +) { + let candidates = [...(meshes ?? [])] + let anchor + for (let leg of path.split('.')) { + const match = findMeshAndAnchor(leg, candidates, throwOnMiss) + if (!match) { + return null + } + candidates = meshes.filter(({ id }) => id === match.anchor.snappedId) + anchor = match.anchor + } + return anchor ?? null +} + +/** + * @overload + * Finds an anchor and its parent mesh. + * @param {string} anchorId - anchor id. + * @param {import('@tabulous/types').Mesh[]} meshes - list of mesh to search into. + * @param {boolean} [throwOnMiss=true] + * @returns {{ mesh: Mesh; anchor: import('@tabulous/types').Anchor }} tuple of mesh and anchor, if any. + * @throws {Error} when no mesh with such anchor could be found. + * + * @overload + * Finds an anchor and its parent mesh. Can return null id no mesh has such anchor. + * @param {string} anchorId - anchor id. + * @param {import('@tabulous/types').Mesh[]} meshes - list of mesh to search into. + * @param {false} throwOnMiss + * @returns {?{ mesh: Mesh; anchor: import('@tabulous/types').Anchor }} tuple of mesh and anchor, if any. + */ +function findMeshAndAnchor( + /** @type {string} */ anchorId, + /** @type {import('@tabulous/types').Mesh[]} */ meshes, + throwOnMiss = true +) { + for (const mesh of meshes) { + for (const anchor of mesh.anchorable?.anchors ?? []) { + if (anchor.id === anchorId) { + return { mesh, anchor } + } + } + } + if (throwOnMiss) { + throw new Error(`No anchor with id ${anchorId}`) + } + return null +} + +/** + * @overload + * Pop one or several meshes from a given stack. + * @param {string} stackId - id of a stacked mesh to draw from. + * @param {number} count - number of drawned meshes. + * @param {import('@tabulous/types').Mesh[]} meshes - mesh list to search in. + * @param {boolean} [throwOnMiss=true] + * @returns {import('@tabulous/types').Mesh[]} drawn meshes. + * @throws {Error} when not all desired meshes could be drawn. + * + * @overload + * Pop one or several meshes from a given stack. + * Can return a smaller or empty array when not all desired mesh could be drawn. + * @param {string} stackId - id of a stacked mesh to draw from. + * @param {number} count - number of drawned meshes. + * @param {import('@tabulous/types').Mesh[]} meshes - mesh list to search in. + * @param {false} throwOnMiss + * @returns {import('@tabulous/types').Mesh[]} drawn meshes, if any. + */ +export function pop( + /** @type {string} */ stackId, + /** @type {number} */ count, + /** @type {import('@tabulous/types').Mesh[]} */ meshes, + throwOnMiss = true +) { + const drawn = [] + const stack = findMesh(stackId, meshes, throwOnMiss) + if (stack) { + for (let i = 0; i < count; i++) { + const mesh = popMesh(stack, meshes, throwOnMiss) + if (!mesh) { + if (stack.stackable?.stackIds?.length === 0) { + drawn.push(stack) + } + break + } + drawn.push(mesh) + } + } + return drawn +} +/** + * Stack all provided meshes, in order (the first becomes stack base). + * @param {import('@tabulous/types').Mesh[]} meshes - stacked meshes. + */ +export function stackMeshes(meshes) { + const [base, ...others] = meshes + const stackIds = others.map(({ id }) => id) + if (stackIds.length) { + mergeProps(base, { stackable: { stackIds } }) + } +} + +/** + * @overload + * Snap a given mesh onto the specified anchor. + * Search for the anchor within provided meshes. + * If the anchor is already used, tries to stack the meshes (the current snapped mesh must be in provided meshes). + * Abort the operation when meshes can't be stacked. + * @param {string} anchorId - desired anchor id. + * @param {?import('@tabulous/types').Mesh} mesh - snapped mesh, if any. + * @param {import('@tabulous/types').Mesh[]} meshes - all meshes to search the anchor in. + * @param {boolean} [throwOnMiss=true] + * @return {boolean} true if the mesh could be snapped or stacked. False otherwise. + * @throws {Error} when mesh could not be snapped to anchor. + * + * @overload + * Snap a given mesh onto the specified anchor. + * Search for the anchor within provided meshes. + * If the anchor is already used, tries to stack the meshes (the current snapped mesh must be in provided meshes). + * Abort the operation when meshes can't be stacked, or if mesh can't be snapped to anchor. + * @param {string} anchorId - desired anchor id. + * @param {?import('@tabulous/types').Mesh} mesh - snapped mesh, if any. + * @param {import('@tabulous/types').Mesh[]} meshes - all meshes to search the anchor in. + * @param {false} throwOnMiss + * @return {boolean} true if the mesh could be snapped or stacked. False otherwise. + */ +export function snapTo( + /** @type {string} */ anchorId, + /** @type {?import('@tabulous/types').Mesh} */ mesh, + /** @type {import('@tabulous/types').Mesh[]} */ meshes, + throwOnMiss = true +) { + const anchor = findAnchor(anchorId, meshes, throwOnMiss) + if (!anchor || !mesh) { + if (throwOnMiss) { + throw new Error(`No mesh to snap on anchor ${anchorId}`) + } + return false + } + if (anchor.snappedId) { + const snapped = findMesh(anchor.snappedId, meshes, throwOnMiss) + if (!canStack(snapped, mesh)) { + return false + } + stackMeshes([snapped, mesh]) + } else { + anchor.snappedId = mesh.id + } + return true +} + +/** + * @overload + * Unsnapps a mesh from a given anchor. + * @param {string} anchorId - desired anchor id. + * @param {import('@tabulous/types').Mesh[]} meshes - all meshes to search the anchor in. + * @param {boolean} [throwOnMiss=true] + * @returns {?import('@tabulous/types').Mesh} unsnapped meshes, or null if anchor has no snapped mesh + * @throws {Error} when anchor (or snapped mesh) could not be found. + * + * @overload + * Unsnapps a mesh from a given anchor. Can return null if anchor (or snapped mesh) could not be found. + * @param {string} anchorId - desired anchor id. + * @param {import('@tabulous/types').Mesh[]} meshes - all meshes to search the anchor in. + * @param {false} throwOnMiss + * @returns {?import('@tabulous/types').Mesh} unsnapped meshes, or null if anchor does not exist, has no snapped mesh. + */ +export function unsnap( + /** @type {string} */ anchorId, + /** @type {import('@tabulous/types').Mesh[]} */ meshes, + throwOnMiss = true +) { + const anchor = findAnchor(anchorId, meshes, throwOnMiss) + if (!anchor || !anchor.snappedId) { + if (throwOnMiss) { + throw new Error(`Anchor ${anchorId} has no snapped mesh`) + } + return null + } + const id = anchor.snappedId + anchor.snappedId = null + return findMesh(id, meshes, throwOnMiss) +} + +/** + * @param {?import('@tabulous/types').Mesh|undefined} base - mesh base stack. + * @param {?import('@tabulous/types').Mesh|undefined} mesh - mesh to stack onto the base. + * @returns {boolean} whether this mesh can be stacked. + */ +function canStack(base, mesh) { + return Boolean(base?.stackable) && Boolean(mesh?.stackable) +} + +/** + * @overload + * Decrements a quantifiable mesh, by creating another one. + * @param {?import('@tabulous/types').Mesh|undefined} mesh - quantifiable mesh + * @param {boolean} [throwOnMiss=true] + * @returns {import('@tabulous/types').Mesh} the created object + * @throws {Error} when mesh can not be found or decremented. + * + * @overload + * Decrements a quantifiable mesh, by creating another one. + * Can return null if mesh can not be found or decremented. + * @param {?import('@tabulous/types').Mesh|undefined} mesh - quantifiable mesh + * @param {false} throwOnMiss + * @returns {?import('@tabulous/types').Mesh} the created object, when relevant + * + * @param {?import('@tabulous/types').Mesh|undefined} mesh + * @returns {?import('@tabulous/types').Mesh|undefined} + */ +export function decrement(mesh, throwOnMiss = true) { + if ( + mesh?.quantifiable?.quantity !== undefined && + mesh.quantifiable.quantity > 1 + ) { + const clone = merge(mesh, { + id: `${mesh.id}-${crypto.randomUUID()}`, + quantifiable: { quantity: 1 } + }) + mesh.quantifiable.quantity-- + return clone + } + if (throwOnMiss) { + throw new Error( + `Mesh ${mesh?.id} is not quantifiable or has a quantity of 1` + ) + } + return null +} diff --git a/apps/game-utils/src/preference.js b/apps/game-utils/src/preference.js new file mode 100644 index 00000000..43480d0b --- /dev/null +++ b/apps/game-utils/src/preference.js @@ -0,0 +1,17 @@ +// @ts-check + +/** + * Returns all possible values of a preference that were not picked by other players. + * For example: `findAvailableValues(preferences, 'color', colors.players)` returns available colors. + * + * @template T + * @param {import('@tabulous/types').PlayerPreference[]} preferences - list of player preferences objects. + * @param {string} name - name of the preference considered. + * @param {T[]} possibleValues - list of possible values. + * @returns {T[]} filtered possible values (could be empty). + */ +export function findAvailableValues(preferences, name, possibleValues) { + return possibleValues.filter(value => + preferences.every(pref => value !== pref[name]) + ) +} diff --git a/apps/game-utils/src/utils.js b/apps/game-utils/src/utils.js new file mode 100644 index 00000000..52e6fd1d --- /dev/null +++ b/apps/game-utils/src/utils.js @@ -0,0 +1,47 @@ +// @ts-check +import merge from 'deepmerge' + +import { findMesh } from './mesh.js' + +/** + * Deeply merge properties into an object + * @template {Record} Base + * @template {Record} Extension + * @param {Base} object - the object to extend. + * @param {Extension} props - an object deeply merged into the source. + * @returns {Base & { [x: keyof Extension]: any }} the extended object. + */ +export function mergeProps(object, props) { + return Object.assign(object, merge(object, props)) +} + +/** + * @overload + * Pop a single mesh from a stack. + * @param {import('@tabulous/types').Mesh} stack - stacked mesh to draw from. + * @param {import('@tabulous/types').Mesh[]} meshes - mesh list to search in. + * @param {boolean} [throwOnMiss=true] + * @returns {import('@tabulous/types').Mesh} drawn mesh. + * @throws {Error} when no mesh could be drawn. + * + * @overload + * Pop a single mesh from a stack. Can return null if no mesh could be drawn. + * @param {import('@tabulous/types').Mesh} stack - stacked mesh to draw from. + * @param {import('@tabulous/types').Mesh[]} meshes - mesh list to search in. + * @param {false} throwOnMiss + * @returns {?import('@tabulous/types').Mesh} drawn meshes, if any. + */ +export function popMesh( + /** @type {import('@tabulous/types').Mesh} */ stack, + /** @type {import('@tabulous/types').Mesh[]} */ meshes, + throwOnMiss = true +) { + if (!stack.stackable?.stackIds && throwOnMiss) { + throw new Error(`Mesh ${stack.id} is not stackable`) + } + if (stack.stackable?.stackIds?.length) { + const id = stack.stackable.stackIds.pop() + return findMesh(id, meshes, throwOnMiss) + } + return null +} diff --git a/apps/game-utils/tests/camera.test.js b/apps/game-utils/tests/camera.test.js new file mode 100644 index 00000000..b8a7eea4 --- /dev/null +++ b/apps/game-utils/tests/camera.test.js @@ -0,0 +1,46 @@ +// @ts-check +import { faker } from '@faker-js/faker' +import { describe, expect, it } from 'vitest' + +import { buildCameraPosition } from '../src/camera.js' + +describe('buildCameraPosition()', () => { + it('applies all defaults', () => { + const playerId = faker.string.uuid() + expect(buildCameraPosition({ playerId })).toEqual({ + playerId, + index: 0, + target: [0, 0, 0], + alpha: (Math.PI * 3) / 2, + beta: Math.PI / 8, + elevation: 35, + hash: '0-0-0-4.71238898038469-0.39269908169872414-35' + }) + }) + + it('throws on missing player id', () => { + expect(() => buildCameraPosition({})).toThrow( + 'camera position requires playerId' + ) + }) + + it('uses provided data and computes hash', () => { + const playerId = faker.string.uuid() + const index = faker.number.int() + const alpha = faker.number.int() + const beta = faker.number.int() + const elevation = faker.number.int() + const target = [faker.number.int(), faker.number.int(), faker.number.int()] + expect( + buildCameraPosition({ playerId, index, alpha, beta, elevation, target }) + ).toEqual({ + playerId, + index, + target, + alpha, + beta, + elevation, + hash: `${target[0]}-${target[1]}-${target[2]}-${alpha}-${beta}-${elevation}` + }) + }) +}) diff --git a/apps/server/tests/utils/collections.test.js b/apps/game-utils/tests/collection.test.js similarity index 95% rename from apps/server/tests/utils/collections.test.js rename to apps/game-utils/tests/collection.test.js index ce106e08..63a2c43e 100644 --- a/apps/server/tests/utils/collections.test.js +++ b/apps/game-utils/tests/collection.test.js @@ -1,7 +1,7 @@ // @ts-check import { describe, expect, it } from 'vitest' -import { pickRandom, shuffle } from '../../src/utils/index.js' +import { pickRandom, shuffle } from '../src/collection.js' describe('shuffle()', () => { it('randomizes elements of an array, leaving source array unmodified', () => { diff --git a/apps/game-utils/tests/descriptor.test.js b/apps/game-utils/tests/descriptor.test.js new file mode 100644 index 00000000..6a22a58e --- /dev/null +++ b/apps/game-utils/tests/descriptor.test.js @@ -0,0 +1,687 @@ +// @ts-check +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { + createMeshes, + enrichAssets, + reportReusedIds +} from '../src/descriptor.js' +import { + expectSnappedByName, + expectStackedOnSlot, + makeGame +} from './test-utils.js' + +describe('createMeshes()', () => { + it('throws on invalid descriptor', async () => { + const kind = faker.company.name() + // @ts-expect-error + await expect(createMeshes(kind)).rejects.toThrow( + `Game ${kind} does not export a build() function` + ) + await expect(createMeshes(kind, {})).rejects.toThrow( + `Game ${kind} does not export a build() function` + ) + }) + + it('ignores missing mesh', async () => { + const ids = Array.from({ length: 3 }, (_, i) => `card-${i + 1}`) + const descriptor = { + build: () => ({ + meshes: ids.map(id => ({ + id, + texture: '', + shape: /** @type {const} */ ('box') + })), + bags: new Map([['cards', ['card-null', 'card-1', 'card', 'card-2']]]), + slots: [{ bagId: 'cards', x: 1, y: 2, z: 3 }] + }) + } + expect(await createMeshes('cards', descriptor)).toEqual( + expect.arrayContaining( + descriptor.build().meshes.map(expect.objectContaining) + ) + ) + }) + + it('ignores missing bags', async () => { + const ids = Array.from({ length: 3 }, (_, i) => `card-${i + 1}`) + const descriptor = { + build: () => ({ + meshes: ids.map(id => ({ + id, + texture: '', + shape: /** @type {const} */ ('box') + })), + bags: new Map([['cards', ids]]), + slots: [{ bagId: 'unknown', x: 1, y: 2, z: 3 }, { bagId: 'cards' }] + }) + } + expect(await createMeshes('cards', descriptor)).toEqual( + descriptor.build().meshes.map(expect.objectContaining) + ) + }) + + it('trims out mesh dandling in bags', async () => { + const ids = Array.from({ length: 3 }, (_, i) => `card-${i + 1}`) + const descriptor = { + build: () => ({ + meshes: ids.map(id => ({ + id, + texture: '', + shape: /** @type {const} */ ('box') + })), + bags: new Map([['cards', ids]]) + }) + } + expect(await createMeshes('cards', descriptor)).toEqual([]) + }) + + it('ignores no bags', async () => { + const ids = Array.from({ length: 3 }, (_, i) => `card-${i + 1}`) + const descriptor = { + build: () => ({ + meshes: ids.map(id => ({ + id, + texture: '', + shape: /** @type {const} */ ('box') + })), + slots: [{ bagId: 'unknown', x: 1, y: 2, z: 3 }] + }) + } + expect(await createMeshes('cards', descriptor)).toEqual( + descriptor.build().meshes + ) + }) + + describe('given a descriptor with a count slot and a countless slot on the same bag', () => { + const ids = Array.from({ length: 10 }, (_, i) => `card-${i + 1}`) + const descriptor = { + build: () => ({ + meshes: ids.map(id => ({ + id, + texture: '', + shape: /** @type {const} */ ('box') + })), + bags: new Map([['cards', ids]]), + slots: [ + { bagId: 'cards', x: 1, y: 2, z: 3, count: 2 }, + { bagId: 'cards', x: 2, y: 3, z: 4 } + ] + }) + } + + it('stacks meshes on slots with random order', async () => { + const { + meshes: originals, + slots: [slot1, slot2] + } = descriptor.build() + const meshes = await createMeshes('cards', descriptor) + expect(meshes).toEqual( + expect.arrayContaining(originals.map(expect.objectContaining)) + ) + expect(meshes).not.toEqual(originals) + expectStackedOnSlot(meshes, slot1) + expectStackedOnSlot(meshes, slot2, 8) + }) + + it('applies different slot order on different games', async () => { + const { meshes: originals } = descriptor.build() + const meshes1 = await createMeshes('cards', descriptor) + const meshes2 = await createMeshes('cards', descriptor) + expect(meshes1).not.toEqual(originals) + expect(meshes2).not.toEqual(originals) + expect(meshes1).not.toEqual(meshes2) + }) + }) + + describe('given a descriptor with multiple count slots on the same bag', () => { + const ids = Array.from({ length: 10 }, (_, i) => `card-${i + 1}`) + const descriptor = { + build: () => ({ + meshes: ids.map(id => ({ + id, + texture: '', + shape: /** @type {const} */ ('box') + })), + bags: new Map([['cards', ids]]), + slots: [ + { bagId: 'cards', x: 1, y: 2, z: 3, count: 2 }, + { bagId: 'cards', x: 2, y: 3, z: 4, count: 3 } + ] + }) + } + + it('removes remaining meshes after processing all slots', async () => { + const { + slots: [slot1, slot2] + } = descriptor.build() + const meshes = await createMeshes('cards', descriptor) + expect(meshes).toHaveLength(slot1.count + slot2.count) + expectStackedOnSlot(meshes, slot1) + expectStackedOnSlot(meshes, slot2) + }) + }) + + describe('given a descriptor with multiple slots on the same anchor', () => { + const ids = Array.from({ length: 10 }, (_, i) => `card-${i + 1}`) + const boardId = 'board' + const descriptor = { + build: () => ({ + meshes: [ + ...ids.map(id => ({ + id, + texture: '', + shape: /** @type {const} */ ('box') + })), + { + id: boardId, + texture: '', + shape: /** @type {const} */ ('box'), + anchorable: { anchors: [{ id: 'anchor' }] } + } + ], + bags: new Map([['cards', ids]]), + slots: [ + { bagId: 'cards', anchorId: 'anchor', count: 2 }, + { bagId: 'cards', anchorId: 'anchor', count: 3 }, + { bagId: 'cards', anchorId: 'anchor' } + ] + }) + } + + it('push meshes to the same stack', async () => { + const { + slots: [slot] + } = descriptor.build() + const meshes = await createMeshes('cards', descriptor) + expect(meshes).toHaveLength(ids.length + 1) + const { id: stackId } = expectStackedOnSlot(meshes, slot, ids.length) + const board = meshes.find(({ id }) => id === boardId) + expect(board).toBeDefined() + expect(board?.anchorable?.anchors?.[0]?.snappedId).toEqual(stackId) + }) + }) + + describe('given a descriptor with anchorable board', () => { + const ids = Array.from({ length: 10 }, (_, i) => `card-${i + 1}`) + const initialMeshes = [ + { + id: 'board', + texture: '', + shape: /** @type {const} */ ('box'), + anchorable: { + anchors: [{ id: 'first' }, { id: 'second' }, { id: 'third' }] + } + }, + ...ids.map(id => ({ + id, + texture: '', + shape: /** @type {const} */ ('box'), + anchorable: { anchors: [{ id: 'top' }, { id: 'bottom' }] } + })) + ] + const bags = new Map([['cards', ids]]) + + it('snaps a random mesh on anchor', async () => { + const slots = [ + { bagId: 'cards', anchorId: 'first', count: 1, name: 'first' }, + { bagId: 'cards', anchorId: 'third', count: 1, name: 'third' } + ] + const meshes = await createMeshes('cards', { + build: () => ({ meshes: initialMeshes, slots, bags }) + }) + + const board = meshes.find(({ id }) => id === 'board') + expect(board).toBeDefined() + + expectSnappedByName( + meshes, + slots[0].name, + board?.anchorable?.anchors?.[0] + ) + expect(board?.anchorable?.anchors?.[1].snappedId).toBeUndefined() + expectSnappedByName( + meshes, + slots[1].name, + board?.anchorable?.anchors?.[2] + ) + }) + + it('snaps a random mesh on chained anchor', async () => { + const slots = [ + { bagId: 'cards', anchorId: 'second', count: 1, name: 'base' }, + { bagId: 'cards', anchorId: 'second.top', count: 1, name: 'top' }, + { bagId: 'cards', anchorId: 'second.bottom', count: 1, name: 'bottom' } + ] + const meshes = await createMeshes('cards', { + build: () => ({ meshes: initialMeshes, slots, bags }) + }) + + const board = meshes.find(({ id }) => id === 'board') + expect(board).toBeDefined() + + expect(board?.anchorable?.anchors?.[0].snappedId).toBeUndefined() + expectSnappedByName(meshes, 'base', board?.anchorable?.anchors?.[1]) + expect(board?.anchorable?.anchors?.[2].snappedId).toBeUndefined() + + const base = meshes.find(mesh => 'name' in mesh && mesh.name === 'base') + expectSnappedByName(meshes, 'top', base?.anchorable?.anchors?.[0]) + expectSnappedByName(meshes, 'bottom', base?.anchorable?.anchors?.[1]) + }) + + it('snaps a random mesh on long chained anchor', async () => { + const slots = [ + { bagId: 'cards', anchorId: 'second', count: 1, name: 'base' }, + { bagId: 'cards', anchorId: 'second.top', count: 1, name: 'first' }, + { + bagId: 'cards', + anchorId: 'second.top.top', + count: 1, + name: 'second' + }, + { + bagId: 'cards', + anchorId: 'second.top.top.bottom', + count: 1, + name: 'third' + } + ] + const meshes = await createMeshes('cards', { + build: () => ({ meshes: initialMeshes, slots, bags }) + }) + + const board = meshes.find(({ id }) => id === 'board') + expect(board).toBeDefined() + + expect(board?.anchorable?.anchors?.[0].snappedId).toBeUndefined() + expectSnappedByName(meshes, 'base', board?.anchorable?.anchors?.[1]) + expect(board?.anchorable?.anchors?.[2].snappedId).toBeUndefined() + + const base = meshes.find(mesh => 'name' in mesh && mesh.name === 'base') + expectSnappedByName(meshes, 'first', base?.anchorable?.anchors?.[0]) + expect(base?.anchorable?.anchors?.[1].snappedId).toBeUndefined() + + const first = meshes.find(mesh => 'name' in mesh && mesh.name === 'first') + expectSnappedByName(meshes, 'second', first?.anchorable?.anchors?.[0]) + expect(first?.anchorable?.anchors?.[1].snappedId).toBeUndefined() + + const second = meshes.find( + mesh => 'name' in mesh && mesh.name === 'second' + ) + expectSnappedByName(meshes, 'third', second?.anchorable?.anchors?.[1]) + expect(second?.anchorable?.anchors?.[0].snappedId).toBeUndefined() + + const third = meshes.find(mesh => 'name' in mesh && mesh.name === 'third') + expect(third?.anchorable?.anchors?.[0].snappedId).toBeUndefined() + expect(third?.anchorable?.anchors?.[1].snappedId).toBeUndefined() + }) + + it('can stack on top of an anchor', async () => { + const slots = [ + { bagId: 'cards', anchorId: 'second', count: 3, name: 'base' } + ] + const meshes = await createMeshes('cards', { + build: () => ({ + meshes: [ + initialMeshes[0], + ...ids.map(id => ({ + id, + texture: '', + shape: /** @type {const} */ ('box') + })) + ], + slots, + bags + }) + }) + + const board = meshes.find(({ id }) => id === 'board') + const snapped = meshes.filter( + mesh => 'name' in mesh && mesh.name === 'base' + ) + expect(board).toBeDefined() + expect(snapped).toHaveLength(3) + expect(board?.anchorable?.anchors?.[0].snappedId).toBeUndefined() + const base = snapped.filter(mesh => mesh.stackable) + expect(base).toHaveLength(1) + expect(base?.[0]?.stackable?.stackIds).toEqual( + expect.arrayContaining( + snapped.filter(mesh => mesh !== base[0]).map(({ id }) => id) + ) + ) + }) + + it('can mix stack and anchors', async () => { + const slots = [ + { bagId: 'cards', anchorId: 'second', count: 1, name: 'base' }, + { bagId: 'cards', x: 1, z: 2 } + ] + const meshes = await createMeshes('cards', { + build: () => ({ + meshes: [ + ...ids.map(id => ({ + id, + texture: '', + shape: /** @type {const} */ ('box') + })), + initialMeshes[0] + ], + slots, + bags + }) + }) + + const board = meshes.find(({ id }) => id === 'board') + const base = meshes.find(mesh => 'name' in mesh && mesh.name === 'base') + expect(board).toBeDefined() + expect(base).toBeDefined() + expect(base?.x).toBeUndefined() + expect(board?.anchorable?.anchors?.[0].snappedId).toBeUndefined() + expectSnappedByName(meshes, 'base', board?.anchorable?.anchors?.[1]) + expect(board?.anchorable?.anchors?.[2].snappedId).toBeUndefined() + expect( + meshes + .filter( + mesh => + 'name' in mesh && mesh.name !== 'base' && mesh.id !== 'board' + ) + .every(({ x }) => x === 1) + ).toBe(true) + }) + }) + + describe('given a descriptor with multiple slots on the same bag', () => { + const ids = Array.from({ length: 10 }, (_, i) => `card-${i + 1}`) + const descriptor = { + build: () => ({ + meshes: ids.map(id => ({ + id, + texture: '', + shape: /** @type {const} */ ('box') + })), + bags: new Map([['cards', ids]]), + slots: [ + { bagId: 'cards', x: 10, count: 2 }, + { bagId: 'cards', x: 5, count: 1 }, + { bagId: 'cards', x: 1 } + ] + }) + } + + it('draws meshes to fill slots', async () => { + const meshes = await createMeshes('cards', descriptor) + expect(meshes).toEqual( + expect.arrayContaining( + descriptor.build().meshes.map(expect.objectContaining) + ) + ) + expect( + meshes.filter(({ stackable }) => stackable?.stackIds?.length === 1) + ).toHaveLength(1) + expect(meshes.filter(({ x }) => x === 10)).toHaveLength(2) + expect(meshes.filter(({ x }) => x === 5)).toHaveLength(1) + expect( + meshes.filter(({ stackable }) => stackable?.stackIds?.length === 6) + ).toHaveLength(1) + expect(meshes.filter(({ x }) => x === 1)).toHaveLength(7) + }) + }) +}) + +describe('enrichAssets()', () => { + it('enriches mesh relative texture', () => { + const kind = faker.lorem.word() + const texture = faker.system.commonFileName('png') + expect( + enrichAssets( + makeGame({ + kind, + meshes: [{ id: '1', shape: 'box', texture }] + }) + ).meshes?.[0].texture + ).toEqual(`/${kind}/textures/${texture}`) + }) + + it('does not enrich mesh absolute texture', () => { + const kind = faker.lorem.word() + const texture = faker.system.filePath() + expect( + enrichAssets( + makeGame({ + kind, + meshes: [{ id: '1', shape: 'box', texture }] + }) + ).meshes?.[0].texture + ).toEqual(texture) + }) + + it('does not enrich mesh colored texture', () => { + const kind = faker.lorem.word() + const texture = faker.internet.color() + expect( + enrichAssets( + makeGame({ + kind, + meshes: [{ id: '1', shape: 'box', texture }] + }) + ).meshes?.[0].texture + ).toEqual(texture) + }) + + it('enriches mesh relative model', () => { + const kind = faker.lorem.word() + const file = faker.system.commonFileName('png') + expect( + enrichAssets( + makeGame({ + kind, + meshes: [{ id: '1', shape: 'box', texture: '', file }] + }) + ).meshes?.[0].file + ).toEqual(`/${kind}/models/${file}`) + }) + + it('does not enrich mesh absolute model', () => { + const kind = faker.lorem.word() + const file = faker.system.filePath() + expect( + enrichAssets( + makeGame({ + kind, + meshes: [{ id: '1', shape: 'box', texture: '', file }] + }) + ).meshes?.[0].file + ).toEqual(file) + }) + + it('enriches mesh relative front image', () => { + const kind = faker.lorem.word() + const frontImage = faker.system.commonFileName('png') + expect( + enrichAssets( + makeGame({ + kind, + meshes: [ + { id: '1', shape: 'box', texture: '', detailable: { frontImage } } + ] + }) + ).meshes?.[0].detailable?.frontImage + ).toEqual(`/${kind}/images/${frontImage}`) + }) + + it('does not enrich mesh absolute front image', () => { + const kind = faker.lorem.word() + const frontImage = faker.system.filePath() + expect( + enrichAssets( + makeGame({ + kind, + meshes: [ + { id: '1', shape: 'box', texture: '', detailable: { frontImage } } + ] + }) + ).meshes?.[0].detailable?.frontImage + ).toEqual(frontImage) + }) + + it('enriches mesh relative back image', () => { + const kind = faker.lorem.word() + const backImage = faker.system.commonFileName('png') + expect( + enrichAssets( + makeGame({ + kind, + meshes: [ + { + id: '1', + shape: 'box', + texture: '', + detailable: { frontImage: '', backImage } + } + ] + }) + ).meshes?.[0].detailable?.backImage + ).toEqual(`/${kind}/images/${backImage}`) + }) + + it('does not enrich mesh absolute front image', () => { + const kind = faker.lorem.word() + const frontImage = faker.system.filePath() + expect( + enrichAssets( + makeGame({ + kind, + meshes: [ + { id: '1', shape: 'box', texture: '', detailable: { frontImage } } + ] + }) + ).meshes?.[0]?.detailable?.frontImage + ).toEqual(frontImage) + }) + + it('enriches all mesh relative assets', () => { + const kind = faker.lorem.word() + const texture = faker.system.commonFileName('png') + const file = faker.system.commonFileName('png') + const frontImage = faker.system.commonFileName('png') + const backImage = faker.system.commonFileName('png') + const { + hands: [ + { + meshes: [mesh] + } + ] + } = /** @type {import('@tabulous/types').StartedGame} */ ( + enrichAssets( + makeGame({ + kind, + hands: [ + { + playerId: 'foo', + meshes: [ + { + id: '1', + shape: 'box', + texture, + file, + detailable: { frontImage, backImage } + } + ] + } + ] + }) + ) + ) + expect(mesh.texture).toEqual(`/${kind}/textures/${texture}`) + expect(mesh.file).toEqual(`/${kind}/models/${file}`) + expect(mesh.detailable?.frontImage).toEqual(`/${kind}/images/${frontImage}`) + expect(mesh.detailable?.backImage).toEqual(`/${kind}/images/${backImage}`) + }) +}) + +describe('reportReusedIds()', () => { + const warn = vi.spyOn(console, 'warn') + const game = makeGame({ + name: 'test-game' + }) + + beforeEach(() => { + vi.resetAllMocks() + }) + + it('does not report valid descriptor', () => { + reportReusedIds({ + ...game, + meshes: [ + { id: 'box1', shape: 'box', texture: '' }, + { id: 'box2', shape: 'box', texture: '' } + ], + hands: [ + { + playerId: 'a', + meshes: [ + { id: 'box3', shape: 'box', texture: '' }, + { id: 'box4', shape: 'box', texture: '' } + ] + }, + { + playerId: 'b', + meshes: [{ id: 'box5', shape: 'box', texture: '' }] + } + ] + }) + expect(warn).not.toHaveBeenCalled() + }) + + it('reports reused mesh ids', () => { + reportReusedIds({ + ...game, + meshes: [ + { id: 'box1', shape: 'box', texture: '' }, + { id: 'box2', shape: 'box', texture: '' }, + { id: 'box3', shape: 'box', texture: '' }, + { id: 'box1', shape: 'box', texture: '' } + ], + hands: [ + { + playerId: 'a', + meshes: [{ id: 'box3', shape: 'box', texture: '' }] + } + ] + }) + expect(warn).toHaveBeenCalledOnce() + expect(warn).toHaveBeenCalledWith(expect.stringContaining('box1, box3')) + }) + + it('reports reused anchor ids', () => { + reportReusedIds({ + ...game, + meshes: [ + { + id: 'box1', + shape: 'box', + texture: '', + anchorable: { anchors: [{ id: 'anchor1' }] } + }, + { id: 'box2', shape: 'box', texture: '' } + ], + hands: [ + { + playerId: 'a', + meshes: [ + { + id: 'box3', + shape: 'box', + texture: '', + anchorable: { anchors: [{ id: 'anchor1' }, { id: 'box2' }] } + } + ] + } + ] + }) + expect(warn).toHaveBeenCalledOnce() + expect(warn).toHaveBeenCalledWith(expect.stringContaining('anchor1, box2')) + }) +}) diff --git a/apps/server/tests/games-test.js b/apps/game-utils/tests/game.js similarity index 83% rename from apps/server/tests/games-test.js rename to apps/game-utils/tests/game.js index df7a091d..a05eac75 100644 --- a/apps/server/tests/games-test.js +++ b/apps/game-utils/tests/game.js @@ -1,42 +1,30 @@ // @ts-check -/** - * @typedef {import('@tabulous/server/src/graphql').Game} Game - * @typedef {import('@tabulous/server/src/services/catalog').AddPlayer} AddPlayer - * @typedef {import('@tabulous/server/src/services/catalog').Build} Build - * @typedef {import('@tabulous/server/src/services/catalog').GameDescriptor} GameDescriptor - * @typedef {import('@tabulous/server/src/services/games').Schema} Schema - * @typedef {import('@tabulous/server/src/services/games').StartedGameData} StartedGameData - * @typedef {import('@tabulous/server/src/services/players').Player} Player - * @typedef {import('@tabulous/server/src/utils/games').GameSetup} GameSetup - */ - import { faker } from '@faker-js/faker' -import { - ajv, - createMeshes, - pickRandom, - reportReusedIds -} from '@tabulous/server/src/utils/index.js' +import Ajv from 'ajv/dist/2020.js' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createMeshes, pickRandom, reportReusedIds } from '../src' + +const ajv = new Ajv({ + $data: true, + allErrors: true, + strictSchema: false +}) + +/** @template {Record} Parameters */ export function buildDescriptorTestSuite( /** @type {string} */ name, - /** @type {GameDescriptor} */ descriptor + /** @type {Partial>} */ descriptor ) { describe(`${name} game descriptor`, () => { - vi.mock('node:crypto', async () => { - // no random factor so we get stable UUIDs. - const actual = /** @type {?} */ (await vi.importActual('node:crypto')) - let counter = 1 - return { - ...actual, - randomUUID: () => `00000000-0000-0000-0000-000000${counter++}` - } - }) + let counter = 1 beforeEach(() => { // no random factor so we get stable results with game random bags. vi.spyOn(Math, 'random').mockReturnValue(0) + vi.spyOn(crypto, 'randomUUID').mockImplementation( + () => `00000000-0000-0000-0000-000000${counter++}` + ) }) it('exports a compliant game descriptor', () => { @@ -108,7 +96,10 @@ export function buildDescriptorTestSuite( () => { it('enrolls each allowed players with a valid JSON schema', async () => { let game = await buildGame( - /** @type {GameDescriptor} */ ({ ...descriptor, name }) + /** @type {import('@tabulous/types').GameDescriptor} */ ({ + ...descriptor, + name + }) ) for (let rank = 1; rank <= (descriptor.maxSeats ?? 8); rank++) { const player = makePlayer(rank) @@ -137,7 +128,7 @@ export function buildDescriptorTestSuite( }) } -/** @returns {Player} */ +/** @returns {import('@tabulous/types').Player} */ function makePlayer(/** @type {number} */ rank) { return { id: `player-${rank}`, @@ -146,8 +137,10 @@ function makePlayer(/** @type {number} */ rank) { } } -/** @returns {Promise} */ -async function buildGame(/** @type {GameDescriptor} */ descriptor) { +/** @returns {Promise} */ +async function buildGame( + /** @type {import('@tabulous/types').GameDescriptor} */ descriptor +) { return { id: 'game-unique-id', kind: descriptor.name, @@ -167,10 +160,11 @@ async function buildGame(/** @type {GameDescriptor} */ descriptor) { } } +/** @template {Record} Parameters */ async function enroll( - /** @type {GameDescriptor} */ descriptor, - /** @type {StartedGameData} */ game, - /** @type {Player} */ guest, + /** @type {Partial>} */ descriptor, + /** @type {import('@tabulous/types').StartedGame} */ game, + /** @type {import('@tabulous/types').Player} */ guest, /** @type {Record} */ parameters ) { game.playerIds.push(guest.id) @@ -184,12 +178,14 @@ async function enroll( game.preferences.map(({ color }) => color) ) }) - return await /** @type {GameDescriptor & { addPlayer: AddPlayer }} */ ( + return await /** @type {import('@tabulous/types').GameDescriptor & { addPlayer: import('@tabulous/types').AddPlayer }} */ ( descriptor ).addPlayer(game, guest, /** @type {?} */ (parameters)) } -function buildParameters(/** @type {?Schema} */ schema) { +function buildParameters( + /** @type {?import('@tabulous/types').Schema} */ schema +) { /** @type {Record} */ const result = {} if (schema?.type === 'object') { diff --git a/apps/game-utils/tests/hand.test.js b/apps/game-utils/tests/hand.test.js new file mode 100644 index 00000000..add2c399 --- /dev/null +++ b/apps/game-utils/tests/hand.test.js @@ -0,0 +1,178 @@ +// @ts-check +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it } from 'vitest' + +import { drawInHand, findOrCreateHand } from '../src/hand.js' +import { makeGame } from './test-utils.js' + +describe('drawInHand()', () => { + const playerId = faker.string.uuid() + /** @type {import('@tabulous/types').StartedGame} */ + let game + + beforeEach(() => { + game = makeGame({ + meshes: [ + { id: 'A', texture: '', shape: 'box' }, + { + id: 'B', + texture: '', + shape: 'box', + anchorable: { anchors: [{ id: 'discard', snappedId: 'C' }] } + }, + { + id: 'C', + texture: '', + shape: 'box', + stackable: { stackIds: ['A', 'E', 'D'] } + }, + { id: 'D', texture: '', shape: 'box' }, + { id: 'E', texture: '', shape: 'box' } + ] + }) + }) + + it('throws error on unknown anchor', () => { + expect(() => drawInHand(game, { playerId, fromAnchor: 'unknown' })).toThrow( + `No anchor with id unknown` + ) + }) + + it('draws one mesh into a new hand', () => { + drawInHand(game, { playerId, fromAnchor: 'discard' }) + expect(game).toEqual( + expect.objectContaining({ + hands: [{ playerId, meshes: [{ id: 'D', texture: '', shape: 'box' }] }], + meshes: [ + { id: 'A', texture: '', shape: 'box' }, + { + id: 'B', + texture: '', + shape: 'box', + anchorable: { anchors: [{ id: 'discard', snappedId: 'C' }] } + }, + { + id: 'C', + texture: '', + shape: 'box', + stackable: { stackIds: ['A', 'E'] } + }, + { id: 'E', texture: '', shape: 'box' } + ] + }) + ) + }) + + it('draws multiple meshes into a new hand', () => { + const props = { foo: 'bar' } + drawInHand(game, { playerId, count: 2, fromAnchor: 'discard', props }) + expect(game).toEqual( + expect.objectContaining({ + hands: [ + { + playerId, + meshes: [ + { id: 'D', texture: '', shape: 'box', ...props }, + { id: 'E', texture: '', shape: 'box', ...props } + ] + } + ], + meshes: [ + { id: 'A', texture: '', shape: 'box' }, + { + id: 'B', + texture: '', + shape: 'box', + anchorable: { anchors: [{ id: 'discard', snappedId: 'C' }] } + }, + { id: 'C', texture: '', shape: 'box', stackable: { stackIds: ['A'] } } + ] + }) + ) + }) + + it('draws until depletion into a new hand', () => { + drawInHand(game, { playerId, count: 10, fromAnchor: 'discard' }) + expect(game).toEqual( + expect.objectContaining({ + hands: [ + { + playerId, + meshes: [ + { id: 'D', texture: '', shape: 'box' }, + { id: 'E', texture: '', shape: 'box' }, + { id: 'A', texture: '', shape: 'box' }, + { + id: 'C', + texture: '', + shape: 'box', + stackable: { stackIds: [] } + } + ] + } + ], + meshes: [ + { + id: 'B', + texture: '', + shape: 'box', + anchorable: { anchors: [{ id: 'discard', snappedId: null }] } + } + ] + }) + ) + }) + + it('throws when drawing from empty anchor', () => { + delete game.meshes[1].anchorable?.anchors?.[0].snappedId + expect(() => + drawInHand(game, { playerId, count: 2, fromAnchor: 'discard' }) + ).toThrow('Anchor discard has no snapped mesh') + }) +}) + +describe('findOrCreateHand()', () => { + it('finds existing hand', () => { + const playerId1 = faker.string.uuid() + const playerId2 = faker.string.uuid() + + const game = makeGame({ + hands: [ + { + playerId: playerId1, + meshes: [ + { id: 'A', texture: '', shape: /** @type {const} */ ('box') } + ] + }, + { + playerId: playerId2, + meshes: [ + { id: 'B', texture: '', shape: /** @type {const} */ ('box') } + ] + } + ] + }) + expect(findOrCreateHand(game, playerId1)).toEqual(game.hands[0]) + expect(findOrCreateHand(game, playerId2)).toEqual(game.hands[1]) + }) + + it('creates new hand', () => { + const playerId1 = faker.string.uuid() + const playerId2 = faker.string.uuid() + + const game = makeGame({ + hands: [ + { + playerId: playerId1, + meshes: [ + { id: 'A', texture: '', shape: /** @type {const} */ ('box') } + ] + } + ] + }) + const created = { playerId: playerId2, meshes: [] } + expect(findOrCreateHand(game, playerId1)).toEqual(game.hands[0]) + expect(findOrCreateHand(game, playerId2)).toEqual(created) + expect(game.hands[1]).toEqual(created) + }) +}) diff --git a/apps/game-utils/tests/mesh.test.js b/apps/game-utils/tests/mesh.test.js new file mode 100644 index 00000000..7aefe908 --- /dev/null +++ b/apps/game-utils/tests/mesh.test.js @@ -0,0 +1,489 @@ +// @ts-check +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it } from 'vitest' + +import { + decrement, + findAnchor, + findMesh, + pop, + snapTo, + stackMeshes, + unsnap +} from '../src/mesh.js' +import { cloneAsJSON, makeGame } from './test-utils.js' + +describe('pop()', () => { + /** @type {import('@tabulous/types').StartedGame} */ + let game + + beforeEach(() => { + game = makeGame({ + meshes: [ + { id: 'A', texture: '', shape: 'box' }, + { + id: 'B', + texture: '', + shape: 'box', + anchorable: { anchors: [{ id: 'discard', snappedId: 'C' }] } + }, + { + id: 'C', + texture: '', + shape: 'box', + stackable: { stackIds: ['A', 'E', 'D'] } + }, + { id: 'D', texture: '', shape: 'box' }, + { id: 'E', texture: '', shape: 'box' } + ] + }) + }) + + it('draws one mesh from a stack', () => { + const { meshes } = game + expect(pop('C', 1, game.meshes)).toEqual([meshes[3]]) + expect(meshes[2].stackable?.stackIds).toEqual(['A', 'E']) + }) + + it('draws several meshes from a stack', () => { + const { meshes } = game + expect(pop('C', 2, meshes)).toEqual([meshes[3], meshes[4]]) + expect(meshes[2].stackable?.stackIds).toEqual(['A']) + }) + + it('can deplete a stack', () => { + const { meshes } = game + expect(pop('C', 10, meshes)).toEqual([ + meshes[3], + meshes[4], + meshes[0], + meshes[2] + ]) + expect(meshes[2].stackable?.stackIds).toEqual([]) + }) + + it('does nothing on unstackable meshes', () => { + expect(pop('A', 1, game.meshes, false)).toEqual([]) + }) + + it('can throw on unstackable meshes', () => { + expect(() => pop('A', 1, game.meshes)).toThrow('Mesh A is not stackable') + }) + + it('does nothing on unknown meshes', () => { + expect(pop('K', 1, game.meshes, false)).toEqual([]) + }) + + it('can throw on unknown meshes', () => { + expect(() => pop('K', 1, game.meshes)).toThrow('No mesh with id K') + }) +}) + +describe('findMesh()', () => { + const meshes = Array.from({ length: 10 }, () => ({ + id: faker.string.uuid(), + texture: '', + shape: /** @type {const} */ ('box') + })) + + it('returns existing meshes', () => { + expect(findMesh(meshes[5].id, meshes)).toEqual(meshes[5]) + expect(findMesh(meshes[8].id, meshes)).toEqual(meshes[8]) + }) + + it.each([faker.string.uuid(), meshes[0].id])( + 'returns null on unknown ids', + anchor => { + expect(findMesh(anchor, [], false)).toBeNull() + } + ) + + it.each([faker.string.uuid(), meshes[0].id])( + 'can throw on unknown ids', + id => { + expect(() => findMesh(id, [])).toThrow(`No mesh with id ${id}`) + } + ) +}) + +describe('findAnchor()', () => { + const anchors = Array.from({ length: 10 }, () => ({ + id: faker.string.uuid() + })) + + const meshes = [ + { id: 'mesh0', texture: '', shape: /** @type {const} */ ('box') }, + { + id: 'mesh1', + texture: '', + shape: /** @type {const} */ ('box'), + anchorable: { anchors: anchors.slice(0, 3) } + }, + { + id: 'mesh2', + texture: '', + shape: /** @type {const} */ ('box'), + anchorable: { anchors: [] } + }, + { + id: 'mesh3', + texture: '', + shape: /** @type {const} */ ('box'), + anchorable: { anchors: anchors.slice(3, 6) } + }, + { + id: 'mesh4', + texture: '', + shape: /** @type {const} */ ('box'), + anchorable: { anchors: anchors.slice(6) } + } + ] + + it.each([faker.string.uuid(), anchors[0].id])( + 'returns null on unknown anchor', + anchor => { + expect(findAnchor(anchor, [], false)).toBeNull() + } + ) + + it.each([faker.string.uuid(), anchors[0].id])( + 'can throw on unknown anchor', + anchor => { + expect(() => findAnchor(anchor, [])).toThrow( + `No anchor with id ${anchor}` + ) + } + ) + + it('returns existing anchor', () => { + expect(findAnchor(anchors[0].id, meshes)).toEqual(anchors[0]) + expect(findAnchor(anchors[4].id, meshes)).toEqual(anchors[4]) + expect(findAnchor(anchors[7].id, meshes)).toEqual(anchors[7]) + }) + + it('returns existing, deep, anchor', () => { + const meshes = [ + { + id: 'mesh0', + texture: '', + shape: /** @type {const} */ ('box'), + anchorable: { anchors: [{ id: 'bottom' }] } + }, + { + id: 'mesh1', + texture: '', + shape: /** @type {const} */ ('box'), + anchorable: { anchors: [{ id: 'bottom', snappedId: 'mesh3' }] } + }, + { + id: 'mesh2', + texture: '', + shape: /** @type {const} */ ('box'), + anchorable: { anchors: [{ id: 'start', snappedId: 'mesh1' }] } + }, + { + id: 'mesh3', + texture: '', + shape: /** @type {const} */ ('box'), + anchorable: { anchors: [{ id: 'bottom', snappedId: 'mesh0' }] } + } + ] + expect(findAnchor('start.bottom', meshes)).toEqual( + meshes[1].anchorable?.anchors?.[0] + ) + expect(findAnchor('start.bottom.bottom', meshes)).toEqual( + meshes[3].anchorable?.anchors?.[0] + ) + expect(findAnchor('start.bottom.bottom.bottom', meshes)).toEqual( + meshes[0].anchorable?.anchors?.[0] + ) + expect(findAnchor('bottom', meshes)).toEqual( + meshes[0].anchorable?.anchors?.[0] + ) + }) +}) + +describe('snapTo()', () => { + /** @type {import('@tabulous/types').Mesh[]} */ + let meshes + + beforeEach(() => { + meshes = [ + { id: 'mesh0', texture: '', shape: 'box' }, + { + id: 'mesh1', + texture: '', + shape: 'box', + anchorable: { anchors: [{ id: 'anchor1' }] } + }, + { + id: 'mesh2', + texture: '', + shape: 'box', + anchorable: { anchors: [{ id: 'anchor2' }, { id: 'anchor3' }] } + }, + { id: 'mesh3', texture: '', shape: 'box' } + ] + }) + + it('snaps a mesh to an existing anchor', () => { + expect(snapTo('anchor3', meshes[0], meshes)).toBe(true) + expect(meshes[2]).toEqual({ + id: 'mesh2', + texture: '', + shape: 'box', + anchorable: { + anchors: [{ id: 'anchor2' }, { id: 'anchor3', snappedId: 'mesh0' }] + } + }) + }) + + it('stacks a mesh if anchor is in use', () => { + meshes[0].stackable = {} + meshes[1].stackable = {} + expect(snapTo('anchor2', meshes[0], meshes)).toBe(true) + expect(snapTo('anchor2', meshes[1], meshes)).toBe(true) + expect(meshes[2]).toEqual({ + id: 'mesh2', + texture: '', + shape: 'box', + anchorable: { + anchors: [{ id: 'anchor2', snappedId: 'mesh0' }, { id: 'anchor3' }] + } + }) + expect(meshes[0]).toEqual({ + id: 'mesh0', + texture: '', + shape: 'box', + stackable: { stackIds: ['mesh1'] } + }) + }) + + it('ignores unstackable mesh on an anchor in use', () => { + meshes[0].stackable = {} + expect(snapTo('anchor2', meshes[0], meshes)).toBe(true) + const state = cloneAsJSON(meshes) + expect(snapTo('anchor2', meshes[1], meshes)).toBe(false) + expect(state).toEqual(meshes) + }) + + it('ignores mesh on an anchor in use with unstackable mesh', () => { + expect(snapTo('anchor2', meshes[0], meshes)).toBe(true) + meshes[1].stackable = {} + const state = cloneAsJSON(meshes) + expect(snapTo('anchor2', meshes[1], meshes)).toBe(false) + expect(state).toEqual(meshes) + }) + + it('ignores unknown anchor', () => { + const state = cloneAsJSON(meshes) + expect(snapTo('anchor10', meshes[0], meshes, false)).toBe(false) + expect(state).toEqual(meshes) + }) + + it('can throw on unknown anchor', () => { + expect(() => snapTo('anchor10', meshes[0], meshes)).toThrow( + 'No anchor with id anchor10' + ) + }) + + it('ignores unknown mesh', () => { + const state = cloneAsJSON(meshes) + expect(snapTo('anchor1', null, meshes, false)).toBe(false) + expect(state).toEqual(meshes) + }) + + it('can throw on unknown mesh', () => { + expect(() => snapTo('anchor1', null, meshes)).toThrow( + 'No mesh to snap on anchor anchor1' + ) + }) +}) + +describe('unsnap()', () => { + /** @type {import('@tabulous/types').Mesh[]} */ + let meshes + + beforeEach(() => { + meshes = [ + { + id: 'mesh1', + texture: '', + shape: 'box', + anchorable: { + anchors: [{ id: 'anchor1', snappedId: 'mesh2' }, { id: 'anchor2' }] + } + }, + { + id: 'mesh2', + texture: '', + shape: 'box', + anchorable: { + anchors: [ + { id: 'anchor3', snappedId: 'mesh3' }, + { id: 'anchor4', snappedId: 'unknown' } + ] + } + }, + { + id: 'mesh3', + texture: '', + shape: 'box' + } + ] + }) + + it('returns nothing on unknown anchor', () => { + expect(unsnap('unknown', meshes, false)).toBeNull() + }) + + it('can throw on unknown anchor', () => { + expect(() => unsnap('unknown', meshes)).toThrow('No anchor with id unknown') + }) + + it('returns nothing on anchor with no snapped mesh', () => { + expect(unsnap('anchor2', meshes, false)).toBeNull() + }) + + it('can throw on anchor with no snapped mesh', () => { + expect(() => unsnap('anchor2', meshes)).toThrow( + 'Anchor anchor2 has no snapped mesh' + ) + }) + + it('returns nothing on anchor with unknown snapped mesh', () => { + expect(unsnap('anchor4', meshes, false)).toBeNull() + }) + + it('can throw on anchor with unknown snapped mesh', () => { + expect(() => unsnap('anchor4', meshes)).toThrow('No mesh with id unknown') + }) + + it('returns mesh and unsnapps it', () => { + expect(unsnap('anchor3', meshes)).toEqual(meshes[2]) + expect(meshes[1].anchorable?.anchors).toEqual([ + { id: 'anchor3', snappedId: null }, + { id: 'anchor4', snappedId: 'unknown' } + ]) + }) +}) + +describe('stackMeshes()', () => { + /** @type {import('@tabulous/types').Mesh[]} */ + let meshes + + beforeEach(() => { + meshes = [ + { id: 'mesh0', texture: '', shape: 'box' }, + { id: 'mesh1', texture: '', shape: 'box' }, + { id: 'mesh2', texture: '', shape: 'box' }, + { id: 'mesh3', texture: '', shape: 'box' }, + { id: 'mesh4', texture: '', shape: 'box' } + ] + }) + + it('stacks a list of meshes in order', () => { + stackMeshes(meshes) + expect(meshes).toEqual([ + { + id: 'mesh0', + texture: '', + shape: 'box', + stackable: { stackIds: ['mesh1', 'mesh2', 'mesh3', 'mesh4'] } + }, + ...meshes.slice(1) + ]) + }) + + it('stacks on top of an existing stack', () => { + meshes[0].stackable = { stackIds: ['mesh4', 'mesh3'] } + stackMeshes([meshes[0], ...meshes.slice(1, 3)]) + expect(meshes).toEqual([ + { + id: 'mesh0', + texture: '', + shape: 'box', + stackable: { stackIds: ['mesh4', 'mesh3', 'mesh1', 'mesh2'] } + }, + ...meshes.slice(1) + ]) + }) + + it('do nothing on a stack of one', () => { + stackMeshes(meshes.slice(0, 1)) + expect(meshes).toEqual([ + { id: 'mesh0', texture: '', shape: 'box' }, + ...meshes.slice(1) + ]) + }) +}) + +describe('decrement()', () => { + it('ignores non quantifiable meshes', () => { + const mesh = { + id: 'mesh1', + texture: '', + shape: /**@type {const } */ ('box') + } + expect(decrement(mesh, false)).toBeNull() + expect(mesh).toEqual({ id: 'mesh1', texture: '', shape: 'box' }) + }) + + it('can throw on non quantifiable meshes', () => { + expect(() => + decrement({ + id: 'mesh1', + texture: '', + shape: /**@type {const } */ ('box') + }) + ).toThrow('Mesh mesh1 is not quantifiable or has a quantity of 1') + }) + + it('ignores quantifiable mesh of 1', () => { + const mesh = { + id: 'mesh1', + texture: '', + shape: /**@type {const } */ ('box'), + quantifiable: { quantity: 1 } + } + expect(decrement(mesh, false)).toBeNull() + expect(mesh).toEqual({ + id: 'mesh1', + texture: '', + shape: 'box', + quantifiable: { quantity: 1 } + }) + }) + + it('can throw on quantifiable mesh of 1', () => { + expect(() => + decrement({ + id: 'mesh1', + texture: '', + shape: /**@type {const } */ ('box'), + quantifiable: { quantity: 1 } + }) + ).toThrow('Mesh mesh1 is not quantifiable or has a quantity of 1') + }) + + it('decrements a quantifiable mesh by 1', () => { + const mesh = { + id: 'mesh1', + texture: '', + shape: /**@type {const } */ ('box'), + quantifiable: { quantity: 6 } + } + expect(decrement(mesh)).toEqual({ + id: expect.stringMatching(/^mesh1-/), + texture: '', + shape: 'box', + quantifiable: { quantity: 1 } + }) + expect(mesh).toEqual({ + id: 'mesh1', + texture: '', + shape: 'box', + quantifiable: { quantity: 5 } + }) + }) +}) diff --git a/apps/game-utils/tests/preference.test.js b/apps/game-utils/tests/preference.test.js new file mode 100644 index 00000000..0310c1db --- /dev/null +++ b/apps/game-utils/tests/preference.test.js @@ -0,0 +1,52 @@ +// @ts-check +import { describe, expect, it } from 'vitest' + +import { findAvailableValues } from '../src/preference.js' + +describe('findAvailableValues()', () => { + const colors = ['red', 'green', 'blue'] + + it('returns all possible values when there are no preferences', () => { + expect(findAvailableValues([], 'color', colors)).toEqual(colors) + }) + + it('returns nothing when all possible values were used', () => { + expect( + findAvailableValues( + colors.map(color => ({ color, playerId: '' })), + 'color', + colors + ) + ).toEqual([]) + }) + + it('returns available values', () => { + expect( + findAvailableValues( + [ + { color: 'red', playerId: '' }, + { color: 'lime', playerId: '' }, + { color: 'azure', playerId: '' }, + { color: 'blue', playerId: '' } + ], + 'color', + colors + ) + ).toEqual(['green']) + }) + + it('ignores unknown preference name', () => { + expect( + findAvailableValues( + [ + { color: 'red', playerId: '' }, + { color: 'lime', playerId: '' }, + { color: 'azure', playerId: '' }, + { color: 'blue', playerId: '' } + ], + 'unknown', + colors + ) + ).toEqual(colors) + }) +}) diff --git a/apps/game-utils/tests/test-utils.js b/apps/game-utils/tests/test-utils.js new file mode 100644 index 00000000..a540a522 --- /dev/null +++ b/apps/game-utils/tests/test-utils.js @@ -0,0 +1,69 @@ +// @ts-check +import { expect } from 'vitest' + +export function expectStackedOnSlot( + /** @type {import('@tabulous/types').Mesh[]} */ meshes, + /** @type {import('@tabulous/types').Slot|undefined} */ slot, + count = slot?.count ?? 1 +) { + const stack = meshes.find( + ({ stackable }) => stackable?.stackIds?.length === count - 1 + ) + expect(stack).toBeDefined() + const stackedMeshes = meshes.filter( + ({ id }) => stack?.stackable?.stackIds?.includes(id) || id === stack?.id + ) + expect(stackedMeshes).toHaveLength(count) + expect( + stackedMeshes.every( + ({ x, y, z }) => x === slot?.x && y === slot?.y && z === slot?.z + ) + ).toBe(true) + return /** @type {import('@tabulous/types').Mesh} */ (stack) +} + +export function expectSnappedByName( + /** @type {import('@tabulous/types').Mesh[]} */ meshes, + /** @type {string} */ name, + /** @type {import('@tabulous/types').Anchor|undefined} */ anchor +) { + const candidates = meshes.filter(mesh => 'name' in mesh && name === mesh.name) + expect(candidates).toHaveLength(1) + expect(anchor?.snappedId).toEqual(candidates[0].id) +} + +/** + * Performs a deep clone, using JSON parse and stringify + * This is a slow, destructive (functions, Date and Regex are lost) method, only suitable in tests + * @template {object} T + * @param {T} object - cloned object + * @returns {T} a clone + */ +export function cloneAsJSON(object) { + return JSON.parse(JSON.stringify(object)) +} + +/** + * @param {Partial} [overrides = {}] - optional game overrides. + * @returns {import('@tabulous/types').StartedGame} the test game. + */ +export function makeGame(overrides = {}) { + return { + id: 'test', + kind: 'playground', + name: 'Playground', + locales: { fr: { title: 'Terrain de jeu' } }, + created: Date.now(), + availableSeats: 4, + ownerId: 'admin', + meshes: [], + hands: [], + messages: [], + cameras: [], + history: [], + preferences: [], + playerIds: [], + guestIds: [], + ...overrides + } +} diff --git a/apps/games/chess/index.js b/apps/games/chess/index.js index ca1f6adb..81a15d98 100644 --- a/apps/games/chess/index.js +++ b/apps/games/chess/index.js @@ -1,10 +1,8 @@ // @ts-check -/** @typedef {import('@tabulous/server/src/services/catalog').GameDescriptor} GameDescriptor */ - export { build } from './logic/build.js' export { addPlayer, askForParameters } from './logic/players.js' -/** @type {GameDescriptor['locales']} */ +/** @type {import('@tabulous/types').GameDescriptor['locales']} */ export const locales = { fr: { title: 'Echecs' }, en: { title: 'Chess' } @@ -20,18 +18,18 @@ export const minTime = 30 export const minAge = 6 -/** @type {GameDescriptor['tableSpec']} */ +/** @type {import('@tabulous/types').GameDescriptor['tableSpec']} */ export const tableSpec = { texture: '/table-textures/wood-1.webp', width: 100, height: 100 } -/** @type {GameDescriptor['zoomSpec']} */ +/** @type {import('@tabulous/types').GameDescriptor['zoomSpec']} */ export const zoomSpec = { min: 20 } // https://coolors.co/dda15e-606c38-fefae0-bd5d2c-6d938e -/** @type {GameDescriptor['colors']} */ +/** @type {import('@tabulous/types').GameDescriptor['colors']} */ export const colors = { base: '#dda15e', primary: '#606c38', @@ -39,7 +37,7 @@ export const colors = { players: ['#dda15e', '#606c38'] } -/** @type {GameDescriptor['actions']} */ +/** @type {import('@tabulous/types').GameDescriptor['actions']} */ export const actions = { button1: ['rotate'] } diff --git a/apps/games/chess/index.test.js b/apps/games/chess/index.test.js index b97302d9..225b9f20 100644 --- a/apps/games/chess/index.test.js +++ b/apps/games/chess/index.test.js @@ -1,8 +1,6 @@ // @ts-check -/** @typedef {import('@tabulous/server/src/services/catalog').GameDescriptor} GameDescriptor */ - -import { buildDescriptorTestSuite } from '@tabulous/server/tests/games-test.js' +import { buildDescriptorTestSuite } from '@tabulous/game-utils/tests/game.js' import * as descriptor from '.' -buildDescriptorTestSuite('chess', /** @type {GameDescriptor} */ (descriptor)) +buildDescriptorTestSuite('chess', descriptor) diff --git a/apps/games/chess/logic/build.js b/apps/games/chess/logic/build.js index 020def2a..26c7b927 100644 --- a/apps/games/chess/logic/build.js +++ b/apps/games/chess/logic/build.js @@ -2,7 +2,7 @@ import { buildBoard, buildPieces } from './builders/index.js' import { blackId, whiteId } from './constants.js' -/** @type {import('@tabulous/server/src/services/catalog').Build} */ +/** @type {import('@tabulous/types').Build} */ export function build() { return { /** diff --git a/apps/games/chess/logic/builders/board.js b/apps/games/chess/logic/builders/board.js index 6cf71eee..3bdce097 100644 --- a/apps/games/chess/logic/builders/board.js +++ b/apps/games/chess/logic/builders/board.js @@ -1,12 +1,7 @@ // @ts-check -/** - * @typedef {import('@tabulous/server/src/graphql').Anchor} Anchor - * @typedef {import('@tabulous/server/src/graphql').Mesh} Mesh - */ - import { blackId, faceUVs, pieces, sizes, whiteId } from '../constants.js' -/** @returns {Mesh} */ +/** @returns {import('@tabulous/types').Mesh} */ export function buildBoard() { return { shape: 'roundedTile', @@ -19,7 +14,7 @@ export function buildBoard() { } } -/** @returns {Anchor[]} */ +/** @returns {import('@tabulous/types').Anchor[]} */ function buildAnchors() { const anchors = [] const max = pieces.length diff --git a/apps/games/chess/logic/builders/index.js b/apps/games/chess/logic/builders/index.js index 722af91f..bf36f32f 100644 --- a/apps/games/chess/logic/builders/index.js +++ b/apps/games/chess/logic/builders/index.js @@ -1,2 +1,3 @@ +// @ts-check export * from './board.js' export * from './pieces.js' diff --git a/apps/games/chess/logic/builders/pieces.js b/apps/games/chess/logic/builders/pieces.js index cbc8cb62..5e494f5b 100644 --- a/apps/games/chess/logic/builders/pieces.js +++ b/apps/games/chess/logic/builders/pieces.js @@ -1,13 +1,8 @@ // @ts-check -/** - * @typedef {import('@tabulous/server/src/graphql').Mesh} Mesh - * @typedef {import('../constants').Side} Side - */ - import { blackId, colors, pieces } from '../constants.js' -export function buildPieces(/** @type {Side} */ color) { - /** @type {Mesh[]} */ +export function buildPieces(/** @type {import('../constants').Side} */ color) { + /** @type {import('@tabulous/types').Mesh[]} */ const meshes = [] const invert = color === blackId for (const kind of pieces) { diff --git a/apps/games/chess/logic/constants.js b/apps/games/chess/logic/constants.js index bfbfd7e3..11d088bc 100644 --- a/apps/games/chess/logic/constants.js +++ b/apps/games/chess/logic/constants.js @@ -1,8 +1,4 @@ // @ts-check -/** - * @typedef {import("@tabulous/server/src/utils").CameraPosition} CameraPosition - */ - /** @typedef {'white'|'black'} Side */ /** @type {Side} */ @@ -43,7 +39,7 @@ export const pieces = [ 'rook-2' ] -/** @type {Record>} */ +/** @type {Record>} */ export const cameraPositions = { [blackId]: { alpha: Math.PI / 2, diff --git a/apps/games/chess/logic/players.js b/apps/games/chess/logic/players.js index 2f85d443..eee50678 100644 --- a/apps/games/chess/logic/players.js +++ b/apps/games/chess/logic/players.js @@ -1,19 +1,14 @@ // @ts-check -/** @typedef {import('./constants').Side} Side */ - -import { - buildCameraPosition, - findAvailableValues -} from '@tabulous/server/src/utils/index.js' +import { buildCameraPosition, findAvailableValues } from '@tabulous/game-utils' import { blackId, cameraPositions, whiteId } from './constants.js' /** * @typedef {object} Parameters - * @property {Side} side - player and pieces color; + * @property {import('./constants').Side} side - player and pieces color; */ -/** @type {import('@tabulous/server/src/services/catalog').AskForParameters} */ +/** @type {import('@tabulous/types').AskForParameters} */ export function askForParameters({ game: { preferences } }) { const sides = findAvailableValues(preferences, 'side', [whiteId, blackId]) return sides.length <= 1 @@ -33,11 +28,11 @@ export function askForParameters({ game: { preferences } }) { } } -/** @type {import('@tabulous/server/src/services/catalog').AddPlayer} */ +/** @type {import('@tabulous/types').AddPlayer} */ export function addPlayer(game, player, parameters) { const { cameras, preferences } = game // use selected preferences, or look for the first player color, and choose the other one. - const side = /** @type {Side} */ ( + const side = /** @type {import('./constants').Side} */ ( preferences.length === 2 ? preferences[0].side === whiteId ? blackId diff --git a/apps/games/draughts/index.js b/apps/games/draughts/index.js index 63b60a53..de332a34 100644 --- a/apps/games/draughts/index.js +++ b/apps/games/draughts/index.js @@ -1,10 +1,8 @@ // @ts-check -/** @typedef {import('@tabulous/server/src/services/catalog').GameDescriptor} GameDescriptor */ - export { build } from './logic/build.js' export { addPlayer, askForParameters } from './logic/players.js' -/** @type {GameDescriptor['locales']} */ +/** @type {import('@tabulous/types').GameDescriptor['locales']} */ export const locales = { fr: { title: 'Dames' }, en: { title: 'Draughts' } @@ -20,18 +18,18 @@ export const minSeats = 2 export const maxSeats = 2 -/** @type {GameDescriptor['tableSpec']} */ +/** @type {import('@tabulous/types').GameDescriptor['tableSpec']} */ export const tableSpec = { texture: '/table-textures/wood-4.webp', width: 100, height: 100 } -/** @type {GameDescriptor['zoomSpec']} */ +/** @type {import('@tabulous/types').GameDescriptor['zoomSpec']} */ export const zoomSpec = { min: 20 } // https://coolors.co/ebd8c3-fbe0e0-ffeeee-bd5d2c-6d938e -/** @type {GameDescriptor['colors']} */ +/** @type {import('@tabulous/types').GameDescriptor['colors']} */ export const colors = { base: '#ebd8c3', primary: '#fbe0e0', @@ -40,5 +38,5 @@ export const colors = { } // disable all button actions -/** @type {GameDescriptor['actions']} */ +/** @type {import('@tabulous/types').GameDescriptor['actions']} */ export const actions = {} diff --git a/apps/games/draughts/index.test.js b/apps/games/draughts/index.test.js index ddf7898d..674efe53 100644 --- a/apps/games/draughts/index.test.js +++ b/apps/games/draughts/index.test.js @@ -1,8 +1,6 @@ // @ts-check -/** @typedef {import('@tabulous/server/src/services/catalog').GameDescriptor} GameDescriptor */ - -import { buildDescriptorTestSuite } from '@tabulous/server/tests/games-test.js' +import { buildDescriptorTestSuite } from '@tabulous/game-utils/tests/game.js' import * as descriptor from '.' -buildDescriptorTestSuite('draughts', /** @type {GameDescriptor} */ (descriptor)) +buildDescriptorTestSuite('draughts', descriptor) diff --git a/apps/games/draughts/logic/build.js b/apps/games/draughts/logic/build.js index 17332bb7..fbfa3fb0 100644 --- a/apps/games/draughts/logic/build.js +++ b/apps/games/draughts/logic/build.js @@ -2,7 +2,7 @@ import { buildBoard, buildPawns } from './builders/index.js' import { blackId, counts, whiteId } from './constants.js' -/** @type {import('@tabulous/server/src/services/catalog').Build} */ +/** @type {import('@tabulous/types').Build} */ export function build() { // 20 white pawns, 20 black pawns const meshes = [...buildPawns(), buildBoard()] diff --git a/apps/games/draughts/logic/builders/board.js b/apps/games/draughts/logic/builders/board.js index 1a4a86b5..77a02e1a 100644 --- a/apps/games/draughts/logic/builders/board.js +++ b/apps/games/draughts/logic/builders/board.js @@ -1,12 +1,7 @@ // @ts-check -/** - * @typedef {import('@tabulous/server/src/graphql').Anchor} Anchor - * @typedef {import('@tabulous/server/src/graphql').Mesh} Mesh - */ - import { counts, faceUVs, sizes } from '../constants.js' -/** @returns {Mesh} */ +/** @returns {import('@tabulous/types').Mesh} */ export function buildBoard() { return { shape: 'roundedTile', @@ -20,7 +15,7 @@ export function buildBoard() { } function buildPawnAnchors() { - /** @type {Anchor[]} */ + /** @type {import('@tabulous/types').Anchor[]} */ const anchors = [] const spacing = { x: sizes.board.width / counts.columns, diff --git a/apps/games/draughts/logic/builders/index.js b/apps/games/draughts/logic/builders/index.js index c081093c..cab91396 100644 --- a/apps/games/draughts/logic/builders/index.js +++ b/apps/games/draughts/logic/builders/index.js @@ -1,2 +1,3 @@ +// @ts-check export * from './board.js' export * from './pawn.js' diff --git a/apps/games/draughts/logic/builders/pawn.js b/apps/games/draughts/logic/builders/pawn.js index c34bbad6..de39fe15 100644 --- a/apps/games/draughts/logic/builders/pawn.js +++ b/apps/games/draughts/logic/builders/pawn.js @@ -1,10 +1,8 @@ // @ts-check -/** @typedef {import('@tabulous/server/src/graphql').Mesh} Mesh */ - import { blackId, colors, counts, sizes, whiteId } from '../constants.js' export function buildPawns() { - /** @type {Mesh[]} */ + /** @type {import('@tabulous/types').Mesh[]} */ const meshes = [] for (const kind of [whiteId, blackId]) { for (let index = 1; index <= counts.pawns; index++) { diff --git a/apps/games/draughts/logic/constants.js b/apps/games/draughts/logic/constants.js index 178e1bd9..2f1250e4 100644 --- a/apps/games/draughts/logic/constants.js +++ b/apps/games/draughts/logic/constants.js @@ -1,6 +1,4 @@ // @ts-check -/** @typedef {import("@tabulous/server/src/utils").CameraPosition} CameraPosition*/ - /** @typedef {'white'|'black'} Side */ export const counts = { pawns: 20, columns: 10 } @@ -33,7 +31,7 @@ export const faceUVs = { ] } -/** @type {Record>} */ +/** @type {Record>} */ export const cameraPositions = { [blackId]: { alpha: Math.PI / 2, diff --git a/apps/games/draughts/logic/players.js b/apps/games/draughts/logic/players.js index 28839b63..9b485809 100644 --- a/apps/games/draughts/logic/players.js +++ b/apps/games/draughts/logic/players.js @@ -1,19 +1,14 @@ // @ts-check -/** @typedef {import('./constants').Side} Side */ - -import { - buildCameraPosition, - findAvailableValues -} from '@tabulous/server/src/utils/index.js' +import { buildCameraPosition, findAvailableValues } from '@tabulous/game-utils' import { blackId, cameraPositions, whiteId } from './constants.js' /** * @typedef {object} Parameters - * @property {Side} side - player and pieces color; + * @property {import('./constants').Side} side - player and pieces color; */ -/** @type {import('@tabulous/server/src/services/catalog').AskForParameters} */ +/** @type {import('@tabulous/types').AskForParameters} */ export function askForParameters({ game: { preferences } }) { const sides = findAvailableValues(preferences, 'side', [whiteId, blackId]) return sides.length <= 1 @@ -33,11 +28,11 @@ export function askForParameters({ game: { preferences } }) { } } -/** @type {import('@tabulous/server/src/services/catalog').AddPlayer} */ +/** @type {import('@tabulous/types').AddPlayer} */ export function addPlayer(game, player, parameters) { const { cameras, preferences } = game // use selected preferences, or look for the first player color, and choose the other one. - const side = /** @type {Side} */ ( + const side = /** @type {import('./constants').Side} */ ( preferences.length === 2 ? preferences[0].side === whiteId ? blackId diff --git a/apps/games/jsconfig.json b/apps/games/jsconfig.json index b94a1f40..4dce78ce 100644 --- a/apps/games/jsconfig.json +++ b/apps/games/jsconfig.json @@ -1,5 +1,4 @@ { "extends": "../../jsconfig.json", - "include": ["*/**/*.js"], - "exclude": ["*/**/*.test.js"] + "include": ["*/**/*.js"] } diff --git a/apps/games/klondike/index.js b/apps/games/klondike/index.js index abfabf54..d0058b41 100644 --- a/apps/games/klondike/index.js +++ b/apps/games/klondike/index.js @@ -1,10 +1,8 @@ // @ts-check -/** @typedef {import('@tabulous/server/src/services/catalog').GameDescriptor} GameDescriptor */ - export { build } from './logic/build.js' export { addPlayer } from './logic/players.js' -/** @type {GameDescriptor['locales']} */ +/** @type {import('@tabulous/types').GameDescriptor['locales']} */ export const locales = { fr: { title: 'Solitaire' }, en: { title: 'Klondike' } @@ -18,17 +16,17 @@ export const minSeats = 1 export const maxSeats = 1 -/** @type {GameDescriptor['tableSpec']} */ +/** @type {import('@tabulous/types').GameDescriptor['tableSpec']} */ export const tableSpec = { texture: '#325532ff' } -/** @type {GameDescriptor['colors']} */ +/** @type {import('@tabulous/types').GameDescriptor['colors']} */ export const colors = { base: '#afe619', primary: '#8367c7', secondary: '#73778c' } -/** @type {GameDescriptor['actions']} */ +/** @type {import('@tabulous/types').GameDescriptor['actions']} */ export const actions = { button1: ['flip'], button2: ['rotate'] diff --git a/apps/games/klondike/index.test.js b/apps/games/klondike/index.test.js index 1bb3cc80..acd5c318 100644 --- a/apps/games/klondike/index.test.js +++ b/apps/games/klondike/index.test.js @@ -1,8 +1,6 @@ // @ts-check -/** @typedef {import('@tabulous/server/src/services/catalog').GameDescriptor} GameDescriptor */ - -import { buildDescriptorTestSuite } from '@tabulous/server/tests/games-test.js' +import { buildDescriptorTestSuite } from '@tabulous/game-utils/tests/game.js' import * as descriptor from '.' -buildDescriptorTestSuite('klondike', /** @type {GameDescriptor} */ (descriptor)) +buildDescriptorTestSuite('klondike', descriptor) diff --git a/apps/games/klondike/logic/build.js b/apps/games/klondike/logic/build.js index 5bff210d..02aa52a4 100644 --- a/apps/games/klondike/logic/build.js +++ b/apps/games/klondike/logic/build.js @@ -4,7 +4,7 @@ import { anchorIds } from './constants.js' const bagId = 'cards' -/** @type {import('@tabulous/server/src/services/catalog').Build} */ +/** @type {import('@tabulous/types').Build} */ export function build() { /** * 13 cards of each suit: spades, diamonds, clubs, hearts. diff --git a/apps/games/klondike/logic/builders/board.js b/apps/games/klondike/logic/builders/board.js index 017ba01d..79c1ffc7 100644 --- a/apps/games/klondike/logic/builders/board.js +++ b/apps/games/klondike/logic/builders/board.js @@ -1,9 +1,4 @@ // @ts-check -/** - * @typedef {import('@tabulous/server/src/graphql').Anchor} Anchor - * @typedef {import('@tabulous/server/src/graphql').Mesh} Mesh - */ - import { anchorIds, counts, @@ -14,7 +9,7 @@ import { suits } from '../constants.js' -/** @returns {Mesh} */ +/** @returns {import('@tabulous/types').Mesh} */ export function buildBoard() { return { shape: 'card', @@ -35,7 +30,7 @@ export function buildBoard() { } function buildGoalAnchors() { - /** @type {Anchor[]} */ + /** @type {import('@tabulous/types').Anchor[]} */ const anchors = [] for (const [column, suit] of suits.entries()) { anchors.push({ @@ -50,7 +45,7 @@ function buildGoalAnchors() { } function buildColumnAnchors() { - /** @type {Anchor[]} */ + /** @type {import('@tabulous/types').Anchor[]} */ const anchors = [] for (let column = 0; column < counts.columns; column++) { anchors.push({ diff --git a/apps/games/klondike/logic/builders/cards.js b/apps/games/klondike/logic/builders/cards.js index 5d2b5e0d..dfd08295 100644 --- a/apps/games/klondike/logic/builders/cards.js +++ b/apps/games/klondike/logic/builders/cards.js @@ -1,10 +1,8 @@ // @ts-check -/** @typedef {import('@tabulous/server/src/graphql').Mesh} Mesh */ - import { counts, sizes, spacing, suits } from '../constants.js' export function buildCards() { - /** @type {Mesh[]} */ + /** @type {import('@tabulous/types').Mesh[]} */ const meshes = [] for (const suit of suits) { for (let index = 1; index <= counts.suits; index++) { diff --git a/apps/games/klondike/logic/builders/index.js b/apps/games/klondike/logic/builders/index.js index 90ddec33..7494283f 100644 --- a/apps/games/klondike/logic/builders/index.js +++ b/apps/games/klondike/logic/builders/index.js @@ -1,2 +1,3 @@ +// @ts-check export * from './board.js' export * from './cards.js' diff --git a/apps/games/klondike/logic/players.js b/apps/games/klondike/logic/players.js index 61ce3ee9..3e27e5c6 100644 --- a/apps/games/klondike/logic/players.js +++ b/apps/games/klondike/logic/players.js @@ -1,18 +1,14 @@ // @ts-check -/** - * @typedef {import('@tabulous/server/src/graphql').FlippableState} FlippableState - * @typedef {import('@tabulous/server/src/graphql').Mesh} Mesh - */ import { buildCameraPosition, - draw, findAnchor, + pop, snapTo -} from '@tabulous/server/src/utils/index.js' +} from '@tabulous/game-utils' import { anchorIds, counts } from './constants.js' -/** @type {import('@tabulous/server/src/services/catalog').AddPlayer} */ +/** @type {import('@tabulous/types').AddPlayer} */ export function addPlayer(game, player) { game.cameras.push( buildCameraPosition({ @@ -27,9 +23,10 @@ export function addPlayer(game, player) { for (let column = 0; column < counts.columns; column++) { const anchorId = `${anchorIds.column}-${column + 1}` - const thread = /** @type {(Mesh & {flippable: FlippableState})[]} */ ( - draw(reserveId, column + 1, game.meshes) - ) + const thread = + /** @type {(import('@tabulous/types').Mesh & {flippable: import('@tabulous/types').FlippableState})[]} */ ( + pop(reserveId, column + 1, game.meshes) + ) for ( let parent = thread[0], i = 1; i < thread.length; diff --git a/apps/games/mah-jong/index.js b/apps/games/mah-jong/index.js index 399812d2..e51f7e58 100644 --- a/apps/games/mah-jong/index.js +++ b/apps/games/mah-jong/index.js @@ -1,11 +1,9 @@ // @ts-check -/** @typedef {import('@tabulous/server/src/services/catalog').GameDescriptor} GameDescriptor */ - export { build } from './logic/build.js' export { colors } from './logic/constants.js' export { addPlayer, askForParameters } from './logic/player.js' -/** @type {GameDescriptor['locales']} */ +/** @type {import('@tabulous/types').GameDescriptor['locales']} */ export const locales = { fr: { title: 'Mah-jong' }, en: { title: 'Mah-jong' } @@ -19,13 +17,13 @@ export const maxSeats = 4 export const minTime = 60 -/** @type {GameDescriptor['tableSpec']} */ +/** @type {import('@tabulous/types').GameDescriptor['tableSpec']} */ export const tableSpec = { texture: '#36823e' } -/** @type {GameDescriptor['zoomSpec']} */ +/** @type {import('@tabulous/types').GameDescriptor['zoomSpec']} */ export const zoomSpec = { hand: 35, min: 20, max: 90 } -/** @type {GameDescriptor['actions']} */ +/** @type {import('@tabulous/types').GameDescriptor['actions']} */ export const actions = { button1: ['rotate', 'random'], button2: ['flip'] diff --git a/apps/games/mah-jong/index.test.js b/apps/games/mah-jong/index.test.js index f3caad8f..af1966b5 100644 --- a/apps/games/mah-jong/index.test.js +++ b/apps/games/mah-jong/index.test.js @@ -1,8 +1,6 @@ // @ts-check -/** @typedef {import('@tabulous/server/src/services/catalog').GameDescriptor} GameDescriptor */ - -import { buildDescriptorTestSuite } from '@tabulous/server/tests/games-test.js' +import { buildDescriptorTestSuite } from '@tabulous/game-utils/tests/game.js' import * as descriptor from '.' -buildDescriptorTestSuite('mah-jong', /** @type {GameDescriptor} */ (descriptor)) +buildDescriptorTestSuite('mah-jong', descriptor) diff --git a/apps/games/mah-jong/logic/build.js b/apps/games/mah-jong/logic/build.js index ed269b19..39d6e713 100644 --- a/apps/games/mah-jong/logic/build.js +++ b/apps/games/mah-jong/logic/build.js @@ -3,7 +3,7 @@ import { buildDice, buildMainBoard, buildTiles } from './builders/index.js' import { buildDealerMark } from './builders/marks.js' import { walls, wallSize } from './constants.js' -/** @type {import('@tabulous/server/src/services/catalog').Build} */ +/** @type {import('@tabulous/types').Build} */ export function build() { const tiles = buildTiles() const bagId = 'tiles-bag' diff --git a/apps/games/mah-jong/logic/builders/boards.js b/apps/games/mah-jong/logic/builders/boards.js index 0132fb98..feaa07fa 100644 --- a/apps/games/mah-jong/logic/builders/boards.js +++ b/apps/games/mah-jong/logic/builders/boards.js @@ -1,13 +1,7 @@ // @ts-check -/** - * @typedef {import('@tabulous/server/src/graphql').Anchor} Anchor - * @typedef {import('@tabulous/server/src/graphql').Mesh} Mesh - * @typedef {import('../constants').Wall} Wall - */ - import { kinds, riverSize, shapes, walls, wallSize } from '../constants.js' -/** @returns {Mesh} */ +/** @returns {import('@tabulous/types').Mesh} */ export function buildMainBoard() { const { east, south, west, north } = walls return { @@ -31,13 +25,13 @@ export function buildMainBoard() { } function buildWallAnchors( - /** @type {{ wall: Wall, isHorizontal: boolean, angle: number }} */ { + /** @type {{ wall: import('../constants').Wall, isHorizontal: boolean, angle: number }} */ { wall, isHorizontal, angle } ) { - /** @type {Anchor[]} */ + /** @type {import('@tabulous/types').Anchor[]} */ const anchors = [] const { width, height, depth } = shapes.anchor const start = (width * wallSize - 2) * -0.5 @@ -65,13 +59,13 @@ function buildWallAnchors( } function buildRiverAnchors( - /** @type {{ wall: Wall, isHorizontal: boolean, angle: number }} */ { + /** @type {{ wall: import('../constants').Wall, isHorizontal: boolean, angle: number }} */ { wall, isHorizontal, angle } ) { - /** @type {Anchor[]} */ + /** @type {import('@tabulous/types').Anchor[]} */ const anchors = [] const { width, height, depth } = shapes.anchor const anchorWidth = width * 1.2 diff --git a/apps/games/mah-jong/logic/builders/dice.js b/apps/games/mah-jong/logic/builders/dice.js index e5482db5..df76ca39 100644 --- a/apps/games/mah-jong/logic/builders/dice.js +++ b/apps/games/mah-jong/logic/builders/dice.js @@ -1,8 +1,6 @@ // @ts-check -/** @typedef {import('@tabulous/server/src/graphql').Mesh} Mesh */ - export function buildDice() { - /** @type {Mesh[]} */ + /** @type {import('@tabulous/types').Mesh[]} */ const dices = [] const diameter = 0.6 for (let rank = 1; rank <= 2; rank++) { diff --git a/apps/games/mah-jong/logic/builders/index.js b/apps/games/mah-jong/logic/builders/index.js index aba04cb8..cb9a4a54 100644 --- a/apps/games/mah-jong/logic/builders/index.js +++ b/apps/games/mah-jong/logic/builders/index.js @@ -1,3 +1,4 @@ +// @ts-check export * from './boards.js' export * from './dice.js' export * from './marks.js' diff --git a/apps/games/mah-jong/logic/builders/marks.js b/apps/games/mah-jong/logic/builders/marks.js index 287ceca0..77dd3378 100644 --- a/apps/games/mah-jong/logic/builders/marks.js +++ b/apps/games/mah-jong/logic/builders/marks.js @@ -1,9 +1,7 @@ // @ts-check -/** @typedef {import('@tabulous/server/src/graphql').Mesh} Mesh */ - import { shapes } from '../constants.js' -/** @returns {Mesh} */ +/** @returns {import('@tabulous/types').Mesh} */ export function buildDealerMark() { return { id: 'dealer-mark', diff --git a/apps/games/mah-jong/logic/builders/sticks.js b/apps/games/mah-jong/logic/builders/sticks.js index a8f5f41e..1f0727cc 100644 --- a/apps/games/mah-jong/logic/builders/sticks.js +++ b/apps/games/mah-jong/logic/builders/sticks.js @@ -1,10 +1,8 @@ // @ts-check -/** @typedef {import('@tabulous/server/src/graphql').Mesh} Mesh */ - import { kinds } from '../constants.js' export function buildSticks(/** @type {number} */ playerRank) { - /** @type {Mesh[]} */ + /** @type {import('@tabulous/types').Mesh[]} */ const sticks = [] const { x, z, angle, offset } = playerRank === 0 diff --git a/apps/games/mah-jong/logic/builders/tiles.js b/apps/games/mah-jong/logic/builders/tiles.js index 48f09b41..8dc1b28b 100644 --- a/apps/games/mah-jong/logic/builders/tiles.js +++ b/apps/games/mah-jong/logic/builders/tiles.js @@ -1,10 +1,8 @@ // @ts-check -/** @typedef {import('@tabulous/server/src/graphql').Mesh} Mesh */ - import { faceUVs, kinds, maxTilePByKind, shapes } from '../constants.js' export function buildTiles() { - /** @type {Mesh[]} */ + /** @type {import('@tabulous/types').Mesh[]} */ const meshes = [] for (const [kind, max] of maxTilePByKind) { for (let rank = 1; rank <= max; rank++) { diff --git a/apps/games/mah-jong/logic/player.js b/apps/games/mah-jong/logic/player.js index b07bea1b..35a23bd6 100644 --- a/apps/games/mah-jong/logic/player.js +++ b/apps/games/mah-jong/logic/player.js @@ -1,10 +1,5 @@ // @ts-check -/** @typedef {import('@tabulous/server/src/graphql').Mesh} Mesh */ - -import { - buildCameraPosition, - findAvailableValues -} from '@tabulous/server/src/utils/index.js' +import { buildCameraPosition, findAvailableValues } from '@tabulous/game-utils' import { buildSticks } from './builders/index.js' import { colors } from './constants.js' @@ -14,7 +9,7 @@ import { colors } from './constants.js' * @property {string} color - player color; */ -/** @type {import('@tabulous/server/src/services/catalog').AskForParameters} */ +/** @type {import('@tabulous/types').AskForParameters} */ export function askForParameters({ game: { preferences } }) { return { type: 'object', @@ -38,7 +33,7 @@ export function askForParameters({ game: { preferences } }) { * - third is looking toward south, * - fourth is looking toward east * Then creates sticks for counting points - * @type {import('@tabulous/server/src/services/catalog').AddPlayer} + * @type {import('@tabulous/types').AddPlayer} */ export function addPlayer(game, player) { const rank = game.preferences.length - 1 diff --git a/apps/games/package.json b/apps/games/package.json index a81212eb..bf2e87f7 100644 --- a/apps/games/package.json +++ b/apps/games/package.json @@ -9,6 +9,7 @@ "typecheck": "tsc -p jsconfig.json --noEmit" }, "dependencies": { - "@tabulous/server": "workspace:*" + "@tabulous/game-utils": "workspace:*", + "@tabulous/types": "workspace:*" } } diff --git a/apps/games/playground/index.js b/apps/games/playground/index.js index cd2fe0e0..0ab01c72 100644 --- a/apps/games/playground/index.js +++ b/apps/games/playground/index.js @@ -1,13 +1,11 @@ // @ts-check -/** @typedef {import('@tabulous/server/src/services/catalog.js').GameDescriptor} GameDescriptor */ - export * from './logic/players.js' export function build() { return { meshes: [] } } -/** @type {GameDescriptor['locales']} */ +/** @type {import('@tabulous/types').GameDescriptor['locales']} */ export const locales = { fr: { title: 'Aire de jeux' }, en: { title: 'Playground' } @@ -17,13 +15,13 @@ export const minSeats = 2 export const maxSeats = 8 -/** @type {GameDescriptor['zoomSpec']} */ +/** @type {import('@tabulous/types').GameDescriptor['zoomSpec']} */ export const zoomSpec = { hand: 25 } -/** @type {GameDescriptor['tableSpec']} */ +/** @type {import('@tabulous/types').GameDescriptor['tableSpec']} */ export const tableSpec = { texture: '#046724ff' } -/** @type {GameDescriptor['colors']} */ +/** @type {import('@tabulous/types').GameDescriptor['colors']} */ export const colors = { base: '#51a16a', primary: '#c45335', diff --git a/apps/games/playground/logic/build.js b/apps/games/playground/logic/build.js index be555006..6129580f 100644 --- a/apps/games/playground/logic/build.js +++ b/apps/games/playground/logic/build.js @@ -1,7 +1,5 @@ // @ts-check -/** @typedef {import('@tabulous/server/src/graphql').Mesh} Mesh */ - -import { stackMeshes } from '@tabulous/server/src/utils/index.js' +import { stackMeshes } from '@tabulous/game-utils' const sizes = { card: { depth: 4.25, width: 3, height: 0.01 }, @@ -12,7 +10,7 @@ const spacing = { } export function buildCards(full = false) { - /** @type {Mesh[]} */ + /** @type {import('@tabulous/types').Mesh[]} */ const cards = [] for (const suit of ['spades', 'diamonds', 'clubs', 'hearts']) { for (let index = 1; index <= 13; index++) { @@ -50,7 +48,7 @@ export function buildDice( /** @type {number} */ count, offset = 0 ) { - /** @type {Mesh[]} */ + /** @type {import('@tabulous/types').Mesh[]} */ const dices = [] for (let rank = 1; rank <= count; rank++) { dices.push({ diff --git a/apps/games/playground/logic/players.js b/apps/games/playground/logic/players.js index f0bfa6a7..5e578493 100644 --- a/apps/games/playground/logic/players.js +++ b/apps/games/playground/logic/players.js @@ -9,7 +9,7 @@ import { buildCards, buildDice } from './build.js' * @property {number} die8Count - 'How many 8-faces die: 0~5. */ -/** @type {import('@tabulous/server/src/services/catalog').AskForParameters} */ +/** @type {import('@tabulous/types').AskForParameters} */ export function askForParameters({ game: { preferences } }) { return preferences.length ? null @@ -54,7 +54,7 @@ export function askForParameters({ game: { preferences } }) { } } -/** @type {import('@tabulous/server/src/services/catalog').AddPlayer} */ +/** @type {import('@tabulous/types').AddPlayer} */ export function addPlayer(game, player, parameters) { const { cardCount, die4Count, die6Count, die8Count } = parameters let offset = 0 diff --git a/apps/server/jsconfig.json b/apps/server/jsconfig.json index d7d32c3d..80171a65 100644 --- a/apps/server/jsconfig.json +++ b/apps/server/jsconfig.json @@ -1,4 +1,10 @@ { "extends": "../../jsconfig.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@src/*": ["src/*"] + } + }, "include": ["src/**/*.js", "tests/**/*.js", "migrations/*.js"] } diff --git a/apps/server/migrations/001-redis.js b/apps/server/migrations/001-redis.js index b65593a4..b25fe7dd 100644 --- a/apps/server/migrations/001-redis.js +++ b/apps/server/migrations/001-redis.js @@ -1,9 +1,4 @@ // @ts-check -/** - * @template {{id: string}} M - * @typedef {import('@tabulous/server/src/repositories/abstract-repository').AbstractRepository } AbstractRepository - */ - import { access, readFile } from 'node:fs/promises' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -16,7 +11,7 @@ export async function apply(repositories) { /** * @template {{id: string}} M - * @template {AbstractRepository} R + * @template {import('@src/repositories/abstract-repository').AbstractRepository} R * @param {R} repository * @returns {Promise} */ diff --git a/apps/server/migrations/003-fix-players-index.js b/apps/server/migrations/003-fix-players-index.js index 7b5fb88f..1b50be3a 100644 --- a/apps/server/migrations/003-fix-players-index.js +++ b/apps/server/migrations/003-fix-players-index.js @@ -1,5 +1,4 @@ // @ts-check - /** @type {import('.').Apply} */ export async function apply({ players }, redis) { console.log('delete undefined provider index') diff --git a/apps/server/migrations/index.d.ts b/apps/server/migrations/index.d.ts index 36b6f4c6..420d13ca 100644 --- a/apps/server/migrations/index.d.ts +++ b/apps/server/migrations/index.d.ts @@ -1,7 +1,8 @@ /* eslint-disable no-unused-vars */ -import { default as Repositories } from '@tabulous/server/src/repositories' import type { Redis } from 'ioredis' +import * as Repositories from '../src/repositories' + // Applies a migration, given repositories instances and initialized Redis client. export type Apply = ( repositories: typeof Repositories, diff --git a/apps/server/migrations/index.js b/apps/server/migrations/index.js index e7fe4a38..7f9f6d25 100644 --- a/apps/server/migrations/index.js +++ b/apps/server/migrations/index.js @@ -7,7 +7,7 @@ import { parseArgs } from 'node:util' import { config } from 'dotenv' import Redis from 'ioredis' -import repositories from '../src/repositories/index.js' +import * as repositories from '../src/repositories/index.js' const __dirname = dirname(fileURLToPath(import.meta.url)) const migrationfileRexExp = /^\d+-.+\.js$/ diff --git a/apps/server/migrations/utils.js b/apps/server/migrations/utils.js index 96baf98b..19dbde16 100644 --- a/apps/server/migrations/utils.js +++ b/apps/server/migrations/utils.js @@ -1,13 +1,8 @@ // @ts-check -/** - * @template {{id: string}} M - * @typedef {import('@tabulous/server/src/repositories/abstract-repository').AbstractRepository } AbstractRepository - */ - /** * Recursively fetches all model pages of a given repository, applying a function to each of them. * @template {{ id: string }} M - * @param {AbstractRepository} repository - repository of models. + * @param {import('@src/repositories').AbstractRepository} repository - repository of models. * @param {(obj: M) => Promise} apply - function individually applied to each model. * @param {{ from: number }} [params = { from: 0 }] - fetch parameters, for internal use. */ diff --git a/apps/server/package.json b/apps/server/package.json index 8920a51d..5f30f52b 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -17,6 +17,8 @@ "@fastify/websocket": "^8.2.0", "@isaacs/ttlcache": "^1.4.1", "@orama/orama": "^1.2.4", + "@tabulous/game-utils": "workspace:*", + "@tabulous/types": "workspace:*", "ajv": "^8.12.0", "deepmerge": "^4.3.1", "dotenv": "^16.3.1", diff --git a/apps/server/src/graphql/catalog-resolver.js b/apps/server/src/graphql/catalog-resolver.js index a128d182..f22d5f3b 100644 --- a/apps/server/src/graphql/catalog-resolver.js +++ b/apps/server/src/graphql/catalog-resolver.js @@ -1,12 +1,4 @@ // @ts-check -/** - * @typedef {import('.').CatalogItem} CatalogItem - * @typedef {import('.').GrantAccessArgs} GrantAccessArgs - * @typedef {import('.').RevokeAccessArgs} RevokeAccessArgs - * @typedef {import('./utils').GraphQLAnonymousContext} GraphQLAnonymousContext - * @typedef {import('./utils').GraphQLContext} GraphQLContext - */ - import services from '../services/index.js' import { isAdmin } from './utils.js' @@ -17,8 +9,8 @@ export default { * Requires valid authentication. * @param {unknown} obj - graphQL object. * @param {unknown} args - query arguments. - * @param {GraphQLAnonymousContext} context - graphQL context. - * @returns {Promise} list of catalog items. + * @param {import('@src/plugins/graphql').GraphQLContext} context - graphQL context. + * @returns list of catalog items. */ listCatalog: (obj, args, { player }) => services.listCatalog(player) }, @@ -29,8 +21,8 @@ export default { * Grants another player access to a given catalog item. * Requires authentication and elevated privileges. * @param {unknown} obj - graphQL object. - * @param {GrantAccessArgs} args - query arguments. - * @returns {Promise} true if access was granted. + * @param {import('.').GrantAccessArgs} args - query arguments. + * @returns true if access was granted. */ async (obj, { playerId, itemName }) => { return (await services.grantAccess(playerId, itemName)) !== null @@ -43,8 +35,8 @@ export default { * Requires authentication and elevated privileges. * @async * @param {unknown} obj - graphQL object. - * @param {RevokeAccessArgs} args - query arguments. - * @returns {Promise} true if access was revoked. + * @param {import('.').RevokeAccessArgs} args - query arguments. + * @returns true if access was revoked. */ async (obj, { playerId, itemName }) => { return (await services.revokeAccess(playerId, itemName)) !== null diff --git a/apps/server/src/graphql/games-resolver.js b/apps/server/src/graphql/games-resolver.js index 985103c1..30d4894c 100644 --- a/apps/server/src/graphql/games-resolver.js +++ b/apps/server/src/graphql/games-resolver.js @@ -1,22 +1,4 @@ // @ts-check -/** - * @typedef {import('.').CreateGameArgs} CreateGameArgs - * @typedef {import('.').DeleteGameArgs} DeleteGameArgs - * @typedef {import('.').Game} Game - * @typedef {import('.').GameParameters} GameParameters - * @typedef {import('.').GamePlayer} Player - * @typedef {import('.').InviteArgs} InviteArgs - * @typedef {import('.').JoinGameArgs} JoinGameArgs - * @typedef {import('.').KickArgs} KickArgs - * @typedef {import('.').PlayerAction} PlayerAction - * @typedef {import('.').PlayerMove} PlayerMove - * @typedef {import('.').PromoteGameArgs} PromoteGameArgs - * @typedef {import('.').ReceiveGameUpdatesArgs} ReceiveGameUpdatesArgs - * @typedef {import('.').SaveGameArgs} SaveGameArgs - * @typedef {import('./utils').GraphQLContext} GraphQLContext - * @typedef {import('./utils').PubSubQueue} PubSubQueue - */ - import { filter } from 'rxjs/operators' import services from '../services/index.js' @@ -28,10 +10,11 @@ const logger = makeLogger('games-resolver') /** * Scafolds Mercurius loaders for specific properties of queried objects. * These loaders will fill populate a game's players field from its playerIds and guestIds array. - * @returns {{ loader: import('mercurius').Loader, opts: { cache: boolean } }} built loaders + * @returns built loaders */ function buildPlayerLoader() { return { + /** @type {import('mercurius').Loader}*/ async loader(queries) { return Promise.all( queries.map( @@ -40,13 +23,13 @@ function buildPlayerLoader() { services .getPlayerById([...obj.playerIds, ...obj.guestIds]) .then(players => - /** @type {Player[]} */ (players.filter(Boolean)).map( - player => ({ - ...player, - isGuest: obj.guestIds.includes(player.id), - isOwner: obj.ownerId === player.id - }) - ) + /** @type {import('@tabulous/types').Player[]} */ ( + players.filter(Boolean) + ).map(player => ({ + ...player, + isGuest: obj.guestIds.includes(player.id), + isOwner: obj.ownerId === player.id + })) ) ) ) @@ -76,8 +59,8 @@ export default { * Requires valid authentication. * @param {unknown} obj - graphQL object. * @param {unknown} args - subscription arguments. - * @param {GraphQLContext} context - graphQL context. - * @returns {Promise} list of current games. + * @param {import('./utils').GraphQLContext} context - graphQL context. + * @returns list of current games. */ (obj, args, { player }) => services.listGames(player.id) ) @@ -89,9 +72,9 @@ export default { * Instanciates a new game for the a current player (who becomes its owner). * Requires valid authentication. * @param {unknown} obj - graphQL object. - * @param {CreateGameArgs} args - mutation arguments. - * @param {GraphQLContext} context - graphQL context. - * @returns {Promise} created game details, or null. + * @param {import('.').CreateGameArgs} args - mutation arguments. + * @param {import('./utils').GraphQLContext} context - graphQL context. + * @returns created game details, or null. */ (obj, { kind }, { player }) => services.createGame(kind, player) ), @@ -102,9 +85,9 @@ export default { * May returns other parameters if provided values disn't suffice, or the actual game content. * Requires valid authentication. * @param {unknown} obj - graphQL object. - * @param {JoinGameArgs} args - mutation arguments. - * @param {GraphQLContext} context - graphQL context. - * @returns {Promise} joined game in case of success, new required parameters, or null. + * @param {import('.').JoinGameArgs} args - mutation arguments. + * @param {import('./utils').GraphQLContext} context - graphQL context. + * @returns joined game in case of success, new required parameters, or null. */ (obj, { gameId, parameters: paramString }, { player }) => { let parameters = null @@ -129,9 +112,9 @@ export default { * May returns parameters if needed, or the actual game content. * Requires valid authentication. * @param {unknown} obj - graphQL object. - * @param {PromoteGameArgs} args - mutation arguments. - * @param {GraphQLContext} context - graphQL context. - * @returns {Promise} joined game in case of success, new required parameters, or null. + * @param {import('.').PromoteGameArgs} args - mutation arguments. + * @param {import('./utils').GraphQLContext} context - graphQL context. + * @returns joined game in case of success, new required parameters, or null. */ (obj, { gameId, kind }, { player }) => services.promoteGame(gameId, kind, player) @@ -142,9 +125,9 @@ export default { * Saves a current player's existing game details. * Requires valid authentication. * @param {unknown} obj - graphQL object. - * @param {SaveGameArgs} args - mutation arguments. - * @param {GraphQLContext} context - graphQL context. - * @returns {Promise} created game details, or null. + * @param {import('.').SaveGameArgs} args - mutation arguments. + * @param {import('./utils').GraphQLContext} context - graphQL context. + * @returns created game details, or null. */ (obj, { game }, { player }) => services.saveGame(game, player.id) ), @@ -154,9 +137,9 @@ export default { * Deletes a current player's existing game. * Requires valid authentication. * @param {unknown} obj - graphQL object. - * @param {DeleteGameArgs} args - mutation arguments. - * @param {GraphQLContext} context - graphQL context. - * @returns {Promise} deleted game details, or null. + * @param {import('.').DeleteGameArgs} args - mutation arguments. + * @param {import('./utils').GraphQLContext} context - graphQL context. + * @returns deleted game details, or null. */ (obj, { gameId }, { player }) => services.deleteGame(gameId, player) ), @@ -166,9 +149,9 @@ export default { * Invites another player to a current player's game. * Requires valid authentication. * @param {unknown} obj - graphQL object. - * @param {InviteArgs} args - mutation arguments. - * @param {GraphQLContext} context - graphQL context. - * @returns {Promise} saved game details, or null. + * @param {import('.').InviteArgs} args - mutation arguments. + * @param {import('./utils').GraphQLContext} context - graphQL context. + * @returns saved game details, or null. */ (obj, { gameId, playerIds: guestIds }, { player }) => services.invite(gameId, guestIds, player.id) @@ -179,9 +162,9 @@ export default { * Kick a player from a current player's game. * Requires valid authentication. * @param {unknown} obj - graphQL object. - * @param {KickArgs} args - mutation arguments. - * @param {GraphQLContext} context - graphQL context. - * @returns {Promise} saved game details, or null. + * @param {import('.').KickArgs} args - mutation arguments. + * @param {import('./utils').GraphQLContext} context - graphQL context. + * @returns saved game details, or null. */ (obj, { gameId, playerId: kickedId }, { player }) => services.kick(gameId, kickedId, player.id) @@ -196,9 +179,8 @@ export default { * Requires valid authentication. * @param {unknown} obj - graphQL object. * @param {unknown} args - subscription arguments. - * @param {GraphQLContext} context - graphQL context. - * @yields {Game[]} - * @returns {import('./utils').PubSubQueue} + * @param {import('./utils').GraphQLContext} context - graphQL context. + * @yields {import('.').Game[]} */ async (obj, args, { player, pubsub }) => { const topic = `listGames-${player.id}` @@ -229,10 +211,9 @@ export default { * Sends a given game updates from server. * Requires valid authentication, and users must have this game in their list. * @param {unknown} obj - graphQL object. - * @param {ReceiveGameUpdatesArgs} args - subscription argument. - * @param {GraphQLContext} context - graphQL context. - * @yields {Game} - * @returns {PubSubQueue} + * @param {import('.').ReceiveGameUpdatesArgs} args - subscription argument. + * @param {import('./utils').GraphQLContext} context - graphQL context. + * @yields {import('.').Game} */ async (obj, { gameId }, { player, pubsub }) => { const topic = `receiveGameUpdates-${player.id}-${gameId}` @@ -266,7 +247,7 @@ export default { GameOrParameters: { /** * Distinguishes returned Game from GameParameters - * @param {?Game|GameParameters} obj - either a Game or a GameParameters object. + * @param {?import('.').Game|import('.').GameParameters} obj - either a Game or a GameParameters object. * @returns the type of this object. */ resolveType(obj) { @@ -277,7 +258,7 @@ export default { GameParameters: { /** * Serializer for schema. - * @param {import('../services/games').GameParameters} obj - serialized game parameter schema + * @param {import('@tabulous/types').GameParameters} obj - serialized game parameter schema */ schemaString: obj => JSON.stringify(obj.schema) }, @@ -285,7 +266,7 @@ export default { HistoryRecord: { /** * Distinguishes returned PlayerMove and PlayerAction - * @param {?PlayerMove|PlayerAction} obj - either a player move or action object. + * @param {?import('@tabulous/types').HistoryRecord} obj - either a player move or action object. * @returns the type of this object. */ resolveType(obj) { diff --git a/apps/server/src/graphql/index.d.ts b/apps/server/src/graphql/index.d.ts index c95e9b70..500959e9 100644 --- a/apps/server/src/graphql/index.d.ts +++ b/apps/server/src/graphql/index.d.ts @@ -1,6 +1,13 @@ /* eslint-disable no-unused-vars */ -import type { ActionSpec, GameDescriptor } from '../services/catalog' -import type * as games from '../services/games' +import type { + ActionSpec, + GameData, + GameDescriptor, + GameParameters as FullGameParameters, + Player as FullPlayer, + TurnCredentials +} from '@tabulous/types' + import type * as players from '../services/players' import type * as creds from '../services/turn-credentials' import type { Level } from '../utils/logger' @@ -14,17 +21,6 @@ export type CatalogItem = Pick & > > -export type { - ActionName, - ActionSpec, - ColorSpec, - Copyright, - ItemLocale, - ItemLocales, - TableSpec, - ZoomSpec -} from '../services/catalog' - export type ButtonName = keyof ActionSpec export interface GrantAccessArgs { @@ -39,13 +35,13 @@ export interface RevokeAccessArgs { // Generated from games.graphql -export type GamePlayer = players.Player & { +export type GamePlayer = FullPlayer & { isGuest?: boolean isOwner?: boolean } export type Game = Pick< - games.GameData, + GameData, | 'id' | 'created' | 'kind' @@ -57,7 +53,7 @@ export type Game = Pick< > & Partial< Pick< - games.GameData, + GameData, | 'messages' | 'locales' | 'meshes' @@ -69,40 +65,13 @@ export type Game = Pick< > > & { players?: GamePlayer[] } -export type { - Anchor, - AnchorableState, - CameraPosition, - DetailableState, - Dimension, - DrawableState, - FlippableState, - Hand, - HistoryRecord, - InitialTransform, - LockableState, - Mesh, - Message, - MovableState, - PlayerAction, - PlayerMove, - PlayerPreference, - Point, - QuantifiableState, - RandomizableState, - RotableState, - Shape, - StackableState, - Targetable -} from '../services/games' - export type GameParameters = Pick< - games.GameParameters, + FullGameParameters, 'error' | 'id' | 'kind' > & Partial< Pick< - games.GameParameters, + FullGameParameters, | 'locales' | 'preferences' | 'rulesBookPageCount' @@ -154,10 +123,10 @@ export interface LoggerLevel { } // Generated from players.graphql -export type Player = Pick & +export type Player = Pick & Partial< Pick< - players.Player, + FullPlayer, | 'currentGameId' | 'avatar' | 'provider' @@ -178,12 +147,10 @@ export type FriendshipUpdate = { from: Player } & Pick< 'requested' | 'proposed' | 'accepted' | 'declined' > -export type TurnCredentials = creds.TurnCredentials - export interface PlayerWithTurnCredentials { token: string // authentication token. player: Player // authenticated player. - turnCredentials: creds.TurnCredentials // credentials for the TURN server. + turnCredentials: TurnCredentials // credentials for the TURN server. } export interface SearchPlayersArgs { diff --git a/apps/server/src/graphql/logger-resolver.js b/apps/server/src/graphql/logger-resolver.js index c907fb72..fb9c25f1 100644 --- a/apps/server/src/graphql/logger-resolver.js +++ b/apps/server/src/graphql/logger-resolver.js @@ -1,10 +1,4 @@ // @ts-check -/** - * @typedef {import('.').LoggerLevel} LoggerLevel - * @typedef {import('./utils').GraphQLAnonymousContext} GraphQLAnonymousContext - * @typedef {import('./utils').GraphQLContext} GraphQLContext - */ - import { configureLoggers, currentLevels } from '../utils/logger.js' import { isAdmin } from './utils.js' @@ -16,7 +10,7 @@ export default { /** * Returns configured loggers respective levels. * Requires authentication and elevated privileges. - * @returns {LoggerLevel[]} the ordered list of logger names and their respective levels. + * @returns the ordered list of logger names and their respective levels. */ serializeLoggerLevels ) @@ -25,7 +19,7 @@ export default { Mutation: { /** * @typedef {object} ConfigureLoggerLevelsArgs - * @property {LoggerLevel[]} levels - new logger levels. + * @property {import('.').LoggerLevel[]} levels - new logger levels. */ configureLoggerLevels: isAdmin( @@ -34,7 +28,7 @@ export default { * Requires authentication and elevated privileges. * @param {unknown} obj - graphQL object. * @param {ConfigureLoggerLevelsArgs} args - mutation arguments. - * @returns {LoggerLevel[]} the ordered list of logger names and their respective levels. + * @returns the ordered list of logger names and their respective levels. */ (obj, { levels }) => { configureLoggers( diff --git a/apps/server/src/graphql/players-resolver.js b/apps/server/src/graphql/players-resolver.js index db77d9e1..1bdbcd25 100644 --- a/apps/server/src/graphql/players-resolver.js +++ b/apps/server/src/graphql/players-resolver.js @@ -1,23 +1,8 @@ // @ts-check -/** - * @typedef {import('.').AddPlayerArgs} AddPlayerArgs - * @typedef {import('.').Friendship} Friendship - * @typedef {import('.').FriendshipUpdate} FriendshipUpdate - * @typedef {import('.').ListPlayersArgs} ListPlayersArgs - * @typedef {import('.').LogInArgs} LogInArgs - * @typedef {import('.').Player} Player - * @typedef {import('.').PlayerWithTurnCredentials} PlayerWithTurnCredentials - * @typedef {import('.').SearchPlayersArgs} SearchPlayersArgs - * @typedef {import('.').TargetedPlayerArgs} TargetedPlayerArgs - * @typedef {import('.').UpdateCurrentPlayerArgs} UpdateCurrentPlayerArgs - * @typedef {import('./utils').GraphQLContext} GraphQLContext - * @typedef {import('./utils').PubSubQueue} PubSubQueue - */ - import { filter } from 'rxjs' import { makeToken } from '../plugins/utils.js' -import repositories from '../repositories/index.js' +import * as repositories from '../repositories/index.js' import services from '../services/index.js' import { hash, makeLogger } from '../utils/index.js' import { isAdmin, isAuthenticated } from './utils.js' @@ -30,10 +15,11 @@ const logger = makeLogger('players-resolver') * The loader only resolve once, and do not fetched once the models are available. * @param {string} field - field name for which the loader is defined. * @param {string} idField - field storing the resolved id. - * @returns {{ loader: import('mercurius').Loader, opts: { cache: boolean } }} built loaders + * @returns built loaders */ function buildPlayerLoader(field, idField) { return { + /** @type {import('mercurius').Loader}*/ async loader(queries) { const ids = queries.reduce( (/** @type {string[]} */ ids, { obj }) => @@ -74,8 +60,8 @@ export default { * Requires valid authentication. * @param {unknown} obj - graphQL object. * @param {unknown} args - query arguments: - * @param {GraphQLContext} context - graphQL context. - * @returns {PlayerWithTurnCredentials} current player with turn credentials. + * @param {import('./utils').GraphQLContext} context - graphQL context. + * @returns current player with turn credentials. */ (obj, args, { player, conf, token }) => { logger.trace( @@ -94,9 +80,9 @@ export default { * Returns players (except the current one) which username contains searched text. * Requires valid authentication. * @param {unknown} obj - graphQL object. - * @param {SearchPlayersArgs} args - query arguments. - * @param {GraphQLContext} context - graphQL context. - * @returns {Promise} list (potentially empty) of matching players. + * @param {import('.').SearchPlayersArgs} args - query arguments. + * @param {import('./utils').GraphQLContext} context - graphQL context. + * @returns list (potentially empty) of matching players. */ (obj, { search, includeCurrent }, { player }) => services.searchPlayers(search, player.id, !includeCurrent) @@ -107,8 +93,8 @@ export default { * Returns a page or players. * Requires authentication and elevated privileges. * @param {unknown} obj - graphQL object. - * @param {ListPlayersArgs} args - query arguments, including: - * @returns {Promise>} extract of the player list. + * @param {import('.').ListPlayersArgs} args - query arguments, including: + * @returns extract of the player list. */ (obj, args) => repositories.players.list(args) ), @@ -119,12 +105,10 @@ export default { * Requires valid authentication. * @param {unknown} obj - graphQL object. * @param {unknown} args - query arguments. - * @param {GraphQLContext} context - graphQL context. - * @returns {Promise} list (potentially empty) of friend players. + * @param {import('./utils').GraphQLContext} context - graphQL context. + * @returns list (potentially empty) of friend players. */ - (obj, args, { player }) => - // @ts-expect-error: player is enriched by loaders - services.listFriends(player.id) + (obj, args, { player }) => services.listFriends(player.id) ) }, @@ -135,8 +119,8 @@ export default { * The clear password provided is hashed before being stored. * Requires authentication and elevated privileges. * @param {unknown} obj - graphQL object. - * @param {AddPlayerArgs} args - mutation arguments. - * @returns {Promise} the created player. + * @param {import('.').AddPlayerArgs} args - mutation arguments. + * @returns the created player. */ async (obj, { id, username, password }) => services.upsertPlayer({ id, username, password: hash(password) }) @@ -146,9 +130,9 @@ export default { * Authenticates an user from their user id. * Returns a token to allow browser issueing authenticated requests. * @param {unknown} obj - graphQL object. - * @param {LogInArgs} args - mutation arguments. - * @param {GraphQLContext} context - graphQL context. - * @returns {Promise} authentified player with turn credentials. + * @param {import('.').LogInArgs} args - mutation arguments. + * @param {import('./utils').GraphQLContext} context - graphQL context. + * @returns authentified player with turn credentials. */ logIn: async (obj, { id, password }, { conf }) => { logger.trace('authenticates manual player') @@ -170,8 +154,8 @@ export default { * Record an user accepting the terms of service. * @param {unknown} obj - graphQL object. * @param {unknown} args - mutation arguments. - * @param {GraphQLContext} context - graphQL context. - * @returns {Promise} saved player. + * @param {import('./utils').GraphQLContext} context - graphQL context. + * @returns saved player. */ (obj, args, { player }) => services.acceptTerms(player) ), @@ -181,13 +165,13 @@ export default { * Updates current player's details. * Requires authentication. * @param {unknown} obj - graphQL object. - * @param {UpdateCurrentPlayerArgs} args - mutation arguments. - * @param {GraphQLContext} context - graphQL context. - * @returns {Promise} the updated player. + * @param {import('.').UpdateCurrentPlayerArgs} args - mutation arguments. + * @param {import('./utils').GraphQLContext} context - graphQL context. + * @returns the updated player. */ async (obj, { username, avatar, usernameSearchable }, { player }) => { logger.trace('updates current player') - /** @type {Partial} */ + /** @type {Partial} */ const update = { id: player.id } if (username !== undefined) { // https://en.wikipedia.org/wiki/Latin_script_in_Unicode @@ -221,8 +205,8 @@ export default { * Deletes an existing player account. * Requires authentication and elevated privileges. * @param {unknown} obj - graphQL object. - * @param {TargetedPlayerArgs} args - mutation arguments. - * @returns {Promise} deleted player account, or null. + * @param {import('.').TargetedPlayerArgs} args - mutation arguments. + * @returns deleted player account, or null. */ (obj, { id }) => repositories.players.deleteById(id) ), @@ -232,9 +216,9 @@ export default { * Sends a friend request from one player to another one. * Requires valid authentication. * @param {unknown} obj - graphQL object. - * @param {TargetedPlayerArgs} args - mutation arguments. - * @param {GraphQLContext} context - graphQL context. - * @returns {Promise} true if the operation succeeds. + * @param {import('.').TargetedPlayerArgs} args - mutation arguments. + * @param {import('./utils').GraphQLContext} context - graphQL context. + * @returns true if the operation succeeds. */ (obj, { id }, { player }) => services.requestFriendship(player, id) ), @@ -244,9 +228,9 @@ export default { * Accepts a friend request from another player. * Requires valid authentication. * @param {unknown} obj - graphQL object. - * @param {TargetedPlayerArgs} args - mutation arguments. - * @param {GraphQLContext} context - graphQL context. - * @returns {Promise} true if the operation succeeds. + * @param {import('.').TargetedPlayerArgs} args - mutation arguments. + * @param {import('./utils').GraphQLContext} context - graphQL context. + * @returns true if the operation succeeds. */ (obj, { id }, { player }) => services.acceptFriendship(player, id) ), @@ -256,9 +240,9 @@ export default { * Declines a friend request or ends existing friendship with another player. * Requires valid authentication. * @param {unknown} obj - graphQL object. - * @param {TargetedPlayerArgs} args - mutation arguments. - * @param {GraphQLContext} context - graphQL context. - * @returns {Promise} true if the operation succeeds. + * @param {import('.').TargetedPlayerArgs} args - mutation arguments. + * @param {import('./utils').GraphQLContext} context - graphQL context. + * @returns true if the operation succeeds. */ (obj, { id }, { player }) => services.endFriendship(player, id) ) @@ -272,9 +256,8 @@ export default { * Requires valid authentication. * @param {unknown} obj - graphQL object. * @param {object} args - subscription arguments. - * @param {GraphQLContext} context - graphQL context. - * @yields {FriendshipUpdate} - * @returns {PubSubQueue} + * @param {import('./utils').GraphQLContext} context - graphQL context. + * @yields {import('.').FriendshipUpdate} */ async (obj, args, { player, pubsub }) => { const topic = `friendship-${player.id}` diff --git a/apps/server/src/graphql/resolvers.js b/apps/server/src/graphql/resolvers.js index 5138bd05..d8558913 100644 --- a/apps/server/src/graphql/resolvers.js +++ b/apps/server/src/graphql/resolvers.js @@ -35,7 +35,7 @@ export { loaders, resolvers, schema } /** * @param {string} fileName - loaded file - * @returns {string} file content + * @returns file content */ function loadTypeDefs(fileName) { return readFileSync(join(folder, fileName)).toString() diff --git a/apps/server/src/graphql/signals-resolver.js b/apps/server/src/graphql/signals-resolver.js index 6e283c42..3dda92b9 100644 --- a/apps/server/src/graphql/signals-resolver.js +++ b/apps/server/src/graphql/signals-resolver.js @@ -1,12 +1,4 @@ // @ts-check -/** - * @typedef {import('.').AwaitSignalArgs} AwaitSignalArgs - * @typedef {import('.').SendSignalArgs} SendSignalArgs - * @typedef {import('.').Signal} Signal - * @typedef {import('./utils').GraphQLContext} GraphQLContext - * @typedef {import('./utils').PubSubQueue} PubSubQueue - */ - import services from '../services/index.js' import { makeLogger } from '../utils/index.js' import { isAuthenticated } from './utils.js' @@ -29,24 +21,30 @@ const logger = makeLogger('signals-resolver') */ export default { Mutation: { - /** - * Emits signal addressed from current player onto the subscription of another player. - * Requires valid authentication. - * @param {unknown} obj - graphQL object. - * @param {SendSignalArgs} args - mutation arguments, including: - * @param {GraphQLContext} context - graphQL context. - * @returns {Signal} signal addressed. - */ - sendSignal: isAuthenticated((obj, { signal }, { player, pubsub }) => { - signal.from = player.id - const res = { - topic: `sendSignal-${signal.to}`, - payload: { awaitSignal: signal } + sendSignal: isAuthenticated( + /** + * Emits signal addressed from current player onto the subscription of another player. + * Requires valid authentication. + * @param {unknown} obj - graphQL object. + * @param {import('.').SendSignalArgs} args - mutation arguments, including: + * @param {import('./utils').GraphQLContext} context - graphQL context. + * @returns signal addressed. + */ + (obj, { signal }, { player, pubsub }) => { + /** @type {import('.').Signal} */ + const result = { + ...signal, + from: player.id + } + const res = { + topic: `sendSignal-${signal.to}`, + payload: { awaitSignal: result } + } + pubsub.publish(res) + logger.trace({ res }, 'sent signal') + return result } - pubsub.publish(res) - logger.trace({ res }, 'sent signal') - return signal - }) + ) }, Subscription: { @@ -56,10 +54,9 @@ export default { * Emits signal addressed to the current player * Requires valid authentication. * @param {unknown} obj - graphQL object. - * @param {AwaitSignalArgs} args - subscription arguments. - * @param {GraphQLContext} context - graphQL context. - * @yields {Signal} - * @returns {PubSubQueue} + * @param {import('.').AwaitSignalArgs} args - subscription arguments. + * @param {import('./utils').GraphQLContext} context - graphQL context. + * @yields {import('.').Signal} */ async (obj, { gameId }, { player, pubsub }) => { const queue = await pubsub.subscribe(`sendSignal-${player.id}`) diff --git a/apps/server/src/graphql/utils.js b/apps/server/src/graphql/utils.js index eece2adf..bced751c 100644 --- a/apps/server/src/graphql/utils.js +++ b/apps/server/src/graphql/utils.js @@ -1,15 +1,9 @@ -// @ts-check -/** - * @typedef {import('mercurius').PubSub} PubSub - * @typedef {import('../plugins/graphql').GraphQLContext} GraphQLAnonymousContext - */ - import mercurius from 'mercurius' const { ErrorWithProps } = mercurius -/** @typedef {Omit & { player: NonNullable }} GraphQLContext*/ -/** @typedef {ReturnType} PubSubQueue*/ +/** @typedef {Omit & { player: NonNullable }} GraphQLContext*/ +/** @typedef {ReturnType} PubSubQueue*/ /** * @template Source, Context, Args diff --git a/apps/server/src/plugins/auth.js b/apps/server/src/plugins/auth.js index 17165e25..71d16f92 100644 --- a/apps/server/src/plugins/auth.js +++ b/apps/server/src/plugins/auth.js @@ -1,30 +1,23 @@ // @ts-check -/** - * @typedef {import('fastify').FastifyInstance} FastifyInstance - * @typedef {import('../services/auth/oauth2').ProviderInit} ProviderInit - * @typedef {import('./utils').SignerOptions} SignerOptions - */ - import services from '../services/index.js' import { addToLogContext, makeLogger } from '../utils/index.js' import { makeToken } from './utils.js' /** - * @typedef {object} AuthOptions authentication plugin options, including: + * @typedef {object} Options authentication plugin options, including: * @property {string} domain - public facing domain (full url) for authentication redirections. * @property {string} allowedOrigins - regular expression for allowed domains during authentication. - * @property {SignerOptions} jwt - options used to encrypt JWT token: needs 'key' at least. - * @property {Omit} [github] - Github authentication provider options. - * @property {Omit} [google] - Google authentication provider options. + * @property {import('./utils').SignerOptions} jwt - options used to encrypt JWT token: needs 'key' at least. + * @property {Omit} [github] - Github authentication provider options. + * @property {Omit} [google] - Google authentication provider options. */ const logger = makeLogger('auth-plugin') /** * Registers endpoint to handle player authentication with various authentication providers. - * @param {FastifyInstance} app - a fastify application. - * @param {AuthOptions} options - plugin's options. - * @returns {Promise} + * @param {import('fastify').FastifyInstance} app - a fastify application. + * @param {Options} options - plugin's options. */ export default async function registerAuth(app, options) { const { githubAuth, googleAuth, upsertPlayer } = services @@ -34,13 +27,15 @@ export default async function registerAuth(app, options) { const name = /** @type {'github'|'google'} */ (provider.name) if (name in options) { provider.init({ - .../** @type {ProviderInit} */ (options[name]), + .../** @type {Omit}} */ ( + options[name] + ), redirect: `${options.domain}/${name}/callback` }) app.get( `/${name}/connect`, - // @ts-expect-error: Property 'redirect' does not exist on type 'unknown' + // @ts-expect-error -- Property 'redirect' does not exist on type 'unknown' ({ query: { redirect }, hostname, protocol }, reply) => { addToLogContext({ authProvider: name }) const origin = `${protocol}://${hostname}` @@ -67,7 +62,7 @@ export default async function registerAuth(app, options) { app.get( `/${name}/callback`, - // @ts-expect-error: Property 'code', 'state' does not exist on type 'unknown' + // @ts-expect-error -- Property 'code', 'state' does not exist on type 'unknown' async ({ query: { code, state } }, reply) => { addToLogContext({ authProvider: name }) logger.trace('handles auth provider callback') diff --git a/apps/server/src/plugins/cors.js b/apps/server/src/plugins/cors.js index 27383378..fffac676 100644 --- a/apps/server/src/plugins/cors.js +++ b/apps/server/src/plugins/cors.js @@ -2,7 +2,7 @@ import corsPlugin from '@fastify/cors' /** - * @typedef {object} CorsOptions CORS plugin options, including: + * @typedef {object} Options CORS plugin options, including: * @property {string} allowedOrigins - regular expression for allowed domains for CORS. */ @@ -10,7 +10,7 @@ import corsPlugin from '@fastify/cors' * Registers endpoints to handle CORS requests. * @async * @param {import('fastify').FastifyInstance} app - a fastify application. - * @param {CorsOptions} opts - plugin's options. + * @param {Options} opts - plugin's options. */ export default async function registerCors(app, opts) { const origin = new RegExp(opts.allowedOrigins) diff --git a/apps/server/src/plugins/graphql.js b/apps/server/src/plugins/graphql.js index 80002a6e..f738754e 100644 --- a/apps/server/src/plugins/graphql.js +++ b/apps/server/src/plugins/graphql.js @@ -1,13 +1,4 @@ // @ts-check -/** - * @typedef {import('fastify').FastifyInstance} FastifyInstance - * @typedef {import('mercurius').MercuriusContext} MercuriusContext - * @typedef {import('mercurius').MercuriusOptions} MercuriusOptions - * @typedef {import('../server').Server} Server - * @typedef {import('../services/configuration').Configuration} Configuration - * @typedef {import('../services/players').Player} Player - */ - import mercurius from 'mercurius' import redis from 'mqemitter-redis' @@ -25,15 +16,15 @@ const logger = makeLogger('graphql-plugin') /** * @typedef {object} Context Context passed to every GraphQL resolver. - * @property {?Player} player - authenticated player, if any. + * @property {?import('@tabulous/types').Player} player - authenticated player, if any. * @property {string} token - authentication token (could be empty). - * @property {Configuration} conf - application configuration. + * @property {import('@src/services/configuration').Configuration} conf - application configuration. */ -/** @typedef {MercuriusContext & Context} GraphQLContext */ +/** @typedef {import('mercurius').MercuriusContext & Context} GraphQLContext */ /** - * @typedef {MercuriusOptions & GraphQLCustomOptions} GraphQLOptions graphQL plugin options. + * @typedef {import('mercurius').MercuriusOptions & GraphQLCustomOptions} Options graphQL plugin options. * You can use any of Mercurius options but `schema`, `resolvers`, `loaders` and `context` which are computed. */ @@ -46,16 +37,15 @@ const logger = makeLogger('graphql-plugin') * Request authentication expects a Bearer token ("Bearer " + a valid JWT). * In the case of GraphQL subscription, bearer is expected on the connection payload message. * In the case of GraphQL queries and mutations, bearer is expected in the Authorization header - * @param {FastifyInstance} fastify - a fastify application. - * @param {GraphQLOptions} opts - plugin options. - * @returns {Promise} + * @param {import('fastify').FastifyInstance} fastify - a fastify application. + * @param {Options} opts - plugin options. */ export default async function registerGraphQL( fastify, { allowedOrigins, pubsubUrl, ...opts } ) { const allowedOriginsRegExp = new RegExp(allowedOrigins) - const app = /** @type {Server} */ (fastify) + const app = /** @type {import('@src/server').Server} */ (fastify) await app.register(mercurius, { ...opts, schema, @@ -78,9 +68,9 @@ export default async function registerGraphQL( const player = await getAuthenticatedPlayer(token, app.conf.auth.jwt.key) addToLogContext({ graphql: { - // @ts-expect-error: 'body' is of type 'unknown' + // @ts-expect-error -- 'body' is of type 'unknown' operation: body.operationName, - // @ts-expect-error: 'body' is of type 'unknown' + // @ts-expect-error -- 'body' is of type 'unknown' variables: body.variables, currentPlayer: player && { id: player.id, username: player.username } } @@ -96,10 +86,6 @@ export default async function registerGraphQL( }) } -/** - * @param {string} [value] - * @returns {string} - */ -function extractBearer(value) { +function extractBearer(/** @type {string|undefined} */ value) { return (value ?? '').replace('Bearer ', '') } diff --git a/apps/server/src/plugins/static.js b/apps/server/src/plugins/static.js index bb12f4ee..abfde136 100644 --- a/apps/server/src/plugins/static.js +++ b/apps/server/src/plugins/static.js @@ -2,7 +2,7 @@ import staticPlugin from '@fastify/static' /** - * @typedef {object} StaticOptions Static content plugin options, including: + * @typedef {object} Options Static content plugin options, including: * @property {string} path - folder absolute path containing static files * @property {string} pathPrefix - URL path prefix for the static directory */ @@ -11,7 +11,7 @@ import staticPlugin from '@fastify/static' * Registers endpoints to serve the static game client. * @async * @param {import('fastify').FastifyInstance} app - a fastify application. - * @param {StaticOptions} opts - plugin's options. + * @param {Options} opts - plugin's options. */ export default async function registerStatic(app, opts) { app.register(staticPlugin, { diff --git a/apps/server/src/plugins/utils.js b/apps/server/src/plugins/utils.js index e663c413..356412fd 100644 --- a/apps/server/src/plugins/utils.js +++ b/apps/server/src/plugins/utils.js @@ -1,20 +1,14 @@ // @ts-check -/** - * @typedef {import('fast-jwt').SignerOptions} FullSignerOptions - * @typedef {import('fast-jwt').SignerSync} Signer - * @typedef {import('../services/players').Player} Player - */ - import { createSigner, createVerifier } from 'fast-jwt' import services from '../services/index.js' import { makeLogger } from '../utils/logger.js' -/** @typedef {Partial & { key: string }} SignerOptions */ +/** @typedef {Partial & { key: string }} SignerOptions */ /** @typedef {(jwt: string) => { id: string }} Verifier */ -/** @type {Map} */ +/** @type {Map} */ const signerByKey = new Map() /** @type {Map} */ const verifierByKey = new Map() @@ -25,9 +19,10 @@ const logger = makeLogger() * @async * @param {string} jwt - JWT set during authenticated and received from the incoming request. * @param {string} key - key used to verify the received sent. - * @returns {Promise} the corresponding player, if any. + * @returns the corresponding player, if any. */ export async function getAuthenticatedPlayer(jwt, key) { + /** @type {?import('@tabulous/types').Player} */ let player = null if (jwt) { try { @@ -46,15 +41,15 @@ export async function getAuthenticatedPlayer(jwt, key) { /** * Creates a signed JWT to identify the current player. - * @param {Player} player - authenticated player. + * @param {import('@tabulous/types').Player} player - authenticated player. * @param {SignerOptions} signerOptions - options used to sign the sent JWT. - * @returns {string} the token created. + * @returns the token created. */ export function makeToken(player, signerOptions) { return getSigner(signerOptions)({ id: player.id }) } -/** @type {(opts: SignerOptions) => Signer} */ +/** @type {(opts: SignerOptions) => import('fast-jwt').SignerSync} */ function getSigner(opts) { let sign = signerByKey.get(opts.key) if (!sign) { diff --git a/apps/server/src/repositories/abstract-repository.js b/apps/server/src/repositories/abstract-repository.js index 0a41f6e9..0929ea38 100644 --- a/apps/server/src/repositories/abstract-repository.js +++ b/apps/server/src/repositories/abstract-repository.js @@ -1,8 +1,4 @@ // @ts-check -/** - * @typedef {import('ioredis').ChainableCommander} Transaction - */ - import { randomUUID } from 'node:crypto' import Redis from 'ioredis' @@ -21,7 +17,7 @@ import { makeLogger } from '../utils/index.js' /** * @template {object} T * @typedef {object} DeleteTransactionContext - * @property {Transaction} transaction - deletion transaction. + * @property {import('ioredis').ChainableCommander} transaction - deletion transaction. * @property {(?T)[]} models - array of deleted record object. * @property {string[]} keys - array of deleted Redis keys. */ @@ -29,7 +25,7 @@ import { makeLogger } from '../utils/index.js' /** * @template {object} T * @typedef {object} SaveTransactionContext - * @property {Transaction} transaction - save transaction. + * @property {import('ioredis').ChainableCommander} transaction - save transaction. * @property {T[]} models - array of saved models. * @property {(?T)[]} existings - array of pre-existing models. * @property {(number | string)[]} indexed - for newly inserted models, their score and id. @@ -38,7 +34,7 @@ import { makeLogger } from '../utils/index.js' /** * @template {object} T * @typedef {object} SaveModelContext - * @property {Transaction} transaction - save transaction. + * @property {import('ioredis').ChainableCommander} transaction - save transaction. * @property {T} model - saved model. * @property {string} key - Redis key for this model. */ @@ -89,7 +85,7 @@ export class AbstractRepository { * Builds the key of a given model of this repository * @protected * @param {string} id - the concerned model id. - * @returns {string} the corresponding key. + * @returns the corresponding key. */ _buildKey(id) { return `${this.name}:${id}` @@ -101,7 +97,6 @@ export class AbstractRepository { * @param {object} args - connection arguments: * @param {string} args.url - url to the Redis Database, including potential authentication. * @param {boolean} [args.isProduction = true] - when false, displays friendly stacktraces, which penalize performances. - * @returns {Promise} */ async connect({ url, isProduction = true }) { if (!this.client) { @@ -138,7 +133,6 @@ export class AbstractRepository { /** * Tears the repository down to release its connection. - * @returns {Promise} */ async release() { this.logger.trace('releasing Redis connection') @@ -177,20 +171,17 @@ export class AbstractRepository { /** * @overload - * @param {(string|undefined)[]} [id] - * @returns {Promise<(?T)[]>} + * Get a single model by id. + * @param {(string|undefined)[]} id - desired ids. + * @returns {Promise<(?T)[]>} matching models, or nulls. */ /** * @overload - * @param {string} [id] - * @returns {Promise} - */ - /** - * Get a single or several model by their id. - * @param {string|(string|undefined)[]} [id] - desired id(s). - * @returns {Promise} matching model(s), or null(s). + * Get a several model by their id. + * @param {undefined|string} id - desired id. + * @returns {Promise} matching model, or null. */ - async getById(id) { + async getById(/** @type {undefined|string|(string|undefined)[]} */ id) { const ids = /** @type {string[]} */ ( (Array.isArray(id) ? id : [id]).filter(Boolean) ) @@ -214,7 +205,7 @@ export class AbstractRepository { * The default implementation hydrates a Redis hash into an JSON object: all its properties are strings. * @protected * @param {string} key - the Redis key. - * @returns {Promise} the corresponding model. + * @returns the corresponding model. */ async _fetchModel(key) { const data = await /** @type {Redis} */ (this.client).hgetall(key) @@ -232,23 +223,24 @@ export class AbstractRepository { /** * @overload - * @param {Partial} data - * @returns {Promise} + * Saves given model to storage. + * It creates new model when needed, and updates existing ones (based on provided id). + * Partial update is supported: incoming data is merged with previous (top level properties only). + * Time complexity if O(2N + 1) + M * O(log(T)), with N saved records, M newly saved records, T total of records. + * @param {Partial} data - single saved (partial) models. + * @returns {Promise} single saved model. */ + /** * @overload - * @param {Partial[]} data - * @returns {Promise} - */ - /** * Saves given model to storage. * It creates new model when needed, and updates existing ones (based on provided id). * Partial update is supported: incoming data is merged with previous (top level properties only). * Time complexity if O(2N + 1) + M * O(log(T)), with N saved records, M newly saved records, T total of records. - * @param {Partial|Partial[]} data - single or array of saved (partial) models. - * @returns {Promise} single or array of saved models. + * @param {Partial[]} data - array of saved (partial) models. + * @returns {Promise} array of saved models. */ - async save(data) { + async save(/** @type {Partial|Partial[]} */ data) { const records = Array.isArray(data) ? data : [data] this.logger.trace({ ctx: { count: records.length } }, 'saving model(s)') const models = [] @@ -314,7 +306,7 @@ export class AbstractRepository { * Allows subclasses to tweak the Redis transaction used to save records. * @protected * @param {SaveTransactionContext} context - contextual information. - * @returns {Transaction|Promise} the applied transaction. + * @returns {import('ioredis').ChainableCommander|Promise} the applied transaction. */ _enrichSaveTransaction({ transaction, indexed }) { if (indexed.length) { @@ -327,21 +319,19 @@ export class AbstractRepository { /** * @overload - * @param {string[]} ids - * @returns {Promise<(?T)[]>} + * Deletes models by their ids. + * Unmatching ids will be simply ignored and null will be returned instead. + * @param {string[]} ids - ids of removed models. + * @returns {Promise<(?T)[]>} array of removed models or nulls. */ /** * @overload - * @param {string} id - * @returns {Promise} - */ - /** * Deletes models by their ids. * Unmatching ids will be simply ignored and null will be returned instead. - * @param {string|string[]} ids - ids of removed models. - * @returns {Promise} single or array of removed models and nulls. + * @param {string} id - id of removed model. + * @returns {Promise} single removed model or null. */ - async deleteById(ids) { + async deleteById(/** @type {string|string[]} */ ids) { const recordIds = Array.isArray(ids) ? ids : [ids] this.logger.trace({ ctx: { ids: recordIds } }, 'deleting model(s)') /** @type {(?T)[]} */ @@ -379,7 +369,7 @@ export class AbstractRepository { * Allows subclasses to tweak the Redis transaction used to delete records. * @protected * @param {DeleteTransactionContext} context - contextual information. - * @returns {Transaction|Promise} the applied transaction. + * @returns {import('ioredis').ChainableCommander|Promise} the applied transaction. */ _enrichDeleteTransaction({ transaction, models }) { const ids = /** @type {string[]} */ ( @@ -392,7 +382,7 @@ export class AbstractRepository { /** * Deserializer for boolean values. * @param {string} value - serialized boolean. - * @returns {boolean} the corresponding boolean. + * @returns the corresponding boolean. */ export function deserializeBoolean(value) { return value === 'true' @@ -401,22 +391,22 @@ export function deserializeBoolean(value) { /** * Deserializer for number values. * @param {string} value - serialized number. - * @returns {number} the corresponding number. + * @returns the corresponding number. */ export const deserializeNumber = parseFloat /** * Deserializer for array values. * @param {string} value - serialized array. - * @returns {string[]} the corresponding array. + * @returns the corresponding array. */ export function deserializeArray(value) { return (value ?? '').split(',').filter(Boolean) } /** - * @param {Transaction} transaction - Transaction to serialize. - * @returns {string} serialized version of the transaction. + * @param {import('ioredis').ChainableCommander} transaction - Transaction to serialize. + * @returns serialized version of the transaction. */ function serializeTransaction(transaction) { const commands = [] diff --git a/apps/server/src/repositories/catalog-items.js b/apps/server/src/repositories/catalog-items.js index 9e9ea1f9..9b07b7a6 100644 --- a/apps/server/src/repositories/catalog-items.js +++ b/apps/server/src/repositories/catalog-items.js @@ -1,8 +1,4 @@ // @ts-check -/** - * @typedef {import('../services/catalog').GameDescriptor} CatalogItem - */ - import { readdir } from 'node:fs/promises' import { pathToFileURL } from 'node:url' @@ -15,9 +11,9 @@ class CatalogItemRepository { */ constructor() { this.name = 'catalog' - /** @type {CatalogItem[]} */ + /** @type {import('@tabulous/types').GameDescriptor[]} */ this.models = [] - /** @type {Map} */ + /** @type {Map} */ this.modelsByName = new Map() this.logger = makeLogger(`${this.name}-repository`, { ctx: { name: this.name } @@ -29,7 +25,6 @@ class CatalogItemRepository { * It reads all available descriptors. * @param {object} args - connection arguments: * @param {string} args.path - folder path containing game descriptors. - * @returns {Promise} * @throws {Error} when provided path is not a readable folder. */ async connect({ path }) { @@ -92,7 +87,7 @@ class CatalogItemRepository { /** * Lists all catalog items. * It complies with Page convention, but always returns all the available items. - * @returns {Promise>} a given page of catalog items. + * @returns {Promise>} a given page of catalog items. */ async list() { this.logger.trace('listing models') @@ -106,20 +101,17 @@ class CatalogItemRepository { /** * @overload - * @param {string} id - * @returns {Promise} + * Get a single model by their id. + * @param {string} id - desired id. + * @returns {Promise} matching model, or null. */ /** * @overload - * @param {string[]} id - * @returns {Promise<(?CatalogItem)[]>} - */ - /** - * Get a single or several model by their id. - * @param {string|string[]} id - desired id(s). - * @returns {Promise} matching model(s), or null(s). + * Get a several models by their ids. + * @param {string[]} id - desired ids. + * @returns {Promise<(?import('@tabulous/types').GameDescriptor)[]>} matching models, or nulls. */ - async getById(id) { + async getById(/** @type {string|string[]} */ id) { const ids = Array.isArray(id) ? id : [id] const results = [] for (const id of ids) { diff --git a/apps/server/src/repositories/games.js b/apps/server/src/repositories/games.js index 4169dc87..74ed22c1 100644 --- a/apps/server/src/repositories/games.js +++ b/apps/server/src/repositories/games.js @@ -1,18 +1,11 @@ // @ts-check -/** - * @typedef {import('../services/games').GameData} Game - * @typedef {import('./abstract-repository').SaveTransactionContext} SaveTransactionContext - * @typedef {import('./abstract-repository').DeleteTransactionContext} DeleteTransactionContext - * @typedef {SaveTransactionContext['transaction']} Transaction - */ - import { AbstractRepository, deserializeArray, deserializeNumber } from './abstract-repository.js' -/** @extends AbstractRepository */ +/** @extends AbstractRepository */ class GameRepository extends AbstractRepository { static fields = [ { name: 'created', deserialize: deserializeNumber }, @@ -33,7 +26,7 @@ class GameRepository extends AbstractRepository { * Fetches game from a Redis Hash. * @override * @param {string} key - the Redis key. - * @returns {Promise} the corresponding model. + * @returns the corresponding model. */ async _fetchModel(key) { const data = await super._fetchModel(key) @@ -47,7 +40,7 @@ class GameRepository extends AbstractRepository { /** * Saves player as Redis Hash. * @override - * @param {import('./abstract-repository').SaveModelContext} context - the save operation context. + * @param {import('./abstract-repository').SaveModelContext} context - the save operation context. */ _saveModel({ transaction, model, key }) { const { id, created, playerIds, guestIds, ownerId, ...otherFields } = model @@ -64,7 +57,7 @@ class GameRepository extends AbstractRepository { /** * Builds the key of the set holding all game ids of a given player. * @param {string} playerId - the concerned player id. - * @returns {string} the corresponding key. + * @returns the corresponding key. */ _buildPlayerKey(playerId) { return `index:${this.name}:players:${playerId}` @@ -73,10 +66,10 @@ class GameRepository extends AbstractRepository { /** * When saving games, updates guests' and players' respecive player sets of games. * @override - * @param {SaveTransactionContext} context - contextual information. + * @param {import('./abstract-repository').SaveTransactionContext} context - contextual information. */ _enrichSaveTransaction(context) { - const transaction = /** @type {Transaction} */ ( + const transaction = /** @type {import('ioredis').ChainableCommander} */ ( super._enrichSaveTransaction(context) ) removeGamesFromPlayerSets.call(this, transaction, context.existings) @@ -97,10 +90,10 @@ class GameRepository extends AbstractRepository { /** * When deleting games, removes them from player sets of games. * @override - * @param {DeleteTransactionContext} context - contextual information. + * @param {import('./abstract-repository').DeleteTransactionContext} context - contextual information. */ _enrichDeleteTransaction(context) { - const transaction = /** @type {Transaction} */ ( + const transaction = /** @type {import('ioredis').ChainableCommander} */ ( super._enrichDeleteTransaction(context) ) removeGamesFromPlayerSets.call(this, transaction, context.models) @@ -110,7 +103,7 @@ class GameRepository extends AbstractRepository { /** * Lists all games of a given player. * @param {string} playerId - id of the player for which games are returned. - * @returns {Promise} this player's games. + * @returns this player's games. */ async listByPlayerId(playerId) { const ctx = { playerId } @@ -119,9 +112,9 @@ class GameRepository extends AbstractRepository { return [] } const ids = await this.client.smembers(this._buildPlayerKey(playerId)) - const games = /** @type {Game[]} */ (await this.getById(ids.sort())).filter( - Boolean - ) + const games = /** @type {import('@tabulous/types').GameData[]} */ ( + await this.getById(ids.sort()) + ).filter(Boolean) this.logger.debug( { ctx, res: games.map(({ kind, id }) => ({ id, kind })) }, 'listed games by player' @@ -132,8 +125,8 @@ class GameRepository extends AbstractRepository { /** * @this {GameRepository} - * @param {DeleteTransactionContext['transaction']} transaction - * @param {DeleteTransactionContext['models']} models + * @param {import('./abstract-repository').DeleteTransactionContext['transaction']} transaction + * @param {import('./abstract-repository').DeleteTransactionContext['models']} models */ function removeGamesFromPlayerSets(transaction, models) { for (const game of models) { diff --git a/apps/server/src/repositories/index.js b/apps/server/src/repositories/index.js index 5ded95a1..0a6d8d36 100644 --- a/apps/server/src/repositories/index.js +++ b/apps/server/src/repositories/index.js @@ -1,9 +1,5 @@ // @ts-check -import * as abstractRepository from './abstract-repository.js' -import * as catalogItems from './catalog-items.js' -import * as games from './games.js' -import * as players from './players.js' - -// because ESM modules can't be easily mocked, exports an object that can be monkey-patched -// it prevents from destructuring imported object -export default { ...abstractRepository, ...games, ...players, ...catalogItems } +export * from './abstract-repository.js' +export * from './catalog-items.js' +export * from './games.js' +export * from './players.js' diff --git a/apps/server/src/repositories/players.js b/apps/server/src/repositories/players.js index d539b088..957133bc 100644 --- a/apps/server/src/repositories/players.js +++ b/apps/server/src/repositories/players.js @@ -1,13 +1,4 @@ // @ts-check -/** - * @typedef {import('ioredis').Redis} Redis - * @typedef {import('./abstract-repository').DeleteTransactionContext} DeleteTransactionContext - * @typedef {import('./abstract-repository').SaveTransactionContext} SaveTransactionContext - * @typedef {import('./abstract-repository').Transaction} Transaction - * @typedef {import('../services/players').Player} Player - * @typedef {import('../utils/logger').Logger} Logger - */ - import { count, create, @@ -51,7 +42,7 @@ export const FriendshipEnded = 4 * @property {number} state - state of the relationship. */ -/** @extends AbstractRepository */ +/** @extends AbstractRepository */ class PlayerRepository extends AbstractRepository { static fields = [ // enforce no game id to be null @@ -81,7 +72,6 @@ class PlayerRepository extends AbstractRepository { * In addition to connecting to the Database, also connects to the search index. * @override * @param {{ url: string, isProduction?: boolean }} args - connection arguments. - * @returns {Promise} */ async connect(args) { await super.connect(args) @@ -91,7 +81,6 @@ class PlayerRepository extends AbstractRepository { /** * In addition to disconnecting from the Database, also disconnect the search index. * @override - * @returns {Promise} */ async release() { await super.release() @@ -100,8 +89,8 @@ class PlayerRepository extends AbstractRepository { /** * Builds the key of the string holding player id for a given provider id. - * @param {Pick} details - the desired provider details. - * @returns {string} the corresponding key. + * @param {Pick} details - the desired provider details. + * @returns the corresponding key. */ _buildProviderIdKey({ provider, providerId }) { return `index:${this.name}:providers:${provider}:${providerId}` @@ -111,7 +100,7 @@ class PlayerRepository extends AbstractRepository { * Builds the key a player's friends list. * @protected * @param {string} id - the concerned model id. - * @returns {string} the corresponding key. + * @returns the corresponding key. */ _buildFriendsKey(id) { return `friends:${id}` @@ -120,11 +109,10 @@ class PlayerRepository extends AbstractRepository { /** * When saving players, add their provider id references, and updates their username for autocompletion. * @override - * @param {SaveTransactionContext} context - contextual information. - * @returns {Promise} + * @param {import('./abstract-repository').SaveTransactionContext} context - contextual information. */ async _enrichSaveTransaction(context) { - const transaction = /** @type {Transaction} */ ( + const transaction = /** @type {import('ioredis').ChainableCommander} */ ( super._enrichSaveTransaction(context) ) const { models, existings } = context @@ -148,10 +136,10 @@ class PlayerRepository extends AbstractRepository { /** * When deleting players, removes their provider id references and username for autocompletion. * @override - * @param {DeleteTransactionContext} context - contextual information. + * @param {import('./abstract-repository').DeleteTransactionContext} context - contextual information. */ async _enrichDeleteTransaction(context) { - const transaction = /** @type {Transaction} */ ( + const transaction = /** @type {import('ioredis').ChainableCommander} */ ( super._enrichDeleteTransaction(context) ) const references = /** @type {string[]} */ ( @@ -172,11 +160,9 @@ class PlayerRepository extends AbstractRepository { await removeFromIndex(this.searchIndex, context.models, this.logger) for (const player of context.models) { if (player) { - for (const id of await /** @type {Redis} */ (this.client).zrange( - this._buildFriendsKey(player.id), - 0, - -1 - )) { + for (const id of await /** @type {import('ioredis').Redis} */ ( + this.client + ).zrange(this._buildFriendsKey(player.id), 0, -1)) { transaction.zrem(this._buildFriendsKey(id), player.id) } } @@ -186,8 +172,8 @@ class PlayerRepository extends AbstractRepository { /** * Finds a player by their provider and providerId details. - * @param {Pick} details - the desired provider details. - * @returns {Promise} the corresponding player or null. + * @param {Pick} details - the desired provider details. + * @returns the corresponding player or null. */ async getByProviderDetails(details) { if (!this.client) { @@ -205,12 +191,12 @@ class PlayerRepository extends AbstractRepository { * @param {number} [args.from = 0] - 0-based index of the first result * @param {number} [args.size = 10] - maximum number of models returned after first results. * @param {boolean} [args.exact = false] - for exact search (un-searchable players can be retrieved). - * @returns {Promise>} a given page of matching players. + * @returns {Promise>} a given page of matching players. */ async searchByUsername({ search: term, from = 0, size = 10, exact = false }) { const ctx = { search, from, size, exact } this.logger.trace({ ctx }, 'finding players') - /** @type {Player[]} */ + /** @type {import('@tabulous/types').Player[]} */ let results = [] let total = 0 if (this.searchIndex) { @@ -231,7 +217,7 @@ class PlayerRepository extends AbstractRepository { count = hits.length } total = count - results = /** @type {Player[]} */ ( + results = /** @type {import('@tabulous/types').Player[]} */ ( await this.getById(hits.map(({ id }) => id)) ) } @@ -250,7 +236,6 @@ class PlayerRepository extends AbstractRepository { /** * Resets search index to match models in database - * @returns {Promise} */ async reindexModels() { this.logger.trace('re-indexing all models') @@ -292,7 +277,7 @@ class PlayerRepository extends AbstractRepository { * @param {string} requestingId - if of the requesting player. * @param {string} targetedId - if of the targeted player * @param {number} [state = Friendship] - state of the current relationship. - * @returns {Promise} true if the relationship was recorded. + * @returns true if the relationship was recorded. */ async makeFriends(requestingId, targetedId, state = FriendshipRequested) { const ctx = { requestingId, targetedId, state } @@ -303,7 +288,9 @@ class PlayerRepository extends AbstractRepository { ) { return false } - const transaction = /** @type {Redis} */ (this.client).multi() + const transaction = /** @type {import('ioredis').Redis} */ ( + this.client + ).multi() const targetedKey = this._buildFriendsKey(targetedId) const requestingKey = this._buildFriendsKey(requestingId) if (state === FriendshipEnded) { @@ -336,7 +323,7 @@ class PlayerRepository extends AbstractRepository { /** * List friendship relationships with other players. * @param {string} playerId - player for which friends are being fetched. - * @returns {Promise} list of relationships. + * @returns list of relationships. */ async listFriendships(playerId) { this.logger.trace({ ctx: { playerId } }, 'listing friendships') @@ -344,6 +331,7 @@ class PlayerRepository extends AbstractRepository { if (!this.client) { return [] } + /** @type {Friendship[]} */ const friendships = ( await this.client.zrevrange( this._buildFriendsKey(playerId), @@ -377,30 +365,31 @@ export const players = new PlayerRepository() /** * @param {SearchIndex} searchIndex - * @param {Player[]} models - * @param {Logger} logger - * @returns {Promise} + * @param {import('@tabulous/types').Player[]} models + * @param {import('pino').Logger} logger */ async function insertIntoIndex(searchIndex, models, logger) { if (searchIndex) { const ctx = { insertedIds: models.map(({ id }) => id) } logger.trace({ ctx }, 'inserting documents into search index') - const inserted = await insertMultiple(searchIndex, models) + const inserted = await insertMultiple( + searchIndex, + /** @type {Record[]} */ (models) + ) logger.debug({ ctx, res: inserted }, 'inserted documents into search index') } } /** * @param {SearchIndex} searchIndex - * @param {(?Player)[]} models - * @param {Logger} logger - * @returns {Promise} + * @param {(?import('@tabulous/types').Player)[]} models + * @param {import('pino').Logger} logger */ async function removeFromIndex(searchIndex, models, logger) { if (searchIndex) { - const removedIds = /** @type {Player[]} */ (models.filter(Boolean)).map( - ({ id }) => id - ) + const removedIds = /** @type {import('@tabulous/types').Player[]} */ ( + models.filter(Boolean) + ).map(({ id }) => id) if (removedIds.length) { const ctx = { removedIds } logger.trace({ ctx }, 'removing documents from search index') diff --git a/apps/server/src/server.js b/apps/server/src/server.js index ed9052a1..1cd9b893 100644 --- a/apps/server/src/server.js +++ b/apps/server/src/server.js @@ -1,22 +1,18 @@ // @ts-check -/** - * @typedef {import('./services/configuration').Configuration} Configuration - */ - import fastify from 'fastify' -import repositories from './repositories/index.js' +import * as repositories from './repositories/index.js' import { createLogContext } from './utils/index.js' -/** @typedef {import('fastify').FastifyInstance & { conf: Configuration }} Server */ +/** @typedef {import('fastify').FastifyInstance & { conf: import('./services/configuration').Configuration }} Server */ /** * Starts Tabulous server, using provided configuration. * Server has graphQL endpoints registered, and can serve static files. * Its configuration object is available as a decorator: the `conf` property. * It connects all repositories. - * @param {Configuration} config - server options - * @returns {Promise} configured and started server. + * @param {import('./services/configuration').Configuration} config - server options + * @returns configured and started server. */ export async function startServer(config) { const app = fastify({ @@ -41,6 +37,6 @@ export async function startServer(config) { await app.listen(config.serverUrl) app.log.info({ res: app.server.address() }, 'started server') - // @ts-expect-error: Property 'conf' is missing - return app + // @ts-expect-error -- TS does not recognize fastify decorators + return /** @type {Server} */ (app) } diff --git a/apps/server/src/services/auth/github.js b/apps/server/src/services/auth/github.js index 2f00cdbd..d26a9d10 100644 --- a/apps/server/src/services/auth/github.js +++ b/apps/server/src/services/auth/github.js @@ -63,13 +63,11 @@ class GithubAuthProvider extends OAuth2Provider { export const githubAuth = new GithubAuthProvider('github') -/** - * @param {string} id - * @param {string} secret - * @param {string} code - * @returns {Promise} - */ -async function fetchToken(id, secret, code) { +async function fetchToken( + /** @type {string} */ id, + /** @type {string} */ secret, + /** @type {string} */ code +) { const response = await fetch(urls.token, { method: 'POST', headers: { @@ -89,11 +87,7 @@ async function fetchToken(id, secret, code) { .access_token } -/** - * @param {string} token - * @returns {Promise} - */ -async function fetchUser(token) { +async function fetchUser(/** @type {string} */ token) { const response = await fetch(urls.user, { headers: { Accept: 'application/json', @@ -103,19 +97,17 @@ async function fetchUser(token) { if (!response.ok) { throw new Error('forbidden') } - return response.json() + return /** @type {Promise} */ (response.json()) } -/** - * @param {GithubUser} user - * @returns {Partial}> - */ -function mapToUserDetails({ - id: providerId, - login: username, - avatar_url: avatar, - email, - name -}) { +function mapToUserDetails( + /** @type {GithubUser} */ { + id: providerId, + login: username, + avatar_url: avatar, + email, + name + } +) { return { username, avatar, email, providerId, fullName: name || username } } diff --git a/apps/server/src/services/auth/google.js b/apps/server/src/services/auth/google.js index 2ceff51f..2a72a180 100644 --- a/apps/server/src/services/auth/google.js +++ b/apps/server/src/services/auth/google.js @@ -67,12 +67,10 @@ class GoogleAuthProvider extends OAuth2Provider { export const googleAuth = new GoogleAuthProvider('google') -/** - * @param {string} code - * @param {GoogleAuthProvider} provider - * @returns {Promise} - */ -async function fetchUserData(code, { id, secret, redirect }) { +async function fetchUserData( + /** @type {string} */ code, + /** @type {GoogleAuthProvider} */ { id, secret, redirect } +) { const body = new FormData() body.append('code', code) body.append('client_id', id) @@ -86,21 +84,21 @@ async function fetchUserData(code, { id, secret, redirect }) { if (!response.ok) { throw new Error('forbidden') } - return decodeJwt( - /** @type {{ id_token: string }} */ (await response.json()).id_token + return /** @type {GoogleUser} */ ( + decodeJwt( + /** @type {{ id_token: string }} */ (await response.json()).id_token + ) ) } -/** - * @param {GoogleUser} user - * @returns {Partial}> - */ -function mapToUserDetails({ - sub: providerId, - given_name: username, - picture: avatar, - email, - name -}) { +function mapToUserDetails( + /** @type {GoogleUser} */ { + sub: providerId, + given_name: username, + picture: avatar, + email, + name + } +) { return { username, avatar, email, providerId, fullName: name || username } } diff --git a/apps/server/src/services/auth/oauth2.js b/apps/server/src/services/auth/oauth2.js index da2293ba..5755cc27 100644 --- a/apps/server/src/services/auth/oauth2.js +++ b/apps/server/src/services/auth/oauth2.js @@ -7,7 +7,7 @@ import { makeLogger } from '../../utils/index.js' /** * @typedef {object} UserDetailsAndLocation - * @property {Partial} user - user details from the provider + * @property {Partial} user - user details from the provider * @property {string} location - final desired redirection url */ @@ -49,7 +49,7 @@ export class OAuth2Provider { * Store final location for 1h and return a unique key that can be used as CSRF token. * Intended for subclasses. * @param {string} [location='/'] - the stored location - * @returns {string} key to access this location + * @returns key to access this location */ storeFinalLocation(location = '/') { const key = randomUUID() diff --git a/apps/server/src/services/catalog.js b/apps/server/src/services/catalog.js index b3c66650..43b04002 100644 --- a/apps/server/src/services/catalog.js +++ b/apps/server/src/services/catalog.js @@ -1,112 +1,13 @@ // @ts-check -/** - * @typedef {import('./players').Player} Player - * @typedef {import('./games').StartedGameData} GameData - * @typedef {import('../utils/games').GameSetup} GameSetup - */ -/** - * @template T - * @typedef {import('./games').Schema} Schema - */ - -import repositories from '../repositories/index.js' +import * as repositories from '../repositories/index.js' import { makeLogger } from '../utils/index.js' -/** @typedef {() => GameSetup | Promise} Build */ - -/** - * @template Parameters - * @typedef {(game: GameData, guest: Player, parameters: Parameters) => GameData | Promise} AddPlayer - */ - -/** - * @template Parameters - * @typedef {(args: { game: GameData; player: Player }) => ?Schema | Promise>} AskForParameters - */ - -/** - * @typedef {object} GameDescriptor a catalog item - * @property {string} name - item unique name. - * @property {ItemLocales} locales - all the localized data fort his item. - * @property {number} [minSeats] - minimum seats required to play, when relevant. - * @property {number} [maxSeats] - maximum seats allowed, when relevant. - * @property {number} [minAge] - minimum age suggested. - * @property {number} [maxAge] - maximum age suggested. - * @property {number} [minTime] - minimum time observed. - * @property {Copyright} [copyright] - copyright data, meaning this item has restricted access. - * @property {number} [rulesBookPageCount] - number of pages in the rules book, if any. - * @property {ZoomSpec} [zoomSpec] - zoom specifications for main and hand scene. - * @property {TableSpec} [tableSpec] - table specifications to customize visual. - * @property {ColorSpec} [colors] - allowed colors for players and UI. - * @property {ActionSpec} [actions] - action customizations. - * @property {Build} [build] - function invoked build initial game. - * @property {AddPlayer>} [addPlayer] - function invoked when a player joins a game for the first time. - * @property {AskForParameters>} [askForParameters] - function invoked to generate a joining player's parameters. - */ - -/** - * @typedef {object} _ItemLocales - * @property {ItemLocale} [fr] - French locale - * @property {ItemLocale} [en] - English locale - * - * @typedef {Record & _ItemLocales} ItemLocales All the localized data for a catalog item. - */ - -/** - * @typedef {object} ItemLocale Localized data - * @property {string} title - catalog item title. - */ - -/** - * @typedef {object} PersonOrCompany a game author, designer or publisher - * @property {string} name - this person/company's name - */ - -/** - * @typedef {object} Copyright game copyright data - * @property {PersonOrCompany[]} authors - game authors. - * @property {PersonOrCompany[]} [designers] - game designers. - * @property {PersonOrCompany[]} [publishers] - game publishers. - */ - -/** - * @typedef {object} ZoomSpec zoom specifications for main and hand scene. - * @property {number} [min] - minimum zoom level allowed on the main scene. - * @property {number} [max] - maximum zoom level allowed on the main scene. - * @property {number} [hand] - fixed zoom level for the hand scene. - */ - -/** - * @typedef {object} TableSpec table specifications for customization. - * @property {number} [width] - minimum zoom level allowed on the main scene. - * @property {number} [height] - maximum zoom level allowed on the main scene. - * @property {string} [texture] - texture image file path, or hex color. - */ - -/** - * @typedef {object} ColorSpec players and UI color customization. - * @property {string} [base] - base hex color. - * @property {string} [primary] - primary hex color. - * @property {string} [secondary] - secondary hex color. - * @property {string[]} [players] - list of possible colors for players. - */ - -/** - * @typedef {'decrement'|'detail'|'draw'|'flip'|'flipAll'|'increment'|'play'|'pop'|'push'|'random'|'reorder'|'rotate'|'setFace'|'snap'|'toggleLock'|'unsnap'|'unsnapAll'} ActionName - */ - -/** - * @typedef {object} ActionSpec action buttons configuration. - * @property {ActionName[]} [button1] - actions assigned to tab/left click, if any. - * @property {ActionName[]} [button2] - actions assigned to long 2 fingers tap/long left click, if any - */ - const logger = makeLogger('catalog-service') /** * Computes a given player's catalog, only including free games and restricted games they can access. - * @param {?Player} player - related player. - * @returns {Promise} the full list of catalog items for this player. + * @param {?import('@tabulous/types').Player} player - related player. + * @returns the full list of catalog items for this player. */ export async function listCatalog(player) { const ctx = { playerId: player?.id } @@ -123,9 +24,9 @@ export async function listCatalog(player) { /** * Indicates whether a given player can access the provided catalog item. - * @param {?Player} player - related player. - * @param {GameDescriptor} item - the checked catalog item. - * @returns {boolean} true when the item is publicly available or if this player was granted access. + * @param {?import('@tabulous/types').Player} player - related player. + * @param {import('@tabulous/types').GameDescriptor} item - the checked catalog item. + * @returns true when the item is publicly available or if this player was granted access. */ export function canAccess(player, item) { const accessGranted = item.copyright @@ -143,7 +44,7 @@ export function canAccess(player, item) { * Does nothing when player or item is unknown, or if item is not copyrighted * @param {string} playerId - id of the related player. * @param {string} itemName - catalog item name this player will have access to. - * @returns {Promise} saved player, or null + * @returns saved player, or null */ export async function grantAccess(playerId, itemName) { const ctx = { playerId, itemName } @@ -170,7 +71,7 @@ export async function grantAccess(playerId, itemName) { * Does nothing when player or item is unknown, or if item is not copyrighted * @param {string} playerId - id of the related player. * @param {string} itemName - catalog item name this player will lost access to. - * @returns {Promise} saved player, or null + * @returns saved player, or null */ export async function revokeAccess(playerId, itemName) { const ctx = { playerId, itemName } diff --git a/apps/server/src/services/configuration.js b/apps/server/src/services/configuration.js index 4d296173..8e2e1408 100644 --- a/apps/server/src/services/configuration.js +++ b/apps/server/src/services/configuration.js @@ -1,12 +1,4 @@ // @ts-check -/** - * @typedef {import('../plugins/auth').AuthOptions} AuthOptions - * @typedef {import('../plugins/graphql').GraphQLOptions} GraphQLOptions - * @typedef {import('../plugins/static').StaticOptions} StaticOptions - * @typedef {import('../plugins/cors').CorsOptions} CorsOptions - * @typedef {import('../utils/logger').Level} Level - */ - import { isAbsolute, join } from 'node:path' import { cwd } from 'node:process' @@ -21,7 +13,7 @@ import { makeLogger } from '../utils/index.js' /** * @typedef {object} LoggerOptions - * @property {Level} level - level used for logging. + * @property {import('pino').LevelWithSilent} level - level used for logging. */ /** @@ -38,11 +30,11 @@ import { makeLogger } from '../utils/index.js' * @property {LoggerOptions} logger - Pino logger options, including: * @property {DataOptions} data - configuration to connect to the database: * @property {GamesOpptions} games - game engine properties, including; - * @property {AuthOptions} auth - options for the authentication plugin. + * @property {import('@src/plugins/auth').Options} auth - options for the authentication plugin. * @property {object} plugins - options for all plugin used: - * @property {GraphQLOptions} plugins.graphql - options for the GraphQL plugin. - * @property {StaticOptions} plugins.static - options for the static files plugin. - * @property {CorsOptions} plugins.cors - options for the CORS plugin. + * @property {import('@src/plugins/graphql').Options} plugins.graphql - options for the GraphQL plugin. + * @property {import('@src/plugins/static').Options} plugins.static - options for the static files plugin. + * @property {import('@src/plugins/cors').Options} plugins.cors - options for the CORS plugin. * @property {{secret: string}} turn - configuratino for the TURN server. * @see {@link https://nodejs.org/docs/latest-v16.x/api/net.html#net_server_listen_options_callback} * @see {@link https://nodejs.org/docs/latest-v16.x/api/tls.html#tls_tls_createsecurecontext_options} @@ -134,11 +126,7 @@ const validate = new Ajv({ allErrors: true }).compile({ } }) -/** - * @param {string} path - * @returns {string} - */ -function makeAbsolute(path) { +function makeAbsolute(/** @type {string} */ path) { return isAbsolute(path) ? path : join(cwd(), path) } @@ -161,7 +149,7 @@ function makeAbsolute(path) { * - GOOGLE_ID: Optional Google OAuth application ID used to identify players. * - GOOGLE_SECRET: Optional Google OAuth application secret used to identify players. * - * @returns {Configuration} the loaded configuration. + * @returns the loaded configuration. * @throws {Error} when the provided environment variables do not match expected values. */ export function loadConfiguration() { @@ -184,7 +172,7 @@ export function loadConfiguration() { TURN_SECRET } = process.env - // @ts-expect-error: NODE_ENV: Argument of type 'string | undefined' is not assignable to parameter of type 'string' + // @ts-expect-error -- NODE_ENV: Argument of type 'string | undefined' is not assignable to parameter of type 'string' const isProduction = /^\w*production\w*$/i.test(NODE_ENV) const allowedOrigins = ALLOWED_ORIGINS_REGEXP ?? @@ -200,14 +188,14 @@ export function loadConfiguration() { port: PORT ? Number(PORT) : 3001 }, logger: { - // @ts-expect-error: Type 'string' is not assignable to type 'Level' + // @ts-expect-error -- Type 'string' is not assignable to type 'Level' level: LOG_LEVEL ?? 'debug' }, plugins: { graphql: { graphiql: !isProduction, allowedOrigins, - // @ts-expect-error: Type 'string | undefined' is not assignable to type 'string' + // @ts-expect-error -- Type 'string | undefined' is not assignable to type 'string' pubsubUrl: PUBSUB_URL ?? (isProduction ? undefined : 'redis://127.0.0.1:6379') }, @@ -223,16 +211,16 @@ export function loadConfiguration() { path: GAMES_PATH ?? join('..', 'games') }, data: { - // @ts-expect-error: Type 'string | undefined' is not assignable to type 'string' + // @ts-expect-error -- Type 'string | undefined' is not assignable to type 'string' url: REDIS_URL ?? (isProduction ? undefined : 'redis://127.0.0.1:6379') }, turn: { - // @ts-expect-error: Type 'string | undefined' is not assignable to type 'string' + // @ts-expect-error -- Type 'string | undefined' is not assignable to type 'string' secret: TURN_SECRET }, auth: { jwt: { - // @ts-expect-error: Type 'string | undefined' is not assignable to type 'string' + // @ts-expect-error -- Type 'string | undefined' is not assignable to type 'string' key: JWT_KEY ?? (isProduction ? undefined : 'dummy-test-key') }, domain: @@ -259,7 +247,7 @@ export function loadConfiguration() { 'configuration is invalid: please check your environment variables' ) throw new Error( - // @ts-expect-error: 'validate.errors' is possibly 'null' or 'undefined' + // @ts-expect-error -- 'validate.errors' is possibly 'null' or 'undefined' validate.errors.reduce( (message, error) => `${message}\n${error.instancePath} ${error.message}`, diff --git a/apps/server/src/services/games.js b/apps/server/src/services/games.js index 4efb5b15..f98c57a5 100644 --- a/apps/server/src/services/games.js +++ b/apps/server/src/services/games.js @@ -1,267 +1,20 @@ // @ts-check -/** - * @typedef {import('./players').Player} Player - * @typedef {import('./catalog').ActionName} ActionName - * @typedef {import('./catalog').GameDescriptor} GameDescriptor - * @typedef {import('../repositories/players').Friendship} Friendship - */ - +import { + createMeshes, + enrichAssets, + pickRandom, + reportReusedIds +} from '@tabulous/game-utils' import { concatMap, mergeMap, Subject } from 'rxjs' -import repositories from '../repositories/index.js' +import * as repositories from '../repositories/index.js' import { FriendshipAccepted, FriendshipProposed } from '../repositories/players.js' -import { - ajv, - createMeshes, - enrichAssets, - getParameterSchema, - makeLogger, - pickRandom, - reportReusedIds -} from '../utils/index.js' +import { ajv, getParameterSchema, makeLogger } from '../utils/index.js' import { canAccess } from './catalog.js' -/** - * @typedef {object} Game an active game, or a lobby - * @property {string} id - unique game id. - * @property {number} created - game creation timestamp. - * @property {string} ownerId - id of the player who created this game - * @property {string} [kind] - game kind (relates with game descriptor). Unset means a waiting room. - * @property {string[]} playerIds - (active) player ids. - * @property {string[]} guestIds - guest (future player) ids. - */ - -/** - * @typedef {object} _GameData - * @property {number} availableSeats - number of seats still available. - * @property {Mesh[]} meshes - game meshes. - * @property {Message[]} messages - game discussion thread, if any. - * @property {CameraPosition[]} cameras - player's saved camera positions, if any. - * @property {Hand[]} hands - player's private hands, id any. - * @property {PlayerPreference[]} preferences - preferences for each players. - * @property {HistoryRecord[]} history - player actions and move history. - */ -/** @typedef {Game & GameDescriptor & _GameData} GameData */ -/** @typedef {GameData & Required>} StartedGameData */ - -/** - * @typedef {'box'|'card'|'custom'|'die'|'prism'|'roundedTile'|'roundToken'} Shape - */ - -/** - * @typedef {object} Point - * @property {number} [x] - 3D coordinate along the X axis (horizontal). - * @property {number} [z] - 3D coordinate along the Z axis (vertical). - * @property {number} [y] - 3D coordinate along the Y axis (altitude). - */ - -/** - * @typedef {object} Dimension - * @property {number} [width] - mesh's width (X axis), for boxes, cards, prisms, and rounded tiles. - * @property {number} [height] - mesh's height (Y axis), for boxes, cards, prisms, rounded tokens and rounded tiles. - * @property {number} [depth] - mesh's depth (Z axis), for boxes, cards, and rounded tiles. - * @property {number} [diameter] - mesh's diameter (X+Z axis), for round tokens and dice. - */ - -/** - * @typedef {object} _Mesh a 3D mesh, with a given shape. Some of its attribute are shape-specific: - * @property {Shape} shape - the mesh shape. - * @property {string} id - mesh unique id. - * @property {string} texture - path to its texture file or hex color. - * @property {number[][]} [faceUV] - list of face UV (Vector4 components), to map texture on the mesh (depends on its shape). - * @property {InitialTransform} [transform] - initial transformation baked into the mesh's vertices. - * @property {number} [borderRadius] - corner radius, for rounded tiles. - * @property {string} [file] - path to the custom mesh OBJ file. - * @property {number} [edges] - number of edges, for prisms. - * @property {number} [faces] - number of faces, for dice. - * @property {DetailableState} [detailable] - if this mesh could be detailed, contains details. - * @property {MovableState} [movable] - if this mesh could be moved, contains move state. - * @property {FlippableState} [flippable] - if this mesh could be flipped, contains flip state. - * @property {RotableState} [rotable] - if this mesh could be rotated along Y axis, contains rotation state. - * @property {AnchorableState} [anchorable] - if this mesh has anchors, contains their state. - * @property {StackableState} [stackable] - if this mesh could be stack under others, contains stack state. - * @property {DrawableState} [drawable] - if this mesh could be drawn in player hand, contains coonfiguration. - * @property {LockableState} [lockable] - if this mesh could be locked, contains (un)locke state. - * @property {QuantifiableState} [quantifiable] - if instances of this mesh could grouped together and split, contains quantity state. - * @property {RandomizableState} [randomizable] - if this mesh could be randomized, contains face state. - */ -/** @typedef {_Mesh & Point & Dimension} Mesh */ - -/** - * @typedef {object} Targetable common properties for targets (stacks, anchors, quantifiable...) - * @property {string[]} [kinds] - acceptable meshe kinds, that could be snapped to the anchor. Leave undefined to accept all. - * @property {number} [extent=2] - dimension multiplier applied to the drop target. - * @property {number} [priority=0] - priority applied when multiple targets with same altitude apply. - * @property {boolean} [enabled=true] - whether this anchor is enabled or not. - */ - -/** - * @typedef {object} InitialTransform - * @property {number} [yaw=0] - rotation along the Y axis. - * @property {number} [pitch=0] - rotation along the X axis. - * @property {number} [roll=0] - rotation along the Z axis. - * @property {number} [scaleX=1] - scale applied along the X axis. - * @property {number} [scaleY=1] - scale applied along the Y axis. - * @property {number} [scaleZ=1] - scale applied along the Z axis. - */ - -/** - * @typedef {object} DetailableState state for detailable meshes: - * @property {string} frontImage - path to its front image. - * @property {string} [backImage] - path to its back image, when relevant. - */ - -/** - * @typedef {object} MovableState state for movable meshes: - * @property {number} [duration=100] - move animation duration, in milliseconds. - * @property {number} [snapDistance=0.25] - distance between dots of an imaginary snap grid. - * @property {string} [kind] - kind used when dragging and droping the mesh over targets. - * @property {Point[]} [partCenters] - when this mesh has serveral parts, coordinate of each part barycenter. - */ - -/** - * @typedef {object} FlippableState state for flippable meshes: - * @property {boolean} [isFlipped=false] - true means the back face is visible. - * @property {number} [duration=500] - flip animation duration, in milliseconds. - */ - -/** - * @typedef {object} RotableState state for flippable meshes: - * @property {number} [angle=0] - rotation angle along Y axis (yaw), in radian. - * @property {number} [duration=200] - rotation animation duration, in milliseconds. - */ - -/** - * @typedef {object} _StackableState state for stackable meshes: - * @property {string[]} [stackIds=[]] - ordered list of ids for meshes stacked on top of this one. - * @property {number} [duration=100] - stack animations duration, in milliseconds. - * @property {number} [angle] - angle applied to any rotable mesh pushed to the stack. - */ -/** @typedef {_StackableState & Targetable} StackableState */ - -/** - * @typedef {object} AnchorableState state for anchorable meshes: - * @property {Anchor[]} [anchors] - list of anchors. - * @property {number} [duration=100] - snap animation duration, in milliseconds. - */ - -/** - * @typedef {object} _Anchor a rectangular anchor definition (coordinates are relative to the parent mesh): - * @property {string} id - this anchor id. - * @property {?string} [snappedId] - id of the mesh currently snapped to this anchor. - * @property {string} [playerId] - when set, only this player can snap meshes to this anchor. - * @property {number} [angle] - angle applied to any rotable mesh snapped to the anchor. - * @property {boolean} [flip] - flip state applied to any flippable mesh snapped to the anchor. - * @property {boolean} [ignoreParts=false] - when set, and when snapping a multi-part mesh, takes it barycenter into account. - */ -/** @typedef {_Anchor & Point & Dimension & Targetable} Anchor */ - -/** - * @typedef {object} DrawableState state for drawable meshes: - * @property {boolean} [unflipOnPick=true] - unflip flipped mesh when picking them in hand. - * @property {boolean} [flipOnPlay=false] - flip flipable meshes when playing them from hand. - * @property {number} [angleOnPick=0] - set angle of rotable meshes when picking them in hand. - * @property {number} [duration=750] - duration (in milliseconds) of the draw animation. - */ - -/** - * @typedef {object} LockableState state for locable mehes: - * @property {boolean} [isLocked=false] - whether this mesh is locked or not. - */ - -/** - * @typedef {object} _QuantifiableState behavior persistent state, including: - * @property {number} [quantity=1] - number of items, including this one. - * @property {number} [duration=100] - duration (in milliseconds) when pushing individual meshes. - */ -/** @typedef {Targetable & _QuantifiableState} QuantifiableState */ - -/** - * @typedef {object} RandomizableState - * @property {number} [face=1] - current face set. - * @property {number} [duration=600] - duration (in milliseconds) of the random animation. The set animartion is a third of it. - * @property {boolean} [canBeSet=false] - whether face could be manually set or not. -} - */ -/** - * @typedef {object} Message a message in the discussion thread. - * @property {string} playerId - sender id. - * @property {string} text - message's textual content. - * @property {number} time - creation timestamp. - */ - -/** - * @typedef {object} CameraPosition a saved Arc rotate camera position - * @property {string} hash - hash for this position, to ease comparisons and change detections. - * @property {string} playerId - id of the player for who this camera position is relevant. - * @property {number} index - 0-based index for this saved position. - * @property {number[]} target - 3D cooordinates of the camera target, as per Babylon's specs. - * @property {number} alpha - the longitudinal rotation, in radians. - * @property {number} beta - the longitudinal rotation, in radians. - * @property {number} elevation - the distance from the target (Babylon's radius). - * @see https://doc.babylonjs.com/divingDeeper/cameras/camera_introduction#arc-rotate-camera - */ - -/** - * @typedef {object} Hand a player's private hand. - * @property {string} playerId - owner id. - * @property {Mesh[]} meshes - ordered list of meshes. - */ - -/** - * @typedef {object} _PlayerPreference - * @property {string} playerId - if of this player. - * @property {string} [color] - hex color for this player, if any. - * @property {number} [angle] - yaw (Y angle) on the table, if any. - */ - -/** @typedef { Record & _PlayerPreference } PlayerPreference */ - -/** - * @typedef {object} _HistoryRecord common fields for history records. - * @property {number} time - when this record happened (timestamp). - * @property {string} playerId - who created this record. - * @property {string} meshId - modified mesh id. - * @property {boolean} fromHand - whether this operation happened in this player's hand. - * @property {number} [duration] - optional animation duration, in milliseconds. - */ - -/** - * @typedef {object} _PlayerAction - * @property {ActionName} fn - name of the applied action. - * @property {string} argsStr - stringified arguments for this action. - * @property {string} [revertStr] - optional stringified arguments for reverting this action. - * @typedef { _HistoryRecord & _PlayerAction } PlayerAction an action in the game history. - */ - -/** - * @typedef {object} _PlayerMove - * @property {number[]} pos - absolute position. - * @property {number[]} prev - previous absolute position. - * @typedef { _HistoryRecord & _PlayerMove } PlayerMove a move in the game history. - */ - -/** @typedef {PlayerAction|PlayerMove} HistoryRecord */ - -/** - * @template Parameters - * @typedef {import('ajv').JSONSchemaType} Schema - */ - -/** - * @template Parameters - * @typedef {object} _GameParameters parameters required to join a given game. - * @property {Schema} schema - a JSON Type Definition schema used to validate required parmeters. - * @property {string} [error] - validation error, when relevant. - */ -/** - * @template Parameters - * @typedef {GameData & _GameParameters} GameParameters - */ - const maxOwnedGames = 6 const logger = makeLogger('games-service') @@ -284,7 +37,7 @@ const gameListsUpdate$ = new Subject() /** * @typedef {object} GameListUpdate an updated list of player games. * @property {string} playerId - the corresponding player id. - * @property {Game[]} games - their games. + * @property {import('@tabulous/types').Game[]} games - their games. */ /** @@ -302,8 +55,8 @@ export const gameListsUpdate = gameListsUpdate$.pipe( * When kind is provided, it instanciates an unique set of meshes, based on the descriptor's bags and slots. * Notifies this player's for new game. * @param {string|undefined} kind - game's kind. - * @param {Player} player - creating player. - * @returns {Promise} the created game. + * @param {import('@tabulous/types').Player} player - creating player. + * @returns the created game. * @throws {Error} when no descriptor could be found for this kind. * @throws {Error} when this game is restricted and was not granted to player. * @throws {Error} when player already owns too many games. @@ -312,8 +65,11 @@ export async function createGame(kind, player) { const ctx = { playerId: player.id, kind } logger.trace({ ctx }, 'creating new game') await checkGameLimit(player) - /** @type {Partial} */ - const descriptor = kind ? await findDescriptor(kind, player) : { maxSeats: 8 } + const descriptor = kind + ? await findDescriptor(kind, player) + : /** @type {Partial} */ ({ + maxSeats: 8 + }) // trim some data out of the descriptor before saving it as game properties // eslint-disable-next-line no-unused-vars @@ -343,7 +99,7 @@ export async function createGame(kind, player) { /** * Triggers notification of a game's players and guests. - * @param {Game} game - notified game. + * @param {import('@tabulous/types').Game} game - notified game. * @param {string[]} extras - extra notified player id */ function notifyAllPeers(game, ...extras) { @@ -351,7 +107,7 @@ function notifyAllPeers(game, ...extras) { } /** - * @param {Player} player - player to check game limit. + * @param {import('@tabulous/types').Player} player - player to check game limit. * @param {string[]} excludedGameIds - list of game ids to exclude from counting. * @throws {Error} when this player reached the game limit. */ @@ -364,8 +120,7 @@ async function checkGameLimit(player, excludedGameIds = []) { /** * @param {string} name - catalog item name. - * @param {Player} player - player accessing this game. - * @returns {Promise} + * @param {import('@tabulous/types').Player} player - player accessing this game. */ async function findDescriptor(name, player) { const descriptor = await repositories.catalogItems.getById(name) @@ -387,8 +142,8 @@ async function findDescriptor(name, player) { * May returns parameters if needed, or the actual game content. * @param {string} gameId - loaded game id. * @param {string} kind - game's kind. - * @param {Player} player - joining guest - * @returns {Promise>} the promoted game, its parameters, or null. + * @param {import('@tabulous/types').Player} player - joining guest + * @returns the promoted game or null. * @throws {Error} when no descriptor could be found for this kind. * @throws {Error} when this game is restricted and was not granted to player. * @throws {Error} when player already owns too many games (not counting this one). @@ -440,16 +195,16 @@ export async function promoteGame(gameId, kind, player) { } /** - * @param {?Game} game - checked game. + * @param {?import('@tabulous/types').Game} game - checked game. * @param {string} userId - user to check. - * @returns {boolean} whether this user is a player of the game + * @returns whether this user is a player of the game */ function isPlayer(game, userId) { return game?.playerIds.includes(userId) ?? false } /** - * @param {Game} lobby - game to check. + * @param {import('@tabulous/types').Game} lobby - game to check. * @param {number} availableSeats - maximum seats allowed. * @throws {Error} when this games has not more available seats. */ @@ -469,8 +224,8 @@ function checkAvailableSeats(lobby, availableSeats) { * - the player does not own the game or is not an admin * Updates game lists of all related players. * @param {string} gameId - loaded game id. - * @param {Player} player - deleting player. - * @returns {Promise} the deleted game, or null. + * @param {import('@tabulous/types').Player} player - deleting player. + * @returns the deleted game, or null. */ export async function deleteGame(gameId, player) { const ctx = { playerId: player.id, gameId } @@ -486,17 +241,17 @@ export async function deleteGame(gameId, player) { } /** - * @param {?Game} game - checked game. + * @param {?import('@tabulous/types').Game} game - checked game. * @param {string} playerId - player to check. - * @returns {boolean} whether this player owns the game or not. + * @returns whether this player owns the game or not. */ function isOwner(game, playerId) { return game?.ownerId === playerId } /** - * @param {?Player} player - checked player. - * @returns {boolean} whether this player is an admin or not. + * @param {?import('@tabulous/types').Player} player - checked player. + * @returns whether this player is an admin or not. */ function isAdmin(player) { return player?.isAdmin === true @@ -513,9 +268,9 @@ function isAdmin(player) { * - the player is not a guest * @template Parameters * @param {string} gameId - loaded game id. - * @param {Player} player - joining guest + * @param {import('@tabulous/types').Player} player - joining guest * @param {?Parameters} [parameters] - parameters values for this player, when joining for the first time. - * @returns {Promise>} the loaded game, its parameters, or null. + * @returns {Promise>} the loaded game, its parameters, or null. * @throws {Error} when player is a guest and the game has no more availabe seats. * @throws {Error} when player is a guest and an error occured while adding them. */ @@ -535,9 +290,9 @@ export async function joinGame(gameId, player, parameters) { if (maybeGame.availableSeats <= 0) { throw new Error('no more available seats') } - const game = /** @type {StartedGameData}*/ (maybeGame) + const game = /** @type {import('@tabulous/types').StartedGame}*/ (maybeGame) try { - /** @type {?GameDescriptor} */ + /** @type {?import('@tabulous/types').GameDescriptor} */ let descriptor = null if (game.kind) { descriptor = await repositories.catalogItems.getById(game.kind) @@ -589,19 +344,19 @@ export async function joinGame(gameId, player, parameters) { } /** - * @param {?Game} game - checked game. + * @param {?import('@tabulous/types').Game} game - checked game. * @param {string} userId - user to check. - * @returns {boolean} whether this user is a guest or not. + * @returns whether this user is a guest or not. */ function isGuest(game, userId) { return (game?.guestIds ?? []).indexOf(userId) >= 0 } /** - * @template Parameters - * @param {Schema} schema - JSONSchema to validate + * @template {Record} Parameters + * @param {import('@tabulous/types').Schema} schema - JSONSchema to validate * @param {object} parameters - validated object - * @returns {string|undefined} a validation error string or undefined + * @returns a validation error string or undefined */ function validateParameters(schema, parameters) { if (schema && !ajv.validate(schema, parameters)) { @@ -619,11 +374,11 @@ function validateParameters(schema, parameters) { * are used. * If the descriptor is missing, guest is added to a lobby. * @param {object} args - * @param {?GameDescriptor} args.descriptor - game descriptor. - * @param {StartedGameData} args.game - full game data. - * @param {Player} args.guest - guest who is becoming a player. + * @param {?import('@tabulous/types').GameDescriptor} args.descriptor - game descriptor. + * @param {import('@tabulous/types').StartedGame} args.game - full game data. + * @param {import('@tabulous/types').Player} args.guest - guest who is becoming a player. * @param {?{ color?: string }} args.parameters - parameters provided by the guest. - * @returns {Promise} enriched game data. + * @returns enriched game data. */ async function enrichWithPlayer({ descriptor, game, guest, parameters }) { game.playerIds.push(guest.id) @@ -643,16 +398,19 @@ async function enrichWithPlayer({ descriptor, game, guest, parameters }) { if (!descriptor?.addPlayer) { return game } - return enrichAssets(await descriptor.addPlayer(game, guest, parameters ?? {})) + const r = enrichAssets( + await descriptor.addPlayer(game, guest, parameters ?? {}) + ) + return r } /** * Saves an existing game. * The operation will abort and return null when: * - the player does not own the game - * @param {Partial} game - saved game. + * @param {Partial} game - saved game. * @param {string} playerId - saving playerId id. - * @returns {Promise} the saved game, or null. + * @returns the saved game, or null. */ export async function saveGame(game, playerId) { const ctx = { playerId, gameId: game.id } @@ -683,12 +441,12 @@ export async function saveGame(game, playerId) { * @param {string} gameId - shared game id. * @param {string[]} guestIds - invited player ids. * @param {string} playerId - inviting player id. - * @returns {Promise} updated game, or null + * @returns updated game, or null */ export async function invite(gameId, guestIds, playerId) { const ctx = { playerId, gameId, guestIds } logger.trace({ ctx }, 'inviting to game') - const guests = /** @type {Player[]} */ ( + const guests = /** @type {import('@tabulous/types').Player[]} */ ( ((await repositories.players.getById(guestIds)) ?? []).filter(Boolean) ) let gameOrLobby = await repositories.games.getById(gameId) @@ -719,8 +477,8 @@ export async function invite(gameId, guestIds, playerId) { /** * @param {string} userId - checked user id. - * @param {Game} game - checked game. - * @returns {boolean} whether this user is already in players or guest of this game. + * @param {import('@tabulous/types').Game} game - checked game. + * @returns whether this user is already in players or guest of this game. */ function isGuestAlreadyPlaying(userId, { playerIds, guestIds }) { return playerIds.includes(userId) || guestIds.includes(userId) @@ -728,8 +486,8 @@ function isGuestAlreadyPlaying(userId, { playerIds, guestIds }) { /** * @param {string} guestId - checked guest id. - * @param {Friendship[]} friendships - current player existing relationships. - * @returns {boolean} whether this guest is a friend of the current player. + * @param {import('@src/repositories/players').Friendship[]} friendships - current player existing relationships. + * @returns whether this guest is a friend of the current player. */ function isGuestAFriend(guestId, friendships) { return friendships.some( @@ -749,7 +507,7 @@ function isGuestAFriend(guestId, friendships) { * @param {string} gameId - the game or lobby id. * @param {string} kickedId - the kicked user id. * @param {string} playerId - the kicking player id. - * @returns {Promise} updated game data. + * @returns updated game data. */ export async function kick(gameId, kickedId, playerId) { const ctx = { playerId, gameId, kickedId } @@ -786,7 +544,7 @@ export async function kick(gameId, kickedId, playerId) { /** * Lists all games this players is in. * @param {string} playerId - player id. - * @returns {Promise} a list of games (could be empty). + * @returns a list of games (could be empty). */ export async function listGames(playerId) { return (await repositories.games.listByPlayerId(playerId)).filter(Boolean) @@ -795,7 +553,6 @@ export async function listGames(playerId) { /** * Updates game lists including a given player id. Usefull when updating a player's username. * @param {string} playerId - the player id which is triggering the update. - * @return {Promise} */ export async function notifyRelatedPlayers(playerId) { const playerIds = new Set() @@ -814,7 +571,7 @@ export async function notifyRelatedPlayers(playerId) { * Count the number of games a given player owns. * @param {string} playerId - id of the desired player. * @param {string[]} [excludedGameIds] - optional list of game ids to exclude from the count. - * @returns {Promise} the number of owned games. + * @returns the number of owned games. */ export async function countOwnGames(playerId, excludedGameIds = []) { return (await listGames(playerId)).reduce( @@ -825,8 +582,8 @@ export async function countOwnGames(playerId, excludedGameIds = []) { } /** - * @param {GameData|GameParameters} game - serialized game - * @returns {object} important serialized fields + * @param {import('@tabulous/types').GameData|import('@tabulous/types').GameParameters} game - serialized game + * @returns important serialized fields */ function serializeForLogs({ id, diff --git a/apps/server/src/services/players.js b/apps/server/src/services/players.js index b6c64b02..baebfcb3 100644 --- a/apps/server/src/services/players.js +++ b/apps/server/src/services/players.js @@ -3,7 +3,7 @@ import { createHash } from 'node:crypto' import { Subject } from 'rxjs' -import repositories from '../repositories/index.js' +import * as repositories from '../repositories/index.js' import { makeLogger } from '../utils/index.js' const { @@ -15,49 +15,15 @@ const { const logger = makeLogger('players-service') -/** - * @typedef {object} Player a player account. - * @property {string} id - unique id. - * @property {string} username - player user name. - * @property {?string} currentGameId - game this player is currently playing. - * @property {string} [avatar] - avatar used for display. - * @property {string} [provider] - player's authentication provider, when relevant. - * @property {string} [providerId] - authentication provider own id, when relevant. - * @property {string} [email] - email from authentication provider, when relevant. - * @property {string} [fullName] - full name from the authentication provider, when relevant. - * @property {boolean} [termsAccepted] - whether this player has accepted terms of service. - * @property {string} [password] - the account password hash, when relevant. - * @property {boolean} [isAdmin] - whether this player has elevated priviledges or not. - * @property {string[]} [catalog] - list of copyrighted games this player has accessed to. - * @property {boolean} [usernameSearchable] - whether this player could by found when searching usernames. - */ - -/** - * @typedef {object} Friendship a relationship between two players. - * @property {string} playerId - id of the target player (origin player is implicit). - * @property {boolean} [isRequest] - when true, indicates a friendship request from the target player. - * @property {boolean} [isProposal] - when true, indicates a friendship request sent to the target player. - */ - -/** - * @typedef {object} FriendshipUpdate an update on a friendship relationship - * @property {string} from - player sending the update. - * @property {string} to - player receiving the update. - * @property {boolean} [requested] - indicates that sender requested friendship. - * @property {boolean} [proposed] - indicates that sender proposed new friendship. - * @property {boolean} [accepted] - whether the relationship is accepted. - * @property {boolean} [declined] - whether the relationship is decline. - */ - -/** @type {Subject} */ +/** @type {Subject} */ export const friendshipUpdates = new Subject() /** * Creates or updates a player account, saving user details as they are provided. * If the incoming data contains provider & providerId fields, it keeps previous id, avatar and username. * In case no id is provided, a new one is created. - * @param {Partial} userDetails - creation details. - * @returns {Promise} the creates player. + * @param {Partial} userDetails - creation details. + * @returns the creates player. */ export async function upsertPlayer(userDetails) { const ctx = { ...userDetails, external: false } @@ -65,9 +31,8 @@ export async function upsertPlayer(userDetails) { if (userDetails.provider && userDetails.providerId && userDetails.username) { ctx.external = true // data comes from an external provider - const existing = await repositories.players.getByProviderDetails( - userDetails - ) + const existing = + await repositories.players.getByProviderDetails(userDetails) if (!existing) { // first connection const { username } = userDetails @@ -98,7 +63,7 @@ export async function upsertPlayer(userDetails) { } userDetails.currentGameId = null const saved = await repositories.players.save( - /** @type {Pick} */ ( + /** @type {Pick} */ ( userDetails ) ) @@ -108,20 +73,19 @@ export async function upsertPlayer(userDetails) { /** * @overload - * @param {(string|undefined)[]} [id] - * @returns {Promise<(?Player)[]>} + * Returns a several players from their ids. + * @param {(string|undefined)[]} playerId - desired player ids. + * @returns {Promise<(?import('@tabulous/types').Player)[]>} matching players, or nulls. */ /** * @overload - * @param {string} [id] - * @returns {Promise} + * Returns a single player from its id. + * @param {string} playerId - desired player id. + * @returns {Promise} matching player, or null. */ -/** - * Returns a single or several player from their id. - * @param {string|(string|undefined)[]} [playerId] - desired player id(s). - * @returns {Promise} matching player(s), or null(s). - */ -export async function getPlayerById(playerId) { +export async function getPlayerById( + /** @type {string|(string|undefined)[]} */ playerId +) { // @ts-expect-error: overload + template does not play well together return repositories.players.getById(playerId) } @@ -131,7 +95,7 @@ export async function getPlayerById(playerId) { * Does nothing when no player is matching the given id. * @param {string} playerId - related player id. * @param {?string} currentGameId - id of the current game, or null. - * @returns {Promise} the modified player. + * @returns the modified player. */ export async function setCurrentGameId(playerId, currentGameId) { const player = await getPlayerById(playerId) @@ -151,7 +115,7 @@ export async function setCurrentGameId(playerId, currentGameId) { * @param {string} search - searched text. * @param {string} playerId - the current player id. * @param {boolean} [excludeCurrent=true] - whether to exclude current player from results. - * @returns {Promise} list of matching players. + * @returns list of matching players. */ export async function searchPlayers(search, playerId, excludeCurrent = true) { if ((search ?? '').trim().length < 2) return [] @@ -180,7 +144,7 @@ export async function searchPlayers(search, playerId, excludeCurrent = true) { * It can include a player id from the results. * @param {string} username - tested username. * @param {string} [excludedId] - id of excluded player. - * @returns {Promise} whether this username is already in use, or not. + * @returns whether this username is already in use, or not. */ export async function isUsernameUsed(username, excludedId) { const ctx = { username, excludedId } @@ -196,8 +160,8 @@ export async function isUsernameUsed(username, excludedId) { /** * Records a player accepting terms of service. - * @param {Player} player - the corresponding player. - * @returns {Promise} the player, updates. + * @param {import('@tabulous/types').Player} player - the corresponding player. + * @returns the player, updates. */ export async function acceptTerms(player) { logger.trace( @@ -213,11 +177,9 @@ export async function acceptTerms(player) { return repositories.players.save({ ...player, termsAccepted: true }) } -/** - * @param {Partial} userDetails - * @returns {Promise} - */ -async function findGravatar(userDetails) { +async function findGravatar( + /** @type {Partial} */ userDetails +) { if (!userDetails.email) { return undefined } @@ -238,11 +200,12 @@ async function findGravatar(userDetails) { /** * Returns the list of friends of a given player, including friendship requests, and blocked players. * @param {string} playerId - player id for who the list is returned. - * @returns {Promise} list (possibly empty) of friendship relationships for the specified player. + * @returns list (possibly empty) of friendship relationships for the specified player. */ export async function listFriends(playerId) { const ctx = { playerId } logger.trace({ ctx }, 'listing player friends') + /** @type {import('@tabulous/types').Friendship[]} */ const list = [] for (const { id, state } of await repositories.players.listFriendships( playerId @@ -262,9 +225,9 @@ export async function listFriends(playerId) { /** * Proposes a friendship request from one player to another one. * Publishes an update. - * @param {Player} sender - sender player. + * @param {import('@tabulous/types').Player} sender - sender player. * @param {string} playerId - id of the destination player. - * @returns {Promise} true if the request was proposed. + * @returns true if the request was proposed. */ export async function requestFriendship(sender, playerId) { const ctx = { playerId: sender.id, futureFriend: playerId } @@ -281,9 +244,9 @@ export async function requestFriendship(sender, playerId) { /** * Accepts a friendship request from another player. * Publishes an update. - * @param {Player} sender - accepting player. + * @param {import('@tabulous/types').Player} sender - accepting player. * @param {string} playerId - id of the requesting player. - * @returns {Promise} true if the request was accepted. + * @returns true if the request was accepted. */ export async function acceptFriendship(sender, playerId) { const ctx = { playerId: sender.id, futureFriend: playerId } @@ -312,9 +275,8 @@ export async function acceptFriendship(sender, playerId) { /** * Declines a friendship request or ends existing friendship. - * @param {Player} sender - declining player. + * @param {import('@tabulous/types').Player} sender - declining player. * @param {string} playerId - id of the declined player. - * @returns {Promise} */ export async function endFriendship(sender, playerId) { const ctx = { playerId: sender.id, futureFriend: playerId } diff --git a/apps/server/src/services/turn-credentials.js b/apps/server/src/services/turn-credentials.js index 664cf4fd..45fbdbcd 100644 --- a/apps/server/src/services/turn-credentials.js +++ b/apps/server/src/services/turn-credentials.js @@ -1,16 +1,10 @@ // @ts-check import { createHmac } from 'node:crypto' -/** - * @typedef {object} TurnCredentials used to connect to the turn server - * @property {string} username - unix timestamp representing the expiry date. - * @property {string} credentials - required to connect. - */ - /** * Generates valid credentials for using the turn server. * @param {string} secret - secret configured in coTurn server as `static-auth-secret`. - * @returns {TurnCredentials} credentials for using the turn server. + * @returns {import('@tabulous/types').TurnCredentials} credentials for using the turn server. */ export function generateTurnCredentials(secret) { // credits to https://medium.com/@helderjbe/setting-up-a-turn-server-with-node-production-ready-8f4a4c36e64d @@ -21,11 +15,6 @@ export function generateTurnCredentials(secret) { } } -/** - * @param {string} secret - * @param {string} value - * @returns {string} - */ -function hash(secret, value) { +function hash(/** @type {string} */ secret, /** @type {string} */ value) { return createHmac('sha1', secret).update(value).digest('base64') } diff --git a/apps/server/src/utils/crypto.js b/apps/server/src/utils/crypto.js index 0829967d..5cee69bd 100644 --- a/apps/server/src/utils/crypto.js +++ b/apps/server/src/utils/crypto.js @@ -4,7 +4,7 @@ import { createHash } from 'node:crypto' /** * Hashes the provided value. * @param {string} value - the clear string. - * @returns {string} its hash. + * @returns its hash. */ export function hash(value) { return createHash('sha512').update(value).digest('hex') diff --git a/apps/server/src/utils/games.js b/apps/server/src/utils/games.js index 4117165c..c61181cd 100644 --- a/apps/server/src/utils/games.js +++ b/apps/server/src/utils/games.js @@ -1,61 +1,6 @@ // @ts-check -/** - * @typedef {import('../services/players').Player} Player - * @typedef {import('../services/catalog').GameDescriptor} GameDescriptor - * @typedef {import('../services/games').GameData} GameData - * @typedef {import('../services/games').StartedGameData} StartedGameData - * @typedef {import('../services/games').Mesh} Mesh - * @typedef {import('../services/games').Anchor} Anchor - * @typedef {import('../services/games').Hand} Hand - * @typedef {import('../services/games').CameraPosition} CameraPosition - * @typedef {import('../services/games').PlayerPreference} PlayerPreference - */ -/** - * @template Parameters - * @typedef {import('../services/games').GameParameters} GameParameters - */ - -import { randomUUID } from 'node:crypto' - +import { addAbsoluteAsset, isRelativeAsset } from '@tabulous/game-utils' import Ajv from 'ajv/dist/2020.js' -import merge from 'deepmerge' - -import { shuffle } from './collections.js' - -/** @typedef {Map} Bags */ - -/** - * @typedef {object} GameSetup setup for a given game instance, including meshes, bags and slots. - * Meshes could be cards, round tokens, rounded tiles... They must have an id. - * Use bags to randomize meshes, and use slots to assign them to given positions (and with specific properties). - * Slot will stack onto meshes already there, optionnaly snapping them to an anchor. - * Meshes remaining in bags after processing all slots will be removed. - * @property {Mesh[]} [meshes] - all meshes. - * @property {Bags} [bags] - map of randomized bags, as a list of mesh ids. - * @property {Slot[]} [slots] - a list of position slots - */ - -/** - * @typedef {object} Slot position slot for meshes. - * A slot draw a mesh from a bag (`bagId`), and assigns it provided propertis (x, y, z, texture, movable...). - * - * Slots without an anchor picks as many meshes as needed (count), and stack them. - * When there is no count, they exhaust the bag. - * - * Slots with an anchor (`anchorId`) draw as many mesh as needed (count), - * snap the first to any other mesh with that anchor, and stack others on top of it. - * `anchorId` may be a chain of anchors: "column-2.bottom.top" draw and snaps on anchor "top", of a mesh snapped on - * anchor "bottom", of a mesh snapped on anchor "column-2". - * If such configuration can not be found, the slot is ignored. - * - * NOTES: - * 1. when using multiple slots on the same bag, slot with no count nor anchor MUST COME LAST. - * 2. meshes remaining in bags after processing all slots will be removed. - * - * @property {string} bagId - id of a bag to pick meshes. - * @property {string} [anchorId] - id of the anchor to snap to. - * @property {number} [count] - number of mesh drawn from bag. - */ /** * Unique AJV instance used for game parameter validation. @@ -66,593 +11,14 @@ export const ajv = new Ajv({ strictSchema: false }) -/** - * Creates a unique game from a game descriptor. - * @param {string} kind - created game's kind. - * @param {Partial & Pick} descriptor - to create game from. - * @returns {Promise} a list of serialized 3D meshes. - */ -export async function createMeshes(kind, descriptor) { - if (!descriptor || !descriptor.build) { - throw new Error(`Game ${kind} does not export a build() function`) - } - const { slots, bags, meshes } = await descriptor.build() - const meshById = cloneAll(meshes ?? []) - const allMeshes = [...meshById.values()] - const meshesByBagId = randomizeBags(bags, meshById) - for (const slot of slots ?? []) { - fillSlot(slot, meshesByBagId, allMeshes) - } - removeDandlingMeshes(meshesByBagId, allMeshes) - return allMeshes -} - -/** - * @param {Mesh[]} meshes - meshes to clone. - * @returns {Map} cloned meshes. - */ -function cloneAll(meshes) { - const all = new Map() - for (const mesh of meshes) { - all.set(mesh.id, merge(mesh, {})) - } - return all -} - -/** - * Walk through all game meshes (main scene and player hands) - * to enrich their assets (textures, images, models) with absoluyte paths. - * @template {Pick & Required>} T - * @param {T} game - altered game data. - * @returns {T} the altered game data. - */ -export function enrichAssets(game) { - const allMeshes = [ - ...game.meshes, - ...game.hands.flatMap(({ meshes }) => meshes) - ] - if (game.kind) { - for (const mesh of allMeshes) { - if (isRelativeAsset(mesh.texture)) { - mesh.texture = addAbsoluteAsset(mesh.texture, game.kind, 'texture') - } - if (mesh.file && isRelativeAsset(mesh.file)) { - mesh.file = addAbsoluteAsset(mesh.file, game.kind, 'model') - } - if (mesh.detailable && isRelativeAsset(mesh.detailable.frontImage)) { - mesh.detailable.frontImage = addAbsoluteAsset( - mesh.detailable.frontImage, - game.kind, - 'image' - ) - } - if ( - mesh.detailable?.backImage && - isRelativeAsset(mesh.detailable.backImage) - ) { - mesh.detailable.backImage = addAbsoluteAsset( - mesh.detailable.backImage, - game.kind, - 'image' - ) - } - } - } - return game -} - -/** - * @param {string} [path] - tested path. - * @returns {boolean} whether this path is a relative assets. - */ -function isRelativeAsset(path) { - return path && !path.startsWith('#') && !path.startsWith('/') ? true : false -} - -/** - * @param {string} path - relative path. - * @param {string} kind - game kind. - * @param {string} assetType - asset type. - * @returns {string} the absolute asset path - */ -function addAbsoluteAsset(path, kind, assetType) { - return `/${kind}/${assetType}s/${path}` -} - -/** - * @param {Bags|undefined} bags - a map of bags. - * @param {Map} meshById - map of meshes. - * @returns {Map} a list of randomized meshes per bags. - */ -function randomizeBags(bags, meshById) { - const meshesByBagId = new Map() - if (bags instanceof Map) { - for (const [bagId, meshIds] of bags) { - meshesByBagId.set( - bagId, - shuffle(meshIds) - .map(id => meshById.get(id)) - .filter(Boolean) - ) - } - } - return meshesByBagId -} - -/** - * @param {Slot} slot - slot to fill. - * @param {Map} meshesByBagId - randomized meshes per bags. - * @param {Mesh[]} allMeshes - all meshes - */ -function fillSlot( - { bagId, anchorId, count, ...props }, - meshesByBagId, - allMeshes -) { - const candidates = meshesByBagId.get(bagId) - if (candidates?.length) { - const meshes = candidates.splice(0, count ?? candidates.length) - for (const mesh of meshes) { - mergeProps(mesh, props) - } - if (anchorId) { - const anchor = findAnchor(anchorId, allMeshes) - if (anchor) { - if (anchor.snappedId) { - const mesh = findMesh(anchor.snappedId, allMeshes) - if (mesh) { - meshes.splice(0, 0, mesh) - } - } else { - anchor.snappedId = meshes[0].id - } - } - } - stackMeshes(meshes) - } -} - -/** - * @overload - * Finds an anchor from a path. - * @param {string} path - path to the desired anchor id, its steps separated with '.'. - * @param {Mesh[]} meshes - list of mesh to search into. - * @param {boolean} [throwOnMiss=true] - * @returns {Anchor} the desired anchor. - * @throws {Error} when the anchor could not be found. - * - * @overload - * Finds an anchor from a path. Can return null if no anchor could be found. - * @param {string} path - path to the desired anchor id, its steps separated with '.'. - * @param {Mesh[]} meshes - list of mesh to search into. - * @param {false} throwOnMiss - * @returns {?Anchor} the desired anchor, or null if it can't be found. - */ -export function findAnchor( - /** @type {string} */ path, - /** @type {Mesh[]} */ meshes, - throwOnMiss = true -) { - let candidates = [...(meshes ?? [])] - let anchor - for (let leg of path.split('.')) { - const match = findMeshAndAnchor(leg, candidates, throwOnMiss) - if (!match) { - return null - } - candidates = meshes.filter(({ id }) => id === match.anchor.snappedId) - anchor = match.anchor - } - return anchor ?? null -} - -/** - * @overload - * Finds an anchor and its parent mesh. - * @param {string} anchorId - anchor id. - * @param {Mesh[]} meshes - list of mesh to search into. - * @param {boolean} [throwOnMiss=true] - * @returns {{ mesh: Mesh; anchor: Anchor }} tuple of mesh and anchor, if any. - * @throws {Error} when no mesh with such anchor could be found. - * - * @overload - * Finds an anchor and its parent mesh. Can return null id no mesh has such anchor. - * @param {string} anchorId - anchor id. - * @param {Mesh[]} meshes - list of mesh to search into. - * @param {false} throwOnMiss - * @returns {?{ mesh: Mesh; anchor: Anchor }} tuple of mesh and anchor, if any. - */ -function findMeshAndAnchor( - /** @type {string} */ anchorId, - /** @type {Mesh[]} */ meshes, - throwOnMiss = true -) { - for (const mesh of meshes) { - for (const anchor of mesh.anchorable?.anchors ?? []) { - if (anchor.id === anchorId) { - return { mesh, anchor } - } - } - } - if (throwOnMiss) { - throw new Error(`No anchor with id ${anchorId}`) - } - return null -} - -/** - * @param {Map} meshesByBagId - list of meshes by bag. - * @param {Mesh[]} allMeshes - list of all meshes. - */ -function removeDandlingMeshes(meshesByBagId, allMeshes) { - const removedIds = [] - for (const [, meshes] of meshesByBagId) { - removedIds.push(...meshes.map(({ id }) => id)) - } - for (const id of removedIds) { - allMeshes.splice( - allMeshes.findIndex(mesh => mesh.id === id), - 1 - ) - } -} - -/** - * Alter game data to draw some meshes from a given anchor into a player's hand. - * Automatically creates player hands if needed. - * If provided anchor has fewer meshes as requested, depletes it. - * @param {StartedGameData} game - altered game data. - * @param {object} params - operation parameters: - * @param {string} params.playerId - player id for which meshes are drawn. - * @param {string} params.fromAnchor - id of the anchor to draw from. - * @param {number} [params.count = 1] - number of drawn mesh - * @param {any} [params.props = {}] - other props merged into draw meshes. - * @throws {Error} when no anchor or stack could be found - */ -export function drawInHand( - game, - { playerId, count = 1, fromAnchor, props = {} } -) { - const hand = findOrCreateHand(game, playerId) - const { meshes } = game - const anchor = findAnchor(fromAnchor, meshes) - const stack = findMesh(anchor.snappedId, meshes, false) - if (!stack) { - throw new Error(`Anchor ${fromAnchor} has no snapped mesh`) - } - for (let i = 0; i < count; i++) { - /** @type {Mesh} */ - const drawn = - stack.stackable?.stackIds?.length === 0 ? stack : drawMesh(stack, meshes) - mergeProps(drawn, props) - hand.meshes.push(drawn) - meshes.splice(meshes.indexOf(drawn), 1) - if (drawn === stack) { - break - } - } - if (stack.stackable?.stackIds?.length === 0) { - anchor.snappedId = null - } -} - -/** - * Finds the hand of a given player, optionally creating it. - * @param {StartedGameData} game - altered game data. - * @param {string} playerId - player id for which hand is created. - * @returns {Hand} existing hand, or created one. - */ -export function findOrCreateHand(game, playerId) { - let hand = game.hands.find(hand => hand.playerId === playerId) - if (!hand) { - hand = { playerId, meshes: [] } - game.hands.push(hand) - } - return hand -} - -/** - * @overload - * Finds a mesh by id. - * @param {?string|undefined} id - desired mesh id. - * @param {Mesh[]} meshes - mesh list to search in. - * @param {boolean} [throwOnMiss=true] - throws if mesh can't be found. - * @returns {Mesh} corresponding mesh. - * - * @overload - * Finds a mesh by id. - * @param {?string|undefined} id - desired mesh id. - * @param {Mesh[]} meshes - mesh list to search in. - * @param {false} throwOnMiss - does not throw can't be found. - * @returns {?Mesh} corresponding mesh, if any. - */ -export function findMesh( - /** @type {?string|undefined} */ id, - /** @type {Mesh[]} */ meshes, - throwOnMiss = true -) { - const mesh = meshes?.find(mesh => mesh.id === id) ?? null - if (throwOnMiss && !mesh) { - throw new Error(`No mesh with id ${id}`) - } - return mesh -} - -/** - * @overload - * Draw one or several meshes from a given stack. - * @param {string} stackId - id of a stacked mesh to draw from. - * @param {number} count - number of drawned meshes. - * @param {Mesh[]} meshes - mesh list to search in. - * @param {boolean} [throwOnMiss=true] - * @returns {Mesh[]} drawn meshes. - * @throws {Error} when not all desired meshes could be drawn. - * - * @overload - * Draw one or several meshes from a given stack. - * Can return a smaller or empty array when not all desired mesh could be drawn. - * @param {string} stackId - id of a stacked mesh to draw from. - * @param {number} count - number of drawned meshes. - * @param {Mesh[]} meshes - mesh list to search in. - * @param {false} throwOnMiss - * @returns {Mesh[]} drawn meshes, if any. - */ -export function draw( - /** @type {string} */ stackId, - /** @type {number} */ count, - /** @type {Mesh[]} */ meshes, - throwOnMiss = true -) { - const drawn = [] - const stack = findMesh(stackId, meshes, throwOnMiss) - if (stack) { - for (let i = 0; i < count; i++) { - const mesh = drawMesh(stack, meshes, throwOnMiss) - if (!mesh) { - if (stack.stackable?.stackIds?.length === 0) { - drawn.push(stack) - } - break - } - drawn.push(mesh) - } - } - return drawn -} - -/** - * @overload - * Draw a single mesh from a stack. - * @param {Mesh} stack - stack to draw from. - * @param {Mesh[]} meshes - mesh list to search in. - * @param {boolean} [throwOnMiss=true] - * @returns {Mesh} drawn mesh. - * @throws {Error} when no mesh could be drawn. - * - * @overload - * Draw a single mesh from a stack. Can return null if no mesh could be drawn. - * @param {Mesh} stack - stack to draw from. - * @param {Mesh[]} meshes - mesh list to search in. - * @param {false} throwOnMiss - * @returns {?Mesh} drawn meshes, if any. - */ -function drawMesh( - /** @type {Mesh} */ stack, - /** @type {Mesh[]} */ meshes, - throwOnMiss = true -) { - if (!stack.stackable?.stackIds && throwOnMiss) { - throw new Error(`Mesh ${stack.id} is not stackable`) - } - if (stack.stackable?.stackIds?.length) { - const id = stack.stackable.stackIds.pop() - return findMesh(id, meshes, throwOnMiss) - } - return null -} - -/** - * Stack all provided meshes, in order (the first becomes stack base). - * @param {Mesh[]} meshes - stacked meshes. - */ -export function stackMeshes(meshes) { - const [base, ...others] = meshes - const stackIds = others.map(({ id }) => id) - if (stackIds.length) { - mergeProps(base, { stackable: { stackIds } }) - } -} - -/** - * @overload - * Snap a given mesh onto the specified anchor. - * Search for the anchor within provided meshes. - * If the anchor is already used, tries to stack the meshes (the current snapped mesh must be in provided meshes). - * Abort the operation when meshes can't be stacked. - * @param {string} anchorId - desired anchor id. - * @param {?Mesh} mesh - snapped mesh, if any. - * @param {Mesh[]} meshes - all meshes to search the anchor in. - * @param {boolean} [throwOnMiss=true] - * @return {boolean} true if the mesh could be snapped or stacked. False otherwise. - * @throws {Error} when mesh could not be snapped to anchor. - * - * @overload - * Snap a given mesh onto the specified anchor. - * Search for the anchor within provided meshes. - * If the anchor is already used, tries to stack the meshes (the current snapped mesh must be in provided meshes). - * Abort the operation when meshes can't be stacked, or if mesh can't be snapped to anchor. - * @param {string} anchorId - desired anchor id. - * @param {?Mesh} mesh - snapped mesh, if any. - * @param {Mesh[]} meshes - all meshes to search the anchor in. - * @param {false} throwOnMiss - * @return {boolean} true if the mesh could be snapped or stacked. False otherwise. - */ -export function snapTo( - /** @type {string} */ anchorId, - /** @type {?Mesh} */ mesh, - /** @type {Mesh[]} */ meshes, - throwOnMiss = true -) { - const anchor = findAnchor(anchorId, meshes, throwOnMiss) - if (!anchor || !mesh) { - if (throwOnMiss) { - throw new Error(`No mesh to snap on anchor ${anchorId}`) - } - return false - } - if (anchor.snappedId) { - const snapped = findMesh(anchor.snappedId, meshes, throwOnMiss) - if (!canStack(snapped, mesh)) { - return false - } - stackMeshes([/** @type {Mesh} */ (snapped), mesh]) - } else { - anchor.snappedId = mesh.id - } - return true -} - -/** - * @overload - * Unsnapps a mesh from a given anchor. - * @param {string} anchorId - desired anchor id. - * @param {Mesh[]} meshes - all meshes to search the anchor in. - * @param {boolean} [throwOnMiss=true] - * @returns {?Mesh} unsnapped meshes, or null if anchor has no snapped mesh - * @throws {Error} when anchor (or snapped mesh) could not be found. - * - * @overload - * Unsnapps a mesh from a given anchor. Can return null if anchor (or snapped mesh) could not be found. - * @param {string} anchorId - desired anchor id. - * @param {Mesh[]} meshes - all meshes to search the anchor in. - * @param {false} throwOnMiss - * @returns {?Mesh} unsnapped meshes, or null if anchor does not exist, has no snapped mesh. - */ -export function unsnap( - /** @type {string} */ anchorId, - /** @type {Mesh[]} */ meshes, - throwOnMiss = true -) { - const anchor = findAnchor(anchorId, meshes, throwOnMiss) - if (!anchor || !anchor.snappedId) { - if (throwOnMiss) { - throw new Error(`Anchor ${anchorId} has no snapped mesh`) - } - return null - } - const id = anchor.snappedId - anchor.snappedId = null - return findMesh(id, meshes, throwOnMiss) -} - -/** - * @param {?Mesh|undefined} base - mesh base stack. - * @param {?Mesh|undefined} mesh - mesh to stack onto the base. - * @returns {boolean} whether this mesh can be stacked. - */ -function canStack(base, mesh) { - return Boolean(base?.stackable) && Boolean(mesh?.stackable) -} - -/** - * Deeply merge properties into an object - * @param {object} object - the object to extend. - * @param {object} props - an object deeply merged into the source. - * @returns {object} the extended object. - */ -export function mergeProps(object, props) { - return Object.assign(object, merge(object, props)) -} - -/** - * @overload - * Decrements a quantifiable mesh, by creating another one. - * @param {?Mesh|undefined} mesh - quantifiable mesh - * @param {boolean} [throwOnMiss=true] - * @returns {Mesh} the created object - * @throws {Error} when mesh can not be found or decremented. - * - * @overload - * Decrements a quantifiable mesh, by creating another one. - * Can return null if mesh can not be found or drecremented. - * @param {?Mesh|undefined} mesh - quantifiable mesh - * @param {false} throwOnMiss - * @returns {?Mesh} the created object, when relevant - * - * @param {?Mesh|undefined} mesh - * @param {boolean} [throwOnMiss] - * @returns {?Mesh} - */ -export function decrement(mesh, throwOnMiss = true) { - if ( - mesh?.quantifiable?.quantity !== undefined && - mesh.quantifiable.quantity > 1 - ) { - const clone = merge(mesh, { - id: `${mesh.id}-${randomUUID()}`, - quantifiable: { quantity: 1 } - }) - mesh.quantifiable.quantity-- - return clone - } - if (throwOnMiss) { - throw new Error( - `Mesh ${mesh?.id} is not quantifiable or has a quantity of 1` - ) - } - return null -} - -/** - * Builds a camera save for a given player, with default values: - * - alpha = PI * 3/2 (south) - * - beta = PI / 8 (slightly elevated from ground) - * - elevation = 35 - * - target = [0,0,0] (the origin) - * - index = 0 (default camera position) - * It adds the hash. - * @param {Partial} cameraPosition - a partial camera position without hash. - * @returns {CameraPosition} the built camera position. - */ -export function buildCameraPosition({ - playerId, - index = 0, - target = [0, 0, 0], - alpha = (3 * Math.PI) / 2, - beta = Math.PI / 8, - elevation = 35 -} = {}) { - if (!playerId) { - throw new Error('camera position requires playerId') - } - return addHash({ - hash: '', - playerId, - index, - target, - alpha, - beta, - elevation - }) -} - -/** - * @param {CameraPosition} position - camera to extend with hash. - * @returns {CameraPosition} the augmented camera. - */ -function addHash(position) { - position.hash = `${position.target[0]}-${position.target[1]}-${position.target[2]}-${position.alpha}-${position.beta}-${position.elevation}` - return position -} - /** * Loads a game descriptor's parameter schema. * If defined, enriches any image found; * @param {object} args - arguments, including: - * @param {?Pick} args.descriptor - game descriptor. - * @param {StartedGameData} args.game - current game's data. - * @param {Player} args.player - player for which descriptor is retrieved. - * @returns {Promise>} the parameter schema, or null. + * @param {?Pick} args.descriptor - game descriptor. + * @param {import('@tabulous/types').StartedGame} args.game - current game's data. + * @param {import('@tabulous/types').Player} args.player - player for which descriptor is retrieved. + * @returns {Promise>} the parameter schema, or null. */ export async function getParameterSchema({ descriptor, game, player }) { const schema = await descriptor?.askForParameters?.({ game, player }) @@ -674,54 +40,3 @@ export async function getParameterSchema({ descriptor, game, player }) { } return schema ? { ...game, schema } : null } - -/** - * Returns all possible values of a preference that were not picked by other players. - * For example: `findAvailableValues(preferences, 'color', colors.players)` returns available colors. - * - * @template T - * @param {PlayerPreference[]} preferences - list of player preferences objects. - * @param {string} name - name of the preference considered. - * @param {T[]} possibleValues - list of possible values. - * @returns {T[]} filtered possible values (could be empty). - */ -export function findAvailableValues(preferences, name, possibleValues) { - return possibleValues.filter(value => - preferences.every(pref => value !== pref[name]) - ) -} - -/** - * Crawls game data to find mesh and anchor ids that are not unique. - * Reports them on console. - * @param {Pick} game - checked game data. - * @param {boolean} throwViolations - whether to throw instead of reporting - */ -export function reportReusedIds(game, throwViolations = false) { - const meshes = [...game.meshes].concat( - ...game.hands.map(({ meshes }) => meshes) - ) - const uniqueIds = new Set() - const reusedIds = new Set() - - function check(/** @type {Anchor|Mesh} */ { id }) { - if (uniqueIds.has(id)) { - reusedIds.add(id) - } else { - uniqueIds.add(id) - } - } - for (const mesh of meshes) { - check(mesh) - mesh.anchorable?.anchors?.forEach(check) - } - if (reusedIds.size) { - const message = `game ${game.kind} (${game.id}) has reused ids: ${[ - ...reusedIds - ].join(', ')}` - if (throwViolations) { - throw new Error(message) - } - console.warn(message) - } -} diff --git a/apps/server/src/utils/index.js b/apps/server/src/utils/index.js index f1014a05..0258ffd0 100644 --- a/apps/server/src/utils/index.js +++ b/apps/server/src/utils/index.js @@ -1,5 +1,4 @@ // @ts-check -export * from './collections.js' export * from './crypto.js' export * from './games.js' export * from './logger.js' diff --git a/apps/server/src/utils/logger.js b/apps/server/src/utils/logger.js index e53e821f..9e66f859 100644 --- a/apps/server/src/utils/logger.js +++ b/apps/server/src/utils/logger.js @@ -1,16 +1,11 @@ // @ts-check -/** - * @typedef {import('pino').Logger} Logger - * @typedef {import('pino').LevelWithSilent} Level - */ - import { AsyncLocalStorage } from 'node:async_hooks' import pino from 'pino' /** @typedef {Record} LogContext */ -/** @type {Map} */ +/** @type {Map} */ const loggers = new Map() /** @type {AsyncLocalStorage} */ @@ -18,7 +13,7 @@ const logContext = new AsyncLocalStorage() /** * Initial level values for loggers - * @type {Record} + * @type {Record} */ export const currentLevels = { 'auth-plugin': 'warn', @@ -61,7 +56,7 @@ export function addToLogContext(object) { * A loggers will automatically include data from the curret log context. * @param {string} [name = 'server'] - retrieved logger's name. * @param {LogContext} [initialObject] - optional contextual object attached to this logger. - * @returns {Logger} the built (or cached) logger. + * @returns the built (or cached) logger. */ export function makeLogger(name = 'server', initialObject) { if (!loggers.has(name)) { @@ -81,14 +76,13 @@ export function makeLogger(name = 'server', initialObject) { }) ) } - // @ts-expect-error: Type 'Logger | undefined' is not assignable to type 'Logger' - return loggers.get(name) + return /** @type {import('pino').Logger} */ (loggers.get(name)) } /** * Configures loggers' log level. * Will ignore unknown loggers and unsupported levels - * @param {Record} levels - a record which property names are logger names and values are new level value. + * @param {Record} levels - a record which property names are logger names and values are new level value. */ export function configureLoggers(levels) { for (const name in levels) { diff --git a/apps/server/tests/fixtures/games/6-takes/index.js b/apps/server/tests/fixtures/games/6-takes/index.js index 096abd2e..0dc9c7ab 100644 --- a/apps/server/tests/fixtures/games/6-takes/index.js +++ b/apps/server/tests/fixtures/games/6-takes/index.js @@ -1,3 +1,4 @@ +// @ts-nocheck export function build() { return { meshes: [], diff --git a/apps/server/tests/fixtures/games/belote/index.js b/apps/server/tests/fixtures/games/belote/index.js index 5376aa7c..488e8c7d 100644 --- a/apps/server/tests/fixtures/games/belote/index.js +++ b/apps/server/tests/fixtures/games/belote/index.js @@ -1,3 +1,4 @@ +// @ts-nocheck export function build() { return { meshes: [{ shape: 'card', id: 'as-of-diamonds', texture: 'test.ktx2' }], diff --git a/apps/server/tests/fixtures/games/draughts/index.js b/apps/server/tests/fixtures/games/draughts/index.js index 2451f4f5..5f10b5c2 100644 --- a/apps/server/tests/fixtures/games/draughts/index.js +++ b/apps/server/tests/fixtures/games/draughts/index.js @@ -1,3 +1,4 @@ +// @ts-nocheck export function build() { return { meshes: [{ shape: 'token', id: 'white-1' }], diff --git a/apps/server/tests/graphql/catalog-resolver.test.js b/apps/server/tests/graphql/catalog-resolver.test.js index 62687d8b..cff7bdac 100644 --- a/apps/server/tests/graphql/catalog-resolver.test.js +++ b/apps/server/tests/graphql/catalog-resolver.test.js @@ -26,7 +26,7 @@ vi.mock('../../src/services/index.js', () => ({ describe('given a started server', () => { /** @type {import('fastify').FastifyInstance} */ let server - /** @type {import('../../src/services/index').default} */ + /** @type {import('@src/services/index').default} */ let services vi.spyOn(makeLogger('graphql-plugin'), 'warn').mockImplementation(() => {}) const player = { id: faker.string.uuid(), username: faker.person.firstName() } diff --git a/apps/server/tests/graphql/games-resolver.test.js b/apps/server/tests/graphql/games-resolver.test.js index 0a50fc6f..97934528 100644 --- a/apps/server/tests/graphql/games-resolver.test.js +++ b/apps/server/tests/graphql/games-resolver.test.js @@ -1,13 +1,4 @@ // @ts-check -/** - * @typedef {import('fastify').FastifyInstance} FastifyInstance - * @typedef {import('@tabulous/server/src/services/players').Player} Player - * @typedef {import('@tabulous/server/src/services/games').ActionName} ActionName - * @typedef {import('@tabulous/server/src/services/games').GameData} GameData - * @typedef {import('@tabulous/server/src/services/games').GameListUpdate} GameListUpdate - * @typedef {import('@tabulous/server/src/services/games').GameParameters} GameParameters - */ - import { faker } from '@faker-js/faker' import fastify from 'fastify' import { Subject } from 'rxjs' @@ -36,23 +27,23 @@ import { } from '../test-utils.js' describe('given a started server', () => { - /** @type {FastifyInstance} */ + /** @type {import('fastify').FastifyInstance} */ let server /** @type {import('ws')} */ let ws /** @type {ReturnType} */ let restoreServices const services = - /** @type {import('../test-utils').MockedMethods & {gameListsUpdate: Subject}} */ ( + /** @type {import('../test-utils').MockedMethods & {gameListsUpdate: Subject}} */ ( realServices ) vi.spyOn(makeLogger('graphql-plugin'), 'warn').mockImplementation(() => {}) - const players = /** @type {Player[]} */ ([ + const players = /** @type {import('@tabulous/types').Player[]} */ ([ { id: 'player-0', username: faker.person.firstName() }, { id: 'player-1', username: faker.person.firstName() }, { id: 'player-2', username: faker.person.firstName() } ]) - const guests = /** @type {Player[]} */ ([ + const guests = /** @type {import('@tabulous/types').Player[]} */ ([ { id: 'guest-0', username: faker.person.firstName() }, { id: 'guest-1', username: faker.person.firstName() }, { id: 'guest-2', username: faker.person.firstName() } @@ -68,7 +59,7 @@ describe('given a started server', () => { await server.listen() ws = await openGraphQLWebSocket(server) restoreServices = mockMethods(services) - /** @type {Subject} */ + /** @type {Subject} */ services.gameListsUpdate = new Subject() }) @@ -106,7 +97,7 @@ describe('given a started server', () => { it('returns current games', async () => { const playerId = players[0].id - const games = /** @type {GameData[]} */ ([ + const games = /** @type {import('@tabulous/types').GameData[]} */ ([ { id: faker.string.uuid(), created: faker.date.past().getTime(), @@ -383,7 +374,7 @@ describe('given a started server', () => { it('loads game details and resolves player objects', async () => { const [player] = players - const game = /** @type {GameData} */ ({ + const game = /** @type {import('@tabulous/types').GameData} */ ({ id: faker.string.uuid(), kind: 'tarot', created: faker.date.past().getTime(), @@ -455,13 +446,14 @@ describe('given a started server', () => { it('loads game parameters', async () => { const [player] = players - const gameParameters = /** @type {GameParameters} */ ({ - id: faker.string.uuid(), - schema: {}, - ownerId: player.id, - playerIds: players.map(({ id }) => id), - guestIds: guests.map(({ id }) => id) - }) + const gameParameters = + /** @type {import('@tabulous/types').GameParameters} */ ({ + id: faker.string.uuid(), + schema: {}, + ownerId: player.id, + playerIds: players.map(({ id }) => id), + guestIds: guests.map(({ id }) => id) + }) const value = faker.lorem.words() services.getPlayerById .mockResolvedValueOnce(player) @@ -543,7 +535,7 @@ describe('given a started server', () => { time: Date.now() - 5000, playerId: player.id, meshId: 'box1', - fn: /** @type {ActionName} */ ('flip'), + fn: /** @type {const} */ ('flip'), argsStr: '[]', fromHand: true }, diff --git a/apps/server/tests/graphql/logger-resolver.test.js b/apps/server/tests/graphql/logger-resolver.test.js index 26a509d2..af8bf6d6 100644 --- a/apps/server/tests/graphql/logger-resolver.test.js +++ b/apps/server/tests/graphql/logger-resolver.test.js @@ -1,9 +1,4 @@ // @ts-check -/** - * @typedef {import('fastify').FastifyInstance} FastifyInstance - * @typedef {import('../../src/services/players').Player} Player - */ - import { faker } from '@faker-js/faker' import fastify from 'fastify' import { @@ -27,7 +22,7 @@ import { import { clearDatabase, getRedisTestUrl } from '../test-utils.js' describe('given a started server', () => { - /** @type {FastifyInstance} */ + /** @type {import('fastify').FastifyInstance} */ let server /** @type {import('ws')} */ let ws @@ -38,7 +33,7 @@ describe('given a started server', () => { realServices ) const pubsubUrl = getRedisTestUrl() - /** @type {Player} */ + /** @type {import('@tabulous/types').Player} */ const player = { isAdmin: true, id: faker.string.uuid(), diff --git a/apps/server/tests/graphql/players-resolver.test.js b/apps/server/tests/graphql/players-resolver.test.js index 3c5ed72d..b521bd63 100644 --- a/apps/server/tests/graphql/players-resolver.test.js +++ b/apps/server/tests/graphql/players-resolver.test.js @@ -1,10 +1,4 @@ // @ts-check -/** - * @typedef {import('fastify').FastifyInstance} FastifyInstance - * @typedef {import('../../src/services/players').Player} Player - * @typedef {import('../../src/services/players').FriendshipUpdate} FriendshipUpdate - */ - import { faker } from '@faker-js/faker' import { createVerifier } from 'fast-jwt' import fastify from 'fastify' @@ -21,7 +15,7 @@ import { } from 'vitest' import graphQL from '../../src/plugins/graphql.js' -import realRepositories from '../../src/repositories/index.js' +import * as realRepositories from '../../src/repositories/index.js' import realServices from '../../src/services/index.js' import { hash, makeLogger } from '../../src/utils/index.js' import { @@ -34,14 +28,14 @@ import { } from '../test-utils.js' describe('given a started server', () => { - /** @type {FastifyInstance} */ + /** @type {import('fastify').FastifyInstance} */ let server /** @type {import('ws')} */ let ws /** @type {ReturnType} */ let restoreServices const services = - /** @type {import('vitest').Mocked & {friendshipUpdates: Subject}} */ ( + /** @type {import('vitest').Mocked & {friendshipUpdates: Subject}} */ ( realServices ) const repository = @@ -68,7 +62,7 @@ describe('given a started server', () => { from: 0, results: [] })) - /** @type {Subject} */ + /** @type {Subject} */ services.friendshipUpdates = new Subject() }) @@ -216,7 +210,7 @@ describe('given a started server', () => { }) it.each( - /** @type {{ title: string; user: ?Player, password: string }[]} */ ([ + /** @type {{ title: string; user: ?import('@tabulous/types').Player, password: string }[]} */ ([ { title: 'unfound account', user: null, password }, { title: 'account with no password', @@ -739,7 +733,8 @@ describe('given a started server', () => { services.getPlayerById.mockResolvedValueOnce(player) services.isUsernameUsed.mockResolvedValueOnce(false) services.upsertPlayer.mockImplementation( - async player => /** @type {Player} */ (player) + async player => + /** @type {import('@tabulous/types').Player} */ (player) ) const response = await server.inject({ method: 'POST', @@ -942,7 +937,8 @@ describe('given a started server', () => { services.getPlayerById.mockResolvedValueOnce(player) services.isUsernameUsed.mockResolvedValueOnce(false) services.upsertPlayer.mockImplementation( - async player => /** @type {Player} */ (player) + async player => + /** @type {import('@tabulous/types').Player} */ (player) ) const response = await server.inject({ method: 'POST', diff --git a/apps/server/tests/graphql/signals-resolver.test.js b/apps/server/tests/graphql/signals-resolver.test.js index 92ac16cf..51a15981 100644 --- a/apps/server/tests/graphql/signals-resolver.test.js +++ b/apps/server/tests/graphql/signals-resolver.test.js @@ -1,9 +1,4 @@ // @ts-check -/** - * @typedef {import('fastify').FastifyInstance} FastifyInstance - * @typedef {import('../../src/services/players').Player} Player - */ - import { setTimeout } from 'node:timers/promises' import { faker } from '@faker-js/faker' @@ -33,7 +28,7 @@ import { import { clearDatabase, getRedisTestUrl } from '../test-utils.js' describe('given a started server', () => { - /** @type {FastifyInstance} */ + /** @type {import('fastify').FastifyInstance} */ let server /** @type {import('ws')} */ let ws diff --git a/apps/server/tests/plugins/auth.test.js b/apps/server/tests/plugins/auth.test.js index cb3899db..5d9f61fa 100644 --- a/apps/server/tests/plugins/auth.test.js +++ b/apps/server/tests/plugins/auth.test.js @@ -1,8 +1,4 @@ // @ts-check -/** - * @typedef {import('fastify').FastifyInstance} FastifyInstance - */ - import { faker } from '@faker-js/faker' import { createVerifier } from 'fast-jwt' import fastify from 'fastify' @@ -41,7 +37,7 @@ describe('auth plugin', () => { const domain = 'https://localhost:3000' const jwtOptions = { key: faker.string.uuid() } const allowedOrigins = `http:\\/\\/localhost:80` - /** @type {FastifyInstance} */ + /** @type {import('fastify').FastifyInstance} */ let server /** @type {import('../../src/plugins/auth')} */ let authPlugin diff --git a/apps/server/tests/plugins/cors.test.js b/apps/server/tests/plugins/cors.test.js index 1f77ef07..173052ff 100644 --- a/apps/server/tests/plugins/cors.test.js +++ b/apps/server/tests/plugins/cors.test.js @@ -1,8 +1,4 @@ // @ts-check -/** - * @typedef {import('fastify').FastifyInstance} FastifyInstance - */ - import { faker } from '@faker-js/faker' import fastify from 'fastify' import { afterEach, describe, expect, it } from 'vitest' @@ -11,7 +7,7 @@ import corsPlugin from '../../src/plugins/cors.js' import graphqlPlugin from '../../src/plugins/graphql.js' describe('cors plugin', () => { - /** @type {FastifyInstance} */ + /** @type {import('fastify').FastifyInstance} */ let server afterEach(() => server?.close()) diff --git a/apps/server/tests/plugins/graphql.test.js b/apps/server/tests/plugins/graphql.test.js index db45e499..62dcaead 100644 --- a/apps/server/tests/plugins/graphql.test.js +++ b/apps/server/tests/plugins/graphql.test.js @@ -1,8 +1,4 @@ // @ts-check -/** - * @typedef {import('fastify').FastifyInstance} FastifyInstance - */ - import { faker } from '@faker-js/faker' import fastify from 'fastify' import { @@ -23,7 +19,7 @@ import { makeLogger } from '../../src/utils/index.js' import { mockMethods } from '../test-utils.js' describe('graphql plugin', () => { - /** @type {FastifyInstance} */ + /** @type {import('fastify').FastifyInstance} */ let server /** @type {ReturnType} */ let restoreServices diff --git a/apps/server/tests/plugins/static.test.js b/apps/server/tests/plugins/static.test.js index 6906ea79..d899f533 100644 --- a/apps/server/tests/plugins/static.test.js +++ b/apps/server/tests/plugins/static.test.js @@ -1,8 +1,4 @@ // @ts-check -/** - * @typedef {import('fastify').FastifyInstance} FastifyInstance - */ - import fastify from 'fastify' import { resolve } from 'path' import { fileURLToPath } from 'url' @@ -11,7 +7,7 @@ import { afterEach, describe, expect, it } from 'vitest' import staticPlugin from '../../src/plugins/static.js' describe('static plugin', () => { - /** @type {FastifyInstance} */ + /** @type {import('fastify').FastifyInstance} */ let server afterEach(() => server?.close()) diff --git a/apps/server/tests/repositories/games.test.js b/apps/server/tests/repositories/games.test.js index 11520331..2da57bfa 100644 --- a/apps/server/tests/repositories/games.test.js +++ b/apps/server/tests/repositories/games.test.js @@ -1,8 +1,4 @@ // @ts-check -/** - * @typedef {import('../../src/services/games').Game} Game - */ - import { faker } from '@faker-js/faker' import { afterEach, beforeEach, describe, expect, it } from 'vitest' @@ -15,12 +11,12 @@ describe('given a connected repository and several games', () => { const playerId2 = '2' const playerId3 = '3' - /** @type {Game[]} */ + /** @type {import('@tabulous/types').Game[]} */ let models = [] beforeEach(async () => { await games.connect({ url: redisUrl }) - models = /** @type {Game[]} */ ([ + models = /** @type {import('@tabulous/types').Game[]} */ ([ { id: 'model-0', ownerId: playerId1, @@ -193,11 +189,6 @@ describe('given a connected repository and several games', () => { }) }) -/** - * - * @param {Game[]} results - * @returns {Game[]} - */ -function sortResults(results) { +function sortResults(/** @type {import('@tabulous/types').Game[]} */ results) { return results.sort((a, b) => (a.id < b.id ? -1 : 1)) } diff --git a/apps/server/tests/repositories/players.test.js b/apps/server/tests/repositories/players.test.js index 3c273f4b..4f03f74b 100644 --- a/apps/server/tests/repositories/players.test.js +++ b/apps/server/tests/repositories/players.test.js @@ -1,8 +1,4 @@ // @ts-check -/** - * @typedef {import('../../src/services/players').Player} Player - */ - import { faker } from '@faker-js/faker' import { afterEach, beforeEach, describe, expect, it } from 'vitest' @@ -24,12 +20,12 @@ describe('given a connected repository and several players', () => { const providerId2 = faker.string.sample() const providerId3 = faker.string.sample() - /** @type {Player[]} */ + /** @type {import('@tabulous/types').Player[]} */ let models = [] beforeEach(async () => { await players.connect({ url: redisUrl }) - models = /** @type {Player[]} */ ([ + models = /** @type {import('@tabulous/types').Player[]} */ ([ { id: `p1-${faker.number.int(100)}`, username: 'Jane', diff --git a/apps/server/tests/server.test.js b/apps/server/tests/server.test.js index 7c3fab2b..c6f62536 100644 --- a/apps/server/tests/server.test.js +++ b/apps/server/tests/server.test.js @@ -1,9 +1,4 @@ // @ts-check -/** - * @typedef {import('../src/server').Server} Server - * @typedef {import('../src/services/configuration').Configuration} Configuration - */ - // mandatory side effect for vi to load auth plugin import '../src/plugins/utils.js' @@ -21,11 +16,11 @@ import { vi } from 'vitest' -import repositories from '../src/repositories/index.js' +import * as repositories from '../src/repositories/index.js' import { startServer } from '../src/server.js' describe('startServer()', () => { - /** @type {Server}} */ + /** @type {import('@src/server').Server}} */ let app /** @type {number} */ let port @@ -101,7 +96,7 @@ describe('startServer()', () => { }) it('decorates app with configuration property', async () => { - /** @type {Configuration} */ + /** @type {import('@src/services/configuration').Configuration} */ const conf = { isProduction: true, serverUrl: { host: undefined, port }, diff --git a/apps/server/tests/services/auth/github.test.js b/apps/server/tests/services/auth/github.test.js index 647ae123..cbeb7a61 100644 --- a/apps/server/tests/services/auth/github.test.js +++ b/apps/server/tests/services/auth/github.test.js @@ -1,8 +1,4 @@ // @ts-check -/** - * @typedef {import('undici').Interceptable} Interceptable - */ - import { faker } from '@faker-js/faker' import { MockAgent, setGlobalDispatcher } from 'undici' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -25,9 +21,9 @@ describe('Github authentication service', () => { const secret = faker.internet.password() /** @type {MockAgent} */ let mockAgent - /** @type {Interceptable} */ + /** @type {import('undici').Interceptable} */ let githubMock - /** @type {Interceptable} */ + /** @type {import('undici').Interceptable} */ let githubApiMock beforeEach(() => { diff --git a/apps/server/tests/services/auth/google.test.js b/apps/server/tests/services/auth/google.test.js index 9bc7d96b..37d96821 100644 --- a/apps/server/tests/services/auth/google.test.js +++ b/apps/server/tests/services/auth/google.test.js @@ -1,8 +1,4 @@ // @ts-check -/** - * @typedef {import('undici').Interceptable} Interceptable - */ - import { faker } from '@faker-js/faker' import { createSigner } from 'fast-jwt' import { MockAgent, setGlobalDispatcher } from 'undici' @@ -29,7 +25,7 @@ describe('Google authentication service', () => { const redirect = faker.internet.url() /** @type {MockAgent} */ let mockAgent - /** @type {Interceptable} */ + /** @type {import('undici').Interceptable} */ let googleApiMock beforeEach(() => { diff --git a/apps/server/tests/services/catalog.test.js b/apps/server/tests/services/catalog.test.js index b896df65..058dad5a 100644 --- a/apps/server/tests/services/catalog.test.js +++ b/apps/server/tests/services/catalog.test.js @@ -1,8 +1,4 @@ // @ts-check -/** - * @typedef {import('../../src/services/players').Player} Player - */ - import { faker } from '@faker-js/faker' import { join } from 'path' import { @@ -15,7 +11,7 @@ import { it } from 'vitest' -import repositories from '../../src/repositories/index.js' +import * as repositories from '../../src/repositories/index.js' import { grantAccess, listCatalog, @@ -24,7 +20,7 @@ import { import { clearDatabase, getRedisTestUrl } from '../test-utils.js' describe('Catalog service', () => { - const players = /** @type {Player[]} */ ([ + const players = /** @type {import('@tabulous/types').Player[]} */ ([ { id: faker.string.uuid(), username: faker.person.fullName(), diff --git a/apps/server/tests/services/games.test.js b/apps/server/tests/services/games.test.js index 26ad42b8..1c4a3709 100644 --- a/apps/server/tests/services/games.test.js +++ b/apps/server/tests/services/games.test.js @@ -1,14 +1,4 @@ // @ts-check -/** - * @typedef {import('rxjs').Subscription} Subscription - * @typedef {import('../../src/services/players').Player} Player - * @typedef {import('../../src/services/games').GameData} GameData - * @typedef {import('../../src/services/games').GameListUpdate} GameListUpdate - * @typedef {import('../../src/services/games').CameraPosition} CameraPosition - * @typedef {import('../../src/services/games').PlayerAction} PlayerAction - * @typedef {import('../../src/services/games').PlayerMove} PlayerMove - */ - import { faker } from '@faker-js/faker' import { join } from 'path' import { setTimeout } from 'timers/promises' @@ -23,7 +13,7 @@ import { vi } from 'vitest' -import repositories from '../../src/repositories/index.js' +import * as repositories from '../../src/repositories/index.js' import { grantAccess, revokeAccess } from '../../src/services/catalog.js' import { countOwnGames, @@ -42,26 +32,26 @@ import { clearDatabase, getRedisTestUrl } from '../test-utils.js' describe('given a subscription to game lists and an initialized repository', () => { const redisUrl = getRedisTestUrl() - /** @type {GameListUpdate[]} */ + /** @type {import('@src/services/games').GameListUpdate[]} */ const updates = [] - /** @type {Player} */ + /** @type {import('@tabulous/types').Player} */ const player = { id: 'player', username: '', currentGameId: null } - /** @type {Player} */ + /** @type {import('@tabulous/types').Player} */ const peer = { id: 'peer-1', username: '', currentGameId: null } - /** @type {Player} */ + /** @type {import('@tabulous/types').Player} */ const peer2 = { id: 'peer-2', username: '', currentGameId: null, isAdmin: true } - /** @type {GameData[]} */ + /** @type {import('@tabulous/types').GameData[]} */ const games = [] - /** @type {GameData} */ + /** @type {import('@tabulous/types').GameData} */ let game - /** @type {GameData} */ + /** @type {import('@tabulous/types').GameData} */ let lobby - /** @type {Subscription} */ + /** @type {import('rxjs').Subscription} */ let subscription beforeAll(async () => { @@ -388,8 +378,12 @@ describe('given a subscription to game lists and an initialized repository', () describe('given joined owner', () => { beforeEach(async () => { - game = /** @type {GameData} */ (await joinGame(game.id, player)) - lobby = /** @type {GameData} */ (await joinGame(lobby.id, player)) + game = /** @type {import('@tabulous/types').GameData} */ ( + await joinGame(game.id, player) + ) + lobby = /** @type {import('@tabulous/types').GameData} */ ( + await joinGame(lobby.id, player) + ) await setTimeout(50) updates.splice(0) }) @@ -432,14 +426,15 @@ describe('given a subscription to game lists and an initialized repository', () }) it('throws when the maximum number of players was reached', async () => { - const grantedPlayer = /** @type {Player} */ ( - await grantAccess(player.id, 'splendor') - ) + const grantedPlayer = + /** @type {import('@tabulous/types').Player} */ ( + await grantAccess(player.id, 'splendor') + ) await deleteGame(game.id, grantedPlayer) - game = /** @type {GameData} */ ( + game = /** @type {import('@tabulous/types').GameData} */ ( await createGame('splendor', grantedPlayer) ) // it has 4 seats - game = /** @type {GameData} */ ( + game = /** @type {import('@tabulous/types').GameData} */ ( await joinGame(game.id, grantedPlayer) ) const guests = await repositories.players.save([ @@ -761,16 +756,17 @@ describe('given a subscription to game lists and an initialized repository', () }) it('can save cameras', async () => { - const cameras = /** @type {CameraPosition[]} */ ([ - { - playerId: player.id, - index: 0, - target: [0, 0, 0], - alpha: Math.PI, - beta: 0, - elevation: 10 - } - ]) + const cameras = + /** @type {import('@tabulous/types').CameraPosition[]} */ ([ + { + playerId: player.id, + index: 0, + target: [0, 0, 0], + alpha: Math.PI, + beta: 0, + elevation: 10 + } + ]) expect(await saveGame({ id: game.id, cameras }, player.id)).toEqual({ ...game, cameras, @@ -779,21 +775,22 @@ describe('given a subscription to game lists and an initialized repository', () }) it('can save history', async () => { - const history = /** @type {(PlayerMove|PlayerAction)[]} */ ([ - { - time: Date.now() - 5000, - playerId: player.id, - meshId: 'box1', - fn: 'flip', - argsStr: '[]' - }, - { - time: Date.now() - 3000, - playerId: player.id, - meshId: 'box1', - pos: [0, 0, 3] - } - ]) + const history = + /** @type {(import('@tabulous/types').HistoryRecord)[]} */ ([ + { + time: Date.now() - 5000, + playerId: player.id, + meshId: 'box1', + fn: 'flip', + argsStr: '[]' + }, + { + time: Date.now() - 3000, + playerId: player.id, + meshId: 'box1', + pos: [0, 0, 3] + } + ]) expect(await saveGame({ id: game.id, history }, player.id)).toEqual({ ...game, history @@ -907,7 +904,7 @@ describe('given a subscription to game lists and an initialized repository', () guestIds: [peer.id] }) - const loaded = /** @type {GameData} */ ( + const loaded = /** @type {import('@tabulous/types').GameData} */ ( await joinGame(game.id, player) ) expect(loaded.playerIds).toEqual([player.id]) @@ -969,9 +966,13 @@ describe('given a subscription to game lists and an initialized repository', () describe('given another player', () => { beforeEach(async () => { await invite(game.id, [peer.id], player.id) - game = /** @type {GameData} */ (await joinGame(game.id, peer)) + game = /** @type {import('@tabulous/types').GameData} */ ( + await joinGame(game.id, peer) + ) await invite(lobby.id, [peer.id], player.id) - lobby = /** @type {GameData} */ (await joinGame(lobby.id, peer)) + lobby = /** @type {import('@tabulous/types').GameData} */ ( + await joinGame(lobby.id, peer) + ) await setTimeout(50) updates.splice(0) }) @@ -1070,7 +1071,9 @@ describe('given a subscription to game lists and an initialized repository', () it('allows non-owner to kick lobby players', async () => { await invite(lobby.id, [peer2.id], player.id) - lobby = /** @type {GameData} */ (await joinGame(lobby.id, peer2)) + lobby = /** @type {import('@tabulous/types').GameData} */ ( + await joinGame(lobby.id, peer2) + ) await setTimeout(50) updates.splice(0) @@ -1120,7 +1123,9 @@ describe('given a subscription to game lists and an initialized repository', () }) describe('listGames()', () => { - const getId = (/** @type {GameData} */ { id }) => id + const getId = ( + /** @type {import('@tabulous/types').GameData} */ { id } + ) => id beforeEach(async () => { games.push(game) @@ -1209,11 +1214,19 @@ describe('given a subscription to game lists and an initialized repository', () const game1 = await createGame('belote', player) await joinGame(game1.id, player) await invite(game1.id, [peer.id], player.id) - games.push(/** @type {GameData} */ (await joinGame(game1.id, peer))) + games.push( + /** @type {import('@tabulous/types').GameData} */ ( + await joinGame(game1.id, peer) + ) + ) const game2 = await createGame('belote', peer2) await joinGame(game2.id, peer2) await invite(game2.id, [player.id], peer2.id) - games.push(/** @type {GameData} */ (await joinGame(game2.id, player))) + games.push( + /** @type {import('@tabulous/types').GameData} */ ( + await joinGame(game2.id, player) + ) + ) await setTimeout(50) updates.splice(0, updates.length) @@ -1239,11 +1252,19 @@ describe('given a subscription to game lists and an initialized repository', () const game1 = await createGame('belote', player) await joinGame(game1.id, player) await invite(game1.id, [peer.id], player.id) - games.push(/** @type {GameData} */ (await joinGame(game1.id, peer))) + games.push( + /** @type {import('@tabulous/types').GameData} */ ( + await joinGame(game1.id, peer) + ) + ) const game2 = await createGame('belote', player) await joinGame(game2.id, player) await invite(game2.id, [peer.id], player.id) - games.push(/** @type {GameData} */ (await joinGame(game2.id, peer))) + games.push( + /** @type {import('@tabulous/types').GameData} */ ( + await joinGame(game2.id, peer) + ) + ) await setTimeout(50) updates.splice(0, updates.length) diff --git a/apps/server/tests/services/players.test.js b/apps/server/tests/services/players.test.js index b0293289..5dc64d70 100644 --- a/apps/server/tests/services/players.test.js +++ b/apps/server/tests/services/players.test.js @@ -1,9 +1,4 @@ // @ts-check -/** - * @typedef {import('rxjs').Subscription} Subscription - * @typedef {import('../../src/services/players').Player} Player - */ - import { faker } from '@faker-js/faker' import { vi } from 'vitest' import { @@ -16,7 +11,7 @@ import { it } from 'vitest' -import repositories from '../../src/repositories/index.js' +import * as repositories from '../../src/repositories/index.js' import { acceptFriendship, acceptTerms, @@ -203,7 +198,7 @@ describe('given initialized repository', () => { avatar: faker.internet.avatar(), isAdmin: true }) - /** @type {Partial} */ + /** @type {Partial} */ let update = { id: original.id, avatar: faker.internet.avatar(), @@ -248,7 +243,7 @@ describe('given initialized repository', () => { }) describe('given some players', () => { - let players = /** @type {Player[]} */ ([ + let players = /** @type {import('@tabulous/types').Player[]} */ ([ { id: `adam-${faker.number.int(100)}`, username: 'Adam Destine', @@ -281,7 +276,7 @@ describe('given initialized repository', () => { } ]) - /** @type {Subscription} */ + /** @type {import('rxjs').Subscription} */ let subscription const friendshipUpdateReceived = vi.fn() diff --git a/apps/server/tests/test-utils.js b/apps/server/tests/test-utils.js index c48d4556..6d23caf2 100644 --- a/apps/server/tests/test-utils.js +++ b/apps/server/tests/test-utils.js @@ -99,17 +99,6 @@ export function mockMethods(object) { return () => Object.assign(object, original) } -/** - * Performs a deep clone, using JSON parse and stringify - * This is a slow, destructive (functions, Date and Regex are lost) method, only suitable in tests - * @template {object} T - * @param {T} object - cloned object - * @returns {T} a clone - */ -export function cloneAsJSON(object) { - return JSON.parse(JSON.stringify(object)) -} - /** * For testing purposes, signs a player id in a valid JWT. * @param {string} playerId - signed player id. diff --git a/apps/server/tests/utils/games.test.js b/apps/server/tests/utils/games.test.js index c7ec84f1..8aafe782 100644 --- a/apps/server/tests/utils/games.test.js +++ b/apps/server/tests/utils/games.test.js @@ -1,1121 +1,18 @@ // @ts-check -/** - * @typedef {import('../../src/services/games').Anchor} Anchor - * @typedef {import('../../src/services/games').GameDescriptor} GameDescriptor - * @typedef {import('../../src/services/games').GameParameters} GameParameters - * @typedef {import('../../src/services/games').Mesh} Mesh - * @typedef {import('../../src/services/games').Point} Point - * @typedef {import('../../src/services/games').StartedGameData} StartedGameData - * @typedef {import('../../src/services/players').Player} Player - * @typedef {import('../../src/utils/games').Slot} Slot - */ - import { faker } from '@faker-js/faker' import { vi } from 'vitest' import { beforeEach, describe, expect, it } from 'vitest' -import { - buildCameraPosition, - createMeshes, - decrement, - draw, - drawInHand, - enrichAssets, - findAnchor, - findAvailableValues, - findMesh, - findOrCreateHand, - getParameterSchema, - reportReusedIds, - snapTo, - stackMeshes, - unsnap -} from '../../src/utils/games.js' -import { cloneAsJSON } from '../test-utils.js' - -describe('createMeshes()', () => { - it('throws on invalid descriptor', async () => { - const kind = faker.company.name() - // @ts-expect-error - await expect(createMeshes(kind)).rejects.toThrow( - `Game ${kind} does not export a build() function` - ) - await expect(createMeshes(kind, {})).rejects.toThrow( - `Game ${kind} does not export a build() function` - ) - }) - - it('ignores missing mesh', async () => { - const ids = Array.from({ length: 3 }, (_, i) => `card-${i + 1}`) - const descriptor = { - build: () => ({ - meshes: /** @type {Mesh[]} */ (ids.map(id => ({ id }))), - bags: new Map([['cards', ['card-null', 'card-1', 'card', 'card-2']]]), - slots: [{ bagId: 'cards', x: 1, y: 2, z: 3 }] - }) - } - expect(await createMeshes('cards', descriptor)).toEqual( - expect.arrayContaining( - descriptor.build().meshes.map(expect.objectContaining) - ) - ) - }) - - it('ignores missing bags', async () => { - const ids = Array.from({ length: 3 }, (_, i) => `card-${i + 1}`) - const descriptor = { - build: () => ({ - meshes: /** @type {Mesh[]} */ (ids.map(id => ({ id }))), - bags: new Map([['cards', ids]]), - slots: [{ bagId: 'unknown', x: 1, y: 2, z: 3 }, { bagId: 'cards' }] - }) - } - expect(await createMeshes('cards', descriptor)).toEqual( - descriptor.build().meshes.map(expect.objectContaining) - ) - }) - - it('trims out mesh dandling in bags', async () => { - const ids = Array.from({ length: 3 }, (_, i) => `card-${i + 1}`) - const descriptor = { - build: () => ({ - meshes: /** @type {Mesh[]} */ (ids.map(id => ({ id }))), - bags: new Map([['cards', ids]]) - }) - } - expect(await createMeshes('cards', descriptor)).toEqual([]) - }) - - it('ignores no bags', async () => { - const ids = Array.from({ length: 3 }, (_, i) => `card-${i + 1}`) - const descriptor = { - build: () => ({ - meshes: /** @type {Mesh[]} */ (ids.map(id => ({ id }))), - slots: [{ bagId: 'unknown', x: 1, y: 2, z: 3 }] - }) - } - expect(await createMeshes('cards', descriptor)).toEqual( - descriptor.build().meshes - ) - }) - - describe('given a descriptor with a count slot and a countless slot on the same bag', () => { - const ids = Array.from({ length: 10 }, (_, i) => `card-${i + 1}`) - const descriptor = { - build: () => ({ - meshes: /** @type {Mesh[]} */ (ids.map(id => ({ id }))), - bags: new Map([['cards', ids]]), - slots: [ - { bagId: 'cards', x: 1, y: 2, z: 3, count: 2 }, - { bagId: 'cards', x: 2, y: 3, z: 4 } - ] - }) - } - - it('stacks meshes on slots with random order', async () => { - const { - meshes: originals, - slots: [slot1, slot2] - } = descriptor.build() - const meshes = await createMeshes('cards', descriptor) - expect(meshes).toEqual( - expect.arrayContaining(originals.map(expect.objectContaining)) - ) - expect(meshes).not.toEqual(originals) - expectStackedOnSlot(meshes, slot1) - expectStackedOnSlot(meshes, slot2, 8) - }) - - it('applies different slot order on different games', async () => { - const { meshes: originals } = descriptor.build() - const meshes1 = await createMeshes('cards', descriptor) - const meshes2 = await createMeshes('cards', descriptor) - expect(meshes1).not.toEqual(originals) - expect(meshes2).not.toEqual(originals) - expect(meshes1).not.toEqual(meshes2) - }) - }) - - describe('given a descriptor with multiple count slots on the same bag', () => { - const ids = Array.from({ length: 10 }, (_, i) => `card-${i + 1}`) - const descriptor = { - build: () => ({ - meshes: /** @type {Mesh[]} */ (ids.map(id => ({ id }))), - bags: new Map([['cards', ids]]), - slots: [ - { bagId: 'cards', x: 1, y: 2, z: 3, count: 2 }, - { bagId: 'cards', x: 2, y: 3, z: 4, count: 3 } - ] - }) - } - - it('removes remaining meshes after processing all slots', async () => { - const { - slots: [slot1, slot2] - } = descriptor.build() - const meshes = await createMeshes('cards', descriptor) - expect(meshes).toHaveLength(slot1.count + slot2.count) - expectStackedOnSlot(meshes, slot1) - expectStackedOnSlot(meshes, slot2) - }) - }) - - describe('given a descriptor with multiple slots on the same anchor', () => { - const ids = Array.from({ length: 10 }, (_, i) => `card-${i + 1}`) - const boardId = 'board' - const descriptor = { - build: () => ({ - meshes: /** @type {Mesh[]} */ ([ - ...ids.map(id => ({ id })), - { id: boardId, anchorable: { anchors: [{ id: 'anchor' }] } } - ]), - bags: new Map([['cards', ids]]), - slots: [ - { bagId: 'cards', anchorId: 'anchor', count: 2 }, - { bagId: 'cards', anchorId: 'anchor', count: 3 }, - { bagId: 'cards', anchorId: 'anchor' } - ] - }) - } - - it('push meshes to the same stack', async () => { - const { - slots: [slot] - } = descriptor.build() - const meshes = await createMeshes('cards', descriptor) - expect(meshes).toHaveLength(ids.length + 1) - const { id: stackId } = expectStackedOnSlot(meshes, slot, ids.length) - const board = meshes.find(({ id }) => id === boardId) - expect(board).toBeDefined() - expect(board?.anchorable?.anchors?.[0]?.snappedId).toEqual(stackId) - }) - }) - - describe('given a descriptor with anchorable board', () => { - const ids = Array.from({ length: 10 }, (_, i) => `card-${i + 1}`) - const initialMeshes = /** @type {Mesh[]} */ ([ - { - id: 'board', - anchorable: { - anchors: [{ id: 'first' }, { id: 'second' }, { id: 'third' }] - } - }, - ...ids.map(id => ({ - id, - anchorable: { anchors: [{ id: 'top' }, { id: 'bottom' }] } - })) - ]) - const bags = new Map([['cards', ids]]) - - it('snaps a random mesh on anchor', async () => { - const slots = [ - { bagId: 'cards', anchorId: 'first', count: 1, name: 'first' }, - { bagId: 'cards', anchorId: 'third', count: 1, name: 'third' } - ] - const meshes = - /** @type {(Mesh & { name: string, anchorable: { anchors: Anchor[] } })[]} */ ( - await createMeshes('cards', { - build: () => ({ meshes: initialMeshes, slots, bags }) - }) - ) - const board = meshes.find(({ id }) => id === 'board') - expect(board).toBeDefined() - - expectSnappedByName(meshes, slots[0].name, board?.anchorable.anchors[0]) - expect(board?.anchorable.anchors[1].snappedId).toBeUndefined() - expectSnappedByName(meshes, slots[1].name, board?.anchorable.anchors[2]) - }) - - it('snaps a random mesh on chained anchor', async () => { - const slots = [ - { bagId: 'cards', anchorId: 'second', count: 1, name: 'base' }, - { bagId: 'cards', anchorId: 'second.top', count: 1, name: 'top' }, - { bagId: 'cards', anchorId: 'second.bottom', count: 1, name: 'bottom' } - ] - const meshes = - /** @type {(Mesh & { name: string, anchorable: { anchors: Anchor[] } })[]} */ ( - await createMeshes('cards', { - build: () => ({ meshes: initialMeshes, slots, bags }) - }) - ) - const board = meshes.find(({ id }) => id === 'board') - expect(board).toBeDefined() - - expect(board?.anchorable.anchors[0].snappedId).toBeUndefined() - expectSnappedByName(meshes, 'base', board?.anchorable.anchors[1]) - expect(board?.anchorable.anchors[2].snappedId).toBeUndefined() - - const base = meshes.find(mesh => mesh.name === 'base') - expectSnappedByName(meshes, 'top', base?.anchorable.anchors[0]) - expectSnappedByName(meshes, 'bottom', base?.anchorable.anchors[1]) - }) - - it('snaps a random mesh on long chained anchor', async () => { - const slots = [ - { bagId: 'cards', anchorId: 'second', count: 1, name: 'base' }, - { bagId: 'cards', anchorId: 'second.top', count: 1, name: 'first' }, - { - bagId: 'cards', - anchorId: 'second.top.top', - count: 1, - name: 'second' - }, - { - bagId: 'cards', - anchorId: 'second.top.top.bottom', - count: 1, - name: 'third' - } - ] - const meshes = - /** @type {(Mesh & { name: string, anchorable: { anchors: Anchor[] } })[]} */ ( - await createMeshes('cards', { - build: () => ({ meshes: initialMeshes, slots, bags }) - }) - ) - const board = meshes.find(({ id }) => id === 'board') - expect(board).toBeDefined() - - expect(board?.anchorable.anchors[0].snappedId).toBeUndefined() - expectSnappedByName(meshes, 'base', board?.anchorable.anchors[1]) - expect(board?.anchorable.anchors[2].snappedId).toBeUndefined() - - const base = meshes.find(mesh => mesh.name === 'base') - expectSnappedByName(meshes, 'first', base?.anchorable.anchors[0]) - expect(base?.anchorable.anchors[1].snappedId).toBeUndefined() - - const first = meshes.find(mesh => mesh.name === 'first') - expectSnappedByName(meshes, 'second', first?.anchorable.anchors[0]) - expect(first?.anchorable.anchors[1].snappedId).toBeUndefined() - - const second = meshes.find(mesh => mesh.name === 'second') - expectSnappedByName(meshes, 'third', second?.anchorable.anchors[1]) - expect(second?.anchorable.anchors[0].snappedId).toBeUndefined() - - const third = meshes.find(mesh => mesh.name === 'third') - expect(third?.anchorable.anchors[0].snappedId).toBeUndefined() - expect(third?.anchorable.anchors[1].snappedId).toBeUndefined() - }) - - it('can stack on top of an anchor', async () => { - const slots = [ - { bagId: 'cards', anchorId: 'second', count: 3, name: 'base' } - ] - const meshes = - /** @type {(Mesh & { name: string, anchorable: { anchors: Anchor[] } })[]} */ ( - await createMeshes('cards', { - build: () => ({ - meshes: /** @type {Mesh[]} */ ([ - initialMeshes[0], - ...ids.map(id => ({ id })) - ]), - slots, - bags - }) - }) - ) - const board = meshes.find(({ id }) => id === 'board') - const snapped = meshes.filter(mesh => mesh.name === 'base') - expect(board).toBeDefined() - expect(snapped).toHaveLength(3) - expect(board?.anchorable.anchors[0].snappedId).toBeUndefined() - const base = snapped.filter(mesh => mesh.stackable) - expect(base).toHaveLength(1) - expect(base?.[0]?.stackable?.stackIds).toEqual( - expect.arrayContaining( - snapped.filter(mesh => mesh !== base[0]).map(({ id }) => id) - ) - ) - }) - - it('can mix stack and anchors', async () => { - const slots = [ - { bagId: 'cards', anchorId: 'second', count: 1, name: 'base' }, - { bagId: 'cards', x: 1, z: 2 } - ] - const meshes = - /** @type {(Mesh & { name: string, anchorable: { anchors: Anchor[] } })[]} */ ( - await createMeshes('cards', { - build: () => ({ - meshes: /** @type {Mesh[]} */ ([ - ...ids.map(id => ({ id })), - initialMeshes[0] - ]), - slots, - bags - }) - }) - ) - const board = meshes.find(({ id }) => id === 'board') - const base = meshes.find(mesh => mesh.name === 'base') - expect(board).toBeDefined() - expect(base).toBeDefined() - expect(base?.x).toBeUndefined() - expect(board?.anchorable.anchors[0].snappedId).toBeUndefined() - expectSnappedByName(meshes, 'base', board?.anchorable.anchors[1]) - expect(board?.anchorable.anchors[2].snappedId).toBeUndefined() - expect( - meshes - .filter(mesh => mesh.name !== 'base' && mesh.id !== 'board') - .every(({ x }) => x === 1) - ).toBe(true) - }) - }) - - describe('given a descriptor with multiple slots on the same bag', () => { - const ids = Array.from({ length: 10 }, (_, i) => `card-${i + 1}`) - const descriptor = { - build: () => ({ - meshes: /** @type {Mesh[]} */ (ids.map(id => ({ id }))), - bags: new Map([['cards', ids]]), - slots: [ - { bagId: 'cards', x: 10, count: 2 }, - { bagId: 'cards', x: 5, count: 1 }, - { bagId: 'cards', x: 1 } - ] - }) - } - - it('draws meshes to fill slots', async () => { - const meshes = await createMeshes('cards', descriptor) - expect(meshes).toEqual( - expect.arrayContaining( - descriptor.build().meshes.map(expect.objectContaining) - ) - ) - expect( - meshes.filter(({ stackable }) => stackable?.stackIds?.length === 1) - ).toHaveLength(1) - expect(meshes.filter(({ x }) => x === 10)).toHaveLength(2) - expect(meshes.filter(({ x }) => x === 5)).toHaveLength(1) - expect( - meshes.filter(({ stackable }) => stackable?.stackIds?.length === 6) - ).toHaveLength(1) - expect(meshes.filter(({ x }) => x === 1)).toHaveLength(7) - }) - }) -}) - -describe('enrichAssets()', () => { - it('enriches mesh relative texture', () => { - const kind = faker.lorem.word() - const texture = faker.system.commonFileName('png') - expect( - enrichAssets({ - kind, - meshes: [{ id: '1', shape: 'box', texture }], - hands: [] - }).meshes[0].texture - ).toEqual(`/${kind}/textures/${texture}`) - }) - - it('does not enrich mesh absolute texture', () => { - const kind = faker.lorem.word() - const texture = faker.system.filePath() - expect( - enrichAssets({ - kind, - meshes: [{ id: '1', shape: 'box', texture }], - hands: [] - }).meshes[0].texture - ).toEqual(texture) - }) - - it('does not enrich mesh colored texture', () => { - const kind = faker.lorem.word() - const texture = faker.internet.color() - expect( - enrichAssets({ - kind, - meshes: [{ id: '1', shape: 'box', texture }], - hands: [] - }).meshes[0].texture - ).toEqual(texture) - }) - - it('enriches mesh relative model', () => { - const kind = faker.lorem.word() - const file = faker.system.commonFileName('png') - expect( - enrichAssets({ - kind, - meshes: [{ id: '1', shape: 'box', texture: '', file }], - hands: [] - }).meshes[0].file - ).toEqual(`/${kind}/models/${file}`) - }) - - it('does not enrich mesh absolute model', () => { - const kind = faker.lorem.word() - const file = faker.system.filePath() - expect( - enrichAssets({ - kind, - meshes: [{ id: '1', shape: 'box', texture: '', file }], - hands: [] - }).meshes[0].file - ).toEqual(file) - }) - - it('enriches mesh relative front image', () => { - const kind = faker.lorem.word() - const frontImage = faker.system.commonFileName('png') - expect( - enrichAssets({ - kind, - meshes: [ - { id: '1', shape: 'box', texture: '', detailable: { frontImage } } - ], - hands: [] - }).meshes[0].detailable.frontImage - ).toEqual(`/${kind}/images/${frontImage}`) - }) - - it('does not enrich mesh absolute front image', () => { - const kind = faker.lorem.word() - const frontImage = faker.system.filePath() - expect( - enrichAssets({ - kind, - meshes: [ - { id: '1', shape: 'box', texture: '', detailable: { frontImage } } - ], - hands: [] - }).meshes[0].detailable.frontImage - ).toEqual(frontImage) - }) - - it('enriches mesh relative back image', () => { - const kind = faker.lorem.word() - const backImage = faker.system.commonFileName('png') - expect( - enrichAssets({ - kind, - meshes: [ - { - id: '1', - shape: 'box', - texture: '', - detailable: { frontImage: '', backImage } - } - ], - hands: [] - }).meshes[0].detailable.backImage - ).toEqual(`/${kind}/images/${backImage}`) - }) - - it('does not enrich mesh absolute front image', () => { - const kind = faker.lorem.word() - const frontImage = faker.system.filePath() - expect( - enrichAssets({ - kind, - meshes: [ - { id: '1', shape: 'box', texture: '', detailable: { frontImage } } - ], - hands: [] - }).meshes[0]?.detailable?.frontImage - ).toEqual(frontImage) - }) - - it('enriches all mesh relative assets', () => { - const kind = faker.lorem.word() - const texture = faker.system.commonFileName('png') - const file = faker.system.commonFileName('png') - const frontImage = faker.system.commonFileName('png') - const backImage = faker.system.commonFileName('png') - const { - hands: [ - { - meshes: [mesh] - } - ] - } = enrichAssets({ - kind, - meshes: [], - hands: [ - { - playerId: 'foo', - meshes: [ - { - id: '1', - shape: 'box', - texture, - file, - detailable: { frontImage, backImage } - } - ] - } - ] - }) - expect(mesh.texture).toEqual(`/${kind}/textures/${texture}`) - expect(mesh.file).toEqual(`/${kind}/models/${file}`) - expect(mesh.detailable.frontImage).toEqual(`/${kind}/images/${frontImage}`) - expect(mesh.detailable.backImage).toEqual(`/${kind}/images/${backImage}`) - }) -}) - -describe('draw()', () => { - /** @type {StartedGameData} */ - let game - - beforeEach(() => { - game = /** @type {StartedGameData} */ ({ - meshes: [ - { id: 'A' }, - { - id: 'B', - anchorable: { anchors: [{ id: 'discard', snappedId: 'C' }] } - }, - { id: 'C', stackable: { stackIds: ['A', 'E', 'D'] } }, - { id: 'D' }, - { id: 'E' } - ] - }) - }) - - it('draws one mesh from a stack', () => { - const { meshes } = game - expect(draw('C', 1, game.meshes)).toEqual([meshes[3]]) - expect(meshes[2].stackable?.stackIds).toEqual(['A', 'E']) - }) - - it('draws several meshes from a stack', () => { - const { meshes } = game - expect(draw('C', 2, meshes)).toEqual([meshes[3], meshes[4]]) - expect(meshes[2].stackable?.stackIds).toEqual(['A']) - }) - - it('can deplete a stack', () => { - const { meshes } = game - expect(draw('C', 10, meshes)).toEqual([ - meshes[3], - meshes[4], - meshes[0], - meshes[2] - ]) - expect(meshes[2].stackable?.stackIds).toEqual([]) - }) - - it('does nothing on unstackable meshes', () => { - expect(draw('A', 1, game.meshes, false)).toEqual([]) - }) - - it('can throw on unstackable meshes', () => { - expect(() => draw('A', 1, game.meshes)).toThrow('Mesh A is not stackable') - }) - - it('does nothing on unknown meshes', () => { - expect(draw('K', 1, game.meshes, false)).toEqual([]) - }) - - it('can throw on unknown meshes', () => { - expect(() => draw('K', 1, game.meshes)).toThrow('No mesh with id K') - }) -}) - -describe('drawInHand()', () => { - const playerId = faker.string.uuid() - /** @type {StartedGameData} */ - let game - - beforeEach(() => { - // @ts-expect-error: missing properties - game = /** @type {StartedGameData} */ ({ - hands: [], - meshes: [ - { id: 'A' }, - { - id: 'B', - anchorable: { anchors: [{ id: 'discard', snappedId: 'C' }] } - }, - { id: 'C', stackable: { stackIds: ['A', 'E', 'D'] } }, - { id: 'D' }, - { id: 'E' } - ] - }) - }) - - it('throws error on unknown anchor', () => { - expect(() => drawInHand(game, { playerId, fromAnchor: 'unknown' })).toThrow( - `No anchor with id unknown` - ) - }) - - it('draws one mesh into a new hand', () => { - drawInHand(game, { playerId, fromAnchor: 'discard' }) - expect(game).toEqual({ - hands: [{ playerId, meshes: [{ id: 'D' }] }], - meshes: [ - { id: 'A' }, - { - id: 'B', - anchorable: { anchors: [{ id: 'discard', snappedId: 'C' }] } - }, - { id: 'C', stackable: { stackIds: ['A', 'E'] } }, - { id: 'E' } - ] - }) - }) - - it('draws multiple meshes into a new hand', () => { - const props = { foo: 'bar' } - drawInHand(game, { playerId, count: 2, fromAnchor: 'discard', props }) - expect(game).toEqual({ - hands: [ - { - playerId, - meshes: [ - { id: 'D', ...props }, - { id: 'E', ...props } - ] - } - ], - meshes: [ - { id: 'A' }, - { - id: 'B', - anchorable: { anchors: [{ id: 'discard', snappedId: 'C' }] } - }, - { id: 'C', stackable: { stackIds: ['A'] } } - ] - }) - }) - - it('draws until depletion into a new hand', () => { - drawInHand(game, { playerId, count: 10, fromAnchor: 'discard' }) - expect(game).toEqual({ - hands: [ - { - playerId, - meshes: [ - { id: 'D' }, - { id: 'E' }, - { id: 'A' }, - { id: 'C', stackable: { stackIds: [] } } - ] - } - ], - meshes: [ - { - id: 'B', - anchorable: { anchors: [{ id: 'discard', snappedId: null }] } - } - ] - }) - }) - - it('throws when drawing from empty anchor', () => { - delete game.meshes[1].anchorable?.anchors?.[0].snappedId - expect(() => - drawInHand(game, { playerId, count: 2, fromAnchor: 'discard' }) - ).toThrow('Anchor discard has no snapped mesh') - }) -}) - -describe('findMesh()', () => { - const meshes = /** @type {Mesh[]} */ ( - Array.from({ length: 10 }, () => ({ - id: faker.string.uuid() - })) - ) - - it('returns existing meshes', () => { - expect(findMesh(meshes[5].id, meshes)).toEqual(meshes[5]) - expect(findMesh(meshes[8].id, meshes)).toEqual(meshes[8]) - }) - - it.each([faker.string.uuid(), meshes[0].id])( - 'returns null on unknown ids', - anchor => { - expect(findMesh(anchor, [], false)).toBeNull() - } - ) - - it.each([faker.string.uuid(), meshes[0].id])( - 'can throw on unknown ids', - id => { - expect(() => findMesh(id, [])).toThrow(`No mesh with id ${id}`) - } - ) -}) - -describe('findOrCreateHand()', () => { - it('finds existing hand', () => { - const playerId1 = faker.string.uuid() - const playerId2 = faker.string.uuid() - - const game = /** @type {StartedGameData} */ ({ - hands: [ - { playerId: playerId1, meshes: [{ id: 'A' }] }, - { playerId: playerId2, meshes: [{ id: 'B' }] } - ] - }) - expect(findOrCreateHand(game, playerId1)).toEqual(game.hands[0]) - expect(findOrCreateHand(game, playerId2)).toEqual(game.hands[1]) - }) - - it('creates new hand', () => { - const playerId1 = faker.string.uuid() - const playerId2 = faker.string.uuid() - - const game = /** @type {StartedGameData} */ ({ - hands: [{ playerId: playerId1, meshes: [{ id: 'A' }] }] - }) - const created = { playerId: playerId2, meshes: [] } - expect(findOrCreateHand(game, playerId1)).toEqual(game.hands[0]) - expect(findOrCreateHand(game, playerId2)).toEqual(created) - expect(game.hands[1]).toEqual(created) - }) -}) - -describe('findAnchor()', () => { - const anchors = Array.from({ length: 10 }, () => ({ - id: faker.string.uuid() - })) - - const meshes = /** @type {Mesh[]} */ ([ - { id: 'mesh0' }, - { id: 'mesh1', anchorable: { anchors: anchors.slice(0, 3) } }, - { id: 'mesh2', anchorable: { anchors: [] } }, - { id: 'mesh3', anchorable: { anchors: anchors.slice(3, 6) } }, - { id: 'mesh4', anchorable: { anchors: anchors.slice(6) } } - ]) - - it.each([faker.string.uuid(), anchors[0].id])( - 'returns null on unknown anchor', - anchor => { - expect(findAnchor(anchor, [], false)).toBeNull() - } - ) - - it.each([faker.string.uuid(), anchors[0].id])( - 'can throw on unknown anchor', - anchor => { - expect(() => findAnchor(anchor, [])).toThrow( - `No anchor with id ${anchor}` - ) - } - ) - - it('returns existing anchor', () => { - expect(findAnchor(anchors[0].id, meshes)).toEqual(anchors[0]) - expect(findAnchor(anchors[4].id, meshes)).toEqual(anchors[4]) - expect(findAnchor(anchors[7].id, meshes)).toEqual(anchors[7]) - }) - - it('returns existing, deep, anchor', () => { - const meshes = /** @type {Mesh[]} */ ([ - { id: 'mesh0', anchorable: { anchors: [{ id: 'bottom' }] } }, - { - id: 'mesh1', - anchorable: { anchors: [{ id: 'bottom', snappedId: 'mesh3' }] } - }, - { - id: 'mesh2', - anchorable: { anchors: [{ id: 'start', snappedId: 'mesh1' }] } - }, - { - id: 'mesh3', - anchorable: { anchors: [{ id: 'bottom', snappedId: 'mesh0' }] } - } - ]) - expect(findAnchor('start.bottom', meshes)).toEqual( - meshes[1].anchorable?.anchors?.[0] - ) - expect(findAnchor('start.bottom.bottom', meshes)).toEqual( - meshes[3].anchorable?.anchors?.[0] - ) - expect(findAnchor('start.bottom.bottom.bottom', meshes)).toEqual( - meshes[0].anchorable?.anchors?.[0] - ) - expect(findAnchor('bottom', meshes)).toEqual( - meshes[0].anchorable?.anchors?.[0] - ) - }) -}) - -describe('snapTo()', () => { - /** @type {Mesh[]} */ - let meshes - - beforeEach(() => { - meshes = /** @type {Mesh[]} */ ([ - { id: 'mesh0' }, - { id: 'mesh1', anchorable: { anchors: [{ id: 'anchor1' }] } }, - { - id: 'mesh2', - anchorable: { anchors: [{ id: 'anchor2' }, { id: 'anchor3' }] } - }, - { id: 'mesh3' } - ]) - }) - - it('snaps a mesh to an existing anchor', () => { - expect(snapTo('anchor3', meshes[0], meshes)).toBe(true) - expect(meshes[2]).toEqual({ - id: 'mesh2', - anchorable: { - anchors: [{ id: 'anchor2' }, { id: 'anchor3', snappedId: 'mesh0' }] - } - }) - }) - - it('stacks a mesh if anchor is in use', () => { - meshes[0].stackable = {} - meshes[1].stackable = {} - expect(snapTo('anchor2', meshes[0], meshes)).toBe(true) - expect(snapTo('anchor2', meshes[1], meshes)).toBe(true) - expect(meshes[2]).toEqual({ - id: 'mesh2', - anchorable: { - anchors: [{ id: 'anchor2', snappedId: 'mesh0' }, { id: 'anchor3' }] - } - }) - expect(meshes[0]).toEqual({ - id: 'mesh0', - stackable: { stackIds: ['mesh1'] } - }) - }) - - it('ignores unstackable mesh on an anchor in use', () => { - meshes[0].stackable = {} - expect(snapTo('anchor2', meshes[0], meshes)).toBe(true) - const state = cloneAsJSON(meshes) - expect(snapTo('anchor2', meshes[1], meshes)).toBe(false) - expect(state).toEqual(meshes) - }) - - it('ignores mesh on an anchor in use with unstackable mesh', () => { - expect(snapTo('anchor2', meshes[0], meshes)).toBe(true) - meshes[1].stackable = {} - const state = cloneAsJSON(meshes) - expect(snapTo('anchor2', meshes[1], meshes)).toBe(false) - expect(state).toEqual(meshes) - }) - - it('ignores unknown anchor', () => { - const state = cloneAsJSON(meshes) - expect(snapTo('anchor10', meshes[0], meshes, false)).toBe(false) - expect(state).toEqual(meshes) - }) - - it('can throw on unknown anchor', () => { - expect(() => snapTo('anchor10', meshes[0], meshes)).toThrow( - 'No anchor with id anchor10' - ) - }) - - it('ignores unknown mesh', () => { - const state = cloneAsJSON(meshes) - expect(snapTo('anchor1', null, meshes, false)).toBe(false) - expect(state).toEqual(meshes) - }) - - it('can throw on unknown mesh', () => { - expect(() => snapTo('anchor1', null, meshes)).toThrow( - 'No mesh to snap on anchor anchor1' - ) - }) -}) - -describe('unsnap()', () => { - /** @type {Mesh[]} */ - let meshes - - beforeEach(() => { - meshes = /** @type {Mesh[]} */ ([ - { - id: 'mesh1', - anchorable: { - anchors: [{ id: 'anchor1', snappedId: 'mesh2' }, { id: 'anchor2' }] - } - }, - { - id: 'mesh2', - anchorable: { - anchors: [ - { id: 'anchor3', snappedId: 'mesh3' }, - { id: 'anchor4', snappedId: 'unknown' } - ] - } - }, - { - id: 'mesh3' - } - ]) - }) - - it('returns nothing on unknown anchor', () => { - expect(unsnap('unknown', meshes, false)).toBeNull() - }) - - it('can throw on unknown anchor', () => { - expect(() => unsnap('unknown', meshes)).toThrow('No anchor with id unknown') - }) - - it('returns nothing on anchor with no snapped mesh', () => { - expect(unsnap('anchor2', meshes, false)).toBeNull() - }) - - it('can throw on anchor with no snapped mesh', () => { - expect(() => unsnap('anchor2', meshes)).toThrow( - 'Anchor anchor2 has no snapped mesh' - ) - }) - - it('returns nothing on anchor with unknown snapped mesh', () => { - expect(unsnap('anchor4', meshes, false)).toBeNull() - }) - - it('can throw on anchor with unknown snapped mesh', () => { - expect(() => unsnap('anchor4', meshes)).toThrow('No mesh with id unknown') - }) - - it('returns mesh and unsnapps it', () => { - expect(unsnap('anchor3', meshes)).toEqual(meshes[2]) - expect(meshes[1].anchorable?.anchors).toEqual([ - { id: 'anchor3', snappedId: null }, - { id: 'anchor4', snappedId: 'unknown' } - ]) - }) -}) - -describe('stackMeshes()', () => { - /** @type {Mesh[]} */ - let meshes - - beforeEach(() => { - meshes = /** @type {Mesh[]} */ ([ - { id: 'mesh0' }, - { id: 'mesh1' }, - { id: 'mesh2' }, - { id: 'mesh3' }, - { id: 'mesh4' } - ]) - }) - - it('stacks a list of meshes in order', () => { - stackMeshes(meshes) - expect(meshes).toEqual([ - { - id: 'mesh0', - stackable: { stackIds: ['mesh1', 'mesh2', 'mesh3', 'mesh4'] } - }, - ...meshes.slice(1) - ]) - }) - - it('stacks on top of an existing stack', () => { - meshes[0].stackable = { stackIds: ['mesh4', 'mesh3'] } - stackMeshes([meshes[0], ...meshes.slice(1, 3)]) - expect(meshes).toEqual([ - { - id: 'mesh0', - stackable: { stackIds: ['mesh4', 'mesh3', 'mesh1', 'mesh2'] } - }, - ...meshes.slice(1) - ]) - }) - - it('do nothing on a stack of one', () => { - stackMeshes(meshes.slice(0, 1)) - expect(meshes).toEqual([{ id: 'mesh0' }, ...meshes.slice(1)]) - }) -}) - -describe('decrement()', () => { - it('ignores non quantifiable meshes', () => { - const mesh = /** @type {Mesh} */ ({ id: 'mesh1' }) - expect(decrement(mesh, false)).toBeNull() - expect(mesh).toEqual({ id: 'mesh1' }) - }) - - it('can throw on non quantifiable meshes', () => { - expect(() => decrement(/** @type {Mesh} */ ({ id: 'mesh1' }))).toThrow( - 'Mesh mesh1 is not quantifiable or has a quantity of 1' - ) - }) - - it('ignores quantifiable mesh of 1', () => { - const mesh = /** @type {Mesh} */ ({ - id: 'mesh1', - quantifiable: { quantity: 1 } - }) - expect(decrement(mesh, false)).toBeNull() - expect(mesh).toEqual({ id: 'mesh1', quantifiable: { quantity: 1 } }) - }) - - it('can throw on quantifiable mesh of 1', () => { - expect(() => - decrement( - /** @type {Mesh} */ ({ id: 'mesh1', quantifiable: { quantity: 1 } }) - ) - ).toThrow('Mesh mesh1 is not quantifiable or has a quantity of 1') - }) - - it('decrements a quantifiable mesh by 1', () => { - const mesh = /** @type {Mesh} */ ({ - id: 'mesh1', - quantifiable: { quantity: 6 } - }) - expect(decrement(mesh)).toEqual({ - id: expect.stringMatching(/^mesh1-/), - quantifiable: { quantity: 1 } - }) - expect(mesh).toEqual({ id: 'mesh1', quantifiable: { quantity: 5 } }) - }) -}) - -describe('buildCameraPosition()', () => { - it('applies all defaults', () => { - const playerId = faker.string.uuid() - expect(buildCameraPosition({ playerId })).toEqual({ - playerId, - index: 0, - target: [0, 0, 0], - alpha: (Math.PI * 3) / 2, - beta: Math.PI / 8, - elevation: 35, - hash: '0-0-0-4.71238898038469-0.39269908169872414-35' - }) - }) - - it('throws on missing player id', () => { - expect(() => buildCameraPosition({})).toThrow( - 'camera position requires playerId' - ) - }) - - it('uses provided data and computes hash', () => { - const playerId = faker.string.uuid() - const index = faker.number.int() - const alpha = faker.number.int() - const beta = faker.number.int() - const elevation = faker.number.int() - const target = [faker.number.int(), faker.number.int(), faker.number.int()] - expect( - buildCameraPosition({ playerId, index, alpha, beta, elevation, target }) - ).toEqual({ - playerId, - index, - target, - alpha, - beta, - elevation, - hash: `${target[0]}-${target[1]}-${target[2]}-${alpha}-${beta}-${elevation}` - }) - }) -}) +import { getParameterSchema } from '../../src/utils/games.js' describe('getParameterSchema()', () => { const askForParameters = vi.fn() const kind = faker.lorem.word() - const game = /** @type {StartedGameData} */ ({ + const game = /** @type {import('@tabulous/types').StartedGame} */ ({ kind, meshes: [{ id: faker.string.uuid() }] }) - /** @type {Player} */ + /** @type {import('@tabulous/types').Player} */ const player = { id: faker.string.uuid(), username: faker.person.fullName(), @@ -1178,253 +75,71 @@ describe('getParameterSchema()', () => { }) it('enriches image metadatas', async () => { - const { schema } = /** @type {GameParameters} */ ( - await getParameterSchema({ - descriptor: { - askForParameters: () => ({ - type: 'object', - properties: { - suite: { - type: 'string', - enum: ['clubs', 'spades'], - nullable: true, - metadata: { - images: { - clubs: 'clubs.png', - spades: 'spades.png' + expect( + ( + await getParameterSchema({ + descriptor: { + askForParameters: () => ({ + type: 'object', + properties: { + suite: { + type: 'string', + enum: ['clubs', 'spades'], + nullable: true, + metadata: { + images: { + clubs: 'clubs.png', + spades: 'spades.png' + } } + }, + side: { + type: 'string', + enum: ['white', 'black'], + nullable: true } - }, - side: { - type: 'string', - enum: ['white', 'black'], - nullable: true } - } - }) - }, - game, - player - }) - ) - expect(schema.properties.suite.metadata.images).toEqual({ + }) + }, + game, + player + }) + )?.schema.properties.suite.metadata.images + ).toEqual({ clubs: `/${kind}/images/clubs.png`, spades: `/${kind}/images/spades.png` }) }) it('does not enrich image absolute metadata', async () => { - const { schema } = /** @type {GameParameters} */ ( - await getParameterSchema({ - descriptor: { - askForParameters: () => ({ - type: 'object', - properties: { - suite: { - type: 'string', - enum: ['clubs', 'spades'], - nullable: true, - metadata: { - images: { - clubs: '/clubs.png', - spades: '#spades.png' + expect( + ( + await getParameterSchema({ + descriptor: { + askForParameters: () => ({ + type: 'object', + properties: { + suite: { + type: 'string', + enum: ['clubs', 'spades'], + nullable: true, + metadata: { + images: { + clubs: '/clubs.png', + spades: '#spades.png' + } } } } - } - }) - }, - game, - player - }) - ) - expect(schema.properties.suite.metadata.images).toEqual({ + }) + }, + game, + player + }) + )?.schema.properties.suite.metadata.images + ).toEqual({ clubs: `/clubs.png`, spades: `#spades.png` }) }) }) - -describe('findAvailableValues()', () => { - const colors = ['red', 'green', 'blue'] - - it('returns all possible values when there are no preferences', () => { - expect(findAvailableValues([], 'color', colors)).toEqual(colors) - }) - - it('returns nothing when all possible values were used', () => { - expect( - findAvailableValues( - colors.map(color => ({ color, playerId: '' })), - 'color', - colors - ) - ).toEqual([]) - }) - - it('returns available values', () => { - expect( - findAvailableValues( - [ - { color: 'red', playerId: '' }, - { color: 'lime', playerId: '' }, - { color: 'azure', playerId: '' }, - { color: 'blue', playerId: '' } - ], - 'color', - colors - ) - ).toEqual(['green']) - }) - - it('ignores unknown preference name', () => { - expect( - findAvailableValues( - [ - { color: 'red', playerId: '' }, - { color: 'lime', playerId: '' }, - { color: 'azure', playerId: '' }, - { color: 'blue', playerId: '' } - ], - 'unknown', - colors - ) - ).toEqual(colors) - }) -}) - -describe('reportReusedIds()', () => { - const warn = vi.spyOn(console, 'warn') - /** @type {StartedGameData} */ - const game = { - id: '', - name: 'test-game', - kind: 'test', - created: Date.now(), - locales: { en: { title: '' }, fr: { title: '' } }, - ownerId: '', - availableSeats: 0, - meshes: [], - hands: [], - preferences: [], - cameras: [], - messages: [], - playerIds: [], - guestIds: [], - history: [] - } - - beforeEach(() => { - vi.resetAllMocks() - }) - - it('does not report valid descriptor', () => { - reportReusedIds({ - ...game, - meshes: [ - { id: 'box1', shape: 'box', texture: '' }, - { id: 'box2', shape: 'box', texture: '' } - ], - hands: [ - { - playerId: 'a', - meshes: [ - { id: 'box3', shape: 'box', texture: '' }, - { id: 'box4', shape: 'box', texture: '' } - ] - }, - { - playerId: 'b', - meshes: [{ id: 'box5', shape: 'box', texture: '' }] - } - ] - }) - expect(warn).not.toHaveBeenCalled() - }) - - it('reports reused mesh ids', () => { - reportReusedIds({ - ...game, - meshes: [ - { id: 'box1', shape: 'box', texture: '' }, - { id: 'box2', shape: 'box', texture: '' }, - { id: 'box3', shape: 'box', texture: '' }, - { id: 'box1', shape: 'box', texture: '' } - ], - hands: [ - { - playerId: 'a', - meshes: [{ id: 'box3', shape: 'box', texture: '' }] - } - ] - }) - expect(warn).toHaveBeenCalledOnce() - expect(warn).toHaveBeenCalledWith(expect.stringContaining('box1, box3')) - }) - - it('reports reused anchor ids', () => { - reportReusedIds({ - ...game, - meshes: [ - { - id: 'box1', - shape: 'box', - texture: '', - anchorable: { anchors: [{ id: 'anchor1' }] } - }, - { id: 'box2', shape: 'box', texture: '' } - ], - hands: [ - { - playerId: 'a', - meshes: [ - { - id: 'box3', - shape: 'box', - texture: '', - anchorable: { anchors: [{ id: 'anchor1' }, { id: 'box2' }] } - } - ] - } - ] - }) - expect(warn).toHaveBeenCalledOnce() - expect(warn).toHaveBeenCalledWith(expect.stringContaining('anchor1, box2')) - }) -}) - -/** - * @param {Mesh[]} meshes - * @param {Slot & Point} [slot] - * @param {number} [count] - * @returns {Mesh} - */ -function expectStackedOnSlot(meshes, slot, count = slot?.count) { - const stack = meshes.find( - // @ts-expect-error: count is defined - ({ stackable }) => stackable?.stackIds?.length === count - 1 - ) - expect(stack).toBeDefined() - const stackedMeshes = meshes.filter( - ({ id }) => stack?.stackable?.stackIds?.includes(id) || id === stack?.id - ) - // @ts-expect-error: count is defined - expect(stackedMeshes).toHaveLength(count) - expect( - stackedMeshes.every( - ({ x, y, z }) => x === slot?.x && y === slot?.y && z === slot?.z - ) - ).toBe(true) - return /** @type {Mesh} */ (stack) -} - -/** - * @param {(Mesh & { name: string })[]} meshes - * @param {string} name - * @param {Anchor} [anchor] - */ -function expectSnappedByName(meshes, name, anchor) { - const candidates = meshes.filter(mesh => name === mesh.name) - expect(candidates).toHaveLength(1) - expect(anchor?.snappedId).toEqual(candidates[0].id) -} diff --git a/apps/server/tests/utils/logger.test.js b/apps/server/tests/utils/logger.test.js index 3830e505..d0f5c913 100644 --- a/apps/server/tests/utils/logger.test.js +++ b/apps/server/tests/utils/logger.test.js @@ -1,8 +1,4 @@ // @ts-check -/** - * @typedef {import('../../src/utils/logger').Logger} Logger - */ - import { setTimeout } from 'node:timers/promises' import { faker } from '@faker-js/faker' @@ -54,7 +50,7 @@ describe('makeLogger()', () => { describe('given a logger', () => { let name = '' - /** @type {Logger} */ + /** @type {import('pino').Logger} */ let logger beforeEach(() => { diff --git a/apps/types/index.d.ts b/apps/types/index.d.ts new file mode 100644 index 00000000..4b7c3d60 --- /dev/null +++ b/apps/types/index.d.ts @@ -0,0 +1,599 @@ +/* eslint-disable no-unused-vars */ +import type { JSONSchemaExport, JSONSchemaType } from 'ajv' + +declare module '.' { + /** A player account. */ + export interface Player { + /** unique id. */ + id: string + /** player user name. */ + username: string + /** game this player is currently playing. */ + currentGameId: string | null + /** avatar used for display. */ + avatar?: string + /** player's authentication provider, when relevant. */ + provider?: string + /** authentication provider own id, when relevant. */ + providerId?: string + /** email from authentication provider, when relevant. */ + email?: string + /** full name from the authentication provider, when relevant. */ + fullName?: string + /** whether this player has accepted terms of service. */ + termsAccepted?: boolean + /** the account password hash, when relevant. */ + password?: string + /** whether this player has elevated priviledges or not. */ + isAdmin?: boolean + /** list of copyrighted games this player has accessed to. */ + catalog?: string[] + /** whether this player could by found when searching usernames. */ + usernameSearchable?: boolean + } + + /** A Game descriptor in the game catalog */ + export interface GameDescriptor< + Parameters extends Record = object + > { + /** unique name. */ + name: string + /** all the localized data fort his item. */ + locales: ItemLocales + /** minimum seats required to play, when relevant. */ + minSeats?: number + /** maximum seats allowed, when relevant. */ + maxSeats?: number + /** minimum age suggested. */ + minAge?: number + /** maximum age suggested. */ + maxAge?: number + /** minimum time observed. */ + minTime?: number + /** copyright data, meaning this item has restricted access. */ + copyright?: Copyright + /** number of pages in the rules book, if any. */ + rulesBookPageCount?: number + /** zoom specifications for main and hand scene. */ + zoomSpec?: ZoomSpec + /** table specifications to customize visual. */ + tableSpec?: TableSpec + /** allowed colors for players and UI. */ + colors?: ColorSpec + /** action customizations. */ + actions?: ActionSpec + /** function invoked build initial game. */ + build?: Build + /** function invoked when a player joins a game for the first time. */ + addPlayer?: AddPlayer + /** function invoked to generate a joining player's parameters. */ + askForParameters?: AskForParameters + } + + /** All the localized data for a catalog item. */ + export type ItemLocales = Record & { + /** French locale */ + fr?: ItemLocale + /** English locale */ + en?: ItemLocale + } + + /** Localized data */ + export type ItemLocale = Record & { + /** catalog item title. */ + title: string + } + + /** a game author, designer or publisher */ + export interface PersonOrCompany { + /** this person/company's name */ + name: string + } + + /** game copyright data */ + export type Copyright = Record & { + /** game authors. */ + authors: PersonOrCompany[] + /** game designers. */ + designers?: PersonOrCompany[] + /** game publishers. */ + publishers?: PersonOrCompany[] + } + + /** zoom specifications for main and hand scene. */ + export interface ZoomSpec { + /** minimum zoom level allowed on the main scene. */ + min?: number + /** maximum zoom level allowed on the main scene. */ + max?: number + /** fixed zoom level for the hand scene. */ + hand?: number + } + + /** table specifications for customization. */ + export interface TableSpec { + /** minimum zoom level allowed on the main scene. */ + width?: number + /** maximum zoom level allowed on the main scene. */ + height?: number + /** texture image file path, or hex color. */ + texture?: string + } + + /** players and UI color customization. */ + export interface ColorSpec { + /** base hex color. */ + base?: string + /** primary hex color. */ + primary?: string + /** secondary hex color. */ + secondary?: string + /** list of possible colors for players. */ + players?: string[] + } + + export type ActionName = + | 'decrement' + | 'detail' + | 'draw' + | 'flip' + | 'flipAll' + | 'increment' + | 'play' + | 'pop' + | 'push' + | 'random' + | 'reorder' + | 'rotate' + | 'setFace' + | 'snap' + | 'toggleLock' + | 'unsnap' + | 'unsnapAll' + + /** action buttons configuration. */ + export interface ActionSpec { + /** actions assigned to tab/left click, if any. */ + button1?: ActionName[] + /** actions assigned to long 2 fingers tap/long left click, if any */ + button2?: ActionName[] + } + + /** Function to build a game setup. */ + export type Build = () => GameSetup | Promise + + /** Function invoked when adding a player to the game. */ + export type AddPlayer< + Parameters extends Record, + Game extends GameData = GameData + > = ( + game: Game, + guest: Player, + parameters: Parameters + ) => Game | Promise + + export type Schema> = + JSONSchemaType + + /** Parameters required to join a given game. */ + export type GameParameters> = + GameData & { + /** a JSON Export type Definition schema used to validate required parmeters. */ + schema: Schema + /** validation error, when relevant. */ + error?: string + } + + /** Function invoked when resolving game parameters for a joining player. */ + export type AskForParameters = (args: { + game: GameData + player: Player + }) => ?(Schema | Promise>) + + /** + * Setup for a given game instance, including meshes, bags and slots. + * Meshes could be cards, round tokens, rounded tiles... They must have an id. + * Use bags to randomize meshes, and use slots to assign them to given positions (and with specific properties). + * Slot will stack onto meshes already there, optionnaly snapping them to an anchor. + * Meshes remaining in bags after processing all slots will be removed. + */ + export interface GameSetup { + /** all meshes. */ + meshes?: Mesh[] + /** map of randomized bags, as a list of mesh ids. */ + bags?: Map + /** a list of position slots */ + slots?: Slot[] + } + + /** + * Position slot for meshes. + * A slot draw a mesh from a bag (`bagId`), and assigns it provided propertis (x, y, z, texture, movable...). + * + * Slots without an anchor picks as many meshes as needed (count), and stack them. + * When there is no count, they exhaust the bag. + * + * Slots with an anchor (`anchorId`) draw as many mesh as needed (count), + * snap the first to any other mesh with that anchor, and stack others on top of it. + * `anchorId` may be a chain of anchors: "column-2.bottom.top" draw and snaps on anchor "top", of a mesh snapped on + * anchor "bottom", of a mesh snapped on anchor "column-2". + * If such configuration can not be found, the slot is ignored. + * + * NOTES: + * 1. when using multiple slots on the same bag, slot with no count nor anchor MUST COME LAST. + * 2. meshes remaining in bags after processing all slots will be removed. + */ + export type Slot = { + /** id of a bag to pick meshes. */ + bagId: string + /** id of the anchor to snap to. */ + anchorId?: string + /** number of mesh drawn from bag. */ + count?: number + } & Partial + + /** an active game, or a lobby */ + export interface Game { + /** unique game id. */ + id: string + /** game creation timestamp. */ + created: number + /** id of the player who created this game */ + ownerId: string + /** game kind (relates with game descriptor). Unset means a waiting room. */ + kind?: string + /** (active) player ids. */ + playerIds: string[] + /** guest (future player) ids. */ + guestIds: string[] + } + + /** Data of a game instance after setup was applied. */ + export type GameData = Game & + GameDescriptor & { + /** number of seats still available. */ + availableSeats: number + /** game meshes. */ + meshes: Mesh[] + /** game discussion thread, if any. */ + messages: Message[] + /** player's saved camera positions, if any. */ + cameras: CameraPosition[] + /** player's private hands, id any. */ + hands: Hand[] + /** preferences for each players. */ + preferences: PlayerPreference[] + /** player actions and move history. */ + history: HistoryRecord[] + } + + /** Data of a started game. */ + export type StartedGame = GameData & + Required> + + /** Supported mesh shapes. */ + export type Shape = + | 'box' + | 'card' + | 'custom' + | 'die' + | 'prism' + | 'roundedTile' + | 'roundToken' + + export interface Point { + /** 3D coordinate along the X axis (horizontal). */ + x?: number + /** 3D coordinate along the Z axis (vertical). */ + z?: number + /** 3D coordinate along the Y axis (altitude). */ + y?: number + } + + export interface Dimension { + /** mesh's width (X axis), for boxes, cards, prisms, and rounded tiles. */ + width?: number + /** mesh's height (Y axis), for boxes, cards, prisms, rounded tokens and rounded tiles. */ + height?: number + /** mesh's depth (Z axis), for boxes, cards, and rounded tiles. */ + depth?: number + /** mesh's diameter (X+Z axis), for round tokens and dice. */ + diameter?: number + } + + /** A 3D mesh, with a given shape. Some of its attribute are shape-specific. */ + export type Mesh = { + /** the mesh shape. */ + shape: Shape + /** mesh unique id. */ + id: string + /** path to its texture file or hex color. */ + texture: string + /** list of face UV (Vector4 components), to map texture on the mesh (depends on its shape). */ + faceUV?: number[][] + /** initial transformation baked into the mesh's vertices. */ + transform?: InitialTransform + /** corner radius, for rounded tiles. */ + borderRadius?: number + /** path to the custom mesh OBJ file. */ + file?: string + /** number of edges, for prisms. */ + edges?: number + /** number of faces, for dice. */ + faces?: number + /** if this mesh could be detailed, contains details. */ + detailable?: DetailableState + /** if this mesh could be moved, contains move state. */ + movable?: MovableState + /** if this mesh could be flipped, contains flip state. */ + flippable?: FlippableState + /** if this mesh could be rotated along Y axis, contains rotation state. */ + rotable?: RotableState + /** if this mesh has anchors, contains their state. */ + anchorable?: AnchorableState + /** if this mesh could be stack under others, contains stack state. */ + stackable?: StackableState + /** if this mesh could be drawn in player hand, contains coonfiguration. */ + drawable?: DrawableState + /** if this mesh could be locked, contains (un)locke state. */ + lockable?: LockableState + /** if instances of this mesh could grouped together and split, contains quantity state. */ + quantifiable?: QuantifiableState + /** if this mesh could be randomized, contains face state. */ + randomizable?: RandomizableState + } & Point & + Dimension + + /** 3D transforation baked into a mesh's vertices. */ + export interface InitialTransform { + /** rotation along the Y axis. */ + yaw?: number + /** rotation along the X axis. */ + pitch?: number + /** rotation along the Z axis. */ + roll?: number + /** scale applied along the X axis. */ + scaleX?: number + /** scale applied along the Y axis. */ + scaleY?: number + /** scale applied along the Z axis. */ + scaleZ?: number + } + + /** State for detailable meshes. */ + export interface DetailableState { + /** path to its front image. */ + frontImage: string + /** path to its back image, when relevant. */ + backImage?: string + } + + /** State for movable meshes. */ + export interface MovableState { + /** move animation duration, in milliseconds. */ + duration?: number + /** distance between dots of an imaginary snap grid. */ + snapDistance?: number + /** kind used when dragging and droping the mesh over targets. */ + kind?: string + /** when this mesh has serveral parts, coordinate of each part barycenter. */ + partCenters?: Point[] + } + + /** State for flippable meshes. */ + export interface FlippableState { + /** true means the back face is visible. */ + isFlipped?: boolean + /** flip animation duration, in milliseconds. */ + duration?: number + } + + /** state for flippable meshes: */ + export interface RotableState { + /** rotation angle along Y axis (yaw), in radian. */ + angle?: number + /** rotation animation duration, in milliseconds. */ + duration?: number + } + + /** State for stackable meshes. */ + export type StackableState = _Targetable & { + /** ordered list of ids for meshes stacked on top of this one. */ + stackIds?: string[] + /** stack animations duration, in milliseconds. */ + duration?: number + /** angle applied to any rotable mesh pushed to the stack. */ + angle?: number + } + + /** State for anchorable meshes. */ + export interface AnchorableState { + /** list of anchors. */ + anchors?: Anchor[] + /** snap animation duration, in milliseconds. */ + duration?: number + } + + /** A rectangular anchor definition (coordinates are relative to the parent mesh). */ + export type Anchor = { + /** this anchor id. */ + id: string + /** id of the mesh currently snapped to this anchor. */ + snappedId?: ?string + /** when set, only this player can snap meshes to this anchor. */ + playerId?: string + /** angle applied to any rotable mesh snapped to the anchor. */ + angle?: number + /** flip state applied to any flippable mesh snapped to the anchor. */ + flip?: boolean + /** when set, and when snapping a multi-part mesh, takes it barycenter into account. */ + ignoreParts?: boolean + } & Point & + Dimension & + _Targetable + + /** State for drawable meshes. */ + export interface DrawableState { + /** unflip flipped mesh when picking them in hand. */ + unflipOnPick?: boolean + /** flip flipable meshes when playing them from hand. */ + flipOnPlay?: boolean + /** set angle of rotable meshes when picking them in hand. */ + angleOnPick?: number + /** duration (in milliseconds) of the draw animation. */ + duration?: number + } + + /** State for locable mehes. */ + export interface LockableState { + /** whether this mesh is locked or not. */ + isLocked?: boolean + } + + /** State for quantifiable meshes. */ + export type QuantifiableState = _Targetable & { + /** number of items, including this one. */ + quantity?: number + /** duration (in milliseconds) when pushing individual meshes. */ + duration?: number + } + + /** State for randomizable meshes. */ + export interface RandomizableState { + /** current face set. */ + face?: number + /** duration (in milliseconds) of the random animation. The set animartion is a third of it. */ + duration?: number + /** whether face could be manually set or not. */ + canBeSet?: boolean + } + + /** A message in the discussion thread. */ + export interface Message { + /** sender id. */ + playerId: string + /** message's textual content. */ + text: string + /** creation timestamp. */ + time: number + } + + /** + * A saved Arc rotate camera position + * @see https://doc.babylonjs.com/divingDeeper/cameras/camera_introduction#arc-rotate-camera + */ + export interface CameraPosition { + /** hash for this position, to ease comparisons and change detections. */ + hash: string + /** id of the player for who this camera position is relevant. */ + playerId: string + /** 0-based index for this saved position. */ + index: number + /** 3D cooordinates of the camera target, as per Babylon's specs. */ + target: number[] + /** the longitudinal rotation, in radians. */ + alpha?: number + /** the longitudinal rotation, in radians. */ + beta: number + /** the distance from the target (Babylon's radius). */ + elevation: number + } + + /** A player's private hand. */ + export interface Hand { + /** owner id. */ + playerId: string + /** ordered list of meshes. */ + meshes: Mesh[] + } + + /** Preferences collected with game parameters for a given player. */ + export type PlayerPreference = Record & { + /** if of this player. */ + playerId: string + /** hex color for this player, if any. */ + color?: string + /** yaw (Y angle) on the table, if any. */ + angle?: number + } + + /** An action in the game history. */ + export type PlayerAction = _HistoryRecord & { + /** name of the applied action. */ + fn: ActionName + /** stringified arguments for this action. */ + argsStr: string + /** optional stringified arguments for reverting this action. */ + revertStr?: string + } + + /** A move in the game history. */ + export type PlayerMove = _HistoryRecord & { + /** absolute position. */ + pos: number[] + /** previous absolute position. */ + prev: number[] + } + + export type HistoryRecord = PlayerAction | PlayerMove + + /** A relationship between two players. */ + export interface Friendship { + /** id of the target player (origin player is implicit). */ + playerId: string + /** when true, indicates a friendship request from the target player. */ + isRequest?: boolean + /** when true, indicates a friendship request sent to the target player. */ + isProposal?: boolean + } + + /** An update on a friendship relationship. */ + export interface FriendshipUpdate { + /** player sending the update. */ + from: string + /** player receiving the update. */ + to: string + /** indicates that sender requested friendship. */ + requested?: boolean + /** indicates that sender proposed new friendship. */ + proposed?: boolean + /** whether the relationship is accepted. */ + accepted?: boolean + /** whether the relationship is decline. */ + declined?: boolean + } + + /** Used to connect to the turn server */ + export interface TurnCredentials { + /** unix timestamp representing the expiry date. */ + username: string + /** required to connect. */ + credentials: string + } +} + +/** Common properties for targets (stacks, anchors, quantifiable...) */ +type _Targetable = { + /** acceptable meshe kinds, that could be snapped to the anchor. Leave undefined to accept all. */ + kinds?: string[] + /** dimension multiplier applied to the drop target. */ + extent?: number + /** priority applied when multiple targets with same altitude apply. */ + priority?: number + /** whether this anchor is enabled or not. */ + enabled?: boolean +} + +/** Common properties for history records. */ +type _HistoryRecord = { + /** when this record happened (timestamp). */ + time: number + /** who created this record. */ + playerId: string + /** modified mesh id. */ + meshId: string + /** whether this operation happened in this player's hand. */ + fromHand: boolean + /** optional animation duration, in milliseconds. */ + duration?: number +} diff --git a/apps/types/jsconfig.json b/apps/types/jsconfig.json new file mode 100644 index 00000000..056e8f5a --- /dev/null +++ b/apps/types/jsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../jsconfig.json" +} diff --git a/apps/types/package.json b/apps/types/package.json new file mode 100644 index 00000000..ef9acc76 --- /dev/null +++ b/apps/types/package.json @@ -0,0 +1,12 @@ +{ + "name": "@tabulous/types", + "version": "0.0.1", + "description": "Common types like game data and descriptors", + "type": "module", + "scripts": { + "typecheck": "tsc -p jsconfig.json --noEmit" + }, + "devDependencies": { + "ajv": "^8.12.0" + } +} diff --git a/apps/web/jsconfig.json b/apps/web/jsconfig.json index 3cf32a82..a086fc41 100644 --- a/apps/web/jsconfig.json +++ b/apps/web/jsconfig.json @@ -2,14 +2,12 @@ "extends": "./.svelte-kit/tsconfig.json", "compilerOptions": { "allowJs": true, - // "checkJs": true, - "baseUrl": ".", "esModuleInterop": true, - + "baseUrl": ".", "paths": { "@src/*": ["src/*"], "@tests/*": ["tests/*"], - "@tabulous/*": ["../*"] + "@tabulous/server/*": ["../server/src/*"] }, "sourceMap": true, "strict": true, diff --git a/apps/web/package.json b/apps/web/package.json index 9ca2dcb3..71a9a2bb 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -40,6 +40,7 @@ "@sveltejs/adapter-vercel": "^3.0.3", "@sveltejs/kit": "^1.25.0", "@sveltejs/vite-plugin-svelte": "^2.4.6", + "@tabulous/types": "workspace:*", "@testing-library/dom": "^9.3.1", "@testing-library/jest-dom": "^6.1.3", "@testing-library/svelte": "^4.0.3", diff --git a/apps/web/src/3d/behaviors/anchorable.js b/apps/web/src/3d/behaviors/anchorable.js index a022e7b0..29bee760 100644 --- a/apps/web/src/3d/behaviors/anchorable.js +++ b/apps/web/src/3d/behaviors/anchorable.js @@ -1,19 +1,4 @@ // @ts-check -/** - * @typedef {import('@babylonjs/core').Mesh} Mesh - * @typedef {import('@babylonjs/core').Scene} Scene - * @typedef {import('@tabulous/server/src/graphql').AnchorableState} AnchorableState - * @typedef {import('@tabulous/server/src/graphql').ActionName} ActionName - * @typedef {import('@src/3d/behaviors/stackable').StackBehavior} StackBehavior - * @typedef {import('@src/3d/behaviors/targetable').DropDetails} DropDetails - * @typedef {import('@src/3d/managers/move').MoveDetails} MoveDetails - * @typedef {import('@src/3d/managers/target').SingleDropZone} SingleDropZone - */ -/** - * @template T - * @typedef {import('@babylonjs/core').Observer} Observer - */ - import { Vector3 } from '@babylonjs/core/Maths/math.vector.js' import { makeLogger } from '../../utils/logger' @@ -30,7 +15,7 @@ import { import { AnchorBehaviorName } from './names' import { TargetBehavior } from './targetable' -/** @typedef {AnchorableState & Required>} RequiredAnchorableState */ +/** @typedef {import('@tabulous/types').AnchorableState & Required>} RequiredAnchorableState */ const logger = makeLogger(AnchorBehaviorName) @@ -38,26 +23,23 @@ export class AnchorBehavior extends TargetBehavior { /** * Creates behavior to make a mesh anchorable: it has one or several anchors to snap other meshes. * Each anchor can take up to one mesh only. - * @param {AnchorableState} state - behavior state. - * @param {import('@src/3d/managers').Managers} managers - current managers. + * @param {import('@tabulous/types').AnchorableState} state - behavior state. + * @param {import('../managers').Managers} managers - current managers. */ constructor(state, managers) { super({}, managers) - /** @type {RequiredAnchorableState} state - the behavior's current state. */ + /** the behavior's current state. */ this.state = /** @type {RequiredAnchorableState} */ (state) - /** @protected @type {?Observer} */ + /** @protected @type {?import('@babylonjs/core').Observer} */ this.dropObserver = null - /** @protected @type {?Observer} */ + /** @protected @type {?import('@babylonjs/core').Observer} */ this.moveObserver = null - /** @protected @type {?Observer}} */ + /** @protected @type {?import('@babylonjs/core').Observer}} */ this.actionObserver = null - /** @internal @type {Map} */ + /** @internal @type {Map} */ this.zoneBySnappedId = new Map() } - /** - * @property {string} name - this behavior's constant name. - */ get name() { return AnchorBehaviorName } @@ -70,7 +52,7 @@ export class AnchorBehavior extends TargetBehavior { * - the `unsnapAll()` method. * It binds to its drop observable to snap dropped meshes on the anchor (unless the anchor is already full). * It binds to the drag manager to unsnap dragged meshes, when dragged independently from the current mesh - * @param {Mesh} mesh - which becomes anchorable. + * @param {import('@babylonjs/core').Mesh} mesh - which becomes anchorable. */ attach(mesh) { super.attach(mesh) @@ -97,7 +79,9 @@ export class AnchorBehavior extends TargetBehavior { this.zoneBySnappedId.has(mesh?.id) && !( this.managers.selection.meshes.has(mesh) && - this.managers.selection.meshes.has(/** @type {Mesh} */ (this.mesh)) + this.managers.selection.meshes.has( + /** @type {import('@babylonjs/core').Mesh} */ (this.mesh) + ) ) ) { this.unsnap(mesh.id) @@ -233,7 +217,7 @@ export class AnchorBehavior extends TargetBehavior { /** * Revert snap and unsnap actions. Ignores other actions - * @param {ActionName} action - reverted action. + * @param {import('@tabulous/types').ActionName} action - reverted action. * @param {any[]} [args] - reverted arguments. */ async revert(action, args = []) { @@ -259,7 +243,7 @@ export class AnchorBehavior extends TargetBehavior { /** * Updates this behavior's state and mesh to match provided data. - * @param {AnchorableState} state - state to update to. + * @param {import('@tabulous/types').AnchorableState} state - state to update to. */ fromState({ anchors = [], duration = 100 } = {}) { if (!this.mesh) { @@ -394,7 +378,7 @@ async function internalUnsnap( /** * @param {AnchorBehavior} behavior - concerned behavior. * @param {string} snappedId - snapped mesh id. - * @param {SingleDropZone} zone - drop zone. + * @param {import('../managers').SingleDropZone} zone - drop zone. * @param {boolean} [loading=false] - whether the scene is loading. */ async function snapToAnchor(behavior, snappedId, zone, loading = false) { @@ -441,8 +425,8 @@ async function snapToAnchor(behavior, snappedId, zone, loading = false) { /** * @param {AnchorBehavior} behavior - concerned behavior. - * @param {SingleDropZone} zone - drop zone. - * @param {Mesh} snapped - snapped mesh. + * @param {import('../managers').SingleDropZone} zone - drop zone. + * @param {import('@babylonjs/core').Mesh} snapped - snapped mesh. */ function setAnchor(behavior, zone, snapped) { const { @@ -459,8 +443,8 @@ function setAnchor(behavior, zone, snapped) { /** * @param {AnchorBehavior} behavior - concerned behavior. - * @param {SingleDropZone} zone - drop zone. - * @param {Mesh} snapped - unsnapped mesh. + * @param {import('../managers').SingleDropZone} zone - drop zone. + * @param {import('@babylonjs/core').Mesh} snapped - unsnapped mesh. */ function unsetAnchor(behavior, zone, snapped) { const { @@ -475,7 +459,7 @@ function unsetAnchor(behavior, zone, snapped) { } /** - * @param {Scene|undefined} scene - scene containing meshes. + * @param {import('@babylonjs/core').Scene|undefined} scene - scene containing meshes. * @param {string} meshId - searched mesh id. * @returns list of stacked meshes, if any. */ @@ -484,6 +468,8 @@ function getMeshList(scene, meshId) { if (!mesh) { return null } - const stackable = /** @type {?StackBehavior} */ (getTargetableBehavior(mesh)) + const stackable = /** @type {?import('.').StackBehavior} */ ( + getTargetableBehavior(mesh) + ) return stackable?.stack ? [...stackable.stack] : [mesh] } diff --git a/apps/web/src/3d/behaviors/animatable.js b/apps/web/src/3d/behaviors/animatable.js index 91455185..5731c2f6 100644 --- a/apps/web/src/3d/behaviors/animatable.js +++ b/apps/web/src/3d/behaviors/animatable.js @@ -1,10 +1,4 @@ // @ts-check -/** - * @typedef {import('@babylonjs/core').Mesh} Mesh - * @typedef {import('@babylonjs/core').Vector3} Vector3 - * @typedef {import('@src/3d/utils').Vector3KeyFrame} Vector3KeyFrame - */ - import { Animation } from '@babylonjs/core/Animations/animation' import { runAnimation } from '../utils/behaviors' @@ -21,7 +15,7 @@ export class AnimateBehavior { * @param {number} [params.frameRate=60] - number of frames per second. */ constructor({ frameRate } = {}) { - /** @type {?Mesh} mesh - the related mesh. */ + /** @type {?import('@babylonjs/core').Mesh} mesh - the related mesh. */ this.mesh = null /** @type {number} frameRate - number of frames per second. */ this.frameRate = frameRate ?? 60 @@ -43,9 +37,6 @@ export class AnimateBehavior { ) } - /** - * @property {string} name - this behavior's constant name. - */ get name() { return AnimateBehaviorName } @@ -58,7 +49,7 @@ export class AnimateBehavior { /** * Attaches this behavior to a mesh. - * @param {Mesh} mesh - which becomes detailable. + * @param {import('@babylonjs/core').Mesh} mesh - which becomes detailable. */ attach(mesh) { this.mesh = mesh @@ -77,8 +68,8 @@ export class AnimateBehavior { * - applies gravity (if requested) * - returns * Does nothing if the mesh is already being animated. - * @param {Vector3} to - the desired new absolute position. - * @param {?Vector3} rotation - its final rotation (set to null to leave unmodified). + * @param {import('@babylonjs/core').Vector3} to - the desired new absolute position. + * @param {?import('@babylonjs/core').Vector3} rotation - its final rotation (set to null to leave unmodified). * @param {number} duration - move duration (in milliseconds). * @param {boolean} [gravity=true] - applies gravity at the end. */ @@ -91,7 +82,7 @@ export class AnimateBehavior { { animation: moveAnimation, duration: mesh.getEngine().isLoading ? 0 : duration, - keys: /** @type {Vector3KeyFrame[]} */ ([ + keys: /** @type {import('../utils').Vector3KeyFrame[]} */ ([ { frame: 0, values: convertToLocal(mesh.absolutePosition, mesh).asArray() @@ -104,7 +95,7 @@ export class AnimateBehavior { frameSpecs.push({ animation: rotateAnimation, duration: mesh.getEngine().isLoading ? 0 : duration, - keys: /** @type {Vector3KeyFrame[]} */ ([ + keys: /** @type {import('../utils').Vector3KeyFrame[]} */ ([ { frame: 0, values: mesh.rotation.asArray() }, { frame: 100, values: rotation.asArray() } ]) diff --git a/apps/web/src/3d/behaviors/detailable.js b/apps/web/src/3d/behaviors/detailable.js index 16e5bd39..de268864 100644 --- a/apps/web/src/3d/behaviors/detailable.js +++ b/apps/web/src/3d/behaviors/detailable.js @@ -1,10 +1,4 @@ // @ts-check -/** - * @typedef {import('@babylonjs/core').Mesh} Mesh - * @typedef {import('@tabulous/server/src/graphql').DetailableState} DetailableState - * @typedef {import('../utils').ScreenPosition} ScreenPosition - */ - import { attachFunctions, attachProperty, @@ -16,21 +10,18 @@ import { DetailBehaviorName, StackBehaviorName } from './names' export class DetailBehavior { /** * Creates behavior to get details of a mesh. - * @param {DetailableState} state - behavior state. - * @param {import('@src/3d/managers').Managers} managers - current managers. + * @param {import('@tabulous/types').DetailableState} state - behavior state. + * @param {import('../managers').Managers} managers - current managers. */ constructor(state, managers) { /** @internal */ this.managers = managers - /** @type {?Mesh} mesh - the related mesh. */ + /** @type {?import('@babylonjs/core').Mesh} mesh - the related mesh. */ this.mesh = null - /** @type {DetailableState} state - the behavior's current state. */ + /** @type {import('@tabulous/types').DetailableState} state - the behavior's current state. */ this.state = state } - /** - * @property {string} name - this behavior's constant name. - */ get name() { return DetailBehaviorName } @@ -46,7 +37,7 @@ export class DetailBehavior { * - `frontImage` property. * - `backImage` property. * - the `detail()` method. - * @param {Mesh} mesh - which becomes detailable. + * @param {import('@babylonjs/core').Mesh} mesh - which becomes detailable. */ attach(mesh) { this.mesh = mesh @@ -67,7 +58,7 @@ export class DetailBehavior { if (!this.mesh) return const stackable = this.mesh.getBehaviorByName(StackBehaviorName) this.managers.control.onDetailedObservable.notifyObservers({ - position: /** @type {ScreenPosition} */ ( + position: /** @type {import('../utils').ScreenPosition} */ ( getMeshScreenPosition(this.mesh) ), images: /** @type {string[]} */ ( @@ -81,7 +72,7 @@ export class DetailBehavior { /** * Updates this behavior's state and mesh to match provided data. - * @param {DetailableState} state - state to update to. + * @param {import('@tabulous/types').DetailableState} state - state to update to. */ fromState({ frontImage = '', backImage }) { if (!this.mesh) { diff --git a/apps/web/src/3d/behaviors/drawable.js b/apps/web/src/3d/behaviors/drawable.js index f195f578..80003bb4 100644 --- a/apps/web/src/3d/behaviors/drawable.js +++ b/apps/web/src/3d/behaviors/drawable.js @@ -1,13 +1,4 @@ // @ts-check -/** - * @typedef {import('@babylonjs/core').Mesh} Mesh - * @typedef {import('@tabulous/server/src/graphql').ActionName} ActionName - * @typedef {import('@tabulous/server/src/graphql').DrawableState} DrawableState - * @typedef {import('@tabulous/server/src/graphql').Mesh} SerializedMesh - * @typedef {import('@src/3d/utils').FloatKeyFrame} FloatKeyFrame - * @typedef {import('@src/3d/utils').Vector3KeyFrame} Vector3KeyFrame - */ - import { Animation } from '@babylonjs/core/Animations/animation' import { actionNames } from '../utils/actions' @@ -21,19 +12,19 @@ import { isAnimationInProgress } from '../utils/mesh' import { AnimateBehavior } from './animatable' import { DrawBehaviorName } from './names' -/** @typedef {Required} RequiredDrawableState */ +/** @typedef {Required} RequiredDrawableState */ export class DrawBehavior extends AnimateBehavior { /** * Creates behavior to draw mesh from and to player's hand. - * @param {DrawableState} state - behavior state. - * @param {import('@src/3d/managers').Managers} managers - current managers. + * @param {import('@tabulous/types').DrawableState} state - behavior state. + * @param {import('../managers').Managers} managers - current managers. */ constructor(state, managers) { super() /** @internal */ this.managers = managers - /** @type {RequiredDrawableState} state - the behavior's current state. */ + /** the behavior's current state. */ this.state = /** @type {RequiredDrawableState} */ (state) /** @protected @type {Animation} */ this.fadeAnimation = new Animation( @@ -45,9 +36,6 @@ export class DrawBehavior extends AnimateBehavior { ) } - /** - * @property {string} name - this behavior's constant name. - */ get name() { return DrawBehaviorName } @@ -64,7 +52,7 @@ export class DrawBehavior extends AnimateBehavior { * - the `play()` method. * - the `drawable` getter. * - the `playable` getter. - * @param {Mesh} mesh - which becomes drawable. + * @param {import('@babylonjs/core').Mesh} mesh - which becomes drawable. */ attach(mesh) { this.mesh = mesh @@ -81,7 +69,7 @@ export class DrawBehavior extends AnimateBehavior { /** * Draws the related mesh with an animation into the player's hand: * - delegates draw to hand manager, who will call animateToHand(). - * @param {SerializedMesh} [state] - when applying drawn from peers, state of the drawn mesh. + * @param {import('@tabulous/types').Mesh} [state] - when applying drawn from peers, state of the drawn mesh. * @param {string} [playerId] - the peer who drew mesh, if any. */ async draw(state, playerId) { @@ -104,7 +92,7 @@ export class DrawBehavior extends AnimateBehavior { /** * Revert play actions. Ignores other actions. - * @param {ActionName} action - reverted action. + * @param {import('@tabulous/types').ActionName} action - reverted action. * @param {any[]} [args] - reverted arguments. */ async revert(action, args = []) { @@ -166,7 +154,7 @@ export class DrawBehavior extends AnimateBehavior { /** * Updates this behavior's state and mesh to match provided data. - * @param {DrawableState} state - state to update to. + * @param {import('@tabulous/types').DrawableState} state - state to update to. */ fromState({ duration = 750, @@ -195,9 +183,9 @@ export class DrawBehavior extends AnimateBehavior { /** * - * @param {Mesh} mesh - animated mesh. + * @param {import('@babylonjs/core').Mesh} mesh - animated mesh. * @param {boolean} [invert=false] - whether to invert or not. - * @returns {Promise<{ fadeKeys: FloatKeyFrame[], moveKeys: Vector3KeyFrame[] }>} generated key frames. + * @returns {Promise<{ fadeKeys: import('../utils').FloatKeyFrame[], moveKeys: import('../utils').Vector3KeyFrame[] }>} generated key frames. */ async function buildAnimationKeys(mesh, invert = false) { // delay so that all observer of onAction to perform: we need the mesh to be have not parents before getting its position diff --git a/apps/web/src/3d/behaviors/flippable.js b/apps/web/src/3d/behaviors/flippable.js index 12f15ecf..45a0d964 100644 --- a/apps/web/src/3d/behaviors/flippable.js +++ b/apps/web/src/3d/behaviors/flippable.js @@ -1,10 +1,4 @@ // @ts-check -/** - * @typedef {import('@babylonjs/core').Mesh} Mesh - * @typedef {import('@tabulous/server/src/graphql').ActionName} ActionName - * @typedef {import('@tabulous/server/src/graphql').FlippableState} FlippableState - */ - import { makeLogger } from '../../utils/logger' import { actionNames } from '../utils/actions' import { @@ -19,7 +13,7 @@ import { getDimensions, isAnimationInProgress } from '../utils/mesh' import { AnimateBehavior } from './animatable' import { FlipBehaviorName } from './names' -/** @typedef {FlippableState & Required>} RequiredFlippableState */ +/** @typedef {import('@tabulous/types').FlippableState & Required>} RequiredFlippableState */ const Tolerance = 0.000001 @@ -28,20 +22,17 @@ const logger = makeLogger(FlipBehaviorName) export class FlipBehavior extends AnimateBehavior { /** * Creates behavior to make a mesh flippable with animation. - * @param {FlippableState} state - behavior state. - * @param {import('@src/3d/managers').Managers} managers - current managers. + * @param {import('@tabulous/types').FlippableState} state - behavior state. + * @param {import('../managers').Managers} managers - current managers. */ constructor(state, managers) { super() /** @internal */ this.managers = managers - /** @type {RequiredFlippableState} state - the behavior's current state. */ + /** the behavior's current state. */ this.state = /** @type {RequiredFlippableState} */ (state) } - /** - * @property {string} name - this behavior's constant name. - */ get name() { return FlipBehaviorName } @@ -53,7 +44,7 @@ export class FlipBehavior extends AnimateBehavior { * It initializes its rotation according to the flip status. * * It observes other actions so it could flip this mesh when snapped to an anchor which requires it. - * @param {Mesh} mesh - which becomes detailable. + * @param {import('@babylonjs/core').Mesh} mesh - which becomes detailable. */ attach(mesh) { super.attach(mesh) @@ -74,7 +65,7 @@ export class FlipBehavior extends AnimateBehavior { /** * Revert flip actions. Ignores other actions - * @param {ActionName} action - reverted action. + * @param {import('@tabulous/types').ActionName} action - reverted action. */ async revert(action) { if (action === actionNames.flip && this.mesh) { @@ -84,7 +75,7 @@ export class FlipBehavior extends AnimateBehavior { /** * Updates this behavior's state and mesh to match provided data. - * @param {FlippableState} state - state to update to. + * @param {import('@tabulous/types').FlippableState} state - state to update to. */ fromState({ isFlipped = false, duration = 500 } = {}) { if (!this.mesh) { diff --git a/apps/web/src/3d/behaviors/index.js b/apps/web/src/3d/behaviors/index.js index 552f0032..f7113301 100644 --- a/apps/web/src/3d/behaviors/index.js +++ b/apps/web/src/3d/behaviors/index.js @@ -1,3 +1,4 @@ +// @ts-check export * from './anchorable' export * from './animatable' export * from './detailable' diff --git a/apps/web/src/3d/behaviors/lockable.js b/apps/web/src/3d/behaviors/lockable.js index 1198a7df..f535090e 100644 --- a/apps/web/src/3d/behaviors/lockable.js +++ b/apps/web/src/3d/behaviors/lockable.js @@ -1,37 +1,29 @@ // @ts-check -/** - * @typedef {import('@babylonjs/core').Mesh} Mesh - * @typedef {import('@tabulous/server/src/graphql').ActionName} ActionName - * @typedef {import('@tabulous/server/src/graphql').LockableState} LockableState - */ - import { makeLogger } from '../../utils/logger' import { actionNames } from '../utils/actions' import { attachFunctions, attachProperty } from '../utils/behaviors' +import { MoveBehavior } from './movable' import { LockBehaviorName, MoveBehaviorName } from './names' -/** @typedef {LockableState & Required>} RequiredLockableState */ +/** @typedef {import('@tabulous/types').LockableState & Required>} RequiredLockableState */ const logger = makeLogger(LockBehaviorName) export class LockBehavior { /** * Creates behavior to lock some actions on a mesh, by acting on other behaviors. - * @param {LockableState} state - behavior state. - * @param {import('@src/3d/managers').Managers} managers - current managers. + * @param {import('@tabulous/types').LockableState} state - behavior state. + * @param {import('../managers').Managers} managers - current managers. */ constructor(state, managers) { /** @internal */ this.managers = managers - /** @type {?Mesh} mesh - the related mesh. */ + /** @type {?import('@babylonjs/core').Mesh} mesh - the related mesh. */ this.mesh = null - /** @type {RequiredLockableState} state - the behavior's current state. */ + /** the behavior's current state. */ this.state = { isLocked: state?.isLocked ?? false } } - /** - * @property {string} name - this behavior's constant name. - */ get name() { return LockBehaviorName } @@ -47,7 +39,7 @@ export class LockBehavior { * - `isLocked` property. * - the `toggleLock()` method. * It also enables or disables companion behaviors based on desired state. - * @param {Mesh} mesh - which becomes detailable. + * @param {import('@babylonjs/core').Mesh} mesh - which becomes detailable. */ attach(mesh) { this.mesh = mesh @@ -74,7 +66,7 @@ export class LockBehavior { /** * Revert flip actions. Ignores other actions - * @param {ActionName} action - reverted action. + * @param {import('@tabulous/types').ActionName} action - reverted action. */ async revert(action) { if (action === actionNames.toggleLock && this.mesh) { @@ -84,7 +76,7 @@ export class LockBehavior { /** * Updates this behavior's state and mesh to match provided data. - * @param {LockableState} state - state to update to. + * @param {import('@tabulous/types').LockableState} state - state to update to. */ fromState({ isLocked = false } = {}) { if (!this.mesh) { @@ -117,7 +109,10 @@ function internalToggle( } } -function setEnabled(/** @type {Mesh} */ mesh, /** @type {boolean} */ enabled) { +function setEnabled( + /** @type {import('@babylonjs/core').Mesh} */ mesh, + /** @type {boolean} */ enabled +) { const behavior = mesh.getBehaviorByName(MoveBehaviorName) if ( behavior && @@ -126,7 +121,7 @@ function setEnabled(/** @type {Mesh} */ mesh, /** @type {boolean} */ enabled) { ) { logger.debug( { mesh, behavior }, - `${enabled ? 'unlocks' : 'locks'} behavior ${MoveBehaviorName}` + `${enabled ? 'unlocks' : 'locks'} behavior ${MoveBehavior.name}` ) behavior.enabled = enabled } diff --git a/apps/web/src/3d/behaviors/movable.js b/apps/web/src/3d/behaviors/movable.js index 450b2284..319f4f1f 100644 --- a/apps/web/src/3d/behaviors/movable.js +++ b/apps/web/src/3d/behaviors/movable.js @@ -1,36 +1,28 @@ // @ts-check -/** - * @typedef {import('@babylonjs/core').Mesh} Mesh - * @typedef {import('@tabulous/server/src/graphql').MovableState} MovableState - */ - import { attachProperty } from '../utils/behaviors' import { AnimateBehavior } from './animatable' import { MoveBehaviorName } from './names' -/** @typedef {MovableState & Required>} RequiredMovableState */ +/** @typedef {import('@tabulous/types').MovableState & Required>} RequiredMovableState */ export class MoveBehavior extends AnimateBehavior { /** * Creates behavior to make a mesh movable, and droppable over target zones. * When moving mesh, its final position will snap to a virtual grid. * A mesh can only be dropped onto zones with the same kind. - * @param {MovableState} state - behavior state. - * @param {import('@src/3d/managers').Managers} managers - current managers. + * @param {import('@tabulous/types').MovableState} state - behavior state. + * @param {import('../managers').Managers} managers - current managers. */ constructor(state, managers) { super() /** @internal */ this.managers = managers - /** @type {RequiredMovableState} state - the behavior's current state. */ + /** the behavior's current state. */ this.state = /** @type {RequiredMovableState} */ (state) /** @type {boolean} enabled - activity status (true by default). */ this.enabled = true } - /** - * @property {string} name - this behavior's constant name. - */ get name() { return MoveBehaviorName } @@ -38,7 +30,7 @@ export class MoveBehavior extends AnimateBehavior { /** * Attaches this behavior to a mesh, registering it to the drag manager. Adds to the mesh metadata: * - `partCenters` property. - * @param {Mesh} mesh - which becomes movable. + * @param {import('@babylonjs/core').Mesh} mesh - which becomes movable. */ attach(mesh) { super.attach(mesh) @@ -60,7 +52,7 @@ export class MoveBehavior extends AnimateBehavior { /** * Updates this behavior's state and mesh to match provided data. - * @param {MovableState} state - state to update to. + * @param {import('@tabulous/types').MovableState} state - state to update to. */ fromState({ kind, partCenters, snapDistance = 0.25, duration = 100 } = {}) { if (!this.mesh) { diff --git a/apps/web/src/3d/behaviors/quantifiable.js b/apps/web/src/3d/behaviors/quantifiable.js index 4bc2c30c..3bbbca69 100644 --- a/apps/web/src/3d/behaviors/quantifiable.js +++ b/apps/web/src/3d/behaviors/quantifiable.js @@ -1,19 +1,4 @@ // @ts-check -/** - * @typedef {import('@babylonjs/core').Mesh} Mesh - * @typedef {import('@tabulous/server/src/graphql').ActionName} ActionName - * @typedef {import('@tabulous/server/src/graphql').QuantifiableState} QuantifiableState - * @typedef {import('@tabulous/server/src/graphql').Mesh} SerializedMesh - * @typedef {import('@src/3d/behaviors/targetable').DropZone} DropZone - * @typedef {import('@src/3d/behaviors/targetable').SingleDropZone} SingleDropZone - * @typedef {import('@src/3d/behaviors/targetable').DropDetails} DropDetails - * @typedef {import('@src/3d/managers/move').PreMoveDetails} PreMoveDetails - */ -/** - * @template T - * @typedef {import('@babylonjs/core').Observer} Observer - */ - import { Vector3 } from '@babylonjs/core/Maths/math.vector' import { makeLogger } from '../../utils/logger' @@ -28,9 +13,9 @@ import { createMeshFromState } from '../utils/scene-loader' import { MoveBehaviorName, QuantityBehaviorName } from './names' import { TargetBehavior } from './targetable' -/** @typedef {QuantifiableState & Required>} RequiredQuantifiableState */ +/** @typedef {import('@tabulous/types').QuantifiableState & Required>} RequiredQuantifiableState */ -const logger = makeLogger('quantifiable') +const logger = makeLogger(QuantityBehaviorName) export class QuantityBehavior extends TargetBehavior { /** @@ -38,24 +23,21 @@ export class QuantityBehavior extends TargetBehavior { * and targetable (one can drop other quantifiable meshs). * Dropped meshes are destroyed while quantity is incremented. * Poped meshes are created on the fly, except when the quantity is 1. - * @param {QuantifiableState} state - behavior state. - * @param {import('@src/3d/managers').Managers} managers - current managers. + * @param {import('@tabulous/types').QuantifiableState} state - behavior state. + * @param {import('../managers').Managers} managers - current managers. */ constructor(state, managers) { super({}, managers) - /** @type {RequiredQuantifiableState} state - the behavior's current state. */ + /** the behavior's current state. */ this.state = /** @type {RequiredQuantifiableState} */ (state) - /** @protected @type {?Observer} */ + /** @protected @type {?import('@babylonjs/core').Observer} */ this.preMoveObserver = null - /** @protected @type {?Observer} */ + /** @protected @type {?import('@babylonjs/core').Observer} */ this.dropObserver = null - /** @protected @type {DropZone}} */ + /** @protected @type {import('../managers').DropZone}} */ this.dropZone } - /** - * @property {string} name - this behavior's constant name. - */ get name() { return QuantityBehaviorName } @@ -68,7 +50,7 @@ export class QuantityBehavior extends TargetBehavior { * - a `canIncrement()` function to determin whether a mesh could increment the quantity. * It binds to its drop observable to increment when dropping meshes. * It binds to the drag manager drag observable to decrement (unless quantity is 1). - * @param {Mesh} mesh - which becomes detailable. + * @param {import('@babylonjs/core').Mesh} mesh - which becomes detailable. */ attach(mesh) { super.attach(mesh) @@ -112,7 +94,7 @@ export class QuantityBehavior extends TargetBehavior { /** * Determines whether a movable mesh can increment the current one. - * @param {Mesh} mesh - tested (movable) mesh. + * @param {import('@babylonjs/core').Mesh} mesh - tested (movable) mesh. * @returns true if this mesh can increment. */ canIncrement(mesh) { @@ -153,7 +135,7 @@ export class QuantityBehavior extends TargetBehavior { */ async decrement(count = 1, withMove = false) { const { mesh, state } = this - /** @type {?Mesh} */ + /** @type {?import('@babylonjs/core').Mesh} */ let created = null if (!mesh || state.quantity === 1) return created const createdId = makeId(mesh) @@ -172,12 +154,12 @@ export class QuantityBehavior extends TargetBehavior { state.quantity -= quantity const serialized = - /** @type {SerializedMesh & { quantifiable: RequiredQuantifiableState }} */ ( + /** @type {import('@tabulous/types').Mesh & { quantifiable: RequiredQuantifiableState }} */ ( mesh.metadata.serialize() ) serialized.quantifiable.quantity = quantity serialized.id = createdId - created = /** @type {Mesh} */ ( + created = /** @type {import('@babylonjs/core').Mesh} */ ( await createMeshFromState(serialized, mesh.getScene(), this.managers) ) @@ -205,7 +187,7 @@ export class QuantityBehavior extends TargetBehavior { /** * Revert increment and decrement actions. Ignores other actions - * @param {ActionName} action - reverted action. + * @param {import('@tabulous/types').ActionName} action - reverted action. * @param {any[]} [args] - reverted arguments. */ async revert(action, args = []) { @@ -220,28 +202,34 @@ export class QuantityBehavior extends TargetBehavior { } = this const scene = mesh.getScene() await Promise.all( - states.map(async (/** @type {SerializedMesh} */ state) => { - const count = state.quantifiable?.quantity ?? 1 - this.managers.control.record({ - mesh, - fn: actionNames.decrement, - args: [count, withMove], - duration, - revert: [state.id, withMove], - isLocal: true - }) - this.state.quantity -= count - const created = await createMeshFromState(state, scene, this.managers) - if (withMove) { - created.setAbsolutePosition(mesh.absolutePosition) - await animateMove( - created, - Vector3.FromArray([state.x ?? 0, state.y ?? 0, state.z ?? 0]), - null, - duration + states.map( + async (/** @type {import('@tabulous/types').Mesh} */ state) => { + const count = state.quantifiable?.quantity ?? 1 + this.managers.control.record({ + mesh, + fn: actionNames.decrement, + args: [count, withMove], + duration, + revert: [state.id, withMove], + isLocal: true + }) + this.state.quantity -= count + const created = await createMeshFromState( + state, + scene, + this.managers ) + if (withMove) { + created.setAbsolutePosition(mesh.absolutePosition) + await animateMove( + created, + Vector3.FromArray([state.x ?? 0, state.y ?? 0, state.z ?? 0]), + null, + duration + ) + } } - }) + ) ) updateIndicator(this.managers, mesh, this.state.quantity) } else if (action === actionNames.decrement) { @@ -251,7 +239,7 @@ export class QuantityBehavior extends TargetBehavior { /** * Updates this behavior's state and mesh to match provided data. - * @param {QuantifiableState} state - state to update to. + * @param {import('@tabulous/types').QuantifiableState} state - state to update to. */ fromState({ quantity = 1, @@ -267,7 +255,9 @@ export class QuantityBehavior extends TargetBehavior { updateIndicator(this.managers, this.mesh, quantity) // dispose previous drop zone if (this.dropZone) { - this.removeZone(/** @type {SingleDropZone} */ (this.dropZone)) + this.removeZone( + /** @type {import('../managers').SingleDropZone} */ (this.dropZone) + ) } this.dropZone = this.addZone( buildTargetMesh(`quantifiable-zone-${this.mesh.id}`, this.mesh), @@ -285,7 +275,7 @@ async function internalIncrement( /** @type {boolean} */ immediate, isLocal = false ) { - const meshes = /** @type {Mesh[]} */ ( + const meshes = /** @type {import('@babylonjs/core').Mesh[]} */ ( meshIds?.map(id => mesh?.getScene().getMeshById(id)).filter(Boolean) ) if (!meshes.length || !mesh) return @@ -317,13 +307,13 @@ async function internalIncrement( } } -function getQuantity(/** @type {Mesh} */ mesh) { +function getQuantity(/** @type {import('@babylonjs/core').Mesh} */ mesh) { return mesh.metadata?.quantity ?? 1 } function updateIndicator( - /** @type {import('@src/3d/managers').Managers} */ { indicator }, - /** @type {Mesh} */ mesh, + /** @type {import('../managers').Managers} */ { indicator }, + /** @type {import('@babylonjs/core').Mesh} */ mesh, /** @type {number} */ size ) { const id = `${mesh.id}.quantity` @@ -334,6 +324,6 @@ function updateIndicator( } } -function makeId(/** @type {Mesh} */ mesh) { +function makeId(/** @type {import('@babylonjs/core').Mesh} */ mesh) { return `${mesh.id}-${crypto.randomUUID()}` } diff --git a/apps/web/src/3d/behaviors/randomizable.js b/apps/web/src/3d/behaviors/randomizable.js index 121ab5e3..5e740822 100644 --- a/apps/web/src/3d/behaviors/randomizable.js +++ b/apps/web/src/3d/behaviors/randomizable.js @@ -1,17 +1,6 @@ // @ts-check -/** - * @typedef {import('@babylonjs/core').Axis} Axis - * @typedef {import('@babylonjs/core').Mesh} Mesh - * @typedef {import('@tabulous/server/src/graphql').ActionName} ActionName - * @typedef {import('@tabulous/server/src/graphql').RandomizableState} RandomizableState - * @typedef {import('@src/3d/utils').AnimationSpec} AnimationSpec - * @typedef {import('@src/3d/utils').QuaternionKeyFrame} QuaternionKeyFrame - * @typedef {import('@src/3d/utils').Vector3KeyFrame} Vector3KeyFrame - */ - import { Animation } from '@babylonjs/core/Animations/animation' -import { VertexBuffer } from '@babylonjs/core/Buffers/buffer' -import { Quaternion, Vector3 } from '@babylonjs/core/Maths/math.vector' +import { Quaternion } from '@babylonjs/core/Maths/math.vector' import { makeLogger } from '../../utils/logger' import { actionNames } from '../utils/actions' @@ -26,7 +15,7 @@ import { getDimensions, isAnimationInProgress } from '../utils/mesh' import { AnimateBehavior } from './animatable' import { RandomBehaviorName } from './names' -const logger = makeLogger('randomizable') +const logger = makeLogger(RandomBehaviorName) const { cos, floor, PI, random, sin } = Math /** @@ -34,35 +23,34 @@ const { cos, floor, PI, random, sin } = Math * @property {number} max - maximum face value (minimum is always 1). * @property {Map} quaternionPerFace - map of Euler angles [x, y, z] applied when setting a given fave * - * @typedef {RandomizableState & Required>} RequiredRandomizableState + * @typedef {import('@tabulous/types').RandomizableState & Required>} RequiredRandomizableState */ export class RandomBehavior extends AnimateBehavior { /** * Creates behavior to make a mesh randomizable: it has a face vaule and this face can be set, or randomly set. - * @param {RandomizableState & Extras} stateWithExtra - behavior persistent state, with internal parameters provided by the mesh. - * @param {import('@src/3d/managers').Managers} managers - current managers. + * @param {import('@tabulous/types').RandomizableState} state - behavior persistent state. + * @param {import('../managers').Managers} managers - current managers. + * @param {Record & { max?: number; quaternionPerFace?: Map}} extra - extras provided by the Die mesh. */ - constructor(stateWithExtra, managers) { + constructor(state, managers, { max, quaternionPerFace }) { super() /** @internal */ this.managers = managers - /** @type {RequiredRandomizableState} state - the behavior's current state (+ extras) */ - this.state = /** @type {RequiredRandomizableState} */ (stateWithExtra) - if (!(stateWithExtra.quaternionPerFace instanceof Map)) { + /** the behavior's current state (+ extras) */ + this.state = /** @type {RequiredRandomizableState} */ (state) + if (!(quaternionPerFace instanceof Map)) { throw new Error(`RandomBehavior needs quaternionPerFace`) } - /** @type {number} */ - this.max = stateWithExtra.max - /** @internal @type {Map} */ - this.quaternionPerFace = stateWithExtra.quaternionPerFace + /** maximum number of faces */ + this.max = max ?? 0 + /** @internal */ + this.quaternionPerFace = quaternionPerFace if (!(this.max > 1)) { throw new Error( `RandomBehavior's max should be higher than ${this.state.face ?? 1}` ) } - /** @internal @type {{ positions: number[], normals: number[] }} */ - this.save = { positions: [], normals: [] } /** @internal @type {Animation} */ this.rollAnimation = new Animation( 'roll', @@ -73,9 +61,6 @@ export class RandomBehavior extends AnimateBehavior { ) } - /** - * @property {string} name - this behavior's constant name. - */ get name() { return RandomBehaviorName } @@ -86,7 +71,7 @@ export class RandomBehavior extends AnimateBehavior { * - a `maxFace` number. * - a `random()` function to randomly set a new face value. * - a `setFace()` function to set the face to a given value. - * @param {Mesh} mesh - which becomes detailable. + * @param {import('@babylonjs/core').Mesh} mesh - which becomes detailable. */ attach(mesh) { super.attach(mesh) @@ -140,7 +125,7 @@ export class RandomBehavior extends AnimateBehavior { /** * Revert setFace and random actions. Ignores other actions - * @param {ActionName} action - reverted action. + * @param {import('@tabulous/types').ActionName} action - reverted action. * @param {any[]} [args] - reverted arguments. */ async revert(action, args = []) { @@ -156,7 +141,7 @@ export class RandomBehavior extends AnimateBehavior { /** * Updates this behavior's state and mesh to match provided data. - * @param {RandomizableState} state - state to update to. + * @param {import('@tabulous/types').RandomizableState} state - state to update to. */ fromState({ face = 1, duration = 600, canBeSet = false } = {}) { if (!this.mesh) { @@ -169,21 +154,6 @@ export class RandomBehavior extends AnimateBehavior { ) } const attach = detachFromParent(this.mesh) - this.mesh.computeWorldMatrix(true) - - const restore = saveTranslation(this.mesh) - this.save = { - positions: /** @type {number[]} */ ( - this.mesh.getVerticesData(VertexBuffer.PositionKind) - ), - normals: /** @type {number[]} */ ( - this.mesh.getVerticesData(VertexBuffer.NormalKind) - ) - } - this.mesh.markVerticesDataAsUpdatable(VertexBuffer.PositionKind, true) - this.mesh.markVerticesDataAsUpdatable(VertexBuffer.NormalKind, true) - restore() - applyRotation(this) attach() attachFunctions(this, 'random') @@ -203,7 +173,7 @@ function internalRandom( isLocal = false ) { const { - state: { face: oldFace, duration }, + state: { duration }, quaternionPerFace, mesh, rollAnimation, @@ -221,16 +191,10 @@ function internalRandom( { animation: rollAnimation, duration, - keys: /** @type {QuaternionKeyFrame[]} */ ([ + keys: /** @type {import('../utils').QuaternionKeyFrame[]} */ ([ { frame: 0, - values: /** @type {Quaternion} */ ( - quaternionPerFace.get(oldFace ?? 0) - ) - .multiply( - /** @type {Quaternion} */ (quaternionPerFace.get(face)).invert() - ) - .asArray() + values: mesh.rotationQuaternion?.asArray() }, { frame: 25, @@ -252,14 +216,14 @@ function internalRandom( }, { frame: 100, - values: makeRandomRotation('y', 0.5 * PI).asArray() + values: quaternionPerFace.get(face)?.asArray() } ]) }, { animation: moveAnimation, duration, - keys: /** @type {Vector3KeyFrame[]} */ ([ + keys: /** @type {import('../utils').Vector3KeyFrame[]} */ ([ { frame: 0, values: mesh.position.asArray() }, { frame: 50, @@ -281,7 +245,7 @@ function internalSetFace( isLocal = false ) { const { - state: { face: oldFace, duration }, + state: { duration }, quaternionPerFace, mesh, rollAnimation @@ -292,61 +256,31 @@ function internalSetFace( return animate(behavior, isLocal, 'setFace', face, duration / 3, { animation: rollAnimation, duration: duration / 3, - keys: /** @type {QuaternionKeyFrame[]} */ ([ + keys: /** @type {import('../utils').QuaternionKeyFrame[]} */ ([ { frame: 0, - values: /** @type {Quaternion} */ (quaternionPerFace.get(oldFace)) - .multiply( - /** @type {Quaternion} */ (quaternionPerFace.get(face)).invert() - ) - .asArray() + values: mesh.rotationQuaternion?.asArray() }, - { frame: 100, values: [0, 0, 0, 1] } + { frame: 100, values: quaternionPerFace.get(face)?.asArray() } ]) }) } function applyRotation( - /** @type {RandomBehavior} */ { - mesh, - quaternionPerFace, - state: { face }, - save - } + /** @type {RandomBehavior} */ { mesh, quaternionPerFace, state: { face } } ) { if (!mesh) return - const restore = saveTranslation(mesh) - mesh.updateVerticesData(VertexBuffer.PositionKind, [...save.positions]) - mesh.updateVerticesData(VertexBuffer.NormalKind, [...save.normals]) - mesh.rotationQuaternion = /** @type {Quaternion} */ ( - quaternionPerFace.get(face ?? 1) - ).clone() - mesh.bakeCurrentTransformIntoVertices() - mesh.refreshBoundingInfo() - restore() -} - -/** - * Temporary set a mesh's absolute position to the origin. - * @param {Mesh} mesh - concerned mesh. - * @returns function used to restore absolute position. - */ -function saveTranslation(mesh) { - const translation = mesh.absolutePosition.clone() - mesh.setAbsolutePosition(Vector3.Zero()) - return () => { - mesh.setAbsolutePosition(translation) - translation - } + mesh.rotationQuaternion = + quaternionPerFace.get(face ?? 1)?.clone() ?? Quaternion.Identity() } /** * @param {RandomBehavior} behavior - animated behavior. * @param {boolean} isLocal - action locality. - * @param {ActionName} fn - function name, for logging. + * @param {import('@tabulous/types').ActionName} fn - function name, for logging. * @param {number} face - face to animate to. * @param {number|undefined} duration - animation duration. - * @param {...AnimationSpec} animations - animation spec used. + * @param {...import('../utils').AnimationSpec} animations - animation spec used. * @returns resolves when animation is completed. */ async function animate(behavior, isLocal, fn, face, duration, ...animations) { @@ -389,7 +323,7 @@ async function animate(behavior, isLocal, fn, face, duration, ...animations) { /** * Builds a random quaterion on a given axis. - * @param {Axis} axis - concerned mesh. + * @param {import('@babylonjs/core').Axis} axis - concerned mesh. * @param {number} [limit] - maximum rotation allowed. */ function makeRandomRotation(axis, limit = 2 * PI) { diff --git a/apps/web/src/3d/behaviors/rotable.js b/apps/web/src/3d/behaviors/rotable.js index 8f09a6f4..51dde079 100644 --- a/apps/web/src/3d/behaviors/rotable.js +++ b/apps/web/src/3d/behaviors/rotable.js @@ -1,11 +1,4 @@ // @ts-check -/** - * @typedef {import('@babylonjs/core').Mesh} Mesh - * @typedef {import('@tabulous/server/src/graphql').ActionName} ActionName - * @typedef {import('@tabulous/server/src/graphql').RotableState} RotableState - * @typedef {import('@src/3d/utils').Vector3KeyFrame} Vector3KeyFrame - */ - import { Vector3 } from '@babylonjs/core/Maths/math' import { makeLogger } from '../../utils/logger' @@ -22,9 +15,9 @@ import { convertToLocal, getAbsoluteRotation } from '../utils/vector' import { AnimateBehavior } from './animatable' import { RotateBehaviorName } from './names' -/** @typedef {RotableState & Required>} RequiredRotableState */ +/** @typedef {import('@tabulous/types').RotableState & Required>} RequiredRotableState */ -const logger = makeLogger('rotable') +const logger = makeLogger(RotateBehaviorName) const rotationStep = Math.PI * 0.5 @@ -34,8 +27,8 @@ export class RotateBehavior extends AnimateBehavior { * It will add to this mesh's metadata: * - a `rotate()` function to rotate by 45°. * - a rotation `angle` (in radian). - * @param {RotableState} state - rotable state. - * @param {import('@src/3d/managers').Managers} managers - current managers. + * @param {import('@tabulous/types').RotableState} state - rotable state. + * @param {import('../managers').Managers} managers - current managers. */ constructor(state, managers) { super() @@ -44,9 +37,6 @@ export class RotateBehavior extends AnimateBehavior { this._state = /** @type {RequiredRotableState} */ (state) } - /** - * @property {string} name - this behavior's constant name. - */ get name() { return RotateBehaviorName } @@ -69,7 +59,7 @@ export class RotateBehavior extends AnimateBehavior { * When attaching/detaching this mesh to a parent, adjust rotation angle * according to the parent's own rotation, so that rotating the parent * will accordingly rotate the child. - * @param {Mesh} mesh - which becomes detailable. + * @param {import('@babylonjs/core').Mesh} mesh - which becomes detailable. */ attach(mesh) { super.attach(mesh) @@ -92,7 +82,7 @@ export class RotateBehavior extends AnimateBehavior { /** * Revert rotate actions. Ignores other actions - * @param {ActionName} action - reverted action. + * @param {import('@tabulous/types').ActionName} action - reverted action. * @param {any[]} [args] - reverted arguments. */ async revert(action, args = []) { @@ -103,7 +93,7 @@ export class RotateBehavior extends AnimateBehavior { /** * Updates this behavior's state and mesh to match provided data. - * @param {RotableState} state - state to update to. + * @param {import('@tabulous/types').RotableState} state - state to update to. */ fromState({ angle = 0, duration = 200 } = {}) { if (!this.mesh) { @@ -170,7 +160,7 @@ async function internalRotate( { animation: moveAnimation, duration, - keys: /** @type {Vector3KeyFrame[]} */ ([ + keys: /** @type {import('../utils').Vector3KeyFrame[]} */ ([ { frame: 0, values: [x, y, z] }, { frame: 50, diff --git a/apps/web/src/3d/behaviors/stackable.js b/apps/web/src/3d/behaviors/stackable.js index a8e56f64..4bcd4493 100644 --- a/apps/web/src/3d/behaviors/stackable.js +++ b/apps/web/src/3d/behaviors/stackable.js @@ -1,20 +1,4 @@ // @ts-check -/** - * @typedef {import('@babylonjs/core').Mesh} Mesh - * @typedef {import('@tabulous/server/src/graphql').ActionName} ActionName - * @typedef {import('@tabulous/server/src/graphql').Anchor} Anchor - * @typedef {import('@tabulous/server/src/graphql').StackableState} StackableState - * @typedef {import('@src/3d/behaviors/animatable').AnimateBehavior} AnimateBehavior - * @typedef {import('@src/3d/behaviors/targetable').DropDetails} DropDetails - * @typedef {import('@src/3d/managers/move').MoveDetails} MoveDetails - * @typedef {import('@src/3d/managers/target').SingleDropZone} SingleDropZone - * @typedef {import('@src/3d/utils').Vector3KeyFrame} Vector3KeyFrame - */ -/** - * @template T - * @typedef {import('@babylonjs/core').Observer} Observer - */ - import { Vector3 } from '@babylonjs/core/Maths/math.vector' import { makeLogger } from '../../utils/logger' @@ -46,10 +30,10 @@ import { } from './names' import { TargetBehavior } from './targetable' -/** @typedef {StackableState & Required> & Required>} RequiredStackableState */ -/** @typedef {StackBehavior & { mesh: Mesh }} AttachedStackBehavior */ +/** @typedef {import('@tabulous/types').StackableState & Required> & Required>} RequiredStackableState */ +/** @typedef {StackBehavior & { mesh: import('@babylonjs/core').Mesh }} AttachedStackBehavior */ -const logger = makeLogger('stackable') +const logger = makeLogger(StackBehaviorName) export class StackBehavior extends TargetBehavior { /** @@ -57,34 +41,31 @@ export class StackBehavior extends TargetBehavior { * and targetable (it can receive other stackable meshs). * Once a mesh is stacked bellow others, it can not be moved independently, and its targets and anchors are disabled. * Only the highest mesh on stack can be moved (it is automatically poped out) and be targeted. - * @param {StackableState} state - behavior state. - * @param {import('@src/3d/managers').Managers} managers - current managers. + * @param {import('@tabulous/types').StackableState} state - behavior state. + * @param {import('../managers').Managers} managers - current managers. */ constructor(state = {}, managers) { super({}, managers) /** @type {RequiredStackableState} */ this._state = /** @type {RequiredStackableState} */ (state) - /** @type {Mesh[]} array of meshes (initially contains this mesh). */ + /** @type {import('@babylonjs/core').Mesh[]} array of meshes (initially contains this mesh). */ this.stack = [] /** @type {boolean} */ this.inhibitControl = false /** @internal @type {?AttachedStackBehavior} */ this.base = null - /** @protected @type {?Observer} */ + /** @protected @type {?import('@babylonjs/core').Observer} */ this.moveObserver = null - /** @protected @type {?Observer} */ + /** @protected @type {?import('@babylonjs/core').Observer} */ this.dropObserver = null - /** @protected @type {?Observer} */ + /** @protected @type {?import('@babylonjs/core').Observer} */ this.actionObserver = null /** @internal @type {boolean} */ this.isReordering = false - /** @protected @type {SingleDropZone}} */ + /** @protected @type {import('../managers').SingleDropZone}} */ this.dropZone } - /** - * @property {string} name - this behavior's constant name. - */ get name() { return StackBehaviorName } @@ -99,7 +80,7 @@ export class StackBehavior extends TargetBehavior { * - a `canPush()` function to determin whether a mesh could be pushed on this stack. * It binds to its drop observable to push dropped meshes to the stack. * It binds to the drag manager drag observable to pop the first stacked mesh when dragging it. - * @param {Mesh} mesh - which becomes detailable. + * @param {import('@babylonjs/core').Mesh} mesh - which becomes detailable. */ attach(mesh) { super.attach(mesh) @@ -159,7 +140,7 @@ export class StackBehavior extends TargetBehavior { /** * Determines whether a movable mesh can be stack onto this mesh (or its stack). - * @param {Mesh} mesh - tested (movable) mesh. + * @param {import('@babylonjs/core').Mesh} mesh - tested (movable) mesh. * @returns {boolean} true if this mesh can be stacked. */ canPush(mesh) { @@ -253,7 +234,7 @@ export class StackBehavior extends TargetBehavior { /** * Revert push, pop, flipAll and reorder actions. Ignores other actions - * @param {ActionName} action - reverted action. + * @param {import('@tabulous/types').ActionName} action - reverted action. * @param {any[]} [args] - reverted arguments. */ async revert(action, args = []) { @@ -307,7 +288,7 @@ export class StackBehavior extends TargetBehavior { /** * Gets this behavior's state. - * @returns {StackableState} this behavior's state for serialization. + * @returns {import('@tabulous/types').StackableState} this behavior's state for serialization. */ get state() { return { @@ -325,7 +306,7 @@ export class StackBehavior extends TargetBehavior { /** * Updates this behavior's state and mesh to match provided data. - * @param {StackableState} state - state to update to. + * @param {import('@tabulous/types').StackableState} state - state to update to. */ fromState({ stackIds = [], @@ -449,14 +430,14 @@ function internalPop( /** @type {boolean} */ withMove, isLocal = false ) { - /** @type {Mesh[]} */ + /** @type {import('@babylonjs/core').Mesh[]} */ const poped = [] const stack = behavior.base?.stack ?? behavior.stack if (stack.length <= 1) return poped const limit = Math.min(count, stack.length - 1) for (let times = 0; times < limit; times++) { - const mesh = /** @type {Mesh} */ (stack.pop()) + const mesh = /** @type {import('@babylonjs/core').Mesh} */ (stack.pop()) poped.push(mesh) setBase(mesh, null) updateIndicator(behavior.managers, mesh, 0) @@ -497,7 +478,7 @@ async function internalReorder( } const posById = new Map(old.map(({ id }, i) => [id, i])) - /** @type {Mesh[]} */ + /** @type {import('@babylonjs/core').Mesh[]} */ const stack = ids.map(id => old[posById.get(id) ?? -1]).filter(Boolean) const oldIds = old.map(({ id }) => id) @@ -563,7 +544,7 @@ async function internalReorder( const shift = getDimensions(stack[0]).width * 0.75 await Promise.all( stack.slice(1).map((mesh, rank) => { - const behavior = /** @type {AnimateBehavior} */ ( + const behavior = /** @type {import('.').AnimateBehavior} */ ( getAnimatableBehavior(mesh) ) const [x, y, z] = mesh.position.asArray() @@ -618,7 +599,7 @@ async function internalReorder( // then restore await Promise.all( stack.slice(1).map((mesh, rank) => { - const behavior = /** @type {AnimateBehavior} */ ( + const behavior = /** @type {import('.').AnimateBehavior} */ ( getAnimatableBehavior(mesh) ) const { x, y, z, pitch, yaw, roll } = positionsAndRotations[rank++] @@ -629,7 +610,7 @@ async function internalReorder( { animation: behavior.rotateAnimation, duration: restoreDuration, - keys: /** @type {Vector3KeyFrame[]} */ ([ + keys: /** @type {import('../utils').Vector3KeyFrame[]} */ ([ { frame: 0, values: mesh.rotation.asArray() }, { frame: 100, values: [pitch, yaw, roll] } ]) @@ -637,7 +618,7 @@ async function internalReorder( { animation: behavior.moveAnimation, duration: restoreDuration, - keys: /** @type {Vector3KeyFrame[]} */ ([ + keys: /** @type {import('../utils').Vector3KeyFrame[]} */ ([ { frame: 0, values: mesh.position.asArray() }, { frame: 100, values: [x, y, z] } ]) @@ -683,7 +664,7 @@ async function internalFlipAll( } /** - * @param {Mesh[]} stack - stack of meshes. + * @param {import('@babylonjs/core').Mesh[]} stack - stack of meshes. * @param {number} rank - rank of the updated mesh in the stack. * @param {boolean} enabled - new status applied. * @param {StackBehavior} behavior - current stack behavior @@ -716,14 +697,14 @@ function setStatus(stack, rank, enabled, behavior) { } function setBase( - /** @type {Mesh} */ mesh, + /** @type {import('@babylonjs/core').Mesh} */ mesh, /** @type {?AttachedStackBehavior} */ base ) { const targetable = /** @type {?AttachedStackBehavior} */ ( getTargetableBehavior(mesh) ) if (targetable) { - const parent = /** @type {?Mesh} */ (mesh.parent) + const parent = /** @type {?import('@babylonjs/core').Mesh} */ (mesh.parent) if (base) { mesh.setParent(base.mesh) } else if (parent && targetable.stack.includes(parent)) { @@ -737,8 +718,8 @@ function setBase( } function updateIndicator( - /** @type {import('@src/3d/managers').Managers} */ { indicator }, - /** @type {Mesh} */ mesh, + /** @type {import('../managers').Managers} */ { indicator }, + /** @type {import('@babylonjs/core').Mesh} */ mesh, /** @type {number} */ size ) { const id = `${mesh.id}.stack-size` @@ -758,7 +739,9 @@ function invertStack(/** @type {StackBehavior} */ behavior) { ) } -function getFinalAltitudeAboveStack(/** @type {Mesh[]} */ stack) { +function getFinalAltitudeAboveStack( + /** @type {import('@babylonjs/core').Mesh[]} */ stack +) { let y = stack[0].absolutePosition.y - getDimensions(stack[0]).height * 0.5 for (const mesh of stack) { y += getDimensions(mesh).height + altitudeGap diff --git a/apps/web/src/3d/behaviors/targetable.js b/apps/web/src/3d/behaviors/targetable.js index cc31dfe7..0a15244d 100644 --- a/apps/web/src/3d/behaviors/targetable.js +++ b/apps/web/src/3d/behaviors/targetable.js @@ -1,25 +1,9 @@ // @ts-check -/** - * @typedef {import('@babylonjs/core').Mesh} Mesh - * @typedef {import('@tabulous/server/src/graphql').Anchor} Anchor - * @typedef {import('@tabulous/server/src/graphql').Targetable} Targetable - * @typedef {import('@src/3d/managers/target').SingleDropZone} SingleDropZone - * @typedef {import('@src/3d/managers/target').DropZone} DropZone - */ - import { Observable } from '@babylonjs/core/Misc/observable.js' import { TargetBehaviorName } from './names' -/** @typedef {Targetable & Required> & Pick} ZoneProps properties of a drop zone */ - -/** - * @typedef {object} DropDetails detailed images definitions for a given mesh: - * @property {Mesh[]} dropped - a list of dropped meshes. - * @property {DropZone} zone - the zone onto meshes are dropped. - * @property {boolean} [immediate=false] - when true, no animation should be ran.dropped. - * @property {boolean} [isLocal=false] - set action locality. - */ +/** @typedef {Required> & Pick} ZoneProps properties of a drop zone */ export class TargetBehavior { /** @@ -28,22 +12,19 @@ export class TargetBehavior { * All zones can be enable and disabled at once. * An observable emits every time one of the zone receives a drop. * @param {object} state - unused state. - * @param {import('@src/3d/managers').Managers} managers - current managers. + * @param {import('../managers').Managers} managers - current managers. */ constructor(state, managers) { - /** @type {?Mesh} mesh - the related mesh. */ + /** @type {?import('@babylonjs/core').Mesh} mesh - the related mesh. */ this.mesh = null - /** @type {SingleDropZone[]} defined drop zones for this target. */ + /** @type {import('../managers').SingleDropZone[]} defined drop zones for this target. */ this.zones = [] - /** @type {Observable} emits every time draggable meshes are dropped to one of the zones.*/ + /** @type {Observable} emits every time draggable meshes are dropped to one of the zones.*/ this.onDropObservable = new Observable() /** @internal */ this.managers = managers } - /** - * @property {string} name - this behavior's constant name. - */ get name() { return TargetBehaviorName } @@ -56,7 +37,7 @@ export class TargetBehavior { /** * Attaches this behavior to a mesh, registering it to the target manager. - * @param {Mesh} mesh - which becomes detailable. + * @param {import('@babylonjs/core').Mesh} mesh - which becomes detailable. */ attach(mesh) { this.mesh = mesh @@ -79,7 +60,7 @@ export class TargetBehavior { /** * Adds a new zone to this mesh, making it invisible and unpickable. * By default, zone is enabled, accepts all kind, with a priority of 0, and no playerId. - * @param {Mesh} mesh - invisible, unpickable mesh acting as drop zone. + * @param {import('@babylonjs/core').Mesh} mesh - invisible, unpickable mesh acting as drop zone. * @param {ZoneProps} properties - drop zone properties. * @returns the created zone. */ @@ -89,7 +70,7 @@ export class TargetBehavior { mesh.isHittable = false mesh.isDropZone = true mesh.scalingDeterminant = 1.01 - /** @type {SingleDropZone} */ + /** @type {import('../managers').SingleDropZone} */ const zone = { mesh, targetable: this, @@ -113,7 +94,7 @@ export class TargetBehavior { /** * Removes an existing zone, disposing its mesh. * Does nothing if no zone is bound to the given mesh id - * @param {SingleDropZone} zone - removed zone mesh. + * @param {import('../managers').SingleDropZone} zone - removed zone mesh. */ removeZone(zone) { const idx = this.zones.indexOf(zone) diff --git a/apps/web/src/3d/managers/camera.js b/apps/web/src/3d/managers/camera.js index 805b93d4..803852ac 100644 --- a/apps/web/src/3d/managers/camera.js +++ b/apps/web/src/3d/managers/camera.js @@ -1,12 +1,4 @@ // @ts-check -/** - * @typedef {import('@babylonjs/core').Animatable} Animatable - * @typedef {import('@babylonjs/core').Scene} Scene - * @typedef {import('@tabulous/server/src/graphql').CameraPosition} RawCameraPosition - * @typedef {import('@tabulous/server/src/graphql').ZoomSpec} ZoomSpec - * @typedef {import('@src/3d/utils').ScreenPosition} ScreenPosition - */ - import { Animation } from '@babylonjs/core/Animations/animation.js' import { ArcRotateCamera } from '@babylonjs/core/Cameras/arcRotateCamera.js' import { TargetCamera } from '@babylonjs/core/Cameras/targetCamera.js' @@ -16,7 +8,7 @@ import { Observable } from '@babylonjs/core/Misc/observable.js' import { makeLogger } from '../../utils/logger' import { isPositionAboveTable, screenToGround } from '../utils/vector' -/** @typedef {Omit} CameraPosition */ +/** @typedef {Omit} CameraPosition */ export class CameraManager { /** @@ -34,8 +26,8 @@ export class CameraManager { * @param {number} [params.minY] - minimum camera altitude, in 3D coordinate * @param {number} [params.maxY] - maximum camera altitude, in 3D coordinate * @param {number} [params.minAngle] - minimum camera angle (with x/z plane), in radian - * @param {Scene} [params.scene] - main scene. - * @param {Scene} [params.handScene] - hand scene. + * @param {import('@babylonjs/core').Scene} [params.scene] - main scene. + * @param {import('@babylonjs/core').Scene} [params.handScene] - hand scene. */ constructor({ y = 35, @@ -92,7 +84,7 @@ export class CameraManager { /** * Adjust the main camera zoom range (when relevant), and the fixed hand zoom (when relevant). - * @param {ZoomSpec} [zoomSpec] - zoom specification + * @param {import('@tabulous/types').ZoomSpec} [zoomSpec] - zoom specification * @throws {Error} when called prior to initialization. */ adjustZoomLevels({ min, max, hand } = {}) { @@ -112,8 +104,8 @@ export class CameraManager { * Applies a given movement to the camera on x/z plane, with animation. * Coordinates outside the table will be ignored. * Ends with the animation. - * @param {ScreenPosition} movementStart - movement starting point, in screen coordinate. - * @param {ScreenPosition} movementEnd -movement ending point, in screen coordinate. + * @param {import('../utils').ScreenPosition} movementStart - movement starting point, in screen coordinate. + * @param {import('../utils').ScreenPosition} movementEnd -movement ending point, in screen coordinate. * @param {number} [duration=300] - animation duration, in ms. */ async pan(movementStart, movementEnd, duration = 300) { @@ -246,7 +238,7 @@ const pan = /** @type {Animation & {targetProperty: 'lockedTarget'}} */ ( ) ) -/** @type {?Animatable} */ +/** @type {?import('@babylonjs/core').Animatable} */ let currentAnimation = null async function animate( diff --git a/apps/web/src/3d/managers/control.js b/apps/web/src/3d/managers/control.js index 2e66ec56..b7646090 100644 --- a/apps/web/src/3d/managers/control.js +++ b/apps/web/src/3d/managers/control.js @@ -1,13 +1,4 @@ // @ts-check -/** - * @typedef {import('@babylonjs/core').Mesh} Mesh - * @typedef {import('@babylonjs/core').Scene} Scene - * @typedef {import('@tabulous/server/src/graphql').ActionName} ActionName - * @typedef {import('@tabulous/server/src/graphql').ZoomSpec} ZoomSpec - * @typedef {import('@src/3d/managers/camera').CameraPosition} CameraPosition - * @typedef {import('@src/3d/utils').ScreenPosition} ScreenPosition - */ - import { Vector3 } from '@babylonjs/core/Maths/math.vector.js' import { Observable } from '@babylonjs/core/Misc/observable.js' @@ -30,21 +21,21 @@ import { animateMove } from '../utils/behaviors' * @typedef {Action|Move} ActionOrMove * * @typedef {object} RecordedAction applied action to a given mesh: - * @property {Mesh} mesh - modified mesh. - * @property {ActionName} fn - name of the applied action. + * @property {import('@babylonjs/core').Mesh} mesh - modified mesh. + * @property {import('@tabulous/types').ActionName} fn - name of the applied action. * @property {any[]} args - argument array for this action. * @property {any[]} [revert] - when action can't be reverted with the same args, specific data required. * @property {number} [duration] - optional animation duration, in milliseconds. * @property {boolean} [isLocal] - indicates a local action that should not be re-recorded nor sent to peers. * * @typedef {object} RecordedMove applied action to a given mesh: - * @property {Mesh} mesh - modified mesh. + * @property {import('@babylonjs/core').Mesh} mesh - modified mesh. * @property {number[]} pos - absolute position. * @property {number[]} prev - absolute position before the move. * @property {number} [duration] - optional animation duration, in milliseconds. * * @typedef {object} MeshDetails details of a given mesh. - * @property {ScreenPosition} position - screen position (2D pixels) of the detailed mesh. + * @property {import('../utils').ScreenPosition} position - screen position (2D pixels) of the detailed mesh. * @property {string[]} images - list of images for this mesh (could be multiple for stacked meshes). */ @@ -56,27 +47,27 @@ export class ControlManager { * Clears all observers on scene disposal. * Invokes init() before any other function. * @param {object} params - parameters, including: - * @param {Scene} params.scene - main scene. - * @param {Scene} params.handScene - scene for meshes in hand. + * @param {import('@babylonjs/core').Scene} params.scene - main scene. + * @param {import('@babylonjs/core').Scene} params.handScene - scene for meshes in hand. */ constructor({ scene, handScene }) { /** @type {Observable} emits applied actions. */ this.onActionObservable = new Observable() /** @type {Observable} emits when displaying details of a given mesh. */ this.onDetailedObservable = new Observable() - /** @type {Observable>} emits the list of controlled meshes. */ + /** @type {Observable>} emits the list of controlled meshes. */ this.onControlledObservable = new Observable() /** @internal */ this.scene = scene /** @internal */ this.handScene = handScene - /** @internal @type {Map} */ + /** @internal @type {Map} */ this.controlables = new Map() // prevents loops when applying an received action /** @internal @type {Set} */ this.localKeys = new Set() - /** @internal @type {import('@src/3d/managers').Managers} */ + /** @internal @type {import('.').Managers} */ this.managers this.scene.onDisposeObservable.addOnce(() => { @@ -90,7 +81,7 @@ export class ControlManager { * Initializes with game data and managers * * @param {object} params - parameters, including: - * @param {import('@src/3d/managers').Managers} params.managers - current managers. + * @param {import('.').Managers} params.managers - current managers. */ init({ managers }) { this.managers = managers @@ -99,7 +90,7 @@ export class ControlManager { /** * Registers a new controllable mesh. * Does nothing if this mesh is already managed. - * @param {Mesh} mesh - controled mesh (needs at least an id property). + * @param {import('@babylonjs/core').Mesh} mesh - controled mesh (needs at least an id property). */ registerControlable(mesh) { this.controlables.set(mesh.id, mesh) @@ -114,7 +105,7 @@ export class ControlManager { /** * Unregisters a controlled mesh. * Does nothing on unmanaged mesh. - * @param {Mesh} mesh - controlled mesh (needs at least an id property). + * @param {import('@babylonjs/core').Mesh} mesh - controlled mesh (needs at least an id property). */ unregisterControlable(mesh) { if (this.controlables.delete(mesh?.id)) { @@ -123,7 +114,7 @@ export class ControlManager { } /** - * @param {Mesh} mesh - tested mesh + * @param {import('@babylonjs/core').Mesh} mesh - tested mesh * @returns whether this mesh is controlled or not */ isManaging(mesh) { @@ -161,8 +152,8 @@ export class ControlManager { /** * Invokes a mesh's function as if it was local (does not propagate to peer). * Used by cascading actions. - * @param {?Mesh} mesh - mesh on which the action is called. - * @param {ActionName} fn - incoked function name. + * @param {?import('@babylonjs/core').Mesh} mesh - mesh on which the action is called. + * @param {import('@tabulous/types').ActionName} fn - incoked function name. * @param {...any} args - invokation arguments, if any. */ async invokeLocal(mesh, fn, ...args) { diff --git a/apps/web/src/3d/managers/custom-shape.js b/apps/web/src/3d/managers/custom-shape.js index 0a7d5064..ce8cd53e 100644 --- a/apps/web/src/3d/managers/custom-shape.js +++ b/apps/web/src/3d/managers/custom-shape.js @@ -1,8 +1,4 @@ // @ts-check -/** - * @typedef {import('@src/graphql').Game} Game - */ - import { makeLogger } from '../../utils/logger' import { getDieModelFile } from '../meshes' @@ -23,7 +19,7 @@ export class CustomShapeManager { /** * Download modesl and cache their results. - * @param {Game} game - game data. + * @param {import('@src/graphql').Game} game - game data. */ async init({ meshes, hands }) { logger.debug( @@ -75,7 +71,9 @@ export class CustomShapeManager { } } -function extractFiles(/** @type {Game['meshes']} */ meshes) { +function extractFiles( + /** @type {import('@tabulous/types').Mesh[]|undefined} */ meshes +) { /** @type {string[]} */ const files = [] for (const { id, shape, file, faces } of meshes ?? []) { diff --git a/apps/web/src/3d/managers/hand.js b/apps/web/src/3d/managers/hand.js index a2629eaf..d4457270 100644 --- a/apps/web/src/3d/managers/hand.js +++ b/apps/web/src/3d/managers/hand.js @@ -1,27 +1,4 @@ // @ts-check -/** - * @typedef {import('@babylonjs/core').Engine} Engine - * @typedef {import('@babylonjs/core').Mesh} Mesh - * @typedef {import('@babylonjs/core').Scene} Scene - * @typedef {import('@tabulous/server/src/graphql').Dimension} Dimension - * @typedef {import('@tabulous/server/src/graphql').Hand} Hand - * @typedef {import('@tabulous/server/src/graphql').Mesh} SerializedMesh - * @typedef {import('@src/3d/behaviors/anchorable').AnchorBehavior} AnchorBehavior - * @typedef {import('@src/3d/behaviors/drawable').DrawBehavior} DrawBehavior - * @typedef {import('@src/3d/behaviors/flippable').FlipBehavior} FlipBehavior - * @typedef {import('@src/3d/behaviors/quantifiable').QuantityBehavior} QuantityBehavior - * @typedef {import('@src/3d/behaviors/rotable').RotateBehavior} RotateBehavior - * @typedef {import('@src/3d/behaviors/stackable').StackBehavior} StackBehavior - * @typedef {import('@src/3d/managers/input').DragData} DragData - * @typedef {import('@src/3d/managers/target').DropZone} DropZone - * @typedef {import('@src/3d/utils').ScreenPosition} ScreenPosition - * @typedef {import('@src/graphql').Game} Game - */ -/** - * @template T - * @typedef {import('@babylonjs/core').Observer} Observer - */ - import { Vector3 } from '@babylonjs/core/Maths/math.vector.js' import { Observable } from '@babylonjs/core/Misc/observable.js' import { debounceTime, Subject } from 'rxjs' @@ -50,9 +27,9 @@ import { } from '../utils/vector' /** - * @typedef {Required>} EngineDimension observed dimension of the rendering engine (pixels). - * @typedef {Required>} MeshDimension observed dimension of a mesh (3D units). - * @typedef {{ meshes: Mesh[] }} HandChange details of a change in hand. + * @typedef {Required>} EngineDimension observed dimension of the rendering engine (pixels). + * @typedef {Required>} MeshDimension observed dimension of a mesh (3D units). + * @typedef {{ meshes: import('@babylonjs/core').Mesh[] }} HandChange details of a change in hand. */ /** @@ -75,8 +52,8 @@ export class HandManager { * Is starts disabled and must be manually enabled. * Invokes init() before any other function. * @param {object} params - parameters, including: - * @param {Scene} params.scene - main scene. - * @param {Scene} params.handScene - scene for meshes in hand. + * @param {import('@babylonjs/core').Scene} params.scene - main scene. + * @param {import('@babylonjs/core').Scene} params.handScene - scene for meshes in hand. * @param {HTMLElement} params.overlay - HTML element defining hand's available height. * @param {number} [params.gap=0.5] - gap between hand meshes, when render width allows it, in 3D coordinates. * @param {number} [params.verticalPadding=1] - vertical padding between meshes and the viewport edges, in 3D coordinates. @@ -131,7 +108,7 @@ export class HandManager { this.contentDimensions = { width: 0, depth: 0 } /** @internal @type {Map} */ this.dimensionsByMeshId = new Map() - /** @internal @type {Mesh[]} */ + /** @internal @type {import('@babylonjs/core').Mesh[]} */ this.moved = [] /** @internal @type {Subject} */ this.changes$ = new Subject() @@ -146,14 +123,14 @@ export class HandManager { }) /** @internal @type {string} */ this.playerId - /** @internal @type {import('@src/3d/managers').Managers} */ + /** @internal @type {import('.').Managers} */ this.managers } /** * Initialize with game data * @param {object} params - parameters, including: - * @param {import('@src/3d/managers').Managers} params.managers - current managers. + * @param {import('.').Managers} params.managers - current managers. * @param {string} params.playerId - id of the local player. * @param {number} [params.angleOnPlay=0] - angle applied when playing rotable meshes, due to the player position. */ @@ -179,17 +156,17 @@ export class HandManager { }, { observable: this.managers.control.onActionObservable, - handle: ( - /** @type {import('@src/3d/managers').ActionOrMove} */ action - ) => handleAction(this, action) + handle: (/** @type {import('.').ActionOrMove} */ action) => + handleAction(this, action) }, { observable: this.managers.input.onDragObservable, - handle: (/** @type {DragData} */ action) => handDrag(this, action) + handle: (/** @type {import('.').DragData} */ action) => + handDrag(this, action) }, { observable: this.handScene.onNewMeshAddedObservable, - handle: (/** @type {Mesh} */ added) => { + handle: (/** @type {import('@babylonjs/core').Mesh} */ added) => { // delay because mesh names are set after being constructed setTimeout(() => { if (isSerializable(added)) { @@ -201,7 +178,7 @@ export class HandManager { }, { observable: this.handScene.onMeshRemovedObservable, - handle: (/** @type {Mesh} */ removed) => { + handle: (/** @type {import('@babylonjs/core').Mesh} */ removed) => { if (isSerializable(removed)) { logger.info( { mesh: removed }, @@ -257,11 +234,11 @@ export class HandManager { * 4. if required (unflipOnPick is true), unflips flippable mesh * 5. if relevant (angleOnPick differs from mesh rotation), rotates rotable mesh * - * @param {Mesh} drawnMesh - drawn mesh + * @param {import('@babylonjs/core').Mesh} drawnMesh - drawn mesh */ async draw(drawnMesh) { const drawable = getDrawable(drawnMesh) - /** @type {Mesh[]} */ + /** @type {import('@babylonjs/core').Mesh[]} */ if (!this.enabled || !drawable || drawnMesh.getScene() === this.handScene) { return } @@ -278,11 +255,11 @@ export class HandManager { * 6. runs animation on the main scene (fades in and descends) * 7. clears current selection * - * @param {Mesh} playedMesh - played mesh + * @param {import('@babylonjs/core').Mesh} playedMesh - played mesh */ async play(playedMesh) { const drawable = getDrawable(playedMesh) - /** @type {Mesh[]} */ + /** @type {import('@babylonjs/core').Mesh[]} */ if ( !this.enabled || !drawable || @@ -300,7 +277,7 @@ export class HandManager { * 2. if player is current player, same as draw() with a local action in control manager. * 3. if player is a peer, displays a peer indicator * - * @param {SerializedMesh} state - the state of the drawn mesh. + * @param {import('@tabulous/types').Mesh} state - the state of the drawn mesh. * @param {string} playerId - id of the peer who drawn mesh. */ async applyDraw(state, playerId) { @@ -335,7 +312,7 @@ export class HandManager { * 5. runs animation on the main scene (fades in and descends) * 6. if player is a peer, displays a peer indicator * - * @param {SerializedMesh} state - the state of the played mesh. + * @param {import('@tabulous/types').Mesh} state - the state of the played mesh. * @param {string} playerId - id of the peer who played mesh. */ async applyPlay(state, playerId) { @@ -372,7 +349,7 @@ export class HandManager { /** * Indicates when the user pointer (in screen coordinate) is over the hand. - * @param {MouseEvent|ScreenPosition|undefined} position - pointer or mouse event. + * @param {MouseEvent|import('../utils').ScreenPosition|undefined} position - pointer or mouse event. * @returns whether the pointer is over the hand or not. */ isPointerInHand(position) { @@ -382,7 +359,7 @@ export class HandManager { } /** - * @param {?Mesh} [mesh] - tested mesh + * @param {?import('@babylonjs/core').Mesh} [mesh] - tested mesh * @returns whether this mesh is in the hand or not */ isManaging(mesh) { @@ -396,7 +373,7 @@ export class HandManager { /** * @param {HandManager} manager - manager instance. - * @param {import('@src/3d/managers').ActionOrMove} action - applied action. + * @param {import('.').ActionOrMove} action - applied action. */ function handleAction(manager, action) { if ( @@ -415,7 +392,7 @@ function handleAction(manager, action) { /** * @param {HandManager} manager - manager instance. - * @param {DragData} drag - drag details. + * @param {import('.').DragData} drag - drag details. */ async function handDrag(manager, { type, mesh, event }) { const { handScene, managers } = manager @@ -442,9 +419,9 @@ async function handDrag(manager, { type, mesh, event }) { if (moved.length && isHandMeshNextToMain(manager, event)) { const { x: positionX, z } = screenToGround(manager.scene, event) const origin = moved[0].absolutePosition.x - /** @type {Mesh[]} */ + /** @type {import('@babylonjs/core').Mesh[]} */ const droppedList = [] - /** @type {?{ mesh: Mesh, position: Vector3, duration?: number }} */ + /** @type {?{ mesh: import('@babylonjs/core').Mesh, position: Vector3, duration?: number }} */ let saved = null for (const movedMesh of [...moved]) { const x = positionX + movedMesh.absolutePosition.x - origin @@ -454,7 +431,7 @@ async function handDrag(manager, { type, mesh, event }) { ) const wasSelected = managers.selection.meshes.has(movedMesh) const mesh = await createMainMesh(manager, movedMesh, { x, z }) - /** @type {?DropZone} */ + /** @type {?import('.').DropZone} */ let dropZone if (droppedList.length) { // when first drawn mesh was dropped on player zone, tries to drop others on top of it. @@ -473,7 +450,7 @@ async function handDrag(manager, { type, mesh, event }) { mesh, position: mesh.absolutePosition.clone(), duration: - /** @type {StackBehavior|AnchorBehavior|QuantityBehavior} */ ( + /** @type {import('../behaviors').StackBehavior|import('../behaviors').AnchorBehavior|import('../behaviors').QuantityBehavior} */ ( dropZone.targetable ).state.duration } @@ -535,7 +512,7 @@ async function handDrag(manager, { type, mesh, event }) { function isMainMeshNextToHand( /** @type {HandManager} */ { transitionMargin, extent: { screenHeight } }, - /** @type {Mesh} */ mesh + /** @type {import('@babylonjs/core').Mesh} */ mesh ) { return (getMeshScreenPosition(mesh)?.y ?? 0) > screenHeight - transitionMargin } @@ -549,8 +526,8 @@ function isHandMeshNextToMain( /** * @param {HandManager} manager - manager instance. - * @param {Mesh} handMesh - mesh transfered from hand to main scene. - * @param {Partial} [extraState] - optional state used to create the new mesh. + * @param {import('@babylonjs/core').Mesh} handMesh - mesh transfered from hand to main scene. + * @param {Partial} [extraState] - optional state used to create the new mesh. * @returns created mesh. */ async function createMainMesh(manager, handMesh, extraState = {}) { @@ -566,8 +543,8 @@ async function createMainMesh(manager, handMesh, extraState = {}) { /** * @param {HandManager} manager - manager instance. - * @param {Mesh} mainMesh - mesh transfered from main to hand scene. - * @param {Partial} [extraState] - optional state used to create the new mesh. + * @param {import('@babylonjs/core').Mesh} mainMesh - mesh transfered from main to hand scene. + * @param {Partial} [extraState] - optional state used to create the new mesh. * @returns created mesh. */ async function createHandMesh(manager, mainMesh, extraState = {}) { @@ -577,8 +554,8 @@ async function createHandMesh(manager, mainMesh, extraState = {}) { } function record( - /** @type {Mesh} */ mesh, - /** @type {import('@src/3d/managers').Managers} */ managers, + /** @type {import('@babylonjs/core').Mesh} */ mesh, + /** @type {import('.').Managers} */ managers, /** @type {actionNames['play'] | actionNames['draw']} */ fn, /** @type {string} */ playerId, /** @type {boolean} */ isLocal = false, @@ -600,7 +577,7 @@ function record( function computeExtent( /** @type {HandManager} */ manager, - /** @type {Engine} */ engine + /** @type {import('@babylonjs/core').Engine} */ engine ) { const { handScene } = manager const size = getViewPortSize(engine) @@ -652,7 +629,7 @@ async function layoutMeshs(/** @type {HandManager} */ manager) { extent, onHandChangeObservable } = manager - const meshes = /** @type {Mesh[]} */ ( + const meshes = /** @type {import('@babylonjs/core').Mesh[]} */ ( [...dimensionsByMeshId.keys()] .map(id => handScene.getMeshById(id)) .filter(Boolean) @@ -699,25 +676,29 @@ async function layoutMeshs(/** @type {HandManager} */ manager) { } /** @returns {EngineDimension} this engine's dimention. */ -function getViewPortSize(/** @type {Engine} */ engine) { +function getViewPortSize( + /** @type {import('@babylonjs/core').Engine} */ engine +) { return { width: engine.getRenderWidth(), height: engine.getRenderHeight() } } -function animateToHand(/** @type {Mesh} */ mesh) { +function animateToHand(/** @type {import('@babylonjs/core').Mesh} */ mesh) { mesh.isPhantom = true - const drawable = /** @type {DrawBehavior} */ (getDrawable(mesh)) + const drawable = /** @type {import('../behaviors').DrawBehavior} */ ( + getDrawable(mesh) + ) mesh.onAnimationEnd.addOnce(() => mesh.dispose()) return drawable.animateToHand() } -function getDrawable(/** @type {Mesh} */ mesh) { +function getDrawable(/** @type {import('@babylonjs/core').Mesh} */ mesh) { return mesh?.getBehaviorByName(DrawBehaviorName) } -function transformOnPick(/** @type {SerializedMesh} */ state) { +function transformOnPick(/** @type {import('@tabulous/types').Mesh} */ state) { const { drawable } = state if (!drawable) return if (state.flippable?.isFlipped && drawable.unflipOnPick) { @@ -733,7 +714,7 @@ function transformOnPick(/** @type {SerializedMesh} */ state) { function transformOnPlay( /** @type {HandManager} */ { angleOnPlay }, - /** @type {Mesh} */ mesh + /** @type {import('@babylonjs/core').Mesh} */ mesh ) { const flippable = mesh.getBehaviorByName(FlipBehaviorName) const drawable = getDrawable(mesh) @@ -750,30 +731,32 @@ function transformOnPlay( /** @returns whether this selected mesh is drawable. */ function hasSelectedDrawableMeshes( - /** @type {?Mesh|undefined} */ mesh, - /** @type {import('@src/3d/managers').Managers} */ managers + /** @type {?import('@babylonjs/core').Mesh|undefined} */ mesh, + /** @type {import('.').Managers} */ managers ) { return ( Boolean(mesh) && managers.selection - .getSelection(/** @type {Mesh} */ (mesh)) + .getSelection(/** @type {import('@babylonjs/core').Mesh} */ (mesh)) .some(mesh => mesh.getBehaviorByName(DrawBehaviorName)) ) } async function playMeshes( /** @type {HandManager} */ manager, - /** @type {Mesh[]} */ meshes + /** @type {import('@babylonjs/core').Mesh[]} */ meshes ) { const { extent, scene, managers } = manager - /** @type {?Mesh} */ + /** @type {?import('@babylonjs/core').Mesh} */ let dropped = null - /** @type {Mesh[]} */ + /** @type {import('@babylonjs/core').Mesh[]} */ const created = [] for (const drawnMesh of meshes) { logger.info({ mesh: drawnMesh }, `play mesh ${drawnMesh.id} from hand`) const screenPosition = { - x: /** @type {ScreenPosition} */ (getMeshScreenPosition(drawnMesh)).x, + x: /** @type {import('../utils').ScreenPosition} */ ( + getMeshScreenPosition(drawnMesh) + ).x, y: extent.size.height * 0.5 } const position = screenToGround(scene, screenPosition) @@ -786,7 +769,7 @@ async function playMeshes( z: position.z }) created.push(mesh) - /** @type {?DropZone} */ + /** @type {?import('.').DropZone} */ let dropZone = null if (dropped) { // when first drawn mesh was dropped on player zone, tries to drop others on top of it. @@ -823,8 +806,8 @@ async function playMeshes( } function findStackZone( - /** @type {import('@src/3d/managers').Managers} */ managers, - /** @type {Mesh} */ mesh + /** @type {import('.').Managers} */ managers, + /** @type {import('@babylonjs/core').Mesh} */ mesh ) { mesh.computeWorldMatrix(true) return managers.target.findDropZone( @@ -834,9 +817,9 @@ function findStackZone( } function canDropAbove( - /** @type {import('@src/3d/managers').Managers} */ managers, - /** @type {Mesh} */ baseMesh, - /** @type {Mesh} */ dropped + /** @type {import('.').Managers} */ managers, + /** @type {import('@babylonjs/core').Mesh} */ baseMesh, + /** @type {import('@babylonjs/core').Mesh} */ dropped ) { const positionSave = dropped.absolutePosition.clone() dropped.setAbsolutePosition( @@ -853,7 +836,7 @@ function canDropAbove( async function pickMesh( /** @type {HandManager} */ manager, - /** @type {Mesh} */ mesh, + /** @type {import('@babylonjs/core').Mesh} */ mesh, isLocal = false ) { logger.info({ mesh }, `pick mesh ${mesh.id} in hand`) diff --git a/apps/web/src/3d/managers/index.js b/apps/web/src/3d/managers/index.js index d37a9697..b0ee8a19 100644 --- a/apps/web/src/3d/managers/index.js +++ b/apps/web/src/3d/managers/index.js @@ -1,3 +1,4 @@ +// @ts-check /** * @typedef {object} Managers * @property {import('@src/3d/managers/camera').CameraManager} camera diff --git a/apps/web/src/3d/managers/indicator.js b/apps/web/src/3d/managers/indicator.js index eecd93e8..2c504178 100644 --- a/apps/web/src/3d/managers/indicator.js +++ b/apps/web/src/3d/managers/indicator.js @@ -1,14 +1,4 @@ // @ts-check -/** - * @typedef {import('@babylonjs/core').Animatable} Animatable - * @typedef {import('@babylonjs/core').Mesh} Mesh - * @typedef {import('@babylonjs/core').Scene} Scene - * @typedef {import('@tabulous/server/src/graphql').ActionName} ActionName - * @typedef {import('@tabulous/server/src/graphql').ZoomSpec} ZoomSpec - * @typedef {import('@src/3d/managers/camera').CameraPosition} CameraPosition - * @typedef {import('@src/3d/utils').ScreenPosition} ScreenPosition - */ - import { BoundingBox } from '@babylonjs/core/Culling/boundingBox.js' import { Vector3 } from '@babylonjs/core/Maths/math.vector.js' import { Observable } from '@babylonjs/core/Misc/observable.js' @@ -20,14 +10,14 @@ import { getMeshScreenPosition, getScreenPosition } from '../utils/vector' /** * @typedef {object} MeshSizeIndicator indicates the size (or quantity) of a given mesh. * @property {string} id - indicator unique id. - * @property {Mesh} mesh - concerned mesh. + * @property {import('@babylonjs/core').Mesh} mesh - concerned mesh. * @property {number} size - size displayed. */ /** * @typedef {object} MeshPlayerIndicator indicates belonging to a specific player. * @property {string} id - indicator unique id. - * @property {Mesh} mesh - concerned mesh. + * @property {import('@babylonjs/core').Mesh} mesh - concerned mesh. * @property {string} playerId - id of the related player. */ @@ -39,7 +29,7 @@ import { getMeshScreenPosition, getScreenPosition } from '../utils/vector' /** * @typedef {object} FeedbackIndicator temporary feedback for a given action. - * @property {ActionName|'unlock'|'lock'} action - the related action + * @property {import('@tabulous/types').ActionName|'unlock'|'lock'} action - the related action * @property {number[]} position - feedback position in 3D coordinates. * @property {string} [playerId] - id of the related player, if any. */ @@ -47,7 +37,7 @@ import { getMeshScreenPosition, getScreenPosition } from '../utils/vector' /** * @typedef {object} _ManagedIndicator * @property {string} id - indicator unique id. - * @property {ScreenPosition} screenPosition - 2D position in pixels. + * @property {import('../utils').ScreenPosition} screenPosition - 2D position in pixels. * @property {boolean} [isFeedback] - indicates temporary feedback. * * @typedef {(MeshSizeIndicator|MeshPlayerIndicator) & _ManagedIndicator} ManagedIndicator indicator managed by this manager. @@ -63,10 +53,10 @@ export class IndicatorManager { /** * Creates a manager for indications above meshes. * @param {object} params - parameters, including: - * @param {Scene} params.scene - main scene + * @param {import('@babylonjs/core').Scene} params.scene - main scene */ constructor({ scene }) { - /** @type {Scene} the main scene. */ + /** the main scene. */ this.scene = scene /** @type {Observable} emits when the indicator list has changed. */ this.onChangeObservable = new Observable() @@ -216,7 +206,7 @@ function handleFrame(/** @type {IndicatorManager} */ manager) { */ function setMeshPosition(indicator) { const { depth } = getDimensions(indicator.mesh) - const { x, y } = /** @type {ScreenPosition} */ ( + const { x, y } = /** @type {import('../utils').ScreenPosition} */ ( getMeshScreenPosition(indicator.mesh, [0, 0, depth / 2]) ) const hasChanged = diff --git a/apps/web/src/3d/managers/input.js b/apps/web/src/3d/managers/input.js index cf206e4a..b741ae3c 100644 --- a/apps/web/src/3d/managers/input.js +++ b/apps/web/src/3d/managers/input.js @@ -1,9 +1,4 @@ // @ts-check -/** - * @typedef {import('@babylonjs/core').Mesh} Mesh - * @typedef {import('@src/3d/managers/camera').CameraPosition} CameraPosition - */ - import { Observable } from '@babylonjs/core/Misc/observable.js' import { Scene } from '@babylonjs/core/scene.js' @@ -30,7 +25,7 @@ const DragMinimumDistance = 5 /** * @typedef {object} _TapData - * @property {?Mesh} mesh - mesh (if any) bellow the pointer. + * @property {?import('@babylonjs/core').Mesh} mesh - mesh (if any) bellow the pointer. * @property {number} button - the pointer button used * @property {number} pointers - number of pointers pressed. * @property {boolean} fromHand - whether the event occured on the hand or the main scene. @@ -41,7 +36,7 @@ const DragMinimumDistance = 5 /** * @typedef {object} _DragData - * @property {?Mesh} mesh - mesh (if any) bellow the pointer. + * @property {?import('@babylonjs/core').Mesh} mesh - mesh (if any) bellow the pointer. * @property {number} button - the pointer button used * @property {number} pointers - number of pointers pressed. * @property {boolean} [long] - whether pinch started with a long press ('dragStart' only). @@ -51,7 +46,7 @@ const DragMinimumDistance = 5 /** * @typedef {object} _KeyData - * @property {?Mesh} mesh - mesh upon which event occured. + * @property {?import('@babylonjs/core').Mesh} mesh - mesh upon which event occured. * @property {KeyModifiers} modifiers - for key event, active modifiers. * @property {string} key - which key was pressed * @see https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_key_values @@ -61,7 +56,7 @@ const DragMinimumDistance = 5 /** * @typedef {object} _WheelData - * @property {?Mesh} mesh - mesh upon which event occured. + * @property {?import('@babylonjs/core').Mesh} mesh - mesh upon which event occured. * * @typedef {EventData<'wheel', WheelEvent> & _WheelData} WheelData Mouse wheel events. */ @@ -84,7 +79,7 @@ const DragMinimumDistance = 5 /** * @typedef {object} _HoverData - * @property {Mesh} mesh - mesh upon which event occured. + * @property {import('@babylonjs/core').Mesh} mesh - mesh upon which event occured. * * @typedef {EventData<'hoverStart'|'hoverStop', PointerEvent> & _HoverData} HoverData Hover events. */ @@ -99,7 +94,7 @@ const DragMinimumDistance = 5 /** * @typedef {object} StoredPointer - * @property {?Mesh} mesh - mesh (if any) bellow the pointer. + * @property {?import('@babylonjs/core').Mesh} mesh - mesh (if any) bellow the pointer. * @property {number} button - the pointer button used * @property {PointerEvent} event - the original event object. * @property {number} timestamp - input timestamp in milliseconds. @@ -150,7 +145,7 @@ export class InputManager { this.interaction = interaction /** @internal */ interaction.style.setProperty('--cursor', 'move') - /** @internal @type {import('@src/3d/managers').Managers} */ + /** @internal @type {import('.').Managers} */ this.managers /** @internal */ } @@ -158,7 +153,7 @@ export class InputManager { /** * Initializes with other managers. * @param {object} params - parameters, including: - * @param {import('@src/3d/managers').Managers} params.managers - current managers. + * @param {import('.').Managers} params.managers - current managers. */ init({ managers }) { // same finger/stylus/mouse will have same pointerId for down, move(s) and up events @@ -176,7 +171,7 @@ export class InputManager { } /** @type {?StoredPointer} */ let dragOrigin = null - /** @type {Map} */ + /** @type {Map} */ let hoveredByPointerId = new Map() let lastTap = 0 let lastMoveEvent = /** @type {PointerEvent} */ ({}) @@ -196,7 +191,7 @@ export class InputManager { const startHover = ( /** @type {PointerEvent} */ event, - /** @type {Mesh} */ mesh + /** @type {import('@babylonjs/core').Mesh} */ mesh ) => { if (hoveredByPointerId.get(event.pointerId) !== mesh) { /** @type {HoverData} */ @@ -610,18 +605,20 @@ export class InputManager { /** * @param {Scene} scene - scene in which mesh are picked. - * @param {import('@src/3d/managers').Managers} managers - other managers. + * @param {import('.').Managers} managers - other managers. * @param {MouseEvent} event - picking event. * @returns picked mesh, if any. */ function findPickedMesh(scene, { selection }, { x, y }) { - return /** @type {?Mesh} */ ( + return /** @type {?import('@babylonjs/core').Mesh} */ ( scene .multiPickWithRay( scene.createPickingRay(x, y, null, null), mesh => mesh.isPickable && - !selection.isSelectedByPeer(/** @type {Mesh} */ (mesh)) + !selection.isSelectedByPeer( + /** @type {import('@babylonjs/core').Mesh} */ (mesh) + ) ) ?.sort((a, b) => a.distance - b.distance)[0]?.pickedMesh ?? null ) diff --git a/apps/web/src/3d/managers/material.js b/apps/web/src/3d/managers/material.js index db75136e..ca2f0849 100644 --- a/apps/web/src/3d/managers/material.js +++ b/apps/web/src/3d/managers/material.js @@ -1,11 +1,4 @@ // @ts-check -/** - * @typedef {import('@babylonjs/core').Scene} Scene - * @typedef {import('@babylonjs/core').Mesh} Mesh - * @typedef {import('@babylonjs/core').ShadowGenerator} ShadowGenerator - * @typedef {import('@src/graphql').Game} Game - */ - // mandatory side effect import '@babylonjs/core/Lights/Shadows/shadowGeneratorSceneComponent.js' @@ -43,8 +36,8 @@ export class MaterialManager { * - allows using the same texture in between hand and main scene * - automatically clears cache scene disposal. * @param {object} params - parameters, including: - * @param {Scene} params.scene - main scene. - * @param {Scene} [params.handScene] - scene for meshes in hand. + * @param {import('@babylonjs/core').Scene} params.scene - main scene. + * @param {import('@babylonjs/core').Scene} [params.handScene] - scene for meshes in hand. * @param {string} [params.gameAssetsUrl] - base url hosting the game textures. * @param {string} [params.locale] - locale used to download the game textures. * @param {boolean} [params.isWebGL1] - true if the rendering engine only supports WebGL1. @@ -74,7 +67,7 @@ export class MaterialManager { /** * Initializes with game data. - * @param {Game} game - loaded game data. + * @param {import('@src/graphql').Game} game - loaded game data. */ init(game) { if (!this.disabled) { @@ -85,7 +78,7 @@ export class MaterialManager { /** * Creates a material from provided texture and attaches it to a mesh. * Configures mesh to receive shadows and to have an overlay color. - * @param {Mesh} mesh - related mesh. + * @param {import('@babylonjs/core').Mesh} mesh - related mesh. * @param {string} texture - texture url or hexadecimal string color. */ configure(mesh, texture) { @@ -102,7 +95,7 @@ export class MaterialManager { * Creates or reuse an existing material, on a given scene. * It is not attached to any mesh * @param {string} texture - texture url or hexadecimal string color. - * @param {Scene} scene - scene used. + * @param {import('@babylonjs/core').Scene} scene - scene used. * @returns the build (or cached) material. */ buildOnDemand(texture, scene) { @@ -139,7 +132,7 @@ export class MaterialManager { /** * @param {MaterialManager} manager - manager instance. - * @param {Scene} scene - concerned scene. + * @param {import('@babylonjs/core').Scene} scene - concerned scene. * @returns map of cached materials for this scene. */ function getMaterialCache(manager, scene) { @@ -150,7 +143,7 @@ function getMaterialCache(manager, scene) { function preloadMaterials( /** @type {MaterialManager} */ manager, - /** @type {Game} */ game + /** @type {import('@src/graphql').Game} */ game ) { for (const { texture } of [ ...(game.meshes ?? []), @@ -163,7 +156,7 @@ function preloadMaterials( /** * @param {MaterialManager} manager - manager instance. * @param {string} url - material texture url or color code. - * @param {Scene} usedScene - concerned scene. + * @param {import('@babylonjs/core').Scene} usedScene - concerned scene. * @returns built material. */ function buildMaterials(manager, url, usedScene) { @@ -186,7 +179,7 @@ function buildMaterials(manager, url, usedScene) { * @param {Map} materialByUrl cached material for this scene * @param {MaterialManager} manager - manager instance. * @param {string} url - material texture url or color code. - * @param {Scene} scene - concerned scene. + * @param {import('@babylonjs/core').Scene} scene - concerned scene. * @returns built material. */ function buildMaterial( @@ -249,9 +242,10 @@ function adaptTextureUrl(base, texture, locale, isWebGL1) { function attachMaterialError(material) { material.onError = (effect, errors) => { if (errors?.includes('FRAGMENT SHADER')) { - const shadowGenerator = /** @type {ShadowGenerator} */ ( - material.getScene().lights[0].getShadowGenerator() - ) + const shadowGenerator = + /** @type {import('@babylonjs/core').ShadowGenerator} */ ( + material.getScene().lights[0].getShadowGenerator() + ) shadowGenerator.usePercentageCloserFiltering = false shadowGenerator.useContactHardeningShadow = false } diff --git a/apps/web/src/3d/managers/move.js b/apps/web/src/3d/managers/move.js index afca11a9..12a2844a 100644 --- a/apps/web/src/3d/managers/move.js +++ b/apps/web/src/3d/managers/move.js @@ -1,12 +1,4 @@ // @ts-check -/** - * @typedef {import('@babylonjs/core').Mesh} Mesh - * @typedef {import('@babylonjs/core').Scene} Scene - * @typedef {import('@src/3d/behaviors/movable').MoveBehavior} MoveBehavior - * @typedef {import('@src/3d/managers/target').DropZone} DropZone - * @typedef {import('@src/3d/utils').ScreenPosition} ScreenPosition - */ - import { BoundingInfo } from '@babylonjs/core/Culling/boundingInfo.js' import { Vector3 } from '@babylonjs/core/Maths/math.vector.js' import { Observable } from '@babylonjs/core/Misc/observable.js' @@ -22,12 +14,12 @@ const logger = makeLogger('move') /** * @typedef {object} MoveDetails - * @property {Mesh} mesh - moved mesh. + * @property {import('@babylonjs/core').Mesh} mesh - moved mesh. */ /** * @typedef {object} PreMoveDetails - * @property {Mesh[]} meshes - meshes that are about to be moved. + * @property {import('@babylonjs/core').Mesh[]} meshes - meshes that are about to be moved. */ export class MoveManager { @@ -41,7 +33,7 @@ export class MoveManager { * Prior to move operation, the onPreMoveObservable allows to add or remove meshes to the list. * Invokes init() before any other function. * @param {object} params - parameters, including: - * @param {Scene} params.scene - scene attached to. + * @param {import('@babylonjs/core').Scene} params.scene - scene attached to. * @param {number} [params.elevation=0.5] - elevation applied to meshes while dragging them. */ constructor({ scene, elevation = 0.5 }) { @@ -57,18 +49,18 @@ export class MoveManager { this.scene = scene /** @internal @type {Set} managed mesh ids. */ this.meshIds = new Set() - /** @internal @type {Map} managed behaviors by their mesh id. */ + /** @internal @type {Map} managed behaviors by their mesh id. */ this.behaviorByMeshId = new Map() - /** @internal @type {Set} set of meshes to re-select after moving them. */ + /** @internal @type {Set} set of meshes to re-select after moving them. */ this.autoSelect = new Set() - /** @internal @type {import('@src/3d/managers').Managers} */ + /** @internal @type {import('.').Managers} */ this.managers } /** * Initializes with other managers. * @param {object} params - parameters, including: - * @param {import('@src/3d/managers').Managers} params.managers - current managers. + * @param {import('.').Managers} params.managers - current managers. */ init({ managers }) { this.managers = managers @@ -78,8 +70,8 @@ export class MoveManager { * Start moving a managed mesh, recording its position. * If it is part of the active selection, moves the entire selection. * Does nothing on unmanaged meshes or mesh with disabled behavior. - * @param {Mesh} mesh - to be moved. - * @param {ScreenPosition} event - mouse or touch event containing the screen position. + * @param {import('@babylonjs/core').Mesh} mesh - to be moved. + * @param {import('../utils').ScreenPosition} event - mouse or touch event containing the screen position. */ start(mesh, event) { if (!this.isManaging(mesh) || isDisabled(this, mesh)) { @@ -93,11 +85,13 @@ export class MoveManager { mesh => this.isManaging(mesh) && mesh.getScene() === sceneUsed ) : [mesh] - /** @type {Mesh[]} */ + /** @type {import('@babylonjs/core').Mesh[]} */ let moved = [] for (const mesh of meshes) { if ( - !meshes.includes(/** @type {Mesh} */ (mesh.parent)) && + !meshes.includes( + /** @type {import('@babylonjs/core').Mesh} */ (mesh.parent) + ) && !isDisabled(this, mesh) ) { moved.push(mesh) @@ -106,7 +100,7 @@ export class MoveManager { let lastPosition = screenToGround(sceneUsed, event) - /** @type {Set} */ + /** @type {Set} */ let zones = new Set() this.inProgress = true const actionObserver = this.managers.control.onActionObservable.add( @@ -133,7 +127,9 @@ export class MoveManager { } ) - const deselectAuto = (/** @type {(?Mesh)[]} */ meshes) => { + const deselectAuto = ( + /** @type {(?import('@babylonjs/core').Mesh)[]} */ meshes + ) => { for (const mesh of meshes) { if (mesh && this.autoSelect.has(mesh)) { this.managers.selection.unselect(mesh) @@ -142,7 +138,9 @@ export class MoveManager { } } - const startMoving = (/** @type {Mesh} */ mesh) => { + const startMoving = ( + /** @type {import('@babylonjs/core').Mesh} */ mesh + ) => { if (!this.managers.selection.meshes.has(mesh)) { this.autoSelect.add(mesh) this.managers.selection.select(mesh) @@ -158,7 +156,9 @@ export class MoveManager { } // dynamically assign continue function to keep moved, zones and lastPosition in scope - this.continue = (/** @type {ScreenPosition} */ event) => { + this.continue = ( + /** @type {import('../utils').ScreenPosition} */ event + ) => { if (moved.length === 0) return for (const zone of zones) { this.managers.target.clear(zone) @@ -188,8 +188,9 @@ export class MoveManager { mesh.setAbsolutePosition(mesh.absolutePosition.addInPlace(move)) const zone = this.managers.target.findDropZone( mesh, - /** @type {MoveBehavior} */ (this.behaviorByMeshId.get(mesh.id)) - .state.kind + /** @type {import('../behaviors').MoveBehavior} */ ( + this.behaviorByMeshId.get(mesh.id) + ).state.kind ) if (zone) { zones.add(zone) @@ -213,12 +214,14 @@ export class MoveManager { this.getActiveZones = () => [...zones] // dynamically assign exclude function to keep moved in scope - this.isMoving = (/** @type {?Mesh} */ mesh) => { + this.isMoving = (/** @type {?import('@babylonjs/core').Mesh} */ mesh) => { return moved.some(({ id }) => id === mesh?.id) } // dynamically assign exclude function to keep moved in scope - this.exclude = (/** @type {(?Mesh)[]} */ ...meshes) => { + this.exclude = ( + /** @type {(?import('@babylonjs/core').Mesh)[]} */ ...meshes + ) => { moved = moved.filter(({ id }) => meshes.every(excluded => excluded?.id !== id) ) @@ -226,7 +229,9 @@ export class MoveManager { } // dynamically assign include function to keep moved in scope - this.include = (/** @type {(?Mesh)[]} */ ...meshes) => { + this.include = ( + /** @type {(?import('@babylonjs/core').Mesh)[]} */ ...meshes + ) => { for (const mesh of meshes) { if ( mesh && @@ -257,7 +262,7 @@ export class MoveManager { deselectAuto(moved) // trigger drop operation on all identified drop zones - /** @type {Mesh[]} */ + /** @type {import('@babylonjs/core').Mesh[]} */ const dropped = [] for (const zone of zones) { const meshes = this.managers.target.dropOn(zone) @@ -281,7 +286,9 @@ export class MoveManager { const { x, y, z } = mesh.absolutePosition const { state: { snapDistance, duration } - } = /** @type {MoveBehavior} */ (this.behaviorByMeshId.get(mesh.id)) + } = /** @type {import('../behaviors').MoveBehavior} */ ( + this.behaviorByMeshId.get(mesh.id) + ) const absolutePosition = new Vector3( Math.round(x / snapDistance) * snapDistance, y, @@ -320,7 +327,7 @@ export class MoveManager { * Updates the last position and identifies potential targets. * Stops the operation when the pointer leaves the table. * Does nothing if the operation was not started, or stopped. - * @param {ScreenPosition} event - mouse or touch event containing the screen position. + * @param {import('../utils').ScreenPosition} event - mouse or touch event containing the screen position. */ // eslint-disable-next-line no-unused-vars continue(event) {} @@ -328,7 +335,7 @@ export class MoveManager { /** * Removes some of the moved meshes. * They will stay with their current position. - * @param {...?Mesh} meshes - excluded meshes. + * @param {...?import('@babylonjs/core').Mesh} meshes - excluded meshes. */ // eslint-disable-next-line no-unused-vars exclude(...meshes) {} @@ -336,7 +343,7 @@ export class MoveManager { /** * Adds some meshes to the moving selection. * Does nothing if no operation is in progress. - * @param {...?Mesh} meshes - included meshes. + * @param {...?import('@babylonjs/core').Mesh} meshes - included meshes. */ // eslint-disable-next-line no-unused-vars include(...meshes) {} @@ -350,14 +357,14 @@ export class MoveManager { /** * Returns all drop zones actives while moving meshes - * @returns {(DropZone)[]} an array (possibly empty) of active zones + * @returns {(import('.').DropZone)[]} an array (possibly empty) of active zones */ getActiveZones() { return [] } /** - * @param {?Mesh} mesh - tested mesh + * @param {?import('@babylonjs/core').Mesh} mesh - tested mesh * @returns whether this mesh is being moved */ // eslint-disable-next-line no-unused-vars @@ -368,7 +375,7 @@ export class MoveManager { /** * Registers a new MoveBehavior, making it possible to move its mesh. * Does nothing if this behavior is already managed. - * @param {?MoveBehavior} behavior - movable behavior + * @param {?import('../behaviors').MoveBehavior} behavior - movable behavior */ registerMovable(behavior) { if (behavior?.mesh?.id) { @@ -380,7 +387,7 @@ export class MoveManager { /** * Unregisters an existing MoveBehavior. * Does nothing on unmanaged behaviors. - * @param {?MoveBehavior} behavior - movable behavior + * @param {?import('../behaviors').MoveBehavior} behavior - movable behavior */ unregisterMovable(behavior) { if ( @@ -394,7 +401,7 @@ export class MoveManager { } /** - * @param {?Mesh} [mesh] - tested mesh + * @param {?import('@babylonjs/core').Mesh} [mesh] - tested mesh * @returns whether this mesh is controlled or not */ isManaging(mesh) { @@ -403,7 +410,7 @@ export class MoveManager { /** * Notify listerners of moving meshes - * @param {...Mesh} meshes - moving meshes + * @param {...import('@babylonjs/core').Mesh} meshes - moving meshes */ notifyMove(...meshes) { for (const mesh of meshes) { @@ -413,7 +420,7 @@ export class MoveManager { } /** - * @param {Mesh[]} moved - moved meshes. + * @param {import('@babylonjs/core').Mesh[]} moved - moved meshes. * @returns bounding box info for this group of meshes. */ function computeMovedExtend(moved) { @@ -439,9 +446,9 @@ function computeMovedExtend(moved) { } /** - * @param {Scene} scene - scene used for moving meshes . - * @param {import('@src/3d/managers').Managers} managers - other managers. - * @param {Mesh[]} moved - moved meshes. + * @param {import('@babylonjs/core').Scene} scene - scene used for moving meshes . + * @param {import('.').Managers} managers - other managers. + * @param {import('@babylonjs/core').Mesh[]} moved - moved meshes. * @param {Vector3} min - moved mesh bounding box minimum. * @returns list of possibly colliding bounding boxes. */ @@ -502,7 +509,7 @@ function elevateWhenColliding(boundingBoxes, min, max) { /** * @param {MoveManager} manager - manager instance. - * @param {Mesh} mesh - tested mesh. + * @param {import('@babylonjs/core').Mesh} mesh - tested mesh. * @returnswhether this mesh could be moved. */ function isDisabled({ behaviorByMeshId, managers }, mesh) { diff --git a/apps/web/src/3d/managers/replay.js b/apps/web/src/3d/managers/replay.js index db2d0c57..a0603d89 100644 --- a/apps/web/src/3d/managers/replay.js +++ b/apps/web/src/3d/managers/replay.js @@ -17,21 +17,21 @@ export class ReplayManager { constructor({ engine, moveDuration = 200 }) { /** game engin. */ this.engine = engine - /** @type {import('@tabulous/server/src/graphql').HistoryRecord[]} list of available history records. */ + /** @type {import('@tabulous/types').HistoryRecord[]} list of available history records. */ this.history = [] /** @type {number} current rank when replaying records */ this.rank = 0 - /** @type {Observable} emits when history has changed. */ + /** @type {Observable} emits when history has changed. */ this.onHistoryObservable = new Observable() /** @type {Observable} emits when the replay ranks is modified. */ this.onReplayRankObservable = new Observable() - /** @type {import('@babylonjs/core').Observer?} */ + /** @type {import('@babylonjs/core').Observer?} */ this.actionObserver /** @internal avoid concurrent replays */ this.inhibitReplay = false /** @internal */ this.moveDuration = moveDuration - /** @internal @type {import('@src/3d/managers').Managers} */ + /** @internal @type {import('.').Managers} */ this.managers /** @internal @type {string} */ this.playerId @@ -48,8 +48,8 @@ export class ReplayManager { * Set the initial history, and connects to the control manager to record new local actions. * @param {object} params - parameters, including: * @param {string} params.playerId - id of the local player. - * @param {import('@src/3d/managers').Managers} params.managers - current managers. - * @param {import('@tabulous/server/src/graphql').HistoryRecord[]} [params.history] - initial history. + * @param {import('.').Managers} params.managers - current managers. + * @param {import('@tabulous/types').HistoryRecord[]} [params.history] - initial history. */ init({ managers, history = [], playerId }) { this.managers = managers @@ -68,7 +68,7 @@ export class ReplayManager { /** * Reset records and rank, and notifies listeners. - * @param {import('@tabulous/server/src/graphql').HistoryRecord[]} [history] - history content. + * @param {import('@tabulous/types').HistoryRecord[]} [history] - history content. */ reset(history = []) { this.history = history @@ -81,7 +81,7 @@ export class ReplayManager { * Record a new action or move into history. * It collapses redundant moves together and notifies listeners. * Only updates current rank if it was on last. - * @param {import('@src/3d/managers').ActionOrMove} record - received record. + * @param {import('.').ActionOrMove} record - received record. * @param {string} [playerId] - id of the player who sent the record. */ record(record, playerId = this.playerId) { @@ -94,13 +94,11 @@ export class ReplayManager { 'adding to the history' ) const needsRankUpdate = this.rank === this.history.length - /** @type {import('@tabulous/server/src/graphql').HistoryRecord} */ + /** @type {import('@tabulous/types').HistoryRecord} */ const result = 'fn' in record ? { - fn: /** @type {import('@tabulous/server/src/graphql').ActionName} */ ( - record.fn - ), + fn: /** @type {import('@tabulous/types').ActionName} */ (record.fn), argsStr: JSON.stringify(record.args), revertStr: record.revert ? JSON.stringify(record.revert) @@ -158,7 +156,7 @@ export class ReplayManager { /** * Apply or revert a given revord, unless it comes from a peer's hand. * @param {ReplayManager} manager - current manager. - * @param {import('@tabulous/server/src/graphql').HistoryRecord} record - concerned record. + * @param {import('@tabulous/types').HistoryRecord} record - concerned record. * @param {boolean} [reverting] - whether the record should be reverted (true) or applied (false). */ async function apply( @@ -205,8 +203,8 @@ async function apply( * Appends a record to history. Also handles these cases: * - when moving a mesh, if this mesh's previous action is a move by the same player in the same scene, then collapse moves. * - when moving a mesh, if this mesh's previous action is a draw by the same player, then ignore the move on table. - * @param {import('@tabulous/server/src/graphql').HistoryRecord[]} history - history of records. - * @param {import('@tabulous/server/src/graphql').HistoryRecord} added - candidate record to add. + * @param {import('@tabulous/types').HistoryRecord[]} history - history of records. + * @param {import('@tabulous/types').HistoryRecord} added - candidate record to add. * @returns the collapsed history. */ function collapseAndAppendHistory(history, added) { diff --git a/apps/web/src/3d/managers/score.js b/apps/web/src/3d/managers/score.js new file mode 100644 index 00000000..77883a07 --- /dev/null +++ b/apps/web/src/3d/managers/score.js @@ -0,0 +1,93 @@ +import { makeLogger } from '../../utils' +import { actionNames } from '../utils/actions' + +const logger = makeLogger('rules') + +const { snap, unsnap } = actionNames + +export class ScoreManager { + /** + * Builds a manager to orchestrate replaying, backward and forward, history records. + * Invokes init() before any other function. + * @param {object} params - parameters, including: + * @param {import('@babylonjs/core').Engine} params.engine - 3d engine. + */ + constructor({ engine }) { + /** game engin. */ + this.engine = engine + /** @type {import('@babylonjs/core').Observer?} */ + this.actionObserver + this.engine.onDisposeObservable.addOnce(() => { + this.managers?.control.onActionObservable.remove(this.actionObserver) + }) + } + + /** + * Initializes with game rules. + * Connects to the control manager to evaluate rules. + * @param {object} params - parameters, including: + * @param {import('@src/3d/managers').Managers} params.managers - current managers. + */ + init({ managers }) { + this.managers = managers + this.actionObserver = managers.control.onActionObservable.add(action => + evaluate(this, action) + ) + logger.debug('rules manager initialized') + } +} + +async function evaluate( + /** @type {ScoreManager} */ { engine }, + /** @type {import('@src/3d/managers').ActionOrMove} */ action +) { + if (!('fn' in action)) { + return + } + const state = engine.serialize() + // 6-takes + if ( + (action.fn === snap || action.fn === unsnap) && + action.meshId === 'board' + ) { + const { snappedId } = findAnchor('score-player-1', state.meshes) + const snapped = findMesh(snappedId, state.meshes, false) + console.log('player 1', snapped?.id) + } +} + +function findAnchor(path, meshes, throwOnMiss = true) { + let candidates = [...(meshes ?? [])] + let anchor + for (let leg of path.split('.')) { + const match = findMeshAndAnchor(leg, candidates, throwOnMiss) + if (!match) { + return null + } + candidates = meshes.filter(({ id }) => id === match.anchor.snappedId) + anchor = match.anchor + } + return anchor ?? null +} + +function findMeshAndAnchor(anchorId, meshes, throwOnMiss = true) { + for (const mesh of meshes) { + for (const anchor of mesh.anchorable?.anchors ?? []) { + if (anchor.id === anchorId) { + return { mesh, anchor } + } + } + } + if (throwOnMiss) { + throw new Error(`No anchor with id ${anchorId}`) + } + return null +} + +function findMesh(id, meshes, throwOnMiss = true) { + const mesh = meshes?.find(mesh => mesh.id === id) ?? null + if (throwOnMiss && !mesh) { + throw new Error(`No mesh with id ${id}`) + } + return mesh +} diff --git a/apps/web/src/3d/managers/selection.js b/apps/web/src/3d/managers/selection.js index 48dc14c4..66abb176 100644 --- a/apps/web/src/3d/managers/selection.js +++ b/apps/web/src/3d/managers/selection.js @@ -50,7 +50,7 @@ export class SelectionManager { this.selectionByPeerId = new Map() /** @internal @type {Map} */ this.colorByPlayerId = new Map() - /** @internal @type {import('@src/3d/managers').Managers} */ + /** @internal @type {import('.').Managers} */ this.managers } @@ -59,7 +59,7 @@ export class SelectionManager { * Updates colors to reflect players in game. * @param {object} params - parameters, including: * @param {string} params.playerId - current player id, to find selection box color. - * @param {import('@src/3d/managers').Managers} params.managers - other managers. + * @param {import('.').Managers} params.managers - other managers. * @param {Map} params.colorByPlayerId - map of hexadecimal color strings used for selection box, and selected mesh overlay, by player id. */ init({ managers, playerId, colorByPlayerId }) { @@ -76,8 +76,8 @@ export class SelectionManager { /** * Draws selection box between two points (in screen coordinates) - * @param {import('@src/3d/utils').ScreenPosition} start - selection box's start screen position. - * @param {import('@src/3d/utils').ScreenPosition} end - selection box's end screen position. + * @param {import('../utils').ScreenPosition} start - selection box's start screen position. + * @param {import('../utils').ScreenPosition} end - selection box's end screen position. */ drawSelectionBox(start, end) { logger.debug({ start, end }, `draw selection box`) diff --git a/apps/web/src/3d/managers/target.js b/apps/web/src/3d/managers/target.js index 0cdcfc1f..b9a3e107 100644 --- a/apps/web/src/3d/managers/target.js +++ b/apps/web/src/3d/managers/target.js @@ -1,15 +1,4 @@ // @ts-check -/** - * @typedef {import('@babylonjs/core').Mesh} Mesh - * @typedef {import('@babylonjs/core').Scene} Scene - * @typedef {import('@babylonjs/core').Vector3} Vector3 - * @typedef {import('@tabulous/server/src/graphql').Anchor} Anchor - * @typedef {import('@tabulous/server/src/graphql').Targetable} Targetable - * @typedef {import('@src/3d/behaviors/targetable').TargetBehavior} TargetBehavior - * @typedef {import('@src/3d/behaviors/targetable').DropDetails} DropDetails - * @typedef {import('@src/3d/utils').ScreenPosition} ScreenPosition - */ - import { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial' import { Color3, Color4 } from '@babylonjs/core/Maths/math.color' @@ -23,19 +12,29 @@ import { isAbove } from '../utils/gravity' const logger = makeLogger('target') +/** + * @typedef {object} DropDetails detailed images definitions for a given mesh: + * @property {import('@babylonjs/core').Mesh[]} dropped - a list of dropped meshes. + * @property {import('.').DropZone} zone - the zone onto meshes are dropped. + * @property {boolean} [immediate=false] - when true, no animation should be ran.dropped. + * @property {boolean} [isLocal=false] - set action locality. + */ + /** * @typedef {object} _SingleDropZone definition of a target drop zone - * @property {TargetBehavior} targetable - the enclosing targetable behavior. - * @property {Mesh} mesh - invisible, unpickable mesh acting as drop zone. + * @property {import('../behaviors').TargetBehavior} targetable - the enclosing targetable behavior. + * @property {import('@babylonjs/core').Mesh} mesh - invisible, unpickable mesh acting as drop zone. * - * @typedef {Record & Targetable & Required> & Pick & Required> & _SingleDropZone} SingleDropZone definition of a target drop zone + * @typedef {Record & Omit & + * Required> & + * _SingleDropZone} SingleDropZone definition of a target drop zone */ /** * @typedef {object} MultiDropZone a virtual drop zone made of several other zones * @property {SingleDropZone[]} parts - a list of part for this zone. - * @property {TargetBehavior} targetable - targetable of the first part. - * @property {Mesh} mesh - mesh of the first part. + * @property {import('../behaviors').TargetBehavior} targetable - targetable of the first part. + * @property {import('@babylonjs/core').Mesh} mesh - mesh of the first part. */ /** @typedef {SingleDropZone|MultiDropZone} DropZone */ @@ -52,7 +51,7 @@ export class TargetManager { * Invokes init() before any other function. * * @param {object} params - parameters, including: - * @param {Scene} params.scene - main scene. + * @param {import('@babylonjs/core').Scene} params.scene - main scene. */ constructor({ scene }) { /** the main scene. */ @@ -61,20 +60,20 @@ export class TargetManager { this.playerId /** @type {Color4} current player color. */ this.color - /** @internal @type {Set} set of managed behaviors. */ + /** @internal @type {Set} set of managed behaviors. */ this.behaviors = new Set() - /** @internal @type {Map} map of droppable meshes by drop zone.*/ + /** @internal @type {Map} map of droppable meshes by drop zone.*/ this.droppablesByDropZone = new Map() /** @internal @type {StandardMaterial} material applied to active drop zones. */ this.material - /** @internal @type {import('@src/3d/managers').Managers} */ + /** @internal @type {import('.').Managers} */ this.managers } /** * Initialize with game data. * @param {object} params - parameters, including: - * @param {import('@src/3d/managers').Managers} params.managers - other managers. + * @param {import('.').Managers} params.managers - other managers. * @param {string} params.playerId - current player Id. * @param {string} params.color - hexadecimal color string used for highlighting targets. */ @@ -91,7 +90,7 @@ export class TargetManager { /** * Registers a new targetable behavior. * Does nothing if this behavior is already managed. - * @param {TargetBehavior} behavior - targetable behavior. + * @param {import('../behaviors').TargetBehavior} behavior - targetable behavior. */ registerTargetable(behavior) { if (behavior?.mesh) { @@ -102,7 +101,7 @@ export class TargetManager { /** * Unregisters a targetable behavior, clearing all its zones. * Does nothing on unmanaged behavior. - * @param {TargetBehavior} behavior - controlled behavior. + * @param {import('../behaviors').TargetBehavior} behavior - controlled behavior. */ unregisterTargetable(behavior) { this.behaviors.delete(behavior) @@ -112,7 +111,7 @@ export class TargetManager { } /** - * @param {Mesh} mesh - tested mesh. + * @param {import('@babylonjs/core').Mesh} mesh - tested mesh. * @returns whether this mesh's target behavior is controlled or not */ isManaging(mesh) { @@ -125,7 +124,7 @@ export class TargetManager { * In case several zones are valid, the one with highest priority, or with highest elevation, will prevail. * The found zone is highlithed, and the dragged mesh will be saved as potential droppable for this zone. * - * @param {Mesh} dragged - a dragged mesh. + * @param {import('@babylonjs/core').Mesh} dragged - a dragged mesh. * @param {string} [kind] - drag kind. * @returns matching zone, if any. */ @@ -148,7 +147,7 @@ export class TargetManager { * In case several zones are bellow the mesh, the one with highest priority, or with highest elevation, will prevail. * The found zone is highlithed, and the dragged mesh will be saved as potential droppable for this zone. * - * @param {Mesh} dragged - a dragged mesh. + * @param {import('@babylonjs/core').Mesh} dragged - a dragged mesh. * @param {string} [kind] - drag kind. * @returns matching zone, if any. */ @@ -232,8 +231,8 @@ export class TargetManager { /** * @param {TargetManager} manager - manager instance. - * @param {Mesh} dragged - dragged mesh to check. - * @param {(zone: SingleDropZone, partCenters: Vector3[]) => boolean} isMatching - matching function to test candidate zones. + * @param {import('@babylonjs/core').Mesh} dragged - dragged mesh to check. + * @param {(zone: SingleDropZone, partCenters: import('@babylonjs/core').Vector3[]) => boolean} isMatching - matching function to test candidate zones. * @param {string} [kind] - dragged kind. * @returns matching zone, if any. */ @@ -249,7 +248,10 @@ function findZone(manager, dragged, isMatching, kind) { } const excluded = [dragged, ...managers.selection.meshes] for (const targetable of behaviors) { - const { mesh } = /** @type {TargetBehavior & { mesh: Mesh }} */ (targetable) + const { mesh } = + /** @type {import('../behaviors').TargetBehavior & { mesh: import('@babylonjs/core').Mesh }} */ ( + targetable + ) if (!excluded.includes(mesh) && mesh.getScene() === scene) { for (const zone of targetable.zones) { if (isMatching(zone, partCenters)) { @@ -283,7 +285,7 @@ function findZone(manager, dragged, isMatching, kind) { /** * @param {TargetManager} manager - manager instance. * @param {?DropZone} zone - matching zone to highlight. - * @param {Mesh} dragged - dragged mesh to check. + * @param {import('@babylonjs/core').Mesh} dragged - dragged mesh to check. * @param {string} [kind] - dragged kind. * @returns matching zone, if any. */ @@ -335,8 +337,8 @@ function sortCandidates(candidates) { } /** - * @param {Mesh} mesh - considered mesh. - * @param {Vector3[]} partCenters - part position for this mesh. + * @param {import('@babylonjs/core').Mesh} mesh - considered mesh. + * @param {import('@babylonjs/core').Vector3[]} partCenters - part position for this mesh. * @param {SingleDropZone} zone - candidate zone. * @returns whether this zone is close to the mesh or one of its part. */ @@ -353,7 +355,7 @@ function isAPartCenterClose(mesh, partCenters, zone) { } /** - * @param {Vector3} position - checked position. + * @param {import('@babylonjs/core').Vector3} position - checked position. * @param {SingleDropZone} zone - candidate zone. * @returns whether this candidate is close enough to the point. */ @@ -370,7 +372,7 @@ function isCloseTo( } /** - * @param {Vector3[]} partCenters - absolute position of the mesh parts. + * @param {import('@babylonjs/core').Vector3[]} partCenters - absolute position of the mesh parts. * @param {Candidate[]} candidates - list of drop zones to group together. * @returns the built multi drop zone, if all parts have a matching zone. */ diff --git a/apps/web/src/3d/meshes/box.js b/apps/web/src/3d/meshes/box.js index 2df71fd1..24a6b1ab 100644 --- a/apps/web/src/3d/meshes/box.js +++ b/apps/web/src/3d/meshes/box.js @@ -15,8 +15,8 @@ import { applyInitialTransform, setExtras } from '../utils/mesh' * 5. negative Z (0°) * 6. positive Z (180°) * By default, boxes have a dimension of 1. - * @param {Omit} params - box parameters. - * @param {import('@src/3d/managers').Managers} managers - current managers. + * @param {Omit} params - box parameters. + * @param {import('../managers').Managers} managers - current managers. * @param {import('@babylonjs/core').Scene} scene - scene for the created mesh. * @returns the created box mesh. */ @@ -63,7 +63,7 @@ export function createBox( setExtras(mesh, { metadata: { serialize: () => ({ - shape: /** @type {'box'} */ (mesh.name), + shape: /** @type {import('@tabulous/types').Shape} */ (mesh.name), id, x: mesh.absolutePosition.x, y: mesh.absolutePosition.y, diff --git a/apps/web/src/3d/meshes/card.js b/apps/web/src/3d/meshes/card.js index 4c95c13e..85be0bc7 100644 --- a/apps/web/src/3d/meshes/card.js +++ b/apps/web/src/3d/meshes/card.js @@ -10,8 +10,8 @@ import { applyInitialTransform, setExtras } from '../utils/mesh' * Cards are boxes whith a given width, height and depth. Only top and back faces UVs can be specified * By default, the card dimension follows American poker card standard (beetween 1.39 & 1.41). * A card's texture must have 2 faces, back then front, aligned horizontally. - * @param {Omit} params - card parameters. - * @param {import('@src/3d/managers').Managers} managers - current managers. + * @param {Omit} params - card parameters. + * @param {import('../managers').Managers} managers - current managers. * @param {import('@babylonjs/core').Scene} scene - scene for the created mesh. * @returns the created card mesh. */ @@ -62,7 +62,7 @@ export function createCard( setExtras(mesh, { metadata: { serialize: () => ({ - shape: /** @type {'card'} */ (mesh.name), + shape: /** @type {import('@tabulous/types').Shape} */ (mesh.name), id, x: mesh.absolutePosition.x, y: mesh.absolutePosition.y, diff --git a/apps/web/src/3d/meshes/custom.js b/apps/web/src/3d/meshes/custom.js index b54659e8..5d54af99 100644 --- a/apps/web/src/3d/meshes/custom.js +++ b/apps/web/src/3d/meshes/custom.js @@ -12,8 +12,8 @@ OBJFileLoader.UV_SCALING = new Vector2(-1, 1) /** * Creates a custom mesh by importing .obj file. * It must contain the file parameter. - * @param {Omit & Required>} params - custom mesh parameters. - * @param {import('@src/3d/managers').Managers} managers - current managers. + * @param {Omit & Required>} params - custom mesh parameters. + * @param {import('../managers').Managers} managers - current managers. * @param {import('@babylonjs/core').Scene} scene - scene for the created mesh. * @returns the created custom mesh. */ diff --git a/apps/web/src/3d/meshes/die.js b/apps/web/src/3d/meshes/die.js index 4e8bb4b6..a2b310fb 100644 --- a/apps/web/src/3d/meshes/die.js +++ b/apps/web/src/3d/meshes/die.js @@ -9,8 +9,8 @@ import { createCustom } from './custom' /** * Creates a die, which could have from 4, 6, or 8 faces. * By default, dices have a diameter of 1, and 6 faces. - * @param {Omit} params - die parameters. - * @param {import('@src/3d/managers').Managers} managers - current managers. + * @param {Omit} params - die parameters. + * @param {import('../managers').Managers} managers - current managers. * @param {import('@babylonjs/core').Scene} scene - scene for the created mesh. * @returns the created die mesh. */ @@ -53,7 +53,7 @@ export async function createDie( scene.addMesh(mesh, true) mesh.metadata.serialize = () => ({ - shape: /** @type {'die'} */ (mesh.name), + shape: /** @type {import('@tabulous/types').Shape} */ (mesh.name), id, x: mesh.absolutePosition.x, y: mesh.absolutePosition.y, @@ -65,12 +65,10 @@ export async function createDie( ...serializeBehaviors(mesh.behaviors) }) - behaviorStates.randomizable = { - ...(behaviorStates.randomizable || {}), + registerBehaviors(mesh, { randomizable: {}, ...behaviorStates }, managers, { max: faces, quaternionPerFace: getQuaternions(faces) - } - registerBehaviors(mesh, behaviorStates, managers) + }) return mesh } @@ -139,7 +137,7 @@ export function getQuaternions(faces) { ]) } if (faces === 8) { - // axis along which rotation bringe 1 to 3, 5 and 7, or 2 to 4, 6 and 8 + // axis along which rotation brings 1 to 3, 5 and 7, or 2 to 4, 6 and 8 const x = 0 const y = -cos(toRad(-55)) const z = sin(toRad(-55)) diff --git a/apps/web/src/3d/meshes/prism.js b/apps/web/src/3d/meshes/prism.js index c98157ce..3c391b37 100644 --- a/apps/web/src/3d/meshes/prism.js +++ b/apps/web/src/3d/meshes/prism.js @@ -9,8 +9,8 @@ import { applyInitialTransform, setExtras } from '../utils/mesh' * Creates a prism, with a given number of base edge (starting at 3). * A prism's texture must have edges + 2 faces, starting with back and ending with front, aligned horizontally. * By default, prisms have 6 edges and a width of 3. - * @param {Omit} params - prism parameters. - * @param {import('@src/3d/managers').Managers} managers - current managers. + * @param {Omit} params - prism parameters. + * @param {import('../managers').Managers} managers - current managers. * @param {import('@babylonjs/core').Scene} scene - scene for the created mesh. * @returns the created prism mesh. */ @@ -55,7 +55,7 @@ export function createPrism( isCylindric: true, metadata: { serialize: () => ({ - shape: /** @type {'prism'} */ (mesh.name), + shape: /** @type {import('@tabulous/types').Shape} */ (mesh.name), id, x: mesh.absolutePosition.x, y: mesh.absolutePosition.y, diff --git a/apps/web/src/3d/meshes/round-token.js b/apps/web/src/3d/meshes/round-token.js index 1bd5ad0d..4ec496e5 100644 --- a/apps/web/src/3d/meshes/round-token.js +++ b/apps/web/src/3d/meshes/round-token.js @@ -10,9 +10,9 @@ import { applyInitialTransform, setExtras } from '../utils/mesh' * Tokens are cylinders, so their position is their center. * A token's texture must have 3 faces, back then edge then front, aligned horizontally. * By default tokens have a diameter of 2. - * @param {Omit} params - token parameters. + * @param {Omit} params - token parameters. * @param {import('@babylonjs/core').Scene} scene - scene for the created mesh. - * @param {import('@src/3d/managers').Managers} managers - current managers. + * @param {import('../managers').Managers} managers - current managers. * @returns the created token mesh. */ export function createRoundToken( @@ -55,7 +55,7 @@ export function createRoundToken( isCylindric: true, metadata: { serialize: () => ({ - shape: /** @type {'roundToken'} */ (mesh.name), + shape: /** @type {import('@tabulous/types').Shape} */ (mesh.name), id, x: mesh.absolutePosition.x, y: mesh.absolutePosition.y, diff --git a/apps/web/src/3d/meshes/rounded-tile.js b/apps/web/src/3d/meshes/rounded-tile.js index 443efafc..82592015 100644 --- a/apps/web/src/3d/meshes/rounded-tile.js +++ b/apps/web/src/3d/meshes/rounded-tile.js @@ -13,8 +13,8 @@ import { applyInitialTransform, setExtras } from '../utils/mesh' * Tiles are boxes, so their position is their center. * A tile's texture must have 2 faces, back then front, aligned horizontally. * By default tiles have a width and depth of 3 with a border radius of 0.4. - * @param {Omit} params - token parameters. - * @param {import('@src/3d/managers').Managers} managers - current managers. + * @param {Omit} params - token parameters. + * @param {import('../managers').Managers} managers - current managers. * @param {import('@babylonjs/core').Scene} scene - scene for the created mesh. * @returns the created tile mesh. */ @@ -78,7 +78,7 @@ export function createRoundedTile( setExtras(mesh, { metadata: { serialize: () => ({ - shape: /** @type {'roundedTile'} */ (mesh.name), + shape: /** @type {import('@tabulous/types').Shape} */ (mesh.name), id, x: mesh.absolutePosition.x, y: mesh.absolutePosition.y, @@ -102,7 +102,7 @@ export function createRoundedTile( } /** - * @param {Required & { faceUV: Vector4 }>} cornerParams - corner parameters + * @param {Required & { faceUV: Vector4 }>} cornerParams - corner parameters * @param {boolean} isTop - whether if this corner is on the top or the bottom. * @param {boolean} isLeft - whether if this corner is on the left or the right. * @returns Constructive Solid Geometry built for this corner. diff --git a/apps/web/src/3d/utils/actions.js b/apps/web/src/3d/utils/actions.js index 132f9b51..b4bf9d23 100644 --- a/apps/web/src/3d/utils/actions.js +++ b/apps/web/src/3d/utils/actions.js @@ -1,11 +1,4 @@ // @ts-check -/** - * @typedef {import('@tabulous/server/src/graphql').ActionName} ActionName - * @typedef {import('@tabulous/server/src/graphql').ButtonName} ButtonName - * @typedef {import('@tabulous/server/src/graphql').Mesh} SerializedMesh - * @typedef {import('@src/types').Translate} Translate - */ - // Keep this file free from @babylon (in)direct imports, to allow Svelte component referencing them // Otherwise this would bloat production chunks with Babylonjs (1.6Mb uncompressed) import { @@ -22,9 +15,8 @@ import { /** * Action names defined in beheviors, attached to mesh metadata, * and that can be used in actionNamesByKey map. - * @type {Record} */ -export const actionNames = { +export const actionNames = /** @type {const} */ ({ decrement: 'decrement', detail: 'detail', draw: 'draw', @@ -41,11 +33,11 @@ export const actionNames = { snap: 'snap', toggleLock: 'toggleLock', unsnap: 'unsnap' -} +}) /** * button ids triggered in game-interaction and used in actionNamesByButton map - * @type {Record} + * @type {Record} */ export const buttonIds = { button1: 'button1', @@ -54,12 +46,12 @@ export const buttonIds = { /** * Parse game data to build a map of supported action names by their shortcuts. - * @param {SerializedMesh[]} meshes - serialized meshes to be analyzed. - * @param {Translate} translate - translation + * @param {import('@tabulous/types').Mesh[]} meshes - serialized meshes to be analyzed. + * @param {import('@src/types').Translate} translate - translation * @returns map of supported action names by shortcut. */ export function buildActionNamesByKey(meshes, translate) { - /** @type {Map} */ + /** @type {Map} */ const actionNamesByKey = new Map() let hasStackable = false let hasQuantifiable = false @@ -101,7 +93,7 @@ export function buildActionNamesByKey(meshes, translate) { if (hasStackable || hasQuantifiable) { actionNamesByKey.set( translate('shortcuts.push'), - /** @type {ActionName[]} */ ( + /** @type {import('@tabulous/types').ActionName[]} */ ( [ hasStackable ? actionNames.push : undefined, hasQuantifiable ? actionNames.increment : undefined @@ -110,7 +102,7 @@ export function buildActionNamesByKey(meshes, translate) { ) actionNamesByKey.set( translate('shortcuts.pop'), - /** @type {ActionName[]} */ ( + /** @type {import('@tabulous/types').ActionName[]} */ ( [ hasStackable ? actionNames.pop : undefined, hasQuantifiable ? actionNames.decrement : undefined diff --git a/apps/web/src/3d/utils/behaviors.js b/apps/web/src/3d/utils/behaviors.js index 6f19ae28..bab00181 100644 --- a/apps/web/src/3d/utils/behaviors.js +++ b/apps/web/src/3d/utils/behaviors.js @@ -1,16 +1,4 @@ // @ts-check -/** - * @typedef {import('@babylonjs/core').AbstractMesh} AbstractMesh - * @typedef {import('@babylonjs/core').Mesh} Mesh - * @typedef {import('@tabulous/server/src/graphql').Dimension} Dimension - * @typedef {import('@tabulous/server/src/graphql').Mesh} _SerializedMesh - * @typedef {import('@src/3d/behaviors').AnimateBehavior} AnimateBehavior - * @typedef {import('@src/3d/behaviors').TargetBehavior} TargetBehavior - * @typedef {import('@src/3d/behaviors/names')} _BehaviorNames - * @typedef {import('@src/3d/behaviors/randomizable').Extras} Extras - * @typedef {import('@src/3d/managers/target').DropZone} DropZone - */ - import { Animation } from '@babylonjs/core/Animations/animation' import { Quaternion, Vector3 } from '@babylonjs/core/Maths/math.vector' import { CreateBox } from '@babylonjs/core/Meshes/Builders/boxBuilder' @@ -44,14 +32,17 @@ import { StackBehavior } from '../behaviors/stackable' import { applyGravity, getCenterAltitudeAbove } from './gravity' import { setExtras } from './mesh' -/** @typedef {_BehaviorNames[keyof _BehaviorNames]} BehaviorNames */ +/** @typedef {'anchorable'|'detailable'|'drawable'|'flippable'|'lockable'|'movable'|'quantifiable'|'randomizable'|'rotable'|'stackable'} BehaviorNames */ + /** @typedef {AnchorBehavior|DetailBehavior|DrawBehavior|FlipBehavior|LockBehavior|MoveBehavior|QuantityBehavior|RandomBehavior|RotateBehavior|StackBehavior} Behavior */ -/** @typedef {Record & Pick} BehaviorState */ -/** @typedef {_SerializedMesh & { randomizable?: _SerializedMesh['randomizable'] & Partial }} SerializedMesh */ + +/** @typedef {Record & Pick} BehaviorState */ + +/** @typedef {import('@tabulous/types').Mesh & { randomizable?: import('@tabulous/types').Mesh['randomizable'] & Partial }} SerializedMesh */ const animationLogger = makeLogger('animatable') -/** @type {?[BehaviorNames, { new (state: ?, managers: import('@src/3d/managers').Managers): Behavior }][]} */ +/** @type {?[BehaviorNames, { new (state: ?, managers: import('../managers').Managers, extras: Record): Behavior }][]} */ let constructors = null function getConstructors() { @@ -78,14 +69,19 @@ function getConstructors() { * It uses behavior names to identify the desired behaviore. * For example, when given parameters contain 'anchorable' object, it creates an AnchorBehavior * and attach it to the mesh. - * @param {Mesh} mesh - the modified mesh. - * @param {BehaviorState} params - parameters, which may contain behavior specific states. - * @param {import('@src/3d/managers').Managers} managers - current managers + * @param {import('@babylonjs/core').Mesh} mesh - the modified mesh. + * @param {Partial} params - parameters, which may contain behavior specific states. + * @param {import('../managers').Managers} managers - current managers */ -export function registerBehaviors(mesh, params, managers) { +export function registerBehaviors( + mesh, + params, + managers, + /** @type {Record} */ extras = {} +) { for (const [name, constructor] of getConstructors()) { if (params[name]) { - mesh.addBehavior(new constructor(params[name], managers), true) + mesh.addBehavior(new constructor(params[name], managers, extras), true) } } } @@ -98,8 +94,9 @@ export function registerBehaviors(mesh, params, managers) { */ export function restoreBehaviors(behaviors, state) { for (const behavior of behaviors) { - if (state[behavior.name] && behavior.name !== StackBehaviorName) { - behavior.fromState?.(state[behavior.name]) + const { name } = behavior + if (name in state && state[name] && name !== StackBehaviorName) { + behavior.fromState?.(state[name]) } } } @@ -121,7 +118,7 @@ export function serializeBehaviors(behaviors) { /** * Moves, with an animation if possible, a mesh to a given position. * When requested, will apply gravity at the end. - * @param {Mesh} mesh - the moved mesh. + * @param {import('@babylonjs/core').Mesh} mesh - the moved mesh. * @param {Vector3} absolutePosition - its final, absolute position. * @param {?Vector3} rotation - its final rotation (set to null to leave unmodified). * @param {number} [duration] - how long, in ms, the move will last. @@ -155,8 +152,8 @@ export function animateMove( /** * Finds and returns an animatable behavior of a given mesh. - * @param {Mesh} [mesh] - related mesh. - * @returns {?MoveBehavior|FlipBehavior|DrawBehavior|RotateBehavior|RandomBehavior|AnimateBehavior|undefined} an Animatable behavior (or one of its subimplementation) if any. + * @param {import('@babylonjs/core').Mesh} [mesh] - related mesh. + * @returns {?MoveBehavior|FlipBehavior|DrawBehavior|RotateBehavior|RandomBehavior|import('../behaviors').AnimateBehavior|undefined} an Animatable behavior (or one of its subimplementation) if any. */ export function getAnimatableBehavior(mesh) { return ( @@ -171,8 +168,8 @@ export function getAnimatableBehavior(mesh) { /** * Finds and returns a targetable behavior of a given mesh. - * @param {Mesh} [mesh] - related mesh. - * @returns {?StackBehavior|AnchorBehavior|QuantityBehavior|TargetBehavior|undefined} a Target behavior (or one of its subimplementation) if any. + * @param {import('@babylonjs/core').Mesh} [mesh] - related mesh. + * @returns {?StackBehavior|AnchorBehavior|QuantityBehavior|import('../behaviors').TargetBehavior|undefined} a Target behavior (or one of its subimplementation) if any. */ export function getTargetableBehavior(mesh) { return ( @@ -185,7 +182,7 @@ export function getTargetableBehavior(mesh) { /** * Indicates whether a mesh is flipped or not. - * @param {Mesh} [mesh] - related mesh. + * @param {import('@babylonjs/core').Mesh} [mesh] - related mesh. * @returns whether the mesh is flipped. */ export function isMeshFlipped(mesh) { @@ -194,7 +191,7 @@ export function isMeshFlipped(mesh) { /** * Indicates whether a mesh has been rotated twice (its angle is PI). - * @param {Mesh} [mesh] - related mesh. + * @param {import('@babylonjs/core').Mesh} [mesh] - related mesh. * @returns whether the mesh is inverted. */ export function isMeshInverted(mesh) { @@ -203,7 +200,7 @@ export function isMeshInverted(mesh) { /** * Indicates whether a mesh is locked (prevents moves and interactions) - * @param {Mesh} [mesh] - related mesh. + * @param {import('@babylonjs/core').Mesh} [mesh] - related mesh. * @returns whether the mesh is locked. */ export function isMeshLocked(mesh) { @@ -212,7 +209,7 @@ export function isMeshLocked(mesh) { /** * Returns absolute position of all part centers of a given mesh (after applying any transformations). - * @param {Mesh} mesh - related mesh + * @param {import('@babylonjs/core').Mesh} mesh - related mesh * @returns a list of absolute positions. */ export function getMeshAbsolutePartCenters(mesh) { @@ -287,7 +284,7 @@ export function attachFunctions(behavior, ...functionNames) { * - the mesh's onAnimationEnd observers are notified. * * The animation keys are serialized as per Babylon's Animation Curve Editor. - * @param {AnimateBehavior} behavior - animated behavior. + * @param {import('../behaviors').AnimateBehavior} behavior - animated behavior. * @param {?() => void} onEnd - function invoked when all animations have completed. * @param {AnimationSpec[]} animationSpecs - list of animation specs. */ @@ -366,7 +363,7 @@ export function runAnimation({ mesh, frameRate }, onEnd, ...animationSpecs) { * one needs to temporary detach an animated mesh from its parent, or rotation may alter the movements. * This function detaches a given mesh, keeping its absolute position and rotation unchanged, then * returns a function to re-attach to the original parent (or new, if it has changed meanwhile). - * @template {AbstractMesh} M + * @template {import('@babylonjs/core').AbstractMesh} M * @param {M} mesh - detached mesh. * @param {boolean} [detachChildren=true] - set to detach the mesh from its children. * @returns a function to re-attach to the original (or new) parent. @@ -402,8 +399,8 @@ export function detachFromParent(mesh, detachChildren = true) { /** * Computes the final position of a given above a drop zone - * @param {Mesh} droppedMesh - mesh dropped above zone. - * @param {DropZone} zone - drop zone. + * @param {import('@babylonjs/core').Mesh} droppedMesh - mesh dropped above zone. + * @param {import('../managers').DropZone} zone - drop zone. * @returns absolute position for this mesh. */ export function getPositionAboveZone(droppedMesh, zone) { @@ -412,7 +409,7 @@ export function getPositionAboveZone(droppedMesh, zone) { return new Vector3( x, getCenterAltitudeAbove( - /** @type {Mesh} */ (zone.targetable.mesh), + /** @type {import('@babylonjs/core').Mesh} */ (zone.targetable.mesh), droppedMesh ), z @@ -504,8 +501,8 @@ function parseFloat( * Creates a cylinder for cylindric meshes or when providing dimension's diameter. * Otherwise, creates a box. * @param {string} name - new mesh's name - * @param {Mesh} parent - mesh to copy dimensions and shape from. - * @param {Dimension} [dimensions] - target dimensions. When specified, prevail on parent's shape: + * @param {import('@babylonjs/core').Mesh} parent - mesh to copy dimensions and shape from. + * @param {import('@tabulous/types').Dimension} [dimensions] - target dimensions. When specified, prevail on parent's shape: * @returns created target mesh. */ export function buildTargetMesh(name, parent, dimensions) { @@ -535,7 +532,7 @@ export function buildTargetMesh(name, parent, dimensions) { /** * Returns the current face image of a Detailable mesh. - * @param {Mesh} mesh - concerned mesh. + * @param {import('@babylonjs/core').Mesh} mesh - concerned mesh. * @returns the mesh back image if it is flipped, or its front image. Defaults to null. */ export function selectDetailedFace(mesh) { diff --git a/apps/web/src/3d/utils/gravity.js b/apps/web/src/3d/utils/gravity.js index 371be9d0..50924cee 100644 --- a/apps/web/src/3d/utils/gravity.js +++ b/apps/web/src/3d/utils/gravity.js @@ -1,12 +1,4 @@ // @ts-check -/** - * @typedef {import('@babylonjs/core').AbstractMesh} AbstractMesh - * @typedef {import('@babylonjs/core').BoundingBox} BoundingBox - * @typedef {import('@babylonjs/core').Mesh} Mesh - * @typedef {import('@src/utils/math').Circle} Circle - * @typedef {import('@src/utils/math').Rectangle} Rectangle - */ - import { Vector3 } from '@babylonjs/core/Maths/math.vector.js' import { makeLogger } from '../../utils/logger' @@ -18,7 +10,7 @@ import { intersectRectangleWithCircle } from '../../utils/math' -/** @typedef {Rectangle | Circle} Geometry */ +/** @typedef {import('@src/utils/math').Rectangle | import('@src/utils/math').Circle} Geometry */ const logger = makeLogger('gravity') @@ -29,7 +21,7 @@ export const altitudeGap = 0.01 /** * Return the altitude of a mesh center if it was lying on the ground - * @template {AbstractMesh} T + * @template {import('@babylonjs/core').AbstractMesh} T * @param {T} mesh - dested mesh. * @returns resulting y coordinage. */ @@ -39,7 +31,7 @@ export function getGroundAltitude(mesh) { /** * Returns the absolute altitude (Y axis) above a given mesh, including minimum spacing. - * @template {AbstractMesh} T + * @template {import('@babylonjs/core').AbstractMesh} T * @param {T} mesh - related mesh. * @returns resulting Y coordinate. */ @@ -50,7 +42,7 @@ export function getAltitudeAbove(mesh) { /** * Computes the Y coordinate to assign to 'mesh' so it goes above 'other', considering their heights. * Does not modifies any coordinate. - * @template {AbstractMesh} T + * @template {import('@babylonjs/core').AbstractMesh} T * @param {T} meshBelow - foundation to put the mesh on. * @param {T} meshAbove - positionned over the other mesh. * @returns resulting Y coordinate. @@ -64,7 +56,7 @@ export function getCenterAltitudeAbove(meshBelow, meshAbove) { * Changes a mesh's Y coordinate so it lies on mesh below, or on the ground. * It'll check all other meshes in the same scene to identify the ones below (partial overlap is supported). * Does not run any animation, and change its absolute position. - * @param {Mesh} mesh - applied mesh. + * @param {import('@babylonjs/core').Mesh} mesh - applied mesh. * @returns the mesh's new absolute position */ export function applyGravity(mesh) { @@ -95,7 +87,7 @@ export function applyGravity(mesh) { /** * Indicates when a mesh is hovering another one. * Does not modifies any coordinates. - * @template {AbstractMesh} T + * @template {import('@babylonjs/core').AbstractMesh} T * @param {T} mesh - checked mesh. * @param {T} target - other mesh. * @returns true when mesh is hovering the target. @@ -107,7 +99,7 @@ export function isAbove(mesh, target) { /** * Sort meshes by elevation. * This will guarantee a proper gravity application when running operations (moves, flips...) in parallel. - * @template {AbstractMesh} T + * @template {import('@babylonjs/core').AbstractMesh} T * @param {Iterable} [meshes] - array of meshes to order. * @param {boolean} [highestFirst = false] - false to return highest first. * @returns sorted array. @@ -121,7 +113,7 @@ export function sortByElevation(meshes, highestFirst = false) { } /** - * @template {AbstractMesh} T + * @template {import('@babylonjs/core').AbstractMesh} T * @param {T} mesh - reference mesh. * @param {T[]} candidates - list of candidate meshes to consider. * @returns candidate meshes bellow the reference mesh. @@ -160,15 +152,19 @@ function intersectGeometries(geometryA, geometryB) { return intersectRectangles(rectangleA, rectangleB) } return intersectRectangleWithCircle( - rectangleA ? rectangleA : /** @type {Rectangle} */ (rectangleB), - circleA ? circleA : /** @type {Circle} */ (circleB) + rectangleA + ? rectangleA + : /** @type {import('@src/utils/math').Rectangle} */ (rectangleB), + circleA + ? circleA + : /** @type {import('@src/utils/math').Circle} */ (circleB) ) } /** - * @template {AbstractMesh} T + * @template {import('@babylonjs/core').AbstractMesh} T * @param {T} mesh - reference mesh. - * @param {BoundingBox} boundingBox - bounding box to build geometry for. + * @param {import('@babylonjs/core').BoundingBox} boundingBox - bounding box to build geometry for. * @returns bounding box's geomertry object. */ function buildGeometry(mesh, boundingBox) { @@ -177,8 +173,8 @@ function buildGeometry(mesh, boundingBox) { } /** - * @param {BoundingBox} reference - reference bounding box. - * @param {BoundingBox} tested - tested bounding box. + * @param {import('@babylonjs/core').BoundingBox} reference - reference bounding box. + * @param {import('@babylonjs/core').BoundingBox} tested - tested bounding box. * @returns whether tested bounding box is below the reference. */ function isGloballyBelow(reference, tested) { diff --git a/apps/web/src/3d/utils/index.js b/apps/web/src/3d/utils/index.js index 42238a2d..e1c1b953 100644 --- a/apps/web/src/3d/utils/index.js +++ b/apps/web/src/3d/utils/index.js @@ -1,3 +1,4 @@ +// @ts-check export * from './actions' export * from './behaviors' export * from './gravity' diff --git a/apps/web/src/3d/utils/lights.js b/apps/web/src/3d/utils/lights.js index 072a2b8f..7a87cae8 100644 --- a/apps/web/src/3d/utils/lights.js +++ b/apps/web/src/3d/utils/lights.js @@ -1,9 +1,4 @@ // @ts-check -/** - * @typedef {import('@babylonjs/core').Scene} Scene - * @typedef {import('@tabulous/server/src/graphql').TableSpec} TableSpec - */ - // mandatory side effect import '@babylonjs/core/Lights/Shadows/shadowGeneratorSceneComponent.js' @@ -27,8 +22,8 @@ import { TableId } from './table.js' * Creates directional light for the hand scene * Note: all meshes created before this light will not project any shadow. * @param {object} params - parameters, including: - * @param {Scene} params.scene - main scene. - * @param {Scene} params.handScene - hand scene. + * @param {import('@babylonjs/core').Scene} params.scene - main scene. + * @param {import('@babylonjs/core').Scene} params.handScene - hand scene. * @returns {LightResult} an object containing created light and shadowGenerator. */ export function createLights({ scene, handScene }) { @@ -52,14 +47,18 @@ export function createLights({ scene, handScene }) { return { light, ambientLight, handLight, shadowGenerator } } -function makeDirectionalLight(/** @type {Scene} */ scene) { +function makeDirectionalLight( + /** @type {import('@babylonjs/core').Scene} */ scene +) { const light = new DirectionalLight('sun', new Vector3(0, -1, 0), scene) light.position = new Vector3(0, 20, 0) light.intensity = 1 return light } -function makeAmbientLight(/** @type {Scene} */ scene) { +function makeAmbientLight( + /** @type {import('@babylonjs/core').Scene} */ scene +) { const light = new HemisphericLight('ambient', new Vector3(0, 1, 0), scene) light.intensity = 0.8 return light diff --git a/apps/web/src/3d/utils/mesh.js b/apps/web/src/3d/utils/mesh.js index 26454a5b..a1cf9032 100644 --- a/apps/web/src/3d/utils/mesh.js +++ b/apps/web/src/3d/utils/mesh.js @@ -1,15 +1,9 @@ // @ts-check -/** - * @typedef {import('@babylonjs/core').AbstractMesh} AbstractMesh - * @typedef {import('@babylonjs/core').Mesh} Mesh - * @typedef {import('@tabulous/server/src/graphql').InitialTransform} InitialTransform - */ - import { Observable } from '@babylonjs/core/Misc/observable' /** * Configures Tabulous extra mesh attributes on a Babylon.js mesh. - * @template {AbstractMesh} M + * @template {import('@babylonjs/core').AbstractMesh} M * @param {M} mesh - initialized mesh. * @param {Partial>} [extras] - extra attribute values. * @return {M} the modified mesh. @@ -28,7 +22,7 @@ export function setExtras(mesh, extras = {}) { } /** - * @template {AbstractMesh} M + * @template {import('@babylonjs/core').AbstractMesh} M * @param {?M} [mesh] - tested mesh. * @returns {boolean} whether a mesh or any of ist (indirect) children are animated. */ @@ -44,7 +38,7 @@ export function isAnimationInProgress(mesh) { /** * Indicates whether a given container completely contain the tested mesh, using their bounding boxes. - * @template {AbstractMesh} M + * @template {import('@babylonjs/core').AbstractMesh} M * @param {M} container - container that may contain the mesh. * @param {M} mesh - tested mesh. * @returns true if container contains mesh, false otherwise. @@ -69,7 +63,7 @@ export function isContaining(container, mesh) { /** * Returns a given mesh's dimension, that is its extent on Y and X axes. * **Requires a fresh world matrix**. - * @template {AbstractMesh} T + * @template {import('@babylonjs/core').AbstractMesh} T * @param {T} mesh - sized mesh. * @returns mesh's dimensions. */ @@ -81,8 +75,8 @@ export function getDimensions(mesh) { /** * Applies an initial transformation to the built mesh, baking it into the vertices. - * @param {Mesh} mesh - transformed mesh. - * @param {InitialTransform} [transform] - initial transform applied (may be undefined). + * @param {import('@babylonjs/core').Mesh} mesh - transformed mesh. + * @param {import('@tabulous/types').InitialTransform} [transform] - initial transform applied (may be undefined). */ export function applyInitialTransform(mesh, transform) { if (transform) { diff --git a/apps/web/src/3d/utils/scene-loader.js b/apps/web/src/3d/utils/scene-loader.js index 2cea0179..c824f8de 100644 --- a/apps/web/src/3d/utils/scene-loader.js +++ b/apps/web/src/3d/utils/scene-loader.js @@ -1,16 +1,4 @@ // @ts-check -/** - * @typedef {import('@babylonjs/core').Mesh} Mesh - * @typedef {import('@babylonjs/core').Scene} Scene - * @typedef {import('@tabulous/server/src/graphql').AnchorableState} AnchorableState - * @typedef {import('@tabulous/server/src/graphql').Mesh} SerializedMesh - * @typedef {import('@tabulous/server/src/graphql').Shape} Shape - * @typedef {import('@tabulous/server/src/graphql').StackableState} StackableState - * @typedef {import('@src/3d/behaviors/anchorable').AnchorBehavior} AnchorBehavior - * @typedef {import('@src/3d/behaviors/stackable').StackBehavior} StackBehavior - * @typedef {import('@src/3d/managers').Managers} Managers - */ - // mandatory side effect import '@babylonjs/core/Loading/loadingScreen.js' @@ -28,11 +16,11 @@ import { createRoundToken } from '../meshes/round-token' import { createRoundedTile } from '../meshes/rounded-tile' import { restoreBehaviors } from './behaviors' -/** @typedef {(state: Omit, managers: Managers, scene: Scene) => Mesh|Promise} MeshCreator */ +/** @typedef {(state: Omit, managers: import('../managers').Managers, scene: import('@babylonjs/core').Scene) => import('@babylonjs/core').Mesh|Promise} MeshCreator */ const logger = makeLogger('scene-loader') -/** @type {Map} */ +/** @type {Map} */ const meshCreatorByName = new Map([ ['box', /** @type {?} */ (createBox)], ['card', /** @type {?} */ (createCard)], @@ -47,20 +35,22 @@ const supportedNames = new Set([...meshCreatorByName.keys()]) /** * Indicates whether a mesh can be serialized and loaded - * @param {Mesh} mesh - tested mesh. + * @param {import('@babylonjs/core').Mesh} mesh - tested mesh. * @returns whether this mesh could be serialized and loaded. */ export function isSerializable(mesh) { - return supportedNames.has(/** @type {Shape} */ (mesh.name)) + return supportedNames.has( + /** @type {import('@tabulous/types').Shape} */ (mesh.name) + ) } /** * Serializes a scene's meshes. - * @param {Scene} [scene] - 3D scene serialized. - * @returns {SerializedMesh[]} list of serialized meshes. + * @param {import('@babylonjs/core').Scene} [scene] - 3D scene serialized. + * @returns {import('@tabulous/types').Mesh[]} list of serialized meshes. */ export function serializeMeshes(scene) { - /** @type {SerializedMesh[]} */ + /** @type {import('@tabulous/types').Mesh[]} */ const meshes = [] for (const mesh of scene?.meshes ?? []) { if (isSerializable(mesh) && !mesh.isPhantom) { @@ -73,9 +63,9 @@ export function serializeMeshes(scene) { /** * Creates a meshes into the provided scene. - * @param {SerializedMesh} state - serialized mesh state. - * @param {Scene} scene - 3D scene used. - * @param {Managers} managers - current managers. + * @param {import('@tabulous/types').Mesh} state - serialized mesh state. + * @param {import('@babylonjs/core').Scene} scene - 3D scene used. + * @param {import('../managers').Managers} managers - current managers. * @returns mesh created. */ export async function createMeshFromState(state, scene, managers) { @@ -95,9 +85,9 @@ export async function createMeshFromState(state, scene, managers) { * Loads meshes into the provided scene: * - either creates new mesh, or updates existing ones, based on their ids * - deletes existing mesh that are not found in the provided data - * @param {Scene} scene - 3D scene used. - * @param {SerializedMesh[]} meshes - a list of serialized meshes data. - * @param {Managers} managers - current managers. + * @param {import('@babylonjs/core').Scene} scene - 3D scene used. + * @param {import('@tabulous/types').Mesh[]} meshes - a list of serialized meshes data. + * @param {import('../managers').Managers} managers - current managers. */ export async function loadMeshes(scene, meshes, managers) { const disposables = new Set(scene.meshes) @@ -107,9 +97,9 @@ export async function loadMeshes(scene, meshes, managers) { } } - /** @type {{stackBehavior: StackBehavior, stackable: StackableState, y: number}[]} */ + /** @type {{stackBehavior: import('../behaviors').StackBehavior, stackable: import('@tabulous/types').StackableState, y: number}[]} */ const stackables = [] - /** @type {{anchorBehavior: AnchorBehavior, anchorable: AnchorableState, y: number}[]} */ + /** @type {{anchorBehavior: import('../behaviors').AnchorBehavior, anchorable: import('@tabulous/types').AnchorableState, y: number}[]} */ const anchorables = [] logger.debug({ meshes }, `loads meshes`) @@ -181,8 +171,8 @@ export async function loadMeshes(scene, meshes, managers) { } /** - * @param {SerializedMesh} mesh - serialized mesh to trim. - * @return {SerializedMesh} the same mesh without its anchorable and stackable behaviors. + * @param {import('@tabulous/types').Mesh} mesh - serialized mesh to trim. + * @return {import('@tabulous/types').Mesh} the same mesh without its anchorable and stackable behaviors. */ function skipDelayableBehaviors({ stackable, anchorable, ...state }) { return { diff --git a/apps/web/src/3d/utils/scene.js b/apps/web/src/3d/utils/scene.js index 6c1b3af5..f671e9be 100644 --- a/apps/web/src/3d/utils/scene.js +++ b/apps/web/src/3d/utils/scene.js @@ -1,6 +1,4 @@ // @ts-check -/** @typedef {import('@babylonjs/core').Mesh} Mesh */ - import { Scene } from '@babylonjs/core/scene.js' /** @@ -14,13 +12,13 @@ export class ExtendedScene extends Scene { */ constructor(engine, options) { super(engine, options) - /** @type {Map} */ + /** @type {Map} */ this.meshById = new Map() this.detachControl() } /** - * @param {Mesh} mesh - added mesh. + * @param {import('@babylonjs/core').Mesh} mesh - added mesh. * @param {boolean} [recursive] - whether to add children meshes as well. * @see https://doc.babylonjs.com/typedoc/classes/babylon.scene#addMesh */ @@ -37,7 +35,7 @@ export class ExtendedScene extends Scene { } /** - * @param {Mesh} mesh - removed mesh. + * @param {import('@babylonjs/core').Mesh} mesh - removed mesh. * @param {boolean} [recursive] - whether to remove children meshes as well. * @returns removed mesh index. * @see https://doc.babylonjs.com/typedoc/classes/babylon.scene#removeMesh @@ -53,7 +51,9 @@ export class ExtendedScene extends Scene { * @see https://doc.babylonjs.com/typedoc/classes/babylon.scene#getMeshByID */ getMeshById(id) { - return /** @type {?Mesh} */ (this.meshById.get(id) ?? null) + return /** @type {?import('@babylonjs/core').Mesh} */ ( + this.meshById.get(id) ?? null + ) } /** @see https://doc.babylonjs.com/typedoc/classes/babylon.scene#dispose */ diff --git a/apps/web/src/3d/utils/table.js b/apps/web/src/3d/utils/table.js index 51a4bb41..0f8c38e9 100644 --- a/apps/web/src/3d/utils/table.js +++ b/apps/web/src/3d/utils/table.js @@ -7,8 +7,8 @@ export const TableId = 'table' /** * Creates ground mesh to act as table, that received shadows but can not receive rays. * Table is always 0.01 unit bellow (Y axis) origin. - * @param {import('@tabulous/server/src/graphql').TableSpec|undefined} tableSpec - table parameters - * @param {import('@src/3d/managers').Managers} managers - current managers. + * @param {import('@tabulous/types').TableSpec|undefined} tableSpec - table parameters + * @param {import('../managers').Managers} managers - current managers. * @param {import('@babylonjs/core').Scene} scene - scene to host the table (default to last scene). * @returns the created table ground. */ diff --git a/apps/web/src/3d/utils/vector.js b/apps/web/src/3d/utils/vector.js index 5e8aa5a8..49c15bf0 100644 --- a/apps/web/src/3d/utils/vector.js +++ b/apps/web/src/3d/utils/vector.js @@ -1,12 +1,4 @@ // @ts-check -/** - * @typedef {import('@babylonjs/core').AbstractMesh} AbstractMesh - * @typedef {import('@babylonjs/core').Mesh} Mesh - * @typedef {import('@babylonjs/core').Camera} Camera - * @typedef {import('@babylonjs/core').Node} Node - * @typedef {import('@babylonjs/core').Scene} Scene - */ - import { Ray } from '@babylonjs/core/Culling/ray.js' import { Matrix, @@ -22,13 +14,13 @@ import { TableId } from './table.js' * @property {number} y - y coordinate. */ -/** @type {?Mesh} */ +/** @type {?import('@babylonjs/core').Mesh} */ let table = null /** * Converts a screen position into a point on the ground (3D, scene). * Useful to know where on the ground a player has clicked. - * @param {Scene} scene - current scene. + * @param {import('@babylonjs/core').Scene} scene - current scene. * @param {ScreenPosition} position - screen position. * @returns 3D point on the ground plane (Y axis) for this position, if any. */ @@ -40,7 +32,7 @@ export function screenToGround(scene, { x, y }) { /** * Indicates whether a screen position (2D, DOM) is above the table mesh. - * @param {Scene} scene - current scene. + * @param {import('@babylonjs/core').Scene} scene - current scene. * @param {ScreenPosition} position - screen position. * @returns true if the point is within the table area, false otherwise. */ @@ -56,7 +48,7 @@ export function isAboveTable(scene, { x, y }) { /** * Indicates whether a world position (3D, scene) is above the table mesh. - * @param {Scene} scene - current scene. + * @param {import('@babylonjs/core').Scene} scene - current scene. * @param {Vector3} position - 3D position. * @returns true if the point is within the table area, false otherwise. */ @@ -74,7 +66,7 @@ export function isPositionAboveTable(scene, position) { /** * Returns screen coordinate of a given mesh. - * @template {AbstractMesh} T + * @template {import('@babylonjs/core').AbstractMesh} T * @param {T?} [mesh] - the tested mesh. * @param {[number, number, number]} [offset = [0, 0, 0]] - optional offset (3D coordinates) applied. * @returns this mesh's screen position. @@ -92,7 +84,7 @@ export function getMeshScreenPosition(mesh, offset = [0, 0, 0]) { /** * Returns screen coordinate of a 3D position given a scene's active camera. - * @param {Scene} scene - current scene. + * @param {import('@babylonjs/core').Scene} scene - current scene. * @param {Vector3} position - 3D position. * @returns {ScreenPosition} the corresponding screen position */ @@ -102,17 +94,16 @@ export function getScreenPosition(scene, position) { position, Matrix.Identity(), scene.getTransformMatrix(), - /** @type {Camera} */ (scene.activeCamera).viewport.toGlobal( - engine.getRenderWidth(), - engine.getRenderHeight() - ) + /** @type {import('@babylonjs/core').Camera} */ ( + scene.activeCamera + ).viewport.toGlobal(engine.getRenderWidth(), engine.getRenderHeight()) ) return { x, y } } /** * Converts a position in world space into a given mesh's local space. - * @template {AbstractMesh} T + * @template {import('@babylonjs/core').AbstractMesh} T * @param {Vector3} absolutePosition - absolute position to convert. * @param {T} mesh - mesh into which position is converted. * @returns the converted position in mesh's local space. @@ -128,7 +119,7 @@ export function convertToLocal(absolutePosition, mesh) { /** * Returns mesh local rotation in world space. - * @template {Node} T + * @template {import('@babylonjs/core').Node} T * @param {T} mesh - related mesh. * @returns absolute rotation (Euler angles). */ diff --git a/apps/web/src/app.d.ts b/apps/web/src/app.d.ts index 6ae9d136..91d189b0 100644 --- a/apps/web/src/app.d.ts +++ b/apps/web/src/app.d.ts @@ -2,8 +2,8 @@ import '@poppanator/sveltekit-svg/dist/svg' import type { Locale } from '@src/common' -import type { PlayerWithTurnCredentials } from '@src/graphql' import type { DeepRequired } from '@src/types' +import type { PlayerWithTurnCredentials } from '@tabulous/server/graphql' // for information about these interfaces // See https://kit.svelte.dev/docs/types#app diff --git a/apps/web/src/components/Aside/AvatarGrid.svelte b/apps/web/src/components/Aside/AvatarGrid.svelte index 5641bbb7..b3c8956c 100644 --- a/apps/web/src/components/Aside/AvatarGrid.svelte +++ b/apps/web/src/components/Aside/AvatarGrid.svelte @@ -1,22 +1,15 @@ diff --git a/apps/web/src/components/Aside/PlayerAvatar.svelte b/apps/web/src/components/Aside/PlayerAvatar.svelte index c01baff7..703d8107 100644 --- a/apps/web/src/components/Aside/PlayerAvatar.svelte +++ b/apps/web/src/components/Aside/PlayerAvatar.svelte @@ -1,16 +1,11 @@ diff --git a/apps/web/src/components/Discussion/Container.svelte b/apps/web/src/components/Discussion/Container.svelte index dd172a45..df18722e 100644 --- a/apps/web/src/components/Discussion/Container.svelte +++ b/apps/web/src/components/Discussion/Container.svelte @@ -1,11 +1,5 @@ diff --git a/apps/web/src/components/Dropdown.svelte b/apps/web/src/components/Dropdown.svelte index 5a83ac1b..744362c0 100644 --- a/apps/web/src/components/Dropdown.svelte +++ b/apps/web/src/components/Dropdown.svelte @@ -1,15 +1,13 @@ diff --git a/apps/web/src/components/Header.svelte b/apps/web/src/components/Header.svelte index cd4ad3d3..e4655362 100644 --- a/apps/web/src/components/Header.svelte +++ b/apps/web/src/components/Header.svelte @@ -1,14 +1,5 @@ diff --git a/apps/web/src/routes/[[lang=lang]]/(auth)/game/[gameId]/GameHand.svelte b/apps/web/src/routes/[[lang=lang]]/(auth)/game/[gameId]/GameHand.svelte index a36c3a88..535c1744 100644 --- a/apps/web/src/routes/[[lang=lang]]/(auth)/game/[gameId]/GameHand.svelte +++ b/apps/web/src/routes/[[lang=lang]]/(auth)/game/[gameId]/GameHand.svelte @@ -1,7 +1,5 @@ diff --git a/apps/web/src/routes/[[lang=lang]]/(auth)/game/[gameId]/MeshDetails.svelte b/apps/web/src/routes/[[lang=lang]]/(auth)/game/[gameId]/MeshDetails.svelte index 860eba9f..494eee18 100644 --- a/apps/web/src/routes/[[lang=lang]]/(auth)/game/[gameId]/MeshDetails.svelte +++ b/apps/web/src/routes/[[lang=lang]]/(auth)/game/[gameId]/MeshDetails.svelte @@ -1,15 +1,10 @@