diff --git a/backend/rorapp/functions/__init__.py b/backend/rorapp/functions/__init__.py index 7e211078..209a3e32 100644 --- a/backend/rorapp/functions/__init__.py +++ b/backend/rorapp/functions/__init__.py @@ -1,5 +1,6 @@ # The functions module provides a collection of public functions that are intended to be used by other parts of the application. from .faction_leader_helper import select_faction_leader, set_faction_leader # noqa: F401 +from .forum_phase_helper import initiate_situation # noqa: F401 from .forum_phase_starter import start_forum_phase # noqa: F401 from .game_deleter import delete_all_games # noqa: F401 from .game_generator import generate_game # noqa: F401 diff --git a/backend/rorapp/functions/faction_leader_helper.py b/backend/rorapp/functions/faction_leader_helper.py index b41ba9c7..8267a53b 100644 --- a/backend/rorapp/functions/faction_leader_helper.py +++ b/backend/rorapp/functions/faction_leader_helper.py @@ -2,6 +2,7 @@ from typing import Optional from rorapp.functions.action_helper import delete_old_actions from rorapp.functions.forum_phase_helper import ( + generate_initiate_situation_action, get_next_faction_in_forum_phase, ) from rorapp.functions.mortality_phase_starter import setup_mortality_phase @@ -23,7 +24,6 @@ from rorapp.serializers import ( ActionLogSerializer, ActionSerializer, - StepSerializer, TitleSerializer, SenatorActionLogSerializer, ) @@ -87,7 +87,9 @@ def set_faction_leader(senator_id: int) -> (Response, dict): messages_to_send.extend(proceed_to_next_step_if_forum_phase(game.id, step, faction)) messages_to_send.extend(delete_old_actions(game.id)) - return Response({"message": "Faction leader selected"}, status=200), messages_to_send + return Response( + {"message": "Faction leader selected"}, status=200 + ), messages_to_send def get_previous_title(faction) -> Optional[Title]: @@ -182,30 +184,7 @@ def proceed_to_next_step_if_forum_phase(game_id, step, faction) -> [dict]: next_faction = get_next_faction_in_forum_phase(faction) if next_faction is not None: - new_step = Step(index=step.index + 1, phase=step.phase) - new_step.save() - - messages_to_send.append( - generate_select_faction_leader_action(next_faction, new_step) - ) - - messages_to_send.append( - create_websocket_message("step", StepSerializer(new_step).data) - ) + messages_to_send.extend(generate_initiate_situation_action(next_faction)) else: messages_to_send.extend(start_next_turn(game_id, step)) return messages_to_send - - -def generate_select_faction_leader_action(faction: Faction, step: Step) -> dict: - senators = Senator.objects.filter(faction=faction, death_step__isnull=True) - senator_id_list = [senator.id for senator in senators] - action = Action( - step=step, - faction=faction, - type="select_faction_leader", - required=True, - parameters=senator_id_list, - ) - action.save() - return create_websocket_message("action", ActionSerializer(action).data) diff --git a/backend/rorapp/functions/forum_phase_helper.py b/backend/rorapp/functions/forum_phase_helper.py index 76c09943..91d2f271 100644 --- a/backend/rorapp/functions/forum_phase_helper.py +++ b/backend/rorapp/functions/forum_phase_helper.py @@ -1,5 +1,17 @@ -from typing import Optional -from rorapp.models import Faction +from rest_framework.response import Response +from typing import List, Optional +from rorapp.functions.websocket_message_helper import ( + create_websocket_message, + destroy_websocket_message, +) +from rorapp.models import ( + Action, + Faction, + Phase, + Senator, + Step, +) +from rorapp.serializers import ActionSerializer, StepSerializer def get_next_faction_in_forum_phase( @@ -23,3 +35,81 @@ def get_next_faction_in_forum_phase( if next_faction_index >= len(factions): return None return factions[next_faction_index] + + +def generate_select_faction_leader_action(faction: Faction, step: Step) -> dict: + senators = Senator.objects.filter(faction=faction, death_step__isnull=True) + senator_id_list = [senator.id for senator in senators] + action = Action( + step=step, + faction=faction, + type="select_faction_leader", + required=True, + parameters=senator_id_list, + ) + action.save() + return create_websocket_message("action", ActionSerializer(action).data) + + +def generate_initiate_situation_action(faction: Faction) -> List[dict]: + messages_to_send = [] + + # Create new step + latest_step = Step.objects.filter(phase__turn__game=faction.game.id).order_by( + "-index" + )[0] + # Need to get latest phase because the latest step might not be from the current forum phase + latest_phase = Phase.objects.filter(turn__game=faction.game.id).order_by("-index")[ + 0 + ] + new_step = Step(index=latest_step.index + 1, phase=latest_phase) + new_step.save() + messages_to_send.append( + create_websocket_message("step", StepSerializer(new_step).data) + ) + + # Create new action + action = Action( + step=new_step, + faction=faction, + type="initiate_situation", + required=True, + ) + action.save() + messages_to_send.append( + create_websocket_message("action", ActionSerializer(action).data) + ) + return messages_to_send + + +def initiate_situation(action_id: int) -> dict: + """ + Initiate a random situation. + + This function is called when a player initiates a situation during the forum phase. + + Args: + action_id (int): The action ID. + + Returns: + dict: The response with a message and a status code. + """ + messages_to_send = [] + + # Mark the action as complete + action = Action.objects.get(id=action_id) + action.completed = True + action.save() + messages_to_send.append(destroy_websocket_message("action", action_id)) + + # Create new step + new_step = Step(index=action.step.index + 1, phase=action.step.phase) + new_step.save() + messages_to_send.append( + create_websocket_message("step", StepSerializer(new_step).data) + ) + + messages_to_send.append( + generate_select_faction_leader_action(action.faction, new_step) + ) + return Response({"message": "Situation initiated"}, status=200), messages_to_send diff --git a/backend/rorapp/functions/forum_phase_starter.py b/backend/rorapp/functions/forum_phase_starter.py index 87d5a1e0..7c7721b9 100644 --- a/backend/rorapp/functions/forum_phase_starter.py +++ b/backend/rorapp/functions/forum_phase_starter.py @@ -1,19 +1,25 @@ from typing import List from rorapp.functions.action_helper import delete_old_actions -from rorapp.functions.faction_leader_helper import generate_select_faction_leader_action +from rorapp.functions.forum_phase_helper import generate_initiate_situation_action from rorapp.functions.websocket_message_helper import create_websocket_message from rorapp.models import ( Faction, Phase, Step, ) -from rorapp.serializers import ( - StepSerializer, - PhaseSerializer, -) +from rorapp.serializers import PhaseSerializer + +def start_forum_phase(game_id: int) -> List[dict]: + """ + Start the forum phase. -def start_forum_phase(game_id) -> List[dict]: + Args: + game_id (int): The game ID. + + Returns: + List[dict]: The WebSocket messages to send. + """ messages_to_send = [] latest_step = Step.objects.filter(phase__turn__game=game_id).order_by("-index")[0] @@ -25,16 +31,9 @@ def start_forum_phase(game_id) -> List[dict]: messages_to_send.append( create_websocket_message("phase", PhaseSerializer(new_phase).data) ) - new_step = Step(index=latest_step.index + 1, phase=new_phase) - new_step.save() - messages_to_send.append( - create_websocket_message("step", StepSerializer(new_step).data) - ) - # Create actions + # Create action and new step first_faction = Faction.objects.filter(game__id=game_id).order_by("rank").first() - messages_to_send.append( - generate_select_faction_leader_action(first_faction, new_step) - ) + messages_to_send.extend(generate_initiate_situation_action(first_faction)) messages_to_send.extend(delete_old_actions(game_id)) return messages_to_send diff --git a/backend/rorapp/functions/game_starter.py b/backend/rorapp/functions/game_starter.py index 5ea60b33..622041d4 100644 --- a/backend/rorapp/functions/game_starter.py +++ b/backend/rorapp/functions/game_starter.py @@ -9,7 +9,7 @@ from django.contrib.auth.models import User from rest_framework.response import Response from rest_framework.exceptions import NotFound, PermissionDenied -from rorapp.functions.faction_leader_helper import generate_select_faction_leader_action +from rorapp.functions.forum_phase_helper import generate_select_faction_leader_action from rorapp.functions.rank_helper import rank_senators_and_factions from rorapp.functions.websocket_message_helper import ( send_websocket_messages, diff --git a/backend/rorapp/tests/forum_phase_tests.py b/backend/rorapp/tests/forum_phase_tests.py index e1e70d4d..01bda060 100644 --- a/backend/rorapp/tests/forum_phase_tests.py +++ b/backend/rorapp/tests/forum_phase_tests.py @@ -25,7 +25,7 @@ def test_forum_phase(self) -> None: for player_count in range(3, 7): self.do_forum_phase_test(player_count) - def action_processor(self, action: Action) -> dict: + def faction_leader_action_processor(self, action: Action) -> dict: faction = Faction.objects.filter(player=action.faction.player.id).get( game=action.faction.game.id ) @@ -35,32 +35,48 @@ def action_processor(self, action: Action) -> dict: def do_forum_phase_test(self, player_count: int) -> None: random.seed(1) - game_id, faction_ids_with_leadership = self.setup_game_in_forum_phase(player_count) + game_id, faction_ids_with_leadership = self.setup_game_in_forum_phase( + player_count + ) for _ in range(0, player_count): check_latest_phase(self, game_id, "Forum") - potential_actions = get_and_check_actions( + situation_potential_actions = get_and_check_actions( + self, game_id, False, "initiate_situation", 1 + ) + submit_actions( + self, + game_id, + situation_potential_actions, + self.faction_leader_action_processor, + ) + check_latest_phase(self, game_id, "Forum") + faction_leader_potential_actions = get_and_check_actions( self, game_id, False, "select_faction_leader", 1 ) - self.assertEqual(len(potential_actions), 1) faction_leader_titles = Title.objects.filter( name="Faction Leader", - senator__faction=potential_actions[0].faction, - end_step=None + senator__faction=faction_leader_potential_actions[0].faction, + end_step=None, ) - + # If the faction already has a leader, then there should be no existing faction leader title. - self.assertEqual(len(faction_leader_titles), 1 if potential_actions[0].faction.id in faction_ids_with_leadership else 0) + self.assertEqual( + len(faction_leader_titles), + 1 + if faction_leader_potential_actions[0].faction.id + in faction_ids_with_leadership + else 0, + ) submit_actions( self, game_id, - potential_actions, - self.action_processor, + faction_leader_potential_actions, + self.faction_leader_action_processor, ) - self.assertEqual(len(potential_actions), 1) faction_leader_titles = Title.objects.filter( name="Faction Leader", - senator__faction=potential_actions[0].faction, - end_step=None + senator__faction=faction_leader_potential_actions[0].faction, + end_step=None, ) self.assertEqual(len(faction_leader_titles), 1) check_latest_phase(self, game_id, "Mortality") @@ -72,7 +88,8 @@ def setup_game_in_forum_phase(self, player_count: int) -> (int, List[int]): faction_ids_with_leadership = set_some_faction_leaders(game_id) start_forum_phase(game_id) return (game_id, faction_ids_with_leadership) - + + def set_some_faction_leaders(game_id: int) -> List[int]: """ Assigns faction leader titles to 2 senators then returns their faction IDs. @@ -80,12 +97,15 @@ def set_some_faction_leaders(game_id: int) -> List[int]: factions = Faction.objects.filter(game=game_id) first_faction = factions.first() second_faction = factions.last() - senator_in_faction_1 = Senator.objects.filter(game=game_id, faction=first_faction).first() - senator_in_faction_2 = Senator.objects.filter(game=game_id, faction=second_faction).first() + senator_in_faction_1 = Senator.objects.filter( + game=game_id, faction=first_faction + ).first() + senator_in_faction_2 = Senator.objects.filter( + game=game_id, faction=second_faction + ).first() set_faction_leader(senator_in_faction_1.id) set_faction_leader(senator_in_faction_2.id) return [ senator_in_faction_1.faction.id, senator_in_faction_2.faction.id, ] - diff --git a/backend/rorapp/tests/test_helper.py b/backend/rorapp/tests/test_helper.py index c959266d..e293f8e8 100644 --- a/backend/rorapp/tests/test_helper.py +++ b/backend/rorapp/tests/test_helper.py @@ -1,10 +1,9 @@ from typing import List from django.db.models.query import QuerySet from django.test import TestCase -from rorapp.models import Action, Phase, Senator, Step +from rorapp.models import Action, Phase, Step, Turn from django.contrib.auth.models import User from typing import Callable -from rorapp.functions import set_faction_leader def check_all_actions( @@ -118,8 +117,14 @@ def check_latest_phase( expected_latest_phase_name: str, expected_phase_count: int | None = None, ) -> None: - phases = Phase.objects.filter(turn__game=game_id) + """ + Check that the latest phase has the expected name, and matches the latest step and latest turn. + """ + latest_turn = Turn.objects.filter(game=game_id).order_by("-index").first() + phases = Phase.objects.filter(turn=latest_turn).order_by("-index") if expected_phase_count: test_case.assertEqual(phases.count(), expected_phase_count) - latest_phase = phases[len(phases) - 1] + latest_phase = phases.first() + latest_step = Step.objects.filter(phase__turn=latest_turn).order_by("-index").first() + test_case.assertEqual(latest_phase.id, latest_step.phase.id) test_case.assertEqual(latest_phase.name, expected_latest_phase_name) diff --git a/backend/rorapp/views/submit_action.py b/backend/rorapp/views/submit_action.py index 38f6054c..c4b1ae2d 100644 --- a/backend/rorapp/views/submit_action.py +++ b/backend/rorapp/views/submit_action.py @@ -3,8 +3,12 @@ from rest_framework import viewsets from rest_framework.decorators import action from rest_framework.response import Response -from rorapp.functions import face_mortality, select_faction_leader -from rorapp.functions import send_websocket_messages +from rorapp.functions import ( + face_mortality, + initiate_situation, + select_faction_leader, + send_websocket_messages, +) from rorapp.models import Game, Faction, Step, Action @@ -15,7 +19,9 @@ class SubmitActionViewSet(viewsets.ViewSet): @action(detail=True, methods=["post"]) @transaction.atomic - def submit_action(self, request: HttpRequest, game_id: int, action_id: int | None =None): + def submit_action( + self, request: HttpRequest, game_id: int, action_id: int | None = None + ): # Try to get the game try: game = Game.objects.get(id=game_id) @@ -55,7 +61,9 @@ def submit_action(self, request: HttpRequest, game_id: int, action_id: int | Non return self.perform_action(game.id, action, request) - def perform_action(self, game_id: int, action: Action, request: HttpRequest) -> Response: + def perform_action( + self, game_id: int, action: Action, request: HttpRequest + ) -> Response: response = None messages = None match action.type: @@ -63,6 +71,8 @@ def perform_action(self, game_id: int, action: Action, request: HttpRequest) -> response, messages = select_faction_leader(action.id, request.data) case "face_mortality": response, messages = face_mortality(action.id) + case "initiate_situation": + response, messages = initiate_situation(action.id) case _: return Response({"message": "Action type is invalid"}, status=400) diff --git a/frontend/components/GamePage.tsx b/frontend/components/GamePage.tsx index 01724078..b1c82e19 100644 --- a/frontend/components/GamePage.tsx +++ b/frontend/components/GamePage.tsx @@ -11,7 +11,6 @@ import useWebSocket from "react-use-websocket" import Tabs from "@mui/material/Tabs" import Tab from "@mui/material/Tab" -import Box from "@mui/material/Box" import CircularProgress from "@mui/material/CircularProgress" import { useGameContext } from "@/contexts/GameContext" @@ -621,9 +620,7 @@ const GamePage = (props: GamePageProps) => { {`${game!.name} | Republic of Rome Online`} -
+
diff --git a/frontend/components/actionDialogs/ActionDialog.module.css b/frontend/components/actionDialogs/ActionDialog.module.css deleted file mode 100644 index 1b58f5c5..00000000 --- a/frontend/components/actionDialogs/ActionDialog.module.css +++ /dev/null @@ -1,11 +0,0 @@ -button.closeButton { - position: absolute; - right: 8px; - top: 8px; -} - -.content { - display: flex; - flex-direction: column; - gap: 16px; -} diff --git a/frontend/components/actionDialogs/ActionDialog.tsx b/frontend/components/actionDialogs/ActionDialog.tsx index 16165f48..39bd4f8a 100644 --- a/frontend/components/actionDialogs/ActionDialog.tsx +++ b/frontend/components/actionDialogs/ActionDialog.tsx @@ -5,6 +5,7 @@ import Collection from "@/classes/Collection" import Action from "@/classes/Action" import SelectFactionLeaderDialog from "./ActionDialog_SelectFactionLeader" import FaceMortalityDialog from "./ActionDialog_FaceMortality" +import InitiateSituationDialog from "./ActionDialog_InitiateSituation" interface ActionDialogProps { actions: Collection @@ -16,6 +17,7 @@ interface ActionDialogProps { const dialogs: { [key: string]: React.ComponentType } = { select_faction_leader: SelectFactionLeaderDialog, face_mortality: FaceMortalityDialog, + initiate_situation: InitiateSituationDialog, } // Dialog box that displays the action that the player must take diff --git a/frontend/components/actionDialogs/ActionDialog_FaceMortality.tsx b/frontend/components/actionDialogs/ActionDialog_FaceMortality.tsx index 1da1d0e9..a6a44ff3 100644 --- a/frontend/components/actionDialogs/ActionDialog_FaceMortality.tsx +++ b/frontend/components/actionDialogs/ActionDialog_FaceMortality.tsx @@ -11,7 +11,6 @@ import CloseIcon from "@mui/icons-material/Close" import Action from "@/classes/Action" import Collection from "@/classes/Collection" -import actionDialogStyles from "./ActionDialog.module.css" import DeadIcon from "@/images/icons/dead.svg" import request from "@/functions/request" import { useAuthContext } from "@/contexts/AuthContext" @@ -33,9 +32,7 @@ const FaceMortalityDialog = (props: FaceMortalityDialogProps) => { } = useAuthContext() const { game } = useGameContext() - const [requiredAction, setRequiredAction] = useState( - null - ) + const [requiredAction, setRequiredAction] = useState(null) // Set required action useEffect(() => { @@ -64,15 +61,13 @@ const FaceMortalityDialog = (props: FaceMortalityDialogProps) => { return ( <> Ready to Face Mortality? - props.setOpen(false)} - > - - +
+ props.setOpen(false)}> + + +
- +
“Death is the wish of some, the relief of many, and the end of all.” Seneca the Younger diff --git a/frontend/components/actionDialogs/ActionDialog_InitiateSituation.tsx b/frontend/components/actionDialogs/ActionDialog_InitiateSituation.tsx new file mode 100644 index 00000000..472fb4f8 --- /dev/null +++ b/frontend/components/actionDialogs/ActionDialog_InitiateSituation.tsx @@ -0,0 +1,86 @@ +import { useEffect, useState } from "react" +import Image from "next/image" +import { + Button, + DialogActions, + DialogContent, + DialogTitle, + IconButton, +} from "@mui/material" +import CloseIcon from "@mui/icons-material/Close" + +import Action from "@/classes/Action" +import Collection from "@/classes/Collection" +import DeadIcon from "@/images/icons/dead.svg" +import request from "@/functions/request" +import { useAuthContext } from "@/contexts/AuthContext" +import { useGameContext } from "@/contexts/GameContext" + +interface InitiateSituationDialogProps { + setOpen: (open: boolean) => void + actions: Collection +} + +// Action dialog allows the player to ready up for mortality +const InitiateSituationDialog = (props: InitiateSituationDialogProps) => { + const { + accessToken, + refreshToken, + setAccessToken, + setRefreshToken, + setUser, + } = useAuthContext() + const { game } = useGameContext() + + const [requiredAction, setRequiredAction] = useState(null) + + // Set required action + useEffect(() => { + const requiredAction = props.actions.asArray.find( + (a) => a.required === true + ) + if (requiredAction) setRequiredAction(requiredAction) + }, [props.actions]) + + // Handle dialog submission + const handleSubmit = async () => { + if (game && requiredAction) { + request( + "POST", + `games/${game.id}/submit-action/${requiredAction.id}/`, + accessToken, + refreshToken, + setAccessToken, + setRefreshToken, + setUser + ) + props.setOpen(false) + } + } + + return ( + <> + Initiate a Situation +
+ props.setOpen(false)}> + + +
+ + +
+

