@idosgames/mcp 0.1.8 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "social-system",
3
3
  "description": "Build a friends / social system in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.social (SocialService): load the friends list, incoming friend requests, and recommended friends, send/accept/decline friend requests, remove a friend, and read the social activity timeline (attacks, raids, friend-adds). Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants a friends list screen, friend-request inbox, add-friend flow, recommended friends / player search, or an activity feed, or otherwise touches client.social, SocialService, SocialModels, FriendPublicProfile, or the social timeline — even if they don't name the module explicitly.",
4
- "content": "---\nname: social-system\ndescription: >-\n Build a friends / social system in a game on the iDosGames TypeScript SDK\n (@idosgames/core) via client.social (SocialService): load the friends list,\n incoming friend requests, and recommended friends, send/accept/decline\n friend requests, remove a friend, and read the social activity timeline\n (attacks, raids, friend-adds). Use this whenever the user is working in the\n iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants a\n friends list screen, friend-request inbox, add-friend flow, recommended\n friends / player search, or an activity feed, or otherwise touches\n client.social, SocialService, SocialModels, FriendPublicProfile, or the\n social timeline — even if they don't name the module explicitly.\n---\n\n# Social system (iDosGames TS SDK)\n\nThe Social module is a friends graph plus a lightweight activity feed: players\nsend/accept/decline friend requests, end up with a flat \"Accepted\" friends\nlist, and can read a timeline of social events (attacks, raids, friend-adds).\nIt is **server-authoritative** for the graph itself — the client asks the\nbackend to send/accept/decline/remove, the backend validates and applies it,\nand the SDK mirrors the confirmed result into a local cache your UI reads.\n\nThis skill is for **using** the production `SocialService`, not for porting or\nextending it. If a call is rejected, that's the backend enforcing a rule\n(empty/self target, request not found) — surface the error, don't try to\nreproduce the check client-side. Note some things you might expect to be\nrejected aren't — see Gotchas for duplicate sends and removing a non-friend.\n\n## Counters (server) vs the three lists + the feed (local cache)\n\n⚠ **`UserSocialState` has two halves, and they come from different places.**\n\nThe **counters** — `FriendsCount`, `IncomingCount`, `OutgoingCount` — are what\nthe server actually sends inside player state, and they are correct the moment\nthe player logs in. Friendships and requests themselves live in their own edge\ncollection: they used to be three arrays inside the player document, which\nmeant whoever sent you a request grew *your* document, without a ceiling, and\nit was re-read on every one of *your* calls.\n\nThe **four arrays below are a local SDK cache**, not server state. Nothing\nfills them on login — each is filled by its own call, and `OutgoingRequests`\nonly ever by your own sends. They are lost on restart, because nothing\nre-sends them.\n\nPlan the UI around that: **badges and counts come from the counters, lists only\nfrom a screen that loads them.** A friends-count badge needs no call; a friends\nlist screen must call `getFriendsList()` or it renders empty for a player who\nhas friends.\n\nThe four arrays, all string `UserID` lists except the timeline:\n\n- **`Accepted`** — this player's friends. Populated by `getFriendsList()`\n (which despite its name fills `Accepted`, not a separate `Friends` field) and\n grown/shrunk by `acceptFriendRequest`/`removeFriend`.\n- **`IncomingRequests`** — other players who requested _this_ player.\n Populated by `getIncomingRequests()`; shrinks on accept/decline.\n- **`OutgoingRequests`** — requests _this_ player sent that haven't been\n accepted/declined yet. Grown client-side by `sendFriendRequest`, and loaded\n from the server by `getOutgoingRequests()`. **Call it on any screen that\n offers \"Add friend\"**: without it the list only knows about sends made in\n *this* run, so after a restart (or on a second device) a player who already\n asked someone is offered \"Add\" again. An accepted request leaves this list\n and appears in `Accepted`.\n- **`Timeline`** — a feed of `SocialTimelineEvent` (attacks, raids, friend\n adds), each with an actor, optional `OwnerImpact` (a `ResourceOperation`),\n and `IsBlocked`. Populated by `getTimeline()`.\n\n`getRecommendedFriends()` is separate again: it doesn't touch the cache at all\n(no list to patch), it just returns a `FriendsListResponse` for you to render\nan \"add friend\" suggestion screen from.\n\n## Setup\n\n```ts\nimport { createIDosGamesClient } from \"@idosgames/core\";\n\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\nawait client.auth.loginWithDeviceID(); // or any auth.* method\n\nconst social = client.social; // the SocialService\n```\n\nEvery social method requires an authenticated session. Without one they return\n`{ ok: false, reason: \"unauthorized\" }` — they do not throw. There is one\n`client` per player; don't share it across sessions.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: a discriminated union that is\neither `{ ok: true, data }` or `{ ok: false, reason, error }`. Always branch on\n`result.ok` before touching `result.data`. `reason` is one of `\"client\"` (bad\nlocal args, e.g. missing target id), `\"unauthorized\"`, `\"throttled\"` (fired the\nsame endpoint again inside the throttle window), `\"connection\"` (transient,\noffer Retry), `\"validation\"` (response/schema drift), or `\"server\"` (backend\nrejected it — `error` carries the human-readable reason).\n\n| Method | Purpose | `data` on success |\n| ----------------------------------- | ------------------------------------------- | --------------------------------- |\n| `getFriendsList()` | Load this player's accepted friends. | `FriendsListResponse` (`Friends`) |\n| `getIncomingRequests()` | Load pending requests sent to this player. | `FriendsListResponse` (`Friends`) |\n| `getOutgoingRequests()` | Load pending requests this player sent. | `FriendsListResponse` (`Friends`) |\n| `getRecommendedFriends(limit?)` | Suggested players to add (default limit 5). | `FriendsListResponse` (`Friends`) |\n| `sendFriendRequest(targetUserID)` | Send a friend request. | `FriendActionResponse` (`Status`) |\n| `acceptFriendRequest(requesterID)` | Accept an incoming request. | `FriendActionResponse` (`Status`) |\n| `declineFriendRequest(requesterID)` | Decline an incoming request. | `FriendActionResponse` (`Status`) |\n| `removeFriend(friendUserID)` | Unfriend an existing friend. | `FriendActionResponse` (`Status`) |\n| `getTimeline()` | Load the social activity feed. | `TimelineResponse` (`Events`) |\n\n`FriendActionResponse.Status` is one of `RequestSent`, `Accepted`, `Declined`,\n`Removed` — mirrors the action you just took, not a general friendship state.\n\nThe response is **self-sufficient**: `Counters` carries the sizes of YOUR lists\nafter the operation (`FriendsCount`, `IncomingRequestsCount`,\n`OutgoingRequestsCount`), and `Target` carries the other side's public profile\nwhere the UI needs it right now — sending and accepting a request. Apply your\nown edit locally and reconcile against `Counters`; do not re-issue\n`getFriendsList()` just to redraw. `Target` is absent for decline/remove: the\nentry disappears from the list anyway, so the server does not read the profile.\n\n`getRecommendedFriends(limit)` only lets you tune `limit` (default 5); there is\nno offset/cursor param client-side, and the backend additionally fixes an\ninternal 7-day activity window server-side — see Gotchas.\n\nOn success, each method (except `getRecommendedFriends`) **mirrors the\nconfirmed change into the cache and emits an event** — you don't apply\nanything by hand.\n\n## Reading state and reacting to changes\n\nDrive the UI off the cache, not off one-off return values.\n\n```ts\nconst social = client.data.user.state?.Social;\n\n// Server-sent, correct immediately after login — use these for badges/counts.\nsocial?.FriendsCount; // number\nsocial?.IncomingCount; // number — e.g. the red dot on the friends tab\nsocial?.OutgoingCount; // number\n\n// Local cache — EMPTY until the matching call below has run at least once.\nsocial?.Accepted; // string[] of friend UserIDs — getFriendsList()\nsocial?.IncomingRequests; // string[] awaiting your accept/decline — getIncomingRequests()\nsocial?.OutgoingRequests; // string[] you've sent, not yet resolved — getOutgoingRequests()\nsocial?.Timeline; // SocialTimelineEvent[] — getTimeline()\n```\n\n⚠ Do not derive a count by taking `.length` of one of those arrays: before the\nmatching call has run they are empty, even for a player who has friends and\npending requests. That is exactly what the counters are for.\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `social:friendsListLoaded` → `FriendsListResponse`\n- `social:incomingRequestsLoaded` → `FriendsListResponse`\n- `social:outgoingRequestsLoaded` → `FriendsListResponse`\n- `social:recommendedFriendsLoaded` → `FriendsListResponse` (cache untouched)\n- `social:friendRequestSent` → `FriendActionResponse`\n- `social:friendRequestAccepted` → `FriendActionResponse`\n- `social:friendRequestDeclined` → `FriendActionResponse`\n- `social:friendRemoved` → `FriendActionResponse`\n- `social:timelineLoaded` → `TimelineResponse`\n\nThe coarse `user:socialUpdated` (and `user:anyUpdated`) also fire on any social\ncache write (everything above except recommendations) — handy for a\n\"re-render everything\" hook.\n\n```ts\nconst off = client.on(\"social:friendRequestAccepted\", (r) => {\n console.log(`${r.TargetUserID} is now a friend (${r.Status})`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Friends list + incoming requests screen\n\n```ts\nawait client.social.getFriendsList();\nawait client.social.getIncomingRequests();\n\nconst social = client.data.user.state?.Social;\nconst friends = social?.Accepted ?? []; // render as friend rows\nconst incoming = social?.IncomingRequests ?? []; // render with accept/decline buttons\n```\n\n### Send, then track as outgoing until resolved\n\n```ts\nconst res = await client.social.sendFriendRequest(\"player-42\");\nif (!res.ok) return showError(res.error); // \"Invalid target user.\" if empty/self; see Gotchas for dupes\n\n// cache now has \"player-42\" in OutgoingRequests; UI can show \"Pending\".\n// That entry is local to this run — call getOutgoingRequests() when the screen\n// opens so a restarted app still shows \"Pending\" instead of \"Add\".\n// There's no push for the other side's decision — re-check via\n// getFriendsList()/getOutgoingRequests() (e.g. on next screen focus): an\n// accepted request moves to Accepted and leaves OutgoingRequests.\n```\n\n### Accept or decline an incoming request\n\n```ts\nconst res = await client.social.acceptFriendRequest(\"player-7\");\nif (!res.ok) return showError(res.error); // \"Friend request not found.\" if not actually incoming\n// \"player-7\" moved from IncomingRequests to Accepted in the cache.\n\nawait client.social.declineFriendRequest(\"player-8\");\n// \"player-8\" removed from IncomingRequests; no trace kept client-side.\n```\n\n### Remove a friend\n\n```ts\nconst res = await client.social.removeFriend(\"player-7\");\nif (!res.ok) return showError(res.error); // \"Invalid friend user.\" if id is empty\n// \"player-7\" removed from Accepted in the cache.\n```\n\nNote: `removeFriend` doesn't verify the two are actually friends before\npatching — it's an unconditional `$pull` on both sides, so calling it on a\nnon-friend id just succeeds as a no-op (`Status: \"Removed\"`), it never rejects\nwith \"not a friend\".\n\n### Recommended friends (add-friend suggestions)\n\n```ts\nconst res = await client.social.getRecommendedFriends(10);\nif (res.ok) {\n for (const profile of res.data.Friends ?? []) {\n // profile.UserID, profile.PublicData — render an \"Add\" button that\n // calls sendFriendRequest(profile.UserID)\n }\n}\n```\n\n### Activity timeline\n\n```ts\nconst res = await client.social.getTimeline();\nif (res.ok) {\n for (const event of res.data.Events ?? []) {\n // event.Type: \"Attack\" | \"Raid\" | \"FriendAdd\" (free-form string, not a closed enum client-side)\n // event.ActorUserID / event.ActorProfile, event.OwnerImpact (ResourceOperation), event.IsBlocked\n }\n}\n```\n\nIn practice today only `\"Attack\"` and `\"Raid\"` events are ever written (by the\nGameLoop module, on resolving an attack/raid against this player) — `FriendAdd`\nis a modeled event type with no current caller anywhere in the backend, so\ndon't expect friend-add activity to show up in the timeline yet. Build your UI\nagainst the type string, not against an assumption of which types are live.\n\n## Gotchas\n\n- **`getFriendsList()` fills `Accepted`, not `Friends`.** The response DTO is\n named `FriendsListResponse` with a `Friends` array, but the cache field it\n writes to is `Social.Accepted` — don't look for `Social.Friends`.\n- **No outgoing-request removal/cancel method.** Once sent, an outgoing\n request only leaves `OutgoingRequests` when you next call\n `getFriendsList()`/`getIncomingRequests()` and it's no longer reflected by\n the backend (accepted or declined elsewhere) — there's no client-side cancel\n and no dedicated \"outgoing requests\" fetch.\n- **`getRecommendedFriends` never touches the cache.** It's the only read\n method here with no `applySocial*`/cache write — treat its result as\n transient render data, not state.\n- **Recommendations are filtered server-side, not just randomly sampled.**\n The backend excludes yourself, your current `Accepted`/`IncomingRequests`/\n `OutgoingRequests` (so you never get suggested someone you already have a\n pending relationship with), requires the candidate to have a non-null public\n profile, and requires the candidate to have made a request within the last 7\n days (hardcoded server-side, not a client param) — then samples `limit`\n results pseudo-randomly. A quiet title can legitimately return an empty list\n even with plenty of registered players.\n- **Friend cap is 999, enforced only on `acceptFriendRequest`.** When your\n `Accepted` list is already at the cap, accepting doesn't reject — the server\n first auto-evicts your least-recently-active friend (by\n `LastRequestHeaders.RequestTime`) via an internal `removeFriend`, then adds\n the new one. `sendFriendRequest` itself has no cap check, so you can always\n accumulate incoming requests even while full.\n- **`sendFriendRequest` rejects a self-request or empty id** with\n `\"Invalid target user.\"`, but does **not** reject re-sending to someone you\n already sent a request to, already have incoming from, or are already\n friends with — `OutgoingRequests`/`IncomingRequests` are Mongo `$addToSet`,\n so a duplicate send is a harmless no-op that still returns\n `Status: \"RequestSent\"`. Don't rely on an error to detect \"already\n pending\" — check the cache (`OutgoingRequests`/`Accepted`) before sending.\n- **Guard against double-submit.** Each call mints a fresh idempotency key, so\n two separate calls are two real operations. Disable the control while a call\n is in flight. (Firing the same endpoint again within the throttle window,\n default 600 ms, is rejected with `reason: \"throttled\"` rather than\n duplicated, but don't rely on that for correctness.)\n- **Render from the cache, handle the error from the result.** The happy path\n updates the cache + emits an event; the failure path gives you `reason` +\n `error`. Use `reason` to decide behavior (retry on `\"connection\"`, re-auth on\n `\"unauthorized\"`, toast the `error` on `\"server\"`).\n- **`TimelineEvent.Type` is a free-form string client-side**, not a strict\n union — the model uses `.passthrough()` so new event types the backend adds\n later still round-trip; don't hard-code an exhaustive switch without a\n default case.\n- **Friendship can also be granted outside Social**, e.g. Referral activation\n calls an internal mutual-friend helper directly — it's still just `Accepted`\n entries on both sides, so it shows up the same way once you refresh\n `getFriendsList()`; no separate signal distinguishes \"how\" a friend was\n added.\n",
4
+ "content": "---\nname: social-system\ndescription: >-\n Build a friends / social system in a game on the iDosGames TypeScript SDK\n (@idosgames/core) via client.social (SocialService): load the friends list,\n incoming friend requests, and recommended friends, send/accept/decline\n friend requests, remove a friend, and read the social activity timeline\n (attacks, raids, friend-adds). Use this whenever the user is working in the\n iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants a\n friends list screen, friend-request inbox, add-friend flow, recommended\n friends / player search, or an activity feed, or otherwise touches\n client.social, SocialService, SocialModels, FriendPublicProfile, or the\n social timeline — even if they don't name the module explicitly.\n---\n\n# Social system (iDosGames TS SDK)\n\nThe Social module is a friends graph plus a lightweight activity feed: players\nsend/accept/decline friend requests, end up with a flat \"Accepted\" friends\nlist, and can read a timeline of social events (attacks, raids, friend-adds).\nIt is **server-authoritative** for the graph itself — the client asks the\nbackend to send/accept/decline/remove, the backend validates and applies it,\nand the SDK mirrors the confirmed result into a local cache your UI reads.\n\nThis skill is for **using** the production `SocialService`, not for porting or\nextending it. If a call is rejected, that's the backend enforcing a rule\n(empty/self target, request not found) — surface the error, don't try to\nreproduce the check client-side. Note some things you might expect to be\nrejected aren't — see Gotchas for duplicate sends and removing a non-friend.\n\n## Counters (server) vs the three lists + the feed (local cache)\n\n⚠ **`UserSocialState` has two halves, and they come from different places.**\n\nThe **counters** — `FriendsCount`, `IncomingCount`, `OutgoingCount` — are what\nthe server actually sends inside player state, and they are correct the moment\nthe player logs in. Friendships and requests themselves live in their own edge\ncollection: they used to be three arrays inside the player document, which\nmeant whoever sent you a request grew _your_ document, without a ceiling, and\nit was re-read on every one of _your_ calls.\n\nThe **four arrays below are a local SDK cache**, not server state. Nothing\nfills them on login — each is filled by its own call, and `OutgoingRequests`\nonly ever by your own sends. They are lost on restart, because nothing\nre-sends them.\n\nPlan the UI around that: **badges and counts come from the counters, lists only\nfrom a screen that loads them.** A friends-count badge needs no call; a friends\nlist screen must call `getFriendsList()` or it renders empty for a player who\nhas friends.\n\nThe four arrays, all string `UserID` lists except the timeline:\n\n- **`Accepted`** — this player's friends. Populated by `getFriendsList()`\n (which despite its name fills `Accepted`, not a separate `Friends` field) and\n grown/shrunk by `acceptFriendRequest`/`removeFriend`.\n- **`IncomingRequests`** — other players who requested _this_ player.\n Populated by `getIncomingRequests()`; shrinks on accept/decline.\n- **`OutgoingRequests`** — requests _this_ player sent that haven't been\n accepted/declined yet. Grown client-side by `sendFriendRequest`, and loaded\n from the server by `getOutgoingRequests()`. **Call it on any screen that\n offers \"Add friend\"**: without it the list only knows about sends made in\n _this_ run, so after a restart (or on a second device) a player who already\n asked someone is offered \"Add\" again. An accepted request leaves this list\n and appears in `Accepted`.\n- **`Timeline`** — a feed of `SocialTimelineEvent` (attacks, raids, friend\n adds), each with an actor, optional `OwnerImpact` (a `ResourceOperation`),\n and `IsBlocked`. Populated by `getTimeline()`.\n\n`getRecommendedFriends()` is separate again: it doesn't touch the cache at all\n(no list to patch), it just returns a `FriendsListResponse` for you to render\nan \"add friend\" suggestion screen from.\n\n## Setup\n\n```ts\nimport { createIDosGamesClient } from \"@idosgames/core\";\n\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\nawait client.auth.loginWithDeviceID(); // or any auth.* method\n\nconst social = client.social; // the SocialService\n```\n\nEvery social method requires an authenticated session. Without one they return\n`{ ok: false, reason: \"unauthorized\" }` — they do not throw. There is one\n`client` per player; don't share it across sessions.\n\n## Methods\n\nAll methods return `Promise<OperationResult<T>>`: a discriminated union that is\neither `{ ok: true, data }` or `{ ok: false, reason, error }`. Always branch on\n`result.ok` before touching `result.data`. `reason` is one of `\"client\"` (bad\nlocal args, e.g. missing target id), `\"unauthorized\"`, `\"throttled\"` (fired the\nsame endpoint again inside the throttle window), `\"connection\"` (transient,\noffer Retry), `\"validation\"` (response/schema drift), or `\"server\"` (backend\nrejected it — `error` carries the human-readable reason).\n\n| Method | Purpose | `data` on success |\n| ----------------------------------- | ------------------------------------------- | --------------------------------- |\n| `getFriendsList()` | Load this player's accepted friends. | `FriendsListResponse` (`Friends`) |\n| `getIncomingRequests()` | Load pending requests sent to this player. | `FriendsListResponse` (`Friends`) |\n| `getOutgoingRequests()` | Load pending requests this player sent. | `FriendsListResponse` (`Friends`) |\n| `getRecommendedFriends(limit?)` | Suggested players to add (default limit 5). | `FriendsListResponse` (`Friends`) |\n| `sendFriendRequest(targetUserID)` | Send a friend request. | `FriendActionResponse` (`Status`) |\n| `acceptFriendRequest(requesterID)` | Accept an incoming request. | `FriendActionResponse` (`Status`) |\n| `declineFriendRequest(requesterID)` | Decline an incoming request. | `FriendActionResponse` (`Status`) |\n| `removeFriend(friendUserID)` | Unfriend an existing friend. | `FriendActionResponse` (`Status`) |\n| `getTimeline()` | Load the social activity feed. | `TimelineResponse` (`Events`) |\n\n`FriendActionResponse.Status` is one of `RequestSent`, `Accepted`, `Declined`,\n`Removed` — mirrors the action you just took, not a general friendship state.\n\nThe response is **self-sufficient**: `Counters` carries the sizes of YOUR lists\nafter the operation (`FriendsCount`, `IncomingRequestsCount`,\n`OutgoingRequestsCount`), and `Target` carries the other side's public profile\nwhere the UI needs it right now — sending and accepting a request. Apply your\nown edit locally and reconcile against `Counters`; do not re-issue\n`getFriendsList()` just to redraw. `Target` is absent for decline/remove: the\nentry disappears from the list anyway, so the server does not read the profile.\n\n`getRecommendedFriends(limit)` only lets you tune `limit` (default 5); there is\nno offset/cursor param client-side, and the backend additionally fixes an\ninternal 7-day activity window server-side — see Gotchas.\n\nOn success, each method (except `getRecommendedFriends`) **mirrors the\nconfirmed change into the cache and emits an event** — you don't apply\nanything by hand.\n\n## Reading state and reacting to changes\n\nDrive the UI off the cache, not off one-off return values.\n\n```ts\nconst social = client.data.user.state?.Social;\n\n// Server-sent, correct immediately after login — use these for badges/counts.\nsocial?.FriendsCount; // number\nsocial?.IncomingCount; // number — e.g. the red dot on the friends tab\nsocial?.OutgoingCount; // number\n\n// Local cache — EMPTY until the matching call below has run at least once.\nsocial?.Accepted; // string[] of friend UserIDs — getFriendsList()\nsocial?.IncomingRequests; // string[] awaiting your accept/decline — getIncomingRequests()\nsocial?.OutgoingRequests; // string[] you've sent, not yet resolved — getOutgoingRequests()\nsocial?.Timeline; // SocialTimelineEvent[] — getTimeline()\n```\n\n⚠ Do not derive a count by taking `.length` of one of those arrays: before the\nmatching call has run they are empty, even for a player who has friends and\npending requests. That is exactly what the counters are for.\n\nSubscribe with `client.on(...)`; each returns an unsubscribe fn:\n\n- `social:friendsListLoaded` → `FriendsListResponse`\n- `social:incomingRequestsLoaded` → `FriendsListResponse`\n- `social:outgoingRequestsLoaded` → `FriendsListResponse`\n- `social:recommendedFriendsLoaded` → `FriendsListResponse` (cache untouched)\n- `social:friendRequestSent` → `FriendActionResponse`\n- `social:friendRequestAccepted` → `FriendActionResponse`\n- `social:friendRequestDeclined` → `FriendActionResponse`\n- `social:friendRemoved` → `FriendActionResponse`\n- `social:timelineLoaded` → `TimelineResponse`\n\nThe coarse `user:socialUpdated` (and `user:anyUpdated`) also fire on any social\ncache write (everything above except recommendations) — handy for a\n\"re-render everything\" hook.\n\n```ts\nconst off = client.on(\"social:friendRequestAccepted\", (r) => {\n console.log(`${r.TargetUserID} is now a friend (${r.Status})`);\n});\n// later: off();\n```\n\n## Recipes\n\n### Friends list + incoming requests screen\n\n```ts\nawait client.social.getFriendsList();\nawait client.social.getIncomingRequests();\n\nconst social = client.data.user.state?.Social;\nconst friends = social?.Accepted ?? []; // render as friend rows\nconst incoming = social?.IncomingRequests ?? []; // render with accept/decline buttons\n```\n\n### Send, then track as outgoing until resolved\n\n```ts\nconst res = await client.social.sendFriendRequest(\"player-42\");\nif (!res.ok) return showError(res.error); // \"Invalid target user.\" if empty/self; see Gotchas for dupes\n\n// cache now has \"player-42\" in OutgoingRequests; UI can show \"Pending\".\n// That entry is local to this run — call getOutgoingRequests() when the screen\n// opens so a restarted app still shows \"Pending\" instead of \"Add\".\n// There's no push for the other side's decision — re-check via\n// getFriendsList()/getOutgoingRequests() (e.g. on next screen focus): an\n// accepted request moves to Accepted and leaves OutgoingRequests.\n```\n\n### Accept or decline an incoming request\n\n```ts\nconst res = await client.social.acceptFriendRequest(\"player-7\");\nif (!res.ok) return showError(res.error); // \"Friend request not found.\" if not actually incoming\n// \"player-7\" moved from IncomingRequests to Accepted in the cache.\n\nawait client.social.declineFriendRequest(\"player-8\");\n// \"player-8\" removed from IncomingRequests; no trace kept client-side.\n```\n\n### Remove a friend\n\n```ts\nconst res = await client.social.removeFriend(\"player-7\");\nif (!res.ok) return showError(res.error); // \"Invalid friend user.\" if id is empty\n// \"player-7\" removed from Accepted in the cache.\n```\n\nNote: `removeFriend` doesn't verify the two are actually friends before\npatching — it's an unconditional `$pull` on both sides, so calling it on a\nnon-friend id just succeeds as a no-op (`Status: \"Removed\"`), it never rejects\nwith \"not a friend\".\n\n### Recommended friends (add-friend suggestions)\n\n```ts\nconst res = await client.social.getRecommendedFriends(10);\nif (res.ok) {\n for (const profile of res.data.Friends ?? []) {\n // profile.UserID, profile.PublicData — render an \"Add\" button that\n // calls sendFriendRequest(profile.UserID)\n }\n}\n```\n\n### Activity timeline\n\n```ts\nconst res = await client.social.getTimeline();\nif (res.ok) {\n for (const event of res.data.Events ?? []) {\n // event.Type: \"Attack\" | \"Raid\" | \"FriendAdd\" (free-form string, not a closed enum client-side)\n // event.ActorUserID / event.ActorProfile, event.OwnerImpact (ResourceOperation), event.IsBlocked\n }\n}\n```\n\nIn practice today only `\"Attack\"` and `\"Raid\"` events are ever written (by the\nGameLoop module, on resolving an attack/raid against this player) — `FriendAdd`\nis a modeled event type with no current caller anywhere in the backend, so\ndon't expect friend-add activity to show up in the timeline yet. Build your UI\nagainst the type string, not against an assumption of which types are live.\n\n## Gotchas\n\n- **`getFriendsList()` fills `Accepted`, not `Friends`.** The response DTO is\n named `FriendsListResponse` with a `Friends` array, but the cache field it\n writes to is `Social.Accepted` — don't look for `Social.Friends`.\n- **No outgoing-request removal/cancel method.** Once sent, an outgoing\n request only leaves `OutgoingRequests` when you next call\n `getFriendsList()`/`getIncomingRequests()` and it's no longer reflected by\n the backend (accepted or declined elsewhere) — there's no client-side cancel\n and no dedicated \"outgoing requests\" fetch.\n- **`getRecommendedFriends` never touches the cache.** It's the only read\n method here with no `applySocial*`/cache write — treat its result as\n transient render data, not state.\n- **Recommendations are filtered server-side, not just randomly sampled.**\n The backend excludes yourself, your current `Accepted`/`IncomingRequests`/\n `OutgoingRequests` (so you never get suggested someone you already have a\n pending relationship with), requires the candidate to have a non-null public\n profile, and requires the candidate to have made a request within the last 7\n days (hardcoded server-side, not a client param) — then samples `limit`\n results pseudo-randomly. A quiet title can legitimately return an empty list\n even with plenty of registered players.\n- **Friend cap is 999, enforced only on `acceptFriendRequest`.** When your\n `Accepted` list is already at the cap, accepting doesn't reject — the server\n first auto-evicts your least-recently-active friend (by\n `LastRequestHeaders.RequestTime`) via an internal `removeFriend`, then adds\n the new one. `sendFriendRequest` itself has no cap check, so you can always\n accumulate incoming requests even while full.\n- **`sendFriendRequest` rejects a self-request or empty id** with\n `\"Invalid target user.\"`, but does **not** reject re-sending to someone you\n already sent a request to, already have incoming from, or are already\n friends with — `OutgoingRequests`/`IncomingRequests` are Mongo `$addToSet`,\n so a duplicate send is a harmless no-op that still returns\n `Status: \"RequestSent\"`. Don't rely on an error to detect \"already\n pending\" — check the cache (`OutgoingRequests`/`Accepted`) before sending.\n- **Guard against double-submit.** Each call mints a fresh idempotency key, so\n two separate calls are two real operations. Disable the control while a call\n is in flight. (Firing the same endpoint again within the throttle window,\n default 600 ms, is rejected with `reason: \"throttled\"` rather than\n duplicated, but don't rely on that for correctness.)\n- **Render from the cache, handle the error from the result.** The happy path\n updates the cache + emits an event; the failure path gives you `reason` +\n `error`. Use `reason` to decide behavior (retry on `\"connection\"`, re-auth on\n `\"unauthorized\"`, toast the `error` on `\"server\"`).\n- **`TimelineEvent.Type` is a free-form string client-side**, not a strict\n union — the model uses `.passthrough()` so new event types the backend adds\n later still round-trip; don't hard-code an exhaustive switch without a\n default case.\n- **Friendship can also be granted outside Social**, e.g. Referral activation\n calls an internal mutual-friend helper directly — it's still just `Accepted`\n entries on both sides, so it shows up the same way once you refresh\n `getFriendsList()`; no separate signal distinguishes \"how\" a friend was\n added.\n",
5
5
  "references": []
6
6
  }