volleyballsimtypes 0.0.499 → 0.0.501
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.
- package/dist/cjs/src/api/index.d.ts +1 -0
- package/dist/cjs/src/data/models/tactics.d.ts +3 -1
- package/dist/cjs/src/data/models/tactics.js +5 -0
- package/dist/cjs/src/data/transformers/tactics.js +32 -0
- package/dist/cjs/src/data/transformers/tactics.test.js +37 -0
- package/dist/cjs/src/service/competition/event-rewards.d.ts +3 -2
- package/dist/cjs/src/service/competition/event-rewards.js +5 -3
- package/dist/cjs/src/service/competition/event-rewards.test.js +9 -9
- package/dist/cjs/src/service/team/schemas/tactics.z.d.ts +1 -0
- package/dist/cjs/src/service/team/schemas/tactics.z.js +20 -0
- package/dist/cjs/src/service/team/schemas/tactics.z.test.js +80 -0
- package/dist/cjs/src/service/team/schemas/team.z.d.ts +1 -0
- package/dist/cjs/src/service/team/tactics.d.ts +2 -0
- package/dist/cjs/src/service/team/tactics.js +2 -1
- package/dist/esm/src/api/index.d.ts +1 -0
- package/dist/esm/src/data/models/tactics.d.ts +3 -1
- package/dist/esm/src/data/models/tactics.js +5 -0
- package/dist/esm/src/data/transformers/tactics.js +32 -0
- package/dist/esm/src/data/transformers/tactics.test.js +37 -0
- package/dist/esm/src/service/competition/event-rewards.d.ts +3 -2
- package/dist/esm/src/service/competition/event-rewards.js +5 -3
- package/dist/esm/src/service/competition/event-rewards.test.js +9 -9
- package/dist/esm/src/service/team/schemas/tactics.z.d.ts +1 -0
- package/dist/esm/src/service/team/schemas/tactics.z.js +20 -0
- package/dist/esm/src/service/team/schemas/tactics.z.test.js +80 -0
- package/dist/esm/src/service/team/schemas/team.z.d.ts +1 -0
- package/dist/esm/src/service/team/tactics.d.ts +2 -0
- package/dist/esm/src/service/team/tactics.js +2 -1
- package/package.json +1 -1
|
@@ -139,6 +139,7 @@ export interface Tactics {
|
|
|
139
139
|
offensivePreferences: ApiOffensivePreference[];
|
|
140
140
|
systemSets?: ApiSystemSet[];
|
|
141
141
|
offensivePreferenceSets?: ApiOffensivePreference[][];
|
|
142
|
+
serveAttackShares?: Record<string, number>;
|
|
142
143
|
blockingPreferences: ApiBlockingPreference[];
|
|
143
144
|
blockingPreferenceSets?: ApiBlockingPreference[][];
|
|
144
145
|
replaceKnockedImmediately?: Record<string, boolean>;
|
|
@@ -76,6 +76,7 @@ export interface TacticsAttributes {
|
|
|
76
76
|
offensive_preferences: OffensivePreferenceAttributes[];
|
|
77
77
|
system_sets?: SystemSetAttributes[];
|
|
78
78
|
offensive_preference_sets?: OffensivePreferenceAttributes[][];
|
|
79
|
+
serve_attack_shares: Record<PlayerId, number>;
|
|
79
80
|
replace_knocked_immediately?: Record<PlayerId, boolean>;
|
|
80
81
|
injury_replacements?: InjuryReplacementAttributes[];
|
|
81
82
|
libero_injury?: LiberoInjuryAttributes;
|
|
@@ -84,7 +85,7 @@ export interface TacticsAttributes {
|
|
|
84
85
|
}
|
|
85
86
|
export type TacticsPk = 'team_id';
|
|
86
87
|
export type TacticsId = TacticsModel[TacticsPk];
|
|
87
|
-
export type TacticsOptionalAttributes = 'lineup' | 'libero_replacements' | 'receive_rotation_offset' | 'designated_subs' | 'substitution_band' | 'second_libero' | 'libero_sub' | 'libero_replacement_sets' | 'rotation_system' | 'designated_setters' | 'offensive_preferences' | 'system_sets' | 'offensive_preference_sets' | 'replace_knocked_immediately' | 'injury_replacements' | 'libero_injury' | 'blocking_preferences' | 'blocking_preference_sets';
|
|
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';
|
|
88
89
|
export type TacticsCreationAttributes = Optional<TacticsAttributes, TacticsOptionalAttributes>;
|
|
89
90
|
export declare class TacticsModel extends Model<TacticsAttributes, TacticsCreationAttributes> implements TacticsAttributes {
|
|
90
91
|
team_id: string;
|
|
@@ -101,6 +102,7 @@ export declare class TacticsModel extends Model<TacticsAttributes, TacticsCreati
|
|
|
101
102
|
offensive_preferences: OffensivePreferenceAttributes[];
|
|
102
103
|
system_sets?: SystemSetAttributes[];
|
|
103
104
|
offensive_preference_sets?: OffensivePreferenceAttributes[][];
|
|
105
|
+
serve_attack_shares: Record<PlayerId, number>;
|
|
104
106
|
replace_knocked_immediately?: Record<PlayerId, boolean>;
|
|
105
107
|
injury_replacements?: InjuryReplacementAttributes[];
|
|
106
108
|
libero_injury?: LiberoInjuryAttributes;
|
|
@@ -75,6 +75,11 @@ class TacticsModel extends sequelize_1.Model {
|
|
|
75
75
|
type: sequelize_1.DataTypes.JSONB,
|
|
76
76
|
allowNull: true
|
|
77
77
|
},
|
|
78
|
+
serve_attack_shares: {
|
|
79
|
+
type: sequelize_1.DataTypes.JSONB,
|
|
80
|
+
allowNull: false,
|
|
81
|
+
defaultValue: {}
|
|
82
|
+
},
|
|
78
83
|
replace_knocked_immediately: {
|
|
79
84
|
type: sequelize_1.DataTypes.JSONB,
|
|
80
85
|
allowNull: true
|
|
@@ -85,6 +85,7 @@ function transformToAttributes(tactics, teamId) {
|
|
|
85
85
|
weights: p.weights,
|
|
86
86
|
tempos: p.tempos
|
|
87
87
|
}))),
|
|
88
|
+
serve_attack_shares: tactics.serveAttackShares ?? {},
|
|
88
89
|
blocking_preferences: tactics.blockingPreferences.map(p => ({ rotation: p.rotation, targets: p.targets })),
|
|
89
90
|
blocking_preference_sets: tactics.blockingPreferenceSets?.map(prefs => prefs.map(p => ({ rotation: p.rotation, targets: p.targets }))),
|
|
90
91
|
replace_knocked_immediately: tactics.replaceKnockedImmediately,
|
|
@@ -191,6 +192,21 @@ function transformToObject(model, roster) {
|
|
|
191
192
|
weights: p.weights,
|
|
192
193
|
tempos: p.tempos
|
|
193
194
|
}))),
|
|
195
|
+
// LENIENT: keep only serve shares keyed by a current libero-replacement (flat or per-set) with a value in the
|
|
196
|
+
// 0-100 band, so a stale key (a released player, or a libero-replacement config change) can never fail schema
|
|
197
|
+
// validation and crash a team load; a dropped key = no serve share.
|
|
198
|
+
serveAttackShares: (() => {
|
|
199
|
+
const liberoReplacementIds = new Set([
|
|
200
|
+
...model.libero_replacements,
|
|
201
|
+
...(model.libero_replacement_sets ?? []).flat()
|
|
202
|
+
]);
|
|
203
|
+
const healed = {};
|
|
204
|
+
for (const [id, value] of Object.entries(model.serve_attack_shares ?? {})) {
|
|
205
|
+
if (liberoReplacementIds.has(id) && typeof value === 'number' && value >= 0 && value <= 100)
|
|
206
|
+
healed[id] = value;
|
|
207
|
+
}
|
|
208
|
+
return healed;
|
|
209
|
+
})(),
|
|
194
210
|
blockingPreferences: (model.blocking_preferences ?? []).map(p => ({ rotation: p.rotation, targets: p.targets })),
|
|
195
211
|
blockingPreferenceSets: model.blocking_preference_sets?.map(prefs => prefs.map(p => ({ rotation: p.rotation, targets: p.targets }))),
|
|
196
212
|
// LENIENT: keep only boolean entries keyed by a court starter or, when a second libero exists, the starting
|
|
@@ -336,6 +352,21 @@ function tacticsColumnsToApiDto(model) {
|
|
|
336
352
|
offensivePreferences: model.offensive_preferences,
|
|
337
353
|
systemSets: model.system_sets,
|
|
338
354
|
offensivePreferenceSets: model.offensive_preference_sets,
|
|
355
|
+
// Same stale-data policy as the heals below: only serve shares keyed by a current libero-replacement (flat or
|
|
356
|
+
// per-set) with a value in the 0-100 band survive; an empty map surfaces as absent so the UI never reloads and
|
|
357
|
+
// re-emits a stale key.
|
|
358
|
+
serveAttackShares: (() => {
|
|
359
|
+
const liberoReplacementIds = new Set([
|
|
360
|
+
...model.libero_replacements,
|
|
361
|
+
...(model.libero_replacement_sets ?? []).flat()
|
|
362
|
+
]);
|
|
363
|
+
const healed = {};
|
|
364
|
+
for (const [id, value] of Object.entries(model.serve_attack_shares ?? {})) {
|
|
365
|
+
if (liberoReplacementIds.has(id) && typeof value === 'number' && value >= 0 && value <= 100)
|
|
366
|
+
healed[id] = value;
|
|
367
|
+
}
|
|
368
|
+
return Object.keys(healed).length > 0 ? healed : undefined;
|
|
369
|
+
})(),
|
|
339
370
|
blockingPreferences: model.blocking_preferences,
|
|
340
371
|
blockingPreferenceSets: model.blocking_preference_sets,
|
|
341
372
|
// Same stale-data policy as the heals below: only boolean entries keyed by a court starter or, when a second
|
|
@@ -443,6 +474,7 @@ function apiTacticsConfigToAttributes(config, teamId) {
|
|
|
443
474
|
offensive_preferences: config.offensivePreferences,
|
|
444
475
|
system_sets: config.systemSets,
|
|
445
476
|
offensive_preference_sets: config.offensivePreferenceSets,
|
|
477
|
+
serve_attack_shares: config.serveAttackShares ?? {},
|
|
446
478
|
blocking_preferences: config.blockingPreferences ?? [],
|
|
447
479
|
blocking_preference_sets: config.blockingPreferenceSets,
|
|
448
480
|
replace_knocked_immediately: config.replaceKnockedImmediately,
|
|
@@ -143,6 +143,22 @@ function makeTactics(designatedSubs) {
|
|
|
143
143
|
(0, globals_1.expect)(dto.systemSets).toBeUndefined();
|
|
144
144
|
(0, globals_1.expect)(dto.offensivePreferenceSets).toBeUndefined();
|
|
145
145
|
});
|
|
146
|
+
(0, globals_1.it)('surfaces serve attack shares keyed by a libero replacement', () => {
|
|
147
|
+
const model = makeTactics([]);
|
|
148
|
+
model.serve_attack_shares = { lf: 30 }; // lf is the configured libero_replacement
|
|
149
|
+
const dto = (0, tactics_1.tacticsColumnsToApiDto)(model);
|
|
150
|
+
(0, globals_1.expect)(dto.serveAttackShares).toEqual({ lf: 30 });
|
|
151
|
+
});
|
|
152
|
+
(0, globals_1.it)('drops a serve attack share keyed by a non-libero-replacement (stale heal)', () => {
|
|
153
|
+
const model = makeTactics([]);
|
|
154
|
+
model.serve_attack_shares = { lf: 30, rf: 40 }; // rf is not in libero_replacements (['lf'])
|
|
155
|
+
const dto = (0, tactics_1.tacticsColumnsToApiDto)(model);
|
|
156
|
+
(0, globals_1.expect)(dto.serveAttackShares).toEqual({ lf: 30 });
|
|
157
|
+
});
|
|
158
|
+
(0, globals_1.it)('leaves serveAttackShares undefined when the column is empty', () => {
|
|
159
|
+
const dto = (0, tactics_1.tacticsColumnsToApiDto)(makeTactics([]));
|
|
160
|
+
(0, globals_1.expect)(dto.serveAttackShares).toBeUndefined();
|
|
161
|
+
});
|
|
146
162
|
});
|
|
147
163
|
(0, globals_1.describe)('per-set tactics — service <-> row round-trip', () => {
|
|
148
164
|
const country = (0, test_helpers_1.makeCountry)();
|
|
@@ -190,6 +206,27 @@ function makeTactics(designatedSubs) {
|
|
|
190
206
|
(0, globals_1.expect)(back.systemSets).toBeUndefined();
|
|
191
207
|
(0, globals_1.expect)(back.offensivePreferenceSets).toBeUndefined();
|
|
192
208
|
});
|
|
209
|
+
(0, globals_1.it)('round-trips serve attack shares keyed by a libero replacement', () => {
|
|
210
|
+
const withServe = service_1.Tactics.create({
|
|
211
|
+
lineup: { 4: p4, 3: p3, 2: p2, 5: p5, 6: p6, 1: p1, bench: [] },
|
|
212
|
+
liberoReplacements: [p5],
|
|
213
|
+
serveAttackShares: { [p5.id]: 35 }
|
|
214
|
+
});
|
|
215
|
+
const attrs = (0, tactics_1.transformFromTactics)(withServe, 'team-1');
|
|
216
|
+
(0, globals_1.expect)(attrs.serve_attack_shares).toEqual({ [p5.id]: 35 });
|
|
217
|
+
const back = (0, tactics_1.transformToTactics)(attrs, roster);
|
|
218
|
+
(0, globals_1.expect)(back.serveAttackShares).toEqual({ [p5.id]: 35 });
|
|
219
|
+
});
|
|
220
|
+
(0, globals_1.it)('drops a stale serve attack share key on load (lenient heal, no crash)', () => {
|
|
221
|
+
const attrs = (0, tactics_1.transformFromTactics)(service_1.Tactics.create({
|
|
222
|
+
lineup: { 4: p4, 3: p3, 2: p2, 5: p5, 6: p6, 1: p1, bench: [] },
|
|
223
|
+
liberoReplacements: [p5],
|
|
224
|
+
serveAttackShares: { [p5.id]: 35 }
|
|
225
|
+
}), 'team-1');
|
|
226
|
+
attrs.libero_replacements = [];
|
|
227
|
+
const back = (0, tactics_1.transformToTactics)(attrs, roster);
|
|
228
|
+
(0, globals_1.expect)(back.serveAttackShares).toEqual({});
|
|
229
|
+
});
|
|
193
230
|
(0, globals_1.it)('round-trips a per-starter tempos map on offensive preferences', () => {
|
|
194
231
|
const withTempos = service_1.Tactics.create({
|
|
195
232
|
lineup: { 4: p4, 3: p3, 2: p2, 5: p5, 6: p6, 1: p1, bench: [] },
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type EventRewardBand = 'PARTICIPATION' | '
|
|
1
|
+
export type EventRewardBand = 'PARTICIPATION' | 'FINAL_GROUP' | 'TOP_10' | 'TOP_5' | 'TOP_3' | 'TOP_1';
|
|
2
2
|
export interface EventRewardRow {
|
|
3
3
|
readonly band: EventRewardBand;
|
|
4
4
|
/** Best final position that earns this row; PARTICIPATION has none. */
|
|
@@ -15,7 +15,8 @@ export interface EventRewardRow {
|
|
|
15
15
|
export declare const EVENT_REWARDS: readonly EventRewardRow[];
|
|
16
16
|
/**
|
|
17
17
|
* The band a finish falls into. `position` is the place in the FINAL table; null means the team never reached
|
|
18
|
-
* the final
|
|
18
|
+
* the final (it went out in the group stage) and takes the participation row. The final holds MAX_FINAL_SIZE
|
|
19
|
+
* teams and the widest banded row (FINAL_GROUP) covers exactly that, so every finalist earns a banded row.
|
|
19
20
|
*/
|
|
20
21
|
export declare function eventRewardBandFor(position: number | null): EventRewardBand;
|
|
21
22
|
export interface EventPayout {
|
|
@@ -8,6 +8,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
8
8
|
exports.EVENT_REWARDS = void 0;
|
|
9
9
|
exports.eventRewardBandFor = eventRewardBandFor;
|
|
10
10
|
exports.eventPayoutFor = eventPayoutFor;
|
|
11
|
+
const event_schedule_1 = require("./event-schedule");
|
|
11
12
|
/**
|
|
12
13
|
* Best band first. A team earns its own row and every row BELOW it, which is what "cumulative" means: first
|
|
13
14
|
* place collects all six.
|
|
@@ -17,18 +18,19 @@ exports.EVENT_REWARDS = [
|
|
|
17
18
|
{ band: 'TOP_3', maxPosition: 3, packs: 0, coins: 500 },
|
|
18
19
|
{ band: 'TOP_5', maxPosition: 5, packs: 0, coins: 1000 },
|
|
19
20
|
{ band: 'TOP_10', maxPosition: 10, packs: 1, coins: 0 },
|
|
20
|
-
{ band: '
|
|
21
|
+
{ band: 'FINAL_GROUP', maxPosition: event_schedule_1.MAX_FINAL_SIZE, packs: 0, coins: 1000 },
|
|
21
22
|
{ band: 'PARTICIPATION', maxPosition: null, packs: 1, coins: 1000 }
|
|
22
23
|
];
|
|
23
24
|
/**
|
|
24
25
|
* The band a finish falls into. `position` is the place in the FINAL table; null means the team never reached
|
|
25
|
-
* the final
|
|
26
|
+
* the final (it went out in the group stage) and takes the participation row. The final holds MAX_FINAL_SIZE
|
|
27
|
+
* teams and the widest banded row (FINAL_GROUP) covers exactly that, so every finalist earns a banded row.
|
|
26
28
|
*/
|
|
27
29
|
function eventRewardBandFor(position) {
|
|
28
30
|
if (position == null)
|
|
29
31
|
return 'PARTICIPATION';
|
|
30
32
|
// Rows are ordered best-first, so the FIRST row the position satisfies is the narrowest band it earns.
|
|
31
|
-
// Scanning to the end instead would always land on the widest, handing first place a "
|
|
33
|
+
// Scanning to the end instead would always land on the widest, handing first place a "final group" badge.
|
|
32
34
|
for (const row of exports.EVENT_REWARDS) {
|
|
33
35
|
if (row.maxPosition != null && position <= row.maxPosition)
|
|
34
36
|
return row.band;
|
|
@@ -11,11 +11,11 @@ const event_rewards_1 = require("./event-rewards");
|
|
|
11
11
|
[5, 'TOP_5'],
|
|
12
12
|
[6, 'TOP_10'],
|
|
13
13
|
[10, 'TOP_10'],
|
|
14
|
-
[11, '
|
|
15
|
-
[20, '
|
|
16
|
-
//
|
|
17
|
-
[21, '
|
|
18
|
-
[24, '
|
|
14
|
+
[11, 'FINAL_GROUP'],
|
|
15
|
+
[20, 'FINAL_GROUP'],
|
|
16
|
+
// The final holds 24 teams and FINAL_GROUP covers all of them.
|
|
17
|
+
[21, 'FINAL_GROUP'],
|
|
18
|
+
[24, 'FINAL_GROUP']
|
|
19
19
|
])('places a finish of %i in %s', (position, band) => {
|
|
20
20
|
(0, globals_1.expect)((0, event_rewards_1.eventRewardBandFor)(position)).toBe(band);
|
|
21
21
|
});
|
|
@@ -26,9 +26,9 @@ const event_rewards_1 = require("./event-rewards");
|
|
|
26
26
|
(0, globals_1.describe)('eventPayoutFor()', () => {
|
|
27
27
|
globals_1.it.each([
|
|
28
28
|
// position, packs, coins. Cumulative: every row from Participation down to the finish.
|
|
29
|
-
[null, 1, 1000], // group stage: 1 pack + 1000
|
|
30
|
-
[24, 1,
|
|
31
|
-
[20, 1, 2000], // +
|
|
29
|
+
[null, 1, 1000], // never reached the final (group stage): 1 pack + 1000
|
|
30
|
+
[24, 1, 2000], // last of the 24-team final: final group's 1000 on top of participation
|
|
31
|
+
[20, 1, 2000], // + final group's 1000
|
|
32
32
|
[11, 1, 2000],
|
|
33
33
|
[10, 2, 2000], // + top 10's pack
|
|
34
34
|
[6, 2, 2000],
|
|
@@ -50,7 +50,7 @@ const event_rewards_1 = require("./event-rewards");
|
|
|
50
50
|
}
|
|
51
51
|
});
|
|
52
52
|
(0, globals_1.it)('adds up to the owner-defined table for a winner', () => {
|
|
53
|
-
// 1x pack + 1000 (participation), +1000 (
|
|
53
|
+
// 1x pack + 1000 (participation), +1000 (final group), +1 pack (top 10), +1000 (top 5), +500 (top 3), +1 pack (top 1)
|
|
54
54
|
const total = event_rewards_1.EVENT_REWARDS.reduce((acc, row) => ({ packs: acc.packs + row.packs, coins: acc.coins + row.coins }), { packs: 0, coins: 0 });
|
|
55
55
|
(0, globals_1.expect)((0, event_rewards_1.eventPayoutFor)(1)).toEqual(total);
|
|
56
56
|
(0, globals_1.expect)(total).toEqual({ packs: 3, coins: 3500 });
|
|
@@ -267,6 +267,7 @@ export declare const TacticsInputSchema: z.ZodObject<{
|
|
|
267
267
|
HIGH: "HIGH";
|
|
268
268
|
}>>>;
|
|
269
269
|
}, z.core.$strip>>>;
|
|
270
|
+
serveAttackShares: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodNumber>>;
|
|
270
271
|
systemSets: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
271
272
|
rotationSystem: z.ZodEnum<typeof RotationSystemEnum>;
|
|
272
273
|
designatedSetters: z.ZodArray<z.ZodCustom<Player, Player>>;
|
|
@@ -95,6 +95,11 @@ exports.TacticsInputSchema = zod_1.z.object({
|
|
|
95
95
|
rotationSystem: zod_1.z.nativeEnum(rotation_system_1.RotationSystemEnum).default(rotation_system_1.RotationSystemEnum.SIX_ZERO),
|
|
96
96
|
designatedSetters: zod_1.z.array(playerInstanceSchema).default([]),
|
|
97
97
|
offensivePreferences: zod_1.z.array(offensivePreferenceSchema).default([]),
|
|
98
|
+
// Attack shares on serve (libero-serve feature): per-player attack share the sim applies when a
|
|
99
|
+
// libero-replaced player is on court unreplaced (serving, or through a libero injury). Keyed by starter id.
|
|
100
|
+
// This is NOT part of the offensivePreferences sum-to-100; its band (0-100) and libero-replacement-key rules
|
|
101
|
+
// are validated independently in the superRefine below.
|
|
102
|
+
serveAttackShares: zod_1.z.record(zod_1.z.string(), zod_1.z.number().int()).default({}),
|
|
98
103
|
// Per-set overrides (5 entries, index 0 = set 1; absent => the flat fields apply to all sets). Each entry is
|
|
99
104
|
// validated with the same cross-field rules as the flat fields (see superRefine below).
|
|
100
105
|
systemSets: zod_1.z.array(systemSetSchema).max(5).optional(),
|
|
@@ -216,6 +221,21 @@ exports.TacticsInputSchema = zod_1.z.object({
|
|
|
216
221
|
for (const [i, preferences] of (data.blockingPreferenceSets ?? []).entries()) {
|
|
217
222
|
validateBlockingPreferences(preferences, ['blockingPreferenceSets', i]);
|
|
218
223
|
}
|
|
224
|
+
// Serve attack shares (libero-serve feature): only a current libero-replacement starter (flat or per-set) may
|
|
225
|
+
// carry one, and each value must be in the 0-100 band. Validated independently of the offensive-preference
|
|
226
|
+
// sum-to-100 (a serve share never enters that total).
|
|
227
|
+
const liberoReplacementIds = new Set([
|
|
228
|
+
...data.liberoReplacements.map(p => p.id),
|
|
229
|
+
...(data.liberoReplacementSets ?? []).flatMap(set => set.map(p => p.id))
|
|
230
|
+
]);
|
|
231
|
+
for (const [id, value] of Object.entries(data.serveAttackShares)) {
|
|
232
|
+
if (!liberoReplacementIds.has(id)) {
|
|
233
|
+
ctx.addIssue({ code: 'custom', message: 'SERVE_ATTACK_SHARE_NOT_A_LIBERO_REPLACEMENT', path: ['serveAttackShares'] });
|
|
234
|
+
}
|
|
235
|
+
if (value < 0 || value > 100) {
|
|
236
|
+
ctx.addIssue({ code: 'custom', message: 'SERVE_ATTACK_SHARE_OUT_OF_BAND', path: ['serveAttackShares'] });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
219
239
|
// Second libero (L2). The second libero is a reserve (not a starter, the libero, or a bench player). A swap
|
|
220
240
|
// rule (FATIGUE/ALWAYS in any set) needs someone to swap in, so it requires a second libero, and a second
|
|
221
241
|
// libero needs a starting libero to replace ("no L2 without an L"). Per-set libero replacements, like the
|
|
@@ -313,3 +313,83 @@ const test_helpers_1 = require("../../test-helpers");
|
|
|
313
313
|
(0, globals_1.expect)(res.success).toBe(false);
|
|
314
314
|
});
|
|
315
315
|
});
|
|
316
|
+
(0, globals_1.describe)('TacticsInputSchema — serve attack shares', () => {
|
|
317
|
+
const country = (0, test_helpers_1.makeCountry)();
|
|
318
|
+
const p1 = (0, test_helpers_1.makePlayer)(country);
|
|
319
|
+
const p2 = (0, test_helpers_1.makePlayer)(country);
|
|
320
|
+
const p3 = (0, test_helpers_1.makePlayer)(country);
|
|
321
|
+
const p4 = (0, test_helpers_1.makePlayer)(country);
|
|
322
|
+
const p5 = (0, test_helpers_1.makePlayer)(country);
|
|
323
|
+
const p6 = (0, test_helpers_1.makePlayer)(country);
|
|
324
|
+
const reserve = (0, test_helpers_1.makePlayer)(country);
|
|
325
|
+
// p5 sits at zone 5 (back row) and is the configured libero replacement, so it may carry a serve share.
|
|
326
|
+
function baseInput(overrides = {}) {
|
|
327
|
+
return {
|
|
328
|
+
lineup: { 4: p4, 3: p3, 2: p2, 5: p5, 6: p6, 1: p1, bench: [] },
|
|
329
|
+
liberoReplacements: [p5],
|
|
330
|
+
...overrides
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
(0, globals_1.it)('accepts a serve share keyed by a libero replacement, value in band', () => {
|
|
334
|
+
const res = tactics_z_1.TacticsInputSchema.safeParse(baseInput({ serveAttackShares: { [p5.id]: 30 } }));
|
|
335
|
+
(0, globals_1.expect)(res.success).toBe(true);
|
|
336
|
+
if (res.success)
|
|
337
|
+
(0, globals_1.expect)(res.data.serveAttackShares[p5.id]).toBe(30);
|
|
338
|
+
});
|
|
339
|
+
(0, globals_1.it)('accepts 0 and 100 at the band edges', () => {
|
|
340
|
+
(0, globals_1.expect)(tactics_z_1.TacticsInputSchema.safeParse(baseInput({ serveAttackShares: { [p5.id]: 0 } })).success).toBe(true);
|
|
341
|
+
(0, globals_1.expect)(tactics_z_1.TacticsInputSchema.safeParse(baseInput({ serveAttackShares: { [p5.id]: 100 } })).success).toBe(true);
|
|
342
|
+
});
|
|
343
|
+
(0, globals_1.it)('rejects a serve share keyed by a starter who is not a libero replacement', () => {
|
|
344
|
+
const res = tactics_z_1.TacticsInputSchema.safeParse(baseInput({ serveAttackShares: { [p2.id]: 30 } }));
|
|
345
|
+
(0, globals_1.expect)(res.success).toBe(false);
|
|
346
|
+
if (!res.success) {
|
|
347
|
+
(0, globals_1.expect)(res.error.issues.some(i => i.message === 'SERVE_ATTACK_SHARE_NOT_A_LIBERO_REPLACEMENT')).toBe(true);
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
(0, globals_1.it)('rejects a serve share keyed by a reserve', () => {
|
|
351
|
+
const res = tactics_z_1.TacticsInputSchema.safeParse(baseInput({ serveAttackShares: { [reserve.id]: 30 } }));
|
|
352
|
+
(0, globals_1.expect)(res.success).toBe(false);
|
|
353
|
+
if (!res.success) {
|
|
354
|
+
(0, globals_1.expect)(res.error.issues.some(i => i.message === 'SERVE_ATTACK_SHARE_NOT_A_LIBERO_REPLACEMENT')).toBe(true);
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
(0, globals_1.it)('rejects an out-of-band serve share (over 100)', () => {
|
|
358
|
+
const res = tactics_z_1.TacticsInputSchema.safeParse(baseInput({ serveAttackShares: { [p5.id]: 101 } }));
|
|
359
|
+
(0, globals_1.expect)(res.success).toBe(false);
|
|
360
|
+
if (!res.success) {
|
|
361
|
+
(0, globals_1.expect)(res.error.issues.some(i => i.message === 'SERVE_ATTACK_SHARE_OUT_OF_BAND')).toBe(true);
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
(0, globals_1.it)('rejects a negative serve share', () => {
|
|
365
|
+
const res = tactics_z_1.TacticsInputSchema.safeParse(baseInput({ serveAttackShares: { [p5.id]: -1 } }));
|
|
366
|
+
(0, globals_1.expect)(res.success).toBe(false);
|
|
367
|
+
if (!res.success) {
|
|
368
|
+
(0, globals_1.expect)(res.error.issues.some(i => i.message === 'SERVE_ATTACK_SHARE_OUT_OF_BAND')).toBe(true);
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
(0, globals_1.it)('validates independently of the offensive-preference sum-to-100 (never enters that total)', () => {
|
|
372
|
+
// The other attackers' weights sum to exactly 100; the serve share on the libero replacement is separate
|
|
373
|
+
// and must not push the rotation over 100 or otherwise fail.
|
|
374
|
+
const res = tactics_z_1.TacticsInputSchema.safeParse(baseInput({
|
|
375
|
+
offensivePreferences: [{ rotation: 1, weights: { [p2.id]: 50, [p3.id]: 50 } }],
|
|
376
|
+
serveAttackShares: { [p5.id]: 40 }
|
|
377
|
+
}));
|
|
378
|
+
(0, globals_1.expect)(res.success).toBe(true);
|
|
379
|
+
});
|
|
380
|
+
(0, globals_1.it)('accepts a serve share on a per-set libero replacement', () => {
|
|
381
|
+
// p6 is a back-row starter made a libero replacement only via liberoReplacementSets.
|
|
382
|
+
const res = tactics_z_1.TacticsInputSchema.safeParse(baseInput({
|
|
383
|
+
liberoReplacements: [],
|
|
384
|
+
liberoReplacementSets: [[p6]],
|
|
385
|
+
serveAttackShares: { [p6.id]: 25 }
|
|
386
|
+
}));
|
|
387
|
+
(0, globals_1.expect)(res.success).toBe(true);
|
|
388
|
+
});
|
|
389
|
+
(0, globals_1.it)('defaults serveAttackShares to an empty map when omitted', () => {
|
|
390
|
+
const res = tactics_z_1.TacticsInputSchema.safeParse(baseInput());
|
|
391
|
+
(0, globals_1.expect)(res.success).toBe(true);
|
|
392
|
+
if (res.success)
|
|
393
|
+
(0, globals_1.expect)(res.data.serveAttackShares).toEqual({});
|
|
394
|
+
});
|
|
395
|
+
});
|
|
@@ -275,6 +275,7 @@ export declare const TeamInputSchema: z.ZodObject<{
|
|
|
275
275
|
HIGH: "HIGH";
|
|
276
276
|
}>>>;
|
|
277
277
|
}, z.core.$strip>>>;
|
|
278
|
+
serveAttackShares: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodNumber>>;
|
|
278
279
|
systemSets: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
279
280
|
rotationSystem: z.ZodEnum<typeof import("..").RotationSystemEnum>;
|
|
280
281
|
designatedSetters: z.ZodArray<z.ZodCustom<Player, Player>>;
|
|
@@ -34,6 +34,7 @@ export interface TacticsOpts {
|
|
|
34
34
|
readonly rotationSystem: RotationSystemEnum;
|
|
35
35
|
readonly designatedSetters: Player[];
|
|
36
36
|
readonly offensivePreferences: OffensivePreference[];
|
|
37
|
+
readonly serveAttackShares: Record<string, number>;
|
|
37
38
|
readonly systemSets?: SystemSet[];
|
|
38
39
|
readonly offensivePreferenceSets?: OffensivePreference[][];
|
|
39
40
|
readonly replaceKnockedImmediately: Record<string, boolean>;
|
|
@@ -54,6 +55,7 @@ export declare class Tactics {
|
|
|
54
55
|
readonly rotationSystem: RotationSystemEnum;
|
|
55
56
|
readonly designatedSetters: Player[];
|
|
56
57
|
readonly offensivePreferences: OffensivePreference[];
|
|
58
|
+
readonly serveAttackShares: Record<string, number>;
|
|
57
59
|
readonly systemSets?: SystemSet[];
|
|
58
60
|
readonly offensivePreferenceSets?: OffensivePreference[][];
|
|
59
61
|
readonly replaceKnockedImmediately: Record<string, boolean>;
|
|
@@ -18,7 +18,7 @@ class Tactics {
|
|
|
18
18
|
}
|
|
19
19
|
return new Tactics(result.data);
|
|
20
20
|
}
|
|
21
|
-
constructor({ lineup, liberoReplacements, receiveRotationOffset = false, designatedSubs = [], substitutionBand = energy_band_1.EnergyBand.TIRED, secondLibero, liberoSub, liberoReplacementSets, rotationSystem = rotation_system_1.RotationSystemEnum.SIX_ZERO, designatedSetters = [], offensivePreferences = [], systemSets, offensivePreferenceSets, replaceKnockedImmediately = {}, injuryReplacements = [], liberoInjury, 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 }) {
|
|
22
22
|
this.lineup = lineup;
|
|
23
23
|
this.liberoReplacements = liberoReplacements;
|
|
24
24
|
this.receiveRotationOffset = receiveRotationOffset;
|
|
@@ -30,6 +30,7 @@ class Tactics {
|
|
|
30
30
|
this.rotationSystem = rotationSystem;
|
|
31
31
|
this.designatedSetters = designatedSetters;
|
|
32
32
|
this.offensivePreferences = offensivePreferences;
|
|
33
|
+
this.serveAttackShares = serveAttackShares;
|
|
33
34
|
this.systemSets = systemSets;
|
|
34
35
|
this.offensivePreferenceSets = offensivePreferenceSets;
|
|
35
36
|
this.replaceKnockedImmediately = replaceKnockedImmediately;
|
|
@@ -139,6 +139,7 @@ export interface Tactics {
|
|
|
139
139
|
offensivePreferences: ApiOffensivePreference[];
|
|
140
140
|
systemSets?: ApiSystemSet[];
|
|
141
141
|
offensivePreferenceSets?: ApiOffensivePreference[][];
|
|
142
|
+
serveAttackShares?: Record<string, number>;
|
|
142
143
|
blockingPreferences: ApiBlockingPreference[];
|
|
143
144
|
blockingPreferenceSets?: ApiBlockingPreference[][];
|
|
144
145
|
replaceKnockedImmediately?: Record<string, boolean>;
|
|
@@ -76,6 +76,7 @@ export interface TacticsAttributes {
|
|
|
76
76
|
offensive_preferences: OffensivePreferenceAttributes[];
|
|
77
77
|
system_sets?: SystemSetAttributes[];
|
|
78
78
|
offensive_preference_sets?: OffensivePreferenceAttributes[][];
|
|
79
|
+
serve_attack_shares: Record<PlayerId, number>;
|
|
79
80
|
replace_knocked_immediately?: Record<PlayerId, boolean>;
|
|
80
81
|
injury_replacements?: InjuryReplacementAttributes[];
|
|
81
82
|
libero_injury?: LiberoInjuryAttributes;
|
|
@@ -84,7 +85,7 @@ export interface TacticsAttributes {
|
|
|
84
85
|
}
|
|
85
86
|
export type TacticsPk = 'team_id';
|
|
86
87
|
export type TacticsId = TacticsModel[TacticsPk];
|
|
87
|
-
export type TacticsOptionalAttributes = 'lineup' | 'libero_replacements' | 'receive_rotation_offset' | 'designated_subs' | 'substitution_band' | 'second_libero' | 'libero_sub' | 'libero_replacement_sets' | 'rotation_system' | 'designated_setters' | 'offensive_preferences' | 'system_sets' | 'offensive_preference_sets' | 'replace_knocked_immediately' | 'injury_replacements' | 'libero_injury' | 'blocking_preferences' | 'blocking_preference_sets';
|
|
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';
|
|
88
89
|
export type TacticsCreationAttributes = Optional<TacticsAttributes, TacticsOptionalAttributes>;
|
|
89
90
|
export declare class TacticsModel extends Model<TacticsAttributes, TacticsCreationAttributes> implements TacticsAttributes {
|
|
90
91
|
team_id: string;
|
|
@@ -101,6 +102,7 @@ export declare class TacticsModel extends Model<TacticsAttributes, TacticsCreati
|
|
|
101
102
|
offensive_preferences: OffensivePreferenceAttributes[];
|
|
102
103
|
system_sets?: SystemSetAttributes[];
|
|
103
104
|
offensive_preference_sets?: OffensivePreferenceAttributes[][];
|
|
105
|
+
serve_attack_shares: Record<PlayerId, number>;
|
|
104
106
|
replace_knocked_immediately?: Record<PlayerId, boolean>;
|
|
105
107
|
injury_replacements?: InjuryReplacementAttributes[];
|
|
106
108
|
libero_injury?: LiberoInjuryAttributes;
|
|
@@ -72,6 +72,11 @@ export class TacticsModel extends Model {
|
|
|
72
72
|
type: DataTypes.JSONB,
|
|
73
73
|
allowNull: true
|
|
74
74
|
},
|
|
75
|
+
serve_attack_shares: {
|
|
76
|
+
type: DataTypes.JSONB,
|
|
77
|
+
allowNull: false,
|
|
78
|
+
defaultValue: {}
|
|
79
|
+
},
|
|
75
80
|
replace_knocked_immediately: {
|
|
76
81
|
type: DataTypes.JSONB,
|
|
77
82
|
allowNull: true
|
|
@@ -79,6 +79,7 @@ function transformToAttributes(tactics, teamId) {
|
|
|
79
79
|
weights: p.weights,
|
|
80
80
|
tempos: p.tempos
|
|
81
81
|
}))),
|
|
82
|
+
serve_attack_shares: tactics.serveAttackShares ?? {},
|
|
82
83
|
blocking_preferences: tactics.blockingPreferences.map(p => ({ rotation: p.rotation, targets: p.targets })),
|
|
83
84
|
blocking_preference_sets: tactics.blockingPreferenceSets?.map(prefs => prefs.map(p => ({ rotation: p.rotation, targets: p.targets }))),
|
|
84
85
|
replace_knocked_immediately: tactics.replaceKnockedImmediately,
|
|
@@ -185,6 +186,21 @@ function transformToObject(model, roster) {
|
|
|
185
186
|
weights: p.weights,
|
|
186
187
|
tempos: p.tempos
|
|
187
188
|
}))),
|
|
189
|
+
// LENIENT: keep only serve shares keyed by a current libero-replacement (flat or per-set) with a value in the
|
|
190
|
+
// 0-100 band, so a stale key (a released player, or a libero-replacement config change) can never fail schema
|
|
191
|
+
// validation and crash a team load; a dropped key = no serve share.
|
|
192
|
+
serveAttackShares: (() => {
|
|
193
|
+
const liberoReplacementIds = new Set([
|
|
194
|
+
...model.libero_replacements,
|
|
195
|
+
...(model.libero_replacement_sets ?? []).flat()
|
|
196
|
+
]);
|
|
197
|
+
const healed = {};
|
|
198
|
+
for (const [id, value] of Object.entries(model.serve_attack_shares ?? {})) {
|
|
199
|
+
if (liberoReplacementIds.has(id) && typeof value === 'number' && value >= 0 && value <= 100)
|
|
200
|
+
healed[id] = value;
|
|
201
|
+
}
|
|
202
|
+
return healed;
|
|
203
|
+
})(),
|
|
188
204
|
blockingPreferences: (model.blocking_preferences ?? []).map(p => ({ rotation: p.rotation, targets: p.targets })),
|
|
189
205
|
blockingPreferenceSets: model.blocking_preference_sets?.map(prefs => prefs.map(p => ({ rotation: p.rotation, targets: p.targets }))),
|
|
190
206
|
// LENIENT: keep only boolean entries keyed by a court starter or, when a second libero exists, the starting
|
|
@@ -330,6 +346,21 @@ function tacticsColumnsToApiDto(model) {
|
|
|
330
346
|
offensivePreferences: model.offensive_preferences,
|
|
331
347
|
systemSets: model.system_sets,
|
|
332
348
|
offensivePreferenceSets: model.offensive_preference_sets,
|
|
349
|
+
// Same stale-data policy as the heals below: only serve shares keyed by a current libero-replacement (flat or
|
|
350
|
+
// per-set) with a value in the 0-100 band survive; an empty map surfaces as absent so the UI never reloads and
|
|
351
|
+
// re-emits a stale key.
|
|
352
|
+
serveAttackShares: (() => {
|
|
353
|
+
const liberoReplacementIds = new Set([
|
|
354
|
+
...model.libero_replacements,
|
|
355
|
+
...(model.libero_replacement_sets ?? []).flat()
|
|
356
|
+
]);
|
|
357
|
+
const healed = {};
|
|
358
|
+
for (const [id, value] of Object.entries(model.serve_attack_shares ?? {})) {
|
|
359
|
+
if (liberoReplacementIds.has(id) && typeof value === 'number' && value >= 0 && value <= 100)
|
|
360
|
+
healed[id] = value;
|
|
361
|
+
}
|
|
362
|
+
return Object.keys(healed).length > 0 ? healed : undefined;
|
|
363
|
+
})(),
|
|
333
364
|
blockingPreferences: model.blocking_preferences,
|
|
334
365
|
blockingPreferenceSets: model.blocking_preference_sets,
|
|
335
366
|
// Same stale-data policy as the heals below: only boolean entries keyed by a court starter or, when a second
|
|
@@ -437,6 +468,7 @@ function apiTacticsConfigToAttributes(config, teamId) {
|
|
|
437
468
|
offensive_preferences: config.offensivePreferences,
|
|
438
469
|
system_sets: config.systemSets,
|
|
439
470
|
offensive_preference_sets: config.offensivePreferenceSets,
|
|
471
|
+
serve_attack_shares: config.serveAttackShares ?? {},
|
|
440
472
|
blocking_preferences: config.blockingPreferences ?? [],
|
|
441
473
|
blocking_preference_sets: config.blockingPreferenceSets,
|
|
442
474
|
replace_knocked_immediately: config.replaceKnockedImmediately,
|
|
@@ -141,6 +141,22 @@ describe('tacticsColumnsToApiDto()', () => {
|
|
|
141
141
|
expect(dto.systemSets).toBeUndefined();
|
|
142
142
|
expect(dto.offensivePreferenceSets).toBeUndefined();
|
|
143
143
|
});
|
|
144
|
+
it('surfaces serve attack shares keyed by a libero replacement', () => {
|
|
145
|
+
const model = makeTactics([]);
|
|
146
|
+
model.serve_attack_shares = { lf: 30 }; // lf is the configured libero_replacement
|
|
147
|
+
const dto = tacticsColumnsToApiDto(model);
|
|
148
|
+
expect(dto.serveAttackShares).toEqual({ lf: 30 });
|
|
149
|
+
});
|
|
150
|
+
it('drops a serve attack share keyed by a non-libero-replacement (stale heal)', () => {
|
|
151
|
+
const model = makeTactics([]);
|
|
152
|
+
model.serve_attack_shares = { lf: 30, rf: 40 }; // rf is not in libero_replacements (['lf'])
|
|
153
|
+
const dto = tacticsColumnsToApiDto(model);
|
|
154
|
+
expect(dto.serveAttackShares).toEqual({ lf: 30 });
|
|
155
|
+
});
|
|
156
|
+
it('leaves serveAttackShares undefined when the column is empty', () => {
|
|
157
|
+
const dto = tacticsColumnsToApiDto(makeTactics([]));
|
|
158
|
+
expect(dto.serveAttackShares).toBeUndefined();
|
|
159
|
+
});
|
|
144
160
|
});
|
|
145
161
|
describe('per-set tactics — service <-> row round-trip', () => {
|
|
146
162
|
const country = makeCountry();
|
|
@@ -188,6 +204,27 @@ describe('per-set tactics — service <-> row round-trip', () => {
|
|
|
188
204
|
expect(back.systemSets).toBeUndefined();
|
|
189
205
|
expect(back.offensivePreferenceSets).toBeUndefined();
|
|
190
206
|
});
|
|
207
|
+
it('round-trips serve attack shares keyed by a libero replacement', () => {
|
|
208
|
+
const withServe = Tactics.create({
|
|
209
|
+
lineup: { 4: p4, 3: p3, 2: p2, 5: p5, 6: p6, 1: p1, bench: [] },
|
|
210
|
+
liberoReplacements: [p5],
|
|
211
|
+
serveAttackShares: { [p5.id]: 35 }
|
|
212
|
+
});
|
|
213
|
+
const attrs = transformFromTactics(withServe, 'team-1');
|
|
214
|
+
expect(attrs.serve_attack_shares).toEqual({ [p5.id]: 35 });
|
|
215
|
+
const back = transformToTactics(attrs, roster);
|
|
216
|
+
expect(back.serveAttackShares).toEqual({ [p5.id]: 35 });
|
|
217
|
+
});
|
|
218
|
+
it('drops a stale serve attack share key on load (lenient heal, no crash)', () => {
|
|
219
|
+
const attrs = transformFromTactics(Tactics.create({
|
|
220
|
+
lineup: { 4: p4, 3: p3, 2: p2, 5: p5, 6: p6, 1: p1, bench: [] },
|
|
221
|
+
liberoReplacements: [p5],
|
|
222
|
+
serveAttackShares: { [p5.id]: 35 }
|
|
223
|
+
}), 'team-1');
|
|
224
|
+
attrs.libero_replacements = [];
|
|
225
|
+
const back = transformToTactics(attrs, roster);
|
|
226
|
+
expect(back.serveAttackShares).toEqual({});
|
|
227
|
+
});
|
|
191
228
|
it('round-trips a per-starter tempos map on offensive preferences', () => {
|
|
192
229
|
const withTempos = Tactics.create({
|
|
193
230
|
lineup: { 4: p4, 3: p3, 2: p2, 5: p5, 6: p6, 1: p1, bench: [] },
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type EventRewardBand = 'PARTICIPATION' | '
|
|
1
|
+
export type EventRewardBand = 'PARTICIPATION' | 'FINAL_GROUP' | 'TOP_10' | 'TOP_5' | 'TOP_3' | 'TOP_1';
|
|
2
2
|
export interface EventRewardRow {
|
|
3
3
|
readonly band: EventRewardBand;
|
|
4
4
|
/** Best final position that earns this row; PARTICIPATION has none. */
|
|
@@ -15,7 +15,8 @@ export interface EventRewardRow {
|
|
|
15
15
|
export declare const EVENT_REWARDS: readonly EventRewardRow[];
|
|
16
16
|
/**
|
|
17
17
|
* The band a finish falls into. `position` is the place in the FINAL table; null means the team never reached
|
|
18
|
-
* the final
|
|
18
|
+
* the final (it went out in the group stage) and takes the participation row. The final holds MAX_FINAL_SIZE
|
|
19
|
+
* teams and the widest banded row (FINAL_GROUP) covers exactly that, so every finalist earns a banded row.
|
|
19
20
|
*/
|
|
20
21
|
export declare function eventRewardBandFor(position: number | null): EventRewardBand;
|
|
21
22
|
export interface EventPayout {
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// One source of truth for three consumers: the simulator grants from it, the About view lists it, and the
|
|
4
4
|
// accolades page names the band. Rewards are CUMULATIVE, so a team collects every row from Participation down
|
|
5
5
|
// to its own placement.
|
|
6
|
+
import { MAX_FINAL_SIZE } from './event-schedule';
|
|
6
7
|
/**
|
|
7
8
|
* Best band first. A team earns its own row and every row BELOW it, which is what "cumulative" means: first
|
|
8
9
|
* place collects all six.
|
|
@@ -12,18 +13,19 @@ export const EVENT_REWARDS = [
|
|
|
12
13
|
{ band: 'TOP_3', maxPosition: 3, packs: 0, coins: 500 },
|
|
13
14
|
{ band: 'TOP_5', maxPosition: 5, packs: 0, coins: 1000 },
|
|
14
15
|
{ band: 'TOP_10', maxPosition: 10, packs: 1, coins: 0 },
|
|
15
|
-
{ band: '
|
|
16
|
+
{ band: 'FINAL_GROUP', maxPosition: MAX_FINAL_SIZE, packs: 0, coins: 1000 },
|
|
16
17
|
{ band: 'PARTICIPATION', maxPosition: null, packs: 1, coins: 1000 }
|
|
17
18
|
];
|
|
18
19
|
/**
|
|
19
20
|
* The band a finish falls into. `position` is the place in the FINAL table; null means the team never reached
|
|
20
|
-
* the final
|
|
21
|
+
* the final (it went out in the group stage) and takes the participation row. The final holds MAX_FINAL_SIZE
|
|
22
|
+
* teams and the widest banded row (FINAL_GROUP) covers exactly that, so every finalist earns a banded row.
|
|
21
23
|
*/
|
|
22
24
|
export function eventRewardBandFor(position) {
|
|
23
25
|
if (position == null)
|
|
24
26
|
return 'PARTICIPATION';
|
|
25
27
|
// Rows are ordered best-first, so the FIRST row the position satisfies is the narrowest band it earns.
|
|
26
|
-
// Scanning to the end instead would always land on the widest, handing first place a "
|
|
28
|
+
// Scanning to the end instead would always land on the widest, handing first place a "final group" badge.
|
|
27
29
|
for (const row of EVENT_REWARDS) {
|
|
28
30
|
if (row.maxPosition != null && position <= row.maxPosition)
|
|
29
31
|
return row.band;
|
|
@@ -9,11 +9,11 @@ describe('eventRewardBandFor()', () => {
|
|
|
9
9
|
[5, 'TOP_5'],
|
|
10
10
|
[6, 'TOP_10'],
|
|
11
11
|
[10, 'TOP_10'],
|
|
12
|
-
[11, '
|
|
13
|
-
[20, '
|
|
14
|
-
//
|
|
15
|
-
[21, '
|
|
16
|
-
[24, '
|
|
12
|
+
[11, 'FINAL_GROUP'],
|
|
13
|
+
[20, 'FINAL_GROUP'],
|
|
14
|
+
// The final holds 24 teams and FINAL_GROUP covers all of them.
|
|
15
|
+
[21, 'FINAL_GROUP'],
|
|
16
|
+
[24, 'FINAL_GROUP']
|
|
17
17
|
])('places a finish of %i in %s', (position, band) => {
|
|
18
18
|
expect(eventRewardBandFor(position)).toBe(band);
|
|
19
19
|
});
|
|
@@ -24,9 +24,9 @@ describe('eventRewardBandFor()', () => {
|
|
|
24
24
|
describe('eventPayoutFor()', () => {
|
|
25
25
|
it.each([
|
|
26
26
|
// position, packs, coins. Cumulative: every row from Participation down to the finish.
|
|
27
|
-
[null, 1, 1000], // group stage: 1 pack + 1000
|
|
28
|
-
[24, 1,
|
|
29
|
-
[20, 1, 2000], // +
|
|
27
|
+
[null, 1, 1000], // never reached the final (group stage): 1 pack + 1000
|
|
28
|
+
[24, 1, 2000], // last of the 24-team final: final group's 1000 on top of participation
|
|
29
|
+
[20, 1, 2000], // + final group's 1000
|
|
30
30
|
[11, 1, 2000],
|
|
31
31
|
[10, 2, 2000], // + top 10's pack
|
|
32
32
|
[6, 2, 2000],
|
|
@@ -48,7 +48,7 @@ describe('eventPayoutFor()', () => {
|
|
|
48
48
|
}
|
|
49
49
|
});
|
|
50
50
|
it('adds up to the owner-defined table for a winner', () => {
|
|
51
|
-
// 1x pack + 1000 (participation), +1000 (
|
|
51
|
+
// 1x pack + 1000 (participation), +1000 (final group), +1 pack (top 10), +1000 (top 5), +500 (top 3), +1 pack (top 1)
|
|
52
52
|
const total = EVENT_REWARDS.reduce((acc, row) => ({ packs: acc.packs + row.packs, coins: acc.coins + row.coins }), { packs: 0, coins: 0 });
|
|
53
53
|
expect(eventPayoutFor(1)).toEqual(total);
|
|
54
54
|
expect(total).toEqual({ packs: 3, coins: 3500 });
|
|
@@ -267,6 +267,7 @@ export declare const TacticsInputSchema: z.ZodObject<{
|
|
|
267
267
|
HIGH: "HIGH";
|
|
268
268
|
}>>>;
|
|
269
269
|
}, z.core.$strip>>>;
|
|
270
|
+
serveAttackShares: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodNumber>>;
|
|
270
271
|
systemSets: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
271
272
|
rotationSystem: z.ZodEnum<typeof RotationSystemEnum>;
|
|
272
273
|
designatedSetters: z.ZodArray<z.ZodCustom<Player, Player>>;
|
|
@@ -92,6 +92,11 @@ export const TacticsInputSchema = z.object({
|
|
|
92
92
|
rotationSystem: z.nativeEnum(RotationSystemEnum).default(RotationSystemEnum.SIX_ZERO),
|
|
93
93
|
designatedSetters: z.array(playerInstanceSchema).default([]),
|
|
94
94
|
offensivePreferences: z.array(offensivePreferenceSchema).default([]),
|
|
95
|
+
// Attack shares on serve (libero-serve feature): per-player attack share the sim applies when a
|
|
96
|
+
// libero-replaced player is on court unreplaced (serving, or through a libero injury). Keyed by starter id.
|
|
97
|
+
// This is NOT part of the offensivePreferences sum-to-100; its band (0-100) and libero-replacement-key rules
|
|
98
|
+
// are validated independently in the superRefine below.
|
|
99
|
+
serveAttackShares: z.record(z.string(), z.number().int()).default({}),
|
|
95
100
|
// Per-set overrides (5 entries, index 0 = set 1; absent => the flat fields apply to all sets). Each entry is
|
|
96
101
|
// validated with the same cross-field rules as the flat fields (see superRefine below).
|
|
97
102
|
systemSets: z.array(systemSetSchema).max(5).optional(),
|
|
@@ -213,6 +218,21 @@ export const TacticsInputSchema = z.object({
|
|
|
213
218
|
for (const [i, preferences] of (data.blockingPreferenceSets ?? []).entries()) {
|
|
214
219
|
validateBlockingPreferences(preferences, ['blockingPreferenceSets', i]);
|
|
215
220
|
}
|
|
221
|
+
// Serve attack shares (libero-serve feature): only a current libero-replacement starter (flat or per-set) may
|
|
222
|
+
// carry one, and each value must be in the 0-100 band. Validated independently of the offensive-preference
|
|
223
|
+
// sum-to-100 (a serve share never enters that total).
|
|
224
|
+
const liberoReplacementIds = new Set([
|
|
225
|
+
...data.liberoReplacements.map(p => p.id),
|
|
226
|
+
...(data.liberoReplacementSets ?? []).flatMap(set => set.map(p => p.id))
|
|
227
|
+
]);
|
|
228
|
+
for (const [id, value] of Object.entries(data.serveAttackShares)) {
|
|
229
|
+
if (!liberoReplacementIds.has(id)) {
|
|
230
|
+
ctx.addIssue({ code: 'custom', message: 'SERVE_ATTACK_SHARE_NOT_A_LIBERO_REPLACEMENT', path: ['serveAttackShares'] });
|
|
231
|
+
}
|
|
232
|
+
if (value < 0 || value > 100) {
|
|
233
|
+
ctx.addIssue({ code: 'custom', message: 'SERVE_ATTACK_SHARE_OUT_OF_BAND', path: ['serveAttackShares'] });
|
|
234
|
+
}
|
|
235
|
+
}
|
|
216
236
|
// Second libero (L2). The second libero is a reserve (not a starter, the libero, or a bench player). A swap
|
|
217
237
|
// rule (FATIGUE/ALWAYS in any set) needs someone to swap in, so it requires a second libero, and a second
|
|
218
238
|
// libero needs a starting libero to replace ("no L2 without an L"). Per-set libero replacements, like the
|
|
@@ -311,3 +311,83 @@ describe('TacticsInputSchema — blocking preferences', () => {
|
|
|
311
311
|
expect(res.success).toBe(false);
|
|
312
312
|
});
|
|
313
313
|
});
|
|
314
|
+
describe('TacticsInputSchema — serve attack shares', () => {
|
|
315
|
+
const country = makeCountry();
|
|
316
|
+
const p1 = makePlayer(country);
|
|
317
|
+
const p2 = makePlayer(country);
|
|
318
|
+
const p3 = makePlayer(country);
|
|
319
|
+
const p4 = makePlayer(country);
|
|
320
|
+
const p5 = makePlayer(country);
|
|
321
|
+
const p6 = makePlayer(country);
|
|
322
|
+
const reserve = makePlayer(country);
|
|
323
|
+
// p5 sits at zone 5 (back row) and is the configured libero replacement, so it may carry a serve share.
|
|
324
|
+
function baseInput(overrides = {}) {
|
|
325
|
+
return {
|
|
326
|
+
lineup: { 4: p4, 3: p3, 2: p2, 5: p5, 6: p6, 1: p1, bench: [] },
|
|
327
|
+
liberoReplacements: [p5],
|
|
328
|
+
...overrides
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
it('accepts a serve share keyed by a libero replacement, value in band', () => {
|
|
332
|
+
const res = TacticsInputSchema.safeParse(baseInput({ serveAttackShares: { [p5.id]: 30 } }));
|
|
333
|
+
expect(res.success).toBe(true);
|
|
334
|
+
if (res.success)
|
|
335
|
+
expect(res.data.serveAttackShares[p5.id]).toBe(30);
|
|
336
|
+
});
|
|
337
|
+
it('accepts 0 and 100 at the band edges', () => {
|
|
338
|
+
expect(TacticsInputSchema.safeParse(baseInput({ serveAttackShares: { [p5.id]: 0 } })).success).toBe(true);
|
|
339
|
+
expect(TacticsInputSchema.safeParse(baseInput({ serveAttackShares: { [p5.id]: 100 } })).success).toBe(true);
|
|
340
|
+
});
|
|
341
|
+
it('rejects a serve share keyed by a starter who is not a libero replacement', () => {
|
|
342
|
+
const res = TacticsInputSchema.safeParse(baseInput({ serveAttackShares: { [p2.id]: 30 } }));
|
|
343
|
+
expect(res.success).toBe(false);
|
|
344
|
+
if (!res.success) {
|
|
345
|
+
expect(res.error.issues.some(i => i.message === 'SERVE_ATTACK_SHARE_NOT_A_LIBERO_REPLACEMENT')).toBe(true);
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
it('rejects a serve share keyed by a reserve', () => {
|
|
349
|
+
const res = TacticsInputSchema.safeParse(baseInput({ serveAttackShares: { [reserve.id]: 30 } }));
|
|
350
|
+
expect(res.success).toBe(false);
|
|
351
|
+
if (!res.success) {
|
|
352
|
+
expect(res.error.issues.some(i => i.message === 'SERVE_ATTACK_SHARE_NOT_A_LIBERO_REPLACEMENT')).toBe(true);
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
it('rejects an out-of-band serve share (over 100)', () => {
|
|
356
|
+
const res = TacticsInputSchema.safeParse(baseInput({ serveAttackShares: { [p5.id]: 101 } }));
|
|
357
|
+
expect(res.success).toBe(false);
|
|
358
|
+
if (!res.success) {
|
|
359
|
+
expect(res.error.issues.some(i => i.message === 'SERVE_ATTACK_SHARE_OUT_OF_BAND')).toBe(true);
|
|
360
|
+
}
|
|
361
|
+
});
|
|
362
|
+
it('rejects a negative serve share', () => {
|
|
363
|
+
const res = TacticsInputSchema.safeParse(baseInput({ serveAttackShares: { [p5.id]: -1 } }));
|
|
364
|
+
expect(res.success).toBe(false);
|
|
365
|
+
if (!res.success) {
|
|
366
|
+
expect(res.error.issues.some(i => i.message === 'SERVE_ATTACK_SHARE_OUT_OF_BAND')).toBe(true);
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
it('validates independently of the offensive-preference sum-to-100 (never enters that total)', () => {
|
|
370
|
+
// The other attackers' weights sum to exactly 100; the serve share on the libero replacement is separate
|
|
371
|
+
// and must not push the rotation over 100 or otherwise fail.
|
|
372
|
+
const res = TacticsInputSchema.safeParse(baseInput({
|
|
373
|
+
offensivePreferences: [{ rotation: 1, weights: { [p2.id]: 50, [p3.id]: 50 } }],
|
|
374
|
+
serveAttackShares: { [p5.id]: 40 }
|
|
375
|
+
}));
|
|
376
|
+
expect(res.success).toBe(true);
|
|
377
|
+
});
|
|
378
|
+
it('accepts a serve share on a per-set libero replacement', () => {
|
|
379
|
+
// p6 is a back-row starter made a libero replacement only via liberoReplacementSets.
|
|
380
|
+
const res = TacticsInputSchema.safeParse(baseInput({
|
|
381
|
+
liberoReplacements: [],
|
|
382
|
+
liberoReplacementSets: [[p6]],
|
|
383
|
+
serveAttackShares: { [p6.id]: 25 }
|
|
384
|
+
}));
|
|
385
|
+
expect(res.success).toBe(true);
|
|
386
|
+
});
|
|
387
|
+
it('defaults serveAttackShares to an empty map when omitted', () => {
|
|
388
|
+
const res = TacticsInputSchema.safeParse(baseInput());
|
|
389
|
+
expect(res.success).toBe(true);
|
|
390
|
+
if (res.success)
|
|
391
|
+
expect(res.data.serveAttackShares).toEqual({});
|
|
392
|
+
});
|
|
393
|
+
});
|
|
@@ -275,6 +275,7 @@ export declare const TeamInputSchema: z.ZodObject<{
|
|
|
275
275
|
HIGH: "HIGH";
|
|
276
276
|
}>>>;
|
|
277
277
|
}, z.core.$strip>>>;
|
|
278
|
+
serveAttackShares: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodNumber>>;
|
|
278
279
|
systemSets: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
279
280
|
rotationSystem: z.ZodEnum<typeof import("..").RotationSystemEnum>;
|
|
280
281
|
designatedSetters: z.ZodArray<z.ZodCustom<Player, Player>>;
|
|
@@ -34,6 +34,7 @@ export interface TacticsOpts {
|
|
|
34
34
|
readonly rotationSystem: RotationSystemEnum;
|
|
35
35
|
readonly designatedSetters: Player[];
|
|
36
36
|
readonly offensivePreferences: OffensivePreference[];
|
|
37
|
+
readonly serveAttackShares: Record<string, number>;
|
|
37
38
|
readonly systemSets?: SystemSet[];
|
|
38
39
|
readonly offensivePreferenceSets?: OffensivePreference[][];
|
|
39
40
|
readonly replaceKnockedImmediately: Record<string, boolean>;
|
|
@@ -54,6 +55,7 @@ export declare class Tactics {
|
|
|
54
55
|
readonly rotationSystem: RotationSystemEnum;
|
|
55
56
|
readonly designatedSetters: Player[];
|
|
56
57
|
readonly offensivePreferences: OffensivePreference[];
|
|
58
|
+
readonly serveAttackShares: Record<string, number>;
|
|
57
59
|
readonly systemSets?: SystemSet[];
|
|
58
60
|
readonly offensivePreferenceSets?: OffensivePreference[][];
|
|
59
61
|
readonly replaceKnockedImmediately: Record<string, boolean>;
|
|
@@ -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, 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 }) {
|
|
19
19
|
this.lineup = lineup;
|
|
20
20
|
this.liberoReplacements = liberoReplacements;
|
|
21
21
|
this.receiveRotationOffset = receiveRotationOffset;
|
|
@@ -27,6 +27,7 @@ export class Tactics {
|
|
|
27
27
|
this.rotationSystem = rotationSystem;
|
|
28
28
|
this.designatedSetters = designatedSetters;
|
|
29
29
|
this.offensivePreferences = offensivePreferences;
|
|
30
|
+
this.serveAttackShares = serveAttackShares;
|
|
30
31
|
this.systemSets = systemSets;
|
|
31
32
|
this.offensivePreferenceSets = offensivePreferenceSets;
|
|
32
33
|
this.replaceKnockedImmediately = replaceKnockedImmediately;
|