diff --git a/.clue/id-ledger.yaml b/.clue/id-ledger.yaml index c6d6c0aa..7c7ba7a2 100644 --- a/.clue/id-ledger.yaml +++ b/.clue/id-ledger.yaml @@ -228,6 +228,24 @@ events: - {id: M-003, kind: numeric, state: retired, prefix: M, component: "3"} - {id: M-004, kind: numeric, state: retired, prefix: M, component: "4"} - {id: M-130, kind: numeric, state: retired, prefix: M, component: "130"} + - {id: CH-018, kind: numeric, state: reserved, prefix: CH, component: "18"} + - {id: HARN-002, kind: numeric, state: reserved, prefix: HARN, component: "2"} + - {id: HARN-003, kind: numeric, state: reserved, prefix: HARN, component: "3"} + - {id: HARN-004, kind: numeric, state: reserved, prefix: HARN, component: "4"} + - {id: HARN-005, kind: numeric, state: reserved, prefix: HARN, component: "5"} + - {id: HARN-006, kind: numeric, state: reserved, prefix: HARN, component: "6"} + - {id: HARN-007, kind: numeric, state: reserved, prefix: HARN, component: "7"} + - {id: HARN-008, kind: numeric, state: reserved, prefix: HARN, component: "8"} + - {id: M-148, kind: numeric, state: reserved, prefix: M, component: "148"} + - {id: AN-016, kind: numeric, state: reserved, prefix: AN, component: "16"} + - {id: AN-017, kind: numeric, state: reserved, prefix: AN, component: "17"} + - {id: TASKS-011, kind: numeric, state: reserved, prefix: TASKS, component: "11"} + - {id: OQ-009, kind: numeric, state: reserved, prefix: OQ, component: "9"} + - {id: CH-018, kind: numeric, state: live, prefix: CH, component: "18"} + - {id: HARN-008, kind: numeric, state: live, prefix: HARN, component: "8"} + - {id: M-148, kind: numeric, state: live, prefix: M, component: "148"} + - {id: AN-016, kind: numeric, state: live, prefix: AN, component: "16"} + - {id: TASKS-011, kind: numeric, state: live, prefix: TASKS, component: "11"} high-water: - {id: ADR-002, kind: numeric, state: reserved, prefix: ADR, component: "2"} - {id: AN-014, kind: numeric, state: reserved, prefix: AN, component: "14"} diff --git a/compat-test/README.md b/compat-test/README.md index 507c2101..66b2b6bd 100644 --- a/compat-test/README.md +++ b/compat-test/README.md @@ -204,6 +204,18 @@ Full error details land in `errors/robocode/.log` and `parity-registry.json` is the tracked evidence carrier. It appends each subject observation with the exact jar identity, setup, engine artifacts, normalized errors, and focused retest link. Normalized error origins skip engine implementation frames from both classic and the bridge so the first legacy application frame remains comparable across engines. It also retains an append-only diagnosis history, so a later triage decision cannot rewrite an earlier one. `parity-registry.md` renders the current status of every subject for review. Import an existing checkpoint with `--sync-registry`; after a diagnosis, tag a case with `--set-cause ` and rerun that cause with `--retest-cause --repair `. +### Skipped-turn telemetry + +Pass `--capture-skipped-turns` to enable opt-in records for Tank Royale bridge callbacks; capture is off by default. Each registry observation stores `tank_royale.skipped_turn_telemetry` with a status and, only when capture is complete, an `events` list of unique `{bot_id, round, turn}` values sorted by round and turn, including warm-up. `disabled`, `unavailable`, and `incomplete` use `events: null`; `captured` with `events: []` means the completed run delivered no skipped-turn callbacks to the bridge. Per-bot readiness and completion-count markers let the harness detect an older bridge jar or a truncated capture. The data describes callbacks delivered to the bridge and does not claim to record server detections that never reach it. + +Use the conformance probe to check a forced-skip run locally: + +```bash +python compat_test.py --conformance conformance-robots --conformance-source conformance-robots/conformance/probes/SkippedTurnProbe.java --robot-class conformance.probes.SkippedTurnProbe --engine tr --rounds 1 --capture-skipped-turns +``` + +The conformance JSON includes `skipped_turn_telemetry` at the top level. On a parity sweep, use `--capture-skipped-turns` and inspect the per-observation field in `parity-registry.json`. Repeated `--confirm-score` runs keep one record per attempt under `confirmation.skipped_turn_telemetry_runs`; the regression gate prints the same per-attempt JSON to stdout. + **Every row states the setup it was measured at.** The report is regenerated from the state file long after the battles ran, so a single header describing the current configuration would restate every stored row as though it had been measured under today's settings -- diff --git a/compat-test/compat_test.py b/compat-test/compat_test.py index b7c81aba..470981b4 100644 --- a/compat-test/compat_test.py +++ b/compat-test/compat_test.py @@ -58,6 +58,12 @@ BASE_DIR = Path(__file__).resolve().parent TANK_ROYALE_HOME = Path(os.environ.get("COMPAT_TANK_ROYALE_HOME", r"C:\Code\tank-royale")) +SKIPPED_TURN_TELEMETRY_PROPERTY = "robocode.bridge.skippedTurnTelemetry" +SKIPPED_TURN_READY_RE = re.compile(r"^BRIDGE_SKIPPED_TURN_TELEMETRY_READY botId=(\d+)\s*$") +SKIPPED_TURN_EVENT_RE = re.compile( + r"^BRIDGE_SKIPPED_TURN botId=(\d+) round=(\d+) turn=(\d+)\s*$") +SKIPPED_TURN_COMPLETE_RE = re.compile( + r"^BRIDGE_SKIPPED_TURN_TELEMETRY_COMPLETE botId=(\d+) eventCount=(\d+)\s*$") def local_bot_api_jar(): @@ -404,7 +410,7 @@ def kill_process_tree(proc: subprocess.Popen, diagnostics=None): return proc.poll() is not None -def run_java(cmd, cwd, timeout, abort_when=None, poll_seconds=2.0): +def run_java(cmd, cwd, timeout, abort_when=None, poll_seconds=2.0, env=None): """Runs a java command; returns (returncode, stdout+stderr, timed_out). `abort_when` is an optional callable polled while the battle runs. When it returns @@ -416,7 +422,7 @@ def run_java(cmd, cwd, timeout, abort_when=None, poll_seconds=2.0): harness rather than the battle.""" proc = subprocess.Popen( cmd, cwd=str(cwd), stdout=subprocess.PIPE, stderr=subprocess.STDOUT, - text=True, encoding="utf-8", errors="replace") + text=True, encoding="utf-8", errors="replace", env=env) chunks = [] reader = threading.Thread(target=_drain, args=(proc.stdout, chunks), daemon=True) @@ -741,7 +747,7 @@ def run_rc_battle(jar_path: Path, classname, version, opts, setup, def summarize_worker_result(out_file, returncode, output, timed_out, elapsed, engine): result = { - "ok": False, "score": None, "scores": [], "errors": [], + "ok": False, "completed": False, "score": None, "scores": [], "errors": [], "error_count": 0, "elapsed": round(elapsed, 1), "log_text": "", } cleanup_failures = [] @@ -773,6 +779,7 @@ def summarize_worker_result(out_file, returncode, output, timed_out, elapsed, en return result result["ok"] = bool(data.get("ok")) + result["completed"] = bool(data.get("completed")) result["selected"] = data.get("selected") scores = [p.get("score", 0) for p in data.get("participants", [])] result["scores"] = [round(float(s), 1) for s in scores] @@ -922,6 +929,83 @@ def staged_log_dirs(bot_dirs): return list(dict.fromkeys(expanded)) +def skipped_turn_telemetry(bot_dirs, enabled, completed, expected_bots): + """Reads the opt-in bridge markers without treating a missing marker as zero skips.""" + if not enabled: + return {"status": "disabled", "events": None} + if not completed: + return {"status": "incomplete", "events": None} + + ready_bot_ids = set() + completed_event_counts = {} + events = set() + directories = staged_log_dirs(bot_dirs) + if not directories: + return {"status": "incomplete", "events": None} + + for directory in directories: + try: + with open(directory / "stdout.log", encoding="utf-8", errors="replace") as log: + for line in log: + line = line.rstrip("\r\n") + ready_match = SKIPPED_TURN_READY_RE.fullmatch(line) + if ready_match: + ready_bot_ids.add(int(ready_match.group(1))) + continue + complete_match = SKIPPED_TURN_COMPLETE_RE.fullmatch(line) + if complete_match: + bot_id, event_count = (int(value) for value in complete_match.groups()) + if bot_id in completed_event_counts: + return {"status": "incomplete", "events": None} + completed_event_counts[bot_id] = event_count + continue + event_match = SKIPPED_TURN_EVENT_RE.fullmatch(line) + if event_match: + events.add(tuple(int(value) for value in event_match.groups())) + except OSError: + return {"status": "incomplete", "events": None} + + if expected_bots < 1 or len(ready_bot_ids) != expected_bots: + return {"status": "unavailable", "events": None} + + if any(bot_id not in ready_bot_ids for bot_id, _, _ in events): + return {"status": "unavailable", "events": None} + + if set(completed_event_counts) != ready_bot_ids: + return {"status": "incomplete", "events": None} + + events_by_bot = {} + for bot_id, _, _ in events: + events_by_bot[bot_id] = events_by_bot.get(bot_id, 0) + 1 + if any(completed_event_counts[bot_id] != events_by_bot.get(bot_id, 0) + for bot_id in ready_bot_ids): + return {"status": "incomplete", "events": None} + + ordered_events = [ + {"bot_id": bot_id, "round": round_number, "turn": turn_number} + for bot_id, round_number, turn_number in sorted( + events, key=lambda event: (event[1], event[2], event[0])) + ] + return {"status": "captured", "events": ordered_events} + + +def java_env_for_skipped_turn_telemetry(enabled): + """Controls the telemetry property in the runner JVM and any bot JVMs it starts.""" + env = os.environ.copy() + options = env.get("JAVA_TOOL_OPTIONS", "") + property_option = re.compile( + rf"(? diff --git a/docs/capabilities/CAP-007-compatibility-harness/criteria.md b/docs/capabilities/CAP-007-compatibility-harness/criteria.md index 7eb8c668..28762387 100644 --- a/docs/capabilities/CAP-007-compatibility-harness/criteria.md +++ b/docs/capabilities/CAP-007-compatibility-harness/criteria.md @@ -76,4 +76,15 @@ Feature: The compatibility harness When its battle setup is resolved Then the battlefield, round count, and participant count are that division's official values # The harness currently uses one setup for every division. Plan door: M-001. + + @HARN-008 @draft + Scenario: Skipped-turn telemetry preserves events and distinguishes missing capture from zero + Test-type: Integration + Given a completed Tank Royale compatibility run with telemetry enabled + When the bridge receives skipped-turn events + Then the observation records one event per distinct bot ID, round, and skipped turn number, including warm-up turns + And a completed enabled run with no events is captured with an empty event list + And disabled, unavailable, or incomplete capture has a distinct status and does not claim zero events + # Callback-level telemetry is opt-in and does not claim to see server events that never reach the bridge. Plan door: M-006. + # The harness is Python and is not yet a supported evidence carrier. ``` diff --git a/docs/capabilities/CAP-007-compatibility-harness/design.md b/docs/capabilities/CAP-007-compatibility-harness/design.md index 55322a41..5326ddbc 100644 --- a/docs/capabilities/CAP-007-compatibility-harness/design.md +++ b/docs/capabilities/CAP-007-compatibility-harness/design.md @@ -10,7 +10,7 @@ reversal-cost: low # CAP-007 — design -`status: draft`: the orchestration and isolation described here exist; the division setups, averaging, fail-fast, and tracing do not. +`status: draft`: the orchestration, isolation, and opt-in skipped-turn capture described here exist; the division setups, averaging, fail-fast, and tracing do not. ## Shape @@ -34,6 +34,8 @@ Compare: scores as the sum of all staged participants' totals, errors as normali The harness imports an interrupted checkpoint with `--sync-registry`, runs new first-pass work in bounded `--limit` batches, and selects retests with `--retry-unresolved` or `--retest-cause`. Matched subjects stay recorded and are not selected after an unrelated repair. +Skipped-turn capture is opt-in with `--capture-skipped-turns`. For a completed Tank Royale run, `tank_royale.skipped_turn_telemetry` in each registry observation has `status: captured` and an `events` list containing distinct `{bot_id, round, turn}` records in round/turn order; warm-up turns are included. `disabled`, `unavailable`, and `incomplete` statuses carry `events: null`, so only a completed capture can report an empty list. The bridge buffers each bot's readiness and event markers until `GameEnded`, then writes a completion marker with its distinct-event count; the harness checks both markers against the registry events before reporting capture as complete. These records cover `SkippedTurnEvent` callbacks delivered to bridge bots and do not claim to observe server detections that never reach a bot. + ## What changes for the milestone **Division setups.** One named constant per division carrying the official battlefield, rounds, and participant count, and one constant for the collection root — `C-003` and `C-007`. The single hard-coded setup goes away. diff --git a/docs/plans/P-001-bridge-parity-campaign.md b/docs/plans/P-001-bridge-parity-campaign.md index 2c9024fb..839e9188 100644 --- a/docs/plans/P-001-bridge-parity-campaign.md +++ b/docs/plans/P-001-bridge-parity-campaign.md @@ -83,6 +83,7 @@ They are bookkeeping rather than a second plan. A door closes when its criterion | M-141 | `EVT-013` | `EVT-013` is active, with evidence attributable to it. Successor to `EVT-003` (`M-103`). Work lands under M-001. | done | | M-142 | `EVT-014` | `EVT-014` is active, with evidence attributable to it. Successor to `EVT-007` (`M-107`). Work lands under M-001. | done | | M-143 | `EVT-015` | `EVT-015` is active, with evidence attributable to it. Successor to `EVT-001` (`M-101`). Work lands under M-001. | done | +| M-148 | `HARN-008` | `HARN-008` is active, with evidence attributable to it. Work lands under M-006. | todo | ## Why this order diff --git a/robocode-api/src/main/java/dev/robocode/tankroyale/bridge/BotPeer.java b/robocode-api/src/main/java/dev/robocode/tankroyale/bridge/BotPeer.java index 093933ee..0dd0cca6 100644 --- a/robocode-api/src/main/java/dev/robocode/tankroyale/bridge/BotPeer.java +++ b/robocode-api/src/main/java/dev/robocode/tankroyale/bridge/BotPeer.java @@ -35,6 +35,11 @@ public final class BotPeer implements ITeamRobotPeer, IJuniorRobotPeer { + private static final String SKIPPED_TURN_TELEMETRY_PROPERTY = + "robocode.bridge.skippedTurnTelemetry"; + private static final boolean SKIPPED_TURN_TELEMETRY_ENABLED = + Boolean.getBoolean(SKIPPED_TURN_TELEMETRY_PROPERTY); + private volatile IBasicRobot robot; private volatile IBasicEvents basicEvents; private volatile IAdvancedEvents advancedEvents; @@ -54,6 +59,10 @@ public final class BotPeer implements ITeamRobotPeer, IJuniorRobotPeer { private boolean hitWallHandlerActive; private boolean hitWallHandlerBlocked; private boolean suppressScansForBlockedWallHandler; + private final List skippedTurnTelemetryRecords = SKIPPED_TURN_TELEMETRY_ENABLED + ? new ArrayList<>() : Collections.emptyList(); + private final Set skippedTurnTelemetryEventRecords = SKIPPED_TURN_TELEMETRY_ENABLED + ? new HashSet<>() : Collections.emptySet(); @SuppressWarnings("unused") public BotPeer(IBasicRobot robot, BotInfo botInfo) { @@ -445,10 +454,30 @@ private void dispatchRobotDeathEvent(BotDeathEvent botDeathEvent) { private void dispatchSkippedTurnEvent(BotEvent botEvent) { log("-> onSkippedTurn"); var skippedTurnEvent = (SkippedTurnEvent) botEvent; + if (SKIPPED_TURN_TELEMETRY_ENABLED) { + String record = "BRIDGE_SKIPPED_TURN botId=" + bot.getMyId() + + " round=" + bot.getRoundNumber() + + " turn=" + skippedTurnEvent.getTurnNumber(); + if (skippedTurnTelemetryEventRecords.add(record)) { + skippedTurnTelemetryRecords.add(record); + } + } var robocodeEvent = new robocode.SkippedTurnEvent(skippedTurnEvent.getTurnNumber()); dispatchRobotCallback(() -> advancedEvents.onSkippedTurn(robocodeEvent)); } + private void flushSkippedTurnTelemetry() { + if (SKIPPED_TURN_TELEMETRY_ENABLED) { + for (String record : skippedTurnTelemetryRecords) { + System.out.println(record); + } + System.out.println("BRIDGE_SKIPPED_TURN_TELEMETRY_COMPLETE botId=" + bot.getMyId() + + " eventCount=" + skippedTurnTelemetryEventRecords.size()); + skippedTurnTelemetryRecords.clear(); + skippedTurnTelemetryEventRecords.clear(); + } + } + private void dispatchDeathEvent() { log("-> onDeath"); var robocodeEvent = new robocode.DeathEvent(); @@ -1252,6 +1281,10 @@ public void onTeamMessage(TeamMessageEvent teamMessageEvent) { @Override public void onGameStarted(GameStartedEvent gameStatedEvent) { totalTurns.set(0); + if (SKIPPED_TURN_TELEMETRY_ENABLED) { + skippedTurnTelemetryRecords.add( + "BRIDGE_SKIPPED_TURN_TELEMETRY_READY botId=" + bot.getMyId()); + } // Legacy robots may read battlefield dimensions from setPeer(). The Bot API fills // GameSetup immediately before publishing this callback, so attach the peer here // instead of before the connection has received game setup. @@ -1260,6 +1293,7 @@ public void onGameStarted(GameStartedEvent gameStatedEvent) { @Override public void onGameEnded(GameEndedEvent gameEndedEvent) { + flushSkippedTurnTelemetry(); log("-> onBattleEnded"); if (basicEvents instanceof IBasicEvents2) { ((IBasicEvents2) basicEvents).onBattleEnded(new robocode.BattleEndedEvent(