Skip to content

Refactor MapGenerator for Efficiency and Determinism - #131

Merged
candour merged 2 commits into
mainfrom
refactor-map-generator-deterministic-3761664495891248248
May 8, 2026
Merged

Refactor MapGenerator for Efficiency and Determinism#131
candour merged 2 commits into
mainfrom
refactor-map-generator-deterministic-3761664495891248248

Conversation

@candour

@candour candour commented May 7, 2026

Copy link
Copy Markdown
Owner

I have refactored the map generation logic in MapGenerator.kt to be significantly more efficient and deterministic. The previous implementation relied on a while(true) loop that randomly placed pillars and hoped for a valid path, which could lead to performance issues or non-deterministic behavior.

The new implementation first selects the start and end positions and uses the existing A* pathfinding to carve a guaranteed valid path through an empty grid. It then fills the remaining tiles, only placing obstacles (pillars) on coordinates that are NOT part of the guaranteed path. This ensures a valid, playable map in $O(\text{Area})$ time.

Additionally, I've centralized the coordinate conversion logic to reduce duplication and added Random injection support throughout the utility to support better unit testing and game state determinism. I've also updated MainViewModel.kt to pass its own random instance to the generator.

All unit tests pass, and the project compiles successfully.


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

Summary by CodeRabbit

  • Bug Fixes

    • Map generation now always produces a valid traversable path and avoids prior infinite/retry behavior, improving level reliability and load performance.
  • Refactor

    • Randomness is now centralized for map creation and enemy ordering, yielding more consistent and reproducible game states (including on reset).
  • Documentation

    • Added an entry describing the map generator rewrite and determinism improvements.

- Transitions `generateRandomVerticalMap` from a brute-force retry loop to a single-pass "path-first" carving strategy.
- Guarantees a valid path from START to GOAL_TABLE by pre-calculating it on an empty grid.
- Centralizes offset-to-axial coordinate conversion in a private `offsetToAxial` helper.
- Adds `Random` injection to all generation methods for improved determinism and testability.
- Updates `MainViewModel` to pass its internal `random` instance to the generator.
- Documents the changes in `fixes.md`.

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 May 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

MapGenerator now accepts an injected Random and computes a guaranteed path first, then generates tiles while avoiding pillars on that path; generateMap uses the injected Random for floor variants. MainViewModel threads its random into map generation and uses it for enemy selection and shuffling.

Changes

Random Injection and Deterministic Map Generation

Layer / File(s) Summary
Utility Functions and Helpers
app/src/main/java/com/messark/hawker/utils/MapGenerator.kt
Adds offsetToAxial helper and changes getWeightedFloorVariant to accept Random with adjusted non-zero variant selection.
Deterministic Path-First Algorithm
app/src/main/java/com/messark/hawker/utils/MapGenerator.kt
Rewrites generateRandomVerticalMap to compute a baseline path via Pathfinding.findPath on an empty grid, avoid pillar placement on that path, and drive randomness from an injected Random instead of a retry loop.
Map Generation with Injected Random
app/src/main/java/com/messark/hawker/utils/MapGenerator.kt
Updates generateMap(mapData, random) to use offsetToAxial and call getWeightedFloorVariant(random) for FLOOR tiles.
Enemy Wave RNG & Shuffling
app/src/main/java/com/messark/hawker/MainViewModel.kt
MainViewModel now uses its random for enemy type selection (random.nextInt(...)) and for shuffling produced enemy lists in early and later wave generation.
ViewModel Map Calls
app/src/main/java/com/messark/hawker/MainViewModel.kt
initializeGame() and resetGame() now pass MainViewModel.random into MapGenerator.generateRandomVerticalMap(...).
Change Documentation
fixes.md
Adds REF-002 documenting the MapGenerator rewrite: removal of while(true) brute-force loop, addition of offsetToAxial, and Random injection for testability.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • candour/towerpower#125: Modifies MapGenerator and MainViewModel; related due to overlapping MapGenerator and Pathfinding adjustments.
  • candour/towerpower#120: Refactors Pathfinding.findPath internals which this PR calls to compute guaranteed paths.
  • candour/towerpower#115: Changes MainViewModel to use an injected/seeden Random for deterministic behavior, related to RNG threading updates in this PR.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% 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 title 'Refactor MapGenerator for Efficiency and Determinism' accurately summarizes the main change: rewriting MapGenerator to eliminate the inefficient while(true) loop and introduce deterministic Random injection.
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 refactor-map-generator-deterministic-3761664495891248248

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

