feat(@projects/@magic-civilization): 🏗️ p3-26 B3 (2/4) — improvement subsystem logic (build-tick + dispatch + yields)
De-stubs the headless improvement pipeline: - improvement_phase::process_improvement_build_phase — ticks pending_improvements down, completes at 0 → appends the improvement id to the owning city's city_improvements (so it yields). Registered in END_OF_TURN_PHASES (ecology, healing, improvement_build). - dispatch::build_improvement — replaces the stub: validates via the action gate, finds the worker's owning city (CityState.owned_tiles, else first city), queues a PendingImprovement with turns_remaining from improvement_defs[id].build_turns. (improvement_id, previously dropped by the dispatcher, is now plumbed through.) - apply_end_turn repopulates the fresh processor's improvement_yield_table from state.improvement_defs each turn, so process_improvement_yields actually folds food/ production into cities (was a no-op — table never loaded in real play). Tests: build-tick (3), dispatch queue (1), registry order. mc-turn 279/0, mc-player-api 136/0. Remaining (3/4): FFI + harness to boot improvement_defs from public/resources/improvements. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
cb451832e0
commit
f4e9d02115
4 changed files with 195 additions and 8 deletions
|
|
@ -125,13 +125,10 @@ pub fn apply_action(
|
|||
PlayerAction::FoundCity { unit_id } => {
|
||||
invoke_unit_action(state, unit_id, ActionKind::FoundCity)
|
||||
}
|
||||
PlayerAction::BuildImprovement { unit_id, .. } => {
|
||||
// Improvement id is captured on the wire but the underlying
|
||||
// `invoke()` handler reads it from per-unit context; passing
|
||||
// the action kind is sufficient at this layer. TRACKED: p2-67
|
||||
// Phase 1 follow-up will plumb the improvement_id through.
|
||||
invoke_unit_action(state, unit_id, ActionKind::BuildImprovement)
|
||||
}
|
||||
PlayerAction::BuildImprovement {
|
||||
unit_id,
|
||||
improvement_id,
|
||||
} => build_improvement(state, unit_id, &improvement_id.0),
|
||||
PlayerAction::PillageFriendly { unit_id } => {
|
||||
invoke_unit_action(state, unit_id, ActionKind::PillageFriendly)
|
||||
}
|
||||
|
|
@ -409,6 +406,18 @@ fn apply_end_turn(state: &mut GameState, player: PlayerId) -> Result<Vec<Event>,
|
|||
// single authored source; `load_authored_encounter_rates` bakes a
|
||||
// build-time copy for this headless path (no GDScript DataLoader).
|
||||
processor.load_authored_encounter_rates();
|
||||
// p3-26 B3: the fresh-per-turn processor starts with an empty improvement
|
||||
// yield table; repopulate it from the boot-loaded defs so completed
|
||||
// improvements fold food/production into their owning cities.
|
||||
for (id, def) in &state.improvement_defs {
|
||||
processor.improvement_yield_table.insert(
|
||||
id.clone(),
|
||||
mc_turn::processor::ImprovementYieldEntry {
|
||||
food: def.food,
|
||||
production: def.production,
|
||||
},
|
||||
);
|
||||
}
|
||||
// Load the boot-loaded TechWeb so `process_science` auto-advances
|
||||
// research (topological order) each turn. Without this the fresh
|
||||
// per-turn processor has `tech_web_parsed: None` and research is frozen
|
||||
|
|
@ -1478,6 +1487,57 @@ fn find_unit_indices(
|
|||
})
|
||||
}
|
||||
|
||||
/// p3-26 B3: begin a tile improvement on the worker's hex. Validates via the
|
||||
/// action-handler gate (worker-keyword / terrain), then queues a
|
||||
/// `PendingImprovement` credited to the owning city (the player's city whose
|
||||
/// `owned_tiles` contains the worker's hex, else the first city). The build-tick
|
||||
/// phase completes it after `build_turns` and folds the yields in. No-op if the
|
||||
/// player has no city to credit or the improvement id is unknown (build_turns
|
||||
/// defaults to 1).
|
||||
fn build_improvement(
|
||||
state: &mut GameState,
|
||||
unit_id: &str,
|
||||
improvement_id: &str,
|
||||
) -> Result<Vec<Event>, ActionError> {
|
||||
let unit_u32 = parse_unit_id(unit_id)?;
|
||||
let (player_idx, unit_idx) = find_unit_indices(state, unit_u32)?;
|
||||
action_handlers::invoke(state, player_idx, unit_idx, ActionKind::BuildImprovement).map_err(
|
||||
|e| ActionError::IllegalAction {
|
||||
message: format!("{e}"),
|
||||
},
|
||||
)?;
|
||||
let (col, row) = {
|
||||
let u = &state.players[player_idx].units[unit_idx];
|
||||
(u.col, u.row)
|
||||
};
|
||||
let turns = state
|
||||
.improvement_defs
|
||||
.get(improvement_id)
|
||||
.map(|d| d.build_turns)
|
||||
.unwrap_or(1)
|
||||
.max(1) as i32;
|
||||
let player = &mut state.players[player_idx];
|
||||
let city_idx = match player
|
||||
.cities
|
||||
.iter()
|
||||
.position(|c| c.owned_tiles.contains(&(col, row)))
|
||||
{
|
||||
Some(i) => i,
|
||||
None if !player.cities.is_empty() => 0,
|
||||
None => return Ok(Vec::new()),
|
||||
};
|
||||
player
|
||||
.pending_improvements
|
||||
.push(mc_state::game_state::PendingImprovement {
|
||||
col,
|
||||
row,
|
||||
improvement_id: improvement_id.to_string(),
|
||||
city_idx,
|
||||
turns_remaining: turns,
|
||||
});
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
fn invoke_unit_action(
|
||||
state: &mut GameState,
|
||||
unit_id: &str,
|
||||
|
|
@ -2188,6 +2248,32 @@ mod tests {
|
|||
state
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_improvement_queues_pending_on_owning_city() {
|
||||
use mc_city::CityState;
|
||||
use mc_state::game_state::ImprovementDef;
|
||||
let mut state = make_state_with_units(vec![(0, 1, 5, 5)]);
|
||||
let mut city = CityState::default();
|
||||
city.owned_tiles = vec![(5, 5)];
|
||||
state.players[0].cities.push(city);
|
||||
state.players[0].city_improvements = vec![vec![]];
|
||||
state.improvement_defs.insert(
|
||||
"farm".into(),
|
||||
ImprovementDef {
|
||||
build_turns: 3,
|
||||
food: 2,
|
||||
production: 0,
|
||||
},
|
||||
);
|
||||
|
||||
build_improvement(&mut state, "1", "farm").expect("build queues a pending improvement");
|
||||
let pending = &state.players[0].pending_improvements;
|
||||
assert_eq!(pending.len(), 1, "one pending improvement queued");
|
||||
assert_eq!(pending[0].improvement_id, "farm");
|
||||
assert_eq!(pending[0].city_idx, 0, "credited to the owning city");
|
||||
assert_eq!(pending[0].turns_remaining, 3, "turns_remaining from build_turns def");
|
||||
}
|
||||
|
||||
fn empty_state_with_one_unit(unit_id: u32) -> GameState {
|
||||
make_state_with_units(vec![(0, unit_id, 0, 0)])
|
||||
}
|
||||
|
|
|
|||
98
src/simulator/crates/mc-turn/src/improvement_phase.rs
Normal file
98
src/simulator/crates/mc-turn/src/improvement_phase.rs
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
//! p3-26 B3 — tile-improvement build-tick.
|
||||
//!
|
||||
//! Mirrors the live `TurnProcessorHelpersScript.process_improvements`: each
|
||||
//! `pending_improvement` ticks down `turns_remaining`; at 0 the improvement id is
|
||||
//! appended to its owning city's `city_improvements` list, so from the next turn
|
||||
//! `process_improvement_yields` folds its food/production into the city.
|
||||
//!
|
||||
//! Registered in [`crate::sim_phases::END_OF_TURN_PHASES`]. Runs end-of-turn, so
|
||||
//! an improvement completed this turn begins yielding next turn (the yield phase
|
||||
//! runs early in the per-player loop) — matching the live ordering.
|
||||
|
||||
use mc_state::game_state::GameState;
|
||||
|
||||
/// Advance every player's in-progress improvements by one turn and complete any
|
||||
/// that reach zero turns remaining. No-op when nothing is under construction.
|
||||
pub fn process_improvement_build_phase(state: &mut GameState) {
|
||||
for player in &mut state.players {
|
||||
if player.pending_improvements.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Decrement, collecting completed indices to remove afterward.
|
||||
let mut completed: Vec<usize> = Vec::new();
|
||||
for (i, pending) in player.pending_improvements.iter_mut().enumerate() {
|
||||
pending.turns_remaining -= 1;
|
||||
if pending.turns_remaining <= 0 {
|
||||
completed.push(i);
|
||||
}
|
||||
}
|
||||
// Remove high-index-first so earlier indices stay valid.
|
||||
for &i in completed.iter().rev() {
|
||||
let done = player.pending_improvements.remove(i);
|
||||
if done.city_idx >= player.city_improvements.len() {
|
||||
player
|
||||
.city_improvements
|
||||
.resize(done.city_idx + 1, Vec::new());
|
||||
}
|
||||
player.city_improvements[done.city_idx].push(done.improvement_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use mc_state::game_state::{PendingImprovement, PlayerState};
|
||||
|
||||
fn player_with_pending(turns: i32, city_idx: usize) -> PlayerState {
|
||||
PlayerState {
|
||||
city_improvements: vec![vec![]],
|
||||
pending_improvements: vec![PendingImprovement {
|
||||
col: 2,
|
||||
row: 3,
|
||||
improvement_id: "farm".into(),
|
||||
city_idx,
|
||||
turns_remaining: turns,
|
||||
}],
|
||||
..PlayerState::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn state_with(player: PlayerState) -> GameState {
|
||||
let mut s = GameState::default();
|
||||
s.players.push(player);
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_ticks_down_without_completing() {
|
||||
let mut s = state_with(player_with_pending(3, 0));
|
||||
process_improvement_build_phase(&mut s);
|
||||
assert_eq!(s.players[0].pending_improvements.len(), 1, "still building");
|
||||
assert_eq!(s.players[0].pending_improvements[0].turns_remaining, 2);
|
||||
assert!(s.players[0].city_improvements[0].is_empty(), "not yet placed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_completes_and_places_on_city() {
|
||||
let mut s = state_with(player_with_pending(1, 0));
|
||||
process_improvement_build_phase(&mut s);
|
||||
assert!(s.players[0].pending_improvements.is_empty(), "completed + removed");
|
||||
assert_eq!(
|
||||
s.players[0].city_improvements[0],
|
||||
vec!["farm".to_string()],
|
||||
"improvement appended to owning city"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completion_resizes_city_improvements_if_needed() {
|
||||
// city_idx 2 but city_improvements starts with 1 slot → resize.
|
||||
let mut p = player_with_pending(1, 2);
|
||||
p.city_improvements = vec![vec![]];
|
||||
let mut s = state_with(p);
|
||||
process_improvement_build_phase(&mut s);
|
||||
assert_eq!(s.players[0].city_improvements.len(), 3);
|
||||
assert_eq!(s.players[0].city_improvements[2], vec!["farm".to_string()]);
|
||||
}
|
||||
}
|
||||
|
|
@ -49,6 +49,8 @@ pub mod happiness_phase;
|
|||
pub mod healing;
|
||||
/// p3-27 — Per-turn ecology (fauna populations + flora succession) tick.
|
||||
pub mod ecology_phase;
|
||||
/// p3-26 B3 — tile-improvement build-tick.
|
||||
pub mod improvement_phase;
|
||||
/// End-of-turn world-simulation phase registry (ecology, healing, …).
|
||||
pub mod sim_phases;
|
||||
#[cfg(feature = "gpu")]
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ pub type SimPhaseFn = fn(&mut GameState);
|
|||
pub const END_OF_TURN_PHASES: &[(&str, SimPhaseFn)] = &[
|
||||
("ecology", crate::ecology_phase::process_ecology_phase),
|
||||
("healing", crate::healing::process_healing_phase),
|
||||
("improvement_build", crate::improvement_phase::process_improvement_build_phase),
|
||||
];
|
||||
|
||||
/// Run every registered end-of-turn phase in order.
|
||||
|
|
@ -40,7 +41,7 @@ mod tests {
|
|||
#[test]
|
||||
fn registry_lists_phases_in_documented_order() {
|
||||
let names: Vec<&str> = END_OF_TURN_PHASES.iter().map(|(n, _)| *n).collect();
|
||||
assert_eq!(names, vec!["ecology", "healing"]);
|
||||
assert_eq!(names, vec!["ecology", "healing", "improvement_build"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue