feat(@projects/@magic-civilization): ✨ add tech research dispatch support
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
df9f5c362c
commit
d5c8375b6a
3 changed files with 242 additions and 14 deletions
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue