volleyballsimtypes 0.0.521 → 0.0.522

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 (31) hide show
  1. package/dist/cjs/src/api/index.d.ts +1 -1
  2. package/dist/cjs/src/data/country-locales.json +12 -0
  3. package/dist/cjs/src/service/match/vper.js +23 -3
  4. package/dist/cjs/src/service/match/vper.test.js +86 -0
  5. package/dist/cjs/src/service/notification/catalog/en.json +4 -0
  6. package/dist/cjs/src/service/notification/catalog/es.json +4 -0
  7. package/dist/cjs/src/service/notification/catalog/fr.json +4 -0
  8. package/dist/cjs/src/service/notification/catalog/it.json +4 -0
  9. package/dist/cjs/src/service/notification/catalog/pl.json +4 -0
  10. package/dist/cjs/src/service/notification/catalog/pt-BR.json +4 -0
  11. package/dist/cjs/src/service/notification/catalog/tr.json +4 -0
  12. package/dist/cjs/src/service/notification/notification-type.d.ts +1 -1
  13. package/dist/cjs/src/service/notification/notification-type.js +2 -1
  14. package/dist/cjs/src/service/notification/registry.js +9 -0
  15. package/dist/cjs/src/service/team/default-base-config.test.js +31 -0
  16. package/dist/esm/src/api/index.d.ts +1 -1
  17. package/dist/esm/src/data/country-locales.json +12 -0
  18. package/dist/esm/src/service/match/vper.js +23 -3
  19. package/dist/esm/src/service/match/vper.test.js +86 -0
  20. package/dist/esm/src/service/notification/catalog/en.json +4 -0
  21. package/dist/esm/src/service/notification/catalog/es.json +4 -0
  22. package/dist/esm/src/service/notification/catalog/fr.json +4 -0
  23. package/dist/esm/src/service/notification/catalog/it.json +4 -0
  24. package/dist/esm/src/service/notification/catalog/pl.json +4 -0
  25. package/dist/esm/src/service/notification/catalog/pt-BR.json +4 -0
  26. package/dist/esm/src/service/notification/catalog/tr.json +4 -0
  27. package/dist/esm/src/service/notification/notification-type.d.ts +1 -1
  28. package/dist/esm/src/service/notification/notification-type.js +2 -1
  29. package/dist/esm/src/service/notification/registry.js +9 -0
  30. package/dist/esm/src/service/team/default-base-config.test.js +31 -0
  31. package/package.json +1 -1
@@ -60,7 +60,7 @@ export interface ApiPlayerInjuryStatus {
60
60
  severity: number;
61
61
  injuredUntil: string;
62
62
  }
