volleyballsimtypes 0.0.504 → 0.0.505

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 (43) hide show
  1. package/dist/cjs/src/api/index.d.ts +2 -1
  2. package/dist/cjs/src/data/models/tactics.d.ts +4 -2
  3. package/dist/cjs/src/data/models/tactics.js +4 -0
  4. package/dist/cjs/src/data/transformers/rally-event-injury.test.js +1 -1
  5. package/dist/cjs/src/data/transformers/rally-event-positional.test.js +10 -2
  6. package/dist/cjs/src/data/transformers/rally-event.js +3 -1
  7. package/dist/cjs/src/data/transformers/rally.test.js +2 -2
  8. package/dist/cjs/src/data/transformers/tactics.js +4 -0
  9. package/dist/cjs/src/data/transformers/tactics.test.js +24 -0
  10. package/dist/cjs/src/service/event/schemas/spike.z.js +3 -1
  11. package/dist/cjs/src/service/event/spike.d.ts +2 -4
  12. package/dist/cjs/src/service/event/spike.js +5 -3
  13. package/dist/cjs/src/service/team/base-config.d.ts +10 -0
  14. package/dist/cjs/src/service/team/base-config.js +14 -0
  15. package/dist/cjs/src/service/team/index.d.ts +1 -0
  16. package/dist/cjs/src/service/team/index.js +1 -0
  17. package/dist/cjs/src/service/team/schemas/tactics.z.d.ts +7 -0
  18. package/dist/cjs/src/service/team/schemas/tactics.z.js +11 -0
  19. package/dist/cjs/src/service/team/schemas/team.z.d.ts +7 -0
  20. package/dist/cjs/src/service/team/tactics.d.ts +3 -0
  21. package/dist/cjs/src/service/team/tactics.js +2 -1
  22. package/dist/esm/src/api/index.d.ts +2 -1
  23. package/dist/esm/src/data/models/tactics.d.ts +4 -2
  24. package/dist/esm/src/data/models/tactics.js +4 -0
  25. package/dist/esm/src/data/transformers/rally-event-injury.test.js +1 -1
  26. package/dist/esm/src/data/transformers/rally-event-positional.test.js +10 -2
  27. package/dist/esm/src/data/transformers/rally-event.js +3 -1
  28. package/dist/esm/src/data/transformers/rally.test.js +2 -2
  29. package/dist/esm/src/data/transformers/tactics.js +4 -0
  30. package/dist/esm/src/data/transformers/tactics.test.js +24 -0
  31. package/dist/esm/src/service/event/schemas/spike.z.js +3 -1
  32. package/dist/esm/src/service/event/spike.d.ts +2 -4
  33. package/dist/esm/src/service/event/spike.js +5 -3
  34. package/dist/esm/src/service/team/base-config.d.ts +10 -0
  35. package/dist/esm/src/service/team/base-config.js +11 -0
  36. package/dist/esm/src/service/team/index.d.ts +1 -0
  37. package/dist/esm/src/service/team/index.js +1 -0
  38. package/dist/esm/src/service/team/schemas/tactics.z.d.ts +7 -0
  39. package/dist/esm/src/service/team/schemas/tactics.z.js +11 -0
  40. package/dist/esm/src/service/team/schemas/team.z.d.ts +7 -0
  41. package/dist/esm/src/service/team/tactics.d.ts +3 -0
  42. package/dist/esm/src/service/team/tactics.js +2 -1
  43. 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, BlockingAssignment, CourtPosition, ConditionLogic, EnergyBand, MatchForfeitType, PerformanceStats, PinchCondition, RotationSystemEnum, SubBackMode, SubBand } from '../service';
1
+ import { BaseConfig, 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, BlockingAssignment, CourtPosition, ConditionLogic, EnergyBand, MatchForfeitType, PerformanceStats, PinchCondition, RotationSystemEnum, SubBackMode, SubBand } from '../service';
2
2
  export * from './tactics-player-refs';
3
3
  export type Rally = DataProps<_Rally> & {
4
4
  homePlayerPosition: PlayerPosition[];
@@ -140,6 +140,7 @@ export interface Tactics {
140
140
  systemSets?: ApiSystemSet[];
141
141
  offensivePreferenceSets?: ApiOffensivePreference[][];
142
142
  serveAttackShares?: Record<string, number>;
143
+ basePositions?: BaseConfig;
143
144
  blockingPreferences: ApiBlockingPreference[];
144
145
  blockingPreferenceSets?: ApiBlockingPreference[][];
145
146
  replaceKnockedImmediately?: Record<string, boolean>;
@@ -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 { BlockingAssignment, ConditionLogic, CourtPosition, EnergyBand, PinchCondition, RotationSystemEnum, SubBackMode, SubBand, SubMode } from '../../service';
4
+ import { BaseConfig, BlockingAssignment, ConditionLogic, CourtPosition, EnergyBand, PinchCondition, RotationSystemEnum, SubBackMode, SubBand, SubMode } from '../../service';
5
5
  export interface TacticsLineupAttributes {
6
6
  [CourtPosition.LIBERO_ZONE]?: PlayerId;
7
7
  [CourtPosition.LEFT_BACK]: PlayerId;
@@ -82,10 +82,11 @@ export interface TacticsAttributes {
82
82
  libero_injury?: LiberoInjuryAttributes;
83
83
  blocking_preferences: BlockingPreferenceAttributes[];
84
84
  blocking_preference_sets?: BlockingPreferenceAttributes[][];
85
+ base_positions?: BaseConfig;
85
86
  }
86
87
  export type TacticsPk = 'team_id';
87
88
  export type TacticsId = TacticsModel[TacticsPk];
88
- 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' | 'serve_attack_shares' | 'replace_knocked_immediately' | 'injury_replacements' | 'libero_injury' | 'blocking_preferences' | 'blocking_preference_sets';
89
+ 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' | 'serve_attack_shares' | 'replace_knocked_immediately' | 'injury_replacements' | 'libero_injury' | 'blocking_preferences' | 'blocking_preference_sets' | 'base_positions';
89
90
  export type TacticsCreationAttributes = Optional<TacticsAttributes, TacticsOptionalAttributes>;
90
91
  export declare class TacticsModel extends Model<TacticsAttributes, TacticsCreationAttributes> implements TacticsAttributes {
91
92
  team_id: string;
@@ -108,6 +109,7 @@ export declare class TacticsModel extends Model<TacticsAttributes, TacticsCreati
108
109
  libero_injury?: LiberoInjuryAttributes;
109
110
  blocking_preferences: BlockingPreferenceAttributes[];
110
111
  blocking_preference_sets?: BlockingPreferenceAttributes[][];
112
+ base_positions?: BaseConfig;
111
113
  team: TeamModel;
112
114
  getTeam: Sequelize.BelongsToGetAssociationMixin<TeamModel>;
113
115
  setTeam: Sequelize.BelongsToSetAssociationMixin<TeamModel, TeamId>;
@@ -100,6 +100,10 @@ class TacticsModel extends sequelize_1.Model {
100
100
  blocking_preference_sets: {
101
101
  type: sequelize_1.DataTypes.JSONB,
102
102
  allowNull: true
103
+ },
104
+ base_positions: {
105
+ type: sequelize_1.DataTypes.JSONB,
106
+ allowNull: true
103
107
  }
104
108
  }, {
105
109
  sequelize,
@@ -41,7 +41,7 @@ const rally_event_1 = require("./rally-event");
41
41
  score: 62.5,
42
42
  target: 4,
43
43
  failure: service_1.SpikeFailureEnum.NO_FAILURE,
44
- type: service_1.SpikeTypeEnum.CROSS_SHOT,
44
+ type: service_1.SpikeTypeEnum.SPIKE,
45
45
  ...(incident != null ? { incident } : {})
46
46
  });
47
47
  }
@@ -14,7 +14,7 @@ const rally_event_1 = require("./rally-event");
14
14
  score: 60,
15
15
  target: 4,
16
16
  failure: service_1.SpikeFailureEnum.NO_FAILURE,
17
- type: service_1.SpikeTypeEnum.CROSS_SHOT,
17
+ type: service_1.SpikeTypeEnum.SPIKE,
18
18
  contact: { x: 1.234, y: -0.5 },
19
19
  landing: { x: -2.5, y: 6.789 },
20
20
  speed: 92.456
@@ -43,7 +43,7 @@ const rally_event_1 = require("./rally-event");
43
43
  (0, globals_1.expect)(decoded.digQuality).toBe(service_1.DigQualityEnum.MEDIOCRE);
44
44
  });
45
45
  (0, globals_1.it)('omits the positional keys when absent, and a legacy event decodes them as undefined', () => {
46
- const spike = service_1.Spike.create({ playerId, score: 60, target: 4, failure: service_1.SpikeFailureEnum.NO_FAILURE, type: service_1.SpikeTypeEnum.CROSS_SHOT });
46
+ const spike = service_1.Spike.create({ playerId, score: 60, target: 4, failure: service_1.SpikeFailureEnum.NO_FAILURE, type: service_1.SpikeTypeEnum.SPIKE });
47
47
  const compact = (0, rally_event_1.transformToCompact)(spike, playerIndex);
48
48
  (0, globals_1.expect)('c' in compact).toBe(false);
49
49
  (0, globals_1.expect)('v' in compact).toBe(false);
@@ -53,4 +53,12 @@ const rally_event_1 = require("./rally-event");
53
53
  (0, globals_1.expect)(decoded.speed).toBeUndefined();
54
54
  (0, globals_1.expect)(decoded.digQuality).toBeUndefined();
55
55
  });
56
+ (0, globals_1.it)('collapses legacy LINE_SHOT(1)/REVERSE_CROSS(2) to SPIKE(0) on decode (owner 2026-08-03)', () => {
57
+ // A zone-engine match persisted before the collapse carries type 1 or 2 in its jsonb; decode heals them to
58
+ // SPIKE so the match still opens. TIP(3) and up are untouched.
59
+ const base = (0, rally_event_1.transformToCompact)(service_1.Spike.create({ playerId, score: 60, target: 4, failure: service_1.SpikeFailureEnum.NO_FAILURE, type: service_1.SpikeTypeEnum.SPIKE }), playerIndex);
60
+ (0, globals_1.expect)((0, rally_event_1.transformToSpike)({ ...base, t: 1 }, roster).type).toBe(service_1.SpikeTypeEnum.SPIKE);
61
+ (0, globals_1.expect)((0, rally_event_1.transformToSpike)({ ...base, t: 2 }, roster).type).toBe(service_1.SpikeTypeEnum.SPIKE);
62
+ (0, globals_1.expect)((0, rally_event_1.transformToSpike)({ ...base, t: 3 }, roster).type).toBe(service_1.SpikeTypeEnum.TIP);
63
+ });
56
64
  });
@@ -138,7 +138,9 @@ function transformToSet(event, roster) {
138
138
  function transformToSpike(event, roster) {
139
139
  return service_1.Spike.create({
140
140
  failure: event.f,
141
- type: event.t,
141
+ // Legacy LINE_SHOT(1)/REVERSE_CROSS(2) collapse to SPIKE(0) (owner 2026-08-03): direction is redundant with
142
+ // the stored contact+landing coords. Maps old zone-engine matches so they keep decoding; new events emit 0/3-6.
143
+ type: event.t === 1 || event.t === 2 ? 0 : event.t,
142
144
  playerId: roster[event.p],
143
145
  target: event.a,
144
146
  score: event.s,
@@ -19,7 +19,7 @@ const rally_1 = require("./rally");
19
19
  service_1.Serve.create({ playerId: pA, target: 6, score: 55, failure: service_1.ServeFailureEnum.NO_FAILURE, type: service_1.ServeTypeEnum.JUMP_TOPSPIN }),
20
20
  service_1.Reception.create({ playerId: pB, target: 5, score: 50, failure: service_1.ReceptionFailureEnum.NO_FAILURE, type: service_1.ReceptionTypeEnum.DIG }),
21
21
  service_1.Set.create({ playerId: pC, target: 3, score: 48, failure: service_1.SetFailureEnum.NO_FAILURE, type: service_1.SetTypeEnum.OVERHAND, tempo: service_1.SetTempoEnum.FIRST }),
22
- service_1.Spike.create({ playerId: pA, target: 4, score: 62.5, failure: service_1.SpikeFailureEnum.NO_FAILURE, type: service_1.SpikeTypeEnum.CROSS_SHOT }),
22
+ service_1.Spike.create({ playerId: pA, target: 4, score: 62.5, failure: service_1.SpikeFailureEnum.NO_FAILURE, type: service_1.SpikeTypeEnum.SPIKE }),
23
23
  service_1.Block.create({ playerId: pB, blockers: [pB, pC], target: 8, score: 48, failure: service_1.BlockFailureEnum.NO_FAILURE, type: service_1.BlockTypeEnum.DOUBLE }),
24
24
  service_1.FreeBall.create({ playerId: pC, target: 6, score: 45, failure: service_1.FreeBallFailureEnum.NO_FAILURE, type: service_1.FreeBallTypeEnum.OVER })
25
25
  ];
@@ -49,7 +49,7 @@ const rally_1 = require("./rally");
49
49
  (0, globals_1.expect)(rec.type).toBe(service_1.ReceptionTypeEnum.DIG);
50
50
  (0, globals_1.expect)(set.type).toBe(service_1.SetTypeEnum.OVERHAND);
51
51
  (0, globals_1.expect)(set.tempo).toBe(service_1.SetTempoEnum.FIRST);
52
- (0, globals_1.expect)(spike.type).toBe(service_1.SpikeTypeEnum.CROSS_SHOT);
52
+ (0, globals_1.expect)(spike.type).toBe(service_1.SpikeTypeEnum.SPIKE);
53
53
  (0, globals_1.expect)(spike.score).toBe(62.5);
54
54
  (0, globals_1.expect)(block.type).toBe(service_1.BlockTypeEnum.DOUBLE);
55
55
  (0, globals_1.expect)(block.blockers).toEqual([pB, pC]);
@@ -86,6 +86,7 @@ function transformToAttributes(tactics, teamId) {
86
86
  tempos: p.tempos
87
87
  }))),
88
88
  serve_attack_shares: tactics.serveAttackShares ?? {},
89
+ base_positions: tactics.basePositions,
89
90
  blocking_preferences: tactics.blockingPreferences.map(p => ({ rotation: p.rotation, targets: p.targets })),
90
91
  blocking_preference_sets: tactics.blockingPreferenceSets?.map(prefs => prefs.map(p => ({ rotation: p.rotation, targets: p.targets }))),
91
92
  replace_knocked_immediately: tactics.replaceKnockedImmediately,
@@ -207,6 +208,7 @@ function transformToObject(model, roster) {
207
208
  }
208
209
  return healed;
209
210
  })(),
211
+ basePositions: model.base_positions ?? undefined,
210
212
  blockingPreferences: (model.blocking_preferences ?? []).map(p => ({ rotation: p.rotation, targets: p.targets })),
211
213
  blockingPreferenceSets: model.blocking_preference_sets?.map(prefs => prefs.map(p => ({ rotation: p.rotation, targets: p.targets }))),
212
214
  // LENIENT: keep only boolean entries keyed by a court starter or, when a second libero exists, the starting
@@ -367,6 +369,7 @@ function tacticsColumnsToApiDto(model) {
367
369
  }
368
370
  return Object.keys(healed).length > 0 ? healed : undefined;
369
371
  })(),
372
+ basePositions: model.base_positions ?? undefined,
370
373
  blockingPreferences: model.blocking_preferences,
371
374
  blockingPreferenceSets: model.blocking_preference_sets,
372
375
  // Same stale-data policy as the heals below: only boolean entries keyed by a court starter or, when a second
@@ -475,6 +478,7 @@ function apiTacticsConfigToAttributes(config, teamId) {
475
478
  system_sets: config.systemSets,
476
479
  offensive_preference_sets: config.offensivePreferenceSets,
477
480
  serve_attack_shares: config.serveAttackShares ?? {},
481
+ base_positions: config.basePositions,
478
482
  blocking_preferences: config.blockingPreferences ?? [],
479
483
  blocking_preference_sets: config.blockingPreferenceSets,
480
484
  replace_knocked_immediately: config.replaceKnockedImmediately,
@@ -227,6 +227,30 @@ function makeTactics(designatedSubs) {
227
227
  const back = (0, tactics_1.transformToTactics)(attrs, roster);
228
228
  (0, globals_1.expect)(back.serveAttackShares).toEqual({});
229
229
  });
