volleyballsimtypes 0.0.478 → 0.0.480

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.
Files changed (85) hide show
  1. package/dist/cjs/src/api/index.d.ts +1 -0
  2. package/dist/cjs/src/data/models/tactics.d.ts +1 -0
  3. package/dist/cjs/src/data/transformers/rally-event-tempo.test.d.ts +1 -0
  4. package/dist/cjs/src/data/transformers/rally-event-tempo.test.js +40 -0
  5. package/dist/cjs/src/data/transformers/rally-event.d.ts +1 -0
  6. package/dist/cjs/src/data/transformers/rally-event.js +5 -1
  7. package/dist/cjs/src/data/transformers/tactics.js +8 -4
  8. package/dist/cjs/src/data/transformers/tactics.test.js +13 -0
  9. package/dist/cjs/src/service/event/schemas/set.z.d.ts +1 -0
  10. package/dist/cjs/src/service/event/schemas/set.z.js +4 -1
  11. package/dist/cjs/src/service/event/schemas/set.z.test.d.ts +1 -0
  12. package/dist/cjs/src/service/event/schemas/set.z.test.js +29 -0
  13. package/dist/cjs/src/service/event/set.d.ts +7 -0
  14. package/dist/cjs/src/service/event/set.js +15 -3
  15. package/dist/cjs/src/service/index.d.ts +1 -0
  16. package/dist/cjs/src/service/index.js +1 -0
  17. package/dist/cjs/src/service/notification/catalog/en.json +41 -0
  18. package/dist/cjs/src/service/notification/catalog/es.json +41 -0
  19. package/dist/cjs/src/service/notification/catalog/fr.json +41 -0
  20. package/dist/cjs/src/service/notification/catalog/index.d.ts +3 -0
  21. package/dist/cjs/src/service/notification/catalog/index.js +22 -0
  22. package/dist/cjs/src/service/notification/catalog/it.json +41 -0
  23. package/dist/cjs/src/service/notification/catalog/pl.json +41 -0
  24. package/dist/cjs/src/service/notification/catalog/pt-BR.json +41 -0
  25. package/dist/cjs/src/service/notification/catalog/tr.json +41 -0
  26. package/dist/cjs/src/service/notification/index.d.ts +5 -0
  27. package/dist/cjs/src/service/notification/index.js +23 -0
  28. package/dist/cjs/src/service/notification/notification-type.d.ts +6 -0
  29. package/dist/cjs/src/service/notification/notification-type.js +30 -0
  30. package/dist/cjs/src/service/notification/notification.test.d.ts +1 -0
  31. package/dist/cjs/src/service/notification/notification.test.js +128 -0
  32. package/dist/cjs/src/service/notification/payloads.d.ts +34 -0
  33. package/dist/cjs/src/service/notification/payloads.js +5 -0
  34. package/dist/cjs/src/service/notification/registry.d.ts +13 -0
  35. package/dist/cjs/src/service/notification/registry.js +112 -0
  36. package/dist/cjs/src/service/notification/render.d.ts +10 -0
  37. package/dist/cjs/src/service/notification/render.js +64 -0
  38. package/dist/cjs/src/service/team/offensive-preference.d.ts +2 -0
  39. package/dist/cjs/src/service/team/schemas/tactics.z.d.ts +8 -0
  40. package/dist/cjs/src/service/team/schemas/tactics.z.js +12 -2
  41. package/dist/cjs/src/service/team/schemas/tactics.z.test.js +24 -0
  42. package/dist/cjs/src/service/team/schemas/team.z.d.ts +8 -0
  43. package/dist/esm/src/api/index.d.ts +1 -0
  44. package/dist/esm/src/data/models/tactics.d.ts +1 -0
  45. package/dist/esm/src/data/transformers/rally-event-tempo.test.d.ts +1 -0
  46. package/dist/esm/src/data/transformers/rally-event-tempo.test.js +38 -0
  47. package/dist/esm/src/data/transformers/rally-event.d.ts +1 -0
  48. package/dist/esm/src/data/transformers/rally-event.js +5 -1
  49. package/dist/esm/src/data/transformers/tactics.js +8 -4
  50. package/dist/esm/src/data/transformers/tactics.test.js +13 -0
  51. package/dist/esm/src/service/event/schemas/set.z.d.ts +1 -0
  52. package/dist/esm/src/service/event/schemas/set.z.js +4 -1
  53. package/dist/esm/src/service/event/schemas/set.z.test.d.ts +1 -0
  54. package/dist/esm/src/service/event/schemas/set.z.test.js +27 -0
  55. package/dist/esm/src/service/event/set.d.ts +7 -0
  56. package/dist/esm/src/service/event/set.js +14 -2
  57. package/dist/esm/src/service/index.d.ts +1 -0
  58. package/dist/esm/src/service/index.js +1 -0
  59. package/dist/esm/src/service/notification/catalog/en.json +41 -0
  60. package/dist/esm/src/service/notification/catalog/es.json +41 -0
  61. package/dist/esm/src/service/notification/catalog/fr.json +41 -0
  62. package/dist/esm/src/service/notification/catalog/index.d.ts +3 -0
  63. package/dist/esm/src/service/notification/catalog/index.js +16 -0
  64. package/dist/esm/src/service/notification/catalog/it.json +41 -0
  65. package/dist/esm/src/service/notification/catalog/pl.json +41 -0
  66. package/dist/esm/src/service/notification/catalog/pt-BR.json +41 -0
  67. package/dist/esm/src/service/notification/catalog/tr.json +41 -0
  68. package/dist/esm/src/service/notification/index.d.ts +5 -0
  69. package/dist/esm/src/service/notification/index.js +5 -0
  70. package/dist/esm/src/service/notification/notification-type.d.ts +6 -0
  71. package/dist/esm/src/service/notification/notification-type.js +26 -0
  72. package/dist/esm/src/service/notification/notification.test.d.ts +1 -0
  73. package/dist/esm/src/service/notification/notification.test.js +126 -0
  74. package/dist/esm/src/service/notification/payloads.d.ts +34 -0
  75. package/dist/esm/src/service/notification/payloads.js +4 -0
  76. package/dist/esm/src/service/notification/registry.d.ts +13 -0
  77. package/dist/esm/src/service/notification/registry.js +109 -0
  78. package/dist/esm/src/service/notification/render.d.ts +10 -0
  79. package/dist/esm/src/service/notification/render.js +58 -0
  80. package/dist/esm/src/service/team/offensive-preference.d.ts +2 -0
  81. package/dist/esm/src/service/team/schemas/tactics.z.d.ts +8 -0
  82. package/dist/esm/src/service/team/schemas/tactics.z.js +12 -2
  83. package/dist/esm/src/service/team/schemas/tactics.z.test.js +24 -0
  84. package/dist/esm/src/service/team/schemas/team.z.d.ts +8 -0
  85. package/package.json +6 -1
