Skip to content

refactor: Introduce MVC architecture — Controllers, Services, and Repositories#353

Closed
Copilot wants to merge 3 commits into
mainfrom
copilot/refactor-roam-game-architecture
Closed

refactor: Introduce MVC architecture — Controllers, Services, and Repositories#353
Copilot wants to merge 3 commits into
mainfrom
copilot/refactor-roam-game-architecture

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Apr 20, 2026

Standardizes non-model classes into Controller, Service, and Repository layers following the MVC pattern, replacing logic scattered across screen classes and world utilities.

Repository Layer (src/repositories/)

  • StatsRepository — stats JSON persistence; Stats.save()/Stats.load() now delegate here
  • CodexRepository — codex JSON persistence
  • PlayerRepository — player location, attributes, inventory persistence
  • WorldRepository — room JSON persistence
  • ConfigRepository — window size persistence

Service Layer (src/services/)

  • MovementService — movement, cooldowns, room transitions, stone pushing
  • InventoryService — food consumption, hotbar slot selection
  • CraftingService — recipe validation and execution
  • WorldService — room load/generate with stats/status side effects
  • EntityService — entity interactions, living entity deaths, codex discovery, crop harvesting; _getLocationAndRoomAtMousePosition raises NotImplementedError — mouse-position resolution is the caller's (screen's) responsibility
  • SaveService — orchestrates save/load across repositories; InventoryJsonReaderWriter is injected via DI rather than constructed manually

Controller Layer (src/controllers/)

  • PlayerController — routes player movement and hotbar key events; no Graphik dependency (screenshot handling stays in the screen)
  • InventoryController — thin router only: delegates craftRecipe to CraftingService and changeSelectedSlot to InventoryService; all UI state (cursorSlot, craftPanelOpen) and hit-testing geometry remain in InventoryScreen
  • WorldController — routes world save/load and room discovery
  • MenuController — removed; MainMenuScreen already owns all navigation state and transition triggers

Existing class updates

  • Statssave()/load() delegate to StatsRepository
  • WorldScreen — injects WorldService, EntityService, WorldRepository; delegates getOrLoadRoom, _loadOrGenerateRoom, discoverLivingEntitiesInRoom, checkForLivingEntityDeaths, saveRoomToFile
  • InventoryScreen — injects InventoryController; delegates craftRecipe to it, removing inline business logic from the view

DI integration

All new classes use @component for auto-wiring. tests/conftest.py explicitly imports new modules at module level (with an explicit sys.path insert) to trigger @component registration before DI resolution — required because pytest processes pythonpath entries after conftest.py is initially loaded. Test directories omit __init__.py to avoid namespace collisions with same-named source packages.

Tests

50 new tests across tests/repositories/, tests/services/, and tests/controllers/. All 461 tests pass. test_inventoryController.py includes test_no_ui_state and test_no_pygame_or_graphik_dependency assertions to enforce layer compliance. test_craftRecipe_inventory_full is deterministic: fills all 25 slots so zero space remains for the crafted result, then asserts False return and "Inventory full" status.

Copilot AI and others added 2 commits April 20, 2026 19:36
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Introduces an MVC-style layering (Controllers/Services/Repositories) to centralize game logic and persistence that was previously embedded in screen classes and utilities, with DI integration and new unit tests.

Changes:

  • Added src/repositories/, src/services/, and src/controllers/ layers (all DI-wired via @component) to encapsulate persistence, business logic, and input routing.
  • Updated Stats and WorldScreen to delegate persistence and world/entity operations to the new layers.
  • Expanded test suite with new controller/service/repository tests and updated DI bootstrap for tests.

Reviewed changes

