diff --git a/.yarn/cache/@mui-icons-material-npm-5.16.4-d62c35da3a-b0559215a1.zip b/.yarn/cache/@mui-icons-material-npm-5.16.4-d62c35da3a-b0559215a1.zip new file mode 100644 index 00000000..00715b2d Binary files /dev/null and b/.yarn/cache/@mui-icons-material-npm-5.16.4-d62c35da3a-b0559215a1.zip differ diff --git a/.yarn/cache/@reduxjs-toolkit-npm-2.2.5-cdc856b5fc-41d7eb5025.zip b/.yarn/cache/@reduxjs-toolkit-npm-2.2.5-cdc856b5fc-41d7eb5025.zip new file mode 100644 index 00000000..a05edce5 Binary files /dev/null and b/.yarn/cache/@reduxjs-toolkit-npm-2.2.5-cdc856b5fc-41d7eb5025.zip differ diff --git a/.yarn/cache/@types-use-sync-external-store-npm-0.0.3-875a91a914-161ddb8eec.zip b/.yarn/cache/@types-use-sync-external-store-npm-0.0.3-875a91a914-161ddb8eec.zip new file mode 100644 index 00000000..65f8d4f4 Binary files /dev/null and b/.yarn/cache/@types-use-sync-external-store-npm-0.0.3-875a91a914-161ddb8eec.zip differ diff --git a/.yarn/cache/immer-npm-10.1.1-973ae10d09-07c67970b7.zip b/.yarn/cache/immer-npm-10.1.1-973ae10d09-07c67970b7.zip new file mode 100644 index 00000000..84dae941 Binary files /dev/null and b/.yarn/cache/immer-npm-10.1.1-973ae10d09-07c67970b7.zip differ diff --git a/.yarn/cache/react-redux-npm-9.1.2-8af4985431-1ee9cf41f2.zip b/.yarn/cache/react-redux-npm-9.1.2-8af4985431-1ee9cf41f2.zip new file mode 100644 index 00000000..dafa80d0 Binary files /dev/null and b/.yarn/cache/react-redux-npm-9.1.2-8af4985431-1ee9cf41f2.zip differ diff --git a/.yarn/cache/redux-npm-5.0.1-f8e6b1cb23-e74affa900.zip b/.yarn/cache/redux-npm-5.0.1-f8e6b1cb23-e74affa900.zip new file mode 100644 index 00000000..e7004946 Binary files /dev/null and b/.yarn/cache/redux-npm-5.0.1-f8e6b1cb23-e74affa900.zip differ diff --git a/.yarn/cache/redux-thunk-npm-3.1.0-6a8fdd3211-bea96f8233.zip b/.yarn/cache/redux-thunk-npm-3.1.0-6a8fdd3211-bea96f8233.zip new file mode 100644 index 00000000..6c2dec97 Binary files /dev/null and b/.yarn/cache/redux-thunk-npm-3.1.0-6a8fdd3211-bea96f8233.zip differ diff --git a/.yarn/cache/reselect-npm-5.1.1-667568f51c-5d32d48be2.zip b/.yarn/cache/reselect-npm-5.1.1-667568f51c-5d32d48be2.zip new file mode 100644 index 00000000..7d4ef0b4 Binary files /dev/null and b/.yarn/cache/reselect-npm-5.1.1-667568f51c-5d32d48be2.zip differ diff --git a/.yarn/cache/tsconfig-paths-npm-4.2.0-ac1edf8677-28c5f7bbbc.zip b/.yarn/cache/tsconfig-paths-npm-4.2.0-ac1edf8677-28c5f7bbbc.zip new file mode 100644 index 00000000..0d3418a2 Binary files /dev/null and b/.yarn/cache/tsconfig-paths-npm-4.2.0-ac1edf8677-28c5f7bbbc.zip differ diff --git a/.yarn/cache/use-sync-external-store-npm-1.2.2-7923c915e1-fe07c071c4.zip b/.yarn/cache/use-sync-external-store-npm-1.2.2-7923c915e1-fe07c071c4.zip new file mode 100644 index 00000000..c5206e51 Binary files /dev/null and b/.yarn/cache/use-sync-external-store-npm-1.2.2-7923c915e1-fe07c071c4.zip differ diff --git a/packages/webapp/.eslintrc.json b/packages/webapp/.eslintrc.json index bffb357a..5b687efe 100644 --- a/packages/webapp/.eslintrc.json +++ b/packages/webapp/.eslintrc.json @@ -1,3 +1,11 @@ { - "extends": "next/core-web-vitals" + "extends": "next/core-web-vitals", + "overrides": [ + { + "files": ["src/game/**"], + "rules": { + "react-hooks/rules-of-hooks": "off", + }, + } + ] } diff --git a/packages/webapp/package.json b/packages/webapp/package.json index ee6fb03d..35ff8939 100644 --- a/packages/webapp/package.json +++ b/packages/webapp/package.json @@ -5,13 +5,13 @@ "scripts": { "dev": "concurrently \"yarn dev:next\" \"yarn dev:server\"", "dev:next": "next dev", - "dev:server": "ts-node-dev -P tsconfig.server.json --respawn --transpile-only --ignore-watch .next src/server.ts", + "dev:server": "ts-node-dev -P tsconfig.server.json --respawn --transpile-only --ignore-watch .next -r tsconfig-paths/register src/server.ts", "build": "yarn build:next && yarn build:server", "build:next": "next build", "build:server": "tsc -P tsconfig.server.json", "start": "yarn start:next & yarn start:server", "start:next": "next start", - "start:server": "node dist/server.js", + "start:server": "node -r tsconfig-paths/register dist/server.js", "lint": "next lint", "test": "jest" }, @@ -19,6 +19,7 @@ "@emotion/cache": "^11.11.0", "@emotion/react": "^11.11.4", "@emotion/styled": "^11.11.5", + "@mui/icons-material": "^5.16.4", "@mui/lab": "^5.0.0-alpha.170", "@mui/material": "^5.15.19", "@mui/material-nextjs": "^5.15.11", @@ -27,7 +28,8 @@ "next": "14.2.3", "react": "^18", "react-dom": "^18", - "react-redux": "^9.1.2" + "react-redux": "^9.1.2", + "tsconfig-paths": "^4.2.0" }, "devDependencies": { "@types/jest": "^29.5.12", diff --git a/packages/webapp/src/app/layout.tsx b/packages/webapp/src/app/layout.tsx index d73227be..d53c7000 100644 --- a/packages/webapp/src/app/layout.tsx +++ b/packages/webapp/src/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import { Inter } from "next/font/google"; import { AppRouterCacheProvider } from '@mui/material-nextjs/v13-appRouter'; import "./globals.css"; +import { CssBaseline } from "@mui/material"; const inter = Inter({ subsets: ["latin"] }); @@ -19,6 +20,7 @@ export default function RootLayout({ + {children} diff --git a/packages/webapp/src/components/ActionBoard/ActionBar/ActionBar.selectors.ts b/packages/webapp/src/components/ActionBoard/ActionBar/ActionBar.selectors.ts new file mode 100644 index 00000000..f8ba78bc --- /dev/null +++ b/packages/webapp/src/components/ActionBoard/ActionBar/ActionBar.selectors.ts @@ -0,0 +1,58 @@ +import { ActionMoveName } from '@/game/core/stage/action/move/type'; +import { GameContext } from '../../GameContextHelpers'; +import { GameState } from '@/game/store/store'; +import { RuleSelector } from '@/game/store/slice/rule'; +import { ActionSlotSelector } from '@/game/store/slice/actionSlot'; +import { AppDispatch } from '@/lib/store'; +import { UserActionMoves, getCurrentAction, setCurrentAction } from '@/lib/reducers/actionStepSlice'; +import { createSelector } from '@reduxjs/toolkit'; + +export interface StateProps { + isActionBarVisible: boolean; +} + +export const mapStateToProps = createSelector(getCurrentAction, (currentAction) => ({ + isActionBarVisible: currentAction === null, +})); + +export interface DispatchProps { + onActionClick: (action: UserActionMoves) => void; +} + +export const mapDispatchToProps = (dispatch: AppDispatch): DispatchProps => ({ + onActionClick: (action: UserActionMoves) => dispatch(setCurrentAction(action)), +}); + +export enum ActionMoveState { + Available = 'available', + Occupied = 'occupied', + Disabled = 'disabled' +} + +const getActionMoveState = (state: GameState, actionMove: ActionMoveName): ActionMoveState => { + if (!RuleSelector.isActionSlotAvailable(state.rules, actionMove)) { + return ActionMoveState.Disabled; + } + if (ActionSlotSelector.isOccupied(state.table.actionSlots[actionMove])) { + return ActionMoveState.Occupied; + } + return ActionMoveState.Available; +}; + +export interface GameContextProps { + actionsState: Record; +} + +export const mapGameContextToProps = ({ G }: GameContext) => { + const actionsState: Record = { + [UserActionMoves.CreateProject]: getActionMoveState(G, UserActionMoves.CreateProject), + [UserActionMoves.Recruit]: getActionMoveState(G, UserActionMoves.Recruit), + [UserActionMoves.ContributeOwnedProjects]: getActionMoveState(G, UserActionMoves.ContributeOwnedProjects), + [UserActionMoves.ContributeJoinedProjects]: getActionMoveState(G, UserActionMoves.ContributeJoinedProjects), + [UserActionMoves.RemoveAndRefillJobs]: getActionMoveState(G, UserActionMoves.RemoveAndRefillJobs), + [UserActionMoves.Mirror]: getActionMoveState(G, UserActionMoves.Mirror), + [UserActionMoves.EndActionTurn]: ActionMoveState.Available, + }; + + return { actionsState }; +}; diff --git a/packages/webapp/src/components/ActionBoard/ActionBar/ActionBar.tsx b/packages/webapp/src/components/ActionBoard/ActionBar/ActionBar.tsx new file mode 100644 index 00000000..c50e2442 --- /dev/null +++ b/packages/webapp/src/components/ActionBoard/ActionBar/ActionBar.tsx @@ -0,0 +1,73 @@ +import React from 'react'; +import { Box, Button, Grid } from '@mui/material'; +import { styled } from '@mui/material/styles'; +import { connectGameContext } from '../../GameContextHelpers'; +import { connect } from 'react-redux'; +import { ActionMoveState, mapGameContextToProps, mapDispatchToProps, GameContextProps, StateProps, DispatchProps, mapStateToProps } from './ActionBar.selectors'; +import { UserActionMoves } from '@/lib/reducers/actionStepSlice'; + +type Props = GameContextProps & StateProps & DispatchProps; + +const StyledButton = styled(Button)(({ theme }) => ({ + margin: theme.spacing(1), + [`&.${ActionMoveState.Available}`]: { + backgroundColor: theme.palette.success.main, + color: theme.palette.common.white, + '&:hover': { + backgroundColor: theme.palette.success.dark, + }, + }, + [`&.${ActionMoveState.Occupied}`]: { + backgroundColor: theme.palette.warning.main, + color: theme.palette.common.white, + '&:hover': { + backgroundColor: theme.palette.warning.dark, + }, + }, + [`&.${ActionMoveState.Disabled}`]: { + backgroundColor: theme.palette.action.disabled, + color: theme.palette.text.disabled, + }, +})); + +const EndActionButton = styled(Button)(({ theme }) => ({ + margin: theme.spacing(1), + backgroundColor: theme.palette.primary.main, + color: theme.palette.common.white, + '&:hover': { + backgroundColor: theme.palette.primary.dark, + }, +})); + +const ActionBar: React.FC = ({ isActionBarVisible, actionsState, onActionClick }) => { + if (!isActionBarVisible) { + return null; + } + return ( + + + {Object.entries(actionsState).map(([action, state]) => ( + action === UserActionMoves.EndActionTurn ? ( + onActionClick(action)} + > + End Action Turn + + ) : ( + state === ActionMoveState.Available && onActionClick(action as UserActionMoves)} + disabled={state === ActionMoveState.Disabled} + > + {action.replace(/([A-Z])/g, ' $1').replace(/^./, str => str.toUpperCase())} + + ) + ))} + + + ); +}; + +export default connectGameContext(mapGameContextToProps)(connect(mapStateToProps, mapDispatchToProps)(ActionBar)); diff --git a/packages/webapp/src/components/ActionBoard/ActionBoard.tsx b/packages/webapp/src/components/ActionBoard/ActionBoard.tsx deleted file mode 100644 index 62d735e2..00000000 --- a/packages/webapp/src/components/ActionBoard/ActionBoard.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import React from 'react'; -import { Button, Toolbar } from "@mui/material"; -import { Dispatch, createSelector } from "@reduxjs/toolkit"; -import { Page, hasNextPage, isPageCancellable, wizardActions } from '@/lib/reducers/wizard'; -import { connect } from 'react-redux'; -import { selectWizard } from '@/lib/selector'; - -enum ActionName { - CREATE_PROJECT = 'CREATE_PROJECT', - RECRUIT = 'RECRUIT', - CONTRIBUTE_JOINED_PROJECTS = 'CONTRIBUTE_JOINED_PROJECTS', - CONTRIBUTE_OWNED_PROJECTS = 'CONTRIBUTE_OWNED_PROJECTS', - REMOVE_AND_REFILL_JOBS = 'REMOVE_AND_REFILL_JOBS', - MIRROR = 'MIRROR', -} - -const getPagesByActionName = (name: ActionName): Page[] => { - switch (name) { - case ActionName.CREATE_PROJECT: - return [ - { - isCancellable: true, - requiredSteps: { - 'player--hand--project': { - type: 'player--hand--project', - limit: 1, - } - }, - toggledSteps: [], - }, - { - isCancellable: true, - requiredSteps: { - 'table--active-job': { - type: 'table--active-job', - limit: 1, - } - }, - toggledSteps: [], - }, - ]; - default: - return []; - } -} - -interface StateProps { - showCancelBtn: boolean; - showConfirmBtn: boolean; - showSubmitBtn: boolean; -} - -interface DispatchProps { - onCreateProjectActionClick: () => void; - onConfirmBtnClick: () => void; - onCancelBtnClick: () => void; - onSubmitBtnClick: () => void; -} - -type Props = StateProps & DispatchProps; - -const ActionBoard: React.FC = ({ showCancelBtn, showConfirmBtn, showSubmitBtn, onCancelBtnClick, onConfirmBtnClick, onSubmitBtnClick }) => { - return ( - - {showCancelBtn && } - {showConfirmBtn && } - {showSubmitBtn && } - - ) -} - -const mapStateToProps = createSelector( - selectWizard, (wizard): StateProps => { - const showCancelBtn = isPageCancellable(wizard); - const showConfirmBtn = hasNextPage(wizard); - const showSubmitBtn = !hasNextPage(wizard); - return { - showCancelBtn, - showConfirmBtn, - showSubmitBtn, - } -}); - -const mapDispatchToProps = (dispatch: Dispatch) => { - const onCreateProjectActionClick = () => { - dispatch(wizardActions.init(getPagesByActionName(ActionName.CREATE_PROJECT))); -} -const onConfirmBtnClick = () => { - dispatch(wizardActions.nextPage()); -} -const onCancelBtnClick = () => { - dispatch(wizardActions.prevPage()); -} -const onSubmitBtnClick = () => { - dispatch(wizardActions.clear()); -} - return { - onCreateProjectActionClick, - onConfirmBtnClick, - onCancelBtnClick, - onSubmitBtnClick, - } -} - -export default connect(mapStateToProps, mapDispatchToProps)(ActionBoard); diff --git a/packages/webapp/src/components/ActionBoard/ActionStepper/ActionStepper.selectors.ts b/packages/webapp/src/components/ActionBoard/ActionStepper/ActionStepper.selectors.ts new file mode 100644 index 00000000..c59a2a11 --- /dev/null +++ b/packages/webapp/src/components/ActionBoard/ActionStepper/ActionStepper.selectors.ts @@ -0,0 +1,117 @@ +import { AppDispatch } from '@/lib/store'; +import { createSelector } from '@reduxjs/toolkit'; +import { UserActionMoves, getCurrentAction, getCurrentStep, resetAction, setActionStep, setOwnedContributionInteractive, setHandPorjectCardsInteractive, setJobSlotsInteractive, setProjectSlotsInteractive, setJoinedContributionInteractive } from '@/lib/reducers/actionStepSlice'; +import { GameContext } from '../../GameContextHelpers'; +import { getSelectedHandProjectCards, resetHandProjectCardSelection } from '@/lib/reducers/handProjectCardSlice'; +import { getSelectedJobSlots, resetJobSlotSelection } from '@/lib/reducers/jobSlotSlice'; +import { getSelectedProjectSlots, resetProjectSlotSelection } from '@/lib/reducers/projectSlotSlice'; +import { ActionMoveName, ActionMoves } from '@/game/core/stage/action/move/type'; +import { getContributions, resetContribution } from '@/lib/reducers/contributionSlice'; +import { ContributionAction, getTotalContributionValue } from '@/game/core/ContributionAction'; +import { RuleSelector } from '@/game/store/slice/rule'; + +export interface GameContextProps { + getMaxContributionValue: (actionName: ActionMoveName) => number; + onCreateProject: ActionMoves['createProject']; + onRecruit: ActionMoves['recruit']; + onContributeOwnedProjects: ActionMoves['contributeOwnedProjects']; + onContributeJoinedProjects: ActionMoves['contributeJoinedProjects']; + onRemoveAndRefillJobs: ActionMoves['removeAndRefillJobs']; + onMirror: ActionMoves['mirror']; + onEndActionTurn: () => void; +} + +export const mapGameContextToProps = (gameContext: GameContext): GameContextProps => { + const { G, events, moves } = gameContext as GameContext & { moves: ActionMoves }; + const getMaxContributionValue = (actionName: ActionMoveName) => RuleSelector.getMaxContributionValue(G.rules, actionName); + + return { + getMaxContributionValue, + onCreateProject: moves.createProject, + onRecruit: moves.recruit, + onContributeOwnedProjects: moves.contributeOwnedProjects, + onContributeJoinedProjects: moves.contributeJoinedProjects, + onRemoveAndRefillJobs: moves.removeAndRefillJobs, + onMirror: moves.mirror, + onEndActionTurn: events.endTurn!, + }; +} + +interface Step { + name: string; +} + +export interface StateProps { + steps: Step[]; + currentStep: number; + currentAction: UserActionMoves | null; + selectedHandProjectCards: string[]; + selectedJobSlots: string[]; + selectedProjectSlots: string[]; + contributions: ContributionAction[]; + totalContributionValue: number; +} + +const stepsMap: Record = { + createProject: [{name: 'Select One Hand Project Card, Select One Job Slot'}], + recruit: [{name: 'Select One Job Slot, Select One Project Slot'}], + contributeOwnedProjects: [{name: 'Contribute to Owned Projects'}], + contributeJoinedProjects: [{name: 'Contribute to Joined Projects'}], + removeAndRefillJobs: [{name: 'Select At least One Job Slot'}], + mirror: [{name: 'Select One Action Slot'}, {name: 'TBD'}], + endActionTurn: [{name: 'Confirm End Action Turn'}], +} + +export const mapStateToProps = createSelector( + getCurrentStep, + getCurrentAction, + getSelectedHandProjectCards, + getSelectedJobSlots, + getSelectedProjectSlots, + getContributions, + (currentStep, currentAction, handProjectCards, jobSlots, projectSlots, contributions): StateProps => { + const steps = currentAction ? stepsMap[currentAction] : []; + const selectedHandProjectCards = Object.keys(handProjectCards).filter(cardId => handProjectCards[cardId]); + const selectedJobSlots = Object.keys(jobSlots).filter(slotId => jobSlots[slotId]); + const selectedProjectSlots = Object.keys(projectSlots).filter(slotId => projectSlots[slotId]); + const totalContributionValue = getTotalContributionValue(contributions); + + return { + steps, + currentStep, + currentAction, + selectedHandProjectCards, + selectedJobSlots, + selectedProjectSlots, + contributions, + totalContributionValue, + }; +}); + +export interface DispatchProps { + setActionStep: (step: number) => void; + setHandPorjectCardsInteractive: () => void; + setJobSlotsInteractive: () => void; + setProjectSlotsInteractive: () => void; + setOwnedContributionInteractive: () => void; + setJoinedContributionInteractive: () => void; + resetAction: () => void; + resetHandProjectCardSelection: () => void; + resetJobSlotSelection: () => void; + resetProjectSlotSelection: () => void; + resetContribution: () => void; +} + +export const mapDispatchToProps = (dispatch: AppDispatch): DispatchProps => ({ + setActionStep: (step: number) => dispatch(setActionStep(step)), + setHandPorjectCardsInteractive: () => dispatch(setHandPorjectCardsInteractive()), + setJobSlotsInteractive: () => dispatch(setJobSlotsInteractive()), + setProjectSlotsInteractive: () => dispatch(setProjectSlotsInteractive()), + setOwnedContributionInteractive: () => dispatch(setOwnedContributionInteractive()), + setJoinedContributionInteractive: () => dispatch(setJoinedContributionInteractive()), + resetAction: () => dispatch(resetAction()), + resetHandProjectCardSelection: () => dispatch(resetHandProjectCardSelection()), + resetJobSlotSelection: () => dispatch(resetJobSlotSelection()), + resetProjectSlotSelection: () => dispatch(resetProjectSlotSelection()), + resetContribution: () => dispatch(resetContribution()), +}); diff --git a/packages/webapp/src/components/ActionBoard/ActionStepper/ActionStepper.tsx b/packages/webapp/src/components/ActionBoard/ActionStepper/ActionStepper.tsx new file mode 100644 index 00000000..ba4a6f02 --- /dev/null +++ b/packages/webapp/src/components/ActionBoard/ActionStepper/ActionStepper.tsx @@ -0,0 +1,177 @@ +import React, { useEffect } from 'react'; +import { Box, Button, Step, StepLabel, Stepper } from '@mui/material'; +import { connect } from 'react-redux'; +import { StateProps, DispatchProps, GameContextProps, mapStateToProps, mapDispatchToProps, mapGameContextToProps } from './ActionStepper.selectors'; +import { connectGameContext } from '../../GameContextHelpers'; +import { UserActionMoves } from '@/lib/reducers/actionStepSlice'; + +type Props = StateProps & DispatchProps & GameContextProps; + +const ActionStepper: React.FC = ({ + currentStep, + currentAction, + setActionStep, + resetAction, + steps, + getMaxContributionValue, + onCreateProject, + onRecruit, + onContributeJoinedProjects, + onContributeOwnedProjects, + onRemoveAndRefillJobs, + onMirror, + onEndActionTurn, + selectedHandProjectCards, + selectedJobSlots, + selectedProjectSlots, + contributions, + totalContributionValue, + setHandPorjectCardsInteractive, + setJobSlotsInteractive, + setProjectSlotsInteractive, + setOwnedContributionInteractive, + setJoinedContributionInteractive, + resetHandProjectCardSelection, + resetJobSlotSelection, + resetProjectSlotSelection, + resetContribution, +}) => { + useEffect(() => { + switch (currentAction) { + case null: + resetHandProjectCardSelection(); + resetJobSlotSelection(); + resetProjectSlotSelection(); + resetContribution(); + break; + case UserActionMoves.CreateProject: + if (currentStep === 0) { + setHandPorjectCardsInteractive(); + setJobSlotsInteractive(); + } + break; + case UserActionMoves.Recruit: + if (currentStep === 0) { + setJobSlotsInteractive(); + setProjectSlotsInteractive(); + } + break; + case UserActionMoves.ContributeOwnedProjects: + if (currentStep === 0) { + setOwnedContributionInteractive(); + } + break; + case UserActionMoves.ContributeJoinedProjects: + if (currentStep === 0) { + setJoinedContributionInteractive(); + } + break; + case UserActionMoves.RemoveAndRefillJobs: + if (currentStep === 0) { + setJobSlotsInteractive(); + } + break; + } + }, [currentAction, currentStep]); + + const handleNext = () => { + if (currentStep === steps.length - 1) { + switch (currentAction) { + case UserActionMoves.CreateProject: + onCreateProject(selectedHandProjectCards[0], selectedJobSlots[0]); + break; + case UserActionMoves.Recruit: + onRecruit(selectedJobSlots[0], selectedProjectSlots[0]); + break; + case UserActionMoves.ContributeOwnedProjects: + onContributeOwnedProjects(contributions); + break; + case UserActionMoves.ContributeJoinedProjects: + onContributeJoinedProjects(contributions); + break; + case UserActionMoves.RemoveAndRefillJobs: + onRemoveAndRefillJobs(selectedJobSlots); + break; + case UserActionMoves.EndActionTurn: + onEndActionTurn(); + break; + } + resetAction(); + } else { + setActionStep(currentStep + 1); + } + }; + + const handleBack = () => { + if (currentStep === 0) { + resetAction(); + } else { + setActionStep(currentStep - 1); + } + }; + + const getIsNextEnabled = (): boolean => { + switch (currentAction) { + case UserActionMoves.CreateProject: + return selectedHandProjectCards.length === 1 && selectedJobSlots.length === 1; + case UserActionMoves.Recruit: + return selectedJobSlots.length === 1 && selectedProjectSlots.length === 1; + case UserActionMoves.ContributeOwnedProjects: + const maxOwned = getMaxContributionValue(UserActionMoves.ContributeOwnedProjects); + return 0 < totalContributionValue && totalContributionValue <= maxOwned; + case UserActionMoves.ContributeJoinedProjects: + const maxJoined = getMaxContributionValue(UserActionMoves.ContributeJoinedProjects); + return 0 < totalContributionValue && totalContributionValue <= maxJoined; + case UserActionMoves.RemoveAndRefillJobs: + return selectedJobSlots.length > 0; + default: + return true; + } + }; + + const isNextEnabled = getIsNextEnabled(); + + const getProgressMessage = (): string => { + switch (currentAction) { + case UserActionMoves.CreateProject: + return `Select ${selectedHandProjectCards.length} Hand Project Card, Select ${selectedJobSlots.length} Job Slot`; + case UserActionMoves.Recruit: + return `Select ${selectedJobSlots.length} Job Slot, Select ${selectedProjectSlots.length} Project Slot`; + case UserActionMoves.ContributeOwnedProjects: + return `Contribute ${totalContributionValue} / ${getMaxContributionValue(UserActionMoves.ContributeOwnedProjects)} to Owned Projects`; + case UserActionMoves.ContributeJoinedProjects: + return `Contribute ${totalContributionValue} / ${getMaxContributionValue(UserActionMoves.ContributeJoinedProjects)} to Joined Projects`; + case UserActionMoves.RemoveAndRefillJobs: + return `Select ${selectedJobSlots.length} Job Slot`; + case UserActionMoves.EndActionTurn: + return 'Confirm End Action Turn'; + default: + return ''; + } + }; + + const progressMessage = getProgressMessage(); + + return !!currentAction && ( + + + {steps.map((step) => ( + + {step.name} + + ))} + + + + {progressMessage} + + + + ); +}; + +export default connectGameContext(mapGameContextToProps)(connect(mapStateToProps, mapDispatchToProps)(ActionStepper)); diff --git a/packages/webapp/src/components/BoardGame.tsx b/packages/webapp/src/components/BoardGame.tsx index 2fb57dde..5c9b9ff7 100644 --- a/packages/webapp/src/components/BoardGame.tsx +++ b/packages/webapp/src/components/BoardGame.tsx @@ -1,30 +1,45 @@ -import { Client, BoardProps } from 'boardgame.io/react'; +import { Client } from 'boardgame.io/react'; import { SocketIO, Local } from 'boardgame.io/multiplayer' -import game, { GameState } from '@/game'; +import game from '@/game'; import Table from '@/components/Table/Table'; -import Players from '@/components/Players/Players'; -import DevActions from '@/components/DevActions/DevActions'; -import ActionBoard from './ActionBoard/ActionBoard'; +import ActionBar from './ActionBoard/ActionBar/ActionBar'; +import GameHeader from './GameHeader/GameHeader'; +import UserPanel from './UserPanel/UserPanel'; +import { Box } from '@mui/material'; +import { GameContext } from './GameContextHelpers'; +import ActionStepper from './ActionBoard/ActionStepper/ActionStepper'; + +const Board: React.FC = (gameContext) => { + const { G, playerID, ctx } = gameContext; -const Board: React.FC> = (props) => { - const { G, ctx, debug } = props; return ( -
- - - - {debug && } - + + {!!playerID && } + + + {playerID === ctx.currentPlayer && <>} + +
+ + + ); +}; + +type OwnProps = { + isLocal: boolean; } -const Boardgame: React.FC<{ isLocal: boolean} & React.ComponentProps>> = ({ isLocal, ...props }) => { +type Props = OwnProps & React.ComponentProps>; + +const Boardgame: React.FC = ({ isLocal, ...props }) => { const multiplayer = isLocal ? Local() : SocketIO({ server: 'localhost:8000' }); const BoardgameComponent = Client({ game, board: Board, multiplayer, + numPlayers: 3, }) return ; } diff --git a/packages/webapp/src/components/DevActions/ContributionValueInputBox.tsx b/packages/webapp/src/components/DevActions/ContributionValueInputBox.tsx deleted file mode 100644 index 3e3293f7..00000000 --- a/packages/webapp/src/components/DevActions/ContributionValueInputBox.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { Box, Stack, TextField } from "@mui/material"; -import React from "react"; - -interface Props { - value: number; - onChange: (event: React.ChangeEvent) => void; -} - -const ContributionValueInputBox: React.FC = ({ value, onChange }) => ( - - Value - - -); - -export default ContributionValueInputBox; diff --git a/packages/webapp/src/components/DevActions/DevActions.tsx b/packages/webapp/src/components/DevActions/DevActions.tsx deleted file mode 100644 index 04207917..00000000 --- a/packages/webapp/src/components/DevActions/DevActions.tsx +++ /dev/null @@ -1,184 +0,0 @@ -'use client'; -import { useCallback, useState } from 'react'; -import { BoardProps } from 'boardgame.io/react'; -import { - Stack, - Button, - Box, - Tab, -} from '@mui/material'; -import { TabContext, TabList, TabPanel } from '@mui/lab'; -import { GameState } from '@/game'; -import { AllMoves } from '@/game/moves/type'; -import HandProjectCards from './HandProjectCards'; -import JobSlots from './JobSlots'; -import ProjectBoard from './ProjectBoard'; -import ContributionValueInputBox from './ContributionValueInputBox'; - -const DevActions: React.FC> = (props) => { - const { G, playerID, moves: nonTypeMoves, events, ctx } = props; - const moves = nonTypeMoves as unknown as AllMoves; - - const [tabValue, setTabValue] = useState('create-project'); - const onTabChange = (event: React.SyntheticEvent, newValue: string) => { - setTabValue(newValue); - }; - - const [projectCardIndex, setProjectCardIndex] = useState(0); - const [activeJobCardIndex, setActiveJobCardIndex] = useState(0); - const [activeProjectIndex, setActiveProjectIndex] = useState(0); - const [jobName, setJobName] = useState(''); - const [value, setValue] = useState(0); - const [contributions, setContributions] = useState<{ activeProjectIndex: number; jobName: string; value: number }[]>([]); - const onAddContribution = useCallback(() => { - if (activeProjectIndex >= 0 && jobName !== '' && value > 0) { - setContributions(cons => { - return [...cons, { activeProjectIndex, jobName, value }]; - }); - } - }, [activeProjectIndex, jobName, value]); - - if (playerID === null) { - return null; - } - - const onCreateProject = () => moves.createProject(projectCardIndex, activeJobCardIndex); - const onRecruit = () => moves.recruit(activeJobCardIndex, activeProjectIndex); - const onContributeJoinedProjects = () => { - moves.contributeJoinedProjects(contributions); - setContributions([]); - }; - const onContributeOwnedProjects = () => { - moves.contributeOwnedProjects(contributions); - setContributions([]); - }; - const onEndAction = () => events.endStage!(); - const onEndSettle = () => events.endStage!(); - const onRefillAndEnd = () => moves.refillAndEnd(); - const myCurrentStage = ctx.activePlayers ? ctx.activePlayers[playerID] : '' - - return ( -
- {myCurrentStage ?
my current stage: {myCurrentStage}
: null} - { - myCurrentStage === 'action' && - - - - - - - - - - - - - setProjectCardIndex(parseInt(event.target.value))} - value={projectCardIndex} - /> - setActiveJobCardIndex(parseInt(event.target.value))} - value={activeJobCardIndex} - /> - - - - - - setActiveProjectIndex(parseInt(event.target.value))} - projectValue={activeProjectIndex} - onJobNameChange={event => setJobName(event.target.value)} - jobNameValue={jobName} - /> - setActiveJobCardIndex(parseInt(event.target.value))} - value={activeJobCardIndex} - /> - - - - - - setActiveProjectIndex(parseInt(event.target.value))} - projectValue={activeProjectIndex} - onJobNameChange={event => setJobName(event.target.value)} - jobNameValue={jobName} - /> - setValue(parseInt(event.target.value))} - /> - - current contribution entities: {JSON.stringify(contributions)} - - - - - - setActiveProjectIndex(parseInt(event.target.value))} - projectValue={activeProjectIndex} - onJobNameChange={event => setJobName(event.target.value)} - jobNameValue={jobName} - /> - setValue(parseInt(event.target.value))} - /> - - current contribution entities: {JSON.stringify(contributions)} - - - - - - setActiveJobCardIndex(parseInt(event.target.value))} - value={activeJobCardIndex} - /> - - - - - - - - } - { - myCurrentStage === 'settle' && - -
- -
-
- } - { - myCurrentStage === 'discard' && - -
- -
-
- } - { - myCurrentStage === 'refill' && - - - - } -
- ); -} - -export default DevActions; diff --git a/packages/webapp/src/components/DevActions/HandProjectCards.tsx b/packages/webapp/src/components/DevActions/HandProjectCards.tsx deleted file mode 100644 index a69cdd8e..00000000 --- a/packages/webapp/src/components/DevActions/HandProjectCards.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { ProjectCard } from '@/game'; -import { - Stack, - Box, - RadioGroup, - Radio, - FormControlLabel, -} from '@mui/material'; - -interface Props { - projects: ProjectCard[]; - onChange: (event: React.ChangeEvent) => void; - value: number; -} - -const HandProjectCards: React.FC = ({ projects: projectCardss, onChange, value }) => ( - - Hand project cards - - { - projectCardss.map((projectCard, index) => - <> - } - label={projectCard.name} - /> - Job requirements: - { - Object.keys(projectCard.requirements).map(jobName => ( - - {jobName}: {projectCard.requirements[jobName]} - - )) - } - - ) - } - - -); - -export default HandProjectCards; diff --git a/packages/webapp/src/components/DevActions/JobSlots.tsx b/packages/webapp/src/components/DevActions/JobSlots.tsx deleted file mode 100644 index d7fb595f..00000000 --- a/packages/webapp/src/components/DevActions/JobSlots.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { JobSlotsState } from '@/game'; -import { - Stack, - Box, - RadioGroup, - Radio, - FormControlLabel, -} from '@mui/material'; - -interface Props { - jobSlots: JobSlotsState; - onChange: (event: React.ChangeEvent) => void; - value: number; -} - -const JobSlots: React.FC = ({ jobSlots, onChange, value }) => ( - - Active job cards - - { - jobSlots.map((job, index) => - } label={job.name} />) - } - - -); - -export default JobSlots; diff --git a/packages/webapp/src/components/DevActions/ProjectBoard.tsx b/packages/webapp/src/components/DevActions/ProjectBoard.tsx deleted file mode 100644 index 69237e63..00000000 --- a/packages/webapp/src/components/DevActions/ProjectBoard.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { - Stack, - Box, - RadioGroup, - Radio, - FormControlLabel, -} from '@mui/material'; -import { ProjectBoardState } from "@/game"; - -interface Props { - projectBoard: ProjectBoardState; - onProjectSlotChange: (event: React.ChangeEvent) => void; - projectValue: number; - onJobNameChange: (event: React.ChangeEvent) => void; - jobNameValue: string; -} - -const ProjectBoard: React.FC = ({ projectBoard, onProjectSlotChange, projectValue, onJobNameChange, jobNameValue }) => ( - - Active project cards - - { - projectBoard.map(project => project.card).map((projectCard, index) => - <> - } - label={projectCard.name} - /> - Job requirements: - { - (projectValue === index) && ( - - { - Object.keys(projectCard.requirements).map((jobName) => - } - label={jobName} - /> - ) - } - - ) - || - Object.keys(projectCard.requirements).map(jobName => ( - - {jobName}: {projectCard.requirements[jobName]} - - )) - } - - ) - } - - -); - -export default ProjectBoard; diff --git a/packages/webapp/src/components/DevView.tsx b/packages/webapp/src/components/DevView.tsx index 4e7a7d7a..a2e0e43c 100644 --- a/packages/webapp/src/components/DevView.tsx +++ b/packages/webapp/src/components/DevView.tsx @@ -1,25 +1,49 @@ 'use client'; -import { Box, Typography } from '@mui/material'; +import React, { useState } from 'react'; +import { Box, Tabs, Tab } from '@mui/material'; import Boardgame from '@/components/BoardGame'; +import { playerNameMap } from './playerNameMap'; +import TabPanel from './TabPanel'; + +function a11yProps(index: number) { + return { + id: `tab-${index}`, + 'aria-controls': `tabpanel-${index}`, + }; +} + +const DevView: React.FC<{ isLocal: boolean }> = ({ isLocal }) => { + const [value, setValue] = useState(0); + + const handleChange = (event: React.SyntheticEvent, newValue: number) => { + setValue(newValue); + }; -function DevView({ isLocal }: { isLocal: boolean }) { return ( <> - - Player 0 view - + + + + + + + - - Player 1 view + + + + - - - Observer view + + + + + - + ); -} +}; export default DevView; diff --git a/packages/webapp/src/components/GameContextHelpers/connectGameContext.tsx b/packages/webapp/src/components/GameContextHelpers/connectGameContext.tsx new file mode 100644 index 00000000..bea8cc2b --- /dev/null +++ b/packages/webapp/src/components/GameContextHelpers/connectGameContext.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import { GameState } from "@/game"; +import { BoardProps } from 'boardgame.io/react'; + +export type GameContext = BoardProps; +export type GameContextComponentProps = { gameContext: GameContext; }; +export type MapGameContextToProps = (context: GameContext, ownProps: TOwnProps) => TProps; + +export const connectGameContext = (mapGameContextToProps: MapGameContextToProps) => (Component: React.FC) => { + const GameContextComponent: React.FC = ({ gameContext, ...restProps }) => { + const ownProps = restProps as unknown as TOwnProps; + const props = mapGameContextToProps(gameContext, ownProps); + return ; + }; + + return GameContextComponent; +}; diff --git a/packages/webapp/src/components/GameContextHelpers/index.ts b/packages/webapp/src/components/GameContextHelpers/index.ts new file mode 100644 index 00000000..8141b385 --- /dev/null +++ b/packages/webapp/src/components/GameContextHelpers/index.ts @@ -0,0 +1,2 @@ +export { connectGameContext } from './connectGameContext'; +export type { GameContext, GameContextComponentProps, MapGameContextToProps } from './connectGameContext'; diff --git a/packages/webapp/src/components/GameHeader/GameHeader.tsx b/packages/webapp/src/components/GameHeader/GameHeader.tsx new file mode 100644 index 00000000..a91c1f53 --- /dev/null +++ b/packages/webapp/src/components/GameHeader/GameHeader.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import Grid from '@mui/material/Grid'; +import PlayerStatus from './PlayerStatus'; +import { PlayersState, ScoreBoardState } from '@/game'; +import { playerNameMap } from '../playerNameMap'; +import { PlayersSelector } from '@/game/store/slice/players'; +import { ScoreBoardSelector } from '@/game/store/slice/scoreBoard'; + +type GameHeaderProps = { + players: PlayersState; + scoreBoard: ScoreBoardState; +}; + +const GameHeader: React.FC = ({ players, scoreBoard }) => { + return ( + + {Object.keys(players).map((id) => ( + + + + ))} + + ); +}; + +export default GameHeader; diff --git a/packages/webapp/src/components/GameHeader/PlayerStatus.tsx b/packages/webapp/src/components/GameHeader/PlayerStatus.tsx new file mode 100644 index 00000000..b155f33b --- /dev/null +++ b/packages/webapp/src/components/GameHeader/PlayerStatus.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import Grid from '@mui/material/Grid'; +import Typography from '@mui/material/Typography'; + +type PlayerStatusProps = { + name: string; + workerTokens: number; + actionTokens: number; + score: number; +}; + +const PlayerStatus: React.FC = ({ name, workerTokens, actionTokens, score }) => { + return ( + + + {name} + + + Workers: {workerTokens} + + + Actions: {actionTokens} + + + Score: {score} + + + ); +}; + +export default PlayerStatus; diff --git a/packages/webapp/src/components/Players/Players.tsx b/packages/webapp/src/components/Players/Players.tsx deleted file mode 100644 index 25814dd7..00000000 --- a/packages/webapp/src/components/Players/Players.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { List, ListItem, Typography, Stack } from '@mui/material'; -import { PlayersState } from '@/game'; - -interface Props { - players: PlayersState; -} - -const Players: React.FC = (props) => { - const players = Object.keys(props.players).map(player => ( - - Player {player} - - - WorkerTokens: {props.players[player].token.workers} - - - ActionTokens: {props.players[player].token.actions} - - - CompletedProjects: {JSON.stringify(props.players[player].completed.projects)} - - - - )); - - return ( - <> - Players - - {players} - - - ); -} - -export default Players; diff --git a/packages/webapp/src/components/ProjectBoard/ProjectSlot.tsx b/packages/webapp/src/components/ProjectBoard/ProjectSlot.tsx deleted file mode 100644 index 0bae4e46..00000000 --- a/packages/webapp/src/components/ProjectBoard/ProjectSlot.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { ProjectSlotState } from '@/game'; -import { - Box, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, - Typography, - Paper, -} from '@mui/material'; - -type Props = { - project?: ProjectSlotState; -}; - -const ProjectSlot: React.FC = ({ project }) => { - const projectName = project?.card.name; - const headRow = ( - - 玩家 - 職業 - 貢獻 - 進度 - - ); - - const workerRows = project?.contributions.map(worker => - - {worker.worker} - {worker.jobName} - {worker.value} - {project.card.requirements[worker.jobName]} - - ) - - return ( - - -
- - - - {projectName} - - - {headRow} - - - {workerRows} - - - {headRow} - -
- - - ) -} - -export default ProjectSlot; diff --git a/packages/webapp/src/components/TabPanel.tsx b/packages/webapp/src/components/TabPanel.tsx new file mode 100644 index 00000000..cc67ac6e --- /dev/null +++ b/packages/webapp/src/components/TabPanel.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import Box from '@mui/material/Box'; + +interface TabPanelProps { + children?: React.ReactNode; + index: number; + value: number; +} + +const TabPanel: React.FC = ({ children, value, index, ...other }) => { + return ( + + ); +}; + +export default TabPanel; diff --git a/packages/webapp/src/components/Table/JobBoard/JobCard.tsx b/packages/webapp/src/components/Table/JobBoard/JobCard.tsx new file mode 100644 index 00000000..4f854cf1 --- /dev/null +++ b/packages/webapp/src/components/Table/JobBoard/JobCard.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import Paper from '@mui/material/Paper'; +import Typography from '@mui/material/Typography'; +import { styled } from '@mui/material/styles'; +import { useAppDispatch, useAppSelector } from '@/lib/hooks'; +import { getSelectedJobSlots, toggleJobSlotSelection } from '@/lib/reducers/jobSlotSlice'; +import { Avatar } from '../../common/Avatar'; +import { isJobSlotsInteractive } from '@/lib/reducers/actionStepSlice'; + +type JobCardProps = { + id: string; + title: string; +}; + +const StyledPaper = styled(Paper)(({ theme }) => ({ + border: '1px solid black', + cursor: 'pointer', + padding: '16px', + position: 'relative', + display: 'flex', + alignItems: 'center', + '&:hover': { + backgroundColor: theme.palette.action.hover, + }, + '&.selected': { + backgroundColor: theme.palette.action.selected, + }, +})); + +const JobCard: React.FC = ({ id, title }) => { + const dispatch = useAppDispatch(); + const isInteractive = useAppSelector(isJobSlotsInteractive); + const selectedCards = useAppSelector(getSelectedJobSlots); + const selected = isInteractive && !!selectedCards[id]; + + const handleSelect = () => { + if (!isInteractive) return; + dispatch(toggleJobSlotSelection(id)); + }; + + return ( + + + {title} + + ); +}; + +export default JobCard; diff --git a/packages/webapp/src/components/Table/JobBoard/JobSlots.tsx b/packages/webapp/src/components/Table/JobBoard/JobSlots.tsx new file mode 100644 index 00000000..0459655f --- /dev/null +++ b/packages/webapp/src/components/Table/JobBoard/JobSlots.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import Grid from '@mui/material/Grid'; +import JobCard from './JobCard'; +import { JobSlotsState } from '@/game'; + +type JobCardListProps = { + jobs: JobSlotsState; +}; + +const JobSlots: React.FC = ({ jobs }) => { + return ( + + {jobs.map((job) => ( + + + + ))} + + ); +}; + +export default JobSlots; diff --git a/packages/webapp/src/components/Table/ProjectBoard/Contribution.tsx b/packages/webapp/src/components/Table/ProjectBoard/Contribution.tsx new file mode 100644 index 00000000..e2c30ef1 --- /dev/null +++ b/packages/webapp/src/components/Table/ProjectBoard/Contribution.tsx @@ -0,0 +1,61 @@ +import React, { useEffect, useState } from 'react'; +import { Box } from '@mui/material'; +import { PlayerID } from 'boardgame.io'; +import { IconButton } from '@mui/material'; +import AddCircleOutlinedIcon from '@mui/icons-material/AddCircleOutlined'; +import RemoveCircleOutlinedIcon from '@mui/icons-material/RemoveCircleOutlined'; +import ContributionAvatarWithPlayerBadge from '@/components/common/ContributionAvatarWithPlayerBadge'; +import { playerNameMap } from '@/components/playerNameMap'; + +interface Props { + worker: PlayerID + initialValue: number; + min: number; + max: number; + isInteractive: boolean; + onChange: (value: number) => void; +} + +const Contribution: React.FC = ({ worker, initialValue, min, max, isInteractive, onChange }) => { + const [value, setValue] = useState(initialValue); + + useEffect(() => { + setValue(initialValue); + }, [isInteractive, initialValue]); + + const handleIncrement = () => { + if (value < max) { + const newValue = value + 1; + setValue(newValue); + onChange(newValue); + } + }; + + const handleDecrement = () => { + if (value > min) { + const newValue = value - 1; + setValue(newValue); + onChange(newValue); + } + }; + + const displayValue = isInteractive ? value : initialValue; + + return ( + + {isInteractive && ( + + + + )} + + {isInteractive &&( + + + + )} + + ); +}; + +export default Contribution; diff --git a/packages/webapp/src/components/Table/ProjectBoard/JobAndContributions.tsx b/packages/webapp/src/components/Table/ProjectBoard/JobAndContributions.tsx new file mode 100644 index 00000000..0eed0606 --- /dev/null +++ b/packages/webapp/src/components/Table/ProjectBoard/JobAndContributions.tsx @@ -0,0 +1,42 @@ +import Grid from '@mui/material/Grid'; +import Box from '@mui/material/Box'; +import JobNameAvatarWithContributionBadges from '@/components/common/JobNameAvatarWithContributionBadges'; +import { ProjectContribution } from '@/game/store/slice/projectSlot/projectSlot'; +import { PlayerID } from 'boardgame.io'; +import Contribution from './Contribution'; + +interface Props { + jobName: string; + requirements: number; + contributions: ProjectContribution[]; + interactivePlayers: Record; + onContributionChange: (jobName: string, diffAmount: number) => void; +} + +export const JobAndContributions: React.FC = ({ jobName, requirements, contributions, interactivePlayers, onContributionChange }) => { + const totalJobContributions = contributions.filter((contribution) => contribution.jobName === jobName).reduce((acc, contribution) => acc + contribution.value, 0); + + return ( + + + + + {contributions.map((contribution) => ( + { + const diffAmount = value - contribution.value; + onContributionChange(jobName, diffAmount); + }} /> + ))} + + ); +}; + +export default JobAndContributions; diff --git a/packages/webapp/src/components/Table/ProjectBoard/ProjectBoard.tsx b/packages/webapp/src/components/Table/ProjectBoard/ProjectBoard.tsx new file mode 100644 index 00000000..1d3af043 --- /dev/null +++ b/packages/webapp/src/components/Table/ProjectBoard/ProjectBoard.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import Grid from '@mui/material/Grid'; +import ProjectSlot from './ProjectSlot'; +import { ProjectSlotState } from '@/game'; + +type ProjectBoardProps = { + slots: ProjectSlotState[]; + playerID: string | null; +}; + +const ProjectBoard: React.FC = ({ slots, playerID }) => { + return ( + + {slots.map((slot) => ( + + + + ))} + + ); +}; + +export default ProjectBoard; diff --git a/packages/webapp/src/components/Table/ProjectBoard/ProjectSlot.selectors.ts b/packages/webapp/src/components/Table/ProjectBoard/ProjectSlot.selectors.ts new file mode 100644 index 00000000..f4c20e62 --- /dev/null +++ b/packages/webapp/src/components/Table/ProjectBoard/ProjectSlot.selectors.ts @@ -0,0 +1,41 @@ +import { JobName, ProjectSlotID } from "@/game"; +import { ContributionAction } from "@/game/core/ContributionAction"; +import { isJoinedContributionInteractive, isOwnedContributionInteractive, isProjectSlotsInteractive } from "@/lib/reducers/actionStepSlice"; +import { getContributions, updateContribute } from "@/lib/reducers/contributionSlice"; +import { getSelectedProjectSlots, toggleProjectSlotSelection } from "@/lib/reducers/projectSlotSlice"; +import { AppDispatch } from "@/lib/store"; +import { createSelector } from "@reduxjs/toolkit"; + +export interface StateProps { + isInteractive: boolean; + selectedSlots: Record; + isOwnedInteractive: boolean; + isJoinedInteractive: boolean; +} + +export const mapStateToProps = createSelector( + isProjectSlotsInteractive, + getSelectedProjectSlots, + isOwnedContributionInteractive, + isJoinedContributionInteractive, + (isInteractive, selectedSlots, isOwnedInteractive, isJoinedInteractive): StateProps => ({ + isInteractive, + selectedSlots, + isOwnedInteractive, + isJoinedInteractive, + }) +); + +export interface DispatchProps { + toggleProjectSlotSelection: (slotId: ProjectSlotID) => void; + updateContribution: (slotId: ProjectSlotID, jobName: JobName, diffAmount: number) => void; +} + +export const mapDispatchToProps = (dispatch: AppDispatch): DispatchProps => ({ + toggleProjectSlotSelection: (slotId) => { + dispatch(toggleProjectSlotSelection(slotId)); + }, + updateContribution: (slotId, jobName, diffAmount) => { + dispatch(updateContribute({ slotId, jobName, diffAmount })); + }, +}); diff --git a/packages/webapp/src/components/Table/ProjectBoard/ProjectSlot.tsx b/packages/webapp/src/components/Table/ProjectBoard/ProjectSlot.tsx new file mode 100644 index 00000000..6cc3eed1 --- /dev/null +++ b/packages/webapp/src/components/Table/ProjectBoard/ProjectSlot.tsx @@ -0,0 +1,96 @@ +import React from 'react'; +import { ProjectSlotState } from '@/game'; +import Avatar from '@mui/material/Avatar'; +import Box from '@mui/material/Box'; +import Grid from '@mui/material/Grid'; +import Paper from '@mui/material/Paper'; +import Typography from '@mui/material/Typography'; +import Chip from '@mui/material/Chip'; +import { styled } from '@mui/material/styles'; +import { playerNameMap } from '../../playerNameMap'; +import { JobAndContributions } from './JobAndContributions'; +import { PlayerID } from 'boardgame.io'; +import { DispatchProps, mapDispatchToProps, mapStateToProps, StateProps } from './ProjectSlot.selectors'; +import { connect } from 'react-redux'; + +type OwnProps = { + slot: ProjectSlotState; + playerID: PlayerID | null; +}; + +type Props = OwnProps & StateProps & DispatchProps; + +const StyledPaper = styled(Paper)(({ theme }) => ({ + minHeight: '200px', + border: '1px solid black', + cursor: 'pointer', + padding: '16px', + position: 'relative', + '&:hover': { + backgroundColor: theme.palette.action.hover, + }, + '&.selected': { + backgroundColor: theme.palette.action.selected, + }, +})); + +const ProjectSlot: React.FC = ({ + slot, + playerID, + isInteractive, + isJoinedInteractive, + isOwnedInteractive, + selectedSlots, + toggleProjectSlotSelection, + updateContribution, +}) => { + const selected = isInteractive && !!selectedSlots[slot.id]; + + const onProjectSlotClick = () => { + if (!isInteractive) return; + toggleProjectSlotSelection(slot.id); + }; + + const projectName = slot.card?.name; + const projectType = slot.card?.type; + const owner = playerNameMap[slot.owner]; + const requirements = slot.card?.requirements || {}; + const requiredJobs = Object.keys(slot.card?.requirements || []); + + const interactivePlayers = playerID === null ? {} : { + [playerID!]: (isOwnedInteractive && slot.owner === playerID) || (isJoinedInteractive && slot.owner !== playerID), + }; + + const onContributionChange = (jobName: string, diffAmount: number) => { + updateContribution(slot.id, jobName, diffAmount); + }; + + return ( + + + + {owner} + {projectName} + + {!!projectType && ()} + + {requiredJobs.map((jobName) => ( + contribution.jobName === jobName)} + interactivePlayers={interactivePlayers} + onContributionChange={onContributionChange} + /> + ))} + + + + ); +}; + +export default connect(mapStateToProps, mapDispatchToProps)(ProjectSlot); diff --git a/packages/webapp/src/components/Table/Table.tsx b/packages/webapp/src/components/Table/Table.tsx index 3985a21d..2a831277 100644 --- a/packages/webapp/src/components/Table/Table.tsx +++ b/packages/webapp/src/components/Table/Table.tsx @@ -1,26 +1,27 @@ -import { Box, Grid } from '@mui/material'; -import ProjectSlot from '@/components/ProjectBoard/ProjectSlot'; +import { Grid, Typography } from '@mui/material'; import { TableState } from '@/game'; +import ProjectBoard from './ProjectBoard/ProjectBoard'; +import JobSlots from './JobBoard/JobSlots'; +import { PlayerID } from 'boardgame.io'; interface Props { table: TableState; + playerID: PlayerID | null; } const Table: React.FC = (props) => { - const maxActiveProjects = 6; - const activeProjects = [...props.table.projectBoard, ...Array(maxActiveProjects)].slice(0, maxActiveProjects); - return ( - - - {activeProjects.map((p, pIndex) => ( - - - - ))} + + + Project Slots + - - ) -} + + Job Slots + + + + ); +}; export default Table; diff --git a/packages/webapp/src/components/UserPanel/HandProjectCard.tsx b/packages/webapp/src/components/UserPanel/HandProjectCard.tsx new file mode 100644 index 00000000..c8bfae09 --- /dev/null +++ b/packages/webapp/src/components/UserPanel/HandProjectCard.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import Grid from '@mui/material/Grid'; +import Typography from '@mui/material/Typography'; +import { ProjectCard } from '@/game'; +import { getSelectedHandProjectCards, toggleHandProjectCardSelection } from '@/lib/reducers/handProjectCardSlice'; +import { Chip, Paper, styled } from '@mui/material'; +import JobNameAvatarWithContributionBadges from '../common/JobNameAvatarWithContributionBadges'; +import { useAppDispatch, useAppSelector } from '@/lib/hooks'; +import { isHandProjectCardsInteractive } from '@/lib/reducers/actionStepSlice'; + +const SelectablePaper = styled(Paper)(({ theme }) => ({ + padding: '16px', + display: 'flex', + flexDirection: 'column', // Ensures children are stacked vertically + alignItems: 'flex-start', // Align children to the start + cursor: 'pointer', + '&:hover': { + backgroundColor: theme.palette.action.hover, + }, + '&.selected': { + backgroundColor: theme.palette.action.selected, + }, +})); + +const HandProjectCard: React.FC<{ card: ProjectCard; }> = ({ card }) => { + const dispatch = useAppDispatch(); + const selectedProjectCards = useAppSelector(getSelectedHandProjectCards); + const isInteractive = useAppSelector(isHandProjectCardsInteractive); + const selected = isInteractive && !!selectedProjectCards[card.id]; + + const handleSelect = (cardId: string) => { + if (!isInteractive) return; + dispatch(toggleHandProjectCardSelection(cardId)); + }; + + return ( + + handleSelect(card.id)} + className={selected ? 'selected' : ''} + > + + {card.name} {/* Emphasized title */} + {!!card.type && ( + + )} + + + {Object.keys(card.requirements).map((jobName) => ( + + + + ))} + + + + ); +}; + +export default HandProjectCard; diff --git a/packages/webapp/src/components/UserPanel/UserPanel.tsx b/packages/webapp/src/components/UserPanel/UserPanel.tsx new file mode 100644 index 00000000..f5e55920 --- /dev/null +++ b/packages/webapp/src/components/UserPanel/UserPanel.tsx @@ -0,0 +1,77 @@ +import React from 'react'; +import Grid from '@mui/material/Grid'; +import Paper from '@mui/material/Paper'; +import Typography from '@mui/material/Typography'; +import { styled } from '@mui/material/styles'; +import { ProjectCard } from '@/game'; +import { GameContext, connectGameContext } from '../GameContextHelpers'; +import { playerNameMap } from '../playerNameMap'; +import { PlayersSelector } from '@/game/store/slice/players'; +import { ScoreBoardSelector } from '@/game/store/slice/scoreBoard'; +import HandProjectCard from './HandProjectCard'; + +type UserPanelProps = { + userName: string; + workerTokens: number; + actionTokens: number; + score: number; + projectCards: ProjectCard[]; +}; + +const StyledPaper = styled(Paper)(({ theme }) => ({ + padding: theme.spacing(2), + height: '100vh', // Full height + overflowY: 'auto', // Scroll if content overflows + width: '300px', // Fixed width +})); + +const UserPanel: React.FC = ({ userName, workerTokens, actionTokens, score, projectCards }) => { + + return ( + + + + {userName} + + + Workers: {workerTokens} + + + Actions: {actionTokens} + + + Score: {score} + + + Project Cards + + + {projectCards.map((card) => ( + + ))} + + + + ); +}; + +const mapGameContextToProps = ({ G, playerID }: GameContext): UserPanelProps => { + // only player will see user panel + playerID = playerID!; + + const userName = playerNameMap[playerID]; + const actionTokens = PlayersSelector.getNumActionTokens(G.players, playerID); + const workerTokens=PlayersSelector.getNumWorkerTokens(G.players, playerID); + const score=ScoreBoardSelector.getPlayerPoints(G.table.scoreBoard, playerID); + const projectCards= PlayersSelector.getProjectCards(G.players, playerID); + + return { + userName, + workerTokens, + actionTokens, + score, + projectCards, + }; +} + +export default connectGameContext(mapGameContextToProps)(UserPanel); diff --git a/packages/webapp/src/components/common/Avatar.tsx b/packages/webapp/src/components/common/Avatar.tsx new file mode 100644 index 00000000..42495a4a --- /dev/null +++ b/packages/webapp/src/components/common/Avatar.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import MuiAvatar from '@mui/material/Avatar'; +import { styled } from '@mui/material/styles'; + +export type Size = 'small' | 'medium' | 'large'; + +const sizeStyles = { + small: { + width: '24px', + height: '24px', + fontSize: '12px', + }, + medium: { + width: '40px', + height: '40px', + fontSize: '14px', + }, + large: { + width: '56px', + height: '56px', + fontSize: '16px', + }, +}; + +const StyledAvatar = styled(MuiAvatar)<{ size: Size }>(({ size }) => ({ + ...sizeStyles[size], + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + textAlign: 'center', + whiteSpace: 'nowrap', // Prevent text from wrapping + writingMode: 'horizontal-tb', // Ensure horizontal text direction + lineHeight: 'normal', // Ensure normal line height +})); + +export const Avatar: React.FC<{ title: string; size?: Size }> = ({ title, size = 'medium' }) => ( + + {title.slice(0, 2)} + +); diff --git a/packages/webapp/src/components/common/ContributionAvatarWithPlayerBadge.tsx b/packages/webapp/src/components/common/ContributionAvatarWithPlayerBadge.tsx new file mode 100644 index 00000000..e6879bb0 --- /dev/null +++ b/packages/webapp/src/components/common/ContributionAvatarWithPlayerBadge.tsx @@ -0,0 +1,16 @@ + +import { Avatar, AvatarProps, Badge } from '@mui/material'; + +interface Props { + playerID: string; + contributions: number; + sizes?: AvatarProps['sizes']; +} + +const ContributionAvatarWithPlayerBadge: React.FC = ({ playerID, contributions, sizes }) => ( + + {contributions} + +); + +export default ContributionAvatarWithPlayerBadge; diff --git a/packages/webapp/src/components/common/JobNameAvatarWithContributionBadges.tsx b/packages/webapp/src/components/common/JobNameAvatarWithContributionBadges.tsx new file mode 100644 index 00000000..bea387a8 --- /dev/null +++ b/packages/webapp/src/components/common/JobNameAvatarWithContributionBadges.tsx @@ -0,0 +1,19 @@ +import { Avatar, Size } from '@/components/common/Avatar'; +import { Badge } from '@mui/material'; + +interface Props { + jobTitle: string; + requirements: number; + totalContributions?: number; + size?: Size; +} + +const JobNameAvatarWithContributionBadges: React.FC = ({ jobTitle, requirements, totalContributions = 0, size }) => ( + + + + + +); + +export default JobNameAvatarWithContributionBadges; diff --git a/packages/webapp/src/components/playerNameMap.ts b/packages/webapp/src/components/playerNameMap.ts new file mode 100644 index 00000000..4283736f --- /dev/null +++ b/packages/webapp/src/components/playerNameMap.ts @@ -0,0 +1,7 @@ +import { PlayerID } from "boardgame.io"; + +export const playerNameMap: Record = { + 0: 'Alice', + 1: 'Bob', + 2: 'Charlie', +}; diff --git a/packages/webapp/src/game/card.d.ts b/packages/webapp/src/game/card.d.ts index 884c1ef0..809d027d 100644 --- a/packages/webapp/src/game/card.d.ts +++ b/packages/webapp/src/game/card.d.ts @@ -1,12 +1,25 @@ -export type BaseCard = { name: string }; - -export type ProjectCard = BaseCard & { +export type ProjectName = string; +export type ProjectCard = { + id: string; + name: ProjectName; + type: string; + difficulty: number; + description: string; requirements: Record; }; -export type JobCard = BaseCard; +export type JobCard = { + id: string; + name: JobName; +}; export type JobName = string; -export type ForceCard = BaseCard; - -export type EventCard = BaseCard; +export type EventName = string; +export type EventFunctionName = string; +export type EventCard = { + id: string; + name: EventName; + description: string; + function_name: EventFunctionName; + type: string; +}; diff --git a/packages/webapp/src/game/core/ContributionAction.ts b/packages/webapp/src/game/core/ContributionAction.ts new file mode 100644 index 00000000..85dbda90 --- /dev/null +++ b/packages/webapp/src/game/core/ContributionAction.ts @@ -0,0 +1,13 @@ +import { JobName } from "../card"; +import { ProjectSlotID } from "../store/slice/projectSlot/projectSlot"; + + +export interface ContributionAction { + jobName: JobName; + value: number; + projectSlotId: ProjectSlotID; +} + +export const getTotalContributionValue = (contributions: ContributionAction[]): number => { + return contributions.map(({ value }) => value).reduce((a, b) => a + b, 0); +}; diff --git a/packages/webapp/src/game/core/handler/eventCardHandlers.ts b/packages/webapp/src/game/core/handler/eventCardHandlers.ts new file mode 100644 index 00000000..bf663885 --- /dev/null +++ b/packages/webapp/src/game/core/handler/eventCardHandlers.ts @@ -0,0 +1,23 @@ +import { RuleMutator } from "@/game/store/slice/rule"; +import { GameHookHandler } from "../type" +import { ScoreBoardSelector } from "@/game/store/slice/scoreBoard"; + +type EventCardHandler = { + start: GameHookHandler; + end?: GameHookHandler; +} + +const endGameAfterThisRound: EventCardHandler = { + start: ({ G }) => { + // Leftover action tokens are converted to 1 victory points + RuleMutator.setSettlementLastContributorVictoryPoints(G.rules, 1); + }, + end: ({ G, events }) => { + RuleMutator.setSettlementLastContributorVictoryPoints(G.rules, 0); + events.endGame({ winner: ScoreBoardSelector.getWinner(G.table.scoreBoard) }); + }, +} + +export const eventCardHandlers: Record = { + end_game_after_this_round: endGameAfterThisRound, +}; diff --git a/packages/webapp/src/game/core/handler/passStartPlayerToken.ts b/packages/webapp/src/game/core/handler/passStartPlayerToken.ts new file mode 100644 index 00000000..d0c1543b --- /dev/null +++ b/packages/webapp/src/game/core/handler/passStartPlayerToken.ts @@ -0,0 +1,6 @@ +import { GameHookHandler } from "../type"; + +export const passStartPlayerToken: GameHookHandler = ({ ctx }) => { + console.log('pass start player token to next player'); + ctx.playOrder = ctx.playOrder.slice(1).concat(ctx.playOrder[0]); +}; diff --git a/packages/webapp/src/game/core/handler/playEventCard.ts b/packages/webapp/src/game/core/handler/playEventCard.ts new file mode 100644 index 00000000..fd4cd82a --- /dev/null +++ b/packages/webapp/src/game/core/handler/playEventCard.ts @@ -0,0 +1,15 @@ +import { DeckMutator, DeckSelector } from "@/game/store/slice/deck"; +import { GameHookHandler } from "../type"; +import { TableMutator } from "@/game/store/slice/table"; +import { eventCardHandlers } from "./eventCardHandlers"; + +export const playEventCard: GameHookHandler = (context) => { + const { G } = context; + const eventCard = DeckSelector.peek(G.decks.events, 1)[0]; + DeckMutator.draw(G.decks.events, 1); + console.log('play event card', eventCard.name); + TableMutator.playEvent(G.table, eventCard); + + const eventCardHandler = eventCardHandlers[eventCard.function_name]; + eventCardHandler?.start(context); +}; diff --git a/packages/webapp/src/game/core/handler/refill.ts b/packages/webapp/src/game/core/handler/refill.ts new file mode 100644 index 00000000..cbf1355e --- /dev/null +++ b/packages/webapp/src/game/core/handler/refill.ts @@ -0,0 +1,25 @@ +import { GameHookHandler } from "@/game/core/type"; +import { ActionSlotsMutator } from "@/game/store/slice/actionSlots"; +import { DeckMutator, DeckSelector } from "@/game/store/slice/deck"; +import { PlayersMutator, PlayersSelector } from "@/game/store/slice/players"; +import { RuleSelector } from "@/game/store/slice/rule"; + +export const refill: GameHookHandler = ({ G, ctx }) => { + console.log('refill stage') + // refill project cards + const maxProjectCards = RuleSelector.getPlayerMaxProjectCards(G.rules); + const numProjectsInHand = PlayersSelector.getNumProjects(G.players, ctx.currentPlayer); + const refillCardNumber = maxProjectCards - numProjectsInHand; + const projectCards = DeckSelector.peek(G.decks.projects, refillCardNumber); + DeckMutator.draw(G.decks.projects, refillCardNumber); + PlayersMutator.addProjects(G.players, ctx.currentPlayer, projectCards); + + // refill action points + const numActionTokens = RuleSelector.getPlayerMaxActionTokens(G.rules); + PlayersMutator.resetActionTokens(G.players, ctx.currentPlayer, numActionTokens); + + // reset active moves + ActionSlotsMutator.reset(G.table.actionSlots); + + console.log('end refill stage') +} diff --git a/packages/webapp/src/game/core/handler/removeEventCard.ts b/packages/webapp/src/game/core/handler/removeEventCard.ts new file mode 100644 index 00000000..1aa40fd2 --- /dev/null +++ b/packages/webapp/src/game/core/handler/removeEventCard.ts @@ -0,0 +1,15 @@ +import { TableMutator, TableSelector } from "@/game/store/slice/table"; +import { GameHookHandler } from "../type"; +import { eventCardHandlers } from "./eventCardHandlers"; +import { DeckMutator } from "@/game/store/slice/deck"; + +export const removeEventCard: GameHookHandler = (context) => { + const { G } = context; + const eventCard = TableSelector.getCurrentEvent(G.table); + console.log('remove event card', eventCard!.name); + const eventCardHandler = eventCardHandlers[eventCard!.function_name]; + eventCardHandler?.end?.(context); + + TableMutator.removeEvent(G.table); + DeckMutator.discard(G.decks.events, [eventCard!]); +}; diff --git a/packages/webapp/src/game/core/handler/scoreLeftoverActionTokens.ts b/packages/webapp/src/game/core/handler/scoreLeftoverActionTokens.ts new file mode 100644 index 00000000..6b201ab4 --- /dev/null +++ b/packages/webapp/src/game/core/handler/scoreLeftoverActionTokens.ts @@ -0,0 +1,19 @@ +import { PlayersSelector } from "@/game/store/slice/players"; +import { GameHookHandler } from "../type"; +import { RuleSelector } from "@/game/store/slice/rule"; +import { ScoreBoardMutator } from "@/game/store/slice/scoreBoard"; + +export const scoreLeftoverActionTokens: GameHookHandler = ({ G, ctx }) => { + console.log('score leftover action tokens'); + const victoryPointsPerActionToken = RuleSelector.getSettlementLeftoverActionTokensVictoryPoints(G.rules); + if (victoryPointsPerActionToken <= 0) { + console.log('no victory points for leftover action tokens'); + return; + } + + const currentPlayer = ctx.currentPlayer; + const leftoverActionTokens = PlayersSelector.getNumActionTokens(G.players, currentPlayer); + const victoryPoints = leftoverActionTokens * victoryPointsPerActionToken; + ScoreBoardMutator.add(G.table.scoreBoard, currentPlayer, victoryPoints); + console.log('end score leftover action tokens'); +} diff --git a/packages/webapp/src/game/core/handler/settleProjects.ts b/packages/webapp/src/game/core/handler/settleProjects.ts new file mode 100644 index 00000000..cf555a5d --- /dev/null +++ b/packages/webapp/src/game/core/handler/settleProjects.ts @@ -0,0 +1,57 @@ +import { GameHookHandler } from "@/game/core/type"; +import { DeckMutator } from "@/game/store/slice/deck"; +import { PlayersMutator } from "@/game/store/slice/players"; +import { ProjectBoardMutator, ProjectBoardSelector } from "@/game/store/slice/projectBoard"; +import { ProjectSlotMutator, ProjectSlotSelector } from "@/game/store/slice/projectSlot/projectSlot"; +import { RuleSelector } from "@/game/store/slice/rule"; +import { ScoreBoardMutator } from "@/game/store/slice/scoreBoard"; + +export const settleProjects: GameHookHandler = (({ G }) => { + console.log('settle projects') + const fulfilledProjectSlots = ProjectBoardSelector.getRequirementFulfilled(G.table.projectBoard); + + if (fulfilledProjectSlots.length === 0) { + console.log('no fulfilled projects. skip settle projects') + return; + } + fulfilledProjectSlots.forEach(projectSlot => { + const contributors = ProjectSlotSelector.getContributors(projectSlot); + + contributors.forEach(contributor => { + // score points + const victoryPoints = ProjectSlotSelector.getPlayerContribution(projectSlot, contributor); + ScoreBoardMutator.add(G.table.scoreBoard, contributor, victoryPoints); + + // return worker tokens + const workerTokens = ProjectSlotSelector.getPlayerWorkerTokens(projectSlot, contributor); + ProjectSlotMutator.removeContributor(projectSlot, contributor); + PlayersMutator.addWorkerTokens(G.players, contributor, workerTokens); + }); + + // score bonus points + // last contributor bonus + const lastContributorBonusPoints = RuleSelector.getSettlementLastContributorVictoryPoints(G.rules); + ScoreBoardMutator.add(G.table.scoreBoard, projectSlot.lastContributor, lastContributorBonusPoints); + + // owner bonus + const ownerBonusPoints = RuleSelector.getSettlementProjectOwnerVictoryPoints(G.rules); + const { owner, numWorkerToken } = ProjectSlotSelector.getOwner(projectSlot); + ScoreBoardMutator.add(G.table.scoreBoard, owner, ownerBonusPoints); + // return owner token + PlayersMutator.addWorkerTokens(G.players, owner, numWorkerToken); + ProjectSlotMutator.unassignOwner(projectSlot); + }); + // Remove from table + ProjectBoardMutator.remove(G.table.projectBoard, fulfilledProjectSlots); + + if (RuleSelector.isStandardRule(G.rules)) { + // Update OpenSourceTree in standard version + // TODO: implement OpenSourceTree + } else { + // Discard Project Card in simple version + const projectCards = fulfilledProjectSlots.map(project => project.card); + DeckMutator.discard(G.decks.projects, projectCards); + } + + console.log('end settle projects') +}) diff --git a/packages/webapp/src/game/core/playerView.ts b/packages/webapp/src/game/core/playerView.ts new file mode 100644 index 00000000..da5e0228 --- /dev/null +++ b/packages/webapp/src/game/core/playerView.ts @@ -0,0 +1,28 @@ +import { Ctx, PlayerID } from "boardgame.io"; +import { Player } from "../store/slice/players"; + +type PartialBy = Omit & Partial>; +type PlayerViewFn = (context: { + G: G; + ctx: Ctx; + playerID: PlayerID | null; +}) => any; + +export const playerView: PlayerViewFn = ({ G, playerID}) => { + const { decks, players, ...view } = G; + const publicPlayers: Record> = {}; + for (let id in players) { + if (id === playerID) { + publicPlayers[id] = players[id]; + } else { + // hide hand from the other players and observers + const { hand, ...player } = players[id]; + publicPlayers[id] = player; + } + } + + return { + ...view, + players: publicPlayers, + }; +} diff --git a/packages/webapp/src/game/core/setup.ts b/packages/webapp/src/game/core/setup.ts new file mode 100644 index 00000000..cc93080d --- /dev/null +++ b/packages/webapp/src/game/core/setup.ts @@ -0,0 +1,140 @@ +import { Ctx, DefaultPluginAPIs } from 'boardgame.io'; +import rawProjectCards from '../data/card/projects.json'; +import rawJobCards from '../data/card/jobs.json'; +import rawEventCards from '../data/card/events.json'; +import { EventCard, JobCard, ProjectCard } from '../card'; +import { PlayersMutator } from '../store/slice/players'; +import GameStore, { GameState } from '../store/store'; +import { DeckMutator, DeckSelector } from '../store/slice/deck'; +import { ScoreBoardMutator } from "../store/slice/scoreBoard"; +import { JobSlotsMutator } from '../store/slice/jobSlots'; +import { RuleSelector } from '../store/slice/rule'; +import { reservoirSampling } from '../utils'; +import { ProjectBoardMutator } from '../store/slice/projectBoard'; + +type SetupFn = Record, + SetupData extends any = any> = ( + context: PluginAPIs & DefaultPluginAPIs & { ctx: Ctx; }, + setupData?: SetupData + ) => G; + +const getUuid = (randomFn: () => number = Math.random ) => { + return randomFn().toString(32).slice(2); +} + +interface RawProjectCard { + name: string; + type: string; + difficulty: number; + description: string; + requirements: Record; +} + +interface RawJobCard { + name: string; + number_of_cards: number; +} + +export const setup: SetupFn = ({ ctx, random }) => { + console.log('setup game') + + console.log('init state') + // get default game state + const G = GameStore.initialState(); + + // TODO: initialize rule by difficulty and number of players + // RuleMutator.setupNumPlayers(G.rules, ctx.numPlayers); + + console.log('setup decks') + // add cards to decks + console.log('setup project cards') + const mapToProjectCards = (rawProjectCards: RawProjectCard[]): ProjectCard[] => { + return rawProjectCards.map(rawProjectCard => ({ + id: getUuid(random.Number), + ...rawProjectCard, + })); + }; + + const projectCards = mapToProjectCards(rawProjectCards as unknown as RawProjectCard[]); + const shuffledProjectCards = random.Shuffle(projectCards); + DeckMutator.initialize(G.decks.projects, shuffledProjectCards); + + console.log('setup job cards') + const mapToJobCards = (rawJobCards: RawJobCard[]): JobCard[] => { + const jobCards: JobCard[] = []; + rawJobCards.forEach(rawJobCard => { + const jobCardCreator = () => ({ + id: getUuid(random.Number), + name: rawJobCard.name, + }); + for (let i = 0; i < rawJobCard.number_of_cards; i++) { + jobCards.push(jobCardCreator()); + } + }) + return jobCards; + }; + + const jobCards = mapToJobCards(rawJobCards); + const shuffledJobCards = random.Shuffle(jobCards); + DeckMutator.initialize(G.decks.jobs, shuffledJobCards); + + console.log('setup event cards'); + // TODO: Validate event card function names + const eventCards = rawEventCards.map(rawEventCard => ({ id: getUuid(random.Number), ...rawEventCard }) as unknown as EventCard); + // find end game event card + // pick N random event cards based on rule and shuffle them + // add end game event card to the end + const lastRoundEventCards = eventCards.filter(card => card.type === 'last_round'); + if (lastRoundEventCards.length === 0) { + throw new Error('last round event card not found'); + } + if (lastRoundEventCards.length > 1) { + throw new Error('multiple last round event cards found'); + } + const endGameEventCard = lastRoundEventCards[0]; + + const basicEventCards = eventCards.filter(card => card.type === 'basic'); + const nonEndGameEventCardCount = RuleSelector.getNonEndGameNumberOfEventCards(G.rules); + const eventCardsWithoutEndGame = reservoirSampling(basicEventCards, nonEndGameEventCardCount, random.Number); + const shuffledEventCards = random.Shuffle(eventCardsWithoutEndGame); + shuffledEventCards.push(endGameEventCard); + // initialize event deck + DeckMutator.initialize(G.decks.events, shuffledEventCards); + + console.log('setup table') + // setup job slots + const maxJobCards = RuleSelector.getTableMaxJobSlots(G.rules); + const jobCardsInPlay = DeckSelector.peek(G.decks.jobs, maxJobCards); + DeckMutator.draw(G.decks.jobs, maxJobCards); + JobSlotsMutator.addJobCards(G.table.jobSlots, jobCardsInPlay); + // setup project slots + const maxProjectSlots = RuleSelector.getTableMaxProjectSlots(G.rules); + ProjectBoardMutator.initialize(G.table.projectBoard, maxProjectSlots); + + console.log('setup players') + // initialize players and score board + PlayersMutator.initialize(G.players, ctx.playOrder); + ScoreBoardMutator.initialize(G.table.scoreBoard, ctx.playOrder); + + console.log('setup player hands') + // setup player hands + const maxProjectCards = RuleSelector.getPlayerMaxProjectCards(G.rules); + ctx.playOrder.forEach(playerId => { + const projectCards = DeckSelector.peek(G.decks.projects, maxProjectCards); + DeckMutator.draw(G.decks.projects, maxProjectCards); + PlayersMutator.addProjects(G.players, playerId, projectCards); + }); + + console.log('setup player tokens') + // setup player tokens + const numWorkerTokens = RuleSelector.getPlayerMaxWorkerTokens(G.rules); + const numActionTokens = RuleSelector.getPlayerMaxActionTokens(G.rules); + ctx.playOrder.forEach(playerId => { + PlayersMutator.resetWorkerTokens(G.players, playerId, numWorkerTokens); + PlayersMutator.resetActionTokens(G.players, playerId, numActionTokens); + }); + + console.log('end setup game') + return G; +}; diff --git a/packages/webapp/src/game/core/stage/action/action.ts b/packages/webapp/src/game/core/stage/action/action.ts new file mode 100644 index 00000000..0e23c885 --- /dev/null +++ b/packages/webapp/src/game/core/stage/action/action.ts @@ -0,0 +1,47 @@ +import { createProject } from "./move/createProject"; +import { recruit } from "./move/recruit"; +import { contributeOwnedProjects } from "./move/contributeOwnedProjects"; +import { contributeJoinedProjects } from "./move/contributeJoinedProjects"; +import { removeAndRefillJobs } from "./move/removeAndRefillJobs"; +import { mirror } from "./move/mirror"; +import { GameStageConfig } from "@/game/core/type"; +import { INVALID_MOVE } from 'boardgame.io/core'; + +const withErrorBoundary = (moveFn: (...args: any[]) => any, fallback: any) => (...args: any[]) => { + try { + return moveFn(...args); + } catch (e) { + console.error(e); + } + return fallback; +}; + +export const action: GameStageConfig = { + moves: { + createProject: { + client: false, + move: withErrorBoundary(createProject, INVALID_MOVE), + }, + recruit: { + // client cannot see decks, discard job card should evaluated on server side + client: false, + move: withErrorBoundary(recruit, INVALID_MOVE), + }, + contributeOwnedProjects: { + client: false, + move: withErrorBoundary(contributeOwnedProjects, INVALID_MOVE), + }, + contributeJoinedProjects: { + client: false, + move: withErrorBoundary(contributeJoinedProjects, INVALID_MOVE), + }, + removeAndRefillJobs: { + client: false, + move: withErrorBoundary(removeAndRefillJobs, INVALID_MOVE), + }, + mirror: { + client: false, + move: withErrorBoundary(mirror, INVALID_MOVE), + }, + }, +}; diff --git a/packages/webapp/src/game/core/stage/action/move/contributeJoinedProjects.ts b/packages/webapp/src/game/core/stage/action/move/contributeJoinedProjects.ts new file mode 100644 index 00000000..23bb41e4 --- /dev/null +++ b/packages/webapp/src/game/core/stage/action/move/contributeJoinedProjects.ts @@ -0,0 +1,54 @@ +import { ProjectBoardSelector } from '@/game/store/slice/projectBoard'; +import { ProjectSlotMutator, ProjectSlotSelector } from '@/game/store/slice/projectSlot/projectSlot'; +import { GameMove } from '@/game/core/type'; +import { ContributionAction, getTotalContributionValue } from '@/game/core/ContributionAction'; +import { ActionSlotMutator, ActionSlotSelector } from '@/game/store/slice/actionSlot'; +import { PlayersMutator, PlayersSelector } from '@/game/store/slice/players'; +import { RuleSelector } from '@/game/store/slice/rule'; + +export type ContributeJoinedProjects = (contributions: ContributionAction[]) => void; + +export const contributeJoinedProjects: GameMove = ({ G, playerID }, contributions) => { + if (!RuleSelector.isActionSlotAvailable(G.rules, 'contributeJoinedProjects')) { + throw new Error('Action slot is not available'); + } + if (ActionSlotSelector.isOccupied(G.table.actionSlots.contributeJoinedProjects)) { + throw new Error('Action slot is occupied'); + } + + console.log('use action tokens') + const actionCosts = RuleSelector.getActionTokenCost(G.rules, 'contributeJoinedProjects'); + PlayersMutator.useActionTokens(G.players, playerID, actionCosts); + if (PlayersSelector.getNumActionTokens(G.players, playerID) < 0) { + throw new Error('Not enough action tokens'); + } + ActionSlotMutator.occupy(G.table.actionSlots.contributeJoinedProjects); + + contributions.forEach(({ projectSlotId, jobName }) => { + const projectSlot = ProjectBoardSelector.getBySlotId(G.table.projectBoard, projectSlotId); + if (!projectSlot) { + throw new Error('Project slot not found'); + } + const projectOwner = ProjectSlotSelector.getOwner(projectSlot); + if (projectOwner.owner === playerID) { + throw new Error('Project slot is owned by player'); + } + + if (!ProjectSlotSelector.hasWorker(projectSlot, jobName, playerID)) { + throw new Error('No worker token'); + } + }); + + const totalContributions = getTotalContributionValue(contributions); + const maxContributions = RuleSelector.getMaxContributionValue(G.rules, 'contributeJoinedProjects'); + if (totalContributions > maxContributions) { + throw new Error('Exceed maximum contribution value'); + } + + console.log('update contributions') + contributions.forEach(({ projectSlotId, jobName, value }) => { + // update contributions to given contribution points + const projectSlot = ProjectBoardSelector.getBySlotId(G.table.projectBoard, projectSlotId); + ProjectSlotMutator.pushWorker(projectSlot!, jobName, playerID, value); + }); +}; diff --git a/packages/webapp/src/game/core/stage/action/move/contributeOwnedProjects.ts b/packages/webapp/src/game/core/stage/action/move/contributeOwnedProjects.ts new file mode 100644 index 00000000..d3c5e4e7 --- /dev/null +++ b/packages/webapp/src/game/core/stage/action/move/contributeOwnedProjects.ts @@ -0,0 +1,54 @@ +import { ProjectBoardSelector } from '@/game/store/slice/projectBoard'; +import { ProjectSlotMutator, ProjectSlotSelector } from '@/game/store/slice/projectSlot/projectSlot'; +import { GameMove } from '@/game/core/type'; +import { ContributionAction, getTotalContributionValue } from '@/game/core/ContributionAction'; +import { ActionSlotMutator, ActionSlotSelector } from '@/game/store/slice/actionSlot'; +import { PlayersMutator, PlayersSelector } from '@/game/store/slice/players'; +import { RuleSelector } from '@/game/store/slice/rule'; + +export type ContributeOwnedProjects = (contributions: ContributionAction[]) => void; + +export const contributeOwnedProjects: GameMove = ({ G, playerID }, contributions) => { + if (!RuleSelector.isActionSlotAvailable(G.rules, 'contributeOwnedProjects')) { + throw new Error('Action slot is not available'); + } + if (ActionSlotSelector.isOccupied(G.table.actionSlots.contributeOwnedProjects)) { + throw new Error('Action slot is occupied'); + } + + console.log('use action tokens') + const contributeActionCosts = RuleSelector.getActionTokenCost(G.rules, 'contributeOwnedProjects'); + PlayersMutator.useActionTokens(G.players, playerID, contributeActionCosts); + if (PlayersSelector.getNumActionTokens(G.players, playerID) < 0) { + throw new Error('Not enough action tokens'); + } + ActionSlotMutator.occupy(G.table.actionSlots.contributeOwnedProjects); + + contributions.forEach(({ projectSlotId, jobName }) => { + const projectSlot = ProjectBoardSelector.getBySlotId(G.table.projectBoard, projectSlotId); + if (!projectSlot) { + throw new Error('Project slot not found'); + } + const projectOwner = ProjectSlotSelector.getOwner(projectSlot); + if (projectOwner.owner !== playerID) { + throw new Error('Project slot is not owned by player'); + } + + if (!ProjectSlotSelector.hasWorker(projectSlot, jobName, playerID)) { + throw new Error('No worker token'); + } + }); + + const totalContributions = getTotalContributionValue(contributions); + const maxOwnedContributions = RuleSelector.getMaxContributionValue(G.rules, 'contributeOwnedProjects'); + if (totalContributions > maxOwnedContributions) { + throw new Error('Exceed maximum contribution value'); + } + + console.log('update contributions') + contributions.forEach(({ projectSlotId, jobName, value }) => { + // update contributions to given contribution points + const projectSlot = ProjectBoardSelector.getBySlotId(G.table.projectBoard, projectSlotId); + ProjectSlotMutator.pushWorker(projectSlot!, jobName, playerID, value); + }); +}; diff --git a/packages/webapp/src/game/core/stage/action/move/createProject.ts b/packages/webapp/src/game/core/stage/action/move/createProject.ts new file mode 100644 index 00000000..42659081 --- /dev/null +++ b/packages/webapp/src/game/core/stage/action/move/createProject.ts @@ -0,0 +1,88 @@ +import { ProjectBoardMutator, ProjectBoardSelector } from '@/game/store/slice/projectBoard'; +import { DeckMutator, DeckSelector } from '@/game/store/slice/deck'; +import { ProjectSlotMutator } from '@/game/store/slice/projectSlot/projectSlot'; +import { GameMove } from '@/game/core/type'; +import { ActionSlotMutator, ActionSlotSelector } from '@/game/store/slice/actionSlot'; +import { ScoreBoardMutator } from '@/game/store/slice/scoreBoard'; +import { PlayersMutator, PlayersSelector } from '@/game/store/slice/players'; +import { JobSlotsMutator, JobSlotsSelector } from '@/game/store/slice/jobSlots'; +import { RuleSelector } from '@/game/store/slice/rule'; + +export type CreateProject = (projectCardId: string, jobCardId: string) => void; + +export const createProject: GameMove = ({ G, playerID }, projectCardId, jobCardId) => { + if (!RuleSelector.isActionSlotAvailable(G.rules, 'createProject')) { + throw new Error('Action slot not available'); + } + if (ActionSlotSelector.isOccupied(G.table.actionSlots.createProject)) { + throw new Error('Action slot is occupied'); + } + + console.log('use action tokens') + // TODO: replace hardcoded number with dynamic rules + const actionTokenCosts = RuleSelector.getActionTokenCost(G.rules, 'createProject'); + PlayersMutator.useActionTokens(G.players, playerID, actionTokenCosts); + if (PlayersSelector.getNumActionTokens(G.players, playerID) < 0) { + throw new Error('Not enough action tokens'); + } + ActionSlotMutator.occupy(G.table.actionSlots.createProject); + + console.log('use worker tokens') + const projectOwnerWorkerTokenCosts = RuleSelector.getProjectOwnerWorkerTokenCost(G.rules, 'createProject'); + PlayersMutator.useWorkerTokens(G.players, playerID, projectOwnerWorkerTokenCosts); + if (PlayersSelector.getNumWorkerTokens(G.players, playerID) < 0) { + throw new Error('Not enough worker tokens'); + } + + console.log('use project card') + // check project card in in hand + const projectCard = PlayersSelector.getProjectCardById(G.players, playerID, projectCardId); + if (!projectCard) { + throw new Error('Project card not found'); + } + PlayersMutator.useProject(G.players, playerID, projectCard); + ProjectBoardMutator.add(G.table.projectBoard, projectCard); + + // assign worker token to owner slot + const projectSlot = ProjectBoardSelector.getSlotByCard(G.table.projectBoard, projectCard); + ProjectSlotMutator.assignOwner(projectSlot, playerID, projectOwnerWorkerTokenCosts); + + console.log('use job card') + // check job card is on the table + const jobCard = JobSlotsSelector.getJobCardById(G.table.jobSlots, jobCardId); + if (!jobCard) { + throw new Error('Job card not found'); + } + + // remove and discard job card + JobSlotsMutator.removeJobCard(G.table.jobSlots, jobCard); + DeckMutator.discard(G.decks.jobs, [jobCard]); + + // check job card is required in project + if (!Object.keys(projectCard.requirements).includes(jobCard.name)) { + throw new Error('Job card is not required in project'); + } + + // assign worker token to job slot + const assignWorkerTokenCosts = RuleSelector.getAssignWorkerTokenCost(G.rules, 'createProject'); + PlayersMutator.useWorkerTokens(G.players, playerID, assignWorkerTokenCosts); + if (PlayersSelector.getNumWorkerTokens(G.players, playerID) < 0) { + throw new Error('Not enough worker tokens'); + } + const initialContributionValue = RuleSelector.getAssignWorkerInitialContributionValue(G.rules, 'createProject'); + ProjectSlotMutator.assignWorker(projectSlot, jobCard.name, playerID, initialContributionValue); + + console.log('refill job card') + // Refill job card + const maxJobSlots = RuleSelector.getTableMaxJobSlots(G.rules); + const refillCardNumber = maxJobSlots - G.table.jobSlots.length; + const jobCards = DeckSelector.peek(G.decks.jobs, refillCardNumber); + DeckMutator.draw(G.decks.jobs, refillCardNumber); + JobSlotsMutator.addJobCards(G.table.jobSlots, jobCards); + + console.log('score victory points') + const victoryPoints = RuleSelector.getActionVictoryPoints(G.rules, 'createProject'); + ScoreBoardMutator.add(G.table.scoreBoard, playerID, victoryPoints); + + console.log('end create project') +}; diff --git a/packages/webapp/src/game/moves/mirror.ts b/packages/webapp/src/game/core/stage/action/move/mirror.ts similarity index 63% rename from packages/webapp/src/game/moves/mirror.ts rename to packages/webapp/src/game/core/stage/action/move/mirror.ts index 0f9c42eb..ece4a2ad 100644 --- a/packages/webapp/src/game/moves/mirror.ts +++ b/packages/webapp/src/game/core/stage/action/move/mirror.ts @@ -1,19 +1,37 @@ import { INVALID_MOVE } from 'boardgame.io/core'; -import { CreateProject, createProject } from './createProject'; -import { GameMove, ActionMove } from './type'; +import { GameMove } from '@/game/core/type'; +import { ActionSlotMutator, ActionSlotSelector } from '@/game/store/slice/actionSlot'; import { Recruit, recruit } from './recruit'; import { ContributeOwnedProjects, contributeOwnedProjects } from './contributeOwnedProjects'; import { RemoveAndRefillJobs, removeAndRefillJobs } from './removeAndRefillJobs'; import { ContributeJoinedProjects, contributeJoinedProjects } from './contributeJoinedProjects'; -import { ActionSlotMutator, ActionSlotSelector } from '../store/slice/actionSlot'; +import { ActionMoveName } from './type'; +import { PlayersMutator, PlayersSelector } from '@/game/store/slice/players'; +import { RuleSelector } from '@/game/store/slice/rule'; +import { CreateProject, createProject } from './createProject'; -export type Mirror = (actionName: ActionMove, ...params: any[]) => void; +export type Mirror = (actionName: ActionMoveName, ...params: any[]) => void; export const mirror: GameMove = (context, actionName, ...params) => { - const { G } = context; + const { G, playerID } = context; + if (!RuleSelector.isActionSlotAvailable(G.rules, 'mirror')) { + return INVALID_MOVE; + } if (!ActionSlotSelector.isAvailable(G.table.actionSlots.mirror)) { return INVALID_MOVE; } + console.log('use action tokens') + const mirrorActionCost = RuleSelector.getActionTokenCost(G.rules, 'mirror'); + PlayersMutator.useActionTokens(G.players, playerID, mirrorActionCost); + if (PlayersSelector.getNumActionTokens(G.players, playerID) < 0) { + return INVALID_MOVE; + } + ActionSlotMutator.occupy(G.table.actionSlots.mirror); + + if (RuleSelector.getActionTokenCost(G.rules, actionName) <= mirrorActionCost) { + return INVALID_MOVE; + } + // TODO: add token to bypass the active moves check when its inactive let result = null; switch (actionName) { @@ -41,6 +59,4 @@ export const mirror: GameMove = (context, actionName, ...params) => { if (result === INVALID_MOVE) { return INVALID_MOVE; } - - ActionSlotMutator.occupy(G.table.actionSlots.mirror); }; diff --git a/packages/webapp/src/game/core/stage/action/move/recruit.ts b/packages/webapp/src/game/core/stage/action/move/recruit.ts new file mode 100644 index 00000000..7becc38e --- /dev/null +++ b/packages/webapp/src/game/core/stage/action/move/recruit.ts @@ -0,0 +1,72 @@ +import { ProjectBoardSelector } from '@/game/store/slice/projectBoard'; +import { DeckMutator, DeckSelector } from '@/game/store/slice/deck'; +import { ProjectSlotMutator, ProjectSlotSelector } from '@/game/store/slice/projectSlot/projectSlot'; +import { GameMove } from '@/game/core/type'; +import { ActionSlotMutator, ActionSlotSelector } from '@/game/store/slice/actionSlot'; +import { PlayersMutator, PlayersSelector } from '@/game/store/slice/players'; +import { RuleSelector } from '@/game/store/slice/rule'; +import { JobSlotsMutator, JobSlotsSelector } from '@/game/store/slice/jobSlots'; + +export type Recruit = (jobCardId: string, projectSlotId: string) => void; + +export const recruit: GameMove = ({ G, playerID }, jobCardId, projectSlotId) => { + if (!RuleSelector.isActionSlotAvailable(G.rules, 'recruit')) { + throw new Error('Action slot not available'); + } + if (ActionSlotSelector.isOccupied(G.table.actionSlots.recruit)) { + throw new Error('Action slot is occupied'); + } + + console.log('use action tokens') + const actionTokenCosts = RuleSelector.getActionTokenCost(G.rules, 'recruit'); + PlayersMutator.useActionTokens(G.players, playerID, actionTokenCosts); + if (PlayersSelector.getNumActionTokens(G.players, playerID) < 0) { + throw new Error('Not enough action tokens'); + } + ActionSlotMutator.occupy(G.table.actionSlots.recruit); + + console.log('use job card') + // check job card is on the table + const jobCard = JobSlotsSelector.getJobCardById(G.table.jobSlots, jobCardId); + if (!jobCard) { + throw new Error('Job card not found'); + } + + const activeProject = ProjectBoardSelector.getBySlotId(G.table.projectBoard, projectSlotId); + if (!activeProject) { + throw new Error('Project slot not found'); + } + + // User cannot place more than one worker in same job + if (ProjectSlotSelector.hasWorker(activeProject, jobCard.name, playerID)) { + throw new Error('Worker already assigned'); + } + + // remove and discard job card + JobSlotsMutator.removeJobCard(G.table.jobSlots, jobCard); + DeckMutator.discard(G.decks.jobs, [jobCard]); + + const jobContribution = ProjectSlotSelector.getJobContribution(activeProject, jobCard.name); + // Check job requirment is not fulfilled yet + if (jobContribution >= activeProject.card!.requirements[jobCard.name]) { + throw new Error('Job requirement already fulfilled'); + } + + console.log('use worker tokens') + const assignWorkerTokenCosts = RuleSelector.getAssignWorkerTokenCost(G.rules, 'recruit'); + PlayersMutator.useWorkerTokens(G.players, playerID, assignWorkerTokenCosts); + if (PlayersSelector.getNumWorkerTokens(G.players, playerID) < 0) { + throw new Error('Not enough worker tokens'); + } + + // assign worker token + const initialContributionValue = RuleSelector.getAssignWorkerInitialContributionValue(G.rules, 'recruit'); + ProjectSlotMutator.assignWorker(activeProject, jobCard.name, playerID, initialContributionValue); + + // Refill job card + const maxJobSlots = RuleSelector.getTableMaxJobSlots(G.rules); + const refillCardNumber = maxJobSlots - G.table.jobSlots.length; + const jobCards = DeckSelector.peek(G.decks.jobs, refillCardNumber); + DeckMutator.draw(G.decks.jobs, refillCardNumber); + JobSlotsMutator.addJobCards(G.table.jobSlots, jobCards); +}; diff --git a/packages/webapp/src/game/core/stage/action/move/removeAndRefillJobs.ts b/packages/webapp/src/game/core/stage/action/move/removeAndRefillJobs.ts new file mode 100644 index 00000000..569a8a23 --- /dev/null +++ b/packages/webapp/src/game/core/stage/action/move/removeAndRefillJobs.ts @@ -0,0 +1,49 @@ +import { DeckMutator, DeckSelector } from '@/game/store/slice/deck'; +import { GameMove } from '@/game/core/type'; +import { ActionSlotMutator, ActionSlotSelector } from '@/game/store/slice/actionSlot'; +import { PlayersMutator, PlayersSelector } from '@/game/store/slice/players'; +import { RuleSelector } from '@/game/store/slice/rule'; +import { ScoreBoardMutator } from '@/game/store/slice/scoreBoard'; +import { JobSlotsMutator, JobSlotsSelector } from '@/game/store/slice/jobSlots'; + +export type RemoveAndRefillJobs = (jobCardIds: string[]) => void; +export const removeAndRefillJobs: GameMove = ({ G, playerID }, jobCardIds) => { + if (!RuleSelector.isActionSlotAvailable(G.rules, 'removeAndRefillJobs')) { + throw new Error('Action slot not available'); + } + if (ActionSlotSelector.isOccupied(G.table.actionSlots.removeAndRefillJobs)) { + throw new Error('Action slot is occupied'); + } + + console.log('use action tokens') + const actionTokenCosts = RuleSelector.getActionTokenCost(G.rules, 'removeAndRefillJobs'); + PlayersMutator.useActionTokens(G.players, playerID, actionTokenCosts); + if (PlayersSelector.getNumActionTokens(G.players, playerID) < 0) { + throw new Error('Not enough action tokens'); + } + ActionSlotMutator.occupy(G.table.actionSlots.removeAndRefillJobs); + + console.log('remove job cards') + // check job card is on the table + const jobCardsToRemove = JobSlotsSelector.getJobCardsByIds(G.table.jobSlots, jobCardIds); + if (jobCardsToRemove.length !== jobCardIds.length) { + throw new Error('At least one job card not found'); + } + + // remove and discard job card + JobSlotsMutator.removeJobCards(G.table.jobSlots, jobCardsToRemove); + DeckMutator.discard(G.decks.jobs, jobCardsToRemove); + + console.log('refill job cards') + // refill job cards + const maxJobCards = RuleSelector.getTableMaxJobSlots(G.rules); + const filledJobSlots = JobSlotsSelector.getNumFilledSlots(G.table.jobSlots); + const refillCardNumber = maxJobCards - filledJobSlots; + const jobCardsToRefill = DeckSelector.peek(G.decks.jobs, refillCardNumber); + DeckMutator.draw(G.decks.jobs, refillCardNumber); + JobSlotsMutator.addJobCards(G.table.jobSlots, jobCardsToRefill); + + console.log('score victory points') + const victoryPoints = RuleSelector.getActionVictoryPoints(G.rules, 'removeAndRefillJobs'); + ScoreBoardMutator.add(G.table.scoreBoard, playerID, victoryPoints); +}; diff --git a/packages/webapp/src/game/core/stage/action/move/type.d.ts b/packages/webapp/src/game/core/stage/action/move/type.d.ts new file mode 100644 index 00000000..0c98abe7 --- /dev/null +++ b/packages/webapp/src/game/core/stage/action/move/type.d.ts @@ -0,0 +1,16 @@ +import { ContributeJoinedProjects } from "./contributeJoinedProjects"; +import { ContributeOwnedProjects } from "./contributeOwnedProjects"; +import { CreateProject } from "./createProject"; +import { Mirror } from "./mirror"; +import { Recruit } from "./recruit"; +import { RemoveAndRefillJobs } from "./removeAndRefillJobs"; + +export interface ActionMoves { + createProject: CreateProject; + recruit: Recruit; + contributeOwnedProjects: ContributeOwnedProjects; + contributeJoinedProjects: ContributeJoinedProjects; + removeAndRefillJobs: RemoveAndRefillJobs; + mirror: Mirror; +}; +export type ActionMoveName = keyof ActionMoves; diff --git a/packages/webapp/src/game/core/type.d.ts b/packages/webapp/src/game/core/type.d.ts new file mode 100644 index 00000000..a30da3aa --- /dev/null +++ b/packages/webapp/src/game/core/type.d.ts @@ -0,0 +1,21 @@ +import { FnContext, PlayerID, StageConfig } from 'boardgame.io'; +import { INVALID_MOVE } from 'boardgame.io/core'; +import { GameState } from '../store/store'; +import { CreateProject } from './stage/action/move/createProject'; +import { Mirror } from './stage/action/move/mirror'; +import { RemoveAndRefillJobs } from './stage/action/move/removeAndRefillJobs'; +import { ContributeJoinedProjects } from './stage/action/move/contributeJoinedProjects'; +import { ContributeOwnedProjects } from './stage/action/move/contributeOwnedProjects'; +import { Recruit } from './stage/action/move/recruit'; +import { ActionMoves, ActionMoveName } from './stage/action/move/type'; + +export type AllMoves = ActionMoves; +export type AllMoveNames = ActionMoveName; + +// Define the type of a move to support type checking +export type GameMove void> = (context: FnContext & { playerID: PlayerID }, ...args: Parameters) => void | GameState | typeof INVALID_MOVE; + +// Define the type of a hook to support type checking +export type GameHookHandler = (context: FnContext) => void | GameState; + +export type GameStageConfig = StageConfig; diff --git a/packages/webapp/src/game/data/card/events.json b/packages/webapp/src/game/data/card/events.json index db90f0dc..b3a5f676 100644 --- a/packages/webapp/src/game/data/card/events.json +++ b/packages/webapp/src/game/data/card/events.json @@ -1,74 +1,44 @@ [ { - "name": "數位政委", - "description": "巧遇數位政委,場內所有開放政府專案人力貢獻值+1" - }, - { - "name": "數位政委", - "description": "巧遇數位政委,場內所有開放原始碼專案人力貢獻值+1" - }, - { - "name": "數位政委", - "description": "巧遇數位政委,場內所有開放資料專案人力貢獻值+1" - }, - { - "name": "挖角", - "description": "每個玩家抽一張左手邊玩家手牌的資源卡" + "name": "是芥末日", + "description": "翻開此卡,本輪結束後,遊戲結束。在回合中可不將行動點用完,剩餘一個行動點數 +1 分", + "function_name": "end_game_after_this_round", + "type": "last_round" }, { "name": "人力釋出", - "description": "每個玩家傳一張手牌資源卡給左手邊的玩家" + "description": "立即將人力資源區的人力卡全部棄掉,並重新補滿", + "function_name": "discard_and_refill_all_worker_slots", + "type": "basic" }, { - "name": "番茄醬工作法", - "description": "貢獻專案時,可分配的貢獻值+1" + "name": "斜槓青年", + "description": "本輪所有玩家在發起專案行動或招募人力行動時,使用的第一張人力卡可以無視人力需求招募至專案", + "function_name": "ignore_first_worker_requirement", + "type": "basic" }, { "name": "四大自由", - "description": "每位玩家補充手牌時可多補一張資源卡" + "description": "立即多翻開兩張人力卡至人力資源區,即人力資源區上限 +2。本輪結束時由尾家選擇兩張棄掉", + "function_name": "add_two_worker_slots", + "type": "basic" }, { "name": "會計年度結算", - "description": "本輪結案的專案,發起者額外獲得 2 點積分" - }, - { - "name": "國際交流", - "description": "所有人交出資源牌,洗勻後發回" - }, - { - "name": "不務正業", - "description": "所有人交出專案牌,洗勻後發回" - }, - { - "name": "斜槓青年", - "description": "每人第一張打出的人力卡,可以無視人力需求放入專案" - }, - { - "name": "抱歉了我的肝", - "description": "但我真的想做那個酷專案。每人第一次招募人力時,可用一個行動點招募兩個人力" - }, - { - "name": "猛漢肺炎來襲", - "description": "場內所有專案卡的所有人力貢獻值-1" - }, - { - "name": "鯊魚咬斷海底電纜啦!", - "description": "本輪不能發起專案" + "description": "本輪結案的專案,發起者額外獲得 2分", + "function_name": "project_owner_gets_two_points", + "type": "basic" }, { - "name": "GitHub 當機啦!", - "description": "本輪不能貢獻專案" - }, - { - "name": "又老又窮啊!", - "description": "本輪不能使用源力卡" - }, - { - "name": "減薪休假啊!", - "description": "本輪不能招募人力" + "name": "青年補助", + "description": "本輪目前影響力分數最低的玩家,可多使用一個灰色行動點指示物,若最低分不只一位,則無人可以使用", + "function_name": "the_only_player_with_the_lowest_victory_points_gets_one_extra_action_token", + "type": "basic" }, { - "name": "青年補助", - "description": "目前專案完成數最少的人,立即抽 2 張資源卡,且本回合行動點+1" + "name": "番茄醬工作法", + "description": "本輪執行發起人貢獻時可分配的貢獻值 +1", + "function_name": "increase_one_owned_project_contribution_value", + "type": "basic" } ] diff --git a/packages/webapp/src/game/data/card/forces.json b/packages/webapp/src/game/data/card/forces.json deleted file mode 100644 index 20092202..00000000 --- a/packages/webapp/src/game/data/card/forces.json +++ /dev/null @@ -1,38 +0,0 @@ -[ - { - "name": "專案補助" - }, - { - "name": "雲端硬碟" - }, - { - "name": "開放定義" - }, - { - "name": "開源授權" - }, - { - "name": "開放元年" - }, - { - "name": "開源教教我" - }, - { - "name": "累積深蹲之力" - }, - { - "name": "開坑救救我" - }, - { - "name": "入坑揪揪我" - }, - { - "name": "零的轉移" - }, - { - "name": "人事異動" - }, - { - "name": "鉗形攻勢" - } -] diff --git a/packages/webapp/src/game/data/card/goals.json b/packages/webapp/src/game/data/card/goals.json deleted file mode 100644 index 5ea76ab3..00000000 --- a/packages/webapp/src/game/data/card/goals.json +++ /dev/null @@ -1,54 +0,0 @@ -[ - { - "name": "政客終結者", - "description": "完成 2 / 3 / 4 張開放政府專案時,獲得 1 / 2 / 3 共同積分" - }, - { - "name": "新資料夾", - "description": "完成 2 / 3 / 4 張開放資料專案時,獲得 1 / 2 / 3 共同積分" - }, - { - "name": "GitHub Master", - "description": "完成 2 / 3 / 4 張開放原始碼專案時,獲得 1 / 2 / 3 共同積分" - }, - { - "name": "開源綠手指", - "description": "三種專案類型各完成 1 / 2 / 3 張時,獲得 2 / 5 / 10 共同積分" - }, - { - "name": "協作平台", - "description": "每位玩家都作為發起者完成 1 / 2 張專案時,獲得 3 / 6 共同積分" - }, - { - "name": "眾志成城", - "description": "完成 5 / 6 / 7 張專案時,獲得 3 / 4 / 5 共同積分" - }, - { - "name": "猴子管理員", - "description": "當完成專案的工程師達到 5 / 10 / 15 位時,獲得 1 / 2 / 3 共同積分" - }, - { - "name": "議題實驗室", - "description": "當完成專案的議題工作者達到 3 / 5 / 7 位時,獲得 1 / 2 / 3 共同積分" - }, - { - "name": "投戎從筆", - "description": "當完成專案的文字工作者達到 3 / 5 / 7 位時,獲得 1 / 2 / 3 共同積分" - }, - { - "name": "國考菁英", - "description": "當完成專案的公務員達到 3 / 5 / 7 位時,獲得 1 / 2 / 3 共同積分" - }, - { - "name": "Unlimited Artwork", - "description": "當完成專案的美術設計達到 1 / 2 位時,獲得 2 / 3 共同積分" - }, - { - "name": "六法全書唯一開源", - "description": "當完成專案的法務達到 1 / 2 位時,獲得 2 / 3 共同積分" - }, - { - "name": "本專案沒有", - "description": "當完成專案的行銷公關達到 2 位時,獲得 5 共同積分" - } -] diff --git a/packages/webapp/src/game/data/card/jobs.json b/packages/webapp/src/game/data/card/jobs.json index b07beff8..37a7106b 100644 --- a/packages/webapp/src/game/data/card/jobs.json +++ b/packages/webapp/src/game/data/card/jobs.json @@ -1,182 +1,30 @@ [ { - "name": "行銷公關" + "name": "行銷公關", + "number_of_cards": 3 }, { - "name": "行銷公關" + "name": "法務專家", + "number_of_cards": 5 }, { - "name": "法務" + "name": "美術設計", + "number_of_cards": 7 }, { - "name": "法務" + "name": "公務人員", + "number_of_cards": 7 }, { - "name": "法務" + "name": "文字工作者", + "number_of_cards": 7 }, { - "name": "法務" + "name": "議題工作者", + "number_of_cards": 9 }, { - "name": "美術與設計" - }, - { - "name": "美術與設計" - }, - { - "name": "美術與設計" - }, - { - "name": "美術與設計" - }, - { - "name": "美術與設計" - }, - { - "name": "公務員" - }, - { - "name": "公務員" - }, - { - "name": "公務員" - }, - { - "name": "公務員" - }, - { - "name": "公務員" - }, - { - "name": "公務員" - }, - { - "name": "公務員" - }, - { - "name": "公務員" - }, - { - "name": "公務員" - }, - { - "name": "文字工作者" - }, - { - "name": "文字工作者" - }, - { - "name": "文字工作者" - }, - { - "name": "文字工作者" - }, - { - "name": "文字工作者" - }, - { - "name": "文字工作者" - }, - { - "name": "文字工作者" - }, - { - "name": "文字工作者" - }, - { - "name": "文字工作者" - }, - { - "name": "議題工作者" - }, - { - "name": "議題工作者" - }, - { - "name": "議題工作者" - }, - { - "name": "議題工作者" - }, - { - "name": "議題工作者" - }, - { - "name": "議題工作者" - }, - { - "name": "議題工作者" - }, - { - "name": "議題工作者" - }, - { - "name": "議題工作者" - }, - { - "name": "議題工作者" - }, - { - "name": "工程師" - }, - { - "name": "工程師" - }, - { - "name": "工程師" - }, - { - "name": "工程師" - }, - { - "name": "工程師" - }, - { - "name": "工程師" - }, - { - "name": "工程師" - }, - { - "name": "工程師" - }, - { - "name": "工程師" - }, - { - "name": "工程師" - }, - { - "name": "工程師" - }, - { - "name": "工程師" - }, - { - "name": "工程師" - }, - { - "name": "工程師" - }, - { - "name": "工程師" - }, - { - "name": "工程師" - }, - { - "name": "工程師" - }, - { - "name": "工程師" - }, - { - "name": "工程師" - }, - { - "name": "工程師" - }, - { - "name": "工程師" + "name": "工程師", + "number_of_cards": 12 } ] diff --git a/packages/webapp/src/game/data/card/projects.json b/packages/webapp/src/game/data/card/projects.json index afb68440..b208abcf 100644 --- a/packages/webapp/src/game/data/card/projects.json +++ b/packages/webapp/src/game/data/card/projects.json @@ -1,549 +1,530 @@ [ - { - "name": "台灣成功加入 OGP 組織", - "type": "開放政府", - "description": "這是一套訴求「透明、參與、課責、涵容」的國際標準", - "jobs": [ - "行銷公關", - "行銷公關", - "法務", - "文字工作者", - "議題工作者", - "議題工作者" - ], - "requirements": { - "行銷公關": 12, - "法務": 6, - "文字工作者": 6, - "議題工作者": 12 + { + "name": "BigBlueButton", + "type": "開放原始碼", + "difficulty": 2, + "description": "開源線上會議系統,免下載,可與多種學習管理系統整合", + "requirements": { + "美術設計": 8, + "工程師": 8 + } + }, + { + "name": "Firefox", + "type": "開放原始碼", + "difficulty": 3, + "description": "自由及開放原始碼的網頁瀏覽器", + "requirements": { + "行銷公關": 4, + "美術設計": 6, + "工程師": 12 + } + }, + { + "name": "Inkscape", + "type": "開放原始碼", + "difficulty": 2, + "description": "一款通用公眾授權條款釋出的開源向量圖形編輯器", + "requirements": { + "美術設計": 4, + "文字工作者": 3, + "工程師": 9 + } + }, + { + "name": "LibreOffice", + "type": "開放原始碼", + "difficulty": 3, + "description": "一套自由及開放原始碼的辦公軟體", + "requirements": { + "美術設計": 4, + "文字工作者": 4, + "工程師": 14 + } + }, + { + "name": "Linux Kernel", + "type": "開放原始碼", + "difficulty": 4, + "description": "開源的類 Unix 作業系統核心", + "requirements": { + "工程師": 24 + } + }, + { + "name": "Notepad++", + "type": "開放原始碼", + "difficulty": 1, + "description": "萬能開源純文字編輯器,支援超多語言/程式語言", + "requirements": { + "工程師": 8 + } + }, + { + "name": "OCF Lab", + "type": "開放原始碼", + "difficulty": 1, + "description": "由開放文化基金會創立,關注開放的新聞平台", + "requirements": { + "美術設計": 3, + "文字工作者": 3, + "議題工作者": 2 + } + }, + { + "name": "OPass", + "type": "開放原始碼", + "difficulty": 1, + "description": "整合各大臺灣開源資訊社群研討會的報到平台 App", + "requirements": { + "公務人員": 2, + "工程師": 6 + } + }, + { + "name": "Open Hack Farm", + "type": "開放原始碼", + "difficulty": 2, + "description": "「開放農業實驗基地」致力開源及開放資料的農業應用", + "requirements": { + "公務人員": 4, + "議題工作者": 6, + "工程師": 6 + } + }, + { + "name": "Public Money Public Code", + "type": "開放原始碼", + "difficulty": 3, + "description": "「公費建制政府系統以自由開源軟體授權釋出」的倡議", + "requirements": { + "行銷公關": 5, + "法務專家": 9, + "文字工作者": 4, + "議題工作者": 4 + } + }, + { + "name": "了解開源授權", + "type": "開放原始碼", + "difficulty": 1, + "description": "授權條款是開源不可缺少的要件,與軟體的自由程度直接相關", + "requirements": { + "文字工作者": 2, + "議題工作者": 3, + "工程師": 3 + } + }, + { + "name": "新酷音輸入法", + "type": "開放原始碼", + "difficulty": 1, + "description": "新酷音輸入法,是開放原始碼的智慧型中文注音輸入法", + "requirements": { + "文字工作者": 3, + "工程師": 5 + } + }, + { + "name": "洋蔥瀏覽器", + "type": "開放原始碼", + "difficulty": 2, + "description": "一款較安全的瀏覽器,不僅可匿名,更自動刪除敏感瀏覽資料", + "requirements": { + "美術設計": 6, + "工程師": 10 + } + }, + { + "name": "vTaiwan", + "type": "開放政府", + "difficulty": 3, + "description": "供民眾針對台灣法規表達意見與納入政策討論過程的平台", + "requirements": { + "法務專家": 4, + "公務人員": 7, + "文字工作者": 4, + "議題工作者": 7 + } + }, + { + "name": "來跟立委吃一碗", + "type": "開放政府", + "difficulty": 1, + "description": "用開放資料看立委最愛用國家的錢去哪些餐廳", + "requirements": { + "美術設計": 5, + "工程師": 3 + } + }, + { + "name": "公共政策網路參與平台", + "type": "開放政府", + "difficulty": 2, + "description": "一個供民眾留下對政策的意見,政府機關回覆的網路平台", + "requirements": { + "法務專家": 2, + "公務人員": 6, + "議題工作者": 8 + } + }, + { + "name": "國民法官", + "type": "開放政府", + "difficulty": 1, + "description": "結合社會期待與法官專業,讓公民參與法院判決的機制", + "requirements": { + "法務專家": 3, + "公務人員": 3, + "工程師": 2 + } + }, + { + "name": "實價登錄到門牌", + "type": "開放政府", + "difficulty": 2, + "description": "揭露每戶房屋的成交價格,以推動房價交易透明化", + "requirements": { + "美術設計": 4, + "文字工作者": 4, + "議題工作者": 6, + "工程師": 2 + } + }, + { + "name": "台灣成功加入「開放政府夥伴聯盟」", + "type": "開放政府", + "difficulty": 4, + "description": "這是一套訴求「透明、參與、課責、涵容」的國際標準", + "requirements": { + "行銷公關": 8, + "法務專家": 4, + "文字工作者": 4, + "議題工作者": 8 + } + }, + { + "name": "Democracy OS", + "type": "開放政府", + "difficulty": 2, + "description": "民主參與系統,可討論政策、模擬投票、進行參與式預算等", + "requirements": { + "公務人員": 8, + "議題工作者": 4, + "工程師": 4 + } + }, + { + "name": "台灣賄選實價登錄地圖", + "type": "開放政府", + "difficulty": 2, + "description": "將賄選判決資料製成地圖,輕鬆得知你的一票值多少", + "requirements": { + "法務專家": 3, + "議題工作者": 4, + "工程師": 9 + } + }, + { + "name": "政府資料開放平臺", + "type": "開放政府", + "difficulty": 1, + "description": "整合政府各機關的開放資料的網路平臺", + "requirements": { + "議題工作者": 3, + "工程師": 5 + } + }, + { + "name": "民意代表投票指南", + "type": "開放政府", + "difficulty": 2, + "description": "彙整候選人資料,幫助選民好好做功課、投下神聖的一票", + "requirements": { + "美術設計": 2, + "公務人員": 3, + "文字工作者": 8, + "議題工作者": 3 + } + }, + { + "name": "立法院會議直播", + "type": "開放政府", + "difficulty": 1, + "description": "將立法院議事過程公開直播,以滿足公民知情的權利", + "requirements": { + "公務人員": 4, + "議題工作者": 2, + "工程師": 2 + } + }, + { + "name": "開放街圖", + "type": "開放資料", + "difficulty": 2, + "description": "所有人都能編輯的地圖,可用在所有需地理資料的專案", + "requirements": { + "美術設計": 10, + "工程師": 12 + } + }, + { + "name": "資料申請小幫手", + "type": "開放政府", + "difficulty": 1, + "description": "一套協助民眾索取政府資料更佳簡便、更視覺化的機制", + "requirements": { + "美術設計": 2, + "公務人員": 2, + "工程師": 4 + } + }, + { + "name": "全民追公車", + "type": "開放資料", + "difficulty": 2, + "description": "利用交通局開放資料,做大眾運輸路線、位置追蹤App", + "requirements": { + "美術設計": 2, + "公務人員": 6, + "工程師": 8 + } + }, + { + "name": "政治獻金透明化修法", + "type": "開放政府", + "difficulty": 2, + "description": "以群案外包的方式,推動政治獻金資料透明化", + "requirements": { + "法務專家": 10, + "公務人員": 6, + "議題工作者": 6 + } + }, + { + "name": "Common Voice", + "type": "開放資料", + "difficulty": 2, + "description": "蒐集各語言真人發音資料庫,讓電腦學會最真實的說話方式", + "requirements": { + "美術設計": 4, + "文字工作者": 5, + "工程師": 7 + } + }, + { + "name": "口罩地圖", + "type": "開放資料", + "difficulty": 1, + "description": "因應肺炎政策,串接醫療院所資料的口罩存貨查詢程式", + "requirements": { + "公務人員": 2, + "工程師": 6 + } + }, + { + "name": "台灣賄選實價登錄地圖", + "type": "開放政府", + "difficulty": 2, + "description": "將賄選判決資料製成地圖,輕鬆得知你的一票值多少", + "requirements": { + "法務專家": 3, + "議題工作者": 4, + "工程師": 9 + } + }, + { + "name": "政府資料開放平臺", + "type": "開放政府", + "difficulty": 1, + "description": "整合政府各機關的開放資料的網路平台", + "requirements": { + "議題工作者": 3, + "工程師": 5 + } + }, + { + "name": "民意代表投票指南", + "type": "開放政府", + "difficulty": 2, + "description": "彙整候選人資料,幫助選民好好做功課、投下神聖的一票", + "requirements": { + "美術設計": 2, + "公務人員": 3, + "文字工作者": 8, + "議題工作者": 3 + } + }, + { + "name": "立法院會議直播", + "type": "開放政府", + "difficulty": 1, + "description": "將立法院議事過程公開直播,以滿足公民知情的權利", + "requirements": { + "公務人員": 4, + "議題工作者": 2, + "工程師": 2 + } + }, + { + "name": "開放街圖", + "type": "開放資料", + "difficulty": 3, + "description": "所有人都能編輯的地圖,可用在所有需地理資料的專案", + "requirements": { + "美術設計": 10, + "工程師": 12 + } + }, + { + "name": "資料申請小幫手", + "type": "開放政府", + "difficulty": 1, + "description": "一套協助民眾索取政府資料更佳簡便、更視覺化的機制", + "requirements": { + "美術設計": 2, + "公務人員": 2, + "工程師": 4 + } + }, + { + "name": "全民追公車", + "type": "開放資料", + "difficulty": 2, + "description": "利用交通局開放資料,做大眾運輸路線、位置追蹤App", + "requirements": { + "美術設計": 2, + "公務人員": 6, + "工程師": 8 + } + }, + { + "name": "政治獻金透明化修法", + "type": "開放政府", + "difficulty": 3, + "description": "以群案外包的方式,推動政治獻金資料透明化", + "requirements": { + "法務專家": 10, + "公務人員": 6, + "議題工作者": 6 + } + }, + { + "name": "Common Voice", + "type": "開放資料", + "difficulty": 2, + "description": "蒐集各語言真人發音資料庫,讓電腦學會最真實的說話方式", + "requirements": { + "美術設計": 4, + "文字工作者": 5, + "工程師": 7 + } + }, + { + "name": "口罩地圖", + "type": "開放資料", + "difficulty": 1, + "description": "因應肺炎政策,串接醫療院所資料的口罩存貨查詢程式", + "requirements": { + "公務人員": 2, + "工程師": 6 + } + }, + { + "name": "國家寶藏", + "type": "開放資料", + "difficulty": 1, + "description": "讓志工到美國翻拍流落的臺灣史料,公開給大眾免費使用", + "requirements": { + "文字工作者": 4, + "議題工作者": 2, + "工程師": 2 + } + }, + { + "name": "在專制國家推動開放資料專法", + "type": "開放資料", + "difficulty": 4, + "description": "把政府長年蒐集的各種資料,攤在陽光下給民眾檢視使用", + "requirements": { + "法務專家": 8, + "公務人員": 8, + "議題工作者": 8 + } + }, + { + "name": "掃了再買", + "type": "開放資料", + "difficulty": 3, + "description": "一掃就讓你看光商品企業違規裁罰紀錄的良心購物App", + "requirements": { + "行銷公關": 4, + "議題工作者": 10, + "工程師": 8 + } + }, + { + "name": "求職天眼通", + "type": "開放資料", + "difficulty": 1, + "description": "瀏覽求職網時可同步顯示公司評價和網友點評的外掛程式", + "requirements": { + "議題工作者": 3, + "工程師": 5 + } + }, + { + "name": "環境感測器網路系統", + "type": "開放資料", + "difficulty": 1, + "description": "開源和公益的「環境感測器」,輕鬆偵測空氣和水文品質", + "requirements": { + "議題工作者": 2, + "工程師": 6 + } + }, + { + "name": "萌典", + "type": "開放資料", + "difficulty": 2, + "description": "力挺臺灣本地語言,收錄國台客多語的開放免費線上字典", + "requirements": { + "文字工作者": 8, + "議題工作者": 2, + "工程師": 6 + } + }, + { + "name": "農地違章工廠回報", + "type": "開放資料", + "difficulty": 2, + "description": "快速匿名檢舉農地中的違章工廠,讓你伸張正義免驚找碴", + "requirements": { + "美術設計": 2, + "議題工作者": 8, + "工程師": 6 + } + }, + { + "name": "開放館藏", + "type": "開放資料", + "difficulty": 1, + "description": "開放博物館文物資料並數位化,讓愛好者拿來合法使用", + "requirements": { + "美術設計": 3, + "公務人員": 2, + "工程師": 3 + } + }, + { + "name": "真的假的", + "type": "開放資料", + "difficulty": 2, + "description": "LINE聊天機器人形式的假消息即時闢謠、查證平台", + "requirements": { + "文字工作者": 12, + "議題工作者": 2, + "工程師": 2 + } } - }, - { - "name": "V 歹丸", - "type": "開放政府", - "description": "供民眾針對台灣法規表達意見、納入政策討論過程的平台", - "jobs": [ - "法務", - "公務員", - "公務員", - "文字工作者", - "議題工作者", - "議題工作者" - ], - "requirements": { - "法務": 6, - "公務員": 10, - "文字工作者": 5, - "議題工作者": 11 - } - }, - { - "name": "政治獻金透明化修法", - "type": "開放政府", - "description": "以群眾外包的方式,推動政治獻金資料透明化", - "jobs": [ - "法務", - "法務", - "公務員", - "公務員", - "議題工作者", - "議題工作者" - ], - "requirements": { - "法務": 11, - "公務員": 10, - "議題工作者": 10 - } - }, - { - "name": "實價登錄到門牌", - "type": "開放政府", - "description": "揭露每戶房屋的成交價格,以推動房價交易透明化", - "jobs": [ - "美術與設計", - "公務員", - "文字工作者", - "議題工作者", - "議題工作者", - "工程師" - ], - "requirements": { - "美術與設計": 3, - "公務員": 3, - "文字工作者": 5, - "議題工作者": 8, - "工程師": 4 - } - }, - { - "name": "民意代表投票指南", - "type": "開放政府", - "description": "彙整候選人資料,幫助選民好好做功課、投下神聖的一票", - "jobs": [ - "美術與設計", - "公務員", - "文字工作者", - "文字工作者", - "文字工作者", - "工程師" - ], - "requirements": { - "美術與設計": 3, - "公務員": 3, - "文字工作者": 12, - "工程師": 4 - } - }, - { - "name": "公共政策網路參與平臺", - "type": "開放政府", - "description": "一個供民眾留下對政策的意見,政府機關回覆的網路平台", - "jobs": [ - "法務", - "公務員", - "公務員", - "議題工作者", - "議題工作者", - "議題工作者" - ], - "requirements": { - "法務": 4, - "公務員": 8, - "議題工作者": 12 - } - }, - { - "name": "政府資料開放平台", - "type": "開放政府", - "description": "整合政府各機關的開放資料的網路平台", - "jobs": [ - "公務員", - "公務員", - "議題工作者", - "工程師", - "工程師", - "工程師" - ], - "requirements": { - "公務員": 3, - "議題工作者": 2, - "工程師": 7 - } - }, - { - "name": "國民法官", - "type": "開放政府", - "description": "結合社會期待與法官專業,讓公民參與法院判決的機制", - "jobs": [ - "法務", - "公務員", - "文字工作者", - "議題工作者", - "工程師", - "工程師" - ], - "requirements": { - "法務": 3, - "公務員": 2, - "文字工作者": 1, - "議題工作者": 2, - "工程師": 4 - } - }, - { - "name": "開放國會直播", - "type": "開放政府", - "description": "將立法院議事過程公開直播,以滿足公民知情的權利", - "jobs": [ - "公務員", - "公務員", - "公務員", - "議題工作者", - "議題工作者", - "工程師" - ], - "requirements": { - "公務員": 6, - "議題工作者": 3, - "工程師": 3 - } - }, - { - "name": "資料申請小精靈", - "type": "開放政府", - "description": "一套協助民眾索取政府資料更簡便、更視覺化的機制", - "jobs": [ - "美術與設計", - "公務員", - "公務員", - "公務員", - "工程師", - "工程師" - ], - "requirements": { - "美術與設計": 1, - "公務員": 5, - "工程師": 6 - } - }, - { - "name": "在專制國家推動開放資料專法", - "type": "開放資料", - "description": "把政府長年蒐集的各種資料,攤在陽光下給民眾檢視使用", - "jobs": [ - "法務", - "法務", - "公務員", - "公務員", - "議題工作者", - "議題工作者" - ], - "requirements": { - "法務": 12, - "公務員": 12, - "議題工作者": 12 - } - }, - { - "name": "Open Street Guide", - "type": "開放資料", - "description": "所有人都能編輯的地圖,可用在所有需地理資料的專案", - "jobs": [ - "美術與設計", - "美術與設計", - "美術與設計", - "工程師", - "工程師", - "工程師" - ], - "requirements": { - "美術與設計": 15, - "工程師": 18 - } - }, - { - "name": "嗶了再買", - "type": "開放資料", - "description": "一掃就讓你看光商品企業違規裁罰紀錄的良心購物App", - "jobs": [ - "行銷公關", - "議題工作者", - "議題工作者", - "議題工作者", - "工程師", - "工程師" - ], - "requirements": { - "行銷公關": 6, - "議題工作者": 15, - "工程師": 12 - } - }, - { - "name": "ㄅㄧㄤˋ典", - "type": "開放資料", - "description": "力挺臺灣本地語言,收錄國台客多語的開放免費線上字典", - "jobs": [ - "文字工作者", - "文字工作者", - "文字工作者", - "議題工作者", - "工程師", - "工程師" - ], - "requirements": { - "文字工作者": 12, - "議題工作者": 4, - "工程師": 8 - } - }, - { - "name": "全民追公車", - "type": "開放資料", - "description": "利用交通局開放資料,做大眾運輸路線、位置追蹤App", - "jobs": [ - "美術與設計", - "公務員", - "工程師", - "工程師", - "工程師", - "工程師" - ], - "requirements": { - "美術與設計": 4, - "公務員": 5, - "工程師": 14 - } - }, - { - "name": "真假!?別再傳謠言", - "type": "開放資料", - "description": "Line聊天機器人形式的假消息即時闢謠、查證平台。", - "jobs": [ - "文字工作者", - "文字工作者", - "文字工作者", - "文字工作者", - "議題工作者", - "工程師" - ], - "requirements": { - "文字工作者": 17, - "議題工作者": 3, - "工程師": 4 - } - }, - { - "name": "口罩藏寶圖 口罩在哪裡", - "type": "開放資料", - "description": "因應肺炎政策,串接醫療院所資料的口罩存貨查詢程式", - "jobs": [ - "公務員", - "公務員", - "工程師", - "工程師", - "工程師", - "工程師" - ], - "requirements": { - "公務員": 4, - "工程師": 8 - } - }, - { - "name": "開放館藏", - "type": "開放資料", - "description": "開放博物館的文物資料並數位化,讓愛好者拿來合法使用", - "jobs": [ - "美術與設計", - "美術與設計", - "公務員", - "工程師", - "工程師", - "工程師" - ], - "requirements": { - "美術與設計": 4, - "公務員": 2, - "工程師": 6 - } - }, - { - "name": "就職順風耳", - "type": "開放資料", - "description": "瀏覽求職網時可同步顯示公司評價和網友點評的外掛程式", - "jobs": [ - "議題工作者", - "議題工作者", - "工程師", - "工程師", - "工程師", - "工程師" - ], - "requirements": { - "議題工作者": 3, - "工程師": 9 - } - }, - { - "name": "國家祕寶", - "type": "開放資料", - "description": "讓志工到美國翻拍流落的臺灣史料,公開給大眾免費使用", - "jobs": [ - "文字工作者", - "文字工作者", - "文字工作者", - "議題工作者", - "議題工作者", - "工程師" - ], - "requirements": { - "文字工作者": 5, - "議題工作者": 3, - "工程師": 4 - } - }, - { - "name": "Linux kerkernel", - "type": "開放原始碼", - "description": "開源的類 Unix 作業系統核心。", - "jobs": [ - "工程師", - "工程師", - "工程師", - "工程師", - "工程師", - "工程師" - ], - "requirements": { - "工程師": 36 - } - }, - { - "name": "Firebox", - "type": "開放原始碼", - "description": "自由及開放原始碼的網頁瀏覽器。", - "jobs": [ - "行銷公關", - "法務", - "美術與設計", - "工程師", - "工程師", - "工程師" - ], - "requirements": { - "行銷公關": 4, - "法務": 4, - "美術與設計": 6, - "工程師": 18 - } - }, - { - "name": "BiangOffice.org", - "type": "開放原始碼", - "description": "一套自由及開放原始碼的辦公軟體。", - "jobs": [ - "美術與設計", - "文字工作者", - "工程師", - "工程師", - "工程師", - "工程師" - ], - "requirements": { - "美術與設計": 6, - "文字工作者": 5, - "工程師": 21 - } - }, - { - "name": "Public Money Public Code", - "type": "開放原始碼", - "description": "「公費建制政府系統以自由開源軟體授權釋出」的倡議。", - "jobs": [ - "行銷公關", - "法務", - "法務", - "公務員", - "文字工作者", - "議題工作者" - ], - "requirements": { - "行銷公關": 6, - "法務": 10, - "公務員": 4, - "文字工作者": 6, - "議題工作者": 6 - } - }, - { - "name": "Inksgap", - "type": "開放原始碼", - "description": "一款通用公眾授權條款釋出的開源向量圖形編輯器。", - "jobs": [ - "行銷公關", - "美術與設計", - "文字工作者", - "工程師", - "工程師", - "工程師" - ], - "requirements": { - "行銷公關": 2, - "美術與設計": 6, - "文字工作者": 3, - "工程師": 14 - } - }, - { - "name": "製作開源農業感測器", - "type": "開放原始碼", - "description": "「開放農業實驗基地」致力開源及開放資料的農業應用。", - "jobs": [ - "議題工作者", - "議題工作者", - "工程師", - "工程師", - "工程師", - "工程師" - ], - "requirements": { - "議題工作者": 8, - "工程師": 15 - } - }, - { - "name": "了解開源授權", - "type": "開放原始碼", - "description": "授權條款是開源不可缺少的要件,與自由程度直接相關。", - "jobs": [ - "法務", - "美術與設計", - "公務員", - "文字工作者", - "議題工作者", - "工程師" - ], - "requirements": { - "法務": 2, - "美術與設計": 2, - "公務員": 2, - "文字工作者": 2, - "議題工作者": 2, - "工程師": 2 - } - }, - { - "name": "AllPass", - "type": "開放原始碼", - "description": "整合各大臺灣開源資訊社群研討會的報到平台 app。", - "jobs": [ - "美術與設計", - "美術與設計", - "工程師", - "工程師", - "工程師", - "工程師" - ], - "requirements": { - "美術與設計": 3, - "工程師": 9 - } - }, - { - "name": "酷音未來輸入法", - "type": "開放原始碼", - "description": "酷音未來輸入法,是開放原始碼的智慧型中文注音輸入法。", - "jobs": [ - "文字工作者", - "文字工作者", - "工程師", - "工程師", - "工程師", - "工程師" - ], - "requirements": { - "文字工作者": 4, - "工程師": 8 - } - }, - { - "name": "OCF Lab", - "type": "開放原始碼", - "description": "由開放文化基金會創立,關注開放的新聞平台。", - "jobs": [ - "行銷公關", - "美術與設計", - "文字工作者", - "文字工作者", - "文字工作者", - "議題工作者" - ], - "requirements": { - "行銷公關": 2, - "美術與設計": 2, - "文字工作者": 5, - "議題工作者": 3 - } - } ] diff --git a/packages/webapp/src/game/game.ts b/packages/webapp/src/game/game.ts index be4f26a2..5676aeb7 100644 --- a/packages/webapp/src/game/game.ts +++ b/packages/webapp/src/game/game.ts @@ -1,85 +1,25 @@ -import { Game, MoveFn, PlayerID } from 'boardgame.io'; -import { INVALID_MOVE } from 'boardgame.io/core'; -import projectCards from './data/card/projects.json'; -import jobCards from './data/card/jobs.json'; -import forceCards from './data/card/forces.json'; -import eventCards from './data/card/events.json'; -import { recruit } from './moves/recruit'; -import { contributeOwnedProjects } from './moves/contributeOwnedProjects'; -import { removeAndRefillJobs } from './moves/removeAndRefillJobs'; -import { contributeJoinedProjects } from './moves/contributeJoinedProjects'; -import { mirror } from './moves/mirror'; -import { createProject } from './moves/createProject'; -import { ProjectCard } from './card'; -import { Player, PlayersMutator } from './store/slice/players'; -import GameStore, { GameState } from './store/store'; -import { DeckMutator, DeckSelector } from './store/slice/deck'; -import { ProjectBoardMutator, ProjectBoardSelector } from './store/slice/projectBoard'; -import { selectors } from './store/slice/projectSlot/projectSlot.selectors'; -import { CardsMutator } from './store/slice/cards'; -import { ActionSlotsMutator } from './store/slice/actionSlots'; - -type PartialBy = Omit & Partial>; +import { Game } from 'boardgame.io'; +import { GameState } from './store/store'; +import { setup } from './core/setup'; +import { playerView } from './core/playerView'; +import { action } from './core/stage/action/action'; +import { playEventCard } from './core/handler/playEventCard'; +import { removeEventCard } from './core/handler/removeEventCard'; +import { passStartPlayerToken } from './core/handler/passStartPlayerToken'; +import { PlayersSelector } from './store/slice/players'; +import { settleProjects } from './core/handler/settleProjects'; +import { scoreLeftoverActionTokens } from './core/handler/scoreLeftoverActionTokens'; +import { refill } from './core/handler/refill'; export const OpenStarTerVillage: Game = { - setup: ({ ctx }) => { - const G = GameStore.initialState(); - - DeckMutator.initialize(G.decks.projects, projectCards as unknown as ProjectCard[]); - DeckMutator.initialize(G.decks.jobs, jobCards); - DeckMutator.initialize(G.decks.forces, forceCards); - DeckMutator.initialize(G.decks.events, eventCards); - - PlayersMutator.initialize(G.players, ctx.playOrder); - - return G; - }, - moves: { - - }, - phases: { - play: { - start: true, - onBegin: ({ random, G }) => { - // shuffle cards - const shuffler = random.Shuffle; - DeckMutator.shuffleDrawPile(G.decks.events, shuffler); - - DeckMutator.shuffleDrawPile(G.decks.projects, shuffler); - const maxProjectCards = 2; - for (let playerId in G.players) { - const projectCards = DeckSelector.peek(G.decks.projects, maxProjectCards); - DeckMutator.draw(G.decks.projects, maxProjectCards); - CardsMutator.add(G.players[playerId].hand.projects, projectCards); - } - - const isForceCardsEnabled = false; - if (isForceCardsEnabled) { - DeckMutator.shuffleDrawPile(G.decks.forces, shuffler); - const maxForceCards = 2; - for (let playerId in G.players) { - const forceCards = DeckSelector.peek(G.decks.forces, maxForceCards); - DeckMutator.draw(G.decks.forces, maxForceCards); - CardsMutator.add(G.players[playerId].hand.forces, forceCards); - } - } - - DeckMutator.shuffleDrawPile(G.decks.jobs, shuffler); - const maxJobCards = 5; - const jobCards = DeckSelector.peek(G.decks.jobs, maxJobCards); - DeckMutator.draw(G.decks.jobs, maxJobCards); - CardsMutator.add(G.table.jobSlots, jobCards); - - for (let playerId in G.players) { - G.players[playerId].token.workers = 10; - G.players[playerId].token.actions = 3; - } - }, - }, - }, + setup: setup, turn: { - onBegin: () => { - // roundStart do something + onBegin: (context) => { + const { ctx } = context; + if (ctx.playOrderPos === 0) { + console.log('first player starts'); + playEventCard(context); + } }, /** * send current player to action stage. @@ -95,143 +35,23 @@ export const OpenStarTerVillage: Game = { }, }, stages: { - action: { - moves: { - createProject: { - client: false, - move: createProject, - }, - recruit: { - // client cannot see decks, discard job card should evaluated on server side - client: false, - move: recruit, - }, - contributeOwnedProjects: { - client: false, - move: contributeOwnedProjects, - }, - contributeJoinedProjects: { - client: false, - move: contributeJoinedProjects, - }, - removeAndRefillJobs: { - client: false, - move: removeAndRefillJobs, - }, - mirror: { - client: false, - move: mirror, - }, - }, - next: 'settle', - }, - settle: { - moves: { - settle: { - client: false, - // client trigger settle project and move on to next stage - move: (({ G }) => { - const activeProjects = G.table.projectBoard; - const fulfilledProjects = ProjectBoardSelector.filterFulfilled(activeProjects); - if (fulfilledProjects.length === 0) { - return; - } - fulfilledProjects.forEach(project => { - // Update Score - Object.keys(G.players).forEach(playerId => { - const victoryPoints = selectors.getPlayerContribution(project, playerId); - G.players[playerId].victoryPoints += victoryPoints; - }); - // Return Tokens - Object.keys(G.players).forEach(playerId => { - const workerTokens = selectors.getPlayerWorkerTokens(project, playerId); - G.players[playerId].token.workers += workerTokens; - }); - }); - // Update OpenSourceTree - // Remove from table - ProjectBoardMutator.remove(activeProjects, fulfilledProjects); - // Discard Project Card - const projectCards = fulfilledProjects.map(project => project.card); - DeckMutator.discard(G.decks.projects, projectCards); - }), - }, - }, - next: 'discard', - }, - discard: { - moves: { - discardProjects: { - noLimit: true, - move: () => { }, - }, - discardForces: () => { - const isForceCardsEnabled = false; - if (!isForceCardsEnabled) { - return INVALID_MOVE; - } - }, - }, - next: 'refill', - }, - refill: { - moves: { - refillAndEnd: { - client: false, - move: (context) => { - const refillProject: MoveFn = (({G, ctx}) => { - const maxProjectCards = 2; - const refillCardNumber = maxProjectCards - G.players[ctx.currentPlayer].hand.projects.length; - const projectCards = DeckSelector.peek(G.decks.projects, refillCardNumber); - DeckMutator.draw(G.decks.projects, refillCardNumber); - CardsMutator.add(G.players[ctx.currentPlayer].hand.projects, projectCards); - }); - - const refillForce: MoveFn = (({G, ctx}) => { - const maxForceCards = 2; - const refillCardNumber = maxForceCards - G.players[ctx.currentPlayer].hand.forces.length; - const forceCards = DeckSelector.peek(G.decks.forces, refillCardNumber); - DeckMutator.draw(G.decks.forces, refillCardNumber); - CardsMutator.add(G.players[ctx.currentPlayer].hand.forces, forceCards); - }); - - // refill cards - refillProject(context); - const isForceCardsEnabled = false; - if (isForceCardsEnabled) { - refillForce(context); - } - - const { G, ctx, events} = context; - // refill action points - G.players[ctx.currentPlayer].token.actions = 3; - - // reset active moves - ActionSlotsMutator.reset(G.table.actionSlots); - events.endTurn() - }, - } - }, - }, + action, }, - onEnd: () => { }, - }, - playerView: ({ G, ctx, playerID}) => { - const { decks, players, ...view } = G; - const publicPlayers: Record> = {}; - for (let id in players) { - if (id === playerID) { - publicPlayers[id] = players[id]; - } else { - // hide hand from the other players and observers - const { hand, ...player } = players[id]; - publicPlayers[id] = player; + endIf: ({ G, ctx }) => { + return PlayersSelector.getNumActionTokens(G.players, ctx.currentPlayer) === 0; + }, + onEnd: (context) => { + const { ctx } = context; + settleProjects(context); + scoreLeftoverActionTokens(context); + refill(context); + if (ctx.playOrderPos === ctx.numPlayers - 1) { + console.log('last player ends'); + removeEventCard(context); + + passStartPlayerToken(context); } - } - - return { - ...view, - players: publicPlayers, - }; + }, }, + playerView, }; diff --git a/packages/webapp/src/game/index.ts b/packages/webapp/src/game/index.ts index 03c0cfc5..263e752e 100644 --- a/packages/webapp/src/game/index.ts +++ b/packages/webapp/src/game/index.ts @@ -1,13 +1,15 @@ export type { GameState } from "./store/store"; -export type { BaseCard, EventCard, ForceCard, JobCard, JobName, ProjectCard } from "./card"; +export type { EventCard, JobCard, JobName, ProjectCard } from "./card"; export type { Deck as DeckState } from './store/slice/deck'; export type { Decks as DecksState } from './store/slice/decks'; export type { ActionSlot as ActionSlotState } from './store/slice/actionSlot'; export type { ActionSlots as ActionSlotsState } from './store/slice/actionSlots'; -export type { ProjectSlot as ProjectSlotState } from './store/slice/projectSlot/projectSlot'; +export type { ProjectSlot as ProjectSlotState, ProjectSlotID } from './store/slice/projectSlot/projectSlot'; export type { ProjectBoard as ProjectBoardState } from './store/slice/projectBoard'; +export type { Rule as RuleState } from './store/slice/rule'; +export type { ScoreBoard as ScoreBoardState } from './store/slice/scoreBoard'; +export type { JobSlots as JobSlotsState } from './store/slice/jobSlots'; export type { EventSlot as EventSlotState } from './store/slice/table'; -export type { JobSlots as JobSlotsState } from './store/slice/table'; export type { Table as TableState } from './store/slice/table'; export type { Player as PlayerState, Players as PlayersState } from './store/slice/players'; diff --git a/packages/webapp/src/game/moves/contributeJoinedProjects.ts b/packages/webapp/src/game/moves/contributeJoinedProjects.ts deleted file mode 100644 index f3755b83..00000000 --- a/packages/webapp/src/game/moves/contributeJoinedProjects.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { INVALID_MOVE } from 'boardgame.io/core'; -import { isInRange } from '../utils'; -import { ProjectBoardSelector } from '../store/slice/projectBoard'; -import { ProjectSlotMutator, ProjectSlotSelector } from '../store/slice/projectSlot/projectSlot'; -import { GameMove, ContributionAction } from './type'; -import { ActionSlotMutator, ActionSlotSelector } from '../store/slice/actionSlot'; - -export type ContributeJoinedProjects = (contributions: ContributionAction[]) => void; - -export const contributeJoinedProjects: GameMove = ({ G, ctx, playerID }, contributions) => { - if (!ActionSlotSelector.isAvailable(G.table.actionSlots.contributeJoinedProjects)) { - return INVALID_MOVE; - } - - const currentPlayer = playerID; - const currentPlayerToken = G.players[currentPlayer].token; - const contributeActionCosts = 1; - if (currentPlayerToken.actions < contributeActionCosts) { - return INVALID_MOVE; - } - const activeProjects = G.table.projectBoard; - const isInvalid = contributions.map(({ activeProjectIndex, jobName }) => { - if (!isInRange(activeProjectIndex, activeProjects.length)) { - return true; - } - const activeProject = ProjectBoardSelector.getById(activeProjects, activeProjectIndex); - if (activeProject.owner === currentPlayer) { - return true; - } - - if (!ProjectSlotSelector.hasWorker(activeProject, jobName, currentPlayer)) { - return true; - } - }).some(x => x); - if (isInvalid) { - return INVALID_MOVE; - } - const totalContributions = contributions.map(({ value }) => value).reduce((a, b) => a + b, 0); - const maxJoinedContributions = 3; - if (totalContributions > maxJoinedContributions) { - return INVALID_MOVE; - } - - // deduct action tokens - currentPlayerToken.actions -= contributeActionCosts; - contributions.forEach(({ activeProjectIndex, jobName, value }) => { - // update contributions to given contribution points - const activeProject = ProjectBoardSelector.getById(G.table.projectBoard, activeProjectIndex); - ProjectSlotMutator.pushWorker(activeProject, jobName, currentPlayer, value); - }); - - ActionSlotMutator.occupy(G.table.actionSlots.contributeJoinedProjects); -}; diff --git a/packages/webapp/src/game/moves/contributeOwnedProjects.ts b/packages/webapp/src/game/moves/contributeOwnedProjects.ts deleted file mode 100644 index 8d8bbaed..00000000 --- a/packages/webapp/src/game/moves/contributeOwnedProjects.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { INVALID_MOVE } from 'boardgame.io/core'; -import { isInRange } from '../utils'; -import { ProjectBoardSelector } from '../store/slice/projectBoard'; -import { ProjectSlotMutator, ProjectSlotSelector } from '../store/slice/projectSlot/projectSlot'; -import { GameMove, ContributionAction } from './type'; -import { ActionSlotMutator, ActionSlotSelector } from '../store/slice/actionSlot'; - -export type ContributeOwnedProjects = (contributions: ContributionAction[]) => void; - -export const contributeOwnedProjects: GameMove = ({ G, playerID }, contributions) => { - if (!ActionSlotSelector.isAvailable(G.table.actionSlots.contributeOwnedProjects)) { - return INVALID_MOVE; - } - - const currentPlayer = playerID; - const currentPlayerToken = G.players[currentPlayer].token; - const contributeActionCosts = 1; - if (currentPlayerToken.actions < contributeActionCosts) { - return INVALID_MOVE; - } - const activeProjects = G.table.projectBoard; - const isInvalid = contributions.map(({ activeProjectIndex, jobName }) => { - if (!isInRange(activeProjectIndex, activeProjects.length)) { - return true; - } - const activeProject = ProjectBoardSelector.getById(activeProjects, activeProjectIndex); - if (activeProject.owner !== currentPlayer) { - return true; - } - - if (!ProjectSlotSelector.hasWorker(activeProject, jobName, currentPlayer)) { - return true; - } - }).some(x => x); - if (isInvalid) { - return INVALID_MOVE; - } - const totalContributions = contributions.map(({ value }) => value).reduce((a, b) => a + b, 0); - const maxOwnedContributions = 4; - if (totalContributions > maxOwnedContributions) { - return INVALID_MOVE; - } - - // deduct action tokens - currentPlayerToken.actions -= contributeActionCosts; - contributions.forEach(({ activeProjectIndex, jobName, value }) => { - // update contributions to given contribution points - const activeProject = ProjectBoardSelector.getById(G.table.projectBoard, activeProjectIndex); - ProjectSlotMutator.pushWorker(activeProject, jobName, currentPlayer, value); - }); - - ActionSlotMutator.occupy(G.table.actionSlots.contributeOwnedProjects); -}; diff --git a/packages/webapp/src/game/moves/createProject.ts b/packages/webapp/src/game/moves/createProject.ts deleted file mode 100644 index 8a2bf29f..00000000 --- a/packages/webapp/src/game/moves/createProject.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { INVALID_MOVE } from 'boardgame.io/core'; -import { isInRange } from '../utils'; -import { ProjectBoardMutator, ProjectBoardSelector } from '../store/slice/projectBoard'; -import { DeckMutator, DeckSelector } from '../store/slice/deck'; -import { ProjectSlotMutator } from '../store/slice/projectSlot/projectSlot'; -import { GameMove } from './type'; -import { CardsMutator, CardsSelector } from '../store/slice/cards'; -import { ActionSlotMutator, ActionSlotSelector } from '../store/slice/actionSlot'; - -export type CreateProject = (projectCardIndex: number, jobCardIndex: number) => void; - -export const createProject: GameMove = ({ G, playerID }, projectCardIndex, jobCardIndex) => { - if (!ActionSlotSelector.isAvailable(G.table.actionSlots.createProject)) { - return INVALID_MOVE; - } - - const currentPlayer = playerID; - const currentPlayerToken = G.players[currentPlayer].token; - // TODO: replace hardcoded number with dynamic rules - const createProjectActionCosts = 2; - if (currentPlayerToken.actions < createProjectActionCosts) { - return INVALID_MOVE; - } - const createProjectWorkerCosts = 1; - if (currentPlayerToken.workers < createProjectWorkerCosts) { - return INVALID_MOVE; - } - - // check project card in in hand - const currentHandProjects = G.players[currentPlayer].hand.projects; - if (!isInRange(projectCardIndex, currentHandProjects.length)) { - return INVALID_MOVE; - } - - // check job card is on the table - const currentJobs = G.table.jobSlots; - if (!isInRange(jobCardIndex, currentJobs.length)) { - return INVALID_MOVE; - } - - // check job card is required in project - const projectCard = CardsSelector.getById(currentHandProjects, projectCardIndex); - const jobCard = CardsSelector.getById(currentJobs, jobCardIndex); - if (!Object.keys(projectCard.requirements).includes(jobCard.name)) { - return INVALID_MOVE; - } - - // reduce action tokens - currentPlayerToken.actions -= createProjectActionCosts; - CardsMutator.removeOne(currentHandProjects, projectCard); - CardsMutator.removeOne(currentJobs, jobCard); - - // initial active project - ProjectBoardMutator.add(G.table.projectBoard, projectCard, currentPlayer); - const activeProject = ProjectBoardSelector.getLast(G.table.projectBoard); - - // reduce worker token - currentPlayerToken.workers -= createProjectWorkerCosts; - // assign worker token - const jobInitPoints = 1; - ProjectSlotMutator.assignWorker(activeProject, jobCard.name, currentPlayer, jobInitPoints); - // score victory points - const createProjectVictoryPoints = 1; - G.players[currentPlayer].victoryPoints += createProjectVictoryPoints; - - // discard job card - DeckMutator.discard(G.decks.jobs, [jobCard]); - - // Refill job card - const maxJobCards = 5; - const refillCardNumber = maxJobCards - currentJobs.length; - const jobCards = DeckSelector.peek(G.decks.jobs, refillCardNumber); - DeckMutator.draw(G.decks.jobs, refillCardNumber); - CardsMutator.add(currentJobs, jobCards); - - ActionSlotMutator.occupy(G.table.actionSlots.createProject); -}; diff --git a/packages/webapp/src/game/moves/recruit.ts b/packages/webapp/src/game/moves/recruit.ts deleted file mode 100644 index 9c909faa..00000000 --- a/packages/webapp/src/game/moves/recruit.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { INVALID_MOVE } from 'boardgame.io/core'; -import { isInRange } from '../utils'; -import { ProjectBoardSelector } from '../store/slice/projectBoard'; -import { DeckMutator, DeckSelector } from '../store/slice/deck'; -import { ProjectSlotMutator, ProjectSlotSelector } from '../store/slice/projectSlot/projectSlot'; -import { GameMove } from './type'; -import { CardsMutator, CardsSelector } from '../store/slice/cards'; -import { ActionSlotMutator, ActionSlotSelector } from '../store/slice/actionSlot'; - -export type Recruit = (resourceCardIndex: number, activeProjectIndex: number) => void; - -export const recruit: GameMove = ({ G, playerID }, jobCardIndex, activeProjectIndex) => { - if (!ActionSlotSelector.isAvailable(G.table.actionSlots.recruit)) { - return INVALID_MOVE; - } - - const currentPlayer = playerID; - const currentPlayerToken = G.players[currentPlayer].token; - const recruitActionCosts = 1; - if (currentPlayerToken.actions < recruitActionCosts) { - return INVALID_MOVE; - } - const recruitWorkerCosts = 1; - if (currentPlayerToken.workers < recruitWorkerCosts) { - return INVALID_MOVE; - } - - const currentJobs = G.table.jobSlots; - if (!isInRange(jobCardIndex, currentJobs.length)) { - return INVALID_MOVE; - } - - const activeProjects = G.table.projectBoard; - if (!isInRange(activeProjectIndex, activeProjects.length)) { - return INVALID_MOVE; - } - const jobCard = CardsSelector.getById(currentJobs, jobCardIndex); - const activeProject = ProjectBoardSelector.getById(G.table.projectBoard, activeProjectIndex); - const jobContribution = ProjectSlotSelector.getJobContribution(activeProject, jobCard.name); - // Check job requirment is not fulfilled yet - if (jobContribution >= activeProject.card.requirements[jobCard.name]) { - return INVALID_MOVE; - } - // User cannot place more than one worker in same job - if (ProjectSlotSelector.hasWorker(activeProject, jobCard.name, currentPlayer)) { - return INVALID_MOVE; - } - - // reduce action - currentPlayerToken.actions -= recruitActionCosts; - CardsMutator.removeOne(currentJobs, jobCard); - - // reduce worker tokens - currentPlayerToken.workers -= recruitWorkerCosts; - // assign worker token - const jobInitPoints = 1; - ProjectSlotMutator.assignWorker(activeProject, jobCard.name, currentPlayer, jobInitPoints); - - // discard job card - DeckMutator.discard(G.decks.jobs, [jobCard]); - - // Refill job card - const maxJobCards = 5; - const refillCardNumber = maxJobCards - currentJobs.length; - const jobCards = DeckSelector.peek(G.decks.jobs, refillCardNumber); - DeckMutator.draw(G.decks.jobs, refillCardNumber); - CardsMutator.add(currentJobs, jobCards); - - ActionSlotMutator.occupy(G.table.actionSlots.recruit); -}; diff --git a/packages/webapp/src/game/moves/removeAndRefillJobs.ts b/packages/webapp/src/game/moves/removeAndRefillJobs.ts deleted file mode 100644 index dc6fbbd1..00000000 --- a/packages/webapp/src/game/moves/removeAndRefillJobs.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { INVALID_MOVE } from 'boardgame.io/core'; -import { isInRange } from '../utils'; -import { DeckMutator, DeckSelector } from '../store/slice/deck'; -import { GameMove } from './type'; -import { CardsMutator } from '../store/slice/cards'; -import { ActionSlotMutator, ActionSlotSelector } from '../store/slice/actionSlot'; - -export type RemoveAndRefillJobs = (jobCardIndices: number[]) => void; -export const removeAndRefillJobs: GameMove = ({ G }, jobCardIndices) => { - if (!ActionSlotSelector.isAvailable(G.table.actionSlots.removeAndRefillJobs)) { - return INVALID_MOVE; - } - - const currentJob = G.table.jobSlots; - const jobDeck = G.decks.jobs; - const isInvalid = jobCardIndices.map(index => !isInRange(index, currentJob.length)).some(x => x); - if (isInvalid) { - return INVALID_MOVE; - } - const removedJobCards = jobCardIndices.map(index => currentJob[index]); - CardsMutator.remove(currentJob, removedJobCards); - DeckMutator.discard(jobDeck, removedJobCards); - - const maxJobCards = 5; - const refillCardNumber = maxJobCards - currentJob.length; - const jobCards = DeckSelector.peek(jobDeck, refillCardNumber); - DeckMutator.draw(jobDeck, refillCardNumber); - CardsMutator.add(currentJob, jobCards); - - ActionSlotMutator.occupy(G.table.actionSlots.removeAndRefillJobs); -}; diff --git a/packages/webapp/src/game/moves/type.d.ts b/packages/webapp/src/game/moves/type.d.ts deleted file mode 100644 index f535e48a..00000000 --- a/packages/webapp/src/game/moves/type.d.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { FnContext, PlayerID } from 'boardgame.io'; -import { INVALID_MOVE } from 'boardgame.io/core'; -import { JobName } from '../card'; -import { GameState } from '../store/store'; -import { CreateProject } from './createProject'; -import { Mirror } from './mirror'; -import { RemoveAndRefillJobs } from './removeAndRefillJobs'; -import { ContributeJoinedProjects } from './contributeJoinedProjects'; -import { ContributeOwnedProjects } from './contributeOwnedProjects'; -import { Recruit } from './recruit'; - -export type AllMoves = ActionMoves & StageMoves; -export type AllMoveNames = keyof AllMoves; - -export interface ActionMoves { - createProject: CreateProject; - recruit: Recruit; - contributeOwnedProjects: ContributeOwnedProjects; - contributeJoinedProjects: ContributeJoinedProjects; - removeAndRefillJobs: RemoveAndRefillJobs; - mirror: Mirror; -}; -export type ActionMove = keyof ActionMoves; - -export interface StageMoves { - settle: Settle; - refillAndEnd: RefillAndEnd; -}; -export type StageMoveNames = keyof StageMoves; - -export type Contribution = { jobName: JobName; value: number } -export interface ContributionAction extends Contribution { - activeProjectIndex: number; -} - -export type Settle = () => void; -export type RefillAndEnd = () => void; -export type RefillProject = () => void; -export type RefillForce = () => void; - -// Define the type of a move to support type checking -export type GameMove void> = (context: FnContext & { playerID: PlayerID }, ...args: Parameters) => void | GameState | typeof INVALID_MOVE; diff --git a/packages/webapp/src/game/store/slice/actionSlot.ts b/packages/webapp/src/game/store/slice/actionSlot.ts index 9ec5263e..13e0465e 100644 --- a/packages/webapp/src/game/store/slice/actionSlot.ts +++ b/packages/webapp/src/game/store/slice/actionSlot.ts @@ -1,14 +1,11 @@ export interface ActionSlot { - isActive: boolean; isOccupied: boolean; } export const initialState = (): ActionSlot => ({ - isActive: true, isOccupied: false, }); const reset = (state: ActionSlot) => { - state.isActive = true; state.isOccupied = false; }; @@ -16,10 +13,6 @@ const occupy = (state: ActionSlot) => { state.isOccupied = true; } -const isAvailable = (state: ActionSlot) => { - return state.isActive && !state.isOccupied; -}; - const ActionSlotSlice = { initialState, mutators: { @@ -27,7 +20,8 @@ const ActionSlotSlice = { reset, }, selectors: { - isAvailable, + isAvailable: (state: ActionSlot) => !state.isOccupied, + isOccupied: (state: ActionSlot) => state.isOccupied, }, }; diff --git a/packages/webapp/src/game/store/slice/actionSlots.ts b/packages/webapp/src/game/store/slice/actionSlots.ts index b6f2be9a..55a68b56 100644 --- a/packages/webapp/src/game/store/slice/actionSlots.ts +++ b/packages/webapp/src/game/store/slice/actionSlots.ts @@ -1,7 +1,7 @@ -import { ActionMove } from "../../moves/type"; +import { ActionMoveName } from "@/game/core/stage/action/move/type"; import ActionSlotSlice, { ActionSlot, ActionSlotMutator } from "./actionSlot"; -export type ActionSlots = Record; +export type ActionSlots = Record; const initialState = (): ActionSlots => ({ contributeJoinedProjects: ActionSlotSlice.initialState(), diff --git a/packages/webapp/src/game/store/slice/cards.ts b/packages/webapp/src/game/store/slice/cards.ts deleted file mode 100644 index 5d0143a4..00000000 --- a/packages/webapp/src/game/store/slice/cards.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { filterInplace } from '../../utils'; - -const getById = (cards: T[], index: number): T => { - return cards[index]; -}; -const add = (cards: T[], newCards: T[]): void => { - cards.push(...newCards); -}; -const addOne = (cards: T[], newCard: T): void => { - cards.push(newCard); -}; -const remove = (cards: T[], discardCards: T[]): void => { - // O(M+N) - // convert discard cards to card => number map - const discardCardsMap = new Map(); - for (let discardCard of discardCards) { - discardCardsMap.set(discardCard, (discardCardsMap.get(discardCard) ?? 0) + 1); - } - // remove discard card as empty in cards - filterInplace(cards, (card) => { - if ((discardCardsMap.get(card) ?? 0) > 0) { - discardCardsMap.set(card, discardCardsMap.get(card)! - 1); - return false; - } - return true; - }); -} -const removeOne = (cards: T[], discardCard: T): void => { - return remove(cards, [discardCard]); -}; - -const initialState = (): T[] => []; - -const CardsSlice = { - initialState, - mutators: { - add, - addOne, - remove, - removeOne, - }, - selectors: { - getById, - }, -}; - -export const CardsMutator = CardsSlice.mutators; -export const CardsSelector = CardsSlice.selectors; -export default CardsSlice; diff --git a/packages/webapp/src/game/store/slice/deck.ts b/packages/webapp/src/game/store/slice/deck.ts index 1889566b..bd37d6e7 100644 --- a/packages/webapp/src/game/store/slice/deck.ts +++ b/packages/webapp/src/game/store/slice/deck.ts @@ -8,21 +8,11 @@ const initialState = (): Deck => ({ discardPile: [], }); -type Shuffler = (pile: T[]) => T[]; - const initialize = (state: Deck, cards: T[]): void => { state.drawPile = cards; state.discardPile = []; } -const shuffleDrawPile = (state: Deck, shuffler: Shuffler): void => { - state.drawPile = shuffler(state.drawPile); -} - -const shuffleDiscardPile = (state: Deck, shuffler: Shuffler): void => { - state.discardPile = shuffler(state.discardPile); -} - const moveDiscardPileUnderDrawPile = (state: Deck): void => { state.drawPile.push(...state.discardPile); state.discardPile = []; @@ -44,8 +34,6 @@ const DeckSlice = { initialState, mutators: { initialize, - shuffleDrawPile, - shuffleDiscardPile, moveDiscardPileUnderDrawPile, draw, discard, diff --git a/packages/webapp/src/game/store/slice/decks.ts b/packages/webapp/src/game/store/slice/decks.ts index fe62045e..3f3d7390 100644 --- a/packages/webapp/src/game/store/slice/decks.ts +++ b/packages/webapp/src/game/store/slice/decks.ts @@ -1,17 +1,15 @@ -import { EventCard, ForceCard, JobCard, ProjectCard } from "../../card"; +import { EventCard, JobCard, ProjectCard } from "../../card"; import DeckSlice, { Deck } from "./deck"; export type Decks = { projects: Deck; jobs: Deck; - forces: Deck; events: Deck; }; const initialState = (): Decks => ({ projects: DeckSlice.initialState(), jobs: DeckSlice.initialState(), - forces: DeckSlice.initialState(), events: DeckSlice.initialState(), }); diff --git a/packages/webapp/src/game/store/slice/jobSlots.ts b/packages/webapp/src/game/store/slice/jobSlots.ts new file mode 100644 index 00000000..01164a74 --- /dev/null +++ b/packages/webapp/src/game/store/slice/jobSlots.ts @@ -0,0 +1,47 @@ +import { JobCard } from "../../card"; + +export type JobSlots = JobCard[]; + +const initialState = (): JobSlots => []; + +const mutators = { + removeJobCards: (state: JobSlots, jobCards: JobCard[]): void => { + jobCards.forEach((jobCard) => { + const index = state.findIndex((card) => card.id === jobCard.id); + if (index !== -1) { + state.splice(index, 1); + } + }); + }, + removeJobCard: (state: JobSlots, jobCard: JobCard): void => { + const index = state.findIndex((card) => card.id === jobCard.id); + if (index !== -1) { + state.splice(index, 1); + } + }, + addJobCards: (state: JobSlots, jobCards: JobCard[]): void => { + state.push(...jobCards); + }, +}; + +const selectors = { + getJobCardById: (state: JobSlots, id: string): JobCard | undefined => { + return state.find((card) => card.id === id); + }, + getJobCardsByIds: (state: JobSlots, ids: string[]): JobCard[] => { + return state.filter((card) => ids.includes(card.id)); + }, + getNumFilledSlots: (state: JobSlots): number => { + return state.length; + }, +}; + +const JobSlotsSlice = { + initialState, + mutators, + selectors, +}; + +export const JobSlotsMutator = JobSlotsSlice.mutators; +export const JobSlotsSelector = JobSlotsSlice.selectors; +export default JobSlotsSlice; diff --git a/packages/webapp/src/game/store/slice/players.ts b/packages/webapp/src/game/store/slice/players.ts index a0d19f23..ba9c9088 100644 --- a/packages/webapp/src/game/store/slice/players.ts +++ b/packages/webapp/src/game/store/slice/players.ts @@ -1,9 +1,8 @@ import { PlayerID } from "boardgame.io"; -import { ForceCard, ProjectCard } from "../../card"; +import { ProjectCard } from "../../card"; export interface Hand { projects: ProjectCard[]; - forces: ForceCard[]; } export interface Player { @@ -12,35 +11,96 @@ export interface Player { workers: number; actions: number; }; - completed: { - projects: ProjectCard[]; - }; - victoryPoints: number; } const playerInitialState = (): Player => ({ - hand: { projects: [], forces: [] }, + hand: { projects: [] }, token: { workers: 0, actions: 0 }, - completed: { projects: [] }, - victoryPoints: 0, }); export type Players = Record; const initialState = (): Players => ({}); -export const initialize = (state: Players, playerNames: string[]): void => { +const initialize = (state: Players, playerNames: PlayerID[]): void => { playerNames.forEach(player => { state[player] = playerInitialState(); }); } +const getNumWorkerTokens = (state: Players, playerId: PlayerID): number => { + return state[playerId].token.workers; +}; + +const getNumActionTokens = (state: Players, playerId: PlayerID): number => { + return state[playerId].token.actions; +}; + +const getNumProjects = (state: Players, playerId: PlayerID): number => { + return state[playerId].hand.projects.length; +}; + +const getProjectCards = (state: Players, playerId: PlayerID): ProjectCard[] => { + return state[playerId].hand.projects; +} + +const getProjectCardById = (state: Players, playerId: PlayerID, projectId: string): ProjectCard | undefined => { + return state[playerId].hand.projects.find(p => p.id === projectId); +} + +const addProjects = (state: Players, playerId: PlayerID, projects: ProjectCard[]): void => { + state[playerId].hand.projects.push(...projects); +}; + +const useProject = (state: Players, playerId: PlayerID, project: ProjectCard): void => { + // remove the first project that matches the project card + const index = state[playerId].hand.projects.findIndex(p => p.id === project.id); + if (index !== -1) { + state[playerId].hand.projects.splice(index, 1); + } +}; + +const addWorkerTokens = (state: Players, playerId: PlayerID, numWorkers: number): void => { + state[playerId].token.workers += numWorkers; +}; + +const useWorkerTokens = (state: Players, playerId: PlayerID, numWorkers: number): void => { + state[playerId].token.workers -= numWorkers; +}; + +const resetWorkerTokens = (state: Players, playerId: PlayerID, numWorkers: number): void => { + state[playerId].token.workers = numWorkers; +}; + +const useActionTokens = (state: Players, playerId: PlayerID, numActions: number): void => { + state[playerId].token.actions -= numActions; +}; + +const resetActionTokens = (state: Players, playerId: PlayerID, numActions: number): void => { + state[playerId].token.actions = numActions; +}; + const PlayersSlice = { initialState, mutators: { initialize, + addProjects, + useProject, + addWorkerTokens, + useWorkerTokens, + resetWorkerTokens, + useActionTokens, + resetActionTokens, + }, + selectors: { + getNumWorkerTokens, + getNumActionTokens, + getNumProjects, + getProjectCards, + getProjectCardById, }, }; export const PlayersMutator = PlayersSlice.mutators; +export const PlayersSelector = PlayersSlice.selectors; export default PlayersSlice; diff --git a/packages/webapp/src/game/store/slice/projectBoard.ts b/packages/webapp/src/game/store/slice/projectBoard.ts index 917ea2ab..95f06612 100644 --- a/packages/webapp/src/game/store/slice/projectBoard.ts +++ b/packages/webapp/src/game/store/slice/projectBoard.ts @@ -1,51 +1,78 @@ -import { PlayerID } from 'boardgame.io'; -import { filterInplace } from '../../utils'; import { ProjectCard } from '../../card'; -import { ProjectSlot, ProjectSlotSelector } from './projectSlot/projectSlot'; +import ProjectSlotSlice, { ProjectSlot, ProjectSlotSelector } from './projectSlot/projectSlot'; export type ProjectBoard = ProjectSlot[]; const initialState = (): ProjectSlot[] => []; -const add = (state: ProjectSlot[], card: ProjectCard, owner: PlayerID): void => { - const activeProject: ProjectSlot = { - card, - owner, - contributions: [], - }; - state.push(activeProject); +const initialize = (state: ProjectSlot[], maxProjectSlots: number): void => { + for (let i = 0; i < maxProjectSlots; i++) { + const slotId = `project-slot-${i}`; + state.push(ProjectSlotSlice.initialState(slotId)); + } } -const remove = (state: ProjectSlot[], removedProjects: ProjectSlot[]): void => { - filterInplace(state, project => !removedProjects.includes(project)); +const add = (state: ProjectSlot[], card: ProjectCard): void => { + const firstEmptySlot = state.find(project => !project.card); + if (!firstEmptySlot) { + throw new Error('No empty project slot'); + } + firstEmptySlot.card = card; +} + +const remove = (state: ProjectSlot[], removedSlots: ProjectSlot[]): void => { + removedSlots.forEach(removeSlot => { + const slotIndexToBeRemoved = state.findIndex(slot => slot.id === removeSlot.id); + if (slotIndexToBeRemoved === -1) { + throw new Error('Project slot not found'); + } + state[slotIndexToBeRemoved] = ProjectSlotSlice.initialState(removeSlot.id); + }); } const getById = (state: ProjectSlot[], index: number): ProjectSlot => { return state[index]; } -const getLast = (state: ProjectSlot[]): ProjectSlot => { - return state[state.length - 1]; +const getBySlotId = (state: ProjectSlot[], id: string): ProjectSlot | undefined => { + const slot = state.find(project => project.id === id); + return slot; +} + +const getSlotByCard = (state: ProjectSlot[], card: ProjectCard): ProjectSlot => { + const slot = state.find(project => project.card?.id === card.id); + if (!slot) { + throw new Error('Project slot not found'); + } + return slot; } -const filterFulfilled = (state: ProjectSlot[]): ProjectSlot[] => { +const getRequirementFulfilled = (state: ProjectSlot[]): ProjectSlot[] => { return state.filter(project => { - const fulfilledThresholds = Object.keys(project.card.requirements) - .map(jobName => ProjectSlotSelector.getJobContribution(project, jobName) >= project.card.requirements[jobName]); - return fulfilledThresholds.every(x => x); + if (!project.card) { + return false; + } + for (const jobName in project.card.requirements) { + if (ProjectSlotSelector.getJobContribution(project, jobName) < project.card.requirements[jobName]) { + return false; + } + } + return true; }); } const ProjectBoardSlice = { initialState, mutators: { + initialize, add, remove, }, selectors: { getById, - getLast, - filterFulfilled, + getBySlotId, + getSlotByCard, + getRequirementFulfilled, }, }; diff --git a/packages/webapp/src/game/store/slice/projectSlot/projectSlot.mutators.ts b/packages/webapp/src/game/store/slice/projectSlot/projectSlot.mutators.ts index 19b5c19d..40a50a32 100644 --- a/packages/webapp/src/game/store/slice/projectSlot/projectSlot.mutators.ts +++ b/packages/webapp/src/game/store/slice/projectSlot/projectSlot.mutators.ts @@ -1,22 +1,38 @@ import { PlayerID } from "boardgame.io"; import { JobName } from "../../../card"; import { findContribution } from "./projectSlot.utils"; -import { ProjectSlot, ProjectContribution } from "./projectSlot"; - -const assignWorker = (state: ProjectSlot, jobName: JobName, playerId: PlayerID, points: number): void => { - const contribution: ProjectContribution = { jobName, worker: playerId, value: points }; - state.contributions.push(contribution); -}; +import { ProjectSlot } from "./projectSlot"; const pushWorker = (state: ProjectSlot, jobName: JobName, playerId: PlayerID, points: number): void => { const contribution = findContribution(state.contributions, jobName, playerId); if (!contribution) { - throw new Error(`${jobName} work played by ${playerId} not found in ${state.card.name}`); + state.contributions.push({ jobName, worker: playerId, value: points }); + } else { + contribution.value += points; } - contribution.value += points; + state.lastContributor = playerId; +}; + +const assignWorker = pushWorker; + +const removeContributor = (state: ProjectSlot, playerId: PlayerID): void => { + state.contributions = state.contributions.filter(contribution => contribution.worker !== playerId); +}; + +const assignOwner = (state: ProjectSlot, playerId: PlayerID, numWorkerTokens: number): void => { + state.owner = playerId; + state.ownerToken = numWorkerTokens; +}; + +const unassignOwner = (state: ProjectSlot): void => { + state.owner = ''; + state.ownerToken = 0; }; export const mutators = { assignWorker, pushWorker, + removeContributor, + assignOwner, + unassignOwner, }; diff --git a/packages/webapp/src/game/store/slice/projectSlot/projectSlot.selectors.ts b/packages/webapp/src/game/store/slice/projectSlot/projectSlot.selectors.ts index 25fba103..cb0590c8 100644 --- a/packages/webapp/src/game/store/slice/projectSlot/projectSlot.selectors.ts +++ b/packages/webapp/src/game/store/slice/projectSlot/projectSlot.selectors.ts @@ -30,17 +30,28 @@ const getPlayerContribution = (state: ProjectSlot, playerId: PlayerID): number = } const getPlayerWorkerTokens = (state: ProjectSlot, playerId: PlayerID): number => { - const ownerToken = state.owner === playerId ? 1 : 0; const jobTokens = state.contributions .filter(contribution => contribution.worker === playerId) .length; - return ownerToken + jobTokens; + return jobTokens; } +const getContributors = (state: ProjectSlot): PlayerID[] => { + const contributors = state.contributions.map(contribution => contribution.worker); + const uniqueContributors = Array.from(new Set(contributors)); + return uniqueContributors; +}; + +const getOwner = (state: ProjectSlot): { owner: PlayerID, numWorkerToken: number } => { + return { owner: state.owner, numWorkerToken: state.ownerToken }; +}; + export const selectors = { hasWorker, getWorkerContribution, getJobContribution, getPlayerContribution, getPlayerWorkerTokens, + getContributors, + getOwner, }; diff --git a/packages/webapp/src/game/store/slice/projectSlot/projectSlot.ts b/packages/webapp/src/game/store/slice/projectSlot/projectSlot.ts index 625e0cc7..d5f7a970 100644 --- a/packages/webapp/src/game/store/slice/projectSlot/projectSlot.ts +++ b/packages/webapp/src/game/store/slice/projectSlot/projectSlot.ts @@ -1,23 +1,31 @@ import { PlayerID } from "boardgame.io"; import { mutators } from "./projectSlot.mutators"; import { selectors } from "./projectSlot.selectors"; -import { Contribution } from "../../../moves/type"; -import { ProjectCard } from "../../../card"; +import { JobName, ProjectCard } from "../../../card"; -export interface ProjectContribution extends Contribution { +export interface ProjectContribution { + jobName: JobName; + value: number worker: PlayerID; } +export type ProjectSlotID = string; + export interface ProjectSlot { - card: ProjectCard; + id: ProjectSlotID; + card?: ProjectCard; owner: PlayerID; + ownerToken: number; contributions: ProjectContribution[]; + lastContributor: PlayerID; } -const initialState = (): ProjectSlot => ({ - card: { name: '', requirements: {} }, +const initialState = (id: string): ProjectSlot => ({ + id, owner: '', + ownerToken: 0, contributions: [], + lastContributor: '', }) const ProjectSlotSlice = { diff --git a/packages/webapp/src/game/store/slice/rule.ts b/packages/webapp/src/game/store/slice/rule.ts new file mode 100644 index 00000000..5208368a --- /dev/null +++ b/packages/webapp/src/game/store/slice/rule.ts @@ -0,0 +1,256 @@ +import { ActionMoveName } from "@/game/core/stage/action/move/type"; + +interface ActionSlotRule { + available: boolean; +} + +interface ActionRule { + actionCost: number; +} + +interface ScoreWhenAction { + victoryPoints: number; +} + +interface InitialProject { + projectOwnerWorkerCost: number; +} + +interface AssignWorker { + assignWorkerCost: number; + initialContributionValue: number; +} + +interface WorkerContribution { + maxContributionValue: number; +} + +interface ActionRules { + createProject: ActionRule & InitialProject & AssignWorker & ScoreWhenAction; + recruit: ActionRule & AssignWorker; + contributeOwnedProjects: ActionRule & WorkerContribution; + contributeJoinedProjects: ActionRule & WorkerContribution; + removeAndRefillJobs: ActionRule & ScoreWhenAction; + mirror: ActionRule; +} + +export interface Rule { + type: 'simple' | 'standard'; + action: ActionRules; + numNonEndGameEventCards: number; + table: { + maxJobSlots: number; + maxProjectSlots: number; + actionSlots: Record; + }, + player: { + maxActionTokens: number; + maxWorkerTokens: number; + maxProjectCards: number; + }, + settlement: { + leftoverActionTokensVictoryPoints: number; + projectOwnerVictoryPoints: number; + lastContributorVictoryPoints: number; + }, +} + +const initialState = (): Rule => { + const actionRules: ActionRules = { + createProject: { + actionCost: 2, + victoryPoints: 2, + projectOwnerWorkerCost: 1, + assignWorkerCost: 1, + initialContributionValue: 1, + }, + recruit: { + actionCost: 1, + assignWorkerCost: 1, + initialContributionValue: 2, + }, + contributeOwnedProjects: { + actionCost: 1, + maxContributionValue: 4, + }, + contributeJoinedProjects: { + actionCost: 1, + maxContributionValue: 5, + }, + removeAndRefillJobs: { + actionCost: 1, + victoryPoints: 1, + }, + mirror: { + actionCost: 1, + }, + }; + + const actionSlots: Record = { + createProject: { available: true }, + recruit: { available: true }, + contributeOwnedProjects: { available: true }, + contributeJoinedProjects: { available: true }, + removeAndRefillJobs: { available: true }, + mirror: { available: false }, + }; + + return { + type: 'simple', + action: actionRules, + numNonEndGameEventCards: 5, + table: { + maxJobSlots: 8, + maxProjectSlots: 8, + actionSlots, + }, + player: { + maxActionTokens: 4, + maxWorkerTokens: 12, + maxProjectCards: 2, + }, + settlement: { + leftoverActionTokensVictoryPoints: 0, + projectOwnerVictoryPoints: 2, + lastContributorVictoryPoints: 2, + }, + }; +} + +const setSettlementLastContributorVictoryPoints = (rule: Rule, victoryPoints: number): void => { + rule.settlement.lastContributorVictoryPoints = victoryPoints; +}; + +const isStandardRule = (rule: Rule): boolean => { + return rule.type === 'standard'; +} + +const getNonEndGameNumberOfEventCards = (rule: Rule): number => { + return rule.numNonEndGameEventCards; +} + +const isActionSlotAvailable = (rule: Rule, actionName: ActionMoveName): boolean => { + return rule.table.actionSlots[actionName].available; +} + +const getActionTokenCost = (rule: Rule, actionName: ActionMoveName): number => { + return rule.action[actionName].actionCost; +} + +const IsScoreWhenAction = (actionRule: any): actionRule is ScoreWhenAction => { + return actionRule.victoryPoints !== undefined; +} + +const getActionVictoryPoints = (rule: Rule, actionName: ActionMoveName): number => { + const mayScoreWhenAction = rule.action[actionName]; + if (!IsScoreWhenAction(mayScoreWhenAction)) { + throw new Error(`Score when action rule is not defined in ${actionName}`); + } + return mayScoreWhenAction.victoryPoints; +} + +const IsInitialProject = (actionRule: any): actionRule is InitialProject => { + return actionRule.projectOwnerWorkerCost !== undefined; +} + +const getProjectOwnerWorkerTokenCost = (rule: Rule, actionName: ActionMoveName): number => { + const mayInitialProject = rule.action[actionName]; + if (!IsInitialProject(mayInitialProject)) { + throw new Error(`Initial project rule is not defined in ${actionName}`); + } + return mayInitialProject.projectOwnerWorkerCost; +} + +const IsAssignWorker = (actionRule: any): actionRule is AssignWorker => { + return actionRule.assignWorkerCost !== undefined && actionRule.initialContributionValue !== undefined; +} + +const getAssignWorkerTokenCost = (rule: Rule, actionName: ActionMoveName): number => { + const mayAssignWorker = rule.action[actionName]; + if (!IsAssignWorker(mayAssignWorker)) { + throw new Error(`Assign worker rule is not defined in ${actionName}`); + } + return mayAssignWorker.assignWorkerCost; +} + +const getAssignWorkerInitialContributionValue = (rule: Rule, actionName: ActionMoveName): number => { + const mayAssignWorker = rule.action[actionName]; + if (!IsAssignWorker(mayAssignWorker)) { + throw new Error(`Assign worker rule is not defined in ${actionName}`); + } + return mayAssignWorker.initialContributionValue; +} + +const IsWorkerContribution = (actionRule: any): actionRule is WorkerContribution => { + return actionRule.maxContributionValue !== undefined; +} + +const getMaxContributionValue = (rule: Rule, actionName: ActionMoveName): number => { + const mayWorkerContribution = rule.action[actionName]; + if (!IsWorkerContribution(mayWorkerContribution)) { + throw new Error('Worker contribution rule is not defined'); + } + return mayWorkerContribution.maxContributionValue; +} + +const getTableMaxJobSlots = (rule: Rule): number => { + return rule.table.maxJobSlots; +} + +const getTableMaxProjectSlots = (rule: Rule): number => { + return rule.table.maxProjectSlots; +} + +const getPlayerMaxActionTokens = (rule: Rule): number => { + return rule.player.maxActionTokens; +} + +const getPlayerMaxWorkerTokens = (rule: Rule): number => { + return rule.player.maxWorkerTokens; +} + +const getPlayerMaxProjectCards = (rule: Rule): number => { + return rule.player.maxProjectCards; +} + +const getSettlementLeftoverActionTokensVictoryPoints = (rule: Rule): number => { + return rule.settlement.leftoverActionTokensVictoryPoints; +} + +const getSettlementProjectOwnerVictoryPoints = (rule: Rule): number => { + return rule.settlement.projectOwnerVictoryPoints; +}; + +const getSettlementLastContributorVictoryPoints = (rule: Rule): number => { + return rule.settlement.lastContributorVictoryPoints; +}; + +const RuleSlice = { + initialState, + mutators: { + setSettlementLastContributorVictoryPoints, + }, + selectors: { + isStandardRule, + getNonEndGameNumberOfEventCards, + isActionSlotAvailable, + getActionTokenCost, + getActionVictoryPoints, + getProjectOwnerWorkerTokenCost, + getAssignWorkerTokenCost, + getAssignWorkerInitialContributionValue, + getMaxContributionValue, + getTableMaxJobSlots, + getTableMaxProjectSlots, + getPlayerMaxActionTokens, + getPlayerMaxWorkerTokens, + getPlayerMaxProjectCards, + getSettlementLeftoverActionTokensVictoryPoints, + getSettlementProjectOwnerVictoryPoints, + getSettlementLastContributorVictoryPoints, + }, +}; + +export const RuleMutator = RuleSlice.mutators; +export const RuleSelector = RuleSlice.selectors; +export default RuleSlice; diff --git a/packages/webapp/src/game/store/slice/scoreBoard.ts b/packages/webapp/src/game/store/slice/scoreBoard.ts new file mode 100644 index 00000000..7f0da23b --- /dev/null +++ b/packages/webapp/src/game/store/slice/scoreBoard.ts @@ -0,0 +1,42 @@ +import { PlayerID } from "boardgame.io"; + + +export type ScoreBoard = Record; +const initialState = (): ScoreBoard => ({}); +const mutators = { + initialize: (state: ScoreBoard, playerNames: PlayerID[]): void => { + playerNames.forEach(player => { + state[player] = 0; + }); + }, + add: (state: ScoreBoard, playerId: PlayerID, points: number): void => { + state[playerId] += points; + }, +}; + +const selectors = { + getPlayerPoints: (state: ScoreBoard, playerId: PlayerID): number => { + return state[playerId]; + }, + getWinner: (state: ScoreBoard): PlayerID => { + let winner: PlayerID = ''; + let maxPoints = -Infinity; + Object.entries(state).forEach(([playerId, points]) => { + if (points > maxPoints) { + maxPoints = points; + winner = playerId; + } + }); + return winner; + }, +}; + +const ScoreBoardSlice = { + initialState, + selectors, + mutators, +}; + +export const ScoreBoardMutator = ScoreBoardSlice.mutators; +export const ScoreBoardSelector = ScoreBoardSlice.selectors; +export default ScoreBoardSlice; diff --git a/packages/webapp/src/game/store/slice/table.ts b/packages/webapp/src/game/store/slice/table.ts index 94c52fce..5dc86801 100644 --- a/packages/webapp/src/game/store/slice/table.ts +++ b/packages/webapp/src/game/store/slice/table.ts @@ -1,28 +1,51 @@ -import { EventCard, JobCard } from "../../card"; +import { EventCard } from "../../card"; import ProjectBoardSlice, { ProjectBoard } from "./projectBoard"; +import JobSlotsSlice, { JobSlots } from "./jobSlots"; import ActionSlotsSlice, { ActionSlots } from "./actionSlots"; +import ScoreBoardSlice, { ScoreBoard } from "./scoreBoard"; // TODO: move event slot into a separate slice export type EventSlot = EventCard | null; -// TODO: move job slots into a separate slice -export type JobSlots = JobCard[]; export interface Table { eventSlot: EventCard | null; projectBoard: ProjectBoard; - jobSlots: JobCard[]; + jobSlots: JobSlots; actionSlots: ActionSlots; + scoreBoard: ScoreBoard; } const initialState = (): Table => ({ eventSlot: null, projectBoard: ProjectBoardSlice.initialState(), - jobSlots: [], + jobSlots: JobSlotsSlice.initialState(), actionSlots: ActionSlotsSlice.initialState(), + scoreBoard: ScoreBoardSlice.initialState(), }); +const playEvent = (state: Table, eventCard: EventCard): void => { + state.eventSlot = eventCard; +}; + +const removeEvent = (state: Table): void => { + state.eventSlot = null; +}; + +const getCurrentEvent = (state: Table): EventCard | null => { + return state.eventSlot; +} + const TableSlice = { initialState, + mutators: { + playEvent, + removeEvent, + }, + selectors: { + getCurrentEvent, + }, }; +export const TableMutator = TableSlice.mutators; +export const TableSelector = TableSlice.selectors; export default TableSlice; diff --git a/packages/webapp/src/game/store/store.ts b/packages/webapp/src/game/store/store.ts index a04e3cfd..d3bd7dec 100644 --- a/packages/webapp/src/game/store/store.ts +++ b/packages/webapp/src/game/store/store.ts @@ -1,9 +1,7 @@ import PlayersSlice, { Players } from "./slice/players"; import TableSlice, { Table } from "./slice/table"; import DecksSlice, { Decks } from "./slice/decks"; - -export interface Rule { -} +import RuleSlice, { Rule } from "./slice/rule"; export interface GameState { rules: Rule; @@ -13,7 +11,7 @@ export interface GameState { } const initialState = (): GameState => ({ - rules: {}, + rules: RuleSlice.initialState(), decks: DecksSlice.initialState(), table: TableSlice.initialState(), players: PlayersSlice.initialState(), diff --git a/packages/webapp/src/game/utils.test.ts b/packages/webapp/src/game/utils.test.ts deleted file mode 100644 index 51700ddd..00000000 --- a/packages/webapp/src/game/utils.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { filterInplace, isInRange, zip } from "./utils"; - -describe('utils', () => { - describe('isInRange', () => { - describe('given upper bound', () => { - test.each([ - [1, 2], - [2, 10], - [0, 5], - [3, 4], - ])('is in the range', (val, upperBond) => { - expect(isInRange(val, upperBond)).toBeTruthy(); - }); - - test.each([ - [3, 2], - [-1, 10], - [5, 5], - [100, 4], - ])('is NOT in the range', (val, upperBond) => { - expect(isInRange(val, upperBond)).toBeFalsy(); - }); - }); - - describe('given lower bound and upper bound', () => { - test.each([ - [3, 2, 4], - [0, 0, 3], - [-2, -5, 5], - [3, -1, 4], - ])('is in the range', (val, lowerBond, upperBond) => { - expect(isInRange(val, lowerBond, upperBond)).toBeTruthy(); - }); - - test.each([ - [-2, 1, 4], - [-4, -1, 3], - [5, 0, 5], - [6, 1, 3], - ])('is NOT in the range', (val, lowerBond, upperBond) => { - expect(isInRange(val, lowerBond, upperBond)).toBeFalsy(); - }); - }); - }); - - describe('zip', () => { - it('should zip two lists to be one list of tuples', () => { - const array1 = [3, 2, 1]; - const array2 = ['foo', 'bar', 'foobar']; - const results = zip(array1, array2); - expect(results[0]).toEqual([3, 'foo']); - expect(results[1]).toEqual([2, 'bar']); - expect(results[2]).toEqual([1, 'foobar']); - }); - }); - - describe('filterInplace', () => { - it('should filter inplace', () => { - const array = [1, 2, 3, 4, 5]; - filterInplace(array, x => x % 2 == 0); - expect(array).toEqual([2, 4]); - }); - }); -}); diff --git a/packages/webapp/src/game/utils.ts b/packages/webapp/src/game/utils.ts index 07ac1cfe..71463ebe 100644 --- a/packages/webapp/src/game/utils.ts +++ b/packages/webapp/src/game/utils.ts @@ -1,29 +1,10 @@ -export const isInRange = (val: number, a: number, b?: number) => { - if (typeof b !== 'number') { - b = a; - a = 0; - } - return a <= val && val < b; -} - -export function zip(array1: S[], array2: T[]): [S, T][] { - const size = Math.min(array1.length, array2.length); - const results: [S, T][] = new Array(size); - for (let i = 0; i < size; i++) { - results[i] = [array1[i], array2[i]]; - } - return results; -} - -export function filterInplace(array: T[], condition: (t: T, i: number, thisArg: T[]) => boolean) { - let writePtr = 0; - let readPtr = 0; - while (readPtr < array.length) { - if (condition(array[readPtr], readPtr, array)) { - array[writePtr] = array[readPtr]; - writePtr++; +export function reservoirSampling(array: T[], k: number, randomFn: () => number = Math.random): T[] { + const reservoir = array.slice(0, k); + for (let i = k; i < array.length; i++) { + const j = Math.floor(randomFn() * (i + 1)); + if (j < k) { + reservoir[j] = array[i]; } - readPtr++; } - array.length = writePtr; + return reservoir; } diff --git a/packages/webapp/src/lib/reducers/actionStepSlice.ts b/packages/webapp/src/lib/reducers/actionStepSlice.ts new file mode 100644 index 00000000..7de3e706 --- /dev/null +++ b/packages/webapp/src/lib/reducers/actionStepSlice.ts @@ -0,0 +1,102 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; + +export enum UserActionMoves { + CreateProject = 'createProject', + Recruit = 'recruit', + ContributeOwnedProjects = 'contributeOwnedProjects', + ContributeJoinedProjects = 'contributeJoinedProjects', + RemoveAndRefillJobs = 'removeAndRefillJobs', + Mirror = 'mirror', + EndActionTurn = 'endActionTurn' +} + +interface ActionStepState { + currentStep: number; + currentAction: UserActionMoves | null; + interactiveState: { + handProjectCards: boolean; + jobSlots: boolean; + projectSlots: boolean; + onwedContribution: boolean; + joinedContribution: boolean; + }; +} + +const initialInteractiveState: ActionStepState['interactiveState'] = { + handProjectCards: false, + jobSlots: false, + projectSlots: false, + onwedContribution: false, + joinedContribution: false, +}; + +const initialState: ActionStepState = { + currentStep: 0, + currentAction: null, + interactiveState: initialInteractiveState, +}; + +const actionStepSlice = createSlice({ + name: 'actionSteps', + initialState, + reducers: { + setActionStep: (state, action: PayloadAction) => { + state.currentStep = action.payload; + }, + setCurrentAction: (state, action: PayloadAction) => { + state.currentAction = action.payload; + }, + resetAction: (state) => { + state.currentStep = 0; + state.currentAction = null; + state.interactiveState = initialInteractiveState; + }, + setHandPorjectCardsInteractive: (state) => { + state.interactiveState.handProjectCards = true; + }, + setJobSlotsInteractive: (state) => { + state.interactiveState.jobSlots = true; + }, + setProjectSlotsInteractive: (state) => { + state.interactiveState.projectSlots = true; + }, + setOwnedContributionInteractive: (state) => { + state.interactiveState.onwedContribution = true; + }, + setJoinedContributionInteractive: (state) => { + state.interactiveState.joinedContribution = true; + } + }, + selectors: { + getCurrentStep: (state: ActionStepState) => state.currentStep, + getCurrentAction: (state: ActionStepState) => state.currentAction, + isHandProjectCardsInteractive: (state: ActionStepState) => state.interactiveState.handProjectCards, + isJobSlotsInteractive: (state: ActionStepState) => state.interactiveState.jobSlots, + isProjectSlotsInteractive: (state: ActionStepState) => state.interactiveState.projectSlots, + isOwnedContributionInteractive: (state: ActionStepState) => state.interactiveState.onwedContribution, + isJoinedContributionInteractive: (state: ActionStepState) => state.interactiveState.joinedContribution, + } +}); + +export const { + setActionStep, + setCurrentAction, + resetAction, + setHandPorjectCardsInteractive, + setJobSlotsInteractive, + setProjectSlotsInteractive, + setOwnedContributionInteractive, + setJoinedContributionInteractive, +} = actionStepSlice.actions; + +export const { + getCurrentStep, + getCurrentAction, + isHandProjectCardsInteractive, + isJobSlotsInteractive, + isProjectSlotsInteractive, + isOwnedContributionInteractive, + isJoinedContributionInteractive, +} = actionStepSlice.selectors; + +export default actionStepSlice.reducer; diff --git a/packages/webapp/src/lib/reducers/contributionSlice.ts b/packages/webapp/src/lib/reducers/contributionSlice.ts new file mode 100644 index 00000000..e099b4fe --- /dev/null +++ b/packages/webapp/src/lib/reducers/contributionSlice.ts @@ -0,0 +1,44 @@ +import { JobName, ProjectSlotID } from '@/game'; +import { ContributionAction } from '@/game/core/ContributionAction'; +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; + +interface ContributionState { + contributions: ContributionAction[]; +} + +const initialState: ContributionState = { + contributions: [], +}; + +interface UpdateContributePayload { + slotId: ProjectSlotID; + jobName: JobName; + diffAmount: number; +} + +const contributionSlice = createSlice({ + name: 'contributions', + initialState, + reducers: { + updateContribute: (state, action: PayloadAction) => { + const { slotId, jobName, diffAmount } = action.payload; + const contribution = state.contributions.find((c) => c.projectSlotId === slotId && c.jobName === jobName); + if (contribution) { + contribution.value = diffAmount; + } else { + state.contributions.push({ projectSlotId: slotId, jobName, value: diffAmount }); + } + }, + resetContribution: (state) => { + state.contributions = []; + }, + }, + selectors: { + getContributions: (state: ContributionState) => state.contributions, + }, +}); + +export const { updateContribute, resetContribution } = contributionSlice.actions; +export const { getContributions } = contributionSlice.selectors; + +export default contributionSlice.reducer; diff --git a/packages/webapp/src/lib/reducers/handProjectCardSlice.ts b/packages/webapp/src/lib/reducers/handProjectCardSlice.ts new file mode 100644 index 00000000..33fa51db --- /dev/null +++ b/packages/webapp/src/lib/reducers/handProjectCardSlice.ts @@ -0,0 +1,33 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; + +interface HandProjectCardState { + selectedCards: Record; +} + +const initialState: HandProjectCardState = { + selectedCards: {}, +}; + +const handProjectCardSlice = createSlice({ + name: 'handProjectCards', + initialState, + reducers: { + toggleHandProjectCardSelection: (state, action: PayloadAction) => { + if (state.selectedCards[action.payload]) { + delete state.selectedCards[action.payload]; + } else { + state.selectedCards[action.payload] = true; + } + }, + resetHandProjectCardSelection: (state) => { + state.selectedCards = {}; + }, + }, + selectors: { + getSelectedHandProjectCards: (state: HandProjectCardState) => state.selectedCards, + }, +}); + +export const { toggleHandProjectCardSelection, resetHandProjectCardSelection } = handProjectCardSlice.actions; +export const { getSelectedHandProjectCards } = handProjectCardSlice.selectors; +export default handProjectCardSlice.reducer; diff --git a/packages/webapp/src/lib/reducers/jobSlotSlice.ts b/packages/webapp/src/lib/reducers/jobSlotSlice.ts new file mode 100644 index 00000000..2f72983a --- /dev/null +++ b/packages/webapp/src/lib/reducers/jobSlotSlice.ts @@ -0,0 +1,33 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; + +interface JobSlotState { + selectedSlots: { [key: string]: boolean }; +} + +const initialState: JobSlotState = { + selectedSlots: {}, +}; + +const jobSlotSlice = createSlice({ + name: 'jobSlots', + initialState, + reducers: { + toggleJobSlotSelection: (state, action: PayloadAction) => { + if (state.selectedSlots[action.payload]) { + delete state.selectedSlots[action.payload]; + } else { + state.selectedSlots[action.payload] = true; + } + }, + resetJobSlotSelection: (state) => { + state.selectedSlots = {}; + }, + }, + selectors: { + getSelectedJobSlots: (state: JobSlotState) => state.selectedSlots, + } +}); + +export const { toggleJobSlotSelection, resetJobSlotSelection } = jobSlotSlice.actions; +export const { getSelectedJobSlots } = jobSlotSlice.selectors; +export default jobSlotSlice.reducer; diff --git a/packages/webapp/src/lib/reducers/projectSlotSlice.ts b/packages/webapp/src/lib/reducers/projectSlotSlice.ts new file mode 100644 index 00000000..ec00e2cb --- /dev/null +++ b/packages/webapp/src/lib/reducers/projectSlotSlice.ts @@ -0,0 +1,34 @@ +import { ProjectSlotID } from '@/game'; +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; + +interface ProjectSlotState { + selectedSlots: Record; +} + +const initialState: ProjectSlotState = { + selectedSlots: {}, +}; + +const projectSlotSlice = createSlice({ + name: 'projectSlots', + initialState, + reducers: { + toggleProjectSlotSelection: (state, action: PayloadAction) => { + if (state.selectedSlots[action.payload]) { + delete state.selectedSlots[action.payload]; + } else { + state.selectedSlots[action.payload] = true; + } + }, + resetProjectSlotSelection: (state) => { + state.selectedSlots = {}; + }, + }, + selectors: { + getSelectedProjectSlots: (state: ProjectSlotState) => state.selectedSlots, + } +}); + +export const { toggleProjectSlotSelection, resetProjectSlotSelection } = projectSlotSlice.actions; +export const { getSelectedProjectSlots } = projectSlotSlice.selectors; +export default projectSlotSlice.reducer; diff --git a/packages/webapp/src/lib/reducers/wizard.ts b/packages/webapp/src/lib/reducers/wizard.ts deleted file mode 100644 index 280aa3d3..00000000 --- a/packages/webapp/src/lib/reducers/wizard.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { createSlice } from '@reduxjs/toolkit' - -type ActiveEventType = 'table--event'; -type ActiveProjectType = 'table--active-project'; -type ActiveJobType = 'table--active-job'; -type ActiveMoveType = 'table--active-move'; - -export type TableType = ActiveEventType | ActiveProjectType | ActiveJobType | ActiveMoveType; - -type ProjectType = 'player--hand--project'; -type ForceType = 'player--hand--force'; - -export type HandType = ProjectType | ForceType; - -export type PlayerType = HandType; - -export type StepType = TableType | PlayerType; - -type Step = { - type: StepType; - value: any; - prevValue?: any; -} - -type RequiredStep = { - type: StepType; - limit?: number; // exactly limit, default is 1 - min?: number; // minimum limit, default is 0 - max?: number; // maximum limit, default is Inf -} - -export type Page = { - isCancellable: boolean; - requiredSteps: Partial>; - toggledSteps: Step[]; -} - -type WizardState = { - pages: Page[]; - currentPage: number; -} - -const initialState: WizardState = { - pages: [], - currentPage: -1, -} - -const wizardSlice = createSlice({ - name: 'wizard', - initialState, - reducers: { - init: (state, action) => { - state.pages = action.payload; - state.currentPage = 0; - }, - clear: () => initialState, - toggleOnStep: (state, action) => { - state.pages[state.currentPage].toggledSteps.push(action.payload); - }, - toggleOffStep: (state, action) => { - const idx = state.pages[state.currentPage].toggledSteps.findIndex((step) => - action.payload.type === step.type - && action.payload.value === step.value - && action.payload.prevValue === step.prevValue); - - if (idx >= 0) { - state.pages[state.currentPage].toggledSteps.splice(idx); - } - }, - nextPage: (state) => { - state.currentPage ++; - state.currentPage = Math.min(state.currentPage, state.pages.length); - }, - prevPage: (state) => { - state.currentPage --; - state.currentPage = Math.max(0, state.currentPage); - }, - }, -}); - -export const wizardReducer = wizardSlice.reducer; -export const wizardActions = wizardSlice.actions; - -// Wizard Selector - -export const isLegitPage = (state: WizardState): boolean => state.currentPage >= 0; - -export const isPageCancellable = (state: WizardState): boolean => isLegitPage(state) && state.pages[state.currentPage].isCancellable; - -export const hasNextPage = (state: WizardState): boolean => isLegitPage(state) && state.currentPage < state.pages.length; - -export const hasPrevPage = (state: WizardState): boolean => state.currentPage > 1; - -export const getCurrentPage = (state: WizardState): Page => state.pages[state.currentPage]; - -// Page Selector - -export const isRequiredStep = (state: Page, stepType: StepType): boolean => !!state.requiredSteps[stepType]; - -export const isToggledStep = (state: Page, step: Step): boolean => { - const idx = state.toggledSteps.findIndex(toggled => step.type === toggled.type && step.value === toggled.value && step.prevValue === toggled.prevValue); - return idx >= 0; -} - -const getRequiredLimit = (required: RequiredStep) => { - if (required.limit) { - return { - min: required.limit, - max: required.limit, - } - } - if (required.min || required.max) { - return { - min: required.min || 0, - max: required.max || Infinity, - } - } - return { - min: 0, - max: Infinity, - } -} - -export const isRequiredStepsFulfilled = (state: Page): boolean => { - return Object.values(state.requiredSteps) - .every((requiredStep) => { - const { min, max } = getRequiredLimit(requiredStep); - const toggled = state.toggledSteps.filter(step => step.type === requiredStep.type).length; - - return min <= toggled && toggled <= max; - }); -} diff --git a/packages/webapp/src/lib/selector.ts b/packages/webapp/src/lib/selector.ts deleted file mode 100644 index 02130c1d..00000000 --- a/packages/webapp/src/lib/selector.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { RootState } from "./store"; - -export const selectWizard = (state: RootState) => state.wizard; diff --git a/packages/webapp/src/lib/store.ts b/packages/webapp/src/lib/store.ts index 830765b1..7504eacf 100644 --- a/packages/webapp/src/lib/store.ts +++ b/packages/webapp/src/lib/store.ts @@ -1,10 +1,18 @@ import { configureStore } from '@reduxjs/toolkit' -import { wizardReducer }from './reducers/wizard' +import projectSlotSlice from './reducers/projectSlotSlice' +import jobSlotSlice from './reducers/jobSlotSlice' +import handProjectCardSlice from './reducers/handProjectCardSlice' +import actionStepSlice from './reducers/actionStepSlice' +import contributionSlice from './reducers/contributionSlice' export const makeStore = () => { return configureStore({ reducer: { - wizard: wizardReducer, + projectSlots: projectSlotSlice, + jobSlots: jobSlotSlice, + handProjectCards: handProjectCardSlice, + actionSteps: actionStepSlice, + contributions: contributionSlice, }, }) } diff --git a/packages/webapp/src/server.ts b/packages/webapp/src/server.ts index aab55688..e5ce013d 100644 --- a/packages/webapp/src/server.ts +++ b/packages/webapp/src/server.ts @@ -1,5 +1,5 @@ import { Server, Origins } from "boardgame.io/server"; -import { OpenStarTerVillage } from "./game/game"; +import game from "./game"; async function serve() { const port = Number(process.env.PORT) || 8000; @@ -8,7 +8,7 @@ async function serve() { console.log(`Starting server on port ${port} in ${dev ? 'dev' : 'production'} mode...`); const server = Server({ - games: [OpenStarTerVillage], + games: [game], origins: [ // Allow localhost to connect, except when NODE_ENV is 'production'. Origins.LOCALHOST_IN_DEVELOPMENT, diff --git a/packages/webapp/tsconfig.server.json b/packages/webapp/tsconfig.server.json index 32b0f820..9289e1f2 100644 --- a/packages/webapp/tsconfig.server.json +++ b/packages/webapp/tsconfig.server.json @@ -9,6 +9,9 @@ "resolveJsonModule": true, "esModuleInterop": true, "skipLibCheck": true, + "paths": { + "@/*": ["./src/*"] + } }, "include": ["./src/server.ts", "./src/game/**/*.ts"], "exclude": ["node_modules"] diff --git a/yarn.lock b/yarn.lock index f00cfd63..98930f71 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1099,6 +1099,22 @@ __metadata: languageName: node linkType: hard +"@mui/icons-material@npm:^5.16.4": + version: 5.16.4 + resolution: "@mui/icons-material@npm:5.16.4" + dependencies: + "@babel/runtime": ^7.23.9 + peerDependencies: + "@mui/material": ^5.0.0 + "@types/react": ^17.0.0 || ^18.0.0 + react: ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + "@types/react": + optional: true + checksum: b0559215a10819a082539a7ae43aedafb30bc109c5d6994ac6d748e46688d705e6b44b62693f89aea1fca3afdc6deb665d884c6bad0a2cffde9c5443027e3019 + languageName: node + linkType: hard + "@mui/lab@npm:^5.0.0-alpha.170": version: 5.0.0-alpha.170 resolution: "@mui/lab@npm:5.0.0-alpha.170" @@ -1415,6 +1431,7 @@ __metadata: "@emotion/cache": ^11.11.0 "@emotion/react": ^11.11.4 "@emotion/styled": ^11.11.5 + "@mui/icons-material": ^5.16.4 "@mui/lab": ^5.0.0-alpha.170 "@mui/material": ^5.15.19 "@mui/material-nextjs": ^5.15.11 @@ -1434,6 +1451,7 @@ __metadata: react-redux: ^9.1.2 ts-node: ^10.9.2 ts-node-dev: ^2.0.0 + tsconfig-paths: ^4.2.0 typescript: ^5.4.5 languageName: unknown linkType: soft @@ -5722,7 +5740,7 @@ __metadata: languageName: node linkType: hard -"json5@npm:^2.2.3": +"json5@npm:^2.2.2, json5@npm:^2.2.3": version: 2.2.3 resolution: "json5@npm:2.2.3" bin: @@ -8005,6 +8023,17 @@ __metadata: languageName: node linkType: hard +"tsconfig-paths@npm:^4.2.0": + version: 4.2.0 + resolution: "tsconfig-paths@npm:4.2.0" + dependencies: + json5: ^2.2.2 + minimist: ^1.2.6 + strip-bom: ^3.0.0 + checksum: 28c5f7bbbcabc9dabd4117e8fdc61483f6872a1c6b02a4b1c4d68c5b79d06896c3cc9547610c4c3ba64658531caa2de13ead1ea1bf321c7b53e969c4752b98c7 + languageName: node + linkType: hard + "tsconfig@npm:^7.0.0": version: 7.0.0 resolution: "tsconfig@npm:7.0.0"