151 lines
5.2 KiB
Rust
151 lines
5.2 KiB
Rust
|
|
//! The edition dataset, vendored and read by hand (ADR-0011).
|
||
|
|
//!
|
||
|
|
//! `Problems.csv` is authoritative (ground-game, GROUND-WP-0002 T01). The
|
||
|
|
//! engine used to invent Problem values and suits; it reads them now.
|
||
|
|
//!
|
||
|
|
//! **Why not the `csv` crate.** It fits — 17,651 marginal lines against
|
||
|
|
//! AM-4b's 19,742 of headroom — and is refused anyway, because that is
|
||
|
|
//! 89% of everything the budget has left to read 20 rows, and the next
|
||
|
|
//! dependency would have 2,091 lines to live in. If this data ever grows
|
||
|
|
//! nested quoting, embedded newlines, or multiple dialects, that decision
|
||
|
|
//! is wrong and `csv` is the answer (ADR-0011 D1).
|
||
|
|
|
||
|
|
use crate::{SolutionCard, Suit};
|
||
|
|
|
||
|
|
/// One Problem as the edition prints it.
|
||
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
|
|
pub struct EditionProblem {
|
||
|
|
/// `hidden_priority`: 0 is the Surface Problem.
|
||
|
|
pub priority: u8,
|
||
|
|
pub value: u8,
|
||
|
|
pub suit: Suit,
|
||
|
|
/// `visibility == "Surface"` — dealt face up (GR-S01).
|
||
|
|
pub surface: bool,
|
||
|
|
}
|
||
|
|
|
||
|
|
const CSV: &str = include_str!("../../../editions/ground-darvo-r0/Problems.csv");
|
||
|
|
|
||
|
|
/// Split one CSV record, honouring `"…"` quoting.
|
||
|
|
///
|
||
|
|
/// `problem_text` contains commas, which is the only reason this is not a
|
||
|
|
/// `split(',')`. Doubled quotes inside a quoted field are not handled and
|
||
|
|
/// do not occur; if they ever do, this returns the wrong field count and
|
||
|
|
/// `problems_of` fails loudly rather than mis-parsing.
|
||
|
|
fn fields(line: &str) -> Vec<String> {
|
||
|
|
let mut out = Vec::new();
|
||
|
|
let mut cur = String::new();
|
||
|
|
let mut quoted = false;
|
||
|
|
for c in line.chars() {
|
||
|
|
match c {
|
||
|
|
'"' => quoted = !quoted,
|
||
|
|
',' if !quoted => out.push(std::mem::take(&mut cur)),
|
||
|
|
c => cur.push(c),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
out.push(cur);
|
||
|
|
out
|
||
|
|
}
|
||
|
|
|
||
|
|
fn suit_of(s: &str) -> Option<Suit> {
|
||
|
|
match s.trim() {
|
||
|
|
"Clarify" => Some(Suit::Clarify),
|
||
|
|
"Repair" => Some(Suit::Repair),
|
||
|
|
"Boundary" => Some(Suit::Boundary),
|
||
|
|
"Change" => Some(Suit::Change),
|
||
|
|
_ => None,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Every Problem of one scenario, ordered by `hidden_priority`.
|
||
|
|
///
|
||
|
|
/// Returns `Err` rather than an empty list when the data does not parse:
|
||
|
|
/// a loader that silently reads nothing would hand `setup` a game with no
|
||
|
|
/// Problems and look like a rules bug.
|
||
|
|
pub fn problems_of(scenario_id: &str) -> Result<Vec<EditionProblem>, String> {
|
||
|
|
let mut lines = CSV.lines();
|
||
|
|
let header = lines.next().ok_or("edition data is empty")?;
|
||
|
|
let cols: Vec<String> = fields(header)
|
||
|
|
.into_iter()
|
||
|
|
.map(|c| c.trim_start_matches('\u{feff}').trim().to_string())
|
||
|
|
.collect();
|
||
|
|
let at = |name: &str| -> Result<usize, String> {
|
||
|
|
cols.iter()
|
||
|
|
.position(|c| c == name)
|
||
|
|
.ok_or_else(|| format!("edition data has no column {name:?}"))
|
||
|
|
};
|
||
|
|
let (c_scn, c_pri, c_val, c_sol, c_vis) = (
|
||
|
|
at("scenario_id")?,
|
||
|
|
at("hidden_priority")?,
|
||
|
|
at("point_value")?,
|
||
|
|
at("required_solution")?,
|
||
|
|
at("visibility")?,
|
||
|
|
);
|
||
|
|
|
||
|
|
let mut out = Vec::new();
|
||
|
|
for line in lines.filter(|l| !l.trim().is_empty()) {
|
||
|
|
let f = fields(line);
|
||
|
|
if f.len() != cols.len() {
|
||
|
|
return Err(format!(
|
||
|
|
"edition row has {} fields, header has {}: {line}",
|
||
|
|
f.len(),
|
||
|
|
cols.len()
|
||
|
|
));
|
||
|
|
}
|
||
|
|
if f[c_scn].trim() != scenario_id {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
out.push(EditionProblem {
|
||
|
|
priority: f[c_pri]
|
||
|
|
.trim()
|
||
|
|
.parse()
|
||
|
|
.map_err(|_| format!("hidden_priority {:?} is not a number", f[c_pri]))?,
|
||
|
|
value: f[c_val]
|
||
|
|
.trim()
|
||
|
|
.parse()
|
||
|
|
.map_err(|_| format!("point_value {:?} is not a number", f[c_val]))?,
|
||
|
|
suit: suit_of(&f[c_sol])
|
||
|
|
.ok_or_else(|| format!("required_solution {:?} is not a suit", f[c_sol]))?,
|
||
|
|
surface: f[c_vis].trim() == "Surface",
|
||
|
|
});
|
||
|
|
}
|
||
|
|
if out.is_empty() {
|
||
|
|
return Err(format!("edition data has no Problems for {scenario_id}"));
|
||
|
|
}
|
||
|
|
out.sort_by_key(|p| p.priority);
|
||
|
|
Ok(out)
|
||
|
|
}
|
||
|
|
|
||
|
|
/// GR-S01 as ruled by ground-game 2026-08-04: **Surface always, plus
|
||
|
|
/// hidden priorities `1..=k`**, with k by seat band. Surface is never one
|
||
|
|
/// of the hidden slots.
|
||
|
|
///
|
||
|
|
/// Available points are therefore 6 / 9 / 12 with this edition's values —
|
||
|
|
/// the numbers ground-game ruled the thresholds 5 / 7 / 9 against.
|
||
|
|
pub fn hidden_depth(players: u8) -> Result<u8, String> {
|
||
|
|
match players {
|
||
|
|
2 => Ok(2),
|
||
|
|
3..=4 => Ok(3),
|
||
|
|
5..=6 => Ok(4),
|
||
|
|
other => Err(format!("GR-S01: unsupported player count {other}")),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// The Problems dealt at `players` seats, Surface first.
|
||
|
|
pub fn deal(scenario_id: &str, players: u8) -> Result<Vec<EditionProblem>, String> {
|
||
|
|
let k = hidden_depth(players)?;
|
||
|
|
let all = problems_of(scenario_id)?;
|
||
|
|
let dealt: Vec<EditionProblem> = all
|
||
|
|
.into_iter()
|
||
|
|
.filter(|p| p.surface || (p.priority >= 1 && p.priority <= k))
|
||
|
|
.collect();
|
||
|
|
Ok(dealt)
|
||
|
|
}
|
||
|
|
|
||
|
|
/// The core Solution deck, 6 per suit in canonical order (GR-S04).
|
||
|
|
pub fn solution_deck() -> Vec<SolutionCard> {
|
||
|
|
[Suit::Clarify, Suit::Repair, Suit::Boundary, Suit::Change]
|
||
|
|
.into_iter()
|
||
|
|
.flat_map(|suit| std::iter::repeat_n(SolutionCard { suit }, 6))
|
||
|
|
.collect()
|
||
|
|
}
|