Copilot reviewed 32 out of 35 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
tests/services/test_worldService.py Adds unit coverage for WorldService room load/generate behavior and side effects.
tests/services/test_inventoryService.py Adds unit coverage for inventory eating and hotbar selection status messaging.
tests/services/test_entityService.py Adds unit coverage for codex discovery and living-entity death handling.
tests/services/test_craftingService.py Adds unit coverage for crafting success/failure paths (including an “inventory full” scenario).
tests/repositories/test_worldRepository.py Adds unit coverage for world room path building and JSON/room persistence to disk.
tests/repositories/test_statsRepository.py Adds unit coverage for stats save/load plus an integration-ish roundtrip via DI-resolved Stats.
tests/repositories/test_playerRepository.py Adds unit coverage for player location/attributes persistence behaviors.
tests/repositories/test_configRepository.py Adds unit coverage for window-size persistence delegation to Config.
tests/repositories/test_codexRepository.py Adds unit coverage for codex persistence and “no file” behavior.
tests/controllers/test_worldController.py Adds unit coverage for controller delegation to services/preloader.
tests/controllers/test_menuController.py Adds unit coverage for menu controller state transitions (with pygame init/quit fixture).
tests/controllers/test_inventoryController.py Adds unit coverage for cursor-slot swapping, crafting delegation, and click handling.
tests/conftest.py Adjusts test DI bootstrap to ensure src/ importability and eager @component registration via imports.
src/stats/stats.py Refactors Stats.save/load to delegate to StatsRepository and injects it via DI.
src/services/worldService.py Adds WorldService to encapsulate room retrieval/load/generation side effects.
src/services/saveService.py Adds SaveService orchestrating save/load across repositories (stats/tick/codex/player/world).
src/services/movementService.py Adds MovementService encapsulating movement/cooldowns/room transitions and stone pushing logic.
src/services/inventoryService.py Adds InventoryService for inventory eating and selected-slot status updates.
src/services/entityService.py Adds EntityService for interactions, codex discovery, crop harvesting, and living-entity death handling.
src/services/craftingService.py Adds CraftingService with capacity pre-check + crafting execution and status reporting.
src/services/init.py Introduces services package marker.
src/screen/worldScreen.py Injects and delegates selected world/entity/persistence operations to new services/repositories.
src/repositories/worldRepository.py Adds WorldRepository for room persistence to disk (+ helper for room paths / raw JSON writes).
src/repositories/statsRepository.py Adds StatsRepository to own stats JSON schema validation and persistence.
src/repositories/playerRepository.py Adds PlayerRepository for player location/attributes/inventory persistence.
src/repositories/configRepository.py Adds ConfigRepository to delegate config persistence (window size).
src/repositories/codexRepository.py Adds CodexRepository for codex JSON persistence and schema validation.
src/repositories/init.py Introduces repositories package marker.
src/controllers/worldController.py Adds WorldController routing world persistence/discovery/preloading calls to services.
src/controllers/playerController.py Adds PlayerController routing movement/hotbar inputs to MovementService/InventoryService.
src/controllers/menuController.py Adds MenuController managing menu navigation and screen-change flags.
src/controllers/inventoryController.py Adds InventoryController routing inventory interactions and crafting.
src/controllers/init.py Introduces controllers package marker.
src/bootstrap.py Registers InventoryJsonReaderWriter with DI as a transient factory.
CHANGELOG.md Documents the MVC refactor and adds learning-log entries about DI import/pytest pythonpath ordering.

Comment thread tests/services/test_craftingService.py Outdated
Comment on lines +56 to +67
def test_craftRecipe_inventory_full():
service, status = makeService()
recipe = Recipe("Wood Floor", {OakWood: 4}, WoodFloor, "assets/images/woodFloor.png")
inventory = makeInventoryWithOakWood(4)
# Fill all remaining slots
oak = OakWood()
for slot in inventory.getInventorySlots():
if slot.isEmpty():
slot.add(oak)
result = service.craftRecipe(recipe, inventory)
# Should either succeed (no real full issue with 25 slots) or fail gracefully
assert isinstance(result, bool)
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new test_craftRecipe_inventory_full doesn’t actually validate the “inventory full” behavior: it only asserts the return is a bool, and the setup adds a single OakWood instance to each empty slot (leaving most stack capacity available). This can pass even if the capacity check is wrong. Make the test deterministic by filling relevant stacks to getMaxStackSize() (and/or all slots) and asserting the expected return value and status.set(...) message (e.g., "Inventory full").

Copilot uses AI. Check for mistakes.
Comment thread tests/conftest.py Outdated
Comment on lines +4 to +8
# Ensure src directory is in the Python path so repository/service/controller packages
# are importable. This needs to happen before any other imports.
_srcDir = os.path.join(os.path.dirname(__file__), "..", "src")
if _srcDir not in sys.path:
sys.path.insert(0, _srcDir)
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tests/conftest.py mostly uses camelCase local names (e.g., previousRegistrations, overrideDependency), but _srcDir introduces a different naming style. To stay consistent with the existing naming in this file, consider renaming it (e.g., _srcDir_srcDir is still mixed) to a camelCase name like srcDir/_srcDirPath (and keep it private if desired).

Copilot uses AI. Check for mistakes.
Comment thread src/services/saveService.py Outdated
Comment on lines +41 to +43
def _makeInventoryReaderWriter(self):
return InventoryJsonReaderWriter(self.config)

Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SaveService manually instantiates InventoryJsonReaderWriter even though bootstrap.py now registers it with the DI container. This duplicates construction logic and bypasses the DI wiring pattern used elsewhere (e.g., factory/registration for types needing runtime primitives). Consider injecting InventoryJsonReaderWriter (or an injected factory/provider) into SaveService and removing _makeInventoryReaderWriter() / direct construction so creation stays centralized in DI bootstrap.

Copilot uses AI. Check for mistakes.
Comment on lines +216 to +224
def _getLocationAndRoomAtMousePosition(self, currentRoom, map):
"""Delegate to WorldScreen helper - accessed via graphik context.

This method is intentionally thin; actual pixel math stays in WorldScreen.
EntityService callers should pass the resolved (location, room) tuple.
This stub exists so callers internal to EntityService can be unit-tested.
"""
return -1, None

Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

EntityService._getLocationAndRoomAtMousePosition() is currently a stub that always returns (-1, None), which makes executeGatherAction()/executePlaceAction() effectively no-ops if they’re ever wired in. To avoid silent broken behavior, either implement this via an injected callback/provider, or raise NotImplementedError (and keep the mouse-position resolving responsibility in the caller) until the method is properly wired.

Copilot uses AI. Check for mistakes.
@dmccoystephenson
Copy link
Copy Markdown
Member

@copilot Before this PR merges, a full audit of each new class is required to verify that it is placed in the correct layer. Naming alone does not constitute correct MVC separation. Please review every new class against the definitions below and make whatever changes are necessary — including renaming, moving, splitting, or deleting classes — to achieve honest layer compliance.

Layer definitions:

  • Views (Screen classes) — own the game loop, rendering, and raw input event capture. A screen's only responsibilities are drawing state to the display and forwarding input events to the appropriate controller. Screen classes must not contain business logic, persistence logic, or direct domain mutations. They are the entry point into the MVC flow, not a layer to be replaced by controllers.
  • Controllers — thin input routers only. Receive forwarded input events from a screen, determine which action was requested, and delegate immediately to a service. Must contain zero business logic, zero persistence logic, and zero UI state.
  • Services — all business logic and orchestration. Must have no knowledge of rendering, input events, or pygame primitives.
  • Repositories — pure I/O only. Read and write data to/from disk. Must contain no business rules, no game logic, and no domain behavior beyond serialization.

The expected call flow is: Screen (captures input) → Controller (routes action) → Service (executes logic) → Repository (persists data)

Specific issues to address:

  • Screen classes (e.g. WorldScreen) must not be renamed or replaced by controllers. Audit them to confirm they are acting purely as views: rendering game state and forwarding events. Any remaining business logic inside a screen must be extracted into the appropriate service.
  • InventoryController holds mutable UI state (cursorSlot, craftPanelOpen, lastCraftToggleTime) and contains substantial hit-testing geometry (_isInsideInventoryPanel, _isInsideCraftButton, _isInsideCraftPanel, etc.). This does not meet the definition of a controller. Hit-testing and cursor state that is tightly coupled to the rendered layout belongs in the screen or a dedicated view-helper; business logic belongs in a service. Evaluate whether this class should be split, renamed, or relocated accordingly.
  • MenuController stores nextScreen and changeScreen state and drives screen transitions. Determine whether this responsibility belongs in a controller or in a dedicated navigation service, and whether the corresponding screen class should own the transition trigger instead.
  • Verify that no service class imports or references pygame, Graphik, or any screen/UI type.
  • Verify that no repository class contains business logic beyond serialization.

The goal is a structurally correct MVC implementation, not surface-level naming compliance.

…an layer boundaries

Agent-Logs-Url: https://github.com/Preponderous-Software/roam/sessions/ad31c0bb-633e-4af8-8f94-3a3cb6a8f744

Co-authored-by: dmccoystephenson <21204351+dmccoystephenson@users.noreply.github.com>
Copy link
Copy Markdown
Contributor Author

Copilot AI commented Apr 20, 2026

@copilot Before this PR merges, a full audit of each new class is required to verify that it is placed in the correct layer. Naming alone does not constitute correct MVC separation. Please review ever...

Addressed in e296e0f. Here is what was done for each issue:

InventoryController — removed all UI state (cursorSlot, craftPanelOpen, lastCraftToggleTime), all hit-testing geometry (_isInsideInventoryPanel / _isInsideCraftButton / _isInsideCraftPanel / _isInsideBackButton), handleMouseClickEvent, and all pygame/Graphik/screenshot imports. The controller now has exactly two methods: craftRecipe(recipe)CraftingService and changeSelectedSlot(index)InventoryService. InventoryScreen was updated to inject and delegate craftRecipe to the controller, removing the duplicate inline business logic from the view.

MenuController — deleted. MainMenuScreen already owns all navigation state (nextScreen/changeScreen) and transition triggers (switchToSaveSelectionScreen, quitApplication). MenuController was dead code that duplicated screen state and imported pygame.

