feat(@projects/@magic-civilization): add tech research dispatch support

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-11 00:59:49 -07:00
parent df9f5c362c
commit d5c8375b6a
3 changed files with 242 additions and 14 deletions

View file

@ -6,6 +6,7 @@ edition = "2021"
[dependencies]
mc-core = { path = "../mc-core" }
mc-city = { path = "../mc-city" }
mc-tech = { path = "../mc-tech" }
mc-trade = { path = "../mc-trade" }
mc-turn = { path = "../mc-turn" }
mc-combat = { path = "../mc-combat" }

View file

@ -133,14 +133,20 @@ pub fn apply_action(
.into(),
}),
PlayerAction::QueueProduction { .. }
| PlayerAction::RemoveFromQueue { .. }
| PlayerAction::QueueReorder { .. }
PlayerAction::QueueProduction { city_id, item, tile: _ } => {
apply_queue_production(state, player, city_id, item)
}
PlayerAction::RemoveFromQueue { city_id, index: _ } => {
apply_clear_queue(state, player, city_id)
}
PlayerAction::QueueReorder { .. }
| PlayerAction::RushBuy { .. }
| PlayerAction::BuyTile { .. }
| PlayerAction::SetFocus { .. }
| PlayerAction::MergeBuildings { .. } => Err(ActionError::NotYetImplemented {
message: "city ops dispatch into mc-city — TRACKED: p2-67 Phase 1 follow-up".into(),
message: "reorder / rush_buy / buy_tile / set_focus / merge_buildings need \
bench-vs-full City reconciliation TRACKED: p2-67 Phase 1 follow-up"
.into(),
}),
PlayerAction::BuildingAction { .. } => Err(ActionError::NotYetImplemented {
@ -149,16 +155,12 @@ pub fn apply_action(
.into(),
}),
PlayerAction::ResearchTech { .. } => Err(ActionError::NotYetImplemented {
message: "ResearchTech requires a TechWeb handle on GameState — \
TRACKED: p2-67 Phase 1 follow-up (web threading)"
.into(),
}),
PlayerAction::ResearchTradition { .. } => Err(ActionError::NotYetImplemented {
message: "ResearchTradition requires a CultureWeb handle on GameState — \
TRACKED: p2-67 Phase 1 follow-up (web threading)"
.into(),
}),
PlayerAction::ResearchTech { tech_id } => {
apply_research_tech(state, player, tech_id)
}
PlayerAction::ResearchTradition { tradition_id } => {
apply_research_tradition(state, player, tradition_id)
}
PlayerAction::OfferPeace { to } => {
// mc-trade::offer_peace is an EA stub (always rejects). Emit a
@ -368,6 +370,125 @@ fn apply_accept_peace(
Ok(Vec::new())
}
fn find_city_indices(
state: &GameState,
city_id: &str,
) -> Result<(usize, usize), ActionError> {
// City id convention used by the projector: "{player_idx}_{city_idx}"
// (see projection::project_cities). Parse + bounds-check both halves.
let (pi_str, ci_str) = city_id
.split_once('_')
.ok_or_else(|| ActionError::UnknownCity {
city_id: city_id.to_string(),
})?;
let pi: usize = pi_str.parse().map_err(|_| ActionError::UnknownCity {
city_id: city_id.to_string(),
})?;
let ci: usize = ci_str.parse().map_err(|_| ActionError::UnknownCity {
city_id: city_id.to_string(),
})?;
if pi >= state.players.len() {
return Err(ActionError::UnknownCity {
city_id: city_id.to_string(),
});
}
if ci >= state.players[pi].cities.len() {
return Err(ActionError::UnknownCity {
city_id: city_id.to_string(),
});
}
Ok((pi, ci))
}
fn apply_queue_production(
state: &mut GameState,
_player: PlayerId,
city_id: &str,
item: &str,
) -> Result<Vec<Event>, ActionError> {
let (pi, ci) = find_city_indices(state, city_id)?;
// Bench `CityState` carries a single-item `queue: Option<Queueable>`.
// Detect whether the requested id is a known unit or treat as building.
// Heuristic: if the id contains "dwarf_" we treat as Unit; otherwise
// Item (the bench struct doesn't carry a Building variant — full
// City does, tracked separately).
use mc_core::ids::UnitId;
let queueable: mc_city::Queueable = if item.starts_with("dwarf_") {
mc_city::Queueable::Unit {
unit_id: UnitId::new(item),
}
} else {
mc_city::Queueable::Item {
item_id: item.to_string(),
}
};
let city: &mut mc_city::CityState = &mut state.players[pi].cities[ci];
city.queue = Some(queueable);
// Reset production_stored when the queue head changes.
city.production_stored = 0;
// Default cost when the caller did not supply one; processor will
// recompute on first tick if a real registry is available.
if city.queue_cost.is_none() {
city.queue_cost = Some(40);
}
Ok(Vec::new())
}
fn apply_clear_queue(
state: &mut GameState,
_player: PlayerId,
city_id: &str,
) -> Result<Vec<Event>, ActionError> {
let (pi, ci) = find_city_indices(state, city_id)?;
let city: &mut mc_city::CityState = &mut state.players[pi].cities[ci];
city.queue = None;
city.queue_cost = None;
city.queue_tier = None;
city.production_stored = 0;
Ok(Vec::new())
}
fn apply_research_tech(
state: &mut GameState,
player: PlayerId,
tech_id: &str,
) -> Result<Vec<Event>, ActionError> {
let pi: usize = player as usize;
if pi >= state.players.len() {
return Err(ActionError::Internal {
message: format!("player slot {player} out of range"),
});
}
// Lazy-init `player_tech` so adapters can pick research even when
// the harness hasn't seeded a PlayerTechState yet.
if state.players[pi].player_tech.is_none() {
state.players[pi].player_tech = Some(mc_tech::PlayerTechState::new());
}
let pt: &mut mc_tech::PlayerTechState =
state.players[pi].player_tech.as_mut().unwrap();
let _ = pt.set_researching_unchecked(tech_id);
Ok(Vec::new())
}
fn apply_research_tradition(
state: &mut GameState,
player: PlayerId,
tradition_id: &str,
) -> Result<Vec<Event>, ActionError> {
let pi: usize = player as usize;
if pi >= state.players.len() {
return Err(ActionError::Internal {
message: format!("player slot {player} out of range"),
});
}
// Bench tradition state lives in flat fields on `PlayerState` —
// see `game_state.rs::researching_tradition` (a String). Set
// directly; the turn processor reads from that field.
state.players[pi].researching_tradition = tradition_id.to_string();
state.players[pi].culture_research_progress = 0;
Ok(Vec::new())
}
fn apply_ransom_response(
state: &mut GameState,
player: PlayerId,
@ -687,6 +808,93 @@ mod tests {
assert_eq!(rs.relation, mc_trade::relation::Relation::Neutral);
}
#[test]
fn queue_production_with_unit_id_sets_unit_queueable() {
let mut state = make_state_with_units(vec![(0, 1, 0, 0)]);
// Add a starter city so QueueProduction has a target.
state.players[0].cities.push(mc_city::CityState::starter());
let _ = apply_action(
&mut state,
0,
&PlayerAction::QueueProduction {
city_id: "0_0".into(),
item: "dwarf_warrior".into(),
tile: None,
},
)
.unwrap();
let q = state.players[0].cities[0].queue.as_ref().unwrap();
assert!(matches!(q, mc_city::Queueable::Unit { .. }));
}
#[test]
fn queue_production_with_item_id_sets_item_queueable() {
let mut state = make_state_with_units(vec![(0, 1, 0, 0)]);
state.players[0].cities.push(mc_city::CityState::starter());
let _ = apply_action(
&mut state,
0,
&PlayerAction::QueueProduction {
city_id: "0_0".into(),
item: "iron_sword".into(),
tile: None,
},
)
.unwrap();
let q = state.players[0].cities[0].queue.as_ref().unwrap();
assert!(matches!(q, mc_city::Queueable::Item { .. }));
}
#[test]
fn queue_production_unknown_city_returns_unknown_city_error() {
let mut state = GameState::default();
let err = apply_action(
&mut state,
0,
&PlayerAction::QueueProduction {
city_id: "99_99".into(),
item: "dwarf_warrior".into(),
tile: None,
},
)
.unwrap_err();
assert!(matches!(err, ActionError::UnknownCity { .. }));
}
#[test]
fn research_tech_sets_player_tech_state() {
let mut state = make_state_with_units(vec![(0, 1, 0, 0)]);
let _ = apply_action(
&mut state,
0,
&PlayerAction::ResearchTech {
tech_id: "bronze_working".into(),
},
)
.unwrap();
let pt = state.players[0].player_tech.as_ref().unwrap();
assert_eq!(pt.current_research(), Some("bronze_working"));
assert_eq!(pt.research_progress(), 0);
}
#[test]
fn research_tradition_sets_flat_field_on_player_state() {
let mut state = make_state_with_units(vec![(0, 1, 0, 0)]);
let _ = apply_action(
&mut state,
0,
&PlayerAction::ResearchTradition {
tradition_id: "ancestor_veneration".into(),
},
)
.unwrap();
assert_eq!(
state.players[0].researching_tradition,
"ancestor_veneration"
);
assert_eq!(state.players[0].culture_research_progress, 0);
}
#[test]
fn ransom_response_on_unknown_offer_returns_illegal_action() {
let mut state = GameState::default();

View file

@ -103,6 +103,25 @@ impl PlayerTechState {
// -- Mutation -------------------------------------------------------------
/// Set the currently-researched tech without checking prerequisites
/// or registering the tech in a `TechWeb`. Used by callers that
/// don't have a `&TechWeb` handle (e.g. the player-API dispatcher
/// drives this from a typed `PlayerAction::ResearchTech` and the
/// turn processor validates legality before the choice settles).
///
/// Resets `research_progress` to 0 (matches `start_research`).
/// Returns the previously-researching tech id, if any.
pub fn set_researching_unchecked(&mut self, tech_id: &str) -> Option<String> {
let prev: Option<String> = self.researching.take();
if tech_id.is_empty() {
self.research_progress = 0;
return prev;
}
self.researching = Some(tech_id.to_string());
self.research_progress = 0;
prev
}
/// Begin researching a tech. Returns `Err` if prerequisites are not met
/// or the tech is already researched.
pub fn start_research(&mut self, tech_id: &str, web: &TechWeb) -> Result<(), TechError> {