volleyballsimtypes 0.0.489 → 0.0.492
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/cjs/src/api/index.d.ts +4 -3
- package/dist/cjs/src/api/index.js +15 -0
- package/dist/cjs/src/api/tactics-player-refs.d.ts +13 -0
- package/dist/cjs/src/api/tactics-player-refs.js +43 -0
- package/dist/cjs/src/data/event-purge.d.ts +14 -0
- package/dist/cjs/src/data/event-purge.js +116 -0
- package/dist/cjs/src/data/index.d.ts +1 -0
- package/dist/cjs/src/data/index.js +1 -0
- package/dist/cjs/src/data/init-models.js +41 -0
- package/dist/cjs/src/data/models/competition.d.ts +1 -1
- package/dist/cjs/src/data/models/competition.js +1 -1
- package/dist/cjs/src/data/models/event-bot-team.d.ts +21 -0
- package/dist/cjs/src/data/models/event-bot-team.js +44 -0
- package/dist/cjs/src/data/models/event-competition.d.ts +29 -0
- package/dist/cjs/src/data/models/event-competition.js +52 -0
- package/dist/cjs/src/data/models/event-pack-player.d.ts +22 -0
- package/dist/cjs/src/data/models/event-pack-player.js +31 -0
- package/dist/cjs/src/data/models/event-pack.d.ts +37 -0
- package/dist/cjs/src/data/models/event-pack.js +55 -0
- package/dist/cjs/src/data/models/event-result.d.ts +34 -0
- package/dist/cjs/src/data/models/event-result.js +48 -0
- package/dist/cjs/src/data/models/event-squad.d.ts +30 -0
- package/dist/cjs/src/data/models/event-squad.js +46 -0
- package/dist/cjs/src/data/models/event.d.ts +51 -0
- package/dist/cjs/src/data/models/event.js +82 -0
- package/dist/cjs/src/data/models/index.d.ts +7 -0
- package/dist/cjs/src/data/models/index.js +7 -0
- package/dist/cjs/src/data/models/lineup-preset.d.ts +3 -1
- package/dist/cjs/src/data/models/lineup-preset.js +53 -0
- package/dist/cjs/src/data/models/tactics.d.ts +2 -2
- package/dist/cjs/src/data/transformers/tactics.test.js +19 -0
- package/dist/cjs/src/index.d.ts +1 -0
- package/dist/cjs/src/index.js +6 -0
- package/dist/cjs/src/service/competition/event-draft.d.ts +46 -0
- package/dist/cjs/src/service/competition/event-draft.js +85 -0
- package/dist/cjs/src/service/competition/event-draft.test.d.ts +1 -0
- package/dist/cjs/src/service/competition/event-draft.test.js +91 -0
- package/dist/cjs/src/service/competition/event-rewards.d.ts +26 -0
- package/dist/cjs/src/service/competition/event-rewards.js +49 -0
- package/dist/cjs/src/service/competition/event-rewards.test.d.ts +1 -0
- package/dist/cjs/src/service/competition/event-rewards.test.js +58 -0
- package/dist/cjs/src/service/competition/event-schedule.d.ts +126 -0
- package/dist/cjs/src/service/competition/event-schedule.js +141 -0
- package/dist/cjs/src/service/competition/event-schedule.test.d.ts +1 -0
- package/dist/cjs/src/service/competition/event-schedule.test.js +138 -0
- package/dist/cjs/src/service/competition/index.d.ts +3 -0
- package/dist/cjs/src/service/competition/index.js +3 -0
- package/dist/cjs/src/service/match/index.d.ts +1 -0
- package/dist/cjs/src/service/match/index.js +1 -0
- package/dist/cjs/src/service/match/match-generator.js +25 -48
- package/dist/cjs/src/service/match/match-generator.test.d.ts +1 -0
- package/dist/cjs/src/service/match/match-generator.test.js +48 -0
- package/dist/cjs/src/service/match/round-robin.d.ts +11 -0
- package/dist/cjs/src/service/match/round-robin.js +44 -0
- package/dist/cjs/src/service/match/round-robin.test.d.ts +1 -0
- package/dist/cjs/src/service/match/round-robin.test.js +71 -0
- package/dist/cjs/src/service/notification/catalog/en.json +21 -0
- package/dist/cjs/src/service/notification/catalog/es.json +21 -0
- package/dist/cjs/src/service/notification/catalog/fr.json +21 -0
- package/dist/cjs/src/service/notification/catalog/it.json +21 -0
- package/dist/cjs/src/service/notification/catalog/pl.json +21 -0
- package/dist/cjs/src/service/notification/catalog/pt-BR.json +21 -0
- package/dist/cjs/src/service/notification/catalog/tr.json +21 -0
- package/dist/cjs/src/service/notification/notification-type.d.ts +1 -1
- package/dist/cjs/src/service/notification/notification-type.js +6 -1
- package/dist/cjs/src/service/notification/notification.test.js +3 -1
- package/dist/cjs/src/service/notification/registry.js +52 -0
- package/dist/cjs/src/service/player/player-generator.d.ts +1 -0
- package/dist/cjs/src/service/player/player-generator.js +16 -1
- package/dist/cjs/src/service/team/blocking-preference.d.ts +3 -1
- package/dist/cjs/src/service/team/blocking-preference.js +7 -0
- package/dist/cjs/src/service/team/schemas/tactics.z.d.ts +2 -2
- package/dist/cjs/src/service/team/schemas/tactics.z.js +7 -2
- package/dist/cjs/src/service/team/schemas/tactics.z.test.js +32 -0
- package/dist/cjs/src/service/team/schemas/team.z.d.ts +2 -2
- package/dist/esm/src/api/index.d.ts +4 -3
- package/dist/esm/src/api/index.js +1 -0
- package/dist/esm/src/api/tactics-player-refs.d.ts +13 -0
- package/dist/esm/src/api/tactics-player-refs.js +40 -0
- package/dist/esm/src/data/event-purge.d.ts +14 -0
- package/dist/esm/src/data/event-purge.js +110 -0
- package/dist/esm/src/data/index.d.ts +1 -0
- package/dist/esm/src/data/index.js +1 -0
- package/dist/esm/src/data/init-models.js +42 -1
- package/dist/esm/src/data/models/competition.d.ts +1 -1
- package/dist/esm/src/data/models/competition.js +1 -1
- package/dist/esm/src/data/models/event-bot-team.d.ts +21 -0
- package/dist/esm/src/data/models/event-bot-team.js +40 -0
- package/dist/esm/src/data/models/event-competition.d.ts +29 -0
- package/dist/esm/src/data/models/event-competition.js +48 -0
- package/dist/esm/src/data/models/event-pack-player.d.ts +22 -0
- package/dist/esm/src/data/models/event-pack-player.js +27 -0
- package/dist/esm/src/data/models/event-pack.d.ts +37 -0
- package/dist/esm/src/data/models/event-pack.js +51 -0
- package/dist/esm/src/data/models/event-result.d.ts +34 -0
- package/dist/esm/src/data/models/event-result.js +44 -0
- package/dist/esm/src/data/models/event-squad.d.ts +30 -0
- package/dist/esm/src/data/models/event-squad.js +42 -0
- package/dist/esm/src/data/models/event.d.ts +51 -0
- package/dist/esm/src/data/models/event.js +78 -0
- package/dist/esm/src/data/models/index.d.ts +7 -0
- package/dist/esm/src/data/models/index.js +7 -0
- package/dist/esm/src/data/models/lineup-preset.d.ts +3 -1
- package/dist/esm/src/data/models/lineup-preset.js +20 -0
- package/dist/esm/src/data/models/tactics.d.ts +2 -2
- package/dist/esm/src/data/transformers/tactics.test.js +20 -1
- package/dist/esm/src/index.d.ts +1 -0
- package/dist/esm/src/index.js +4 -0
- package/dist/esm/src/service/competition/event-draft.d.ts +46 -0
- package/dist/esm/src/service/competition/event-draft.js +78 -0
- package/dist/esm/src/service/competition/event-draft.test.d.ts +1 -0
- package/dist/esm/src/service/competition/event-draft.test.js +89 -0
- package/dist/esm/src/service/competition/event-rewards.d.ts +26 -0
- package/dist/esm/src/service/competition/event-rewards.js +44 -0
- package/dist/esm/src/service/competition/event-rewards.test.d.ts +1 -0
- package/dist/esm/src/service/competition/event-rewards.test.js +56 -0
- package/dist/esm/src/service/competition/event-schedule.d.ts +126 -0
- package/dist/esm/src/service/competition/event-schedule.js +129 -0
- package/dist/esm/src/service/competition/event-schedule.test.d.ts +1 -0
- package/dist/esm/src/service/competition/event-schedule.test.js +136 -0
- package/dist/esm/src/service/competition/index.d.ts +3 -0
- package/dist/esm/src/service/competition/index.js +3 -0
- package/dist/esm/src/service/match/index.d.ts +1 -0
- package/dist/esm/src/service/match/index.js +1 -0
- package/dist/esm/src/service/match/match-generator.js +25 -48
- package/dist/esm/src/service/match/match-generator.test.d.ts +1 -0
- package/dist/esm/src/service/match/match-generator.test.js +43 -0
- package/dist/esm/src/service/match/round-robin.d.ts +11 -0
- package/dist/esm/src/service/match/round-robin.js +41 -0
- package/dist/esm/src/service/match/round-robin.test.d.ts +1 -0
- package/dist/esm/src/service/match/round-robin.test.js +69 -0
- package/dist/esm/src/service/notification/catalog/en.json +21 -0
- package/dist/esm/src/service/notification/catalog/es.json +21 -0
- package/dist/esm/src/service/notification/catalog/fr.json +21 -0
- package/dist/esm/src/service/notification/catalog/it.json +21 -0
- package/dist/esm/src/service/notification/catalog/pl.json +21 -0
- package/dist/esm/src/service/notification/catalog/pt-BR.json +21 -0
- package/dist/esm/src/service/notification/catalog/tr.json +21 -0
- package/dist/esm/src/service/notification/notification-type.d.ts +1 -1
- package/dist/esm/src/service/notification/notification-type.js +6 -1
- package/dist/esm/src/service/notification/notification.test.js +3 -1
- package/dist/esm/src/service/notification/registry.js +52 -0
- package/dist/esm/src/service/player/player-generator.d.ts +1 -0
- package/dist/esm/src/service/player/player-generator.js +15 -0
- package/dist/esm/src/service/team/blocking-preference.d.ts +3 -1
- package/dist/esm/src/service/team/blocking-preference.js +6 -1
- package/dist/esm/src/service/team/schemas/tactics.z.d.ts +2 -2
- package/dist/esm/src/service/team/schemas/tactics.z.js +7 -2
- package/dist/esm/src/service/team/schemas/tactics.z.test.js +32 -0
- package/dist/esm/src/service/team/schemas/team.z.d.ts +2 -2
- package/package.json +6 -1
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, it, expect } from '@jest/globals';
|
|
2
|
+
import { DRAFT_PLAN, DRAFT_SQUAD_SIZE, PACK_SIZE, draftPackRole, generatePack, isDraftComplete, packQualityBand } from './event-draft';
|
|
3
|
+
import { RarityEnum, RoleEnum, calculateRoleScore } from '../player';
|
|
4
|
+
import { makeCountry } from '../test-helpers';
|
|
5
|
+
const COUNTRY = makeCountry();
|
|
6
|
+
describe('DRAFT_PLAN', () => {
|
|
7
|
+
it('drafts a full 14-player squad, one pick per pack', () => {
|
|
8
|
+
expect(DRAFT_SQUAD_SIZE).toBe(14);
|
|
9
|
+
expect(DRAFT_PLAN).toHaveLength(14);
|
|
10
|
+
});
|
|
11
|
+
it('guarantees a positionally valid squad', () => {
|
|
12
|
+
const counts = DRAFT_PLAN.reduce((acc, role) => {
|
|
13
|
+
acc[role] = (acc[role] ?? 0) + 1;
|
|
14
|
+
return acc;
|
|
15
|
+
}, {});
|
|
16
|
+
expect(counts[RoleEnum.SETTER]).toBe(2);
|
|
17
|
+
expect(counts[RoleEnum.OUTSIDE_HITTER]).toBe(4);
|
|
18
|
+
expect(counts[RoleEnum.MIDDLE_BLOCKER]).toBe(3);
|
|
19
|
+
expect(counts[RoleEnum.OPPOSITE_HITTER]).toBe(2);
|
|
20
|
+
expect(counts[RoleEnum.LIBERO]).toBe(2);
|
|
21
|
+
expect(counts.ANY).toBe(1);
|
|
22
|
+
// Enough for a legal starting six plus a libero, whatever the free pack is spent on: a 5-1 needs a setter,
|
|
23
|
+
// two middles, two outsides and an opposite on court, and a libero off it.
|
|
24
|
+
expect(counts[RoleEnum.SETTER]).toBeGreaterThanOrEqual(1);
|
|
25
|
+
expect(counts[RoleEnum.MIDDLE_BLOCKER]).toBeGreaterThanOrEqual(2);
|
|
26
|
+
expect(counts[RoleEnum.OUTSIDE_HITTER]).toBeGreaterThanOrEqual(2);
|
|
27
|
+
expect(counts[RoleEnum.OPPOSITE_HITTER]).toBeGreaterThanOrEqual(1);
|
|
28
|
+
expect(counts[RoleEnum.LIBERO]).toBeGreaterThanOrEqual(1);
|
|
29
|
+
});
|
|
30
|
+
it('walks pack by pack and then reports the draft complete', () => {
|
|
31
|
+
for (let i = 0; i < DRAFT_SQUAD_SIZE; i++) {
|
|
32
|
+
expect(draftPackRole(i)).toBeDefined();
|
|
33
|
+
expect(isDraftComplete(i)).toBe(false);
|
|
34
|
+
}
|
|
35
|
+
expect(draftPackRole(DRAFT_SQUAD_SIZE)).toBeUndefined();
|
|
36
|
+
expect(isDraftComplete(DRAFT_SQUAD_SIZE)).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
describe('generatePack()', () => {
|
|
40
|
+
const first = (roles) => roles[0];
|
|
41
|
+
it.each([RarityEnum.COMMON, RarityEnum.RARE, RarityEnum.LEGENDARY])('offers %s players of the pack role, all inside the fairness band', (rarity) => {
|
|
42
|
+
const role = RoleEnum.OUTSIDE_HITTER;
|
|
43
|
+
const band = packQualityBand(COUNTRY, rarity, role);
|
|
44
|
+
const pack = generatePack(COUNTRY, rarity, role, () => band, first);
|
|
45
|
+
expect(pack).toHaveLength(PACK_SIZE);
|
|
46
|
+
for (const player of pack) {
|
|
47
|
+
expect(player.rarity).toBe(rarity);
|
|
48
|
+
expect(player.roles).toContain(role);
|
|
49
|
+
const score = calculateRoleScore(player.stats, role);
|
|
50
|
+
expect(score).toBeGreaterThanOrEqual(band.min);
|
|
51
|
+
expect(score).toBeLessThanOrEqual(band.max);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
it('keeps two packs of the same role comparable', () => {
|
|
55
|
+
const role = RoleEnum.MIDDLE_BLOCKER;
|
|
56
|
+
const band = packQualityBand(COUNTRY, RarityEnum.RARE, role);
|
|
57
|
+
const score = (p) => calculateRoleScore(p.stats, role);
|
|
58
|
+
const a = generatePack(COUNTRY, RarityEnum.RARE, role, () => band, first).map(score);
|
|
59
|
+
const b = generatePack(COUNTRY, RarityEnum.RARE, role, () => band, first).map(score);
|
|
60
|
+
// The point of the band: the best pack cannot run away from the worst one.
|
|
61
|
+
const spread = Math.max(...a, ...b) - Math.min(...a, ...b);
|
|
62
|
+
expect(spread).toBeLessThanOrEqual(band.max - band.min);
|
|
63
|
+
});
|
|
64
|
+
it('rolls a free pack at the role it is handed', () => {
|
|
65
|
+
const band = packQualityBand(COUNTRY, RarityEnum.COMMON, RoleEnum.LIBERO);
|
|
66
|
+
const pack = generatePack(COUNTRY, RarityEnum.COMMON, 'ANY', () => band, () => RoleEnum.LIBERO);
|
|
67
|
+
expect(pack).toHaveLength(PACK_SIZE);
|
|
68
|
+
for (const player of pack)
|
|
69
|
+
expect(player.roles).toContain(RoleEnum.LIBERO);
|
|
70
|
+
});
|
|
71
|
+
it('terminates even when nothing can land in band', () => {
|
|
72
|
+
const impossible = { role: RoleEnum.SETTER, min: Number.MAX_SAFE_INTEGER, max: Number.MAX_SAFE_INTEGER };
|
|
73
|
+
const pack = generatePack(COUNTRY, RarityEnum.COMMON, RoleEnum.SETTER, () => impossible, first);
|
|
74
|
+
// Generation gives up rather than spinning: a full pack is still returned.
|
|
75
|
+
expect(pack).toHaveLength(PACK_SIZE);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
describe('packQualityBand()', () => {
|
|
79
|
+
it('brackets the median of what the generator actually produces', () => {
|
|
80
|
+
const band = packQualityBand(COUNTRY, RarityEnum.LEGENDARY, RoleEnum.SETTER);
|
|
81
|
+
expect(band.min).toBeLessThan(band.max);
|
|
82
|
+
expect(band.min).toBeGreaterThan(0);
|
|
83
|
+
});
|
|
84
|
+
it('scales with rarity, so a legendary band sits above a common one', () => {
|
|
85
|
+
const common = packQualityBand(COUNTRY, RarityEnum.COMMON, RoleEnum.OUTSIDE_HITTER);
|
|
86
|
+
const legendary = packQualityBand(COUNTRY, RarityEnum.LEGENDARY, RoleEnum.OUTSIDE_HITTER);
|
|
87
|
+
expect(legendary.min).toBeGreaterThan(common.max);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export type EventRewardBand = 'PARTICIPATION' | 'TOP_20' | 'TOP_10' | 'TOP_5' | 'TOP_3' | 'TOP_1';
|
|
2
|
+
export interface EventRewardRow {
|
|
3
|
+
readonly band: EventRewardBand;
|
|
4
|
+
/** Best final position that earns this row; PARTICIPATION has none. */
|
|
5
|
+
readonly maxPosition: number | null;
|
|
6
|
+
/** Event Packs awarded by this row alone. */
|
|
7
|
+
readonly packs: number;
|
|
8
|
+
/** Coins awarded by this row alone. */
|
|
9
|
+
readonly coins: number;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Best band first. A team earns its own row and every row BELOW it, which is what "cumulative" means: first
|
|
13
|
+
* place collects all six.
|
|
14
|
+
*/
|
|
15
|
+
export declare const EVENT_REWARDS: readonly EventRewardRow[];
|
|
16
|
+
/**
|
|
17
|
+
* The band a finish falls into. `position` is the place in the FINAL table; null means the team never reached
|
|
18
|
+
* the final, and so does any position past the last banded row, which both count as taking part.
|
|
19
|
+
*/
|
|
20
|
+
export declare function eventRewardBandFor(position: number | null): EventRewardBand;
|
|
21
|
+
export interface EventPayout {
|
|
22
|
+
readonly packs: number;
|
|
23
|
+
readonly coins: number;
|
|
24
|
+
}
|
|
25
|
+
/** Everything a finish earns, adding its own row to every row beneath it. */
|
|
26
|
+
export declare function eventPayoutFor(position: number | null): EventPayout;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// What an event pays out, and the band a team's finish falls into. Owner-defined 2026-07-23.
|
|
2
|
+
//
|
|
3
|
+
// One source of truth for three consumers: the simulator grants from it, the About view lists it, and the
|
|
4
|
+
// accolades page names the band. Rewards are CUMULATIVE, so a team collects every row from Participation down
|
|
5
|
+
// to its own placement.
|
|
6
|
+
/**
|
|
7
|
+
* Best band first. A team earns its own row and every row BELOW it, which is what "cumulative" means: first
|
|
8
|
+
* place collects all six.
|
|
9
|
+
*/
|
|
10
|
+
export const EVENT_REWARDS = [
|
|
11
|
+
{ band: 'TOP_1', maxPosition: 1, packs: 1, coins: 0 },
|
|
12
|
+
{ band: 'TOP_3', maxPosition: 3, packs: 0, coins: 500 },
|
|
13
|
+
{ band: 'TOP_5', maxPosition: 5, packs: 0, coins: 1000 },
|
|
14
|
+
{ band: 'TOP_10', maxPosition: 10, packs: 1, coins: 0 },
|
|
15
|
+
{ band: 'TOP_20', maxPosition: 20, packs: 0, coins: 1000 },
|
|
16
|
+
{ band: 'PARTICIPATION', maxPosition: null, packs: 1, coins: 1000 }
|
|
17
|
+
];
|
|
18
|
+
/**
|
|
19
|
+
* The band a finish falls into. `position` is the place in the FINAL table; null means the team never reached
|
|
20
|
+
* the final, and so does any position past the last banded row, which both count as taking part.
|
|
21
|
+
*/
|
|
22
|
+
export function eventRewardBandFor(position) {
|
|
23
|
+
if (position == null)
|
|
24
|
+
return 'PARTICIPATION';
|
|
25
|
+
// Rows are ordered best-first, so the FIRST row the position satisfies is the narrowest band it earns.
|
|
26
|
+
// Scanning to the end instead would always land on the widest, handing first place a "top 20" badge.
|
|
27
|
+
for (const row of EVENT_REWARDS) {
|
|
28
|
+
if (row.maxPosition != null && position <= row.maxPosition)
|
|
29
|
+
return row.band;
|
|
30
|
+
}
|
|
31
|
+
return 'PARTICIPATION';
|
|
32
|
+
}
|
|
33
|
+
/** Everything a finish earns, adding its own row to every row beneath it. */
|
|
34
|
+
export function eventPayoutFor(position) {
|
|
35
|
+
const band = eventRewardBandFor(position);
|
|
36
|
+
const from = EVENT_REWARDS.findIndex(row => row.band === band);
|
|
37
|
+
let packs = 0;
|
|
38
|
+
let coins = 0;
|
|
39
|
+
for (const row of EVENT_REWARDS.slice(from)) {
|
|
40
|
+
packs += row.packs;
|
|
41
|
+
coins += row.coins;
|
|
42
|
+
}
|
|
43
|
+
return { packs, coins };
|
|
44
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, it, expect } from '@jest/globals';
|
|
2
|
+
import { EVENT_REWARDS, eventPayoutFor, eventRewardBandFor } from './event-rewards';
|
|
3
|
+
describe('eventRewardBandFor()', () => {
|
|
4
|
+
it.each([
|
|
5
|
+
[1, 'TOP_1'],
|
|
6
|
+
[2, 'TOP_3'],
|
|
7
|
+
[3, 'TOP_3'],
|
|
8
|
+
[4, 'TOP_5'],
|
|
9
|
+
[5, 'TOP_5'],
|
|
10
|
+
[6, 'TOP_10'],
|
|
11
|
+
[10, 'TOP_10'],
|
|
12
|
+
[11, 'TOP_20'],
|
|
13
|
+
[20, 'TOP_20'],
|
|
14
|
+
// Inside a 24-team final but outside every banded row, so it counts as taking part.
|
|
15
|
+
[21, 'PARTICIPATION'],
|
|
16
|
+
[24, 'PARTICIPATION']
|
|
17
|
+
])('places a finish of %i in %s', (position, band) => {
|
|
18
|
+
expect(eventRewardBandFor(position)).toBe(band);
|
|
19
|
+
});
|
|
20
|
+
it('treats a team that never reached the final as participation', () => {
|
|
21
|
+
expect(eventRewardBandFor(null)).toBe('PARTICIPATION');
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
describe('eventPayoutFor()', () => {
|
|
25
|
+
it.each([
|
|
26
|
+
// position, packs, coins. Cumulative: every row from Participation down to the finish.
|
|
27
|
+
[null, 1, 1000], // group stage: 1 pack + 1000
|
|
28
|
+
[24, 1, 1000], // in the final but outside the top 20: still just participation
|
|
29
|
+
[20, 1, 2000], // + top 20's 1000
|
|
30
|
+
[11, 1, 2000],
|
|
31
|
+
[10, 2, 2000], // + top 10's pack
|
|
32
|
+
[6, 2, 2000],
|
|
33
|
+
[5, 2, 3000], // + top 5's 1000
|
|
34
|
+
[4, 2, 3000],
|
|
35
|
+
[3, 2, 3500], // + top 3's 500
|
|
36
|
+
[2, 2, 3500],
|
|
37
|
+
[1, 3, 3500] // + the winner's pack
|
|
38
|
+
])('pays a finish of %s with %i packs and %i coins', (position, packs, coins) => {
|
|
39
|
+
expect(eventPayoutFor(position)).toEqual({ packs, coins });
|
|
40
|
+
});
|
|
41
|
+
it('never pays less for a better finish', () => {
|
|
42
|
+
const finishes = [null, 24, 20, 11, 10, 6, 5, 4, 3, 2, 1];
|
|
43
|
+
for (let i = 1; i < finishes.length; i++) {
|
|
44
|
+
const worse = eventPayoutFor(finishes[i - 1]);
|
|
45
|
+
const better = eventPayoutFor(finishes[i]);
|
|
46
|
+
expect(better.packs).toBeGreaterThanOrEqual(worse.packs);
|
|
47
|
+
expect(better.coins).toBeGreaterThanOrEqual(worse.coins);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
it('adds up to the owner-defined table for a winner', () => {
|
|
51
|
+
// 1x pack + 1000 (participation), +1000 (top 20), +1 pack (top 10), +1000 (top 5), +500 (top 3), +1 pack (top 1)
|
|
52
|
+
const total = EVENT_REWARDS.reduce((acc, row) => ({ packs: acc.packs + row.packs, coins: acc.coins + row.coins }), { packs: 0, coins: 0 });
|
|
53
|
+
expect(eventPayoutFor(1)).toEqual(total);
|
|
54
|
+
expect(total).toEqual({ packs: 3, coins: 3500 });
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { Rarity } from '../player';
|
|
2
|
+
export type EventKey = 'WAR_OF_THE_COMMONS' | 'RARE_GEMS' | 'STUFF_OF_LEGENDS';
|
|
3
|
+
/**
|
|
4
|
+
* How an event is played out. One member today; a new format is a new member here AND the code path in the
|
|
5
|
+
* simulator that builds its schedule, which is why formats live in code rather than in a table.
|
|
6
|
+
* - `GROUPS_FINAL` equal groups playing a round robin, then one final group of the qualifiers on the last day.
|
|
7
|
+
*/
|
|
8
|
+
export type EventFormat = 'GROUPS_FINAL';
|
|
9
|
+
/**
|
|
10
|
+
* Where an event is in its life.
|
|
11
|
+
* - `REGISTRATION` the lineup can be saved; saving is what registers the team.
|
|
12
|
+
* - `IN_PROGRESS` matches are scheduled and playing (group stage, then the final).
|
|
13
|
+
* - `REVIEW` finished; results readable, nothing playing.
|
|
14
|
+
* - `ARCHIVED` purged; only the Event row itself survives.
|
|
15
|
+
*/
|
|
16
|
+
export type EventStatus = 'REGISTRATION' | 'IN_PROGRESS' | 'REVIEW' | 'ARCHIVED';
|
|
17
|
+
/**
|
|
18
|
+
* A preset: everything about HOW an event works. An admin cannot change any of it, they pick one of these and
|
|
19
|
+
* say when it starts, so an event can never be configured into a shape the simulator cannot run.
|
|
20
|
+
*/
|
|
21
|
+
export interface EventDefinition {
|
|
22
|
+
readonly key: EventKey;
|
|
23
|
+
readonly format: EventFormat;
|
|
24
|
+
/** Every player referenced anywhere in the event lineup must be EXACTLY this rarity. */
|
|
25
|
+
readonly rarity: Rarity;
|
|
26
|
+
/** Total days from the start, the last of which is the final. */
|
|
27
|
+
readonly durationDays: number;
|
|
28
|
+
}
|
|
29
|
+
export declare const EVENT_CATALOGUE: readonly EventDefinition[];
|
|
30
|
+
/** An event must be long enough to hold a group stage AND a final day. */
|
|
31
|
+
export declare const MIN_DURATION_DAYS = 2;
|
|
32
|
+
/** Registration has to give players a real chance to build a squad, and cannot be scheduled absurdly far out. */
|
|
33
|
+
export declare const MIN_REGISTRATION_DAYS = 1;
|
|
34
|
+
export declare const MAX_REGISTRATION_DAYS = 30;
|
|
35
|
+
/** Results stay readable for this long after an event ends, then everything but the Event row is deleted. */
|
|
36
|
+
export declare const REVIEW_DAYS = 3;
|
|
37
|
+
/**
|
|
38
|
+
* The final group is 24 teams: at one match per hour that is 23 rounds, which is the largest round robin that
|
|
39
|
+
* still fits inside the single final day.
|
|
40
|
+
*/
|
|
41
|
+
export declare const MAX_FINAL_SIZE = 24;
|
|
42
|
+
/** A group is padded with bots to at least this many teams, so a small turnout still plays a real group stage. */
|
|
43
|
+
export declare const MIN_GROUP_SIZE = 16;
|
|
44
|
+
/** Rounds are never closer together than an hour, which is what bounds how large a group can be. */
|
|
45
|
+
export declare const MIN_ROUND_SPACING_MINUTES = 60;
|
|
46
|
+
/** One match per hour in the final: 24 teams = 23 rounds, 00:00 to 23:00. */
|
|
47
|
+
export declare const FINAL_ROUND_SPACING_MINUTES = 60;
|
|
48
|
+
/**
|
|
49
|
+
* How a turnout is split into groups, and how many of each group go through.
|
|
50
|
+
*
|
|
51
|
+
* Owner-defined (2026-07-23). Read as "fewer than `maxParticipants`". Every row multiplies out to exactly
|
|
52
|
+
* MAX_FINAL_SIZE, so the final is always 24 teams however many entered, and every group sends the same number
|
|
53
|
+
* through. Group counts that cannot divide 24 into whole qualifiers (5, 10) are deliberately absent.
|
|
54
|
+
*/
|
|
55
|
+
export interface EventGroupTier {
|
|
56
|
+
/** Exclusive upper bound on the entrant count for this tier. */
|
|
57
|
+
readonly maxParticipants: number;
|
|
58
|
+
readonly groupCount: number;
|
|
59
|
+
readonly qualifiersPerGroup: number;
|
|
60
|
+
}
|
|
61
|
+
export declare const EVENT_GROUP_TIERS: readonly EventGroupTier[];
|
|
62
|
+
/** The tier a turnout falls into. The last row has no upper bound, so this always resolves. */
|
|
63
|
+
export declare function eventGroupTierFor(participantCount: number): EventGroupTier;
|
|
64
|
+
/** The preset a stored event was created from. Its rules are read from this, never re-derived. */
|
|
65
|
+
export declare function eventDefinitionForKey(key: EventKey): EventDefinition;
|
|
66
|
+
export interface EventWindows {
|
|
67
|
+
readonly key: EventKey;
|
|
68
|
+
readonly format: EventFormat;
|
|
69
|
+
readonly rarity: Rarity;
|
|
70
|
+
/** Registration opens this many days before the start. */
|
|
71
|
+
readonly registrationOpensAt: Date;
|
|
72
|
+
/** Registration closes the instant the event starts. */
|
|
73
|
+
readonly registrationClosesAt: Date;
|
|
74
|
+
readonly startsAt: Date;
|
|
75
|
+
/** 00:00 UTC on the last day: the final is a single day, one round per hour. */
|
|
76
|
+
readonly finalStartsAt: Date;
|
|
77
|
+
/** The exclusive end of the last day. */
|
|
78
|
+
readonly endsAt: Date;
|
|
79
|
+
/** Review ends and everything but the Event row is deleted. */
|
|
80
|
+
readonly purgeAt: Date;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Every date an event will ever have, computed once from its preset, its start day and how much lead time
|
|
84
|
+
* registration gets. The start is floored to 00:00 UTC so an event always runs whole days.
|
|
85
|
+
*
|
|
86
|
+
* ```
|
|
87
|
+
* start - registrationDays registration opens
|
|
88
|
+
* start registration closes, group stage begins
|
|
89
|
+
* start + duration - 1 the final (one day)
|
|
90
|
+
* start + duration the event ends, review begins
|
|
91
|
+
* start + duration + 3 purge
|
|
92
|
+
* ```
|
|
93
|
+
*/
|
|
94
|
+
export declare function eventWindowsFor(definition: EventDefinition, startsAt: Date, registrationDays: number): EventWindows;
|
|
95
|
+
/** How long the group stage has, in hours: everything before the final day. */
|
|
96
|
+
export declare function groupStageHours(definition: EventDefinition): number;
|
|
97
|
+
export declare function eventStatusAt(windows: EventWindows, now: Date): EventStatus;
|
|
98
|
+
export interface EventGroupPlan {
|
|
99
|
+
readonly groupCount: number;
|
|
100
|
+
/** Equal across every group, always even, bots making up the difference. */
|
|
101
|
+
readonly groupSize: number;
|
|
102
|
+
/** How many bot teams have to be generated to fill the groups. */
|
|
103
|
+
readonly botCount: number;
|
|
104
|
+
readonly qualifiersPerGroup: number;
|
|
105
|
+
readonly finalSize: number;
|
|
106
|
+
/** Minutes between group-stage rounds, so the whole round robin lands inside the group-stage window. */
|
|
107
|
+
readonly groupRoundSpacingMinutes: number;
|
|
108
|
+
/** True when the group stage cannot fit even at the minimum spacing; the caller must log it loudly. */
|
|
109
|
+
readonly overflows: boolean;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Rounds start on the hour or the half hour and nothing else, so a kickoff never reads as 19:12.
|
|
113
|
+
*
|
|
114
|
+
* Snapped DOWN, so a stage that already fits its window still fits: rounding up could push the last round past
|
|
115
|
+
* the final. Events start at 00:00 UTC, so every round lands on the grid.
|
|
116
|
+
*/
|
|
117
|
+
export declare const ROUND_SPACING_GRID_MINUTES = 30;
|
|
118
|
+
/**
|
|
119
|
+
* How a turnout of `participantCount` real teams is split, and how the round robin is paced to fit
|
|
120
|
+
* `stageHours` (the event's days before its final day, from groupStageHours).
|
|
121
|
+
*
|
|
122
|
+
* The group count and the number that go through come from EVENT_GROUP_TIERS. Groups are all equal in size,
|
|
123
|
+
* always even (a round robin needs an even field for everyone to play every round) and bot-padded to at least
|
|
124
|
+
* MIN_GROUP_SIZE, so a small turnout still plays a full group stage.
|
|
125
|
+
*/
|
|
126
|
+
export declare function planEventGroups(participantCount: number, stageHours: number): EventGroupPlan;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import dayjs from 'dayjs';
|
|
2
|
+
import utc from 'dayjs/plugin/utc';
|
|
3
|
+
import { RarityEnum } from '../player';
|
|
4
|
+
dayjs.extend(utc);
|
|
5
|
+
// The presets an admin can schedule. Adding one here (plus its name in the 7 locale files) is all a new event
|
|
6
|
+
// needs, as long as its format is one the simulator already builds.
|
|
7
|
+
export const EVENT_CATALOGUE = [
|
|
8
|
+
{ key: 'WAR_OF_THE_COMMONS', format: 'GROUPS_FINAL', rarity: RarityEnum.COMMON, durationDays: 7 },
|
|
9
|
+
{ key: 'RARE_GEMS', format: 'GROUPS_FINAL', rarity: RarityEnum.RARE, durationDays: 7 },
|
|
10
|
+
{ key: 'STUFF_OF_LEGENDS', format: 'GROUPS_FINAL', rarity: RarityEnum.LEGENDARY, durationDays: 7 }
|
|
11
|
+
];
|
|
12
|
+
/** An event must be long enough to hold a group stage AND a final day. */
|
|
13
|
+
export const MIN_DURATION_DAYS = 2;
|
|
14
|
+
/** Registration has to give players a real chance to build a squad, and cannot be scheduled absurdly far out. */
|
|
15
|
+
export const MIN_REGISTRATION_DAYS = 1;
|
|
16
|
+
export const MAX_REGISTRATION_DAYS = 30;
|
|
17
|
+
/** Results stay readable for this long after an event ends, then everything but the Event row is deleted. */
|
|
18
|
+
export const REVIEW_DAYS = 3;
|
|
19
|
+
/**
|
|
20
|
+
* The final group is 24 teams: at one match per hour that is 23 rounds, which is the largest round robin that
|
|
21
|
+
* still fits inside the single final day.
|
|
22
|
+
*/
|
|
23
|
+
export const MAX_FINAL_SIZE = 24;
|
|
24
|
+
/** A group is padded with bots to at least this many teams, so a small turnout still plays a real group stage. */
|
|
25
|
+
export const MIN_GROUP_SIZE = 16;
|
|
26
|
+
/** Rounds are never closer together than an hour, which is what bounds how large a group can be. */
|
|
27
|
+
export const MIN_ROUND_SPACING_MINUTES = 60;
|
|
28
|
+
/** One match per hour in the final: 24 teams = 23 rounds, 00:00 to 23:00. */
|
|
29
|
+
export const FINAL_ROUND_SPACING_MINUTES = 60;
|
|
30
|
+
export const EVENT_GROUP_TIERS = [
|
|
31
|
+
{ maxParticipants: 64, groupCount: 3, qualifiersPerGroup: 8 },
|
|
32
|
+
{ maxParticipants: 96, groupCount: 4, qualifiersPerGroup: 6 },
|
|
33
|
+
{ maxParticipants: 128, groupCount: 6, qualifiersPerGroup: 4 },
|
|
34
|
+
{ maxParticipants: 192, groupCount: 8, qualifiersPerGroup: 3 },
|
|
35
|
+
{ maxParticipants: Number.POSITIVE_INFINITY, groupCount: 12, qualifiersPerGroup: 2 }
|
|
36
|
+
];
|
|
37
|
+
/** The tier a turnout falls into. The last row has no upper bound, so this always resolves. */
|
|
38
|
+
export function eventGroupTierFor(participantCount) {
|
|
39
|
+
return EVENT_GROUP_TIERS.find(tier => participantCount < tier.maxParticipants) ?? EVENT_GROUP_TIERS[EVENT_GROUP_TIERS.length - 1];
|
|
40
|
+
}
|
|
41
|
+
/** The preset a stored event was created from. Its rules are read from this, never re-derived. */
|
|
42
|
+
export function eventDefinitionForKey(key) {
|
|
43
|
+
const definition = EVENT_CATALOGUE.find(e => e.key === key);
|
|
44
|
+
if (definition == null)
|
|
45
|
+
throw new Error(`UNKNOWN_EVENT_KEY: ${key}`);
|
|
46
|
+
return definition;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Every date an event will ever have, computed once from its preset, its start day and how much lead time
|
|
50
|
+
* registration gets. The start is floored to 00:00 UTC so an event always runs whole days.
|
|
51
|
+
*
|
|
52
|
+
* ```
|
|
53
|
+
* start - registrationDays registration opens
|
|
54
|
+
* start registration closes, group stage begins
|
|
55
|
+
* start + duration - 1 the final (one day)
|
|
56
|
+
* start + duration the event ends, review begins
|
|
57
|
+
* start + duration + 3 purge
|
|
58
|
+
* ```
|
|
59
|
+
*/
|
|
60
|
+
export function eventWindowsFor(definition, startsAt, registrationDays) {
|
|
61
|
+
const start = dayjs.utc(startsAt).startOf('day');
|
|
62
|
+
return {
|
|
63
|
+
key: definition.key,
|
|
64
|
+
format: definition.format,
|
|
65
|
+
rarity: definition.rarity,
|
|
66
|
+
registrationOpensAt: start.subtract(registrationDays, 'day').toDate(),
|
|
67
|
+
registrationClosesAt: start.toDate(),
|
|
68
|
+
startsAt: start.toDate(),
|
|
69
|
+
finalStartsAt: start.add(definition.durationDays - 1, 'day').toDate(),
|
|
70
|
+
endsAt: start.add(definition.durationDays, 'day').toDate(),
|
|
71
|
+
purgeAt: start.add(definition.durationDays + REVIEW_DAYS, 'day').toDate()
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
/** How long the group stage has, in hours: everything before the final day. */
|
|
75
|
+
export function groupStageHours(definition) {
|
|
76
|
+
return (definition.durationDays - 1) * 24;
|
|
77
|
+
}
|
|
78
|
+
export function eventStatusAt(windows, now) {
|
|
79
|
+
const t = now.getTime();
|
|
80
|
+
if (t < windows.startsAt.getTime())
|
|
81
|
+
return 'REGISTRATION';
|
|
82
|
+
if (t < windows.endsAt.getTime())
|
|
83
|
+
return 'IN_PROGRESS';
|
|
84
|
+
if (t < windows.purgeAt.getTime())
|
|
85
|
+
return 'REVIEW';
|
|
86
|
+
return 'ARCHIVED';
|
|
87
|
+
}
|
|
88
|
+
function evenUp(n) {
|
|
89
|
+
return n % 2 === 0 ? n : n + 1;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Rounds start on the hour or the half hour and nothing else, so a kickoff never reads as 19:12.
|
|
93
|
+
*
|
|
94
|
+
* Snapped DOWN, so a stage that already fits its window still fits: rounding up could push the last round past
|
|
95
|
+
* the final. Events start at 00:00 UTC, so every round lands on the grid.
|
|
96
|
+
*/
|
|
97
|
+
export const ROUND_SPACING_GRID_MINUTES = 30;
|
|
98
|
+
function snapToGrid(minutes) {
|
|
99
|
+
return Math.floor(minutes / ROUND_SPACING_GRID_MINUTES) * ROUND_SPACING_GRID_MINUTES;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* How a turnout of `participantCount` real teams is split, and how the round robin is paced to fit
|
|
103
|
+
* `stageHours` (the event's days before its final day, from groupStageHours).
|
|
104
|
+
*
|
|
105
|
+
* The group count and the number that go through come from EVENT_GROUP_TIERS. Groups are all equal in size,
|
|
106
|
+
* always even (a round robin needs an even field for everyone to play every round) and bot-padded to at least
|
|
107
|
+
* MIN_GROUP_SIZE, so a small turnout still plays a full group stage.
|
|
108
|
+
*/
|
|
109
|
+
export function planEventGroups(participantCount, stageHours) {
|
|
110
|
+
const tier = eventGroupTierFor(participantCount);
|
|
111
|
+
const groupCount = tier.groupCount;
|
|
112
|
+
const groupSize = Math.max(MIN_GROUP_SIZE, evenUp(Math.ceil(participantCount / groupCount)));
|
|
113
|
+
// Capped by the group size purely as a guard; with a 16-team minimum and at most 8 going through it never binds.
|
|
114
|
+
const qualifiersPerGroup = Math.min(tier.qualifiersPerGroup, groupSize);
|
|
115
|
+
// One round per pairing: an even group plays groupSize-1 rounds, spread across the whole group-stage window.
|
|
116
|
+
const rounds = groupSize - 1;
|
|
117
|
+
const spacing = Math.floor((stageHours * 60) / rounds);
|
|
118
|
+
return {
|
|
119
|
+
groupCount,
|
|
120
|
+
groupSize,
|
|
121
|
+
botCount: groupCount * groupSize - participantCount,
|
|
122
|
+
qualifiersPerGroup,
|
|
123
|
+
finalSize: qualifiersPerGroup * groupCount,
|
|
124
|
+
groupRoundSpacingMinutes: Math.max(MIN_ROUND_SPACING_MINUTES, snapToGrid(spacing)),
|
|
125
|
+
// Judged on the UNSNAPPED spacing: snapping only ever shortens the gap, so it cannot rescue a stage that
|
|
126
|
+
// already does not fit, and judging the snapped value would hide a genuine overflow.
|
|
127
|
+
overflows: spacing < MIN_ROUND_SPACING_MINUTES
|
|
128
|
+
};
|
|
129
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { describe, it, expect } from '@jest/globals';
|
|
2
|
+
import { EVENT_CATALOGUE, MAX_FINAL_SIZE, MIN_GROUP_SIZE, MIN_ROUND_SPACING_MINUTES, REVIEW_DAYS, eventDefinitionForKey, eventStatusAt, eventWindowsFor, groupStageHours, planEventGroups } from './event-schedule';
|
|
3
|
+
import { RarityEnum } from '../player';
|
|
4
|
+
const COMMONS = eventDefinitionForKey('WAR_OF_THE_COMMONS');
|
|
5
|
+
describe('EVENT_CATALOGUE', () => {
|
|
6
|
+
it('restricts the three launch presets to a single rarity each, over one week', () => {
|
|
7
|
+
expect(EVENT_CATALOGUE.map(e => e.rarity)).toEqual([RarityEnum.COMMON, RarityEnum.RARE, RarityEnum.LEGENDARY]);
|
|
8
|
+
expect(EVENT_CATALOGUE.every(e => e.durationDays === 7)).toBe(true);
|
|
9
|
+
expect(EVENT_CATALOGUE.every(e => e.format === 'GROUPS_FINAL')).toBe(true);
|
|
10
|
+
});
|
|
11
|
+
it('leaves room for a final day in every preset', () => {
|
|
12
|
+
expect(EVENT_CATALOGUE.every(e => e.durationDays >= 2)).toBe(true);
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
describe('eventDefinitionForKey()', () => {
|
|
16
|
+
it('reads the rules off the key an event stores', () => {
|
|
17
|
+
expect(eventDefinitionForKey('STUFF_OF_LEGENDS').rarity).toBe(RarityEnum.LEGENDARY);
|
|
18
|
+
expect(eventDefinitionForKey('RARE_GEMS').rarity).toBe(RarityEnum.RARE);
|
|
19
|
+
expect(eventDefinitionForKey('WAR_OF_THE_COMMONS').rarity).toBe(RarityEnum.COMMON);
|
|
20
|
+
});
|
|
21
|
+
it('throws on a key that is not in the catalogue', () => {
|
|
22
|
+
expect(() => eventDefinitionForKey('NOT_AN_EVENT')).toThrow('UNKNOWN_EVENT_KEY');
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
describe('eventWindowsFor()', () => {
|
|
26
|
+
it('derives every date from the preset, the start day and the registration lead time', () => {
|
|
27
|
+
const w = eventWindowsFor(COMMONS, new Date('2026-08-03T00:00:00.000Z'), 4);
|
|
28
|
+
expect(w.registrationOpensAt.toISOString()).toBe('2026-07-30T00:00:00.000Z'); // start - 4 days
|
|
29
|
+
expect(w.registrationClosesAt.toISOString()).toBe('2026-08-03T00:00:00.000Z'); // = startsAt
|
|
30
|
+
expect(w.startsAt.toISOString()).toBe('2026-08-03T00:00:00.000Z');
|
|
31
|
+
expect(w.finalStartsAt.toISOString()).toBe('2026-08-09T00:00:00.000Z'); // last day of 7
|
|
32
|
+
expect(w.endsAt.toISOString()).toBe('2026-08-10T00:00:00.000Z');
|
|
33
|
+
expect(w.purgeAt.toISOString()).toBe('2026-08-13T00:00:00.000Z'); // + 3 review days
|
|
34
|
+
});
|
|
35
|
+
it('floors the start to 00:00 UTC so an event always runs whole days', () => {
|
|
36
|
+
const w = eventWindowsFor(COMMONS, new Date('2026-08-03T17:42:11.000Z'), 4);
|
|
37
|
+
expect(w.startsAt.toISOString()).toBe('2026-08-03T00:00:00.000Z');
|
|
38
|
+
});
|
|
39
|
+
it('moves registration with the lead time and nothing else', () => {
|
|
40
|
+
const a = eventWindowsFor(COMMONS, new Date('2026-08-03T00:00:00.000Z'), 1);
|
|
41
|
+
const b = eventWindowsFor(COMMONS, new Date('2026-08-03T00:00:00.000Z'), 14);
|
|
42
|
+
expect(a.registrationOpensAt.toISOString()).toBe('2026-08-02T00:00:00.000Z');
|
|
43
|
+
expect(b.registrationOpensAt.toISOString()).toBe('2026-07-20T00:00:00.000Z');
|
|
44
|
+
expect(a.startsAt.getTime()).toBe(b.startsAt.getTime());
|
|
45
|
+
expect(a.endsAt.getTime()).toBe(b.endsAt.getTime());
|
|
46
|
+
expect(a.purgeAt.getTime()).toBe(b.purgeAt.getTime());
|
|
47
|
+
});
|
|
48
|
+
it('gives the final exactly one day, whatever the duration', () => {
|
|
49
|
+
for (const durationDays of [2, 3, 7, 10]) {
|
|
50
|
+
const w = eventWindowsFor({ ...COMMONS, durationDays }, new Date('2026-08-03T00:00:00.000Z'), 4);
|
|
51
|
+
expect(w.endsAt.getTime() - w.finalStartsAt.getTime()).toBe(24 * 3600 * 1000);
|
|
52
|
+
expect(w.endsAt.getTime() - w.startsAt.getTime()).toBe(durationDays * 24 * 3600 * 1000);
|
|
53
|
+
expect(w.purgeAt.getTime() - w.endsAt.getTime()).toBe(REVIEW_DAYS * 24 * 3600 * 1000);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
describe('groupStageHours()', () => {
|
|
58
|
+
it('is every day before the final day', () => {
|
|
59
|
+
expect(groupStageHours({ ...COMMONS, durationDays: 7 })).toBe(144);
|
|
60
|
+
expect(groupStageHours({ ...COMMONS, durationDays: 2 })).toBe(24);
|
|
61
|
+
expect(groupStageHours({ ...COMMONS, durationDays: 10 })).toBe(216);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
describe('eventStatusAt()', () => {
|
|
65
|
+
const w = eventWindowsFor(COMMONS, new Date('2026-08-03T00:00:00.000Z'), 4);
|
|
66
|
+
it.each([
|
|
67
|
+
['2026-07-30T00:00:00.000Z', 'REGISTRATION'],
|
|
68
|
+
['2026-08-02T23:59:59.000Z', 'REGISTRATION'],
|
|
69
|
+
['2026-08-03T00:00:00.000Z', 'IN_PROGRESS'],
|
|
70
|
+
['2026-08-09T12:00:00.000Z', 'IN_PROGRESS'],
|
|
71
|
+
['2026-08-09T23:59:59.000Z', 'IN_PROGRESS'],
|
|
72
|
+
['2026-08-10T00:00:00.000Z', 'REVIEW'],
|
|
73
|
+
['2026-08-12T23:59:59.000Z', 'REVIEW'],
|
|
74
|
+
['2026-08-13T00:00:00.000Z', 'ARCHIVED']
|
|
75
|
+
])('reads %s as %s', (iso, status) => {
|
|
76
|
+
expect(eventStatusAt(w, new Date(iso))).toBe(status);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
describe('planEventGroups()', () => {
|
|
80
|
+
const WEEK = groupStageHours(COMMONS); // 144h, the six days before the final
|
|
81
|
+
it.each([
|
|
82
|
+
// turnout, groups, size, qualifiers/group, final. Tiers: <64 = 3 groups top 8, <96 = 4 top 6,
|
|
83
|
+
// <128 = 6 top 4, <192 = 8 top 3, otherwise 12 top 2. Every tier multiplies out to a 24-team final.
|
|
84
|
+
[1, 3, MIN_GROUP_SIZE, 8, 24],
|
|
85
|
+
[12, 3, MIN_GROUP_SIZE, 8, 24],
|
|
86
|
+
[48, 3, MIN_GROUP_SIZE, 8, 24],
|
|
87
|
+
[63, 3, 22, 8, 24],
|
|
88
|
+
[64, 4, MIN_GROUP_SIZE, 6, 24],
|
|
89
|
+
[95, 4, 24, 6, 24],
|
|
90
|
+
[96, 6, MIN_GROUP_SIZE, 4, 24],
|
|
91
|
+
[127, 6, 22, 4, 24],
|
|
92
|
+
[128, 8, MIN_GROUP_SIZE, 3, 24],
|
|
93
|
+
[191, 8, 24, 3, 24],
|
|
94
|
+
[192, 12, MIN_GROUP_SIZE, 2, 24],
|
|
95
|
+
[300, 12, 26, 2, 24]
|
|
96
|
+
])('splits a turnout of %i into %i groups of %i', (n, groupCount, groupSize, qualifiersPerGroup, finalSize) => {
|
|
97
|
+
const plan = planEventGroups(n, WEEK);
|
|
98
|
+
expect(plan.groupCount).toBe(groupCount);
|
|
99
|
+
expect(plan.groupSize).toBe(groupSize);
|
|
100
|
+
expect(plan.qualifiersPerGroup).toBe(qualifiersPerGroup);
|
|
101
|
+
expect(plan.finalSize).toBe(finalSize);
|
|
102
|
+
expect(plan.botCount).toBe(groupCount * groupSize - n);
|
|
103
|
+
});
|
|
104
|
+
it.each([1, 2, 7, 12, 33, 40, 41, 60, 61, 99, 100, 137, 250, 500])('keeps every group even, bot-padded and inside the final cap for a turnout of %i', (n) => {
|
|
105
|
+
const plan = planEventGroups(n, WEEK);
|
|
106
|
+
expect(plan.groupSize % 2).toBe(0); // even, so nobody sits a group-stage round out
|
|
107
|
+
expect(plan.groupSize).toBeGreaterThanOrEqual(MIN_GROUP_SIZE);
|
|
108
|
+
expect([3, 4, 6, 8, 12]).toContain(plan.groupCount);
|
|
109
|
+
expect(plan.botCount).toBeGreaterThanOrEqual(0);
|
|
110
|
+
expect(plan.groupCount * plan.groupSize).toBe(n + plan.botCount);
|
|
111
|
+
// Every tier is built to fill the final exactly, however many entered.
|
|
112
|
+
expect(plan.finalSize).toBe(MAX_FINAL_SIZE);
|
|
113
|
+
expect(plan.finalSize % 2).toBe(0); // an odd final would leave a team without an opponent every round
|
|
114
|
+
expect(plan.qualifiersPerGroup).toBeLessThanOrEqual(plan.groupSize);
|
|
115
|
+
expect(plan.groupRoundSpacingMinutes).toBeGreaterThanOrEqual(MIN_ROUND_SPACING_MINUTES);
|
|
116
|
+
// Rounds start on the hour or the half hour, never at 19:12.
|
|
117
|
+
expect(plan.groupRoundSpacingMinutes % 30).toBe(0);
|
|
118
|
+
});
|
|
119
|
+
it('fits the whole round robin inside the group-stage window until it genuinely cannot', () => {
|
|
120
|
+
for (const n of [1, 40, 100, 250, 700]) {
|
|
121
|
+
const plan = planEventGroups(n, WEEK);
|
|
122
|
+
expect(plan.overflows).toBe(false);
|
|
123
|
+
expect((plan.groupSize - 1) * plan.groupRoundSpacingMinutes).toBeLessThanOrEqual(WEEK * 60);
|
|
124
|
+
}
|
|
125
|
+
// 12 groups of 168 needs 167 rounds; at the one-hour minimum that is far more than six days, so the plan
|
|
126
|
+
// reports the overflow instead of silently generating a schedule that runs past the deadline.
|
|
127
|
+
expect(planEventGroups(2000, WEEK).overflows).toBe(true);
|
|
128
|
+
});
|
|
129
|
+
it('paces rounds to the window it is given, so a shorter event packs them tighter', () => {
|
|
130
|
+
const long = planEventGroups(40, WEEK);
|
|
131
|
+
const short = planEventGroups(40, groupStageHours({ ...COMMONS, durationDays: 3 })); // 48h
|
|
132
|
+
expect(short.groupSize).toBe(long.groupSize);
|
|
133
|
+
expect(short.groupRoundSpacingMinutes).toBeLessThan(long.groupRoundSpacingMinutes);
|
|
134
|
+
expect((short.groupSize - 1) * short.groupRoundSpacingMinutes).toBeLessThanOrEqual(48 * 60);
|
|
135
|
+
});
|
|
136
|
+
});
|