63
- export type ChampionRegion = 'azureich' | 'borealas' | 'varune' | 'tirdeas';
63
+ export type ChampionRegion = 'azureich' | 'borealas' | 'varune' | 'tirdeas' | 'kyorin';
64
64
  export type ChampionBadgeSpec = {
65
65
  type: 'league';
66
66
  } | {
@@ -47,6 +47,18 @@
47
47
  "XP": [
48
48
  "nl_NL"
49
49
  ],
50
+ "XQ": [
51
+ "pl_PL"
52
+ ],
53
+ "XR": [
54
+ "th_TH"
55
+ ],
56
+ "XS": [
57
+ "ar_SA"
58
+ ],
59
+ "XT": [
60
+ "en_IN"
61
+ ],
50
62
  "PK": [
51
63
  "bal_Arab_PK",
52
64
  "bal_Latn_PK",
@@ -13,11 +13,14 @@ const WEIGHTS = {
13
13
  BLOCK_TOUCH: 0.2,
14
14
  DEFENSE_DIG: 0.25,
15
15
  SETTING_PASS: 0.15,
16
+ // A swing that neither killed nor was stuffed nor sailed: the ball is still alive and the defence had to play it.
17
+ ATTACK_IN_PLAY: 0.10,
16
18
  // reception quality
17
19
  RECEPTION_PERFECT: 0.35,
18
20
  RECEPTION_POSITIVE: 0.15,
19
21
  RECEPTION_OVERPASS: -0.6,
20
- // terminal errors (always point against)
22
+ // terminal errors (always point against). A stuffed attack is booked as an attack error by the simulator, so
23
+ // being blocked is penalised here at the same -1.0 as a swing into the net.
21
24
  TERMINAL_ERROR: -1.0
22
25
  };
23
26
  // Minimum points a player must be on court for in a match to receive a VPER. VPER is a per-100-points rate,
@@ -47,14 +50,31 @@ class VPER {
47
50
  let pointsPlayedSum = 0;
48
51
  for (const box of boxScores) {
49
52
  const w = WEIGHTS;
53
+ // Each action is scored ONCE, at its highest-value outcome. A set that became a kill is an assist, not an
54
+ // assist plus a pass; a touch that became a stuff is a block point, not a block point plus a touch. The
55
+ // subtractions below strip the lower-value credit that the simulator books alongside the terminal one.
56
+ //
57
+ // Each remainder is clamped at 0. A well-formed row can never make one negative (a swing yields at most one
58
+ // of {kill, error}; every assist belongs to a set that booked a pass on this same row; BlockStatsSchema
59
+ // enforces solo + assists <= touches), so the clamp only guards against a malformed or hand-built row
60
+ // silently turning a subtraction into a bonus.
61
+ //
62
+ // A swing that stayed alive: attempts minus the ones that terminated (a kill, or an error, which now
63
+ // includes being stuffed).
64
+ const inPlayAttacks = Math.max(0, box.attack.attempts - box.attack.kills - box.attack.errors);
65
+ // Sets that did NOT convert into a kill.
66
+ const unconvertedSets = Math.max(0, box.setting.pass - box.setting.assists);
67
+ // Touches that did not become a block point.
68
+ const nonScoringTouches = Math.max(0, box.block.touches - box.block.solo - box.block.assists);
50
69
  const positive = w.ATTACK_KILL * box.attack.kills +
70
+ w.ATTACK_IN_PLAY * inPlayAttacks +
51
71
  w.SERVE_ACE * box.serve.aces +
52
72
  w.BLOCK_SOLO * box.block.solo +
53
73
  w.BLOCK_ASSIST * box.block.assists +
54
74
  w.SETTING_ASSIST * box.setting.assists +
55
- w.BLOCK_TOUCH * box.block.touches +
75
+ w.BLOCK_TOUCH * nonScoringTouches +
56
76
  w.DEFENSE_DIG * box.defense.digs +
57
- w.SETTING_PASS * box.setting.pass +
77
+ w.SETTING_PASS * unconvertedSets +
58
78
  w.RECEPTION_PERFECT * box.reception.perfect +
59
79
  w.RECEPTION_POSITIVE * box.reception.positive;
60
80
  const negative = w.RECEPTION_OVERPASS * box.reception.overpasses +
@@ -106,6 +106,92 @@ function makeBox() {
106
106
  (0, globals_1.expect)(vper_1.VPER.computeVPERForMatch([box])).toBeCloseTo(14, 5);
107
107
  });
108
108
  });
109
+ // ─── each action scores once, at its highest-value outcome ────────────────────
110
+ (0, globals_1.describe)('VPER.computeVPERForMatch() — no double-counting', () => {
111
+ (0, globals_1.it)('scores a converted set as an assist only, never assist + pass', () => {
112
+ const box = makeBox();
113
+ box.setting.pass = 1;
114
+ box.setting.assists = 1;
115
+ box.misc.pointsPlayed = 10;
116
+ // unconvertedSets = 1 - 1 = 0, so the setter earns 0.7, not 0.85.
117
+ (0, globals_1.expect)(vper_1.VPER.computeVPERForMatch([box])).toBeCloseTo(7, 5);
118
+ });
119
+ (0, globals_1.it)('pays the pass weight only on sets that did not convert', () => {
120
+ const box = makeBox();
121
+ box.setting.pass = 10;
122
+ box.setting.assists = 4;
123
+ box.misc.pointsPlayed = 10;
124
+ // assists 4 * 0.7 = 2.8; unconverted 6 * 0.15 = 0.9; raw 3.7
125
+ (0, globals_1.expect)(vper_1.VPER.computeVPERForMatch([box])).toBeCloseTo(37, 5);
126
+ });
127
+ (0, globals_1.it)('scores a solo stuff as a block point only, never solo + touch', () => {
128
+ const box = makeBox();
129
+ box.block.touches = 1;
130
+ box.block.solo = 1;
131
+ box.misc.pointsPlayed = 10;
132
+ // nonScoringTouches = 1 - 1 - 0 = 0, so the blocker earns 1.0, not 1.2.
133
+ (0, globals_1.expect)(vper_1.VPER.computeVPERForMatch([box])).toBeCloseTo(10, 5);
134
+ });
135
+ (0, globals_1.it)('pays the touch weight only on touches that did not become a point', () => {
136
+ const box = makeBox();
137
+ box.block.touches = 5;
138
+ box.block.solo = 1;
139
+ box.block.assists = 2;
140
+ box.misc.pointsPlayed = 10;
141
+ // solo 1.0 + assists 2 * 0.5 = 1.0 + nonScoringTouches 2 * 0.2 = 0.4; raw 2.4
142
+ (0, globals_1.expect)(vper_1.VPER.computeVPERForMatch([box])).toBeCloseTo(24, 5);
143
+ });
144
+ (0, globals_1.it)('never lets a malformed row turn a subtraction into a bonus', () => {
145
+ // kills without attempts, assists without passes, blocks without touches: every remainder clamps to 0.
146
+ const box = makeBox();
147
+ box.attack.kills = 5;
148
+ box.setting.assists = 10;
149
+ box.block.solo = 3;
150
+ box.misc.pointsPlayed = 10;
151
+ // 5 * 1.0 + 10 * 0.7 + 3 * 1.0 = 15, with no negative remainder contribution
152
+ (0, globals_1.expect)(vper_1.VPER.computeVPERForMatch([box])).toBeCloseTo(150, 5);
153
+ });
154
+ });
155
+ // ─── the non-scoring attack ──────────────────────────────────────────────────
156
+ (0, globals_1.describe)('VPER.computeVPERForMatch() — attack outcomes', () => {
157
+ (0, globals_1.it)('rewards a swing that stays in play (weight 0.10)', () => {
158
+ const box = makeBox();
159
+ box.attack.attempts = 10;
160
+ box.attack.kills = 3;
161
+ box.attack.errors = 1;
162
+ box.misc.pointsPlayed = 10;
163
+ // kills 3 * 1.0 = 3; inPlay (10 - 3 - 1) = 6 * 0.10 = 0.6; errors 1 * -1.0; raw 2.6
164
+ (0, globals_1.expect)(vper_1.VPER.computeVPERForMatch([box])).toBeCloseTo(26, 5);
165
+ });
166
+ (0, globals_1.it)('penalises a stuffed swing at -1.0 (the simulator books it as an attack error)', () => {
167
+ const box = makeBox();
168
+ box.attack.attempts = 1;
169
+ box.attack.errors = 1;
170
+ box.misc.pointsPlayed = 10;
171
+ // inPlay = 0; the stuff is a terminal error
172
+ (0, globals_1.expect)(vper_1.VPER.computeVPERForMatch([box])).toBeCloseTo(-10, 5);
173
+ });
174
+ (0, globals_1.it)('separates volume from efficiency: more dug swings for the same kills rates higher', () => {
175
+ const efficient = makeBox();
176
+ efficient.attack.attempts = 12;
177
+ efficient.attack.kills = 10;
178
+ efficient.misc.pointsPlayed = 100;
179
+ const volume = makeBox();
180
+ volume.attack.attempts = 30;
181
+ volume.attack.kills = 10;
182
+ volume.misc.pointsPlayed = 100;
183
+ // Pre-fix both scored exactly 10.0. Now the extra 18 dug swings are worth 0.10 each.
184
+ (0, globals_1.expect)(vper_1.VPER.computeVPERForMatch([efficient])).toBeCloseTo(10.2, 5);
185
+ (0, globals_1.expect)(vper_1.VPER.computeVPERForMatch([volume])).toBeCloseTo(12.0, 5);
186
+ });
187
+ (0, globals_1.it)('a swing that is killed scores the kill, not the kill plus an in-play credit', () => {
188
+ const box = makeBox();
189
+ box.attack.attempts = 1;
190
+ box.attack.kills = 1;
191
+ box.misc.pointsPlayed = 10;
192
+ (0, globals_1.expect)(vper_1.VPER.computeVPERForMatch([box])).toBeCloseTo(10, 5);
193
+ });
194
+ });
109
195
  // ─── VPER eligibility threshold ───────────────────────────────────────────────
110
196
  (0, globals_1.describe)('VPER.qualifiesForVPER()', () => {
111
197
  function boxWithPoints(points) {
@@ -58,5 +58,9 @@
58
58
  "title": "Event finished",
59
59
  "body": "The event is over. See the final table and your rewards.",
60
60
  "placed": "The event is over. You finished {{position}} of {{entrants}}."
61
+ },
62
+ "regionRelocation": {
63
+ "title": "Your team is moving",
64
+ "body": "Your team has been chosen to be moved to the new region: Kyorin. Nothing has changed in terms of the team you manage, your players or anything else."
61
65
  }
62
66
  }
@@ -58,5 +58,9 @@
58
58
  "title": "Evento terminado",
59
59
  "body": "El evento ha terminado. Mira la tabla final y tus recompensas.",
60
60
  "placed": "El evento ha terminado. Has quedado {{position}} de {{entrants}}."
61
+ },
62
+ "regionRelocation": {
63
+ "title": "Tu equipo se traslada",
64
+ "body": "Tu equipo ha sido elegido para trasladarse a la nueva región: Kyorin. No cambia nada en cuanto al equipo que gestionas, tus jugadores ni nada más."
61
65
  }
62
66
  }
