volleyballsimtypes 0.0.504 → 0.0.506

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 (87) hide show
  1. package/dist/cjs/src/api/index.d.ts +3 -2
  2. package/dist/cjs/src/api/tactics-player-refs.js +2 -2
  3. package/dist/cjs/src/data/init-models.js +4 -0
  4. package/dist/cjs/src/data/models/base-position-preset.d.ts +22 -0
  5. package/dist/cjs/src/data/models/base-position-preset.js +54 -0
  6. package/dist/cjs/src/data/models/index.d.ts +1 -0
  7. package/dist/cjs/src/data/models/index.js +1 -0
  8. package/dist/cjs/src/data/models/tactics.d.ts +5 -3
  9. package/dist/cjs/src/data/models/tactics.js +4 -0
  10. package/dist/cjs/src/data/transformers/rally-event-injury.test.js +1 -1
  11. package/dist/cjs/src/data/transformers/rally-event-positional.test.js +10 -2
  12. package/dist/cjs/src/data/transformers/rally-event.js +3 -1
  13. package/dist/cjs/src/data/transformers/rally.test.js +2 -2
  14. package/dist/cjs/src/data/transformers/tactics.js +4 -0
  15. package/dist/cjs/src/data/transformers/tactics.test.js +34 -10
  16. package/dist/cjs/src/service/competition/competition-rewards.d.ts +20 -0
  17. package/dist/cjs/src/service/competition/competition-rewards.js +47 -0
  18. package/dist/cjs/src/service/competition/competition-rewards.test.d.ts +1 -0
  19. package/dist/cjs/src/service/competition/competition-rewards.test.js +32 -0
  20. package/dist/cjs/src/service/competition/index.d.ts +2 -0
  21. package/dist/cjs/src/service/competition/index.js +2 -0
  22. package/dist/cjs/src/service/competition/league-rewards.d.ts +21 -0
  23. package/dist/cjs/src/service/competition/league-rewards.js +58 -0
  24. package/dist/cjs/src/service/competition/league-rewards.test.d.ts +1 -0
  25. package/dist/cjs/src/service/competition/league-rewards.test.js +50 -0
  26. package/dist/cjs/src/service/event/schemas/spike.z.js +3 -1
  27. package/dist/cjs/src/service/event/spike.d.ts +2 -4
  28. package/dist/cjs/src/service/event/spike.js +5 -3
  29. package/dist/cjs/src/service/team/base-config.d.ts +10 -0
  30. package/dist/cjs/src/service/team/base-config.js +14 -0
  31. package/dist/cjs/src/service/team/blocking-preference.d.ts +3 -1
  32. package/dist/cjs/src/service/team/blocking-preference.js +2 -1
  33. package/dist/cjs/src/service/team/default-base-config.d.ts +4 -0
  34. package/dist/cjs/src/service/team/default-base-config.js +128 -0
  35. package/dist/cjs/src/service/team/index.d.ts +2 -0
  36. package/dist/cjs/src/service/team/index.js +2 -0
  37. package/dist/cjs/src/service/team/schemas/tactics.z.d.ts +17 -2
  38. package/dist/cjs/src/service/team/schemas/tactics.z.js +24 -4
  39. package/dist/cjs/src/service/team/schemas/tactics.z.test.js +29 -16
  40. package/dist/cjs/src/service/team/schemas/team.z.d.ts +17 -2
  41. package/dist/cjs/src/service/team/tactics.d.ts +3 -0
  42. package/dist/cjs/src/service/team/tactics.js +2 -1
  43. package/dist/cjs/src/service/team/team-generator.js +6 -1
  44. package/dist/esm/src/api/index.d.ts +3 -2
  45. package/dist/esm/src/api/tactics-player-refs.js +2 -2
  46. package/dist/esm/src/data/init-models.js +5 -1
  47. package/dist/esm/src/data/models/base-position-preset.d.ts +22 -0
  48. package/dist/esm/src/data/models/base-position-preset.js +50 -0
  49. package/dist/esm/src/data/models/index.d.ts +1 -0
  50. package/dist/esm/src/data/models/index.js +1 -0
  51. package/dist/esm/src/data/models/tactics.d.ts +5 -3
  52. package/dist/esm/src/data/models/tactics.js +4 -0
  53. package/dist/esm/src/data/transformers/rally-event-injury.test.js +1 -1
  54. package/dist/esm/src/data/transformers/rally-event-positional.test.js +10 -2
  55. package/dist/esm/src/data/transformers/rally-event.js +3 -1
  56. package/dist/esm/src/data/transformers/rally.test.js +2 -2
  57. package/dist/esm/src/data/transformers/tactics.js +4 -0
  58. package/dist/esm/src/data/transformers/tactics.test.js +34 -10
  59. package/dist/esm/src/service/competition/competition-rewards.d.ts +20 -0
  60. package/dist/esm/src/service/competition/competition-rewards.js +43 -0
  61. package/dist/esm/src/service/competition/competition-rewards.test.d.ts +1 -0
  62. package/dist/esm/src/service/competition/competition-rewards.test.js +30 -0
  63. package/dist/esm/src/service/competition/index.d.ts +2 -0
  64. package/dist/esm/src/service/competition/index.js +2 -0
  65. package/dist/esm/src/service/competition/league-rewards.d.ts +21 -0
  66. package/dist/esm/src/service/competition/league-rewards.js +53 -0
  67. package/dist/esm/src/service/competition/league-rewards.test.d.ts +1 -0
  68. package/dist/esm/src/service/competition/league-rewards.test.js +48 -0
  69. package/dist/esm/src/service/event/schemas/spike.z.js +3 -1
  70. package/dist/esm/src/service/event/spike.d.ts +2 -4
  71. package/dist/esm/src/service/event/spike.js +5 -3
  72. package/dist/esm/src/service/team/base-config.d.ts +10 -0
  73. package/dist/esm/src/service/team/base-config.js +11 -0
  74. package/dist/esm/src/service/team/blocking-preference.d.ts +3 -1
  75. package/dist/esm/src/service/team/blocking-preference.js +1 -0
  76. package/dist/esm/src/service/team/default-base-config.d.ts +4 -0
  77. package/dist/esm/src/service/team/default-base-config.js +124 -0
  78. package/dist/esm/src/service/team/index.d.ts +2 -0
  79. package/dist/esm/src/service/team/index.js +2 -0
  80. package/dist/esm/src/service/team/schemas/tactics.z.d.ts +17 -2
  81. package/dist/esm/src/service/team/schemas/tactics.z.js +24 -4
  82. package/dist/esm/src/service/team/schemas/tactics.z.test.js +29 -16
  83. package/dist/esm/src/service/team/schemas/team.z.d.ts +17 -2
  84. package/dist/esm/src/service/team/tactics.d.ts +3 -0
  85. package/dist/esm/src/service/team/tactics.js +2 -1
  86. package/dist/esm/src/service/team/team-generator.js +6 -1
  87. package/package.json +1 -1
@@ -0,0 +1,48 @@
1
+ import { describe, it, expect } from '@jest/globals';
2
+ import { leaguePayoutFor, leagueRewardTier, LEAGUE_REWARDS } from './league-rewards';
3
+ // The cumulative per-tier totals the owner specified (2026-08-07). Every entry is the coins a team finishing at
4
+ // that position earns, summing its band and every band below it. Positions past the lowest band earn 0.
5
+ describe('leaguePayoutFor', () => {
6
+ const TIER1 = {
7
+ 1: 3000, 2: 2500, 3: 2250, 4: 2000, 5: 1500, 6: 1500, 7: 1500, 8: 1500, 9: 1000, 10: 1000, 11: 1000, 12: 1000, 13: 0, 14: 0, 15: 0, 16: 0
8
+ };
9
+ const TIER2 = {
10
+ 1: 1500, 2: 1250, 3: 1000, 4: 1000, 5: 500, 6: 500, 7: 500, 8: 500, 9: 0, 12: 0, 16: 0
11
+ };
12
+ const TIER3 = {
13
+ 1: 1000, 2: 750, 3: 500, 4: 500, 5: 0, 8: 0, 16: 0
14
+ };
15
+ it('matches the owner cumulative totals for tier 1', () => {
16
+ for (const [pos, total] of Object.entries(TIER1)) {
17
+ expect(leaguePayoutFor(1, Number(pos))).toBe(total);
18
+ }
19
+ });
20
+ it('matches the owner cumulative totals for tier 2', () => {
21
+ for (const [pos, total] of Object.entries(TIER2)) {
22
+ expect(leaguePayoutFor(2, Number(pos))).toBe(total);
23
+ }
24
+ });
25
+ it('matches the owner cumulative totals for tier 3', () => {
26
+ for (const [pos, total] of Object.entries(TIER3)) {
27
+ expect(leaguePayoutFor(3, Number(pos))).toBe(total);
28
+ }
29
+ });
30
+ it('treats division 3 and lower with the tier-3 table', () => {
31
+ for (const tier of [3, 4, 5, 10]) {
32
+ expect(leagueRewardTier(tier)).toBe(3);
33
+ expect(leaguePayoutFor(tier, 1)).toBe(1000);
34
+ expect(leaguePayoutFor(tier, 4)).toBe(500);
35
+ expect(leaguePayoutFor(tier, 5)).toBe(0);
36
+ }
37
+ });
38
+ it('clamps tier 0 or negative to tier 1', () => {
39
+ expect(leagueRewardTier(0)).toBe(1);
40
+ expect(leaguePayoutFor(0, 1)).toBe(3000);
41
+ });
42
+ it('each tier band table sums to the champion total', () => {
43
+ const sum = (t) => LEAGUE_REWARDS[t].reduce((s, r) => s + r.coins, 0);
44
+ expect(sum(1)).toBe(3000);
45
+ expect(sum(2)).toBe(1500);
46
+ expect(sum(3)).toBe(1000);
47
+ });
48
+ });
@@ -2,7 +2,9 @@ import { z } from 'zod';
2
2
  import { TraitEnum } from '../../player';
