xpt-shared-types 1.1.0 → 1.3.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.
- package/README.md +6 -6
- package/dist/bracket.d.ts +56 -0
- package/dist/bracket.js +144 -0
- package/dist/data/countriesList.js +1 -1
- package/dist/generated/enums.d.ts +1 -5
- package/dist/generated/inputs.d.ts +5 -17
- package/dist/generated/models.d.ts +5 -17
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -0
- package/package.json +1 -1
- package/src/bracket.ts +193 -0
- package/src/data/countriesList.ts +1 -1
- package/src/generated/enums.ts +2 -16
- package/src/generated/inputs.ts +4 -20
- package/src/generated/models.ts +4 -20
- package/src/index.ts +3 -0
package/README.md
CHANGED
|
@@ -54,7 +54,7 @@ frontend cannot consume.
|
|
|
54
54
|
**Everything except `id` and `documentId` is optional.** Not just relations.
|
|
55
55
|
This codebase uses Strapi's `fields` selection pervasively, so even a
|
|
56
56
|
schema-`required` attribute like `user.email` is absent from most responses.
|
|
57
|
-
The base model is the
|
|
57
|
+
The base model is the _minimum guarantee_, not the full row.
|
|
58
58
|
|
|
59
59
|
**Single values are `?: T | null`.** Strapi sends `null` for an unset value
|
|
60
60
|
rather than omitting the key. Lists come back as `[]`, so they are not nullable.
|
|
@@ -83,7 +83,7 @@ asked for:
|
|
|
83
83
|
```ts
|
|
84
84
|
const tournament = await getTournament(slug); // Populated<Tournament, 'game' | 'prizes'>
|
|
85
85
|
|
|
86
|
-
tournament.game.title;
|
|
86
|
+
tournament.game.title; // ok — the annotation says it was populated
|
|
87
87
|
tournament.region?.name; // still optional — this query did not populate it
|
|
88
88
|
```
|
|
89
89
|
|
|
@@ -95,10 +95,10 @@ silently disables exactly the checking this package exists to provide.
|
|
|
95
95
|
|
|
96
96
|
The two apps are on different Yarn majors, so they link differently:
|
|
97
97
|
|
|
98
|
-
| Repo
|
|
99
|
-
|
|
|
100
|
-
| `xpt-strapi` | 1.22.5 (classic) | `"xpt-shared-types": "link:../xpt-shared-types"`
|
|
101
|
-
| `xpt-client` | 4.1.1 (Berry)
|
|
98
|
+
| Repo | Yarn | Dependency |
|
|
99
|
+
| ------------ | ---------------- | -------------------------------------------------- |
|
|
100
|
+
| `xpt-strapi` | 1.22.5 (classic) | `"xpt-shared-types": "link:../xpt-shared-types"` |
|
|
101
|
+
| `xpt-client` | 4.1.1 (Berry) | `"xpt-shared-types": "portal:../xpt-shared-types"` |
|
|
102
102
|
|
|
103
103
|
`portal:` is Berry-only and fails on Yarn 1. Both symlink to this directory, so
|
|
104
104
|
edits are picked up after `yarn build` with no publish step.
|
|
@@ -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;
|
package/dist/bracket.js
ADDED
|
@@ -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
|
+
}
|
|
@@ -9,13 +9,11 @@ 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` */
|
|
16
16
|
export type MatchWinnerSlot = "home" | "away";
|
|
17
|
-
/** `Prize.rank` */
|
|
18
|
-
export type PrizeRank = "one" | "two" | "three";
|
|
19
17
|
/** `Referral.status` */
|
|
20
18
|
export type ReferralStatus = "pending" | "completed";
|
|
21
19
|
/** `Team.current_status` */
|
|
@@ -34,8 +32,6 @@ export type TournamentParticipantEntryType = "solo" | "team";
|
|
|
34
32
|
export type TournamentParticipantStatus = "registered" | "active" | "eliminated" | "completed";
|
|
35
33
|
/** `TournamentRole.role` */
|
|
36
34
|
export type TournamentRoleRole = "moderator" | "admin";
|
|
37
|
-
/** `TournamentStage.stageName` */
|
|
38
|
-
export type TournamentStageStageName = "Round 32" | "Round 16" | "Quarter Final" | "Semi-Final" | "Third Round" | "Finals" | "League";
|
|
39
35
|
/** `Tournament.teamSize` */
|
|
40
36
|
export type TournamentTeamSize = "one" | "two" | "three" | "four" | "five";
|
|
41
37
|
/** `Tournament.type` */
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { BlocksContent, MediaInput, RelationInput } from '../contracts';
|
|
2
|
-
import type { FriendshipStatus, GameRequestCustomLobbies, GameRequestGenre, GameRequestTeamPlay, MatchLobbyStatus, MatchRound, MatchStreamPlatform, MatchWinnerSlot,
|
|
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';
|
|
3
3
|
/** Write payload for `about`. */
|
|
4
4
|
export interface AboutInput {
|
|
5
5
|
heroTitle?: string;
|
|
@@ -171,13 +171,6 @@ export interface PrivacyInput {
|
|
|
171
171
|
content?: BlocksContent;
|
|
172
172
|
title?: string;
|
|
173
173
|
}
|
|
174
|
-
/** Write payload for `prize`. */
|
|
175
|
-
export interface PrizeInput {
|
|
176
|
-
description?: string;
|
|
177
|
-
value?: number;
|
|
178
|
-
tournament?: RelationInput;
|
|
179
|
-
rank?: PrizeRank;
|
|
180
|
-
}
|
|
181
174
|
/** Write payload for `referral`. */
|
|
182
175
|
export interface ReferralInput {
|
|
183
176
|
referrer?: RelationInput;
|
|
@@ -277,18 +270,19 @@ export interface TournamentInput {
|
|
|
277
270
|
game?: RelationInput;
|
|
278
271
|
currentStatus?: TournamentCurrentStatus;
|
|
279
272
|
group?: RelationInput;
|
|
280
|
-
prizes?:
|
|
273
|
+
prizes?: unknown;
|
|
281
274
|
tournament_participants?: RelationInput | RelationInput[];
|
|
282
275
|
tournament_roles?: RelationInput | RelationInput[];
|
|
283
276
|
league_tables?: RelationInput | RelationInput[];
|
|
284
277
|
rules?: string;
|
|
285
278
|
hasThirdPlace?: boolean;
|
|
279
|
+
hasMatchLobby?: boolean;
|
|
286
280
|
isPrivate?: boolean;
|
|
287
281
|
checkInTime?: number;
|
|
288
282
|
region?: RelationInput;
|
|
289
283
|
platform?: RelationInput;
|
|
290
284
|
gameAccount?: RelationInput;
|
|
291
|
-
tournament_stages?:
|
|
285
|
+
tournament_stages?: unknown;
|
|
292
286
|
prizesDistributed?: boolean;
|
|
293
287
|
discordUrl?: string;
|
|
294
288
|
twitterUrl?: string;
|
|
@@ -320,13 +314,6 @@ export interface TournamentRoleInput {
|
|
|
320
314
|
users_permissions_user?: RelationInput;
|
|
321
315
|
tournament?: RelationInput;
|
|
322
316
|
}
|
|
323
|
-
/** Write payload for `tournament-stage`. */
|
|
324
|
-
export interface TournamentStageInput {
|
|
325
|
-
stageName?: TournamentStageStageName;
|
|
326
|
-
bestOf?: number;
|
|
327
|
-
tournament?: RelationInput;
|
|
328
|
-
isThirdPlace?: boolean;
|
|
329
|
-
}
|
|
330
317
|
/** Write payload for `user`. */
|
|
331
318
|
export interface UserInput {
|
|
332
319
|
username?: string;
|
|
@@ -354,6 +341,7 @@ export interface UserInput {
|
|
|
354
341
|
notificationPreferences?: unknown;
|
|
355
342
|
referralCode?: string;
|
|
356
343
|
referralRewardClaimed?: boolean;
|
|
344
|
+
lastSeenAt?: string;
|
|
357
345
|
}
|
|
358
346
|
/** Write payload for `user-game-account`. */
|
|
359
347
|
export interface UserGameAccountInput {
|
|
@@ -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,
|
|
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';
|
|
3
3
|
/** `about` */
|
|
4
4
|
export interface About extends StrapiDocument {
|
|
5
5
|
heroTitle?: string | null;
|
|
@@ -171,13 +171,6 @@ export interface Privacy extends StrapiDocument {
|
|
|
171
171
|
content?: BlocksContent | null;
|
|
172
172
|
title?: string | null;
|
|
173
173
|
}
|
|
174
|
-
/** `prize` */
|
|
175
|
-
export interface Prize extends StrapiDocument {
|
|
176
|
-
description?: string | null;
|
|
177
|
-
value?: number | null;
|
|
178
|
-
tournament?: Tournament | null;
|
|
179
|
-
rank?: PrizeRank | null;
|
|
180
|
-
}
|
|
181
174
|
/** `referral` */
|
|
182
175
|
export interface Referral extends StrapiDocument {
|
|
183
176
|
referrer?: User | null;
|
|
@@ -277,18 +270,19 @@ export interface Tournament extends StrapiDocument {
|
|
|
277
270
|
game?: Game | null;
|
|
278
271
|
currentStatus?: TournamentCurrentStatus | null;
|
|
279
272
|
group?: Group | null;
|
|
280
|
-
prizes?:
|
|
273
|
+
prizes?: unknown | null;
|
|
281
274
|
tournament_participants?: TournamentParticipant[];
|
|
282
275
|
tournament_roles?: TournamentRole[];
|
|
283
276
|
league_tables?: LeagueTable[];
|
|
284
277
|
rules?: string | null;
|
|
285
278
|
hasThirdPlace?: boolean | null;
|
|
279
|
+
hasMatchLobby?: boolean | null;
|
|
286
280
|
isPrivate?: boolean | null;
|
|
287
281
|
checkInTime?: number | null;
|
|
288
282
|
region?: Region | null;
|
|
289
283
|
platform?: Platform | null;
|
|
290
284
|
gameAccount?: GameAccount | null;
|
|
291
|
-
tournament_stages?:
|
|
285
|
+
tournament_stages?: unknown | null;
|
|
292
286
|
prizesDistributed?: boolean | null;
|
|
293
287
|
discordUrl?: string | null;
|
|
294
288
|
twitterUrl?: string | null;
|
|
@@ -320,13 +314,6 @@ export interface TournamentRole extends StrapiDocument {
|
|
|
320
314
|
users_permissions_user?: User | null;
|
|
321
315
|
tournament?: Tournament | null;
|
|
322
316
|
}
|
|
323
|
-
/** `tournament-stage` */
|
|
324
|
-
export interface TournamentStage extends StrapiDocument {
|
|
325
|
-
stageName?: TournamentStageStageName | null;
|
|
326
|
-
bestOf?: number | null;
|
|
327
|
-
tournament?: Tournament | null;
|
|
328
|
-
isThirdPlace?: boolean | null;
|
|
329
|
-
}
|
|
330
317
|
/** `user` */
|
|
331
318
|
export interface User extends StrapiDocument {
|
|
332
319
|
username?: string | null;
|
|
@@ -354,6 +341,7 @@ export interface User extends StrapiDocument {
|
|
|
354
341
|
notificationPreferences?: unknown | null;
|
|
355
342
|
referralCode?: string | null;
|
|
356
343
|
referralRewardClaimed?: boolean | null;
|
|
344
|
+
lastSeenAt?: string | null;
|
|
357
345
|
}
|
|
358
346
|
/** `user-game-account` */
|
|
359
347
|
export interface UserGameAccount extends StrapiDocument {
|
package/dist/index.d.ts
CHANGED
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
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
|
+
}
|
package/src/generated/enums.ts
CHANGED
|
@@ -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"
|
|
@@ -62,12 +64,6 @@ export type MatchWinnerSlot =
|
|
|
62
64
|
| "home"
|
|
63
65
|
| "away";
|
|
64
66
|
|
|
65
|
-
/** `Prize.rank` */
|
|
66
|
-
export type PrizeRank =
|
|
67
|
-
| "one"
|
|
68
|
-
| "two"
|
|
69
|
-
| "three";
|
|
70
|
-
|
|
71
67
|
/** `Referral.status` */
|
|
72
68
|
export type ReferralStatus =
|
|
73
69
|
| "pending"
|
|
@@ -127,16 +123,6 @@ export type TournamentRoleRole =
|
|
|
127
123
|
| "moderator"
|
|
128
124
|
| "admin";
|
|
129
125
|
|
|
130
|
-
/** `TournamentStage.stageName` */
|
|
131
|
-
export type TournamentStageStageName =
|
|
132
|
-
| "Round 32"
|
|
133
|
-
| "Round 16"
|
|
134
|
-
| "Quarter Final"
|
|
135
|
-
| "Semi-Final"
|
|
136
|
-
| "Third Round"
|
|
137
|
-
| "Finals"
|
|
138
|
-
| "League";
|
|
139
|
-
|
|
140
126
|
/** `Tournament.teamSize` */
|
|
141
127
|
export type TournamentTeamSize =
|
|
142
128
|
| "one"
|
package/src/generated/inputs.ts
CHANGED
|
@@ -16,7 +16,6 @@ import type {
|
|
|
16
16
|
MatchRound,
|
|
17
17
|
MatchStreamPlatform,
|
|
18
18
|
MatchWinnerSlot,
|
|
19
|
-
PrizeRank,
|
|
20
19
|
ReferralStatus,
|
|
21
20
|
TeamCurrentStatus,
|
|
22
21
|
TeamInviteStatus,
|
|
@@ -26,7 +25,6 @@ import type {
|
|
|
26
25
|
TournamentParticipantEntryType,
|
|
27
26
|
TournamentParticipantStatus,
|
|
28
27
|
TournamentRoleRole,
|
|
29
|
-
TournamentStageStageName,
|
|
30
28
|
TournamentTeamSize,
|
|
31
29
|
TournamentType,
|
|
32
30
|
UserTransactionStripeStatus,
|
|
@@ -225,14 +223,6 @@ export interface PrivacyInput {
|
|
|
225
223
|
title?: string;
|
|
226
224
|
}
|
|
227
225
|
|
|
228
|
-
/** Write payload for `prize`. */
|
|
229
|
-
export interface PrizeInput {
|
|
230
|
-
description?: string;
|
|
231
|
-
value?: number;
|
|
232
|
-
tournament?: RelationInput;
|
|
233
|
-
rank?: PrizeRank;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
226
|
/** Write payload for `referral`. */
|
|
237
227
|
export interface ReferralInput {
|
|
238
228
|
referrer?: RelationInput;
|
|
@@ -341,18 +331,19 @@ export interface TournamentInput {
|
|
|
341
331
|
game?: RelationInput;
|
|
342
332
|
currentStatus?: TournamentCurrentStatus;
|
|
343
333
|
group?: RelationInput;
|
|
344
|
-
prizes?:
|
|
334
|
+
prizes?: unknown;
|
|
345
335
|
tournament_participants?: RelationInput | RelationInput[];
|
|
346
336
|
tournament_roles?: RelationInput | RelationInput[];
|
|
347
337
|
league_tables?: RelationInput | RelationInput[];
|
|
348
338
|
rules?: string;
|
|
349
339
|
hasThirdPlace?: boolean;
|
|
340
|
+
hasMatchLobby?: boolean;
|
|
350
341
|
isPrivate?: boolean;
|
|
351
342
|
checkInTime?: number;
|
|
352
343
|
region?: RelationInput;
|
|
353
344
|
platform?: RelationInput;
|
|
354
345
|
gameAccount?: RelationInput;
|
|
355
|
-
tournament_stages?:
|
|
346
|
+
tournament_stages?: unknown;
|
|
356
347
|
prizesDistributed?: boolean;
|
|
357
348
|
discordUrl?: string;
|
|
358
349
|
twitterUrl?: string;
|
|
@@ -388,14 +379,6 @@ export interface TournamentRoleInput {
|
|
|
388
379
|
tournament?: RelationInput;
|
|
389
380
|
}
|
|
390
381
|
|
|
391
|
-
/** Write payload for `tournament-stage`. */
|
|
392
|
-
export interface TournamentStageInput {
|
|
393
|
-
stageName?: TournamentStageStageName;
|
|
394
|
-
bestOf?: number;
|
|
395
|
-
tournament?: RelationInput;
|
|
396
|
-
isThirdPlace?: boolean;
|
|
397
|
-
}
|
|
398
|
-
|
|
399
382
|
/** Write payload for `user`. */
|
|
400
383
|
export interface UserInput {
|
|
401
384
|
username?: string;
|
|
@@ -423,6 +406,7 @@ export interface UserInput {
|
|
|
423
406
|
notificationPreferences?: unknown;
|
|
424
407
|
referralCode?: string;
|
|
425
408
|
referralRewardClaimed?: boolean;
|
|
409
|
+
lastSeenAt?: string;
|
|
426
410
|
}
|
|
427
411
|
|
|
428
412
|
/** Write payload for `user-game-account`. */
|
package/src/generated/models.ts
CHANGED
|
@@ -17,7 +17,6 @@ import type {
|
|
|
17
17
|
MatchRound,
|
|
18
18
|
MatchStreamPlatform,
|
|
19
19
|
MatchWinnerSlot,
|
|
20
|
-
PrizeRank,
|
|
21
20
|
ReferralStatus,
|
|
22
21
|
TeamCurrentStatus,
|
|
23
22
|
TeamInviteStatus,
|
|
@@ -27,7 +26,6 @@ import type {
|
|
|
27
26
|
TournamentParticipantEntryType,
|
|
28
27
|
TournamentParticipantStatus,
|
|
29
28
|
TournamentRoleRole,
|
|
30
|
-
TournamentStageStageName,
|
|
31
29
|
TournamentTeamSize,
|
|
32
30
|
TournamentType,
|
|
33
31
|
UserTransactionStripeStatus,
|
|
@@ -226,14 +224,6 @@ export interface Privacy extends StrapiDocument {
|
|
|
226
224
|
title?: string | null;
|
|
227
225
|
}
|
|
228
226
|
|
|
229
|
-
/** `prize` */
|
|
230
|
-
export interface Prize extends StrapiDocument {
|
|
231
|
-
description?: string | null;
|
|
232
|
-
value?: number | null;
|
|
233
|
-
tournament?: Tournament | null;
|
|
234
|
-
rank?: PrizeRank | null;
|
|
235
|
-
}
|
|
236
|
-
|
|
237
227
|
/** `referral` */
|
|
238
228
|
export interface Referral extends StrapiDocument {
|
|
239
229
|
referrer?: User | null;
|
|
@@ -342,18 +332,19 @@ export interface Tournament extends StrapiDocument {
|
|
|
342
332
|
game?: Game | null;
|
|
343
333
|
currentStatus?: TournamentCurrentStatus | null;
|
|
344
334
|
group?: Group | null;
|
|
345
|
-
prizes?:
|
|
335
|
+
prizes?: unknown | null;
|
|
346
336
|
tournament_participants?: TournamentParticipant[];
|
|
347
337
|
tournament_roles?: TournamentRole[];
|
|
348
338
|
league_tables?: LeagueTable[];
|
|
349
339
|
rules?: string | null;
|
|
350
340
|
hasThirdPlace?: boolean | null;
|
|
341
|
+
hasMatchLobby?: boolean | null;
|
|
351
342
|
isPrivate?: boolean | null;
|
|
352
343
|
checkInTime?: number | null;
|
|
353
344
|
region?: Region | null;
|
|
354
345
|
platform?: Platform | null;
|
|
355
346
|
gameAccount?: GameAccount | null;
|
|
356
|
-
tournament_stages?:
|
|
347
|
+
tournament_stages?: unknown | null;
|
|
357
348
|
prizesDistributed?: boolean | null;
|
|
358
349
|
discordUrl?: string | null;
|
|
359
350
|
twitterUrl?: string | null;
|
|
@@ -389,14 +380,6 @@ export interface TournamentRole extends StrapiDocument {
|
|
|
389
380
|
tournament?: Tournament | null;
|
|
390
381
|
}
|
|
391
382
|
|
|
392
|
-
/** `tournament-stage` */
|
|
393
|
-
export interface TournamentStage extends StrapiDocument {
|
|
394
|
-
stageName?: TournamentStageStageName | null;
|
|
395
|
-
bestOf?: number | null;
|
|
396
|
-
tournament?: Tournament | null;
|
|
397
|
-
isThirdPlace?: boolean | null;
|
|
398
|
-
}
|
|
399
|
-
|
|
400
383
|
/** `user` */
|
|
401
384
|
export interface User extends StrapiDocument {
|
|
402
385
|
username?: string | null;
|
|
@@ -424,6 +407,7 @@ export interface User extends StrapiDocument {
|
|
|
424
407
|
notificationPreferences?: unknown | null;
|
|
425
408
|
referralCode?: string | null;
|
|
426
409
|
referralRewardClaimed?: boolean | null;
|
|
410
|
+
lastSeenAt?: string | null;
|
|
427
411
|
}
|
|
428
412
|
|
|
429
413
|
/** `user-game-account` */
|
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';
|