volleyballsimtypes 0.0.480 → 0.0.482

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 (37) hide show
  1. package/dist/cjs/src/api/index.d.ts +8 -2
  2. package/dist/cjs/src/data/models/tactics.d.ts +11 -3
  3. package/dist/cjs/src/data/models/tactics.js +9 -0
  4. package/dist/cjs/src/data/transformers/tactics.js +12 -4
  5. package/dist/cjs/src/data/transformers/tactics.test.js +23 -9
  6. package/dist/cjs/src/service/competition/standing.js +5 -0
  7. package/dist/cjs/src/service/competition/standing.test.js +19 -8
  8. package/dist/cjs/src/service/team/blocking-preference.d.ts +5 -0
  9. package/dist/cjs/src/service/team/blocking-preference.js +2 -0
  10. package/dist/cjs/src/service/team/index.d.ts +1 -0
  11. package/dist/cjs/src/service/team/index.js +1 -0
  12. package/dist/cjs/src/service/team/offensive-preference.d.ts +1 -2
  13. package/dist/cjs/src/service/team/schemas/tactics.z.d.ts +11 -3
  14. package/dist/cjs/src/service/team/schemas/tactics.z.js +43 -10
  15. package/dist/cjs/src/service/team/schemas/tactics.z.test.js +81 -8
  16. package/dist/cjs/src/service/team/schemas/team.z.d.ts +10 -2
  17. package/dist/cjs/src/service/team/tactics.d.ts +5 -0
  18. package/dist/cjs/src/service/team/tactics.js +3 -1
  19. package/dist/esm/src/api/index.d.ts +8 -2
  20. package/dist/esm/src/data/models/tactics.d.ts +11 -3
  21. package/dist/esm/src/data/models/tactics.js +9 -0
  22. package/dist/esm/src/data/transformers/tactics.js +12 -4
  23. package/dist/esm/src/data/transformers/tactics.test.js +24 -10
  24. package/dist/esm/src/service/competition/standing.js +5 -0
  25. package/dist/esm/src/service/competition/standing.test.js +19 -8
  26. package/dist/esm/src/service/team/blocking-preference.d.ts +5 -0
  27. package/dist/esm/src/service/team/blocking-preference.js +1 -0
  28. package/dist/esm/src/service/team/index.d.ts +1 -0
  29. package/dist/esm/src/service/team/index.js +1 -0
  30. package/dist/esm/src/service/team/offensive-preference.d.ts +1 -2
  31. package/dist/esm/src/service/team/schemas/tactics.z.d.ts +11 -3
  32. package/dist/esm/src/service/team/schemas/tactics.z.js +44 -11
  33. package/dist/esm/src/service/team/schemas/tactics.z.test.js +81 -8
  34. package/dist/esm/src/service/team/schemas/team.z.d.ts +10 -2
  35. package/dist/esm/src/service/team/tactics.d.ts +5 -0
  36. package/dist/esm/src/service/team/tactics.js +3 -1
  37. package/package.json +1 -1
