@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.
Files changed (37) hide show
  1. package/dist/cli.js +2 -2
  2. package/package.json +1 -1
  3. package/registry/host.json +69 -25
  4. package/registry/index.json +360 -412
  5. package/registry/modules/board-game.json +7 -7
  6. package/registry/modules/{character-ui.json → character.json} +8 -11
  7. package/registry/modules/chat.json +71 -0
  8. package/registry/modules/{craft-ui.json → craft.json} +8 -11
  9. package/registry/modules/{deal-offer-ui.json → deals.json} +7 -10
  10. package/registry/modules/{events-ui.json → events.json} +8 -11
  11. package/registry/modules/{friends-ui.json → friends.json} +8 -11
  12. package/registry/modules/idle-rpg.json +6 -6
  13. package/registry/modules/{inventory-ui.json → inventory.json} +9 -12
  14. package/registry/modules/{referral-ui.json → invite.json} +8 -11
  15. package/registry/modules/{leaderboard-ui.json → leaderboard.json} +8 -11
  16. package/registry/modules/marketplace.json +72 -0
  17. package/registry/modules/mini-game.json +69 -0
  18. package/registry/modules/{profile-ui.json → profile.json} +8 -11
  19. package/registry/modules/{pvp-ui.json → pvp.json} +9 -12
  20. package/registry/modules/{rewards-ui.json → rewards.json} +8 -11
  21. package/registry/modules/{season-ui.json → season.json} +8 -11
  22. package/registry/modules/{store-ui.json → store.json} +9 -12
  23. package/registry/modules/{tutorial-ui.json → tutorials.json} +8 -11
  24. package/registry/modules/voxelcraft.json +2 -446
  25. package/registry/modules/workshop.json +11 -6
  26. package/registry/skills/dev-test-loop.json +1 -1
  27. package/registry/skills/idosgames-compose-modules.json +2 -2
  28. package/registry/skills/idosgames-getting-started.json +2 -2
  29. package/registry/skills/idosgames-module-contract.json +2 -2
  30. package/registry/skills/idosgames-project-structure.json +1 -1
  31. package/registry/skills/idosgames-ui-kit.json +2 -2
  32. package/registry/skills/push-notifications.json +1 -1
  33. package/registry/skills/workshop-system.json +1 -1
  34. package/registry/modules/chat-ui.json +0 -74
  35. package/registry/modules/game-hud.json +0 -104
  36. package/registry/modules/marketplace-ui.json +0 -75
  37. package/registry/modules/showcase.json +0 -92
