diff --git a/.clue/id-ledger.yaml b/.clue/id-ledger.yaml index e0368a6..07cf1f1 100644 --- a/.clue/id-ledger.yaml +++ b/.clue/id-ledger.yaml @@ -5,18 +5,18 @@ counters: ARCH: "3" C: "7" CAP: "8" - CH: "5" + CH: "6" CRIT: "8" DES: "8" - EVT: "14" + EVT: "15" G: "2" - IDR: "4" - OQ: "2" + IDR: "5" + OQ: "3" P: "1" PDR: "2" REH: "1" ROUTE: "12" - TASKS: "4" + TASKS: "5" entries: - id: ADR-001 kind: numeric @@ -218,6 +218,11 @@ entries: state: live prefix: CH component: "5" + - id: CH-006 + kind: numeric + state: live + prefix: CH + component: "6" - id: CRIT-001 kind: numeric state: live @@ -300,7 +305,7 @@ entries: component: "8" - id: EVT-001 kind: numeric - state: live + state: retired prefix: EVT component: "1" - id: EVT-002 @@ -368,6 +373,11 @@ entries: state: live prefix: EVT component: "14" + - id: EVT-015 + kind: numeric + state: live + prefix: EVT + component: "15" - id: G-001 kind: numeric state: live @@ -398,6 +408,11 @@ entries: state: live prefix: IDR component: "4" + - id: IDR-005 + kind: numeric + state: live + prefix: IDR + component: "5" - id: OQ-001 kind: numeric state: live @@ -408,6 +423,11 @@ entries: state: live prefix: OQ component: "2" + - id: OQ-003 + kind: numeric + state: live + prefix: OQ + component: "3" - id: P-001 kind: numeric state: live @@ -508,3 +528,8 @@ entries: state: live prefix: TASKS component: "4" + - id: TASKS-005 + kind: numeric + state: live + prefix: TASKS + component: "5" diff --git a/compat-test/RcBattleWorker.java b/compat-test/RcBattleWorker.java index 0b63444..53ed077 100644 --- a/compat-test/RcBattleWorker.java +++ b/compat-test/RcBattleWorker.java @@ -2,6 +2,7 @@ import robocode.control.BattlefieldSpecification; import robocode.control.BattleSpecification; import robocode.control.RobocodeEngine; +import robocode.control.RandomFactory; import robocode.control.RobotSpecification; import robocode.control.events.BattleAdaptor; import robocode.control.events.BattleCompletedEvent; @@ -51,6 +52,7 @@ public static void main(String[] args) throws Exception { int height = Integer.parseInt(opt.getOrDefault("height", "600")); int participants = Integer.parseInt(opt.getOrDefault("participants", "2")); String select = opt.get("select"); + String enemySelect = opt.get("enemy-select"); Path outFile = Path.of(require(opt, "out")); int timeoutSecs = Integer.parseInt(opt.getOrDefault("timeout", "0")); @@ -61,7 +63,11 @@ public static void main(String[] args) throws Exception { Map result = new HashMap<>(); int exitCode; try { - runBattle(home, rounds, width, height, participants, select, result); + if (Boolean.parseBoolean(opt.getOrDefault("deterministic", "false"))) { + // Match RobocodeTestBed's deterministic setup used by the authoritative source test. + RandomFactory.resetDeterministic(0); + } + runBattle(home, rounds, width, height, participants, select, enemySelect, result); exitCode = Boolean.TRUE.equals(result.get("ok")) ? 0 : 1; } catch (Throwable t) { result.put("ok", false); @@ -74,14 +80,15 @@ public static void main(String[] args) throws Exception { } private static void runBattle(File home, int rounds, int width, int height, int participantCount, - String select, Map result) { + String select, String enemySelect, Map result) { Collector collector = new Collector(); RobocodeEngine engine = new RobocodeEngine(home); try { engine.addBattleListener(collector); engine.setVisible(false); - RobotSpecification[] participants = selectParticipants(engine, select, participantCount); + RobotSpecification[] participants = selectParticipants( + engine, select, participantCount, enemySelect); if (participants == null || participants.length == 0) { result.put("ok", false); result.put("fatal", "No robot found in repository matching: " + select); @@ -148,7 +155,16 @@ private static void runBattle(File home, int rounds, int width, int height, int * * The count is the division's official participant count: two for 1-vs-1, ten for melee. */ - private static RobotSpecification[] selectParticipants(RobocodeEngine engine, String select, int count) { + private static RobotSpecification[] selectParticipants(RobocodeEngine engine, String select, int count, + String enemySelect) { + if (enemySelect != null && !enemySelect.isBlank()) { + RobotSpecification chosen = selectOne(engine, select); + RobotSpecification enemy = selectOne(engine, enemySelect); + if (chosen == null || enemy == null) { + return null; + } + return new RobotSpecification[]{chosen, enemy}; + } int wanted = Math.max(2, count); if (select != null && !select.isBlank()) { String selection = String.join(", ", java.util.Collections.nCopies(wanted, select)); @@ -176,6 +192,29 @@ private static RobotSpecification[] selectParticipants(RobocodeEngine engine, St return participants; } + private static RobotSpecification selectOne(RobocodeEngine engine, String select) { + if (select != null && !select.isBlank()) { + RobotSpecification[] specs = engine.getLocalRepository(select); + if (specs != null && specs.length > 0) { + return specs[0]; + } + } + RobotSpecification[] all = engine.getLocalRepository(); + if (all == null || all.length == 0) { + return null; + } + if (select != null && !select.isBlank()) { + String className = select.split(" ")[0]; + for (RobotSpecification spec : all) { + if (className.equals(spec.getClassName())) { + return spec; + } + } + return null; + } + return all[0]; + } + /** Collects results, errors and per-robot console output from battle events. */ private static class Collector extends BattleAdaptor { final List battleErrors = new ArrayList<>(); diff --git a/compat-test/compat_test.py b/compat-test/compat_test.py index aba6acc..b77652f 100644 --- a/compat-test/compat_test.py +++ b/compat-test/compat_test.py @@ -379,7 +379,8 @@ def save_state(state): # Classic Robocode side # ---------------------------------------------------------------------------------- -def run_rc_battle(jar_path: Path, classname, version, opts, setup): +def run_rc_battle(jar_path: Path, classname, version, opts, setup, + enemy_jar_path=None, enemy_class=None): """Runs one classic Robocode battle for the jar at the division setup; returns a summary.""" home_dir = WORK_DIR / "rc-home" (home_dir / "config").mkdir(parents=True, exist_ok=True) @@ -394,6 +395,8 @@ def run_rc_battle(jar_path: Path, classname, version, opts, setup): robots_dir = WORK_DIR / "rc-robots" clean_dir(robots_dir) shutil.copyfile(jar_path, robots_dir / jar_path.name) + if enemy_jar_path is not None: + shutil.copyfile(enemy_jar_path, robots_dir / Path(enemy_jar_path).name) out_file = WORK_DIR / "rc-result.json" out_file.unlink(missing_ok=True) @@ -420,6 +423,8 @@ def run_rc_battle(jar_path: Path, classname, version, opts, setup): "--out", str(out_file), "--timeout", str(max(30, opts.timeout - 15)), ] + if enemy_class: + cmd.extend(["--enemy-select", enemy_class, "--deterministic", "true"]) started = time.time() returncode, output, timed_out = run_java(cmd, cwd=home_dir, timeout=opts.timeout) elapsed = time.time() - started @@ -485,9 +490,16 @@ def summarize_worker_result(out_file, returncode, output, timed_out, elapsed, en # Tank Royale side # ---------------------------------------------------------------------------------- -def ensure_tr_lib(opts): - lib_dir = WORK_DIR / "tr-bots" / "lib" +def ensure_tr_lib(opts, staging): + lib_dir = staging / "lib" lib_dir.mkdir(parents=True, exist_ok=True) + # A previous run may have staged a different Bot API version. The generated boot scripts + # use a wildcard classpath, so retaining both versions silently selects the wrong one. + for entry in list(lib_dir.iterdir()): + if entry.is_dir(): + shutil.rmtree(entry, ignore_errors=True) + else: + entry.unlink(missing_ok=True) for jar in (opts.bridge_api_jar, opts.bot_api_jar, opts.wrapper_jar): src = Path(jar) dst = lib_dir / src.name @@ -536,11 +548,11 @@ def stage_bot_dirs(bot_dir: Path, participants): return dirs -def wrap_jar_for_tr(jar_path: Path, classname, version, opts): +def wrap_jar_for_tr(jar_path: Path, classname, version, opts, staging_name="tr-bots"): """Stages the jar, runs the robots-wrapper, returns (bot_dir, error_message).""" - staging = WORK_DIR / "tr-bots" + staging = WORK_DIR / staging_name staging.mkdir(parents=True, exist_ok=True) - ensure_tr_lib(opts) + ensure_tr_lib(opts, staging) # Remove artifacts from the previous robot (keep lib/) for entry in staging.iterdir(): @@ -572,7 +584,8 @@ def wrap_jar_for_tr(jar_path: Path, classname, version, opts): return chosen, None -def run_tr_battle(jar_path: Path, classname, version, opts, setup, rc_signatures=None): +def run_tr_battle(jar_path: Path, classname, version, opts, setup, rc_signatures=None, + enemy_jar_path=None, enemy_class=None): """Wraps the jar and runs one Tank Royale battle at the division setup. `rc_signatures` is the set of exception signatures the classic side produced for this @@ -587,7 +600,18 @@ def run_tr_battle(jar_path: Path, classname, version, opts, setup, rc_signatures "error_count": 1, "log_text": "=== Wrapping failed ===\n" + wrap_error, } - bot_dirs = stage_bot_dirs(bot_dir, setup["participants"]) + bot_dirs = [bot_dir] if enemy_jar_path is not None else stage_bot_dirs( + bot_dir, setup["participants"]) + if enemy_jar_path is not None: + enemy_dir, enemy_error = wrap_jar_for_tr( + enemy_jar_path, enemy_class, None, opts, staging_name="tr-bots-enemy") + if enemy_error: + return { + "ok": False, "score": None, "scores": [], "elapsed": 0.0, + "errors": ["HARNESS: " + enemy_error.splitlines()[0]], + "error_count": 1, "log_text": "=== Wrapping opponent failed ===\n" + enemy_error, + } + bot_dirs.append(enemy_dir) out_file = WORK_DIR / "tr-result.json" out_file.unlink(missing_ok=True) @@ -896,6 +920,8 @@ def parse_args(): conf.add_argument("--robot-class", help="robot class to select, when it cannot be derived from the " "jar name (classic's test robot jars hold many robots)") + conf.add_argument("--enemy-class", + help="run the selected robot against this opponent fixture instead of copies") conf.add_argument("--participants", type=int, default=None, help="participant count for --conformance") @@ -1232,6 +1258,11 @@ def run_conformance(opts): setup["rounds"] = opts.rounds if opts.participants is not None: setup["participants"] = opts.participants + if opts.enemy_class: + # An explicit opponent is a 1-vs-1 fixture, matching classic Robocode's test bed. + setup["participants"] = 2 + + enemy_jar = None if opts.conformance_source: class_dir, error = compile_conformance_robot(opts, opts.conformance_source) @@ -1260,16 +1291,33 @@ def run_conformance(opts): return 2 jar, version = packaged, "1.0" + if opts.enemy_class: + # The installed classic robots are an external, read-only fixture (C-007). Package + # only the requested opponent into a temporary jar so both engines receive the same + # class and descriptor without modifying the installation. + enemy_classes = Path(opts.robocode_home) / "robots" + packaged, error = package_test_robot_jar( + enemy_classes, opts.enemy_class, WORK_DIR / "conformance-enemy") + if error: + print(json.dumps({"ok": False, "fatal": error})) + return 2 + enemy_jar = packaged + if opts.engine == "rc": - result = run_rc_battle(jar, classname, version, opts, setup) + result = run_rc_battle(jar, classname, version, opts, setup, + enemy_jar, opts.enemy_class) consoles = result.pop("consoles", None) if consoles is None: consoles = read_worker_consoles(WORK_DIR / "rc-result.json") result["consoles"] = consoles else: - result = run_tr_battle(jar, classname, version, opts, setup) + result = run_tr_battle(jar, classname, version, opts, setup, + enemy_jar_path=enemy_jar, enemy_class=opts.enemy_class) # The bridge side's console output is whatever the bot processes wrote. - result["consoles"] = collect_bot_consoles() + staging_dirs = [WORK_DIR / "tr-bots"] + if opts.enemy_class: + staging_dirs.append(WORK_DIR / "tr-bots-enemy") + result["consoles"] = collect_bot_consoles(staging_dirs) result.pop("log_text", None) result["setup"] = setup print(json.dumps(result)) @@ -1284,18 +1332,18 @@ def read_worker_consoles(out_file: Path): return [] -def collect_bot_consoles(): +def collect_bot_consoles(staging_dirs=None): """Each staged bot instance writes its own stdout/stderr; return them per instance.""" consoles = [] - staging = WORK_DIR / "tr-bots" - if not staging.exists(): - return consoles - for d in sorted(staging.iterdir()): - if not d.is_dir() or d.name == "lib": + for staging in staging_dirs or [WORK_DIR / "tr-bots"]: + if not staging.exists(): continue - text = read_capped(d / "stdout.log") + read_capped(d / "stderr.log") - if text.strip(): - consoles.append(text) + for d in sorted(staging.iterdir()): + if not d.is_dir() or d.name == "lib": + continue + text = read_capped(d / "stdout.log") + read_capped(d / "stderr.log") + if text.strip(): + consoles.append(text) return consoles diff --git a/compat-test/conformance-robots/conformance/probes/EventPriorityProbe.java b/compat-test/conformance-robots/conformance/probes/EventPriorityProbe.java new file mode 100644 index 0000000..8f75b71 --- /dev/null +++ b/compat-test/conformance-robots/conformance/probes/EventPriorityProbe.java @@ -0,0 +1,52 @@ +package conformance.probes; + +import robocode.AdvancedRobot; +import robocode.HitWallEvent; +import robocode.ScannedRobotEvent; + +/** Controls scan dispatch inside and outside a higher-priority wall handler. */ +public class EventPriorityProbe extends AdvancedRobot { + + private boolean wallHandlerActive; + private boolean controlWindow = true; + + @Override + public void run() { + // First make scans higher priority than HitWallEvent. A radar sweep in that handler + // must enter onScannedRobot, proving the same window can generate a scan on both engines. + setEventPriority("ScannedRobotEvent", 40); + while (true) { + ahead(10); + } + } + + @Override + public void onHitWall(HitWallEvent event) { + wallHandlerActive = true; + try { + if (!controlWindow) { + out.println("SuppressionWindowEntered!!!"); + } + turnRadarRight(360); + if (controlWindow) { + // Subsequent wall handlers exercise the classic lower-priority expiry rule. + setEventPriority("ScannedRobotEvent", 10); + controlWindow = false; + } + } finally { + wallHandlerActive = false; + } + } + + @Override + public void onScannedRobot(ScannedRobotEvent event) { + out.println("ScanObserved!!!"); + if (wallHandlerActive) { + if (controlWindow) { + out.println("ScanControlDuringWallHandler!!!"); + } else { + out.println("ScannedDuringWallHandler!!!"); + } + } + } +} diff --git a/conformance-test/src/test/java/dev/robocode/tankroyale/bridge/conformance/ConformanceHarness.java b/conformance-test/src/test/java/dev/robocode/tankroyale/bridge/conformance/ConformanceHarness.java index b1d5895..5c7d8e4 100644 --- a/conformance-test/src/test/java/dev/robocode/tankroyale/bridge/conformance/ConformanceHarness.java +++ b/conformance-test/src/test/java/dev/robocode/tankroyale/bridge/conformance/ConformanceHarness.java @@ -95,6 +95,11 @@ static String missingEnvironment() { * @param robotClass fully qualified, e.g. {@code tested.robots.InteruptibleEvent} */ BattleOutcome run(Engine engine, String robotClass, Path source) { + return run(engine, robotClass, source, null); + } + + /** Runs a robot against a named opponent fixture when the source test requires one. */ + BattleOutcome run(Engine engine, String robotClass, Path source, String enemyClass) { List command = new ArrayList<>(List.of( python, HARNESS.toString(), @@ -107,6 +112,10 @@ BattleOutcome run(Engine engine, String robotClass, Path source) { command.add("--conformance-source"); command.add(source.toString()); } + if (enemyClass != null) { + command.add("--enemy-class"); + command.add(enemyClass); + } try { Process process = new ProcessBuilder(command) diff --git a/conformance-test/src/test/java/dev/robocode/tankroyale/bridge/conformance/ConformanceTestBase.java b/conformance-test/src/test/java/dev/robocode/tankroyale/bridge/conformance/ConformanceTestBase.java index 1ba1656..2128fe6 100644 --- a/conformance-test/src/test/java/dev/robocode/tankroyale/bridge/conformance/ConformanceTestBase.java +++ b/conformance-test/src/test/java/dev/robocode/tankroyale/bridge/conformance/ConformanceTestBase.java @@ -44,13 +44,29 @@ interface Expectation { * Runs the robot on both engines and applies the same expectation to each. */ void assertOnBothEngines(String robotClass, Expectation expectation) { - assertOnBothEngines(robotClass, null, expectation); + assertOnBothEngines(robotClass, (Path) null, expectation); } /** Runs a locally held probe source on both engines after compiling it against classic. */ void assertOnBothEngines(String robotClass, Path source, Expectation expectation) { + assertOnBothEngines(robotClass, source, null, expectation); + } + + /** Runs a robot against the same named opponent fixture on both engines. */ + void assertOnBothEngines(String robotClass, String enemyClass, Expectation expectation) { + assertOnBothEngines(robotClass, null, enemyClass, expectation); + } + + /** Runs a locally held probe against the same named opponent fixture on both engines. */ + void assertOnBothEngines(String robotClass, Path source, String enemyClass, + Expectation expectation) { + assertOnBothEnginesFixture(robotClass, source, enemyClass, expectation); + } + + private void assertOnBothEnginesFixture(String robotClass, Path source, String enemyClass, + Expectation expectation) { for (Engine engine : Engine.values()) { - BattleOutcome outcome = outcomeFor(engine, robotClass, source); + BattleOutcome outcome = outcomeFor(engine, robotClass, source, enemyClass); assertTrue(outcome.completed(), () -> "the battle did not complete on " + engine + " (" + outcome.summary() + ")"); expectation.check(outcome, engine); @@ -65,12 +81,27 @@ void assertOnBothEngines(String robotClass, Path source, Expectation expectation * reason that has nothing to do with what it claims to check. */ BattleOutcome outcomeFor(Engine engine, String robotClass) { - return outcomeFor(engine, robotClass, null); + return outcomeForFixture(engine, robotClass, null, null); } private BattleOutcome outcomeFor(Engine engine, String robotClass, Path source) { - return ran.computeIfAbsent(engine.name() + " " + robotClass, - key -> harness.run(engine, robotClass, source)); + return outcomeForFixture(engine, robotClass, source, null); + } + + /** Runs a robot against an opponent fixture, reusing that exact battle within this test. */ + BattleOutcome outcomeFor(Engine engine, String robotClass, String enemyClass) { + return outcomeForFixture(engine, robotClass, null, enemyClass); + } + + /** Runs a probe against an opponent fixture, reusing that exact battle within this test. */ + BattleOutcome outcomeFor(Engine engine, String robotClass, Path source, String enemyClass) { + return outcomeForFixture(engine, robotClass, source, enemyClass); + } + + private BattleOutcome outcomeForFixture(Engine engine, String robotClass, Path source, + String enemyClass) { + String key = engine.name() + " " + robotClass + " vs " + enemyClass; + return ran.computeIfAbsent(key, ignored -> harness.run(engine, robotClass, source, enemyClass)); } /** The number of rounds every battle in this run is configured for. */ diff --git a/conformance-test/src/test/java/dev/robocode/tankroyale/bridge/conformance/EventPriorityConformanceTest.java b/conformance-test/src/test/java/dev/robocode/tankroyale/bridge/conformance/EventPriorityConformanceTest.java new file mode 100644 index 0000000..0638bbe --- /dev/null +++ b/conformance-test/src/test/java/dev/robocode/tankroyale/bridge/conformance/EventPriorityConformanceTest.java @@ -0,0 +1,79 @@ +package dev.robocode.tankroyale.bridge.conformance; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Acceptance evidence for EVT-015 — the classic event-priority filter expectation. + * + * The probe first raises scan priority and proves that a radar sweep enters the scan handler + * while the wall handler is blocked. It then lowers scan priority and proves the same sweep does + * not enter that handler, matching classic's authoritative EventPriorityFilter boundary. + */ +class EventPriorityConformanceTest extends ConformanceTestBase { + + private static final String ROBOT = "conformance.probes.EventPriorityProbe"; + private static final String ENEMY = "sample.Target"; + private static final String SCAN_OBSERVED = "ScanObserved!!!"; + private static final String SCAN_CONTROL = "ScanControlDuringWallHandler!!!"; + private static final String SUPPRESSION_WINDOW = "SuppressionWindowEntered!!!"; + private static final String SCANNED = "ScannedDuringWallHandler!!!"; + private static final java.nio.file.Path SOURCE = ConformanceHarness.repoRoot() + .resolve("compat-test/conformance-robots/conformance/probes/EventPriorityProbe.java"); + + @Test + @DisplayName("EVT-015: a lower-priority scan is suppressed on both engines") + void testEVT015_IntegrationPositive_LowerPriorityScanIsSuppressedOnBothEngines() { + assertOnBothEngines(ROBOT, SOURCE, ENEMY, (outcome, engine) -> { + assertTrue(outcome.anyConsoleContains(SCAN_OBSERVED), + () -> "the priority probe observed no scan on " + engine + + " (" + outcome.summary() + ")"); + assertTrue(outcome.anyConsoleContains(SCAN_CONTROL), + () -> "the priority probe found no high-priority scan during the wall handler on " + engine + + " (" + outcome.summary() + ")"); + assertTrue(outcome.anyConsoleContains(SUPPRESSION_WINDOW), + () -> "the priority probe did not reach the lower-priority window on " + engine + + " (" + outcome.summary() + ")"); + assertFalse(outcome.anyConsoleContains(SCANNED), + () -> "the lower-priority scan handler ran on " + engine + + " (" + outcome.summary() + ")"); + }); + } + + @Test + @DisplayName("EVT-015 negative: the bridge does not report a scan classic did not see") + void testEVT015_IntegrationNegative_BridgeDoesNotReportAScanClassicDidNotSee() { + BattleOutcome classic = outcomeFor(Engine.CLASSIC, ROBOT, SOURCE, ENEMY); + BattleOutcome bridge = outcomeFor(Engine.BRIDGE, ROBOT, SOURCE, ENEMY); + + assertTrue(classic.completed(), () -> "the classic priority-probe battle did not complete (" + + classic.summary() + ")"); + assertTrue(bridge.completed(), () -> "the bridge priority-probe battle did not complete (" + + bridge.summary() + ")"); + assertTrue(classic.anyConsoleContains(SCAN_OBSERVED), + () -> "the classic priority probe observed no scan (" + classic.summary() + ")"); + assertTrue(bridge.anyConsoleContains(SCAN_OBSERVED), + () -> "the bridge priority probe observed no scan (" + bridge.summary() + ")"); + assertTrue(classic.anyConsoleContains(SCAN_CONTROL), + () -> "the classic priority probe found no high-priority scan during the wall handler (" + + classic.summary() + ")"); + assertTrue(bridge.anyConsoleContains(SCAN_CONTROL), + () -> "the bridge priority probe found no high-priority scan during the wall handler (" + + bridge.summary() + ")"); + assertTrue(classic.anyConsoleContains(SUPPRESSION_WINDOW), + () -> "the classic priority probe did not reach the lower-priority window (" + + classic.summary() + ")"); + assertTrue(bridge.anyConsoleContains(SUPPRESSION_WINDOW), + () -> "the bridge priority probe did not reach the lower-priority window (" + + bridge.summary() + ")"); + assertFalse(classic.anyConsoleContains(SCANNED), + () -> "the classic priority-probe baseline reported a scan (" + + classic.summary() + ")"); + assertFalse(bridge.anyConsoleContains(SCANNED), + () -> "the bridge reported a scan that classic Robocode did not (" + + bridge.summary() + ")"); + } +} diff --git a/docs/capabilities/CAP-001-event-dispatch-parity/README.md b/docs/capabilities/CAP-001-event-dispatch-parity/README.md index 6031d7e..54b186c 100644 --- a/docs/capabilities/CAP-001-event-dispatch-parity/README.md +++ b/docs/capabilities/CAP-001-event-dispatch-parity/README.md @@ -35,4 +35,4 @@ The physics the events describe. That a `ScannedRobotEvent` arrives at the right `draft`. The redesign that routed events through the Bot API's own event queue is implemented and believed correct, but it was verified by running battles and reading scores. Every criterion here is unproven in the sense that matters: nothing would tell us if it broke again. `M-001` is the plan door. -The conformance tier now reaches some of them. `EVT-004`, `EVT-011`, `EVT-012`, `EVT-013`, and `EVT-014` are active — the tests that already proved them were retagged after [`G-002`](../../goals/G-002-conformance-evidence-proves-the-criterion-it-names.md) found them mistagged, and `EVT-003`/`EVT-007` retired rather than be credited with evidence they cannot honestly claim ([`IDR-003`](../../decisions/IDR-003-evt-003-scoped-to-what-classic-actually-proves.md), [`IDR-004`](../../decisions/IDR-004-evt-007-scoped-to-observable-survivor-delivery.md)). `EVT-004` and `EVT-014` are proven with a locally built matched Tank Royale Bot API and runner pair under [`PDR-002`](../../decisions/PDR-002-locally-built-tank-royale-artifacts-for-conformance.md), which contains the server repair [`AN-009`](../../analysis/AN-009-the-server-never-sends-a-death-to-any-bot.md) identified. The capability still holds at `draft` because most criteria remain unproven. +The conformance tier now reaches some of them. `EVT-004`, `EVT-011`, `EVT-012`, `EVT-013`, `EVT-014`, and `EVT-015` are active — the tests that already proved them were retagged after [`G-002`](../../goals/G-002-conformance-evidence-proves-the-criterion-it-names.md) found them mistagged, and `EVT-001`/`EVT-003`/`EVT-007` retired rather than be credited with evidence they cannot honestly claim ([`IDR-003`](../../decisions/IDR-003-evt-003-scoped-to-what-classic-actually-proves.md), [`IDR-004`](../../decisions/IDR-004-evt-007-scoped-to-observable-survivor-delivery.md), [`IDR-005`](../../decisions/IDR-005-evt-001-scoped-to-classic-filter-behavior.md)). `EVT-004` and `EVT-014` are proven with a locally built matched Tank Royale Bot API and runner pair under [`PDR-002`](../../decisions/PDR-002-locally-built-tank-royale-artifacts-for-conformance.md), which contains the server repair [`AN-009`](../../analysis/AN-009-the-server-never-sends-a-death-to-any-bot.md) identified. `EVT-015` is proven with the same matched pair and a bridge-owned two-phase probe: a higher-priority scan callback is required inside `onHitWall`, then a lower-priority scan callback is required to be absent. The capability still holds at `draft` because most criteria remain unproven. diff --git a/docs/capabilities/CAP-001-event-dispatch-parity/criteria.md b/docs/capabilities/CAP-001-event-dispatch-parity/criteria.md index 269c8cc..c2bba7a 100644 --- a/docs/capabilities/CAP-001-event-dispatch-parity/criteria.md +++ b/docs/capabilities/CAP-001-event-dispatch-parity/criteria.md @@ -11,18 +11,18 @@ reversal-cost: low # CAP-001 — acceptance criteria -Most criteria here are `@draft` against `M-001`; three (`EVT-011`, `EVT-012`, `EVT-013`) are active. Each names the classic test robot that will prove it, because classic's own conformance suite already encodes these expectations and the conformance tier restates them against both engines. +Most criteria here are `@draft` against `M-001`; `EVT-004`, `EVT-011`, `EVT-012`, `EVT-013`, `EVT-014`, and `EVT-015` are active. Each names the classic test robot or bridge-owned probe that will prove it, because classic's own conformance suite already encodes these expectations and the conformance tier restates them against both engines. ```gherkin Feature: Event dispatch and timing parity - @EVT-001 @draft + @EVT-001 @retired Scenario: Events dispatch in classic priority order Test-type: Integration Given a robot that records the order in which its handlers are entered When the same battle runs on classic Robocode and on Tank Royale through the bridge Then the recorded order is the same on both engines - # Proven by the ported EventPriorityFilter robot. Plan door: M-001. + # Retired: the named EventPriorityFilter robot does not record handler order. See IDR-005; successor EVT-015. @EVT-002 @draft Scenario: A blocking call inside a handler does not discard pending same-priority events @@ -88,6 +88,14 @@ Feature: Event dispatch and timing parity # to EVT-007; see IDR-004. (single-direction): a battle with a death necessarily has a # survivor, so the missing marker is the behavior this criterion detects. + @EVT-015 + Scenario: A lower-priority scan is suppressed while a higher-priority wall handler is blocked + Test-type: Integration + Given a priority probe runs against sample.Target, moves to a wall, and turns its radar from onHitWall + When the same battle runs on classic Robocode and on Tank Royale through the bridge + Then neither engine's robot output contains the scan marker + # Proven by EventPriorityConformanceTest with a bridge-owned probe and sample.Target fixture. The probe first raises scan priority and requires a scan callback inside onHitWall, then lowers scan priority and requires that callback to be absent. Successor to EVT-001; see IDR-005. Plan door: M-001. + @EVT-008 @draft Scenario: Skipped turns are reported to the robot Test-type: Integration diff --git a/docs/capabilities/CAP-001-event-dispatch-parity/design.md b/docs/capabilities/CAP-001-event-dispatch-parity/design.md index 81406ba..1747d7a 100644 --- a/docs/capabilities/CAP-001-event-dispatch-parity/design.md +++ b/docs/capabilities/CAP-001-event-dispatch-parity/design.md @@ -2,7 +2,7 @@ id: DES-001 type: design status: active -links: [CAP-001, IDR-001, ADR-001, ARCH-002] +links: [CAP-001, IDR-001, IDR-005, ADR-001, ARCH-002] title: Event dispatch and timing parity — design provenance: inferred reversal-cost: low @@ -16,6 +16,8 @@ The Bot API owns an event queue with priorities, interruptible events, and a thr The consequence is that priority ordering and interruptibility are the Bot API's behaviour, not the bridge's. `IDR-001` records why that delegation replaced the alternative. +One compatibility edge remains at the boundary: classic expires lower-priority scan events while a higher-priority `onHitWall` handler is blocked by a radar turn, while the Bot API can retain those events until the handler returns. `BotPeer` observes the configured priorities and suppresses only those lower-priority scans during the blocked interval and its current-turn remainder; same-priority re-entry remains delegated to the Bot API. `IDR-005` records why this narrow boundary rule is needed. + ## What this replaced, and why the shape matters The bridge previously dispatched events itself: a manual switch over event types, driven from the turn loop, with bookkeeping to track which events had already been dispatched and which were mid-dispatch. diff --git a/docs/decisions/IDR-005-evt-001-scoped-to-classic-filter-behavior.md b/docs/decisions/IDR-005-evt-001-scoped-to-classic-filter-behavior.md new file mode 100644 index 0000000..83e4595 --- /dev/null +++ b/docs/decisions/IDR-005-evt-001-scoped-to-classic-filter-behavior.md @@ -0,0 +1,23 @@ +--- +id: IDR-005 +type: decision +status: inferred +author: agent +accepted-by: [] +links: [CAP-001, PDR-001] +title: EVT-001's priority-order claim is retired; the filter boundary is measured directly +--- + +# IDR-005 — EVT-001's priority-order claim is retired; the filter boundary is measured directly + +## Decision + +Retire `EVT-001` and mint `EVT-015`: a lower-priority scan handler is not entered while a higher-priority wall handler is blocked on a radar turn, on classic Robocode and through the bridge. + +## Context + +The named classic robot prints a scan marker and its authoritative test asserts that the marker is absent while running against `sample.Target`; it does not record a handler order. A criterion about recorded order therefore claims behavior that its source evidence cannot observe. The conformance harness stages that opponent fixture for both engines and resets classic's deterministic test seed for this fixture. Because Tank Royale has no battle seed, the conformance test uses a bridge-owned two-phase probe: it first raises scan priority and requires a scan callback inside the blocked wall-handler window, then lowers scan priority and requires that callback to be absent. + +## Consequences + +The conformance test measures the observable classic expectation without generalizing it into a complete event-order claim. Broader priority ordering remains unproven until a source robot records it directly. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 93d3144..3c75571 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -28,4 +28,5 @@ A decision that changes a methodology contract inventories every live carrier th - [IDR-003 — EVT-003's higher-priority claim is retired; interruptible re-entry evidence is scoped to what classic's own robot proves](IDR-003-evt-003-scoped-to-what-classic-actually-proves.md) · `inferred` — `EVT-003` retires. - [PDR-002 — Conformance uses locally built Tank Royale artifacts rather than waiting for releases](PDR-002-locally-built-tank-royale-artifacts-for-conformance.md) · `verified` — When bridge conformance needs a Tank Royale repair that is not released, build the Tank Royale Bot API and runner locally from the same upstream revision and use that pair for the comparison. - [IDR-004 — EVT-007's cross-engine death-order claim is retired; survivor delivery is measured directly](IDR-004-evt-007-scoped-to-observable-survivor-delivery.md) · `inferred` — Retire `EVT-007` and mint `EVT-014`: a surviving robot receives another robot's death event on each engine. +- [IDR-005 — EVT-001's priority-order claim is retired; the filter boundary is measured directly](IDR-005-evt-001-scoped-to-classic-filter-behavior.md) · `inferred` — `EVT-001` retires and `EVT-015` measures the observable filter behavior. diff --git a/docs/plans/P-001-bridge-parity-campaign.md b/docs/plans/P-001-bridge-parity-campaign.md index a33e04e..3b69944 100644 --- a/docs/plans/P-001-bridge-parity-campaign.md +++ b/docs/plans/P-001-bridge-parity-campaign.md @@ -40,7 +40,7 @@ They are bookkeeping rather than a second plan. A door closes when its criterion | ID | Proves | Exit criterion | Status | |---|---|---|---| -| M-101 | `EVT-001` | `EVT-001` is active, with evidence attributable to it. Work lands under M-001. | todo | +| M-101 | `EVT-001` | Dropped: `EVT-001` retired (`IDR-005`); see `M-143` for its successor `EVT-015`. | dropped | | M-102 | `EVT-002` | `EVT-002` is active, with evidence attributable to it. Work lands under M-001. | todo | | M-103 | `EVT-003` | Dropped: `EVT-003` retired (`IDR-003`); see `M-141` for its successor `EVT-013`. | dropped | | M-104 | `EVT-004` | `EVT-004` is active, with evidence attributable to it. Work lands under M-001. | done | @@ -82,6 +82,7 @@ They are bookkeeping rather than a second plan. A door closes when its criterion | M-140 | `EVT-012` | `EVT-012` is active, with evidence attributable to it. Work lands under M-001. | done | | 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 | ## 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 71e5065..e2298fa 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 @@ -47,6 +47,10 @@ public final class BotPeer implements ITeamRobotPeer, IJuniorRobotPeer { private final AtomicReference currentRobotStatus = new AtomicReference<>(); private boolean stopThread; + private volatile int suppressScansThroughTurn = -1; + private boolean hitWallHandlerActive; + private boolean hitWallHandlerBlocked; + private boolean suppressScansForBlockedWallHandler; @SuppressWarnings("unused") public BotPeer(IBasicRobot robot, BotInfo botInfo) { @@ -293,10 +297,34 @@ private void dispatchStatusEvent(BotEvent botEvent) { private void dispatchScannedRobotEvent(BotEvent botEvent) { log("-> onScannedRobot"); var scannedBotEvent = (ScannedBotEvent) botEvent; + if (shouldSuppressScannedEvent(scannedBotEvent)) { + return; + } var scannedRobotEvent = ScannedRobotEventMapper.map(scannedBotEvent, bot); basicEvents.onScannedRobot(scannedRobotEvent); } + /** + * Classic expires lower-priority scan events while a higher-priority wall handler is + * blocked on a radar turn. The Bot API queue can retain those events until the handler + * returns, so the bridge must discard only events from that blocked interval rather than + * allowing them to cross the classic API boundary afterwards. + */ + private boolean shouldSuppressScannedEvent(ScannedBotEvent scannedBotEvent) { + if (hitWallHandlerActive && hitWallHandlerBlocked && suppressScansForBlockedWallHandler) { + return true; + } + int throughTurn = suppressScansThroughTurn; + if (throughTurn < 0) { + return false; + } + if (scannedBotEvent.getTurnNumber() <= throughTurn) { + return true; + } + suppressScansThroughTurn = -1; + return false; + } + private void dispatchBulletMissedEvent(BotEvent botEvent) { log("-> onBulletMissed"); var bulletHitWallEvent = (BulletHitWallEvent) botEvent; @@ -333,7 +361,25 @@ private void dispatchHitByBulletEvent(BotEvent botEvent) { private void dispatchHitWallEvent() { log("-> onHitWall"); - basicEvents.onHitWall(new robocode.HitWallEvent(calcBearingToWallRadians(bot.getDirection()))); + hitWallHandlerActive = true; + hitWallHandlerBlocked = false; + suppressScansForBlockedWallHandler = scansHaveLowerPriorityThanWall(); + try { + basicEvents.onHitWall(new robocode.HitWallEvent(calcBearingToWallRadians(bot.getDirection()))); + } finally { + if (hitWallHandlerBlocked && suppressScansForBlockedWallHandler) { + suppressScansThroughTurn = bot.getTurnNumber(); + } + hitWallHandlerActive = false; + hitWallHandlerBlocked = false; + suppressScansForBlockedWallHandler = false; + } + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + private boolean scansHaveLowerPriorityThanWall() { + return bot.getEventPriority((Class) ScannedBotEvent.class) + < bot.getEventPriority((Class) dev.robocode.tankroyale.botapi.events.HitWallEvent.class); } private void dispatchHitRobotEvent(BotEvent botEvent) { @@ -421,6 +467,9 @@ public void turnGun(double radians) { @Override public void turnRadar(double radians) { log("turnRadar()"); + if (hitWallHandlerActive) { + hitWallHandlerBlocked = true; + } bot.turnRadarRight(toDegrees(radians)); } @@ -1074,6 +1123,7 @@ false, map(gameEndedEvent.getResults(), String.valueOf(getMyId()))) @Override public void onRoundStarted(RoundStartedEvent roundStartedEvent) { // no event handler for `round started` in orig. Robocode + suppressScansThroughTurn = -1; firedBullets.clear(); }