From 5ef09a243ccfa4c5bec7508bf044bf9fc5a9ed63 Mon Sep 17 00:00:00 2001 From: Natalie Date: Mon, 25 May 2026 13:46:38 -0700 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=E2=9C=A8=20add=20resource=20tooltip=20feedback=20for=20disable?= =?UTF-8?q?d=20units?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../scenes/city/city_buildable_helper.gd | 40 +++- .../tests/unit/test_city_buildable_helper.gd | 69 +++++++ .../crates/mc-mapgen/src/deposits.rs | 60 ++++++ .../mc-mapgen/tests/deposit_feasibility.rs | 175 ++++++++++++++++++ 4 files changed, 337 insertions(+), 7 deletions(-) create mode 100644 src/game/engine/tests/unit/test_city_buildable_helper.gd create mode 100644 src/simulator/crates/mc-mapgen/tests/deposit_feasibility.rs diff --git a/src/game/engine/scenes/city/city_buildable_helper.gd b/src/game/engine/scenes/city/city_buildable_helper.gd index 41c5608e..e6164c6c 100644 --- a/src/game/engine/scenes/city/city_buildable_helper.gd +++ b/src/game/engine/scenes/city/city_buildable_helper.gd @@ -146,7 +146,14 @@ static func player_owns_resource(player: RefCounted, resource_id: String) -> boo ## Populate `list` with the units the city can currently train. -## Items get metadata `{type: "unit", id: }`. +## Items get metadata `{type: "unit", id: , reason: }`. +## +## Units whose `requires_resource` is missing are still added but shown +## disabled with a "Need " tooltip — mirrors `populate_items` so +## the player can see *why* the unit isn't available instead of having it +## silently disappear (mc-city already returns +## `QueueError::MissingResource` from `enqueue_unit`; the UI just needs to +## surface it). static func populate_units( list: ItemList, city: RefCounted, player: RefCounted ) -> void: @@ -160,19 +167,33 @@ static func populate_units( continue if has_can_build and not city.can_build(uid, player): continue - var req_res: String = str(udata.get("requires_resource", "")) - if req_res != "" and req_res != "null" and not player_owns_resource(player, req_res): - continue # p1-33: building gate — naval units require harbor, aerial units require airfield. var req_bld: String = str(udata.get("requires_building", "")) if req_bld != "" and req_bld != "null" and not city_buildings.has(req_bld): continue + + var reasons: Array[String] = [] + var req_res: String = str(udata.get("requires_resource", "")) + var missing_res: bool = ( + req_res != "" and req_res != "null" + and not player_owns_resource(player, req_res) + ) + if missing_res: + reasons.append("Need %s" % req_res) + var display: String = FormatterScript.resolve_display_name(udata, uid) var cost: int = udata.get("cost", 0) list.add_item("%s (%d)" % [display, cost]) - list.set_item_metadata( - list.item_count - 1, {"type": "unit", "id": uid} - ) + var idx: int = list.item_count - 1 + var meta: Dictionary = {"type": "unit", "id": uid, "reason": ""} + if not reasons.is_empty(): + list.set_item_disabled(idx, true) + list.set_item_custom_fg_color( + idx, Color(0.55, 0.45, 0.45, 1) + ) + meta["reason"] = "\n".join(reasons) + list.set_item_tooltip(idx, meta["reason"]) + list.set_item_metadata(idx, meta) ## Populate `list` with craftable items, gated by producer building presence, @@ -224,6 +245,11 @@ static func populate_items( reasons.append("Requires %s in this city" % secondary) if required_tech != "" and not techs.has(required_tech): reasons.append("Researches at %s" % required_tech) + # Strategic-resource gate — mc-city returns QueueError::MissingResource + # when this isn't satisfied; mirror that here so the player sees why. + var req_res: String = str(idata.get("requires_resource", "")) + if req_res != "" and req_res != "null" and not player_owns_resource(player, req_res): + reasons.append("Need %s" % req_res) if stockpile != null: for mat: Dictionary in materials: var res_id: String = str(mat.get("resource", "")) diff --git a/src/game/engine/tests/unit/test_city_buildable_helper.gd b/src/game/engine/tests/unit/test_city_buildable_helper.gd new file mode 100644 index 00000000..0f11ce56 --- /dev/null +++ b/src/game/engine/tests/unit/test_city_buildable_helper.gd @@ -0,0 +1,69 @@ +extends GutTest +## Task 6 verification gate — no-import variant. +## +## Exercises the resource-gate logic in `city_buildable_helper.gd`. Avoids +## loading scenes or imported assets (no `.tscn`, no textures), so this test +## can run on any warm-cache Godot host without triggering `--import` — +## which is mandatory: see plans/what-are-all-the-gleaming-flurry.md §7. + +const BuildableHelper: GDScript = preload( + "res://engine/scenes/city/city_buildable_helper.gd" +) +const PlayerScript: GDScript = preload( + "res://engine/src/entities/player.gd" +) + + +# ── player_owns_resource — edge cases that don't need GameState ────────────── + + +func test_empty_resource_id_is_unconstrained() -> void: + var player: RefCounted = PlayerScript.new() + assert_true( + BuildableHelper.player_owns_resource(player, ""), + "empty resource_id must short-circuit to true (no constraint)", + ) + + +func test_null_player_is_unconstrained() -> void: + assert_true( + BuildableHelper.player_owns_resource(null, "iron_ore"), + "null player must short-circuit to true (matches existing semantics)", + ) + + +func test_player_with_no_cities_does_not_own_resource() -> void: + # Without any cities to enumerate tiles for, the player cannot own iron_ore. + # Tolerates GameState being absent (the function falls through to false in + # either branch when the cities array is empty). + var player: RefCounted = PlayerScript.new() + assert_false( + BuildableHelper.player_owns_resource(player, "iron_ore"), + "player with empty cities array cannot own a strategic resource", + ) + + +# ── populate_units / populate_items — script-load contract ─────────────────── +# +# Asserts the public helpers are addressable (catches accidental renames or +# signature drift) without invoking them, which would require DataLoader, +# GameState, and a real ItemList scene tree. + + +func test_populate_units_is_static_method() -> void: + var fns: Array = BuildableHelper.get_script_method_list() + var names: Array[String] = [] + for fn: Dictionary in fns: + names.append(str(fn.get("name", ""))) + assert_true( + names.has("populate_units"), + "populate_units must remain a public static method on BuildableHelper", + ) + assert_true( + names.has("populate_items"), + "populate_items must remain a public static method on BuildableHelper", + ) + assert_true( + names.has("player_owns_resource"), + "player_owns_resource must remain public so the missing-resource check is callable", + ) diff --git a/src/simulator/crates/mc-mapgen/src/deposits.rs b/src/simulator/crates/mc-mapgen/src/deposits.rs index a364bfb5..e6a8256c 100644 --- a/src/simulator/crates/mc-mapgen/src/deposits.rs +++ b/src/simulator/crates/mc-mapgen/src/deposits.rs @@ -60,6 +60,43 @@ pub struct DepositSpec { pub placer_class: String, } +impl DepositSpec { + /// Parse a JSON array of deposit entries (one element per `public/resources/ + /// deposits/*.json` file) into a placement-ready catalog. Entries without a + /// recognised `placer_class` are dropped silently — the orchestrator skips + /// them anyway, and skipping at parse time keeps catalogs forward-compatible + /// with future placer classes added by other game packs. + /// + /// Callers are responsible for the file I/O (this crate stays I/O-free so + /// WASM and native builds behave identically) — typical pattern from + /// `api-gdext`: + /// + /// ```text + /// let json = std::fs::read_to_string(catalog_path)?; + /// let specs = DepositSpec::parse_catalog(&json)?; + /// place_deposits(&mut grid, &specs, &starts, seed); + /// ``` + pub fn parse_catalog(json: &str) -> Result, serde_json::Error> { + let raw: Vec = serde_json::from_str(json)?; + let mut out = Vec::with_capacity(raw.len()); + for entry in raw { + // Skip entries with no placer_class — every Game-1 deposit has one, + // but generic class-template files (`magical.json`, `marine.json`, + // `mineral.json`, `organic.json`) in the shared pool do not. + if entry.get("placer_class").and_then(|v| v.as_str()).is_none() { + continue; + } + match serde_json::from_value::(entry) { + Ok(spec) if PlacerClass::parse(&spec.placer_class).is_some() => { + out.push(spec); + } + _ => continue, + } + } + Ok(out) + } +} + /// Discriminator for which [`DepositPlacer`] impl handles a deposit. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum PlacerClass { @@ -430,6 +467,29 @@ mod tests { assert_eq!(terrain_factor(&spec, "hills"), 1.0); } + #[test] + fn parse_catalog_filters_unknown_classes_and_missing_tags() { + let json = r#"[ + {"id": "iron_ore", "placer_class": "metal", "terrains": ["hills"], + "near_start": true, "min_per_player": 1}, + {"id": "future_deposit", "placer_class": "psionic", "terrains": []}, + {"id": "no_class_template", "terrains": ["plains"]}, + {"id": "wheat", "placer_class": "food", "terrains": ["plains"]} + ]"#; + let specs = DepositSpec::parse_catalog(json).expect("valid catalog parses"); + let ids: Vec<&str> = specs.iter().map(|s| s.id.as_str()).collect(); + assert_eq!(ids, vec!["iron_ore", "wheat"]); + let iron = specs.iter().find(|s| s.id == "iron_ore").unwrap(); + assert!(iron.near_start); + assert_eq!(iron.min_per_player, 1); + } + + #[test] + fn parse_catalog_rejects_non_array_json() { + let err = DepositSpec::parse_catalog(r#"{"not": "an array"}"#).unwrap_err(); + assert!(err.to_string().contains("invalid type") || err.to_string().contains("expected")); + } + #[test] fn substream_tags_differ_per_deposit() { // Two distinct ids must produce distinct substream tags so that diff --git a/src/simulator/crates/mc-mapgen/tests/deposit_feasibility.rs b/src/simulator/crates/mc-mapgen/tests/deposit_feasibility.rs new file mode 100644 index 00000000..1f0f1b20 --- /dev/null +++ b/src/simulator/crates/mc-mapgen/tests/deposit_feasibility.rs @@ -0,0 +1,175 @@ +//! Deposit feasibility integration test (Gap B from +//! plans/what-are-all-the-gleaming-flurry.md). +//! +//! Boots a real worldgen pipeline for multiple seeds and map sizes, then runs +//! [`mc_mapgen::place_deposits`] against a representative Game-1 deposit catalog. +//! Asserts: +//! +//! 1. Every catalog deposit places ≥ 1 tile on ≥ COVERAGE_TARGET fraction of seeds. +//! 2. Deposits with `min_per_player > 0` have their per-player floor satisfied +//! on ≥ NEAR_START_TARGET fraction of seeds when player starts are provided. +//! +//! Catalog is inlined (not loaded from disk) — `mc-mapgen` does no file I/O so +//! integration tests use a hand-written subset that mirrors the curated Game-1 +//! manifest at `public/games/age-of-dwarves/data/deposits/manifest.json`. + +use mc_mapgen::{place_deposits, DepositSpec, MapGenerator}; + +/// Fraction of seeds on which a deposit must place at least one tile. +const COVERAGE_TARGET: f32 = 0.95; +/// Fraction of seeds on which `min_per_player * starts` floor must hold. +const NEAR_START_TARGET: f32 = 0.90; +/// Number of seeds per map size — kept low so CI stays fast; bump for soak runs. +const SEEDS_PER_SIZE: u32 = 25; + +fn make_spec(id: &str, class: &str, terrains: &[&str], min_per_player: u32) -> DepositSpec { + DepositSpec { + id: id.to_string(), + terrains: terrains.iter().map(|s| s.to_string()).collect(), + near_start: min_per_player > 0, + min_per_player, + placer_class: class.to_string(), + } +} + +fn catalog() -> Vec { + vec![ + make_spec("iron_ore", "metal", + &["hills", "mountains", "plains", "grassland", "tundra"], 1), + make_spec("gold_vein", "metal", + &["hills", "mountains", "tundra", "desert"], 0), + make_spec("coal_seam", "coal", + &["hills", "plains", "grassland", "forest", "wetland"], 0), + make_spec("diamond", "gem", + &["mountains", "hills", "tundra"], 0), + make_spec("chalk", "mineral", + &["hills", "plains", "grassland"], 0), + make_spec("obsidian", "volcanic", + &["mountains", "hills", "desert"], 0), + make_spec("fish", "marine", + &["ocean", "coast", "lake"], 0), + make_spec("wheat", "food", + &["plains", "grassland"], 0), + ] +} + +fn fake_starts(num_players: usize, w: i32, h: i32) -> Vec<(i32, i32)> { + // Spread evenly across the map's mid-latitude; convert offset to axial. + use mc_core::algorithms::hex; + let mut out = Vec::with_capacity(num_players); + let row = h / 2; + for i in 0..num_players as i32 { + let col = (w * (i + 1)) / (num_players as i32 + 1); + let (q, r) = hex::offset_to_axial(col, row); + out.push((q, r)); + } + out +} + +#[test] +fn every_deposit_places_on_most_seeds_at_standard_size() { + let gen = MapGenerator::new("{}"); + let cat = catalog(); + let mut counts: std::collections::HashMap<&str, u32> = cat + .iter() + .map(|d| (d.id.as_str(), 0)) + .collect(); + + for seed in 0..SEEDS_PER_SIZE { + let mut grid = gen.generate(seed as u64, "standard"); + let starts = fake_starts(4, grid.width, grid.height); + let placed = place_deposits(&mut grid, &cat, &starts, seed as u64); + assert!(placed > 0, "no deposits placed at all on seed {seed}"); + + for spec in &cat { + let any = grid.tiles.iter().any(|t| t.resource_id == spec.id); + if any { + *counts.get_mut(spec.id.as_str()).unwrap() += 1; + } + } + } + + for (id, hits) in &counts { + let frac = (*hits as f32) / (SEEDS_PER_SIZE as f32); + assert!( + frac >= COVERAGE_TARGET, + "deposit '{id}' only placed on {hits}/{SEEDS_PER_SIZE} seeds \ + (fraction {frac:.2}, target {COVERAGE_TARGET:.2})", + ); + } +} + +#[test] +fn iron_ore_min_per_player_floor_holds() { + let gen = MapGenerator::new("{}"); + let cat = catalog(); + let iron_min = cat.iter().find(|d| d.id == "iron_ore").unwrap().min_per_player; + assert!(iron_min >= 1, "test fixture must require iron_ore guarantee"); + + let mut satisfied = 0u32; + for seed in 0..SEEDS_PER_SIZE { + let mut grid = gen.generate(seed as u64, "standard"); + let starts = fake_starts(4, grid.width, grid.height); + place_deposits(&mut grid, &cat, &starts, seed as u64); + + let iron_count = grid.tiles.iter().filter(|t| t.resource_id == "iron_ore").count(); + let floor = iron_min as usize * starts.len(); + if iron_count >= floor { + satisfied += 1; + } + } + + let frac = (satisfied as f32) / (SEEDS_PER_SIZE as f32); + assert!( + frac >= NEAR_START_TARGET, + "iron_ore min_per_player floor satisfied on only {satisfied}/{SEEDS_PER_SIZE} \ + seeds (fraction {frac:.2}, target {NEAR_START_TARGET:.2})", + ); +} + +#[test] +fn placement_is_deterministic_for_same_seed() { + let gen = MapGenerator::new("{}"); + let cat = catalog(); + let starts = fake_starts(2, 80, 52); + + let mut a = gen.generate(7, "standard"); + let mut b = gen.generate(7, "standard"); + place_deposits(&mut a, &cat, &starts, 7); + place_deposits(&mut b, &cat, &starts, 7); + + let a_ids: Vec<&str> = a.tiles.iter().map(|t| t.resource_id.as_str()).collect(); + let b_ids: Vec<&str> = b.tiles.iter().map(|t| t.resource_id.as_str()).collect(); + assert_eq!(a_ids, b_ids, "place_deposits must be deterministic per seed"); +} + +#[test] +fn different_seeds_produce_different_placements() { + let gen = MapGenerator::new("{}"); + let cat = catalog(); + let starts = fake_starts(2, 80, 52); + + let mut a = gen.generate(11, "standard"); + let mut b = gen.generate(11, "standard"); + place_deposits(&mut a, &cat, &starts, 11); + place_deposits(&mut b, &cat, &starts, 19); + + let a_iron: Vec = a + .tiles + .iter() + .enumerate() + .filter(|(_, t)| t.resource_id == "iron_ore") + .map(|(i, _)| i) + .collect(); + let b_iron: Vec = b + .tiles + .iter() + .enumerate() + .filter(|(_, t)| t.resource_id == "iron_ore") + .map(|(i, _)| i) + .collect(); + assert_ne!( + a_iron, b_iron, + "different placement seeds should yield different iron_ore tiles" + ); +}