xpt-shared-types 1.1.0 → 1.3.0

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,56 @@
1
+ /**
2
+ * Bracket shape — how many rounds a single-elimination tournament has, what
3
+ * each is called, how matches are numbered, and where each winner goes.
4
+ *
5
+ * This is the single source of truth for both apps: xpt-strapi re-exports it
6
+ * from `src/utils/bracket.ts` (adding the Strapi-coupled stage sync), and
7
+ * xpt-client's Storybook fixtures consume it directly — a fixture built any
8
+ * other way could show a bracket the backend can never produce.
9
+ */
10
+ import type { MatchRound } from './generated';
11
+ /** The largest bracket the round names can describe. */
12
+ export declare const MAX_BRACKET_SIZE = 128;
13
+ export declare function nextPowerOf2(n: number): number;
14
+ export interface BracketRound {
15
+ name: MatchRound;
16
+ matchCount: number;
17
+ }
18
+ export declare function buildBracketRounds(bracketSize: number, hasThirdPlace: boolean): BracketRound[];
19
+ /** The rounds a tournament of this many entrants will be played over. */
20
+ export declare function bracketRoundsForParticipants(participants: number, hasThirdPlace: boolean): BracketRound[];
21
+ /** Uniform Fisher–Yates shuffle. A `sort(() => Math.random() - 0.5)` is
22
+ * biased — comparison sorts assume a consistent comparator, so some orderings
23
+ * come up measurably more often than others. */
24
+ export declare function shuffle<T>(items: readonly T[]): T[];
25
+ /**
26
+ * Standard bracket seed order: which seed (0-based) sits in each first-round
27
+ * slot. Built by the usual doubling rule — each seed is paired with its
28
+ * complement, so seed 0 meets seed 1 only in the final.
29
+ *
30
+ * This is also the bye-safety property: slots are paired (s, size-1-s), and
31
+ * byes are the seeds from `checkedIn` upward. Both sides of a pair being byes
32
+ * would need `checkedIn <= (size-1)/2`, but `nextPowerOf2` guarantees
33
+ * `checkedIn > size/2` — so a bye always faces a real entrant.
34
+ */
35
+ export declare function seedOrder(bracketSize: number): number[];
36
+ export interface PlannedMatch {
37
+ round: MatchRound;
38
+ matchNumber: number;
39
+ /** Where this match's winner goes; null for Finals and Third Round. */
40
+ nextMatchNumber: number | null;
41
+ winnerSlot: 'home' | 'away' | null;
42
+ /** First-round matches only: index into the seeded slot array (null = later round). */
43
+ homeSlot: number | null;
44
+ awaySlot: number | null;
45
+ }
46
+ export interface BracketPlan {
47
+ bracketSize: number;
48
+ rounds: BracketRound[];
49
+ matches: PlannedMatch[];
50
+ }
51
+ /**
52
+ * The full bracket as data: every match, its number, and where its winner
53
+ * goes. Numbering is sequential across rounds in bracket order, with Third
54
+ * Round (when present) sitting before Finals.
55
+ */
56
+ export declare function buildBracketPlan(bracketSize: number, hasThirdPlace: boolean): BracketPlan;
@@ -0,0 +1,144 @@
1
+ "use strict";
2
+ /**
3
+ * Bracket shape — how many rounds a single-elimination tournament has, what
4
+ * each is called, how matches are numbered, and where each winner goes.
5
+ *
6
+ * This is the single source of truth for both apps: xpt-strapi re-exports it
7
+ * from `src/utils/bracket.ts` (adding the Strapi-coupled stage sync), and
8
+ * xpt-client's Storybook fixtures consume it directly — a fixture built any
9
+ * other way could show a bracket the backend can never produce.
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.MAX_BRACKET_SIZE = void 0;
13
+ exports.nextPowerOf2 = nextPowerOf2;
14
+ exports.buildBracketRounds = buildBracketRounds;
15
+ exports.bracketRoundsForParticipants = bracketRoundsForParticipants;
16
+ exports.shuffle = shuffle;
17
+ exports.seedOrder = seedOrder;
18
+ exports.buildBracketPlan = buildBracketPlan;
19
+ /** The largest bracket the round names can describe. */
20
+ exports.MAX_BRACKET_SIZE = 128;
21
+ function nextPowerOf2(n) {
22
+ if (n <= 2)
23
+ return 2;
24
+ let p = 2;
25
+ while (p < n)
26
+ p *= 2;
27
+ return p;
28
+ }
29
+ /**
30
+ * The name of the round that *starts* with this many players left. Halving
31
+ * from the bracket size down to 2 walks the whole bracket, so there is no
32
+ * table of per-size name lists to fall out of sync — the one way a size can
33
+ * go wrong is a size that is not a power of two in range, and that throws.
34
+ */
35
+ const ROUND_NAME_BY_SIZE = {
36
+ 128: 'Round 128',
37
+ 64: 'Round 64',
38
+ 32: 'Round 32',
39
+ 16: 'Round 16',
40
+ 8: 'Quarter Final',
41
+ 4: 'Semi-Final',
42
+ 2: 'Finals',
43
+ };
44
+ function buildBracketRounds(bracketSize, hasThirdPlace) {
45
+ // An unrecognised size used to fall back to a single round called "Finals"
46
+ // holding `bracketSize / 2` matches. An unplayable bracket is worse than no
47
+ // bracket.
48
+ if (!ROUND_NAME_BY_SIZE[bracketSize]) {
49
+ throw new Error(`Unsupported bracket size ${bracketSize} — must be a power of two between 2 and ${exports.MAX_BRACKET_SIZE}`);
50
+ }
51
+ const rounds = [];
52
+ for (let size = bracketSize; size >= 2; size /= 2) {
53
+ rounds.push({ name: ROUND_NAME_BY_SIZE[size], matchCount: size / 2 });
54
+ }
55
+ // Inject Third Round slot before Finals when enabled (4+ players)
56
+ if (hasThirdPlace && bracketSize >= 4) {
57
+ const finalsIdx = rounds.findIndex((r) => r.name === 'Finals');
58
+ if (finalsIdx > 0) {
59
+ rounds.splice(finalsIdx, 0, { name: 'Third Round', matchCount: 1 });
60
+ }
61
+ }
62
+ return rounds;
63
+ }
64
+ /** The rounds a tournament of this many entrants will be played over. */
65
+ function bracketRoundsForParticipants(participants, hasThirdPlace) {
66
+ return buildBracketRounds(nextPowerOf2(participants), hasThirdPlace);
67
+ }
68
+ /** Uniform Fisher–Yates shuffle. A `sort(() => Math.random() - 0.5)` is
69
+ * biased — comparison sorts assume a consistent comparator, so some orderings
70
+ * come up measurably more often than others. */
71
+ function shuffle(items) {
72
+ const result = [...items];
73
+ for (let i = result.length - 1; i > 0; i--) {
74
+ const j = Math.floor(Math.random() * (i + 1));
75
+ [result[i], result[j]] = [result[j], result[i]];
76
+ }
77
+ return result;
78
+ }
79
+ /**
80
+ * Standard bracket seed order: which seed (0-based) sits in each first-round
81
+ * slot. Built by the usual doubling rule — each seed is paired with its
82
+ * complement, so seed 0 meets seed 1 only in the final.
83
+ *
84
+ * This is also the bye-safety property: slots are paired (s, size-1-s), and
85
+ * byes are the seeds from `checkedIn` upward. Both sides of a pair being byes
86
+ * would need `checkedIn <= (size-1)/2`, but `nextPowerOf2` guarantees
87
+ * `checkedIn > size/2` — so a bye always faces a real entrant.
88
+ */
89
+ function seedOrder(bracketSize) {
90
+ if (!ROUND_NAME_BY_SIZE[bracketSize]) {
91
+ throw new Error(`Unsupported bracket size ${bracketSize} — must be a power of two between 2 and ${exports.MAX_BRACKET_SIZE}`);
92
+ }
93
+ let order = [0];
94
+ while (order.length < bracketSize) {
95
+ const size = order.length * 2;
96
+ const next = [];
97
+ for (const seed of order) {
98
+ next.push(seed, size - 1 - seed);
99
+ }
100
+ order = next;
101
+ }
102
+ return order;
103
+ }
104
+ /**
105
+ * The full bracket as data: every match, its number, and where its winner
106
+ * goes. Numbering is sequential across rounds in bracket order, with Third
107
+ * Round (when present) sitting before Finals.
108
+ */
109
+ function buildBracketPlan(bracketSize, hasThirdPlace) {
110
+ const rounds = buildBracketRounds(bracketSize, hasThirdPlace);
111
+ let counter = 1;
112
+ const roundMatchNumbers = rounds.map((round) => Array.from({ length: round.matchCount }, () => counter++));
113
+ // Winner linkage skips Third Round — it receives losers, not winners.
114
+ const linkage = {};
115
+ const nonThirdRounds = rounds
116
+ .map((round, index) => ({ round, index }))
117
+ .filter(({ round }) => round.name !== 'Third Round');
118
+ for (let ri = 0; ri < nonThirdRounds.length - 1; ri++) {
119
+ const currentNums = roundMatchNumbers[nonThirdRounds[ri].index];
120
+ const nextNums = roundMatchNumbers[nonThirdRounds[ri + 1].index];
121
+ currentNums.forEach((num, mi) => {
122
+ linkage[num] = {
123
+ nextMatchNumber: nextNums[Math.floor(mi / 2)],
124
+ winnerSlot: mi % 2 === 0 ? 'home' : 'away',
125
+ };
126
+ });
127
+ }
128
+ const matches = [];
129
+ rounds.forEach((round, ri) => {
130
+ roundMatchNumbers[ri].forEach((num, mi) => {
131
+ var _a;
132
+ const link = (_a = linkage[num]) !== null && _a !== void 0 ? _a : { nextMatchNumber: null, winnerSlot: null };
133
+ matches.push({
134
+ round: round.name,
135
+ matchNumber: num,
136
+ nextMatchNumber: link.nextMatchNumber,
137
+ winnerSlot: link.winnerSlot,
138
+ homeSlot: ri === 0 ? mi * 2 : null,
139
+ awaySlot: ri === 0 ? mi * 2 + 1 : null,
140
+ });
141
+ });
142
+ });
143
+ return { bracketSize, rounds, matches };
144
+ }
@@ -1090,7 +1090,7 @@ exports.COUNTRIES_LIST = [
1090
1090
  },
