j124 spinner claim: add ConstantSpinner adversary + spinner A/B arms, panel, analyzer
This commit is contained in:
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "ConstantSpinner",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"authors": ["Davide Cappellini"],
|
||||||
|
"description": "True constant-turn spinner adversary — constant speed + constant body turn rate, head-on firing",
|
||||||
|
"homepage": "",
|
||||||
|
"countryCodes": ["IT"],
|
||||||
|
"gameTypes": ["classic", "1v1"],
|
||||||
|
"platform": "Nim",
|
||||||
|
"programmingLang": "Nim"
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Package
|
||||||
|
version = "0.1.0"
|
||||||
|
author = "Davide Cappellini"
|
||||||
|
description = "True constant-turn spinner adversary — constant speed + constant body turn rate"
|
||||||
|
license = "MIT"
|
||||||
|
srcDir = "src"
|
||||||
|
bin = @["ConstantSpinner"]
|
||||||
|
|
||||||
|
# Dependencies
|
||||||
|
requires "nim >= 2.0.0"
|
||||||
|
requires "robocode_tankroyale_botapi >= 1.0.7"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
exec ./out/ConstantSpinner 2>> /tmp/constantspinner_stderr.log
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
--path:"../../.."
|
||||||
|
switch("outdir", "out")
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# make_variant.sh — generate a spin-rate preset of the ConstantSpinner adversary.
|
||||||
|
#
|
||||||
|
# make_variant.sh <NAME> <TURN_RATE_DEG_PER_TICK> <SPEED> [OUTDIR]
|
||||||
|
#
|
||||||
|
# e.g.
|
||||||
|
# make_variant.sh ConstantSpinner_s3 3 5 /tmp/tr_spinners
|
||||||
|
# make_variant.sh ConstantSpinner_s6 6 5 /tmp/tr_spinners
|
||||||
|
#
|
||||||
|
# The variant dir is a normal Tank Royale bot directory named <NAME>: the runner
|
||||||
|
# matches the connecting bot's identity against <OUTDIR>/<NAME>/<NAME>.json, so
|
||||||
|
# the launcher exports BOT_JSON to make the binary handshake under <NAME>.
|
||||||
|
#
|
||||||
|
# The single committed ConstantSpinner binary is reused (no per-variant build);
|
||||||
|
# only SPIN_TURN_RATE / SPIN_SPEED differ.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
BIN="$HERE/out/ConstantSpinner"
|
||||||
|
|
||||||
|
NAME="${1:?usage: make_variant.sh <NAME> <TURN_RATE> <SPEED> [OUTDIR]}"
|
||||||
|
TURN="${2:?usage: make_variant.sh <NAME> <TURN_RATE> <SPEED> [OUTDIR]}"
|
||||||
|
SPEED="${3:?usage: make_variant.sh <NAME> <TURN_RATE> <SPEED> [OUTDIR]}"
|
||||||
|
OUTDIR="${4:-/tmp/tr_spinners}"
|
||||||
|
|
||||||
|
[[ -x "$BIN" ]] || { echo "ERROR: build first: nim c -d:release src/ConstantSpinner.nim" >&2; exit 1; }
|
||||||
|
|
||||||
|
DIR="$OUTDIR/$NAME"
|
||||||
|
mkdir -p "$DIR"
|
||||||
|
|
||||||
|
cat > "$DIR/$NAME.json" <<JSON
|
||||||
|
{
|
||||||
|
"name": "$NAME",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"authors": ["Davide Cappellini"],
|
||||||
|
"description": "ConstantSpinner preset: constant turn ${TURN} deg/tick at speed ${SPEED}",
|
||||||
|
"homepage": "",
|
||||||
|
"countryCodes": ["IT"],
|
||||||
|
"gameTypes": ["classic", "1v1"],
|
||||||
|
"platform": "Nim",
|
||||||
|
"programmingLang": "Nim"
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
|
||||||
|
cat > "$DIR/$NAME.sh" <<SH
|
||||||
|
#!/bin/sh
|
||||||
|
cd "\$(dirname "\$0")"
|
||||||
|
export BOT_JSON="$DIR/$NAME.json"
|
||||||
|
export SPIN_TURN_RATE="$TURN"
|
||||||
|
export SPIN_SPEED="$SPEED"
|
||||||
|
exec "$BIN" 2>> /tmp/constantspinner_stderr.log
|
||||||
|
SH
|
||||||
|
chmod +x "$DIR/$NAME.sh"
|
||||||
|
|
||||||
|
echo "generated $DIR (turn=$TURN deg/tick, speed=$SPEED)"
|
||||||
Binary file not shown.
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "ConstantSpinner",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"authors": ["Davide Cappellini"],
|
||||||
|
"description": "True constant-turn spinner adversary — constant speed + constant body turn rate, head-on firing",
|
||||||
|
"homepage": "",
|
||||||
|
"countryCodes": ["IT"],
|
||||||
|
"gameTypes": ["classic", "1v1"],
|
||||||
|
"platform": "Nim",
|
||||||
|
"programmingLang": "Nim"
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# ConstantSpinner — a TRUE constant-turn spinner.
|
||||||
|
#
|
||||||
|
# Purpose: the legacy roster has no constant-turn spinner. `SpinBot` (the sample
|
||||||
|
# bot) and the in-repo movers are periodic/oscillating, but this fixture is the
|
||||||
|
# degenerate case the owner's gun claim is really about: a body that moves at a
|
||||||
|
# CONSTANT forward speed while turning at a CONSTANT rate, forever. Its path is a
|
||||||
|
# perfect circle of radius v / omega, i.e. a single-frequency, low-entropy,
|
||||||
|
# perfectly periodic movement — the easiest possible target for a fast-adapting
|
||||||
|
# or pattern-matching gun.
|
||||||
|
#
|
||||||
|
# It is a MOVEMENT FIXTURE, not a fighter: the gun is plain head-on, fired at low
|
||||||
|
# power when the turret happens to be on target. No adaptation, no randomness, no
|
||||||
|
# wall logic (the server's wall collision is the only non-periodic perturbation).
|
||||||
|
#
|
||||||
|
# Env (read at round start, so one binary serves every spin rate):
|
||||||
|
# SPIN_TURN_RATE body turn rate, deg/tick (default 6.0)
|
||||||
|
# SPIN_SPEED forward speed, units/tick (default 5.0)
|
||||||
|
# SPIN_FIRE_POWER bullet power (default 1.0)
|
||||||
|
#
|
||||||
|
# The requested turn rate is clamped to the maximum reachable at the target
|
||||||
|
# speed (`MAX_TURN_RATE - 0.75 * speed`), so the ACTUAL turn rate stays exactly
|
||||||
|
# constant instead of being server-clamped while the bot accelerates or slides
|
||||||
|
# along a wall.
|
||||||
|
|
||||||
|
import std/[math, os, strutils]
|
||||||
|
import robocode_tankroyale_botapi
|
||||||
|
|
||||||
|
# The identity JSON. A generated spin-rate variant directory (see
|
||||||
|
# `make_variant.sh`) sets BOT_JSON so the bot handshakes under the variant's own
|
||||||
|
# name, which is what the Tank Royale runner matches against `<dir>/<dir>.json`.
|
||||||
|
const defaultBotJsonPath = currentSourcePath().parentDir / "ConstantSpinner.json"
|
||||||
|
|
||||||
|
proc envFloat(name: string, default: float): float =
|
||||||
|
let v = getEnv(name)
|
||||||
|
if v.len == 0: return default
|
||||||
|
try:
|
||||||
|
parseFloat(v)
|
||||||
|
except ValueError:
|
||||||
|
default
|
||||||
|
|
||||||
|
type ConstantSpinner = ref object of Bot
|
||||||
|
turnRate: float
|
||||||
|
speed: float
|
||||||
|
firePower: float
|
||||||
|
|
||||||
|
method onRoundStarted*(bot: ConstantSpinner, e: RoundStartedEvent) =
|
||||||
|
# Gun and radar stay independent of the (continuously turning) body.
|
||||||
|
setAdjustGunForBodyTurn(true)
|
||||||
|
setAdjustRadarForBodyTurn(true)
|
||||||
|
setAdjustRadarForGunTurn(true)
|
||||||
|
|
||||||
|
bot.speed = envFloat("SPIN_SPEED", 5.0)
|
||||||
|
bot.turnRate = envFloat("SPIN_TURN_RATE", 6.0)
|
||||||
|
bot.firePower = envFloat("SPIN_FIRE_POWER", 1.0)
|
||||||
|
|
||||||
|
# A turn rate above the speed-dependent maximum would be clamped by the
|
||||||
|
# server, making the rate non-constant during acceleration / wall slides.
|
||||||
|
let maxAtSpeed = MAX_TURN_RATE - 0.75 * abs(bot.speed)
|
||||||
|
if bot.turnRate > maxAtSpeed:
|
||||||
|
bot.turnRate = maxAtSpeed
|
||||||
|
if bot.turnRate < 0.0:
|
||||||
|
bot.turnRate = 0.0
|
||||||
|
|
||||||
|
method onScannedBot*(bot: ConstantSpinner, e: ScannedBotEvent) =
|
||||||
|
# Head-on: aim at where the enemy is right now, fire low power whenever the
|
||||||
|
# gun is cool. It is a movement fixture, so the shot goes wherever the turret
|
||||||
|
# happens to point; the radar sweep keeps scans coming.
|
||||||
|
let bearing = directionTo(getX(), getY(), e.x, e.y)
|
||||||
|
let gunDelta = normalizeRelativeAngle(bearing - getGunDirection())
|
||||||
|
setGunTurnRate(gunDelta.clamp(-MAX_GUN_TURN_RATE, MAX_GUN_TURN_RATE))
|
||||||
|
if getGunHeat() <= 0.0:
|
||||||
|
discard setFire(bot.firePower)
|
||||||
|
|
||||||
|
method run*(bot: ConstantSpinner) =
|
||||||
|
while isRunning():
|
||||||
|
# The whole movement: constant turn, constant speed. Nothing else.
|
||||||
|
setRadarTurnRate(45.0)
|
||||||
|
setTurnRate(bot.turnRate)
|
||||||
|
setTargetSpeed(bot.speed)
|
||||||
|
go()
|
||||||
|
|
||||||
|
when isMainModule:
|
||||||
|
var bot = ConstantSpinner()
|
||||||
|
let jsonPath = getEnv("BOT_JSON", defaultBotJsonPath)
|
||||||
|
start(bot, jsonPath)
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# arms_spinner.txt — the SPINNER-CLAIM A/B (j124, owner claim 1).
|
||||||
|
#
|
||||||
|
# Claim: "I never seen a gun that learns wall movement or circular movement like
|
||||||
|
# spinning bot so fast - like a gun made on purpose for those movements."
|
||||||
|
#
|
||||||
|
# Three arms, identical frozen binary, MOVEMENT PINNED (`TR_MOVEMENT=strafe`) in
|
||||||
|
# every arm (same standard as the gun campaign). Only the gun rack differs.
|
||||||
|
# Format: name | ENV=value ENV=value | label
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# 1. REFERENCE — shipped onlyPattern rack, movement pinned.
|
||||||
|
pattern | TR_MOVEMENT=strafe | shipped onlyPattern rack (REFERENCE)
|
||||||
|
|
||||||
|
# 2. BitBrain learned-gain, decay memory (the config the owner most likely ran;
|
||||||
|
# the arm used in the 32-opponent gauntlet and both j121 batches).
|
||||||
|
bitbrain | TR_MOVEMENT=strafe TR_RACK_PATTERN=off TR_RACK_BITBRAIN=both TR_BITBRAIN_GAINS=1.0,1.25,1.5,2.0 TR_BITBRAIN_MEM=decay | BitBrain-only, learned gain, decay
|
||||||
|
|
||||||
|
# 3. BitBrain learned-gain, RETAINED memory (the "adapt across the whole battle"
|
||||||
|
# mode the owner described; not previously panel-tested).
|
||||||
|
bitbrain_ret | TR_MOVEMENT=strafe TR_RACK_PATTERN=off TR_RACK_BITBRAIN=both TR_BITBRAIN_GAINS=1.0,1.25,1.5,2.0 TR_BITBRAIN_MEM=retained | BitBrain-only, learned gain, retained
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# panel_spinner.txt — the SPINNER-CLAIM panel (j124).
|
||||||
|
#
|
||||||
|
# The legacy roster has no constant-turn spinner (docs/gauntlet_bitbrain_vs_pattern.md,
|
||||||
|
# docs/gun_campaign.md). This panel adds two: the new ConstantSpinner adversary at
|
||||||
|
# two spin rates, plus the sample SpinBot as the j121 "spinner" control, plus the
|
||||||
|
# same kind of regular/dodger controls the j117 gauntlet used, so the
|
||||||
|
# regular-vs-dodger comparison is directly readable.
|
||||||
|
#
|
||||||
|
# Format: <bot dir> | <style group> | <note>. `style` is INFERRED (robots.json /
|
||||||
|
# the bot name / the fixture's own constants) and used ONLY to explain a result.
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
/tmp/tr_spinners/ConstantSpinner_s3 | spinner | TRUE constant-turn spinner: 3.0 deg/tick @ speed 5 (r=95.5 px, period 120 ticks)
|
||||||
|
/tmp/tr_spinners/ConstantSpinner_s6 | spinner | TRUE constant-turn spinner: 6.0 deg/tick @ speed 5 (r=47.7 px, period 60 ticks)
|
||||||
|
/home/davide/Projects/tank-royale/sample-bots/java/build/archive/SpinBot | spinner | sample SpinBot (max-turn circle-mover; the j121 control)
|
||||||
|
/tmp/tr_bots/WallAvoider | regular | legacy regular mover control (j117/j121)
|
||||||
|
/tmp/tr_bots/DiamondStealer | regular | legacy rammer / close brawler control (j117/j121)
|
||||||
|
/tmp/tr_bots/HawkOnFire | regular | legacy corner-camper control (j117/j121)
|
||||||
|
/tmp/tr_bots/Diamond | dodger | legacy wave-surfing dodger control (j117/j121)
|
||||||
|
/tmp/tr_bots/CassiusClay | dodger | legacy pattern/surf dodger control (j117/j121)
|
||||||
|
/tmp/tr_bots/GresSuffurd | dodger | legacy wave-surfing dodger control (j117/j121)
|
||||||
@@ -0,0 +1,519 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""spinner_analyze.py — the SPINNER-CLAIM analyzer (j124, owner claim 1).
|
||||||
|
|
||||||
|
python3 tools/ab/spinner_analyze.py <session_dir> [--reference pattern] [--report FILE]
|
||||||
|
|
||||||
|
Reads a session produced by `tools/ab/tournament_run.sh` (layout:
|
||||||
|
<outdir>/<opponent>/<arm>/run<N>.{battle.log,events.jsonl,jsonl,jsonl.rounds.json,bot.stdout.log})
|
||||||
|
and answers the owner's claim:
|
||||||
|
|
||||||
|
"I never seen a gun that learns wall movement or circular movement like
|
||||||
|
spinning bot so fast - like a gun made on purpose for those movements."
|
||||||
|
|
||||||
|
Unlike the gun campaign's gauntlet_analyze.py (exactly 2 arms), this supports N
|
||||||
|
arms and adds the CONVERGENCE layer the claim is really about: our gun's per-round
|
||||||
|
hit rate on the first rounds vs the later rounds, per arm, and within-round
|
||||||
|
(first-half vs second-half ticks) for the spinner opponents.
|
||||||
|
|
||||||
|
Primary metrics stay damage/run and ROUND WINS. Hit rate (ours, per-round) is the
|
||||||
|
SECONDARY mechanism metric, reported but never the verdict. MDE is printed for
|
||||||
|
every paired test so a null is distinguishable from an under-powered null.
|
||||||
|
|
||||||
|
Standard library only. Deterministic (fixed permutation seed).
|
||||||
|
"""
|
||||||
|
import itertools
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import re
|
||||||
|
import statistics
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
import gauntlet_analyze as ga # noqa: E402 (reuse the proven parsers/stats)
|
||||||
|
|
||||||
|
BOT_NAME = "ModularBot"
|
||||||
|
EXACT_PAIR_CAP = 20
|
||||||
|
MC_DRAWS = 200_000
|
||||||
|
MC_SEED = 0x5EED5EED
|
||||||
|
|
||||||
|
|
||||||
|
# ── parsing one run ──────────────────────────────────────────────────────────
|
||||||
|
def read_round_starts(path):
|
||||||
|
try:
|
||||||
|
data = json.load(open(path))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return {}
|
||||||
|
return {r["round"]: r["startTick"] for r in data.get("rounds", [])}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_run(armdir, run, env_req=None):
|
||||||
|
evs = ga.parse_events(os.path.join(armdir, f"run{run}.events.jsonl"))
|
||||||
|
log_text = "".join(ga.read_lines(os.path.join(armdir, f"run{run}.battle.log")))
|
||||||
|
counters = ga.parse_counters(log_text)
|
||||||
|
wins = ga.parse_first_places(log_text)
|
||||||
|
nrounds = ga.round_count(os.path.join(armdir, f"run{run}.jsonl.rounds.json"))
|
||||||
|
starts = read_round_starts(os.path.join(armdir, f"run{run}.jsonl.rounds.json"))
|
||||||
|
|
||||||
|
r = {"run": run, "valid": False, "rounds": nrounds, "wins": wins,
|
||||||
|
"damage": 0.0, "damage_taken": 0.0, "mb_fired": 0, "mb_hits": 0,
|
||||||
|
"opp_fired": 0, "opp_hits": 0, "env_ok": True, "per_round": {}}
|
||||||
|
if env_req:
|
||||||
|
text = "".join(ga.read_lines(os.path.join(armdir, f"run{run}.bot.stdout.log")))
|
||||||
|
r["env_ok"] = all(
|
||||||
|
re.search(re.escape(k) + r"\s*=\s*" + re.escape(v) + r"(\s|$)", text)
|
||||||
|
is not None for k, v in env_req.items())
|
||||||
|
if wins is None or counters is None or nrounds == 0:
|
||||||
|
return r
|
||||||
|
subj, other = ga.attribute_subject(evs, counters)
|
||||||
|
if subj is None:
|
||||||
|
return r
|
||||||
|
|
||||||
|
def slot(rnd):
|
||||||
|
return r["per_round"].setdefault(rnd, {
|
||||||
|
"mb_fired": 0, "mb_hits": 0, "mb_damage": 0.0,
|
||||||
|
"opp_fired": 0, "opp_hits": 0, "opp_damage": 0.0})
|
||||||
|
|
||||||
|
for o in evs:
|
||||||
|
rnd = o.get("round", 0)
|
||||||
|
t = o.get("type")
|
||||||
|
if t == "fire":
|
||||||
|
if o.get("owner") == subj:
|
||||||
|
r["mb_fired"] += 1; slot(rnd)["mb_fired"] += 1
|
||||||
|
elif o.get("owner") == other:
|
||||||
|
r["opp_fired"] += 1; slot(rnd)["opp_fired"] += 1
|
||||||
|
elif t == "hit":
|
||||||
|
d = float(o.get("damage", 0.0))
|
||||||
|
if o.get("owner") == subj:
|
||||||
|
r["damage"] += d; r["mb_hits"] += 1
|
||||||
|
slot(rnd)["mb_damage"] += d; slot(rnd)["mb_hits"] += 1
|
||||||
|
if o.get("owner") == other:
|
||||||
|
r["opp_hits"] += 1; slot(rnd)["opp_damage"] += d
|
||||||
|
slot(rnd)["opp_hits"] += 1
|
||||||
|
if o.get("victim") == subj:
|
||||||
|
r["damage_taken"] += d
|
||||||
|
r["valid"] = True
|
||||||
|
r["round_starts"] = starts
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
def discover_runs(armdir):
|
||||||
|
if not os.path.isdir(armdir):
|
||||||
|
return []
|
||||||
|
return sorted(int(m.group(1)) for m in
|
||||||
|
(re.fullmatch(r"run(\d+)\.jsonl", f) for f in os.listdir(armdir))
|
||||||
|
if m)
|
||||||
|
|
||||||
|
|
||||||
|
def load_arm(opp_dir, arm, runs, env_req):
|
||||||
|
valid, invalid = [], []
|
||||||
|
for run in range(1, runs + 1):
|
||||||
|
r = parse_run(os.path.join(opp_dir, arm), run, env_req)
|
||||||
|
(valid if (r["valid"] and r["env_ok"]) else invalid).append(r)
|
||||||
|
return valid, invalid
|
||||||
|
|
||||||
|
|
||||||
|
# ── aggregation ──────────────────────────────────────────────────────────────
|
||||||
|
def sumruns(runs, key):
|
||||||
|
return sum(r[key] for r in runs)
|
||||||
|
|
||||||
|
|
||||||
|
def pooled(runs):
|
||||||
|
n = len(runs)
|
||||||
|
if n == 0:
|
||||||
|
return None
|
||||||
|
rounds = sumruns(runs, "rounds")
|
||||||
|
mb_fired = sumruns(runs, "mb_fired")
|
||||||
|
opp_fired = sumruns(runs, "opp_fired")
|
||||||
|
return {
|
||||||
|
"runs": n,
|
||||||
|
"damage": sumruns(runs, "damage") / n,
|
||||||
|
"damage_taken": sumruns(runs, "damage_taken") / n,
|
||||||
|
"wins": sumruns(runs, "wins") / n,
|
||||||
|
"win_rate": sumruns(runs, "wins") / rounds if rounds else float("nan"),
|
||||||
|
"rounds": rounds,
|
||||||
|
"hit_rate": 100.0 * sumruns(runs, "mb_hits") / mb_fired if mb_fired else float("nan"),
|
||||||
|
"incoming_hit_rate": 100.0 * sumruns(runs, "opp_hits") / opp_fired if opp_fired else float("nan"),
|
||||||
|
"mb_fired": mb_fired, "opp_fired": opp_fired,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def round_hit_rates(runs):
|
||||||
|
"""{round_index: (hits, fires)} pooled over runs."""
|
||||||
|
out = {}
|
||||||
|
for r in runs:
|
||||||
|
for rnd, d in r["per_round"].items():
|
||||||
|
h, f = out.get(rnd, (0, 0))
|
||||||
|
out[rnd] = (h + d["mb_hits"], f + d["mb_fired"])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ── main ─────────────────────────────────────────────────────────────────────
|
||||||
|
def main():
|
||||||
|
args = sys.argv[1:]
|
||||||
|
if not args:
|
||||||
|
print(__doc__)
|
||||||
|
return 2
|
||||||
|
session_dir = args[0].rstrip("/")
|
||||||
|
ref = "pattern"
|
||||||
|
if "--reference" in args:
|
||||||
|
ref = args[args.index("--reference") + 1]
|
||||||
|
report_path = None
|
||||||
|
if "--report" in args:
|
||||||
|
report_path = args[args.index("--report") + 1]
|
||||||
|
|
||||||
|
session = json.load(open(os.path.join(session_dir, "session.json")))
|
||||||
|
arms = [a["name"] for a in session["arms"]]
|
||||||
|
arm_env = {a["name"]: ga.parse_envspec(a.get("env", "")) for a in session["arms"]}
|
||||||
|
if ref not in arms:
|
||||||
|
ref = arms[0]
|
||||||
|
opps = [o["name"] for o in session["opponents"]]
|
||||||
|
style_of = {o["name"]: (o.get("style") or "other").strip() or "other"
|
||||||
|
for o in session["opponents"]}
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
def out(s=""):
|
||||||
|
lines.append(s)
|
||||||
|
print(s)
|
||||||
|
|
||||||
|
out("### MEASURED: session")
|
||||||
|
out()
|
||||||
|
out(f"* commit `{session['commit']}`, frozen binary sha256 "
|
||||||
|
f"`{session['binary_sha256'][:12]}…`")
|
||||||
|
out(f"* {len(opps)} opponents × {len(arms)} arms × {session['runs']} runs × "
|
||||||
|
f"{session['rounds']} rounds = {len(opps) * len(arms) * session['runs']} battles")
|
||||||
|
out(f"* arms file `{os.path.basename(session.get('arms_file', '?'))}`, "
|
||||||
|
f"panel file `{os.path.basename(session.get('panel_file', '?'))}`")
|
||||||
|
out(f"* reference arm: **`{ref}`** (deltas are arm − reference, opponent by opponent)")
|
||||||
|
out()
|
||||||
|
|
||||||
|
data = {}
|
||||||
|
invalid_total = 0
|
||||||
|
for o in opps:
|
||||||
|
data[o] = {}
|
||||||
|
for a in arms:
|
||||||
|
valid, invalid = load_arm(os.path.join(session_dir, o), a,
|
||||||
|
session["runs"], arm_env.get(a))
|
||||||
|
data[o][a] = valid
|
||||||
|
invalid_total += len(invalid)
|
||||||
|
out(f"* liveness: {invalid_total} run(s) excluded "
|
||||||
|
f"({len(opps) * len(arms) * session['runs']} total)")
|
||||||
|
out()
|
||||||
|
|
||||||
|
# ── spinner movement validation (MEASURED from the opponent trace) ──────
|
||||||
|
out("### MEASURED: the spinner fixture is a true constant-turn spinner")
|
||||||
|
out()
|
||||||
|
out("Per-tick body turn and speed of each spinner opponent, read from the")
|
||||||
|
out("capture's adversary trace (`s*` fields), pooled over its runs.")
|
||||||
|
out()
|
||||||
|
out("| opponent | ticks | turn mode (deg/tick) | mode share | turn SD | speed mode | speed mode share | wall-hug frac |")
|
||||||
|
out("|---|---:|---:|---:|---:|---:|---:|---:|")
|
||||||
|
spinner_opps = [o for o in opps if style_of[o] == "spinner"]
|
||||||
|
for o in spinner_opps:
|
||||||
|
adir = os.path.join(session_dir, o, ref)
|
||||||
|
turns, speeds, wall, n, prev = [], [], 0, 0, None
|
||||||
|
for run in discover_runs(adir):
|
||||||
|
try:
|
||||||
|
fh = open(os.path.join(adir, f"run{run}.jsonl"), errors="replace")
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
with fh:
|
||||||
|
for line in fh:
|
||||||
|
if '"sx"' not in line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
e = json.loads(line)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
if "sx" not in e:
|
||||||
|
continue
|
||||||
|
x, y, h, s = e["sx"], e["sy"], e["sh"], e.get("ss", 0.0)
|
||||||
|
n += 1
|
||||||
|
if min(x, 800 - x, y, 600 - y) < 50:
|
||||||
|
wall += 1
|
||||||
|
if prev is not None:
|
||||||
|
px, py, ph = prev
|
||||||
|
if math.hypot(x - px, y - py) < 30:
|
||||||
|
turns.append((h - ph + 180.0) % 360.0 - 180.0)
|
||||||
|
speeds.append(s)
|
||||||
|
prev = (x, y, h)
|
||||||
|
if not turns:
|
||||||
|
out(f"| {o} | 0 | n/a | n/a | n/a | n/a | n/a | n/a |")
|
||||||
|
continue
|
||||||
|
tc = {}
|
||||||
|
for t in turns:
|
||||||
|
tc[round(t, 1)] = tc.get(round(t, 1), 0) + 1
|
||||||
|
tmode, tcnt = max(tc.items(), key=lambda kv: kv[1])
|
||||||
|
sc = {}
|
||||||
|
for s in speeds:
|
||||||
|
sc[round(s, 1)] = sc.get(round(s, 1), 0) + 1
|
||||||
|
smode, scnt = max(sc.items(), key=lambda kv: kv[1])
|
||||||
|
sd = statistics.pstdev(turns)
|
||||||
|
out(f"| {o} | {n} | {tmode} | {100 * tcnt / len(turns):.1f}% | {sd:.3f} "
|
||||||
|
f"| {smode} | {100 * scnt / len(speeds):.1f}% | {100 * wall / n:.1f}% |")
|
||||||
|
out()
|
||||||
|
|
||||||
|
# ── per-opponent paired table ───────────────────────────────────────────
|
||||||
|
per_opp = {a: {} for a in arms}
|
||||||
|
for o in opps:
|
||||||
|
ref_runs = data[o][ref]
|
||||||
|
if not ref_runs:
|
||||||
|
for a in arms:
|
||||||
|
per_opp[a][o] = None
|
||||||
|
continue
|
||||||
|
rm = pooled(ref_runs)
|
||||||
|
for a in arms:
|
||||||
|
m = pooled(data[o][a])
|
||||||
|
if m is None:
|
||||||
|
per_opp[a][o] = None
|
||||||
|
continue
|
||||||
|
per_opp[a][o] = {
|
||||||
|
"m": m, "ref": rm,
|
||||||
|
"d_damage": m["damage"] - rm["damage"],
|
||||||
|
"d_wins": m["wins"] - rm["wins"],
|
||||||
|
"d_hit": m["hit_rate"] - rm["hit_rate"],
|
||||||
|
"d_taken": m["damage_taken"] - rm["damage_taken"],
|
||||||
|
}
|
||||||
|
|
||||||
|
out("### MEASURED: per-opponent paired table")
|
||||||
|
out()
|
||||||
|
for a in arms:
|
||||||
|
if a == ref:
|
||||||
|
continue
|
||||||
|
out(f"#### `{a}` vs `{ref}`")
|
||||||
|
out()
|
||||||
|
out("| opponent | style | dmg/run ref→arm | Δdmg | wins/run ref→arm | Δwins | our hit% ref→arm | Δhit (pp) | dmg taken Δ |")
|
||||||
|
out("|---|---|---:|---:|---:|---:|---:|---:|---:|")
|
||||||
|
for o in opps:
|
||||||
|
e = per_opp[a][o]
|
||||||
|
if e is None:
|
||||||
|
out(f"| {o} | {style_of[o]} | n/a | n/a | n/a | n/a | n/a | n/a | n/a |")
|
||||||
|
continue
|
||||||
|
m, rm = e["m"], e["ref"]
|
||||||
|
out(f"| {o} | {style_of[o]} | {rm['damage']:.1f}→{m['damage']:.1f} "
|
||||||
|
f"| {e['d_damage']:+.1f} | {rm['wins']:.2f}→{m['wins']:.2f} "
|
||||||
|
f"| {e['d_wins']:+.2f} | {rm['hit_rate']:.2f}→{m['hit_rate']:.2f} "
|
||||||
|
f"| {e['d_hit']:+.2f} | {e['d_taken']:+.1f} |")
|
||||||
|
out()
|
||||||
|
|
||||||
|
# ── pooled dashboard ────────────────────────────────────────────────────
|
||||||
|
out("### MEASURED: pooled dashboard (all valid runs; NOT the verdict)")
|
||||||
|
out()
|
||||||
|
out("| arm | runs | dmg/run | dmg taken/run | wins/run | round wins | win rate | our hit rate | incoming hit rate |")
|
||||||
|
out("|---|---:|---:|---:|---:|---:|---:|---:|---:|")
|
||||||
|
pooled_arm = {}
|
||||||
|
for a in arms:
|
||||||
|
runs = [r for o in opps for r in data[o][a]]
|
||||||
|
m = pooled(runs)
|
||||||
|
pooled_arm[a] = m
|
||||||
|
out(f"| `{a}` | {m['runs']} | {m['damage']:.1f} | {m['damage_taken']:.1f} "
|
||||||
|
f"| {m['wins']:.2f} | {int(sumruns(runs, 'wins'))}/{m['rounds']} "
|
||||||
|
f"| {100 * m['win_rate']:.1f}% | {m['hit_rate']:.2f}% "
|
||||||
|
f"| {m['incoming_hit_rate']:.2f}% |")
|
||||||
|
out()
|
||||||
|
|
||||||
|
# ── cross-opponent aggregation ──────────────────────────────────────────
|
||||||
|
out("### MEASURED: cross-opponent aggregation (the verdict layer)")
|
||||||
|
out()
|
||||||
|
out("| arm | metric | mean Δ | spread (SD) | 95% CI | sign test | p(sign) | p(sign-flip) | p(Wilcoxon) | MDE |")
|
||||||
|
out("|---|---|---:|---:|---|---:|---:|---:|---:|---:|")
|
||||||
|
stats = {}
|
||||||
|
for a in arms:
|
||||||
|
if a == ref:
|
||||||
|
continue
|
||||||
|
st = {}
|
||||||
|
for key, mkey in (("d_damage", "damage"), ("d_wins", "wins"),
|
||||||
|
("d_hit", "our hit rate (pp)")):
|
||||||
|
ds = [per_opp[a][o][key] for o in opps
|
||||||
|
if per_opp[a][o] and not math.isnan(per_opp[a][o][key])]
|
||||||
|
if len(ds) < 2:
|
||||||
|
continue
|
||||||
|
desc = ga.describe(ds)
|
||||||
|
pos, neg, ties, p_sign = ga.sign_test(ds)
|
||||||
|
sf = ga.signflip_perm(ds)
|
||||||
|
pw = wilcoxon_signed(ds)
|
||||||
|
st[key] = {"desc": desc, "pos": pos, "neg": neg, "ties": ties,
|
||||||
|
"p_sign": p_sign, "p_flip": sf["p"], "p_wilcox": pw}
|
||||||
|
out(f"| `{a}` | {mkey} | {desc['mean']:+.2f} | {desc['sd']:.2f} "
|
||||||
|
f"| [{desc['mean'] - 1.96 * desc['se']:+.2f}, "
|
||||||
|
f"{desc['mean'] + 1.96 * desc['se']:+.2f}] "
|
||||||
|
f"| {pos}/{pos + neg} | {p_sign:.4g} | {sf['p']:.4g} "
|
||||||
|
f"| {pw:.4g} | {desc['mde']:.2f} |")
|
||||||
|
stats[a] = st
|
||||||
|
out()
|
||||||
|
|
||||||
|
# ── style split ─────────────────────────────────────────────────────────
|
||||||
|
styles = sorted({style_of[o] for o in opps})
|
||||||
|
out("### MEASURED: by style (explanation only)")
|
||||||
|
out()
|
||||||
|
out("| arm | style | n | mean Δdmg | mean Δwins | mean Δour-hit (pp) |")
|
||||||
|
out("|---|---|---:|---:|---:|---:|")
|
||||||
|
for a in arms:
|
||||||
|
if a == ref:
|
||||||
|
continue
|
||||||
|
for s in styles:
|
||||||
|
es = [per_opp[a][o] for o in opps
|
||||||
|
if per_opp[a][o] and style_of[o] == s]
|
||||||
|
if not es:
|
||||||
|
continue
|
||||||
|
out(f"| `{a}` | {s} | {len(es)} "
|
||||||
|
f"| {statistics.mean([e['d_damage'] for e in es]):+.2f} "
|
||||||
|
f"| {statistics.mean([e['d_wins'] for e in es]):+.2f} "
|
||||||
|
f"| {statistics.mean([e['d_hit'] for e in es]):+.2f} |")
|
||||||
|
out()
|
||||||
|
|
||||||
|
# ── CONVERGENCE ─────────────────────────────────────────────────────────
|
||||||
|
out("### MEASURED: CONVERGENCE — our per-round hit rate")
|
||||||
|
out()
|
||||||
|
out("Pooled over every valid run (sum of hits / sum of shots, per round index).")
|
||||||
|
out()
|
||||||
|
maxr = session["rounds"]
|
||||||
|
out("| arm | " + " | ".join(f"R{i}" for i in range(1, maxr + 1)) + " | R1→Rlast (pp) |")
|
||||||
|
out("|---|" + "---:|" * (maxr + 1))
|
||||||
|
conv_all = {}
|
||||||
|
for a in arms:
|
||||||
|
runs = [r for o in opps for r in data[o][a]]
|
||||||
|
rhs = round_hit_rates(runs)
|
||||||
|
vals = []
|
||||||
|
for i in range(1, maxr + 1):
|
||||||
|
h, f = rhs.get(i, (0, 0))
|
||||||
|
vals.append(100.0 * h / f if f else float("nan"))
|
||||||
|
conv_all[a] = vals
|
||||||
|
delta = (vals[-1] - vals[0]) if not (math.isnan(vals[0]) or math.isnan(vals[-1])) else float("nan")
|
||||||
|
out(f"| `{a}` | " + " | ".join("n/a" if math.isnan(v) else f"{v:.1f}%"
|
||||||
|
for v in vals) + f" | {delta:+.1f} |")
|
||||||
|
out()
|
||||||
|
out("Same, restricted to the TRUE-SPINNER opponents "
|
||||||
|
f"({', '.join(spinner_opps)}):")
|
||||||
|
out()
|
||||||
|
out("| arm | " + " | ".join(f"R{i}" for i in range(1, maxr + 1)) + " | R1→Rlast (pp) |")
|
||||||
|
out("|---|" + "---:|" * (maxr + 1))
|
||||||
|
conv_spin = {}
|
||||||
|
for a in arms:
|
||||||
|
runs = [r for o in spinner_opps for r in data[o][a]]
|
||||||
|
rhs = round_hit_rates(runs)
|
||||||
|
vals = []
|
||||||
|
for i in range(1, maxr + 1):
|
||||||
|
h, f = rhs.get(i, (0, 0))
|
||||||
|
vals.append(100.0 * h / f if f else float("nan"))
|
||||||
|
conv_spin[a] = vals
|
||||||
|
delta = (vals[-1] - vals[0]) if not (math.isnan(vals[0]) or math.isnan(vals[-1])) else float("nan")
|
||||||
|
out(f"| `{a}` | " + " | ".join("n/a" if math.isnan(v) else f"{v:.1f}%"
|
||||||
|
for v in vals) + f" | {delta:+.1f} |")
|
||||||
|
out()
|
||||||
|
|
||||||
|
# within-round early vs late (fast adaptation), spinner opponents
|
||||||
|
out("Within-round adaptation on the true spinners: our hit rate over the")
|
||||||
|
out("first vs second half of each round (round-relative tick from the")
|
||||||
|
out("rounds sidecar), pooled over runs.")
|
||||||
|
out()
|
||||||
|
out("| arm | first-half hit% | second-half hit% | late−early (pp) |")
|
||||||
|
out("|---|---:|---:|---:|")
|
||||||
|
for a in arms:
|
||||||
|
early = [0, 0]
|
||||||
|
late = [0, 0]
|
||||||
|
for o in spinner_opps:
|
||||||
|
adir = os.path.join(session_dir, o, a)
|
||||||
|
for run in discover_runs(adir):
|
||||||
|
evs = ga.parse_events(os.path.join(adir, f"run{run}.events.jsonl"))
|
||||||
|
rpath = os.path.join(adir, f"run{run}.jsonl.rounds.json")
|
||||||
|
starts = read_round_starts(rpath)
|
||||||
|
log_text = "".join(ga.read_lines(os.path.join(adir, f"run{run}.battle.log")))
|
||||||
|
counters = ga.parse_counters(log_text)
|
||||||
|
if counters is None or not starts:
|
||||||
|
continue
|
||||||
|
subj, other = ga.attribute_subject(evs, counters)
|
||||||
|
if subj is None:
|
||||||
|
continue
|
||||||
|
counts = {rr["round"]: rr["count"]
|
||||||
|
for rr in json.load(open(rpath))["rounds"]}
|
||||||
|
for e in evs:
|
||||||
|
rnd = e.get("round", 0)
|
||||||
|
st = starts.get(rnd)
|
||||||
|
if st is None:
|
||||||
|
continue
|
||||||
|
rel = e.get("tick", 0) - st
|
||||||
|
bucket = early if rel < counts.get(rnd, 0) / 2 else late
|
||||||
|
if e.get("type") == "fire" and e.get("owner") == subj:
|
||||||
|
bucket[1] += 1
|
||||||
|
elif e.get("type") == "hit" and e.get("owner") == subj:
|
||||||
|
bucket[0] += 1
|
||||||
|
if early[1] and late[1]:
|
||||||
|
eh = 100.0 * early[0] / early[1]
|
||||||
|
lh = 100.0 * late[0] / late[1]
|
||||||
|
out(f"| `{a}` | {eh:.2f}% | {lh:.2f}% | {lh - eh:+.2f} |")
|
||||||
|
else:
|
||||||
|
out(f"| `{a}` | n/a | n/a | n/a |")
|
||||||
|
out()
|
||||||
|
|
||||||
|
# ── verdict ─────────────────────────────────────────────────────────────
|
||||||
|
out("### The pre-registered reading")
|
||||||
|
out()
|
||||||
|
out("PRIMARY = dmg/run and round wins. Our per-round hit rate is the")
|
||||||
|
out("SECONDARY mechanism metric. An arm is BETTER only if a primary improves")
|
||||||
|
out("with sign-test p<0.05 while the other primary does not go down.")
|
||||||
|
out()
|
||||||
|
out("| arm | Δwins/run | Δdmg/run | Δour-hit (pp) | sign test dmg | verdict |")
|
||||||
|
out("|---|---:|---:|---:|---|---|")
|
||||||
|
for a in arms:
|
||||||
|
if a == ref:
|
||||||
|
continue
|
||||||
|
st = stats.get(a, {})
|
||||||
|
d = st.get("d_damage")
|
||||||
|
w = st.get("d_wins")
|
||||||
|
h = st.get("d_hit")
|
||||||
|
if not d or not w:
|
||||||
|
out(f"| `{a}` | n/a | n/a | n/a | n/a | n/a |")
|
||||||
|
continue
|
||||||
|
up_d = d["desc"]["mean"] > 0 and d["p_sign"] < 0.05
|
||||||
|
up_w = w["desc"]["mean"] > 0 and w["p_sign"] < 0.05
|
||||||
|
if (up_d and w["desc"]["mean"] >= 0) or (up_w and d["desc"]["mean"] >= 0):
|
||||||
|
verdict = "**BETTER**"
|
||||||
|
elif d["desc"]["mean"] < -d["desc"]["mde"] or w["desc"]["mean"] < -w["desc"]["mde"]:
|
||||||
|
verdict = "WORSE"
|
||||||
|
else:
|
||||||
|
verdict = "not distinguishable"
|
||||||
|
out(f"| `{a}` | {w['desc']['mean']:+.2f} | {d['desc']['mean']:+.2f} "
|
||||||
|
f"| {h['desc']['mean']:+.2f} | {d['pos']}/{d['pos'] + d['neg']} "
|
||||||
|
f"p={d['p_sign']:.4g} | {verdict} |")
|
||||||
|
out()
|
||||||
|
|
||||||
|
if report_path:
|
||||||
|
with open(report_path, "w") as fh:
|
||||||
|
fh.write("\n".join(lines) + "\n")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def wilcoxon_signed(deltas):
|
||||||
|
"""Two-sided Wilcoxon signed-rank normal approximation with tie correction."""
|
||||||
|
nz = [d for d in deltas if d != 0.0]
|
||||||
|
n = len(nz)
|
||||||
|
if n < 3:
|
||||||
|
return 1.0
|
||||||
|
order = sorted(range(n), key=lambda i: abs(nz[i]))
|
||||||
|
ranks = [0.0] * n
|
||||||
|
i = 0
|
||||||
|
while i < n:
|
||||||
|
j = i
|
||||||
|
while j + 1 < n and abs(nz[order[j + 1]]) == abs(nz[order[i]]):
|
||||||
|
j += 1
|
||||||
|
avg = (i + j) / 2.0 + 1.0
|
||||||
|
for k in range(i, j + 1):
|
||||||
|
ranks[order[k]] = avg
|
||||||
|
i = j + 1
|
||||||
|
w_plus = sum(ranks[i] for i in range(n) if nz[i] > 0)
|
||||||
|
mu = n * (n + 1) / 4.0
|
||||||
|
cnt = {}
|
||||||
|
for d in nz:
|
||||||
|
cnt[abs(d)] = cnt.get(abs(d), 0) + 1
|
||||||
|
tie = sum(c ** 3 - c for c in cnt.values())
|
||||||
|
sigma2 = n * (n + 1) * (2 * n + 1) / 24.0 - tie / 48.0
|
||||||
|
if sigma2 <= 0:
|
||||||
|
return 1.0
|
||||||
|
z = (w_plus - mu - 0.5 * (1 if w_plus > mu else -1)) / math.sqrt(sigma2)
|
||||||
|
return min(1.0, 2.0 * 0.5 * math.erfc(abs(z) / math.sqrt(2.0)))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Reference in New Issue
Block a user