@@ -1,5 +1,5 @@
1
1
  {
2
- "id": "pvp-ui",
2
+ "id": "pvp",
3
3
  "meta": {
4
4
  "name": "Arena",
5
5
  "type": "app",
@@ -7,8 +7,9 @@
7
7
  },
8
8
  "catalog": {
9
9
  "type": "feature",
10
+ "inBase": true,
10
11
  "summary": "PvP duels for any game: open duels to join, your duels, creating a duel for coins, a zone strategy and the fight replayed round by round.",
11
- "description": "A ready PvP arena (Unity Alikhan/PVP/Lobby) for ANY game, built on the @idosgames/react/ui kit, client.match and client.character. Tabs: open duels (join with one of your heroes) and your duels (waiting — cancel returns the entry; finished — won / lost / draw). Popups: a new duel (rule, hero by power, entry within the rule's bounds and what the winner takes after the burn), the battle strategy (attack and defence zone — head, body, legs — per round, saved as the default) and the result, replayed round by round (whose blow, where, hit / critical / block / dodge, damage, HP left). The server fights the battle instantly from the heroes' stats; a draw returns the entries. Without a hero the screen offers the Heroes feature. Registers the 'pvp' feature. Ships title-seed.json: a coin duel (10–500, 10% burned) fought by the showcase heroes' Attack and Health.",
12
+ "description": "A ready PvP arena (Unity Alikhan/PVP/Lobby) for ANY game, built on the @idosgames/react/ui kit, client.match and client.character. Tabs: open duels (join with one of your heroes) and your duels (waiting — cancel returns the entry; finished — won / lost / draw). Popups: a new duel (rule, hero by power, entry within the rule's bounds and what the winner takes after the burn), the battle strategy (attack and defence zone — head, body, legs — per round, saved as the default) and the result, replayed round by round (whose blow, where, hit / critical / block / dodge, damage, HP left). The server fights the battle instantly from the heroes' stats; a draw returns the entries. Without a hero the screen offers the Heroes feature. Registers the 'pvp' feature. Ships title-seed.json: a coin duel (10–500, 10% burned) fought by the demo heroes' Attack and Health.",
12
13
  "provides": [
13
14
  "open duels to join",
14
15
  "my duels with cancel",
@@ -34,11 +35,11 @@
34
35
  },
35
36
  "dependencies": {
36
37
  "@idosgames/core": "0.19.0",
37
- "@idosgames/module-sdk": "0.5.0",
38
+ "@idosgames/module-sdk": "0.6.0",
38
39
  "@idosgames/react": "0.3.0",
39
40
  "react": "19.2.7"
40
41
  },
41
- "contentHash": "fd37397dfd33215ffcf0cf1322b0d7dccca1ebe171a0d06a07e866666346a3c3",
42
+ "contentHash": "57db9149531e3285b2a843ae6a4aa181be750a067e0e142a0c2792920d1f0a36",
42
43
  "files": [
43
44
  {
44
45
  "path": "i18n.ts",
@@ -46,19 +47,15 @@
46
47
  },
47
48
  {
48
49
  "path": "index.ts",
49
- "content": "// @idosgames/mod-pvp-ui — PvP duels for any game: open duels, your duels, creating a duel for coins, a battle strategy and the fight's result.\n\nexport { pvpUiModule } from \"./module\";\n"
50
- },
51
- {
52
- "path": "model.test.ts",
53
- "content": "import { describe, expect, it } from \"vitest\";\nimport seed from \"../title-seed.json\";\nimport {\n BUILT_IN_RULE,\n clampEntry,\n duelRules,\n matchEntry,\n outcomeFor,\n roundLines,\n strategyOrDefault,\n winnerTakes,\n} from \"./model\";\n\nconst defs = (seed as unknown as { Match: never }).Match;\n\ndescribe(\"pvp\", () => {\n it(\"reads the seeded duel: coins from 10 to 500, 10% burned\", () => {\n const [duel] = duelRules(defs, \"CO\");\n expect(duel).toMatchObject({\n id: \"duel\",\n name: \"Duel\",\n currency: \"CO\",\n min: 10,\n max: 500,\n burnRate: 0.1,\n });\n expect(winnerTakes(50, duel!.burnRate)).toBe(90);\n expect(clampEntry(5, duel!)).toBe(10);\n expect(clampEntry(9999, duel!)).toBe(500);\n });\n\n it(\"a title without rules still has the built-in duel\", () => {\n expect(duelRules(null, \"CO\")).toEqual([\n {\n id: BUILT_IN_RULE,\n name: \"Duel\",\n description: null,\n currency: \"CO\",\n min: 1,\n max: Number.MAX_SAFE_INTEGER,\n burnRate: 0,\n },\n ]);\n });\n\n it(\"the saved strategy, capped at 10 rounds, or three rounds to start from\", () => {\n expect(strategyOrDefault(null)).toHaveLength(3);\n const saved = Array.from({ length: 12 }, () => ({\n AttackTarget: \"Legs\",\n DefenseTarget: \"Nonsense\",\n }));\n const steps = strategyOrDefault(saved);\n expect(steps).toHaveLength(10);\n expect(steps[0]).toEqual({ AttackTarget: \"Legs\", DefenseTarget: \"Head\" });\n });\n\n it(\"outcome and rounds from my side\", () => {\n expect(outcomeFor({ WinnerUserID: \"me\" }, \"me\")).toBe(\"win\");\n expect(outcomeFor({ WinnerUserID: \"you\" }, \"me\")).toBe(\"lose\");\n expect(outcomeFor({ IsDraw: true }, \"me\")).toBe(\"draw\");\n const lines = roundLines(\n [\n {\n RoundIndex: 1,\n AttackerID: \"me\",\n AttackZone: \"Torso\",\n HitType: \"Critical\",\n DamageDealt: 17.6,\n DefenderHpRemaining: 102.4,\n },\n ],\n \"me\",\n );\n expect(lines).toEqual([\n {\n round: 1,\n mine: true,\n zone: \"Torso\",\n hit: \"Critical\",\n damage: 18,\n hpLeft: 102,\n },\n ]);\n });\n\n it(\"reads a listed duel's entry\", () => {\n expect(\n matchEntry({ Entry: { Entries: [{ CurrencyID: \"CO\", Amount: 50 }] } }),\n ).toEqual({ currency: \"CO\", amount: 50 });\n expect(matchEntry({})).toEqual({ currency: null, amount: 0 });\n });\n});\n"
50
+ "content": "// @idosgames/mod-pvp — PvP duels for any game: open duels, your duels, creating a duel for coins, a battle strategy and the fight's result.\n\nexport { pvpModule } from \"./module\";\n"
54
51
  },
55
52
  {
56
53
  "path": "model.ts",
57
- "content": "// Pure helpers of the PvP screen (Unity Alikhan/PVP/Lobby): the title's duel rules and their entry\n// bounds, the strategy (attack / defence zone per round), the outcome of a fight and its rounds.\n// The server fights the battle — nothing here computes damage. Covered by model.test.ts.\n\nexport type Zone = \"Head\" | \"Torso\" | \"Legs\";\nexport const ZONES: Zone[] = [\"Head\", \"Torso\", \"Legs\"];\nexport type Step = { AttackTarget: Zone; DefenseTarget: Zone };\n\n/** The built-in rule every title has, used when the title configures none. */\nexport const BUILT_IN_RULE = \"InstantBattle1v1\";\n\nexport interface DuelRule {\n id: string;\n name: string;\n description: string | null;\n currency: string | null;\n min: number;\n max: number;\n burnRate: number;\n}\n\ntype RuleDef = {\n RuleID?: string | null;\n DisplayName?: string | null;\n Description?: string | null;\n Economy?: { BurnRate?: number | null } | null;\n Entry?: {\n Allowed?: Array<{\n Kind?: string | null;\n CurrencyID?: string | null;\n MinAmount?: number | null;\n MaxAmount?: number | null;\n } | null> | null;\n } | null;\n};\n\n/** The duel rules and their currency entry bounds (the first currency rule of the whitelist). */\nexport function duelRules(\n defs:\n | {\n InstantBattle?: {\n Rules?: Record<string, RuleDef | null> | null;\n } | null;\n }\n | null\n | undefined,\n fallbackCurrency: string | null,\n): DuelRule[] {\n const rules = Object.entries(defs?.InstantBattle?.Rules ?? {})\n .filter(([, r]) => r)\n .map(([id, r]) => {\n const allowed = (r!.Entry?.Allowed ?? []).find(\n (a) => a?.Kind === \"VirtualCurrency\",\n );\n return {\n id: r!.RuleID || id,\n name: r!.DisplayName || id,\n description: r!.Description ?? null,\n currency: allowed?.CurrencyID ?? fallbackCurrency,\n min: Math.max(1, Number(allowed?.MinAmount ?? 1)),\n max:\n Number(allowed?.MaxAmount ?? 0) > 0\n ? Number(allowed!.MaxAmount)\n : Number.MAX_SAFE_INTEGER,\n burnRate: Number(r!.Economy?.BurnRate ?? 0),\n } satisfies DuelRule;\n });\n return rules.length > 0\n ? rules\n : [\n {\n id: BUILT_IN_RULE,\n name: \"Duel\",\n description: null,\n currency: fallbackCurrency,\n min: 1,\n max: Number.MAX_SAFE_INTEGER,\n burnRate: 0,\n },\n ];\n}\n\n/** What the winner takes: both entries minus the burn. */\nexport function winnerTakes(entry: number, burnRate: number): number {\n return Math.floor(entry * 2 * (1 - Math.min(1, Math.max(0, burnRate))));\n}\n\nexport function clampEntry(value: number, rule: DuelRule): number {\n return Math.min(rule.max, Math.max(rule.min, Math.floor(value)));\n}\n\n/** The entry of a listed match: its first currency line. */\nexport function matchEntry(match: {\n Entry?: {\n Entries?: Array<{\n CurrencyID?: string | null;\n Amount?: number | null;\n } | null> | null;\n } | null;\n}): { currency: string | null; amount: number } {\n const e = match.Entry?.Entries?.find((x) => x?.CurrencyID);\n return { currency: e?.CurrencyID ?? null, amount: Number(e?.Amount ?? 0) };\n}\n\n/** The saved strategy, or three sensible rounds to start from. */\nexport function strategyOrDefault(\n saved:\n | ReadonlyArray<{\n AttackTarget?: string | null;\n DefenseTarget?: string | null;\n }>\n | null\n | undefined,\n): Step[] {\n const steps = (saved ?? [])\n .map((s) => ({\n AttackTarget: asZone(s.AttackTarget),\n DefenseTarget: asZone(s.DefenseTarget),\n }))\n .slice(0, 10);\n return steps.length > 0\n ? steps\n : [\n { AttackTarget: \"Head\", DefenseTarget: \"Torso\" },\n { AttackTarget: \"Torso\", DefenseTarget: \"Head\" },\n { AttackTarget: \"Legs\", DefenseTarget: \"Torso\" },\n ];\n}\n\nfunction asZone(z: string | null | undefined): Zone {\n return z === \"Torso\" || z === \"Legs\" ? z : \"Head\";\n}\n\nexport type Outcome = \"win\" | \"lose\" | \"draw\";\n\nexport function outcomeFor(\n battle:\n | { WinnerUserID?: string | null; IsDraw?: boolean | null }\n | null\n | undefined,\n me: string | null | undefined,\n): Outcome {\n if (!battle || battle.IsDraw || !battle.WinnerUserID) return \"draw\";\n return battle.WinnerUserID === me ? \"win\" : \"lose\";\n}\n\nexport interface RoundLine {\n round: number;\n mine: boolean;\n zone: Zone;\n hit: \"Hit\" | \"Critical\" | \"Block\" | \"Dodge\";\n damage: number;\n hpLeft: number;\n}\n\n/** The fight round by round, from my side: whose blow, where, how it landed, HP left. */\nexport function roundLines(\n log:\n | ReadonlyArray<{\n RoundIndex?: number | null;\n AttackerID?: string | null;\n AttackZone?: string | null;\n HitType?: string | null;\n DamageDealt?: number | null;\n DefenderHpRemaining?: number | null;\n }>\n | null\n | undefined,\n me: string | null | undefined,\n): RoundLine[] {\n return (log ?? []).map((e, i) => ({\n // The engine counts rounds from 1.\n round: Number(e.RoundIndex ?? i + 1),\n mine: e.AttackerID === me,\n zone: asZone(e.AttackZone),\n hit:\n e.HitType === \"Critical\" || e.HitType === \"Block\" || e.HitType === \"Dodge\"\n ? e.HitType\n : \"Hit\",\n damage: Math.round(Number(e.DamageDealt ?? 0)),\n hpLeft: Math.max(0, Math.round(Number(e.DefenderHpRemaining ?? 0))),\n }));\n}\n"
54
+ "content": "// Pure helpers of the PvP screen (Unity Alikhan/PVP/Lobby): the title's duel rules and their entry\n// bounds, the strategy (attack / defence zone per round), the outcome of a fight and its rounds.\n// The server fights the battle — nothing here computes damage. Covered by model.test.ts.\n\nexport type Zone = \"Head\" | \"Torso\" | \"Legs\";\nexport const ZONES: Zone[] = [\"Head\", \"Torso\", \"Legs\"];\nexport type Step = { AttackTarget: Zone; DefenseTarget: Zone };\n\n/** The built-in rule every title has, used when the title configures none. */\nexport const BUILT_IN_RULE = \"InstantBattle1v1\";\n\nexport interface DuelRule {\n id: string;\n name: string;\n description: string | null;\n currency: string | null;\n min: number;\n max: number;\n burnRate: number;\n}\n\ntype RuleDef = {\n RuleID?: string | null;\n DisplayName?: string | null;\n Description?: string | null;\n Economy?: { BurnRate?: number | null } | null;\n Entry?: {\n Allowed?: Array<{\n Kind?: string | null;\n CurrencyID?: string | null;\n MinAmount?: number | null;\n MaxAmount?: number | null;\n } | null> | null;\n } | null;\n};\n\n/** The duel rules and their currency entry bounds (the first currency rule of the whitelist). */\nexport function duelRules(\n defs:\n | {\n InstantBattle?: {\n Rules?: Record<string, RuleDef | null> | null;\n } | null;\n }\n | null\n | undefined,\n fallbackCurrency: string | null,\n): DuelRule[] {\n const rules = Object.entries(defs?.InstantBattle?.Rules ?? {})\n .filter(([, r]) => r)\n .map(([id, r]) => {\n const allowed = (r!.Entry?.Allowed ?? []).find(\n (a) => a?.Kind === \"VirtualCurrency\",\n );\n return {\n id: r!.RuleID || id,\n name: r!.DisplayName || id,\n description: r!.Description ?? null,\n currency: allowed?.CurrencyID ?? fallbackCurrency,\n min: Math.max(1, Number(allowed?.MinAmount ?? 1)),\n max:\n Number(allowed?.MaxAmount ?? 0) > 0\n ? Number(allowed!.MaxAmount)\n : Number.MAX_SAFE_INTEGER,\n burnRate: Number(r!.Economy?.BurnRate ?? 0),\n } satisfies DuelRule;\n });\n return rules.length > 0\n ? rules\n : [\n {\n id: BUILT_IN_RULE,\n name: \"Duel\",\n description: null,\n currency: fallbackCurrency,\n min: 1,\n max: Number.MAX_SAFE_INTEGER,\n burnRate: 0,\n },\n ];\n}\n\n/** What the winner takes: both entries minus the burn. */\nexport function winnerTakes(entry: number, burnRate: number): number {\n return Math.floor(entry * 2 * (1 - Math.min(1, Math.max(0, burnRate))));\n}\n\nexport function clampEntry(value: number, rule: DuelRule): number {\n return Math.min(rule.max, Math.max(rule.min, Math.floor(value)));\n}\n\n/** The entry of a listed match: its first currency line. */\nexport function matchEntry(match: {\n Entry?: {\n Entries?: Array<{\n CurrencyID?: string | null;\n Amount?: number | null;\n } | null> | null;\n } | null;\n}): { currency: string | null; amount: number } {\n const e = match.Entry?.Entries?.find((x) => x?.CurrencyID);\n return { currency: e?.CurrencyID ?? null, amount: Number(e?.Amount ?? 0) };\n}\n\n/** The saved strategy, or three sensible rounds to start from. */\nexport function strategyOrDefault(\n saved:\n | ReadonlyArray<{\n AttackTarget?: string | null;\n DefenseTarget?: string | null;\n }>\n | null\n | undefined,\n): Step[] {\n const steps = (saved ?? [])\n .map((s) => ({\n AttackTarget: asZone(s.AttackTarget),\n DefenseTarget: asZone(s.DefenseTarget),\n }))\n .slice(0, 10);\n return steps.length > 0\n ? steps\n : [\n { AttackTarget: \"Head\", DefenseTarget: \"Torso\" },\n { AttackTarget: \"Torso\", DefenseTarget: \"Head\" },\n { AttackTarget: \"Legs\", DefenseTarget: \"Torso\" },\n ];\n}\n\nfunction asZone(z: string | null | undefined): Zone {\n return z === \"Torso\" || z === \"Legs\" ? z : \"Head\";\n}\n\nexport type Outcome = \"win\" | \"lose\" | \"draw\";\n\nexport function outcomeFor(\n battle:\n | { WinnerUserID?: string | null; IsDraw?: boolean | null }\n | null\n | undefined,\n me: string | null | undefined,\n): Outcome {\n if (!battle || battle.IsDraw || !battle.WinnerUserID) return \"draw\";\n return battle.WinnerUserID === me ? \"win\" : \"lose\";\n}\n\nexport interface RoundLine {\n round: number;\n mine: boolean;\n zone: Zone;\n hit: \"Hit\" | \"Critical\" | \"Block\" | \"Dodge\";\n damage: number;\n hpLeft: number;\n}\n\n/** The fight round by round, from my side: whose blow, where, how it landed, HP left. */\nexport function roundLines(\n log:\n | ReadonlyArray<{\n RoundIndex?: number | null;\n AttackerID?: string | null;\n AttackZone?: string | null;\n HitType?: string | null;\n DamageDealt?: number | null;\n DefenderHpRemaining?: number | null;\n }>\n | null\n | undefined,\n me: string | null | undefined,\n): RoundLine[] {\n return (log ?? []).map((e, i) => ({\n // The engine counts rounds from 1.\n round: Number(e.RoundIndex ?? i + 1),\n mine: e.AttackerID === me,\n zone: asZone(e.AttackZone),\n hit:\n e.HitType === \"Critical\" || e.HitType === \"Block\" || e.HitType === \"Dodge\"\n ? e.HitType\n : \"Hit\",\n damage: Math.round(Number(e.DamageDealt ?? 0)),\n hpLeft: Math.max(0, Math.round(Number(e.DefenderHpRemaining ?? 0))),\n }));\n}\n\n/** Whether the title has a duel rule — until it does, the lobby hides the Arena tab. */\nexport function hasTitleData(\n section:\n | { InstantBattle?: { Rules?: Record<string, unknown> | null } | null }\n | null\n | undefined,\n): boolean {\n return Object.keys(section?.InstantBattle?.Rules ?? {}).length > 0;\n}\n"
58
55
  },
59
56
  {
60
57
  "path": "module.ts",
61
- "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { makePvpScreen } from \"./PvpScreen\";\nimport { t } from \"./i18n\";\n\n// PvP duels as a feature of the game: open duels to join, my duels, and creating one. The fight is\n// resolved instantly by the server from the heroes' stats and the saved strategy.\nexport const pvpUiModule: Module = defineModule({\n id: \"pvp-ui\",\n meta: { name: \"Arena\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"pvp\",\n label: t(\"title\"),\n icon: \"swords\",\n group: \"social\",\n order: 95,\n Screen: makePvpScreen(ctx.features),\n });\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"pvp\" }),\n describeActions: {},\n });\n },\n});\n"
58
+ "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { configSection } from \"@idosgames/react/ui\";\nimport { makePvpScreen } from \"./PvpScreen\";\nimport { hasTitleData } from \"./model\";\nimport { t } from \"./i18n\";\n\n// PvP duels as a feature of the game: open duels to join, my duels, and creating one. The fight is\n// resolved instantly by the server from the heroes' stats and the saved strategy.\nexport const pvpModule: Module = defineModule({\n id: \"pvp\",\n meta: { name: \"Arena\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"pvp\",\n label: t(\"title\"),\n icon: \"swords\",\n group: \"social\",\n order: 95,\n available: false,\n Screen: makePvpScreen(ctx.features),\n });\n ctx.features.setAvailable(\n \"pvp\",\n hasTitleData(\n configSection<Parameters<typeof hasTitleData>[0]>(ctx.client, \"Match\"),\n ),\n \"no duel rules on the title (Match.InstantBattle.Rules)\",\n );\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"pvp\" }),\n describeActions: {},\n });\n },\n});\n"
62
59
  },
63
60
  {
64
61
  "path": "PvpScreen.tsx",
@@ -66,7 +63,7 @@
66
63
  },
67
64
  {
68
65
  "path": "module.meta.json",
69
- "content": "{\n \"id\": \"pvp-ui\",\n \"type\": \"feature\",\n \"summary\": \"PvP duels for any game: open duels to join, your duels, creating a duel for coins, a zone strategy and the fight replayed round by round.\",\n \"description\": \"A ready PvP arena (Unity Alikhan/PVP/Lobby) for ANY game, built on the @idosgames/react/ui kit, client.match and client.character. Tabs: open duels (join with one of your heroes) and your duels (waiting — cancel returns the entry; finished — won / lost / draw). Popups: a new duel (rule, hero by power, entry within the rule's bounds and what the winner takes after the burn), the battle strategy (attack and defence zone — head, body, legs — per round, saved as the default) and the result, replayed round by round (whose blow, where, hit / critical / block / dodge, damage, HP left). The server fights the battle instantly from the heroes' stats; a draw returns the entries. Without a hero the screen offers the Heroes feature. Registers the 'pvp' feature. Ships title-seed.json: a coin duel (10–500, 10% burned) fought by the showcase heroes' Attack and Health.\",\n \"provides\": [\n \"open duels to join\",\n \"my duels with cancel\",\n \"create a duel with an entry\",\n \"zone battle strategy\",\n \"fight result replayed round by round\",\n \"demo duel rule\"\n ],\n \"tags\": [\"pvp\", \"duel\", \"arena\", \"battle\", \"matchmaking\", \"ui\", \"screen\"],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.0\"\n}\n"
66
+ "content": "{\n \"id\": \"pvp\",\n \"type\": \"feature\",\n \"inBase\": true,\n \"summary\": \"PvP duels for any game: open duels to join, your duels, creating a duel for coins, a zone strategy and the fight replayed round by round.\",\n \"description\": \"A ready PvP arena (Unity Alikhan/PVP/Lobby) for ANY game, built on the @idosgames/react/ui kit, client.match and client.character. Tabs: open duels (join with one of your heroes) and your duels (waiting — cancel returns the entry; finished — won / lost / draw). Popups: a new duel (rule, hero by power, entry within the rule's bounds and what the winner takes after the burn), the battle strategy (attack and defence zone — head, body, legs — per round, saved as the default) and the result, replayed round by round (whose blow, where, hit / critical / block / dodge, damage, HP left). The server fights the battle instantly from the heroes' stats; a draw returns the entries. Without a hero the screen offers the Heroes feature. Registers the 'pvp' feature. Ships title-seed.json: a coin duel (10–500, 10% burned) fought by the demo heroes' Attack and Health.\",\n \"provides\": [\n \"open duels to join\",\n \"my duels with cancel\",\n \"create a duel with an entry\",\n \"zone battle strategy\",\n \"fight result replayed round by round\",\n \"demo duel rule\"\n ],\n \"tags\": [\"pvp\", \"duel\", \"arena\", \"battle\", \"matchmaking\", \"ui\", \"screen\"],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.0\"\n}\n"
70
67
  }
71
68
  ]
72
69
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "id": "rewards-ui",
2
+ "id": "rewards",
3
3
  "meta": {
4
4
  "name": "Rewards",
5
5
  "type": "app",
@@ -7,6 +7,7 @@
7
7
  },
8
8
  "catalog": {
9
9
  "type": "feature",
10
+ "inBase": true,
10
11
  "summary": "Rewards screen for any game: a daily login calendar that greets the player, quests by cycle with details and the quest points track.",
11
12
  "description": "A ready rewards screen for ANY game, built on the @idosgames/react/ui kit. A daily login calendar (collected / today / locked cards, the last day a big milestone) that pops up by itself once per session while a reward is waiting (like DailyBonusAutoOpener in the Unity project), and the title's quests: cycles as tabs (daily, weekly, achievements), progress bars, a quest details popup with every goal, 'Claim all' for finished quests, and the quest points track with chests to claim along it. Registers the 'rewards' feature; its badge counts the daily bonus and finished quests. Ships title-seed.json: a 7-day calendar and four daily quests.",
12
13
  "provides": [
@@ -37,11 +38,11 @@
37
38
  },
38
39
  "dependencies": {
39
40
  "@idosgames/core": "0.19.0",
40
- "@idosgames/module-sdk": "0.5.0",
41
+ "@idosgames/module-sdk": "0.6.0",
41
42
  "@idosgames/react": "0.3.0",
42
43
  "react": "19.2.7"
43
44
  },
44
- "contentHash": "d1668715eb73258f9b6e9f3b24159cf369fac3954d1ccf74bab6c3c5fe674944",
45
+ "contentHash": "f7f8672832798aa56674aa1a5705fac1a036a8dd7cd6febfd3a930bd953b5b41",
45
46
  "files": [
46
47
  {
47
48
  "path": "i18n.ts",
@@ -49,19 +50,15 @@
49
50
  },
50
51
  {
51
52
  "path": "index.ts",
52
- "content": "// @idosgames/mod-rewards-ui — Rewards screen for any game: the daily login calendar that greets the player, quests by cycle with details and the quest points track.\n\nexport { rewardsUiModule } from \"./module\";\n"
53
- },
54
- {
55
- "path": "model.test.ts",
56
- "content": "import { describe, expect, it } from \"vitest\";\nimport seed from \"../title-seed.json\";\nimport {\n calendarColumns,\n claimableRewards,\n dailyView,\n lastCardSpan,\n pickCalendar,\n questRows,\n questTabs,\n rewardsBadge,\n trackMilestones,\n type DailyCalendarView,\n type QuestSection,\n type RewardSection,\n} from \"./model\";\n\n// The helpers run against the module's OWN demo settings (title-seed.json, generated by the engine\n// from the fixture it plays through for real). If the seed changes shape, these say which part of\n// the screen would stop drawing it.\n\ndescribe(\"daily calendar\", () => {\n const calendar = pickCalendar(\n seed.Reward as unknown as RewardSection,\n ) as DailyCalendarView;\n const now = new Date(\"2026-09-27T12:00:00Z\");\n\n it(\"a new player can claim day 1\", () => {\n const v = dailyView(calendar, undefined, now);\n expect(v.canClaim).toBe(true);\n expect(v.days.map((d) => d.state)).toEqual([\n \"today\",\n \"locked\",\n \"locked\",\n \"locked\",\n \"locked\",\n \"locked\",\n \"locked\",\n ]);\n });\n\n it(\"after today's claim the next day waits for tomorrow (UTC)\", () => {\n const v = dailyView(\n calendar,\n { CollectedDays: 1, LastClaimAt: \"2026-09-27T08:00:00Z\" },\n now,\n );\n expect(v.canClaim).toBe(false);\n expect(v.days[0]?.state).toBe(\"collected\");\n const tomorrow = dailyView(\n calendar,\n { CollectedDays: 1, LastClaimAt: \"2026-09-26T23:00:00Z\" },\n now,\n );\n expect(tomorrow.days[1]?.state).toBe(\"today\");\n });\n\n it(\"a looping calendar shows the full row right after the last day\", () => {\n const v = dailyView(\n calendar,\n { CollectedDays: 7, LastClaimAt: \"2026-09-27T08:00:00Z\" },\n now,\n );\n expect(v.days.every((d) => d.state === \"collected\")).toBe(true);\n expect(\n dailyView(\n calendar,\n { CollectedDays: 7, LastClaimAt: \"2026-09-26T08:00:00Z\" },\n now,\n ).days[0]?.state,\n ).toBe(\"today\");\n });\n\n it(\"the server's MinValue date means never claimed\", () => {\n expect(\n dailyView(\n calendar,\n { CollectedDays: 0, LastClaimAt: \"0001-01-01T00:00:00Z\" },\n now,\n ).canClaim,\n ).toBe(true);\n });\n\n it(\"the week is one row only on a tablet; the last card closes a short row\", () => {\n expect(calendarColumns(\"phone\", false, 7)).toBe(4);\n expect(calendarColumns(\"tablet\", false, 7)).toBe(7);\n expect(calendarColumns(\"desktop\", false, 7)).toBe(4);\n expect(calendarColumns(\"tablet\", true, 7)).toBe(4);\n expect(lastCardSpan(7, 4)).toBe(2);\n expect(lastCardSpan(7, 7)).toBe(1);\n expect(lastCardSpan(5, 4)).toBe(4);\n });\n});\n\ndescribe(\"quests\", () => {\n const section = seed.Quest as unknown as QuestSection;\n\n it(\"every seeded quest draws with its goals and reward, ready ones first\", () => {\n const rows = questRows(section, {\n Cycles: {\n daily: {\n Quests: {\n daily_shop: {\n Status: \"Completed\",\n Objectives: { buy: { CurrentValue: 1, Completed: true } },\n },\n daily_spend: {\n Status: \"Active\",\n Objectives: { spend: { CurrentValue: 120, Completed: false } },\n },\n },\n },\n },\n });\n expect(rows[0]).toMatchObject({\n questID: \"daily_shop\",\n status: \"Completed\",\n cycleID: \"daily\",\n });\n expect(rows.find((r) => r.questID === \"daily_spend\")).toMatchObject({\n progress: 120,\n target: 200,\n });\n for (const row of rows) {\n expect(row.reward.length).toBeGreaterThan(0);\n expect(row.objectives.length).toBeGreaterThan(0);\n }\n });\n\n it(\"every seeded quest also moves the season, and its reward says by how much\", () => {\n const rows = questRows(section, null);\n expect(rows.length).toBeGreaterThan(0);\n expect(\n rows.every((r) =>\n r.reward.some(\n (l) =>\n l.kind === \"token\" && l.id === \"Season:main\" && l.amount === 10,\n ),\n ),\n ).toBe(true);\n });\n\n it(\"tabs are the cycles that have quests; claim-all previews the finished ones\", () => {\n const rows = questRows(section, {\n Cycles: { daily: { Quests: { daily_shop: { Status: \"Completed\" } } } },\n });\n expect(questTabs(section, rows).map((t) => t.id)).toEqual([\"daily\"]);\n expect(claimableRewards(rows).length).toBeGreaterThan(0);\n expect(rewardsBadge(true, rows, 1)).toBe(3);\n });\n\n it(\"the points track marks what is claimable\", () => {\n const cycle = {\n Milestones: {\n a: { RequiredProgress: 10 },\n b: { RequiredProgress: 30 },\n c: { RequiredProgress: 5 },\n },\n };\n expect(\n trackMilestones(cycle, 12, [\"c\"]).map((m) => [m.id, m.state]),\n ).toEqual([\n [\"c\", \"claimed\"],\n [\"a\", \"ready\"],\n [\"b\", \"locked\"],\n ]);\n expect(trackMilestones(null, 0, [])).toEqual([]);\n });\n});\n"
53
+ "content": "// @idosgames/mod-rewards — Rewards screen for any game: the daily login calendar that greets the player, quests by cycle with details and the quest points track.\n\nexport { rewardsModule } from \"./module\";\n"
57
54
  },
58
55
  {
59
56
  "path": "model.ts",
60
- "content": "// Pure helpers of the rewards screen: the daily calendar arithmetic, quest rows and the quest points\n// track. No React, no client calls — covered by model.test.ts against the module's own demo settings.\n\nimport type { ResourceGrant } from \"@idosgames/core\";\nimport {\n grantLines,\n mergeLines,\n type Layout,\n type ResourceLine,\n} from \"@idosgames/react/ui\";\n\n// ── Configuration (the fields this screen reads) ─────────────────────────────────────────────\n\nexport interface DailyDayView {\n DayNumber?: number;\n Rewards?: ResourceGrant | null;\n IsMilestone?: boolean;\n}\n\nexport interface DailyCalendarView {\n CalendarID?: string;\n DisplayName?: string | null;\n Days?: DailyDayView[] | null;\n IsLooping?: boolean;\n ClaimMode?: string;\n ClaimCooldownSeconds?: number;\n}\n\nexport interface RewardSection {\n DailyCalendars?: Record<string, DailyCalendarView | null> | null;\n}\n\nexport interface QuestObjectiveView {\n ObjectiveID?: string;\n DisplayName?: string | null;\n MetricID?: string | null;\n TargetValue?: number;\n Source?: string;\n}\n\nexport interface QuestDefinitionView {\n QuestID?: string;\n Identity?: {\n DisplayName?: string | null;\n Description?: string | null;\n } | null;\n Linking?: { CycleIDs?: string[] | null } | null;\n Objectives?: Record<string, QuestObjectiveView | null> | null;\n Reward?: { Grant?: ResourceGrant | null } | null;\n}\n\nexport interface QuestMilestoneView {\n MilestoneID?: string | null;\n DisplayName?: string | null;\n RequiredProgress?: number | null;\n Rewards?: ResourceGrant | null;\n}\n\nexport interface QuestCycleView {\n CycleID?: string;\n DisplayName?: string | null;\n Milestones?: Record<string, QuestMilestoneView | null> | null;\n}\n\nexport interface QuestSection {\n Cycles?: Record<string, QuestCycleView | null> | null;\n Quests?: Record<string, QuestDefinitionView | null> | null;\n}\n\nexport interface QuestProgressView {\n Status?: string;\n Objectives?: Record<\n string,\n { ObjectiveID?: string; CurrentValue?: number; Completed?: boolean } | null\n > | null;\n}\n\nexport interface QuestStateView {\n Cycles?: Record<\n string,\n {\n Quests?: Record<string, QuestProgressView | null> | null;\n CycleEndUtc?: string | null;\n } | null\n > | null;\n PermanentQuests?: Record<string, QuestProgressView | null> | null;\n}\n\n// ── Daily calendar ───────────────────────────────────────────────────────────────────────────\n\nexport function pickCalendar(\n section: RewardSection | undefined,\n): (DailyCalendarView & { CalendarID: string }) | null {\n const all = Object.entries(section?.DailyCalendars ?? {})\n .map(([id, def]) =>\n def ? { ...def, CalendarID: def.CalendarID ?? id } : null,\n )\n .filter(\n (d): d is DailyCalendarView & { CalendarID: string } =>\n d !== null && (d.Days?.length ?? 0) > 0,\n )\n .sort((a, b) => a.CalendarID.localeCompare(b.CalendarID));\n return all[0] ?? null;\n}\n\nexport interface DailyView {\n days: Array<{ day: DailyDayView; state: \"collected\" | \"today\" | \"locked\" }>;\n canClaim: boolean;\n collected: number;\n}\n\n/**\n * Calendar state for drawing — the rule of the Unity DailyBonusWindowController: never claimed →\n * claimable; \"CalendarDayUtc\" → once per UTC date; \"SlidingWindow\" → after the cooldown. The\n * server is the authority — this only decides which card glows.\n */\nexport function dailyView(\n calendar: DailyCalendarView,\n state: { CollectedDays?: number; LastClaimAt?: string } | null | undefined,\n now: Date,\n): DailyView {\n const days = [...(calendar.Days ?? [])].sort(\n (a, b) => (a.DayNumber ?? 0) - (b.DayNumber ?? 0),\n );\n const total = days.length;\n const collectedAll = Math.max(0, Number(state?.CollectedDays ?? 0));\n\n const last = state?.LastClaimAt ? new Date(state.LastClaimAt) : null;\n const neverClaimed =\n !last || Number.isNaN(last.getTime()) || last.getUTCFullYear() < 2000;\n let canClaim: boolean;\n if (neverClaimed) canClaim = true;\n else if (calendar.ClaimMode === \"SlidingWindow\")\n canClaim =\n (now.getTime() - last.getTime()) / 1000 >=\n (calendar.ClaimCooldownSeconds ?? 0);\n else canClaim = utcDate(last) < utcDate(now);\n\n const finished = calendar.IsLooping === false && collectedAll >= total;\n if (finished) canClaim = false;\n\n const collected =\n total === 0\n ? 0\n : calendar.IsLooping === false\n ? Math.min(collectedAll, total)\n : collectedAll % total;\n // A loop that just wrapped shows the whole row as collected until the next claim opens.\n const shownCollected =\n !canClaim && collected === 0 && collectedAll > 0 ? total : collected;\n\n return {\n days: days.map((day, i) => ({\n day,\n state:\n i < shownCollected\n ? \"collected\"\n : i === shownCollected && canClaim\n ? \"today\"\n : \"locked\",\n })),\n canClaim,\n collected: shownCollected,\n };\n}\n\nfunction utcDate(d: Date): string {\n return d.toISOString().slice(0, 10);\n}\n\n/**\n * Columns of the calendar: the week in one row only on a tablet, where the calendar has the whole\n * width. A phone is too narrow, and on a desktop the calendar shares the screen with the quests\n * (two columns) — rows of four there, like in the popup.\n */\nexport function calendarColumns(\n layout: Layout,\n compact: boolean,\n days: number,\n): number {\n if (compact || layout !== \"tablet\") return Math.min(4, Math.max(1, days));\n return Math.min(7, Math.max(1, days));\n}\n\n/** How many columns the LAST card spans, so a short final row is filled (the big day 7). */\nexport function lastCardSpan(days: number, columns: number): number {\n const rest = days % columns;\n return rest === 0 ? 1 : columns - rest + 1;\n}\n\n// ── Quests ───────────────────────────────────────────────────────────────────────────────────\n\nexport interface ObjectiveRow {\n id: string;\n name: string;\n progress: number;\n target: number;\n done: boolean;\n}\n\nexport interface QuestRow {\n questID: string;\n /** undefined — a permanent quest. */\n cycleID: string | undefined;\n name: string;\n description: string;\n status: \"Active\" | \"Completed\" | \"Claimed\" | \"Expired\";\n progress: number;\n target: number;\n objectives: ObjectiveRow[];\n reward: ResourceLine[];\n}\n\n/**\n * Quests with the player's progress. One bar per quest is the SUM over its objectives against the\n * sum of targets (Unity quest window); the detail popup lists every objective.\n */\nexport function questRows(\n section: QuestSection | undefined,\n state: QuestStateView | null | undefined,\n): QuestRow[] {\n const rows: QuestRow[] = [];\n for (const [id, def] of Object.entries(section?.Quests ?? {})) {\n if (!def) continue;\n const questID = def.QuestID ?? id;\n const cycleID = def.Linking?.CycleIDs?.[0] ?? undefined;\n const progress = cycleID\n ? state?.Cycles?.[cycleID]?.Quests?.[questID]\n : state?.PermanentQuests?.[questID];\n const status = (progress?.Status ?? \"Active\") as QuestRow[\"status\"];\n\n const objectives: ObjectiveRow[] = Object.entries(def.Objectives ?? {})\n .filter(([, o]) => o)\n .map(([oid, o]) => {\n const objectiveID = o?.ObjectiveID ?? oid;\n const target = Math.max(1, Number(o?.TargetValue ?? 1));\n const p = progress?.Objectives?.[objectiveID];\n const done =\n status === \"Completed\" ||\n status === \"Claimed\" ||\n p?.Completed === true;\n return {\n id: objectiveID,\n name: o?.DisplayName || objectiveID,\n target,\n progress: done\n ? target\n : Math.min(target, Number(p?.CurrentValue ?? 0)),\n done,\n };\n });\n const target = objectives.reduce((s, o) => s + o.target, 0) || 1;\n const current = objectives.reduce((s, o) => s + o.progress, 0);\n\n rows.push({\n questID,\n cycleID,\n name: def.Identity?.DisplayName || questID,\n description: def.Identity?.Description || \"\",\n status,\n progress:\n status === \"Completed\" || status === \"Claimed\" ? target : current,\n target,\n objectives,\n reward: grantLines(def.Reward?.Grant),\n });\n }\n // Ready to claim first, then in progress, then done.\n const weight = { Completed: 0, Active: 1, Claimed: 2, Expired: 3 } as const;\n return rows.sort(\n (a, b) =>\n weight[a.status] - weight[b.status] || a.questID.localeCompare(b.questID),\n );\n}\n\n/** The quest tabs: cycles that have quests, then \"permanent\" when there are cycle-less quests. */\nexport function questTabs(\n section: QuestSection | undefined,\n rows: QuestRow[],\n): Array<{ id: string; name: string }> {\n const used = new Set(rows.map((r) => r.cycleID ?? \"\"));\n const tabs = Object.entries(section?.Cycles ?? {})\n .map(([id, c]) => ({ id: c?.CycleID ?? id, name: c?.DisplayName || id }))\n .filter((c) => used.has(c.id))\n .sort((a, b) => a.id.localeCompare(b.id));\n if (used.has(\"\")) tabs.push({ id: \"\", name: \"\" });\n return tabs;\n}\n\n/** Everything a \"Claim all\" collects (the reward preview of the batch). */\nexport function claimableRewards(rows: QuestRow[]): ResourceLine[] {\n return mergeLines(\n rows.filter((r) => r.status === \"Completed\").flatMap((r) => r.reward),\n );\n}\n\n// ── Points track ─────────────────────────────────────────────────────────────────────────────\n\nexport interface TrackMilestone {\n id: string;\n name: string;\n required: number;\n reward: ResourceLine[];\n state: \"claimed\" | \"ready\" | \"locked\";\n}\n\n/** Milestones of a cycle's quest points track (Unity QuestWindowMilestoneController). */\nexport function trackMilestones(\n cycle: QuestCycleView | null | undefined,\n points: number,\n claimed: readonly string[],\n): TrackMilestone[] {\n return Object.entries(cycle?.Milestones ?? {})\n .map(([id, m]) => ({ id: m?.MilestoneID ?? id, m }))\n .filter((x): x is { id: string; m: QuestMilestoneView } => !!x.m)\n .sort(\n (a, b) =>\n Number(a.m.RequiredProgress ?? 0) - Number(b.m.RequiredProgress ?? 0),\n )\n .map(({ id, m }) => {\n const required = Number(m.RequiredProgress ?? 0);\n return {\n id,\n name: m.DisplayName || id,\n required,\n reward: grantLines(m.Rewards),\n state: claimed.includes(id)\n ? \"claimed\"\n : points >= required\n ? \"ready\"\n : \"locked\",\n };\n });\n}\n\n/** Badge of the rewards screen: the daily bonus waiting + quests and track rewards ready. */\nexport function rewardsBadge(\n dailyReady: boolean,\n rows: QuestRow[],\n trackReady: number,\n): number {\n return (\n (dailyReady ? 1 : 0) +\n rows.filter((r) => r.status === \"Completed\").length +\n trackReady\n );\n}\n"
57
+ "content": "// Pure helpers of the rewards screen: the daily calendar arithmetic, quest rows and the quest points\n// track. No React, no client calls — covered by model.test.ts against the module's own demo settings.\n\nimport type { ResourceGrant } from \"@idosgames/core\";\nimport {\n grantLines,\n mergeLines,\n type Layout,\n type ResourceLine,\n} from \"@idosgames/react/ui\";\n\n// ── Configuration (the fields this screen reads) ─────────────────────────────────────────────\n\nexport interface DailyDayView {\n DayNumber?: number;\n Rewards?: ResourceGrant | null;\n IsMilestone?: boolean;\n}\n\nexport interface DailyCalendarView {\n CalendarID?: string;\n DisplayName?: string | null;\n Days?: DailyDayView[] | null;\n IsLooping?: boolean;\n ClaimMode?: string;\n ClaimCooldownSeconds?: number;\n}\n\nexport interface RewardSection {\n DailyCalendars?: Record<string, DailyCalendarView | null> | null;\n}\n\nexport interface QuestObjectiveView {\n ObjectiveID?: string;\n DisplayName?: string | null;\n MetricID?: string | null;\n TargetValue?: number;\n Source?: string;\n}\n\nexport interface QuestDefinitionView {\n QuestID?: string;\n Identity?: {\n DisplayName?: string | null;\n Description?: string | null;\n } | null;\n Linking?: { CycleIDs?: string[] | null } | null;\n Objectives?: Record<string, QuestObjectiveView | null> | null;\n Reward?: { Grant?: ResourceGrant | null } | null;\n}\n\nexport interface QuestMilestoneView {\n MilestoneID?: string | null;\n DisplayName?: string | null;\n RequiredProgress?: number | null;\n Rewards?: ResourceGrant | null;\n}\n\nexport interface QuestCycleView {\n CycleID?: string;\n DisplayName?: string | null;\n Milestones?: Record<string, QuestMilestoneView | null> | null;\n}\n\nexport interface QuestSection {\n Cycles?: Record<string, QuestCycleView | null> | null;\n Quests?: Record<string, QuestDefinitionView | null> | null;\n}\n\nexport interface QuestProgressView {\n Status?: string;\n Objectives?: Record<\n string,\n { ObjectiveID?: string; CurrentValue?: number; Completed?: boolean } | null\n > | null;\n}\n\nexport interface QuestStateView {\n Cycles?: Record<\n string,\n {\n Quests?: Record<string, QuestProgressView | null> | null;\n CycleEndUtc?: string | null;\n } | null\n > | null;\n PermanentQuests?: Record<string, QuestProgressView | null> | null;\n}\n\n// ── Daily calendar ───────────────────────────────────────────────────────────────────────────\n\nexport function pickCalendar(\n section: RewardSection | undefined,\n): (DailyCalendarView & { CalendarID: string }) | null {\n const all = Object.entries(section?.DailyCalendars ?? {})\n .map(([id, def]) =>\n def ? { ...def, CalendarID: def.CalendarID ?? id } : null,\n )\n .filter(\n (d): d is DailyCalendarView & { CalendarID: string } =>\n d !== null && (d.Days?.length ?? 0) > 0,\n )\n .sort((a, b) => a.CalendarID.localeCompare(b.CalendarID));\n return all[0] ?? null;\n}\n\nexport interface DailyView {\n days: Array<{ day: DailyDayView; state: \"collected\" | \"today\" | \"locked\" }>;\n canClaim: boolean;\n collected: number;\n}\n\n/**\n * Calendar state for drawing — the rule of the Unity DailyBonusWindowController: never claimed →\n * claimable; \"CalendarDayUtc\" → once per UTC date; \"SlidingWindow\" → after the cooldown. The\n * server is the authority — this only decides which card glows.\n */\nexport function dailyView(\n calendar: DailyCalendarView,\n state: { CollectedDays?: number; LastClaimAt?: string } | null | undefined,\n now: Date,\n): DailyView {\n const days = [...(calendar.Days ?? [])].sort(\n (a, b) => (a.DayNumber ?? 0) - (b.DayNumber ?? 0),\n );\n const total = days.length;\n const collectedAll = Math.max(0, Number(state?.CollectedDays ?? 0));\n\n const last = state?.LastClaimAt ? new Date(state.LastClaimAt) : null;\n const neverClaimed =\n !last || Number.isNaN(last.getTime()) || last.getUTCFullYear() < 2000;\n let canClaim: boolean;\n if (neverClaimed) canClaim = true;\n else if (calendar.ClaimMode === \"SlidingWindow\")\n canClaim =\n (now.getTime() - last.getTime()) / 1000 >=\n (calendar.ClaimCooldownSeconds ?? 0);\n else canClaim = utcDate(last) < utcDate(now);\n\n const finished = calendar.IsLooping === false && collectedAll >= total;\n if (finished) canClaim = false;\n\n const collected =\n total === 0\n ? 0\n : calendar.IsLooping === false\n ? Math.min(collectedAll, total)\n : collectedAll % total;\n // A loop that just wrapped shows the whole row as collected until the next claim opens.\n const shownCollected =\n !canClaim && collected === 0 && collectedAll > 0 ? total : collected;\n\n return {\n days: days.map((day, i) => ({\n day,\n state:\n i < shownCollected\n ? \"collected\"\n : i === shownCollected && canClaim\n ? \"today\"\n : \"locked\",\n })),\n canClaim,\n collected: shownCollected,\n };\n}\n\nfunction utcDate(d: Date): string {\n return d.toISOString().slice(0, 10);\n}\n\n/**\n * Columns of the calendar: the week in one row only on a tablet, where the calendar has the whole\n * width. A phone is too narrow, and on a desktop the calendar shares the screen with the quests\n * (two columns) — rows of four there, like in the popup.\n */\nexport function calendarColumns(\n layout: Layout,\n compact: boolean,\n days: number,\n): number {\n if (compact || layout !== \"tablet\") return Math.min(4, Math.max(1, days));\n return Math.min(7, Math.max(1, days));\n}\n\n/** How many columns the LAST card spans, so a short final row is filled (the big day 7). */\nexport function lastCardSpan(days: number, columns: number): number {\n const rest = days % columns;\n return rest === 0 ? 1 : columns - rest + 1;\n}\n\n// ── Quests ───────────────────────────────────────────────────────────────────────────────────\n\nexport interface ObjectiveRow {\n id: string;\n name: string;\n progress: number;\n target: number;\n done: boolean;\n}\n\nexport interface QuestRow {\n questID: string;\n /** undefined — a permanent quest. */\n cycleID: string | undefined;\n name: string;\n description: string;\n status: \"Active\" | \"Completed\" | \"Claimed\" | \"Expired\";\n progress: number;\n target: number;\n objectives: ObjectiveRow[];\n reward: ResourceLine[];\n}\n\n/**\n * Quests with the player's progress. One bar per quest is the SUM over its objectives against the\n * sum of targets (Unity quest window); the detail popup lists every objective.\n */\nexport function questRows(\n section: QuestSection | undefined,\n state: QuestStateView | null | undefined,\n): QuestRow[] {\n const rows: QuestRow[] = [];\n for (const [id, def] of Object.entries(section?.Quests ?? {})) {\n if (!def) continue;\n const questID = def.QuestID ?? id;\n const cycleID = def.Linking?.CycleIDs?.[0] ?? undefined;\n const progress = cycleID\n ? state?.Cycles?.[cycleID]?.Quests?.[questID]\n : state?.PermanentQuests?.[questID];\n const status = (progress?.Status ?? \"Active\") as QuestRow[\"status\"];\n\n const objectives: ObjectiveRow[] = Object.entries(def.Objectives ?? {})\n .filter(([, o]) => o)\n .map(([oid, o]) => {\n const objectiveID = o?.ObjectiveID ?? oid;\n const target = Math.max(1, Number(o?.TargetValue ?? 1));\n const p = progress?.Objectives?.[objectiveID];\n const done =\n status === \"Completed\" ||\n status === \"Claimed\" ||\n p?.Completed === true;\n return {\n id: objectiveID,\n name: o?.DisplayName || objectiveID,\n target,\n progress: done\n ? target\n : Math.min(target, Number(p?.CurrentValue ?? 0)),\n done,\n };\n });\n const target = objectives.reduce((s, o) => s + o.target, 0) || 1;\n const current = objectives.reduce((s, o) => s + o.progress, 0);\n\n rows.push({\n questID,\n cycleID,\n name: def.Identity?.DisplayName || questID,\n description: def.Identity?.Description || \"\",\n status,\n progress:\n status === \"Completed\" || status === \"Claimed\" ? target : current,\n target,\n objectives,\n reward: grantLines(def.Reward?.Grant),\n });\n }\n // Ready to claim first, then in progress, then done.\n const weight = { Completed: 0, Active: 1, Claimed: 2, Expired: 3 } as const;\n return rows.sort(\n (a, b) =>\n weight[a.status] - weight[b.status] || a.questID.localeCompare(b.questID),\n );\n}\n\n/** The quest tabs: cycles that have quests, then \"permanent\" when there are cycle-less quests. */\nexport function questTabs(\n section: QuestSection | undefined,\n rows: QuestRow[],\n): Array<{ id: string; name: string }> {\n const used = new Set(rows.map((r) => r.cycleID ?? \"\"));\n const tabs = Object.entries(section?.Cycles ?? {})\n .map(([id, c]) => ({ id: c?.CycleID ?? id, name: c?.DisplayName || id }))\n .filter((c) => used.has(c.id))\n .sort((a, b) => a.id.localeCompare(b.id));\n if (used.has(\"\")) tabs.push({ id: \"\", name: \"\" });\n return tabs;\n}\n\n/** Everything a \"Claim all\" collects (the reward preview of the batch). */\nexport function claimableRewards(rows: QuestRow[]): ResourceLine[] {\n return mergeLines(\n rows.filter((r) => r.status === \"Completed\").flatMap((r) => r.reward),\n );\n}\n\n// ── Points track ─────────────────────────────────────────────────────────────────────────────\n\nexport interface TrackMilestone {\n id: string;\n name: string;\n required: number;\n reward: ResourceLine[];\n state: \"claimed\" | \"ready\" | \"locked\";\n}\n\n/** Milestones of a cycle's quest points track (Unity QuestWindowMilestoneController). */\nexport function trackMilestones(\n cycle: QuestCycleView | null | undefined,\n points: number,\n claimed: readonly string[],\n): TrackMilestone[] {\n return Object.entries(cycle?.Milestones ?? {})\n .map(([id, m]) => ({ id: m?.MilestoneID ?? id, m }))\n .filter((x): x is { id: string; m: QuestMilestoneView } => !!x.m)\n .sort(\n (a, b) =>\n Number(a.m.RequiredProgress ?? 0) - Number(b.m.RequiredProgress ?? 0),\n )\n .map(({ id, m }) => {\n const required = Number(m.RequiredProgress ?? 0);\n return {\n id,\n name: m.DisplayName || id,\n required,\n reward: grantLines(m.Rewards),\n state: claimed.includes(id)\n ? \"claimed\"\n : points >= required\n ? \"ready\"\n : \"locked\",\n };\n });\n}\n\n/** Badge of the rewards screen: the daily bonus waiting + quests and track rewards ready. */\nexport function rewardsBadge(\n dailyReady: boolean,\n rows: QuestRow[],\n trackReady: number,\n): number {\n return (\n (dailyReady ? 1 : 0) +\n rows.filter((r) => r.status === \"Completed\").length +\n trackReady\n );\n}\n\n/** Whether the title has a daily calendar or quests — until it does, the lobby hides the tab. */\nexport function hasTitleData(\n reward: RewardSection | undefined,\n quest: QuestSection | undefined,\n): boolean {\n return (\n Object.keys(reward?.DailyCalendars ?? {}).length > 0 ||\n Object.keys(quest?.Quests ?? {}).length > 0\n );\n}\n"
61
58
  },
62
59
  {
63
60
  "path": "module.ts",
64
- "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { RewardsScreen, makeRewardsWatcher } from \"./RewardsScreen\";\nimport { t } from \"./i18n\";\n\n// Rewards as a feature of the game: the daily calendar and the quests. The always-on watcher greets\n// the player with the daily bonus once per session and keeps the badge.\nexport const rewardsUiModule: Module = defineModule({\n id: \"rewards-ui\",\n meta: { name: \"Rewards\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"rewards\",\n label: t(\"title\"),\n icon: \"gift\",\n group: \"events\",\n order: 40,\n primary: true,\n Screen: RewardsScreen,\n });\n ctx.registerPanel({\n id: \"daily\",\n slot: \"modal\",\n activeOnly: false,\n component: makeRewardsWatcher(ctx.features),\n });\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"rewards\" }),\n describeActions: {},\n });\n },\n});\n"
61
+ "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { configSection } from \"@idosgames/react/ui\";\nimport { RewardsScreen, makeRewardsWatcher } from \"./RewardsScreen\";\nimport { hasTitleData, type QuestSection, type RewardSection } from \"./model\";\nimport { t } from \"./i18n\";\n\n// Rewards as a feature of the game: the daily calendar and the quests. The always-on watcher greets\n// the player with the daily bonus once per session and keeps the badge.\nexport const rewardsModule: Module = defineModule({\n id: \"rewards\",\n meta: { name: \"Rewards\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"rewards\",\n label: t(\"title\"),\n icon: \"gift\",\n group: \"events\",\n order: 40,\n primary: true,\n available: false,\n Screen: RewardsScreen,\n });\n ctx.features.setAvailable(\n \"rewards\",\n hasTitleData(\n configSection<RewardSection>(ctx.client, \"Reward\"),\n configSection<QuestSection>(ctx.client, \"Quest\"),\n ),\n \"no daily calendar (Reward) and no quests (Quest) on the title\",\n );\n ctx.registerPanel({\n id: \"daily\",\n slot: \"modal\",\n activeOnly: false,\n component: makeRewardsWatcher(ctx.features),\n });\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"rewards\" }),\n describeActions: {},\n });\n },\n});\n"
65
62
  },
66
63
  {
67
64
  "path": "RewardsScreen.tsx",
@@ -69,7 +66,7 @@
69
66
  },
70
67
  {
71
68
  "path": "module.meta.json",
72
- "content": "{\n \"id\": \"rewards-ui\",\n \"type\": \"feature\",\n \"summary\": \"Rewards screen for any game: a daily login calendar that greets the player, quests by cycle with details and the quest points track.\",\n \"description\": \"A ready rewards screen for ANY game, built on the @idosgames/react/ui kit. A daily login calendar (collected / today / locked cards, the last day a big milestone) that pops up by itself once per session while a reward is waiting (like DailyBonusAutoOpener in the Unity project), and the title's quests: cycles as tabs (daily, weekly, achievements), progress bars, a quest details popup with every goal, 'Claim all' for finished quests, and the quest points track with chests to claim along it. Registers the 'rewards' feature; its badge counts the daily bonus and finished quests. Ships title-seed.json: a 7-day calendar and four daily quests.\",\n \"provides\": [\n \"daily login rewards calendar\",\n \"daily bonus popup on entry\",\n \"quests by cycle with progress\",\n \"quest details popup\",\n \"claim all finished quests\",\n \"quest points track with milestone chests\",\n \"badge for waiting rewards\",\n \"demo daily calendar and quests\"\n ],\n \"tags\": [\n \"daily-rewards\",\n \"calendar\",\n \"quests\",\n \"missions\",\n \"achievements\",\n \"ui\",\n \"screen\",\n \"events\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.0\"\n}\n"
69
+ "content": "{\n \"id\": \"rewards\",\n \"type\": \"feature\",\n \"inBase\": true,\n \"summary\": \"Rewards screen for any game: a daily login calendar that greets the player, quests by cycle with details and the quest points track.\",\n \"description\": \"A ready rewards screen for ANY game, built on the @idosgames/react/ui kit. A daily login calendar (collected / today / locked cards, the last day a big milestone) that pops up by itself once per session while a reward is waiting (like DailyBonusAutoOpener in the Unity project), and the title's quests: cycles as tabs (daily, weekly, achievements), progress bars, a quest details popup with every goal, 'Claim all' for finished quests, and the quest points track with chests to claim along it. Registers the 'rewards' feature; its badge counts the daily bonus and finished quests. Ships title-seed.json: a 7-day calendar and four daily quests.\",\n \"provides\": [\n \"daily login rewards calendar\",\n \"daily bonus popup on entry\",\n \"quests by cycle with progress\",\n \"quest details popup\",\n \"claim all finished quests\",\n \"quest points track with milestone chests\",\n \"badge for waiting rewards\",\n \"demo daily calendar and quests\"\n ],\n \"tags\": [\n \"daily-rewards\",\n \"calendar\",\n \"quests\",\n \"missions\",\n \"achievements\",\n \"ui\",\n \"screen\",\n \"events\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.0\"\n}\n"
73
70
  }
74
71
  ]
75
72
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "id": "season-ui",
2
+ "id": "season",
3
3
  "meta": {
4
4
  "name": "Season",
5
5
  "type": "app",
@@ -7,8 +7,9 @@
7
7
  },
8
8
  "catalog": {
9
9
  "type": "feature",
10
+ "inBase": true,
10
11
  "summary": "Seasons for any game: the active season with its timer, a status bar, a tier ladder with a reward each, claim all and a VIP second prize.",
11
- "description": "A ready season screen (Unity Core/Window MilestoneRewards) for ANY game, built on the @idosgames/react/ui kit and client.season. Every season chain of the title is a tab showing its active season: name and timer, the player's tier, the status bar toward the next tier and the tier ladder. Each tier pays its reward once ('Claim all' takes every reached tier in one call); a tier with premium tiers pays a VIP second prize when the pass is active at the moment of the claim. The VIP pass (benefits, prices in currencies) is a popup. The server decides the tier — including status tokens that arrive as rewards of other systems. Registers the 'season' feature; its badge counts tier rewards waiting and it reloads when status tokens are granted. Status comes from the game: a chain open to clients takes client.season.grantStatusTokens (the showcase mini-game grants status for every round). Ships title-seed.json: two alternating 28-day seasons with five tiers and a VIP prize on each.",
12
+ "description": "A ready season screen (Unity Core/Window MilestoneRewards) for ANY game, built on the @idosgames/react/ui kit and client.season. Every season chain of the title is a tab showing its active season: name and timer, the player's tier, the status bar toward the next tier and the tier ladder. Each tier pays its reward once ('Claim all' takes every reached tier in one call); a tier with premium tiers pays a VIP second prize when the pass is active at the moment of the claim. The VIP pass (benefits, prices in currencies) is a popup. The server decides the tier — including status tokens that arrive as rewards of other systems. Registers the 'season' feature; its badge counts tier rewards waiting and it reloads when status tokens are granted. Status comes from the game: a chain open to clients takes client.season.grantStatusTokens (the mini-game module grants status for every round). Ships title-seed.json: two alternating 28-day seasons with five tiers and a VIP prize on each.",
12
13
  "provides": [
13
14
  "season screen with a tab per chain",
14
15
  "season timer and status bar",
@@ -37,11 +38,11 @@
37
38
  },
38
39
  "dependencies": {
39
40
  "@idosgames/core": "0.19.0",
40
- "@idosgames/module-sdk": "0.5.0",
41
+ "@idosgames/module-sdk": "0.6.0",
41
42
  "@idosgames/react": "0.3.0",
42
43
  "react": "19.2.7"
43
44
  },
44
- "contentHash": "81f29463cedaef764861e232b709c16c5688536ccf7fffab23407496311313a8",
45
+ "contentHash": "2c1033ffa9a9f428fffce4a01e3a650069fc7d022d90c2b0d6e7426c2c73d245",
45
46
  "files": [
46
47
  {
47
48
  "path": "i18n.ts",
@@ -49,11 +50,7 @@
49
50
  },
50
51
  {
51
52
  "path": "index.ts",
52
- "content": "// @idosgames/mod-season-ui — Seasons for any game: status tiers with a reward each, the season timer, claim all and a VIP second prize.\n\nexport { seasonUiModule } from \"./module\";\n"
53
- },
54
- {
55
- "path": "model.test.ts",
56
- "content": "import { describe, expect, it } from \"vitest\";\nimport seed from \"../title-seed.json\";\nimport {\n claimedLines,\n readyTiers,\n seasonBadge,\n seasonTokens,\n tierProgress,\n tierRows,\n} from \"./model\";\n\n// GetActiveSeason returns the season definition with the player's state; here it is assembled from\n// the seeded chain's first season.\ntype Chain = {\n SeasonChainID: string;\n Seasons: Array<{ SeasonID: string; Tiers: unknown[] }>;\n};\nconst chain = Object.values(\n (seed as unknown as { Season: { Chains: Record<string, Chain> } }).Season\n .Chains,\n)[0]!;\nconst NOW = Date.parse(\"2026-09-28T12:00:00Z\");\nconst vip = {\n Subscriptions: { vip: { ExpirationDate: \"2026-10-28T00:00:00Z\" } },\n};\nconst info = (currentTier: number, claimed: number[] = []) =>\n ({\n SeasonChainID: chain.SeasonChainID,\n Season: chain.Seasons[0],\n UserState: { CurrentTier: currentTier, ClaimedTierRewards: claimed },\n }) as never;\n\ndescribe(\"season\", () => {\n it(\"reads the seeded ladder: five tiers from 0 status, each with a VIP prize\", () => {\n const rows = tierRows(info(1), null, NOW);\n expect(rows.map((r) => r.required)).toEqual([0, 50, 150, 300, 500]);\n expect(\n rows.every((r) => r.vip.length > 0 && r.vipPremiumID === \"vip\"),\n ).toBe(true);\n expect(rows[0]).toMatchObject({\n state: \"ready\",\n current: true,\n vipHeld: false,\n });\n expect(rows[1]!.state).toBe(\"locked\");\n });\n\n it(\"reached tiers are claimable once; the VIP part follows the pass held now\", () => {\n const rows = tierRows(info(3, [1]), vip, NOW);\n expect(rows.map((r) => r.state)).toEqual([\n \"claimed\",\n \"ready\",\n \"ready\",\n \"locked\",\n \"locked\",\n ]);\n expect(readyTiers(rows)).toEqual([2, 3]);\n expect(rows[1]!.vipHeld).toBe(true);\n });\n\n it(\"status never reads below the reached tier's threshold, and the bar spans tier to tier\", () => {\n expect(seasonTokens(info(3), 0)).toBe(150);\n expect(seasonTokens(info(3), 180)).toBe(180);\n const rows = tierRows(info(3), null, NOW);\n expect(tierProgress(rows, 180)).toMatchObject({ from: 150, goal: 300 });\n expect(tierProgress(rows, 900).next).toBeNull();\n });\n\n it(\"the badge counts tier rewards waiting\", () => {\n expect(seasonBadge([info(2)], null, NOW)).toBe(2);\n expect(seasonBadge([info(2, [1, 2])], null, NOW)).toBe(0);\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"
53
+ "content": "// @idosgames/mod-season — Seasons for any game: status tiers with a reward each, the season timer, claim all and a VIP second prize.\n\nexport { seasonModule } from \"./module\";\n"
57
54
  },
58
55
  {
59
56
  "path": "model.ts",
@@ -61,7 +58,7 @@
61
58
  },
62
59
  {
63
60
  "path": "module.ts",
64
- "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport {\n createSeasonStore,\n makeSeasonScreen,\n makeSeasonWatcher,\n} from \"./SeasonScreen\";\nimport { t } from \"./i18n\";\n\n// Seasons as a feature of the game. The always-on watcher keeps the badge current (tier rewards\n// waiting) and reloads after status tokens are granted; the screen shows each chain's active season.\nexport const seasonUiModule: Module = defineModule({\n id: \"season-ui\",\n meta: { name: \"Season\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n const store = createSeasonStore(ctx.features);\n ctx.features.register({\n id: \"season\",\n label: t(\"title\"),\n icon: \"trophy\",\n group: \"events\",\n order: 26,\n Screen: makeSeasonScreen(store),\n });\n ctx.registerPanel({\n id: \"badge\",\n slot: \"modal\",\n activeOnly: false,\n component: makeSeasonWatcher(store),\n });\n ctx.exposeToAgent({\n state: () => ({\n ui: \"dom\",\n feature: \"season\",\n chains: store.get()?.map((s) => s.info.SeasonChainID) ?? [],\n }),\n describeActions: {},\n });\n },\n});\n"
61
+ "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport {\n createSeasonStore,\n makeSeasonScreen,\n makeSeasonWatcher,\n} from \"./SeasonScreen\";\nimport { t } from \"./i18n\";\n\n// Seasons as a feature of the game. The always-on watcher keeps the badge current (tier rewards\n// waiting) and reloads after status tokens are granted; the screen shows each chain's active season.\nexport const seasonModule: Module = defineModule({\n id: \"season\",\n meta: { name: \"Season\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n const store = createSeasonStore(ctx.features);\n ctx.features.register({\n id: \"season\",\n label: t(\"title\"),\n icon: \"trophy\",\n group: \"events\",\n order: 26,\n available: false,\n Screen: makeSeasonScreen(store),\n });\n // The tab shows while a season is running — the watcher loads them at start.\n store.subscribe(() =>\n ctx.features.setAvailable(\n \"season\",\n (store.get()?.length ?? 0) > 0,\n \"no active season right now (Season chains)\",\n ),\n );\n ctx.registerPanel({\n id: \"badge\",\n slot: \"modal\",\n activeOnly: false,\n component: makeSeasonWatcher(store),\n });\n ctx.exposeToAgent({\n state: () => ({\n ui: \"dom\",\n feature: \"season\",\n chains: store.get()?.map((s) => s.info.SeasonChainID) ?? [],\n }),\n describeActions: {},\n });\n },\n});\n"
65
62
  },
66
63
  {
67
64
  "path": "SeasonScreen.tsx",
@@ -73,7 +70,7 @@
73
70
  },
74
71
  {
75
72
  "path": "module.meta.json",
76
- "content": "{\n \"id\": \"season-ui\",\n \"type\": \"feature\",\n \"summary\": \"Seasons for any game: the active season with its timer, a status bar, a tier ladder with a reward each, claim all and a VIP second prize.\",\n \"description\": \"A ready season screen (Unity Core/Window MilestoneRewards) for ANY game, built on the @idosgames/react/ui kit and client.season. Every season chain of the title is a tab showing its active season: name and timer, the player's tier, the status bar toward the next tier and the tier ladder. Each tier pays its reward once ('Claim all' takes every reached tier in one call); a tier with premium tiers pays a VIP second prize when the pass is active at the moment of the claim. The VIP pass (benefits, prices in currencies) is a popup. The server decides the tier — including status tokens that arrive as rewards of other systems. Registers the 'season' feature; its badge counts tier rewards waiting and it reloads when status tokens are granted. Status comes from the game: a chain open to clients takes client.season.grantStatusTokens (the showcase mini-game grants status for every round). Ships title-seed.json: two alternating 28-day seasons with five tiers and a VIP prize on each.\",\n \"provides\": [\n \"season screen with a tab per chain\",\n \"season timer and status bar\",\n \"tier ladder with rewards\",\n \"claim all reached tiers\",\n \"VIP second prize on tiers\",\n \"VIP pass purchase popup\",\n \"season badge\",\n \"demo seasons\"\n ],\n \"tags\": [\n \"season\",\n \"battle-pass\",\n \"tiers\",\n \"progression\",\n \"live-ops\",\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"
73
+ "content": "{\n \"id\": \"season\",\n \"type\": \"feature\",\n \"inBase\": true,\n \"summary\": \"Seasons for any game: the active season with its timer, a status bar, a tier ladder with a reward each, claim all and a VIP second prize.\",\n \"description\": \"A ready season screen (Unity Core/Window MilestoneRewards) for ANY game, built on the @idosgames/react/ui kit and client.season. Every season chain of the title is a tab showing its active season: name and timer, the player's tier, the status bar toward the next tier and the tier ladder. Each tier pays its reward once ('Claim all' takes every reached tier in one call); a tier with premium tiers pays a VIP second prize when the pass is active at the moment of the claim. The VIP pass (benefits, prices in currencies) is a popup. The server decides the tier — including status tokens that arrive as rewards of other systems. Registers the 'season' feature; its badge counts tier rewards waiting and it reloads when status tokens are granted. Status comes from the game: a chain open to clients takes client.season.grantStatusTokens (the mini-game module grants status for every round). Ships title-seed.json: two alternating 28-day seasons with five tiers and a VIP prize on each.\",\n \"provides\": [\n \"season screen with a tab per chain\",\n \"season timer and status bar\",\n \"tier ladder with rewards\",\n \"claim all reached tiers\",\n \"VIP second prize on tiers\",\n \"VIP pass purchase popup\",\n \"season badge\",\n \"demo seasons\"\n ],\n \"tags\": [\n \"season\",\n \"battle-pass\",\n \"tiers\",\n \"progression\",\n \"live-ops\",\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"
77
74
  }
78
75
  ]
79
76
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "id": "store-ui",
2
+ "id": "store",
3
3
  "meta": {
4
4
  "name": "Shop",
5
5
  "type": "app",
@@ -7,8 +7,9 @@
7
7
  },
8
8
  "catalog": {
9
9
  "type": "feature",
10
+ "inBase": true,
10
11
  "summary": "Shop screen for any game: the title's storefront with stores as tabs, offer cards, purchase confirmation and the reward animation.",
11
- "description": "A ready shop for ANY game, built on the @idosgames/react/ui kit. It draws the storefront the server resolves for the player (client.store.getStorefront — rotation, limits, timers and badges are the server's), several stores as tabs, the first section of a store as a carousel and the rest as grids, a free daily gift that shines, sold-out and not-yet-available states with countdowns, a confirmation popup (a bottom sheet on phones) and the 'You got' animation where rewards fly to the balance counters. Registers itself as the 'store' feature of the game (ctx.features): the showcase lobby shows it as a tab, the shared HUD's ☰ lists it, and any module can open it with ctx.features.open('store'). Its badge counts free offers waiting. Ships title-seed.json: a demo store with a daily gift, items and currency packs.",
12
+ "description": "A ready shop for ANY game, built on the @idosgames/react/ui kit. It draws the storefront the server resolves for the player (client.store.getStorefront — rotation, limits, timers and badges are the server's), several stores as tabs, the first section of a store as a carousel and the rest as grids, a free daily gift that shines, sold-out and not-yet-available states with countdowns, a confirmation popup (a bottom sheet on phones) and the 'You got' animation where rewards fly to the balance counters. Registers itself as the 'store' feature of the game (ctx.features): the lobby of the base shows it as a tab, and any module can open it with ctx.features.open('store'). Its badge counts free offers waiting. Ships title-seed.json: a demo store with a daily gift, items and currency packs.",
12
13
  "provides": [
13
14
  "shop screen from the title storefront",
14
15
  "several stores as tabs",
@@ -37,11 +38,11 @@
37
38
  },
38
39
  "dependencies": {
39
40
  "@idosgames/core": "0.19.0",
40
- "@idosgames/module-sdk": "0.5.0",
41
+ "@idosgames/module-sdk": "0.6.0",
41
42
  "@idosgames/react": "0.3.0",
42
43
  "react": "19.2.7"
43
44
  },
44
- "contentHash": "f9ef650d747113ee2ad1347cfe8b2d7e8b7d3b2ccfcaf0c1dedbfcdb4d509359",
45
+ "contentHash": "355f6a164da0c42e75ba35eef6f480417e676dbfc5985cef8cccc6127beebde5",
45
46
  "files": [
46
47
  {
47
48
  "path": "i18n.ts",
@@ -49,19 +50,15 @@
49
50
  },
50
51
  {
51
52
  "path": "index.ts",
52
- "content": "// @idosgames/mod-store-ui — Shop screen for any game: the title's storefront with several stores as tabs, a carousel and grids of offers, purchase confirmation and the reward animation.\n\nexport { storeUiModule } from \"./module\";\n"
53
- },
54
- {
55
- "path": "model.test.ts",
56
- "content": "import { describe, expect, it } from \"vitest\";\nimport seed from \"../title-seed.json\";\nimport { grantLines } from \"@idosgames/react/ui\";\nimport {\n earliestRefresh,\n freeOffersWaiting,\n isFree,\n slotsOf,\n sortedSections,\n sortedStores,\n} from \"./model\";\n\n// The storefront the server resolves is not the raw config; the helpers are checked on small\n// resolved shapes, and the seed on \"every offer draws\".\n\nconst offer = (\n id: string,\n free: boolean,\n extra: Record<string, unknown> = {},\n) => ({\n OfferID: id,\n PriceOptions: [\n free\n ? { IsFree: true }\n : {\n Cost: {\n Standard: {\n Entries: [\n { Type: \"VirtualCurrency\", CurrencyID: \"CO\", Amount: 50 },\n ],\n },\n },\n },\n ],\n ...extra,\n});\n\ndescribe(\"shop\", () => {\n it(\"every seeded offer has a reward the shop can draw\", () => {\n const offers = Object.values(\n (\n seed as unknown as {\n Store: {\n StoreOffers: Record<string, { Reward: { Grant: unknown } }>;\n };\n }\n ).Store.StoreOffers,\n );\n expect(offers.length).toBeGreaterThan(0);\n for (const o of offers)\n expect(grantLines(o.Reward.Grant as never).length).toBeGreaterThan(0);\n });\n\n it(\"orders stores, sections and slots by SortOrder\", () => {\n const front = {\n Stores: [\n { StoreID: \"b\", SortOrder: 2 },\n {\n StoreID: \"a\",\n SortOrder: 1,\n Sections: [\n { SectionID: \"y\", SortOrder: 2, Slots: [] },\n {\n SectionID: \"x\",\n SortOrder: 1,\n Slots: [\n { SlotID: \"2\", SortOrder: 2 },\n { SlotID: \"1\", SortOrder: 1 },\n ],\n },\n ],\n },\n ],\n } as never;\n expect(sortedStores(front).map((s) => s.StoreID)).toEqual([\"a\", \"b\"]);\n const sections = sortedSections(sortedStores(front)[0]);\n expect(sections.map((s) => s.SectionID)).toEqual([\"x\", \"y\"]);\n expect(slotsOf(sections[0]!).map((s) => s.SlotID)).toEqual([\"1\", \"2\"]);\n });\n\n it(\"free = explicitly free or no price; the badge counts free offers still available\", () => {\n expect(isFree(offer(\"gift\", true) as never)).toBe(true);\n expect(isFree({ OfferID: \"x\", PriceOptions: [] } as never)).toBe(true);\n expect(isFree(offer(\"potion\", false) as never)).toBe(false);\n const front = {\n Stores: [\n {\n StoreID: \"main\",\n SortOrder: 0,\n Sections: [\n {\n SectionID: \"d\",\n SortOrder: 0,\n Slots: [\n { SlotID: \"1\", SortOrder: 0, Offer: offer(\"gift\", true) },\n {\n SlotID: \"2\",\n SortOrder: 1,\n Offer: offer(\"gift2\", true, { State: { SoldOut: true } }),\n },\n { SlotID: \"3\", SortOrder: 2, Offer: offer(\"potion\", false) },\n { SlotID: \"4\", SortOrder: 3 },\n ],\n },\n ],\n },\n ],\n } as never;\n expect(freeOffersWaiting(front)).toBe(1);\n expect(freeOffersWaiting(null)).toBe(0);\n });\n\n it(\"a section's countdown is its earliest card refresh\", () => {\n expect(\n earliestRefresh({\n SectionID: \"s\",\n SortOrder: 0,\n Slots: [\n { SlotID: \"a\", SortOrder: 0, RefreshesAtUtc: \"2026-09-28T00:00:00Z\" },\n { SlotID: \"b\", SortOrder: 1, RefreshesAtUtc: \"2026-09-27T20:00:00Z\" },\n ],\n } as never),\n ).toBe(\"2026-09-27T20:00:00Z\");\n });\n});\n"
53
+ "content": "// @idosgames/mod-store — Shop screen for any game: the title's storefront with several stores as tabs, a carousel and grids of offers, purchase confirmation and the reward animation.\n\nexport { storeModule } from \"./module\";\n"
57
54
  },
58
55
  {
59
56
  "path": "model.ts",
60
- "content": "// Pure helpers of the shop screen — covered by model.test.ts.\n\nimport type {\n GetStorefrontResponse,\n StorefrontOfferView,\n StorefrontSectionView,\n StorefrontSlotView,\n} from \"@idosgames/core\";\nimport { costLines } from \"@idosgames/react/ui\";\n\nexport function sortedSections(\n store: { Sections?: StorefrontSectionView[] | null } | null | undefined,\n): StorefrontSectionView[] {\n return (store?.Sections ?? [])\n .slice()\n .sort((a, b) => a.SortOrder - b.SortOrder);\n}\n\nexport function slotsOf(section: StorefrontSectionView): StorefrontSlotView[] {\n return (section.Slots ?? [])\n .slice()\n .sort((a, b) => a.SortOrder - b.SortOrder);\n}\n\n/** When the first card of a section refreshes — the section's countdown. */\nexport function earliestRefresh(section: StorefrontSectionView): string | null {\n const times = slotsOf(section)\n .map((s) => s.RefreshesAtUtc)\n .filter((x): x is string => typeof x === \"string\")\n .sort();\n return times[0] ?? null;\n}\n\nexport function isFree(offer: StorefrontOfferView): boolean {\n const option = offer.PriceOptions?.[0];\n return option?.IsFree === true || costLines(option?.Cost).length === 0;\n}\n\n/** Offers the player can take right now (not sold out, not waiting for their window). */\nexport function isAvailable(offer: StorefrontOfferView): boolean {\n return offer.State?.SoldOut !== true && !offer.State?.AvailableAtUtc;\n}\n\n/** The badge of the shop: free offers waiting to be taken (the daily gift). */\nexport function freeOffersWaiting(\n front: GetStorefrontResponse | null | undefined,\n): number {\n let n = 0;\n for (const store of front?.Stores ?? [])\n for (const section of store.Sections ?? [])\n for (const slot of section.Slots ?? [])\n if (slot.Offer && isFree(slot.Offer) && isAvailable(slot.Offer)) n++;\n return n;\n}\n\n/** Stores by their order; the first is the default tab. */\nexport function sortedStores(\n front: GetStorefrontResponse | null | undefined,\n): NonNullable<GetStorefrontResponse[\"Stores\"]> {\n return (front?.Stores ?? [])\n .slice()\n .sort((a, b) => a.SortOrder - b.SortOrder);\n}\n"
57
+ "content": "// Pure helpers of the shop screen — covered by model.test.ts.\n\nimport type {\n GetStorefrontResponse,\n StorefrontOfferView,\n StorefrontSectionView,\n StorefrontSlotView,\n} from \"@idosgames/core\";\nimport { costLines } from \"@idosgames/react/ui\";\n\nexport function sortedSections(\n store: { Sections?: StorefrontSectionView[] | null } | null | undefined,\n): StorefrontSectionView[] {\n return (store?.Sections ?? [])\n .slice()\n .sort((a, b) => a.SortOrder - b.SortOrder);\n}\n\nexport function slotsOf(section: StorefrontSectionView): StorefrontSlotView[] {\n return (section.Slots ?? [])\n .slice()\n .sort((a, b) => a.SortOrder - b.SortOrder);\n}\n\n/** When the first card of a section refreshes — the section's countdown. */\nexport function earliestRefresh(section: StorefrontSectionView): string | null {\n const times = slotsOf(section)\n .map((s) => s.RefreshesAtUtc)\n .filter((x): x is string => typeof x === \"string\")\n .sort();\n return times[0] ?? null;\n}\n\nexport function isFree(offer: StorefrontOfferView): boolean {\n const option = offer.PriceOptions?.[0];\n return option?.IsFree === true || costLines(option?.Cost).length === 0;\n}\n\n/** Offers the player can take right now (not sold out, not waiting for their window). */\nexport function isAvailable(offer: StorefrontOfferView): boolean {\n return offer.State?.SoldOut !== true && !offer.State?.AvailableAtUtc;\n}\n\n/** The badge of the shop: free offers waiting to be taken (the daily gift). */\nexport function freeOffersWaiting(\n front: GetStorefrontResponse | null | undefined,\n): number {\n let n = 0;\n for (const store of front?.Stores ?? [])\n for (const section of store.Sections ?? [])\n for (const slot of section.Slots ?? [])\n if (slot.Offer && isFree(slot.Offer) && isAvailable(slot.Offer)) n++;\n return n;\n}\n\n/** Stores by their order; the first is the default tab. */\nexport function sortedStores(\n front: GetStorefrontResponse | null | undefined,\n): NonNullable<GetStorefrontResponse[\"Stores\"]> {\n return (front?.Stores ?? [])\n .slice()\n .sort((a, b) => a.SortOrder - b.SortOrder);\n}\n\n/** Whether the storefront has any offer — until it does, the lobby hides the Shop tab. */\nexport function hasOffers(\n front: GetStorefrontResponse | null | undefined,\n): boolean {\n return (front?.Stores ?? []).some((store) =>\n (store.Sections ?? []).some((section) =>\n (section.Slots ?? []).some((slot) => slot.Offer != null),\n ),\n );\n}\n"
61
58
  },
62
59
  {
63
60
  "path": "module.ts",
64
- "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 { StoreScreen } from \"./StoreScreen\";\nimport { freeOffersWaiting } from \"./model\";\nimport { t } from \"./i18n\";\n\n// The shop as a feature of the game (ctx.features): the lobby shows it as a main tab, the shared\n// HUD's ☰ lists it. Its badge counts free offers waiting to be taken (the daily gift), kept fresh\n// by an invisible always-on panel — the screen itself is mounted only while open.\nexport const storeUiModule: Module = defineModule({\n id: \"store-ui\",\n meta: { name: \"Shop\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"store\",\n label: t(\"title\"),\n icon: \"shop\",\n group: \"economy\",\n order: 10,\n primary: true,\n Screen: StoreScreen,\n });\n ctx.registerPanel({\n id: \"badge\",\n slot: \"modal\",\n activeOnly: false,\n component: makeBadgeWatcher(ctx.features),\n });\n ctx.exposeToAgent({\n state: () => ({\n ui: \"dom\",\n feature: \"store\",\n badge: ctx.features.get(\"store\")?.badge ?? 0,\n }),\n describeActions: {},\n });\n },\n});\n\nfunction makeBadgeWatcher(features: FeatureRegistry) {\n return function StoreBadge() {\n const client = useIDosGamesClient();\n useEffect(() => {\n const update = () =>\n features.setBadge(\n \"store\",\n freeOffersWaiting(client.store.cachedStorefront),\n );\n void client.store.getStorefront().then(update);\n const off = client.on(\"user:anyUpdated\", update);\n // A free gift comes back with the next day: look again every few minutes.\n const id = setInterval(\n () => void client.store.getStorefront().then(update),\n 5 * 60_000,\n );\n return () => {\n off();\n clearInterval(id);\n };\n }, [client]);\n return createElement(\"span\", { hidden: true });\n };\n}\n"
61
+ "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 { StoreScreen } from \"./StoreScreen\";\nimport { freeOffersWaiting, hasOffers } from \"./model\";\nimport { t } from \"./i18n\";\n\n// The shop as a feature of the game (ctx.features): the lobby shows it as a main tab once the\n// storefront has offers. Its badge counts free offers waiting to be taken (the daily gift), kept\n// fresh by an invisible always-on panel — the screen itself is mounted only while open.\nexport const storeModule: Module = defineModule({\n id: \"store\",\n meta: { name: \"Shop\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"store\",\n label: t(\"title\"),\n icon: \"shop\",\n group: \"economy\",\n order: 10,\n primary: true,\n available: false,\n Screen: StoreScreen,\n });\n ctx.registerPanel({\n id: \"badge\",\n slot: \"modal\",\n activeOnly: false,\n component: makeBadgeWatcher(ctx.features),\n });\n ctx.exposeToAgent({\n state: () => ({\n ui: \"dom\",\n feature: \"store\",\n badge: ctx.features.get(\"store\")?.badge ?? 0,\n }),\n describeActions: {},\n });\n },\n});\n\nfunction makeBadgeWatcher(features: FeatureRegistry) {\n return function StoreBadge() {\n const client = useIDosGamesClient();\n useEffect(() => {\n const update = () => {\n const front = client.store.cachedStorefront;\n features.setAvailable(\n \"store\",\n hasOffers(front),\n \"the storefront has no offers (Store section)\",\n );\n features.setBadge(\"store\", freeOffersWaiting(front));\n };\n void client.store.getStorefront().then(update);\n const off = client.on(\"user:anyUpdated\", update);\n // A free gift comes back with the next day: look again every few minutes.\n const id = setInterval(\n () => void client.store.getStorefront().then(update),\n 5 * 60_000,\n );\n return () => {\n off();\n clearInterval(id);\n };\n }, [client]);\n return createElement(\"span\", { hidden: true });\n };\n}\n"
65
62
  },
66
63
  {
67
64
  "path": "StoreScreen.tsx",
@@ -69,7 +66,7 @@
69
66
  },
70
67
  {
71
68
  "path": "module.meta.json",
72
- "content": "{\n \"id\": \"store-ui\",\n \"type\": \"feature\",\n \"summary\": \"Shop screen for any game: the title's storefront with stores as tabs, offer cards, purchase confirmation and the reward animation.\",\n \"description\": \"A ready shop for ANY game, built on the @idosgames/react/ui kit. It draws the storefront the server resolves for the player (client.store.getStorefront — rotation, limits, timers and badges are the server's), several stores as tabs, the first section of a store as a carousel and the rest as grids, a free daily gift that shines, sold-out and not-yet-available states with countdowns, a confirmation popup (a bottom sheet on phones) and the 'You got' animation where rewards fly to the balance counters. Registers itself as the 'store' feature of the game (ctx.features): the showcase lobby shows it as a tab, the shared HUD's ☰ lists it, and any module can open it with ctx.features.open('store'). Its badge counts free offers waiting. Ships title-seed.json: a demo store with a daily gift, items and currency packs.\",\n \"provides\": [\n \"shop screen from the title storefront\",\n \"several stores as tabs\",\n \"offer carousel and grids with countdowns\",\n \"free daily gift offer\",\n \"purchase confirmation popup\",\n \"reward animation with icons flying to balances\",\n \"shop badge for free offers\",\n \"demo store settings\"\n ],\n \"tags\": [\n \"shop\",\n \"store\",\n \"storefront\",\n \"purchase\",\n \"offers\",\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"
69
+ "content": "{\n \"id\": \"store\",\n \"type\": \"feature\",\n \"inBase\": true,\n \"summary\": \"Shop screen for any game: the title's storefront with stores as tabs, offer cards, purchase confirmation and the reward animation.\",\n \"description\": \"A ready shop for ANY game, built on the @idosgames/react/ui kit. It draws the storefront the server resolves for the player (client.store.getStorefront — rotation, limits, timers and badges are the server's), several stores as tabs, the first section of a store as a carousel and the rest as grids, a free daily gift that shines, sold-out and not-yet-available states with countdowns, a confirmation popup (a bottom sheet on phones) and the 'You got' animation where rewards fly to the balance counters. Registers itself as the 'store' feature of the game (ctx.features): the lobby of the base shows it as a tab, and any module can open it with ctx.features.open('store'). Its badge counts free offers waiting. Ships title-seed.json: a demo store with a daily gift, items and currency packs.\",\n \"provides\": [\n \"shop screen from the title storefront\",\n \"several stores as tabs\",\n \"offer carousel and grids with countdowns\",\n \"free daily gift offer\",\n \"purchase confirmation popup\",\n \"reward animation with icons flying to balances\",\n \"shop badge for free offers\",\n \"demo store settings\"\n ],\n \"tags\": [\n \"shop\",\n \"store\",\n \"storefront\",\n \"purchase\",\n \"offers\",\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"
73
70
  }
74
71
  ]
75
72
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "id": "tutorial-ui",
2
+ "id": "tutorials",
3
3
  "meta": {
4
4
  "name": "Tutorials",
5
5
  "type": "app",
@@ -7,8 +7,9 @@
7
7
  },
8
8
  "catalog": {
9
9
  "type": "feature",
10
+ "inBase": true,
10
11
  "summary": "Onboarding for any game: hint bubbles next to marked elements, step by step, skip, a completion reward and a replay list.",
11
- "description": "A ready onboarding overlay (Unity Runtime/UI/Tutorial, TutorialAnchorRegistry) for ANY game, built on the @idosgames/react/ui kit and client.tutorial. It shows the current step of the running flow as a hint bubble next to the element marked data-tutorial-anchor=\"{AnchorID}\" — any element of any module (lobby tabs are tab:{featureID}, the profile button lobby:profile, the play button play:start) — with the rest of the screen dimmed. It follows the element as the layout changes, steps aside while a popup is open, offers 'Next' only on steps the player closes (a SystemEvent step waits for the real game action), 'Skip' when the flow allows it, and the reward when the flow ends; every shown step is reported for the funnel. Flows start by themselves when the title says so. The 'Tutorials' feature lists the flows and replays the ones the title lets the player restart. Ships title-seed.json: a five-step welcome tour of the showcase lobby with a gem reward.",
12
+ "description": "A ready onboarding overlay (Unity Runtime/UI/Tutorial, TutorialAnchorRegistry) for ANY game, built on the @idosgames/react/ui kit and client.tutorial. It shows the current step of the running flow as a hint bubble next to the element marked data-tutorial-anchor=\"{AnchorID}\" — any element of any module (lobby tabs are tab:{featureID}, the profile button lobby:profile, a game's Play button play:{modeID}, the mini-game's round play:start) — with the rest of the screen dimmed. It follows the element as the layout changes, steps aside while a popup is open, offers 'Next' only on steps the player closes (a SystemEvent step waits for the real game action), 'Skip' when the flow allows it, and the reward when the flow ends; every shown step is reported for the funnel. Flows start by themselves when the title says so. The 'Tutorials' feature lists the flows and replays the ones the title lets the player restart. Ships title-seed.json: a five-step welcome tour of the lobby with a gem reward.",
12
13
  "provides": [
13
14
  "tutorial overlay with a highlighted element",
14
15
  "hint bubble with next and skip",
@@ -33,11 +34,11 @@
33
34
  },
34
35
  "dependencies": {
35
36
  "@idosgames/core": "0.19.0",
36
- "@idosgames/module-sdk": "0.5.0",
37
+ "@idosgames/module-sdk": "0.6.0",
37
38
  "@idosgames/react": "0.3.0",
38
39
  "react": "19.2.7"
39
40
  },
40
- "contentHash": "ddd651cc2cff6692f7b98c2904c63f9e0548148b2b77d135c00be6c3039a1c56",
41
+ "contentHash": "e208b492977fc69db40d6b2105869911937437a5fd0b7aa15c90eedc0d88efff",
41
42
  "files": [
42
43
  {
43
44
  "path": "i18n.ts",
@@ -45,11 +46,7 @@
45
46
  },
46
47
  {
47
48
  "path": "index.ts",
48
- "content": "// @idosgames/mod-tutorial-ui — Onboarding for any game: hint bubbles next to marked elements, step by step, with a reward and a replay list.\n\nexport { tutorialUiModule } from \"./module\";\n"
49
- },
50
- {
51
- "path": "model.test.ts",
52
- "content": "import { describe, expect, it } from \"vitest\";\nimport seed from \"../title-seed.json\";\nimport {\n activeFlow,\n anchorOf,\n bubblePlacement,\n canTapNext,\n stepCounter,\n stepText,\n} from \"./model\";\n\ndescribe(\"tutorial\", () => {\n it(\"the seeded welcome tour: five steps on the lobby's anchors, skippable, replayable\", () => {\n const flow = (\n seed as unknown as {\n Tutorial: {\n Flows: Record<\n string,\n {\n Steps: Record<string, { Identity: { AnchorID?: string } }>;\n Policy: { Skippable: boolean; RestartPolicy: string };\n }\n >;\n };\n }\n ).Tutorial.Flows.welcome!;\n expect(Object.keys(flow.Steps)).toHaveLength(5);\n expect(\n Object.values(flow.Steps).map((s) => s.Identity.AnchorID ?? null),\n ).toEqual([\n null,\n \"tab:store\",\n \"tab:character\",\n \"tab:rewards\",\n \"lobby:profile\",\n ]);\n expect(flow.Policy).toMatchObject({\n Skippable: true,\n RestartPolicy: \"OnRequest\",\n });\n });\n\n it(\"shows the running flow, else the one whose reward waits\", () => {\n const running = { FlowID: \"a\", Status: \"InProgress\", CurrentStep: {} };\n const pending = { FlowID: \"b\", Status: \"Completed\", RewardPending: true };\n expect(activeFlow([pending, running])?.FlowID).toBe(\"a\");\n expect(activeFlow([pending])?.FlowID).toBe(\"b\");\n expect(activeFlow([{ FlowID: \"c\", Status: \"Completed\" }])).toBeNull();\n });\n\n it(\"'Next' only on steps the player closes; the highlight prefers HighlightTarget\", () => {\n expect(canTapNext({ Completion: { Mode: \"ClientAck\" } })).toBe(true);\n expect(canTapNext({})).toBe(true);\n expect(canTapNext({ Completion: { Mode: \"SystemEvent\" } })).toBe(false);\n expect(\n anchorOf({ Identity: { AnchorID: \"a\", HighlightTarget: \"h\" } }),\n ).toBe(\"h\");\n expect(anchorOf({ Identity: { AnchorID: \"a\" } })).toBe(\"a\");\n expect(\n stepText({ Identity: { TitleKey: \"k\", Body: \"b\" } }, (k) => `[${k}]`),\n ).toEqual({ title: \"[k]\", body: \"b\" });\n expect(stepCounter({ FlowID: \"x\", TotalSteps: 5, CompletedSteps: 1 })).toBe(\n \"2 / 5\",\n );\n });\n\n it(\"puts the bubble below the element, above it near the bottom, inside the screen\", () => {\n const screen = { width: 375, height: 812 };\n const bubble = { width: 320, height: 150 };\n expect(\n bubblePlacement(\n { left: 10, top: 100, width: 60, height: 40 },\n screen,\n bubble,\n ),\n ).toEqual({ left: 12, top: 152, above: false });\n const low = bubblePlacement(\n { left: 300, top: 740, width: 70, height: 60 },\n screen,\n bubble,\n );\n expect(low.above).toBe(true);\n expect(low.top).toBe(740 - 12 - 150);\n expect(low.left).toBe(375 - 12 - 320);\n expect(bubblePlacement(null, screen, bubble)).toEqual({\n left: 27.5,\n top: 331,\n above: false,\n });\n });\n});\n"
49
+ "content": "// @idosgames/mod-tutorials — Onboarding for any game: hint bubbles next to marked elements, step by step, with a reward and a replay list.\n\nexport { tutorialsModule } from \"./module\";\n"
53
50
  },
54
51
  {
55
52
  "path": "model.ts",
@@ -57,7 +54,7 @@
57
54
  },
58
55
  {
59
56
  "path": "module.ts",
60
- "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport {\n createTutorialStore,\n makeTutorialOverlay,\n makeTutorialsScreen,\n} from \"./TutorialOverlay\";\nimport { t } from \"./i18n\";\n\n// Onboarding for any game: an always-on overlay shows the current step next to the element marked\n// data-tutorial-anchor=\"{AnchorID}\"; the \"Tutorials\" feature lists the flows and replays them.\nexport const tutorialUiModule: Module = defineModule({\n id: \"tutorial-ui\",\n meta: { name: \"Tutorials\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n const store = createTutorialStore();\n ctx.registerPanel({\n id: \"overlay\",\n slot: \"modal\",\n activeOnly: false,\n component: makeTutorialOverlay(store),\n });\n ctx.features.register({\n id: \"tutorials\",\n label: t(\"title\"),\n icon: \"bulb\",\n group: \"profile\",\n order: 110,\n Screen: makeTutorialsScreen(store),\n });\n ctx.exposeToAgent({\n state: () => ({\n ui: \"dom\",\n feature: \"tutorials\",\n running:\n store\n .get()\n ?.filter((f) => f.Status === \"InProgress\")\n .map((f) => f.FlowID) ?? [],\n }),\n describeActions: {},\n });\n },\n});\n"
57
+ "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport {\n createTutorialStore,\n makeTutorialOverlay,\n makeTutorialsScreen,\n} from \"./TutorialOverlay\";\nimport { t } from \"./i18n\";\n\n// Onboarding for any game: an always-on overlay shows the current step next to the element marked\n// data-tutorial-anchor=\"{AnchorID}\"; the \"Tutorials\" feature lists the flows and replays them.\nexport const tutorialsModule: Module = defineModule({\n id: \"tutorials\",\n meta: { name: \"Tutorials\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n const store = createTutorialStore();\n ctx.registerPanel({\n id: \"overlay\",\n slot: \"modal\",\n activeOnly: false,\n component: makeTutorialOverlay(store),\n });\n ctx.features.register({\n id: \"tutorials\",\n label: t(\"title\"),\n icon: \"bulb\",\n group: \"profile\",\n order: 110,\n available: false,\n Screen: makeTutorialsScreen(store),\n });\n // The list shows once the player has a flow to see again — from the state the overlay loads at\n // start anyway. No call of its own: the startup burst of every module's first read already\n // brushes the engine's per-player rate limit.\n store.subscribe(() =>\n ctx.features.setAvailable(\n \"tutorials\",\n (store.get()?.length ?? 0) > 0,\n \"no tutorial flow for this player (Tutorial section empty, or no flow started yet)\",\n ),\n );\n ctx.exposeToAgent({\n state: () => ({\n ui: \"dom\",\n feature: \"tutorials\",\n running:\n store\n .get()\n ?.filter((f) => f.Status === \"InProgress\")\n .map((f) => f.FlowID) ?? [],\n }),\n describeActions: {},\n });\n },\n});\n"
61
58
  },
62
59
  {
63
60
  "path": "TutorialOverlay.tsx",
@@ -65,7 +62,7 @@
65
62
  },
66
63
  {
67
64
  "path": "module.meta.json",
68
- "content": "{\n \"id\": \"tutorial-ui\",\n \"type\": \"feature\",\n \"summary\": \"Onboarding for any game: hint bubbles next to marked elements, step by step, skip, a completion reward and a replay list.\",\n \"description\": \"A ready onboarding overlay (Unity Runtime/UI/Tutorial, TutorialAnchorRegistry) for ANY game, built on the @idosgames/react/ui kit and client.tutorial. It shows the current step of the running flow as a hint bubble next to the element marked data-tutorial-anchor=\\\"{AnchorID}\\\" — any element of any module (lobby tabs are tab:{featureID}, the profile button lobby:profile, the play button play:start) — with the rest of the screen dimmed. It follows the element as the layout changes, steps aside while a popup is open, offers 'Next' only on steps the player closes (a SystemEvent step waits for the real game action), 'Skip' when the flow allows it, and the reward when the flow ends; every shown step is reported for the funnel. Flows start by themselves when the title says so. The 'Tutorials' feature lists the flows and replays the ones the title lets the player restart. Ships title-seed.json: a five-step welcome tour of the showcase lobby with a gem reward.\",\n \"provides\": [\n \"tutorial overlay with a highlighted element\",\n \"hint bubble with next and skip\",\n \"any element as an anchor via data-tutorial-anchor\",\n \"tutorial completion reward\",\n \"tutorials list with replay\",\n \"demo welcome tour\"\n ],\n \"tags\": [\"tutorial\", \"onboarding\", \"ftue\", \"coach-marks\", \"hints\", \"ui\"],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.0\"\n}\n"
65
+ "content": "{\n \"id\": \"tutorials\",\n \"type\": \"feature\",\n \"inBase\": true,\n \"summary\": \"Onboarding for any game: hint bubbles next to marked elements, step by step, skip, a completion reward and a replay list.\",\n \"description\": \"A ready onboarding overlay (Unity Runtime/UI/Tutorial, TutorialAnchorRegistry) for ANY game, built on the @idosgames/react/ui kit and client.tutorial. It shows the current step of the running flow as a hint bubble next to the element marked data-tutorial-anchor=\\\"{AnchorID}\\\" — any element of any module (lobby tabs are tab:{featureID}, the profile button lobby:profile, a game's Play button play:{modeID}, the mini-game's round play:start) — with the rest of the screen dimmed. It follows the element as the layout changes, steps aside while a popup is open, offers 'Next' only on steps the player closes (a SystemEvent step waits for the real game action), 'Skip' when the flow allows it, and the reward when the flow ends; every shown step is reported for the funnel. Flows start by themselves when the title says so. The 'Tutorials' feature lists the flows and replays the ones the title lets the player restart. Ships title-seed.json: a five-step welcome tour of the lobby with a gem reward.\",\n \"provides\": [\n \"tutorial overlay with a highlighted element\",\n \"hint bubble with next and skip\",\n \"any element as an anchor via data-tutorial-anchor\",\n \"tutorial completion reward\",\n \"tutorials list with replay\",\n \"demo welcome tour\"\n ],\n \"tags\": [\"tutorial\", \"onboarding\", \"ftue\", \"coach-marks\", \"hints\", \"ui\"],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.0\"\n}\n"
69
66
  }
70
67
  ]
71
68
  }