diff --git a/packages/client/package.json b/packages/client/package.json index faa0f61a..e2e04a09 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -23,6 +23,7 @@ "@types/recharts": "^1.8.24", "axios": "^0.25.0", "date-fns": "^2.28.0", + "deepmerge": "^4.3.1", "get-user-locale": "^2.2.1", "i18next": "^22.4.14", "lodash": "^4.17.21", @@ -57,11 +58,16 @@ ], "rules": { "no-use-before-define": "off", - "no-restricted-imports": ["error", { - "name": "axios", - "importNames": ["default"], - "message": "Please use `http`, the axios client configured in the request utils (src/utils/request)." - }], + "no-restricted-imports": [ + "error", + { + "name": "axios", + "importNames": [ + "default" + ], + "message": "Please use `http`, the axios client configured in the request utils (src/utils/request)." + } + ], "@typescript-eslint/no-use-before-define": "off" } }, diff --git a/packages/client/src/lib/array.ts b/packages/client/src/lib/array.ts index 7fcddb2d..37befc83 100644 --- a/packages/client/src/lib/array.ts +++ b/packages/client/src/lib/array.ts @@ -1,4 +1,6 @@ -export { deepFreeze, sortBy, sumReducer, toArray }; +import get from "lodash/get"; + +export { deepFreeze, indexArrayBy, sortBy, sumReducer, toArray }; type OnlyNumberKeys = keyof { [K in keyof T as T[K] extends number ? K : never]: T[K]; @@ -34,3 +36,57 @@ const toArray = (val: T | T[]): T[] => { const isArraySafe = (val: T | T[]): val is T[] => { return val && Array.isArray(val); }; + +/** + * Create an object from an array where items are indexed by the specified key. + * @example + * const arr = [{ id: 1, name: "John"}, { id: 2, name: "Doe"}]; + * indexArrayBy(arr, "name"); + * // => { "John": { id: 1, name: "John"}, "Doe": { id: 2, name: "Doe"} } + */ +const indexArrayBy = < + T extends Record, + U extends DeepPath +>( + arr: T[], + key: U +): Record => { + return Object.fromEntries(arr.map((item) => [get(item, key), item])); +}; + +type DeepPath> = DeepPathRecursive< + T, + keyof T, + "", + [1, 2, 3] +>; + +type DeepPathRecursive< + T extends Record, + TKey extends keyof T, + TPrefix extends string, + TRecursion extends any[] +> = TKey extends TemplatableTypes + ? Head extends never + ? `${TPrefix}${TKey}` + : T[TKey] extends number | string + ? `${TPrefix}${TKey}` + : T[TKey] extends Record + ? DeepPathRecursive< + T[TKey], + keyof T[TKey], + `${TPrefix}${TKey}.`, + Tail + > + : "" + : ""; + +type TemplatableTypes = string | number | bigint | boolean | null | undefined; + +type Head = T extends [infer THead, ...infer _] + ? THead + : never; + +type Tail = T extends [infer _, ...infer TTail] + ? TTail + : never; diff --git a/packages/client/src/lib/formatter.ts b/packages/client/src/lib/formatter.ts index 559298dd..b4db4ea9 100644 --- a/packages/client/src/lib/formatter.ts +++ b/packages/client/src/lib/formatter.ts @@ -7,6 +7,7 @@ export { formatProduction, formatProductionGw, formatResource, + formatUserName, }; function formatBudget(value?: number) { @@ -52,3 +53,7 @@ function formatNumber(value?: number, options: Intl.NumberFormatOptions = {}) { ? new Intl.NumberFormat(userLocale, options).format(value) : ""; } + +function formatUserName(user: { firstName: string; lastName: string }): string { + return `${user.firstName} ${user.lastName}`; +} diff --git a/packages/client/src/modules/administration/Games/Game/Animation/Launch.tsx b/packages/client/src/modules/administration/Games/Game/Animation/Launch.tsx index 356270d2..e0b5fff9 100644 --- a/packages/client/src/modules/administration/Games/Game/Animation/Launch.tsx +++ b/packages/client/src/modules/administration/Games/Game/Animation/Launch.tsx @@ -1,4 +1,3 @@ -import { Button } from "@mui/material"; import RocketLaunchIcon from "@mui/icons-material/RocketLaunch"; import { useState } from "react"; import { useMutation, useQueryClient } from "react-query"; @@ -6,6 +5,7 @@ import { IGame, ITeamWithPlayers } from "../../../../../utils/types"; import { SuccessAlert } from "../../../../alert"; import { useNavigate } from "react-router-dom"; import { hasGameStarted } from "../../utils"; +import { Button } from "../../../../common/components/Button"; import { Dialog } from "../../../../common/components/Dialog"; import { Typography } from "../../../../common/components/Typography"; import { NO_TEAM } from "../../../../common/constants/teams"; @@ -65,7 +65,7 @@ export default function Launch({ game }: { game: IGameWithTeams }) { const playersWithoutTeams = hasPlayersWithoutTeam(game.teams); const navigate = useNavigate(); - const mutation = useMutation( + const launchGameMutation = useMutation( () => { const path = `/api/games/${game.id}`; return http.put(path, { status: "playing" }); @@ -80,7 +80,7 @@ export default function Launch({ game }: { game: IGameWithTeams }) { const launchGame = () => { if (!hasGameStarted(game.status)) { - mutation.mutate({ status: true }); + launchGameMutation.mutate({ status: true }); } setSuccessDialogOpen(false); }; @@ -97,12 +97,10 @@ export default function Launch({ game }: { game: IGameWithTeams }) { return (
- {mutation.isSuccess && } + {launchGameMutation.isSuccess && } + } @@ -128,7 +131,9 @@ export default function Launch({ game }: { game: IGameWithTeams }) { handleClose={() => setErrorDialogOpen(false)} actions={ <> - + } > diff --git a/packages/client/src/modules/common/components/Button/Button.tsx b/packages/client/src/modules/common/components/Button/Button.tsx new file mode 100644 index 00000000..89e2c9a0 --- /dev/null +++ b/packages/client/src/modules/common/components/Button/Button.tsx @@ -0,0 +1,43 @@ +import { Button as ButtonLib, CircularProgress } from "@mui/material"; +import { useMemo } from "react"; + +export { Button }; + +interface ButtonProps { + type?: "primary" | "secondary"; + loading?: boolean; + onClick: () => void | Promise; + children: React.ReactNode; +} + +function Button({ + type = "primary", + loading = false, + onClick, + children, +}: ButtonProps) { + const buttonProps = useMemo(() => { + if (type === "primary") { + return { + color: "secondary", + variant: "contained", + }; + } + return { + color: "primary", + }; + }, [type]); + const loaderColor = useMemo( + () => (type === "primary" ? "primary" : "secondary"), + [type] + ); + + return ( + + {loading && ( + + )} + {children} + + ); +} diff --git a/packages/client/src/modules/common/components/Button/index.ts b/packages/client/src/modules/common/components/Button/index.ts new file mode 100644 index 00000000..0064dee8 --- /dev/null +++ b/packages/client/src/modules/common/components/Button/index.ts @@ -0,0 +1 @@ +export { Button } from "./Button"; diff --git a/packages/client/src/modules/persona/persona.ts b/packages/client/src/modules/persona/persona.ts index 3a4eca5f..8da14c4d 100644 --- a/packages/client/src/modules/persona/persona.ts +++ b/packages/client/src/modules/persona/persona.ts @@ -12,7 +12,7 @@ import { computeMetals, PhysicalResourceNeedDatum, } from "../play/gameEngines/resourcesEngine"; -import { TeamAction } from "../../utils/types"; +import { ProductionAction, TeamAction } from "../../utils/types"; export { buildInitialPersona }; export type { Persona }; @@ -28,10 +28,11 @@ interface Persona { metals: PhysicalResourceNeedDatum[]; } -const buildInitialPersona: ( +const buildInitialPersona = ( personalization: PersoForm, - teamActions: TeamAction[] -) => Persona = (personalization: PersoForm, teamActions: TeamAction[]) => { + teamActions: TeamAction[], + productionActionById: Record +): Persona => { const formattedPersonalization = fillPersonalization(personalization); const intermediateValues = computeIntermediateValues( formattedPersonalization @@ -48,8 +49,12 @@ const buildInitialPersona: ( consumption as ConsumptionDatum[] ); - const materials = computeMaterials(production, teamActions); - const metals = computeMetals(production, teamActions); + const materials = computeMaterials( + production, + teamActions, + productionActionById + ); + const metals = computeMetals(production, teamActions, productionActionById); const persona: Persona = { budget: 13.7, diff --git a/packages/client/src/modules/play/Components/Synthesis/Synthesis.tsx b/packages/client/src/modules/play/Components/Synthesis/Synthesis.tsx index 9a7b294c..0300063e 100644 --- a/packages/client/src/modules/play/Components/Synthesis/Synthesis.tsx +++ b/packages/client/src/modules/play/Components/Synthesis/Synthesis.tsx @@ -4,12 +4,12 @@ import { emphasizeText } from "../../../common/utils"; import { synthesisConstants } from "../../playerActions/constants/synthesis"; import { Icon } from "../../../common/components/Icon"; import { useTeamValues } from "../../context/playContext"; -import { ITeamWithPlayers } from "../../../../utils/types"; +import { ITeam } from "../../../../utils/types"; import { getDaysTo2050 } from "../../../../lib/time"; export { SynthesisRecap, SynthesisBudget, SynthesisCarbon }; -function SynthesisRecap({ team }: { team: ITeamWithPlayers }) { +function SynthesisRecap({ team }: { team: ITeam }) { return ( @@ -20,7 +20,7 @@ function SynthesisRecap({ team }: { team: ITeamWithPlayers }) { ); } -function SynthesisBudget({ team }: { team: ITeamWithPlayers | null }) { +function SynthesisBudget({ team }: { team: ITeam | null }) { const daysTo2050 = getDaysTo2050(); const { getTeamById } = useTeamValues(); const teamValues = getTeamById(team?.id); @@ -51,7 +51,7 @@ function SynthesisBudget({ team }: { team: ITeamWithPlayers | null }) { ); } -function SynthesisCarbon({ team }: { team: ITeamWithPlayers | null }) { +function SynthesisCarbon({ team }: { team: ITeam | null }) { const { getTeamById } = useTeamValues(); const teamValues = getTeamById(team?.id); const teamCarbonFootprintInKgPerDay = teamValues?.carbonFootprint || 0; diff --git a/packages/client/src/modules/play/Components/TeamActionsRecap/TeamActionsRecap.tsx b/packages/client/src/modules/play/Components/TeamActionsRecap/TeamActionsRecap.tsx index d9dfaa2f..09bc5b73 100644 --- a/packages/client/src/modules/play/Components/TeamActionsRecap/TeamActionsRecap.tsx +++ b/packages/client/src/modules/play/Components/TeamActionsRecap/TeamActionsRecap.tsx @@ -3,7 +3,7 @@ import sumBy from "lodash/sumBy"; import React from "react"; import { Typography } from "../../../common/components/Typography"; -import { useCurrentStep } from "../../context/playContext"; +import { useCurrentStep, usePlay } from "../../context/playContext"; import { Icon } from "../../../common/components/Icon"; import { computeTeamActionStats } from "../../utils/production"; import { TeamAction } from "../../../../utils/types"; @@ -26,11 +26,12 @@ function TeamActionsRecap({ showCredibility?: boolean; }) { const currentStep = useCurrentStep(); + const { productionActionById } = usePlay(); const energyNameToEnergyStats = Object.fromEntries( teamActions.map((teamAction) => [ - teamAction.action.name, - computeTeamActionStats(teamAction), + productionActionById[teamAction.actionId].name, + computeTeamActionStats(teamAction, productionActionById), ]) ); @@ -78,7 +79,7 @@ function TeamActionsRecap({ {teamActions.map((teamAction) => ( @@ -96,12 +97,13 @@ function EnergyListItem({ showCredibility?: boolean; }) { const theme = useTheme(); + const { productionActionById } = usePlay(); if (!teamAction) { return null; } - const stats = computeTeamActionStats(teamAction); + const stats = computeTeamActionStats(teamAction, productionActionById); const color = teamAction.isTouched ? theme.palette.secondary.main : "white"; return ( @@ -121,7 +123,12 @@ function EnergyListItem({
)} - {t(`production.energy.${teamAction.action.name}.name`)} : + {t( + `production.energy.${ + productionActionById[teamAction.actionId].name + }.name` + )} + : diff --git a/packages/client/src/modules/play/GameConsole/GameConsoleView.tsx b/packages/client/src/modules/play/GameConsole/GameConsoleView.tsx index 535f25e0..7a7dee98 100644 --- a/packages/client/src/modules/play/GameConsole/GameConsoleView.tsx +++ b/packages/client/src/modules/play/GameConsole/GameConsoleView.tsx @@ -15,6 +15,7 @@ import { useTranslation } from "../../translations/useTranslation"; export { GameConsoleView }; +// TODO: protect page using user role. function GameConsoleView() { const [selectedScreen, setSelectedScreen] = useState("Teams"); return ( @@ -32,7 +33,7 @@ function GameConsoleView() { function Header(props: any) { const { selectedScreen, setSelectedScreen } = props; const [isDialogOpen, setIsDialogOpen] = useState(false); - const { game, isStepFinished, updateGame } = usePlay(); + const { game, updateGame } = usePlay(); const theme = useTheme(); const { t } = useTranslation(); @@ -95,14 +96,14 @@ function Header(props: any) { sx={{ border: `1px solid ${theme.palette.secondary.main}` }} onClick={() => setIsDialogOpen(true)} > - {!isStepFinished ? stopStepLabel : startStepLabel} + {!game.isStepFinished ? stopStepLabel : startStepLabel} setIsDialogOpen(false)} content={ - !isStepFinished + !game.isStepFinished ? t(`dialog.step.end`, { stepNumber: currentStepNumber }) : t(`dialog.step.start`, { stepNumber: nextStepNumber }) } @@ -123,7 +124,7 @@ function Header(props: any) { color="secondary" variant="contained" onClick={() => { - !isStepFinished ? stopStep() : startStep(); + !game.isStepFinished ? stopStep() : startStep(); setIsDialogOpen(false); }} > diff --git a/packages/client/src/modules/play/GameConsole/PlayerChart.tsx b/packages/client/src/modules/play/GameConsole/PlayerChart.tsx index c6784bb6..3d735dfd 100644 --- a/packages/client/src/modules/play/GameConsole/PlayerChart.tsx +++ b/packages/client/src/modules/play/GameConsole/PlayerChart.tsx @@ -1,4 +1,4 @@ -import { ITeamWithPlayers, IUser } from "../../../utils/types"; +import { ITeam } from "../../../utils/types"; import { StackedEnergyBars } from "../../charts/StackedEnergyBars"; import { sumAllValues, sumForAndFormat } from "../../persona"; import { usePersonaByUserId, usePlay } from "../context/playContext"; @@ -9,13 +9,18 @@ import { ResourcesPerStepChart, } from "../../charts"; import { useTranslation } from "../../translations/useTranslation"; +import { formatUserName } from "../../../lib/formatter"; export { PlayerChart }; -function PlayerChart({ team }: { team: ITeamWithPlayers }) { +function PlayerChart({ team }: { team: ITeam }) { const { t } = useTranslation(); + const { players } = usePlay(); - const userIds = team.players.map(({ user }) => user.id); + const userIds = useMemo( + () => players.filter((p) => p.teamId === team.id).map((p) => p.userId), + [players, team] + ); const personaByUserId = usePersonaByUserId(userIds); const [firstPersona] = Object.values(personaByUserId); @@ -66,21 +71,29 @@ function PlayerChart({ team }: { team: ITeamWithPlayers }) { return ; } -function useBuildData({ team }: { team: ITeamWithPlayers }) { - const { game } = usePlay(); - const userIds = team.players.map(({ user }) => user.id); +function useBuildData({ team }: { team: ITeam }) { + const { game, players } = usePlay(); + + const playersInTeam = useMemo( + () => players.filter((p) => p.teamId === team.id), + [players, team] + ); + const userIds = useMemo( + () => playersInTeam.map((p) => p.userId), + [playersInTeam] + ); const personaByUserId = usePersonaByUserId(userIds); - const [firstPersona] = Object.values(personaByUserId); // TODO: I am not sure how production should be computed. Sum for all team members? + const [firstPersona] = Object.values(personaByUserId); return [ - ...team.players.map((player) => { - const userId = player.user.id; + ...playersInTeam.map((player) => { + const userId = player.userId; const personaBySteps = personaByUserId[userId]!.personaBySteps; const playerConsumption = personaBySteps[game.lastFinishedStep].consumption; return { - name: buildName(player.user), + name: formatUserName(player.user), type: "consumption", total: sumAllValues(playerConsumption) || 0, grey: sumForAndFormat(playerConsumption, "grey"), @@ -117,7 +130,3 @@ function useBuildData({ team }: { team: ITeamWithPlayers }) { }, ]; } - -function buildName(user: IUser): string { - return `${user.firstName} ${user.lastName}`; -} diff --git a/packages/client/src/modules/play/GameConsole/StatsConsole.tsx b/packages/client/src/modules/play/GameConsole/StatsConsole.tsx index 2c3e994f..4cec38ff 100644 --- a/packages/client/src/modules/play/GameConsole/StatsConsole.tsx +++ b/packages/client/src/modules/play/GameConsole/StatsConsole.tsx @@ -12,7 +12,7 @@ import { } from "./utils/statsConsoleValues"; import { useTranslation } from "../../translations/useTranslation"; import { I18nTranslateFunction } from "../../translations"; -import { IEnrichedGame } from "../../../utils/types"; +import { IEnrichedGame, ITeam } from "../../../utils/types"; export { StatsConsole }; @@ -24,7 +24,7 @@ export interface StatsData { function StatsConsole() { const { t } = useTranslation(); - const { game } = usePlay(); + const { game, teams } = usePlay(); const { teamValues } = useTeamValues(); const theme = useTheme(); @@ -101,7 +101,7 @@ function StatsConsole() { > Points - {displayPoints(game, teamIdToTeamValues)} + {displayPoints(game, teams, teamIdToTeamValues)} {game.isSynthesisStep && !game.isLarge && ( @@ -115,7 +115,7 @@ function StatsConsole() { Nom du scénario - {game.teams.map((team) => ( + {teams.map((team) => ( {team.name} @@ -138,7 +138,7 @@ function StatsConsole() { CO2 ( {carbonFootprintUnit}) - {displayCarbonFootprint(game, teamIdToTeamValues, t)} + {displayCarbonFootprint(game, teams, teamIdToTeamValues, t)} @@ -151,7 +151,7 @@ function StatsConsole() { Budget ({budgetUnit}) - {displayBudget(game, teamIdToTeamValues, t)} + {displayBudget(game, teams, teamIdToTeamValues, t)} @@ -185,19 +185,22 @@ function SummaryCard({ function displayPoints( game: IEnrichedGame, + teams: ITeam[], teamIdToTeamValues: TeamIdToValues ) { - const teamsToDisplay = buildValuesPoints(game, teamIdToTeamValues); + const teamsToDisplay = buildValuesPoints(game, teams, teamIdToTeamValues); return ; } function displayCarbonFootprint( game: IEnrichedGame, + teams: ITeam[], teamIdToTeamValues: TeamIdToValues, t: I18nTranslateFunction ) { const valuesCarbonFootprint = buildValuesCarbonFootprint( game, + teams, teamIdToTeamValues, t ).map((value) => ({ ...value, icon: })); @@ -206,11 +209,15 @@ function displayCarbonFootprint( function displayBudget( game: IEnrichedGame, + teams: ITeam[], teamIdToTeamValues: TeamIdToValues, t: I18nTranslateFunction ) { - const valuesBudget = buildValuesBudget(game, teamIdToTeamValues, t).map( - (value) => ({ ...value, icon: }) - ); + const valuesBudget = buildValuesBudget( + game, + teams, + teamIdToTeamValues, + t + ).map((value) => ({ ...value, icon: })); return ; } diff --git a/packages/client/src/modules/play/GameConsole/TeamConsoleContent.tsx b/packages/client/src/modules/play/GameConsole/TeamConsoleContent.tsx index 598fce40..bde36163 100644 --- a/packages/client/src/modules/play/GameConsole/TeamConsoleContent.tsx +++ b/packages/client/src/modules/play/GameConsole/TeamConsoleContent.tsx @@ -2,31 +2,41 @@ import { Box, Rating, useTheme } from "@mui/material"; import { PlayerChart } from "./PlayerChart"; import { PlayBox } from "../Components"; import { Icon } from "../../common/components/Icon"; -import { ITeamWithPlayers, IUser, Player } from "../../../utils/types"; +import { ITeam, Player } from "../../../utils/types"; import { usePersonaByUserId, useCurrentStep, usePlay, } from "../context/playContext"; import { Typography } from "../../common/components/Typography"; -import { formatBudget, formatCarbonFootprint } from "../../../lib/formatter"; +import { + formatBudget, + formatCarbonFootprint, + formatUserName, +} from "../../../lib/formatter"; import { TeamActionsRecap } from "../Components/TeamActionsRecap"; import { getTeamActionsAtCurrentStep } from "../utils/teamActions"; import { sumAllValues } from "../../persona"; import { SynthesisRecap } from "../Components/Synthesis"; +import { useMemo } from "react"; export { TeamConsoleContent }; -function TeamConsoleContent({ team }: { team: ITeamWithPlayers }) { - const { game } = usePlay(); +function TeamConsoleContent({ team }: { team: ITeam }) { + const { game, players, productionActionById } = usePlay(); const currentStep = useCurrentStep(); + const playersInTeam = useMemo( + () => players.filter((p) => p.teamId === team.id), + [players, team] + ); const isProductionStep = currentStep?.type === "production"; const isSynthesisStep = currentStep?.id === "final-situation"; const teamActionsAtCurrentStep = getTeamActionsAtCurrentStep( game.step, - team.actions + team.actions, + productionActionById ); const PlayerComponent = getPlayerComponent(isProductionStep, isSynthesisStep); @@ -50,7 +60,7 @@ function TeamConsoleContent({ team }: { team: ITeamWithPlayers }) { - {team.players.map((player) => ( + {playersInTeam.map((player) => ( ))} @@ -89,7 +99,7 @@ function PlayerSynthesis({ player }: { player: Player }) { return ( - {buildName(player.user)} + {formatUserName(player.user)} - {buildName(player.user)} + {formatUserName(player.user)} @@ -179,7 +189,7 @@ function PlayerConsumption({ player }: { player: Player }) { return ( - {buildName(player.user)} + {formatUserName(player.user)} {player.hasFinishedStep ? ( ); } - -function buildName(user: IUser): string { - return `${user.firstName} ${user.lastName}`; -} diff --git a/packages/client/src/modules/play/GameConsole/TeamConsoleHeader.tsx b/packages/client/src/modules/play/GameConsole/TeamConsoleHeader.tsx index 64d7d8fb..8412d520 100644 --- a/packages/client/src/modules/play/GameConsole/TeamConsoleHeader.tsx +++ b/packages/client/src/modules/play/GameConsole/TeamConsoleHeader.tsx @@ -1,5 +1,5 @@ import { Box, Grid, Tooltip, useTheme } from "@mui/material"; -import { ITeamWithPlayers } from "../../../utils/types"; +import { ITeam } from "../../../utils/types"; import { PlayBox } from "../Components"; import { useCurrentStep, usePlay } from "../context/playContext"; import { Icon } from "../../common/components/Icon"; @@ -15,7 +15,7 @@ function TeamConsoleHeader({ selectedTeamId: number; setSelectedTeamId: React.Dispatch>; }) { - const { game } = usePlay(); + const { teams } = usePlay(); const currentStep = useCurrentStep(); const isProductionStep = currentStep?.type === "production"; @@ -23,7 +23,7 @@ function TeamConsoleHeader({ return ( - {game.teams.map((team) => { + {teams.map((team) => { return ( p.teamId === team.id); const color = isSelected ? `${theme.palette.secondary.main} !important` @@ -145,7 +149,7 @@ function TeamConsumption({ - {team.players.map(({ user, hasFinishedStep }) => { + {playersInTeam.map(({ user, hasFinishedStep }) => { return (
@@ -170,7 +174,7 @@ function TeamSynthesis({ team, isSelected, }: { - team: ITeamWithPlayers; + team: ITeam; isSelected: boolean; }) { const theme = useTheme(); diff --git a/packages/client/src/modules/play/GameConsole/TeamConsoleLayout.tsx b/packages/client/src/modules/play/GameConsole/TeamConsoleLayout.tsx index e2488790..91a6f63f 100644 --- a/packages/client/src/modules/play/GameConsole/TeamConsoleLayout.tsx +++ b/packages/client/src/modules/play/GameConsole/TeamConsoleLayout.tsx @@ -1,5 +1,5 @@ import { Box } from "@mui/material"; -import { useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { usePlay } from "../context/playContext"; import { TeamConsoleContent } from "./TeamConsoleContent"; import { TeamConsoleHeader } from "./TeamConsoleHeader"; @@ -7,13 +7,22 @@ import { TeamConsoleHeader } from "./TeamConsoleHeader"; export { TeamConsoleLayout }; function TeamConsoleLayout() { - const { game } = usePlay(); + const { teams } = usePlay(); const [selectedTeamId, setSelectedTeamId] = useState( - game.teams[0]?.id + teams[0]?.id || 0 ); - const selectedTeam = game.teams.find(({ id }) => id === selectedTeamId); + const selectedTeam = useMemo( + () => teams.find(({ id }) => id === selectedTeamId), + [selectedTeamId, teams] + ); + + useEffect(() => { + if (!selectedTeamId && teams.length) { + setSelectedTeamId(teams[0].id); + } + }, [selectedTeamId, teams]); if (!selectedTeamId || !selectedTeam) return <>; diff --git a/packages/client/src/modules/play/GameConsole/utils/statsConsoleValues.tsx b/packages/client/src/modules/play/GameConsole/utils/statsConsoleValues.tsx index 96c7ec68..8bd85b89 100644 --- a/packages/client/src/modules/play/GameConsole/utils/statsConsoleValues.tsx +++ b/packages/client/src/modules/play/GameConsole/utils/statsConsoleValues.tsx @@ -44,6 +44,7 @@ function computeCarbonFootprint( export const buildValuesPoints = ( game: IEnrichedGame, + teams: ITeam[], teamIdToTeamValues: TeamIdToValues ) => { const teamValues = Object.values(teamIdToTeamValues); @@ -53,7 +54,7 @@ export const buildValuesPoints = ( .map((team, index) => ({ id: team.id, icon: getPointIcon(index + 1), - name: game.teams.find((t: ITeam) => team.id === t.id)?.name || "", + name: teams.find((t: ITeam) => team.id === t.id)?.name || "", value: formatPoints(teamIdToTeamValues[team.id].points), })); }; @@ -80,14 +81,13 @@ function getPointIcon(rank: number): JSX.Element { export const buildValuesBudget = ( game: IEnrichedGame, + teams: ITeam[], teamIdToTeamValues: TeamIdToValues, t: I18nTranslateFunction ) => { if (game.isLarge) { - const budgets = game.teams.map( - (team) => teamIdToTeamValues[team.id].budget - ); - const budgetsSpent = game.teams.map( + const budgets = teams.map((team) => teamIdToTeamValues[team.id].budget); + const budgetsSpent = teams.map( (team) => teamIdToTeamValues[team.id].budgetSpent ); return [ @@ -120,7 +120,7 @@ export const buildValuesBudget = ( }, ]; } - return game.teams.map((team) => ({ + return teams.map((team) => ({ id: team.id, name: team.name, value: computeBudget( @@ -133,11 +133,12 @@ export const buildValuesBudget = ( export const buildValuesCarbonFootprint = ( game: IEnrichedGame, + teams: ITeam[], teamIdToTeamValues: TeamIdToValues, t: I18nTranslateFunction ) => { if (game.isLarge) { - const footprints = game.teams.map( + const footprints = teams.map( (team) => teamIdToTeamValues[team.id].carbonFootprint || 0 ); return [ @@ -164,7 +165,7 @@ export const buildValuesCarbonFootprint = ( }, ]; } - return game.teams.map((team) => ({ + return teams.map((team) => ({ id: team.id, name: team.name, value: computeCarbonFootprint( diff --git a/packages/client/src/modules/play/Personalization/PersonalizationForm.tsx b/packages/client/src/modules/play/Personalization/PersonalizationForm.tsx index f83f3039..41704958 100644 --- a/packages/client/src/modules/play/Personalization/PersonalizationForm.tsx +++ b/packages/client/src/modules/play/Personalization/PersonalizationForm.tsx @@ -33,13 +33,15 @@ import { Accordion } from "../../common/components/Accordion"; import { FormStatusBanner } from "./common/FormStatusBanner"; import { useTranslation } from "../../translations/useTranslation"; import { http } from "../../../utils/request"; +import { useCurrentPlayer } from "../context/hooks/player"; export { PersonalizationForm }; function PersonalizationForm() { const gameId = useGameId(); const { t } = useTranslation(); - const { profile, updateProfile } = usePlay(); + const { updateProfile } = usePlay(); + const { profile } = useCurrentPlayer(); const [formSaveStatus, setFormSaveStatus] = useState< "draft-saved" | "form-validated" | "error" | null >(null); @@ -85,10 +87,10 @@ function PersonalizationForm() { profile?.personalization?.heatingInvoice || getQuestionByName("heatingInvoice")?.defaultValue; return { - ...profile.personalization, + ...(profile?.personalization || {}), heatingConsumption: consumption, heatingInvoice: invoice, - }; + } as PersoForm; }, [profile]), }); diff --git a/packages/client/src/modules/play/Personalization/common/FormStatusBanner.tsx b/packages/client/src/modules/play/Personalization/common/FormStatusBanner.tsx index d4570522..2c603a5d 100644 --- a/packages/client/src/modules/play/Personalization/common/FormStatusBanner.tsx +++ b/packages/client/src/modules/play/Personalization/common/FormStatusBanner.tsx @@ -1,17 +1,17 @@ import { useCallback } from "react"; import { Grid, Typography, useTheme } from "@mui/material"; import { FormStatus } from "../models/form"; -import { usePlay } from "../../context/playContext"; import { useTranslation } from "../../../translations/useTranslation"; +import { useCurrentPlayer } from "../../context/hooks/player"; export { FormStatusBanner }; function FormStatusBanner() { const theme = useTheme(); const { t } = useTranslation(); - const { profile } = usePlay(); + const { profile } = useCurrentPlayer(); - const formatLastUpdateDate = useCallback((date: Date) => { + const formatLastUpdateDate = useCallback((date: string) => { if (!date) { return "Aucune action enregistrée"; } diff --git a/packages/client/src/modules/play/PlayerPersona/Persona.tsx b/packages/client/src/modules/play/PlayerPersona/Persona.tsx index 57061f9c..4024005a 100644 --- a/packages/client/src/modules/play/PlayerPersona/Persona.tsx +++ b/packages/client/src/modules/play/PlayerPersona/Persona.tsx @@ -9,10 +9,10 @@ import { Question, } from "../Personalization/models/form"; import { fulfillsConditions } from "../Personalization/utils/formValidation"; -import { usePlay } from "../context/playContext"; import { QuestionLine, QuestionText } from "../Personalization/styles/form"; import { DescriptionValue } from "./Persona.styles"; import { formatBooleanValue } from "../../../utils/format"; +import { useCurrentPlayer } from "../context/hooks/player"; export { Persona }; @@ -70,7 +70,7 @@ function Persona() { ); }; - const { profile } = usePlay(); + const { profile } = useCurrentPlayer(); return ( diff --git a/packages/client/src/modules/play/PlayerPersona/PlayerHeader.tsx b/packages/client/src/modules/play/PlayerPersona/PlayerHeader.tsx index e640c3eb..9bfb6cc8 100644 --- a/packages/client/src/modules/play/PlayerPersona/PlayerHeader.tsx +++ b/packages/client/src/modules/play/PlayerPersona/PlayerHeader.tsx @@ -17,7 +17,6 @@ import { useMyTeam, usePlay, useTeamValues, - usePersona, } from "../context/playContext"; import { sumAllValues } from "../../persona"; import { Icon } from "../../common/components/Icon"; @@ -29,6 +28,7 @@ import { import { Typography } from "../../common/components/Typography"; import { RowItem } from "../../common/components/RowItem"; import { useTranslation } from "../../translations/useTranslation"; +import { usePersona } from "../context/hooks/player"; export { PlayerHeader, Header, Actions }; @@ -226,7 +226,7 @@ function Header({ function Actions({ className }: { className?: string }) { const { t } = useTranslation(); - const { game, isGameFinished, isStepFinished } = usePlay(); + const { game } = usePlay(); const currentStep = useCurrentStep(); const iconName = @@ -266,7 +266,9 @@ function Actions({ className }: { className?: string }) { mt: 2, width: "200px", }} - disabled={!isGameFinished && (game.step === 0 || isStepFinished)} + disabled={ + !game.isGameFinished && (game.step === 0 || game.isStepFinished) + } > {t(`cta.go-to-step.${currentStep?.id}` as any)} diff --git a/packages/client/src/modules/play/Stats/StatsGraphs.tsx b/packages/client/src/modules/play/Stats/StatsGraphs.tsx index ef787b16..33d57688 100644 --- a/packages/client/src/modules/play/Stats/StatsGraphs.tsx +++ b/packages/client/src/modules/play/Stats/StatsGraphs.tsx @@ -13,11 +13,12 @@ import { } from "../../common/components/EnergyButtons"; import { STEPS } from "../constants"; import _ from "lodash"; -import { usePersona, usePlay } from "../context/playContext"; +import { usePlay } from "../context/playContext"; import { sumAllValues, sumForAndFormat } from "../../persona"; import { IGame } from "../../../utils/types"; import { Tabs } from "../../common/components/Tabs"; import { useTranslation } from "../../translations/useTranslation"; +import { usePersona } from "../context/hooks/player"; export { StatsGraphs }; diff --git a/packages/client/src/modules/play/context/hooks/player/index.ts b/packages/client/src/modules/play/context/hooks/player/index.ts new file mode 100644 index 00000000..898d672c --- /dev/null +++ b/packages/client/src/modules/play/context/hooks/player/index.ts @@ -0,0 +1,2 @@ +export * from "./useCurrentPlayer"; +export * from "./usePersona"; diff --git a/packages/client/src/modules/play/context/hooks/player/useCurrentPlayer.ts b/packages/client/src/modules/play/context/hooks/player/useCurrentPlayer.ts new file mode 100644 index 00000000..01fd8582 --- /dev/null +++ b/packages/client/src/modules/play/context/hooks/player/useCurrentPlayer.ts @@ -0,0 +1,46 @@ +import { STEPS } from "../../../constants"; +import { computePlayerActionsStats } from "../../../utils/playerActions"; +import { usePlay } from "../../playContext"; +import { useMemo } from "react"; +import { getTeamActionsAtCurrentStep } from "../../../utils/teamActions"; +import { useAuth } from "../../../../auth/authProvider"; + +export { useCurrentPlayer }; + +function useCurrentPlayer() { + const { user } = useAuth(); + const { consumptionActionById, game, players, productionActionById, teams } = + usePlay(); + + const player = useMemo( + () => players.find((p) => p.userId === user?.id)!, + [players, user] + ); + const team = useMemo( + () => teams.find((t) => t.id === player.teamId)!, + [player, teams] + ); + const teamActionsAtCurrentStep = useMemo( + () => + getTeamActionsAtCurrentStep( + game.step, + team.actions, + productionActionById + ), + [game.step, team.actions, productionActionById] + ); + + return { + player, + profile: player.profile, + playerActions: player.actions, + actionPointsAvailableAtCurrentStep: STEPS[game.step].availableActionPoints, + teamActions: team.actions, + teamActionsAtCurrentStep, + ...computePlayerActionsStats( + game.step, + player.actions, + consumptionActionById + ), + }; +} diff --git a/packages/client/src/modules/play/context/hooks/player/usePersona.ts b/packages/client/src/modules/play/context/hooks/player/usePersona.ts new file mode 100644 index 00000000..d66d2f6e --- /dev/null +++ b/packages/client/src/modules/play/context/hooks/player/usePersona.ts @@ -0,0 +1,29 @@ +import { buildPersona } from "../../../utils/persona"; +import { buildInitialPersona } from "../../../../persona/persona"; +import { usePlay } from "../../playContext"; +import { useCurrentPlayer } from "./useCurrentPlayer"; + +export { usePersona }; + +function usePersona() { + const { consumptionActionById, game, productionActionById } = usePlay(); + const { player, playerActions, teamActions } = useCurrentPlayer(); + + const personalization = player.profile.personalization; + + const initialPersona = buildInitialPersona( + personalization, + teamActions, + productionActionById + ); + + return buildPersona( + game, + personalization, + initialPersona, + playerActions, + consumptionActionById, + teamActions, + productionActionById + ); +} diff --git a/packages/client/src/modules/play/context/playContext.tsx b/packages/client/src/modules/play/context/playContext.tsx index 7dd10da5..303ee290 100644 --- a/packages/client/src/modules/play/context/playContext.tsx +++ b/packages/client/src/modules/play/context/playContext.tsx @@ -4,42 +4,31 @@ import { CircularProgress } from "@mui/material"; import * as React from "react"; import { useMatch, useNavigate } from "react-router-dom"; import { + Action, + IEnrichedGame, IGame, - ITeamWithPlayers, + ITeam, Player, - PlayerActions, - TeamAction, + ProductionAction, } from "../../../utils/types"; import { useAuth } from "../../auth/authProvider"; -import { - GameStep, - GameStepType, - isStepOfType, - LARGE_GAME_TEAMS, - STEPS, -} from "../constants"; -import { sortBy } from "../../../lib/array"; +import { GameStep, GameStepType, isStepOfType, STEPS } from "../constants"; import { buildPersona } from "../utils/persona"; -import { computePlayerActionsStats } from "../utils/playerActions"; -import { getTeamActionsAtCurrentStep } from "../utils/teamActions"; import { mean } from "../../../lib/math"; import { range } from "lodash"; import { sumAllValues } from "../../persona"; -import { NO_TEAM } from "../../common/constants/teams"; import { buildInitialPersona } from "../../persona/persona"; import { WEB_SOCKET_URL } from "../../common/constants"; +import { usePlayStore } from "./usePlayStore"; +import { updateCollection } from "./playContext.utils"; export { - PlayProvider, RootPlayProvider, useCurrentStep, useMyTeam, useTeamValues, useLoadedPlay as usePlay, usePersonaByUserId, - usePlayerActions, - useTeamActions, - usePersona, }; export type { TeamIdToValues }; @@ -64,9 +53,13 @@ interface TeamValues { } interface IPlayContext { + consumptionActions: Action[]; + consumptionActionById: Record; game: IEnrichedGame; - isGameFinished: boolean; - isStepFinished: boolean; + players: Player[]; + productionActions: ProductionAction[]; + productionActionById: Record; + teams: ITeam[]; updateGame: (update: Partial) => void; updatePlayerActions: ( update: { @@ -75,9 +68,7 @@ interface IPlayContext { }[] ) => void; setActionPointsLimitExceeded: (limitExceeded: boolean) => void; - player: PlayerState; updatePlayer: (options: { hasFinishedStep?: boolean }) => void; - profile: any; readProfile: () => void; updateProfile: ( options: { userId: number; update: any }, @@ -91,18 +82,6 @@ interface IPlayContext { scenarioName?: string; }) => void; } -type IEnrichedGame = IGame & { - teams: ITeamWithPlayers[]; - isLarge?: boolean; - isSynthesisStep?: boolean; -}; - -interface PlayerState { - hasFinishedStep: boolean; - actionPointsLimitExceeded: boolean; - playerActions: PlayerActions[]; - teamActions: TeamAction[]; -} const PlayContext = React.createContext(null); @@ -117,55 +96,61 @@ function RootPlayProvider({ children }: { children: React.ReactNode }) { function PlayProvider({ children }: { children: React.ReactNode }) { const { token, user } = useAuth(); const match = useMatch(`play/games/:gameId/*`); - if (!match) throw new Error("Provider use outside of game play."); - const gameId = +(match.params.gameId as string); - const [gameWithTeams, setGameWithTeams] = useState( - null - ); - const [player, setPlayer] = useState({ - hasFinishedStep: true, - actionPointsLimitExceeded: false, - playerActions: [], - teamActions: [], - }); - const [profile, setProfile] = useState({}); + const gameId = useMemo(() => { + return +(match?.params.gameId as string) || 0; + }, [match?.params.gameId]); + + const { + consumptionActions, + consumptionActionById, + game, + isInitialised, + players, + productionActions, + productionActionById, + teams, + setConsumptionActions, + setGame, + setIsInitialised, + setPlayers, + setProductionActions, + setTeams, + } = usePlayStore(); const { socket } = useGameSocket({ gameId, token, - setGameWithTeams, - setPlayer, - setProfile, + setConsumptionActions, + setGame, + setIsInitialised, + setPlayers, + setProductionActions, + setTeams, }); - const gameWithSortedTeams = useMemo( - () => enrichTeams(gameWithTeams), - [gameWithTeams] - ); - const readProfile = useCallback(() => { if (user?.id) { - socket?.emit("readProfile", { gameId, userId: user.id }); + socket?.emit("profile:read"); } - }, [gameId, socket, user]); + }, [socket, user]); const setActionPointsLimitExceeded = useCallback( (actionPointsLimitExceeded: boolean) => { - setPlayer((previous) => ({ ...previous, actionPointsLimitExceeded })); + setPlayers(([player]) => [{ ...player, actionPointsLimitExceeded }]); }, - [setPlayer] + [setPlayers] ); - if (gameWithSortedTeams === null || socket === null) { + if (!isInitialised || !game || !socket) { return ; } const updateGame = (update: Partial) => { - setGameWithTeams((previous) => { + setGame((previous) => { if (previous === null) return null; return { ...previous, ...update }; }); - socket.emit("updateGame", { gameId, update }); + socket.emit("game:update", { update }); }; const updatePlayerActions = ( @@ -174,19 +159,18 @@ function PlayProvider({ children }: { children: React.ReactNode }) { id: number; }[] ) => { - if (player.hasFinishedStep) { + if (players[0].hasFinishedStep) { return; } - socket.emit("updatePlayerActions", { - gameId, - step: gameWithSortedTeams.step, + socket.emit("player-actions:update", { + step: game.step, playerActions, }); }; const updatePlayer = ({ hasFinishedStep }: { hasFinishedStep?: boolean }) => { - socket.emit("updatePlayer", { gameId, hasFinishedStep }); + socket.emit("player:update", { hasFinishedStep }); }; const updateProfile = ( @@ -199,7 +183,7 @@ function PlayProvider({ children }: { children: React.ReactNode }) { }, onRespond?: (args: { success: boolean }) => void ) => { - socket.emit("updateProfile", { gameId, userId, update }, onRespond); + socket.emit("profile:update", { gameId, userId, update }, onRespond); }; const updateTeam = ({ @@ -212,8 +196,8 @@ function PlayProvider({ children }: { children: React.ReactNode }) { }[]; scenarioName?: string; }) => { - socket.emit("updateTeam", { - step: gameWithSortedTeams.step, + socket.emit("team:update", { + step: game.step, teamActions, scenarioName, }); @@ -222,16 +206,17 @@ function PlayProvider({ children }: { children: React.ReactNode }) { return ( name !== NO_TEAM) - .sort(sortBy("id", "asc")), - isLarge: - (gameWithTeams && gameWithTeams.teams.length > LARGE_GAME_TEAMS) || - false, - isSynthesisStep: gameWithTeams && gameWithTeams.step === STEPS.length - 1, - }; - } - return null; -} - function useLoadedPlay(): IPlayContext { const playValue = usePlay(); if (playValue === null) { @@ -266,79 +235,97 @@ function useLoadedPlay(): IPlayContext { return playValue; } -function useMyTeam(): ITeamWithPlayers | null { - const { game: gameWithTeams } = useLoadedPlay(); +function useMyTeam(): ITeam | null { + const { players, teams } = useLoadedPlay(); const { user } = useAuth(); - if (!user) return null; - if (!gameWithTeams) return null; - return ( - gameWithTeams.teams.find((team) => - team.players.some((player) => player.userId === user.id) - ) ?? null - ); + + const myTeam = useMemo(() => { + if (!user || !teams.length) { + return null; + } + + const myPlayer = players.find((p) => p.userId === user?.id); + if (!myPlayer) { + return null; + } + + return teams.find((t) => t.id === myPlayer.teamId) || null; + }, [players, teams, user]); + + return myTeam; } function useTeamValues(): { teamValues: TeamValues[]; getTeamById: (id: number | undefined) => TeamValues | undefined; } { - const { game } = useLoadedPlay(); - const userIds: number[] = []; - game.teams.map((team) => - team.players.map(({ user }) => userIds.push(user?.id)) + const { game, players, teams } = useLoadedPlay(); + + const userIds: number[] = useMemo( + () => players.map((p) => p.userId), + [players] ); const personaByUserId = usePersonaByUserId(userIds); - const teamValues = game.teams.map((team) => ({ - id: team.id, - playerCount: team.players.length, - points: mean( - team.players.map( - ({ userId }) => personaByUserId[userId].currentPersona.points - ) - ), - budget: mean( - team.players.map( - ({ userId }) => personaByUserId[userId].currentPersona.budget - ) - ), - budgetSpent: mean( - team?.players - .map(({ userId }) => personaByUserId[userId]) - .map( - (persona) => - persona.getPersonaAtStep(0).budget - persona.currentPersona.budget - ) - ), - carbonFootprint: mean( - team.players.map( - ({ userId }) => personaByUserId[userId].currentPersona.carbonFootprint - ) - ), - carbonFootprintReduction: mean( - team?.players - .map(({ userId }) => personaByUserId[userId]) - .map( - (persona) => - (1 - - persona.currentPersona.carbonFootprint / - persona.getPersonaAtStep(0).carbonFootprint) * - 100 - ) - ), - stepToConsumption: buildStepToData( - "consumption", - game, - team, - personaByUserId - ), - stepToProduction: buildStepToData( - "production", - game, - team, - personaByUserId - ), - })); + const teamValues = useMemo(() => { + return teams.map((team) => { + const playersInTeam = players.filter((p) => p.teamId === team.id); + + return { + id: team.id, + playerCount: playersInTeam.length, + points: mean( + playersInTeam.map( + ({ userId }) => personaByUserId[userId].currentPersona.points + ) + ), + budget: mean( + playersInTeam.map( + ({ userId }) => personaByUserId[userId].currentPersona.budget + ) + ), + budgetSpent: mean( + playersInTeam + .map(({ userId }) => personaByUserId[userId]) + .map( + (persona) => + persona.getPersonaAtStep(0).budget - + persona.currentPersona.budget + ) + ), + carbonFootprint: mean( + playersInTeam.map( + ({ userId }) => + personaByUserId[userId].currentPersona.carbonFootprint + ) + ), + carbonFootprintReduction: mean( + playersInTeam + .map(({ userId }) => personaByUserId[userId]) + .map( + (persona) => + (1 - + persona.currentPersona.carbonFootprint / + persona.getPersonaAtStep(0).carbonFootprint) * + 100 + ) + ), + stepToConsumption: buildStepToData( + "consumption", + game, + playersInTeam, + personaByUserId + ), + stepToProduction: buildStepToData( + "production", + game, + playersInTeam, + personaByUserId + ), + }; + }); + // TODO: check `personaByUserId` in deps doesn't trigger infinite renders. + }, [game, personaByUserId, players, teams]); const getTeamById = (id: number | undefined) => { return teamValues.find((t) => t.id === id); @@ -353,7 +340,7 @@ function useTeamValues(): { function buildStepToData( dataType: GameStepType, game: IGame, - team: ITeamWithPlayers, + players: Player[], personaByUserId: ReturnType ) { return Object.fromEntries( @@ -361,7 +348,7 @@ function buildStepToData( .filter((step) => isStepOfType(step, dataType)) .map((step: number) => [ step, - buildStepData(dataType, step, team, personaByUserId), + buildStepData(dataType, step, players, personaByUserId), ]) ); } @@ -369,14 +356,12 @@ function buildStepToData( function buildStepData( dataType: GameStepType, step: number, - team: ITeamWithPlayers, + players: Player[], personaByUserId: ReturnType ) { return mean( - team.players - .map( - ({ user }) => personaByUserId[user.id].getPersonaAtStep(step)[dataType] - ) + players + .map((p) => personaByUserId[p.userId].getPersonaAtStep(step)[dataType]) .map((data) => parseInt(sumAllValues(data as { type: string; value: number }[])) ) @@ -392,43 +377,30 @@ function useCurrentStep(): GameStep | null { return STEPS?.[game.step] || null; } -function usePlayerActions() { - const { game, player } = useLoadedPlay(); - - return { - playerActions: player.playerActions, - actionPointsAvailableAtCurrentStep: STEPS[game.step].availableActionPoints, - ...computePlayerActionsStats(game.step, player.playerActions), - }; -} - -function useTeamActions() { - const { game, player } = useLoadedPlay(); - - return { - teamActions: player.teamActions, - teamActionsAtCurrentStep: getTeamActionsAtCurrentStep( - game.step, - player.teamActions - ), - }; -} - function useGameSocket({ gameId, token, - setGameWithTeams, - setPlayer, - setProfile, + setConsumptionActions, + setGame, + setIsInitialised, + setPlayers, + setProductionActions, + setTeams, }: { gameId: number; token: string | null; - setGameWithTeams: React.Dispatch>; - setPlayer: React.Dispatch>; - setProfile: React.Dispatch>; + setConsumptionActions: React.Dispatch>; + setGame: React.Dispatch>; + setIsInitialised: React.Dispatch>; + setPlayers: React.Dispatch>; + setProductionActions: React.Dispatch< + React.SetStateAction + >; + setTeams: React.Dispatch>; }): { socket: Socket | null } { const [socket, setSocket] = useState(null); const navigate = useNavigate(); + useEffect(() => { const newSocket = io(WEB_SOCKET_URL, { withCredentials: true, @@ -437,61 +409,80 @@ function useGameSocket({ }, }); - newSocket.on("resetGameState", (state) => { - const { gameWithTeams } = state; - setGameWithTeams(gameWithTeams); + newSocket.on("game:init", (state) => { + setConsumptionActions(state.consumptionActions); + setGame(state.game); + setPlayers(state.players); + setProductionActions(state.productionActions); + setTeams(state.teams); + setIsInitialised(true); + }); + + newSocket.on("game:leave", () => { + navigate("/play/my-games"); + }); + + newSocket.on("game:update", ({ update }: { update: Partial }) => { + setGame((previous) => { + if (previous === null) { + return null; + } + if (previous.status !== "finished" && update.status === "finished") { + navigate("/play"); + } + + if ( + update.lastFinishedStep && + update.lastFinishedStep !== previous.lastFinishedStep + ) { + navigate(`/play/games/${previous.id}/persona/stats`); + } + return { ...previous, ...update }; + }); }); newSocket.on( - "gameUpdated", - ({ update }: { update: Partial }) => { - setGameWithTeams((previous) => { - if (previous === null) return null; - if (previous.status !== "finished" && update.status === "finished") { - navigate("/play"); - } - - if ( - update.lastFinishedStep && - update.lastFinishedStep !== previous.lastFinishedStep - ) { - navigate(`/play/games/${previous.id}/persona/stats`); - } - return { ...previous, ...update }; - }); + "player-actions:update", + ({ + updates, + }: { + updates: (Partial & Pick)[]; + }) => { + setPlayers((players) => updateCollection(players, "userId", updates)); } ); newSocket.on( - "playerActionsUpdated", - ({ playerActions = [] }: { playerActions: PlayerActions[] }) => { - setPlayer((previous) => ({ - ...previous, - playerActions: playerActions.sort(sortBy("actionId", "asc")), - })); + "player-actions:action-points-limit-exceeded", + ({ + updates, + }: { + updates: (Partial & Pick)[]; + }) => { + setPlayers((players) => updateCollection(players, "userId", updates)); } ); - newSocket.on("actionPointsLimitExceeded", () => { - setPlayer((previous) => ({ - ...previous, - actionPointsLimitExceeded: true, - })); - }); - newSocket.on( - "playerUpdated", - ({ update }: { update: Partial }) => { - setPlayer((previous) => ({ ...previous, ...update })); + "player:update", + ({ + updates, + }: { + updates: (Partial & Pick)[]; + }) => { + setPlayers((players) => updateCollection(players, "userId", updates)); } ); - newSocket.on("profileUpdated", ({ update }: { update: Partial }) => { - setProfile((previous: any) => ({ ...previous, ...update })); - }); + newSocket.on( + "team:update", + ({ updates }: { updates: (Partial & Pick)[] }) => { + setTeams((teams) => updateCollection(teams, "id", updates)); + } + ); newSocket.on("connect", () => { - newSocket.emit("joinGame", gameId); + newSocket.emit("game:join", gameId); }); setSocket(newSocket); @@ -499,7 +490,25 @@ function useGameSocket({ return () => { newSocket.disconnect(); }; - }, [gameId, token, setGameWithTeams, setPlayer, setProfile, navigate]); + }, [ + gameId, + token, + /** + * Don't include `navigate` in dependencies since it changes on every route change, + * which trigger a socket disconnection and reconnection. + * @see https://github.com/remix-run/react-router/issues/7634 + * + * /!\ Only use absolute navigation inside of this `useEffect`! + */ + // navigate, + setConsumptionActions, + setGame, + setIsInitialised, + setPlayers, + setProductionActions, + setTeams, + ]); + return { socket }; } @@ -512,72 +521,61 @@ function usePersonaByUserId( userIds: number[] ): Record>; function usePersonaByUserId(userIds: number | number[]) { - const { game: gameWithTeams } = useLoadedPlay(); + const { consumptionActionById, game, players, productionActionById, teams } = + useLoadedPlay(); if (typeof userIds === "number") { - const { team, player } = getUserTeamAndPlayer(gameWithTeams, userIds); - const personalization = player?.profile.personalization; + const { team, player } = getUserTeamAndPlayer(userIds, { players, teams }); + const personalization = player?.profile?.personalization!; const teamActions = team?.actions || []; const initialPersona = buildInitialPersona( - player?.profile.personalization, - teamActions + personalization, + teamActions, + productionActionById ); return buildPersona( - gameWithTeams, + game, personalization, initialPersona, player?.actions || [], - teamActions + consumptionActionById, + teamActions, + productionActionById ); } return Object.fromEntries( userIds.map((userId) => { - const { team, player } = getUserTeamAndPlayer(gameWithTeams, userId); - const personalization = player?.profile.personalization; + const { team, player } = getUserTeamAndPlayer(userId, { players, teams }); + const personalization = player?.profile?.personalization!; const teamActions = team?.actions || []; - const initialPersona = buildInitialPersona(personalization, teamActions); + const initialPersona = buildInitialPersona( + personalization, + teamActions, + productionActionById + ); return [ userId, buildPersona( - gameWithTeams, + game, personalization, initialPersona, player?.actions || [], - teamActions + consumptionActionById, + teamActions, + productionActionById ), ]; }) ); } -function getUserTeamAndPlayer(game: IEnrichedGame, userId: number) { - const team = game.teams.find((team: ITeamWithPlayers) => - team.players.find((player: Player) => player.userId === userId) - ); - - const player = team?.players.find( - (player: Player) => player.userId === userId - ); +function getUserTeamAndPlayer( + userId: number, + { players, teams }: { players: Player[]; teams: ITeam[] } +) { + const player = players.find((p) => p.userId === userId) || null; + const team = teams.find((t) => t.id === player?.teamId) || null; return { team, player }; } - -function usePersona() { - const { game, player } = useLoadedPlay(); - const { user } = useAuth(); - const profile = user && getUserTeamAndPlayer(game, user.id)?.player?.profile; - const { playerActions } = usePlayerActions(); - const initialPersona = buildInitialPersona( - profile.personalization, - player.teamActions - ); - - return buildPersona( - game, - profile.personalization, - initialPersona, - playerActions, - player.teamActions - ); -} diff --git a/packages/client/src/modules/play/context/playContext.utils.ts b/packages/client/src/modules/play/context/playContext.utils.ts new file mode 100644 index 00000000..d2713c0f --- /dev/null +++ b/packages/client/src/modules/play/context/playContext.utils.ts @@ -0,0 +1,47 @@ +import mergeDeep from "deepmerge"; +import { isObject } from "lodash"; + +export { updateCollection }; + +function updateCollection, U extends keyof T>( + collection: T[], + idField: U, + updates: (Partial & Pick)[] +) { + const idFieldToItemIdx = Object.fromEntries( + collection.map((item, idx) => [item[idField], idx]) + ); + + updates.forEach((update) => { + const itemIdx = idFieldToItemIdx[update[idField]]; + if (itemIdx == null) { + return; + } + + const item = collection[itemIdx]; + collection.splice(itemIdx, 1, { + ...(mergeDeep(item, update, { + arrayMerge: mergeArrays, + }) as T), + }); + }); + + return [...collection]; +} + +function mergeArrays>( + targetArray: T[] = [], + sourceArray: T[] = [] +) { + if (doesArrayHoldsItemsWithIdField(targetArray, "id")) { + return updateCollection(targetArray, "id", sourceArray as any); + } + return sourceArray; +} + +function doesArrayHoldsItemsWithIdField( + arr: Record[] | any[], + idField: string +): arr is Record[] { + return !!arr.length && isObject(arr[0]) && (arr[0] as any)?.[idField] != null; +} diff --git a/packages/client/src/modules/play/context/usePlayStore.ts b/packages/client/src/modules/play/context/usePlayStore.ts new file mode 100644 index 00000000..e45783f0 --- /dev/null +++ b/packages/client/src/modules/play/context/usePlayStore.ts @@ -0,0 +1,65 @@ +import { useMemo, useState } from "react"; +import { + Action, + IEnrichedGame, + IGame, + ITeam, + Player, + ProductionAction, +} from "../../../utils/types"; +import { LARGE_GAME_TEAMS, STEPS } from "../constants"; +import { indexArrayBy } from "../../../lib/array"; + +export { usePlayStore }; + +function usePlayStore() { + const [isInitialised, setIsInitialised] = useState(false); + const [game, setGame] = useState(null); + const [consumptionActions, setConsumptionActions] = useState([]); + const [productionActions, setProductionActions] = useState< + ProductionAction[] + >([]); + const [players, setPlayers] = useState([]); + const [teams, setTeams] = useState([]); + + const enrichedGame: IEnrichedGame | null = useMemo(() => { + if (!game) { + return null; + } + + return { + ...(game || {}), + isLarge: teams.length > LARGE_GAME_TEAMS || false, + isSynthesisStep: game && game.step === STEPS.length - 1, + isGameFinished: game?.status === "finished", + isStepFinished: game?.step === game?.lastFinishedStep, + }; + }, [game, teams]); + + const consumptionActionById = useMemo( + () => indexArrayBy(consumptionActions, "id"), + [consumptionActions] + ); + + const productionActionById = useMemo( + () => indexArrayBy(productionActions, "id"), + [productionActions] + ); + + return { + consumptionActions, + consumptionActionById, + game: enrichedGame, + isInitialised, + players, + productionActions, + productionActionById, + teams, + setConsumptionActions, + setGame, + setIsInitialised, + setPlayers, + setProductionActions, + setTeams, + }; +} diff --git a/packages/client/src/modules/play/gameEngines/consumptionPointsEngine.spec.ts b/packages/client/src/modules/play/gameEngines/consumptionPointsEngine.spec.ts index 6b584a72..e3938985 100644 --- a/packages/client/src/modules/play/gameEngines/consumptionPointsEngine.spec.ts +++ b/packages/client/src/modules/play/gameEngines/consumptionPointsEngine.spec.ts @@ -1,4 +1,4 @@ -import { ActionNames, PlayerActions } from "../../../utils/types"; +import { Action, ActionNames, PlayerActions } from "../../../utils/types"; import { PersoForm } from "../Personalization/models/form"; import { availableActions } from "../playerActions/constants/actions"; import { computeConsumptionPoints } from "./consumptionPointsEngine"; @@ -14,6 +14,21 @@ interface Test { }; } +// TODO: rework `computeConsumptionPoints` to pass a custom configuration. +// Here it is a quick fix to handle the new `playContext` store structure. +const consumptionActionIdByName = Object.fromEntries( + Object.values(availableActions).map((actionName, idx) => [ + actionName, + idx + 1, + ]) +); +const consumptionActionById = Object.fromEntries( + Object.values(availableActions).map((actionName, idx) => [ + idx + 1, + { name: actionName } as unknown as Action, + ]) +); + describe("consumptionPointsEngine ", () => { describe("personalization", () => { const TESTS: Test[] = [ @@ -349,6 +364,7 @@ const runTests = ( const consumptionPoints = computeConsumptionPoints( test.setup.personalization || buildPersonalization(), test.setup.performedPlayerAction || buildPlayerActions(), + consumptionActionById, STEP ); @@ -381,9 +397,7 @@ function buildPlayerActions( return performedActionNames.map( (actionName) => ({ - action: { - name: actionName, - }, + actionId: consumptionActionIdByName[actionName], isPerformed: true, } as PlayerActions) ); diff --git a/packages/client/src/modules/play/gameEngines/consumptionPointsEngine.ts b/packages/client/src/modules/play/gameEngines/consumptionPointsEngine.ts index 0991a8c2..22cf16f3 100644 --- a/packages/client/src/modules/play/gameEngines/consumptionPointsEngine.ts +++ b/packages/client/src/modules/play/gameEngines/consumptionPointsEngine.ts @@ -1,5 +1,5 @@ import { sumReducer } from "../../../lib/array"; -import { ActionNames, PlayerActions } from "../../../utils/types"; +import { Action, ActionNames, PlayerActions } from "../../../utils/types"; import { PersoForm } from "../Personalization/models/form"; import { availableActions } from "../playerActions/constants/actions"; @@ -279,13 +279,17 @@ const PLAYER_ACTIONS_TO_SCORE_CONFIG: Partial< function computeConsumptionPoints( personalization: PersoForm, performedPlayerAction: PlayerActions[], + consumptionActionById: Record, step: number ) { return evaluateScoreConfigs( getScoreConfigs(), step, personalization, - getIsPlayerActionPerformedChecker(performedPlayerAction) + getIsPlayerActionPerformedChecker( + performedPlayerAction, + consumptionActionById + ) ); } @@ -297,11 +301,12 @@ function getScoreConfigs() { } function getIsPlayerActionPerformedChecker( - performedPlayerAction: PlayerActions[] + performedPlayerAction: PlayerActions[], + consumptionActionById: Record ) { const playerActionsToIsPerformed = Object.fromEntries( performedPlayerAction.map((performedAction) => [ - performedAction.action.name, + consumptionActionById[performedAction.actionId].name, performedAction.isPerformed, ]) ); diff --git a/packages/client/src/modules/play/gameEngines/resourcesEngine.ts b/packages/client/src/modules/play/gameEngines/resourcesEngine.ts index b04913e3..1298f474 100644 --- a/packages/client/src/modules/play/gameEngines/resourcesEngine.ts +++ b/packages/client/src/modules/play/gameEngines/resourcesEngine.ts @@ -1,6 +1,7 @@ import { MaterialsType, MetalsType, + ProductionAction, ProductionTypes, TeamAction, } from "../../../utils/types"; @@ -17,9 +18,38 @@ export interface PhysicalResourceNeedDatum { value: number; } +const computeMaterials = ( + production: ProductionDatum[], + performedTeamActions: TeamAction[], + productionActionById: Record +): PhysicalResourceNeedDatum[] => { + return computePhysicalResources( + production, + performedTeamActions, + productionActionById, + Object.values(materials), + "materials" + ); +}; + +const computeMetals = ( + production: ProductionDatum[], + performedTeamActions: TeamAction[], + productionActionById: Record +): PhysicalResourceNeedDatum[] => { + return computePhysicalResources( + production, + performedTeamActions, + productionActionById, + Object.values(metals), + "metals" + ); +}; + const computePhysicalResources = ( production: ProductionDatum[], performedTeamActions: TeamAction[], + productionActionById: Record, resourcesTypes: MaterialsType[] | MetalsType[], type: "materials" | "metals" ): PhysicalResourceNeedDatum[] => { @@ -28,10 +58,13 @@ const computePhysicalResources = ( const prodConstants = Object.values(productionConstants).find( (prodConfig) => prodConfig.name === prod.name ); - const powerNeed = - performedTeamActions.find( - (teamAction: TeamAction) => teamAction.action.name === prod.name - )?.action.powerNeededKWh || 0; + const teamAction = performedTeamActions.find( + (teamAction: TeamAction) => + productionActionById[teamAction.actionId]?.name === prod.name + ); + const productionAction = + productionActionById[teamAction?.actionId || -1] || {}; + const powerNeed = productionAction.powerNeededKWh || 0; return resourcesTypes.map((resourceType: MaterialsType | MetalsType) => ({ type: resourceType, prodType: prodConstants?.type || "", @@ -61,27 +94,3 @@ const computePhysicalResources = ( })); }); }; - -const computeMaterials = ( - production: ProductionDatum[], - performedTeamActions: TeamAction[] -): PhysicalResourceNeedDatum[] => { - return computePhysicalResources( - production, - performedTeamActions, - Object.values(materials), - "materials" - ); -}; - -const computeMetals = ( - production: ProductionDatum[], - performedTeamActions: TeamAction[] -): PhysicalResourceNeedDatum[] => { - return computePhysicalResources( - production, - performedTeamActions, - Object.values(metals), - "metals" - ); -}; diff --git a/packages/client/src/modules/play/playerActions/PlayerActionsContent.tsx b/packages/client/src/modules/play/playerActions/PlayerActionsContent.tsx index 449419d1..2542daad 100644 --- a/packages/client/src/modules/play/playerActions/PlayerActionsContent.tsx +++ b/packages/client/src/modules/play/playerActions/PlayerActionsContent.tsx @@ -11,29 +11,34 @@ import { Typography } from "../../common/components/Typography"; import { useState } from "react"; import { PlayerActions } from "../../../utils/types"; -import { usePlay, usePlayerActions } from "../context/playContext"; +import { usePlay } from "../context/playContext"; import { ActionHelpDialog } from "./HelpDialogs"; import { Dialog } from "../../common/components/Dialog"; import { Icon } from "../../common/components/Icon"; +import { useCurrentPlayer } from "../context/hooks/player"; export { PlayerActionsContent }; function PlayerActionsContent() { - const { updatePlayerActions, player, setActionPointsLimitExceeded } = - usePlay(); const { + consumptionActionById, + updatePlayerActions, + setActionPointsLimitExceeded, + } = usePlay(); + const { + player, actionPointsAvailableAtCurrentStep, playerActionsAtCurrentStep: playerActions, - } = usePlayerActions(); + } = useCurrentPlayer(); const handleActionChange = (playerActionId: number, isPerformed: boolean) => { setActionPointsLimitExceeded(false); - updatePlayerActions( - playerActions.map((pa) => ({ - id: pa.id, - isPerformed: pa.id === playerActionId ? isPerformed : pa.isPerformed, - })) - ); + updatePlayerActions([ + { + id: playerActionId, + isPerformed, + }, + ]); }; return ( @@ -46,13 +51,15 @@ function PlayerActionsContent() { onPlayerActionChanged={(isPerformed) => handleActionChange(playerAction.id, isPerformed) } - helpCardLink={playerAction.action.helpCardLink} + helpCardLink={ + consumptionActionById[playerAction.actionId].helpCardLink + } /> ); })} setActionPointsLimitExceeded(false)} actions={ <> @@ -97,6 +104,7 @@ function ActionLayout({ onPlayerActionChanged: (isPerformed: boolean) => void; helpCardLink: string; }) { + const { consumptionActionById } = usePlay(); const [openHelp, setOpenHelp] = useState(false); const handleClickOpenHelp = () => setOpenHelp(true); @@ -134,7 +142,7 @@ function ActionLayout({ message={helpMessage} helpCardLink={helpCardLink} /> - {playerAction.action.description} + {consumptionActionById[playerAction.actionId].description} @@ -143,12 +151,14 @@ function ActionLayout({ name="action-points-cost" readOnly max={3} - value={playerAction.action.actionPointCost} + value={ + consumptionActionById[playerAction.actionId].actionPointCost + } /> - {`${playerAction.action.financialCost}€/j`} + {`${consumptionActionById[playerAction.actionId].financialCost}€/j`} diff --git a/packages/client/src/modules/play/playerActions/PlayerActionsHeader.tsx b/packages/client/src/modules/play/playerActions/PlayerActionsHeader.tsx index 0224d36a..93202fb0 100644 --- a/packages/client/src/modules/play/playerActions/PlayerActionsHeader.tsx +++ b/packages/client/src/modules/play/playerActions/PlayerActionsHeader.tsx @@ -2,11 +2,7 @@ import { Box, IconButton } from "@mui/material"; import HelpIcon from "@mui/icons-material/Help"; import { useState } from "react"; import { Typography } from "../../common/components/Typography"; -import { - useCurrentStep, - usePersona, - usePlayerActions, -} from "../context/playContext"; +import { useCurrentStep } from "../context/playContext"; import { StepHelpDialog } from "./HelpDialogs"; import { Icon } from "../../common/components/Icon"; import { formatBudget, formatCarbonFootprint } from "../../../lib/formatter"; @@ -18,6 +14,7 @@ import { MediaQuery, } from "./PlayerActionsHeader.styles"; import { t } from "../../translations"; +import { usePersona, useCurrentPlayer } from "../context/hooks/player"; export { PlayerActionsHeader }; @@ -90,7 +87,7 @@ function PlayerActionsHeader() { } function ActionPoints() { - const { actionPointsUsedAtCurrentStep } = usePlayerActions(); + const { actionPointsUsedAtCurrentStep } = useCurrentPlayer(); const currentStep = useCurrentStep(); const availableActionPoints = currentStep?.availableActionPoints ?? 0; diff --git a/packages/client/src/modules/play/playerActions/SynthesisContent.tsx b/packages/client/src/modules/play/playerActions/SynthesisContent.tsx index 37da3857..4d5cbd2b 100644 --- a/packages/client/src/modules/play/playerActions/SynthesisContent.tsx +++ b/packages/client/src/modules/play/playerActions/SynthesisContent.tsx @@ -13,9 +13,9 @@ export { SynthesisScenarioName }; function SynthesisScenarioName() { const { t } = useTranslation(); - const { isGameFinished } = usePlay(); + const { game } = usePlay(); - const isTeamEditable = !isGameFinished; + const isTeamEditable = !game.isGameFinished; return ( diff --git a/packages/client/src/modules/play/playerActions/TeamActionsContent.tsx b/packages/client/src/modules/play/playerActions/TeamActionsContent.tsx index 4ad05796..0132c377 100644 --- a/packages/client/src/modules/play/playerActions/TeamActionsContent.tsx +++ b/packages/client/src/modules/play/playerActions/TeamActionsContent.tsx @@ -4,8 +4,8 @@ import { Box } from "@mui/material"; import { useState } from "react"; import { Typography } from "../../common/components/Typography"; -import { TeamAction } from "../../../utils/types"; -import { usePlay, useTeamActions } from "../context/playContext"; +import { ProductionAction, TeamAction } from "../../../utils/types"; +import { usePlay } from "../context/playContext"; import { Accordion } from "../../common/components/Accordion"; import { t } from "../../translations"; import { Icon } from "../../common/components/Icon"; @@ -15,12 +15,14 @@ import { Dialog } from "../../common/components/Dialog"; import { formatBudget, formatProductionGw } from "../../../lib/formatter"; import { useTranslation } from "../../translations/useTranslation"; import { ENERGY_SHIFT_TARGET_YEAR } from "../../common/constants"; +import { useCurrentPlayer } from "../context/hooks/player"; export { TeamActionsContent }; function TeamActionsContent({ style }: { style?: React.CSSProperties }) { - const { teamActionsAtCurrentStep } = useTeamActions(); + const { teamActionsAtCurrentStep } = useCurrentPlayer(); const { t } = useTranslation(); + const { productionActionById } = usePlay(); const [openHelpDialog, setOpenHelpDialog] = useState(false); const [helpCardLink, setHelpCardLink] = useState(""); @@ -33,8 +35,11 @@ function TeamActionsContent({ style }: { style?: React.CSSProperties }) { .map((teamAction) => createTeamActionOption({ teamAction, + productionActionById, onOpenHelpCard: () => { - setHelpCardLink(teamAction.action.helpCardLink); + setHelpCardLink( + productionActionById[teamAction.actionId].helpCardLink + ); setOpenHelpDialog(true); }, }) @@ -82,9 +87,11 @@ function TeamActionsContent({ style }: { style?: React.CSSProperties }) { function createTeamActionOption({ teamAction, + productionActionById, onOpenHelpCard, }: { teamAction: TeamAction; + productionActionById: Record; onOpenHelpCard: () => void; }) { if (!teamAction) { @@ -92,10 +99,11 @@ function createTeamActionOption({ } return { - key: teamAction.action.name, + key: productionActionById[teamAction.actionId].name, header: ( ), @@ -105,9 +113,11 @@ function createTeamActionOption({ function TeamActionOptionHeader({ teamAction, + productionActionById, onOpenHelpCard, }: { teamAction: TeamAction; + productionActionById: Record; onOpenHelpCard: () => void; }) { const handleOnOpenHelpCard = (e: React.MouseEvent) => { @@ -119,14 +129,18 @@ function TeamActionOptionHeader({ - {t(`production.energy.${teamAction.action.name}.accordion.title`)} + {t( + `production.energy.${ + productionActionById[teamAction.actionId].name + }.accordion.title` + )} ); } function TeamActionOptionContent({ teamAction }: { teamAction: TeamAction }) { - const { updateTeam } = usePlay(); + const { productionActionById, updateTeam } = usePlay(); const [value, setValue] = useState(teamAction.value); @@ -145,18 +159,26 @@ function TeamActionOptionContent({ teamAction }: { teamAction: TeamAction }) { setValue(value); }; - const actionUnit = teamAction.action.unit === "percentage" ? "%" : " m²"; + const actionUnit = + productionActionById[teamAction.actionId].unit === "percentage" + ? "%" + : " m²"; const labelFormatter = (value: number) => `${value}${actionUnit}`; const localTeamAction = { ...teamAction, value }; - const localStats = computeTeamActionStats(localTeamAction); + const localStats = computeTeamActionStats( + localTeamAction, + productionActionById + ); return ( Puissance installée en France en 2022 :{" "} - {formatProductionGw(teamAction.action.currentYearPowerNeedGw)} GW soit{" "} - {teamAction.action.defaultTeamValue} + {formatProductionGw( + productionActionById[teamAction.actionId].currentYearPowerNeedGw + )}{" "} + GW soit {productionActionById[teamAction.actionId].defaultTeamValue} {actionUnit} @@ -169,8 +191,8 @@ function TeamActionOptionContent({ teamAction }: { teamAction: TeamAction }) { > {t( - `production.energy.${teamAction.action.name}.accordion.label-slider` + `production.energy.${ + productionActionById[teamAction.actionId].name + }.accordion.label-slider` )} @@ -222,24 +248,38 @@ function TeamActionOptionContent({ teamAction }: { teamAction: TeamAction }) { function TeamActionCredibility({ teamAction }: { teamAction: TeamAction }) { const { ready, t } = useTranslation(); + const { productionActionById } = usePlay(); - const { isCredible } = computeTeamActionStats(teamAction); + const { isCredible } = computeTeamActionStats( + teamAction, + productionActionById + ); const credibilityI18n = useMemo(() => { if (!ready) { return []; } if (isCredible) { - return t(`production.energy.${teamAction.action.name}.value.credible`, { + return t( + `production.energy.${ + productionActionById[teamAction.actionId].name + }.value.credible`, + { + year: ENERGY_SHIFT_TARGET_YEAR, + returnObjects: true, + } + ); + } + return t( + `production.energy.${ + productionActionById[teamAction.actionId].name + }.value.not-credible`, + { year: ENERGY_SHIFT_TARGET_YEAR, returnObjects: true, - }); - } - return t(`production.energy.${teamAction.action.name}.value.not-credible`, { - year: ENERGY_SHIFT_TARGET_YEAR, - returnObjects: true, - }); - }, [isCredible, ready, teamAction.action.name, t]); + } + ); + }, [isCredible, ready, productionActionById, t, teamAction]); return ( (false); const handleClickOpen = () => setOpen(true); diff --git a/packages/client/src/modules/play/utils/persona.ts b/packages/client/src/modules/play/utils/persona.ts index 39386a6b..5bf51659 100644 --- a/packages/client/src/modules/play/utils/persona.ts +++ b/packages/client/src/modules/play/utils/persona.ts @@ -1,8 +1,10 @@ import range from "lodash/range"; import sum from "lodash/sum"; import { - IGameWithTeams, + Action, + IGame, PlayerActions, + ProductionAction, TeamAction, } from "../../../utils/types"; import { ConsumptionDatum } from "../../persona/consumption"; @@ -26,17 +28,21 @@ import { computeNewProductionData, computeTeamActionStats } from "./production"; export { buildPersona }; function buildPersona( - game: IGameWithTeams, + game: IGame, personalization: PersoForm, basePersona: Persona, playerActions: PlayerActions[], - teamActions: TeamAction[] + consumptionActionById: Record, + teamActions: TeamAction[], + productionActionById: Record ) { const personaBySteps = getResultsByStep( personalization, basePersona, playerActions, - teamActions + consumptionActionById, + teamActions, + productionActionById ); const getPersonaAtStep = (step: number) => { @@ -63,7 +69,9 @@ function getResultsByStep( personalization: PersoForm, basePersona: Persona, playerActions: PlayerActions[], - teamActions: TeamAction[] + consumptionActionById: Record, + teamActions: TeamAction[], + productionActionById: Record ): Record { return Object.fromEntries( range(0, MAX_NUMBER_STEPS + 1).map((step) => [ @@ -73,7 +81,9 @@ function getResultsByStep( basePersona, step, playerActions, - teamActions + consumptionActionById, + teamActions, + productionActionById ), ]) ); @@ -84,7 +94,9 @@ function computeResultsByStep( basePersona: Persona, step: number, playerActions: PlayerActions[] = [], - teamActions: TeamAction[] = [] + consumptionActionById: Record, + teamActions: TeamAction[] = [], + productionActionById: Record ): Persona { if (step === 0) { return basePersona; @@ -92,20 +104,24 @@ function computeResultsByStep( const performedPlayerActions = playerActions.filter( (playerAction: PlayerActions) => - playerAction.action.step <= step && playerAction.isPerformed === true + consumptionActionById[playerAction.actionId].step <= step && + playerAction.isPerformed === true ); const performedTeamActions = teamActions.filter( - (teamAction: TeamAction) => teamAction.action.step <= step + (teamAction: TeamAction) => + productionActionById[teamAction.actionId].step <= step ); const playerActionsCost = sum( performedPlayerActions.map( - (playerAction: PlayerActions) => playerAction.action.financialCost + (playerAction: PlayerActions) => + consumptionActionById[playerAction.actionId].financialCost ) ); const teamActionsCost = sum( performedTeamActions.map( - (teamAction: TeamAction) => computeTeamActionStats(teamAction).cost + (teamAction: TeamAction) => + computeTeamActionStats(teamAction, productionActionById).cost ) ); const costPerDay = playerActionsCost + teamActionsCost; @@ -113,7 +129,8 @@ function computeResultsByStep( const teamPoints = sum( performedTeamActions.map( - (teamAction: TeamAction) => computeTeamActionStats(teamAction).points + (teamAction: TeamAction) => + computeTeamActionStats(teamAction, productionActionById).points ) ); @@ -124,13 +141,19 @@ function computeResultsByStep( ? computeCO2Points(basePersona.carbonFootprint) : 0; const points = - computeConsumptionPoints(personalization, performedPlayerActions, step) + + computeConsumptionPoints( + personalization, + performedPlayerActions, + consumptionActionById, + step + ) + teamPoints + budgetPoints + co2Points; const performedActionsNames = performedPlayerActions.map( - (playerAction: PlayerActions) => playerAction.action.name + (playerAction: PlayerActions) => + consumptionActionById[playerAction.actionId].name ); const newConsumption = computeNewConsumptionData( @@ -140,15 +163,25 @@ function computeResultsByStep( const newProduction = computeNewProductionData( performedTeamActions, + productionActionById, basePersona ); - const newMaterials = computeMaterials(newProduction, teamActions); - const newMetals = computeMetals(newProduction, teamActions); + const newMaterials = computeMaterials( + newProduction, + teamActions, + productionActionById + ); + const newMetals = computeMetals( + newProduction, + teamActions, + productionActionById + ); const { actionPointsUsedAtCurrentStep } = computePlayerActionsStats( step, - playerActions + playerActions, + consumptionActionById ); const carbonProductionElectricMix = diff --git a/packages/client/src/modules/play/utils/playerActions.ts b/packages/client/src/modules/play/utils/playerActions.ts index 17f1eb98..97a24691 100644 --- a/packages/client/src/modules/play/utils/playerActions.ts +++ b/packages/client/src/modules/play/utils/playerActions.ts @@ -1,17 +1,21 @@ -import { PlayerActions } from "../../../utils/types"; +import { Action, PlayerActions } from "../../../utils/types"; export { computePlayerActionsStats }; function computePlayerActionsStats( currentStep: number, - playerActions: PlayerActions[] + playerActions: PlayerActions[], + consumptionActionById: Record ) { const playerActionsAtCurrentStep = playerActions.filter( - (pa) => pa.action.step === currentStep + (pa) => consumptionActionById[pa.actionId].step === currentStep ); const actionPointsUsedAtCurrentStep = playerActionsAtCurrentStep.reduce( - (sum, pa) => (pa.isPerformed ? sum + pa.action.actionPointCost : sum), + (sum, pa) => + pa.isPerformed + ? sum + consumptionActionById[pa.actionId].actionPointCost + : sum, 0 ); diff --git a/packages/client/src/modules/play/utils/production.ts b/packages/client/src/modules/play/utils/production.ts index 6768cad9..cd9820e1 100644 --- a/packages/client/src/modules/play/utils/production.ts +++ b/packages/client/src/modules/play/utils/production.ts @@ -1,21 +1,25 @@ -import { TeamAction } from "../../../utils/types"; +import { ProductionAction, TeamAction } from "../../../utils/types"; import { Persona } from "../../persona/persona"; import { ProductionDatum } from "../../persona/production"; export { computeNewProductionData, computeTeamActionStats }; -function computeTeamActionStats(teamAction: TeamAction) { +function computeTeamActionStats( + teamAction: TeamAction, + productionActionById: Record +) { + const productionAction = productionActionById[teamAction.actionId]; // TODO: see with Gregory for renaming (should be `power` instead)? - const productionKwh = computeProduction(teamAction); + const productionKwh = computeProduction(teamAction, productionActionById); // TODO: see with Gregory for renaming (should be `production` instead)? - const powerNeedGw = productionKwh * teamAction.action.powerNeededKWh; - const cost = productionKwh * teamAction.action.lcoe; + const powerNeedGw = productionKwh * productionAction.powerNeededKWh; + const cost = productionKwh * productionAction.lcoe; const points = - teamAction.action.pointsIntervals?.find( + productionAction.pointsIntervals?.find( ({ min, max }) => min < teamAction.value && teamAction.value <= max )?.points || 0; - const isCredible = teamAction.value <= teamAction.action.credibilityThreshold; + const isCredible = teamAction.value <= productionAction.credibilityThreshold; return { productionKwh, @@ -26,29 +30,35 @@ function computeTeamActionStats(teamAction: TeamAction) { }; } -function computeProduction(teamAction: TeamAction): number { - if (teamAction.action.unit === "area") { - return teamAction.value * teamAction.action.areaEnergy; +function computeProduction( + teamAction: TeamAction, + productionActionById: Record +): number { + const productionAction = productionActionById[teamAction.actionId]; + if (productionAction.unit === "area") { + return teamAction.value * productionAction.areaEnergy; } - if (teamAction.action.unit === "percentage") { - return (teamAction.value / 100) * teamAction.action.totalEnergy; + if (productionAction.unit === "percentage") { + return (teamAction.value / 100) * productionAction.totalEnergy; } throw new Error( - `Energy unit ${(teamAction.action as any).unit} not supported` + `Energy unit ${(productionAction as any).unit} not supported` ); } function computeNewProductionData( performedTeamActions: TeamAction[], + productionActionById: Record, persona: Persona ) { const productionNameToNewProduction = Object.fromEntries( performedTeamActions .map((teamAction) => ({ - name: teamAction.action.name, - type: teamAction.action.type, - value: computeTeamActionStats(teamAction).productionKwh, + name: productionActionById[teamAction.actionId].name, + type: productionActionById[teamAction.actionId].type, + value: computeTeamActionStats(teamAction, productionActionById) + .productionKwh, })) .map((production) => [production.name, production]) ); diff --git a/packages/client/src/modules/play/utils/teamActions.ts b/packages/client/src/modules/play/utils/teamActions.ts index f2362c1c..6c9f8cf3 100644 --- a/packages/client/src/modules/play/utils/teamActions.ts +++ b/packages/client/src/modules/play/utils/teamActions.ts @@ -1,19 +1,22 @@ -import { TeamAction } from "../../../utils/types"; +import { ProductionAction, TeamAction } from "../../../utils/types"; export { getTeamActionsAtCurrentStep }; function getTeamActionsAtCurrentStep( currentStep: number, - teamActions: TeamAction[] + teamActions: TeamAction[], + productionActionById: Record ) { const teamActionsAtCurrentStep = teamActions .filter( (teamAction) => - teamAction.action.step === currentStep && teamAction.action.isPlayable + productionActionById[teamAction.actionId].step === currentStep && + productionActionById[teamAction.actionId].isPlayable ) .sort( (teamActionA, teamActionB) => - teamActionA.action.order - teamActionB.action.order + productionActionById[teamActionA.actionId].order - + productionActionById[teamActionB.actionId].order ); return teamActionsAtCurrentStep; diff --git a/packages/client/src/utils/types.ts b/packages/client/src/utils/types.ts index 34028b9b..d89255fe 100644 --- a/packages/client/src/utils/types.ts +++ b/packages/client/src/utils/types.ts @@ -13,12 +13,16 @@ export type { IUser, Action, ActionNames, + Personalization, + PersonalizationName, Player, PlayerActions, ProductionAction, ProductionActionNames, ProductionActionType, ProductionActionUnit, + Profile, + ProfileStatus, TeamAction, MaterialsType, MetalsType, @@ -32,13 +36,16 @@ type WithTeams = T & { teams: ITeamWithPlayers[] }; type IGame = Game; type IGameWithTeacher = WithTeacher; type IGameWithTeams = WithTeams; -type IEnrichedGame = IGameWithTeams & { +type IEnrichedGame = IGame & { isLarge?: boolean; isSynthesisStep?: boolean; + isGameFinished: boolean; + isStepFinished: boolean; }; interface ITeam { id: number; + gameId: number; name: string; scenarioName: string; actions: TeamAction[]; @@ -53,12 +60,89 @@ interface Player { gameId: number; teamId: number; userId: number; - user: IUser; - profile: any; + user: { + id: number; + country: string; + email: string; + firstName: string; + lastName: string; + roleId: number; + }; + profile: Profile; actions: PlayerActions[]; hasFinishedStep: boolean; + actionPointsLimitExceeded?: boolean; +} + +interface Profile { + id: number; + userId: number; + personalizationId: number; + personalization: Personalization; + status: ProfileStatus; + lastStatusUpdate: string; } +type ProfileStatus = "draft" | "pendingValidation" | "validated"; + +interface Personalization { + id: number; + origin: string; + personalizationName: PersonalizationName; + numberAdults: number; + numberKids: number; + car: boolean; + carEnergy: string; + carConsumption: number; + carDistanceAlone: number; + carDistanceHoushold: number; + carAge: string; + carDistanceCarsharing: number; + planeDistance: number; + trainDistance: number; + houseType: string; + houseSurface: number; + heatingEnergy: string; + heatingConsumption: number; + heatingInvoice: number; + heatPump: boolean; + heatingTemperature: boolean; + airConditionning: boolean; + aCRoomNb: number; + aCDaysNb: number; + showerBath: string; + showerNumber: number; + showerTime: string; + cookingKettle: boolean; + cookingPlateTime: number; + cookingOvenTime: number; + cleaningWashingTime: number; + cleaningDryerTime: number; + cleaningDishwasherTime: number; + refrigeratorNumber: number; + freezerNumber: number; + lightingSystem: string; + eatingVegan: boolean; + eatingVegetables: boolean; + eatingDairies: boolean; + eatingEggs: boolean; + eatingMeat: boolean; + eatingTinDrink: number; + eatingZeroWaste: boolean; + eatingLocal: boolean; + eatingCatNumber: number; + eatingDogNumber: number; + eatingHorse: boolean; + numericEquipment: boolean; + numericWebTimeDay: boolean; + numericVideoTimeDay: boolean; + clothingQuantity: boolean; + createdAt: string; + updatedAt: string; +} + +type PersonalizationName = "form" | "oilgre"; + interface Action { id: number; name: ActionNames; @@ -74,10 +158,8 @@ type ActionNames = typeof availableActions[keyof typeof availableActions]; interface PlayerActions { id: number; - player: Player; userId: number; gameId: number; - action: Action; actionId: number; isPerformed: boolean; } @@ -131,7 +213,6 @@ interface TeamAction { id: number; teamId: number; actionId: number; - action: ProductionAction; /** * Value chosen by the team. * If action unit is `percentage`, value is in [0,100]. diff --git a/packages/server/.env.example b/packages/server/.env.example index 18cfe212..05a979dd 100644 --- a/packages/server/.env.example +++ b/packages/server/.env.example @@ -5,7 +5,7 @@ PORT=8080 # connection to DB : postgresql://:@:/ DATABASE_URL=postgresql://postgres:postgres@localhost:5432/ogre-development -REDIS_URI=redis://localhost:6379 +REDIS_URL=redis://localhost:6379 SENDGRID_API_KEY=... SECRET_KEY="secret-key" \ No newline at end of file diff --git a/packages/server/package.json b/packages/server/package.json index 989af2a7..8ee62e98 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -37,7 +37,7 @@ "db:migrate": "npx prisma migrate dev", "db:seed": "npx ts-node ./src/database/seed.ts", "db:studio": "npx prisma studio", - "dev": "NODE_ENV=development ts-node-dev --respawn ./src/index.ts", + "dev": "NODE_ENV=development ts-node-dev --respawn --watch src ./src/index.ts", "env:default": "cp .env.example .env", "check-type": "tsc --noEmit", "lint": "eslint .", diff --git a/packages/server/src/lib/array.ts b/packages/server/src/lib/array.ts index 823394a5..ef6c9e1f 100644 --- a/packages/server/src/lib/array.ts +++ b/packages/server/src/lib/array.ts @@ -1,4 +1,20 @@ -export const getAddOrRemoveCount = (arr: T[], refLength: number) => ({ +export { batchItems, getAddOrRemoveCount }; + +const batchItems = async ( + arr: T[], + batchSize: number, + processor: (items: T[]) => Promise +) => { + let idx = 0; + while (idx < arr.length) { + const items = arr.slice(idx, idx + batchSize); + // eslint-disable-next-line no-await-in-loop + await processor(items); + idx += batchSize; + } +}; + +const getAddOrRemoveCount = (arr: T[], refLength: number) => ({ addCount: Math.max(refLength - arr.length, 0), removeCount: Math.max(arr.length - refLength, 0), }); diff --git a/packages/server/src/lib/object.ts b/packages/server/src/lib/object.ts new file mode 100644 index 00000000..3e206fa2 --- /dev/null +++ b/packages/server/src/lib/object.ts @@ -0,0 +1,33 @@ +export { ObjectBuilder }; + +class ObjectBuilder> { + private obj: Partial = {}; + + /** + * Augment the object with an object or a key/value pair. + * @example + * build + * .add({ username: "OGRE", role: "player" }) + * .add("email", "ogre@atelierogre.com"); + */ + public add( + keyOrObj: TKey | TObj, + value?: T[TKey] + ) { + if (keyOrObj == null) { + return this; + } + + if (typeof keyOrObj === "object") { + this.obj = { ...this.obj, ...keyOrObj }; + } else if (value !== undefined) { + this.obj[keyOrObj] = value; + } + + return this; + } + + public get() { + return this.obj; + } +} diff --git a/packages/server/src/modules/actions/services/playerActions.ts b/packages/server/src/modules/actions/services/playerActions.ts index 392bf381..709fbb68 100644 --- a/packages/server/src/modules/actions/services/playerActions.ts +++ b/packages/server/src/modules/actions/services/playerActions.ts @@ -7,11 +7,11 @@ const model = database.playerActions; type Model = PlayerActions; export { + model as queries, create, getMany, getOrCreatePlayerActions, removeForPlayer, - updatePlayerActions, }; async function create({ @@ -116,31 +116,3 @@ async function getOrCreatePlayerActions( return []; } } - -async function updatePlayerActions( - userId: number, - playerActions: { - isPerformed: boolean; - id: number; - }[] -): Promise { - const [{ gameId }] = await Promise.all( - playerActions.map((playerAction) => - database.playerActions.update({ - where: { - id_userId: { - id: playerAction.id, - userId, - }, - }, - data: { - isPerformed: playerAction.isPerformed, - }, - }) - ) - ); - - const updatedPlayerActions = await getOrCreatePlayerActions(gameId, userId); - - return updatedPlayerActions; -} diff --git a/packages/server/src/modules/games/controllers/index.ts b/packages/server/src/modules/games/controllers/index.ts index 24e682ae..74ab911c 100644 --- a/packages/server/src/modules/games/controllers/index.ts +++ b/packages/server/src/modules/games/controllers/index.ts @@ -15,6 +15,9 @@ import { validateProfilesController } from "./validateProfilesController"; import { updateProfilesController } from "./updateProfilesController"; import { generateCode } from "../services/utils"; import { getManyGamesControllers } from "./getManyGamesController"; +import * as playerActionsServices from "../../actions/services/playerActions"; +import * as teamActionsServices from "../../teamActions/services"; +import { batchItems } from "../../../lib/array"; const crudController = { createController, @@ -95,9 +98,39 @@ async function updateGame(request: Request, response: Response) { const update = bodySchema.parse(request.body); if (update?.status === "playing") { - const defaultPersonalization = await getDefault(); - await playersServices.setDefaultProfiles(id, defaultPersonalization); + await prepareGameForLaunch(id); } const document = await services.update(id, update); response.status(200).json({ document }); } + +async function prepareGameForLaunch(gameId: number) { + const BATCH_SIZE = 25; + + const defaultPersonalization = await getDefault(); + await playersServices.setDefaultProfiles(gameId, defaultPersonalization); + + const game = await services.queries.findUnique({ + where: { + id: gameId, + }, + include: { + players: true, + teams: true, + }, + }); + + await batchItems(game?.players || [], BATCH_SIZE, async (playerBatch) => { + const processingPlayerActions = playerBatch.map((p) => + playerActionsServices.getOrCreatePlayerActions(gameId, p.userId) + ); + await Promise.all(processingPlayerActions); + }); + + await batchItems(game?.teams || [], BATCH_SIZE, async (teamBatch) => { + const processingTeamActions = teamBatch.map((t) => + teamActionsServices.getOrCreateTeamActions(t.id) + ); + await Promise.all(processingTeamActions); + }); +} diff --git a/packages/server/src/modules/games/routes.ts b/packages/server/src/modules/games/routes.ts index d5b09298..fa085408 100644 --- a/packages/server/src/modules/games/routes.ts +++ b/packages/server/src/modules/games/routes.ts @@ -76,4 +76,18 @@ router.post( }), asyncErrorHandler(controllers.removeTeamController) ); -router.put("/:id", asyncErrorHandler(controllers.updateGame)); +router.put( + "/:id", + guardResource({ + roles: ["admin"], + ownership: async (user, request) => { + const gameId = parseInt(request.params.id, 10); + const game = await gameServices.getDocument(gameId); + + return { + success: user.id === game?.teacherId, + }; + }, + }), + asyncErrorHandler(controllers.updateGame) +); diff --git a/packages/server/src/modules/games/services/index.ts b/packages/server/src/modules/games/services/index.ts index a35af158..93702ce7 100644 --- a/packages/server/src/modules/games/services/index.ts +++ b/packages/server/src/modules/games/services/index.ts @@ -3,19 +3,19 @@ import { database } from "../../../database"; import { NO_TEAM } from "../../teams/constants/teams"; import { services as teamServices } from "../../teams/services"; import { Game } from "../types"; -import { initState } from "./initState"; import { register } from "./register"; const model = database.game; type Model = Game; const crudServices = { + queries: model, getDocument, getMany, create, update, }; -const services = { ...crudServices, initState, register }; +const services = { ...crudServices, register }; export { services }; diff --git a/packages/server/src/modules/games/services/initState.ts b/packages/server/src/modules/games/services/initState.ts deleted file mode 100644 index 768744cf..00000000 --- a/packages/server/src/modules/games/services/initState.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { database } from "../../../database"; - -export { initState }; - -async function initState({ gameId }: { gameId: number }) { - const gameWithTeams = await database.game.findUnique({ - where: { id: gameId }, - include: { - teams: { - where: { isDeleted: false }, - include: { - players: { - include: { - user: true, - actions: { - include: { - action: true, - }, - }, - profile: { - include: { - personalization: true, - }, - }, - }, - }, - actions: { - include: { - action: { - include: { - pointsIntervals: true, - }, - }, - }, - }, - }, - }, - }, - }); - return { gameWithTeams }; -} diff --git a/packages/server/src/modules/games/services/personalization.ts b/packages/server/src/modules/games/services/personalization.ts index cbde15a7..98cce250 100644 --- a/packages/server/src/modules/games/services/personalization.ts +++ b/packages/server/src/modules/games/services/personalization.ts @@ -1,7 +1,7 @@ import { Personalization } from "@prisma/client"; import { database } from "../../../database"; -export { update, create, getDefault }; +export { update, getDefault }; async function update(personalizationId: number, personalization: any) { return database.personalization.update({ @@ -20,9 +20,3 @@ async function getDefault(): Promise { }, }) as unknown as Personalization; } - -async function create(personalization: any) { - return database.personalization.create({ - data: personalization, - }); -} diff --git a/packages/server/src/modules/players/services/index.ts b/packages/server/src/modules/players/services/index.ts index 91343e5b..6dc3f79b 100644 --- a/packages/server/src/modules/players/services/index.ts +++ b/packages/server/src/modules/players/services/index.ts @@ -9,7 +9,7 @@ type Model = PlayersPrisma; export { services }; const crudServices = { - find, + queries: model, remove, setDefaultProfiles, update, @@ -19,29 +19,6 @@ const crudServices = { const services = { ...crudServices }; -async function find(gameId: number, userId: number): Promise { - // TODO: find a way to link Prisma typing with attributes included in `include` section. - return model.findFirst({ - where: { - gameId, - userId, - }, - include: { - actions: { - include: { - action: true, - }, - }, - team: true, - profile: { - include: { - personalization: true, - }, - }, - }, - }) as unknown as Players; -} - async function update( gameId: number, userId: number, diff --git a/packages/server/src/modules/profiles/services/index.ts b/packages/server/src/modules/profiles/services/index.ts deleted file mode 100644 index efbc6d9a..00000000 --- a/packages/server/src/modules/profiles/services/index.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { Profile as ProfilePrisma } from "@prisma/client"; -import { database } from "../../../database"; -import { Profile } from "../types"; - -const model = database.profile; -type Model = ProfilePrisma; - -export { services }; - -const crudServices = { - find, - create, - update, -}; - -const services = { ...crudServices }; - -async function find(id: number): Promise { - return model.findFirst({ - where: { - id, - }, - include: { - personalization: true, - }, - }) as unknown as Profile; -} - -async function create(document: Omit): Promise { - return model.create({ - data: document, - include: { - personalization: true, - }, - }) as unknown as Profile; -} - -async function update( - id: number, - document: Partial> -): Promise { - return model.update({ - where: { - id, - }, - data: document, - include: { - personalization: true, - }, - }) as unknown as Profile; -} diff --git a/packages/server/src/modules/profiles/types/index.ts b/packages/server/src/modules/profiles/types/index.ts deleted file mode 100644 index 0558289e..00000000 --- a/packages/server/src/modules/profiles/types/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Personalization, ProfileStatus } from "@prisma/client"; - -export type { Profile }; - -interface Profile { - id: number; - userId: number; - personalization: Personalization; - personalizationId: number; - status: ProfileStatus; - lastStatusUpdate: Date; -} diff --git a/packages/server/src/modules/teamActions/services/teamActions.ts b/packages/server/src/modules/teamActions/services/teamActions.ts index 2cdbe62f..7f953832 100644 --- a/packages/server/src/modules/teamActions/services/teamActions.ts +++ b/packages/server/src/modules/teamActions/services/teamActions.ts @@ -7,7 +7,7 @@ import * as productionActionsServices from "../../productionActions/services"; const model = database.teamActions; type Model = TeamActions; -export { create, getMany, getOrCreateTeamActions, updateTeamActions }; +export { model as queries, create, getMany, getOrCreateTeamActions }; async function create({ actionId, @@ -90,31 +90,3 @@ async function getOrCreateTeamActions(teamId: number) { return []; } } - -async function updateTeamActions( - teamId: number, - teamActions: { - id: number; - value: number; - isTouched: boolean; - }[] -): Promise { - await Promise.all( - teamActions.map((teamAction) => - database.teamActions.update({ - where: { - id_teamId: { - id: teamAction.id, - teamId, - }, - }, - data: { - value: teamAction.value, - isTouched: teamAction.isTouched, - }, - }) - ) - ); - - return getOrCreateTeamActions(teamId); -} diff --git a/packages/server/src/modules/teams/services/index.ts b/packages/server/src/modules/teams/services/index.ts index 8a5f9d43..3bf25aec 100644 --- a/packages/server/src/modules/teams/services/index.ts +++ b/packages/server/src/modules/teams/services/index.ts @@ -9,6 +9,7 @@ type Model = Team; export { services }; const crudServices = { + queries: model, create, getMany, get, diff --git a/packages/server/src/modules/users/services/authenticateUser.ts b/packages/server/src/modules/users/services/authenticateUser.ts index 2895fd3c..0079d36f 100644 --- a/packages/server/src/modules/users/services/authenticateUser.ts +++ b/packages/server/src/modules/users/services/authenticateUser.ts @@ -5,6 +5,13 @@ import { User } from "../types"; export { authenticateUser }; +/** + * Authenticate a user from an authentication token. + * + * Throws if the token is missing, malformed, invalid or the user can't be authenticated. + * + * @param token + */ async function authenticateUser(token = ""): Promise { let email: string | undefined; try { diff --git a/packages/server/src/modules/websocket/constants.ts b/packages/server/src/modules/websocket/constants.ts index afa8806d..6ca42c8f 100644 --- a/packages/server/src/modules/websocket/constants.ts +++ b/packages/server/src/modules/websocket/constants.ts @@ -5,4 +5,5 @@ const rooms = { players: (gameId: number) => `${gameId}/players`, teachers: (gameId: number) => `${gameId}/teachers`, team: (gameId: number, teamId: number) => `${gameId}/team/${teamId}`, + user: (gameId: number, playerId: number) => `${gameId}/users/${playerId}`, }; diff --git a/packages/server/src/modules/websocket/eventHandlers/joinGameHandler.ts b/packages/server/src/modules/websocket/eventHandlers/joinGameHandler.ts index c91c67b6..c940f301 100644 --- a/packages/server/src/modules/websocket/eventHandlers/joinGameHandler.ts +++ b/packages/server/src/modules/websocket/eventHandlers/joinGameHandler.ts @@ -1,63 +1,154 @@ /* eslint-disable no-param-reassign */ import { z } from "zod"; import invariant from "tiny-invariant"; -import { services as gameServices } from "../../games/services"; -import { services as playersServices } from "../../players/services"; import { services as usersServices } from "../../users/services"; -import * as playerActionsServices from "../../actions/services/playerActions"; -import * as teamActionsServices from "../../teamActions/services"; -import { Server, Socket } from "../types"; +import { GameInitEmitted, Server, Socket } from "../types"; import { rooms } from "../constants"; import { wrapHandler } from "../utils"; +import { + actionServices, + gameServices, + playerServices, + productionActionServices, + roleServices, + teamServices, +} from "../services"; +import { BusinessError } from "../../utils/businessError"; +import { Game } from "../../games/types"; +import { User } from "../../users/types"; export { handleJoinGame }; function handleJoinGame(io: Server, socket: Socket) { socket.on( - "joinGame", + "game:join", wrapHandler(async (rawGameId: unknown) => { - const gameId = z.number().parse(rawGameId); - socket.join(`${gameId}`); - const gameState = await gameServices.initState({ gameId }); - socket.emit("resetGameState", gameState); - const token = socket.handshake.auth?.token || ""; const user = await usersServices.authenticateUser(token); + const gameId = z.number().parse(rawGameId); + const game = await gameServices.findOne(gameId); + invariant(game, `Could not find game ${gameId}`); + socket.data.gameId = gameId; socket.data.user = user; - const game = await gameServices.getDocument(gameId); - const isPlayer = game?.teacherId !== user.id; - - if (isPlayer) { - const player = await playersServices.find(gameId, user.id); + let gameInitData: GameInitEmitted; + if (await hasPlayerAccess(user, game)) { + const player = await playerServices.findOne(gameId, user.id, { + include: { + user: true, + actions: { + orderBy: { + actionId: "asc", + }, + }, + profile: { + include: { + personalization: true, + }, + }, + }, + }); invariant( player, - `Could not find player with game id ${gameId} and user id ${user.id}` + `Could not find player for game ${gameId} and user ${user.id}` + ); + + const [team, consumptionActions, productionActions] = await Promise.all( + [ + teamServices.findOne(player.teamId, { + include: { + actions: { + orderBy: { + actionId: "asc", + }, + }, + }, + }), + actionServices.findAll(), + productionActionServices.findAll(), + ] ); + invariant(team, `Could not find team ${player.teamId}`); + socket.join(`${gameId}`); + socket.join(rooms.user(gameId, user.id)); socket.join(rooms.players(gameId)); socket.join(rooms.team(gameId, player.teamId)); - const [playerActions, teamActions] = await Promise.all([ - playerActionsServices.getOrCreatePlayerActions(gameId, user.id), - teamActionsServices.getOrCreateTeamActions(player.teamId), - ]); + gameInitData = { + game, + consumptionActions, + productionActions, + players: [player], + teams: [team], + }; + } else if (await hasTeacherAccess(user, game)) { + const [players, teams, consumptionActions, productionActions] = + await Promise.all([ + playerServices.findMany(gameId, { + include: { + user: true, + actions: true, + profile: { + include: { + personalization: true, + }, + }, + }, + }), + teamServices.findMany(gameId, { + include: { + actions: true, + }, + }), + actionServices.findAll(), + productionActionServices.findAll(), + ]); - socket.emit("playerActionsUpdated", { playerActions }); - socket.emit("playerUpdated", { - update: { - hasFinishedStep: player?.hasFinishedStep, - teamActions, - }, - }); - socket.emit("profileUpdated", { - update: player.profile, - }); - } else { + socket.join(`${gameId}`); + socket.join(rooms.user(gameId, user.id)); socket.join(rooms.teachers(gameId)); + + gameInitData = { + game, + consumptionActions, + productionActions, + players, + teams, + }; + } else { + socket.emit("game:leave"); + throw new BusinessError( + `User ${user.id} is not authorized to join game ${game.id}` + ); } + + socket.emit("game:init", gameInitData); }) ); } + +async function hasTeacherAccess(user: User, game: Game) { + if (game.teacherId === user.id) { + return true; + } + + // TODO: protect game access with a list of allowed teachers? + const userRole = await roleServices.findOne(user.roleId); + if (["admin", "teacher"].includes(userRole?.name || "")) { + return true; + } + + return false; +} + +async function hasPlayerAccess(user: User, game: Game) { + const player = await playerServices.findOne(game.id, user.id); + if (player) { + return true; + } + + return false; +} diff --git a/packages/server/src/modules/websocket/eventHandlers/readProfileHandler.ts b/packages/server/src/modules/websocket/eventHandlers/readProfileHandler.ts index a0e0a490..a2512a8f 100644 --- a/packages/server/src/modules/websocket/eventHandlers/readProfileHandler.ts +++ b/packages/server/src/modules/websocket/eventHandlers/readProfileHandler.ts @@ -1,34 +1,32 @@ -import { z } from "zod"; import invariant from "tiny-invariant"; import { Server, Socket } from "../types"; -import { services as playersServices } from "../../players/services"; -import { services as profileServices } from "../../profiles/services"; -import { wrapHandler } from "../utils"; +import { getSocketData, wrapHandler } from "../utils"; +import { playerServices } from "../services"; export { handleReadProfile }; function handleReadProfile(io: Server, socket: Socket) { socket.on( - "readProfile", - wrapHandler(async (args: unknown) => { - const schema = z.object({ - gameId: z.number(), - userId: z.number(), - }); - - const { gameId, userId } = schema.parse(args); - - const player = await playersServices.find(gameId, userId); + "profile:read", + wrapHandler(async () => { + const { gameId, user } = getSocketData(socket); + const player = await playerServices.findOne(gameId, user.id, { + include: { + profile: { + include: { + personalization: true, + }, + }, + }, + }); invariant( - player?.profileId, - `Could not find player for user ${userId} and game ${gameId}` + player?.profile, + `Could not find player profile for user ${user.id} and game ${gameId}` ); - const profile = await profileServices.find(player.profileId); - - socket.emit("profileUpdated", { - update: profile, + socket.emit("player:update", { + updates: [player], }); }) ); diff --git a/packages/server/src/modules/websocket/eventHandlers/updateGameHandler.ts b/packages/server/src/modules/websocket/eventHandlers/updateGameHandler.ts index cf937ce9..457ff482 100644 --- a/packages/server/src/modules/websocket/eventHandlers/updateGameHandler.ts +++ b/packages/server/src/modules/websocket/eventHandlers/updateGameHandler.ts @@ -1,62 +1,64 @@ import invariant from "tiny-invariant"; import { z } from "zod"; import { Server, Socket } from "../types"; -import { services as gameServices } from "../../games/services"; -import { services as playersServices } from "../../players/services"; -import * as teamActionsServices from "../../teamActions/services"; import { rooms } from "../constants"; -import { hasFinishedStep, wrapHandler } from "../utils"; +import { getSocketData, hasFinishedStep, wrapHandler } from "../utils"; +import { gameServices, playerServices } from "../services"; export { handleUpdateGame }; function handleUpdateGame(io: Server, socket: Socket) { socket.on( - "updateGame", + "game:update", wrapHandler(async (args: unknown) => { const schema = z.object({ - gameId: z.number(), update: z.object({ step: z.number().optional(), lastFinishedStep: z.number().optional(), status: z.enum(["ready", "draft", "playing", "finished"]).optional(), }), }); - const { gameId, update } = schema.parse(args); + const { update } = schema.parse(args); - const game = await gameServices.getDocument(gameId); + const { gameId } = getSocketData(socket); + const game = await gameServices.findOne(gameId); invariant(game, `Could not find game with id ${gameId}`); - const gameUpdated = await gameServices.update(gameId, update); - - io.to(rooms.game(gameId)).emit("gameUpdated", { update }); + const gameUpdated = await gameServices.queries.update({ + where: { id: gameId }, + data: update, + include: { + players: { + select: { + userId: true, + }, + }, + }, + }); - const teamIds = gameUpdated.teams.map((team) => team.id); - const teamActions = await Promise.all( - teamIds.map((teamId) => - teamActionsServices.getOrCreateTeamActions(teamId) - ) - ); + io.to(rooms.game(gameId)).emit("game:update", { update }); const hasGameFinishedStep = hasFinishedStep(gameUpdated); const hasPreviousGameFinishedStep = hasFinishedStep(game); if (hasPreviousGameFinishedStep !== hasGameFinishedStep) { - await playersServices.updateMany(gameId, { + await playerServices.updateMany(gameId, { hasFinishedStep: hasGameFinishedStep, }); } - teamIds.forEach((teamId, index) => { - io.to(rooms.team(gameId, teamId)).emit("playerUpdated", { - update: { - hasFinishedStep: hasGameFinishedStep, - teamActions: teamActions[index], - }, + + const playerUpdates = gameUpdated.players.map((p) => ({ + userId: p.userId, + hasFinishedStep: hasGameFinishedStep, + })); + + playerUpdates.forEach((playerUpdate) => { + io.to(rooms.user(gameId, playerUpdate.userId)).emit("player:update", { + updates: [playerUpdate], }); }); - - const gameLatestUpdate = await gameServices.getDocument(gameId); - io.to(rooms.game(gameId)).emit("gameUpdated", { - update: gameLatestUpdate, + io.to(rooms.teachers(gameId)).emit("player:update", { + updates: playerUpdates, }); }) ); diff --git a/packages/server/src/modules/websocket/eventHandlers/updatePlayerActionsHandler.ts b/packages/server/src/modules/websocket/eventHandlers/updatePlayerActionsHandler.ts index 02a99b86..48d31868 100644 --- a/packages/server/src/modules/websocket/eventHandlers/updatePlayerActionsHandler.ts +++ b/packages/server/src/modules/websocket/eventHandlers/updatePlayerActionsHandler.ts @@ -1,21 +1,19 @@ import { z } from "zod"; +import invariant from "tiny-invariant"; import { Server, Socket } from "../types"; -import { services as gameServices } from "../../games/services"; -import { services as playersServices } from "../../players/services"; -import * as playerActionsServices from "../../actions/services/playerActions"; import { GameStep, STEPS } from "../../../constants/steps"; import { PlayerActions } from "../../actions/types"; import { rooms } from "../constants"; import { getSocketData, wrapHandler } from "../utils"; +import { playerActionServices, playerServices } from "../services"; export { updatePlayerActions }; function updatePlayerActions(io: Server, socket: Socket) { socket.on( - "updatePlayerActions", + "player-actions:update", wrapHandler(async (args: unknown) => { const schema = z.object({ - gameId: z.number(), step: z.number(), playerActions: z .object({ @@ -31,16 +29,15 @@ function updatePlayerActions(io: Server, socket: Socket) { const { gameId, user } = getSocketData(socket); - const player = await playersServices.find(gameId, user.id); - if (!player) { - throw new Error( - `Could not find player for gameId ${gameId} and userId ${user.id}` - ); - } - - if (player.hasFinishedStep) { - throw new Error(`Player has already finished the current step`); - } + const player = await playerServices.findOne(gameId, user.id); + invariant( + player, + `Could not find player for gameId ${gameId} and userId ${user.id}` + ); + invariant( + !player.hasFinishedStep, + `Player has already finished the current step` + ); const lastChosenPlayerActions = await computeLastChosenPlayerActions( gameId, @@ -49,22 +46,36 @@ function updatePlayerActions(io: Server, socket: Socket) { playerActionsUpdate ); - if (!canUpdatePlayerActions(lastChosenPlayerActions, STEPS[stepId])) { - socket.emit("actionPointsLimitExceeded"); + if ( + !canUpdatePlayerActions( + lastChosenPlayerActions.playerActionsAtCurrentStep, + STEPS[stepId] + ) + ) { + socket.emit("player-actions:action-points-limit-exceeded", { + updates: [ + { + userId: player.userId, + actionPointsLimitExceeded: true, + }, + ], + }); return; } - const playerActions = await playerActionsServices.updatePlayerActions( + const playerActions = await playerActionServices.updateMany( user.id, - lastChosenPlayerActions + lastChosenPlayerActions.playerActionsFreshlyUpdated ); - const game = await gameServices.getDocument(gameId); - - socket.emit("playerActionsUpdated", { - playerActions, - }); - io.to(rooms.teachers(gameId)).emit("gameUpdated", { update: game }); + const updates = [ + { + userId: player.userId, + actions: playerActions, + }, + ]; + socket.emit("player-actions:update", { updates }); + io.to(rooms.teachers(gameId)).emit("player-actions:update", { updates }); }) ); } @@ -77,23 +88,30 @@ async function computeLastChosenPlayerActions( isPerformed: boolean; id: number; }[] -): Promise { - const idToPlayerActions = Object.fromEntries( +) { + const playerActionsUpdateById = Object.fromEntries( playerActionsUpdate.map((playerAction) => [playerAction.id, playerAction]) ); - const playerActions = ( - await playerActionsServices.getOrCreatePlayerActions(gameId, userId) + const playerActionsAtCurrentStep = ( + await playerActionServices.findManyWithActions(gameId, userId) ) .filter((playerAction) => playerAction.action.step === step) .map((playerAction) => ({ ...playerAction, - isPerformed: idToPlayerActions[playerAction.id] - ? idToPlayerActions[playerAction.id].isPerformed + isPerformed: playerActionsUpdateById[playerAction.id] + ? playerActionsUpdateById[playerAction.id].isPerformed : playerAction.isPerformed, })); - return playerActions; + const playerActionsFreshlyUpdated = playerActionsAtCurrentStep.filter( + (playerAction) => !!playerActionsUpdateById[playerAction.id] + ); + + return { + playerActionsAtCurrentStep, + playerActionsFreshlyUpdated, + }; } function canUpdatePlayerActions( diff --git a/packages/server/src/modules/websocket/eventHandlers/updatePlayerHandler.ts b/packages/server/src/modules/websocket/eventHandlers/updatePlayerHandler.ts index e6a73199..56b55d14 100644 --- a/packages/server/src/modules/websocket/eventHandlers/updatePlayerHandler.ts +++ b/packages/server/src/modules/websocket/eventHandlers/updatePlayerHandler.ts @@ -1,16 +1,16 @@ import { z } from "zod"; +import invariant from "tiny-invariant"; import { safe } from "../../../lib/fp"; -import { services as gameServices } from "../../games/services"; -import { services as playersServices } from "../../players/services"; import { rooms } from "../constants"; import { Server, Socket } from "../types"; -import { wrapHandler } from "../utils"; +import { getSocketData, wrapHandler } from "../utils"; +import { playerServices } from "../services"; export { handleUpdatePlayer }; function handleUpdatePlayer(io: Server, socket: Socket) { socket.on( - "updatePlayer", + "player:update", wrapHandler(async (args: unknown) => { await handleUpdateHasFinishedStepSafely(io, socket, args); }) @@ -25,36 +25,29 @@ async function handleUpdateHasFinishedStepSafely( await safe( async () => { const schema = z.object({ - gameId: z.number(), hasFinishedStep: z.boolean(), }); - const { gameId, hasFinishedStep } = schema.parse(args); + const { hasFinishedStep } = schema.parse(args); - const { user } = socket.data; - if (!user) { - throw new Error(`User not authenticated`); - } + const { gameId, user } = getSocketData(socket); - let player = await playersServices.find(gameId, user.id); - if (!player) { - throw new Error( - `Could not find player for gameId ${gameId} and userId ${user.id}` - ); - } + const player = await playerServices.findOne(gameId, user.id); + invariant( + player, + `Could not find player for game ${gameId} and user ${user.id}` + ); - player = await playersServices.update(gameId, user.id, { + await playerServices.update(gameId, user.id, { hasFinishedStep, }); - const game = await gameServices.getDocument(gameId); - - io.to(rooms.teachers(gameId)).emit("gameUpdated", { - update: game, - }); - socket.emit("playerUpdated", { - update: { - hasFinishedStep, - }, + const playerUpdate = { + userId: player.userId, + hasFinishedStep, + }; + socket.emit("player:update", { updates: [playerUpdate] }); + io.to(rooms.teachers(gameId)).emit("player:update", { + updates: [playerUpdate], }); }, { logError: true } diff --git a/packages/server/src/modules/websocket/eventHandlers/updateProfileHandler.ts b/packages/server/src/modules/websocket/eventHandlers/updateProfileHandler.ts index b6076cc2..3055b9fc 100644 --- a/packages/server/src/modules/websocket/eventHandlers/updateProfileHandler.ts +++ b/packages/server/src/modules/websocket/eventHandlers/updateProfileHandler.ts @@ -1,20 +1,22 @@ import { z } from "zod"; import { ProfileStatus } from "@prisma/client"; +import invariant from "tiny-invariant"; import { Server, Socket } from "../types"; -import { services as playersServices } from "../../players/services"; -import { services as profileServices } from "../../profiles/services"; -import { wrapHandler } from "../utils"; -import { update, create } from "../../games/services/personalization"; +import { getSocketData, wrapHandler } from "../utils"; +import { + personalizationServices, + playerServices, + profileServices, +} from "../services"; export { handleUpdateProfile }; function handleUpdateProfile(io: Server, socket: Socket) { socket.on( - "updateProfile", + "profile:update", wrapHandler(async (args: unknown, respond: (...argz: any[]) => void) => { + // TODO: fix schema required/optional values const schema = z.object({ - gameId: z.number(), - userId: z.number(), update: z.object({ profileStatus: z.string(), origin: z.string(), @@ -70,37 +72,65 @@ function handleUpdateProfile(io: Server, socket: Socket) { }), }); - const { gameId, userId, update: updateData } = schema.parse(args); + const { update: updateData } = schema.parse(args); + const { gameId, user } = getSocketData(socket); + const userId = user.id; - const player = await playersServices.find(gameId, userId); - const { profileStatus, ...personalizationData } = updateData; + let player = await playerServices.findOne(gameId, userId, { + include: { + profile: { + include: { + personalization: true, + }, + }, + }, + }); + invariant( + player, + `Could not find player for user ${user.id} and game ${gameId}` + ); - if (player?.profileId && player?.profile.personalizationId) { - const personalizationId = player?.profile.personalizationId; - await update(personalizationId, personalizationData); - await profileServices.update(player?.profileId, { + const { profileStatus, ...personalizationData } = updateData; + if (player.profile?.personalization) { + await personalizationServices.update( + player.profile.personalization.id, + personalizationData as any + ); + await profileServices.update(player.profile.id, { status: profileStatus as ProfileStatus, lastStatusUpdate: new Date(), }); } else { - const personalization = await create(personalizationData); + const personalization = await personalizationServices.create( + personalizationData as any + ); const profile = await profileServices.create({ userId, personalizationId: personalization.id, status: profileStatus as ProfileStatus, lastStatusUpdate: new Date(), }); - await playersServices.update(gameId, userId, { + await playerServices.update(gameId, userId, { profileId: profile?.id, }); } - const newPlayer = await playersServices.find(gameId, userId); - const profile = - newPlayer?.profileId && - (await profileServices.find(newPlayer.profileId)); - socket.emit("profileUpdated", { - update: profile, + player = await playerServices.findOne(gameId, userId, { + include: { + profile: { + include: { + personalization: true, + }, + }, + }, + }); + invariant( + player, + `Could not find player for user ${user.id} and game ${gameId}` + ); + + socket.emit("player:update", { + updates: [player], }); respond({ success: true }); }) diff --git a/packages/server/src/modules/websocket/eventHandlers/updateTeamHandler.ts b/packages/server/src/modules/websocket/eventHandlers/updateTeamHandler.ts index b60379b3..2a133ffa 100644 --- a/packages/server/src/modules/websocket/eventHandlers/updateTeamHandler.ts +++ b/packages/server/src/modules/websocket/eventHandlers/updateTeamHandler.ts @@ -1,11 +1,6 @@ -import { Game, Players } from "@prisma/client"; import invariant from "tiny-invariant"; import { z } from "zod"; import { safe } from "../../../lib/fp"; -import { services as gameServices } from "../../games/services"; -import { services as playersServices } from "../../players/services"; -import { services as teamServices } from "../../teams/services"; -import * as teamActionsServices from "../../teamActions/services"; import { rooms } from "../constants"; import { Server, Socket } from "../types"; import { @@ -14,12 +9,18 @@ import { isGameFinished, wrapHandler, } from "../utils"; +import { + gameServices, + playerServices, + teamActionServices, + teamServices, +} from "../services"; export { handleUpdateTeam }; function handleUpdateTeam(io: Server, socket: Socket) { socket.on( - "updateTeam", + "team:update", wrapHandler(async (args: unknown) => { await handleUpdateTeamSafely(io, socket, args); }) @@ -70,7 +71,7 @@ async function handleUpdateTeamSafely( teamActionsUpdate ); - const teamActions = await teamActionsServices.updateTeamActions( + const teamActions = await teamActionServices.updateMany( player.teamId, validTeamActionsUpdate.map((update) => ({ id: update.id, @@ -79,8 +80,21 @@ async function handleUpdateTeamSafely( })) ); - io.to(rooms.team(gameId, player.teamId)).emit("playerUpdated", { - update: { teamActions }, + io.to(rooms.team(gameId, player.teamId)).emit("team:update", { + updates: [ + { + id: player.teamId, + actions: teamActions, + }, + ], + }); + io.to(rooms.teachers(gameId)).emit("team:update", { + updates: [ + { + id: player.teamId, + actions: teamActions, + }, + ], }); } @@ -88,39 +102,27 @@ async function handleUpdateTeamSafely( const updatedTeam = await teamServices.update(player.teamId, { scenarioName, }); - io.to(rooms.team(gameId, updatedTeam.id)).emit("gameUpdated", { - update: game, + io.to(rooms.team(gameId, updatedTeam.id)).emit("team:update", { + updates: [updatedTeam], }); - } - - const gameLatestUpdate = await gameServices.getDocument(gameId); - - if (scenarioName) { - io.to(rooms.team(gameId, player.teamId)).emit("gameUpdated", { - update: gameLatestUpdate, + io.to(rooms.teachers(gameId)).emit("team:update", { + updates: [updatedTeam], }); } - - io.to(rooms.teachers(gameId)).emit("gameUpdated", { - update: gameLatestUpdate, - }); }, { logError: true } ); } -async function getPlayerAndGame( - gameId: number, - userId: number -): Promise<{ game: Game; player: Players }> { +async function getPlayerAndGame(gameId: number, userId: number) { const [game, player] = await Promise.all([ - gameServices.getDocument(gameId), - playersServices.find(gameId, userId), + gameServices.findOne(gameId), + playerServices.findOne(gameId, userId), ]); - invariant(game, `Could not find game for gameId ${gameId}`); + invariant(game, `Could not find game ${gameId}`); invariant( player, - `Could not find player for gameId ${gameId} and userId ${userId}` + `Could not find player for game ${gameId} and user ${userId}` ); return { game, player }; @@ -135,9 +137,10 @@ async function filterValidTeamActionsUpdate( }[] ) { const teamActionIds = teamActionsUpdate.map((update) => update.id); - const teamActions = await teamActionsServices.getMany({ - ids: teamActionIds, - teamId, + const teamActions = await teamActionServices.findMany(teamActionIds, teamId, { + include: { + action: true, + }, }); const teamActionIdToUpdatable = Object.fromEntries( teamActions diff --git a/packages/server/src/modules/websocket/services/actionServices.ts b/packages/server/src/modules/websocket/services/actionServices.ts new file mode 100644 index 00000000..11293e30 --- /dev/null +++ b/packages/server/src/modules/websocket/services/actionServices.ts @@ -0,0 +1,15 @@ +import { database } from "../../../database"; + +const model = database.action; + +export const actionServices = { + findAll, +}; + +async function findAll() { + return model.findMany({ + orderBy: { + id: "asc", + }, + }); +} diff --git a/packages/server/src/modules/websocket/services/gameServices.ts b/packages/server/src/modules/websocket/services/gameServices.ts new file mode 100644 index 00000000..70df197b --- /dev/null +++ b/packages/server/src/modules/websocket/services/gameServices.ts @@ -0,0 +1,12 @@ +import { services } from "../../games/services"; + +export const gameServices = { + queries: services.queries, + findOne, +}; + +async function findOne(gameId: number) { + return services.queries.findUnique({ + where: { id: gameId }, + }); +} diff --git a/packages/server/src/modules/websocket/services/index.ts b/packages/server/src/modules/websocket/services/index.ts new file mode 100644 index 00000000..601dd8ff --- /dev/null +++ b/packages/server/src/modules/websocket/services/index.ts @@ -0,0 +1,10 @@ +export * from "./actionServices"; +export * from "./gameServices"; +export * from "./personalizationServices"; +export * from "./playerActionServices"; +export * from "./playerServices"; +export * from "./productionActionServices"; +export * from "./profileServices"; +export * from "./roleServices"; +export * from "./teamActionServices"; +export * from "./teamServices"; diff --git a/packages/server/src/modules/websocket/services/personalizationServices.ts b/packages/server/src/modules/websocket/services/personalizationServices.ts new file mode 100644 index 00000000..67566f89 --- /dev/null +++ b/packages/server/src/modules/websocket/services/personalizationServices.ts @@ -0,0 +1,27 @@ +import { Personalization } from "@prisma/client"; +import { database } from "../../../database"; + +const model = database.personalization; + +export const personalizationServices = { + create, + update, +}; + +async function create(data: Parameters[0]["data"]) { + return model.create({ + data, + }); +} + +async function update( + personalizationId: number, + data: Partial> +) { + return model.update({ + where: { + id: personalizationId, + }, + data, + }); +} diff --git a/packages/server/src/modules/websocket/services/playerActionServices.ts b/packages/server/src/modules/websocket/services/playerActionServices.ts new file mode 100644 index 00000000..95065c9a --- /dev/null +++ b/packages/server/src/modules/websocket/services/playerActionServices.ts @@ -0,0 +1,64 @@ +import { PlayerActions } from "@prisma/client"; +import * as services from "../../actions/services/playerActions"; + +export const playerActionServices = { + queries: services.queries, + findMany, + findManyWithActions, + updateMany, +}; + +async function findMany(gameId: number, userId: number) { + return services.queries.findMany({ + where: { + AND: { + gameId, + userId, + }, + }, + orderBy: { + actionId: "asc", + }, + }); +} + +async function findManyWithActions(gameId: number, userId: number) { + return services.queries.findMany({ + where: { + AND: { + gameId, + userId, + }, + }, + include: { + action: true, + }, + orderBy: { + actionId: "asc", + }, + }); +} + +async function updateMany( + userId: number, + actions: { + isPerformed: boolean; + id: number; + }[] +): Promise { + return Promise.all( + actions.map((playerAction) => + services.queries.update({ + where: { + id_userId: { + id: playerAction.id, + userId, + }, + }, + data: { + isPerformed: playerAction.isPerformed, + }, + }) + ) + ); +} diff --git a/packages/server/src/modules/websocket/services/playerServices.ts b/packages/server/src/modules/websocket/services/playerServices.ts new file mode 100644 index 00000000..5a41b2e7 --- /dev/null +++ b/packages/server/src/modules/websocket/services/playerServices.ts @@ -0,0 +1,83 @@ +import { Players, Prisma } from "@prisma/client"; +import { database } from "../../../database"; + +const model = database.players; + +export const playerServices = { + findOne, + findMany, + update, + updateMany, +}; + +async function findOne< + OptionsInclude extends Parameters[0]["include"] +>( + gameId: number, + userId: number, + options: { + include?: OptionsInclude; + } = {} +): Promise | null> { + return model.findUnique({ + where: { + userId_gameId: { + gameId, + userId, + }, + }, + ...options, + }) as any; +} + +async function findMany< + OptionsInclude extends NonNullable< + Parameters[0] + >["include"] +>( + gameId: number, + options: { + include?: OptionsInclude; + } = {} +): Promise< + Prisma.PlayersGetPayload<{ + include: OptionsInclude; + }>[] +> { + return model.findMany({ + where: { + gameId, + }, + ...options, + }) as any; +} + +async function update( + gameId: number, + userId: number, + data: Partial> +) { + return model.update({ + where: { + userId_gameId: { + gameId, + userId, + }, + }, + data, + }); +} + +async function updateMany( + gameId: number, + data: Partial> +): Promise { + await model.updateMany({ + where: { + gameId, + }, + data, + }); +} diff --git a/packages/server/src/modules/websocket/services/productionActionServices.ts b/packages/server/src/modules/websocket/services/productionActionServices.ts new file mode 100644 index 00000000..b2d79e8e --- /dev/null +++ b/packages/server/src/modules/websocket/services/productionActionServices.ts @@ -0,0 +1,15 @@ +import { database } from "../../../database"; + +const model = database.productionAction; + +export const productionActionServices = { + findAll, +}; + +async function findAll() { + return model.findMany({ + orderBy: { + id: "asc", + }, + }); +} diff --git a/packages/server/src/modules/websocket/services/profileServices.ts b/packages/server/src/modules/websocket/services/profileServices.ts new file mode 100644 index 00000000..5abd442e --- /dev/null +++ b/packages/server/src/modules/websocket/services/profileServices.ts @@ -0,0 +1,36 @@ +import { Profile } from "@prisma/client"; +import { database } from "../../../database"; + +const model = database.profile; + +export const profileServices = { + create, + findOne, + update, +}; + +async function findOne(id: number): Promise { + return model.findFirst({ + where: { + id, + }, + }); +} + +async function create(data: Omit): Promise { + return model.create({ + data, + }); +} + +async function update( + id: number, + data: Partial> +): Promise { + return model.update({ + where: { + id, + }, + data, + }); +} diff --git a/packages/server/src/modules/websocket/services/roleServices.ts b/packages/server/src/modules/websocket/services/roleServices.ts new file mode 100644 index 00000000..9ed0c104 --- /dev/null +++ b/packages/server/src/modules/websocket/services/roleServices.ts @@ -0,0 +1,16 @@ +import { Role } from "@prisma/client"; +import { database } from "../../../database"; + +const model = database.role; + +export const roleServices = { + findOne, +}; + +async function findOne(id: number): Promise { + return model.findFirst({ + where: { + id, + }, + }); +} diff --git a/packages/server/src/modules/websocket/services/teamActionServices.ts b/packages/server/src/modules/websocket/services/teamActionServices.ts new file mode 100644 index 00000000..028e1b20 --- /dev/null +++ b/packages/server/src/modules/websocket/services/teamActionServices.ts @@ -0,0 +1,66 @@ +import { Prisma } from "@prisma/client"; +import { database } from "../../../database"; + +const model = database.teamActions; + +export const teamActionServices = { + findMany, + updateMany, +}; + +async function findMany< + OptionsInclude extends NonNullable< + Parameters[0] + >["include"] +>( + ids: number[], + teamId: number, + options: { + include?: OptionsInclude; + } = {} +): Promise< + Prisma.TeamActionsGetPayload<{ + include: OptionsInclude; + }>[] +> { + return model.findMany({ + where: { + AND: { + id: { + in: ids, + }, + teamId, + }, + }, + orderBy: { + actionId: "asc", + }, + ...options, + }) as any; +} + +async function updateMany( + teamId: number, + actions: { + id: number; + value: number; + isTouched: boolean; + }[] +) { + return Promise.all( + actions.map((teamAction) => + model.update({ + where: { + id_teamId: { + id: teamAction.id, + teamId, + }, + }, + data: { + value: teamAction.value, + isTouched: teamAction.isTouched, + }, + }) + ) + ); +} diff --git a/packages/server/src/modules/websocket/services/teamServices.ts b/packages/server/src/modules/websocket/services/teamServices.ts new file mode 100644 index 00000000..c3ef8f17 --- /dev/null +++ b/packages/server/src/modules/websocket/services/teamServices.ts @@ -0,0 +1,70 @@ +import { Prisma, Team } from "@prisma/client"; +import { database } from "../../../database"; +import { ObjectBuilder } from "../../../lib/object"; +import { NO_TEAM } from "../../teams/constants/teams"; + +const model = database.team; + +export const teamServices = { + findOne, + findMany, + update, +}; + +async function findOne< + OptionsInclude extends Parameters[0]["include"] +>( + id: number, + options: { + include?: OptionsInclude; + } = {} +): Promise | null> { + return model.findUnique({ + where: { + id, + }, + ...options, + }) as any; +} + +async function findMany< + OptionsInclude extends NonNullable< + Parameters[0] + >["include"] +>( + gameId: number, + options: { + includeNoTeam?: boolean; + include?: OptionsInclude; + } = {} +): Promise< + Prisma.TeamGetPayload<{ + include: OptionsInclude; + }>[] +> { + const { includeNoTeam, ...optionsRest } = options; + + const whereBuilder = new ObjectBuilder().add("gameId", gameId); + if (!includeNoTeam) { + whereBuilder.add({ name: { not: { equals: NO_TEAM } } }); + } + + return model.findMany({ + where: whereBuilder.get(), + orderBy: { + id: "asc", + }, + ...optionsRest, + }) as any; +} + +async function update(teamId: number, data: Partial>) { + return model.update({ + where: { + id: teamId, + }, + data, + }); +} diff --git a/packages/server/src/modules/websocket/types.ts b/packages/server/src/modules/websocket/types.ts index a9f7e834..d760212e 100644 --- a/packages/server/src/modules/websocket/types.ts +++ b/packages/server/src/modules/websocket/types.ts @@ -1,13 +1,65 @@ -import { Server, Socket as SocketLib } from "socket.io"; +import { Server as ServerLib, Socket as SocketLib } from "socket.io"; +import { + Action, + Game, + Personalization, + PlayerActions, + Players, + ProductionAction, + Profile, + Team, + TeamActions, +} from "@prisma/client"; import { User } from "../users/types"; -export { Server, Socket, SocketData }; +export { + GameInitEmitted, + PlayerEmitted, + Server, + Socket, + SocketData, + TeamEmitted, +}; // TODO: type the socket // eslint-disable-next-line @typescript-eslint/no-explicit-any type ListenEvents = any; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type EmitEvents = any; + +type EmitEvents = { + "game:init": (args: GameInitEmitted) => void; + "game:leave": () => void; + "game:update": (args: { update: Partial }) => void; + "player:update": (args: { updates: Partial[] }) => void; + "player-actions:update": (args: { + updates: Partial[]; + }) => void; + "player-actions:action-points-limit-exceeded": (args: { + updates: Partial[]; + }) => void; + "team:update": (args: { updates: Partial[] }) => void; +}; + +type GameInitEmitted = { + game: Game; + consumptionActions: Action[]; + productionActions: ProductionAction[]; + players: PlayerEmitted[]; + teams: TeamEmitted[]; +}; +type PlayerEmitted = Players & { + actionPointsLimitExceeded?: boolean; + user: User; + actions: PlayerActions[]; + profile: + | (Profile & { + personalization: Personalization; + }) + | null; +}; +type TeamEmitted = Team & { + actions: TeamActions[]; +}; + // eslint-disable-next-line @typescript-eslint/no-explicit-any type ServerSideEvents = any; type SocketData = { @@ -15,4 +67,5 @@ type SocketData = { user: User; }; +type Server = ServerLib; type Socket = SocketLib; diff --git a/yarn.lock b/yarn.lock index 82bd8bd0..ca87f74f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3405,6 +3405,11 @@ deepmerge@^4.2.2: resolved "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.0.tgz" integrity sha512-z2wJZXrmeHdvYJp/Ux55wIjqo81G5Bp4c+oELTW+7ar6SogWHajt5a9gO3s3IDaGSAXjDk0vlQKN3rms8ab3og== +deepmerge@^4.3.1: + version "4.3.1" + resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a" + integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A== + define-lazy-prop@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz"