diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..257acde2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +.git +**/node_modules +**/dist +**/.next +**/coverage +**/.env* +screenshots diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 59fba610..76580fd5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,42 @@ on: - main jobs: + protocol: + name: Shared protocol (typecheck, test, build) + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + cache-dependency-path: packages/protocol/pnpm-lock.yaml + + - name: Install dependencies + working-directory: packages/protocol + run: pnpm install --frozen-lockfile + + - name: Typecheck + working-directory: packages/protocol + run: pnpm typecheck + + - name: Test every client packet round trip + working-directory: packages/protocol + run: pnpm test + + - name: Build + working-directory: packages/protocol + run: pnpm build + api: name: API (typecheck, test, build) runs-on: ubuntu-latest @@ -95,6 +131,7 @@ jobs: server: name: Server (typecheck, test, lint, build) runs-on: ubuntu-latest + needs: protocol steps: - name: Checkout @@ -135,6 +172,7 @@ jobs: frontend: name: Frontend (typecheck, lint, build) runs-on: ubuntu-latest + needs: protocol steps: - name: Checkout @@ -190,13 +228,13 @@ jobs: - name: Build Server image uses: docker/build-push-action@v6 with: - context: ./server + context: . file: ./server/Dockerfile push: false - name: Build Frontend image uses: docker/build-push-action@v6 with: - context: ./frontend + context: . file: ./frontend/Dockerfile push: false diff --git a/README.md b/README.md index 75c2f8f4..7e8739ae 100644 --- a/README.md +++ b/README.md @@ -172,8 +172,12 @@ Abrir `http://localhost:3000`. | API REST, autenticación y datos del juego | `api/` | 3001 | | Server del juego, WebSocket con protocolo binario | `server/` | 7666 | | Frontend, Next.js + PixiJS | `frontend/` | 3000 | +| Contrato binario compartido | `packages/protocol/` | — | | PostgreSQL | `database/aoweb.sql` | 5432 | +La guía para validar cambios del protocolo está en +[`docs/protocol-testing.md`](docs/protocol-testing.md). + ## Contribuir Las issues abiertas están en [github.com/Bitcoindefi/OpenAO/issues](https://github.com/Bitcoindefi/OpenAO/issues). diff --git a/docs/protocol-testing.md b/docs/protocol-testing.md new file mode 100644 index 00000000..e46aef26 --- /dev/null +++ b/docs/protocol-testing.md @@ -0,0 +1,75 @@ +# Cómo probar el protocolo binario compartido + +## 1. Probar el contrato aislado + +Desde la raíz del repositorio: + +```bash +cd packages/protocol +pnpm install --frozen-lockfile +pnpm typecheck +pnpm test +pnpm build +``` + +La suite tiene un fixture y un snapshot de bytes para cada opcode +cliente→servidor. Comprueba tres cosas distintas: + +- que todos los opcodes sean únicos; +- que no pueda existir un opcode sin prueba; +- que `decode(encode(payload))` recupere el mismo payload y conserve el formato + de bytes conocido, incluyendo strings UTF-8. + +## 2. Comprobar ambos consumidores + +```bash +cd server +pnpm install --frozen-lockfile +pnpm exec tsc --noEmit +pnpm exec eslint "src/**/*.ts" +pnpm build +``` + +```bash +cd frontend +pnpm install --frozen-lockfile +pnpm exec tsc --noEmit +pnpm lint +NEXT_PUBLIC_AOWEB_TEST_MODE=true pnpm build +``` + +En PowerShell, la última línea se ejecuta así: + +```powershell +$env:NEXT_PUBLIC_AOWEB_TEST_MODE = "true" +pnpm build +``` + +## 3. Demostrar que el contrato protege ambos lados + +Como prueba local temporal, renombrar `position` a `positionV2` únicamente en +`packages/protocol/src/opcodes.ts` y ejecutar los dos typechecks del paso 2. +Frontend y servidor deben fallar al compilar porque ambos consumen esa clave. +Revertir el cambio después de comprobarlo. + +Si se cambia solamente el valor numérico de un opcode, `pnpm test` debe fallar +contra el snapshot de bytes. Esto evita publicar accidentalmente un cambio de +wire incompatible. + +## 4. Prueba manual dentro del juego + +Levantar API, server y frontend como indica el README. Entrar con un personaje y +recorrer esta lista mientras se observa la consola del server y la del navegador: + +1. Conectar el personaje y caminar en las cuatro direcciones. +2. Escribir en el chat y confirmar que tildes y emoji llegan completos. +3. Usar, equipar, reordenar, tirar, recoger, comprar y vender un objeto. +4. Lanzar ataque cuerpo a cuerpo, a distancia y un hechizo. +5. Abrir banco, cambiar de pestaña, mover un objeto, depositar y retirar oro. +6. Abrir mercado, refrescar y cerrar; abrir retos y refrescar. +7. Abrir crafting y crear un objeto válido. +8. Salir y volver a entrar. + +El resultado esperado es el mismo comportamiento previo, sin mensajes de +`Unknown client packet opcode`, `Cannot read ... bytes` ni `trailing bytes`. +Esos errores indican inmediatamente qué paquete no coincide con el contrato. diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 38d4fb84..07e38025 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -6,14 +6,16 @@ ENV PATH="$PNPM_HOME:$PATH" RUN corepack enable FROM base AS deps -WORKDIR /app +WORKDIR /app/frontend -COPY package.json pnpm-lock.yaml ./ -COPY pnpm-workspace.yaml ./ +COPY packages/protocol/package.json /app/packages/protocol/package.json +COPY packages/protocol/src /app/packages/protocol/src +COPY packages/protocol/tsconfig.json /app/packages/protocol/tsconfig.json +COPY frontend/package.json frontend/pnpm-lock.yaml frontend/pnpm-workspace.yaml ./ RUN pnpm install --frozen-lockfile FROM deps AS build -WORKDIR /app +WORKDIR /app/frontend ARG API_BASE_URL=http://localhost:3001 ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:3001 @@ -40,22 +42,26 @@ ENV SITE_URL=$SITE_URL ENV NEXT_BUILD_ID=$NEXT_BUILD_ID ENV GAME_DATA_ADMIN_PROXY_TOKEN=$GAME_DATA_ADMIN_PROXY_TOKEN -COPY . . +COPY packages/protocol /app/packages/protocol +COPY frontend . RUN pnpm build FROM base AS runtime -WORKDIR /app +WORKDIR /app/frontend ENV NODE_ENV=production ENV PORT=3000 -COPY package.json pnpm-lock.yaml ./ -COPY pnpm-workspace.yaml ./ +COPY packages/protocol/package.json /app/packages/protocol/package.json +COPY packages/protocol/src /app/packages/protocol/src +COPY packages/protocol/tsconfig.json /app/packages/protocol/tsconfig.json +COPY frontend/package.json frontend/pnpm-lock.yaml frontend/pnpm-workspace.yaml ./ RUN pnpm install --prod --frozen-lockfile -COPY --from=build /app/.next ./.next -COPY --from=build /app/public ./public -COPY --from=build /app/next.config.ts ./next.config.ts +COPY --from=build /app/packages/protocol/dist /app/packages/protocol/dist +COPY --from=build /app/frontend/.next ./.next +COPY --from=build /app/frontend/public ./public +COPY --from=build /app/frontend/next.config.ts ./next.config.ts EXPOSE 3000 diff --git a/frontend/docker-compose.blue.yml b/frontend/docker-compose.blue.yml index b7075406..9a0705ad 100644 --- a/frontend/docker-compose.blue.yml +++ b/frontend/docker-compose.blue.yml @@ -3,7 +3,8 @@ name: aoweb-frontend-blue services: frontend: build: - context: . + context: ../ + dockerfile: frontend/Dockerfile args: NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL:-http://host.docker.internal/api} NEXT_PUBLIC_WS_URL: ${NEXT_PUBLIC_WS_URL:-ws://localhost:7666} diff --git a/frontend/docker-compose.green.yml b/frontend/docker-compose.green.yml index a65cabe0..269a886d 100644 --- a/frontend/docker-compose.green.yml +++ b/frontend/docker-compose.green.yml @@ -3,7 +3,8 @@ name: aoweb-frontend-green services: frontend: build: - context: . + context: ../ + dockerfile: frontend/Dockerfile args: NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL:-http://host.docker.internal/api} NEXT_PUBLIC_WS_URL: ${NEXT_PUBLIC_WS_URL:-ws://localhost:7666} diff --git a/frontend/docker-compose.test.yml b/frontend/docker-compose.test.yml index a0a54ceb..d0d2a4dc 100644 --- a/frontend/docker-compose.test.yml +++ b/frontend/docker-compose.test.yml @@ -3,7 +3,8 @@ name: aoweb-frontend-test services: frontend: build: - context: . + context: ../ + dockerfile: frontend/Dockerfile extra_hosts: - "host.docker.internal:host-gateway" args: diff --git a/frontend/docker-compose.yml b/frontend/docker-compose.yml index 3b408c33..9cf58a4a 100644 --- a/frontend/docker-compose.yml +++ b/frontend/docker-compose.yml @@ -1,7 +1,8 @@ services: frontend: build: - context: . + context: ../ + dockerfile: frontend/Dockerfile args: NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL:-http://host.docker.internal/api} NEXT_PUBLIC_WS_URL: ${NEXT_PUBLIC_WS_URL:-ws://localhost:7666} diff --git a/frontend/lib/aowProtocol.ts b/frontend/lib/aowProtocol.ts index 84599869..1d680986 100644 --- a/frontend/lib/aowProtocol.ts +++ b/frontend/lib/aowProtocol.ts @@ -1,83 +1,34 @@ -export const CLIENT_PACKET_ID = { - getMyCharacter: 1, - getCharacter: 2, - changeRopa: 3, - actPosition: 4, - changeHeading: 5, - deleteCharacter: 6, - dialog: 7, - console: 8, - pong: 9, - animFX: 10, - inmo: 11, - updateHP: 12, - updateMaxHP: 13, - updateMana: 14, - telepMe: 15, - actOnline: 19, - consoleOnline: 20, - actPositionServer: 21, - actExp: 22, - actMyLevel: 23, - actGold: 24, - actColorName: 25, - changeHelmet: 26, - changeWeapon: 27, - error: 28, - changeName: 29, - getNpc: 30, - changeShield: 31, - putBodyAndHeadDead: 32, - revivirUsuario: 33, - quitarUserInvItem: 34, - renderItem: 35, - deleteItem: 36, - agregarUserInvItem: 37, - changeArrow: 38, - blockMap: 39, - changeObjIndex: 40, - openTrade: 41, - aprenderSpell: 42, - closeForce: 43, - nameMap: 44, - changeBody: 45, - navegando: 46, - updateAgilidad: 47, - updateFuerza: 48, - playSound: 49, - openBail: 50, - closeBail: 51, - openAdminIntervals: 52, - panelSnapshot: 53, - panelSnapshotChunk: 54, - partyState: 55, - clanState: 56, - characterStatsSnapshot: 57, - characterStatsSnapshotChunk: 58, - startCastBar: 59, - stopCastBar: 60, - openCrafting: 61, - closeTrade: 62, - openMarket: 63, - openRetos: 64, - createProjectile: 65, - spellProjectile: 66, - globalNotice: 67, - batch: 68, - tInmo: 69, - tUpdateHP: 70, - tUpdateMana: 71, - areaCharactersSnapshot: 72, - areaNpcsSnapshot: 73, - areaItemsSnapshot: 74, - areaMetaSnapshot: 75, - moveEntity: 76, - selfFlagsDelta: 77, - selfVitalsDelta: 78, - selfMapMetaDelta: 79, - spellVisual: 80, - entityVitalsDelta: 81, -} as const; +import { + CLIENT_PACKET_ID, + SERVER_PACKET_ID, + PacketReader, + PacketWriter, + encodeClientPacket, + type CraftingMaterial, + type CraftingRecipe, + type CraftingState, + type MarketClaimEntry, + type MarketListingEntry, + type MarketListingGroupEntry, + type MarketPriceSort, + type MarketState, + type RetoEntry, + type RetosState, +} from "@openao/protocol"; + +export { CLIENT_PACKET_ID, SERVER_PACKET_ID, PacketWriter }; +export type { + CraftingMaterial, + CraftingRecipe, + CraftingState, + MarketClaimEntry, + MarketListingEntry, + MarketListingGroupEntry, + MarketPriceSort, + MarketState, + RetoEntry, + RetosState, +}; export type PanelShare = { type: string; @@ -170,39 +121,6 @@ export type CharacterStatsSnapshotChunk = { chunk: string; }; -export const SERVER_PACKET_ID = { - changeHeading: 175, - click: 183, - useItemClick: 197, - equiparItem: 210, - connectCharacter: 212, - position: 176, - dialog: 221, - ping: 184, - attackMele: 229, - attackRange: 236, - attackSpell: 243, - tirarItem: 200, - agarrarItem: 205, - buyItem: 214, - sellItem: 222, - resyncPosition: 187, - changeSeguro: 196, - reorderSpell: 228, - reorderInventoryItem: 235, - toggleHiddenSkill: 244, - useItemU: 203, - changeClanSeguro: 209, - craftItem: 246, - reorderBankItem: 230, - changeBankTab: 216, - depositBankGold: 224, - withdrawBankGold: 237, - closeTrade: 190, - marketAction: 239, - retosAction: 248, -} as const; - export interface CharacterSnapshot { id: number; nameCharacter: string; @@ -335,102 +253,6 @@ export interface BailOffer { canPay: boolean; } -export interface CraftingMaterial { - itemId: number; - name: string; - amount: number; - owned: number; -} - -export interface CraftingRecipe { - itemId: number; - name: string; - grhIndex: number; - details: string; - stats: string; - skill: number; - category: string; - materials: CraftingMaterial[]; -} - -export interface CraftingState { - profession: "carpentry" | "blacksmith" | "tailoring"; - title: string; - recipes: CraftingRecipe[]; -} - -export interface MarketListingEntry { - id: string; - itemId: number; - sellerName: string; - itemName: string; - itemGrhIndex: number; - quantity: number; - price: number; - status: "active" | "sold" | "expired" | "cancelled"; - expiresAt: string; - createdAt: string; -} - -export interface MarketListingGroupEntry { - itemId: number; - itemName: string; - itemGrhIndex: number; - totalListings: number; - totalQuantity: number; - minUnitPrice: number; - listings: MarketListingEntry[]; -} - -export interface MarketClaimEntry { - id: string; - claimType: "gold" | "item"; - goldAmount: number; - itemName: string | null; - itemGrhIndex: number | null; - itemQuantity: number | null; - createdAt: string; -} - -export type MarketPriceSort = "recent" | "asc" | "desc"; - -export interface MarketState { - npcName: string; - publicationFeeBps: number; - defaultDurationHours: number; - maxDurationHours: number; - hasMoreListings: boolean; - listingGroups: MarketListingGroupEntry[]; - myListings: MarketListingEntry[]; - claims: MarketClaimEntry[]; -} - -export interface RetoEntry { - id: string; - createdAt: number; - teamSize: 1 | 2; - proposer: { - id: string; - persistedId: string; - name: string; - level: number; - className: string; - raceName: string; - }; - participants: Array<{ - id: string; - persistedId: string; - name: string; - level: number; - className: string; - raceName: string; - }>; -} - -export interface RetosState { - challenges: RetoEntry[]; -} - export interface PlayerHudState { id?: number; nameCharacter: string; @@ -845,151 +667,6 @@ function parsePlayerTradeItem(reader: PacketReader): TradeItem { }; } -export class PacketWriter { - private bytes: number[] = []; - private encoder = new TextEncoder(); - - constructor(packetId: number) { - this.writeByte(packetId); - } - - writeByte(value: number | boolean): void { - const normalized = - typeof value === "boolean" ? (value ? 1 : 0) : value || 0; - this.bytes.push(normalized & 0xff); - } - - writeShort(value: number): void { - const normalized = value || 0; - this.bytes.push(normalized & 0xff, (normalized >> 8) & 0xff); - } - - writeInt(value: number): void { - const normalized = value || 0; - this.bytes.push( - normalized & 0xff, - (normalized >> 8) & 0xff, - (normalized >> 16) & 0xff, - (normalized >> 24) & 0xff, - ); - } - - writeDouble(value: number): void { - const buffer = new ArrayBuffer(8); - const view = new DataView(buffer); - view.setFloat64(0, value || 0, true); - - for (let index = 0; index < 8; index++) { - this.bytes.push(view.getUint8(index)); - } - } - - writeString(value: string): void { - const normalized = value || ""; - const encoded = this.encoder.encode(normalized); - this.writeShort(Array.from(normalized).length); - for (const byte of encoded) { - this.bytes.push(byte); - } - } - - toArrayBuffer(): ArrayBuffer { - return Uint8Array.from(this.bytes).buffer; - } -} - -class PacketReader { - private view: DataView; - private offset = 0; - private decoder = new TextDecoder(); - - constructor(buffer: ArrayBuffer) { - this.view = new DataView(buffer); - } - - canReadBytes(length: number): boolean { - return this.offset + length <= this.view.byteLength; - } - - getByte(): number { - const value = this.view.getUint8(this.offset); - this.offset += 1; - return value; - } - - getShort(): number { - const value = this.view.getUint16(this.offset, true); - this.offset += 2; - return value; - } - - getInt(): number { - const value = this.view.getUint32(this.offset, true); - this.offset += 4; - return value; - } - - getDouble(): number { - const value = this.view.getFloat64(this.offset, true); - this.offset += 8; - return value; - } - - getString(): string { - const charLength = this.getShort(); - const byteLength = this.getUtf8ByteLength(charLength); - - const bytes = new Uint8Array( - this.view.buffer, - this.view.byteOffset + this.offset, - byteLength, - ); - this.offset += byteLength; - return decodeHtmlEntities(this.decoder.decode(bytes)); - } - - getBytes(length: number): ArrayBuffer { - if (length < 0 || !this.canReadBytes(length)) { - throw new RangeError(`Cannot read ${length} bytes from packet.`); - } - - const bytes = new Uint8Array( - this.view.buffer, - this.view.byteOffset + this.offset, - length, - ); - this.offset += length; - return bytes.slice().buffer; - } - - private getUtf8ByteLength(charLength: number): number { - let byteLength = 0; - let charactersRead = 0; - - while (charactersRead < charLength) { - const currentByte = this.view.getUint8(this.offset + byteLength); - - if ((currentByte & 0x80) === 0) { - byteLength += 1; - } else if ((currentByte & 0xe0) === 0xc0) { - byteLength += 2; - } else if ((currentByte & 0xf0) === 0xe0) { - byteLength += 3; - } else if ((currentByte & 0xf8) === 0xf0) { - byteLength += 4; - } else { - throw new RangeError( - `Invalid UTF-8 leading byte: ${currentByte}`, - ); - } - - charactersRead += 1; - } - - return byteLength; - } -} - function decodeHtmlEntities(value: string): string { const normalized = value.replace( /&(#x?[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]+)(?![a-zA-Z0-9]);?/g, @@ -1897,7 +1574,7 @@ export function parseServerPacket(buffer: ArrayBuffer): ParsedServerPacket { return { type: "unknown", payload: { packetId: -1 } }; } - const reader = new PacketReader(buffer); + const reader = new PacketReader(buffer, decodeHtmlEntities); const packetId = reader.getByte(); return parseServerPacketById(packetId, reader); } @@ -1907,7 +1584,7 @@ export function parseServerFrame(buffer: ArrayBuffer): ParsedServerPacket[] { return [{ type: "unknown", payload: { packetId: -1 } }]; } - const reader = new PacketReader(buffer); + const reader = new PacketReader(buffer, decodeHtmlEntities); const packetId = reader.getByte(); if (packetId !== CLIENT_PACKET_ID.batch) { @@ -1940,7 +1617,7 @@ export function inspectServerFramePacketIds(buffer: ArrayBuffer): number[] { return [-1]; } - const reader = new PacketReader(buffer); + const reader = new PacketReader(buffer, decodeHtmlEntities); const packetId = reader.getByte(); if (packetId !== CLIENT_PACKET_ID.batch) { @@ -1994,26 +1671,22 @@ export function createConnectCharacterPacket(params: { typeGame?: number; idChar?: number; }): ArrayBuffer { - const writer = new PacketWriter(SERVER_PACKET_ID.connectCharacter); - writer.writeString(params.ticket.trim()); - writer.writeByte(params.typeGame ?? 1); - writer.writeByte(params.idChar ?? 0); - return writer.toArrayBuffer(); + return encodeClientPacket("connectCharacter", { + ticket: params.ticket.trim(), + typeGame: params.typeGame ?? 1, + idChar: params.idChar ?? 0, + }).buffer as ArrayBuffer; } export function createPositionPacket( heading: number, moveId: number, ): ArrayBuffer { - const writer = new PacketWriter(SERVER_PACKET_ID.position); - writer.writeByte(heading); - writer.writeInt(moveId); - return writer.toArrayBuffer(); + return encodeClientPacket("position", { heading, moveId }).buffer as ArrayBuffer; } export function createResyncPositionPacket(): ArrayBuffer { - const writer = new PacketWriter(SERVER_PACKET_ID.resyncPosition); - return writer.toArrayBuffer(); + return encodeClientPacket("resyncPosition").buffer as ArrayBuffer; } export function createClickPacket( @@ -2021,97 +1694,69 @@ export function createClickPacket( y: number, button = 0, ): ArrayBuffer { - const writer = new PacketWriter(SERVER_PACKET_ID.click); - writer.writeByte(x); - writer.writeByte(y); - writer.writeByte(button); - return writer.toArrayBuffer(); + return encodeClientPacket("click", { x, y, button }).buffer as ArrayBuffer; } export function createChangeHeadingPacket(heading: number): ArrayBuffer { - const writer = new PacketWriter(SERVER_PACKET_ID.changeHeading); - writer.writeByte(heading); - return writer.toArrayBuffer(); + return encodeClientPacket("changeHeading", { heading }).buffer as ArrayBuffer; } export function createPingPacket(token = 0): ArrayBuffer { - const writer = new PacketWriter(SERVER_PACKET_ID.ping); - writer.writeInt(token); - return writer.toArrayBuffer(); + return encodeClientPacket("ping", { token }).buffer as ArrayBuffer; } export function createChangeSeguroPacket(): ArrayBuffer { - return new PacketWriter(SERVER_PACKET_ID.changeSeguro).toArrayBuffer(); + return encodeClientPacket("changeSeguro").buffer as ArrayBuffer; } export function createChangeClanSeguroPacket(): ArrayBuffer { - return new PacketWriter(SERVER_PACKET_ID.changeClanSeguro).toArrayBuffer(); + return encodeClientPacket("changeClanSeguro").buffer as ArrayBuffer; } export function createDialogPacket(message: string): ArrayBuffer { - const writer = new PacketWriter(SERVER_PACKET_ID.dialog); - writer.writeString(message); - return writer.toArrayBuffer(); + return encodeClientPacket("dialog", { message }).buffer as ArrayBuffer; } export function createEquipItemPacket(slot: number): ArrayBuffer { - const writer = new PacketWriter(SERVER_PACKET_ID.equiparItem); - writer.writeInt(slot); - return writer.toArrayBuffer(); + return encodeClientPacket("equiparItem", { slot }).buffer as ArrayBuffer; } export function createUseItemClickPacket(slot: number): ArrayBuffer { - const writer = new PacketWriter(SERVER_PACKET_ID.useItemClick); - writer.writeInt(slot); - return writer.toArrayBuffer(); + return encodeClientPacket("useItemClick", { slot }).buffer as ArrayBuffer; } export function createUseItemUPacket(slot: number): ArrayBuffer { - const writer = new PacketWriter(SERVER_PACKET_ID.useItemU); - writer.writeInt(slot); - return writer.toArrayBuffer(); + return encodeClientPacket("useItemU", { slot }).buffer as ArrayBuffer; } export function createDropItemPacket( slot: number, amount: number, ): ArrayBuffer { - const writer = new PacketWriter(SERVER_PACKET_ID.tirarItem); - writer.writeInt(slot); - writer.writeShort(amount); - return writer.toArrayBuffer(); + return encodeClientPacket("tirarItem", { slot, amount }).buffer as ArrayBuffer; } export function createPickupItemPacket(): ArrayBuffer { - return new PacketWriter(SERVER_PACKET_ID.agarrarItem).toArrayBuffer(); + return encodeClientPacket("agarrarItem").buffer as ArrayBuffer; } export function createBuyItemPacket(slot: number, amount: number): ArrayBuffer { - const writer = new PacketWriter(SERVER_PACKET_ID.buyItem); - writer.writeByte(slot); - writer.writeShort(amount); - return writer.toArrayBuffer(); + return encodeClientPacket("buyItem", { slot, amount }).buffer as ArrayBuffer; } export function createSellItemPacket( slot: number, amount: number, ): ArrayBuffer { - const writer = new PacketWriter(SERVER_PACKET_ID.sellItem); - writer.writeByte(slot); - writer.writeShort(amount); - return writer.toArrayBuffer(); + return encodeClientPacket("sellItem", { slot, amount }).buffer as ArrayBuffer; } export function createAttackMeleePacket(): ArrayBuffer { - return new PacketWriter(SERVER_PACKET_ID.attackMele).toArrayBuffer(); + return encodeClientPacket("attackMele").buffer as ArrayBuffer; } export function createAttackRangePacket(x: number, y: number): ArrayBuffer { - const writer = new PacketWriter(SERVER_PACKET_ID.attackRange); - writer.writeByte(x); - writer.writeByte(y); - return writer.toArrayBuffer(); + return encodeClientPacket("attackRange", { x, y }).buffer as ArrayBuffer; } export function createAttackSpellPacket( @@ -2120,89 +1765,75 @@ export function createAttackSpellPacket( y: number, preferSelfIfEmpty = false, ): ArrayBuffer { - const writer = new PacketWriter(SERVER_PACKET_ID.attackSpell); - writer.writeByte(spellSlot); - writer.writeByte(x); - writer.writeByte(y); - writer.writeByte(preferSelfIfEmpty ? 1 : 0); - return writer.toArrayBuffer(); + return encodeClientPacket("attackSpell", { + spellSlot, + x, + y, + preferSelfIfEmpty, + }).buffer as ArrayBuffer; } export function createReorderSpellPacket( sourceSlot: number, targetSlot: number, ): ArrayBuffer { - const writer = new PacketWriter(SERVER_PACKET_ID.reorderSpell); - writer.writeByte(sourceSlot); - writer.writeByte(targetSlot); - return writer.toArrayBuffer(); + return encodeClientPacket("reorderSpell", { sourceSlot, targetSlot }).buffer as ArrayBuffer; } export function createReorderInventoryItemPacket( sourceSlot: number, targetSlot: number, ): ArrayBuffer { - const writer = new PacketWriter(SERVER_PACKET_ID.reorderInventoryItem); - writer.writeByte(sourceSlot); - writer.writeByte(targetSlot); - return writer.toArrayBuffer(); + return encodeClientPacket("reorderInventoryItem", { sourceSlot, targetSlot }).buffer as ArrayBuffer; } export function createReorderBankItemPacket( sourceSlot: number, targetSlot: number, ): ArrayBuffer { - const writer = new PacketWriter(SERVER_PACKET_ID.reorderBankItem); - writer.writeByte(sourceSlot); - writer.writeByte(targetSlot); - return writer.toArrayBuffer(); + return encodeClientPacket("reorderBankItem", { sourceSlot, targetSlot }).buffer as ArrayBuffer; } export function createChangeBankTabPacket( tab: "character" | "account" | "clan", ): ArrayBuffer { - const writer = new PacketWriter(SERVER_PACKET_ID.changeBankTab); - writer.writeByte(tab === "account" ? 1 : tab === "clan" ? 2 : 0); - return writer.toArrayBuffer(); + return encodeClientPacket("changeBankTab", { tab }).buffer as ArrayBuffer; } export function createDepositBankGoldPacket(amount: number): ArrayBuffer { - const writer = new PacketWriter(SERVER_PACKET_ID.depositBankGold); - writer.writeInt(amount); - return writer.toArrayBuffer(); + return encodeClientPacket("depositBankGold", { amount }).buffer as ArrayBuffer; } export function createWithdrawBankGoldPacket(amount: number): ArrayBuffer { - const writer = new PacketWriter(SERVER_PACKET_ID.withdrawBankGold); - writer.writeInt(amount); - return writer.toArrayBuffer(); + return encodeClientPacket("withdrawBankGold", { amount }).buffer as ArrayBuffer; } export function createCloseTradePacket(): ArrayBuffer { - return new PacketWriter(SERVER_PACKET_ID.closeTrade).toArrayBuffer(); + return encodeClientPacket("closeTrade").buffer as ArrayBuffer; } export function createMarketActionPacket( action: "refresh" | "create" | "buy" | "cancel" | "claim", payload: Record = {}, ): ArrayBuffer { - const writer = new PacketWriter(SERVER_PACKET_ID.marketAction); - writer.writeString(JSON.stringify({ action, ...payload })); - return writer.toArrayBuffer(); + return encodeClientPacket("marketAction", { + action, + ...payload, + } as { action: typeof action } & Record).buffer as ArrayBuffer; } export function createRetosActionPacket( action: "refresh" | "create" | "join" | "cancel", payload: Record = {}, ): ArrayBuffer { - const writer = new PacketWriter(SERVER_PACKET_ID.retosAction); - writer.writeString(JSON.stringify({ action, ...payload })); - return writer.toArrayBuffer(); + return encodeClientPacket("retosAction", { + action, + ...payload, + } as { action: typeof action } & Record).buffer as ArrayBuffer; } export function createToggleHiddenSkillPacket(): ArrayBuffer { - const writer = new PacketWriter(SERVER_PACKET_ID.toggleHiddenSkill); - return writer.toArrayBuffer(); + return encodeClientPacket("toggleHiddenSkill").buffer as ArrayBuffer; } export function createCraftItemPacket( @@ -2210,13 +1841,11 @@ export function createCraftItemPacket( itemId: number, amount: number, ): ArrayBuffer { - const writer = new PacketWriter(SERVER_PACKET_ID.craftItem); - writer.writeByte( - profession === "blacksmith" ? 1 : profession === "tailoring" ? 2 : 0, - ); - writer.writeInt(itemId); - writer.writeShort(amount); - return writer.toArrayBuffer(); + return encodeClientPacket("craftItem", { + profession, + itemId, + amount, + }).buffer as ArrayBuffer; } export function toPlayerHudState(snapshot: CharacterSnapshot): PlayerHudState { diff --git a/frontend/next.config.ts b/frontend/next.config.ts index 81f1a251..92f5a31e 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -1,4 +1,5 @@ import { execSync } from "node:child_process"; +import path from "node:path"; import type { NextConfig } from "next"; function resolveBuildId(): string { @@ -19,6 +20,10 @@ const buildId = resolveBuildId(); const nextConfig: NextConfig = { allowedDevOrigins: ["127.0.0.1", "localhost"], + transpilePackages: ["@openao/protocol"], + turbopack: { + root: path.resolve(process.cwd(), ".."), + }, env: { NEXT_PUBLIC_NEXT_BUILD_ID: buildId, }, diff --git a/frontend/package.json b/frontend/package.json index b5764534..5d08000d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -3,13 +3,17 @@ "version": "0.1.0", "private": true, "scripts": { + "protocol:build": "pnpm exec tsc -p ../packages/protocol/tsconfig.json", + "predev": "pnpm run protocol:build", "dev": "next dev --turbopack --hostname 0.0.0.0", + "prebuild": "pnpm run protocol:build", "build": "next build", "start": "next start", "lint": "eslint", "generate:headswithfile": "node ./scripts/generate-headswithfile.mjs" }, "dependencies": { + "@openao/protocol": "link:../packages/protocol", "dotenv": "^17.4.2", "howler": "^2.2.4", "lucide-react": "^1.16.0", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 595ebf20..dcd3e0f7 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@openao/protocol': + specifier: link:../packages/protocol + version: link:../packages/protocol dotenv: specifier: ^17.4.2 version: 17.4.2 diff --git a/packages/protocol/README.md b/packages/protocol/README.md new file mode 100644 index 00000000..d076115d --- /dev/null +++ b/packages/protocol/README.md @@ -0,0 +1,17 @@ +# @openao/protocol + +Contrato binario compartido por el frontend y el servidor de OpenAO. + +La fuente de verdad de los opcodes, límites, payloads y codecs vive en `src/`. +Un cambio incompatible se verifica ejecutando: + +```bash +pnpm install +pnpm test +pnpm typecheck +pnpm build +``` + +Las pruebas recorren todos los paquetes cliente→servidor definidos en +`SERVER_PACKET_ID` y exigen que `decode(encode(payload))` recupere exactamente +el mismo payload. diff --git a/packages/protocol/package.json b/packages/protocol/package.json new file mode 100644 index 00000000..1d517cbe --- /dev/null +++ b/packages/protocol/package.json @@ -0,0 +1,32 @@ +{ + "name": "@openao/protocol", + "version": "0.1.0", + "private": true, + "description": "Shared binary protocol contract for the OpenAO client and server", + "main": "dist/index.js", + "types": "src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist", + "src" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "test": "vitest run", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "devDependencies": { + "@types/node": "^25.8.0", + "typescript": "^6.0.3", + "vitest": "^3.2.7" + }, + "engines": { + "node": ">=24" + }, + "license": "ISC" +} diff --git a/packages/protocol/pnpm-lock.yaml b/packages/protocol/pnpm-lock.yaml new file mode 100644 index 00000000..b2a7d3bf --- /dev/null +++ b/packages/protocol/pnpm-lock.yaml @@ -0,0 +1,1035 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@types/node': + specifier: ^25.8.0 + version: 25.9.5 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vitest: + specifier: ^3.2.7 + version: 3.2.7(@types/node@25.9.5) + +packages: + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-android-arm-eabi@4.62.4': + resolution: {integrity: sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.4': + resolution: {integrity: sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.4': + resolution: {integrity: sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.4': + resolution: {integrity: sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.4': + resolution: {integrity: sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.4': + resolution: {integrity: sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.4': + resolution: {integrity: sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.62.4': + resolution: {integrity: sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.62.4': + resolution: {integrity: sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.62.4': + resolution: {integrity: sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.62.4': + resolution: {integrity: sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.62.4': + resolution: {integrity: sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.62.4': + resolution: {integrity: sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.62.4': + resolution: {integrity: sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.62.4': + resolution: {integrity: sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.62.4': + resolution: {integrity: sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.62.4': + resolution: {integrity: sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.62.4': + resolution: {integrity: sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.62.4': + resolution: {integrity: sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.62.4': + resolution: {integrity: sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.4': + resolution: {integrity: sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.4': + resolution: {integrity: sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.4': + resolution: {integrity: sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.4': + resolution: {integrity: sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.4': + resolution: {integrity: sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==} + cpu: [x64] + os: [win32] + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@25.9.5': + resolution: {integrity: sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==} + + '@vitest/expect@3.2.7': + resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==} + + '@vitest/mocker@3.2.7': + resolution: {integrity: sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.7': + resolution: {integrity: sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==} + + '@vitest/runner@3.2.7': + resolution: {integrity: sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==} + + '@vitest/snapshot@3.2.7': + resolution: {integrity: sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==} + + '@vitest/spy@3.2.7': + resolution: {integrity: sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==} + + '@vitest/utils@3.2.7': + resolution: {integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} + engines: {node: ^10 || ^12 || >=14} + + rollup@4.62.4: + resolution: {integrity: sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite@7.3.6: + resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@3.2.7: + resolution: {integrity: sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.7 + '@vitest/ui': 3.2.7 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + +snapshots: + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true + + '@rollup/rollup-android-arm-eabi@4.62.4': + optional: true + + '@rollup/rollup-android-arm64@4.62.4': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.4': + optional: true + + '@rollup/rollup-darwin-x64@4.62.4': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.4': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.4': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.4': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.4': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.4': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.4': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.4': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.4': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.4': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.4': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.4': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.4': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.4': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.4': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.4': + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/node@25.9.5': + dependencies: + undici-types: 7.24.6 + + '@vitest/expect@3.2.7': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.7(vite@7.3.6(@types/node@25.9.5))': + dependencies: + '@vitest/spy': 3.2.7 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.6(@types/node@25.9.5) + + '@vitest/pretty-format@3.2.7': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.7': + dependencies: + '@vitest/utils': 3.2.7 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.7': + dependencies: + tinyspy: 4.0.4 + + '@vitest/utils@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + + assertion-error@2.0.1: {} + + cac@6.7.14: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + check-error@2.1.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-eql@5.0.2: {} + + es-module-lexer@1.7.0: {} + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + fsevents@2.3.3: + optional: true + + js-tokens@9.0.1: {} + + loupe@3.2.1: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + ms@2.1.3: {} + + nanoid@3.3.18: {} + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + postcss@8.5.26: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + rollup@4.62.4: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@rollup/rollup-android-arm-eabi': 4.62.4 + '@rollup/rollup-android-arm64': 4.62.4 + '@rollup/rollup-darwin-arm64': 4.62.4 + '@rollup/rollup-darwin-x64': 4.62.4 + '@rollup/rollup-freebsd-arm64': 4.62.4 + '@rollup/rollup-freebsd-x64': 4.62.4 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.4 + '@rollup/rollup-linux-arm-musleabihf': 4.62.4 + '@rollup/rollup-linux-arm64-gnu': 4.62.4 + '@rollup/rollup-linux-arm64-musl': 4.62.4 + '@rollup/rollup-linux-loong64-gnu': 4.62.4 + '@rollup/rollup-linux-loong64-musl': 4.62.4 + '@rollup/rollup-linux-ppc64-gnu': 4.62.4 + '@rollup/rollup-linux-ppc64-musl': 4.62.4 + '@rollup/rollup-linux-riscv64-gnu': 4.62.4 + '@rollup/rollup-linux-riscv64-musl': 4.62.4 + '@rollup/rollup-linux-s390x-gnu': 4.62.4 + '@rollup/rollup-linux-x64-gnu': 4.62.4 + '@rollup/rollup-linux-x64-musl': 4.62.4 + '@rollup/rollup-openbsd-x64': 4.62.4 + '@rollup/rollup-openharmony-arm64': 4.62.4 + '@rollup/rollup-win32-arm64-msvc': 4.62.4 + '@rollup/rollup-win32-ia32-msvc': 4.62.4 + '@rollup/rollup-win32-x64-gnu': 4.62.4 + '@rollup/rollup-win32-x64-msvc': 4.62.4 + fsevents: 2.3.3 + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} + + typescript@6.0.3: {} + + undici-types@7.24.6: {} + + vite-node@3.2.4(@types/node@25.9.5): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.6(@types/node@25.9.5) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@7.3.6(@types/node@25.9.5): + dependencies: + esbuild: 0.28.2 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + postcss: 8.5.26 + rollup: 4.62.4 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 25.9.5 + fsevents: 2.3.3 + + vitest@3.2.7(@types/node@25.9.5): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.7 + '@vitest/mocker': 3.2.7(vite@7.3.6(@types/node@25.9.5)) + '@vitest/pretty-format': 3.2.7 + '@vitest/runner': 3.2.7 + '@vitest/snapshot': 3.2.7 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.6(@types/node@25.9.5) + vite-node: 3.2.4(@types/node@25.9.5) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.9.5 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 diff --git a/packages/protocol/src/__snapshots__/protocol.test.ts.snap b/packages/protocol/src/__snapshots__/protocol.test.ts.snap new file mode 100644 index 00000000..8d45bf2a --- /dev/null +++ b/packages/protocol/src/__snapshots__/protocol.test.ts.snap @@ -0,0 +1,390 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`client-to-server packet round trips > round-trips agarrarItem 1`] = ` +Uint8Array [ + 205, +] +`; + +exports[`client-to-server packet round trips > round-trips attackMele 1`] = ` +Uint8Array [ + 229, +] +`; + +exports[`client-to-server packet round trips > round-trips attackRange 1`] = ` +Uint8Array [ + 236, + 55, + 44, +] +`; + +exports[`client-to-server packet round trips > round-trips attackSpell 1`] = ` +Uint8Array [ + 243, + 8, + 51, + 49, + 1, +] +`; + +exports[`client-to-server packet round trips > round-trips buyItem 1`] = ` +Uint8Array [ + 214, + 4, + 25, + 0, +] +`; + +exports[`client-to-server packet round trips > round-trips changeBankTab 1`] = ` +Uint8Array [ + 216, + 2, +] +`; + +exports[`client-to-server packet round trips > round-trips changeClanSeguro 1`] = ` +Uint8Array [ + 209, +] +`; + +exports[`client-to-server packet round trips > round-trips changeHeading 1`] = ` +Uint8Array [ + 175, + 3, +] +`; + +exports[`client-to-server packet round trips > round-trips changeSeguro 1`] = ` +Uint8Array [ + 196, +] +`; + +exports[`client-to-server packet round trips > round-trips click 1`] = ` +Uint8Array [ + 183, + 42, + 67, + 1, +] +`; + +exports[`client-to-server packet round trips > round-trips closeTrade 1`] = ` +Uint8Array [ + 190, +] +`; + +exports[`client-to-server packet round trips > round-trips connectCharacter 1`] = ` +Uint8Array [ + 212, + 8, + 0, + 115, + 101, + 115, + 105, + 195, + 179, + 110, + 45, + 240, + 159, + 166, + 138, + 1, + 2, +] +`; + +exports[`client-to-server packet round trips > round-trips craftItem 1`] = ` +Uint8Array [ + 246, + 2, + 146, + 1, + 0, + 0, + 6, + 0, +] +`; + +exports[`client-to-server packet round trips > round-trips depositBankGold 1`] = ` +Uint8Array [ + 224, + 128, + 132, + 30, + 0, +] +`; + +exports[`client-to-server packet round trips > round-trips dialog 1`] = ` +Uint8Array [ + 221, + 16, + 0, + 194, + 161, + 72, + 111, + 108, + 97, + 44, + 32, + 65, + 114, + 103, + 101, + 110, + 116, + 117, + 109, + 33, +] +`; + +exports[`client-to-server packet round trips > round-trips equiparItem 1`] = ` +Uint8Array [ + 210, + 7, + 0, + 0, + 0, +] +`; + +exports[`client-to-server packet round trips > round-trips marketAction 1`] = ` +Uint8Array [ + 239, + 60, + 0, + 123, + 34, + 97, + 99, + 116, + 105, + 111, + 110, + 34, + 58, + 34, + 98, + 117, + 121, + 34, + 44, + 34, + 108, + 105, + 115, + 116, + 105, + 110, + 103, + 73, + 100, + 34, + 58, + 34, + 108, + 105, + 115, + 116, + 105, + 110, + 103, + 45, + 55, + 34, + 44, + 34, + 101, + 120, + 112, + 101, + 99, + 116, + 101, + 100, + 80, + 114, + 105, + 99, + 101, + 34, + 58, + 51, + 53, + 48, + 125, +] +`; + +exports[`client-to-server packet round trips > round-trips ping 1`] = ` +Uint8Array [ + 184, + 64, + 226, + 1, + 0, +] +`; + +exports[`client-to-server packet round trips > round-trips position 1`] = ` +Uint8Array [ + 176, + 4, + 128, + 61, + 241, + 255, +] +`; + +exports[`client-to-server packet round trips > round-trips reorderBankItem 1`] = ` +Uint8Array [ + 230, + 3, + 12, +] +`; + +exports[`client-to-server packet round trips > round-trips reorderInventoryItem 1`] = ` +Uint8Array [ + 235, + 2, + 11, +] +`; + +exports[`client-to-server packet round trips > round-trips reorderSpell 1`] = ` +Uint8Array [ + 228, + 1, + 10, +] +`; + +exports[`client-to-server packet round trips > round-trips resyncPosition 1`] = ` +Uint8Array [ + 187, +] +`; + +exports[`client-to-server packet round trips > round-trips retosAction 1`] = ` +Uint8Array [ + 248, + 45, + 0, + 123, + 34, + 97, + 99, + 116, + 105, + 111, + 110, + 34, + 58, + 34, + 106, + 111, + 105, + 110, + 34, + 44, + 34, + 99, + 104, + 97, + 108, + 108, + 101, + 110, + 103, + 101, + 73, + 100, + 34, + 58, + 34, + 99, + 104, + 97, + 108, + 108, + 101, + 110, + 103, + 101, + 45, + 50, + 34, + 125, +] +`; + +exports[`client-to-server packet round trips > round-trips sellItem 1`] = ` +Uint8Array [ + 222, + 9, + 13, + 0, +] +`; + +exports[`client-to-server packet round trips > round-trips tirarItem 1`] = ` +Uint8Array [ + 200, + 12, + 0, + 0, + 0, + 244, + 1, +] +`; + +exports[`client-to-server packet round trips > round-trips toggleHiddenSkill 1`] = ` +Uint8Array [ + 244, +] +`; + +exports[`client-to-server packet round trips > round-trips useItemClick 1`] = ` +Uint8Array [ + 197, + 18, + 0, + 0, + 0, +] +`; + +exports[`client-to-server packet round trips > round-trips useItemU 1`] = ` +Uint8Array [ + 203, + 20, + 0, + 0, + 0, +] +`; + +exports[`client-to-server packet round trips > round-trips withdrawBankGold 1`] = ` +Uint8Array [ + 237, + 176, + 113, + 11, + 0, +] +`; diff --git a/packages/protocol/src/binary.ts b/packages/protocol/src/binary.ts new file mode 100644 index 00000000..8daa450d --- /dev/null +++ b/packages/protocol/src/binary.ts @@ -0,0 +1,215 @@ +export type BinaryInput = ArrayBuffer | ArrayBufferView; + +export function toUint8Array(input: BinaryInput): Uint8Array { + if (input instanceof ArrayBuffer) { + return new Uint8Array(input); + } + + return new Uint8Array(input.buffer, input.byteOffset, input.byteLength); +} + +function normalizeInteger(value: number | string | boolean | null | undefined): number { + if (value === true) { + return 1; + } + + if (value === false || value == null || value === "") { + return 0; + } + + return Number.parseInt(String(value), 10) || 0; +} + +export class PacketWriter { + private readonly bytes: number[] = []; + private readonly encoder = new TextEncoder(); + + constructor(packetId?: number) { + if (packetId !== undefined) { + this.writeByte(packetId); + } + } + + writeByte(value: number | string | boolean | null | undefined, signed = false): void { + const normalized = normalizeInteger(value); + const buffer = new ArrayBuffer(1); + const view = new DataView(buffer); + + if (signed) { + view.setInt8(0, normalized); + } else { + view.setUint8(0, normalized); + } + + this.bytes.push(view.getUint8(0)); + } + + writeShort(value: number | string | boolean | null | undefined, signed = false): void { + this.writeInteger(value, 2, signed); + } + + writeInt(value: number | string | boolean | null | undefined, signed = false): void { + this.writeInteger(value, 4, signed); + } + + writeFloat(value: number | string | boolean | null | undefined): void { + const buffer = new ArrayBuffer(4); + const view = new DataView(buffer); + view.setFloat32(0, Number(value) || 0, true); + this.pushBuffer(buffer); + } + + writeDouble(value: number | string | boolean | null | undefined): void { + const buffer = new ArrayBuffer(8); + const view = new DataView(buffer); + view.setFloat64(0, Number(value) || 0, true); + this.pushBuffer(buffer); + } + + writeString(value: string | null | undefined): void { + const normalized = value ?? ""; + const encoded = this.encoder.encode(normalized); + this.writeShort(Array.from(normalized).length); + this.bytes.push(...encoded); + } + + toUint8Array(): Uint8Array { + return Uint8Array.from(this.bytes); + } + + toArrayBuffer(): ArrayBuffer { + return this.toUint8Array().buffer as ArrayBuffer; + } + + private writeInteger( + value: number | string | boolean | null | undefined, + byteLength: 2 | 4, + signed: boolean, + ): void { + const normalized = normalizeInteger(value); + const buffer = new ArrayBuffer(byteLength); + const view = new DataView(buffer); + + if (byteLength === 2) { + signed ? view.setInt16(0, normalized, true) : view.setUint16(0, normalized, true); + } else { + signed ? view.setInt32(0, normalized, true) : view.setUint32(0, normalized, true); + } + + this.pushBuffer(buffer); + } + + private pushBuffer(buffer: ArrayBuffer): void { + this.bytes.push(...new Uint8Array(buffer)); + } +} + +export class PacketReader { + private readonly view: DataView; + private readonly decoder = new TextDecoder(); + private offset = 0; + + constructor( + input: BinaryInput, + private readonly transformString: (value: string) => string = (value) => value, + ) { + const bytes = toUint8Array(input); + this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + } + + get remainingBytes(): number { + return this.view.byteLength - this.offset; + } + + canReadBytes(length: number): boolean { + return length >= 0 && this.offset + length <= this.view.byteLength; + } + + getByte(signed = false): number { + this.assertReadable(1); + const value = signed ? this.view.getInt8(this.offset) : this.view.getUint8(this.offset); + this.offset += 1; + return value; + } + + getShort(signed = false): number { + this.assertReadable(2); + const value = signed ? this.view.getInt16(this.offset, true) : this.view.getUint16(this.offset, true); + this.offset += 2; + return value; + } + + getInt(signed = false): number { + this.assertReadable(4); + const value = signed ? this.view.getInt32(this.offset, true) : this.view.getUint32(this.offset, true); + this.offset += 4; + return value; + } + + getFloat(): number { + this.assertReadable(4); + const value = this.view.getFloat32(this.offset, true); + this.offset += 4; + return value; + } + + getDouble(): number { + this.assertReadable(8); + const value = this.view.getFloat64(this.offset, true); + this.offset += 8; + return value; + } + + getString(): string { + const characterLength = this.getShort(); + const byteLength = this.getUtf8ByteLength(characterLength); + this.assertReadable(byteLength); + const bytes = new Uint8Array( + this.view.buffer, + this.view.byteOffset + this.offset, + byteLength, + ); + this.offset += byteLength; + return this.transformString(this.decoder.decode(bytes)); + } + + getBytes(length: number): ArrayBuffer { + this.assertReadable(length); + const bytes = new Uint8Array( + this.view.buffer, + this.view.byteOffset + this.offset, + length, + ); + this.offset += length; + return bytes.slice().buffer; + } + + private getUtf8ByteLength(characterLength: number): number { + let byteLength = 0; + + for (let charactersRead = 0; charactersRead < characterLength; charactersRead += 1) { + this.assertReadable(byteLength + 1); + const currentByte = this.view.getUint8(this.offset + byteLength); + + if ((currentByte & 0x80) === 0) { + byteLength += 1; + } else if ((currentByte & 0xe0) === 0xc0) { + byteLength += 2; + } else if ((currentByte & 0xf0) === 0xe0) { + byteLength += 3; + } else if ((currentByte & 0xf8) === 0xf0) { + byteLength += 4; + } else { + throw new RangeError(`Invalid UTF-8 leading byte: ${currentByte}`); + } + } + + return byteLength; + } + + private assertReadable(length: number): void { + if (!this.canReadBytes(length)) { + throw new RangeError(`Cannot read ${length} bytes from packet; ${this.remainingBytes} remain.`); + } + } +} diff --git a/packages/protocol/src/clientPackets.ts b/packages/protocol/src/clientPackets.ts new file mode 100644 index 00000000..a20b4729 --- /dev/null +++ b/packages/protocol/src/clientPackets.ts @@ -0,0 +1,256 @@ +import { PacketReader, PacketWriter, type BinaryInput } from "./binary.js"; +import { SERVER_PACKET_ID, type ServerPacketName } from "./opcodes.js"; + +export type MarketAction = "refresh" | "create" | "buy" | "cancel" | "claim"; +export type RetosAction = "refresh" | "create" | "join" | "cancel"; +export type CraftingProfession = "carpentry" | "blacksmith" | "tailoring"; +export type BankTab = "character" | "account" | "clan"; + +export type MarketActionPayload = { action: MarketAction } & Record; +export type RetosActionPayload = { action: RetosAction } & Record; + +export type ClientPacketPayloads = { + changeHeading: { heading: number }; + click: { x: number; y: number; button: number }; + useItemClick: { slot: number }; + equiparItem: { slot: number }; + connectCharacter: { ticket: string; typeGame: number; idChar: number }; + position: { heading: number; moveId: number }; + dialog: { message: string }; + ping: { token: number }; + attackMele: undefined; + attackRange: { x: number; y: number }; + attackSpell: { spellSlot: number; x: number; y: number; preferSelfIfEmpty: boolean }; + tirarItem: { slot: number; amount: number }; + agarrarItem: undefined; + buyItem: { slot: number; amount: number }; + sellItem: { slot: number; amount: number }; + resyncPosition: undefined; + changeSeguro: undefined; + reorderSpell: { sourceSlot: number; targetSlot: number }; + reorderInventoryItem: { sourceSlot: number; targetSlot: number }; + toggleHiddenSkill: undefined; + useItemU: { slot: number }; + changeClanSeguro: undefined; + craftItem: { profession: CraftingProfession; itemId: number; amount: number }; + reorderBankItem: { sourceSlot: number; targetSlot: number }; + changeBankTab: { tab: BankTab }; + depositBankGold: { amount: number }; + withdrawBankGold: { amount: number }; + closeTrade: undefined; + marketAction: MarketActionPayload; + retosAction: RetosActionPayload; +}; + +type PacketArguments = ClientPacketPayloads[K] extends undefined + ? [type: K, payload?: undefined] + : [type: K, payload: ClientPacketPayloads[K]]; + +export type DecodedClientPacket = { + [K in ServerPacketName]: { + type: K; + id: (typeof SERVER_PACKET_ID)[K]; + payload: ClientPacketPayloads[K]; + }; +}[ServerPacketName]; + +export function encodeClientPacket(...args: PacketArguments): Uint8Array { + const [type, rawPayload] = args as [ServerPacketName, ClientPacketPayloads[ServerPacketName]]; + const writer = new PacketWriter(SERVER_PACKET_ID[type]); + const payload = rawPayload as Record | undefined; + + switch (type) { + case "connectCharacter": + writer.writeString(String(payload?.ticket ?? "")); + writer.writeByte(Number(payload?.typeGame ?? 0)); + writer.writeByte(Number(payload?.idChar ?? 0)); + break; + case "position": + writer.writeByte(Number(payload?.heading ?? 0)); + writer.writeInt(Number(payload?.moveId ?? 0)); + break; + case "click": + writer.writeByte(Number(payload?.x ?? 0)); + writer.writeByte(Number(payload?.y ?? 0)); + writer.writeByte(Number(payload?.button ?? 0)); + break; + case "changeHeading": + writer.writeByte(Number(payload?.heading ?? 0)); + break; + case "ping": + writer.writeInt(Number(payload?.token ?? 0)); + break; + case "dialog": + writer.writeString(String(payload?.message ?? "")); + break; + case "equiparItem": + case "useItemClick": + case "useItemU": + writer.writeInt(Number(payload?.slot ?? 0)); + break; + case "tirarItem": + writer.writeInt(Number(payload?.slot ?? 0)); + writer.writeShort(Number(payload?.amount ?? 0)); + break; + case "buyItem": + case "sellItem": + writer.writeByte(Number(payload?.slot ?? 0)); + writer.writeShort(Number(payload?.amount ?? 0)); + break; + case "attackRange": + writer.writeByte(Number(payload?.x ?? 0)); + writer.writeByte(Number(payload?.y ?? 0)); + break; + case "attackSpell": + writer.writeByte(Number(payload?.spellSlot ?? 0)); + writer.writeByte(Number(payload?.x ?? 0)); + writer.writeByte(Number(payload?.y ?? 0)); + writer.writeByte(Boolean(payload?.preferSelfIfEmpty)); + break; + case "reorderSpell": + case "reorderInventoryItem": + case "reorderBankItem": + writer.writeByte(Number(payload?.sourceSlot ?? 0)); + writer.writeByte(Number(payload?.targetSlot ?? 0)); + break; + case "changeBankTab": + writer.writeByte(payload?.tab === "account" ? 1 : payload?.tab === "clan" ? 2 : 0); + break; + case "depositBankGold": + case "withdrawBankGold": + writer.writeInt(Number(payload?.amount ?? 0)); + break; + case "marketAction": + case "retosAction": + writer.writeString(JSON.stringify(payload ?? {})); + break; + case "craftItem": + writer.writeByte(payload?.profession === "blacksmith" ? 1 : payload?.profession === "tailoring" ? 2 : 0); + writer.writeInt(Number(payload?.itemId ?? 0)); + writer.writeShort(Number(payload?.amount ?? 0)); + break; + case "attackMele": + case "agarrarItem": + case "resyncPosition": + case "changeSeguro": + case "toggleHiddenSkill": + case "changeClanSeguro": + case "closeTrade": + break; + default: + assertNever(type); + } + + return writer.toUint8Array(); +} + +export function decodeClientPacket(input: BinaryInput): DecodedClientPacket { + const reader = new PacketReader(input); + const id = reader.getByte(); + const type = getServerPacketName(id); + let payload: ClientPacketPayloads[ServerPacketName]; + + switch (type) { + case "connectCharacter": + payload = { ticket: reader.getString(), typeGame: reader.getByte(), idChar: reader.getByte() }; + break; + case "position": + payload = { heading: reader.getByte(), moveId: reader.getInt() }; + break; + case "click": + payload = { x: reader.getByte(), y: reader.getByte(), button: reader.getByte() }; + break; + case "changeHeading": + payload = { heading: reader.getByte() }; + break; + case "ping": + payload = { token: reader.canReadBytes(4) ? reader.getInt() : 0 }; + break; + case "dialog": + payload = { message: reader.getString() }; + break; + case "equiparItem": + case "useItemClick": + case "useItemU": + payload = { slot: reader.getInt() }; + break; + case "tirarItem": + payload = { slot: reader.getInt(), amount: reader.getShort() }; + break; + case "buyItem": + case "sellItem": + payload = { slot: reader.getByte(), amount: reader.getShort() }; + break; + case "attackRange": + payload = { x: reader.getByte(), y: reader.getByte() }; + break; + case "attackSpell": + payload = { + spellSlot: reader.getByte(), + x: reader.getByte(), + y: reader.getByte(), + preferSelfIfEmpty: reader.canReadBytes(1) && reader.getByte() === 1, + }; + break; + case "reorderSpell": + case "reorderInventoryItem": + case "reorderBankItem": + payload = { sourceSlot: reader.getByte(), targetSlot: reader.getByte() }; + break; + case "changeBankTab": { + const rawTab = reader.getByte(); + payload = { tab: rawTab === 1 ? "account" : rawTab === 2 ? "clan" : "character" }; + break; + } + case "depositBankGold": + case "withdrawBankGold": + payload = { amount: reader.getInt() }; + break; + case "marketAction": + payload = JSON.parse(reader.getString() || "{}") as MarketActionPayload; + break; + case "retosAction": + payload = JSON.parse(reader.getString() || "{}") as RetosActionPayload; + break; + case "craftItem": { + const rawProfession = reader.getByte(); + payload = { + profession: rawProfession === 1 ? "blacksmith" : rawProfession === 2 ? "tailoring" : "carpentry", + itemId: reader.getInt(), + amount: reader.getShort(), + }; + break; + } + case "attackMele": + case "agarrarItem": + case "resyncPosition": + case "changeSeguro": + case "toggleHiddenSkill": + case "changeClanSeguro": + case "closeTrade": + payload = undefined; + break; + default: + assertNever(type); + } + + if (reader.remainingBytes !== 0) { + throw new RangeError(`${reader.remainingBytes} trailing bytes remain in ${type} packet.`); + } + + return { type, id: SERVER_PACKET_ID[type], payload } as DecodedClientPacket; +} + +function getServerPacketName(id: number): ServerPacketName { + const entry = Object.entries(SERVER_PACKET_ID).find(([, packetId]) => packetId === id); + + if (!entry) { + throw new RangeError(`Unknown client packet opcode: ${id}.`); + } + + return entry[0] as ServerPacketName; +} + +function assertNever(value: never): never { + throw new Error(`Unhandled client packet type: ${String(value)}.`); +} diff --git a/packages/protocol/src/constants.ts b/packages/protocol/src/constants.ts new file mode 100644 index 00000000..946572ee --- /dev/null +++ b/packages/protocol/src/constants.ts @@ -0,0 +1,8 @@ +export const PROTOCOL_LIMITS = { + mapMinCoordinate: 1, + mapMaxCoordinate: 100, + clientViewRangeX: 10, + clientViewRangeY: 10, + clientViewExtraBottomY: 1, + maxSnapshotChunkCharacters: 60_000, +} as const; diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts new file mode 100644 index 00000000..df361cca --- /dev/null +++ b/packages/protocol/src/index.ts @@ -0,0 +1,5 @@ +export * from "./binary.js"; +export * from "./clientPackets.js"; +export * from "./constants.js"; +export * from "./messages.js"; +export * from "./opcodes.js"; diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts new file mode 100644 index 00000000..81024643 --- /dev/null +++ b/packages/protocol/src/messages.ts @@ -0,0 +1,92 @@ +import type { CraftingProfession } from "./clientPackets.js"; + +export interface CraftingMaterial { + itemId: number; + name: string; + amount: number; + owned: number; +} + +export interface CraftingRecipe { + itemId: number; + name: string; + grhIndex: number; + details: string; + stats: string; + skill: number; + category: string; + materials: CraftingMaterial[]; +} + +export interface CraftingState { + profession: CraftingProfession; + title: string; + recipes: CraftingRecipe[]; +} + +export interface MarketListingEntry { + id: string; + itemId: number; + sellerName: string; + itemName: string; + itemGrhIndex: number; + quantity: number; + price: number; + status: "active" | "sold" | "expired" | "cancelled"; + expiresAt: string; + createdAt: string; +} + +export interface MarketListingGroupEntry { + itemId: number; + itemName: string; + itemGrhIndex: number; + totalListings: number; + totalQuantity: number; + minUnitPrice: number; + listings: MarketListingEntry[]; +} + +export interface MarketClaimEntry { + id: string; + claimType: "gold" | "item"; + goldAmount: number; + itemName: string | null; + itemGrhIndex: number | null; + itemQuantity: number | null; + createdAt: string; +} + +export type MarketPriceSort = "recent" | "asc" | "desc"; + +export interface MarketState { + npcName: string; + publicationFeeBps: number; + defaultDurationHours: number; + maxDurationHours: number; + hasMoreListings: boolean; + listingGroups: MarketListingGroupEntry[]; + myListings: MarketListingEntry[]; + claims: MarketClaimEntry[]; +} + +export interface RetoParticipant { + id: string; + persistedId: string; + name: string; + level: number; + className: string; + raceName: string; +} + +export interface RetoEntry { + id: string; + createdAt: number; + teamSize: 1 | 2; + proposer: RetoParticipant; + participants: RetoParticipant[]; +} + +export interface RetosState { + challenges: RetoEntry[]; +} diff --git a/packages/protocol/src/opcodes.ts b/packages/protocol/src/opcodes.ts new file mode 100644 index 00000000..1359bae9 --- /dev/null +++ b/packages/protocol/src/opcodes.ts @@ -0,0 +1,120 @@ +export const CLIENT_PACKET_ID = { + getMyCharacter: 1, + getCharacter: 2, + changeRopa: 3, + actPosition: 4, + changeHeading: 5, + deleteCharacter: 6, + dialog: 7, + console: 8, + pong: 9, + animFX: 10, + inmo: 11, + updateHP: 12, + updateMaxHP: 13, + updateMana: 14, + telepMe: 15, + actOnline: 19, + consoleOnline: 20, + actPositionServer: 21, + actExp: 22, + actMyLevel: 23, + actGold: 24, + actColorName: 25, + changeHelmet: 26, + changeWeapon: 27, + error: 28, + changeName: 29, + getNpc: 30, + changeShield: 31, + putBodyAndHeadDead: 32, + revivirUsuario: 33, + quitarUserInvItem: 34, + renderItem: 35, + deleteItem: 36, + agregarUserInvItem: 37, + changeArrow: 38, + blockMap: 39, + changeObjIndex: 40, + openTrade: 41, + aprenderSpell: 42, + closeForce: 43, + nameMap: 44, + changeBody: 45, + navegando: 46, + updateAgilidad: 47, + updateFuerza: 48, + playSound: 49, + openBail: 50, + closeBail: 51, + openAdminIntervals: 52, + panelSnapshot: 53, + panelSnapshotChunk: 54, + partyState: 55, + clanState: 56, + characterStatsSnapshot: 57, + characterStatsSnapshotChunk: 58, + startCastBar: 59, + stopCastBar: 60, + openCrafting: 61, + closeTrade: 62, + openMarket: 63, + openRetos: 64, + createProjectile: 65, + spellProjectile: 66, + globalNotice: 67, + batch: 68, + tInmo: 69, + tUpdateHP: 70, + tUpdateMana: 71, + areaCharactersSnapshot: 72, + areaNpcsSnapshot: 73, + areaItemsSnapshot: 74, + areaMetaSnapshot: 75, + moveEntity: 76, + selfFlagsDelta: 77, + selfVitalsDelta: 78, + selfMapMetaDelta: 79, + spellVisual: 80, + entityVitalsDelta: 81, +} as const; + +export const SERVER_PACKET_ID = { + changeHeading: 175, + click: 183, + useItemClick: 197, + equiparItem: 210, + connectCharacter: 212, + position: 176, + dialog: 221, + ping: 184, + attackMele: 229, + attackRange: 236, + attackSpell: 243, + tirarItem: 200, + agarrarItem: 205, + buyItem: 214, + sellItem: 222, + resyncPosition: 187, + changeSeguro: 196, + reorderSpell: 228, + reorderInventoryItem: 235, + toggleHiddenSkill: 244, + useItemU: 203, + changeClanSeguro: 209, + craftItem: 246, + reorderBankItem: 230, + changeBankTab: 216, + depositBankGold: 224, + withdrawBankGold: 237, + closeTrade: 190, + marketAction: 239, + retosAction: 248, +} as const; + +export type ClientPacketIdMap = typeof CLIENT_PACKET_ID; +export type ServerPacketIdMap = typeof SERVER_PACKET_ID; +export type ClientPacketName = keyof ClientPacketIdMap; +export type ServerPacketName = keyof ServerPacketIdMap; +export type ClientPacketId = ClientPacketIdMap[ClientPacketName]; +export type ServerPacketId = ServerPacketIdMap[ServerPacketName]; diff --git a/packages/protocol/src/protocol.test.ts b/packages/protocol/src/protocol.test.ts new file mode 100644 index 00000000..0a647480 --- /dev/null +++ b/packages/protocol/src/protocol.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "vitest"; +import { + CLIENT_PACKET_ID, + SERVER_PACKET_ID, + PacketReader, + PacketWriter, + decodeClientPacket, + encodeClientPacket, + type ClientPacketPayloads, + type ServerPacketName, +} from "./index.js"; + +const fixtures = { + changeHeading: { heading: 3 }, + click: { x: 42, y: 67, button: 1 }, + useItemClick: { slot: 18 }, + equiparItem: { slot: 7 }, + connectCharacter: { ticket: "sesión-🦊", typeGame: 1, idChar: 2 }, + position: { heading: 4, moveId: 4_294_000_000 }, + dialog: { message: "¡Hola, Argentum!" }, + ping: { token: 123_456 }, + attackMele: undefined, + attackRange: { x: 55, y: 44 }, + attackSpell: { spellSlot: 8, x: 51, y: 49, preferSelfIfEmpty: true }, + tirarItem: { slot: 12, amount: 500 }, + agarrarItem: undefined, + buyItem: { slot: 4, amount: 25 }, + sellItem: { slot: 9, amount: 13 }, + resyncPosition: undefined, + changeSeguro: undefined, + reorderSpell: { sourceSlot: 1, targetSlot: 10 }, + reorderInventoryItem: { sourceSlot: 2, targetSlot: 11 }, + toggleHiddenSkill: undefined, + useItemU: { slot: 20 }, + changeClanSeguro: undefined, + craftItem: { profession: "tailoring", itemId: 402, amount: 6 }, + reorderBankItem: { sourceSlot: 3, targetSlot: 12 }, + changeBankTab: { tab: "clan" }, + depositBankGold: { amount: 2_000_000 }, + withdrawBankGold: { amount: 750_000 }, + closeTrade: undefined, + marketAction: { action: "buy", listingId: "listing-7", expectedPrice: 350 }, + retosAction: { action: "join", challengeId: "challenge-2" }, +} satisfies { [K in ServerPacketName]: ClientPacketPayloads[K] }; + +describe("packet opcodes", () => { + it("keeps every opcode unique within its direction", () => { + const clientIds = Object.values(CLIENT_PACKET_ID); + const serverIds = Object.values(SERVER_PACKET_ID); + + expect(new Set(clientIds).size).toBe(clientIds.length); + expect(new Set(serverIds).size).toBe(serverIds.length); + }); + + it("has a round-trip fixture for every client-to-server packet", () => { + expect(Object.keys(fixtures).sort()).toEqual(Object.keys(SERVER_PACKET_ID).sort()); + }); +}); + +describe("client-to-server packet round trips", () => { + const encode = encodeClientPacket as (type: ServerPacketName, payload?: unknown) => Uint8Array; + + for (const type of Object.keys(fixtures) as ServerPacketName[]) { + it(`round-trips ${type}`, () => { + const payload = fixtures[type]; + const encoded = encode(type, payload); + const decoded = decodeClientPacket(encoded); + + expect(encoded).toMatchSnapshot(); + expect(decoded.type).toBe(type); + expect(decoded.id).toBe(SERVER_PACKET_ID[type]); + expect(decoded.payload).toEqual(payload); + }); + } +}); + +describe("binary primitives", () => { + it("round-trips every primitive and preserves Unicode character lengths", () => { + const writer = new PacketWriter(); + writer.writeByte(255); + writer.writeByte(-12, true); + writer.writeShort(65_535); + writer.writeShort(-12_345, true); + writer.writeInt(4_294_967_295); + writer.writeInt(-123_456_789, true); + writer.writeFloat(123.5); + writer.writeDouble(Math.PI); + writer.writeString("áéí 🦊"); + + const reader = new PacketReader(writer.toUint8Array()); + expect(reader.getByte()).toBe(255); + expect(reader.getByte(true)).toBe(-12); + expect(reader.getShort()).toBe(65_535); + expect(reader.getShort(true)).toBe(-12_345); + expect(reader.getInt()).toBe(4_294_967_295); + expect(reader.getInt(true)).toBe(-123_456_789); + expect(reader.getFloat()).toBe(123.5); + expect(reader.getDouble()).toBe(Math.PI); + expect(reader.getString()).toBe("áéí 🦊"); + expect(reader.remainingBytes).toBe(0); + }); + + it("rejects unknown, truncated, and trailing packet bytes", () => { + expect(() => decodeClientPacket(Uint8Array.of(255))).toThrow(/Unknown/); + expect(() => decodeClientPacket(Uint8Array.of(SERVER_PACKET_ID.position, 1))).toThrow(/Cannot read/); + + const valid = encodeClientPacket("changeHeading", { heading: 2 }); + expect(() => decodeClientPacket(Uint8Array.from([...valid, 99]))).toThrow(/trailing bytes/); + }); +}); diff --git a/packages/protocol/tsconfig.json b/packages/protocol/tsconfig.json new file mode 100644 index 00000000..fbc35fa8 --- /dev/null +++ b/packages/protocol/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022", "DOM"], + "declaration": true, + "strict": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "outDir": "dist", + "rootDir": "src", + "skipLibCheck": true + }, + "include": ["src/**/*.ts"], + "exclude": ["dist", "node_modules", "**/*.test.ts"] +} diff --git a/server/Dockerfile b/server/Dockerfile index 3b35308c..b622d915 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -6,30 +6,36 @@ ENV PATH="$PNPM_HOME:$PATH" RUN corepack enable FROM base AS deps -WORKDIR /app +WORKDIR /app/server -COPY package.json pnpm-lock.yaml ./ -COPY pnpm-workspace.yaml ./ +COPY packages/protocol/package.json /app/packages/protocol/package.json +COPY packages/protocol/src /app/packages/protocol/src +COPY packages/protocol/tsconfig.json /app/packages/protocol/tsconfig.json +COPY server/package.json server/pnpm-lock.yaml server/pnpm-workspace.yaml ./ RUN pnpm install --frozen-lockfile FROM deps AS build -WORKDIR /app +WORKDIR /app/server -COPY . . +COPY packages/protocol /app/packages/protocol +COPY server . RUN pnpm build FROM base AS runtime -WORKDIR /app +WORKDIR /app/server ENV NODE_ENV=production -COPY package.json pnpm-lock.yaml ./ -COPY pnpm-workspace.yaml ./ +COPY packages/protocol/package.json /app/packages/protocol/package.json +COPY packages/protocol/src /app/packages/protocol/src +COPY packages/protocol/tsconfig.json /app/packages/protocol/tsconfig.json +COPY server/package.json server/pnpm-lock.yaml server/pnpm-workspace.yaml ./ RUN pnpm install --prod --frozen-lockfile -COPY --from=build /app/dist ./dist -COPY --from=build /app/jsons ./jsons -COPY --from=build /app/mapas_source ./mapas_source +COPY --from=build /app/packages/protocol/dist /app/packages/protocol/dist +COPY --from=build /app/server/dist ./dist +COPY --from=build /app/server/jsons ./jsons +COPY --from=build /app/server/mapas_source ./mapas_source EXPOSE 7666 diff --git a/server/docker-compose.test.yml b/server/docker-compose.test.yml index 69d8e038..65509408 100644 --- a/server/docker-compose.test.yml +++ b/server/docker-compose.test.yml @@ -3,7 +3,8 @@ name: aoweb-server-test services: server: build: - context: . + context: ../ + dockerfile: server/Dockerfile restart: unless-stopped volumes: - ./jsons/npcsInMap.json:/app/jsons/npcsInMap.json diff --git a/server/docker-compose.yml b/server/docker-compose.yml index ead311c8..8549724d 100644 --- a/server/docker-compose.yml +++ b/server/docker-compose.yml @@ -1,7 +1,8 @@ services: server: build: - context: . + context: ../ + dockerfile: server/Dockerfile restart: unless-stopped volumes: - ./jsons/npcsInMap.json:/app/jsons/npcsInMap.json diff --git a/server/package.json b/server/package.json index a702f5ad..4165e8ad 100644 --- a/server/package.json +++ b/server/package.json @@ -4,8 +4,8 @@ "description": "", "main": "dist/server.js", "dependencies": { + "@openao/protocol": "link:../packages/protocol", "axios": "^1.16.1", - "bytebuffer": "^5.0.1", "https": "^1.0.0", "lodash": "^4.18.1", "ws": "^8.20.1" @@ -22,8 +22,11 @@ "typescript-eslint": "^8.59.3" }, "scripts": { - "clean": "rm -rf dist", + "protocol:build": "pnpm exec tsc -p ../packages/protocol/tsconfig.json", + "prebuild": "pnpm run protocol:build", + "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"", "build": "pnpm run clean && tsc && node scripts/copy-assets.cjs", + "predev": "pnpm run protocol:build", "dev": "NODE_ENV=development tsx watch src/server.ts", "test": "tsx --test tests/**/*.test.ts", "compact-objs": "tsx src/scripts/compactObjsJson.ts", @@ -34,7 +37,7 @@ "export-client-objs": "tsx src/scripts/exportClientObjs.ts", "test:client-activity": "tsx src/scripts/testClientActivityPolicy.ts", "start": "node dist/server.js", - "lint": "eslint './src/**/*.ts'", + "lint": "eslint \"./src/**/*.ts\"", "world-map": "node scripts/build-world-map.cjs", "compare-original-data": "tsx src/scripts/compareOriginalData.ts" }, diff --git a/server/pnpm-lock.yaml b/server/pnpm-lock.yaml index 3e272255..b0fbec8b 100644 --- a/server/pnpm-lock.yaml +++ b/server/pnpm-lock.yaml @@ -8,12 +8,12 @@ importers: .: dependencies: + '@openao/protocol': + specifier: link:../packages/protocol + version: link:../packages/protocol axios: specifier: ^1.16.1 version: 1.16.1 - bytebuffer: - specifier: ^5.0.1 - version: 5.0.1 https: specifier: ^1.0.0 version: 1.0.0 @@ -374,10 +374,6 @@ packages: resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} engines: {node: 18 || 20 || >=22} - bytebuffer@5.0.1: - resolution: {integrity: sha512-IuzSdmADppkZ6DlpycMkm8l9zeEq16fWtLvunEwFiYciR/BHo4E8/xs5piFquG+Za8OWmMqHF8zuRviz2LHvRQ==} - engines: {node: '>=0.8'} - call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -616,10 +612,6 @@ packages: lodash@4.18.1: resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} - long@3.2.0: - resolution: {integrity: sha512-ZYvPPOMqUwPoDsbJaR10iQJYnMuZhRTvHYl62ErLIEX7RgFlziSBUUvrt3OVfc47QlHHpzPZYP17g3Fv7oeJkg==} - engines: {node: '>=0.6'} - math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -1028,10 +1020,6 @@ snapshots: dependencies: balanced-match: 4.0.4 - bytebuffer@5.0.1: - dependencies: - long: 3.2.0 - call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -1292,8 +1280,6 @@ snapshots: lodash@4.18.1: {} - long@3.2.0: {} - math-intrinsics@1.1.0: {} mime-db@1.52.0: {} diff --git a/server/pnpm-workspace.yaml b/server/pnpm-workspace.yaml index 118ff3f0..daf11c40 100644 --- a/server/pnpm-workspace.yaml +++ b/server/pnpm-workspace.yaml @@ -1,2 +1,2 @@ allowBuilds: - esbuild: false + esbuild: true diff --git a/server/src/challengeManager.ts b/server/src/challengeManager.ts index e2c0b09c..68a37b37 100644 --- a/server/src/challengeManager.ts +++ b/server/src/challengeManager.ts @@ -1,3 +1,4 @@ +import type { RetoEntry } from "@openao/protocol"; import type { GameApi } from "./game"; import type { HandleProtocolApi } from "./handleProtocol"; import type { SocketApi } from "./socket"; @@ -88,13 +89,7 @@ type ActiveMatch = { finished: boolean; }; -type ChallengeListItem = { - id: string; - createdAt: number; - teamSize: TeamSize; - proposer: ReturnType; - participants: Array>; -}; +type ChallengeListItem = RetoEntry; function getGame() { return require("./game") as GameApi; diff --git a/server/src/game.ts b/server/src/game.ts index 21b2edc1..fd52373d 100644 --- a/server/src/game.ts +++ b/server/src/game.ts @@ -1,3 +1,9 @@ +import type { + MarketClaimEntry, + MarketListingEntry, + MarketListingGroupEntry, + MarketState, +} from "@openao/protocol"; import { getFactionColor, getFactionConfig, @@ -2746,38 +2752,11 @@ type MarketCommandResult = { message: string; }; -type MarketUiListing = { - id: string; - itemId: number; - sellerName: string; - itemName: string; - itemGrhIndex: number; - quantity: number; - price: number; - status: "active" | "sold" | "expired" | "cancelled"; - expiresAt: string; - createdAt: string; -}; +type MarketUiListing = MarketListingEntry; -type MarketUiListingGroup = { - itemId: number; - itemName: string; - itemGrhIndex: number; - totalListings: number; - totalQuantity: number; - minUnitPrice: number; - listings: MarketUiListing[]; -}; +type MarketUiListingGroup = MarketListingGroupEntry; -type MarketUiClaim = { - id: string; - claimType: "gold" | "item"; - goldAmount: number; - itemName: string | null; - itemGrhIndex: number | null; - itemQuantity: number | null; - createdAt: string; -}; +type MarketUiClaim = MarketClaimEntry; type MarketBrowseOptions = { listingLimit?: number; @@ -2786,16 +2765,7 @@ type MarketBrowseOptions = { sortPrice?: "recent" | "asc" | "desc"; }; -type MarketUiState = { - npcName: string; - publicationFeeBps: number; - defaultDurationHours: number; - maxDurationHours: number; - hasMoreListings: boolean; - listingGroups: MarketUiListingGroup[]; - myListings: MarketUiListing[]; - claims: MarketUiClaim[]; -}; +type MarketUiState = MarketState; function normalizeFaction(value: unknown): CharacterFaction { return value === "armada" || value === "caos" ? value : "none"; diff --git a/server/src/handleProtocol.ts b/server/src/handleProtocol.ts index 01e46025..01aafd59 100644 --- a/server/src/handleProtocol.ts +++ b/server/src/handleProtocol.ts @@ -1,3 +1,9 @@ +import { + PROTOCOL_LIMITS, + type CraftingState, + type MarketState, + type RetosState, +} from "@openao/protocol"; import type { PackageApi } from "./package"; import type { SocketApi } from "./socket"; import { getRequiredFactionForItem } from "./factions"; @@ -22,90 +28,6 @@ export {}; type ConsoleChannel = "console" | "global" | "party" | "clan" | "whisper"; -type CraftingOpenPayload = { - profession: "carpentry" | "blacksmith" | "tailoring"; - title: string; - recipes: Array<{ - itemId: number; - name: string; - grhIndex: number; - details: string; - stats: string; - skill: number; - category: string; - materials: Array<{ - itemId: number; - name: string; - amount: number; - owned: number; - }>; - }>; -}; - -type MarketOpenPayload = { - npcName: string; - publicationFeeBps: number; - defaultDurationHours: number; - maxDurationHours: number; - listingGroups: Array<{ - itemId: number; - itemName: string; - itemGrhIndex: number; - totalListings: number; - totalQuantity: number; - minUnitPrice: number; - listings: Array<{ - id: string; - sellerName: string; - itemName: string; - itemGrhIndex: number; - quantity: number; - price: number; - status: "active" | "sold" | "expired" | "cancelled"; - expiresAt: string; - createdAt: string; - }>; - }>; - myListings: Array<{ - id: string; - sellerName: string; - itemName: string; - itemGrhIndex: number; - quantity: number; - price: number; - status: "active" | "sold" | "expired" | "cancelled"; - expiresAt: string; - createdAt: string; - }>; - claims: Array<{ - id: string; - claimType: "gold" | "item"; - goldAmount: number; - itemId: number | null; - itemName: string | null; - itemGrhIndex: number | null; - itemQuantity: number | null; - sourceListingId: string | null; - createdAt: string; - }>; -}; - -type RetosOpenPayload = { - challenges: Array<{ - id: string; - createdAt: number; - teamSize: 1 | 2; - proposer: { - id: string; - persistedId: string; - name: string; - level: number; - className: string; - raceName: string; - }; - }>; -}; - type AreaItemSnapshot = { idItem: number; map: number; @@ -146,7 +68,7 @@ const socket = require("./socket") as SocketApi; const funct = require("./functions"); const balance = require("./balance"); -const PANEL_SNAPSHOT_CHUNK_CHAR_LIMIT = 60000; +const PANEL_SNAPSHOT_CHUNK_CHAR_LIMIT = PROTOCOL_LIMITS.maxSnapshotChunkCharacters; function splitSnapshotIntoChunks(serializedSnapshot: string) { const characters = Array.from(serializedSnapshot); @@ -385,9 +307,9 @@ export type HandleProtocolApi = { agregarUserInvItem: (idUser: EntityId, idPos: number | string, client: RuntimeClient) => void; blockMap: (idMap: number, pos: Position, block: number | boolean, client: RuntimeClient) => void; openTrade: (idUser: EntityId, idNpc: EntityId, client: RuntimeClient) => void; - openCrafting: (payload: CraftingOpenPayload, client: RuntimeClient) => void; - openMarket: (payload: MarketOpenPayload, client: RuntimeClient) => void; - openRetos: (payload: RetosOpenPayload, client: RuntimeClient) => void; + openCrafting: (payload: CraftingState, client: RuntimeClient) => void; + openMarket: (payload: MarketState, client: RuntimeClient) => void; + openRetos: (payload: RetosState, client: RuntimeClient) => void; aprenderSpell: (idUser: EntityId, idPosSpell: number | string) => void; closeForce: (idUser: EntityId) => void; nameMap: (idUser: EntityId) => void; diff --git a/server/src/package.ts b/server/src/package.ts index d7a6f3f3..fd51e424 100644 --- a/server/src/package.ts +++ b/server/src/package.ts @@ -1,165 +1,22 @@ -export {}; - -type ByteBufferLike = { - offset: number; - limit: number; - writeInt8: (value: number) => void; - writeUint8: (value: number) => void; - writeInt16: (value: number) => void; - writeUint16: (value: number) => void; - writeInt32: (value: number) => void; - writeUint32: (value: number) => void; - writeFloat: (value: number) => void; - writeDouble: (value: number) => void; - writeString: (value: string) => void; - readInt8: () => number; - readUint8: () => number; - readInt16: () => number; - readUint16: () => number; - readInt32: () => number; - readUint32: () => number; - readFloat: () => number; - readDouble: () => number; - readString: (length: number, metrics: number) => string; - flip: () => void; - toBuffer: () => Buffer; -}; - -type ByteBufferConstructor = { - new (capacity: number, littleEndian: boolean): ByteBufferLike; - DEFAULT_CAPACITY: number; - METRICS_CHARS: number; - calculateUTF8Chars: (value: string) => number; - wrap: (value: Buffer, encoding: "utf8", littleEndian: boolean) => ByteBufferLike; -}; - -const ByteBuffer = require("bytebuffer") as ByteBufferConstructor; - -const clientPacketID = { - getMyCharacter: 1, - getCharacter: 2, - changeRopa: 3, - actPosition: 4, - changeHeading: 5, - deleteCharacter: 6, - dialog: 7, - console: 8, - pong: 9, - animFX: 10, - inmo: 11, - updateHP: 12, - updateMaxHP: 13, - updateMana: 14, - telepMe: 15, - actOnline: 19, - consoleOnline: 20, - actPositionServer: 21, - actExp: 22, - actMyLevel: 23, - actGold: 24, - actColorName: 25, - changeHelmet: 26, - changeWeapon: 27, - error: 28, - changeName: 29, - getNpc: 30, - changeShield: 31, - putBodyAndHeadDead: 32, - revivirUsuario: 33, - quitarUserInvItem: 34, - renderItem: 35, - deleteItem: 36, - agregarUserInvItem: 37, - changeArrow: 38, - blockMap: 39, - changeObjIndex: 40, - openTrade: 41, - aprenderSpell: 42, - closeForce: 43, - nameMap: 44, - changeBody: 45, - navegando: 46, - updateAgilidad: 47, - updateFuerza: 48, - playSound: 49, - openBail: 50, - closeBail: 51, - openAdminIntervals: 52, - panelSnapshot: 53, - panelSnapshotChunk: 54, - partyState: 55, - clanState: 56, - characterStatsSnapshot: 57, - characterStatsSnapshotChunk: 58, - startCastBar: 59, - stopCastBar: 60, - openCrafting: 61, - closeTrade: 62, - openMarket: 63, - openRetos: 64, - createProjectile: 65, - spellProjectile: 66, - globalNotice: 67, - batch: 68, - tInmo: 69, - tUpdateHP: 70, - tUpdateMana: 71, - areaCharactersSnapshot: 72, - areaNpcsSnapshot: 73, - areaItemsSnapshot: 74, - areaMetaSnapshot: 75, - moveEntity: 76, - selfFlagsDelta: 77, - selfVitalsDelta: 78, - selfMapMetaDelta: 79, - spellVisual: 80, - entityVitalsDelta: 81, -} as const; - -const serverPacketID = { - changeHeading: 175, - click: 183, - useItemClick: 197, - equiparItem: 210, - connectCharacter: 212, - position: 176, - dialog: 221, - ping: 184, - attackMele: 229, - attackRange: 236, - attackSpell: 243, - tirarItem: 200, - agarrarItem: 205, - buyItem: 214, - sellItem: 222, - resyncPosition: 187, - changeSeguro: 196, - reorderSpell: 228, - reorderInventoryItem: 235, - toggleHiddenSkill: 244, - useItemU: 203, - changeClanSeguro: 209, - craftItem: 246, - reorderBankItem: 230, - changeBankTab: 216, - depositBankGold: 224, - withdrawBankGold: 237, - closeTrade: 190, - marketAction: 239, - retosAction: 248, -} as const; +import { + CLIENT_PACKET_ID, + SERVER_PACKET_ID, + PacketReader, + PacketWriter, + decodeClientPacket, + type DecodedClientPacket, +} from "@openao/protocol"; type PacketChunk = Buffer | ArrayBuffer | ArrayBufferView | string; -export type ClientPacketID = typeof clientPacketID; -export type ServerPacketID = typeof serverPacketID; +export type ClientPacketID = typeof CLIENT_PACKET_ID; +export type ServerPacketID = typeof SERVER_PACKET_ID; export type PacketPayload = PacketChunk | ReadonlyArray; export type PackageApi = { clientPacketID: ClientPacketID; serverPacketID: ServerPacketID; - bufferRcv: ByteBufferLike; - bufferSnd: ByteBufferLike; + decodeClientPacket: (data: PacketPayload) => DecodedClientPacket; setData: (data: PacketPayload) => void; getPackageID: () => number; setPackageID: (packageID: number) => void; @@ -180,23 +37,7 @@ export type PackageApi = { encodeBatchFrame: (buffers: readonly Buffer[]) => Buffer; }; -function createByteBuffer() { - return new ByteBuffer(ByteBuffer.DEFAULT_CAPACITY, true); -} - -function normalizeNumericInput(value: number | string | boolean | null | undefined) { - if (value === true) { - return 1; - } - - if (value === false || value == null || value === "") { - return 0; - } - - return Number.parseInt(String(value), 10) || 0; -} - -function toBuffer(chunk: PacketChunk) { +function toBuffer(chunk: PacketChunk): Buffer { if (Buffer.isBuffer(chunk)) { return chunk; } @@ -212,123 +53,97 @@ function toBuffer(chunk: PacketChunk) { return Buffer.from(chunk, "utf8"); } +let reader = new PacketReader(new ArrayBuffer(0)); +let writer = new PacketWriter(); + const pkg: PackageApi = { - clientPacketID, - serverPacketID, - bufferRcv: createByteBuffer(), - bufferSnd: createByteBuffer(), + clientPacketID: CLIENT_PACKET_ID, + serverPacketID: SERVER_PACKET_ID, - setData(data) { - if (Array.isArray(data)) { - this.bufferRcv = ByteBuffer.wrap(Buffer.concat(data.map((chunk) => toBuffer(chunk))), "utf8", true); - return; - } + decodeClientPacket(data) { + const buffer = Array.isArray(data) + ? Buffer.concat(data.map((chunk) => toBuffer(chunk))) + : toBuffer(data as PacketChunk); + return decodeClientPacket(buffer); + }, - this.bufferRcv = ByteBuffer.wrap(toBuffer(data as PacketChunk), "utf8", true); + setData(data) { + const buffer = Array.isArray(data) + ? Buffer.concat(data.map((chunk) => toBuffer(chunk))) + : toBuffer(data as PacketChunk); + reader = new PacketReader(buffer); }, getPackageID() { - return this.getByte(); + return reader.getByte(); }, setPackageID(packageID) { - this.bufferSnd = createByteBuffer(); - this.writeByte(packageID); + writer = new PacketWriter(packageID); }, writeByte(numByte, signed) { - const value = normalizeNumericInput(numByte); - - if (signed) { - this.bufferSnd.writeInt8(value); - return; - } - - this.bufferSnd.writeUint8(value); + writer.writeByte(numByte, signed); }, writeShort(numShort, signed) { - const value = normalizeNumericInput(numShort); - - if (signed) { - this.bufferSnd.writeInt16(value); - return; - } - - this.bufferSnd.writeUint16(value); + writer.writeShort(numShort, signed); }, writeInt(numInt, signed) { - const value = normalizeNumericInput(numInt); - - if (signed) { - this.bufferSnd.writeInt32(value); - return; - } - - this.bufferSnd.writeUint32(value); + writer.writeInt(numInt, signed); }, writeFloat(numFloat) { - this.bufferSnd.writeFloat(normalizeNumericInput(numFloat)); + writer.writeFloat(numFloat); }, writeDouble(numDouble) { - this.bufferSnd.writeDouble(normalizeNumericInput(numDouble)); + writer.writeDouble(numDouble); }, writeString(dataString) { - const value = dataString ?? ""; - - this.writeShort(ByteBuffer.calculateUTF8Chars(value)); - this.bufferSnd.writeString(value); + writer.writeString(dataString); }, getByte(signed) { - return signed ? this.bufferRcv.readInt8() : this.bufferRcv.readUint8(); + return reader.getByte(signed); }, getShort(signed) { - return signed ? this.bufferRcv.readInt16() : this.bufferRcv.readUint16(); + return reader.getShort(signed); }, getInt(signed) { - return signed ? this.bufferRcv.readInt32() : this.bufferRcv.readUint32(); + return reader.getInt(signed); }, getFloat() { - return this.bufferRcv.readFloat(); + return reader.getFloat(); }, getDouble() { - return this.bufferRcv.readDouble(); + return reader.getDouble(); }, getString() { - if (!this.canReadBytes(2)) { + if (!reader.canReadBytes(2)) { return ""; } - const lengthStr = this.getShort(); - - if (lengthStr < 0 || !this.canReadBytes(lengthStr)) { + try { + return reader.getString(); + } catch { return ""; } - - return this.bufferRcv.readString(lengthStr, ByteBuffer.METRICS_CHARS); }, canReadBytes(length) { - if (length < 0) { - return false; - } - - return this.bufferRcv.offset + length <= this.bufferRcv.limit; + return reader.canReadBytes(length); }, dataSend() { - this.bufferSnd.flip(); - return this.bufferSnd.toBuffer(); + return Buffer.from(writer.toUint8Array()); }, encodeBatchFrame(buffers) { diff --git a/server/src/protocol.ts b/server/src/protocol.ts index e19fc662..37140cd7 100644 --- a/server/src/protocol.ts +++ b/server/src/protocol.ts @@ -1,4 +1,5 @@ export {}; +import { PROTOCOL_LIMITS } from "@openao/protocol"; import { getFactionColor, getFactionRankTitle, getMaxEligibleFactionRank, type CharacterFaction } from "./factions"; import type { DataObject, @@ -899,9 +900,9 @@ const AREA_DIAMETER_X = vars.areaVisionDiameterX; const AREA_DIAMETER_Y = vars.areaVisionDiameterY; const AREA_OUTSIDE_OFFSET_X = vars.areaVisionOutsideOffsetX; const AREA_OUTSIDE_OFFSET_Y = vars.areaVisionOutsideOffsetY; -const CLIENT_VIEW_RANGE_X = 10; -const CLIENT_VIEW_RANGE_Y = 10; -const CLIENT_VIEW_EXTRA_BOTTOM_Y = 1; +const CLIENT_VIEW_RANGE_X = PROTOCOL_LIMITS.clientViewRangeX; +const CLIENT_VIEW_RANGE_Y = PROTOCOL_LIMITS.clientViewRangeY; +const CLIENT_VIEW_EXTRA_BOTTOM_Y = PROTOCOL_LIMITS.clientViewExtraBottomY; function isOutsideClientVision(origin: { x: number; y: number }, target: { x: number; y: number }) { const deltaX = Math.abs(origin.x - target.x); diff --git a/server/src/server.ts b/server/src/server.ts index 4bce2386..86838d6d 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -542,9 +542,14 @@ wsServer?.on("connection", function (ws: RuntimeClient, request: RuntimeConnecti return; } + const decodedPacket = pkg.decodeClientPacket(data as PacketPayload); pkg.setData(data as PacketPayload); const packageID = pkg.getPackageID(); + if (decodedPacket.id !== packageID) { + return; + } + trackClientActivity(ws, packageID); protocol.handleData(ws, packageID);