Json encoding#42
Merged
Merged
Conversation
Introduce `client.sendPacketToServer(codec, packet)` as the single choke
point for outgoing typed packets. When `encode_packets_as_json` is on it
emits `{"$header": codec.header, ...packet}`; otherwise it delegates to
`codec.encode(packet)`. The legacy `sendToServer(message: string)` is
renamed to `sendStringToServer` as a raw-wire escape hatch.
`PacketCodec` now carries `header: string` so the codec is the single
source of truth for both its wire header and its encode/decode rules.
Direction-asymmetric packets get split into Client/Server codec pairs
mirroring the `MSClient`/`MSServer` pattern: `ID`, `VS_SPEAK`, `VS_JOIN`,
`VS_LEAVE`. Send-only codecs are added for `AM`, `AN`, `AE`, `VS_FRAME`.
The per-packet `sendXX` wrappers and the ad-hoc raw-wire callers
(`HI`, `SP`, voice, list pagination) all funnel through
`sendPacketToServer`, so the JSON flag toggles every outgoing path.
MS-specific: drop the replay-loopback branch from `sendMS`, rename
`self_offset` -> `offset`, and lift `MSPacketClient`/`MSPacketServer`
to the top of the file.
Also replace every `→` with `->` across the codebase.
Aligns all packet interface field names with the Packet Reference spec, which uses snake_case throughout. Internal client-side state (e.g. the `playerlist` Map type) is left in camelCase since it is not on the wire. Renames per packet: - ARUP: updateType/updateData -> update_type/update_data - ASS: assetUrl -> asset_url - AUTH: authState -> auth_state - CC: charId/charPw -> char_id/char_pw - CH: charId -> char_id - CT: isFromServer -> is_from_server - EE / PE: desc/img -> description/image - FM / SM: musicList -> music_list - MA: length -> duration - MC: charId/track -> char_id/name - PN: playerCount/maxPlayers/serverDescription -> snake_case - PV: charId -> char_id - SC: charData -> char_data - SI: charCnt/eviCnt/musCnt -> char_cnt/evi_cnt/mus_cnt - TI: timerId -> timer_id Kept ID's `player_count` (deliberate divergence from spec's misleading `player_number`).
Adds a leaner schema-driven API alongside the existing `PacketCodec`:
- `Field { name, type, default? }` — `default` makes the field optional;
without it the field is required and `decode` throws if missing.
- Free `decode(fields, body)` and `encode(header, fields, packet, asJson)`
functions handle both positional `HEADER#a#b#%` wire and the JSON
envelope `{"$header": "HEADER", ...}`.
Wire-format negotiation:
- `encode_packets_as_json` is now a mutable `let` with a setter.
- The `decryptor` packet (always sent by servers on connect) doubles as
the negotiation packet: `decryptor#JSON#%` flips the client into JSON
mode for all subsequent outgoing packets.
- `HI` is deferred from `onOpen` to `receivedecryptor`, so the wire
format is settled before the first outgoing packet.
Split MC into Client/Server forms to mirror MS/ID/VS_* (asymmetric wire:
Server -> Client has `looping`/`channel`, Client -> Server omits them).
The legacy `PacketCodec`/`makeCodec`/`parsePacket`/`encodePacket` path is
preserved so existing packets continue to work; only MC and decryptor
have moved partially. Full migration to come.
Each packet shape can now be declared as a class whose property order is
the wire field order and whose initializers carry both the default value
and (via `typeof`) the type. A `req("type")` sentinel marks required
fields — declared-as the actual TS type at compile time, recognised as
"required + this type" at runtime by the schema walker.
MC's two direction variants are now `MCPacketClient` and `MCPacketServer`
classes; the separate interface declarations and `Field[]` arrays are
gone. The class doubles as the TS type used by handlers
(`receiveMC(packet: MCPacketClient)`).
`decode` and `encode` accept either a `Field[]` array or a class
constructor; `walkSchema` normalises both. The registry's
`PacketBinding` gains a `schema` slot for class-based packets alongside
the existing `fields` and `codec` slots. Dispatcher checks `schema`
first, then `fields`, then `codec`.
Only MC has migrated; everything else still uses `codec`/`fields`.
- `coerce` now applies `decodeChat` (after `unescapeChat`) so fanta-decoded string fields arrive at consumers fully decoded — matching what `JSON.parse` does natively. `receiveMC` no longer calls `decodeChat`. - `safeTags` removed from `receiveMC`; it's HTML protection and belongs at HTML sinks, not in the parse step. None of MC's sinks need it. - `looping` is a proper `boolean` defaulting to `false`; the schema infers type via `typeof` and the wire still encodes 0/1. - `encode` recognises the `req()` sentinel as "field unset" so a fresh `new MCPacketServer()` with required fields unfilled throws cleanly instead of serialising the sentinel as garbage. - `sendPacketToServer` / `sendPacketToSelf` gain a class-schema overload; each schema class declares its wire identity via `static header`. Per- packet send wrappers shrink to a one-liner that delegates to it. - `receiveMC` body simplified: top-level locals dropped, `packet.X` inlined, char-lookup uses optional chaining instead of try/catch, and the redundant `musicname` reassignment is gone. - `receiveMC` and `sendMC` are now `function` declarations.
- sendPacketToServer -> sendPacket - sendStringToServer -> sendString - receiveServerString -> receiveString The send/receive verbs already encode direction; the suffix was noise. Direction markers on codec / class names (`MCPacketServer`, `MSServer`, etc.) are unchanged — those encode wire-form direction, not method direction.
Refactors MC to match the target mental model:
- `receiveMC(body)` is the literal inverse of `sendMC(packet)`. Both are
per-packet, full-pipeline orchestrators looked up via the registry.
- Per-packet `decodeMC(body) -> MCPacketClient` and
`encodeMC(packet) -> string` make the type/defaults seam explicit.
Orchestrators only ever call these wrappers, never the generic
`decode`/`encode` — so a future schema library can plug in by replacing
just `decodeMC`/`encodeMC`.
- `decode` accepts string OR already-parsed object so the JSON receive
path doesn't re-stringify.
- `encode` and `decode` share a private `cast()` helper that runs the
same gauntlet (instantiate class -> overlay -> validate `req()`
sentinels). Public `applyDefaults` is gone.
- Registry entry for MC is `{ receive: receiveMC, send: sendMC }` — no
schema, no codec, no parse field. Dispatcher hands the raw body to
`entry.receive` and lets the per-packet code own decoding.
- `PacketBinding.receive` widened to `(input: any) => void` so the new
wire-input pattern (MC) and the legacy typed-input pattern coexist
until the rest of the packets migrate.
`decode` now always takes a string and auto-detects format via the
leading `{` (JSON envelope) or split-on-`#` (positional fanta). Drops
the dual string|object input shape.
`receivePacket` is gone. `receiveString` in JSON mode hands the whole
chunk to `dispatchPacket`, which already auto-detects format via
`readHeader`. One pipeline, one decode entry point.
`receiveMC` / `decodeMC` tightened to `(body: string) => ...`.
Verifies the inverse property: `decode(schema, encode(schema, packet))` equals the original packet across both wire formats (fanta and JSON) and both direction-specific schemas (`MCPacketClient`, `MCPacketServer`). Also covers class defaults being filled in on partial-encode round-trips, and the "missing required field" throws on both ends.
Class schemas now extend a shared `Packet` base whose `[key: string]:
unknown` index signature lets `cast`/`encode`/`decode` do dynamic
property access without `Record<string, unknown>` escapes. The static
property and the JSON envelope key both use `$header` for symmetry.
The dispatch model splits along role and direction:
clientReceive / clientSend -- what the program does as a Client
serverReceive / serverSend -- what it does as a Server
Each map is `header -> single function` (no shared codec/binding
object); receive handlers own their decode, senders own their encode.
The legacy `clientPacketRegistry` and `serverPacketRegistry` stay
during the migration as a fallback for unmigrated packets.
Other cleanups in the same pass:
- cast strict: drops keys not declared on the schema
- coerce strict: throws on empty/invalid number tokens and on
booleans other than "0"/"1"
- decode accepts the raw frame including `#%` (peels terminators)
- encode emits canonical `HEADER#%` for zero-field schemas
- `Schema<T>` collapsed to class-only (legacy Field[] arm dropped)
- walkSchema yields just `{ name, type }`
- receiveData replaces receiveString/dispatchPacket; one WS frame =
one packet, TCP-era `%`-splitting + temp_packet buffer gone
- `json_mode` rename (the flag drives both directions, not just
encode)
Migrated alongside MC: CC (client -> server, with the gatekeep
preserved) and DONE (server -> client, empty payload). Round-trip
tests added for both, plus a focused `codec.test.ts` exercising the
pure decode/encode mechanics against synthetic schemas.
Tests directory renamed from `__tests__` to `tests`.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The `encoding.ts` module name overloaded the protocol concept of "encoding" (the wire-format encode/decode pipeline) with what this file actually does — escape and unescape string fragments. Renamed the module and its four exports to describe the operation directly: encoding.ts -> escaping.ts escapeChat -> escapeFanta (AO1 wire meta-char escaping) unescapeChat -> unescapeFanta (inverse) safeTags -> safeHtmlTags (HTML angle-bracket escaping) decodeChat -> unescapeUnicode (\uXXXX -> char) `escapeFanta` / `unescapeFanta` now make the wire layer they operate on explicit; `safeHtmlTags` is HTML-domain, not protocol-domain; and `unescapeUnicode` describes what the function actually does instead of being a generic "decode." Updated all 35 consumer files plus docs in tests. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Now that the old `encoding.ts` module is gone (renamed to `escaping.ts`), the `encoding` name is free for what these tests actually cover: the protocol encode/decode pipeline. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`AUTHPacket extends Packet` with `auth_state` as `req("number")`; the
legacy `PacketCodec` is gone. `receiveAUTH(body)` inlines its decode
against the class schema. Registry entry moves from the legacy
`clientPacketRegistry` codec branch into the `clientReceive` quadrant
map.
Round-trip tests cover both wire formats and the
missing-required-field error path (encode + decode).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The AO1 spec defines CC's wire as `CC#0#{char_id}#{char_pw}#%` where
position 0 is a hardcoded literal `0`. webAO has historically emitted
`playerID` at that slot in violation of the spec. `char_pw` is also
deprecated (and was always passed as the placeholder string `"web"`).
Introduces `lit()` as a third schema primitive (alongside `req()` and
plain defaults). A literal field is emitted at its declared wire
position on encode, the position is consumed but dropped on decode,
and `cast` strips it from the typed result. The new `Literal` brand
type plus a `Wire<T>` mapped type remove literal-typed fields from
the public-facing `encode` input and `decode` output, so callers
never see `_zero` or `_char_pw_deprecated` in autocomplete.
CC now models the wire honestly: `_zero = lit(0)` and
`_char_pw_deprecated = lit("")` carry the wire positions; the typed
API surfaces just `{ char_id }`. Both senders (pickChar, DONE) drop
the spec-violating player_id and the deprecated char_pw.
`sendCC`'s roster-validity gatekeep moves to `pickChar` — that's a
UI concern about which slots map to real characters, not a protocol
concern.
Alongside this:
- BB migrated to the Packet schema pattern (message = req("string")).
- Encoding tests grow a `WithLiteral` synthetic schema and 5 focused
tests for the lit() mechanic.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
PV's wire is `PV#{player_id}#CID#{char_id}#%`. The `CID` token at
position 1 is a protocol-mandated padding literal — now declared on
the schema via `_cid = lit("CID")` instead of being hand-built in
the codec template.
Per-packet logic also got too heavy: receivePV was doing all the
character-switch work (loading the ini, rebuilding the emote panel,
probing extensions, toggling the custom button). Extracted to
`src/client/changeChar.ts` so `receivePV` is just decode-and-dispatch.
`iniedit` now calls `changeChar` directly rather than synthesising a
fake PV wire just to retrigger the UI flow.
Side effects:
- CC's PV-ack synthesis switches from a raw template string to
`encode(PVPacket, ...)`, picking up the CID literal from the
schema.
- PVPacket interface and the legacy PV codec are gone.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The previous I/O surface conflated two distinct loopback directions
under a single `sendToSelf` and named its outbound counterpart after
its argument type (`sendString`). The clean shape is direction-named:
sendData -- transmit to the server (replay: loopback to
the server-side dispatcher)
receiveData -- dispatch via clientReceive (we, as client,
got this from a server — real or synthesized)
receiveDataAsServer -- dispatch via serverReceive (we, as server,
got this from a client — replay / emulation)
There is no `sendDataToClient` because that's logically equivalent
to `receiveData` — pretending the server sent us something is the
same as us receiving it.
Under the hood `receiveData` and `receiveDataAsServer` are one-line
delegates to a shared `dispatchFrame(data, map, role)`. The role
string surfaces in the "Unknown packet header for X receiver" warning
so it's obvious which side rejected.
`sendToSelf` and `handleSelf` retired. Their three real call sites
(receiveCC, receiveHI, receiveaskchaa) all synthesized server -> client
packets and become `client.receiveData(wire)`. Same for the other
in-handler synth points (RD, RM, RC, ID, changeBackgroundOOC).
`sendString` -> `sendData` everywhere (MC, CC, sendPacket).
`handleReplay` calls `receiveData` directly.
Replay-mode loopback now finally reaches the server-emulation
handlers it was always meant to (HI, CC, askchaa) by routing through
`receiveDataAsServer`.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Receive-only (server -> client), simple single-direction shapes:
ASS, AUTH, BB, BD, CHECK, decryptor, JD, KB, KK, MM, PR, TI
Bidirectional with server-side emulation handlers:
RC, RM, RD
RC/RM/RD are client-to-server in real operation (the client asks
the server for more characters / music / a ready signal). Their
receive handlers are server-emulation: they fire when the server
side receives the packet from a client and synthesise the SC/SM/
BN+DONE response back. These entries belong in `serverReceive` so
the replay-mode loopback (`sendData` -> `receiveDataAsServer`)
actually finds them. Each gets a matching `sendX` and a `clientSend`
entry, so SI / SC / SM (the consumers that previously called
`client.sendPacket(RC, {})` against the legacy codec API) now use
`client.send.RC({})` / `RM` / `RD` with full autocomplete.
Tally so far: 19 packets migrated (15 in `clientReceive`, 4 in
`serverReceive`, 5 in `clientSend`).
`tests/simpleReceivers.test.ts` batches round-trip tests for ASS,
BD, KB, KK, JD, CHECK -- shapes are uniform enough that one file
covers them.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`sendData` previously branched on `mode === "replay"` to decide whether to write to the WebSocket or loop back through `receiveDataAsServer`. That conflated "the URL is a replay URL" with "we should synthesise server responses locally" -- the same condition today, but conceptually different and not what we'd want to flip from a test. Promote the second meaning to an `acting_as_server` instance flag on Client, initialised from `mode === "replay"` so existing replay flows behave identically. `sendData` checks the flag directly; the two remaining `mode === "replay"` checks (the WebSocket setup and the connection-string fallback) are genuine URL-mode decisions and stay as-is. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The literal-field mechanism (`lit()`, `Literal` brand, special-case
literal handling in walkSchema/cast/encode/decode) was working but
leaked `_zero?: Literal`-shaped fields into the typed caller API. Even
with the Literal brand making them inert, they still cluttered
autocomplete and required `Wire<T>` mapped types to strip them again.
Replaced with two narrow override hooks at the schema-class level:
static toArgs?(packet): string[] // produce fanta args list
static fromArgs?(args): Partial<T> // parse fanta args list
The library still owns wire framing (`HEADER#…#%`), terminator handling,
header reading, the JSON envelope path, and the cast/required-field
gauntlet. Only the args-list step is overridable.
Default behavior (the 99% case) goes through `defaultToArgs` /
`defaultFromArgs`, which walk the schema and serialize/coerce in
declaration order — same code as before, just extracted as named
exports so overrides can call them when partially customising.
Schema classes now declare fields with their true TS-level
required/optional shape (`name: type = req()` for required, `name?: type
= default` for optional). Encode's parameter type is `T` directly; no
`Partial<Wire<T>>`. Decode's return type is the new `Decoded<T> =
{ [K in keyof T]-?: T[K] }` — reflects the runtime guarantee that cast
fills defaults / throws on missing required.
CC and PV migrated as the worked examples: literals (`0`, deprecated
`char_pw`, `CID`) sealed inside their respective toArgs/fromArgs;
caller-facing type is just `{ char_id }` for CC and
`{ player_id, char_id }` for PV. No more `_zero` / `_cid` in
autocomplete or decoded results.
Other migrated packets (AUTH, BB, BD, JD, KB, KK, MC, PR, TI,
decryptor, ASS, RC, RD, RM) updated to the new field syntax.
Library-side cleanups:
- Dropped legacy `makeCodec`, `parsePacket`, `fillDefaults`, `zeroFor`,
`FieldSpec` — no consumers after the migration. ~55 lines deleted.
- Replaced per-packet `sendX` boilerplate with `makeSender` /
`makeServerSender` factories that derive sender signatures from
the schema. Registries collapse to one-line entries.
- `sendDataAsServer` checks header against `serverSend` registry
before routing back to `receiveData`.
- `client.sendAsServer = serverSend` exposed for typed
`client.sendAsServer.PV({...})` calls.
- Added `acting_as_server` instance flag, decoupling sendData's
loopback branch from `mode === "replay"`.
Tests updated for the new typed signatures; cases that intentionally
pass missing required fields use `as Schema` casts to bypass TS and
verify the runtime gauntlet.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
aolib is the in-progress redesign of the packet codec layer as a
standalone, transport-agnostic TypeScript library that owns every AO
packet schema, both wire formats (fanta + JSON), encode/decode, and the
typed session/dispatch surface.
Currently scaffolded:
- README with usage, API surface, design principles
- fields.ts with the field primitives (implemented)
- index.ts with stub session factories (typed surface locked; runtime
stubbed until schema/encode/decode/transport land)
- exampleClient.ts / exampleServer.ts — runnable spec for both sides
- package.json / tsconfig.json — set up for standalone extraction
URL is a local file:// for now since there's no remote yet. When aolib
moves to a real remote (GitHub etc.), edit .gitmodules to point there
and `git submodule sync src/aolib` to update the working tree.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Tracks aolib bcf0a6a — adds "Protocol reference" section pointing at https://github.com/AttorneyOnline/docs as the authoritative wire spec and the convention for when real-world implementations diverge. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Tracks aolib a01f1c5 — adds the packet() constructor (schema.ts) and
locks the wrong-direction guarantee in the README ("Guarantees"
section) plus the stub session error message.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Submodule overhead wasn't worth it at this stage — aolib is still co-evolving with the rest of webAO, and the bump-pointer-per-change workflow adds friction for a library that's basically a folder. Bringing it back into the parent repo so changes are one commit instead of two. The aolib commit history is collapsed into this single commit. The content is exactly what was at aolib's last submodule commit a01f1c5: README, fields.ts, index.ts, schema.ts, exampleClient.ts, exampleServer.ts, package.json, tsconfig.json, .gitignore. The standalone-package metadata (package.json / tsconfig.json / .gitignore) is kept — when aolib is ready to extract back out into its own repo, the move is a `git mv src/aolib /elsewhere && git init` away. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Restructures the field layer to make JSON validation library-level
instead of per-field, adds the missing array primitive, and renames
blob to nested.
fields.ts:
- FieldKind now granular: "string" | "number" | "boolean" instead
of one "scalar" tag. Lets the JSON walker dispatch by leaf type
without per-field methods.
- Field interface shrinks to { kind, __t, fromFanta, toFanta } —
JSON codecs gone.
- Wrappers carry inner: OptionalField has inner: Field<T> so the
JSON walker can recurse for validation.
- blob() -> nested(). Better name for the cross-format concept; the
fanta `separator` stays as fanta-only metadata. JSON sees a real
nested object.
- New array(element). Variable-length list; fanta-greedy (consumes
remaining slots), native array in JSON. Per-token codecs throw —
the schema walker special-cases arrays and calls element.fromFanta
directly.
- custom() keeps optional fromJson / toJson for genuinely weird
custom fields; default is identity for the JSON path.
json.ts (new):
- fromJson(field, value, name) switches on kind and validates
strictly per leaf type, recursing through nested and array.
- toJson(field, value) is identity for everything except nested /
array (recurses) and custom (hook if present).
- The nested separator is NEVER read here — that's a fanta detail.
JSON sees nested objects natively.
- Error messages include field paths: 'foo.bar[3]' style.
index.ts: exports updated (nested + array replace blob, new types
re-exported, fromJson / toJson added).
README: folder structure lists json.ts; EI example uses nested();
added SM (array of scalars) and VS_PEERS (array of nested) worked
examples showing fanta-vs-JSON wire shape contrast.
Deferred (not needed yet): discriminated unions (ARUP), records,
JSON Schema interop.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds the type-level walker and the JSON Schema documentation export.
types.ts:
- In<S> — caller input shape: required fields required, optional
fields optional, wire-only literals stripped, nested + array
recurse to preserve typed structure.
- Out<S> — decoded output shape: same as In<S> but optionals lose
their `?` because cast has already filled defaults. Literals
still stripped.
- InFields<F> / OutFields<F> at the fields-record level; schema-
level In/Out are thin delegations.
- FieldInValue / FieldOutValue per-field, recursing through
nested(...) and array(...) so types like
array(nested({uid: num(), name: str()})) come out as
{ uid: number; name: string }[].
- Worked examples (commented) demonstrate the expected shapes and
catch type drift on regression.
jsonSchema.ts:
- toJsonSchema(schema) emits standards-compliant JSON Schema
(draft-07) for the JSON envelope shape.
- Literal fields (CC's `0`, PV's `CID`) are wire-only — omitted from
the schema, matching what the JSON wire actually carries.
- nested → object, array → array, optional → default-annotated.
- custom fields opt in to a `jsonSchema` property; default is `{}`.
- Consumable by AsyncAPI / Stoplight / Redoc / Ajv; documentation
path, not the runtime validation path (fromJson still does that).
fields.ts:
- ArrayField<E extends Field<unknown>> instead of ArrayField<T> —
carries the element FIELD as generic so In/Out can recurse into
nested-inside-array shapes.
README:
- Adds a "Documentation export" section between principles and
guarantees showing the toJsonSchema output for MC.
- Folder structure lists jsonSchema.ts.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Four new test files cover the foundational layer end-to-end:
fields.test.ts (35 tests):
- str/num/bool: round-trip ASCII, chat-escape meta-chars
(#&%$), unicode escapes (\uXXXX), strict validation errors
include the field name.
- opt: kind, inner reference, default value, delegated codecs.
- lit: kind, value preserved for string/number/boolean variants.
Notes that walkers never invoke its codecs.
- nested: default and custom separators, packing/unpacking,
round-trip, sub-field errors propagate with dotted paths.
- array: kind, element reference, per-token codecs throw with
a clear message (walker special-cases arrays).
- custom: codecs stored, errors propagate, optional JSON hooks.
json.test.ts (38 tests):
- fromJson per kind: strict type validation, descriptive
error messages with field paths.
- Optional defaults, literal returns value, nested + array
recursion with sub-paths in errors (foo.bar[3]-style).
- Empty arrays, deep nesting, custom field opt-in.
- toJson identity for leaves, recursion for nested + array,
custom hook dispatch.
- Round-trip property for scalars, nested, array-of-nested.
schema.test.ts (5 tests):
- packet() sets $header, fields. Optional toArgs/fromArgs
overrides stored when given, absent when not. Empty schemas.
jsonSchema.test.ts (12 tests):
- draft-07 envelope, $header as const, additionalProperties:
false. Required list excludes optional and literal fields.
- Optional fields carry their default. Literals omitted.
- Nested → type:object recursion. Array → type:array with
items. Array of nested objects emits recursive items schema.
- Custom field jsonSchema property opt-in, default {}.
Synthetic schemas only — no per-AO-packet tests yet since the
packets/ directory doesn't exist. Tests are exhaustive over the
primitives themselves; per-packet behavior will be covered by
their own test files once schemas land.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
cast.ts:
- The required-field gauntlet shared by both wire formats.
- For each schema field: literal ⇒ strip; optional with no value ⇒
fill from inner.default; required with no value ⇒ throw.
- Recurses through nested (sub-fields) and array (elements). Extra
keys in partial input are silently dropped — the schema is the
source of truth for shape.
encode.ts:
- encode(schema, packet, mode) with mode: "fanta" | "json".
- Single pipeline: cast(packet) → mode dispatch.
- JSON path: { $header, ...toJson(field, value) per non-literal }
then JSON.stringify.
- Fanta path: schema.toArgs(filled) override if defined, else
toFantaArgs(fields, filled). Framed as HEADER#…#%
(or HEADER#% for zero-field schemas).
decode.ts:
- decode(schema, wire) auto-detects format from the first byte
(`{` ⇒ JSON; else ⇒ fanta).
- JSON path: JSON.parse → match $header → per-field fromJson →
cast.
- Fanta path: peel terminator (canonical #%, legacy #, or none) →
split on # → match header → schema.fromArgs(args)
override if defined, else fromFantaArgs(fields, args)
→ cast.
- Header mismatch throws defensively; the session-level dispatcher
should already route by header, but cheap defense in depth.
index.ts: exports encode, decode, cast, type WireMode.
Tests (73 new, 206 aolib total, 391 full suite):
- cast.test.ts: required-missing, optional default fill, literal
strip, extra-key drop, nested + array recursion (each with
happy + error cases), empty schema.
- encode.test.ts: JSON envelope shape including literal stripping,
fanta wire shape including literal positions, defaults filled
via cast, array greedy expansion, schema-level toArgs override
in fanta-only path.
- decode.test.ts: format auto-detect, JSON + fanta paths with all
terminator variants, literal forgiveness on the wire, override
dispatch, header mismatch, malformed input, full encode/decode
round-trips for MC / CC / PV / DONE / SM / VS_PEERS.
The library can now do end-to-end wire conversion for any schema
expressible with the primitives. Next file: session implementation
in index.ts (replace stub with real registries + dispatch + the
wrong-direction guard).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replaces the index.ts stub session with a working implementation.
Both ends of the protocol now compile, encode, decode, and dispatch
end-to-end through aolib alone.
session.ts:
- makeSession(role, config) returns the shared ServerSession &
ClientSession runtime. The two factories (server / client) just
pick the role.
- .send is a Proxy keyed by header. On every access it:
1. Looks up the schema in the role's outbound registry
(server -> c2sSchemas, client -> s2cSchemas).
2. If absent but present in the *opposite* outbound, throws a
role-aware wrong-direction error explaining which factory
the caller should be using.
3. If absent entirely, throws "no schema registered".
4. Otherwise returns (packet) => transport.send(encode(schema,
packet, mode)).
- .on works symmetrically against the inbound registry; the wrong-
direction message also tells the caller to use the opposite
factory's on instead of this one's.
- receive(wire) is the single dispatch pipeline:
readHeader -> schema lookup -> decode -> mode-flip check
-> handler dispatch
Each failure point routes through one of the five observability
hooks; default behavior is a console warn / error so callers can
skip hooks during prototyping without losing visibility.
- Wire mode is per-session, starts at "fanta", flips to "json"
when receive sees `decryptor` with value `JSON`. The flip only
affects outbound — inbound auto-detect always handles both
formats.
- close() drops handlers and gates subsequent send / receive.
packets/:
Initial set of schemas — enough to exercise both directions and
the mode-flip:
c2s: HI, CC, MC (request shape)
s2c: decryptor, ID, PV, BB, DONE, SM, MC (broadcast shape)
index.ts exports c2sSchemas and s2cSchemas as `const`-asserted
objects; the session's mapped types pick literal keys from them
to build .send and .on namespaces. MC has two schemas (request /
broadcast) with the same wire header but different field sets,
proving the bidirectional case works.
decode.ts:
- Adds readHeader(wire) — peeks at just the header without decoding
the body. Used by the session dispatcher; throws on malformed
input so the caller can route to onMalformedFrame.
index.ts:
- Removed the AnyPacket placeholders, the stub session factories,
and the loose SendRegistry / OnRegistry types — they're all
obsolete now that session.ts owns the real implementation.
- Re-exports session factories, packet registries, every schema
constant, and the existing field / wire-format / type / schema
primitives.
exampleClient.ts / exampleServer.ts:
- Rewritten against the strict typed namespaces. Both files now
typecheck and use only the packets currently in the registry;
they'll grow as more schemas land.
Tests (35 new, 276 aolib total, 426 full suite):
- send.<correct-direction>: encodes, includes positional literals,
fills defaults via cast, refuses on closed session.
- send.<wrong-direction>: role-aware error explains both why and
which factory to use instead.
- send.<unknown header>: separate "no schema registered" error.
- on.<correct-direction>: dispatches typed packet, strips literals,
replaces handler on re-registration.
- on.<wrong-direction>: role-aware error.
- bidirectional MC: server.send.MC uses request shape, server.on.MC
receives broadcast shape; mirror for client.
- receive dispatch: header routing, JSON wire format, ignores after
close.
- Each of the five hooks fires only on its specific failure mode;
a single integration test runs all five paths sequentially and
asserts the order.
- Mode-flip: starts fanta, flips on decryptor("JSON"), no-op on
other decryptor values, per-session (two sessions don't share).
- Hookless defaults don't throw (console fallback is silenced
around these tests to keep output clean).
Next: extend the packets/ registry with the rest of the AO surface
(MS, AUTH, ARUP, etc.) — pure additive work now that the session
runtime is settled.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Brings the c2sSchemas / s2cSchemas registries to near-complete AO2
coverage. Each packet lives in its own file under src/aolib/packets/
and is registered (under its wire header) into whichever direction
maps apply.
C2S (23 total, 14 new):
- Lifecycle: askchaa, RC, RD, RM, CH
- Gameplay: CT, HP, RT, ZZ
- Evidence: AE, AM, AN, DE, EE, PE
- Moderation: MA
- Voice: VS_FRAME, VS_JOIN, VS_LEAVE, VS_SPEAK
- Already had: HI, CC, MC
S2C (41 total, 32 new):
- Lifecycle: SI, CHECK
- Gameplay: CT, HP, RT, ZZ, SP, JD, TI, BN, ASS, AUTH
- Lists: FM, FA, FL, SC, CharsCheck, RMC
- Incremental lists: CI, EM, EI, LE
- Roster: PN, PR, PU
- Moderation: BD, KB, KK
- Voice: VS_AUDIO, VS_CAPS, VS_PEERS, VS_JOIN, VS_LEAVE, VS_SPEAK
- Already had: decryptor, ID, PV, BB, DONE, SM, MC
Deferred to a follow-up:
- MS — 32-field chat packet with custom enum parsers (Side,
EmoteModifier, ShoutModifier, TextColor, etc.) and asymmetric
c2s/s2c shapes (26 vs 32 fields). Needs custom field types.
- ARUP — area-status update with a discriminated payload that
varies by update_type; needs a custom field or per-update-type
union.
- MM — deprecated music-mode packet, intentionally skipped.
Schema design decisions:
Asymmetric bidirectional packets (different shape per direction)
use the established `<HEADER>Request` (c2s) / `<HEADER>Broadcast`
(s2c) naming, as MC already does. Now applies to CT, VS_JOIN,
VS_LEAVE, VS_SPEAK.
Symmetric bidirectional packets (identical shape both ways) get
a single schema constant registered in both maps. Applies to HP,
RT, ZZ.
Legacy-optional fields (BN.position, RT.judgeId, ZZ.target,
PN.server_description, CT.is_from_server) canonicalise to
always-present-at-default — emitters always include the slot.
Decoders on both old and new code handle "missing" and "empty"
identically, so this is a benign wire-format simplification.
Nested packets (EI single, LE / CI / EM array-of-nested) use the
nested() / array(nested()) primitives. Internal `&` separators
are owned by the field, not by callers. Slight wire-format
divergence from legacy on EI (trailing `&` dropped); decoders
tolerate both forms.
packets/index.ts:
Registries grouped by purpose (handshake / gameplay / lists /
moderation / voice) for diff readability. All schema constants
re-exported by name. Direction-keyed maps remain `as const` so
session's mapped types pick literal keys.
index.ts:
Re-exports updated to include every new schema constant.
Tests (26 new, 78 aolib total, 452 full suite):
- Registry shape snapshot: explicit list of expected headers per
direction. Fails loudly on accidental removal in a future PR.
- Header-key consistency: every schema's $header matches its
registry key.
- Round-trips by shape kind: scalar-only (HP, MA, TI), optional-
with-default (BN, RT, ZZ, PN), array (FL, VS_PEERS, FA empty),
nested single (EI), array-of-nested (LE, CI), empty (askchaa,
CHECK).
- Session integration: every new c2s/s2c is reachable via the
expected role's send/on namespace; wrong-direction calls fail
as before.
- Bidirectional shape divergence: CT, VS_SPEAK have different
fields per direction; HP works as one schema in both.
- Chat-meta escape passes through the registry (BB, CT).
Next: MS and ARUP, which need custom-field plumbing.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
MS is the largest packet in the protocol (26 fields c2s, 30 fields s2c
including the four paired_* fields). It's also the only packet that
encodes enums and structured offsets, so the design needed two new
pieces:
1. An enum-as-Field builder using custom() with the legacy parsers'
fallback defaults baked in. Each enum is exposed as both:
- a TypeScript enum on the public API (Side.JUDGE, etc.)
- a CustomField<EnumT> in the schema
Both ends preserve the underlying value on the wire — string for
Side ("jud"), number for the rest (1, 2, ...) — and the JSON form
is the underlying value directly.
2. A custom offset() field that packs `{x, y}` into one positional
slot as `x<and>y` with the `&` chat-escaped per the legacy MS
spec. nested() would have used a raw `&` separator and broken
wire compatibility, so MS rolls its own codec.
Public API:
Side, DeskModifier, EmoteModifier, ShoutModifier, Flip, TextColor
isFullView, Offset
MSRequest, MSBroadcast
The two schemas share a `HEAD` of 17 fields and a `TAIL` of 8 fields
via spread; only the middle four `paired_*` fields and offset are
direction-specific.
Schema-side leniency: every field except character / message / side /
char_id has an `opt()` wrapper with the legacy fallback default, so
callers can ship a minimal `{character, message, side, char_id}` and
the schema fills in the other 22+ defaults.
Tests (32 new, 110 aolib total, 484 full suite):
- Enum round-trip: every value of every enum survives both wires.
- Enum fallbacks: malformed wire (garbage / wrong case / unknown
int) decodes to the legacy default (SHOWN / WITNESS / NO_PREANIM
/ etc.). ShoutModifier's legacy `4&{name}` form parses to CUSTOM.
- Minimal input: typing just `{character, message, side, char_id}`
produces a 26-token wire with every other field at its default.
- Offset: packs as `x<and>y` on fanta, as `{x,y}` on JSON. Malformed
offset slot falls back to `{x:0, y:0}` rather than throwing.
- Asymmetric: MSRequest is 26 slots, MSBroadcast is 30 slots.
`paired_*` fields round-trip via MSBroadcast.
- Required-field guard: missing char_id throws at cast.
- Chat-meta escape: #, &, %, $ in any string field survives fanta
round-trip.
- JSON envelope round-trip: all 30 fields preserved with their
typed enum values.
- Session integration: server.send.MS uses MSRequest (26 slots),
server.on.MS receives MSBroadcast (30 slots); mirror for client.
Registry: MS registered in both c2sSchemas (request) and s2cSchemas
(broadcast). The packet count is now 56 of 59:
- 24 c2s headers (one new: MS)
- 42 s2c headers (one new: MS)
Remaining: ARUP (discriminated union on update_type), MM (deprecated,
intentionally skipped).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
ARUP's payload type depends on its discriminator (`update_type`),
which doesn't fit the standard greedy `array()` primitive. Used a
schema-level `toArgs` / `fromArgs` override:
- `update_data` declared as a one-slot placeholder CustomField so
In/Out infer `AreaUpdateData = number[] | string[]`. Its fanta
codecs throw if invoked — they shouldn't be, the override
intercepts.
- The override switches on `update_type`: PLAYER_COUNT maps to
`number[]` (no escape needed), STATUS / CASE_MANAGER / LOCKED
map to `string[]` with full fanta escape on encode and unescape
on decode.
- Decode tolerates malformed wires the way legacy did: an unknown
`update_type` falls back to PLAYER_COUNT; a non-numeric entry in
a PLAYER_COUNT payload becomes 0 rather than NaN.
Enum convention matches MS:
- `AreaUpdateType` enum exposed at the public API.
- `AreaUpdateData` payload alias re-exported.
- `update_type` is a `CustomField<AreaUpdateType>` via the same
enum-as-field pattern.
JSON behavior is the default-identity path on custom() — the typed
payload is JSON-native (heterogeneous array of primitives), so
encode/decode pass through. No special-casing.
Tests (15 new, 125 aolib total, 498 full suite):
- Round-trip per update_type, including CM names with chat-meta
chars (#, &, %, $).
- Wire format check that `&` in string payloads becomes `<and>`
on the wire (and survives decode).
- PLAYER_COUNT payload is not escaped.
- Empty payload allowed.
- Unknown update_type and non-numeric PLAYER_COUNT tokens fall
back to the legacy default.
- JSON envelope round-trip for every update_type.
- Session integration: server.on.ARUP delivers the typed packet.
Registry coverage is now 57 of 59 packets:
- 24 c2s headers, 43 s2c headers.
- Only MM (deprecated AO1 music-mode) remains intentionally
unported. AO2 servers gate music mode server-side; the packet
is documented as no-op.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
src/client.ts now owns two aolib sessions and registers all handlers
through them; src/packets.ts is reduced to a clean header -> handler
dispatch table.
What changed in the architecture:
- Client gained `server: aolib.ServerSession` (talks to the remote
AO server) and `clientSession: aolib.ClientSession` (synthesises
a server in replay mode). Both are created in `connect()` before
the WebSocket so handler registration is racing-safe.
- `server.send.X(packet)` ships outbound. In replay it loops back to
`clientSession.receive(wire)` instead of the socket.
- `clientSession.send.X(packet)` is the synthesis-direction sender
used only in replay; its output always loops to
`server.receive(wire)`.
- The old `dispatchFrame` / `clientReceive` / `serverReceive` /
`clientSend` / `serverSend` registries are deleted. `onMessage`
just calls `server.receive(msg)` and aolib handles header lookup,
decoding, defaults, validation, and dispatch — including the five
observability hooks that route through console by default.
- `json_mode` / `setJsonMode` are gone. aolib owns wire-mode per
session and auto-flips on `decryptor({value:"JSON"})`.
src/packets.ts is now the user-facing dispatch table the migration
asked for — one line per packet:
server.on.MS(handleChatMessage);
server.on.MC(playMusicChange);
server.on.BB(showBlockingAlert);
...
`registerProtocol(server, clientSession)` is called once from
`client.ts`. The function-call form (`server.on.MS(handleChatMessage)`)
checks the handler signature against the schema at compile time;
wrong-direction registrations are caught both statically and at
runtime.
Per-packet handler renames (domain-action names):
s2c:
ARUP -> applyAreaStatus MS -> handleChatMessage
ASS -> applyAssetOrigin PN -> applyServerInfo
AUTH -> applyModAuth PR -> applyPlayerRosterChange
BB -> showBlockingAlert PU -> applyPlayerFieldUpdate
BD -> showBanDialog PV -> applyCharacterPick
BN -> applyBackgroundChange RMC -> applyMusicSeek
CHECK -> onServerKeepalive RT -> applyTestimonyState
Cha... -> applyCharacterAvailability SC -> applyFullCharacterList
CI -> applyCharacterBatch SI -> applyServerCounts
CT -> appendOOCMessage SM -> applyMusicListBatch
decr... -> applyEncryptionMode SP -> applyCharacterSide
DONE -> finishServerJoin TI -> applyTimerUpdate
EI -> applyEvidenceInfo VS_AUDIO -> handleVoiceAudio
EM -> applyEvidenceListBatch VS_CAPS -> applyVoiceCapabilities
FA -> applyFullAreaList VS_JOIN -> handleVoicePeerJoin
FL -> applyFeatureFlags VS_LEAVE -> handleVoicePeerLeave
FM -> applyFullMusicList VS_PEERS -> applyVoicePeerList
HP -> applyHealthBar VS_SPEAK -> applyVoicePeerSpeak
ID -> applyServerIdentity ZZ -> showModcallNotice
JD -> toggleJudgePanel
KB -> showKickAndBanScreen
KK -> showKickScreen
LE -> applyEvidenceList
MC -> playMusicChange
c2s (replay-mode synthesis only):
askchaa -> onAreaCharRequest RC -> onCharacterListRequest
CC -> onCharacterChoose RD -> onReady
CH -> onClientKeepalive RM -> onMusicListRequest
HI -> onClientIdentify
Each handler now takes aolib's typed packet (`Out<typeof aolib.X>`)
directly — no more `(body: string)` boilerplate that decoded inside
every handler. aolib decodes once at the dispatch boundary and passes
the typed packet through.
Per-packet wire-format objects are deleted: the legacy `PacketCodec`,
`Packet` base class, `XXPacket` interfaces, and per-file `static
toArgs`/`fromArgs` overrides are gone. Schema + encode + decode all
live in aolib's `packets/`.
Sender call sites collapsed: every `sendXX(packet)` helper is removed.
Calls in `dom/`, `voice/`, and other packet files now go through
`client.server.send.XX(...)` directly. Replay-mode synthesis (CC ack,
RC / RD / RM responders) goes through `client.clientSession.send.X` or
`client.server.receive(...)`.
Updated all remaining consumers of legacy types:
- `src/viewport/interfaces/ChatMsg.ts` and `defaultChatMsg.ts` now
type the MS packet as `aolib.Out<typeof aolib.MSBroadcast>`.
- `src/dom/updateActionCommands.ts` imports `Side` from aolib.
- `src/dom/onICEnter.ts` rewritten to build the packet against
`aolib.In<typeof MSRequest>` with aolib's enums.
- `src/dom/modCallTest.ts` calls `showModcallNotice` directly.
- `src/dom/changeRoleOOC.ts` uses `aolib.Side` and stopped trying
to send `SP` (server -> client, not the client's place).
Cleanup:
- Deleted `src/Packet.ts` (legacy base class).
- Deleted `src/packets/{AE,AM,AN,MM}.ts` — c2s-only senders / deprecated
MM had no handler and no remaining importers.
- Deleted `src/tests/{AUTH,BB,CC,DONE,encoding,MC,packetCodec,PV,
simpleReceivers}.test.ts` — covered exhaustively by `src/aolib/tests/`.
- Aolib's SP schema upgraded from `num()` to a `Side`-valued custom
field so the handler hands a typed enum to `updateActionCommands`.
- `aolib/index.ts` now also re-exports `ARUP`.
Tests: 355 pass / 0 fail (down from 484 since the legacy duplicates
are removed; aolib's 290 tests still cover the wire surface).
Typecheck: clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
After the aolib migration, the per-packet handler files in src/packets/
had stale doc comments referencing the old encode/decode flow, dead
`escapeFanta` / `unescapeFanta` imports, vestigial type aliases the
aolib equivalents already cover, and an empty VS_FRAME stub left over
from when it was a sender-only codec file.
Changes:
- src/packets/MS.ts: deleted the entire vestigial enum + parser
block (DeskModifier, EmoteModifier, ShoutModifier, Flip,
TextColor, Side, Offset, parseSide, parseFlip, encodeOffset,
etc.) — all of these live in aolib now. The file is just the
handler.
- Eight call sites that imported the MS enums from
`../packets/MS` (viewport / dom / client modules) now import
from `../aolib`.
- Stripped dead `escapeFanta` / `unescapeFanta` imports from CT,
ZZ, ARUP, CT, FA, EE, HI, FM, FL — none of them were used in
the handler bodies. aolib's str field unescaped before the
handler ever saw the value.
- addTrack drops its `unescapeFanta` call for the same reason.
- Trimmed verbose codec-talk doc comments in CI, LE, RT, SC that
explained the old wire-format dance.
- Deleted unused `EvidenceData` interface in LE.
- Deleted `src/packets/VS_FRAME.ts` — empty file with no handler
and no importers.
src/escaping.ts: dropped `escapeFanta` and `unescapeFanta` since
nothing outside aolib's own internal walker uses them anymore. Kept
`safeHtmlTags` and `unescapeUnicode` — both are genuine handler
utilities (DOM safety + JS-string decoding, not wire-format codec).
src/tests/escaping.test.ts: dropped the two tests that covered the
removed functions; the surviving four tests for `safeHtmlTags` and
`unescapeUnicode` still pass.
Tests: 353 pass / 0 fail (down 2 from the dropped escapeFanta tests).
Typecheck: clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Each `src/packets/<HEADER>.ts` wrapper was just a function importing
something from another module and forwarding the typed packet to it.
Moved every handler into the module that already owns its domain, so
the function lives next to the code it actually touches. `src/packets/`
is gone; `src/packets.ts` (the dispatch table) imports from the new
homes.
Handler -> new home:
Voice (all six VS_*):
handleVoiceAudio
applyVoiceCapabilities
applyVoicePeerList
handleVoicePeerJoin
handleVoicePeerLeave
applyVoicePeerSpeak
-> src/voice/voice.ts
Bans / alerts:
showBlockingAlert -> src/client/handleBans.ts
showBanDialog
showKickAndBanScreen
showKickScreen
Character selection / load:
applyCharacterPick -> src/client/changeChar.ts
applyCharacterBatch -> src/client/handleCharacterInfo.ts
applyFullCharacterList
applyCharacterAvailability
-> src/dom/pickChar.ts
applyCharacterSide -> src/dom/updateActionCommands.ts
toggleJudgePanel
Music:
applyMusicListBatch -> src/client/addTrack.ts
applyFullMusicList
applyEvidenceListBatch (EM bridges areas + music)
playMusicChange -> src/viewport/utils/playMusic.ts (new)
applyMusicSeek
Areas:
applyFullAreaList -> src/client/createArea.ts
applyAreaStatus
applyBackgroundChange -> src/viewport/utils/setSide.ts
Evidence:
applyEvidenceInfo -> src/dom/pickEvidence.ts
applyEvidenceList
Player roster:
applyPlayerRosterChange -> src/dom/renderPlayerList.ts
applyPlayerFieldUpdate
OOC log:
appendOOCMessage -> src/dom/oocLog.ts (new)
showModcallNotice
Handshake:
applyEncryptionMode -> src/client/handshake.ts (new)
applyServerIdentity
applyServerInfo
finishServerJoin
Replay-mode synthesis:
onClientIdentify -> src/client/replay.ts (new)
onAreaCharRequest
onCharacterChoose
onCharacterListRequest
onMusicListRequest
onReady
Other servers-pushed UI updates:
applyServerCounts -> src/client/fetchLists.ts
applyAssetOrigin -> src/client/aoHost.ts
applyFeatureFlags -> src/client/featureFlags.ts (new)
handleChatMessage -> src/viewport/utils/handleICSpeaking.ts
applyTestimonyState -> src/viewport/utils/initTestimonyUpdater.ts
applyHealthBar -> src/dom/healthBar.ts (new)
applyTimerUpdate -> src/dom/timer.ts (new)
applyModAuth -> src/dom/applyModAuth.ts (new)
Keepalives (truly no-op):
onServerKeepalive (CHECK) inlined as `() => {}` in packets.ts
onClientKeepalive (CH) inlined as `() => {}` in packets.ts
Deleted (vestigial empty handlers from c2s-only senders):
DE, EE, MA, PE -- bodies were comments only; senders go through
`client.server.send.X(...)` directly, no handler needed.
src/packets/ is deleted entirely. src/packets.ts is now just the
dispatch table — imports from the new homes and registers them via
`server.on.X(handler)` and `clientSession.on.X(handler)`.
modCallTest.ts updated to import showModcallNotice from its new
home (`./oocLog` instead of `../packets/ZZ`).
Tests: 353 pass / 0 fail.
Typecheck: clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The file is no longer a packet directory or a codec registry — it's the single function that wires every handler into the aolib session. Renaming it so the filename matches the export (`registerProtocol`) makes the import line self-documenting and disambiguates from the unrelated `src/aolib/packets/` directory (which holds the schemas). The only importer (`src/client.ts`) was updated; aolib's internal `./packets` references point at `src/aolib/packets/` and are unrelated. Tests: 353 pass / 0 fail. Typecheck: clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Handler signatures were leaking the library's internal type machinery:
(packet: aolib.Out<typeof aolib.MCBroadcast>) => void
That's a public API smell — every handler had to know that `Out` is the
type-level walker, that the schema constant lives behind `typeof`, and
that bidirectional packets pick a `Broadcast` schema. None of that
should leave the library.
Fix: aolib now exports one `XPacket` type alias per schema, derived
from `Out<typeof X>`. Handlers read:
(packet: aolib.MCPacket) => void
(packet: aolib.BBPacket) => void
(packet: aolib.MSPacket) => void
New file: src/aolib/packetTypes.ts. One alias per packet:
- Unidirectional packets get a single alias: `XPacket = Out<typeof X>`.
- Symmetric bidirectional packets (HP, RT, ZZ, SP) also get one
alias since the shape is the same both ways.
- Asymmetric bidirectional packets (MC, MS, CT, VS_JOIN, VS_LEAVE,
VS_SPEAK) get two:
`XPacket` defaults to the Broadcast (s2c) shape — the dominant
case for client-side code in this repo.
`XRequestPacket` is the Request (c2s) form.
src/aolib/index.ts re-exports the entire module with `export type *`.
Sweep across handlers (one perl pass): every
`aolib.Out<typeof aolib.X>` got rewritten to `aolib.XPacket`. For the
bidirectional schemas the perl initially produced `XBroadcastPacket`;
a second pass collapsed those to the un-suffixed `XPacket` since the
default is the Broadcast form.
Files touched: addTrack, aoHost, changeChar, createArea, featureFlags,
fetchLists, handleBans, handleCharacterInfo, handshake, replay
(src/client/); applyModAuth, healthBar, oocLog, pickChar, pickEvidence,
renderPlayerList, timer, updateActionCommands (src/dom/);
defaultChatMsg, ChatMsg, handleICSpeaking, initTestimonyUpdater,
playMusic, setSide (src/viewport/); voice (src/voice/).
Tests: 353 pass / 0 fail. Typecheck: clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`XPacket` covered the receive side; this commit adds `XInput` for the
send side. Same hide-the-mechanic principle: callers who want to type
a packet they're about to send should write
const p: aolib.MSInput = { ... };
client.server.send.MS(p);
instead of leaking
const p: aolib.In<typeof aolib.MSRequest> = { ... };
Aliasing rules now symmetric:
Unidirectional / symmetric bidirectional packets:
XPacket = Out<typeof X> // what we receive
XInput = In<typeof X> // what we send
Asymmetric bidirectional (MC, MS, CT, VS_JOIN, VS_LEAVE, VS_SPEAK)
default to the client-side roles:
XPacket = Out<XBroadcast> // s2c — what we receive
XInput = In<XRequest> // c2s — what we send
Server-side roles are also exposed:
XRequestPacket = Out<XRequest> // server-side decode
XBroadcastInput = In<XBroadcast> // server-side send
No existing call sites currently use the In form (the only one,
src/dom/onICEnter.ts, builds the packet inline via send.MS({...})),
so this commit just expands the API surface — no handler signatures
change. The symmetry matters for future code that wants to declare
a typed packet variable for `send`.
Tests: 353 pass / 0 fail. Typecheck: clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The previous commit added an XInput alias for every packet. Most were
noise — for unidirectional and symmetric-bidirectional packets,
`client.server.send.X({...})` already infers the input shape; no one
needed `aolib.BBInput` or `aolib.HPInput`.
Trimmed to: XInput aliases only on the six asymmetric bidirectional
packets (MC, MS, CT, VS_JOIN, VS_LEAVE, VS_SPEAK), where the input
shape (Request) genuinely differs from the receive shape (Broadcast)
and a typed-variable declaration could disambiguate. The mirror-
direction aliases (XRequestPacket, XBroadcastInput) are also kept for
server-side users of the library.
Tests: 353 pass / 0 fail. Typecheck: clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`export const handler = (...) => { ... }` was a stylistic miss on my
part during the migration. For top-level named functions there's no
reason to use the arrow form — `export function` is more idiomatic,
hoists, and shows up cleanly in stack traces.
Converted across src/client/, src/dom/, src/viewport/, src/voice/ via
a perl pass that anchors on column-0 `};` (so nested arrow function
terminators inside handler bodies don't get misparsed).
Pre-existing arrow-form setters in src/client.ts (setCHATBOX,
setClient, setSelectedMenu, etc.) are left as-is — not part of the
migration scope and they're trivial state mutators rather than named
behaviors.
Tests: 353 pass / 0 fail. Typecheck: clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Same conversion as the prior commit but applied to the pre-existing
setters (setCHATBOX, setClient, setSelectedMenu, setSelectedShout,
setExtraFeatures, setLastICMessageTime) and the `delay` helper. They
weren't part of the migration scope last time around but are
inconsistent with the rest of the codebase now that the handlers use
`export function`.
`delay` was an expression-body arrow (returned a Promise directly);
the block-form perl pattern didn't catch it, so it got a manual edit
to wrap the body in `{ return ... }`.
Tests: 353 pass / 0 fail. Typecheck: clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Five exports had multi-line signatures my earlier perl pattern didn't
catch (because the params regex required them on one line). Did these
by hand:
getFilenameFromPath (src/utils/paths.ts) -- expression-body arrow
setAOhost (src/client/aoHost.ts) -- return-type annotation
request (src/services/request.ts) -- async expression-body
ensureCharIni (src/client/handleCharacterInfo.ts) -- async
getFavourites, saveFavourites (src/dom/toggleFavourite.ts)
src/dom/toggleFavourite.ts had broken syntax from a too-permissive
earlier perl pass that matched across function boundaries; rewrote
the whole file to clean function-declaration form.
All export/internal `const X = (...) => {...}` patterns outside the
aolib library are now `function X(...) {...}`.
Tests: 353 pass / 0 fail. Typecheck: clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ver's ID
The handshake is bidirectional on the ID step:
server -> client: ID#<player_count>#<software>#<version>#%
client -> server: ID#<software>#<version>#% (gates the rest)
Most legacy servers (akashi, tsuserver, KFO, Athena) wait for the
client's ID reply before sending anything else. If they don't get it
the join just sits silently after their own ID — no PN, no SI, no
character roster.
The migration to aolib dropped this reply because ID was only
registered as s2c in aolib's schema registry, so `server.send.ID`
didn't exist on the typed surface. webAO worked fine in the meantime
because it never expects the reply (and the handler still synthesises
a local PN for it).
Fix:
- Add `IDRequest = packet("ID", { software, version })` in
src/aolib/packets/ID.ts (alongside the existing s2c `ID`).
- Register `IDRequest` under `c2sSchemas.ID` so `server.send.ID(...)`
is now typed and dispatched.
- Move `IDPacket` to the bidirectional-asymmetric section in
packetTypes.ts and add `IDInput`, `IDRequestPacket`,
`IDBroadcastInput` to mirror the convention used for MC / MS / CT
/ VS_*.
- `applyServerIdentity` now sends the c2s ID reply for non-webAO
servers (mirrors the legacy behavior). webAO branch unchanged.
- Registry snapshot test updated to include "ID" in c2sSchemas.
Tests: 353 pass / 0 fail. Typecheck: clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
ID's first numeric field is the *player slot id* the server is
assigning to the client, not a population count. PN is the packet
that carries the population count. Mislabeling them as the same field
name was misleading — `client.playerID = packet.player_count` read
like a category error.
Field rename across:
- src/aolib/packets/ID.ts (schema + doc)
- src/client/handshake.ts (applyServerIdentity now reads
`packet.player_number`)
- src/aolib/exampleServer.ts (the example server's `send.ID(...)`)
- src/aolib/tests/session.test.ts (the send.ID test)
PN's `player_count` field is unchanged — that one's correctly named.
Tests: 353 pass / 0 fail. Typecheck: clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
I picked the wrong name on the previous rename. The spec calls the ID field `player_id` — that's what server code (akashi/tsuserver) and the AO2-Client reference implementation both use. Renamed across the schema, the handler, the example server, and the session test. Tests: 353 pass / 0 fail. Typecheck: clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…sentinel
`applyServerIdentity` had a branch on `serverSoftware === "webAO"` to
decide whether to synthesise PN locally. That was a leak of replay-
mode concerns into a client-side handler — using the server's software
name as a sentinel for "this is our own fake server" only worked
because no real server identifies as "webAO" (that's the client's
name). Tying the handler's behavior to a string the wire happens to
carry is fragile.
Replaced with the same wiring pattern the other replay handlers use:
- applyServerIdentity always sends our ID reply, no special cases.
The webAO branch is gone.
- In replay mode, the outbound ID loops back to
`clientSession.on.ID`, which now dispatches to a new
`onClientIdentified` handler in `src/client/replay.ts`. That
handler synthesises PN exactly like the legacy webAO branch did.
- `registerProtocol.ts` registers `clientSession.on.ID(
onClientIdentified)` alongside the other server-side replay
handlers (HI / askchaa / CC / RC / RD / RM).
In non-replay mode the new c2s ID handler simply doesn't fire — the
outbound goes to the WebSocket and the real server replies with its
own PN. Behavior unchanged on real servers; replay mode arrives at
PN through the registered handler instead of a string check.
Tests: 353 pass / 0 fail. Typecheck: clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
session.ts: replace `callHook(...) ?? defaultX(...)` with `if (!callHook(...)) defaultX(...)`. The `??` form was a clever one-liner but it produces a value that's then discarded, which ESLint flags as an unused expression. The if/else form makes the intent explicit: "call the hook if registered; otherwise fall through to the default". Removed unused eslint-disable directives in session.ts (no-console isn't an active rule in this codebase, and @typescript-eslint/no-explicit-any wasn't firing on the `Schema<any>` alias). Dropped genuinely unused imports: - OptionalField in src/aolib/fanta.ts - num in src/aolib/packets/ARUP.ts (ARUP uses custom only, not num) - bool in src/aolib/tests/decode.test.ts - Fields in src/aolib/types.ts Two indent warnings in fields.ts and three in ARUP.ts were auto-fixed by `eslint --fix`. Lint: clean. Typecheck: clean. Tests: 353 pass / 0 fail. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The library was inspecting packet bodies to decide protocol behavior:
if (header === "decryptor" && packet.value === "JSON") {
mode = "json";
}
That's too opaque — the library shouldn't know what `decryptor`
means, and a future protocol change (or a different application of
aolib) shouldn't have to fight the library's hardcoded packet-content
match. Mode is the application's business.
API change:
- `session.setMode(mode: WireMode): void` on both ServerSession and
ClientSession. Sets the outbound wire format. Inbound continues
to auto-detect per packet (first byte `{` = JSON, else fanta) so
nothing has to coordinate on the receive side.
- The session's internal `receive` no longer inspects packet
contents; it only dispatches.
Application change:
- `applyEncryptionMode` (the decryptor handler) now reads the
packet's `value` itself and calls `client.server.setMode("json")`
when the server advertises JSON. Then it kicks off the join with
HI as before.
Tests:
- The old `describe("wire-mode flip on decryptor", ...)` block
became `describe("setMode: explicit outbound-format switching",
...)`.
- New tests cover `setMode('json')`, `setMode('fanta')` (toggle
back), per-session isolation, and an explicit regression test
that the library does NOT auto-flip on receiving decryptor.
Tests: 354 pass / 0 fail. Typecheck + lint: clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The two-state design doesn't justify a stringly-typed argument.
`setJsonMode(true)` reads cleaner than `setMode("json")` and there's
no risk of a typo passing the type-check ("Json" vs "json", etc.).
API change:
- ServerSession / ClientSession.setMode(mode: WireMode)
-> ServerSession / ClientSession.setJsonMode(enabled: boolean)
- Implementation: `mode = enabled ? "json" : "fanta"`.
Call site update:
- applyEncryptionMode now just does
client.server.setJsonMode(packet.value === "JSON")
instead of branching on the string.
Tests / docs / session test block updated to match. Tests:
354 pass / 0 fail. Typecheck + lint: clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The abbreviated _cnt suffix was the only place in the aolib registry
using that convention; everything else (player_count on PN,
max_players, etc.) uses the spelled-out _count. Consistency is worth
the rename.
Changes:
- src/aolib/packets/SI.ts: field names.
- src/client/fetchLists.ts: applyServerCounts uses packet.char_count
/ .evi_count / .mus_count.
- src/aolib/tests/packets.test.ts: SI round-trip assertion updated.
Tests: 354 pass / 0 fail. Typecheck + lint: clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two related fixes for receiving real Athena server traffic in JSON mode:
1. SC: schema was `char_data: array(str())` — modeled after the
legacy fanta wire where each character was a `&`-packed string.
Athena (and the modern spec, Packet Reference §SC) sends each
entry as a typed `{name, desc, evidence}` object, so aolib's
JSON decoder rejected the array with "expected string, got
object". Updated to `array(nested({...}))`:
char_data: array(nested({
name: str(),
desc: opt(str(), ""),
evidence: opt(str(), ""),
}))
This is the spec form and works on both wires — fanta packs
`name&desc&evidence` per slot, JSON carries the object directly.
`applyFullCharacterList` adapts the typed object back to the
legacy positional `chargs[]` layout `setupCharacterBasic` expects
(with an empty blips slot at index 2, since `chargs[3]` is the
evidence field setupCharacterBasic reads).
2. num() JSON decoder: rejected stringified numbers like `"0"`.
Athena JSON-stringifies positional values one-to-one, so
`player_id: num()` would fail with "expected number, got string".
The decoder now accepts both the native number form and any
string that parses to a finite Number. Empty string and non-
numeric strings still throw.
Updated tests:
- fromJson:number — now asserts ACCEPT for "42", "0", "-3.14";
new rejects for empty string and non-numeric strings.
- fromJson:optional and fromJson:nested — sub-field invalid-input
cases switched from "5" / "3" to "abc" since stringified ints
are now valid.
Tests: 356 pass / 0 fail. Typecheck + lint: clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Athena's JSON envelope sends `music_list` as an array of typed
`{name}` objects — same pattern as the SC fix in the prior commit.
Our schema was still `array(str())`, modeled after the legacy fanta
positional form, so aolib's JSON decoder rejected it with
"expected string, got object".
Spec form (Packet Reference §SM, §FM):
music_data { name: string }
SM / FM: music_list = array[music_data]
Both schemas updated to `array(nested({ name: str() }))`. This is the
spec form and works on both wires — fanta carries each entry as one
positional slot with just the name, JSON as an object.
Handlers updated:
- applyMusicListBatch (SM): iterates `for ({name} of music_list)`
instead of `tracks[i]`. The legacy trailing-empty workaround
becomes a simple `if (!name) continue;` skip.
- applyFullMusicList (FM): same pattern.
The two aolib example files (exampleClient / exampleServer) were
also using the string-list shape; updated to match.
Tests: 356 pass / 0 fail. Typecheck + lint: clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Same family of legacy-server compat as the num() change two commits ago. Athena and other servers JSON-stringify positional values one-to-one, so booleans land as `"1"` / `"0"` strings instead of native `true` / `false`. aolib's strict bool decode was rejecting them with "expected boolean, got string", causing the whole packet to fail and the handler to not fire. bool() now accepts: - native booleans `true` / `false` (as before) - string `"1"` -> `true` - string `"0"` -> `false` Everything else still rejects, including: - `1` / `0` (native numbers — picky distinct from the string form) - `"true"` / `"false"` - empty string - `null` / other types Updated the test that previously asserted strict rejection. The behavior is parallel to num()'s string tolerance, so the two decoders read the same way for callers debugging a real server. Tests: 357 pass / 0 fail. Typecheck + lint: clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
JSON compat (Athena's string-encoded numbers/booleans, SC/SM/FM as
array-of-object) is still being shaken out, so flipping every client
into JSON when the server advertises it is too aggressive. Adding a
URL-level feature gate so we can roll out per-deployment.
Behavior:
- Default (no flag): client stays on fanta even if the server
advertises JSON via decryptor("JSON").
- With `?json_mode=true`: client flips to JSON on decryptor("JSON")
the same way as before.
queryParser exposes the new `json_mode: boolean` field.
applyEncryptionMode reads it once at module load and AND-s the flag
into the setJsonMode call.
Once we're confident in the JSON path the gate can come out — and
the diff is small.
Tests: 357 pass / 0 fail. Typecheck + lint: clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Owner
|
omni the goat @OmniTroid always making the best commits.. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.