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/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/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/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 8ac873ec0..bec44b284 100644
--- a/bot-api/dotnet/api/src/Constants.cs
+++ b/bot-api/dotnet/api/src/Constants.cs
@@ -149,15 +149,21 @@ 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)
- /// 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 bytes, which is 32768.
- public const int TeamMessageMaxSize = 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-message packets that can be sent per turn, which is 64.
///
- /// The maximum number of team messages per turn, which is 10.
- public const int MaxNumberOfTeamMessagesPerTurn = 10;
-}
\ No newline at end of file
+ /// 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 1c67409ea..0f6a76cfd 100644
--- a/bot-api/dotnet/api/src/IBaseBot.cs
+++ b/bot-api/dotnet/api/src/IBaseBot.cs
@@ -13,15 +13,21 @@ 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 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;
+
///
/// The method used to start running the bot. You should call this method from the main
/// method or similar.
@@ -732,15 +738,26 @@ 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 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 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);
+ ///
+ /// 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.
///
@@ -750,16 +767,28 @@ 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 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.
- /// 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);
+ ///
+ /// 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.
///
@@ -1179,4 +1208,4 @@ public interface IBaseBot
///
///
void SetEventPriority(Type eventType, int priority);
-}
\ No newline at end of file
+}
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 5f899c201..82e79f5e8 100644
--- a/bot-api/dotnet/api/src/internal/BaseBotInternals.cs
+++ b/bot-api/dotnet/api/src/internal/BaseBotInternals.cs
@@ -808,15 +808,22 @@ 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);
- BotIntent.TeamMessages.Add(new S.TeamMessage
+ 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);
}
internal Color? BodyColor
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 063eb6ef6..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,7 +296,15 @@ internal void AddEvent(BotEvent botEvent)
{
lock (_events)
{
- if (_events.Count < MaxQueueSize)
+ // 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 8af9369e2..9b7b8b7e7 100644
--- a/bot-api/dotnet/api/src/internal/IntentValidator.cs
+++ b/bot-api/dotnet/api/src/internal/IntentValidator.cs
@@ -117,15 +117,22 @@ public static void ValidateTeammateId(int? teammateId, ICollection teammate
public static void ValidateTeamMessage(object message, int currentTeamMessageCount)
{
- if (currentTeamMessageCount == IBaseBot.MaxNumberOfTeamMessagesPerTurn)
- throw new InvalidOperationException(
- "The maximum number team massages has already been reached: " +
+ if (currentTeamMessageCount >= IBaseBot.MaxNumberOfTeamMessagesPerTurn)
+ throw new BotException(
+ "The maximum number of team messages has already been reached: " +
IBaseBot.MaxNumberOfTeamMessagesPerTurn);
if (message == null)
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);
@@ -134,5 +141,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/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 ff9befa3e..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;
@@ -33,7 +34,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;
@@ -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/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/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/TeamMessageLimitsTest.cs b/bot-api/dotnet/test/src/TeamMessageLimitsTest.cs
new file mode 100644
index 000000000..3f531e224
--- /dev/null
+++ b/bot-api/dotnet/test/src/TeamMessageLimitsTest.cs
@@ -0,0 +1,32 @@
+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", 10));
+ 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/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 161493b47..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,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)
- * 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 = 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-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 = 10;
+ 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/main/java/dev/robocode/tankroyale/botapi/IBaseBot.java b/bot-api/java/src/main/java/dev/robocode/tankroyale/botapi/IBaseBot.java
index c89c307b2..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,11 +851,16 @@ 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} 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 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,17 +876,47 @@ 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} 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.
- * @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
*/
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 cc1bdda36..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,17 +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 2b5fac5c4..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
@@ -230,7 +230,15 @@ 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)) {
+ // 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);
} else {
System.err.println("Maximum event queue size has been reached: " + MAX_QUEUE_SIZE);
@@ -238,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 0d04d71e9..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
@@ -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,22 +112,36 @@ 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");
}
}
+ 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().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/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 f1a0b289e..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,12 +3,14 @@
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;
import java.util.Collection;
-import java.util.HashSet;
-import java.util.Set;
+import java.util.List;
/**
* Utility class for mapping events.
@@ -28,8 +30,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;
}
@@ -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 209b366ab..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
@@ -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.*;
@@ -83,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) {}
@@ -239,6 +242,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/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/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..7d92aaff0
--- /dev/null
+++ b/bot-api/java/src/test/java/dev/robocode/tankroyale/botapi/internal/TeamMessageLimitsTest.java
@@ -0,0 +1,32 @@
+package dev.robocode.tankroyale.botapi.internal;
+
+import dev.robocode.tankroyale.botapi.BotException;
+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 accepts64MessagesAndRejects65th() {
+ assertDoesNotThrow(() -> IntentValidator.validateTeamMessage("hello", 10));
+ 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")
+ 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/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/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 fec45695b..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
@@ -698,7 +698,11 @@ 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 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.
@@ -706,8 +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 message to a specific teammate.
+ """Sends a compact JSON message to a teammate for delivery on the next turn.
+
+ 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.
@@ -716,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 93f925eb8..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
@@ -17,10 +17,16 @@ 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-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)."""
@abstractmethod
def start(self) -> None:
@@ -891,17 +897,18 @@ 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` 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.
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 +925,37 @@ 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` 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.
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
+
+ @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
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..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
@@ -92,12 +92,13 @@
to zero, cooling at the rate defined by the game setup's gun cooling rate.
"""
-MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN = 10
-"""
-Maximum number of team messages that can be sent per turn.
-"""
+MAX_NUMBER_OF_TEAM_MESSAGES_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 = 32768
-"""
-Maximum size of a team message in bytes (32 KB).
-"""
+TEAM_MESSAGE_MAX_SIZE = 48 * 1024
+"""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 aa36c2e16..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
@@ -867,7 +867,10 @@ 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 ..team_message_batch import TeamMessageBatch
+ from .json_util import MessageEncoder
IntentValidator.validate_teammate_id(teammate_id, self.teammate_ids)
@@ -879,14 +882,31 @@ 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)
# Color and Graphics - Delegated
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 86f415d5c..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
@@ -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,17 @@ 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:
+ # 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:
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..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,7 +11,9 @@
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,
)
@@ -128,11 +130,18 @@ 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")
+ @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:
@@ -140,6 +149,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/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/python/tests/test_team_message_limits.py b/bot-api/python/tests/test_team_message_limits.py
new file mode 100644
index 000000000..56d00773a
--- /dev/null
+++ b/bot-api/python/tests/test_team_message_limits.py
@@ -0,0 +1,30 @@
+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.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(BotException):
+ 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/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/bots/java/TeamMessageLoadBot/TeamMessageLoadBot.java b/bot-api/tests/bots/java/TeamMessageLoadBot/TeamMessageLoadBot.java
new file mode 100644
index 000000000..e82813ed2
--- /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 64 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 < 64; 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 == 64) { 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..5d39b51b5 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,23 @@
"type": "positive",
"method": "getConstant",
"args": ["MAX_NUMBER_OF_TEAM_MESSAGES_PER_TURN"],
- "expected": { "returns": 10 }
+ "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",
+ "type": "positive",
+ "method": "getConstant",
+ "args": ["TEAM_MESSAGES_MAX_BYTES_PER_TURN"],
+ "expected": { "returns": 262144 }
}
]
}
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 9e3d18df7..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,8 @@ 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 */
readonly _internals: BaseBotInternals;
@@ -177,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 2a23d3a66..71c68fe28 100644
--- a/bot-api/typescript/src/Constants.ts
+++ b/bot-api/typescript/src/Constants.ts
@@ -122,13 +122,18 @@ 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)
- * 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: 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,
+ * 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,
} as const;
diff --git a/bot-api/typescript/src/IBaseBot.ts b/bot-api/typescript/src/IBaseBot.ts
index 5ad579613..8a20adbcc 100644
--- a/bot-api/typescript/src/IBaseBot.ts
+++ b/bot-api/typescript/src/IBaseBot.ts
@@ -25,15 +25,22 @@ 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.
*/
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;
+ /** 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;
/** Starts the bot, connecting to the server and running until the game ends. */
start(): void;
@@ -230,11 +237,29 @@ 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 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 message to a specific teammate. */
+ /**
+ * Sends a compact JSON message to a teammate for delivery on the next turn.
+ * 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 b7e4afbd8..ef30606db 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,16 @@ export class EventQueue {
}
addEvent(event: BotEvent): void {
- if (this.events.length >= MAX_QUEUE_SIZE) {
+ // 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;
}
this.events.push(event);
diff --git a/bot-api/typescript/src/index.ts b/bot-api/typescript/src/index.ts
index c4700ac2f..6591cfed7 100644
--- a/bot-api/typescript/src/index.ts
+++ b/bot-api/typescript/src/index.ts
@@ -21,6 +21,8 @@ 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";
+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 d90a229ac..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,18 +1148,31 @@ 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 = [];
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",
+ messageType: batch ? "team-message-batch-v1" : (typeof message === "object" && message !== null ? message.constructor.name : "string"),
receiverId: teammateId ?? null,
- });
+ };
+ 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 b61990c5b..1527737c6 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 {
@@ -96,17 +97,29 @@ 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 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");
}
}
+ 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 (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/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/bot-api/typescript/test/TeamMessageLimits.test.ts b/bot-api/typescript/test/TeamMessageLimits.test.ts
new file mode 100644
index 000000000..baac19d1a
--- /dev/null
+++ b/bot-api/typescript/test/TeamMessageLimits.test.ts
@@ -0,0 +1,22 @@
+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 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);
+ });
+
+ 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/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
new file mode 100644
index 000000000..54aac6971
--- /dev/null
+++ b/changes/CH-047-team-message-limits/open-questions.md
@@ -0,0 +1,75 @@
+---
+id: OQ-002
+type: open-question
+status: draft
+links: [CH-047]
+title: CH-047 open questions
+---
+
+# Open questions
+
+## 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` 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.
+
+## 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.
+
+## 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.
+
+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.
+
+## 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.
+
+## 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.
+
+## 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.
+
+## 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.
+
+## 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/changes/CH-047-team-message-limits/proposal.md b/changes/CH-047-team-message-limits/proposal.md
new file mode 100644
index 000000000..406d8b4b2
--- /dev/null
+++ b/changes/CH-047-team-message-limits/proposal.md
@@ -0,0 +1,19 @@
+---
+id: CH-047
+type: change
+status: open
+links: [CAP-006]
+title: Batch team messages across Tank Royale
+---
+
+# 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. 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 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
+
+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
new file mode 100644
index 000000000..0d98991a8
--- /dev/null
+++ b/changes/CH-047-team-message-limits/tasks.md
@@ -0,0 +1,17 @@
+---
+id: TASKS-002
+type: tasks
+status: open
+links: [CH-047]
+title: CH-047 implementation tasks
+---
+
+# Tasks
+
+- [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..273f7351c 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,284 @@ class BattleRunnerIntegrationTest {
}
}
+ @Tag("integration")
+ @Tag("slow")
+ @Tag("PRO-006")
+ @Tag("Integration")
+ @Tag("Positive")
+ @Test
+ @Timeout(120)
+ fun testPRO006_IntegrationPositive_fiveBotsDeliverOrdered128ItemBatchesWithoutSkippedTurns() {
+ runFiveBotTeamMessageTrial(
+ teamName = "TeamMessageBatchStressTeam",
+ botName = "TeamMessageBatchStress",
+ expectedReceivedItemsPerBot = 4 * 60 * 128,
+ outboundTeamMessageBytes = estimateBatchArrayBytes(128),
+ itemsPerBatch = 128
+ )
+ }
+
+ @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,
+ 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
+ )
+ }
+
+ @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,
+ recordExactSkippedTurnNumbers: Boolean = false
+ ) {
+ 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(teamName)),
+ 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 $botName workload must reach turn $expectedTurn")
+ .isTrue()
+ handle.onTickEvent.off(owner)
+ handle.stop()
+ val states = finalStates.get()!!.filter { it.name == botName }
+ assertThat(states).hasSize(5)
+ println(
+ "TEAM_MESSAGE_TRIAL_STATE workload=$botName " + states.joinToString { state ->
+ "id=${state.id},body=${state.bodyColor},tracks=${state.tracksColor}," +
+ "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()
+ 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)
+ 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)
+ 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)
+ }
+
+ 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 itemsPerBatch=$itemsPerBatch tps=$measuredTps " +
+ "outboundTeamMessagesBytes=$outboundTeamMessageBytes " +
+ "estimatedTeamPayloadFanoutBytes=${outboundTeamMessageBytes * 4} " +
+ "receivedPerBot=$expectedReceivedItemsPerBot " +
+ "exactSkippedTurnNumbersByBot=$exactSkippedTurnNumbersByBot " +
+ "receivedItemsByBot=$receivedItemsByBot skippedTurnCountByBot=$skippedTurnCountByBot " +
+ "protocolErrorsByBot=$protocolErrorsByBot contentErrorsByBot=$contentErrorsByBot " +
+ "minTimeLeftMicros=${minTimeLeftMicros.minOrNull()} " +
+ "averageTimeLeftMicros=${averageTimeLeftMicros.average().toLong()} " +
+ "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 && !recordExactSkippedTurnNumbers) {
+ assertThat(maxTeamMessageHandlerMicros).containsOnly(0)
+ }
+ }
+ }
+
+ private fun decodeRgb(color: String?): Int = requireNotNull(color).removePrefix("#").take(6).toInt(16)
+
+ 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 itemsPerBatch).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/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..cda18823c
--- /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 * 64 * 60, "Missing team messages for $path: ${fields[1]}")
+ }
+ }
+ }
+}
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/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/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/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
new file mode 100644
index 000000000..55fdf950d
--- /dev/null
+++ b/sample-bots/java/TeamMessageBatchStress/TeamMessageBatchStress.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 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;
+ 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();
+ }
+
+ @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/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/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"
+}
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
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 668e79395..26abcc770 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: 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: 4
+ maxItems: 64
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-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 f7f64ce7f..5a333589f 100644
--- a/schema/schemas/team-message.schema.yaml
+++ b/schema/schemas/team-message.schema.yaml
@@ -1,11 +1,15 @@
$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 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. A batch
+ packet carries an ordered `messages` array as one compact JSON string.
type: string
- maxLength: 32768
messageType:
description: The message type, e.g. a class name
type: string
@@ -14,4 +18,4 @@ properties:
type: integer
required:
- message
- - messageType
\ No newline at end of file
+ - messageType
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/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..754bfb392
--- /dev/null
+++ b/server/src/main/kotlin/dev/robocode/tankroyale/server/connection/TeamMessagePolicy.kt
@@ -0,0 +1,114 @@
+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
+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
+ 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"
+ }
+ var logicalMessageCount = 0
+ 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"
+ }
+ 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 &&
+ recipient.asString.toIntOrNull() != null)) {
+ "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 3a552e2e2..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)
@@ -554,6 +565,17 @@ 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 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
+ }
+
var shouldProcessBreakpointTurn = false
synchronized(tickLock) {
val existingIntent = botIntents[conn]
@@ -586,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/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/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/main/kotlin/dev/robocode/tankroyale/server/rules/rules.kt b/server/src/main/kotlin/dev/robocode/tankroyale/server/rules/rules.kt
index 807d90657..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
@@ -90,8 +90,17 @@ 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 = 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
+
+/** 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..dacb81636
--- /dev/null
+++ b/server/src/test/kotlin/connection/TeamMessagePolicyTest.kt
@@ -0,0 +1,133 @@
+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
+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 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(53) { messages.add(item("message-${it + 11}")) }
+ TeamMessagePolicy.validate(intent(messages))
+ messages.add(item("message-64"))
+ 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)) }
+ 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..c7cc5e00e
--- /dev/null
+++ b/server/src/test/kotlin/connection/TeamMessageRawClientTest.kt
@@ -0,0 +1,77 @@
+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({
+ tags(Tag("Unit"))
+
+ 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/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")
+ }
+})
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