Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
2 changes: 0 additions & 2 deletions src/retroquest/act1/Act1.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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')
Expand Down
4 changes: 1 addition & 3 deletions src/retroquest/act1/Act1StoryFlags.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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"
Comment thread
rmstdope marked this conversation as resolved.
FLAG_WITNESSED_SHADOW_EVENT = "witnessed_shadow_event"
FLAG_ACCEPTED_MIRA_APPRENTICESHIP = "accepted_mira_apprenticeship"
Expand Down
35 changes: 0 additions & 35 deletions src/retroquest/act1/quests/LostLetter.py

This file was deleted.

13 changes: 11 additions & 2 deletions src/retroquest/engine/CommandParser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
]
Expand Down Expand Up @@ -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)
51 changes: 49 additions & 2 deletions tests/retroquest/act1/test_Integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -65,7 +64,6 @@
PreparingForTheRoadQuest(),
FadedPhotographQuest(),
ShadowsOverWillowbrookQuest(),
LostLetterQuest()
]

def test_golden_path_act1_completion():
Expand Down Expand Up @@ -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}"
)

18 changes: 18 additions & 0 deletions tests/retroquest/engine/test_CommandParser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
13 changes: 7 additions & 6 deletions web/e2e/game.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})

Expand Down Expand Up @@ -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()
})

Expand Down
15 changes: 9 additions & 6 deletions web/src/components/MobileDrawer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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')
}
}
</script>

Expand Down
1 change: 1 addition & 0 deletions web/src/components/SidePanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ defineEmits<{

<template>
<div
data-testid="side-panel"
class="w-80 bg-bg-sidebar border-l border-border overflow-y-auto shrink-0 p-3 max-md:hidden"
>
<!-- Active Quests -->
Expand Down
1 change: 1 addition & 0 deletions web/src/components/TopBar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ defineEmits<{

<template>
<div
data-testid="top-bar"
class="flex items-center justify-between px-4 py-2 bg-bg-secondary border-b border-border min-h-12"
>
<span class="text-xl font-bold text-accent tracking-wide"
Expand Down
80 changes: 80 additions & 0 deletions web/src/stores/useGameStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -888,6 +888,86 @@ describe('useGameStore', () => {
})
})

// --- act transition ---

describe('act transition', () => {
function setupTransitionMocks() {
bridge.isAcceptingInput.mockReturnValue(false)
bridge.isActRunning.mockReturnValue(false)
bridge.activateQuest.mockReturnValue(null)
bridge.updateQuest.mockReturnValue(null)
bridge.completeQuest.mockReturnValue(null)
bridge.advanceToRunning.mockReturnValue([
'Congratulations Act 1!',
'Act 2 begins.',
'',
])
}

beforeEach(async () => {
await store.initGame()
store.dismissModal() // dismiss init intro modal
})

it('shows act transition modal immediately when command causes transition', () => {
setupTransitionMocks()
store.submitCommand('use map')
expect(store.showModal).toBe(true)
expect(store.modalTitle).toBe('🏆 Act Complete!')
expect(store.modalBody).toBe(
'<rendered>Congratulations Act 1!</rendered>',
)
})

it('shows act intro modal after dismissing transition modal', () => {
setupTransitionMocks()
store.submitCommand('use map')
store.dismissModal()
expect(store.showModal).toBe(true)
expect(store.modalTitle).toBe('📖 Act Intro')
expect(store.modalBody).toBe('<rendered>Act 2 begins.</rendered>')
})

it('fires look after all transition modals are dismissed', () => {
setupTransitionMocks()
store.submitCommand('use map')
store.dismissModal() // dismiss transition modal
bridge.look.mockReturnValue('You are in Act 2.')
store.dismissModal() // dismiss intro modal
expect(bridge.look).toHaveBeenCalled()
})

it('shows transition modal after quest completion modals when act transitions', () => {
bridge.completeQuest
.mockReturnValueOnce('Main quest complete!')
.mockReturnValueOnce(null)
bridge.activateQuest.mockReturnValue(null)
bridge.updateQuest.mockReturnValue(null)
bridge.isAcceptingInput.mockReturnValue(false)
bridge.isActRunning.mockReturnValue(false)
bridge.advanceToRunning.mockReturnValue([
'Congratulations Act 1!',
'Act 2 begins.',
'',
])
store.submitCommand('use map')
expect(store.modalTitle).toBe('🏆 Quest Complete!')
store.dismissModal()
expect(store.showModal).toBe(true)
expect(store.modalTitle).toBe('🏆 Act Complete!')
})

it('does not trigger transition modals when act is running normally', () => {
// isActRunning stays true (default), so no transition modals
bridge.activateQuest.mockReturnValue(null)
bridge.updateQuest.mockReturnValue(null)
bridge.completeQuest.mockReturnValue(null)
store.submitCommand('look')
expect(store.showModal).toBe(false)
expect(bridge.advanceToRunning).not.toHaveBeenCalled()
})
})

// --- getSaveSlots / saveToSlot / loadFromSlot ---

describe('getSaveSlots', () => {
Expand Down
Loading
Loading