use std::collections::{BTreeMap, BTreeSet};
use serde::Serialize;
use crate::diagnostic::{Diagnostic, DwCode, ExitTier};
crate::dw_code! {
pub const DW_METRIC_UNKNOWN: DwCode = DwCode::new("DW0812", ExitTier::Build);
}
crate::dw_code! {
pub const DW_METRIC_PROVISIONAL: DwCode = DwCode::new("DW0813", ExitTier::Build).about_the_engine();
}
pub const METRICS_VERSION: u32 = 4;
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_REACH: [(i64, u32); 24] = [
(1, 3),
(0, 4),
(-1, 4),
(-2, 4),
(-3, 5),
(-4, 5),
(-5, 6),
(-6, 6),
(-7, 6),
(-8, 6),
(-9, 7),
(-10, 7),
(-11, 7),
(-12, 7),
(-13, 7),
(-14, 8),
(-15, 8),
(-16, 8),
(-17, 8),
(-18, 9),
(-19, 9),
(-20, 9),
(-21, 9),
(-22, 9),
];
pub const WATER_CLIMB_OUT_RISE: i32 = 1;
#[must_use]
pub fn jump_max_gap(rise_16: i64) -> Option<u32> {
if rise_16 > MAX_JUMP_RISE_16 {
return None;
}
if rise_16 < -(unarmoured_survivable_fall_blocks() as i64) * FULL_16 {
return None;
}
let row = if rise_16 > 0 {
1
} else {
-((-rise_16) / FULL_16)
};
JUMP_REACH.iter().find(|(r, _)| *r == row).map(|(_, g)| *g)
}
#[must_use]
pub fn selector_reaches_body_in_cell(lo: [i32; 3], hi: [i32; 3], cell: [i32; 3]) -> bool {
let half = PLAYER_WIDTH / 2.0;
let body_lo = [
f64::from(cell[0]) - half,
f64::from(cell[1]),
f64::from(cell[2]) - half,
];
let body_hi = [
f64::from(cell[0]) + 1.0 + half,
f64::from(cell[1]) + PLAYER_HEIGHT,
f64::from(cell[2]) + 1.0 + half,
];
(0..3).all(|i| body_lo[i] <= f64::from(hi[i]) + 1.0 && body_hi[i] >= f64::from(lo[i]))
}
pub const JUMP_AIRBORNE_TICKS: f64 = 12.0;
pub const WALK_SPEED_BLOCKS_PER_SECOND: f64 = 4.317;
pub const SPRINT_SPEED_BLOCKS_PER_SECOND: f64 = 5.612;
pub const SPRINT_SPEED_PAGE: &str = "Sprinting";
pub const TICKS_PER_SECOND: f64 = 20.0;
pub const POLL_HORIZONTAL_BLOCKS_PER_TICK: f64 = 0.5878;
pub const POLL_FALL_BLOCKS_PER_TICK: f64 = 3.92;
pub const FALL_DAMAGE_ONSET_BLOCKS: f64 = 3.0;
pub const CLIMB_SET_SPEED_BLOCKS_PER_TICK: f64 = 0.2;
pub const CLIMB_SLIDE_BLOCKS_PER_TICK: f64 = 0.15;
pub const AIR_DRAG: f64 = 0.98;
pub const GRAVITY_BLOCKS_PER_TICK2: f64 = 0.08;
#[must_use]
pub fn climb_blocks_per_tick() -> f64 {
(CLIMB_SET_SPEED_BLOCKS_PER_TICK - GRAVITY_BLOCKS_PER_TICK2) * AIR_DRAG
}
#[must_use]
pub fn climb_catch_fall_blocks() -> u32 {
let (mut v, mut fallen) = (0.0_f64, 0.0_f64);
loop {
let next = (v + GRAVITY_BLOCKS_PER_TICK2) * AIR_DRAG;
if next >= 1.0 {
return fallen.floor() as u32;
}
v = next;
fallen += v;
}
}
#[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)]
pub struct Body {
pub width: f64,
pub height: f64,
}
impl Body {
pub const PLAYER: Body = Body {
width: PLAYER_WIDTH,
height: PLAYER_HEIGHT,
};
#[must_use]
pub fn new(width: f64, height: f64) -> Body {
Body { width, height }
}
#[must_use]
pub fn half_width(&self) -> f64 {
self.width / 2.0
}
}
#[must_use]
pub fn selection_aabb(lo: [i32; 3], hi: [i32; 3]) -> ([f64; 3], [f64; 3]) {
(
[lo[0] as f64, lo[1] as f64, lo[2] as f64],
[hi[0] as f64 + 1.0, hi[1] as f64 + 1.0, hi[2] as f64 + 1.0],
)
}
#[must_use]
pub fn body_meets_volume(feet: [f64; 3], body: Body, lo: [i32; 3], hi: [i32; 3]) -> bool {
let (vlo, vhi) = selection_aabb(lo, hi);
let half = body.half_width();
let blo = [feet[0] - half, feet[1], feet[2] - half];
let bhi = [feet[0] + half, feet[1] + body.height, feet[2] + half];
(0..3).all(|i| blo[i] < vhi[i] && bhi[i] > vlo[i])
}
#[must_use]
pub fn keep_out_box(body: Body, lo: [i32; 3], hi: [i32; 3]) -> ([i32; 3], [i32; 3]) {
let half = body.half_width();
let mut out_lo = [0i32; 3];
let mut out_hi = [0i32; 3];
for i in [0usize, 2] {
out_lo[i] = (lo[i] as f64 - 1.0 - half).floor() as i32 + 1;
out_hi[i] = (hi[i] as f64 + 1.0 + half).ceil() as i32 - 1;
}
out_lo[1] = (lo[1] as f64 - body.height).floor() as i32 + 1;
out_hi[1] = hi[1];
(out_lo, out_hi)
}
#[must_use]
pub fn cell_can_meet_volume(cell: [i32; 3], body: Body, lo: [i32; 3], hi: [i32; 3]) -> bool {
feet_can_meet_volume(cell, i64::from(cell[1]) * 16, body, lo, hi)
}
#[must_use]
pub fn feet_can_meet_volume(
cell: [i32; 3],
feet_16: i64,
body: Body,
lo: [i32; 3],
hi: [i32; 3],
) -> bool {
let (klo, khi) = keep_out_box(body, lo, hi);
if ![0usize, 2]
.iter()
.all(|&i| klo[i] <= cell[i] && cell[i] <= khi[i])
{
return false;
}
let feet = feet_16 as f64 / 16.0;
feet < f64::from(hi[1]) + 1.0 && feet + body.height > f64::from(lo[1])
}
#[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 Datum {
pub datum: &'static str,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(untagged)]
pub enum MetricValue {
Count(u32),
Number(f64),
Flag(bool),
Opening(Opening),
Pitch(Pitch),
Datum(Datum),
}
#[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,
Storey,
Pacing,
}
impl MetricKind {
#[must_use]
pub fn prefix(self) -> &'static str {
match self {
MetricKind::Opening => "opening.",
MetricKind::Pitch => "pitch.",
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::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::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.",
),
),
(
"sprint.speed",
player(
MetricValue::Number(SPRINT_SPEED_BLOCKS_PER_SECOND),
"blocks/second",
Provenance::VanillaRule,
"A sprinting player covers 5.612 blocks a second on the flat (the \
wiki's Sprinting page). Not a pacing basis; it is how far a body can \
carry itself under a darkness grant, which leaves the sprint, in the \
blind-reach proof.",
),
),
(
"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 \
no designed drop may pass.",
),
),
(
"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.",
),
),
(
"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(
"datum",
MetricValue::Datum(Datum {
datum: "floor-surface",
}),
"none",
Provenance::Provisional,
"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. A box's \
footprint is any whole number of blocks on either axis.",
),
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(
"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 datum(&self, reads: &mut Reads) -> Option<Datum> {
match self.building.get("datum")?.value(reads) {
MetricValue::Datum(g) => Some(*g),
_ => None,
}
}
#[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());
if self.datum(&mut reads).is_some() {
checked += 1;
} else {
failures.push("the table defines no `datum` convention".to_string());
}
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"));
}
}
_ => {}
}
}
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(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::Opening, "cathedral")
.expect_err("`cathedral` is not an opening");
let d = err.diagnostic("site-plan", "/content/seams/0/opening");
assert_eq!(d.code, "DW0812");
assert!(d.message.contains("cathedral"));
assert!(d.message.contains("arch"), "the defined set is named");
assert_eq!(d.stage, "site-plan");
assert_eq!(d.path, "/content/seams/0/opening");
}
#[test]
fn every_kind_resolves_at_least_one_defined_name() {
let m = Metrics::table();
for kind in [MetricKind::Opening, MetricKind::Pitch, 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 a_box_selector_spans_the_whole_of_its_last_cell() {
assert_eq!(
selection_aabb([7, 63, 20], [11, 67, 24]),
([7.0, 63.0, 20.0], [12.0, 68.0, 25.0])
);
let (lo, hi) = selection_aabb([0, 0, 0], [0, 0, 0]);
assert_eq!((lo, hi), ([0.0, 0.0, 0.0], [1.0, 1.0, 1.0]));
}
#[test]
fn a_body_beside_a_face_is_inside_the_volume_and_its_cell_is_not() {
let west_pit = ([7, 63, 20], [11, 67, 24]);
let spider = Body::new(1.4, 1.4);
assert!(
body_meets_volume([12.59, 65.0, 21.02], spider, west_pit.0, west_pit.1),
"hitbox 11.89..13.29 against a box face at 12.0"
);
assert!(!(west_pit.0[0]..=west_pit.1[0]).contains(&12));
assert!(!body_meets_volume(
[12.5, 65.0, 21.5],
Body::PLAYER,
west_pit.0,
west_pit.1
));
assert!(cell_can_meet_volume(
[12, 65, 21],
Body::PLAYER,
west_pit.0,
west_pit.1
));
}
#[test]
fn a_face_that_exactly_touches_is_not_inside() {
let v = ([0, 0, 0], [0, 0, 0]);
assert!(!body_meets_volume([0.5, 1.0, 0.5], Body::PLAYER, v.0, v.1));
assert!(body_meets_volume([0.5, 0.999, 0.5], Body::PLAYER, v.0, v.1));
}
#[test]
fn the_keep_out_box_widens_with_the_body() {
let v = ([10, 60, 10], [12, 62, 12]);
assert_eq!(
keep_out_box(Body::PLAYER, v.0, v.1),
([9, 59, 9], [13, 62, 13])
);
assert_eq!(
keep_out_box(Body::new(1.4, 1.4), v.0, v.1),
([9, 59, 9], [13, 62, 13])
);
assert_eq!(
keep_out_box(Body::new(0.9, 2.9), v.0, v.1),
([9, 58, 9], [13, 62, 13])
);
assert!(!cell_can_meet_volume(
[11, 63, 11],
Body::new(0.9, 2.9),
v.0,
v.1
));
}
#[test]
fn feet_below_the_floor_reach_the_course_they_stand_on() {
let v = ([10, 60, 10], [12, 62, 12]);
for body in [Body::PLAYER, Body::new(0.9, 2.9)] {
let (klo, khi) = keep_out_box(body, v.0, v.1);
for x in 7..=15 {
for y in 55..=66 {
for z in 7..=15 {
let c = [x, y, z];
let boxed = (0..3).all(|i| klo[i] <= c[i] && c[i] <= khi[i]);
assert_eq!(
feet_can_meet_volume(c, i64::from(y) * 16, body, v.0, v.1),
boxed,
"{c:?}"
);
}
}
}
}
let tip = [11, 63, 11];
assert!(!cell_can_meet_volume(tip, Body::PLAYER, v.0, v.1));
assert!(feet_can_meet_volume(
tip,
62 * 16 + 11,
Body::PLAYER,
v.0,
v.1
));
assert!(!feet_can_meet_volume(tip, 63 * 16, Body::PLAYER, v.0, v.1));
}
#[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);
}
#[test]
fn the_jump_reach_runs_from_the_apex_to_the_deepest_survivable_fall_and_never_narrows() {
assert_eq!(JUMP_REACH.first().map(|r| r.0), Some(1));
assert_eq!(
JUMP_REACH.last().map(|r| r.0),
Some(-(unarmoured_survivable_fall_blocks() as i64))
);
for w in JUMP_REACH.windows(2) {
assert_eq!(w[1].0, w[0].0 - 1, "one row per whole block of rise");
assert!(
w[1].1 >= w[0].1,
"a deeper landing never admits a narrower gap: {w:?}"
);
}
assert_eq!(jump_max_gap(MAX_JUMP_RISE_16), Some(3));
assert_eq!(jump_max_gap(MAX_JUMP_RISE_16 + 1), None);
assert_eq!(jump_max_gap(0), Some(4));
assert_eq!(jump_max_gap(-24), Some(4), "a drop of 1.5 reads the -1 row");
assert_eq!(jump_max_gap(-22 * FULL_16), Some(9));
assert_eq!(jump_max_gap(-22 * FULL_16 - 1), None);
}
}