xpt-shared-types 1.3.1 → 1.4.1

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.
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Which platforms a game runs on, and which account networks a player needs
3
+ * on each of them.
4
+ *
5
+ * A tournament carries three catalogue relations — `game`, `platform` and
6
+ * `gameAccount` — and until these functions existed they were three
7
+ * unconnected choices. A host could put Super Smash Bros on PlayStation, or
8
+ * ask for a Steam ID in an Xbox tournament, and nothing anywhere objected.
9
+ *
10
+ * The chain is game -> platform -> account, each step narrowing the next:
11
+ *
12
+ * - a game is tagged with the platforms it runs on, and with the account
13
+ * networks it uses (`Valorant` -> Riot, `Counter-Strike 2` -> Steam);
14
+ * - a platform is tagged with the networks that exist on that box
15
+ * (PlayStation -> PSN, EA, Epic, Riot);
16
+ * - the accounts valid for a tournament are the *intersection* of the two.
17
+ *
18
+ * Neither list is derivable from the other. PC hosts {Steam, Epic, Battle.net,
19
+ * Riot, EA}, but Overwatch 2 on PC is only Battle.net or Steam — so the
20
+ * platform alone over-generates. And a game's platforms are not the union of
21
+ * its networks' platforms: 2XKO is PC-only, yet Riot must be tagged with
22
+ * PlayStation and Xbox for Valorant, which would wrongly put 2XKO on consoles.
23
+ * Both edges are real; the intersection is the answer.
24
+ *
25
+ * Lives here rather than in xpt-strapi because the client filters its radio
26
+ * groups with the identical rule. Two implementations of an intersection is
27
+ * two chances to disagree about what a host is allowed to pick.
28
+ *
29
+ * Pure on purpose: no Strapi, no I/O, so the rules are unit-tested directly
30
+ * (see xpt-strapi `tests/unit/utils/gameCatalogue.test.ts`).
31
+ */
32
+ /** The identifying fields of a populated Strapi relation, all optional. */
33
+ export interface CatalogueRef {
34
+ id?: number | string | null;
35
+ documentId?: string | null;
36
+ name?: string | null;
37
+ }
38
+ /** A platform, with the account networks that exist on it. */
39
+ export interface CataloguePlatformLike extends CatalogueRef {
40
+ game_accounts?: readonly CatalogueRef[] | null;
41
+ }
42
+ /** A game, with the platforms it runs on and the networks it uses. */
43
+ export interface CatalogueGameLike extends CatalogueRef {
44
+ title?: string | null;
45
+ platforms?: readonly CatalogueRef[] | null;
46
+ game_accounts?: readonly CatalogueRef[] | null;
47
+ }
48
+ /**
49
+ * Whether two relation references point at the same row.
50
+ *
51
+ * `documentId` wins when both sides carry one, because a numeric `id` differs
52
+ * between a draft and its published entry while the documentId does not. Two
53
+ * references that share *neither* field are never equal — the tempting
54
+ * shorthand `a.id === b.id` reads as true when both are `undefined`, which is
55
+ * how `hasGameAccountFor` used to admit any player to a tournament that had no
56
+ * account set at all.
57
+ */
58
+ export declare function sameRef(a?: CatalogueRef | null, b?: CatalogueRef | null): boolean;
59
+ /**
60
+ * The platforms a game can be played on.
61
+ *
62
+ * An untagged game returns the whole catalogue — see `isTagged`. Results keep
63
+ * the order of `allPlatforms` so the radio group a host sees is stable and
64
+ * matches the unfiltered list they saw before any tagging happened.
65
+ */
66
+ export declare function platformsForGame<P extends CataloguePlatformLike>(game: CatalogueGameLike | null | undefined, allPlatforms: readonly P[]): P[];
67
+ /**
68
+ * The account networks valid for a game on a platform.
69
+ *
70
+ * With no platform chosen yet the game's own list is returned unfiltered —
71
+ * that is what lets a single-network game (Fortnite, Valorant, Dota 2) resolve
72
+ * its account at creation time, before a platform exists.
73
+ *
74
+ * An empty result when both sides *are* tagged is returned as-is rather than
75
+ * falling back to the full catalogue. It means the two tag lists disagree,
76
+ * which is a data bug: surfacing it as "no account type available" gets it
77
+ * fixed, whereas quietly showing all ten hides it forever.
78
+ */
79
+ export declare function gameAccountsFor<A extends CatalogueRef>(game: CatalogueGameLike | null | undefined, platform: CataloguePlatformLike | null | undefined, allGameAccounts: readonly A[]): A[];
80
+ /**
81
+ * The one candidate, when there is exactly one.
82
+ *
83
+ * Drives auto-connect: a single valid option is not a choice, so the server
84
+ * connects it and the host is never shown a dialog with one radio in it.
85
+ */
86
+ export declare function soleCandidate<T>(candidates: readonly T[]): T | null;
87
+ /**
88
+ * The candidate platforms that carry this account network — the chain read
89
+ * backwards.
90
+ *
91
+ * Most networks answer the platform question by themselves: a PSN ID only
92
+ * exists on a PlayStation, a Steam ID only on PC. Pair that with
93
+ * `soleCandidate` and choosing the account fills the platform in, which is the
94
+ * other half of what makes these two cards feel like one decision.
95
+ *
96
+ * The four cross-platform networks (EA, Epic, Battle.net, Riot) match several
97
+ * and so infer nothing — correctly, because an EA ID really does not say which
98
+ * box you are on.
99
+ *
100
+ * An **untagged** platform is excluded rather than treated as permissive. That
101
+ * is the opposite of `gameAccountsFor`, and deliberate: not knowing what a
102
+ * platform carries is a reason to stay quiet, not a licence to claim it carries
103
+ * this.
104
+ */
105
+ export declare function platformsCarrying<P extends CataloguePlatformLike>(gameAccount: CatalogueRef | null | undefined, candidates: readonly P[]): P[];
106
+ /** Whether this platform is one the game actually runs on. */
107
+ export declare function isPlatformValidForGame(game: CatalogueGameLike | null | undefined, platform: CatalogueRef | null | undefined, allPlatforms: readonly CataloguePlatformLike[]): boolean;
108
+ /** Whether this account network is one the game uses on this platform. */
109
+ export declare function isGameAccountValidFor(game: CatalogueGameLike | null | undefined, platform: CataloguePlatformLike | null | undefined, gameAccount: CatalogueRef | null | undefined, allGameAccounts: readonly CatalogueRef[]): boolean;
@@ -0,0 +1,164 @@
1
+ "use strict";
2
+ /**
3
+ * Which platforms a game runs on, and which account networks a player needs
4
+ * on each of them.
5
+ *
6
+ * A tournament carries three catalogue relations — `game`, `platform` and
7
+ * `gameAccount` — and until these functions existed they were three
8
+ * unconnected choices. A host could put Super Smash Bros on PlayStation, or
9
+ * ask for a Steam ID in an Xbox tournament, and nothing anywhere objected.
10
+ *
11
+ * The chain is game -> platform -> account, each step narrowing the next:
12
+ *
13
+ * - a game is tagged with the platforms it runs on, and with the account
14
+ * networks it uses (`Valorant` -> Riot, `Counter-Strike 2` -> Steam);
15
+ * - a platform is tagged with the networks that exist on that box
16
+ * (PlayStation -> PSN, EA, Epic, Riot);
17
+ * - the accounts valid for a tournament are the *intersection* of the two.
18
+ *
19
+ * Neither list is derivable from the other. PC hosts {Steam, Epic, Battle.net,
20
+ * Riot, EA}, but Overwatch 2 on PC is only Battle.net or Steam — so the
21
+ * platform alone over-generates. And a game's platforms are not the union of
22
+ * its networks' platforms: 2XKO is PC-only, yet Riot must be tagged with
23
+ * PlayStation and Xbox for Valorant, which would wrongly put 2XKO on consoles.
24
+ * Both edges are real; the intersection is the answer.
25
+ *
26
+ * Lives here rather than in xpt-strapi because the client filters its radio
27
+ * groups with the identical rule. Two implementations of an intersection is
28
+ * two chances to disagree about what a host is allowed to pick.
29
+ *
30
+ * Pure on purpose: no Strapi, no I/O, so the rules are unit-tested directly
31
+ * (see xpt-strapi `tests/unit/utils/gameCatalogue.test.ts`).
32
+ */
33
+ Object.defineProperty(exports, "__esModule", { value: true });
34
+ exports.sameRef = sameRef;
35
+ exports.platformsForGame = platformsForGame;
36
+ exports.gameAccountsFor = gameAccountsFor;
37
+ exports.soleCandidate = soleCandidate;
38
+ exports.platformsCarrying = platformsCarrying;
39
+ exports.isPlatformValidForGame = isPlatformValidForGame;
40
+ exports.isGameAccountValidFor = isGameAccountValidFor;
41
+ /**
42
+ * Whether two relation references point at the same row.
43
+ *
44
+ * `documentId` wins when both sides carry one, because a numeric `id` differs
45
+ * between a draft and its published entry while the documentId does not. Two
46
+ * references that share *neither* field are never equal — the tempting
47
+ * shorthand `a.id === b.id` reads as true when both are `undefined`, which is
48
+ * how `hasGameAccountFor` used to admit any player to a tournament that had no
49
+ * account set at all.
50
+ */
51
+ function sameRef(a, b) {
52
+ if (!a || !b)
53
+ return false;
54
+ if (a.documentId && b.documentId)
55
+ return a.documentId === b.documentId;
56
+ const aId = a.id;
57
+ const bId = b.id;
58
+ if (aId === null || aId === undefined)
59
+ return false;
60
+ if (bId === null || bId === undefined)
61
+ return false;
62
+ return String(aId) === String(bId);
63
+ }
64
+ /**
65
+ * Whether a catalogue tag list says anything.
66
+ *
67
+ * An absent list and an empty one mean the same thing: nobody has tagged this
68
+ * row yet. Strapi serialises an unpopulated relation and a populated-but-empty
69
+ * one identically once it reaches the client, so there is no way to tell them
70
+ * apart and no reason to try — either way we have no opinion, and an untagged
71
+ * row must never lock a host out of a game we simply forgot to configure.
72
+ */
73
+ function isTagged(list) {
74
+ return Array.isArray(list) && list.length > 0;
75
+ }
76
+ /** The master list, minus any row that repeats one already seen. */
77
+ function dedupe(rows) {
78
+ const seen = [];
79
+ for (const row of rows) {
80
+ if (!seen.some((kept) => sameRef(kept, row)))
81
+ seen.push(row);
82
+ }
83
+ return seen;
84
+ }
85
+ /**
86
+ * The platforms a game can be played on.
87
+ *
88
+ * An untagged game returns the whole catalogue — see `isTagged`. Results keep
89
+ * the order of `allPlatforms` so the radio group a host sees is stable and
90
+ * matches the unfiltered list they saw before any tagging happened.
91
+ */
92
+ function platformsForGame(game, allPlatforms) {
93
+ const all = dedupe(allPlatforms);
94
+ if (!game || !isTagged(game.platforms))
95
+ return all;
96
+ return all.filter((platform) => game.platforms.some((tag) => sameRef(tag, platform)));
97
+ }
98
+ /**
99
+ * The account networks valid for a game on a platform.
100
+ *
101
+ * With no platform chosen yet the game's own list is returned unfiltered —
102
+ * that is what lets a single-network game (Fortnite, Valorant, Dota 2) resolve
103
+ * its account at creation time, before a platform exists.
104
+ *
105
+ * An empty result when both sides *are* tagged is returned as-is rather than
106
+ * falling back to the full catalogue. It means the two tag lists disagree,
107
+ * which is a data bug: surfacing it as "no account type available" gets it
108
+ * fixed, whereas quietly showing all ten hides it forever.
109
+ */
110
+ function gameAccountsFor(game, platform, allGameAccounts) {
111
+ const all = dedupe(allGameAccounts);
112
+ const byGame = !game || !isTagged(game.game_accounts)
113
+ ? all
114
+ : all.filter((account) => game.game_accounts.some((tag) => sameRef(tag, account)));
115
+ // No platform yet, or a platform nobody has tagged: the game is the only
116
+ // authority we have.
117
+ if (!platform || !isTagged(platform.game_accounts))
118
+ return byGame;
119
+ return byGame.filter((account) => platform.game_accounts.some((tag) => sameRef(tag, account)));
120
+ }
121
+ /**
122
+ * The one candidate, when there is exactly one.
123
+ *
124
+ * Drives auto-connect: a single valid option is not a choice, so the server
125
+ * connects it and the host is never shown a dialog with one radio in it.
126
+ */
127
+ function soleCandidate(candidates) {
128
+ return candidates.length === 1 ? candidates[0] : null;
129
+ }
130
+ /**
131
+ * The candidate platforms that carry this account network — the chain read
132
+ * backwards.
133
+ *
134
+ * Most networks answer the platform question by themselves: a PSN ID only
135
+ * exists on a PlayStation, a Steam ID only on PC. Pair that with
136
+ * `soleCandidate` and choosing the account fills the platform in, which is the
137
+ * other half of what makes these two cards feel like one decision.
138
+ *
139
+ * The four cross-platform networks (EA, Epic, Battle.net, Riot) match several
140
+ * and so infer nothing — correctly, because an EA ID really does not say which
141
+ * box you are on.
142
+ *
143
+ * An **untagged** platform is excluded rather than treated as permissive. That
144
+ * is the opposite of `gameAccountsFor`, and deliberate: not knowing what a
145
+ * platform carries is a reason to stay quiet, not a licence to claim it carries
146
+ * this.
147
+ */
148
+ function platformsCarrying(gameAccount, candidates) {
149
+ if (!gameAccount)
150
+ return [];
151
+ return dedupe(candidates).filter((platform) => { var _a; return ((_a = platform.game_accounts) !== null && _a !== void 0 ? _a : []).some((tag) => sameRef(tag, gameAccount)); });
152
+ }
153
+ /** Whether this platform is one the game actually runs on. */
154
+ function isPlatformValidForGame(game, platform, allPlatforms) {
155
+ if (!platform)
156
+ return false;
157
+ return platformsForGame(game, allPlatforms).some((valid) => sameRef(valid, platform));
158
+ }
159
+ /** Whether this account network is one the game uses on this platform. */
160
+ function isGameAccountValidFor(game, platform, gameAccount, allGameAccounts) {
161
+ if (!gameAccount)
162
+ return false;
163
+ return gameAccountsFor(game, platform, allGameAccounts).some((valid) => sameRef(valid, gameAccount));
164
+ }
@@ -14,6 +14,8 @@ export type MatchRound = "Round 128" | "Round 64" | "Round 32" | "Round 16" | "Q
14
14
  export type MatchStreamPlatform = "twitch" | "youtube" | "kick" | "other";
