From 859730f02d9169813db7cb7d7d5e6c6cffac6bc5 Mon Sep 17 00:00:00 2001 From: John McCardle Date: Thu, 2 Jul 2026 20:19:30 -0400 Subject: [PATCH] Gauntlet #340: spec, package skeleton, scoring, six trials Add the DESIGN.md (spec copy + implementation deviations), the trials package (Trial base + ordered registry + six stress trials: entity swarm, animation storm, grid titan, pathfinder rush, ui avalanche, sightline siege), and the RampController/scoring logic. All pure Python under tests/benchmarks/gauntlet/. Co-Authored-By: Claude Fable 5 --- tests/benchmarks/gauntlet/DESIGN.md | 235 ++++++++++++++++++ tests/benchmarks/gauntlet/__init__.py | 6 + tests/benchmarks/gauntlet/scoring.py | 177 +++++++++++++ tests/benchmarks/gauntlet/trials/__init__.py | 85 +++++++ .../gauntlet/trials/animation_storm.py | 88 +++++++ .../gauntlet/trials/entity_swarm.py | 92 +++++++ .../benchmarks/gauntlet/trials/grid_titan.py | 87 +++++++ .../gauntlet/trials/pathfinder_rush.py | 74 ++++++ .../gauntlet/trials/sightline_siege.py | 100 ++++++++ .../gauntlet/trials/ui_avalanche.py | 95 +++++++ 10 files changed, 1039 insertions(+) create mode 100644 tests/benchmarks/gauntlet/DESIGN.md create mode 100644 tests/benchmarks/gauntlet/__init__.py create mode 100644 tests/benchmarks/gauntlet/scoring.py create mode 100644 tests/benchmarks/gauntlet/trials/__init__.py create mode 100644 tests/benchmarks/gauntlet/trials/animation_storm.py create mode 100644 tests/benchmarks/gauntlet/trials/entity_swarm.py create mode 100644 tests/benchmarks/gauntlet/trials/grid_titan.py create mode 100644 tests/benchmarks/gauntlet/trials/pathfinder_rush.py create mode 100644 tests/benchmarks/gauntlet/trials/sightline_siege.py create mode 100644 tests/benchmarks/gauntlet/trials/ui_avalanche.py diff --git a/tests/benchmarks/gauntlet/DESIGN.md b/tests/benchmarks/gauntlet/DESIGN.md new file mode 100644 index 0000000..2e60b25 --- /dev/null +++ b/tests/benchmarks/gauntlet/DESIGN.md @@ -0,0 +1,235 @@ +# Implementation deviations from the original spec + +*(Recorded by the implementer per the spec's instruction below. The original design is +preserved verbatim after this section.)* + +1. **`get_metrics()["frame_time"]` is in MILLISECONDS, not seconds.** The spec's "Engine + API notes" says frame_time is in seconds; runtime probing shows raw values around 16.7 + at 60 fps -- i.e. milliseconds. Scoring and the HUD consume frame_time directly as ms + (no `* 1000`). +2. **vsync / framerate cap must be disabled to measure.** With default windowed vsync the + frame_time floors at ~16.7 ms (the 60 Hz budget) regardless of load, so the ramp could + never observe a genuine failure. `run_gauntlet.py` and `gauntlet_main.py` set + `mcrfpy.window.vsync = False` and `mcrfpy.window.framerate_limit = 0` at startup. +3. **HUD FPS is derived as `1000 / frame_time`.** The metrics `fps` field is a cumulative + running average over total runtime (it starts in the thousands and converges slowly), + not an instantaneous rate, so it is unusable as a live readout. Instantaneous FPS is + computed from the smoothed frame time instead. +4. **`draw_calls`, `ui_elements`, `visible_elements`, and entity render counters read 0** + in this build even while a scene is visibly rendering (the engine's own metrics test + notes these "may be 0 if scene hasn't rendered yet"). They are displayed honestly + (0 when the engine reports 0) rather than faked; `metrics_at_peak` still stores the full + dict. frame_time is the sole scoring signal. +5. **`tick(dt_ms)` runs on a dedicated 100 ms simulation Timer**, separate from the 16 ms + scoring sampler and the 100 ms HUD refresh. The spec described tick as "optional + per-sample work"; each trial's periodic load (grid.step, path queries, color-region + rewrites, z-shuffles, FOV recompute) runs on this sim cadence, matching each trial's own + description ("driven on a 100 ms Timer", "every tick", etc.). +6. **Headless full runs cannot score.** In headless mode frame_time is 0.0 (no real + rendering) and timers fire one-per-`step()`, so `run_gauntlet.py` only yields a real + baseline windowed (or under xvfb). The headless unit test exercises + setup / set_load / teardown directly, not the ramp. +7. Animations use the current `obj.animate(...)` method (the older + `mcrfpy.Animation(...).start()` seen in some demos is not used). + +--- + +# THE GAUNTLET — McRogueFace Interactive Stress Benchmark + +**Design spec (Fable, 2026-07-02).** Implementer: follow this spec; where the engine +API disagrees with an assumption here, prefer the engine and note the deviation at the +top of the committed copy of this file. + +## Concept + +An on-screen benchmark the user *watches*: six themed "trials", each stress-testing one +engine subsystem with steadily ramping load until the frame budget breaks. The maximum +sustained load is that trial's score. Scores are written as a JSON baseline so future +engine changes can be diffed (regression = red, improvement = green) right on screen. + +Two ways to run: +- **Interactive** (`gauntlet_main.py`): menu, watch any trial, drive load manually or auto-ramp. +- **Baseline run** (`run_gauntlet.py`): runs all six trials back-to-back with auto-ramp, + shows the results screen, writes `baseline/gauntlet/latest.json` (and promotes to + `baseline.json` if none exists). + +## File layout + +``` +tests/benchmarks/gauntlet/ +├── DESIGN.md # this file (committed with the code) +├── gauntlet_main.py # interactive entry point (menu scene + trial scenes + results) +├── run_gauntlet.py # unattended full run -> results screen -> baseline JSON +├── hud.py # shared HUD overlay (see Visual Identity) +├── scoring.py # RampController: hold-window sampling, p50/p95, pass/fail, grades +├── baseline_io.py # read/write/compare baseline JSON (schema below) +└── trials/ + ├── __init__.py # Trial base class + TRIALS registry (ordered) + ├── entity_swarm.py + ├── animation_storm.py + ├── grid_titan.py + ├── pathfinder_rush.py + ├── ui_avalanche.py + └── sightline_siege.py +``` + +**All source files ASCII-only** (the `--exec` loader rejects non-ASCII). No unicode +glyphs anywhere, including Caption text: use `^` / `v` for deltas, `*` for accents. + +## Visual identity + +Dark, instrument-panel aesthetic. The benchmark should feel like a cockpit gauge +cluster bolted over a chaotic arena. + +Palette (mcrfpy.Color): +- Background `#0d0f14` (13,15,20); HUD panel fill `#161a22` (22,26,34) with 1px outline `#2a3140` (42,49,64) +- Primary text `#e8eaf0` (232,234,240); dim text `#8a93a6` (138,147,166) +- Frame-budget colors: OK (< 16.7 ms) mint `#38d996` (56,217,150); warn (16.7-33 ms) + amber `#f5b83d` (245,184,61); fail (> 33 ms) red `#e5484d` (229,72,77) + +Trial accent colors (used for the trial banner, its menu row, its arena flavor, and +its row on the results screen): + +| # | Trial | Subsystem | Accent | +|---|------------------|---------------------------|---------------------------| +| 1 | ENTITY SWARM | entity step/render | amber (245,165,36) | +| 2 | ANIMATION STORM | animation manager | magenta (229,85,157) | +| 3 | GRID TITAN | grid render + layer writes| cyan (53,193,214) | +| 4 | PATHFINDER RUSH | Dijkstra/A* queries | green (76,194,110) | +| 5 | UI AVALANCHE | UI hierarchy + draw calls | violet (154,110,245) | +| 6 | SIGHTLINE SIEGE | FOV + perspective | crimson (229,72,77) | + +### HUD (hud.py) — identical overlay on every trial scene + +Top strip, full width, panel-filled: +- Left: trial name in its accent color, plus one-line description in dim text. +- Center: **frame-time sparkline** — 60 thin Frame bars (4 px wide, 2 px gap), + bar height = frame_time clamped to [0, 50] ms mapped to [2, 48] px, colored by the + budget colors above. A 1px horizontal hairline marks the 16.7 ms budget. +- Right: big FPS readout (large Caption, budget-colored), under it + `frame p95: NN.N ms`, `draw calls: N`, and the load line `LOAD: ` + with ramp state tag `[RAMP k]` / `[HOLD]` / `[MANUAL]`. + +Bottom strip: key legend in dim text +`[SPACE] pause [LEFT/RIGHT] trial [-/+] load [A] auto-ramp [R] run gauntlet [S] shot [ESC] menu/quit` + +HUD refreshes on a 100 ms Timer (10 Hz), NOT per frame; metric samples for scoring are +taken on their own 16 ms Timer. HUD cost is constant across trials — that is fine, it +is part of the harness and identical everywhere. + +### Menu scene + +Title `THE GAUNTLET` centered large, subtitle `McRogueFace stress benchmark` in dim +text; engine version + commit short-hash bottom-left (read version from +`mcrfpy.__version__` if present, else omit; commit passed in by run script or read +lazily via `subprocess` is NOT allowed inside the engine — obtain it in baseline_io +with `git rev-parse --short HEAD` guarded by try/except when writing JSON only). +Six menu rows, one per trial: number, name in accent, unit, and — if a baseline +exists — its baseline max_load in dim text. A slow color-cycle animation on the title +(animate fill_color through the six accents, 12 s loop) gives the screen life without +costing measurable frame time. + +### Results scene + +Table, one row per trial: name (accent), `max_load unit`, `p95 ms at peak`, and when a +baseline exists: delta column `^ +12%` (mint) / `v -8%` (red) / `= same` (dim). +Footer: **GAUNTLET SCORE** = geometric mean of per-trial `max_load / baseline_max_load` +ratios, displayed as `xNN.N%` vs baseline (=100% at parity); when no baseline exists show +`FIRST RUN -- baseline recorded` instead. Below it, letter grade per trial vs baseline: +S >= 150%, A >= 100%, B >= 80%, C >= 60%, D below; overall grade from the geomean. + +## Trials + +Common contract (`trials/__init__.py`): + +```python +class Trial: + name = "ENTITY SWARM"; unit = "entities"; accent = (245,165,36) + description = "one line" + base_load = 50 # starting load + growth = 1.6 # geometric ramp factor: load_k = round(base * growth**k) + def setup(self, scene, ui): ... # build arena, return nothing + def set_load(self, level_value): ... # create/destroy stress objects to match + def tick(self, dt_ms): ... # optional per-sample work (default no-op) + def teardown(self): ... +``` + +1. **ENTITY SWARM** — one Grid (~40x25 visible), N entities with SEEK behavior toward a + wandering target using a Dijkstra map, `grid.step()` driven on a 100 ms Timer; + entities render with sprites. Load = entity count. +2. **ANIMATION STORM** — N small Frames scattered on screen, each running two concurrent + animations (position with random easing, fill_color pulse), re-launched from the + animation-complete callback so the storm is self-sustaining. Load = live animations (2N). +3. **GRID TITAN** — square Grid of side S with a TileLayer + ColorLayer; every tick, + rewrite a 32x32 ColorLayer region (rolling window) and animate the camera center in a + slow orbit so chunks keep invalidating. Load = S (grid side; cells = S*S). growth 1.4. +4. **PATHFINDER RUSH** — static maze grid (BSP or simple rooms); each tick issue Q + `grid.find_path` / Dijkstra queries between random walkable pairs. Load = queries/tick. +5. **UI AVALANCHE** — nested Frame trees (depth 5) each with Captions and Sprites, plus a + z-order shuffle of the top-level frames every tick to defeat caching. Load = total + UI elements. +6. **SIGHTLINE SIEGE** — Grid with scattered walls; N entities each with an active FOV + (`compute_fov`, radius 10) recomputed on a 100 ms step as they random-walk, exercising + perspective writeback (#316). Load = FOV entities. + +Trials must create everything under their own Scene and fully dispose in `teardown()` +(remove entities, stop timers, drop references) so trials do not contaminate each other. + +## Ramp + scoring (scoring.py) + +- Auto-ramp: set load, **settle 1.0 s** (discard samples), then **hold 2.0 s** collecting + `get_metrics()["frame_time"]` samples on the 16 ms sampler Timer. +- Pass: p95 <= 16.67 ms -> next ramp step. Fail: stop; trial score = last PASSING load + and its held p50/p95. Also bail immediately if any sample > 100 ms (hard cap). +- Record per trial: `unit, max_load, p50_ms, p95_ms, samples, metrics_at_peak` + (metrics_at_peak = the full get_metrics() dict from the last passing window). + +## Baseline JSON (baseline_io.py) + +Path: `tests/benchmarks/baseline/gauntlet/`. Schema: + +```json +{ "schema": 1, "version": "0.2.8", "commit": "abc1234", "date": "2026-07-02", + "platform": "", "budget_ms": 16.67, + "trials": { "entity_swarm": { "unit": "entities", "max_load": 3200, + "p50_ms": 9.1, "p95_ms": 15.8, "samples": 122, + "metrics_at_peak": { "...": "full get_metrics dict" } } }, + "gauntlet_score_vs_baseline": 1.0 } +``` + +Every full run writes `latest.json`; if `baseline.json` is absent, copy it there. +`baseline_io.compare(latest, baseline)` returns per-trial ratios + geomean for the +results screen. Never overwrite `baseline.json` automatically once it exists. + +## Engine API notes for the implementer (verified against this codebase) + +- `mcrfpy.get_metrics()` -> dict with `frame_time` (s), `avg_frame_time`, `fps`, + `draw_calls`, `ui_elements`, `visible_elements`, `current_frame`, `runtime`, + `grid_render_time`, `entity_render_time`, `fov_overlay_time`, `python_time`, + `animation_time`, `grid_cells_rendered`, `entities_rendered`, `total_entities`. +- Scenes: `s = mcrfpy.Scene("name")`; children via `s.children`; activate with + `mcrfpy.current_scene = s` or `s.activate()`; keyboard `s.on_key = fn(key, state)` + with `mcrfpy.Key` / `mcrfpy.InputState` enums. +- Timers: `mcrfpy.Timer("name", cb, interval_ms)`, callback `(timer, runtime_ms)`; + methods stop/pause/resume/restart. In headless, timers fire only via `mcrfpy.step(dt)`, + ONE event per step. +- Animation: `obj.animate("x", 500.0, 2.0, mcrfpy.Easing.EASE_IN_OUT, callback=fn)`; + callback receives `(target, property, final_value)`. +- Captions: keyword args (`Caption(text=..., pos=...)`); Grid center is in pixels, + `grid.center_camera((tx, ty))` for tile coords; `grid.at(x,y)` GridPoint has + walkable/transparent; layers: construct `ColorLayer(name=..., z_index=...)` then + `grid.add_layer(layer)`; `entity.set_behavior(mcrfpy.Behavior.SEEK, pathfinder=...)`. +- Check `tests/demo/screens/*.py` and `tests/benchmarks/*.py` for working idioms before + writing each trial; `stubs/mcrfpy.pyi` is the API contract. + +## Verification expected from the implementer + +1. Each trial runs standalone headless (`--headless --exec`) driving `mcrfpy.step()`: + setup, set_load at two levels, teardown — asserting object counts and no errors. + Commit as `tests/unit/gauntlet_trials_test.py` (fast, suite-friendly). +2. Screenshot of the menu, one mid-trial scene, and the results scene via + `automation.screenshot()` under headless timers; eyeball-check layout matches this + spec (attach paths in your report). +3. A real baseline attempt: run `run_gauntlet.py` windowed if a display is available, + else under `xvfb-run` if installed. If neither works, say so in your report and + leave baseline capture to the operator — do NOT ship a fake baseline.json. diff --git a/tests/benchmarks/gauntlet/__init__.py b/tests/benchmarks/gauntlet/__init__.py new file mode 100644 index 0000000..76be068 --- /dev/null +++ b/tests/benchmarks/gauntlet/__init__.py @@ -0,0 +1,6 @@ +"""The Gauntlet -- McRogueFace interactive stress benchmark (Gitea #340). + +Run interactively: ./mcrogueface tests/benchmarks/gauntlet/gauntlet_main.py +Capture a baseline: ./mcrogueface tests/benchmarks/gauntlet/run_gauntlet.py +See DESIGN.md for the full specification and implementation deviations. +""" diff --git a/tests/benchmarks/gauntlet/scoring.py b/tests/benchmarks/gauntlet/scoring.py new file mode 100644 index 0000000..e74775f --- /dev/null +++ b/tests/benchmarks/gauntlet/scoring.py @@ -0,0 +1,177 @@ +"""Ramp control and scoring for The Gauntlet. + +The RampController is a small state machine driven by a scoring "sample" call +(nominally a 16 ms Timer). It sets a trial's load, discards a settle window, +collects frame-time samples over a hold window, and decides pass/fail: + + pass : p95 <= budget_ms -> record as new peak, ramp load up one step + fail : p95 > budget_ms -> stop; score = last passing load + bail : any sample > hard_cap_ms -> stop immediately (fail) + +frame_time is read from get_metrics()["frame_time"] and is already in +milliseconds in this engine build (see DESIGN.md deviation 1). +""" + +BUDGET_MS = 16.67 +HARD_CAP_MS = 100.0 +SETTLE_MS = 1000 +HOLD_MS = 2000 +MAX_STEPS = 40 # safety ceiling so a trial that never breaks budget still terminates + + +def percentile(values, q): + """Nearest-rank percentile of an unsorted list. q in [0, 1].""" + if not values: + return 0.0 + s = sorted(values) + if len(s) == 1: + return s[0] + idx = int(round(q * (len(s) - 1))) + idx = max(0, min(len(s) - 1, idx)) + return s[idx] + + +def load_at_step(base_load, growth, k): + """Geometric ramp: load_k = round(base * growth**k).""" + return int(round(base_load * (growth ** k))) + + +def grade_for_ratio(ratio): + """Letter grade vs baseline ratio (max_load / baseline_max_load).""" + if ratio >= 1.50: + return "S" + if ratio >= 1.00: + return "A" + if ratio >= 0.80: + return "B" + if ratio >= 0.60: + return "C" + return "D" + + +class RampController: + """Drives one trial through its auto-ramp and produces a result dict. + + Usage: create, call start(), then call sample(now_ms, metrics) repeatedly + from the scoring timer. When .done is True, .result holds the final dict. + """ + + def __init__(self, trial, metrics_provider, + budget_ms=BUDGET_MS, hard_cap_ms=HARD_CAP_MS, + settle_ms=SETTLE_MS, hold_ms=HOLD_MS, max_steps=MAX_STEPS, + on_finish=None): + self.trial = trial + self.metrics_provider = metrics_provider # callable -> full get_metrics() dict + self.budget_ms = budget_ms + self.hard_cap_ms = hard_cap_ms + self.settle_ms = settle_ms + self.hold_ms = hold_ms + self.max_steps = max_steps + self.on_finish = on_finish + + self.k = 0 + self.load = trial.base_load + self.phase = "idle" # idle | settle | hold | done + self.phase_start = None + self.samples = [] + self.last_pass = None # dict of last passing window + self.done = False + self.result = None + + # -- public API ------------------------------------------------------- + def start(self): + self.k = 0 + self.load = int(self.trial.base_load) + self.trial.set_load(self.load) + self.phase = "settle" + self.phase_start = None + self.samples = [] + self.last_pass = None + self.done = False + self.result = None + + @property + def ramp_tag(self): + if self.done: + return "[DONE]" + if self.phase == "hold": + return "[HOLD]" + return "[RAMP %d]" % self.k + + def sample(self, now_ms, metrics=None): + if self.done or self.phase == "idle": + return + if metrics is None: + metrics = self.metrics_provider() + ft = float(metrics.get("frame_time", 0.0)) + + if self.phase_start is None: + self.phase_start = now_ms + elapsed = now_ms - self.phase_start + + if self.phase == "settle": + if elapsed >= self.settle_ms: + self.phase = "hold" + self.phase_start = now_ms + self.samples = [] + return + + # hold phase + self.samples.append(ft) + if ft > self.hard_cap_ms: + self._evaluate(metrics, forced_fail=True) + return + if elapsed >= self.hold_ms: + self._evaluate(metrics, forced_fail=False) + + # -- internal --------------------------------------------------------- + def _evaluate(self, metrics, forced_fail): + p50 = percentile(self.samples, 0.50) + p95 = percentile(self.samples, 0.95) + passed = (not forced_fail) and (p95 <= self.budget_ms) + + if passed: + self.last_pass = { + "load": self.load, + "p50_ms": round(p50, 3), + "p95_ms": round(p95, 3), + "samples": len(self.samples), + "metrics_at_peak": dict(metrics), + } + if self.k + 1 > self.max_steps: + self._finish() + return + self.k += 1 + self.load = load_at_step(self.trial.base_load, self.trial.growth, self.k) + self.trial.set_load(self.load) + self.phase = "settle" + self.phase_start = None + self.samples = [] + else: + self._finish() + + def _finish(self): + self.phase = "done" + self.done = True + if self.last_pass is not None: + lp = self.last_pass + self.result = { + "unit": self.trial.unit, + "max_load": lp["load"], + "p50_ms": lp["p50_ms"], + "p95_ms": lp["p95_ms"], + "samples": lp["samples"], + "metrics_at_peak": lp["metrics_at_peak"], + } + else: + # First load already failed -- record a zero score honestly. + self.result = { + "unit": self.trial.unit, + "max_load": 0, + "p50_ms": round(percentile(self.samples, 0.50), 3), + "p95_ms": round(percentile(self.samples, 0.95), 3), + "samples": len(self.samples), + "metrics_at_peak": self.metrics_provider(), + } + if self.on_finish: + self.on_finish(self) diff --git a/tests/benchmarks/gauntlet/trials/__init__.py b/tests/benchmarks/gauntlet/trials/__init__.py new file mode 100644 index 0000000..f413395 --- /dev/null +++ b/tests/benchmarks/gauntlet/trials/__init__.py @@ -0,0 +1,85 @@ +"""Trial base class and ordered TRIALS registry for The Gauntlet. + +Each trial stress-tests one engine subsystem. The harness owns all Timers; a +trial only implements setup / set_load / tick / teardown and holds no timers of +its own, so teardown fully disposes its scene contents and trials never +contaminate each other. +""" + + +class Trial: + # -- identity (override in subclasses) -------------------------------- + key = "trial" + name = "TRIAL" + unit = "units" + accent = (200, 200, 200) + description = "one line" + + # -- ramp parameters -------------------------------------------------- + base_load = 50 + growth = 1.6 + + # -- lifecycle -------------------------------------------------------- + def __init__(self): + self.scene = None + self.ui = None + self.load = 0 + + def setup(self, scene, ui): + """Build the arena under `scene`. `ui` is scene.children.""" + self.scene = scene + self.ui = ui + + def set_load(self, level_value): + """Create/destroy stress objects so the live load matches level_value.""" + raise NotImplementedError + + def tick(self, dt_ms): + """Periodic simulation work (100 ms sim cadence). Default: no-op.""" + pass + + def teardown(self): + """Remove everything this trial created.""" + if self.scene is not None: + children = self.scene.children + # Clear any grids' entity collections first. + for child in list(children): + ents = getattr(child, "entities", None) + if ents is not None: + try: + while len(ents): + ents.remove(ents[len(ents) - 1]) + except Exception: + pass + while len(children): + children.remove(children[len(children) - 1]) + self.scene = None + self.ui = None + self.load = 0 + + # -- helpers ---------------------------------------------------------- + def _count_children(self): + return len(self.ui) if self.ui is not None else 0 + + +# Populated at import time from the individual trial modules. +from .entity_swarm import EntitySwarm +from .animation_storm import AnimationStorm +from .grid_titan import GridTitan +from .pathfinder_rush import PathfinderRush +from .ui_avalanche import UIAvalanche +from .sightline_siege import SightlineSiege + +TRIALS = [ + EntitySwarm, + AnimationStorm, + GridTitan, + PathfinderRush, + UIAvalanche, + SightlineSiege, +] + + +def make_trials(): + """Return a fresh instance of every trial, in order.""" + return [cls() for cls in TRIALS] diff --git a/tests/benchmarks/gauntlet/trials/animation_storm.py b/tests/benchmarks/gauntlet/trials/animation_storm.py new file mode 100644 index 0000000..dcf7e4a --- /dev/null +++ b/tests/benchmarks/gauntlet/trials/animation_storm.py @@ -0,0 +1,88 @@ +"""Trial 2: ANIMATION STORM -- animation manager stress. + +N small Frames, each running two concurrent, self-relaunching animations +(position with random easing + a fill_color pulse). Load = live animations = 2N. +""" +import random + +import mcrfpy + +from . import Trial + +ARENA_X, ARENA_Y = 20, 110 +ARENA_W, ARENA_H = 984, 620 +BOX = 16 + + +class AnimationStorm(Trial): + key = "animation_storm" + name = "ANIMATION STORM" + unit = "animations" + accent = (229, 85, 157) + description = "N frames x 2 self-sustaining animations" + base_load = 60 + growth = 1.6 + + def setup(self, scene, ui): + super().setup(scene, ui) + self.rng = random.Random(0xA817) + self.frames = [] + self.live = set() + self.easings = [ + mcrfpy.Easing.LINEAR, mcrfpy.Easing.EASE_IN_OUT_SINE, + mcrfpy.Easing.EASE_OUT_CUBIC, mcrfpy.Easing.EASE_IN_OUT_BACK, + mcrfpy.Easing.EASE_OUT_BOUNCE, + ] + + def _new_frame(self): + x = self.rng.uniform(ARENA_X, ARENA_X + ARENA_W - BOX) + y = self.rng.uniform(ARENA_Y, ARENA_Y + ARENA_H - BOX) + fr = mcrfpy.Frame(pos=(x, y), size=(BOX, BOX)) + fr.fill_color = mcrfpy.Color(self.rng.randint(80, 255), + self.rng.randint(40, 200), + self.rng.randint(120, 255)) + self.ui.append(fr) + self.frames.append(fr) + self.live.add(fr) + self._launch_pos(fr) + self._launch_color(fr) + + def _launch_pos(self, fr): + if fr not in self.live: + return + tx = self.rng.uniform(ARENA_X, ARENA_X + ARENA_W - BOX) + dur = self.rng.uniform(0.4, 1.2) + fr.animate("x", tx, dur, self.rng.choice(self.easings), + callback=self._on_pos_done) + + def _launch_color(self, fr): + if fr not in self.live: + return + tgt = float(self.rng.choice((40, 255))) + dur = self.rng.uniform(0.3, 0.9) + fr.animate("fill_color.r", tgt, dur, mcrfpy.Easing.EASE_IN_OUT_SINE, + callback=self._on_color_done) + + def _on_pos_done(self, target, prop, val): + self._launch_pos(target) + + def _on_color_done(self, target, prop, val): + self._launch_color(target) + + def set_load(self, level_value): + target_frames = max(1, int(level_value) // 2) + while len(self.frames) < target_frames: + self._new_frame() + while len(self.frames) > target_frames: + fr = self.frames.pop() + self.live.discard(fr) + try: + self.ui.remove(fr) + except Exception: + pass + self.load = len(self.frames) * 2 + + def teardown(self): + self.live = set() + self.frames = [] + super().teardown() diff --git a/tests/benchmarks/gauntlet/trials/entity_swarm.py b/tests/benchmarks/gauntlet/trials/entity_swarm.py new file mode 100644 index 0000000..8424b7d --- /dev/null +++ b/tests/benchmarks/gauntlet/trials/entity_swarm.py @@ -0,0 +1,92 @@ +"""Trial 1: ENTITY SWARM -- entity step + render stress. + +One 40x25 grid, N entities with SEEK behavior chasing a wandering target via a +Dijkstra map, advanced with grid.step() on the sim tick. Load = entity count. +""" +import random + +import mcrfpy + +from . import Trial + +GRID_W, GRID_H = 40, 25 +CELL = 24 # display px per cell (zoom applied) +ARENA_X, ARENA_Y = 40, 110 +SEEK = int(mcrfpy.Behavior.SEEK) + + +class EntitySwarm(Trial): + key = "entity_swarm" + name = "ENTITY SWARM" + unit = "entities" + accent = (245, 165, 36) + description = "N seeking entities, grid.step() every tick" + base_load = 60 + growth = 1.6 + + def setup(self, scene, ui): + super().setup(scene, ui) + self.rng = random.Random(0xE117) + self.ticks = 0 + self.target = (GRID_W // 2, GRID_H // 2) + + grid = mcrfpy.Grid(grid_size=(GRID_W, GRID_H), + pos=(ARENA_X, ARENA_Y), + size=(GRID_W * CELL, GRID_H * CELL)) + grid.zoom = CELL / 16.0 + grid.fill_color = mcrfpy.Color(18, 22, 30) + for y in range(GRID_H): + for x in range(GRID_W): + c = grid.at(x, y) + edge = (x == 0 or y == 0 or x == GRID_W - 1 or y == GRID_H - 1) + c.walkable = not edge + c.transparent = not edge + ui.append(grid) + self.grid = grid + self.entities = [] + self.dmap = grid.get_dijkstra_map(self.target) + grid.center_camera((GRID_W / 2.0, GRID_H / 2.0)) + + def _spawn(self): + while True: + x = self.rng.randint(1, GRID_W - 2) + y = self.rng.randint(1, GRID_H - 2) + if (x, y) != self.target: + break + e = mcrfpy.Entity((x, y), grid=self.grid) + e.sprite_index = self.rng.choice((84, 85, 86, 100, 101)) + e.move_speed = 0.0 + e.set_behavior(SEEK, pathfinder=self.dmap) + self.entities.append(e) + + def set_load(self, level_value): + target_n = max(1, int(level_value)) + while len(self.entities) < target_n: + self._spawn() + while len(self.entities) > target_n: + e = self.entities.pop() + try: + e.die() + except Exception: + pass + self.load = len(self.entities) + + def _move_target(self): + self.target = (self.rng.randint(1, GRID_W - 2), + self.rng.randint(1, GRID_H - 2)) + self.grid.clear_dijkstra_maps() + self.dmap = self.grid.get_dijkstra_map(self.target) + for e in self.entities: + e.set_behavior(SEEK, pathfinder=self.dmap) + + def tick(self, dt_ms): + self.ticks += 1 + if self.ticks % 6 == 0: + self._move_target() + self.grid.step() + + def teardown(self): + self.entities = [] + self.grid = None + self.dmap = None + super().teardown() diff --git a/tests/benchmarks/gauntlet/trials/grid_titan.py b/tests/benchmarks/gauntlet/trials/grid_titan.py new file mode 100644 index 0000000..02b58fe --- /dev/null +++ b/tests/benchmarks/gauntlet/trials/grid_titan.py @@ -0,0 +1,87 @@ +"""Trial 3: GRID TITAN -- grid render + layer-write stress. + +Square SxS grid with a TileLayer base and a ColorLayer overlay. Every tick a +32x32 ColorLayer region is rewritten (rolling window) and the camera orbits so +render chunks keep invalidating. Load = S (grid side); cells = S*S. +""" +import math + +import mcrfpy + +from . import Trial + +VIEW_PX = 620 +ARENA_X, ARENA_Y = 200, 115 +REGION = 32 + + +class GridTitan(Trial): + key = "grid_titan" + name = "GRID TITAN" + unit = "grid side" + accent = (53, 193, 214) + description = "SxS grid, rolling color region + orbiting camera" + base_load = 20 + growth = 1.4 + + def setup(self, scene, ui): + super().setup(scene, ui) + self.grid = None + self.color = None + self.angle = 0.0 + self.roll = 0 + self.side = 0 + + def _build(self, side): + if self.grid is not None: + try: + self.ui.remove(self.grid) + except Exception: + pass + self.side = side + grid = mcrfpy.Grid(grid_size=(side, side), + pos=(ARENA_X, ARENA_Y), + size=(VIEW_PX, VIEW_PX), + texture=mcrfpy.default_texture) + grid.zoom = VIEW_PX / float(side * 16) + grid.fill_color = mcrfpy.Color(10, 12, 18) + base = mcrfpy.TileLayer(z_index=-2, name="base", texture=mcrfpy.default_texture) + grid.add_layer(base) + base.fill(0) + color = mcrfpy.ColorLayer(z_index=-1, name="overlay") + grid.add_layer(color) + color.fill(mcrfpy.Color(20, 26, 38, 120)) + self.ui.append(grid) + self.grid = grid + self.color = color + grid.center_camera((side / 2.0, side / 2.0)) + + def set_load(self, level_value): + side = max(4, int(level_value)) + self._build(side) + self.load = side + + def tick(self, dt_ms): + if self.grid is None: + return + s = self.side + # Orbit the camera around the grid centre. + self.angle += 0.18 + r = s * 0.15 + cx, cy = s / 2.0, s / 2.0 + self.grid.center_camera((cx + r * math.cos(self.angle), + cy + r * math.sin(self.angle))) + # Rewrite a rolling REGIONxREGION color window. + w = min(REGION, s) + span = max(1, s - w) + self.roll = (self.roll + 3) % span + ox = self.roll + oy = (self.roll * 2) % span + phase = (self.angle * 40) % 255 + col = mcrfpy.Color(int(phase), int(255 - phase), 160, 180) + self.color.fill_rect((ox, oy), (w, w), col) + + def teardown(self): + self.grid = None + self.color = None + super().teardown() diff --git a/tests/benchmarks/gauntlet/trials/pathfinder_rush.py b/tests/benchmarks/gauntlet/trials/pathfinder_rush.py new file mode 100644 index 0000000..04207de --- /dev/null +++ b/tests/benchmarks/gauntlet/trials/pathfinder_rush.py @@ -0,0 +1,74 @@ +"""Trial 4: PATHFINDER RUSH -- A* query stress. + +A static maze grid; every tick issues Q grid.find_path queries between random +walkable pairs. Load = queries per tick. +""" +import random + +import mcrfpy + +from . import Trial + +GRID_W, GRID_H = 60, 40 +CELL = 15 +ARENA_X, ARENA_Y = 60, 120 + + +class PathfinderRush(Trial): + key = "pathfinder_rush" + name = "PATHFINDER RUSH" + unit = "queries/tick" + accent = (76, 194, 110) + description = "A* find_path queries across a static maze" + base_load = 5 + growth = 1.6 + + def setup(self, scene, ui): + super().setup(scene, ui) + self.rng = random.Random(0x9A2E) + self.queries = 0 + + grid = mcrfpy.Grid(grid_size=(GRID_W, GRID_H), + pos=(ARENA_X, ARENA_Y), + size=(GRID_W * CELL, GRID_H * CELL)) + grid.zoom = CELL / 16.0 + grid.fill_color = mcrfpy.Color(10, 14, 20) + floor = mcrfpy.ColorLayer(z_index=-1, name="floor") + grid.add_layer(floor) + floor.fill(mcrfpy.Color(40, 54, 44)) + + wall_c = mcrfpy.Color(14, 20, 16) + self.walkables = [] + for y in range(GRID_H): + for x in range(GRID_W): + c = grid.at(x, y) + wall = (x == 0 or y == 0 or x == GRID_W - 1 or y == GRID_H - 1) + # Pillar/room maze: isolated blockers keep the map connected but windy. + if not wall and x % 4 == 2 and y % 3 == 1: + wall = True + c.walkable = not wall + c.transparent = not wall + if wall: + floor.set((x, y), wall_c) + else: + self.walkables.append((x, y)) + ui.append(grid) + self.grid = grid + + def set_load(self, level_value): + self.queries = max(1, int(level_value)) + self.load = self.queries + + def tick(self, dt_ms): + if self.grid is None: + return + w = self.walkables + for _ in range(self.queries): + a = w[self.rng.randrange(len(w))] + b = w[self.rng.randrange(len(w))] + self.grid.find_path(a, b) + + def teardown(self): + self.grid = None + self.walkables = [] + super().teardown() diff --git a/tests/benchmarks/gauntlet/trials/sightline_siege.py b/tests/benchmarks/gauntlet/trials/sightline_siege.py new file mode 100644 index 0000000..4a08984 --- /dev/null +++ b/tests/benchmarks/gauntlet/trials/sightline_siege.py @@ -0,0 +1,100 @@ +"""Trial 6: SIGHTLINE SIEGE -- FOV + perspective writeback stress. + +A grid with scattered walls; N entities each recompute an active FOV (radius 10) +via update_visibility() on the sim tick as they random-walk. Load = FOV entities. +""" +import random + +import mcrfpy + +from . import Trial + +GRID_W, GRID_H = 50, 32 +CELL = 18 +ARENA_X, ARENA_Y = 60, 118 +SIGHT = 10 +STEPS = ((1, 0), (-1, 0), (0, 1), (0, -1)) + + +class SightlineSiege(Trial): + key = "sightline_siege" + name = "SIGHTLINE SIEGE" + unit = "FOV entities" + accent = (229, 72, 77) + description = "N random-walking entities, per-entity FOV recompute" + base_load = 20 + growth = 1.6 + + def setup(self, scene, ui): + super().setup(scene, ui) + self.rng = random.Random(0xF0F0) + + grid = mcrfpy.Grid(grid_size=(GRID_W, GRID_H), + pos=(ARENA_X, ARENA_Y), + size=(GRID_W * CELL, GRID_H * CELL), + texture=mcrfpy.default_texture) + grid.zoom = CELL / 16.0 + grid.fill_color = mcrfpy.Color(8, 10, 16) + grid.fov_radius = SIGHT + floor = mcrfpy.ColorLayer(z_index=-1, name="floor") + grid.add_layer(floor) + floor.fill(mcrfpy.Color(38, 30, 34)) + wall_c = mcrfpy.Color(16, 12, 14) + + self.walkables = [] + for y in range(GRID_H): + for x in range(GRID_W): + c = grid.at(x, y) + wall = (x == 0 or y == 0 or x == GRID_W - 1 or y == GRID_H - 1) + if not wall and self.rng.random() < 0.16: + wall = True + c.walkable = not wall + c.transparent = not wall + if wall: + floor.set((x, y), wall_c) + else: + self.walkables.append((x, y)) + ui.append(grid) + self.grid = grid + self.entities = [] + + def _spawn(self): + x, y = self.walkables[self.rng.randrange(len(self.walkables))] + e = mcrfpy.Entity((x, y), grid=self.grid) + e.sprite_index = 90 + e.sight_radius = SIGHT + e.move_speed = 0.0 + e.update_visibility() + self.entities.append(e) + + def set_load(self, level_value): + target_n = max(1, int(level_value)) + while len(self.entities) < target_n: + self._spawn() + while len(self.entities) > target_n: + e = self.entities.pop() + try: + e.die() + except Exception: + pass + self.load = len(self.entities) + + def tick(self, dt_ms): + g = self.grid + for e in self.entities: + x, y = e.grid_x, e.grid_y + dirs = list(STEPS) + self.rng.shuffle(dirs) + for dx, dy in dirs: + nx, ny = x + dx, y + dy + if 0 <= nx < GRID_W and 0 <= ny < GRID_H and g.at(nx, ny).walkable: + e.grid_x = nx + e.grid_y = ny + break + e.update_visibility() + + def teardown(self): + self.entities = [] + self.grid = None + self.walkables = [] + super().teardown() diff --git a/tests/benchmarks/gauntlet/trials/ui_avalanche.py b/tests/benchmarks/gauntlet/trials/ui_avalanche.py new file mode 100644 index 0000000..321a425 --- /dev/null +++ b/tests/benchmarks/gauntlet/trials/ui_avalanche.py @@ -0,0 +1,95 @@ +"""Trial 5: UI AVALANCHE -- UI hierarchy + draw-call stress. + +Nested Frame trees (depth 5), each level carrying a Caption and a Sprite, plus a +z-order shuffle of the top-level frames every tick to defeat render caching. +Load = total UI elements. +""" +import random + +import mcrfpy + +from . import Trial + +ARENA_X, ARENA_Y = 20, 108 +COL_W, ROW_H = 132, 132 +COLS = 7 +DEPTH = 5 +PER_TREE = 15 # 1 root frame + 5*(caption+sprite) + 4 nested frames + + +class UIAvalanche(Trial): + key = "ui_avalanche" + name = "UI AVALANCHE" + unit = "elements" + accent = (154, 110, 245) + description = "Depth-5 frame trees, z-shuffled every tick" + base_load = 60 + growth = 1.6 + + def setup(self, scene, ui): + super().setup(scene, ui) + self.rng = random.Random(0x5A1A) + self.trees = [] # list of root Frames + self.total = 0 + + def _build_tree(self, index): + col = index % COLS + row = index // COLS + x = ARENA_X + col * COL_W + y = ARENA_Y + row * ROW_H + root = mcrfpy.Frame(pos=(x, y), size=(120, 120)) + root.fill_color = mcrfpy.Color(self.rng.randint(30, 90), + self.rng.randint(20, 70), + self.rng.randint(60, 120)) + root.outline = 1 + root.outline_color = mcrfpy.Color(154, 110, 245) + self.ui.append(root) + count = 1 + parent = root + size = 120 + for d in range(DEPTH): + cap = mcrfpy.Caption(text="L%d" % d, pos=(4, 2)) + cap.fill_color = mcrfpy.Color(220, 220, 240) + parent.children.append(cap) + count += 1 + spr = mcrfpy.Sprite(pos=(4, 18), texture=mcrfpy.default_texture, + sprite_index=self.rng.randint(0, 120)) + parent.children.append(spr) + count += 1 + if d < DEPTH - 1: + size -= 18 + child = mcrfpy.Frame(pos=(10, 30), size=(size, size)) + child.fill_color = mcrfpy.Color(self.rng.randint(30, 90), + self.rng.randint(20, 70), + self.rng.randint(60, 120)) + parent.children.append(child) + count += 1 + parent = child + self.trees.append(root) + self.total += count + + def set_load(self, level_value): + target = max(PER_TREE, int(level_value)) + while self.total < target: + self._build_tree(len(self.trees)) + # Shed whole trees while we can stay at or above target. + while self.trees and (self.total - PER_TREE) >= target: + root = self.trees.pop() + try: + self.ui.remove(root) + except Exception: + pass + self.total -= PER_TREE + self.load = self.total + + def tick(self, dt_ms): + # Shuffle z-order of top-level frames to invalidate caches. + order = list(range(len(self.trees))) + self.rng.shuffle(order) + for z, root in zip(order, self.trees): + root.z_index = z + + def teardown(self): + self.trees = [] + self.total = 0 + super().teardown()