fix(protocol): support bot targets in binary player refs

This commit is contained in:
scamiv
2026-03-25 18:12:08 +01:00
parent 3f3f980ce2
commit 315e98ff28
4 changed files with 179 additions and 29 deletions
+76 -29
View File
@@ -5,6 +5,7 @@ import {
BinaryMessageType,
BinaryProtocolContext,
BinaryServerGameplayMessage,
INLINE_PLAYER_ID_INDEX,
INTENT_FLAG_OPTION_A,
INTENT_FLAG_OPTION_B,
createBinaryProtocolContext,
@@ -29,7 +30,7 @@ import {
ServerTurnMessage,
StampedIntent,
} from "./Schemas";
import { UnitType } from "./game/Game";
import { AllPlayers, UnitType } from "./game/Game";
const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder();
@@ -460,7 +461,7 @@ function encodeIntent(
switch (intent.type) {
case "attack":
if (intent.targetID !== null) {
writer.writeUint16(playerIdToIndex(intent.targetID, context));
writePlayerRef(writer, intent.targetID, context);
}
if (intent.troops !== null) {
writer.writeFloat64(intent.troops);
@@ -473,7 +474,7 @@ function encodeIntent(
writer.writeUint32(intent.tile);
return;
case "mark_disconnected":
writer.writeUint16(playerIdToIndex(intent.clientID, context));
writeRequiredPlayerRef(writer, intent.clientID, context);
writer.writeBoolean(intent.isDisconnected);
return;
case "boat":
@@ -484,23 +485,23 @@ function encodeIntent(
writer.writeUint32(intent.unitID);
return;
case "allianceRequest":
writer.writeUint16(playerIdToIndex(intent.recipient, context));
writeRequiredPlayerRef(writer, intent.recipient, context);
return;
case "allianceReject":
writer.writeUint16(playerIdToIndex(intent.requestor, context));
writeRequiredPlayerRef(writer, intent.requestor, context);
return;
case "breakAlliance":
writer.writeUint16(playerIdToIndex(intent.recipient, context));
writeRequiredPlayerRef(writer, intent.recipient, context);
return;
case "targetPlayer":
writer.writeUint16(playerIdToIndex(intent.target, context));
writeRequiredPlayerRef(writer, intent.target, context);
return;
case "emoji":
writer.writeUint16(playerIdToIndex(intent.recipient, context));
writePlayerRef(writer, intent.recipient, context);
writer.writeUint16(intent.emoji);
return;
case "donate_gold":
writer.writeUint16(playerIdToIndex(intent.recipient, context));
writeRequiredPlayerRef(writer, intent.recipient, context);
if (intent.gold === null) {
writer.writeBoolean(false);
} else {
@@ -509,7 +510,7 @@ function encodeIntent(
}
return;
case "donate_troops":
writer.writeUint16(playerIdToIndex(intent.recipient, context));
writeRequiredPlayerRef(writer, intent.recipient, context);
if (intent.troops === null) {
writer.writeBoolean(false);
} else {
@@ -526,7 +527,7 @@ function encodeIntent(
writer.writeUint32(intent.unitId);
return;
case "embargo":
writer.writeUint16(playerIdToIndex(intent.targetID, context));
writeRequiredPlayerRef(writer, intent.targetID, context);
writer.writeBoolean(intent.action === "start");
return;
case "embargo_all":
@@ -537,14 +538,14 @@ function encodeIntent(
writer.writeUint32(intent.tile);
return;
case "quick_chat":
writer.writeUint16(playerIdToIndex(intent.recipient, context));
writeRequiredPlayerRef(writer, intent.recipient, context);
writer.writeString(intent.quickChatKey);
if (intent.target !== undefined) {
writer.writeUint16(playerIdToIndex(intent.target, context));
writeRequiredPlayerRef(writer, intent.target, context);
}
return;
case "allianceExtension":
writer.writeUint16(playerIdToIndex(intent.recipient, context));
writeRequiredPlayerRef(writer, intent.recipient, context);
return;
case "delete_unit":
writer.writeUint32(intent.unitId);
@@ -576,9 +577,7 @@ function decodeIntent(
const hasTroops = (flags & INTENT_FLAG_OPTION_B) !== 0;
return {
type: "attack",
targetID: hasTarget
? requireClientId(reader.readUint16(), context)
: null,
targetID: hasTarget ? readRequiredPlayerRef(reader, context) : null,
troops: hasTroops ? reader.readFloat64() : null,
};
}
@@ -598,7 +597,7 @@ function decodeIntent(
assertIntentFlags(intentType, flags, 0);
return {
type: "mark_disconnected",
clientID: requireClientId(reader.readUint16(), context),
clientID: readRequiredPlayerRef(reader, context),
isDisconnected: reader.readBoolean(),
};
case "boat":
@@ -618,29 +617,29 @@ function decodeIntent(
assertIntentFlags(intentType, flags, 0);
return {
type: "allianceRequest",
recipient: requireClientId(reader.readUint16(), context),
recipient: readRequiredPlayerRef(reader, context),
};
case "allianceReject":
assertIntentFlags(intentType, flags, 0);
return {
type: "allianceReject",
requestor: requireClientId(reader.readUint16(), context),
requestor: readRequiredPlayerRef(reader, context),
};
case "breakAlliance":
assertIntentFlags(intentType, flags, 0);
return {
type: "breakAlliance",
recipient: requireClientId(reader.readUint16(), context),
recipient: readRequiredPlayerRef(reader, context),
};
case "targetPlayer":
assertIntentFlags(intentType, flags, 0);
return {
type: "targetPlayer",
target: requireClientId(reader.readUint16(), context),
target: readRequiredPlayerRef(reader, context),
};
case "emoji": {
assertIntentFlags(intentType, flags, 0);
const recipient = playerIndexToId(reader.readUint16(), context);
const recipient = readPlayerRef(reader, context);
if (recipient === null) {
throw new Error("Emoji recipient cannot be null");
}
@@ -652,7 +651,7 @@ function decodeIntent(
}
case "donate_gold": {
assertIntentFlags(intentType, flags, 0);
const recipient = requireClientId(reader.readUint16(), context);
const recipient = readRequiredPlayerRef(reader, context);
const hasGold = reader.readBoolean();
return {
type: "donate_gold",
@@ -662,7 +661,7 @@ function decodeIntent(
}
case "donate_troops": {
assertIntentFlags(intentType, flags, 0);
const recipient = requireClientId(reader.readUint16(), context);
const recipient = readRequiredPlayerRef(reader, context);
const hasTroops = reader.readBoolean();
return {
type: "donate_troops",
@@ -699,7 +698,7 @@ function decodeIntent(
assertIntentFlags(intentType, flags, 0);
return {
type: "embargo",
targetID: requireClientId(reader.readUint16(), context),
targetID: readRequiredPlayerRef(reader, context),
action: reader.readBoolean() ? "start" : "stop",
};
case "embargo_all":
@@ -717,11 +716,11 @@ function decodeIntent(
};
case "quick_chat": {
assertIntentFlags(intentType, flags, INTENT_FLAG_OPTION_A);
const recipient = requireClientId(reader.readUint16(), context);
const recipient = readRequiredPlayerRef(reader, context);
const quickChatKey = reader.readString();
const target =
(flags & INTENT_FLAG_OPTION_A) !== 0
? requireClientId(reader.readUint16(), context)
? readRequiredPlayerRef(reader, context)
: undefined;
if (!QuickChatKeySchema.safeParse(quickChatKey).success) {
throw new Error(`Invalid quick chat key: ${quickChatKey}`);
@@ -737,7 +736,7 @@ function decodeIntent(
assertIntentFlags(intentType, flags, 0);
return {
type: "allianceExtension",
recipient: requireClientId(reader.readUint16(), context),
recipient: readRequiredPlayerRef(reader, context),
};
case "delete_unit":
assertIntentFlags(intentType, flags, 0);
@@ -773,6 +772,54 @@ function assertIntentFlags(
}
}
function writePlayerRef(
writer: BinaryWriter,
playerId: string | null | typeof AllPlayers,
context: BinaryProtocolContext,
) {
if (playerId === null || playerId === AllPlayers) {
writer.writeUint16(playerIdToIndex(playerId, context));
return;
}
const mappedIndex = context.playerIdToIndex.get(playerId);
if (mappedIndex !== undefined) {
writer.writeUint16(mappedIndex);
return;
}
writer.writeUint16(INLINE_PLAYER_ID_INDEX);
writer.writeString(playerId);
}
function writeRequiredPlayerRef(
writer: BinaryWriter,
playerId: string,
context: BinaryProtocolContext,
) {
writePlayerRef(writer, playerId, context);
}
function readPlayerRef(
reader: BinaryReader,
context: BinaryProtocolContext,
): string | null | typeof AllPlayers {
const playerIndex = reader.readUint16();
if (playerIndex === INLINE_PLAYER_ID_INDEX) {
return reader.readString();
}
return playerIndexToId(playerIndex, context);
}
function readRequiredPlayerRef(
reader: BinaryReader,
context: BinaryProtocolContext,
): string {
const playerId = readPlayerRef(reader, context);
if (playerId === null || playerId === AllPlayers) {
throw new Error(`Expected player ID, received ${String(playerId)}`);
}
return playerId;
}
export function isBinaryGameplayClientMessage(
message: ClientMessage,
): message is BinaryClientGameplayMessage {
+1
View File
@@ -16,6 +16,7 @@ export const BINARY_PROTOCOL_VERSION = 1;
export const BINARY_HEADER_SIZE = 4;
export const NO_PLAYER_INDEX = 0xffff;
export const ALL_PLAYERS_INDEX = 0xfffe;
export const INLINE_PLAYER_ID_INDEX = 0xfffd;
export enum BinaryMessageType {
Intent = 1,
+28
View File
@@ -36,6 +36,14 @@ const clientIntentMessages: ClientIntentMessage[] = [
troops: 12.5,
},
},
{
type: "intent",
intent: {
type: "attack",
targetID: "kli0dx59",
troops: 18,
},
},
{
type: "intent",
intent: {
@@ -300,6 +308,26 @@ describe("BinaryCodec", () => {
expect(decoded).toEqual(message);
});
it("round-trips server turn messages with non-lobby target ids", () => {
const message: ServerTurnMessage = {
type: "turn",
turn: {
turnNumber: 6,
intents: [
{
type: "attack",
targetID: "kli0dx59",
troops: 9,
clientID: "P0000001",
},
],
},
};
const encoded = encodeBinaryServerGameplayMessage(message, context);
const decoded = decodeBinaryServerGameplayMessage(encoded, context);
expect(decoded).toEqual(message);
});
it("round-trips server desync messages", () => {
const message: ServerDesyncMessage = {
type: "desync",
@@ -163,6 +163,80 @@ describe("GameServer binary gameplay protocol", () => {
);
});
it("round-trips binary attacks that target bot-style ids", () => {
const logger = createMockLogger();
const game = new GameServer(
"TEST0003",
logger as any,
Date.now(),
{
turnIntervalMs: () => 100,
env: () => 0,
} as any,
createGameConfig(),
);
const clientA = createClient(
"P0000001",
"44444444-4444-4444-8444-444444444444",
"Alice",
);
expect(game.joinClient(clientA.client)).toBe("joined");
game.start();
const startPayload = clientA.ws.sent.find(
(message): message is string =>
typeof message === "string" && message.includes('"type":"start"'),
);
expect(startPayload).toBeDefined();
const binaryContext = binaryContextFromGameStartInfo(
JSON.parse(startPayload!).gameStartInfo,
);
const attackMessage = encodeBinaryClientGameplayMessage(
{
type: "intent",
intent: {
type: "attack",
targetID: "kli0dx59",
troops: 25,
},
},
binaryContext,
);
clientA.ws.emit("message", attackMessage, true);
(game as any).endTurn();
const binaryTurn = [...clientA.ws.sent]
.reverse()
.find(
(message): message is Uint8Array =>
message instanceof Uint8Array && message[1] === 2,
);
expect(binaryTurn).toBeDefined();
const decodedTurn = decodeBinaryServerGameplayMessage(
binaryTurn!,
binaryContext,
);
expect(decodedTurn.type).toBe("turn");
if (decodedTurn.type !== "turn") {
throw new Error("Expected binary turn message");
}
expect(decodedTurn.turn.intents).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: "attack",
targetID: "kli0dx59",
troops: 25,
clientID: "P0000001",
}),
]),
);
});
it("accepts JSON rejoin after start and responds with JSON start", () => {
const logger = createMockLogger();
const game = new GameServer(