15
15
  /** `Match.winnerSlot` */
16
16
  export type MatchWinnerSlot = "home" | "away";
17
+ /** `Platform.code` */
18
+ export type PlatformCode = "pc" | "playstation" | "xbox" | "nintendo" | "mobile";
17
19
  /** `Referral.status` */
18
20
  export type ReferralStatus = "pending" | "completed";
19
21
  /** `Team.current_status` */
@@ -1,5 +1,5 @@
1
1
  import type { BlocksContent, MediaInput, RelationInput } from '../contracts';
2
- import type { FriendshipStatus, GameRequestCustomLobbies, GameRequestGenre, GameRequestTeamPlay, MatchLobbyStatus, MatchRound, MatchStreamPlatform, MatchWinnerSlot, ReferralStatus, TeamCurrentStatus, TeamInviteStatus, TeamInviteTeamRole, TeamPlayerTeamRole, TournamentCurrentStatus, TournamentParticipantEntryType, TournamentParticipantStatus, TournamentRoleRole, TournamentTeamSize, TournamentType, UserTransactionStripeStatus, UserTransactionType } from './enums';
2
+ import type { FriendshipStatus, GameRequestCustomLobbies, GameRequestGenre, GameRequestTeamPlay, MatchLobbyStatus, MatchRound, MatchStreamPlatform, MatchWinnerSlot, PlatformCode, ReferralStatus, TeamCurrentStatus, TeamInviteStatus, TeamInviteTeamRole, TeamPlayerTeamRole, TournamentCurrentStatus, TournamentParticipantEntryType, TournamentParticipantStatus, TournamentRoleRole, TournamentTeamSize, TournamentType, UserTransactionStripeStatus, UserTransactionType } from './enums';
3
3
  /** Write payload for `about`. */
