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 <noreply@anthropic.com>
This commit is contained in:
John McCardle 2026-07-02 20:19:30 -04:00
commit 859730f02d
10 changed files with 1039 additions and 0 deletions

View file

@ -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: <value> <unit>`
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": "<platform.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.

View file

@ -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.
"""

View file

@ -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)

View file

@ -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]

View file

@ -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()

View file

@ -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()

View file

@ -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()

View file

@ -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()

View file

@ -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()

View file

@ -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()