Skip to main content

foch_test/
compile.rs

1//! Generate the test layer and launch description for one game session.
2//!
3//! Protocol v2 extends the v1 runner contract (same environment variables
4//! and bundle role) with several cases per session, per-clause checks,
5//! fixture checks and multi-step cases. Each case logs engine `EVENT` lines
6//! `FOCH_TEST_V2 <run> <session digest> c<index> <record>`, where a record is
7//! `BEGIN`, `PASS <check>`, `FAIL <check>` or `END`.
8
9use 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	/// Mod under test; used for readable generated names.
29	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	/// The case runs in its declared country.
37	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	/// The record name logged by the generated script.
53	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/// A check the log must report, on the given engine date, in plan order.
64#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
65pub struct PlannedCheck {
66	pub case: CaseIndex,
67	pub kind: CheckKind,
68	pub date: GameDate,
69	/// Source of the condition or step, for failure reports.
70	pub span: SourceSpan,
71}
72
73/// When a case begins and ends inside the session.
74#[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/// Everything one game session needs. Files are relative to a fresh bundle
98/// directory; the caller writes them and launches the runner.
99#[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	/// Prefix of a single case's records.
118	pub fn case_prefix(&self, case: CaseIndex) -> String {
119		format!("{} c{}", self.marker_prefix, case.0)
120	}
121}
122
123/// What a runner declares it can provide.
124#[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
148/// A runner that lacks a required capability must be refused, never asked
149/// to approximate it.
150pub 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					// Later steps observe the event's effects from a new event,
317					// matching the verified v1 call sequence.
318					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
413/// Writes one case's chain of hidden events.
414struct 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	/// Continue in a new event after `days` real game days.
446	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
501/// `Mod Name` -> `mod_name`; `None` when nothing ASCII remains.
502fn 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
528/// Re-parses user script inside a wrapper whose closing offset is known, so
529/// user text cannot escape its block, and re-emits it canonically.
530fn 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}