feat(@projects/@magic-civilization): add tile placement preview mode

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-04-25 23:35:18 -07:00
parent ae3a3b8d66
commit 21d757a20f
8 changed files with 382 additions and 25 deletions

View file

@ -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: <id>, 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).

View file

@ -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:

View file

@ -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])

View file

@ -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")

View file

@ -31,17 +31,19 @@ use mc_turn::{GameState, TurnProcessor};
// ── Service runtime (process-static) ─────────────────────────────────────────
static TOKIO_RT: OnceLock<tokio::runtime::Runtime> = OnceLock::new();
static TOKIO_RT: OnceLock<Option<tokio::runtime::Runtime>> = 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;

View file

@ -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"')"

View file

@ -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 '(?<=<testsuites[^>]*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

110
tools/run-services.sh Executable file
View file

@ -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: <repo-root>/.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