feat(@projects/@magic-civilization): ✨ add resource tooltip feedback for disabled units
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
fc137c5984
commit
5ef09a243c
4 changed files with 337 additions and 7 deletions
|
|
@ -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", ""))
|
||||
|
|
|
|||
69
src/game/engine/tests/unit/test_city_buildable_helper.gd
Normal file
69
src/game/engine/tests/unit/test_city_buildable_helper.gd
Normal 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",
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
175
src/simulator/crates/mc-mapgen/tests/deposit_feasibility.rs
Normal file
175
src/simulator/crates/mc-mapgen/tests/deposit_feasibility.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue