feat(@projects/@magic-civilization): 🛤️ Rail-1 Phase-0 — project equipped items to UnitView

EquippedItemView (item_id, category, charges_remaining, triggers_in_combat —
the exact mc_items::EquippedItem fields, cited) + UnitView.equipped, projected
from MapUnit.equipped for OWN units only, omitted from the wire when empty.
Surfaces the unit_panel.gd:789 entity read via view_json.

happiness_breakdown DEFERRED (verified, not fabricated): the per-contributor
breakdown is a transient calculate_happiness return (mc-happiness/pool.rs:170),
not persisted PlayerState — only the scalar happiness pool is stored
(game_state.rs:1295), already surfaced as ResourceView.happiness_pool. A
Phase-1 SOT-flip widening, like XP/culture_stored.

Dispatched simulator-infra; verify gate: mc-player-api green incl. new equipped
round-trip/omit test. Additive (serde defaults).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-27 08:19:07 -04:00
parent 76b3e48ae3
commit 8c3e7b8a27
2 changed files with 90 additions and 1 deletions

View file

@ -46,7 +46,7 @@ use mc_state::game_state::GameState;
use mc_vision::{compute_vision, PlayerVision, VisionCatalog};
use crate::view::{
CityView, CivicsView, CultureView, DiplomacyView, LegalActionEntry,
CityView, CivicsView, CultureView, DiplomacyView, EquippedItemView, LegalActionEntry,
PendingEventsView, PlayerView, ProductionQueueEntry, ResearchView, ResourceView, ScoreView,
TileView, UnitPostureView, UnitView,
};
@ -392,6 +392,21 @@ fn project_units(
} else {
Vec::new()
},
// Own units expose their equipped gear; enemies do not (no
// loadout leak). Empty vecs are omitted on the wire.
equipped: if is_own {
unit.equipped
.iter()
.map(|it| EquippedItemView {
item_id: it.item_id.clone(),
category: it.category.clone(),
charges_remaining: it.charges_remaining,
triggers_in_combat: it.triggers_in_combat,
})
.collect()
} else {
Vec::new()
},
});
}
}

View file

@ -206,6 +206,22 @@ impl UnitPostureView {
}
}
/// One equipped item on a unit. Mirrors the subset of
/// [`mc_items::EquippedItem`] the unit panel needs to render gear + remaining
/// charges. Surfaced for **own units only** (no enemy-loadout leak).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EquippedItemView {
/// Item definition id (e.g. `"iron_sword"`).
pub item_id: String,
/// Schema category: `"permanent"` | `"consumable"` | `"tool"`.
pub category: String,
/// Charges left before the item is spent (`0` for permanent gear).
pub charges_remaining: i32,
/// True if charges tick down once per combat resolved (combat-charge mode).
#[serde(default, skip_serializing_if = "core::ops::Not::not")]
pub triggers_in_combat: bool,
}
/// One unit — own or visible enemy.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct UnitView {
@ -243,6 +259,9 @@ pub struct UnitView {
/// Per-unit legal actions. Empty on enemy units.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub legal_actions: Vec<LegalActionEntry>,
/// Equipped gear (own units only). Omitted on the wire when empty.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub equipped: Vec<EquippedItemView>,
}
/// One explored tile.
@ -479,6 +498,7 @@ mod tests {
formation_id: None,
posture: UnitPostureView::default(),
legal_actions: Vec::new(),
equipped: Vec::new(),
};
let json = serde_json::to_string(&u).unwrap();
assert!(json.contains("\"type\":\"dwarf_warrior\""), "json={}", json);
@ -520,6 +540,7 @@ mod tests {
formation_id: Some(7),
posture: UnitPostureView { braced: true, ..Default::default() },
legal_actions: Vec::new(),
equipped: Vec::new(),
};
let json = serde_json::to_string(&u).unwrap();
assert!(json.contains("\"braced\":true"), "active posture must serialize: {json}");
@ -531,4 +552,57 @@ mod tests {
let json2 = serde_json::to_string(&u).unwrap();
assert!(!json2.contains("braced"), "resting posture omitted: {json2}");
}
#[test]
fn unit_view_equipped_round_trips_and_omits_when_empty() {
let mut u = UnitView {
id: "u_3".into(),
type_id: "dwarf_warrior".into(),
position: [2, 2],
owner: 0,
hp: 12,
max_hp: 12,
movement_left: 2,
movement_max: 2,
experience: 0,
promotion_available: false,
fortified: false,
sentry: false,
formation_id: None,
posture: UnitPostureView::default(),
legal_actions: Vec::new(),
equipped: Vec::new(),
};
// Empty loadout → field omitted entirely (wire economy).
let json = serde_json::to_string(&u).unwrap();
assert!(!json.contains("equipped"), "empty equipped omitted: {json}");
// Populate gear: a permanent weapon + a combat-charge charm.
u.equipped = vec![
EquippedItemView {
item_id: "iron_sword".into(),
category: "permanent".into(),
charges_remaining: 0,
triggers_in_combat: false,
},
EquippedItemView {
item_id: "war_charm".into(),
category: "consumable".into(),
charges_remaining: 3,
triggers_in_combat: true,
},
];
let json = serde_json::to_string(&u).unwrap();
assert!(json.contains("\"item_id\":\"iron_sword\""), "json={json}");
assert!(json.contains("\"charges_remaining\":3"), "json={json}");
assert!(json.contains("\"triggers_in_combat\":true"), "json={json}");
// The permanent item's `triggers_in_combat:false` is omitted (default).
assert_eq!(
json.matches("triggers_in_combat").count(),
1,
"false triggers_in_combat omitted: {json}"
);
let back: UnitView = serde_json::from_str(&json).unwrap();
assert_eq!(u, back, "equipped round-trips");
}
}