volleyballsimtypes 0.0.433 → 0.0.436

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.
@@ -92,15 +92,26 @@ function buildPinchServerSubs(pinchServerSubs, roster) {
92
92
  }
93
93
  function transformToObject(model, roster) {
94
94
  const lineup = transformToLineup(model.lineup, roster);
95
- // A second libero only makes sense alongside a starting libero (LIBERO_ZONE). Edge/legacy data can carry a
96
- // second_libero (and its swap config) with no starting libero -- e.g. the starting libero was removed
97
- // without clearing it -- which fails validation on load and would crash the whole sim batch. Drop the
98
- // orphaned pair so the team still loads (it just plays without liberos); the next tactics save rewrites it.
99
- // Drop the second libero when there's no starting libero, OR when the L2 player is no longer on the roster
100
- // (e.g. retired/dismissed) -- both leave it orphaned. Use roster.find (not findPlayer) so a gone L2 yields
95
+ // A second libero is only valid as a RESERVE: it needs a starting libero (LIBERO_ZONE), the L2 player must be
96
+ // on the roster, and it must NOT already occupy the lineup (the 6 starters, the libero zone, or the bench).
97
+ // Edge/legacy data can violate any of these -- the starting libero was removed without clearing the L2, the
98
+ // L2 retired/was dismissed, or the L2 is also the starting libero -- and any such row fails validation on
99
+ // load (SECOND_LIBERO_MUST_BE_RESERVE / REQUIRES_LIBERO) and would crash the whole sim batch. Drop the
100
+ // offending second libero (and, via the gate below, its swap config) so the team still loads and plays
101
+ // without an L2; the next tactics save rewrites it cleanly. roster.find (not findPlayer) so a gone L2 yields
101
102
  // undefined instead of throwing.
103
+ const inLineupIds = new Set([
104
+ lineup[service_1.CourtPosition.LEFT_FRONT].id,
105
+ lineup[service_1.CourtPosition.MIDDLE_FRONT].id,
106
+ lineup[service_1.CourtPosition.RIGHT_FRONT].id,
107
+ lineup[service_1.CourtPosition.LEFT_BACK].id,
108
+ lineup[service_1.CourtPosition.MIDDLE_BACK].id,
109
+ lineup[service_1.CourtPosition.RIGHT_BACK].id,
110
+ ...(lineup[service_1.CourtPosition.LIBERO_ZONE] != null ? [lineup[service_1.CourtPosition.LIBERO_ZONE].id] : []),
111
+ ...lineup.bench.map((b) => b.id)
112
+ ]);
102
113
  const hasStartingLibero = lineup[service_1.CourtPosition.LIBERO_ZONE] != null;
103
- const secondLibero = (hasStartingLibero && model.second_libero != null)
114
+ const secondLibero = (hasStartingLibero && model.second_libero != null && !inLineupIds.has(model.second_libero))
104
115
  ? roster.find((p) => p.id === model.second_libero)
105
116
  : undefined;
106
117
  return service_1.Tactics.create({
@@ -143,6 +154,23 @@ function transformToObject(model, roster) {
143
154
  // to keep the active preset and the mirrored Tactics row in the same shape. Applies the same legacy fixups as
144
155
  // the service transformer: resolve the designated-sub `mode` and drop a legacy 'NEVER' fatigueBand.
145
156
  function tacticsColumnsToApiDto(model) {
157
+ // Same reserve-only fixup as the service transformer (transformToObject): a second libero with no starting
158
+ // libero, or one that already occupies the lineup (6 starters + libero zone + bench), is invalid, so don't
159
+ // surface it (or its swap config) to the API/preset shape -- otherwise the UI would reload and re-emit a
160
+ // collision that the server then rejects.
161
+ const lineupIds = new Set([
162
+ model.lineup[service_1.CourtPosition.LEFT_FRONT],
163
+ model.lineup[service_1.CourtPosition.MIDDLE_FRONT],
164
+ model.lineup[service_1.CourtPosition.RIGHT_FRONT],
165
+ model.lineup[service_1.CourtPosition.LEFT_BACK],
166
+ model.lineup[service_1.CourtPosition.MIDDLE_BACK],
167
+ model.lineup[service_1.CourtPosition.RIGHT_BACK],
168
+ ...(model.lineup[service_1.CourtPosition.LIBERO_ZONE] != null ? [model.lineup[service_1.CourtPosition.LIBERO_ZONE]] : []),
169
+ ...model.lineup.bench
170
+ ]);
171
+ const validSecondLibero = (model.second_libero != null && model.lineup[service_1.CourtPosition.LIBERO_ZONE] != null && !lineupIds.has(model.second_libero))
172
+ ? model.second_libero
173
+ : undefined;
146
174
  return {
147
175
  lineup: model.lineup,
148
176
  substitutionTolerance: model.substitution_tolerance,
@@ -150,8 +178,8 @@ function tacticsColumnsToApiDto(model) {
150
178
  pinchServerSubs: model.pinch_server_subs,
151
179
  receiveRotationOffset: model.receive_rotation_offset,
152
180
  substitutionBand: model.substitution_band,
153
- secondLibero: model.second_libero,
154
- liberoSub: model.libero_sub,
181
+ secondLibero: validSecondLibero,
182
+ liberoSub: validSecondLibero != null ? model.libero_sub : undefined,
155
183
  liberoReplacementSets: model.libero_replacement_sets,
156
184
  designatedSubs: (model.designated_subs ?? []).map(d => ({
157
185
  starterId: d.starterId,
@@ -63,4 +63,22 @@ function makeTactics(designatedSubs) {
63
63
  (0, globals_1.expect)(dto.designatedSubs[0].fatigueBand).toBe(service_1.EnergyBand.WORN);
64
64
  (0, globals_1.expect)(dto.designatedSubs[0].benchFatigueBand).toBe(service_1.EnergyBand.TIRED);
65
65
  });
66
+ (0, globals_1.it)('drops a second libero that collides with the starting libero (not a reserve)', () => {
67
+ const model = makeTactics([]);
68
+ model.lineup[service_1.CourtPosition.LIBERO_ZONE] = 'libero1';
69
+ model.second_libero = 'libero1'; // same player as the starting libero -> invalid
70
+ model.libero_sub = { mode: 'FATIGUE' };
71
+ const dto = (0, tactics_1.tacticsColumnsToApiDto)(model);
72
+ (0, globals_1.expect)(dto.secondLibero).toBeUndefined();
73
+ (0, globals_1.expect)(dto.liberoSub).toBeUndefined();
74
+ });
75
+ (0, globals_1.it)('keeps a second libero that is a genuine reserve', () => {
76
+ const model = makeTactics([]);
77
+ model.lineup[service_1.CourtPosition.LIBERO_ZONE] = 'libero1';
78
+ model.second_libero = 'reserve1'; // not in the lineup -> valid reserve
79
+ model.libero_sub = { mode: 'FATIGUE' };
80
+ const dto = (0, tactics_1.tacticsColumnsToApiDto)(model);
81
+ (0, globals_1.expect)(dto.secondLibero).toBe('reserve1');
82
+ (0, globals_1.expect)(dto.liberoSub).toEqual({ mode: 'FATIGUE' });
83
+ });
66
84
  });
@@ -123,10 +123,27 @@ function selectImprovableStat(rarity, trainingFocus, currentStats) {
123
123
  if ((multipliers[key] ?? 0) > 0)
124
124
  focusPool.push(col);
125
125
  }
126
- if (focusPool.length > 0) {
127
- return focusPool[Math.floor(Math.random() * focusPool.length)];
128
- }
129
- if (fallbackPool.length === 0)
126
+ const pool = focusPool.length > 0 ? focusPool : fallbackPool;
127
+ if (pool.length === 0)
130
128
  return null;
131
- return fallbackPool[Math.floor(Math.random() * fallbackPool.length)];
129
+ return weightedPickByRoom(pool, currentStats, cap);
130
+ }
131
+ // Pick a stat biased toward the ones furthest below the cap (weight = cap - stat), so improvements fill a
132
+ // player's weaknesses first and they are far less likely to end lopsided by chance. The bias is linear and
133
+ // soft: a low stat is more likely but a higher one can still be picked, so players keep some natural variance
134
+ // rather than converging on an identical flat sheet. Capped stats carry weight 0 (and are already excluded).
135
+ function weightedPickByRoom(pool, currentStats, cap) {
136
+ let total = 0;
137
+ for (const col of pool)
138
+ total += Math.max(0, cap - (currentStats[col] ?? 0));
139
+ // Degenerate guard: if every stat is at the cap (total room 0), fall back to a uniform pick.
140
+ if (total <= 0)
141
+ return pool[Math.floor(Math.random() * pool.length)];
142
+ let r = Math.random() * total;
143
+ for (const col of pool) {
144
+ r -= Math.max(0, cap - (currentStats[col] ?? 0));
145
+ if (r < 0)
146
+ return col;
147
+ }
148
+ return pool[pool.length - 1];
132
149
  }
@@ -87,15 +87,26 @@ function buildPinchServerSubs(pinchServerSubs, roster) {
87
87
  }
88
88
  function transformToObject(model, roster) {
89
89
  const lineup = transformToLineup(model.lineup, roster);
90
- // A second libero only makes sense alongside a starting libero (LIBERO_ZONE). Edge/legacy data can carry a
91
- // second_libero (and its swap config) with no starting libero -- e.g. the starting libero was removed
92
- // without clearing it -- which fails validation on load and would crash the whole sim batch. Drop the
93
- // orphaned pair so the team still loads (it just plays without liberos); the next tactics save rewrites it.
94
- // Drop the second libero when there's no starting libero, OR when the L2 player is no longer on the roster
95
- // (e.g. retired/dismissed) -- both leave it orphaned. Use roster.find (not findPlayer) so a gone L2 yields
90
+ // A second libero is only valid as a RESERVE: it needs a starting libero (LIBERO_ZONE), the L2 player must be
91
+ // on the roster, and it must NOT already occupy the lineup (the 6 starters, the libero zone, or the bench).
92
+ // Edge/legacy data can violate any of these -- the starting libero was removed without clearing the L2, the
93
+ // L2 retired/was dismissed, or the L2 is also the starting libero -- and any such row fails validation on
94
+ // load (SECOND_LIBERO_MUST_BE_RESERVE / REQUIRES_LIBERO) and would crash the whole sim batch. Drop the
95
+ // offending second libero (and, via the gate below, its swap config) so the team still loads and plays
96
+ // without an L2; the next tactics save rewrites it cleanly. roster.find (not findPlayer) so a gone L2 yields
96
97
  // undefined instead of throwing.
98
+ const inLineupIds = new Set([
99
+ lineup[CourtPosition.LEFT_FRONT].id,
100
+ lineup[CourtPosition.MIDDLE_FRONT].id,
101
+ lineup[CourtPosition.RIGHT_FRONT].id,
102
+ lineup[CourtPosition.LEFT_BACK].id,
103
+ lineup[CourtPosition.MIDDLE_BACK].id,
104
+ lineup[CourtPosition.RIGHT_BACK].id,
105
+ ...(lineup[CourtPosition.LIBERO_ZONE] != null ? [lineup[CourtPosition.LIBERO_ZONE].id] : []),
106
+ ...lineup.bench.map((b) => b.id)
107
+ ]);
97
108
  const hasStartingLibero = lineup[CourtPosition.LIBERO_ZONE] != null;
98
- const secondLibero = (hasStartingLibero && model.second_libero != null)
109
+ const secondLibero = (hasStartingLibero && model.second_libero != null && !inLineupIds.has(model.second_libero))
99
110
  ? roster.find((p) => p.id === model.second_libero)
100
111
  : undefined;
101
112
  return Tactics.create({
@@ -138,6 +149,23 @@ function transformToObject(model, roster) {
138
149
  // to keep the active preset and the mirrored Tactics row in the same shape. Applies the same legacy fixups as
139
150
  // the service transformer: resolve the designated-sub `mode` and drop a legacy 'NEVER' fatigueBand.
140
151
  function tacticsColumnsToApiDto(model) {
152
+ // Same reserve-only fixup as the service transformer (transformToObject): a second libero with no starting
153
+ // libero, or one that already occupies the lineup (6 starters + libero zone + bench), is invalid, so don't
154
+ // surface it (or its swap config) to the API/preset shape -- otherwise the UI would reload and re-emit a
155
+ // collision that the server then rejects.
156
+ const lineupIds = new Set([
157
+ model.lineup[CourtPosition.LEFT_FRONT],
158
+ model.lineup[CourtPosition.MIDDLE_FRONT],
159
+ model.lineup[CourtPosition.RIGHT_FRONT],
160
+ model.lineup[CourtPosition.LEFT_BACK],
161
+ model.lineup[CourtPosition.MIDDLE_BACK],
162
+ model.lineup[CourtPosition.RIGHT_BACK],
163
+ ...(model.lineup[CourtPosition.LIBERO_ZONE] != null ? [model.lineup[CourtPosition.LIBERO_ZONE]] : []),
164
+ ...model.lineup.bench
165
+ ]);
166
+ const validSecondLibero = (model.second_libero != null && model.lineup[CourtPosition.LIBERO_ZONE] != null && !lineupIds.has(model.second_libero))
167
+ ? model.second_libero
168
+ : undefined;
141
169
  return {
142
170
  lineup: model.lineup,
143
171
  substitutionTolerance: model.substitution_tolerance,
@@ -145,8 +173,8 @@ function tacticsColumnsToApiDto(model) {
145
173
  pinchServerSubs: model.pinch_server_subs,
146
174
  receiveRotationOffset: model.receive_rotation_offset,
147
175
  substitutionBand: model.substitution_band,
148
- secondLibero: model.second_libero,
149
- liberoSub: model.libero_sub,
176
+ secondLibero: validSecondLibero,
177
+ liberoSub: validSecondLibero != null ? model.libero_sub : undefined,
150
178
  liberoReplacementSets: model.libero_replacement_sets,
151
179
  designatedSubs: (model.designated_subs ?? []).map(d => ({
152
180
  starterId: d.starterId,
@@ -61,4 +61,22 @@ describe('tacticsColumnsToApiDto()', () => {
61
61
  expect(dto.designatedSubs[0].fatigueBand).toBe(EnergyBand.WORN);
62
62
  expect(dto.designatedSubs[0].benchFatigueBand).toBe(EnergyBand.TIRED);
63
63
  });
64
+ it('drops a second libero that collides with the starting libero (not a reserve)', () => {
65
+ const model = makeTactics([]);
66
+ model.lineup[CourtPosition.LIBERO_ZONE] = 'libero1';
67
+ model.second_libero = 'libero1'; // same player as the starting libero -> invalid
68
+ model.libero_sub = { mode: 'FATIGUE' };
69
+ const dto = tacticsColumnsToApiDto(model);
70
+ expect(dto.secondLibero).toBeUndefined();
71
+ expect(dto.liberoSub).toBeUndefined();
72
+ });
73
+ it('keeps a second libero that is a genuine reserve', () => {
74
+ const model = makeTactics([]);
75
+ model.lineup[CourtPosition.LIBERO_ZONE] = 'libero1';
76
+ model.second_libero = 'reserve1'; // not in the lineup -> valid reserve
77
+ model.libero_sub = { mode: 'FATIGUE' };
78
+ const dto = tacticsColumnsToApiDto(model);
79
+ expect(dto.secondLibero).toBe('reserve1');
80
+ expect(dto.liberoSub).toEqual({ mode: 'FATIGUE' });
81
+ });
64
82
  });
@@ -113,10 +113,27 @@ export function selectImprovableStat(rarity, trainingFocus, currentStats) {
113
113
  if ((multipliers[key] ?? 0) > 0)
114
114
  focusPool.push(col);
115
115
  }
116
- if (focusPool.length > 0) {
117
- return focusPool[Math.floor(Math.random() * focusPool.length)];
118
- }
119
- if (fallbackPool.length === 0)
116
+ const pool = focusPool.length > 0 ? focusPool : fallbackPool;
117
+ if (pool.length === 0)
120
118
  return null;
121
- return fallbackPool[Math.floor(Math.random() * fallbackPool.length)];
119
+ return weightedPickByRoom(pool, currentStats, cap);
120
+ }
121
+ // Pick a stat biased toward the ones furthest below the cap (weight = cap - stat), so improvements fill a
122
+ // player's weaknesses first and they are far less likely to end lopsided by chance. The bias is linear and
123
+ // soft: a low stat is more likely but a higher one can still be picked, so players keep some natural variance
124
+ // rather than converging on an identical flat sheet. Capped stats carry weight 0 (and are already excluded).
125
+ function weightedPickByRoom(pool, currentStats, cap) {
126
+ let total = 0;
127
+ for (const col of pool)
128
+ total += Math.max(0, cap - (currentStats[col] ?? 0));
129
+ // Degenerate guard: if every stat is at the cap (total room 0), fall back to a uniform pick.
130
+ if (total <= 0)
131
+ return pool[Math.floor(Math.random() * pool.length)];
132
+ let r = Math.random() * total;
133
+ for (const col of pool) {
134
+ r -= Math.max(0, cap - (currentStats[col] ?? 0));
135
+ if (r < 0)
136
+ return col;
137
+ }
138
+ return pool[pool.length - 1];
122
139
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "volleyballsimtypes",
3
- "version": "0.0.433",
3
+ "version": "0.0.436",
4
4
  "description": "vbsim types",
5
5
  "main": "./dist/cjs/src/index.js",
6
6
  "module": "./dist/esm/src/index.js",