volleyballsimtypes 0.0.459 → 0.0.463

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 (47) hide show
  1. package/dist/cjs/src/api/index.d.ts +2 -0
  2. package/dist/cjs/src/data/init-models.js +13 -0
  3. package/dist/cjs/src/data/models/index.d.ts +2 -0
  4. package/dist/cjs/src/data/models/index.js +2 -0
  5. package/dist/cjs/src/data/models/match-preset.d.ts +28 -0
  6. package/dist/cjs/src/data/models/match-preset.js +65 -0
  7. package/dist/cjs/src/data/models/match.d.ts +2 -1
  8. package/dist/cjs/src/data/models/user-device-token.d.ts +23 -0
  9. package/dist/cjs/src/data/models/user-device-token.js +65 -0
  10. package/dist/cjs/src/data/transformers/division.js +1 -1
  11. package/dist/cjs/src/data/transformers/match-preset-override.test.d.ts +1 -0
  12. package/dist/cjs/src/data/transformers/match-preset-override.test.js +275 -0
  13. package/dist/cjs/src/data/transformers/match.d.ts +2 -1
  14. package/dist/cjs/src/data/transformers/match.js +15 -3
  15. package/dist/cjs/src/data/transformers/qualifier.js +1 -1
  16. package/dist/cjs/src/data/transformers/season.js +1 -1
  17. package/dist/cjs/src/data/transformers/tactics.d.ts +3 -2
  18. package/dist/cjs/src/data/transformers/tactics.js +36 -0
  19. package/dist/cjs/src/data/transformers/tactics.test.js +61 -0
  20. package/dist/cjs/src/data/transformers/team.d.ts +7 -1
  21. package/dist/cjs/src/data/transformers/team.js +22 -4
  22. package/dist/cjs/src/data/transformers/tournament.js +1 -1
  23. package/dist/cjs/src/service/team/schemas/tactics.z.test.js +115 -0
  24. package/dist/esm/src/api/index.d.ts +2 -0
  25. package/dist/esm/src/data/init-models.js +14 -1
  26. package/dist/esm/src/data/models/index.d.ts +2 -0
  27. package/dist/esm/src/data/models/index.js +2 -0
  28. package/dist/esm/src/data/models/match-preset.d.ts +28 -0
  29. package/dist/esm/src/data/models/match-preset.js +61 -0
  30. package/dist/esm/src/data/models/match.d.ts +2 -1
  31. package/dist/esm/src/data/models/user-device-token.d.ts +23 -0
  32. package/dist/esm/src/data/models/user-device-token.js +61 -0
  33. package/dist/esm/src/data/transformers/division.js +1 -1
  34. package/dist/esm/src/data/transformers/match-preset-override.test.d.ts +1 -0
  35. package/dist/esm/src/data/transformers/match-preset-override.test.js +273 -0
  36. package/dist/esm/src/data/transformers/match.d.ts +2 -1
  37. package/dist/esm/src/data/transformers/match.js +15 -3
  38. package/dist/esm/src/data/transformers/qualifier.js +1 -1
  39. package/dist/esm/src/data/transformers/season.js +1 -1
  40. package/dist/esm/src/data/transformers/tactics.d.ts +3 -2
  41. package/dist/esm/src/data/transformers/tactics.js +36 -1
  42. package/dist/esm/src/data/transformers/tactics.test.js +63 -2
  43. package/dist/esm/src/data/transformers/team.d.ts +7 -1
  44. package/dist/esm/src/data/transformers/team.js +20 -5
  45. package/dist/esm/src/data/transformers/tournament.js +1 -1
  46. package/dist/esm/src/service/team/schemas/tactics.z.test.js +115 -0
  47. package/package.json +1 -1