@@ -58,5 +58,9 @@
58
58
  "title": "Événement terminé",
59
59
  "body": "L'événement est terminé. Consulte le classement final et tes récompenses.",
60
60
  "placed": "L'événement est terminé. Tu finis {{position}} sur {{entrants}}."
61
+ },
62
+ "regionRelocation": {
63
+ "title": "Votre équipe déménage",
64
+ "body": "Votre équipe a été choisie pour être transférée vers la nouvelle région : Kyorin. Rien ne change concernant l'équipe que vous gérez, vos joueurs ni quoi que ce soit d'autre."
61
65
  }
62
66
  }
@@ -58,5 +58,9 @@
58
58
  "title": "Evento concluso",
59
59
  "body": "L'evento è finito. Guarda la classifica finale e i tuoi premi.",
60
60
  "placed": "L'evento è finito. Hai chiuso {{position}} su {{entrants}}."
61
+ },
62
+ "regionRelocation": {
63
+ "title": "La tua squadra si trasferisce",
64
+ "body": "La tua squadra è stata scelta per essere spostata nella nuova regione: Kyorin. Non cambia nulla riguardo alla squadra che gestisci, ai tuoi giocatori o a qualsiasi altra cosa."
61
65
  }
62
66
  }
@@ -58,5 +58,9 @@
58
58
  "title": "Wydarzenie zakończone",
59
59
  "body": "Wydarzenie się skończyło. Zobacz tabelę końcową i nagrody.",
60
60
  "placed": "Wydarzenie się skończyło. Zająłeś {{position}} miejsce na {{entrants}}."
61
+ },
62
+ "regionRelocation": {
63
+ "title": "Twoja drużyna się przenosi",
64
+ "body": "Twoja drużyna została wybrana do przeniesienia do nowego regionu: Kyorin. Nic się nie zmienia, jeśli chodzi o zarządzaną przez ciebie drużynę, twoich zawodników ani cokolwiek innego."
61
65
  }
62
66
  }
@@ -58,5 +58,9 @@
58
58
  "title": "Evento encerrado",
59
59
  "body": "O evento acabou. Veja a tabela final e suas recompensas.",
60
60
  "placed": "O evento acabou. Você terminou em {{position}} de {{entrants}}."
61
+ },
62
+ "regionRelocation": {
63
+ "title": "Seu time está mudando",
64
+ "body": "Seu time foi escolhido para ser movido para a nova região: Kyorin. Nada muda em relação ao time que você gerencia, seus jogadores ou qualquer outra coisa."
61
65
  }
62
66
  }
@@ -58,5 +58,9 @@
58
58
  "title": "Etkinlik bitti",
59
59
  "body": "Etkinlik sona erdi. Final tablosuna ve ödüllerine bak.",
