@idosgames/mcp 0.1.14 → 0.1.16
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.
- package/package.json +1 -1
- package/registry/host.json +1 -1
- package/registry/index.json +29 -29
- package/registry/modules/board-game.json +7 -7
- package/registry/modules/game-hud.json +4 -4
- package/registry/modules/idle-rpg.json +7 -7
- package/registry/modules/voxelcraft.json +615 -71
- package/registry/modules/workshop.json +5 -5
- package/registry/skills/blockchain-system.json +1 -1
- package/registry/skills/idosgames-module-contract.json +1 -1
- package/registry/skills/social-system.json +2 -2
- package/registry/skills/workshop-system.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "social-system",
|
|
3
|
-
"description": "Build
|
|
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",
|
|
3
|
+
"description": "Build social features in a game on the iDosGames TypeScript SDK (@idosgames/core) via client.social (SocialService): friends (requests, accept/decline, remove, recommendations), the notification inbox (attacks, raids, new friends, sales, likes, follows, comments, replies; unread badge), and the generic social layer — follow/unfollow players, block/unblock, like/favorite any object (Workshop items, comments), comments with one level of replies, reports with auto-hide, and batch counters (followers, likes, comments) for cards. Use this whenever the user wants a friends screen, a notification bell / inbox, follow buttons, likes, comments, a report button, a block list, or touches client.social, SocialService, SocialModels, SocialEntityState, SocialInboxItem — even if they don't name the module.",
|
|
4
|
+
"content": "---\nname: social-system\ndescription: >-\n Build social features in a game on the iDosGames TypeScript SDK\n (@idosgames/core) via client.social (SocialService): friends (requests,\n accept/decline, remove, recommendations), the notification inbox (attacks,\n raids, new friends, sales, likes, follows, comments, replies; unread badge),\n and the generic social layer — follow/unfollow players, block/unblock,\n like/favorite any object (Workshop items, comments), comments with one level\n of replies, reports with auto-hide, and batch counters (followers, likes,\n comments) for cards. Use this whenever the user wants a friends screen, a\n notification bell / inbox, follow buttons, likes, comments, a report button,\n a block list, or touches client.social, SocialService, SocialModels,\n SocialEntityState, SocialInboxItem — even if they don't name the module.\n---\n\n# Social system (iDosGames TS SDK)\n\n`client.social` covers three things that share one backend module (`v2/Social`):\n\n1. **Friends** — a two-sided graph with requests (`sendFriendRequest` → `acceptFriendRequest`).\n2. **The inbox** — notifications about what happened TO this player.\n3. **The social layer** — follows, blocks, likes/favorites, comments and reports on ANY object,\n addressed as `(entityType, entityID)`.\n\nEverything is **server-authoritative**. A refused call is the backend enforcing a rule — surface\n`result.error` (a code like `COMMENTS_DISABLED`), don't re-implement the check client-side.\n\n## Setup\n\n```ts\nimport { createIDosGamesClient } from \"@idosgames/core\";\n\nconst client = createIDosGamesClient({ titleID: \"your-title-id\" });\nawait client.auth.loginWithDeviceID(); // any auth.* method; guests work too\n```\n\nEvery method returns `Promise<OperationResult<T>>` — `{ ok: true, data }` or\n`{ ok: false, reason, error }`; never throws for expected failures. `reason`: `\"client\"` (bad local\nargs, no request sent), `\"unauthorized\"`, `\"throttled\"`, `\"connection\"` (offer Retry),\n`\"validation\"`, `\"server\"` (`error` carries the backend code).\n\n## 1. The social layer — objects, not features\n\nA target is `(entityType, entityID)`:\n\n| entityType | entityID | follow | block | like | favorite | comment | report |\n| ---------------- | ----------------------------- | ------ | ----- | ---- | -------- | ------- | ------ |\n| `\"User\"` | a player's UserID (this game) | ✓ | ✓ | | | | ✓ |\n| `\"WorkshopItem\"` | a Workshop ContentID | | | ✓ | ✓ | ✓ * | ✓ |\n| `\"Comment\"` | a CommentID | | | ✓ | | | ✓ |\n\n\\* only when the title turns comments on (see Config). Anything else → `UNSUPPORTED_TARGET`.\n\nThe acting person is always the logged-in player. **Every mutation answers with the target's fresh\n`SocialEntityState`** — counters + my marks — so render from it, no refetch:\n\n```ts\nconst r = await client.social.follow(\"User\", authorID);\nif (r.ok) {\n r.data.Following; // true\n r.data.Counts?.Followers; // the author's follower count, after my follow\n}\n```\n\n| Method | Does | `data` |\n| --------------------------------------------------------------- | ----------------------------------------------- | -------------------------------- |\n| `follow(type, id)` / `unfollow(type, id)` | Follow a player | `SocialEntityState` |\n| `block(type, id)` / `unblock(type, id)` | Block: drops follows both ways, closes comments | `SocialEntityState` |\n| `like` / `unlike` / `favorite` / `unfavorite` | Reactions | `SocialEntityState` |\n| `getEntityStates(type, ids[])` | Counters + my marks for up to 50 objects | `{ Items: SocialEntityState[] }` |\n| `getFollowers(cursor?, limit?)` / `getFollowing` / `getBlocked` | MY lists (other people's lists are private) | `SocialActorPage` |\n| `getMyReactions(kind?, { entityType?, cursor?, limit? })` | My likes or favorites, newest first | `SocialReactionPage` |\n| `addComment(type, id, text, parentCommentID?)` | Comment, or reply to a top-level comment | `SocialCommentView` |\n| `editComment(commentID, text)` / `deleteComment(commentID)` | Own comments only | view / `{ Deleted }` |\n| `getComments(type, id, { parentCommentID?, cursor?, limit? })` | A page of comments or of one thread's replies | `SocialCommentPage` |\n| `report(type, id, reason, comment?)` | One report per person per object | `SocialReportResponse` |\n\n`reason` for reports: `\"Inappropriate\" | \"Spam\" | \"Stolen\" | \"Broken\" | \"Harassment\" | \"Other\"`.\n\n### Paging\n\nList pages carry `HasMore`, `NextCursorAt`, `NextCursorID`. Pass them back **as is**:\n\n```ts\nlet cursor: { at?: string | null; id?: string | null } | undefined;\ndo {\n const page = await client.social.getComments(\"WorkshopItem\", contentID, {\n cursor,\n limit: 20,\n });\n if (!page.ok) break;\n render(page.data.Items ?? []);\n cursor = page.data.HasMore\n ? { at: page.data.NextCursorAt, id: page.data.NextCursorID }\n : undefined;\n} while (cursor);\n```\n\nTop-level comments come newest first; replies (`parentCommentID`) oldest first. `TotalCount` is\nonly on the first top-level page.\n\n### Comments — what the UI must handle\n\n- **Deleted comments come back as placeholders** (`Status: \"Deleted\"`, `Text` and author null) so\n replies keep their thread. Render \"comment deleted\", don't drop the row if it has `Replies`.\n- One level of replies only: replying to a reply → `REPLY_DEPTH_EXCEEDED`.\n- Comments of players _I_ blocked are not in my pages. A player who blocked me (or whom I blocked)\n can't comment under the other's objects or in their threads → `BLOCKED`.\n- A comment reported by enough people disappears until the publisher reviews it.\n\n### Error codes you'll see\n\n`TARGET_REQUIRED`, `UNSUPPORTED_TARGET`, `USER_NOT_FOUND`, `CANNOT_TARGET_SELF`,\n`CONTENT_NOT_FOUND`, `COMMENT_NOT_FOUND`, `CANNOT_LIKE_OWN_CONTENT`, `CANNOT_REPORT_OWN_CONTENT`,\n`BLOCKED`, `FOLLOWING_LIMIT_REACHED`, `DAILY_LIMIT_REACHED`, `COMMENTS_DISABLED`,\n`COMMENTING_RESTRICTED`, `TEXT_REQUIRED`, `TEXT_TOO_LONG`, `TOO_MANY_LINKS`,\n`REPLY_DEPTH_EXCEEDED`, `REACTION_REQUIRED`, `REPORT_REASON_REQUIRED`, `TOO_MANY_TARGETS`, plus the\nWorkshop gate codes (`WORKSHOP_DISABLED`, `WORKSHOP_NOT_AVAILABLE`) on Workshop targets.\n\nRemoving your own mark (unfollow, unlike, unblock, deleting your comment) always works — even when\nthe object got hidden or comments were turned off.\n\n### Config\n\n`client.data.config` → `Social` (a sanitized view): `CommentsEnabled`, `CommentableTypes`,\n`CommentMaxLength`, `CommentMaxLinks`. Use it to hide the comment box and validate length before\nsending. Comments are **off by default**; follows, likes and reports work without the section.\nPer-day caps exist but are server-only — don't hard-code numbers.\n\n## 2. The inbox (notification bell)\n\n| Method | Does |\n| --------------------------------------- | ------------------------------------------------------- |\n| `getInbox({ limit?, cursor?, verbs? })` | A page, newest first. First page also has `UnreadCount` |\n| `getInboxUnread()` | Just the badge (capped at 100 → show \"99+\") |\n| `markInboxSeen()` | Opening the inbox screen: badge → 0 |\n\n- **Reading never marks anything seen.** Call `markInboxSeen()` once when the inbox screen opens —\n not per page.\n- Items (`SocialInboxItem`): `Verb` (`Attack`, `Raid`, `FriendAdded`, `WorkshopSale`, `Followed`,\n `Liked`, `Commented`, `Replied`, …), `LastActors` (up to 3, newest first), `Count`,\n `EntityType`/`EntityID`, `Payload.Title`, `OccurredAt` (show this), and for game events\n `OwnerImpact` (a `ResourceOperation` — what the player lost), `IsBlocked`, `TargetObjectName`.\n- **Likes and follows are aggregated** per object per day: one item with `Count` — render\n \"Ann and 41 others liked _Sky island_\". A bot or deleted actor has `Key: null` — use your own\n placeholder name.\n- Cursor: `{ beforeUpdatedAt: NextBeforeUpdatedAt, beforeID: NextBeforeID }` from the previous page.\n- `verbs` filters (empty = all). Items expire after 60 days.\n\n## 3. Friends\n\n| Method | Purpose | `data` |\n| --------------------------------------------------------------------------------------- | ------------------------------------ | --------------------------------- |\n| `getFriendsList()` | My friends (fills `Social.Accepted`) | `FriendsListResponse` (`Friends`) |\n| `getIncomingRequests()` | Requests sent to me | `FriendsListResponse` |\n| `getOutgoingRequests()` | Requests I sent, unanswered | `FriendsListResponse` |\n| `getRecommendedFriends(limit = 5)` | Suggestions (cache untouched) | `FriendsListResponse` |\n| `sendFriendRequest(userID)` | Send | `FriendActionResponse` |\n| `acceptFriendRequest(userID)` / `declineFriendRequest(userID)` / `removeFriend(userID)` | | `FriendActionResponse` |\n\n`FriendActionResponse` is self-sufficient: `Status` (`RequestSent`, `Accepted`, `Declined`,\n`Removed`), `Counters` (sizes of MY lists after the call) and `Target` (the other side's profile on\nsend/accept). Friends ≠ follows: a friendship is mutual and needs a request; a follow is one-way.\n\n### Counters (server) vs lists (local cache)\n\n`client.data.user.state?.Social`:\n\n- `FriendsCount`, `IncomingCount`, `OutgoingCount`, `InboxSeenAt` — **server-sent**, correct right\n after login. Use them for badges.\n- `Accepted`, `IncomingRequests`, `OutgoingRequests` — **local cache**, empty until the matching\n `get*` call runs on the screen that shows them. Never count with `.length`.\n\n### Friends gotchas\n\n- Sending to someone who already sent ME a request **accepts** it (`Status: \"Accepted\"`); sending\n to an existing friend is a success too. Pending requests are capped (100 sent, 300 received per\n player) → `\"Too many pending friend requests.\"`.\n- The friend cap is 999: accepting while full evicts your least-recently-active friend.\n- `removeFriend` removes only an accepted friendship — it never deletes a pending request.\n- Recommendations: same title only, active in the last 7 days, excluding people you're already\n linked with — a quiet title can return an empty list.\n\n### Friends from idosgames.com (platform bridge)\n\nThe publisher picks where \"friends\" come from in the `PlatformBridge` config section (LiveOps →\nPlatform bridge). The server applies it to **every** friend consumer at once — friends list, friends\nleaderboard, gifts, attack targets, friends-only Workshop items, recommendations — so the game never\nmerges two lists itself.\n\n| `PlatformBridge.Friends` | Friends are | In-game requests |\n| ------------------------ | ------------------------------------------------------------------ | ------------------------------------ |\n| `TitleOnly` (default) | in-game friendships | work |\n| `TitleAndPlatform` | + mutual follows on idosgames.com where both people play this game | work |\n| `PlatformOnly` | only those mutual follows | refused with `FRIENDS_FROM_PLATFORM` |\n\n- Each friend in `getFriendsList()` carries `Source`: `\"Title\"` or `\"Platform\"`. A platform friend\n cannot be removed in the game (the friendship is a follow on the site) — hide \"Remove friend\".\n- `client.social.friendRequestsAllowed()` is `false` in `PlatformOnly`: hide \"Add friend\", the\n requests tabs and the suggestions (the server returns them empty anyway).\n- A **guest** (no idosgames.com account) never gets platform friends, in any mode.\n- `PlatformBridge.Profile` decides the player's name/avatar on login from the site: `Default` = the\n site's until the player renames themselves in the game (then the game name sticks), `Always` =\n overwritten on every site login, `Off` = never taken. Nothing to do in the client.\n- Game notifications (attack, raid, new friend, Workshop sale) can also reach the player's bell on\n idosgames.com if the publisher selected them — the in-game inbox is unchanged either way.\n\n## Events\n\n`client.on(name, fn)` returns an unsubscribe function.\n\n- Friends: `social:friendsListLoaded`, `social:incomingRequestsLoaded`,\n `social:outgoingRequestsLoaded`, `social:recommendedFriendsLoaded`, `social:friendRequestSent`,\n `social:friendRequestAccepted`, `social:friendRequestDeclined`, `social:friendRemoved`.\n- Inbox: `social:inboxLoaded` (`InboxResponse`), `social:inboxBadge` (`InboxBadgeResponse` — after\n `getInboxUnread` and `markInboxSeen`).\n- Layer: `social:entityStateChanged` (`SocialEntityState` — after any follow/block/like/favorite),\n `social:commentAdded`, `social:commentEdited`, `social:commentDeleted`, `social:reported`.\n- Coarse: `user:socialUpdated` on any social cache write.\n\n## Recipes\n\n### Follow button on a player card\n\n```ts\nconst states = await client.social.getEntityStates(\"User\", [playerID]);\nlet following = states.ok && states.data.Items?.[0]?.Following === true;\n\nasync function toggle() {\n const r = following\n ? await client.social.unfollow(\"User\", playerID)\n : await client.social.follow(\"User\", playerID);\n if (r.ok) following = r.data.Following === true;\n else toast(r.error);\n}\n```\n\n### Like + comment count on a Workshop card\n\nWorkshop cards already carry `Stats.Likes` and `Liked`; for the comment count ask\n`getEntityStates(\"WorkshopItem\", ids)` for the whole page at once (≤ 50 ids) → `Counts.Comments`.\n\n### Notification bell\n\n```ts\nconst badge = await client.social.getInboxUnread(); // on app start / focus\n// open the screen:\nconst first = await client.social.getInbox({ limit: 30 });\nawait client.social.markInboxSeen();\n```\n\n## Gotchas\n\n- **Guard against double-submit** — disable the control while a call is in flight. Repeats are\n harmless server-side (a second like/follow/report changes nothing), but each is a real call.\n- **Don't parse ids.** `SocialActorSnapshot.Key` is `t:{UserID}` in a game today; treat it as\n opaque except for display fallbacks.\n- **Enums are strings and open-ended** (`Verb`, `Status`, `EntityType`): always keep a `default`\n branch — the backend adds values without an SDK release.\n",
|
|
5
5
|
"references": []
|
|
6
6
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "workshop-system",
|
|
3
3
|
"description": "Let players share what they make — maps, levels, worlds, skins, 3D models, any file — through the Workshop of the iDosGames TypeScript SDK (@idosgames/core client.workshop, WorkshopService): configure content types for a title, publish with files and a thumbnail, set access (free, a price in in-game resources, or \"hold these resources to unlock\" — while held or once), browse the catalog with filters and publisher collections, acquire, download and open content, likes, favorites, following authors, reports, official content. Also covers plugging a game into the ready-made `workshop` module via ctx.content (ContentTypeHandler: listLocal / capture / open). Use this whenever the user wants user-generated content, a level/map sharing screen, selling maps or skins between players, unlocking content for holders of an item, a creator catalog, or touches client.workshop, WorkshopService, publish, acquire, downloadFiles, WorkshopAccessOption, WorkshopDefinitions, ContentTypes, ctx.content or the workshop module — even if they don't name the module.",
|
|
4
|
-
"content": "---\nname: workshop-system\ndescription: >-\n Let players share what they make — maps, levels, worlds, skins, 3D models, any\n file — through the Workshop of the iDosGames TypeScript SDK\n (@idosgames/core client.workshop, WorkshopService): configure content types for\n a title, publish with files and a thumbnail, set access (free, a price in\n in-game resources, or \"hold these resources to unlock\" — while held or once),\n browse the catalog with filters and publisher collections, acquire, download\n and open content, likes, favorites, following authors, reports, official\n content. Also covers plugging a game into the ready-made `workshop` module via\n ctx.content (ContentTypeHandler: listLocal / capture / open). Use this whenever\n the user wants user-generated content, a level/map sharing screen, selling\n maps or skins between players, unlocking content for holders of an item, a\n creator catalog, or touches client.workshop, WorkshopService, publish,\n acquire, downloadFiles, WorkshopAccessOption, WorkshopDefinitions,\n ContentTypes, ctx.content or the workshop module — even if they don't name\n the module.\n---\n\n# Workshop (iDosGames TS SDK)\n\nThe Workshop is a catalog of content made by players (and by the publisher — \"official\"). It is\n**not** the Marketplace: nothing is transferred. Acquiring grants a **license** to a digital copy —\none publication is acquired by many players and the author keeps it. For trading actual items\nbetween players use **marketplace-system**.\n\nEverything game-specific lives in the title config (`Workshop` section, edited on the dashboard\npage LiveOps → Workshop): which content types exist, which files each has, who may publish, which\naccess modes authors may offer, commission, moderation. The server enforces all of it — surface a\nrefusal, don't re-implement the check.\n\n## Two layers\n\n| You want | Use |\n| ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- |\n| A ready catalog screen in a composed game | install the **`workshop` module** and register your content type with `ctx.content` (below) — no Workshop code of your own |\n| Your own UI, or a game without the module system | call `client.workshop.*` directly |\n\n## Config: content types\n\n`Workshop.ContentTypes` is a dictionary keyed by the type id your game sends:\n\n```jsonc\n\"Workshop\": {\n \"Enabled\": true,\n \"ContentTypes\": {\n \"voxelcraft.world\": {\n \"DisplayName\": \"World\",\n \"Files\": { \"main\": { \"AllowedMimeTypes\": [\"application/json\"], \"MaxBytes\": 10485760 } },\n // \"extra\": { \"AllowedMimeTypes\": [\"model/gltf-binary\"], \"MaxCount\": 4, \"Required\": false }\n \"AllowedAccessModes\": [\"Free\", \"Price\", \"Holding\"], // null = all\n \"PublishFeeOptions\": null, // PriceOptions, Standard part only\n \"MaxPublishedPerPlayer\": 50, \"DailyPublishCap\": 10\n }\n },\n \"Moderation\": { \"AutoHideReportThreshold\": 5, \"RequireApproval\": false }\n}\n```\n\n- `Files: null` = one required `main` JSON file ≤ 10 MB. The server checks size and **file\n signature** against the declared MIME type; `application/octet-stream` passes only if listed.\n- The section is **not** in the public title config the client caches (it holds the moderation\n blocklist). Read what the client may know with `client.workshop.getDefinitions()` — which also\n says which types this player may publish and which collections are live.\n\n## Access options — ANY one opens\n\nA publication carries a list of options; the player needs to satisfy **one**:\n\n```ts\naccess: [\n {\n Mode: \"Holding\",\n Holding: {\n Match: \"Any\",\n Mode: \"WhileHeld\",\n Requirements: [\n { Type: \"Item\", CatalogID: \"keys\", ItemID: \"gold_key\", Amount: 1 },\n ],\n },\n },\n {\n Mode: \"Price\",\n Price: {\n Entries: [{ Type: \"VirtualCurrency\", CurrencyID: \"GOLD\", Amount: 100 }],\n },\n },\n];\n// = free for holders of a gold key, 100 GOLD for everyone else\n```\n\n- `Free` — anyone.\n- `Price` — the buyer pays, the author receives the price minus the title's commission. Official\n content: the whole price goes to the title.\n- `Holding` — nothing is spent. `WhileHeld`: checked on **every download**, spend the key and access\n is gone (no license is written). `UnlockOnce`: checked once, then a permanent license. `Match: All`\n needs every requirement, `Any` one of them. Items may carry `MinLevel`.\n\n## Client API (`client.workshop`)\n\n```ts\nconst defs = await client.workshop.getDefinitions();\nconst page = await client.workshop.browse({\n contentType: \"voxelcraft.world\",\n sort: \"Popular\",\n});\nconst card = await client.workshop.getContent(contentID); // + per-option availability\n\nconst pub = await client.workshop.publish({\n contentType: \"voxelcraft.world\",\n files: [\n {\n role: \"main\",\n contentType: \"application/json\",\n data: JSON.stringify(save),\n },\n ],\n thumbnail: { contentType: \"image/webp\", data: webpBlob },\n title: \"Frost Keep\",\n tags: [\"castle\"],\n visibility: \"Public\", // Public | Unlisted | Friends\n access: [{ Mode: \"Free\" }],\n});\n\nconst got = await client.workshop.acquire(contentID, option); // pass the option you SHOWED\nconst dl = await client.workshop.downloadFiles(contentID); // bytes of every file\n```\n\n- `publish` does declare → upload straight to storage by signed URLs → verify and release. With\n `contentID` it uploads a new revision of your own publication. A file from `client.ai` can be\n published by URL: `{ role, contentType, sourceAssetUrl }` — the server copies it.\n- **`acquire(contentID, option)` sends `ExpectedPrice = option.Price`.** If the author changed the\n price meanwhile the server refuses instead of charging a price the player never saw — re-read the\n card and ask again. Acquiring twice is safe: the second answer says `AlreadyOwned`, nothing charged.\n- `getDownload` / `downloadFiles` return short-lived signed links — fetch right away, don't store them.\n- Also: `updateContent`, `unpublish` (buyers keep access), `getMyContent`, `getMyLicenses`,\n `
|
|
4
|
+
"content": "---\nname: workshop-system\ndescription: >-\n Let players share what they make — maps, levels, worlds, skins, 3D models, any\n file — through the Workshop of the iDosGames TypeScript SDK\n (@idosgames/core client.workshop, WorkshopService): configure content types for\n a title, publish with files and a thumbnail, set access (free, a price in\n in-game resources, or \"hold these resources to unlock\" — while held or once),\n browse the catalog with filters and publisher collections, acquire, download\n and open content, likes, favorites, following authors, reports, official\n content. Also covers plugging a game into the ready-made `workshop` module via\n ctx.content (ContentTypeHandler: listLocal / capture / open). Use this whenever\n the user wants user-generated content, a level/map sharing screen, selling\n maps or skins between players, unlocking content for holders of an item, a\n creator catalog, or touches client.workshop, WorkshopService, publish,\n acquire, downloadFiles, WorkshopAccessOption, WorkshopDefinitions,\n ContentTypes, ctx.content or the workshop module — even if they don't name\n the module.\n---\n\n# Workshop (iDosGames TS SDK)\n\nThe Workshop is a catalog of content made by players (and by the publisher — \"official\"). It is\n**not** the Marketplace: nothing is transferred. Acquiring grants a **license** to a digital copy —\none publication is acquired by many players and the author keeps it. For trading actual items\nbetween players use **marketplace-system**.\n\nEverything game-specific lives in the title config (`Workshop` section, edited on the dashboard\npage LiveOps → Workshop): which content types exist, which files each has, who may publish, which\naccess modes authors may offer, commission, moderation. The server enforces all of it — surface a\nrefusal, don't re-implement the check.\n\n## Two layers\n\n| You want | Use |\n| ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- |\n| A ready catalog screen in a composed game | install the **`workshop` module** and register your content type with `ctx.content` (below) — no Workshop code of your own |\n| Your own UI, or a game without the module system | call `client.workshop.*` directly |\n\n## Config: content types\n\n`Workshop.ContentTypes` is a dictionary keyed by the type id your game sends:\n\n```jsonc\n\"Workshop\": {\n \"Enabled\": true,\n \"ContentTypes\": {\n \"voxelcraft.world\": {\n \"DisplayName\": \"World\",\n \"Files\": { \"main\": { \"AllowedMimeTypes\": [\"application/json\"], \"MaxBytes\": 10485760 } },\n // \"extra\": { \"AllowedMimeTypes\": [\"model/gltf-binary\"], \"MaxCount\": 4, \"Required\": false }\n \"AllowedAccessModes\": [\"Free\", \"Price\", \"Holding\"], // null = all\n \"PublishFeeOptions\": null, // PriceOptions, Standard part only\n \"MaxPublishedPerPlayer\": 50, \"DailyPublishCap\": 10\n }\n },\n \"Moderation\": { \"AutoHideReportThreshold\": 5, \"RequireApproval\": false }\n}\n```\n\n- `Files: null` = one required `main` JSON file ≤ 10 MB. The server checks size and **file\n signature** against the declared MIME type; `application/octet-stream` passes only if listed.\n- The section is **not** in the public title config the client caches (it holds the moderation\n blocklist). Read what the client may know with `client.workshop.getDefinitions()` — which also\n says which types this player may publish and which collections are live.\n\n## Access options — ANY one opens\n\nA publication carries a list of options; the player needs to satisfy **one**:\n\n```ts\naccess: [\n {\n Mode: \"Holding\",\n Holding: {\n Match: \"Any\",\n Mode: \"WhileHeld\",\n Requirements: [\n { Type: \"Item\", CatalogID: \"keys\", ItemID: \"gold_key\", Amount: 1 },\n ],\n },\n },\n {\n Mode: \"Price\",\n Price: {\n Entries: [{ Type: \"VirtualCurrency\", CurrencyID: \"GOLD\", Amount: 100 }],\n },\n },\n];\n// = free for holders of a gold key, 100 GOLD for everyone else\n```\n\n- `Free` — anyone.\n- `Price` — the buyer pays, the author receives the price minus the title's commission. Official\n content: the whole price goes to the title.\n- `Holding` — nothing is spent. `WhileHeld`: checked on **every download**, spend the key and access\n is gone (no license is written). `UnlockOnce`: checked once, then a permanent license. `Match: All`\n needs every requirement, `Any` one of them. Items may carry `MinLevel`.\n\n## Client API (`client.workshop`)\n\n```ts\nconst defs = await client.workshop.getDefinitions();\nconst page = await client.workshop.browse({\n contentType: \"voxelcraft.world\",\n sort: \"Popular\",\n});\nconst card = await client.workshop.getContent(contentID); // + per-option availability\n\nconst pub = await client.workshop.publish({\n contentType: \"voxelcraft.world\",\n files: [\n {\n role: \"main\",\n contentType: \"application/json\",\n data: JSON.stringify(save),\n },\n ],\n thumbnail: { contentType: \"image/webp\", data: webpBlob },\n title: \"Frost Keep\",\n tags: [\"castle\"],\n visibility: \"Public\", // Public | Unlisted | Friends\n access: [{ Mode: \"Free\" }],\n});\n\nconst got = await client.workshop.acquire(contentID, option); // pass the option you SHOWED\nconst dl = await client.workshop.downloadFiles(contentID); // bytes of every file\n```\n\n- `publish` does declare → upload straight to storage by signed URLs → verify and release. With\n `contentID` it uploads a new revision of your own publication. A file from `client.ai` can be\n published by URL: `{ role, contentType, sourceAssetUrl }` — the server copies it.\n- **`acquire(contentID, option)` sends `ExpectedPrice = option.Price`.** If the author changed the\n price meanwhile the server refuses instead of charging a price the player never saw — re-read the\n card and ask again. Acquiring twice is safe: the second answer says `AlreadyOwned`, nothing charged.\n- `getDownload` / `downloadFiles` return short-lived signed links — fetch right away, don't store them.\n- Also: `updateContent`, `unpublish` (buyers keep access), `getMyContent`, `getMyLicenses`,\n `getMyFavorites`, `getCreatorProfile`, `getCollection`.\n- ⚠ **Likes, favorites, following an author, comments and reports are `client.social`, not\n `client.workshop`** (see the `social-system` skill): `client.social.like(\"WorkshopItem\", id)`,\n `favorite(\"WorkshopItem\", id)`, `follow(\"User\", creatorUserID)`,\n `report(\"WorkshopItem\", id, \"Broken\")`, `addComment(\"WorkshopItem\", id, text)`. The Workshop\n rules still apply to them (gate, visibility, \"can't like your own\", \"you may report what you\n already got\"). Cards keep `Stats.Likes`/`Favorites` and `Liked`/`Favorited`; the author's follower\n count and \"am I following\" come from `getCreatorProfile` or `client.social.getEntityStates(\"User\", …)`.\n- Events: `workshop:definitionsLoaded | published | updated | acquired` (reactions emit\n `social:entityStateChanged`).\n\n## Plugging a game into the `workshop` module\n\n```ts\nsetup(ctx) {\n ctx.content.registerType({\n type: \"level\", // = key of Workshop.ContentTypes\n label: \"Level\", icon: \"🧩\", modeId: \"my-game\",\n listLocal: async () => myLevels.map((l) => ({ id: l.id, name: l.name })),\n capture: async (id) => ({ files: [{ role: \"main\", contentType: \"application/json\",\n data: JSON.stringify(load(id)) }], suggestedTitle: load(id).name }),\n open: async (c) => startLevel(JSON.parse(new TextDecoder().decode(c.files[0].data))),\n });\n}\n```\n\nThe module lists your local items, publishes them, and after `open` switches to `modeId`. A type\nwith no handler is still browsable and acquirable — it just can't be published or opened from the\ngame. Contract details: **idosgames-module-contract**.\n\n## Don't\n\n- Don't gate access on the client — show `getContent`'s option availability and let `acquire` decide.\n- Don't write a \"license\" of your own for `WhileHeld` content: access must follow the player's\n inventory.\n- Don't cache signed URLs or put private files in the public config.\n",
|
|
5
5
|
"references": []
|
|
6
6
|
}
|