feat(protocol): add shared binary gameplay codec

This commit is contained in:
scamiv
2026-03-25 17:06:16 +01:00
parent 4e126c2e79
commit 9af2880017
2 changed files with 1019 additions and 0 deletions
+784
View File
@@ -0,0 +1,784 @@
import {
BINARY_HEADER_SIZE,
BINARY_PROTOCOL_VERSION,
BinaryClientGameplayMessage,
BinaryMessageType,
BinaryProtocolContext,
BinaryServerGameplayMessage,
INTENT_FLAG_OPTION_A,
INTENT_FLAG_OPTION_B,
createBinaryProtocolContext,
intentTypeToOpcode,
opcodeToIntentType,
opcodeToUnitType,
playerIdToIndex,
playerIndexToId,
requireClientId,
stampedIntentClientIndex,
unitTypeToOpcode,
} from "./BinaryProtocol";
import {
ClientHashMessage,
ClientIntentMessage,
ClientMessage,
ClientPingMessage,
GameStartInfo,
Intent,
QuickChatKeySchema,
ServerDesyncMessage,
ServerTurnMessage,
StampedIntent,
} from "./Schemas";
import { UnitType } from "./game/Game";
const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder();
class BinaryWriter {
private readonly chunks: Uint8Array[] = [];
private totalLength = 0;
writeUint8(value: number) {
const chunk = new Uint8Array(1);
chunk[0] = value;
this.push(chunk);
}
writeUint16(value: number) {
const chunk = new Uint8Array(2);
new DataView(chunk.buffer).setUint16(0, value, true);
this.push(chunk);
}
writeUint32(value: number) {
const chunk = new Uint8Array(4);
new DataView(chunk.buffer).setUint32(0, value, true);
this.push(chunk);
}
writeInt32(value: number) {
const chunk = new Uint8Array(4);
new DataView(chunk.buffer).setInt32(0, value, true);
this.push(chunk);
}
writeFloat64(value: number) {
const chunk = new Uint8Array(8);
new DataView(chunk.buffer).setFloat64(0, value, true);
this.push(chunk);
}
writeBoolean(value: boolean) {
this.writeUint8(value ? 1 : 0);
}
writeString(value: string) {
const encoded = textEncoder.encode(value);
this.writeUint16(encoded.length);
this.push(encoded);
}
writeFrame(
messageType: BinaryMessageType,
writePayload: () => void,
): Uint8Array {
this.writeUint8(BINARY_PROTOCOL_VERSION);
this.writeUint8(messageType);
this.writeUint16(0);
writePayload();
return this.finish();
}
finish(): Uint8Array {
const output = new Uint8Array(this.totalLength);
let offset = 0;
for (const chunk of this.chunks) {
output.set(chunk, offset);
offset += chunk.length;
}
return output;
}
private push(chunk: Uint8Array) {
this.chunks.push(chunk);
this.totalLength += chunk.length;
}
}
class BinaryReader {
private readonly view: DataView;
private offset = 0;
constructor(private readonly bytes: Uint8Array) {
this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
}
readHeader(expectedType: BinaryMessageType) {
if (this.bytes.byteLength < BINARY_HEADER_SIZE) {
throw new Error("Binary frame too short");
}
const version = this.readUint8();
if (version !== BINARY_PROTOCOL_VERSION) {
throw new Error(`Unsupported binary protocol version: ${version}`);
}
const messageType = this.readUint8();
if (messageType !== expectedType) {
throw new Error(
`Unexpected binary message type: expected ${expectedType}, received ${messageType}`,
);
}
const flags = this.readUint16();
if (flags !== 0) {
throw new Error(`Unsupported binary header flags: ${flags}`);
}
}
readUint8(): number {
this.ensureAvailable(1);
const value = this.view.getUint8(this.offset);
this.offset += 1;
return value;
}
readUint16(): number {
this.ensureAvailable(2);
const value = this.view.getUint16(this.offset, true);
this.offset += 2;
return value;
}
readUint32(): number {
this.ensureAvailable(4);
const value = this.view.getUint32(this.offset, true);
this.offset += 4;
return value;
}
readInt32(): number {
this.ensureAvailable(4);
const value = this.view.getInt32(this.offset, true);
this.offset += 4;
return value;
}
readFloat64(): number {
this.ensureAvailable(8);
const value = this.view.getFloat64(this.offset, true);
this.offset += 8;
return value;
}
readBoolean(): boolean {
const value = this.readUint8();
if (value !== 0 && value !== 1) {
throw new Error(`Invalid boolean value: ${value}`);
}
return value === 1;
}
readString(): string {
const length = this.readUint16();
this.ensureAvailable(length);
const value = textDecoder.decode(
this.bytes.subarray(this.offset, this.offset + length),
);
this.offset += length;
return value;
}
ensureFinished() {
if (this.offset !== this.bytes.byteLength) {
throw new Error(
`Unexpected trailing bytes: ${this.bytes.byteLength - this.offset}`,
);
}
}
private ensureAvailable(length: number) {
if (this.offset + length > this.bytes.byteLength) {
throw new Error("Unexpected end of binary frame");
}
}
}
export function binaryContextFromGameStartInfo(
gameStartInfo: Pick<GameStartInfo, "players">,
): BinaryProtocolContext {
return createBinaryProtocolContext(gameStartInfo);
}
export function toUint8Array(data: ArrayBuffer | Uint8Array): Uint8Array {
if (data instanceof Uint8Array) {
return data;
}
return new Uint8Array(data);
}
export function encodeBinaryClientGameplayMessage(
message: BinaryClientGameplayMessage,
context: BinaryProtocolContext,
): Uint8Array {
switch (message.type) {
case "intent":
return encodeClientIntentMessage(message, context);
case "hash":
return encodeClientHashMessage(message);
case "ping":
return encodeClientPingMessage(message);
}
}
export function decodeBinaryClientGameplayMessage(
data: ArrayBuffer | Uint8Array,
context: BinaryProtocolContext,
): ClientIntentMessage | ClientHashMessage | ClientPingMessage {
const bytes = toUint8Array(data);
if (bytes.byteLength < BINARY_HEADER_SIZE) {
throw new Error("Binary frame too short");
}
switch (bytes[1]) {
case BinaryMessageType.Intent:
return decodeClientIntentMessage(bytes, context);
case BinaryMessageType.Hash:
return decodeClientHashMessage(bytes);
case BinaryMessageType.Ping:
return decodeClientPingMessage(bytes);
default:
throw new Error(`Unknown client binary message type: ${bytes[1]}`);
}
}
export function encodeBinaryServerGameplayMessage(
message: BinaryServerGameplayMessage,
context: BinaryProtocolContext,
): Uint8Array {
switch (message.type) {
case "turn":
return encodeServerTurnMessage(message, context);
case "desync":
return encodeServerDesyncMessage(message);
}
}
export function decodeBinaryServerGameplayMessage(
data: ArrayBuffer | Uint8Array,
context: BinaryProtocolContext,
): ServerTurnMessage | ServerDesyncMessage {
const bytes = toUint8Array(data);
if (bytes.byteLength < BINARY_HEADER_SIZE) {
throw new Error("Binary frame too short");
}
switch (bytes[1]) {
case BinaryMessageType.Turn:
return decodeServerTurnMessage(bytes, context);
case BinaryMessageType.Desync:
return decodeServerDesyncMessage(bytes);
default:
throw new Error(`Unknown server binary message type: ${bytes[1]}`);
}
}
export function encodeClientIntentMessage(
message: ClientIntentMessage,
context: BinaryProtocolContext,
): Uint8Array {
const writer = new BinaryWriter();
return writer.writeFrame(BinaryMessageType.Intent, () => {
encodeIntent(writer, message.intent, context);
});
}
export function decodeClientIntentMessage(
data: ArrayBuffer | Uint8Array,
context: BinaryProtocolContext,
): ClientIntentMessage {
const reader = new BinaryReader(toUint8Array(data));
reader.readHeader(BinaryMessageType.Intent);
const intent = decodeIntent(reader, context);
reader.ensureFinished();
return {
type: "intent",
intent,
};
}
export function encodeClientHashMessage(
message: ClientHashMessage,
): Uint8Array {
const writer = new BinaryWriter();
return writer.writeFrame(BinaryMessageType.Hash, () => {
writer.writeUint32(message.turnNumber);
writer.writeInt32(message.hash);
});
}
export function decodeClientHashMessage(
data: ArrayBuffer | Uint8Array,
): ClientHashMessage {
const reader = new BinaryReader(toUint8Array(data));
reader.readHeader(BinaryMessageType.Hash);
const turnNumber = reader.readUint32();
const hash = reader.readInt32();
reader.ensureFinished();
return {
type: "hash",
turnNumber,
hash,
};
}
export function encodeClientPingMessage(
_message: ClientPingMessage,
): Uint8Array {
const writer = new BinaryWriter();
return writer.writeFrame(BinaryMessageType.Ping, () => {});
}
export function decodeClientPingMessage(
data: ArrayBuffer | Uint8Array,
): ClientPingMessage {
const reader = new BinaryReader(toUint8Array(data));
reader.readHeader(BinaryMessageType.Ping);
reader.ensureFinished();
return { type: "ping" };
}
export function encodeServerTurnMessage(
message: ServerTurnMessage,
context: BinaryProtocolContext,
): Uint8Array {
const writer = new BinaryWriter();
return writer.writeFrame(BinaryMessageType.Turn, () => {
writer.writeUint32(message.turn.turnNumber);
writer.writeUint16(message.turn.intents.length);
for (const intent of message.turn.intents) {
writer.writeUint16(stampedIntentClientIndex(intent, context));
encodeIntent(writer, intent, context);
}
});
}
export function decodeServerTurnMessage(
data: ArrayBuffer | Uint8Array,
context: BinaryProtocolContext,
): ServerTurnMessage {
const reader = new BinaryReader(toUint8Array(data));
reader.readHeader(BinaryMessageType.Turn);
const turnNumber = reader.readUint32();
const intentCount = reader.readUint16();
const intents: StampedIntent[] = [];
for (let i = 0; i < intentCount; i++) {
const clientIndex = reader.readUint16();
const clientID = requireClientId(clientIndex, context);
const intent = decodeIntent(reader, context);
intents.push({
...intent,
clientID,
} as StampedIntent);
}
reader.ensureFinished();
return {
type: "turn",
turn: {
turnNumber,
intents,
},
};
}
export function encodeServerDesyncMessage(
message: ServerDesyncMessage,
): Uint8Array {
const writer = new BinaryWriter();
return writer.writeFrame(BinaryMessageType.Desync, () => {
writer.writeUint32(message.turn);
writer.writeBoolean(message.correctHash !== null);
if (message.correctHash !== null) {
writer.writeInt32(message.correctHash);
}
writer.writeUint16(message.clientsWithCorrectHash);
writer.writeUint16(message.totalActiveClients);
});
}
export function decodeServerDesyncMessage(
data: ArrayBuffer | Uint8Array,
): ServerDesyncMessage {
const reader = new BinaryReader(toUint8Array(data));
reader.readHeader(BinaryMessageType.Desync);
const turn = reader.readUint32();
const hasCorrectHash = reader.readBoolean();
const correctHash = hasCorrectHash ? reader.readInt32() : null;
const clientsWithCorrectHash = reader.readUint16();
const totalActiveClients = reader.readUint16();
reader.ensureFinished();
return {
type: "desync",
turn,
correctHash,
clientsWithCorrectHash,
totalActiveClients,
};
}
function encodeIntent(
writer: BinaryWriter,
intent: Intent,
context: BinaryProtocolContext,
) {
const intentOpcode = intentTypeToOpcode(intent.type);
writer.writeUint8(intentOpcode);
let flags = 0;
switch (intent.type) {
case "attack":
if (intent.targetID !== null) {
flags |= INTENT_FLAG_OPTION_A;
}
if (intent.troops !== null) {
flags |= INTENT_FLAG_OPTION_B;
}
break;
case "build_unit":
if (intent.rocketDirectionUp !== undefined) {
flags |= INTENT_FLAG_OPTION_A;
}
if (intent.rocketDirectionUp) {
flags |= INTENT_FLAG_OPTION_B;
}
break;
case "quick_chat":
if (intent.target !== undefined) {
flags |= INTENT_FLAG_OPTION_A;
}
break;
}
writer.writeUint16(flags);
switch (intent.type) {
case "attack":
if (intent.targetID !== null) {
writer.writeUint16(playerIdToIndex(intent.targetID, context));
}
if (intent.troops !== null) {
writer.writeFloat64(intent.troops);
}
return;
case "cancel_attack":
writer.writeString(intent.attackID);
return;
case "spawn":
writer.writeUint32(intent.tile);
return;
case "mark_disconnected":
writer.writeUint16(playerIdToIndex(intent.clientID, context));
writer.writeBoolean(intent.isDisconnected);
return;
case "boat":
writer.writeFloat64(intent.troops);
writer.writeUint32(intent.dst);
return;
case "cancel_boat":
writer.writeUint32(intent.unitID);
return;
case "allianceRequest":
writer.writeUint16(playerIdToIndex(intent.recipient, context));
return;
case "allianceReject":
writer.writeUint16(playerIdToIndex(intent.requestor, context));
return;
case "breakAlliance":
writer.writeUint16(playerIdToIndex(intent.recipient, context));
return;
case "targetPlayer":
writer.writeUint16(playerIdToIndex(intent.target, context));
return;
case "emoji":
writer.writeUint16(playerIdToIndex(intent.recipient, context));
writer.writeUint16(intent.emoji);
return;
case "donate_gold":
writer.writeUint16(playerIdToIndex(intent.recipient, context));
if (intent.gold === null) {
writer.writeBoolean(false);
} else {
writer.writeBoolean(true);
writer.writeFloat64(intent.gold);
}
return;
case "donate_troops":
writer.writeUint16(playerIdToIndex(intent.recipient, context));
if (intent.troops === null) {
writer.writeBoolean(false);
} else {
writer.writeBoolean(true);
writer.writeFloat64(intent.troops);
}
return;
case "build_unit":
writer.writeUint8(unitTypeToOpcode(intent.unit));
writer.writeUint32(intent.tile);
return;
case "upgrade_structure":
writer.writeUint8(unitTypeToOpcode(intent.unit));
writer.writeUint32(intent.unitId);
return;
case "embargo":
writer.writeUint16(playerIdToIndex(intent.targetID, context));
writer.writeBoolean(intent.action === "start");
return;
case "embargo_all":
writer.writeBoolean(intent.action === "start");
return;
case "move_warship":
writer.writeUint32(intent.unitId);
writer.writeUint32(intent.tile);
return;
case "quick_chat":
writer.writeUint16(playerIdToIndex(intent.recipient, context));
writer.writeString(intent.quickChatKey);
if (intent.target !== undefined) {
writer.writeUint16(playerIdToIndex(intent.target, context));
}
return;
case "allianceExtension":
writer.writeUint16(playerIdToIndex(intent.recipient, context));
return;
case "delete_unit":
writer.writeUint32(intent.unitId);
return;
case "toggle_pause":
writer.writeBoolean(intent.paused);
return;
case "kick_player":
case "update_game_config":
throw new Error(`Unsupported binary intent type: ${intent.type}`);
}
}
function decodeIntent(
reader: BinaryReader,
context: BinaryProtocolContext,
): Intent {
const intentType = opcodeToIntentType(reader.readUint8());
const flags = reader.readUint16();
switch (intentType) {
case "attack": {
assertIntentFlags(
intentType,
flags,
INTENT_FLAG_OPTION_A | INTENT_FLAG_OPTION_B,
);
const hasTarget = (flags & INTENT_FLAG_OPTION_A) !== 0;
const hasTroops = (flags & INTENT_FLAG_OPTION_B) !== 0;
return {
type: "attack",
targetID: hasTarget
? requireClientId(reader.readUint16(), context)
: null,
troops: hasTroops ? reader.readFloat64() : null,
};
}
case "cancel_attack":
assertIntentFlags(intentType, flags, 0);
return {
type: "cancel_attack",
attackID: reader.readString(),
};
case "spawn":
assertIntentFlags(intentType, flags, 0);
return {
type: "spawn",
tile: reader.readUint32(),
};
case "mark_disconnected":
assertIntentFlags(intentType, flags, 0);
return {
type: "mark_disconnected",
clientID: requireClientId(reader.readUint16(), context),
isDisconnected: reader.readBoolean(),
};
case "boat":
assertIntentFlags(intentType, flags, 0);
return {
type: "boat",
troops: reader.readFloat64(),
dst: reader.readUint32(),
};
case "cancel_boat":
assertIntentFlags(intentType, flags, 0);
return {
type: "cancel_boat",
unitID: reader.readUint32(),
};
case "allianceRequest":
assertIntentFlags(intentType, flags, 0);
return {
type: "allianceRequest",
recipient: requireClientId(reader.readUint16(), context),
};
case "allianceReject":
assertIntentFlags(intentType, flags, 0);
return {
type: "allianceReject",
requestor: requireClientId(reader.readUint16(), context),
};
case "breakAlliance":
assertIntentFlags(intentType, flags, 0);
return {
type: "breakAlliance",
recipient: requireClientId(reader.readUint16(), context),
};
case "targetPlayer":
assertIntentFlags(intentType, flags, 0);
return {
type: "targetPlayer",
target: requireClientId(reader.readUint16(), context),
};
case "emoji": {
assertIntentFlags(intentType, flags, 0);
const recipient = playerIndexToId(reader.readUint16(), context);
if (recipient === null) {
throw new Error("Emoji recipient cannot be null");
}
return {
type: "emoji",
recipient,
emoji: reader.readUint16(),
};
}
case "donate_gold": {
assertIntentFlags(intentType, flags, 0);
const recipient = requireClientId(reader.readUint16(), context);
const hasGold = reader.readBoolean();
return {
type: "donate_gold",
recipient,
gold: hasGold ? reader.readFloat64() : null,
};
}
case "donate_troops": {
assertIntentFlags(intentType, flags, 0);
const recipient = requireClientId(reader.readUint16(), context);
const hasTroops = reader.readBoolean();
return {
type: "donate_troops",
recipient,
troops: hasTroops ? reader.readFloat64() : null,
};
}
case "build_unit": {
assertIntentFlags(
intentType,
flags,
INTENT_FLAG_OPTION_A | INTENT_FLAG_OPTION_B,
);
const unit = decodeUnit(reader.readUint8());
const tile = reader.readUint32();
const hasRocketDirection = (flags & INTENT_FLAG_OPTION_A) !== 0;
const rocketDirectionUp =
hasRocketDirection && (flags & INTENT_FLAG_OPTION_B) !== 0;
return {
type: "build_unit",
unit,
tile,
rocketDirectionUp: hasRocketDirection ? rocketDirectionUp : undefined,
};
}
case "upgrade_structure":
assertIntentFlags(intentType, flags, 0);
return {
type: "upgrade_structure",
unit: decodeUnit(reader.readUint8()),
unitId: reader.readUint32(),
};
case "embargo":
assertIntentFlags(intentType, flags, 0);
return {
type: "embargo",
targetID: requireClientId(reader.readUint16(), context),
action: reader.readBoolean() ? "start" : "stop",
};
case "embargo_all":
assertIntentFlags(intentType, flags, 0);
return {
type: "embargo_all",
action: reader.readBoolean() ? "start" : "stop",
};
case "move_warship":
assertIntentFlags(intentType, flags, 0);
return {
type: "move_warship",
unitId: reader.readUint32(),
tile: reader.readUint32(),
};
case "quick_chat": {
assertIntentFlags(intentType, flags, INTENT_FLAG_OPTION_A);
const recipient = requireClientId(reader.readUint16(), context);
const quickChatKey = reader.readString();
const target =
(flags & INTENT_FLAG_OPTION_A) !== 0
? requireClientId(reader.readUint16(), context)
: undefined;
if (!QuickChatKeySchema.safeParse(quickChatKey).success) {
throw new Error(`Invalid quick chat key: ${quickChatKey}`);
}
return {
type: "quick_chat",
recipient,
quickChatKey,
target,
};
}
case "allianceExtension":
assertIntentFlags(intentType, flags, 0);
return {
type: "allianceExtension",
recipient: requireClientId(reader.readUint16(), context),
};
case "delete_unit":
assertIntentFlags(intentType, flags, 0);
return {
type: "delete_unit",
unitId: reader.readUint32(),
};
case "toggle_pause":
assertIntentFlags(intentType, flags, 0);
return {
type: "toggle_pause",
paused: reader.readBoolean(),
};
}
throw new Error(`Unhandled binary intent type: ${intentType}`);
}
function decodeUnit(opcode: number): UnitType {
return opcodeToUnitType(opcode);
}
function assertIntentFlags(
intentType: Intent["type"],
flags: number,
allowedFlags: number,
) {
const invalidFlags = flags & ~allowedFlags;
if (invalidFlags !== 0) {
throw new Error(
`Unsupported flags ${invalidFlags} for binary intent type ${intentType}`,
);
}
}
export function isBinaryGameplayClientMessage(
message: ClientMessage,
): message is BinaryClientGameplayMessage {
return (
message.type === "intent" ||
message.type === "hash" ||
message.type === "ping"
);
}
+235
View File
@@ -0,0 +1,235 @@
import {
ClientHashMessage,
ClientID,
ClientIntentMessage,
ClientPingMessage,
GameStartInfo,
Intent,
ServerDesyncMessage,
ServerTurnMessage,
StampedIntent,
} from "./Schemas";
import { AllPlayers, UnitType } from "./game/Game";
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 enum BinaryMessageType {
Intent = 1,
Turn = 2,
Hash = 3,
Ping = 4,
Desync = 5,
}
export enum BinaryIntentType {
Attack = 1,
CancelAttack = 2,
Spawn = 3,
MarkDisconnected = 4,
BoatAttack = 5,
CancelBoat = 6,
AllianceRequest = 7,
AllianceReject = 8,
BreakAlliance = 9,
TargetPlayer = 10,
Emoji = 11,
DonateGold = 12,
DonateTroops = 13,
BuildUnit = 14,
UpgradeStructure = 15,
Embargo = 16,
EmbargoAll = 17,
MoveWarship = 18,
QuickChat = 19,
AllianceExtension = 20,
DeleteUnit = 21,
TogglePause = 22,
}
export const INTENT_FLAG_OPTION_A = 1 << 0;
export const INTENT_FLAG_OPTION_B = 1 << 1;
export type BinaryClientGameplayMessage =
| ClientIntentMessage
| ClientHashMessage
| ClientPingMessage;
export type BinaryServerGameplayMessage =
| ServerTurnMessage
| ServerDesyncMessage;
const INTENT_TYPE_TO_OPCODE: Record<
Intent["type"],
BinaryIntentType | undefined
> = {
attack: BinaryIntentType.Attack,
cancel_attack: BinaryIntentType.CancelAttack,
spawn: BinaryIntentType.Spawn,
mark_disconnected: BinaryIntentType.MarkDisconnected,
boat: BinaryIntentType.BoatAttack,
cancel_boat: BinaryIntentType.CancelBoat,
allianceRequest: BinaryIntentType.AllianceRequest,
allianceReject: BinaryIntentType.AllianceReject,
breakAlliance: BinaryIntentType.BreakAlliance,
targetPlayer: BinaryIntentType.TargetPlayer,
emoji: BinaryIntentType.Emoji,
donate_gold: BinaryIntentType.DonateGold,
donate_troops: BinaryIntentType.DonateTroops,
build_unit: BinaryIntentType.BuildUnit,
upgrade_structure: BinaryIntentType.UpgradeStructure,
embargo: BinaryIntentType.Embargo,
embargo_all: BinaryIntentType.EmbargoAll,
move_warship: BinaryIntentType.MoveWarship,
quick_chat: BinaryIntentType.QuickChat,
allianceExtension: BinaryIntentType.AllianceExtension,
delete_unit: BinaryIntentType.DeleteUnit,
kick_player: undefined,
toggle_pause: BinaryIntentType.TogglePause,
update_game_config: undefined,
};
const OPCODE_TO_INTENT_TYPE: Record<BinaryIntentType, Intent["type"]> = {
[BinaryIntentType.Attack]: "attack",
[BinaryIntentType.CancelAttack]: "cancel_attack",
[BinaryIntentType.Spawn]: "spawn",
[BinaryIntentType.MarkDisconnected]: "mark_disconnected",
[BinaryIntentType.BoatAttack]: "boat",
[BinaryIntentType.CancelBoat]: "cancel_boat",
[BinaryIntentType.AllianceRequest]: "allianceRequest",
[BinaryIntentType.AllianceReject]: "allianceReject",
[BinaryIntentType.BreakAlliance]: "breakAlliance",
[BinaryIntentType.TargetPlayer]: "targetPlayer",
[BinaryIntentType.Emoji]: "emoji",
[BinaryIntentType.DonateGold]: "donate_gold",
[BinaryIntentType.DonateTroops]: "donate_troops",
[BinaryIntentType.BuildUnit]: "build_unit",
[BinaryIntentType.UpgradeStructure]: "upgrade_structure",
[BinaryIntentType.Embargo]: "embargo",
[BinaryIntentType.EmbargoAll]: "embargo_all",
[BinaryIntentType.MoveWarship]: "move_warship",
[BinaryIntentType.QuickChat]: "quick_chat",
[BinaryIntentType.AllianceExtension]: "allianceExtension",
[BinaryIntentType.DeleteUnit]: "delete_unit",
[BinaryIntentType.TogglePause]: "toggle_pause",
};
const UNIT_TYPE_TO_OPCODE = new Map<UnitType, number>(
Object.values(UnitType).map((type, index) => [type, index + 1]),
);
const OPCODE_TO_UNIT_TYPE = new Map<number, UnitType>(
Object.values(UnitType).map((type, index) => [index + 1, type]),
);
export interface BinaryProtocolContext {
readonly playerIds: readonly ClientID[];
readonly playerIdToIndex: ReadonlyMap<ClientID, number>;
}
export function createBinaryProtocolContext(
gameStartInfo: Pick<GameStartInfo, "players">,
): BinaryProtocolContext {
const playerIds = gameStartInfo.players.map((player) => player.clientID);
const playerIdToIndex = new Map<ClientID, number>();
playerIds.forEach((clientID, index) => {
playerIdToIndex.set(clientID, index);
});
return {
playerIds,
playerIdToIndex,
};
}
export function intentTypeToOpcode(
intentType: Intent["type"],
): BinaryIntentType {
const opcode = INTENT_TYPE_TO_OPCODE[intentType];
if (opcode === undefined) {
throw new Error(`Unsupported binary intent type: ${intentType}`);
}
return opcode;
}
export function opcodeToIntentType(opcode: number): Intent["type"] {
const intentType = OPCODE_TO_INTENT_TYPE[opcode as BinaryIntentType];
if (intentType === undefined) {
throw new Error(`Unknown binary intent opcode: ${opcode}`);
}
return intentType;
}
export function unitTypeToOpcode(unitType: UnitType): number {
const opcode = UNIT_TYPE_TO_OPCODE.get(unitType);
if (opcode === undefined) {
throw new Error(`Unknown unit type: ${unitType}`);
}
return opcode;
}
export function opcodeToUnitType(opcode: number): UnitType {
const unitType = OPCODE_TO_UNIT_TYPE.get(opcode);
if (unitType === undefined) {
throw new Error(`Unknown unit opcode: ${opcode}`);
}
return unitType;
}
export function playerIdToIndex(
playerId: ClientID | null | typeof AllPlayers,
context: BinaryProtocolContext,
): number {
if (playerId === null) {
return NO_PLAYER_INDEX;
}
if (playerId === AllPlayers) {
return ALL_PLAYERS_INDEX;
}
const index = context.playerIdToIndex.get(playerId);
if (index === undefined) {
throw new Error(`Unknown player ID: ${playerId}`);
}
return index;
}
export function playerIndexToId(
playerIndex: number,
context: BinaryProtocolContext,
): ClientID | null | typeof AllPlayers {
if (playerIndex === NO_PLAYER_INDEX) {
return null;
}
if (playerIndex === ALL_PLAYERS_INDEX) {
return AllPlayers;
}
const playerId = context.playerIds[playerIndex];
if (playerId === undefined) {
throw new Error(`Invalid player index: ${playerIndex}`);
}
return playerId;
}
export function requireClientId(
playerIndex: number,
context: BinaryProtocolContext,
): ClientID {
const playerId = playerIndexToId(playerIndex, context);
if (playerId === null || playerId === AllPlayers) {
throw new Error(`Expected client player index, received ${playerIndex}`);
}
return playerId;
}
export function stampedIntentClientIndex(
intent: Pick<StampedIntent, "clientID">,
context: BinaryProtocolContext,
): number {
const index = context.playerIdToIndex.get(intent.clientID);
if (index === undefined) {
throw new Error(`Unknown stamped client ID: ${intent.clientID}`);
}
return index;
}