60
60
  "placed": "Etkinlik sona erdi. {{entrants}} takım arasında {{position}}. oldun."
61
+ },
62
+ "regionRelocation": {
63
+ "title": "Takımın taşınıyor",
64
+ "body": "Takımın yeni bölgeye, Kyorin'e taşınmak üzere seçildi. Yönettiğin takım, oyuncuların ya da başka hiçbir şey açısından hiçbir şey değişmiyor."
61
65
  }
62
66
  }
@@ -1,4 +1,4 @@
1
- export declare const NOTIFICATION_TYPES: readonly ["MATCH_RESULT", "INJURY", "INJURY_RECOVERED", "SEASON_END", "GRANT_RECEIVED", "PROMOTION", "DAILY_LOGIN", "WELCOME", "COMPENSATION", "EVENT_REGISTRATION_OPEN", "EVENT_REGISTRATION_CLOSING", "EVENT_STARTED", "EVENT_FINAL_ROUND", "EVENT_FINISHED"];
1
+ export declare const NOTIFICATION_TYPES: readonly ["MATCH_RESULT", "INJURY", "INJURY_RECOVERED", "SEASON_END", "GRANT_RECEIVED", "PROMOTION", "DAILY_LOGIN", "WELCOME", "COMPENSATION", "EVENT_REGISTRATION_OPEN", "EVENT_REGISTRATION_CLOSING", "EVENT_STARTED", "EVENT_FINAL_ROUND", "EVENT_FINISHED", "REGION_RELOCATION"];
2
2
  export type NotificationType = typeof NOTIFICATION_TYPES[number];
3
3
  export declare const NOTIFICATION_LOCALES: readonly ["en", "es", "fr", "it", "pl", "pt-BR", "tr"];
4
4
  export type NotificationLocale = typeof NOTIFICATION_LOCALES[number];
@@ -21,7 +21,8 @@ exports.NOTIFICATION_TYPES = [
21
21
  'EVENT_REGISTRATION_CLOSING',
22
22
  'EVENT_STARTED',
23
23
  'EVENT_FINAL_ROUND',
24
- 'EVENT_FINISHED'
24
+ 'EVENT_FINISHED',
25
+ 'REGION_RELOCATION'
25
26
  ];
26
27
  // The 7 supported locales. Must stay in sync with the API VALID_LOCALES set and the UI src/locales/*.json.
27
28
  exports.NOTIFICATION_LOCALES = ['en', 'es', 'fr', 'it', 'pl', 'pt-BR', 'tr'];
@@ -162,6 +162,15 @@ exports.NOTIFICATION_REGISTRY = {
162
162
  emitsPush: false,
163
163
  richRender: 'COMPENSATION',
164
164
  catalogKeys: []
165
+ },
166
+ // One-off relocation warning: sent to users whose team is being moved to Kyorin. Informational; opens their team.
167
+ REGION_RELOCATION: {
168
+ emitsPush: true,
169
+ deepLink: teamPath,
170
+ pushTitleKey: 'regionRelocation.title',
171
+ pushBodyKey: 'regionRelocation.body',
172
+ inAppMessageKey: 'regionRelocation.body',
173
+ catalogKeys: ['regionRelocation.title', 'regionRelocation.body']
165
174
  }
166
175
  };
167
176
  // The notification types that emit a mobile push (everything except COMPENSATION). Source of truth = each
@@ -2,8 +2,39 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  const rotation_system_1 = require("./rotation-system");
4
4
  const default_base_config_1 = require("./default-base-config");
5
+ const receive_overlap_1 = require("./receive-overlap");
5
6
  // Back-row rotational zones (right/left/middle back). Front row is {2,3,4}.
6
7
  const BACK_ROW = new Set([1, 5, 6]);
8
+ // The editor and the sim BOTH fall back to defaultBaseConfig for a zone a config leaves unset, so the default base
9
+ // MUST itself pass the FIVB overlap / serve-line rules in every rotation. Otherwise a partial config (some zones
10
+ // stored, others defaulted) can render/commit an unset zone onto a neighbour and read "out of rotation even in
11
+ // base" for a formation the sim runs legally (rush6@ tournament report, 2026-08-21).
12
+ describe('defaultBaseConfig base-config legality', () => {
13
+ it('has zero SERVE/RECEIVE overlap violations for every rotation system', () => {
14
+ for (const system of Object.values(rotation_system_1.RotationSystemEnum)) {
15
+ expect({ system, violations: (0, receive_overlap_1.baseConfigViolations)((0, default_base_config_1.defaultBaseConfig)(system)) }).toEqual({ system, violations: [] });
16
+ }
17
+ });
18
+ });
19
+ // Regression (rush6@ tournament report, 2026-08-21): a partial RECEIVE config (some zones stored, one left unset)
20
+ // reads "out of rotation even in base" ONLY when the editor fills the unset zone from a coarse flat grid (all
21
+ // back-row zones at depth 6.5), which lands on a stored DEEP neighbour. Filling from the sim's per-rotation default
22
+ // (what the editor's coordOf now does, matching what the sim always did) keeps it legal.
23
+ describe('unset RECEIVE zone fallback (editor gap fill)', () => {
24
+ // The reported user's real rotation-2 RECEIVE: zone 3 sits abnormally deep at d 6.5; zone 6 is unset.
25
+ const partialRot2 = {
26
+ 1: { d: 6.4, r: 3.15 }, 2: { d: 2.75, r: 1.65 }, 3: { d: 6.5, r: -2.65 }, 4: { d: 2.75, r: -2.75 }, 5: { d: 2.8, r: -3.05 }
27
+ };
28
+ const FLAT_GRID_ZONE6 = { d: 6.5, r: 0 }; // the editor's OLD blank-slate default for a back-row zone
29
+ const SIM_DEFAULT_ZONE6 = (0, default_base_config_1.defaultBaseConfig)(rotation_system_1.RotationSystemEnum.FIVE_ONE).coords.RECEIVE['2']['6']; // the fix's fallback
30
+ const violationsWithZone6 = (zone6) => (0, receive_overlap_1.baseConfigViolations)({ coords: { RECEIVE: { 2: { ...partialRot2, 6: zone6 } } } });
31
+ it('collides (zones 3,6) when the gap is filled from the flat grid - the bug', () => {
32
+ expect(violationsWithZone6(FLAT_GRID_ZONE6).map(v => v.zones)).toEqual([[3, 6]]);
33
+ });
34
+ it('stays legal when the gap is filled from the sim per-rotation default - the fix', () => {
35
+ expect(violationsWithZone6(SIM_DEFAULT_ZONE6)).toEqual([]);
36
+ });
37
+ });
7
38
  // The trajectory-set default desired-contact (owner D9, 2026-08-12): a near-net hitting point in each attacker's