1091
1091
  {
1092
1092
  countryName: "United Kingdom",
1093
- alpha2: "GB-UKM",
1093
+ alpha2: "GB",
1094
1094
  alpha3: "GBR",
1095
1095
  numeric: "826",
1096
1096
  },
@@ -9,7 +9,7 @@ export type GameRequestTeamPlay = "No (solo)" | "Yes, 2v2" | "Yes, 3v3" | "Yes,
9
9
  /** `Match.lobbyStatus` */
10
10
  export type MatchLobbyStatus = "waiting" | "ready" | "disputed" | "completed";
11
11
  /** `Match.round` */
12
- export type MatchRound = "Round 32" | "Round 16" | "Quarter Final" | "Semi-Final" | "Third Round" | "Finals" | "League";
12
+ export type MatchRound = "Round 128" | "Round 64" | "Round 32" | "Round 16" | "Quarter Final" | "Semi-Final" | "Third Round" | "Finals" | "League";
13
13
  /** `Match.streamPlatform` */
14
14
  export type MatchStreamPlatform = "twitch" | "youtube" | "kick" | "other";
15
15
  /** `Match.winnerSlot` */
@@ -35,7 +35,7 @@ export type TournamentParticipantStatus = "registered" | "active" | "eliminated"
35
35
  /** `TournamentRole.role` */
