feat(@projects/@magic-civilization): ✨ add tile placement preview mode
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
ae3a3b8d66
commit
21d757a20f
8 changed files with 382 additions and 25 deletions
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
138
src/game/engine/scenes/tests/placement_mode_proof.gd
Normal file
138
src/game/engine/scenes/tests/placement_mode_proof.gd
Normal 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])
|
||||
6
src/game/engine/scenes/tests/placement_mode_proof.tscn
Normal file
6
src/game/engine/scenes/tests/placement_mode_proof.tscn
Normal 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")
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"')"
|
||||
|
|
|
|||
|
|
@ -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
110
tools/run-services.sh
Executable 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
|
||||
Loading…
Add table
Reference in a new issue