delvewright_dsl/metrics.rs
1//! The metrics standard (spec-0049 §2) — pipeline stage 0.
2//!
3//! One machine-readable table, engine-owned data, in two halves whose epistemic
4//! status differs and is recorded per entry:
5//!
6//! * **Player metrics** — facts of pinned Minecraft Java 1.21.11. Not chosen,
7//! so not calibratable: walking a level cannot make a player 0.7 blocks wide.
8//! * **Building metrics** — standards this project fixes. Every one carries
9//! [`BuildingEntry::calibrated`], `false` until the metrics gym's walk
10//! (spec-0049 §2.3) rules on it.
11//!
12//! # Why this module is in `delvewright-dsl`
13//!
14//! It is the crate every other one already reaches: `schem`, `grammar`, `admit`,
15//! `compiler` and `render` all resolve it. A metrics module that the navigation
16//! model imports but the render layer cannot would be one authority for some
17//! consumers and a copy for the rest, which is the shape this table exists to
18//! end. It is also the crate that owns [`crate::diagnostic`], and the stage-3
19//! and stage-4 documents whose names resolve into this table are validated here.
20//!
21//! # One authority, structurally
22//!
23//! The player half is not a second table that agrees with the navigation model —
24//! it **is** the navigation model's constants. `compiler::nav` imports
25//! [`MAX_AUTO_STEP_16`], [`MAX_JUMP_RISE_16`] and [`FULL_16`] from here;
26//! `compiler::crosshair`, `compiler::render_plan`, `compiler::view::viewer` and
27//! `compiler::combat` import the body constants they used to declare. There is
28//! one definition of each number in the workspace and the export re-serializes
29//! it, so a player metric cannot drift from the model that proves routes.
30//!
31//! What that replaced is worth recording, because it is the defect this table
32//! was written against rather than a hypothetical: the player eye height was
33//! declared four times (`compiler::render_plan`, `compiler::view::viewer`,
34//! `compiler::creator` in milli-blocks, `render::occupancy` in `f32`) and the
35//! body width twice, each a literal `0.6` or `1.62` with its own doc comment
36//! saying it was vanilla's. Nothing related them, so nothing could have gone red
37//! had one moved.
38//!
39//! # Provenance is recorded per entry
40//!
41//! [`Provenance`] says where a number came from, and the four values are
42//! deliberately not interchangeable: an [`Provenance::EngineConstant`] cannot
43//! drift because there is nothing to drift from, a [`Provenance::VanillaRule`] is
44//! a claim about the game this repository has **not** measured on a running
45//! server, a [`Provenance::Derived`] carries its arithmetic in its note, and a
46//! [`Provenance::Provisional`] is a seed for the gym and is not a standard yet.
47//! Dressing the last as one of the first three is the failure this field exists
48//! to make impossible to commit silently.
49//!
50//! # A provisional value cannot be consumed quietly
51//!
52//! [`BuildingEntry::value`] is the only way to read a building metric's number,
53//! and it takes `&mut `[`Reads`]. So a verdict that rests on an uncalibrated
54//! standard has, by construction, recorded that it did, and [`Metrics::notice`]
55//! turns that ledger into `DW0813`. The obligation lives in the signature, not
56//! in a line of documentation somebody has to remember.
57//!
58//! The residual, named rather than implied: a caller that constructs its own
59//! [`Reads`], reads through it and drops it has bypassed the notice. That is a
60//! deliberate act and not the omission the rule exists to catch — nothing
61//! *forgets* to thread a ledger it had to construct.
62//!
63//! On the campaign path it is closed rather than merely narrow.
64//! [`crate::validate::validate_campaign_with`] constructs **one** ledger and
65//! threads it through the stage-3 and stage-4 checks together, so every building
66//! metric either of them rests a verdict on lands in the ledger the notice
67//! reads. There is no second ledger for a read to disappear into.
68
69use std::collections::{BTreeMap, BTreeSet};
70
71use serde::Serialize;
72
73use crate::diagnostic::{Diagnostic, DwCode, ExitTier};
74
75/// `DW0812`: a document names a metrics entry the table does not define — a
76/// `size_class`, an `opening` or a `pitch` that resolves to nothing.
77pub const DW_METRIC_UNKNOWN: DwCode = DwCode::new("DW0812", ExitTier::Build);
78
79/// `DW0813`: a verdict rests on a standard the gym has not walked.
80///
81/// This rule asks the campaign for nothing at all. It reports a property of the
82/// ENGINE's own table — that some number a check just used is a seed rather than
83/// a standard — so no campaign could adopt its way out of it. It is a warning
84/// (exit 0) for the same reason: a provisional number is still a number,
85/// the check still refuses, and what the line adds is that the green rests on
86/// something nobody has walked.
87pub const DW_METRIC_PROVISIONAL: DwCode = DwCode::new("DW0813", ExitTier::Build).about_the_engine();
88
89// ---------------------------------------------------------------------------
90// The player half — the one definition of each constant in this workspace.
91// ---------------------------------------------------------------------------
92
93/// The table's own revision. Bumped whenever the exported JSON changes by a
94/// single byte **from a version that has merged**, which
95/// `crates/dsl/tests/metrics.rs` enforces against a committed digest: a consumer
96/// that pins a version is pinning values, and values that move under a fixed
97/// version are the drift the pin was bought to prevent.
98///
99/// The qualifier is a scope, not an escape hatch, and the difference is worth
100/// the sentence: the harm exists only where something could already have pinned
101/// the number, so a branch still authoring the version it introduces has nothing
102/// to break. It is also not a claim anybody makes about their own change —
103/// whether a version has merged is a fact of `origin/main`.
104///
105/// No document declares a metrics version and no surface is gated by one. What
106/// it needs is that the number cannot stand still while the table moves, and
107/// that is the digest test.
108pub const METRICS_VERSION: u32 = 2;
109
110/// Player collision-box width in blocks (`0.6 × 0.6 × 1.8` standing).
111pub const PLAYER_WIDTH: f64 = 0.6;
112
113/// Player collision-box height in blocks, standing.
114pub const PLAYER_HEIGHT: f64 = 1.8;
115
116/// Player collision-box height in blocks, crouched.
117pub const PLAYER_CROUCHED_HEIGHT: f64 = 1.5;
118
119/// Player eye height above the floor of the cell the body stands in.
120pub const PLAYER_EYE_HEIGHT: f64 = 1.62;
121
122/// Player maximum health, in half-heart damage points.
123pub const PLAYER_MAX_HEALTH: f64 = 20.0;
124
125/// The largest rise, in sixteenths of a block, a walker crosses **without
126/// jumping** — vanilla's player `maxUpStep` is 0.6 blocks, and 9/16 = 0.5625 is
127/// the largest sixteenth under it (10/16 = 0.625 already needs a jump). A rise
128/// within this budget needs no headroom above the *source* cell: the player walks
129/// straight up onto a slab or a path edge.
130pub const MAX_AUTO_STEP_16: i64 = 9;
131
132/// The largest rise a walker can reach **by jumping**, in sixteenths. A vanilla
133/// player's jump apex is ≈1.2522 blocks, so a surface 20/16 = 1.25 up is
134/// reachable and 21/16 = 1.3125 is not. This is the bound that makes the
135/// **1.5-block** slab-to-full-block step-up the impossible move it is.
136pub const MAX_JUMP_RISE_16: i64 = 20;
137
138/// A full block's height in sixteenths.
139pub const FULL_16: i64 = 16;
140
141// One number, one definition: a full block is 16/16 here and in the collision
142// table this feeds, and this refuses to compile the day the two drift. It is
143// asserted on this side because `blockshape` must compile knowing nothing about
144// the crate around it — the prefab generators are a separate workspace and reach
145// it through `prefab-invariants`, which re-exports it.
146const _: () = assert!(crate::blockshape::FULL_HEIGHT_16 as i64 == FULL_16);
147
148/// What a walker has to do to gain a given rise — the engine's ONE answer, and
149/// the reason it is here rather than in either walk.
150///
151/// Two walks ask this question of two different object classes. The compiler's
152/// navigation model asks it of an assembled world, where a floor has a real
153/// collision top and a body has a footprint; `delvec::schem`'s walk asks it of a
154/// box of cells with a passability answer for each, which is what a grammar
155/// expansion, a structure template read off disk and a reassembled zone all are.
156/// The rule belongs to neither walk: it is a fact about the pinned game's body,
157/// and this crate is where the three numbers it is written in terms of already
158/// live — so the rule lives beside them, and both walks read one answer.
159///
160/// The two callers still measure the rise differently, and that is a difference
161/// of **measurement**, never of rule: a box of cells with no collision heights
162/// can only read a rise as whole cells, which over-states every partial-block
163/// step and therefore only ever refuses. The rise is the input; this is the rule.
164#[derive(Debug, Clone, Copy, PartialEq, Eq)]
165pub enum Rise {
166 /// Inside [`MAX_AUTO_STEP_16`]: the body walks straight up, never leaves the
167 /// ground, and needs no room over its head.
168 Walk,
169 /// Past the auto-step budget and inside [`MAX_JUMP_RISE_16`]: the body jumps,
170 /// so the cell its head sweeps through must be clear or it head-bonks.
171 Jump,
172 /// Past the jump apex. No player makes this step.
173 Beyond,
174}
175
176/// Classify a rise between two standing surfaces, in sixteenths. Negative and
177/// zero rises are [`Rise::Walk`] — stepping down and walking level ask nothing
178/// of the ceiling.
179#[must_use]
180pub fn classify_rise_16(rise_16: i64) -> Rise {
181 if rise_16 <= MAX_AUTO_STEP_16 {
182 Rise::Walk
183 } else if rise_16 <= MAX_JUMP_RISE_16 {
184 Rise::Jump
185 } else {
186 Rise::Beyond
187 }
188}
189
190/// Can a body make this step? `head_clear` answers *is the cell the head sweeps
191/// through clear at the source* — it is consulted only for a [`Rise::Jump`], so a
192/// caller may compute it lazily.
193///
194/// This predicate is the whole of the step rule. A walk that answers "can a body
195/// get from here to there" without going through it is a second opinion, and the
196/// two this engine had disagreed by exactly this term: one of them omitted the
197/// head sweep, so it connected a full-block rise under a two-course ceiling that
198/// the other refuses, and the gate that admits a prefab proved its **positive**
199/// reachability claim over the looser of the two.
200#[must_use]
201pub fn step_allowed(rise_16: i64, head_clear: impl FnOnce() -> bool) -> bool {
202 match classify_rise_16(rise_16) {
203 Rise::Walk => true,
204 Rise::Jump => head_clear(),
205 Rise::Beyond => false,
206 }
207}
208
209/// **Does the selector volume of the inclusive block box `lo..=hi` reach a body
210/// standing anywhere in `cell`?**
211///
212/// Vanilla's `@a[x=lo.x,dx=hi.x-lo.x,…]` builds the AABB `[lo, hi + 1]` on each
213/// axis — `EntitySelectorParser::createAabb` adds the block's own extent on the
214/// positive side, which is why `dx=0` still selects a whole block column — and
215/// keeps every entity whose own box **intersects** it. A standing body is
216/// [`PLAYER_WIDTH`] square about its position and [`PLAYER_HEIGHT`] tall from its
217/// feet, so a volume matches bodies whose FEET CELL it does not contain: half a
218/// width past every horizontal face, and a course below the bottom layer where
219/// only the head is inside.
220///
221/// This is the **generous** reading of that test — *could any body standing in
222/// this cell be matched* — because every caller uses it to keep a body OUT. A
223/// cell it clears is one no body standing in it can be selected from; a cell it
224/// reports is one some body standing in it can. Horizontally the whole cell of
225/// positions counts, since nothing makes a walking player stop at a cell centre
226/// and a pathfinder routinely parks one flush against a face.
227///
228/// The comparison is non-strict where vanilla's `AABB.intersects` is strict, so
229/// a body that exactly TOUCHES a face counts here and does not on the server.
230/// That is the same hair of generosity `bodyInVolume` takes in the harness, kept
231/// identical on purpose so the two sides cannot answer differently about a cell,
232/// and it costs one layer: a body whose feet sit exactly on the volume's ceiling
233/// is reported and is not really killed. Generous is the safe direction for a
234/// rule that keeps bodies out, and being the SAME on both sides matters more
235/// than the layer.
236///
237/// A consequence worth stating rather than leaving to be discovered: horizontally
238/// the shell is one cell wide for any body narrower than two blocks, so
239/// [`PLAYER_WIDTH`] does not decide it — the sweep over the cell's own positions
240/// does. [`PLAYER_HEIGHT`] does decide the vertical shell.
241///
242/// The engine's other reader of the same vanilla rule is
243/// `compiler::reach::ReachCompletion::possibly_completes_from`, which asks it of
244/// one stated body position rather than of a cell of them; the harness's is
245/// `bodyInVolume` in `harness/src/death-loop.ts`.
246///
247/// Here rather than in `delvec` for the reason [`step_allowed`] is here: the body
248/// is this table's, and a second copy of the arithmetic beside it is what this
249/// module exists to stop.
250#[must_use]
251pub fn selector_reaches_body_in_cell(lo: [i32; 3], hi: [i32; 3], cell: [i32; 3]) -> bool {
252 let half = PLAYER_WIDTH / 2.0;
253 // The extreme reach of a body standing anywhere in `cell`: horizontally every
254 // position of the cell, each carrying half a width past itself; vertically the
255 // feet on the cell floor and the head PLAYER_HEIGHT above them.
256 let body_lo = [
257 f64::from(cell[0]) - half,
258 f64::from(cell[1]),
259 f64::from(cell[2]) - half,
260 ];
261 let body_hi = [
262 f64::from(cell[0]) + 1.0 + half,
263 f64::from(cell[1]) + PLAYER_HEIGHT,
264 f64::from(cell[2]) + 1.0 + half,
265 ];
266 (0..3).all(|i| body_lo[i] <= f64::from(hi[i]) + 1.0 && body_hi[i] >= f64::from(lo[i]))
267}
268
269/// Ticks a jumping player spends off the ground, apex to landing included.
270pub const JUMP_AIRBORNE_TICKS: f64 = 12.0;
271
272/// Player walking speed on the flat, in blocks per second (not sprinting).
273pub const WALK_SPEED_BLOCKS_PER_SECOND: f64 = 4.317;
274
275/// Server ticks per second.
276pub const TICKS_PER_SECOND: f64 = 20.0;
277
278/// The fall distance in blocks below which vanilla deals no fall damage: damage
279/// is `ceil(distance − 3)` points, so a 3-block fall is free and a 4-block fall
280/// costs one.
281pub const FALL_DAMAGE_ONSET_BLOCKS: f64 = 3.0;
282
283/// Ticks a walking player spends crossing one block on the flat.
284///
285/// Derived rather than stored, because both operands are facts and nothing
286/// downstream decides a route on this number — it is the pacing denominator.
287#[must_use]
288pub fn walk_ticks_per_block() -> f64 {
289 TICKS_PER_SECOND / WALK_SPEED_BLOCKS_PER_SECOND
290}
291
292/// The largest fall an unarmoured player at full health survives, in blocks.
293///
294/// `ceil(d − 3) < 20` holds up to `d = 22`, which lands on one half-heart; 23
295/// blocks deals 20 and kills. Derived from [`FALL_DAMAGE_ONSET_BLOCKS`] and
296/// [`PLAYER_MAX_HEALTH`] so that moving either moves this, and it exists so the
297/// designed-drop policy beside it has a physical ceiling to be **tighter than**
298/// rather than a number chosen next to nothing.
299#[must_use]
300pub fn unarmoured_survivable_fall_blocks() -> f64 {
301 (FALL_DAMAGE_ONSET_BLOCKS + PLAYER_MAX_HEALTH - 1.0).floor()
302}
303
304/// Horizontal cells a standing body needs to pass: `ceil(0.6)`.
305#[must_use]
306pub fn passable_width_cells() -> u32 {
307 PLAYER_WIDTH.ceil() as u32
308}
309
310/// Vertical cells a standing body needs to pass: `ceil(1.8)`.
311///
312/// This is a **player** metric and the spec's building half listed it, which is
313/// the correction worth naming: the width and clearance at which a body can pass
314/// at all are functions of the collision box, so no walk can change them and
315/// `calibrated` would mean nothing on them. What the gym calibrates is the
316/// *designed* minimum — a way class's `min_width` and `min_clearance` — which is
317/// a comfort judgement and can never be chosen below this floor —
318/// [`Metrics::self_check`] is what holds it there, over every way class the
319/// table defines rather than over the two entries that used to stand alone.
320#[must_use]
321pub fn passable_clearance_cells() -> u32 {
322 PLAYER_HEIGHT.ceil() as u32
323}
324
325// ---------------------------------------------------------------------------
326// A body has a width — the one answer to "does this body meet this volume"
327// ---------------------------------------------------------------------------
328
329/// A body's standing collision box: `width × height × width`, centred
330/// horizontally on the body's position and rising from its feet.
331///
332/// It lives here for the reason [`step_allowed`] does. Three consumers ask the
333/// same geometric question of the same object class — the routing model asks
334/// which cells a walker may occupy beside a killing volume, the posted-place
335/// proof asks whether a body seated at a declared cell is inside one, and the
336/// emitter formats the runtime selector that decides both at play time — and a
337/// question with three answers is a question three gates can disagree about.
338/// The disagreement was not hypothetical: every proof reasoned in whole cells
339/// while the runtime selected on hitbox intersection, so the engine routed a
340/// player along a cell whose occupant the volume kills, chose that same cell as
341/// the "safe" lip a stake stands on, and reported both green.
342#[derive(Debug, Clone, Copy, PartialEq)]
343pub struct Body {
344 /// Collision-box width and depth, in blocks.
345 pub width: f64,
346 /// Collision-box height, in blocks.
347 pub height: f64,
348}
349
350impl Body {
351 /// The player's own body, from the constants above rather than from a
352 /// second pair of literals.
353 pub const PLAYER: Body = Body {
354 width: PLAYER_WIDTH,
355 height: PLAYER_HEIGHT,
356 };
357
358 /// A body of the given standing hitbox.
359 #[must_use]
360 pub fn new(width: f64, height: f64) -> Body {
361 Body { width, height }
362 }
363
364 /// Half the width — the reach of the box either side of the body's own
365 /// position.
366 #[must_use]
367 pub fn half_width(&self) -> f64 {
368 self.width / 2.0
369 }
370}
371
372/// The world-space AABB vanilla builds from a box selector over the inclusive
373/// cell box `lo..=hi`.
374///
375/// **This is a fact about the game, and it is what the emitter writes.** A box
376/// selector is spelled `x=lo,dx=hi-lo,…` (`emit::box_selector_args`), and
377/// vanilla's `EntitySelectorParser` turns `x`/`dx` into the half-open span
378/// `[x, x+dx+1]` — which is why `dx=0` selects the whole of one block rather
379/// than a plane. So the inclusive cell box `lo..=hi` is the world volume
380/// `[lo, hi+1]`, and an entity is selected when its **hitbox intersects** it,
381/// never when the cell it stands in is one of the box's cells.
382///
383/// Stated as a [`Provenance::VanillaRule`] would be: this repository has not
384/// measured the parser, but it has measured the consequence — bodies whose feet
385/// cell lies outside a declared volume are killed by it, at the two faces this
386/// arithmetic predicts.
387#[must_use]
388pub fn selection_aabb(lo: [i32; 3], hi: [i32; 3]) -> ([f64; 3], [f64; 3]) {
389 (
390 [lo[0] as f64, lo[1] as f64, lo[2] as f64],
391 [hi[0] as f64 + 1.0, hi[1] as f64 + 1.0, hi[2] as f64 + 1.0],
392 )
393}
394
395/// Does a body standing with its feet at `feet` meet the volume `lo..=hi`?
396///
397/// `feet` is the position vanilla keeps for an entity: the horizontal centre of
398/// its collision box, at the box's base. So the body occupies
399/// `[x ± w/2] × [y, y + h] × [z ± w/2]`, and the answer is whether that
400/// intersects [`selection_aabb`]. The comparison is strict on both sides, as
401/// vanilla's `AABB::intersects` is: a body whose face exactly touches the
402/// volume's face is not inside it.
403///
404/// This is the **exact** reading, for a body whose position is known — a summon
405/// writes literal coordinates, and `compiler::nav::cell_center` is the one place
406/// that says which coordinates a body seated on a cell gets. For a body that may
407/// stand anywhere within a cell, ask [`cell_can_meet_volume`] instead.
408#[must_use]
409pub fn body_meets_volume(feet: [f64; 3], body: Body, lo: [i32; 3], hi: [i32; 3]) -> bool {
410 let (vlo, vhi) = selection_aabb(lo, hi);
411 let half = body.half_width();
412 let blo = [feet[0] - half, feet[1], feet[2] - half];
413 let bhi = [feet[0] + half, feet[1] + body.height, feet[2] + half];
414 (0..3).all(|i| blo[i] < vhi[i] && bhi[i] > vlo[i])
415}
416
417/// **The inclusive cell box a body of these dimensions must keep out of** — the
418/// cells from which its hitbox can reach the volume `lo..=hi`.
419///
420/// A walker's cell does not fix its position: a body whose feet block is `c`
421/// stands anywhere in `[c, c+1)` horizontally, so its box spans
422/// `[c - w/2, c + 1 + w/2)`. The cells that can meet the volume are therefore
423/// the volume's own, widened by however many cells half a width reaches into —
424/// one, for every body in the engine's dims table, and the derivation rather
425/// than the constant is what is written here so a wider body widens it.
426///
427/// Vertically the body's feet sit at the cell's own floor and it rises `height`
428/// blocks, so the box reaches `ceil(height)` cells down from the volume's floor
429/// and not at all above its ceiling: standing **on top** of a volume is safe,
430/// standing under one with your head in it is not.
431///
432/// The direction is deliberate and is the opposite of
433/// `compiler::reach::ReachCompletion::certainly_completes_from`, which asks the
434/// same geometry about a volume that GRANTS something and so demands the certain
435/// case. Here the volume kills, so the refusal takes the possible case: a
436/// generous rule is safe for a reward and unsound for a hazard.
437#[must_use]
438pub fn keep_out_box(body: Body, lo: [i32; 3], hi: [i32; 3]) -> ([i32; 3], [i32; 3]) {
439 let half = body.half_width();
440 let mut out_lo = [0i32; 3];
441 let mut out_hi = [0i32; 3];
442 for i in [0usize, 2] {
443 // `c + 1 + half > lo` for some c, and `c - half < hi + 1`.
444 out_lo[i] = (lo[i] as f64 - 1.0 - half).floor() as i32 + 1;
445 out_hi[i] = (hi[i] as f64 + 1.0 + half).ceil() as i32 - 1;
446 }
447 // `c + height > lo_y` (feet at the cell floor), and `c <= hi_y` — a body
448 // whose feet are at `hi_y + 1` starts exactly where the volume ends.
449 out_lo[1] = (lo[1] as f64 - body.height).floor() as i32 + 1;
450 out_hi[1] = hi[1];
451 (out_lo, out_hi)
452}
453
454/// Can a body whose feet block is `cell` meet the volume `lo..=hi` from **some**
455/// position inside that cell? The refusing reading — see [`keep_out_box`].
456#[must_use]
457pub fn cell_can_meet_volume(cell: [i32; 3], body: Body, lo: [i32; 3], hi: [i32; 3]) -> bool {
458 let (klo, khi) = keep_out_box(body, lo, hi);
459 (0..3).all(|i| klo[i] <= cell[i] && cell[i] <= khi[i])
460}
461
462// ---------------------------------------------------------------------------
463// Entry shapes
464// ---------------------------------------------------------------------------
465
466/// Where a number came from. The four are not interchangeable — see the module
467/// docs.
468#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
469#[serde(rename_all = "kebab-case")]
470pub enum Provenance {
471 /// The number **is** a constant this module defines and the rest of the
472 /// workspace imports. One definition, so nothing to drift from.
473 EngineConstant,
474 /// A stated rule of pinned Minecraft Java 1.21.11 that no engine constant
475 /// held before this table, and that this repository has **not** measured on
476 /// a running server. The note names the rule so the claim is checkable.
477 VanillaRule,
478 /// Computed from other entries; the note carries the arithmetic.
479 Derived,
480 /// A seed for the metrics gym's calibration walk. Chosen, not established.
481 Provisional,
482}
483
484/// A named opening in the standard seam set, in cells.
485#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
486pub struct Opening {
487 /// Clear width, in cells.
488 pub width: u32,
489 /// Clear height, in cells.
490 pub height: u32,
491}
492
493/// A named stair-pitch standard: a rise:run pattern together with the vanilla
494/// blocks that realize it, and the per-step rise the realization actually
495/// presents to a walking body.
496#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
497pub struct Pitch {
498 /// Blocks of rise per `run` blocks of horizontal travel.
499 pub rise: u32,
500 /// Blocks of horizontal travel per `rise` blocks of rise.
501 pub run: u32,
502 /// The rise in sixteenths that one realized tread presents. A stair block
503 /// offers its lower half first, so a 1:1 stair run steps 8/16 twice per
504 /// block of rise rather than 16/16 once; a slab ramp steps 8/16 as well.
505 /// This is the number [`Metrics::self_check`] holds under the walk-up
506 /// budget, which is what makes "standard pitch" mean *walked*, not merely
507 /// *legal*.
508 pub step_16: i64,
509 /// The vanilla realization, named so the derivation above is checkable
510 /// against blocks rather than asserted.
511 pub realization: &'static str,
512}
513
514/// A rung of the size-class ladder — the vocabulary a layout-graph node declares
515/// and a site-plan box is judged against.
516#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
517pub struct SizeClass {
518 /// Smallest interior footprint, `[x, z]` in cells, inclusive.
519 pub min_footprint: [u32; 2],
520 /// Largest interior footprint, `[x, z]` in cells, inclusive.
521 pub max_footprint: [u32; 2],
522 /// Least interior clearance, in cells.
523 pub min_clearance: u32,
524 /// Nominal blocks of route a body walks crossing a place of this class —
525 /// the per-leg length the pacing projection sums over a critical path
526 /// (spec-0049 §3.3).
527 ///
528 /// The code that reads it is not named here on purpose: a `DW` number in a
529 /// source comment is a code as far as `tools/ci/check-dw-codes.py` is
530 /// concerned, and one whose check lands two rounds from now has no catalog
531 /// row to match, so naming it early reds the docs job on a rule nothing has
532 /// written yet.
533 pub nominal_traverse_blocks: u32,
534}
535
536/// A **way class** — the vocabulary a layout-graph node declares for a place
537/// whose footprint is bounded in one axis and free in the other: a road, a
538/// causeway, a corridor, a duct (spec-0053 §3).
539///
540/// # Why it is a second kind of classification and not a rung
541///
542/// The size-class ladder classifies a place by both horizontal extents at once,
543/// and that is what it is for. A route has no second extent to classify: a cut
544/// ledge one body wide climbing a whole seaward face is 4 by 90, and for any
545/// rung to admit it that rung would have to span 4..90 on an axis — a class in
546/// which an alcove and an expanse are the same thing has stopped classifying.
547/// The failure is by KIND, not by margin, which is why no calibration of the
548/// ladder reaches it.
549///
550/// # What it bounds, and what it deliberately does not
551///
552/// A way class bounds the **cross-section** — the axis a body feels walking it
553/// — and says nothing whatever about the run. A route's length is per-campaign
554/// geometry, not a standard: a village lane, a canyon rim trail, a ship's
555/// gangway and a mine gallery are the same class of thing at four wildly
556/// different lengths, and a `max_length` here would be this month's map wearing
557/// a standard's clothes. The run is *measured*, into pacing, and is never
558/// compared against anything (spec-0053 §7).
559///
560/// The elongation demand a way-classed box must satisfy is therefore
561/// **structural rather than a constant**: the run must exceed
562/// [`Self::max_width`], which is exactly what a room cannot supply. A square box
563/// can never qualify, because its "run" equals its width and one number cannot
564/// both be `<= max_width` and exceed it. That is what makes "declare it a way to
565/// escape the ladder" refused by the object's own shape rather than by a rule
566/// the author could satisfy by choosing differently.
567#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
568pub struct WayClass {
569 /// Narrowest cross-section, in cells, inclusive. Never below the physical
570 /// passable width — [`Metrics::self_check`] holds it there.
571 pub min_width: u32,
572 /// Widest cross-section, in cells, inclusive. Doubles as the elongation
573 /// floor: a way-classed box's run must **exceed** this.
574 pub max_width: u32,
575 /// Least interior clearance, in cells.
576 pub min_clearance: u32,
577}
578
579/// The kit grid: the quantum box extents are multiples of, and the datum
580/// convention that fixes what a declared `y` means.
581#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
582pub struct Grid {
583 /// The footprint quantum `q`, in blocks.
584 pub quantum: u32,
585 /// The axes extents are quantized on. Vertical extent is not quantized:
586 /// storey heights are their own entries and a box's height follows them.
587 pub axes: [&'static str; 2],
588 /// What a box's declared datum `y` names. `floor-surface`: the walk plane is
589 /// at `y`, and whatever stands in the box later puts its own floor there.
590 pub datum: &'static str,
591}
592
593/// One entry's value. `untagged`, so the export reads as the number or object it
594/// is rather than as a wrapper a consumer has to unpick.
595#[derive(Debug, Clone, PartialEq, Serialize)]
596#[serde(untagged)]
597pub enum MetricValue {
598 /// A count of cells, blocks or ticks.
599 Count(u32),
600 /// A measurement that is not a whole number of anything.
601 Number(f64),
602 /// A yes/no fact.
603 Flag(bool),
604 /// A named seam opening.
605 Opening(Opening),
606 /// A named stair pitch.
607 Pitch(Pitch),
608 /// A rung of the size-class ladder.
609 SizeClass(SizeClass),
610 /// A way class — a route's cross-section.
611 WayClass(WayClass),
612 /// The kit grid.
613 Grid(Grid),
614}
615
616/// One player metric: a fact of the pinned game.
617#[derive(Debug, Clone, PartialEq, Serialize)]
618pub struct PlayerEntry {
619 /// The value.
620 pub value: MetricValue,
621 /// What the value counts (`blocks`, `sixteenths`, `ticks`, …), or `none`.
622 pub unit: &'static str,
623 /// Where the number came from.
624 pub provenance: Provenance,
625 /// One sentence: the constant this **is**, the vanilla rule it states, or
626 /// the arithmetic it was derived by.
627 pub note: &'static str,
628}
629
630/// One building metric: a standard this project fixes.
631///
632/// The value is deliberately not a public field. [`BuildingEntry::value`] is the
633/// only way to read it and it takes `&mut `[`Reads`], so nothing can rest a
634/// verdict on an uncalibrated standard without saying that it did.
635#[derive(Debug, Clone, PartialEq, Serialize)]
636pub struct BuildingEntry {
637 /// The entry's own key, carried so a read records itself without the caller
638 /// having to repeat the name it looked up under.
639 key: &'static str,
640 value: MetricValue,
641 /// What the value counts, or `none`.
642 pub unit: &'static str,
643 /// Where the number came from. Orthogonal to `calibrated`: a pitch's
644 /// geometry can be derived from vanilla blocks while the judgement that it
645 /// is *the* standard is still unwalked.
646 pub provenance: Provenance,
647 /// Whether the metrics gym's walk has ruled on this value. `false` on every
648 /// entry at this version.
649 pub calibrated: bool,
650 /// One sentence: what the number is for and what the gym is being asked to
651 /// decide about it.
652 pub note: &'static str,
653}
654
655impl BuildingEntry {
656 /// Read the value, recording the read.
657 ///
658 /// The `&mut `[`Reads`] is the whole mechanism: [`Metrics::notice`] reports
659 /// exactly the uncalibrated entries a run actually consumed, so `DW0813`
660 /// cannot be forgotten by a check that reads one and can never fire over a
661 /// standard nothing looked at.
662 #[must_use]
663 pub fn value(&self, reads: &mut Reads) -> &MetricValue {
664 reads.record(self);
665 &self.value
666 }
667
668 /// The value with no read recorded — for rendering the table, never for
669 /// deciding anything.
670 ///
671 /// Reachable only inside this crate, and used at exactly one site: the
672 /// export, which reports the table rather than resting a verdict on it. A
673 /// serialization is not a verdict, and counting it as one would put every
674 /// entry in every `DW0813` line and make the code mean nothing.
675 pub(crate) fn value_for_display(&self) -> &MetricValue {
676 &self.value
677 }
678
679 /// The entry's key.
680 #[must_use]
681 pub fn key(&self) -> &'static str {
682 self.key
683 }
684}
685
686/// The building metrics a run's verdicts have read.
687///
688/// Deterministic (ADR-0006): a `BTreeSet` of `&'static str`, so the order a
689/// `DW0813` line names them in is the table's own order and not the order the
690/// checks happened to run in.
691#[derive(Debug, Clone, Default, PartialEq, Eq)]
692pub struct Reads {
693 read: BTreeSet<&'static str>,
694 provisional: BTreeSet<&'static str>,
695}
696
697impl Reads {
698 /// A ledger nothing has read through yet.
699 #[must_use]
700 pub fn new() -> Self {
701 Self::default()
702 }
703
704 fn record(&mut self, entry: &BuildingEntry) {
705 self.read.insert(entry.key);
706 if !entry.calibrated {
707 self.provisional.insert(entry.key);
708 }
709 }
710
711 /// How many building metrics this run's verdicts read, and how many of those
712 /// the gym has not walked.
713 #[must_use]
714 pub fn binding(&self) -> ReadBinding {
715 ReadBinding {
716 read: self.read.len(),
717 provisional: self.provisional.len(),
718 }
719 }
720
721 /// The uncalibrated entries read, in table order.
722 #[must_use]
723 pub fn provisional(&self) -> Vec<&'static str> {
724 self.provisional.iter().copied().collect()
725 }
726
727 /// Every entry read, in table order.
728 ///
729 /// The metrics gym's coverage numerator: an entry the gym's construction
730 /// never read is an entry no bay was built from, so a walk of the gym cannot
731 /// rule on it. Taking it from the same ledger the reads are recorded in is
732 /// what stops the gym's own coverage claim being a list somebody maintains
733 /// beside the generator.
734 #[must_use]
735 pub fn read(&self) -> BTreeSet<&'static str> {
736 self.read.clone()
737 }
738}
739
740/// What a run's building-metric reads bound to.
741#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
742pub struct ReadBinding {
743 /// Building metrics read.
744 pub read: usize,
745 /// Of those, entries whose `calibrated` is false.
746 pub provisional: usize,
747}
748
749/// A name a document wrote that this table does not define — `DW0812`'s payload.
750///
751/// It carries the defined set as well as the bad name, because the author's next
752/// action is choosing a real one and a refusal that only says *no* sends them to
753/// read the compiler.
754#[derive(Debug, Clone, PartialEq, Eq)]
755pub struct UnknownMetric {
756 /// The kind of entry the document was naming (`size class`, `opening`, …).
757 pub kind: &'static str,
758 /// The same kind, plural — carried rather than derived, because three of the
759 /// six nouns end in a sibilant and `{kind}s` is wrong for them.
760 pub kind_plural: &'static str,
761 /// The name it wrote.
762 pub named: String,
763 /// Every name this table does define of that kind, in table order.
764 pub defined: Vec<&'static str>,
765}
766
767impl UnknownMetric {
768 /// The `DW0812` refusal, located by the caller.
769 #[must_use]
770 pub fn diagnostic(&self, stage: &str, path: &str) -> Diagnostic {
771 let defined = if self.defined.is_empty() {
772 "nothing".to_string()
773 } else {
774 self.defined.join(", ")
775 };
776 Diagnostic::error(
777 DW_METRIC_UNKNOWN,
778 stage,
779 path,
780 format!(
781 "the metrics table defines no {kind} called `{named}`. The table is \
782 the single authority for this vocabulary, so a name it does not \
783 define cannot compile and no check downstream has to cope with one. \
784 Defined {plural}: {defined}. Run `delvec metrics` for the whole \
785 table, including what each entry is for.",
786 kind = self.kind,
787 plural = self.kind_plural,
788 named = self.named,
789 ),
790 )
791 }
792}
793
794// ---------------------------------------------------------------------------
795// The table
796// ---------------------------------------------------------------------------
797
798/// The metrics standard, as exported and as read.
799#[derive(Debug, Clone, PartialEq, Serialize)]
800pub struct Metrics {
801 /// The table's revision.
802 pub metrics_version: u32,
803 /// The Minecraft version the player half states facts about.
804 pub mc_version: &'static str,
805 /// Facts of the pinned game, in key order.
806 pub player: BTreeMap<&'static str, PlayerEntry>,
807 /// Standards this project fixes, in key order.
808 pub building: BTreeMap<&'static str, BuildingEntry>,
809}
810
811/// Kinds of building entry a document can name, for [`Metrics::resolve`].
812#[derive(Debug, Clone, Copy, PartialEq, Eq)]
813pub enum MetricKind {
814 /// A seam opening (`opening.<name>`).
815 Opening,
816 /// A stair pitch (`pitch.<name>`).
817 Pitch,
818 /// A rung of the size-class ladder (`size-class.<name>`).
819 SizeClass,
820 /// A way class (`way-class.<name>`) — the second kind of place
821 /// classification (spec-0053 §3). A [`MetricKind`] rather than a lookup of
822 /// its own for the reason [`Metrics::resolve`] gives: this is a name a
823 /// DOCUMENT writes, so it goes through the one path from a name to an entry
824 /// and a name the table does not define is `DW0812` here exactly as it is
825 /// for a size class.
826 WayClass,
827 /// A storey height (`storey.<name>`).
828 Storey,
829 /// A pacing coefficient (`pacing.<name>`) — blocks of route per minute of
830 /// play. Named like the rest of the vocabulary rather than reached by a
831 /// second lookup, because [`Metrics::resolve`] is the ONE path from a name
832 /// to an entry and a coefficient read any other way would be a second
833 /// authority (spec-0049 §2.2). No campaign document names one; the pacing
834 /// projection does.
835 Pacing,
836}
837
838impl MetricKind {
839 /// The key prefix entries of this kind carry.
840 #[must_use]
841 pub fn prefix(self) -> &'static str {
842 match self {
843 MetricKind::Opening => "opening.",
844 MetricKind::Pitch => "pitch.",
845 MetricKind::SizeClass => "size-class.",
846 MetricKind::WayClass => "way-class.",
847 MetricKind::Storey => "storey.",
848 MetricKind::Pacing => "pacing.",
849 }
850 }
851
852 /// What the kind is called in a refusal, **plural**.
853 ///
854 /// A fact about the kind rather than an `s` appended where a message needed
855 /// one: three of the six nouns end in a sibilant, so `{noun}s` reads
856 /// `size classs`, `stair pitchs` and `way classs`. Written out here, a kind
857 /// added later cannot inherit that by default — it has to answer.
858 #[must_use]
859 pub fn plural(self) -> &'static str {
860 match self {
861 MetricKind::Opening => "seam openings",
862 MetricKind::Pitch => "stair pitches",
863 MetricKind::SizeClass => "size classes",
864 MetricKind::WayClass => "way classes",
865 MetricKind::Storey => "storey heights",
866 MetricKind::Pacing => "pacing coefficients",
867 }
868 }
869
870 /// What the kind is called in a refusal.
871 #[must_use]
872 pub fn noun(self) -> &'static str {
873 match self {
874 MetricKind::Opening => "seam opening",
875 MetricKind::Pitch => "stair pitch",
876 MetricKind::SizeClass => "size class",
877 MetricKind::WayClass => "way class",
878 MetricKind::Storey => "storey height",
879 MetricKind::Pacing => "pacing coefficient",
880 }
881 }
882}
883
884fn player(
885 value: MetricValue,
886 unit: &'static str,
887 provenance: Provenance,
888 note: &'static str,
889) -> PlayerEntry {
890 PlayerEntry {
891 value,
892 unit,
893 provenance,
894 note,
895 }
896}
897
898fn building(
899 key: &'static str,
900 value: MetricValue,
901 unit: &'static str,
902 provenance: Provenance,
903 note: &'static str,
904) -> (&'static str, BuildingEntry) {
905 (
906 key,
907 BuildingEntry {
908 key,
909 value,
910 unit,
911 provenance,
912 // Every entry lands uncalibrated. The gym walk flips it, one entry
913 // at a time, and that walk is the only thing that may.
914 calibrated: false,
915 note,
916 },
917 )
918}
919
920impl Metrics {
921 /// The table.
922 ///
923 /// Rebuilt per call rather than held in a `static`, because the values are a
924 /// handful of maps and a `static` would need interior mutability the moment
925 /// the gym's walk starts flipping `calibrated`. Deterministic by
926 /// construction: `BTreeMap`, no clock, no environment.
927 #[must_use]
928 pub fn table() -> Self {
929 let player_entries: Vec<(&'static str, PlayerEntry)> = vec![
930 (
931 "body.width",
932 player(
933 MetricValue::Number(PLAYER_WIDTH),
934 "blocks",
935 Provenance::EngineConstant,
936 "The standing collision box is 0.6 wide; `dsl::metrics::PLAYER_WIDTH` \
937 is the definition `compiler::nav`'s entity dims table and \
938 `compiler::crosshair` both import.",
939 ),
940 ),
941 (
942 "body.height",
943 player(
944 MetricValue::Number(PLAYER_HEIGHT),
945 "blocks",
946 Provenance::EngineConstant,
947 "The standing collision box is 1.8 tall; the same definition the \
948 entity dims table reads for `minecraft:player`.",
949 ),
950 ),
951 (
952 "body.crouched-height",
953 player(
954 MetricValue::Number(PLAYER_CROUCHED_HEIGHT),
955 "blocks",
956 Provenance::VanillaRule,
957 "A sneaking player's collision box is 1.5 tall, which is what lets a \
958 body pass under a cell a standing one cannot. No engine constant \
959 held this before the table, and nothing in this workspace has \
960 measured it on a running server.",
961 ),
962 ),
963 (
964 "body.eye-height",
965 player(
966 MetricValue::Number(PLAYER_EYE_HEIGHT),
967 "blocks",
968 Provenance::EngineConstant,
969 "The eye sits 1.62 above the floor of the cell the body stands in. \
970 This is the definition the render plan, the viewer page and the \
971 creator overlay all import, having each declared their own before.",
972 ),
973 ),
974 (
975 "body.max-health",
976 player(
977 MetricValue::Number(PLAYER_MAX_HEALTH),
978 "half-hearts",
979 Provenance::EngineConstant,
980 "Twenty points of health, the definition `compiler::combat` imports \
981 for its winnability arithmetic.",
982 ),
983 ),
984 (
985 "step.walk-up",
986 player(
987 MetricValue::Count(MAX_AUTO_STEP_16 as u32),
988 "sixteenths",
989 Provenance::EngineConstant,
990 "The largest rise a walker crosses without jumping: vanilla's player \
991 `maxUpStep` is 0.6 blocks and 9/16 is the largest sixteenth under \
992 it. `compiler::nav` imports this as its step rule's walk-up budget.",
993 ),
994 ),
995 (
996 "step.jump-rise",
997 player(
998 MetricValue::Count(MAX_JUMP_RISE_16 as u32),
999 "sixteenths",
1000 Provenance::EngineConstant,
1001 "The largest rise a walker reaches by jumping: the apex is ≈1.2522 \
1002 blocks, so 20/16 is reachable and 21/16 is not. `compiler::nav` \
1003 imports this, and it is why a slab-to-full-block step-up of 1.5 is \
1004 the impossible move it is.",
1005 ),
1006 ),
1007 (
1008 "step.block",
1009 player(
1010 MetricValue::Count(FULL_16 as u32),
1011 "sixteenths",
1012 Provenance::EngineConstant,
1013 "A full block in the sixteenths the step rule is denominated in, so \
1014 that no comparison in the navigation model is a float.",
1015 ),
1016 ),
1017 (
1018 "jump.airborne",
1019 player(
1020 MetricValue::Number(JUMP_AIRBORNE_TICKS),
1021 "ticks",
1022 Provenance::VanillaRule,
1023 "A jump is airborne about twelve ticks, apex to landing. This was \
1024 prose in the navigation model's elevation-weight derivation and \
1025 nothing could read it; the table is where it becomes data.",
1026 ),
1027 ),
1028 (
1029 "walk.speed",
1030 player(
1031 MetricValue::Number(WALK_SPEED_BLOCKS_PER_SECOND),
1032 "blocks/second",
1033 Provenance::VanillaRule,
1034 "A walking player covers 4.317 blocks a second on the flat; \
1035 sprinting is faster and is not the pacing basis, because a route \
1036 nobody has learnt is walked.",
1037 ),
1038 ),
1039 (
1040 "walk.ticks-per-block",
1041 player(
1042 MetricValue::Number(walk_ticks_per_block()),
1043 "ticks",
1044 Provenance::Derived,
1045 "Twenty ticks a second over 4.317 blocks a second. Against the \
1046 twelve airborne ticks of a jump this is what makes a block of \
1047 climb cost about two and a half blocks of walking, which is where \
1048 the navigation model's elevation weight of two comes from.",
1049 ),
1050 ),
1051 (
1052 "fluid.passable",
1053 player(
1054 MetricValue::Flag(false),
1055 "none",
1056 Provenance::VanillaRule,
1057 "Water and lava are impassable to every proof in this engine and are \
1058 never floor either: a body cannot stand on a fluid surface, so the \
1059 two sets are disjoint and both gate standability. Marked as a rule \
1060 rather than an engine constant deliberately — the navigation model \
1061 encodes this as the SHAPE of its occupancy sets and not as a shared \
1062 `const`, so there is no single definition for this row to be, and \
1063 claiming otherwise would be the overclaim the provenance field \
1064 exists to prevent.",
1065 ),
1066 ),
1067 (
1068 "fall.damage-onset",
1069 player(
1070 MetricValue::Number(FALL_DAMAGE_ONSET_BLOCKS),
1071 "blocks",
1072 Provenance::VanillaRule,
1073 "Fall damage is `ceil(distance − 3)` points, so a three-block fall is \
1074 free and a four-block fall costs one. Stated from the vanilla \
1075 damage rule; this repository has not measured it on a running \
1076 server.",
1077 ),
1078 ),
1079 (
1080 "fall.unarmoured-survivable",
1081 player(
1082 MetricValue::Number(unarmoured_survivable_fall_blocks()),
1083 "blocks",
1084 Provenance::Derived,
1085 "`ceil(distance − 3) < 20` holds up to 22 blocks, which lands a \
1086 full-health unarmoured body on one half-heart; 23 deals twenty and \
1087 kills. The survivable ceiling is a function of health and armour, \
1088 and this is its unarmoured, full-health case — the physical bound \
1089 the designed-drop policy is deliberately tighter than.",
1090 ),
1091 ),
1092 (
1093 "passable.width",
1094 player(
1095 MetricValue::Count(passable_width_cells()),
1096 "cells",
1097 Provenance::Derived,
1098 "`ceil(0.6)`: one cell is the narrowest a standing body fits \
1099 through. This is a fact, not a standard — no walk can change it, \
1100 and it is the floor the designed corridor minimum may never be \
1101 chosen below.",
1102 ),
1103 ),
1104 (
1105 "passable.clearance",
1106 player(
1107 MetricValue::Count(passable_clearance_cells()),
1108 "cells",
1109 Provenance::Derived,
1110 "`ceil(1.8)`: two cells is the lowest a standing body passes under. \
1111 Like the width beside it this is a fact rather than a standard, \
1112 which is why neither carries a calibration flag.",
1113 ),
1114 ),
1115 ];
1116
1117 let building_entries: Vec<(&'static str, BuildingEntry)> = vec![
1118 building(
1119 "grid",
1120 MetricValue::Grid(Grid {
1121 quantum: 4,
1122 axes: ["x", "z"],
1123 datum: "floor-surface",
1124 }),
1125 "blocks",
1126 Provenance::Provisional,
1127 "The footprint quantum every site-plan box's horizontal extents are \
1128 multiples of, and the datum convention: a box's floor SURFACE is at \
1129 its declared y, and whatever stands in the box later puts its walk \
1130 plane there. Four is a seed and nothing in the existing piece library \
1131 argues for it — the cave tileset is odd on every axis and the keep \
1132 tileset is even but not quartered — so what the gym is being asked is \
1133 whether a quantum this fine buys anything a coarser one would not.",
1134 ),
1135 building(
1136 "way-class.corridor",
1137 MetricValue::WayClass(WayClass {
1138 min_width: 2,
1139 max_width: 4,
1140 min_clearance: 3,
1141 }),
1142 "cells",
1143 Provenance::Provisional,
1144 "The narrow way: a passage, a duct, a gallery cut through rock. Its \
1145 `min_width` and `min_clearance` ARE the two numbers this table used to \
1146 publish as `corridor.min-width` and `corridor.min-clearance` — one \
1147 cell is passable and reads as a crawlspace, two lets two bodies pass, \
1148 and two blocks of clearance puts the ceiling on the walker's head — and \
1149 they are fields here rather than entries of their own so that there is \
1150 one authority for the narrow way rather than a class beside two loose \
1151 numbers nothing could spell. The gym walks widths one, two and three \
1152 and clearances two, three and four. It is also asked a question that \
1153 could not be posed while these numbers were unreachable: the kit \
1154 quantum beside them is 4 and every box extent is a multiple of it, so \
1155 the narrowest way any plan can currently DRAW is four cells, and the \
1156 walk decides whether the floor moves up or the quantum moves down.",
1157 ),
1158 building(
1159 "way-class.road",
1160 MetricValue::WayClass(WayClass {
1161 min_width: 4,
1162 max_width: 16,
1163 min_clearance: 6,
1164 }),
1165 "cells",
1166 Provenance::Provisional,
1167 "The broad way: a village lane, a causeway, a quay, a ledge cut across a \
1168 cliff face. Wide enough that a party walks it abreast and something can \
1169 come the other way, which is the difference from the corridor beside it \
1170 and is what the walk is being asked to place. The clearance seed is \
1171 higher than the corridor's because a way this wide reads as roofless \
1172 even when it is not, and a low ceiling over a broad floor is the one \
1173 combination that reads as a mistake.",
1174 ),
1175 building(
1176 "opening.door",
1177 MetricValue::Opening(Opening {
1178 width: 1,
1179 height: 2,
1180 }),
1181 "cells",
1182 Provenance::Provisional,
1183 "The narrow seam: one body at a time, the size of a vanilla door. Seeded \
1184 at the smallest opening a standing body passes, so the gym is deciding \
1185 whether the tightest legal seam is one anybody wants to walk.",
1186 ),
1187 building(
1188 "opening.arch",
1189 MetricValue::Opening(Opening {
1190 width: 2,
1191 height: 3,
1192 }),
1193 "cells",
1194 Provenance::Provisional,
1195 "The ordinary seam between two interior places: two abreast, headroom \
1196 over both.",
1197 ),
1198 building(
1199 "opening.passage",
1200 MetricValue::Opening(Opening {
1201 width: 3,
1202 height: 3,
1203 }),
1204 "cells",
1205 Provenance::Derived,
1206 "The three-by-three doorway the existing jigsaw socket conventions \
1207 already standardize on — `cave:socket` and `tk:socket` are both this \
1208 opening, so the prefab library has been built against it for as long \
1209 as it has existed. Derived from that convention rather than chosen \
1210 here, and still uncalibrated: what the gym decides is whether the \
1211 convention is right, not what it is.",
1212 ),
1213 building(
1214 "opening.gateway",
1215 MetricValue::Opening(Opening {
1216 width: 5,
1217 height: 5,
1218 }),
1219 "cells",
1220 Provenance::Provisional,
1221 "The broad seam a thing of scenery scale passes: a cart, a barge, a \
1222 processional. Seeded wide enough to read as an event from inside the \
1223 place it opens onto, which is the judgement the walk is for.",
1224 ),
1225 building(
1226 "pitch.stair",
1227 MetricValue::Pitch(Pitch {
1228 rise: 1,
1229 run: 1,
1230 step_16: 8,
1231 realization: "minecraft:*_stairs",
1232 }),
1233 "none",
1234 Provenance::Derived,
1235 "One block of rise per block of run, realized in stair blocks. The \
1236 geometry is vanilla's: a stair offers its lower half first, so the \
1237 body walks two eight-sixteenth steps per block of rise and never \
1238 jumps. What is uncalibrated is the comfort judgement — whether a climb \
1239 this steep is one a player wants to make repeatedly.",
1240 ),
1241 building(
1242 "pitch.ramp",
1243 MetricValue::Pitch(Pitch {
1244 rise: 1,
1245 run: 2,
1246 step_16: 8,
1247 realization: "minecraft:*_slab + full block",
1248 }),
1249 "none",
1250 Provenance::Derived,
1251 "One block of rise per two of run, realized as a bottom slab then a full \
1252 block. Same eight-sixteenth tread as the stair and half the pitch, so \
1253 it is the gentle standard; the run it costs is what the gym weighs it \
1254 on.",
1255 ),
1256 building(
1257 "storey.low",
1258 MetricValue::Count(5),
1259 "blocks",
1260 Provenance::Derived,
1261 "Floor course, three cells of interior clearance, ceiling course. Taken \
1262 from the existing cave tileset, every passage and room of which is five \
1263 blocks tall, so this is the storey the shipped library already has \
1264 rather than a number invented here.",
1265 ),
1266 building(
1267 "storey.standard",
1268 MetricValue::Count(8),
1269 "blocks",
1270 Provenance::Provisional,
1271 "The storey an interior room of consequence gets: six cells of clearance \
1272 between courses. A seed, and the walk is what says whether a room this \
1273 tall reads as generous or merely as far away.",
1274 ),
1275 building(
1276 "storey.hall",
1277 MetricValue::Count(14),
1278 "blocks",
1279 Provenance::Provisional,
1280 "The storey a hall gets, where the height itself is the effect. The seed \
1281 is deliberately at the point where volume starts costing walking time \
1282 for nothing, because that is the trade the walk has to judge.",
1283 ),
1284 building(
1285 "size-class.alcove",
1286 MetricValue::SizeClass(SizeClass {
1287 min_footprint: [4, 4],
1288 max_footprint: [8, 8],
1289 min_clearance: 3,
1290 nominal_traverse_blocks: 6,
1291 }),
1292 "cells",
1293 Provenance::Provisional,
1294 "A place a body stands in rather than crosses: a shrine, a landing, a \
1295 cell. The smallest rung of the ladder, and the whole ladder's bounds \
1296 are seeds — what the walk fixes is where one class stops feeling like \
1297 the next.",
1298 ),
1299 building(
1300 "size-class.room",
1301 MetricValue::SizeClass(SizeClass {
1302 min_footprint: [8, 8],
1303 max_footprint: [16, 16],
1304 min_clearance: 4,
1305 nominal_traverse_blocks: 12,
1306 }),
1307 "cells",
1308 Provenance::Provisional,
1309 "A place with a purpose and something in it: a guardroom, a chapel, a \
1310 workshop.",
1311 ),
1312 building(
1313 "size-class.hall",
1314 MetricValue::SizeClass(SizeClass {
1315 min_footprint: [16, 16],
1316 max_footprint: [32, 32],
1317 min_clearance: 8,
1318 nominal_traverse_blocks: 24,
1319 }),
1320 "cells",
1321 Provenance::Provisional,
1322 "A place a fight or a crowd fits in, and the smallest rung whose height \
1323 is doing work of its own.",
1324 ),
1325 building(
1326 "size-class.arena",
1327 MetricValue::SizeClass(SizeClass {
1328 min_footprint: [32, 32],
1329 max_footprint: [64, 64],
1330 min_clearance: 12,
1331 nominal_traverse_blocks: 48,
1332 }),
1333 "cells",
1334 Provenance::Provisional,
1335 "A place built around one encounter, with room to retreat and re-approach.",
1336 ),
1337 building(
1338 "size-class.expanse",
1339 MetricValue::SizeClass(SizeClass {
1340 min_footprint: [64, 64],
1341 max_footprint: [128, 128],
1342 min_clearance: 16,
1343 nominal_traverse_blocks: 96,
1344 }),
1345 "cells",
1346 Provenance::Provisional,
1347 "A shore, a valley floor, a cavern — a place whose job is that crossing \
1348 it takes time. The rung most at risk of being a big empty room, which \
1349 is what the walk is watching for.",
1350 ),
1351 building(
1352 "drop.max-designed-rise",
1353 MetricValue::Count(5),
1354 "blocks",
1355 Provenance::Provisional,
1356 "The deepest fall a designed one-way drop edge may declare. A policy \
1357 cap, not a physical one: the unarmoured survivable fall beside it in \
1358 the player half is 22 blocks, and this is far tighter on purpose, \
1359 because a drop is a topology decision and should not also be a health \
1360 decision. Five costs two of twenty at full health, which is the seed \
1361 the walk argues with.",
1362 ),
1363 building(
1364 "pacing.route-blocks-per-minute",
1365 MetricValue::Count(60),
1366 "blocks/minute",
1367 Provenance::Provisional,
1368 "Blocks of route a party gets through per minute of play, once looking, \
1369 fighting and backtracking are in it. Carried with NO THRESHOLD \
1370 anywhere until the first walked blockout and the first full playtest \
1371 calibrate it: a threshold on a number this uncertain would be defending \
1372 nothing. Its upper bound is the pure-walk figure beside it, which no \
1373 party achieves.",
1374 ),
1375 building(
1376 "pacing.walk-only-blocks-per-minute",
1377 MetricValue::Count((WALK_SPEED_BLOCKS_PER_SECOND * 60.0) as u32),
1378 "blocks/minute",
1379 Provenance::Derived,
1380 "Walking speed times sixty: what a body covers doing nothing but \
1381 walking in a straight line. It exists so the route coefficient above \
1382 has a ceiling that is a fact rather than another guess, and so the \
1383 ratio between them is the thing the playtest actually measures.",
1384 ),
1385 ];
1386
1387 Metrics {
1388 metrics_version: METRICS_VERSION,
1389 mc_version: crate::blocks::MC_VERSION,
1390 player: player_entries.into_iter().collect(),
1391 building: building_entries.into_iter().collect(),
1392 }
1393 }
1394
1395 /// Resolve a name a **document** wrote to its building entry.
1396 ///
1397 /// This is the **only** path from an authored name to an entry, and it is
1398 /// what makes the table the single authority rather than a suggestion: a
1399 /// name it does not define cannot be resolved, so it cannot compile, so no
1400 /// check downstream ever meets one.
1401 ///
1402 /// The other half of that guarantee is that no key string is spelled outside
1403 /// this module. The entries no document names — the kit grid, the designed-
1404 /// drop cap — are reached through the accessors below rather than by looking
1405 /// the key up in [`Metrics::building`], which is public so that a *reporter*
1406 /// can walk the whole table (`delvec metrics` counts it; the tests iterate
1407 /// it). Reporting is not resolution: a caller that walks every entry cannot
1408 /// name a wrong one, and a caller that wants ONE entry has an accessor and
1409 /// therefore no reason to type a key.
1410 ///
1411 /// # Errors
1412 ///
1413 /// [`UnknownMetric`], which the caller turns into `DW0812` with its own
1414 /// stage and path.
1415 pub fn resolve(&self, kind: MetricKind, named: &str) -> Result<&BuildingEntry, UnknownMetric> {
1416 let key = format!("{}{}", kind.prefix(), named);
1417 self.building
1418 .get(key.as_str())
1419 .ok_or_else(|| UnknownMetric {
1420 kind: kind.noun(),
1421 kind_plural: kind.plural(),
1422 named: named.to_string(),
1423 defined: self.names_of(kind),
1424 })
1425 }
1426
1427 /// The kit grid — the quantum a site-plan box's footprint is a multiple of,
1428 /// and the datum convention that fixes what a declared floor `y` means.
1429 ///
1430 /// One of the entries **no document names**: an author writes a number, not
1431 /// the word `grid`, so it has no place in [`Metrics::resolve`]'s naming
1432 /// vocabulary and would need a [`MetricKind`] whose prefix is the empty
1433 /// string — which would make `names_of` return the whole table. An accessor
1434 /// instead, so the key string still lives here and nowhere else.
1435 ///
1436 /// `None` only if the table stopped defining it, which
1437 /// [`Metrics::self_check`] reports as an internal error.
1438 #[must_use]
1439 pub fn grid(&self, reads: &mut Reads) -> Option<Grid> {
1440 match self.building.get("grid")?.value(reads) {
1441 MetricValue::Grid(g) => Some(*g),
1442 _ => None,
1443 }
1444 }
1445
1446 /// The deepest fall a **designed** one-way drop may declare, in blocks — a
1447 /// policy cap, deliberately tighter than the survivability fact in the
1448 /// player half. See [`Metrics::grid`] for why this is an accessor.
1449 #[must_use]
1450 pub fn max_designed_drop_blocks(&self, reads: &mut Reads) -> Option<u32> {
1451 match self.building.get("drop.max-designed-rise")?.value(reads) {
1452 MetricValue::Count(n) => Some(*n),
1453 _ => None,
1454 }
1455 }
1456
1457 /// The **widest** standard opening in the table, in cells — the floor a
1458 /// contact seam's span must exceed (spec-0053 §4).
1459 ///
1460 /// Derived from the table rather than seeded, and that is the whole of why
1461 /// the floor is honest: anything at or under this width **could have been a
1462 /// portal**, so a doorway declared a contact to dodge the standard set is
1463 /// refused by its own width. A seeded floor would be a number an author
1464 /// could argue with; this one is a consequence of the standard set, and it
1465 /// moves when the standard set moves.
1466 ///
1467 /// It walks every opening rather than naming one, so a broader standard
1468 /// landing tomorrow raises the floor with no edit here — the failure mode a
1469 /// hand-named `opening.gateway` would have is that the floor silently stops
1470 /// being the broadest the day a broader one is added.
1471 ///
1472 /// `None` only if the table defines no opening at all, which
1473 /// [`Metrics::self_check`] reports as an internal error.
1474 #[must_use]
1475 pub fn broadest_opening_width(&self, reads: &mut Reads) -> Option<u32> {
1476 let mut widest: Option<u32> = None;
1477 for name in self.names_of(MetricKind::Opening) {
1478 let Ok(entry) = self.resolve(MetricKind::Opening, name) else {
1479 continue;
1480 };
1481 if let MetricValue::Opening(o) = entry.value(reads) {
1482 widest = Some(widest.map_or(o.width, |w: u32| w.max(o.width)));
1483 }
1484 }
1485 widest
1486 }
1487
1488 /// Every name defined for a kind, in table order.
1489 #[must_use]
1490 pub fn names_of(&self, kind: MetricKind) -> Vec<&'static str> {
1491 self.building
1492 .keys()
1493 .filter_map(|k| k.strip_prefix(kind.prefix()))
1494 .collect()
1495 }
1496
1497 /// The `DW0813` notice for a run, or `None` when no verdict rested on an
1498 /// unwalked standard.
1499 ///
1500 /// `None` at a zero binding is the calibrated end state and not a vacuity:
1501 /// the line's job is to say that a green rests on a seed, and once the gym
1502 /// has walked every entry a run reads there is nothing left for it to say.
1503 /// The distinguishable failure — a run that read NOTHING — is reported by
1504 /// [`Reads::binding`], which every caller states whether or not this returns
1505 /// a line.
1506 #[must_use]
1507 pub fn notice(&self, reads: &Reads, stage: &str) -> Option<Diagnostic> {
1508 let provisional = reads.provisional();
1509 if provisional.is_empty() {
1510 return None;
1511 }
1512 let binding = reads.binding();
1513 Some(Diagnostic::warning(
1514 DW_METRIC_PROVISIONAL,
1515 stage,
1516 "",
1517 format!(
1518 "{n} of the {read} building metric(s) this run read are provisional — the \
1519 metrics gym has not walked them: {names}. The checks still ran and still \
1520 refuse; what is unproven is the number they refused against.",
1521 n = binding.provisional,
1522 read = binding.read,
1523 names = provisional.join(", "),
1524 ),
1525 ))
1526 }
1527
1528 /// Check the table against itself and against the player half.
1529 ///
1530 /// These are verdicts, and they read building metrics through a [`Reads`], so
1531 /// they are also what gives `DW0813` a live binding at this version: `delvec
1532 /// metrics` runs them, and the notice names the seeds the consistency verdict
1533 /// rested on. They are the reason the mechanism is demonstrable now rather
1534 /// than at the round that adds the documents.
1535 ///
1536 /// A violation is an **internal error**, not a diagnostic: the table is
1537 /// engine data, so an inconsistent one is a defect in this file and not in
1538 /// anybody's campaign, and there is no author to address a refusal to.
1539 #[must_use]
1540 pub fn self_check(&self) -> SelfCheck {
1541 let mut reads = Reads::new();
1542 let mut failures: Vec<String> = Vec::new();
1543 let mut checked = 0usize;
1544
1545 let floor_w = u64::from(passable_width_cells());
1546 let floor_h = u64::from(passable_clearance_cells());
1547
1548 // A place that is a route must be spellable at all. Zero way classes is
1549 // the state spec-0053 was written to end — the metrics gym reported
1550 // `corridor.min-width` and `corridor.min-clearance` unreachable because
1551 // no document could name a place that is not a box with a size class —
1552 // so an empty way vocabulary is an internal error rather than a table
1553 // that happens to be short one kind.
1554 checked += 1;
1555 if self.names_of(MetricKind::WayClass).is_empty() {
1556 failures.push(
1557 "the table defines no way class, so no document can state a place that is \
1558 a route"
1559 .to_string(),
1560 );
1561 }
1562
1563 // The contact floor is derived from the standard opening set (spec-0053
1564 // §4), so an empty set would make that floor `None` and the refusal it
1565 // is the floor for unable to separate a doorway from a front.
1566 checked += 1;
1567 if self.broadest_opening_width(&mut reads).is_none() {
1568 failures.push(
1569 "the table defines no standard opening, so a contact seam's width floor — \
1570 the width a front must exceed to be a front rather than a door — cannot \
1571 be derived"
1572 .to_string(),
1573 );
1574 }
1575
1576 let quantum = match self.grid(&mut reads) {
1577 Some(g) => {
1578 checked += 1;
1579 if g.quantum == 0 {
1580 failures.push("the kit grid's quantum is zero".to_string());
1581 }
1582 g.quantum
1583 }
1584 None => {
1585 failures.push("the table defines no kit `grid`".to_string());
1586 1
1587 }
1588 };
1589
1590 for (key, entry) in &self.building {
1591 match entry.value(&mut reads) {
1592 MetricValue::Opening(o) => {
1593 checked += 1;
1594 if u64::from(o.width) < floor_w || u64::from(o.height) < floor_h {
1595 failures.push(format!(
1596 "`{key}` is {}×{}, which no standing body passes ({floor_w}×{floor_h} \
1597 is the floor)",
1598 o.width, o.height
1599 ));
1600 }
1601 }
1602 MetricValue::Pitch(p) => {
1603 checked += 1;
1604 if p.step_16 > MAX_AUTO_STEP_16 {
1605 failures.push(format!(
1606 "`{key}` presents a tread of {}/16, over the {MAX_AUTO_STEP_16}/16 \
1607 walk-up budget, so it is climbed by jumping and is not a standard \
1608 pitch",
1609 p.step_16
1610 ));
1611 }
1612 if p.rise == 0 || p.run == 0 {
1613 failures.push(format!("`{key}` has a zero rise or run"));
1614 }
1615 }
1616 MetricValue::SizeClass(c) => {
1617 checked += 1;
1618 for (axis, lo, hi) in [
1619 ("x", c.min_footprint[0], c.max_footprint[0]),
1620 ("z", c.min_footprint[1], c.max_footprint[1]),
1621 ] {
1622 if lo > hi {
1623 failures
1624 .push(format!("`{key}` has a {axis} minimum above its maximum"));
1625 }
1626 if lo % quantum != 0 || hi % quantum != 0 {
1627 failures.push(format!(
1628 "`{key}` bounds its {axis} footprint at {lo}..{hi}, which is not \
1629 on the kit grid's quantum of {quantum}"
1630 ));
1631 }
1632 }
1633 if u64::from(c.min_clearance) < floor_h {
1634 failures.push(format!(
1635 "`{key}` allows a clearance of {}, under the passable floor of \
1636 {floor_h}",
1637 c.min_clearance
1638 ));
1639 }
1640 if c.nominal_traverse_blocks == 0 {
1641 failures.push(format!("`{key}` has a nominal traverse of zero"));
1642 }
1643 }
1644 MetricValue::WayClass(w) => {
1645 checked += 1;
1646 // The two floors the freestanding `corridor.min-*` entries
1647 // used to be checked at, re-asserted here against every way
1648 // class rather than against the one that inherited them: a
1649 // designed minimum is a comfort judgement and a standard
1650 // under the physical passable size would be a standard
1651 // nothing can use, which is true of a road exactly as it is
1652 // of a corridor.
1653 if u64::from(w.min_width) < floor_w {
1654 failures.push(format!(
1655 "`{key}` allows a width of {}, under the `passable.width` floor of \
1656 {floor_w}",
1657 w.min_width
1658 ));
1659 }
1660 if u64::from(w.min_clearance) < floor_h {
1661 failures.push(format!(
1662 "`{key}` allows a clearance of {}, under the `passable.clearance` \
1663 floor of {floor_h}",
1664 w.min_clearance
1665 ));
1666 }
1667 if w.min_width > w.max_width {
1668 failures.push(format!(
1669 "`{key}` bounds its width at {}..{}, a minimum above its maximum",
1670 w.min_width, w.max_width
1671 ));
1672 }
1673 // `max_width` is the elongation floor as well as the widest
1674 // cross-section, and a box's horizontal extents are
1675 // multiples of the kit quantum (`DW0825`). A `max_width` off
1676 // the quantum is therefore a bound no plan can draw a way
1677 // AT, which makes the widest member of the class
1678 // uninstantiable and the gym unable to rule on it. The
1679 // narrow bound is deliberately NOT held to the quantum: the
1680 // corridor's inherited floor of 2 sits under a quantum of 4
1681 // and which of those two provisional numbers moves is the
1682 // walk's to decide, not this file's.
1683 if !w.max_width.is_multiple_of(quantum) {
1684 failures.push(format!(
1685 "`{key}` bounds its width at {}, which is not on the kit grid's \
1686 quantum of {quantum}, so no box can be drawn at the widest member \
1687 of the class",
1688 w.max_width
1689 ));
1690 }
1691 }
1692 _ => {}
1693 }
1694 }
1695
1696 for (key, floor) in [
1697 ("storey.low", floor_h + 2),
1698 ("storey.standard", floor_h + 2),
1699 ("storey.hall", floor_h + 2),
1700 ] {
1701 let Some(entry) = self.building.get(key) else {
1702 failures.push(format!("the table defines no `{key}`"));
1703 continue;
1704 };
1705 checked += 1;
1706 if let MetricValue::Count(n) = entry.value(&mut reads)
1707 && u64::from(*n) < floor
1708 {
1709 failures.push(format!(
1710 "`{key}` is {n} blocks, which leaves no passable interior between a \
1711 floor course and a ceiling course ({floor} is the floor)"
1712 ));
1713 }
1714 }
1715
1716 // The policy cap is deliberately tighter than the physical one. A cap
1717 // that reached the survivability ceiling would not be a policy.
1718 if let Some(n) = self.max_designed_drop_blocks(&mut reads) {
1719 checked += 1;
1720 let physical = unarmoured_survivable_fall_blocks();
1721 if f64::from(n) >= physical {
1722 failures.push(format!(
1723 "`drop.max-designed-rise` is {n} blocks, at or past the unarmoured \
1724 survivable fall of {physical}, so it is not a policy cap at all"
1725 ));
1726 }
1727 }
1728
1729 // A party cannot out-pace a body walking in a straight line.
1730 if let (Some(route), Some(walk)) = (
1731 self.building.get("pacing.route-blocks-per-minute"),
1732 self.building.get("pacing.walk-only-blocks-per-minute"),
1733 ) {
1734 checked += 1;
1735 if let (MetricValue::Count(r), MetricValue::Count(w)) =
1736 (route.value(&mut reads), walk.value(&mut reads))
1737 && r > w
1738 {
1739 failures.push(format!(
1740 "`pacing.route-blocks-per-minute` is {r}, over the pure-walk ceiling of {w}"
1741 ));
1742 }
1743 }
1744
1745 SelfCheck {
1746 binding: SelfCheckBinding {
1747 invariants: checked,
1748 entries: self.building.len(),
1749 reads: reads.binding(),
1750 },
1751 reads,
1752 failures,
1753 }
1754 }
1755}
1756
1757/// What [`Metrics::self_check`] examined and what it found.
1758#[derive(Debug, Clone, PartialEq)]
1759pub struct SelfCheck {
1760 /// The ledger the verdicts read through, for [`Metrics::notice`].
1761 pub reads: Reads,
1762 /// What the run bound to. Stated whether or not anything failed, because a
1763 /// check that examined nothing is a finding and not a pass.
1764 pub binding: SelfCheckBinding,
1765 /// Inconsistencies, each a whole sentence. Non-empty is an internal error.
1766 pub failures: Vec<String>,
1767}
1768
1769/// [`Metrics::self_check`]'s binding count.
1770#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1771pub struct SelfCheckBinding {
1772 /// Invariants evaluated.
1773 pub invariants: usize,
1774 /// Building entries in the table.
1775 pub entries: usize,
1776 /// What those invariants read.
1777 pub reads: ReadBinding,
1778}
1779
1780/// The table rendered for export: every entry with its value, unit, provenance
1781/// and note, plus the halves' own counts.
1782///
1783/// Separate from [`Metrics`]'s own `Serialize` because the building half's value
1784/// is private — an export is the one consumer that reads a number without
1785/// resting a verdict on it.
1786#[must_use]
1787pub fn export(metrics: &Metrics) -> serde_json::Value {
1788 let mut player = serde_json::Map::new();
1789 for (k, e) in &metrics.player {
1790 player.insert((*k).to_string(), serde_json::json!(e));
1791 }
1792 let mut building = serde_json::Map::new();
1793 let mut uncalibrated = 0usize;
1794 for (k, e) in &metrics.building {
1795 if !e.calibrated {
1796 uncalibrated += 1;
1797 }
1798 building.insert(
1799 (*k).to_string(),
1800 serde_json::json!({
1801 "value": e.value_for_display(),
1802 "unit": e.unit,
1803 "provenance": e.provenance,
1804 "calibrated": e.calibrated,
1805 "note": e.note,
1806 }),
1807 );
1808 }
1809 serde_json::json!({
1810 "metrics_version": metrics.metrics_version,
1811 "mc_version": metrics.mc_version,
1812 "counts": {
1813 "player": metrics.player.len(),
1814 "building": metrics.building.len(),
1815 "uncalibrated": uncalibrated,
1816 },
1817 "player": player,
1818 "building": building,
1819 })
1820}
1821
1822#[cfg(test)]
1823mod tests {
1824 use super::*;
1825
1826 #[test]
1827 fn the_table_is_consistent_with_itself_and_with_the_player_half() {
1828 let m = Metrics::table();
1829 let check = m.self_check();
1830 assert!(
1831 check.failures.is_empty(),
1832 "the shipped metrics table is inconsistent: {:?}",
1833 check.failures
1834 );
1835 assert!(
1836 check.binding.invariants > 0,
1837 "a self-check that evaluated nothing is vacuous, not a pass"
1838 );
1839 }
1840
1841 #[test]
1842 fn every_building_entry_lands_uncalibrated() {
1843 let m = Metrics::table();
1844 assert!(!m.building.is_empty(), "the building half is empty");
1845 for (k, e) in &m.building {
1846 assert!(
1847 !e.calibrated,
1848 "`{k}` claims to be calibrated, but the metrics gym has not been walked"
1849 );
1850 }
1851 }
1852
1853 #[test]
1854 fn a_verdict_that_reads_a_seed_raises_dw0813_naming_it() {
1855 let m = Metrics::table();
1856 let check = m.self_check();
1857 assert!(
1858 check.binding.reads.provisional > 0,
1859 "the self-check read no provisional entry, so DW0813 binds to nothing"
1860 );
1861 let d = m
1862 .notice(&check.reads, "metrics")
1863 .expect("a run that read a seed owes the notice");
1864 assert_eq!(d.code, "DW0813");
1865 assert_eq!(d.severity, crate::diagnostic::Severity::Warning);
1866 for name in check.reads.provisional() {
1867 assert!(
1868 d.message.contains(name),
1869 "the notice must name `{name}`, the seed a verdict rested on"
1870 );
1871 }
1872 }
1873
1874 #[test]
1875 fn a_run_that_read_nothing_provisional_gets_no_notice() {
1876 let m = Metrics::table();
1877 let reads = Reads::new();
1878 assert!(m.notice(&reads, "metrics").is_none());
1879 assert_eq!(reads.binding().read, 0);
1880 }
1881
1882 #[test]
1883 fn an_undefined_name_is_dw0812_and_names_what_is_defined() {
1884 let m = Metrics::table();
1885 let err = m
1886 .resolve(MetricKind::SizeClass, "cathedral")
1887 .expect_err("`cathedral` is not a size class");
1888 let d = err.diagnostic("layout-graph", "/nodes/0/size_class");
1889 assert_eq!(d.code, "DW0812");
1890 assert!(d.message.contains("cathedral"));
1891 assert!(d.message.contains("room"), "the defined set is named");
1892 assert_eq!(d.stage, "layout-graph");
1893 assert_eq!(d.path, "/nodes/0/size_class");
1894 }
1895
1896 #[test]
1897 fn every_kind_resolves_at_least_one_defined_name() {
1898 let m = Metrics::table();
1899 for kind in [
1900 MetricKind::Opening,
1901 MetricKind::Pitch,
1902 MetricKind::SizeClass,
1903 MetricKind::Storey,
1904 ] {
1905 let names = m.names_of(kind);
1906 assert!(
1907 !names.is_empty(),
1908 "{} resolves nothing, so DW0812 would refuse every name",
1909 kind.noun()
1910 );
1911 for n in names {
1912 assert!(m.resolve(kind, n).is_ok(), "`{n}` does not resolve");
1913 }
1914 }
1915 }
1916
1917 // --- a body has a width -------------------------------------------------
1918
1919 /// The fact the whole rule rests on, written down where it can be checked: a
1920 /// box selector spelled `x=lo,dx=hi-lo` is the world volume `[lo, hi+1]`, so
1921 /// `dx=0` is one whole block and never a plane.
1922 #[test]
1923 fn a_box_selector_spans_the_whole_of_its_last_cell() {
1924 assert_eq!(
1925 selection_aabb([7, 63, 20], [11, 67, 24]),
1926 ([7.0, 63.0, 20.0], [12.0, 68.0, 25.0])
1927 );
1928 let (lo, hi) = selection_aabb([0, 0, 0], [0, 0, 0]);
1929 assert_eq!((lo, hi), ([0.0, 0.0, 0.0], [1.0, 1.0, 1.0]));
1930 }
1931
1932 /// **The measurement this rule was written from.** A wave's spider, 1.4 wide,
1933 /// walked to `x = 12.59` beside a pit whose box ends at `x = 12.0` and was
1934 /// killed there — its hitbox reaches `11.89`. Its feet cell is 12, which is
1935 /// outside the box, so every cell-shaped reading in the engine called that
1936 /// position safe.
1937 #[test]
1938 fn a_body_beside_a_face_is_inside_the_volume_and_its_cell_is_not() {
1939 let west_pit = ([7, 63, 20], [11, 67, 24]);
1940 let spider = Body::new(1.4, 1.4);
1941 assert!(
1942 body_meets_volume([12.59, 65.0, 21.02], spider, west_pit.0, west_pit.1),
1943 "hitbox 11.89..13.29 against a box face at 12.0"
1944 );
1945 // The cell it stands in is not one of the volume's cells.
1946 assert!(!(west_pit.0[0]..=west_pit.1[0]).contains(&12));
1947 // A player at the same cell's CENTRE is clear of it — 12.2..12.8 — which
1948 // is why the seat rule and the walk rule are two different readings.
1949 assert!(!body_meets_volume(
1950 [12.5, 65.0, 21.5],
1951 Body::PLAYER,
1952 west_pit.0,
1953 west_pit.1
1954 ));
1955 // …and the walk rule refuses that cell anyway, because a walker does not
1956 // stand at cell centres.
1957 assert!(cell_can_meet_volume(
1958 [12, 65, 21],
1959 Body::PLAYER,
1960 west_pit.0,
1961 west_pit.1
1962 ));
1963 }
1964
1965 /// The touching case is NOT an intersection, exactly as vanilla's own
1966 /// `AABB::intersects` is strict: a body standing on a volume's top face with
1967 /// its feet at the face is clear of it.
1968 #[test]
1969 fn a_face_that_exactly_touches_is_not_inside() {
1970 let v = ([0, 0, 0], [0, 0, 0]);
1971 assert!(!body_meets_volume([0.5, 1.0, 0.5], Body::PLAYER, v.0, v.1));
1972 assert!(body_meets_volume([0.5, 0.999, 0.5], Body::PLAYER, v.0, v.1));
1973 }
1974
1975 /// The keep-out box is a DERIVATION, not a constant one: it widens with the
1976 /// body. Horizontally every body up to two blocks wide reaches exactly one
1977 /// cell (half a width can never cross two cell boundaries at once);
1978 /// vertically a body taller than two blocks reaches one cell further down
1979 /// than a player does, which is the only place the bodies in this engine's
1980 /// dims table differ.
1981 #[test]
1982 fn the_keep_out_box_widens_with_the_body() {
1983 let v = ([10, 60, 10], [12, 62, 12]);
1984 assert_eq!(
1985 keep_out_box(Body::PLAYER, v.0, v.1),
1986 ([9, 59, 9], [13, 62, 13])
1987 );
1988 // 1.4 wide: the same ring. Half a width is 0.7, which crosses one
1989 // boundary and not two.
1990 assert_eq!(
1991 keep_out_box(Body::new(1.4, 1.4), v.0, v.1),
1992 ([9, 59, 9], [13, 62, 13])
1993 );
1994 // 2.9 tall (a warden): one cell further DOWN, because its head reaches
1995 // into the volume from a cell a player's head does not.
1996 assert_eq!(
1997 keep_out_box(Body::new(0.9, 2.9), v.0, v.1),
1998 ([9, 58, 9], [13, 62, 13])
1999 );
2000 // Standing ON the volume's top cell is safe for every body: the feet
2001 // start where the volume ends.
2002 assert!(!cell_can_meet_volume(
2003 [11, 63, 11],
2004 Body::new(0.9, 2.9),
2005 v.0,
2006 v.1
2007 ));
2008 }
2009
2010 #[test]
2011 fn the_derived_player_values_are_the_arithmetic_their_notes_claim() {
2012 assert!((walk_ticks_per_block() - 20.0 / 4.317).abs() < f64::EPSILON);
2013 assert!((walk_ticks_per_block() - 4.633).abs() < 0.001);
2014 assert_eq!(unarmoured_survivable_fall_blocks(), 22.0);
2015 assert_eq!(passable_width_cells(), 1);
2016 assert_eq!(passable_clearance_cells(), 2);
2017 }
2018}