36
36
  export type TournamentRoleRole = "moderator" | "admin";
37
37
  /** `TournamentStage.stageName` */
38
- export type TournamentStageStageName = "Round 32" | "Round 16" | "Quarter Final" | "Semi-Final" | "Third Round" | "Finals" | "League";
38
+ export type TournamentStageStageName = "Round 128" | "Round 64" | "Round 32" | "Round 16" | "Quarter Final" | "Semi-Final" | "Third Round" | "Finals" | "League";
39
39
  /** `Tournament.teamSize` */
40
40
  export type TournamentTeamSize = "one" | "two" | "three" | "four" | "five";
41
41
  /** `Tournament.type` */
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from './generated';
2
2
  export * from './contracts';
3
3
  export * from './manual';
4
+ export * from './bracket';
4
5
  export * from './data/countriesList';
package/dist/index.js CHANGED
@@ -20,5 +20,7 @@ __exportStar(require("./generated"), exports);
20
20
  __exportStar(require("./contracts"), exports);
21
21
  // Hand-written types with no Strapi content type behind them
22
22
  __exportStar(require("./manual"), exports);
23
+ // Bracket shape — shared by the backend generator and Storybook fixtures
24
+ __exportStar(require("./bracket"), exports);
23
25
  // Data
24
26
  __exportStar(require("./data/countriesList"), exports);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xpt-shared-types",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "description": "Shared types and data for XPT projects",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
