volleyballsimtypes 0.0.481 → 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 (33) 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/team/blocking-preference.d.ts +5 -0
  7. package/dist/cjs/src/service/team/blocking-preference.js +2 -0
  8. package/dist/cjs/src/service/team/index.d.ts +1 -0
  9. package/dist/cjs/src/service/team/index.js +1 -0
  10. package/dist/cjs/src/service/team/offensive-preference.d.ts +1 -2
  11. package/dist/cjs/src/service/team/schemas/tactics.z.d.ts +11 -3
  12. package/dist/cjs/src/service/team/schemas/tactics.z.js +43 -10
  13. package/dist/cjs/src/service/team/schemas/tactics.z.test.js +81 -8
  14. package/dist/cjs/src/service/team/schemas/team.z.d.ts +10 -2
  15. package/dist/cjs/src/service/team/tactics.d.ts +5 -0
  16. package/dist/cjs/src/service/team/tactics.js +3 -1
  17. package/dist/esm/src/api/index.d.ts +8 -2
  18. package/dist/esm/src/data/models/tactics.d.ts +11 -3
  19. package/dist/esm/src/data/models/tactics.js +9 -0
  20. package/dist/esm/src/data/transformers/tactics.js +12 -4
  21. package/dist/esm/src/data/transformers/tactics.test.js +24 -10
  22. package/dist/esm/src/service/team/blocking-preference.d.ts +5 -0
  23. package/dist/esm/src/service/team/blocking-preference.js +1 -0
  24. package/dist/esm/src/service/team/index.d.ts +1 -0
  25. package/dist/esm/src/service/team/index.js +1 -0
  26. package/dist/esm/src/service/team/offensive-preference.d.ts +1 -2
  27. package/dist/esm/src/service/team/schemas/tactics.z.d.ts +11 -3
  28. package/dist/esm/src/service/team/schemas/tactics.z.js +44 -11
  29. package/dist/esm/src/service/team/schemas/tactics.z.test.js +81 -8
  30. package/dist/esm/src/service/team/schemas/team.z.d.ts +10 -2
  31. package/dist/esm/src/service/team/tactics.d.ts +5 -0
  32. package/dist/esm/src/service/team/tactics.js +3 -1
  33. package/package.json +1 -1
@@ -18,7 +18,7 @@ class Tactics {
18
18
  }
19
19
  return new Tactics(result.data);
20
20
  }