github-actions Bot commented May 7, 2026

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

🧹 Nitpick comments (3)
app/src/main/java/com/messark/hawker/utils/MapGenerator.kt (2)

38-38: ⚡ Quick win

Silent null fallback could mask a degenerate case.

Pathfinding.findPath(...)?.toSet() ?: emptySet() quietly falls back to "no guaranteed path", at which point the coord !in guaranteedPath check no longer protects anything and pillars can block the map. On an empty grid with valid endpoints this should never happen, but it is a load-bearing invariant of the new algorithm — worth either asserting or logging a warning so a future regression in Pathfinding does not silently re-introduce unsolvable maps.

🛡️ Suggested defensive guard
-        // Guaranteed path carving: use A* on an empty grid to find a baseline path
-        val guaranteedPath = Pathfinding.findPath(startPos, endPos, emptySet(), allCoords)?.toSet() ?: emptySet()
+        // Guaranteed path carving: use A* on an empty grid to find a baseline path.
+        // On an empty grid with both endpoints in `allCoords`, A* must succeed.
+        val guaranteedPath = Pathfinding.findPath(startPos, endPos, emptySet(), allCoords)?.toSet()
+            ?: error("Pathfinding failed on an empty grid; check Pathfinding/offsetToAxial invariants")

54-56: ⚡ Quick win

Floor-variant assignment is inconsistent with generateMap.

Here, every non-PILLAR tile (including START and GOAL_TABLE) gets a weighted floor variant via getWeightedFloorVariant(random). In generateMap at Line 87, only TileType.FLOOR tiles receive a variant; START/GOAL_TABLE get 0. Two callers producing the same tile type with different floorVariant values will likely confuse downstream rendering or save/load logic. Pick one rule and apply it in both.

♻️ Align with `generateMap` (FLOOR-only variants)
-            val floorVariant = if (type != TileType.PILLAR) {
-                getWeightedFloorVariant(random)
-            } else 0
+            val floorVariant = if (type == TileType.FLOOR) {
+                getWeightedFloorVariant(random)
+            } else 0
app/src/main/java/com/messark/hawker/MainViewModel.kt (1)

243-260: ⚡ Quick win

Static Random.Default here defeats the determinism the PR is establishing.

Adjacent to your changes: generateEnemyList still calls kotlin.random.Random.nextInt(allowedTiers.size) (Line 245), bypassing the injected random field. Seeding the view-model produces a deterministic map but a non-deterministic enemy roster, which makes deterministic replay/testing only half work. Trivial to fix while the file is open.

♻️ Use the injected `random` field
-            val type = allowedTiers[kotlin.random.Random.nextInt(allowedTiers.size)]
+            val type = allowedTiers[random.nextInt(allowedTiers.size)]
@@
-        return enemyList.shuffled()
+        return enemyList.shuffled(random)

(Also worth considering for the .shuffled() calls at Lines 211 and 266 if you want full reproducibility of the wave roster.)


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: af64eaf9-2fc3-461d-a61d-fdc218b70d3b

📥 Commits

Reviewing files that changed from the base of the PR and between 0c5be2a and 2ba8318.

📒 Files selected for processing (3)
  • app/src/main/java/com/messark/hawker/MainViewModel.kt
  • app/src/main/java/com/messark/hawker/utils/MapGenerator.kt
  • fixes.md

Comment thread app/src/main/java/com/messark/hawker/utils/MapGenerator.kt
@candour
candour enabled auto-merge (squash) May 8, 2026 00:09
@candour

candour commented May 8, 2026

Copy link
Copy Markdown
Owner Author

In MapGenerator.kt:
Update comment: else branch produces values 1–6, which map to floor02–floor07, not floor01–floor07.

The comment is indeed out of sync. 1 + random.nextInt(6) returns values 1–6, but those index into FLOOR_RECTS at positions 1–6, yielding sprites floor02 through floor07. The comment "floor01 to floor07" is misleading because floor01 (at index 0) is only reachable from the if branch.

The suggested fix in the original review is also incorrect: the comment should say "floor02 to floor07", not "floor01 to floor06". The value 6 maps to FLOOR_RECTS[6], which is floor07, and floor07 exists in the asset set and remains reachable.

Pathfinding.findPath(...)?.toSet() ?: emptySet() quietly falls back to "no guaranteed path", at which point the coord !in guaranteedPath check no longer protects anything and pillars can block the map. On an empty grid with valid endpoints this should never happen, but it is a load-bearing invariant of the new algorithm — worth either asserting or logging a warning so a future regression in Pathfinding does not silently re-introduce unsolvable maps.