package/src/bracket.ts ADDED
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Bracket shape — how many rounds a single-elimination tournament has, what
3
+ * each is called, how matches are numbered, and where each winner goes.
4
+ *
5
+ * This is the single source of truth for both apps: xpt-strapi re-exports it
6
+ * from `src/utils/bracket.ts` (adding the Strapi-coupled stage sync), and
7
+ * xpt-client's Storybook fixtures consume it directly — a fixture built any
8
+ * other way could show a bracket the backend can never produce.
9
+ */
10
+
11
+ import type { MatchRound } from './generated';
12
+
13
+ /** The largest bracket the round names can describe. */
14
+ export const MAX_BRACKET_SIZE = 128;
15
+
16
+ export function nextPowerOf2(n: number): number {
17
+ if (n <= 2) return 2;
18
+ let p = 2;
19
+ while (p < n) p *= 2;
20
+ return p;
21
+ }
22
+
23
+ export interface BracketRound {
24
+ name: MatchRound;
25
+ matchCount: number;
26
+ }
27
+
28
+ /**
29
+ * The name of the round that *starts* with this many players left. Halving
30
+ * from the bracket size down to 2 walks the whole bracket, so there is no
31
+ * table of per-size name lists to fall out of sync — the one way a size can
32
+ * go wrong is a size that is not a power of two in range, and that throws.
33
+ */
34
+ const ROUND_NAME_BY_SIZE: Record<number, MatchRound> = {
35
+ 128: 'Round 128',
36
+ 64: 'Round 64',
37
+ 32: 'Round 32',
38
+ 16: 'Round 16',
39
+ 8: 'Quarter Final',
40
+ 4: 'Semi-Final',
41
+ 2: 'Finals',
42
+ };
43
+
44
+ export function buildBracketRounds(
45
+ bracketSize: number,
46
+ hasThirdPlace: boolean
47
+ ): BracketRound[] {
48
+ // An unrecognised size used to fall back to a single round called "Finals"
49
+ // holding `bracketSize / 2` matches. An unplayable bracket is worse than no
50
+ // bracket.
51
+ if (!ROUND_NAME_BY_SIZE[bracketSize]) {
52
+ throw new Error(
53
+ `Unsupported bracket size ${bracketSize} — must be a power of two between 2 and ${MAX_BRACKET_SIZE}`
54
+ );
55
+ }
56
+
57
+ const rounds: BracketRound[] = [];
58
+
59
+ for (let size = bracketSize; size >= 2; size /= 2) {
60
+ rounds.push({ name: ROUND_NAME_BY_SIZE[size], matchCount: size / 2 });
61
+ }
62
+
63
+ // Inject Third Round slot before Finals when enabled (4+ players)
64
+ if (hasThirdPlace && bracketSize >= 4) {
65
+ const finalsIdx = rounds.findIndex((r) => r.name === 'Finals');
66
+ if (finalsIdx > 0) {
67
+ rounds.splice(finalsIdx, 0, { name: 'Third Round', matchCount: 1 });
68
+ }
69
+ }
70
+
71
+ return rounds;
72
+ }
73
+
74
+ /** The rounds a tournament of this many entrants will be played over. */
75
+ export function bracketRoundsForParticipants(
76
+ participants: number,
77
+ hasThirdPlace: boolean
78
+ ): BracketRound[] {
79
+ return buildBracketRounds(nextPowerOf2(participants), hasThirdPlace);
80
+ }
81
+
82
+ /** Uniform Fisher–Yates shuffle. A `sort(() => Math.random() - 0.5)` is
83
+ * biased — comparison sorts assume a consistent comparator, so some orderings
84
+ * come up measurably more often than others. */
85
+ export function shuffle<T>(items: readonly T[]): T[] {
86
+ const result = [...items];
87
+ for (let i = result.length - 1; i > 0; i--) {
88
+ const j = Math.floor(Math.random() * (i + 1));
89
+ [result[i], result[j]] = [result[j], result[i]];
90
+ }
91
+ return result;
92
+ }
93
+
94
+ /**
95
+ * Standard bracket seed order: which seed (0-based) sits in each first-round
96
+ * slot. Built by the usual doubling rule — each seed is paired with its
97
+ * complement, so seed 0 meets seed 1 only in the final.
98
+ *
99
+ * This is also the bye-safety property: slots are paired (s, size-1-s), and
100
+ * byes are the seeds from `checkedIn` upward. Both sides of a pair being byes
101
+ * would need `checkedIn <= (size-1)/2`, but `nextPowerOf2` guarantees
102
+ * `checkedIn > size/2` — so a bye always faces a real entrant.
103
+ */
104
+ export function seedOrder(bracketSize: number): number[] {
105
+ if (!ROUND_NAME_BY_SIZE[bracketSize]) {
106
+ throw new Error(
107
+ `Unsupported bracket size ${bracketSize} — must be a power of two between 2 and ${MAX_BRACKET_SIZE}`
108
+ );
109
+ }
110
+
111
+ let order = [0];
112
+ while (order.length < bracketSize) {
113
+ const size = order.length * 2;
114
+ const next: number[] = [];
115
+ for (const seed of order) {
116
+ next.push(seed, size - 1 - seed);
117
+ }
118
+ order = next;
119
+ }
120
+ return order;
121
+ }
122
+
123
+ export interface PlannedMatch {
124
+ round: MatchRound;
125
+ matchNumber: number;
126
+ /** Where this match's winner goes; null for Finals and Third Round. */
127
+ nextMatchNumber: number | null;
128
+ winnerSlot: 'home' | 'away' | null;
129
+ /** First-round matches only: index into the seeded slot array (null = later round). */
130
+ homeSlot: number | null;
131
+ awaySlot: number | null;
132
+ }
133
+
134
+ export interface BracketPlan {
135
+ bracketSize: number;
136
+ rounds: BracketRound[];
137
+ matches: PlannedMatch[];
138
+ }
139
+
140
+ /**
141
+ * The full bracket as data: every match, its number, and where its winner
142
+ * goes. Numbering is sequential across rounds in bracket order, with Third
143
+ * Round (when present) sitting before Finals.
144
+ */
145
+ export function buildBracketPlan(
146
+ bracketSize: number,
147
+ hasThirdPlace: boolean
148
+ ): BracketPlan {
149
+ const rounds = buildBracketRounds(bracketSize, hasThirdPlace);
150
+
151
+ let counter = 1;
152
+ const roundMatchNumbers = rounds.map((round) =>
153
+ Array.from({ length: round.matchCount }, () => counter++)
154
+ );
155
+
156
+ // Winner linkage skips Third Round — it receives losers, not winners.
157
+ const linkage: Record<
158
+ number,
159
+ { nextMatchNumber: number; winnerSlot: 'home' | 'away' }
160
+ > = {};
161
+ const nonThirdRounds = rounds
162
+ .map((round, index) => ({ round, index }))
163
+ .filter(({ round }) => round.name !== 'Third Round');
164
+
165
+ for (let ri = 0; ri < nonThirdRounds.length - 1; ri++) {
166
+ const currentNums = roundMatchNumbers[nonThirdRounds[ri].index];
167
+ const nextNums = roundMatchNumbers[nonThirdRounds[ri + 1].index];
168
+
169
+ currentNums.forEach((num, mi) => {
170
+ linkage[num] = {
171
+ nextMatchNumber: nextNums[Math.floor(mi / 2)],
172
+ winnerSlot: mi % 2 === 0 ? 'home' : 'away',
173
+ };
174
+ });
175
+ }
176
+
177
+ const matches: PlannedMatch[] = [];
178
+ rounds.forEach((round, ri) => {
179
+ roundMatchNumbers[ri].forEach((num, mi) => {
180
+ const link = linkage[num] ?? { nextMatchNumber: null, winnerSlot: null };
181
+ matches.push({
182
+ round: round.name,
183
+ matchNumber: num,
184
+ nextMatchNumber: link.nextMatchNumber,
185
+ winnerSlot: link.winnerSlot,
186
+ homeSlot: ri === 0 ? mi * 2 : null,
187
+ awaySlot: ri === 0 ? mi * 2 + 1 : null,
188
+ });
189
+ });
190
+ });
191
+
192
+ return { bracketSize, rounds, matches };
193
+ }
@@ -1089,7 +1089,7 @@ export const COUNTRIES_LIST: Country[] = [
1089
1089
  },
