robo_match (faber_tweann v2.4.0)
View SourceRobo Rumble: the match runner, the scoring rule and the start ensemble.
AUDIT FIX. This module exists because an adversarial audit found the kill gate was not executable: the gauntlet asserted its own difficulty order in a docstring, nothing in the repo drove robo_gauntlet:act/4 at all, and no scoring rule existed anywhere. PLAN_ROBO_RUMBLE.md section 6 gates the whole front on kill gate 0a, so an unrunnable gate meant every later result on this front rested on a claim nobody had ever measured.
THE PERCEPTION CONTRACT, PINNED HERE. A bot must be handed the arena whose scans field was produced by the same step that produced its own tank. Step first and you hand every bot a world one turn stale, silently, and nothing fails. loop/4 therefore acts on the CURRENT arena and only then steps, and robo_match_tests asserts that a runner which steps first is detectably wrong.
THE SCORING RULE IS DAMAGE-PRIMARY, SURVIVAL AS TIEBREAK, AND NEVER ENERGY. This is a measured decision, not a taste. robo_gauntlet budgets every gun so that a bot which is missing decays toward its fire floor while a bot that never fires keeps a full bar, so ANY energy-derived score structurally rewards not shooting. Measured, energy scoring ranked the sitting duck above a bot that moves and shoots. It would also hand a future fitness function a strong local optimum reading "never fire", which is worth knowing before that function is written rather than after.
There are no magic weights here. Damage and survival are reported and ordered separately, lexicographically, so no arbitrary exchange rate between them is ever invented. A weighted sum would have to justify its constants and could not.
RANK AGAINST THE FIELD, NOT PAIRWISE. sitting_duck versus spinner is degenerate on purpose: neither can hurt the other, so that pair cannot rank robustly under any rule and reading the ladder off adjacent pairs is meaningless. Against the field the two separate cleanly, because the duck stands still and dies to the tracking bots while the spinner moves and does not. That is exactly the difference rung 2 is supposed to encode.
DETERMINISM. The engine has no randomness, so a start configuration is the ONLY free variable in a match. The ensemble below is therefore pre-registered in code rather than chosen per experiment, and both seats are always played, because a gate evaluated on starts picked after seeing the results is not a gate.
Summary
Types
-type outcome() :: #{damage := integer(), survived := non_neg_integer(), alive := boolean()}.
Functions
-spec duel(robo_gauntlet:kind(), robo_gauntlet:kind(), tuple()) -> [map()].
-spec field() -> [{robo_gauntlet:kind(), integer(), non_neg_integer()}].
-spec match(robo_gauntlet:kind(), robo_gauntlet:kind(), tuple()) -> #{a := outcome(), b := outcome(), turns := non_neg_integer()}.
-spec rank() -> [robo_gauntlet:kind()].
-spec run(#arena{turn :: non_neg_integer(), tanks :: list(), bullets :: list(), scans :: list()}, [{term(), robo_gauntlet:kind()}], [{term(), term()}], map()) -> map().
-spec score_of(robo_gauntlet:kind(), [tuple()]) -> {integer(), non_neg_integer()}.