diff --git a/.project/objectives/p1-26-tile-placement-preview-ux.md b/.project/objectives/p1-26-tile-placement-preview-ux.md index 3d15a74b..64162fbe 100644 --- a/.project/objectives/p1-26-tile-placement-preview-ux.md +++ b/.project/objectives/p1-26-tile-placement-preview-ux.md @@ -23,11 +23,17 @@ This objective covers the **UX + supporting data extension** for tile-targeted b - ✓ JSON schema extended: `buildings/*.json` gains `placement_tile_required: bool` (default false), `placeable_on: [terrain|biome ids]`, `adjacency: { from: , yield_bonus: {...} }[]`. Building loaders in `mc-city` / DataLoader teach the new fields. `BuildingDef` + `BuildingRegistry` added to `mc-city`; `BUILDING_SCHEMA.md` documents the full schema. 49/49 mc-city tests green on apricot. - ✓ At least 4 starter buildings flipped to `placement_tile_required: true` (forge near mountain/hill → +1 production, marketplace near road/river → +1 trade, library on grassland/plains only, monument on hill → +1 culture). -- ❌ City screen "Build" button enters a **placement mode** for tile-required buildings: +- ✓ City screen "Build" button enters a **placement mode** for tile-required buildings: - Mouse-over tile in the city footprint highlights it, shows a yield-delta tooltip ("+2 production, +1 from adjacency to mountain") + - Implemented via `GdCityActions::preview_yields` (Rust bridge in `src/simulator/api-gdext/src/lib.rs:3888`) called each hover tick from `world_map_hover.gd`; result injected into `tile_info_panel.gd::show_tile_with_placement_preview` - Invalid tiles greyed out with reason ("requires hill") + - `preview_yields` returns `valid: false` + `reason: "requires X"` from `mc_city::placement::preview_building_yields`; panel renders reason in red via `show_tile_with_placement_preview` - Click confirms placement and queues the build at that tile + - `world_map.gd::_confirm_building_placement` validates tile is in city owned tiles, calls `city.add_to_queue_with_tile(bid, axial)` (`src/game/engine/src/entities/city.gd`), emits `EventBus.building_placement_confirmed` - ESC cancels + - `world_map.gd::_handle_escape_key` checks `_placement_pick_mode` before rally/waypoint, calls `_exit_building_placement_pick_mode` + - Entry point: `city_screen.gd::_on_add_queue_pressed` branches on `DataLoader.get_building(id).placement_tile_required`, emits `EventBus.building_placement_pick_requested` and closes the screen + - Proof scene: `src/game/engine/scenes/tests/placement_mode_proof.gd` — tests queue tile-tagging, preview_yields valid/invalid, EventBus signal round-trip - ❌ Worker improvement build flow extended with the same preview overlay (move worker to tile → "Build farm" button shows the yield-delta + adjacency math before committing). - ❌ Tile-placed buildings render at the chosen hex (sprite asset + tile_info_panel mention). - ❌ Tile-placed buildings persist through save/load (slot-based saves include the tile coordinate per building). diff --git a/src/game/engine/scenes/city/city_screen.gd b/src/game/engine/scenes/city/city_screen.gd index 5e06571a..38046338 100644 --- a/src/game/engine/scenes/city/city_screen.gd +++ b/src/game/engine/scenes/city/city_screen.gd @@ -405,6 +405,19 @@ func _on_add_queue_pressed() -> void: var item_id: String = meta.get("id", "") if item_type == "" or item_id == "": return + + ## p1-26c: tile-required buildings enter placement-pick mode instead of + ## queuing directly. We close the city screen and emit the EventBus signal + ## so world_map can enter its placement-pick state. + if item_type == "building": + var bdef: Dictionary = DataLoader.get_building(item_id) + if bdef.get("placement_tile_required", false) as bool: + _placement_building_id = item_id + var bdef_json: String = JSON.stringify(bdef) + EventBus.building_placement_pick_requested.emit(item_id, _city, bdef_json) + _on_close_pressed() + return + _city.add_to_queue(item_type, item_id) EventBus.production_queued.emit(_city, item_id, int(_city.owner_index)) _refresh_queue(GameState.get_game_map()) @@ -608,6 +621,14 @@ func _on_set_rally_pressed() -> void: _on_close_pressed() +## p1-26c: called when world_map confirms a tile placement. If this city screen +## is for the same city, refresh the queue panel to show the new entry. +func _on_building_placement_confirmed(_building_id: String, city: Variant, _tile_pos: Vector2i) -> void: + if city == _city and visible: + _refresh_queue(GameState.get_game_map()) + _refresh_buildable() + + ## Refresh rally badge text on the building row when a rally point is confirmed. func _on_rally_point_set(building_id: String, hex: Vector2i, _command: String) -> void: if not visible: diff --git a/src/game/engine/scenes/tests/placement_mode_proof.gd b/src/game/engine/scenes/tests/placement_mode_proof.gd new file mode 100644 index 00000000..a9133dd4 --- /dev/null +++ b/src/game/engine/scenes/tests/placement_mode_proof.gd @@ -0,0 +1,138 @@ +extends Node +## Proof scene for p1-26c: city-screen placement mode UI. +## +## Exercises the placement flow programmatically without a live world_map: +## 1. Creates a stub city with tiles and verifies add_to_queue_with_tile. +## 2. Constructs a GdCityActions bridge and calls preview_yields for a forge +## on a plains tile adjacent to mountains → expects production > 0. +## 3. Verifies the queue entry carries tile_pos after add_to_queue_with_tile. +## 4. Verifies EventBus signals fire in the right order. +## +## Runs headless; exits 0 on all assertions passing, 1 on first failure. + +const CityScript: GDScript = preload("res://engine/src/entities/city.gd") + +var _pass_count: int = 0 +var _fail_count: int = 0 + + +func _ready() -> void: + DataLoader.load_theme("age-of-dwarves") + ThemeVocabulary.load_vocabulary("age-of-dwarves") + await get_tree().process_frame + _run_all() + _report() + get_tree().quit(_fail_count) + + +func _assert(cond: bool, label: String) -> void: + if cond: + _pass_count += 1 + print("[placement_mode_proof] PASS %s" % label) + else: + _fail_count += 1 + print("[placement_mode_proof] FAIL %s" % label) + + +func _run_all() -> void: + _test_add_to_queue_with_tile() + _test_preview_yields_valid() + _test_preview_yields_invalid() + _test_eventbus_signals() + + +func _test_add_to_queue_with_tile() -> void: + var city: RefCounted = CityScript.new("test_city") + city.found("TestCity", 3, 3, false, 1) + city.owner = 0 + + var tile_pos: Vector2i = Vector2i(4, 3) + var ok: bool = city.add_to_queue_with_tile("forge", tile_pos) + _assert(ok, "add_to_queue_with_tile returns true for buildable building") + _assert(city.production_queue.size() == 1, "queue has one entry after add_to_queue_with_tile") + if city.production_queue.size() == 1: + var entry: Dictionary = city.production_queue[0] as Dictionary + _assert(entry.get("type") == "building", "queue entry type is building") + _assert(entry.get("id") == "forge", "queue entry id is forge") + _assert(entry.has("tile_pos"), "queue entry carries tile_pos") + _assert(entry.get("tile_pos") == tile_pos, "tile_pos in queue matches chosen hex") + + +func _test_preview_yields_valid() -> void: + if not ClassDB.class_exists("GdCityActions"): + print("[placement_mode_proof] SKIP preview_yields (GdCityActions not loaded)") + return + + var bridge: RefCounted = ClassDB.instantiate("GdCityActions") as RefCounted + if bridge == null: + print("[placement_mode_proof] SKIP preview_yields (instantiate returned null)") + return + + ## Forge BuildingDef: placeable on any terrain, +1 production adjacent to mountains/hills. + var forge_def_json: String = JSON.stringify({ + "id": "forge", + "placement_tile_required": true, + "placeable_on": [], + "adjacency": [ + {"from": "mountains", "yield_bonus": {"production": 1}}, + {"from": "hills", "yield_bonus": {"production": 1}}, + ] + }) + var result: Dictionary = bridge.call( + "preview_yields", forge_def_json, 4, 3, "plains", "mountains,grassland" + ) as Dictionary + _assert(result.get("valid", false) as bool, "preview_yields: valid=true on plains (forge has no placeable_on restriction)") + _assert(result.get("production", 0) as int == 1, "preview_yields: production delta = 1 from adjacent mountains") + _assert(result.get("reason", "") == "", "preview_yields: reason is empty on valid tile") + + +func _test_preview_yields_invalid() -> void: + if not ClassDB.class_exists("GdCityActions"): + return + + var bridge: RefCounted = ClassDB.instantiate("GdCityActions") as RefCounted + if bridge == null: + return + + ## Library: only placeable on grassland or plains. + var lib_def_json: String = JSON.stringify({ + "id": "library", + "placement_tile_required": true, + "placeable_on": ["grassland", "plains"], + "adjacency": [] + }) + var result: Dictionary = bridge.call( + "preview_yields", lib_def_json, 4, 3, "desert", "" + ) as Dictionary + _assert(not (result.get("valid", true) as bool), "preview_yields: valid=false on desert for library") + _assert(result.get("reason", "") != "", "preview_yields: reason is non-empty for invalid tile") + + +func _test_eventbus_signals() -> void: + var received_placement: bool = false + var received_confirmed: bool = false + var captured_bid: String = "" + var captured_tile: Vector2i = Vector2i(-1, -1) + + EventBus.building_placement_pick_requested.connect( + func(bid: String, _city: Variant, _json: String) -> void: + received_placement = true + captured_bid = bid + ) + EventBus.building_placement_confirmed.connect( + func(_bid: String, _city: Variant, tile_pos: Vector2i) -> void: + received_confirmed = true + captured_tile = tile_pos + ) + + EventBus.building_placement_pick_requested.emit("forge", null, "{}") + EventBus.building_placement_confirmed.emit("forge", null, Vector2i(5, 3)) + + _assert(received_placement, "building_placement_pick_requested signal fires and is received") + _assert(captured_bid == "forge", "building_placement_pick_requested carries building_id") + _assert(received_confirmed, "building_placement_confirmed signal fires and is received") + _assert(captured_tile == Vector2i(5, 3), "building_placement_confirmed carries correct tile_pos") + + +func _report() -> void: + print("[placement_mode_proof] Results: %d passed, %d failed" % [_pass_count, _fail_count]) diff --git a/src/game/engine/scenes/tests/placement_mode_proof.tscn b/src/game/engine/scenes/tests/placement_mode_proof.tscn new file mode 100644 index 00000000..6006e732 --- /dev/null +++ b/src/game/engine/scenes/tests/placement_mode_proof.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://engine/scenes/tests/placement_mode_proof.gd" id="1"] + +[node name="PlacementModeProof" type="Node"] +script = ExtResource("1") diff --git a/src/simulator/api-gdext/src/ai.rs b/src/simulator/api-gdext/src/ai.rs index 5cb404a3..fbd3f5a0 100644 --- a/src/simulator/api-gdext/src/ai.rs +++ b/src/simulator/api-gdext/src/ai.rs @@ -31,17 +31,19 @@ use mc_turn::{GameState, TurnProcessor}; // ── Service runtime (process-static) ───────────────────────────────────────── -static TOKIO_RT: OnceLock = OnceLock::new(); +static TOKIO_RT: OnceLock> = OnceLock::new(); static SERVICE_WARN_EMITTED: AtomicBool = AtomicBool::new(false); -fn tokio_rt() -> &'static tokio::runtime::Runtime { - TOKIO_RT.get_or_init(|| { - tokio::runtime::Builder::new_multi_thread() - .worker_threads(2) - .enable_all() - .build() - .expect("failed to build tokio runtime for mcts service client") - }) +fn tokio_rt() -> Option<&'static tokio::runtime::Runtime> { + TOKIO_RT + .get_or_init(|| { + tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .enable_all() + .build() + .ok() + }) + .as_ref() } fn socket_path() -> String { @@ -65,12 +67,7 @@ fn auto_start_service() { return; }; - let log_dir = std::path::Path::new( - &std::env::var("MC_LOCAL_DIR") - .unwrap_or_else(|_| ".local/run".to_owned()), - ).join("run"); - let _ = std::fs::create_dir_all(&log_dir); - let log_file = log_dir.join("mcts-server.log"); + let log_file = std::path::PathBuf::from("/tmp/mc-mcts-server.log"); let Ok(log_out) = std::fs::OpenOptions::new() .create(true).append(true).open(&log_file) @@ -80,7 +77,6 @@ fn auto_start_service() { let Ok(log_err) = log_out.try_clone() else { return; }; let _ = Command::new(&bin) - .arg("--socket") .arg(socket_path()) .stdout(log_out) .stderr(log_err) @@ -164,7 +160,8 @@ fn try_choose_via_service( } let sock = socket_path(); - let results = tokio_rt() + let rt = tokio_rt()?; + let results = rt .block_on(mc_mcts_service::client::submit_batch(&sock, jobs)) .ok()?; @@ -304,6 +301,10 @@ impl GdMcTreeController { /// Run MCTS from the serialized `game_state_json` for `player_index` and return /// the best `McAction` as a string: `"Idle"`, `"FoundCity"`, or `"SpawnUnit"`. /// + /// Attempts the `mcts-server` service path first (p1-27c); falls back to the + /// local `Tree::simulate_parallel` path on any connection or protocol error. + /// Log tag `"mcts: service"` or `"mcts: local"` indicates which path ran. + /// /// Returns `"Idle"` on JSON parse failure so GDScript always gets a valid value. #[func] fn choose_action(&self, game_state_json: GString, player_index: i64, seed: i64) -> GString { @@ -320,14 +321,34 @@ impl GdMcTreeController { let pi = player_index.max(0) as usize; snapshot.active_player = pi as u8; let base_seed = seed as u64; - let depth = self.rollout_depth; + // Service path (p1-27c): attempt once per call, fall back on any error. + if let Some((action, _win_rate)) = try_choose_via_service( + &snapshot, + self.rollout_budget, + self.rollout_depth.min(255) as u8, + base_seed, + ) { + godot_print!("mcts: service"); + return GString::from(match action { + McAction::Idle => "Idle", + McAction::FoundCity => "FoundCity", + McAction::SpawnUnit => "SpawnUnit", + }); + } + + // Service unavailable — warn once then use local path. + if !SERVICE_WARN_EMITTED.swap(true, Ordering::Relaxed) { + auto_start_service(); + godot_warn!("mcts: service unavailable, using local path (mcts: local)"); + } + godot_print!("mcts: local"); + + let depth = self.rollout_depth; let mut tree = Tree::new(snapshot) .with_gpu_context(self.gpu_context_if_enabled()); tree.use_priors = self.priors_enabled; - // rollout_fn: walk the snapshot forward `depth` steps with random actions, - // score from the perspective of `pi`. let rollout_fn = move |snap: &McSnapshot, rng: &mut XorShift64| -> f32 { let step_fn = |s: &McSnapshot, _d: u32, rng: &mut XorShift64| { let actions = s.legal_actions(); @@ -350,7 +371,6 @@ impl GdMcTreeController { let budget = if self.budget_ms > 0 { Some(self.budget_ms) } else { None }; tree.simulate_parallel(self.rollout_budget as usize, base_seed, rollout_fn, budget); - // Best action = child of root with most visits (robust child selection). let root_children = tree.root().children.clone(); let best_child_idx = root_children .into_iter() @@ -431,7 +451,12 @@ impl GdMcTreeController { } /// Convenience: return the best action and the win-rate estimate as a JSON dict. - /// `{ "action": "FoundCity", "win_rate": 0.62 }` + /// `{ "action": "FoundCity", "win_rate": 0.62, "root_idle": N, ... }` + /// + /// Attempts the `mcts-server` service path first (p1-27c); falls back to + /// `Tree::simulate_parallel` on any service error. When the service path is + /// used, `root_idle`/`root_found`/`root_spawn` are set to 0 (visit-count + /// breakdowns are not available from the service). #[func] fn choose_action_with_stats( &self, @@ -455,8 +480,32 @@ impl GdMcTreeController { let pi = player_index.max(0) as usize; snapshot.active_player = pi as u8; let base_seed = seed as u64; - let depth = self.rollout_depth; + // Service path (p1-27c): visit counts not available from batch results. + if let Some((action, win_rate)) = try_choose_via_service( + &snapshot, + self.rollout_budget, + self.rollout_depth.min(255) as u8, + base_seed, + ) { + godot_print!("mcts: service"); + let action_str = match action { + McAction::Idle => "Idle", + McAction::FoundCity => "FoundCity", + McAction::SpawnUnit => "SpawnUnit", + }; + return GString::from(format!( + r#"{{"action":"{action_str}","win_rate":{win_rate:.4},"root_idle":0,"root_found":0,"root_spawn":0}}"# + )); + } + + if !SERVICE_WARN_EMITTED.swap(true, Ordering::Relaxed) { + auto_start_service(); + godot_warn!("mcts: service unavailable, using local path (mcts: local)"); + } + godot_print!("mcts: local"); + + let depth = self.rollout_depth; let mut tree = Tree::new(snapshot) .with_gpu_context(self.gpu_context_if_enabled()); tree.use_priors = self.priors_enabled; @@ -507,7 +556,6 @@ impl GdMcTreeController { McAction::SpawnUnit => "SpawnUnit", }; - // Build per-action visit counts for root divergence analysis (p0-38). let root = tree.root(); let mut visits_idle = 0u32; let mut visits_found = 0u32; diff --git a/tools/autoplay-batch.sh b/tools/autoplay-batch.sh index 6b8e2402..c8746350 100755 --- a/tools/autoplay-batch.sh +++ b/tools/autoplay-batch.sh @@ -140,6 +140,13 @@ echo "Parallel: $PARALLEL concurrent seed(s)" echo "Safety timeout: ${SAFETY_TIMEOUT}s per game" echo "============================================================" +# Start the MCTS service (idempotent — no-op if already running). +# Runs only for local batches; remote batches rely on the remote host's +# run-services.sh invocation (or service already running on apricot). +if [ -z "$AUTOPLAY_HOST" ]; then + "$SCRIPT_DIR/run-services.sh" services:up 2>/dev/null || true +fi + # Resolve REMOTE_HOME once upfront (parallel workers all need it and racing for it breaks) if [ -n "$AUTOPLAY_HOST" ]; then REMOTE_HOME="$(ssh "$AUTOPLAY_HOST" 'echo "$HOME"')" diff --git a/tools/gut-headless.sh b/tools/gut-headless.sh index 8fb8a05d..0d5d375f 100755 --- a/tools/gut-headless.sh +++ b/tools/gut-headless.sh @@ -1,15 +1,36 @@ #!/usr/bin/env bash # Runs the GUT headless test suite — mirrors the CI "headless GUT" step exactly. +# Flatpak swallows get_tree().quit() exit codes, so we write a JUnit XML report +# to a home-accessible path and parse it for the failure count. +# # Usage: bash tools/gut-headless.sh [extra gut flags] set -uo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +RESULTS_DIR="$REPO_ROOT/.local/iter" +mkdir -p "$RESULTS_DIR" +RESULTS_FILE="$RESULTS_DIR/gut-junit.xml" + flatpak run --filesystem=home org.godotengine.Godot \ --path "$REPO_ROOT/src/game" \ --headless \ -s addons/gut/gut_cmdln.gd \ -gdir=engine/tests/unit \ -gexit \ + "-gjunit_xml_file=$RESULTS_FILE" \ "$@" + +# Flatpak swallows Godot's quit() exit code; parse the JUnit report instead. +FAIL_COUNT=0 +if [[ -f "$RESULTS_FILE" ]]; then + FAIL_COUNT=$(grep -oP '(?<=]*failures=")[0-9]+' "$RESULTS_FILE" 2>/dev/null || echo 0) + FAIL_COUNT=${FAIL_COUNT:-0} +fi + +if [[ "$FAIL_COUNT" -gt 0 ]]; then + echo "gut-headless: $FAIL_COUNT failing test(s)" >&2 + exit 1 +fi +exit 0 diff --git a/tools/run-services.sh b/tools/run-services.sh new file mode 100755 index 00000000..2d73d71b --- /dev/null +++ b/tools/run-services.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# run-services.sh — Manage the mc-magic-civilization background services. +# +# Usage: +# tools/run-services.sh services:up — start mcts-server (idempotent) +# tools/run-services.sh services:down — stop mcts-server +# tools/run-services.sh services:status — print service status +# +# Environment: +# MCTS_SOCKET_PATH — Unix socket path (default: /tmp/mc-mcts.sock) +# MCTS_SERVER_BIN — Path to mcts-server binary (default: search PATH, then +# src/simulator/target/release/mcts-server) +# MC_RUN_DIR — Directory for PID and log files +# (default: /.local/run) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" + +MCTS_SOCKET_PATH="${MCTS_SOCKET_PATH:-/tmp/mc-mcts.sock}" +MC_RUN_DIR="${MC_RUN_DIR:-$PROJECT_DIR/.local/run}" +PID_FILE="$MC_RUN_DIR/mcts-server.pid" +LOG_FILE="$MC_RUN_DIR/mcts-server.log" + +_find_mcts_server() { + # 1. Caller-supplied path. + if [ -n "${MCTS_SERVER_BIN:-}" ] && [ -x "$MCTS_SERVER_BIN" ]; then + echo "$MCTS_SERVER_BIN" + return 0 + fi + # 2. On PATH. + if command -v mcts-server >/dev/null 2>&1; then + command -v mcts-server + return 0 + fi + # 3. Release build next to simulator workspace. + local release_bin="$PROJECT_DIR/src/simulator/target/release/mcts-server" + if [ -x "$release_bin" ]; then + echo "$release_bin" + return 0 + fi + return 1 +} + +_is_running() { + [ -f "$PID_FILE" ] || return 1 + local pid + pid="$(cat "$PID_FILE")" + kill -0 "$pid" 2>/dev/null +} + +_cmd_up() { + if _is_running; then + local pid + pid="$(cat "$PID_FILE")" + echo "mcts-server already running (pid $pid)" + return 0 + fi + + local bin + if ! bin="$(_find_mcts_server)"; then + echo "ERROR: mcts-server binary not found." >&2 + echo " Build it with: cd src/simulator && cargo build --release -p mc-mcts-service" >&2 + echo " Or set MCTS_SERVER_BIN=/path/to/mcts-server" >&2 + exit 1 + fi + + mkdir -p "$MC_RUN_DIR" + echo "Starting mcts-server ($bin) → socket $MCTS_SOCKET_PATH" + nohup "$bin" "$MCTS_SOCKET_PATH" >> "$LOG_FILE" 2>&1 & + local pid=$! + echo "$pid" > "$PID_FILE" + echo "mcts-server started (pid $pid, log: $LOG_FILE)" +} + +_cmd_down() { + if ! _is_running; then + echo "mcts-server not running" + rm -f "$PID_FILE" + return 0 + fi + local pid + pid="$(cat "$PID_FILE")" + kill -TERM "$pid" 2>/dev/null || true + rm -f "$PID_FILE" + echo "mcts-server stopped (pid $pid)" +} + +_cmd_status() { + if _is_running; then + local pid + pid="$(cat "$PID_FILE")" + echo "mcts-server running (pid $pid, socket: $MCTS_SOCKET_PATH)" + else + echo "mcts-server not running" + rm -f "$PID_FILE" 2>/dev/null || true + fi +} + +CMD="${1:-}" +case "$CMD" in + services:up) _cmd_up ;; + services:down) _cmd_down ;; + services:status) _cmd_status ;; + *) + echo "Usage: $0 {services:up|services:down|services:status}" >&2 + exit 1 + ;; +esac