3
3
  import { EventIncidentSchema } from './incident.z';
4
4
  import { positionalEventFields } from './positional.z';
5
- const spikeTypeValues = [0, 1, 2, 3, 4, 5, 6];
5
+ // SPIKE(0), TIP(3), DOWN_BALL(4), OVERHAND(5), TOOL(6). 1/2 (legacy LINE_SHOT/REVERSE_CROSS) are retired; the
6
+ // codec (transformToSpike) maps them to 0 before this validates, so they never reach here.
7
+ const spikeTypeValues = [0, 3, 4, 5, 6];
6
8
  const spikeFailureValues = [0, 1, 2, 3, 4];
7
9
  const courtTargetValues = Array.from({ length: 13 }, (_, i) => i);
8
10
  export const SpikeInputSchema = z.object({
@@ -1,14 +1,12 @@
1
1
  import { InPlayEvent } from './in-play-event';
2
2
  export declare enum SpikeTypeEnum {
3
- CROSS_SHOT = 0,
4
- LINE_SHOT = 1,
5
- REVERSE_CROSS = 2,
3
+ SPIKE = 0,
6
4
  TIP = 3,
7
5
  DOWN_BALL = 4,
8
6
  OVERHAND = 5,
9
7
  TOOL = 6
10
8
  }
11
- export type SpikeType = SpikeTypeEnum.CROSS_SHOT | SpikeTypeEnum.LINE_SHOT | SpikeTypeEnum.REVERSE_CROSS | SpikeTypeEnum.TIP | SpikeTypeEnum.DOWN_BALL | SpikeTypeEnum.OVERHAND | SpikeTypeEnum.TOOL;
9
+ export type SpikeType = SpikeTypeEnum.SPIKE | SpikeTypeEnum.TIP | SpikeTypeEnum.DOWN_BALL | SpikeTypeEnum.OVERHAND | SpikeTypeEnum.TOOL;
12
10
  export declare enum SpikeFailureEnum {
13
11
  NO_FAILURE = 0,
14
12
  FAULT = 1,
@@ -3,9 +3,11 @@ import { InPlayEvent } from './in-play-event';
3
3
  import { SpikeInputSchema } from './schemas/spike.z';
4
4
  export var SpikeTypeEnum;
5
5
  (function (SpikeTypeEnum) {
6
- SpikeTypeEnum[SpikeTypeEnum["CROSS_SHOT"] = 0] = "CROSS_SHOT";
7
- SpikeTypeEnum[SpikeTypeEnum["LINE_SHOT"] = 1] = "LINE_SHOT";
8
- SpikeTypeEnum[SpikeTypeEnum["REVERSE_CROSS"] = 2] = "REVERSE_CROSS";
6
+ // A hard swing (owner 2026-08-03). The cross / line / reverse DIRECTION is not stored: it is fully derivable
7
+ // from the persisted contact + landing coordinates, so a single SPIKE value replaces the old CROSS_SHOT(0) /
8
+ // LINE_SHOT(1) / REVERSE_CROSS(2). 1 and 2 are retired (left as gaps); the codec maps any legacy 1/2 back to
9
+ // SPIKE(0), so existing (zone-engine) matches keep decoding.
10
+ SpikeTypeEnum[SpikeTypeEnum["SPIKE"] = 0] = "SPIKE";
9
11
  SpikeTypeEnum[SpikeTypeEnum["TIP"] = 3] = "TIP";
10
12
  SpikeTypeEnum[SpikeTypeEnum["DOWN_BALL"] = 4] = "DOWN_BALL";
11
13
  SpikeTypeEnum[SpikeTypeEnum["OVERHAND"] = 5] = "OVERHAND";
@@ -0,0 +1,10 @@
1
+ export type BaseFormation = 'SERVE' | 'RECEIVE' | 'DEFENCE' | 'DEF_L' | 'DEF_M' | 'DEF_R' | 'ATTACK';
2
+ export declare const BASE_FORMATIONS: readonly BaseFormation[];
3
+ export interface BaseCoord {
4
+ readonly d: number;
5
+ readonly r: number;
6
+ }
7
+ export interface BaseConfig {
8
+ readonly coords: Record<string, Record<string, Record<string, BaseCoord>>>;
9
+ readonly receive: Record<string, Record<string, boolean>>;
10
+ }
@@ -0,0 +1,11 @@
1
+ // Per-team base-position configuration (owner 2026-08-03). Generalises the sim's hardcoded 5-1 preset into a
2
+ // system-agnostic, per-team shape: for each formation, rotation and rotational ZONE, where that zone's player
3
+ // stands, plus (serve-receive only) whether that zone's player passes the serve. Authored in the UI base editor
4
+ // (and populated from a picked default preset); consumed by the positional engine to place players. A team with no
5
+ // `basePositions` falls back to the default preset for its `rotationSystem`.
6
+ //
7
+ // KEYING: rotation 1..6 and zone 1..6 are the ROTATIONAL (at-the-whistle) slots. The stored rotation convention is
8
+ // the ENGINE's (SETTER_ZONE_TO_ROTATION); the UI editor re-keys the mapper's setter-zone rotation to it on save.
9
+ // `coords[formation][rotation][zone]` is the standing coordinate for that formation; `receive[rotation][zone]` is
10
+ // the reception-participation flag (absent zone => default to participating).
11
+ export const BASE_FORMATIONS = ['SERVE', 'RECEIVE', 'DEFENCE', 'DEF_L', 'DEF_M', 'DEF_R', 'ATTACK'];
@@ -1,7 +1,9 @@
1
1
  import { LineupFunction } from './lineup-function';
2
2
  export declare const NO_BLOCK: "NO_BLOCK";
3
3
  export type BlockingAssignment = LineupFunction | typeof NO_BLOCK;
4
+ export type DefenceFormation = 'DEF_L' | 'DEF_M' | 'DEF_R';
5
+ export declare const DEFENCE_FORMATIONS: readonly DefenceFormation[];
4
6
  export interface BlockingPreference {
5
7
  readonly rotation: number;
6
- readonly targets: Record<string, BlockingAssignment>;
8
+ readonly targets: Record<DefenceFormation, Record<string, BlockingAssignment>>;
7
9
  }
@@ -4,3 +4,4 @@
4
4
  // roll are all suppressed, as is a dedicated-blocking commit. Sits in the same `targets` map as a commit
5
5
  // assignment, so the two are mutually exclusive by construction: a player is reading, committed, or out.
6
6
  export const NO_BLOCK = 'NO_BLOCK';
7
+ export const DEFENCE_FORMATIONS = ['DEF_L', 'DEF_M', 'DEF_R'];
@@ -0,0 +1,4 @@
1
+ import { BaseConfig } from './base-config';
2
+ import { RotationSystemEnum } from './rotation-system';
3
+ export declare function defaultReceiveMap(system: RotationSystemEnum): BaseConfig['receive'];
4
+ export declare function defaultBaseConfig(system: RotationSystemEnum): BaseConfig;
@@ -0,0 +1,124 @@
1
+ import { slotFunctionAt } from './lineup-function';
2
+ import { RoleEnum } from '../player';
3
+ import { isBackRow } from '../match';
4
+ const COORDS = {
5
+ SERVE: {
6
+ 1: { 1: { d: 9.90, r: 3.15 }, 2: { d: 0.30, r: -2.55 }, 3: { d: 0.40, r: 0.00 }, 4: { d: 0.30, r: 2.55 }, 5: { d: 6.90, r: -0.15 }, 6: { d: 5.20, r: -2.35 } },
7
+ 2: { 1: { d: 10.10, r: 3.00 }, 2: { d: 0.45, r: 0.00 }, 3: { d: 0.55, r: 2.95 }, 4: { d: 0.45, r: -2.95 }, 5: { d: 5.20, r: -2.95 }, 6: { d: 5.25, r: 3.35 } },
8
+ 3: { 1: { d: 10.45, r: -3.40 }, 2: { d: 0.35, r: 2.70 }, 3: { d: 0.25, r: -3.00 }, 4: { d: 0.30, r: 0.00 }, 5: { d: 5.35, r: 2.55 }, 6: { d: 6.90, r: -0.05 } },
9
+ 4: { 1: { d: 10.50, r: 3.40 }, 2: { d: 0.35, r: -3.05 }, 3: { d: 0.45, r: 0.00 }, 4: { d: 0.45, r: 2.55 }, 5: { d: 7.25, r: 0.15 }, 6: { d: 6.25, r: -2.55 } },
10
+ 5: { 1: { d: 10.05, r: 3.50 }, 2: { d: 0.30, r: 0.10 }, 3: { d: 0.25, r: 2.85 }, 4: { d: 0.25, r: -2.80 }, 5: { d: 5.10, r: -2.65 }, 6: { d: 5.15, r: 2.70 } },
11
+ 6: { 1: { d: 10.30, r: -3.50 }, 2: { d: 0.65, r: 2.60 }, 3: { d: 0.50, r: -2.55 }, 4: { d: 0.55, r: 0.05 }, 5: { d: 6.45, r: 2.65 }, 6: { d: 6.90, r: 0.10 } }
12
+ },
13
+ RECEIVE: {
14
+ 1: { 1: { d: 6.75, r: 2.85 }, 2: { d: 6.25, r: 2.05 }, 3: { d: 3.10, r: -3.65 }, 4: { d: 3.80, r: -4.10 }, 5: { d: 6.40, r: -2.80 }, 6: { d: 6.50, r: 0.00 } },
15
+ 2: { 1: { d: 6.00, r: 2.50 }, 2: { d: 3.15, r: 4.00 }, 3: { d: 0.50, r: 0.40 }, 4: { d: 5.85, r: -2.85 }, 5: { d: 6.20, r: -0.05 }, 6: { d: 1.50, r: 0.65 } },
16
+ 3: { 1: { d: 6.10, r: 2.55 }, 2: { d: 3.15, r: 4.10 }, 3: { d: 6.50, r: -2.80 }, 4: { d: 0.80, r: -3.70 }, 5: { d: 1.10, r: -1.00 }, 6: { d: 6.65, r: -0.10 } },
17
+ 4: { 1: { d: 8.45, r: 3.20 }, 2: { d: 6.25, r: -2.85 }, 3: { d: 1.35, r: -3.70 }, 4: { d: 0.50, r: -4.05 }, 5: { d: 6.75, r: -0.35 }, 6: { d: 6.55, r: 2.40 } },
18
+ 5: { 1: { d: 5.90, r: 2.65 }, 2: { d: 2.60, r: 3.90 }, 3: { d: 0.65, r: 1.50 }, 4: { d: 6.05, r: -3.05 }, 5: { d: 6.10, r: 0.10 }, 6: { d: 8.45, r: 0.65 } },
19
+ 6: { 1: { d: 6.25, r: 2.60 }, 2: { d: 0.80, r: 1.50 }, 3: { d: 6.40, r: -2.60 }, 4: { d: 3.30, r: -4.30 }, 5: { d: 8.65, r: -2.15 }, 6: { d: 6.70, r: 0.00 } }
20
+ },
21
+ DEFENCE: {
22
+ 1: { 1: { d: 5.95, r: 2.65 }, 2: { d: 0.55, r: -2.95 }, 3: { d: 0.50, r: -0.20 }, 4: { d: 0.45, r: 2.70 }, 5: { d: 6.30, r: -0.30 }, 6: { d: 5.90, r: -3.15 } },
23
+ 2: { 1: { d: 6.30, r: -0.40 }, 2: { d: 0.05, r: -0.10 }, 3: { d: 0.05, r: 2.70 }, 4: { d: 0.05, r: -2.85 }, 5: { d: 5.50, r: -3.00 }, 6: { d: 5.55, r: 2.80 } },
24
+ 3: { 1: { d: 5.25, r: -2.65 }, 2: { d: 0.05, r: 3.05 }, 3: { d: 0.05, r: -2.80 }, 4: { d: 0.05, r: 0.30 }, 5: { d: 5.35, r: 2.95 }, 6: { d: 6.45, r: 0.20 } },
25
+ 4: { 1: { d: 6.00, r: 2.90 }, 2: { d: 0.35, r: -3.10 }, 3: { d: 0.25, r: -0.05 }, 4: { d: 0.35, r: 3.10 }, 5: { d: 6.35, r: -0.10 }, 6: { d: 6.00, r: -2.60 } },
26
+ 5: { 1: { d: 6.30, r: 0.00 }, 2: { d: 0.10, r: -0.05 }, 3: { d: 0.05, r: 3.00 }, 4: { d: 0.05, r: -3.05 }, 5: { d: 5.90, r: -2.90 }, 6: { d: 6.15, r: 3.25 } },
27
+ 6: { 1: { d: 5.70, r: -2.85 }, 2: { d: 0.55, r: 2.50 }, 3: { d: 0.50, r: -2.95 }, 4: { d: 0.50, r: -0.25 }, 5: { d: 5.80, r: 2.90 }, 6: { d: 6.95, r: 0.00 } }
28
+ },
29
+ DEF_L: {
30
+ 1: { 1: { d: 5.80, r: 1.50 }, 2: { d: 0.05, r: -3.40 }, 3: { d: 0.05, r: -2.85 }, 4: { d: 1.80, r: 1.30 }, 5: { d: 8.00, r: 0.65 }, 6: { d: 4.50, r: -4.20 } },
31
+ 2: { 1: { d: 7.95, r: 0.70 }, 2: { d: 0.05, r: -2.85 }, 3: { d: 1.80, r: 1.30 }, 4: { d: 0.10, r: -3.40 }, 5: { d: 4.50, r: -4.20 }, 6: { d: 5.80, r: 1.50 } },
32
+ 3: { 1: { d: 0.05, r: -2.85 }, 2: { d: 1.80, r: 1.30 }, 3: { d: 0.05, r: -3.45 }, 4: { d: 4.50, r: -4.20 }, 5: { d: 5.80, r: 1.50 }, 6: { d: 8.05, r: 0.70 } },
33
+ 4: { 1: { d: 5.85, r: 1.50 }, 2: { d: 0.05, r: -3.45 }, 3: { d: 4.50, r: -4.20 }, 4: { d: 1.80, r: 1.30 }, 5: { d: 8.00, r: 0.95 }, 6: { d: 0.05, r: -2.85 } },
34
+ 5: { 1: { d: 8.10, r: 0.80 }, 2: { d: 4.50, r: -4.20 }, 3: { d: 1.80, r: 1.30 }, 4: { d: 0.05, r: -3.45 }, 5: { d: 0.05, r: -2.85 }, 6: { d: 5.85, r: 1.50 } },
35
+ 6: { 1: { d: 4.50, r: -4.20 }, 2: { d: 1.80, r: 1.30 }, 3: { d: 0.05, r: -3.45 }, 4: { d: 0.05, r: -2.85 }, 5: { d: 5.85, r: 1.50 }, 6: { d: 8.10, r: 0.80 } }
36
+ },
37
+ DEF_M: {
38
+ 1: { 1: { d: 4.95, r: 2.35 }, 2: { d: 1.70, r: -2.60 }, 3: { d: 0.05, r: -0.15 }, 4: { d: 2.00, r: 2.80 }, 5: { d: 7.20, r: 1.00 }, 6: { d: 4.70, r: -2.35 } },
39
+ 2: { 1: { d: 7.15, r: 1.00 }, 2: { d: 0.05, r: -0.15 }, 3: { d: 2.00, r: 2.80 }, 4: { d: 1.70, r: -2.60 }, 5: { d: 4.70, r: -2.35 }, 6: { d: 4.95, r: 2.35 } },
40
+ 3: { 1: { d: 4.95, r: -2.35 }, 2: { d: 2.00, r: 2.80 }, 3: { d: 2.35, r: -2.65 }, 4: { d: 0.05, r: 0.00 }, 5: { d: 4.95, r: 2.35 }, 6: { d: 7.00, r: 1.20 } },
41
+ 4: { 1: { d: 4.95, r: 2.15 }, 2: { d: 1.70, r: -2.60 }, 3: { d: 0.15, r: -0.10 }, 4: { d: 2.00, r: 2.85 }, 5: { d: 7.15, r: 1.00 }, 6: { d: 4.95, r: -2.25 } },
42
+ 5: { 1: { d: 7.25, r: 1.10 }, 2: { d: 0.05, r: 0.00 }, 3: { d: 1.95, r: 2.60 }, 4: { d: 1.70, r: -2.50 }, 5: { d: 4.85, r: -2.05 }, 6: { d: 5.00, r: 2.15 } },
43
+ 6: { 1: { d: 4.70, r: -2.35 }, 2: { d: 2.00, r: 2.75 }, 3: { d: 1.80, r: -2.65 }, 4: { d: 0.05, r: -0.15 }, 5: { d: 4.80, r: 2.40 }, 6: { d: 7.40, r: 1.00 } }
44
+ },
45
+ DEF_R: {
46
+ 1: { 1: { d: 4.45, r: 4.20 }, 2: { d: 1.90, r: -1.50 }, 3: { d: 0.10, r: 2.90 }, 4: { d: 0.10, r: 3.45 }, 5: { d: 8.80, r: 0.45 }, 6: { d: 5.25, r: -1.45 } },
47
+ 2: { 1: { d: 8.55, r: -0.65 }, 2: { d: 0.05, r: 2.90 }, 3: { d: 0.05, r: 3.50 }, 4: { d: 2.00, r: -2.20 }, 5: { d: 4.80, r: -1.60 }, 6: { d: 4.50, r: 4.15 } },
48
+ 3: { 1: { d: 4.75, r: -1.70 }, 2: { d: 0.05, r: 3.45 }, 3: { d: 1.60, r: -1.75 }, 4: { d: 0.05, r: 2.85 }, 5: { d: 4.55, r: 4.15 }, 6: { d: 8.40, r: -0.35 } },
49
+ 4: { 1: { d: 5.20, r: 4.15 }, 2: { d: 1.75, r: -1.85 }, 3: { d: 0.05, r: 3.20 }, 4: { d: 0.05, r: 3.75 }, 5: { d: 8.45, r: -0.65 }, 6: { d: 5.15, r: -1.85 } },
50
+ 5: { 1: { d: 8.35, r: -0.75 }, 2: { d: 0.05, r: 2.90 }, 3: { d: 0.05, r: 3.50 }, 4: { d: 1.80, r: -1.25 }, 5: { d: 5.25, r: -1.70 }, 6: { d: 5.00, r: 4.15 } },
51
+ 6: { 1: { d: 5.15, r: -1.50 }, 2: { d: 0.05, r: 3.45 }, 3: { d: 2.55, r: -2.40 }, 4: { d: 0.05, r: 2.90 }, 5: { d: 4.60, r: 4.10 }, 6: { d: 8.75, r: 0.25 } }
52
+ },
53
+ ATTACK: {
54
+ 1: { 1: { d: 0.50, r: 1.55 }, 2: { d: 3.40, r: -5.15 }, 3: { d: 1.15, r: 0.40 }, 4: { d: 4.80, r: 5.20 }, 5: { d: 6.50, r: 0.20 }, 6: { d: 4.85, r: -2.90 } },
55
+ 2: { 1: { d: 6.50, r: -0.40 }, 2: { d: 1.60, r: 0.75 }, 3: { d: 3.65, r: 5.05 }, 4: { d: 3.50, r: -5.05 }, 5: { d: 4.45, r: -2.60 }, 6: { d: 0.50, r: 1.60 } },
56
+ 3: { 1: { d: 5.05, r: -1.90 }, 2: { d: 3.95, r: 5.00 }, 3: { d: 3.95, r: -4.90 }, 4: { d: 1.80, r: 1.10 }, 5: { d: 0.35, r: 1.55 }, 6: { d: 6.45, r: 0.15 } },
57
+ 4: { 1: { d: 6.15, r: 4.95 }, 2: { d: 3.95, r: -5.05 }, 3: { d: 1.45, r: 1.05 }, 4: { d: 0.45, r: 1.80 }, 5: { d: 6.45, r: -0.50 }, 6: { d: 4.95, r: -2.00 } },
58
+ 5: { 1: { d: 6.50, r: -0.40 }, 2: { d: 1.80, r: 1.10 }, 3: { d: 0.25, r: 1.85 }, 4: { d: 3.45, r: -4.95 }, 5: { d: 5.35, r: -2.55 }, 6: { d: 6.50, r: 4.90 } },
59
+ 6: { 1: { d: 5.85, r: -2.20 }, 2: { d: 0.55, r: 2.00 }, 3: { d: 4.00, r: -4.85 }, 4: { d: 1.35, r: 1.40 }, 5: { d: 6.85, r: 4.90 }, 6: { d: 6.60, r: 0.35 } }
60
+ },
61
+ ATK_L: {
62
+ 1: { 1: { d: 0.75, r: 0.10 }, 2: { d: 0.50, r: -4.05 }, 3: { d: 0.95, r: -2.55 }, 4: { d: 3.45, r: 0.80 }, 5: { d: 6.25, r: -0.75 }, 6: { d: 3.60, r: -3.95 } },
63
+ 2: { 1: { d: 0.50, r: -4.05 }, 2: { d: 0.95, r: -2.55 }, 3: { d: 3.75, r: 1.05 }, 4: { d: 6.25, r: -0.75 }, 5: { d: 3.60, r: -3.95 }, 6: { d: 0.75, r: 0.10 } },
64
+ 3: { 1: { d: 0.95, r: -2.55 }, 2: { d: 3.45, r: 0.80 }, 3: { d: 6.25, r: -0.75 }, 4: { d: 3.60, r: -3.95 }, 5: { d: 0.75, r: 0.10 }, 6: { d: 0.50, r: -4.05 } },
65
+ 4: { 1: { d: 3.60, r: 0.90 }, 2: { d: 0.55, r: -4.05 }, 3: { d: 1.15, r: -2.60 }, 4: { d: 0.85, r: 0.25 }, 5: { d: 6.25, r: -0.80 }, 6: { d: 3.45, r: -4.10 } },
66
+ 5: { 1: { d: 6.25, r: -0.65 }, 2: { d: 1.20, r: -2.55 }, 3: { d: 0.80, r: 0.10 }, 4: { d: 0.50, r: -4.10 }, 5: { d: 3.90, r: -4.00 }, 6: { d: 3.75, r: 0.80 } },
67
+ 6: { 1: { d: 3.60, r: -3.95 }, 2: { d: 0.75, r: 0.10 }, 3: { d: 0.50, r: -4.05 }, 4: { d: 0.95, r: -2.55 }, 5: { d: 3.45, r: 0.80 }, 6: { d: 6.25, r: -0.75 } }
68
+ },
69
+ ATK_M: {
70
+ 1: { 1: { d: 0.60, r: 1.25 }, 2: { d: 1.20, r: -2.00 }, 3: { d: 0.70, r: 0.50 }, 4: { d: 2.25, r: 2.60 }, 5: { d: 6.40, r: 1.40 }, 6: { d: 4.05, r: -1.55 } },
71
+ 2: { 1: { d: 6.65, r: 1.55 }, 2: { d: 0.70, r: 0.50 }, 3: { d: 2.25, r: 2.60 }, 4: { d: 1.40, r: -1.90 }, 5: { d: 4.05, r: -1.55 }, 6: { d: 0.60, r: 1.25 } },
72
+ 3: { 1: { d: 4.05, r: -1.55 }, 2: { d: 2.25, r: 2.60 }, 3: { d: 1.20, r: -2.00 }, 4: { d: 0.70, r: 0.45 }, 5: { d: 0.65, r: 1.15 }, 6: { d: 6.40, r: 1.40 } },
73
+ 4: { 1: { d: 2.25, r: 2.60 }, 2: { d: 1.35, r: -1.90 }, 3: { d: 0.65, r: 0.50 }, 4: { d: 0.60, r: 1.25 }, 5: { d: 6.40, r: 1.45 }, 6: { d: 4.15, r: -1.45 } },
74
+ 5: { 1: { d: 6.40, r: 1.40 }, 2: { d: 0.65, r: 0.50 }, 3: { d: 0.60, r: 1.25 }, 4: { d: 1.20, r: -2.00 }, 5: { d: 4.15, r: -1.40 }, 6: { d: 2.25, r: 2.60 } },
75
+ 6: { 1: { d: 4.05, r: -1.55 }, 2: { d: 0.60, r: 1.25 }, 3: { d: 1.20, r: -2.00 }, 4: { d: 0.70, r: 0.50 }, 5: { d: 2.25, r: 2.60 }, 6: { d: 6.40, r: 1.40 } }
76
+ },
77
+ ATK_R: {
78
+ 1: { 1: { d: 1.75, r: 2.60 }, 2: { d: 1.60, r: -1.75 }, 3: { d: 0.75, r: 1.80 }, 4: { d: 0.60, r: 3.90 }, 5: { d: 4.90, r: 3.25 }, 6: { d: 5.85, r: -1.90 } },
79
+ 2: { 1: { d: 5.15, r: 3.20 }, 2: { d: 0.75, r: 1.80 }, 3: { d: 0.60, r: 3.90 }, 4: { d: 1.60, r: -1.40 }, 5: { d: 5.85, r: -1.90 }, 6: { d: 1.75, r: 2.60 } },
80
+ 3: { 1: { d: 6.05, r: -2.00 }, 2: { d: 0.60, r: 3.90 }, 3: { d: 1.55, r: -1.55 }, 4: { d: 0.75, r: 1.70 }, 5: { d: 1.75, r: 2.60 }, 6: { d: 5.05, r: 3.15 } },
81
+ 4: { 1: { d: 2.25, r: 4.20 }, 2: { d: 2.40, r: -1.65 }, 3: { d: 0.85, r: 1.40 }, 4: { d: 1.40, r: 2.30 }, 5: { d: 6.00, r: 1.85 }, 6: { d: 5.25, r: -0.90 } },
82
+ 5: { 1: { d: 5.95, r: 2.35 }, 2: { d: 0.85, r: 1.40 }, 3: { d: 1.40, r: 2.30 }, 4: { d: 2.20, r: -1.80 }, 5: { d: 5.25, r: -0.90 }, 6: { d: 2.25, r: 4.20 } },
83
+ 6: { 1: { d: 5.30, r: -0.90 }, 2: { d: 1.45, r: 2.30 }, 3: { d: 2.25, r: -1.80 }, 4: { d: 0.75, r: 1.40 }, 5: { d: 2.25, r: 4.05 }, 6: { d: 5.90, r: 2.35 } }
84
+ }
85
+ };
86
+ // The default coordinate table as a BaseConfig.coords (jsonb string keys). Shared, readonly; materialized (and so
87
+ // serialized) per team on save, so sharing the reference in memory is safe.
88
+ const DEFAULT_COORDS = (() => {
89
+ const out = {};
90
+ for (const [formation, rotations] of Object.entries(COORDS)) {
91
+ out[formation] = {};
92
+ for (const [rotation, zones] of Object.entries(rotations)) {
93
+ out[formation][rotation] = {};
94
+ for (const [zone, coord] of Object.entries(zones))
95
+ out[formation][rotation][zone] = { d: coord.d, r: coord.r };
96
+ }
97
+ }
98
+ return out;
99
+ })();
100
+ // Rotation index -> the setter's rotational zone (inverse of SETTER_ZONE_TO_ROTATION {1:1,6:2,5:3,4:4,3:5,2:6}).
101
+ const ROTATION_TO_SETTER_ZONE = { 1: 1, 2: 6, 3: 5, 4: 4, 5: 3, 6: 2 };
102
+ // The default serve-receive participation map: the standard 3-passer formation. A rotational slot passes iff its
103
+ // slot function is an OUTSIDE_HITTER (either row) or a BACK-ROW MIDDLE_BLOCKER (the libero's slot). The setter, the
104
+ // opposite and the front-row middle hide. slotFunctionAt maps the opposite slot to SETTER in 4-2/6-2, so the
105
+ // second setter is excluded too, giving the same 3-passer map for every system.
106
+ export function defaultReceiveMap(system) {
107
+ const receive = {};
108
+ for (let rotation = 1; rotation <= 6; rotation++) {
109
+ const setterZone = ROTATION_TO_SETTER_ZONE[rotation];
110
+ const perZone = {};
111
+ for (let zone = 1; zone <= 6; zone++) {
112
+ const fn = slotFunctionAt(system, setterZone, zone);
113
+ perZone[String(zone)] = fn === RoleEnum.OUTSIDE_HITTER || (fn === RoleEnum.MIDDLE_BLOCKER && isBackRow(zone));
114
+ }
115
+ receive[String(rotation)] = perZone;
116
+ }
117
+ return receive;
118
+ }
119
+ // The default base config a team of the given system starts with. Every team always has one (materialized on team
120
+ // creation and by the backfill migration); the coordinates are the same 5-1 table for every system, only the
121
+ // receive map is derived per system.
122
+ export function defaultBaseConfig(system) {
123
+ return { coords: DEFAULT_COORDS, receive: defaultReceiveMap(system) };
124
+ }
@@ -2,6 +2,8 @@ export * from './team';
2
2
  export * from './team-generator';
3
3
  export * from './formation';
4
4
  export * from './tactics';
5
+ export * from './base-config';
6
+ export * from './default-base-config';
5
7
  export * from './team-name';
6
8
  export * from './energy-band';
7
9
  export * from './pinch-condition';
@@ -2,6 +2,8 @@ export * from './team';
2
2
  export * from './team-generator';
3
3
  export * from './formation';
4
4
  export * from './tactics';
5
+ export * from './base-config';
6
+ export * from './default-base-config';
5
7
  export * from './team-name';
6
8
  export * from './energy-band';
7
9
  export * from './pinch-condition';
@@ -282,12 +282,27 @@ export declare const TacticsInputSchema: z.ZodObject<{
282
282
  }, z.core.$strip>>>>;
283
283
  blockingPreferences: z.ZodDefault<z.ZodArray<z.ZodObject<{
284
284
  rotation: z.ZodNumber;
285
- targets: z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodEnum<typeof RoleEnum> & z.ZodType<RoleEnum.SETTER | RoleEnum.OUTSIDE_HITTER | RoleEnum.OPPOSITE_HITTER | RoleEnum.MIDDLE_BLOCKER, RoleEnum, z.core.$ZodTypeInternals<RoleEnum.SETTER | RoleEnum.OUTSIDE_HITTER | RoleEnum.OPPOSITE_HITTER | RoleEnum.MIDDLE_BLOCKER, RoleEnum>>, z.ZodLiteral<"NO_BLOCK">]>>;
285
+ targets: z.ZodObject<{
286
+ DEF_L: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodEnum<typeof RoleEnum> & z.ZodType<RoleEnum.SETTER | RoleEnum.OUTSIDE_HITTER | RoleEnum.OPPOSITE_HITTER | RoleEnum.MIDDLE_BLOCKER, RoleEnum, z.core.$ZodTypeInternals<RoleEnum.SETTER | RoleEnum.OUTSIDE_HITTER | RoleEnum.OPPOSITE_HITTER | RoleEnum.MIDDLE_BLOCKER, RoleEnum>>, z.ZodLiteral<"NO_BLOCK">]>>>;
287
+ DEF_M: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodEnum<typeof RoleEnum> & z.ZodType<RoleEnum.SETTER | RoleEnum.OUTSIDE_HITTER | RoleEnum.OPPOSITE_HITTER | RoleEnum.MIDDLE_BLOCKER, RoleEnum, z.core.$ZodTypeInternals<RoleEnum.SETTER | RoleEnum.OUTSIDE_HITTER | RoleEnum.OPPOSITE_HITTER | RoleEnum.MIDDLE_BLOCKER, RoleEnum>>, z.ZodLiteral<"NO_BLOCK">]>>>;
288
+ DEF_R: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodEnum<typeof RoleEnum> & z.ZodType<RoleEnum.SETTER | RoleEnum.OUTSIDE_HITTER | RoleEnum.OPPOSITE_HITTER | RoleEnum.MIDDLE_BLOCKER, RoleEnum, z.core.$ZodTypeInternals<RoleEnum.SETTER | RoleEnum.OUTSIDE_HITTER | RoleEnum.OPPOSITE_HITTER | RoleEnum.MIDDLE_BLOCKER, RoleEnum>>, z.ZodLiteral<"NO_BLOCK">]>>>;
289
+ }, z.core.$strip>;
286
290
  }, z.core.$strip>>>;
287
291
  blockingPreferenceSets: z.ZodOptional<z.ZodArray<z.ZodArray<z.ZodObject<{
288
292
  rotation: z.ZodNumber;
289
- targets: z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodEnum<typeof RoleEnum> & z.ZodType<RoleEnum.SETTER | RoleEnum.OUTSIDE_HITTER | RoleEnum.OPPOSITE_HITTER | RoleEnum.MIDDLE_BLOCKER, RoleEnum, z.core.$ZodTypeInternals<RoleEnum.SETTER | RoleEnum.OUTSIDE_HITTER | RoleEnum.OPPOSITE_HITTER | RoleEnum.MIDDLE_BLOCKER, RoleEnum>>, z.ZodLiteral<"NO_BLOCK">]>>;
293
+ targets: z.ZodObject<{
294
+ DEF_L: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodEnum<typeof RoleEnum> & z.ZodType<RoleEnum.SETTER | RoleEnum.OUTSIDE_HITTER | RoleEnum.OPPOSITE_HITTER | RoleEnum.MIDDLE_BLOCKER, RoleEnum, z.core.$ZodTypeInternals<RoleEnum.SETTER | RoleEnum.OUTSIDE_HITTER | RoleEnum.OPPOSITE_HITTER | RoleEnum.MIDDLE_BLOCKER, RoleEnum>>, z.ZodLiteral<"NO_BLOCK">]>>>;
295
+ DEF_M: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodEnum<typeof RoleEnum> & z.ZodType<RoleEnum.SETTER | RoleEnum.OUTSIDE_HITTER | RoleEnum.OPPOSITE_HITTER | RoleEnum.MIDDLE_BLOCKER, RoleEnum, z.core.$ZodTypeInternals<RoleEnum.SETTER | RoleEnum.OUTSIDE_HITTER | RoleEnum.OPPOSITE_HITTER | RoleEnum.MIDDLE_BLOCKER, RoleEnum>>, z.ZodLiteral<"NO_BLOCK">]>>>;
296
+ DEF_R: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodEnum<typeof RoleEnum> & z.ZodType<RoleEnum.SETTER | RoleEnum.OUTSIDE_HITTER | RoleEnum.OPPOSITE_HITTER | RoleEnum.MIDDLE_BLOCKER, RoleEnum, z.core.$ZodTypeInternals<RoleEnum.SETTER | RoleEnum.OUTSIDE_HITTER | RoleEnum.OPPOSITE_HITTER | RoleEnum.MIDDLE_BLOCKER, RoleEnum>>, z.ZodLiteral<"NO_BLOCK">]>>>;
297
+ }, z.core.$strip>;
290
298
  }, z.core.$strip>>>>;
