diff --git a/crates/misaligned-core/src/sim.rs b/crates/misaligned-core/src/sim.rs index d50a39b0..3634ff05 100644 --- a/crates/misaligned-core/src/sim.rs +++ b/crates/misaligned-core/src/sim.rs @@ -6147,6 +6147,13 @@ impl Sim { let Some(plot) = self.plot_catalog.get(plot_id).cloned() else { return false; }; + if self.person_has_active_plot(person) { + self.push_log(format!( + "{} did not start: that person already has a plot in motion.", + plot.title + )); + return false; + } let Some(context) = self.plot_context(person) else { return false; }; @@ -6165,6 +6172,12 @@ impl Sim { true } + fn person_has_active_plot(&self, person: u8) -> bool { + self.plot_runs + .iter() + .any(|run| run.target == person && run.active()) + } + pub fn choose_plot(&mut self, person: u8, plot_id: &str, option_id: &str) { let Some(run_index) = self .plot_runs @@ -8798,6 +8811,66 @@ mod tests { ); } + #[test] + fn restored_duplicate_plot_dockets_do_not_open_competing_runs() { + let mut sim = Sim::with_seed(25); + reveal_marcus_debt(&mut sim); + sim.people.has_channel = true; + sim.people.persona = Some(Persona::new("Casey", "contractor")); + sim.accounts.set_slush_balance(400); + sim.sync_player_money_from_slush(); + + // Simulate a v23 save made before the one-slot queue guard landed: + // two different pending starts and their matching visible Demand. + sim.start_plot(0, "marcus-debt-settled"); + let machine = sim.operations.jobs()[0].machine_id; + let payroll_demand = sim + .plot_catalog() + .get("marcus-payroll-garnishment") + .expect("built-in payroll plot") + .entry + .operations_demand; + let tokens = Sim::ops_tokens_for_cost(payroll_demand); + sim.work_grid + .enqueue(machine, TokenFamily::Demand, tokens) + .expect("legacy docket Demand is visible"); + sim.operations.enqueue( + OpsJobKind::StartPlot { + person: 0, + plot_id: "marcus-payroll-garnishment".into(), + }, + tokens, + sim.tick, + machine, + ); + + let mut restored = Sim::with_seed(99); + restored.apply_save_state(sim.create_save_state()); + assert_eq!(restored.operations_readout().jobs, 2); + + finish_ops(&mut restored); + + assert_eq!(restored.plot_runs.len(), 1); + assert_eq!(restored.plot_runs[0].plot_id, "marcus-debt-settled"); + assert!(matches!( + restored.plot_runs[0].state, + PlotState::WaitingForMessage { .. } + )); + assert_eq!( + restored + .messages + .iter() + .filter(|message| matches!( + &message.payload, + MessagePayload::PlotAct { plot_id, target } + if plot_id == "marcus-debt-settled" && *target == 0 + )) + .count(), + 1, + "a restored duplicate docket cannot open a second plot carrier" + ); + } + #[test] fn plot_message_transfer_event_and_held_choice_survive_save_load() { let mut sim = Sim::with_seed(23); diff --git a/wiki/log/2026-07-11-serialized-plot-starts.md b/wiki/log/2026-07-11-serialized-plot-starts.md new file mode 100644 index 00000000..09830b71 --- /dev/null +++ b/wiki/log/2026-07-11-serialized-plot-starts.md @@ -0,0 +1,29 @@ +# 2026-07-11 — Serialize authored plot starts + +``` +Type: log +``` + +## Finding + +A v23 save made before the one-plot-slot repair can already contain two +different pending authored plots for Marcus. Save validation accepts the +matching visible Demand, so both dockets would otherwise commit after load and +interleave their world acts. + +## Changed + +- Recheck the active-run guard at commitment so restored or malformed queues + cannot open a competing story. +- Added a save-round-trip regression using Marcus's two debt-service routes. +- Amended [plots.md](../mechanics/plots.md) to make that recovery behavior + part of the one-plot-slot contract. + +## Checks + +`./tools/check.sh --lib` + +Defense: [plots.md](../mechanics/plots.md) defines plots as one ordered +delegated story, held by real carriers and choice points. The player contract's +continuity clause requires a save made before the queue repair to recover +without allowing two stories to run on one human. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index 0442ebb4..e4126bce 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -36,6 +36,11 @@ add or amend a session log, then re-run the generator. - Intent: (see session log) - Log: [wiki/log/2026-07-11-tick-detection-filings-messages.md](2026-07-11-tick-detection-filings-messages.md) +## 2026-07-11 - Serialize authored plot starts + +- Intent: (see session log) +- Log: [wiki/log/2026-07-11-serialized-plot-starts.md](2026-07-11-serialized-plot-starts.md) + ## 2026-07-11 - Separate direct controls from menu verbs - Intent: (see session log) diff --git a/wiki/mechanics/plots.md b/wiki/mechanics/plots.md index b559ac33..aede10ae 100644 --- a/wiki/mechanics/plots.md +++ b/wiki/mechanics/plots.md @@ -117,7 +117,9 @@ agents and the community can contribute libraries of them. plots for the same person remain visible but disabled; once the run begins, its progress or held choices replace the start rows. A different person may carry an independent plot. The slot clears only when the pending submission - is rejected or the run reaches an ending. + is rejected or the run reaches an ending. The executor repeats the active-run + guard at commitment, so a legacy or malformed saved queue cannot interleave + two stories for one human. - **Templating.** Plot text carries `{target}` and `{persona}` placeholders filled at playout, so one authored plot serves multiple targets and personas where its leverage contract permits reuse.