PlayerController — removed the Graphik constructor dependency and the screenshot key handler. Screenshot handling stays in WorldScreen (the view).

SaveServiceInventoryJsonReaderWriter is now injected via the DI container; the manual _makeInventoryReaderWriter() helper has been removed.

EntityService._getLocationAndRoomAtMousePosition() — now raises NotImplementedError with a message explaining that mouse-position resolution belongs in the caller (the screen), preventing silent broken behavior.

Layer compliance assertions verified by two new tests in test_inventoryController.py: test_no_ui_state and test_no_pygame_or_graphik_dependency.

@dmccoystephenson
Copy link
Copy Markdown
Member

@copilot The new layers were added on top of existing code without removing the logic they were meant to replace. The result is duplication, dead classes, and a WorldScreen that remains ~2000 lines. The following changes are required. Deletions, renames, and restructuring are all expected and appropriate — including in the test suite.


Layer definitions (reference for all changes below)

Every class must have a clear layer identity. Classes that do not fit one of the following categories should be dissolved into one that does.

  • Screen (View) — rendering and raw input capture only. Resolves view-level concerns (e.g. mouse position to grid location) and forwards actions to a controller. No business logic, no persistence, no domain mutations.
  • Controller — thin input router. Receives forwarded events from a screen, determines which action was requested, and delegates immediately to a service. No state, no logic.
  • Service — all business logic and orchestration. No pygame, no rendering, no input events.
  • Repository — pure I/O. Reads and writes data to disk. No business rules.
  • Model — state and data-access only. No simulation logic, no rendering, no persistence.

Call flow: Screen → Controller → Service → Repository


Deletions (no replacement needed)

  • Delete WorldScreenPersistence — duplicates PlayerRepository, WorldRepository, and SaveService. Remove the class and update all callers to use those directly.
  • Delete MenuController — holds nextScreen/changeScreen navigation state, which is a screen responsibility. Every other screen manages this itself. Delete the class and fold any remaining logic into MainMenuScreen.
  • Delete ConfigRepositorysaveWindowSize is a single-line pass-through to Config.saveWindowSize, which already exists and is called directly elsewhere. This class owns no I/O and provides no value.
  • Delete the old InventoryController — the pre-PR version containing cursorSlot, craftPanelOpen, lastCraftToggleTime, and all hit-testing geometry must be removed. The new thin InventoryController with only craftRecipe and changeSelectedSlot is correct and must be the only version.

WorldScreen — extract all non-view logic

WorldScreen must be reduced to rendering and input forwarding. Extract the following:

  • Introduce GameTickService — move all per-tick orchestration out of WorldScreen: calls to moveLivingEntities, reproduceLivingEntities, tickExcrement, tickCrops, energy depletion, auto-eat, status expiry, and map image updates. WorldScreen should call gameTickService.tick(currentRoom) once per frame and nothing more.
  • Fix EntityService.executeGatherAction and executePlaceAction_getLocationAndRoomAtMousePosition currently raises NotImplementedError, meaning WorldScreen is still resolving mouse positions itself. Change both methods to accept (targetLocation, targetRoom) as direct parameters. WorldScreen resolves the grid location from mouse coordinates (a legitimate view concern) and passes it in.
  • Remove the save_callback patternWorldScreen passes a save callback down through PlayerController into MovementService. This is an inverted dependency: a service is orchestrating a screen-level concern. Remove the callback parameter from both PlayerController and MovementService. After any movement call that returns a new room, WorldScreen checks whether the room changed and calls worldController.save(currentRoom) directly.

Room — remove simulation behaviour

Room is a Model. It must not own simulation logic. Extract moveLivingEntities, reproduceLivingEntities, tickExcrement, tickCrops, and _feedLivingEntityIfNeeded into GameTickService or EntityService. Room retains only data access and entity placement methods.


WorldController — remove redundant delegation

discoverLivingEntitiesInRoom is a single-line wrapper around EntityService.discoverLivingEntitiesInRoom with no added logic. Remove it and have WorldScreen call EntityService directly, or define a clear and consistent scope for WorldController that justifies it as an intermediary for all world-level actions.


Definition of done

  • Every class belongs unambiguously to one layer: Screen, Controller, Service, Repository, or Model.
  • WorldScreen contains only rendering and input-forwarding logic.
  • No screen class contains business logic, persistence logic, or direct domain mutations.
  • WorldScreenPersistence, the original InventoryController, MenuController, and ConfigRepository do not exist.
  • SaveService is the sole code path for all save and load operations.
  • Tests for deleted or restructured classes are deleted or rewritten accordingly. Tests must reflect the final structure, not the intermediate one.
  • All tests pass; new tests cover GameTickService.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants