volleyballsimtypes 0.0.467 → 0.0.470

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 +1 -1
  2. package/dist/cjs/src/data/models/tactics.d.ts +3 -3
  3. package/dist/cjs/src/data/models/tactics.js +3 -4
  4. package/dist/cjs/src/data/transformers/lineup-preset.js +4 -7
  5. package/dist/cjs/src/data/transformers/lineup-preset.test.js +9 -7
  6. package/dist/cjs/src/data/transformers/tactics.js +28 -4
  7. package/dist/cjs/src/service/player/injury.d.ts +2 -1
  8. package/dist/cjs/src/service/player/injury.js +1 -0
  9. package/dist/cjs/src/service/player/injury.test.js +2 -1
  10. package/dist/cjs/src/service/player/schemas/player.z.js +1 -1
  11. package/dist/cjs/src/service/team/schemas/injury-replacement.z.test.js +28 -2
  12. package/dist/cjs/src/service/team/schemas/tactics.z.d.ts +1 -1
  13. package/dist/cjs/src/service/team/schemas/tactics.z.js +12 -4
  14. package/dist/cjs/src/service/team/schemas/team.z.d.ts +1 -1
  15. package/dist/cjs/src/service/team/tactics.d.ts +2 -2
  16. package/dist/cjs/src/service/team/tactics.js +2 -2
  17. package/dist/esm/src/api/index.d.ts +1 -1
  18. package/dist/esm/src/data/models/tactics.d.ts +3 -3
  19. package/dist/esm/src/data/models/tactics.js +3 -4
  20. package/dist/esm/src/data/transformers/lineup-preset.js +4 -7
  21. package/dist/esm/src/data/transformers/lineup-preset.test.js +9 -7
  22. package/dist/esm/src/data/transformers/tactics.js +28 -4
  23. package/dist/esm/src/service/player/injury.d.ts +2 -1
  24. package/dist/esm/src/service/player/injury.js +1 -0
  25. package/dist/esm/src/service/player/injury.test.js +2 -1
  26. package/dist/esm/src/service/player/schemas/player.z.js +1 -1
  27. package/dist/esm/src/service/team/schemas/injury-replacement.z.test.js +28 -2
  28. package/dist/esm/src/service/team/schemas/tactics.z.d.ts +1 -1
  29. package/dist/esm/src/service/team/schemas/tactics.z.js +12 -4
  30. package/dist/esm/src/service/team/schemas/team.z.d.ts +1 -1
  31. package/dist/esm/src/service/team/tactics.d.ts +2 -2
  32. package/dist/esm/src/service/team/tactics.js +2 -2
  33. package/package.json +1 -1
@@ -132,7 +132,7 @@ export interface Tactics {
132
132
  offensivePreferences: ApiOffensivePreference[];
133
133
  systemSets?: ApiSystemSet[];
134
134
  offensivePreferenceSets?: ApiOffensivePreference[][];
135
- replaceInjuredImmediately: boolean;
135
+ replaceKnockedImmediately?: Record<string, boolean>;
136
136
  injuryReplacements?: ApiInjuryReplacement[];
137
137
  }