21
- constructor({ lineup, liberoReplacements, receiveRotationOffset = false, designatedSubs = [], substitutionBand = energy_band_1.EnergyBand.TIRED, secondLibero, liberoSub, liberoReplacementSets, rotationSystem = rotation_system_1.RotationSystemEnum.SIX_ZERO, designatedSetters = [], offensivePreferences = [], systemSets, offensivePreferenceSets, replaceKnockedImmediately = {}, injuryReplacements = [], liberoInjury }) {
21
+ constructor({ lineup, liberoReplacements, receiveRotationOffset = false, designatedSubs = [], substitutionBand = energy_band_1.EnergyBand.TIRED, secondLibero, liberoSub, liberoReplacementSets, rotationSystem = rotation_system_1.RotationSystemEnum.SIX_ZERO, designatedSetters = [], offensivePreferences = [], systemSets, offensivePreferenceSets, replaceKnockedImmediately = {}, injuryReplacements = [], liberoInjury, blockingPreferences = [], blockingPreferenceSets }) {
22
22
  this.lineup = lineup;
23
23
  this.liberoReplacements = liberoReplacements;
24
24
  this.receiveRotationOffset = receiveRotationOffset;
@@ -35,6 +35,8 @@ class Tactics {
35
35
  this.replaceKnockedImmediately = replaceKnockedImmediately;
36
36
  this.injuryReplacements = injuryReplacements;
37
37
  this.liberoInjury = liberoInjury;
38
+ this.blockingPreferences = blockingPreferences;
39
+ this.blockingPreferenceSets = blockingPreferenceSets;
38
40
  }
39
41
  findPlayerInLineup(id) {
40
42
  if (this.lineup.bench.some(p => p.id === id))
@@ -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>;
@@ -83,6 +83,15 @@ export class TacticsModel extends Model {
83
83
  libero_injury: {
84
84
  type: DataTypes.JSONB,
85
85
  allowNull: true
86
+ },
87
+ blocking_preferences: {
88
+ type: DataTypes.JSONB,
89
+ allowNull: false,
90
+ defaultValue: []
91
+ },
92
+ blocking_preference_sets: {
93
+ type: DataTypes.JSONB,
94
+ allowNull: true
86
95
  }
87
96
  }, {
88
97
  sequelize,
@@ -86,7 +86,7 @@ function transformToAttributes(tactics, teamId) {
86
86
  designated_setters: tactics.designatedSetters.map((s) => s.id),
87
87
  offensive_preferences: tactics.offensivePreferences.map(p => ({
88
88
  rotation: p.rotation,
89
- order: p.order.map((a) => a.id),
89
+ weights: p.weights,
90
90
  tempos: p.tempos
91
91
  })),
92
92
  system_sets: tactics.systemSets?.map(s => ({
@@ -95,9 +95,11 @@ function transformToAttributes(tactics, teamId) {
95
95
  })),
96
96
  offensive_preference_sets: tactics.offensivePreferenceSets?.map(prefs => prefs.map(p => ({
97
97
  rotation: p.rotation,
98
- order: p.order.map((a) => a.id),
98
+ weights: p.weights,
99
99
  tempos: p.tempos
100
100
  }))),
101
+ blocking_preferences: tactics.blockingPreferences.map(p => ({ rotation: p.rotation, targets: p.targets })),
102
+ blocking_preference_sets: tactics.blockingPreferenceSets?.map(prefs => prefs.map(p => ({ rotation: p.rotation, targets: p.targets }))),
101
103
  replace_knocked_immediately: tactics.replaceKnockedImmediately,
102
104
  injury_replacements: tactics.injuryReplacements.map(ir => ({
103
105
  starterId: ir.starter.id,
@@ -186,7 +188,7 @@ function transformToObject(model, roster) {
186
188
  designatedSetters: (model.designated_setters ?? []).map((id) => findPlayer(id, roster)),
187
189
  offensivePreferences: (model.offensive_preferences ?? []).map(p => ({
188
190
  rotation: p.rotation,
189
- order: p.order.map((id) => findPlayer(id, roster)),
191
+ weights: p.weights,
190
192
  tempos: p.tempos
191
193
  })),
192
194
  systemSets: model.system_sets?.map(s => ({
@@ -195,9 +197,11 @@ function transformToObject(model, roster) {
195
197
  })),
196
198
  offensivePreferenceSets: model.offensive_preference_sets?.map(prefs => prefs.map(p => ({
197
199
  rotation: p.rotation,
198
- order: p.order.map((id) => findPlayer(id, roster)),
200
+ weights: p.weights,
199
201
  tempos: p.tempos
200
202
  }))),
203
+ blockingPreferences: (model.blocking_preferences ?? []).map(p => ({ rotation: p.rotation, targets: p.targets })),
204
+ blockingPreferenceSets: model.blocking_preference_sets?.map(prefs => prefs.map(p => ({ rotation: p.rotation, targets: p.targets }))),
201
205
  // LENIENT: keep only boolean entries keyed by a court starter or, when a second libero exists, the starting
202
206
  // libero (the libero knock toggle is an L2-only feature, owner 2026-07-06) so stale jsonb (an ex-starter's
203
207
  // key, a non-boolean value, a libero key left after the L2 was removed) can never fail schema validation
@@ -354,6 +358,8 @@ function tacticsColumnsToApiDto(model) {
354
358
  offensivePreferences: model.offensive_preferences,
355
359
  systemSets: model.system_sets,
356
360
  offensivePreferenceSets: model.offensive_preference_sets,
361
+ blockingPreferences: model.blocking_preferences,
362
+ blockingPreferenceSets: model.blocking_preference_sets,
357
363
  // Same stale-data policy as the heals below: only boolean entries keyed by a court starter or, when a second
358
364
  // libero exists, the starting libero (the libero knock toggle is an L2-only feature, owner 2026-07-06)
359
365
  // survive; an empty map surfaces as absent (missing key = ON).
@@ -459,6 +465,8 @@ function apiTacticsConfigToAttributes(config, teamId) {
459
465
  offensive_preferences: config.offensivePreferences,
460
466
  system_sets: config.systemSets,
461
467
  offensive_preference_sets: config.offensivePreferenceSets,
468
+ blocking_preferences: config.blockingPreferences ?? [],
469
+ blocking_preference_sets: config.blockingPreferenceSets,
462
470
  replace_knocked_immediately: config.replaceKnockedImmediately,
463
471
  injury_replacements: config.injuryReplacements,
464
472
  libero_injury: config.liberoInjury
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect } from '@jest/globals';
2
2
  import { tacticsColumnsToApiDto, transformFromTactics, transformToTactics } from './tactics';
3
- import { CourtPosition, EnergyBand, RotationSystemEnum, Tactics } from '../../service';
3
+ import { CourtPosition, EnergyBand, RoleEnum, RotationSystemEnum, Tactics } from '../../service';
4
4
  import { makeCountry, makePlayer } from '../../service/test-helpers';
5
5
  // Minimal TacticsModel-shaped object; cast through unknown since the transformer only reads columns.
6
6
  function makeTactics(designatedSubs) {
@@ -24,7 +24,8 @@ function makeTactics(designatedSubs) {
24
24
  libero_replacement_sets: undefined,
25
25
  rotation_system: '5-1',
26
26
  designated_setters: ['lf'],
27
- offensive_preferences: [{ rotation: 1, order: ['lf', 'rf'] }]
27
+ offensive_preferences: [{ rotation: 1, weights: { lf: 60, rf: 40 } }],
28
+ blocking_preferences: []
28
29
  };
29
30
  }
30
31
  describe('tacticsColumnsToApiDto()', () => {
@@ -35,7 +36,7 @@ describe('tacticsColumnsToApiDto()', () => {
35
36
  expect(dto.rotationSystem).toBe('5-1');
36
37
  expect(dto.liberoReplacements).toEqual(['lf']);
37
38
  expect(dto.lineup.bench).toEqual(['b1', 'b2']);
38
- expect(dto.offensivePreferences).toEqual([{ rotation: 1, order: ['lf', 'rf'] }]);
39
+ expect(dto.offensivePreferences).toEqual([{ rotation: 1, weights: { lf: 60, rf: 40 } }]);
39
40
  });
40
41
  it("resolves a legacy isPinchServer entry to mode 'PINCH'", () => {
41
42
  const dto = tacticsColumnsToApiDto(makeTactics([
@@ -125,10 +126,10 @@ describe('tacticsColumnsToApiDto()', () => {
125
126
  it('passes per-set tactics columns through to the API DTO shape', () => {
126
127
  const model = makeTactics([]);
127
128
  model.system_sets = [{ rotationSystem: RotationSystemEnum.FIVE_ONE, designatedSetters: ['lf'] }];
128
- model.offensive_preference_sets = [[{ rotation: 1, order: ['rf', 'mf'] }]];
129
+ model.offensive_preference_sets = [[{ rotation: 1, weights: { rf: 55, mf: 45 } }]];
129
130
  const dto = tacticsColumnsToApiDto(model);
130
131
  expect(dto.systemSets).toEqual([{ rotationSystem: '5-1', designatedSetters: ['lf'] }]);
131
- expect(dto.offensivePreferenceSets).toEqual([[{ rotation: 1, order: ['rf', 'mf'] }]]);
132
+ expect(dto.offensivePreferenceSets).toEqual([[{ rotation: 1, weights: { rf: 55, mf: 45 } }]]);
132
133
  });
133
134
  it('leaves the per-set DTO fields undefined when the columns are absent', () => {
134
135
  const dto = tacticsColumnsToApiDto(makeTactics([]));
@@ -152,7 +153,7 @@ describe('per-set tactics — service <-> row round-trip', () => {
152
153
  { rotationSystem: RotationSystemEnum.FIVE_ONE, designatedSetters: [p1] },
153
154
  { rotationSystem: RotationSystemEnum.SIX_ZERO, designatedSetters: [] }
154
155
  ],
155
- offensivePreferenceSets: [[{ rotation: 1, order: [p2, p3] }]]
156
+ offensivePreferenceSets: [[{ rotation: 1, weights: { [p2.id]: 60, [p3.id]: 40 } }]]
156
157
  });
157
158
  it('transformFromTactics writes the per-set overrides as id-based jsonb columns', () => {
158
159
  const attrs = transformFromTactics(tactics, 'team-1');
@@ -160,7 +161,7 @@ describe('per-set tactics — service <-> row round-trip', () => {
160
161
  { rotationSystem: '5-1', designatedSetters: [p1.id] },
161
162
  { rotationSystem: '6-0', designatedSetters: [] }
162
163
  ]);
163
- expect(attrs.offensive_preference_sets).toEqual([[{ rotation: 1, order: [p2.id, p3.id] }]]);
164
+ expect(attrs.offensive_preference_sets).toEqual([[{ rotation: 1, weights: { [p2.id]: 60, [p3.id]: 40 } }]]);
164
165
  });
165
166
  it('transformToTactics resolves the stored ids back to roster players', () => {
166
167
  const attrs = transformFromTactics(tactics, 'team-1');
@@ -168,7 +169,7 @@ describe('per-set tactics — service <-> row round-trip', () => {
168
169
  expect(back.systemSets?.[0].rotationSystem).toBe(RotationSystemEnum.FIVE_ONE);
169
170
  expect(back.systemSets?.[0].designatedSetters[0].id).toBe(p1.id);
170
171
  expect(back.systemSets?.[1].designatedSetters).toEqual([]);
171
- expect(back.offensivePreferenceSets?.[0][0].order.map(p => p.id)).toEqual([p2.id, p3.id]);
172
+ expect(back.offensivePreferenceSets?.[0][0].weights).toEqual({ [p2.id]: 60, [p3.id]: 40 });
172
173
  });
173
174
  it('omits the per-set columns entirely for an all-sets config', () => {
174
175
  const flat = Tactics.create({
@@ -186,15 +187,28 @@ describe('per-set tactics — service <-> row round-trip', () => {
186
187
  const withTempos = Tactics.create({
187
188
  lineup: { 4: p4, 3: p3, 2: p2, 5: p5, 6: p6, 1: p1, bench: [] },
188
189
  liberoReplacements: [],
189
- offensivePreferences: [{ rotation: 1, order: [p2, p3], tempos: { [p2.id]: 'FAST', [p3.id]: 'HIGH' } }]
190
+ offensivePreferences: [{ rotation: 1, weights: { [p2.id]: 60, [p3.id]: 40 }, tempos: { [p2.id]: 'FAST', [p3.id]: 'HIGH' } }]
190
191
  });
191
192
  const attrs = transformFromTactics(withTempos, 'team-1');
192
193
  expect(attrs.offensive_preferences).toEqual([
193
- { rotation: 1, order: [p2.id, p3.id], tempos: { [p2.id]: 'FAST', [p3.id]: 'HIGH' } }
194
+ { rotation: 1, weights: { [p2.id]: 60, [p3.id]: 40 }, tempos: { [p2.id]: 'FAST', [p3.id]: 'HIGH' } }
194
195
  ]);
195
196
  const back = transformToTactics(attrs, roster);
196
197
  expect(back.offensivePreferences[0].tempos).toEqual({ [p2.id]: 'FAST', [p3.id]: 'HIGH' });
197
198
  });
199
+ it('round-trips blocking preferences as id-keyed function maps', () => {
200
+ const withBlocking = Tactics.create({
201
+ lineup: { 4: p4, 3: p3, 2: p2, 5: p5, 6: p6, 1: p1, bench: [] },
202
+ liberoReplacements: [],
203
+ blockingPreferences: [{ rotation: 1, targets: { [p4.id]: RoleEnum.OUTSIDE_HITTER, [p3.id]: RoleEnum.MIDDLE_BLOCKER } }]
204
+ });
205
+ const attrs = transformFromTactics(withBlocking, 'team-1');
206
+ expect(attrs.blocking_preferences).toEqual([
207
+ { rotation: 1, targets: { [p4.id]: RoleEnum.OUTSIDE_HITTER, [p3.id]: RoleEnum.MIDDLE_BLOCKER } }
208
+ ]);
209
+ const back = transformToTactics(attrs, roster);
210
+ expect(back.blockingPreferences[0].targets).toEqual({ [p4.id]: RoleEnum.OUTSIDE_HITTER, [p3.id]: RoleEnum.MIDDLE_BLOCKER });
211
+ });
198
212
  });
199
213
  describe('libero injuries — service <-> row round-trip + lenient heal', () => {
200
214
  const country = makeCountry();
@@ -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 @@
1
+ export {};
@@ -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';
@@ -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';
@@ -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>;
@@ -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, setterCountFor } from '../rotation-system';
5
5
  import { DesignatedSubSchema, SubBandSchema } from './designated-sub.z';
@@ -47,14 +47,23 @@ const lineupSchema = z.object({
47
47
  });
48
48
  // Per-player tempo preference (set-tempo rework): absent key = Auto.
49
49
  const tempoPreferenceSchema = z.enum(['FAST', 'HIGH']);
50
- // One rotation's ranked attacker priority. `rotation` is the main setter's rotational slot (1..6); `order` lists
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
+ // One rotation's attacker weighting. `rotation` is the main setter's rotational slot (1..6); `weights` maps each
51
+ // attacker's starter id to an integer percentage share (values sum to 100; 0 removes an attacker). `tempos`
52
+ // optionally forces a per-starter tempo (Auto when absent). Cross-field checks (keys are starters, weights sum to
53
+ // 100) live in the superRefine below.
53
54
  const offensivePreferenceSchema = z.object({
54
55
  rotation: z.number().int().min(1).max(6),
55
- order: z.array(playerInstanceSchema),
56
+ weights: z.record(z.string(), z.number().int().min(0)),
56
57
  tempos: z.record(z.string(), tempoPreferenceSchema).optional()
57
58
  });
59
+ // The opposing attacking functions a dedicated blocker can commit to: every RoleEnum except the libero.
60
+ const blockingFunctionSchema = z.nativeEnum(RoleEnum).refine((v) => v !== RoleEnum.LIBERO, { message: 'BLOCKING_TARGET_CANNOT_BE_LIBERO' });
61
+ // One rotation's dedicated-blocking assignments: `targets` maps my front-row starter ids to the opposing function
62
+ // each commits to block. Cross-field checks (keys are starters) live in the superRefine below.
63
+ const blockingPreferenceSchema = z.object({
64
+ rotation: z.number().int().min(1).max(6),
65
+ targets: z.record(z.string(), blockingFunctionSchema)
66
+ });
58
67
  // One set's rotation-system override (per-set tactics): system + its designated setters travel together.
59
68
  const systemSetSchema = z.object({
60
69
  rotationSystem: z.nativeEnum(RotationSystemEnum),
@@ -82,6 +91,9 @@ export const TacticsInputSchema = z.object({
82
91
  // validated with the same cross-field rules as the flat fields (see superRefine below).
83
92
  systemSets: z.array(systemSetSchema).max(5).optional(),
84
93
  offensivePreferenceSets: z.array(z.array(offensivePreferenceSchema)).max(5).optional(),
94
+ // Blocking preferences (dedicated blocking). Defaults keep every existing Tactics.create({...}) call valid.
95
+ blockingPreferences: z.array(blockingPreferenceSchema).default([]),
96
+ blockingPreferenceSets: z.array(z.array(blockingPreferenceSchema)).max(5).optional(),
85
97
  // Injuries. Defaults keep every existing Tactics.create({...}) call valid. replaceKnockedImmediately is
86
98
  // PER STARTER (owner order 2026-07-05): keyed by starter id, missing key = ON; keys validated against the
87
99
  // lineup in the superRefine below. injuryReplacements is the per-starter explicit replacement mapping
@@ -150,16 +162,17 @@ export const TacticsInputSchema = z.object({
150
162
  ctx.addIssue({ code: 'custom', message: 'OFFENSIVE_PREFERENCE_DUPLICATE_ROTATION', path });
151
163
  }
152
164
  seenRotations.add(pref.rotation);
153
- const ids = pref.order.map(p => p.id);
154
- if (new Set(ids).size !== ids.length) {
155
- ctx.addIssue({ code: 'custom', message: 'OFFENSIVE_PREFERENCE_DUPLICATE_ATTACKER', path });
156
- }
157
- for (const id of ids) {
165
+ // Weight keys are on-court starters, and the percentages must sum to exactly 100 (0 removes an attacker).
166
+ for (const id of Object.keys(pref.weights)) {
158
167
  if (!starterIds.has(id)) {
159
168
  ctx.addIssue({ code: 'custom', message: 'OFFENSIVE_PREFERENCE_ATTACKER_NOT_A_STARTER', path });
160
169
  }
161
170
  }
162
- // Tempo preference keys are starter ids too (same rule as order).
171
+ const total = Object.values(pref.weights).reduce((sum, w) => sum + w, 0);
172
+ if (total !== 100) {
173
+ ctx.addIssue({ code: 'custom', message: 'OFFENSIVE_PREFERENCE_WEIGHTS_MUST_SUM_TO_100', path });
174
+ }
175
+ // Tempo preference keys are starter ids too.
163
176
  for (const id of Object.keys(pref.tempos ?? {})) {
164
177
  if (!starterIds.has(id)) {
165
178
  ctx.addIssue({ code: 'custom', message: 'OFFENSIVE_PREFERENCE_TEMPO_NOT_A_STARTER', path });
@@ -167,6 +180,22 @@ export const TacticsInputSchema = z.object({
167
180
  }
168
181
  }
169
182
  };
183
+ // Blocking preferences: unique rotations; each assignment keys an on-court starter (the UI only offers front-row
184
+ // players, the schema checks starters). The target function is validated by blockingFunctionSchema.
185
+ const validateBlockingPreferences = (preferences, path) => {
186
+ const seenRotations = new Set();
187
+ for (const pref of preferences) {
188
+ if (seenRotations.has(pref.rotation)) {
189
+ ctx.addIssue({ code: 'custom', message: 'BLOCKING_PREFERENCE_DUPLICATE_ROTATION', path });
190
+ }
191
+ seenRotations.add(pref.rotation);
192
+ for (const id of Object.keys(pref.targets)) {
193
+ if (!starterIds.has(id)) {
194
+ ctx.addIssue({ code: 'custom', message: 'BLOCKING_PREFERENCE_BLOCKER_NOT_A_STARTER', path });
195
+ }
196
+ }
197
+ }
198
+ };
170
199
  validateSystem(data.rotationSystem, data.designatedSetters, ['designatedSetters']);
171
200
  for (const [i, systemSet] of (data.systemSets ?? []).entries()) {
172
201
  validateSystem(systemSet.rotationSystem, systemSet.designatedSetters, ['systemSets', i]);
@@ -175,6 +204,10 @@ export const TacticsInputSchema = z.object({
175
204
  for (const [i, preferences] of (data.offensivePreferenceSets ?? []).entries()) {
176
205
  validateOffensivePreferences(preferences, ['offensivePreferenceSets', i]);
177
206
  }
207
+ validateBlockingPreferences(data.blockingPreferences, ['blockingPreferences']);
208
+ for (const [i, preferences] of (data.blockingPreferenceSets ?? []).entries()) {
209
+ validateBlockingPreferences(preferences, ['blockingPreferenceSets', i]);
210
+ }
178
211
  // Second libero (L2). The second libero is a reserve (not a starter, the libero, or a bench player). A swap
179
212
  // rule (FATIGUE/ALWAYS in any set) needs someone to swap in, so it requires a second libero, and a second
180
213
  // libero needs a starting libero to replace ("no L2 without an L"). Per-set libero replacements, like the
@@ -1,5 +1,6 @@
1
1
  import { describe, it, expect } from '@jest/globals';
2
2
  import { TacticsInputSchema } from './tactics.z';
3
+ import { RoleEnum } from '../../player';
3
4
  import { makeCountry, makePlayer } from '../../test-helpers';
4
5
  describe('TacticsInputSchema — second libero rules', () => {
5
6
  const country = makeCountry();
@@ -93,7 +94,7 @@ describe('TacticsInputSchema — per-set tactics (systemSets / offensivePreferen
93
94
  { rotationSystem: '4-2', designatedSetters: [p1, p4] }
94
95
  ],
95
96
  offensivePreferenceSets: [
96
- [{ rotation: 1, order: [p2, p3] }],
97
+ [{ rotation: 1, weights: { [p2.id]: 60, [p3.id]: 40 } }],
97
98
  []
98
99
  ]
99
100
  }));
@@ -102,7 +103,7 @@ describe('TacticsInputSchema — per-set tactics (systemSets / offensivePreferen
102
103
  it('carries the per-set fields through Tactics.create parsing', () => {
103
104
  const res = TacticsInputSchema.safeParse(baseInput({
104
105
  systemSets: [{ rotationSystem: '5-1', designatedSetters: [p1] }],
105
- offensivePreferenceSets: [[{ rotation: 2, order: [p3] }]]
106
+ offensivePreferenceSets: [[{ rotation: 2, weights: { [p3.id]: 100 } }]]
106
107
  }));
107
108
  expect(res.success).toBe(true);
108
109
  if (res.success) {
@@ -151,7 +152,7 @@ describe('TacticsInputSchema — per-set tactics (systemSets / offensivePreferen
151
152
  });
152
153
  it('rejects a per-set preferences list with a duplicate rotation', () => {
153
154
  const res = TacticsInputSchema.safeParse(baseInput({
154
- offensivePreferenceSets: [[{ rotation: 1, order: [p2] }, { rotation: 1, order: [p3] }]]
155
+ offensivePreferenceSets: [[{ rotation: 1, weights: { [p2.id]: 100 } }, { rotation: 1, weights: { [p3.id]: 100 } }]]
155
156
  }));
156
157
  expect(res.success).toBe(false);
157
158
  if (!res.success) {
@@ -159,18 +160,27 @@ describe('TacticsInputSchema — per-set tactics (systemSets / offensivePreferen
159
160
  expect(issue?.path).toEqual(['offensivePreferenceSets', 0]);
160
161
  }
161
162
  });
162
- it('rejects a per-set preferences order that lists a non-starter', () => {
163
+ it('rejects a per-set preferences map that keys a non-starter', () => {
163
164
  const res = TacticsInputSchema.safeParse(baseInput({
164
- offensivePreferenceSets: [[{ rotation: 1, order: [reserve] }]]
165
+ offensivePreferenceSets: [[{ rotation: 1, weights: { [reserve.id]: 100 } }]]
165
166
  }));
166
167
  expect(res.success).toBe(false);
167
168
  if (!res.success) {
168
169
  expect(res.error.issues.some(i => i.message === 'OFFENSIVE_PREFERENCE_ATTACKER_NOT_A_STARTER')).toBe(true);
169
170
  }
170
171
  });
172
+ it('rejects offensive preference weights that do not sum to 100', () => {
173
+ const res = TacticsInputSchema.safeParse(baseInput({
174
+ offensivePreferences: [{ rotation: 1, weights: { [p2.id]: 50, [p3.id]: 40 } }]
175
+ }));
176
+ expect(res.success).toBe(false);
177
+ if (!res.success) {
178
+ expect(res.error.issues.some(i => i.message === 'OFFENSIVE_PREFERENCE_WEIGHTS_MUST_SUM_TO_100')).toBe(true);
179
+ }
180
+ });
171
181
  it('accepts offensive preferences with a starter-keyed tempos map', () => {
172
182
  const res = TacticsInputSchema.safeParse(baseInput({
173
- offensivePreferences: [{ rotation: 1, order: [p2, p3], tempos: { [p2.id]: 'FAST', [p3.id]: 'HIGH' } }]
183
+ offensivePreferences: [{ rotation: 1, weights: { [p2.id]: 60, [p3.id]: 40 }, tempos: { [p2.id]: 'FAST', [p3.id]: 'HIGH' } }]
174
184
  }));
175
185
  expect(res.success).toBe(true);
176
186
  if (res.success) {
@@ -179,7 +189,7 @@ describe('TacticsInputSchema — per-set tactics (systemSets / offensivePreferen
179
189
  });
180
190
  it('rejects a tempos map keyed by a non-starter', () => {
181
191
  const res = TacticsInputSchema.safeParse(baseInput({
182
- offensivePreferences: [{ rotation: 1, order: [p2], tempos: { [reserve.id]: 'FAST' } }]
192
+ offensivePreferences: [{ rotation: 1, weights: { [p2.id]: 100 }, tempos: { [reserve.id]: 'FAST' } }]
183
193
  }));
184
194
  expect(res.success).toBe(false);
185
195
  if (!res.success) {
@@ -188,7 +198,7 @@ describe('TacticsInputSchema — per-set tactics (systemSets / offensivePreferen
188
198
  });
189
199
  it('rejects an invalid tempo value', () => {
190
200
  const res = TacticsInputSchema.safeParse(baseInput({
191
- offensivePreferences: [{ rotation: 1, order: [p2], tempos: { [p2.id]: 'SLOW' } }]
201
+ offensivePreferences: [{ rotation: 1, weights: { [p2.id]: 100 }, tempos: { [p2.id]: 'SLOW' } }]
192
202
  }));
193
203
  expect(res.success).toBe(false);
194
204
  });
@@ -206,3 +216,66 @@ describe('TacticsInputSchema — per-set tactics (systemSets / offensivePreferen
206
216
  }
207
217
  });
208
218
  });
219
+ describe('TacticsInputSchema — blocking preferences', () => {
220
+ const country = makeCountry();
221
+ const p1 = makePlayer(country);
222
+ const p2 = makePlayer(country);
223
+ const p3 = makePlayer(country);
224
+ const p4 = makePlayer(country);
225
+ const p5 = makePlayer(country);
226
+ const p6 = makePlayer(country);
227
+ const reserve = makePlayer(country);
228
+ // Front-row starters (zones 2/3/4) are p2, p3, p4; the validator only requires target keys to be starters.
229
+ function baseInput(overrides = {}) {
230
+ return {
231
+ lineup: { 4: p4, 3: p3, 2: p2, 5: p5, 6: p6, 1: p1, bench: [] },
232
+ liberoReplacements: [],
233
+ ...overrides
234
+ };
235
+ }
236
+ it('accepts a blocking preference that keys starters to attacking functions', () => {
237
+ const res = TacticsInputSchema.safeParse(baseInput({
238
+ blockingPreferences: [{ rotation: 1, targets: { [p4.id]: RoleEnum.OUTSIDE_HITTER, [p3.id]: RoleEnum.MIDDLE_BLOCKER } }]
239
+ }));
240
+ expect(res.success).toBe(true);
241
+ if (res.success) {
242
+ expect(res.data.blockingPreferences[0].targets[p4.id]).toBe(RoleEnum.OUTSIDE_HITTER);
243
+ }
244
+ });
245
+ it('rejects a blocking target that is not a starter', () => {
246
+ const res = TacticsInputSchema.safeParse(baseInput({
247
+ blockingPreferences: [{ rotation: 1, targets: { [reserve.id]: RoleEnum.OUTSIDE_HITTER } }]
248
+ }));
249
+ expect(res.success).toBe(false);
250
+ if (!res.success) {
251
+ expect(res.error.issues.some(i => i.message === 'BLOCKING_PREFERENCE_BLOCKER_NOT_A_STARTER')).toBe(true);
252
+ }
253
+ });
254
+ it('rejects a blocking target function of libero', () => {
255
+ const res = TacticsInputSchema.safeParse(baseInput({
256
+ blockingPreferences: [{ rotation: 1, targets: { [p4.id]: RoleEnum.LIBERO } }]
257
+ }));
258
+ expect(res.success).toBe(false);
259
+ if (!res.success) {
260
+ expect(res.error.issues.some(i => i.message === 'BLOCKING_TARGET_CANNOT_BE_LIBERO')).toBe(true);
261
+ }
262
+ });
263
+ it('rejects a duplicate rotation in blocking preferences', () => {
264
+ const res = TacticsInputSchema.safeParse(baseInput({
265
+ blockingPreferences: [
266
+ { rotation: 1, targets: { [p4.id]: RoleEnum.OUTSIDE_HITTER } },
267
+ { rotation: 1, targets: { [p3.id]: RoleEnum.MIDDLE_BLOCKER } }
268
+ ]
269
+ }));
270
+ expect(res.success).toBe(false);
271
+ if (!res.success) {
272
+ expect(res.error.issues.some(i => i.message === 'BLOCKING_PREFERENCE_DUPLICATE_ROTATION')).toBe(true);
273
+ }
274
+ });
275
+ it('accepts per-set blocking preference overrides', () => {
276
+ const res = TacticsInputSchema.safeParse(baseInput({
277
+ blockingPreferenceSets: [[{ rotation: 1, targets: { [p4.id]: RoleEnum.OPPOSITE_HITTER } }], []]
278
+ }));
279
+ expect(res.success).toBe(true);
280
+ });
281
+ });
@@ -277,7 +277,7 @@ export declare const TeamInputSchema: z.ZodObject<{
277
277
  designatedSetters: z.ZodDefault<z.ZodArray<z.ZodCustom<Player, Player>>>;
278
278
  offensivePreferences: z.ZodDefault<z.ZodArray<z.ZodObject<{
279
279
  rotation: z.ZodNumber;
280
- order: z.ZodArray<z.ZodCustom<Player, Player>>;
280
+ weights: z.ZodRecord<z.ZodString, z.ZodNumber>;
281
281
  tempos: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodEnum<{
282
282
  FAST: "FAST";
283
283
  HIGH: "HIGH";
@@ -289,12 +289,20 @@ export declare const TeamInputSchema: z.ZodObject<{
289
289
  }, z.core.$strip>>>;
290
290
  offensivePreferenceSets: z.ZodOptional<z.ZodArray<z.ZodArray<z.ZodObject<{
291
291
  rotation: z.ZodNumber;
292
- order: z.ZodArray<z.ZodCustom<Player, Player>>;
292
+ weights: z.ZodRecord<z.ZodString, z.ZodNumber>;
293
293
  tempos: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodEnum<{
294
294
  FAST: "FAST";
295
295
  HIGH: "HIGH";
296
296
  }>>>;
297
297
  }, z.core.$strip>>>>;
298
+ blockingPreferences: z.ZodDefault<z.ZodArray<z.ZodObject<{
299
+ rotation: z.ZodNumber;
300
+ targets: z.ZodRecord<z.ZodString, z.ZodEnum<typeof import("../../player").RoleEnum> & z.ZodType<import("../../player").RoleEnum.SETTER | import("../../player").RoleEnum.OUTSIDE_HITTER | import("../../player").RoleEnum.OPPOSITE_HITTER | import("../../player").RoleEnum.MIDDLE_BLOCKER, import("../../player").RoleEnum, z.core.$ZodTypeInternals<import("../../player").RoleEnum.SETTER | import("../../player").RoleEnum.OUTSIDE_HITTER | import("../../player").RoleEnum.OPPOSITE_HITTER | import("../../player").RoleEnum.MIDDLE_BLOCKER, import("../../player").RoleEnum>>>;
301
+ }, z.core.$strip>>>;
302
+ blockingPreferenceSets: z.ZodOptional<z.ZodArray<z.ZodArray<z.ZodObject<{
303
+ rotation: z.ZodNumber;
304
+ targets: z.ZodRecord<z.ZodString, z.ZodEnum<typeof import("../../player").RoleEnum> & z.ZodType<import("../../player").RoleEnum.SETTER | import("../../player").RoleEnum.OUTSIDE_HITTER | import("../../player").RoleEnum.OPPOSITE_HITTER | import("../../player").RoleEnum.MIDDLE_BLOCKER, import("../../player").RoleEnum, z.core.$ZodTypeInternals<import("../../player").RoleEnum.SETTER | import("../../player").RoleEnum.OUTSIDE_HITTER | import("../../player").RoleEnum.OPPOSITE_HITTER | import("../../player").RoleEnum.MIDDLE_BLOCKER, import("../../player").RoleEnum>>>;
305
+ }, z.core.$strip>>>>;
298
306
  replaceKnockedImmediately: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
299
307
  injuryReplacements: z.ZodDefault<z.ZodArray<z.ZodObject<{
300
308
  starter: z.ZodCustom<Player, Player>;