Expand runtime event graph service
This commit is contained in:
parent
049ffa6bd8
commit
6ebe5fffeb
14 changed files with 1803 additions and 254 deletions
|
|
@ -29,7 +29,8 @@ pub use pk4::{
|
|||
extract_pk4_entry_bytes, extract_pk4_entry_file, inspect_pk4_bytes, inspect_pk4_file,
|
||||
};
|
||||
pub use runtime::{
|
||||
RuntimeCompany, RuntimeEventRecord, RuntimeSaveProfileState, RuntimeServiceState, RuntimeState,
|
||||
RuntimeCompany, RuntimeCompanyTarget, RuntimeEffect, RuntimeEventRecord,
|
||||
RuntimeEventRecordTemplate, RuntimeSaveProfileState, RuntimeServiceState, RuntimeState,
|
||||
RuntimeWorldRestoreState,
|
||||
};
|
||||
pub use smp::{
|
||||
|
|
|
|||
|
|
@ -11,6 +11,63 @@ pub struct RuntimeCompany {
|
|||
pub debt: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum RuntimeCompanyTarget {
|
||||
AllActive,
|
||||
Ids { ids: Vec<u32> },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum RuntimeEffect {
|
||||
SetWorldFlag {
|
||||
key: String,
|
||||
value: bool,
|
||||
},
|
||||
AdjustCompanyCash {
|
||||
target: RuntimeCompanyTarget,
|
||||
delta: i64,
|
||||
},
|
||||
AdjustCompanyDebt {
|
||||
target: RuntimeCompanyTarget,
|
||||
delta: i64,
|
||||
},
|
||||
SetCandidateAvailability {
|
||||
name: String,
|
||||
value: u32,
|
||||
},
|
||||
SetSpecialCondition {
|
||||
label: String,
|
||||
value: u32,
|
||||
},
|
||||
AppendEventRecord {
|
||||
record: Box<RuntimeEventRecordTemplate>,
|
||||
},
|
||||
ActivateEventRecord {
|
||||
record_id: u32,
|
||||
},
|
||||
DeactivateEventRecord {
|
||||
record_id: u32,
|
||||
},
|
||||
RemoveEventRecord {
|
||||
record_id: u32,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RuntimeEventRecordTemplate {
|
||||
pub record_id: u32,
|
||||
pub trigger_kind: u8,
|
||||
pub active: bool,
|
||||
#[serde(default)]
|
||||
pub marks_collection_dirty: bool,
|
||||
#[serde(default)]
|
||||
pub one_shot: bool,
|
||||
#[serde(default)]
|
||||
pub effects: Vec<RuntimeEffect>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RuntimeEventRecord {
|
||||
pub record_id: u32,
|
||||
|
|
@ -20,6 +77,27 @@ pub struct RuntimeEventRecord {
|
|||
pub service_count: u32,
|
||||
#[serde(default)]
|
||||
pub marks_collection_dirty: bool,
|
||||
#[serde(default)]
|
||||
pub one_shot: bool,
|
||||
#[serde(default)]
|
||||
pub has_fired: bool,
|
||||
#[serde(default)]
|
||||
pub effects: Vec<RuntimeEffect>,
|
||||
}
|
||||
|
||||
impl RuntimeEventRecordTemplate {
|
||||
pub fn into_runtime_record(self) -> RuntimeEventRecord {
|
||||
RuntimeEventRecord {
|
||||
record_id: self.record_id,
|
||||
trigger_kind: self.trigger_kind,
|
||||
active: self.active,
|
||||
service_count: 0,
|
||||
marks_collection_dirty: self.marks_collection_dirty,
|
||||
one_shot: self.one_shot,
|
||||
has_fired: false,
|
||||
effects: self.effects,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
|
|
@ -131,6 +209,14 @@ impl RuntimeState {
|
|||
if !seen_record_ids.insert(record.record_id) {
|
||||
return Err(format!("duplicate record_id {}", record.record_id));
|
||||
}
|
||||
for (effect_index, effect) in record.effects.iter().enumerate() {
|
||||
validate_runtime_effect(effect, &seen_company_ids).map_err(|err| {
|
||||
format!(
|
||||
"event_runtime_records[record_id={}].effects[{effect_index}] {err}",
|
||||
record.record_id
|
||||
)
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
for key in self.world_flags.keys() {
|
||||
|
|
@ -216,6 +302,77 @@ impl RuntimeState {
|
|||
}
|
||||
}
|
||||
|
||||
fn validate_runtime_effect(
|
||||
effect: &RuntimeEffect,
|
||||
valid_company_ids: &BTreeSet<u32>,
|
||||
) -> Result<(), String> {
|
||||
match effect {
|
||||
RuntimeEffect::SetWorldFlag { key, .. } => {
|
||||
if key.trim().is_empty() {
|
||||
return Err("key must not be empty".to_string());
|
||||
}
|
||||
}
|
||||
RuntimeEffect::AdjustCompanyCash { target, .. }
|
||||
| RuntimeEffect::AdjustCompanyDebt { target, .. } => {
|
||||
validate_company_target(target, valid_company_ids)?;
|
||||
}
|
||||
RuntimeEffect::SetCandidateAvailability { name, .. } => {
|
||||
if name.trim().is_empty() {
|
||||
return Err("name must not be empty".to_string());
|
||||
}
|
||||
}
|
||||
RuntimeEffect::SetSpecialCondition { label, .. } => {
|
||||
if label.trim().is_empty() {
|
||||
return Err("label must not be empty".to_string());
|
||||
}
|
||||
}
|
||||
RuntimeEffect::AppendEventRecord { record } => {
|
||||
validate_event_record_template(record, valid_company_ids)?;
|
||||
}
|
||||
RuntimeEffect::ActivateEventRecord { .. }
|
||||
| RuntimeEffect::DeactivateEventRecord { .. }
|
||||
| RuntimeEffect::RemoveEventRecord { .. } => {}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_event_record_template(
|
||||
record: &RuntimeEventRecordTemplate,
|
||||
valid_company_ids: &BTreeSet<u32>,
|
||||
) -> Result<(), String> {
|
||||
for (effect_index, effect) in record.effects.iter().enumerate() {
|
||||
validate_runtime_effect(effect, valid_company_ids).map_err(|err| {
|
||||
format!(
|
||||
"template record_id={}.effects[{effect_index}] {err}",
|
||||
record.record_id
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_company_target(
|
||||
target: &RuntimeCompanyTarget,
|
||||
valid_company_ids: &BTreeSet<u32>,
|
||||
) -> Result<(), String> {
|
||||
match target {
|
||||
RuntimeCompanyTarget::AllActive => Ok(()),
|
||||
RuntimeCompanyTarget::Ids { ids } => {
|
||||
if ids.is_empty() {
|
||||
return Err("target ids must not be empty".to_string());
|
||||
}
|
||||
for company_id in ids {
|
||||
if !valid_company_ids.contains(company_id) {
|
||||
return Err(format!("target references unknown company_id {company_id}"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -298,4 +455,91 @@ mod tests {
|
|||
|
||||
assert!(state.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_event_effect_targeting_unknown_company() {
|
||||
let state = RuntimeState {
|
||||
calendar: CalendarPoint {
|
||||
year: 1830,
|
||||
month_slot: 0,
|
||||
phase_slot: 0,
|
||||
tick_slot: 0,
|
||||
},
|
||||
world_flags: BTreeMap::new(),
|
||||
save_profile: RuntimeSaveProfileState::default(),
|
||||
world_restore: RuntimeWorldRestoreState::default(),
|
||||
metadata: BTreeMap::new(),
|
||||
companies: vec![RuntimeCompany {
|
||||
company_id: 1,
|
||||
current_cash: 100,
|
||||
debt: 0,
|
||||
}],
|
||||
event_runtime_records: vec![RuntimeEventRecord {
|
||||
record_id: 7,
|
||||
trigger_kind: 1,
|
||||
active: true,
|
||||
service_count: 0,
|
||||
marks_collection_dirty: false,
|
||||
one_shot: false,
|
||||
has_fired: false,
|
||||
effects: vec![RuntimeEffect::AdjustCompanyCash {
|
||||
target: RuntimeCompanyTarget::Ids { ids: vec![2] },
|
||||
delta: 50,
|
||||
}],
|
||||
}],
|
||||
candidate_availability: BTreeMap::new(),
|
||||
special_conditions: BTreeMap::new(),
|
||||
service_state: RuntimeServiceState::default(),
|
||||
};
|
||||
|
||||
assert!(state.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_template_effect_targeting_unknown_company() {
|
||||
let state = RuntimeState {
|
||||
calendar: CalendarPoint {
|
||||
year: 1830,
|
||||
month_slot: 0,
|
||||
phase_slot: 0,
|
||||
tick_slot: 0,
|
||||
},
|
||||
world_flags: BTreeMap::new(),
|
||||
save_profile: RuntimeSaveProfileState::default(),
|
||||
world_restore: RuntimeWorldRestoreState::default(),
|
||||
metadata: BTreeMap::new(),
|
||||
companies: vec![RuntimeCompany {
|
||||
company_id: 1,
|
||||
current_cash: 100,
|
||||
debt: 0,
|
||||
}],
|
||||
event_runtime_records: vec![RuntimeEventRecord {
|
||||
record_id: 7,
|
||||
trigger_kind: 1,
|
||||
active: true,
|
||||
service_count: 0,
|
||||
marks_collection_dirty: false,
|
||||
one_shot: false,
|
||||
has_fired: false,
|
||||
effects: vec![RuntimeEffect::AppendEventRecord {
|
||||
record: Box::new(RuntimeEventRecordTemplate {
|
||||
record_id: 8,
|
||||
trigger_kind: 0x0a,
|
||||
active: true,
|
||||
marks_collection_dirty: false,
|
||||
one_shot: false,
|
||||
effects: vec![RuntimeEffect::AdjustCompanyCash {
|
||||
target: RuntimeCompanyTarget::Ids { ids: vec![2] },
|
||||
delta: 50,
|
||||
}],
|
||||
}),
|
||||
}],
|
||||
}],
|
||||
candidate_availability: BTreeMap::new(),
|
||||
special_conditions: BTreeMap::new(),
|
||||
service_state: RuntimeServiceState::default(),
|
||||
};
|
||||
|
||||
assert!(state.validate().is_err());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
use std::collections::BTreeSet;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{RuntimeState, RuntimeSummary, calendar::BoundaryEventKind};
|
||||
use crate::{
|
||||
RuntimeCompanyTarget, RuntimeEffect, RuntimeEventRecordTemplate, RuntimeState, RuntimeSummary,
|
||||
calendar::BoundaryEventKind,
|
||||
};
|
||||
|
||||
const PERIODIC_TRIGGER_KIND_ORDER: [u8; 6] = [1, 0, 3, 2, 5, 4];
|
||||
|
||||
|
|
@ -39,6 +44,12 @@ pub struct ServiceEvent {
|
|||
pub kind: String,
|
||||
pub trigger_kind: Option<u8>,
|
||||
pub serviced_record_ids: Vec<u32>,
|
||||
pub applied_effect_count: u32,
|
||||
pub mutated_company_ids: Vec<u32>,
|
||||
pub appended_record_ids: Vec<u32>,
|
||||
pub activated_record_ids: Vec<u32>,
|
||||
pub deactivated_record_ids: Vec<u32>,
|
||||
pub removed_record_ids: Vec<u32>,
|
||||
pub dirty_rerun: bool,
|
||||
}
|
||||
|
||||
|
|
@ -51,6 +62,23 @@ pub struct StepResult {
|
|||
pub service_events: Vec<ServiceEvent>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum EventGraphMutation {
|
||||
Append(RuntimeEventRecordTemplate),
|
||||
Activate { record_id: u32 },
|
||||
Deactivate { record_id: u32 },
|
||||
Remove { record_id: u32 },
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct AppliedEffectsSummary {
|
||||
applied_effect_count: u32,
|
||||
appended_record_ids: Vec<u32>,
|
||||
activated_record_ids: Vec<u32>,
|
||||
deactivated_record_ids: Vec<u32>,
|
||||
removed_record_ids: Vec<u32>,
|
||||
}
|
||||
|
||||
pub fn execute_step_command(
|
||||
state: &mut RuntimeState,
|
||||
command: &StepCommand,
|
||||
|
|
@ -67,11 +95,11 @@ pub fn execute_step_command(
|
|||
}
|
||||
StepCommand::StepCount { steps } => step_count(state, *steps, &mut boundary_events),
|
||||
StepCommand::ServiceTriggerKind { trigger_kind } => {
|
||||
service_trigger_kind(state, *trigger_kind, &mut service_events);
|
||||
service_trigger_kind(state, *trigger_kind, &mut service_events)?;
|
||||
0
|
||||
}
|
||||
StepCommand::ServicePeriodicBoundary => {
|
||||
service_periodic_boundary(state, &mut service_events);
|
||||
service_periodic_boundary(state, &mut service_events)?;
|
||||
0
|
||||
}
|
||||
};
|
||||
|
|
@ -137,20 +165,44 @@ fn boundary_kind_label(boundary: BoundaryEventKind) -> &'static str {
|
|||
}
|
||||
}
|
||||
|
||||
fn service_periodic_boundary(state: &mut RuntimeState, service_events: &mut Vec<ServiceEvent>) {
|
||||
fn service_periodic_boundary(
|
||||
state: &mut RuntimeState,
|
||||
service_events: &mut Vec<ServiceEvent>,
|
||||
) -> Result<(), String> {
|
||||
state.service_state.periodic_boundary_calls += 1;
|
||||
|
||||
for trigger_kind in PERIODIC_TRIGGER_KIND_ORDER {
|
||||
service_trigger_kind(state, trigger_kind, service_events);
|
||||
service_trigger_kind(state, trigger_kind, service_events)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn service_trigger_kind(
|
||||
state: &mut RuntimeState,
|
||||
trigger_kind: u8,
|
||||
service_events: &mut Vec<ServiceEvent>,
|
||||
) {
|
||||
) -> Result<(), String> {
|
||||
let eligible_indices = state
|
||||
.event_runtime_records
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, record)| {
|
||||
record.active
|
||||
&& record.trigger_kind == trigger_kind
|
||||
&& !(record.one_shot && record.has_fired)
|
||||
})
|
||||
.map(|(index, _)| index)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut serviced_record_ids = Vec::new();
|
||||
let mut applied_effect_count = 0_u32;
|
||||
let mut mutated_company_ids = BTreeSet::new();
|
||||
let mut appended_record_ids = Vec::new();
|
||||
let mut activated_record_ids = Vec::new();
|
||||
let mut deactivated_record_ids = Vec::new();
|
||||
let mut removed_record_ids = Vec::new();
|
||||
let mut staged_event_graph_mutations = Vec::new();
|
||||
let mut dirty_rerun = false;
|
||||
|
||||
*state
|
||||
|
|
@ -159,27 +211,237 @@ fn service_trigger_kind(
|
|||
.entry(trigger_kind)
|
||||
.or_insert(0) += 1;
|
||||
|
||||
for record in &mut state.event_runtime_records {
|
||||
if record.active && record.trigger_kind == trigger_kind {
|
||||
for index in eligible_indices {
|
||||
let (record_id, record_effects, record_marks_collection_dirty, record_one_shot) = {
|
||||
let record = &state.event_runtime_records[index];
|
||||
(
|
||||
record.record_id,
|
||||
record.effects.clone(),
|
||||
record.marks_collection_dirty,
|
||||
record.one_shot,
|
||||
)
|
||||
};
|
||||
|
||||
let effect_summary = apply_runtime_effects(
|
||||
state,
|
||||
&record_effects,
|
||||
&mut mutated_company_ids,
|
||||
&mut staged_event_graph_mutations,
|
||||
)?;
|
||||
applied_effect_count += effect_summary.applied_effect_count;
|
||||
appended_record_ids.extend(effect_summary.appended_record_ids);
|
||||
activated_record_ids.extend(effect_summary.activated_record_ids);
|
||||
deactivated_record_ids.extend(effect_summary.deactivated_record_ids);
|
||||
removed_record_ids.extend(effect_summary.removed_record_ids);
|
||||
|
||||
{
|
||||
let record = &mut state.event_runtime_records[index];
|
||||
record.service_count += 1;
|
||||
serviced_record_ids.push(record.record_id);
|
||||
state.service_state.total_event_record_services += 1;
|
||||
if trigger_kind != 0x0a && record.marks_collection_dirty {
|
||||
dirty_rerun = true;
|
||||
if record_one_shot {
|
||||
record.has_fired = true;
|
||||
}
|
||||
}
|
||||
|
||||
serviced_record_ids.push(record_id);
|
||||
state.service_state.total_event_record_services += 1;
|
||||
if trigger_kind != 0x0a && record_marks_collection_dirty {
|
||||
dirty_rerun = true;
|
||||
}
|
||||
}
|
||||
|
||||
commit_staged_event_graph_mutations(state, &staged_event_graph_mutations)?;
|
||||
|
||||
service_events.push(ServiceEvent {
|
||||
kind: "trigger_dispatch".to_string(),
|
||||
trigger_kind: Some(trigger_kind),
|
||||
serviced_record_ids,
|
||||
applied_effect_count,
|
||||
mutated_company_ids: mutated_company_ids.into_iter().collect(),
|
||||
appended_record_ids,
|
||||
activated_record_ids,
|
||||
deactivated_record_ids,
|
||||
removed_record_ids,
|
||||
dirty_rerun,
|
||||
});
|
||||
|
||||
if dirty_rerun {
|
||||
state.service_state.dirty_rerun_count += 1;
|
||||
service_trigger_kind(state, 0x0a, service_events);
|
||||
service_trigger_kind(state, 0x0a, service_events)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_runtime_effects(
|
||||
state: &mut RuntimeState,
|
||||
effects: &[RuntimeEffect],
|
||||
mutated_company_ids: &mut BTreeSet<u32>,
|
||||
staged_event_graph_mutations: &mut Vec<EventGraphMutation>,
|
||||
) -> Result<AppliedEffectsSummary, String> {
|
||||
let mut summary = AppliedEffectsSummary::default();
|
||||
|
||||
for effect in effects {
|
||||
match effect {
|
||||
RuntimeEffect::SetWorldFlag { key, value } => {
|
||||
state.world_flags.insert(key.clone(), *value);
|
||||
}
|
||||
RuntimeEffect::AdjustCompanyCash { target, delta } => {
|
||||
let company_ids = resolve_company_target_ids(state, target)?;
|
||||
for company_id in company_ids {
|
||||
let company = state
|
||||
.companies
|
||||
.iter_mut()
|
||||
.find(|company| company.company_id == company_id)
|
||||
.ok_or_else(|| {
|
||||
format!("missing company_id {company_id} while applying cash effect")
|
||||
})?;
|
||||
company.current_cash =
|
||||
company.current_cash.checked_add(*delta).ok_or_else(|| {
|
||||
format!("company_id {company_id} cash adjustment overflow")
|
||||
})?;
|
||||
mutated_company_ids.insert(company_id);
|
||||
}
|
||||
}
|
||||
RuntimeEffect::AdjustCompanyDebt { target, delta } => {
|
||||
let company_ids = resolve_company_target_ids(state, target)?;
|
||||
for company_id in company_ids {
|
||||
let company = state
|
||||
.companies
|
||||
.iter_mut()
|
||||
.find(|company| company.company_id == company_id)
|
||||
.ok_or_else(|| {
|
||||
format!("missing company_id {company_id} while applying debt effect")
|
||||
})?;
|
||||
company.debt = apply_u64_delta(company.debt, *delta, company_id)?;
|
||||
mutated_company_ids.insert(company_id);
|
||||
}
|
||||
}
|
||||
RuntimeEffect::SetCandidateAvailability { name, value } => {
|
||||
state.candidate_availability.insert(name.clone(), *value);
|
||||
}
|
||||
RuntimeEffect::SetSpecialCondition { label, value } => {
|
||||
state.special_conditions.insert(label.clone(), *value);
|
||||
}
|
||||
RuntimeEffect::AppendEventRecord { record } => {
|
||||
staged_event_graph_mutations.push(EventGraphMutation::Append((**record).clone()));
|
||||
summary.appended_record_ids.push(record.record_id);
|
||||
}
|
||||
RuntimeEffect::ActivateEventRecord { record_id } => {
|
||||
staged_event_graph_mutations.push(EventGraphMutation::Activate {
|
||||
record_id: *record_id,
|
||||
});
|
||||
summary.activated_record_ids.push(*record_id);
|
||||
}
|
||||
RuntimeEffect::DeactivateEventRecord { record_id } => {
|
||||
staged_event_graph_mutations.push(EventGraphMutation::Deactivate {
|
||||
record_id: *record_id,
|
||||
});
|
||||
summary.deactivated_record_ids.push(*record_id);
|
||||
}
|
||||
RuntimeEffect::RemoveEventRecord { record_id } => {
|
||||
staged_event_graph_mutations.push(EventGraphMutation::Remove {
|
||||
record_id: *record_id,
|
||||
});
|
||||
summary.removed_record_ids.push(*record_id);
|
||||
}
|
||||
}
|
||||
|
||||
summary.applied_effect_count += 1;
|
||||
}
|
||||
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
fn commit_staged_event_graph_mutations(
|
||||
state: &mut RuntimeState,
|
||||
staged_event_graph_mutations: &[EventGraphMutation],
|
||||
) -> Result<(), String> {
|
||||
for mutation in staged_event_graph_mutations {
|
||||
match mutation {
|
||||
EventGraphMutation::Append(record) => {
|
||||
if state
|
||||
.event_runtime_records
|
||||
.iter()
|
||||
.any(|existing| existing.record_id == record.record_id)
|
||||
{
|
||||
return Err(format!(
|
||||
"cannot append duplicate event record_id {}",
|
||||
record.record_id
|
||||
));
|
||||
}
|
||||
state
|
||||
.event_runtime_records
|
||||
.push(record.clone().into_runtime_record());
|
||||
}
|
||||
EventGraphMutation::Activate { record_id } => {
|
||||
let record = state
|
||||
.event_runtime_records
|
||||
.iter_mut()
|
||||
.find(|record| record.record_id == *record_id)
|
||||
.ok_or_else(|| {
|
||||
format!("cannot activate missing event record_id {record_id}")
|
||||
})?;
|
||||
record.active = true;
|
||||
}
|
||||
EventGraphMutation::Deactivate { record_id } => {
|
||||
let record = state
|
||||
.event_runtime_records
|
||||
.iter_mut()
|
||||
.find(|record| record.record_id == *record_id)
|
||||
.ok_or_else(|| {
|
||||
format!("cannot deactivate missing event record_id {record_id}")
|
||||
})?;
|
||||
record.active = false;
|
||||
}
|
||||
EventGraphMutation::Remove { record_id } => {
|
||||
let index = state
|
||||
.event_runtime_records
|
||||
.iter()
|
||||
.position(|record| record.record_id == *record_id)
|
||||
.ok_or_else(|| format!("cannot remove missing event record_id {record_id}"))?;
|
||||
state.event_runtime_records.remove(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state.validate()
|
||||
}
|
||||
|
||||
fn resolve_company_target_ids(
|
||||
state: &RuntimeState,
|
||||
target: &RuntimeCompanyTarget,
|
||||
) -> Result<Vec<u32>, String> {
|
||||
match target {
|
||||
RuntimeCompanyTarget::AllActive => Ok(state
|
||||
.companies
|
||||
.iter()
|
||||
.map(|company| company.company_id)
|
||||
.collect()),
|
||||
RuntimeCompanyTarget::Ids { ids } => {
|
||||
let known_ids = state
|
||||
.companies
|
||||
.iter()
|
||||
.map(|company| company.company_id)
|
||||
.collect::<BTreeSet<_>>();
|
||||
for company_id in ids {
|
||||
if !known_ids.contains(company_id) {
|
||||
return Err(format!("target references unknown company_id {company_id}"));
|
||||
}
|
||||
}
|
||||
Ok(ids.clone())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_u64_delta(current: u64, delta: i64, company_id: u32) -> Result<u64, String> {
|
||||
if delta >= 0 {
|
||||
current
|
||||
.checked_add(delta as u64)
|
||||
.ok_or_else(|| format!("company_id {company_id} debt adjustment overflow"))
|
||||
} else {
|
||||
current
|
||||
.checked_sub(delta.unsigned_abs())
|
||||
.ok_or_else(|| format!("company_id {company_id} debt adjustment underflow"))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -189,8 +451,9 @@ mod tests {
|
|||
|
||||
use super::*;
|
||||
use crate::{
|
||||
CalendarPoint, RuntimeCompany, RuntimeEventRecord, RuntimeSaveProfileState,
|
||||
RuntimeServiceState, RuntimeWorldRestoreState,
|
||||
CalendarPoint, RuntimeCompany, RuntimeCompanyTarget, RuntimeEffect, RuntimeEventRecord,
|
||||
RuntimeEventRecordTemplate, RuntimeSaveProfileState, RuntimeServiceState,
|
||||
RuntimeWorldRestoreState,
|
||||
};
|
||||
|
||||
fn state() -> RuntimeState {
|
||||
|
|
@ -267,6 +530,12 @@ mod tests {
|
|||
active: true,
|
||||
service_count: 0,
|
||||
marks_collection_dirty: true,
|
||||
one_shot: false,
|
||||
has_fired: false,
|
||||
effects: vec![RuntimeEffect::SetWorldFlag {
|
||||
key: "runtime.effect_fired".to_string(),
|
||||
value: true,
|
||||
}],
|
||||
},
|
||||
RuntimeEventRecord {
|
||||
record_id: 2,
|
||||
|
|
@ -274,6 +543,12 @@ mod tests {
|
|||
active: true,
|
||||
service_count: 0,
|
||||
marks_collection_dirty: false,
|
||||
one_shot: false,
|
||||
has_fired: false,
|
||||
effects: vec![RuntimeEffect::AdjustCompanyCash {
|
||||
target: RuntimeCompanyTarget::AllActive,
|
||||
delta: 5,
|
||||
}],
|
||||
},
|
||||
RuntimeEventRecord {
|
||||
record_id: 3,
|
||||
|
|
@ -281,6 +556,12 @@ mod tests {
|
|||
active: true,
|
||||
service_count: 0,
|
||||
marks_collection_dirty: false,
|
||||
one_shot: false,
|
||||
has_fired: false,
|
||||
effects: vec![RuntimeEffect::SetSpecialCondition {
|
||||
label: "Dirty rerun fired".to_string(),
|
||||
value: 1,
|
||||
}],
|
||||
},
|
||||
],
|
||||
..state()
|
||||
|
|
@ -296,6 +577,9 @@ mod tests {
|
|||
assert_eq!(state.event_runtime_records[0].service_count, 1);
|
||||
assert_eq!(state.event_runtime_records[1].service_count, 1);
|
||||
assert_eq!(state.event_runtime_records[2].service_count, 1);
|
||||
assert_eq!(state.world_flags.get("runtime.effect_fired"), Some(&true));
|
||||
assert_eq!(state.companies[0].current_cash, 15);
|
||||
assert_eq!(state.special_conditions.get("Dirty rerun fired"), Some(&1));
|
||||
assert_eq!(
|
||||
state.service_state.trigger_dispatch_counts.get(&1),
|
||||
Some(&1)
|
||||
|
|
@ -308,5 +592,444 @@ mod tests {
|
|||
state.service_state.trigger_dispatch_counts.get(&0x0a),
|
||||
Some(&1)
|
||||
);
|
||||
assert_eq!(result.service_events.len(), 7);
|
||||
assert_eq!(result.service_events[0].applied_effect_count, 1);
|
||||
assert_eq!(
|
||||
result
|
||||
.service_events
|
||||
.iter()
|
||||
.find(|event| event.trigger_kind == Some(4))
|
||||
.expect("trigger kind 4 event should be present")
|
||||
.applied_effect_count,
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
result
|
||||
.service_events
|
||||
.iter()
|
||||
.find(|event| event.trigger_kind == Some(0x0a))
|
||||
.expect("trigger kind 0x0a event should be present")
|
||||
.applied_effect_count,
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
result
|
||||
.service_events
|
||||
.iter()
|
||||
.find(|event| event.trigger_kind == Some(4))
|
||||
.expect("trigger kind 4 event should be present")
|
||||
.mutated_company_ids,
|
||||
vec![1]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applies_company_effects_for_specific_targets() {
|
||||
let mut state = RuntimeState {
|
||||
companies: vec![
|
||||
RuntimeCompany {
|
||||
company_id: 1,
|
||||
current_cash: 10,
|
||||
debt: 5,
|
||||
},
|
||||
RuntimeCompany {
|
||||
company_id: 2,
|
||||
current_cash: 20,
|
||||
debt: 8,
|
||||
},
|
||||
],
|
||||
event_runtime_records: vec![RuntimeEventRecord {
|
||||
record_id: 10,
|
||||
trigger_kind: 7,
|
||||
active: true,
|
||||
service_count: 0,
|
||||
marks_collection_dirty: false,
|
||||
one_shot: false,
|
||||
has_fired: false,
|
||||
effects: vec![
|
||||
RuntimeEffect::AdjustCompanyCash {
|
||||
target: RuntimeCompanyTarget::Ids { ids: vec![2] },
|
||||
delta: 4,
|
||||
},
|
||||
RuntimeEffect::AdjustCompanyDebt {
|
||||
target: RuntimeCompanyTarget::Ids { ids: vec![2] },
|
||||
delta: -3,
|
||||
},
|
||||
],
|
||||
}],
|
||||
..state()
|
||||
};
|
||||
|
||||
let result = execute_step_command(
|
||||
&mut state,
|
||||
&StepCommand::ServiceTriggerKind { trigger_kind: 7 },
|
||||
)
|
||||
.expect("targeted company effects should succeed");
|
||||
|
||||
assert_eq!(state.companies[0].current_cash, 10);
|
||||
assert_eq!(state.companies[1].current_cash, 24);
|
||||
assert_eq!(state.companies[1].debt, 5);
|
||||
assert_eq!(result.service_events[0].applied_effect_count, 2);
|
||||
assert_eq!(result.service_events[0].mutated_company_ids, vec![2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_shot_record_only_fires_once() {
|
||||
let mut state = RuntimeState {
|
||||
event_runtime_records: vec![RuntimeEventRecord {
|
||||
record_id: 20,
|
||||
trigger_kind: 2,
|
||||
active: true,
|
||||
service_count: 0,
|
||||
marks_collection_dirty: false,
|
||||
one_shot: true,
|
||||
has_fired: false,
|
||||
effects: vec![RuntimeEffect::SetWorldFlag {
|
||||
key: "one_shot".to_string(),
|
||||
value: true,
|
||||
}],
|
||||
}],
|
||||
..state()
|
||||
};
|
||||
|
||||
execute_step_command(
|
||||
&mut state,
|
||||
&StepCommand::ServiceTriggerKind { trigger_kind: 2 },
|
||||
)
|
||||
.expect("first one-shot service should succeed");
|
||||
let second = execute_step_command(
|
||||
&mut state,
|
||||
&StepCommand::ServiceTriggerKind { trigger_kind: 2 },
|
||||
)
|
||||
.expect("second one-shot service should succeed");
|
||||
|
||||
assert_eq!(state.event_runtime_records[0].service_count, 1);
|
||||
assert!(state.event_runtime_records[0].has_fired);
|
||||
assert_eq!(
|
||||
second.service_events[0].serviced_record_ids,
|
||||
Vec::<u32>::new()
|
||||
);
|
||||
assert_eq!(second.service_events[0].applied_effect_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_debt_underflow() {
|
||||
let mut state = RuntimeState {
|
||||
companies: vec![RuntimeCompany {
|
||||
company_id: 1,
|
||||
current_cash: 10,
|
||||
debt: 2,
|
||||
}],
|
||||
event_runtime_records: vec![RuntimeEventRecord {
|
||||
record_id: 30,
|
||||
trigger_kind: 3,
|
||||
active: true,
|
||||
service_count: 0,
|
||||
marks_collection_dirty: false,
|
||||
one_shot: false,
|
||||
has_fired: false,
|
||||
effects: vec![RuntimeEffect::AdjustCompanyDebt {
|
||||
target: RuntimeCompanyTarget::AllActive,
|
||||
delta: -3,
|
||||
}],
|
||||
}],
|
||||
..state()
|
||||
};
|
||||
|
||||
let result = execute_step_command(
|
||||
&mut state,
|
||||
&StepCommand::ServiceTriggerKind { trigger_kind: 3 },
|
||||
);
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appended_record_waits_until_later_pass_without_dirty_rerun() {
|
||||
let mut state = RuntimeState {
|
||||
event_runtime_records: vec![RuntimeEventRecord {
|
||||
record_id: 40,
|
||||
trigger_kind: 5,
|
||||
active: true,
|
||||
service_count: 0,
|
||||
marks_collection_dirty: false,
|
||||
one_shot: true,
|
||||
has_fired: false,
|
||||
effects: vec![RuntimeEffect::AppendEventRecord {
|
||||
record: Box::new(RuntimeEventRecordTemplate {
|
||||
record_id: 41,
|
||||
trigger_kind: 5,
|
||||
active: true,
|
||||
marks_collection_dirty: false,
|
||||
one_shot: false,
|
||||
effects: vec![RuntimeEffect::SetWorldFlag {
|
||||
key: "follow_on_later_pass".to_string(),
|
||||
value: true,
|
||||
}],
|
||||
}),
|
||||
}],
|
||||
}],
|
||||
..state()
|
||||
};
|
||||
|
||||
let first = execute_step_command(
|
||||
&mut state,
|
||||
&StepCommand::ServiceTriggerKind { trigger_kind: 5 },
|
||||
)
|
||||
.expect("first pass should succeed");
|
||||
|
||||
assert_eq!(first.service_events.len(), 1);
|
||||
assert_eq!(first.service_events[0].serviced_record_ids, vec![40]);
|
||||
assert_eq!(first.service_events[0].appended_record_ids, vec![41]);
|
||||
assert_eq!(state.world_flags.get("follow_on_later_pass"), None);
|
||||
assert_eq!(state.event_runtime_records.len(), 2);
|
||||
assert_eq!(state.event_runtime_records[1].service_count, 0);
|
||||
|
||||
let second = execute_step_command(
|
||||
&mut state,
|
||||
&StepCommand::ServiceTriggerKind { trigger_kind: 5 },
|
||||
)
|
||||
.expect("second pass should succeed");
|
||||
|
||||
assert_eq!(second.service_events[0].serviced_record_ids, vec![41]);
|
||||
assert_eq!(state.world_flags.get("follow_on_later_pass"), Some(&true));
|
||||
assert!(state.event_runtime_records[0].has_fired);
|
||||
assert_eq!(state.event_runtime_records[1].service_count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appended_record_runs_in_dirty_rerun_after_commit() {
|
||||
let mut state = RuntimeState {
|
||||
event_runtime_records: vec![RuntimeEventRecord {
|
||||
record_id: 50,
|
||||
trigger_kind: 1,
|
||||
active: true,
|
||||
service_count: 0,
|
||||
marks_collection_dirty: true,
|
||||
one_shot: false,
|
||||
has_fired: false,
|
||||
effects: vec![RuntimeEffect::AppendEventRecord {
|
||||
record: Box::new(RuntimeEventRecordTemplate {
|
||||
record_id: 51,
|
||||
trigger_kind: 0x0a,
|
||||
active: true,
|
||||
marks_collection_dirty: false,
|
||||
one_shot: false,
|
||||
effects: vec![RuntimeEffect::SetWorldFlag {
|
||||
key: "dirty_rerun_follow_on".to_string(),
|
||||
value: true,
|
||||
}],
|
||||
}),
|
||||
}],
|
||||
}],
|
||||
..state()
|
||||
};
|
||||
|
||||
let result = execute_step_command(
|
||||
&mut state,
|
||||
&StepCommand::ServiceTriggerKind { trigger_kind: 1 },
|
||||
)
|
||||
.expect("dirty rerun with follow-on should succeed");
|
||||
|
||||
assert_eq!(result.service_events.len(), 2);
|
||||
assert_eq!(result.service_events[0].serviced_record_ids, vec![50]);
|
||||
assert_eq!(result.service_events[0].appended_record_ids, vec![51]);
|
||||
assert_eq!(result.service_events[1].trigger_kind, Some(0x0a));
|
||||
assert_eq!(result.service_events[1].serviced_record_ids, vec![51]);
|
||||
assert_eq!(state.service_state.dirty_rerun_count, 1);
|
||||
assert_eq!(state.event_runtime_records.len(), 2);
|
||||
assert_eq!(state.event_runtime_records[1].service_count, 1);
|
||||
assert_eq!(state.world_flags.get("dirty_rerun_follow_on"), Some(&true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lifecycle_mutations_commit_between_passes() {
|
||||
let mut state = RuntimeState {
|
||||
event_runtime_records: vec![
|
||||
RuntimeEventRecord {
|
||||
record_id: 60,
|
||||
trigger_kind: 7,
|
||||
active: true,
|
||||
service_count: 0,
|
||||
marks_collection_dirty: false,
|
||||
one_shot: true,
|
||||
has_fired: false,
|
||||
effects: vec![
|
||||
RuntimeEffect::AppendEventRecord {
|
||||
record: Box::new(RuntimeEventRecordTemplate {
|
||||
record_id: 64,
|
||||
trigger_kind: 7,
|
||||
active: true,
|
||||
marks_collection_dirty: false,
|
||||
one_shot: false,
|
||||
effects: vec![RuntimeEffect::SetCandidateAvailability {
|
||||
name: "Appended Industry".to_string(),
|
||||
value: 1,
|
||||
}],
|
||||
}),
|
||||
},
|
||||
RuntimeEffect::DeactivateEventRecord { record_id: 61 },
|
||||
RuntimeEffect::ActivateEventRecord { record_id: 62 },
|
||||
RuntimeEffect::RemoveEventRecord { record_id: 63 },
|
||||
],
|
||||
},
|
||||
RuntimeEventRecord {
|
||||
record_id: 61,
|
||||
trigger_kind: 7,
|
||||
active: true,
|
||||
service_count: 0,
|
||||
marks_collection_dirty: false,
|
||||
one_shot: false,
|
||||
has_fired: false,
|
||||
effects: vec![RuntimeEffect::SetWorldFlag {
|
||||
key: "deactivated_after_first_pass".to_string(),
|
||||
value: true,
|
||||
}],
|
||||
},
|
||||
RuntimeEventRecord {
|
||||
record_id: 62,
|
||||
trigger_kind: 7,
|
||||
active: false,
|
||||
service_count: 0,
|
||||
marks_collection_dirty: false,
|
||||
one_shot: false,
|
||||
has_fired: false,
|
||||
effects: vec![RuntimeEffect::SetSpecialCondition {
|
||||
label: "Activated On Second Pass".to_string(),
|
||||
value: 1,
|
||||
}],
|
||||
},
|
||||
RuntimeEventRecord {
|
||||
record_id: 63,
|
||||
trigger_kind: 7,
|
||||
active: true,
|
||||
service_count: 0,
|
||||
marks_collection_dirty: false,
|
||||
one_shot: false,
|
||||
has_fired: false,
|
||||
effects: vec![RuntimeEffect::SetWorldFlag {
|
||||
key: "removed_after_first_pass".to_string(),
|
||||
value: true,
|
||||
}],
|
||||
},
|
||||
],
|
||||
..state()
|
||||
};
|
||||
|
||||
let first = execute_step_command(
|
||||
&mut state,
|
||||
&StepCommand::ServiceTriggerKind { trigger_kind: 7 },
|
||||
)
|
||||
.expect("first lifecycle pass should succeed");
|
||||
|
||||
assert_eq!(
|
||||
first.service_events[0].serviced_record_ids,
|
||||
vec![60, 61, 63]
|
||||
);
|
||||
assert_eq!(first.service_events[0].appended_record_ids, vec![64]);
|
||||
assert_eq!(first.service_events[0].activated_record_ids, vec![62]);
|
||||
assert_eq!(first.service_events[0].deactivated_record_ids, vec![61]);
|
||||
assert_eq!(first.service_events[0].removed_record_ids, vec![63]);
|
||||
assert_eq!(
|
||||
state
|
||||
.event_runtime_records
|
||||
.iter()
|
||||
.map(|record| (record.record_id, record.active))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![(60, true), (61, false), (62, true), (64, true)]
|
||||
);
|
||||
|
||||
let second = execute_step_command(
|
||||
&mut state,
|
||||
&StepCommand::ServiceTriggerKind { trigger_kind: 7 },
|
||||
)
|
||||
.expect("second lifecycle pass should succeed");
|
||||
|
||||
assert_eq!(second.service_events[0].serviced_record_ids, vec![62, 64]);
|
||||
assert_eq!(
|
||||
state.special_conditions.get("Activated On Second Pass"),
|
||||
Some(&1)
|
||||
);
|
||||
assert_eq!(
|
||||
state.candidate_availability.get("Appended Industry"),
|
||||
Some(&1)
|
||||
);
|
||||
assert_eq!(
|
||||
state.world_flags.get("deactivated_after_first_pass"),
|
||||
Some(&true)
|
||||
);
|
||||
assert_eq!(
|
||||
state.world_flags.get("removed_after_first_pass"),
|
||||
Some(&true)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_duplicate_appended_record_id() {
|
||||
let mut state = RuntimeState {
|
||||
event_runtime_records: vec![
|
||||
RuntimeEventRecord {
|
||||
record_id: 70,
|
||||
trigger_kind: 4,
|
||||
active: true,
|
||||
service_count: 0,
|
||||
marks_collection_dirty: false,
|
||||
one_shot: false,
|
||||
has_fired: false,
|
||||
effects: vec![RuntimeEffect::AppendEventRecord {
|
||||
record: Box::new(RuntimeEventRecordTemplate {
|
||||
record_id: 71,
|
||||
trigger_kind: 4,
|
||||
active: true,
|
||||
marks_collection_dirty: false,
|
||||
one_shot: false,
|
||||
effects: Vec::new(),
|
||||
}),
|
||||
}],
|
||||
},
|
||||
RuntimeEventRecord {
|
||||
record_id: 71,
|
||||
trigger_kind: 4,
|
||||
active: true,
|
||||
service_count: 0,
|
||||
marks_collection_dirty: false,
|
||||
one_shot: false,
|
||||
has_fired: false,
|
||||
effects: Vec::new(),
|
||||
},
|
||||
],
|
||||
..state()
|
||||
};
|
||||
|
||||
let result = execute_step_command(
|
||||
&mut state,
|
||||
&StepCommand::ServiceTriggerKind { trigger_kind: 4 },
|
||||
);
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_missing_lifecycle_mutation_target() {
|
||||
let mut state = RuntimeState {
|
||||
event_runtime_records: vec![RuntimeEventRecord {
|
||||
record_id: 80,
|
||||
trigger_kind: 6,
|
||||
active: true,
|
||||
service_count: 0,
|
||||
marks_collection_dirty: false,
|
||||
one_shot: false,
|
||||
has_fired: false,
|
||||
effects: vec![RuntimeEffect::ActivateEventRecord { record_id: 999 }],
|
||||
}],
|
||||
..state()
|
||||
};
|
||||
|
||||
let result = execute_step_command(
|
||||
&mut state,
|
||||
&StepCommand::ServiceTriggerKind { trigger_kind: 6 },
|
||||
);
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue