Debug scatter command, and put pawn movement on the game clock #96

Merged
liamjd merged 1 commit from 95-debug-scatter-command into main 2026-09-13 19:02:39 +01:00
Owner

Closes #95

Adds the X debug command: every idle pawn walks to a random reachable tile. This is milestone #4's proof that navigation works in the real game, replacing #81's right-click move order — scaffolding for a mechanic the design docs rule out, which would have needed selection, input arbitration and unreachable feedback before a pawn took a step.

It proves more of the stack than #80's probe could. That probe drives PawnView by hand at a fixed delta on a stamped grid; this exercises Pawns.move_to(), the real frame loop, generated terrain and several pawns at once.

The bug it immediately exposed

PawnView walked on the raw frame delta. Opening the main menu paused GameClock and the pawns carried on walking — and GameClock.speed was ignored by everything in the project, so a speed control would have left pawns at 1×. Same root cause, so it is fixed once.

GameClock.delta_for(real_delta) now owns the conversion from real to simulated time, and PawnView asks it rather than the engine. Its doc comment states the rule for future callers: nothing simulating should read the engine's delta directly.

Why not get_tree().paused. Only four things consume delta, and the split is not "paused vs not" but simulation vs presentation: the clock must always run, the camera should keep working while paused so the player can look around, and only PawnView is simulation. Tree pause would freeze the camera unless nearly everything were annotated as an exception.

Why not the tick signal. Pause and speed would come free, but at 10 ticks/sec the motion is visibly stepped. Movement is the presentation of a simulated quantity, so it wants smooth interpolation driven by simulated time.

delta_for() mirrors advance()'s guards, so a negative delta or a negative speed returns zero rather than walking a pawn backwards along its path.

Also fixed: GameClock.reset()

var now: int = 0:
	get: return _now
...
func reset() -> void:
	_accumulator = 0.0
	now = 0        # writes a field nothing reads

now has a getter returning _now and no setter, so reset() wrote the backing field while _now kept its value — the clock did not reset. Nothing calls it yet, which is why it had not bitten; it would have failed on the first map regeneration or second colony.

A test that was passing for the wrong reason

Raising the base pawn speed to 2.0 broke test_the_record_follows_the_view_across_the_map, which hard-coded _process(2.0) on an unstated assumption of 1 tile/second. At 2.0 the pawn completed the whole 4-tile path inside that one call, so "mid-path" was no longer true.

The defect was the test's, not the speed change: its intent is "part-way along a walk, the record tracks the capsule", which has nothing to do with the default speed. A helper now expresses the delta in tiles, so it means the same thing at any speed.

test_stopping_a_pawn_idles_it_and_drops_the_path had the same brittleness and is fixed alongside. It still passed at 2.0, but at 4.0 the pawn would have arrived before stop() was called and the test would have gone green for the wrong reason — silent, and worse than the failure that was actually hit.

Verification