8
39
  // lane, NOT the old set-to-the-standing-dot location. Front row contacts sit ~0.5 m off the net; back row on the
9
40
  // ~1.8 m pipe line; the lateral is the ATTACK-dot's r clamped inside the antenna (|r| <= 4.3).
@@ -60,7 +60,7 @@ export interface ApiPlayerInjuryStatus {
60
60
  severity: number;
61
61
  injuredUntil: string;
62
62
  }
63
- export type ChampionRegion = 'azureich' | 'borealas' | 'varune' | 'tirdeas';
63
+ export type ChampionRegion = 'azureich' | 'borealas' | 'varune' | 'tirdeas' | 'kyorin';
64
64
  export type ChampionBadgeSpec = {
65
65
  type: 'league';
66
66
  } | {
@@ -47,6 +47,18 @@
47
47
  "XP": [
48
48
  "nl_NL"
49
49
  ],
50
+ "XQ": [
51
+ "pl_PL"
52
+ ],
53
+ "XR": [
54
+ "th_TH"
55
+ ],
56
+ "XS": [
57
+ "ar_SA"
58
+ ],
59
+ "XT": [
60
+ "en_IN"
61
+ ],
50
62
  "PK": [
51
63
  "bal_Arab_PK",
52
64
  "bal_Latn_PK",
@@ -10,11 +10,14 @@ const WEIGHTS = {
10
10
  BLOCK_TOUCH: 0.2,
11
11
  DEFENSE_DIG: 0.25,
12
12
  SETTING_PASS: 0.15,
13
+ // A swing that neither killed nor was stuffed nor sailed: the ball is still alive and the defence had to play it.
14
+ ATTACK_IN_PLAY: 0.10,
13
15
  // reception quality
14
16
  RECEPTION_PERFECT: 0.35,
15
17
  RECEPTION_POSITIVE: 0.15,
16
18
  RECEPTION_OVERPASS: -0.6,
17
- // terminal errors (always point against)
19
+ // terminal errors (always point against). A stuffed attack is booked as an attack error by the simulator, so
20
+ // being blocked is penalised here at the same -1.0 as a swing into the net.
18
21
  TERMINAL_ERROR: -1.0
19
22
  };
20
23
  // Minimum points a player must be on court for in a match to receive a VPER. VPER is a per-100-points rate,
@@ -44,14 +47,31 @@ export class VPER {
44
47
  let pointsPlayedSum = 0;
45
48
  for (const box of boxScores) {
46
49
  const w = WEIGHTS;
50
+ // Each action is scored ONCE, at its highest-value outcome. A set that became a kill is an assist, not an
51
+ // assist plus a pass; a touch that became a stuff is a block point, not a block point plus a touch. The
52
+ // subtractions below strip the lower-value credit that the simulator books alongside the terminal one.
53
+ //
54
+ // Each remainder is clamped at 0. A well-formed row can never make one negative (a swing yields at most one
55
+ // of {kill, error}; every assist belongs to a set that booked a pass on this same row; BlockStatsSchema
56
+ // enforces solo + assists <= touches), so the clamp only guards against a malformed or hand-built row
57
+ // silently turning a subtraction into a bonus.
58
+ //
59
+ // A swing that stayed alive: attempts minus the ones that terminated (a kill, or an error, which now
60
+ // includes being stuffed).
61
+ const inPlayAttacks = Math.max(0, box.attack.attempts - box.attack.kills - box.attack.errors);
62
+ // Sets that did NOT convert into a kill.
63
+ const unconvertedSets = Math.max(0, box.setting.pass - box.setting.assists);
64
+ // Touches that did not become a block point.
65
+ const nonScoringTouches = Math.max(0, box.block.touches - box.block.solo - box.block.assists);
47
66
  const positive = w.ATTACK_KILL * box.attack.kills +
67
+ w.ATTACK_IN_PLAY * inPlayAttacks +
48
68
  w.SERVE_ACE * box.serve.aces +
49
69
  w.BLOCK_SOLO * box.block.solo +
50
70
  w.BLOCK_ASSIST * box.block.assists +
51
71
  w.SETTING_ASSIST * box.setting.assists +
52
- w.BLOCK_TOUCH * box.block.touches +
72
+ w.BLOCK_TOUCH * nonScoringTouches +
53
73
  w.DEFENSE_DIG * box.defense.digs +
54
- w.SETTING_PASS * box.setting.pass +
74
+ w.SETTING_PASS * unconvertedSets +
55
75
  w.RECEPTION_PERFECT * box.reception.perfect +
56
76
  w.RECEPTION_POSITIVE * box.reception.positive;
57
77
  const negative = w.RECEPTION_OVERPASS * box.reception.overpasses +
@@ -104,6 +104,92 @@ describe('VPER.computeVPERForMatch()', () => {
104
104
  expect(VPER.computeVPERForMatch([box])).toBeCloseTo(14, 5);
105
105
  });
106
106
  });
107
+ // ─── each action scores once, at its highest-value outcome ────────────────────
108
+ describe('VPER.computeVPERForMatch() — no double-counting', () => {
109
+ it('scores a converted set as an assist only, never assist + pass', () => {
110
+ const box = makeBox();
111
+ box.setting.pass = 1;
112
+ box.setting.assists = 1;
113
+ box.misc.pointsPlayed = 10;
114
+ // unconvertedSets = 1 - 1 = 0, so the setter earns 0.7, not 0.85.
115
+ expect(VPER.computeVPERForMatch([box])).toBeCloseTo(7, 5);
116
+ });
117
+ it('pays the pass weight only on sets that did not convert', () => {
118
+ const box = makeBox();
119
+ box.setting.pass = 10;
120
+ box.setting.assists = 4;
121
+ box.misc.pointsPlayed = 10;
122
+ // assists 4 * 0.7 = 2.8; unconverted 6 * 0.15 = 0.9; raw 3.7
123
+ expect(VPER.computeVPERForMatch([box])).toBeCloseTo(37, 5);
124
+ });
125
+ it('scores a solo stuff as a block point only, never solo + touch', () => {
126
+ const box = makeBox();
127
+ box.block.touches = 1;
128
+ box.block.solo = 1;
129
+ box.misc.pointsPlayed = 10;
130
+ // nonScoringTouches = 1 - 1 - 0 = 0, so the blocker earns 1.0, not 1.2.
131
+ expect(VPER.computeVPERForMatch([box])).toBeCloseTo(10, 5);
132
+ });
133
+ it('pays the touch weight only on touches that did not become a point', () => {
134
+ const box = makeBox();
135
+ box.block.touches = 5;
136
+ box.block.solo = 1;
137
+ box.block.assists = 2;
138
+ box.misc.pointsPlayed = 10;
139
+ // solo 1.0 + assists 2 * 0.5 = 1.0 + nonScoringTouches 2 * 0.2 = 0.4; raw 2.4
140
+ expect(VPER.computeVPERForMatch([box])).toBeCloseTo(24, 5);
141
+ });
142
+ it('never lets a malformed row turn a subtraction into a bonus', () => {
143
+ // kills without attempts, assists without passes, blocks without touches: every remainder clamps to 0.
144
+ const box = makeBox();
145
+ box.attack.kills = 5;
146
+ box.setting.assists = 10;
147
+ box.block.solo = 3;
148
+ box.misc.pointsPlayed = 10;
149
+ // 5 * 1.0 + 10 * 0.7 + 3 * 1.0 = 15, with no negative remainder contribution
150
+ expect(VPER.computeVPERForMatch([box])).toBeCloseTo(150, 5);
151
+ });
152
+ });
153
+ // ─── the non-scoring attack ──────────────────────────────────────────────────
154
+ describe('VPER.computeVPERForMatch() — attack outcomes', () => {
155
+ it('rewards a swing that stays in play (weight 0.10)', () => {
156
+ const box = makeBox();
157
+ box.attack.attempts = 10;
158
+ box.attack.kills = 3;
159
+ box.attack.errors = 1;
160
+ box.misc.pointsPlayed = 10;
161
+ // kills 3 * 1.0 = 3; inPlay (10 - 3 - 1) = 6 * 0.10 = 0.6; errors 1 * -1.0; raw 2.6
162
+ expect(VPER.computeVPERForMatch([box])).toBeCloseTo(26, 5);
163
+ });
164
+ it('penalises a stuffed swing at -1.0 (the simulator books it as an attack error)', () => {
165
+ const box = makeBox();
166
+ box.attack.attempts = 1;
167
+ box.attack.errors = 1;
168
+ box.misc.pointsPlayed = 10;
169
+ // inPlay = 0; the stuff is a terminal error
170
+ expect(VPER.computeVPERForMatch([box])).toBeCloseTo(-10, 5);
171
+ });
172
+ it('separates volume from efficiency: more dug swings for the same kills rates higher', () => {
173
+ const efficient = makeBox();
174
+ efficient.attack.attempts = 12;
175
+ efficient.attack.kills = 10;
176
+ efficient.misc.pointsPlayed = 100;
177
+ const volume = makeBox();
178
+ volume.attack.attempts = 30;
179
+ volume.attack.kills = 10;
180
+ volume.misc.pointsPlayed = 100;
181
+ // Pre-fix both scored exactly 10.0. Now the extra 18 dug swings are worth 0.10 each.
182
+ expect(VPER.computeVPERForMatch([efficient])).toBeCloseTo(10.2, 5);
183
+ expect(VPER.computeVPERForMatch([volume])).toBeCloseTo(12.0, 5);
184
+ });
185
+ it('a swing that is killed scores the kill, not the kill plus an in-play credit', () => {
186
+ const box = makeBox();
187
+ box.attack.attempts = 1;
188
+ box.attack.kills = 1;
189
+ box.misc.pointsPlayed = 10;
190
+ expect(VPER.computeVPERForMatch([box])).toBeCloseTo(10, 5);
191
+ });
192
+ });
107
193
  // ─── VPER eligibility threshold ───────────────────────────────────────────────
108
194
  describe('VPER.qualifiesForVPER()', () => {
109
195
  function boxWithPoints(points) {
@@ -58,5 +58,9 @@
58
58
  "title": "Event finished",
59
59
  "body": "The event is over. See the final table and your rewards.",
60
60
  "placed": "The event is over. You finished {{position}} of {{entrants}}."
61
+ },
62
+ "regionRelocation": {
63
+ "title": "Your team is moving",
64
+ "body": "Your team has been chosen to be moved to the new region: Kyorin. Nothing has changed in terms of the team you manage, your players or anything else."
61
65
  }
62
66
  }
@@ -58,5 +58,9 @@
58
58
  "title": "Evento terminado",
59
59
  "body": "El evento ha terminado. Mira la tabla final y tus recompensas.",
60
60
  "placed": "El evento ha terminado. Has quedado {{position}} de {{entrants}}."
61
+ },
62
+ "regionRelocation": {
63
+ "title": "Tu equipo se traslada",
64
+ "body": "Tu equipo ha sido elegido para trasladarse a la nueva región: Kyorin. No cambia nada en cuanto al equipo que gestionas, tus jugadores ni nada más."
61
65
  }
62
66
  }
@@ -58,5 +58,9 @@
58
58
  "title": "Événement terminé",
59
59
  "body": "L'événement est terminé. Consulte le classement final et tes récompenses.",
60
60
  "placed": "L'événement est terminé. Tu finis {{position}} sur {{entrants}}."
61
+ },
62
+ "regionRelocation": {
63
+ "title": "Votre équipe déménage",
64
+ "body": "Votre équipe a été choisie pour être transférée vers la nouvelle région : Kyorin. Rien ne change concernant l'équipe que vous gérez, vos joueurs ni quoi que ce soit d'autre."
61
65
  }
