@idosgames/mcp 0.1.18 → 0.1.19
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 +360 -412
- package/registry/modules/board-game.json +7 -7
- package/registry/modules/{character-ui.json → character.json} +8 -11
- package/registry/modules/chat.json +71 -0
- package/registry/modules/{craft-ui.json → craft.json} +8 -11
- package/registry/modules/{deal-offer-ui.json → deals.json} +7 -10
- package/registry/modules/{events-ui.json → events.json} +8 -11
- package/registry/modules/{friends-ui.json → friends.json} +8 -11
- package/registry/modules/idle-rpg.json +6 -6
- package/registry/modules/{inventory-ui.json → inventory.json} +9 -12
- package/registry/modules/{referral-ui.json → invite.json} +8 -11
- package/registry/modules/{leaderboard-ui.json → leaderboard.json} +8 -11
- package/registry/modules/marketplace.json +72 -0
- package/registry/modules/mini-game.json +69 -0
- package/registry/modules/{profile-ui.json → profile.json} +8 -11
- package/registry/modules/{pvp-ui.json → pvp.json} +9 -12
- package/registry/modules/{rewards-ui.json → rewards.json} +8 -11
- package/registry/modules/{season-ui.json → season.json} +8 -11
- package/registry/modules/{store-ui.json → store.json} +9 -12
- package/registry/modules/{tutorial-ui.json → tutorials.json} +8 -11
- package/registry/modules/voxelcraft.json +2 -446
- package/registry/modules/workshop.json +11 -6
- 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/push-notifications.json +1 -1
- package/registry/skills/workshop-system.json +1 -1
- package/registry/modules/chat-ui.json +0 -74
- package/registry/modules/game-hud.json +0 -104
- package/registry/modules/marketplace-ui.json +0 -75
- 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",
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
},
|
|
8
8
|
"catalog": {
|
|
9
9
|
"type": "feature",
|
|
10
|
+
"inBase": true,
|
|
10
11
|
"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.",
|
|
11
12
|
"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.",
|
|
12
13
|
"provides": [
|
|
@@ -35,11 +36,11 @@
|
|
|
35
36
|
},
|
|
36
37
|
"dependencies": {
|
|
37
38
|
"@idosgames/core": "0.19.0",
|
|
38
|
-
"@idosgames/module-sdk": "0.
|
|
39
|
+
"@idosgames/module-sdk": "0.6.0",
|
|
39
40
|
"@idosgames/react": "0.3.0",
|
|
40
41
|
"react": "19.2.7"
|
|
41
42
|
},
|
|
42
|
-
"contentHash": "
|
|
43
|
+
"contentHash": "cc3d5ab811dc1299d64a4bcd4361d097ce776fcfd20949ddac015271111beed0",
|
|
43
44
|
"files": [
|
|
44
45
|
{
|
|
45
46
|
"path": "DealsScreen.tsx",
|
|
@@ -51,11 +52,7 @@
|
|
|
51
52
|
},
|
|
52
53
|
{
|
|
53
54
|
"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"
|
|
55
|
+
"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
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 {\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
|
|
63
|
+
"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
64
|
},
|
|
68
65
|
{
|
|
69
66
|
"path": "module.meta.json",
|
|
70
|
-
"content": "{\n \"id\": \"
|
|
67
|
+
"content": "{\n \"id\": \"deals\",\n \"type\": \"feature\",\n \"inBase\": true,\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
68
|
}
|
|
72
69
|
]
|
|
73
70
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"id": "events
|
|
2
|
+
"id": "events",
|
|
3
3
|
"meta": {
|
|
4
4
|
"name": "Events",
|
|
5
5
|
"type": "app",
|
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
},
|
|
8
8
|
"catalog": {
|
|
9
9
|
"type": "feature",
|
|
10
|
+
"inBase": true,
|
|
10
11
|
"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) and the VIP pass (its benefits and prices in currencies) are popups. 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
|
|
12
|
+
"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) and the VIP pass (its benefits and prices in currencies) are popups. 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
13
|
"provides": [
|
|
13
14
|
"events screen with a tab per active event",
|
|
14
15
|
"event timer and token progress bar",
|
|
@@ -38,11 +39,11 @@
|
|
|
38
39
|
},
|
|
39
40
|
"dependencies": {
|
|
40
41
|
"@idosgames/core": "0.19.0",
|
|
41
|
-
"@idosgames/module-sdk": "0.
|
|
42
|
+
"@idosgames/module-sdk": "0.6.0",
|
|
42
43
|
"@idosgames/react": "0.3.0",
|
|
43
44
|
"react": "19.2.7"
|
|
44
45
|
},
|
|
45
|
-
"contentHash": "
|
|
46
|
+
"contentHash": "4f8a725287979ac38c7332be0b9085920b08ca774914bf7c00bce6dcaa0cc9dc",
|
|
46
47
|
"files": [
|
|
47
48
|
{
|
|
48
49
|
"path": "EventsScreen.tsx",
|
|
@@ -54,11 +55,7 @@
|
|
|
54
55
|
},
|
|
55
56
|
{
|
|
56
57
|
"path": "index.ts",
|
|
57
|
-
"content": "// @idosgames/mod-events
|
|
58
|
-
},
|
|
59
|
-
{
|
|
60
|
-
"path": "model.test.ts",
|
|
61
|
-
"content": "import { describe, expect, it } from \"vitest\";\nimport seed from \"../title-seed.json\";\nimport {\n claimedLines,\n claimableNow,\n eventsBadge,\n hasPremium,\n milestoneRows,\n nextGoal,\n premiumKey,\n tokenSources,\n} from \"./model\";\n\n// The server resolves active events (definition content + the player's token progress); here one is\n// assembled from the seeded definition the way GetActiveEvents returns it.\ntype Def = { TimedEventID: string; Content: unknown };\nconst def = Object.values(\n (seed as unknown as { TimedEvent: { Definitions: Record<string, Def> } })\n .TimedEvent.Definitions,\n)[0]!;\nconst NOW = Date.parse(\"2026-09-28T12:00:00Z\");\nconst vip = {\n Subscriptions: { vip: { ExpirationDate: \"2026-10-28T00:00:00Z\" } },\n MaxActiveTier: 1,\n};\nconst event = (\n earned: number,\n claims: Record<string, { ClaimedAt?: string; PremiumTiers?: string[] }> = {},\n) =>\n ({\n Type: \"Cyclic\",\n TimedEventID: def.TimedEventID,\n Content: def.Content,\n Progress: {\n Balance: { TotalEarned: earned },\n Milestone: { Claims: claims },\n },\n }) as never;\n\ndescribe(\"events\", () => {\n it(\"reads the seeded festival: five milestones in order, each with a VIP prize\", () => {\n const rows = milestoneRows(event(0), null, NOW);\n expect(rows.map((r) => r.required)).toEqual([10, 50, 120, 250, 400]);\n expect(\n rows.every((r) => r.vip.length > 0 && r.vipPremiumID === \"vip\"),\n ).toBe(true);\n // The reward also moves the season: the status is a line of it like the coins.\n expect(rows[0]!.reward).toEqual([\n { kind: \"currency\", id: \"CO\", amount: 100 },\n { kind: \"token\", id: \"Season:main\", amount: 10 },\n ]);\n expect(rows[4]!.featured).toBe(true);\n // Each milestone moves the season: 10 status for the first, 50 for the crown.\n expect(\n rows.map((r) => r.reward.find((l) => l.kind === \"token\")?.amount),\n ).toEqual([10, 20, 30, 40, 50]);\n });\n\n it(\"a reached milestone is claimable; without a pass its VIP prize stays locked\", () => {\n const [first, second] = milestoneRows(event(12), null, NOW);\n expect(first).toMatchObject({ state: \"ready\", vipState: \"locked\" });\n expect(second).toMatchObject({ state: \"locked\", vipState: \"locked\" });\n expect(claimableNow(first!)).toBe(true);\n });\n\n it(\"a pass bought after the claim offers the VIP top-up, once\", () => {\n const claimed = { stars_10: { ClaimedAt: \"2026-09-27T10:00:00Z\" } };\n const row = milestoneRows(event(12, claimed), vip, NOW)[0]!;\n expect(row).toMatchObject({ state: \"claimed\", vipState: \"ready\" });\n expect(claimableNow(row)).toBe(true);\n const topped = {\n stars_10: { ClaimedAt: \"2026-09-27T10:00:00Z\", PremiumTiers: [\"vip#1\"] },\n };\n expect(milestoneRows(event(12, topped), vip, NOW)[0]).toMatchObject({\n state: \"claimed\",\n vipState: \"granted\",\n });\n });\n\n it(\"an expired pass is not a pass\", () => {\n expect(hasPremium(vip, \"vip\", 1, NOW)).toBe(true);\n expect(hasPremium(vip, \"vip\", 1, Date.parse(\"2026-11-01T00:00:00Z\"))).toBe(\n false,\n );\n expect(hasPremium({ MaxActiveTier: 2 }, null, 1, NOW)).toBe(true);\n expect(premiumKey({ RequiredPremiumID: \"vip\", MinPremiumTier: 1 })).toBe(\n \"vip#1\",\n );\n expect(premiumKey({ MinPremiumTier: 2 })).toBe(\"*#2\");\n });\n\n it(\"the bar counts from the last reached milestone to the next\", () => {\n const e = event(60);\n expect(nextGoal(e, milestoneRows(e, null, NOW))).toEqual({\n earned: 60,\n from: 50,\n goal: 120,\n });\n });\n\n it(\"the badge counts milestones and VIP top-ups waiting\", () => {\n expect(eventsBadge([event(55)], null, NOW)).toBe(2);\n expect(\n eventsBadge([event(55, { stars_10: { ClaimedAt: \"x\" } })], vip, NOW),\n ).toBe(2);\n expect(eventsBadge([event(5)], vip, NOW)).toBe(0);\n });\n\n it(\"stars come from rounds (capped per day), shop purchases and claimed quests\", () => {\n expect(tokenSources(event(0))).toEqual([\n { kind: \"CustomAction\", action: \"play_round\", amount: 5, dailyCap: 20 },\n { kind: \"StorePurchase\", action: null, amount: 2, dailyCap: 0 },\n { kind: \"QuestComplete\", action: null, amount: 10, dailyCap: 0 },\n ]);\n });\n});\n\ndescribe(\"claimed lines\", () => {\n it(\"sums the standard part and the VIP bundles of the applied operation\", () => {\n const coins = (n: number) => ({\n Entries: [{ Type: \"VirtualCurrency\", CurrencyID: \"CO\", Amount: n }],\n });\n const data = {\n Resources: {\n Grant: {\n Standard: coins(50),\n PremiumTiers: [{ RequiredPremiumID: \"vip\", Resources: coins(50) }],\n },\n },\n };\n expect(claimedLines(data)).toEqual([\n { kind: \"currency\", id: \"CO\", amount: 100 },\n ]);\n expect(\n claimedLines({ Rewards: { Grant: { Standard: coins(10) } } }),\n ).toEqual([{ kind: \"currency\", id: \"CO\", amount: 10 }]);\n expect(claimedLines({})).toBeNull();\n });\n});\n"
|
|
58
|
+
"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"
|
|
62
59
|
},
|
|
63
60
|
{
|
|
64
61
|
"path": "model.ts",
|
|
@@ -66,7 +63,7 @@
|
|
|
66
63
|
},
|
|
67
64
|
{
|
|
68
65
|
"path": "module.ts",
|
|
69
|
-
"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
|
|
66
|
+
"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"
|
|
70
67
|
},
|
|
71
68
|
{
|
|
72
69
|
"path": "VipPopup.tsx",
|
|
@@ -74,7 +71,7 @@
|
|
|
74
71
|
},
|
|
75
72
|
{
|
|
76
73
|
"path": "module.meta.json",
|
|
77
|
-
"content": "{\n \"id\": \"events
|
|
74
|
+
"content": "{\n \"id\": \"events\",\n \"type\": \"feature\",\n \"inBase\": true,\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) and the VIP pass (its benefits and prices in currencies) are popups. 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 \"VIP pass purchase popup\",\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"
|
|
78
75
|
}
|
|
79
76
|
]
|
|
80
77
|
}
|
|
@@ -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",
|
|
@@ -35,11 +36,11 @@
|
|
|
35
36
|
},
|
|
36
37
|
"dependencies": {
|
|
37
38
|
"@idosgames/core": "0.19.0",
|
|
38
|
-
"@idosgames/module-sdk": "0.
|
|
39
|
+
"@idosgames/module-sdk": "0.6.0",
|
|
39
40
|
"@idosgames/react": "0.3.0",
|
|
40
41
|
"react": "19.2.7"
|
|
41
42
|
},
|
|
42
|
-
"contentHash": "
|
|
43
|
+
"contentHash": "a7ff6c415397f9e9f406bffa2b8f26da60ab46a1ebdb0d7b0155c3fe843dc862",
|
|
43
44
|
"files": [
|
|
44
45
|
{
|
|
45
46
|
"path": "FriendsScreen.tsx",
|
|
@@ -51,11 +52,7 @@
|
|
|
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
|
}
|
|
@@ -50,20 +50,20 @@
|
|
|
50
50
|
},
|
|
51
51
|
"dependencies": {
|
|
52
52
|
"@idosgames/core": "0.19.0",
|
|
53
|
-
"@idosgames/module-sdk": "0.
|
|
53
|
+
"@idosgames/module-sdk": "0.6.0",
|
|
54
54
|
"@idosgames/react": "0.3.0",
|
|
55
|
-
"@idosgames/wallet": "0.5.
|
|
55
|
+
"@idosgames/wallet": "0.5.2",
|
|
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",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"id": "inventory
|
|
2
|
+
"id": "inventory",
|
|
3
3
|
"meta": {
|
|
4
4
|
"name": "Inventory",
|
|
5
5
|
"type": "app",
|
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
},
|
|
8
8
|
"catalog": {
|
|
9
9
|
"type": "feature",
|
|
10
|
+
"inBase": true,
|
|
10
11
|
"summary": "Inventory screen for any game: owned items by rarity with category tabs, item cards, chests opened with an animation.",
|
|
11
|
-
"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. Lootboxes: a Chests tab lists the title's lootboxes with every way to open them, and an item that is a chest's key gets an 'Open' button — the box shakes while the server rolls it, bursts, and the rewards fly out. Equipment hands off to the heroes screen (ctx.features.open('character', { itemInstanceID })) when character
|
|
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. Lootboxes: a Chests tab lists the title's lootboxes with every way to open them, and an item that is a chest's key gets an 'Open' button — the box shakes while the server rolls it, bursts, and the rewards fly out. Equipment hands off to the heroes screen (ctx.features.open('character', { itemInstanceID })) when character is installed. Registers the 'inventory' feature; its badge counts chests the player can open with the items they hold.",
|
|
12
13
|
"provides": [
|
|
13
14
|
"inventory screen with rarity and categories",
|
|
14
15
|
"item details popup",
|
|
@@ -35,11 +36,11 @@
|
|
|
35
36
|
},
|
|
36
37
|
"dependencies": {
|
|
37
38
|
"@idosgames/core": "0.19.0",
|
|
38
|
-
"@idosgames/module-sdk": "0.
|
|
39
|
+
"@idosgames/module-sdk": "0.6.0",
|
|
39
40
|
"@idosgames/react": "0.3.0",
|
|
40
41
|
"react": "19.2.7"
|
|
41
42
|
},
|
|
42
|
-
"contentHash": "
|
|
43
|
+
"contentHash": "4fee98ecaddf2c0e9a997e974160da2ad2add6b6d48c046b602c14e48bc81822",
|
|
43
44
|
"files": [
|
|
44
45
|
{
|
|
45
46
|
"path": "i18n.ts",
|
|
@@ -47,27 +48,23 @@
|
|
|
47
48
|
},
|
|
48
49
|
{
|
|
49
50
|
"path": "index.ts",
|
|
50
|
-
"content": "// @idosgames/mod-inventory
|
|
51
|
+
"content": "// @idosgames/mod-inventory — Inventory screen for any game: the player's items by rarity with filters, an item card, opening lootboxes with an animation, and a hand-off of equipment to the heroes screen.\n\nexport { inventoryModule } from \"./module\";\n"
|
|
51
52
|
},
|
|
52
53
|
{
|
|
53
54
|
"path": "InventoryScreen.tsx",
|
|
54
55
|
"content": "import { useEffect, useMemo, useRef, 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 Card,\n EmptyState,\n Icon,\n ItemTile,\n Popup,\n ResourceList,\n Tabs,\n configSection,\n errorText,\n grantedBy,\n outlined,\n useCatalog,\n useCelebrate,\n useGameLayout,\n usePopupClose,\n useSound,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n canPay,\n categoryOf,\n lootboxOptions,\n lootboxesOpenedBy,\n ownedItems,\n type Category,\n type InventoryView,\n type LootboxOption,\n type LootboxView,\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. Chests: a tab of\n// the title's lootboxes plus \"Open\" on an item that is a chest's key — both open with a shaking box\n// and the reward animation (Unity LootboxOpeningPopUp). Equipment hands off to the heroes screen.\n\ntype Tab = Category | \"chests\";\n\nexport function makeInventoryScreen(features: FeatureRegistry) {\n return function InventoryScreen({ args }: FeatureScreenProps): ReactNode {\n return (\n <Inventory\n features={features}\n initialTab={args?.tab === \"chests\" ? \"chests\" : \"all\"}\n />\n );\n };\n}\n\nfunction Inventory({\n features,\n initialTab,\n}: {\n features: FeatureRegistry;\n initialTab: Tab;\n}): 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<Tab>(initialTab);\n const [open, setOpen] = useState<string | null>(null);\n const [opening, setOpening] = useState<LootboxOption | null>(null);\n\n // Lootbox definitions come with the title config; fetch them once if the config had none.\n const lootboxSection = configSection<{\n Definitions?: Record<string, LootboxView | null> | null;\n }>(client, \"Lootbox\");\n useEffect(() => {\n if (!lootboxSection) void client.lootbox.getDefinitions();\n }, [client, lootboxSection]);\n // Instances of unstackable items (levels, equipped) come with the full inventory read only.\n useEffect(() => {\n void client.user.getUserInventory();\n }, [client]);\n const options = useMemo(\n () => lootboxOptions(lootboxSection),\n [lootboxSection],\n );\n\n const inventory = state?.InventoryV2 as InventoryView | undefined;\n const owned = ownedItems(inventory, catalog.items);\n const shown =\n tab === \"all\" || tab === \"chests\"\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: Tab; 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 ...(options.length > 0\n ? [{ id: \"chests\" as const, label: t(\"chests\") }]\n : []),\n ];\n\n return (\n <div style={{ display: \"grid\", gap: 14 }}>\n <Tabs tabs={tabs} value={tab} onChange={setTab} />\n {tab === \"chests\" ? (\n <Chests options={options} inventory={inventory} onOpen={setOpening} />\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 opens={lootboxesOpenedBy(open, options)}\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 onOpenLootbox={(option) => {\n setOpen(null);\n setOpening(option);\n }}\n onClose={() => setOpen(null)}\n />\n ) : null}\n {opening ? (\n <LootboxOpening option={opening} onClose={() => setOpening(null)} />\n ) : null}\n </div>\n );\n}\n\nfunction Chests({\n options,\n inventory,\n onOpen,\n}: {\n options: LootboxOption[];\n inventory: InventoryView | undefined;\n onOpen: (o: LootboxOption) => void;\n}): ReactNode {\n const catalog = useCatalog();\n const stagger = useStagger();\n if (options.length === 0)\n return <EmptyState glyph=\"chest\" text={t(\"noChests\")} />;\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(170px, 1fr))\",\n gap: 12,\n }}\n >\n {options.map((o, i) => {\n const affordable = canPay(o, inventory);\n return (\n <Card\n key={`${o.lootboxID}:${o.optionID}`}\n style={{\n display: \"grid\",\n justifyItems: \"center\",\n gap: 8,\n ...stagger(i),\n }}\n >\n <div style={{ ...outlined, fontSize: 15, textAlign: \"center\" }}>\n {catalog.localize(o.lootboxID)}\n </div>\n <div className=\"idos-bounce\">\n <Icon glyph=\"chest\" size={64} />\n </div>\n {o.name ? (\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {catalog.localize(o.name)}\n </div>\n ) : null}\n <Button\n tone=\"gold\"\n disabled={!affordable}\n onClick={() => onOpen(o)}\n style={{ width: \"100%\" }}\n >\n {o.price.length === 0 ? (\n t(\"open\")\n ) : (\n <ResourceList lines={o.price} size={20} />\n )}\n </Button>\n </Card>\n );\n })}\n </div>\n );\n}\n\nfunction ItemCard({\n itemID,\n owned,\n opens,\n canEquip,\n onEquip,\n onOpenLootbox,\n onClose,\n}: {\n itemID: string;\n owned: ReturnType<typeof ownedItems>[number] | undefined;\n opens: LootboxOption[];\n canEquip: boolean;\n onEquip: (instanceID: string | undefined) => void;\n onOpenLootbox: (option: LootboxOption) => 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 {opens.map((o) => (\n <Button\n key={`${o.lootboxID}:${o.optionID}`}\n tone=\"gold\"\n attract\n onClick={() => onOpenLootbox(o)}\n >\n {t(\"openFor\")} <ResourceList lines={o.price} size={18} />\n </Button>\n ))}\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\n/** The box shakes while the server rolls it, bursts open, and the rewards pop out. */\nfunction LootboxOpening({\n option,\n onClose,\n}: {\n option: LootboxOption;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const play = useSound();\n const kit = useUiKit();\n const boxRef = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n\n const openBox = async (close: () => void) => {\n setBusy(true);\n const started = Date.now();\n const res = await client.lootbox.open(option.lootboxID, 1, option.optionID);\n // Let the shake play for a moment even on a fast server — the anticipation is the point.\n const wait = Math.max(\n 0,\n (kit.motion.on ? 700 : 0) - (Date.now() - started),\n );\n await new Promise((r) => setTimeout(r, wait));\n setBusy(false);\n if (!res.ok) {\n toast(errorText(res.error), \"error\");\n return;\n }\n play(\"lootbox\");\n kit.fx.burst(boxRef.current);\n close();\n celebrate(grantedBy(res.data), { sound: \"levelUp\" });\n };\n\n return (\n <Popup\n title={catalog.localize(option.lootboxID)}\n onClose={onClose}\n variant=\"center\"\n >\n <div\n style={{\n display: \"grid\",\n justifyItems: \"center\",\n gap: 12,\n padding: \"10px 0\",\n }}\n >\n <div\n ref={boxRef}\n style={{\n animation: busy\n ? \"idos-shake .35s ease-in-out infinite\"\n : \"idos-bounce 1.2s ease-in-out infinite\",\n }}\n >\n <Icon glyph=\"chest\" size={120} />\n </div>\n <ResourceList lines={option.price} />\n </div>\n <OpenButton busy={busy} onOpen={openBox} />\n </Popup>\n );\n}\n\nfunction OpenButton({\n busy,\n onOpen,\n}: {\n busy: boolean;\n onOpen: (close: () => void) => Promise<void>;\n}): ReactNode {\n const close = usePopupClose();\n return (\n <Button\n tone=\"gold\"\n size=\"lg\"\n attract\n busy={busy}\n onClick={() => void onOpen(close)}\n >\n {busy ? t(\"opening\") : t(\"open\")}\n </Button>\n );\n}\n"
|
|
55
56
|
},
|
|
56
|
-
{
|
|
57
|
-
"path": "model.test.ts",
|
|
58
|
-
"content": "import { describe, expect, it } from \"vitest\";\nimport { itemDefinitions } from \"@idosgames/react/ui\";\nimport {\n canPay,\n categoryOf,\n lootboxOptions,\n lootboxesOpenedBy,\n openableWithItems,\n ownedItems,\n} from \"./model\";\n\nconst items = itemDefinitions({\n Catalogs: {\n Item: {\n Items: {\n potion: {\n ItemID: \"potion\",\n Tags: [\"potion\"],\n IsStackable: true,\n Metadata: { RarityID: \"Common\" },\n },\n sword: {\n ItemID: \"sword\",\n ItemClass: \"Weapon\",\n IsStackable: false,\n Metadata: { RarityID: \"Epic\" },\n },\n crown: {\n ItemID: \"crown\",\n IsStackable: false,\n Metadata: { RarityID: \"Legendary\" },\n },\n key: { ItemID: \"key\", Tags: [\"key\"], IsStackable: true },\n },\n },\n },\n});\n\ndescribe(\"inventory\", () => {\n it(\"lists owned items rarest first, with instances best level first\", () => {\n const owned = ownedItems(\n {\n Items: { potion: { StackableAmount: 3, TotalAmount: 3 } },\n UnstackableItems: {\n a: { ItemID: \"crown\" },\n b: { ItemID: \"sword\", Level: 1 },\n c: { ItemID: \"sword\", Level: 4 },\n },\n },\n items,\n );\n expect(owned.map((o) => [o.itemID, o.count])).toEqual([\n [\"crown\", 1],\n [\"sword\", 2],\n [\"potion\", 3],\n ]);\n expect(owned[1]?.instances.map((i) => i.id)).toEqual([\"c\", \"b\"]);\n expect(ownedItems(undefined, items)).toEqual([]);\n });\n\n it(\"sorts items into categories by class and tags\", () => {\n expect(categoryOf(items.get(\"sword\"))).toBe(\"equipment\");\n expect(categoryOf(items.get(\"crown\"))).toBe(\"equipment\");\n expect(categoryOf(items.get(\"potion\"))).toBe(\"consumable\");\n expect(categoryOf(undefined)).toBe(\"other\");\n });\n});\n\ndescribe(\"chests\", () => {\n const section = {\n Definitions: {\n gold_chest: {\n LootboxID: \"gold_chest\",\n PriceOptions: {\n coins: {\n Cost: {\n Standard: {\n Entries: [\n { Type: \"VirtualCurrency\", CurrencyID: \"CO\", Amount: 300 },\n ],\n },\n },\n },\n key: {\n Cost: {\n Standard: {\n Entries: [{ Type: \"Item\", ItemID: \"key\", Amount: 1 }],\n },\n },\n },\n },\n },\n },\n };\n\n it(\"one entry per way to open, and a key item opens its chest\", () => {\n const options = lootboxOptions(section);\n expect(options.map((o) => o.optionID)).toEqual([\"coins\", \"key\"]);\n expect(lootboxesOpenedBy(\"key\", options).map((o) => o.optionID)).toEqual([\n \"key\",\n ]);\n expect(lootboxesOpenedBy(\"potion\", options)).toEqual([]);\n });\n\n it(\"checks what the player holds; the badge counts chests openable with items\", () => {\n const options = lootboxOptions(section);\n const inv = {\n VirtualCurrencies: { CO: { Amount: 100 } },\n Items: { key: { TotalAmount: 1 } },\n };\n expect(canPay(options[0]!, inv)).toBe(false);\n expect(canPay(options[1]!, inv)).toBe(true);\n expect(openableWithItems(options, inv)).toBe(1);\n expect(openableWithItems(options, { Items: {} })).toBe(0);\n });\n});\n"
|
|
59
|
-
},
|
|
60
57
|
{
|
|
61
58
|
"path": "model.ts",
|
|
62
|
-
"content": "// Pure helpers of the inventory screen — covered by model.test.ts.\n\nimport {\n costLines,\n rarityRank,\n type ItemDefinitionView,\n type ResourceLine,\n} 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\nexport interface LootboxView {\n LootboxID?: string;\n DisplayName?: string | null;\n PriceOptions?: Record<\n string,\n { OptionID?: string | null; Name?: string | null; Cost?: unknown } | null\n > | null;\n}\n\nexport interface LootboxOption {\n lootboxID: string;\n optionID: string;\n name: string | null;\n price: ResourceLine[];\n}\n\n/** Every way to open every lootbox of the title (one entry per price option). */\nexport function lootboxOptions(\n section:\n | { Definitions?: Record<string, LootboxView | null> | null }\n | null\n | undefined,\n): LootboxOption[] {\n const out: LootboxOption[] = [];\n for (const [id, def] of Object.entries(section?.Definitions ?? {})) {\n if (!def) continue;\n for (const [optionID, option] of Object.entries(def.PriceOptions ?? {})) {\n if (!option) continue;\n out.push({\n lootboxID: def.LootboxID ?? id,\n optionID: option.OptionID || optionID,\n name: option.Name ?? null,\n price: costLines(option.Cost as never),\n });\n }\n }\n return out.sort(\n (a, b) =>\n a.lootboxID.localeCompare(b.lootboxID) ||\n a.optionID.localeCompare(b.optionID),\n );\n}\n\n/** Lootboxes an item opens: options whose price consumes that item (a chest or a key). */\nexport function lootboxesOpenedBy(\n itemID: string,\n options: LootboxOption[],\n): LootboxOption[] {\n return options.filter((o) =>\n o.price.some((line) => line.kind === \"item\" && line.id === itemID),\n );\n}\n\n/** Whether the player holds what an option costs (currencies and items). */\nexport function canPay(\n option: LootboxOption,\n inventory: InventoryView | null | undefined,\n): boolean {\n return option.price.every((line) => {\n if (line.kind === \"currency\")\n return (\n Number(inventory?.VirtualCurrencies?.[line.id]?.Amount ?? 0) >=\n line.amount\n );\n if (line.kind === \"item\")\n return (\n Number(\n inventory?.Items?.[line.id]?.TotalAmount ??\n inventory?.Items?.[line.id]?.StackableAmount ??\n 0,\n ) >= line.amount\n );\n return true;\n });\n}\n\n/** Badge: lootboxes the player can open right now with items (chests, keys) — not with currency. */\nexport function openableWithItems(\n options: LootboxOption[],\n inventory: InventoryView | null | undefined,\n): number {\n return options.filter(\n (o) => o.price.some((l) => l.kind === \"item\") && canPay(o, inventory),\n ).length;\n}\n"
|
|
59
|
+
"content": "// Pure helpers of the inventory screen — covered by model.test.ts.\n\nimport {\n costLines,\n rarityRank,\n type ItemDefinitionView,\n type ResourceLine,\n} 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\nexport interface LootboxView {\n LootboxID?: string;\n DisplayName?: string | null;\n PriceOptions?: Record<\n string,\n { OptionID?: string | null; Name?: string | null; Cost?: unknown } | null\n > | null;\n}\n\nexport interface LootboxOption {\n lootboxID: string;\n optionID: string;\n name: string | null;\n price: ResourceLine[];\n}\n\n/** Every way to open every lootbox of the title (one entry per price option). */\nexport function lootboxOptions(\n section:\n | { Definitions?: Record<string, LootboxView | null> | null }\n | null\n | undefined,\n): LootboxOption[] {\n const out: LootboxOption[] = [];\n for (const [id, def] of Object.entries(section?.Definitions ?? {})) {\n if (!def) continue;\n for (const [optionID, option] of Object.entries(def.PriceOptions ?? {})) {\n if (!option) continue;\n out.push({\n lootboxID: def.LootboxID ?? id,\n optionID: option.OptionID || optionID,\n name: option.Name ?? null,\n price: costLines(option.Cost as never),\n });\n }\n }\n return out.sort(\n (a, b) =>\n a.lootboxID.localeCompare(b.lootboxID) ||\n a.optionID.localeCompare(b.optionID),\n );\n}\n\n/** Lootboxes an item opens: options whose price consumes that item (a chest or a key). */\nexport function lootboxesOpenedBy(\n itemID: string,\n options: LootboxOption[],\n): LootboxOption[] {\n return options.filter((o) =>\n o.price.some((line) => line.kind === \"item\" && line.id === itemID),\n );\n}\n\n/** Whether the player holds what an option costs (currencies and items). */\nexport function canPay(\n option: LootboxOption,\n inventory: InventoryView | null | undefined,\n): boolean {\n return option.price.every((line) => {\n if (line.kind === \"currency\")\n return (\n Number(inventory?.VirtualCurrencies?.[line.id]?.Amount ?? 0) >=\n line.amount\n );\n if (line.kind === \"item\")\n return (\n Number(\n inventory?.Items?.[line.id]?.TotalAmount ??\n inventory?.Items?.[line.id]?.StackableAmount ??\n 0,\n ) >= line.amount\n );\n return true;\n });\n}\n\n/** Badge: lootboxes the player can open right now with items (chests, keys) — not with currency. */\nexport function openableWithItems(\n options: LootboxOption[],\n inventory: InventoryView | null | undefined,\n): number {\n return options.filter(\n (o) => o.price.some((l) => l.kind === \"item\") && canPay(o, inventory),\n ).length;\n}\n\n/** The title's item catalogs, as far as the inventory tab cares. */\nexport interface ItemCatalogsView {\n Catalogs?: Record<\n string,\n { Items?: Record<string, unknown> | null } | null\n > | null;\n}\n\n/** Whether the title has items at all — until it does, the lobby hides the Inventory tab. */\nexport function hasTitleData(section: ItemCatalogsView | undefined): boolean {\n return Object.values(section?.Catalogs ?? {}).some(\n (c) => Object.keys(c?.Items ?? {}).length > 0,\n );\n}\n"
|
|
63
60
|
},
|
|
64
61
|
{
|
|
65
62
|
"path": "module.ts",
|
|
66
|
-
"content": "import { createElement, useEffect } from \"react\";\nimport {\n defineModule,\n type FeatureRegistry,\n type Module,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport { configSection } from \"@idosgames/react/ui\";\nimport { makeInventoryScreen } from \"./InventoryScreen\";\nimport {\n lootboxOptions,\n openableWithItems,\n type InventoryView,\n type LootboxView,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// The inventory as a feature of the game. Badge: chests the player can open right now with the\n// items they hold (a chest item or a key).\nexport const
|
|
63
|
+
"content": "import { createElement, useEffect } from \"react\";\nimport {\n defineModule,\n type FeatureRegistry,\n type Module,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport { configSection } from \"@idosgames/react/ui\";\nimport { makeInventoryScreen } from \"./InventoryScreen\";\nimport {\n hasTitleData,\n lootboxOptions,\n openableWithItems,\n type InventoryView,\n type ItemCatalogsView,\n type LootboxView,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// The inventory as a feature of the game. Badge: chests the player can open right now with the\n// items they hold (a chest item or a key).\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: \"chest\",\n group: \"economy\",\n order: 50,\n primary: true,\n available: false,\n Screen: makeInventoryScreen(ctx.features),\n });\n ctx.features.setAvailable(\n \"inventory\",\n hasTitleData(configSection<ItemCatalogsView>(ctx.client, \"Item\")),\n \"no items on the title (the Item catalogs are empty)\",\n );\n ctx.registerPanel({\n id: \"badge\",\n slot: \"modal\",\n activeOnly: false,\n component: makeBadgeWatcher(ctx.features),\n });\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"inventory\" }),\n describeActions: {},\n });\n },\n});\n\nfunction makeBadgeWatcher(features: FeatureRegistry) {\n return function InventoryBadge() {\n const client = useIDosGamesClient();\n useEffect(() => {\n const update = () => {\n const options = lootboxOptions(\n configSection<{\n Definitions?: Record<string, LootboxView | null> | null;\n }>(client, \"Lootbox\"),\n );\n features.setBadge(\n \"inventory\",\n openableWithItems(\n options,\n client.data.user.state?.InventoryV2 as InventoryView | undefined,\n ),\n );\n };\n update();\n return client.on(\"user:anyUpdated\", update);\n }, [client]);\n return createElement(\"span\", { hidden: true });\n };\n}\n"
|
|
67
64
|
},
|
|
68
65
|
{
|
|
69
66
|
"path": "module.meta.json",
|
|
70
|
-
"content": "{\n \"id\": \"inventory
|
|
67
|
+
"content": "{\n \"id\": \"inventory\",\n \"type\": \"feature\",\n \"inBase\": true,\n \"summary\": \"Inventory screen for any game: owned items by rarity with category tabs, item cards, chests opened with an animation.\",\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. Lootboxes: a Chests tab lists the title's lootboxes with every way to open them, and an item that is a chest's key gets an 'Open' button — the box shakes while the server rolls it, bursts, and the rewards fly out. Equipment hands off to the heroes screen (ctx.features.open('character', { itemInstanceID })) when character is installed. Registers the 'inventory' feature; its badge counts chests the player can open with the items they hold.\",\n \"provides\": [\n \"inventory screen with rarity and categories\",\n \"item details popup\",\n \"lootbox list and opening animation\",\n \"open a chest with a key item\",\n \"hand-off of equipment to the heroes screen\",\n \"badge for openable chests\"\n ],\n \"tags\": [\n \"inventory\",\n \"items\",\n \"lootbox\",\n \"chest\",\n \"rarity\",\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
68
|
}
|
|
72
69
|
]
|
|
73
70
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"id": "
|
|
2
|
+
"id": "invite",
|
|
3
3
|
"meta": {
|
|
4
4
|
"name": "Invite",
|
|
5
5
|
"type": "app",
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
},
|
|
8
8
|
"catalog": {
|
|
9
9
|
"type": "feature",
|
|
10
|
+
"inBase": true,
|
|
10
11
|
"summary": "Invite friends for any game: your code and link to share, friends who came by it, rewards for them, and entering a friend's code in a popup.",
|
|
11
12
|
"description": "A ready invite-a-friend screen (Unity V2/Runtime/UI/Referral) for ANY game, built on the @idosgames/react/ui kit and client.referral. The player's own short code (minted by the server when the screen opens) with 'Copy' and 'Share link' (the system share sheet, else the clipboard), how many friends came by it and the reward ladder for them (claim one or all). Entering a friend's code is a popup: it shows the newcomer's gift, reads the code the way the server does (8 letters, separators and case ignored) and pays the gift once. A player bound at login by an invite link sees 'You joined by a friend's code'. Registers the 'invite' feature; its badge counts invite rewards ready. Ships title-seed.json: a gift for the newcomer and rewards for 1, 3 and 5 friends.",
|
|
12
13
|
"provides": [
|
|
@@ -35,11 +36,11 @@
|
|
|
35
36
|
},
|
|
36
37
|
"dependencies": {
|
|
37
38
|
"@idosgames/core": "0.19.0",
|
|
38
|
-
"@idosgames/module-sdk": "0.
|
|
39
|
+
"@idosgames/module-sdk": "0.6.0",
|
|
39
40
|
"@idosgames/react": "0.3.0",
|
|
40
41
|
"react": "19.2.7"
|
|
41
42
|
},
|
|
42
|
-
"contentHash": "
|
|
43
|
+
"contentHash": "d161151b1e04e282113f473531db599440adbb713d52badc0d09ce6474c88a57",
|
|
43
44
|
"files": [
|
|
44
45
|
{
|
|
45
46
|
"path": "i18n.ts",
|
|
@@ -47,19 +48,15 @@
|
|
|
47
48
|
},
|
|
48
49
|
{
|
|
49
50
|
"path": "index.ts",
|
|
50
|
-
"content": "// @idosgames/mod-
|
|
51
|
-
},
|
|
52
|
-
{
|
|
53
|
-
"path": "model.test.ts",
|
|
54
|
-
"content": "import { describe, expect, it } from \"vitest\";\nimport seed from \"../title-seed.json\";\nimport { inviteRows, normalizeCode, prettyCode, referralBadge } from \"./model\";\n\nconst defs = (\n seed as unknown as {\n Referral: { IsEnabled: boolean; InviteRewards: Record<string, never> };\n }\n).Referral;\n\ndescribe(\"invite\", () => {\n it(\"the seeded ladder: 1, 3 and 5 friends\", () => {\n expect(defs.IsEnabled).toBe(true);\n const rows = inviteRows(defs.InviteRewards, { FollowersCount: 0 });\n expect(rows.map((r) => r.required)).toEqual([1, 3, 5]);\n expect(rows[0]!.reward).toEqual([\n { kind: \"currency\", id: \"GE\", amount: 10 },\n ]);\n expect(rows.every((r) => r.state === \"locked\")).toBe(true);\n });\n\n it(\"friends who came open steps; a claimed step stays claimed\", () => {\n const rows = inviteRows(defs.InviteRewards, {\n FollowersCount: 3,\n InviteRewardStates: { invite_1: { IsClaimed: true } },\n });\n expect(rows.map((r) => r.state)).toEqual([\"claimed\", \"ready\", \"locked\"]);\n expect(referralBadge(rows)).toBe(1);\n });\n\n it(\"reads a typed code the way the server does: separators dropped, upper case, its alphabet only\", () => {\n expect(normalizeCode(\"wdjb-mjht\")).toBe(\"WDJBMJHT\");\n expect(normalizeCode(\" WDJB MJHT \")).toBe(\"WDJBMJHT\");\n expect(normalizeCode(\"WDJBMJH\")).toBeNull();\n expect(normalizeCode(\"ABCDEFGH\")).toBeNull(); // vowels are not in the alphabet\n expect(normalizeCode(\"B0CDFGHJ\")).toBeNull();\n expect(prettyCode(\"WDJBMJHT\")).toBe(\"WDJB-MJHT\");\n });\n});\n"
|
|
51
|
+
"content": "// @idosgames/mod-invite — Invite friends for any game: your code and link, entering a friend's code, and rewards for friends who joined.\n\nexport { inviteModule } from \"./module\";\n"
|
|
55
52
|
},
|
|
56
53
|
{
|
|
57
54
|
"path": "model.ts",
|
|
58
|
-
"content": "// Pure helpers of the invite screen (Unity V2/Runtime/UI/Referral): the code a player types, the\n// ladder of rewards for friends who came by your code, and the badge. The server validates codes,\n// counts friends and pays — this only decides what the screen shows. Covered by model.test.ts.\n\nimport { grantLines, type ResourceLine } from \"@idosgames/react/ui\";\n\n/** The code alphabet: no vowels, no 0/O, no 1/I/L — so a code read aloud is typed right. */\nconst CODE_ALPHABET = /^[BCDFGHJKMNPQRSTVWXZ]{8}$/;\n\n/** A code as the player typed it: separators dropped, upper case; `null` when it can't be a code. */\nexport function normalizeCode(input: string): string | null {\n const code = input.replace(/[\\s\\-_.]/g, \"\").toUpperCase();\n return CODE_ALPHABET.test(code) ? code : null;\n}\n\n/** \"WDJB-MJHT\" — easier to read out and copy by eye. */\nexport function prettyCode(code: string | null | undefined): string {\n if (!code) return \"\";\n return code.length === 8 ? `${code.slice(0, 4)}-${code.slice(4)}` : code;\n}\n\nexport interface InviteRow {\n id: string;\n name: string | null;\n required: number;\n reward: ResourceLine[];\n state: \"claimed\" | \"ready\" | \"locked\";\n}\n\nexport function inviteRows(\n rewards:\n | Record<\n string,\n {\n MilestoneID?: string | null;\n DisplayName?: string | null;\n RequiredProgress?: number | null;\n Rewards?: unknown;\n } | null\n >\n | null\n | undefined,\n state:\n | {\n FollowersCount?: number | null;\n InviteRewardStates?: Record<\n string,\n { IsClaimed?: boolean | null } | null\n > | null;\n }\n | null\n | undefined,\n): InviteRow[] {\n const friends = Number(state?.FollowersCount ?? 0);\n return Object.entries(rewards ?? {})\n .filter(([, m]) => m)\n .map(([key, m]) => {\n const id = m!.MilestoneID || key;\n const required = Number(m!.RequiredProgress ?? 0);\n const claimed = state?.InviteRewardStates?.[id]?.IsClaimed === true;\n return {\n id,\n name: m!.DisplayName ?? null,\n required,\n reward: grantLines(m!.Rewards as never),\n state: claimed ? \"claimed\" : friends >= required ? \"ready\" : \"locked\",\n } satisfies InviteRow;\n })\n .sort((a, b) => a.required - b.required || a.id.localeCompare(b.id));\n}\n\n/** Badge: invite rewards ready to take. */\nexport function referralBadge(rows: InviteRow[]): number {\n return rows.filter((r) => r.state === \"ready\").length;\n}\n"
|
|
55
|
+
"content": "// Pure helpers of the invite screen (Unity V2/Runtime/UI/Referral): the code a player types, the\n// ladder of rewards for friends who came by your code, and the badge. The server validates codes,\n// counts friends and pays — this only decides what the screen shows. Covered by model.test.ts.\n\nimport { grantLines, type ResourceLine } from \"@idosgames/react/ui\";\n\n/** The code alphabet: no vowels, no 0/O, no 1/I/L — so a code read aloud is typed right. */\nconst CODE_ALPHABET = /^[BCDFGHJKMNPQRSTVWXZ]{8}$/;\n\n/** A code as the player typed it: separators dropped, upper case; `null` when it can't be a code. */\nexport function normalizeCode(input: string): string | null {\n const code = input.replace(/[\\s\\-_.]/g, \"\").toUpperCase();\n return CODE_ALPHABET.test(code) ? code : null;\n}\n\n/** \"WDJB-MJHT\" — easier to read out and copy by eye. */\nexport function prettyCode(code: string | null | undefined): string {\n if (!code) return \"\";\n return code.length === 8 ? `${code.slice(0, 4)}-${code.slice(4)}` : code;\n}\n\nexport interface InviteRow {\n id: string;\n name: string | null;\n required: number;\n reward: ResourceLine[];\n state: \"claimed\" | \"ready\" | \"locked\";\n}\n\nexport function inviteRows(\n rewards:\n | Record<\n string,\n {\n MilestoneID?: string | null;\n DisplayName?: string | null;\n RequiredProgress?: number | null;\n Rewards?: unknown;\n } | null\n >\n | null\n | undefined,\n state:\n | {\n FollowersCount?: number | null;\n InviteRewardStates?: Record<\n string,\n { IsClaimed?: boolean | null } | null\n > | null;\n }\n | null\n | undefined,\n): InviteRow[] {\n const friends = Number(state?.FollowersCount ?? 0);\n return Object.entries(rewards ?? {})\n .filter(([, m]) => m)\n .map(([key, m]) => {\n const id = m!.MilestoneID || key;\n const required = Number(m!.RequiredProgress ?? 0);\n const claimed = state?.InviteRewardStates?.[id]?.IsClaimed === true;\n return {\n id,\n name: m!.DisplayName ?? null,\n required,\n reward: grantLines(m!.Rewards as never),\n state: claimed ? \"claimed\" : friends >= required ? \"ready\" : \"locked\",\n } satisfies InviteRow;\n })\n .sort((a, b) => a.required - b.required || a.id.localeCompare(b.id));\n}\n\n/** Badge: invite rewards ready to take. */\nexport function referralBadge(rows: InviteRow[]): number {\n return rows.filter((r) => r.state === \"ready\").length;\n}\n\n/** Whether invites are on for the title (off by default) — until they are, the lobby hides the tab. */\nexport function hasTitleData(\n section: { IsEnabled?: boolean | null } | null | undefined,\n): boolean {\n return section?.IsEnabled === true;\n}\n"
|
|
59
56
|
},
|
|
60
57
|
{
|
|
61
58
|
"path": "module.ts",
|
|
62
|
-
"content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { makeReferralScreen, makeReferralWatcher } from \"./ReferralScreen\";\nimport { t } from \"./i18n\";\n\n// Invite friends as a feature of the game. The watcher sets the badge (invite rewards ready) once\n// on login and whenever the cached referral state changes.\nexport const
|
|
59
|
+
"content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { configSection } from \"@idosgames/react/ui\";\nimport { makeReferralScreen, makeReferralWatcher } from \"./ReferralScreen\";\nimport { hasTitleData } from \"./model\";\nimport { t } from \"./i18n\";\n\n// Invite friends as a feature of the game. The watcher sets the badge (invite rewards ready) once\n// on login and whenever the cached referral state changes.\nexport const inviteModule: Module = defineModule({\n id: \"invite\",\n meta: { name: \"Invite\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"invite\",\n label: t(\"title\"),\n icon: \"link\",\n group: \"social\",\n order: 90,\n available: false,\n Screen: makeReferralScreen(ctx.features),\n });\n ctx.features.setAvailable(\n \"invite\",\n hasTitleData(\n configSection<{ IsEnabled?: boolean | null }>(ctx.client, \"Referral\"),\n ),\n \"invites are off on the title (Referral.IsEnabled)\",\n );\n ctx.registerPanel({\n id: \"badge\",\n slot: \"modal\",\n activeOnly: false,\n component: makeReferralWatcher(ctx.features),\n });\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"invite\" }),\n describeActions: {},\n });\n },\n});\n"
|
|
63
60
|
},
|
|
64
61
|
{
|
|
65
62
|
"path": "ReferralScreen.tsx",
|
|
@@ -67,7 +64,7 @@
|
|
|
67
64
|
},
|
|
68
65
|
{
|
|
69
66
|
"path": "module.meta.json",
|
|
70
|
-
"content": "{\n \"id\": \"
|
|
67
|
+
"content": "{\n \"id\": \"invite\",\n \"type\": \"feature\",\n \"inBase\": true,\n \"summary\": \"Invite friends for any game: your code and link to share, friends who came by it, rewards for them, and entering a friend's code in a popup.\",\n \"description\": \"A ready invite-a-friend screen (Unity V2/Runtime/UI/Referral) for ANY game, built on the @idosgames/react/ui kit and client.referral. The player's own short code (minted by the server when the screen opens) with 'Copy' and 'Share link' (the system share sheet, else the clipboard), how many friends came by it and the reward ladder for them (claim one or all). Entering a friend's code is a popup: it shows the newcomer's gift, reads the code the way the server does (8 letters, separators and case ignored) and pays the gift once. A player bound at login by an invite link sees 'You joined by a friend's code'. Registers the 'invite' feature; its badge counts invite rewards ready. Ships title-seed.json: a gift for the newcomer and rewards for 1, 3 and 5 friends.\",\n \"provides\": [\n \"own invite code and link\",\n \"share or copy the invite\",\n \"friends who came by your code\",\n \"invite reward ladder with claim all\",\n \"enter a friend's code popup\",\n \"invite badge\",\n \"demo referral rewards\"\n ],\n \"tags\": [\"referral\", \"invite\", \"friends\", \"growth\", \"social\", \"ui\", \"screen\"],\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
|
}
|