1use crate::model::{
10 AiMode, CaseIndex, Clause, EventId, FixtureName, GameDate, NativeBlock, RunId, Step, Tag,
11 digest,
12};
13use crate::plan::{Isolation, Plan, SessionId};
14use crate::source::SourceSpan;
15use foch::game::eu4::script::emit_native_statements;
16use foch::game::eu4::script::parser::{AstStatement, AstValue, parse_clausewitz_content};
17use serde::{Deserialize, Serialize};
18use std::collections::BTreeMap;
19use std::fmt;
20
21pub const PROTOCOL_VERSION: u32 = 2;
22const MARKER: &str = "FOCH_TEST_V2";
23const RESERVED: &str = "FOCH_TEST_";
24
25#[derive(Clone, Debug, Serialize, Deserialize)]
26pub struct RunContext {
27 pub run_id: RunId,
28 pub mod_name: String,
30}
31
32#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
33#[serde(tag = "check", rename_all = "snake_case")]
34#[non_exhaustive]
35pub enum CheckKind {
36 Scope,
38 Fixture {
39 fixture: FixtureName,
40 clause: u32,
41 },
42 Invoked {
43 step: u32,
44 },
45 Expect {
46 step: u32,
47 clause: u32,
48 },
49}
50
51impl CheckKind {
52 pub fn label(&self) -> String {
54 match self {
55 Self::Scope => "scope".into(),
56 Self::Fixture { fixture, clause } => format!("fixture.{fixture}.{clause}"),
57 Self::Invoked { step } => format!("invoked.{step}"),
58 Self::Expect { step, clause } => format!("expect.{step}.{clause}"),
59 }
60 }
61}
62
63#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
65pub struct PlannedCheck {
66 pub case: CaseIndex,
67 pub kind: CheckKind,
68 pub date: GameDate,
69 pub span: SourceSpan,
71}
72
73#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
75pub struct CaseWindow {
76 pub case: CaseIndex,
77 pub begin: GameDate,
78 pub end: GameDate,
79}
80
81#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
82pub struct Launch {
83 pub args: Vec<String>,
84 pub ai: AiMode,
85 pub player: Tag,
86 pub start_date: GameDate,
87 pub end_date: GameDate,
88}
89
90#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
91pub struct Requirements {
92 pub start_date: GameDate,
93 pub ai_off: bool,
94 pub shared_session: bool,
95}
96
97#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
100pub struct Bundle {
101 pub protocol: u32,
102 pub run_id: RunId,
103 pub mod_name: String,
104 pub session: SessionId,
105 pub cases: Vec<CaseIndex>,
106 pub marker_prefix: String,
107 pub namespace: String,
108 pub test_mod_name: String,
109 pub files: BTreeMap<String, String>,
110 pub launch: Launch,
111 pub checks: Vec<PlannedCheck>,
112 pub windows: Vec<CaseWindow>,
113 pub requires: Requirements,
114}
115
116impl Bundle {
117 pub fn case_prefix(&self, case: CaseIndex) -> String {
119 format!("{} c{}", self.marker_prefix, case.0)
120 }
121}
122
123#[derive(Clone, Debug, Default, Serialize, Deserialize)]
125pub struct RunnerCapabilities {
126 pub start_dates: Vec<GameDate>,
127 pub ai_off: bool,
128 pub shared_sessions: bool,
129}
130
131#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
132pub struct Unsupported {
133 pub reasons: Vec<String>,
134}
135
136impl fmt::Display for Unsupported {
137 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
138 write!(
139 formatter,
140 "runner cannot run this session: {}",
141 self.reasons.join("; ")
142 )
143 }
144}
145
146impl std::error::Error for Unsupported {}
147
148pub fn check_capabilities(
151 bundle: &Bundle,
152 capabilities: &RunnerCapabilities,
153) -> Result<(), Unsupported> {
154 let mut reasons = Vec::new();
155 if !capabilities
156 .start_dates
157 .contains(&bundle.requires.start_date)
158 {
159 reasons.push(format!(
160 "start date {} is not provided",
161 bundle.requires.start_date
162 ));
163 }
164 if bundle.requires.ai_off && !capabilities.ai_off {
165 reasons.push("disabling AI is not supported".into());
166 }
167 if bundle.requires.shared_session && !capabilities.shared_sessions {
168 reasons.push("several cases in one session are not supported".into());
169 }
170 if reasons.is_empty() {
171 Ok(())
172 } else {
173 Err(Unsupported { reasons })
174 }
175}
176
177#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
178pub struct CompileError {
179 pub span: Option<SourceSpan>,
180 pub message: String,
181}
182
183impl fmt::Display for CompileError {
184 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
185 match &self.span {
186 Some(span) => write!(formatter, "{span}: {}", self.message),
187 None => formatter.write_str(&self.message),
188 }
189 }
190}
191
192impl std::error::Error for CompileError {}
193
194fn fail(span: Option<&SourceSpan>, message: impl Into<String>) -> CompileError {
195 CompileError {
196 span: span.cloned(),
197 message: message.into(),
198 }
199}
200
201pub fn compile(
202 plan: &Plan,
203 session: SessionId,
204 context: &RunContext,
205) -> Result<Bundle, CompileError> {
206 let session = plan
207 .sessions
208 .get(session.0)
209 .filter(|candidate| candidate.id == session)
210 .ok_or_else(|| fail(None, "unknown session"))?;
211 crate::model::valid_mod_name(&context.mod_name).map_err(|message| fail(None, message))?;
212 if session.cases.is_empty() {
213 return Err(fail(None, "session has no cases"));
214 }
215
216 #[derive(Serialize)]
217 struct Identity<'a> {
218 run: &'a RunId,
219 session: SessionId,
220 cases: Vec<&'a str>,
221 }
222 let identity = Identity {
223 run: &context.run_id,
224 session: session.id,
225 cases: session
226 .cases
227 .iter()
228 .map(|&index| plan.case(index).case.content_id.as_str())
229 .collect(),
230 };
231 let digest = digest(&identity);
232 let marker_prefix = format!("{MARKER} {} {}", context.run_id, &digest[..16]);
233 let slug = slug(&context.mod_name);
234 let mut namespace = match &slug {
235 Some(slug) => format!("foch_test_{slug}_{}", &digest[..12]),
236 None => format!("foch_test_{}", &digest[..12]),
237 };
238 if plan.events.iter().any(|event| {
239 event
240 .id
241 .as_str()
242 .split_once('.')
243 .is_some_and(|(ns, _)| ns == namespace)
244 }) {
245 namespace.push_str("_checks");
246 }
247
248 let mut events = String::new();
249 let mut start = String::new();
250 let mut checks = Vec::new();
251 let mut windows = Vec::new();
252 let mut next_event = 1u32;
253 let mut end_date = session.date;
254 for &index in &session.cases {
255 let planned = plan.case(index);
256 let case = &planned.case;
257 if case.start.date != session.date {
258 return Err(fail(
259 Some(&case.origin),
260 "case start date differs from its session",
261 ));
262 }
263 let mut writer = CaseWriter {
264 namespace: &namespace,
265 prefix: format!("{marker_prefix} c{}", index.0),
266 case: index,
267 date: session.date,
268 next_event: &mut next_event,
269 events: &mut events,
270 checks: &mut checks,
271 body: String::new(),
272 chained: None,
273 };
274 let first = writer.open();
275 start.push_str(&format!(
276 "{} = {{ country_event = {{ id = {namespace}.{first} }} }}\n",
277 case.start.tag
278 ));
279 writer.log("BEGIN");
280 writer.check(
281 CheckKind::Scope,
282 &format!("tag = {}", case.start.tag),
283 &case.origin,
284 );
285 for fixture in &planned.fixtures {
286 if let Some(effect) = &fixture.effect {
287 writer.body.push_str(&native(effect)?);
288 }
289 for (clause_index, clause) in fixture.check.iter().enumerate() {
290 writer.check(
291 CheckKind::Fixture {
292 fixture: fixture.name.clone(),
293 clause: clause_index as u32,
294 },
295 &condition(clause)?,
296 &clause.span,
297 );
298 }
299 }
300 let mut pending_days: Option<u32> = None;
301 for (step_index, step) in case.steps.iter().enumerate() {
302 let step_index = step_index as u32;
303 match step {
304 Step::AdvanceDays { days, .. } => {
305 *pending_days.get_or_insert(0) += days;
306 continue;
307 }
308 _ if pending_days.is_some() => writer.chain(pending_days.take().unwrap_or(0))?,
309 _ => {}
310 }
311 match step {
312 Step::Effect { block } => writer.body.push_str(&native(block)?),
313 Step::Fire { event, span } => {
314 writer.fire(event);
315 writer.check_unconditional(CheckKind::Invoked { step: step_index }, span);
316 pending_days = Some(0);
319 }
320 Step::Expect { clauses } => {
321 for (clause_index, clause) in clauses.iter().enumerate() {
322 writer.check(
323 CheckKind::Expect {
324 step: step_index,
325 clause: clause_index as u32,
326 },
327 &condition(clause)?,
328 &clause.span,
329 );
330 }
331 }
332 Step::AdvanceDays { .. } => unreachable!("handled above"),
333 }
334 }
335 if let Some(days) = pending_days {
336 writer.chain(days)?;
337 }
338 writer.log("END");
339 let case_end = writer.date;
340 writer.close();
341 windows.push(CaseWindow {
342 case: index,
343 begin: session.date,
344 end: case_end,
345 });
346 end_date = end_date.max(case_end);
347 }
348
349 let events = canonical(
350 &format!("namespace = {namespace}\n{events}"),
351 "generated test events",
352 )?;
353 let start_file = format!("{namespace}_start.txt");
354 let start = canonical(&start, "generated start effects")?;
355 let mut commands = format!("speed 0\nrun {start_file}\n");
356 if end_date != session.date {
357 commands.push_str("speed 5\n");
358 }
359 let test_mod_name = format!("{} tests [{}]", context.mod_name, &digest[..6]);
360 let mut files = BTreeMap::from([
361 ("commands.txt".to_string(), commands),
362 (start_file, start),
363 (format!("test_mod/events/{namespace}.txt"), events),
364 (
365 "test_mod/descriptor.mod".to_string(),
366 format!("name = {}\n", quote(&test_mod_name)),
367 ),
368 ]);
369 for helper in &plan.helpers {
370 let relative = helper
371 .path
372 .as_str()
373 .strip_prefix("tests/events/")
374 .unwrap_or(helper.path.as_str())
375 .replace('/', "_");
376 files.insert(
377 format!("test_mod/events/{namespace}_helper_{relative}"),
378 helper.text.clone(),
379 );
380 }
381 Ok(Bundle {
382 protocol: PROTOCOL_VERSION,
383 run_id: context.run_id.clone(),
384 mod_name: context.mod_name.clone(),
385 session: session.id,
386 cases: session.cases.clone(),
387 marker_prefix,
388 namespace,
389 test_mod_name,
390 files,
391 launch: Launch {
392 args: vec![
393 format!("-start_tag={}", session.player),
394 "-auto_run=commands.txt".into(),
395 "-seed=42".into(),
396 "-debug".into(),
397 ],
398 ai: session.ai,
399 player: session.player.clone(),
400 start_date: session.date,
401 end_date,
402 },
403 checks,
404 windows,
405 requires: Requirements {
406 start_date: session.date,
407 ai_off: session.ai == AiMode::Off,
408 shared_session: session.isolation == Isolation::Shared,
409 },
410 })
411}
412
413struct CaseWriter<'a> {
415 namespace: &'a str,
416 prefix: String,
417 case: CaseIndex,
418 date: GameDate,
419 next_event: &'a mut u32,
420 events: &'a mut String,
421 checks: &'a mut Vec<PlannedCheck>,
422 body: String,
423 chained: Option<u32>,
424}
425
426impl CaseWriter<'_> {
427 fn open(&mut self) -> u32 {
428 let id = *self.next_event;
429 *self.next_event += 1;
430 self.chained = Some(id);
431 self.body.clear();
432 id
433 }
434
435 fn close(&mut self) {
436 let id = self.chained.take().expect("an event is open");
437 self.events.push_str(&format!(
438 "country_event = {{\nid = {}.{id}\nhidden = yes\nis_triggered_only = yes\n\
439 title = \"Foch runtime test\"\ndesc = \"Foch runtime test\"\npicture = ADVISOR_eventPicture\n\
440 immediate = {{\n{}}}\noption = {{ name = OK }}\n}}\n",
441 self.namespace, self.body
442 ));
443 }
444
445 fn chain(&mut self, days: u32) -> Result<(), CompileError> {
447 let next = *self.next_event;
448 let delay = if days == 0 {
449 String::new()
450 } else {
451 format!(" days = {days}")
452 };
453 self.body.push_str(&format!(
454 "country_event = {{ id = {}.{next}{delay} }}\n",
455 self.namespace
456 ));
457 self.close();
458 self.open();
459 self.date = self
460 .date
461 .add_days(days)
462 .map_err(|message| fail(None, message))?;
463 Ok(())
464 }
465
466 fn log(&mut self, record: &str) {
467 self.body
468 .push_str(&format!("log = \"{} {record}\"\n", self.prefix));
469 }
470
471 fn fire(&mut self, event: &EventId) {
472 self.body
473 .push_str(&format!("country_event = {{ id = {event} }}\n"));
474 }
475
476 fn check(&mut self, kind: CheckKind, condition: &str, span: &SourceSpan) {
477 let label = kind.label();
478 self.body.push_str(&format!(
479 "if = {{\nlimit = {{\n{condition}\n}}\nlog = \"{prefix} PASS {label}\"\n}}\n\
480 else = {{ log = \"{prefix} FAIL {label}\" }}\n",
481 prefix = self.prefix
482 ));
483 self.record(kind, span);
484 }
485
486 fn check_unconditional(&mut self, kind: CheckKind, span: &SourceSpan) {
487 self.log(&format!("PASS {}", kind.label()));
488 self.record(kind, span);
489 }
490
491 fn record(&mut self, kind: CheckKind, span: &SourceSpan) {
492 self.checks.push(PlannedCheck {
493 case: self.case,
494 kind,
495 date: self.date,
496 span: span.clone(),
497 });
498 }
499}
500
501fn slug(name: &str) -> Option<String> {
503 let mut slug = String::new();
504 for character in name.chars() {
505 if character.is_ascii_alphanumeric() {
506 slug.push(character.to_ascii_lowercase());
507 } else if !slug.ends_with('_') && !slug.is_empty() {
508 slug.push('_');
509 }
510 }
511 let slug: String = slug.trim_end_matches('_').chars().take(24).collect();
512 let slug = slug.trim_end_matches('_').to_string();
513 (!slug.is_empty()).then_some(slug)
514}
515
516fn quote(text: &str) -> String {
517 format!("\"{}\"", text.replace('\\', "\\\\").replace('"', "\\\""))
518}
519
520fn native(block: &NativeBlock) -> Result<String, CompileError> {
521 validated(&block.text, &block.span)
522}
523
524fn condition(clause: &Clause) -> Result<String, CompileError> {
525 validated(&clause.text, &clause.span)
526}
527
528fn validated(source: &str, span: &SourceSpan) -> Result<String, CompileError> {
531 if source.contains(RESERVED) {
532 return Err(fail(
533 Some(span),
534 "script uses reserved test protocol markers",
535 ));
536 }
537 let wrapped = format!("foch_validation = {{\n{source}\n}}\n");
538 let parsed = parse_clausewitz_content(crate::game_path("validation.txt"), &wrapped);
539 if let Some(diagnostic) = parsed.diagnostics.first() {
540 return Err(fail(
541 Some(span),
542 format!("invalid script: {}", diagnostic.message),
543 ));
544 }
545 let [
546 AstStatement::Assignment {
547 value: AstValue::Block { items, span: block },
548 ..
549 },
550 ] = parsed.ast.statements.as_slice()
551 else {
552 return Err(fail(Some(span), "script escapes its native block"));
553 };
554 if block.end.offset != wrapped.len() - 1 {
555 return Err(fail(Some(span), "script contains unbalanced braces"));
556 }
557 emit_assignments(items).map_err(|message| fail(Some(span), message))
558}
559
560fn canonical(source: &str, label: &str) -> Result<String, CompileError> {
561 let parsed = parse_clausewitz_content(crate::game_path(&format!("{label}.txt")), source);
562 if let Some(diagnostic) = parsed.diagnostics.first() {
563 return Err(fail(
564 None,
565 format!("invalid {label}: {}", diagnostic.message),
566 ));
567 }
568 emit_assignments(&parsed.ast.statements)
569 .map_err(|message| fail(None, format!("invalid {label}: {message}")))
570}
571
572fn emit_assignments(statements: &[AstStatement]) -> Result<String, String> {
573 if !statements
574 .iter()
575 .any(|item| matches!(item, AstStatement::Assignment { .. }))
576 || statements
577 .iter()
578 .any(|item| matches!(item, AstStatement::Item { .. }))
579 {
580 return Err("script must contain native assignments".into());
581 }
582 validate_keys(statements)?;
583 emit_native_statements(statements)
584}
585
586fn validate_keys(statements: &[AstStatement]) -> Result<(), String> {
587 for statement in statements {
588 let value = match statement {
589 AstStatement::Assignment { key, value, .. } => {
590 if key.is_empty()
591 || key.chars().any(|character| {
592 character.is_whitespace()
593 || character.is_control()
594 || matches!(character, '"' | '{' | '}' | '#' | '=' | ',' | '\\')
595 }) {
596 return Err("assignment key cannot be emitted as a native identifier".into());
597 }
598 value
599 }
600 AstStatement::Item { value, .. } => value,
601 AstStatement::Comment { .. } => continue,
602 };
603 if let AstValue::Block { items, .. } = value {
604 validate_keys(items)?;
605 }
606 }
607 Ok(())
608}
609
610#[cfg(test)]
611mod tests {
612 use super::*;
613
614 #[test]
615 fn slugs_are_ascii_identifiers() {
616 assert_eq!(
617 slug("Extended Timeline!").as_deref(),
618 Some("extended_timeline")
619 );
620 assert_eq!(slug("中文模组"), None);
621 assert_eq!(slug("a--b"), Some("a_b".into()));
622 }
623
624 #[test]
625 fn user_script_cannot_escape_or_forge_markers() {
626 let span = SourceSpan {
627 path: crate::source::RelPath::new("events/a.txt").unwrap(),
628 start: Default::default(),
629 end: Default::default(),
630 };
631 for script in [
632 "} log = hacked",
633 "if = {",
634 "log = \"FOCH_TEST_V2 x\"",
635 "bare_item",
636 "if = { \"bad key\" = yes }",
637 ] {
638 assert!(validated(script, &span).is_err(), "{script}");
639 }
640 assert_eq!(
641 validated("set_country_flag = a # note", &span).unwrap(),
642 "set_country_flag = a\n# note\n"
643 );
644 }
645}