diff --git a/.gitignore b/.gitignore index a108647..d2c0c3e 100644 --- a/.gitignore +++ b/.gitignore @@ -166,3 +166,7 @@ src/run.py *.txt .tmp + +# Playwright test artefacts (generated at repo root when tests run from here) +test-results/ +playwright-report/ diff --git a/src/retroquest/act1/Act1.py b/src/retroquest/act1/Act1.py index 15d60af..16836e3 100644 --- a/src/retroquest/act1/Act1.py +++ b/src/retroquest/act1/Act1.py @@ -26,7 +26,6 @@ from retroquest.act1.quests.PreparingForTheRoad import PreparingForTheRoadQuest from retroquest.act1.quests.FadedPhotograph import FadedPhotographQuest from retroquest.act1.quests.ShadowsOverWillowbrook import ShadowsOverWillowbrookQuest -from retroquest.act1.quests.LostLetter import LostLetterQuest from retroquest.act1.Act1StoryFlags import FLAG_SHADOWS_OVER_WILLOWBROOK_COMPLETED class Act1(Act): @@ -60,7 +59,6 @@ def __init__(self) -> None: OhDeerOhDeerQuest(), PreparingForTheRoadQuest(), FadedPhotographQuest(), - LostLetterQuest(), ] music_file = "Conquest - Market (freetouse.com).mp3" super().__init__(name="Act1", rooms=rooms, quests=quests, music_file=music_file, music_info='Market by Conquest\nSource: https://freetouse.com/music\nCopyright Free Background Music') diff --git a/src/retroquest/act1/Act1StoryFlags.py b/src/retroquest/act1/Act1StoryFlags.py index 22e1fa4..a18b39f 100644 --- a/src/retroquest/act1/Act1StoryFlags.py +++ b/src/retroquest/act1/Act1StoryFlags.py @@ -7,7 +7,7 @@ to manage game state and narrative progression. Flags include: -- Discovery events (e.g., finding the lost letter, photo) +- Discovery events (e.g., finding and returning the faded photograph) - Character interactions (e.g., talking to the grandmother, priest, fisherman, blacksmith, villager) - Investigation and observation events (e.g., investigating withered crops, observing deer, examining the well) @@ -17,8 +17,6 @@ These constants are intended for use throughout the game's codebase to ensure consistent reference to story flags and facilitate maintainable game logic. """ -FLAG_FOUND_LOST_LETTER = "found_lost_letter" -FLAG_ASKED_GRANDMOTHER_ABOUT_LETTER = "asked_grandmother_about_letter" FLAG_INVESTIGATED_WITHERED_CROPS = "investigated_withered_crops" FLAG_WITNESSED_SHADOW_EVENT = "witnessed_shadow_event" FLAG_ACCEPTED_MIRA_APPRENTICESHIP = "accepted_mira_apprenticeship" diff --git a/src/retroquest/act1/quests/LostLetter.py b/src/retroquest/act1/quests/LostLetter.py deleted file mode 100644 index e45a2fb..0000000 --- a/src/retroquest/act1/quests/LostLetter.py +++ /dev/null @@ -1,35 +0,0 @@ -from ...engine.Quest import Quest -from ...engine.GameState import GameState -from ..Act1StoryFlags import ( - FLAG_FOUND_LOST_LETTER, - FLAG_ASKED_GRANDMOTHER_ABOUT_LETTER -) -from typing import Any - -# The Lost Letter Quest - Steps to Completion -# 1. The player searches the floorboard in Elior's Cottage and finds the faded letter. -# 2. The game sets FLAG_FOUND_LOST_LETTER to indicate the letter has been found. -# 3. The letter item is given to the player and can be examined. -# 4. The player talks to Grandmother about the letter, triggering special dialogue. -# 5. The game sets FLAG_ASKED_GRANDMOTHER_ABOUT_LETTER after the conversation. -# 6. Optionally, a clue or follow-up quest is unlocked by this letter. -# 7. The quest is now complete and connects to the main story thread. - -class LostLetterQuest(Quest): - def __init__(self) -> None: - super().__init__( - name="The Lost Letter", - description=( - "While cleaning Elior’s cottage, you discover a faded letter hidden beneath a floorboard. " - "The letter is addressed to your grandmother and mentions a secret your parents were investigating before they vanished." - ), - completion="You have uncovered a clue about your parents' fate. The letter hints at a mystery that will unfold in future acts." - ) - - def check_trigger(self, game_state: GameState) -> bool: - # Triggered when the player searches the floorboard in Elior's Cottage - return game_state.get_story_flag(FLAG_FOUND_LOST_LETTER) - - def check_completion(self, game_state: GameState) -> bool: - # Completed when the player talks to Grandmother about the letter - return game_state.get_story_flag(FLAG_ASKED_GRANDMOTHER_ABOUT_LETTER) diff --git a/src/retroquest/engine/CommandParser.py b/src/retroquest/engine/CommandParser.py index 9d27e79..516f8e5 100644 --- a/src/retroquest/engine/CommandParser.py +++ b/src/retroquest/engine/CommandParser.py @@ -4,7 +4,9 @@ from typing import Any, TYPE_CHECKING _ACT_1_CHEAT_COMMANDS: list[str] = [ - "use lantern", "take bread", "use journal", "talk to grandmother", + "use lantern", "take bread", "use journal", "search", + "take faded photograph", "examine faded photograph", + "talk to grandmother", "e", "talk to villager", "w", "s", "take carrot", "use hoe", "take knife", "s", "take egg", "take feather", "use bread with chicken", "take key", "n", "e", "examine well", @@ -32,7 +34,8 @@ "s", "s", "talk to priest", "n", "n", "n", "w", "n", "n", "e", "n", "talk to shopkeeper", "s", "w", "s", "s", "e", "s", "s", "s", "use matches with candle", "take locket", "n", "n", "n", - "w", "n", "n", "w", "n", "give locket to grandmother", + "w", "n", "n", "w", "n", "give faded photograph to grandmother", + "give locket to grandmother", "cast bless", "s", "e", "s", "s", "e", "s", "s", "s", "e", "give shiny ring to merchant", ] @@ -313,4 +316,10 @@ def _execute_cheat(self, commands: list[str]) -> str: result = self.parse(command) if result is not None: results.append(str(result)) + while self.game.state.next_activated_quest(): + pass + while self.game.state.next_updated_quest(): + pass + while self.game.state.next_completed_quest(): + pass return '\n'.join(results) diff --git a/tests/retroquest/act1/test_Integration.py b/tests/retroquest/act1/test_Integration.py index 46432a9..cd6e300 100644 --- a/tests/retroquest/act1/test_Integration.py +++ b/tests/retroquest/act1/test_Integration.py @@ -27,7 +27,6 @@ from retroquest.act1.quests.OhDeerOhDeer import OhDeerOhDeerQuest from retroquest.act1.quests.PreparingForTheRoad import PreparingForTheRoadQuest from retroquest.act1.quests.FadedPhotograph import FadedPhotographQuest -from retroquest.act1.quests.LostLetter import LostLetterQuest from retroquest.act1.quests.ShadowsOverWillowbrook import ShadowsOverWillowbrookQuest from ..utils.utils import (check_character_in_room, check_current_room, check_quests, execute_commands, check_item_in_inventory, check_item_in_room, @@ -65,7 +64,6 @@ PreparingForTheRoadQuest(), FadedPhotographQuest(), ShadowsOverWillowbrookQuest(), - LostLetterQuest() ] def test_golden_path_act1_completion(): @@ -508,3 +506,52 @@ def test_golden_path_act1_completion(): # Final check: Act I should be completed assert game.acts[game.current_act].is_completed(game.state), "Act I is not marked as completed." + +def test_cheat_act_1_completes_all_side_quests_and_leaves_main_active(): + """cheat act 1 must complete every completable side quest and leave only the main quest active. + + The cheat sequence gives the Rare Flower to Mira before any quest + processing normally occurs. The cheat engine must therefore drain + quest-activation and quest-completion state between commands. Without + that, check_completion returns False when the frontend later calls + completeQuest() because the flower is no longer in inventory or rooms. + """ + act = Act1() + act.music_file = '' + game = Game([act]) + + # Capture the expected side-quest names before the cheat runs, because + # act.quests is drained during cheat execution. + expected_side_quests = sorted(q.name for q in act.quests if not q.is_main()) + + game.command_parser.parse("cheat act 1") + + # Drain quest events, simulating what the web frontend's pollQuestEvents() + # does after a command returns. + while game.state.next_activated_quest(): + pass + while game.state.next_updated_quest(): + pass + while game.state.next_completed_quest(): + pass + + active_names = [q.name for q in game.state.activated_quests] + active_main = [q.name for q in game.state.activated_quests if q.is_main()] + active_side = [q.name for q in game.state.activated_quests if not q.is_main()] + + assert active_main == ["Shadows Over Willowbrook"], ( + f"Only the main quest should remain active after 'cheat act 1', " + f"but active quests are: {active_names}" + ) + assert active_side == [], ( + f"No side quests should remain active after 'cheat act 1', " + f"but active side quests are: {active_side}" + ) + completed_side = sorted( + q.name for q in game.state.completed_quests if not q.is_main() + ) + assert completed_side == expected_side_quests, ( + f"All completable side quests should be completed after 'cheat act 1', " + f"but completed side quests are: {completed_side}" + ) + diff --git a/tests/retroquest/engine/test_CommandParser.py b/tests/retroquest/engine/test_CommandParser.py index c3325d2..6e099c3 100644 --- a/tests/retroquest/engine/test_CommandParser.py +++ b/tests/retroquest/engine/test_CommandParser.py @@ -3,12 +3,30 @@ import pytest from engine.CommandParser import CommandParser + +class DummyGameState: + """Minimal stub for GameState used by DummyGame in CommandParser tests.""" + + def next_activated_quest(self): + """Always return None: no quests to activate in unit tests.""" + return None + + def next_updated_quest(self): + """Always return None: no quests to update in unit tests.""" + return None + + def next_completed_quest(self): + """Always return None: no quests to complete in unit tests.""" + return None + + class DummyGame: """A minimal fake Game that records calls for CommandParser tests.""" def __init__(self): """Create the fake game and a list to record calls.""" self.calls = [] + self.state = DummyGameState() def move(self, direction, arg=None): """Record a movement request.""" diff --git a/web/e2e/game.spec.ts b/web/e2e/game.spec.ts index dce4a90..8c0b6d7 100644 --- a/web/e2e/game.spec.ts +++ b/web/e2e/game.spec.ts @@ -357,7 +357,7 @@ test.describe('Mobile viewport', () => { }) test('sidebar is hidden on mobile', async ({ page }) => { - const sidebar = page.locator('.max-md\\:hidden') + const sidebar = page.getByTestId('side-panel') await expect(sidebar).toBeHidden() }) @@ -394,20 +394,21 @@ test.describe('Mobile toolbar overflow fix', () => { test('Quicksave, Quickload, Save, Load, Help are hidden in toolbar on mobile', async ({ page, }) => { + const topBar = page.getByTestId('top-bar') await expect( - page.getByRole('button', { name: /Quicksave/i }).first(), + topBar.getByRole('button', { name: /Quicksave/i }), ).toBeHidden() await expect( - page.getByRole('button', { name: /Quickload/i }).first(), + topBar.getByRole('button', { name: /Quickload/i }), ).toBeHidden() await expect( - page.getByRole('button', { name: /^πŸ’Ύ Save$/i }), + topBar.getByRole('button', { name: /^πŸ’Ύ Save$/i }), ).toBeHidden() await expect( - page.getByRole('button', { name: /^πŸ“‚ Load$/i }), + topBar.getByRole('button', { name: /^πŸ“‚ Load$/i }), ).toBeHidden() await expect( - page.getByRole('button', { name: /^❓ Help$/i }), + topBar.getByRole('button', { name: /^❓ Help$/i }), ).toBeHidden() }) diff --git a/web/src/components/MobileDrawer.vue b/web/src/components/MobileDrawer.vue index a615840..5c4efaa 100644 --- a/web/src/components/MobileDrawer.vue +++ b/web/src/components/MobileDrawer.vue @@ -34,12 +34,15 @@ const emit = defineEmits<{ function emitAndClose( action: 'quickSave' | 'quickLoad' | 'save' | 'load' | 'help', ) { - if (action === 'quickSave') emit('quickSave') - else if (action === 'quickLoad') emit('quickLoad') - else if (action === 'save') emit('save') - else if (action === 'load') emit('load') - else emit('help') - emit('close') + try { + if (action === 'quickSave') emit('quickSave') + else if (action === 'quickLoad') emit('quickLoad') + else if (action === 'save') emit('save') + else if (action === 'load') emit('load') + else emit('help') + } finally { + emit('close') + } } diff --git a/web/src/components/SidePanel.vue b/web/src/components/SidePanel.vue index 2712602..5ceab76 100644 --- a/web/src/components/SidePanel.vue +++ b/web/src/components/SidePanel.vue @@ -34,6 +34,7 @@ defineEmits<{