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 (
-
+
+ {!!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 (
+
+ {value === index && (
+
+ {children}
+
+ )}
+
+ );
+};
+
+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"