@idosgames/mcp 0.1.18 → 0.1.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +2 -2
- package/package.json +1 -1
- package/registry/host.json +69 -25
- package/registry/index.json +360 -412
- package/registry/modules/board-game.json +7 -7
- package/registry/modules/{character-ui.json → character.json} +8 -11
- package/registry/modules/chat.json +71 -0
- package/registry/modules/{craft-ui.json → craft.json} +8 -11
- package/registry/modules/{deal-offer-ui.json → deals.json} +7 -10
- package/registry/modules/{events-ui.json → events.json} +8 -11
- package/registry/modules/{friends-ui.json → friends.json} +8 -11
- package/registry/modules/idle-rpg.json +6 -6
- package/registry/modules/{inventory-ui.json → inventory.json} +9 -12
- package/registry/modules/{referral-ui.json → invite.json} +8 -11
- package/registry/modules/{leaderboard-ui.json → leaderboard.json} +8 -11
- package/registry/modules/marketplace.json +72 -0
- package/registry/modules/mini-game.json +69 -0
- package/registry/modules/{profile-ui.json → profile.json} +8 -11
- package/registry/modules/{pvp-ui.json → pvp.json} +9 -12
- package/registry/modules/{rewards-ui.json → rewards.json} +8 -11
- package/registry/modules/{season-ui.json → season.json} +8 -11
- package/registry/modules/{store-ui.json → store.json} +9 -12
- package/registry/modules/{tutorial-ui.json → tutorials.json} +8 -11
- package/registry/modules/voxelcraft.json +2 -446
- package/registry/modules/workshop.json +11 -6
- package/registry/skills/dev-test-loop.json +1 -1
- package/registry/skills/idosgames-compose-modules.json +2 -2
- package/registry/skills/idosgames-getting-started.json +2 -2
- package/registry/skills/idosgames-module-contract.json +2 -2
- package/registry/skills/idosgames-project-structure.json +1 -1
- package/registry/skills/idosgames-ui-kit.json +2 -2
- package/registry/skills/push-notifications.json +1 -1
- package/registry/skills/workshop-system.json +1 -1
- package/registry/modules/chat-ui.json +0 -74
- package/registry/modules/game-hud.json +0 -104
- package/registry/modules/marketplace-ui.json +0 -75
- package/registry/modules/showcase.json +0 -92
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"id": "leaderboard
|
|
2
|
+
"id": "leaderboard",
|
|
3
3
|
"meta": {
|
|
4
4
|
"name": "Leaderboards",
|
|
5
5
|
"type": "app",
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
},
|
|
8
8
|
"catalog": {
|
|
9
9
|
"type": "feature",
|
|
10
|
+
"inBase": true,
|
|
10
11
|
"summary": "Leaderboards screen for any game: boards as tabs, top players and my rank, rank rewards, score milestones and the reward of the last round.",
|
|
11
12
|
"description": "A ready leaderboards screen for ANY game, built on the @idosgames/react/ui kit. The title's enabled leaderboards are tabs; each shows a card with the player's score, rank and the countdown to the end of the round, and the top list with the player highlighted and medals for the top three. Rank rewards and score milestones open as popups (claim a milestone there). When a round ends, the reward for the place the player took pops up by itself with a Claim button (like LeaderboardCycleRewardPopup in the Unity SDK). A game that has something to play for the score registers a 'play' feature and a Play button appears on the card. Registers the 'leaderboard' feature; its badge counts boards with a reward waiting. Ships title-seed.json: a weekly best-score leaderboard with rank rewards.",
|
|
12
13
|
"provides": [
|
|
@@ -37,11 +38,11 @@
|
|
|
37
38
|
},
|
|
38
39
|
"dependencies": {
|
|
39
40
|
"@idosgames/core": "0.19.0",
|
|
40
|
-
"@idosgames/module-sdk": "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": "
|
|
45
|
+
"contentHash": "779700ab18f4bf06c4b7ab37ff23bfb609f956bc3fe8e6fd2bf642c1f2c4999a",
|
|
45
46
|
"files": [
|
|
46
47
|
{
|
|
47
48
|
"path": "i18n.ts",
|
|
@@ -49,27 +50,23 @@
|
|
|
49
50
|
},
|
|
50
51
|
{
|
|
51
52
|
"path": "index.ts",
|
|
52
|
-
"content": "// @idosgames/mod-leaderboard
|
|
53
|
+
"content": "// @idosgames/mod-leaderboard — Leaderboards screen for any game: several boards as tabs, top players and my rank, rank rewards, score milestones and the reward of the previous cycle.\n\nexport { leaderboardModule } from \"./module\";\n"
|
|
53
54
|
},
|
|
54
55
|
{
|
|
55
56
|
"path": "LeaderboardScreen.tsx",
|
|
56
57
|
"content": "import { useCallback, useEffect, useState, type ReactNode } from \"react\";\nimport type {\n GetLeaderboardResponse,\n GetMyProgressResponse,\n LeaderboardUserEntry,\n} from \"@idosgames/core\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Badge,\n Button,\n Card,\n Center,\n EmptyState,\n Icon,\n Popup,\n ProgressBar,\n ResourceList,\n SectionTitle,\n Spinner,\n Tabs,\n Timer,\n configSection,\n errorText,\n formatAmount,\n grantLines,\n grantedBy,\n outlined,\n panel,\n useCatalog,\n useCelebrate,\n useGameLayout,\n useNow,\n usePopupClose,\n useStagger,\n useToast,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n boardAction,\n cachedProgress,\n enabledBoards,\n milestoneRows,\n rewardForRank,\n type LeaderboardDefinitionView,\n type LeaderboardSection,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Leaderboards (Unity Runtime/UI/Leaderboard): the boards as tabs, a card with my score, my rank and\n// the countdown, and the top list — the player highlighted. Rank rewards and score milestones open\n// as popups (a list of rewards does not belong on the main screen). A game that has something to\n// PLAY for the score registers a \"play\" feature, and a Play button appears here.\n\nconst MEDALS = [\"#ffd24a\", \"#d9e1f2\", \"#e0955a\"];\n\nexport function makeLeaderboardScreen(features: FeatureRegistry) {\n return function LeaderboardScreen({ args }: FeatureScreenProps): ReactNode {\n return (\n <Leaderboards\n features={features}\n initial={\n typeof args?.leaderboardID === \"string\" ? args.leaderboardID : null\n }\n />\n );\n };\n}\n\nfunction Leaderboards({\n features,\n initial,\n}: {\n features: FeatureRegistry;\n initial: string | null;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const layout = useGameLayout();\n const toast = useToast();\n const now = useNow();\n const boards = enabledBoards(\n configSection<LeaderboardSection>(client, \"Leaderboard\"),\n );\n const [boardID, setBoardID] = useState<string | null>(initial);\n const board = boards.find((b) => b.LeaderboardID === boardID) ?? boards[0];\n const id = board?.LeaderboardID;\n\n const [top, setTop] = useState<GetLeaderboardResponse | null>(null);\n const [progress, setProgress] = useState<GetMyProgressResponse | null>(null);\n const [loading, setLoading] = useState(true);\n const [popup, setPopup] = useState<\"ranks\" | \"milestones\" | null>(null);\n\n const load = useCallback(async () => {\n if (!id) return;\n const [a, b] = await Promise.all([\n client.leaderboard.getLeaderboard(id),\n client.leaderboard.getMyProgress(id),\n ]);\n setLoading(false);\n if (a.ok) setTop(a.data);\n else toast(errorText(a.error), \"error\");\n if (b.ok) setProgress(b.data);\n }, [client, id, toast]);\n\n useEffect(() => {\n setLoading(true);\n void load();\n }, [load]);\n\n // A score submitted anywhere in the game refreshes the table.\n useEffect(\n () => client.on(\"leaderboard:scoreSubmitted\", () => void load()),\n [client, load],\n );\n\n if (!board || !id) return <EmptyState glyph=\"trophy\" text={t(\"noBoards\")} />;\n\n const me = client.auth.context?.userID;\n const users = (top?.TopUsers ?? [])\n .slice()\n .sort((a, b) => (a.Rank ?? 1e9) - (b.Rank ?? 1e9));\n const milestoneCount = Object.keys(board.Milestones ?? {}).length;\n // An invites board asks to invite (the referral screen), any other one to play.\n const action = boardAction(board);\n const cta =\n action === \"invite\"\n ? features.get(\"invite\")\n ? \"invite\"\n : null\n : features.get(\"play\")\n ? \"play\"\n : null;\n\n const hero = (\n <div\n className=\"idos-shine\"\n style={{\n ...panel,\n padding: 16,\n display: \"grid\",\n gap: 10,\n justifyItems: \"center\",\n background: \"linear-gradient(180deg, #5a3fd0 0%, #25207a 100%)\",\n }}\n >\n <Icon glyph=\"trophy\" size={56} />\n <div style={{ ...outlined, fontSize: 20, textAlign: \"center\" }}>\n {catalog.localize(board.DisplayName ?? id)}\n </div>\n <Timer at={top?.CycleEndUtc} now={now} label={`⏱ ${t(\"endsIn\")}`} />\n <div style={{ display: \"flex\", gap: 18 }}>\n <Stat\n label={\n board.ScoreDisplayName\n ? catalog.localize(board.ScoreDisplayName)\n : t(\"myScore\")\n }\n value={formatAmount(Number(progress?.CurrentScore ?? 0))}\n />\n <Stat\n label={t(\"myRank\")}\n value={progress?.LastKnownRank ? `#${progress.LastKnownRank}` : \"—\"}\n />\n </div>\n <div\n style={{\n display: \"flex\",\n gap: 8,\n flexWrap: \"wrap\",\n justifyContent: \"center\",\n }}\n >\n {cta === \"play\" ? (\n <Button\n attract\n onClick={() => features.open(\"play\")}\n data-tutorial-anchor=\"leaderboard:play\"\n >\n ▶ {t(\"play\")}\n </Button>\n ) : cta === \"invite\" ? (\n <Button\n attract\n onClick={() => features.open(\"invite\")}\n data-tutorial-anchor=\"leaderboard:invite\"\n >\n <Icon glyph=\"friends\" size={18} /> {t(\"invite\")}\n </Button>\n ) : null}\n {(board.RankRewards?.length ?? 0) > 0 ? (\n <Button tone=\"blue\" size=\"sm\" onClick={() => setPopup(\"ranks\")}>\n <Icon glyph=\"gift\" size={18} /> {t(\"rankRewards\")}\n </Button>\n ) : null}\n {milestoneCount > 0 ? (\n <span style={{ position: \"relative\" }}>\n <Button\n tone=\"blue\"\n size=\"sm\"\n onClick={() => setPopup(\"milestones\")}\n >\n <Icon glyph=\"star\" size={18} /> {t(\"milestones\")}\n </Button>\n <Badge count={progress?.HasUnclaimedMilestone ? 1 : 0} dot />\n </span>\n ) : null}\n </div>\n </div>\n );\n\n const list = (\n <div>\n <SectionTitle>{t(\"top\")}</SectionTitle>\n {loading ? (\n <Center minHeight={120}>\n <Spinner />\n </Center>\n ) : users.length === 0 ? (\n <EmptyState glyph=\"trophy\" text={t(\"empty\")} />\n ) : (\n <TopList users={users} me={me} />\n )}\n </div>\n );\n\n return (\n <div style={{ display: \"grid\", gap: 16 }}>\n <Tabs\n tabs={boards.map((b) => ({\n id: b.LeaderboardID,\n label: catalog.localize(b.DisplayName ?? b.LeaderboardID),\n }))}\n value={id}\n onChange={setBoardID}\n />\n {layout === \"desktop\" ? (\n <div\n style={{\n display: \"grid\",\n gap: 20,\n gridTemplateColumns: \"minmax(0, 5fr) minmax(0, 6fr)\",\n alignItems: \"start\",\n }}\n >\n {hero}\n {list}\n </div>\n ) : (\n <>\n {hero}\n {list}\n </>\n )}\n {popup === \"ranks\" ? (\n <RankRewardsPopup\n def={board}\n myRank={progress?.LastKnownRank ?? null}\n onClose={() => setPopup(null)}\n />\n ) : null}\n {popup === \"milestones\" ? (\n <MilestonesPopup\n def={board}\n progress={progress}\n onClaimed={load}\n onClose={() => setPopup(null)}\n />\n ) : null}\n </div>\n );\n}\n\nfunction Stat({ label, value }: { label: string; value: string }): ReactNode {\n return (\n <div style={{ display: \"grid\", justifyItems: \"center\" }}>\n <span style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {label}\n </span>\n <span style={{ ...outlined, fontSize: 20, color: v.gold }}>{value}</span>\n </div>\n );\n}\n\nfunction TopList({\n users,\n me,\n}: {\n users: LeaderboardUserEntry[];\n me: string | undefined;\n}): ReactNode {\n const stagger = useStagger();\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {users.slice(0, 100).map((entry, i) => {\n const place = entry.Rank ?? i + 1;\n const mine = entry.UserID === me;\n const medal = place <= 3 ? MEDALS[place - 1] : undefined;\n const name =\n entry.PublicProfile?.Username ||\n (mine ? t(\"you\") : `${t(\"player\")} ${entry.UserID.slice(-4)}`);\n return (\n <div\n key={entry.UserID}\n style={{\n ...panel,\n padding: \"8px 12px\",\n display: \"flex\",\n alignItems: \"center\",\n gap: 12,\n border: mine ? `2px solid ${v.gold}` : panel.border,\n background: mine\n ? \"linear-gradient(180deg, #4b5ae0 0%, #2a2f86 100%)\"\n : panel.background,\n ...stagger(i),\n }}\n >\n <span\n style={{\n width: 34,\n height: 34,\n borderRadius: \"50%\",\n display: \"grid\",\n placeItems: \"center\",\n background: medal ?? \"rgba(0,0,0,.3)\",\n ...outlined,\n fontSize: 15,\n color: medal ? \"#5a3a00\" : v.text,\n textShadow: medal ? \"none\" : outlined.textShadow,\n flex: \"none\",\n }}\n >\n {place}\n </span>\n <span\n style={{\n ...outlined,\n fontSize: 15,\n flex: 1,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n }}\n >\n {name}\n {mine && entry.PublicProfile?.Username ? ` (${t(\"you\")})` : \"\"}\n </span>\n <span\n style={{\n display: \"inline-flex\",\n alignItems: \"center\",\n gap: 4,\n ...outlined,\n fontSize: 16,\n color: v.gold,\n }}\n >\n <Icon glyph=\"star\" size={18} />\n {formatAmount(Number(entry.Score ?? 0))}\n </span>\n </div>\n );\n })}\n </div>\n );\n}\n\nfunction RankRewardsPopup({\n def,\n myRank,\n onClose,\n}: {\n def: LeaderboardDefinitionView;\n myRank: number | null;\n onClose: () => void;\n}): ReactNode {\n const stagger = useStagger();\n return (\n <Popup title={t(\"rankRewards\")} onClose={onClose}>\n <div style={{ display: \"grid\", gap: 8 }}>\n {(def.RankRewards ?? []).map((r, i) => {\n const lines = grantLines(r?.Rewards);\n const mineHere =\n myRank !== null &&\n rewardForRank({ RankRewards: [r] }, myRank).length > 0;\n return (\n <Card\n key={r?.Rank ?? i}\n highlight={mineHere}\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n alignItems: \"center\",\n padding: \"10px 12px\",\n ...stagger(i),\n }}\n >\n <span style={{ ...outlined, fontSize: 16 }}>#{r?.Rank}</span>\n <ResourceList lines={lines} />\n </Card>\n );\n })}\n </div>\n </Popup>\n );\n}\n\nfunction MilestonesPopup({\n def,\n progress,\n onClaimed,\n onClose,\n}: {\n def: LeaderboardDefinitionView & { LeaderboardID: string };\n progress: GetMyProgressResponse | null;\n onClaimed: () => Promise<void>;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const stagger = useStagger();\n const [busy, setBusy] = useState<string | null>(null);\n const earned = Number(\n progress?.MilestoneTokensEarned ?? progress?.ScoreEarnedThisCycle ?? 0,\n );\n const rows = milestoneRows(\n def,\n earned,\n cachedProgress(state?.Leaderboard, def.LeaderboardID).claimed,\n );\n\n const claim = async (milestoneID: string) => {\n setBusy(milestoneID);\n const res = await client.leaderboard.claimMilestone(\n def.LeaderboardID,\n milestoneID,\n );\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n celebrate(grantedBy(res.data));\n await onClaimed();\n };\n\n return (\n <Popup title={t(\"milestones\")} onClose={onClose} width={440}>\n <div style={{ display: \"grid\", gap: 10 }}>\n {rows.map((row, i) => (\n <Card\n key={row.id}\n highlight={row.state === \"ready\"}\n style={{\n display: \"grid\",\n gap: 8,\n opacity: row.state === \"claimed\" ? 0.6 : 1,\n ...stagger(i),\n }}\n >\n <div\n style={{\n display: \"flex\",\n justifyContent: \"space-between\",\n alignItems: \"center\",\n gap: 8,\n }}\n >\n <span style={{ ...outlined, fontSize: 15 }}>\n {catalog.localize(row.name)}\n </span>\n <ResourceList lines={row.reward} />\n </div>\n <ProgressBar\n value={Math.min(earned, row.required)}\n max={row.required}\n tone={row.state === \"ready\" ? \"gold\" : \"green\"}\n />\n {row.state === \"ready\" ? (\n <Button\n tone=\"gold\"\n size=\"sm\"\n attract\n busy={busy === row.id}\n onClick={() => void claim(row.id)}\n >\n {t(\"claim\")}\n </Button>\n ) : row.state === \"claimed\" ? (\n <div style={{ justifySelf: \"center\" }}>\n <Icon glyph=\"check\" size={26} />\n </div>\n ) : null}\n </Card>\n ))}\n </div>\n </Popup>\n );\n}\n\n/**\n * The reward of a finished round pops up by itself (Unity LeaderboardCycleRewardPopup): the place\n * the player took, what it earns, and \"Claim\". An always-on panel; it also keeps the badge.\n */\nexport function makeCycleRewardWatcher(features: FeatureRegistry) {\n return function CycleRewardWatcher(): ReactNode {\n const client = useIDosGamesClient();\n const [pending, setPending] = useState<{\n def: LeaderboardDefinitionView & { LeaderboardID: string };\n rank: number | null;\n } | null>(null);\n\n useEffect(() => {\n let cancelled = false;\n const check = async () => {\n const boards = enabledBoards(\n configSection<LeaderboardSection>(client, \"Leaderboard\"),\n );\n if (boards.length === 0) return;\n const res = await client.leaderboard.getProgressBatch(\n boards.map((b) => b.LeaderboardID),\n );\n if (cancelled || !res.ok) return;\n const progress = res.data.map((r) =>\n r.Success ? (r.Data ?? null) : null,\n );\n features.setBadge(\n \"leaderboard\",\n progress.filter(\n (p) => p?.HasUnclaimedReward || p?.HasUnclaimedMilestone,\n ).length,\n );\n const withReward = progress.find((p) => p?.HasUnclaimedReward);\n const def = withReward\n ? boards.find((b) => b.LeaderboardID === withReward.LeaderboardID)\n : undefined;\n if (withReward && def)\n setPending({ def, rank: withReward.LastKnownRank ?? null });\n };\n void check();\n const offA = client.on(\n \"leaderboard:cycleRewardClaimed\",\n () => void check(),\n );\n const offB = client.on(\n \"leaderboard:milestoneClaimed\",\n () => void check(),\n );\n return () => {\n cancelled = true;\n offA();\n offB();\n };\n }, [client]);\n\n if (!pending) return null;\n return (\n <CycleRewardPopup\n def={pending.def}\n rank={pending.rank}\n onClose={() => setPending(null)}\n />\n );\n };\n}\n\nfunction CycleRewardPopup({\n def,\n rank,\n onClose,\n}: {\n def: LeaderboardDefinitionView & { LeaderboardID: string };\n rank: number | null;\n onClose: () => void;\n}): ReactNode {\n const catalog = useCatalog();\n const lines = rewardForRank(def, rank);\n return (\n <Popup title={t(\"lastCycle\")} onClose={onClose} variant=\"center\" queued>\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 10 }}>\n <div className=\"idos-bounce\">\n <Icon glyph=\"trophy\" size={84} />\n </div>\n <div style={{ ...outlined, fontSize: 16 }}>\n {catalog.localize(def.DisplayName ?? def.LeaderboardID)}\n </div>\n <div style={{ ...outlined, fontSize: 14, color: v.textDim }}>\n {t(\"yourPlace\")}\n </div>\n <div style={{ ...outlined, fontSize: 34, color: v.gold }}>\n {rank ? `#${rank}` : t(\"notRanked\")}\n </div>\n {lines.length > 0 ? (\n <>\n <div style={{ ...outlined, fontSize: 14, color: v.textDim }}>\n {t(\"reward\")}\n </div>\n <ResourceList lines={lines} size={26} gap={14} />\n </>\n ) : null}\n </div>\n <ClaimCycle leaderboardID={def.LeaderboardID} />\n </Popup>\n );\n}\n\nfunction ClaimCycle({ leaderboardID }: { leaderboardID: string }): ReactNode {\n const client = useIDosGamesClient();\n const close = usePopupClose();\n const celebrate = useCelebrate();\n const toast = useToast();\n const [busy, setBusy] = useState(false);\n return (\n <Button\n tone=\"gold\"\n size=\"lg\"\n attract\n busy={busy}\n onClick={() => {\n setBusy(true);\n void client.leaderboard.claimCycleReward(leaderboardID).then((res) => {\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n close();\n celebrate(grantedBy(res.data));\n });\n }}\n >\n {t(\"claim\")}\n </Button>\n );\n}\n"
|
|
57
58
|
},
|
|
58
|
-
{
|
|
59
|
-
"path": "model.test.ts",
|
|
60
|
-
"content": "import { describe, expect, it } from \"vitest\";\nimport seed from \"../title-seed.json\";\nimport {\n boardAction,\n cachedProgress,\n claimableBoards,\n enabledBoards,\n milestoneRows,\n parseRankRange,\n rewardForRank,\n type LeaderboardSection,\n} from \"./model\";\n\ndescribe(\"leaderboards\", () => {\n const section = seed.Leaderboard as unknown as LeaderboardSection;\n\n it(\"the seeded weekly board comes first; disabled boards are skipped\", () => {\n expect(enabledBoards(section)[0]?.LeaderboardID).toBe(\"weekly_score\");\n expect(\n enabledBoards({ Definitions: { b: { IsEnabled: false }, a: {} } }).map(\n (b) => b.LeaderboardID,\n ),\n ).toEqual([\"a\"]);\n expect(enabledBoards(undefined)).toEqual([]);\n });\n\n it(\"the seeded invites board asks to invite, the score board to play\", () => {\n const boards = enabledBoards(section);\n expect(boards.map((b) => b.LeaderboardID)).toEqual([\n \"weekly_score\",\n \"weekly_invites\",\n ]);\n expect(boardAction(boards[0])).toBe(\"play\");\n expect(boardAction(boards[1])).toBe(\"invite\");\n expect(\n boardAction({\n ScoreSources: [\n { SourceType: \"ReferralInvite\" },\n { SourceType: \"CustomAction\" },\n ],\n }),\n ).toBe(\"play\");\n });\n\n it(\"reads rank ranges\", () => {\n expect(parseRankRange(\"1\")).toEqual([1, 1]);\n expect(parseRankRange(\"2-5\")).toEqual([2, 5]);\n expect(parseRankRange(\"51+\")).toEqual([51, Number.POSITIVE_INFINITY]);\n expect(parseRankRange(\"top\")).toBeNull();\n });\n\n it(\"finds the reward of a rank in the seeded rank rewards\", () => {\n const board = enabledBoards(section)[0];\n expect(rewardForRank(board, 1).length).toBeGreaterThan(0);\n expect(rewardForRank(board, 0)).toEqual([]);\n expect(rewardForRank(board, 1_000_000)).toEqual([]);\n });\n\n it(\"milestones in order, with what is claimable\", () => {\n const def = {\n Milestones: {\n m2: { RequiredProgress: 500 },\n m1: { RequiredProgress: 100 },\n },\n };\n expect(milestoneRows(def, 150, []).map((m) => [m.id, m.state])).toEqual([\n [\"m1\", \"ready\"],\n [\"m2\", \"locked\"],\n ]);\n expect(milestoneRows(def, 600, [\"m1\"])[0]?.state).toBe(\"claimed\");\n });\n\n it(\"finds a board's claimed milestones under the cache's cycle-doc key\", () => {\n const state = {\n ProgressByCycle: { T123_weekly_score: { ClaimedMilestoneIDs: [\"m1\"] } },\n };\n expect(cachedProgress(state, \"weekly_score\").claimed).toEqual([\"m1\"]);\n expect(cachedProgress(state, \"other\").claimed).toEqual([]);\n });\n\n it(\"badge counts boards with something to claim\", () => {\n expect(\n claimableBoards([\n { LeaderboardID: \"a\", HasUnclaimedReward: true },\n { LeaderboardID: \"b\" },\n null,\n ]),\n ).toBe(1);\n });\n});\n"
|
|
61
|
-
},
|
|
62
59
|
{
|
|
63
60
|
"path": "model.ts",
|
|
64
|
-
"content": "// Pure helpers of the leaderboards screen — covered by model.test.ts.\n\nimport type {\n GetMyProgressResponse,\n ResourceGrant,\n UserLeaderboardsState,\n} from \"@idosgames/core\";\nimport { grantLines, type ResourceLine } from \"@idosgames/react/ui\";\n\nexport interface MilestoneView {\n MilestoneID?: string | null;\n DisplayName?: string | null;\n RequiredProgress?: number | null;\n Rewards?: ResourceGrant | null;\n SortOrder?: number | null;\n}\n\nexport interface LeaderboardDefinitionView {\n LeaderboardID?: string;\n DisplayName?: string | null;\n ScoreDisplayName?: string | null;\n IsEnabled?: boolean | null;\n RankRewards?: Array<{\n Rank?: string | null;\n Rewards?: ResourceGrant | null;\n } | null> | null;\n Milestones?: Record<string, MilestoneView | null> | null;\n ScoreSources?: Array<{ SourceType?: string | null } | null> | null;\n}\n\n/**\n * What the board's call-to-action does. A board fed only by invites (`ReferralInvite` — the server\n * scores a new friend) asks the player to invite, not to play: playing would not move it.\n */\nexport function boardAction(\n def: LeaderboardDefinitionView | undefined,\n): \"invite\" | \"play\" {\n const sources = (def?.ScoreSources ?? []).filter((s) => s);\n return sources.length > 0 &&\n sources.every((s) => s?.SourceType === \"ReferralInvite\")\n ? \"invite\"\n : \"play\";\n}\n\nexport interface LeaderboardSection {\n Definitions?: Record<string, LeaderboardDefinitionView | null> | null;\n}\n\n/** Enabled boards, the preferred one first, then by id. */\nexport function enabledBoards(\n section: LeaderboardSection | undefined,\n preferred = \"weekly_score\",\n): Array<LeaderboardDefinitionView & { LeaderboardID: string }> {\n return Object.entries(section?.Definitions ?? {})\n .map(([id, def]) =>\n def ? { ...def, LeaderboardID: def.LeaderboardID ?? id } : null,\n )\n .filter(\n (d): d is LeaderboardDefinitionView & { LeaderboardID: string } =>\n d !== null && d.IsEnabled !== false,\n )\n .sort((a, b) =>\n a.LeaderboardID === preferred\n ? -1\n : b.LeaderboardID === preferred\n ? 1\n : a.LeaderboardID.localeCompare(b.LeaderboardID),\n );\n}\n\n/** \"1\" → [1,1]; \"2-5\" → [2,5]; \"51+\" → [51, ∞]; junk → null. */\nexport function parseRankRange(\n range: string | null | undefined,\n): [number, number] | null {\n const s = (range ?? \"\").trim();\n let m = /^(\\d+)$/.exec(s);\n if (m) return [Number(m[1]), Number(m[1])];\n m = /^(\\d+)\\s*-\\s*(\\d+)$/.exec(s);\n if (m) return [Number(m[1]), Number(m[2])];\n m = /^(\\d+)\\s*\\+$/.exec(s);\n if (m) return [Number(m[1]), Number.POSITIVE_INFINITY];\n return null;\n}\n\n/** What a rank earns under the board's rank rewards. */\nexport function rewardForRank(\n def: LeaderboardDefinitionView | undefined,\n rank: number | null | undefined,\n): ResourceLine[] {\n if (!rank || rank < 1) return [];\n for (const r of def?.RankRewards ?? []) {\n const range = parseRankRange(r?.Rank);\n if (range && rank >= range[0] && rank <= range[1])\n return grantLines(r?.Rewards);\n }\n return [];\n}\n\nexport interface MilestoneRow {\n id: string;\n name: string;\n required: number;\n reward: ResourceLine[];\n state: \"claimed\" | \"ready\" | \"locked\";\n}\n\n/** Score milestones of the current cycle, in order, with what is claimable. */\nexport function milestoneRows(\n def: LeaderboardDefinitionView | undefined,\n progress: number,\n claimed: readonly string[],\n): MilestoneRow[] {\n return Object.entries(def?.Milestones ?? {})\n .map(([id, m]) => ({ id: m?.MilestoneID ?? id, m }))\n .filter(\n (x): x is { id: string; m: MilestoneView } =>\n x.m !== null && x.m !== undefined,\n )\n .sort(\n (a, b) =>\n Number(a.m.RequiredProgress ?? 0) - Number(b.m.RequiredProgress ?? 0) ||\n Number(a.m.SortOrder ?? 0) - Number(b.m.SortOrder ?? 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 : progress >= required\n ? \"ready\"\n : \"locked\",\n };\n });\n}\n\n/** The cached progress of one board (the cache keys it by an internal cycle-doc id). */\nexport function cachedProgress(\n state: UserLeaderboardsState | null | undefined,\n leaderboardID: string,\n): { claimed: string[] } {\n for (const [key, p] of Object.entries(state?.ProgressByCycle ?? {})) {\n if (\n key === leaderboardID ||\n key.endsWith(`_${leaderboardID}`) ||\n key.includes(`_${leaderboardID}:`)\n )\n return { claimed: p.ClaimedMilestoneIDs ?? [] };\n }\n return { claimed: [] };\n}\n\n/** Badge: boards with a rank reward or a milestone waiting. */\nexport function claimableBoards(\n progress: ReadonlyArray<GetMyProgressResponse | null | undefined>,\n): number {\n return progress.filter(\n (p) =>\n p?.HasUnclaimedReward ||\n p?.HasUnclaimedMilestone ||\n (p?.ClaimableGraceCycles?.length ?? 0) > 0,\n ).length;\n}\n"
|
|
61
|
+
"content": "// Pure helpers of the leaderboards screen — covered by model.test.ts.\n\nimport type {\n GetMyProgressResponse,\n ResourceGrant,\n UserLeaderboardsState,\n} from \"@idosgames/core\";\nimport { grantLines, type ResourceLine } from \"@idosgames/react/ui\";\n\nexport interface MilestoneView {\n MilestoneID?: string | null;\n DisplayName?: string | null;\n RequiredProgress?: number | null;\n Rewards?: ResourceGrant | null;\n SortOrder?: number | null;\n}\n\nexport interface LeaderboardDefinitionView {\n LeaderboardID?: string;\n DisplayName?: string | null;\n ScoreDisplayName?: string | null;\n IsEnabled?: boolean | null;\n RankRewards?: Array<{\n Rank?: string | null;\n Rewards?: ResourceGrant | null;\n } | null> | null;\n Milestones?: Record<string, MilestoneView | null> | null;\n ScoreSources?: Array<{ SourceType?: string | null } | null> | null;\n}\n\n/**\n * What the board's call-to-action does. A board fed only by invites (`ReferralInvite` — the server\n * scores a new friend) asks the player to invite, not to play: playing would not move it.\n */\nexport function boardAction(\n def: LeaderboardDefinitionView | undefined,\n): \"invite\" | \"play\" {\n const sources = (def?.ScoreSources ?? []).filter((s) => s);\n return sources.length > 0 &&\n sources.every((s) => s?.SourceType === \"ReferralInvite\")\n ? \"invite\"\n : \"play\";\n}\n\nexport interface LeaderboardSection {\n Definitions?: Record<string, LeaderboardDefinitionView | null> | null;\n}\n\n/** Enabled boards, the preferred one first, then by id. */\nexport function enabledBoards(\n section: LeaderboardSection | undefined,\n preferred = \"weekly_score\",\n): Array<LeaderboardDefinitionView & { LeaderboardID: string }> {\n return Object.entries(section?.Definitions ?? {})\n .map(([id, def]) =>\n def ? { ...def, LeaderboardID: def.LeaderboardID ?? id } : null,\n )\n .filter(\n (d): d is LeaderboardDefinitionView & { LeaderboardID: string } =>\n d !== null && d.IsEnabled !== false,\n )\n .sort((a, b) =>\n a.LeaderboardID === preferred\n ? -1\n : b.LeaderboardID === preferred\n ? 1\n : a.LeaderboardID.localeCompare(b.LeaderboardID),\n );\n}\n\n/** \"1\" → [1,1]; \"2-5\" → [2,5]; \"51+\" → [51, ∞]; junk → null. */\nexport function parseRankRange(\n range: string | null | undefined,\n): [number, number] | null {\n const s = (range ?? \"\").trim();\n let m = /^(\\d+)$/.exec(s);\n if (m) return [Number(m[1]), Number(m[1])];\n m = /^(\\d+)\\s*-\\s*(\\d+)$/.exec(s);\n if (m) return [Number(m[1]), Number(m[2])];\n m = /^(\\d+)\\s*\\+$/.exec(s);\n if (m) return [Number(m[1]), Number.POSITIVE_INFINITY];\n return null;\n}\n\n/** What a rank earns under the board's rank rewards. */\nexport function rewardForRank(\n def: LeaderboardDefinitionView | undefined,\n rank: number | null | undefined,\n): ResourceLine[] {\n if (!rank || rank < 1) return [];\n for (const r of def?.RankRewards ?? []) {\n const range = parseRankRange(r?.Rank);\n if (range && rank >= range[0] && rank <= range[1])\n return grantLines(r?.Rewards);\n }\n return [];\n}\n\nexport interface MilestoneRow {\n id: string;\n name: string;\n required: number;\n reward: ResourceLine[];\n state: \"claimed\" | \"ready\" | \"locked\";\n}\n\n/** Score milestones of the current cycle, in order, with what is claimable. */\nexport function milestoneRows(\n def: LeaderboardDefinitionView | undefined,\n progress: number,\n claimed: readonly string[],\n): MilestoneRow[] {\n return Object.entries(def?.Milestones ?? {})\n .map(([id, m]) => ({ id: m?.MilestoneID ?? id, m }))\n .filter(\n (x): x is { id: string; m: MilestoneView } =>\n x.m !== null && x.m !== undefined,\n )\n .sort(\n (a, b) =>\n Number(a.m.RequiredProgress ?? 0) - Number(b.m.RequiredProgress ?? 0) ||\n Number(a.m.SortOrder ?? 0) - Number(b.m.SortOrder ?? 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 : progress >= required\n ? \"ready\"\n : \"locked\",\n };\n });\n}\n\n/** The cached progress of one board (the cache keys it by an internal cycle-doc id). */\nexport function cachedProgress(\n state: UserLeaderboardsState | null | undefined,\n leaderboardID: string,\n): { claimed: string[] } {\n for (const [key, p] of Object.entries(state?.ProgressByCycle ?? {})) {\n if (\n key === leaderboardID ||\n key.endsWith(`_${leaderboardID}`) ||\n key.includes(`_${leaderboardID}:`)\n )\n return { claimed: p.ClaimedMilestoneIDs ?? [] };\n }\n return { claimed: [] };\n}\n\n/** Badge: boards with a rank reward or a milestone waiting. */\nexport function claimableBoards(\n progress: ReadonlyArray<GetMyProgressResponse | null | undefined>,\n): number {\n return progress.filter(\n (p) =>\n p?.HasUnclaimedReward ||\n p?.HasUnclaimedMilestone ||\n (p?.ClaimableGraceCycles?.length ?? 0) > 0,\n ).length;\n}\n\n/** Whether the title has an enabled leaderboard — until it does, the lobby hides the tab. */\nexport function hasTitleData(section: LeaderboardSection | undefined): boolean {\n return Object.values(section?.Definitions ?? {}).some(\n (d) =>\n d != null && (d as { IsEnabled?: boolean | null }).IsEnabled !== false,\n );\n}\n"
|
|
65
62
|
},
|
|
66
63
|
{
|
|
67
64
|
"path": "module.ts",
|
|
68
|
-
"content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport {\n makeCycleRewardWatcher,\n makeLeaderboardScreen,\n} from \"./LeaderboardScreen\";\nimport { t } from \"./i18n\";\n\n// Leaderboards as a feature of the game. The always-on watcher pops up the reward of a finished\n// round and keeps the badge (a rank reward or a milestone waiting).\nexport const
|
|
65
|
+
"content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { configSection } from \"@idosgames/react/ui\";\nimport {\n makeCycleRewardWatcher,\n makeLeaderboardScreen,\n} from \"./LeaderboardScreen\";\nimport { hasTitleData, type LeaderboardSection } from \"./model\";\nimport { t } from \"./i18n\";\n\n// Leaderboards as a feature of the game. The always-on watcher pops up the reward of a finished\n// round and keeps the badge (a rank reward or a milestone waiting).\nexport const leaderboardModule: Module = defineModule({\n id: \"leaderboard\",\n meta: { name: \"Leaderboards\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"leaderboard\",\n label: t(\"title\"),\n icon: \"trophy\",\n group: \"events\",\n order: 30,\n primary: true,\n available: false,\n Screen: makeLeaderboardScreen(ctx.features),\n });\n ctx.features.setAvailable(\n \"leaderboard\",\n hasTitleData(\n configSection<LeaderboardSection>(ctx.client, \"Leaderboard\"),\n ),\n \"no enabled leaderboard on the title\",\n );\n ctx.registerPanel({\n id: \"cycle-reward\",\n slot: \"modal\",\n activeOnly: false,\n component: makeCycleRewardWatcher(ctx.features),\n });\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"leaderboard\" }),\n describeActions: {},\n });\n },\n});\n"
|
|
69
66
|
},
|
|
70
67
|
{
|
|
71
68
|
"path": "module.meta.json",
|
|
72
|
-
"content": "{\n \"id\": \"leaderboard
|
|
69
|
+
"content": "{\n \"id\": \"leaderboard\",\n \"type\": \"feature\",\n \"inBase\": true,\n \"summary\": \"Leaderboards screen for any game: boards as tabs, top players and my rank, rank rewards, score milestones and the reward of the last round.\",\n \"description\": \"A ready leaderboards screen for ANY game, built on the @idosgames/react/ui kit. The title's enabled leaderboards are tabs; each shows a card with the player's score, rank and the countdown to the end of the round, and the top list with the player highlighted and medals for the top three. Rank rewards and score milestones open as popups (claim a milestone there). When a round ends, the reward for the place the player took pops up by itself with a Claim button (like LeaderboardCycleRewardPopup in the Unity SDK). A game that has something to play for the score registers a 'play' feature and a Play button appears on the card. Registers the 'leaderboard' feature; its badge counts boards with a reward waiting. Ships title-seed.json: a weekly best-score leaderboard with rank rewards.\",\n \"provides\": [\n \"leaderboard screen with several boards as tabs\",\n \"top players and my rank\",\n \"round countdown\",\n \"rank rewards popup\",\n \"score milestones with claiming\",\n \"auto popup for the reward of the finished round\",\n \"badge for waiting rewards\",\n \"demo leaderboard settings\"\n ],\n \"tags\": [\n \"leaderboard\",\n \"ranking\",\n \"top\",\n \"milestones\",\n \"rank-rewards\",\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
|
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "marketplace",
|
|
3
|
+
"meta": {
|
|
4
|
+
"name": "Marketplace",
|
|
5
|
+
"type": "app",
|
|
6
|
+
"engine": "dom"
|
|
7
|
+
},
|
|
8
|
+
"catalog": {
|
|
9
|
+
"type": "feature",
|
|
10
|
+
"inBase": true,
|
|
11
|
+
"summary": "Marketplace screen for any game: player listings and auctions, selling, bids, direct trades, claims and history.",
|
|
12
|
+
"description": "A ready player marketplace (Unity Alikhan/Marketplace) for ANY game, built on the @idosgames/react/ui kit and client.marketplace. Tabs: the market (items grouped with their cheapest price), my lots, my bids, direct trades, things to claim and history. Everything detailed is a popup: an item's offers (cheapest listing first, then auctions ending soonest), one offer (buy, bid with the engine's minimum next bid, cancel your own), selling (pick an item — only tradable, never a worn one — then listing or auction, amount, price in a currency tradable between players, duration and the commission), and incoming trades (accept / decline). The server holds the escrow and settles every trade. Registers the 'marketplace' feature; its badge counts things to claim and trades waiting. Ships title-seed.json: listings and auctions for currencies with a 5% commission.",
|
|
13
|
+
"provides": [
|
|
14
|
+
"player marketplace screen",
|
|
15
|
+
"listings and auctions",
|
|
16
|
+
"sell popup with price and duration",
|
|
17
|
+
"bids with the minimum next bid",
|
|
18
|
+
"direct trades",
|
|
19
|
+
"claims and history",
|
|
20
|
+
"marketplace badge",
|
|
21
|
+
"demo marketplace rules"
|
|
22
|
+
],
|
|
23
|
+
"tags": [
|
|
24
|
+
"marketplace",
|
|
25
|
+
"trading",
|
|
26
|
+
"auction",
|
|
27
|
+
"listing",
|
|
28
|
+
"p2p",
|
|
29
|
+
"ui",
|
|
30
|
+
"screen",
|
|
31
|
+
"economy"
|
|
32
|
+
],
|
|
33
|
+
"author": {
|
|
34
|
+
"name": "iDos Games",
|
|
35
|
+
"url": "https://idosgames.com"
|
|
36
|
+
},
|
|
37
|
+
"version": "0.1.0"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@idosgames/core": "0.19.0",
|
|
41
|
+
"@idosgames/module-sdk": "0.6.0",
|
|
42
|
+
"@idosgames/react": "0.3.0",
|
|
43
|
+
"react": "19.2.7"
|
|
44
|
+
},
|
|
45
|
+
"contentHash": "f9cd6340a024f37d6be7101f08dbf9aafbf1a8a52aa05f7ff1f2f43db3bc071d",
|
|
46
|
+
"files": [
|
|
47
|
+
{
|
|
48
|
+
"path": "i18n.ts",
|
|
49
|
+
"content": "import { makeT } from \"@idosgames/react/ui\";\n\nconst EN = {\n title: \"Market\",\n market: \"Market\",\n myLots: \"My lots\",\n myBids: \"My bids\",\n trades: \"Trades\",\n claims: \"To claim\",\n history: \"History\",\n sell: \"Sell\",\n lots: \"lot|lots\",\n auctions: \"auction|auctions\",\n buy: \"Buy\",\n bid: \"Bid\",\n yourBid: \"Your bid\",\n currentBid: \"Current bid\",\n startingBid: \"Starting bid\",\n leading: \"You lead\",\n endsIn: \"Ends in\",\n listing: \"Fixed price\",\n auction: \"Auction\",\n price: \"Price\",\n amount: \"Amount\",\n duration: \"Duration\",\n hours: \"h\",\n commission: \"Commission\",\n create: \"Put up for sale\",\n cancel: \"Take back\",\n claim: \"Claim\",\n claimAll: \"Claim all\",\n accept: \"Accept\",\n decline: \"Decline\",\n gift: \"Gift\",\n wants: \"Wants\",\n from: \"From\",\n pickItem: \"Pick an item\",\n nothingToSell: \"Nothing to sell — tradable items you own appear here.\",\n noTradableCurrency:\n \"No currency of this game can be traded between players yet.\",\n empty: \"Nothing here yet.\",\n closed: \"The market is closed right now.\",\n disabled: \"No marketplace in this game. Enable it in LiveOps → Marketplace.\",\n sold: \"Sold\",\n bought: \"Bought\",\n seller: \"Seller\",\n you: \"You\",\n auctionRuns: \"The auction runs to its end — come back to claim the proceeds.\",\n} as const;\n\nexport const t = makeT<keyof typeof EN>(EN, {\n title: \"Рынок\",\n market: \"Рынок\",\n myLots: \"Мои лоты\",\n myBids: \"Мои ставки\",\n trades: \"Обмены\",\n claims: \"К получению\",\n history: \"История\",\n sell: \"Продать\",\n lots: \"лот|лота|лотов\",\n auctions: \"аукцион|аукциона|аукционов\",\n buy: \"Купить\",\n bid: \"Ставка\",\n yourBid: \"Ваша ставка\",\n currentBid: \"Текущая ставка\",\n startingBid: \"Начальная ставка\",\n leading: \"Вы лидируете\",\n endsIn: \"До конца\",\n listing: \"Фиксированная цена\",\n auction: \"Аукцион\",\n price: \"Цена\",\n amount: \"Количество\",\n duration: \"Срок\",\n hours: \"ч\",\n commission: \"Комиссия\",\n create: \"Выставить\",\n cancel: \"Снять\",\n claim: \"Забрать\",\n claimAll: \"Забрать всё\",\n accept: \"Принять\",\n decline: \"Отклонить\",\n gift: \"Подарок\",\n wants: \"Просит\",\n from: \"От\",\n pickItem: \"Выберите предмет\",\n nothingToSell:\n \"Продавать нечего — здесь появятся ваши предметы, которыми можно торговать.\",\n noTradableCurrency:\n \"В этой игре пока нет валюты, которой игроки могут торговать между собой.\",\n empty: \"Пока пусто.\",\n closed: \"Рынок сейчас закрыт.\",\n disabled: \"Рынка в этой игре нет. Включите его в LiveOps → Marketplace.\",\n sold: \"Продано\",\n bought: \"Куплено\",\n seller: \"Продавец\",\n you: \"Вы\",\n auctionRuns: \"Аукцион идёт до конца — возвращайтесь забрать выручку.\",\n});\n"
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"path": "index.ts",
|
|
53
|
+
"content": "// @idosgames/mod-marketplace — Marketplace screen for any game: browse lots by item, buy and bid, sell your items as listings or auctions, direct trades, claims and history.\n\nexport { marketplaceModule } from \"./module\";\n"
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
"path": "MarketplaceScreen.tsx",
|
|
57
|
+
"content": "import { useCallback, useEffect, useState, type ReactNode } from \"react\";\nimport type {\n MarketplaceDefinitions,\n MarketplaceGroupedOfferView,\n MarketplaceHistoryEntryView,\n MarketplaceMyStateResponse,\n MarketplaceOfferView,\n} from \"@idosgames/core\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n Center,\n EmptyState,\n Icon,\n IconButton,\n ItemTile,\n Popup,\n ResourceList,\n Spinner,\n Tabs,\n Timer,\n bundleLines,\n canAfford,\n errorText,\n formatAmount,\n grantedBy,\n outlined,\n panel,\n plural,\n useCatalog,\n useCelebrate,\n useNow,\n usePopupClose,\n useSound,\n useStagger,\n useToast,\n v,\n type ItemDefinitionView,\n} from \"@idosgames/react/ui\";\nimport {\n auctionDurations,\n commissionPercent,\n listingDurations,\n minNextBid,\n offerPrice,\n groupCounts,\n priceBundle,\n sellableItems,\n sortOffers,\n tradableCurrencies,\n type SellableItem,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// The marketplace (Unity Alikhan/Marketplace): a window of tabs — the market by item, my lots, my\n// bids, trades, things to claim, history — and everything with detail in popups: an item's lots, one\n// lot (buy / bid / take back), and selling (pick an item → listing or auction → price and duration).\n// The server holds every escrow and settles every trade.\n\ntype Tab = \"market\" | \"mine\" | \"bids\" | \"trades\" | \"claims\" | \"history\";\n\nexport function makeMarketplaceScreen(features: FeatureRegistry) {\n return function MarketplaceScreen(_: FeatureScreenProps): ReactNode {\n return <Marketplace features={features} />;\n };\n}\n\nfunction Marketplace({ features }: { features: FeatureRegistry }): ReactNode {\n const client = useIDosGamesClient();\n const [defs, setDefs] = useState<{\n defs: MarketplaceDefinitions | null;\n open: boolean;\n } | null>(null);\n const [mine, setMine] = useState<MarketplaceMyStateResponse | null>(null);\n const [tab, setTab] = useState<Tab>(\"market\");\n const [selling, setSelling] = useState(false);\n const [offer, setOffer] = useState<MarketplaceOfferView | null>(null);\n const [itemID, setItemID] = useState<string | null>(null);\n const [version, setVersion] = useState(0);\n\n const loadMine = useCallback(async () => {\n const res = await client.marketplace.getMyState();\n if (res.ok) {\n setMine(res.data);\n features.setBadge(\n \"marketplace\",\n (res.data.Claimables?.length ?? 0) +\n (res.data.IncomingTrades?.length ?? 0),\n );\n }\n }, [client, features]);\n\n useEffect(() => {\n void client.marketplace.getDefinitions().then((res) =>\n setDefs(\n res.ok\n ? {\n defs: res.data.Definitions ?? null,\n open:\n res.data.IsOpenNow !== false && res.data.GatePassed !== false,\n }\n : { defs: null, open: false },\n ),\n );\n void client.user.getUserInventory();\n void loadMine();\n }, [client, loadMine]);\n\n const refresh = () => {\n setVersion((n) => n + 1);\n void loadMine();\n };\n\n if (!defs)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (!defs.defs || defs.defs.Enabled === false)\n return <EmptyState glyph=\"market\" text={t(\"disabled\")} />;\n\n const tabs: Array<{ id: Tab; label: string; badge?: number }> = [\n { id: \"market\", label: t(\"market\") },\n { id: \"mine\", label: t(\"myLots\"), badge: 0 },\n { id: \"bids\", label: t(\"myBids\") },\n ...((mine?.IncomingTrades?.length ?? 0) +\n (mine?.OutgoingTrades?.length ?? 0) >\n 0\n ? [\n {\n id: \"trades\" as const,\n label: t(\"trades\"),\n badge: mine?.IncomingTrades?.length ?? 0,\n },\n ]\n : []),\n { id: \"claims\", label: t(\"claims\"), badge: mine?.Claimables?.length ?? 0 },\n { id: \"history\", label: t(\"history\") },\n ];\n\n return (\n // minmax(0, 1fr): the tab row scrolls instead of widening the column past the screen.\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 14,\n }}\n >\n {!defs.open ? (\n <div\n style={{\n ...panel,\n padding: 10,\n ...outlined,\n fontSize: 13,\n textAlign: \"center\",\n color: v.gold,\n }}\n >\n {t(\"closed\")}\n </div>\n ) : null}\n <div style={{ display: \"flex\", gap: 10, alignItems: \"center\" }}>\n <div style={{ flex: 1, minWidth: 0 }}>\n <Tabs tabs={tabs} value={tab} onChange={setTab} />\n </div>\n <Button\n tone=\"gold\"\n size=\"sm\"\n disabled={!defs.open}\n onClick={() => setSelling(true)}\n data-tutorial-anchor=\"market:sell\"\n >\n <Icon glyph=\"plus\" size={16} /> {t(\"sell\")}\n </Button>\n </div>\n <div key={`${tab}:${version}`}>\n {tab === \"market\" ? <MarketTab onPick={setItemID} /> : null}\n {tab === \"mine\" ? (\n <OfferList\n offers={[...(mine?.MyOffers ?? []), ...(mine?.MyBuyOrders ?? [])]}\n onPick={setOffer}\n />\n ) : null}\n {tab === \"bids\" ? (\n <OfferList offers={mine?.MyLeadingBids ?? []} onPick={setOffer} />\n ) : null}\n {tab === \"trades\" ? <Trades mine={mine} onDone={refresh} /> : null}\n {tab === \"claims\" ? (\n <Claims offers={mine?.Claimables ?? []} onDone={refresh} />\n ) : null}\n {tab === \"history\" ? <History /> : null}\n </div>\n {itemID ? (\n <ItemOffersPopup\n itemID={itemID}\n version={version}\n onPick={(o) => setOffer(o)}\n onClose={() => setItemID(null)}\n />\n ) : null}\n {offer ? (\n <OfferPopup\n offer={offer}\n defs={defs.defs}\n onDone={refresh}\n onClose={() => setOffer(null)}\n />\n ) : null}\n {selling ? (\n <SellPopup\n defs={defs.defs}\n onDone={refresh}\n onClose={() => setSelling(false)}\n />\n ) : null}\n </div>\n );\n}\n\n// ── Market ───────────────────────────────────────────────────────────────────────────────────\n\nfunction MarketTab({\n onPick,\n}: {\n onPick: (itemID: string) => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const stagger = useStagger();\n const [groups, setGroups] = useState<MarketplaceGroupedOfferView[] | null>(\n null,\n );\n useEffect(() => {\n void client.marketplace\n .getGroupedOffers()\n .then((res) => setGroups(res.ok ? (res.data.Groups ?? []) : []));\n }, [client]);\n if (!groups)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n const items = groups.filter((g) => g.GoodsItemID);\n if (items.length === 0)\n return <EmptyState glyph=\"market\" text={t(\"empty\")} />;\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {items.map((g, i) => (\n <Card\n key={g.GoodsItemID!}\n onClick={() => onPick(g.GoodsItemID!)}\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 12,\n padding: 10,\n ...stagger(i),\n }}\n >\n <ItemTile itemID={g.GoodsItemID!} size={56} />\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 15 }}>\n {catalog.itemName(g.GoodsItemID!)}\n </div>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {countsLine(groupCounts(g))}\n </div>\n </div>\n </Card>\n ))}\n </div>\n );\n}\n\nfunction countsLine({\n listings,\n auctions,\n}: {\n listings: number;\n auctions: number;\n}): string {\n return [\n listings > 0\n ? `${formatAmount(listings)} ${plural(listings, t(\"lots\"))}`\n : \"\",\n auctions > 0\n ? `${formatAmount(auctions)} ${plural(auctions, t(\"auctions\"))}`\n : \"\",\n ]\n .filter(Boolean)\n .join(\" · \");\n}\n\nfunction OfferRow({\n offer,\n onPick,\n index,\n}: {\n offer: MarketplaceOfferView;\n onPick: (o: MarketplaceOfferView) => void;\n index: number;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const stagger = useStagger();\n const now = useNow();\n const me = client.auth.context?.userID;\n const auction = offer.OfferType === \"Auction\";\n const bid = Number(\n offer.Auction?.CurrentBid ?? offer.Auction?.StartingBid ?? 0,\n );\n const bidLine = offer.Auction?.BidCurrencyID\n ? [\n {\n kind: \"currency\" as const,\n id: offer.Auction.BidCurrencyID,\n amount: bid,\n },\n ]\n : [];\n return (\n <Card\n onClick={() => onPick(offer)}\n highlight={auction && offer.Auction?.CurrentBidderID === me}\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 12,\n padding: 10,\n ...stagger(index),\n }}\n >\n {offer.GoodsItemID ? (\n <ItemTile\n itemID={offer.GoodsItemID}\n count={Number(offer.GoodsAmount ?? 1)}\n size={52}\n />\n ) : (\n <Icon glyph=\"coin\" size={40} />\n )}\n <div style={{ flex: 1, minWidth: 0, display: \"grid\", gap: 2 }}>\n <span style={{ ...outlined, fontSize: 14 }}>\n {offer.GoodsItemID\n ? catalog.itemName(offer.GoodsItemID)\n : offer.GoodsCurrencyID}\n </span>\n <span style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {auction\n ? t(\"auction\")\n : offer.OfferType === \"Listing\"\n ? t(\"listing\")\n : offer.OfferType}{\" \"}\n ·{\" \"}\n {offer.CreatorUserID === me\n ? t(\"you\")\n : (offer.CreatorPublicData?.Username ??\n `${t(\"seller\")} ${String(offer.CreatorUserID ?? \"\").slice(-4)}`)}\n </span>\n {offer.ExpiresAt ? (\n <Timer at={offer.ExpiresAt} now={now} label={t(\"endsIn\")} />\n ) : null}\n </div>\n <ResourceList lines={auction ? bidLine : offerPrice(offer)} size={18} />\n </Card>\n );\n}\n\nfunction OfferList({\n offers,\n onPick,\n}: {\n offers: MarketplaceOfferView[];\n onPick: (o: MarketplaceOfferView) => void;\n}): ReactNode {\n if (offers.length === 0)\n return <EmptyState glyph=\"market\" text={t(\"empty\")} />;\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {offers.map((o, i) => (\n <OfferRow key={o.OfferID ?? i} offer={o} onPick={onPick} index={i} />\n ))}\n </div>\n );\n}\n\n// `version` bumps after any trade on the screen: the list re-reads, so a bought or withdrawn lot leaves it.\nfunction ItemOffersPopup({\n itemID,\n version,\n onPick,\n onClose,\n}: {\n itemID: string;\n version: number;\n onPick: (o: MarketplaceOfferView) => void;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const [offers, setOffers] = useState<MarketplaceOfferView[] | null>(null);\n useEffect(() => {\n void client.marketplace\n .getOffersByItem(itemID)\n .then((res) =>\n setOffers(res.ok ? sortOffers(res.data.Offers ?? []) : []),\n );\n }, [client, itemID, version]);\n return (\n <Popup title={catalog.itemName(itemID)} onClose={onClose} width={480}>\n {offers === null ? (\n <Center minHeight={120}>\n <Spinner />\n </Center>\n ) : (\n <OfferList offers={offers} onPick={onPick} />\n )}\n </Popup>\n );\n}\n\n// ── One lot ──────────────────────────────────────────────────────────────────────────────────\n\nfunction OfferPopup({\n offer,\n defs,\n onDone,\n onClose,\n}: {\n offer: MarketplaceOfferView;\n defs: MarketplaceDefinitions;\n onDone: () => void;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const state = useUserState();\n const now = useNow();\n const me = client.auth.context?.userID;\n const own = offer.CreatorUserID === me;\n const auction = offer.OfferType === \"Auction\";\n const ended = offer.ExpiresAt\n ? new Date(offer.ExpiresAt).getTime() <= now\n : false;\n const balances = state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number }> | undefined;\n const price = offerPrice(offer);\n const [bid, setBid] = useState(minNextBid(offer, defs));\n const bidLine = offer.Auction?.BidCurrencyID\n ? [\n {\n kind: \"currency\" as const,\n id: offer.Auction.BidCurrencyID,\n amount: bid,\n },\n ]\n : [];\n\n return (\n <Popup\n title={\n offer.GoodsItemID ? catalog.itemName(offer.GoodsItemID) : t(\"market\")\n }\n onClose={onClose}\n >\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 8 }}>\n {offer.GoodsItemID ? (\n <ItemTile\n itemID={offer.GoodsItemID}\n count={Number(offer.GoodsAmount ?? 1)}\n size={96}\n />\n ) : null}\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {auction ? t(\"auction\") : t(\"listing\")} ·{\" \"}\n {own ? t(\"you\") : (offer.CreatorPublicData?.Username ?? t(\"seller\"))}\n </div>\n {offer.ExpiresAt ? (\n <Timer at={offer.ExpiresAt} now={now} label={t(\"endsIn\")} />\n ) : null}\n {auction ? (\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 4 }}>\n <span style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {offer.Auction?.CurrentBidderID\n ? t(\"currentBid\")\n : t(\"startingBid\")}\n </span>\n <ResourceList\n lines={\n offer.Auction?.BidCurrencyID\n ? [\n {\n kind: \"currency\",\n id: offer.Auction.BidCurrencyID,\n amount: Number(\n offer.Auction.CurrentBid ??\n offer.Auction.StartingBid ??\n 0,\n ),\n },\n ]\n : []\n }\n size={24}\n />\n {offer.Auction?.CurrentBidderID === me ? (\n <span style={{ ...outlined, fontSize: 13, color: v.green }}>\n {t(\"leading\")}\n </span>\n ) : null}\n </div>\n ) : (\n <ResourceList lines={price} size={24} />\n )}\n </div>\n {own ? (\n auction ? (\n ended ? (\n <Act\n label={t(\"claim\")}\n tone=\"gold\"\n run={() => client.marketplace.claimAuction(offer.OfferID!)}\n onDone={onDone}\n />\n ) : (\n // An auction runs to its end: bids are escrowed, so the seller cannot pull it.\n <div\n style={{\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {t(\"auctionRuns\")}\n </div>\n )\n ) : (\n <Act\n label={t(\"cancel\")}\n tone=\"grey\"\n run={() => client.marketplace.cancelListing(offer.OfferID!)}\n onDone={onDone}\n />\n )\n ) : auction ? (\n ended ? (\n <Act\n label={t(\"claim\")}\n tone=\"gold\"\n run={() => client.marketplace.claimAuction(offer.OfferID!)}\n onDone={onDone}\n />\n ) : (\n <>\n <Stepper\n value={bid}\n min={minNextBid(offer, defs)}\n onChange={setBid}\n />\n <Act\n label={\n <>\n {t(\"bid\")} <ResourceList lines={bidLine} size={18} />\n </>\n }\n tone=\"gold\"\n disabled={!canAfford(bidLine, balances)}\n run={() => client.marketplace.placeBid(offer.OfferID!, bid)}\n onDone={onDone}\n sound=\"purchase\"\n />\n </>\n )\n ) : (\n <Act\n label={\n <>\n {t(\"buy\")} <ResourceList lines={price} size={18} />\n </>\n }\n tone=\"gold\"\n disabled={!canAfford(price, balances)}\n run={() => client.marketplace.buy(offer.OfferID!)}\n onDone={onDone}\n sound=\"purchase\"\n celebrateGoods={offer}\n />\n )}\n </Popup>\n );\n}\n\ntype Result =\n { ok: true; data: unknown } | { ok: false; error?: string | null };\n\n/** A button that runs one marketplace call, then closes the popup and celebrates what came in. */\nfunction Act({\n label,\n tone,\n run,\n onDone,\n disabled = false,\n sound = \"claim\",\n celebrateGoods,\n}: {\n label: ReactNode;\n tone: \"gold\" | \"grey\" | \"green\" | \"red\";\n run: () => Promise<Result>;\n onDone: () => void;\n disabled?: boolean;\n sound?: \"claim\" | \"purchase\";\n celebrateGoods?: MarketplaceOfferView;\n}): ReactNode {\n const close = usePopupClose();\n const celebrate = useCelebrate();\n const toast = useToast();\n const play = useSound();\n const [busy, setBusy] = useState(false);\n return (\n <Button\n tone={tone}\n size=\"lg\"\n disabled={disabled}\n busy={busy}\n onClick={() => {\n setBusy(true);\n void run().then((res) => {\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n play(sound);\n close();\n onDone();\n const granted = grantedBy(res.data);\n if (granted.length > 0) celebrate(granted);\n else if (celebrateGoods?.GoodsItemID)\n celebrate([\n {\n kind: \"item\",\n id: celebrateGoods.GoodsItemID,\n amount: Number(celebrateGoods.GoodsAmount ?? 1),\n },\n ]);\n });\n }}\n >\n {label}\n </Button>\n );\n}\n\nfunction Stepper({\n value,\n min,\n onChange,\n max,\n}: {\n value: number;\n min: number;\n onChange: (n: number) => void;\n max?: number;\n}): ReactNode {\n const clamp = (n: number) =>\n Math.max(min, max !== undefined ? Math.min(max, n) : n);\n const step = Math.max(1, Math.round(value * 0.1));\n return (\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n justifyContent: \"center\",\n }}\n >\n <IconButton\n glyph=\"back\"\n label=\"-\"\n onClick={() => onChange(clamp(value - step))}\n size={36}\n />\n <input\n type=\"number\"\n inputMode=\"numeric\"\n value={value}\n min={min}\n max={max}\n onChange={(e) =>\n onChange(clamp(Math.floor(Number(e.target.value) || min)))\n }\n style={{\n width: 110,\n textAlign: \"center\",\n padding: \"8px 10px\",\n borderRadius: 12,\n border: `2px solid ${v.panelEdge}`,\n background: \"rgba(0,0,0,.3)\",\n color: v.text,\n fontFamily: v.font,\n fontSize: 18,\n fontWeight: 800,\n }}\n />\n <IconButton\n glyph=\"plus\"\n label=\"+\"\n onClick={() => onChange(clamp(value + step))}\n size={36}\n />\n </div>\n );\n}\n\n// ── Trades, claims, history ──────────────────────────────────────────────────────────────────\n\nfunction Trades({\n mine,\n onDone,\n}: {\n mine: MarketplaceMyStateResponse | null;\n onDone: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const toast = useToast();\n const celebrate = useCelebrate();\n const incoming = mine?.IncomingTrades ?? [];\n const outgoing = mine?.OutgoingTrades ?? [];\n const answer = async (offer: MarketplaceOfferView, accept: boolean) => {\n const res = await client.marketplace.respondDirectTrade(\n offer.OfferID!,\n accept,\n );\n if (!res.ok) return toast(errorText(res.error), \"error\");\n if (accept && offer.GoodsItemID)\n celebrate([\n {\n kind: \"item\",\n id: offer.GoodsItemID,\n amount: Number(offer.GoodsAmount ?? 1),\n },\n ]);\n onDone();\n };\n if (incoming.length + outgoing.length === 0)\n return <EmptyState glyph=\"swords\" text={t(\"empty\")} />;\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {incoming.map((o) => (\n <Card key={o.OfferID!} highlight style={{ display: \"grid\", gap: 8 }}>\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 10 }}>\n {o.GoodsItemID ? (\n <ItemTile itemID={o.GoodsItemID} size={52} />\n ) : null}\n <div style={{ flex: 1 }}>\n <div style={{ ...outlined, fontSize: 14 }}>\n {o.GoodsItemID ? catalog.itemName(o.GoodsItemID) : \"\"}\n </div>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"from\")}{\" \"}\n {o.CreatorPublicData?.Username ??\n String(o.CreatorUserID ?? \"\").slice(-6)}{\" \"}\n ·{\" \"}\n {bundleLines(o.Price as never).length ? t(\"wants\") : t(\"gift\")}\n </div>\n </div>\n <ResourceList lines={bundleLines(o.Price as never)} />\n </div>\n <div\n style={{ display: \"grid\", gridTemplateColumns: \"1fr 1fr\", gap: 8 }}\n >\n <Button tone=\"grey\" size=\"sm\" onClick={() => void answer(o, false)}>\n {t(\"decline\")}\n </Button>\n <Button tone=\"green\" size=\"sm\" onClick={() => void answer(o, true)}>\n {t(\"accept\")}\n </Button>\n </div>\n </Card>\n ))}\n {outgoing.map((o, i) => (\n <OfferRow\n key={o.OfferID!}\n offer={o}\n index={i}\n onPick={() =>\n void client.marketplace\n .cancelDirectTrade(o.OfferID!)\n .then(() => onDone())\n }\n />\n ))}\n </div>\n );\n}\n\nfunction Claims({\n offers,\n onDone,\n}: {\n offers: MarketplaceOfferView[];\n onDone: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const celebrate = useCelebrate();\n const toast = useToast();\n const [busy, setBusy] = useState<string | null>(null);\n const claim = async (offer: MarketplaceOfferView | null) => {\n setBusy(offer?.OfferID ?? \"all\");\n const res = offer\n ? offer.OfferType === \"Auction\"\n ? await client.marketplace.claimAuction(offer.OfferID!)\n : await client.marketplace.claimBack(offer.OfferID!)\n : await client.marketplace.claimBack();\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n celebrate(grantedBy(res.data));\n onDone();\n };\n if (offers.length === 0) return <EmptyState glyph=\"gift\" text={t(\"empty\")} />;\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {offers.length > 1 ? (\n <Button\n tone=\"gold\"\n attract\n busy={busy === \"all\"}\n onClick={() => void claim(null)}\n >\n {t(\"claimAll\")}\n </Button>\n ) : null}\n {offers.map((o, i) => (\n <div\n key={o.OfferID!}\n style={{\n display: \"grid\",\n gridTemplateColumns: \"1fr auto\",\n gap: 8,\n alignItems: \"center\",\n }}\n >\n <OfferRow offer={o} index={i} onPick={() => void claim(o)} />\n <Button\n tone=\"gold\"\n size=\"sm\"\n busy={busy === o.OfferID}\n onClick={() => void claim(o)}\n >\n {t(\"claim\")}\n </Button>\n </div>\n ))}\n </div>\n );\n}\n\nfunction History(): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const stagger = useStagger();\n const me = client.auth.context?.userID;\n const [entries, setEntries] = useState<MarketplaceHistoryEntryView[] | null>(\n null,\n );\n useEffect(() => {\n void client.marketplace\n .getHistory()\n .then((res) => setEntries(res.ok ? (res.data.Entries ?? []) : []));\n }, [client]);\n if (!entries)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (entries.length === 0)\n return <EmptyState glyph=\"clock\" text={t(\"empty\")} />;\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {entries.map((e, i) => (\n <Card\n key={e.OfferID ?? i}\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n padding: 10,\n ...stagger(i),\n }}\n >\n {e.GoodsItemID ? (\n <ItemTile\n itemID={e.GoodsItemID}\n count={Number(e.GoodsAmount ?? 1)}\n size={48}\n />\n ) : null}\n <div style={{ flex: 1 }}>\n <div style={{ ...outlined, fontSize: 14 }}>\n {e.GoodsItemID ? catalog.itemName(e.GoodsItemID) : \"\"}\n </div>\n <div\n style={{\n ...outlined,\n fontSize: 12,\n color: e.SellerUserID === me ? v.green : v.gold,\n }}\n >\n {e.SellerUserID === me ? t(\"sold\") : t(\"bought\")}\n </div>\n </div>\n <ResourceList lines={bundleLines(e.PricePaid as never)} size={16} />\n </Card>\n ))}\n </div>\n );\n}\n\n// ── Selling ──────────────────────────────────────────────────────────────────────────────────\n\nfunction SellPopup({\n defs,\n onDone,\n onClose,\n}: {\n defs: MarketplaceDefinitions;\n onDone: () => void;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const [picked, setPicked] = useState<SellableItem | null>(null);\n const items = catalog.items as Map<\n string,\n ItemDefinitionView & { CatalogID?: string; IsTradable?: boolean | null }\n >;\n const sellable = sellableItems(state?.InventoryV2 as never, items);\n const currencies = tradableCurrencies(catalog.currencies as never);\n const [kind, setKind] = useState<\"listing\" | \"auction\">(\"listing\");\n const [amount, setAmount] = useState(1);\n const [currency, setCurrency] = useState(currencies[0] ?? \"CO\");\n const [price, setPrice] = useState(100);\n const durations =\n kind === \"listing\" ? listingDurations(defs) : auctionDurations(defs);\n const [hours, setHours] = useState(durations[0] ?? 24);\n const auctionsOn = defs.Auctions?.Enabled !== false;\n\n if (!picked)\n return (\n <Popup title={t(\"pickItem\")} onClose={onClose} width={480}>\n {currencies.length === 0 ? (\n <EmptyState glyph=\"market\" text={t(\"noTradableCurrency\")} />\n ) : sellable.length === 0 ? (\n <EmptyState glyph=\"market\" text={t(\"nothingToSell\")} />\n ) : (\n <div\n style={{\n display: \"flex\",\n flexWrap: \"wrap\",\n gap: 10,\n justifyContent: \"center\",\n }}\n >\n {sellable.map((s) => (\n <ItemTile\n key={s.itemID}\n itemID={s.itemID}\n count={s.amount}\n size={72}\n onClick={() => setPicked(s)}\n />\n ))}\n </div>\n )}\n </Popup>\n );\n\n const create = () =>\n kind === \"listing\"\n ? client.marketplace.createListing(\n picked.itemID,\n picked.catalogID,\n picked.instances.length ? 1 : amount,\n priceBundle(currency, price) as never,\n hours,\n picked.instances.length ? [picked.instances[0]!] : undefined,\n )\n : client.marketplace.createAuction(\n picked.itemID,\n picked.catalogID,\n picked.instances.length ? 1 : amount,\n \"VirtualCurrency\" as never,\n price,\n hours,\n {\n bidCurrencyID: currency,\n itemInstanceIDs: picked.instances.length\n ? [picked.instances[0]!]\n : undefined,\n },\n );\n\n return (\n <Popup\n title={catalog.itemName(picked.itemID)}\n onClose={onClose}\n width={440}\n >\n <div style={{ display: \"grid\", justifyItems: \"center\" }}>\n <ItemTile\n itemID={picked.itemID}\n count={picked.instances.length ? 1 : amount}\n size={88}\n />\n </div>\n {auctionsOn ? (\n <Tabs\n tabs={[\n { id: \"listing\", label: t(\"listing\") },\n { id: \"auction\", label: t(\"auction\") },\n ]}\n value={kind}\n onChange={(k) => {\n setKind(k);\n setHours(\n (k === \"listing\"\n ? listingDurations(defs)\n : auctionDurations(defs))[0] ?? 24,\n );\n }}\n />\n ) : null}\n {picked.instances.length === 0 && picked.amount > 1 ? (\n <Field label={t(\"amount\")}>\n <Stepper\n value={amount}\n min={1}\n max={picked.amount}\n onChange={setAmount}\n />\n </Field>\n ) : null}\n <Field label={kind === \"listing\" ? t(\"price\") : t(\"startingBid\")}>\n {currencies.length > 1 ? (\n <Tabs\n tabs={currencies.map((c) => ({\n id: c,\n label: catalog.currencyName(c),\n glyph: catalog.iconOf({ kind: \"currency\", id: c, amount: 0 }),\n }))}\n value={currency}\n onChange={setCurrency}\n />\n ) : null}\n <Stepper value={price} min={1} onChange={setPrice} />\n </Field>\n <Field label={t(\"duration\")}>\n <Tabs\n tabs={durations.map((h) => ({\n id: String(h),\n label: `${h} ${t(\"hours\")}`,\n }))}\n value={String(hours)}\n onChange={(h) => setHours(Number(h))}\n />\n </Field>\n {commissionPercent(defs) > 0 ? (\n <div\n style={{\n ...outlined,\n fontSize: 12,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {t(\"commission\")}: {commissionPercent(defs)}%\n </div>\n ) : null}\n <Act label={t(\"create\")} tone=\"gold\" run={create} onDone={onDone} />\n </Popup>\n );\n}\n\nfunction Field({\n label,\n children,\n}: {\n label: string;\n children: ReactNode;\n}): ReactNode {\n return (\n <div style={{ display: \"grid\", gap: 6 }}>\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>{label}</div>\n {children}\n </div>\n );\n}\n"
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
"path": "model.ts",
|
|
61
|
+
"content": "// Pure helpers of the marketplace screen (Unity Alikhan/Marketplace) — covered by model.test.ts.\n// The server holds the escrow and settles every trade; these only decide what the screen shows.\n\nimport type {\n MarketplaceDefinitions,\n MarketplaceOfferView,\n} from \"@idosgames/core\";\nimport {\n bundleLines,\n type ItemDefinitionView,\n type ResourceLine,\n} from \"@idosgames/react/ui\";\n\n/** The price of a listing, as lines. */\nexport function offerPrice(offer: MarketplaceOfferView): ResourceLine[] {\n return bundleLines(offer.Price as never);\n}\n\n/** The single currency of a price (listings are priced in one currency in this screen). */\nexport function priceAmount(offer: MarketplaceOfferView): number {\n return offerPrice(offer)[0]?.amount ?? 0;\n}\n\n/** Offers for one item, cheapest listing first, then auctions ending soonest. */\nexport function sortOffers(\n offers: MarketplaceOfferView[],\n): MarketplaceOfferView[] {\n const listings = offers\n .filter((o) => o.OfferType === \"Listing\")\n .sort((a, b) => priceAmount(a) - priceAmount(b));\n const auctions = offers\n .filter((o) => o.OfferType === \"Auction\")\n .sort((a, b) =>\n String(a.ExpiresAt ?? \"\").localeCompare(String(b.ExpiresAt ?? \"\")),\n );\n return [\n ...listings,\n ...auctions,\n ...offers.filter(\n (o) => o.OfferType !== \"Listing\" && o.OfferType !== \"Auction\",\n ),\n ];\n}\n\n/** The lowest bid the server accepts next: the current bid plus the configured step (or the start). */\nexport function minNextBid(\n offer: MarketplaceOfferView,\n defs: MarketplaceDefinitions | null | undefined,\n): number {\n const a = offer.Auction;\n const current = Number(a?.CurrentBid ?? 0);\n if (!a || current <= 0 || !a.CurrentBidderID)\n return Math.max(1, Number(a?.StartingBid ?? 1));\n // The engine's rule: step = max(ceil(current × fraction), MinBidStepAbsolute); 0.05 = 5%.\n const fraction = Number(defs?.Auctions?.MinBidStepPercent ?? 0.05);\n const abs = Number(defs?.Auctions?.MinBidStepAbsolute ?? 0);\n const step = Math.max(1, abs, Math.ceil(current * fraction));\n return current + step;\n}\n\n/** Durations the title allows for a listing (hours), with a sane fallback. */\nexport function listingDurations(\n defs: MarketplaceDefinitions | null | undefined,\n): number[] {\n const allowed = (defs?.Listings?.AllowedDurationsHours ?? []).filter(\n (h) => h > 0,\n );\n return allowed.length > 0 ? [...allowed].sort((a, b) => a - b) : [24, 72];\n}\n\n/** Durations for an auction within the title's bounds. */\nexport function auctionDurations(\n defs: MarketplaceDefinitions | null | undefined,\n): number[] {\n const min = Math.max(1, Number(defs?.Auctions?.MinDurationHours ?? 1));\n const max = Math.max(min, Number(defs?.Auctions?.MaxDurationHours ?? 72));\n return [...new Set([min, Math.min(max, 24), max])]\n .filter((h) => h >= min && h <= max)\n .sort((a, b) => a - b);\n}\n\n/** Commission the seller pays, in percent for display. The config holds a FRACTION (0.05 = 5%). */\nexport function commissionPercent(\n defs: MarketplaceDefinitions | null | undefined,\n): number {\n return Math.round(Number(defs?.Commission?.Percent ?? 0.05) * 1000) / 10;\n}\n\nexport interface SellableItem {\n itemID: string;\n catalogID: string;\n amount: number;\n /** Unstackable: instance ids the player can put up (not equipped). */\n instances: string[];\n}\n\n/** Owned items the player may sell: tradable definitions, instances that are not worn. */\nexport function sellableItems(\n inventory:\n | {\n Items?: Record<\n string,\n { StackableAmount?: number; TotalAmount?: number } | null\n > | null;\n UnstackableItems?: Record<\n string,\n { ItemID?: string; EquippedSlot?: unknown } | null\n > | null;\n }\n | null\n | undefined,\n items: Map<\n string,\n ItemDefinitionView & { CatalogID?: string; IsTradable?: boolean | null }\n >,\n): SellableItem[] {\n const out = new Map<string, SellableItem>();\n for (const [id, t] of Object.entries(inventory?.Items ?? {})) {\n const def = items.get(id);\n if (!def || def.IsTradable === false || def.IsStackable === false) continue;\n const amount = Number(t?.StackableAmount ?? t?.TotalAmount ?? 0);\n if (amount > 0)\n out.set(id, {\n itemID: id,\n catalogID: def.CatalogID ?? \"Item\",\n amount,\n instances: [],\n });\n }\n for (const [instanceID, inst] of Object.entries(\n inventory?.UnstackableItems ?? {},\n )) {\n if (!inst?.ItemID || inst.EquippedSlot) continue;\n const def = items.get(inst.ItemID);\n if (!def || def.IsTradable === false) continue;\n const entry = out.get(inst.ItemID) ?? {\n itemID: inst.ItemID,\n catalogID: def.CatalogID ?? \"Item\",\n amount: 0,\n instances: [],\n };\n entry.instances.push(instanceID);\n entry.amount = entry.instances.length;\n out.set(inst.ItemID, entry);\n }\n return [...out.values()].sort((a, b) => a.itemID.localeCompare(b.itemID));\n}\n\n/** Listings and auctions of a market group. The server fills CountsByType (the *Count fields are\n * optional mirrors of it), so both are read. */\nexport function groupCounts(g: {\n CountsByType?: Partial<Record<string, number>> | null;\n ListingCount?: number | null;\n AuctionCount?: number | null;\n}): { listings: number; auctions: number } {\n return {\n listings: Number(g.ListingCount ?? g.CountsByType?.Listing ?? 0),\n auctions: Number(g.AuctionCount ?? g.CountsByType?.Auction ?? 0),\n };\n}\n\n/** Currencies a lot can be priced in: the engine refuses one not tradable between players. */\nexport function tradableCurrencies(\n currencies: Record<\n string,\n { Permissions?: { IsTradable?: boolean | null } | null } | null | undefined\n >,\n): string[] {\n return Object.entries(currencies)\n .filter(([, c]) => c?.Permissions?.IsTradable === true)\n .map(([id]) => id);\n}\n\n/** A price bundle of one currency. */\nexport function priceBundle(\n currencyID: string,\n amount: number,\n): {\n Entries: Array<{\n Type: \"VirtualCurrency\";\n CurrencyID: string;\n Amount: number;\n }>;\n} {\n return {\n Entries: [\n {\n Type: \"VirtualCurrency\",\n CurrencyID: currencyID,\n Amount: Math.max(1, Math.floor(amount)),\n },\n ],\n };\n}\n\n/** Badge of the marketplace: things to claim + incoming trades waiting for an answer. */\nexport function marketplaceBadge(\n state:\n | { Claimables?: unknown[] | null; IncomingTrades?: unknown[] | null }\n | null\n | undefined,\n): number {\n return (\n (state?.Claimables?.length ?? 0) + (state?.IncomingTrades?.length ?? 0)\n );\n}\n\n/** Whether the title's marketplace is set up and not switched off — else the lobby hides the tab. */\nexport function hasTitleData(\n section: { Enabled?: boolean | null } | null | undefined,\n): boolean {\n return section != null && section.Enabled !== false;\n}\n"
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
"path": "module.ts",
|
|
65
|
+
"content": "import { createElement, useEffect } from \"react\";\nimport {\n defineModule,\n type FeatureRegistry,\n type Module,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport { configSection } from \"@idosgames/react/ui\";\nimport { makeMarketplaceScreen } from \"./MarketplaceScreen\";\nimport { hasTitleData, marketplaceBadge } from \"./model\";\nimport { t } from \"./i18n\";\n\n// The marketplace as a feature of the game. Badge: things to claim (won lots, expired escrow) and\n// incoming trades waiting for an answer — read once at start and after every marketplace action.\nexport const marketplaceModule: Module = defineModule({\n id: \"marketplace\",\n meta: { name: \"Marketplace\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"marketplace\",\n label: t(\"title\"),\n icon: \"market\",\n group: \"economy\",\n order: 70,\n available: false,\n Screen: makeMarketplaceScreen(ctx.features),\n });\n ctx.features.setAvailable(\n \"marketplace\",\n hasTitleData(\n configSection<{ Enabled?: boolean | null }>(ctx.client, \"Marketplace\"),\n ),\n \"the marketplace is not set up on the title, or switched off\",\n );\n ctx.registerPanel({\n id: \"badge\",\n slot: \"modal\",\n activeOnly: false,\n component: makeBadgeWatcher(ctx.features),\n });\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"marketplace\" }),\n describeActions: {},\n });\n },\n});\n\nfunction makeBadgeWatcher(features: FeatureRegistry) {\n return function MarketplaceBadge() {\n const client = useIDosGamesClient();\n useEffect(() => {\n // A title without the section has no marketplace — no request at all.\n if (!configSection(client, \"Marketplace\")) return;\n const check = () =>\n void client.marketplace\n .getMyState()\n .then(\n (res) =>\n res.ok &&\n features.setBadge(\"marketplace\", marketplaceBadge(res.data)),\n );\n check();\n const id = setInterval(check, 5 * 60_000);\n return () => clearInterval(id);\n }, [client]);\n return createElement(\"span\", { hidden: true });\n };\n}\n"
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"path": "module.meta.json",
|
|
69
|
+
"content": "{\n \"id\": \"marketplace\",\n \"type\": \"feature\",\n \"inBase\": true,\n \"summary\": \"Marketplace screen for any game: player listings and auctions, selling, bids, direct trades, claims and history.\",\n \"description\": \"A ready player marketplace (Unity Alikhan/Marketplace) for ANY game, built on the @idosgames/react/ui kit and client.marketplace. Tabs: the market (items grouped with their cheapest price), my lots, my bids, direct trades, things to claim and history. Everything detailed is a popup: an item's offers (cheapest listing first, then auctions ending soonest), one offer (buy, bid with the engine's minimum next bid, cancel your own), selling (pick an item — only tradable, never a worn one — then listing or auction, amount, price in a currency tradable between players, duration and the commission), and incoming trades (accept / decline). The server holds the escrow and settles every trade. Registers the 'marketplace' feature; its badge counts things to claim and trades waiting. Ships title-seed.json: listings and auctions for currencies with a 5% commission.\",\n \"provides\": [\n \"player marketplace screen\",\n \"listings and auctions\",\n \"sell popup with price and duration\",\n \"bids with the minimum next bid\",\n \"direct trades\",\n \"claims and history\",\n \"marketplace badge\",\n \"demo marketplace rules\"\n ],\n \"tags\": [\n \"marketplace\",\n \"trading\",\n \"auction\",\n \"listing\",\n \"p2p\",\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"
|
|
70
|
+
}
|
|
71
|
+
]
|
|
72
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "mini-game",
|
|
3
|
+
"meta": {
|
|
4
|
+
"name": "Mini-game",
|
|
5
|
+
"type": "game",
|
|
6
|
+
"engine": "dom",
|
|
7
|
+
"genre": "casual"
|
|
8
|
+
},
|
|
9
|
+
"catalog": {
|
|
10
|
+
"type": "template",
|
|
11
|
+
"summary": "The smallest complete game: a 15-second 'catch the stars' round launched from the lobby's Play button, with a leaderboard score.",
|
|
12
|
+
"description": "A tiny game of its own mode (React/DOM, no engine): tap as many stars as you can in 15 seconds, gold stars are worth 3. The score goes to the title's leaderboard, and a round counts for quests that ask for games_played, for timed events that pay for the 'play_round' action and for season chains open to clients. The platform gives it to a new title when no other game fits the idea — it shows where a game plugs into the base (sign-in → lobby → Play) and is meant to be replaced by the real game. No demo settings of its own: it plays on the base's world.",
|
|
13
|
+
"provides": [
|
|
14
|
+
"a small tap game launched from the lobby",
|
|
15
|
+
"score submitted to the leaderboard",
|
|
16
|
+
"rounds counted by quests, events and the season",
|
|
17
|
+
"placeholder game to replace with the real one"
|
|
18
|
+
],
|
|
19
|
+
"tags": [
|
|
20
|
+
"starter",
|
|
21
|
+
"mini-game",
|
|
22
|
+
"casual",
|
|
23
|
+
"tap",
|
|
24
|
+
"leaderboard"
|
|
25
|
+
],
|
|
26
|
+
"author": {
|
|
27
|
+
"name": "iDos Games",
|
|
28
|
+
"url": "https://idosgames.com"
|
|
29
|
+
},
|
|
30
|
+
"version": "0.1.0"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@idosgames/core": "0.19.0",
|
|
34
|
+
"@idosgames/module-sdk": "0.6.0",
|
|
35
|
+
"@idosgames/react": "0.3.0",
|
|
36
|
+
"react": "19.2.7"
|
|
37
|
+
},
|
|
38
|
+
"contentHash": "26089593df1b939aee3317743caeda47f82971bfd85dbd73eb9e95a9bc5e3d5c",
|
|
39
|
+
"files": [
|
|
40
|
+
{
|
|
41
|
+
"path": "i18n.ts",
|
|
42
|
+
"content": "import { makeT } from \"@idosgames/react/ui\";\n\n// Strings of the mini-game. The leaderboard's name is the publisher's word (title config).\n\nconst EN = {\n title: \"Star catch\",\n play: \"Play\",\n playAgain: \"Play again\",\n tapStars: \"Tap the stars!\",\n timeLeft: \"Time\",\n score: \"Score\",\n yourScore: \"Your score\",\n submitted: \"Submitted to the leaderboard\",\n seasonStatus: \"season status\",\n toTable: \"Leaderboard\",\n best: \"Tap as many stars as you can in 15 seconds. Gold stars are worth 3.\",\n} as const;\n\nexport type StringKey = keyof typeof EN;\n\nexport const t = makeT<StringKey>(EN, {\n title: \"Ловец звёзд\",\n play: \"Играть\",\n playAgain: \"Ещё раз\",\n tapStars: \"Ловите звёзды!\",\n timeLeft: \"Время\",\n score: \"Очки\",\n yourScore: \"Ваш результат\",\n submitted: \"Результат отправлен в таблицу\",\n seasonStatus: \"статуса сезона\",\n toTable: \"Таблица лидеров\",\n best: \"Поймайте как можно больше звёзд за 15 секунд. Золотая — 3 очка.\",\n});\n"
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"path": "index.ts",
|
|
46
|
+
"content": "// @idosgames/mod-mini-game — \"Star catch\", the smallest complete game module: a 15-second tap round\n// launched from the lobby's Play button, with a leaderboard score.\n//\n// Primary export is the module manifest; the pieces are exported too, so a project can reuse the\n// round inside its own game after copying this into src/modules/.\n\nexport { miniGameModule } from \"./module\";\nexport { makeMiniGamePanel } from \"./MiniGamePanel\";\nexport { MiniGame } from \"./MiniGame\";\nexport * from \"./model\";\n"
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"path": "MiniGame.tsx",
|
|
50
|
+
"content": "import { useEffect, useRef, useState, type ReactNode } from \"react\";\nimport {\n Button,\n Icon,\n outlined,\n panel,\n useSound,\n v,\n} from \"@idosgames/react/ui\";\nimport { t } from \"./i18n\";\n\n// \"Tap the stars\": a 15-second round that produces a score for the leaderboard. Deliberately tiny —\n// the placeholder a project replaces with its real game, keeping the same contract: play, then hand\n// the score to onFinish (the play screen submits it and reports the round to quests).\n\nconst ROUND_MS = 15_000;\nconst SPAWN_MS = 520;\nconst LIFE_MS = 1_150;\n\ninterface Star {\n id: number;\n x: number;\n y: number;\n golden: boolean;\n bornAt: number;\n}\n\nexport function MiniGame({\n onFinish,\n onCancel,\n height = 380,\n}: {\n onFinish: (score: number) => void;\n onCancel: () => void;\n height?: number;\n}): ReactNode {\n const play = useSound();\n const [stars, setStars] = useState<Star[]>([]);\n const [score, setScore] = useState(0);\n const [leftMs, setLeftMs] = useState(ROUND_MS);\n const [pops, setPops] = useState<\n Array<{ id: number; x: number; y: number; value: number }>\n >([]);\n const scoreRef = useRef(0);\n const nextId = useRef(1);\n const finished = useRef(false);\n // Latest callback without restarting the round when the parent re-renders.\n const finish = useRef(onFinish);\n finish.current = onFinish;\n\n useEffect(() => {\n const started = performance.now();\n const spawn = setInterval(() => {\n const now = performance.now();\n setStars((list) => [\n ...list.filter((s) => now - s.bornAt < LIFE_MS),\n {\n id: nextId.current++,\n x: 8 + Math.random() * 78,\n y: 10 + Math.random() * 72,\n golden: Math.random() < 0.15,\n bornAt: now,\n },\n ]);\n }, SPAWN_MS);\n const clock = setInterval(() => {\n const left = Math.max(0, ROUND_MS - (performance.now() - started));\n setLeftMs(left);\n if (left === 0 && !finished.current) {\n finished.current = true;\n clearInterval(spawn);\n clearInterval(clock);\n finish.current(scoreRef.current);\n }\n }, 100);\n return () => {\n clearInterval(spawn);\n clearInterval(clock);\n };\n }, []);\n\n const hit = (star: Star) => {\n const value = star.golden ? 3 : 1;\n play(star.golden ? \"coin\" : \"countTick\");\n scoreRef.current += value;\n setScore(scoreRef.current);\n setStars((list) => list.filter((s) => s.id !== star.id));\n const popID = nextId.current++;\n setPops((list) => [...list, { id: popID, x: star.x, y: star.y, value }]);\n setTimeout(\n () => setPops((list) => list.filter((p) => p.id !== popID)),\n 600,\n );\n };\n\n return (\n <div\n style={{\n ...panel,\n position: \"relative\",\n height: `min(${height}px, 70vh)`,\n minHeight: 300,\n overflow: \"hidden\",\n background:\n \"radial-gradient(circle at 50% 30%, #3b47b8 0%, #151a52 75%)\",\n touchAction: \"manipulation\",\n userSelect: \"none\",\n }}\n >\n <div\n style={{\n position: \"absolute\",\n top: 10,\n left: 12,\n right: 12,\n display: \"flex\",\n justifyContent: \"space-between\",\n zIndex: 2,\n }}\n >\n <span style={{ ...outlined, fontSize: 18 }}>\n {t(\"score\")}: {score}\n </span>\n <span\n style={{\n ...outlined,\n fontSize: 18,\n color: leftMs < 4000 ? v.red : v.text,\n }}\n >\n {t(\"timeLeft\")}: {Math.ceil(leftMs / 1000)}\n </span>\n </div>\n <div\n style={{\n position: \"absolute\",\n top: 40,\n left: 0,\n right: 0,\n textAlign: \"center\",\n ...outlined,\n fontSize: 14,\n color: v.textDim,\n opacity: score === 0 ? 1 : 0,\n transition: \"opacity .4s\",\n }}\n >\n {t(\"tapStars\")}\n </div>\n {stars.map((s) => (\n <button\n key={s.id}\n type=\"button\"\n aria-label=\"star\"\n onPointerDown={() => hit(s)}\n style={{\n position: \"absolute\",\n left: `${s.x}%`,\n top: `${s.y}%`,\n width: 56,\n height: 56,\n marginLeft: -28,\n marginTop: -28,\n border: \"none\",\n background: \"none\",\n padding: 0,\n cursor: \"pointer\",\n animation: `idos-star ${v.normal} ease-out`,\n }}\n >\n <Icon\n glyph=\"star\"\n size={s.golden ? 56 : 44}\n color={s.golden ? \"#ffe066\" : \"#fff27a\"}\n />\n </button>\n ))}\n {pops.map((p) => (\n <span\n key={p.id}\n style={{\n position: \"absolute\",\n left: `${p.x}%`,\n top: `${p.y}%`,\n ...outlined,\n fontSize: 22,\n color: v.gold,\n pointerEvents: \"none\",\n animation: \"idos-rise-in .6s ease reverse\",\n }}\n >\n +{p.value}\n </span>\n ))}\n <div\n style={{\n position: \"absolute\",\n bottom: 10,\n left: 0,\n right: 0,\n display: \"flex\",\n justifyContent: \"center\",\n }}\n >\n <Button tone=\"grey\" size=\"sm\" onClick={onCancel}>\n ✕\n </Button>\n </div>\n </div>\n );\n}\n"
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"path": "MiniGamePanel.tsx",
|
|
54
|
+
"content": "import {\n useCallback,\n useState,\n type ComponentType,\n type ReactNode,\n} from \"react\";\nimport type { IDosGamesClient } from \"@idosgames/core\";\nimport type { FeatureRegistry } from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n Icon,\n configSection,\n errorText,\n formatAmount,\n outlined,\n panel,\n screenBackground,\n useCatalog,\n useGameLayout,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport { MiniGame } from \"./MiniGame\";\nimport {\n GAMES_PLAYED_METRIC,\n PLAY_ROUND_ACTION,\n ROUND_STATUS,\n eventsCountingRounds,\n pickLeaderboard,\n questsCountGames,\n seasonChainsForRounds,\n type LeaderboardSection,\n type QuestSection,\n type SeasonSection,\n type TimedEventSection,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// The mini-game's mode — the whole screen while it is on (the lobby's \"Play\" starts it, the base's\n// round button top-left returns to the lobby). A round submits its score to the title's leaderboard,\n// reports itself to quests that count rounds, and feeds the events and seasons that pay for a round;\n// the table itself is the leaderboard module's screen (\"Leaderboard\" opens it through ctx.features).\n\nexport function makeMiniGamePanel(features: FeatureRegistry): ComponentType {\n return function MiniGamePanel(): ReactNode {\n return (\n <div\n className=\"idos-scroll\"\n style={{\n position: \"absolute\",\n inset: 0,\n overflowY: \"auto\",\n background: screenBackground,\n fontFamily: v.font,\n color: v.text,\n // Room for the base's \"To the lobby\" button in the top-left corner.\n padding: \"64px 14px 24px\",\n pointerEvents: \"auto\",\n }}\n >\n <div style={{ maxWidth: 760, margin: \"0 auto\" }}>\n <Round features={features} />\n </div>\n </div>\n );\n };\n}\n\nfunction Round({ features }: { features: FeatureRegistry }): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const kit = useUiKit();\n const layout = useGameLayout();\n const [playing, setPlaying] = useState(false);\n const [result, setResult] = useState<number | null>(null);\n const [bonus, setBonus] = useState<string[]>([]);\n const catalog = useCatalog();\n const board = pickLeaderboard(\n configSection<LeaderboardSection>(client, \"Leaderboard\"),\n );\n\n const finish = useCallback(\n async (score: number) => {\n setPlaying(false);\n setResult(score);\n kit.play(score > 0 ? \"levelUp\" : \"notify\");\n if (board && score > 0) {\n const res = await client.leaderboard.submitScore(\n board.LeaderboardID,\n score,\n );\n if (!res.ok) toast(errorText(res.error), \"error\");\n }\n // One round played — only if a quest counts rounds (otherwise the server refuses the metric).\n if (questsCountGames(configSection<QuestSection>(client, \"Quest\")))\n await client.quest.addQuestProgress(GAMES_PLAYED_METRIC, 1);\n if (score > 0) setBonus(await rewardRound(client, catalog.localize));\n },\n [client, board, toast, kit, catalog],\n );\n\n if (playing)\n return (\n <MiniGame\n height={layout === \"desktop\" ? 560 : layout === \"tablet\" ? 480 : 380}\n onFinish={(s) => void finish(s)}\n onCancel={() => setPlaying(false)}\n />\n );\n\n return (\n <div\n className=\"idos-shine\"\n style={{\n ...panel,\n padding: 20,\n display: \"grid\",\n gap: 12,\n justifyItems: \"center\",\n background: \"linear-gradient(180deg, #5a3fd0 0%, #25207a 100%)\",\n maxWidth: 520,\n margin: \"0 auto\",\n }}\n >\n <div className=\"idos-bounce\">\n <Icon glyph=\"star\" size={72} />\n </div>\n <div\n style={{\n ...outlined,\n fontSize: 15,\n textAlign: \"center\",\n color: v.textDim,\n }}\n >\n {t(\"best\")}\n </div>\n {result !== null ? (\n <div\n style={{\n ...outlined,\n fontSize: 18,\n color: v.gold,\n animation: `idos-pop-in ${v.slow} ease-out both`,\n }}\n >\n {t(\"yourScore\")}: {formatAmount(result)}\n {result > 0 && board ? (\n <div\n style={{ fontSize: 13, color: v.textDim, textAlign: \"center\" }}\n >\n {t(\"submitted\")}\n </div>\n ) : null}\n {bonus.map((line) => (\n <div\n key={line}\n style={{ fontSize: 14, color: v.green, textAlign: \"center\" }}\n >\n {line}\n </div>\n ))}\n </div>\n ) : null}\n <Button\n attract\n size=\"lg\"\n style={{ minWidth: 180 }}\n onClick={() => {\n setResult(null);\n setBonus([]);\n setPlaying(true);\n }}\n data-tutorial-anchor=\"play:start\"\n >\n ▶ {result === null ? t(\"play\") : t(\"playAgain\")}\n </Button>\n {board && features.get(\"leaderboard\")?.available ? (\n <Button\n tone=\"blue\"\n size=\"sm\"\n onClick={() =>\n features.open(\"leaderboard\", {\n leaderboardID: board.LeaderboardID,\n })\n }\n >\n <Icon glyph=\"trophy\" size={18} /> {t(\"toTable\")}\n </Button>\n ) : null}\n </div>\n );\n}\n\n/**\n * A round's share of the title's live-ops: tokens of every active event that pays for a round (a\n * client-safe CustomAction, capped per day by the event) and status in every season chain open to\n * clients. Returns what was granted, for the result card. Best-effort: a refusal (a cap reached,\n * no active season) just grants nothing.\n */\nasync function rewardRound(\n client: IDosGamesClient,\n localize: (text: string | null | undefined) => string,\n): Promise<string[]> {\n const lines: string[] = [];\n const eventIDs = eventsCountingRounds(\n configSection<TimedEventSection>(client, \"TimedEvent\"),\n );\n if (eventIDs.length > 0) {\n const active = await client.timedEvent.getActiveEvents();\n for (const e of active.ok ? (active.data.ActiveEvents ?? []) : []) {\n if (\n !e.TimedEventID ||\n !eventIDs.includes(e.TimedEventID) ||\n e.CanEarn === false ||\n !e.Type\n )\n continue;\n const res = await client.timedEvent.grantTokens(\n e.Type,\n e.TimedEventID,\n \"CustomAction\",\n undefined,\n undefined,\n { ActionName: PLAY_ROUND_ACTION },\n );\n const op = res.ok\n ? (res.data.Grant?.Standard?.EventTokens?.[0] as\n { Applied?: number; Amount?: number } | undefined)\n : undefined;\n const amount = Number(op?.Applied ?? op?.Amount ?? 0);\n if (amount > 0)\n lines.push(\n `+${formatAmount(amount)} ${localize(e.Content?.Token?.DisplayName ?? e.Content?.DisplayName ?? e.TimedEventID)}`,\n );\n }\n }\n for (const chain of seasonChainsForRounds(\n configSection<SeasonSection>(client, \"Season\"),\n )) {\n const res = await client.season.grantStatusTokens(chain, ROUND_STATUS);\n if (res.ok && Number(res.data.AmountGranted ?? 0) > 0)\n lines.push(\n `+${formatAmount(Number(res.data.AmountGranted))} ${t(\"seasonStatus\")}`,\n );\n }\n return lines;\n}\n"
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"path": "model.ts",
|
|
58
|
+
"content": "// Pure helpers of the mini-game — covered by model.test.ts.\n\n/** Metric the mini-game reports once per finished round. A quest counts it only if it asks for it. */\nexport const GAMES_PLAYED_METRIC = \"games_played\";\n\n/** Leaderboard the mini-game submits to when the title has it; otherwise the first enabled one. */\nexport const PREFERRED_LEADERBOARD = \"weekly_score\";\n\nexport interface LeaderboardSection {\n Definitions?: Record<\n string,\n {\n LeaderboardID?: string;\n DisplayName?: string | null;\n IsEnabled?: boolean | null;\n ScoreSources?: unknown[] | null;\n } | null\n > | null;\n}\n\n/**\n * The leaderboard the mini-game plays for: the preferred id if enabled, else the first enabled one.\n * A board fed by its own sources (invites, purchases…) is not a score table — the server fills it\n * and refuses a submitted score, so the mini-game never picks it.\n */\nexport function pickLeaderboard(\n section: LeaderboardSection | undefined,\n): { LeaderboardID: string; DisplayName?: string | null } | null {\n const defs = Object.entries(section?.Definitions ?? {})\n .map(([id, def]) =>\n def ? { ...def, LeaderboardID: def.LeaderboardID ?? id } : null,\n )\n .filter(\n (\n d,\n ): d is {\n LeaderboardID: string;\n DisplayName?: string | null;\n IsEnabled?: boolean | null;\n } => d !== null && d.IsEnabled !== false,\n )\n .filter(\n (d) =>\n ((d as { ScoreSources?: unknown[] | null }).ScoreSources ?? [])\n .length === 0,\n );\n return (\n defs.find((d) => d.LeaderboardID === PREFERRED_LEADERBOARD) ??\n defs.sort((a, b) => a.LeaderboardID.localeCompare(b.LeaderboardID))[0] ??\n null\n );\n}\n\nexport interface QuestSection {\n Quests?: Record<\n string,\n {\n Objectives?: Record<\n string,\n { MetricID?: string | null; Source?: string } | null\n > | null;\n } | null\n > | null;\n}\n\n/** Whether some quest counts the mini-game's rounds — only then is the metric worth reporting. */\nexport function questsCountGames(section: QuestSection | undefined): boolean {\n return Object.values(section?.Quests ?? {}).some((q) =>\n Object.values(q?.Objectives ?? {}).some(\n (o) =>\n o?.MetricID === GAMES_PLAYED_METRIC &&\n (o.Source ?? \"ClientApi\") === \"ClientApi\",\n ),\n );\n}\n\n// ── A round feeds the title's events and seasons ─────────────────────────────────────────────\n// Found in the config, so the mini-game needs neither the events nor the season module installed.\n\n/** The CustomAction a round of the mini-game reports to timed events. */\nexport const PLAY_ROUND_ACTION = \"play_round\";\n/** Status a round grants to every season chain open to clients. */\nexport const ROUND_STATUS = 10;\n\nexport interface TimedEventSection {\n Definitions?: Record<\n string,\n {\n Content?: EventContentView | null;\n Events?: Array<{ Content?: EventContentView | null } | null> | null;\n } | null\n > | null;\n}\ntype EventContentView = {\n TokenSources?: Array<{\n SourceType?: string | null;\n Params?: Record<string, string> | null;\n } | null> | null;\n};\n\nfunction countsRounds(content: EventContentView | null | undefined): boolean {\n return (content?.TokenSources ?? []).some(\n (s) =>\n s?.SourceType === \"CustomAction\" &&\n s.Params?.ActionName === PLAY_ROUND_ACTION,\n );\n}\n\n/** Events (by id) whose token sources pay for a round — own content or any phase of a chain. */\nexport function eventsCountingRounds(\n section: TimedEventSection | undefined,\n): string[] {\n return Object.entries(section?.Definitions ?? {})\n .filter(\n ([, d]) =>\n d &&\n (countsRounds(d.Content) ||\n (d.Events ?? []).some((p) => countsRounds(p?.Content))),\n )\n .map(([id]) => id);\n}\n\nexport interface SeasonSection {\n Chains?: Record<\n string,\n { GrantTokensAccessMode?: string | null } | null\n > | null;\n}\n\n/**\n * Season chains the client may grant status to. Only an explicit \"Both\" / \"ClientOnly\" opens a\n * chain: the engine's default is ServerOnly, so a missing field means \"server only\" too.\n */\nexport function seasonChainsForRounds(\n section: SeasonSection | undefined,\n): string[] {\n return Object.entries(section?.Chains ?? {})\n .filter(\n ([, c]) =>\n c?.GrantTokensAccessMode === \"Both\" ||\n c?.GrantTokensAccessMode === \"ClientOnly\",\n )\n .map(([id]) => id);\n}\n"
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"path": "module.ts",
|
|
62
|
+
"content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { makeMiniGamePanel } from \"./MiniGamePanel\";\nimport { t } from \"./i18n\";\n\n// The smallest complete game module: a mode of its own (a route + one full-screen panel, no scene)\n// that the base's lobby offers as \"Play\". It is what a new title gets when no other game fits the idea\n// — the placeholder the real game replaces, with the same contract: register a route, draw the mode,\n// and leave the lobby, balances and wallet to the base.\nexport const miniGameModule: Module = defineModule({\n id: \"mini-game\",\n meta: {\n name: \"Mini-game\",\n type: \"game\",\n genre: \"casual\",\n engine: \"dom\",\n },\n setup(ctx) {\n ctx.registerRoute({ id: \"mini-game\", label: t(\"title\"), icon: \"⭐\" });\n ctx.registerPanel({\n id: \"round\",\n slot: \"overlay\",\n component: makeMiniGamePanel(ctx.features),\n });\n\n // The round is ordinary React in the DOM: an agent reads it and presses its buttons like a person.\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", mode: ctx.modes.current() }),\n describeActions: {},\n });\n },\n});\n"
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
"path": "module.meta.json",
|
|
66
|
+
"content": "{\n \"id\": \"mini-game\",\n \"type\": \"template\",\n \"summary\": \"The smallest complete game: a 15-second 'catch the stars' round launched from the lobby's Play button, with a leaderboard score.\",\n \"description\": \"A tiny game of its own mode (React/DOM, no engine): tap as many stars as you can in 15 seconds, gold stars are worth 3. The score goes to the title's leaderboard, and a round counts for quests that ask for games_played, for timed events that pay for the 'play_round' action and for season chains open to clients. The platform gives it to a new title when no other game fits the idea — it shows where a game plugs into the base (sign-in → lobby → Play) and is meant to be replaced by the real game. No demo settings of its own: it plays on the base's world.\",\n \"provides\": [\n \"a small tap game launched from the lobby\",\n \"score submitted to the leaderboard\",\n \"rounds counted by quests, events and the season\",\n \"placeholder game to replace with the real one\"\n ],\n \"tags\": [\"starter\", \"mini-game\", \"casual\", \"tap\", \"leaderboard\"],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.0\"\n}\n"
|
|
67
|
+
}
|
|
68
|
+
]
|
|
69
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"id": "profile
|
|
2
|
+
"id": "profile",
|
|
3
3
|
"meta": {
|
|
4
4
|
"name": "Profile",
|
|
5
5
|
"type": "app",
|
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
},
|
|
8
8
|
"catalog": {
|
|
9
9
|
"type": "feature",
|
|
10
|
+
"inBase": true,
|
|
10
11
|
"summary": "Profile and settings for any game: name and ID, sound, volume and motion, language, analytics consent, tutorials, log out and account deletion.",
|
|
11
|
-
"description": "A ready profile screen (Unity Alikhan/Username, UI/HUD/Settings, Runtime/UI/Consent, Localization) for ANY game, built on the @idosgames/react/ui kit. The player's name (rename in a popup with the server's rules: 3–24 characters, trimmed) and ID with copy; settings: sound on/off, volume and 'fewer animations' — the kit's player prefs, kept on this device and applied to every screen at once — the game's language (the title's languages, when there are two or more) and consent to anonymous analytics (client.analytics). Buttons: Tutorials (when
|
|
12
|
+
"description": "A ready profile screen (Unity Alikhan/Username, UI/HUD/Settings, Runtime/UI/Consent, Localization) for ANY game, built on the @idosgames/react/ui kit. The player's name (rename in a popup with the server's rules: 3–24 characters, trimmed) and ID with copy; settings: sound on/off, volume and 'fewer animations' — the kit's player prefs, kept on this device and applied to every screen at once — the game's language (the title's languages, when there are two or more) and consent to anonymous analytics (client.analytics). Buttons: Tutorials (when tutorials is installed), Log out (confirmed) and Delete account — a popup that unlocks only after the player types the confirmation word, then deletes this game's data for the player. Registers the 'profile' feature; the lobby's profile button opens it. No title settings needed.",
|
|
12
13
|
"provides": [
|
|
13
14
|
"player name and ID",
|
|
14
15
|
"rename popup",
|
|
@@ -36,11 +37,11 @@
|
|
|
36
37
|
},
|
|
37
38
|
"dependencies": {
|
|
38
39
|
"@idosgames/core": "0.19.0",
|
|
39
|
-
"@idosgames/module-sdk": "0.
|
|
40
|
+
"@idosgames/module-sdk": "0.6.0",
|
|
40
41
|
"@idosgames/react": "0.3.0",
|
|
41
42
|
"react": "19.2.7"
|
|
42
43
|
},
|
|
43
|
-
"contentHash": "
|
|
44
|
+
"contentHash": "13df00ce0a01385a46bf73d7b50c6308526dbd3d5e1fb585e00b16df388d677d",
|
|
44
45
|
"files": [
|
|
45
46
|
{
|
|
46
47
|
"path": "i18n.ts",
|
|
@@ -48,11 +49,7 @@
|
|
|
48
49
|
},
|
|
49
50
|
{
|
|
50
51
|
"path": "index.ts",
|
|
51
|
-
"content": "// @idosgames/mod-profile
|
|
52
|
-
},
|
|
53
|
-
{
|
|
54
|
-
"path": "model.test.ts",
|
|
55
|
-
"content": "import { describe, expect, it } from \"vitest\";\nimport {\n deleteUnlocked,\n languageChoices,\n nameProblem,\n shortAddress,\n unlinkRefusal,\n volumePercent,\n} from \"./model\";\n\ndescribe(\"profile\", () => {\n it(\"applies the server's name rules: trimmed, 3–24 characters\", () => {\n expect(nameProblem(\" An \")).toBe(\"short\");\n expect(nameProblem(\" Ann \")).toBeNull();\n expect(nameProblem(\"Anna\")).toBeNull();\n expect(nameProblem(\"x\".repeat(25))).toBe(\"long\");\n });\n\n it(\"shows the volume as a clamped percentage\", () => {\n expect(volumePercent(0.35)).toBe(35);\n expect(volumePercent(2)).toBe(100);\n expect(volumePercent(undefined)).toBe(100);\n });\n\n it(\"deletion unlocks only on the typed word, in any case\", () => {\n expect(deleteUnlocked(\" удалить \", \"УДАЛИТЬ\")).toBe(true);\n expect(deleteUnlocked(\"удали\", \"УДАЛИТЬ\")).toBe(false);\n });\n\n it(\"shortens a wallet address to its ends\", () => {\n expect(shortAddress(\"0x1234567890abcdef1234567890abcdef12345678\")).toBe(\n \"0x1234…5678\",\n );\n expect(shortAddress(\"0xabc\")).toBe(\"0xabc\");\n expect(shortAddress(null)).toBe(\"\");\n });\n\n it(\"names the server's unlink refusals and leaves the rest as they are\", () => {\n expect(unlinkRefusal(\"WALLET_IS_LOGIN_WALLET\")).toBe(\"unlinkLogin\");\n expect(unlinkRefusal(\"WALLET_CHANGES_TOO_FREQUENT\")).toBe(\"unlinkTooMany\");\n expect(unlinkRefusal(\"WALLET_NOT_LINKED\")).toBe(\"unlinkMissing\");\n expect(unlinkRefusal(\"RATE_LIMIT_EXCEEDED\")).toBeNull();\n });\n\n it(\"lists the title's languages in its order\", () => {\n expect(\n languageChoices([\n { Locale: \"ru\", DisplayName: \"Русский\", Order: 1 },\n { Locale: \"en\", DisplayName: \"English\", Order: 0 },\n { Locale: null },\n ]),\n ).toEqual([\n { id: \"en\", name: \"English\" },\n { id: \"ru\", name: \"Русский\" },\n ]);\n });\n});\n"
|
|
52
|
+
"content": "// @idosgames/mod-profile — Profile for any game: name, ID, language, sound and motion settings, analytics consent, log out and account deletion.\n\nexport { profileModule } from \"./module\";\n"
|
|
56
53
|
},
|
|
57
54
|
{
|
|
58
55
|
"path": "model.ts",
|
|
@@ -60,7 +57,7 @@
|
|
|
60
57
|
},
|
|
61
58
|
{
|
|
62
59
|
"path": "module.ts",
|
|
63
|
-
"content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { makeProfileScreen } from \"./ProfileScreen\";\nimport { t } from \"./i18n\";\n\n// Profile and settings as a feature of the game. The
|
|
60
|
+
"content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { makeProfileScreen } from \"./ProfileScreen\";\nimport { t } from \"./i18n\";\n\n// Profile and settings as a feature of the game. The lobby of the base opens it from its profile\n// button (features.open(\"profile\")).\nexport const profileModule: Module = defineModule({\n id: \"profile\",\n meta: { name: \"Profile\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"profile\",\n label: t(\"title\"),\n icon: \"user\",\n group: \"profile\",\n order: 100,\n Screen: makeProfileScreen(ctx.features),\n });\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"profile\" }),\n describeActions: {},\n });\n },\n});\n"
|
|
64
61
|
},
|
|
65
62
|
{
|
|
66
63
|
"path": "ProfileScreen.tsx",
|
|
@@ -68,7 +65,7 @@
|
|
|
68
65
|
},
|
|
69
66
|
{
|
|
70
67
|
"path": "module.meta.json",
|
|
71
|
-
"content": "{\n \"id\": \"profile
|
|
68
|
+
"content": "{\n \"id\": \"profile\",\n \"type\": \"feature\",\n \"inBase\": true,\n \"summary\": \"Profile and settings for any game: name and ID, sound, volume and motion, language, analytics consent, tutorials, log out and account deletion.\",\n \"description\": \"A ready profile screen (Unity Alikhan/Username, UI/HUD/Settings, Runtime/UI/Consent, Localization) for ANY game, built on the @idosgames/react/ui kit. The player's name (rename in a popup with the server's rules: 3–24 characters, trimmed) and ID with copy; settings: sound on/off, volume and 'fewer animations' — the kit's player prefs, kept on this device and applied to every screen at once — the game's language (the title's languages, when there are two or more) and consent to anonymous analytics (client.analytics). Buttons: Tutorials (when tutorials is installed), Log out (confirmed) and Delete account — a popup that unlocks only after the player types the confirmation word, then deletes this game's data for the player. Registers the 'profile' feature; the lobby's profile button opens it. No title settings needed.\",\n \"provides\": [\n \"player name and ID\",\n \"rename popup\",\n \"sound, volume and fewer animations\",\n \"language picker\",\n \"analytics consent\",\n \"log out\",\n \"account deletion with confirmation\"\n ],\n \"tags\": [\n \"profile\",\n \"settings\",\n \"account\",\n \"sound\",\n \"language\",\n \"privacy\",\n \"ui\",\n \"screen\"\n ],\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.1.0\"\n}\n"
|
|
72
69
|
}
|
|
73
70
|
]
|
|
74
71
|
}
|