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
|
@@ -2,9 +2,13 @@ import dayjs from 'dayjs';
|
|
|
2
2
|
import utc from 'dayjs/plugin/utc';
|
|
3
3
|
import { v4 as uuidv4 } from 'uuid';
|
|
4
4
|
import { Match } from './match';
|
|
5
|
+
import { roundRobinRounds } from './round-robin';
|
|
5
6
|
import { shuffle } from '../utils';
|
|
6
7
|
import { StatusEnum } from '../../data';
|
|
7
8
|
dayjs.extend(utc);
|
|
9
|
+
// Consecutive matches in a league season start half an hour apart, so a 16-team double round robin (240 matches)
|
|
10
|
+
// spans five days from the iteration's Monday.
|
|
11
|
+
const MATCH_SPACING_MINUTES = 30;
|
|
8
12
|
// eslint-disable-next-line @typescript-eslint/no-extraneous-class
|
|
9
13
|
export default class MatchGenerator {
|
|
10
14
|
static generateMatches(teams, baseDate) {
|
|
@@ -16,57 +20,30 @@ export default class MatchGenerator {
|
|
|
16
20
|
if (t.size !== teams.length) {
|
|
17
21
|
throw new Error('DUPLICATE_TEAMS');
|
|
18
22
|
}
|
|
19
|
-
|
|
20
|
-
const l = shuffled.length - 1;
|
|
21
|
-
const half = Math.ceil(shuffled.length / 2);
|
|
22
|
-
const matches = [];
|
|
23
|
-
// We fix the first team
|
|
24
|
-
let firstTeam = shuffled.shift();
|
|
25
|
-
if (firstTeam == null) {
|
|
23
|
+
if (teams.length < 1) {
|
|
26
24
|
throw new Error('EMPTY_TEAM_ARRAY');
|
|
27
25
|
}
|
|
28
|
-
|
|
29
|
-
let awayRow;
|
|
30
|
-
for (let i = 0; i < l; i++) {
|
|
31
|
-
// split array in 2 halves
|
|
32
|
-
const row1 = shuffled.slice(0, half);
|
|
33
|
-
const row2 = shuffled.slice(half);
|
|
34
|
-
row2.push(firstTeam);
|
|
35
|
-
if (i % 2 === 0) {
|
|
36
|
-
homeRow = row1;
|
|
37
|
-
awayRow = row2;
|
|
38
|
-
}
|
|
39
|
-
else {
|
|
40
|
-
homeRow = row2;
|
|
41
|
-
awayRow = row1;
|
|
42
|
-
}
|
|
43
|
-
for (let j = 0; j < half; j++) {
|
|
44
|
-
matches.push(Match.create({
|
|
45
|
-
id: uuidv4(),
|
|
46
|
-
homeTeam: homeRow[j],
|
|
47
|
-
awayTeam: awayRow[j],
|
|
48
|
-
scheduledDate: date.toDate(),
|
|
49
|
-
status: StatusEnum.PENDING
|
|
50
|
-
}));
|
|
51
|
-
date = date.add(30, 'minutes');
|
|
52
|
-
}
|
|
53
|
-
firstTeam = row2.pop();
|
|
54
|
-
row1.unshift(row2.shift());
|
|
55
|
-
row2.push(row1.pop());
|
|
56
|
-
shuffled = [...row1, ...row2];
|
|
57
|
-
}
|
|
58
|
-
// Generate Inverted matches for home-away pairings
|
|
59
|
-
matches.push(...matches.map(match => {
|
|
26
|
+
const nextDate = () => {
|
|
60
27
|
const scheduledDate = date.toDate();
|
|
61
|
-
date = date.add(
|
|
62
|
-
return
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
28
|
+
date = date.add(MATCH_SPACING_MINUTES, 'minutes');
|
|
29
|
+
return scheduledDate;
|
|
30
|
+
};
|
|
31
|
+
// First leg: one pass of the round robin, matches laid out back to back in round order.
|
|
32
|
+
const firstLeg = roundRobinRounds(shuffle([...teams])).flat().map(([homeTeam, awayTeam]) => Match.create({
|
|
33
|
+
id: uuidv4(),
|
|
34
|
+
homeTeam,
|
|
35
|
+
awayTeam,
|
|
36
|
+
scheduledDate: nextDate(),
|
|
37
|
+
status: StatusEnum.PENDING
|
|
38
|
+
}));
|
|
39
|
+
// Second leg: the same fixtures with the sides swapped, so every pairing is played home and away.
|
|
40
|
+
const secondLeg = firstLeg.map(match => Match.create({
|
|
41
|
+
id: uuidv4(),
|
|
42
|
+
homeTeam: match.awayTeam,
|
|
43
|
+
awayTeam: match.homeTeam,
|
|
44
|
+
scheduledDate: nextDate(),
|
|
45
|
+
status: StatusEnum.PENDING
|
|
69
46
|
}));
|
|
70
|
-
return
|
|
47
|
+
return [...firstLeg, ...secondLeg];
|
|
71
48
|
}
|
|
72
49
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, it, expect } from '@jest/globals';
|
|
2
|
+
import MatchGenerator from './match-generator';
|
|
3
|
+
import { makeTeam } from '../test-helpers';
|
|
4
|
+
// The league season schedule. These assertions pin the behaviour the rollover depends on, so the shared
|
|
5
|
+
// roundRobinRounds helper (used by events too) cannot silently change how a season is laid out.
|
|
6
|
+
const MONDAY = new Date('2026-07-20T00:00:00.000Z');
|
|
7
|
+
function orderedKey(match) {
|
|
8
|
+
return `${match.homeTeam.id}>${match.awayTeam.id}`;
|
|
9
|
+
}
|
|
10
|
+
describe('MatchGenerator.generateMatches()', () => {
|
|
11
|
+
it('plays a full double round robin for a 16-team division', () => {
|
|
12
|
+
const teams = Array.from({ length: 16 }, () => makeTeam());
|
|
13
|
+
const matches = MatchGenerator.generateMatches(teams, MONDAY);
|
|
14
|
+
// 16 teams x 15 opponents, home and away.
|
|
15
|
+
expect(matches).toHaveLength(240);
|
|
16
|
+
const seen = new Set(matches.map(orderedKey));
|
|
17
|
+
expect(seen.size).toBe(240);
|
|
18
|
+
// Every ordered pairing exists exactly once, so every team hosts and visits every other team.
|
|
19
|
+
for (const home of teams) {
|
|
20
|
+
for (const away of teams) {
|
|
21
|
+
if (home.id === away.id)
|
|
22
|
+
continue;
|
|
23
|
+
expect(seen.has(`${home.id}>${away.id}`)).toBe(true);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
it('anchors to 00:00 UTC of the base day and spaces matches 30 minutes apart', () => {
|
|
28
|
+
const teams = Array.from({ length: 16 }, () => makeTeam());
|
|
29
|
+
const matches = MatchGenerator.generateMatches(teams, new Date('2026-07-20T17:42:11.000Z'));
|
|
30
|
+
const times = matches.map(m => m.scheduledDate.getTime()).sort((a, b) => a - b);
|
|
31
|
+
expect(new Date(times[0]).toISOString()).toBe('2026-07-20T00:00:00.000Z');
|
|
32
|
+
for (let i = 1; i < times.length; i++) {
|
|
33
|
+
expect(times[i] - times[i - 1]).toBe(30 * 60 * 1000);
|
|
34
|
+
}
|
|
35
|
+
// 240 matches at half-hour spacing = five days, ending Friday 23:30 UTC.
|
|
36
|
+
expect(new Date(times[times.length - 1]).toISOString()).toBe('2026-07-24T23:30:00.000Z');
|
|
37
|
+
});
|
|
38
|
+
it('rejects a duplicated team and an empty division', () => {
|
|
39
|
+
const team = makeTeam();
|
|
40
|
+
expect(() => MatchGenerator.generateMatches([team, team], MONDAY)).toThrow('DUPLICATE_TEAMS');
|
|
41
|
+
expect(() => MatchGenerator.generateMatches([], MONDAY)).toThrow('EMPTY_TEAM_ARRAY');
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Circle-method single round robin. Returns one entry per round, each holding the pairings that round plays,
|
|
3
|
+
* as `[home, away]` tuples. Every unordered pair of entrants appears exactly once across all rounds.
|
|
4
|
+
*
|
|
5
|
+
* An ODD entrant count produces a bye: one entrant sits out each round, so a round has `floor(n / 2)` pairings
|
|
6
|
+
* instead of `n / 2`. Callers that need everyone playing every round must pad to an even count first.
|
|
7
|
+
*
|
|
8
|
+
* Home and away alternate by round and by court index, so no entrant ends up at home in all of its games. For a
|
|
9
|
+
* double round robin the caller replays the same rounds with the sides swapped, which balances them exactly.
|
|
10
|
+
*/
|
|
11
|
+
export declare function roundRobinRounds<T>(entrants: T[]): Array<Array<[T, T]>>;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// A bye slot, used to keep the circle even when an odd number of entrants is passed. Pairings that involve it
|
|
2
|
+
// are dropped, so that entrant sits the round out. Module-level so the type is nameable inside the function.
|
|
3
|
+
const BYE = Symbol('ROUND_ROBIN_BYE');
|
|
4
|
+
/**
|
|
5
|
+
* Circle-method single round robin. Returns one entry per round, each holding the pairings that round plays,
|
|
6
|
+
* as `[home, away]` tuples. Every unordered pair of entrants appears exactly once across all rounds.
|
|
7
|
+
*
|
|
8
|
+
* An ODD entrant count produces a bye: one entrant sits out each round, so a round has `floor(n / 2)` pairings
|
|
9
|
+
* instead of `n / 2`. Callers that need everyone playing every round must pad to an even count first.
|
|
10
|
+
*
|
|
11
|
+
* Home and away alternate by round and by court index, so no entrant ends up at home in all of its games. For a
|
|
12
|
+
* double round robin the caller replays the same rounds with the sides swapped, which balances them exactly.
|
|
13
|
+
*/
|
|
14
|
+
export function roundRobinRounds(entrants) {
|
|
15
|
+
if (entrants.length < 2)
|
|
16
|
+
return [];
|
|
17
|
+
const wheel = entrants.length % 2 === 0 ? [...entrants] : [...entrants, BYE];
|
|
18
|
+
const size = wheel.length;
|
|
19
|
+
const half = size / 2;
|
|
20
|
+
const rounds = [];
|
|
21
|
+
// Index 0 stays put while the rest rotate one place per round; pairing i against size-1-i then yields every
|
|
22
|
+
// pair exactly once over size-1 rounds.
|
|
23
|
+
let circle = wheel;
|
|
24
|
+
for (let round = 0; round < size - 1; round++) {
|
|
25
|
+
const pairings = [];
|
|
26
|
+
for (let court = 0; court < half; court++) {
|
|
27
|
+
const a = circle[court];
|
|
28
|
+
const b = circle[size - 1 - court];
|
|
29
|
+
if (a === BYE || b === BYE)
|
|
30
|
+
continue;
|
|
31
|
+
// Flip the whole column every other round. A rotating entrant advances one place per round, so it spends
|
|
32
|
+
// consecutive rounds on the same side of the circle and this parity alternates its home/away within them.
|
|
33
|
+
// Folding the court index into the parity instead would keep it CONSTANT for that entrant (its index and
|
|
34
|
+
// the round number rise together), pinning some entrants to one side for the entire event.
|
|
35
|
+
pairings.push(round % 2 === 0 ? [a, b] : [b, a]);
|
|
36
|
+
}
|
|
37
|
+
rounds.push(pairings);
|
|
38
|
+
circle = [circle[0], circle[size - 1], ...circle.slice(1, size - 1)];
|
|
39
|
+
}
|
|
40
|
+
return rounds;
|
|
41
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { roundRobinRounds } from './round-robin';
|
|
2
|
+
function key(a, b) {
|
|
3
|
+
return a < b ? `${a}:${b}` : `${b}:${a}`;
|
|
4
|
+
}
|
|
5
|
+
function entrants(n) {
|
|
6
|
+
return Array.from({ length: n }, (_, i) => `t${i}`);
|
|
7
|
+
}
|
|
8
|
+
describe('roundRobinRounds()', () => {
|
|
9
|
+
it('returns nothing for fewer than two entrants', () => {
|
|
10
|
+
expect(roundRobinRounds([])).toEqual([]);
|
|
11
|
+
expect(roundRobinRounds(['solo'])).toEqual([]);
|
|
12
|
+
});
|
|
13
|
+
it.each([2, 4, 6, 8, 16, 20, 50])('pairs every even field of %i exactly once', (n) => {
|
|
14
|
+
const rounds = roundRobinRounds(entrants(n));
|
|
15
|
+
expect(rounds).toHaveLength(n - 1);
|
|
16
|
+
rounds.forEach(round => expect(round).toHaveLength(n / 2));
|
|
17
|
+
const seen = new Set();
|
|
18
|
+
for (const round of rounds) {
|
|
19
|
+
// Nobody plays twice in the same round.
|
|
20
|
+
const playing = new Set();
|
|
21
|
+
for (const [home, away] of round) {
|
|
22
|
+
expect(home).not.toEqual(away);
|
|
23
|
+
expect(playing.has(home)).toBe(false);
|
|
24
|
+
expect(playing.has(away)).toBe(false);
|
|
25
|
+
playing.add(home);
|
|
26
|
+
playing.add(away);
|
|
27
|
+
const k = key(home, away);
|
|
28
|
+
expect(seen.has(k)).toBe(false);
|
|
29
|
+
seen.add(k);
|
|
30
|
+
}
|
|
31
|
+
expect(playing.size).toBe(n);
|
|
32
|
+
}
|
|
33
|
+
expect(seen.size).toBe((n * (n - 1)) / 2);
|
|
34
|
+
});
|
|
35
|
+
it.each([3, 5, 7, 15])('gives an odd field of %i one bye per round and still pairs everyone once', (n) => {
|
|
36
|
+
const rounds = roundRobinRounds(entrants(n));
|
|
37
|
+
// An odd field runs the even-sized circle it was padded into: n rounds, one entrant idle in each.
|
|
38
|
+
expect(rounds).toHaveLength(n);
|
|
39
|
+
rounds.forEach(round => expect(round).toHaveLength((n - 1) / 2));
|
|
40
|
+
const seen = new Set();
|
|
41
|
+
const byes = new Map();
|
|
42
|
+
for (const round of rounds) {
|
|
43
|
+
const playing = new Set();
|
|
44
|
+
for (const [home, away] of round) {
|
|
45
|
+
seen.add(key(home, away));
|
|
46
|
+
playing.add(home);
|
|
47
|
+
playing.add(away);
|
|
48
|
+
}
|
|
49
|
+
const idle = entrants(n).filter(t => !playing.has(t));
|
|
50
|
+
expect(idle).toHaveLength(1);
|
|
51
|
+
byes.set(idle[0], (byes.get(idle[0]) ?? 0) + 1);
|
|
52
|
+
}
|
|
53
|
+
expect(seen.size).toBe((n * (n - 1)) / 2);
|
|
54
|
+
// Exactly one bye each: nobody sits out twice while another entrant never rests.
|
|
55
|
+
expect([...byes.values()]).toEqual(Array.from({ length: n }, () => 1));
|
|
56
|
+
});
|
|
57
|
+
it.each([8, 16, 20, 50])('splits home and away near evenly across a field of %i', (n) => {
|
|
58
|
+
const homeCounts = new Map();
|
|
59
|
+
for (const round of roundRobinRounds(entrants(n))) {
|
|
60
|
+
for (const [home] of round)
|
|
61
|
+
homeCounts.set(home, (homeCounts.get(home) ?? 0) + 1);
|
|
62
|
+
}
|
|
63
|
+
// n-1 games each, so half of them is the ideal. Folding the court index into the side parity used to pin
|
|
64
|
+
// some entrants to a single side for the whole event; this guards that regression.
|
|
65
|
+
for (const t of entrants(n)) {
|
|
66
|
+
expect(Math.abs((homeCounts.get(t) ?? 0) - (n - 1) / 2)).toBeLessThanOrEqual(2);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -37,5 +37,26 @@
|
|
|
37
37
|
"generic": {
|
|
38
38
|
"title": "World Aces",
|
|
39
39
|
"body": "You have a new notification."
|
|
40
|
+
},
|
|
41
|
+
"eventRegistrationOpen": {
|
|
42
|
+
"title": "Event registration open",
|
|
43
|
+
"body": "A new event is open. Draft your squad from the packs before registration closes."
|
|
44
|
+
},
|
|
45
|
+
"eventRegistrationClosing": {
|
|
46
|
+
"title": "Registration closes soon",
|
|
47
|
+
"body": "Registration for the event closes in an hour. Draft your squad to take part."
|
|
48
|
+
},
|
|
49
|
+
"eventStarted": {
|
|
50
|
+
"title": "Event started",
|
|
51
|
+
"body": "Your event matches have begun. Good luck!"
|
|
52
|
+
},
|
|
53
|
+
"eventFinalRound": {
|
|
54
|
+
"title": "You reached the final",
|
|
55
|
+
"body": "You qualified for the final round. It starts now."
|
|
56
|
+
},
|
|
57
|
+
"eventFinished": {
|
|
58
|
+
"title": "Event finished",
|
|
59
|
+
"body": "The event is over. See the final table and your rewards.",
|
|
60
|
+
"placed": "The event is over. You finished {{position}} of {{entrants}}."
|
|
40
61
|
}
|
|
41
62
|
}
|
|
@@ -37,5 +37,26 @@
|
|
|
37
37
|
"generic": {
|
|
38
38
|
"title": "World Aces",
|
|
39
39
|
"body": "Tienes una nueva notificación."
|
|
40
|
+
},
|
|
41
|
+
"eventRegistrationOpen": {
|
|
42
|
+
"title": "Inscripciones abiertas",
|
|
43
|
+
"body": "Hay un nuevo evento abierto. Arma tu plantilla con los sobres antes de que cierren las inscripciones."
|
|
44
|
+
},
|
|
45
|
+
"eventRegistrationClosing": {
|
|
46
|
+
"title": "Las inscripciones cierran pronto",
|
|
47
|
+
"body": "Las inscripciones del evento cierran en una hora. Arma tu plantilla para participar."
|
|
48
|
+
},
|
|
49
|
+
"eventStarted": {
|
|
50
|
+
"title": "El evento ha empezado",
|
|
51
|
+
"body": "Tus partidos del evento han comenzado. ¡Suerte!"
|
|
52
|
+
},
|
|
53
|
+
"eventFinalRound": {
|
|
54
|
+
"title": "Has llegado a la final",
|
|
55
|
+
"body": "Te has clasificado para la ronda final. Empieza ahora."
|
|
56
|
+
},
|
|
57
|
+
"eventFinished": {
|
|
58
|
+
"title": "Evento terminado",
|
|
59
|
+
"body": "El evento ha terminado. Mira la tabla final y tus recompensas.",
|
|
60
|
+
"placed": "El evento ha terminado. Has quedado {{position}} de {{entrants}}."
|
|
40
61
|
}
|
|
41
62
|
}
|
|
@@ -37,5 +37,26 @@
|
|
|
37
37
|
"generic": {
|
|
38
38
|
"title": "World Aces",
|
|
39
39
|
"body": "Vous avez une nouvelle notification."
|
|
40
|
+
},
|
|
41
|
+
"eventRegistrationOpen": {
|
|
42
|
+
"title": "Inscriptions ouvertes",
|
|
43
|
+
"body": "Un nouvel événement est ouvert. Compose ton effectif avec les packs avant la clôture des inscriptions."
|
|
44
|
+
},
|
|
45
|
+
"eventRegistrationClosing": {
|
|
46
|
+
"title": "Les inscriptions ferment bientôt",
|
|
47
|
+
"body": "Les inscriptions à l'événement ferment dans une heure. Compose ton effectif pour participer."
|
|
48
|
+
},
|
|
49
|
+
"eventStarted": {
|
|
50
|
+
"title": "L'événement a commencé",
|
|
51
|
+
"body": "Tes matchs ont commencé. Bonne chance !"
|
|
52
|
+
},
|
|
53
|
+
"eventFinalRound": {
|
|
54
|
+
"title": "Tu es en finale",
|
|
55
|
+
"body": "Tu es qualifié pour la finale. Elle commence maintenant."
|
|
56
|
+
},
|
|
57
|
+
"eventFinished": {
|
|
58
|
+
"title": "Événement terminé",
|
|
59
|
+
"body": "L'événement est terminé. Consulte le classement final et tes récompenses.",
|
|
60
|
+
"placed": "L'événement est terminé. Tu finis {{position}} sur {{entrants}}."
|
|
40
61
|
}
|
|
41
62
|
}
|
|
@@ -37,5 +37,26 @@
|
|
|
37
37
|
"generic": {
|
|
38
38
|
"title": "World Aces",
|
|
39
39
|
"body": "Hai una nuova notifica."
|
|
40
|
+
},
|
|
41
|
+
"eventRegistrationOpen": {
|
|
42
|
+
"title": "Iscrizioni aperte",
|
|
43
|
+
"body": "Un nuovo evento è aperto. Costruisci la rosa dai pacchetti prima che chiudano le iscrizioni."
|
|
44
|
+
},
|
|
45
|
+
"eventRegistrationClosing": {
|
|
46
|
+
"title": "Le iscrizioni stanno per chiudere",
|
|
47
|
+
"body": "Le iscrizioni all'evento chiudono tra un'ora. Costruisci la rosa per partecipare."
|
|
48
|
+
},
|
|
49
|
+
"eventStarted": {
|
|
50
|
+
"title": "L'evento è iniziato",
|
|
51
|
+
"body": "Le tue partite sono iniziate. In bocca al lupo!"
|
|
52
|
+
},
|
|
53
|
+
"eventFinalRound": {
|
|
54
|
+
"title": "Sei in finale",
|
|
55
|
+
"body": "Ti sei qualificato per la finale. Inizia ora."
|
|
56
|
+
},
|
|
57
|
+
"eventFinished": {
|
|
58
|
+
"title": "Evento concluso",
|
|
59
|
+
"body": "L'evento è finito. Guarda la classifica finale e i tuoi premi.",
|
|
60
|
+
"placed": "L'evento è finito. Hai chiuso {{position}} su {{entrants}}."
|
|
40
61
|
}
|
|
41
62
|
}
|
|
@@ -37,5 +37,26 @@
|
|
|
37
37
|
"generic": {
|
|
38
38
|
"title": "World Aces",
|
|
39
39
|
"body": "Masz nowe powiadomienie."
|
|
40
|
+
},
|
|
41
|
+
"eventRegistrationOpen": {
|
|
42
|
+
"title": "Zapisy otwarte",
|
|
43
|
+
"body": "Nowe wydarzenie jest otwarte. Zbuduj skład z paczek, zanim zamkną się zapisy."
|
|
44
|
+
},
|
|
45
|
+
"eventRegistrationClosing": {
|
|
46
|
+
"title": "Zapisy wkrótce się zamkną",
|
|
47
|
+
"body": "Zapisy na wydarzenie zamykają się za godzinę. Zbuduj skład, aby wziąć udział."
|
|
48
|
+
},
|
|
49
|
+
"eventStarted": {
|
|
50
|
+
"title": "Wydarzenie ruszyło",
|
|
51
|
+
"body": "Twoje mecze się rozpoczęły. Powodzenia!"
|
|
52
|
+
},
|
|
53
|
+
"eventFinalRound": {
|
|
54
|
+
"title": "Jesteś w finale",
|
|
55
|
+
"body": "Awansowałeś do rundy finałowej. Zaczyna się teraz."
|
|
56
|
+
},
|
|
57
|
+
"eventFinished": {
|
|
58
|
+
"title": "Wydarzenie zakończone",
|
|
59
|
+
"body": "Wydarzenie się skończyło. Zobacz tabelę końcową i nagrody.",
|
|
60
|
+
"placed": "Wydarzenie się skończyło. Zająłeś {{position}} miejsce na {{entrants}}."
|
|
40
61
|
}
|
|
41
62
|
}
|
|
@@ -37,5 +37,26 @@
|
|
|
37
37
|
"generic": {
|
|
38
38
|
"title": "World Aces",
|
|
39
39
|
"body": "Você tem uma nova notificação."
|
|
40
|
+
},
|
|
41
|
+
"eventRegistrationOpen": {
|
|
42
|
+
"title": "Inscrições abertas",
|
|
43
|
+
"body": "Um novo evento está aberto. Monte seu elenco com os pacotes antes das inscrições fecharem."
|
|
44
|
+
},
|
|
45
|
+
"eventRegistrationClosing": {
|
|
46
|
+
"title": "As inscrições fecham em breve",
|
|
47
|
+
"body": "As inscrições do evento fecham em uma hora. Monte seu elenco para participar."
|
|
48
|
+
},
|
|
49
|
+
"eventStarted": {
|
|
50
|
+
"title": "O evento começou",
|
|
51
|
+
"body": "Suas partidas do evento começaram. Boa sorte!"
|
|
52
|
+
},
|
|
53
|
+
"eventFinalRound": {
|
|
54
|
+
"title": "Você chegou à final",
|
|
55
|
+
"body": "Você se classificou para a rodada final. Começa agora."
|
|
56
|
+
},
|
|
57
|
+
"eventFinished": {
|
|
58
|
+
"title": "Evento encerrado",
|
|
59
|
+
"body": "O evento acabou. Veja a tabela final e suas recompensas.",
|
|
60
|
+
"placed": "O evento acabou. Você terminou em {{position}} de {{entrants}}."
|
|
40
61
|
}
|
|
41
62
|
}
|
|
@@ -37,5 +37,26 @@
|
|
|
37
37
|
"generic": {
|
|
38
38
|
"title": "World Aces",
|
|
39
39
|
"body": "Yeni bir bildirimin var."
|
|
40
|
+
},
|
|
41
|
+
"eventRegistrationOpen": {
|
|
42
|
+
"title": "Kayıtlar açıldı",
|
|
43
|
+
"body": "Yeni bir etkinlik açıldı. Kayıtlar kapanmadan paketlerden kadronu kur."
|
|
44
|
+
},
|
|
45
|
+
"eventRegistrationClosing": {
|
|
46
|
+
"title": "Kayıtlar birazdan kapanıyor",
|
|
47
|
+
"body": "Etkinlik kayıtları bir saat içinde kapanıyor. Katılmak için kadronu kur."
|
|
48
|
+
},
|
|
49
|
+
"eventStarted": {
|
|
50
|
+
"title": "Etkinlik başladı",
|
|
51
|
+
"body": "Etkinlik maçların başladı. Bol şans!"
|
|
52
|
+
},
|
|
53
|
+
"eventFinalRound": {
|
|
54
|
+
"title": "Finale kaldın",
|
|
55
|
+
"body": "Final turuna kaldın. Şimdi başlıyor."
|
|
56
|
+
},
|
|
57
|
+
"eventFinished": {
|
|
58
|
+
"title": "Etkinlik bitti",
|
|
59
|
+
"body": "Etkinlik sona erdi. Final tablosuna ve ödüllerine bak.",
|
|
60
|
+
"placed": "Etkinlik sona erdi. {{entrants}} takım arasında {{position}}. oldun."
|
|
40
61
|
}
|
|
41
62
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export declare const NOTIFICATION_TYPES: readonly ["MATCH_RESULT", "INJURY", "INJURY_RECOVERED", "SEASON_END", "GRANT_RECEIVED", "PROMOTION", "DAILY_LOGIN", "WELCOME", "COMPENSATION"];
|
|
1
|
+
export declare const NOTIFICATION_TYPES: readonly ["MATCH_RESULT", "INJURY", "INJURY_RECOVERED", "SEASON_END", "GRANT_RECEIVED", "PROMOTION", "DAILY_LOGIN", "WELCOME", "COMPENSATION", "EVENT_REGISTRATION_OPEN", "EVENT_REGISTRATION_CLOSING", "EVENT_STARTED", "EVENT_FINAL_ROUND", "EVENT_FINISHED"];
|
|
2
2
|
export type NotificationType = typeof NOTIFICATION_TYPES[number];
|
|
3
3
|
export declare const NOTIFICATION_LOCALES: readonly ["en", "es", "fr", "it", "pl", "pt-BR", "tr"];
|
|
4
4
|
export type NotificationLocale = typeof NOTIFICATION_LOCALES[number];
|
|
@@ -11,7 +11,12 @@ export const NOTIFICATION_TYPES = [
|
|
|
11
11
|
'PROMOTION',
|
|
12
12
|
'DAILY_LOGIN',
|
|
13
13
|
'WELCOME',
|
|
14
|
-
'COMPENSATION'
|
|
14
|
+
'COMPENSATION',
|
|
15
|
+
'EVENT_REGISTRATION_OPEN',
|
|
16
|
+
'EVENT_REGISTRATION_CLOSING',
|
|
17
|
+
'EVENT_STARTED',
|
|
18
|
+
'EVENT_FINAL_ROUND',
|
|
19
|
+
'EVENT_FINISHED'
|
|
15
20
|
];
|
|
16
21
|
// The 7 supported locales. Must stay in sync with the API VALID_LOCALES set and the UI src/locales/*.json.
|
|
17
22
|
export const NOTIFICATION_LOCALES = ['en', 'es', 'fr', 'it', 'pl', 'pt-BR', 'tr'];
|
|
@@ -130,7 +130,9 @@ describe('push preferences (PUSH_NOTIFICATION_TYPES + pushEnabledFor)', () => {
|
|
|
130
130
|
.toEqual(NOTIFICATION_TYPES.filter((t) => NOTIFICATION_REGISTRY[t].emitsPush).sort((a, b) => a.localeCompare(b)));
|
|
131
131
|
expect(PUSH_NOTIFICATION_TYPES).not.toContain('COMPENSATION');
|
|
132
132
|
expect(PUSH_NOTIFICATION_TYPES).toContain('MATCH_RESULT');
|
|
133
|
-
|
|
133
|
+
// COMPENSATION is the ONLY in-app-only type, so every other type pushes. Expressed as a relationship
|
|
134
|
+
// rather than a count, so adding a type does not need this number edited.
|
|
135
|
+
expect(PUSH_NOTIFICATION_TYPES).toHaveLength(NOTIFICATION_TYPES.length - 1);
|
|
134
136
|
});
|
|
135
137
|
it('is opt-out: absent / null / empty / true all enable, only explicit false suppresses', () => {
|
|
136
138
|
expect(pushEnabledFor(undefined, 'MATCH_RESULT')).toBe(true);
|
|
@@ -85,6 +85,58 @@ export const NOTIFICATION_REGISTRY = {
|
|
|
85
85
|
inAppMessageKey: 'promotion.body',
|
|
86
86
|
catalogKeys: ['promotion.title', 'promotion.body']
|
|
87
87
|
},
|
|
88
|
+
// In-game events. All five deep-link to the Events section, which is where every phase of an event lives.
|
|
89
|
+
// eventName is the event's key; the UI resolves it to the translated name, so the catalog copy interpolates
|
|
90
|
+
// a key rather than a localised string the sender would have to translate.
|
|
91
|
+
EVENT_REGISTRATION_OPEN: {
|
|
92
|
+
emitsPush: true,
|
|
93
|
+
deepLink: () => '/events',
|
|
94
|
+
pushTitleKey: 'eventRegistrationOpen.title',
|
|
95
|
+
pushBodyKey: 'eventRegistrationOpen.body',
|
|
96
|
+
inAppMessageKey: 'eventRegistrationOpen.body',
|
|
97
|
+
catalogKeys: ['eventRegistrationOpen.title', 'eventRegistrationOpen.body']
|
|
98
|
+
},
|
|
99
|
+
EVENT_REGISTRATION_CLOSING: {
|
|
100
|
+
emitsPush: true,
|
|
101
|
+
deepLink: () => '/events',
|
|
102
|
+
pushTitleKey: 'eventRegistrationClosing.title',
|
|
103
|
+
pushBodyKey: 'eventRegistrationClosing.body',
|
|
104
|
+
inAppMessageKey: 'eventRegistrationClosing.body',
|
|
105
|
+
catalogKeys: ['eventRegistrationClosing.title', 'eventRegistrationClosing.body']
|
|
106
|
+
},
|
|
107
|
+
EVENT_STARTED: {
|
|
108
|
+
emitsPush: true,
|
|
109
|
+
deepLink: () => '/events',
|
|
110
|
+
pushTitleKey: 'eventStarted.title',
|
|
111
|
+
pushBodyKey: 'eventStarted.body',
|
|
112
|
+
inAppMessageKey: 'eventStarted.body',
|
|
113
|
+
catalogKeys: ['eventStarted.title', 'eventStarted.body']
|
|
114
|
+
},
|
|
115
|
+
EVENT_FINAL_ROUND: {
|
|
116
|
+
emitsPush: true,
|
|
117
|
+
deepLink: () => '/events',
|
|
118
|
+
pushTitleKey: 'eventFinalRound.title',
|
|
119
|
+
pushBodyKey: 'eventFinalRound.body',
|
|
120
|
+
inAppMessageKey: 'eventFinalRound.body',
|
|
121
|
+
catalogKeys: ['eventFinalRound.title', 'eventFinalRound.body']
|
|
122
|
+
},
|
|
123
|
+
EVENT_FINISHED: {
|
|
124
|
+
emitsPush: true,
|
|
125
|
+
deepLink: () => '/events',
|
|
126
|
+
pushTitleKey: 'eventFinished.title',
|
|
127
|
+
// Copy depends on whether we know where they finished; a missing position never renders "You finished th".
|
|
128
|
+
pushBodyKey: (p) => readString(p.position) != null || typeof p.position === 'number'
|
|
129
|
+
? 'eventFinished.placed'
|
|
130
|
+
: 'eventFinished.body',
|
|
131
|
+
inAppMessageKey: (p) => readString(p.position) != null || typeof p.position === 'number'
|
|
132
|
+
? 'eventFinished.placed'
|
|
133
|
+
: 'eventFinished.body',
|
|
134
|
+
params: (p) => ({
|
|
135
|
+
position: typeof p.position === 'number' ? String(p.position) : (readString(p.position) ?? ''),
|
|
136
|
+
entrants: typeof p.entrants === 'number' ? String(p.entrants) : (readString(p.entrants) ?? '')
|
|
137
|
+
}),
|
|
138
|
+
catalogKeys: ['eventFinished.title', 'eventFinished.body', 'eventFinished.placed']
|
|
139
|
+
},
|
|
88
140
|
DAILY_LOGIN: {
|
|
89
141
|
emitsPush: true,
|
|
90
142
|
// Fires when the reward becomes claimable (00:00 UTC), so it opens the daily-login claim view.
|
|
@@ -35,6 +35,7 @@ export interface GachaPullConfig {
|
|
|
35
35
|
}
|
|
36
36
|
export declare const NORMAL_PULL_CONFIG: GachaPullConfig;
|
|
37
37
|
export declare const QUALIFIER_PULL_CONFIG: GachaPullConfig;
|
|
38
|
+
export declare const EVENT_PULL_CONFIG: GachaPullConfig;
|
|
38
39
|
export declare const TOURNAMENT_PULL_CONFIG: GachaPullConfig;
|
|
39
40
|
export declare function scoutPullConfig(level: string): GachaPullConfig;
|
|
40
41
|
export declare function rollRarity(pity: PityState, config?: GachaPullConfig): RollResult;
|
|
@@ -63,6 +63,21 @@ export const QUALIFIER_PULL_CONFIG = {
|
|
|
63
63
|
accumulateMythicPity: true,
|
|
64
64
|
accumulateSpecialPity: true
|
|
65
65
|
};
|
|
66
|
+
// Event Pack (x5): the NATIONAL pool and structure, with TRIPLE the legendary and mythic chance. Special is
|
|
67
|
+
// untouched and rare absorbs the difference, so it still sums to 1. Builds all three pities, like NATIONAL.
|
|
68
|
+
// Owner-defined 2026-07-23.
|
|
69
|
+
export const EVENT_PULL_CONFIG = {
|
|
70
|
+
special: 0.003,
|
|
71
|
+
mythic: 0.03,
|
|
72
|
+
legendary: 0.30,
|
|
73
|
+
rare: 0.667,
|
|
74
|
+
applyLegendaryPity: true,
|
|
75
|
+
applyMythicPity: true,
|
|
76
|
+
applySpecialPity: true,
|
|
77
|
+
accumulateLegendaryPity: true,
|
|
78
|
+
accumulateMythicPity: true,
|
|
79
|
+
accumulateSpecialPity: true
|
|
80
|
+
};
|
|
66
81
|
// Tournament gacha (x5 per match): LEGENDARY–SPECIAL, no legendary pity (floor is legendary)
|
|
67
82
|
export const TOURNAMENT_PULL_CONFIG = {
|
|
68
83
|
special: 0.01,
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { LineupFunction } from './lineup-function';
|
|
2
|
+
export declare const NO_BLOCK: "NO_BLOCK";
|
|
3
|
+
export type BlockingAssignment = LineupFunction | typeof NO_BLOCK;
|
|
2
4
|
export interface BlockingPreference {
|
|
3
5
|
readonly rotation: number;
|
|
4
|
-
readonly targets: Record<string,
|
|
6
|
+
readonly targets: Record<string, BlockingAssignment>;
|
|
5
7
|
}
|
|
@@ -1 +1,6 @@
|
|
|
1
|
-
|
|
1
|
+
// A front-row player opted OUT of blocking for this rotation. They never join the wall under any circumstance:
|
|
2
|
+
// not as the lead blocker (the lane's next-nearest front-row player leads instead), not as a side blocker, and
|
|
3
|
+
// no trait can coerce them in -- MOVING_WALL's guaranteed lead hand, its beaten-read hand and its side-joiner
|
|
4
|
+
// roll are all suppressed, as is a dedicated-blocking commit. Sits in the same `targets` map as a commit
|
|
5
|
+
// assignment, so the two are mutually exclusive by construction: a player is reading, committed, or out.
|
|
6
|
+
export const NO_BLOCK = 'NO_BLOCK';
|
|
@@ -281,11 +281,11 @@ export declare const TacticsInputSchema: z.ZodObject<{
|
|
|
281
281
|
}, z.core.$strip>>>>;
|
|
282
282
|
blockingPreferences: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
283
283
|
rotation: z.ZodNumber;
|
|
284
|
-
targets: z.ZodRecord<z.ZodString, z.ZodEnum<typeof RoleEnum> & z.ZodType<RoleEnum.SETTER | RoleEnum.OUTSIDE_HITTER | RoleEnum.OPPOSITE_HITTER | RoleEnum.MIDDLE_BLOCKER, RoleEnum, z.core.$ZodTypeInternals<RoleEnum.SETTER | RoleEnum.OUTSIDE_HITTER | RoleEnum.OPPOSITE_HITTER | RoleEnum.MIDDLE_BLOCKER, RoleEnum
|
|
284
|
+
targets: z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodEnum<typeof RoleEnum> & z.ZodType<RoleEnum.SETTER | RoleEnum.OUTSIDE_HITTER | RoleEnum.OPPOSITE_HITTER | RoleEnum.MIDDLE_BLOCKER, RoleEnum, z.core.$ZodTypeInternals<RoleEnum.SETTER | RoleEnum.OUTSIDE_HITTER | RoleEnum.OPPOSITE_HITTER | RoleEnum.MIDDLE_BLOCKER, RoleEnum>>, z.ZodLiteral<"NO_BLOCK">]>>;
|
|
285
285
|
}, z.core.$strip>>>;
|
|
286
286
|
blockingPreferenceSets: z.ZodOptional<z.ZodArray<z.ZodArray<z.ZodObject<{
|
|
287
287
|
rotation: z.ZodNumber;
|
|
288
|
-
targets: z.ZodRecord<z.ZodString, z.ZodEnum<typeof RoleEnum> & z.ZodType<RoleEnum.SETTER | RoleEnum.OUTSIDE_HITTER | RoleEnum.OPPOSITE_HITTER | RoleEnum.MIDDLE_BLOCKER, RoleEnum, z.core.$ZodTypeInternals<RoleEnum.SETTER | RoleEnum.OUTSIDE_HITTER | RoleEnum.OPPOSITE_HITTER | RoleEnum.MIDDLE_BLOCKER, RoleEnum
|
|
288
|
+
targets: z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodEnum<typeof RoleEnum> & z.ZodType<RoleEnum.SETTER | RoleEnum.OUTSIDE_HITTER | RoleEnum.OPPOSITE_HITTER | RoleEnum.MIDDLE_BLOCKER, RoleEnum, z.core.$ZodTypeInternals<RoleEnum.SETTER | RoleEnum.OUTSIDE_HITTER | RoleEnum.OPPOSITE_HITTER | RoleEnum.MIDDLE_BLOCKER, RoleEnum>>, z.ZodLiteral<"NO_BLOCK">]>>;
|
|
289
289
|
}, z.core.$strip>>>>;
|
|
290
290
|
replaceKnockedImmediately: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
|
|
291
291
|
injuryReplacements: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { Player, RoleEnum } from '../../player';
|
|
3
|
+
import { NO_BLOCK } from '../blocking-preference';
|
|
3
4
|
import { EnergyBand } from '../energy-band';
|
|
4
5
|
import { RotationSystemEnum, setterCountFor } from '../rotation-system';
|
|
5
6
|
import { DesignatedSubSchema, SubBandSchema } from './designated-sub.z';
|
|
@@ -56,8 +57,12 @@ const offensivePreferenceSchema = z.object({
|
|
|
56
57
|
weights: z.record(z.string(), z.number().int().min(0)),
|
|
57
58
|
tempos: z.record(z.string(), tempoPreferenceSchema).optional()
|
|
58
59
|
});
|
|
59
|
-
//
|
|
60
|
-
|
|
60
|
+
// What a front-row starter can be assigned: an opposing attacking function to commit to (every RoleEnum except
|
|
61
|
+
// the libero, which never attacks), or NO_BLOCK to sit the rotation out of the block entirely.
|
|
62
|
+
const blockingFunctionSchema = z.union([
|
|
63
|
+
z.nativeEnum(RoleEnum).refine((v) => v !== RoleEnum.LIBERO, { message: 'BLOCKING_TARGET_CANNOT_BE_LIBERO' }),
|
|
64
|
+
z.literal(NO_BLOCK)
|
|
65
|
+
]);
|
|
61
66
|
// One rotation's dedicated-blocking assignments: `targets` maps my front-row starter ids to the opposing function
|
|
62
67
|
// each commits to block. Cross-field checks (keys are starters) live in the superRefine below.
|
|
63
68
|
const blockingPreferenceSchema = z.object({
|