@@ -0,0 +1,126 @@
1
+ import { describe, it, expect } from '@jest/globals';
2
+ import { NOTIFICATION_TYPES, NOTIFICATION_LOCALES } from './notification-type';
3
+ import { NOTIFICATION_REGISTRY } from './registry';
4
+ import { NOTIFICATION_CATALOG } from './catalog';
5
+ import { renderPush, notificationPath, interpolate, translateNotification } from './render';
6
+ function readNested(node, dotKey) {
7
+ let current = node;
8
+ for (const part of dotKey.split('.')) {
9
+ if (typeof current !== 'object' || current == null)
10
+ return undefined;
11
+ current = current[part];
12
+ }
13
+ return typeof current === 'string' ? current : undefined;
14
+ }
15
+ function placeholders(value) {
16
+ return (value.match(/\{\{(\w+)\}\}/g) ?? []).sort((a, b) => a.localeCompare(b));
17
+ }
18
+ function flattenStrings(node, prefix = '') {
19
+ const out = [];
20
+ if (typeof node !== 'object' || node == null)
21
+ return out;
22
+ for (const [key, value] of Object.entries(node)) {
23
+ if (typeof value === 'string')
24
+ out.push([prefix + key, value]);
25
+ else if (typeof value === 'object' && value != null)
26
+ out.push(...flattenStrings(value, `${prefix}${key}.`));
27
+ }
28
+ return out;
29
+ }
30
+ describe('notification registry + catalog completeness', () => {
31
+ it('has exactly one descriptor per declared type', () => {
32
+ expect(Object.keys(NOTIFICATION_REGISTRY).sort((a, b) => a.localeCompare(b)))
33
+ .toEqual([...NOTIFICATION_TYPES].sort((a, b) => a.localeCompare(b)));
34
+ });
35
+ it('resolves every descriptor catalog key to a non-empty string in all 7 locales', () => {
36
+ for (const type of NOTIFICATION_TYPES) {
37
+ for (const key of NOTIFICATION_REGISTRY[type].catalogKeys) {
38
+ for (const locale of NOTIFICATION_LOCALES) {
39
+ const value = readNested(NOTIFICATION_CATALOG[locale], key);
40
+ expect([locale, key, (value ?? '').length > 0]).toEqual([locale, key, true]);
41
+ }
42
+ }
43
+ }
44
+ });
45
+ it('ships an identical key set across all 7 locales (no drift)', () => {
46
+ const enKeys = flattenStrings(NOTIFICATION_CATALOG.en).map(([key]) => key).sort((a, b) => a.localeCompare(b));
47
+ for (const locale of NOTIFICATION_LOCALES) {
48
+ const keys = flattenStrings(NOTIFICATION_CATALOG[locale]).map(([key]) => key).sort((a, b) => a.localeCompare(b));
49
+ expect([locale, keys]).toEqual([locale, enKeys]);
50
+ }
51
+ });
52
+ it('keeps placeholders identical across locales', () => {
53
+ for (const [key, enValue] of flattenStrings(NOTIFICATION_CATALOG.en)) {
54
+ const expected = placeholders(enValue);
55
+ for (const locale of NOTIFICATION_LOCALES) {
56
+ const value = readNested(NOTIFICATION_CATALOG[locale], key);
57
+ if (value == null)
58
+ continue;
59
+ expect([locale, key, placeholders(value)]).toEqual([locale, key, expected]);
60
+ }
61
+ }
62
+ });
63
+ it('contains no em or en dashes', () => {
64
+ for (const locale of NOTIFICATION_LOCALES) {
65
+ for (const [key, value] of flattenStrings(NOTIFICATION_CATALOG[locale])) {
66
+ expect([locale, key, value.includes('—') || value.includes('–')]).toEqual([locale, key, false]);
67
+ }
68
+ }
69
+ });
70
+ });
71
+ describe('renderPush', () => {
72
+ it('renders a localized MATCH_RESULT win with a named opponent', () => {
73
+ const payload = { matchId: 'm1', result: 'WIN', side: 'HOME', awayTeam: { name: 'Rivals' } };
74
+ const en = renderPush('MATCH_RESULT', payload, 'en');
75
+ expect(en?.title).toBe('Match result');
76
+ expect(en?.body).toBe('You won against Rivals');
77
+ expect(en?.data.path).toBe('/match/m1');
78
+ expect(renderPush('MATCH_RESULT', payload, 'es')?.body).toBe('Ganaste contra Rivals');
79
+ });
80
+ it('uses the unnamed variant and root path when the opponent is unknown', () => {
81
+ const en = renderPush('MATCH_RESULT', { result: 'LOSS' }, 'en');
82
+ expect(en?.body).toBe('You lost your match');
83
+ expect(en?.data.path).toBe('/');
84
+ });
85
+ it('interpolates the injured player name and links to the infirmary', () => {
86
+ const en = renderPush('INJURY', { playerNickname: 'Ace', teamId: 't1' }, 'en');
87
+ expect(en?.title).toBe('Player injured');
88
+ expect(en?.body).toContain('Ace');
89
+ expect(en?.data.path).toBe('/infirmary');
90
+ });
91
+ it('uses a generic subject when the injury payload has no player name', () => {
92
+ const en = renderPush('INJURY', { teamId: 't1' }, 'en');
93
+ expect(en?.body.startsWith('A player')).toBe(true);
94
+ });
95
+ it('returns undefined for in-app-only types (COMPENSATION)', () => {
96
+ expect(renderPush('COMPENSATION', { currency: 100 }, 'en')).toBeUndefined();
97
+ });
98
+ it('falls back to generic copy and root path for an unknown type', () => {
99
+ const en = renderPush('SOMETHING_NEW', {}, 'en');
100
+ expect(en?.title).toBe('World Aces');
101
+ expect(en?.body).toBe('You have a new notification.');
102
+ expect(en?.data.path).toBe('/');
103
+ });
104
+ it('serializes the payload into the FCM data block', () => {
105
+ const en = renderPush('GRANT_RECEIVED', { pullType: 'MYTHIC' }, 'en');
106
+ expect(en?.data.type).toBe('GRANT_RECEIVED');
107
+ expect(JSON.parse(en?.data.payload ?? '{}')).toEqual({ pullType: 'MYTHIC' });
108
+ });
109
+ });
110
+ describe('translateNotification + interpolate + notificationPath', () => {
111
+ it('falls back to English then to the raw key', () => {
112
+ expect(translateNotification('fr', 'promotion.title')).toBe('Promotion');
113
+ expect(translateNotification('en', 'does.not.exist')).toBe('does.not.exist');
114
+ });
115
+ it('replaces placeholders and drops unknown ones', () => {
116
+ expect(interpolate('Hi {{name}}!', { name: 'X' })).toBe('Hi X!');
117
+ expect(interpolate('Hi {{name}}!', {})).toBe('Hi !');
118
+ });
119
+ it('derives the deep-link path per type', () => {
120
+ expect(notificationPath('SEASON_END', { leagueId: 'l1' })).toBe('/league/l1');
121
+ expect(notificationPath('SEASON_END', {})).toBe('/league');
122
+ expect(notificationPath('PROMOTION', { teamId: 't1' })).toBe('/team/t1');
123
+ expect(notificationPath('GRANT_RECEIVED', {})).toBe('/recruit');
124
+ expect(notificationPath('COMPENSATION', {})).toBe('/');
125
+ });
126
+ });
@@ -0,0 +1,34 @@
1
+ export interface MatchResultTeam {
2
+ id?: string;
3
+ name?: string;
4
+ }
5
+ export interface MatchResultPayload {
6
+ matchId?: string;
7
+ homeTeam?: MatchResultTeam;
8
+ awayTeam?: MatchResultTeam;
9
+ result?: 'WIN' | 'LOSS';
10
+ side?: 'HOME' | 'AWAY';
11
+ }
12
+ export interface InjuryPayload {
13
+ playerId?: string;
14
+ playerName?: string;
15
+ playerNickname?: string;
16
+ teamId?: string;
17
+ matchId?: string;
18
+ }
19
+ export interface InjuryRecoveredPayload {
20
+ playerId?: string;
21
+ playerName?: string;
22
+ playerNickname?: string;
23
+ teamId?: string;
24
+ }
25
+ export interface SeasonEndPayload {
26
+ competitionId?: string;
27
+ leagueId?: string;
28
+ position?: number;
29
+ }
30
+ export interface PromotionPayload {
31
+ teamId?: string;
32
+ amount?: number;
33
+ }
34
+ export type NotificationPayload = MatchResultPayload | InjuryPayload | InjuryRecoveredPayload | SeasonEndPayload | PromotionPayload | Record<string, unknown>;
@@ -0,0 +1,4 @@
1
+ // Typed payload shapes per notification type, for call-site safety in the Sim/API. The DB `Notification.payload`
2
+ // column is jsonb (free-form), so the registry reads payloads defensively (Record<string, unknown>); these
3
+ // interfaces document the intended shape and can be used by emitters when constructing the payload.
4
+ export {};
@@ -0,0 +1,13 @@
1
+ import { type NotificationType } from './notification-type';
2
+ export type CatalogKeyResolver = string | ((payload: Record<string, unknown>) => string);
3
+ export interface NotificationDescriptor {
4
+ emitsPush: boolean;
5
+ deepLink?: (payload: Record<string, unknown>) => string;
6
+ pushTitleKey?: CatalogKeyResolver;
7
+ pushBodyKey?: CatalogKeyResolver;
8
+ inAppMessageKey?: CatalogKeyResolver;
9
+ richRender?: NotificationType;
10
+ params?: (payload: Record<string, unknown>) => Record<string, string>;
11
+ catalogKeys: string[];
12
+ }
13
+ export declare const NOTIFICATION_REGISTRY: Record<NotificationType, NotificationDescriptor>;
@@ -0,0 +1,109 @@
1
+ function readString(value) {
2
+ return typeof value === 'string' && value !== '' ? value : null;
3
+ }
4
+ function teamPath(payload) {
5
+ const teamId = readString(payload.teamId);
6
+ return teamId != null ? `/team/${encodeURIComponent(teamId)}` : '/';
7
+ }
8
+ // MATCH_RESULT copy is recipient-relative: `result` is WIN/LOSS from the recipient's view and `side` marks
9
+ // which team is theirs, so the OTHER team is the opponent. When side/names are missing, do not name a winner.
10
+ function matchOpponent(payload) {
11
+ const homeTeam = typeof payload.homeTeam === 'object' && payload.homeTeam != null ? payload.homeTeam : {};
12
+ const awayTeam = typeof payload.awayTeam === 'object' && payload.awayTeam != null ? payload.awayTeam : {};
13
+ const homeName = readString(homeTeam.name);
14
+ const awayName = readString(awayTeam.name);
15
+ if (payload.side === 'HOME')
16
+ return awayName;
17
+ if (payload.side === 'AWAY')
18
+ return homeName;
19
+ return null;
20
+ }
21
+ function injuredName(payload) {
22
+ // Matches the old Sim injuredPlayerName fallback so a name-less payload never renders a subjectless sentence.
23
+ return readString(payload.playerNickname) ?? readString(payload.playerName) ?? 'A player';
24
+ }
25
+ export const NOTIFICATION_REGISTRY = {
26
+ MATCH_RESULT: {
27
+ emitsPush: true,
28
+ deepLink: (p) => {
29
+ const matchId = readString(p.matchId);
30
+ return matchId != null ? `/match/${encodeURIComponent(matchId)}` : '/';
31
+ },
32
+ pushTitleKey: 'matchResult.title',
33
+ pushBodyKey: (p) => {
34
+ const outcome = p.result === 'WIN' ? 'won' : 'lost';
35
+ const named = matchOpponent(p) != null ? 'Named' : 'Unnamed';
36
+ return `matchResult.${outcome}${named}`;
37
+ },
38
+ params: (p) => ({ opponent: matchOpponent(p) ?? '' }),
39
+ richRender: 'MATCH_RESULT',
40
+ catalogKeys: ['matchResult.title', 'matchResult.wonNamed', 'matchResult.wonUnnamed', 'matchResult.lostNamed', 'matchResult.lostUnnamed']
41
+ },
42
+ INJURY: {
43
+ emitsPush: true,
44
+ deepLink: () => '/infirmary',
45
+ pushTitleKey: 'injury.title',
46
+ pushBodyKey: 'injury.body',
47
+ inAppMessageKey: 'injury.body',
48
+ params: (p) => ({ name: injuredName(p) }),
49
+ catalogKeys: ['injury.title', 'injury.body']
50
+ },
51
+ INJURY_RECOVERED: {
52
+ emitsPush: true,
53
+ deepLink: teamPath,
54
+ pushTitleKey: 'injuryRecovered.title',
55
+ pushBodyKey: 'injuryRecovered.body',
56
+ inAppMessageKey: 'injuryRecovered.body',
57
+ params: (p) => ({ name: injuredName(p) }),
58
+ catalogKeys: ['injuryRecovered.title', 'injuryRecovered.body']
59
+ },
60
+ SEASON_END: {
61
+ emitsPush: true,
62
+ deepLink: (p) => {
63
+ const leagueId = readString(p.leagueId);
64
+ return leagueId != null ? `/league/${encodeURIComponent(leagueId)}` : '/league';
65
+ },
66
+ pushTitleKey: 'seasonEnd.title',
67
+ pushBodyKey: 'seasonEnd.body',
68
+ inAppMessageKey: 'seasonEnd.body',
69
+ catalogKeys: ['seasonEnd.title', 'seasonEnd.body']
70
+ },
71
+ GRANT_RECEIVED: {
72
+ emitsPush: true,
73
+ deepLink: () => '/recruit',
74
+ pushTitleKey: 'grant.title',
75
+ pushBodyKey: 'grant.body',
76
+ inAppMessageKey: 'grant.body',
77
+ catalogKeys: ['grant.title', 'grant.body']
78
+ },
79
+ PROMOTION: {
80
+ emitsPush: true,
81
+ deepLink: teamPath,
82
+ pushTitleKey: 'promotion.title',
83
+ pushBodyKey: 'promotion.body',
84
+ inAppMessageKey: 'promotion.body',
85
+ catalogKeys: ['promotion.title', 'promotion.body']
86
+ },
87
+ DAILY_LOGIN: {
88
+ emitsPush: true,
89
+ deepLink: () => '/recruit',
90
+ pushTitleKey: 'dailyLogin.title',
91
+ pushBodyKey: 'dailyLogin.body',
92
+ inAppMessageKey: 'dailyLogin.body',
93
+ catalogKeys: ['dailyLogin.title', 'dailyLogin.body']
94
+ },
95
+ WELCOME: {
96
+ emitsPush: true,
97
+ deepLink: () => '/recruit',
98
+ pushTitleKey: 'welcome.title',
99
+ pushBodyKey: 'welcome.body',
100
+ richRender: 'WELCOME',
101
+ catalogKeys: ['welcome.title', 'welcome.body']
102
+ },
103
+ // In-app only (no push); the UI keeps a bespoke renderer (amount formatting) and its own `compensation` label.
104
+ COMPENSATION: {
105
+ emitsPush: false,
106
+ richRender: 'COMPENSATION',
107
+ catalogKeys: []
108
+ }
109
+ };
@@ -0,0 +1,10 @@
1
+ import { type NotificationLocale } from './notification-type';
2
+ export interface RenderedPush {
3
+ title: string;
4
+ body: string;
5
+ data: Record<string, string>;
6
+ }
7
+ export declare function interpolate(template: string, params: Record<string, string>): string;
8
+ export declare function translateNotification(locale: NotificationLocale, key: string, params?: Record<string, string>): string;
9
+ export declare function renderPush(type: string, payload: Record<string, unknown>, locale: NotificationLocale): RenderedPush | undefined;
10
+ export declare function notificationPath(type: string, payload: Record<string, unknown>): string;
@@ -0,0 +1,58 @@
1
+ import { DEFAULT_NOTIFICATION_LOCALE } from './notification-type';
2
+ import { NOTIFICATION_REGISTRY } from './registry';
3
+ import { NOTIFICATION_CATALOG } from './catalog';
4
+ function readNested(node, dotKey) {
5
+ let current = node;
6
+ for (const part of dotKey.split('.')) {
7
+ if (typeof current !== 'object' || current == null)
8
+ return undefined;
9
+ current = current[part];
10
+ }
11
+ return typeof current === 'string' ? current : undefined;
12
+ }
13
+ function lookupCatalog(locale, dotKey) {
14
+ return readNested(NOTIFICATION_CATALOG[locale], dotKey) ??
15
+ readNested(NOTIFICATION_CATALOG[DEFAULT_NOTIFICATION_LOCALE], dotKey) ??
16
+ dotKey;
17
+ }
18
+ function resolveKey(resolver, payload) {
19
+ if (resolver == null)
20
+ return undefined;
21
+ return typeof resolver === 'function' ? resolver(payload) : resolver;
22
+ }
23
+ export function interpolate(template, params) {
24
+ return template.replace(/\{\{(\w+)\}\}/g, (_match, key) => params[key] ?? '');
25
+ }
26
+ // Resolve one catalog key to a localized, interpolated string. Missing key/locale falls back to English,
27
+ // then to the raw key. Exposed so consumers can render notification copy off the shared catalog too.
28
+ export function translateNotification(locale, key, params = {}) {
29
+ return interpolate(lookupCatalog(locale, key), params);
30
+ }
31
+ // Build the localized push payload for a notification. Returns undefined for in-app-only types (emitsPush
32
+ // false, e.g. COMPENSATION). Unknown/future types fall back to the generic copy and the root path, matching
33
+ // the previous hardcoded fallback. Callers pass the recipient's locale (normalizeNotificationLocale first).
34
+ export function renderPush(type, payload, locale) {
35
+ const buildData = (path) => ({ type, payload: JSON.stringify(payload), path });
36
+ const descriptor = NOTIFICATION_REGISTRY[type];
37
+ if (descriptor == null) {
38
+ return {
39
+ title: translateNotification(locale, 'generic.title'),
40
+ body: translateNotification(locale, 'generic.body'),
41
+ data: buildData('/')
42
+ };
43
+ }
44
+ if (!descriptor.emitsPush)
45
+ return undefined;
46
+ const params = descriptor.params?.(payload) ?? {};
47
+ const titleKey = resolveKey(descriptor.pushTitleKey, payload) ?? 'generic.title';
48
+ const bodyKey = resolveKey(descriptor.pushBodyKey, payload) ?? 'generic.body';
49
+ return {
50
+ title: translateNotification(locale, titleKey, params),
51
+ body: translateNotification(locale, bodyKey, params),
52
+ data: buildData(descriptor.deepLink?.(payload) ?? '/')
53
+ };
54
+ }
55
+ // Deep-link path for a type/payload, used for both the push data.path and the in-app click target.
56
+ export function notificationPath(type, payload) {
57
+ return NOTIFICATION_REGISTRY[type]?.deepLink?.(payload) ?? '/';
58
+ }
@@ -1,5 +1,7 @@
1
1
  import { Player } from '../player';
