feat(mc-magic): Add core magic subsystem and Ascension victory path implementation

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-04-16 15:16:47 -07:00
parent f9a0e7b754
commit c9d396b8a1
2 changed files with 197 additions and 1 deletions

View file

@ -0,0 +1,184 @@
//! Arcane Ascension victory path — second victory condition alongside domination.
//!
//! Scaffold only: defines the requirement config (parsed from `ascension.json`),
//! the runtime state machine, and a pure detection function. Turn-processor
//! integration and UI wiring come in follow-up tasks.
use serde::{Deserialize, Serialize};
/// Ritual lifecycle. Transitions:
/// `Inactive -> RequirementsMet -> Channeling -> (Complete | Failed)`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AscensionPhase {
Inactive,
RequirementsMet,
Channeling,
Complete,
Failed,
}
/// Data-driven thresholds parsed from `ascension.json`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AscensionRequirements {
pub school_wonders_built: u32,
pub schools_maxed: u32,
pub mana_accumulated: u32,
pub high_archon_alive: bool,
#[serde(default)]
pub required_tech: String,
}
impl Default for AscensionRequirements {
fn default() -> Self {
Self {
school_wonders_built: 5,
schools_maxed: 5,
mana_accumulated: 500,
high_archon_alive: true,
required_tech: "arcane_ascension".to_string(),
}
}
}
/// Per-player ascension tracking. Mutated each turn once the ritual is active.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AscensionState {
pub phase: AscensionPhase,
pub wonder_count: u32,
pub schools_maxed: u32,
pub mana_accumulated: u32,
pub high_archon_alive: bool,
pub has_required_tech: bool,
pub channel_turns_remaining: u32,
}
impl Default for AscensionState {
fn default() -> Self {
Self {
phase: AscensionPhase::Inactive,
wonder_count: 0,
schools_maxed: 0,
mana_accumulated: 0,
high_archon_alive: false,
has_required_tech: false,
channel_turns_remaining: 0,
}
}
}
/// Pure check: do the current inputs satisfy the ritual requirements?
pub fn requirements_met(state: &AscensionState, req: &AscensionRequirements) -> bool {
state.wonder_count >= req.school_wonders_built
&& state.schools_maxed >= req.schools_maxed
&& state.mana_accumulated >= req.mana_accumulated
&& (!req.high_archon_alive || state.high_archon_alive)
&& (req.required_tech.is_empty() || state.has_required_tech)
}
/// Advance the phase based on current inputs. Returns the new phase.
/// Call once per turn during Phase 7 victory evaluation.
pub fn evaluate(state: &mut AscensionState, req: &AscensionRequirements) -> AscensionPhase {
match state.phase {
AscensionPhase::Inactive => {
if requirements_met(state, req) {
state.phase = AscensionPhase::RequirementsMet;
}
}
AscensionPhase::RequirementsMet => {
if !state.high_archon_alive && req.high_archon_alive {
state.phase = AscensionPhase::Failed;
}
}
AscensionPhase::Channeling => {
if !state.high_archon_alive && req.high_archon_alive {
state.phase = AscensionPhase::Failed;
} else if state.channel_turns_remaining == 0 {
state.phase = AscensionPhase::Complete;
} else {
state.channel_turns_remaining -= 1;
}
}
AscensionPhase::Complete | AscensionPhase::Failed => {}
}
state.phase
}
/// Short stable tag for the victory-event payload.
pub fn victory_type_str() -> &'static str {
"arcane_ascension"
}
#[cfg(test)]
mod tests {
use super::*;
fn maxed_state() -> AscensionState {
AscensionState {
phase: AscensionPhase::Inactive,
wonder_count: 5,
schools_maxed: 5,
mana_accumulated: 500,
high_archon_alive: true,
has_required_tech: true,
channel_turns_remaining: 0,
}
}
#[test]
fn default_requirements_parse_from_canonical_json() {
let raw = include_str!(
"../../../public/games/age-of-dwarves/data/ascension.json"
);
let v: serde_json::Value = serde_json::from_str(raw).expect("ascension.json parses");
let req: AscensionRequirements =
serde_json::from_value(v["requirements"].clone()).expect("requirements parse");
assert_eq!(req.school_wonders_built, 5);
assert_eq!(req.mana_accumulated, 500);
assert_eq!(req.required_tech, "arcane_ascension");
}
#[test]
fn requirements_not_met_when_any_input_short() {
let req = AscensionRequirements::default();
let mut state = maxed_state();
assert!(requirements_met(&state, &req));
state.mana_accumulated = 499;
assert!(!requirements_met(&state, &req));
state.mana_accumulated = 500;
state.high_archon_alive = false;
assert!(!requirements_met(&state, &req));
}
#[test]
fn evaluate_transitions_inactive_to_requirements_met() {
let req = AscensionRequirements::default();
let mut state = maxed_state();
assert_eq!(evaluate(&mut state, &req), AscensionPhase::RequirementsMet);
}
#[test]
fn channeling_counts_down_to_complete() {
let req = AscensionRequirements::default();
let mut state = maxed_state();
state.phase = AscensionPhase::Channeling;
state.channel_turns_remaining = 2;
assert_eq!(evaluate(&mut state, &req), AscensionPhase::Channeling);
assert_eq!(state.channel_turns_remaining, 1);
assert_eq!(evaluate(&mut state, &req), AscensionPhase::Channeling);
assert_eq!(state.channel_turns_remaining, 0);
assert_eq!(evaluate(&mut state, &req), AscensionPhase::Complete);
}
#[test]
fn high_archon_death_during_channel_fails_ritual() {
let req = AscensionRequirements::default();
let mut state = maxed_state();
state.phase = AscensionPhase::Channeling;
state.channel_turns_remaining = 5;
state.high_archon_alive = false;
assert_eq!(evaluate(&mut state, &req), AscensionPhase::Failed);
}
}

View file

@ -1 +1,13 @@
// TODO: mana economy, spells, Archons, enchantments, Ascension
//! Magic subsystem: mana economy, spells, Archons, enchantments, Ascension.
//!
//! Scaffold stage — only the Ascension victory path is materialised here.
//! Other modules (mana, spells, archons, enchantments) follow in subsequent
//! tasks per CLAUDE.md's atomic-porting protocol.
pub mod ascension;
pub use ascension::{
evaluate as evaluate_ascension, requirements_met as ascension_requirements_met,
victory_type_str as ascension_victory_type_str, AscensionPhase, AscensionRequirements,
AscensionState,
};