Skip to content

Fix towers not firing on resume and expand game persistence - #191

Merged
candour merged 6 commits into
mainfrom
fix-resume-firing-issue-977308410548281756
Jun 17, 2026
Merged

Fix towers not firing on resume and expand game persistence#191
candour merged 6 commits into
mainfrom
fix-resume-firing-issue-977308410548281756

Conversation

@candour

@candour candour commented Jun 16, 2026

Copy link
Copy Markdown
Owner

This PR fixes a bug where towers would stop firing after a player exited to the main menu and resumed the game. The root cause was that the simulationTimeMs clock (which regulates combat timing) was not being saved, causing it to reset to zero on resume while towers still held fire-cooldown timestamps from the previous session.

In addition to fixing the clock, I've expanded the save system to persist the full active wave state, including:

  • Active enemies and their positions/health
  • Flying projectiles and active puddles
  • Wave progress and remaining enemies to spawn
  • Unused Kitchelin Star bonuses (Free upgrades, budget bonuses)

I also ensured that the game auto-saves whenever the player makes a change to the board (building, selling, or upgrading stalls) or navigates away from the game screen. Backward compatibility is maintained for older save files.


PR created automatically by Jules for task 977308410548281756 started by @candour

Summary by CodeRabbit

Release Notes

  • Bug Fixes

    • Improved save/load to persist significantly more in-progress gameplay details, including wave timing, spawn queues, boss-wave indicators, and simulation progress.
    • Save state recovery is now more resilient to older or partially populated save data.
    • Gameplay actions and transitions (building, selling/undo, upgrades/bonuses, wave/level progression, and key resets) now consistently save the most up-to-date state.
  • Tests

    • Updated game-loop persistence expectations to include the correct in-game screen context.

- Fixed synchronization issue where towers stopped firing after resume because the virtual clock (simulationTimeMs) was reset to 0 while towers held last-fire timestamps from the previous session.
- Implemented comprehensive game state persistence, saving enemies, projectiles, puddles, and wave metadata to ensure a seamless "resume where you left off" experience.
- Added auto-save triggers to all significant board actions and navigation events.
- Ensured backward compatibility with legacy save files using null-safe deserialization.
- Excluded Bak Kut Teh buff type from persistence per user preference for randomized selection on wave start.

Co-authored-by: candour <4670475+candour@users.noreply.github.com>
@google-labs-jules

Copy link
Copy Markdown
Contributor

👋 Jules, reporting for duty! I'm here to lend a hand with this pull request.

When you start a review, I'll add a 👀 emoji to each comment to let you know I've read it. I'll focus on feedback directed at me and will do my best to stay out of conversations between you and other bots or reviewers to keep the noise down.

I'll push a commit with your requested changes shortly after. Please note there might be a delay between these steps, but rest assured I'm on the job!

For more direct control, you can switch me to Reactive Mode. When this mode is on, I will only act on comments where you specifically mention me with @jules. You can find this option in the Pull Request section of your global Jules UI settings. You can always switch back!

New to Jules? Learn more at jules.google/docs.


For security, I will only act on instructions from the user who triggered this task.

@coderabbitai

coderabbitai Bot commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

PersistentGameState is extended with 14 new fields covering simulation time, dynamic entity lists (enemies, projectiles, puddles, visual effects), active-wave status, spawn timing, and wave-economy counters. GameStateRepository synchronizes save/load operations and wires these fields bidirectionally. MainViewModel adds a Channel-based save queue with background processor and public saveGame() method, routing all major gameplay mutations (applyCheat, resetGame, wave progression, level transitions, stall actions, and special effects) through this centralized persistence mechanism.

Changes

Game State Persistence Expansion

Layer / File(s) Summary
Save channel infrastructure and initialization
app/src/main/java/com/messark/hawker/MainViewModel.kt
A Channel<GameState>(CONFLATED) buffers enqueued game states. A background IO coroutine (startSaveProcessor) drains the channel and calls gameStateRepository.saveGameState. A public saveGame() method wraps Channel.trySend(). Navigation from GAME to MAIN_MENU, resetGame, and all subsequent gameplay mutations enqueue persistence via this method.
PersistentGameState schema and serialization
app/src/main/java/com/messark/hawker/utils/GameStateRepository.kt
PersistentGameState gains 14 new nullable fields: simulationTimeMs, dynamic entity lists (enemies, projectiles, puddles, visualEffects), wave state (waveActive, enemiesToSpawn, enemiesToSpawnList, isBossWave, bossWaveTriggerTimeMs, lastSpawnTimeMs), and economy counters (goldEarnedThisWave, activeBudgetBonuses, freeSpecificUpgrades). saveGameState is synchronized and populates all fields from GameState. loadGameState restores them with null-safe ?: emptyList() fallbacks.
Wave progression and engine loop persistence
app/src/main/java/com/messark/hawker/MainViewModel.kt
proceedWithWave computes updated state via updateAndGet and enqueues it via saveGame(). The updateGame engine loop guard is tightened to require both currentScreen == GAME and activeTutorial == null. Wave completion sets shouldSave = true and later enqueues the finalized state through saveGame().
Level transition persistence
app/src/main/java/com/messark/hawker/MainViewModel.kt
graduateToNextLevel computes the next-level GameState via updateAndGet and enqueues it for persistence through saveGame().
Stall placement and sales persistence
app/src/main/java/com/messark/hawker/MainViewModel.kt
handleBuildStall, sellStall, undoSell, and cycleTargetMode each capture prevState, compute updatedState via updateAndGet, and conditionally call updateBoostCache() and saveGame(updatedState) only when state references differ.
Special effects and star action persistence
app/src/main/java/com/messark/hawker/MainViewModel.kt
chooseBudgetBonus, restoreHealth, chooseFreeUpgrade, applyOutdoorPuddles, removePillar, and applyUpgrade each use prevState + updateAndGet to compute mutations, update dependent caches conditionally, and persist via saveGame(updatedState) when state changes.
Cheat application persistence
app/src/main/java/com/messark/hawker/MainViewModel.kt
applyCheat now persists the cheated GameState via saveGame() in both in-game and non-game contexts.
Test infrastructure update
app/src/test/java/com/messark/hawker/SpatialIndexIntegrationTest.kt
SpatialIndexIntegrationTest now explicitly sets currentScreen = AppScreen.GAME in game state setup, matching the engine loop's updated gating logic.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • candour/towerpower#174: PR #174's graduation/level-clear flow sets showGraduationOverlay and calls graduateToNextLevel(); this PR's expansion of nullable progression fields in PersistentGameState and persistence integration in graduateToNextLevel() directly support that flow.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 4.35% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'Fix towers not firing on resume and expand game persistence' directly and clearly describes the two main objectives: fixing the tower firing bug and expanding game state persistence.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix-resume-firing-issue-977308410548281756

Warning

Review ran into problems

🔥 Problems

Stopped waiting for pipeline failures after 30000ms. One of your pipelines takes longer than our 30000ms fetch window to run, so review may not consider pipeline-failure results for inline comments if any failures occurred after the fetch window. Increase the timeout if you want to wait longer or run a @coderabbit review after the pipeline has finished.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions

Copy link
Copy Markdown

Build Successful! 🚀

Download APK

Note: This link will be removed when the PR is closed.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 3


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 64664251-7e38-4342-856a-8ac4c0fe997b

📥 Commits

Reviewing files that changed from the base of the PR and between b62d081 and 1d02fbf.

📒 Files selected for processing (2)
  • app/src/main/java/com/messark/hawker/MainViewModel.kt
  • app/src/main/java/com/messark/hawker/utils/GameStateRepository.kt

Comment thread app/src/main/java/com/messark/hawker/MainViewModel.kt Outdated
Comment thread app/src/main/java/com/messark/hawker/MainViewModel.kt Outdated
Comment thread app/src/main/java/com/messark/hawker/utils/GameStateRepository.kt Outdated
- Resolved stall firing issue by persisting simulationTimeMs, ensuring combat timers stay synchronized on resume.
- Expanded PersistentGameState to include enemies, projectiles, puddles, and wave metadata for full session resumption.
- Offloaded all game saving operations to background threads (Dispatchers.IO) to prevent UI jank.
- Added auto-save triggers for all board actions, upgrades, and menu navigation.
- Maintained backward compatibility with null-safe deserialization for legacy save files.

Co-authored-by: candour <4670475+candour@users.noreply.github.com>
@github-actions

Copy link
Copy Markdown

Build Successful! 🚀

Download APK

Note: This link will be removed when the PR is closed.

@candour
candour enabled auto-merge (squash) June 17, 2026 03:54
@candour

candour commented Jun 17, 2026

Copy link
Copy Markdown
Owner Author

@CodeRabbit review

@coderabbitai

coderabbitai Bot commented Jun 17, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/src/main/java/com/messark/hawker/MainViewModel.kt (1)

103-107: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Pause simulation when leaving the game screen.