230
+ (0, globals_1.it)('round-trips base positions (coords + reception participation)', () => {
231
+ const config = {
232
+ coords: { RECEIVE: { 1: { 5: { d: 5.5, r: -3 }, 6: { d: 6.5, r: 0 } } }, DEFENCE: { 3: { 2: { d: 0.9, r: 3 } } } },
233
+ receive: { 1: { 5: true, 2: false }, 4: { 6: true } }
234
+ };
235
+ const withBases = service_1.Tactics.create({
236
+ lineup: { 4: p4, 3: p3, 2: p2, 5: p5, 6: p6, 1: p1, bench: [] },
237
+ liberoReplacements: [],
238
+ basePositions: config
239
+ });
240
+ const attrs = (0, tactics_1.transformFromTactics)(withBases, 'team-1');
241
+ (0, globals_1.expect)(attrs.base_positions).toEqual(config);
242
+ const back = (0, tactics_1.transformToTactics)(attrs, roster);
243
+ (0, globals_1.expect)(back.basePositions).toEqual(config);
244
+ });
245
+ (0, globals_1.it)('leaves basePositions undefined when the column is absent', () => {
246
+ const attrs = (0, tactics_1.transformFromTactics)(service_1.Tactics.create({
247
+ lineup: { 4: p4, 3: p3, 2: p2, 5: p5, 6: p6, 1: p1, bench: [] },
248
+ liberoReplacements: []
249
+ }), 'team-1');
250
+ (0, globals_1.expect)(attrs.base_positions).toBeUndefined();
251
+ const back = (0, tactics_1.transformToTactics)(attrs, roster);
252
+ (0, globals_1.expect)(back.basePositions).toBeUndefined();
253
+ });
230
254
  (0, globals_1.it)('round-trips a per-starter tempos map on offensive preferences', () => {
231
255
  const withTempos = service_1.Tactics.create({
232
256
  lineup: { 4: p4, 3: p3, 2: p2, 5: p5, 6: p6, 1: p1, bench: [] },
@@ -5,7 +5,9 @@ const zod_1 = require("zod");
5
5
  const player_1 = require("../../player");
6
6
  const incident_z_1 = require("./incident.z");
7
7
  const positional_z_1 = require("./positional.z");
8
- const spikeTypeValues = [0, 1, 2, 3, 4, 5, 6];
8
+ // SPIKE(0), TIP(3), DOWN_BALL(4), OVERHAND(5), TOOL(6). 1/2 (legacy LINE_SHOT/REVERSE_CROSS) are retired; the
9
+ // codec (transformToSpike) maps them to 0 before this validates, so they never reach here.
10
+ const spikeTypeValues = [0, 3, 4, 5, 6];
9
11
  const spikeFailureValues = [0, 1, 2, 3, 4];
10
12
  const courtTargetValues = Array.from({ length: 13 }, (_, i) => i);
11
13
  exports.SpikeInputSchema = zod_1.z.object({
@@ -1,14 +1,12 @@
1
1
  import { InPlayEvent } from './in-play-event';
2
2
  export declare enum SpikeTypeEnum {
3
- CROSS_SHOT = 0,
4
- LINE_SHOT = 1,
5
- REVERSE_CROSS = 2,
3
+ SPIKE = 0,
6
4
  TIP = 3,
7
5
  DOWN_BALL = 4,
8
6
  OVERHAND = 5,
9
7
  TOOL = 6
10
8
  }
11
- export type SpikeType = SpikeTypeEnum.CROSS_SHOT | SpikeTypeEnum.LINE_SHOT | SpikeTypeEnum.REVERSE_CROSS | SpikeTypeEnum.TIP | SpikeTypeEnum.DOWN_BALL | SpikeTypeEnum.OVERHAND | SpikeTypeEnum.TOOL;
9
+ export type SpikeType = SpikeTypeEnum.SPIKE | SpikeTypeEnum.TIP | SpikeTypeEnum.DOWN_BALL | SpikeTypeEnum.OVERHAND | SpikeTypeEnum.TOOL;
12
10
  export declare enum SpikeFailureEnum {
13
11
  NO_FAILURE = 0,
14
12
  FAULT = 1,
@@ -6,9 +6,11 @@ const in_play_event_1 = require("./in-play-event");
6
6
  const spike_z_1 = require("./schemas/spike.z");
7
7
  var SpikeTypeEnum;
8
8
  (function (SpikeTypeEnum) {
9
- SpikeTypeEnum[SpikeTypeEnum["CROSS_SHOT"] = 0] = "CROSS_SHOT";
10
- SpikeTypeEnum[SpikeTypeEnum["LINE_SHOT"] = 1] = "LINE_SHOT";
11
- SpikeTypeEnum[SpikeTypeEnum["REVERSE_CROSS"] = 2] = "REVERSE_CROSS";
9
+ // A hard swing (owner 2026-08-03). The cross / line / reverse DIRECTION is not stored: it is fully derivable
10
+ // from the persisted contact + landing coordinates, so a single SPIKE value replaces the old CROSS_SHOT(0) /
11
+ // LINE_SHOT(1) / REVERSE_CROSS(2). 1 and 2 are retired (left as gaps); the codec maps any legacy 1/2 back to
12
+ // SPIKE(0), so existing (zone-engine) matches keep decoding.
13
+ SpikeTypeEnum[SpikeTypeEnum["SPIKE"] = 0] = "SPIKE";
12
14
  SpikeTypeEnum[SpikeTypeEnum["TIP"] = 3] = "TIP";
13
15
  SpikeTypeEnum[SpikeTypeEnum["DOWN_BALL"] = 4] = "DOWN_BALL";
14
16
  SpikeTypeEnum[SpikeTypeEnum["OVERHAND"] = 5] = "OVERHAND";
@@ -0,0 +1,10 @@
1
+ export type BaseFormation = 'SERVE' | 'RECEIVE' | 'DEFENCE' | 'DEF_L' | 'DEF_M' | 'DEF_R' | 'ATTACK';
2
+ export declare const BASE_FORMATIONS: readonly BaseFormation[];
3
+ export interface BaseCoord {
4
+ readonly d: number;
5
+ readonly r: number;
6
+ }
7
+ export interface BaseConfig {
8
+ readonly coords: Record<string, Record<string, Record<string, BaseCoord>>>;
9
+ readonly receive: Record<string, Record<string, boolean>>;
10
+ }
@@ -0,0 +1,14 @@
1
+ "use strict";
2
+ // Per-team base-position configuration (owner 2026-08-03). Generalises the sim's hardcoded 5-1 preset into a
3
+ // system-agnostic, per-team shape: for each formation, rotation and rotational ZONE, where that zone's player
4
+ // stands, plus (serve-receive only) whether that zone's player passes the serve. Authored in the UI base editor
5
+ // (and populated from a picked default preset); consumed by the positional engine to place players. A team with no
6
+ // `basePositions` falls back to the default preset for its `rotationSystem`.
7
+ //
8
+ // KEYING: rotation 1..6 and zone 1..6 are the ROTATIONAL (at-the-whistle) slots. The stored rotation convention is
9
+ // the ENGINE's (SETTER_ZONE_TO_ROTATION); the UI editor re-keys the mapper's setter-zone rotation to it on save.
10
+ // `coords[formation][rotation][zone]` is the standing coordinate for that formation; `receive[rotation][zone]` is
11
+ // the reception-participation flag (absent zone => default to participating).
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.BASE_FORMATIONS = void 0;
14
+ exports.BASE_FORMATIONS = ['SERVE', 'RECEIVE', 'DEFENCE', 'DEF_L', 'DEF_M', 'DEF_R', 'ATTACK'];
@@ -2,6 +2,7 @@ export * from './team';
2
2
  export * from './team-generator';
3
3
  export * from './formation';
4
4
  export * from './tactics';
5
+ export * from './base-config';
5
6
  export * from './team-name';
6
7
  export * from './energy-band';
7
8
  export * from './pinch-condition';
@@ -18,6 +18,7 @@ __exportStar(require("./team"), exports);
18
18
  __exportStar(require("./team-generator"), exports);
19
19
  __exportStar(require("./formation"), exports);
20
20
  __exportStar(require("./tactics"), exports);
21
+ __exportStar(require("./base-config"), exports);
21
22
  __exportStar(require("./team-name"), exports);
22
23
  __exportStar(require("./energy-band"), exports);
23
24
  __exportStar(require("./pinch-condition"), exports);
@@ -288,6 +288,13 @@ export declare const TacticsInputSchema: z.ZodObject<{
288
288
  rotation: z.ZodNumber;
289
289
  targets: z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodEnum<typeof RoleEnum> & z.ZodType<RoleEnum.SETTER | RoleEnum.OUTSIDE_HITTER | RoleEnum.OPPOSITE_HITTER | RoleEnum.MIDDLE_BLOCKER, RoleEnum, z.core.$ZodTypeInternals<RoleEnum.SETTER | RoleEnum.OUTSIDE_HITTER | RoleEnum.OPPOSITE_HITTER | RoleEnum.MIDDLE_BLOCKER, RoleEnum>>, z.ZodLiteral<"NO_BLOCK">]>>;
290
290
  }, z.core.$strip>>>>;
291
+ basePositions: z.ZodOptional<z.ZodObject<{
292
+ coords: z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodObject<{
293
+ d: z.ZodNumber;
294
+ r: z.ZodNumber;
295
+ }, z.core.$strip>>>>;
296
+ receive: z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodBoolean>>;
297
+ }, z.core.$strip>>;
291
298
  replaceKnockedImmediately: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
292
299
  injuryReplacements: z.ZodDefault<z.ZodArray<z.ZodObject<{
293
300
  starter: z.ZodCustom<Player, Player>;
@@ -77,6 +77,15 @@ const systemSetSchema = zod_1.z.object({
77
77
  rotationSystem: zod_1.z.nativeEnum(rotation_system_1.RotationSystemEnum),
78
78
  designatedSetters: zod_1.z.array(playerInstanceSchema)
79
79
  });
80
+ // Base positions (owner 2026-08-03): coords[formation][rotation][zone] -> {d,r} + receive[rotation][zone] -> bool.
81
+ // jsonb keys are strings; the coordinate + boolean leaves are validated. Structural key ranges (formation names,
82
+ // rotations 1-6, zones 1-6) are trusted from the UI editor / preset compiler, which write the engine rotation
83
+ // convention. Out-of-court coordinates are allowed, so d/r are not range-clamped here.
84
+ const baseCoordSchema = zod_1.z.object({ d: zod_1.z.number().finite(), r: zod_1.z.number().finite() });
85
+ const baseConfigSchema = zod_1.z.object({
86
+ coords: zod_1.z.record(zod_1.z.string(), zod_1.z.record(zod_1.z.string(), zod_1.z.record(zod_1.z.string(), baseCoordSchema))),
87
+ receive: zod_1.z.record(zod_1.z.string(), zod_1.z.record(zod_1.z.string(), zod_1.z.boolean()))
88
+ });
80
89
  exports.TacticsInputSchema = zod_1.z.object({
81
90
  lineup: lineupSchema,
82
91
  liberoReplacements: zod_1.z.array(playerInstanceSchema),
@@ -107,6 +116,8 @@ exports.TacticsInputSchema = zod_1.z.object({
107
116
  // Blocking preferences (dedicated blocking). Defaults keep every existing Tactics.create({...}) call valid.
108
117
  blockingPreferences: zod_1.z.array(blockingPreferenceSchema).default([]),
109
118
  blockingPreferenceSets: zod_1.z.array(zod_1.z.array(blockingPreferenceSchema)).max(5).optional(),
119
+ // Base positions (owner 2026-08-03). Optional -> the sim falls back to the default preset for the system.
120
+ basePositions: baseConfigSchema.optional(),
110
121
  // Injuries. Defaults keep every existing Tactics.create({...}) call valid. replaceKnockedImmediately is
111
122
  // PER STARTER (owner order 2026-07-05): keyed by starter id, missing key = ON; keys validated against the
112
123
  // lineup in the superRefine below. injuryReplacements is the per-starter explicit replacement mapping
@@ -296,6 +296,13 @@ export declare const TeamInputSchema: z.ZodObject<{
296
296
  rotation: z.ZodNumber;
297
297
  targets: z.ZodRecord<z.ZodString, z.ZodUnion<readonly [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>>, z.ZodLiteral<"NO_BLOCK">]>>;
298
298
  }, z.core.$strip>>>>;
299
+ basePositions: z.ZodOptional<z.ZodObject<{
300
+ coords: z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodObject<{
301
+ d: z.ZodNumber;
302
+ r: z.ZodNumber;
303
+ }, z.core.$strip>>>>;
304
+ receive: z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodBoolean>>;
305
+ }, z.core.$strip>>;
299
306
  replaceKnockedImmediately: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
300
307
  injuryReplacements: z.ZodDefault<z.ZodArray<z.ZodObject<{
301
308
  starter: z.ZodCustom<Player, Player>;
@@ -8,6 +8,7 @@ import { OffensivePreference } from './offensive-preference';
8
8
  import { BlockingPreference } from './blocking-preference';
9
9
  import { InjuryReplacement } from './injury-replacement';
10
10
  import { LiberoInjuryConfig } from './libero-injury';
11
+ import { BaseConfig } from './base-config';
11
12
  export interface StartingLineup {
12
13
  readonly [CourtPosition.LEFT_FRONT]: Player;
13
14
  readonly [CourtPosition.MIDDLE_FRONT]: Player;
@@ -42,6 +43,7 @@ export interface TacticsOpts {
42
43
  readonly liberoInjury?: LiberoInjuryConfig;
43
44
  readonly blockingPreferences: BlockingPreference[];
44
45
  readonly blockingPreferenceSets?: BlockingPreference[][];
46
+ readonly basePositions?: BaseConfig;
45
47
  }
46
48
  export declare class Tactics {
47
49
  readonly lineup: StartingLineup;
@@ -63,6 +65,7 @@ export declare class Tactics {
63
65
  readonly liberoInjury?: LiberoInjuryConfig;
64
66
  readonly blockingPreferences: BlockingPreference[];
65
67
  readonly blockingPreferenceSets?: BlockingPreference[][];
68
+ readonly basePositions?: BaseConfig;
66
69
  static create(input: unknown): Tactics;
67
70
  private constructor();
68
71
  findPlayerInLineup(id: string): CourtPosition | undefined;
@@ -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 = [], serveAttackShares = {}, systemSets, offensivePreferenceSets, replaceKnockedImmediately = {}, injuryReplacements = [], liberoInjury, blockingPreferences = [], blockingPreferenceSets }) {
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 = [], serveAttackShares = {}, systemSets, offensivePreferenceSets, replaceKnockedImmediately = {}, injuryReplacements = [], liberoInjury, blockingPreferences = [], blockingPreferenceSets, basePositions }) {
22
22
  this.lineup = lineup;
23
23
  this.liberoReplacements = liberoReplacements;
24
24
  this.receiveRotationOffset = receiveRotationOffset;
@@ -38,6 +38,7 @@ class Tactics {
38
38
  this.liberoInjury = liberoInjury;
39
39
  this.blockingPreferences = blockingPreferences;
40
40
  this.blockingPreferenceSets = blockingPreferenceSets;
41
+ this.basePositions = basePositions;
41
42
  }
42
43
  findPlayerInLineup(id) {
43
44
  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, BlockingAssignment, CourtPosition, ConditionLogic, EnergyBand, MatchForfeitType, PerformanceStats, PinchCondition, RotationSystemEnum, SubBackMode, SubBand } from '../service';
1
+ import { BaseConfig, 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, BlockingAssignment, CourtPosition, ConditionLogic, EnergyBand, MatchForfeitType, PerformanceStats, PinchCondition, RotationSystemEnum, SubBackMode, SubBand } from '../service';
2
2
  export * from './tactics-player-refs';
3
3
  export type Rally = DataProps<_Rally> & {
4
4
  homePlayerPosition: PlayerPosition[];
@@ -140,6 +140,7 @@ export interface Tactics {
140
140
  systemSets?: ApiSystemSet[];
141
141
  offensivePreferenceSets?: ApiOffensivePreference[][];
142
142
  serveAttackShares?: Record<string, number>;
143
+ basePositions?: BaseConfig;
143
144
  blockingPreferences: ApiBlockingPreference[];
144
145
  blockingPreferenceSets?: ApiBlockingPreference[][];
145
146
  replaceKnockedImmediately?: Record<string, boolean>;
@@ -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 { BlockingAssignment, ConditionLogic, CourtPosition, EnergyBand, PinchCondition, RotationSystemEnum, SubBackMode, SubBand, SubMode } from '../../service';
4
+ import { BaseConfig, BlockingAssignment, ConditionLogic, CourtPosition, EnergyBand, PinchCondition, RotationSystemEnum, SubBackMode, SubBand, SubMode } from '../../service';
5
5
  export interface TacticsLineupAttributes {
6
6
  [CourtPosition.LIBERO_ZONE]?: PlayerId;
7
7
  [CourtPosition.LEFT_BACK]: PlayerId;
@@ -82,10 +82,11 @@ export interface TacticsAttributes {
82
82
  libero_injury?: LiberoInjuryAttributes;
83
83
  blocking_preferences: BlockingPreferenceAttributes[];
84
84
  blocking_preference_sets?: BlockingPreferenceAttributes[][];
85
+ base_positions?: BaseConfig;
85
86
  }
86
87
  export type TacticsPk = 'team_id';
87
88
  export type TacticsId = TacticsModel[TacticsPk];
88
- 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' | 'serve_attack_shares' | 'replace_knocked_immediately' | 'injury_replacements' | 'libero_injury' | 'blocking_preferences' | 'blocking_preference_sets';
89
+ 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' | 'serve_attack_shares' | 'replace_knocked_immediately' | 'injury_replacements' | 'libero_injury' | 'blocking_preferences' | 'blocking_preference_sets' | 'base_positions';
89
90
  export type TacticsCreationAttributes = Optional<TacticsAttributes, TacticsOptionalAttributes>;
90
91
  export declare class TacticsModel extends Model<TacticsAttributes, TacticsCreationAttributes> implements TacticsAttributes {
91
92
  team_id: string;
@@ -108,6 +109,7 @@ export declare class TacticsModel extends Model<TacticsAttributes, TacticsCreati
108
109
  libero_injury?: LiberoInjuryAttributes;
109
110
  blocking_preferences: BlockingPreferenceAttributes[];
110
111
  blocking_preference_sets?: BlockingPreferenceAttributes[][];
112
+ base_positions?: BaseConfig;
111
113
  team: TeamModel;
112
114
  getTeam: Sequelize.BelongsToGetAssociationMixin<TeamModel>;
113
115
  setTeam: Sequelize.BelongsToSetAssociationMixin<TeamModel, TeamId>;
@@ -97,6 +97,10 @@ export class TacticsModel extends Model {
97
97
  blocking_preference_sets: {
98
98
  type: DataTypes.JSONB,
99
99
  allowNull: true
100
+ },
101
+ base_positions: {
102
+ type: DataTypes.JSONB,
103
+ allowNull: true
100
104
  }
101
105
  }, {
102
106
  sequelize,
@@ -39,7 +39,7 @@ describe('rally-event compact round trip — incident marker on the causing even
39
39
  score: 62.5,
40
40
  target: 4,
41
41
  failure: SpikeFailureEnum.NO_FAILURE,
42
- type: SpikeTypeEnum.CROSS_SHOT,
42
+ type: SpikeTypeEnum.SPIKE,
43
43
  ...(incident != null ? { incident } : {})
44
44
  });
45
45
  }
@@ -12,7 +12,7 @@ describe('rally-event compact round trip -- positional fields (coords / speed /
12
12
  score: 60,
13
13
  target: 4,
14
14
  failure: SpikeFailureEnum.NO_FAILURE,
15
- type: SpikeTypeEnum.CROSS_SHOT,
15
+ type: SpikeTypeEnum.SPIKE,
16
16
  contact: { x: 1.234, y: -0.5 },
17
17
  landing: { x: -2.5, y: 6.789 },
18
18
  speed: 92.456
@@ -41,7 +41,7 @@ describe('rally-event compact round trip -- positional fields (coords / speed /
41
41
  expect(decoded.digQuality).toBe(DigQualityEnum.MEDIOCRE);
42
42
  });
43
43
  it('omits the positional keys when absent, and a legacy event decodes them as undefined', () => {
44
- const spike = Spike.create({ playerId, score: 60, target: 4, failure: SpikeFailureEnum.NO_FAILURE, type: SpikeTypeEnum.CROSS_SHOT });
44
+ const spike = Spike.create({ playerId, score: 60, target: 4, failure: SpikeFailureEnum.NO_FAILURE, type: SpikeTypeEnum.SPIKE });
45
45
  const compact = transformToCompact(spike, playerIndex);
46
46
  expect('c' in compact).toBe(false);
47
47
  expect('v' in compact).toBe(false);
@@ -51,4 +51,12 @@ describe('rally-event compact round trip -- positional fields (coords / speed /
51
51
  expect(decoded.speed).toBeUndefined();
52
52
  expect(decoded.digQuality).toBeUndefined();
53
53
  });
54
+ it('collapses legacy LINE_SHOT(1)/REVERSE_CROSS(2) to SPIKE(0) on decode (owner 2026-08-03)', () => {
55
+ // A zone-engine match persisted before the collapse carries type 1 or 2 in its jsonb; decode heals them to
56
+ // SPIKE so the match still opens. TIP(3) and up are untouched.
57
+ const base = transformToCompact(Spike.create({ playerId, score: 60, target: 4, failure: SpikeFailureEnum.NO_FAILURE, type: SpikeTypeEnum.SPIKE }), playerIndex);
58
+ expect(transformToSpike({ ...base, t: 1 }, roster).type).toBe(SpikeTypeEnum.SPIKE);
59
+ expect(transformToSpike({ ...base, t: 2 }, roster).type).toBe(SpikeTypeEnum.SPIKE);
60
+ expect(transformToSpike({ ...base, t: 3 }, roster).type).toBe(SpikeTypeEnum.TIP);
61
+ });
54
62
  });
@@ -127,7 +127,9 @@ export function transformToSet(event, roster) {
127
127
  export function transformToSpike(event, roster) {
128
128
  return Spike.create({
129
129
  failure: event.f,
130
- type: event.t,
130
+ // Legacy LINE_SHOT(1)/REVERSE_CROSS(2) collapse to SPIKE(0) (owner 2026-08-03): direction is redundant with
131
+ // the stored contact+landing coords. Maps old zone-engine matches so they keep decoding; new events emit 0/3-6.
132
+ type: event.t === 1 || event.t === 2 ? 0 : event.t,
131
133
  playerId: roster[event.p],
132
134
  target: event.a,
133
135
  score: event.s,
@@ -17,7 +17,7 @@ describe('rally compact round trip — every event type', () => {
17
17
  Serve.create({ playerId: pA, target: 6, score: 55, failure: ServeFailureEnum.NO_FAILURE, type: ServeTypeEnum.JUMP_TOPSPIN }),
18
18
  Reception.create({ playerId: pB, target: 5, score: 50, failure: ReceptionFailureEnum.NO_FAILURE, type: ReceptionTypeEnum.DIG }),
19
19
  VolleySet.create({ playerId: pC, target: 3, score: 48, failure: SetFailureEnum.NO_FAILURE, type: SetTypeEnum.OVERHAND, tempo: SetTempoEnum.FIRST }),
20
- Spike.create({ playerId: pA, target: 4, score: 62.5, failure: SpikeFailureEnum.NO_FAILURE, type: SpikeTypeEnum.CROSS_SHOT }),
20
+ Spike.create({ playerId: pA, target: 4, score: 62.5, failure: SpikeFailureEnum.NO_FAILURE, type: SpikeTypeEnum.SPIKE }),
21
21
  Block.create({ playerId: pB, blockers: [pB, pC], target: 8, score: 48, failure: BlockFailureEnum.NO_FAILURE, type: BlockTypeEnum.DOUBLE }),
22
22
  FreeBall.create({ playerId: pC, target: 6, score: 45, failure: FreeBallFailureEnum.NO_FAILURE, type: FreeBallTypeEnum.OVER })
23
23
  ];
@@ -47,7 +47,7 @@ describe('rally compact round trip — every event type', () => {
47
47
  expect(rec.type).toBe(ReceptionTypeEnum.DIG);
48
48
  expect(set.type).toBe(SetTypeEnum.OVERHAND);
49
49
  expect(set.tempo).toBe(SetTempoEnum.FIRST);
50
- expect(spike.type).toBe(SpikeTypeEnum.CROSS_SHOT);
50
+ expect(spike.type).toBe(SpikeTypeEnum.SPIKE);
51
51
  expect(spike.score).toBe(62.5);
52
52
  expect(block.type).toBe(BlockTypeEnum.DOUBLE);
53
53
  expect(block.blockers).toEqual([pB, pC]);
@@ -80,6 +80,7 @@ function transformToAttributes(tactics, teamId) {
80
80
  tempos: p.tempos
81
81
  }))),
82
82
  serve_attack_shares: tactics.serveAttackShares ?? {},
83
+ base_positions: tactics.basePositions,
83
84
  blocking_preferences: tactics.blockingPreferences.map(p => ({ rotation: p.rotation, targets: p.targets })),
84
85
  blocking_preference_sets: tactics.blockingPreferenceSets?.map(prefs => prefs.map(p => ({ rotation: p.rotation, targets: p.targets }))),
85
86
  replace_knocked_immediately: tactics.replaceKnockedImmediately,
@@ -201,6 +202,7 @@ function transformToObject(model, roster) {
201
202
  }
202
203
  return healed;
203
204
  })(),
205
+ basePositions: model.base_positions ?? undefined,
204
206
  blockingPreferences: (model.blocking_preferences ?? []).map(p => ({ rotation: p.rotation, targets: p.targets })),
205
207
  blockingPreferenceSets: model.blocking_preference_sets?.map(prefs => prefs.map(p => ({ rotation: p.rotation, targets: p.targets }))),
206
208
  // LENIENT: keep only boolean entries keyed by a court starter or, when a second libero exists, the starting
@@ -361,6 +363,7 @@ function tacticsColumnsToApiDto(model) {
361
363
  }
362
364
  return Object.keys(healed).length > 0 ? healed : undefined;
363
365
  })(),
366
+ basePositions: model.base_positions ?? undefined,
364
367
  blockingPreferences: model.blocking_preferences,
365
368
  blockingPreferenceSets: model.blocking_preference_sets,
366
369
  // Same stale-data policy as the heals below: only boolean entries keyed by a court starter or, when a second
@@ -469,6 +472,7 @@ function apiTacticsConfigToAttributes(config, teamId) {
469
472
  system_sets: config.systemSets,
470
473
  offensive_preference_sets: config.offensivePreferenceSets,
471
474
  serve_attack_shares: config.serveAttackShares ?? {},
475
+ base_positions: config.basePositions,
472
476
  blocking_preferences: config.blockingPreferences ?? [],
473
477
  blocking_preference_sets: config.blockingPreferenceSets,
474
478
  replace_knocked_immediately: config.replaceKnockedImmediately,
@@ -225,6 +225,30 @@ describe('per-set tactics — service <-> row round-trip', () => {
225
225
  const back = transformToTactics(attrs, roster);
226
226
  expect(back.serveAttackShares).toEqual({});
227
227
  });
228
+ it('round-trips base positions (coords + reception participation)', () => {
229
+ const config = {
230
+ coords: { RECEIVE: { 1: { 5: { d: 5.5, r: -3 }, 6: { d: 6.5, r: 0 } } }, DEFENCE: { 3: { 2: { d: 0.9, r: 3 } } } },
231
+ receive: { 1: { 5: true, 2: false }, 4: { 6: true } }
232
+ };
233
+ const withBases = Tactics.create({
234
+ lineup: { 4: p4, 3: p3, 2: p2, 5: p5, 6: p6, 1: p1, bench: [] },
235
+ liberoReplacements: [],
236
+ basePositions: config
237
+ });
238
+ const attrs = transformFromTactics(withBases, 'team-1');
239
+ expect(attrs.base_positions).toEqual(config);
240
+ const back = transformToTactics(attrs, roster);
241
+ expect(back.basePositions).toEqual(config);
242
+ });
243
+ it('leaves basePositions undefined when the column is absent', () => {
244
+ const attrs = transformFromTactics(Tactics.create({
245
+ lineup: { 4: p4, 3: p3, 2: p2, 5: p5, 6: p6, 1: p1, bench: [] },
246
+ liberoReplacements: []
247
+ }), 'team-1');
248
+ expect(attrs.base_positions).toBeUndefined();
249
+ const back = transformToTactics(attrs, roster);
250
+ expect(back.basePositions).toBeUndefined();
251
+ });
228
252
  it('round-trips a per-starter tempos map on offensive preferences', () => {
229
253
  const withTempos = Tactics.create({
230
254
  lineup: { 4: p4, 3: p3, 2: p2, 5: p5, 6: p6, 1: p1, bench: [] },
@@ -2,7 +2,9 @@ import { z } from 'zod';
2
2
  import { TraitEnum } from '../../player';
3
3
  import { EventIncidentSchema } from './incident.z';
4
4
  import { positionalEventFields } from './positional.z';
5
- const spikeTypeValues = [0, 1, 2, 3, 4, 5, 6];
5
+ // SPIKE(0), TIP(3), DOWN_BALL(4), OVERHAND(5), TOOL(6). 1/2 (legacy LINE_SHOT/REVERSE_CROSS) are retired; the
6
+ // codec (transformToSpike) maps them to 0 before this validates, so they never reach here.
7
+ const spikeTypeValues = [0, 3, 4, 5, 6];
6
8
  const spikeFailureValues = [0, 1, 2, 3, 4];
7
9
  const courtTargetValues = Array.from({ length: 13 }, (_, i) => i);
8
10
  export const SpikeInputSchema = z.object({
@@ -1,14 +1,12 @@
1
1
  import { InPlayEvent } from './in-play-event';
2
2
  export declare enum SpikeTypeEnum {
3
- CROSS_SHOT = 0,
4
- LINE_SHOT = 1,
5
- REVERSE_CROSS = 2,
3
+ SPIKE = 0,
6
4
  TIP = 3,
7
5
  DOWN_BALL = 4,
8
6
  OVERHAND = 5,
9
7
  TOOL = 6
10
8
  }
11
- export type SpikeType = SpikeTypeEnum.CROSS_SHOT | SpikeTypeEnum.LINE_SHOT | SpikeTypeEnum.REVERSE_CROSS | SpikeTypeEnum.TIP | SpikeTypeEnum.DOWN_BALL | SpikeTypeEnum.OVERHAND | SpikeTypeEnum.TOOL;
9
+ export type SpikeType = SpikeTypeEnum.SPIKE | SpikeTypeEnum.TIP | SpikeTypeEnum.DOWN_BALL | SpikeTypeEnum.OVERHAND | SpikeTypeEnum.TOOL;
12
10
  export declare enum SpikeFailureEnum {
13
11
  NO_FAILURE = 0,
14
12
  FAULT = 1,
@@ -3,9 +3,11 @@ import { InPlayEvent } from './in-play-event';
3
3
  import { SpikeInputSchema } from './schemas/spike.z';
4
4
  export var SpikeTypeEnum;
5
5
  (function (SpikeTypeEnum) {
6
- SpikeTypeEnum[SpikeTypeEnum["CROSS_SHOT"] = 0] = "CROSS_SHOT";
7
- SpikeTypeEnum[SpikeTypeEnum["LINE_SHOT"] = 1] = "LINE_SHOT";
8
- SpikeTypeEnum[SpikeTypeEnum["REVERSE_CROSS"] = 2] = "REVERSE_CROSS";
6
+ // A hard swing (owner 2026-08-03). The cross / line / reverse DIRECTION is not stored: it is fully derivable
7
+ // from the persisted contact + landing coordinates, so a single SPIKE value replaces the old CROSS_SHOT(0) /
8
+ // LINE_SHOT(1) / REVERSE_CROSS(2). 1 and 2 are retired (left as gaps); the codec maps any legacy 1/2 back to
9
+ // SPIKE(0), so existing (zone-engine) matches keep decoding.
10
+ SpikeTypeEnum[SpikeTypeEnum["SPIKE"] = 0] = "SPIKE";
9
11
  SpikeTypeEnum[SpikeTypeEnum["TIP"] = 3] = "TIP";
10
12
  SpikeTypeEnum[SpikeTypeEnum["DOWN_BALL"] = 4] = "DOWN_BALL";
11
13
  SpikeTypeEnum[SpikeTypeEnum["OVERHAND"] = 5] = "OVERHAND";
@@ -0,0 +1,10 @@
1
+ export type BaseFormation = 'SERVE' | 'RECEIVE' | 'DEFENCE' | 'DEF_L' | 'DEF_M' | 'DEF_R' | 'ATTACK';
2
+ export declare const BASE_FORMATIONS: readonly BaseFormation[];
3
+ export interface BaseCoord {
4
+ readonly d: number;
5
+ readonly r: number;
6
+ }
7
+ export interface BaseConfig {
8
+ readonly coords: Record<string, Record<string, Record<string, BaseCoord>>>;
9
+ readonly receive: Record<string, Record<string, boolean>>;
10
+ }
@@ -0,0 +1,11 @@
1
+ // Per-team base-position configuration (owner 2026-08-03). Generalises the sim's hardcoded 5-1 preset into a
2
+ // system-agnostic, per-team shape: for each formation, rotation and rotational ZONE, where that zone's player
3
+ // stands, plus (serve-receive only) whether that zone's player passes the serve. Authored in the UI base editor
4
+ // (and populated from a picked default preset); consumed by the positional engine to place players. A team with no
5
+ // `basePositions` falls back to the default preset for its `rotationSystem`.
6
+ //
7
+ // KEYING: rotation 1..6 and zone 1..6 are the ROTATIONAL (at-the-whistle) slots. The stored rotation convention is
8
+ // the ENGINE's (SETTER_ZONE_TO_ROTATION); the UI editor re-keys the mapper's setter-zone rotation to it on save.
9
+ // `coords[formation][rotation][zone]` is the standing coordinate for that formation; `receive[rotation][zone]` is
10
+ // the reception-participation flag (absent zone => default to participating).
11
+ export const BASE_FORMATIONS = ['SERVE', 'RECEIVE', 'DEFENCE', 'DEF_L', 'DEF_M', 'DEF_R', 'ATTACK'];
@@ -2,6 +2,7 @@ export * from './team';
2
2
  export * from './team-generator';
3
3
  export * from './formation';
4
4
  export * from './tactics';
5
+ export * from './base-config';
5
6
  export * from './team-name';
6
7
  export * from './energy-band';
7
8
  export * from './pinch-condition';
@@ -2,6 +2,7 @@ export * from './team';
2
2
  export * from './team-generator';
3
3
  export * from './formation';
4
4
  export * from './tactics';
5
+ export * from './base-config';
5
6
  export * from './team-name';
6
7
  export * from './energy-band';
7
8
  export * from './pinch-condition';
@@ -288,6 +288,13 @@ export declare const TacticsInputSchema: z.ZodObject<{
288
288
  rotation: z.ZodNumber;
289
289
  targets: z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodEnum<typeof RoleEnum> & z.ZodType<RoleEnum.SETTER | RoleEnum.OUTSIDE_HITTER | RoleEnum.OPPOSITE_HITTER | RoleEnum.MIDDLE_BLOCKER, RoleEnum, z.core.$ZodTypeInternals<RoleEnum.SETTER | RoleEnum.OUTSIDE_HITTER | RoleEnum.OPPOSITE_HITTER | RoleEnum.MIDDLE_BLOCKER, RoleEnum>>, z.ZodLiteral<"NO_BLOCK">]>>;
290
290
  }, z.core.$strip>>>>;
291
+ basePositions: z.ZodOptional<z.ZodObject<{
292
+ coords: z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodObject<{
293
+ d: z.ZodNumber;
294
+ r: z.ZodNumber;
295
+ }, z.core.$strip>>>>;
296
+ receive: z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodBoolean>>;
297
+ }, z.core.$strip>>;
291
298
  replaceKnockedImmediately: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
292
299
  injuryReplacements: z.ZodDefault<z.ZodArray<z.ZodObject<{
293
300
  starter: z.ZodCustom<Player, Player>;
@@ -74,6 +74,15 @@ const systemSetSchema = z.object({
74
74
  rotationSystem: z.nativeEnum(RotationSystemEnum),
75
75
  designatedSetters: z.array(playerInstanceSchema)
76
76
  });
77
+ // Base positions (owner 2026-08-03): coords[formation][rotation][zone] -> {d,r} + receive[rotation][zone] -> bool.
78
+ // jsonb keys are strings; the coordinate + boolean leaves are validated. Structural key ranges (formation names,
79
+ // rotations 1-6, zones 1-6) are trusted from the UI editor / preset compiler, which write the engine rotation
80
+ // convention. Out-of-court coordinates are allowed, so d/r are not range-clamped here.
81
+ const baseCoordSchema = z.object({ d: z.number().finite(), r: z.number().finite() });
82
+ const baseConfigSchema = z.object({
83
+ coords: z.record(z.string(), z.record(z.string(), z.record(z.string(), baseCoordSchema))),
84
+ receive: z.record(z.string(), z.record(z.string(), z.boolean()))
85
+ });
77
86
  export const TacticsInputSchema = z.object({
78
87
  lineup: lineupSchema,
79
88
  liberoReplacements: z.array(playerInstanceSchema),
@@ -104,6 +113,8 @@ export const TacticsInputSchema = z.object({
104
113
  // Blocking preferences (dedicated blocking). Defaults keep every existing Tactics.create({...}) call valid.
105
114
  blockingPreferences: z.array(blockingPreferenceSchema).default([]),
106
115
  blockingPreferenceSets: z.array(z.array(blockingPreferenceSchema)).max(5).optional(),
116
+ // Base positions (owner 2026-08-03). Optional -> the sim falls back to the default preset for the system.
117
+ basePositions: baseConfigSchema.optional(),
107
118
  // Injuries. Defaults keep every existing Tactics.create({...}) call valid. replaceKnockedImmediately is
108
119
  // PER STARTER (owner order 2026-07-05): keyed by starter id, missing key = ON; keys validated against the
109
120
  // lineup in the superRefine below. injuryReplacements is the per-starter explicit replacement mapping
@@ -296,6 +296,13 @@ export declare const TeamInputSchema: z.ZodObject<{
296
296
  rotation: z.ZodNumber;
297
297
  targets: z.ZodRecord<z.ZodString, z.ZodUnion<readonly [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>>, z.ZodLiteral<"NO_BLOCK">]>>;
298
298
  }, z.core.$strip>>>>;
299
+ basePositions: z.ZodOptional<z.ZodObject<{
300
+ coords: z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodObject<{
301
+ d: z.ZodNumber;
302
+ r: z.ZodNumber;
303
+ }, z.core.$strip>>>>;
304
+ receive: z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodBoolean>>;
305
+ }, z.core.$strip>>;
299
306
  replaceKnockedImmediately: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
300
307
  injuryReplacements: z.ZodDefault<z.ZodArray<z.ZodObject<{
301
308
  starter: z.ZodCustom<Player, Player>;
@@ -8,6 +8,7 @@ import { OffensivePreference } from './offensive-preference';
8
8
  import { BlockingPreference } from './blocking-preference';
9
9
  import { InjuryReplacement } from './injury-replacement';
10
10
  import { LiberoInjuryConfig } from './libero-injury';
11
+ import { BaseConfig } from './base-config';
11
12
  export interface StartingLineup {
12
13
  readonly [CourtPosition.LEFT_FRONT]: Player;
13
14
  readonly [CourtPosition.MIDDLE_FRONT]: Player;
@@ -42,6 +43,7 @@ export interface TacticsOpts {
42
43
  readonly liberoInjury?: LiberoInjuryConfig;
43
44
  readonly blockingPreferences: BlockingPreference[];
44
45
  readonly blockingPreferenceSets?: BlockingPreference[][];
46
+ readonly basePositions?: BaseConfig;
45
47
  }
46
48
  export declare class Tactics {
47
49
  readonly lineup: StartingLineup;
@@ -63,6 +65,7 @@ export declare class Tactics {
63
65
  readonly liberoInjury?: LiberoInjuryConfig;
64
66
  readonly blockingPreferences: BlockingPreference[];
65
67
  readonly blockingPreferenceSets?: BlockingPreference[][];
68
+ readonly basePositions?: BaseConfig;
66
69
  static create(input: unknown): Tactics;
67
70
  private constructor();
68
71
  findPlayerInLineup(id: string): CourtPosition | undefined;
@@ -15,7 +15,7 @@ export class Tactics {
15
15
  }
16
16
  return new Tactics(result.data);
17
17
  }
18
- constructor({ lineup, liberoReplacements, receiveRotationOffset = false, designatedSubs = [], substitutionBand = EnergyBand.TIRED, secondLibero, liberoSub, liberoReplacementSets, rotationSystem = RotationSystemEnum.SIX_ZERO, designatedSetters = [], offensivePreferences = [], serveAttackShares = {}, systemSets, offensivePreferenceSets, replaceKnockedImmediately = {}, injuryReplacements = [], liberoInjury, blockingPreferences = [], blockingPreferenceSets }) {
18
+ constructor({ lineup, liberoReplacements, receiveRotationOffset = false, designatedSubs = [], substitutionBand = EnergyBand.TIRED, secondLibero, liberoSub, liberoReplacementSets, rotationSystem = RotationSystemEnum.SIX_ZERO, designatedSetters = [], offensivePreferences = [], serveAttackShares = {}, systemSets, offensivePreferenceSets, replaceKnockedImmediately = {}, injuryReplacements = [], liberoInjury, blockingPreferences = [], blockingPreferenceSets, basePositions }) {
19
19
  this.lineup = lineup;
20
20
  this.liberoReplacements = liberoReplacements;
21
21
  this.receiveRotationOffset = receiveRotationOffset;
@@ -35,6 +35,7 @@ export class Tactics {
35
35
  this.liberoInjury = liberoInjury;
36
36
  this.blockingPreferences = blockingPreferences;
37
37
  this.blockingPreferenceSets = blockingPreferenceSets;
38
+ this.basePositions = basePositions;
38
39
  }
39
40
  findPlayerInLineup(id) {
40
41
  if (this.lineup.bench.some(p => p.id === id))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "volleyballsimtypes",
3
- "version": "0.0.504",
3
+ "version": "0.0.505",
4
4
  "description": "vbsim types",
5
5
  "main": "./dist/cjs/src/index.js",
6
6
  "module": "./dist/esm/src/index.js",