@@ -0,0 +1,273 @@
1
+ import { describe, it, expect, jest } from '@jest/globals';
2
+ import { v4 as uuidv4 } from 'uuid';
3
+ import { apiTacticsConfigToTacticsAttributes, tacticsColumnsToApiDto, transformToTactics } from './tactics';
4
+ import { transformToTeam } from './team';
5
+ import { transformToMatch } from './match';
6
+ import { CourtPosition, DeclineProfileEnum, RarityEnum, RoleEnum } from '../../service';
7
+ // ─── shared mocks (model-shaped plain objects, cast through unknown) ──────────
8
+ const countryFixture = {
9
+ country_id: uuidv4(),
10
+ name: 'Test Country',
11
+ alpha_2: 'TC',
12
+ alpha_3: 'TCN',
13
+ country_code: '999',
14
+ iso_3166_2: 'TC-TEST',
15
+ region: '',
16
+ region_code: '',
17
+ sub_region: '',
18
+ sub_region_code: '',
19
+ intermediate_region: '',
20
+ intermediate_region_code: ''
21
+ };
22
+ function makePlayerModel(id) {
23
+ const stat = 50;
24
+ return {
25
+ player_id: id,
26
+ first_name: 'Test',
27
+ last_name: `P-${id.slice(0, 4)}`,
28
+ rarity: RarityEnum.COMMON,
29
+ roles: [RoleEnum.OUTSIDE_HITTER],
30
+ traits: [],
31
+ birth_age: 18,
32
+ birth_iteration: 1,
33
+ last_declined_iteration: null,
34
+ decline_profile: DeclineProfileEnum.STANDARD,
35
+ BoxScores: [],
36
+ BoxScoreTotals: null,
37
+ country: countryFixture,
38
+ PerformanceStat: {
39
+ setting: stat,
40
+ serve: stat,
41
+ spike: stat,
42
+ quick: stat,
43
+ power: stat,
44
+ awareness: stat,
45
+ attack: stat,
46
+ back_attack: stat,
47
+ reception: stat,
48
+ overhand: stat,
49
+ bump: stat,
50
+ block: stat,
51
+ read: stat,
52
+ commit: stat,
53
+ focus: stat,
54
+ defense: stat,
55
+ stamina: stat,
56
+ reflex: stat
57
+ }
58
+ };
59
+ }
60
+ // Six starters; index order is the row lineup's zone 1..6 order.
61
+ const ids = Array.from({ length: 6 }, () => uuidv4());
62
+ const teamId = uuidv4();
63
+ function rowLineup(order) {
64
+ return {
65
+ [CourtPosition.RIGHT_BACK]: order[0],
66
+ [CourtPosition.RIGHT_FRONT]: order[1],
67
+ [CourtPosition.MIDDLE_FRONT]: order[2],
68
+ [CourtPosition.LEFT_FRONT]: order[3],
69
+ [CourtPosition.LEFT_BACK]: order[4],
70
+ [CourtPosition.MIDDLE_BACK]: order[5],
71
+ bench: []
72
+ };
73
+ }
74
+ function makeTacticsRow(order) {
75
+ return {
76
+ team_id: teamId,
77
+ lineup: rowLineup(order),
78
+ libero_replacements: [],
79
+ receive_rotation_offset: false,
80
+ designated_subs: [],
81
+ substitution_band: 'TIRED',
82
+ rotation_system: '6-0',
83
+ designated_setters: [],
84
+ offensive_preferences: []
85
+ };
86
+ }
87
+ function makeTeamModel(activeOrder) {
88
+ return {
89
+ team_id: teamId,
90
+ name: 'Test Team',
91
+ short_name: 'TST',
92
+ rating: 0,
93
+ division_id: uuidv4(),
94
+ active: true,
95
+ country: countryFixture,
96
+ tactics: makeTacticsRow(activeOrder),
97
+ PlayerTeams: ids.map(id => ({ player: makePlayerModel(id) }))
98
+ };
99
+ }
100
+ // The API-DTO config for a preset whose starters are the REVERSE of the active order, so an applied
101
+ // override is visible through the hydrated lineup.
102
+ function makeConfig(order) {
103
+ return {
104
+ lineup: {
105
+ [CourtPosition.RIGHT_BACK]: order[0],
106
+ [CourtPosition.RIGHT_FRONT]: order[1],
107
+ [CourtPosition.MIDDLE_FRONT]: order[2],
108
+ [CourtPosition.LEFT_FRONT]: order[3],
109
+ [CourtPosition.LEFT_BACK]: order[4],
110
+ [CourtPosition.MIDDLE_BACK]: order[5],
111
+ bench: []
112
+ },
113
+ liberoReplacements: [],
114
+ receiveRotationOffset: false,
115
+ designatedSubs: [],
116
+ substitutionBand: 'TIRED',
117
+ rotationSystem: '6-0',
118
+ designatedSetters: [],
119
+ offensivePreferences: []
120
+ };
121
+ }
122
+ const activeOrder = ids;
123
+ const presetOrder = [...ids].reverse();
124
+ function starterAt(tactics, zone) {
125
+ return tactics.lineup[zone]?.id;
126
+ }
127
+ // ─── apiTacticsConfigToTacticsAttributes ──────────────────────────────────────
128
+ describe('apiTacticsConfigToTacticsAttributes()', () => {
129
+ it('round-trips a fully-loaded Tactics row through tacticsColumnsToApiDto', () => {
130
+ const full = {
131
+ team_id: teamId,
132
+ lineup: { ...rowLineup(activeOrder), [CourtPosition.LIBERO_ZONE]: undefined },
133
+ libero_replacements: [ids[5]],
134
+ receive_rotation_offset: true,
135
+ designated_subs: [{ starterId: ids[0], benchId: undefined, mode: 'FATIGUE', fatigueBand: 'WORN', sets: [{ mode: 'NEVER' }] }],
136
+ substitution_band: 'TIRED',
137
+ second_libero: undefined,
138
+ libero_sub: undefined,
139
+ libero_replacement_sets: [[ids[0]], [ids[1]]],
140
+ rotation_system: '5-1',
141
+ designated_setters: [ids[0]],
142
+ offensive_preferences: [{ rotation: 1, order: [ids[1], ids[2]] }],
143
+ system_sets: [{ rotationSystem: '5-1', designatedSetters: [ids[0]] }],
144
+ offensive_preference_sets: [[{ rotation: 2, order: [ids[3]] }]]
145
+ };
146
+ const dto = tacticsColumnsToApiDto(full);
147
+ const back = apiTacticsConfigToTacticsAttributes(dto, teamId);
148
+ expect(back.lineup).toEqual(full.lineup);
149
+ expect(back.libero_replacements).toEqual(full.libero_replacements);
150
+ expect(back.receive_rotation_offset).toBe(true);
151
+ expect(back.designated_subs).toEqual(full.designated_subs);
152
+ expect(back.libero_replacement_sets).toEqual(full.libero_replacement_sets);
153
+ expect(back.rotation_system).toBe('5-1');
154
+ expect(back.designated_setters).toEqual(full.designated_setters);
155
+ expect(back.offensive_preferences).toEqual(full.offensive_preferences);
156
+ expect(back.system_sets).toEqual(full.system_sets);
157
+ expect(back.offensive_preference_sets).toEqual(full.offensive_preference_sets);
158
+ });
159
+ it('hydrates through transformToTactics identically to the row path', () => {
160
+ const roster = ids.map(id => transformToTeam(makeTeamModel(activeOrder)).roster.find(p => p.id === id));
161
+ expect(roster.every(p => p != null)).toBe(true);
162
+ const teamModel = makeTeamModel(activeOrder);
163
+ const players = transformToTeam(teamModel).roster;
164
+ const fromRow = transformToTactics(makeTacticsRow(activeOrder), players);
165
+ const fromConfig = transformToTactics(apiTacticsConfigToTacticsAttributes(makeConfig(activeOrder), teamId), players);
166
+ for (const zone of [1, 2, 3, 4, 5, 6]) {
167
+ expect(starterAt(fromConfig, zone)).toBe(starterAt(fromRow, zone));
168
+ }
169
+ });
170
+ it('throws PLAYER_NOT_FOUND when the config references an off-roster player', () => {
171
+ const players = transformToTeam(makeTeamModel(activeOrder)).roster;
172
+ const drifted = makeConfig([uuidv4(), ...ids.slice(1)]);
173
+ expect(() => transformToTactics(apiTacticsConfigToTacticsAttributes(drifted, teamId), players)).toThrow('PLAYER_NOT_FOUND');
174
+ });
175
+ });
176
+ // ─── transformToTeam override / fallback ──────────────────────────────────────
177
+ describe('transformToTeam() tacticsOverride', () => {
178
+ it('uses the override config when it hydrates', () => {
179
+ const team = transformToTeam(makeTeamModel(activeOrder), undefined, {
180
+ presetId: 'preset-1',
181
+ config: makeConfig(presetOrder)
182
+ });
183
+ expect(starterAt(team.tactics, CourtPosition.RIGHT_BACK)).toBe(presetOrder[0]);
184
+ });
185
+ it('falls back to the active tactics and notifies onFallback when the override fails', () => {
186
+ const onFallback = jest.fn();
187
+ const drifted = makeConfig([uuidv4(), ...ids.slice(1)]);
188
+ const team = transformToTeam(makeTeamModel(activeOrder), undefined, {
189
+ presetId: 'preset-1',
190
+ config: drifted,
191
+ onFallback
192
+ });
193
+ expect(onFallback).toHaveBeenCalledTimes(1);
194
+ expect(onFallback.mock.calls[0][0]).toBe('preset-1');
195
+ expect(starterAt(team.tactics, CourtPosition.RIGHT_BACK)).toBe(activeOrder[0]);
196
+ });
197
+ it('keeps the active tactics when no override is given', () => {
198
+ const team = transformToTeam(makeTeamModel(activeOrder));
199
+ expect(starterAt(team.tactics, CourtPosition.RIGHT_BACK)).toBe(activeOrder[0]);
200
+ });
201
+ });
202
+ // ─── transformToMatch per-team override ───────────────────────────────────────
203
+ describe('transformToMatch() MatchPresets', () => {
204
+ const awayIds = Array.from({ length: 6 }, () => uuidv4());
205
+ const awayTeamId = uuidv4();
206
+ function makeAwayModel() {
207
+ return {
208
+ team_id: awayTeamId,
209
+ name: 'Away Team',
210
+ short_name: 'AWY',
211
+ rating: 0,
212
+ division_id: uuidv4(),
213
+ active: true,
214
+ country: countryFixture,
215
+ tactics: {
216
+ team_id: awayTeamId,
217
+ lineup: {
218
+ [CourtPosition.RIGHT_BACK]: awayIds[0],
219
+ [CourtPosition.RIGHT_FRONT]: awayIds[1],
220
+ [CourtPosition.MIDDLE_FRONT]: awayIds[2],
221
+ [CourtPosition.LEFT_FRONT]: awayIds[3],
222
+ [CourtPosition.LEFT_BACK]: awayIds[4],
223
+ [CourtPosition.MIDDLE_BACK]: awayIds[5],
224
+ bench: []
225
+ },
226
+ libero_replacements: [],
227
+ receive_rotation_offset: false,
228
+ designated_subs: [],
229
+ substitution_band: 'TIRED',
230
+ rotation_system: '6-0',
231
+ designated_setters: [],
232
+ offensive_preferences: []
233
+ },
234
+ PlayerTeams: awayIds.map(id => ({ player: makePlayerModel(id) }))
235
+ };
236
+ }
237
+ function makeMatchModel(withPreset) {
238
+ return {
239
+ match_id: uuidv4(),
240
+ home_team: teamId,
241
+ away_team: awayTeamId,
242
+ scheduled_date: new Date(),
243
+ status: 'PENDING',
244
+ HomeTeam: makeTeamModel(activeOrder),
245
+ AwayTeam: makeAwayModel(),
246
+ MatchSets: [],
247
+ VPERs: [],
248
+ MatchPresets: withPreset
249
+ ? [{ match_id: 'm', team_id: teamId, preset_id: 'preset-1', preset: { config: makeConfig(presetOrder) } }]
250
+ : undefined
251
+ };
252
+ }
253
+ it('applies the home override and leaves the away team on active tactics', () => {
254
+ const match = transformToMatch(makeMatchModel(true));
255
+ expect(starterAt(match.homeTeam.tactics, CourtPosition.RIGHT_BACK)).toBe(presetOrder[0]);
256
+ expect(starterAt(match.awayTeam.tactics, CourtPosition.RIGHT_BACK)).toBe(awayIds[0]);
257
+ });
258
+ it('behaves exactly as before when the association is absent', () => {
259
+ const match = transformToMatch(makeMatchModel(false));
260
+ expect(starterAt(match.homeTeam.tactics, CourtPosition.RIGHT_BACK)).toBe(activeOrder[0]);
261
+ expect(starterAt(match.awayTeam.tactics, CourtPosition.RIGHT_BACK)).toBe(awayIds[0]);
262
+ });
263
+ it('routes the fallback callback with the failing team id', () => {
264
+ const onPresetFallback = jest.fn();
265
+ const model = makeMatchModel(true);
266
+ model.MatchPresets[0].preset.config = makeConfig([uuidv4(), ...ids.slice(1)]);
267
+ const match = transformToMatch(model, undefined, onPresetFallback);
268
+ expect(onPresetFallback).toHaveBeenCalledTimes(1);
269
+ expect(onPresetFallback.mock.calls[0][0]).toBe(teamId);
270
+ expect(onPresetFallback.mock.calls[0][1]).toBe('preset-1');
271
+ expect(starterAt(match.homeTeam.tactics, CourtPosition.RIGHT_BACK)).toBe(activeOrder[0]);
272
+ });
273
+ });
@@ -1,5 +1,6 @@
1
1
  import { MatchAttributes, MatchModel } from '../models';