navigateTo(MAIN_MENU) saves the state, but updateGame() continues running because it only checks activeTutorial. During an active wave, enemies can keep progressing in the main menu; if health reaches zero, handleGameOver() deletes the saved game the player expects to resume.

🐛 Proposed fix
         _gameState.update { state ->
-            if (state.activeTutorial != null) return@update state
+            if (state.currentScreen != AppScreen.GAME || state.activeTutorial != null) return@update state

Also applies to: 420-422

🧹 Nitpick comments (1)
app/src/main/java/com/messark/hawker/MainViewModel.kt (1)

1265-1266: ⚡ Quick win

Remove the duplicate save call.

sellStall() schedules two identical saves for the same newState, doubling IO work and amplifying save-order races.

🧹 Proposed cleanup
             updateBoostCache(newState)
             saveGame(newState)
-            saveGame(newState)
             newState

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 9e46690e-3f72-4e14-b681-884f40e28f44

📥 Commits

Reviewing files that changed from the base of the PR and between 1d02fbf and 78a5920.

📒 Files selected for processing (1)
  • app/src/main/java/com/messark/hawker/MainViewModel.kt

Comment thread app/src/main/java/com/messark/hawker/MainViewModel.kt Outdated
- Replaced synchronous saveGameState call in applyCheat with the asynchronous saveGame wrapper.
- This ensures that applying cheats doesn't cause UI hitches and maintains consistency with the rest of the saving logic.

Co-authored-by: candour <4670475+candour@users.noreply.github.com>
@github-actions

Copy link
Copy Markdown

Build Successful! 🚀

Download APK

Note: This link will be removed when the PR is closed.

- Resolved the stall firing bug by persisting 'simulationTimeMs' in the game state.
- Expanded 'PersistentGameState' and 'loadGameState' to fully preserve active waves, including enemies, projectiles, puddles, and visual effects.
- Refactored 'MainViewModel' to perform all game saving operations asynchronously on 'Dispatchers.IO' to eliminate UI jank during auto-saves.
- Added a guard in 'updateGame' to effectively pause the simulation loop whenever the player navigates away from the Game screen, preventing background Game Over events.
- Implemented null-safe loading in 'GameStateRepository' to maintain backward compatibility with legacy save files.

Co-authored-by: candour <4670475+candour@users.noreply.github.com>
@github-actions

Copy link
Copy Markdown

Build Successful! 🚀

Download APK

Note: This link will be removed when the PR is closed.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

♻️ Duplicate comments (2)
app/src/main/java/com/messark/hawker/MainViewModel.kt (2)

209-213: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Serialize save writes through one path.

saveGame() launches an independent IO write for every mutation, while GameStateRepository.saveGameState() writes the same file directly. Rapid actions can let an older snapshot finish after a newer one and overwrite progress. Route saves through a Mutex/single-writer queue, and have every caller use that path. This is the same persistence race previously discussed, now centralized in saveGame().

🛠️ Suggested direction
+    private val saveMutex = Mutex()
+
     fun saveGame(state: GameState = _gameState.value) {
         viewModelScope.launch(Dispatchers.IO) {
-            gameStateRepository.saveGameState(state)
+            saveMutex.withLock {
+                gameStateRepository.saveGameState(state)
+            }
         }
     }
 import kotlinx.coroutines.*
 import kotlinx.coroutines.flow.*
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock

182-201: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Keep _gameState.update transforms side-effect free.

These transforms still call saveGame(newState). MutableStateFlow.update can re-run its lambda under contention, so this can enqueue duplicate saves or save a candidate state that was not committed. Use updateAndGet/post-update saving for mutations that actually changed state.

#!/bin/bash
# Description: Find save calls still executed from inside MutableStateFlow.update transforms.
# Expectation after the fix: no saveGame(...) call appears before the matching end of an _gameState.update lambda.
rg -n -C 4 '_gameState\.update\s*\{|saveGame\(' app/src/main/java/com/messark/hawker/MainViewModel.kt
🛠️ Suggested pattern
-        _gameState.update {
+        val committedState = _gameState.updateAndGet {
             val newState = it.copy(
                 // mutation fields...
             )
-            saveGame(newState)
             newState
         }
+        saveGame(committedState)

Also applies to: 291-304, 420-545, 1065-1099, 1192-1215, 1236-1266, 1271-1295, 1338-1346, 1352-1360, 1366-1374, 1429-1444, 1454-1473, 1482-1512, 1529-1532


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 248e3739-ddaf-4ea3-a076-ac7a05e5671c

📥 Commits

Reviewing files that changed from the base of the PR and between 78a5920 and 0902f85.

📒 Files selected for processing (2)
  • app/src/main/java/com/messark/hawker/MainViewModel.kt
  • app/src/main/java/com/messark/hawker/utils/GameStateRepository.kt
🚧 Files skipped from review as they are similar to previous changes (1)
  • app/src/main/java/com/messark/hawker/utils/GameStateRepository.kt

… pause

- Fixed stall firing bug by persisting 'simulationTimeMs' in the game state.
- Implemented a serialized save queue using a 'ConflatedChannel' to ensure background I/O operations are processed in order and don't block the UI thread.
- Added a guard in 'updateGame' to effectively pause the simulation loop whenever the player is not on the 'GAME' screen, preventing background progress or Game Over events.
- Expanded 'PersistentGameState' and 'loadGameState' to fully preserve active waves, including enemies, projectiles, puddles, visual effects, and Kitchelin Star bonuses.
- Ensured backward compatibility with null-safe deserialization for older save files.

Co-authored-by: candour <4670475+candour@users.noreply.github.com>
@github-actions

Copy link
Copy Markdown

Build Successful! 🚀

Download APK

Note: This link will be removed when the PR is closed.

… pause

- Fixed stall firing bug by persisting 'simulationTimeMs' in the game state, ensuring combat timers remain synchronized on resume.
- Implemented a serialized save queue using a 'ConflatedChannel' to ensure background I/O operations are processed in order and don't block the UI thread.
- Refactored state updates to be side-effect free by moving 'saveGame' and cache updates outside of '_gameState.update' blocks.
- Added a guard in 'updateGame' to effectively pause the simulation loop whenever the player is not on the 'GAME' screen, preventing background progress or unintended Game Over events.
- Expanded 'PersistentGameState' and 'loadGameState' to fully preserve active waves, including enemies, projectiles, puddles, visual effects, and Kitchelin Star bonuses.
- Ensured backward compatibility with null-safe deserialization for older save files.

Co-authored-by: candour <4670475+candour@users.noreply.github.com>
@github-actions

Copy link
Copy Markdown

Build Successful! 🚀

Download APK

Note: This link will be removed when the PR is closed.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/src/main/java/com/messark/hawker/MainViewModel.kt (1)

557-571: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Side effects inside updateAndGet may execute with uncommitted state.

updateBoostCache(newState) and the shouldSave = true mutation are inside the updateAndGet transform. Per Kotlin coroutines documentation, this transform may be evaluated multiple times if CAS fails. If attempt 1 triggers wave completion and calls updateBoostCache(state_v1), but CAS fails and attempt 2 doesn't trigger completion, stallBoosts reflects state_v1 while the committed state is state_v2.

🛠️ Suggested pattern

Track wave completion detection externally and apply side effects after the atomic update:

+var waveWasActive = false
 var shouldSave = false
 val finalUpdatedState = _gameState.updateAndGet { state ->
     if (state.currentScreen != AppScreen.GAME || state.activeTutorial != null) return@updateAndGet state
+    waveWasActive = state.waveActive && state.enemiesToSpawn == 0 && state.enemies.isEmpty()
     // ... existing transform logic ...
     
     if (newState.waveActive && newState.enemiesToSpawn == 0 && newState.enemies.isEmpty()) {
         // ... wave completion logic ...
-        updateBoostCache(newState)
-        shouldSave = true
     }
     newState
 }

+// Check if wave just completed by comparing before/after
+if (!finalUpdatedState.waveActive && waveWasActive) {
+    updateBoostCache(finalUpdatedState)
+    saveGame(finalUpdatedState)
+}

Alternatively, detect wave completion by comparing committed state against known invariants outside the transform.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: d9f7b5d1-2732-4de3-bbb4-37c239152645

📥 Commits

Reviewing files that changed from the base of the PR and between 0902f85 and c67934b.

📒 Files selected for processing (3)
  • app/src/main/java/com/messark/hawker/MainViewModel.kt
  • app/src/main/java/com/messark/hawker/utils/GameStateRepository.kt
  • app/src/test/java/com/messark/hawker/SpatialIndexIntegrationTest.kt
🚧 Files skipped from review as they are similar to previous changes (1)
  • app/src/main/java/com/messark/hawker/utils/GameStateRepository.kt

Comment thread app/src/main/java/com/messark/hawker/MainViewModel.kt
@candour
candour merged commit 5a19431 into main Jun 17, 2026
3 checks passed
@candour
candour deleted the fix-resume-firing-issue-977308410548281756 branch June 17, 2026 09:19
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.

1 participant