feat(@projects/@magic-civilization): add resource tooltip feedback for disabled units

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-25 13:46:38 -07:00
parent fc137c5984
commit 5ef09a243c
4 changed files with 337 additions and 7 deletions

View file

@ -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: <unit_id>}`.
## Items get metadata `{type: "unit", id: <unit_id>, reason: <str>}`.
##
## Units whose `requires_resource` is missing are still added but shown
## disabled with a "Need <resource>" 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", ""))

View file

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

View file

@ -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<Vec<Self>, serde_json::Error> {
let raw: Vec<serde_json::Value> = 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::<DepositSpec>(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

View file

@ -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<DepositSpec> {
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<usize> = a
.tiles
.iter()
.enumerate()
.filter(|(_, t)| t.resource_id == "iron_ore")
.map(|(i, _)| i)
.collect();
let b_iron: Vec<usize> = 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"
);
}