@idosgames/mcp 0.1.18 → 0.1.20
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/dist/cli.js +2 -2
- package/package.json +1 -1
- package/registry/host.json +69 -25
- package/registry/index.json +895 -620
- package/registry/modules/ai.json +65 -0
- package/registry/modules/board-game.json +8 -8
- package/registry/modules/boosts.json +66 -0
- package/registry/modules/{character-ui.json → character.json} +8 -12
- package/registry/modules/chat.json +70 -0
- package/registry/modules/collections.json +69 -0
- package/registry/modules/coop-events.json +67 -0
- package/registry/modules/{craft-ui.json → craft.json} +8 -12
- package/registry/modules/{deal-offer-ui.json → deals.json} +7 -11
- package/registry/modules/events.json +72 -0
- package/registry/modules/{friends-ui.json → friends.json} +11 -14
- package/registry/modules/idle-rpg.json +7 -7
- package/registry/modules/inventory.json +67 -0
- package/registry/modules/invite.json +70 -0
- package/registry/modules/{leaderboard-ui.json → leaderboard.json} +8 -12
- package/registry/modules/lootboxes.json +67 -0
- package/registry/modules/mailbox.json +71 -0
- package/registry/modules/marketplace.json +71 -0
- package/registry/modules/mini-game.json +69 -0
- package/registry/modules/multiplayer.json +74 -0
- package/registry/modules/premium.json +67 -0
- package/registry/modules/profile.json +70 -0
- package/registry/modules/{pvp-ui.json → pvp.json} +9 -13
- package/registry/modules/quests.json +69 -0
- package/registry/modules/rewards.json +66 -0
- package/registry/modules/season.json +71 -0
- package/registry/modules/{store-ui.json → store.json} +9 -13
- package/registry/modules/{tutorial-ui.json → tutorials.json} +8 -12
- package/registry/modules/voxelcraft.json +15 -455
- package/registry/modules/workshop.json +10 -6
- package/registry/skills/blockchain-system.json +1 -1
- package/registry/skills/dev-test-loop.json +1 -1
- package/registry/skills/idosgames-compose-modules.json +2 -2
- package/registry/skills/idosgames-getting-started.json +2 -2
- package/registry/skills/idosgames-module-contract.json +2 -2
- package/registry/skills/idosgames-project-structure.json +1 -1
- package/registry/skills/idosgames-ui-kit.json +2 -2
- package/registry/skills/mailbox-system.json +6 -0
- package/registry/skills/multiplayer-realtime.json +2 -2
- package/registry/skills/push-notifications.json +1 -1
- package/registry/skills/workshop-system.json +1 -1
- package/registry/modules/chat-ui.json +0 -74
- package/registry/modules/events-ui.json +0 -80
- package/registry/modules/game-hud.json +0 -104
- package/registry/modules/inventory-ui.json +0 -73
- package/registry/modules/marketplace-ui.json +0 -75
- package/registry/modules/profile-ui.json +0 -74
- package/registry/modules/referral-ui.json +0 -73
- package/registry/modules/rewards-ui.json +0 -75
- package/registry/modules/season-ui.json +0 -79
- package/registry/modules/showcase.json +0 -92
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"id": "
|
|
2
|
+
"id": "deals",
|
|
3
3
|
"meta": {
|
|
4
4
|
"name": "Offers",
|
|
5
5
|
"type": "app",
|
|
@@ -34,12 +34,12 @@
|
|
|
34
34
|
"version": "0.1.0"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@idosgames/core": "0.19.
|
|
38
|
-
"@idosgames/module-sdk": "0.
|
|
37
|
+
"@idosgames/core": "0.19.1",
|
|
38
|
+
"@idosgames/module-sdk": "0.7.0",
|
|
39
39
|
"@idosgames/react": "0.3.0",
|
|
40
40
|
"react": "19.2.7"
|
|
41
41
|
},
|
|
42
|
-
"contentHash": "
|
|
42
|
+
"contentHash": "f4633cc8068e4679f7321ca9353fd633271d877e8e003b73c237a7dcfb5f7e71",
|
|
43
43
|
"files": [
|
|
44
44
|
{
|
|
45
45
|
"path": "DealsScreen.tsx",
|
|
@@ -51,11 +51,7 @@
|
|
|
51
51
|
},
|
|
52
52
|
{
|
|
53
53
|
"path": "index.ts",
|
|
54
|
-
"content": "// @idosgames/mod-
|
|
55
|
-
},
|
|
56
|
-
{
|
|
57
|
-
"path": "model.test.ts",
|
|
58
|
-
"content": "import { describe, expect, it } from \"vitest\";\nimport seed from \"../title-seed.json\";\nimport {\n dealMilestones,\n dealShape,\n dealsBadge,\n milestonesReachedBy,\n needsStore,\n nodeKind,\n nodePrice,\n nodeStatus,\n offerRewards,\n orderedNodes,\n} from \"./model\";\n\n// The server resolves active slots (definition + activation state); here a slot is assembled from\n// the seeded offer the way GetActiveDeals returns it.\ntype Offer = {\n Graph: { RootNodeIDs: string[]; Nodes: Array<{ NodeID: string }> };\n};\nconst offers = (\n seed as unknown as { DealOffer: { Offers: Record<string, Offer> } }\n).DealOffer.Offers;\nconst slot = (offerID: string, extra: Record<string, unknown> = {}) =>\n ({ SlotID: offerID, OfferDef: offers[offerID], ...extra }) as never;\n\ndescribe(\"deal offers\", () => {\n it(\"derives the shape from the graph: the starter pack is a chain, the gift a choice\", () => {\n expect(dealShape(slot(\"starter_pack\"))).toBe(\"chain\");\n expect(dealShape(slot(\"welcome_gift\"))).toBe(\"choice\");\n expect(\n dealShape({ OfferDef: { Graph: { Nodes: [{ NodeID: \"x\" }] } } } as never),\n ).toBe(\"single\");\n });\n\n it(\"orders steps along the chain and reads each step's price and kind\", () => {\n const nodes = orderedNodes(slot(\"starter_pack\"));\n expect(nodes.map((n) => n.NodeID)).toEqual([\n \"step_free\",\n \"step_coins\",\n \"step_gems\",\n ]);\n expect(nodes.map(nodeKind)).toEqual([\"free\", \"purchase\", \"purchase\"]);\n expect(nodePrice(nodes[1]!)).toEqual([\n { kind: \"currency\", id: \"CO\", amount: 100 },\n ]);\n expect(needsStore(nodes[1]!)).toBe(false);\n });\n\n it(\"the server's status wins; without state only roots are open\", () => {\n const s = slot(\"starter_pack\", {\n ActivationState: {\n Nodes: {\n step_free: { Status: \"Completed\" },\n step_coins: { Status: \"Available\" },\n },\n },\n });\n const [a, b, c] = orderedNodes(s);\n expect([nodeStatus(s, a!), nodeStatus(s, b!), nodeStatus(s, c!)]).toEqual([\n \"Completed\",\n \"Available\",\n \"Locked\",\n ]);\n });\n\n it(\"previews everything the offer can give, summed by resource\", () => {\n const lines = offerRewards(slot(\"welcome_gift\"));\n expect(lines).toEqual(\n expect.arrayContaining([\n { kind: \"currency\", id: \"CO\", amount: 200 },\n { kind: \"currency\", id: \"GE\", amount: 10 },\n ]),\n );\n });\n\n it(\"milestones: reached, claimed, locked\", () => {\n expect(\n dealMilestones(\n slot(\"starter_pack\", { Milestone: { TotalEarned: 1 } }),\n )[0]!.state,\n ).toBe(\"locked\");\n expect(\n dealMilestones(\n slot(\"starter_pack\", { Milestone: { TotalEarned: 3 } }),\n )[0]!.state,\n ).toBe(\"ready\");\n expect(\n dealMilestones(\n slot(\"starter_pack\", {\n Milestone: { TotalEarned: 3, ClaimedIDs: [\"all_steps\"] },\n }),\n )[0]!.state,\n ).toBe(\"claimed\");\n });\n\n it(\"the last step brings the bonus within reach; a claimed one is not taken twice\", () => {\n const last = orderedNodes(slot(\"starter_pack\"))[2]!;\n expect(\n milestonesReachedBy(\n slot(\"starter_pack\", { Milestone: { TotalEarned: 2 } }),\n last,\n ),\n ).toEqual([\"all_steps\"]);\n expect(\n milestonesReachedBy(\n slot(\"starter_pack\", { Milestone: { TotalEarned: 1 } }),\n last,\n ),\n ).toEqual([]);\n expect(\n milestonesReachedBy(\n slot(\"starter_pack\", {\n Milestone: { TotalEarned: 2, ClaimedIDs: [\"all_steps\"] },\n }),\n last,\n ),\n ).toEqual([]);\n });\n\n it(\"the badge counts free steps open now plus milestone rewards waiting\", () => {\n // step_free + one pick-one gift (two cards, one choice).\n expect(dealsBadge([slot(\"starter_pack\"), slot(\"welcome_gift\")])).toBe(2);\n const later = slot(\"starter_pack\", {\n ActivationState: { Nodes: { step_free: { Status: \"Completed\" } } },\n Milestone: { ReachedUnclaimedIDs: [\"all_steps\"] },\n });\n expect(dealsBadge([later])).toBe(1);\n });\n});\n"
|
|
54
|
+
"content": "// @idosgames/mod-deals — Special offers for any game: timed deals that pop up by themselves, single offers, step chains and choice cards, with a milestone bar.\n\nexport { dealsModule } from \"./module\";\n"
|
|
59
55
|
},
|
|
60
56
|
{
|
|
61
57
|
"path": "model.ts",
|
|
@@ -63,11 +59,11 @@
|
|
|
63
59
|
},
|
|
64
60
|
{
|
|
65
61
|
"path": "module.ts",
|
|
66
|
-
"content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport {\n createDealsStore,\n makeDealsScreen,\n makeDealsWatcher,\n} from \"./DealsScreen\";\nimport { t } from \"./i18n\";\n\n// Special offers as a feature of the game. The always-on watcher pops a newly started deal up by\n// itself (and records the impression); the screen lists every active deal. Badge: free steps and\n// milestone rewards waiting.\nexport const
|
|
62
|
+
"content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport {\n createDealsStore,\n makeDealsScreen,\n makeDealsWatcher,\n} from \"./DealsScreen\";\nimport { t } from \"./i18n\";\n\n// Special offers as a feature of the game. The always-on watcher pops a newly started deal up by\n// itself (and records the impression); the screen lists every active deal. Badge: free steps and\n// milestone rewards waiting.\nexport const dealsModule: Module = defineModule({\n id: \"deals\",\n meta: { name: \"Offers\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n const store = createDealsStore(ctx.features);\n ctx.features.register({\n id: \"deals\",\n label: t(\"title\"),\n icon: \"offer\",\n group: \"economy\",\n order: 15,\n available: false,\n Screen: makeDealsScreen(store),\n });\n // The tab shows while a deal is running — the watcher loads them at start.\n store.subscribe(() =>\n ctx.features.setAvailable(\n \"deals\",\n (store.get()?.length ?? 0) > 0,\n \"no active deal right now (DealOffer slots)\",\n ),\n );\n ctx.registerPanel({\n id: \"auto\",\n slot: \"modal\",\n activeOnly: false,\n component: makeDealsWatcher(store),\n });\n ctx.exposeToAgent({\n state: () => ({\n ui: \"dom\",\n feature: \"deals\",\n active: store.get()?.map((s) => s.SlotID) ?? [],\n }),\n describeActions: {},\n });\n },\n});\n"
|
|
67
63
|
},
|
|
68
64
|
{
|
|
69
65
|
"path": "module.meta.json",
|
|
70
|
-
"content": "{\n \"id\": \"
|
|
66
|
+
"content": "{\n \"id\": \"deals\",\n \"type\": \"feature\",\n \"summary\": \"Special offers for any game: a new deal pops up by itself; single offers, step chains and pick-one gifts with a milestone bar.\",\n \"description\": \"Special offers (Unity Alikhan/DealOffer) for ANY game, built on the @idosgames/react/ui kit and client.dealOffer. An always-on watcher pops a newly started deal up by itself once (and records the impression with recordShow); the 'Offers' screen lists every active deal with its timer and everything it can give. A deal opens as a popup shaped by its node graph: one big offer, a chain of steps (each unlocks the next, the server decides status), or cards to pick one from; free steps, currency prices, store-only steps and the milestone bar with its rewards. Registers the 'deals' feature; its badge counts free steps and milestone rewards waiting. Ships title-seed.json: a three-step starter pack with a bonus and a free pick-one welcome gift.\",\n \"provides\": [\n \"special offers screen\",\n \"auto-popup of a new deal\",\n \"single, chain and pick-one offers\",\n \"offer milestones\",\n \"deals badge\",\n \"demo offers\"\n ],\n \"tags\": [\n \"offers\",\n \"deals\",\n \"starter-pack\",\n \"monetization\",\n \"popup\",\n \"ui\",\n \"screen\",\n \"economy\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.0\"\n}\n"
|
|
71
67
|
}
|
|
72
68
|
]
|
|
73
69
|
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "events",
|
|
3
|
+
"meta": {
|
|
4
|
+
"name": "Events",
|
|
5
|
+
"type": "app",
|
|
6
|
+
"engine": "dom"
|
|
7
|
+
},
|
|
8
|
+
"catalog": {
|
|
9
|
+
"type": "feature",
|
|
10
|
+
"summary": "Limited-time events for any game: event tabs with a timer, a token bar, a milestone track, claim all and a VIP second prize.",
|
|
11
|
+
"description": "A ready events screen (Unity Core/Window LimitedTimeEvent) for ANY game, built on the @idosgames/react/ui kit and client.timedEvent. Every active event the server resolves (scheduled, cyclic or chained) is a tab: its name and timer, the token bar toward the next prize and the milestone track in threshold order. A milestone pays its reward and, when the reward has premium tiers, a VIP second prize; a pass bought later tops up the milestones already claimed. 'Claim all' takes everything waiting in one call. How the tokens are earned (the event's token sources with their daily caps) is a popup; 'Get VIP' opens the VIP system (ctx.features.open('premium', { premiumID })) and shows only when it is installed and has a pass. Registers the 'events' feature; its badge counts milestones and VIP top-ups waiting. Tokens come from the game: a round of play is a CustomAction the game grants with client.timedEvent.grantTokens (the mini-game module does it for every active event that lists the action). Ships title-seed.json: a weekly 'Star festival' with five milestones and a VIP prize on each.",
|
|
12
|
+
"provides": [
|
|
13
|
+
"events screen with a tab per active event",
|
|
14
|
+
"event timer and token progress bar",
|
|
15
|
+
"milestone track with rewards",
|
|
16
|
+
"VIP second prize and top-up after purchase",
|
|
17
|
+
"claim all milestones",
|
|
18
|
+
"how-to-earn popup with daily caps",
|
|
19
|
+
"hand-off to the VIP system",
|
|
20
|
+
"events badge",
|
|
21
|
+
"demo weekly event"
|
|
22
|
+
],
|
|
23
|
+
"tags": [
|
|
24
|
+
"events",
|
|
25
|
+
"live-ops",
|
|
26
|
+
"limited-time",
|
|
27
|
+
"milestones",
|
|
28
|
+
"battle-pass",
|
|
29
|
+
"vip",
|
|
30
|
+
"ui",
|
|
31
|
+
"screen"
|
|
32
|
+
],
|
|
33
|
+
"author": {
|
|
34
|
+
"name": "iDos Games",
|
|
35
|
+
"url": "https://idosgames.com"
|
|
36
|
+
},
|
|
37
|
+
"version": "0.1.0"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@idosgames/core": "0.19.1",
|
|
41
|
+
"@idosgames/module-sdk": "0.7.0",
|
|
42
|
+
"@idosgames/react": "0.3.0",
|
|
43
|
+
"react": "19.2.7"
|
|
44
|
+
},
|
|
45
|
+
"contentHash": "a45709a4e04f56860341a73b92f49d71d6c44a10d2be944d001680d3e8c43af9",
|
|
46
|
+
"files": [
|
|
47
|
+
{
|
|
48
|
+
"path": "EventsScreen.tsx",
|
|
49
|
+
"content": "import {\n useEffect,\n useRef,\n useState,\n useSyncExternalStore,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport type { ActiveEventInfo, IDosGamesClient } from \"@idosgames/core\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n Center,\n EmptyState,\n Icon,\n Popup,\n ProgressBar,\n ResourceList,\n Spinner,\n Tabs,\n Timer,\n errorText,\n formatAmount,\n grantedBy,\n outlined,\n panel,\n useCatalog,\n useCelebrate,\n useNow,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n claimableNow,\n claimedLines,\n eventsBadge,\n hasPremium,\n milestoneRows,\n nextGoal,\n premiumIDsOf,\n tokenSources,\n type MilestoneRow,\n type PremiumView,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Limited-time events (Unity Core/Window LimitedTimeEvent): each active event is a tab — its timer,\n// the token bar toward the next prize and the milestone track. A milestone pays its reward and, for\n// a VIP, a second prize; a VIP bought later tops up the milestones already claimed. How the tokens\n// are earned is a popup; the VIP pass is sold by the VIP system (the premium module).\n\n/** Active events, shared by the screen and the badge watcher (one request for both). */\nexport function createEventsStore(features: FeatureRegistry) {\n let events: ActiveEventInfo[] | null = null;\n const listeners = new Set<() => void>();\n let premium: () => PremiumView | null | undefined = () => null;\n const badge = () =>\n features.setBadge(\n \"events\",\n events ? eventsBadge(events, premium(), Date.now()) : 0,\n );\n return {\n get: () => events,\n subscribe: (l: () => void) => {\n listeners.add(l);\n return () => listeners.delete(l);\n },\n async load(client: IDosGamesClient): Promise<void> {\n premium = () =>\n client.data.user.state?.Premium as PremiumView | undefined;\n const res = await client.timedEvent.getActiveEvents();\n events = res.ok ? (res.data.ActiveEvents ?? []) : [];\n badge();\n for (const l of listeners) l();\n },\n refreshBadge: badge,\n /** The VIP system is installed and has a pass on the title — the VIP prize can be bought. */\n vipOnSale: () => features.get(\"premium\")?.available === true,\n openVip: (premiumID: string) => features.open(\"premium\", { premiumID }),\n };\n}\nexport type EventsStore = ReturnType<typeof createEventsStore>;\n\nexport function makeEventsScreen(store: EventsStore) {\n return function EventsScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const events = useSyncExternalStore(store.subscribe, store.get, store.get);\n const catalog = useCatalog();\n const [tab, setTab] = useState<string | null>(null);\n useEffect(() => {\n void store.load(client);\n }, [client]);\n if (!events)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (events.length === 0)\n return <EmptyState glyph=\"flame\" text={t(\"noEvents\")} />;\n const current = events.find((e) => e.TimedEventID === tab) ?? events[0]!;\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 12,\n }}\n >\n <Tabs\n tabs={events.map((e) => ({\n id: e.TimedEventID!,\n label: catalog.localize(e.Content?.DisplayName ?? e.TimedEventID),\n }))}\n value={current.TimedEventID!}\n onChange={setTab}\n />\n <EventView key={current.TimedEventID} event={current} store={store} />\n </div>\n );\n };\n}\n\n/** Keeps the badge current: loads the events once, then every few minutes and on premium changes. */\nexport function makeEventsWatcher(store: EventsStore) {\n return function EventsWatcher(): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n useEffect(() => {\n void store.load(client);\n const id = setInterval(() => void store.load(client), 5 * 60_000);\n return () => clearInterval(id);\n }, [client]);\n useEffect(() => store.refreshBadge(), [state?.Premium]);\n return null;\n };\n}\n\nfunction EventView({\n event,\n store,\n}: {\n event: ActiveEventInfo;\n store: EventsStore;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const now = useNow();\n const stagger = useStagger();\n const [sources, setSources] = useState(false);\n const [busyAll, setBusyAll] = useState(false);\n const premium = state?.Premium as PremiumView | undefined;\n const rows = milestoneRows(event, premium, now);\n const goal = nextGoal(event, rows);\n const token = catalog.localize(event.Content?.Token?.DisplayName ?? \"\");\n const vipIDs = premiumIDsOf(\n Object.values(event.Content?.Milestones ?? {}).map((m) => m?.Rewards),\n );\n const vipID = vipIDs[0] ?? null;\n const vipHeld = vipID ? hasPremium(premium, vipID, 1, now) : false;\n const ready = rows.filter(claimableNow);\n\n const claimAll = async () => {\n setBusyAll(true);\n const res = await client.timedEvent.claimAllMilestones();\n setBusyAll(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n const got = claimedLines(res.data) ?? grantedBy(res.data);\n celebrate(\n got.length > 0\n ? got\n : ready.flatMap((r) => [\n ...(r.state === \"ready\" ? r.reward : []),\n ...(r.vipState === \"ready\" ? r.vip : []),\n ]),\n );\n await store.load(client);\n };\n\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n <Card\n style={{\n display: \"grid\",\n gap: 10,\n background: \"linear-gradient(180deg, #e0672b 0%, #8a2c1a 100%)\",\n }}\n >\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 10 }}>\n <div className=\"idos-bounce\">\n <Icon\n glyph={event.Content?.AssetPaths?.icon ?? \"flame\"}\n size={46}\n />\n </div>\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 18 }}>\n {catalog.localize(\n event.Content?.DisplayName ?? event.TimedEventID,\n )}\n </div>\n <Timer at={event.ComputedEndUtc} now={now} label={t(\"endsIn\")} />\n </div>\n <Button size=\"sm\" tone=\"blue\" onClick={() => setSources(true)}>\n {t(\"howToEarn\")}\n </Button>\n </div>\n {event.Content?.Description ? (\n <div\n style={{\n ...outlined,\n fontSize: 13,\n fontWeight: 600,\n color: \"rgba(255,255,255,.85)\",\n }}\n >\n {catalog.localize(event.Content.Description)}\n </div>\n ) : null}\n <div style={{ display: \"grid\", gap: 4 }}>\n <div\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n ...outlined,\n fontSize: 13,\n }}\n >\n <span\n style={{ display: \"inline-flex\", alignItems: \"center\", gap: 4 }}\n >\n <Icon glyph=\"star\" size={16} /> {formatAmount(goal.earned)}{\" \"}\n {token}\n </span>\n <span>\n {goal.earned >= goal.goal\n ? t(\"allDone\")\n : `${t(\"nextPrize\")} ${formatAmount(goal.goal)}`}\n </span>\n </div>\n <ProgressBar\n value={Math.min(goal.earned, goal.goal)}\n max={Math.max(1, goal.goal)}\n tone=\"gold\"\n />\n </div>\n </Card>\n\n {vipID && (vipHeld || store.vipOnSale()) ? (\n vipHeld ? (\n <div\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n ...outlined,\n fontSize: 13,\n color: v.gold,\n }}\n >\n <Icon glyph=\"crown\" size={22} /> {t(\"vipActive\")}\n </div>\n ) : (\n <Card\n onClick={() => store.openVip(vipID)}\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n padding: 10,\n border: `2px solid ${v.gold}`,\n }}\n >\n <Icon glyph=\"crown\" size={30} />\n <div style={{ flex: 1, minWidth: 0, ...outlined, fontSize: 13 }}>\n {t(\"vipBenefit\")}\n </div>\n <Button size=\"sm\" tone=\"gold\" attract>\n {t(\"getVip\")}\n </Button>\n </Card>\n )\n ) : null}\n\n {ready.length > 1 ? (\n <Button\n tone=\"green\"\n attract\n busy={busyAll}\n onClick={() => void claimAll()}\n data-tutorial-anchor=\"events:claim-all\"\n >\n {t(\"claimAll\")} · {ready.length}\n </Button>\n ) : null}\n\n <div style={{ display: \"grid\", gap: 8 }}>\n {rows.map((row, i) => (\n <MilestoneCard\n key={row.id}\n event={event}\n row={row}\n index={i}\n earned={goal.earned}\n store={store}\n style={stagger(i)}\n />\n ))}\n </div>\n\n {sources ? (\n <SourcesPopup event={event} onClose={() => setSources(false)} />\n ) : null}\n </div>\n );\n}\n\nfunction MilestoneCard({\n event,\n row,\n index,\n earned,\n store,\n style,\n}: {\n event: ActiveEventInfo;\n row: MilestoneRow;\n index: number;\n earned: number;\n store: EventsStore;\n style: CSSProperties;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const kit = useUiKit();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n const claimable = claimableNow(row);\n\n const claim = async () => {\n setBusy(true);\n const res = await client.timedEvent.claimMilestone(\n event.Type!,\n event.TimedEventID!,\n row.id,\n );\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"claim\");\n kit.fx.burst(ref.current);\n const got = claimedLines(res.data) ?? grantedBy(res.data);\n celebrate(\n got.length > 0\n ? got\n : [\n ...(row.state === \"ready\" ? row.reward : []),\n ...(row.vipState === \"ready\" ? row.vip : []),\n ],\n );\n await store.load(client);\n };\n\n const reached = earned >= row.required;\n return (\n <div\n ref={ref}\n style={{\n ...panel,\n padding: 10,\n display: \"grid\",\n gridTemplateColumns: \"auto minmax(0, 1fr) auto\",\n alignItems: \"center\",\n gap: 10,\n border: claimable\n ? `2px solid ${v.gold}`\n : row.featured\n ? `2px solid ${v.blue}`\n : panel.border,\n opacity: row.state === \"claimed\" && row.vipState !== \"ready\" ? 0.6 : 1,\n ...style,\n }}\n >\n <div style={{ display: \"grid\", justifyItems: \"center\", minWidth: 48 }}>\n <Icon glyph={reached ? \"star\" : \"lock\"} size={24} />\n <span style={{ ...outlined, fontSize: 14 }}>\n {formatAmount(row.required)}\n </span>\n </div>\n <div style={{ display: \"grid\", gap: 6, minWidth: 0 }}>\n {row.name ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {catalog.localize(row.name)}\n </div>\n ) : null}\n <ResourceList lines={row.reward} size={26} />\n {row.vip.length > 0 ? (\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 6,\n opacity: row.vipState === \"locked\" ? 0.5 : 1,\n }}\n >\n <Icon glyph=\"crown\" size={18} />\n <ResourceList lines={row.vip} size={20} />\n {row.vipState === \"granted\" ? (\n <Icon glyph=\"check\" size={16} />\n ) : null}\n </div>\n ) : null}\n </div>\n <div data-index={index}>\n {claimable ? (\n <Button\n size=\"sm\"\n tone=\"green\"\n attract\n busy={busy}\n onClick={() => void claim()}\n data-tutorial-anchor={`events:claim:${row.id}`}\n >\n {row.state === \"claimed\" ? t(\"vipTopUp\") : t(\"claim\")}\n </Button>\n ) : row.state === \"claimed\" ? (\n <span\n style={{\n display: \"inline-flex\",\n alignItems: \"center\",\n gap: 4,\n ...outlined,\n fontSize: 12,\n color: v.green,\n }}\n >\n <Icon glyph=\"check\" size={18} /> {t(\"claimed\")}\n </span>\n ) : (\n <span style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"locked\")}\n </span>\n )}\n </div>\n </div>\n );\n}\n\nfunction SourcesPopup({\n event,\n onClose,\n}: {\n event: ActiveEventInfo;\n onClose: () => void;\n}): ReactNode {\n const catalog = useCatalog();\n const token = catalog.localize(event.Content?.Token?.DisplayName ?? \"\");\n const label = (kind: string) =>\n kind === \"CustomAction\"\n ? t(\"playRound\")\n : kind === \"StorePurchase\"\n ? t(\"shopping\")\n : kind === \"QuestComplete\"\n ? t(\"quests\")\n : kind === \"DailyLogin\"\n ? t(\"login\")\n : t(\"other\");\n return (\n <Popup title={t(\"howToEarn\")} onClose={onClose} width={420}>\n <div style={{ display: \"grid\", gap: 8 }}>\n {tokenSources(event).map((s, i) => (\n <div\n key={i}\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n }}\n >\n <Icon\n glyph={\n s.kind === \"StorePurchase\"\n ? \"shop\"\n : s.kind === \"QuestComplete\"\n ? \"quest\"\n : s.kind === \"DailyLogin\"\n ? \"calendar\"\n : \"star\"\n }\n size={26}\n />\n <div style={{ flex: 1, minWidth: 0, ...outlined, fontSize: 14 }}>\n {label(s.kind)}\n </div>\n <div\n style={{\n textAlign: \"right\",\n ...outlined,\n fontSize: 14,\n color: v.gold,\n }}\n >\n +{formatAmount(s.amount)} {token}\n {s.dailyCap > 0 ? (\n <div\n style={{ fontSize: 11, color: v.textDim }}\n >{`≤ ${s.dailyCap} ${t(\"perDay\")}`}</div>\n ) : null}\n </div>\n </div>\n ))}\n </div>\n </Popup>\n );\n}\n"
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"path": "i18n.ts",
|
|
53
|
+
"content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"Events\",\n noEvents: \"No events right now — check back soon.\",\n endsIn: \"Ends in\",\n claim: \"Claim\",\n claimAll: \"Claim all\",\n claimed: \"Claimed\",\n locked: \"Locked\",\n vipPrize: \"VIP prize\",\n vipTopUp: \"Claim VIP\",\n getVip: \"Get VIP\",\n vipActive: \"VIP active\",\n vipUntil: \"until\",\n vipBenefit:\n \"A second prize on every milestone — also for the ones you already claimed.\",\n howToEarn: \"How to earn\",\n playRound: \"Play a round\",\n perDay: \"per day\",\n shopping: \"Buy in the shop\",\n quests: \"Finish a quest\",\n login: \"Log in\",\n other: \"Other activity\",\n nextPrize: \"Next prize at\",\n allDone: \"All prizes collected!\",\n} as const;\n\nexport const t = makeT<keyof typeof EN>(EN, {\n title: \"Ивенты\",\n noEvents: \"Сейчас ивентов нет — загляните позже.\",\n endsIn: \"До конца\",\n claim: \"Забрать\",\n claimAll: \"Забрать всё\",\n claimed: \"Получено\",\n locked: \"Закрыто\",\n vipPrize: \"Приз VIP\",\n vipTopUp: \"Забрать VIP\",\n getVip: \"Купить VIP\",\n vipActive: \"VIP активен\",\n vipUntil: \"до\",\n vipBenefit: \"Второй приз на каждой вехе — и на тех, что вы уже забрали.\",\n howToEarn: \"Как получить\",\n playRound: \"Сыграйте раунд\",\n perDay: \"в день\",\n shopping: \"Покупка в магазине\",\n quests: \"Выполненное задание\",\n login: \"Вход в игру\",\n other: \"Другие действия\",\n nextPrize: \"Следующий приз на\",\n allDone: \"Все призы собраны!\",\n});\n"
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
"path": "index.ts",
|
|
57
|
+
"content": "// @idosgames/mod-events — Limited-time events for any game: event tabs, a milestone track with rewards, claim all and a VIP second prize.\n\nexport { eventsModule } from \"./module\";\n"
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
"path": "model.ts",
|
|
61
|
+
"content": "// Pure helpers of the events screen (Unity Core/Window LimitedTimeEvent): the milestone track of an\n// active event, its VIP second prize, and what the player can claim. The server resolves which events\n// are active and is the authority on every claim. Covered by model.test.ts.\n\nimport type { ActiveEventInfo, MilestoneDefinition } from \"@idosgames/core\";\nimport {\n bundleLines,\n grantLines,\n type ResourceLine,\n} from \"@idosgames/react/ui\";\n\n/** The player's premium passes, as the client caches them (`state.Premium`). */\nexport interface PremiumView {\n Subscriptions?: Record<\n string,\n { ExpirationDate?: string | null } | null\n > | null;\n MaxActiveTier?: number | null;\n}\n\ntype PremiumTier = {\n RequiredPremiumID?: string | null;\n MinPremiumTier?: number | null;\n Resources?: unknown;\n};\n\n/** Does the player hold the pass a premium bundle asks for (a named pass, or any pass of a tier)? */\nexport function hasPremium(\n premium: PremiumView | null | undefined,\n premiumID: string | null | undefined,\n minTier: number | null | undefined,\n nowMs: number,\n): boolean {\n if (premiumID) {\n const until = premium?.Subscriptions?.[premiumID]?.ExpirationDate;\n return !!until && new Date(until).getTime() > nowMs;\n }\n return (\n Number(premium?.MaxActiveTier ?? 0) >= Math.max(1, Number(minTier ?? 1))\n );\n}\n\n/** The server's key of a granted premium bundle: `{RequiredPremiumID|*}#{MinPremiumTier}`. */\nexport function premiumKey(tier: PremiumTier): string {\n return `${tier.RequiredPremiumID || \"*\"}#${Number(tier.MinPremiumTier ?? 0)}`;\n}\n\n/** Premium passes the rewards ask for (so the screen knows which pass to offer). */\nexport function premiumIDsOf(\n grants: Array<{ PremiumTiers?: PremiumTier[] | null } | null | undefined>,\n): string[] {\n const ids = new Set<string>();\n for (const g of grants)\n for (const t of g?.PremiumTiers ?? [])\n if (t?.RequiredPremiumID) ids.add(t.RequiredPremiumID);\n return [...ids];\n}\n\nexport interface MilestoneRow {\n id: string;\n name: string | null;\n required: number;\n featured: boolean;\n reward: ResourceLine[];\n /** The VIP second prize (empty when the milestone has none). */\n vip: ResourceLine[];\n /** Pass the VIP prize asks for (the first one). */\n vipPremiumID: string | null;\n state: \"claimed\" | \"ready\" | \"locked\";\n /**\n * none — no VIP prize; locked — the player has no pass; ready — the pass is held and the prize\n * can be taken (with the base, or topped up after it); granted — already taken.\n */\n vipState: \"none\" | \"locked\" | \"ready\" | \"granted\";\n}\n\nexport function eventEarned(e: ActiveEventInfo): number {\n return Number(e.Progress?.Balance?.TotalEarned ?? 0);\n}\n\n/** The event's milestones in threshold order, with what the player can do about each. */\nexport function milestoneRows(\n e: ActiveEventInfo,\n premium: PremiumView | null | undefined,\n nowMs: number,\n): MilestoneRow[] {\n const earned = eventEarned(e);\n const claims = e.Progress?.Milestone?.Claims ?? {};\n return Object.entries(e.Content?.Milestones ?? {})\n .filter(([, m]) => m)\n .map(([key, m]) => {\n const def = m as MilestoneDefinition;\n const id = def.MilestoneID || key;\n const claim = claims[id];\n const claimed = !!claim?.ClaimedAt;\n const reached = earned >= Number(def.RequiredProgress ?? 0);\n const tiers = (def.Rewards?.PremiumTiers ?? []) as PremiumTier[];\n const vip = tiers.flatMap((t) => bundleLines(t.Resources as never));\n const held = tiers.filter((t) =>\n hasPremium(premium, t.RequiredPremiumID, t.MinPremiumTier, nowMs),\n );\n const granted = claim?.PremiumTiers ?? [];\n const missing = held.filter((t) => !granted.includes(premiumKey(t)));\n let vipState: MilestoneRow[\"vipState\"] = \"locked\";\n if (tiers.length === 0) vipState = \"none\";\n else if (tiers.every((t) => granted.includes(premiumKey(t))))\n vipState = \"granted\";\n else if (missing.length > 0 && reached) vipState = \"ready\";\n return {\n id,\n name: def.DisplayName ?? null,\n required: Number(def.RequiredProgress ?? 0),\n featured: def.IsFeatured === true,\n reward: grantLines(def.Rewards),\n vip,\n vipPremiumID:\n tiers.find((t) => t.RequiredPremiumID)?.RequiredPremiumID ?? null,\n state: claimed ? \"claimed\" : reached ? \"ready\" : \"locked\",\n vipState,\n } satisfies MilestoneRow;\n })\n .sort((a, b) => a.required - b.required || a.id.localeCompare(b.id));\n}\n\n/** What a tap on the row claims now: the base (with any held VIP part), or only the VIP top-up. */\nexport function claimableNow(row: MilestoneRow): boolean {\n return (\n row.state === \"ready\" ||\n (row.state === \"claimed\" && row.vipState === \"ready\")\n );\n}\n\n/** Progress toward the next unclaimed-or-unreached milestone, for the header bar. */\nexport function nextGoal(\n e: ActiveEventInfo,\n rows: MilestoneRow[],\n): { earned: number; goal: number; from: number } {\n const earned = eventEarned(e);\n const next = rows.find((r) => r.required > earned);\n const prev = [...rows].reverse().find((r) => r.required <= earned);\n return {\n earned,\n goal: next?.required ?? rows[rows.length - 1]?.required ?? earned,\n from: prev?.required ?? 0,\n };\n}\n\n/** Badge: milestones (or VIP top-ups) waiting across the active events. */\nexport function eventsBadge(\n events: ActiveEventInfo[],\n premium: PremiumView | null | undefined,\n nowMs: number,\n): number {\n return events.reduce(\n (n, e) => n + milestoneRows(e, premium, nowMs).filter(claimableNow).length,\n 0,\n );\n}\n\nexport interface TokenSourceView {\n kind: string;\n action: string | null;\n amount: number;\n dailyCap: number;\n}\n\n/** How the event's tokens are earned, for the \"how to get\" popup. */\nexport function tokenSources(e: ActiveEventInfo): TokenSourceView[] {\n return (e.Content?.TokenSources ?? []).map((s) => {\n const src = s as {\n SourceType?: string | null;\n BaseWeight?: number | null;\n Params?: Record<string, string> | null;\n Limits?: { DailyCap?: number | null } | null;\n };\n return {\n kind: String(src.SourceType ?? \"\"),\n action: src.Params?.ActionName ?? null,\n amount: Number(src.BaseWeight ?? 1),\n dailyCap: Number(src.Limits?.DailyCap ?? 0),\n };\n });\n}\n\n/**\n * Everything a claim paid, for the reward popup: the standard part plus every premium (VIP) bundle\n * of the applied operation, summed by resource. The generic reader takes only the standard part,\n * so a VIP prize paid with the base would not show. `null` = the response has no operation.\n */\nexport function claimedLines(data: unknown): ResourceLine[] | null {\n const d = data as\n { Resources?: OpView | null; Rewards?: OpView | null } | null | undefined;\n const op = d?.Resources ?? d?.Rewards;\n if (!op?.Grant) return null;\n const merged = new Map<string, ResourceLine>();\n const add = (lines: ResourceLine[]) => {\n for (const l of lines) {\n const key = `${l.kind}:${l.id}`;\n const prev = merged.get(key);\n merged.set(\n key,\n prev ? { ...prev, amount: prev.amount + l.amount } : { ...l },\n );\n }\n };\n add(bundleLines(op.Grant.Standard as never));\n for (const t of op.Grant.PremiumTiers ?? [])\n add(bundleLines(t?.Resources as never));\n return [...merged.values()];\n}\ntype OpView = {\n Grant?: {\n Standard?: unknown;\n PremiumTiers?: Array<{ Resources?: unknown } | null> | null;\n } | null;\n};\n"
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
"path": "module.ts",
|
|
65
|
+
"content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport {\n createEventsStore,\n makeEventsScreen,\n makeEventsWatcher,\n} from \"./EventsScreen\";\nimport { t } from \"./i18n\";\n\n// Limited-time events as a feature of the game. The always-on watcher keeps the badge current\n// (milestones and VIP top-ups waiting); the screen shows every active event as a tab.\nexport const eventsModule: Module = defineModule({\n id: \"events\",\n meta: { name: \"Events\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n const store = createEventsStore(ctx.features);\n ctx.features.register({\n id: \"events\",\n label: t(\"title\"),\n icon: \"flame\",\n group: \"events\",\n order: 25,\n available: false,\n Screen: makeEventsScreen(store),\n });\n // The tab shows while an event is running — the watcher loads them at start.\n store.subscribe(() =>\n ctx.features.setAvailable(\n \"events\",\n (store.get()?.length ?? 0) > 0,\n \"no active event right now (TimedEvent)\",\n ),\n );\n ctx.registerPanel({\n id: \"badge\",\n slot: \"modal\",\n activeOnly: false,\n component: makeEventsWatcher(store),\n });\n ctx.exposeToAgent({\n state: () => ({\n ui: \"dom\",\n feature: \"events\",\n active: store.get()?.map((e) => e.TimedEventID) ?? [],\n }),\n describeActions: {},\n });\n },\n});\n"
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"path": "module.meta.json",
|
|
69
|
+
"content": "{\n \"id\": \"events\",\n \"type\": \"feature\",\n \"summary\": \"Limited-time events for any game: event tabs with a timer, a token bar, a milestone track, claim all and a VIP second prize.\",\n \"description\": \"A ready events screen (Unity Core/Window LimitedTimeEvent) for ANY game, built on the @idosgames/react/ui kit and client.timedEvent. Every active event the server resolves (scheduled, cyclic or chained) is a tab: its name and timer, the token bar toward the next prize and the milestone track in threshold order. A milestone pays its reward and, when the reward has premium tiers, a VIP second prize; a pass bought later tops up the milestones already claimed. 'Claim all' takes everything waiting in one call. How the tokens are earned (the event's token sources with their daily caps) is a popup; 'Get VIP' opens the VIP system (ctx.features.open('premium', { premiumID })) and shows only when it is installed and has a pass. Registers the 'events' feature; its badge counts milestones and VIP top-ups waiting. Tokens come from the game: a round of play is a CustomAction the game grants with client.timedEvent.grantTokens (the mini-game module does it for every active event that lists the action). Ships title-seed.json: a weekly 'Star festival' with five milestones and a VIP prize on each.\",\n \"provides\": [\n \"events screen with a tab per active event\",\n \"event timer and token progress bar\",\n \"milestone track with rewards\",\n \"VIP second prize and top-up after purchase\",\n \"claim all milestones\",\n \"how-to-earn popup with daily caps\",\n \"hand-off to the VIP system\",\n \"events badge\",\n \"demo weekly event\"\n ],\n \"tags\": [\n \"events\",\n \"live-ops\",\n \"limited-time\",\n \"milestones\",\n \"battle-pass\",\n \"vip\",\n \"ui\",\n \"screen\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.0\"\n}\n"
|
|
70
|
+
}
|
|
71
|
+
]
|
|
72
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"id": "friends
|
|
2
|
+
"id": "friends",
|
|
3
3
|
"meta": {
|
|
4
4
|
"name": "Friends",
|
|
5
5
|
"type": "app",
|
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
},
|
|
8
8
|
"catalog": {
|
|
9
9
|
"type": "feature",
|
|
10
|
+
"inBase": true,
|
|
10
11
|
"summary": "Friends for any game: the friends list, requests, suggestions and the notification inbox, with a player card and add-by-ID in popups.",
|
|
11
|
-
"description": "A ready friends screen (Unity Runtime/UI/Friends) for ANY game, built on the @idosgames/react/ui kit and client.social. Tabs: friends, requests (incoming with accept / decline, and the ones you sent), suggestions (players active in this game lately) and news — the notification inbox (attacks, raids, new friends, sales, follows, likes, comments), opened as seen. The player's own ID sits on top with a copy button. Everything detailed is a popup: adding a friend by ID, the player card (level, power; message — opens the chat feature when chat
|
|
12
|
+
"description": "A ready friends screen (Unity Runtime/UI/Friends) for ANY game, built on the @idosgames/react/ui kit and client.social. Tabs: friends, requests (incoming with accept / decline, and the ones you sent), suggestions (players active in this game lately) and news — the notification inbox (attacks, raids, new friends, sales, follows, likes, comments), opened as seen. The player's own ID sits on top with a copy button. Everything detailed is a popup: adding a friend by ID, the player card (level, power; message — opens the chat feature when chat is installed; remove friend; block) and the confirmations. When the title takes friends from idosgames.com only, requests, suggestions and 'Add' disappear and platform friends cannot be removed in the game. Registers the 'friends' feature; its badge counts requests waiting and unread news. No title settings needed.",
|
|
12
13
|
"provides": [
|
|
13
14
|
"friends list",
|
|
14
15
|
"incoming and sent friend requests",
|
|
@@ -34,28 +35,24 @@
|
|
|
34
35
|
"version": "0.1.0"
|
|
35
36
|
},
|
|
36
37
|
"dependencies": {
|
|
37
|
-
"@idosgames/core": "0.19.
|
|
38
|
-
"@idosgames/module-sdk": "0.
|
|
38
|
+
"@idosgames/core": "0.19.1",
|
|
39
|
+
"@idosgames/module-sdk": "0.7.0",
|
|
39
40
|
"@idosgames/react": "0.3.0",
|
|
40
41
|
"react": "19.2.7"
|
|
41
42
|
},
|
|
42
|
-
"contentHash": "
|
|
43
|
+
"contentHash": "24e30cf8e6717be3fa368830a3b5fdee5a5b8f823a890051274ef1900e570bc9",
|
|
43
44
|
"files": [
|
|
44
45
|
{
|
|
45
46
|
"path": "FriendsScreen.tsx",
|
|
46
|
-
"content": "import { useCallback, useEffect, useState, type ReactNode } from \"react\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Center,\n ConfirmPopup,\n EmptyState,\n Icon,\n Popup,\n Spinner,\n Tabs,\n errorText,\n formatAmount,\n outlined,\n panel,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n actorsText,\n canRemove,\n cleanUserID,\n displayName,\n friendsBadge,\n inboxKind,\n initial,\n sortByName,\n type InboxKind,\n type ProfileView,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Friends (Unity Runtime/UI/Friends): the friends list, requests (incoming and sent), suggestions\n// and the notification inbox as tabs. A player card, adding by ID and the confirmations are popups.\n// In \"friends from idosgames.com only\" mode the requests, suggestions and \"Add\" disappear.\n\ntype Tab = \"friends\" | \"requests\" | \"suggested\" | \"inbox\";\ntype InboxItem = {\n Id: string;\n Verb?: string | null;\n Count?: number | null;\n LastActors?: Array<{ DisplayName?: string | null } | null> | null;\n Payload?: { Title?: string | null } | null;\n OccurredAt?: string | null;\n};\n\n/** Keeps the badge: incoming requests (sent with the player state) + unread notifications. */\nexport function makeFriendsWatcher(features: FeatureRegistry) {\n return function FriendsWatcher(): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const [unread, setUnread] = useState(0);\n useEffect(() => {\n const load = () =>\n void client.social\n .getInboxUnread()\n .then((r) => r.ok && setUnread(Number(r.data.UnreadCount ?? 0)));\n load();\n const id = setInterval(load, 5 * 60_000);\n const off = client.on(\"social:inboxBadge\", (r) =>\n setUnread(Number(r.UnreadCount ?? 0)),\n );\n return () => {\n clearInterval(id);\n off();\n };\n }, [client]);\n useEffect(\n () =>\n features.setBadge(\n \"friends\",\n friendsBadge(\n state?.Social as { IncomingCount?: number } | undefined,\n unread,\n ),\n ),\n [state?.Social, unread],\n );\n return null;\n };\n}\n\nexport function makeFriendsScreen(features: FeatureRegistry) {\n return function FriendsScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const [tab, setTab] = useState<Tab>(\"friends\");\n const [adding, setAdding] = useState(false);\n const [card, setCard] = useState<ProfileView | null>(null);\n const [version, setVersion] = useState(0);\n const requestsAllowed = client.social.friendRequestsAllowed();\n const incoming = Number(\n (state?.Social as { IncomingCount?: number } | undefined)\n ?.IncomingCount ?? 0,\n );\n const refresh = () => setVersion((n) => n + 1);\n const tabs: Array<{ id: Tab; label: string; badge?: number }> = [\n { id: \"friends\", label: t(\"friends\") },\n ...(requestsAllowed\n ? [\n { id: \"requests\" as const, label: t(\"requests\"), badge: incoming },\n { id: \"suggested\" as const, label: t(\"suggested\") },\n ]\n : []),\n { id: \"inbox\", label: t(\"inbox\") },\n ];\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 12,\n }}\n >\n <MyID />\n <div style={{ display: \"flex\", gap: 10, alignItems: \"center\" }}>\n <div style={{ flex: 1, minWidth: 0 }}>\n <Tabs tabs={tabs} value={tab} onChange={setTab} />\n </div>\n {requestsAllowed ? (\n <Button\n tone=\"gold\"\n size=\"sm\"\n onClick={() => setAdding(true)}\n data-tutorial-anchor=\"friends:add\"\n >\n <Icon glyph=\"plus\" size={16} /> {t(\"add\")}\n </Button>\n ) : null}\n </div>\n <div key={`${tab}:${version}`}>\n {tab === \"friends\" ? <FriendList onPick={setCard} /> : null}\n {tab === \"requests\" ? (\n <Requests onChange={refresh} onPick={setCard} />\n ) : null}\n {tab === \"suggested\" ? <Suggested onPick={setCard} /> : null}\n {tab === \"inbox\" ? <Inbox /> : null}\n </div>\n {adding ? (\n <AddFriendPopup onClose={() => setAdding(false)} onSent={refresh} />\n ) : null}\n {card ? (\n <PlayerCard\n player={card}\n features={features}\n onClose={() => setCard(null)}\n onChange={refresh}\n />\n ) : null}\n </div>\n );\n };\n}\n\nfunction MyID(): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const id = client.auth.context?.userID ?? \"\";\n const copy = () =>\n void navigator.clipboard?.writeText(id).then(() => toast(t(\"copied\")));\n return (\n <div\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n }}\n >\n <span style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"yourID\")}\n </span>\n <span\n style={{\n flex: 1,\n minWidth: 0,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n fontFamily: \"monospace\",\n fontSize: 13,\n }}\n >\n {id}\n </span>\n <Button size=\"sm\" tone=\"blue\" onClick={copy}>\n <Icon glyph=\"copy\" size={16} />\n </Button>\n </div>\n );\n}\n\nfunction Avatar({\n player,\n size = 44,\n}: {\n player: ProfileView;\n size?: number;\n}): ReactNode {\n const url = player.PublicData?.AvatarUrl;\n return (\n <div\n style={{\n width: size,\n height: size,\n flex: \"0 0 auto\",\n borderRadius: \"50%\",\n overflow: \"hidden\",\n display: \"grid\",\n placeItems: \"center\",\n background: `linear-gradient(180deg, ${v.blue}, ${v.blueDeep})`,\n border: \"2px solid rgba(255,255,255,.6)\",\n ...outlined,\n fontSize: size * 0.42,\n }}\n >\n {url ? (\n <img\n src={url}\n alt=\"\"\n style={{ width: \"100%\", height: \"100%\", objectFit: \"cover\" }}\n />\n ) : (\n initial(player)\n )}\n </div>\n );\n}\n\nfunction PlayerRow({\n player,\n index,\n onPick,\n right,\n}: {\n player: ProfileView;\n index: number;\n onPick?: (p: ProfileView) => void;\n right?: ReactNode;\n}): ReactNode {\n const stagger = useStagger();\n return (\n <div\n onClick={onPick ? () => onPick(player) : undefined}\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n cursor: onPick ? \"pointer\" : undefined,\n ...stagger(index),\n }}\n >\n <Avatar player={player} />\n <div style={{ flex: 1, minWidth: 0 }}>\n <div\n style={{\n ...outlined,\n fontSize: 15,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n }}\n >\n {displayName(player)}\n </div>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {player.PublicData?.Level\n ? `${t(\"level\")} ${player.PublicData.Level}`\n : \"\"}\n {player.PublicData?.Power\n ? ` · ${t(\"power\")} ${formatAmount(player.PublicData.Power)}`\n : \"\"}\n {player.Source === \"Platform\" ? ` · ${t(\"platformFriend\")}` : \"\"}\n </div>\n </div>\n {right}\n </div>\n );\n}\n\nfunction useList(\n load: () => Promise<{\n ok: boolean;\n data?: { Friends?: ProfileView[] | null };\n }>,\n): ProfileView[] | null {\n const [list, setList] = useState<ProfileView[] | null>(null);\n useEffect(() => {\n void load().then((r) =>\n setList(r.ok ? sortByName(r.data?.Friends ?? []) : []),\n );\n }, [load]);\n return list;\n}\n\nfunction FriendList({\n onPick,\n}: {\n onPick: (p: ProfileView) => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const list = useList(\n useCallback(() => client.social.getFriendsList(), [client]),\n );\n if (!list)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (list.length === 0)\n return <EmptyState glyph=\"friends\" text={t(\"noFriends\")} />;\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {list.map((p, i) => (\n <PlayerRow key={p.UserID} player={p} index={i} onPick={onPick} />\n ))}\n </div>\n );\n}\n\nfunction Requests({\n onChange,\n onPick,\n}: {\n onChange: () => void;\n onPick: (p: ProfileView) => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const kit = useUiKit();\n const incoming = useList(\n useCallback(() => client.social.getIncomingRequests(), [client]),\n );\n const outgoing = useList(\n useCallback(() => client.social.getOutgoingRequests(), [client]),\n );\n const [busy, setBusy] = useState<string | null>(null);\n if (!incoming || !outgoing)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (incoming.length === 0 && outgoing.length === 0)\n return <EmptyState glyph=\"friends\" text={t(\"noRequests\")} />;\n const answer = async (id: string, yes: boolean) => {\n setBusy(id);\n const res = yes\n ? await client.social.acceptFriendRequest(id)\n : await client.social.declineFriendRequest(id);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n if (yes) kit.play(\"claim\");\n onChange();\n };\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n {incoming.length > 0 ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"incoming\")}\n </div>\n ) : null}\n {incoming.map((p, i) => (\n <PlayerRow\n key={p.UserID}\n player={p}\n index={i}\n onPick={onPick}\n right={\n <div\n style={{ display: \"flex\", gap: 6 }}\n onClick={(e) => e.stopPropagation()}\n >\n <Button\n size=\"sm\"\n tone=\"green\"\n busy={busy === p.UserID}\n onClick={() => void answer(p.UserID, true)}\n data-tutorial-anchor=\"friends:accept\"\n >\n {t(\"accept\")}\n </Button>\n <Button\n size=\"sm\"\n tone=\"grey\"\n disabled={busy === p.UserID}\n onClick={() => void answer(p.UserID, false)}\n >\n ✕\n </Button>\n </div>\n }\n />\n ))}\n {outgoing.length > 0 ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"outgoing\")}\n </div>\n ) : null}\n {outgoing.map((p, i) => (\n <PlayerRow\n key={p.UserID}\n player={p}\n index={incoming.length + i}\n onPick={onPick}\n right={<Icon glyph=\"clock\" size={20} />}\n />\n ))}\n </div>\n );\n}\n\nfunction Suggested({\n onPick,\n}: {\n onPick: (p: ProfileView) => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const list = useList(\n useCallback(() => client.social.getRecommendedFriends(10), [client]),\n );\n const [sent, setSent] = useState<Set<string>>(new Set());\n if (!list)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (list.length === 0)\n return <EmptyState glyph=\"friends\" text={t(\"noSuggestions\")} />;\n const add = async (id: string) => {\n const res = await client.social.sendFriendRequest(id);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n setSent((s) => new Set(s).add(id));\n toast(t(\"sent\"));\n };\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {list.map((p, i) => (\n <PlayerRow\n key={p.UserID}\n player={p}\n index={i}\n onPick={onPick}\n right={\n <span onClick={(e) => e.stopPropagation()}>\n {sent.has(p.UserID) ? (\n <Icon glyph=\"check\" size={22} />\n ) : (\n <Button\n size=\"sm\"\n tone=\"green\"\n onClick={() => void add(p.UserID)}\n >\n {t(\"add\")}\n </Button>\n )}\n </span>\n }\n />\n ))}\n </div>\n );\n}\n\nconst VERB_TEXT: Record<InboxKind, Parameters<typeof t>[0]> = {\n attack: \"vAttack\",\n raid: \"vRaid\",\n friend: \"vFriend\",\n sale: \"vSale\",\n follow: \"vFollow\",\n like: \"vLike\",\n comment: \"vComment\",\n reply: \"vReply\",\n other: \"vOther\",\n};\nconst VERB_GLYPH: Record<\n InboxKind,\n \"swords\" | \"flame\" | \"friends\" | \"coin\" | \"user\" | \"heart\" | \"chat\"\n> = {\n attack: \"swords\",\n raid: \"flame\",\n friend: \"friends\",\n sale: \"coin\",\n follow: \"user\",\n like: \"heart\",\n comment: \"chat\",\n reply: \"chat\",\n other: \"chat\",\n};\n\nfunction Inbox(): ReactNode {\n const client = useIDosGamesClient();\n const stagger = useStagger();\n const [items, setItems] = useState<InboxItem[] | null>(null);\n useEffect(() => {\n void client.social.getInbox({ limit: 30 }).then((r) => {\n setItems(r.ok ? ((r.data.Items ?? []) as InboxItem[]) : []);\n // Opening the news marks them seen (once, not per page).\n if (r.ok) void client.social.markInboxSeen();\n });\n }, [client]);\n if (!items)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (items.length === 0)\n return <EmptyState glyph=\"bell\" text={t(\"noInbox\")} />;\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {items.map((it, i) => {\n const kind = inboxKind(it.Verb);\n return (\n <div\n key={it.Id}\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n ...stagger(i),\n }}\n >\n <Icon glyph={VERB_GLYPH[kind]} size={28} />\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 14 }}>\n {actorsText(\n it.LastActors,\n it.Count,\n t(\"someone\"),\n t(\"and\"),\n t(\"more\"),\n )}{\" \"}\n {t(VERB_TEXT[kind])}\n {it.Payload?.Title ? ` · ${it.Payload.Title}` : \"\"}\n </div>\n {it.OccurredAt ? (\n <div style={{ fontSize: 11, color: v.textDim }}>\n {new Date(it.OccurredAt).toLocaleString()}\n </div>\n ) : null}\n </div>\n </div>\n );\n })}\n </div>\n );\n}\n\nfunction AddFriendPopup({\n onClose,\n onSent,\n}: {\n onClose: () => void;\n onSent: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const kit = useUiKit();\n const [value, setValue] = useState(\"\");\n const [busy, setBusy] = useState(false);\n const id = cleanUserID(value, client.auth.context?.userID);\n const send = async () => {\n if (!id) return;\n setBusy(true);\n const res = await client.social.sendFriendRequest(id);\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"notify\");\n toast(t(\"sent\"));\n onSent();\n onClose();\n };\n return (\n <Popup title={t(\"addFriend\")} onClose={onClose} width={400}>\n <div style={{ display: \"grid\", gap: 10 }}>\n <input\n value={value}\n onChange={(e) => setValue(e.target.value)}\n placeholder={t(\"playerID\")}\n autoFocus\n style={{\n ...panel,\n padding: \"12px 14px\",\n fontSize: 15,\n color: v.text,\n outline: \"none\",\n fontFamily: \"monospace\",\n }}\n onKeyDown={(e) => e.key === \"Enter\" && void send()}\n />\n {value && !id ? (\n <div style={{ ...outlined, fontSize: 12, color: v.red }}>\n {t(\"badID\")}\n </div>\n ) : null}\n <Button\n tone=\"green\"\n disabled={!id}\n busy={busy}\n onClick={() => void send()}\n >\n {t(\"send\")}\n </Button>\n </div>\n </Popup>\n );\n}\n\nfunction PlayerCard({\n player,\n features,\n onClose,\n onChange,\n}: {\n player: ProfileView;\n features: FeatureRegistry;\n onClose: () => void;\n onChange: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const [ask, setAsk] = useState<\"remove\" | \"block\" | null>(null);\n const hasChat = !!features.get(\"chat\");\n const isFriend =\n !!player.Source ||\n (\n client.data.user.state?.Social?.Accepted as\n Array<{ UserID?: string }> | undefined\n )?.some((f) => f.UserID === player.UserID);\n return (\n <Popup title={displayName(player)} onClose={onClose} width={400}>\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 8 }}>\n <Avatar player={player} size={84} />\n <div\n style={{\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {player.PublicData?.Level\n ? `${t(\"level\")} ${player.PublicData.Level}`\n : \"\"}\n {player.PublicData?.Power\n ? ` · ${t(\"power\")} ${formatAmount(player.PublicData.Power)}`\n : \"\"}\n </div>\n <div\n style={{ fontFamily: \"monospace\", fontSize: 11, color: v.textDim }}\n >\n {player.UserID}\n </div>\n </div>\n <div style={{ display: \"grid\", gap: 8 }}>\n {hasChat && isFriend ? (\n <Button\n tone=\"blue\"\n onClick={() => {\n // The chat opens in its own window — the card must not stay on top of it.\n onClose();\n features.open(\"chat\", { userID: player.UserID });\n }}\n >\n <Icon glyph=\"chat\" size={18} /> {t(\"message\")}\n </Button>\n ) : null}\n {isFriend && canRemove(player) ? (\n <Button tone=\"grey\" onClick={() => setAsk(\"remove\")}>\n {t(\"remove\")}\n </Button>\n ) : null}\n <Button tone=\"red\" onClick={() => setAsk(\"block\")}>\n {t(\"block\")}\n </Button>\n </div>\n {ask ? (\n <ConfirmPopup\n title={ask === \"remove\" ? t(\"remove\") : t(\"block\")}\n tone=\"red\"\n onClose={() => setAsk(null)}\n onConfirm={async () => {\n const res =\n ask === \"remove\"\n ? await client.social.removeFriend(player.UserID)\n : await client.social.block(\"User\", player.UserID);\n if (!res.ok) {\n toast(errorText(res.error), \"error\");\n return false;\n }\n if (ask === \"block\") toast(t(\"blocked\"));\n onChange();\n onClose();\n }}\n >\n <div style={{ ...outlined, fontSize: 14, textAlign: \"center\" }}>\n {ask === \"remove\" ? t(\"removeAsk\") : t(\"blockAsk\")}\n </div>\n </ConfirmPopup>\n ) : null}\n </Popup>\n );\n}\n"
|
|
47
|
+
"content": "import { useCallback, useEffect, useState, type ReactNode } from \"react\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Center,\n ConfirmPopup,\n EmptyState,\n Icon,\n Popup,\n Spinner,\n Tabs,\n errorText,\n formatAmount,\n outlined,\n panel,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n actorsText,\n canRemove,\n cleanUserID,\n displayName,\n friendsBadge,\n inboxKind,\n initial,\n sortByName,\n type InboxKind,\n type ProfileView,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Friends (Unity Runtime/UI/Friends): the friends list, requests (incoming and sent), suggestions\n// and the notification inbox as tabs. A player card, adding by ID and the confirmations are popups.\n// In \"friends from idosgames.com only\" mode the requests, suggestions and \"Add\" disappear.\n\ntype Tab = \"friends\" | \"requests\" | \"suggested\" | \"inbox\";\ntype InboxItem = {\n Id: string;\n Verb?: string | null;\n Count?: number | null;\n LastActors?: Array<{ DisplayName?: string | null } | null> | null;\n Payload?: { Title?: string | null } | null;\n OccurredAt?: string | null;\n};\n\n/** Keeps the badge: incoming requests (sent with the player state) + unread notifications. */\nexport function makeFriendsWatcher(features: FeatureRegistry) {\n return function FriendsWatcher(): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const [unread, setUnread] = useState(0);\n useEffect(() => {\n const load = () =>\n void client.social\n .getInboxUnread()\n .then((r) => r.ok && setUnread(Number(r.data.UnreadCount ?? 0)));\n load();\n const id = setInterval(load, 5 * 60_000);\n const off = client.on(\"social:inboxBadge\", (r) =>\n setUnread(Number(r.UnreadCount ?? 0)),\n );\n return () => {\n clearInterval(id);\n off();\n };\n }, [client]);\n useEffect(\n () =>\n features.setBadge(\n \"friends\",\n friendsBadge(\n state?.Social as { IncomingCount?: number } | undefined,\n unread,\n ),\n ),\n [state?.Social, unread],\n );\n return null;\n };\n}\n\nexport function makeFriendsScreen(features: FeatureRegistry) {\n return function FriendsScreen(_: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const [tab, setTab] = useState<Tab>(\"friends\");\n const [adding, setAdding] = useState(false);\n const [card, setCard] = useState<ProfileView | null>(null);\n const [version, setVersion] = useState(0);\n const requestsAllowed = client.social.friendRequestsAllowed();\n const incoming = Number(\n (state?.Social as { IncomingCount?: number } | undefined)\n ?.IncomingCount ?? 0,\n );\n const refresh = () => setVersion((n) => n + 1);\n const tabs: Array<{ id: Tab; label: string; badge?: number }> = [\n { id: \"friends\", label: t(\"friends\") },\n ...(requestsAllowed\n ? [\n { id: \"requests\" as const, label: t(\"requests\"), badge: incoming },\n { id: \"suggested\" as const, label: t(\"suggested\") },\n ]\n : []),\n { id: \"inbox\", label: t(\"inbox\") },\n ];\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 12,\n }}\n >\n <MyID />\n <div style={{ display: \"flex\", gap: 10, alignItems: \"center\" }}>\n <div style={{ flex: 1, minWidth: 0 }}>\n <Tabs tabs={tabs} value={tab} onChange={setTab} />\n </div>\n {requestsAllowed ? (\n <Button\n tone=\"gold\"\n size=\"sm\"\n onClick={() => setAdding(true)}\n data-tutorial-anchor=\"friends:add\"\n >\n <Icon glyph=\"plus\" size={16} /> {t(\"add\")}\n </Button>\n ) : null}\n </div>\n <div key={`${tab}:${version}`}>\n {tab === \"friends\" ? <FriendList onPick={setCard} /> : null}\n {tab === \"requests\" ? (\n <Requests onChange={refresh} onPick={setCard} />\n ) : null}\n {tab === \"suggested\" ? <Suggested onPick={setCard} /> : null}\n {tab === \"inbox\" ? <Inbox /> : null}\n </div>\n {adding ? (\n <AddFriendPopup onClose={() => setAdding(false)} onSent={refresh} />\n ) : null}\n {card ? (\n <PlayerCard\n player={card}\n features={features}\n onClose={() => setCard(null)}\n onChange={refresh}\n />\n ) : null}\n </div>\n );\n };\n}\n\nfunction MyID(): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const id = client.auth.context?.userID ?? \"\";\n const copy = () =>\n void navigator.clipboard?.writeText(id).then(() => toast(t(\"copied\")));\n return (\n <div\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n }}\n >\n <span style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"yourID\")}\n </span>\n <span\n style={{\n flex: 1,\n minWidth: 0,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n fontFamily: \"monospace\",\n fontSize: 13,\n }}\n >\n {id}\n </span>\n <Button size=\"sm\" tone=\"blue\" onClick={copy}>\n <Icon glyph=\"copy\" size={16} />\n </Button>\n </div>\n );\n}\n\nfunction Avatar({\n player,\n size = 44,\n}: {\n player: ProfileView;\n size?: number;\n}): ReactNode {\n const url = player.PublicData?.AvatarUrl;\n return (\n <div\n style={{\n width: size,\n height: size,\n flex: \"0 0 auto\",\n borderRadius: \"50%\",\n overflow: \"hidden\",\n display: \"grid\",\n placeItems: \"center\",\n background: `linear-gradient(180deg, ${v.blue}, ${v.blueDeep})`,\n border: \"2px solid rgba(255,255,255,.6)\",\n ...outlined,\n fontSize: size * 0.42,\n }}\n >\n {url ? (\n <img\n src={url}\n alt=\"\"\n style={{ width: \"100%\", height: \"100%\", objectFit: \"cover\" }}\n />\n ) : (\n initial(player)\n )}\n </div>\n );\n}\n\nfunction PlayerRow({\n player,\n index,\n onPick,\n right,\n}: {\n player: ProfileView;\n index: number;\n onPick?: (p: ProfileView) => void;\n right?: ReactNode;\n}): ReactNode {\n const stagger = useStagger();\n return (\n <div\n onClick={onPick ? () => onPick(player) : undefined}\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n cursor: onPick ? \"pointer\" : undefined,\n ...stagger(index),\n }}\n >\n <Avatar player={player} />\n <div style={{ flex: 1, minWidth: 0 }}>\n <div\n style={{\n ...outlined,\n fontSize: 15,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n }}\n >\n {displayName(player)}\n </div>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {player.PublicData?.Level\n ? `${t(\"level\")} ${player.PublicData.Level}`\n : \"\"}\n {player.PublicData?.Power\n ? ` · ${t(\"power\")} ${formatAmount(player.PublicData.Power)}`\n : \"\"}\n {player.Source === \"Platform\" ? ` · ${t(\"platformFriend\")}` : \"\"}\n </div>\n </div>\n {right}\n </div>\n );\n}\n\nfunction useList(\n load: () => Promise<{\n ok: boolean;\n data?: { Friends?: ProfileView[] | null };\n }>,\n): ProfileView[] | null {\n const [list, setList] = useState<ProfileView[] | null>(null);\n useEffect(() => {\n void load().then((r) =>\n setList(r.ok ? sortByName(r.data?.Friends ?? []) : []),\n );\n }, [load]);\n return list;\n}\n\nfunction FriendList({\n onPick,\n}: {\n onPick: (p: ProfileView) => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const list = useList(\n useCallback(() => client.social.getFriendsList(), [client]),\n );\n if (!list)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (list.length === 0)\n return <EmptyState glyph=\"friends\" text={t(\"noFriends\")} />;\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {list.map((p, i) => (\n <PlayerRow key={p.UserID} player={p} index={i} onPick={onPick} />\n ))}\n </div>\n );\n}\n\nfunction Requests({\n onChange,\n onPick,\n}: {\n onChange: () => void;\n onPick: (p: ProfileView) => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const kit = useUiKit();\n const incoming = useList(\n useCallback(() => client.social.getIncomingRequests(), [client]),\n );\n const outgoing = useList(\n useCallback(() => client.social.getOutgoingRequests(), [client]),\n );\n const [busy, setBusy] = useState<string | null>(null);\n if (!incoming || !outgoing)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (incoming.length === 0 && outgoing.length === 0)\n return <EmptyState glyph=\"friends\" text={t(\"noRequests\")} />;\n const answer = async (id: string, yes: boolean) => {\n setBusy(id);\n const res = yes\n ? await client.social.acceptFriendRequest(id)\n : await client.social.declineFriendRequest(id);\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n if (yes) kit.play(\"claim\");\n onChange();\n };\n return (\n <div style={{ display: \"grid\", gap: 12 }}>\n {incoming.length > 0 ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"incoming\")}\n </div>\n ) : null}\n {incoming.map((p, i) => (\n <PlayerRow\n key={p.UserID}\n player={p}\n index={i}\n onPick={onPick}\n right={\n <div\n style={{ display: \"flex\", gap: 6 }}\n onClick={(e) => e.stopPropagation()}\n >\n <Button\n size=\"sm\"\n tone=\"green\"\n busy={busy === p.UserID}\n onClick={() => void answer(p.UserID, true)}\n data-tutorial-anchor=\"friends:accept\"\n >\n {t(\"accept\")}\n </Button>\n <Button\n size=\"sm\"\n tone=\"grey\"\n disabled={busy === p.UserID}\n onClick={() => void answer(p.UserID, false)}\n >\n ✕\n </Button>\n </div>\n }\n />\n ))}\n {outgoing.length > 0 ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"outgoing\")}\n </div>\n ) : null}\n {outgoing.map((p, i) => (\n <PlayerRow\n key={p.UserID}\n player={p}\n index={incoming.length + i}\n onPick={onPick}\n right={<Icon glyph=\"clock\" size={20} />}\n />\n ))}\n </div>\n );\n}\n\nfunction Suggested({\n onPick,\n}: {\n onPick: (p: ProfileView) => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const list = useList(\n useCallback(() => client.social.getRecommendedFriends(10), [client]),\n );\n const [sent, setSent] = useState<Set<string>>(new Set());\n if (!list)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (list.length === 0)\n return <EmptyState glyph=\"friends\" text={t(\"noSuggestions\")} />;\n const add = async (id: string) => {\n const res = await client.social.sendFriendRequest(id);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n setSent((s) => new Set(s).add(id));\n toast(t(\"sent\"));\n };\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {list.map((p, i) => (\n <PlayerRow\n key={p.UserID}\n player={p}\n index={i}\n onPick={onPick}\n right={\n <span onClick={(e) => e.stopPropagation()}>\n {sent.has(p.UserID) ? (\n <Icon glyph=\"check\" size={22} />\n ) : (\n <Button\n size=\"sm\"\n tone=\"green\"\n onClick={() => void add(p.UserID)}\n >\n {t(\"add\")}\n </Button>\n )}\n </span>\n }\n />\n ))}\n </div>\n );\n}\n\nconst VERB_TEXT: Record<InboxKind, Parameters<typeof t>[0]> = {\n attack: \"vAttack\",\n raid: \"vRaid\",\n friend: \"vFriend\",\n sale: \"vSale\",\n follow: \"vFollow\",\n like: \"vLike\",\n comment: \"vComment\",\n reply: \"vReply\",\n other: \"vOther\",\n};\nconst VERB_GLYPH: Record<\n InboxKind,\n \"swords\" | \"flame\" | \"friends\" | \"coin\" | \"user\" | \"heart\" | \"chat\"\n> = {\n attack: \"swords\",\n raid: \"flame\",\n friend: \"friends\",\n sale: \"coin\",\n follow: \"user\",\n like: \"heart\",\n comment: \"chat\",\n reply: \"chat\",\n other: \"chat\",\n};\n\nfunction Inbox(): ReactNode {\n const client = useIDosGamesClient();\n const stagger = useStagger();\n const [items, setItems] = useState<InboxItem[] | null>(null);\n useEffect(() => {\n void client.social.getInbox({ limit: 30 }).then((r) => {\n setItems(r.ok ? ((r.data.Items ?? []) as InboxItem[]) : []);\n // Opening the news marks them seen (once, not per page).\n if (r.ok) void client.social.markInboxSeen();\n });\n }, [client]);\n if (!items)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (items.length === 0)\n return <EmptyState glyph=\"bell\" text={t(\"noInbox\")} />;\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {items.map((it, i) => {\n const kind = inboxKind(it.Verb);\n return (\n <div\n key={it.Id}\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n ...stagger(i),\n }}\n >\n <Icon glyph={VERB_GLYPH[kind]} size={28} />\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 14 }}>\n {actorsText(\n it.LastActors,\n it.Count,\n t(\"someone\"),\n t(\"and\"),\n t(\"more\"),\n )}{\" \"}\n {t(VERB_TEXT[kind])}\n {it.Payload?.Title ? ` · ${it.Payload.Title}` : \"\"}\n </div>\n {it.OccurredAt ? (\n <div style={{ fontSize: 11, color: v.textDim }}>\n {new Date(it.OccurredAt).toLocaleString()}\n </div>\n ) : null}\n </div>\n </div>\n );\n })}\n </div>\n );\n}\n\nfunction AddFriendPopup({\n onClose,\n onSent,\n}: {\n onClose: () => void;\n onSent: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const kit = useUiKit();\n const [value, setValue] = useState(\"\");\n const [busy, setBusy] = useState(false);\n const id = cleanUserID(value, client.auth.context?.userID);\n const send = async () => {\n if (!id) return;\n setBusy(true);\n const res = await client.social.sendFriendRequest(id);\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n kit.play(\"notify\");\n toast(t(\"sent\"));\n onSent();\n onClose();\n };\n return (\n <Popup title={t(\"addFriend\")} onClose={onClose} width={400}>\n <div style={{ display: \"grid\", gap: 10 }}>\n <input\n value={value}\n onChange={(e) => setValue(e.target.value)}\n placeholder={t(\"playerID\")}\n autoFocus\n style={{\n ...panel,\n padding: \"12px 14px\",\n fontSize: 15,\n color: v.text,\n outline: \"none\",\n fontFamily: \"monospace\",\n }}\n onKeyDown={(e) => e.key === \"Enter\" && void send()}\n />\n {value && !id ? (\n <div style={{ ...outlined, fontSize: 12, color: v.red }}>\n {t(\"badID\")}\n </div>\n ) : null}\n <Button\n tone=\"green\"\n disabled={!id}\n busy={busy}\n onClick={() => void send()}\n >\n {t(\"send\")}\n </Button>\n </div>\n </Popup>\n );\n}\n\nfunction PlayerCard({\n player,\n features,\n onClose,\n onChange,\n}: {\n player: ProfileView;\n features: FeatureRegistry;\n onClose: () => void;\n onChange: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const [ask, setAsk] = useState<\"remove\" | \"block\" | null>(null);\n const hasChat = !!features.get(\"chat\");\n const hasMail = !!features.get(\"mailbox\")?.available;\n const isFriend =\n !!player.Source ||\n (\n client.data.user.state?.Social?.Accepted as\n Array<{ UserID?: string }> | undefined\n )?.some((f) => f.UserID === player.UserID);\n return (\n <Popup title={displayName(player)} onClose={onClose} width={400}>\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 8 }}>\n <Avatar player={player} size={84} />\n <div\n style={{\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {player.PublicData?.Level\n ? `${t(\"level\")} ${player.PublicData.Level}`\n : \"\"}\n {player.PublicData?.Power\n ? ` · ${t(\"power\")} ${formatAmount(player.PublicData.Power)}`\n : \"\"}\n </div>\n <div\n style={{ fontFamily: \"monospace\", fontSize: 11, color: v.textDim }}\n >\n {player.UserID}\n </div>\n </div>\n <div style={{ display: \"grid\", gap: 8 }}>\n {hasChat && isFriend ? (\n <Button\n tone=\"blue\"\n onClick={() => {\n // The chat opens in its own window — the card must not stay on top of it.\n onClose();\n features.open(\"chat\", { userID: player.UserID });\n }}\n >\n <Icon glyph=\"chat\" size={18} /> {t(\"message\")}\n </Button>\n ) : null}\n {hasMail && isFriend ? (\n <Button\n tone=\"gold\"\n onClick={() => {\n // Gifts and transfers live in the mailbox — it opens straight on this friend.\n onClose();\n features.open(\"mailbox\", {\n friendUserID: player.UserID,\n friendName: displayName(player),\n });\n }}\n >\n <Icon glyph=\"gift\" size={18} /> {t(\"gift\")}\n </Button>\n ) : null}\n {isFriend && canRemove(player) ? (\n <Button tone=\"grey\" onClick={() => setAsk(\"remove\")}>\n {t(\"remove\")}\n </Button>\n ) : null}\n <Button tone=\"red\" onClick={() => setAsk(\"block\")}>\n {t(\"block\")}\n </Button>\n </div>\n {ask ? (\n <ConfirmPopup\n title={ask === \"remove\" ? t(\"remove\") : t(\"block\")}\n tone=\"red\"\n onClose={() => setAsk(null)}\n onConfirm={async () => {\n const res =\n ask === \"remove\"\n ? await client.social.removeFriend(player.UserID)\n : await client.social.block(\"User\", player.UserID);\n if (!res.ok) {\n toast(errorText(res.error), \"error\");\n return false;\n }\n if (ask === \"block\") toast(t(\"blocked\"));\n onChange();\n onClose();\n }}\n >\n <div style={{ ...outlined, fontSize: 14, textAlign: \"center\" }}>\n {ask === \"remove\" ? t(\"removeAsk\") : t(\"blockAsk\")}\n </div>\n </ConfirmPopup>\n ) : null}\n </Popup>\n );\n}\n"
|
|
47
48
|
},
|
|
48
49
|
{
|
|
49
50
|
"path": "i18n.ts",
|
|
50
|
-
"content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"Friends\",\n friends: \"Friends\",\n requests: \"Requests\",\n suggested: \"Suggested\",\n inbox: \"News\",\n noFriends: \"No friends yet — add someone by ID or pick from the suggestions.\",\n noRequests: \"No requests right now.\",\n noSuggestions: \"No suggestions right now — come back later.\",\n noInbox: \"Nothing new yet.\",\n incoming: \"Want to be friends\",\n outgoing: \"Waiting for an answer\",\n accept: \"Accept\",\n decline: \"Decline\",\n add: \"Add\",\n addFriend: \"Add a friend\",\n sent: \"Request sent\",\n playerID: \"Player ID\",\n yourID: \"Your ID\",\n copied: \"Copied\",\n send: \"Send request\",\n badID: \"Enter another player's ID\",\n message: \"Message\",\n remove: \"Remove friend\",\n removeAsk: \"Remove this player from your friends?\",\n block: \"Block\",\n blockAsk:\n \"Block this player? You stop seeing each other, and the friendship ends.\",\n blocked: \"Blocked\",\n platformFriend: \"Friend on idosgames.com\",\n level: \"Level\",\n power: \"Power\",\n someone: \"Someone\",\n and: \"and\",\n more: \"more\",\n vAttack: \"attacked you\",\n vRaid: \"raided you\",\n vFriend: \"is now your friend\",\n vSale: \"bought your creation\",\n vFollow: \"followed you\",\n vLike: \"liked your creation\",\n vComment: \"commented\",\n vReply: \"replied to you\",\n vOther: \"did something\",\n} as const;\n\nexport const t = makeT<keyof typeof EN>(EN, {\n title: \"Друзья\",\n friends: \"Друзья\",\n requests: \"Заявки\",\n suggested: \"Рекомендуем\",\n inbox: \"Новости\",\n noFriends:\n \"Друзей пока нет — добавьте игрока по ID или выберите из рекомендаций.\",\n noRequests: \"Заявок нет.\",\n noSuggestions: \"Рекомендаций пока нет — загляните позже.\",\n noInbox: \"Пока ничего нового.\",\n incoming: \"Хотят дружить\",\n outgoing: \"Ждут ответа\",\n accept: \"Принять\",\n decline: \"Отклонить\",\n add: \"Добавить\",\n addFriend: \"Добавить друга\",\n sent: \"Заявка отправлена\",\n playerID: \"ID игрока\",\n yourID: \"Ваш ID\",\n copied: \"Скопировано\",\n send: \"Отправить заявку\",\n badID: \"Введите ID другого игрока\",\n message: \"Написать\",\n remove: \"Удалить из друзей\",\n removeAsk: \"Удалить игрока из друзей?\",\n block: \"Заблокировать\",\n blockAsk:\n \"Заблокировать игрока? Вы перестанете видеть друг друга, дружба закончится.\",\n blocked: \"Заблокирован\",\n platformFriend: \"Друг на idosgames.com\",\n level: \"Уровень\",\n power: \"Сила\",\n someone: \"Кто-то\",\n and: \"и\",\n more: \"ещё\",\n vAttack: \"напал на вас\",\n vRaid: \"совершил набег\",\n vFriend: \"теперь ваш друг\",\n vSale: \"купил ваше творение\",\n vFollow: \"подписался на вас\",\n vLike: \"оценил ваше творение\",\n vComment: \"оставил комментарий\",\n vReply: \"ответил вам\",\n vOther: \"что-то сделал\",\n});\n"
|
|
51
|
+
"content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"Friends\",\n friends: \"Friends\",\n requests: \"Requests\",\n suggested: \"Suggested\",\n inbox: \"News\",\n noFriends: \"No friends yet — add someone by ID or pick from the suggestions.\",\n noRequests: \"No requests right now.\",\n noSuggestions: \"No suggestions right now — come back later.\",\n noInbox: \"Nothing new yet.\",\n incoming: \"Want to be friends\",\n outgoing: \"Waiting for an answer\",\n accept: \"Accept\",\n decline: \"Decline\",\n add: \"Add\",\n addFriend: \"Add a friend\",\n sent: \"Request sent\",\n playerID: \"Player ID\",\n yourID: \"Your ID\",\n copied: \"Copied\",\n send: \"Send request\",\n badID: \"Enter another player's ID\",\n message: \"Message\",\n gift: \"Send a gift\",\n remove: \"Remove friend\",\n removeAsk: \"Remove this player from your friends?\",\n block: \"Block\",\n blockAsk:\n \"Block this player? You stop seeing each other, and the friendship ends.\",\n blocked: \"Blocked\",\n platformFriend: \"Friend on idosgames.com\",\n level: \"Level\",\n power: \"Power\",\n someone: \"Someone\",\n and: \"and\",\n more: \"more\",\n vAttack: \"attacked you\",\n vRaid: \"raided you\",\n vFriend: \"is now your friend\",\n vSale: \"bought your creation\",\n vFollow: \"followed you\",\n vLike: \"liked your creation\",\n vComment: \"commented\",\n vReply: \"replied to you\",\n vOther: \"did something\",\n} as const;\n\nexport const t = makeT<keyof typeof EN>(EN, {\n title: \"Друзья\",\n friends: \"Друзья\",\n requests: \"Заявки\",\n suggested: \"Рекомендуем\",\n inbox: \"Новости\",\n noFriends:\n \"Друзей пока нет — добавьте игрока по ID или выберите из рекомендаций.\",\n noRequests: \"Заявок нет.\",\n noSuggestions: \"Рекомендаций пока нет — загляните позже.\",\n noInbox: \"Пока ничего нового.\",\n incoming: \"Хотят дружить\",\n outgoing: \"Ждут ответа\",\n accept: \"Принять\",\n decline: \"Отклонить\",\n add: \"Добавить\",\n addFriend: \"Добавить друга\",\n sent: \"Заявка отправлена\",\n playerID: \"ID игрока\",\n yourID: \"Ваш ID\",\n copied: \"Скопировано\",\n send: \"Отправить заявку\",\n badID: \"Введите ID другого игрока\",\n message: \"Написать\",\n gift: \"Подарить\",\n remove: \"Удалить из друзей\",\n removeAsk: \"Удалить игрока из друзей?\",\n block: \"Заблокировать\",\n blockAsk:\n \"Заблокировать игрока? Вы перестанете видеть друг друга, дружба закончится.\",\n blocked: \"Заблокирован\",\n platformFriend: \"Друг на idosgames.com\",\n level: \"Уровень\",\n power: \"Сила\",\n someone: \"Кто-то\",\n and: \"и\",\n more: \"ещё\",\n vAttack: \"напал на вас\",\n vRaid: \"совершил набег\",\n vFriend: \"теперь ваш друг\",\n vSale: \"купил ваше творение\",\n vFollow: \"подписался на вас\",\n vLike: \"оценил ваше творение\",\n vComment: \"оставил комментарий\",\n vReply: \"ответил вам\",\n vOther: \"что-то сделал\",\n});\n"
|
|
51
52
|
},
|
|
52
53
|
{
|
|
53
54
|
"path": "index.ts",
|
|
54
|
-
"content": "// @idosgames/mod-friends
|
|
55
|
-
},
|
|
56
|
-
{
|
|
57
|
-
"path": "model.test.ts",
|
|
58
|
-
"content": "import { describe, expect, it } from \"vitest\";\nimport {\n actorsText,\n canRemove,\n cleanUserID,\n displayName,\n friendsBadge,\n inboxKind,\n initial,\n sortByName,\n} from \"./model\";\n\ndescribe(\"friends\", () => {\n it(\"names a player by username, else by a short id\", () => {\n expect(\n displayName({ UserID: \"U1\", PublicData: { Username: \" Ann \" } }),\n ).toBe(\"Ann\");\n expect(displayName({ UserID: \"ABCDEFGHIJKLMNOP\" })).toBe(\"ABCDE…MNOP\");\n expect(initial({ UserID: \"x\", PublicData: { Username: \"bob\" } })).toBe(\"B\");\n expect(\n sortByName([\n { UserID: \"2\", PublicData: { Username: \"Zed\" } },\n { UserID: \"1\", PublicData: { Username: \"Amy\" } },\n ]).map((p) => p.UserID),\n ).toEqual([\"1\", \"2\"]);\n });\n\n it(\"a platform friend is removed on the site, not in the game\", () => {\n expect(canRemove({ UserID: \"a\", Source: \"Title\" })).toBe(true);\n expect(canRemove({ UserID: \"a\", Source: \"Platform\" })).toBe(false);\n });\n\n it(\"an id typed by hand is one token and not my own\", () => {\n expect(cleanUserID(\" ABC123 \", \"ME\")).toBe(\"ABC123\");\n expect(cleanUserID(\"ME\", \"ME\")).toBeNull();\n expect(cleanUserID(\"A B\", \"ME\")).toBeNull();\n expect(cleanUserID(\" \", \"ME\")).toBeNull();\n });\n\n it(\"the badge is requests waiting plus unread news\", () => {\n expect(friendsBadge({ IncomingCount: 2 }, 3)).toBe(5);\n expect(friendsBadge(null, 0)).toBe(0);\n });\n\n it(\"reads the open-ended verbs and aggregates actors\", () => {\n expect(inboxKind(\"FriendAdded\")).toBe(\"friend\");\n expect(inboxKind(\"SomethingNew\")).toBe(\"other\");\n expect(actorsText([{ DisplayName: \"Ann\" }], 1, \"?\", \"and\", \"more\")).toBe(\n \"Ann\",\n );\n expect(\n actorsText(\n [{ DisplayName: \"Ann\" }, { DisplayName: \"Bob\" }],\n 2,\n \"?\",\n \"and\",\n \"more\",\n ),\n ).toBe(\"Ann and Bob\");\n expect(\n actorsText(\n [{ DisplayName: \"Ann\" }, { DisplayName: \"\" }],\n 42,\n \"Someone\",\n \"and\",\n \"more\",\n ),\n ).toBe(\"Ann and 41 more\");\n expect(actorsText([], 0, \"Someone\", \"and\", \"more\")).toBe(\"Someone\");\n });\n});\n"
|
|
55
|
+
"content": "// @idosgames/mod-friends — Friends for any game: friends, requests, suggestions and the notification inbox, with a player card in a popup.\n\nexport { friendsModule } from \"./module\";\n"
|
|
59
56
|
},
|
|
60
57
|
{
|
|
61
58
|
"path": "model.ts",
|
|
@@ -63,11 +60,11 @@
|
|
|
63
60
|
},
|
|
64
61
|
{
|
|
65
62
|
"path": "module.ts",
|
|
66
|
-
"content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { makeFriendsScreen, makeFriendsWatcher } from \"./FriendsScreen\";\nimport { t } from \"./i18n\";\n\n// Friends as a feature of the game. The always-on watcher keeps the badge: requests waiting for an\n// answer (a counter that comes with the player state) and unread notifications.\nexport const
|
|
63
|
+
"content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { makeFriendsScreen, makeFriendsWatcher } from \"./FriendsScreen\";\nimport { t } from \"./i18n\";\n\n// Friends as a feature of the game. The always-on watcher keeps the badge: requests waiting for an\n// answer (a counter that comes with the player state) and unread notifications.\nexport const friendsModule: Module = defineModule({\n id: \"friends\",\n meta: { name: \"Friends\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"friends\",\n label: t(\"title\"),\n icon: \"friends\",\n group: \"social\",\n order: 80,\n Screen: makeFriendsScreen(ctx.features),\n });\n ctx.registerPanel({\n id: \"badge\",\n slot: \"modal\",\n activeOnly: false,\n component: makeFriendsWatcher(ctx.features),\n });\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"friends\" }),\n describeActions: {},\n });\n },\n});\n"
|
|
67
64
|
},
|
|
68
65
|
{
|
|
69
66
|
"path": "module.meta.json",
|
|
70
|
-
"content": "{\n \"id\": \"friends
|
|
67
|
+
"content": "{\n \"id\": \"friends\",\n \"type\": \"feature\",\n \"inBase\": true,\n \"summary\": \"Friends for any game: the friends list, requests, suggestions and the notification inbox, with a player card and add-by-ID in popups.\",\n \"description\": \"A ready friends screen (Unity Runtime/UI/Friends) for ANY game, built on the @idosgames/react/ui kit and client.social. Tabs: friends, requests (incoming with accept / decline, and the ones you sent), suggestions (players active in this game lately) and news — the notification inbox (attacks, raids, new friends, sales, follows, likes, comments), opened as seen. The player's own ID sits on top with a copy button. Everything detailed is a popup: adding a friend by ID, the player card (level, power; message — opens the chat feature when chat is installed; remove friend; block) and the confirmations. When the title takes friends from idosgames.com only, requests, suggestions and 'Add' disappear and platform friends cannot be removed in the game. Registers the 'friends' feature; its badge counts requests waiting and unread news. No title settings needed.\",\n \"provides\": [\n \"friends list\",\n \"incoming and sent friend requests\",\n \"friend suggestions\",\n \"notification inbox\",\n \"add a friend by ID\",\n \"player card with message, remove and block\",\n \"friends badge\"\n ],\n \"tags\": [\n \"friends\",\n \"social\",\n \"requests\",\n \"inbox\",\n \"notifications\",\n \"ui\",\n \"screen\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.0\"\n}\n"
|
|
71
68
|
}
|
|
72
69
|
]
|
|
73
70
|
}
|
|
@@ -49,21 +49,21 @@
|
|
|
49
49
|
}
|
|
50
50
|
},
|
|
51
51
|
"dependencies": {
|
|
52
|
-
"@idosgames/core": "0.19.
|
|
53
|
-
"@idosgames/module-sdk": "0.
|
|
52
|
+
"@idosgames/core": "0.19.1",
|
|
53
|
+
"@idosgames/module-sdk": "0.7.0",
|
|
54
54
|
"@idosgames/react": "0.3.0",
|
|
55
|
-
"@idosgames/wallet": "0.
|
|
55
|
+
"@idosgames/wallet": "0.6.0",
|
|
56
56
|
"@tanstack/react-query": "5.101.2",
|
|
57
57
|
"phaser": "4.2.1",
|
|
58
58
|
"react": "19.2.7",
|
|
59
59
|
"viem": "2.55.2",
|
|
60
60
|
"wagmi": "3.7.2"
|
|
61
61
|
},
|
|
62
|
-
"contentHash": "
|
|
62
|
+
"contentHash": "41b86a0971beeab985e44ed77e481ce38c29df0c2fe01e34fd50159d64646e97",
|
|
63
63
|
"files": [
|
|
64
64
|
{
|
|
65
65
|
"path": "chrome.ts",
|
|
66
|
-
"content": "// Which shared chrome the Idle RPG dock draws itself.
|
|
66
|
+
"content": "// Which shared chrome the Idle RPG dock draws itself. Next to the base (its lobby draws\n// the balances and the wallet) the dock hides its currency bar and wallet — `setup()` asks\n// `ctx.sharedUi.shouldDraw(role)` once and closes over the answer. The roster, character detail and\n// equipment are this module's own UI and always stay.\n\nexport interface IdleChrome {\n /** The wallet panel in the detail column (role \"wallet\"). */\n wallet: boolean;\n /** The currency bar at the top of the dock (role \"currency-bar\"). */\n balances: boolean;\n /** The status line at the bottom of the dock (role \"status\"). */\n status: boolean;\n}\n\n/** Everything drawn — Idle RPG installed alone. */\nexport const FULL_CHROME: IdleChrome = {\n wallet: true,\n balances: true,\n status: true,\n};\n"
|
|
67
67
|
},
|
|
68
68
|
{
|
|
69
69
|
"path": "components/CharacterDetail.tsx",
|
|
@@ -99,7 +99,7 @@
|
|
|
99
99
|
},
|
|
100
100
|
{
|
|
101
101
|
"path": "env.ts",
|
|
102
|
-
"content": "//
|
|
102
|
+
"content": "// Build-time config the module reads the standard Vite way: VITE_* variables of the project's\n// .env.local via `import.meta.env`. Unset (as in the platform's editor), the default applies. Read\n// through a cast: a module is copied into projects that may not carry Vite's client types.\n//\n// The TITLE is deliberately NOT here. It comes from the host — `client.titleID`, or `ctx.titleId`\n// in a module's setup(). A module that re-derived the title from the URL could disagree with its\n// host, which is how a build ends up reading one title's data and writing another's.\n\nconst env: Record<string, string | undefined> =\n (import.meta as unknown as { env?: Record<string, string | undefined> })\n .env ?? {};\n\nexport const ENV_WALLETCONNECT_PROJECT_ID: string =\n env.VITE_WALLETCONNECT_PROJECT_ID ?? \"\";\n"
|
|
103
103
|
},
|
|
104
104
|
{
|
|
105
105
|
"path": "events.ts",
|
|
@@ -115,7 +115,7 @@
|
|
|
115
115
|
},
|
|
116
116
|
{
|
|
117
117
|
"path": "module.ts",
|
|
118
|
-
"content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { IdleRpgController } from \"./game/IdleRpgController\";\nimport { createControllerBox } from \"./controller-box\";\nimport { createIdleRpgScene } from \"./scene\";\nimport { makeIdleRpgRootPanel } from \"./RootPanel\";\nimport type { IdleChrome } from \"./chrome\";\nimport { characterUpgraded } from \"./events\";\n\n// The Idle RPG feature module. `setup` is called once by the host: it wires a Phaser scene and the\n// module's UI panel to one shared controller, and registers a nav entry so the player can enter this\n// mode. No app shell, no client creation, no login — the host owns all of that.\nexport const idleRpgModule: Module = defineModule({\n id: \"idle-rpg\",\n meta: {\n name: \"Idle RPG\",\n type: \"game\",\n genre: \"idle-rpg\",\n engine: \"phaser\",\n },\n setup(ctx) {\n const box = createControllerBox<IdleRpgController>();\n\n // Shared chrome: draw our own copy only while no other module (
|
|
118
|
+
"content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { IdleRpgController } from \"./game/IdleRpgController\";\nimport { createControllerBox } from \"./controller-box\";\nimport { createIdleRpgScene } from \"./scene\";\nimport { makeIdleRpgRootPanel } from \"./RootPanel\";\nimport type { IdleChrome } from \"./chrome\";\nimport { characterUpgraded } from \"./events\";\n\n// The Idle RPG feature module. `setup` is called once by the host: it wires a Phaser scene and the\n// module's UI panel to one shared controller, and registers a nav entry so the player can enter this\n// mode. No app shell, no client creation, no login — the host owns all of that.\nexport const idleRpgModule: Module = defineModule({\n id: \"idle-rpg\",\n meta: {\n name: \"Idle RPG\",\n type: \"game\",\n genre: \"idle-rpg\",\n engine: \"phaser\",\n },\n setup(ctx) {\n const box = createControllerBox<IdleRpgController>();\n\n // Shared chrome: draw our own copy only while no other module (the base: the lobby draws them) took the role.\n const chrome: IdleChrome = {\n wallet: ctx.sharedUi.shouldDraw(\"wallet\"),\n balances: ctx.sharedUi.shouldDraw(\"currency-bar\"),\n status: ctx.sharedUi.shouldDraw(\"status\"),\n };\n\n ctx.registerScene(createIdleRpgScene(box));\n ctx.registerPanel({\n id: \"root\",\n slot: \"overlay\",\n component: makeIdleRpgRootPanel(box, {\n chrome,\n // A signal for other modules (declared in module.meta.json → events.emits). The panel gets a\n // plain callback, so UI code never touches the bus directly.\n onCharacterUpgraded: (characterId, level) =>\n ctx.events.emit(characterUpgraded, { characterId, level }),\n }),\n });\n ctx.registerRoute({ id: \"idle-rpg\", label: \"Idle RPG\", icon: \"⚔️\" });\n\n // Тонкая поверхность для агента — по той же причине, что и в board-game: интерфейс модуля\n // живёт в DOM, агент читает и нажимает его сам. Из Phaser'а ему не видно только то, что\n // рисует сцена.\n ctx.exposeToAgent({\n state: () => {\n const controller = box.get();\n return controller\n ? {\n mounted: true,\n scene: controller.describeScene(),\n ui: \"dom\",\n chrome,\n }\n : { mounted: false, chrome };\n },\n describeActions: {},\n });\n },\n});\n"
|
|
119
119
|
},
|
|
120
120
|
{
|
|
121
121
|
"path": "phaser/IdleRpgScene.ts",
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "inventory",
|
|
3
|
+
"meta": {
|
|
4
|
+
"name": "Inventory",
|
|
5
|
+
"type": "app",
|
|
6
|
+
"engine": "dom"
|
|
7
|
+
},
|
|
8
|
+
"catalog": {
|
|
9
|
+
"type": "feature",
|
|
10
|
+
"inBase": true,
|
|
11
|
+
"summary": "Inventory screen for any game: owned items by rarity with category tabs and item cards.",
|
|
12
|
+
"description": "A ready inventory for ANY game, built on the @idosgames/react/ui kit. It shows what the player owns from the SDK's cached inventory (no request of its own): category tabs (equipment, consumables, other), rarity frames, counts and levels, and an item card popup with the description. Equipment hands off to the heroes screen (ctx.features.open('character', { itemInstanceID })) and a chest or key item to the chests screen (ctx.features.open('lootboxes', { itemID })) when those systems are installed. Registers the 'inventory' feature — always shown in the lobby, even before the title has items.",
|
|
13
|
+
"provides": [
|
|
14
|
+
"owned items grid by rarity",
|
|
15
|
+
"category tabs",
|
|
16
|
+
"item card popup",
|
|
17
|
+
"hand-off of equipment to the heroes screen",
|
|
18
|
+
"hand-off of chests and keys to the chests screen"
|
|
19
|
+
],
|
|
20
|
+
"tags": [
|
|
21
|
+
"inventory",
|
|
22
|
+
"items",
|
|
23
|
+
"rarity",
|
|
24
|
+
"ui",
|
|
25
|
+
"screen",
|
|
26
|
+
"economy"
|
|
27
|
+
],
|
|
28
|
+
"author": {
|
|
29
|
+
"name": "iDos Games",
|
|
30
|
+
"url": "https://idosgames.com"
|
|
31
|
+
},
|
|
32
|
+
"version": "0.1.0"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@idosgames/core": "0.19.1",
|
|
36
|
+
"@idosgames/module-sdk": "0.7.0",
|
|
37
|
+
"@idosgames/react": "0.3.0",
|
|
38
|
+
"react": "19.2.7"
|
|
39
|
+
},
|
|
40
|
+
"contentHash": "da42d93b3d21d21109e0f35338c349042dbb01ef442701439dc1d9d4ddbff98d",
|
|
41
|
+
"files": [
|
|
42
|
+
{
|
|
43
|
+
"path": "i18n.ts",
|
|
44
|
+
"content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"Inventory\",\n all: \"All\",\n equipment: \"Equipment\",\n consumable: \"Consumables\",\n other: \"Other\",\n empty: \"Nothing here yet — buy something in the shop.\",\n noItems: \"Items are not configured. Add them in LiveOps → Items.\",\n rarity: \"Rarity\",\n quantity: \"Quantity\",\n level: \"Level\",\n equip: \"Equip\",\n open: \"Open\",\n toShop: \"To the shop\",\n} as const;\n\nexport const t = makeT<keyof typeof EN>(EN, {\n title: \"Инвентарь\",\n all: \"Все\",\n equipment: \"Снаряжение\",\n consumable: \"Расходники\",\n other: \"Разное\",\n empty: \"Пока пусто — купите что-нибудь в магазине.\",\n noItems: \"Предметы не настроены. Добавьте их в LiveOps → Items.\",\n rarity: \"Редкость\",\n quantity: \"Количество\",\n level: \"Уровень\",\n equip: \"Надеть\",\n open: \"Открыть\",\n toShop: \"В магазин\",\n});\n"
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"path": "index.ts",
|
|
48
|
+
"content": "// @idosgames/mod-inventory — Inventory screen for any game: the player's items by rarity with filters, an item card, and hand-offs of equipment to the heroes screen and of chests to the chests screen.\n\nexport { inventoryModule } from \"./module\";\n"
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"path": "InventoryScreen.tsx",
|
|
52
|
+
"content": "import { useEffect, useState, type ReactNode } from \"react\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n EmptyState,\n Icon,\n ItemTile,\n Popup,\n Tabs,\n outlined,\n useCatalog,\n useGameLayout,\n usePopupClose,\n useStagger,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n categoryOf,\n isChestOrKey,\n ownedItems,\n type Category,\n type InventoryView,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// What the player owns, from the SDK's cached inventory (every purchase and claim updates it — no\n// request of its own). Tabs by category, rarest first; a tap opens the item card. Equipment hands off\n// to the heroes screen, a chest or a key to the chests screen (the lootboxes system).\n\nexport function makeInventoryScreen(features: FeatureRegistry) {\n return function InventoryScreen(_: FeatureScreenProps): ReactNode {\n return <Inventory features={features} />;\n };\n}\n\nfunction Inventory({ features }: { features: FeatureRegistry }): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const layout = useGameLayout();\n const stagger = useStagger();\n const [tab, setTab] = useState<Category>(\"all\");\n const [open, setOpen] = useState<string | null>(null);\n\n // Instances of unstackable items (levels, equipped) come with the full inventory read only.\n useEffect(() => {\n void client.user.getUserInventory();\n }, [client]);\n\n const inventory = state?.InventoryV2 as InventoryView | undefined;\n const owned = ownedItems(inventory, catalog.items);\n const shown =\n tab === \"all\"\n ? owned\n : owned.filter((o) => categoryOf(catalog.items.get(o.itemID)) === tab);\n const tile = layout === \"desktop\" ? 100 : layout === \"tablet\" ? 90 : 76;\n\n const tabs: Array<{ id: Category; label: string }> = [\n { id: \"all\", label: t(\"all\") },\n { id: \"equipment\", label: t(\"equipment\") },\n { id: \"consumable\", label: t(\"consumable\") },\n { id: \"other\", label: t(\"other\") },\n ];\n\n return (\n <div style={{ display: \"grid\", gap: 14 }}>\n <Tabs tabs={tabs} value={tab} onChange={setTab} />\n {catalog.items.size === 0 ? (\n <EmptyState glyph=\"box\" text={t(\"noItems\")} />\n ) : shown.length === 0 ? (\n <EmptyState\n glyph=\"chest\"\n text={t(\"empty\")}\n action={\n features.get(\"store\") ? (\n <Button onClick={() => features.open(\"store\")}>\n {t(\"toShop\")}\n </Button>\n ) : undefined\n }\n />\n ) : (\n <div\n data-resource-counter=\"inventory\"\n style={{\n display: \"grid\",\n gridTemplateColumns: `repeat(auto-fill, minmax(${tile + 8}px, 1fr))`,\n gap: layout === \"phone\" ? 12 : 16,\n justifyItems: \"center\",\n }}\n >\n {shown.map((o, i) => (\n <div\n key={o.itemID}\n data-resource-counter={`item:${o.itemID}`}\n style={stagger(i)}\n >\n <ItemTile\n itemID={o.itemID}\n count={o.count}\n level={o.instances[0]?.level}\n size={tile}\n onClick={() => setOpen(o.itemID)}\n />\n </div>\n ))}\n </div>\n )}\n {open ? (\n <ItemCard\n itemID={open}\n owned={owned.find((o) => o.itemID === open)}\n canOpenChest={\n features.get(\"lootboxes\")?.available === true &&\n isChestOrKey(catalog.items.get(open))\n }\n canEquip={\n features.get(\"character\") !== undefined &&\n categoryOf(catalog.items.get(open)) === \"equipment\"\n }\n onEquip={(instanceID) =>\n features.open(\"character\", {\n itemID: open,\n itemInstanceID: instanceID,\n })\n }\n onOpenChest={() => features.open(\"lootboxes\", { itemID: open })}\n onClose={() => setOpen(null)}\n />\n ) : null}\n </div>\n );\n}\n\nfunction ItemCard({\n itemID,\n owned,\n canOpenChest,\n canEquip,\n onEquip,\n onOpenChest,\n onClose,\n}: {\n itemID: string;\n owned: ReturnType<typeof ownedItems>[number] | undefined;\n canOpenChest: boolean;\n canEquip: boolean;\n onEquip: (instanceID: string | undefined) => void;\n onOpenChest: () => void;\n onClose: () => void;\n}): ReactNode {\n const catalog = useCatalog();\n const def = catalog.items.get(itemID);\n const rarity = def?.Metadata?.RarityID ?? \"Common\";\n const color = catalog.rarityColor(rarity);\n const description = catalog.localize(def?.Description);\n return (\n <Popup title={catalog.itemName(itemID)} onClose={onClose}>\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 10 }}>\n <div\n className=\"idos-glow\"\n style={{\n width: 120,\n height: 120,\n borderRadius: 26,\n display: \"grid\",\n placeItems: \"center\",\n border: `4px solid ${color}`,\n background: `radial-gradient(circle at 50% 35%, ${color}66 0%, ${v.panelDeep} 72%)`,\n }}\n >\n <Icon\n glyph={catalog.iconOf({ kind: \"item\", id: itemID, amount: 1 })}\n size={78}\n />\n </div>\n <div style={{ ...outlined, color, fontSize: 15 }}>\n {t(\"rarity\")}: {rarity}\n </div>\n {description ? (\n <div\n style={{\n ...outlined,\n fontWeight: 600,\n color: v.textDim,\n fontSize: 14,\n textAlign: \"center\",\n }}\n >\n {description}\n </div>\n ) : null}\n <div style={{ ...outlined, fontSize: 15 }}>\n {t(\"quantity\")}: {owned?.count ?? 0}\n {owned?.instances[0] && owned.instances[0].level > 0\n ? ` · ${t(\"level\")} ${owned.instances[0].level}`\n : \"\"}\n </div>\n </div>\n {canEquip ? (\n <CloseThen onClick={() => onEquip(owned?.instances[0]?.id)} tone=\"blue\">\n {t(\"equip\")}\n </CloseThen>\n ) : null}\n {canOpenChest ? (\n <CloseThen onClick={onOpenChest} tone=\"green\">\n {t(\"open\")}\n </CloseThen>\n ) : null}\n </Popup>\n );\n}\n\nfunction CloseThen({\n onClick,\n tone,\n children,\n}: {\n onClick: () => void;\n tone: \"blue\" | \"green\";\n children: ReactNode;\n}): ReactNode {\n const close = usePopupClose();\n return (\n <Button\n tone={tone}\n onClick={() => {\n close();\n onClick();\n }}\n >\n {children}\n </Button>\n );\n}\n"
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"path": "model.ts",
|
|
56
|
+
"content": "// Pure helpers of the inventory screen — covered by model.test.ts.\n\nimport { rarityRank, type ItemDefinitionView } from \"@idosgames/react/ui\";\n\nexport interface InventoryView {\n Items?: Record<\n string,\n {\n StackableAmount?: number;\n UnstackableAmount?: number;\n TotalAmount?: number;\n } | null\n > | null;\n UnstackableItems?: Record<\n string,\n { ItemID?: string; Level?: number | null } | null\n > | null;\n VirtualCurrencies?: Record<string, { Amount?: number } | null> | null;\n}\n\nexport interface OwnedItem {\n itemID: string;\n count: number;\n /** Instance ids of an unstackable item (equipment), best level first. */\n instances: Array<{ id: string; level: number }>;\n}\n\n/**\n * What the player owns: stackable counts plus the instances of unstackable items. Sorted rarest\n * first, then by id — a stable order, so the grid does not reshuffle when a count changes.\n */\nexport function ownedItems(\n inventory: InventoryView | null | undefined,\n definitions: Map<string, ItemDefinitionView>,\n): OwnedItem[] {\n const owned = new Map<string, OwnedItem>();\n const get = (id: string): OwnedItem => {\n let o = owned.get(id);\n if (!o) owned.set(id, (o = { itemID: id, count: 0, instances: [] }));\n return o;\n };\n for (const [id, totals] of Object.entries(inventory?.Items ?? {})) {\n const total = Number(\n totals?.TotalAmount ??\n (totals?.StackableAmount ?? 0) + (totals?.UnstackableAmount ?? 0),\n );\n if (total > 0) get(id).count = total;\n }\n for (const [instanceID, instance] of Object.entries(\n inventory?.UnstackableItems ?? {},\n )) {\n if (!instance?.ItemID) continue;\n get(instance.ItemID).instances.push({\n id: instanceID,\n level: Number(instance.Level ?? 0),\n });\n }\n for (const o of owned.values()) {\n if (o.count < o.instances.length) o.count = o.instances.length;\n o.instances.sort((a, b) => b.level - a.level || a.id.localeCompare(b.id));\n }\n return [...owned.values()]\n .filter((o) => o.count > 0)\n .sort((a, b) => {\n const r =\n rarityRank(definitions.get(a.itemID)?.Metadata?.RarityID) -\n rarityRank(definitions.get(b.itemID)?.Metadata?.RarityID);\n return r !== 0 ? r : a.itemID.localeCompare(b.itemID);\n });\n}\n\nexport type Category = \"all\" | \"equipment\" | \"consumable\" | \"other\";\n\nconst EQUIPMENT = [\n \"weapon\",\n \"sword\",\n \"armor\",\n \"shield\",\n \"helmet\",\n \"equipment\",\n \"gear\",\n \"ring\",\n \"boots\",\n \"amulet\",\n];\nconst CONSUMABLE = [\n \"potion\",\n \"scroll\",\n \"consumable\",\n \"food\",\n \"booster\",\n \"key\",\n \"chest\",\n \"lootbox\",\n];\n\n/** Category of an item by its class and tags (equipment = unstackable gear). */\nexport function categoryOf(\n def: ItemDefinitionView | undefined,\n): Exclude<Category, \"all\"> {\n const words = [def?.ItemClass ?? \"\", ...(def?.Tags ?? [])].map((w) =>\n w.toLowerCase(),\n );\n if (words.some((w) => EQUIPMENT.includes(w))) return \"equipment\";\n if (words.some((w) => CONSUMABLE.includes(w))) return \"consumable\";\n if (def?.IsStackable === false) return \"equipment\";\n return \"other\";\n}\n\n/** A chest or a key: an item the chests screen (the lootboxes system) opens. */\nexport function isChestOrKey(def: ItemDefinitionView | undefined): boolean {\n return [def?.ItemClass ?? \"\", ...(def?.Tags ?? [])].some((w) =>\n [\"chest\", \"lootbox\", \"key\"].includes(w.toLowerCase()),\n );\n}\n"
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"path": "module.ts",
|
|
60
|
+
"content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { makeInventoryScreen } from \"./InventoryScreen\";\nimport { t } from \"./i18n\";\n\n// The inventory as a feature of the game. Always in the lobby (owner's decision 29.09.2026), even on a\n// title without items yet — the screen then says so. Chests and keys hand off to the lootboxes system.\nexport const inventoryModule: Module = defineModule({\n id: \"inventory\",\n meta: { name: \"Inventory\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"inventory\",\n label: t(\"title\"),\n icon: \"box\",\n group: \"economy\",\n order: 50,\n primary: true,\n Screen: makeInventoryScreen(ctx.features),\n });\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"inventory\" }),\n describeActions: {},\n });\n },\n});\n"
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"path": "module.meta.json",
|
|
64
|
+
"content": "{\n \"id\": \"inventory\",\n \"type\": \"feature\",\n \"inBase\": true,\n \"summary\": \"Inventory screen for any game: owned items by rarity with category tabs and item cards.\",\n \"description\": \"A ready inventory for ANY game, built on the @idosgames/react/ui kit. It shows what the player owns from the SDK's cached inventory (no request of its own): category tabs (equipment, consumables, other), rarity frames, counts and levels, and an item card popup with the description. Equipment hands off to the heroes screen (ctx.features.open('character', { itemInstanceID })) and a chest or key item to the chests screen (ctx.features.open('lootboxes', { itemID })) when those systems are installed. Registers the 'inventory' feature — always shown in the lobby, even before the title has items.\",\n \"provides\": [\n \"owned items grid by rarity\",\n \"category tabs\",\n \"item card popup\",\n \"hand-off of equipment to the heroes screen\",\n \"hand-off of chests and keys to the chests screen\"\n ],\n \"tags\": [\"inventory\", \"items\", \"rarity\", \"ui\", \"screen\", \"economy\"],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.0\"\n}\n"
|
|
65
|
+
}
|
|
66
|
+
]
|
|
67
|
+
}
|