2
+ export type TempoPreference = 'FAST' | 'HIGH';
2
3
  export interface OffensivePreference {
3
4
  readonly rotation: number;
4
5
  readonly order: Player[];
6
+ readonly tempos?: Record<string, TempoPreference>;
5
7
  }
@@ -270,6 +270,10 @@ export declare const TacticsInputSchema: z.ZodObject<{
270
270
  offensivePreferences: z.ZodDefault<z.ZodArray<z.ZodObject<{
271
271
  rotation: z.ZodNumber;
272
272
  order: z.ZodArray<z.ZodCustom<Player, Player>>;
273
+ tempos: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodEnum<{
274
+ FAST: "FAST";
275
+ HIGH: "HIGH";
276
+ }>>>;
273
277
  }, z.core.$strip>>>;
274
278
  systemSets: z.ZodOptional<z.ZodArray<z.ZodObject<{
275
279
  rotationSystem: z.ZodEnum<typeof RotationSystemEnum>;
@@ -278,6 +282,10 @@ export declare const TacticsInputSchema: z.ZodObject<{
278
282
  offensivePreferenceSets: z.ZodOptional<z.ZodArray<z.ZodArray<z.ZodObject<{
279
283
  rotation: z.ZodNumber;
280
284
  order: z.ZodArray<z.ZodCustom<Player, Player>>;
285
+ tempos: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodEnum<{
286
+ FAST: "FAST";
287
+ HIGH: "HIGH";
288
+ }>>>;
281
289
  }, z.core.$strip>>>>;
282
290
  replaceKnockedImmediately: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
283
291
  injuryReplacements: z.ZodDefault<z.ZodArray<z.ZodObject<{
@@ -45,11 +45,15 @@ const lineupSchema = z.object({
45
45
  });
46
46
  }
47
47
  });
48
+ // Per-player tempo preference (set-tempo rework): absent key = Auto.
49
+ const tempoPreferenceSchema = z.enum(['FAST', 'HIGH']);
48
50
  // One rotation's ranked attacker priority. `rotation` is the main setter's rotational slot (1..6); `order` lists
49
- // the attackers ranked best-first. Cross-field checks (starters only, no dupes) live in the superRefine below.
51
+ // the attackers ranked best-first. `tempos` optionally forces a per-starter tempo (Auto when absent). Cross-field
52
+ // checks (starters only in both order and tempos, no dupes) live in the superRefine below.
50
53
  const offensivePreferenceSchema = z.object({
51
54
  rotation: z.number().int().min(1).max(6),
52
- order: z.array(playerInstanceSchema)
55
+ order: z.array(playerInstanceSchema),
56
+ tempos: z.record(z.string(), tempoPreferenceSchema).optional()
53
57
  });
54
58
  // One set's rotation-system override (per-set tactics): system + its designated setters travel together.
55
59
  const systemSetSchema = z.object({
@@ -155,6 +159,12 @@ export const TacticsInputSchema = z.object({
155
159
  ctx.addIssue({ code: 'custom', message: 'OFFENSIVE_PREFERENCE_ATTACKER_NOT_A_STARTER', path });
156
160
  }
157
161
  }
162
+ // Tempo preference keys are starter ids too (same rule as order).
163
+ for (const id of Object.keys(pref.tempos ?? {})) {
164
+ if (!starterIds.has(id)) {
165
+ ctx.addIssue({ code: 'custom', message: 'OFFENSIVE_PREFERENCE_TEMPO_NOT_A_STARTER', path });
166
+ }
167
+ }
158
168
  }
159
169
  };
160
170
  validateSystem(data.rotationSystem, data.designatedSetters, ['designatedSetters']);
@@ -168,6 +168,30 @@ describe('TacticsInputSchema — per-set tactics (systemSets / offensivePreferen
168
168
  expect(res.error.issues.some(i => i.message === 'OFFENSIVE_PREFERENCE_ATTACKER_NOT_A_STARTER')).toBe(true);
169
169
  }
170
170
  });