In MainViewModel.kt:
Static Random.Default here defeats the determinism the PR is establishing.

Adjacent to your changes: generateEnemyList still calls kotlin.random.Random.nextInt(allowedTiers.size) (Line 245), bypassing the injected random field. Seeding the view-model produces a deterministic map but a non-deterministic enemy roster, which makes deterministic replay/testing only half work. Trivial to fix while the file is open.
Also worth considering for the .shuffled() calls at Lines 211 and 266 if you want full reproducibility of the wave roster

@google-labs-jules

Copy link
Copy Markdown
Contributor

In MapGenerator.kt:
Update comment: else branch produces values 1–6, which map to floor02–floor07, not floor01–floor07.

The comment is indeed out of sync. 1 + random.nextInt(6) returns values 1–6, but those index into FLOOR_RECTS at positions 1–6, yielding sprites floor02 through floor07. The comment "floor01 to floor07" is misleading because floor01 (at index 0) is only reachable from the if branch.

The suggested fix in the original review is also incorrect: the comment should say "floor02 to floor07", not "floor01 to floor06". The value 6 maps to FLOOR_RECTS[6], which is floor07, and floor07 exists in the asset set and remains reachable.

Pathfinding.findPath(...)?.toSet() ?: emptySet() quietly falls back to "no guaranteed path", at which point the coord !in guaranteedPath check no longer protects anything and pillars can block the map. On an empty grid with valid endpoints this should never happen, but it is a load-bearing invariant of the new algorithm — worth either asserting or logging a warning so a future regression in Pathfinding does not silently re-introduce unsolvable maps.

In MainViewModel.kt:
Static Random.Default here defeats the determinism the PR is establishing.

Adjacent to your changes: generateEnemyList still calls kotlin.random.Random.nextInt(allowedTiers.size) (Line 245), bypassing the injected random field. Seeding the view-model produces a deterministic map but a non-deterministic enemy roster, which makes deterministic replay/testing only half work. Trivial to fix while the file is open.
Also worth considering for the .shuffled() calls at Lines 211 and 266 if you want full reproducibility of the wave roster

Good catch. I've updated the comment in getWeightedFloorVariant to correctly reflect that the branch produces variants 'floor02' through 'floor07' (indices 1–6). I've also added an explicit log warning in generateRandomVerticalMap if guaranteedPath is empty, which protects the core invariant of the new algorithm.

- Fixed misleading comment in `MapGenerator.getWeightedFloorVariant` regarding floor variants.
- Added log warning in `MapGenerator.generateRandomVerticalMap` for `guaranteedPath` invariant protection.
- Removed `Random.Default` fallback in `MainViewModel` generator calls.
- Updated `generateEnemyList` and `.shuffled()` logic in `MainViewModel` to use the injected `random` instance, ensuring full game state determinism.
- Updated `fixes.md` with REF-002 entry.

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

github-actions Bot commented May 8, 2026

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.

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

38-43: 💤 Low value

Guaranteed-path invariant correctly guarded.

On an empty grid the A* call should always succeed; the Log.e covers the one real regression scenario (a bug in Pathfinding.findPath). If the log is ever triggered in practice it signals a pathfinding regression, not a map-gen issue, and the map will silently be potentially unsolvable.

One low-cost hardening option: retain the empty-set fallback but also re-randomise the endpoints and retry once before giving up.

🛡️ Optional retry sketch
-        if (guaranteedPath.isEmpty()) {
-            android.util.Log.e("MapGenerator", "Failed to carve a guaranteed path from $startPos to $endPos")
-        }
+        if (guaranteedPath.isEmpty()) {
+            android.util.Log.e("MapGenerator", "Failed to carve a guaranteed path from $startPos to $endPos – retrying with fallback endpoints")
+            // Deterministic fallback: pin start/end to column 0
+            val fallbackStart = offsetToAxial(0, startR)
+            val fallbackEnd  = offsetToAxial(0, endR)
+            return generateRandomVerticalMap(width, height, random)
+        }

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: d83c7a8d-a683-4a83-824c-4b1b3263ca1a

📥 Commits

Reviewing files that changed from the base of the PR and between 2ba8318 and 0d22193.

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

@candour
candour merged commit 2ccd3d0 into main May 8, 2026
3 checks passed
@candour
candour deleted the refactor-map-generator-deterministic-3761664495891248248 branch May 8, 2026 00:30
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