diff --git a/.dprint-plugins/json-0.21.1.wasm b/.dprint-plugins/json-0.21.1.wasm new file mode 100644 index 0000000..beabc5f Binary files /dev/null and b/.dprint-plugins/json-0.21.1.wasm differ diff --git a/.dprint-plugins/markdown-0.20.0.wasm b/.dprint-plugins/markdown-0.20.0.wasm new file mode 100644 index 0000000..ea5cb6c Binary files /dev/null and b/.dprint-plugins/markdown-0.20.0.wasm differ diff --git a/.dprint-plugins/toml-0.7.0.wasm b/.dprint-plugins/toml-0.7.0.wasm new file mode 100644 index 0000000..598011a Binary files /dev/null and b/.dprint-plugins/toml-0.7.0.wasm differ diff --git a/.dprint.json b/.dprint.json index 4ff352d..ae4b279 100644 --- a/.dprint.json +++ b/.dprint.json @@ -23,8 +23,8 @@ "target/**/*" ], "plugins": [ - "https://plugins.dprint.dev/markdown-0.20.0.wasm", - "https://plugins.dprint.dev/toml-0.7.0.wasm", - "https://plugins.dprint.dev/json-0.21.1.wasm" + "./.dprint-plugins/markdown-0.20.0.wasm", + "./.dprint-plugins/toml-0.7.0.wasm", + "./.dprint-plugins/json-0.21.1.wasm" ] } diff --git a/src/SUMMARY.md b/src/SUMMARY.md index 3836040..207db7a 100644 --- a/src/SUMMARY.md +++ b/src/SUMMARY.md @@ -129,6 +129,7 @@ - [polling](./rlink/game/automatch2/polling.md) - [stoppolling](./rlink/game/automatch2/stoppolling.md) - [updateStatus](./rlink/game/automatch2/updatestatus.md) + - [Implementation notes](./rlink/game/automatch2/notes.md) - [/game/challenge](./rlink/game/challenge/README.md) - [getChallengeProgress](./rlink/game/challenge/getchallengeprogress.md) - [getChallengeProgressByProfileID](./rlink/game/challenge/getchallengeprogressbyprofileid.md) diff --git a/src/rlink/game/advertisement/getadvertisements.md b/src/rlink/game/advertisement/getadvertisements.md index 204374c..84952ba 100644 --- a/src/rlink/game/advertisement/getadvertisements.md +++ b/src/rlink/game/advertisement/getadvertisements.md @@ -184,3 +184,54 @@ Pragma: no-cache ] ] ``` + +## Field layout note (AoE2:DE, observed October 2026) + +The sample above shows the 24-field form of the advertisement array. When +implementing a local RLink-compatible server, we found that the current PC +client rejected that 24-field form with: + +``` +WorldwideUpdateAdvertisementAsync - response was not formatted correctly +AdvertisementByIDCache - response was not formatted correctly +``` + +The same client accepted a **27-field** form. The extra three elements are +inserted after the GoG lobby ID (`"0"`): two empty strings and the Xbox session +ID. The accepted layout we observed was: + +| Index | Field | +| ----- | ----------------------------- | +| 0 | advertisement ID | +| 1 | platform session ID | +| 2 | GoG lobby ID (`"0"`) | +| 3 | empty string | +| 4 | empty string | +| 5 | Xbox session ID | +| 6 | host profile ID | +| 7 | state | +| 8 | description | +| 9 | description (duplicate) | +| 10 | visible | +| 11 | map name | +| 12 | options | +| 13 | passworded | +| 14 | max players | +| 15 | slot info | +| 16 | match type | +| 17 | peers | +| 18 | observer count | +| 19 | observer max | +| 20 | observers enabled | +| 21 | observer delay | +| 22 | observers passworded | +| 23 | LAN | +| 24 | start time (or `null`) | +| 25 | relay region (or `null`) | +| 26 | BattleServer name (or `null`) | + +This may be client-version dependent; the 24-field sample is left above because +it is still useful for field names and value shapes. If you are implementing a +server, verify the accepted field count against the exact client build you +target, and test an ordinary custom-game advertisement too: a wrong layout +breaks both custom games and automatch. diff --git a/src/rlink/game/automatch2/notes.md b/src/rlink/game/automatch2/notes.md new file mode 100644 index 0000000..fe96f6c --- /dev/null +++ b/src/rlink/game/automatch2/notes.md @@ -0,0 +1,134 @@ +# AoE2:DE Automatch2 — implementation notes + +These notes come from building a **minimal 1v1 Automatch2** implementation in a +local RLink-compatible server ([ageLANServer](https://github.com/luskaner/ageLANServer)) +and testing it with two AoE2:DE clients. They are intended to complement the +endpoint pages in this section with observed client behaviour and protocol +details that are easy to miss. + +> These findings are empirical. They describe the client build tested in +> October 2026; other versions may differ. + +## End-to-end flow observed + +1. The client calls `GET /game/automatch2/getAutomatchMap`. +2. The client calls `POST /game/automatch2/polling` repeatedly. It sends several + `matchTypes`; 1v1 queues may arrive as `1`, `6` or `20`. +3. When two compatible players are queued, the server returns a *matched* + polling response containing the host profile ID, map, peers, BattleServer + address/ports and an advertisement ID. +4. The **host** receives the matched response **before the advertisement + exists**. It then creates the advertisement by calling + `POST /game/advertisement/update` with the reserved advertisement ID. + AoE2:DE does **not** use `/advertisement/host` for this flow. +5. The joiner keeps polling until the advertisement exists and then receives the + real matched response. +6. The server notifies the joiner over WSS with an `Automatch2GameMessage`. +7. Both clients connect to the relay using the BattleServer address. + +## Polling response notes + +- Field `1` is the **elapsed wait time in seconds**, not a state enum. +- Field `4` is the **host profile ID** (`match.HostID`). The host should compare + it with its own profile before starting the host flow. +- Peer/relay addresses must be resolved **per request** from the local address + that client used to reach the server. Using a synthetic advertisement IP such + as `/10.0.11.x` here makes the client try to resolve it as a hostname and fail. +- `getProfileName?profile_ids=[-1]` may appear during a join. It was also + observed in working flows and is not by itself evidence of a bad host ID. + +## `Automatch2GameMessage` (WSS) + +The payload is a JSON **object** with named keys, including: + +```json +{ + "hostProfileID": 123, + "matchID": 456, + "mapName": "Arabia.rms", + "raceID": 0, + "teamID": 1, + "factionID": 0, + "teamCount": 2, + "maxPlayersPerTeam": 1, + "relayAuthToken": "authtoken", + "relayIP": "192.168.1.10", + "relayPortRaw": 6079, + "relayPortWebSockets": 6080, + "relayPortOutOfBandSocket": 6081, + "matchMembers": [ + { + "sessionID": 123, + "profileID": 123, + "statGroupID": 456, + "raceID": 0, + "teamID": 0, + "ranking": 0, + "url": "192.168.1.10" + }, + { + "sessionID": 789, + "profileID": 789, + "statGroupID": 987, + "raceID": 0, + "teamID": 1, + "ranking": 0, + "url": "" + } + ], + "options": "" +} +``` + +`matchMembers` entries are objects, not positional arrays. The host member's +`url` is the host relay address; joiner members normally have an empty `url`. + +## Advertisement notes (AoE2:DE) + +- An ordinary party/session host uses description `SESSION_MATCH_KEY`, + `matchtype=1`, `maxplayers=4` and an empty map. It is **not** an automatch. +- Automatch advertisements use `matchtype=19` and/or a positive + `automatchPoll_id`. The implementation sets `matchtype=19`, forces the 1v1 + map/max-player values, keeps the ad joinable, and preserves those fields when + the host sends later updates. +- Automatch joins should bypass the random advertisement password; teams are + assigned by the polling response, not by the advertisement peer team field. +- `updatePlatformLobbyID` may return `[0]` for a pending automatch session + instead of an error. +- Store the platform session ID before the advertisement exists and copy it in + when the advertisement is created. +- **Field count matters.** In our tests the current client rejected the 24-field + advertisement layout with `WorldwideUpdateAdvertisementAsync - response was + not formatted correctly` and `AdvertisementByIDCache - response was not + formatted correctly`, for both ordinary and automatch advertisements. A + 27-field layout was accepted. See the note added to + [getAdvertisements](../advertisement/getadvertisements.md). + +## Minimal 1v1 rules used + +- Accept aliases `20`, `6` and `1`; expose canonical queue/leaderboard `6`. +- Maximum 2 players, team size 1, map `Arabia.rms`. +- Pair the earliest queued host with the next compatible player. +- Reserve the match ID as the future advertisement ID. +- Host timeout: about 45 seconds while waiting for the advertisement. +- On cancel/failure, clean up the match and decide explicitly whether players + are requeued. The current simple implementation requeues on host failure; + this is a policy choice, not a protocol requirement. + +## Ideas for future work + +This minimal implementation is intentionally simple. The protocol leaves room +for: + +- MMR/skill-based pairing and real leaderboard/rating updates. +- Multiple maps, map pools and queue types (team games, unranked, etc.). +- Host migration, better cancellation and retry policy. +- Region-aware BattleServer selection and NAT/relay fallback. +- Richer validation of `matchTypes`, races and party state. +- More robust handling of client-version differences in the advertisement and + GameMessage formats. + +## Reference implementation + +- [ageLANServer](https://github.com/luskaner/ageLANServer) (upstream) +- Example fork/PR: [Chichimokers/ageLANServer PR #1](https://github.com/Chichimokers/ageLANServer/pull/1) diff --git a/src/rlink/game/automatch2/polling.md b/src/rlink/game/automatch2/polling.md index 7c22344..1dc2647 100644 --- a/src/rlink/game/automatch2/polling.md +++ b/src/rlink/game/automatch2/polling.md @@ -20,29 +20,31 @@ Cache-Control: no-store appBinCRC=9876&callNum=123&connect_id=ccc&dataCRC=6789&factionIDs=[0,0,0,0]&lastCallTime=111&matchTypes=[20,21,22,23]&modDLLFile=INVALID&modDllCRC=0&modName=INVALID&modVersion=INVALID&options=["","","",""]&partySessionID=444&raceInfoKey=[0,1,2,3]&raceInfoProfileID=[1,1,1,1]&raceInfoRaceID=[8,8,8,8]&relayPingTimes=[1,1,2,2,3,3,4,4,5]&relayRegion=eastus&relayRegions=["westeurope","ukwest","australiasoutheast","westindia","eastus","southeastasia","brazilsouth","westus2","koreacentral"]&sessionID=zzz&versionFlags=0 ``` -| parameter | type | value | comments | -| ----------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------ | -------------------- | -| appBinCRC | CRC | 9876 | | -| callNum | int | 123 | | -| connect_id | str | ccc | | -| dataCRC | int | 6789 | backwards appBinCRC? | -| factionIDs | array[int] | [0,0,0,0] | | -| lastCallTime | int | 111 | | -| matchTypes | array[int] | [20,21,22,23] | | -| modDLLFile | ? | INVALID | | -| modDllCRC | int | 0 | | -| modName | str | INVALID | | -| modVersion | int | INVALID | | -| options | array[str] | ["","","",""] | | -| partySessionID | int | 444 | | -| raceInfoKey | array[int] | [0,1,2,3] | | -| raceInfoProfileID | array[int] | [1,1,1,1] | | -| raceInfoRaceID | array[int] | [8,8,8,8] | | -| relayPingTimes | array[int] | [1,1,2,2,3,3,4,4,5] | | -| relayRegion | str/enum | eastus | | -| relayRegions | array[str/enum] | ["westeurope","ukwest","australiasoutheast","westindia","eastus","southeastasia","brazilsouth","westus2","koreacentral"] | | -| sessionID | str | zzz | | -| versionFlags | int | 0 | | +| parameter | type | value | comments | +| ----------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------- | +| appBinCRC | CRC | 9876 | | +| callNum | int | 123 | | +| connect_id | str | ccc | | +| dataCRC | int | 6789 | backwards appBinCRC? | +| factionIDs | array[int] | [0,0,0,0] | | +| lastCallTime | int | 111 | | +| matchTypes | array[int] | [20,21,22,23] | | +| modDLLFile | ? | INVALID | | +| modDllCRC | int | 0 | | +| modName | str | INVALID | | +| modVersion | int | INVALID | | +| options | array[str] | ["","","",""] | | +| partySessionID | int | 444 | | +| raceInfoKey | array[int] | [0,1,2,3] | | +| raceInfoProfileID | array[int] | [1,1,1,1] | | +| raceInfoRaceID | array[int] | [8,8,8,8] | | +| relayPingTimes | array[int] | [1,1,2,2,3,3,4,4,5] | | +| relayRegion | str/enum | eastus | | +| relayRegions | array[str/enum] | ["westeurope","ukwest","australiasoutheast","westindia","eastus","southeastasia","brazilsouth","westus2","koreacentral"] | | +| sessionID | str | zzz | | +| versionFlags | int | 0 | | +| vetoMapKey | array[int] | [0,0] | Not in the sample above, sent by current clients | +| vetoMaps | array[str] | ["Arabia.rms","Arena.rms"] | Not in the sample above, maps banned by this player | ### Response @@ -104,6 +106,34 @@ appBinCRC=9876&callNum=123&connect_id=ccc&dataCRC=6789&factionIDs=[0,0,0,0]&last ] ``` +### Map vetoes and party session (AoE2:DE) + +The current AoE2:DE client adds two parameters that older captures do not show: + +``` +...&vetoMapKey=%5B0%2C0%5D&vetoMaps=%5B%22Arabia.rms%22%2C%22Arena.rms%22%5D +``` + +- `vetoMapKey` holds indices into the map pool, `vetoMaps` the map names the + player banned in the matchmaking UI. Both describe the same bans; a server can + use whichever is easier to match against its pool. +- Names arrive with the `.rms` suffix (`.rms2` was also seen), while map pools + are often stored without it and with underscores instead of spaces. Comparing + the raw strings is not enough: normalise case, suffix, `_` and repeated + spaces before matching (`"Arabian Desert.rms"` and `"Arabian Desert"` are the + same map). +- The veto is per player. The map chosen for a match should be one that + **neither** queued player banned. If both players together cover the whole + pool, most implementations relax the bans instead of cancelling the match + (in our server: first the host's bans, then the joiner's, then all maps). +- `matchTypes` carries the queue, not the ladder: 1v1 random map arrives as `6` + (also seen as `20` and `1`). The ladder for the leaderboard is + `leaderboard_id=3`, see the + [leaderboard notes](../leaderboard/notes.md). +- `partySessionID` is a real ID even for a solo player (we captured values such + as `414290718` without being in a party), so a positive value does not mean + "queue as a party". + ## AoE4 ### Request diff --git a/src/rlink/game/invitation/README.md b/src/rlink/game/invitation/README.md index b2c8db2..0eb468e 100644 --- a/src/rlink/game/invitation/README.md +++ b/src/rlink/game/invitation/README.md @@ -6,7 +6,11 @@ Endpoints for managing game lobby invitations, allowing players to invite others ## Endpoints -| Endpoint | Method | Description | -| ----------------------------------------- | ------ | ---------------------------------------------- | -| [extendInvitation](./extendinvitation.md) | POST | Send a game lobby invitation to another player | -| [cancelInvitation](./cancelinvitation.md) | POST | Cancel a pending invitation | +| Endpoint | Method | Description | +| ------------------------------------------- | ------ | ---------------------------------------------- | +| [extendInvitation](./extendinvitation.md) | POST | Send a game lobby invitation to another player | +| [replyToInvitation](./replytoinvitation.md) | POST | Answer a received invitation (accept/decline) | +| [cancelInvitation](./cancelinvitation.md) | POST | Cancel a pending invitation | + +The full party invitation sequence, including the WSS messages exchanged by the +inviter and the invitee, is documented in [notes](./notes.md). diff --git a/src/rlink/game/invitation/extendinvitation.md b/src/rlink/game/invitation/extendinvitation.md index 78b55e9..f91e66d 100644 --- a/src/rlink/game/invitation/extendinvitation.md +++ b/src/rlink/game/invitation/extendinvitation.md @@ -32,6 +32,19 @@ Content-Length: 172 ``` [ - 2 // result status code + 0 // result status code: 0 = success, 2 = error ] ``` + +The sample above is the error form. A successful invitation returns `[0]` and +the invitee receives a WSS `ExtendInvitationMessage` with +`[inviterProfileInfo, gatheringid, gatheringpassword]`. + +### Notes + +- The server must validate that the gathering exists, that `gatheringpassword` + matches and that the caller is already a peer of it. The invitee is identified + by **profile ID** (`inviteeid`), not by platform user ID. +- The answer comes back through + [replyToInvitation](./replytoinvitation.md); see the + [invitation flow notes](./notes.md) for the full captured sequence. diff --git a/src/rlink/game/invitation/notes.md b/src/rlink/game/invitation/notes.md new file mode 100644 index 0000000..57ee856 --- /dev/null +++ b/src/rlink/game/invitation/notes.md @@ -0,0 +1,96 @@ +# AoE2:DE party invitations - observed flow + +These notes come from capturing a full party invitation between two AoE2:DE +clients connected to a local RLink-compatible server +([ageLANServer](https://github.com/luskaner/ageLANServer)). They describe what +the client actually sends, so a server can reproduce the flow without +implementing Steam friends. + +> Empirical, from the PC client build tested in October 2026. Other versions may +> differ. + +## Short version + +Invitations are **pure RLink**: the client searches profiles, calls +`invitation/extendInvitation`, and the invitee answers with +`invitation/replyToInvitation`. The party itself is an ordinary advertisement +with `matchtype=1`. Steam is only used for the platform session ID; no Steam +friend list, lobby or `InviteUserToGame` call is involved, so a Steam emulator +without friends still works. + +## Captured sequence + +Profile IDs in the examples: host `2092636891`, invitee `1232055058`, gathering +`150963865`. + +| # | Who | Endpoint | Relevant payload | +| -- | ------- | ----------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | host | `POST /game/advertisement/host` | `advertisementid=-1`, `description=SESSION_MATCH_KEY`, `matchtype=1`, `maxplayers=4`, `mapname=`, `visible=0`, `joinable=1`, `passworded=0`, `party=-1`, `team=-1`, `statgroup=-1` | +| 2 | host | `POST /game/party/peerAdd` | `match_id=150963865`, `profile_ids=[2092636891]`, `race_ids=[0]`, `teamIDs=[-1]` | +| 3 | host | `POST /game/advertisement/updatePlatformSessionID` | `matchID=150963865`, `onlinePlatform=STEAM`, `platformSessionID=...` | +| 4 | host | `POST /game/advertisement/update` | same advertisement fields as step 1 | +| 5 | host | `GET /game/account/FindProfiles?name=c` | called twice, with `c` and `C` (the client re-queries as the text changes) | +| 6 | host | `POST /game/invitation/extendInvitation` | `gatheringid=150963865`, `gatheringpassword=`, `inviteeid=1232055058` | +| 7 | server | WSS to invitee | `ExtendInvitationMessage` | +| 8 | invitee | `GET /game/advertisement/getAdvertisements?match_ids=[150963865]` | | +| 9 | invitee | `GET /game/account/getProfileName?profile_ids=[-1]` | also seen in working flows, not an error by itself | +| 10 | invitee | `POST /game/invitation/replyToInvitation` | `gatheringid=150963865`, `invitationreply=0`, `inviterid=2092636891` | +| 11 | server | WSS to inviter | `ReplyInvitationMessage` | +| 12 | invitee | `POST /game/advertisement/join` | `advertisementid=150963865`, `party=-1`, `team=-1`, `statgroup=-1`, `race=-1`, `password=` | +| 13 | host | `POST /game/party/peerAdd` | `match_id=150963865`, `profile_ids=[1232055058]`, `teamIDs=[-1]` | +| 14 | host | `POST /game/advertisement/update` | advertisement now has two peers | + +The advertisement ID returned by `advertisement/host` (here `150963865`) is the +`gatheringid` used by every invitation call and by `party/peerAdd` as +`match_id`. + +## WSS messages + +The invitation notifications are positional arrays, like most RLink WSS +payloads: + +```jsonc +// ExtendInvitationMessage, sent to the invitee +[ + [ /* inviter profileInfo */ ], // same shape as the profileInfo in the leaderboard responses + 150963865, // gatheringid + "" // gathering password (empty when not passworded) +] + +// ReplyInvitationMessage, sent to the inviter +[ + [ /* invitee profileInfo */ ], + 150963865, + "1" // answer as a string: "1" accept, "0" otherwise +] +``` + +The invitee must receive `ExtendInvitationMessage` for the invitation popup to +appear; the inviter must receive `ReplyInvitationMessage` to update the party +UI. + +## Parameters and gotchas + +- `inviteeid` and `inviterid` are **profile IDs**, not platform user IDs and not + stat group IDs. `statGroup_ids` appears only in `party/peerAdd`. +- `extendInvitation` is validated server-side against the gathering: it must + exist, the `gatheringpassword` must match, and the caller must already be a + peer of it. In our implementation any failure returns `[2]`; a successful + call returns `[0]` (the sample in + [extendInvitation](./extendinvitation.md) shows the error form). +- `FindProfiles` is the invite dialog's search. Our server matches aliases + case-insensitively as a substring and returns **every** matching user, + including the caller and offline profiles. The client does not need a Steam + friend list to invite. +- `advertisement/join` for a party is the normal join endpoint with + `party=-1`, `team=-1`, `statgroup=-1` and `race=-1`. Teams are not assigned by + the party advertisement. +- Members are added with `teamIDs=[-1]` while the party is only a lobby. A + later game start assigns real teams. +- `advertisement/updatePlatformSessionID` carries the Steam lobby/session ID + (`onlinePlatform=STEAM`). A local server can accept and store it; the + invitation flow keeps working without it as long as the advertisement exists. +- The party advertisement is also what the client uses as its session when it + opens the matchmaking screen, so the `partySessionID` sent by + `automatch2/polling` is a real ID even for a solo player (see + [polling](../automatch2/polling.md)). diff --git a/src/rlink/game/invitation/replytoinvitation.md b/src/rlink/game/invitation/replytoinvitation.md new file mode 100644 index 0000000..d7094ce --- /dev/null +++ b/src/rlink/game/invitation/replytoinvitation.md @@ -0,0 +1,57 @@ +# [POST] /game/invitation/replyToInvitation + +**AUTHENTICATION** + +Answer sent by the invitee after the client shows the invitation. The server +relays it to the inviter over WSS (`ReplyInvitationMessage`). + +## AoE2:DE + +### Request + +``` +POST /game/invitation/replyToInvitation HTTP/1.1 +Host: aoe-api.worldsedgelink.com +Accept: */* +Accept-Encoding: identity +Content-Type: application/x-www-form-urlencoded; charset=UTF-8 +Cache-Control: no-store +Pragma: no-cache + +callNum=61&connect_id=yam8gu2dsn23p8fazf6oycimo3433p&gatheringid=150963865&invitationreply=0&inviterid=2092636891&sessionID=yam8gu2dsn23p8fazf6oycimo3433p +``` + +| parameter | type | value | comments | +| --------------- | ---- | ---------- | -------------------------------------------------------------------- | +| callNum | int | 61 | | +| connect_id | str | | | +| gatheringid | int | 150963865 | Advertisement ID of the party/lobby, the same sent in the invitation | +| invitationreply | int | 0 | Reply code, see the note below | +| inviterid | int | 2092636891 | Profile ID of the inviter (not the stat group ID) | +| sessionID | str | | | + +No `lastCallTime` was present in the captured request. + +### Response + +``` +[ + 0 // result status code: 0 = success, 2 = error +] +``` + +### Notes + +- This endpoint is missing from the endpoint tables of older captures, but the + client calls it on every invitation answer: without it the inviter never sees + the answer and the invitee stops before joining. +- `invitationreply` polarity is not obvious. In our captures the client sent + `0` when the user **accepted** and then joined the party, so `0` cannot be + assumed to mean "decline". We did not capture a rejection. +- The server forwards the answer to the inviter as a **string** in + `ReplyInvitationMessage` (`"1"` for accept, `"0"` otherwise in our + implementation). See the [invitation flow notes](./notes.md). +- After a successful reply the invitee normally calls + `advertisement/getAdvertisements?match_ids=[gatheringid]` and then + `advertisement/join`; the inviter receives `party/peerAdd` and + `advertisement/update` for the new member. diff --git a/src/rlink/game/leaderboard/README.md b/src/rlink/game/leaderboard/README.md index b82a562..8f494e4 100644 --- a/src/rlink/game/leaderboard/README.md +++ b/src/rlink/game/leaderboard/README.md @@ -17,3 +17,7 @@ Endpoints for accessing player rankings, statistics, and match history. | [getStatGroupsByProfileIDs](./getstatgroupsbyprofileids.md) | GET | Get stat groups for multiple profile IDs | | [getStatsForLeaderboardByProfileName](./getstatsforleaderboardbyprofilename.md) | GET | Search leaderboard stats by player name | | [setAvatarStatValues](./setavatarstatvalues.md) | POST | Update avatar statistics | + +The positional layout shared by the stat groups, profile infos and leaderboard +stats arrays (and the match history fields) is documented in +[notes](./notes.md). diff --git a/src/rlink/game/leaderboard/notes.md b/src/rlink/game/leaderboard/notes.md new file mode 100644 index 0000000..f8c55dc --- /dev/null +++ b/src/rlink/game/leaderboard/notes.md @@ -0,0 +1,166 @@ +# AoE2:DE leaderboard payloads - field layouts + +The Leaderboard endpoints look like opaque nested arrays, and the client parses +them **positionally**: there are no field names on the wire. This page lists the +layouts we had to match to make the ladder, the party stats and the match +history render in a local RLink-compatible server +([ageLANServer](https://github.com/luskaner/ageLANServer)). + +> Empirical, from the PC client build tested in October 2026. Older captures in +> this wiki can show shorter forms of the same arrays (see the notes per table). + +## Shared response shape + +`getLeaderBoard`, `getPartyStat`, `getStatGroupsByProfileIDs` and +`getStatsForLeaderboardByProfileName` all return the same four elements: + +```jsonc +[ + 0, // result status code + [/* statGroups */], + [/* profileInfos */], + [/* leaderboardStats */], +] +``` + +The three arrays are matched **by index**: `statGroups[i]`, `profileInfos[i]` +and `leaderboardStats[i]` describe the same player, in the same order. + +If the arrays are not aligned (different lengths, a stat group whose profile is +missing, or a stats array that is shorter than the stat groups), the client +discards the whole response and logs: + +``` +LeaderboardCache - response was not formatted correctly +``` + +Our first attempt failed exactly like that. The safe rule is: only emit an entry +when you can fill all three positions, and skip the player otherwise. + +## statGroup (5 fields) + +| Index | Field | Notes | +| ----- | ------------- | ------------------------------------------ | +| 0 | statGroupID | Same value as `statGroup_ids` in `peerAdd` | +| 1 | statGroupName | Empty string when unnamed | +| 2 | metaData | Empty string when there is none | +| 3 | type | `1` for a single player | +| 4 | profileIDs | Array of profile IDs, e.g. `[5591467]` | + +## profileInfo (14 or 15 fields) + +| Index | Field | Notes | +| ----- | ---------------------- | ----------------------------------------------------- | +| 0 | entityVersion | Unix timestamp, only needs to be stable | +| 1 | profileID | | +| 2 | name | Platform path, e.g. `/steam/76561199163227629` | +| 3 | metaData | Avatar metadata as a JSON string, `""` when none | +| 4 | alias | Display name | +| 5 | sanitizedAlias | Only for clients with lib version >= 190 | +| 6 | clanName | Empty string when the player has no clan | +| 7 | personalStatGroupID | | +| 8 | xp | | +| 9 | level | | +| 10 | leaderboardRegionID | | +| 11 | banInfo | `null` when the player is not banned | +| 12 | platformUserID | **String**, not a number (e.g. `"76561199163227629"`) | +| 13 | accountType | `3` Steam, `9` Xbox | +| 14 | linkedPlatformAccounts | Empty array when none | + +The sample in [getLeaderBoard](./getleaderboard.md) has the 14-field form +(without `sanitizedAlias`), because it was captured with an older client. In +that form every field after `alias` shifts down by one, so `clanName` is at +index 5 and `linkedPlatformAccounts` at index 13. + +## leaderboardStat (19 fields) + +| Index | Field | Notes | +| ----- | ----------------- | ---------------------------------------- | +| 0 | statGroupID | | +| 1 | leaderboardID | Ladder ID, `3` for 1v1 random map | +| 2 | wins | | +| 3 | losses | | +| 4 | streak | | +| 5 | disputes | `0` when unused | +| 6 | drops | | +| 7 | ranking | `-1` when unranked | +| 8 | rankTotal | `-1` when unranked | +| 9 | regionRanking | `-1` when unused | +| 10 | regionRankTotal | `-1` when unused | +| 11 | level | | +| 12 | rating | MMR / Elo | +| 13 | lastMatchTime | Unix timestamp | +| 14 | highestRank | | +| 15 | highestRankLevel | | +| 16 | highestRating | | +| 17 | platformRank | Only for clients with lib version >= 190 | +| 18 | platformRankTotal | Only for clients with lib version >= 190 | + +The sample in [getLeaderBoard](./getleaderboard.md) shows a 14-field form +(`statGroupID` through `lastMatchTime`), captured before `highest*` and +`platform*` were added. The current client expects the longer form; the 17-field +form has no `platformRank`/`platformRankTotal` and ends at `highestRating`. + +The same array is used outside the Leaderboard endpoints: + +- `platformlogin` includes it in `selectProfile`. +- The account/profile payloads include an `extraProfileInfo` with the same + positional layout: 17 fields, or 19 with the two `platform*` fields. + +## Ladder ID is not the queue ID + +The 1v1 random map ladder is `leaderboard_id=3`. The automatch queues use +`matchTypes=[6]` (and `20`/`1` appear in other client builds). A server has to +map queue IDs to ladder IDs instead of reusing one for the other. + +`getLeaderBoard` also receives `start` and `count`. `start` is **1-based** +(`start=1` is the first entry), so the page offset is `start - 1`. + +## getRecentMatchHistory + +```jsonc +[ + 0, // result status code + [/* matches */], +] +``` + +Each match has **16 fields**: + +| Index | Field | Notes | +| ----- | ------------------------- | -------------------------------------------------------- | +| 0 | matchHistoryID | | +| 1 | creatorProfileID | | +| 2 | mapName | | +| 3 | maxPlayers | | +| 4 | matchTypeID | `0` custom, `26` for a 1v1 ranked automatch | +| 5 | encodedOptions | Opaque string | +| 6 | encodedSlots | Opaque string | +| 7 | description | `"AUTOMATCH"` for automatch, the map name for customs | +| 8 | startGameTime | Unix timestamp | +| 9 | completionTime | Unix timestamp | +| 10 | observerTotal | | +| 11 | matchHistoryReportResults | See below | +| 12 | matchHistoryItems | Empty array when there are none | +| 13 | matchUrls | Empty array when there are no replays | +| 14 | matchVersionInfo | 7 fields, e.g. `["INVALID","INVALID","INVALID",0,0,0,0]` | +| 15 | matchHistoryMemberStats | See below | + +`matchHistoryReportResults` entries have **9 fields**: + +`matchHistoryID`, `profileID`, `resultType` (`1` win, `0` loss), `teamID`, +`raceID`, `xpGained`, `counters` (JSON string), `customUpdates` (JSON string), +`matchStartDate`. + +`matchHistoryMemberStats` entries have **13 fields**: + +`matchHistoryID`, `profileID`, `raceID`, `statGroupID`, `teamID`, `wins`, +`losses`, `streak`, `arbitration`, `outcome`, `oldRating`, `newRating`, +`reportType`. + +`oldRating`/`newRating` are what the client shows as the MMR change in the match +history. If the server does not store ratings it can send the same value twice +or `0`. + +The request uses `profile_ids` (an array). Clients may pad it with repeated or +placeholder IDs, so a server should de-duplicate them and ignore unknown ones. diff --git a/src/rlink/game/party/reportmatch.md b/src/rlink/game/party/reportmatch.md index b3ea9cd..35077be 100644 --- a/src/rlink/game/party/reportmatch.md +++ b/src/rlink/game/party/reportmatch.md @@ -1,76 +1,5 @@ -# [POST] /game/party/reportMatch +### Notes -**AUTHENTICATION** - -## AoE2:DE - -### Request - -``` -POST /game/party/reportMatch HTTP/1.1 -Cookie: ApplicationGatewayAffinity=0;ApplicationGatewayAffinityCORS=1;worldsedgelink=2; -Host: aoe-api.worldsedgelink.com -Accept-Encoding: identity -Accept: */* -Content-Length: 1265 -Pragma: no-cache -Content-Type: application/x-www-form-urlencoded; charset=UTF-8 -Cache-Control: no-store -Expect: 100-continue - -callNum=123&checkSums=[0,0]&connect_id=ccc&countersZip=zip&itemUpdates=upd&lastCallTime=111&match_id=555&profile_ids=[1,1]&race_ids=[1,2,3]&results=[0,1]&sessionID=zzz&simplayerIDs=[1,7]&teamIDs=[0,1]&xpGained=[2,20] -``` - -| parameter | type | value | comments | -| ------------ | ---------- | ------- | -------- | -| callNum | int | 123 | | -| checkSums | array[int] | [0,0] | | -| connect_id | str | ccc | | -| countersZip | ? | zip | | -| itemUpdates | ? | upd | | -| lastCallTime | timestamp | 111 | | -| match_id | int | 555 | | -| profile_ids | array[int] | [1,1] | | -| race_ids | array[int] | [1,2,3] | | -| results | array[int] | [0,1] | | -| sessionID | str | zzz | | -| simplayerIDs | array[int] | [1,7] | | -| teamIDs | array[int] | [0,1] | | -| xpGained | array[int] | [2,20] | | - -### Response - -``` -[ - 0, // result status code - [], - [ - 186444398, - "https://rl0aoelivemk2blob.blob.core.windows.net/cloudfiles/436432/aoelive_/age2/replay/windows/4.0.0/0/M_186444398_8b31ed6602350cce7ee1736393cab532712c579e545fdd41995117491b1ae319.gz", - "rl0aoelivemk2blob.blob.core.windows.net", - "/cloudfiles/436432/aoelive_/age2/replay/windows/4.0.0/0/M_186444398_8b31ed6602350cce7ee1736393cab532712c579e545fdd41995117491b1ae319.gz", - 0, - "sig=WND8xzRAWkcTItdgU06jbannF1BSfIH%2BaXnF1kx65CA%3D&se=2022-10-22T01%3A06%3A51Z&sv=2019-02-02&sp=cw&sr=b" - ], - [ - -1, - "", - "", - "", - 0, - "" - ], - null, - [], - [], - [], - [], - [], - [], - 0, - null, - [], - [], - [] -] -``` +- **Every client of the match reports the result**, not just the host: in a 1v1 both peers send `reportMatch` with the same `match_id`. A server that stores or rates the match has to be idempotent per `match_id` (our implementation applies the Elo change only on the first report and ignores the duplicates). +- `results` uses `1` for the winner and `0` for the loser, aligned by index with `profile_ids`. +- The response contains the replay upload target; a local server can return the same shape with empty strings when replays are not supported.