343 tests, 342 passing, 1 pre-existing pending (test_task_tree, tracked as #85). Up 11 tests and 19 asserts.

  • delta_for() — passthrough at 1×, scaling at 3×, zero while paused, zero at 0×, zero for a negative delta or speed, and that it is a pure query: 100 calls emit no ticks and do not move now. That last one matters, because it is called every frame by every mover and an implementation that banked into the accumulator would make the tick rate depend on how many pawns exist.
  • The scatter — idle pawns end up MOVING with a path; a pawn already walking keeps the exact path it had; both from the same call, so an implementation that scattered everyone fails even though it passes the first test; a mapless registry is a quiet no-op; an empty registry is safe.

The destination is deliberately unasserted — it is random by design, and random_walkable_position() draws from the main landmass so anything it returns is routable.

Both pawn test scripts now pin GameClock.paused in before_each. It is global mutable state on an autoload, and one script leaving it set would break unrelated tests in ways that look like movement bugs.

Left undone

The find_path()-returns-empty branch of move_to() is still unit-test-only. random_walkable_position() only returns tiles on the main landmass, so scatter cannot produce an unreachable destination. Forcing it in-game needs a separate island or a target beyond destination_snap_radius, and neither justifies a bespoke debug affordance. Worth revisiting with #83, where a placed building cutting an in-flight path makes blocked routes ordinary.

#92 (smoothing the vertical step between terraces) is untouched and still open.

🤖 Generated with Claude Code

https://claude.ai/code/session_012V5CmAgcjna6fBrgZebqjZ

Closes #95 Adds the `X` debug command: every idle pawn walks to a random reachable tile. This is milestone #4's proof that navigation works in the real game, replacing #81's right-click move order — scaffolding for a mechanic the design docs rule out, which would have needed selection, input arbitration and unreachable feedback before a pawn took a step. It proves more of the stack than #80's probe could. That probe drives `PawnView` by hand at a fixed delta on a stamped grid; this exercises `Pawns.move_to()`, the real frame loop, generated terrain and several pawns at once. ## The bug it immediately exposed `PawnView` walked on the raw frame delta. Opening the main menu paused `GameClock` and the pawns carried on walking — and `GameClock.speed` was ignored by everything in the project, so a speed control would have left pawns at 1×. Same root cause, so it is fixed once. `GameClock.delta_for(real_delta)` now owns the conversion from real to simulated time, and `PawnView` asks it rather than the engine. Its doc comment states the rule for future callers: nothing simulating should read the engine's delta directly. **Why not `get_tree().paused`.** Only four things consume delta, and the split is not "paused vs not" but simulation vs presentation: the clock must always run, the camera *should* keep working while paused so the player can look around, and only `PawnView` is simulation. Tree pause would freeze the camera unless nearly everything were annotated as an exception. **Why not the `tick` signal.** Pause and speed would come free, but at 10 ticks/sec the motion is visibly stepped. Movement is the presentation of a simulated quantity, so it wants smooth interpolation driven by simulated time. `delta_for()` mirrors `advance()`'s guards, so a negative delta or a negative speed returns zero rather than walking a pawn backwards along its path. ## Also fixed: `GameClock.reset()` ```gdscript var now: int = 0: get: return _now ... func reset() -> void: _accumulator = 0.0 now = 0 # writes a field nothing reads ``` `now` has a getter returning `_now` and no setter, so `reset()` wrote the backing field while `_now` kept its value — the clock did not reset. Nothing calls it yet, which is why it had not bitten; it would have failed on the first map regeneration or second colony. ## A test that was passing for the wrong reason Raising the base pawn speed to 2.0 broke `test_the_record_follows_the_view_across_the_map`, which hard-coded `_process(2.0)` on an unstated assumption of 1 tile/second. At 2.0 the pawn completed the whole 4-tile path inside that one call, so "mid-path" was no longer true. The defect was the test's, not the speed change: its intent is "part-way along a walk, the record tracks the capsule", which has nothing to do with the default speed. A helper now expresses the delta in **tiles**, so it means the same thing at any speed. `test_stopping_a_pawn_idles_it_and_drops_the_path` had the same brittleness and is fixed alongside. It still passed at 2.0, but at 4.0 the pawn would have arrived *before* `stop()` was called and the test would have gone green for the wrong reason — silent, and worse than the failure that was actually hit. ## Verification **343 tests, 342 passing**, 1 pre-existing pending (`test_task_tree`, tracked as #85). Up 11 tests and 19 asserts. - **`delta_for()`** — passthrough at 1×, scaling at 3×, zero while paused, zero at 0×, zero for a negative delta or speed, and that it is a pure query: 100 calls emit no ticks and do not move `now`. That last one matters, because it is called every frame by every mover and an implementation that banked into the accumulator would make the tick rate depend on how many pawns exist. - **The scatter** — idle pawns end up `MOVING` with a path; a pawn already walking keeps the exact path it had; both from the same call, so an implementation that scattered everyone fails even though it passes the first test; a mapless registry is a quiet no-op; an empty registry is safe. The destination is deliberately unasserted — it is random by design, and `random_walkable_position()` draws from the main landmass so anything it returns is routable. Both pawn test scripts now pin `GameClock.paused` in `before_each`. It is global mutable state on an autoload, and one script leaving it set would break unrelated tests in ways that look like movement bugs. ## Left undone **The `find_path()`-returns-empty branch of `move_to()` is still unit-test-only.** `random_walkable_position()` only returns tiles on the main landmass, so scatter cannot produce an unreachable destination. Forcing it in-game needs a separate island or a target beyond `destination_snap_radius`, and neither justifies a bespoke debug affordance. Worth revisiting with #83, where a placed building cutting an in-flight path makes blocked routes ordinary. #92 (smoothing the vertical step between terraces) is untouched and still open. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_012V5CmAgcjna6fBrgZebqjZ
Adds the X debug command from #95: every idle pawn walks to a random
reachable tile. It replaces #81's right-click move order, which was
scaffolding for a mechanic the game will never ship, and proves more of
the stack than #80's probe could - Pawns.move_to(), the real frame loop,
generated terrain and several pawns at once, rather than PawnView driven
by hand on a stamped grid.

Fixes a bug it immediately exposed: PawnView walked on the raw frame
delta, so pawns kept moving while the main menu had the clock paused, and
GameClock.speed was ignored entirely by everything. GameClock.delta_for()
now owns that conversion and PawnView asks it rather than the engine.
delta_for() mirrors advance()'s guards, so a negative delta or speed
cannot walk a pawn backwards along its path.

Also fixes GameClock.reset(), which assigned to `now` - a property whose
getter returns _now and which has no setter - so it wrote a field nothing
reads and left the clock where it was. Nothing calls reset() yet; it
would have failed on the first map regeneration.

The base pawn speed moves to 2.0, which broke a test that had hard-coded
a delta of 2.0 seconds assuming 1 tile per second. The fix is a test
helper expressing the delta in tiles, so the intent - "part-way along the
path" - no longer silently depends on the production default. The same
brittleness in the stop() test is fixed alongside it, where it would have
started passing for the wrong reason rather than failing.

Both pawn test scripts pin GameClock.paused in before_each, since it is
global mutable state that would otherwise leak between scripts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012V5CmAgcjna6fBrgZebqjZ
liamjd merged commit 7611047c9a into main 2026-09-13 19:02:39 +01:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
liamjd/UntitledColonyBuilder!96
No description provided.