2
2
  import { Match } from '../../service';
3
+ export type PresetFallbackHandler = (teamId: string, presetId: string, err: unknown) => void;
3
4
  declare function transformToAttributes(match: Match): MatchAttributes;
4
- declare function transformToObject(model: MatchModel, currentIteration?: number): Match;
5
+ declare function transformToObject(model: MatchModel, currentIteration?: number, onPresetFallback?: PresetFallbackHandler): Match;
5
6
  export { transformToObject as transformToMatch, transformToAttributes as transformFromMatch };
@@ -11,7 +11,19 @@ function transformToAttributes(match) {
11
11
  status: match.status
12
12
  };
13
13
  }
14
- function transformToObject(model, currentIteration) {
14
+ function transformToObject(model, currentIteration, onPresetFallback) {
15
+ // The (match, team)-chosen preset override for one side, when the loader included MatchPresets with the
16
+ // preset loaded (only the simulator's match query does). Association absent = active tactics, as always.
17
+ const overrideFor = (teamId) => {
18
+ const row = (model.MatchPresets ?? []).find(mp => mp.team_id === teamId);
19
+ if (row?.preset == null)
20
+ return undefined;
21
+ return {
22
+ presetId: row.preset_id,
23
+ config: row.preset.config,
24
+ onFallback: (presetId, err) => onPresetFallback?.(teamId, presetId, err)
25
+ };
26
+ };
15
27
  const sets = (model.MatchSets ?? []).map(transformToMatchSet)
16
28
  .sort((s1, s2) => s1.order - s2.order);
17
29
  let homeScore = 0;
@@ -43,8 +55,8 @@ function transformToObject(model, currentIteration) {
43
55
  const VPERs = (model.VPERs ?? []).map(transformToVPER);
44
56
  return Match.create({
45
57
  id: model.match_id,
46
- homeTeam: transformToTeam(model.HomeTeam, currentIteration),
47
- awayTeam: transformToTeam(model.AwayTeam, currentIteration),
58
+ homeTeam: transformToTeam(model.HomeTeam, currentIteration, overrideFor(model.home_team)),
59
+ awayTeam: transformToTeam(model.AwayTeam, currentIteration, overrideFor(model.away_team)),
48
60
  scheduledDate: new Date(model.scheduled_date),
49
61
  sets,
50
62
  status: model.status,
@@ -40,7 +40,7 @@ function transformToObject(model) {
40
40
  id: model.competition_id,
41
41
  iteration: transformToIteration(model.Iteration),
42
42
  matches: (model.CompetitionMatches ?? []).map(transformToQualifierMatch),
43
- teams: sortTeamsByCompetitionIndex(model.Teams ?? []).map(transformToTeam),
43
+ teams: sortTeamsByCompetitionIndex(model.Teams ?? []).map(team => transformToTeam(team)),
44
44
  status: model.status,
45
45
  region: transformToRegion(regionQualifier),
46
46
  champion
@@ -29,7 +29,7 @@ function transformToAttributes(season) {
29
29
  };
30
30
  }
31
31
  function transformToObject(model) {
32
- const teams = sortTeamsByCompetitionIndex(model.Teams ?? []).map(transformToTeam);
32
+ const teams = sortTeamsByCompetitionIndex(model.Teams ?? []).map(team => transformToTeam(team));
33
33
  // One standing per roster team: the persisted CompetitionStandings aggregate where it exists, a zero-stat
34
34
  // row otherwise. CompetitionStandings only gains a row once a team plays (the DB trigger fires on match
35
35
  // COMPLETE), so mapping it directly dropped un-played teams from the table for the first part of a season.
@@ -2,6 +2,7 @@ import { TacticsAttributes, TacticsModel } from '../models';
2
2
  import { Player, Tactics } from '../../service';
3
3
  import type { Tactics as ApiTactics } from '../../api';
4
4
  declare function transformToAttributes(tactics: Tactics, teamId: string): TacticsAttributes;
5
- declare function transformToObject(model: TacticsModel, roster: Player[]): Tactics;
5
+ declare function transformToObject(model: TacticsAttributes, roster: Player[]): Tactics;
6
6
  declare function tacticsColumnsToApiDto(model: TacticsModel): ApiTactics;
7
- export { transformToObject as transformToTactics, transformToAttributes as transformFromTactics, tacticsColumnsToApiDto };
7
+ declare function apiTacticsConfigToAttributes(config: ApiTactics, teamId: string): TacticsAttributes;
8
+ export { transformToObject as transformToTactics, transformToAttributes as transformFromTactics, tacticsColumnsToApiDto, apiTacticsConfigToAttributes as apiTacticsConfigToTacticsAttributes };
@@ -84,6 +84,8 @@ function transformToAttributes(tactics, teamId) {
84
84
  })))
85
85
  };
86
86
  }
87
+ // Accepts TacticsAttributes (not just TacticsModel) so a per-match preset's config, shaped as column data by
88
+ // apiTacticsConfigToTacticsAttributes below, can be hydrated through the exact same validation/healing path.
87
89
  function transformToObject(model, roster) {
88
90
  const lineup = transformToLineup(model.lineup, roster);
89
91
  // A second libero is only valid as a RESERVE: it needs a starting libero (LIBERO_ZONE), the L2 player must be
@@ -219,4 +221,37 @@ function tacticsColumnsToApiDto(model) {
219
221
  offensivePreferenceSets: model.offensive_preference_sets
220
222
  };
221
223
  }
222
- export { transformToObject as transformToTactics, transformToAttributes as transformFromTactics, tacticsColumnsToApiDto };
224
+ // Inverse of tacticsColumnsToApiDto: shape a preset's stored API config (camelCase, player ids) as
225
+ // TacticsAttributes-shaped column data so transformToTactics can hydrate it against a roster through the
226
+ // exact same validation/healing path as the Tactics row. Only the top-level keys need renaming -- the jsonb
227
+ // sub-objects (designated subs, libero sub, preferences, system sets) are stored camelCase in both shapes.
228
+ // Deliberately NOT mirroring writeTacticsRow's null-clears-column rule: nothing is being UPDATEd here, so
229
+ // absent optionals simply stay undefined.
230
+ function apiTacticsConfigToAttributes(config, teamId) {
231
+ return {
232
+ team_id: teamId,
233
+ lineup: {
234
+ [CourtPosition.LIBERO_ZONE]: config.lineup[CourtPosition.LIBERO_ZONE],
235
+ [CourtPosition.LEFT_BACK]: config.lineup[CourtPosition.LEFT_BACK],
236
+ [CourtPosition.LEFT_FRONT]: config.lineup[CourtPosition.LEFT_FRONT],
237
+ [CourtPosition.MIDDLE_BACK]: config.lineup[CourtPosition.MIDDLE_BACK],
238
+ [CourtPosition.MIDDLE_FRONT]: config.lineup[CourtPosition.MIDDLE_FRONT],
239
+ [CourtPosition.RIGHT_BACK]: config.lineup[CourtPosition.RIGHT_BACK],
240
+ [CourtPosition.RIGHT_FRONT]: config.lineup[CourtPosition.RIGHT_FRONT],
241
+ bench: config.lineup.bench
242
+ },
243
+ libero_replacements: config.liberoReplacements,
244
+ receive_rotation_offset: config.receiveRotationOffset,
245
+ designated_subs: config.designatedSubs,
246
+ substitution_band: config.substitutionBand,
247
+ second_libero: config.secondLibero,
248
+ libero_sub: config.liberoSub,
249
+ libero_replacement_sets: config.liberoReplacementSets,
250
+ rotation_system: config.rotationSystem,
251
+ designated_setters: config.designatedSetters,
252
+ offensive_preferences: config.offensivePreferences,
253
+ system_sets: config.systemSets,
254
+ offensive_preference_sets: config.offensivePreferenceSets
255
+ };
256
+ }
257
+ export { transformToObject as transformToTactics, transformToAttributes as transformFromTactics, tacticsColumnsToApiDto, apiTacticsConfigToAttributes as apiTacticsConfigToTacticsAttributes };
@@ -1,6 +1,7 @@
1
1
  import { describe, it, expect } from '@jest/globals';
2
- import { tacticsColumnsToApiDto } from './tactics';
3
- import { CourtPosition, EnergyBand } from '../../service';
2
+ import { tacticsColumnsToApiDto, transformFromTactics, transformToTactics } from './tactics';
3
+ import { CourtPosition, EnergyBand, RotationSystemEnum, Tactics } from '../../service';
4
+ import { makeCountry, makePlayer } from '../../service/test-helpers';
4
5
  // Minimal TacticsModel-shaped object; cast through unknown since the transformer only reads columns.
5
6
  function makeTactics(designatedSubs) {
6
7
  return {
@@ -75,4 +76,64 @@ describe('tacticsColumnsToApiDto()', () => {
75
76
  expect(dto.secondLibero).toBe('reserve1');
76
77
  expect(dto.liberoSub).toEqual({ mode: 'FATIGUE' });
77
78
  });
79
+ it('passes per-set tactics columns through to the API DTO shape', () => {
80
+ const model = makeTactics([]);
81
+ model.system_sets = [{ rotationSystem: RotationSystemEnum.FIVE_ONE, designatedSetters: ['lf'] }];
82
+ model.offensive_preference_sets = [[{ rotation: 1, order: ['rf', 'mf'] }]];
83
+ const dto = tacticsColumnsToApiDto(model);
84
+ expect(dto.systemSets).toEqual([{ rotationSystem: '5-1', designatedSetters: ['lf'] }]);
85
+ expect(dto.offensivePreferenceSets).toEqual([[{ rotation: 1, order: ['rf', 'mf'] }]]);
86
+ });
87
+ it('leaves the per-set DTO fields undefined when the columns are absent', () => {
88
+ const dto = tacticsColumnsToApiDto(makeTactics([]));
89
+ expect(dto.systemSets).toBeUndefined();
90
+ expect(dto.offensivePreferenceSets).toBeUndefined();
91
+ });
92
+ });
93
+ describe('per-set tactics — service <-> row round-trip', () => {
94
+ const country = makeCountry();
95
+ const p1 = makePlayer(country);
96
+ const p2 = makePlayer(country);
97
+ const p3 = makePlayer(country);
98
+ const p4 = makePlayer(country);
99
+ const p5 = makePlayer(country);
100
+ const p6 = makePlayer(country);
101
+ const roster = [p1, p2, p3, p4, p5, p6];
102
+ const tactics = Tactics.create({
103
+ lineup: { 4: p4, 3: p3, 2: p2, 5: p5, 6: p6, 1: p1, bench: [] },
104
+ liberoReplacements: [],
105
+ systemSets: [
106
+ { rotationSystem: RotationSystemEnum.FIVE_ONE, designatedSetters: [p1] },
107
+ { rotationSystem: RotationSystemEnum.SIX_ZERO, designatedSetters: [] }
108
+ ],
109
+ offensivePreferenceSets: [[{ rotation: 1, order: [p2, p3] }]]
110
+ });
111
+ it('transformFromTactics writes the per-set overrides as id-based jsonb columns', () => {
112
+ const attrs = transformFromTactics(tactics, 'team-1');
113
+ expect(attrs.system_sets).toEqual([
114
+ { rotationSystem: '5-1', designatedSetters: [p1.id] },
115
+ { rotationSystem: '6-0', designatedSetters: [] }
116
+ ]);
117
+ expect(attrs.offensive_preference_sets).toEqual([[{ rotation: 1, order: [p2.id, p3.id] }]]);
118
+ });
119
+ it('transformToTactics resolves the stored ids back to roster players', () => {
120
+ const attrs = transformFromTactics(tactics, 'team-1');
121
+ const back = transformToTactics(attrs, roster);
122
+ expect(back.systemSets?.[0].rotationSystem).toBe(RotationSystemEnum.FIVE_ONE);
123
+ expect(back.systemSets?.[0].designatedSetters[0].id).toBe(p1.id);
124
+ expect(back.systemSets?.[1].designatedSetters).toEqual([]);
125
+ expect(back.offensivePreferenceSets?.[0][0].order.map(p => p.id)).toEqual([p2.id, p3.id]);
126
+ });
127
+ it('omits the per-set columns entirely for an all-sets config', () => {
128
+ const flat = Tactics.create({
129
+ lineup: { 4: p4, 3: p3, 2: p2, 5: p5, 6: p6, 1: p1, bench: [] },
130
+ liberoReplacements: []
131
+ });
132
+ const attrs = transformFromTactics(flat, 'team-1');
133
+ expect(attrs.system_sets).toBeUndefined();
134
+ expect(attrs.offensive_preference_sets).toBeUndefined();
135
+ const back = transformToTactics(attrs, roster);
136
+ expect(back.systemSets).toBeUndefined();
137
+ expect(back.offensivePreferenceSets).toBeUndefined();
138
+ });
78
139
  });
@@ -1,5 +1,11 @@
1
1
  import { Team } from '../../service';
2
2
  import { TeamAttributes, TeamModel } from '../models';
3
+ import type { Tactics as ApiTactics } from '../../api';
4
+ export interface MatchTacticsOverride {
5
+ presetId: string;
6
+ config: ApiTactics;
7
+ onFallback?: (presetId: string, err: unknown) => void;
8
+ }
3
9
  declare function transformToAttributes(team: Team): TeamAttributes;
4
- declare function transformToObject(model: TeamModel, currentIteration?: number): Team;
10
+ declare function transformToObject(model: TeamModel, currentIteration?: number, tacticsOverride?: MatchTacticsOverride): Team;
5
11
  export { transformToObject as transformToTeam, transformToAttributes as transformFromTeam };
@@ -1,5 +1,6 @@
1
1
  import { Team } from '../../service';
2
- import { transformFromTactics, transformToCountry, transformToPlayer, transformToTactics } from '.';
2
+ import { apiTacticsConfigToTacticsAttributes, transformFromTactics, transformToCountry, transformToPlayer, transformToTactics } from '.';
3
+ import logger from '../../logger';
3
4
  function transformToAttributes(team) {
4
5
  return {
5
6
  team_id: team.id,
@@ -12,11 +13,25 @@ function transformToAttributes(team) {
12
13
  division_id: team.divisionId
13
14
  };
14
15
  }
15
- function transformToObject(model, currentIteration) {
16
+ function transformToObject(model, currentIteration, tacticsOverride) {
16
17
  const roster = (model.PlayerTeams ?? []).map((pt) => transformToPlayer(pt.player, currentIteration));
17
- const tactics = model.tactics != null && roster != null && roster.length > 0
18
- ? transformToTactics(model.tactics, roster)
19
- : undefined;
18
+ let tactics;
19
+ if (tacticsOverride != null && roster.length > 0) {
20
+ try {
21
+ tactics = transformToTactics(apiTacticsConfigToTacticsAttributes(tacticsOverride.config, model.team_id), roster);
22
+ }
23
+ catch (err) {
24
+ if (tacticsOverride.onFallback != null)
25
+ tacticsOverride.onFallback(tacticsOverride.presetId, err);
26
+ else
27
+ logger.warn(`MATCH_PRESET_FALLBACK: team=${model.team_id} preset=${tacticsOverride.presetId} failed to hydrate; using active tactics`);
28
+ }
29
+ }
30
+ if (tactics == null) {
31
+ tactics = model.tactics != null && roster.length > 0
32
+ ? transformToTactics(model.tactics, roster)
33
+ : undefined;
34
+ }
20
35
  return Team.create({
21
36
  id: model.team_id,
22
37
  name: model.name,
@@ -41,7 +41,7 @@ function transformToObject(model) {
41
41
  id: model.competition_id,
42
42
  iteration: transformToIteration(model.Iteration),
43
43
  matches: (model.CompetitionMatches ?? []).map(transformToTournamentMatch),
44
- teams: sortTeamsByCompetitionIndex(model.Teams ?? []).map(transformToTeam),
44
+ teams: sortTeamsByCompetitionIndex(model.Teams ?? []).map(team => transformToTeam(team)),
45
45
  status: model.status,
46
46
  champion
47
47
  });
@@ -67,3 +67,118 @@ describe('TacticsInputSchema — second libero rules', () => {
67
67
  }
68
68
  });
69
69
  });
70
+ describe('TacticsInputSchema — per-set tactics (systemSets / offensivePreferenceSets)', () => {
71
+ const country = makeCountry();
72
+ const p1 = makePlayer(country);
73
+ const p2 = makePlayer(country);
74
+ const p3 = makePlayer(country);
75
+ const p4 = makePlayer(country);
76
+ const p5 = makePlayer(country);
77
+ const p6 = makePlayer(country);
78
+ const reserve = makePlayer(country);
79
+ // Minimal valid flat input (6-0 default: no setters, no preferences). Zone layout: p1@1, p2@2, p3@3,
80
+ // p4@4, p5@5, p6@6 — opposite slot pairs (3 apart) are (1,4), (2,5), (3,6).
81
+ function baseInput(overrides = {}) {
82
+ return {
83
+ lineup: { 4: p4, 3: p3, 2: p2, 5: p5, 6: p6, 1: p1, bench: [] },
84
+ liberoReplacements: [],
85
+ ...overrides
86
+ };
87
+ }
88
+ it('accepts valid per-set overrides for both scopes', () => {
89
+ const res = TacticsInputSchema.safeParse(baseInput({
90
+ systemSets: [
91
+ { rotationSystem: '5-1', designatedSetters: [p1] },
92
+ { rotationSystem: '6-0', designatedSetters: [] },
93
+ { rotationSystem: '4-2', designatedSetters: [p1, p4] }
94
+ ],
95
+ offensivePreferenceSets: [
96
+ [{ rotation: 1, order: [p2, p3] }],
97
+ []
98
+ ]
99
+ }));
100
+ expect(res.success).toBe(true);
101
+ });
102
+ it('carries the per-set fields through Tactics.create parsing', () => {
103
+ const res = TacticsInputSchema.safeParse(baseInput({
104
+ systemSets: [{ rotationSystem: '5-1', designatedSetters: [p1] }],
105
+ offensivePreferenceSets: [[{ rotation: 2, order: [p3] }]]
106
+ }));
107
+ expect(res.success).toBe(true);
108
+ if (res.success) {
109
+ expect(res.data.systemSets?.[0].rotationSystem).toBe('5-1');
110
+ expect(res.data.systemSets?.[0].designatedSetters[0].id).toBe(p1.id);
111
+ expect(res.data.offensivePreferenceSets?.[0][0]).toMatchObject({ rotation: 2 });
112
+ }
113
+ });
114
+ it('rejects more than 5 per-set entries', () => {
115
+ const entry = { rotationSystem: '6-0', designatedSetters: [] };
116
+ expect(TacticsInputSchema.safeParse(baseInput({ systemSets: Array.from({ length: 6 }, () => entry) })).success).toBe(false);
117
+ expect(TacticsInputSchema.safeParse(baseInput({ offensivePreferenceSets: Array.from({ length: 6 }, () => []) })).success).toBe(false);
118
+ });
119
+ it('rejects a set entry whose setter count does not match its system (5-1 with none)', () => {
120
+ const res = TacticsInputSchema.safeParse(baseInput({
121
+ systemSets: [{ rotationSystem: '5-1', designatedSetters: [] }]
122
+ }));
123
+ expect(res.success).toBe(false);
124
+ if (!res.success) {
125
+ const issue = res.error.issues.find(i => i.message === 'DESIGNATED_SETTERS_COUNT_5-1_EXPECTED_1');
126
+ expect(issue?.path).toEqual(['systemSets', 0]);
127
+ }
128
+ });
129
+ it('rejects a set entry whose setter is not a court starter', () => {
130
+ const res = TacticsInputSchema.safeParse(baseInput({
131
+ systemSets: [
132
+ { rotationSystem: '6-0', designatedSetters: [] },
133
+ { rotationSystem: '5-1', designatedSetters: [reserve] }
134
+ ]
135
+ }));
136
+ expect(res.success).toBe(false);
137
+ if (!res.success) {
138
+ const issue = res.error.issues.find(i => i.message === 'DESIGNATED_SETTER_NOT_A_STARTER');
139
+ expect(issue?.path).toEqual(['systemSets', 1]);
140
+ }
141
+ });
142
+ it('rejects a 4-2 set entry whose setters are not in opposite slots', () => {
143
+ // p1@1 and p2@2 are adjacent (not 3 apart), so the 4-2 pair rule fails for that set entry.
144
+ const res = TacticsInputSchema.safeParse(baseInput({
145
+ systemSets: [{ rotationSystem: '4-2', designatedSetters: [p1, p2] }]
146
+ }));
147
+ expect(res.success).toBe(false);
148
+ if (!res.success) {
149
+ expect(res.error.issues.some(i => i.message === 'DESIGNATED_SETTERS_NOT_IN_OPPOSITE_SLOTS')).toBe(true);
150
+ }
151
+ });
152
+ it('rejects a per-set preferences list with a duplicate rotation', () => {
153
+ const res = TacticsInputSchema.safeParse(baseInput({
154
+ offensivePreferenceSets: [[{ rotation: 1, order: [p2] }, { rotation: 1, order: [p3] }]]
155
+ }));
156
+ expect(res.success).toBe(false);
157
+ if (!res.success) {
158
+ const issue = res.error.issues.find(i => i.message === 'OFFENSIVE_PREFERENCE_DUPLICATE_ROTATION');
159
+ expect(issue?.path).toEqual(['offensivePreferenceSets', 0]);
160
+ }
161
+ });
162
+ it('rejects a per-set preferences order that lists a non-starter', () => {
163
+ const res = TacticsInputSchema.safeParse(baseInput({
164
+ offensivePreferenceSets: [[{ rotation: 1, order: [reserve] }]]
165
+ }));
166
+ expect(res.success).toBe(false);
167
+ if (!res.success) {
168
+ expect(res.error.issues.some(i => i.message === 'OFFENSIVE_PREFERENCE_ATTACKER_NOT_A_STARTER')).toBe(true);
169
+ }
170
+ });
171
+ it('still validates the flat fields when per-set overrides are present', () => {
172
+ // Flat 5-1 with no setter is invalid even though every set entry is valid.
173
+ const res = TacticsInputSchema.safeParse(baseInput({
174
+ rotationSystem: '5-1',
175
+ designatedSetters: [],
176
+ systemSets: [{ rotationSystem: '6-0', designatedSetters: [] }]
177
+ }));
178
+ expect(res.success).toBe(false);
179
+ if (!res.success) {
180
+ const issue = res.error.issues.find(i => i.message === 'DESIGNATED_SETTERS_COUNT_5-1_EXPECTED_1');
181
+ expect(issue?.path).toEqual(['designatedSetters']);
182
+ }
183
+ });
184
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "volleyballsimtypes",
3
- "version": "0.0.459",
3
+ "version": "0.0.463",
4
4
  "description": "vbsim types",
5
5
  "main": "./dist/cjs/src/index.js",
6
6
  "module": "./dist/esm/src/index.js",