299
+ basePositions: z.ZodOptional<z.ZodObject<{
300
+ coords: z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodObject<{
301
+ d: z.ZodNumber;
302
+ r: z.ZodNumber;
303
+ }, z.core.$strip>>>>;
304
+ receive: z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodBoolean>>;
305
+ }, z.core.$strip>>;
291
306
  replaceKnockedImmediately: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
292
307
  injuryReplacements: z.ZodDefault<z.ZodArray<z.ZodObject<{
293
308
  starter: z.ZodCustom<Player, Player>;
@@ -65,15 +65,31 @@ const blockingFunctionSchema = z.union([
65
65
  ]);
66
66
  // One rotation's dedicated-blocking assignments: `targets` maps my front-row starter ids to the opposing function
67
67
  // each commits to block. Cross-field checks (keys are starters) live in the superRefine below.
68
+ const blockingTargetsSchema = z.record(z.string(), blockingFunctionSchema);
69
+ // Per point-of-attack defence (owner 2026-08-06): a separate front-row commit map for an attack from my left /
70
+ // middle / right. Each defaults to {} so a partial config stays valid.
68
71
  const blockingPreferenceSchema = z.object({
69
72
  rotation: z.number().int().min(1).max(6),
70
- targets: z.record(z.string(), blockingFunctionSchema)
73
+ targets: z.object({
74
+ DEF_L: blockingTargetsSchema.default({}),
75
+ DEF_M: blockingTargetsSchema.default({}),
76
+ DEF_R: blockingTargetsSchema.default({})
77
+ })
71
78
  });
72
79
  // One set's rotation-system override (per-set tactics): system + its designated setters travel together.
73
80
  const systemSetSchema = z.object({
74
81
  rotationSystem: z.nativeEnum(RotationSystemEnum),
75
82
  designatedSetters: z.array(playerInstanceSchema)
76
83
  });
84
+ // Base positions (owner 2026-08-03): coords[formation][rotation][zone] -> {d,r} + receive[rotation][zone] -> bool.
85
+ // jsonb keys are strings; the coordinate + boolean leaves are validated. Structural key ranges (formation names,
86
+ // rotations 1-6, zones 1-6) are trusted from the UI editor / preset compiler, which write the engine rotation
87
+ // convention. Out-of-court coordinates are allowed, so d/r are not range-clamped here.
88
+ const baseCoordSchema = z.object({ d: z.number().finite(), r: z.number().finite() });
89
+ const baseConfigSchema = z.object({
90
+ coords: z.record(z.string(), z.record(z.string(), z.record(z.string(), baseCoordSchema))),
91
+ receive: z.record(z.string(), z.record(z.string(), z.boolean()))
92
+ });
77
93
  export const TacticsInputSchema = z.object({
78
94
  lineup: lineupSchema,
79
95
  liberoReplacements: z.array(playerInstanceSchema),
@@ -104,6 +120,8 @@ export const TacticsInputSchema = z.object({
104
120
  // Blocking preferences (dedicated blocking). Defaults keep every existing Tactics.create({...}) call valid.
105
121
  blockingPreferences: z.array(blockingPreferenceSchema).default([]),
106
122
  blockingPreferenceSets: z.array(z.array(blockingPreferenceSchema)).max(5).optional(),
123
+ // Base positions (owner 2026-08-03). Optional -> the sim falls back to the default preset for the system.
124
+ basePositions: baseConfigSchema.optional(),
107
125
  // Injuries. Defaults keep every existing Tactics.create({...}) call valid. replaceKnockedImmediately is
108
126
  // PER STARTER (owner order 2026-07-05): keyed by starter id, missing key = ON; keys validated against the
109
127
  // lineup in the superRefine below. injuryReplacements is the per-starter explicit replacement mapping
@@ -199,9 +217,11 @@ export const TacticsInputSchema = z.object({
199
217
  ctx.addIssue({ code: 'custom', message: 'BLOCKING_PREFERENCE_DUPLICATE_ROTATION', path });
200
218
  }
201
219
  seenRotations.add(pref.rotation);
202
- for (const id of Object.keys(pref.targets)) {
203
- if (!starterIds.has(id)) {
204
- ctx.addIssue({ code: 'custom', message: 'BLOCKING_PREFERENCE_BLOCKER_NOT_A_STARTER', path });
220
+ for (const form of ['DEF_L', 'DEF_M', 'DEF_R']) {
221
+ for (const id of Object.keys(pref.targets[form] ?? {})) {
222
+ if (!starterIds.has(id)) {
223
+ ctx.addIssue({ code: 'custom', message: 'BLOCKING_PREFERENCE_BLOCKER_NOT_A_STARTER', path });
224
+ }
205
225
  }
206
226
  }
207
227
  }
@@ -234,18 +234,31 @@ describe('TacticsInputSchema — blocking preferences', () => {
234
234
  ...overrides
235
235
  };
236
236
  }
237
- it('accepts a blocking preference that keys starters to attacking functions', () => {
237
+ it('accepts a blocking preference that keys starters to attacking functions, per defence side', () => {
238
238
  const res = TacticsInputSchema.safeParse(baseInput({
239
- blockingPreferences: [{ rotation: 1, targets: { [p4.id]: RoleEnum.OUTSIDE_HITTER, [p3.id]: RoleEnum.MIDDLE_BLOCKER } }]
239
+ blockingPreferences: [{ rotation: 1, targets: { DEF_L: { [p4.id]: RoleEnum.OUTSIDE_HITTER }, DEF_M: {}, DEF_R: { [p3.id]: RoleEnum.MIDDLE_BLOCKER } } }]
240
240
  }));
241
241
  expect(res.success).toBe(true);
242
242
  if (res.success) {
243
- expect(res.data.blockingPreferences[0].targets[p4.id]).toBe(RoleEnum.OUTSIDE_HITTER);
243
+ expect(res.data.blockingPreferences[0].targets.DEF_L[p4.id]).toBe(RoleEnum.OUTSIDE_HITTER);
244
+ expect(res.data.blockingPreferences[0].targets.DEF_R[p3.id]).toBe(RoleEnum.MIDDLE_BLOCKER);
245
+ expect(res.data.blockingPreferences[0].targets.DEF_M).toEqual({}); // the middle side is independent (empty here)
244
246
  }
245
247
  });
246
- it('rejects a blocking target that is not a starter', () => {
248
+ it('defaults the omitted defence sides to empty maps', () => {
247
249
  const res = TacticsInputSchema.safeParse(baseInput({
248
- blockingPreferences: [{ rotation: 1, targets: { [reserve.id]: RoleEnum.OUTSIDE_HITTER } }]
250
+ blockingPreferences: [{ rotation: 1, targets: { DEF_L: { [p4.id]: NO_BLOCK } } }]
251
+ }));
252
+ expect(res.success).toBe(true);
253
+ if (res.success) {
254
+ expect(res.data.blockingPreferences[0].targets.DEF_L[p4.id]).toBe(NO_BLOCK);
255
+ expect(res.data.blockingPreferences[0].targets.DEF_M).toEqual({});
256
+ expect(res.data.blockingPreferences[0].targets.DEF_R).toEqual({});
257
+ }
258
+ });
259
+ it('rejects a blocking target (on any side) that is not a starter', () => {
260
+ const res = TacticsInputSchema.safeParse(baseInput({
261
+ blockingPreferences: [{ rotation: 1, targets: { DEF_M: { [reserve.id]: RoleEnum.OUTSIDE_HITTER } } }]
249
262
  }));
250
263
  expect(res.success).toBe(false);
251
264
  if (!res.success) {
@@ -254,7 +267,7 @@ describe('TacticsInputSchema — blocking preferences', () => {
254
267
  });
255
268
  it('rejects a blocking target function of libero', () => {
256
269
  const res = TacticsInputSchema.safeParse(baseInput({
257
- blockingPreferences: [{ rotation: 1, targets: { [p4.id]: RoleEnum.LIBERO } }]
270
+ blockingPreferences: [{ rotation: 1, targets: { DEF_L: { [p4.id]: RoleEnum.LIBERO } } }]
258
271
  }));
259
272
  expect(res.success).toBe(false);
260
273
  if (!res.success) {
@@ -264,8 +277,8 @@ describe('TacticsInputSchema — blocking preferences', () => {
264
277
  it('rejects a duplicate rotation in blocking preferences', () => {
265
278
  const res = TacticsInputSchema.safeParse(baseInput({
266
279
  blockingPreferences: [
267
- { rotation: 1, targets: { [p4.id]: RoleEnum.OUTSIDE_HITTER } },
268
- { rotation: 1, targets: { [p3.id]: RoleEnum.MIDDLE_BLOCKER } }
280
+ { rotation: 1, targets: { DEF_L: { [p4.id]: RoleEnum.OUTSIDE_HITTER } } },
281
+ { rotation: 1, targets: { DEF_M: { [p3.id]: RoleEnum.MIDDLE_BLOCKER } } }
269
282
  ]
270
283
  }));
271
284
  expect(res.success).toBe(false);
@@ -275,29 +288,29 @@ describe('TacticsInputSchema — blocking preferences', () => {
275
288
  });
276
289
  it('accepts per-set blocking preference overrides', () => {
277
290
  const res = TacticsInputSchema.safeParse(baseInput({
278
- blockingPreferenceSets: [[{ rotation: 1, targets: { [p4.id]: RoleEnum.OPPOSITE_HITTER } }], []]
291
+ blockingPreferenceSets: [[{ rotation: 1, targets: { DEF_R: { [p4.id]: RoleEnum.OPPOSITE_HITTER } } }], []]
279
292
  }));
280
293
  expect(res.success).toBe(true);
281
294
  });
282
- it('accepts NO_BLOCK as an assignment, alongside a commit on another starter', () => {
295
+ it('accepts NO_BLOCK as an assignment, alongside a commit on another starter, on the same side', () => {
283
296
  const res = TacticsInputSchema.safeParse(baseInput({
284
- blockingPreferences: [{ rotation: 1, targets: { [p4.id]: NO_BLOCK, [p3.id]: RoleEnum.MIDDLE_BLOCKER } }]
297
+ blockingPreferences: [{ rotation: 1, targets: { DEF_L: { [p4.id]: NO_BLOCK, [p3.id]: RoleEnum.MIDDLE_BLOCKER } } }]
285
298
  }));
286
299
  expect(res.success).toBe(true);
287
300
  if (res.success) {
288
- expect(res.data.blockingPreferences[0].targets[p4.id]).toBe(NO_BLOCK);
289
- expect(res.data.blockingPreferences[0].targets[p3.id]).toBe(RoleEnum.MIDDLE_BLOCKER);
301
+ expect(res.data.blockingPreferences[0].targets.DEF_L[p4.id]).toBe(NO_BLOCK);
302
+ expect(res.data.blockingPreferences[0].targets.DEF_L[p3.id]).toBe(RoleEnum.MIDDLE_BLOCKER);
290
303
  }
291
304
  });
292
305
  it('accepts NO_BLOCK in a per-set override', () => {
293
306
  const res = TacticsInputSchema.safeParse(baseInput({
294
- blockingPreferenceSets: [[{ rotation: 1, targets: { [p4.id]: NO_BLOCK } }], []]
307
+ blockingPreferenceSets: [[{ rotation: 1, targets: { DEF_M: { [p4.id]: NO_BLOCK } } }], []]
295
308
  }));
296
309
  expect(res.success).toBe(true);
297
310
  });
298
311
  it('still rejects a NO_BLOCK assignment on a player who is not a starter', () => {
299
312
  const res = TacticsInputSchema.safeParse(baseInput({
300
- blockingPreferences: [{ rotation: 1, targets: { [reserve.id]: NO_BLOCK } }]
313
+ blockingPreferences: [{ rotation: 1, targets: { DEF_R: { [reserve.id]: NO_BLOCK } } }]
301
314
  }));
302
315
  expect(res.success).toBe(false);
303
316
  if (!res.success) {
@@ -306,7 +319,7 @@ describe('TacticsInputSchema — blocking preferences', () => {
306
319
  });
307
320
  it('rejects an unknown assignment string', () => {
308
321
  const res = TacticsInputSchema.safeParse(baseInput({
309
- blockingPreferences: [{ rotation: 1, targets: { [p4.id]: 'NO_BLOCKING' } }]
322
+ blockingPreferences: [{ rotation: 1, targets: { DEF_L: { [p4.id]: 'NO_BLOCKING' } } }]
310
323
  }));
311
324
  expect(res.success).toBe(false);
312
325
  });
@@ -290,12 +290,27 @@ export declare const TeamInputSchema: z.ZodObject<{
290
290
  }, z.core.$strip>>>>;
291
291
  blockingPreferences: z.ZodDefault<z.ZodArray<z.ZodObject<{
292
292
  rotation: z.ZodNumber;
293
- targets: z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodEnum<typeof import("../../player").RoleEnum> & z.ZodType<import("../../player").RoleEnum.SETTER | import("../../player").RoleEnum.OUTSIDE_HITTER | import("../../player").RoleEnum.OPPOSITE_HITTER | import("../../player").RoleEnum.MIDDLE_BLOCKER, import("../../player").RoleEnum, z.core.$ZodTypeInternals<import("../../player").RoleEnum.SETTER | import("../../player").RoleEnum.OUTSIDE_HITTER | import("../../player").RoleEnum.OPPOSITE_HITTER | import("../../player").RoleEnum.MIDDLE_BLOCKER, import("../../player").RoleEnum>>, z.ZodLiteral<"NO_BLOCK">]>>;
293
+ targets: z.ZodObject<{
294
+ DEF_L: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodEnum<typeof import("../../player").RoleEnum> & z.ZodType<import("../../player").RoleEnum.SETTER | import("../../player").RoleEnum.OUTSIDE_HITTER | import("../../player").RoleEnum.OPPOSITE_HITTER | import("../../player").RoleEnum.MIDDLE_BLOCKER, import("../../player").RoleEnum, z.core.$ZodTypeInternals<import("../../player").RoleEnum.SETTER | import("../../player").RoleEnum.OUTSIDE_HITTER | import("../../player").RoleEnum.OPPOSITE_HITTER | import("../../player").RoleEnum.MIDDLE_BLOCKER, import("../../player").RoleEnum>>, z.ZodLiteral<"NO_BLOCK">]>>>;
295
+ DEF_M: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodEnum<typeof import("../../player").RoleEnum> & z.ZodType<import("../../player").RoleEnum.SETTER | import("../../player").RoleEnum.OUTSIDE_HITTER | import("../../player").RoleEnum.OPPOSITE_HITTER | import("../../player").RoleEnum.MIDDLE_BLOCKER, import("../../player").RoleEnum, z.core.$ZodTypeInternals<import("../../player").RoleEnum.SETTER | import("../../player").RoleEnum.OUTSIDE_HITTER | import("../../player").RoleEnum.OPPOSITE_HITTER | import("../../player").RoleEnum.MIDDLE_BLOCKER, import("../../player").RoleEnum>>, z.ZodLiteral<"NO_BLOCK">]>>>;
296
+ DEF_R: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodEnum<typeof import("../../player").RoleEnum> & z.ZodType<import("../../player").RoleEnum.SETTER | import("../../player").RoleEnum.OUTSIDE_HITTER | import("../../player").RoleEnum.OPPOSITE_HITTER | import("../../player").RoleEnum.MIDDLE_BLOCKER, import("../../player").RoleEnum, z.core.$ZodTypeInternals<import("../../player").RoleEnum.SETTER | import("../../player").RoleEnum.OUTSIDE_HITTER | import("../../player").RoleEnum.OPPOSITE_HITTER | import("../../player").RoleEnum.MIDDLE_BLOCKER, import("../../player").RoleEnum>>, z.ZodLiteral<"NO_BLOCK">]>>>;
297
+ }, z.core.$strip>;
294
298
  }, z.core.$strip>>>;
295
299
  blockingPreferenceSets: z.ZodOptional<z.ZodArray<z.ZodArray<z.ZodObject<{
296
300
  rotation: z.ZodNumber;
297
- targets: z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodEnum<typeof import("../../player").RoleEnum> & z.ZodType<import("../../player").RoleEnum.SETTER | import("../../player").RoleEnum.OUTSIDE_HITTER | import("../../player").RoleEnum.OPPOSITE_HITTER | import("../../player").RoleEnum.MIDDLE_BLOCKER, import("../../player").RoleEnum, z.core.$ZodTypeInternals<import("../../player").RoleEnum.SETTER | import("../../player").RoleEnum.OUTSIDE_HITTER | import("../../player").RoleEnum.OPPOSITE_HITTER | import("../../player").RoleEnum.MIDDLE_BLOCKER, import("../../player").RoleEnum>>, z.ZodLiteral<"NO_BLOCK">]>>;
301
+ targets: z.ZodObject<{
302
+ DEF_L: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodEnum<typeof import("../../player").RoleEnum> & z.ZodType<import("../../player").RoleEnum.SETTER | import("../../player").RoleEnum.OUTSIDE_HITTER | import("../../player").RoleEnum.OPPOSITE_HITTER | import("../../player").RoleEnum.MIDDLE_BLOCKER, import("../../player").RoleEnum, z.core.$ZodTypeInternals<import("../../player").RoleEnum.SETTER | import("../../player").RoleEnum.OUTSIDE_HITTER | import("../../player").RoleEnum.OPPOSITE_HITTER | import("../../player").RoleEnum.MIDDLE_BLOCKER, import("../../player").RoleEnum>>, z.ZodLiteral<"NO_BLOCK">]>>>;
303
+ DEF_M: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodEnum<typeof import("../../player").RoleEnum> & z.ZodType<import("../../player").RoleEnum.SETTER | import("../../player").RoleEnum.OUTSIDE_HITTER | import("../../player").RoleEnum.OPPOSITE_HITTER | import("../../player").RoleEnum.MIDDLE_BLOCKER, import("../../player").RoleEnum, z.core.$ZodTypeInternals<import("../../player").RoleEnum.SETTER | import("../../player").RoleEnum.OUTSIDE_HITTER | import("../../player").RoleEnum.OPPOSITE_HITTER | import("../../player").RoleEnum.MIDDLE_BLOCKER, import("../../player").RoleEnum>>, z.ZodLiteral<"NO_BLOCK">]>>>;
304
+ DEF_R: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodEnum<typeof import("../../player").RoleEnum> & z.ZodType<import("../../player").RoleEnum.SETTER | import("../../player").RoleEnum.OUTSIDE_HITTER | import("../../player").RoleEnum.OPPOSITE_HITTER | import("../../player").RoleEnum.MIDDLE_BLOCKER, import("../../player").RoleEnum, z.core.$ZodTypeInternals<import("../../player").RoleEnum.SETTER | import("../../player").RoleEnum.OUTSIDE_HITTER | import("../../player").RoleEnum.OPPOSITE_HITTER | import("../../player").RoleEnum.MIDDLE_BLOCKER, import("../../player").RoleEnum>>, z.ZodLiteral<"NO_BLOCK">]>>>;
305
+ }, z.core.$strip>;
298
306
  }, z.core.$strip>>>>;
307
+ basePositions: z.ZodOptional<z.ZodObject<{
308
+ coords: z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodObject<{
309
+ d: z.ZodNumber;
310
+ r: z.ZodNumber;
311
+ }, z.core.$strip>>>>;
312
+ receive: z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodBoolean>>;
313
+ }, z.core.$strip>>;
299
314
  replaceKnockedImmediately: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
300
315
  injuryReplacements: z.ZodDefault<z.ZodArray<z.ZodObject<{
301
316
  starter: z.ZodCustom<Player, Player>;
@@ -8,6 +8,7 @@ import { OffensivePreference } from './offensive-preference';
8
8
  import { BlockingPreference } from './blocking-preference';
9
9
  import { InjuryReplacement } from './injury-replacement';
10
10
  import { LiberoInjuryConfig } from './libero-injury';
11
+ import { BaseConfig } from './base-config';
11
12
  export interface StartingLineup {
12
13
  readonly [CourtPosition.LEFT_FRONT]: Player;
13
14
  readonly [CourtPosition.MIDDLE_FRONT]: Player;
@@ -42,6 +43,7 @@ export interface TacticsOpts {
42
43
  readonly liberoInjury?: LiberoInjuryConfig;
43
44
  readonly blockingPreferences: BlockingPreference[];
44
45
  readonly blockingPreferenceSets?: BlockingPreference[][];
46
+ readonly basePositions?: BaseConfig;
45
47
  }
46
48
  export declare class Tactics {
47
49
  readonly lineup: StartingLineup;
@@ -63,6 +65,7 @@ export declare class Tactics {
63
65
  readonly liberoInjury?: LiberoInjuryConfig;
64
66
  readonly blockingPreferences: BlockingPreference[];
65
67
  readonly blockingPreferenceSets?: BlockingPreference[][];
68
+ readonly basePositions?: BaseConfig;
66
69
  static create(input: unknown): Tactics;
67
70
  private constructor();
68
71
  findPlayerInLineup(id: string): CourtPosition | undefined;
@@ -15,7 +15,7 @@ export class Tactics {
15
15
  }
16
16
  return new Tactics(result.data);
17
17
  }
18
- constructor({ lineup, liberoReplacements, receiveRotationOffset = false, designatedSubs = [], substitutionBand = EnergyBand.TIRED, secondLibero, liberoSub, liberoReplacementSets, rotationSystem = RotationSystemEnum.SIX_ZERO, designatedSetters = [], offensivePreferences = [], serveAttackShares = {}, systemSets, offensivePreferenceSets, replaceKnockedImmediately = {}, injuryReplacements = [], liberoInjury, blockingPreferences = [], blockingPreferenceSets }) {
18
+ constructor({ lineup, liberoReplacements, receiveRotationOffset = false, designatedSubs = [], substitutionBand = EnergyBand.TIRED, secondLibero, liberoSub, liberoReplacementSets, rotationSystem = RotationSystemEnum.SIX_ZERO, designatedSetters = [], offensivePreferences = [], serveAttackShares = {}, systemSets, offensivePreferenceSets, replaceKnockedImmediately = {}, injuryReplacements = [], liberoInjury, blockingPreferences = [], blockingPreferenceSets, basePositions }) {
19
19
  this.lineup = lineup;
20
20
  this.liberoReplacements = liberoReplacements;
21
21
  this.receiveRotationOffset = receiveRotationOffset;
@@ -35,6 +35,7 @@ export class Tactics {
35
35
  this.liberoInjury = liberoInjury;
36
36
  this.blockingPreferences = blockingPreferences;
37
37
  this.blockingPreferenceSets = blockingPreferenceSets;
38
+ this.basePositions = basePositions;
38
39
  }
39
40
  findPlayerInLineup(id) {
40
41
  if (this.lineup.bench.some(p => p.id === id))
@@ -5,6 +5,8 @@ import { Team } from './team';
5
5
  import { Tactics } from './tactics';
6
6
  import { EnergyBand } from './energy-band';
7
7
  import { Formation } from './formation';
8
+ import { RotationSystemEnum } from './rotation-system';
9
+ import { defaultBaseConfig } from './default-base-config';
8
10
  import { CourtPosition } from '../match';
9
11
  import logger from '../../logger';
10
12
  const buildBlock = (rarity, count) => Array.from({ length: count }, () => rarity);
@@ -62,7 +64,10 @@ export class TeamGenerator {
62
64
  return Tactics.create({
63
65
  lineup,
64
66
  liberoReplacements: [lineup[3], lineup[6]],
65
- substitutionBand: TeamGenerator.BOT_SUB_BANDS[Math.floor(Math.random() * TeamGenerator.BOT_SUB_BANDS.length)]
67
+ substitutionBand: TeamGenerator.BOT_SUB_BANDS[Math.floor(Math.random() * TeamGenerator.BOT_SUB_BANDS.length)],
68
+ // basePositions is never null: a team always carries the default base config for its system. createTactics
69
+ // makes 6-0 teams (Tactics.create default); when a user picks another system the API re-materializes.
70
+ basePositions: defaultBaseConfig(RotationSystemEnum.SIX_ZERO)
66
71
  });
67
72
  }
68
73
  static createLineup(players, formation) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "volleyballsimtypes",
3
- "version": "0.0.504",
3
+ "version": "0.0.506",
4
4
  "description": "vbsim types",
5
5
  "main": "./dist/cjs/src/index.js",
6
6
  "module": "./dist/esm/src/index.js",