use std::collections::{BTreeMap, BTreeSet};
use serde::Serialize;
use crate::diagnostic::{Diagnostic, DwCode, ExitTier};
pub const DW_METRIC_UNKNOWN: DwCode = DwCode::new("DW0812", ExitTier::Build);
pub const DW_METRIC_PROVISIONAL: DwCode = DwCode::new("DW0813", ExitTier::Build).about_the_engine();
pub const METRICS_VERSION: u32 = 2;
pub const PLAYER_WIDTH: f64 = 0.6;
pub const PLAYER_HEIGHT: f64 = 1.8;
pub const PLAYER_CROUCHED_HEIGHT: f64 = 1.5;
pub const PLAYER_EYE_HEIGHT: f64 = 1.62;
pub const PLAYER_MAX_HEALTH: f64 = 20.0;
pub const MAX_AUTO_STEP_16: i64 = 9;
pub const MAX_JUMP_RISE_16: i64 = 20;
pub const FULL_16: i64 = 16;
const _: () = assert!(crate::blockshape::FULL_HEIGHT_16 as i64 == FULL_16);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Rise {
Walk,
Jump,
Beyond,
}
#[must_use]
pub fn classify_rise_16(rise_16: i64) -> Rise {
if rise_16 <= MAX_AUTO_STEP_16 {
Rise::Walk
} else if rise_16 <= MAX_JUMP_RISE_16 {
Rise::Jump
} else {
Rise::Beyond
}
}
#[must_use]
pub fn step_allowed(rise_16: i64, head_clear: impl FnOnce() -> bool) -> bool {
match classify_rise_16(rise_16) {
Rise::Walk => true,
Rise::Jump => head_clear(),
Rise::Beyond => false,
}
}
pub const JUMP_AIRBORNE_TICKS: f64 = 12.0;
pub const WALK_SPEED_BLOCKS_PER_SECOND: f64 = 4.317;
pub const TICKS_PER_SECOND: f64 = 20.0;
pub const FALL_DAMAGE_ONSET_BLOCKS: f64 = 3.0;
#[must_use]
pub fn walk_ticks_per_block() -> f64 {
TICKS_PER_SECOND / WALK_SPEED_BLOCKS_PER_SECOND
}
#[must_use]
pub fn unarmoured_survivable_fall_blocks() -> f64 {
(FALL_DAMAGE_ONSET_BLOCKS + PLAYER_MAX_HEALTH - 1.0).floor()
}
#[must_use]
pub fn passable_width_cells() -> u32 {
PLAYER_WIDTH.ceil() as u32
}
#[must_use]
pub fn passable_clearance_cells() -> u32 {
PLAYER_HEIGHT.ceil() as u32
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum Provenance {
EngineConstant,
VanillaRule,
Derived,
Provisional,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub struct Opening {
pub width: u32,
pub height: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub struct Pitch {
pub rise: u32,
pub run: u32,
pub step_16: i64,
pub realization: &'static str,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub struct SizeClass {
pub min_footprint: [u32; 2],
pub max_footprint: [u32; 2],
pub min_clearance: u32,
pub nominal_traverse_blocks: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub struct WayClass {
pub min_width: u32,
pub max_width: u32,
pub min_clearance: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub struct Grid {
pub quantum: u32,
pub axes: [&'static str; 2],
pub datum: &'static str,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(untagged)]
pub enum MetricValue {
Count(u32),
Number(f64),
Flag(bool),
Opening(Opening),
Pitch(Pitch),
SizeClass(SizeClass),
WayClass(WayClass),
Grid(Grid),
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct PlayerEntry {
pub value: MetricValue,
pub unit: &'static str,
pub provenance: Provenance,
pub note: &'static str,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct BuildingEntry {
key: &'static str,
value: MetricValue,
pub unit: &'static str,
pub provenance: Provenance,
pub calibrated: bool,
pub note: &'static str,
}
impl BuildingEntry {
#[must_use]
pub fn value(&self, reads: &mut Reads) -> &MetricValue {
reads.record(self);
&self.value
}
pub(crate) fn value_for_display(&self) -> &MetricValue {
&self.value
}
#[must_use]
pub fn key(&self) -> &'static str {
self.key
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Reads {
read: BTreeSet<&'static str>,
provisional: BTreeSet<&'static str>,
}
impl Reads {
#[must_use]
pub fn new() -> Self {
Self::default()
}
fn record(&mut self, entry: &BuildingEntry) {
self.read.insert(entry.key);
if !entry.calibrated {
self.provisional.insert(entry.key);
}
}
#[must_use]
pub fn binding(&self) -> ReadBinding {
ReadBinding {
read: self.read.len(),
provisional: self.provisional.len(),
}
}
#[must_use]
pub fn provisional(&self) -> Vec<&'static str> {
self.provisional.iter().copied().collect()
}
#[must_use]
pub fn read(&self) -> BTreeSet<&'static str> {
self.read.clone()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub struct ReadBinding {
pub read: usize,
pub provisional: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnknownMetric {
pub kind: &'static str,
pub kind_plural: &'static str,
pub named: String,
pub defined: Vec<&'static str>,
}
impl UnknownMetric {
#[must_use]
pub fn diagnostic(&self, stage: &str, path: &str) -> Diagnostic {
let defined = if self.defined.is_empty() {
"nothing".to_string()
} else {
self.defined.join(", ")
};
Diagnostic::error(
DW_METRIC_UNKNOWN,
stage,
path,
format!(
"the metrics table defines no {kind} called `{named}`. The table is \
the single authority for this vocabulary, so a name it does not \
define cannot compile and no check downstream has to cope with one. \
Defined {plural}: {defined}. Run `delvec metrics` for the whole \
table, including what each entry is for.",
kind = self.kind,
plural = self.kind_plural,
named = self.named,
),
)
}
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct Metrics {
pub metrics_version: u32,
pub mc_version: &'static str,
pub player: BTreeMap<&'static str, PlayerEntry>,
pub building: BTreeMap<&'static str, BuildingEntry>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MetricKind {
Opening,
Pitch,
SizeClass,
WayClass,
Storey,
Pacing,
}
impl MetricKind {
#[must_use]
pub fn prefix(self) -> &'static str {
match self {
MetricKind::Opening => "opening.",
MetricKind::Pitch => "pitch.",
MetricKind::SizeClass => "size-class.",
MetricKind::WayClass => "way-class.",
MetricKind::Storey => "storey.",
MetricKind::Pacing => "pacing.",
}
}
#[must_use]
pub fn plural(self) -> &'static str {
match self {
MetricKind::Opening => "seam openings",
MetricKind::Pitch => "stair pitches",
MetricKind::SizeClass => "size classes",
MetricKind::WayClass => "way classes",
MetricKind::Storey => "storey heights",
MetricKind::Pacing => "pacing coefficients",
}
}
#[must_use]
pub fn noun(self) -> &'static str {
match self {
MetricKind::Opening => "seam opening",
MetricKind::Pitch => "stair pitch",
MetricKind::SizeClass => "size class",
MetricKind::WayClass => "way class",
MetricKind::Storey => "storey height",
MetricKind::Pacing => "pacing coefficient",
}
}
}
fn player(
value: MetricValue,
unit: &'static str,
provenance: Provenance,
note: &'static str,
) -> PlayerEntry {
PlayerEntry {
value,
unit,
provenance,
note,
}
}
fn building(
key: &'static str,
value: MetricValue,
unit: &'static str,
provenance: Provenance,
note: &'static str,
) -> (&'static str, BuildingEntry) {
(
key,
BuildingEntry {
key,
value,
unit,
provenance,
calibrated: false,
note,
},
)
}
impl Metrics {
#[must_use]
pub fn table() -> Self {
let player_entries: Vec<(&'static str, PlayerEntry)> = vec![
(
"body.width",
player(
MetricValue::Number(PLAYER_WIDTH),
"blocks",
Provenance::EngineConstant,
"The standing collision box is 0.6 wide; `dsl::metrics::PLAYER_WIDTH` \
is the definition `compiler::nav`'s entity dims table and \
`compiler::crosshair` both import.",
),
),
(
"body.height",
player(
MetricValue::Number(PLAYER_HEIGHT),
"blocks",
Provenance::EngineConstant,
"The standing collision box is 1.8 tall; the same definition the \
entity dims table reads for `minecraft:player`.",
),
),
(
"body.crouched-height",
player(
MetricValue::Number(PLAYER_CROUCHED_HEIGHT),
"blocks",
Provenance::VanillaRule,
"A sneaking player's collision box is 1.5 tall, which is what lets a \
body pass under a cell a standing one cannot. No engine constant \
held this before the table, and nothing in this workspace has \
measured it on a running server.",
),
),
(
"body.eye-height",
player(
MetricValue::Number(PLAYER_EYE_HEIGHT),
"blocks",
Provenance::EngineConstant,
"The eye sits 1.62 above the floor of the cell the body stands in. \
This is the definition the render plan, the viewer page and the \
creator overlay all import, having each declared their own before.",
),
),
(
"body.max-health",
player(
MetricValue::Number(PLAYER_MAX_HEALTH),
"half-hearts",
Provenance::EngineConstant,
"Twenty points of health, the definition `compiler::combat` imports \
for its winnability arithmetic.",
),
),
(
"step.walk-up",
player(
MetricValue::Count(MAX_AUTO_STEP_16 as u32),
"sixteenths",
Provenance::EngineConstant,
"The largest rise a walker crosses without jumping: vanilla's player \
`maxUpStep` is 0.6 blocks and 9/16 is the largest sixteenth under \
it. `compiler::nav` imports this as its step rule's walk-up budget.",
),
),
(
"step.jump-rise",
player(
MetricValue::Count(MAX_JUMP_RISE_16 as u32),
"sixteenths",
Provenance::EngineConstant,
"The largest rise a walker reaches by jumping: the apex is ≈1.2522 \
blocks, so 20/16 is reachable and 21/16 is not. `compiler::nav` \
imports this, and it is why a slab-to-full-block step-up of 1.5 is \
the impossible move it is.",
),
),
(
"step.block",
player(
MetricValue::Count(FULL_16 as u32),
"sixteenths",
Provenance::EngineConstant,
"A full block in the sixteenths the step rule is denominated in, so \
that no comparison in the navigation model is a float.",
),
),
(
"jump.airborne",
player(
MetricValue::Number(JUMP_AIRBORNE_TICKS),
"ticks",
Provenance::VanillaRule,
"A jump is airborne about twelve ticks, apex to landing. This was \
prose in the navigation model's elevation-weight derivation and \
nothing could read it; the table is where it becomes data.",
),
),
(
"walk.speed",
player(
MetricValue::Number(WALK_SPEED_BLOCKS_PER_SECOND),
"blocks/second",
Provenance::VanillaRule,
"A walking player covers 4.317 blocks a second on the flat; \
sprinting is faster and is not the pacing basis, because a route \
nobody has learnt is walked.",
),
),
(
"walk.ticks-per-block",
player(
MetricValue::Number(walk_ticks_per_block()),
"ticks",
Provenance::Derived,
"Twenty ticks a second over 4.317 blocks a second. Against the \
twelve airborne ticks of a jump this is what makes a block of \
climb cost about two and a half blocks of walking, which is where \
the navigation model's elevation weight of two comes from.",
),
),
(
"fluid.passable",
player(
MetricValue::Flag(false),
"none",
Provenance::VanillaRule,
"Water and lava are impassable to every proof in this engine and are \
never floor either: a body cannot stand on a fluid surface, so the \
two sets are disjoint and both gate standability. Marked as a rule \
rather than an engine constant deliberately — the navigation model \
encodes this as the SHAPE of its occupancy sets and not as a shared \
`const`, so there is no single definition for this row to be, and \
claiming otherwise would be the overclaim the provenance field \
exists to prevent.",
),
),
(
"fall.damage-onset",
player(
MetricValue::Number(FALL_DAMAGE_ONSET_BLOCKS),
"blocks",
Provenance::VanillaRule,
"Fall damage is `ceil(distance − 3)` points, so a three-block fall is \
free and a four-block fall costs one. Stated from the vanilla \
damage rule; this repository has not measured it on a running \
server.",
),
),
(
"fall.unarmoured-survivable",
player(
MetricValue::Number(unarmoured_survivable_fall_blocks()),
"blocks",
Provenance::Derived,
"`ceil(distance − 3) < 20` holds up to 22 blocks, which lands a \
full-health unarmoured body on one half-heart; 23 deals twenty and \
kills. The survivable ceiling is a function of health and armour, \
and this is its unarmoured, full-health case — the physical bound \
the designed-drop policy is deliberately tighter than.",
),
),
(
"passable.width",
player(
MetricValue::Count(passable_width_cells()),
"cells",
Provenance::Derived,
"`ceil(0.6)`: one cell is the narrowest a standing body fits \
through. This is a fact, not a standard — no walk can change it, \
and it is the floor the designed corridor minimum may never be \
chosen below.",
),
),
(
"passable.clearance",
player(
MetricValue::Count(passable_clearance_cells()),
"cells",
Provenance::Derived,
"`ceil(1.8)`: two cells is the lowest a standing body passes under. \
Like the width beside it this is a fact rather than a standard, \
which is why neither carries a calibration flag.",
),
),
];
let building_entries: Vec<(&'static str, BuildingEntry)> = vec![
building(
"grid",
MetricValue::Grid(Grid {
quantum: 4,
axes: ["x", "z"],
datum: "floor-surface",
}),
"blocks",
Provenance::Provisional,
"The footprint quantum every site-plan box's horizontal extents are \
multiples of, and the datum convention: a box's floor SURFACE is at \
its declared y, and whatever stands in the box later puts its walk \
plane there. Four is a seed and nothing in the existing piece library \
argues for it — the cave tileset is odd on every axis and the keep \
tileset is even but not quartered — so what the gym is being asked is \
whether a quantum this fine buys anything a coarser one would not.",
),
building(
"way-class.corridor",
MetricValue::WayClass(WayClass {
min_width: 2,
max_width: 4,
min_clearance: 3,
}),
"cells",
Provenance::Provisional,
"The narrow way: a passage, a duct, a gallery cut through rock. Its \
`min_width` and `min_clearance` ARE the two numbers this table used to \
publish as `corridor.min-width` and `corridor.min-clearance` — one \
cell is passable and reads as a crawlspace, two lets two bodies pass, \
and two blocks of clearance puts the ceiling on the walker's head — and \
they are fields here rather than entries of their own so that there is \
one authority for the narrow way rather than a class beside two loose \
numbers nothing could spell. The gym walks widths one, two and three \
and clearances two, three and four. It is also asked a question that \
could not be posed while these numbers were unreachable: the kit \
quantum beside them is 4 and every box extent is a multiple of it, so \
the narrowest way any plan can currently DRAW is four cells, and the \
walk decides whether the floor moves up or the quantum moves down.",
),
building(
"way-class.road",
MetricValue::WayClass(WayClass {
min_width: 4,
max_width: 16,
min_clearance: 6,
}),
"cells",
Provenance::Provisional,
"The broad way: a village lane, a causeway, a quay, a ledge cut across a \
cliff face. Wide enough that a party walks it abreast and something can \
come the other way, which is the difference from the corridor beside it \
and is what the walk is being asked to place. The clearance seed is \
higher than the corridor's because a way this wide reads as roofless \
even when it is not, and a low ceiling over a broad floor is the one \
combination that reads as a mistake.",
),
building(
"opening.door",
MetricValue::Opening(Opening {
width: 1,
height: 2,
}),
"cells",
Provenance::Provisional,
"The narrow seam: one body at a time, the size of a vanilla door. Seeded \
at the smallest opening a standing body passes, so the gym is deciding \
whether the tightest legal seam is one anybody wants to walk.",
),
building(
"opening.arch",
MetricValue::Opening(Opening {
width: 2,
height: 3,
}),
"cells",
Provenance::Provisional,
"The ordinary seam between two interior places: two abreast, headroom \
over both.",
),
building(
"opening.passage",
MetricValue::Opening(Opening {
width: 3,
height: 3,
}),
"cells",
Provenance::Derived,
"The three-by-three doorway the existing jigsaw socket conventions \
already standardize on — `cave:socket` and `tk:socket` are both this \
opening, so the prefab library has been built against it for as long \
as it has existed. Derived from that convention rather than chosen \
here, and still uncalibrated: what the gym decides is whether the \
convention is right, not what it is.",
),
building(
"opening.gateway",
MetricValue::Opening(Opening {
width: 5,
height: 5,
}),
"cells",
Provenance::Provisional,
"The broad seam a thing of scenery scale passes: a cart, a barge, a \
processional. Seeded wide enough to read as an event from inside the \
place it opens onto, which is the judgement the walk is for.",
),
building(
"pitch.stair",
MetricValue::Pitch(Pitch {
rise: 1,
run: 1,
step_16: 8,
realization: "minecraft:*_stairs",
}),
"none",
Provenance::Derived,
"One block of rise per block of run, realized in stair blocks. The \
geometry is vanilla's: a stair offers its lower half first, so the \
body walks two eight-sixteenth steps per block of rise and never \
jumps. What is uncalibrated is the comfort judgement — whether a climb \
this steep is one a player wants to make repeatedly.",
),
building(
"pitch.ramp",
MetricValue::Pitch(Pitch {
rise: 1,
run: 2,
step_16: 8,
realization: "minecraft:*_slab + full block",
}),
"none",
Provenance::Derived,
"One block of rise per two of run, realized as a bottom slab then a full \
block. Same eight-sixteenth tread as the stair and half the pitch, so \
it is the gentle standard; the run it costs is what the gym weighs it \
on.",
),
building(
"storey.low",
MetricValue::Count(5),
"blocks",
Provenance::Derived,
"Floor course, three cells of interior clearance, ceiling course. Taken \
from the existing cave tileset, every passage and room of which is five \
blocks tall, so this is the storey the shipped library already has \
rather than a number invented here.",
),
building(
"storey.standard",
MetricValue::Count(8),
"blocks",
Provenance::Provisional,
"The storey an interior room of consequence gets: six cells of clearance \
between courses. A seed, and the walk is what says whether a room this \
tall reads as generous or merely as far away.",
),
building(
"storey.hall",
MetricValue::Count(14),
"blocks",
Provenance::Provisional,
"The storey a hall gets, where the height itself is the effect. The seed \
is deliberately at the point where volume starts costing walking time \
for nothing, because that is the trade the walk has to judge.",
),
building(
"size-class.alcove",
MetricValue::SizeClass(SizeClass {
min_footprint: [4, 4],
max_footprint: [8, 8],
min_clearance: 3,
nominal_traverse_blocks: 6,
}),
"cells",
Provenance::Provisional,
"A place a body stands in rather than crosses: a shrine, a landing, a \
cell. The smallest rung of the ladder, and the whole ladder's bounds \
are seeds — what the walk fixes is where one class stops feeling like \
the next.",
),
building(
"size-class.room",
MetricValue::SizeClass(SizeClass {
min_footprint: [8, 8],
max_footprint: [16, 16],
min_clearance: 4,
nominal_traverse_blocks: 12,
}),
"cells",
Provenance::Provisional,
"A place with a purpose and something in it: a guardroom, a chapel, a \
workshop.",
),
building(
"size-class.hall",
MetricValue::SizeClass(SizeClass {
min_footprint: [16, 16],
max_footprint: [32, 32],
min_clearance: 8,
nominal_traverse_blocks: 24,
}),
"cells",
Provenance::Provisional,
"A place a fight or a crowd fits in, and the smallest rung whose height \
is doing work of its own.",
),
building(
"size-class.arena",
MetricValue::SizeClass(SizeClass {
min_footprint: [32, 32],
max_footprint: [64, 64],
min_clearance: 12,
nominal_traverse_blocks: 48,
}),
"cells",
Provenance::Provisional,
"A place built around one encounter, with room to retreat and re-approach.",
),
building(
"size-class.expanse",
MetricValue::SizeClass(SizeClass {
min_footprint: [64, 64],
max_footprint: [128, 128],
min_clearance: 16,
nominal_traverse_blocks: 96,
}),
"cells",
Provenance::Provisional,
"A shore, a valley floor, a cavern — a place whose job is that crossing \
it takes time. The rung most at risk of being a big empty room, which \
is what the walk is watching for.",
),
building(
"drop.max-designed-rise",
MetricValue::Count(5),
"blocks",
Provenance::Provisional,
"The deepest fall a designed one-way drop edge may declare. A policy \
cap, not a physical one: the unarmoured survivable fall beside it in \
the player half is 22 blocks, and this is far tighter on purpose, \
because a drop is a topology decision and should not also be a health \
decision. Five costs two of twenty at full health, which is the seed \
the walk argues with.",
),
building(
"pacing.route-blocks-per-minute",
MetricValue::Count(60),
"blocks/minute",
Provenance::Provisional,
"Blocks of route a party gets through per minute of play, once looking, \
fighting and backtracking are in it. Carried with NO THRESHOLD \
anywhere until the first walked blockout and the first full playtest \
calibrate it: a threshold on a number this uncertain would be defending \
nothing. Its upper bound is the pure-walk figure beside it, which no \
party achieves.",
),
building(
"pacing.walk-only-blocks-per-minute",
MetricValue::Count((WALK_SPEED_BLOCKS_PER_SECOND * 60.0) as u32),
"blocks/minute",
Provenance::Derived,
"Walking speed times sixty: what a body covers doing nothing but \
walking in a straight line. It exists so the route coefficient above \
has a ceiling that is a fact rather than another guess, and so the \
ratio between them is the thing the playtest actually measures.",
),
];
Metrics {
metrics_version: METRICS_VERSION,
mc_version: crate::blocks::MC_VERSION,
player: player_entries.into_iter().collect(),
building: building_entries.into_iter().collect(),
}
}
pub fn resolve(&self, kind: MetricKind, named: &str) -> Result<&BuildingEntry, UnknownMetric> {
let key = format!("{}{}", kind.prefix(), named);
self.building
.get(key.as_str())
.ok_or_else(|| UnknownMetric {
kind: kind.noun(),
kind_plural: kind.plural(),
named: named.to_string(),
defined: self.names_of(kind),
})
}
#[must_use]
pub fn grid(&self, reads: &mut Reads) -> Option<Grid> {
match self.building.get("grid")?.value(reads) {
MetricValue::Grid(g) => Some(*g),
_ => None,
}
}
#[must_use]
pub fn max_designed_drop_blocks(&self, reads: &mut Reads) -> Option<u32> {
match self.building.get("drop.max-designed-rise")?.value(reads) {
MetricValue::Count(n) => Some(*n),
_ => None,
}
}
#[must_use]
pub fn broadest_opening_width(&self, reads: &mut Reads) -> Option<u32> {
let mut widest: Option<u32> = None;
for name in self.names_of(MetricKind::Opening) {
let Ok(entry) = self.resolve(MetricKind::Opening, name) else {
continue;
};
if let MetricValue::Opening(o) = entry.value(reads) {
widest = Some(widest.map_or(o.width, |w: u32| w.max(o.width)));
}
}
widest
}
#[must_use]
pub fn names_of(&self, kind: MetricKind) -> Vec<&'static str> {
self.building
.keys()
.filter_map(|k| k.strip_prefix(kind.prefix()))
.collect()
}
#[must_use]
pub fn notice(&self, reads: &Reads, stage: &str) -> Option<Diagnostic> {
let provisional = reads.provisional();
if provisional.is_empty() {
return None;
}
let binding = reads.binding();
Some(Diagnostic::warning(
DW_METRIC_PROVISIONAL,
stage,
"",
format!(
"{n} of the {read} building metric(s) this run read are provisional — the \
metrics gym has not walked them: {names}. The checks still ran and still \
refuse; what is unproven is the number they refused against.",
n = binding.provisional,
read = binding.read,
names = provisional.join(", "),
),
))
}
#[must_use]
pub fn self_check(&self) -> SelfCheck {
let mut reads = Reads::new();
let mut failures: Vec<String> = Vec::new();
let mut checked = 0usize;
let floor_w = u64::from(passable_width_cells());
let floor_h = u64::from(passable_clearance_cells());
checked += 1;
if self.names_of(MetricKind::WayClass).is_empty() {
failures.push(
"the table defines no way class, so no document can state a place that is \
a route"
.to_string(),
);
}
checked += 1;
if self.broadest_opening_width(&mut reads).is_none() {
failures.push(
"the table defines no standard opening, so a contact seam's width floor — \
the width a front must exceed to be a front rather than a door — cannot \
be derived"
.to_string(),
);
}
let quantum = match self.grid(&mut reads) {
Some(g) => {
checked += 1;
if g.quantum == 0 {
failures.push("the kit grid's quantum is zero".to_string());
}
g.quantum
}
None => {
failures.push("the table defines no kit `grid`".to_string());
1
}
};
for (key, entry) in &self.building {
match entry.value(&mut reads) {
MetricValue::Opening(o) => {
checked += 1;
if u64::from(o.width) < floor_w || u64::from(o.height) < floor_h {
failures.push(format!(
"`{key}` is {}×{}, which no standing body passes ({floor_w}×{floor_h} \
is the floor)",
o.width, o.height
));
}
}
MetricValue::Pitch(p) => {
checked += 1;
if p.step_16 > MAX_AUTO_STEP_16 {
failures.push(format!(
"`{key}` presents a tread of {}/16, over the {MAX_AUTO_STEP_16}/16 \
walk-up budget, so it is climbed by jumping and is not a standard \
pitch",
p.step_16
));
}
if p.rise == 0 || p.run == 0 {
failures.push(format!("`{key}` has a zero rise or run"));
}
}
MetricValue::SizeClass(c) => {
checked += 1;
for (axis, lo, hi) in [
("x", c.min_footprint[0], c.max_footprint[0]),
("z", c.min_footprint[1], c.max_footprint[1]),
] {
if lo > hi {
failures
.push(format!("`{key}` has a {axis} minimum above its maximum"));
}
if lo % quantum != 0 || hi % quantum != 0 {
failures.push(format!(
"`{key}` bounds its {axis} footprint at {lo}..{hi}, which is not \
on the kit grid's quantum of {quantum}"
));
}
}
if u64::from(c.min_clearance) < floor_h {
failures.push(format!(
"`{key}` allows a clearance of {}, under the passable floor of \
{floor_h}",
c.min_clearance
));
}
if c.nominal_traverse_blocks == 0 {
failures.push(format!("`{key}` has a nominal traverse of zero"));
}
}
MetricValue::WayClass(w) => {
checked += 1;
if u64::from(w.min_width) < floor_w {
failures.push(format!(
"`{key}` allows a width of {}, under the `passable.width` floor of \
{floor_w}",
w.min_width
));
}
if u64::from(w.min_clearance) < floor_h {
failures.push(format!(
"`{key}` allows a clearance of {}, under the `passable.clearance` \
floor of {floor_h}",
w.min_clearance
));
}
if w.min_width > w.max_width {
failures.push(format!(
"`{key}` bounds its width at {}..{}, a minimum above its maximum",
w.min_width, w.max_width
));
}
if !w.max_width.is_multiple_of(quantum) {
failures.push(format!(
"`{key}` bounds its width at {}, which is not on the kit grid's \
quantum of {quantum}, so no box can be drawn at the widest member \
of the class",
w.max_width
));
}
}
_ => {}
}
}
for (key, floor) in [
("storey.low", floor_h + 2),
("storey.standard", floor_h + 2),
("storey.hall", floor_h + 2),
] {
let Some(entry) = self.building.get(key) else {
failures.push(format!("the table defines no `{key}`"));
continue;
};
checked += 1;
if let MetricValue::Count(n) = entry.value(&mut reads)
&& u64::from(*n) < floor
{
failures.push(format!(
"`{key}` is {n} blocks, which leaves no passable interior between a \
floor course and a ceiling course ({floor} is the floor)"
));
}
}
if let Some(n) = self.max_designed_drop_blocks(&mut reads) {
checked += 1;
let physical = unarmoured_survivable_fall_blocks();
if f64::from(n) >= physical {
failures.push(format!(
"`drop.max-designed-rise` is {n} blocks, at or past the unarmoured \
survivable fall of {physical}, so it is not a policy cap at all"
));
}
}
if let (Some(route), Some(walk)) = (
self.building.get("pacing.route-blocks-per-minute"),
self.building.get("pacing.walk-only-blocks-per-minute"),
) {
checked += 1;
if let (MetricValue::Count(r), MetricValue::Count(w)) =
(route.value(&mut reads), walk.value(&mut reads))
&& r > w
{
failures.push(format!(
"`pacing.route-blocks-per-minute` is {r}, over the pure-walk ceiling of {w}"
));
}
}
SelfCheck {
binding: SelfCheckBinding {
invariants: checked,
entries: self.building.len(),
reads: reads.binding(),
},
reads,
failures,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct SelfCheck {
pub reads: Reads,
pub binding: SelfCheckBinding,
pub failures: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub struct SelfCheckBinding {
pub invariants: usize,
pub entries: usize,
pub reads: ReadBinding,
}
#[must_use]
pub fn export(metrics: &Metrics) -> serde_json::Value {
let mut player = serde_json::Map::new();
for (k, e) in &metrics.player {
player.insert((*k).to_string(), serde_json::json!(e));
}
let mut building = serde_json::Map::new();
let mut uncalibrated = 0usize;
for (k, e) in &metrics.building {
if !e.calibrated {
uncalibrated += 1;
}
building.insert(
(*k).to_string(),
serde_json::json!({
"value": e.value_for_display(),
"unit": e.unit,
"provenance": e.provenance,
"calibrated": e.calibrated,
"note": e.note,
}),
);
}
serde_json::json!({
"metrics_version": metrics.metrics_version,
"mc_version": metrics.mc_version,
"counts": {
"player": metrics.player.len(),
"building": metrics.building.len(),
"uncalibrated": uncalibrated,
},
"player": player,
"building": building,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_table_is_consistent_with_itself_and_with_the_player_half() {
let m = Metrics::table();
let check = m.self_check();
assert!(
check.failures.is_empty(),
"the shipped metrics table is inconsistent: {:?}",
check.failures
);
assert!(
check.binding.invariants > 0,
"a self-check that evaluated nothing is vacuous, not a pass"
);
}
#[test]
fn every_building_entry_lands_uncalibrated() {
let m = Metrics::table();
assert!(!m.building.is_empty(), "the building half is empty");
for (k, e) in &m.building {
assert!(
!e.calibrated,
"`{k}` claims to be calibrated, but the metrics gym has not been walked"
);
}
}
#[test]
fn a_verdict_that_reads_a_seed_raises_dw0813_naming_it() {
let m = Metrics::table();
let check = m.self_check();
assert!(
check.binding.reads.provisional > 0,
"the self-check read no provisional entry, so DW0813 binds to nothing"
);
let d = m
.notice(&check.reads, "metrics")
.expect("a run that read a seed owes the notice");
assert_eq!(d.code, "DW0813");
assert_eq!(d.severity, crate::diagnostic::Severity::Warning);
for name in check.reads.provisional() {
assert!(
d.message.contains(name),
"the notice must name `{name}`, the seed a verdict rested on"
);
}
}
#[test]
fn a_run_that_read_nothing_provisional_gets_no_notice() {
let m = Metrics::table();
let reads = Reads::new();
assert!(m.notice(&reads, "metrics").is_none());
assert_eq!(reads.binding().read, 0);
}
#[test]
fn an_undefined_name_is_dw0812_and_names_what_is_defined() {
let m = Metrics::table();
let err = m
.resolve(MetricKind::SizeClass, "cathedral")
.expect_err("`cathedral` is not a size class");
let d = err.diagnostic("layout-graph", "/nodes/0/size_class");
assert_eq!(d.code, "DW0812");
assert!(d.message.contains("cathedral"));
assert!(d.message.contains("room"), "the defined set is named");
assert_eq!(d.stage, "layout-graph");
assert_eq!(d.path, "/nodes/0/size_class");
}
#[test]
fn every_kind_resolves_at_least_one_defined_name() {
let m = Metrics::table();
for kind in [
MetricKind::Opening,
MetricKind::Pitch,
MetricKind::SizeClass,
MetricKind::Storey,
] {
let names = m.names_of(kind);
assert!(
!names.is_empty(),
"{} resolves nothing, so DW0812 would refuse every name",
kind.noun()
);
for n in names {
assert!(m.resolve(kind, n).is_ok(), "`{n}` does not resolve");
}
}
}
#[test]
fn the_derived_player_values_are_the_arithmetic_their_notes_claim() {
assert!((walk_ticks_per_block() - 20.0 / 4.317).abs() < f64::EPSILON);
assert!((walk_ticks_per_block() - 4.633).abs() < 0.001);
assert_eq!(unarmoured_survivable_fall_blocks(), 22.0);
assert_eq!(passable_width_cells(), 1);
assert_eq!(passable_clearance_cells(), 2);
}
}