1090
1090
  {
1091
1091
  countryName: "United Kingdom",
1092
- alpha2: "GB-UKM",
1092
+ alpha2: "GB",
1093
1093
  alpha3: "GBR",
1094
1094
  numeric: "826",
1095
1095
  },
@@ -42,6 +42,8 @@ export type MatchLobbyStatus =
42
42
 
43
43
  /** `Match.round` */
44
44
  export type MatchRound =
45
+ | "Round 128"
46
+ | "Round 64"
45
47
  | "Round 32"
46
48
  | "Round 16"
47
49
  | "Quarter Final"
@@ -129,6 +131,8 @@ export type TournamentRoleRole =
129
131
 
130
132
  /** `TournamentStage.stageName` */
131
133
  export type TournamentStageStageName =
134
+ | "Round 128"
135
+ | "Round 64"
132
136
  | "Round 32"
133
137
  | "Round 16"
134
138
  | "Quarter Final"
package/src/index.ts CHANGED
@@ -7,5 +7,8 @@ export * from './contracts';
7
7
  // Hand-written types with no Strapi content type behind them
8
8
  export * from './manual';
9
9
 
10
+ // Bracket shape — shared by the backend generator and Storybook fixtures
11
+ export * from './bracket';
12
+
10
13
  // Data
11
14
  export * from './data/countriesList';