62
66
  }
@@ -58,5 +58,9 @@
58
58
  "title": "Evento concluso",
59
59
  "body": "L'evento è finito. Guarda la classifica finale e i tuoi premi.",
60
60
  "placed": "L'evento è finito. Hai chiuso {{position}} su {{entrants}}."
61
+ },
62
+ "regionRelocation": {
63
+ "title": "La tua squadra si trasferisce",
64
+ "body": "La tua squadra è stata scelta per essere spostata nella nuova regione: Kyorin. Non cambia nulla riguardo alla squadra che gestisci, ai tuoi giocatori o a qualsiasi altra cosa."
61
65
  }
62
66
  }
@@ -58,5 +58,9 @@
58
58
  "title": "Wydarzenie zakończone",
59
59
  "body": "Wydarzenie się skończyło. Zobacz tabelę końcową i nagrody.",
60
60
  "placed": "Wydarzenie się skończyło. Zająłeś {{position}} miejsce na {{entrants}}."
61
+ },
62
+ "regionRelocation": {
63
+ "title": "Twoja drużyna się przenosi",
64
+ "body": "Twoja drużyna została wybrana do przeniesienia do nowego regionu: Kyorin. Nic się nie zmienia, jeśli chodzi o zarządzaną przez ciebie drużynę, twoich zawodników ani cokolwiek innego."
61
65
  }