4
4
  export interface AboutInput {
5
5
  heroTitle?: string;
@@ -48,6 +48,8 @@ export interface GameInput {
48
48
  user_game_stats?: RelationInput | RelationInput[];
49
49
  team_game_stats?: RelationInput | RelationInput[];
50
50
  imgMainPosition?: string;
51
+ platforms?: RelationInput | RelationInput[];
52
+ game_accounts?: RelationInput | RelationInput[];
51
53
  }
52
54
  /** Write payload for `game-account`. */
53
55
  export interface GameAccountInput {
@@ -55,6 +57,8 @@ export interface GameAccountInput {
55
57
  tournaments?: RelationInput | RelationInput[];
56
58
  user_game_accounts?: RelationInput | RelationInput[];
57
59
  imgThumb?: MediaInput;
60
+ games?: RelationInput | RelationInput[];
61
+ platforms?: RelationInput | RelationInput[];
58
62
  }
59
63
  /** Write payload for `game-request`. */
60
64
  export interface GameRequestInput {
@@ -148,7 +152,10 @@ export interface NotificationInput {
148
152
  /** Write payload for `platform`. */
149
153
  export interface PlatformInput {
150
154
  name?: string;
155
+ code?: PlatformCode;
151
156
  tournaments?: RelationInput | RelationInput[];
157
+ games?: RelationInput | RelationInput[];
158
+ game_accounts?: RelationInput | RelationInput[];
152
159
  }
153
160
  /** Write payload for `preset-avatar`. */
154
161
  export interface PresetAvatarInput {
@@ -1,5 +1,5 @@
1
1
  import type { BlocksContent, StrapiDocument, StrapiMedia, UserRole } from '../contracts';
2
- import type { FriendshipStatus, GameRequestCustomLobbies, GameRequestGenre, GameRequestTeamPlay, MatchLobbyStatus, MatchRound, MatchStreamPlatform, MatchWinnerSlot, ReferralStatus, TeamCurrentStatus, TeamInviteStatus, TeamInviteTeamRole, TeamPlayerTeamRole, TournamentCurrentStatus, TournamentParticipantEntryType, TournamentParticipantStatus, TournamentRoleRole, TournamentTeamSize, TournamentType, UserTransactionStripeStatus, UserTransactionType } from './enums';
2
+ import type { FriendshipStatus, GameRequestCustomLobbies, GameRequestGenre, GameRequestTeamPlay, MatchLobbyStatus, MatchRound, MatchStreamPlatform, MatchWinnerSlot, PlatformCode, ReferralStatus, TeamCurrentStatus, TeamInviteStatus, TeamInviteTeamRole, TeamPlayerTeamRole, TournamentCurrentStatus, TournamentParticipantEntryType, TournamentParticipantStatus, TournamentRoleRole, TournamentTeamSize, TournamentType, UserTransactionStripeStatus, UserTransactionType } from './enums';
3
3
  /** `about` */
4
4
  export interface About extends StrapiDocument {
5
5
  heroTitle?: string | null;
@@ -48,6 +48,8 @@ export interface Game extends StrapiDocument {
48
48
  user_game_stats?: UserGameStat[];
49
49
  team_game_stats?: TeamGameStat[];
50
50
  imgMainPosition?: string | null;
51
+ platforms?: Platform[];
52
+ game_accounts?: GameAccount[];
51
53
  }
52
54
  /** `game-account` */
53
55
  export interface GameAccount extends StrapiDocument {
@@ -55,6 +57,8 @@ export interface GameAccount extends StrapiDocument {
55
57
  tournaments?: Tournament[];
56
58
  user_game_accounts?: UserGameAccount[];
57
59
  imgThumb?: StrapiMedia | null;
60
+ games?: Game[];
61
+ platforms?: Platform[];
58
62
  }
59
63
  /** `game-request` */
60
64
  export interface GameRequest extends StrapiDocument {
@@ -148,7 +152,10 @@ export interface Notification extends StrapiDocument {
148
152
  /** `platform` */
149
153
  export interface Platform extends StrapiDocument {
150
154
  name?: string | null;
155
+ code?: PlatformCode | null;
151
156
  tournaments?: Tournament[];
157
+ games?: Game[];
158
+ game_accounts?: GameAccount[];
152
159
  }
153
160
  /** `preset-avatar` */
154
161
  export interface PresetAvatar extends StrapiDocument {
package/dist/index.d.ts CHANGED
@@ -2,4 +2,5 @@ export * from './generated';
2
2
  export * from './contracts';
3
3
  export * from './manual';
4
4
  export * from './bracket';
5
+ export * from './gameCatalogue';
5
6
  export * from './data/countriesList';
package/dist/index.js CHANGED
@@ -22,5 +22,8 @@ __exportStar(require("./contracts"), exports);
22
22
  __exportStar(require("./manual"), exports);
23
23
  // Bracket shape — shared by the backend generator and Storybook fixtures
24
24
  __exportStar(require("./bracket"), exports);
25
+ // Game/platform/account catalogue — the game -> platform -> account chain,
26
+ // applied identically by the backend resolver and the client's pickers
27
+ __exportStar(require("./gameCatalogue"), exports);
25
28
  // Data
26
29
  __exportStar(require("./data/countriesList"), exports);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xpt-shared-types",
3
- "version": "1.3.1",
3
+ "version": "1.4.1",
4
4
  "description": "Shared types and data for XPT projects",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Which platforms a game runs on, and which account networks a player needs
3
+ * on each of them.
4
+ *
5
+ * A tournament carries three catalogue relations — `game`, `platform` and
6
+ * `gameAccount` — and until these functions existed they were three
7
+ * unconnected choices. A host could put Super Smash Bros on PlayStation, or
8
+ * ask for a Steam ID in an Xbox tournament, and nothing anywhere objected.
9
+ *
10
+ * The chain is game -> platform -> account, each step narrowing the next:
11
+ *
12
+ * - a game is tagged with the platforms it runs on, and with the account
13
+ * networks it uses (`Valorant` -> Riot, `Counter-Strike 2` -> Steam);
14
+ * - a platform is tagged with the networks that exist on that box
15
+ * (PlayStation -> PSN, EA, Epic, Riot);
16
+ * - the accounts valid for a tournament are the *intersection* of the two.
17
+ *
18
+ * Neither list is derivable from the other. PC hosts {Steam, Epic, Battle.net,
19
+ * Riot, EA}, but Overwatch 2 on PC is only Battle.net or Steam — so the
20
+ * platform alone over-generates. And a game's platforms are not the union of
21
+ * its networks' platforms: 2XKO is PC-only, yet Riot must be tagged with
22
+ * PlayStation and Xbox for Valorant, which would wrongly put 2XKO on consoles.
23
+ * Both edges are real; the intersection is the answer.
24
+ *
25
+ * Lives here rather than in xpt-strapi because the client filters its radio
26
+ * groups with the identical rule. Two implementations of an intersection is
27
+ * two chances to disagree about what a host is allowed to pick.
28
+ *
29
+ * Pure on purpose: no Strapi, no I/O, so the rules are unit-tested directly
30
+ * (see xpt-strapi `tests/unit/utils/gameCatalogue.test.ts`).
31
+ */
32
+
33
+ /** The identifying fields of a populated Strapi relation, all optional. */
34
+ export interface CatalogueRef {
35
+ id?: number | string | null;
36
+ documentId?: string | null;
37
+ name?: string | null;
38
+ }
39
+
40
+ /** A platform, with the account networks that exist on it. */
41
+ export interface CataloguePlatformLike extends CatalogueRef {
42
+ game_accounts?: readonly CatalogueRef[] | null;
43
+ }
44
+
45
+ /** A game, with the platforms it runs on and the networks it uses. */
46
+ export interface CatalogueGameLike extends CatalogueRef {
47
+ title?: string | null;
48
+ platforms?: readonly CatalogueRef[] | null;
49
+ game_accounts?: readonly CatalogueRef[] | null;
50
+ }
51
+
52
+ /**
53
+ * Whether two relation references point at the same row.
54
+ *
55
+ * `documentId` wins when both sides carry one, because a numeric `id` differs
56
+ * between a draft and its published entry while the documentId does not. Two
57
+ * references that share *neither* field are never equal — the tempting
58
+ * shorthand `a.id === b.id` reads as true when both are `undefined`, which is
59
+ * how `hasGameAccountFor` used to admit any player to a tournament that had no
60
+ * account set at all.
61
+ */
62
+ export function sameRef(
63
+ a?: CatalogueRef | null,
64
+ b?: CatalogueRef | null
65
+ ): boolean {
66
+ if (!a || !b) return false;
67
+
68
+ if (a.documentId && b.documentId) return a.documentId === b.documentId;
69
+
70
+ const aId = a.id;
71
+ const bId = b.id;
72
+ if (aId === null || aId === undefined) return false;
73
+ if (bId === null || bId === undefined) return false;
74
+
75
+ return String(aId) === String(bId);
76
+ }
77
+
78
+ /**
79
+ * Whether a catalogue tag list says anything.
80
+ *
81
+ * An absent list and an empty one mean the same thing: nobody has tagged this
82
+ * row yet. Strapi serialises an unpopulated relation and a populated-but-empty
83
+ * one identically once it reaches the client, so there is no way to tell them
84
+ * apart and no reason to try — either way we have no opinion, and an untagged
85
+ * row must never lock a host out of a game we simply forgot to configure.
86
+ */
87
+ function isTagged(list?: readonly CatalogueRef[] | null): boolean {
88
+ return Array.isArray(list) && list.length > 0;
89
+ }
90
+
91
+ /** The master list, minus any row that repeats one already seen. */
92
+ function dedupe<T extends CatalogueRef>(rows: readonly T[]): T[] {
93
+ const seen: T[] = [];
94
+ for (const row of rows) {
95
+ if (!seen.some((kept) => sameRef(kept, row))) seen.push(row);
96
+ }
97
+ return seen;
98
+ }
99
+
100
+ /**
101
+ * The platforms a game can be played on.
102
+ *
103
+ * An untagged game returns the whole catalogue — see `isTagged`. Results keep
104
+ * the order of `allPlatforms` so the radio group a host sees is stable and
105
+ * matches the unfiltered list they saw before any tagging happened.
106
+ */
107
+ export function platformsForGame<P extends CataloguePlatformLike>(
108
+ game: CatalogueGameLike | null | undefined,
109
+ allPlatforms: readonly P[]
110
+ ): P[] {
111
+ const all = dedupe(allPlatforms);
112
+
113
+ if (!game || !isTagged(game.platforms)) return all;
114
+
115
+ return all.filter((platform) =>
116
+ game.platforms!.some((tag) => sameRef(tag, platform))
117
+ );
118
+ }
119
+
120
+ /**
121
+ * The account networks valid for a game on a platform.
122
+ *
123
+ * With no platform chosen yet the game's own list is returned unfiltered —
124
+ * that is what lets a single-network game (Fortnite, Valorant, Dota 2) resolve
125
+ * its account at creation time, before a platform exists.
126
+ *
127
+ * An empty result when both sides *are* tagged is returned as-is rather than
128
+ * falling back to the full catalogue. It means the two tag lists disagree,
129
+ * which is a data bug: surfacing it as "no account type available" gets it
130
+ * fixed, whereas quietly showing all ten hides it forever.
131
+ */
132
+ export function gameAccountsFor<A extends CatalogueRef>(
133
+ game: CatalogueGameLike | null | undefined,
134
+ platform: CataloguePlatformLike | null | undefined,
135
+ allGameAccounts: readonly A[]
136
+ ): A[] {
137
+ const all = dedupe(allGameAccounts);
138
+
139
+ const byGame =
140
+ !game || !isTagged(game.game_accounts)
141
+ ? all
142
+ : all.filter((account) =>
143
+ game.game_accounts!.some((tag) => sameRef(tag, account))
144
+ );
145
+
146
+ // No platform yet, or a platform nobody has tagged: the game is the only
147
+ // authority we have.
148
+ if (!platform || !isTagged(platform.game_accounts)) return byGame;
149
+
150
+ return byGame.filter((account) =>
151
+ platform.game_accounts!.some((tag) => sameRef(tag, account))
152
+ );
153
+ }
154
+
155
+ /**
156
+ * The one candidate, when there is exactly one.
157
+ *
158
+ * Drives auto-connect: a single valid option is not a choice, so the server
159
+ * connects it and the host is never shown a dialog with one radio in it.
160
+ */
161
+ export function soleCandidate<T>(candidates: readonly T[]): T | null {
162
+ return candidates.length === 1 ? candidates[0] : null;
163
+ }
164
+
165
+ /**
166
+ * The candidate platforms that carry this account network — the chain read
167
+ * backwards.
168
+ *
169
+ * Most networks answer the platform question by themselves: a PSN ID only
170
+ * exists on a PlayStation, a Steam ID only on PC. Pair that with
171
+ * `soleCandidate` and choosing the account fills the platform in, which is the
172
+ * other half of what makes these two cards feel like one decision.
173
+ *
174
+ * The four cross-platform networks (EA, Epic, Battle.net, Riot) match several
175
+ * and so infer nothing — correctly, because an EA ID really does not say which
176
+ * box you are on.
177
+ *
178
+ * An **untagged** platform is excluded rather than treated as permissive. That
179
+ * is the opposite of `gameAccountsFor`, and deliberate: not knowing what a
180
+ * platform carries is a reason to stay quiet, not a licence to claim it carries
181
+ * this.
182
+ */
183
+ export function platformsCarrying<P extends CataloguePlatformLike>(
184
+ gameAccount: CatalogueRef | null | undefined,
185
+ candidates: readonly P[]
186
+ ): P[] {
187
+ if (!gameAccount) return [];
188
+
189
+ return dedupe(candidates).filter((platform) =>
190
+ (platform.game_accounts ?? []).some((tag) => sameRef(tag, gameAccount))
191
+ );
192
+ }
193
+
194
+ /** Whether this platform is one the game actually runs on. */
195
+ export function isPlatformValidForGame(
196
+ game: CatalogueGameLike | null | undefined,
197
+ platform: CatalogueRef | null | undefined,
198
+ allPlatforms: readonly CataloguePlatformLike[]
199
+ ): boolean {
200
+ if (!platform) return false;
201
+ return platformsForGame(game, allPlatforms).some((valid) =>
202
+ sameRef(valid, platform)
203
+ );
204
+ }
205
+
206
+ /** Whether this account network is one the game uses on this platform. */
207
+ export function isGameAccountValidFor(
208
+ game: CatalogueGameLike | null | undefined,
209
+ platform: CataloguePlatformLike | null | undefined,
210
+ gameAccount: CatalogueRef | null | undefined,
211
+ allGameAccounts: readonly CatalogueRef[]
212
+ ): boolean {
213
+ if (!gameAccount) return false;
214
+ return gameAccountsFor(game, platform, allGameAccounts).some((valid) =>
215
+ sameRef(valid, gameAccount)
216
+ );
217
+ }
@@ -64,6 +64,14 @@ export type MatchWinnerSlot =
64
64
  | "home"
65
65
  | "away";
66
66
 
67
+ /** `Platform.code` */
68
+ export type PlatformCode =
69
+ | "pc"
70
+ | "playstation"
71
+ | "xbox"
72
+ | "nintendo"
73
+ | "mobile";
74
+
67
75
  /** `Referral.status` */
68
76
  export type ReferralStatus =
69
77
  | "pending"
@@ -16,6 +16,7 @@ import type {
16
16
  MatchRound,
17
17
  MatchStreamPlatform,
18
18
  MatchWinnerSlot,
19
+ PlatformCode,
19
20
  ReferralStatus,
20
21
  TeamCurrentStatus,
21
22
  TeamInviteStatus,
@@ -85,6 +86,8 @@ export interface GameInput {
85
86
  user_game_stats?: RelationInput | RelationInput[];
86
87
  team_game_stats?: RelationInput | RelationInput[];
87
88
  imgMainPosition?: string;
89
+ platforms?: RelationInput | RelationInput[];
90
+ game_accounts?: RelationInput | RelationInput[];
88
91
  }
89
92
 
90
93
  /** Write payload for `game-account`. */
@@ -93,6 +96,8 @@ export interface GameAccountInput {
93
96
  tournaments?: RelationInput | RelationInput[];
94
97
  user_game_accounts?: RelationInput | RelationInput[];
95
98
  imgThumb?: MediaInput;
99
+ games?: RelationInput | RelationInput[];
100
+ platforms?: RelationInput | RelationInput[];
96
101
  }
97
102
 
98
103
  /** Write payload for `game-request`. */
@@ -194,7 +199,10 @@ export interface NotificationInput {
194
199
  /** Write payload for `platform`. */
195
200
  export interface PlatformInput {
196
201
  name?: string;
202
+ code?: PlatformCode;
197
203
  tournaments?: RelationInput | RelationInput[];
204
+ games?: RelationInput | RelationInput[];
205
+ game_accounts?: RelationInput | RelationInput[];
198
206
  }
199
207
 
200
208
  /** Write payload for `preset-avatar`. */
@@ -17,6 +17,7 @@ import type {
17
17
  MatchRound,
18
18
  MatchStreamPlatform,
19
19
  MatchWinnerSlot,
20
+ PlatformCode,
20
21
  ReferralStatus,
21
22
  TeamCurrentStatus,
22
23
  TeamInviteStatus,
@@ -86,6 +87,8 @@ export interface Game extends StrapiDocument {
86
87
  user_game_stats?: UserGameStat[];
87
88
  team_game_stats?: TeamGameStat[];
88
89
  imgMainPosition?: string | null;
90
+ platforms?: Platform[];
91
+ game_accounts?: GameAccount[];
89
92
  }
90
93
 
91
94
  /** `game-account` */
@@ -94,6 +97,8 @@ export interface GameAccount extends StrapiDocument {
94
97
  tournaments?: Tournament[];
95
98
  user_game_accounts?: UserGameAccount[];
96
99
  imgThumb?: StrapiMedia | null;
100
+ games?: Game[];
101
+ platforms?: Platform[];
97
102
  }
98
103
 
99
104
  /** `game-request` */
@@ -195,7 +200,10 @@ export interface Notification extends StrapiDocument {
195
200
  /** `platform` */
196
201
  export interface Platform extends StrapiDocument {
197
202
  name?: string | null;
203
+ code?: PlatformCode | null;
198
204
  tournaments?: Tournament[];
205
+ games?: Game[];
206
+ game_accounts?: GameAccount[];
199
207
  }
200
208
 
201
209
  /** `preset-avatar` */
package/src/index.ts CHANGED
@@ -1,14 +1,18 @@
1
- // Generated from the Strapi schemas — see scripts/sync-from-strapi.js
2
- export * from './generated';
3
-
4
- // Hand-written contract primitives (response envelopes, Populated, media)
5
- export * from './contracts';
6
-
7
- // Hand-written types with no Strapi content type behind them
8
- export * from './manual';
9
-
10
- // Bracket shape — shared by the backend generator and Storybook fixtures
11
- export * from './bracket';
12
-
13
- // Data
14
- export * from './data/countriesList';
1
+ // Generated from the Strapi schemas — see scripts/sync-from-strapi.js
2
+ export * from './generated';
3
+
4
+ // Hand-written contract primitives (response envelopes, Populated, media)
5
+ export * from './contracts';
6
+
7
+ // Hand-written types with no Strapi content type behind them
8
+ export * from './manual';
9
+
10
+ // Bracket shape — shared by the backend generator and Storybook fixtures
11
+ export * from './bracket';
12
+
13
+ // Game/platform/account catalogue — the game -> platform -> account chain,
14
+ // applied identically by the backend resolver and the client's pickers
15
+ export * from './gameCatalogue';
16
+
17
+ // Data
18
+ export * from './data/countriesList';