@@ -1,4 +1,4 @@
1
- import { BoxScore, DataProps, DeclineStatus, Division as _Division, League as _League, Match as _Match, MatchSet as _MatchSet, Player as _Player, PlayerPosition, Qualifier as _Qualifier, QualifierMatch as _QualifierMatch, Rally as _Rally, RallyEvent as _RallyEvent, PointBreakdown, RallyMetrics, Season as _Season, Standing as _Standing, Team as _Team, Tournament as _Tournament, TournamentMatch as _TournamentMatch, National as _National, NationalMatch as _NationalMatch, Country as _Country, CourtPosition, ConditionLogic, EnergyBand, LiberoSubMode, MatchForfeitType, PerformanceStats, PinchCondition, RotationSystemEnum, SubBackMode, SubBand, SubMode } from '../service';
1
+ import { BoxScore, DataProps, DeclineStatus, Division as _Division, League as _League, Match as _Match, MatchSet as _MatchSet, Player as _Player, PlayerPosition, Qualifier as _Qualifier, QualifierMatch as _QualifierMatch, Rally as _Rally, RallyEvent as _RallyEvent, PointBreakdown, RallyMetrics, Season as _Season, Standing as _Standing, Team as _Team, Tournament as _Tournament, TournamentMatch as _TournamentMatch, National as _National, NationalMatch as _NationalMatch, Country as _Country, CourtPosition, ConditionLogic, EnergyBand, LiberoSubMode, MatchForfeitType, PerformanceStats, PinchCondition, RoleEnum, RotationSystemEnum, SubBackMode, SubBand, SubMode } from '../service';
2
2
  export type Rally = DataProps<_Rally> & {
3
3
  homePlayerPosition: PlayerPosition[];
4
4
  awayPlayerPosition: PlayerPosition[];
@@ -108,9 +108,13 @@ export interface ApiDesignatedSub {
108
108
  }
109
109
  export interface ApiOffensivePreference {
110
110
  rotation: number;
111
- order: string[];
111
+ weights: Record<string, number>;
112
112
  tempos?: Record<string, 'FAST' | 'HIGH'>;
113
113
  }
114
+ export interface ApiBlockingPreference {
115
+ rotation: number;
116
+ targets: Record<string, RoleEnum>;
117
+ }
114
118
  export interface ApiSystemSet {
115
119
  rotationSystem: RotationSystemEnum;
116
120
  designatedSetters: string[];
@@ -148,6 +152,8 @@ export interface Tactics {
148
152
  offensivePreferences: ApiOffensivePreference[];
149
153
  systemSets?: ApiSystemSet[];
150
154
  offensivePreferenceSets?: ApiOffensivePreference[][];
155
+ blockingPreferences: ApiBlockingPreference[];
156
+ blockingPreferenceSets?: ApiBlockingPreference[][];
151
157
  replaceKnockedImmediately?: Record<string, boolean>;
152
158
  injuryReplacements?: ApiInjuryReplacement[];
153
159
  liberoInjury?: ApiLiberoInjury;
@@ -1,7 +1,7 @@
1
1
  import * as Sequelize from 'sequelize';
2
2
  import { Model, Optional } from 'sequelize';
3
3
  import { PlayerId, TeamId, TeamModel } from '.';
4
- import { ConditionLogic, CourtPosition, EnergyBand, LiberoSubMode, PinchCondition, RotationSystemEnum, SubBackMode, SubBand, SubMode } from '../../service';
4
+ import { ConditionLogic, CourtPosition, EnergyBand, LiberoSubMode, PinchCondition, RoleEnum, RotationSystemEnum, SubBackMode, SubBand, SubMode } from '../../service';
5
5
  export interface TacticsLineupAttributes {
6
6
  [CourtPosition.LIBERO_ZONE]?: PlayerId;
7
7
  [CourtPosition.LEFT_BACK]: PlayerId;
@@ -41,9 +41,13 @@ export interface DesignatedSubAttributes {
41
41
  }
42
42
  export interface OffensivePreferenceAttributes {
43
43
  rotation: number;
44
- order: PlayerId[];
44
+ weights: Record<PlayerId, number>;
45
45
  tempos?: Record<PlayerId, 'FAST' | 'HIGH'>;
46
46
  }
47
+ export interface BlockingPreferenceAttributes {
48
+ rotation: number;
49
+ targets: Record<PlayerId, RoleEnum>;
50
+ }
47
51
  export interface SystemSetAttributes {
48
52
  rotationSystem: RotationSystemEnum;
49
53
  designatedSetters: PlayerId[];
@@ -85,10 +89,12 @@ export interface TacticsAttributes {
85
89
  replace_knocked_immediately?: Record<PlayerId, boolean>;
86
90
  injury_replacements?: InjuryReplacementAttributes[];
87
91
  libero_injury?: LiberoInjuryAttributes;
92
+ blocking_preferences: BlockingPreferenceAttributes[];
93
+ blocking_preference_sets?: BlockingPreferenceAttributes[][];
88
94
  }
89
95
  export type TacticsPk = 'team_id';
90
96
  export type TacticsId = TacticsModel[TacticsPk];
91
- export type TacticsOptionalAttributes = 'lineup' | 'libero_replacements' | 'receive_rotation_offset' | 'designated_subs' | 'substitution_band' | 'second_libero' | 'libero_sub' | 'libero_replacement_sets' | 'rotation_system' | 'designated_setters' | 'offensive_preferences' | 'system_sets' | 'offensive_preference_sets' | 'replace_knocked_immediately' | 'injury_replacements' | 'libero_injury';
97
+ export type TacticsOptionalAttributes = 'lineup' | 'libero_replacements' | 'receive_rotation_offset' | 'designated_subs' | 'substitution_band' | 'second_libero' | 'libero_sub' | 'libero_replacement_sets' | 'rotation_system' | 'designated_setters' | 'offensive_preferences' | 'system_sets' | 'offensive_preference_sets' | 'replace_knocked_immediately' | 'injury_replacements' | 'libero_injury' | 'blocking_preferences' | 'blocking_preference_sets';
92
98
  export type TacticsCreationAttributes = Optional<TacticsAttributes, TacticsOptionalAttributes>;
93
99
  export declare class TacticsModel extends Model<TacticsAttributes, TacticsCreationAttributes> implements TacticsAttributes {
94
100
  team_id: string;
@@ -108,6 +114,8 @@ export declare class TacticsModel extends Model<TacticsAttributes, TacticsCreati
108
114
  replace_knocked_immediately?: Record<PlayerId, boolean>;
109
115
  injury_replacements?: InjuryReplacementAttributes[];
110
116
  libero_injury?: LiberoInjuryAttributes;
117
+ blocking_preferences: BlockingPreferenceAttributes[];
118
+ blocking_preference_sets?: BlockingPreferenceAttributes[][];
111
119
  team: TeamModel;
112
120
  getTeam: Sequelize.BelongsToGetAssociationMixin<TeamModel>;
113
121
  setTeam: Sequelize.BelongsToSetAssociationMixin<TeamModel, TeamId>;
@@ -86,6 +86,15 @@ class TacticsModel extends sequelize_1.Model {
86
86
  libero_injury: {
87
87
  type: sequelize_1.DataTypes.JSONB,
88
88
  allowNull: true
89
+ },
90
+ blocking_preferences: {
91
+ type: sequelize_1.DataTypes.JSONB,
92
+ allowNull: false,
93
+ defaultValue: []
94
+ },
95
+ blocking_preference_sets: {
96
+ type: sequelize_1.DataTypes.JSONB,
97
+ allowNull: true
89
98
  }
90
99
  }, {
91
100
  sequelize,
@@ -92,7 +92,7 @@ function transformToAttributes(tactics, teamId) {
92
92
  designated_setters: tactics.designatedSetters.map((s) => s.id),
93
93
  offensive_preferences: tactics.offensivePreferences.map(p => ({
94
94
  rotation: p.rotation,
95
- order: p.order.map((a) => a.id),
95
+ weights: p.weights,
96
96
  tempos: p.tempos
97
97
  })),
98
98
  system_sets: tactics.systemSets?.map(s => ({
@@ -101,9 +101,11 @@ function transformToAttributes(tactics, teamId) {
101
101
  })),
102
102
  offensive_preference_sets: tactics.offensivePreferenceSets?.map(prefs => prefs.map(p => ({
103
103
  rotation: p.rotation,
104
- order: p.order.map((a) => a.id),
104
+ weights: p.weights,
105
105
  tempos: p.tempos
106
106
  }))),
107
+ blocking_preferences: tactics.blockingPreferences.map(p => ({ rotation: p.rotation, targets: p.targets })),
108
+ blocking_preference_sets: tactics.blockingPreferenceSets?.map(prefs => prefs.map(p => ({ rotation: p.rotation, targets: p.targets }))),
107
109
  replace_knocked_immediately: tactics.replaceKnockedImmediately,
108
110
  injury_replacements: tactics.injuryReplacements.map(ir => ({
109
111
  starterId: ir.starter.id,
@@ -192,7 +194,7 @@ function transformToObject(model, roster) {
192
194
  designatedSetters: (model.designated_setters ?? []).map((id) => findPlayer(id, roster)),
193
195
  offensivePreferences: (model.offensive_preferences ?? []).map(p => ({
194
196
  rotation: p.rotation,
195
- order: p.order.map((id) => findPlayer(id, roster)),
197
+ weights: p.weights,
196
198
  tempos: p.tempos
197
199
  })),
198
200
  systemSets: model.system_sets?.map(s => ({
@@ -201,9 +203,11 @@ function transformToObject(model, roster) {
201
203
  })),
202
204
  offensivePreferenceSets: model.offensive_preference_sets?.map(prefs => prefs.map(p => ({
203
205
  rotation: p.rotation,
204
- order: p.order.map((id) => findPlayer(id, roster)),
206
+ weights: p.weights,
205
207
  tempos: p.tempos
206
208
  }))),
209
+ blockingPreferences: (model.blocking_preferences ?? []).map(p => ({ rotation: p.rotation, targets: p.targets })),
210
+ blockingPreferenceSets: model.blocking_preference_sets?.map(prefs => prefs.map(p => ({ rotation: p.rotation, targets: p.targets }))),
207
211
  // LENIENT: keep only boolean entries keyed by a court starter or, when a second libero exists, the starting
208
212
  // libero (the libero knock toggle is an L2-only feature, owner 2026-07-06) so stale jsonb (an ex-starter's
209
213
  // key, a non-boolean value, a libero key left after the L2 was removed) can never fail schema validation
@@ -360,6 +364,8 @@ function tacticsColumnsToApiDto(model) {
360
364
  offensivePreferences: model.offensive_preferences,
361
365
  systemSets: model.system_sets,
362
366
  offensivePreferenceSets: model.offensive_preference_sets,
367
+ blockingPreferences: model.blocking_preferences,
368
+ blockingPreferenceSets: model.blocking_preference_sets,
363
369
  // Same stale-data policy as the heals below: only boolean entries keyed by a court starter or, when a second
364
370
  // libero exists, the starting libero (the libero knock toggle is an L2-only feature, owner 2026-07-06)
365
371
  // survive; an empty map surfaces as absent (missing key = ON).
@@ -465,6 +471,8 @@ function apiTacticsConfigToAttributes(config, teamId) {
465
471
  offensive_preferences: config.offensivePreferences,
466
472
  system_sets: config.systemSets,
467
473
  offensive_preference_sets: config.offensivePreferenceSets,
474
+ blocking_preferences: config.blockingPreferences ?? [],
475
+ blocking_preference_sets: config.blockingPreferenceSets,
468
476
  replace_knocked_immediately: config.replaceKnockedImmediately,
469
477
  injury_replacements: config.injuryReplacements,
470
478
  libero_injury: config.liberoInjury
@@ -26,7 +26,8 @@ function makeTactics(designatedSubs) {
26
26
  libero_replacement_sets: undefined,
27
27
  rotation_system: '5-1',
28
28
  designated_setters: ['lf'],
29
- offensive_preferences: [{ rotation: 1, order: ['lf', 'rf'] }]
29
+ offensive_preferences: [{ rotation: 1, weights: { lf: 60, rf: 40 } }],
30
+ blocking_preferences: []
30
31
  };
31
32
  }
32
33
  (0, globals_1.describe)('tacticsColumnsToApiDto()', () => {
@@ -37,7 +38,7 @@ function makeTactics(designatedSubs) {
37
38
  (0, globals_1.expect)(dto.rotationSystem).toBe('5-1');
38
39
  (0, globals_1.expect)(dto.liberoReplacements).toEqual(['lf']);
39
40
  (0, globals_1.expect)(dto.lineup.bench).toEqual(['b1', 'b2']);
40
- (0, globals_1.expect)(dto.offensivePreferences).toEqual([{ rotation: 1, order: ['lf', 'rf'] }]);
41
+ (0, globals_1.expect)(dto.offensivePreferences).toEqual([{ rotation: 1, weights: { lf: 60, rf: 40 } }]);
41
42
  });
42
43
  (0, globals_1.it)("resolves a legacy isPinchServer entry to mode 'PINCH'", () => {
43
44
  const dto = (0, tactics_1.tacticsColumnsToApiDto)(makeTactics([
@@ -127,10 +128,10 @@ function makeTactics(designatedSubs) {
127
128
  (0, globals_1.it)('passes per-set tactics columns through to the API DTO shape', () => {
128
129
  const model = makeTactics([]);
129
130
  model.system_sets = [{ rotationSystem: service_1.RotationSystemEnum.FIVE_ONE, designatedSetters: ['lf'] }];
130
- model.offensive_preference_sets = [[{ rotation: 1, order: ['rf', 'mf'] }]];
131
+ model.offensive_preference_sets = [[{ rotation: 1, weights: { rf: 55, mf: 45 } }]];
131
132
  const dto = (0, tactics_1.tacticsColumnsToApiDto)(model);
132
133
  (0, globals_1.expect)(dto.systemSets).toEqual([{ rotationSystem: '5-1', designatedSetters: ['lf'] }]);
133
- (0, globals_1.expect)(dto.offensivePreferenceSets).toEqual([[{ rotation: 1, order: ['rf', 'mf'] }]]);
134
+ (0, globals_1.expect)(dto.offensivePreferenceSets).toEqual([[{ rotation: 1, weights: { rf: 55, mf: 45 } }]]);
134
135
  });
135
136
  (0, globals_1.it)('leaves the per-set DTO fields undefined when the columns are absent', () => {
136
137
  const dto = (0, tactics_1.tacticsColumnsToApiDto)(makeTactics([]));
@@ -154,7 +155,7 @@ function makeTactics(designatedSubs) {
154
155
  { rotationSystem: service_1.RotationSystemEnum.FIVE_ONE, designatedSetters: [p1] },
155
156
  { rotationSystem: service_1.RotationSystemEnum.SIX_ZERO, designatedSetters: [] }
156
157
  ],
157
- offensivePreferenceSets: [[{ rotation: 1, order: [p2, p3] }]]
158
+ offensivePreferenceSets: [[{ rotation: 1, weights: { [p2.id]: 60, [p3.id]: 40 } }]]
158
159
  });
159
160
  (0, globals_1.it)('transformFromTactics writes the per-set overrides as id-based jsonb columns', () => {
160
161
  const attrs = (0, tactics_1.transformFromTactics)(tactics, 'team-1');
@@ -162,7 +163,7 @@ function makeTactics(designatedSubs) {
162
163
  { rotationSystem: '5-1', designatedSetters: [p1.id] },
163
164
  { rotationSystem: '6-0', designatedSetters: [] }
164
165
  ]);
165
- (0, globals_1.expect)(attrs.offensive_preference_sets).toEqual([[{ rotation: 1, order: [p2.id, p3.id] }]]);
166
+ (0, globals_1.expect)(attrs.offensive_preference_sets).toEqual([[{ rotation: 1, weights: { [p2.id]: 60, [p3.id]: 40 } }]]);
166
167
  });
167
168
  (0, globals_1.it)('transformToTactics resolves the stored ids back to roster players', () => {
168
169
  const attrs = (0, tactics_1.transformFromTactics)(tactics, 'team-1');
@@ -170,7 +171,7 @@ function makeTactics(designatedSubs) {
170
171
  (0, globals_1.expect)(back.systemSets?.[0].rotationSystem).toBe(service_1.RotationSystemEnum.FIVE_ONE);
171
172
  (0, globals_1.expect)(back.systemSets?.[0].designatedSetters[0].id).toBe(p1.id);
172
173
  (0, globals_1.expect)(back.systemSets?.[1].designatedSetters).toEqual([]);
173
- (0, globals_1.expect)(back.offensivePreferenceSets?.[0][0].order.map(p => p.id)).toEqual([p2.id, p3.id]);
174
+ (0, globals_1.expect)(back.offensivePreferenceSets?.[0][0].weights).toEqual({ [p2.id]: 60, [p3.id]: 40 });
174
175
  });
175
176
  (0, globals_1.it)('omits the per-set columns entirely for an all-sets config', () => {
176
177
  const flat = service_1.Tactics.create({
@@ -188,15 +189,28 @@ function makeTactics(designatedSubs) {
188
189
  const withTempos = service_1.Tactics.create({
189
190
  lineup: { 4: p4, 3: p3, 2: p2, 5: p5, 6: p6, 1: p1, bench: [] },
190
191
  liberoReplacements: [],
191
- offensivePreferences: [{ rotation: 1, order: [p2, p3], tempos: { [p2.id]: 'FAST', [p3.id]: 'HIGH' } }]
192
+ offensivePreferences: [{ rotation: 1, weights: { [p2.id]: 60, [p3.id]: 40 }, tempos: { [p2.id]: 'FAST', [p3.id]: 'HIGH' } }]
192
193
  });
193
194
  const attrs = (0, tactics_1.transformFromTactics)(withTempos, 'team-1');
194
195
  (0, globals_1.expect)(attrs.offensive_preferences).toEqual([
195
- { rotation: 1, order: [p2.id, p3.id], tempos: { [p2.id]: 'FAST', [p3.id]: 'HIGH' } }
196
+ { rotation: 1, weights: { [p2.id]: 60, [p3.id]: 40 }, tempos: { [p2.id]: 'FAST', [p3.id]: 'HIGH' } }
196
197
  ]);
197
198
  const back = (0, tactics_1.transformToTactics)(attrs, roster);
198
199
  (0, globals_1.expect)(back.offensivePreferences[0].tempos).toEqual({ [p2.id]: 'FAST', [p3.id]: 'HIGH' });
199
200
  });
201
+ (0, globals_1.it)('round-trips blocking preferences as id-keyed function maps', () => {
202
+ const withBlocking = service_1.Tactics.create({
203
+ lineup: { 4: p4, 3: p3, 2: p2, 5: p5, 6: p6, 1: p1, bench: [] },
204
+ liberoReplacements: [],
205
+ blockingPreferences: [{ rotation: 1, targets: { [p4.id]: service_1.RoleEnum.OUTSIDE_HITTER, [p3.id]: service_1.RoleEnum.MIDDLE_BLOCKER } }]
206
+ });
207
+ const attrs = (0, tactics_1.transformFromTactics)(withBlocking, 'team-1');
208
+ (0, globals_1.expect)(attrs.blocking_preferences).toEqual([
209
+ { rotation: 1, targets: { [p4.id]: service_1.RoleEnum.OUTSIDE_HITTER, [p3.id]: service_1.RoleEnum.MIDDLE_BLOCKER } }
210
+ ]);
211
+ const back = (0, tactics_1.transformToTactics)(attrs, roster);
212
+ (0, globals_1.expect)(back.blockingPreferences[0].targets).toEqual({ [p4.id]: service_1.RoleEnum.OUTSIDE_HITTER, [p3.id]: service_1.RoleEnum.MIDDLE_BLOCKER });
213
+ });
200
214
  });
201
215
  (0, globals_1.describe)('libero injuries — service <-> row round-trip + lenient heal', () => {
202
216
  const country = (0, test_helpers_1.makeCountry)();
@@ -41,6 +41,11 @@ class Standing {
41
41
  // effective ratio here that treats "won with nothing lost" as +Infinity, ranking it highest.
42
42
  const effectiveRatio = (won, lost) => lost > 0 ? won / lost : (won > 0 ? Infinity : 0);
43
43
  return (standingA, standingB) => {
44
+ // VNL/FIVB order: matches WON is the first criterion, then match points, then set ratio, point ratio, h2h.
45
+ if (standingB.matchesWon > standingA.matchesWon)
46
+ return 1;
47
+ if (standingA.matchesWon > standingB.matchesWon)
48
+ return -1;
44
49
  if (standingB.points > standingA.points)
45
50
  return 1;
46
51
  if (standingA.points > standingB.points)
@@ -115,23 +115,34 @@ function makeStanding(teamId, opts = {}) {
115
115
  });
116
116
  // ─── sortFn ──────────────────────────────────────────────────────────────────
117
117
  (0, globals_1.describe)('sortFn()', () => {
118
- (0, globals_1.it)('sorts by points descending', () => {
118
+ (0, globals_1.it)('ranks by matches won ahead of points (VNL first criterion)', () => {
119
119
  const idA = (0, uuid_1.v4)();
120
120
  const idB = (0, uuid_1.v4)();
121
- // A has 3 points (1 win 3-0), B has 0 points
121
+ // A: won32=2 2 wins, 4 points. B: won30=1 + lost23=2 1 win, 5 points.
122
+ // Wins is the first criterion, so A ranks above B even though B has more points.
123
+ const a = makeStanding(idA, { won32: 2 });
124
+ const b = makeStanding(idB, { won30: 1, lost23: 2 });
125
+ const sorted = [b, a].sort(standing_1.Standing.sortFn([]));
126
+ (0, globals_1.expect)(sorted[0].teamId).toBe(idA);
127
+ (0, globals_1.expect)(sorted[1].teamId).toBe(idB);
128
+ });
129
+ (0, globals_1.it)('breaks ties by points when wins are equal', () => {
130
+ const idA = (0, uuid_1.v4)();
131
+ const idB = (0, uuid_1.v4)();
132
+ // Both 1 win. A: won30=1 → 3 points. B: won32=1 → 2 points. Points decides once wins tie.
122
133
  const a = makeStanding(idA, { won30: 1 });
123
- const b = makeStanding(idB);
134
+ const b = makeStanding(idB, { won32: 1 });
124
135
  const sorted = [b, a].sort(standing_1.Standing.sortFn([]));
125
136
  (0, globals_1.expect)(sorted[0].teamId).toBe(idA);
126
137
  (0, globals_1.expect)(sorted[1].teamId).toBe(idB);
127
138
  });
128
- (0, globals_1.it)('breaks ties by setsRatio when points are equal', () => {
139
+ (0, globals_1.it)('breaks ties by setsRatio when wins and points are equal', () => {
129
140
  const idA = (0, uuid_1.v4)();
130
141
  const idB = (0, uuid_1.v4)();
131
- // Both 4 points, but A has better setsRatio:
132
- // A: won32=2points=4, setsWon=6, setsLost=4, setsRatio=1.5
133
- // B: won31=1, lost23=1 → points=(1*3)+(1)=4, setsWon=5, setsLost=4, setsRatio=1.25
134
- const a = makeStanding(idA, { won32: 2 });
142
+ // Both 1 win and 4 points, but A has the better setsRatio:
143
+ // A: won30=1, lost23=1 → setsWon=5, setsLost=3, setsRatio1.67
144
+ // B: won31=1, lost23=1 → setsWon=5, setsLost=4, setsRatio=1.25
145
+ const a = makeStanding(idA, { won30: 1, lost23: 1 });
135
146
  const b = makeStanding(idB, { won31: 1, lost23: 1 });
136
147
  const sorted = [b, a].sort(standing_1.Standing.sortFn([]));
137
148
  (0, globals_1.expect)(sorted[0].teamId).toBe(idA);
@@ -0,0 +1,5 @@
1
+ import { LineupFunction } from './lineup-function';
2
+ export interface BlockingPreference {
3
+ readonly rotation: number;
4
+ readonly targets: Record<string, LineupFunction>;
5
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -11,5 +11,6 @@ export * from './rotation-system';
11
11
  export * from './lineup-function';
12
12
  export * from './base-position';
13
13
  export * from './offensive-preference';
14
+ export * from './blocking-preference';
14
15
  export * from './injury-replacement';
15
16
  export * from './libero-injury';
@@ -27,5 +27,6 @@ __exportStar(require("./rotation-system"), exports);
27
27
  __exportStar(require("./lineup-function"), exports);
28
28
  __exportStar(require("./base-position"), exports);
29
29
  __exportStar(require("./offensive-preference"), exports);
30
+ __exportStar(require("./blocking-preference"), exports);
30
31
  __exportStar(require("./injury-replacement"), exports);
31
32
  __exportStar(require("./libero-injury"), exports);
@@ -1,7 +1,6 @@
1
- import { Player } from '../player';
2
1
  export type TempoPreference = 'FAST' | 'HIGH';
3
2
  export interface OffensivePreference {
4
3
  readonly rotation: number;
5
- readonly order: Player[];
4
+ readonly weights: Record<string, number>;
6
5
  readonly tempos?: Record<string, TempoPreference>;
7
6
  }
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { Player } from '../../player';
2
+ import { Player, RoleEnum } from '../../player';
3
3
  import { EnergyBand } from '../energy-band';
4
4
  import { RotationSystemEnum } from '../rotation-system';
5
5
  export declare const TacticsInputSchema: z.ZodObject<{
@@ -269,7 +269,7 @@ export declare const TacticsInputSchema: z.ZodObject<{
269
269
  designatedSetters: z.ZodDefault<z.ZodArray<z.ZodCustom<Player, Player>>>;
270
270
  offensivePreferences: z.ZodDefault<z.ZodArray<z.ZodObject<{
271
271
  rotation: z.ZodNumber;
272
- order: z.ZodArray<z.ZodCustom<Player, Player>>;
272
+ weights: z.ZodRecord<z.ZodString, z.ZodNumber>;
273
273
  tempos: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodEnum<{
274
274
  FAST: "FAST";
275
275
  HIGH: "HIGH";
@@ -281,12 +281,20 @@ export declare const TacticsInputSchema: z.ZodObject<{
281
281
  }, z.core.$strip>>>;
282
282
  offensivePreferenceSets: z.ZodOptional<z.ZodArray<z.ZodArray<z.ZodObject<{
283
283
  rotation: z.ZodNumber;
284
- order: z.ZodArray<z.ZodCustom<Player, Player>>;
284
+ weights: z.ZodRecord<z.ZodString, z.ZodNumber>;
285
285
  tempos: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodEnum<{
286
286
  FAST: "FAST";
287
287
  HIGH: "HIGH";
288
288
  }>>>;
289
289
  }, z.core.$strip>>>>;
290
+ blockingPreferences: z.ZodDefault<z.ZodArray<z.ZodObject<{
291
+ rotation: z.ZodNumber;
292
+ 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>>>;
293
+ }, z.core.$strip>>>;
294
+ blockingPreferenceSets: z.ZodOptional<z.ZodArray<z.ZodArray<z.ZodObject<{
295
+ rotation: z.ZodNumber;
296
+ 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>>>;
297
+ }, z.core.$strip>>>>;
290
298
  replaceKnockedImmediately: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
291
299
  injuryReplacements: z.ZodDefault<z.ZodArray<z.ZodObject<{
292
300
  starter: z.ZodCustom<Player, Player>;
@@ -50,14 +50,23 @@ const lineupSchema = zod_1.z.object({
50
50
  });
51
51
  // Per-player tempo preference (set-tempo rework): absent key = Auto.
52
52
  const tempoPreferenceSchema = zod_1.z.enum(['FAST', 'HIGH']);
53
- // One rotation's ranked attacker priority. `rotation` is the main setter's rotational slot (1..6); `order` lists
54
- // the attackers ranked best-first. `tempos` optionally forces a per-starter tempo (Auto when absent). Cross-field
55
- // checks (starters only in both order and tempos, no dupes) live in the superRefine below.
53
+ // One rotation's attacker weighting. `rotation` is the main setter's rotational slot (1..6); `weights` maps each
54
+ // attacker's starter id to an integer percentage share (values sum to 100; 0 removes an attacker). `tempos`
55
+ // optionally forces a per-starter tempo (Auto when absent). Cross-field checks (keys are starters, weights sum to
56
+ // 100) live in the superRefine below.
56
57
  const offensivePreferenceSchema = zod_1.z.object({
57
58
  rotation: zod_1.z.number().int().min(1).max(6),
58
- order: zod_1.z.array(playerInstanceSchema),
59
+ weights: zod_1.z.record(zod_1.z.string(), zod_1.z.number().int().min(0)),
59
60
  tempos: zod_1.z.record(zod_1.z.string(), tempoPreferenceSchema).optional()
60
61
  });
62
+ // The opposing attacking functions a dedicated blocker can commit to: every RoleEnum except the libero.
63
+ const blockingFunctionSchema = zod_1.z.nativeEnum(player_1.RoleEnum).refine((v) => v !== player_1.RoleEnum.LIBERO, { message: 'BLOCKING_TARGET_CANNOT_BE_LIBERO' });
64
+ // One rotation's dedicated-blocking assignments: `targets` maps my front-row starter ids to the opposing function
65
+ // each commits to block. Cross-field checks (keys are starters) live in the superRefine below.
66
+ const blockingPreferenceSchema = zod_1.z.object({
67
+ rotation: zod_1.z.number().int().min(1).max(6),
68
+ targets: zod_1.z.record(zod_1.z.string(), blockingFunctionSchema)
69
+ });
61
70
  // One set's rotation-system override (per-set tactics): system + its designated setters travel together.
62
71
  const systemSetSchema = zod_1.z.object({
63
72
  rotationSystem: zod_1.z.nativeEnum(rotation_system_1.RotationSystemEnum),
@@ -85,6 +94,9 @@ exports.TacticsInputSchema = zod_1.z.object({
85
94
  // validated with the same cross-field rules as the flat fields (see superRefine below).
86
95
  systemSets: zod_1.z.array(systemSetSchema).max(5).optional(),
87
96
  offensivePreferenceSets: zod_1.z.array(zod_1.z.array(offensivePreferenceSchema)).max(5).optional(),
97
+ // Blocking preferences (dedicated blocking). Defaults keep every existing Tactics.create({...}) call valid.
98
+ blockingPreferences: zod_1.z.array(blockingPreferenceSchema).default([]),
99
+ blockingPreferenceSets: zod_1.z.array(zod_1.z.array(blockingPreferenceSchema)).max(5).optional(),
88
100
  // Injuries. Defaults keep every existing Tactics.create({...}) call valid. replaceKnockedImmediately is
89
101
  // PER STARTER (owner order 2026-07-05): keyed by starter id, missing key = ON; keys validated against the
90
102
  // lineup in the superRefine below. injuryReplacements is the per-starter explicit replacement mapping
@@ -153,16 +165,17 @@ exports.TacticsInputSchema = zod_1.z.object({
153
165
  ctx.addIssue({ code: 'custom', message: 'OFFENSIVE_PREFERENCE_DUPLICATE_ROTATION', path });
154
166
  }
155
167
  seenRotations.add(pref.rotation);
156
- const ids = pref.order.map(p => p.id);
157
- if (new Set(ids).size !== ids.length) {
158
- ctx.addIssue({ code: 'custom', message: 'OFFENSIVE_PREFERENCE_DUPLICATE_ATTACKER', path });
159
- }
160
- for (const id of ids) {
168
+ // Weight keys are on-court starters, and the percentages must sum to exactly 100 (0 removes an attacker).
169
+ for (const id of Object.keys(pref.weights)) {
161
170
  if (!starterIds.has(id)) {
162
171
  ctx.addIssue({ code: 'custom', message: 'OFFENSIVE_PREFERENCE_ATTACKER_NOT_A_STARTER', path });
163
172
  }
164
173
  }
165
- // Tempo preference keys are starter ids too (same rule as order).
174
+ const total = Object.values(pref.weights).reduce((sum, w) => sum + w, 0);
175
+ if (total !== 100) {
176
+ ctx.addIssue({ code: 'custom', message: 'OFFENSIVE_PREFERENCE_WEIGHTS_MUST_SUM_TO_100', path });
177
+ }
178
+ // Tempo preference keys are starter ids too.
166
179
  for (const id of Object.keys(pref.tempos ?? {})) {
167
180
  if (!starterIds.has(id)) {
168
181
  ctx.addIssue({ code: 'custom', message: 'OFFENSIVE_PREFERENCE_TEMPO_NOT_A_STARTER', path });
@@ -170,6 +183,22 @@ exports.TacticsInputSchema = zod_1.z.object({
170
183
  }
171
184
  }
172
185
  };
186
+ // Blocking preferences: unique rotations; each assignment keys an on-court starter (the UI only offers front-row
187
+ // players, the schema checks starters). The target function is validated by blockingFunctionSchema.
188
+ const validateBlockingPreferences = (preferences, path) => {
189
+ const seenRotations = new Set();
190
+ for (const pref of preferences) {
191
+ if (seenRotations.has(pref.rotation)) {
192
+ ctx.addIssue({ code: 'custom', message: 'BLOCKING_PREFERENCE_DUPLICATE_ROTATION', path });
193
+ }
194
+ seenRotations.add(pref.rotation);
195
+ for (const id of Object.keys(pref.targets)) {
196
+ if (!starterIds.has(id)) {
197
+ ctx.addIssue({ code: 'custom', message: 'BLOCKING_PREFERENCE_BLOCKER_NOT_A_STARTER', path });
198
+ }
199
+ }
200
+ }
201
+ };
173
202
  validateSystem(data.rotationSystem, data.designatedSetters, ['designatedSetters']);
174
203
  for (const [i, systemSet] of (data.systemSets ?? []).entries()) {
175
204
  validateSystem(systemSet.rotationSystem, systemSet.designatedSetters, ['systemSets', i]);
@@ -178,6 +207,10 @@ exports.TacticsInputSchema = zod_1.z.object({
178
207
  for (const [i, preferences] of (data.offensivePreferenceSets ?? []).entries()) {
179
208
  validateOffensivePreferences(preferences, ['offensivePreferenceSets', i]);
180
209
  }
210
+ validateBlockingPreferences(data.blockingPreferences, ['blockingPreferences']);
211
+ for (const [i, preferences] of (data.blockingPreferenceSets ?? []).entries()) {
212
+ validateBlockingPreferences(preferences, ['blockingPreferenceSets', i]);
213
+ }
181
214
  // Second libero (L2). The second libero is a reserve (not a starter, the libero, or a bench player). A swap
182
215
  // rule (FATIGUE/ALWAYS in any set) needs someone to swap in, so it requires a second libero, and a second
183
216
  // libero needs a starting libero to replace ("no L2 without an L"). Per-set libero replacements, like the
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  const globals_1 = require("@jest/globals");
4
4
  const tactics_z_1 = require("./tactics.z");
5
+ const player_1 = require("../../player");
5
6
  const test_helpers_1 = require("../../test-helpers");
6
7
  (0, globals_1.describe)('TacticsInputSchema — second libero rules', () => {
7
8
  const country = (0, test_helpers_1.makeCountry)();
@@ -95,7 +96,7 @@ const test_helpers_1 = require("../../test-helpers");
95
96
  { rotationSystem: '4-2', designatedSetters: [p1, p4] }
96
97
  ],
97
98
  offensivePreferenceSets: [
98
- [{ rotation: 1, order: [p2, p3] }],
99
+ [{ rotation: 1, weights: { [p2.id]: 60, [p3.id]: 40 } }],
99
100
  []
100
101
  ]
101
102
  }));
@@ -104,7 +105,7 @@ const test_helpers_1 = require("../../test-helpers");
104
105
  (0, globals_1.it)('carries the per-set fields through Tactics.create parsing', () => {
105
106
  const res = tactics_z_1.TacticsInputSchema.safeParse(baseInput({
106
107
  systemSets: [{ rotationSystem: '5-1', designatedSetters: [p1] }],
107
- offensivePreferenceSets: [[{ rotation: 2, order: [p3] }]]
108
+ offensivePreferenceSets: [[{ rotation: 2, weights: { [p3.id]: 100 } }]]
108
109
  }));
109
110
  (0, globals_1.expect)(res.success).toBe(true);
110
111
  if (res.success) {
@@ -153,7 +154,7 @@ const test_helpers_1 = require("../../test-helpers");
153
154
  });
154
155
  (0, globals_1.it)('rejects a per-set preferences list with a duplicate rotation', () => {
155
156
  const res = tactics_z_1.TacticsInputSchema.safeParse(baseInput({
156
- offensivePreferenceSets: [[{ rotation: 1, order: [p2] }, { rotation: 1, order: [p3] }]]
157
+ offensivePreferenceSets: [[{ rotation: 1, weights: { [p2.id]: 100 } }, { rotation: 1, weights: { [p3.id]: 100 } }]]
157
158
  }));
158
159
  (0, globals_1.expect)(res.success).toBe(false);
159
160
  if (!res.success) {
@@ -161,18 +162,27 @@ const test_helpers_1 = require("../../test-helpers");
161
162
  (0, globals_1.expect)(issue?.path).toEqual(['offensivePreferenceSets', 0]);
162
163
  }
163
164
  });
164
- (0, globals_1.it)('rejects a per-set preferences order that lists a non-starter', () => {
165
+ (0, globals_1.it)('rejects a per-set preferences map that keys a non-starter', () => {
165
166
  const res = tactics_z_1.TacticsInputSchema.safeParse(baseInput({
166
- offensivePreferenceSets: [[{ rotation: 1, order: [reserve] }]]
167
+ offensivePreferenceSets: [[{ rotation: 1, weights: { [reserve.id]: 100 } }]]
167
168
  }));
168
169
  (0, globals_1.expect)(res.success).toBe(false);
169
170
  if (!res.success) {
170
171
  (0, globals_1.expect)(res.error.issues.some(i => i.message === 'OFFENSIVE_PREFERENCE_ATTACKER_NOT_A_STARTER')).toBe(true);
171
172
  }
172
173
  });
174
+ (0, globals_1.it)('rejects offensive preference weights that do not sum to 100', () => {
175
+ const res = tactics_z_1.TacticsInputSchema.safeParse(baseInput({
176
+ offensivePreferences: [{ rotation: 1, weights: { [p2.id]: 50, [p3.id]: 40 } }]
177
+ }));
178
+ (0, globals_1.expect)(res.success).toBe(false);
179
+ if (!res.success) {
180
+ (0, globals_1.expect)(res.error.issues.some(i => i.message === 'OFFENSIVE_PREFERENCE_WEIGHTS_MUST_SUM_TO_100')).toBe(true);
181
+ }
182
+ });
173
183
  (0, globals_1.it)('accepts offensive preferences with a starter-keyed tempos map', () => {
174
184
  const res = tactics_z_1.TacticsInputSchema.safeParse(baseInput({
175
- offensivePreferences: [{ rotation: 1, order: [p2, p3], tempos: { [p2.id]: 'FAST', [p3.id]: 'HIGH' } }]
185
+ offensivePreferences: [{ rotation: 1, weights: { [p2.id]: 60, [p3.id]: 40 }, tempos: { [p2.id]: 'FAST', [p3.id]: 'HIGH' } }]
176
186
  }));
177
187
  (0, globals_1.expect)(res.success).toBe(true);
178
188
  if (res.success) {
@@ -181,7 +191,7 @@ const test_helpers_1 = require("../../test-helpers");
181
191
  });
182
192
  (0, globals_1.it)('rejects a tempos map keyed by a non-starter', () => {
183
193
  const res = tactics_z_1.TacticsInputSchema.safeParse(baseInput({
184
- offensivePreferences: [{ rotation: 1, order: [p2], tempos: { [reserve.id]: 'FAST' } }]
194
+ offensivePreferences: [{ rotation: 1, weights: { [p2.id]: 100 }, tempos: { [reserve.id]: 'FAST' } }]
185
195
  }));
186
196
  (0, globals_1.expect)(res.success).toBe(false);
187
197
  if (!res.success) {
@@ -190,7 +200,7 @@ const test_helpers_1 = require("../../test-helpers");
190
200
  });
191
201
  (0, globals_1.it)('rejects an invalid tempo value', () => {
192
202
  const res = tactics_z_1.TacticsInputSchema.safeParse(baseInput({
193
- offensivePreferences: [{ rotation: 1, order: [p2], tempos: { [p2.id]: 'SLOW' } }]
203
+ offensivePreferences: [{ rotation: 1, weights: { [p2.id]: 100 }, tempos: { [p2.id]: 'SLOW' } }]
194
204
  }));
195
205
  (0, globals_1.expect)(res.success).toBe(false);
196
206
  });
@@ -208,3 +218,66 @@ const test_helpers_1 = require("../../test-helpers");
208
218
  }
209
219
  });
210
220
  });
221
+ (0, globals_1.describe)('TacticsInputSchema — blocking preferences', () => {
222
+ const country = (0, test_helpers_1.makeCountry)();
223
+ const p1 = (0, test_helpers_1.makePlayer)(country);
224
+ const p2 = (0, test_helpers_1.makePlayer)(country);
225
+ const p3 = (0, test_helpers_1.makePlayer)(country);
226
+ const p4 = (0, test_helpers_1.makePlayer)(country);
227
+ const p5 = (0, test_helpers_1.makePlayer)(country);
228
+ const p6 = (0, test_helpers_1.makePlayer)(country);
229
+ const reserve = (0, test_helpers_1.makePlayer)(country);
230
+ // Front-row starters (zones 2/3/4) are p2, p3, p4; the validator only requires target keys to be starters.
231
+ function baseInput(overrides = {}) {
232
+ return {
233
+ lineup: { 4: p4, 3: p3, 2: p2, 5: p5, 6: p6, 1: p1, bench: [] },
234
+ liberoReplacements: [],
235
+ ...overrides
236
+ };
237
+ }
238
+ (0, globals_1.it)('accepts a blocking preference that keys starters to attacking functions', () => {
239
+ const res = tactics_z_1.TacticsInputSchema.safeParse(baseInput({
240
+ blockingPreferences: [{ rotation: 1, targets: { [p4.id]: player_1.RoleEnum.OUTSIDE_HITTER, [p3.id]: player_1.RoleEnum.MIDDLE_BLOCKER } }]
241
+ }));
242
+ (0, globals_1.expect)(res.success).toBe(true);
243
+ if (res.success) {
244
+ (0, globals_1.expect)(res.data.blockingPreferences[0].targets[p4.id]).toBe(player_1.RoleEnum.OUTSIDE_HITTER);
245
+ }
246
+ });
247
+ (0, globals_1.it)('rejects a blocking target that is not a starter', () => {
248
+ const res = tactics_z_1.TacticsInputSchema.safeParse(baseInput({
249
+ blockingPreferences: [{ rotation: 1, targets: { [reserve.id]: player_1.RoleEnum.OUTSIDE_HITTER } }]
250
+ }));
251
+ (0, globals_1.expect)(res.success).toBe(false);
252
+ if (!res.success) {
253
+ (0, globals_1.expect)(res.error.issues.some(i => i.message === 'BLOCKING_PREFERENCE_BLOCKER_NOT_A_STARTER')).toBe(true);
254
+ }
255
+ });
256
+ (0, globals_1.it)('rejects a blocking target function of libero', () => {
257
+ const res = tactics_z_1.TacticsInputSchema.safeParse(baseInput({
258
+ blockingPreferences: [{ rotation: 1, targets: { [p4.id]: player_1.RoleEnum.LIBERO } }]
259
+ }));
260
+ (0, globals_1.expect)(res.success).toBe(false);
261
+ if (!res.success) {
262
+ (0, globals_1.expect)(res.error.issues.some(i => i.message === 'BLOCKING_TARGET_CANNOT_BE_LIBERO')).toBe(true);
263
+ }
264
+ });
265
+ (0, globals_1.it)('rejects a duplicate rotation in blocking preferences', () => {
266
+ const res = tactics_z_1.TacticsInputSchema.safeParse(baseInput({
267
+ blockingPreferences: [
268
+ { rotation: 1, targets: { [p4.id]: player_1.RoleEnum.OUTSIDE_HITTER } },
269
+ { rotation: 1, targets: { [p3.id]: player_1.RoleEnum.MIDDLE_BLOCKER } }
270
+ ]
271
+ }));
272
+ (0, globals_1.expect)(res.success).toBe(false);
273
+ if (!res.success) {
274
+ (0, globals_1.expect)(res.error.issues.some(i => i.message === 'BLOCKING_PREFERENCE_DUPLICATE_ROTATION')).toBe(true);
275
+ }
276
+ });
277
+ (0, globals_1.it)('accepts per-set blocking preference overrides', () => {
278
+ const res = tactics_z_1.TacticsInputSchema.safeParse(baseInput({
279
+ blockingPreferenceSets: [[{ rotation: 1, targets: { [p4.id]: player_1.RoleEnum.OPPOSITE_HITTER } }], []]
280
+ }));
281
+ (0, globals_1.expect)(res.success).toBe(true);
282
+ });
283
+ });