62
66
  }
@@ -58,5 +58,9 @@
58
58
  "title": "Evento encerrado",
59
59
  "body": "O evento acabou. Veja a tabela final e suas recompensas.",
60
60
  "placed": "O evento acabou. Você terminou em {{position}} de {{entrants}}."
61
+ },
62
+ "regionRelocation": {
63
+ "title": "Seu time está mudando",
64
+ "body": "Seu time foi escolhido para ser movido para a nova região: Kyorin. Nada muda em relação ao time que você gerencia, seus jogadores ou qualquer outra coisa."
61
65
  }
62
66
  }
@@ -58,5 +58,9 @@
58
58
  "title": "Etkinlik bitti",
59
59
  "body": "Etkinlik sona erdi. Final tablosuna ve ödüllerine bak.",
60
60
  "placed": "Etkinlik sona erdi. {{entrants}} takım arasında {{position}}. oldun."
61
+ },
62
+ "regionRelocation": {
63
+ "title": "Takımın taşınıyor",
64
+ "body": "Takımın yeni bölgeye, Kyorin'e taşınmak üzere seçildi. Yönettiğin takım, oyuncuların ya da başka hiçbir şey açısından hiçbir şey değişmiyor."
61
65
  }
62
66
  }
@@ -1,4 +1,4 @@
1
- export declare const NOTIFICATION_TYPES: readonly ["MATCH_RESULT", "INJURY", "INJURY_RECOVERED", "SEASON_END", "GRANT_RECEIVED", "PROMOTION", "DAILY_LOGIN", "WELCOME", "COMPENSATION", "EVENT_REGISTRATION_OPEN", "EVENT_REGISTRATION_CLOSING", "EVENT_STARTED", "EVENT_FINAL_ROUND", "EVENT_FINISHED"];
1
+ export declare const NOTIFICATION_TYPES: readonly ["MATCH_RESULT", "INJURY", "INJURY_RECOVERED", "SEASON_END", "GRANT_RECEIVED", "PROMOTION", "DAILY_LOGIN", "WELCOME", "COMPENSATION", "EVENT_REGISTRATION_OPEN", "EVENT_REGISTRATION_CLOSING", "EVENT_STARTED", "EVENT_FINAL_ROUND", "EVENT_FINISHED", "REGION_RELOCATION"];
2
2
  export type NotificationType = typeof NOTIFICATION_TYPES[number];
