robo_gauntlet (faber_tweann v2.4.0)
View SourceRobo Rumble: the five scripted opponents that fix the competence floor.
This module is a KILL GATE, not a convenience. PLAN_ROBO_RUMBLE.md section 2 states the reason plainly: absence of cycling is uninterpretable unless the population has crossed a competence floor at which counter-strategies are possible at all. At low competence a tank arena is transitive, so a no-cycling result below the floor is a statement about the optimiser rather than about the substrate, which is exactly the search under-convergence confound that the closed programme left unresolved. These five bots ARE the floor. If they are wrong, every later null result on this front means nothing.
THE FIVE, weakest first, matching the plan's table:
sitting_duck does nothing at all. Engine smoke test. Anything beats it. spinner rotates constantly, drives in a circle, fires on a fixed period. Deterministic, never random. Tests basic tracking of a target that moves. rammer sweeps radar to acquire, then drives at the contact and shoots at close range. Punishes passivity and wall-hugging. circle_strafer holds the contact at a roughly constant range while orbiting it, aiming straight at where the contact WAS. Punishes non-predictive aim. predictive_gun THE FLOOR. Same orbiting movement as the strafer, but the gun estimates the contact's velocity from successive scans and leads the shot by the bullet's flight time. Beating it requires genuine evasion, not merely being in motion.
PARTIAL OBSERVABILITY IS ENFORCED BY STRUCTURE, NOT BY GOOD INTENTIONS. A bot here may read its own tank record and the scans addressed to it, and nothing else. act/4 is the only function that touches the arena, it destructures only the scans field, it filters that list down to the entries whose observer id is the acting tank, and it then calls decide/3, which receives the bot state and the bot's OWN tank and has no way to reach an opponent record. Reading the opponent's tank directly would silently delete the property that this whole substrate was chosen for, and it would do so without failing any test, so the defence is a shape the compiler can check rather than a rule in a comment.
DETERMINISM. Every quantity below is an integer. There is no float, no libm call, no clock, no randomness, no ets, no process dictionary and no message passing anywhere in this module. State is carried by value through act/4, so a bot is a pure function of its history and a match replays bit-identically on any machine. See robo_sim for the unit scheme, which this module obeys without exception: distance and velocity in fixed-point at scale 256, angles as binary angles on 0 to 255 with 256 a full turn, bullet power in tenths on 1 to 30.
NO ATAN2, AND NONE ADDED. A gun needs the direction to a contact as a binary angle, but the scans deliberately carry a vector and never a bearing, because a bearing means atan2 and atan2 means libm. angle_of/1 below recovers the angle by bisection on the sign of a cross product, which is the same technique robo_sim uses in in_arc/4. robo_sim keeps that machinery private, so it is reimplemented here rather than exported from there, on the principle that a gauntlet should not force a change to the engine it is meant to test.
ONE CONVENTION FOR AIMING. A bot commands the raw signed rotation it wants and lets the engine clamp it, because the engine clamps in exactly one place and duplicating those limits here would let the two drift apart. What a bot must therefore reason about is the RESIDUAL: the part of the wanted rotation that the clamp will refuse this turn. residual/1 computes it, and a bot holds fire until the residual is small, so it never spends energy on a shot the gun has not finished turning onto.
THE ENERGY ECONOMY IS A REAL CONSTRAINT AND THE BOTS RESPECT IT. The plan counts firing cost against hit reward as one of the three material differences from the exhausted grid, and the arithmetic bites: a shot costs a tenth of its power and a hit returns three tenths, so a bot must land better than one shot in three merely to break even. A bot that fires whenever its gun is cool and roughly on target burns its whole energy bar inside the turn cap and loses to a target that does nothing at all, which was measured, not imagined. Every gun here is therefore budgeted by budget/2, which never commits more than a twentieth of remaining energy to one shot, so power decays as the bar does and no bot can shoot itself to death.
WALLS ARE THE OTHER SILENT KILLER. Wall contact costs a full unit of energy per turn and zeroes velocity, so a bot that orbits a contact parked near an edge will grind itself flat while its gun works perfectly. The two orbiting bots bend their heading away from a nearby wall, and they do it in vector space rather than by adding angles, because angles do not blend under addition. This is not the rammer's business: punishing wall-hugging is what the rammer is for, and a rammer that flinched from walls would not be one.
Summary
Types
-type kind() :: sitting_duck | spinner | rammer | circle_strafer | predictive_gun.
-type state() :: #bot{tick :: non_neg_integer(), seen :: 0..2, age :: non_neg_integer(), dist :: integer(), ex :: integer(), ey :: integer(), etick :: non_neg_integer(), px :: integer(), py :: integer(), ptick :: non_neg_integer(), target :: term()}.
Functions
-spec act(kind(), state(), #tank{id :: term(), x :: integer(), y :: integer(), heading :: 0..255, gun :: 0..255, radar :: 0..255, vel :: integer(), energy :: integer(), gun_heat :: integer(), dead :: boolean(), damage_dealt :: integer()}, #arena{turn :: non_neg_integer(), tanks :: list(), bullets :: list(), scans :: list()}) -> {#intent{turn_body :: integer(), turn_gun :: integer(), turn_radar :: integer(), accel :: integer(), fire :: integer()}, state()}.
-spec kinds() -> [kind()].