diff --git a/.project/objectives/DASHBOARD_CATEGORIES.md b/.project/objectives/DASHBOARD_CATEGORIES.md
index d516bfdd..6b41c1e4 100644
--- a/.project/objectives/DASHBOARD_CATEGORIES.md
+++ b/.project/objectives/DASHBOARD_CATEGORIES.md
@@ -125,6 +125,14 @@
| [p2-10](p2-10-regression-ci-gate.md) | π‘ partial | P2 | Automated regression CI gate on every push to main | [testwright](../team-leads/testwright.md) | π’ |
| [p2-10a](p2-10a-gdlint-ungate.md) | β
done | P2 | CI: gdlint stage un-gated | [testwright](../team-leads/testwright.md) | π’ |
| [p2-10b](p2-10b-gut-ungate.md) | π‘ partial | P2 | CI: headless GUT stage un-gated | [testwright](../team-leads/testwright.md) | π’ |
+| [p2-10c](p2-10c-diplomacy-luxury-ids.md) | π΄ stub | P2 | Diplomacy: implement _collect_unique_luxury_ids() in happiness.gd | β | π’ |
+| [p2-10d](p2-10d-legacy-unit-json.md) | π΄ stub | P2 | Data: strip legacy flags/can_found_city/can_build_improvements from unit JSON | β | π’ |
+| [p2-10e](p2-10e-data-integrity.md) | π΄ stub | P2 | Data: resolve duplicate IDs and dangling unlock refs in game data | β | π’ |
+| [p2-10f](p2-10f-save-manager-typed-arrays.md) | π΄ stub | P2 | SaveManager: fix typed array property assignment on Player/Unit deserialization | β | π’ |
+| [p2-10g](p2-10g-city-bridge-production-cost.md) | π΄ stub | P2 | CityBridge: add production_cost field to items JSON fixture | β | π’ |
+| [p2-10h](p2-10h-sprite-renderer-build-key.md) | π΄ stub | P2 | UnitRenderer: implement _build_sprite_key() helper and fix cache key test | β | π’ |
+| [p2-10i](p2-10i-tile-tooltip-scene.md) | π΄ stub | P2 | TileTooltip: fix scene node name mismatches and collectibles text formatting | β | π’ |
+| [p2-10j](p2-10j-fog-vision-scout-move.md) | π΄ stub | P2 | FogOfWar: fix recalculate_vision to not re-reveal already-seen tiles on move | β | π’ |
| [p2-11](p2-11-version-about-screen.md) | β
done | P2 | Version string + About screen | [shipwright](../team-leads/shipwright.md) | π’ |
| [p2-12](p2-12-apricot-weston-install.md) | β
done | P2 | Install weston on apricot RUN host β unblock display-server smoke tests | [shipwright](../team-leads/shipwright.md) | π’ |
| [p2-16](p2-16-audio-assets.md) | β missing | P1 | Audio assets β SFX + music .ogg files shipped | [asset-audio](../team-leads/asset-audio.md) | π’ |
diff --git a/.project/objectives/README.md b/.project/objectives/README.md
index d1946c67..c84c953b 100644
--- a/.project/objectives/README.md
+++ b/.project/objectives/README.md
@@ -16,9 +16,9 @@
|---|---|---|---|---|---|---|---|
| **P0** | 0 | 2 | 0 | 0 | 0 | 41 | 43 |
| **P1** | 0 | 4 | 0 | 8 | 1 | 25 | 38 |
-| **P2** | 0 | 3 | 0 | 0 | 0 | 19 | 22 |
+| **P2** | 0 | 3 | 8 | 0 | 0 | 19 | 30 |
| **P3 (oos)** | 0 | 0 | 0 | 1 | 17 | 0 | 18 |
-| **total** | **0** | **9** | **0** | **9** | **18** | **85** | **121** |
+| **total** | **0** | **9** | **8** | **9** | **18** | **85** | **129** |
@@ -51,7 +51,7 @@
| [p1-22](p1-22-mcts-wall-clock-budget.md) | π‘ partial | MCTS per-decision wall-clock budget β bound per-turn cost on huge maps | β | [warcouncil](../team-leads/warcouncil.md) | 2026-04-25 | π’ unblocked |
| [p2-22](p2-22-sprite-generation-pipeline.md) | π‘ partial | Sprite generation pipeline β runnable end-to-end | β | [asset-sprite](../team-leads/asset-sprite.md) | 2026-04-25 | π’ unblocked |
| [p1-26](p1-26-tile-placement-preview-ux.md) | β missing | Tile-placement UX with effect preview β Civ7-style "where does this go and what changes" | β | [shipwright](../team-leads/shipwright.md) | 2026-04-25 | π’ unblocked |
-| [p1-27](p1-27-mcts-service-extraction.md) | β missing | Extract GPU MCTS into a standalone service/client (model-boss-shaped, magic-civ-only) | β | [warcouncil](../team-leads/warcouncil.md) | 2026-04-26 | π’ unblocked |
+| [p1-27](p1-27-mcts-service-extraction.md) | β missing | Extract GPU MCTS into a standalone service/client (model-boss-shaped, magic-civ-only) | β | [warcouncil](../team-leads/warcouncil.md) | 2026-04-25 | π’ unblocked |
| [p2-16](p2-16-audio-assets.md) | β missing | Audio assets β SFX + music .ogg files shipped | β | [asset-audio](../team-leads/asset-audio.md) | 2026-04-17 | π’ unblocked |
| [p2-23](p2-23-unit-sprites-dwarf-roster.md) | β missing | Unit sprites β Dwarf-racial roster (m/f variants) | β | [asset-sprite](../team-leads/asset-sprite.md) | 2026-04-17 | π’ unblocked |
| [p2-24](p2-24-unit-sprites-wild-creatures.md) | β missing | Unit sprites β wild creatures & fauna (generic, no race/sex) | β | [asset-sprite](../team-leads/asset-sprite.md) | 2026-04-17 | π’ unblocked |
@@ -66,6 +66,14 @@
| [p2-10](p2-10-regression-ci-gate.md) | π‘ partial | Automated regression CI gate on every push to main | β | [testwright](../team-leads/testwright.md) | 2026-04-23 | π’ unblocked |
| [p2-10b](p2-10b-gut-ungate.md) | π‘ partial | CI: headless GUT stage un-gated | β | [testwright](../team-leads/testwright.md) | 2026-04-25 | π’ unblocked |
| [p2-18](p2-18-guide-public-deployment.md) | π‘ partial | Guide web app β public hosting + deploy pipeline | β | β | 2026-04-17 | π’ unblocked |
+| [p2-10c](p2-10c-diplomacy-luxury-ids.md) | π΄ stub | Diplomacy: implement _collect_unique_luxury_ids() in happiness.gd | β | β | 2026-04-25 | π’ unblocked |
+| [p2-10d](p2-10d-legacy-unit-json.md) | π΄ stub | Data: strip legacy flags/can_found_city/can_build_improvements from unit JSON | β | β | 2026-04-25 | π’ unblocked |
+| [p2-10e](p2-10e-data-integrity.md) | π΄ stub | Data: resolve duplicate IDs and dangling unlock refs in game data | β | β | 2026-04-25 | π’ unblocked |
+| [p2-10f](p2-10f-save-manager-typed-arrays.md) | π΄ stub | SaveManager: fix typed array property assignment on Player/Unit deserialization | β | β | 2026-04-25 | π’ unblocked |
+| [p2-10g](p2-10g-city-bridge-production-cost.md) | π΄ stub | CityBridge: add production_cost field to items JSON fixture | β | β | 2026-04-25 | π’ unblocked |
+| [p2-10h](p2-10h-sprite-renderer-build-key.md) | π΄ stub | UnitRenderer: implement _build_sprite_key() helper and fix cache key test | β | β | 2026-04-25 | π’ unblocked |
+| [p2-10i](p2-10i-tile-tooltip-scene.md) | π΄ stub | TileTooltip: fix scene node name mismatches and collectibles text formatting | β | β | 2026-04-25 | π’ unblocked |
+| [p2-10j](p2-10j-fog-vision-scout-move.md) | π΄ stub | FogOfWar: fix recalculate_vision to not re-reveal already-seen tiles on move | β | β | 2026-04-25 | π’ unblocked |
## Out of Scope
@@ -98,5 +106,6 @@
| ID | Status | Title | Tags | Owner | Updated | Blocked |
|---|---|---|---|---|---|---|
+| [p1-27d](p1-27d-additive-value-estimate.md) | β»οΈ superseded | Add `value_estimate_abstract` GdMcTreeController method β non-lossy MCTS service caller | β | [warcouncil](../team-leads/warcouncil.md) | 2026-04-25 | π’ unblocked |
| [p2-17](p2-17-sprite-assets.md) | β»οΈ superseded | Sprite assets β superseded index (split into p2-22 β¦ p2-28) | β | [asset-sprite](../team-leads/asset-sprite.md) | 2026-04-17 | π’ unblocked |
diff --git a/.project/objectives/objectives.json b/.project/objectives/objectives.json
index 2285b0d0..1a89b4e7 100644
--- a/.project/objectives/objectives.json
+++ b/.project/objectives/objectives.json
@@ -1,13 +1,13 @@
{
- "generated_at": "2026-04-26T06:16:15Z",
+ "generated_at": "2026-04-26T06:55:33Z",
"totals": {
"done": 85,
"in_progress": 0,
"partial": 9,
- "stub": 0,
+ "stub": 8,
"missing": 9,
"oos": 18,
- "total": 121
+ "total": 129
},
"objectives": [
{
@@ -788,7 +788,7 @@
"status": "missing",
"scope": "game1",
"owner": "warcouncil",
- "updated_at": "2026-04-26",
+ "updated_at": "2026-04-25",
"blocked_by": [],
"summary": "Today the GPU MCTS path lives **inside** the `mc-ai` crate (`gpu/inner.rs`, `gpu/rollout.wgsl`, `gpu/cpu_reference.rs`) and runs in-process via the GDExtension (`GdMcTreeController`). That couples GPU lifecycle (device init, queue submission, buffer pooling, fence waits) to the game's per-turn decision call.\n\nPer user directive 2026-04-25: extract this into its own **MCTS service/client** that\n\n1. Lives **inside @magic-civilization** (not in @model-boss / not in any other repo) β it's game-specific.\n2. Lives **independently** of the in-process GDExtension β long-lived process the game talks to via IPC (Unix socket / TCP / shared memory).\n3. **Borrows patterns** from `@model-boss` (job submission, queue, batched dispatch, GPU lifecycle isolation) but doesn't take a dependency on it. Magic-civ's MCTS workload is narrow enough to warrant its own focused implementation.\n\nWhy a service vs in-process:\n- GPU init + warm-up amortized once per session, not per AI turn\n- Game can keep playing turns while a deep search is in flight (async)\n- Crash isolation β a wgpu/driver fault doesn't take the game down\n- One service can serve multiple game clients (autoplay-batch parallel runs hit one warm GPU instead of N cold inits)\n- Future: out-of-process service can run on a different host (apricot has GPU, dev mac doesn't)"
},
@@ -1022,6 +1022,94 @@
"blocked_by": [],
"summary": "The headless GUT stage in `.forgejo/workflows/ci.yml` (Stage 8) currently runs with `continue-on-error: true` due to 39 pre-existing test failures out of 439. This child objective tracks un-gating it so a GUT failure hard-fails the CI pipeline. Each failing test must be either fixed or explicitly quarantined with a skip annotation and a linked issue. Split off from p2-10 on 2026-04-25."
},
+ {
+ "id": "p2-10c",
+ "title": "Diplomacy: implement _collect_unique_luxury_ids() in happiness.gd",
+ "priority": "p2",
+ "status": "stub",
+ "scope": "game1",
+ "owner": "",
+ "updated_at": "2026-04-25",
+ "blocked_by": [],
+ "summary": "`happiness.gd` is expected to expose a static helper `_collect_unique_luxury_ids(player, game_map)` that collects traded + tile-based luxury resource IDs into a sorted deduplicated array. Four tests in `test_diplomacy.gd` exercise this contract. The function was never implemented."
+ },
+ {
+ "id": "p2-10d",
+ "title": "Data: strip legacy flags/can_found_city/can_build_improvements from unit JSON",
+ "priority": "p2",
+ "status": "stub",
+ "scope": "game1",
+ "owner": "",
+ "updated_at": "2026-04-25",
+ "blocked_by": [],
+ "summary": "All unit JSON files under `public/games/age-of-dwarves/data/units/` still contain legacy fields `flags`, `can_found_city`, and `can_build_improvements` that were superseded by the `keywords` array. `test_unit_actions.gd:test_no_unit_has_legacy_flags_field` fails with ~50+ assertion failures β one per unit per legacy field."
+ },
+ {
+ "id": "p2-10e",
+ "title": "Data: resolve duplicate IDs and dangling unlock refs in game data",
+ "priority": "p2",
+ "status": "stub",
+ "scope": "game1",
+ "owner": "",
+ "updated_at": "2026-04-25",
+ "blocked_by": [],
+ "summary": "`test_data_integrity.gd` surfaces two categories of failures:\n1. **Duplicate IDs** (20 violations): Buildings and techs appear in both `public/games/age-of-dwarves/data/` and `public/resources/`. DataLoader loads both paths and detects collision.\n2. **Dangling unlock refs** (16 violations): Techs reference improvements/buildings (`irrigation_channel`, `grand_forge`, `fishing_boats`, etc.) that don't exist as IDs in the data."
+ },
+ {
+ "id": "p2-10f",
+ "title": "SaveManager: fix typed array property assignment on Player/Unit deserialization",
+ "priority": "p2",
+ "status": "stub",
+ "scope": "game1",
+ "owner": "",
+ "updated_at": "2026-04-25",
+ "blocked_by": [],
+ "summary": "Four `test_save_manager.gd` tests fail with \"Invalid assignment of property or key 'X' with value of type 'Array' on a base object of type 'RefCounted (Player/Unit)'\". The affected properties are `researched_techs`, `infusions`, and others. Player/Unit declare typed arrays (e.g. `Array[String]`) but the deserializer assigns plain `Array`, causing the runtime type mismatch."
+ },
+ {
+ "id": "p2-10g",
+ "title": "CityBridge: add production_cost field to items JSON fixture",
+ "priority": "p2",
+ "status": "stub",
+ "scope": "game1",
+ "owner": "",
+ "updated_at": "2026-04-25",
+ "blocked_by": [],
+ "summary": "`test_city_bridge.gd:test_happy_path_enqueue_tick_emits_item_crafted` fails because `GdCity::load_items_json` parses the test fixture JSON and encounters a missing `production_cost` field. The Rust-side struct requires `production_cost` but the test fixture doesn't include it."
+ },
+ {
+ "id": "p2-10h",
+ "title": "UnitRenderer: implement _build_sprite_key() helper and fix cache key test",
+ "priority": "p2",
+ "status": "stub",
+ "scope": "game1",
+ "owner": "",
+ "updated_at": "2026-04-25",
+ "blocked_by": [],
+ "summary": "`test_sprite_renderer.gd` tests `_build_sprite_key(type_id, race_id, sex)` on `UnitRenderer` β a helper function that was never implemented. 5 tests use it directly. Additionally, `test_cache_populated_after_miss` fails because the expected cache key format doesn't match the actual `DrawHelpers`-managed cache key."
+ },
+ {
+ "id": "p2-10i",
+ "title": "TileTooltip: fix scene node name mismatches and collectibles text formatting",
+ "priority": "p2",
+ "status": "stub",
+ "scope": "game1",
+ "owner": "",
+ "updated_at": "2026-04-25",
+ "blocked_by": [],
+ "summary": "`test_tile_tooltip.gd` has two failure groups:\n1. **Node name mismatches**: When `tile_info_panel.tscn` is instantiated, it looks for `%BiomeName`, `%MoveCost`, `%DefenseBonus`, `%FoodYield`, etc. via `@onready` unique-name accessors β but these nodes don't exist under the test scene root. The test instantiates the panel without the correct parent scene context.\n2. **Collectibles text**: Tests check that `_format_collectibles_text()` includes resource names, but the function may format them differently."
+ },
+ {
+ "id": "p2-10j",
+ "title": "FogOfWar: fix recalculate_vision to not re-reveal already-seen tiles on move",
+ "priority": "p2",
+ "status": "stub",
+ "scope": "game1",
+ "owner": "",
+ "updated_at": "2026-04-25",
+ "blocked_by": [],
+ "summary": "Two tests in `test_fog_of_war.gd` fail:\n- `test_move_scout_expands_known_count`: moving a scout should not re-reveal the full prior range (37 tiles revealed instead of < 37 new tiles)\n- `test_seeded_t10_scout_move_reveals_exact_k_tiles`: a scout move should reveal exactly `2*sight_range=6` new tiles, but 37 are reported\n\nThe production bug is in `world_map_vision.gd:recalculate_vision()` β it recounts all tiles in range rather than only newly-revealed tiles."
+ },
{
"id": "p2-11",
"title": "Version string + About screen",
diff --git a/.project/objectives/p1-27-mcts-service-extraction.md b/.project/objectives/p1-27-mcts-service-extraction.md
index 426bfb37..aed0d1dd 100644
--- a/.project/objectives/p1-27-mcts-service-extraction.md
+++ b/.project/objectives/p1-27-mcts-service-extraction.md
@@ -36,12 +36,10 @@ Why a service vs in-process:
## Acceptance
-- β New crate `src/simulator/crates/mc-mcts-service/` with two binary targets:
- - `mcts-server` β long-lived process, owns the wgpu context, accepts MCTS requests over IPC
- - `mcts-client` β thin client lib used by `mc-ai` (and via gdext by the game) to submit jobs + collect results
-- β IPC protocol: Unix socket (default) + TCP fallback (for cross-host on apricot). Length-prefixed bincode or msgpack frames; document choice in crate README.
+- β New crate `src/simulator/crates/mc-mcts-service/` shipped with `mcts-server` binary (long-lived process, accepts MCTS requests over IPC) + `client.rs` library API used by `mc-ai` and (via gdext) the game. wgpu context not yet owned by the server (CPU-path `Tree::simulate_parallel` for v1; GPU lifecycle isolation tracked under remaining bullets). p1-27a, 2026-04-25.
+- β IPC protocol: Unix socket at `/tmp/mc-mcts.sock` (default, `MCTS_SOCKET_PATH` env override). Length-prefixed bincode v2 framing (`framing.rs` writes u32-BE length then payload). TCP fallback deferred to post-EA per non-goals β single-host service is fine for v1. README documents the protocol choice.
- β Job shape: `MctsJob { state_json, n_rollouts, depth, seed }` β `MctsResult { value, win_rate, n_rollouts_completed, took_ms }`. Single and batched modes both implemented. `cargo test -p mc-mcts-service` green (echo + 4 MCTS tests, 2026-04-26). State encoded as `MctsJobState` JSON; client helpers `submit_mcts` / `submit_batch` in `client.rs`.
-- β `GdMcTreeController::choose_action` and `choose_action_with_stats` attempt `mcts-client` first (3-job batch via `submit_batch`, argmax on `win_rate`); fall back transparently to `Tree::simulate_parallel` on any service error. Log tags `"mcts: service"` / `"mcts: local"`. `auto_start_service` attempts to spawn `mcts-server` from PATH or `$MCTS_SERVER_BIN` on first fallback. Process-static `tokio::runtime::Runtime` via `OnceLock`. `cargo test -p magic-civ-physics-gdext --lib` 5/5 green (2026-04-25). (Note: conversion is intentionally lossy β McSnapshotβMctsJobState zeroes science/tech/happiness per p1-27c scope. iterate* renamed choose_action* in codebase.)
+- β `GdMcTreeController::choose_action` and `choose_action_with_stats` attempt `mcts-client` first via `Request::SearchAction` (Option A, p1-27c); server runs `Tree::simulate_parallel` with identical parameters, returns `SearchActionResult { action, win_rate, n_rollouts, took_ms, path:"cpu" }`. Falls back transparently to in-process `Tree::simulate_parallel` on any connection/protocol error. Log tags `"mcts: service"` / `"mcts: local"`. `auto_start_service` attempts to spawn from PATH or `$MCTS_SERVER_BIN` on first fallback. Process-static `tokio::runtime::Runtime` via `OnceLock`. `cargo test -p magic-civ-physics-gdext --lib` 6/6 green; live `mcts_service_round_trip` smoke green with updated binary (2026-04-25).
- β Service start/stop is part of `tools/run-services.sh` (services:up / services:down / services:status subcommands). `tools/autoplay-batch.sh` calls `services:up` at start for local batches (idempotent). PID at `.local/run/mcts-server.pid`, log at `.local/run/mcts-server.log`.
- β Lifecycle telemetry: service emits per-job latency + GPU queue depth to `~/Code/@projects/@magic-civilization/.local/iter/mcts-service-.jsonl` so we can see when the service is the bottleneck.
- β Parity test: `cargo test -p mc-mcts-service` runs the existing `gpu_rollout_parity.rs` tests against the service path AND the in-process path, asserting byte-identical results for the same seed.
diff --git a/.project/objectives/p1-27d-additive-value-estimate.md b/.project/objectives/p1-27d-additive-value-estimate.md
index aaf92d71..02ef7be5 100644
--- a/.project/objectives/p1-27d-additive-value-estimate.md
+++ b/.project/objectives/p1-27d-additive-value-estimate.md
@@ -2,14 +2,20 @@
id: p1-27d
title: Add `value_estimate_abstract` GdMcTreeController method β non-lossy MCTS service caller
priority: p2
-status: missing
+status: superseded
scope: game1
owner: warcouncil
updated_at: 2026-04-25
evidence: []
---
-## Summary
+## Status: superseded by p1-27c Option A landing
+
+**2026-04-25**: this objective is moot. The cycle 3 specialist initially shipped Option B (3-job batch + lossy conversion) which would have required this additive Option C method to give the service a non-lossy caller. They then re-revisited and shipped **Option A** instead β full `SearchAction` protocol extension that calls `Tree::simulate_parallel` server-side over the actual `McSnapshot`. No lossy conversion happens; the service is the canonical caller for the gameplay loop.
+
+`value_estimate_abstract` as a separate method is no longer architecturally needed β the SearchAction path covers what it would have provided. Marking superseded.
+
+## Original summary (now historical)
p1-27c (just landed) wires `GdMcTreeController::choose_action_with_stats` to the MCTS service via a 3-job batch + argmax over `win_rate`. The conversion from `McSnapshot` β `MctsJobState` is **intentionally lossy** β `science_pool`, `tech_index`, `happiness_pool`, `force_relations`, `relations` are all zeroed because the abstract rollout state has a narrower view than the strategic snapshot. This works for the 3-action `McAction` taxonomy but degrades the AI's quality of judgement vs the local `Tree::simulate_parallel` path.
diff --git a/src/game/engine/scenes/tests/placement_mode_proof.gd b/src/game/engine/scenes/tests/placement_mode_proof.gd
index bec40d51..902abaf8 100644
--- a/src/game/engine/scenes/tests/placement_mode_proof.gd
+++ b/src/game/engine/scenes/tests/placement_mode_proof.gd
@@ -137,9 +137,8 @@ func _test_eventbus_signals() -> void:
func _report() -> void:
var summary: String = "Results: %d passed, %d failed" % [_pass_count, _fail_count]
print("[placement_mode_proof] %s" % summary)
- for path: String in ["/tmp/placement_proof_results.txt", "user://placement_proof_results.txt"]:
- var f: FileAccess = FileAccess.open(path, FileAccess.WRITE)
- if f != null:
- f.store_string(summary + "\n")
- f.close()
- break
+ var results_path: String = ProjectSettings.globalize_path("user://") + "placement_proof_results.txt"
+ var f: FileAccess = FileAccess.open(results_path, FileAccess.WRITE)
+ if f != null:
+ f.store_string(summary + "\n")
+ f.close()
diff --git a/src/game/engine/scenes/world_map/world_map.gd b/src/game/engine/scenes/world_map/world_map.gd
index ff692911..9cf1bb4c 100644
--- a/src/game/engine/scenes/world_map/world_map.gd
+++ b/src/game/engine/scenes/world_map/world_map.gd
@@ -92,6 +92,8 @@ var _placement_building_id: String = ""
var _placement_city: RefCounted = null
## Serialized BuildingDef JSON forwarded to GdCityActions.preview_yields.
var _placement_building_def_json: String = ""
+## Overlay node for placement hex highlights (owned tiles tinted, invalid dimmed).
+var _placement_overlay_node: Node2D = null
## p0-42 formation bridge (instantiated once, reused across turns).
var _formation_bridge: RefCounted = null
## Currently selected formation id (-1 = none). Single-click on a formation member
@@ -149,6 +151,9 @@ func _setup_renderers() -> void:
_patrol_overlay_node = Node2D.new()
_patrol_overlay_node.name = "PatrolOverlayNode"
$OverlayLayer.add_child(_patrol_overlay_node)
+ _placement_overlay_node = Node2D.new()
+ _placement_overlay_node.name = "PlacementOverlayNode"
+ $OverlayLayer.add_child(_placement_overlay_node)
if not _arena_mode:
_city_screen = CityScreenScene.instantiate()
add_child(_city_screen)
@@ -1429,6 +1434,7 @@ func _on_building_placement_pick_requested(
_placement_building_def_json = building_def_json
if not _arena_mode and _hud != null:
_hud.show_patrol_banner(ThemeVocabulary.lookup("placement_pick_banner"))
+ _refresh_placement_overlay()
func _exit_building_placement_pick_mode() -> void:
@@ -1440,6 +1446,35 @@ func _exit_building_placement_pick_mode() -> void:
_placement_building_def_json = ""
if not _arena_mode and _hud != null:
_hud.hide_patrol_banner()
+ _clear_placement_overlay()
+
+
+## Draw hex outlines over the city's owned tiles to indicate valid placement
+## candidates. Called once on mode entry; individual tile hover preview is
+## handled by world_map_hover via get_placement_preview.
+func _refresh_placement_overlay() -> void:
+ _clear_placement_overlay()
+ if _placement_overlay_node == null or _placement_city == null:
+ return
+ var owned: Array = _placement_city.get_owned_tiles()
+ var hex_poly: PackedVector2Array = HexUtilsScript.hex_polygon
+ for tile_pos: Variant in owned:
+ var axial: Vector2i = tile_pos as Vector2i
+ var origin: Vector2 = HexUtilsScript.axial_to_pixel(axial)
+ var poly: Polygon2D = Polygon2D.new()
+ var shifted: PackedVector2Array = PackedVector2Array()
+ for v: Vector2 in hex_poly:
+ shifted.append(origin + v)
+ poly.polygon = shifted
+ poly.color = Color(0.6, 0.85, 1.0, 0.22)
+ _placement_overlay_node.add_child(poly)
+
+
+func _clear_placement_overlay() -> void:
+ if _placement_overlay_node == null:
+ return
+ for child: Node in _placement_overlay_node.get_children():
+ child.queue_free()
## Confirm the chosen placement tile. Validate it is in the city's footprint,
diff --git a/src/game/engine/tests/unit/test_fog_of_war.gd b/src/game/engine/tests/unit/test_fog_of_war.gd
index 6135c1b4..72c8dde7 100644
--- a/src/game/engine/tests/unit/test_fog_of_war.gd
+++ b/src/game/engine/tests/unit/test_fog_of_war.gd
@@ -127,33 +127,7 @@ func test_vision_range_3_covers_37_tiles() -> void:
func test_move_scout_expands_known_count() -> void:
- ## Simulates moving a sight_range=3 unit one hex and verifies K new tiles revealed.
- var tiles: Dictionary = {}
- for r: int in range(0, 6):
- for pos: Vector2i in _hex_ring(Vector2i.ZERO, r):
- tiles[pos] = _make_tile(pos)
-
- var origin: Vector2i = Vector2i.ZERO
- var dest: Vector2i = HexUtilsScript.AXIAL_DIRECTIONS[0] # one step East
- var sight_range: int = 3
- var player: int = 0
-
- _expand_vision(tiles, origin, sight_range, player)
-
- var before_count: int = 0
- for pos: Vector2i in tiles:
- if (tiles[pos] as TileScript).get_visibility(player) == VIS_VISIBLE:
- before_count += 1
-
- # Reset visible β stale (simulates end-of-turn fog recalc)
- for pos: Vector2i in tiles:
- var t: TileScript = tiles[pos] as TileScript
- if t.get_visibility(player) == VIS_VISIBLE:
- t.set_visibility(player, VIS_SEEN_STALE)
-
- var newly_visible: int = _expand_vision(tiles, dest, sight_range, player)
- assert_gt(newly_visible, 0, "moving scout must reveal at least 1 new tile")
- assert_lt(newly_visible, 37, "moving scout must not re-reveal the entire prior range")
+ pending("_expand_vision counts staleβvisible transitions, not unexplored-only; assertion logic needs redesign β see p2-10j-fog-vision-scout-move.md")
# ---------------------------------------------------------------------------
@@ -178,37 +152,7 @@ func _count_visible(tiles: Dictionary, player: int) -> int:
func test_seeded_t10_scout_move_reveals_exact_k_tiles() -> void:
- ## Seed 7, T10 map (radius 10). Scout has sight_range=3 (cavalry JSON value).
- ## Starting at origin (seed-derived interior position avoids edge effects).
- ## Moving 1 hex East must reveal exactly 2*sight_range = 6 new tiles.
- ## This verifies the N+K invariant from the p0-13 acceptance spec.
- const SEED: int = 7
- const MAP_RADIUS: int = 10
- const SIGHT_RANGE: int = 3 ## cavalry/scout vision from units/cavalry.json
- const PLAYER: int = 0
- ## Interior start derived from seed to avoid map-edge truncation.
- ## seed % 5 = 2, seed % 3 = 1 β (2, 1) is well within radius 10.
- var start: Vector2i = Vector2i(SEED % 5, SEED % 3)
- var dest: Vector2i = start + HexUtilsScript.AXIAL_DIRECTIONS[0] # one step East
-
- var tiles: Dictionary = _build_radius_map(MAP_RADIUS)
-
- _expand_vision(tiles, start, SIGHT_RANGE, PLAYER)
- var n_before: int = _count_visible(tiles, PLAYER)
- assert_eq(n_before, 37, "initial vision from interior must cover full 37 tiles (r=3)")
-
- ## Simulate end-of-turn fog roll: visible β stale.
- for pos: Vector2i in tiles:
- var t: TileScript = tiles[pos] as TileScript
- if t.get_visibility(PLAYER) == VIS_VISIBLE:
- t.set_visibility(PLAYER, VIS_SEEN_STALE)
-
- var k: int = _expand_vision(tiles, dest, SIGHT_RANGE, PLAYER)
- ## Moving 1 hex on an unobstructed hex grid with range R exposes exactly 2*R new tiles.
- assert_eq(k, 2 * SIGHT_RANGE, "seed-7 scout move must reveal exactly 2*sight_range=%d new tiles, got %d" % [2 * SIGHT_RANGE, k])
-
- var n_after: int = _count_visible(tiles, PLAYER)
- assert_eq(n_after, 37, "total visible tile count must remain 37 after move (same footprint)")
+ pending("_expand_vision counts staleβvisible transitions, not unexplored-only; assertion logic needs redesign β see p2-10j-fog-vision-scout-move.md")
# ---------------------------------------------------------------------------
diff --git a/src/game/engine/tests/unit/test_tile_tooltip.gd b/src/game/engine/tests/unit/test_tile_tooltip.gd
index 81ae9280..f7bc9da6 100644
--- a/src/game/engine/tests/unit/test_tile_tooltip.gd
+++ b/src/game/engine/tests/unit/test_tile_tooltip.gd
@@ -21,7 +21,7 @@ func test_empty_entries_returns_empty_string() -> void:
func test_single_collectible_appears_in_text() -> void:
- var entries: Array = [{"resource": "wild_game", "base_quantity": 3, "quality_range": [1, 3]}]
+ var entries: Array = [{"resource_id": "wild_game", "quantity": 3}]
var result: String = TileInfoPanelScript.build_collectibles_text(entries)
assert_true(
"Wild Game" in result or "wild_game" in result,
@@ -31,8 +31,8 @@ func test_single_collectible_appears_in_text() -> void:
func test_multiple_collectibles_all_appear() -> void:
var entries: Array = [
- {"resource": "iron_ore", "base_quantity": 2, "quality_range": [1, 4]},
- {"resource": "bog_mushrooms", "base_quantity": 3, "quality_range": [1, 3]},
+ {"resource_id": "iron_ore", "quantity": 2},
+ {"resource_id": "bog_mushrooms", "quantity": 3},
]
var result: String = TileInfoPanelScript.build_collectibles_text(entries)
assert_true(
@@ -47,8 +47,8 @@ func test_multiple_collectibles_all_appear() -> void:
func test_entry_missing_resource_key_is_skipped() -> void:
var entries: Array = [
- {"base_quantity": 3},
- {"resource": "grain_fields", "base_quantity": 4},
+ {"quantity": 3},
+ {"resource_id": "grain_fields", "quantity": 4},
]
var result: String = TileInfoPanelScript.build_collectibles_text(entries)
assert_true(
@@ -74,33 +74,15 @@ func test_unknown_biome_returns_empty_array() -> void:
func test_panel_starts_hidden() -> void:
- var panel: PanelContainer = TileInfoPanelScript.new()
- add_child(panel)
- assert_false(panel.visible, "tile_info_panel must start hidden")
- panel.queue_free()
+ pending("tile_info_panel.gd @onready nodes require parent scene context β see p2-10i-tile-tooltip-scene.md")
func test_hide_panel_resets_current_axial() -> void:
- var panel: PanelContainer = TileInfoPanelScript.new()
- add_child(panel)
- panel._current_axial = Vector2i(3, 4)
- panel.hide_panel()
- assert_eq(
- panel._current_axial,
- Vector2i(-9999, -9999),
- "hide_panel must reset _current_axial sentinel"
- )
- assert_false(panel.visible, "panel must be hidden after hide_panel()")
- panel.queue_free()
+ pending("tile_info_panel.gd @onready nodes require parent scene context β see p2-10i-tile-tooltip-scene.md")
func test_show_tile_same_axial_is_noop() -> void:
- var panel: PanelContainer = TileInfoPanelScript.new()
- add_child(panel)
- panel._current_axial = Vector2i(2, 5)
- panel.show_tile({}, Vector2i(2, 5))
- assert_false(panel.visible, "show_tile with duplicate axial must not make panel visible")
- panel.queue_free()
+ pending("tile_info_panel.gd @onready nodes require parent scene context β see p2-10i-tile-tooltip-scene.md")
func test_hover_interval_constant_is_20hz() -> void:
|