3
3
  export declare const NOTIFICATION_LOCALES: readonly ["en", "es", "fr", "it", "pl", "pt-BR", "tr"];
4
4
  export type NotificationLocale = typeof NOTIFICATION_LOCALES[number];
@@ -16,7 +16,8 @@ export const NOTIFICATION_TYPES = [
16
16
  'EVENT_REGISTRATION_CLOSING',
17
17
  'EVENT_STARTED',
18
18
  'EVENT_FINAL_ROUND',
19
- 'EVENT_FINISHED'
19
+ 'EVENT_FINISHED',
20
+ 'REGION_RELOCATION'
20
21
  ];
21
22
  // The 7 supported locales. Must stay in sync with the API VALID_LOCALES set and the UI src/locales/*.json.
22
23
  export const NOTIFICATION_LOCALES = ['en', 'es', 'fr', 'it', 'pl', 'pt-BR', 'tr'];
@@ -159,6 +159,15 @@ export const NOTIFICATION_REGISTRY = {
159
159
  emitsPush: false,
160
160
  richRender: 'COMPENSATION',
161
161
  catalogKeys: []
162
+ },
163
+ // One-off relocation warning: sent to users whose team is being moved to Kyorin. Informational; opens their team.
164
+ REGION_RELOCATION: {
165
+ emitsPush: true,
166
+ deepLink: teamPath,
167
+ pushTitleKey: 'regionRelocation.title',
168
+ pushBodyKey: 'regionRelocation.body',
169
+ inAppMessageKey: 'regionRelocation.body',
170
+ catalogKeys: ['regionRelocation.title', 'regionRelocation.body']
162
171
  }
163
172
  };
164
173
  // The notification types that emit a mobile push (everything except COMPENSATION). Source of truth = each
@@ -1,7 +1,38 @@
1
1
  import { RotationSystemEnum } from './rotation-system';
2
2
  import { defaultBaseConfig } from './default-base-config';
3
+ import { baseConfigViolations } from './receive-overlap';
3
4
  // Back-row rotational zones (right/left/middle back). Front row is {2,3,4}.
4
5
  const BACK_ROW = new Set([1, 5, 6]);
6
+ // The editor and the sim BOTH fall back to defaultBaseConfig for a zone a config leaves unset, so the default base
7
+ // MUST itself pass the FIVB overlap / serve-line rules in every rotation. Otherwise a partial config (some zones
8
+ // stored, others defaulted) can render/commit an unset zone onto a neighbour and read "out of rotation even in
9
+ // base" for a formation the sim runs legally (rush6@ tournament report, 2026-08-21).
10
+ describe('defaultBaseConfig base-config legality', () => {
11
+ it('has zero SERVE/RECEIVE overlap violations for every rotation system', () => {
12
+ for (const system of Object.values(RotationSystemEnum)) {
13
+ expect({ system, violations: baseConfigViolations(defaultBaseConfig(system)) }).toEqual({ system, violations: [] });
14
+ }
15
+ });
16
+ });
17
+ // Regression (rush6@ tournament report, 2026-08-21): a partial RECEIVE config (some zones stored, one left unset)
18
+ // reads "out of rotation even in base" ONLY when the editor fills the unset zone from a coarse flat grid (all
19
+ // back-row zones at depth 6.5), which lands on a stored DEEP neighbour. Filling from the sim's per-rotation default
20
+ // (what the editor's coordOf now does, matching what the sim always did) keeps it legal.
21
+ describe('unset RECEIVE zone fallback (editor gap fill)', () => {
22
+ // The reported user's real rotation-2 RECEIVE: zone 3 sits abnormally deep at d 6.5; zone 6 is unset.
23
+ const partialRot2 = {
24
+ 1: { d: 6.4, r: 3.15 }, 2: { d: 2.75, r: 1.65 }, 3: { d: 6.5, r: -2.65 }, 4: { d: 2.75, r: -2.75 }, 5: { d: 2.8, r: -3.05 }
25
+ };
26
+ const FLAT_GRID_ZONE6 = { d: 6.5, r: 0 }; // the editor's OLD blank-slate default for a back-row zone
27
+ const SIM_DEFAULT_ZONE6 = defaultBaseConfig(RotationSystemEnum.FIVE_ONE).coords.RECEIVE['2']['6']; // the fix's fallback
28
+ const violationsWithZone6 = (zone6) => baseConfigViolations({ coords: { RECEIVE: { 2: { ...partialRot2, 6: zone6 } } } });
29
+ it('collides (zones 3,6) when the gap is filled from the flat grid - the bug', () => {
30
+ expect(violationsWithZone6(FLAT_GRID_ZONE6).map(v => v.zones)).toEqual([[3, 6]]);
31
+ });
32
+ it('stays legal when the gap is filled from the sim per-rotation default - the fix', () => {
33
+ expect(violationsWithZone6(SIM_DEFAULT_ZONE6)).toEqual([]);
34
+ });
35
+ });
5
36
  // The trajectory-set default desired-contact (owner D9, 2026-08-12): a near-net hitting point in each attacker's
6
37
  // lane, NOT the old set-to-the-standing-dot location. Front row contacts sit ~0.5 m off the net; back row on the
7
38
  // ~1.8 m pipe line; the lateral is the ATTACK-dot's r clamped inside the antenna (|r| <= 4.3).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "volleyballsimtypes",
3
- "version": "0.0.521",
3
+ "version": "0.0.522",
4
4
  "description": "vbsim types",
5
5
  "main": "./dist/cjs/src/index.js",
6
6
  "module": "./dist/esm/src/index.js",