138
138
  export interface ApiLineupPreset {
@@ -66,12 +66,12 @@ export interface TacticsAttributes {
66
66
  offensive_preferences: OffensivePreferenceAttributes[];
67
67
  system_sets?: SystemSetAttributes[];
68
68
  offensive_preference_sets?: OffensivePreferenceAttributes[][];
69
- replace_injured_immediately?: boolean;
69
+ replace_knocked_immediately?: Record<PlayerId, boolean>;
70
70
  injury_replacements?: InjuryReplacementAttributes[];
71
71
  }
72
72
  export type TacticsPk = 'team_id';
73
73
  export type TacticsId = TacticsModel[TacticsPk];
74
- 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_injured_immediately' | 'injury_replacements';
74
+ 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';
75
75
  export type TacticsCreationAttributes = Optional<TacticsAttributes, TacticsOptionalAttributes>;
76
76
  export declare class TacticsModel extends Model<TacticsAttributes, TacticsCreationAttributes> implements TacticsAttributes {
77
77
  team_id: string;
@@ -88,7 +88,7 @@ export declare class TacticsModel extends Model<TacticsAttributes, TacticsCreati
88
88
  offensive_preferences: OffensivePreferenceAttributes[];
89
89
  system_sets?: SystemSetAttributes[];
90
90
  offensive_preference_sets?: OffensivePreferenceAttributes[][];
91
- replace_injured_immediately?: boolean;
91
+ replace_knocked_immediately?: Record<PlayerId, boolean>;
92
92
  injury_replacements?: InjuryReplacementAttributes[];
93
93
  team: TeamModel;
94
94
  getTeam: Sequelize.BelongsToGetAssociationMixin<TeamModel>;
@@ -75,10 +75,9 @@ class TacticsModel extends sequelize_1.Model {
75
75
  type: sequelize_1.DataTypes.JSONB,
76
76
  allowNull: true
77
77
  },
78
- replace_injured_immediately: {
79
- type: sequelize_1.DataTypes.BOOLEAN,
80
- allowNull: false,
81
- defaultValue: true
78
+ replace_knocked_immediately: {
79
+ type: sequelize_1.DataTypes.JSONB,
80
+ allowNull: true
82
81
  },
83
82
  injury_replacements: {
84
83
  type: sequelize_1.DataTypes.JSONB,
@@ -2,18 +2,15 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.transformToLineupPreset = transformToObject;
4
4
  // `config` is already stored as the API Tactics DTO, so a preset row maps to its API shape with no hydration.
5
- // Presets saved before the injuries fields exist lack replaceInjuredImmediately: heal it to the effective
6
- // default (true, matching transformToTactics' hydration) so the DTO's required field is honest and a UI
7
- // toggle seeded from an old preset cannot silently flip the user's effective setting on re-save.
5
+ // No injuries heal here anymore: replaceKnockedImmediately is optional (absent = every starter ON), and old
6
+ // configs carrying the superseded replaceInjuredImmediately boolean pass through the spread as a dead key
7
+ // that the save DTO strips (owner 2026-07-05: the old global value is dropped, default ON).
8
8
  function transformToObject(model) {
9
9
  return {
10
10
  presetId: model.preset_id,
11
11
  name: model.name,
12
12
  isActive: model.is_active,
13
13
  orderIndex: model.order_index,
14
- config: {
15
- ...model.config,
16
- replaceInjuredImmediately: model.config.replaceInjuredImmediately ?? true
17
- }
14
+ config: model.config
18
15
  };
19
16
  }
@@ -3,7 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  const globals_1 = require("@jest/globals");
4
4
  const lineup_preset_1 = require("./lineup-preset");
5
5
  (0, globals_1.describe)('transformToLineupPreset()', () => {
6
- (0, globals_1.it)('maps preset columns to the API shape and heals a pre-injuries config to the effective default', () => {
6
+ (0, globals_1.it)('maps preset columns to the API shape verbatim (no injuries heal: absent map = every starter ON)', () => {
7
7
  const config = { lineup: { bench: [] }, substitutionBand: 'TIRED' };
8
8
  const preset = (0, lineup_preset_1.transformToLineupPreset)({
9
9
  preset_id: 'p1',
@@ -18,13 +18,15 @@ const lineup_preset_1 = require("./lineup-preset");
18
18
  name: 'Starters',
19
19
  isActive: true,
20
20
  orderIndex: 2,
21
- // A config stored before the injuries fields lacks replaceInjuredImmediately; the transform heals it
22
- // to true (the effective hydration default) so the DTO's required field is honest.
23
- config: { ...config, replaceInjuredImmediately: true }
21
+ config
24
22
  });
25
23
  });
26
- (0, globals_1.it)('keeps an explicit replaceInjuredImmediately: false', () => {
27
- const config = { lineup: { bench: [] }, substitutionBand: 'TIRED', replaceInjuredImmediately: false };
24
+ (0, globals_1.it)('keeps an explicit per-starter replaceKnockedImmediately map', () => {
25
+ const config = {
26
+ lineup: { bench: [] },
27
+ substitutionBand: 'TIRED',
28
+ replaceKnockedImmediately: { 'starter-1': false }
29
+ };
28
30
  const preset = (0, lineup_preset_1.transformToLineupPreset)({
29
31
  preset_id: 'p2',
30
32
  team_id: 't1',
@@ -33,6 +35,6 @@ const lineup_preset_1 = require("./lineup-preset");
33
35
  order_index: 3,
34
36
  config
35
37
  });
36
- (0, globals_1.expect)(preset.config.replaceInjuredImmediately).toBe(false);
38
+ (0, globals_1.expect)(preset.config.replaceKnockedImmediately).toEqual({ 'starter-1': false });
37
39
  });
38
40
  });
@@ -88,7 +88,7 @@ function transformToAttributes(tactics, teamId) {
88
88
  rotation: p.rotation,
89
89
  order: p.order.map((a) => a.id)
90
90
  }))),
91
- replace_injured_immediately: tactics.replaceInjuredImmediately,
91
+ replace_knocked_immediately: tactics.replaceKnockedImmediately,
92
92
  injury_replacements: tactics.injuryReplacements.map(ir => ({
93
93
  starterId: ir.starter.id,
94
94
  replacementId: ir.replacement.id,
@@ -171,7 +171,19 @@ function transformToObject(model, roster) {
171
171
  rotation: p.rotation,
172
172
  order: p.order.map((id) => findPlayer(id, roster))
173
173
  }))),
174
- replaceInjuredImmediately: model.replace_injured_immediately ?? true,
174
+ // LENIENT: keep only starter-keyed boolean entries so stale jsonb (an ex-starter's key, a non-boolean
175
+ // value) can never fail schema validation and crash a team load; a dropped key = the ON default.
176
+ replaceKnockedImmediately: (() => {
177
+ const starterIds = new Set([lineup[service_1.CourtPosition.LEFT_FRONT], lineup[service_1.CourtPosition.MIDDLE_FRONT], lineup[service_1.CourtPosition.RIGHT_FRONT],
178
+ lineup[service_1.CourtPosition.LEFT_BACK], lineup[service_1.CourtPosition.MIDDLE_BACK], lineup[service_1.CourtPosition.RIGHT_BACK]]
179
+ .map((p) => p.id));
180
+ const healed = {};
181
+ for (const [id, value] of Object.entries(model.replace_knocked_immediately ?? {})) {
182
+ if (starterIds.has(id) && typeof value === 'boolean')
183
+ healed[id] = value;
184
+ }
185
+ return healed;
186
+ })(),
175
187
  // LENIENT (roster.find, not findPlayer): an entry whose starter or replacement is gone/invalid is DROPPED
176
188
  // rather than crashing the sim batch (a dropped entry = unconfigured = the bench-only auto fallback, per
177
189
  // the owner rules); an invalid benchBackfill drops only the backfill. First occurrence wins on duplicates;
@@ -270,7 +282,19 @@ function tacticsColumnsToApiDto(model) {
270
282
  offensivePreferences: model.offensive_preferences,
271
283
  systemSets: model.system_sets,
272
284
  offensivePreferenceSets: model.offensive_preference_sets,
273
- replaceInjuredImmediately: model.replace_injured_immediately ?? true,
285
+ // Same stale-data policy as the heals below: only starter-keyed boolean entries survive, and an empty
286
+ // map surfaces as absent (missing key = ON).
287
+ replaceKnockedImmediately: (() => {
288
+ const starterSlotIds = new Set([model.lineup[service_1.CourtPosition.LEFT_FRONT], model.lineup[service_1.CourtPosition.MIDDLE_FRONT],
289
+ model.lineup[service_1.CourtPosition.RIGHT_FRONT], model.lineup[service_1.CourtPosition.LEFT_BACK],
290
+ model.lineup[service_1.CourtPosition.MIDDLE_BACK], model.lineup[service_1.CourtPosition.RIGHT_BACK]]);
291
+ const healed = {};
292
+ for (const [id, value] of Object.entries(model.replace_knocked_immediately ?? {})) {
293
+ if (starterSlotIds.has(id) && typeof value === 'boolean')
294
+ healed[id] = value;
295
+ }
296
+ return Object.keys(healed).length > 0 ? healed : undefined;
297
+ })(),
274
298
  // Same stale-data policy as validSecondLibero above: don't surface entries the service load would drop
275
299
  // (starter no longer a starter, replacement now a starter/libero/L2, duplicates), or the UI reloads and
276
300
  // re-emits them forever. Roster membership can't be checked here (ids only); the service heal covers that.
@@ -335,7 +359,7 @@ function apiTacticsConfigToAttributes(config, teamId) {
335
359
  offensive_preferences: config.offensivePreferences,
336
360
  system_sets: config.systemSets,
337
361
  offensive_preference_sets: config.offensivePreferenceSets,
338
- replace_injured_immediately: config.replaceInjuredImmediately,
362
+ replace_knocked_immediately: config.replaceKnockedImmediately,
339
363
  injury_replacements: config.injuryReplacements
340
364
  };
341
365
  }
@@ -2,7 +2,8 @@ import { Rarity } from './rarity';
2
2
  export declare enum InjurySeverityEnum {
3
3
  MINOR = 1,
4
4
  MODERATE = 2,
5
- SEVERE = 3
5
+ SEVERE = 3,
6
+ CRITICAL = 4
6
7
  }
7
8
  export interface PlayerInjury {
8
9
  readonly severity: number;
@@ -10,6 +10,7 @@ var InjurySeverityEnum;
10
10
  InjurySeverityEnum[InjurySeverityEnum["MINOR"] = 1] = "MINOR";
11
11
  InjurySeverityEnum[InjurySeverityEnum["MODERATE"] = 2] = "MODERATE";
12
12
  InjurySeverityEnum[InjurySeverityEnum["SEVERE"] = 3] = "SEVERE";
13
+ InjurySeverityEnum[InjurySeverityEnum["CRITICAL"] = 4] = "CRITICAL";
13
14
  })(InjurySeverityEnum || (exports.InjurySeverityEnum = InjurySeverityEnum = {}));
14
15
  // A long-term injury is active while `at` is before injuredUntil.
15
16
  function hasActiveInjury(injury, at) {
@@ -26,7 +26,8 @@ const test_helpers_1 = require("../test-helpers");
26
26
  }
27
27
  });
28
28
  (0, globals_1.it)('rejects out-of-range severity and profile', () => {
29
- (0, globals_1.expect)(player_z_1.PlayerInputSchema.safeParse({ ...validInput(), injury: { severity: 4, injuredUntil: new Date() } }).success).toBe(false);
29
+ (0, globals_1.expect)(player_z_1.PlayerInputSchema.safeParse({ ...validInput(), injury: { severity: 4, injuredUntil: new Date() } }).success).toBe(true);
30
+ (0, globals_1.expect)(player_z_1.PlayerInputSchema.safeParse({ ...validInput(), injury: { severity: 5, injuredUntil: new Date() } }).success).toBe(false);
30
31
  (0, globals_1.expect)(player_z_1.PlayerInputSchema.safeParse({ ...validInput(), injury: { severity: 0, injuredUntil: new Date() } }).success).toBe(false);
31
32
  (0, globals_1.expect)(player_z_1.PlayerInputSchema.safeParse({ ...validInput(), injuryProneProfile: 1.2 }).success).toBe(false);
32
33
  (0, globals_1.expect)(player_z_1.PlayerInputSchema.safeParse({ ...validInput(), injuryProneProfile: -0.1 }).success).toBe(false);
@@ -41,7 +41,7 @@ exports.PlayerInputSchema = zod_1.z.object({
41
41
  // Defaulted so every existing Player.create caller (generator, transformers, test helpers) stays valid.
42
42
  injuryProneProfile: zod_1.z.number().min(0).max(1).default(0.5),
43
43
  injury: zod_1.z.object({
44
- severity: zod_1.z.number().int().min(1).max(3),
44
+ severity: zod_1.z.number().int().min(1).max(4),
45
45
  injuredUntil: zod_1.z.coerce.date(),
46
46
  debuff: zod_1.z.number().min(0).max(1).optional()
47
47
  }).optional(),
@@ -32,14 +32,40 @@ const test_helpers_1 = require("../../test-helpers");
32
32
  ...overrides
33
33
  };
34
34
  }
35
- (0, globals_1.it)('defaults to true / empty', () => {
35
+ (0, globals_1.it)('defaults to an empty knock map / empty replacements', () => {
36
36
  const res = tactics_z_1.TacticsInputSchema.safeParse(baseInput());
37
37
  (0, globals_1.expect)(res.success).toBe(true);
38
38
  if (res.success) {
39
- (0, globals_1.expect)(res.data.replaceInjuredImmediately).toBe(true);
39
+ (0, globals_1.expect)(res.data.replaceKnockedImmediately).toEqual({});
40
40
  (0, globals_1.expect)(res.data.injuryReplacements).toEqual([]);
41
41
  }
42
42
  });
43
+ (0, globals_1.it)('accepts a starter-keyed replaceKnockedImmediately map', () => {
44
+ const res = tactics_z_1.TacticsInputSchema.safeParse(baseInput({
45
+ replaceKnockedImmediately: { [p1.id]: false, [p3.id]: true }
46
+ }));
47
+ (0, globals_1.expect)(res.success).toBe(true);
48
+ if (res.success) {
49
+ (0, globals_1.expect)(res.data.replaceKnockedImmediately).toEqual({ [p1.id]: false, [p3.id]: true });
50
+ }
51
+ });
52
+ (0, globals_1.it)('rejects a replaceKnockedImmediately key that is not a court starter', () => {
53
+ for (const outsider of [benchA, libero, reserveA]) {
54
+ const res = tactics_z_1.TacticsInputSchema.safeParse(baseInput({
55
+ replaceKnockedImmediately: { [outsider.id]: false }
56
+ }));
57
+ (0, globals_1.expect)(res.success).toBe(false);
58
+ if (!res.success) {
59
+ (0, globals_1.expect)(res.error.issues.some(issue => issue.message === 'REPLACE_KNOCKED_KEY_NOT_A_STARTER')).toBe(true);
60
+ }
61
+ }
62
+ });
63
+ (0, globals_1.it)('rejects a non-boolean replaceKnockedImmediately value', () => {
64
+ const res = tactics_z_1.TacticsInputSchema.safeParse(baseInput({
65
+ replaceKnockedImmediately: { [p1.id]: 'off' }
66
+ }));
67
+ (0, globals_1.expect)(res.success).toBe(false);
68
+ });
43
69
  (0, globals_1.it)('accepts a bench replacement with a reserve backfill', () => {
44
70
  const res = tactics_z_1.TacticsInputSchema.safeParse(baseInput({
45
71
  injuryReplacements: [{ starter: p1, replacement: benchA, benchBackfill: reserveA }]
@@ -123,7 +123,7 @@ export declare const TacticsInputSchema: z.ZodObject<{
123
123
  rotation: z.ZodNumber;
124
124
  order: z.ZodArray<z.ZodCustom<Player, Player>>;
125
125
  }, z.core.$strip>>>>;
126
- replaceInjuredImmediately: z.ZodDefault<z.ZodBoolean>;
126
+ replaceKnockedImmediately: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
127
127
  injuryReplacements: z.ZodDefault<z.ZodArray<z.ZodObject<{
128
128
  starter: z.ZodCustom<Player, Player>;
129
129
  replacement: z.ZodCustom<Player, Player>;
@@ -81,10 +81,11 @@ exports.TacticsInputSchema = zod_1.z.object({
81
81
  // validated with the same cross-field rules as the flat fields (see superRefine below).
82
82
  systemSets: zod_1.z.array(systemSetSchema).max(5).optional(),
83
83
  offensivePreferenceSets: zod_1.z.array(zod_1.z.array(offensivePreferenceSchema)).max(5).optional(),
84
- // Injuries. Defaults keep every existing Tactics.create({...}) call valid. injuryReplacements is the
85
- // per-starter explicit replacement mapping (owner rules: followed verbatim, bench-only auto fallback,
86
- // reserves never auto-promoted); cross-field rules in the superRefine below.
87
- replaceInjuredImmediately: zod_1.z.boolean().default(true),
84
+ // Injuries. Defaults keep every existing Tactics.create({...}) call valid. replaceKnockedImmediately is
85
+ // PER STARTER (owner order 2026-07-05): keyed by starter id, missing key = ON; keys validated against the
86
+ // lineup in the superRefine below. injuryReplacements is the per-starter explicit replacement mapping
87
+ // (owner rules: followed verbatim, bench-only auto fallback, reserves never auto-promoted).
88
+ replaceKnockedImmediately: zod_1.z.record(zod_1.z.string(), zod_1.z.boolean()).default({}),
88
89
  injuryReplacements: zod_1.z.array(zod_1.z.object({
89
90
  starter: playerInstanceSchema,
90
91
  replacement: playerInstanceSchema,
@@ -187,6 +188,13 @@ exports.TacticsInputSchema = zod_1.z.object({
187
188
  }
188
189
  }
189
190
  }
191
+ // Per-starter knock toggle: every key must be a configured court starter (bench/reserve/libero players
192
+ // follow the slot they occupy, so they never carry their own key).
193
+ for (const starterId of Object.keys(data.replaceKnockedImmediately)) {
194
+ if (!starterIds.has(starterId)) {
195
+ ctx.addIssue({ code: 'custom', message: 'REPLACE_KNOCKED_KEY_NOT_A_STARTER', path: ['replaceKnockedImmediately'] });
196
+ }
197
+ }
190
198
  // Injury replacements (owner rules 2026-07-03). starter = a court starter, at most one entry each.
191
199
  // replacement = a bench player or a reserve (never another starter, the libero, or the L2). benchBackfill
192
200
  // only when the replacement came from the bench, and must be a RESERVE (outside the whole lineup, not the
@@ -131,7 +131,7 @@ export declare const TeamInputSchema: z.ZodObject<{
131
131
  rotation: z.ZodNumber;
132
132
  order: z.ZodArray<z.ZodCustom<Player, Player>>;
133
133
  }, z.core.$strip>>>>;
134
- replaceInjuredImmediately: z.ZodDefault<z.ZodBoolean>;
134
+ replaceKnockedImmediately: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
135
135
  injuryReplacements: z.ZodDefault<z.ZodArray<z.ZodObject<{
136
136
  starter: z.ZodCustom<Player, Player>;
137
137
  replacement: z.ZodCustom<Player, Player>;
@@ -34,7 +34,7 @@ export interface TacticsOpts {
34
34
  readonly offensivePreferences: OffensivePreference[];
35
35
  readonly systemSets?: SystemSet[];
36
36
  readonly offensivePreferenceSets?: OffensivePreference[][];
37
- readonly replaceInjuredImmediately: boolean;
37
+ readonly replaceKnockedImmediately: Record<string, boolean>;
38
38
  readonly injuryReplacements: InjuryReplacement[];
39
39
  }
40
40
  export declare class Tactics {
@@ -51,7 +51,7 @@ export declare class Tactics {
51
51
  readonly offensivePreferences: OffensivePreference[];
52
52
  readonly systemSets?: SystemSet[];
53
53
  readonly offensivePreferenceSets?: OffensivePreference[][];
54
- readonly replaceInjuredImmediately: boolean;
54
+ readonly replaceKnockedImmediately: Record<string, boolean>;
55
55
  readonly injuryReplacements: InjuryReplacement[];
56
56
  static create(input: unknown): Tactics;
57
57
  private constructor();
@@ -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, replaceInjuredImmediately = true, injuryReplacements = [] }) {
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 = [] }) {
22
22
  this.lineup = lineup;
23
23
  this.liberoReplacements = liberoReplacements;
24
24
  this.receiveRotationOffset = receiveRotationOffset;
@@ -32,7 +32,7 @@ class Tactics {
32
32
  this.offensivePreferences = offensivePreferences;
33
33
  this.systemSets = systemSets;
34
34
  this.offensivePreferenceSets = offensivePreferenceSets;
35
- this.replaceInjuredImmediately = replaceInjuredImmediately;
35
+ this.replaceKnockedImmediately = replaceKnockedImmediately;
36
36
  this.injuryReplacements = injuryReplacements;
37
37
  }
38
38
  findPlayerInLineup(id) {
@@ -132,7 +132,7 @@ export interface Tactics {
132
132
  offensivePreferences: ApiOffensivePreference[];
133
133
  systemSets?: ApiSystemSet[];
134
134
  offensivePreferenceSets?: ApiOffensivePreference[][];
135
- replaceInjuredImmediately: boolean;
135
+ replaceKnockedImmediately?: Record<string, boolean>;
136
136
  injuryReplacements?: ApiInjuryReplacement[];
137
137
  }
138
138
  export interface ApiLineupPreset {
@@ -66,12 +66,12 @@ export interface TacticsAttributes {
66
66
  offensive_preferences: OffensivePreferenceAttributes[];
67
67
  system_sets?: SystemSetAttributes[];
68
68
  offensive_preference_sets?: OffensivePreferenceAttributes[][];
69
- replace_injured_immediately?: boolean;
69
+ replace_knocked_immediately?: Record<PlayerId, boolean>;
70
70
  injury_replacements?: InjuryReplacementAttributes[];
71
71
  }
72
72
  export type TacticsPk = 'team_id';
73
73
  export type TacticsId = TacticsModel[TacticsPk];
74
- 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_injured_immediately' | 'injury_replacements';
74
+ 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';
75
75
  export type TacticsCreationAttributes = Optional<TacticsAttributes, TacticsOptionalAttributes>;
76
76
  export declare class TacticsModel extends Model<TacticsAttributes, TacticsCreationAttributes> implements TacticsAttributes {
77
77
  team_id: string;
@@ -88,7 +88,7 @@ export declare class TacticsModel extends Model<TacticsAttributes, TacticsCreati
88
88
  offensive_preferences: OffensivePreferenceAttributes[];
89
89
  system_sets?: SystemSetAttributes[];
90
90
  offensive_preference_sets?: OffensivePreferenceAttributes[][];
91
- replace_injured_immediately?: boolean;
91
+ replace_knocked_immediately?: Record<PlayerId, boolean>;
92
92
  injury_replacements?: InjuryReplacementAttributes[];
93
93
  team: TeamModel;
94
94
  getTeam: Sequelize.BelongsToGetAssociationMixin<TeamModel>;
@@ -72,10 +72,9 @@ export class TacticsModel extends Model {
72
72
  type: DataTypes.JSONB,
73
73
  allowNull: true
74
74
  },
75
- replace_injured_immediately: {
76
- type: DataTypes.BOOLEAN,
77
- allowNull: false,
78
- defaultValue: true
75
+ replace_knocked_immediately: {
76
+ type: DataTypes.JSONB,
77
+ allowNull: true
79
78
  },
80
79
  injury_replacements: {
81
80
  type: DataTypes.JSONB,
@@ -1,17 +1,14 @@
1
1
  // `config` is already stored as the API Tactics DTO, so a preset row maps to its API shape with no hydration.
2
- // Presets saved before the injuries fields exist lack replaceInjuredImmediately: heal it to the effective
3
- // default (true, matching transformToTactics' hydration) so the DTO's required field is honest and a UI
4
- // toggle seeded from an old preset cannot silently flip the user's effective setting on re-save.
2
+ // No injuries heal here anymore: replaceKnockedImmediately is optional (absent = every starter ON), and old
3
+ // configs carrying the superseded replaceInjuredImmediately boolean pass through the spread as a dead key
4
+ // that the save DTO strips (owner 2026-07-05: the old global value is dropped, default ON).
5
5
  function transformToObject(model) {
6
6
  return {
7
7
  presetId: model.preset_id,
8
8
  name: model.name,
9
9
  isActive: model.is_active,
10
10
  orderIndex: model.order_index,
11
- config: {
12
- ...model.config,
13
- replaceInjuredImmediately: model.config.replaceInjuredImmediately ?? true
14
- }
11
+ config: model.config
15
12
  };
16
13
  }
17
14
  export { transformToObject as transformToLineupPreset };
@@ -1,7 +1,7 @@
1
1
  import { describe, it, expect } from '@jest/globals';
2
2
  import { transformToLineupPreset } from './lineup-preset';
3
3
  describe('transformToLineupPreset()', () => {
4
- it('maps preset columns to the API shape and heals a pre-injuries config to the effective default', () => {
4
+ it('maps preset columns to the API shape verbatim (no injuries heal: absent map = every starter ON)', () => {
5
5
  const config = { lineup: { bench: [] }, substitutionBand: 'TIRED' };
6
6
  const preset = transformToLineupPreset({
7
7
  preset_id: 'p1',
@@ -16,13 +16,15 @@ describe('transformToLineupPreset()', () => {
16
16
  name: 'Starters',
17
17
  isActive: true,
18
18
  orderIndex: 2,
19
- // A config stored before the injuries fields lacks replaceInjuredImmediately; the transform heals it
20
- // to true (the effective hydration default) so the DTO's required field is honest.
21
- config: { ...config, replaceInjuredImmediately: true }
19
+ config
22
20
  });
23
21
  });
24
- it('keeps an explicit replaceInjuredImmediately: false', () => {
25
- const config = { lineup: { bench: [] }, substitutionBand: 'TIRED', replaceInjuredImmediately: false };
22
+ it('keeps an explicit per-starter replaceKnockedImmediately map', () => {
23
+ const config = {
24
+ lineup: { bench: [] },
25
+ substitutionBand: 'TIRED',
26
+ replaceKnockedImmediately: { 'starter-1': false }
27
+ };
26
28
  const preset = transformToLineupPreset({
27
29
  preset_id: 'p2',
28
30
  team_id: 't1',
@@ -31,6 +33,6 @@ describe('transformToLineupPreset()', () => {
31
33
  order_index: 3,
32
34
  config
33
35
  });
34
- expect(preset.config.replaceInjuredImmediately).toBe(false);
36
+ expect(preset.config.replaceKnockedImmediately).toEqual({ 'starter-1': false });
35
37
  });
36
38
  });
@@ -82,7 +82,7 @@ function transformToAttributes(tactics, teamId) {
82
82
  rotation: p.rotation,
83
83
  order: p.order.map((a) => a.id)
84
84
  }))),
85
- replace_injured_immediately: tactics.replaceInjuredImmediately,
85
+ replace_knocked_immediately: tactics.replaceKnockedImmediately,
86
86
  injury_replacements: tactics.injuryReplacements.map(ir => ({
87
87
  starterId: ir.starter.id,
88
88
  replacementId: ir.replacement.id,
@@ -165,7 +165,19 @@ function transformToObject(model, roster) {
165
165
  rotation: p.rotation,
166
166
  order: p.order.map((id) => findPlayer(id, roster))
167
167
  }))),
168
- replaceInjuredImmediately: model.replace_injured_immediately ?? true,
168
+ // LENIENT: keep only starter-keyed boolean entries so stale jsonb (an ex-starter's key, a non-boolean
169
+ // value) can never fail schema validation and crash a team load; a dropped key = the ON default.
170
+ replaceKnockedImmediately: (() => {
171
+ const starterIds = new Set([lineup[CourtPosition.LEFT_FRONT], lineup[CourtPosition.MIDDLE_FRONT], lineup[CourtPosition.RIGHT_FRONT],
172
+ lineup[CourtPosition.LEFT_BACK], lineup[CourtPosition.MIDDLE_BACK], lineup[CourtPosition.RIGHT_BACK]]
173
+ .map((p) => p.id));
174
+ const healed = {};
175
+ for (const [id, value] of Object.entries(model.replace_knocked_immediately ?? {})) {
176
+ if (starterIds.has(id) && typeof value === 'boolean')
177
+ healed[id] = value;
178
+ }
179
+ return healed;
180
+ })(),
169
181
  // LENIENT (roster.find, not findPlayer): an entry whose starter or replacement is gone/invalid is DROPPED
170
182
  // rather than crashing the sim batch (a dropped entry = unconfigured = the bench-only auto fallback, per
171
183
  // the owner rules); an invalid benchBackfill drops only the backfill. First occurrence wins on duplicates;
@@ -264,7 +276,19 @@ function tacticsColumnsToApiDto(model) {
264
276
  offensivePreferences: model.offensive_preferences,
265
277
  systemSets: model.system_sets,
266
278
  offensivePreferenceSets: model.offensive_preference_sets,
267
- replaceInjuredImmediately: model.replace_injured_immediately ?? true,
279
+ // Same stale-data policy as the heals below: only starter-keyed boolean entries survive, and an empty
280
+ // map surfaces as absent (missing key = ON).
281
+ replaceKnockedImmediately: (() => {
282
+ const starterSlotIds = new Set([model.lineup[CourtPosition.LEFT_FRONT], model.lineup[CourtPosition.MIDDLE_FRONT],
283
+ model.lineup[CourtPosition.RIGHT_FRONT], model.lineup[CourtPosition.LEFT_BACK],
284
+ model.lineup[CourtPosition.MIDDLE_BACK], model.lineup[CourtPosition.RIGHT_BACK]]);
285
+ const healed = {};
286
+ for (const [id, value] of Object.entries(model.replace_knocked_immediately ?? {})) {
287
+ if (starterSlotIds.has(id) && typeof value === 'boolean')
288
+ healed[id] = value;
289
+ }
290
+ return Object.keys(healed).length > 0 ? healed : undefined;
291
+ })(),
268
292
  // Same stale-data policy as validSecondLibero above: don't surface entries the service load would drop
269
293
  // (starter no longer a starter, replacement now a starter/libero/L2, duplicates), or the UI reloads and
270
294
  // re-emits them forever. Roster membership can't be checked here (ids only); the service heal covers that.
@@ -329,7 +353,7 @@ function apiTacticsConfigToAttributes(config, teamId) {
329
353
  offensive_preferences: config.offensivePreferences,
330
354
  system_sets: config.systemSets,
331
355
  offensive_preference_sets: config.offensivePreferenceSets,
332
- replace_injured_immediately: config.replaceInjuredImmediately,
356
+ replace_knocked_immediately: config.replaceKnockedImmediately,
333
357
  injury_replacements: config.injuryReplacements
334
358
  };
335
359
  }
@@ -2,7 +2,8 @@ import { Rarity } from './rarity';
2
2
  export declare enum InjurySeverityEnum {
3
3
  MINOR = 1,
4
4
  MODERATE = 2,
5
- SEVERE = 3
5
+ SEVERE = 3,
6
+ CRITICAL = 4
6
7
  }
7
8
  export interface PlayerInjury {
8
9
  readonly severity: number;
@@ -4,6 +4,7 @@ export var InjurySeverityEnum;
4
4
  InjurySeverityEnum[InjurySeverityEnum["MINOR"] = 1] = "MINOR";
5
5
  InjurySeverityEnum[InjurySeverityEnum["MODERATE"] = 2] = "MODERATE";
6
6
  InjurySeverityEnum[InjurySeverityEnum["SEVERE"] = 3] = "SEVERE";
7
+ InjurySeverityEnum[InjurySeverityEnum["CRITICAL"] = 4] = "CRITICAL";
7
8
  })(InjurySeverityEnum || (InjurySeverityEnum = {}));
8
9
  // A long-term injury is active while `at` is before injuredUntil.
9
10
  export function hasActiveInjury(injury, at) {
@@ -24,7 +24,8 @@ describe('Player injury fields', () => {
24
24
  }
25
25
  });
26
26
  it('rejects out-of-range severity and profile', () => {
27
- expect(PlayerInputSchema.safeParse({ ...validInput(), injury: { severity: 4, injuredUntil: new Date() } }).success).toBe(false);
27
+ expect(PlayerInputSchema.safeParse({ ...validInput(), injury: { severity: 4, injuredUntil: new Date() } }).success).toBe(true);
28
+ expect(PlayerInputSchema.safeParse({ ...validInput(), injury: { severity: 5, injuredUntil: new Date() } }).success).toBe(false);
28
29
  expect(PlayerInputSchema.safeParse({ ...validInput(), injury: { severity: 0, injuredUntil: new Date() } }).success).toBe(false);
29
30
  expect(PlayerInputSchema.safeParse({ ...validInput(), injuryProneProfile: 1.2 }).success).toBe(false);
30
31
  expect(PlayerInputSchema.safeParse({ ...validInput(), injuryProneProfile: -0.1 }).success).toBe(false);
@@ -38,7 +38,7 @@ export const PlayerInputSchema = z.object({
38
38
  // Defaulted so every existing Player.create caller (generator, transformers, test helpers) stays valid.
39
39
  injuryProneProfile: z.number().min(0).max(1).default(0.5),
40
40
  injury: z.object({
41
- severity: z.number().int().min(1).max(3),
41
+ severity: z.number().int().min(1).max(4),
42
42
  injuredUntil: z.coerce.date(),
43
43
  debuff: z.number().min(0).max(1).optional()
44
44
  }).optional(),
@@ -30,14 +30,40 @@ describe('TacticsInputSchema — injuryReplacements (owner rules 2026-07-03)', (
30
30
  ...overrides
31
31
  };
32
32
  }
33
- it('defaults to true / empty', () => {
33
+ it('defaults to an empty knock map / empty replacements', () => {
34
34
  const res = TacticsInputSchema.safeParse(baseInput());
35
35
  expect(res.success).toBe(true);
36
36
  if (res.success) {
37
- expect(res.data.replaceInjuredImmediately).toBe(true);
37
+ expect(res.data.replaceKnockedImmediately).toEqual({});
38
38
  expect(res.data.injuryReplacements).toEqual([]);
39
39
  }
40
40
  });
41
+ it('accepts a starter-keyed replaceKnockedImmediately map', () => {
42
+ const res = TacticsInputSchema.safeParse(baseInput({
43
+ replaceKnockedImmediately: { [p1.id]: false, [p3.id]: true }
44
+ }));
45
+ expect(res.success).toBe(true);
46
+ if (res.success) {
47
+ expect(res.data.replaceKnockedImmediately).toEqual({ [p1.id]: false, [p3.id]: true });
48
+ }
49
+ });
50
+ it('rejects a replaceKnockedImmediately key that is not a court starter', () => {
51
+ for (const outsider of [benchA, libero, reserveA]) {
52
+ const res = TacticsInputSchema.safeParse(baseInput({
53
+ replaceKnockedImmediately: { [outsider.id]: false }
54
+ }));
55
+ expect(res.success).toBe(false);
56
+ if (!res.success) {
57
+ expect(res.error.issues.some(issue => issue.message === 'REPLACE_KNOCKED_KEY_NOT_A_STARTER')).toBe(true);
58
+ }
59
+ }
60
+ });
61
+ it('rejects a non-boolean replaceKnockedImmediately value', () => {
62
+ const res = TacticsInputSchema.safeParse(baseInput({
63
+ replaceKnockedImmediately: { [p1.id]: 'off' }
64
+ }));
65
+ expect(res.success).toBe(false);
66
+ });
41
67
  it('accepts a bench replacement with a reserve backfill', () => {
42
68
  const res = TacticsInputSchema.safeParse(baseInput({
43
69
  injuryReplacements: [{ starter: p1, replacement: benchA, benchBackfill: reserveA }]
@@ -123,7 +123,7 @@ export declare const TacticsInputSchema: z.ZodObject<{
123
123
  rotation: z.ZodNumber;
124
124
  order: z.ZodArray<z.ZodCustom<Player, Player>>;
125
125
  }, z.core.$strip>>>>;
126
- replaceInjuredImmediately: z.ZodDefault<z.ZodBoolean>;
126
+ replaceKnockedImmediately: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
127
127
  injuryReplacements: z.ZodDefault<z.ZodArray<z.ZodObject<{
128
128
  starter: z.ZodCustom<Player, Player>;
129
129
  replacement: z.ZodCustom<Player, Player>;
@@ -78,10 +78,11 @@ export const TacticsInputSchema = z.object({
78
78
  // validated with the same cross-field rules as the flat fields (see superRefine below).
79
79
  systemSets: z.array(systemSetSchema).max(5).optional(),
80
80
  offensivePreferenceSets: z.array(z.array(offensivePreferenceSchema)).max(5).optional(),
81
- // Injuries. Defaults keep every existing Tactics.create({...}) call valid. injuryReplacements is the
82
- // per-starter explicit replacement mapping (owner rules: followed verbatim, bench-only auto fallback,
83
- // reserves never auto-promoted); cross-field rules in the superRefine below.
84
- replaceInjuredImmediately: z.boolean().default(true),
81
+ // Injuries. Defaults keep every existing Tactics.create({...}) call valid. replaceKnockedImmediately is
82
+ // PER STARTER (owner order 2026-07-05): keyed by starter id, missing key = ON; keys validated against the
83
+ // lineup in the superRefine below. injuryReplacements is the per-starter explicit replacement mapping
84
+ // (owner rules: followed verbatim, bench-only auto fallback, reserves never auto-promoted).
85
+ replaceKnockedImmediately: z.record(z.string(), z.boolean()).default({}),
85
86
  injuryReplacements: z.array(z.object({
86
87
  starter: playerInstanceSchema,
87
88
  replacement: playerInstanceSchema,
@@ -184,6 +185,13 @@ export const TacticsInputSchema = z.object({
184
185
  }
185
186
  }
186
187
  }
188
+ // Per-starter knock toggle: every key must be a configured court starter (bench/reserve/libero players
189
+ // follow the slot they occupy, so they never carry their own key).
190
+ for (const starterId of Object.keys(data.replaceKnockedImmediately)) {
191
+ if (!starterIds.has(starterId)) {
192
+ ctx.addIssue({ code: 'custom', message: 'REPLACE_KNOCKED_KEY_NOT_A_STARTER', path: ['replaceKnockedImmediately'] });
193
+ }
194
+ }
187
195
  // Injury replacements (owner rules 2026-07-03). starter = a court starter, at most one entry each.
188
196
  // replacement = a bench player or a reserve (never another starter, the libero, or the L2). benchBackfill
189
197
  // only when the replacement came from the bench, and must be a RESERVE (outside the whole lineup, not the
@@ -131,7 +131,7 @@ export declare const TeamInputSchema: z.ZodObject<{
131
131
  rotation: z.ZodNumber;
132
132
  order: z.ZodArray<z.ZodCustom<Player, Player>>;
133
133
  }, z.core.$strip>>>>;
134
- replaceInjuredImmediately: z.ZodDefault<z.ZodBoolean>;
134
+ replaceKnockedImmediately: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
135
135
  injuryReplacements: z.ZodDefault<z.ZodArray<z.ZodObject<{
136
136
  starter: z.ZodCustom<Player, Player>;
137
137
  replacement: z.ZodCustom<Player, Player>;
@@ -34,7 +34,7 @@ export interface TacticsOpts {
34
34
  readonly offensivePreferences: OffensivePreference[];
35
35
  readonly systemSets?: SystemSet[];
36
36
  readonly offensivePreferenceSets?: OffensivePreference[][];
37
- readonly replaceInjuredImmediately: boolean;
37
+ readonly replaceKnockedImmediately: Record<string, boolean>;
38
38
  readonly injuryReplacements: InjuryReplacement[];
39
39
  }
40
40
  export declare class Tactics {
@@ -51,7 +51,7 @@ export declare class Tactics {
51
51
  readonly offensivePreferences: OffensivePreference[];
52
52
  readonly systemSets?: SystemSet[];
53
53
  readonly offensivePreferenceSets?: OffensivePreference[][];
54
- readonly replaceInjuredImmediately: boolean;
54
+ readonly replaceKnockedImmediately: Record<string, boolean>;
55
55
  readonly injuryReplacements: InjuryReplacement[];
56
56
  static create(input: unknown): Tactics;
57
57
  private constructor();
@@ -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 = [], systemSets, offensivePreferenceSets, replaceInjuredImmediately = true, injuryReplacements = [] }) {
18
+ constructor({ lineup, liberoReplacements, receiveRotationOffset = false, designatedSubs = [], substitutionBand = EnergyBand.TIRED, secondLibero, liberoSub, liberoReplacementSets, rotationSystem = RotationSystemEnum.SIX_ZERO, designatedSetters = [], offensivePreferences = [], systemSets, offensivePreferenceSets, replaceKnockedImmediately = {}, injuryReplacements = [] }) {
19
19
  this.lineup = lineup;
20
20
  this.liberoReplacements = liberoReplacements;
21
21
  this.receiveRotationOffset = receiveRotationOffset;
@@ -29,7 +29,7 @@ export class Tactics {
29
29
  this.offensivePreferences = offensivePreferences;
30
30
  this.systemSets = systemSets;
31
31
  this.offensivePreferenceSets = offensivePreferenceSets;
32
- this.replaceInjuredImmediately = replaceInjuredImmediately;
32
+ this.replaceKnockedImmediately = replaceKnockedImmediately;
33
33
  this.injuryReplacements = injuryReplacements;
34
34
  }
35
35
  findPlayerInLineup(id) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "volleyballsimtypes",
3
- "version": "0.0.467",
3
+ "version": "0.0.470",
4
4
  "description": "vbsim types",
5
5
  "main": "./dist/cjs/src/index.js",
6
6
  "module": "./dist/esm/src/index.js",