+ You must initiate a random Situation. It could be a Secret, a Senator, an Event, a War or an Enemy Leader. +

+

This feature is incomplete, so nothing actually happens.

+
+
+ + + + + + ) +} + +export default InitiateSituationDialog diff --git a/frontend/components/actionDialogs/ActionDialog_SelectFactionLeader.tsx b/frontend/components/actionDialogs/ActionDialog_SelectFactionLeader.tsx index 3207132b..db2ba50c 100644 --- a/frontend/components/actionDialogs/ActionDialog_SelectFactionLeader.tsx +++ b/frontend/components/actionDialogs/ActionDialog_SelectFactionLeader.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react" +import { useState } from "react" import { Button, DialogActions, @@ -13,7 +13,6 @@ import { useGameContext } from "@/contexts/GameContext" import Action from "@/classes/Action" import Collection from "@/classes/Collection" import Senator from "@/classes/Senator" -import actionDialogStyles from "./ActionDialog.module.css" import { useAuthContext } from "@/contexts/AuthContext" import request from "@/functions/request" @@ -80,15 +79,13 @@ const SelectFactionLeaderDialog = (props: SelectFactionLeaderDialogProps) => { return ( <> Select your Faction Leader - props.setOpen(false)} - > - - +
+ props.setOpen(false)}> + + +
- +

Your Faction Leader will be immune from persuasion attempts. In the unfortunate event of the death of your Faction Leader, his heir will diff --git a/frontend/data/actions.json b/frontend/data/actions.json index 6b30b8af..8cef0b61 100644 --- a/frontend/data/actions.json +++ b/frontend/data/actions.json @@ -6,5 +6,9 @@ "face_mortality": { "sentence": "face mortality", "title": "Face Mortality" + }, + "initiate_situation": { + "sentence": "initiate a situation", + "title": "Initiate Situation" } }