mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 23:01:54 +00:00
fix(protocol): support bot targets in binary player refs
This commit is contained in:
+76
-29
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user