Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .clue/id-ledger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.3.1
1.4.0
2 changes: 2 additions & 0 deletions booter/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down
24 changes: 22 additions & 2 deletions bot-api/dotnet/TEAM_MESSAGES_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
10 changes: 9 additions & 1 deletion bot-api/dotnet/api/src/BaseBot.cs
Original file line number Diff line number Diff line change
Expand Up @@ -384,10 +384,18 @@ public bool AdjustRadarForGunTurn
/// <inheritdoc/>
public void BroadcastTeamMessage(object message) => BaseBotInternals.BroadcastTeamMessage(message);

/// <inheritdoc/>
public void BroadcastTeamMessageBatch(System.Collections.IEnumerable messages) =>
BaseBotInternals.BroadcastTeamMessage(new TeamMessageBatch(messages));

/// <inheritdoc/>
public void SendTeamMessage(int teammateId, object message) =>
BaseBotInternals.SendTeamMessage(teammateId, message);

/// <inheritdoc/>
public void SendTeamMessageBatch(int teammateId, System.Collections.IEnumerable messages) =>
BaseBotInternals.SendTeamMessage(teammateId, new TeamMessageBatch(messages));

/// <inheritdoc/>
public Color? BodyColor
{
Expand Down Expand Up @@ -637,4 +645,4 @@ public virtual void OnCustomEvent(CustomEvent customEvent)
public virtual void OnTeamMessage(TeamMessageEvent teamMessageEvent)
{
}
}
}
22 changes: 14 additions & 8 deletions bot-api/dotnet/api/src/Constants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -149,15 +149,21 @@ public static class Constants
public const double StartingGunHeat = 3.0;

/// <summary>
/// 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.
/// </summary>
/// <value>The maximum team message size in bytes, which is 32768.</value>
public const int TeamMessageMaxSize = 32768;
/// <value>The maximum team message size in UTF-8 bytes, which is 49152.</value>
public const int TeamMessageMaxSize = 48 * 1024;

/// <summary>
/// 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.
/// </summary>
/// <value>The maximum number of team messages per turn, which is 10.</value>
public const int MaxNumberOfTeamMessagesPerTurn = 10;
}
/// <value>The maximum number of team-message packets per turn, which is 64.</value>
public const int MaxNumberOfTeamMessagesPerTurn = 64;

/// <summary>The maximum logical payloads across ordinary messages and batch entries per turn, which is 128.</summary>
public const int MaxLogicalTeamMessagesPerTurn = 128;

