From 2cba206257eb4ff8e66fe5d7d7ddf7c9f85ae1b6 Mon Sep 17 00:00:00 2001 From: "Flemming N. Larsen" Date: Tue, 22 Sep 2026 21:01:10 +0200 Subject: [PATCH 01/13] Propose CH-047 team-message limit trial --- .clue/id-ledger.yaml | 9 +++++++++ .../open-questions.md | 11 +++++++++++ .../CH-047-team-message-limits/proposal.md | 19 +++++++++++++++++++ changes/CH-047-team-message-limits/tasks.md | 16 ++++++++++++++++ 4 files changed, 55 insertions(+) create mode 100644 changes/CH-047-team-message-limits/open-questions.md create mode 100644 changes/CH-047-team-message-limits/proposal.md create mode 100644 changes/CH-047-team-message-limits/tasks.md diff --git a/.clue/id-ledger.yaml b/.clue/id-ledger.yaml index ed4bda259..0a6291ca7 100644 --- a/.clue/id-ledger.yaml +++ b/.clue/id-ledger.yaml @@ -321,6 +321,15 @@ events: - {id: UD-008, kind: numeric, state: reserved, prefix: UD, component: "8"} - {id: PDR-014, kind: numeric, state: live, prefix: PDR, component: "14"} - {id: UD-008, kind: numeric, state: live, prefix: UD, component: "8"} + - {id: CH-047, kind: numeric, state: reserved, prefix: CH, component: "47"} + - {id: CH-048, kind: numeric, state: reserved, prefix: CH, component: "48"} + - {id: CAP-018, kind: numeric, state: reserved, prefix: CAP, component: "18"} + - {id: CH-047, kind: numeric, state: live, prefix: CH, component: "47"} + - {id: CAP-018, kind: numeric, state: live, prefix: CAP, component: "18"} + - {id: TASKS-002, kind: numeric, state: reserved, prefix: TASKS, component: "2"} + - {id: OQ-002, kind: numeric, state: reserved, prefix: OQ, component: "2"} + - {id: TASKS-002, kind: numeric, state: live, prefix: TASKS, component: "2"} + - {id: OQ-002, kind: numeric, state: live, prefix: OQ, component: "2"} high-water: - {id: ADR-047, kind: numeric, state: reserved, prefix: ADR, component: "47"} - {id: AN-001, kind: numeric, state: reserved, prefix: AN, component: "1"} diff --git a/changes/CH-047-team-message-limits/open-questions.md b/changes/CH-047-team-message-limits/open-questions.md new file mode 100644 index 000000000..6b19f4c52 --- /dev/null +++ b/changes/CH-047-team-message-limits/open-questions.md @@ -0,0 +1,11 @@ +--- +id: OQ-002 +type: open-question +status: active +links: [CH-047] +title: CH-047 open questions +--- + +# Open questions + +No blocking question is known at proposal time. Trial measurements may reveal a bottleneck that requires a human decision on revised limits before the policy is published. diff --git a/changes/CH-047-team-message-limits/proposal.md b/changes/CH-047-team-message-limits/proposal.md new file mode 100644 index 000000000..ff5e04479 --- /dev/null +++ b/changes/CH-047-team-message-limits/proposal.md @@ -0,0 +1,19 @@ +--- +id: CH-047 +type: change +status: open +links: [] +title: Trial bounded team-message throughput across Tank Royale +--- + +# CH-047 — Trial bounded team-message throughput + +## What and why + +Classic teams can send more messages in a turn than Tank Royale currently forwards. The Bot APIs, server, and schema disagree about both count and size, so a client can accept a message that the server silently drops. Trial one policy for every Tank Royale bot: at most 128 messages and 256 KiB of compact, UTF-8-encoded `teamMessages` array data per bot per turn, and at most 48 KiB for each encoded message. Bound inbound WebSocket text to 1 MiB before JSON parsing. Reject an invalid intent in full rather than delivering a prefix. + +The user supplied this trial plan. It is plan-less in the Tank Royale corpus; CAP-018 will own the accepted messaging criteria. The trial uses local builds only. The policy becomes publishable only if boundary, malformed-client, CombatTeam, and five-bot 30 TPS stress evidence pass. + +## Scope + +Align the server, schema, Java, Python, .NET, and TypeScript Bot APIs; add focused cross-platform and server evidence; document count, byte budgets, failure behavior, and next-turn delivery. After the trial gate passes, add bridge and book evidence in their own repositories. Preserve the bridge's frozen classic API and its read-only robot jars. No release or push to an integration branch is part of this change. diff --git a/changes/CH-047-team-message-limits/tasks.md b/changes/CH-047-team-message-limits/tasks.md new file mode 100644 index 000000000..8263039ae --- /dev/null +++ b/changes/CH-047-team-message-limits/tasks.md @@ -0,0 +1,16 @@ +--- +id: TASKS-002 +type: tasks +status: open +links: [CH-047] +title: CH-047 implementation tasks +--- + +# Tasks + +- [ ] Define CAP-018 criteria for count, byte budgets, atomic rejection, and delivery timing (TML-001 through TML-004). +- [ ] Align schema and server validation, including the pre-parse WebSocket text bound (TML-001, TML-002, TML-003, TML-004). +- [ ] Align Java, Python, .NET, and TypeScript enqueue validation and API documentation (TML-001, TML-002, TML-003). +- [ ] Run boundary, malformed-client, and delivery tests against matched local builds (TML-001, TML-002, TML-003, TML-004). +- [ ] Run CombatTeam and five-bot stress at 30 TPS; record turn, skip, and traffic measurements and apply the acceptance gate (TML-004). +- [ ] After the gate passes, update the Tank Royale article, bridge evidence, and book pages; run relevant checks (TML-001, TML-002, TML-004). From 9164eee490a328302efaf700b0f3b507627740a8 Mon Sep 17 00:00:00 2001 From: "Flemming N. Larsen" Date: Tue, 22 Sep 2026 21:20:27 +0200 Subject: [PATCH 02/13] Trial CH-047 team-message throughput limits --- bot-api/dotnet/api/src/Constants.cs | 9 ++- bot-api/dotnet/api/src/IBaseBot.cs | 9 ++- .../api/src/internal/BaseBotInternals.cs | 7 +- bot-api/dotnet/api/src/internal/EventQueue.cs | 2 +- .../api/src/internal/IntentValidator.cs | 11 ++- .../dotnet/test/src/TeamMessageLimitsTest.cs | 31 ++++++++ .../robocode/tankroyale/botapi/Constants.java | 11 ++- .../botapi/internal/BaseBotInternals.java | 3 + .../botapi/internal/EventQueue.java | 5 +- .../botapi/internal/IntentValidator.java | 14 +++- .../internal/TeamMessageLimitsTest.java | 30 ++++++++ .../robocode_tank_royale/bot_api/constants.py | 7 +- .../bot_api/internal/base_bot_internals.py | 5 ++ .../bot_api/internal/event_queue.py | 6 +- .../bot_api/internal/intent_validator.py | 8 ++ .../python/tests/test_team_message_limits.py | 28 +++++++ .../TeamMessageLoadBot.java | 65 ++++++++++++++++ .../TeamMessageLoadBot.json | 9 +++ .../TeamMessageLoadTeam.json | 13 ++++ bot-api/tests/build.gradle.kts | 2 + bot-api/tests/shared/constants.json | 4 +- bot-api/typescript/src/BaseBot.ts | 1 + bot-api/typescript/src/Constants.ts | 7 +- bot-api/typescript/src/IBaseBot.ts | 9 ++- bot-api/typescript/src/events/EventQueue.ts | 4 +- .../src/internal/BaseBotInternals.ts | 6 +- .../src/internal/intentValidator.ts | 14 +++- .../typescript/test/TeamMessageLimits.test.ts | 20 +++++ .../open-questions.md | 10 ++- .../CH-047-team-message-limits/proposal.md | 2 +- changes/CH-047-team-message-limits/tasks.md | 2 +- .../runner/TeamMessageLoadIntegrationTest.kt | 74 +++++++++++++++++++ schema/schemas/bot-intent.schema.yaml | 6 +- schema/schemas/team-message.schema.yaml | 5 +- .../connection/ClientWebSocketsHandler.kt | 12 +++ .../server/connection/TeamMessagePolicy.kt | 43 +++++++++++ .../tankroyale/server/core/GameServer.kt | 10 +++ .../tankroyale/server/core/TurnProcessor.kt | 4 +- .../robocode/tankroyale/server/rules/rules.kt | 12 ++- .../connection/TeamMessagePolicyTest.kt | 63 ++++++++++++++++ 40 files changed, 531 insertions(+), 52 deletions(-) create mode 100644 bot-api/dotnet/test/src/TeamMessageLimitsTest.cs create mode 100644 bot-api/java/src/test/java/dev/robocode/tankroyale/botapi/internal/TeamMessageLimitsTest.java create mode 100644 bot-api/python/tests/test_team_message_limits.py create mode 100644 bot-api/tests/bots/java/TeamMessageLoadBot/TeamMessageLoadBot.java create mode 100644 bot-api/tests/bots/java/TeamMessageLoadBot/TeamMessageLoadBot.json create mode 100644 bot-api/tests/bots/java/TeamMessageLoadTeam/TeamMessageLoadTeam.json create mode 100644 bot-api/typescript/test/TeamMessageLimits.test.ts create mode 100644 runner/src/test/kotlin/dev/robocode/tankroyale/runner/TeamMessageLoadIntegrationTest.kt create mode 100644 server/src/main/kotlin/dev/robocode/tankroyale/server/connection/TeamMessagePolicy.kt create mode 100644 server/src/test/kotlin/connection/TeamMessagePolicyTest.kt diff --git a/bot-api/dotnet/api/src/Constants.cs b/bot-api/dotnet/api/src/Constants.cs index 8ac873ec0..cfc3e65af 100644 --- a/bot-api/dotnet/api/src/Constants.cs +++ b/bot-api/dotnet/api/src/Constants.cs @@ -153,11 +153,14 @@ public static class Constants /// size of the message object. Messages exceeding this size are rejected. /// /// The maximum team message size in bytes, which is 32768. - public const int TeamMessageMaxSize = 32768; + public const int TeamMessageMaxSize = 48 * 1024; /// /// The maximum number of team messages that can be sent per turn, which is 10 messages. /// /// The maximum number of team messages per turn, which is 10. - public const int MaxNumberOfTeamMessagesPerTurn = 10; -} \ No newline at end of file + public const int MaxNumberOfTeamMessagesPerTurn = 128; + + /// Maximum UTF-8 bytes of the compact encoded teamMessages array per turn. + public const int TeamMessagesMaxBytesPerTurn = 256 * 1024; +} diff --git a/bot-api/dotnet/api/src/IBaseBot.cs b/bot-api/dotnet/api/src/IBaseBot.cs index 1c67409ea..a031b557c 100644 --- a/bot-api/dotnet/api/src/IBaseBot.cs +++ b/bot-api/dotnet/api/src/IBaseBot.cs @@ -13,15 +13,18 @@ namespace Robocode.TankRoyale.BotApi; public interface IBaseBot { /// - /// The maximum size of a team message, which is 32 KB (32.786 bytes). + /// The maximum size of an encoded team message, which is 48 KiB of UTF-8 bytes. /// const int TeamMessageMaxSize = Constants.TeamMessageMaxSize; // bytes /// - /// The maximum number of team messages that can be sent per turn, which is 10 messages. + /// The maximum number of team messages that can be sent per turn, which is 128 messages. /// const int MaxNumberOfTeamMessagesPerTurn = Constants.MaxNumberOfTeamMessagesPerTurn; + /// Maximum UTF-8 bytes of the compact encoded teamMessages array per turn. + const int TeamMessagesMaxBytesPerTurn = Constants.TeamMessagesMaxBytesPerTurn; + /// /// The method used to start running the bot. You should call this method from the main /// method or similar. @@ -1179,4 +1182,4 @@ public interface IBaseBot /// /// void SetEventPriority(Type eventType, int priority); -} \ No newline at end of file +} diff --git a/bot-api/dotnet/api/src/internal/BaseBotInternals.cs b/bot-api/dotnet/api/src/internal/BaseBotInternals.cs index 5f899c201..2e5fd88f9 100644 --- a/bot-api/dotnet/api/src/internal/BaseBotInternals.cs +++ b/bot-api/dotnet/api/src/internal/BaseBotInternals.cs @@ -811,12 +811,15 @@ internal void SendTeamMessage(int? teammateId, object message) var json = JsonConverter.ToJson(message); IntentValidator.ValidateTeamMessageSize(json); - BotIntent.TeamMessages.Add(new S.TeamMessage + var teamMessage = new S.TeamMessage { MessageType = message.GetType().ToString(), Message = json, ReceiverId = teammateId, - }); + }; + var candidateMessages = new System.Collections.Generic.List(BotIntent.TeamMessages) { teamMessage }; + IntentValidator.ValidateTeamMessagesSize(JsonConverter.ToJson(candidateMessages)); + BotIntent.TeamMessages.Add(teamMessage); } internal Color? BodyColor diff --git a/bot-api/dotnet/api/src/internal/EventQueue.cs b/bot-api/dotnet/api/src/internal/EventQueue.cs index 063eb6ef6..f648558e0 100644 --- a/bot-api/dotnet/api/src/internal/EventQueue.cs +++ b/bot-api/dotnet/api/src/internal/EventQueue.cs @@ -293,7 +293,7 @@ internal void AddEvent(BotEvent botEvent) { lock (_events) { - if (_events.Count < MaxQueueSize) + if (botEvent is TeamMessageEvent || _events.Count(e => e is not TeamMessageEvent) < MaxQueueSize) { _events.Add(botEvent); } diff --git a/bot-api/dotnet/api/src/internal/IntentValidator.cs b/bot-api/dotnet/api/src/internal/IntentValidator.cs index 8af9369e2..2e7f5fe63 100644 --- a/bot-api/dotnet/api/src/internal/IntentValidator.cs +++ b/bot-api/dotnet/api/src/internal/IntentValidator.cs @@ -117,9 +117,9 @@ public static void ValidateTeammateId(int? teammateId, ICollection teammate public static void ValidateTeamMessage(object message, int currentTeamMessageCount) { - if (currentTeamMessageCount == IBaseBot.MaxNumberOfTeamMessagesPerTurn) + if (currentTeamMessageCount >= IBaseBot.MaxNumberOfTeamMessagesPerTurn) throw new InvalidOperationException( - "The maximum number team massages has already been reached: " + + "The maximum number of team messages has already been reached: " + IBaseBot.MaxNumberOfTeamMessagesPerTurn); if (message == null) @@ -134,5 +134,12 @@ public static void ValidateTeamMessageSize(string json) $"The team message is larger than the limit of {IBaseBot.TeamMessageMaxSize} bytes (compact JSON format)"); } + public static void ValidateTeamMessagesSize(string json) + { + if (System.Text.Encoding.UTF8.GetByteCount(json) > IBaseBot.TeamMessagesMaxBytesPerTurn) + throw new ArgumentException( + $"The teamMessages array exceeds {IBaseBot.TeamMessagesMaxBytesPerTurn} UTF-8 bytes (compact JSON format)"); + } + public static string ColorToHex(Color? color) => color == null ? null : "#" + ColorUtil.ToHex(color); } diff --git a/bot-api/dotnet/test/src/TeamMessageLimitsTest.cs b/bot-api/dotnet/test/src/TeamMessageLimitsTest.cs new file mode 100644 index 000000000..40032bd11 --- /dev/null +++ b/bot-api/dotnet/test/src/TeamMessageLimitsTest.cs @@ -0,0 +1,31 @@ +using System; +using NUnit.Framework; +using Robocode.TankRoyale.BotApi.Internal; + +namespace Robocode.TankRoyale.BotApi.Tests; + +[TestFixture] +[Category("Unit")] +public class TeamMessageLimitsTest +{ + [Test] + public void CountBoundary() + { + Assert.DoesNotThrow(() => IntentValidator.ValidateTeamMessage("hello", Constants.MaxNumberOfTeamMessagesPerTurn - 1)); + Assert.Throws(() => IntentValidator.ValidateTeamMessage("hello", Constants.MaxNumberOfTeamMessagesPerTurn)); + } + + [Test] + public void UnicodePayloadBoundary() + { + Assert.DoesNotThrow(() => IntentValidator.ValidateTeamMessageSize(new string('é', Constants.TeamMessageMaxSize / 2))); + Assert.Throws(() => IntentValidator.ValidateTeamMessageSize(new string('é', Constants.TeamMessageMaxSize / 2 + 1))); + } + + [Test] + public void AggregateBoundary() + { + Assert.DoesNotThrow(() => IntentValidator.ValidateTeamMessagesSize(new string('x', Constants.TeamMessagesMaxBytesPerTurn))); + Assert.Throws(() => IntentValidator.ValidateTeamMessagesSize(new string('x', Constants.TeamMessagesMaxBytesPerTurn + 1))); + } +} diff --git a/bot-api/java/src/main/java/dev/robocode/tankroyale/botapi/Constants.java b/bot-api/java/src/main/java/dev/robocode/tankroyale/botapi/Constants.java index 161493b47..05b96f560 100644 --- a/bot-api/java/src/main/java/dev/robocode/tankroyale/botapi/Constants.java +++ b/bot-api/java/src/main/java/dev/robocode/tankroyale/botapi/Constants.java @@ -130,13 +130,16 @@ private Constants() { public static final double STARTING_GUN_HEAT = 3.0; /** - * The maximum size of a team message in bytes (32 KB), which is the serialized (compact JSON) + * The maximum size of a team message in UTF-8 bytes (48 KiB), which is the serialized (compact JSON) * size of the message object. Messages exceeding this size are rejected. */ - public static final int TEAM_MESSAGE_MAX_SIZE = 32768; + public static final int TEAM_MESSAGE_MAX_SIZE = 48 * 1024; /** - * The maximum number of team messages that can be sent per turn, which is 10 messages. + * The maximum number of team messages that can be sent per turn, which is 128 messages. */ - public static final int MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN = 10; + public static final int MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN = 128; + + /** Maximum UTF-8 bytes of the compact encoded teamMessages array per turn (256 KiB). */ + public static final int TEAM_MESSAGES_MAX_BYTES_PER_TURN = 256 * 1024; } diff --git a/bot-api/java/src/main/java/dev/robocode/tankroyale/botapi/internal/BaseBotInternals.java b/bot-api/java/src/main/java/dev/robocode/tankroyale/botapi/internal/BaseBotInternals.java index cc1bdda36..c12d2450a 100644 --- a/bot-api/java/src/main/java/dev/robocode/tankroyale/botapi/internal/BaseBotInternals.java +++ b/bot-api/java/src/main/java/dev/robocode/tankroyale/botapi/internal/BaseBotInternals.java @@ -780,6 +780,9 @@ public void sendTeamMessage(Integer teammateId, Object message) { teamMessage.setReceiverId(teammateId); teamMessage.setMessage(json); + var candidateMessages = new java.util.ArrayList<>(botIntent.getTeamMessages()); + candidateMessages.add(teamMessage); + IntentValidator.validateTeamMessagesSize(JsonConverter.toJson(candidateMessages)); botIntent.getTeamMessages().add(teamMessage); } diff --git a/bot-api/java/src/main/java/dev/robocode/tankroyale/botapi/internal/EventQueue.java b/bot-api/java/src/main/java/dev/robocode/tankroyale/botapi/internal/EventQueue.java index 2b5fac5c4..561b71f3d 100644 --- a/bot-api/java/src/main/java/dev/robocode/tankroyale/botapi/internal/EventQueue.java +++ b/bot-api/java/src/main/java/dev/robocode/tankroyale/botapi/internal/EventQueue.java @@ -230,7 +230,10 @@ private static boolean isOldAndNonCriticalEvent(BotEvent botEvent, int turnNumbe void addEvent(BotEvent botEvent) { synchronized (events) { - if (events.size() < MAX_QUEUE_SIZE) { + // Team messages are already bounded per sender by the protocol. A recipient may receive + // more than 256 in one turn, so reserve the ordinary queue limit for other events. + if (botEvent instanceof TeamMessageEvent || + events.stream().filter(event -> !(event instanceof TeamMessageEvent)).count() < MAX_QUEUE_SIZE) { events.add(botEvent); } else { System.err.println("Maximum event queue size has been reached: " + MAX_QUEUE_SIZE); diff --git a/bot-api/java/src/main/java/dev/robocode/tankroyale/botapi/internal/IntentValidator.java b/bot-api/java/src/main/java/dev/robocode/tankroyale/botapi/internal/IntentValidator.java index 0d04d71e9..6e7980777 100644 --- a/bot-api/java/src/main/java/dev/robocode/tankroyale/botapi/internal/IntentValidator.java +++ b/bot-api/java/src/main/java/dev/robocode/tankroyale/botapi/internal/IntentValidator.java @@ -4,6 +4,7 @@ import dev.robocode.tankroyale.botapi.graphics.Color; import dev.robocode.tankroyale.botapi.util.ColorUtil; import java.util.Set; +import java.nio.charset.StandardCharsets; import static dev.robocode.tankroyale.botapi.Constants.*; import static dev.robocode.tankroyale.botapi.util.MathUtil.clamp; import static java.lang.Math.*; @@ -111,9 +112,9 @@ public static void validateTeammateId(Integer teammateId, Set teammateI } public static void validateTeamMessage(Object message, int currentTeamMessageCount) { - if (currentTeamMessageCount == MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN) { + if (currentTeamMessageCount >= MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN) { throw new BotException( - "The maximum number team massages has already been reached: " + MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN); + "The maximum number of team messages has already been reached: " + MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN); } if (message == null) { throw new IllegalArgumentException("The 'message' of a team message cannot be null"); @@ -121,12 +122,19 @@ public static void validateTeamMessage(Object message, int currentTeamMessageCou } public static void validateTeamMessageSize(String json) { - if (json.getBytes().length > TEAM_MESSAGE_MAX_SIZE) { + if (json.getBytes(StandardCharsets.UTF_8).length > TEAM_MESSAGE_MAX_SIZE) { throw new IllegalArgumentException( "The team message is larger than the limit of " + TEAM_MESSAGE_MAX_SIZE + " bytes (compact JSON format)"); } } + public static void validateTeamMessagesSize(String json) { + if (json.getBytes(StandardCharsets.UTF_8).length > TEAM_MESSAGES_MAX_BYTES_PER_TURN) { + throw new IllegalArgumentException("The teamMessages array exceeds " + + TEAM_MESSAGES_MAX_BYTES_PER_TURN + " UTF-8 bytes (compact JSON format)"); + } + } + public static String colorToHex(Color color) { return color == null ? null : "#" + ColorUtil.toHex(color); } diff --git a/bot-api/java/src/test/java/dev/robocode/tankroyale/botapi/internal/TeamMessageLimitsTest.java b/bot-api/java/src/test/java/dev/robocode/tankroyale/botapi/internal/TeamMessageLimitsTest.java new file mode 100644 index 000000000..39e6d5844 --- /dev/null +++ b/bot-api/java/src/test/java/dev/robocode/tankroyale/botapi/internal/TeamMessageLimitsTest.java @@ -0,0 +1,30 @@ +package dev.robocode.tankroyale.botapi.internal; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import static dev.robocode.tankroyale.botapi.Constants.*; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class TeamMessageLimitsTest { + @Test @Tag("Unit") + void accepts128MessagesAndRejects129th() { + assertDoesNotThrow(() -> IntentValidator.validateTeamMessage("hello", 127)); + assertThrows(RuntimeException.class, () -> IntentValidator.validateTeamMessage("hello", 128)); + } + + @Test @Tag("Unit") + void countsUnicodePayloadBytes() { + assertDoesNotThrow(() -> IntentValidator.validateTeamMessageSize("é".repeat(TEAM_MESSAGE_MAX_SIZE / 2))); + assertThrows(IllegalArgumentException.class, + () -> IntentValidator.validateTeamMessageSize("é".repeat(TEAM_MESSAGE_MAX_SIZE / 2 + 1))); + } + + @Test @Tag("Unit") + void aggregateBoundaryIsInclusive() { + assertDoesNotThrow(() -> IntentValidator.validateTeamMessagesSize("x".repeat(TEAM_MESSAGES_MAX_BYTES_PER_TURN))); + assertThrows(IllegalArgumentException.class, + () -> IntentValidator.validateTeamMessagesSize("x".repeat(TEAM_MESSAGES_MAX_BYTES_PER_TURN + 1))); + } +} diff --git a/bot-api/python/src/robocode_tank_royale/bot_api/constants.py b/bot-api/python/src/robocode_tank_royale/bot_api/constants.py index 11774aa17..31656931b 100644 --- a/bot-api/python/src/robocode_tank_royale/bot_api/constants.py +++ b/bot-api/python/src/robocode_tank_royale/bot_api/constants.py @@ -92,12 +92,15 @@ to zero, cooling at the rate defined by the game setup's gun cooling rate. """ -MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN = 10 +MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN = 128 """ Maximum number of team messages that can be sent per turn. """ -TEAM_MESSAGE_MAX_SIZE = 32768 +TEAM_MESSAGE_MAX_SIZE = 48 * 1024 + +TEAM_MESSAGES_MAX_BYTES_PER_TURN = 256 * 1024 +"""Maximum UTF-8 bytes of the compact encoded teamMessages array per turn.""" """ Maximum size of a team message in bytes (32 KB). """ diff --git a/bot-api/python/src/robocode_tank_royale/bot_api/internal/base_bot_internals.py b/bot-api/python/src/robocode_tank_royale/bot_api/internal/base_bot_internals.py index aa36c2e16..83b2e65fb 100644 --- a/bot-api/python/src/robocode_tank_royale/bot_api/internal/base_bot_internals.py +++ b/bot-api/python/src/robocode_tank_royale/bot_api/internal/base_bot_internals.py @@ -867,7 +867,9 @@ def broadcast_team_message(self, message: "Any") -> None: self.send_team_message(None, message) def send_team_message(self, teammate_id: Optional[int], message: Any) -> None: + import json from ..team_message import serialize_team_message + from .json_util import MessageEncoder IntentValidator.validate_teammate_id(teammate_id, self.teammate_ids) @@ -887,6 +889,9 @@ def send_team_message(self, teammate_id: Optional[int], message: Any) -> None: receiver_id=teammate_id, message=json_message_str, ) + candidate_messages = [*team_messages_list, team_message] + compact_messages = json.dumps(candidate_messages, cls=MessageEncoder, separators=(",", ":"), ensure_ascii=False) + IntentValidator.validate_team_messages_size(compact_messages) team_messages_list.append(team_message) # Color and Graphics - Delegated diff --git a/bot-api/python/src/robocode_tank_royale/bot_api/internal/event_queue.py b/bot-api/python/src/robocode_tank_royale/bot_api/internal/event_queue.py index 86f415d5c..1733e774c 100644 --- a/bot-api/python/src/robocode_tank_royale/bot_api/internal/event_queue.py +++ b/bot-api/python/src/robocode_tank_royale/bot_api/internal/event_queue.py @@ -5,7 +5,7 @@ if TYPE_CHECKING: from .base_bot_internals import BaseBotInternals -from ..events import CustomEvent, BotEvent, TickEvent +from ..events import CustomEvent, BotEvent, TickEvent, TeamMessageEvent from .bot_event_handlers import BotEventHandlers from .event_interruption import EventInterruption from .event_priorities import EventPriorities @@ -183,7 +183,9 @@ def is_old_and_non_critical_event(bot_event: BotEvent, turn_number: int) -> bool def add_event(self, bot_event: BotEvent): with self.events_lock: - if len(self.events) < EventQueue.MAX_QUEUE_SIZE: + if isinstance(bot_event, TeamMessageEvent) or sum( + not isinstance(event, TeamMessageEvent) for event in self.events + ) < EventQueue.MAX_QUEUE_SIZE: self.events.append(bot_event) else: print(f"Maximum event queue size has been reached: {EventQueue.MAX_QUEUE_SIZE}") diff --git a/bot-api/python/src/robocode_tank_royale/bot_api/internal/intent_validator.py b/bot-api/python/src/robocode_tank_royale/bot_api/internal/intent_validator.py index 5019cc32a..0ddc1de46 100644 --- a/bot-api/python/src/robocode_tank_royale/bot_api/internal/intent_validator.py +++ b/bot-api/python/src/robocode_tank_royale/bot_api/internal/intent_validator.py @@ -12,6 +12,7 @@ ACCELERATION, MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN, TEAM_MESSAGE_MAX_SIZE, + TEAM_MESSAGES_MAX_BYTES_PER_TURN, ) @@ -140,6 +141,13 @@ def validate_team_message_size(json_message_str: str) -> None: f"The team message is larger than the limit of {TEAM_MESSAGE_MAX_SIZE} bytes (compact JSON format)" ) + @staticmethod + def validate_team_messages_size(json_messages_str: str) -> None: + if len(json_messages_str.encode("utf-8")) > TEAM_MESSAGES_MAX_BYTES_PER_TURN: + raise ValueError( + f"The teamMessages array exceeds {TEAM_MESSAGES_MAX_BYTES_PER_TURN} UTF-8 bytes (compact JSON format)" + ) + @staticmethod def color_to_schema(color: Optional[Color]) -> Optional[str]: return color.to_color_schema() if color else None diff --git a/bot-api/python/tests/test_team_message_limits.py b/bot-api/python/tests/test_team_message_limits.py new file mode 100644 index 000000000..b8606f8bb --- /dev/null +++ b/bot-api/python/tests/test_team_message_limits.py @@ -0,0 +1,28 @@ +import pytest + +from robocode_tank_royale.bot_api.constants import ( + MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN, + TEAM_MESSAGE_MAX_SIZE, + TEAM_MESSAGES_MAX_BYTES_PER_TURN, +) +from robocode_tank_royale.bot_api.internal.intent_validator import IntentValidator + +pytestmark = pytest.mark.Unit + + +def test_count_boundary() -> None: + IntentValidator.validate_team_message("hello", MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN - 1) + with pytest.raises(Exception): + IntentValidator.validate_team_message("hello", MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN) + + +def test_unicode_payload_boundary() -> None: + IntentValidator.validate_team_message_size("é" * (TEAM_MESSAGE_MAX_SIZE // 2)) + with pytest.raises(ValueError): + IntentValidator.validate_team_message_size("é" * (TEAM_MESSAGE_MAX_SIZE // 2 + 1)) + + +def test_aggregate_boundary() -> None: + IntentValidator.validate_team_messages_size("x" * TEAM_MESSAGES_MAX_BYTES_PER_TURN) + with pytest.raises(ValueError): + IntentValidator.validate_team_messages_size("x" * (TEAM_MESSAGES_MAX_BYTES_PER_TURN + 1)) diff --git a/bot-api/tests/bots/java/TeamMessageLoadBot/TeamMessageLoadBot.java b/bot-api/tests/bots/java/TeamMessageLoadBot/TeamMessageLoadBot.java new file mode 100644 index 000000000..3b7da3837 --- /dev/null +++ b/bot-api/tests/bots/java/TeamMessageLoadBot/TeamMessageLoadBot.java @@ -0,0 +1,65 @@ +import dev.robocode.tankroyale.botapi.Bot; +import dev.robocode.tankroyale.botapi.events.SkippedTurnEvent; +import dev.robocode.tankroyale.botapi.events.TeamMessageEvent; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; + +/** Local trial workload: five copies broadcast 128 messages on each of 60 turns. */ +public class TeamMessageLoadBot extends Bot { + private final Map lastBySender = new HashMap<>(); + private int received; + private int outOfOrder; + private int skipped; + + public static void main(String[] args) { + new TeamMessageLoadBot().start(); + } + + @Override + public void run() { + while (isRunning()) { + if (getTurnNumber() <= 60) { + for (int index = 0; index < 128; index++) { + broadcastTeamMessage(getTurnNumber() + ":" + index); + } + } + setTurnRight(1); + go(); + if (getTurnNumber() >= 62) { + writeMetrics(); + } + } + } + + @Override + public void onTeamMessage(TeamMessageEvent event) { + received++; + var payload = (String) event.getMessage(); + var prior = lastBySender.put(event.getSenderId(), payload); + if (prior != null) { + var parts = prior.split(":"); + int expectedTurn = Integer.parseInt(parts[0]); + int expectedIndex = Integer.parseInt(parts[1]) + 1; + if (expectedIndex == 128) { expectedIndex = 0; expectedTurn++; } + if (!payload.equals(expectedTurn + ":" + expectedIndex)) outOfOrder++; + } + } + + @Override + public void onSkippedTurn(SkippedTurnEvent event) { + skipped++; + } + + private void writeMetrics() { + try { + Path file = Path.of("team-message-load-" + getMyId() + ".txt"); + Files.writeString(file, getTurnNumber() + "," + received + "," + outOfOrder + "," + skipped); + } catch (IOException exception) { + exception.printStackTrace(); + } + } +} diff --git a/bot-api/tests/bots/java/TeamMessageLoadBot/TeamMessageLoadBot.json b/bot-api/tests/bots/java/TeamMessageLoadBot/TeamMessageLoadBot.json new file mode 100644 index 000000000..cdbac0031 --- /dev/null +++ b/bot-api/tests/bots/java/TeamMessageLoadBot/TeamMessageLoadBot.json @@ -0,0 +1,9 @@ +{ + "name": "TeamMessageLoadBot", + "version": "1.0", + "authors": ["Tank Royale test"], + "description": "Team-message throughput trial bot", + "platform": "JVM", + "programmingLang": "Java 11", + "gameTypes": ["classic", "melee"] +} diff --git a/bot-api/tests/bots/java/TeamMessageLoadTeam/TeamMessageLoadTeam.json b/bot-api/tests/bots/java/TeamMessageLoadTeam/TeamMessageLoadTeam.json new file mode 100644 index 000000000..ccae0198a --- /dev/null +++ b/bot-api/tests/bots/java/TeamMessageLoadTeam/TeamMessageLoadTeam.json @@ -0,0 +1,13 @@ +{ + "name": "TeamMessageLoadTeam", + "version": "1.0", + "authors": ["Tank Royale test"], + "description": "Five-bot team-message throughput trial", + "teamMembers": [ + "TeamMessageLoadBot", + "TeamMessageLoadBot", + "TeamMessageLoadBot", + "TeamMessageLoadBot", + "TeamMessageLoadBot" + ] +} diff --git a/bot-api/tests/build.gradle.kts b/bot-api/tests/build.gradle.kts index 19d365560..3349abd9d 100644 --- a/bot-api/tests/build.gradle.kts +++ b/bot-api/tests/build.gradle.kts @@ -150,6 +150,8 @@ tasks { prepareJavaBot("WonRoundCounterJava") prepareCsharpBot("WonRoundCounterCSharp") prepareJavaBot("BreakpointStallBot") + prepareJavaBot("TeamMessageLoadBot") + prepareJavaBot("TeamMessageLoadTeam") prepareTsDependencies() prepareTypescriptBot("WonRoundCounterTs") } diff --git a/bot-api/tests/shared/constants.json b/bot-api/tests/shared/constants.json index ccc312e0e..d72fe9d21 100644 --- a/bot-api/tests/shared/constants.json +++ b/bot-api/tests/shared/constants.json @@ -104,7 +104,7 @@ "type": "positive", "method": "getConstant", "args": ["TEAM_MESSAGE_MAX_SIZE"], - "expected": { "returns": 32768 } + "expected": { "returns": 49152 } }, { "id": "TR-API-VAL-005-constants-max-team-messages-per-turn", @@ -112,7 +112,7 @@ "type": "positive", "method": "getConstant", "args": ["MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN"], - "expected": { "returns": 10 } + "expected": { "returns": 128 } } ] } diff --git a/bot-api/typescript/src/BaseBot.ts b/bot-api/typescript/src/BaseBot.ts index 9e3d18df7..eabd8a9b4 100644 --- a/bot-api/typescript/src/BaseBot.ts +++ b/bot-api/typescript/src/BaseBot.ts @@ -41,6 +41,7 @@ import { MathUtil } from "./util/MathUtil.js"; export abstract class BaseBot implements IBaseBot { readonly TEAM_MESSAGE_MAX_SIZE = Constants.TEAM_MESSAGE_MAX_SIZE; readonly MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN = Constants.MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN; + readonly TEAM_MESSAGES_MAX_BYTES_PER_TURN = Constants.TEAM_MESSAGES_MAX_BYTES_PER_TURN; /** @internal */ readonly _internals: BaseBotInternals; diff --git a/bot-api/typescript/src/Constants.ts b/bot-api/typescript/src/Constants.ts index 2a23d3a66..483f9715e 100644 --- a/bot-api/typescript/src/Constants.ts +++ b/bot-api/typescript/src/Constants.ts @@ -125,10 +125,13 @@ export const Constants = { * The maximum size of a team message in bytes (32 KB), which is the serialized (compact JSON) * size of the message object. Messages exceeding this size are rejected. */ - TEAM_MESSAGE_MAX_SIZE: 32768, + TEAM_MESSAGE_MAX_SIZE: 48 * 1024, /** * The maximum number of team messages that can be sent per turn, which is 10 messages. */ - MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN: 10, + MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN: 128, + + /** Maximum UTF-8 bytes of the compact encoded teamMessages array per turn. */ + TEAM_MESSAGES_MAX_BYTES_PER_TURN: 256 * 1024, } as const; diff --git a/bot-api/typescript/src/IBaseBot.ts b/bot-api/typescript/src/IBaseBot.ts index 5ad579613..d3a086515 100644 --- a/bot-api/typescript/src/IBaseBot.ts +++ b/bot-api/typescript/src/IBaseBot.ts @@ -29,11 +29,14 @@ import { TeamMessageEvent } from "./events/TeamMessageEvent.js"; * Interface containing the core API for a bot. */ export interface IBaseBot { - /** The maximum size of a team message in bytes (32 KB). */ - readonly TEAM_MESSAGE_MAX_SIZE: 32768; + /** The maximum size of an encoded team message in UTF-8 bytes (48 KiB). */ + readonly TEAM_MESSAGE_MAX_SIZE: number; /** The maximum number of team messages that can be sent per turn. */ - readonly MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN: 10; + readonly MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN: number; + + /** Maximum UTF-8 bytes of the compact encoded teamMessages array per turn. */ + readonly TEAM_MESSAGES_MAX_BYTES_PER_TURN: number; /** Starts the bot, connecting to the server and running until the game ends. */ start(): void; diff --git a/bot-api/typescript/src/events/EventQueue.ts b/bot-api/typescript/src/events/EventQueue.ts index b7e4afbd8..5c570ce31 100644 --- a/bot-api/typescript/src/events/EventQueue.ts +++ b/bot-api/typescript/src/events/EventQueue.ts @@ -5,6 +5,7 @@ import { Condition } from "./Condition.js"; import { EventPriorities } from "./EventPriorities.js"; import { EventInterruption } from "./EventInterruption.js"; import { BotEventHandlers } from "./BotEventHandlers.js"; +import { TeamMessageEvent } from "./TeamMessageEvent.js"; const MAX_QUEUE_SIZE = 256; const MAX_EVENT_AGE = 2; @@ -24,7 +25,8 @@ export class EventQueue { } addEvent(event: BotEvent): void { - if (this.events.length >= MAX_QUEUE_SIZE) { + if (!(event instanceof TeamMessageEvent) && + this.events.filter(e => !(e instanceof TeamMessageEvent)).length >= MAX_QUEUE_SIZE) { return; } this.events.push(event); diff --git a/bot-api/typescript/src/internal/BaseBotInternals.ts b/bot-api/typescript/src/internal/BaseBotInternals.ts index d90a229ac..315f764da 100644 --- a/bot-api/typescript/src/internal/BaseBotInternals.ts +++ b/bot-api/typescript/src/internal/BaseBotInternals.ts @@ -1154,11 +1154,13 @@ export class BaseBotInternals { IntentValidator.validateTeamMessage(message, this.intent.teamMessages.length); - this.intent.teamMessages.push({ + const teamMessage = { message: json, messageType: typeof message === "object" && message !== null ? message.constructor.name : "string", receiverId: teammateId ?? null, - }); + }; + IntentValidator.validateTeamMessagesSize(toJson([...this.intent.teamMessages, teamMessage])); + this.intent.teamMessages.push(teamMessage); } // --------------------------------------------------------------------------- diff --git a/bot-api/typescript/src/internal/intentValidator.ts b/bot-api/typescript/src/internal/intentValidator.ts index b61990c5b..ab7ecd413 100644 --- a/bot-api/typescript/src/internal/intentValidator.ts +++ b/bot-api/typescript/src/internal/intentValidator.ts @@ -96,8 +96,8 @@ export class IntentValidator { } static validateTeamMessage(message: unknown, currentTeamMessageCount: number): void { - if (currentTeamMessageCount >= 10) { // MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN - throw new Error("The maximum number team messages has already been reached: 10"); + if (currentTeamMessageCount >= Constants.MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN) { + throw new Error("The maximum number of team messages has already been reached: " + Constants.MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN); } if (message === null || message === undefined) { throw new Error("The 'message' of a team message cannot be null"); @@ -105,8 +105,14 @@ export class IntentValidator { } static validateTeamMessageSize(json: string): void { - if (json.length > 32768) { // TEAM_MESSAGE_MAX_SIZE - throw new Error("The team message is larger than the limit of 32768 bytes (compact JSON format)"); + if (new TextEncoder().encode(json).length > Constants.TEAM_MESSAGE_MAX_SIZE) { + throw new Error("The team message is larger than the limit of " + Constants.TEAM_MESSAGE_MAX_SIZE + " bytes (compact JSON format)"); + } + } + + static validateTeamMessagesSize(json: string): void { + if (new TextEncoder().encode(json).length > Constants.TEAM_MESSAGES_MAX_BYTES_PER_TURN) { + throw new Error("The teamMessages array exceeds " + Constants.TEAM_MESSAGES_MAX_BYTES_PER_TURN + " UTF-8 bytes (compact JSON format)"); } } } diff --git a/bot-api/typescript/test/TeamMessageLimits.test.ts b/bot-api/typescript/test/TeamMessageLimits.test.ts new file mode 100644 index 000000000..2c3ec6767 --- /dev/null +++ b/bot-api/typescript/test/TeamMessageLimits.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from "vitest"; +import { Constants } from "../src/Constants.js"; +import { IntentValidator } from "../src/internal/intentValidator.js"; + +describe("Unit: team message limits", () => { + test("accepts 128 messages and rejects the 129th", () => { + expect(() => IntentValidator.validateTeamMessage("hello", Constants.MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN - 1)).not.toThrow(); + expect(() => IntentValidator.validateTeamMessage("hello", Constants.MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN)).toThrow(); + }); + + test("counts Unicode payload bytes", () => { + expect(() => IntentValidator.validateTeamMessageSize("é".repeat(Constants.TEAM_MESSAGE_MAX_SIZE / 2))).not.toThrow(); + expect(() => IntentValidator.validateTeamMessageSize("é".repeat(Constants.TEAM_MESSAGE_MAX_SIZE / 2 + 1))).toThrow(); + }); + + test("includes the aggregate boundary", () => { + expect(() => IntentValidator.validateTeamMessagesSize("x".repeat(Constants.TEAM_MESSAGES_MAX_BYTES_PER_TURN))).not.toThrow(); + expect(() => IntentValidator.validateTeamMessagesSize("x".repeat(Constants.TEAM_MESSAGES_MAX_BYTES_PER_TURN + 1))).toThrow(); + }); +}); diff --git a/changes/CH-047-team-message-limits/open-questions.md b/changes/CH-047-team-message-limits/open-questions.md index 6b19f4c52..a70acc0c0 100644 --- a/changes/CH-047-team-message-limits/open-questions.md +++ b/changes/CH-047-team-message-limits/open-questions.md @@ -8,4 +8,12 @@ title: CH-047 open questions # Open questions -No blocking question is known at proposal time. Trial measurements may reveal a bottleneck that requires a human decision on revised limits before the policy is published. +## Trial result — 2026-09-22 + +The acceptance gate failed under the five-bot workload: each member broadcast 128 small messages for 60 turns at 30 TPS. The runner measured 40.52 TPS and captured 37,760 sent messages with 264,510 encoded payload bytes, so raw turn throughput was not the constraint. The five recipients recorded 29,440 to 29,568 received messages each, against 30,720 expected, 2 to 3 skipped turns each, and 29,187 to 29,342 ordering violations each. + +`MutableTurn` stores private bot events in a `HashSet`, which makes the server's event order non-deterministic. The Bot APIs previously bounded every event queue at 256; the trial widened that handling for team messages, but delivery still fails under the workload. The trial must not publish the 128-message policy. + +## Blocking decision + +Should the next trial first preserve ordered private events and then retest a lower per-turn message count, or should it keep the 128-message target and introduce a separately bounded inbound event queue with an explicit overflow rule? The acceptance gate requires a human decision before either policy is adopted. diff --git a/changes/CH-047-team-message-limits/proposal.md b/changes/CH-047-team-message-limits/proposal.md index ff5e04479..3005a3ffa 100644 --- a/changes/CH-047-team-message-limits/proposal.md +++ b/changes/CH-047-team-message-limits/proposal.md @@ -12,7 +12,7 @@ title: Trial bounded team-message throughput across Tank Royale Classic teams can send more messages in a turn than Tank Royale currently forwards. The Bot APIs, server, and schema disagree about both count and size, so a client can accept a message that the server silently drops. Trial one policy for every Tank Royale bot: at most 128 messages and 256 KiB of compact, UTF-8-encoded `teamMessages` array data per bot per turn, and at most 48 KiB for each encoded message. Bound inbound WebSocket text to 1 MiB before JSON parsing. Reject an invalid intent in full rather than delivering a prefix. -The user supplied this trial plan. It is plan-less in the Tank Royale corpus; CAP-018 will own the accepted messaging criteria. The trial uses local builds only. The policy becomes publishable only if boundary, malformed-client, CombatTeam, and five-bot 30 TPS stress evidence pass. +The user supplied this trial plan. It is plan-less in the Tank Royale corpus; accepted messaging criteria will be added only after the trial passes. The trial uses local builds only. The policy becomes publishable only if boundary, malformed-client, CombatTeam, and five-bot 30 TPS stress evidence pass. ## Scope diff --git a/changes/CH-047-team-message-limits/tasks.md b/changes/CH-047-team-message-limits/tasks.md index 8263039ae..f82b99a7d 100644 --- a/changes/CH-047-team-message-limits/tasks.md +++ b/changes/CH-047-team-message-limits/tasks.md @@ -12,5 +12,5 @@ title: CH-047 implementation tasks - [ ] Align schema and server validation, including the pre-parse WebSocket text bound (TML-001, TML-002, TML-003, TML-004). - [ ] Align Java, Python, .NET, and TypeScript enqueue validation and API documentation (TML-001, TML-002, TML-003). - [ ] Run boundary, malformed-client, and delivery tests against matched local builds (TML-001, TML-002, TML-003, TML-004). -- [ ] Run CombatTeam and five-bot stress at 30 TPS; record turn, skip, and traffic measurements and apply the acceptance gate (TML-004). +- [-] Run CombatTeam and five-bot stress at 30 TPS; record turn, skip, and traffic measurements and apply the acceptance gate (TML-004). The five-bot trial failed: delivery was incomplete and unordered, and recipients recorded skipped turns. A human decision on revised limits is required before further policy work. - [ ] After the gate passes, update the Tank Royale article, bridge evidence, and book pages; run relevant checks (TML-001, TML-002, TML-004). diff --git a/runner/src/test/kotlin/dev/robocode/tankroyale/runner/TeamMessageLoadIntegrationTest.kt b/runner/src/test/kotlin/dev/robocode/tankroyale/runner/TeamMessageLoadIntegrationTest.kt new file mode 100644 index 000000000..7d693db7d --- /dev/null +++ b/runner/src/test/kotlin/dev/robocode/tankroyale/runner/TeamMessageLoadIntegrationTest.kt @@ -0,0 +1,74 @@ +package dev.robocode.tankroyale.runner + +import org.junit.jupiter.api.Tag +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Timeout +import java.nio.file.Files +import java.nio.file.Path +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.TimeUnit +import java.util.stream.Collectors +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue + +/** Runs a five-bot team at the trial count against the matched local server and Bot API. */ +@Tag("integration") +class TeamMessageLoadIntegrationTest { + @Test + @Tag("TML-004") + @Tag("Performance") + @Tag("Positive") + @Timeout(value = 90, unit = TimeUnit.SECONDS) + fun testTML004_PerformancePositive_fiveBotTeamSustains30TpsWithoutSkippedTurns() { + val botDir = Path.of(checkNotNull(System.getProperty("testBots.java.dir"))) + val sampleBotDir = Path.of(checkNotNull(System.getProperty("sampleBots.java.dir"))) + val memberDir = botDir.resolve("TeamMessageLoadBot") + Files.list(memberDir).use { paths -> + paths.filter { it.fileName.toString().startsWith("team-message-load-") }.forEach(Files::deleteIfExists) + } + val ticks = CopyOnWriteArrayList() + BattleRunner.create { + embeddedServer() + enableIntentDiagnostics() + }.use { runner -> + runner.startBattleAsync( + BattleSetup.classic { + numberOfRounds = 1 + minNumberOfParticipants = 2 + maxNumberOfParticipants = 10 + defaultTurnsPerSecond = 30 + }, + listOf(BotEntry.of(botDir.resolve("TeamMessageLoadTeam")), BotEntry.of(sampleBotDir.resolve("Walls"))) + ).use { handle -> + val owner = Any() + handle.onTickEvent.on(owner) { tick -> + if (tick.turnNumber in 2..62) ticks += System.nanoTime() + } + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(35) + while (ticks.size < 61 && System.nanoTime() < deadline) Thread.sleep(100) + handle.stop() + } + assertEquals(61, ticks.size, "Battle did not reach the measured turns") + val elapsedSeconds = (ticks.last() - ticks.first()).toDouble() / 1_000_000_000.0 + val measuredTps = 60.0 / elapsedSeconds + val intents = runner.intentDiagnostics!!.getIntentsForBot("TeamMessageLoadBot") + val messageCount = intents.sumOf { it.intent.teamMessages?.size ?: 0 } + val encodedBytes = intents.sumOf { capture -> + capture.intent.teamMessages?.sumOf { it.message?.toByteArray(Charsets.UTF_8)?.size ?: 0 } ?: 0 + } + println("Team-message load: TPS=$measuredTps, messages=$messageCount, payloadBytes=$encodedBytes") + assertTrue(measuredTps >= 30.0, "Messaging load fell below 30 TPS: $measuredTps") + val metrics = Files.list(memberDir).use { paths -> + paths.filter { it.fileName.toString().startsWith("team-message-load-") }.collect(Collectors.toList()) + } + assertEquals(5, metrics.size, "Expected metrics from all five team members") + metrics.forEach { path -> + val fields = Files.readString(path).trim().split(",").map(String::toInt) + assertTrue(fields[0] >= 62) + assertEquals(0, fields[2], "Message order failed for $path") + assertEquals(0, fields[3], "Skipped turns recorded by $path") + assertTrue(fields[1] >= 4 * 128 * 60, "Missing team messages for $path: ${fields[1]}") + } + } + } +} diff --git a/schema/schemas/bot-intent.schema.yaml b/schema/schemas/bot-intent.schema.yaml index 668e79395..a49f01245 100644 --- a/schema/schemas/bot-intent.schema.yaml +++ b/schema/schemas/bot-intent.schema.yaml @@ -85,11 +85,11 @@ properties: description: New text received from standard error (stderr) type: string teamMessages: - description: Messages to send to one or more individual teammates or broadcast to the entire team + description: Messages delivered in order on the next turn. At most 128 per bot per turn; the compact UTF-8 encoded array is limited to 262144 bytes. An invalid intent is rejected in full. type: array - maxItems: 4 + maxItems: 128 items: $ref: team-message.schema.yaml debugGraphics: description: Debug graphics to draw on the screen - type: string \ No newline at end of file + type: string diff --git a/schema/schemas/team-message.schema.yaml b/schema/schemas/team-message.schema.yaml index f7f64ce7f..ff6d00e84 100644 --- a/schema/schemas/team-message.schema.yaml +++ b/schema/schemas/team-message.schema.yaml @@ -3,9 +3,8 @@ $schema: https://json-schema.org/2020-12/schema# description: Message sent between teammates properties: message: - description: The received message, e.g. in JSON format + description: The encoded message payload, e.g. compact JSON. Its UTF-8 representation is limited to 49152 bytes. type: string - maxLength: 32768 messageType: description: The message type, e.g. a class name type: string @@ -14,4 +13,4 @@ properties: type: integer required: - message - - messageType \ No newline at end of file + - messageType diff --git a/server/src/main/kotlin/dev/robocode/tankroyale/server/connection/ClientWebSocketsHandler.kt b/server/src/main/kotlin/dev/robocode/tankroyale/server/connection/ClientWebSocketsHandler.kt index 5ee924800..177e2ef9b 100644 --- a/server/src/main/kotlin/dev/robocode/tankroyale/server/connection/ClientWebSocketsHandler.kt +++ b/server/src/main/kotlin/dev/robocode/tankroyale/server/connection/ClientWebSocketsHandler.kt @@ -8,12 +8,14 @@ import dev.robocode.tankroyale.common.util.Version import dev.robocode.tankroyale.schema.* import dev.robocode.tankroyale.server.core.ServerSetup import dev.robocode.tankroyale.server.core.StatusCode +import dev.robocode.tankroyale.server.rules.MAX_INBOUND_TEXT_BYTES import org.java_websocket.WebSocket import org.java_websocket.exceptions.WebsocketNotConnectedException import org.java_websocket.handshake.ClientHandshake import org.slf4j.LoggerFactory import java.io.Closeable import java.nio.ByteBuffer +import java.nio.charset.StandardCharsets import java.util.* import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ExecutorService @@ -68,6 +70,10 @@ class ClientWebSocketsHandler( } override fun onMessage(clientSocket: WebSocket, message: String) { + if (message.toByteArray(StandardCharsets.UTF_8).size > MAX_INBOUND_TEXT_BYTES) { + clientSocket.close(1009 /* RFC 6455 message too big */, "Text message exceeds $MAX_INBOUND_TEXT_BYTES UTF-8 bytes") + return + } processMessage(clientSocket, message) } @@ -247,6 +253,12 @@ class ClientWebSocketsHandler( private fun handleIntent(clientSocket: WebSocket, message: String) { botHandshakes[clientSocket]?.let { botHandshake -> + try { + TeamMessagePolicy.validate(gson.fromJson(message, JsonObject::class.java)) + } catch (exception: IllegalArgumentException) { + clientSocket.close(1008 /* RFC 6455 policy violation */, exception.message) + return + } val intent = gson.fromJson(message, BotIntent::class.java) listener.onBotIntent(clientSocket, botHandshake, intent) } diff --git a/server/src/main/kotlin/dev/robocode/tankroyale/server/connection/TeamMessagePolicy.kt b/server/src/main/kotlin/dev/robocode/tankroyale/server/connection/TeamMessagePolicy.kt new file mode 100644 index 000000000..cad70479d --- /dev/null +++ b/server/src/main/kotlin/dev/robocode/tankroyale/server/connection/TeamMessagePolicy.kt @@ -0,0 +1,43 @@ +package dev.robocode.tankroyale.server.connection + +import com.google.gson.JsonObject +import dev.robocode.tankroyale.server.rules.MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN +import dev.robocode.tankroyale.server.rules.MAX_TEAM_MESSAGE_SIZE +import dev.robocode.tankroyale.server.rules.MAX_TEAM_MESSAGES_BYTES_PER_TURN +import java.nio.charset.StandardCharsets + +/** Validates the complete batch before any part of an intent enters the game loop. */ +internal object TeamMessagePolicy { + fun validate(intent: JsonObject) { + val value = intent.get("teamMessages") ?: return + if (value.isJsonNull) return + require(value.isJsonArray) { "teamMessages must be an array" } + val messages = value.asJsonArray + require(messages.size() <= MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN) { + "teamMessages exceeds $MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN messages" + } + require(messages.toString().toByteArray(StandardCharsets.UTF_8).size <= MAX_TEAM_MESSAGES_BYTES_PER_TURN) { + "teamMessages exceeds $MAX_TEAM_MESSAGES_BYTES_PER_TURN UTF-8 bytes" + } + for (item in messages) { + require(item.isJsonObject) { "Each team message must be an object" } + val message = item.asJsonObject.get("message") + require(message != null && message.isJsonPrimitive && message.asJsonPrimitive.isString) { + "Each team message requires an encoded string payload" + } + require(message.asString.toByteArray(StandardCharsets.UTF_8).size <= MAX_TEAM_MESSAGE_SIZE) { + "Team message exceeds $MAX_TEAM_MESSAGE_SIZE UTF-8 bytes" + } + val messageType = item.asJsonObject.get("messageType") + require(messageType != null && messageType.isJsonPrimitive && messageType.asJsonPrimitive.isString) { + "Each team message requires a messageType string" + } + val recipient = item.asJsonObject.get("receiverId") + require(recipient == null || recipient.isJsonNull || + (recipient.isJsonPrimitive && recipient.asJsonPrimitive.isNumber && + recipient.asString.toIntOrNull() != null)) { + "receiverId must be an integer teammate id" + } + } + } +} diff --git a/server/src/main/kotlin/dev/robocode/tankroyale/server/core/GameServer.kt b/server/src/main/kotlin/dev/robocode/tankroyale/server/core/GameServer.kt index 3a552e2e2..1ae5d036f 100644 --- a/server/src/main/kotlin/dev/robocode/tankroyale/server/core/GameServer.kt +++ b/server/src/main/kotlin/dev/robocode/tankroyale/server/core/GameServer.kt @@ -554,6 +554,16 @@ class GameServer( internal fun handleBotIntent(conn: WebSocket, intent: dev.robocode.tankroyale.schema.BotIntent) { if (lifecycleManager.serverState !== ServerState.GAME_RUNNING && lifecycleManager.serverState !== ServerState.GAME_PAUSED) return + val senderId = participantRegistry.participantIds[conn] + val teamId = connectionHandler.getBotHandshakes()[conn]?.teamId + val teammateIds = senderId?.let { getTeammateIds(it, teamId) } ?: emptySet() + if (intent.teamMessages?.any { message -> + message.receiverId != null && BotId(message.receiverId) !in teammateIds + } == true) { + conn.close(1008 /* RFC 6455 policy violation */, "Team message receiverId is not a teammate") + return + } + var shouldProcessBreakpointTurn = false synchronized(tickLock) { val existingIntent = botIntents[conn] diff --git a/server/src/main/kotlin/dev/robocode/tankroyale/server/core/TurnProcessor.kt b/server/src/main/kotlin/dev/robocode/tankroyale/server/core/TurnProcessor.kt index df6e344e6..7acd8ae0c 100644 --- a/server/src/main/kotlin/dev/robocode/tankroyale/server/core/TurnProcessor.kt +++ b/server/src/main/kotlin/dev/robocode/tankroyale/server/core/TurnProcessor.kt @@ -403,9 +403,7 @@ class TurnProcessor( private fun processTeamMessages(bot: MutableBot, intent: BotIntent, turn: MutableTurn) { val teamMessages = intent.teamMessages ?: return - for (index in 0 until teamMessages.size.coerceAtMost(MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN)) { - val msg = teamMessages[index] - if (msg.message.length > MAX_TEAM_MESSAGE_SIZE) continue + for (msg in teamMessages) { if (msg.receiverId != null) { turn.addPrivateBotEvent( msg.receiverId, TeamMessageEvent(turn.turnNumber, msg.message, msg.messageType, bot.id) diff --git a/server/src/main/kotlin/dev/robocode/tankroyale/server/rules/rules.kt b/server/src/main/kotlin/dev/robocode/tankroyale/server/rules/rules.kt index 807d90657..b8edc261c 100644 --- a/server/src/main/kotlin/dev/robocode/tankroyale/server/rules/rules.kt +++ b/server/src/main/kotlin/dev/robocode/tankroyale/server/rules/rules.kt @@ -90,8 +90,14 @@ const val BONUS_PER_RAM_KILL = 0.30 /** Inactivity punishment damage per turn */ const val INACTIVITY_DAMAGE = 0.1 -/** Max. number of characters allowed for a team message */ -const val MAX_TEAM_MESSAGE_SIZE = 4096 +/** Max. UTF-8 bytes of an encoded team-message payload. */ +const val MAX_TEAM_MESSAGE_SIZE = 48 * 1024 /** Max. number of team messages per turn */ -const val MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN = 5 \ No newline at end of file +const val MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN = 128 + +/** Max. UTF-8 bytes of the compact encoded teamMessages array per bot intent. */ +const val MAX_TEAM_MESSAGES_BYTES_PER_TURN = 256 * 1024 + +/** Max. UTF-8 bytes of an incoming WebSocket text message. */ +const val MAX_INBOUND_TEXT_BYTES = 1024 * 1024 diff --git a/server/src/test/kotlin/connection/TeamMessagePolicyTest.kt b/server/src/test/kotlin/connection/TeamMessagePolicyTest.kt new file mode 100644 index 000000000..9b5a1ab93 --- /dev/null +++ b/server/src/test/kotlin/connection/TeamMessagePolicyTest.kt @@ -0,0 +1,63 @@ +package dev.robocode.tankroyale.server.connection + +import com.google.gson.JsonArray +import com.google.gson.JsonObject +import dev.robocode.tankroyale.server.rules.MAX_TEAM_MESSAGE_SIZE +import dev.robocode.tankroyale.server.rules.MAX_TEAM_MESSAGES_BYTES_PER_TURN +import io.kotest.core.Tag +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.assertions.throwables.shouldThrow +import java.nio.charset.StandardCharsets + +class TeamMessagePolicyTest : FunSpec({ + tags(Tag("Unit")) + + fun item(payload: String, recipient: Int? = null) = JsonObject().apply { + addProperty("message", payload) + addProperty("messageType", "String") + recipient?.let { addProperty("receiverId", it) } + } + + fun intent(messages: JsonArray) = JsonObject().apply { add("teamMessages", messages) } + + test("Unit: 11 and 128 messages are accepted in order; 129 are rejected") { + val messages = JsonArray() + repeat(11) { messages.add(item("message-$it")) } + TeamMessagePolicy.validate(intent(messages)) + messages.size() shouldBe 11 + messages[10].asJsonObject.get("message").asString shouldBe "message-10" + repeat(117) { messages.add(item("message-${it + 11}")) } + TeamMessagePolicy.validate(intent(messages)) + messages.add(item("message-128")) + shouldThrow { TeamMessagePolicy.validate(intent(messages)) } + } + + test("Unit: payload limit counts UTF-8 bytes including Unicode") { + TeamMessagePolicy.validate(intent(JsonArray().apply { add(item("é".repeat(MAX_TEAM_MESSAGE_SIZE / 2))) })) + shouldThrow { + TeamMessagePolicy.validate(intent(JsonArray().apply { add(item("é".repeat(MAX_TEAM_MESSAGE_SIZE / 2 + 1))) })) + } + } + + test("Unit: compact array boundary is inclusive") { + val messages = JsonArray() + repeat(5) { messages.add(item("x".repeat(45_000))) } + val baseSize = messages.toString().toByteArray(StandardCharsets.UTF_8).size + messages.add(item("")) + val overhead = messages.toString().toByteArray(StandardCharsets.UTF_8).size - baseSize + val remaining = MAX_TEAM_MESSAGES_BYTES_PER_TURN - baseSize - overhead + messages.remove(messages.size() - 1) + messages.add(item("x".repeat(remaining))) + messages.toString().toByteArray(StandardCharsets.UTF_8).size shouldBe MAX_TEAM_MESSAGES_BYTES_PER_TURN + TeamMessagePolicy.validate(intent(messages)) + messages.remove(messages.size() - 1) + messages.add(item("x".repeat(remaining + 1))) + shouldThrow { TeamMessagePolicy.validate(intent(messages)) } + } + + test("Unit: malformed message rejects the batch") { + val messages = JsonArray().apply { add(item("valid", 7)); add(JsonObject().apply { addProperty("message", 3) }) } + shouldThrow { TeamMessagePolicy.validate(intent(messages)) } + } +}) From cdc67cc4a20e9c8c55ea83b97190df34a8ce4157 Mon Sep 17 00:00:00 2001 From: "Flemming N. Larsen" Date: Tue, 22 Sep 2026 21:25:46 +0200 Subject: [PATCH 03/13] Preserve private event order and duplicates --- .../open-questions.md | 2 +- .../tankroyale/server/mapper/EventsMapper.kt | 2 +- .../robocode/tankroyale/server/model/ITurn.kt | 8 ++--- .../tankroyale/server/model/MutableTurn.kt | 10 +++--- .../robocode/tankroyale/server/model/Turn.kt | 4 +-- .../src/test/kotlin/model/MutableTurnTest.kt | 36 +++++++++++++++++++ 6 files changed, 49 insertions(+), 13 deletions(-) create mode 100644 server/src/test/kotlin/model/MutableTurnTest.kt diff --git a/changes/CH-047-team-message-limits/open-questions.md b/changes/CH-047-team-message-limits/open-questions.md index a70acc0c0..cfd8f99b6 100644 --- a/changes/CH-047-team-message-limits/open-questions.md +++ b/changes/CH-047-team-message-limits/open-questions.md @@ -12,7 +12,7 @@ title: CH-047 open questions The acceptance gate failed under the five-bot workload: each member broadcast 128 small messages for 60 turns at 30 TPS. The runner measured 40.52 TPS and captured 37,760 sent messages with 264,510 encoded payload bytes, so raw turn throughput was not the constraint. The five recipients recorded 29,440 to 29,568 received messages each, against 30,720 expected, 2 to 3 skipped turns each, and 29,187 to 29,342 ordering violations each. -`MutableTurn` stores private bot events in a `HashSet`, which makes the server's event order non-deterministic. The Bot APIs previously bounded every event queue at 256; the trial widened that handling for team messages, but delivery still fails under the workload. The trial must not publish the 128-message policy. +`MutableTurn` had stored private bot events in a `HashSet`, which made event order non-deterministic and discarded equal messages. CH-047 now carries an ordered-list repair, verified through the bot tick mapper. The repeated stress run still misses messages and records skipped turns because client event dispatch cannot drain the inbound batch within its two-turn event-age window. The trial must not publish the 128-message policy. ## Blocking decision diff --git a/server/src/main/kotlin/dev/robocode/tankroyale/server/mapper/EventsMapper.kt b/server/src/main/kotlin/dev/robocode/tankroyale/server/mapper/EventsMapper.kt index 5eef902da..0fe65928d 100644 --- a/server/src/main/kotlin/dev/robocode/tankroyale/server/mapper/EventsMapper.kt +++ b/server/src/main/kotlin/dev/robocode/tankroyale/server/mapper/EventsMapper.kt @@ -3,7 +3,7 @@ package dev.robocode.tankroyale.server.mapper import dev.robocode.tankroyale.schema.* object EventsMapper { - fun map(events: Set): List { + fun map(events: Collection): List { val mappedEvents = mutableListOf() events.forEach { mappedEvents += map(it) } return mappedEvents diff --git a/server/src/main/kotlin/dev/robocode/tankroyale/server/model/ITurn.kt b/server/src/main/kotlin/dev/robocode/tankroyale/server/model/ITurn.kt index 3cce0685c..6f85e7a87 100644 --- a/server/src/main/kotlin/dev/robocode/tankroyale/server/model/ITurn.kt +++ b/server/src/main/kotlin/dev/robocode/tankroyale/server/model/ITurn.kt @@ -16,8 +16,8 @@ interface ITurn { /** Observer events */ val observerEvents: Set - /** Map over bot events */ - val botEvents: Map> + /** Ordered events for each bot. Duplicates are significant for team messages. */ + val botEvents: Map> /** * Returns a bot instance by id. @@ -29,7 +29,7 @@ interface ITurn { /** * Returns the event for a specific bot. * @param botId is the id of the bot. - * @return a set of bot events. + * @return ordered bot events, including duplicates. */ - fun getEvents(botId: BotId): Set = botEvents[botId] ?: HashSet() + fun getEvents(botId: BotId): List = botEvents[botId] ?: emptyList() } diff --git a/server/src/main/kotlin/dev/robocode/tankroyale/server/model/MutableTurn.kt b/server/src/main/kotlin/dev/robocode/tankroyale/server/model/MutableTurn.kt index 597420035..6d75219cf 100644 --- a/server/src/main/kotlin/dev/robocode/tankroyale/server/model/MutableTurn.kt +++ b/server/src/main/kotlin/dev/robocode/tankroyale/server/model/MutableTurn.kt @@ -13,8 +13,8 @@ data class MutableTurn( /** Bullets */ override val bullets: MutableSet = mutableSetOf(), - /** Map over bot events */ - override val botEvents: MutableMap> = mutableMapOf(), + /** Ordered events for each bot. Duplicates are significant for team messages. */ + override val botEvents: MutableMap> = mutableMapOf(), /** Observer events */ override val observerEvents: MutableSet = mutableSetOf(), @@ -38,7 +38,7 @@ data class MutableTurn( * @param event is the bot event, only given to the specified bot. */ fun addPrivateBotEvent(botId: BotId, event: Event) { - botEvents.getOrPut(botId) { HashSet() }.add(event) + botEvents.getOrPut(botId) { mutableListOf() }.add(event) } /** @@ -66,8 +66,8 @@ data class MutableTurn( } /** Returns a deep copy of the bot events */ - private fun copyBotEvents(): Map> { - return botEvents.mapValues { (_, events) -> events.toSet() } + private fun copyBotEvents(): Map> { + return botEvents.mapValues { (_, events) -> events.toList() } } /** diff --git a/server/src/main/kotlin/dev/robocode/tankroyale/server/model/Turn.kt b/server/src/main/kotlin/dev/robocode/tankroyale/server/model/Turn.kt index 8dad495b8..63274f76d 100644 --- a/server/src/main/kotlin/dev/robocode/tankroyale/server/model/Turn.kt +++ b/server/src/main/kotlin/dev/robocode/tankroyale/server/model/Turn.kt @@ -16,7 +16,7 @@ data class Turn( /** Observer events */ override val observerEvents: Set, - /** Map over bot events */ - override val botEvents: Map>, + /** Ordered events for each bot. */ + override val botEvents: Map>, ) : ITurn diff --git a/server/src/test/kotlin/model/MutableTurnTest.kt b/server/src/test/kotlin/model/MutableTurnTest.kt new file mode 100644 index 000000000..5856a04f2 --- /dev/null +++ b/server/src/test/kotlin/model/MutableTurnTest.kt @@ -0,0 +1,36 @@ +package model + +import dev.robocode.tankroyale.server.event.TeamMessageEvent +import dev.robocode.tankroyale.server.mapper.TurnToTickEventForBotMapper +import dev.robocode.tankroyale.server.model.BotId +import dev.robocode.tankroyale.server.model.Bot +import dev.robocode.tankroyale.server.model.MutableTurn +import dev.robocode.tankroyale.server.model.Point +import io.kotest.core.Tag +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldContainExactly + +class MutableTurnTest : FunSpec({ + tags(Tag("Unit")) + + test("Unit: private team messages retain insertion order and duplicates") { + val recipient = BotId(2) + val sender = BotId(1) + val first = TeamMessageEvent(7, "same", "String", sender) + val second = TeamMessageEvent(7, "same", "String", sender) + val third = TeamMessageEvent(7, "last", "String", sender) + val turn = MutableTurn(7) + turn.bots += Bot(recipient, sessionId = null, position = Point(0.0, 0.0), direction = 0.0, gunDirection = 0.0, radarDirection = 0.0) + + turn.addPrivateBotEvent(recipient, first) + turn.addPrivateBotEvent(recipient, second) + turn.addPrivateBotEvent(recipient, third) + + turn.getEvents(recipient) shouldContainExactly listOf(first, second, third) + turn.toTurn().getEvents(recipient) shouldContainExactly listOf(first, second, third) + + val tick = requireNotNull(TurnToTickEventForBotMapper.map(1, turn.toTurn(), recipient, 0)) + tick.events.map { (it as dev.robocode.tankroyale.schema.TeamMessageEvent).message } + .shouldContainExactly("same", "same", "last") + } +}) From f3197327cd284c21f2446007709a347e324038eb Mon Sep 17 00:00:00 2001 From: "Flemming N. Larsen" Date: Tue, 22 Sep 2026 21:47:07 +0200 Subject: [PATCH 04/13] Preserve ordered team message events --- bot-api/dotnet/api/src/mapper/EventMapper.cs | 2 +- bot-api/dotnet/test/src/EventMapperTest.cs | 35 ++++++++++++++++ .../tankroyale/botapi/mapper/EventMapper.java | 8 ++-- .../botapi/internal/EventSystemTest.java | 18 ++++++++ .../botapi/mapper/EventMapperTest.java | 42 +++++++++++++++++++ .../open-questions.md | 6 ++- 6 files changed, 104 insertions(+), 7 deletions(-) create mode 100644 bot-api/dotnet/test/src/EventMapperTest.cs create mode 100644 bot-api/java/src/test/java/dev/robocode/tankroyale/botapi/mapper/EventMapperTest.java diff --git a/bot-api/dotnet/api/src/mapper/EventMapper.cs b/bot-api/dotnet/api/src/mapper/EventMapper.cs index ff9befa3e..f73ea2399 100644 --- a/bot-api/dotnet/api/src/mapper/EventMapper.cs +++ b/bot-api/dotnet/api/src/mapper/EventMapper.cs @@ -33,7 +33,7 @@ internal static TickEvent Map(string json, IBaseBot baseBot) private static IEnumerable Map(JArray events, IBaseBot baseBot) { - var gameEvents = new HashSet(); + var gameEvents = new List(events.Count); foreach (var jEvent in events) { var evt = (JObject)jEvent; diff --git a/bot-api/dotnet/test/src/EventMapperTest.cs b/bot-api/dotnet/test/src/EventMapperTest.cs new file mode 100644 index 000000000..1202584f2 --- /dev/null +++ b/bot-api/dotnet/test/src/EventMapperTest.cs @@ -0,0 +1,35 @@ +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using Robocode.TankRoyale.BotApi.Events; +using Robocode.TankRoyale.BotApi.Mapper; + +namespace Robocode.TankRoyale.BotApi.Tests; + +[TestFixture] +[Category("Unit")] +public class EventMapperTest +{ + [Test] + [Property("ID", "TR-API-EVT-015")] + public void PreservesOrderedDuplicateTeamMessagesFromATick() + { + const string eventJson = "{\"type\":\"TickEventForBot\",\"roundNumber\":1,\"turnNumber\":7," + + "\"botState\":{\"isDroid\":false,\"energy\":100,\"x\":0,\"y\":0,\"direction\":0,\"gunDirection\":0,\"radarDirection\":0,\"radarSweep\":0,\"speed\":0,\"turnRate\":0,\"gunTurnRate\":0,\"radarTurnRate\":0,\"gunHeat\":0,\"enemyCount\":0,\"isDebuggingEnabled\":false}," + + "\"bulletStates\":[],\"events\":[" + + "{\"type\":\"TeamMessageEvent\",\"turnNumber\":7,\"message\":\"\\\"first\\\"\",\"messageType\":\"System.String\",\"senderId\":3}," + + "{\"type\":\"TeamMessageEvent\",\"turnNumber\":7,\"message\":\"\\\"first\\\"\",\"messageType\":\"System.String\",\"senderId\":3}," + + "{\"type\":\"TeamMessageEvent\",\"turnNumber\":7,\"message\":\"\\\"last\\\"\",\"messageType\":\"System.String\",\"senderId\":3}]}"; + + var mapped = EventMapper.Map(eventJson, new TestBot()); + + Assert.That(mapped.Events.OfType().Select(e => e.Message), Is.EqualTo(new[] { "first", "first", "last" })); + } + + private sealed class TestBot : BaseBot + { + internal TestBot() : base(new BotInfo("EventMapperTest", "1.0", new List { "Test" }, null, null, null, new HashSet { "classic" }, null, null, null)) + { + } + } +} diff --git a/bot-api/java/src/main/java/dev/robocode/tankroyale/botapi/mapper/EventMapper.java b/bot-api/java/src/main/java/dev/robocode/tankroyale/botapi/mapper/EventMapper.java index f1a0b289e..56b59f7ce 100644 --- a/bot-api/java/src/main/java/dev/robocode/tankroyale/botapi/mapper/EventMapper.java +++ b/bot-api/java/src/main/java/dev/robocode/tankroyale/botapi/mapper/EventMapper.java @@ -6,9 +6,9 @@ import dev.robocode.tankroyale.botapi.events.*; import dev.robocode.tankroyale.botapi.internal.json.JsonConverter; +import java.util.ArrayList; import java.util.Collection; -import java.util.HashSet; -import java.util.Set; +import java.util.List; /** * Utility class for mapping events. @@ -28,8 +28,8 @@ public static TickEvent map(final dev.robocode.tankroyale.schema.TickEventForBot map(event.getEvents(), baseBot)); } - private static Set map(final Collection events, IBaseBot baseBot) { - Set gameBotEvents = new HashSet<>(); + private static List map(final Collection events, IBaseBot baseBot) { + List gameBotEvents = new ArrayList<>(events.size()); events.forEach(event -> gameBotEvents.add(map(event, baseBot))); return gameBotEvents; } diff --git a/bot-api/java/src/test/java/dev/robocode/tankroyale/botapi/internal/EventSystemTest.java b/bot-api/java/src/test/java/dev/robocode/tankroyale/botapi/internal/EventSystemTest.java index 209b366ab..66482e727 100644 --- a/bot-api/java/src/test/java/dev/robocode/tankroyale/botapi/internal/EventSystemTest.java +++ b/bot-api/java/src/test/java/dev/robocode/tankroyale/botapi/internal/EventSystemTest.java @@ -10,6 +10,7 @@ import java.net.URI; import java.util.*; +import java.util.stream.IntStream; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.*; @@ -239,6 +240,23 @@ void test_TR_API_EVT_001_event_constructors() { assertEquals(condition, ce.getCondition()); } + @Test + @Tag("Unit") + void drainsAnAcceptedTeamMessageBatchInOrder() { + for (int index = 0; index < 512; index++) { + queue.addEvent(new TeamMessageEvent(7, "message-" + index, 1)); + } + + queue.dispatchEvents(7); + + assertThat(botStub.firedEvents) + .hasSize(512) + .allMatch(event -> event instanceof TeamMessageEvent); + assertThat(botStub.firedEvents.stream() + .map(event -> ((TeamMessageEvent) event).getMessage())) + .containsExactly(IntStream.range(0, 512).mapToObj(index -> "message-" + index).toArray()); + } + @Test @Tag("TR-API-EVT-008") void test_TR_API_EVT_008_condition_test_callable() { diff --git a/bot-api/java/src/test/java/dev/robocode/tankroyale/botapi/mapper/EventMapperTest.java b/bot-api/java/src/test/java/dev/robocode/tankroyale/botapi/mapper/EventMapperTest.java new file mode 100644 index 000000000..48ea4a3dc --- /dev/null +++ b/bot-api/java/src/test/java/dev/robocode/tankroyale/botapi/mapper/EventMapperTest.java @@ -0,0 +1,42 @@ +package dev.robocode.tankroyale.botapi.mapper; + +import dev.robocode.tankroyale.botapi.BaseBot; +import dev.robocode.tankroyale.botapi.events.TeamMessageEvent; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@Tag("Unit") +class EventMapperTest { + + @Test + void preservesOrderedDuplicateTeamMessagesFromATick() { + var first = teamMessage("first"); + var duplicate = teamMessage("first"); + var last = teamMessage("last"); + var tick = new dev.robocode.tankroyale.schema.TickEventForBot(); + tick.setTurnNumber(7); + tick.setRoundNumber(1); + tick.setBulletStates(List.of()); + tick.setEvents(List.of(first, duplicate, last)); + + var mapped = EventMapper.map(tick, new BaseBot() {}); + + assertThat(mapped.getEvents()).hasSize(3) + .allMatch(TeamMessageEvent.class::isInstance) + .extracting(event -> ((TeamMessageEvent) event).getMessage()) + .containsExactly("first", "first", "last"); + } + + private static dev.robocode.tankroyale.schema.TeamMessageEvent teamMessage(String message) { + var event = new dev.robocode.tankroyale.schema.TeamMessageEvent(); + event.setTurnNumber(7); + event.setSenderId(3); + event.setMessageType(String.class.getName()); + event.setMessage('"' + message + '"'); + return event; + } +} diff --git a/changes/CH-047-team-message-limits/open-questions.md b/changes/CH-047-team-message-limits/open-questions.md index cfd8f99b6..b96c68c56 100644 --- a/changes/CH-047-team-message-limits/open-questions.md +++ b/changes/CH-047-team-message-limits/open-questions.md @@ -12,8 +12,10 @@ title: CH-047 open questions The acceptance gate failed under the five-bot workload: each member broadcast 128 small messages for 60 turns at 30 TPS. The runner measured 40.52 TPS and captured 37,760 sent messages with 264,510 encoded payload bytes, so raw turn throughput was not the constraint. The five recipients recorded 29,440 to 29,568 received messages each, against 30,720 expected, 2 to 3 skipped turns each, and 29,187 to 29,342 ordering violations each. -`MutableTurn` had stored private bot events in a `HashSet`, which made event order non-deterministic and discarded equal messages. CH-047 now carries an ordered-list repair, verified through the bot tick mapper. The repeated stress run still misses messages and records skipped turns because client event dispatch cannot drain the inbound batch within its two-turn event-age window. The trial must not publish the 128-message policy. +`MutableTurn` had stored private bot events in a `HashSet`, which made event order non-deterministic and discarded equal messages. The Java and .NET Bot APIs then converted the ordered tick events into `HashSet`s, with the same effect. CH-047 now keeps ordered lists across both routes, and the Java mapper regression preserves duplicate messages in order. + +With that repair and a 100 ms test deadline, the five-bot workload delivered all 30,720 messages to every recipient in order, with no skipped turns, at 43.08 TPS. At the normal 30 ms deadline, the workload still failed: each recipient received 29,184 messages, recorded 3 skipped turns, and the runner measured 40.46 TPS. A focused Java Bot API test also drains 512 ordered team messages in one dispatch, so the two-turn event age is not established as the source of the remaining loss. The trial must not publish the 128-message policy. ## Blocking decision -Should the next trial first preserve ordered private events and then retest a lower per-turn message count, or should it keep the 128-message target and introduce a separately bounded inbound event queue with an explicit overflow rule? The acceptance gate requires a human decision before either policy is adopted. +Should the next trial lower the per-turn message count until the normal 30 ms deadline has no skipped turns, or should it change the game turn deadline and demonstrate sustained 30 TPS under that new deadline? The acceptance gate requires a human decision before either policy is adopted. From a73ce6b63ff3a200575b0dca9eff7b4c0de588c8 Mon Sep 17 00:00:00 2001 From: "Flemming N. Larsen" Date: Tue, 22 Sep 2026 21:52:56 +0200 Subject: [PATCH 05/13] Align team message API documentation --- bot-api/dotnet/api/src/Constants.cs | 10 +++--- bot-api/dotnet/api/src/IBaseBot.cs | 18 ++++++---- .../robocode/tankroyale/botapi/IBaseBot.java | 16 +++++---- .../robocode_tank_royale/bot_api/base_bot.py | 10 ++++-- .../bot_api/base_bot_abc.py | 33 ++++++++++--------- .../robocode_tank_royale/bot_api/constants.py | 8 ++--- bot-api/typescript/src/Constants.ts | 6 ++-- bot-api/typescript/src/IBaseBot.ts | 14 ++++++-- 8 files changed, 68 insertions(+), 47 deletions(-) diff --git a/bot-api/dotnet/api/src/Constants.cs b/bot-api/dotnet/api/src/Constants.cs index cfc3e65af..39f0857ef 100644 --- a/bot-api/dotnet/api/src/Constants.cs +++ b/bot-api/dotnet/api/src/Constants.cs @@ -149,18 +149,18 @@ public static class Constants public const double StartingGunHeat = 3.0; /// - /// The maximum size of a team message in bytes (32 KB), which is the serialized (compact JSON) + /// The maximum size of a team message in UTF-8 bytes (48 KiB), which is the serialized (compact JSON) /// size of the message object. Messages exceeding this size are rejected. /// - /// The maximum team message size in bytes, which is 32768. + /// The maximum team message size in UTF-8 bytes, which is 49152. public const int TeamMessageMaxSize = 48 * 1024; /// - /// The maximum number of team messages that can be sent per turn, which is 10 messages. + /// The maximum number of team messages that can be sent per turn, which is 128 messages. /// - /// The maximum number of team messages per turn, which is 10. + /// The maximum number of team messages per turn, which is 128. public const int MaxNumberOfTeamMessagesPerTurn = 128; - /// Maximum UTF-8 bytes of the compact encoded teamMessages array per turn. + /// Maximum UTF-8 bytes of the compact encoded teamMessages array per turn (256 KiB). public const int TeamMessagesMaxBytesPerTurn = 256 * 1024; } diff --git a/bot-api/dotnet/api/src/IBaseBot.cs b/bot-api/dotnet/api/src/IBaseBot.cs index a031b557c..9b1bf964d 100644 --- a/bot-api/dotnet/api/src/IBaseBot.cs +++ b/bot-api/dotnet/api/src/IBaseBot.cs @@ -22,7 +22,7 @@ public interface IBaseBot /// const int MaxNumberOfTeamMessagesPerTurn = Constants.MaxNumberOfTeamMessagesPerTurn; - /// Maximum UTF-8 bytes of the compact encoded teamMessages array per turn. + /// Maximum UTF-8 bytes of the compact encoded teamMessages array per turn (256 KiB). const int TeamMessagesMaxBytesPerTurn = Constants.TeamMessagesMaxBytesPerTurn; /// @@ -735,11 +735,13 @@ public interface IBaseBot /// The maximum team message size limit is defined by . This size is the size of the /// message when it is serialized into a JSON representation. /// - /// The maximum number of messages that can be send/broadcast per turn is defined by - /// . + /// A turn can contain at most messages, and the compact UTF-8 encoded + /// team-message array can contain at most bytes. Each accepted message + /// is delivered on the next turn. A failed call does not enqueue its message. /// /// The message to broadcast. - /// if the size of the message exceeds the size limit. + /// If the per-turn message count has been reached. + /// If the message or complete batch exceeds its UTF-8 byte limit. /// /// void BroadcastTeamMessage(object message); @@ -753,12 +755,14 @@ public interface IBaseBot /// The maximum team message size limit is defined by . This size is the size of the /// message when it is serialized into a JSON representation. /// - /// The maximum number of messages that can be send/broadcast per turn is defined by - /// . + /// A turn can contain at most messages, and the compact UTF-8 encoded + /// team-message array can contain at most bytes. Each accepted message + /// is delivered on the next turn. A failed call does not enqueue its message. /// /// The id of the teammate to send the message to. /// The message to broadcast. - /// if the size of the message exceeds the size limit. + /// If the per-turn message count has been reached. + /// If the recipient is invalid or the message or complete batch exceeds its UTF-8 byte limit. /// /// void SendTeamMessage(int teammateId, object message); diff --git a/bot-api/java/src/main/java/dev/robocode/tankroyale/botapi/IBaseBot.java b/bot-api/java/src/main/java/dev/robocode/tankroyale/botapi/IBaseBot.java index c89c307b2..d04f84c56 100644 --- a/bot-api/java/src/main/java/dev/robocode/tankroyale/botapi/IBaseBot.java +++ b/bot-api/java/src/main/java/dev/robocode/tankroyale/botapi/IBaseBot.java @@ -851,11 +851,13 @@ public interface IBaseBot { * {@value Constants#TEAM_MESSAGE_MAX_SIZE} bytes. This size is the size of the message when it is serialized into a * JSON representation.
*
- * The maximum number of messages that can be send/broadcast per turn is limited to - * {@value Constants#MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN}. + * A turn can contain at most {@value Constants#MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN} messages, and the compact + * UTF-8 encoded team-message array can contain at most {@value Constants#TEAM_MESSAGES_MAX_BYTES_PER_TURN} bytes. + * Each accepted message is delivered on the next turn. A failed call does not enqueue its message. * * @param message is the message to broadcast. - * @throws IllegalArgumentException if the size of the message exceeds the size limit. + * @throws BotException if the per-turn message count has been reached. + * @throws IllegalArgumentException if the message or complete batch exceeds its UTF-8 byte limit. * @see #sendTeamMessage * @see #getTeammateIds */ @@ -871,12 +873,14 @@ public interface IBaseBot { * {@value Constants#TEAM_MESSAGE_MAX_SIZE} bytes. This size is the size of the message when it is serialized into a * JSON representation.
*
- * The maximum number of messages that can be sent/broadcast per turn is limited to - * {@value Constants#MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN}. + * A turn can contain at most {@value Constants#MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN} messages, and the compact + * UTF-8 encoded team-message array can contain at most {@value Constants#TEAM_MESSAGES_MAX_BYTES_PER_TURN} bytes. + * Each accepted message is delivered on the next turn. A failed call does not enqueue its message. * * @param teammateId is the id of the teammate to send the message to. * @param message is the message to send. - * @throws IllegalArgumentException if the size of the message exceeds the size limit. + * @throws BotException if the per-turn message count has been reached. + * @throws IllegalArgumentException if the recipient is invalid or the message or complete batch exceeds its UTF-8 byte limit. * @see #broadcastTeamMessage * @see #getTeammateIds */ diff --git a/bot-api/python/src/robocode_tank_royale/bot_api/base_bot.py b/bot-api/python/src/robocode_tank_royale/bot_api/base_bot.py index fec45695b..46497b4a2 100644 --- a/bot-api/python/src/robocode_tank_royale/bot_api/base_bot.py +++ b/bot-api/python/src/robocode_tank_royale/bot_api/base_bot.py @@ -698,7 +698,10 @@ def is_teammate(self, bot_id: int) -> bool: return self._internals.is_teammate(bot_id) def broadcast_team_message(self, message: Any) -> None: - """Broadcasts a message to all teammates. + """Broadcasts a compact JSON message to all teammates for delivery on the next turn. + + A turn accepts at most 128 messages, each up to 48 KiB UTF-8, and up to 256 KiB for the complete compact array. + A failed call does not enqueue its message. Args: message: The message to broadcast. @@ -707,7 +710,10 @@ def broadcast_team_message(self, message: Any) -> None: self._internals.broadcast_team_message(message) def send_team_message(self, teammate_id: int, message: Any) -> None: - """Sends a message to a specific teammate. + """Sends a compact JSON message to a teammate for delivery on the next turn. + + A turn accepts at most 128 messages, each up to 48 KiB UTF-8, and up to 256 KiB for the complete compact array. + A failed call does not enqueue its message. Args: teammate_id: The ID of the teammate to send the message to. diff --git a/bot-api/python/src/robocode_tank_royale/bot_api/base_bot_abc.py b/bot-api/python/src/robocode_tank_royale/bot_api/base_bot_abc.py index 93f925eb8..f0fd2ba67 100644 --- a/bot-api/python/src/robocode_tank_royale/bot_api/base_bot_abc.py +++ b/bot-api/python/src/robocode_tank_royale/bot_api/base_bot_abc.py @@ -17,10 +17,13 @@ class BaseBotABC(ABC): """ TEAM_MESSAGE_MAX_SIZE: int = _C.TEAM_MESSAGE_MAX_SIZE - """Maximum size of a team message, which is 32 KB.""" + """Maximum UTF-8 bytes of one compact JSON-encoded team message (48 KiB).""" MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN: int = _C.MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN - """The maximum number of team messages that can be sent per turn, which is 10 messages.""" + """Maximum number of team messages that can be sent per turn (128).""" + + TEAM_MESSAGES_MAX_BYTES_PER_TURN: int = _C.TEAM_MESSAGES_MAX_BYTES_PER_TURN + """Maximum UTF-8 bytes of the compact encoded team-message array per turn (256 KiB).""" @abstractmethod def start(self) -> None: @@ -891,17 +894,17 @@ def broadcast_team_message(self, message: Any) -> None: When the message is sent, it is serialized into a JSON representation. This means that all public fields, and only public fields, are serialized into a JSON representation as a data transfer object (DTO). - The maximum team message size limit is defined by `TEAM_MESSAGE_MAX_SIZE`, which is set to 32,768 bytes. - This size is calculated after serializing the message into a JSON representation. - - The maximum number of messages that can be broadcast per turn is limited to `MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN`, - which is set to 10. + A turn accepts at most `MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN` messages. Each compact JSON message uses at most + `TEAM_MESSAGE_MAX_SIZE` UTF-8 bytes, and the complete compact message array uses at most + `TEAM_MESSAGES_MAX_BYTES_PER_TURN` UTF-8 bytes. Accepted messages are delivered on the next turn. A failed call + does not enqueue its message. Args: message: The message to broadcast. Raises: - ValueError: If the size of the message exceeds the size limit. + BotException: If the per-turn message count has been reached. + ValueError: If the message or complete batch exceeds its UTF-8 byte limit. See Also: send_team_message: Method to send a message to teammates. @@ -918,20 +921,18 @@ def send_team_message(self, teammate_id: int, message: Any) -> None: meaning that all public fields, and only public fields, are being serialized into a JSON representation as a DTO (data transfer object). - The maximum team message size limit is defined by - `TEAM_MESSAGE_MAX_SIZE`, which is set to - `TEAM_MESSAGE_MAX_SIZE` bytes. This size is the size of the message - when it is serialized into a JSON representation. - - The maximum number of messages that can be sent/broadcast per turn is - limited to `MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN`. + A turn accepts at most `MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN` messages. Each compact JSON message uses at most + `TEAM_MESSAGE_MAX_SIZE` UTF-8 bytes, and the complete compact message array uses at most + `TEAM_MESSAGES_MAX_BYTES_PER_TURN` UTF-8 bytes. Accepted messages are delivered on the next turn. A failed call + does not enqueue its message. Args: teammate_id: The id of the teammate to send the message to. message: The message to send. Raises: - ValueError: If the size of the message exceeds the size limit. + BotException: If the per-turn message count has been reached. + ValueError: If the recipient is invalid or the message or complete batch exceeds its UTF-8 byte limit. """ pass diff --git a/bot-api/python/src/robocode_tank_royale/bot_api/constants.py b/bot-api/python/src/robocode_tank_royale/bot_api/constants.py index 31656931b..a3d8dade3 100644 --- a/bot-api/python/src/robocode_tank_royale/bot_api/constants.py +++ b/bot-api/python/src/robocode_tank_royale/bot_api/constants.py @@ -94,13 +94,11 @@ MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN = 128 """ -Maximum number of team messages that can be sent per turn. +Maximum number of team messages that can be sent per turn (128). """ TEAM_MESSAGE_MAX_SIZE = 48 * 1024 +"""Maximum UTF-8 bytes of one compact JSON-encoded team message (48 KiB).""" TEAM_MESSAGES_MAX_BYTES_PER_TURN = 256 * 1024 -"""Maximum UTF-8 bytes of the compact encoded teamMessages array per turn.""" -""" -Maximum size of a team message in bytes (32 KB). -""" +"""Maximum UTF-8 bytes of the compact encoded teamMessages array per turn (256 KiB).""" diff --git a/bot-api/typescript/src/Constants.ts b/bot-api/typescript/src/Constants.ts index 483f9715e..0da28050f 100644 --- a/bot-api/typescript/src/Constants.ts +++ b/bot-api/typescript/src/Constants.ts @@ -122,16 +122,16 @@ export const Constants = { STARTING_GUN_HEAT: 3.0, /** - * The maximum size of a team message in bytes (32 KB), which is the serialized (compact JSON) + * The maximum size of a team message in UTF-8 bytes (48 KiB), which is the serialized (compact JSON) * size of the message object. Messages exceeding this size are rejected. */ TEAM_MESSAGE_MAX_SIZE: 48 * 1024, /** - * The maximum number of team messages that can be sent per turn, which is 10 messages. + * The maximum number of team messages that can be sent per turn, which is 128 messages. */ MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN: 128, - /** Maximum UTF-8 bytes of the compact encoded teamMessages array per turn. */ + /** Maximum UTF-8 bytes of the compact encoded teamMessages array per turn (256 KiB). */ TEAM_MESSAGES_MAX_BYTES_PER_TURN: 256 * 1024, } as const; diff --git a/bot-api/typescript/src/IBaseBot.ts b/bot-api/typescript/src/IBaseBot.ts index d3a086515..e06c960db 100644 --- a/bot-api/typescript/src/IBaseBot.ts +++ b/bot-api/typescript/src/IBaseBot.ts @@ -35,7 +35,7 @@ export interface IBaseBot { /** The maximum number of team messages that can be sent per turn. */ readonly MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN: number; - /** Maximum UTF-8 bytes of the compact encoded teamMessages array per turn. */ + /** Maximum UTF-8 bytes of the compact encoded teamMessages array per turn (256 KiB). */ readonly TEAM_MESSAGES_MAX_BYTES_PER_TURN: number; /** Starts the bot, connecting to the server and running until the game ends. */ @@ -233,10 +233,18 @@ export interface IBaseBot { /** Returns whether the given bot ID is a teammate. */ isTeammate(botId: number): boolean; - /** Broadcasts a message to all teammates. */ + /** + * Broadcasts a compact JSON message to all teammates for delivery on the next turn. + * A turn accepts at most 128 messages, each up to 48 KiB UTF-8, and up to 256 KiB for the complete compact array. + * Throws without enqueueing this message when any limit is exceeded. + */ broadcastTeamMessage(message: unknown): void; - /** Sends a message to a specific teammate. */ + /** + * Sends a compact JSON message to a teammate for delivery on the next turn. + * A turn accepts at most 128 messages, each up to 48 KiB UTF-8, and up to 256 KiB for the complete compact array. + * Throws without enqueueing this message when the recipient is invalid or any limit is exceeded. + */ sendTeamMessage(teammateId: number, message: unknown): void; /** The body color of the bot. */ From bcf4a93e5bb279b0188ed37f80b956c85fed838b Mon Sep 17 00:00:00 2001 From: "Flemming N. Larsen" Date: Tue, 22 Sep 2026 21:59:24 +0200 Subject: [PATCH 06/13] Align team message limit API tests --- bot-api/dotnet/api/src/internal/IntentValidator.cs | 2 +- bot-api/dotnet/test/src/TeamMessageLimitsTest.cs | 3 ++- .../tankroyale/botapi/internal/TeamMessageLimitsTest.java | 4 +++- .../bot_api/internal/intent_validator.py | 2 +- bot-api/python/tests/test_team_message_limits.py | 4 +++- bot-api/tests/shared/constants.json | 8 ++++++++ bot-api/typescript/src/IBaseBot.ts | 5 +++++ bot-api/typescript/src/index.ts | 1 + bot-api/typescript/src/internal/intentValidator.ts | 3 ++- bot-api/typescript/test/TeamMessageLimits.test.ts | 4 +++- 10 files changed, 29 insertions(+), 7 deletions(-) diff --git a/bot-api/dotnet/api/src/internal/IntentValidator.cs b/bot-api/dotnet/api/src/internal/IntentValidator.cs index 2e7f5fe63..a36b3c4b8 100644 --- a/bot-api/dotnet/api/src/internal/IntentValidator.cs +++ b/bot-api/dotnet/api/src/internal/IntentValidator.cs @@ -118,7 +118,7 @@ public static void ValidateTeammateId(int? teammateId, ICollection teammate public static void ValidateTeamMessage(object message, int currentTeamMessageCount) { if (currentTeamMessageCount >= IBaseBot.MaxNumberOfTeamMessagesPerTurn) - throw new InvalidOperationException( + throw new BotException( "The maximum number of team messages has already been reached: " + IBaseBot.MaxNumberOfTeamMessagesPerTurn); diff --git a/bot-api/dotnet/test/src/TeamMessageLimitsTest.cs b/bot-api/dotnet/test/src/TeamMessageLimitsTest.cs index 40032bd11..3f531e224 100644 --- a/bot-api/dotnet/test/src/TeamMessageLimitsTest.cs +++ b/bot-api/dotnet/test/src/TeamMessageLimitsTest.cs @@ -11,8 +11,9 @@ public class TeamMessageLimitsTest [Test] public void CountBoundary() { + Assert.DoesNotThrow(() => IntentValidator.ValidateTeamMessage("hello", 10)); Assert.DoesNotThrow(() => IntentValidator.ValidateTeamMessage("hello", Constants.MaxNumberOfTeamMessagesPerTurn - 1)); - Assert.Throws(() => IntentValidator.ValidateTeamMessage("hello", Constants.MaxNumberOfTeamMessagesPerTurn)); + Assert.Throws(() => IntentValidator.ValidateTeamMessage("hello", Constants.MaxNumberOfTeamMessagesPerTurn)); } [Test] diff --git a/bot-api/java/src/test/java/dev/robocode/tankroyale/botapi/internal/TeamMessageLimitsTest.java b/bot-api/java/src/test/java/dev/robocode/tankroyale/botapi/internal/TeamMessageLimitsTest.java index 39e6d5844..d38da2c5e 100644 --- a/bot-api/java/src/test/java/dev/robocode/tankroyale/botapi/internal/TeamMessageLimitsTest.java +++ b/bot-api/java/src/test/java/dev/robocode/tankroyale/botapi/internal/TeamMessageLimitsTest.java @@ -1,5 +1,6 @@ package dev.robocode.tankroyale.botapi.internal; +import dev.robocode.tankroyale.botapi.BotException; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; @@ -10,8 +11,9 @@ class TeamMessageLimitsTest { @Test @Tag("Unit") void accepts128MessagesAndRejects129th() { + assertDoesNotThrow(() -> IntentValidator.validateTeamMessage("hello", 10)); assertDoesNotThrow(() -> IntentValidator.validateTeamMessage("hello", 127)); - assertThrows(RuntimeException.class, () -> IntentValidator.validateTeamMessage("hello", 128)); + assertThrows(BotException.class, () -> IntentValidator.validateTeamMessage("hello", 128)); } @Test @Tag("Unit") diff --git a/bot-api/python/src/robocode_tank_royale/bot_api/internal/intent_validator.py b/bot-api/python/src/robocode_tank_royale/bot_api/internal/intent_validator.py index 0ddc1de46..e6fb50880 100644 --- a/bot-api/python/src/robocode_tank_royale/bot_api/internal/intent_validator.py +++ b/bot-api/python/src/robocode_tank_royale/bot_api/internal/intent_validator.py @@ -129,7 +129,7 @@ def validate_teammate_id(teammate_id: Optional[int], teammate_ids: Set[int]) -> def validate_team_message(message: Any, current_team_message_count: int) -> None: if current_team_message_count >= MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN: raise BotException( - f"The maximum number team messages has already been reached: {MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN}" + f"The maximum number of team messages has already been reached: {MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN}" ) if message is None: raise ValueError("The 'message' of a team message cannot be null") diff --git a/bot-api/python/tests/test_team_message_limits.py b/bot-api/python/tests/test_team_message_limits.py index b8606f8bb..56d00773a 100644 --- a/bot-api/python/tests/test_team_message_limits.py +++ b/bot-api/python/tests/test_team_message_limits.py @@ -5,14 +5,16 @@ TEAM_MESSAGE_MAX_SIZE, TEAM_MESSAGES_MAX_BYTES_PER_TURN, ) +from robocode_tank_royale.bot_api.bot_exception import BotException from robocode_tank_royale.bot_api.internal.intent_validator import IntentValidator pytestmark = pytest.mark.Unit def test_count_boundary() -> None: + IntentValidator.validate_team_message("hello", 10) IntentValidator.validate_team_message("hello", MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN - 1) - with pytest.raises(Exception): + with pytest.raises(BotException): IntentValidator.validate_team_message("hello", MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN) diff --git a/bot-api/tests/shared/constants.json b/bot-api/tests/shared/constants.json index d72fe9d21..75dd81e93 100644 --- a/bot-api/tests/shared/constants.json +++ b/bot-api/tests/shared/constants.json @@ -113,6 +113,14 @@ "method": "getConstant", "args": ["MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN"], "expected": { "returns": 128 } + }, + { + "id": "TR-API-VAL-005-constants-team-messages-max-bytes-per-turn", + "description": "Maximum aggregate team message bytes per turn", + "type": "positive", + "method": "getConstant", + "args": ["TEAM_MESSAGES_MAX_BYTES_PER_TURN"], + "expected": { "returns": 262144 } } ] } diff --git a/bot-api/typescript/src/IBaseBot.ts b/bot-api/typescript/src/IBaseBot.ts index e06c960db..21df67f51 100644 --- a/bot-api/typescript/src/IBaseBot.ts +++ b/bot-api/typescript/src/IBaseBot.ts @@ -25,6 +25,7 @@ import { SkippedTurnEvent } from "./events/SkippedTurnEvent.js"; import { WonRoundEvent } from "./events/WonRoundEvent.js"; import { CustomEvent } from "./events/CustomEvent.js"; import { TeamMessageEvent } from "./events/TeamMessageEvent.js"; +import { BotException } from "./BotException.js"; /** * Interface containing the core API for a bot. */ @@ -237,6 +238,8 @@ export interface IBaseBot { * Broadcasts a compact JSON message to all teammates for delivery on the next turn. * A turn accepts at most 128 messages, each up to 48 KiB UTF-8, and up to 256 KiB for the complete compact array. * Throws without enqueueing this message when any limit is exceeded. + * @throws {BotException} When the per-turn message count has been reached. + * @throws {Error} When the message or complete batch exceeds its UTF-8 byte limit. */ broadcastTeamMessage(message: unknown): void; @@ -244,6 +247,8 @@ export interface IBaseBot { * Sends a compact JSON message to a teammate for delivery on the next turn. * A turn accepts at most 128 messages, each up to 48 KiB UTF-8, and up to 256 KiB for the complete compact array. * Throws without enqueueing this message when the recipient is invalid or any limit is exceeded. + * @throws {BotException} When the per-turn message count has been reached. + * @throws {Error} When the recipient is invalid or the message or complete batch exceeds its UTF-8 byte limit. */ sendTeamMessage(teammateId: number, message: unknown): void; diff --git a/bot-api/typescript/src/index.ts b/bot-api/typescript/src/index.ts index c4700ac2f..be7bceace 100644 --- a/bot-api/typescript/src/index.ts +++ b/bot-api/typescript/src/index.ts @@ -21,6 +21,7 @@ export type { IBaseBot } from "./IBaseBot.js"; export type { IBot } from "./IBot.js"; export { BaseBot } from "./BaseBot.js"; export { Bot } from "./Bot.js"; +export { BotException } from "./BotException.js"; // Events export { BotEvent } from "./events/BotEvent.js"; diff --git a/bot-api/typescript/src/internal/intentValidator.ts b/bot-api/typescript/src/internal/intentValidator.ts index ab7ecd413..318b41119 100644 --- a/bot-api/typescript/src/internal/intentValidator.ts +++ b/bot-api/typescript/src/internal/intentValidator.ts @@ -2,6 +2,7 @@ import { MathUtil } from "../util/MathUtil.js"; import { Color } from "../graphics/Color.js"; import { ColorUtil } from "../util/ColorUtil.js"; import { Constants } from "../Constants.js"; +import { BotException } from "../BotException.js"; export class IntentValidator { static validateFirepower(firepower: number): number { @@ -97,7 +98,7 @@ export class IntentValidator { static validateTeamMessage(message: unknown, currentTeamMessageCount: number): void { if (currentTeamMessageCount >= Constants.MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN) { - throw new Error("The maximum number of team messages has already been reached: " + Constants.MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN); + throw new BotException("The maximum number of team messages has already been reached: " + Constants.MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN); } if (message === null || message === undefined) { throw new Error("The 'message' of a team message cannot be null"); diff --git a/bot-api/typescript/test/TeamMessageLimits.test.ts b/bot-api/typescript/test/TeamMessageLimits.test.ts index 2c3ec6767..74216ed61 100644 --- a/bot-api/typescript/test/TeamMessageLimits.test.ts +++ b/bot-api/typescript/test/TeamMessageLimits.test.ts @@ -1,11 +1,13 @@ import { describe, expect, test } from "vitest"; import { Constants } from "../src/Constants.js"; import { IntentValidator } from "../src/internal/intentValidator.js"; +import { BotException } from "../src/BotException.js"; describe("Unit: team message limits", () => { test("accepts 128 messages and rejects the 129th", () => { + expect(() => IntentValidator.validateTeamMessage("hello", 10)).not.toThrow(); expect(() => IntentValidator.validateTeamMessage("hello", Constants.MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN - 1)).not.toThrow(); - expect(() => IntentValidator.validateTeamMessage("hello", Constants.MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN)).toThrow(); + expect(() => IntentValidator.validateTeamMessage("hello", Constants.MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN)).toThrow(BotException); }); test("counts Unicode payload bytes", () => { From ee091b2ed7ebbd274649d1472f249b9816455710 Mon Sep 17 00:00:00 2001 From: "Flemming N. Larsen" Date: Tue, 22 Sep 2026 22:17:53 +0200 Subject: [PATCH 07/13] Trial lower team message count --- bot-api/dotnet/api/src/Constants.cs | 6 +++--- bot-api/dotnet/api/src/IBaseBot.cs | 2 +- .../main/java/dev/robocode/tankroyale/botapi/Constants.java | 4 ++-- .../tankroyale/botapi/internal/TeamMessageLimitsTest.java | 6 +++--- bot-api/python/src/robocode_tank_royale/bot_api/base_bot.py | 4 ++-- .../python/src/robocode_tank_royale/bot_api/base_bot_abc.py | 2 +- .../python/src/robocode_tank_royale/bot_api/constants.py | 4 ++-- .../bots/java/TeamMessageLoadBot/TeamMessageLoadBot.java | 6 +++--- bot-api/tests/shared/constants.json | 2 +- bot-api/typescript/src/Constants.ts | 4 ++-- bot-api/typescript/src/IBaseBot.ts | 4 ++-- bot-api/typescript/test/TeamMessageLimits.test.ts | 2 +- changes/CH-047-team-message-limits/open-questions.md | 6 +++++- changes/CH-047-team-message-limits/proposal.md | 2 +- changes/CH-047-team-message-limits/tasks.md | 2 +- .../tankroyale/runner/TeamMessageLoadIntegrationTest.kt | 2 +- schema/schemas/bot-intent.schema.yaml | 4 ++-- .../kotlin/dev/robocode/tankroyale/server/rules/rules.kt | 2 +- server/src/test/kotlin/connection/TeamMessagePolicyTest.kt | 6 +++--- 19 files changed, 37 insertions(+), 33 deletions(-) diff --git a/bot-api/dotnet/api/src/Constants.cs b/bot-api/dotnet/api/src/Constants.cs index 39f0857ef..efea8a8c4 100644 --- a/bot-api/dotnet/api/src/Constants.cs +++ b/bot-api/dotnet/api/src/Constants.cs @@ -156,10 +156,10 @@ public static class Constants public const int TeamMessageMaxSize = 48 * 1024; /// - /// The maximum number of team messages that can be sent per turn, which is 128 messages. + /// The maximum number of team messages that can be sent per turn, which is 64 messages. /// - /// The maximum number of team messages per turn, which is 128. - public const int MaxNumberOfTeamMessagesPerTurn = 128; + /// The maximum number of team messages per turn, which is 64. + public const int MaxNumberOfTeamMessagesPerTurn = 64; /// Maximum UTF-8 bytes of the compact encoded teamMessages array per turn (256 KiB). public const int TeamMessagesMaxBytesPerTurn = 256 * 1024; diff --git a/bot-api/dotnet/api/src/IBaseBot.cs b/bot-api/dotnet/api/src/IBaseBot.cs index 9b1bf964d..a5f9a0cbd 100644 --- a/bot-api/dotnet/api/src/IBaseBot.cs +++ b/bot-api/dotnet/api/src/IBaseBot.cs @@ -18,7 +18,7 @@ public interface IBaseBot const int TeamMessageMaxSize = Constants.TeamMessageMaxSize; // bytes /// - /// The maximum number of team messages that can be sent per turn, which is 128 messages. + /// The maximum number of team messages that can be sent per turn, which is 64 messages. /// const int MaxNumberOfTeamMessagesPerTurn = Constants.MaxNumberOfTeamMessagesPerTurn; diff --git a/bot-api/java/src/main/java/dev/robocode/tankroyale/botapi/Constants.java b/bot-api/java/src/main/java/dev/robocode/tankroyale/botapi/Constants.java index 05b96f560..bbfc60a74 100644 --- a/bot-api/java/src/main/java/dev/robocode/tankroyale/botapi/Constants.java +++ b/bot-api/java/src/main/java/dev/robocode/tankroyale/botapi/Constants.java @@ -136,9 +136,9 @@ private Constants() { public static final int TEAM_MESSAGE_MAX_SIZE = 48 * 1024; /** - * The maximum number of team messages that can be sent per turn, which is 128 messages. + * The maximum number of team messages that can be sent per turn, which is 64 messages. */ - public static final int MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN = 128; + public static final int MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN = 64; /** Maximum UTF-8 bytes of the compact encoded teamMessages array per turn (256 KiB). */ public static final int TEAM_MESSAGES_MAX_BYTES_PER_TURN = 256 * 1024; diff --git a/bot-api/java/src/test/java/dev/robocode/tankroyale/botapi/internal/TeamMessageLimitsTest.java b/bot-api/java/src/test/java/dev/robocode/tankroyale/botapi/internal/TeamMessageLimitsTest.java index d38da2c5e..7d92aaff0 100644 --- a/bot-api/java/src/test/java/dev/robocode/tankroyale/botapi/internal/TeamMessageLimitsTest.java +++ b/bot-api/java/src/test/java/dev/robocode/tankroyale/botapi/internal/TeamMessageLimitsTest.java @@ -10,10 +10,10 @@ class TeamMessageLimitsTest { @Test @Tag("Unit") - void accepts128MessagesAndRejects129th() { + void accepts64MessagesAndRejects65th() { assertDoesNotThrow(() -> IntentValidator.validateTeamMessage("hello", 10)); - assertDoesNotThrow(() -> IntentValidator.validateTeamMessage("hello", 127)); - assertThrows(BotException.class, () -> IntentValidator.validateTeamMessage("hello", 128)); + assertDoesNotThrow(() -> IntentValidator.validateTeamMessage("hello", MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN - 1)); + assertThrows(BotException.class, () -> IntentValidator.validateTeamMessage("hello", MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN)); } @Test @Tag("Unit") diff --git a/bot-api/python/src/robocode_tank_royale/bot_api/base_bot.py b/bot-api/python/src/robocode_tank_royale/bot_api/base_bot.py index 46497b4a2..c874439cf 100644 --- a/bot-api/python/src/robocode_tank_royale/bot_api/base_bot.py +++ b/bot-api/python/src/robocode_tank_royale/bot_api/base_bot.py @@ -700,7 +700,7 @@ def is_teammate(self, bot_id: int) -> bool: def broadcast_team_message(self, message: Any) -> None: """Broadcasts a compact JSON message to all teammates for delivery on the next turn. - A turn accepts at most 128 messages, each up to 48 KiB UTF-8, and up to 256 KiB for the complete compact array. + A turn accepts at most 64 messages, each up to 48 KiB UTF-8, and up to 256 KiB for the complete compact array. A failed call does not enqueue its message. Args: @@ -712,7 +712,7 @@ def broadcast_team_message(self, message: Any) -> None: def send_team_message(self, teammate_id: int, message: Any) -> None: """Sends a compact JSON message to a teammate for delivery on the next turn. - A turn accepts at most 128 messages, each up to 48 KiB UTF-8, and up to 256 KiB for the complete compact array. + A turn accepts at most 64 messages, each up to 48 KiB UTF-8, and up to 256 KiB for the complete compact array. A failed call does not enqueue its message. Args: diff --git a/bot-api/python/src/robocode_tank_royale/bot_api/base_bot_abc.py b/bot-api/python/src/robocode_tank_royale/bot_api/base_bot_abc.py index f0fd2ba67..1f440e831 100644 --- a/bot-api/python/src/robocode_tank_royale/bot_api/base_bot_abc.py +++ b/bot-api/python/src/robocode_tank_royale/bot_api/base_bot_abc.py @@ -20,7 +20,7 @@ class BaseBotABC(ABC): """Maximum UTF-8 bytes of one compact JSON-encoded team message (48 KiB).""" MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN: int = _C.MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN - """Maximum number of team messages that can be sent per turn (128).""" + """Maximum number of team messages that can be sent per turn (64).""" TEAM_MESSAGES_MAX_BYTES_PER_TURN: int = _C.TEAM_MESSAGES_MAX_BYTES_PER_TURN """Maximum UTF-8 bytes of the compact encoded team-message array per turn (256 KiB).""" diff --git a/bot-api/python/src/robocode_tank_royale/bot_api/constants.py b/bot-api/python/src/robocode_tank_royale/bot_api/constants.py index a3d8dade3..7dffac563 100644 --- a/bot-api/python/src/robocode_tank_royale/bot_api/constants.py +++ b/bot-api/python/src/robocode_tank_royale/bot_api/constants.py @@ -92,9 +92,9 @@ to zero, cooling at the rate defined by the game setup's gun cooling rate. """ -MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN = 128 +MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN = 64 """ -Maximum number of team messages that can be sent per turn (128). +Maximum number of team messages that can be sent per turn (64). """ TEAM_MESSAGE_MAX_SIZE = 48 * 1024 diff --git a/bot-api/tests/bots/java/TeamMessageLoadBot/TeamMessageLoadBot.java b/bot-api/tests/bots/java/TeamMessageLoadBot/TeamMessageLoadBot.java index 3b7da3837..e82813ed2 100644 --- a/bot-api/tests/bots/java/TeamMessageLoadBot/TeamMessageLoadBot.java +++ b/bot-api/tests/bots/java/TeamMessageLoadBot/TeamMessageLoadBot.java @@ -8,7 +8,7 @@ import java.util.HashMap; import java.util.Map; -/** Local trial workload: five copies broadcast 128 messages on each of 60 turns. */ +/** Local trial workload: five copies broadcast 64 messages on each of 60 turns. */ public class TeamMessageLoadBot extends Bot { private final Map lastBySender = new HashMap<>(); private int received; @@ -23,7 +23,7 @@ public static void main(String[] args) { public void run() { while (isRunning()) { if (getTurnNumber() <= 60) { - for (int index = 0; index < 128; index++) { + for (int index = 0; index < 64; index++) { broadcastTeamMessage(getTurnNumber() + ":" + index); } } @@ -44,7 +44,7 @@ public void onTeamMessage(TeamMessageEvent event) { var parts = prior.split(":"); int expectedTurn = Integer.parseInt(parts[0]); int expectedIndex = Integer.parseInt(parts[1]) + 1; - if (expectedIndex == 128) { expectedIndex = 0; expectedTurn++; } + if (expectedIndex == 64) { expectedIndex = 0; expectedTurn++; } if (!payload.equals(expectedTurn + ":" + expectedIndex)) outOfOrder++; } } diff --git a/bot-api/tests/shared/constants.json b/bot-api/tests/shared/constants.json index 75dd81e93..da75bea4d 100644 --- a/bot-api/tests/shared/constants.json +++ b/bot-api/tests/shared/constants.json @@ -112,7 +112,7 @@ "type": "positive", "method": "getConstant", "args": ["MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN"], - "expected": { "returns": 128 } + "expected": { "returns": 64 } }, { "id": "TR-API-VAL-005-constants-team-messages-max-bytes-per-turn", diff --git a/bot-api/typescript/src/Constants.ts b/bot-api/typescript/src/Constants.ts index 0da28050f..c41d4b8c4 100644 --- a/bot-api/typescript/src/Constants.ts +++ b/bot-api/typescript/src/Constants.ts @@ -128,9 +128,9 @@ export const Constants = { TEAM_MESSAGE_MAX_SIZE: 48 * 1024, /** - * The maximum number of team messages that can be sent per turn, which is 128 messages. + * The maximum number of team messages that can be sent per turn, which is 64 messages. */ - MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN: 128, + MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN: 64, /** Maximum UTF-8 bytes of the compact encoded teamMessages array per turn (256 KiB). */ TEAM_MESSAGES_MAX_BYTES_PER_TURN: 256 * 1024, diff --git a/bot-api/typescript/src/IBaseBot.ts b/bot-api/typescript/src/IBaseBot.ts index 21df67f51..925c45cc4 100644 --- a/bot-api/typescript/src/IBaseBot.ts +++ b/bot-api/typescript/src/IBaseBot.ts @@ -236,7 +236,7 @@ export interface IBaseBot { /** * Broadcasts a compact JSON message to all teammates for delivery on the next turn. - * A turn accepts at most 128 messages, each up to 48 KiB UTF-8, and up to 256 KiB for the complete compact array. + * A turn accepts at most 64 messages, each up to 48 KiB UTF-8, and up to 256 KiB for the complete compact array. * Throws without enqueueing this message when any limit is exceeded. * @throws {BotException} When the per-turn message count has been reached. * @throws {Error} When the message or complete batch exceeds its UTF-8 byte limit. @@ -245,7 +245,7 @@ export interface IBaseBot { /** * Sends a compact JSON message to a teammate for delivery on the next turn. - * A turn accepts at most 128 messages, each up to 48 KiB UTF-8, and up to 256 KiB for the complete compact array. + * A turn accepts at most 64 messages, each up to 48 KiB UTF-8, and up to 256 KiB for the complete compact array. * Throws without enqueueing this message when the recipient is invalid or any limit is exceeded. * @throws {BotException} When the per-turn message count has been reached. * @throws {Error} When the recipient is invalid or the message or complete batch exceeds its UTF-8 byte limit. diff --git a/bot-api/typescript/test/TeamMessageLimits.test.ts b/bot-api/typescript/test/TeamMessageLimits.test.ts index 74216ed61..baac19d1a 100644 --- a/bot-api/typescript/test/TeamMessageLimits.test.ts +++ b/bot-api/typescript/test/TeamMessageLimits.test.ts @@ -4,7 +4,7 @@ import { IntentValidator } from "../src/internal/intentValidator.js"; import { BotException } from "../src/BotException.js"; describe("Unit: team message limits", () => { - test("accepts 128 messages and rejects the 129th", () => { + test("accepts 64 messages and rejects the 65th", () => { expect(() => IntentValidator.validateTeamMessage("hello", 10)).not.toThrow(); expect(() => IntentValidator.validateTeamMessage("hello", Constants.MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN - 1)).not.toThrow(); expect(() => IntentValidator.validateTeamMessage("hello", Constants.MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN)).toThrow(BotException); diff --git a/changes/CH-047-team-message-limits/open-questions.md b/changes/CH-047-team-message-limits/open-questions.md index b96c68c56..a38d8e7d7 100644 --- a/changes/CH-047-team-message-limits/open-questions.md +++ b/changes/CH-047-team-message-limits/open-questions.md @@ -16,6 +16,10 @@ The acceptance gate failed under the five-bot workload: each member broadcast 12 With that repair and a 100 ms test deadline, the five-bot workload delivered all 30,720 messages to every recipient in order, with no skipped turns, at 43.08 TPS. At the normal 30 ms deadline, the workload still failed: each recipient received 29,184 messages, recorded 3 skipped turns, and the runner measured 40.46 TPS. A focused Java Bot API test also drains 512 ordered team messages in one dispatch, so the two-turn event age is not established as the source of the remaining loss. The trial must not publish the 128-message policy. +## Revised 64-message trial — 2026-09-22 + +The five-bot workload at 64 messages per bot per turn also failed at the normal 30 ms deadline. The runner measured 57.49 TPS and captured 19,200 sent messages with 128,520 payload bytes. Four recipients received 14,912 of 15,360 expected messages and recorded 6 ordering violations and 2 skipped turns; the fifth received 14,848 messages, with 4 ordering violations and 1 skipped turn. The reduced count improves throughput but does not meet the zero-skipped-turn acceptance gate. + ## Blocking decision -Should the next trial lower the per-turn message count until the normal 30 ms deadline has no skipped turns, or should it change the game turn deadline and demonstrate sustained 30 TPS under that new deadline? The acceptance gate requires a human decision before either policy is adopted. +Should the next trial lower the per-turn message count again, starting at 32, or should it profile and remove the remaining deadline misses before selecting another limit? The acceptance gate requires a human decision before either policy is adopted. diff --git a/changes/CH-047-team-message-limits/proposal.md b/changes/CH-047-team-message-limits/proposal.md index 3005a3ffa..8ab7b32b8 100644 --- a/changes/CH-047-team-message-limits/proposal.md +++ b/changes/CH-047-team-message-limits/proposal.md @@ -10,7 +10,7 @@ title: Trial bounded team-message throughput across Tank Royale ## What and why -Classic teams can send more messages in a turn than Tank Royale currently forwards. The Bot APIs, server, and schema disagree about both count and size, so a client can accept a message that the server silently drops. Trial one policy for every Tank Royale bot: at most 128 messages and 256 KiB of compact, UTF-8-encoded `teamMessages` array data per bot per turn, and at most 48 KiB for each encoded message. Bound inbound WebSocket text to 1 MiB before JSON parsing. Reject an invalid intent in full rather than delivering a prefix. +Classic teams can send more messages in a turn than Tank Royale currently forwards. The Bot APIs, server, and schema disagree about both count and size, so a client can accept a message that the server silently drops. The first 128-message trial skipped turns under the normal deadline. Trial a revised policy for every Tank Royale bot: at most 64 messages and 256 KiB of compact, UTF-8-encoded `teamMessages` array data per bot per turn, and at most 48 KiB for each encoded message. Bound inbound WebSocket text to 1 MiB before JSON parsing. Reject an invalid intent in full rather than delivering a prefix. The user supplied this trial plan. It is plan-less in the Tank Royale corpus; accepted messaging criteria will be added only after the trial passes. The trial uses local builds only. The policy becomes publishable only if boundary, malformed-client, CombatTeam, and five-bot 30 TPS stress evidence pass. diff --git a/changes/CH-047-team-message-limits/tasks.md b/changes/CH-047-team-message-limits/tasks.md index f82b99a7d..b0499342f 100644 --- a/changes/CH-047-team-message-limits/tasks.md +++ b/changes/CH-047-team-message-limits/tasks.md @@ -12,5 +12,5 @@ title: CH-047 implementation tasks - [ ] Align schema and server validation, including the pre-parse WebSocket text bound (TML-001, TML-002, TML-003, TML-004). - [ ] Align Java, Python, .NET, and TypeScript enqueue validation and API documentation (TML-001, TML-002, TML-003). - [ ] Run boundary, malformed-client, and delivery tests against matched local builds (TML-001, TML-002, TML-003, TML-004). -- [-] Run CombatTeam and five-bot stress at 30 TPS; record turn, skip, and traffic measurements and apply the acceptance gate (TML-004). The five-bot trial failed: delivery was incomplete and unordered, and recipients recorded skipped turns. A human decision on revised limits is required before further policy work. +- [-] Run CombatTeam and five-bot stress at 30 TPS; record turn, skip, and traffic measurements and apply the acceptance gate (TML-004). The 128-message and revised 64-message five-bot trials failed: delivery was incomplete and recipients recorded skipped turns. A human decision on the next limit or profiling work is required before further policy work. - [ ] After the gate passes, update the Tank Royale article, bridge evidence, and book pages; run relevant checks (TML-001, TML-002, TML-004). diff --git a/runner/src/test/kotlin/dev/robocode/tankroyale/runner/TeamMessageLoadIntegrationTest.kt b/runner/src/test/kotlin/dev/robocode/tankroyale/runner/TeamMessageLoadIntegrationTest.kt index 7d693db7d..cda18823c 100644 --- a/runner/src/test/kotlin/dev/robocode/tankroyale/runner/TeamMessageLoadIntegrationTest.kt +++ b/runner/src/test/kotlin/dev/robocode/tankroyale/runner/TeamMessageLoadIntegrationTest.kt @@ -67,7 +67,7 @@ class TeamMessageLoadIntegrationTest { assertTrue(fields[0] >= 62) assertEquals(0, fields[2], "Message order failed for $path") assertEquals(0, fields[3], "Skipped turns recorded by $path") - assertTrue(fields[1] >= 4 * 128 * 60, "Missing team messages for $path: ${fields[1]}") + assertTrue(fields[1] >= 4 * 64 * 60, "Missing team messages for $path: ${fields[1]}") } } } diff --git a/schema/schemas/bot-intent.schema.yaml b/schema/schemas/bot-intent.schema.yaml index a49f01245..8af916705 100644 --- a/schema/schemas/bot-intent.schema.yaml +++ b/schema/schemas/bot-intent.schema.yaml @@ -85,9 +85,9 @@ properties: description: New text received from standard error (stderr) type: string teamMessages: - description: Messages delivered in order on the next turn. At most 128 per bot per turn; the compact UTF-8 encoded array is limited to 262144 bytes. An invalid intent is rejected in full. + description: Messages delivered in order on the next turn. At most 64 per bot per turn; the compact UTF-8 encoded array is limited to 262144 bytes. An invalid intent is rejected in full. type: array - maxItems: 128 + maxItems: 64 items: $ref: team-message.schema.yaml debugGraphics: diff --git a/server/src/main/kotlin/dev/robocode/tankroyale/server/rules/rules.kt b/server/src/main/kotlin/dev/robocode/tankroyale/server/rules/rules.kt index b8edc261c..e671ff916 100644 --- a/server/src/main/kotlin/dev/robocode/tankroyale/server/rules/rules.kt +++ b/server/src/main/kotlin/dev/robocode/tankroyale/server/rules/rules.kt @@ -94,7 +94,7 @@ const val INACTIVITY_DAMAGE = 0.1 const val MAX_TEAM_MESSAGE_SIZE = 48 * 1024 /** Max. number of team messages per turn */ -const val MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN = 128 +const val MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN = 64 /** Max. UTF-8 bytes of the compact encoded teamMessages array per bot intent. */ const val MAX_TEAM_MESSAGES_BYTES_PER_TURN = 256 * 1024 diff --git a/server/src/test/kotlin/connection/TeamMessagePolicyTest.kt b/server/src/test/kotlin/connection/TeamMessagePolicyTest.kt index 9b5a1ab93..9973e0c08 100644 --- a/server/src/test/kotlin/connection/TeamMessagePolicyTest.kt +++ b/server/src/test/kotlin/connection/TeamMessagePolicyTest.kt @@ -21,15 +21,15 @@ class TeamMessagePolicyTest : FunSpec({ fun intent(messages: JsonArray) = JsonObject().apply { add("teamMessages", messages) } - test("Unit: 11 and 128 messages are accepted in order; 129 are rejected") { + test("Unit: 11 and 64 messages are accepted in order; 65 are rejected") { val messages = JsonArray() repeat(11) { messages.add(item("message-$it")) } TeamMessagePolicy.validate(intent(messages)) messages.size() shouldBe 11 messages[10].asJsonObject.get("message").asString shouldBe "message-10" - repeat(117) { messages.add(item("message-${it + 11}")) } + repeat(53) { messages.add(item("message-${it + 11}")) } TeamMessagePolicy.validate(intent(messages)) - messages.add(item("message-128")) + messages.add(item("message-64")) shouldThrow { TeamMessagePolicy.validate(intent(messages)) } } From e63584a368ec1204f0743f5eb0d1870b1c276594 Mon Sep 17 00:00:00 2001 From: "Flemming N. Larsen" Date: Wed, 23 Sep 2026 22:39:56 +0200 Subject: [PATCH 08/13] Add ordered team message batches with review fixes Adds TeamMessageBatch APIs (Java, .NET, Python, TypeScript), batch protocol version 1, and server-side count and byte limits for team messages (CH-047). Review fixes: - .NET EventQueue sorts stably so same-turn team messages keep their arrival order (TR-API-EVT-010). - All four event queues drop team messages older than MAX_EVENT_AGE when a newer event is added, bounding undispatched messages (TR-API-EVT-011). - Server checks team-message receivers against the game-start roster, so messaging a teammate that disconnected no longer closes the sender (PRO-009). - Python TeamMessageBatch accepts one-shot iterables such as generators. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 16 +++ VERSION | 2 +- booter/build.gradle.kts | 2 + bot-api/dotnet/TEAM_MESSAGES_GUIDE.md | 24 +++- bot-api/dotnet/api/src/BaseBot.cs | 10 +- bot-api/dotnet/api/src/Constants.cs | 11 +- bot-api/dotnet/api/src/IBaseBot.cs | 36 ++++-- bot-api/dotnet/api/src/TeamMessageBatch.cs | 25 ++++ .../api/src/internal/BaseBotInternals.cs | 8 +- .../api/src/internal/BotHandshakeFactory.cs | 3 +- bot-api/dotnet/api/src/internal/EventQueue.cs | 13 ++- .../api/src/internal/IntentValidator.cs | 7 ++ .../api/src/internal/TeamMessageBatchCodec.cs | 33 ++++++ bot-api/dotnet/api/src/mapper/EventMapper.cs | 16 +++ .../dotnet/test/src/TeamMessageBatchTest.cs | 36 ++++++ .../test/src/internal/EventSystemTest.cs | 2 + .../test/src/internal/SharedTestRunner.cs | 20 +++- bot-api/dotnet/test/src/internal/TestBot.cs | 2 + .../robocode/tankroyale/botapi/BaseBot.java | 12 ++ .../robocode/tankroyale/botapi/Constants.java | 10 +- .../robocode/tankroyale/botapi/IBaseBot.java | 43 ++++++- .../tankroyale/botapi/TeamMessageBatch.java | 27 +++++ .../botapi/internal/BaseBotInternals.java | 17 ++- .../botapi/internal/BotHandshakeFactory.java | 1 + .../botapi/internal/EventQueue.java | 9 ++ .../botapi/internal/IntentValidator.java | 7 ++ .../internal/TeamMessageBatchCodec.java | 41 +++++++ .../tankroyale/botapi/mapper/EventMapper.java | 15 +++ .../botapi/TeamMessageBatchTest.java | 38 +++++++ .../botapi/internal/EventSystemTest.java | 2 + .../botapi/internal/SharedTestRunner.java | 24 +++- bot-api/python/pyproject.toml | 1 + .../robocode_tank_royale/bot_api/__init__.py | 2 + .../robocode_tank_royale/bot_api/base_bot.py | 22 +++- .../bot_api/base_bot_abc.py | 41 +++++-- .../robocode_tank_royale/bot_api/constants.py | 8 +- .../bot_api/internal/base_bot_internals.py | 19 +++- .../bot_api/internal/bot_handshake_factory.py | 1 + .../bot_api/internal/event_queue.py | 8 ++ .../bot_api/internal/intent_validator.py | 8 ++ .../bot_api/mapper/event_mapper.py | 8 ++ .../bot_api/team_message_batch.py | 20 ++++ .../tests/bot_api/test_team_message_batch.py | 33 ++++++ bot-api/python/tests/test_shared.py | 14 ++- bot-api/tests/TEST-REGISTRY.md | 7 +- bot-api/tests/shared/constants.json | 8 ++ bot-api/tests/shared/event-queue.json | 26 +++++ bot-api/tests/shared/team-message-batch.json | 30 +++++ .../tests/shared/test-definition.schema.json | 10 ++ bot-api/typescript/README.md | 2 +- bot-api/typescript/package-lock.json | 4 +- bot-api/typescript/package.json | 2 +- bot-api/typescript/src/BaseBot.ts | 4 + bot-api/typescript/src/BotHandshakeFactory.ts | 1 + bot-api/typescript/src/Constants.ts | 10 +- bot-api/typescript/src/IBaseBot.ts | 19 +++- bot-api/typescript/src/TeamMessageBatch.ts | 13 +++ bot-api/typescript/src/events/EventQueue.ts | 8 ++ bot-api/typescript/src/index.ts | 1 + .../src/internal/BaseBotInternals.ts | 18 ++- .../src/internal/intentValidator.ts | 6 + bot-api/typescript/src/mapper/EventMapper.ts | 8 ++ bot-api/typescript/src/protocol/schema.ts | 1 + bot-api/typescript/src/version.ts | 2 +- .../typescript/test/SharedTestRunner.test.ts | 16 ++- .../typescript/test/TeamMessageBatch.test.ts | 19 ++++ build.gradle.kts | 1 + .../open-questions.md | 12 +- .../CH-047-team-message-limits/proposal.md | 12 +- changes/CH-047-team-message-limits/tasks.md | 13 ++- .../models/message-schema/README.md | 2 +- .../models/message-schema/intents.md | 16 ++- .../capabilities/CAP-006-protocol/criteria.md | 33 ++++++ docs/capabilities/CAP-006-protocol/design.md | 6 +- .../runner/BattleRunnerIntegrationTest.kt | 107 ++++++++++++++++++ .../TeamMessageBatchStress.java | 105 +++++++++++++++++ .../TeamMessageBatchStress.json | 9 ++ .../TeamMessageBatchStressTeam.json | 14 +++ schema/schemas/bot-handshake.schema.yaml | 6 +- schema/schemas/bot-intent.schema.yaml | 2 +- schema/schemas/team-message-event.schema.yaml | 17 ++- schema/schemas/team-message.schema.yaml | 9 +- server/build.gradle.kts | 2 + .../server/connection/TeamMessagePolicy.kt | 71 ++++++++++++ .../tankroyale/server/core/GameServer.kt | 27 ++++- .../robocode/tankroyale/server/rules/rules.kt | 3 + .../connection/TeamMessagePolicyTest.kt | 70 ++++++++++++ .../connection/TeamMessageRawClientTest.kt | 75 ++++++++++++ .../src/test/kotlin/core/GameLifecycleTest.kt | 49 ++++++++ web/docs/articles/team-messages.md | 86 +++++++++++++- 90 files changed, 1539 insertions(+), 120 deletions(-) create mode 100644 bot-api/dotnet/api/src/TeamMessageBatch.cs create mode 100644 bot-api/dotnet/api/src/internal/TeamMessageBatchCodec.cs create mode 100644 bot-api/dotnet/test/src/TeamMessageBatchTest.cs create mode 100644 bot-api/java/src/main/java/dev/robocode/tankroyale/botapi/TeamMessageBatch.java create mode 100644 bot-api/java/src/main/java/dev/robocode/tankroyale/botapi/internal/TeamMessageBatchCodec.java create mode 100644 bot-api/java/src/test/java/dev/robocode/tankroyale/botapi/TeamMessageBatchTest.java create mode 100644 bot-api/python/src/robocode_tank_royale/bot_api/team_message_batch.py create mode 100644 bot-api/python/tests/bot_api/test_team_message_batch.py create mode 100644 bot-api/tests/shared/team-message-batch.json create mode 100644 bot-api/typescript/src/TeamMessageBatch.ts create mode 100644 bot-api/typescript/test/TeamMessageBatch.test.ts create mode 100644 sample-bots/java/TeamMessageBatchStress/TeamMessageBatchStress.java create mode 100644 sample-bots/java/TeamMessageBatchStress/TeamMessageBatchStress.json create mode 100644 sample-bots/java/TeamMessageBatchStressTeam/TeamMessageBatchStressTeam.json create mode 100644 server/src/test/kotlin/connection/TeamMessageRawClientTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 387ec0088..fca306dee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,19 @@ +## [1.4.0] - 2026-09-23 - Batched team messages + +### ✨ Features + +- Bot API (Java, .NET, Python, TypeScript): Added ordered `TeamMessageBatch` APIs for broadcast and directed team messages. A batch is delivered as one event on the next turn. Team messages from the same turn are handled in the order they were sent, do not count toward the 256-event queue limit, and are discarded once older than two turns. +- Server and Bot APIs: Accepted up to 64 team-message packets and 128 logical payloads per bot per turn, with a 48 KiB UTF-8 limit per encoded packet and a 256 KiB UTF-8 limit for the compact `teamMessages` array. Invalid client calls fail before enqueue; invalid server intents are rejected as a whole. Messages to a teammate that has left the game are dropped without disconnecting the sender. +- Server: Added batch protocol version 1 to bot handshakes. A sender can use batches only when every recipient advertises support. + +### 📚 Documentation + +- Documented batch usage and the count, byte, delivery, compatibility, and rejection rules across the Bot APIs and protocol references. + +### 🐞 Bug Fixes + +- Build: R8 shrink tasks now track their intermediate jars and version input, so rebuilt runner distributions embed the matching server and booter versions. + ## [1.3.1] - 2026-09-13 - Server jar startup fix ### 🐞 Bug Fixes diff --git a/VERSION b/VERSION index 3a3cd8cc8..88c5fb891 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.1 +1.4.0 diff --git a/booter/build.gradle.kts b/booter/build.gradle.kts index f637ea912..0f3161be0 100644 --- a/booter/build.gradle.kts +++ b/booter/build.gradle.kts @@ -70,6 +70,8 @@ tasks { val r8ShrinkTask by registering(JavaExec::class) { // R8 shrinking task (kept name for compatibility) dependsOn(jar) + inputs.files(file(intermediateJar), file("r8-rules.pro")) + inputs.property("version", project.version) outputs.file(finalJar) doFirst { diff --git a/bot-api/dotnet/TEAM_MESSAGES_GUIDE.md b/bot-api/dotnet/TEAM_MESSAGES_GUIDE.md index 93205758d..d2833e053 100644 --- a/bot-api/dotnet/TEAM_MESSAGES_GUIDE.md +++ b/bot-api/dotnet/TEAM_MESSAGES_GUIDE.md @@ -146,11 +146,31 @@ public override void OnTeamMessage(TeamMessageEvent evt) ## Limitations -- Maximum 10 team messages per turn per bot -- Maximum message size: 32,768 bytes (JSON format) +- At most 64 packets per bot per turn, with a combined limit of 128 logical messages counting batch entries +- Each packet is limited to 49,152 UTF-8 bytes, and the compact `teamMessages` array is limited to 262,144 UTF-8 bytes +- Packets are delivered on the next turn; a batch arrives as one event and retains entry order +- All team members must advertise batch version 1 or the server rejects a batch intent +- Clients reject invalid sends before enqueueing them; the server rejects an invalid intent in full - Messages must be serializable to JSON (no circular references) - Complex objects may require custom JSON converters +## Sending Several Messages as One Batch + +```csharp +SendTeamMessageBatch(teammateId, new object[] { new Point(250, 300), new Point(400, 180) }); + +public override void OnTeamMessage(TeamMessageEvent evt) +{ + if (evt.Message is TeamMessageBatch batch) + { + foreach (var message in batch.Messages) + { + // Process entries in their original order. + } + } +} +``` + ## Advanced: Custom Message Types For complex scenarios, you can define custom message types with type discriminators: diff --git a/bot-api/dotnet/api/src/BaseBot.cs b/bot-api/dotnet/api/src/BaseBot.cs index c47c51c90..b647c3027 100644 --- a/bot-api/dotnet/api/src/BaseBot.cs +++ b/bot-api/dotnet/api/src/BaseBot.cs @@ -384,10 +384,18 @@ public bool AdjustRadarForGunTurn /// public void BroadcastTeamMessage(object message) => BaseBotInternals.BroadcastTeamMessage(message); + /// + public void BroadcastTeamMessageBatch(System.Collections.IEnumerable messages) => + BaseBotInternals.BroadcastTeamMessage(new TeamMessageBatch(messages)); + /// public void SendTeamMessage(int teammateId, object message) => BaseBotInternals.SendTeamMessage(teammateId, message); + /// + public void SendTeamMessageBatch(int teammateId, System.Collections.IEnumerable messages) => + BaseBotInternals.SendTeamMessage(teammateId, new TeamMessageBatch(messages)); + /// public Color? BodyColor { @@ -637,4 +645,4 @@ public virtual void OnCustomEvent(CustomEvent customEvent) public virtual void OnTeamMessage(TeamMessageEvent teamMessageEvent) { } -} \ No newline at end of file +} diff --git a/bot-api/dotnet/api/src/Constants.cs b/bot-api/dotnet/api/src/Constants.cs index efea8a8c4..bec44b284 100644 --- a/bot-api/dotnet/api/src/Constants.cs +++ b/bot-api/dotnet/api/src/Constants.cs @@ -149,18 +149,21 @@ public static class Constants public const double StartingGunHeat = 3.0; /// - /// The maximum size of a team message in UTF-8 bytes (48 KiB), which is the serialized (compact JSON) - /// size of the message object. Messages exceeding this size are rejected. + /// The maximum size of one team-message packet in UTF-8 bytes (48 KiB), measured on its compact JSON payload. + /// Messages exceeding this size are rejected. /// /// The maximum team message size in UTF-8 bytes, which is 49152. public const int TeamMessageMaxSize = 48 * 1024; /// - /// The maximum number of team messages that can be sent per turn, which is 64 messages. + /// The maximum number of team-message packets that can be sent per turn, which is 64. /// - /// The maximum number of team messages per turn, which is 64. + /// The maximum number of team-message packets per turn, which is 64. public const int MaxNumberOfTeamMessagesPerTurn = 64; + /// The maximum logical payloads across ordinary messages and batch entries per turn, which is 128. + public const int MaxLogicalTeamMessagesPerTurn = 128; + /// Maximum UTF-8 bytes of the compact encoded teamMessages array per turn (256 KiB). public const int TeamMessagesMaxBytesPerTurn = 256 * 1024; } diff --git a/bot-api/dotnet/api/src/IBaseBot.cs b/bot-api/dotnet/api/src/IBaseBot.cs index a5f9a0cbd..0f6a76cfd 100644 --- a/bot-api/dotnet/api/src/IBaseBot.cs +++ b/bot-api/dotnet/api/src/IBaseBot.cs @@ -18,10 +18,13 @@ public interface IBaseBot const int TeamMessageMaxSize = Constants.TeamMessageMaxSize; // bytes /// - /// The maximum number of team messages that can be sent per turn, which is 64 messages. + /// The maximum number of physical team-message packets that can be sent per turn, which is 64. /// const int MaxNumberOfTeamMessagesPerTurn = Constants.MaxNumberOfTeamMessagesPerTurn; + /// Maximum logical payloads per turn, counting each batch entry, which is 128. + const int MaxLogicalTeamMessagesPerTurn = Constants.MaxLogicalTeamMessagesPerTurn; + /// Maximum UTF-8 bytes of the compact encoded teamMessages array per turn (256 KiB). const int TeamMessagesMaxBytesPerTurn = Constants.TeamMessagesMaxBytesPerTurn; @@ -735,9 +738,11 @@ public interface IBaseBot /// The maximum team message size limit is defined by . This size is the size of the /// message when it is serialized into a JSON representation. /// - /// A turn can contain at most messages, and the compact UTF-8 encoded - /// team-message array can contain at most bytes. Each accepted message - /// is delivered on the next turn. A failed call does not enqueue its message. + /// A turn accepts at most packets and + /// logical payloads, counting batch entries. Each packet is limited to + /// UTF-8 bytes and the compact array to + /// UTF-8 bytes. A batch is delivered as one event with entries in order on the next turn. All recipients must + /// support batch version 1. A failed call does not enqueue its packet. /// /// The message to broadcast. /// If the per-turn message count has been reached. @@ -746,6 +751,13 @@ public interface IBaseBot /// void BroadcastTeamMessage(object message); + /// + /// Broadcasts ordered messages as one packet and one event on the next turn. A batch counts as one per-turn + /// packet and each entry counts toward the 128 logical-payload limit. Every recipient must support batch version + /// 1. Empty batches, null entries, and packets or intents over the documented limits are rejected before enqueue. + /// + void BroadcastTeamMessageBatch(System.Collections.IEnumerable messages); + /// /// Sends a message to a specific teammate. /// @@ -755,9 +767,11 @@ public interface IBaseBot /// The maximum team message size limit is defined by . This size is the size of the /// message when it is serialized into a JSON representation. /// - /// A turn can contain at most messages, and the compact UTF-8 encoded - /// team-message array can contain at most bytes. Each accepted message - /// is delivered on the next turn. A failed call does not enqueue its message. + /// A turn accepts at most packets and + /// logical payloads, counting batch entries. Each packet is limited to + /// UTF-8 bytes and the compact array to + /// UTF-8 bytes. A batch is delivered as one event with entries in order on the next turn. All recipients must + /// support batch version 1. A failed call does not enqueue its packet. /// /// The id of the teammate to send the message to. /// The message to broadcast. @@ -767,6 +781,14 @@ public interface IBaseBot /// void SendTeamMessage(int teammateId, object message); + /// + /// Sends ordered messages to one teammate as one packet and one event on the next turn. A batch counts as one + /// per-turn packet and each entry counts toward the 128 logical-payload limit. The recipient must support batch + /// version 1. Empty batches, null entries, and packets or intents over the documented limits are rejected before + /// enqueue. + /// + void SendTeamMessageBatch(int teammateId, System.Collections.IEnumerable messages); + /// /// The color of the body. Colors can (only) be changed each turn. /// diff --git a/bot-api/dotnet/api/src/TeamMessageBatch.cs b/bot-api/dotnet/api/src/TeamMessageBatch.cs new file mode 100644 index 000000000..d76f5c6df --- /dev/null +++ b/bot-api/dotnet/api/src/TeamMessageBatch.cs @@ -0,0 +1,25 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; + +namespace Robocode.TankRoyale.BotApi; + +/// Immutable ordered, non-null payloads delivered as one team-message event on the next turn. +public sealed class TeamMessageBatch +{ + /// Returns the ordered batch payloads. + public IReadOnlyList Messages { get; } + + /// Creates a batch from a non-empty sequence of non-null payloads. + /// Payloads in delivery order. + /// The sequence is empty or contains a null payload. + public TeamMessageBatch(IEnumerable messages) + { + if (messages == null) throw new ArgumentException("A team message batch must contain at least one message"); + var items = messages.Cast().ToArray(); + if (items.Length == 0) throw new ArgumentException("A team message batch must contain at least one message"); + if (items.Any(item => item == null)) throw new ArgumentException("A team message batch cannot contain null messages"); + Messages = Array.AsReadOnly(items); + } +} diff --git a/bot-api/dotnet/api/src/internal/BaseBotInternals.cs b/bot-api/dotnet/api/src/internal/BaseBotInternals.cs index 2e5fd88f9..82e79f5e8 100644 --- a/bot-api/dotnet/api/src/internal/BaseBotInternals.cs +++ b/bot-api/dotnet/api/src/internal/BaseBotInternals.cs @@ -808,16 +808,20 @@ internal void SendTeamMessage(int? teammateId, object message) IntentValidator.ValidateTeammateId(teammateId, TeammateIds); IntentValidator.ValidateTeamMessage(message, BotIntent.TeamMessages?.Count ?? 0); - var json = JsonConverter.ToJson(message); + var isBatch = message is TeamMessageBatch; + var json = isBatch ? TeamMessageBatchCodec.Encode((TeamMessageBatch)message) : JsonConverter.ToJson(message); IntentValidator.ValidateTeamMessageSize(json); var teamMessage = new S.TeamMessage { - MessageType = message.GetType().ToString(), + MessageType = isBatch ? TeamMessageBatchCodec.MessageType : message.GetType().ToString(), Message = json, ReceiverId = teammateId, }; var candidateMessages = new System.Collections.Generic.List(BotIntent.TeamMessages) { teamMessage }; + var logicalCount = candidateMessages.Sum(item => item.MessageType == TeamMessageBatchCodec.MessageType + ? TeamMessageBatchCodec.DecodeItems(item.Message).Count : 1); + IntentValidator.ValidateLogicalTeamMessageCount(logicalCount); IntentValidator.ValidateTeamMessagesSize(JsonConverter.ToJson(candidateMessages)); BotIntent.TeamMessages.Add(teamMessage); } diff --git a/bot-api/dotnet/api/src/internal/BotHandshakeFactory.cs b/bot-api/dotnet/api/src/internal/BotHandshakeFactory.cs index c9c9f4d51..ef6c1a8fa 100644 --- a/bot-api/dotnet/api/src/internal/BotHandshakeFactory.cs +++ b/bot-api/dotnet/api/src/internal/BotHandshakeFactory.cs @@ -32,6 +32,7 @@ internal static BotHandshake Create(string sessionId, BotInfo botInfo, bool isDr TeamVersion = EnvVars.GetTeamVersion(), IsDroid = isDroid, Secret = serverSecret, + TeamMessageBatchVersion = 1, }; // Set DebuggerAttached field @@ -63,4 +64,4 @@ private static bool IsDebuggerAttached() // Check if a managed debugger is attached return Debugger.IsAttached; } -} \ No newline at end of file +} diff --git a/bot-api/dotnet/api/src/internal/EventQueue.cs b/bot-api/dotnet/api/src/internal/EventQueue.cs index f648558e0..f92888595 100644 --- a/bot-api/dotnet/api/src/internal/EventQueue.cs +++ b/bot-api/dotnet/api/src/internal/EventQueue.cs @@ -196,7 +196,10 @@ private void SortEvents() { lock (_events) { - _events.Sort(this); + // List.Sort is unstable; OrderBy keeps same-turn, same-priority events (e.g. team messages) in arrival order + var sorted = _events.OrderBy(botEvent => botEvent, this).ToList(); + _events.Clear(); + _events.AddRange(sorted); } } @@ -293,6 +296,14 @@ internal void AddEvent(BotEvent botEvent) { lock (_events) { + // Team messages are already bounded per sender by the protocol. A recipient may receive + // more than 256 in one turn, so reserve the ordinary queue limit for other events. + if (botEvent is not TeamMessageEvent) + { + // Old events are otherwise only removed on dispatch, so drop stale team messages here + // to keep the queue bounded when the bot does not call Go(). + _events.RemoveAll(e => e is TeamMessageEvent && IsOldAndNonCriticalEvent(e, botEvent.TurnNumber)); + } if (botEvent is TeamMessageEvent || _events.Count(e => e is not TeamMessageEvent) < MaxQueueSize) { _events.Add(botEvent); diff --git a/bot-api/dotnet/api/src/internal/IntentValidator.cs b/bot-api/dotnet/api/src/internal/IntentValidator.cs index a36b3c4b8..9b7b8b7e7 100644 --- a/bot-api/dotnet/api/src/internal/IntentValidator.cs +++ b/bot-api/dotnet/api/src/internal/IntentValidator.cs @@ -126,6 +126,13 @@ public static void ValidateTeamMessage(object message, int currentTeamMessageCou throw new ArgumentException("The 'message' of a team message cannot be null"); } + public static void ValidateLogicalTeamMessageCount(int logicalMessageCount) + { + if (logicalMessageCount > Constants.MaxLogicalTeamMessagesPerTurn) + throw new BotException("The maximum number of logical team messages has already been reached: " + + Constants.MaxLogicalTeamMessagesPerTurn); + } + public static void ValidateTeamMessageSize(string json) { var bytes = System.Text.Encoding.UTF8.GetBytes(json); diff --git a/bot-api/dotnet/api/src/internal/TeamMessageBatchCodec.cs b/bot-api/dotnet/api/src/internal/TeamMessageBatchCodec.cs new file mode 100644 index 000000000..593eec248 --- /dev/null +++ b/bot-api/dotnet/api/src/internal/TeamMessageBatchCodec.cs @@ -0,0 +1,33 @@ +using System; +using System.Linq; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Robocode.TankRoyale.BotApi.Internal.Json; + +namespace Robocode.TankRoyale.BotApi.Internal; + +internal static class TeamMessageBatchCodec +{ + internal const string MessageType = "team-message-batch-v1"; + + internal static string Encode(TeamMessageBatch batch) => JsonConvert.SerializeObject(new + { + messages = batch.Messages.Select(value => new + { + messageType = value.GetType().ToString(), + message = Robocode.TankRoyale.BotApi.Internal.Json.JsonConverter.ToJson(value), + }), + }); + + internal static JArray DecodeItems(string payload) + { + var root = JObject.Parse(payload); + var messages = root["messages"] as JArray; + if (messages == null || messages.Count == 0) + throw new ArgumentException("Invalid team message batch payload"); + foreach (var item in messages) + if (item["messageType"]?.Type != JTokenType.String || item["message"]?.Type != JTokenType.String) + throw new ArgumentException("Invalid team message batch item"); + return messages; + } +} diff --git a/bot-api/dotnet/api/src/mapper/EventMapper.cs b/bot-api/dotnet/api/src/mapper/EventMapper.cs index f73ea2399..6450e6def 100644 --- a/bot-api/dotnet/api/src/mapper/EventMapper.cs +++ b/bot-api/dotnet/api/src/mapper/EventMapper.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using Robocode.TankRoyale.BotApi.Events; +using Robocode.TankRoyale.BotApi.Internal; using Newtonsoft.Json.Linq; using Robocode.TankRoyale.BotApi.Internal.Json; @@ -141,6 +142,12 @@ private static TeamMessageEvent Map(Schema.TeamMessageEvent source, IBaseBot bas { try { + if (source.MessageType == TeamMessageBatchCodec.MessageType) + { + var values = TeamMessageBatchCodec.DecodeItems(source.Message).Select(item => + JsonConverter.FromJson((string)item["message"], ResolveType((string)item["messageType"], baseBot))).ToArray(); + return new TeamMessageEvent(source.TurnNumber, new TeamMessageBatch(values), source.SenderId); + } // Load the message type from the RECEIVING bot's assembly, not the sender's. // This allows each bot to have its own version of the message class. // For example, if MyFirstLeader sends a "RobotColors" message to MyFirstDroid, @@ -204,4 +211,13 @@ private static TeamMessageEvent Map(Schema.TeamMessageEvent source, IBaseBot bas throw new BotException("Could not parse team message", e); } } + + private static Type ResolveType(string messageType, IBaseBot baseBot) + { + var botAssembly = baseBot.GetType().Assembly; + var type = botAssembly.GetType(messageType) ?? botAssembly.GetTypes().FirstOrDefault(candidate => + candidate.Name == messageType.Split('.').Last() || candidate.FullName == messageType); + type ??= Type.GetType(messageType + "," + botAssembly.GetName().Name) ?? Type.GetType(messageType); + return type ?? throw new BotException($"Could not find type '{messageType}' in bot assembly '{botAssembly.GetName().Name}' or other loaded assemblies"); + } } diff --git a/bot-api/dotnet/test/src/TeamMessageBatchTest.cs b/bot-api/dotnet/test/src/TeamMessageBatchTest.cs new file mode 100644 index 000000000..8e66efb6f --- /dev/null +++ b/bot-api/dotnet/test/src/TeamMessageBatchTest.cs @@ -0,0 +1,36 @@ +using NUnit.Framework; +using Robocode.TankRoyale.BotApi; +using Robocode.TankRoyale.BotApi.Internal; + +namespace Robocode.TankRoyale.BotApi.Tests; + +[TestFixture] +[Category("TR-API-TCK-022")] +public class TeamMessageBatchTest +{ + [Test] + public void BatchPayloadKeepsOrderAndUsesReservedMarker() + { + var batch = new TeamMessageBatch(new[] { "first", "second" }); + var payload = TeamMessageBatchCodec.Encode(batch); + var items = TeamMessageBatchCodec.DecodeItems(payload); + Assert.That(items.Count, Is.EqualTo(2)); + Assert.That((string)items[0]["message"], Is.EqualTo("\"first\"")); + Assert.That((string)items[1]["message"], Is.EqualTo("\"second\"")); + Assert.That(TeamMessageBatchCodec.MessageType, Is.EqualTo("team-message-batch-v1")); + } + + [Test] + public void BatchRequiresAtLeastOneNonNullPayload() + { + Assert.Throws(() => new TeamMessageBatch(System.Array.Empty())); + Assert.Throws(() => new TeamMessageBatch(new object[] { "ok", null })); + } + + [Test] + public void LogicalItemLimitAccepts128AndRejects129() + { + Assert.DoesNotThrow(() => IntentValidator.ValidateLogicalTeamMessageCount(128)); + Assert.Throws(() => IntentValidator.ValidateLogicalTeamMessageCount(129)); + } +} diff --git a/bot-api/dotnet/test/src/internal/EventSystemTest.cs b/bot-api/dotnet/test/src/internal/EventSystemTest.cs index f064dbf78..c8ca74c0d 100644 --- a/bot-api/dotnet/test/src/internal/EventSystemTest.cs +++ b/bot-api/dotnet/test/src/internal/EventSystemTest.cs @@ -68,7 +68,9 @@ public void SetResume() {} public ICollection TeammateIds => new List(); public bool IsTeammate(int botId) => false; public void BroadcastTeamMessage(object message) {} + public void BroadcastTeamMessageBatch(System.Collections.IEnumerable messages) {} public void SendTeamMessage(int teammateId, object message) {} + public void SendTeamMessageBatch(int teammateId, System.Collections.IEnumerable messages) {} public Color? BodyColor { get; set; } public Color? TurretColor { get; set; } public Color? RadarColor { get; set; } diff --git a/bot-api/dotnet/test/src/internal/SharedTestRunner.cs b/bot-api/dotnet/test/src/internal/SharedTestRunner.cs index 39871b28e..8ffec3eb6 100644 --- a/bot-api/dotnet/test/src/internal/SharedTestRunner.cs +++ b/bot-api/dotnet/test/src/internal/SharedTestRunner.cs @@ -139,6 +139,9 @@ public void RunSharedTest(string suiteName, TestCase testCase) ?? GetStaticField(typeof(GameType), (string)args[0]) ?? GetStaticField(typeof(DefaultEventPriority), (string)args[0]); break; + case "createTeamMessageBatch": + lastActionValue = new TeamMessageBatch((System.Collections.IEnumerable)args[0]).Messages; + break; case "isCritical": lastActionValue = CreateEvent((string)args[0]).IsCritical; break; case "getDefaultPriority": lastActionValue = GetStaticField(typeof(DefaultEventPriority), (string)args[0]); break; case "calcBulletSpeed": lastActionValue = mockBot.CalcBulletSpeed(Convert.ToDouble(args[0])); break; @@ -274,13 +277,14 @@ private BotEvent CreateEvent(string eventName) }; } - private BotEvent CreateEventAt(string eventName, int turnNumber) => eventName switch + private BotEvent CreateEventAt(string eventName, int turnNumber, int sequence) => eventName switch { "WonRoundEvent" => new WonRoundEvent(turnNumber), "DeathEvent" => new DeathEvent(turnNumber), "ScannedBotEvent" => new ScannedBotEvent(turnNumber, 0, 0, 0, 0, 0, 0, 0), "SkippedTurnEvent" => new SkippedTurnEvent(turnNumber), "BotDeathEvent" => new BotDeathEvent(turnNumber, 0), + "TeamMessageEvent" => new TeamMessageEvent(turnNumber, sequence.ToString(), 0), _ => throw new ArgumentException($"Unknown event for scenario: {eventName}") }; @@ -363,6 +367,7 @@ private void ExecuteScenario(TestCase testCase) var internals = new BaseBotInternals(botStub, botInfo, new Uri("ws://localhost:7654"), null); var queue = new EventQueue(internals, internals.BotEventHandlers); + var sequence = 0; foreach (var step in testCase.Steps) { var action = (string)step["action"]; @@ -372,7 +377,7 @@ private void ExecuteScenario(TestCase testCase) var turnNumber = Convert.ToInt32(step["turnNumber"]); var repeat = step.ContainsKey("repeat") ? Convert.ToInt32(step["repeat"]) : 1; for (int i = 0; i < repeat; i++) - queue.AddEvent(CreateEventAt(eventType, turnNumber)); + queue.AddEvent(CreateEventAt(eventType, turnNumber, sequence++)); } else if (action == "dispatchEvents") { @@ -389,10 +394,17 @@ private void ExecuteScenario(TestCase testCase) for (int i = 0; i < expectedOrder.Count; i++) Assert.That(fired[i].GetType().Name, Is.EqualTo(expectedOrder[i]), $"Event at index {i} mismatch"); } + if (testCase.ExpectAfter.TryGetValue("dispatchedMessages", out var dispatchedMessagesRaw)) + { + var expectedMessages = ((Newtonsoft.Json.Linq.JArray)dispatchedMessagesRaw).ToObject>(); + var actualMessages = botStub.FiredEvents.OfType().Select(e => e.Message).ToList(); + Assert.That(actualMessages, Is.EqualTo(expectedMessages), "Team message dispatch order mismatch"); + } if (testCase.ExpectAfter.TryGetValue("queueSize", out var queueSizeRaw)) { var expectedSize = Convert.ToInt32(queueSizeRaw); - Assert.That(queue.Events(999), Has.Count.EqualTo(expectedSize), "Queue size mismatch"); + var atTurn = testCase.ExpectAfter.TryGetValue("queueSizeAtTurn", out var atTurnRaw) ? Convert.ToInt32(atTurnRaw) : 999; + Assert.That(queue.Events(atTurn), Has.Count.EqualTo(expectedSize), "Queue size mismatch"); } } @@ -450,7 +462,9 @@ public void SetResume() {} public ICollection TeammateIds => new List(); public bool IsTeammate(int id) => false; public void BroadcastTeamMessage(object m) {} + public void BroadcastTeamMessageBatch(System.Collections.IEnumerable messages) {} public void SendTeamMessage(int id, object m) {} + public void SendTeamMessageBatch(int id, System.Collections.IEnumerable messages) {} public Robocode.TankRoyale.BotApi.Graphics.Color? BodyColor { get; set; } public Robocode.TankRoyale.BotApi.Graphics.Color? TurretColor { get; set; } public Robocode.TankRoyale.BotApi.Graphics.Color? RadarColor { get; set; } diff --git a/bot-api/dotnet/test/src/internal/TestBot.cs b/bot-api/dotnet/test/src/internal/TestBot.cs index f5f8b7758..3838e9fd7 100644 --- a/bot-api/dotnet/test/src/internal/TestBot.cs +++ b/bot-api/dotnet/test/src/internal/TestBot.cs @@ -58,7 +58,9 @@ public void SetResume() {} public ICollection TeammateIds => new List(); public bool IsTeammate(int botId) => false; public void BroadcastTeamMessage(object message) {} + public void BroadcastTeamMessageBatch(System.Collections.IEnumerable messages) {} public void SendTeamMessage(int teammateId, object message) {} + public void SendTeamMessageBatch(int teammateId, System.Collections.IEnumerable messages) {} public Robocode.TankRoyale.BotApi.Graphics.Color? BodyColor { get; set; } public Robocode.TankRoyale.BotApi.Graphics.Color? TurretColor { get; set; } public Robocode.TankRoyale.BotApi.Graphics.Color? RadarColor { get; set; } diff --git a/bot-api/java/src/main/java/dev/robocode/tankroyale/botapi/BaseBot.java b/bot-api/java/src/main/java/dev/robocode/tankroyale/botapi/BaseBot.java index 479a43dc8..dad48fafb 100644 --- a/bot-api/java/src/main/java/dev/robocode/tankroyale/botapi/BaseBot.java +++ b/bot-api/java/src/main/java/dev/robocode/tankroyale/botapi/BaseBot.java @@ -700,6 +700,18 @@ public final void sendTeamMessage(int teammateId, Object message) { baseBotInternals.sendTeamMessage(teammateId, message); } + /** {@inheritDoc} */ + @Override + public final void broadcastTeamMessageBatch(java.util.Collection messages) { + baseBotInternals.broadcastTeamMessage(new TeamMessageBatch(messages)); + } + + /** {@inheritDoc} */ + @Override + public final void sendTeamMessageBatch(int teammateId, java.util.Collection messages) { + baseBotInternals.sendTeamMessage(teammateId, new TeamMessageBatch(messages)); + } + /** * {@inheritDoc} */ diff --git a/bot-api/java/src/main/java/dev/robocode/tankroyale/botapi/Constants.java b/bot-api/java/src/main/java/dev/robocode/tankroyale/botapi/Constants.java index bbfc60a74..3eb29d934 100644 --- a/bot-api/java/src/main/java/dev/robocode/tankroyale/botapi/Constants.java +++ b/bot-api/java/src/main/java/dev/robocode/tankroyale/botapi/Constants.java @@ -6,6 +6,10 @@ @SuppressWarnings("unused") public final class Constants { + /** Maximum number of logical payloads across ordinary messages and batch entries per turn. */ + /** Maximum logical payloads per turn, counting each entry in a batch. */ + public static final int MAX_LOGICAL_TEAM_MESSAGES_PER_TURN = 128; + // Hide constructor to prevent instantiation private Constants() { } @@ -130,13 +134,13 @@ private Constants() { public static final double STARTING_GUN_HEAT = 3.0; /** - * The maximum size of a team message in UTF-8 bytes (48 KiB), which is the serialized (compact JSON) - * size of the message object. Messages exceeding this size are rejected. + * The maximum size of one team-message packet in UTF-8 bytes (48 KiB), measured on its compact JSON payload. + * Messages exceeding this size are rejected. */ public static final int TEAM_MESSAGE_MAX_SIZE = 48 * 1024; /** - * The maximum number of team messages that can be sent per turn, which is 64 messages. + * The maximum number of team-message packets that can be sent per turn, including each batch as one packet. */ public static final int MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN = 64; diff --git a/bot-api/java/src/main/java/dev/robocode/tankroyale/botapi/IBaseBot.java b/bot-api/java/src/main/java/dev/robocode/tankroyale/botapi/IBaseBot.java index d04f84c56..9f3711e90 100644 --- a/bot-api/java/src/main/java/dev/robocode/tankroyale/botapi/IBaseBot.java +++ b/bot-api/java/src/main/java/dev/robocode/tankroyale/botapi/IBaseBot.java @@ -851,9 +851,12 @@ public interface IBaseBot { * {@value Constants#TEAM_MESSAGE_MAX_SIZE} bytes. This size is the size of the message when it is serialized into a * JSON representation.
*
- * A turn can contain at most {@value Constants#MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN} messages, and the compact - * UTF-8 encoded team-message array can contain at most {@value Constants#TEAM_MESSAGES_MAX_BYTES_PER_TURN} bytes. - * Each accepted message is delivered on the next turn. A failed call does not enqueue its message. + * A turn can contain at most {@value Constants#MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN} packets and + * {@value Constants#MAX_LOGICAL_TEAM_MESSAGES_PER_TURN} logical payloads, counting entries in batches. A packet is + * limited to {@value Constants#TEAM_MESSAGE_MAX_SIZE} UTF-8 bytes, and the compact team-message array to + * {@value Constants#TEAM_MESSAGES_MAX_BYTES_PER_TURN} UTF-8 bytes. Each accepted packet is delivered on the next + * turn. A batch is delivered through one event with entries in order. All recipients must support batch version 1. + * A failed call does not enqueue its packet. * * @param message is the message to broadcast. * @throws BotException if the per-turn message count has been reached. @@ -873,9 +876,12 @@ public interface IBaseBot { * {@value Constants#TEAM_MESSAGE_MAX_SIZE} bytes. This size is the size of the message when it is serialized into a * JSON representation.
*
- * A turn can contain at most {@value Constants#MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN} messages, and the compact - * UTF-8 encoded team-message array can contain at most {@value Constants#TEAM_MESSAGES_MAX_BYTES_PER_TURN} bytes. - * Each accepted message is delivered on the next turn. A failed call does not enqueue its message. + * A turn can contain at most {@value Constants#MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN} packets and + * {@value Constants#MAX_LOGICAL_TEAM_MESSAGES_PER_TURN} logical payloads, counting entries in batches. A packet is + * limited to {@value Constants#TEAM_MESSAGE_MAX_SIZE} UTF-8 bytes, and the compact team-message array to + * {@value Constants#TEAM_MESSAGES_MAX_BYTES_PER_TURN} UTF-8 bytes. Each accepted packet is delivered on the next + * turn. A batch is delivered through one event with entries in order. All recipients must support batch version 1. + * A failed call does not enqueue its packet. * * @param teammateId is the id of the teammate to send the message to. * @param message is the message to send. @@ -886,6 +892,31 @@ public interface IBaseBot { */ void sendTeamMessage(int teammateId, Object message); + /** + * Broadcasts ordered payloads as one packet and one team-message event on the next turn. Each entry counts toward + * {@value Constants#MAX_LOGICAL_TEAM_MESSAGES_PER_TURN}; the batch counts as one packet toward + * {@value Constants#MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN}. All recipients must support batch version 1. + * Invalid batches are rejected before enqueue. + * + * @param messages non-empty ordered payloads to broadcast + * @throws IllegalArgumentException if the collection is empty or contains {@code null} + * @throws BotException if the per-turn logical payload limit has been reached + */ + void broadcastTeamMessageBatch(Collection messages); + + /** + * Sends ordered payloads to one teammate as one packet and one team-message event on the next turn. Each entry + * counts toward {@value Constants#MAX_LOGICAL_TEAM_MESSAGES_PER_TURN}; the batch counts as one packet toward + * {@value Constants#MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN}. The recipient must support batch version 1. + * Invalid batches are rejected before enqueue. + * + * @param teammateId recipient teammate ID + * @param messages non-empty ordered payloads to send + * @throws IllegalArgumentException if the recipient or batch is invalid + * @throws BotException if the per-turn logical payload limit has been reached + */ + void sendTeamMessageBatch(int teammateId, Collection messages); + /** * Checks if the movement has been stopped. * diff --git a/bot-api/java/src/main/java/dev/robocode/tankroyale/botapi/TeamMessageBatch.java b/bot-api/java/src/main/java/dev/robocode/tankroyale/botapi/TeamMessageBatch.java new file mode 100644 index 000000000..571fdabb9 --- /dev/null +++ b/bot-api/java/src/main/java/dev/robocode/tankroyale/botapi/TeamMessageBatch.java @@ -0,0 +1,27 @@ +package dev.robocode.tankroyale.botapi; + +import java.util.Collection; +import java.util.List; + +/** + * An immutable, ordered group of non-null payloads delivered through one team-message event on the next turn. + * Use {@link IBaseBot#broadcastTeamMessageBatch(java.util.Collection)} or + * {@link IBaseBot#sendTeamMessageBatch(int, java.util.Collection)} to enqueue a batch. + */ +public final class TeamMessageBatch { + private final List messages; + + public TeamMessageBatch(Collection messages) { + if (messages == null || messages.isEmpty()) { + throw new IllegalArgumentException("A team message batch must contain at least one message"); + } + if (messages.stream().anyMatch(message -> message == null)) { + throw new IllegalArgumentException("A team message batch cannot contain null messages"); + } + this.messages = List.copyOf(messages); + } + + public List getMessages() { + return messages; + } +} diff --git a/bot-api/java/src/main/java/dev/robocode/tankroyale/botapi/internal/BaseBotInternals.java b/bot-api/java/src/main/java/dev/robocode/tankroyale/botapi/internal/BaseBotInternals.java index c12d2450a..21d04e5ed 100644 --- a/bot-api/java/src/main/java/dev/robocode/tankroyale/botapi/internal/BaseBotInternals.java +++ b/bot-api/java/src/main/java/dev/robocode/tankroyale/botapi/internal/BaseBotInternals.java @@ -772,20 +772,33 @@ public void sendTeamMessage(Integer teammateId, Object message) { IntentValidator.validateTeammateId(teammateId, getTeammateIds()); IntentValidator.validateTeamMessage(message, botIntent.getTeamMessages().size()); - var json = JsonConverter.toJson(message); + var json = message instanceof TeamMessageBatch + ? TeamMessageBatchCodec.encode((TeamMessageBatch) message) + : JsonConverter.toJson(message); IntentValidator.validateTeamMessageSize(json); var teamMessage = new TeamMessage(); - teamMessage.setMessageType(message.getClass().getName()); + teamMessage.setMessageType(message instanceof TeamMessageBatch + ? TeamMessageBatchCodec.MESSAGE_TYPE : message.getClass().getName()); teamMessage.setReceiverId(teammateId); teamMessage.setMessage(json); var candidateMessages = new java.util.ArrayList<>(botIntent.getTeamMessages()); candidateMessages.add(teamMessage); + IntentValidator.validateLogicalTeamMessageCount(logicalTeamMessageCount(candidateMessages)); IntentValidator.validateTeamMessagesSize(JsonConverter.toJson(candidateMessages)); botIntent.getTeamMessages().add(teamMessage); } + private static int logicalTeamMessageCount(Collection messages) { + int count = 0; + for (var message : messages) { + count += TeamMessageBatchCodec.MESSAGE_TYPE.equals(message.getMessageType()) + ? TeamMessageBatchCodec.decodeItems(message.getMessage()).size() : 1; + } + return count; + } + public Color getBodyColor() { return tickEvent == null ? null : tickEvent.getBotState().getBodyColor(); } diff --git a/bot-api/java/src/main/java/dev/robocode/tankroyale/botapi/internal/BotHandshakeFactory.java b/bot-api/java/src/main/java/dev/robocode/tankroyale/botapi/internal/BotHandshakeFactory.java index 5036f231f..1ec449021 100644 --- a/bot-api/java/src/main/java/dev/robocode/tankroyale/botapi/internal/BotHandshakeFactory.java +++ b/bot-api/java/src/main/java/dev/robocode/tankroyale/botapi/internal/BotHandshakeFactory.java @@ -40,6 +40,7 @@ static BotHandshake create(String sessionId, BotInfo botInfo, boolean isDroid, S // Set debuggerAttached field (ADR-035) boolean debuggerAttached = isDebuggerAttached(); handshake.setDebuggerAttached(debuggerAttached); + handshake.setTeamMessageBatchVersion(1); // Log hint if debugger is detected if (debuggerAttached) { diff --git a/bot-api/java/src/main/java/dev/robocode/tankroyale/botapi/internal/EventQueue.java b/bot-api/java/src/main/java/dev/robocode/tankroyale/botapi/internal/EventQueue.java index 561b71f3d..7b9ab1074 100644 --- a/bot-api/java/src/main/java/dev/robocode/tankroyale/botapi/internal/EventQueue.java +++ b/bot-api/java/src/main/java/dev/robocode/tankroyale/botapi/internal/EventQueue.java @@ -232,6 +232,11 @@ void addEvent(BotEvent botEvent) { synchronized (events) { // Team messages are already bounded per sender by the protocol. A recipient may receive // more than 256 in one turn, so reserve the ordinary queue limit for other events. + if (!(botEvent instanceof TeamMessageEvent)) { + // Old events are otherwise only removed on dispatch, so drop stale team messages here + // to keep the queue bounded when the bot does not call go(). + removeOldTeamMessages(botEvent.getTurnNumber()); + } if (botEvent instanceof TeamMessageEvent || events.stream().filter(event -> !(event instanceof TeamMessageEvent)).count() < MAX_QUEUE_SIZE) { events.add(botEvent); @@ -241,6 +246,10 @@ void addEvent(BotEvent botEvent) { } } + private void removeOldTeamMessages(int turnNumber) { + events.removeIf(event -> event instanceof TeamMessageEvent && isOldAndNonCriticalEvent(event, turnNumber)); + } + private void addCustomEvents() { baseBotInternals.getConditions().stream().filter(Condition::test).forEach(condition -> addEvent(new CustomEvent(baseBotInternals.getCurrentTickOrThrow().getTurnNumber(), condition)) diff --git a/bot-api/java/src/main/java/dev/robocode/tankroyale/botapi/internal/IntentValidator.java b/bot-api/java/src/main/java/dev/robocode/tankroyale/botapi/internal/IntentValidator.java index 6e7980777..2e47f474a 100644 --- a/bot-api/java/src/main/java/dev/robocode/tankroyale/botapi/internal/IntentValidator.java +++ b/bot-api/java/src/main/java/dev/robocode/tankroyale/botapi/internal/IntentValidator.java @@ -121,6 +121,13 @@ public static void validateTeamMessage(Object message, int currentTeamMessageCou } } + public static void validateLogicalTeamMessageCount(int logicalMessageCount) { + if (logicalMessageCount > MAX_LOGICAL_TEAM_MESSAGES_PER_TURN) { + throw new BotException("The maximum number of logical team messages has already been reached: " + + MAX_LOGICAL_TEAM_MESSAGES_PER_TURN); + } + } + public static void validateTeamMessageSize(String json) { if (json.getBytes(StandardCharsets.UTF_8).length > TEAM_MESSAGE_MAX_SIZE) { throw new IllegalArgumentException( diff --git a/bot-api/java/src/main/java/dev/robocode/tankroyale/botapi/internal/TeamMessageBatchCodec.java b/bot-api/java/src/main/java/dev/robocode/tankroyale/botapi/internal/TeamMessageBatchCodec.java new file mode 100644 index 000000000..16ed23718 --- /dev/null +++ b/bot-api/java/src/main/java/dev/robocode/tankroyale/botapi/internal/TeamMessageBatchCodec.java @@ -0,0 +1,41 @@ +package dev.robocode.tankroyale.botapi.internal; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import dev.robocode.tankroyale.botapi.TeamMessageBatch; +import dev.robocode.tankroyale.botapi.internal.json.JsonConverter; + +/** Compact, platform-independent payload representation for a team-message batch. */ +public final class TeamMessageBatchCodec { + public static final String MESSAGE_TYPE = "team-message-batch-v1"; + + private TeamMessageBatchCodec() { + } + + public static String encode(TeamMessageBatch batch) { + var root = new JsonObject(); + var messages = new JsonArray(); + for (Object value : batch.getMessages()) { + var item = new JsonObject(); + item.addProperty("messageType", value.getClass().getName()); + item.addProperty("message", JsonConverter.toJson(value)); + messages.add(item); + } + root.add("messages", messages); + return JsonConverter.toJson(root); + } + + public static JsonArray decodeItems(String payload) { + var root = JsonParser.parseString(payload); + if (!root.isJsonObject() || !root.getAsJsonObject().has("messages") + || !root.getAsJsonObject().get("messages").isJsonArray()) { + throw new IllegalArgumentException("Invalid team message batch payload"); + } + var messages = root.getAsJsonObject().getAsJsonArray("messages"); + if (messages.isEmpty()) { + throw new IllegalArgumentException("A team message batch must contain at least one message"); + } + return messages; + } +} diff --git a/bot-api/java/src/main/java/dev/robocode/tankroyale/botapi/mapper/EventMapper.java b/bot-api/java/src/main/java/dev/robocode/tankroyale/botapi/mapper/EventMapper.java index 56b59f7ce..d607558ba 100644 --- a/bot-api/java/src/main/java/dev/robocode/tankroyale/botapi/mapper/EventMapper.java +++ b/bot-api/java/src/main/java/dev/robocode/tankroyale/botapi/mapper/EventMapper.java @@ -3,7 +3,9 @@ import dev.robocode.tankroyale.botapi.BotException; import dev.robocode.tankroyale.botapi.BulletState; import dev.robocode.tankroyale.botapi.IBaseBot; +import dev.robocode.tankroyale.botapi.TeamMessageBatch; import dev.robocode.tankroyale.botapi.events.*; +import dev.robocode.tankroyale.botapi.internal.TeamMessageBatchCodec; import dev.robocode.tankroyale.botapi.internal.json.JsonConverter; import java.util.ArrayList; @@ -155,6 +157,19 @@ private static TeamMessageEvent map(final dev.robocode.tankroyale.schema.TeamMes throw new BotException("message in TeamMessageEvent is null"); } try { + if (TeamMessageBatchCodec.MESSAGE_TYPE.equals(source.getMessageType())) { + var values = new ArrayList(); + for (var item : TeamMessageBatchCodec.decodeItems(message)) { + if (!item.isJsonObject()) throw new IllegalArgumentException("Invalid team message batch item"); + var object = item.getAsJsonObject(); + if (!object.has("messageType") || !object.has("message")) { + throw new IllegalArgumentException("Invalid team message batch item"); + } + var type = baseBot.getClass().getClassLoader().loadClass(object.get("messageType").getAsString()); + values.add(JsonConverter.fromJson(object.get("message").getAsString(), type)); + } + return new TeamMessageEvent(source.getTurnNumber(), new TeamMessageBatch(values), source.getSenderId()); + } var type = baseBot.getClass().getClassLoader().loadClass(source.getMessageType()); var messageObject = JsonConverter.fromJson(message, type); return new TeamMessageEvent(source.getTurnNumber(), messageObject, source.getSenderId()); diff --git a/bot-api/java/src/test/java/dev/robocode/tankroyale/botapi/TeamMessageBatchTest.java b/bot-api/java/src/test/java/dev/robocode/tankroyale/botapi/TeamMessageBatchTest.java new file mode 100644 index 000000000..2c7d90633 --- /dev/null +++ b/bot-api/java/src/test/java/dev/robocode/tankroyale/botapi/TeamMessageBatchTest.java @@ -0,0 +1,38 @@ +package dev.robocode.tankroyale.botapi; + +import com.google.gson.JsonParser; +import dev.robocode.tankroyale.botapi.internal.IntentValidator; +import dev.robocode.tankroyale.botapi.internal.TeamMessageBatchCodec; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +@Tag("TR-API-TCK-022") +class TeamMessageBatchTest { + @Test + void batchPayloadKeepsOrderAndUsesReservedMarker() { + var batch = new TeamMessageBatch(List.of("first", "second")); + var payload = TeamMessageBatchCodec.encode(batch); + var items = TeamMessageBatchCodec.decodeItems(payload); + + assertEquals(2, items.size()); + assertEquals("first", JsonParser.parseString(items.get(0).getAsJsonObject().get("message").getAsString()).getAsString()); + assertEquals("second", JsonParser.parseString(items.get(1).getAsJsonObject().get("message").getAsString()).getAsString()); + assertEquals("team-message-batch-v1", TeamMessageBatchCodec.MESSAGE_TYPE); + } + + @Test + void batchMustContainNonNullPayloads() { + assertThrows(IllegalArgumentException.class, () -> new TeamMessageBatch(List.of())); + assertThrows(IllegalArgumentException.class, () -> new TeamMessageBatch(java.util.Arrays.asList("ok", null))); + } + + @Test + void logicalItemLimitAccepts128AndRejects129() { + assertDoesNotThrow(() -> IntentValidator.validateLogicalTeamMessageCount(128)); + assertThrows(BotException.class, () -> IntentValidator.validateLogicalTeamMessageCount(129)); + } +} diff --git a/bot-api/java/src/test/java/dev/robocode/tankroyale/botapi/internal/EventSystemTest.java b/bot-api/java/src/test/java/dev/robocode/tankroyale/botapi/internal/EventSystemTest.java index 66482e727..ea49549f3 100644 --- a/bot-api/java/src/test/java/dev/robocode/tankroyale/botapi/internal/EventSystemTest.java +++ b/bot-api/java/src/test/java/dev/robocode/tankroyale/botapi/internal/EventSystemTest.java @@ -84,7 +84,9 @@ static class BaseBotStub implements IBaseBot { @Override public Set getTeammateIds() { return Collections.emptySet(); } @Override public boolean isTeammate(int botId) { return false; } @Override public void broadcastTeamMessage(Object message) {} + @Override public void broadcastTeamMessageBatch(java.util.Collection messages) {} @Override public void sendTeamMessage(int teammateId, Object message) {} + @Override public void sendTeamMessageBatch(int teammateId, java.util.Collection messages) {} @Override public boolean isStopped() { return false; } @Override public Color getBodyColor() { return null; } @Override public void setBodyColor(Color color) {} diff --git a/bot-api/java/src/test/java/dev/robocode/tankroyale/botapi/internal/SharedTestRunner.java b/bot-api/java/src/test/java/dev/robocode/tankroyale/botapi/internal/SharedTestRunner.java index eb84cceec..f22519af7 100644 --- a/bot-api/java/src/test/java/dev/robocode/tankroyale/botapi/internal/SharedTestRunner.java +++ b/bot-api/java/src/test/java/dev/robocode/tankroyale/botapi/internal/SharedTestRunner.java @@ -9,6 +9,7 @@ import dev.robocode.tankroyale.botapi.BulletState; import dev.robocode.tankroyale.botapi.BotInfo; import dev.robocode.tankroyale.botapi.Constants; +import dev.robocode.tankroyale.botapi.TeamMessageBatch; import dev.robocode.tankroyale.botapi.graphics.Color; import dev.robocode.tankroyale.schema.BotIntent; import dev.robocode.tankroyale.botapi.events.Condition; @@ -199,6 +200,9 @@ private void executeTest(TestCase testCase) { lastActionValue[0] = getStaticField(dev.robocode.tankroyale.botapi.DefaultEventPriority.class, (String) args[0]); } break; + case "createTeamMessageBatch": + lastActionValue[0] = new TeamMessageBatch((Collection) args[0]).getMessages(); + break; case "isCritical": lastActionValue[0] = createEvent((String) args[0]).isCritical(); break; @@ -369,13 +373,14 @@ private BotEvent createEvent(String eventName) { } } - private BotEvent createEventAt(String eventName, int turnNumber) { + private BotEvent createEventAt(String eventName, int turnNumber, int sequence) { switch (eventName) { case "WonRoundEvent": return new WonRoundEvent(turnNumber); case "DeathEvent": return new DeathEvent(turnNumber); case "ScannedBotEvent": return new ScannedBotEvent(turnNumber, 0, 0, 0, 0, 0, 0, 0); case "SkippedTurnEvent":return new SkippedTurnEvent(turnNumber); case "BotDeathEvent": return new BotDeathEvent(turnNumber, 0); + case "TeamMessageEvent":return new TeamMessageEvent(turnNumber, String.valueOf(sequence), 0); default: throw new IllegalArgumentException("Unknown event for scenario: " + eventName); } } @@ -459,6 +464,7 @@ private void executeScenario(TestCase testCase) { BaseBotInternals internals = new BaseBotInternals(botStub, botInfo, URI.create("ws://localhost:7654"), null); EventQueue queue = new EventQueue(internals, internals.getBotEventHandlers()); + int sequence = 0; for (Map step : testCase.steps) { String action = (String) step.get("action"); if ("addEvent".equals(action)) { @@ -466,7 +472,7 @@ private void executeScenario(TestCase testCase) { int turnNumber = ((Number) step.get("turnNumber")).intValue(); int repeat = step.containsKey("repeat") ? ((Number) step.get("repeat")).intValue() : 1; for (int i = 0; i < repeat; i++) { - queue.addEvent(createEventAt(eventType, turnNumber)); + queue.addEvent(createEventAt(eventType, turnNumber, sequence++)); } } else if ("dispatchEvents".equals(action)) { int atTurn = ((Number) step.get("atTurn")).intValue(); @@ -485,9 +491,19 @@ private void executeScenario(TestCase testCase) { "Event at index " + i + " mismatch"); } } + if (expectAfter.containsKey("dispatchedMessages")) { + @SuppressWarnings("unchecked") + List expectedMessages = (List) expectAfter.get("dispatchedMessages"); + List actualMessages = new ArrayList<>(); + for (BotEvent event : botStub.firedEvents) { + if (event instanceof TeamMessageEvent) actualMessages.add(((TeamMessageEvent) event).getMessage()); + } + assertEquals(expectedMessages, actualMessages, "Team message dispatch order mismatch"); + } if (expectAfter.containsKey("queueSize")) { int expectedSize = ((Number) expectAfter.get("queueSize")).intValue(); - assertEquals(expectedSize, queue.getEvents(999).size(), "Queue size mismatch"); + int atTurn = expectAfter.containsKey("queueSizeAtTurn") ? ((Number) expectAfter.get("queueSizeAtTurn")).intValue() : 999; + assertEquals(expectedSize, queue.getEvents(atTurn).size(), "Queue size mismatch"); } } @@ -596,7 +612,9 @@ private static class TestBot implements IBaseBot { @Override public Set getTeammateIds() { return Collections.emptySet(); } @Override public boolean isTeammate(int botId) { return false; } @Override public void broadcastTeamMessage(Object message) {} + @Override public void broadcastTeamMessageBatch(Collection messages) {} @Override public void sendTeamMessage(int teammateId, Object message) {} + @Override public void sendTeamMessageBatch(int teammateId, Collection messages) {} @Override public boolean isStopped() { return false; } @Override public Color getBodyColor() { return null; } @Override public void setBodyColor(Color color) {} diff --git a/bot-api/python/pyproject.toml b/bot-api/python/pyproject.toml index fa2364bab..4bab28ee1 100644 --- a/bot-api/python/pyproject.toml +++ b/bot-api/python/pyproject.toml @@ -43,6 +43,7 @@ markers = [ "VAL: Validation tests (BotInfo, constants, initial position)", "CMD: Command tests (fire, movement, radar)", "TCK: Protocol conformance / tick lifecycle tests", + "TR_API_TCK_022: Team-message batch API parity and validation", "BOT: Bot constructor and lifecycle tests", "UTL: Utility tests (color util, country code, JSON)", "GFX: Graphics tests (color, SVG, point)", diff --git a/bot-api/python/src/robocode_tank_royale/bot_api/__init__.py b/bot-api/python/src/robocode_tank_royale/bot_api/__init__.py index 1cee61929..78978846c 100644 --- a/bot-api/python/src/robocode_tank_royale/bot_api/__init__.py +++ b/bot-api/python/src/robocode_tank_royale/bot_api/__init__.py @@ -18,6 +18,7 @@ from .base_bot import BaseBot from .bot import Bot from .team_message import team_message_type, register_team_message_type +from .team_message_batch import TeamMessageBatch __all__ = [ "BaseBotABC", @@ -38,6 +39,7 @@ # Team message type registration "team_message_type", "register_team_message_type", + "TeamMessageBatch", # Constants - export all of them "MAX_SPEED", "MAX_TURN_RATE", diff --git a/bot-api/python/src/robocode_tank_royale/bot_api/base_bot.py b/bot-api/python/src/robocode_tank_royale/bot_api/base_bot.py index c874439cf..2c0b68547 100644 --- a/bot-api/python/src/robocode_tank_royale/bot_api/base_bot.py +++ b/bot-api/python/src/robocode_tank_royale/bot_api/base_bot.py @@ -700,8 +700,9 @@ def is_teammate(self, bot_id: int) -> bool: def broadcast_team_message(self, message: Any) -> None: """Broadcasts a compact JSON message to all teammates for delivery on the next turn. - A turn accepts at most 64 messages, each up to 48 KiB UTF-8, and up to 256 KiB for the complete compact array. - A failed call does not enqueue its message. + A turn accepts at most 64 packets and 128 logical payloads, counting batch entries. Each packet is at most + 48 KiB UTF-8 and the compact array is at most 256 KiB. Batches arrive as one event with entries in order on + the next turn. Every recipient must support batch version 1. A failed call does not enqueue its packet. Args: message: The message to broadcast. @@ -709,11 +710,18 @@ def broadcast_team_message(self, message: Any) -> None: self._internals.get_current_tick_or_throw() self._internals.broadcast_team_message(message) + def broadcast_team_message_batch(self, messages: Any) -> None: + """Broadcasts ordered messages as one event on the next turn.""" + from .team_message_batch import TeamMessageBatch + self._internals.get_current_tick_or_throw() + self._internals.broadcast_team_message(TeamMessageBatch(messages)) + def send_team_message(self, teammate_id: int, message: Any) -> None: """Sends a compact JSON message to a teammate for delivery on the next turn. - A turn accepts at most 64 messages, each up to 48 KiB UTF-8, and up to 256 KiB for the complete compact array. - A failed call does not enqueue its message. + A turn accepts at most 64 packets and 128 logical payloads, counting batch entries. Each packet is at most + 48 KiB UTF-8 and the compact array is at most 256 KiB. Batches arrive as one event with entries in order on + the next turn. Every recipient must support batch version 1. A failed call does not enqueue its packet. Args: teammate_id: The ID of the teammate to send the message to. @@ -722,6 +730,12 @@ def send_team_message(self, teammate_id: int, message: Any) -> None: self._internals.get_current_tick_or_throw() self._internals.send_team_message(teammate_id, message) + def send_team_message_batch(self, teammate_id: int, messages: Any) -> None: + """Sends ordered messages to one teammate as one event on the next turn.""" + from .team_message_batch import TeamMessageBatch + self._internals.get_current_tick_or_throw() + self._internals.send_team_message(teammate_id, TeamMessageBatch(messages)) + @property def stopped(self) -> bool: """Checks if the bot is currently stopped. diff --git a/bot-api/python/src/robocode_tank_royale/bot_api/base_bot_abc.py b/bot-api/python/src/robocode_tank_royale/bot_api/base_bot_abc.py index 1f440e831..c15f560ae 100644 --- a/bot-api/python/src/robocode_tank_royale/bot_api/base_bot_abc.py +++ b/bot-api/python/src/robocode_tank_royale/bot_api/base_bot_abc.py @@ -20,7 +20,10 @@ class BaseBotABC(ABC): """Maximum UTF-8 bytes of one compact JSON-encoded team message (48 KiB).""" MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN: int = _C.MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN - """Maximum number of team messages that can be sent per turn (64).""" + """Maximum number of team-message packets per turn, with each batch counting as one packet (64).""" + + MAX_LOGICAL_TEAM_MESSAGES_PER_TURN: int = _C.MAX_LOGICAL_TEAM_MESSAGES_PER_TURN + """Maximum logical payloads per turn, counting each batch entry (128).""" TEAM_MESSAGES_MAX_BYTES_PER_TURN: int = _C.TEAM_MESSAGES_MAX_BYTES_PER_TURN """Maximum UTF-8 bytes of the compact encoded team-message array per turn (256 KiB).""" @@ -894,10 +897,11 @@ def broadcast_team_message(self, message: Any) -> None: When the message is sent, it is serialized into a JSON representation. This means that all public fields, and only public fields, are serialized into a JSON representation as a data transfer object (DTO). - A turn accepts at most `MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN` messages. Each compact JSON message uses at most - `TEAM_MESSAGE_MAX_SIZE` UTF-8 bytes, and the complete compact message array uses at most - `TEAM_MESSAGES_MAX_BYTES_PER_TURN` UTF-8 bytes. Accepted messages are delivered on the next turn. A failed call - does not enqueue its message. + A turn accepts at most `MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN` packets and + `MAX_LOGICAL_TEAM_MESSAGES_PER_TURN` logical payloads, counting entries in batches. Each compact JSON packet + uses at most `TEAM_MESSAGE_MAX_SIZE` UTF-8 bytes, and the compact message array uses at most + `TEAM_MESSAGES_MAX_BYTES_PER_TURN` UTF-8 bytes. Accepted messages arrive on the next turn. A failed call does + not enqueue its packet. Args: message: The message to broadcast. @@ -921,10 +925,11 @@ def send_team_message(self, teammate_id: int, message: Any) -> None: meaning that all public fields, and only public fields, are being serialized into a JSON representation as a DTO (data transfer object). - A turn accepts at most `MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN` messages. Each compact JSON message uses at most - `TEAM_MESSAGE_MAX_SIZE` UTF-8 bytes, and the complete compact message array uses at most - `TEAM_MESSAGES_MAX_BYTES_PER_TURN` UTF-8 bytes. Accepted messages are delivered on the next turn. A failed call - does not enqueue its message. + A turn accepts at most `MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN` packets and + `MAX_LOGICAL_TEAM_MESSAGES_PER_TURN` logical payloads, counting entries in batches. Each compact JSON packet + uses at most `TEAM_MESSAGE_MAX_SIZE` UTF-8 bytes, and the compact message array uses at most + `TEAM_MESSAGES_MAX_BYTES_PER_TURN` UTF-8 bytes. Accepted messages arrive on the next turn. A failed call does + not enqueue its packet. Args: teammate_id: The id of the teammate to send the message to. @@ -936,6 +941,24 @@ def send_team_message(self, teammate_id: int, message: Any) -> None: """ pass + @abstractmethod + def broadcast_team_message_batch(self, messages: Any) -> None: + """Broadcasts ordered entries in one packet and one next-turn event. + + Every recipient must support batch version 1. Empty or null-containing batches and packets or intents over + the documented limits are rejected before enqueue. + """ + pass + + @abstractmethod + def send_team_message_batch(self, teammate_id: int, messages: Any) -> None: + """Sends ordered entries to one teammate in one packet and one next-turn event. + + The recipient must support batch version 1. Empty or null-containing batches and packets or intents over + the documented limits are rejected before enqueue. + """ + pass + @property @abstractmethod def stopped(self) -> bool: diff --git a/bot-api/python/src/robocode_tank_royale/bot_api/constants.py b/bot-api/python/src/robocode_tank_royale/bot_api/constants.py index 7dffac563..87416afdb 100644 --- a/bot-api/python/src/robocode_tank_royale/bot_api/constants.py +++ b/bot-api/python/src/robocode_tank_royale/bot_api/constants.py @@ -93,12 +93,12 @@ """ MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN = 64 -""" -Maximum number of team messages that can be sent per turn (64). -""" +"""Maximum physical team-message packets per turn.""" +MAX_LOGICAL_TEAM_MESSAGES_PER_TURN = 128 +"""Maximum logical messages counting entries in all batches per turn.""" TEAM_MESSAGE_MAX_SIZE = 48 * 1024 -"""Maximum UTF-8 bytes of one compact JSON-encoded team message (48 KiB).""" +"""Maximum UTF-8 bytes of one compact JSON-encoded team-message packet (48 KiB).""" TEAM_MESSAGES_MAX_BYTES_PER_TURN = 256 * 1024 """Maximum UTF-8 bytes of the compact encoded teamMessages array per turn (256 KiB).""" diff --git a/bot-api/python/src/robocode_tank_royale/bot_api/internal/base_bot_internals.py b/bot-api/python/src/robocode_tank_royale/bot_api/internal/base_bot_internals.py index 83b2e65fb..93e1ead3e 100644 --- a/bot-api/python/src/robocode_tank_royale/bot_api/internal/base_bot_internals.py +++ b/bot-api/python/src/robocode_tank_royale/bot_api/internal/base_bot_internals.py @@ -869,6 +869,7 @@ def broadcast_team_message(self, message: "Any") -> None: def send_team_message(self, teammate_id: Optional[int], message: Any) -> None: import json from ..team_message import serialize_team_message + from ..team_message_batch import TeamMessageBatch from .json_util import MessageEncoder IntentValidator.validate_teammate_id(teammate_id, self.teammate_ids) @@ -881,15 +882,29 @@ def send_team_message(self, teammate_id: Optional[int], message: Any) -> None: IntentValidator.validate_team_message(message, len(team_messages_list)) # Serialize the message using team_message module which handles Color objects - json_message_str = serialize_team_message(message) + is_batch = isinstance(message, TeamMessageBatch) + if is_batch: + payload = {"messages": [ + {"messageType": type(item).__name__, "message": serialize_team_message(item)} + for item in message.messages + ]} + json_message_str = json.dumps(payload, separators=(",", ":"), ensure_ascii=False) + else: + json_message_str = serialize_team_message(message) IntentValidator.validate_team_message_size(json_message_str) team_message = TeamMessage( - message_type=type(message).__name__, + message_type="team-message-batch-v1" if is_batch else type(message).__name__, receiver_id=teammate_id, message=json_message_str, ) candidate_messages = [*team_messages_list, team_message] + logical_count = sum( + len(json.loads(item.message)["messages"]) + if item.message_type == "team-message-batch-v1" else 1 + for item in candidate_messages + ) + IntentValidator.validate_logical_team_message_count(logical_count) compact_messages = json.dumps(candidate_messages, cls=MessageEncoder, separators=(",", ":"), ensure_ascii=False) IntentValidator.validate_team_messages_size(compact_messages) team_messages_list.append(team_message) diff --git a/bot-api/python/src/robocode_tank_royale/bot_api/internal/bot_handshake_factory.py b/bot-api/python/src/robocode_tank_royale/bot_api/internal/bot_handshake_factory.py index 97d96d5cf..aa6529352 100644 --- a/bot-api/python/src/robocode_tank_royale/bot_api/internal/bot_handshake_factory.py +++ b/bot-api/python/src/robocode_tank_royale/bot_api/internal/bot_handshake_factory.py @@ -36,6 +36,7 @@ def create( # Set debugger_attached field (ADR-035) debugger_attached = BotHandshakeFactory.is_debugger_attached() handshake.debugger_attached = debugger_attached + handshake.team_message_batch_version = 1 # Log hint if debugger is detected if debugger_attached: diff --git a/bot-api/python/src/robocode_tank_royale/bot_api/internal/event_queue.py b/bot-api/python/src/robocode_tank_royale/bot_api/internal/event_queue.py index 1733e774c..6ea1ccf71 100644 --- a/bot-api/python/src/robocode_tank_royale/bot_api/internal/event_queue.py +++ b/bot-api/python/src/robocode_tank_royale/bot_api/internal/event_queue.py @@ -183,6 +183,14 @@ def is_old_and_non_critical_event(bot_event: BotEvent, turn_number: int) -> bool def add_event(self, bot_event: BotEvent): with self.events_lock: + # Team messages are bounded per sender by the protocol, so they do not count toward the ordinary + # queue limit. Old events are otherwise only removed on dispatch, so drop stale team messages here + # to keep the queue bounded when the bot does not call go(). + if not isinstance(bot_event, TeamMessageEvent): + self.events = deque( + event for event in self.events + if not (isinstance(event, TeamMessageEvent) + and EventQueue.is_old_and_non_critical_event(event, bot_event.turn_number))) if isinstance(bot_event, TeamMessageEvent) or sum( not isinstance(event, TeamMessageEvent) for event in self.events ) < EventQueue.MAX_QUEUE_SIZE: diff --git a/bot-api/python/src/robocode_tank_royale/bot_api/internal/intent_validator.py b/bot-api/python/src/robocode_tank_royale/bot_api/internal/intent_validator.py index e6fb50880..458ecea0c 100644 --- a/bot-api/python/src/robocode_tank_royale/bot_api/internal/intent_validator.py +++ b/bot-api/python/src/robocode_tank_royale/bot_api/internal/intent_validator.py @@ -11,6 +11,7 @@ DECELERATION, ACCELERATION, MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN, + MAX_LOGICAL_TEAM_MESSAGES_PER_TURN, TEAM_MESSAGE_MAX_SIZE, TEAM_MESSAGES_MAX_BYTES_PER_TURN, ) @@ -134,6 +135,13 @@ def validate_team_message(message: Any, current_team_message_count: int) -> None if message is None: raise ValueError("The 'message' of a team message cannot be null") + @staticmethod + def validate_logical_team_message_count(logical_message_count: int) -> None: + if logical_message_count > MAX_LOGICAL_TEAM_MESSAGES_PER_TURN: + raise BotException( + f"The maximum number of logical team messages has already been reached: {MAX_LOGICAL_TEAM_MESSAGES_PER_TURN}" + ) + @staticmethod def validate_team_message_size(json_message_str: str) -> None: if len(json_message_str.encode("utf-8")) > TEAM_MESSAGE_MAX_SIZE: diff --git a/bot-api/python/src/robocode_tank_royale/bot_api/mapper/event_mapper.py b/bot-api/python/src/robocode_tank_royale/bot_api/mapper/event_mapper.py index 04f21f9d4..17b2a8e1c 100644 --- a/bot-api/python/src/robocode_tank_royale/bot_api/mapper/event_mapper.py +++ b/bot-api/python/src/robocode_tank_royale/bot_api/mapper/event_mapper.py @@ -209,6 +209,14 @@ def _map_team_message_event(source: SchemaTeamMessageEvent) -> TeamMessageEvent: raise BotException("message_type in TeamMessageEvent is None") try: + if message_type == "team-message-batch-v1": + from ..team_message_batch import TeamMessageBatch + payload = json.loads(message) + items = payload.get("messages") if isinstance(payload, dict) else None + if not isinstance(items, list) or not items: + raise BotException("Invalid team message batch payload") + values = [deserialize_team_message(item["message"], item["messageType"]) for item in items] + return TeamMessageEvent(source.turn_number, TeamMessageBatch(values), source.sender_id) message_object = deserialize_team_message(message, message_type) return TeamMessageEvent(source.turn_number, message_object, source.sender_id) except json.JSONDecodeError as e: diff --git a/bot-api/python/src/robocode_tank_royale/bot_api/team_message_batch.py b/bot-api/python/src/robocode_tank_royale/bot_api/team_message_batch.py new file mode 100644 index 000000000..16da091bd --- /dev/null +++ b/bot-api/python/src/robocode_tank_royale/bot_api/team_message_batch.py @@ -0,0 +1,20 @@ +from dataclasses import dataclass +from typing import Any, Iterable + + +@dataclass(frozen=True) +class TeamMessageBatch: + """Immutable ordered payloads delivered as one team-message event on the next turn. + + A batch must contain at least one non-null value. Each entry counts toward the turn's logical-payload limit. + """ + messages: tuple[Any, ...] + + def __init__(self, messages: Iterable[Any]): + # Materialize first so a one-shot iterable (e.g. a generator) is validated and stored intact + items = tuple(messages) + if not items: + raise ValueError("A team message batch must contain at least one message") + if any(message is None for message in items): + raise ValueError("A team message batch cannot contain null messages") + object.__setattr__(self, "messages", items) diff --git a/bot-api/python/tests/bot_api/test_team_message_batch.py b/bot-api/python/tests/bot_api/test_team_message_batch.py new file mode 100644 index 000000000..7573cbecb --- /dev/null +++ b/bot-api/python/tests/bot_api/test_team_message_batch.py @@ -0,0 +1,33 @@ +import pytest + +from robocode_tank_royale.bot_api import BotException, TeamMessageBatch +from robocode_tank_royale.bot_api.constants import MAX_LOGICAL_TEAM_MESSAGES_PER_TURN +from robocode_tank_royale.bot_api.internal.intent_validator import IntentValidator + + +@pytest.mark.TR_API_TCK_022 +def test_batch_keeps_order_and_limit_is_shared(): + batch = TeamMessageBatch(["first", "second"]) + assert batch.messages == ("first", "second") + assert MAX_LOGICAL_TEAM_MESSAGES_PER_TURN == 128 + IntentValidator.validate_logical_team_message_count(128) + with pytest.raises(BotException): + IntentValidator.validate_logical_team_message_count(129) + + +@pytest.mark.TR_API_TCK_022 +def test_batch_requires_nonempty_nonnull_values(): + with pytest.raises(ValueError): + TeamMessageBatch([]) + with pytest.raises(ValueError): + TeamMessageBatch(["ok", None]) + + +@pytest.mark.TR_API_TCK_022 +def test_batch_accepts_one_shot_iterables(): + batch = TeamMessageBatch(message for message in ["first", "second"]) + assert batch.messages == ("first", "second") + with pytest.raises(ValueError): + TeamMessageBatch(message for message in []) + with pytest.raises(ValueError): + TeamMessageBatch(message for message in ["ok", None]) diff --git a/bot-api/python/tests/test_shared.py b/bot-api/python/tests/test_shared.py index 23407c0ef..e9299b175 100644 --- a/bot-api/python/tests/test_shared.py +++ b/bot-api/python/tests/test_shared.py @@ -15,6 +15,7 @@ from robocode_tank_royale.bot_api.internal.bot_event_handlers import BotEventHandlers from robocode_tank_royale.bot_api.base_bot import BaseBot as BaseBotClass from robocode_tank_royale.bot_api.bot_exception import BotException +from robocode_tank_royale.bot_api.team_message_batch import TeamMessageBatch from robocode_tank_royale.bot_api.bot_info import BotInfo from robocode_tank_royale.bot_api.initial_position import InitialPosition from robocode_tank_royale.bot_api.graphics import Color @@ -88,12 +89,13 @@ def create_event(event_name): if event_name == "HitBotEvent": return HitBotEvent(turn_number=0, victim_id=0, energy=0, x=0, y=0, rammed=False) raise ValueError(f"Unknown event: {event_name}") -def create_event_at(event_name, turn_number): +def create_event_at(event_name, turn_number, sequence=0): if event_name == "WonRoundEvent": return WonRoundEvent(turn_number=turn_number) if event_name == "DeathEvent": return DeathEvent(turn_number=turn_number) if event_name == "ScannedBotEvent": return ScannedBotEvent(turn_number=turn_number, scanned_by_bot_id=0, scanned_bot_id=0, energy=0, x=0, y=0, direction=0, speed=0) if event_name == "SkippedTurnEvent":return SkippedTurnEvent(turn_number=turn_number) if event_name == "BotDeathEvent": return BotDeathEvent(turn_number=turn_number, victim_id=0) + if event_name == "TeamMessageEvent":return TeamMessageEvent(turn_number=turn_number, message=str(sequence), sender_id=0) raise ValueError(f"Unknown event for scenario: {event_name}") @pytest.mark.Unit @@ -164,6 +166,8 @@ def run_action(): last_action_value[0] = getattr(Color, args[0].upper()) elif method == "getConstant": last_action_value[0] = get_constant(args[0]) + elif method == "createTeamMessageBatch": + last_action_value[0] = list(TeamMessageBatch(args[0]).messages) elif method == "isCritical": last_action_value[0] = create_event(args[0]).critical elif method == "getDefaultPriority": @@ -248,6 +252,7 @@ def _run_scenario(test_case): handlers = MagicMock(spec=BotEventHandlers) queue = EventQueue(mock_internals, handlers) + sequence = 0 for step in test_case.get('steps', []): action = step.get('action') if action == 'addEvent': @@ -255,7 +260,8 @@ def _run_scenario(test_case): turn_number = step['turnNumber'] repeat = step.get('repeat', 1) for _ in range(repeat): - queue.add_event(create_event_at(event_type, turn_number)) + queue.add_event(create_event_at(event_type, turn_number, sequence)) + sequence += 1 elif action == 'dispatchEvents': at_turn = step['atTurn'] queue.dispatch_events(at_turn) @@ -268,6 +274,10 @@ def _run_scenario(test_case): for i, expected_type in enumerate(expected_order): actual = fired_calls[i][0][0] assert type(actual).__name__ == expected_type, f"Event at index {i}: got {type(actual).__name__}, expected {expected_type}" + if 'dispatchedMessages' in expect_after: + actual_messages = [call[0][0].message for call in handlers.fire_event.call_args_list + if isinstance(call[0][0], TeamMessageEvent)] + assert actual_messages == expect_after['dispatchedMessages'], "Team message dispatch order mismatch" if 'queueSize' in expect_after: expected_size = expect_after['queueSize'] assert len(queue.events) == expected_size, f"Queue size mismatch: got {len(queue.events)}, expected {expected_size}" diff --git a/bot-api/tests/TEST-REGISTRY.md b/bot-api/tests/TEST-REGISTRY.md index 899f5ce46..f994b57a6 100644 --- a/bot-api/tests/TEST-REGISTRY.md +++ b/bot-api/tests/TEST-REGISTRY.md @@ -72,6 +72,7 @@ This applies to all tiers and all categories (VAL, CMD, TCK, BOT, UTL, GFX). | TR-API-TCK-019 | Final-turn events still drain once the bot thread stops owning the round | 2 | ✅ | ✅ | ✅ | ✅ | | TR-API-TCK-020 | An unexpected error from run() still drains final-turn events | 2 | ✅ | ✅ | ✅ | ✅ | | TR-API-TCK-021 | onWonRound fires once per won round, not once per delivery path | 2 | ❌ | ❌ | ❌ | ✅ | +| TR-API-TCK-022 | Team message batches preserve order, reject empty/null items, and enforce the 128-item limit | 2 | ✅ | ✅ | ✅ | ✅ | ## EVT — Events @@ -86,6 +87,8 @@ This applies to all tiers and all categories (VAL, CMD, TCK, BOT, UTL, GFX). | TR-API-EVT-007 | EventQueue size cap (MAX_QUEUE_SIZE = 256) | 1 | ✅ | ✅ | ✅ | ✅ | | TR-API-EVT-008 | Condition.test() callable and overridable | 2 | ✅ | ✅ | ✅ | ✅ | | TR-API-EVT-009 | CustomEvent dispatches when Condition.test() is true | 2 | ✅ | ✅ | ✅ | ✅ | +| TR-API-EVT-010 | EventQueue keeps same-turn team messages in arrival order (64 events) | 1 | ✅ | ✅ | ✅ | ✅ | +| TR-API-EVT-011 | EventQueue drops stale team messages on add, bounding undispatched messages | 1 | ✅ | ✅ | ✅ | ✅ | ## MDL — Data Models @@ -137,13 +140,13 @@ This applies to all tiers and all categories (VAL, CMD, TCK, BOT, UTL, GFX). |----------|-----------|------|----|--------|------------| | VAL | 5 | 5 | 5 | 5 | 5 | | CMD | 3 | 3 | 3 | 3 | 3 | -| TCK | 18 | 17 | 17 | 17 | 18 | +| TCK | 19 | 18 | 18 | 18 | 19 | | EVT | 9 | 9 | 9 | 9 | 9 | | MDL | 4 | 4 | 4 | 4 | 4 | | BOT | 11 | 11 | 11 | 11 | 11 | | UTL | 3 | 3 | 3 | 3 | 3 | | GFX | 4 | 4 | 4 | 4 | 4 | -| **Total** | **57** | **56** | **56** | **56** | **57** | +| **Total** | **58** | **57** | **57** | **57** | **58** | --- diff --git a/bot-api/tests/shared/constants.json b/bot-api/tests/shared/constants.json index da75bea4d..5d39b51b5 100644 --- a/bot-api/tests/shared/constants.json +++ b/bot-api/tests/shared/constants.json @@ -114,6 +114,14 @@ "args": ["MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN"], "expected": { "returns": 64 } }, + { + "id": "TR-API-VAL-005-constants-max-logical-team-messages-per-turn", + "description": "Maximum logical team message payloads per turn", + "type": "positive", + "method": "getConstant", + "args": ["MAX_LOGICAL_TEAM_MESSAGES_PER_TURN"], + "expected": { "returns": 128 } + }, { "id": "TR-API-VAL-005-constants-team-messages-max-bytes-per-turn", "description": "Maximum aggregate team message bytes per turn", diff --git a/bot-api/tests/shared/event-queue.json b/bot-api/tests/shared/event-queue.json index 2d95f3aa5..241ae158b 100644 --- a/bot-api/tests/shared/event-queue.json +++ b/bot-api/tests/shared/event-queue.json @@ -40,6 +40,32 @@ "expectAfter": { "queueSize": 256 } + }, + { + "id": "TR-API-EVT-010", + "type": "scenario", + "description": "Team messages with the same turn and priority are dispatched in the order they were added, also beyond 16 events", + "steps": [ + { "action": "addEvent", "eventType": "TeamMessageEvent", "turnNumber": 1, "repeat": 64 }, + { "action": "dispatchEvents", "atTurn": 1 } + ], + "expectAfter": { + "dispatchedMessages": ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "30", "31", "32", "33", "34", "35", "36", "37", "38", "39", "40", "41", "42", "43", "44", "45", "46", "47", "48", "49", "50", "51", "52", "53", "54", "55", "56", "57", "58", "59", "60", "61", "62", "63"] + } + }, + { + "id": "TR-API-EVT-011", + "type": "scenario", + "description": "Adding an event for a newer turn drops queued team messages older than MAX_EVENT_AGE turns, so undispatched team messages stay bounded", + "steps": [ + { "action": "addEvent", "eventType": "TeamMessageEvent", "turnNumber": 1, "repeat": 300 }, + { "action": "addEvent", "eventType": "TeamMessageEvent", "turnNumber": 2, "repeat": 2 }, + { "action": "addEvent", "eventType": "SkippedTurnEvent", "turnNumber": 4 } + ], + "expectAfter": { + "queueSize": 3, + "queueSizeAtTurn": 4 + } } ] } diff --git a/bot-api/tests/shared/team-message-batch.json b/bot-api/tests/shared/team-message-batch.json new file mode 100644 index 000000000..9d45caa0c --- /dev/null +++ b/bot-api/tests/shared/team-message-batch.json @@ -0,0 +1,30 @@ +{ + "suite": "team-message-batch", + "description": "Shared batch construction, ordering, and invalid-entry behavior across all Bot APIs.", + "tests": [ + { + "id": "TR-API-TCK-022-batch-preserves-order", + "description": "Positive: a batch preserves payload order.", + "type": "positive", + "method": "createTeamMessageBatch", + "args": [["first", "second"]], + "expected": { "returns": ["first", "second"] } + }, + { + "id": "TR-API-TCK-022-batch-rejects-empty", + "description": "Negative: an empty batch is rejected.", + "type": "negative", + "method": "createTeamMessageBatch", + "args": [[]], + "expected": { "throws": "IllegalArgumentException" } + }, + { + "id": "TR-API-TCK-022-batch-rejects-null-entry", + "description": "Negative: a batch containing a null payload is rejected.", + "type": "negative", + "method": "createTeamMessageBatch", + "args": [["first", null]], + "expected": { "throws": "IllegalArgumentException" } + } + ] +} diff --git a/bot-api/tests/shared/test-definition.schema.json b/bot-api/tests/shared/test-definition.schema.json index c9125cdf2..08cd4579b 100644 --- a/bot-api/tests/shared/test-definition.schema.json +++ b/bot-api/tests/shared/test-definition.schema.json @@ -138,6 +138,16 @@ "type": "integer", "minimum": 0, "description": "Expected number of events remaining in the queue after all steps." + }, + "queueSizeAtTurn": { + "type": "integer", + "minimum": 0, + "description": "Current turn used when reading the queue size (APIs that cull old events on read). Defaults to 999." + }, + "dispatchedMessages": { + "type": "array", + "items": { "type": "string" }, + "description": "Expected sequence of dispatched team-message payloads, in order." } } } diff --git a/bot-api/typescript/README.md b/bot-api/typescript/README.md index 066a8ec74..1b8dfa15f 100644 --- a/bot-api/typescript/README.md +++ b/bot-api/typescript/README.md @@ -26,7 +26,7 @@ npm install @robocode.dev/tank-royale-bot-api To install a specific version: ```bash -npm install @robocode.dev/tank-royale-bot-api@1.3.1 +npm install @robocode.dev/tank-royale-bot-api@1.4.0 ``` The `ws` package is required at runtime in a Node.js environment: diff --git a/bot-api/typescript/package-lock.json b/bot-api/typescript/package-lock.json index 6078ea19d..5db50ef6e 100644 --- a/bot-api/typescript/package-lock.json +++ b/bot-api/typescript/package-lock.json @@ -1,12 +1,12 @@ { "name": "@robocode.dev/tank-royale-bot-api", - "version": "1.3.1", + "version": "1.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@robocode.dev/tank-royale-bot-api", - "version": "1.3.1", + "version": "1.4.0", "license": "Apache-2.0", "devDependencies": { "@types/node": "^22.0.0", diff --git a/bot-api/typescript/package.json b/bot-api/typescript/package.json index 62c28c5c6..89ba49ed4 100644 --- a/bot-api/typescript/package.json +++ b/bot-api/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@robocode.dev/tank-royale-bot-api", - "version": "1.3.1", + "version": "1.4.0", "description": "Tank Royale Bot API for TypeScript/JavaScript", "license": "Apache-2.0", "homepage": "https://robocode-dev.github.io/tank-royale/", diff --git a/bot-api/typescript/src/BaseBot.ts b/bot-api/typescript/src/BaseBot.ts index eabd8a9b4..de109a45e 100644 --- a/bot-api/typescript/src/BaseBot.ts +++ b/bot-api/typescript/src/BaseBot.ts @@ -1,5 +1,6 @@ import { IBaseBot } from "./IBaseBot.js"; import { BotInfo } from "./BotInfo.js"; +import { TeamMessageBatch } from "./TeamMessageBatch.js"; import { BulletState } from "./BulletState.js"; import { Color } from "./graphics/Color.js"; import { IGraphics } from "./graphics/IGraphics.js"; @@ -41,6 +42,7 @@ import { MathUtil } from "./util/MathUtil.js"; export abstract class BaseBot implements IBaseBot { readonly TEAM_MESSAGE_MAX_SIZE = Constants.TEAM_MESSAGE_MAX_SIZE; readonly MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN = Constants.MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN; + readonly MAX_LOGICAL_TEAM_MESSAGES_PER_TURN = Constants.MAX_LOGICAL_TEAM_MESSAGES_PER_TURN; readonly TEAM_MESSAGES_MAX_BYTES_PER_TURN = Constants.TEAM_MESSAGES_MAX_BYTES_PER_TURN; /** @internal */ @@ -178,6 +180,8 @@ export abstract class BaseBot implements IBaseBot { isTeammate(botId: number): boolean { return this._internals.isTeammate(botId); } broadcastTeamMessage(message: unknown): void { this._internals.broadcastTeamMessage(message); } sendTeamMessage(teammateId: number, message: unknown): void { this._internals.sendTeamMessage(teammateId, message); } + broadcastTeamMessageBatch(messages: readonly unknown[]): void { this._internals.broadcastTeamMessage(new TeamMessageBatch(messages)); } + sendTeamMessageBatch(teammateId: number, messages: readonly unknown[]): void { this._internals.sendTeamMessage(teammateId, new TeamMessageBatch(messages)); } getBodyColor(): Color | null { return this._internals.getBodyColor(); } setBodyColor(color: Color | null): void { this._internals.setBodyColor(color); } diff --git a/bot-api/typescript/src/BotHandshakeFactory.ts b/bot-api/typescript/src/BotHandshakeFactory.ts index 97e1c44d9..3a51ab68e 100644 --- a/bot-api/typescript/src/BotHandshakeFactory.ts +++ b/bot-api/typescript/src/BotHandshakeFactory.ts @@ -47,6 +47,7 @@ export class BotHandshakeFactory { // Set debuggerAttached field (ADR-035) const debuggerAttached = BotHandshakeFactory.isDebuggerAttached(envVars); handshake.debuggerAttached = debuggerAttached; + handshake.teamMessageBatchVersion = 1; // Log hint if debugger is detected if (debuggerAttached) { diff --git a/bot-api/typescript/src/Constants.ts b/bot-api/typescript/src/Constants.ts index c41d4b8c4..71c68fe28 100644 --- a/bot-api/typescript/src/Constants.ts +++ b/bot-api/typescript/src/Constants.ts @@ -122,15 +122,17 @@ export const Constants = { STARTING_GUN_HEAT: 3.0, /** - * The maximum size of a team message in UTF-8 bytes (48 KiB), which is the serialized (compact JSON) - * size of the message object. Messages exceeding this size are rejected. + * The maximum size of one team-message packet in UTF-8 bytes (48 KiB), measured on its compact JSON payload. + * Messages exceeding this size are rejected. */ TEAM_MESSAGE_MAX_SIZE: 48 * 1024, /** - * The maximum number of team messages that can be sent per turn, which is 64 messages. - */ + * The maximum number of team-message packets that can be sent per turn, including each batch as one packet. + */ MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN: 64, + /** Maximum logical payloads per turn, counting each entry in a batch. */ + MAX_LOGICAL_TEAM_MESSAGES_PER_TURN: 128, /** Maximum UTF-8 bytes of the compact encoded teamMessages array per turn (256 KiB). */ TEAM_MESSAGES_MAX_BYTES_PER_TURN: 256 * 1024, diff --git a/bot-api/typescript/src/IBaseBot.ts b/bot-api/typescript/src/IBaseBot.ts index 925c45cc4..8a20adbcc 100644 --- a/bot-api/typescript/src/IBaseBot.ts +++ b/bot-api/typescript/src/IBaseBot.ts @@ -33,9 +33,12 @@ export interface IBaseBot { /** The maximum size of an encoded team message in UTF-8 bytes (48 KiB). */ readonly TEAM_MESSAGE_MAX_SIZE: number; - /** The maximum number of team messages that can be sent per turn. */ + /** The maximum number of team-message packets per turn, with each batch counting as one packet. */ readonly MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN: number; + /** Maximum logical payloads per turn, counting batch entries. */ + readonly MAX_LOGICAL_TEAM_MESSAGES_PER_TURN: number; + /** Maximum UTF-8 bytes of the compact encoded teamMessages array per turn (256 KiB). */ readonly TEAM_MESSAGES_MAX_BYTES_PER_TURN: number; @@ -236,21 +239,27 @@ export interface IBaseBot { /** * Broadcasts a compact JSON message to all teammates for delivery on the next turn. - * A turn accepts at most 64 messages, each up to 48 KiB UTF-8, and up to 256 KiB for the complete compact array. - * Throws without enqueueing this message when any limit is exceeded. + * A turn accepts at most 64 packets and 128 logical payloads, counting batch entries. Each packet is at most 48 KiB + * UTF-8 and the compact array is at most 256 KiB. A batch arrives as one ordered event on the next turn. All + * recipients must support batch version 1. Throws without enqueueing the packet when any limit is exceeded. * @throws {BotException} When the per-turn message count has been reached. * @throws {Error} When the message or complete batch exceeds its UTF-8 byte limit. */ broadcastTeamMessage(message: unknown): void; + /** Sends ordered entries in one packet and one next-turn event. Every recipient must support batch version 1. */ + broadcastTeamMessageBatch(messages: readonly unknown[]): void; /** * Sends a compact JSON message to a teammate for delivery on the next turn. - * A turn accepts at most 64 messages, each up to 48 KiB UTF-8, and up to 256 KiB for the complete compact array. - * Throws without enqueueing this message when the recipient is invalid or any limit is exceeded. + * A turn accepts at most 64 packets and 128 logical payloads, counting batch entries. Each packet is at most 48 KiB + * UTF-8 and the compact array is at most 256 KiB. A batch arrives as one ordered event on the next turn. All + * recipients must support batch version 1. Throws without enqueueing the packet when any limit is exceeded. * @throws {BotException} When the per-turn message count has been reached. * @throws {Error} When the recipient is invalid or the message or complete batch exceeds its UTF-8 byte limit. */ sendTeamMessage(teammateId: number, message: unknown): void; + /** Sends ordered entries to one teammate in one packet and one next-turn event. */ + sendTeamMessageBatch(teammateId: number, messages: readonly unknown[]): void; /** The body color of the bot. */ getBodyColor(): Color | null; diff --git a/bot-api/typescript/src/TeamMessageBatch.ts b/bot-api/typescript/src/TeamMessageBatch.ts new file mode 100644 index 000000000..5ea0992be --- /dev/null +++ b/bot-api/typescript/src/TeamMessageBatch.ts @@ -0,0 +1,13 @@ +/** Immutable ordered payloads delivered as one team-message event on the next turn. */ +export class TeamMessageBatch { + readonly messages: readonly unknown[]; + + constructor(messages: readonly unknown[]) { + if (!Array.isArray(messages)) throw new Error("A team message batch must contain at least one message"); + if (messages.length === 0) throw new Error("A team message batch must contain at least one message"); + if (messages.some((message) => message === null || message === undefined)) { + throw new Error("A team message batch cannot contain null messages"); + } + this.messages = Object.freeze([...messages]); + } +} diff --git a/bot-api/typescript/src/events/EventQueue.ts b/bot-api/typescript/src/events/EventQueue.ts index 5c570ce31..ef30606db 100644 --- a/bot-api/typescript/src/events/EventQueue.ts +++ b/bot-api/typescript/src/events/EventQueue.ts @@ -25,6 +25,14 @@ export class EventQueue { } addEvent(event: BotEvent): void { + // Team messages are bounded per sender by the protocol, so they do not count toward the ordinary + // queue limit. Old events are otherwise only removed on dispatch, so drop stale team messages here + // to keep the queue bounded when the bot does not call go(). + if (!(event instanceof TeamMessageEvent)) { + this.events = this.events.filter( + (e) => !(e instanceof TeamMessageEvent) || e.isCritical || event.turnNumber - e.turnNumber <= MAX_EVENT_AGE, + ); + } if (!(event instanceof TeamMessageEvent) && this.events.filter(e => !(e instanceof TeamMessageEvent)).length >= MAX_QUEUE_SIZE) { return; diff --git a/bot-api/typescript/src/index.ts b/bot-api/typescript/src/index.ts index be7bceace..6591cfed7 100644 --- a/bot-api/typescript/src/index.ts +++ b/bot-api/typescript/src/index.ts @@ -22,6 +22,7 @@ export type { IBot } from "./IBot.js"; export { BaseBot } from "./BaseBot.js"; export { Bot } from "./Bot.js"; export { BotException } from "./BotException.js"; +export { TeamMessageBatch } from "./TeamMessageBatch.js"; // Events export { BotEvent } from "./events/BotEvent.js"; diff --git a/bot-api/typescript/src/internal/BaseBotInternals.ts b/bot-api/typescript/src/internal/BaseBotInternals.ts index 315f764da..d16addab8 100644 --- a/bot-api/typescript/src/internal/BaseBotInternals.ts +++ b/bot-api/typescript/src/internal/BaseBotInternals.ts @@ -37,6 +37,7 @@ import { InitialPosition } from "../InitialPosition.js"; import { EventMapper } from "../mapper/EventMapper.js"; import { toJson } from "../json/JsonUtil.js"; import { IntentValidator } from "./intentValidator.js"; +import { TeamMessageBatch } from "../TeamMessageBatch.js"; import { Constants } from "../Constants.js"; import type { BotIntent as SchemaBotIntent } from "../protocol/schema.js"; import { MessageType } from "../protocol/MessageType.js"; @@ -1147,7 +1148,14 @@ export class BaseBotInternals { } sendTeamMessage(teammateId: number | undefined, message: unknown): void { - const json = toJson(message); + IntentValidator.validateTeammateId(teammateId, this.teammateIds); + const batch = message instanceof TeamMessageBatch; + const json = batch + ? toJson({ messages: message.messages.map((value) => ({ + messageType: typeof value === "object" && value !== null ? value.constructor.name : "string", + message: toJson(value), + })) }) + : toJson(message); IntentValidator.validateTeamMessageSize(json); if (!this.intent.teamMessages) this.intent.teamMessages = []; @@ -1156,10 +1164,14 @@ export class BaseBotInternals { const teamMessage = { message: json, - messageType: typeof message === "object" && message !== null ? message.constructor.name : "string", + messageType: batch ? "team-message-batch-v1" : (typeof message === "object" && message !== null ? message.constructor.name : "string"), receiverId: teammateId ?? null, }; - IntentValidator.validateTeamMessagesSize(toJson([...this.intent.teamMessages, teamMessage])); + const candidateMessages = [...this.intent.teamMessages, teamMessage]; + const logicalCount = candidateMessages.reduce((count, candidate) => count + + (candidate.messageType === "team-message-batch-v1" ? (JSON.parse(candidate.message) as { messages: unknown[] }).messages.length : 1), 0); + IntentValidator.validateLogicalTeamMessageCount(logicalCount); + IntentValidator.validateTeamMessagesSize(toJson(candidateMessages)); this.intent.teamMessages.push(teamMessage); } diff --git a/bot-api/typescript/src/internal/intentValidator.ts b/bot-api/typescript/src/internal/intentValidator.ts index 318b41119..1527737c6 100644 --- a/bot-api/typescript/src/internal/intentValidator.ts +++ b/bot-api/typescript/src/internal/intentValidator.ts @@ -105,6 +105,12 @@ export class IntentValidator { } } + static validateLogicalTeamMessageCount(logicalMessageCount: number): void { + if (logicalMessageCount > Constants.MAX_LOGICAL_TEAM_MESSAGES_PER_TURN) { + throw new BotException("The maximum number of logical team messages has already been reached: " + Constants.MAX_LOGICAL_TEAM_MESSAGES_PER_TURN); + } + } + static validateTeamMessageSize(json: string): void { if (new TextEncoder().encode(json).length > Constants.TEAM_MESSAGE_MAX_SIZE) { throw new Error("The team message is larger than the limit of " + Constants.TEAM_MESSAGE_MAX_SIZE + " bytes (compact JSON format)"); diff --git a/bot-api/typescript/src/mapper/EventMapper.ts b/bot-api/typescript/src/mapper/EventMapper.ts index e5f5779da..917232e40 100644 --- a/bot-api/typescript/src/mapper/EventMapper.ts +++ b/bot-api/typescript/src/mapper/EventMapper.ts @@ -28,6 +28,7 @@ import { BulletHitWallEvent } from "../events/BulletHitWallEvent.js"; import { ScannedBotEvent } from "../events/ScannedBotEvent.js"; import { WonRoundEvent } from "../events/WonRoundEvent.js"; import { TeamMessageEvent } from "../events/TeamMessageEvent.js"; +import { TeamMessageBatch } from "../TeamMessageBatch.js"; import { SkippedTurnEvent } from "../events/SkippedTurnEvent.js"; import { BotStateMapper } from "./BotStateMapper.js"; import { BulletStateMapper } from "./BulletStateMapper.js"; @@ -91,6 +92,13 @@ export class EventMapper { } case MessageType.TeamMessageEvent: { const ev = e as SchemaTeamMessageEvent; + if (ev.messageType === "team-message-batch-v1") { + const payload = JSON.parse(ev.message) as { messages?: Array<{ message?: string; messageType?: string }> }; + if (!Array.isArray(payload.messages) || payload.messages.length === 0 || payload.messages.some((item) => typeof item.message !== "string" || typeof item.messageType !== "string")) { + throw new Error("Invalid team message batch payload"); + } + return new TeamMessageEvent(ev.turnNumber, new TeamMessageBatch(payload.messages.map((item) => JSON.parse(item.message!))), ev.senderId); + } return new TeamMessageEvent(ev.turnNumber, ev.message, ev.senderId); } case MessageType.SkippedTurnEvent: { diff --git a/bot-api/typescript/src/protocol/schema.ts b/bot-api/typescript/src/protocol/schema.ts index aa336ae77..f1f08d9c9 100644 --- a/bot-api/typescript/src/protocol/schema.ts +++ b/bot-api/typescript/src/protocol/schema.ts @@ -230,6 +230,7 @@ export interface BotHandshake extends Message { isDroid?: boolean | null; secret?: string | null; debuggerAttached?: boolean | null; + teamMessageBatchVersion?: number | null; } /** BotReady (1.4) */ diff --git a/bot-api/typescript/src/version.ts b/bot-api/typescript/src/version.ts index ff23bd0b5..90b62a3c4 100644 --- a/bot-api/typescript/src/version.ts +++ b/bot-api/typescript/src/version.ts @@ -4,4 +4,4 @@ * Do not edit manually — the version is controlled by the /VERSION file in the repository root * and stamped into this file by the Gradle `syncVersion` task (same mechanism as package.json). */ -export const API_VERSION = "1.3.1"; +export const API_VERSION = "1.4.0"; diff --git a/bot-api/typescript/test/SharedTestRunner.test.ts b/bot-api/typescript/test/SharedTestRunner.test.ts index 28826f410..5c5105b39 100644 --- a/bot-api/typescript/test/SharedTestRunner.test.ts +++ b/bot-api/typescript/test/SharedTestRunner.test.ts @@ -19,6 +19,7 @@ import { EventQueue } from '../src/events/EventQueue.js'; import { EventPriorities } from '../src/events/EventPriorities.js'; import { EventInterruption } from '../src/events/EventInterruption.js'; import { BotEventHandlers } from '../src/events/BotEventHandlers.js'; +import { TeamMessageBatch } from '../src/TeamMessageBatch.js'; const sharedTestsDir = path.resolve(__dirname, '../../tests/shared'); @@ -33,6 +34,7 @@ interface Step { interface ExpectAfter { dispatchOrder?: string[]; queueSize?: number; + dispatchedMessages?: string[]; } interface TestCase { @@ -146,6 +148,7 @@ describe('Unit: Shared Cross-Platform Tests', () => { case 'getConstant': lastActionValue = (Constants as any)[args[0]] ?? (DefaultEventPriority as any)[args[0]] ?? (GameType as any)[args[0]]; break; + case 'createTeamMessageBatch': lastActionValue = new TeamMessageBatch(args[0]).messages; break; case 'isCritical': lastActionValue = createEvent(args[0]).isCritical; break; case 'getDefaultPriority': lastActionValue = getDefaultPriority(args[0]); break; case 'calcBulletSpeed': lastActionValue = mockBot.calcBulletSpeed(args[0]); break; @@ -295,13 +298,14 @@ function getDefaultPriority(eventName: string): number { return (DefaultEventPriority as any)[normalized]; } -function createEventAt(name: string, turn: number): Events.BotEvent { +function createEventAt(name: string, turn: number, sequence: number): Events.BotEvent { switch (name) { case "WonRoundEvent": return new Events.WonRoundEvent(turn); case "DeathEvent": return new Events.DeathEvent(turn); case "ScannedBotEvent": return new Events.ScannedBotEvent(turn, 0, 0, 0, 0, 0, 0, 0); case "SkippedTurnEvent": return new Events.SkippedTurnEvent(turn); case "BotDeathEvent": return new Events.BotDeathEvent(turn, 0); + case "TeamMessageEvent": return new Events.TeamMessageEvent(turn, String(sequence), 0); default: throw new Error(`Unknown event for scenario: ${name}`); } } @@ -313,11 +317,12 @@ function executeScenario(testCase: TestCase): void { const handlers = new BotEventHandlers(); const fireSpy = vi.spyOn(handlers, 'fireEvent'); + let sequence = 0; for (const step of testCase.steps ?? []) { if (step.action === 'addEvent') { const repeat = step.repeat ?? 1; for (let i = 0; i < repeat; i++) { - queue.addEvent(createEventAt(step.eventType!, step.turnNumber!)); + queue.addEvent(createEventAt(step.eventType!, step.turnNumber!, sequence++)); } } else if (step.action === 'dispatchEvents') { queue.dispatchEvents(step.atTurn!, handlers); @@ -332,6 +337,13 @@ function executeScenario(testCase: TestCase): void { expect(fired[i][0].constructor.name).toBe(expectAfter.dispatchOrder[i]); } } + if (expectAfter?.dispatchedMessages) { + const actualMessages = fireSpy.mock.calls + .map(call => call[0]) + .filter(event => event instanceof Events.TeamMessageEvent) + .map(event => (event as Events.TeamMessageEvent).message); + expect(actualMessages).toEqual(expectAfter.dispatchedMessages); + } if (expectAfter?.queueSize !== undefined) { expect(queue.getEvents()).toHaveLength(expectAfter.queueSize); } diff --git a/bot-api/typescript/test/TeamMessageBatch.test.ts b/bot-api/typescript/test/TeamMessageBatch.test.ts new file mode 100644 index 000000000..63ad627b2 --- /dev/null +++ b/bot-api/typescript/test/TeamMessageBatch.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { TeamMessageBatch } from "../src/TeamMessageBatch.js"; +import { Constants } from "../src/Constants.js"; +import { IntentValidator } from "../src/internal/intentValidator.js"; +import { BotException } from "../src/BotException.js"; + +describe("TR-API-TCK-022: TeamMessageBatch", () => { + it("keeps order and exposes the shared logical item limit", () => { + expect(new TeamMessageBatch(["first", "second"]).messages).toEqual(["first", "second"]); + expect(Constants.MAX_LOGICAL_TEAM_MESSAGES_PER_TURN).toBe(128); + expect(() => IntentValidator.validateLogicalTeamMessageCount(128)).not.toThrow(); + expect(() => IntentValidator.validateLogicalTeamMessageCount(129)).toThrow(BotException); + }); + + it("requires at least one non-null payload", () => { + expect(() => new TeamMessageBatch([])).toThrow("at least one message"); + expect(() => new TeamMessageBatch(["ok", null])).toThrow("cannot contain null"); + }); +}); diff --git a/build.gradle.kts b/build.gradle.kts index 3632350b4..4554ea7e1 100755 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -243,6 +243,7 @@ subprojects { // Make sure to replace $version token in version.txt when processing the resources withType { + inputs.property("version", project.version) filesMatching("version.properties") { expand(mapOf("version" to version)) } diff --git a/changes/CH-047-team-message-limits/open-questions.md b/changes/CH-047-team-message-limits/open-questions.md index a38d8e7d7..82202f299 100644 --- a/changes/CH-047-team-message-limits/open-questions.md +++ b/changes/CH-047-team-message-limits/open-questions.md @@ -1,7 +1,7 @@ --- id: OQ-002 type: open-question -status: active +status: draft links: [CH-047] title: CH-047 open questions --- @@ -20,6 +20,14 @@ With that repair and a 100 ms test deadline, the five-bot workload delivered all The five-bot workload at 64 messages per bot per turn also failed at the normal 30 ms deadline. The runner measured 57.49 TPS and captured 19,200 sent messages with 128,520 payload bytes. Four recipients received 14,912 of 15,360 expected messages and recorded 6 ordering violations and 2 skipped turns; the fifth received 14,848 messages, with 4 ordering violations and 1 skipped turn. The reduced count improves throughput but does not meet the zero-skipped-turn acceptance gate. +## Acceptance result — 2026-09-23 + +The batching gate passed on the matched local Tank Royale API and runner. The five-bot team sent 128 ordered items in one batch per turn for 60 turns, after a ten-turn warm-up, with the standard 30 ms intent timeout and a 30 TPS default. Every bot received all 30,720 expected items in order; there were zero skipped turns during the workload. The runner measured 94.94 turns/s from turn 10 through turn 80. The compact encoded outbound team-message arrays totaled 2,747,040 bytes; estimated teammate payload fan-out was 10,988,160 bytes, excluding WebSocket framing and transport overhead. The passing result accepts the 64-packet, 128-logical-payload, 49,152-byte packet, and 262,144-byte per-turn array limits without compression. + +The shared cross-platform boundary suite includes the exact 262,144-byte boundary and rejects 262,145 bytes. It also exercises malformed raw intents, Unicode byte accounting, directed and broadcast batches, order, and next-turn delivery. The bridge team-message conformance tests pass against the matched build. The CombatTeam run completes in both engines with zero reported errors; its score difference is recorded separately below. + +The five-round CombatTeam compatibility run against the matched 1.4.0 API and runner completed in both engines with zero reported errors. Classic scored 10,645 and Tank Royale 13,367 (+25.6%); score parity remains a separate discrepancy and is outside the messaging delivery gate. + ## Blocking decision -Should the next trial lower the per-turn message count again, starting at 32, or should it profile and remove the remaining deadline misses before selecting another limit? The acceptance gate requires a human decision before either policy is adopted. +Resolved by the user on 2026-09-23: trial standard ordered batching in one existing packet and event, with no compression at first. Retain the trial packet and byte caps, require batch version 1 from all recipients, and compare the five-bot 30 TPS result against the same zero-loss, zero-skipped-turn gate. The trial does not become a published policy unless that gate passes. diff --git a/changes/CH-047-team-message-limits/proposal.md b/changes/CH-047-team-message-limits/proposal.md index 8ab7b32b8..4d92ace86 100644 --- a/changes/CH-047-team-message-limits/proposal.md +++ b/changes/CH-047-team-message-limits/proposal.md @@ -2,18 +2,18 @@ id: CH-047 type: change status: open -links: [] -title: Trial bounded team-message throughput across Tank Royale +links: [CAP-006] +title: Batch team messages across Tank Royale --- -# CH-047 — Trial bounded team-message throughput +# CH-047 — Batch team messages across Tank Royale ## What and why -Classic teams can send more messages in a turn than Tank Royale currently forwards. The Bot APIs, server, and schema disagree about both count and size, so a client can accept a message that the server silently drops. The first 128-message trial skipped turns under the normal deadline. Trial a revised policy for every Tank Royale bot: at most 64 messages and 256 KiB of compact, UTF-8-encoded `teamMessages` array data per bot per turn, and at most 48 KiB for each encoded message. Bound inbound WebSocket text to 1 MiB before JSON parsing. Reject an invalid intent in full rather than delivering a prefix. +Classic teams can send more messages in a turn than Tank Royale currently forwards. At the normal 30 ms deadline, the 128-message and 64-message count-only trials both missed the delivery and skipped-turn criteria. This change adds an ordered batch API that carries multiple logical payloads in one existing team-message packet and event. The accepted limits are 64 packets, 128 logical payloads, 48 KiB per encoded packet, and 256 KiB for the compact UTF-8 `teamMessages` array per bot per turn. The server keeps the 1 MiB pre-parse WebSocket bound and rejects invalid intents in full. -The user supplied this trial plan. It is plan-less in the Tank Royale corpus; accepted messaging criteria will be added only after the trial passes. The trial uses local builds only. The policy becomes publishable only if boundary, malformed-client, CombatTeam, and five-bot 30 TPS stress evidence pass. +The user selected batching after the two count-only trials failed. Java is the semantic reference, and Python, .NET, and TypeScript match its behavior. Every recipient must advertise batch protocol version 1. Batches preserve entry order, use the existing next-turn delivery, and have one destination mode per packet. Compression is omitted because batching reduces per-message processing and event fanout without adding compression cost. The policy was accepted after uniform boundary tests, malformed-client checks, CombatTeam conformance, and a five-bot workload passed the agreed delivery and skipped-turn gate on matched local builds. ## Scope -Align the server, schema, Java, Python, .NET, and TypeScript Bot APIs; add focused cross-platform and server evidence; document count, byte budgets, failure behavior, and next-turn delivery. After the trial gate passes, add bridge and book evidence in their own repositories. Preserve the bridge's frozen classic API and its read-only robot jars. No release or push to an integration branch is part of this change. +This change is plan-less; it implements the user-selected change plan in the task discussion and does not serve an existing campaign plan item. Align the server, schema, Java, Python, .NET, and TypeScript Bot APIs; add uniform cross-platform batch tests and focused server evidence; document count, byte budgets, failure behavior, batch compatibility, and next-turn delivery. Add bridge and book evidence in their own repositories. Preserve the bridge's frozen classic API and its read-only robot jars. No release or push to an integration branch is part of this change. diff --git a/changes/CH-047-team-message-limits/tasks.md b/changes/CH-047-team-message-limits/tasks.md index b0499342f..0d98991a8 100644 --- a/changes/CH-047-team-message-limits/tasks.md +++ b/changes/CH-047-team-message-limits/tasks.md @@ -8,9 +8,10 @@ title: CH-047 implementation tasks # Tasks -- [ ] Define CAP-018 criteria for count, byte budgets, atomic rejection, and delivery timing (TML-001 through TML-004). -- [ ] Align schema and server validation, including the pre-parse WebSocket text bound (TML-001, TML-002, TML-003, TML-004). -- [ ] Align Java, Python, .NET, and TypeScript enqueue validation and API documentation (TML-001, TML-002, TML-003). -- [ ] Run boundary, malformed-client, and delivery tests against matched local builds (TML-001, TML-002, TML-003, TML-004). -- [-] Run CombatTeam and five-bot stress at 30 TPS; record turn, skip, and traffic measurements and apply the acceptance gate (TML-004). The 128-message and revised 64-message five-bot trials failed: delivery was incomplete and recipients recorded skipped turns. A human decision on the next limit or profiling work is required before further policy work. -- [ ] After the gate passes, update the Tank Royale article, bridge evidence, and book pages; run relevant checks (TML-001, TML-002, TML-004). +- [x] Record the user-selected batch trial, Java reference semantics, compatibility handshake, and no-compression first implementation (TML-001 through TML-004). +- [x] Align schema and server batch validation, including atomic rejection, recipient capability checks, and the pre-parse WebSocket text bound (PRO-006, PRO-007, PRO-008). +- [x] Align Java, Python, .NET, and TypeScript batch APIs and enqueue validation, with shared `TR-API-TCK-022` cases and matching public documentation (PRO-006, PRO-007). +- [x] Run uniform boundary, malformed-client, directed/broadcast, order, and next-turn tests against matched local builds (PRO-006, PRO-007, PRO-008). +- [x] Run CombatTeam and the five-bot 30 TPS stress workload; record processing time, skipped turns, delivery, and encoded payload traffic, then apply the acceptance gate (PRO-006). +- [x] Address branch review: stable same-turn team-message order in the .NET queue, stale team messages dropped on add in all four queues (`TR-API-EVT-010`, `TR-API-EVT-011`), receivers checked against the game-start roster so a disconnected teammate no longer gets the sender closed, and Python batches accept one-shot iterables (PRO-006, PRO-007, PRO-009). +- [x] After the gate passed, finish the Tank Royale article, bridge collection conformance, Book pages, CHANGELOG, and version roll, then run relevant checks (PRO-006, PRO-007). diff --git a/docs/architecture/models/message-schema/README.md b/docs/architecture/models/message-schema/README.md index 00d9447df..b10307179 100644 --- a/docs/architecture/models/message-schema/README.md +++ b/docs/architecture/models/message-schema/README.md @@ -343,4 +343,4 @@ Use schemas to generate test fixtures and validate responses. --- -**Last Updated:** 2026-08-02 +**Last Updated:** 2026-09-23 diff --git a/docs/architecture/models/message-schema/intents.md b/docs/architecture/models/message-schema/intents.md index 06770f376..111a52c90 100644 --- a/docs/architecture/models/message-schema/intents.md +++ b/docs/architecture/models/message-schema/intents.md @@ -66,9 +66,9 @@ classDiagram } class TeamMessage { - +string type = "team-message" +string message - +int receiverId + +string messageType + +int receiverId? } Message <|-- BotIntent @@ -112,6 +112,10 @@ classDiagram } ``` +An ordinary team-message packet contains one serialized payload. A bot may instead use the reserved `messageType` value `team-message-batch-v1` and put an ordered list of 1–128 typed payload entries in its JSON message. The batch is delivered as one `team-message-event` on the next turn. Directed and broadcast packets each have one destination mode; every recipient must advertise `teamMessageBatchVersion: 1` in its bot handshake. + +The server accepts at most 64 packets and 128 logical payloads across the complete `teamMessages` array in one bot intent. Each encoded packet is limited to 49,152 UTF-8 bytes, and the compact UTF-8 encoding of the complete array is limited to 262,144 bytes. Bot APIs validate before enqueueing; malformed or over-limit intents are rejected atomically. Incoming WebSocket text messages are bounded to 1 MiB before JSON parsing. + ### Example ```json @@ -394,8 +398,8 @@ classDiagram ```json { - "type": "team-message", "message": "Enemy at (500, 300), energy 45", + "messageType": "EnemySpotted", "receiverId": 2 } ``` @@ -404,9 +408,9 @@ classDiagram | Field | Type | Required | Description | |-------|------|----------|-------------| -| `type` | string | ✅ | Always `"team-message"` | | `message` | string | ✅ | Message content (serialized data) | -| `receiverId` | integer | ✅ | Bot ID of teammate to receive message | +| `messageType` | string | ✅ | Message type identifier, such as a class name or application tag | +| `receiverId` | integer | — | Bot ID of one teammate; omit it to broadcast to all teammates | ### Server Processing @@ -459,7 +463,7 @@ onTeamMessageEvent(event) { 1. **Avoid complex calculations** — Keep turn logic under 20ms 2. **Cache static data** — Don't recalculate arena dimensions every turn 3. **Early intent sending** — Send intent as soon as calculated (don't wait until timeout) -4. **Batch team messages** — Don't send multiple messages per turn +4. **Batch related team messages** — Use the Bot API batch methods when multiple payloads should arrive together in one callback. A batch reduces packet and callback overhead, but does not compress the JSON payloads. ### Common Mistakes diff --git a/docs/capabilities/CAP-006-protocol/criteria.md b/docs/capabilities/CAP-006-protocol/criteria.md index e03f24294..c72a7c32b 100644 --- a/docs/capabilities/CAP-006-protocol/criteria.md +++ b/docs/capabilities/CAP-006-protocol/criteria.md @@ -61,4 +61,37 @@ Feature: protocol — WebSocket protocol Then the `server-handshake` SHALL contain a positive integer `behaviorVersion` And the value SHALL identify the game-observable compatibility epoch And a handshake that omits the field SHALL remain readable by a compatibility client + + # Bot team messages support ordered payload batches under explicit per-turn count and byte limits. + + @PRO-006 + Scenario: Deliver a compatible team-message batch + Test-type: Integration + Given a sender and its teammates advertise batch protocol version 1 + When the sender submits a broadcast or directed batch with up to 128 logical payloads + And the complete intent uses at most 64 packets, 49,152 UTF-8 bytes per encoded packet, and 262,144 UTF-8 bytes for its compact `teamMessages` array + Then each intended recipient SHALL receive one team-message event on the next turn + And the event SHALL contain all batch entries in send order + + @PRO-007 + Scenario: Reject an invalid team-message intent atomically + Test-type: Unit + Given a bot submits an empty batch, a null entry, a malformed packet, or a message over a count or UTF-8 byte limit + When the server validates the complete intent + Then the server SHALL reject the intent without delivering any message from it + + @PRO-009 + Scenario: Keep the sender connected when a directed receiver has left the game + Test-type: Unit + Given a bot's teammate was listed in its game-started event and has since disconnected + When the bot sends a directed team message or batch to that teammate + Then the server SHALL NOT close the sender's connection + And the server SHALL NOT deliver the message + And a receiver that was not a teammate at game start SHALL still be rejected as a policy violation + + @PRO-008 + Scenario: Reject an oversized incoming WebSocket text message before parsing + Test-type: Unit + When a client sends a text message larger than 1,048,576 UTF-8 bytes + Then the server SHALL close the connection with a message-too-big status before JSON parsing ``` diff --git a/docs/capabilities/CAP-006-protocol/design.md b/docs/capabilities/CAP-006-protocol/design.md index 8e282c2ec..8f30266be 100644 --- a/docs/capabilities/CAP-006-protocol/design.md +++ b/docs/capabilities/CAP-006-protocol/design.md @@ -10,4 +10,8 @@ reversal-cost: low # CAP-006 design -No capability-local design was extracted at CH-001 — the design lives in the implementation and the architecture corpus (`docs/architecture/`, `docs/decisions/`). Pull design close to the criteria when this capability next changes. +## Team-message batches + +Each Bot API validates its pending messages before enqueueing. Ordinary messages count as one logical payload; a batch counts each contained value. The server validates the complete compact UTF-8 `teamMessages` array before it merges an intent into the turn, then schedules each packet for delivery on the next turn. A batch remains one packet and one recipient event, with its values decoded in send order. + +The protocol allows 64 packets, 128 logical payloads, 49,152 UTF-8 bytes per encoded packet, and 262,144 UTF-8 bytes for the compact array per bot per turn. Bot handshakes advertise batch protocol version 1; batch packets are accepted only when every intended recipient supports version 1. The schema and wire shape are documented in [Intent Messages](../../architecture/models/message-schema/intents.md). diff --git a/runner/src/test/kotlin/dev/robocode/tankroyale/runner/BattleRunnerIntegrationTest.kt b/runner/src/test/kotlin/dev/robocode/tankroyale/runner/BattleRunnerIntegrationTest.kt index e3c206fde..d88a28292 100644 --- a/runner/src/test/kotlin/dev/robocode/tankroyale/runner/BattleRunnerIntegrationTest.kt +++ b/runner/src/test/kotlin/dev/robocode/tankroyale/runner/BattleRunnerIntegrationTest.kt @@ -21,7 +21,9 @@ import java.util.zip.GZIPInputStream import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference import java.util.Collections +import java.nio.charset.StandardCharsets import kotlin.io.path.exists +import kotlinx.serialization.json.JsonPrimitive /** * Integration tests that run real battles against the embedded server with sample bots. @@ -436,6 +438,111 @@ class BattleRunnerIntegrationTest { } } + @Tag("integration") + @Tag("slow") + @Tag("PRO-006") + @Tag("Integration") + @Tag("Positive") + @Test + @Timeout(120) + fun testPRO006_IntegrationPositive_fiveBotsDeliverOrdered128ItemBatchesWithoutSkippedTurns() { + val expectedTurn = 80 + val measuredFromTurn = 10 + val firstTickTurn = AtomicReference() + val startedAtNanos = AtomicReference() + val finishedAtNanos = AtomicReference() + val finalStates = AtomicReference?>() + val completed = CountDownLatch(1) + val owner = Any() + + BattleRunner.create { embeddedServer() }.use { runner -> + val handle = runner.startBattleAsync( + BattleSetup.custom { + numberOfRounds = 1 + minNumberOfParticipants = 7 + maxNumberOfParticipants = 7 + maxInactivityTurns = 120 + turnTimeoutMicros = 30_000 + defaultTurnsPerSecond = 30 + }, + listOf( + BotEntry.of(botDir("TeamMessageBatchStressTeam")), + BotEntry.of(botDir("Walls")), + BotEntry.of(botDir("SpinBot")) + ) + ) + handle.onTickEvent.on(owner) { tick -> + if (firstTickTurn.get() == null && tick.turnNumber >= measuredFromTurn) { + firstTickTurn.set(tick.turnNumber) + startedAtNanos.set(System.nanoTime()) + } + if (tick.turnNumber >= expectedTurn && completed.count > 0) { + finalStates.set(tick.botStates.toList()) + finishedAtNanos.set(System.nanoTime()) + completed.countDown() + } + } + + assertThat(completed.await(90, TimeUnit.SECONDS)) + .describedAs("the five-bot batch workload must reach turn $expectedTurn") + .isTrue() + handle.onTickEvent.off(owner) + handle.stop() + val states = finalStates.get()!!.filter { it.name == "TeamMessageBatchStress" } + assertThat(states).hasSize(5) + println( + "BATCH_STRESS_STATE " + states.joinToString { state -> + "id=${state.id},body=${state.bodyColor},tracks=${state.tracksColor}," + + "turret=${state.turretColor},radar=${state.radarColor}" + } + ) + states.forEach { state -> + val hex = state.bodyColor!!.removePrefix("#") + val received = (hex.substring(0, 2).toInt(16) shl 8) or hex.substring(2, 4).toInt(16) + val protocolErrors = hex.substring(4, 6).toInt(16) + val skippedTurns = state.tracksColor!!.removePrefix("#").substring(2, 4).toInt(16) + assertThat(received) + .describedAs("ordered logical messages received by bot ${state.id}; errors=$protocolErrors skipped=$skippedTurns") + .isEqualTo(4 * 60 * 128) + assertThat(skippedTurns) + .describedAs("skipped turns recorded by bot ${state.id}") + .isZero() + assertThat(protocolErrors) + .describedAs("batch order and protocol errors recorded by bot ${state.id}") + .isZero() + } + + val elapsedSeconds = (finishedAtNanos.get()!! - startedAtNanos.get()!!) / 1_000_000_000.0 + val measuredTps = (expectedTurn - firstTickTurn.get()!!) / elapsedSeconds + val outboundBytes = estimateBatchArrayBytes() + println( + "BATCH_STRESS bots=5 turns=60 tps=$measuredTps outboundTeamMessagesBytes=$outboundBytes " + + "estimatedTeamPayloadFanoutBytes=${outboundBytes * 4} receivedPerBot=${4 * 60 * 128} skipped=0" + ) + assertThat(measuredTps) + .describedAs("measured turn rate under the 30 TPS workload") + .isGreaterThanOrEqualTo(30.0) + } + } + + private fun estimateBatchArrayBytes(): Long { + fun encodedString(value: String) = JsonPrimitive(value).toString() + var totalBytes = 0L + for (botId in 1..5) { + for (turn in 1..60) { + val entries = (0 until 128).joinToString(",") { item -> + "{\"messageType\":\"java.lang.String\",\"message\":" + + encodedString(encodedString("$botId:$turn:$item")) + "}" + } + val batchPayload = "{\"messages\":[$entries]}" + val packet = "[{\"message\":" + encodedString(batchPayload) + + ",\"messageType\":\"team-message-batch-v1\"}]" + totalBytes += packet.toByteArray(StandardCharsets.UTF_8).size + } + } + return totalBytes + } + // ------------------------------------------------------------------------------------- // WonRoundEvent delivery verification — 10-round battle // ------------------------------------------------------------------------------------- diff --git a/sample-bots/java/TeamMessageBatchStress/TeamMessageBatchStress.java b/sample-bots/java/TeamMessageBatchStress/TeamMessageBatchStress.java new file mode 100644 index 000000000..d5960a74f --- /dev/null +++ b/sample-bots/java/TeamMessageBatchStress/TeamMessageBatchStress.java @@ -0,0 +1,105 @@ +import dev.robocode.tankroyale.botapi.Bot; +import dev.robocode.tankroyale.botapi.TeamMessageBatch; +import dev.robocode.tankroyale.botapi.events.SkippedTurnEvent; +import dev.robocode.tankroyale.botapi.events.TeamMessageEvent; +import dev.robocode.tankroyale.botapi.graphics.Color; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** Five-bot batch stress workload used by the local CH-047 acceptance trial. */ +public class TeamMessageBatchStress extends Bot { + private static final int SEND_TURNS = 60; + private static final int FIRST_SEND_TURN = 11; + private static final int LAST_SEND_TURN = FIRST_SEND_TURN + SEND_TURNS - 1; + private static final int ITEMS_PER_BATCH = 128; + + private final Map lastTurnBySender = new HashMap<>(); + private int receivedItems; + private int protocolErrors; + private int skippedTurns; + private int typeErrors; + private int sizeErrors; + private int orderErrors; + private int contentErrors; + + public static void main(String[] args) { + new TeamMessageBatchStress().start(); + } + + @Override + public void run() { + while (isRunning()) { + int turn = getTurnNumber(); + if (turn >= FIRST_SEND_TURN && turn <= LAST_SEND_TURN) { + List messages = new ArrayList<>(ITEMS_PER_BATCH); + for (int item = 0; item < ITEMS_PER_BATCH; item++) { + messages.add(getMyId() + ":" + turn + ":" + item); + } + broadcastTeamMessageBatch(messages); + } + if (turn >= LAST_SEND_TURN + 10 || turn % 5 == 0) { + setBodyColor(Color.fromRgb((receivedItems >>> 8) & 0xff, receivedItems & 0xff, + Math.min(protocolErrors, 0xff))); + setTracksColor(Color.fromRgb(0, Math.min(skippedTurns, 0xff), 0)); + setTurretColor(Color.fromRgb(Math.min(typeErrors, 0xff), Math.min(sizeErrors, 0xff), + Math.min(orderErrors, 0xff))); + setRadarColor(Color.fromRgb(Math.min(contentErrors, 0xff), 0, 0)); + } + go(); + } + } + + @Override + public void onTeamMessage(TeamMessageEvent event) { + if (!(event.getMessage() instanceof TeamMessageBatch)) { + protocolErrors++; + typeErrors++; + return; + } + List messages = ((TeamMessageBatch) event.getMessage()).getMessages(); + if (messages.size() != ITEMS_PER_BATCH) { + protocolErrors++; + sizeErrors++; + return; + } + int senderId = event.getSenderId(); + String[] first = String.valueOf(messages.get(0)).split(":"); + if (first.length != 3 || Integer.parseInt(first[0]) != senderId) { + protocolErrors++; + contentErrors++; + return; + } + int senderTurn = Integer.parseInt(first[1]); + int previousSenderTurn = lastTurnBySender.getOrDefault(senderId, FIRST_SEND_TURN - 1); + if (senderTurn <= previousSenderTurn) { + protocolErrors++; + orderErrors++; + return; + } + if (senderTurn != previousSenderTurn + 1) { + // Count a gap, but keep validating later deliveries so the report shows total + // traffic as well as missing turns instead of treating one gap as permanent loss. + protocolErrors++; + orderErrors++; + } + for (int item = 0; item < ITEMS_PER_BATCH; item++) { + if (!(senderId + ":" + senderTurn + ":" + item).equals(messages.get(item))) { + protocolErrors++; + contentErrors++; + return; + } + } + lastTurnBySender.put(senderId, senderTurn); + receivedItems += ITEMS_PER_BATCH; + } + + @Override + public void onSkippedTurn(SkippedTurnEvent event) { + if (event.getTurnNumber() >= FIRST_SEND_TURN && event.getTurnNumber() <= LAST_SEND_TURN) { + skippedTurns++; + } + } +} diff --git a/sample-bots/java/TeamMessageBatchStress/TeamMessageBatchStress.json b/sample-bots/java/TeamMessageBatchStress/TeamMessageBatchStress.json new file mode 100644 index 000000000..4d8c5210b --- /dev/null +++ b/sample-bots/java/TeamMessageBatchStress/TeamMessageBatchStress.json @@ -0,0 +1,9 @@ +{ + "name": "TeamMessageBatchStress", + "version": "1.0", + "authors": ["Robocode Tank Royale"], + "description": "Five-bot trial workload for ordered 128-entry team-message batches.", + "platform": "JVM", + "programmingLang": "Java 11", + "license": "Apache-2.0" +} diff --git a/sample-bots/java/TeamMessageBatchStressTeam/TeamMessageBatchStressTeam.json b/sample-bots/java/TeamMessageBatchStressTeam/TeamMessageBatchStressTeam.json new file mode 100644 index 000000000..b9d6fbd5f --- /dev/null +++ b/sample-bots/java/TeamMessageBatchStressTeam/TeamMessageBatchStressTeam.json @@ -0,0 +1,14 @@ +{ + "name": "TeamMessageBatchStressTeam", + "version": "1.0", + "authors": ["Robocode Tank Royale"], + "description": "Five copies of the batch stress bot for the local 30 TPS trial.", + "teamMembers": [ + "TeamMessageBatchStress", + "TeamMessageBatchStress", + "TeamMessageBatchStress", + "TeamMessageBatchStress", + "TeamMessageBatchStress" + ], + "license": "Apache-2.0" +} diff --git a/schema/schemas/bot-handshake.schema.yaml b/schema/schemas/bot-handshake.schema.yaml index d27734180..c8662f312 100644 --- a/schema/schemas/bot-handshake.schema.yaml +++ b/schema/schemas/bot-handshake.schema.yaml @@ -82,8 +82,12 @@ properties: Whether a debugger is attached to the bot process when connecting to the server. This is informational only and does not affect gameplay. type: boolean + teamMessageBatchVersion: + description: Highest supported standard team-message batch payload version. Omit when batching is unsupported. + type: integer + minimum: 1 required: - sessionId - name - version - - authors \ No newline at end of file + - authors diff --git a/schema/schemas/bot-intent.schema.yaml b/schema/schemas/bot-intent.schema.yaml index 8af916705..26abcc770 100644 --- a/schema/schemas/bot-intent.schema.yaml +++ b/schema/schemas/bot-intent.schema.yaml @@ -85,7 +85,7 @@ properties: description: New text received from standard error (stderr) type: string teamMessages: - description: Messages delivered in order on the next turn. At most 64 per bot per turn; the compact UTF-8 encoded array is limited to 262144 bytes. An invalid intent is rejected in full. + description: Packets delivered in order on the next turn. At most 64 packets and 128 logical messages per bot per turn; the compact UTF-8 encoded array is limited to 262144 bytes. A team-message-batch-v1 packet carries ordered logical messages. An invalid intent is rejected in full. type: array maxItems: 64 items: diff --git a/schema/schemas/team-message-event.schema.yaml b/schema/schemas/team-message-event.schema.yaml index 5c78bd596..42650309d 100644 --- a/schema/schemas/team-message-event.schema.yaml +++ b/schema/schemas/team-message-event.schema.yaml @@ -4,20 +4,19 @@ description: >- Event occurring when a message has been received from a teammate. Inherits the required 1-based `turnNumber` from `event.schema.yaml`. The event is delivered privately to a teammate (either a specific recipient - or all teammates when broadcast). Server-side limits apply: each bot can send - at most `MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN` team messages per turn, and a - single team message must not exceed `MAX_TEAM_MESSAGE_SIZE` bytes/characters. - If one message exceeds the size limit, that message and any following team - messages in the same turn are ignored by the server. + or all teammates when broadcast). A bot can send at most 64 packets and 128 + logical messages per turn. Each encoded packet is limited to 49152 UTF-8 + bytes and the compact outgoing array is limited to 262144 UTF-8 bytes. + Invalid intents are rejected in full. A `team-message-batch-v1` payload is + delivered as one event and retains the order of its contained messages. extends: $ref: event.schema.yaml properties: message: description: >- - Payload sent by the teammate, typically a JSON-encoded string. The server - enforces a maximum length of 4096 characters. + Compact JSON payload sent by the teammate. The server enforces a maximum + of 49152 UTF-8 bytes. type: string - maxLength: 4096 messageType: description: >- Application-defined message type identifier, e.g., a class name or tag @@ -29,4 +28,4 @@ properties: required: - message - messageType - - senderId \ No newline at end of file + - senderId diff --git a/schema/schemas/team-message.schema.yaml b/schema/schemas/team-message.schema.yaml index ff6d00e84..5a333589f 100644 --- a/schema/schemas/team-message.schema.yaml +++ b/schema/schemas/team-message.schema.yaml @@ -1,9 +1,14 @@ $id: team-message.schema.yaml $schema: https://json-schema.org/2020-12/schema# -description: Message sent between teammates +description: >- + Message sent between teammates. When `messageType` is `team-message-batch-v1`, `message` contains the compact JSON + envelope `{"messages":[{"messageType":"...","message":"..."}]}`. The batch envelope carries ordered logical + payloads through one packet and one next-turn event. properties: message: - description: The encoded message payload, e.g. compact JSON. Its UTF-8 representation is limited to 49152 bytes. + description: >- + The encoded message payload, e.g. compact JSON. Its UTF-8 representation is limited to 49152 bytes. A batch + packet carries an ordered `messages` array as one compact JSON string. type: string messageType: description: The message type, e.g. a class name diff --git a/server/build.gradle.kts b/server/build.gradle.kts index 2f5df7ddc..c93c4bef2 100644 --- a/server/build.gradle.kts +++ b/server/build.gradle.kts @@ -114,6 +114,8 @@ tasks { val r8ShrinkTask by registering(JavaExec::class) { // R8 shrinking task (kept name for compatibility) dependsOn(jar) + inputs.files(file(intermediateJar), file("r8-rules.pro")) + inputs.property("version", project.version) outputs.file(finalJar) doFirst { diff --git a/server/src/main/kotlin/dev/robocode/tankroyale/server/connection/TeamMessagePolicy.kt b/server/src/main/kotlin/dev/robocode/tankroyale/server/connection/TeamMessagePolicy.kt index cad70479d..754bfb392 100644 --- a/server/src/main/kotlin/dev/robocode/tankroyale/server/connection/TeamMessagePolicy.kt +++ b/server/src/main/kotlin/dev/robocode/tankroyale/server/connection/TeamMessagePolicy.kt @@ -1,6 +1,10 @@ package dev.robocode.tankroyale.server.connection import com.google.gson.JsonObject +import com.google.gson.JsonParser +import dev.robocode.tankroyale.schema.TeamMessage +import dev.robocode.tankroyale.server.model.BotId +import dev.robocode.tankroyale.server.rules.MAX_LOGICAL_TEAM_MESSAGES_PER_TURN import dev.robocode.tankroyale.server.rules.MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN import dev.robocode.tankroyale.server.rules.MAX_TEAM_MESSAGE_SIZE import dev.robocode.tankroyale.server.rules.MAX_TEAM_MESSAGES_BYTES_PER_TURN @@ -8,6 +12,36 @@ import java.nio.charset.StandardCharsets /** Validates the complete batch before any part of an intent enters the game loop. */ internal object TeamMessagePolicy { + const val BATCH_MESSAGE_TYPE = "team-message-batch-v1" + + /** + * Checks the receivers of an intent's team messages. Returns the policy-violation reason, or `null` when + * the intent may be processed. + * + * A receiver must be one of the teammates the sender was given at game start. A teammate that has since + * disconnected is still a valid receiver: its messages are not delivered, but the sender is not + * penalized for a roster it cannot observe. Batch support is only required from connected recipients. + */ + fun recipientViolation( + messages: List?, + gameStartTeammateIds: Set, + connectedTeammateIds: Set, + supportsBatch: (BotId) -> Boolean, + ): String? { + val teamMessages = messages.orEmpty() + if (teamMessages.any { it.receiverId != null && BotId(it.receiverId) !in gameStartTeammateIds }) { + return "Team message receiverId is not a teammate" + } + val batchRecipients = teamMessages + .filter { it.messageType == BATCH_MESSAGE_TYPE } + .flatMap { if (it.receiverId == null) connectedTeammateIds else listOf(BotId(it.receiverId)) } + .filter { it in connectedTeammateIds } + if (batchRecipients.any { !supportsBatch(it) }) { + return "A batch recipient does not support team-message-batch-v1" + } + return null + } + fun validate(intent: JsonObject) { val value = intent.get("teamMessages") ?: return if (value.isJsonNull) return @@ -19,6 +53,7 @@ internal object TeamMessagePolicy { require(messages.toString().toByteArray(StandardCharsets.UTF_8).size <= MAX_TEAM_MESSAGES_BYTES_PER_TURN) { "teamMessages exceeds $MAX_TEAM_MESSAGES_BYTES_PER_TURN UTF-8 bytes" } + var logicalMessageCount = 0 for (item in messages) { require(item.isJsonObject) { "Each team message must be an object" } val message = item.asJsonObject.get("message") @@ -32,6 +67,10 @@ internal object TeamMessagePolicy { require(messageType != null && messageType.isJsonPrimitive && messageType.asJsonPrimitive.isString) { "Each team message requires a messageType string" } + require(messageType.asString.isNotBlank()) { "messageType must not be blank" } + logicalMessageCount += if (messageType.asString == BATCH_MESSAGE_TYPE) { + validateBatch(message.asString) + } else 1 val recipient = item.asJsonObject.get("receiverId") require(recipient == null || recipient.isJsonNull || (recipient.isJsonPrimitive && recipient.asJsonPrimitive.isNumber && @@ -39,5 +78,37 @@ internal object TeamMessagePolicy { "receiverId must be an integer teammate id" } } + require(logicalMessageCount <= MAX_LOGICAL_TEAM_MESSAGES_PER_TURN) { + "teamMessages exceeds $MAX_LOGICAL_TEAM_MESSAGES_PER_TURN logical messages" + } + } + + private fun validateBatch(payload: String): Int { + val root = try { JsonParser.parseString(payload) } catch (exception: RuntimeException) { + throw IllegalArgumentException("Invalid team message batch payload", exception) + } + require(root.isJsonObject) { "Invalid team message batch payload" } + val messages = root.asJsonObject.get("messages") + require(messages != null && messages.isJsonArray && messages.asJsonArray.size() > 0) { + "A team message batch must contain at least one message" + } + messages.asJsonArray.forEach { item -> + require(item.isJsonObject) { "Each team message batch item must be an object" } + val objectItem = item.asJsonObject + val itemType = objectItem.get("messageType") + val itemPayload = objectItem.get("message") + require(itemType != null && itemType.isJsonPrimitive && itemType.asJsonPrimitive.isString + && itemType.asString.isNotBlank() + && itemPayload != null && itemPayload.isJsonPrimitive && itemPayload.asJsonPrimitive.isString) { + "Each team message batch item requires messageType and message strings" + } + try { + val decoded = JsonParser.parseString(itemPayload.asString) + require(!decoded.isJsonNull) { "A team message batch item cannot be null" } + } catch (exception: RuntimeException) { + throw IllegalArgumentException("Invalid JSON in team message batch item", exception) + } + } + return messages.asJsonArray.size() } } diff --git a/server/src/main/kotlin/dev/robocode/tankroyale/server/core/GameServer.kt b/server/src/main/kotlin/dev/robocode/tankroyale/server/core/GameServer.kt index 1ae5d036f..ef61df5b0 100644 --- a/server/src/main/kotlin/dev/robocode/tankroyale/server/core/GameServer.kt +++ b/server/src/main/kotlin/dev/robocode/tankroyale/server/core/GameServer.kt @@ -5,6 +5,7 @@ import dev.robocode.tankroyale.schema.* import dev.robocode.tankroyale.schema.GameSetup import dev.robocode.tankroyale.server.connection.ConnectionHandler import dev.robocode.tankroyale.server.connection.GameServerConnectionListener +import dev.robocode.tankroyale.server.connection.TeamMessagePolicy import dev.robocode.tankroyale.server.mapper.* import dev.robocode.tankroyale.server.model.* import dev.robocode.tankroyale.server.model.InitialPosition @@ -124,11 +125,21 @@ class GameServer( timerToShutdown?.shutdown() } + /** + * Teammate IDs each bot received in its game-started event. Team-message receivers are checked against this + * roster, since it is what the bot APIs validate against, even after a teammate disconnects. + */ + @Volatile + private var gameStartTeammateIds: Map> = emptyMap() + /** Send game-started event to all participant bots to get them started */ private fun sendGameStartedToParticipants() { val gameSetup = GameSetupMapper.map(gameSetup) val botHandshakes = connectionHandler.getBotHandshakes() + gameStartTeammateIds = participantRegistry.participantIds.entries.associate { (conn, botId) -> + botId to getTeammateIds(botId, botHandshakes[conn]?.teamId) + } participantRegistry.participantIds.forEach { (conn, botId) -> val teamId = botHandshakes[conn]?.teamId val gameStartedForBot = createGameStartedEventForBot(botId, teamId, gameSetup) @@ -556,11 +567,12 @@ class GameServer( val senderId = participantRegistry.participantIds[conn] val teamId = connectionHandler.getBotHandshakes()[conn]?.teamId - val teammateIds = senderId?.let { getTeammateIds(it, teamId) } ?: emptySet() - if (intent.teamMessages?.any { message -> - message.receiverId != null && BotId(message.receiverId) !in teammateIds - } == true) { - conn.close(1008 /* RFC 6455 policy violation */, "Team message receiverId is not a teammate") + val connectedTeammateIds = senderId?.let { getTeammateIds(it, teamId) } ?: emptySet() + val gameStartTeammates = senderId?.let { gameStartTeammateIds[it] } ?: emptySet() + TeamMessagePolicy.recipientViolation( + intent.teamMessages, gameStartTeammates, connectedTeammateIds, ::supportsTeamMessageBatch + )?.let { reason -> + conn.close(1008 /* RFC 6455 policy violation */, reason) return } @@ -596,6 +608,11 @@ class GameServer( } } + private fun supportsTeamMessageBatch(botId: BotId): Boolean { + val socket = participantRegistry.participantIds.entries.firstOrNull { it.value == botId }?.key ?: return false + return (connectionHandler.getBotHandshakes()[socket]?.teamMessageBatchVersion ?: 0) >= 1 + } + private fun checkAllBotsResponded() { val aliveParticipants = participantRegistry.participants.filter { conn -> participantRegistry.participantIds[conn]?.let { botId -> modelUpdater?.isAlive(botId) == true } ?: false diff --git a/server/src/main/kotlin/dev/robocode/tankroyale/server/rules/rules.kt b/server/src/main/kotlin/dev/robocode/tankroyale/server/rules/rules.kt index e671ff916..a9c063a38 100644 --- a/server/src/main/kotlin/dev/robocode/tankroyale/server/rules/rules.kt +++ b/server/src/main/kotlin/dev/robocode/tankroyale/server/rules/rules.kt @@ -96,6 +96,9 @@ const val MAX_TEAM_MESSAGE_SIZE = 48 * 1024 /** Max. number of team messages per turn */ const val MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN = 64 +/** Max. logical team-message payloads across ordinary packets and batch entries per turn. */ +const val MAX_LOGICAL_TEAM_MESSAGES_PER_TURN = 128 + /** Max. UTF-8 bytes of the compact encoded teamMessages array per bot intent. */ const val MAX_TEAM_MESSAGES_BYTES_PER_TURN = 256 * 1024 diff --git a/server/src/test/kotlin/connection/TeamMessagePolicyTest.kt b/server/src/test/kotlin/connection/TeamMessagePolicyTest.kt index 9973e0c08..dacb81636 100644 --- a/server/src/test/kotlin/connection/TeamMessagePolicyTest.kt +++ b/server/src/test/kotlin/connection/TeamMessagePolicyTest.kt @@ -2,8 +2,11 @@ package dev.robocode.tankroyale.server.connection import com.google.gson.JsonArray import com.google.gson.JsonObject +import dev.robocode.tankroyale.schema.TeamMessage +import dev.robocode.tankroyale.server.model.BotId import dev.robocode.tankroyale.server.rules.MAX_TEAM_MESSAGE_SIZE import dev.robocode.tankroyale.server.rules.MAX_TEAM_MESSAGES_BYTES_PER_TURN +import dev.robocode.tankroyale.server.rules.MAX_LOGICAL_TEAM_MESSAGES_PER_TURN import io.kotest.core.Tag import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe @@ -59,5 +62,72 @@ class TeamMessagePolicyTest : FunSpec({ test("Unit: malformed message rejects the batch") { val messages = JsonArray().apply { add(item("valid", 7)); add(JsonObject().apply { addProperty("message", 3) }) } shouldThrow { TeamMessagePolicy.validate(intent(messages)) } + shouldThrow { + TeamMessagePolicy.validate(intent(JsonArray().apply { + add(item("valid").apply { addProperty("messageType", " ") }) + })) + } + } + + test("Unit: batch counts logical payloads and rejects malformed or null entries") { + fun batch(count: Int): String = JsonObject().apply { + add("messages", JsonArray().apply { + repeat(count) { index -> add(JsonObject().apply { + addProperty("messageType", "String") + addProperty("message", "\"item-$index\"") + }) } + }) + }.toString() + TeamMessagePolicy.validate(intent(JsonArray().apply { add(item(batch(MAX_LOGICAL_TEAM_MESSAGES_PER_TURN)).apply { + addProperty("messageType", TeamMessagePolicy.BATCH_MESSAGE_TYPE) + }) })) + shouldThrow { + TeamMessagePolicy.validate(intent(JsonArray().apply { add(item(batch(MAX_LOGICAL_TEAM_MESSAGES_PER_TURN + 1)).apply { + addProperty("messageType", TeamMessagePolicy.BATCH_MESSAGE_TYPE) + }) })) + } + val malformed = JsonObject().apply { + addProperty("messageType", "String") + addProperty("message", "null") + }.toString() + shouldThrow { + TeamMessagePolicy.validate(intent(JsonArray().apply { add(item(malformed).apply { + addProperty("messageType", TeamMessagePolicy.BATCH_MESSAGE_TYPE) + }) })) + } + } + + fun teamMessage(receiverId: Int?, messageType: String = "String") = TeamMessage().apply { + this.message = "\"payload\"" + this.messageType = messageType + this.receiverId = receiverId + } + + test("Unit: a message to a teammate that disconnected after game start is not a violation").config(tags = setOf(Tag("PRO-009"))) { + val violation = TeamMessagePolicy.recipientViolation( + listOf(teamMessage(3), teamMessage(3, TeamMessagePolicy.BATCH_MESSAGE_TYPE)), + gameStartTeammateIds = setOf(BotId(2), BotId(3)), + connectedTeammateIds = setOf(BotId(2)), + supportsBatch = { it == BotId(2) }, + ) + violation shouldBe null + } + + test("Unit: a receiver outside the game-start team is a violation").config(tags = setOf(Tag("PRO-009"))) { + TeamMessagePolicy.recipientViolation( + listOf(teamMessage(9)), + gameStartTeammateIds = setOf(BotId(2)), + connectedTeammateIds = setOf(BotId(2)), + supportsBatch = { true }, + ) shouldBe "Team message receiverId is not a teammate" + } + + test("Unit: a connected batch recipient without batch support is a violation") { + TeamMessagePolicy.recipientViolation( + listOf(teamMessage(null, TeamMessagePolicy.BATCH_MESSAGE_TYPE)), + gameStartTeammateIds = setOf(BotId(2), BotId(3)), + connectedTeammateIds = setOf(BotId(2), BotId(3)), + supportsBatch = { it == BotId(2) }, + ) shouldBe "A batch recipient does not support team-message-batch-v1" } }) diff --git a/server/src/test/kotlin/connection/TeamMessageRawClientTest.kt b/server/src/test/kotlin/connection/TeamMessageRawClientTest.kt new file mode 100644 index 000000000..dc21c5b21 --- /dev/null +++ b/server/src/test/kotlin/connection/TeamMessageRawClientTest.kt @@ -0,0 +1,75 @@ +package connection + +import com.google.gson.Gson +import com.google.gson.JsonObject +import dev.robocode.tankroyale.server.connection.ClientWebSocketsHandler +import dev.robocode.tankroyale.server.connection.IConnectionListener +import dev.robocode.tankroyale.server.core.ServerSetup +import io.kotest.core.Tag +import io.kotest.core.spec.style.FunSpec +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.java_websocket.WebSocket +import org.java_websocket.handshake.ClientHandshake + +class TeamMessageRawClientTest : FunSpec({ + val listener = mockk(relaxed = true) + val handler = ClientWebSocketsHandler( + setup = ServerSetup(), + listener = listener, + controllerSecrets = emptySet(), + botSecrets = emptySet(), + debugModeSupported = false, + breakpointModeSupported = false, + broadcastFunction = { _, _ -> }, + ) + + afterSpec { handler.close() } + + test("testPRO007_UnitNegative_rejectsMalformedBatchWithoutForwardingIntent") + .config(tags = setOf(Tag("PRO-007"))) { + val socket = mockk(relaxed = true) + var handshakeJson: String? = null + every { socket.send(any()) } answers { handshakeJson = firstArg() } + + handler.onOpen(socket, mockk(relaxed = true)) + verify(timeout = 1_000) { socket.send(any()) } + val sessionId = Gson().fromJson(requireNotNull(handshakeJson), JsonObject::class.java) + .get("sessionId").asString + val botHandshake = JsonObject().apply { + addProperty("type", "BotHandshake") + addProperty("sessionId", sessionId) + addProperty("name", "RawBatchClient") + addProperty("version", "1.4.0") + add("authors", Gson().toJsonTree(listOf("test"))) + } + handler.onMessage(socket, botHandshake.toString()) + verify(timeout = 1_000) { listener.onBotJoined(socket, any()) } + + val rawIntent = """ + {"type":"BotIntent","teamMessages":[ + {"message":"\"valid\"","messageType":"java.lang.String"}, + {"message":"{broken","messageType":"team-message-batch-v1"} + ]} + """.trimIndent() + handler.onMessage(socket, rawIntent) + + verify(timeout = 1_000) { + socket.close(1008, match { it.contains("Invalid team message batch payload") }) + } + verify(exactly = 0) { listener.onBotIntent(any(), any(), any()) } + } + + test("testPRO008_UnitNegative_rejectsWebSocketTextLargerThanOneMiBBeforeParsing") + .config(tags = setOf(Tag("PRO-008"))) { + val socket = mockk(relaxed = true) + val oversized = "{" + "x".repeat(1_048_576) + + handler.onMessage(socket, oversized) + + verify(exactly = 1) { + socket.close(1009, match { it.contains("1048576 UTF-8 bytes") }) + } + } +}) diff --git a/server/src/test/kotlin/core/GameLifecycleTest.kt b/server/src/test/kotlin/core/GameLifecycleTest.kt index a7b94e3f0..0f4157603 100644 --- a/server/src/test/kotlin/core/GameLifecycleTest.kt +++ b/server/src/test/kotlin/core/GameLifecycleTest.kt @@ -2,7 +2,9 @@ package core import dev.robocode.tankroyale.schema.BotAddress import dev.robocode.tankroyale.schema.BotHandshake +import dev.robocode.tankroyale.schema.BotIntent import dev.robocode.tankroyale.schema.BotPolicyUpdate +import dev.robocode.tankroyale.schema.TeamMessage import dev.robocode.tankroyale.schema.GameSetup import dev.robocode.tankroyale.server.connection.ConnectionHandler import dev.robocode.tankroyale.server.core.* @@ -12,6 +14,7 @@ import io.kotest.core.Tag import io.kotest.matchers.shouldBe import io.mockk.every import io.mockk.mockk +import io.mockk.verify import org.java_websocket.WebSocket class GameLifecycleTest : FunSpec({ @@ -120,6 +123,52 @@ class GameLifecycleTest : FunSpec({ lifecycleManager.serverState shouldBe ServerState.GAME_STOPPED } + test("PRO-009: Positive: directed team message to a teammate that left keeps the sender connected") + .config(tags = setOf(Tag("PRO-009"))) { + val connectionHandler = mockk(relaxed = true) + val participantRegistry = ParticipantRegistry(connectionHandler) + val lifecycleManager = GameLifecycleManager() + val gameServer = createGameServer(connectionHandler, participantRegistry, lifecycleManager) + + val sender = mockk(relaxed = true) + val teammate = mockk(relaxed = true) + val enemy = mockk(relaxed = true) + val handshakes = mutableMapOf( + sender to createBotHandshake("Sender").also { it.teamId = 1 }, + teammate to createBotHandshake("Teammate").also { it.teamId = 1 }, + enemy to createBotHandshake("Enemy"), + ) + every { connectionHandler.mapToBotSockets(any()) } returns setOf(sender, teammate, enemy) + every { connectionHandler.getBotHandshakes() } answers { handshakes.toMap() } + + gameServer.handleStartGame(createValidGameSetup(), List(3) { mockk() }) + listOf(sender, teammate, enemy).forEach { gameServer.handleBotReady(it) } + gameServer.handlePauseGame() // keep turns from running while the intents are checked + + val teammateId = participantRegistry.participantIds.getValue(teammate).value + val enemyId = participantRegistry.participantIds.getValue(enemy).value + + // The connection handler drops the handshake of a bot that disconnects + handshakes.remove(teammate) + gameServer.handleBotLeft(teammate) + + fun directedIntent(receiverId: Int) = BotIntent().also { + it.teamMessages = listOf(TeamMessage().also { message -> + message.message = "\"hello\"" + message.messageType = "java.lang.String" + message.receiverId = receiverId + }) + } + + gameServer.handleBotIntent(sender, directedIntent(teammateId)) + verify(exactly = 0) { sender.close(any(), any()) } + + gameServer.handleBotIntent(sender, directedIntent(enemyId)) + verify(exactly = 1) { sender.close(1008, "Team message receiverId is not a teammate") } + + gameServer.handleAbortGame() + } + test("TR-SRV-LIF-001: Negative: Ignore ready signals when not in WAIT_FOR_READY_PARTICIPANTS") { val connectionHandler = mockk(relaxed = true) val participantRegistry = ParticipantRegistry(connectionHandler) diff --git a/web/docs/articles/team-messages.md b/web/docs/articles/team-messages.md index 9d00ec93e..4d9ba357a 100644 --- a/web/docs/articles/team-messages.md +++ b/web/docs/articles/team-messages.md @@ -34,6 +34,83 @@ In the `MyFirstTeam` example, two message types are used: | `RobotColors` | Synchronize team colors across all bots | At the start of a round | | `Point` | Share enemy position coordinates for droids to fire | When an enemy is scanned | +## Sending a Batch in One Event + +When several updates belong together, send them as one ordered batch. The receiving bot gets one `TeamMessageEvent` whose +`message` is a `TeamMessageBatch`; iterate `messages` to process the entries. The batch remains one packet and one callback, +so the server and Bot API do not create a separate event for every entry. Batching reduces per-message framing and callback +work, but does not compress the JSON entries. + +::: code-group + +```java [Java] +sendTeamMessageBatch(teammateId, List.of(new Point(250, 300), new Point(400, 180))); + +@Override +public void onTeamMessage(TeamMessageEvent event) { + if (event.getMessage() instanceof TeamMessageBatch batch) { + for (Object message : batch.getMessages()) { + // Process each entry in send order. + } + } +} +``` + +```csharp [C#] +SendTeamMessageBatch(teammateId, new object[] { new Point(250, 300), new Point(400, 180) }); + +public override void OnTeamMessage(TeamMessageEvent evt) { + if (evt.Message is TeamMessageBatch batch) { + foreach (var message in batch.Messages) { + // Process each entry in send order. + } + } +} +``` + +```python [Python] +self.send_team_message_batch(teammate_id, [Point(250, 300), Point(400, 180)]) + +async def on_team_message(self, event: TeamMessageEvent) -> None: + if isinstance(event.message, TeamMessageBatch): + for message in event.message.messages: + # Process each entry in send order. + pass +``` + +```typescript [TypeScript] +this.sendTeamMessageBatch(teammateId, [point1, point2]); + +override onTeamMessage(event: TeamMessageEvent) { + if (event.message instanceof TeamMessageBatch) { + for (const message of event.message.messages) { + // Process each entry in send order. + } + } +} +``` + +::: + +All members of the team must advertise batch version 1; otherwise the server rejects the sender's intent. A batch is +delivered on the next turn, and entries keep their send order. + +## Limits and errors + +Each bot may send up to 64 team-message packets per turn and up to 128 logical payloads total across ordinary messages +and batches. An encoded packet may contain at most 48 KiB (49,152 UTF-8 bytes), and the compact JSON +`teamMessages` array may contain at most 256 KiB (262,144 UTF-8 bytes) per turn. The byte counts use UTF-8 after compact +JSON encoding. Each client API checks a call before enqueueing it and throws if the packet or the complete turn would +exceed a limit; empty batches and null entries are rejected. The server rejects an invalid intent as a whole, so no +messages from that intent are delivered. Incoming WebSocket text frames are limited to 1 MiB before JSON parsing. + +Batching groups payloads in one packet and callback; it does not compress their contents. These Tank Royale per-turn +limits are separate from classic Robocode's 32,768-byte limit on the original Java-serialized message object. + +Legacy Robocode robots can put a serializable collection inside one `broadcastMessage` or `sendMessage` call and iterate +that collection from one `MessageEvent`. The original Java-serialized object must fit classic Robocode's 32,768-byte +message limit. + ## Defining Message Classes Each bot must define its own message classes. The classes are matched by name, so they must have the same name and @@ -590,8 +667,13 @@ class MyFirstDroid extends Bot implements Droid { ## Limitations -- **Maximum messages per turn**: 10 team messages per bot per turn -- **Maximum message size**: 32,768 bytes (JSON format) +- **Maximum packets per turn**: 64 per bot, including batches +- **Maximum logical payloads per turn**: 128, counting all messages inside batches +- **Maximum packet size**: 49,152 UTF-8 bytes; the compact `teamMessages` array is limited to 262,144 UTF-8 bytes +- **Delivery**: accepted packets arrive on the next turn; batch entries remain ordered and share one event +- **Failure behavior**: a client rejects an invalid call before enqueueing it, and the server rejects an invalid intent in full +- **Batch compatibility**: every recipient must use a Bot API that advertises batch version 1 +- **WebSocket input**: text frames above 1 MiB are closed before the server parses the JSON - **Serialization**: Messages must be serializable to JSON (no circular references) ## Best Practices From 7fa717e38a74fc1169ac847b560c6b97b85b3784 Mon Sep 17 00:00:00 2001 From: "Flemming N. Larsen" Date: Thu, 24 Sep 2026 21:56:38 +0200 Subject: [PATCH 09/13] Record intermittent team batch stress failure --- changes/CH-047-team-message-limits/open-questions.md | 8 +++++++- changes/CH-047-team-message-limits/proposal.md | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/changes/CH-047-team-message-limits/open-questions.md b/changes/CH-047-team-message-limits/open-questions.md index 82202f299..7ddd0f3f7 100644 --- a/changes/CH-047-team-message-limits/open-questions.md +++ b/changes/CH-047-team-message-limits/open-questions.md @@ -20,7 +20,7 @@ With that repair and a 100 ms test deadline, the five-bot workload delivered all The five-bot workload at 64 messages per bot per turn also failed at the normal 30 ms deadline. The runner measured 57.49 TPS and captured 19,200 sent messages with 128,520 payload bytes. Four recipients received 14,912 of 15,360 expected messages and recorded 6 ordering violations and 2 skipped turns; the fifth received 14,848 messages, with 4 ordering violations and 1 skipped turn. The reduced count improves throughput but does not meet the zero-skipped-turn acceptance gate. -## Acceptance result — 2026-09-23 +## Initial acceptance result — 2026-09-23 The batching gate passed on the matched local Tank Royale API and runner. The five-bot team sent 128 ordered items in one batch per turn for 60 turns, after a ten-turn warm-up, with the standard 30 ms intent timeout and a 30 TPS default. Every bot received all 30,720 expected items in order; there were zero skipped turns during the workload. The runner measured 94.94 turns/s from turn 10 through turn 80. The compact encoded outbound team-message arrays totaled 2,747,040 bytes; estimated teammate payload fan-out was 10,988,160 bytes, excluding WebSocket framing and transport overhead. The passing result accepts the 64-packet, 128-logical-payload, 49,152-byte packet, and 262,144-byte per-turn array limits without compression. @@ -28,6 +28,12 @@ The shared cross-platform boundary suite includes the exact 262,144-byte boundar The five-round CombatTeam compatibility run against the matched 1.4.0 API and runner completed in both engines with zero reported errors. Classic scored 10,645 and Tank Royale 13,367 (+25.6%); score parity remains a separate discrepancy and is outside the messaging delivery gate. +## Repeatability check — 2026-09-24 + +On the final PR commit, eleven fresh executions of the five-bot workload ran at the standard 30 ms intent timeout and 30 TPS setting. Ten delivered all 30,720 expected items to every bot in order with no skipped turns. Nine captured passing runs measured 81.1 to 89.3 turns/s; the tenth passed, but its throughput output was not captured. One run failed: one bot recorded a skipped turn, and each of the other four recorded one missing 128-item batch and an ordering gap. This leaves the stress acceptance gate unresolved despite the ten passing executions. + +The measured failure is a missed intent deadline during the messaging workload. Per-turn latency traces and a control run without messaging were not captured, so the cause cannot yet be attributed to message processing or host scheduling, and no revised limit is justified by these measurements. Keep the change and PR in draft pending a human decision on further diagnosis or revised trial limits. + ## Blocking decision Resolved by the user on 2026-09-23: trial standard ordered batching in one existing packet and event, with no compression at first. Retain the trial packet and byte caps, require batch version 1 from all recipients, and compare the five-bot 30 TPS result against the same zero-loss, zero-skipped-turn gate. The trial does not become a published policy unless that gate passes. diff --git a/changes/CH-047-team-message-limits/proposal.md b/changes/CH-047-team-message-limits/proposal.md index 4d92ace86..9ad116519 100644 --- a/changes/CH-047-team-message-limits/proposal.md +++ b/changes/CH-047-team-message-limits/proposal.md @@ -12,7 +12,7 @@ title: Batch team messages across Tank Royale Classic teams can send more messages in a turn than Tank Royale currently forwards. At the normal 30 ms deadline, the 128-message and 64-message count-only trials both missed the delivery and skipped-turn criteria. This change adds an ordered batch API that carries multiple logical payloads in one existing team-message packet and event. The accepted limits are 64 packets, 128 logical payloads, 48 KiB per encoded packet, and 256 KiB for the compact UTF-8 `teamMessages` array per bot per turn. The server keeps the 1 MiB pre-parse WebSocket bound and rejects invalid intents in full. -The user selected batching after the two count-only trials failed. Java is the semantic reference, and Python, .NET, and TypeScript match its behavior. Every recipient must advertise batch protocol version 1. Batches preserve entry order, use the existing next-turn delivery, and have one destination mode per packet. Compression is omitted because batching reduces per-message processing and event fanout without adding compression cost. The policy was accepted after uniform boundary tests, malformed-client checks, CombatTeam conformance, and a five-bot workload passed the agreed delivery and skipped-turn gate on matched local builds. +The user selected batching after the two count-only trials failed. Java is the semantic reference, and Python, .NET, and TypeScript match its behavior. Every recipient must advertise batch protocol version 1. Batches preserve entry order, use the existing next-turn delivery, and have one destination mode per packet. Compression is omitted because batching reduces per-message processing and event fanout without adding compression cost. Uniform boundary tests, malformed-client checks, and bridge conformance pass. An initial five-bot workload passed, but a repeatability check on the final commit found one missed turn and one lost batch in eleven fresh runs. The stress acceptance gate is unresolved, so these limits remain a draft candidate. ## Scope From f83b165d6dbae3a409c745f25939dd7649678fd2 Mon Sep 17 00:00:00 2001 From: "Flemming N. Larsen" Date: Thu, 24 Sep 2026 22:27:41 +0200 Subject: [PATCH 10/13] Measure team batch turn-budget headroom --- .../open-questions.md | 10 +++ .../CH-047-team-message-limits/proposal.md | 4 +- .../runner/BattleRunnerIntegrationTest.kt | 84 ++++++++++++++++--- .../TeamMessageBatchControl.java | 76 +++++++++++++++++ .../TeamMessageBatchControl.json | 9 ++ .../TeamMessageBatchControlTeam.json | 14 ++++ .../TeamMessageBatchStress.java | 35 +++++++- 7 files changed, 216 insertions(+), 16 deletions(-) create mode 100644 sample-bots/java/TeamMessageBatchControl/TeamMessageBatchControl.java create mode 100644 sample-bots/java/TeamMessageBatchControl/TeamMessageBatchControl.json create mode 100644 sample-bots/java/TeamMessageBatchControlTeam/TeamMessageBatchControlTeam.json diff --git a/changes/CH-047-team-message-limits/open-questions.md b/changes/CH-047-team-message-limits/open-questions.md index 7ddd0f3f7..fafbe81e6 100644 --- a/changes/CH-047-team-message-limits/open-questions.md +++ b/changes/CH-047-team-message-limits/open-questions.md @@ -34,6 +34,16 @@ On the final PR commit, eleven fresh executions of the five-bot workload ran at The measured failure is a missed intent deadline during the messaging workload. Per-turn latency traces and a control run without messaging were not captured, so the cause cannot yet be attributed to message processing or host scheduling, and no revised limit is justified by these measurements. Keep the change and PR in draft pending a human decision on further diagnosis or revised trial limits. +## Latency instrumentation and matched control — 2026-09-24 + +The stress bot now samples minimum and average `getTimeLeft()` during the 60 measured turns, maximum time spent constructing and submitting a batch, and maximum duration of one receive-handler callback. A matched five-bot control constructs the same 128 strings on the same turns but sends nothing. Both tests use the same 30 ms timeout, arena, opponents, and turn count. These are per-bot summaries; the handler duration is per callback rather than the sum of callbacks in a turn, and the test does not measure server-internal processing time. + +Eight fresh paired executions completed, counting the initial pair and seven repetitions: seven stress passes and one failure. The next repetition was interrupted after the failure was found. The control passed in each completed pair without skipped turns or unexpected team-message events. In the failed stress run, bot 1 recorded one skipped turn and a minimum time budget of 0 µs. Each of the other four teammates recorded one missing 128-item batch and an ordering gap. Their minimum budgets fell as low as 370 µs, and their maximum batch-construction/submission intervals reached 21,730 µs. Bot 1's maximum individual receive-handler callback was 4,763 µs. In the same pair, the control's minimum and average remaining budgets were 23,221 µs and 29,284 µs, with a maximum payload-construction interval of 6,352 µs. + +The failure directly establishes that the candidate workload can exhaust a bot's turn budget on this machine; the control comparison points to combined team-message processing as the load-sensitive path, but the aggregate telemetry does not isolate client encoding, server delivery, callback scheduling, or host scheduling as the source. A passing stress pair measured a 6,718 µs minimum and 26,638 µs average remaining budget, also showing a narrow margin even without a missed turn. The zero-loss, zero-skip acceptance gate has failed; keep the candidate unpublished and the PR in draft. + +For a next trial, consider limiting each bot to 64 logical payloads per turn while retaining the 49,152-byte packet and 262,144-byte per-turn array caps initially. The measured 128-entry batch packet was about 9.2 KiB, well below either byte cap, so lowering byte limits would not target the observed count-heavy workload. The 64-entry batch candidate is unverified; the earlier 64-message count-only workload used a different, unbatched path and also failed its gate. Do not change the trial policy until a human selects a candidate and its stress gate passes. + ## Blocking decision Resolved by the user on 2026-09-23: trial standard ordered batching in one existing packet and event, with no compression at first. Retain the trial packet and byte caps, require batch version 1 from all recipients, and compare the five-bot 30 TPS result against the same zero-loss, zero-skipped-turn gate. The trial does not become a published policy unless that gate passes. diff --git a/changes/CH-047-team-message-limits/proposal.md b/changes/CH-047-team-message-limits/proposal.md index 9ad116519..1803b2681 100644 --- a/changes/CH-047-team-message-limits/proposal.md +++ b/changes/CH-047-team-message-limits/proposal.md @@ -10,9 +10,9 @@ title: Batch team messages across Tank Royale ## What and why -Classic teams can send more messages in a turn than Tank Royale currently forwards. At the normal 30 ms deadline, the 128-message and 64-message count-only trials both missed the delivery and skipped-turn criteria. This change adds an ordered batch API that carries multiple logical payloads in one existing team-message packet and event. The accepted limits are 64 packets, 128 logical payloads, 48 KiB per encoded packet, and 256 KiB for the compact UTF-8 `teamMessages` array per bot per turn. The server keeps the 1 MiB pre-parse WebSocket bound and rejects invalid intents in full. +Classic teams can send more messages in a turn than Tank Royale currently forwards. At the normal 30 ms deadline, the 128-message and 64-message count-only trials both missed the delivery and skipped-turn criteria. This change adds an ordered batch API that carries multiple logical payloads in one existing team-message packet and event. The draft trial limits are 64 packets, 128 logical payloads, 48 KiB per encoded packet, and 256 KiB for the compact UTF-8 `teamMessages` array per bot per turn. The server keeps the 1 MiB pre-parse WebSocket bound and rejects invalid intents in full. -The user selected batching after the two count-only trials failed. Java is the semantic reference, and Python, .NET, and TypeScript match its behavior. Every recipient must advertise batch protocol version 1. Batches preserve entry order, use the existing next-turn delivery, and have one destination mode per packet. Compression is omitted because batching reduces per-message processing and event fanout without adding compression cost. Uniform boundary tests, malformed-client checks, and bridge conformance pass. An initial five-bot workload passed, but a repeatability check on the final commit found one missed turn and one lost batch in eleven fresh runs. The stress acceptance gate is unresolved, so these limits remain a draft candidate. +The user selected batching after the two count-only trials failed. Java is the semantic reference, and Python, .NET, and TypeScript match its behavior. Every recipient must advertise batch protocol version 1. Batches preserve entry order, use the existing next-turn delivery, and have one destination mode per packet. Compression is omitted because batching reduces per-message processing and event fanout without adding compression cost. Uniform boundary tests, malformed-client checks, and bridge conformance pass. The initial stress run passed, but repeatability testing found missed turns and batches, and the matched no-message control retained substantially more turn budget. The latest instrumented sequence also had one failure in eight fresh paired runs; the stress gate remains unresolved, so these values are draft candidates rather than accepted policy. See OQ-002 for the measurements and a possible next trial. ## Scope diff --git a/runner/src/test/kotlin/dev/robocode/tankroyale/runner/BattleRunnerIntegrationTest.kt b/runner/src/test/kotlin/dev/robocode/tankroyale/runner/BattleRunnerIntegrationTest.kt index d88a28292..b8d107f25 100644 --- a/runner/src/test/kotlin/dev/robocode/tankroyale/runner/BattleRunnerIntegrationTest.kt +++ b/runner/src/test/kotlin/dev/robocode/tankroyale/runner/BattleRunnerIntegrationTest.kt @@ -446,6 +446,36 @@ class BattleRunnerIntegrationTest { @Test @Timeout(120) fun testPRO006_IntegrationPositive_fiveBotsDeliverOrdered128ItemBatchesWithoutSkippedTurns() { + runFiveBotTeamMessageTrial( + teamName = "TeamMessageBatchStressTeam", + botName = "TeamMessageBatchStress", + expectedReceivedItemsPerBot = 4 * 60 * 128, + outboundTeamMessageBytes = estimateBatchArrayBytes() + ) + } + + @Tag("integration") + @Tag("slow") + @Tag("PRO-006") + @Tag("Integration") + @Tag("Positive") + @Test + @Timeout(120) + fun testPRO006_IntegrationPositive_fiveBotNoMessageControlSustains30Tps() { + runFiveBotTeamMessageTrial( + teamName = "TeamMessageBatchControlTeam", + botName = "TeamMessageBatchControl", + expectedReceivedItemsPerBot = 0, + outboundTeamMessageBytes = 0 + ) + } + + private fun runFiveBotTeamMessageTrial( + teamName: String, + botName: String, + expectedReceivedItemsPerBot: Int, + outboundTeamMessageBytes: Long + ) { val expectedTurn = 80 val measuredFromTurn = 10 val firstTickTurn = AtomicReference() @@ -466,7 +496,7 @@ class BattleRunnerIntegrationTest { defaultTurnsPerSecond = 30 }, listOf( - BotEntry.of(botDir("TeamMessageBatchStressTeam")), + BotEntry.of(botDir(teamName)), BotEntry.of(botDir("Walls")), BotEntry.of(botDir("SpinBot")) ) @@ -484,47 +514,75 @@ class BattleRunnerIntegrationTest { } assertThat(completed.await(90, TimeUnit.SECONDS)) - .describedAs("the five-bot batch workload must reach turn $expectedTurn") + .describedAs("the five-bot $botName workload must reach turn $expectedTurn") .isTrue() handle.onTickEvent.off(owner) handle.stop() - val states = finalStates.get()!!.filter { it.name == "TeamMessageBatchStress" } + val states = finalStates.get()!!.filter { it.name == botName } assertThat(states).hasSize(5) println( - "BATCH_STRESS_STATE " + states.joinToString { state -> + "TEAM_MESSAGE_TRIAL_STATE workload=$botName " + states.joinToString { state -> "id=${state.id},body=${state.bodyColor},tracks=${state.tracksColor}," + - "turret=${state.turretColor},radar=${state.radarColor}" + "turret=${state.turretColor},radar=${state.radarColor}," + + "scan=${state.scanColor},gun=${state.gunColor},bullet=${state.bulletColor}" } ) + val minTimeLeftMicros = mutableListOf() + val averageTimeLeftMicros = mutableListOf() + val maxMessageWorkMicros = mutableListOf() + val maxTeamMessageHandlerMicros = mutableListOf() states.forEach { state -> val hex = state.bodyColor!!.removePrefix("#") val received = (hex.substring(0, 2).toInt(16) shl 8) or hex.substring(2, 4).toInt(16) val protocolErrors = hex.substring(4, 6).toInt(16) val skippedTurns = state.tracksColor!!.removePrefix("#").substring(2, 4).toInt(16) + val radar = decodeRgb(state.radarColor) + val contentErrors = (radar ushr 16) and 0xff + val averageLeftMicros = radar and 0xffff + val minLeftMicros = decodeRgb(state.scanColor) + val maxWorkMicros = decodeRgb(state.gunColor) + val maxHandlerMicros = decodeRgb(state.bulletColor) assertThat(received) - .describedAs("ordered logical messages received by bot ${state.id}; errors=$protocolErrors skipped=$skippedTurns") - .isEqualTo(4 * 60 * 128) + .describedAs("ordered logical messages received by $botName ${state.id}; errors=$protocolErrors skipped=$skippedTurns") + .isEqualTo(expectedReceivedItemsPerBot) assertThat(skippedTurns) - .describedAs("skipped turns recorded by bot ${state.id}") + .describedAs("skipped turns recorded by $botName ${state.id}") .isZero() assertThat(protocolErrors) - .describedAs("batch order and protocol errors recorded by bot ${state.id}") + .describedAs("message and protocol errors recorded by $botName ${state.id}") + .isZero() + assertThat(contentErrors) + .describedAs("message content errors recorded by $botName ${state.id}") .isZero() + minTimeLeftMicros.add(minLeftMicros) + averageTimeLeftMicros.add(averageLeftMicros) + maxMessageWorkMicros.add(maxWorkMicros) + maxTeamMessageHandlerMicros.add(maxHandlerMicros) } val elapsedSeconds = (finishedAtNanos.get()!! - startedAtNanos.get()!!) / 1_000_000_000.0 val measuredTps = (expectedTurn - firstTickTurn.get()!!) / elapsedSeconds - val outboundBytes = estimateBatchArrayBytes() println( - "BATCH_STRESS bots=5 turns=60 tps=$measuredTps outboundTeamMessagesBytes=$outboundBytes " + - "estimatedTeamPayloadFanoutBytes=${outboundBytes * 4} receivedPerBot=${4 * 60 * 128} skipped=0" + "TEAM_MESSAGE_TRIAL workload=$botName bots=5 turns=60 tps=$measuredTps " + + "outboundTeamMessagesBytes=$outboundTeamMessageBytes " + + "estimatedTeamPayloadFanoutBytes=${outboundTeamMessageBytes * 4} " + + "receivedPerBot=$expectedReceivedItemsPerBot skipped=0 " + + "minTimeLeftMicros=${minTimeLeftMicros.minOrNull()} " + + "averageTimeLeftMicros=${averageTimeLeftMicros.average().toLong()} " + + "maxMessageWorkMicros=${maxMessageWorkMicros.maxOrNull()} " + + "maxTeamMessageHandlerMicros=${maxTeamMessageHandlerMicros.maxOrNull()}" ) assertThat(measuredTps) - .describedAs("measured turn rate under the 30 TPS workload") + .describedAs("measured turn rate under the $botName workload") .isGreaterThanOrEqualTo(30.0) + if (expectedReceivedItemsPerBot == 0) { + assertThat(maxTeamMessageHandlerMicros).containsOnly(0) + } } } + private fun decodeRgb(color: String?): Int = requireNotNull(color).removePrefix("#").take(6).toInt(16) + private fun estimateBatchArrayBytes(): Long { fun encodedString(value: String) = JsonPrimitive(value).toString() var totalBytes = 0L diff --git a/sample-bots/java/TeamMessageBatchControl/TeamMessageBatchControl.java b/sample-bots/java/TeamMessageBatchControl/TeamMessageBatchControl.java new file mode 100644 index 000000000..f13af99aa --- /dev/null +++ b/sample-bots/java/TeamMessageBatchControl/TeamMessageBatchControl.java @@ -0,0 +1,76 @@ +import dev.robocode.tankroyale.botapi.Bot; +import dev.robocode.tankroyale.botapi.events.SkippedTurnEvent; +import dev.robocode.tankroyale.botapi.events.TeamMessageEvent; +import dev.robocode.tankroyale.botapi.graphics.Color; + +import java.util.ArrayList; +import java.util.List; + +/** Matched no-message control for the local 30 TPS team-message trial. */ +public class TeamMessageBatchControl extends Bot { + private static final int FIRST_MEASURED_TURN = 11; + private static final int LAST_MEASURED_TURN = 70; + private static final int ITEMS_PER_BATCH = 128; + + private int skippedTurns; + private int unexpectedMessages; + private int minTimeLeftMicros = Integer.MAX_VALUE; + private long totalTimeLeftMicros; + private int timeLeftSamples; + private int maxMessageWorkMicros; + + public static void main(String[] args) { + new TeamMessageBatchControl().start(); + } + + @Override + public void run() { + while (isRunning()) { + int turn = getTurnNumber(); + long messageWorkStartedAt = System.nanoTime(); + if (turn >= FIRST_MEASURED_TURN && turn <= LAST_MEASURED_TURN) { + // Keep payload construction equal to the stress bot while omitting the send call. + List messages = new ArrayList<>(ITEMS_PER_BATCH); + for (int item = 0; item < ITEMS_PER_BATCH; item++) { + messages.add(getMyId() + ":" + turn + ":" + item); + } + int timeLeftMicros = Math.max(0, getTimeLeft()); + minTimeLeftMicros = Math.min(minTimeLeftMicros, timeLeftMicros); + totalTimeLeftMicros += timeLeftMicros; + timeLeftSamples++; + maxMessageWorkMicros = Math.max(maxMessageWorkMicros, + (int) ((System.nanoTime() - messageWorkStartedAt) / 1_000)); + } + if (turn >= LAST_MEASURED_TURN + 10 || turn % 5 == 0) { + int averageTimeLeftMicros = timeLeftSamples == 0 ? 0 + : (int) Math.min(0xffff, totalTimeLeftMicros / timeLeftSamples); + setBodyColor(Color.fromRgb(0, 0, Math.min(unexpectedMessages, 0xff))); + setTracksColor(Color.fromRgb(0, Math.min(skippedTurns, 0xff), 0)); + setTurretColor(Color.fromRgb(0, 0, 0)); + setRadarColor(Color.fromRgb(0, (averageTimeLeftMicros >>> 8) & 0xff, + averageTimeLeftMicros & 0xff)); + setScanColor(encodedColor(minTimeLeftMicros == Integer.MAX_VALUE ? 0 : minTimeLeftMicros)); + setGunColor(encodedColor(maxMessageWorkMicros)); + setBulletColor(encodedColor(0)); + } + go(); + } + } + + @Override + public void onSkippedTurn(SkippedTurnEvent event) { + if (event.getTurnNumber() >= FIRST_MEASURED_TURN && event.getTurnNumber() <= LAST_MEASURED_TURN) { + skippedTurns++; + } + } + + @Override + public void onTeamMessage(TeamMessageEvent event) { + unexpectedMessages++; + } + + private static Color encodedColor(int value) { + int rgb = Math.min(Math.max(value, 0), 0xffffff); + return Color.fromRgb((rgb >>> 16) & 0xff, (rgb >>> 8) & 0xff, rgb & 0xff); + } +} diff --git a/sample-bots/java/TeamMessageBatchControl/TeamMessageBatchControl.json b/sample-bots/java/TeamMessageBatchControl/TeamMessageBatchControl.json new file mode 100644 index 000000000..2a56cf4d6 --- /dev/null +++ b/sample-bots/java/TeamMessageBatchControl/TeamMessageBatchControl.json @@ -0,0 +1,9 @@ +{ + "name": "TeamMessageBatchControl", + "version": "1.0", + "authors": ["Robocode Tank Royale"], + "description": "Matched no-message control for the local 30 TPS team-message trial.", + "platform": "JVM", + "programmingLang": "Java 11", + "license": "Apache-2.0" +} diff --git a/sample-bots/java/TeamMessageBatchControlTeam/TeamMessageBatchControlTeam.json b/sample-bots/java/TeamMessageBatchControlTeam/TeamMessageBatchControlTeam.json new file mode 100644 index 000000000..040bae95b --- /dev/null +++ b/sample-bots/java/TeamMessageBatchControlTeam/TeamMessageBatchControlTeam.json @@ -0,0 +1,14 @@ +{ + "name": "TeamMessageBatchControlTeam", + "version": "1.0", + "authors": ["Robocode Tank Royale"], + "description": "Five copies of the no-message control bot for the local 30 TPS trial.", + "teamMembers": [ + "TeamMessageBatchControl", + "TeamMessageBatchControl", + "TeamMessageBatchControl", + "TeamMessageBatchControl", + "TeamMessageBatchControl" + ], + "license": "Apache-2.0" +} diff --git a/sample-bots/java/TeamMessageBatchStress/TeamMessageBatchStress.java b/sample-bots/java/TeamMessageBatchStress/TeamMessageBatchStress.java index d5960a74f..55fdf950d 100644 --- a/sample-bots/java/TeamMessageBatchStress/TeamMessageBatchStress.java +++ b/sample-bots/java/TeamMessageBatchStress/TeamMessageBatchStress.java @@ -24,6 +24,11 @@ public class TeamMessageBatchStress extends Bot { private int sizeErrors; private int orderErrors; private int contentErrors; + private int minTimeLeftMicros = Integer.MAX_VALUE; + private long totalTimeLeftMicros; + private int timeLeftSamples; + private int maxMessageWorkMicros; + private int maxTeamMessageHandlerMicros; public static void main(String[] args) { new TeamMessageBatchStress().start(); @@ -33,12 +38,19 @@ public static void main(String[] args) { public void run() { while (isRunning()) { int turn = getTurnNumber(); + long messageWorkStartedAt = System.nanoTime(); if (turn >= FIRST_SEND_TURN && turn <= LAST_SEND_TURN) { List messages = new ArrayList<>(ITEMS_PER_BATCH); for (int item = 0; item < ITEMS_PER_BATCH; item++) { messages.add(getMyId() + ":" + turn + ":" + item); } broadcastTeamMessageBatch(messages); + int timeLeftMicros = Math.max(0, getTimeLeft()); + minTimeLeftMicros = Math.min(minTimeLeftMicros, timeLeftMicros); + totalTimeLeftMicros += timeLeftMicros; + timeLeftSamples++; + maxMessageWorkMicros = Math.max(maxMessageWorkMicros, + (int) ((System.nanoTime() - messageWorkStartedAt) / 1_000)); } if (turn >= LAST_SEND_TURN + 10 || turn % 5 == 0) { setBodyColor(Color.fromRgb((receivedItems >>> 8) & 0xff, receivedItems & 0xff, @@ -46,7 +58,13 @@ public void run() { setTracksColor(Color.fromRgb(0, Math.min(skippedTurns, 0xff), 0)); setTurretColor(Color.fromRgb(Math.min(typeErrors, 0xff), Math.min(sizeErrors, 0xff), Math.min(orderErrors, 0xff))); - setRadarColor(Color.fromRgb(Math.min(contentErrors, 0xff), 0, 0)); + int averageTimeLeftMicros = timeLeftSamples == 0 ? 0 + : (int) Math.min(0xffff, totalTimeLeftMicros / timeLeftSamples); + setRadarColor(Color.fromRgb(Math.min(contentErrors, 0xff), + (averageTimeLeftMicros >>> 8) & 0xff, averageTimeLeftMicros & 0xff)); + setScanColor(encodedColor(minTimeLeftMicros == Integer.MAX_VALUE ? 0 : minTimeLeftMicros)); + setGunColor(encodedColor(maxMessageWorkMicros)); + setBulletColor(encodedColor(maxTeamMessageHandlerMicros)); } go(); } @@ -54,6 +72,16 @@ public void run() { @Override public void onTeamMessage(TeamMessageEvent event) { + long handlerStartedAt = System.nanoTime(); + try { + handleTeamMessage(event); + } finally { + maxTeamMessageHandlerMicros = Math.max(maxTeamMessageHandlerMicros, + (int) ((System.nanoTime() - handlerStartedAt) / 1_000)); + } + } + + private void handleTeamMessage(TeamMessageEvent event) { if (!(event.getMessage() instanceof TeamMessageBatch)) { protocolErrors++; typeErrors++; @@ -96,6 +124,11 @@ public void onTeamMessage(TeamMessageEvent event) { receivedItems += ITEMS_PER_BATCH; } + private static Color encodedColor(int value) { + int rgb = Math.min(Math.max(value, 0), 0xffffff); + return Color.fromRgb((rgb >>> 16) & 0xff, (rgb >>> 8) & 0xff, rgb & 0xff); + } + @Override public void onSkippedTurn(SkippedTurnEvent event) { if (event.getTurnNumber() >= FIRST_SEND_TURN && event.getTurnNumber() <= LAST_SEND_TURN) { From db6c22a91a75cf146101cf869e7b0ca986647b50 Mon Sep 17 00:00:00 2001 From: "Flemming N. Larsen" Date: Thu, 24 Sep 2026 22:55:04 +0200 Subject: [PATCH 11/13] Trial 64-item team message batches --- .../open-questions.md | 8 + .../CH-047-team-message-limits/proposal.md | 2 +- .../runner/BattleRunnerIntegrationTest.kt | 49 ++++++- .../TeamMessageBatchControl64.java | 76 ++++++++++ .../TeamMessageBatchControl64.json | 9 ++ .../TeamMessageBatchControl64Team.json | 14 ++ .../TeamMessageBatchStress64.java | 138 ++++++++++++++++++ .../TeamMessageBatchStress64.json | 9 ++ .../TeamMessageBatchStress64Team.json | 14 ++ 9 files changed, 312 insertions(+), 7 deletions(-) create mode 100644 sample-bots/java/TeamMessageBatchControl64/TeamMessageBatchControl64.java create mode 100644 sample-bots/java/TeamMessageBatchControl64/TeamMessageBatchControl64.json create mode 100644 sample-bots/java/TeamMessageBatchControl64Team/TeamMessageBatchControl64Team.json create mode 100644 sample-bots/java/TeamMessageBatchStress64/TeamMessageBatchStress64.java create mode 100644 sample-bots/java/TeamMessageBatchStress64/TeamMessageBatchStress64.json create mode 100644 sample-bots/java/TeamMessageBatchStress64Team/TeamMessageBatchStress64Team.json diff --git a/changes/CH-047-team-message-limits/open-questions.md b/changes/CH-047-team-message-limits/open-questions.md index fafbe81e6..82e3d01e6 100644 --- a/changes/CH-047-team-message-limits/open-questions.md +++ b/changes/CH-047-team-message-limits/open-questions.md @@ -44,6 +44,14 @@ The failure directly establishes that the candidate workload can exhaust a bot's For a next trial, consider limiting each bot to 64 logical payloads per turn while retaining the 49,152-byte packet and 262,144-byte per-turn array caps initially. The measured 128-entry batch packet was about 9.2 KiB, well below either byte cap, so lowering byte limits would not target the observed count-heavy workload. The 64-entry batch candidate is unverified; the earlier 64-message count-only workload used a different, unbatched path and also failed its gate. Do not change the trial policy until a human selects a candidate and its stress gate passes. +## Batched 64-entry trial — 2026-09-24 + +The five-bot team sent one batch of 64 ordered payloads per sender for 60 turns at the standard 30 ms timeout and 30 TPS setting. Ten fresh paired executions completed with all 15,360 expected logical items received by each bot in order, zero skipped turns, and no test failures. The matched control constructed 64 payload strings on the same turns and sent none; it also completed all ten runs without skipped turns or unexpected team messages. Each paired run used the same arena and opponents and forced the Gradle test task to rerun. + +Across the ten stress runs, measured throughput ranged from 99.1 to 118.6 turns/s, average remaining turn budget from 26.958 to 27.589 ms, and minimum remaining budget from 8.581 to 15.803 ms. Maximum batch construction and API submission time ranged from 8.716 to 13.655 ms; the slowest single receive-handler callback was 6.164 ms. The control's minimum remaining budget ranged from 21.234 to 25.461 ms, with a 29.325 to 29.453 ms average. The encoded outbound arrays totaled 1,378,320 bytes; estimated teammate payload fan-out was 5,513,280 bytes, excluding WebSocket framing and transport overhead. + +All ten 64-entry runs passed on the user's fast local PC, but their minimum budget fell as low as 8.581 ms. This does not establish reliability on average or lower-spec hardware, and it does not resolve the 128-entry failure. The 64-entry test fixture is measurement evidence only; do not change the candidate API limit or publish the policy based on this host's results. Keep PR 276 in draft until the chosen trial passes on representative slower hardware or an agreed calibrated CPU limit. + ## Blocking decision Resolved by the user on 2026-09-23: trial standard ordered batching in one existing packet and event, with no compression at first. Retain the trial packet and byte caps, require batch version 1 from all recipients, and compare the five-bot 30 TPS result against the same zero-loss, zero-skipped-turn gate. The trial does not become a published policy unless that gate passes. diff --git a/changes/CH-047-team-message-limits/proposal.md b/changes/CH-047-team-message-limits/proposal.md index 1803b2681..406d8b4b2 100644 --- a/changes/CH-047-team-message-limits/proposal.md +++ b/changes/CH-047-team-message-limits/proposal.md @@ -12,7 +12,7 @@ title: Batch team messages across Tank Royale Classic teams can send more messages in a turn than Tank Royale currently forwards. At the normal 30 ms deadline, the 128-message and 64-message count-only trials both missed the delivery and skipped-turn criteria. This change adds an ordered batch API that carries multiple logical payloads in one existing team-message packet and event. The draft trial limits are 64 packets, 128 logical payloads, 48 KiB per encoded packet, and 256 KiB for the compact UTF-8 `teamMessages` array per bot per turn. The server keeps the 1 MiB pre-parse WebSocket bound and rejects invalid intents in full. -The user selected batching after the two count-only trials failed. Java is the semantic reference, and Python, .NET, and TypeScript match its behavior. Every recipient must advertise batch protocol version 1. Batches preserve entry order, use the existing next-turn delivery, and have one destination mode per packet. Compression is omitted because batching reduces per-message processing and event fanout without adding compression cost. Uniform boundary tests, malformed-client checks, and bridge conformance pass. The initial stress run passed, but repeatability testing found missed turns and batches, and the matched no-message control retained substantially more turn budget. The latest instrumented sequence also had one failure in eight fresh paired runs; the stress gate remains unresolved, so these values are draft candidates rather than accepted policy. See OQ-002 for the measurements and a possible next trial. +The user selected batching after the two count-only trials failed. Java is the semantic reference, and Python, .NET, and TypeScript match its behavior. Every recipient must advertise batch protocol version 1. Batches preserve entry order, use the existing next-turn delivery, and have one destination mode per packet. Compression is omitted because batching reduces per-message processing and event fanout without adding compression cost. Uniform boundary tests, malformed-client checks, and bridge conformance pass. The initial stress run passed, but repeatability testing found missed turns and batches, and the matched no-message control retained substantially more turn budget. The latest instrumented 128-entry sequence had one failure in eight fresh paired runs; a separate 64-entry batch trial passed ten fresh pairs on the user's fast local PC, with minimum remaining budget as low as 8.581 ms. The stress gate remains unresolved for slower hardware, so the draft trial values are not accepted policy. See OQ-002 for the measurements. ## Scope diff --git a/runner/src/test/kotlin/dev/robocode/tankroyale/runner/BattleRunnerIntegrationTest.kt b/runner/src/test/kotlin/dev/robocode/tankroyale/runner/BattleRunnerIntegrationTest.kt index b8d107f25..8e0ce9717 100644 --- a/runner/src/test/kotlin/dev/robocode/tankroyale/runner/BattleRunnerIntegrationTest.kt +++ b/runner/src/test/kotlin/dev/robocode/tankroyale/runner/BattleRunnerIntegrationTest.kt @@ -450,7 +450,8 @@ class BattleRunnerIntegrationTest { teamName = "TeamMessageBatchStressTeam", botName = "TeamMessageBatchStress", expectedReceivedItemsPerBot = 4 * 60 * 128, - outboundTeamMessageBytes = estimateBatchArrayBytes() + outboundTeamMessageBytes = estimateBatchArrayBytes(128), + itemsPerBatch = 128 ) } @@ -466,7 +467,42 @@ class BattleRunnerIntegrationTest { teamName = "TeamMessageBatchControlTeam", botName = "TeamMessageBatchControl", expectedReceivedItemsPerBot = 0, - outboundTeamMessageBytes = 0 + outboundTeamMessageBytes = 0, + itemsPerBatch = 128 + ) + } + + @Tag("integration") + @Tag("slow") + @Tag("PRO-006") + @Tag("Integration") + @Tag("Positive") + @Test + @Timeout(120) + fun testPRO006_IntegrationPositive_fiveBotsDeliverOrdered64ItemBatchesWithoutSkippedTurns() { + runFiveBotTeamMessageTrial( + teamName = "TeamMessageBatchStress64Team", + botName = "TeamMessageBatchStress64", + expectedReceivedItemsPerBot = 4 * 60 * 64, + outboundTeamMessageBytes = estimateBatchArrayBytes(64), + itemsPerBatch = 64 + ) + } + + @Tag("integration") + @Tag("slow") + @Tag("PRO-006") + @Tag("Integration") + @Tag("Positive") + @Test + @Timeout(120) + fun testPRO006_IntegrationPositive_fiveBot64ItemNoMessageControlSustains30Tps() { + runFiveBotTeamMessageTrial( + teamName = "TeamMessageBatchControl64Team", + botName = "TeamMessageBatchControl64", + expectedReceivedItemsPerBot = 0, + outboundTeamMessageBytes = 0, + itemsPerBatch = 64 ) } @@ -474,7 +510,8 @@ class BattleRunnerIntegrationTest { teamName: String, botName: String, expectedReceivedItemsPerBot: Int, - outboundTeamMessageBytes: Long + outboundTeamMessageBytes: Long, + itemsPerBatch: Int ) { val expectedTurn = 80 val measuredFromTurn = 10 @@ -563,7 +600,7 @@ class BattleRunnerIntegrationTest { val elapsedSeconds = (finishedAtNanos.get()!! - startedAtNanos.get()!!) / 1_000_000_000.0 val measuredTps = (expectedTurn - firstTickTurn.get()!!) / elapsedSeconds println( - "TEAM_MESSAGE_TRIAL workload=$botName bots=5 turns=60 tps=$measuredTps " + + "TEAM_MESSAGE_TRIAL workload=$botName bots=5 turns=60 itemsPerBatch=$itemsPerBatch tps=$measuredTps " + "outboundTeamMessagesBytes=$outboundTeamMessageBytes " + "estimatedTeamPayloadFanoutBytes=${outboundTeamMessageBytes * 4} " + "receivedPerBot=$expectedReceivedItemsPerBot skipped=0 " + @@ -583,12 +620,12 @@ class BattleRunnerIntegrationTest { private fun decodeRgb(color: String?): Int = requireNotNull(color).removePrefix("#").take(6).toInt(16) - private fun estimateBatchArrayBytes(): Long { + private fun estimateBatchArrayBytes(itemsPerBatch: Int): Long { fun encodedString(value: String) = JsonPrimitive(value).toString() var totalBytes = 0L for (botId in 1..5) { for (turn in 1..60) { - val entries = (0 until 128).joinToString(",") { item -> + val entries = (0 until itemsPerBatch).joinToString(",") { item -> "{\"messageType\":\"java.lang.String\",\"message\":" + encodedString(encodedString("$botId:$turn:$item")) + "}" } diff --git a/sample-bots/java/TeamMessageBatchControl64/TeamMessageBatchControl64.java b/sample-bots/java/TeamMessageBatchControl64/TeamMessageBatchControl64.java new file mode 100644 index 000000000..0b561ebf1 --- /dev/null +++ b/sample-bots/java/TeamMessageBatchControl64/TeamMessageBatchControl64.java @@ -0,0 +1,76 @@ +import dev.robocode.tankroyale.botapi.Bot; +import dev.robocode.tankroyale.botapi.events.SkippedTurnEvent; +import dev.robocode.tankroyale.botapi.events.TeamMessageEvent; +import dev.robocode.tankroyale.botapi.graphics.Color; + +import java.util.ArrayList; +import java.util.List; + +/** Matched no-message control for the local 30 TPS team-message trial. */ +public class TeamMessageBatchControl64 extends Bot { + private static final int FIRST_MEASURED_TURN = 11; + private static final int LAST_MEASURED_TURN = 70; + private static final int ITEMS_PER_BATCH = 64; + + private int skippedTurns; + private int unexpectedMessages; + private int minTimeLeftMicros = Integer.MAX_VALUE; + private long totalTimeLeftMicros; + private int timeLeftSamples; + private int maxMessageWorkMicros; + + public static void main(String[] args) { + new TeamMessageBatchControl64().start(); + } + + @Override + public void run() { + while (isRunning()) { + int turn = getTurnNumber(); + long messageWorkStartedAt = System.nanoTime(); + if (turn >= FIRST_MEASURED_TURN && turn <= LAST_MEASURED_TURN) { + // Keep payload construction equal to the stress bot while omitting the send call. + List messages = new ArrayList<>(ITEMS_PER_BATCH); + for (int item = 0; item < ITEMS_PER_BATCH; item++) { + messages.add(getMyId() + ":" + turn + ":" + item); + } + int timeLeftMicros = Math.max(0, getTimeLeft()); + minTimeLeftMicros = Math.min(minTimeLeftMicros, timeLeftMicros); + totalTimeLeftMicros += timeLeftMicros; + timeLeftSamples++; + maxMessageWorkMicros = Math.max(maxMessageWorkMicros, + (int) ((System.nanoTime() - messageWorkStartedAt) / 1_000)); + } + if (turn >= LAST_MEASURED_TURN + 10 || turn % 5 == 0) { + int averageTimeLeftMicros = timeLeftSamples == 0 ? 0 + : (int) Math.min(0xffff, totalTimeLeftMicros / timeLeftSamples); + setBodyColor(Color.fromRgb(0, 0, Math.min(unexpectedMessages, 0xff))); + setTracksColor(Color.fromRgb(0, Math.min(skippedTurns, 0xff), 0)); + setTurretColor(Color.fromRgb(0, 0, 0)); + setRadarColor(Color.fromRgb(0, (averageTimeLeftMicros >>> 8) & 0xff, + averageTimeLeftMicros & 0xff)); + setScanColor(encodedColor(minTimeLeftMicros == Integer.MAX_VALUE ? 0 : minTimeLeftMicros)); + setGunColor(encodedColor(maxMessageWorkMicros)); + setBulletColor(encodedColor(0)); + } + go(); + } + } + + @Override + public void onSkippedTurn(SkippedTurnEvent event) { + if (event.getTurnNumber() >= FIRST_MEASURED_TURN && event.getTurnNumber() <= LAST_MEASURED_TURN) { + skippedTurns++; + } + } + + @Override + public void onTeamMessage(TeamMessageEvent event) { + unexpectedMessages++; + } + + private static Color encodedColor(int value) { + int rgb = Math.min(Math.max(value, 0), 0xffffff); + return Color.fromRgb((rgb >>> 16) & 0xff, (rgb >>> 8) & 0xff, rgb & 0xff); + } +} diff --git a/sample-bots/java/TeamMessageBatchControl64/TeamMessageBatchControl64.json b/sample-bots/java/TeamMessageBatchControl64/TeamMessageBatchControl64.json new file mode 100644 index 000000000..963477085 --- /dev/null +++ b/sample-bots/java/TeamMessageBatchControl64/TeamMessageBatchControl64.json @@ -0,0 +1,9 @@ +{ + "name": "TeamMessageBatchControl64", + "version": "1.0", + "authors": ["Robocode Tank Royale"], + "description": "Matched no-message control for the 64-entry local team-message trial.", + "platform": "JVM", + "programmingLang": "Java 11", + "license": "Apache-2.0" +} \ No newline at end of file diff --git a/sample-bots/java/TeamMessageBatchControl64Team/TeamMessageBatchControl64Team.json b/sample-bots/java/TeamMessageBatchControl64Team/TeamMessageBatchControl64Team.json new file mode 100644 index 000000000..8efc42745 --- /dev/null +++ b/sample-bots/java/TeamMessageBatchControl64Team/TeamMessageBatchControl64Team.json @@ -0,0 +1,14 @@ +{ + "name": "TeamMessageBatchControl64Team", + "version": "1.0", + "authors": ["Robocode Tank Royale"], + "description": "Five copies of the matched no-message control for the 64-entry batch trial.", + "teamMembers": [ + "TeamMessageBatchControl64", + "TeamMessageBatchControl64", + "TeamMessageBatchControl64", + "TeamMessageBatchControl64", + "TeamMessageBatchControl64" + ], + "license": "Apache-2.0" +} \ No newline at end of file diff --git a/sample-bots/java/TeamMessageBatchStress64/TeamMessageBatchStress64.java b/sample-bots/java/TeamMessageBatchStress64/TeamMessageBatchStress64.java new file mode 100644 index 000000000..93e87030b --- /dev/null +++ b/sample-bots/java/TeamMessageBatchStress64/TeamMessageBatchStress64.java @@ -0,0 +1,138 @@ +import dev.robocode.tankroyale.botapi.Bot; +import dev.robocode.tankroyale.botapi.TeamMessageBatch; +import dev.robocode.tankroyale.botapi.events.SkippedTurnEvent; +import dev.robocode.tankroyale.botapi.events.TeamMessageEvent; +import dev.robocode.tankroyale.botapi.graphics.Color; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** Five-bot batch stress workload used by the local CH-047 acceptance trial. */ +public class TeamMessageBatchStress64 extends Bot { + private static final int SEND_TURNS = 60; + private static final int FIRST_SEND_TURN = 11; + private static final int LAST_SEND_TURN = FIRST_SEND_TURN + SEND_TURNS - 1; + private static final int ITEMS_PER_BATCH = 64; + + private final Map lastTurnBySender = new HashMap<>(); + private int receivedItems; + private int protocolErrors; + private int skippedTurns; + private int typeErrors; + private int sizeErrors; + private int orderErrors; + private int contentErrors; + private int minTimeLeftMicros = Integer.MAX_VALUE; + private long totalTimeLeftMicros; + private int timeLeftSamples; + private int maxMessageWorkMicros; + private int maxTeamMessageHandlerMicros; + + public static void main(String[] args) { + new TeamMessageBatchStress64().start(); + } + + @Override + public void run() { + while (isRunning()) { + int turn = getTurnNumber(); + long messageWorkStartedAt = System.nanoTime(); + if (turn >= FIRST_SEND_TURN && turn <= LAST_SEND_TURN) { + List messages = new ArrayList<>(ITEMS_PER_BATCH); + for (int item = 0; item < ITEMS_PER_BATCH; item++) { + messages.add(getMyId() + ":" + turn + ":" + item); + } + broadcastTeamMessageBatch(messages); + int timeLeftMicros = Math.max(0, getTimeLeft()); + minTimeLeftMicros = Math.min(minTimeLeftMicros, timeLeftMicros); + totalTimeLeftMicros += timeLeftMicros; + timeLeftSamples++; + maxMessageWorkMicros = Math.max(maxMessageWorkMicros, + (int) ((System.nanoTime() - messageWorkStartedAt) / 1_000)); + } + if (turn >= LAST_SEND_TURN + 10 || turn % 5 == 0) { + setBodyColor(Color.fromRgb((receivedItems >>> 8) & 0xff, receivedItems & 0xff, + Math.min(protocolErrors, 0xff))); + setTracksColor(Color.fromRgb(0, Math.min(skippedTurns, 0xff), 0)); + setTurretColor(Color.fromRgb(Math.min(typeErrors, 0xff), Math.min(sizeErrors, 0xff), + Math.min(orderErrors, 0xff))); + int averageTimeLeftMicros = timeLeftSamples == 0 ? 0 + : (int) Math.min(0xffff, totalTimeLeftMicros / timeLeftSamples); + setRadarColor(Color.fromRgb(Math.min(contentErrors, 0xff), + (averageTimeLeftMicros >>> 8) & 0xff, averageTimeLeftMicros & 0xff)); + setScanColor(encodedColor(minTimeLeftMicros == Integer.MAX_VALUE ? 0 : minTimeLeftMicros)); + setGunColor(encodedColor(maxMessageWorkMicros)); + setBulletColor(encodedColor(maxTeamMessageHandlerMicros)); + } + go(); + } + } + + @Override + public void onTeamMessage(TeamMessageEvent event) { + long handlerStartedAt = System.nanoTime(); + try { + handleTeamMessage(event); + } finally { + maxTeamMessageHandlerMicros = Math.max(maxTeamMessageHandlerMicros, + (int) ((System.nanoTime() - handlerStartedAt) / 1_000)); + } + } + + private void handleTeamMessage(TeamMessageEvent event) { + if (!(event.getMessage() instanceof TeamMessageBatch)) { + protocolErrors++; + typeErrors++; + return; + } + List messages = ((TeamMessageBatch) event.getMessage()).getMessages(); + if (messages.size() != ITEMS_PER_BATCH) { + protocolErrors++; + sizeErrors++; + return; + } + int senderId = event.getSenderId(); + String[] first = String.valueOf(messages.get(0)).split(":"); + if (first.length != 3 || Integer.parseInt(first[0]) != senderId) { + protocolErrors++; + contentErrors++; + return; + } + int senderTurn = Integer.parseInt(first[1]); + int previousSenderTurn = lastTurnBySender.getOrDefault(senderId, FIRST_SEND_TURN - 1); + if (senderTurn <= previousSenderTurn) { + protocolErrors++; + orderErrors++; + return; + } + if (senderTurn != previousSenderTurn + 1) { + // Count a gap, but keep validating later deliveries so the report shows total + // traffic as well as missing turns instead of treating one gap as permanent loss. + protocolErrors++; + orderErrors++; + } + for (int item = 0; item < ITEMS_PER_BATCH; item++) { + if (!(senderId + ":" + senderTurn + ":" + item).equals(messages.get(item))) { + protocolErrors++; + contentErrors++; + return; + } + } + lastTurnBySender.put(senderId, senderTurn); + receivedItems += ITEMS_PER_BATCH; + } + + private static Color encodedColor(int value) { + int rgb = Math.min(Math.max(value, 0), 0xffffff); + return Color.fromRgb((rgb >>> 16) & 0xff, (rgb >>> 8) & 0xff, rgb & 0xff); + } + + @Override + public void onSkippedTurn(SkippedTurnEvent event) { + if (event.getTurnNumber() >= FIRST_SEND_TURN && event.getTurnNumber() <= LAST_SEND_TURN) { + skippedTurns++; + } + } +} diff --git a/sample-bots/java/TeamMessageBatchStress64/TeamMessageBatchStress64.json b/sample-bots/java/TeamMessageBatchStress64/TeamMessageBatchStress64.json new file mode 100644 index 000000000..e2eeb1c36 --- /dev/null +++ b/sample-bots/java/TeamMessageBatchStress64/TeamMessageBatchStress64.json @@ -0,0 +1,9 @@ +{ + "name": "TeamMessageBatchStress64", + "version": "1.0", + "authors": ["Robocode Tank Royale"], + "description": "Five-bot trial workload for ordered 64-entry team-message batches.", + "platform": "JVM", + "programmingLang": "Java 11", + "license": "Apache-2.0" +} \ No newline at end of file diff --git a/sample-bots/java/TeamMessageBatchStress64Team/TeamMessageBatchStress64Team.json b/sample-bots/java/TeamMessageBatchStress64Team/TeamMessageBatchStress64Team.json new file mode 100644 index 000000000..893ff7727 --- /dev/null +++ b/sample-bots/java/TeamMessageBatchStress64Team/TeamMessageBatchStress64Team.json @@ -0,0 +1,14 @@ +{ + "name": "TeamMessageBatchStress64Team", + "version": "1.0", + "authors": ["Robocode Tank Royale"], + "description": "Five copies of the 64-entry batch stress bot for the local 30 TPS trial.", + "teamMembers": [ + "TeamMessageBatchStress64", + "TeamMessageBatchStress64", + "TeamMessageBatchStress64", + "TeamMessageBatchStress64", + "TeamMessageBatchStress64" + ], + "license": "Apache-2.0" +} \ No newline at end of file From a00956ad68e74035a78fa88ec805d3434848e773 Mon Sep 17 00:00:00 2001 From: "Flemming N. Larsen" Date: Sun, 27 Sep 2026 00:29:30 +0200 Subject: [PATCH 12/13] test: record CPU-capped 64-item trial --- .../tankroyale/booter/process/ProcessLauncherTest.kt | 2 ++ changes/CH-047-team-message-limits/open-questions.md | 10 ++++++++++ .../test/kotlin/connection/TeamMessageRawClientTest.kt | 2 ++ 3 files changed, 14 insertions(+) diff --git a/booter/src/test/kotlin/dev/robocode/tankroyale/booter/process/ProcessLauncherTest.kt b/booter/src/test/kotlin/dev/robocode/tankroyale/booter/process/ProcessLauncherTest.kt index fb8414378..081cc87ad 100644 --- a/booter/src/test/kotlin/dev/robocode/tankroyale/booter/process/ProcessLauncherTest.kt +++ b/booter/src/test/kotlin/dev/robocode/tankroyale/booter/process/ProcessLauncherTest.kt @@ -1,8 +1,10 @@ package dev.robocode.tankroyale.booter.process import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test +@Tag("Unit") class ProcessLauncherTest { @Test diff --git a/changes/CH-047-team-message-limits/open-questions.md b/changes/CH-047-team-message-limits/open-questions.md index 82e3d01e6..ad7fe9c04 100644 --- a/changes/CH-047-team-message-limits/open-questions.md +++ b/changes/CH-047-team-message-limits/open-questions.md @@ -52,6 +52,16 @@ Across the ten stress runs, measured throughput ranged from 99.1 to 118.6 turns/ All ten 64-entry runs passed on the user's fast local PC, but their minimum budget fell as low as 8.581 ms. This does not establish reliability on average or lower-spec hardware, and it does not resolve the 128-entry failure. The 64-entry test fixture is measurement evidence only; do not change the candidate API limit or publish the policy based on this host's results. Keep PR 276 in draft until the chosen trial passes on representative slower hardware or an agreed calibrated CPU limit. +## CPU-capped 64-entry trial — 2026-09-27 + +The first paired run used a Windows Job Object hard cap of 25% of total host CPU capacity across the full Gradle, runner, and bot process tree on the AMD Ryzen 7 9800X3D (8 physical and 16 logical processors). Windows accounting measured 19.2% average use of total system CPU over the 36.2-second pair. This is a constrained run on the user's fast PC, not a run on lower-spec hardware; the cap also does not establish a general minimum hardware profile. The repeat loop stopped after this first failing pair. + +Under stress, each bot received 14,336 of 15,360 expected logical items, a deficit of 1,024 items or 16 batches of 64 per bot. Each bot recorded 16 ordering gaps and 4 skipped turns; the bot telemetry showed no message-type, batch-size, or content errors. Every bot's minimum `getTimeLeft()` was 0 µs; average remaining time ranged from 26,124 to 27,148 µs. Maximum batch construction and API submission time ranged from 4,744 to 17,162 µs, and maximum receive-handler callback duration ranged from 902 to 1,703 µs. + +The matched no-message control passed under the same cap with no skipped turns or unexpected messages. It measured 105.40 turns/s, a 25,547 µs minimum and 29,560 µs average remaining budget, and 3,831 µs maximum payload-construction time. The comparison points to the messaging workload as load-sensitive, but these measurements do not isolate serialization, server delivery, callback scheduling, or host scheduling as the bottleneck. + +The zero-loss, zero-skip gate failed for the 64-entry batch under this CPU-capped run. Keep PR 276 in draft and do not publish or change the candidate policy from this result alone. For a human-selected next trial, consider a limit of 32 logical payloads per bot per turn while initially retaining the 49,152-byte packet and 262,144-byte per-turn array caps; those byte limits were not approached by this count-heavy workload. This is only a trial recommendation and needs its own stress gate before becoming policy. + ## Blocking decision Resolved by the user on 2026-09-23: trial standard ordered batching in one existing packet and event, with no compression at first. Retain the trial packet and byte caps, require batch version 1 from all recipients, and compare the five-bot 30 TPS result against the same zero-loss, zero-skipped-turn gate. The trial does not become a published policy unless that gate passes. diff --git a/server/src/test/kotlin/connection/TeamMessageRawClientTest.kt b/server/src/test/kotlin/connection/TeamMessageRawClientTest.kt index dc21c5b21..c7cc5e00e 100644 --- a/server/src/test/kotlin/connection/TeamMessageRawClientTest.kt +++ b/server/src/test/kotlin/connection/TeamMessageRawClientTest.kt @@ -14,6 +14,8 @@ import org.java_websocket.WebSocket import org.java_websocket.handshake.ClientHandshake class TeamMessageRawClientTest : FunSpec({ + tags(Tag("Unit")) + val listener = mockk(relaxed = true) val handler = ClientWebSocketsHandler( setup = ServerSetup(), From b3cbb1861339ba712cf32628cd71596b13057da2 Mon Sep 17 00:00:00 2001 From: "Flemming N. Larsen" Date: Sun, 27 Sep 2026 01:22:34 +0200 Subject: [PATCH 13/13] test: record skipped turns in 32-item messaging trial --- .../open-questions.md | 8 ++ .../runner/BattleRunnerIntegrationTest.kt | 120 +++++++++++++--- .../TeamMessageBatchControl32.java | 75 ++++++++++ .../TeamMessageBatchControl32.json | 9 ++ .../TeamMessageBatchControl32Team.json | 14 ++ .../TeamMessageBatchStress32.java | 128 ++++++++++++++++++ .../TeamMessageBatchStress32.json | 9 ++ .../TeamMessageBatchStress32Team.json | 14 ++ 8 files changed, 356 insertions(+), 21 deletions(-) create mode 100644 sample-bots/java/TeamMessageBatchControl32/TeamMessageBatchControl32.java create mode 100644 sample-bots/java/TeamMessageBatchControl32/TeamMessageBatchControl32.json create mode 100644 sample-bots/java/TeamMessageBatchControl32Team/TeamMessageBatchControl32Team.json create mode 100644 sample-bots/java/TeamMessageBatchStress32/TeamMessageBatchStress32.java create mode 100644 sample-bots/java/TeamMessageBatchStress32/TeamMessageBatchStress32.json create mode 100644 sample-bots/java/TeamMessageBatchStress32Team/TeamMessageBatchStress32Team.json diff --git a/changes/CH-047-team-message-limits/open-questions.md b/changes/CH-047-team-message-limits/open-questions.md index ad7fe9c04..54aac6971 100644 --- a/changes/CH-047-team-message-limits/open-questions.md +++ b/changes/CH-047-team-message-limits/open-questions.md @@ -62,6 +62,14 @@ The matched no-message control passed under the same cap with no skipped turns o The zero-loss, zero-skip gate failed for the 64-entry batch under this CPU-capped run. Keep PR 276 in draft and do not publish or change the candidate policy from this result alone. For a human-selected next trial, consider a limit of 32 logical payloads per bot per turn while initially retaining the 49,152-byte packet and 262,144-byte per-turn array caps; those byte limits were not approached by this count-heavy workload. This is only a trial recommendation and needs its own stress gate before becoming policy. +## CPU-capped 32-entry trial — 2026-09-27 + +The 32-entry stress fixture now records the exact skipped-turn numbers in the measured window, turns 11–70; turns 1–10 remain warm-up and are not counted. Two five-bot stress runs failed the delivery gate. In the first, each bot received 7,296 of 7,680 expected items and skipped turns 15, 32, and 61. In the second, bots 1, 2, and 7 skipped turn 18; bots 4 and 5 had no skipped turns. Recipients received 7,584 or 7,616 items, with the deficits matching the batches sent on the skipped turn. The second run measured 39.61 turns/s, a 16,786 µs minimum and 27,879 µs average remaining budget. + +The matched 32-entry no-message control also failed once: all five bots skipped turn 32. It received no messages, measured 96.31 turns/s, and reported a 24,512 µs minimum and 29,458 µs average remaining budget. Since the control also skipped a turn, not every skip can be attributed to team-message processing. The stress run's turn-18 losses still show that the 32-entry messaging workload can miss intents under this cap. + +All three runs used a 25% Windows Job Object CPU hard cap across the Gradle, runner, and bot process tree on the Ryzen 7 9800X3D with 16 logical processors. The initial invocation measured 366.0 job CPU seconds over 100.1 seconds, about 22.9% of total system capacity; later invocations used the same cap, but the local summary script did not emit their CPU averages. Because both stress and control runs recorded skips, this does not establish 32 as reliable or identify a single messaging bottleneck. Keep the policy unchanged and PR 276 in draft; do not lower the candidate limit or publish it from these measurements. A further human decision is needed on whether to measure on representative hardware or isolate runner scheduling under the same CPU cap. + ## Blocking decision Resolved by the user on 2026-09-23: trial standard ordered batching in one existing packet and event, with no compression at first. Retain the trial packet and byte caps, require batch version 1 from all recipients, and compare the five-bot 30 TPS result against the same zero-loss, zero-skipped-turn gate. The trial does not become a published policy unless that gate passes. diff --git a/runner/src/test/kotlin/dev/robocode/tankroyale/runner/BattleRunnerIntegrationTest.kt b/runner/src/test/kotlin/dev/robocode/tankroyale/runner/BattleRunnerIntegrationTest.kt index 8e0ce9717..273f7351c 100644 --- a/runner/src/test/kotlin/dev/robocode/tankroyale/runner/BattleRunnerIntegrationTest.kt +++ b/runner/src/test/kotlin/dev/robocode/tankroyale/runner/BattleRunnerIntegrationTest.kt @@ -506,12 +506,49 @@ class BattleRunnerIntegrationTest { ) } + @Tag("integration") + @Tag("slow") + @Tag("PRO-006") + @Tag("Integration") + @Tag("Positive") + @Test + @Timeout(120) + fun testPRO006_IntegrationPositive_fiveBotsDeliverOrdered32ItemBatchesWithoutSkippedTurns() { + runFiveBotTeamMessageTrial( + teamName = "TeamMessageBatchStress32Team", + botName = "TeamMessageBatchStress32", + expectedReceivedItemsPerBot = 4 * 60 * 32, + outboundTeamMessageBytes = estimateBatchArrayBytes(32), + itemsPerBatch = 32, + recordExactSkippedTurnNumbers = true + ) + } + + @Tag("integration") + @Tag("slow") + @Tag("PRO-006") + @Tag("Integration") + @Tag("Positive") + @Test + @Timeout(120) + fun testPRO006_IntegrationPositive_fiveBot32ItemNoMessageControlSustains30Tps() { + runFiveBotTeamMessageTrial( + teamName = "TeamMessageBatchControl32Team", + botName = "TeamMessageBatchControl32", + expectedReceivedItemsPerBot = 0, + outboundTeamMessageBytes = 0, + itemsPerBatch = 32, + recordExactSkippedTurnNumbers = true + ) + } + private fun runFiveBotTeamMessageTrial( teamName: String, botName: String, expectedReceivedItemsPerBot: Int, outboundTeamMessageBytes: Long, - itemsPerBatch: Int + itemsPerBatch: Int, + recordExactSkippedTurnNumbers: Boolean = false ) { val expectedTurn = 80 val measuredFromTurn = 10 @@ -568,6 +605,12 @@ class BattleRunnerIntegrationTest { val averageTimeLeftMicros = mutableListOf() val maxMessageWorkMicros = mutableListOf() val maxTeamMessageHandlerMicros = mutableListOf() + val exactSkippedTurnNumbersByBot = mutableMapOf>() + val skipMaskMatchesEventCountByBot = mutableMapOf() + val receivedItemsByBot = mutableMapOf() + val skippedTurnCountByBot = mutableMapOf() + val protocolErrorsByBot = mutableMapOf() + val contentErrorsByBot = mutableMapOf() states.forEach { state -> val hex = state.bodyColor!!.removePrefix("#") val received = (hex.substring(0, 2).toInt(16) shl 8) or hex.substring(2, 4).toInt(16) @@ -577,24 +620,26 @@ class BattleRunnerIntegrationTest { val contentErrors = (radar ushr 16) and 0xff val averageLeftMicros = radar and 0xffff val minLeftMicros = decodeRgb(state.scanColor) - val maxWorkMicros = decodeRgb(state.gunColor) - val maxHandlerMicros = decodeRgb(state.bulletColor) - assertThat(received) - .describedAs("ordered logical messages received by $botName ${state.id}; errors=$protocolErrors skipped=$skippedTurns") - .isEqualTo(expectedReceivedItemsPerBot) - assertThat(skippedTurns) - .describedAs("skipped turns recorded by $botName ${state.id}") - .isZero() - assertThat(protocolErrors) - .describedAs("message and protocol errors recorded by $botName ${state.id}") - .isZero() - assertThat(contentErrors) - .describedAs("message content errors recorded by $botName ${state.id}") - .isZero() + receivedItemsByBot[state.id] = received + skippedTurnCountByBot[state.id] = skippedTurns + protocolErrorsByBot[state.id] = protocolErrors + contentErrorsByBot[state.id] = contentErrors + if (recordExactSkippedTurnNumbers) { + // Color fields preserve all 60 measured-turn bits without adding wire traffic. + val skipMask = decodeRgb(state.turretColor).toLong() or + (decodeRgb(state.gunColor).toLong() shl 24) or + ((decodeRgb(state.bulletColor).toLong() and 0xfff) shl 48) + val skippedTurnNumbers = (0 until 60) + .filter { (skipMask and (1L shl it)) != 0L } + .map { it + 11 } + exactSkippedTurnNumbersByBot[state.id] = skippedTurnNumbers + skipMaskMatchesEventCountByBot[state.id] = skippedTurnNumbers.size == skippedTurns + } else { + maxMessageWorkMicros.add(decodeRgb(state.gunColor)) + maxTeamMessageHandlerMicros.add(decodeRgb(state.bulletColor)) + } minTimeLeftMicros.add(minLeftMicros) averageTimeLeftMicros.add(averageLeftMicros) - maxMessageWorkMicros.add(maxWorkMicros) - maxTeamMessageHandlerMicros.add(maxHandlerMicros) } val elapsedSeconds = (finishedAtNanos.get()!! - startedAtNanos.get()!!) / 1_000_000_000.0 @@ -603,16 +648,49 @@ class BattleRunnerIntegrationTest { "TEAM_MESSAGE_TRIAL workload=$botName bots=5 turns=60 itemsPerBatch=$itemsPerBatch tps=$measuredTps " + "outboundTeamMessagesBytes=$outboundTeamMessageBytes " + "estimatedTeamPayloadFanoutBytes=${outboundTeamMessageBytes * 4} " + - "receivedPerBot=$expectedReceivedItemsPerBot skipped=0 " + + "receivedPerBot=$expectedReceivedItemsPerBot " + + "exactSkippedTurnNumbersByBot=$exactSkippedTurnNumbersByBot " + + "receivedItemsByBot=$receivedItemsByBot skippedTurnCountByBot=$skippedTurnCountByBot " + + "protocolErrorsByBot=$protocolErrorsByBot contentErrorsByBot=$contentErrorsByBot " + "minTimeLeftMicros=${minTimeLeftMicros.minOrNull()} " + "averageTimeLeftMicros=${averageTimeLeftMicros.average().toLong()} " + - "maxMessageWorkMicros=${maxMessageWorkMicros.maxOrNull()} " + - "maxTeamMessageHandlerMicros=${maxTeamMessageHandlerMicros.maxOrNull()}" + "maxMessageWorkMicros=${maxMessageWorkMicros.maxOrNull() ?: "not-recorded"} " + + "maxTeamMessageHandlerMicros=${maxTeamMessageHandlerMicros.maxOrNull() ?: "not-recorded"}" ) + receivedItemsByBot.forEach { (botId, received) -> + assertThat(received) + .describedAs("ordered logical messages received by $botName $botId; errors=${protocolErrorsByBot[botId]} skipped=${skippedTurnCountByBot[botId]}") + .isEqualTo(expectedReceivedItemsPerBot) + } + skippedTurnCountByBot.forEach { (botId, skippedTurns) -> + assertThat(skippedTurns) + .describedAs("skipped turns recorded by $botName $botId") + .isZero() + } + protocolErrorsByBot.forEach { (botId, protocolErrors) -> + assertThat(protocolErrors) + .describedAs("message and protocol errors recorded by $botName $botId") + .isZero() + } + contentErrorsByBot.forEach { (botId, contentErrors) -> + assertThat(contentErrors) + .describedAs("message content errors recorded by $botName $botId") + .isZero() + } + skipMaskMatchesEventCountByBot.forEach { (botId, matches) -> + assertThat(matches) + .describedAs("skip-turn bitmap matches the skipped-turn event count for $botName $botId") + .isTrue() + } + exactSkippedTurnNumbersByBot.forEach { (botId, skippedTurns) -> + assertThat(skippedTurns) + .describedAs("exact skipped-turn numbers reported by $botName $botId") + .isEmpty() + } assertThat(measuredTps) .describedAs("measured turn rate under the $botName workload") .isGreaterThanOrEqualTo(30.0) - if (expectedReceivedItemsPerBot == 0) { + if (expectedReceivedItemsPerBot == 0 && !recordExactSkippedTurnNumbers) { assertThat(maxTeamMessageHandlerMicros).containsOnly(0) } } diff --git a/sample-bots/java/TeamMessageBatchControl32/TeamMessageBatchControl32.java b/sample-bots/java/TeamMessageBatchControl32/TeamMessageBatchControl32.java new file mode 100644 index 000000000..ea4ed470a --- /dev/null +++ b/sample-bots/java/TeamMessageBatchControl32/TeamMessageBatchControl32.java @@ -0,0 +1,75 @@ +import dev.robocode.tankroyale.botapi.Bot; +import dev.robocode.tankroyale.botapi.events.SkippedTurnEvent; +import dev.robocode.tankroyale.botapi.events.TeamMessageEvent; +import dev.robocode.tankroyale.botapi.graphics.Color; + +import java.util.ArrayList; +import java.util.List; + +/** Matched no-message control for the 32-entry local team-message trial. */ +public class TeamMessageBatchControl32 extends Bot { + private static final int FIRST_MEASURED_TURN = 11; + private static final int LAST_MEASURED_TURN = 70; + private static final int ITEMS_PER_BATCH = 32; + + private int skippedTurns; + private int unexpectedMessages; + private int minTimeLeftMicros = Integer.MAX_VALUE; + private long totalTimeLeftMicros; + private int timeLeftSamples; + private long skippedTurnMask; + + public static void main(String[] args) { + new TeamMessageBatchControl32().start(); + } + + @Override + public void run() { + while (isRunning()) { + int turn = getTurnNumber(); + if (turn >= FIRST_MEASURED_TURN && turn <= LAST_MEASURED_TURN) { + List messages = new ArrayList<>(ITEMS_PER_BATCH); + for (int item = 0; item < ITEMS_PER_BATCH; item++) { + messages.add(getMyId() + ":" + turn + ":" + item); + } + int timeLeftMicros = Math.max(0, getTimeLeft()); + minTimeLeftMicros = Math.min(minTimeLeftMicros, timeLeftMicros); + totalTimeLeftMicros += timeLeftMicros; + timeLeftSamples++; + } + if (turn >= LAST_MEASURED_TURN + 10 || turn % 5 == 0) { + int averageTimeLeftMicros = timeLeftSamples == 0 ? 0 + : (int) Math.min(0xffff, totalTimeLeftMicros / timeLeftSamples); + setBodyColor(Color.fromRgb(0, 0, Math.min(unexpectedMessages, 0xff))); + setTracksColor(Color.fromRgb(0, Math.min(skippedTurns, 0xff), 0)); + // Keep the control's turn bitmap encoding identical to the stress bot's. + setTurretColor(encodedColor((int) (skippedTurnMask & 0xffffff))); + setRadarColor(Color.fromRgb(0, (averageTimeLeftMicros >>> 8) & 0xff, + averageTimeLeftMicros & 0xff)); + setScanColor(encodedColor(minTimeLeftMicros == Integer.MAX_VALUE ? 0 : minTimeLeftMicros)); + setGunColor(encodedColor((int) ((skippedTurnMask >>> 24) & 0xffffff))); + setBulletColor(encodedColor((int) ((skippedTurnMask >>> 48) & 0xfff))); + } + go(); + } + } + + @Override + public void onSkippedTurn(SkippedTurnEvent event) { + int turn = event.getTurnNumber(); + if (turn >= FIRST_MEASURED_TURN && turn <= LAST_MEASURED_TURN) { + skippedTurns++; + skippedTurnMask |= 1L << (turn - FIRST_MEASURED_TURN); + } + } + + @Override + public void onTeamMessage(TeamMessageEvent event) { + unexpectedMessages++; + } + + private static Color encodedColor(int value) { + int rgb = Math.min(Math.max(value, 0), 0xffffff); + return Color.fromRgb((rgb >>> 16) & 0xff, (rgb >>> 8) & 0xff, rgb & 0xff); + } +} diff --git a/sample-bots/java/TeamMessageBatchControl32/TeamMessageBatchControl32.json b/sample-bots/java/TeamMessageBatchControl32/TeamMessageBatchControl32.json new file mode 100644 index 000000000..df32f1ddb --- /dev/null +++ b/sample-bots/java/TeamMessageBatchControl32/TeamMessageBatchControl32.json @@ -0,0 +1,9 @@ +{ + "name": "TeamMessageBatchControl32", + "version": "1.0", + "authors": ["Robocode Tank Royale"], + "description": "Matched no-message control for the 32-entry local team-message trial.", + "platform": "JVM", + "programmingLang": "Java 11", + "license": "Apache-2.0" +} diff --git a/sample-bots/java/TeamMessageBatchControl32Team/TeamMessageBatchControl32Team.json b/sample-bots/java/TeamMessageBatchControl32Team/TeamMessageBatchControl32Team.json new file mode 100644 index 000000000..d949198bf --- /dev/null +++ b/sample-bots/java/TeamMessageBatchControl32Team/TeamMessageBatchControl32Team.json @@ -0,0 +1,14 @@ +{ + "name": "TeamMessageBatchControl32Team", + "version": "1.0", + "authors": ["Robocode Tank Royale"], + "description": "Five copies of the matched no-message control for the 32-entry batch trial.", + "teamMembers": [ + "TeamMessageBatchControl32", + "TeamMessageBatchControl32", + "TeamMessageBatchControl32", + "TeamMessageBatchControl32", + "TeamMessageBatchControl32" + ], + "license": "Apache-2.0" +} diff --git a/sample-bots/java/TeamMessageBatchStress32/TeamMessageBatchStress32.java b/sample-bots/java/TeamMessageBatchStress32/TeamMessageBatchStress32.java new file mode 100644 index 000000000..ce22787c9 --- /dev/null +++ b/sample-bots/java/TeamMessageBatchStress32/TeamMessageBatchStress32.java @@ -0,0 +1,128 @@ +import dev.robocode.tankroyale.botapi.Bot; +import dev.robocode.tankroyale.botapi.TeamMessageBatch; +import dev.robocode.tankroyale.botapi.events.SkippedTurnEvent; +import dev.robocode.tankroyale.botapi.events.TeamMessageEvent; +import dev.robocode.tankroyale.botapi.graphics.Color; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** Five-bot 32-entry trial workload with per-turn skip telemetry. */ +public class TeamMessageBatchStress32 extends Bot { + private static final int SEND_TURNS = 60; + private static final int FIRST_SEND_TURN = 11; + private static final int LAST_SEND_TURN = FIRST_SEND_TURN + SEND_TURNS - 1; + private static final int ITEMS_PER_BATCH = 32; + + private final Map lastTurnBySender = new HashMap<>(); + private int receivedItems; + private int protocolErrors; + private int skippedTurns; + private int typeErrors; + private int sizeErrors; + private int orderErrors; + private int contentErrors; + private int minTimeLeftMicros = Integer.MAX_VALUE; + private long totalTimeLeftMicros; + private int timeLeftSamples; + private long skippedTurnMask; + + public static void main(String[] args) { + new TeamMessageBatchStress32().start(); + } + + @Override + public void run() { + while (isRunning()) { + int turn = getTurnNumber(); + if (turn >= FIRST_SEND_TURN && turn <= LAST_SEND_TURN) { + List messages = new ArrayList<>(ITEMS_PER_BATCH); + for (int item = 0; item < ITEMS_PER_BATCH; item++) { + messages.add(getMyId() + ":" + turn + ":" + item); + } + broadcastTeamMessageBatch(messages); + int timeLeftMicros = Math.max(0, getTimeLeft()); + minTimeLeftMicros = Math.min(minTimeLeftMicros, timeLeftMicros); + totalTimeLeftMicros += timeLeftMicros; + timeLeftSamples++; + } + if (turn >= LAST_SEND_TURN + 10 || turn % 5 == 0) { + setBodyColor(Color.fromRgb((receivedItems >>> 8) & 0xff, receivedItems & 0xff, + Math.min(protocolErrors, 0xff))); + setTracksColor(Color.fromRgb(0, Math.min(skippedTurns, 0xff), 0)); + // The integration test decodes the 60 measured turns from these three colors. + setTurretColor(encodedColor((int) (skippedTurnMask & 0xffffff))); + int averageTimeLeftMicros = timeLeftSamples == 0 ? 0 + : (int) Math.min(0xffff, totalTimeLeftMicros / timeLeftSamples); + setRadarColor(Color.fromRgb(Math.min(contentErrors, 0xff), + (averageTimeLeftMicros >>> 8) & 0xff, averageTimeLeftMicros & 0xff)); + setScanColor(encodedColor(minTimeLeftMicros == Integer.MAX_VALUE ? 0 : minTimeLeftMicros)); + setGunColor(encodedColor((int) ((skippedTurnMask >>> 24) & 0xffffff))); + setBulletColor(encodedColor((int) ((skippedTurnMask >>> 48) & 0xfff))); + } + go(); + } + } + + @Override + public void onTeamMessage(TeamMessageEvent event) { + handleTeamMessage(event); + } + + private void handleTeamMessage(TeamMessageEvent event) { + if (!(event.getMessage() instanceof TeamMessageBatch)) { + protocolErrors++; + typeErrors++; + return; + } + List messages = ((TeamMessageBatch) event.getMessage()).getMessages(); + if (messages.size() != ITEMS_PER_BATCH) { + protocolErrors++; + sizeErrors++; + return; + } + int senderId = event.getSenderId(); + String[] first = String.valueOf(messages.get(0)).split(":"); + if (first.length != 3 || Integer.parseInt(first[0]) != senderId) { + protocolErrors++; + contentErrors++; + return; + } + int senderTurn = Integer.parseInt(first[1]); + int previousSenderTurn = lastTurnBySender.getOrDefault(senderId, FIRST_SEND_TURN - 1); + if (senderTurn <= previousSenderTurn) { + protocolErrors++; + orderErrors++; + return; + } + if (senderTurn != previousSenderTurn + 1) { + protocolErrors++; + orderErrors++; + } + for (int item = 0; item < ITEMS_PER_BATCH; item++) { + if (!(senderId + ":" + senderTurn + ":" + item).equals(messages.get(item))) { + protocolErrors++; + contentErrors++; + return; + } + } + lastTurnBySender.put(senderId, senderTurn); + receivedItems += ITEMS_PER_BATCH; + } + + private static Color encodedColor(int value) { + int rgb = Math.min(Math.max(value, 0), 0xffffff); + return Color.fromRgb((rgb >>> 16) & 0xff, (rgb >>> 8) & 0xff, rgb & 0xff); + } + + @Override + public void onSkippedTurn(SkippedTurnEvent event) { + int turn = event.getTurnNumber(); + if (turn >= FIRST_SEND_TURN && turn <= LAST_SEND_TURN) { + skippedTurns++; + skippedTurnMask |= 1L << (turn - FIRST_SEND_TURN); + } + } +} diff --git a/sample-bots/java/TeamMessageBatchStress32/TeamMessageBatchStress32.json b/sample-bots/java/TeamMessageBatchStress32/TeamMessageBatchStress32.json new file mode 100644 index 000000000..c6887386b --- /dev/null +++ b/sample-bots/java/TeamMessageBatchStress32/TeamMessageBatchStress32.json @@ -0,0 +1,9 @@ +{ + "name": "TeamMessageBatchStress32", + "version": "1.0", + "authors": ["Robocode Tank Royale"], + "description": "Five-bot trial workload for ordered 32-entry team-message batches.", + "platform": "JVM", + "programmingLang": "Java 11", + "license": "Apache-2.0" +} diff --git a/sample-bots/java/TeamMessageBatchStress32Team/TeamMessageBatchStress32Team.json b/sample-bots/java/TeamMessageBatchStress32Team/TeamMessageBatchStress32Team.json new file mode 100644 index 000000000..8217da214 --- /dev/null +++ b/sample-bots/java/TeamMessageBatchStress32Team/TeamMessageBatchStress32Team.json @@ -0,0 +1,14 @@ +{ + "name": "TeamMessageBatchStress32Team", + "version": "1.0", + "authors": ["Robocode Tank Royale"], + "description": "Five copies of the 32-entry batch stress bot for the local 30 TPS trial.", + "teamMembers": [ + "TeamMessageBatchStress32", + "TeamMessageBatchStress32", + "TeamMessageBatchStress32", + "TeamMessageBatchStress32", + "TeamMessageBatchStress32" + ], + "license": "Apache-2.0" +}