171
+ it('accepts offensive preferences with a starter-keyed tempos map', () => {
172
+ const res = TacticsInputSchema.safeParse(baseInput({
173
+ offensivePreferences: [{ rotation: 1, order: [p2, p3], tempos: { [p2.id]: 'FAST', [p3.id]: 'HIGH' } }]
174
+ }));
175
+ expect(res.success).toBe(true);
176
+ if (res.success) {
177
+ expect(res.data.offensivePreferences[0].tempos).toEqual({ [p2.id]: 'FAST', [p3.id]: 'HIGH' });
178
+ }
179
+ });
180
+ it('rejects a tempos map keyed by a non-starter', () => {
181
+ const res = TacticsInputSchema.safeParse(baseInput({
182
+ offensivePreferences: [{ rotation: 1, order: [p2], tempos: { [reserve.id]: 'FAST' } }]
183
+ }));
184
+ expect(res.success).toBe(false);
185
+ if (!res.success) {
186
+ expect(res.error.issues.some(i => i.message === 'OFFENSIVE_PREFERENCE_TEMPO_NOT_A_STARTER')).toBe(true);
187
+ }
188
+ });
189
+ it('rejects an invalid tempo value', () => {
190
+ const res = TacticsInputSchema.safeParse(baseInput({
191
+ offensivePreferences: [{ rotation: 1, order: [p2], tempos: { [p2.id]: 'SLOW' } }]
192
+ }));
193
+ expect(res.success).toBe(false);
194
+ });
171
195
  it('still validates the flat fields when per-set overrides are present', () => {
172
196
  // Flat 5-1 with no setter is invalid even though every set entry is valid.
173
197
  const res = TacticsInputSchema.safeParse(baseInput({
@@ -278,6 +278,10 @@ export declare const TeamInputSchema: z.ZodObject<{
278
278
  offensivePreferences: z.ZodDefault<z.ZodArray<z.ZodObject<{
279
279
  rotation: z.ZodNumber;
280
280
  order: z.ZodArray<z.ZodCustom<Player, Player>>;
281
+ tempos: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodEnum<{
282
+ FAST: "FAST";
283
+ HIGH: "HIGH";
284
+ }>>>;
281
285
  }, z.core.$strip>>>;
282
286
  systemSets: z.ZodOptional<z.ZodArray<z.ZodObject<{
283
287
  rotationSystem: z.ZodEnum<typeof import("..").RotationSystemEnum>;
@@ -286,6 +290,10 @@ export declare const TeamInputSchema: z.ZodObject<{
286
290
  offensivePreferenceSets: z.ZodOptional<z.ZodArray<z.ZodArray<z.ZodObject<{
287
291
  rotation: z.ZodNumber;
288
292
  order: z.ZodArray<z.ZodCustom<Player, Player>>;
293
+ tempos: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodEnum<{
294
+ FAST: "FAST";
295
+ HIGH: "HIGH";
296
+ }>>>;
289
297
  }, z.core.$strip>>>>;
290
298
  replaceKnockedImmediately: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
291
299
  injuryReplacements: z.ZodDefault<z.ZodArray<z.ZodObject<{
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "volleyballsimtypes",
3
- "version": "0.0.478",
3
+ "version": "0.0.480",
4
4
  "description": "vbsim types",
5
5
  "main": "./dist/cjs/src/index.js",
6
6
  "module": "./dist/esm/src/index.js",
@@ -37,6 +37,11 @@
37
37
  "import": "./dist/esm/src/data/common/index.js",
38
38
  "require": "./dist/cjs/src/data/common/index.js"
39
39
  },
40
+ "./notification": {
41
+ "types": "./dist/esm/src/service/notification/index.d.ts",
42
+ "import": "./dist/esm/src/service/notification/index.js",
43
+ "require": "./dist/cjs/src/service/notification/index.js"
44
+ },
40
45
  "./stat-config": {
41
46
  "types": "./dist/esm/src/stat-config/index.d.ts",
42
47
  "import": "./dist/esm/src/stat-config/index.js",