/// <summary>Maximum UTF-8 bytes of the compact encoded teamMessages array per turn (256 KiB).</summary>
public const int TeamMessagesMaxBytesPerTurn = 256 * 1024;
}
47 changes: 38 additions & 9 deletions bot-api/dotnet/api/src/IBaseBot.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,21 @@ namespace Robocode.TankRoyale.BotApi;
public interface IBaseBot
{
/// <summary>
/// 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.
/// </summary>
const int TeamMessageMaxSize = Constants.TeamMessageMaxSize; // bytes

/// <summary>
/// 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.
/// </summary>
const int MaxNumberOfTeamMessagesPerTurn = Constants.MaxNumberOfTeamMessagesPerTurn;

/// <summary>Maximum logical payloads per turn, counting each batch entry, which is 128.</summary>
const int MaxLogicalTeamMessagesPerTurn = Constants.MaxLogicalTeamMessagesPerTurn;

/// <summary>Maximum UTF-8 bytes of the compact encoded teamMessages array per turn (256 KiB).</summary>
const int TeamMessagesMaxBytesPerTurn = Constants.TeamMessagesMaxBytesPerTurn;

/// <summary>
/// The method used to start running the bot. You should call this method from the main
/// method or similar.
Expand Down Expand Up @@ -732,15 +738,26 @@ public interface IBaseBot
/// The maximum team message size limit is defined by <see cref="TeamMessageMaxSize"/>. 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
/// <see cref="MaxNumberOfTeamMessagesPerTurn"/>.
/// A turn accepts at most <see cref="MaxNumberOfTeamMessagesPerTurn"/> packets and
/// <see cref="MaxLogicalTeamMessagesPerTurn"/> logical payloads, counting batch entries. Each packet is limited to
/// <see cref="TeamMessageMaxSize"/> UTF-8 bytes and the compact array to <see cref="TeamMessagesMaxBytesPerTurn"/>
/// 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.
/// </summary>
/// <param name="message">The message to broadcast.</param>
/// <exception cref="ArgumentException">if the size of the message exceeds the size limit.</exception>
/// <exception cref="BotException">If the per-turn message count has been reached.</exception>
/// <exception cref="ArgumentException">If the message or complete batch exceeds its UTF-8 byte limit.</exception>
/// <seealso cref="SendTeamMessage"/>
/// <seealso cref="TeammateIds"/>
void BroadcastTeamMessage(object message);

/// <summary>
/// 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.
/// </summary>
void BroadcastTeamMessageBatch(System.Collections.IEnumerable messages);

/// <summary>
/// Sends a message to a specific teammate.
///
Expand All @@ -750,16 +767,28 @@ public interface IBaseBot
/// The maximum team message size limit is defined by <see cref="TeamMessageMaxSize"/>. 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
/// <see cref="MaxNumberOfTeamMessagesPerTurn"/>.
/// A turn accepts at most <see cref="MaxNumberOfTeamMessagesPerTurn"/> packets and
/// <see cref="MaxLogicalTeamMessagesPerTurn"/> logical payloads, counting batch entries. Each packet is limited to
/// <see cref="TeamMessageMaxSize"/> UTF-8 bytes and the compact array to <see cref="TeamMessagesMaxBytesPerTurn"/>
/// 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.
/// </summary>
/// <param name="teammateId">The id of the teammate to send the message to.</param>
/// <param name="message">The message to broadcast.</param>
/// <exception cref="ArgumentException">if the size of the message exceeds the size limit.</exception>
/// <exception cref="BotException">If the per-turn message count has been reached.</exception>
/// <exception cref="ArgumentException">If the recipient is invalid or the message or complete batch exceeds its UTF-8 byte limit.</exception>
/// <seealso cref="BroadcastTeamMessage"/>
/// <seealso cref="TeammateIds"/>
void SendTeamMessage(int teammateId, object message);

/// <summary>
/// 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.
/// </summary>
void SendTeamMessageBatch(int teammateId, System.Collections.IEnumerable messages);

/// <summary>
/// The color of the body. Colors can (only) be changed each turn.
/// </summary>
Expand Down Expand Up @@ -1179,4 +1208,4 @@ public interface IBaseBot
/// <see cref="DefaultEventPriority"/>
/// <see cref="GetEventPriority"/>
void SetEventPriority(Type eventType, int priority);
}
}
25 changes: 25 additions & 0 deletions bot-api/dotnet/api/src/TeamMessageBatch.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;

namespace Robocode.TankRoyale.BotApi;

/// <summary>Immutable ordered, non-null payloads delivered as one team-message event on the next turn.</summary>
public sealed class TeamMessageBatch
{
/// <summary>Returns the ordered batch payloads.</summary>
public IReadOnlyList<object> Messages { get; }

/// <summary>Creates a batch from a non-empty sequence of non-null payloads.</summary>
/// <param name="messages">Payloads in delivery order.</param>
/// <exception cref="ArgumentException">The sequence is empty or contains a null payload.</exception>
public TeamMessageBatch(IEnumerable messages)
{
if (messages == null) throw new ArgumentException("A team message batch must contain at least one message");
var items = messages.Cast<object>().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);
}
}
15 changes: 11 additions & 4 deletions bot-api/dotnet/api/src/internal/BaseBotInternals.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<S.TeamMessage>(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
Expand Down
3 changes: 2 additions & 1 deletion bot-api/dotnet/api/src/internal/BotHandshakeFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -63,4 +64,4 @@ private static bool IsDebuggerAttached()
// Check if a managed debugger is attached
return Debugger.IsAttached;
}
}
}
15 changes: 13 additions & 2 deletions bot-api/dotnet/api/src/internal/EventQueue.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -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);
}
Expand Down
20 changes: 17 additions & 3 deletions bot-api/dotnet/api/src/internal/IntentValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -117,15 +117,22 @@ public static void ValidateTeammateId(int? teammateId, ICollection<int> 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);
Expand All @@ -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);
}
Loading
Loading