volleyballsimtypes 0.0.387 → 0.0.389

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 -0
  2. package/dist/cjs/src/data/models/tactics.d.ts +3 -1
  3. package/dist/cjs/src/data/models/tactics.js +5 -0
  4. package/dist/cjs/src/data/transformers/tactics.js +2 -0
  5. package/dist/cjs/src/service/competition/standing.js +15 -8
  6. package/dist/cjs/src/service/competition/standing.test.js +12 -0
  7. package/dist/cjs/src/service/match/court-position.d.ts +1 -0
  8. package/dist/cjs/src/service/match/court-position.js +11 -0
  9. package/dist/cjs/src/service/match/court-position.test.d.ts +1 -0
  10. package/dist/cjs/src/service/match/court-position.test.js +25 -0
  11. package/dist/cjs/src/service/team/schemas/tactics.z.d.ts +1 -0
  12. package/dist/cjs/src/service/team/schemas/tactics.z.js +2 -0
  13. package/dist/cjs/src/service/team/schemas/team.z.d.ts +1 -0
  14. package/dist/cjs/src/service/team/tactics.d.ts +2 -0
  15. package/dist/cjs/src/service/team/tactics.js +2 -1
  16. package/dist/esm/src/api/index.d.ts +1 -0
  17. package/dist/esm/src/data/models/tactics.d.ts +3 -1
  18. package/dist/esm/src/data/models/tactics.js +5 -0
  19. package/dist/esm/src/data/transformers/tactics.js +2 -0
  20. package/dist/esm/src/service/competition/standing.js +15 -8
  21. package/dist/esm/src/service/competition/standing.test.js +12 -0
  22. package/dist/esm/src/service/match/court-position.d.ts +1 -0
  23. package/dist/esm/src/service/match/court-position.js +10 -0
  24. package/dist/esm/src/service/match/court-position.test.d.ts +1 -0
  25. package/dist/esm/src/service/match/court-position.test.js +23 -0
  26. package/dist/esm/src/service/team/schemas/tactics.z.d.ts +1 -0
  27. package/dist/esm/src/service/team/schemas/tactics.z.js +2 -0
  28. package/dist/esm/src/service/team/schemas/team.z.d.ts +1 -0
  29. package/dist/esm/src/service/team/tactics.d.ts +2 -0
  30. package/dist/esm/src/service/team/tactics.js +2 -1
  31. package/package.json +1 -1
@@ -61,6 +61,7 @@ export interface Tactics {
61
61
  substitutionTolerance: number;
62
62
  liberoReplacements: string[];
63
63
  pinchServerSubs: Record<string, string>;
64
+ receiveRotationOffset: boolean;
64
65
  }
65
66
  export type Team = Omit<DataProps<_Team>, 'roster' | '_rating' | 'rating' | 'tactics'> & {
66
67
  roster: Player[];
@@ -19,10 +19,11 @@ export interface TacticsAttributes {
19
19
  lineup: TacticsLineupAttributes;
20
20
  libero_replacements: PlayerId[];
21
21
  pinch_server_subs: PinchServerSubsAttributes;
22
+ receive_rotation_offset: boolean;
22
23
  }
23
24
  export type TacticsPk = 'team_id';
24
25
  export type TacticsId = TacticsModel[TacticsPk];
25
- export type TacticsOptionalAttributes = 'substitution_tolerance' | 'lineup' | 'libero_replacements' | 'pinch_server_subs';
26
+ export type TacticsOptionalAttributes = 'substitution_tolerance' | 'lineup' | 'libero_replacements' | 'pinch_server_subs' | 'receive_rotation_offset';
26
27
  export type TacticsCreationAttributes = Optional<TacticsAttributes, TacticsOptionalAttributes>;
27
28
  export declare class TacticsModel extends Model<TacticsAttributes, TacticsCreationAttributes> implements TacticsAttributes {
28
29
  team_id: string;
@@ -30,6 +31,7 @@ export declare class TacticsModel extends Model<TacticsAttributes, TacticsCreati
30
31
  lineup: TacticsLineupAttributes;
31
32
  libero_replacements: PlayerId[];
32
33
  pinch_server_subs: PinchServerSubsAttributes;
34
+ receive_rotation_offset: boolean;
33
35
  team: TeamModel;
34
36
  getTeam: Sequelize.BelongsToGetAssociationMixin<TeamModel>;
35
37
  setTeam: Sequelize.BelongsToSetAssociationMixin<TeamModel, TeamId>;
@@ -34,6 +34,11 @@ class TacticsModel extends sequelize_1.Model {
34
34
  type: sequelize_1.DataTypes.JSONB,
35
35
  allowNull: false,
36
36
  defaultValue: {}
37
+ },
38
+ receive_rotation_offset: {
39
+ type: sequelize_1.DataTypes.BOOLEAN,
40
+ allowNull: false,
41
+ defaultValue: false
37
42
  }
38
43
  }, {
39
44
  sequelize,
@@ -37,6 +37,7 @@ function transformToAttributes(tactics, teamId) {
37
37
  return {
38
38
  team_id: teamId,
39
39
  substitution_tolerance: tactics.substitutionTolerance ?? 0,
40
+ receive_rotation_offset: tactics.receiveRotationOffset ?? false,
40
41
  lineup: transformFromLineup(tactics.lineup),
41
42
  libero_replacements: tactics.liberoReplacements.map((lr) => lr.id),
42
43
  pinch_server_subs: Object.fromEntries([...tactics.pinchServerSubs].map(([k, v]) => [k.id, v.id]))
@@ -52,6 +53,7 @@ function buildPinchServerSubs(pinchServerSubs, roster) {
52
53
  function transformToObject(model, roster) {
53
54
  return service_1.Tactics.create({
54
55
  substitutionTolerance: model.substitution_tolerance,
56
+ receiveRotationOffset: model.receive_rotation_offset,
55
57
  lineup: transformToLineup(model.lineup, roster),
56
58
  liberoReplacements: model.libero_replacements.map((lr) => findPlayer(lr, roster)),
57
59
  pinchServerSubs: buildPinchServerSubs(model.pinch_server_subs, roster)
@@ -36,20 +36,28 @@ class Standing {
36
36
  this.totalMatches = this.matchesWon + this.matchesLost;
37
37
  }
38
38
  static sortFn(matches) {
39
+ // A zero denominator (no sets/points lost) is the BEST possible ratio, not the worst. The stored
40
+ // setsRatio/pointsRatio fall back to 0 in that case (and the table shows "MAX"), so we compare an
41
+ // effective ratio here that treats "won with nothing lost" as +Infinity, ranking it highest.
42
+ const effectiveRatio = (won, lost) => lost > 0 ? won / lost : (won > 0 ? Infinity : 0);
39
43
  return (standingA, standingB) => {
40
44
  if (standingB.points > standingA.points)
41
45
  return 1;
42
- else if (standingA.points > standingB.points)
46
+ if (standingA.points > standingB.points)
43
47
  return -1;
44
- else if (standingB.setsRatio > standingA.setsRatio)
48
+ const setRatioA = effectiveRatio(standingA.setsWon, standingA.setsLost);
49
+ const setRatioB = effectiveRatio(standingB.setsWon, standingB.setsLost);
50
+ if (setRatioB > setRatioA)
45
51
  return 1;
46
- else if (standingA.setsRatio > standingB.setsRatio)
52
+ if (setRatioA > setRatioB)
47
53
  return -1;
48
- else if (standingB.pointsRatio > standingA.pointsRatio)
54
+ const pointRatioA = effectiveRatio(standingA.pointsWon, standingA.pointsLost);
55
+ const pointRatioB = effectiveRatio(standingB.pointsWon, standingB.pointsLost);
56
+ if (pointRatioB > pointRatioA)
49
57
  return 1;
50
- else if (standingA.pointsRatio > standingB.pointsRatio)
58
+ if (pointRatioA > pointRatioB)
51
59
  return -1;
52
- else if (matches.length > 0) {
60
+ if (matches.length > 0) {
53
61
  const ab = matches.find((match) => match.homeTeam.id === standingA.teamId && match.awayTeam.id === standingB.teamId);
54
62
  const ba = matches.find((match) => match.awayTeam.id === standingA.teamId && match.homeTeam.id === standingB.teamId);
55
63
  if (ba == null && ab == null)
@@ -66,8 +74,7 @@ class Standing {
66
74
  const b = (ba.homeScore ?? 0) + (ab.awayScore ?? 0);
67
75
  return b - a;
68
76
  }
69
- else
70
- return 0;
77
+ return 0;
71
78
  };
72
79
  }
73
80
  calculateStanding(matches) {
@@ -136,6 +136,18 @@ function makeStanding(teamId, opts = {}) {
136
136
  const sorted = [b, a].sort(standing_1.Standing.sortFn([]));
137
137
  (0, globals_1.expect)(sorted[0].teamId).toBe(idA);
138
138
  });
139
+ (0, globals_1.it)('ranks a team with no sets lost (MAX ratio) above a finite setsRatio when points tie', () => {
140
+ const idA = (0, uuid_1.v4)();
141
+ const idB = (0, uuid_1.v4)();
142
+ // Both 6 points. A swept its matches (won30=2 → setsWon=6, setsLost=0 → ratio is MAX/infinite);
143
+ // B also has 6 points but conceded sets (won31=2 → setsWon=6, setsLost=2 → setsRatio=3).
144
+ // The undefeated-in-sets team must rank FIRST, not last (the divide-by-zero bug ranked it last).
145
+ const a = makeStanding(idA, { won30: 2 });
146
+ const b = makeStanding(idB, { won31: 2 });
147
+ const sorted = [b, a].sort(standing_1.Standing.sortFn([]));
148
+ (0, globals_1.expect)(sorted[0].teamId).toBe(idA);
149
+ (0, globals_1.expect)(sorted[1].teamId).toBe(idB);
150
+ });
139
151
  (0, globals_1.it)('falls back to pointsRatio when points and setsRatio are equal', () => {
140
152
  const idA = (0, uuid_1.v4)();
141
153
  const idB = (0, uuid_1.v4)();
@@ -29,6 +29,7 @@ export declare enum CourtTarget {
29
29
  }
30
30
  export declare function getPositions(): CourtPosition[];
31
31
  export declare function rotatePosition(position: number): CourtPosition;
32
+ export declare function rotatePositionBack(position: number): CourtPosition;
32
33
  export declare function isFrontRow(position: number): boolean;
33
34
  export declare function isBackRow(position: number): boolean;
34
35
  export declare function getCourtRow(row: CourtRow): CourtPosition[];
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.CourtTarget = exports.CourtPosition = exports.CourtRow = void 0;
4
4
  exports.getPositions = getPositions;
5
5
  exports.rotatePosition = rotatePosition;
6
+ exports.rotatePositionBack = rotatePositionBack;
6
7
  exports.isFrontRow = isFrontRow;
7
8
  exports.isBackRow = isBackRow;
8
9
  exports.getCourtRow = getCourtRow;
@@ -53,6 +54,16 @@ function rotatePosition(position) {
53
54
  else
54
55
  return CourtPosition.MIDDLE_BACK;
55
56
  }
57
+ // Inverse of rotatePosition (one rotation counter-clockwise): 1->2, 2->3, ... 5->6, 6->1.
58
+ // Used to start a receiving team "one rotation behind" so the chosen server serves first after side-out.
59
+ function rotatePositionBack(position) {
60
+ if (position < 1 || position > 6)
61
+ throw new Error('POSITION_INDEX_OUT_OF_BOUNDS');
62
+ if (position === CourtPosition.MIDDLE_BACK)
63
+ return CourtPosition.RIGHT_BACK;
64
+ else
65
+ return position + 1;
66
+ }
56
67
  function isFrontRow(position) {
57
68
  if (position < 1 || position > 6)
58
69
  throw new Error('POSITION_INDEX_OUT_OF_BOUNDS');
@@ -0,0 +1,25 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const globals_1 = require("@jest/globals");
4
+ const court_position_1 = require("./court-position");
5
+ (0, globals_1.describe)('rotatePositionBack', () => {
6
+ (0, globals_1.it)('rotates one step counter-clockwise (1->2, 2->3, 3->4, 4->5, 5->6, 6->1)', () => {
7
+ (0, globals_1.expect)((0, court_position_1.rotatePositionBack)(court_position_1.CourtPosition.RIGHT_BACK)).toBe(court_position_1.CourtPosition.RIGHT_FRONT); // 1 -> 2
8
+ (0, globals_1.expect)((0, court_position_1.rotatePositionBack)(court_position_1.CourtPosition.RIGHT_FRONT)).toBe(court_position_1.CourtPosition.MIDDLE_FRONT); // 2 -> 3
9
+ (0, globals_1.expect)((0, court_position_1.rotatePositionBack)(court_position_1.CourtPosition.MIDDLE_FRONT)).toBe(court_position_1.CourtPosition.LEFT_FRONT); // 3 -> 4
10
+ (0, globals_1.expect)((0, court_position_1.rotatePositionBack)(court_position_1.CourtPosition.LEFT_FRONT)).toBe(court_position_1.CourtPosition.LEFT_BACK); // 4 -> 5
11
+ (0, globals_1.expect)((0, court_position_1.rotatePositionBack)(court_position_1.CourtPosition.LEFT_BACK)).toBe(court_position_1.CourtPosition.MIDDLE_BACK); // 5 -> 6
12
+ (0, globals_1.expect)((0, court_position_1.rotatePositionBack)(court_position_1.CourtPosition.MIDDLE_BACK)).toBe(court_position_1.CourtPosition.RIGHT_BACK); // 6 -> 1
13
+ });
14
+ (0, globals_1.it)('is the exact inverse of rotatePosition for every court position', () => {
15
+ for (let p = 1; p <= 6; p++) {
16
+ (0, globals_1.expect)((0, court_position_1.rotatePosition)((0, court_position_1.rotatePositionBack)(p))).toBe(p);
17
+ (0, globals_1.expect)((0, court_position_1.rotatePositionBack)((0, court_position_1.rotatePosition)(p))).toBe(p);
18
+ }
19
+ });
20
+ (0, globals_1.it)('throws POSITION_INDEX_OUT_OF_BOUNDS outside 1..6', () => {
21
+ (0, globals_1.expect)(() => (0, court_position_1.rotatePositionBack)(0)).toThrow('POSITION_INDEX_OUT_OF_BOUNDS');
22
+ (0, globals_1.expect)(() => (0, court_position_1.rotatePositionBack)(7)).toThrow('POSITION_INDEX_OUT_OF_BOUNDS');
23
+ (0, globals_1.expect)(() => (0, court_position_1.rotatePositionBack)(-1)).toThrow('POSITION_INDEX_OUT_OF_BOUNDS');
24
+ });
25
+ });
@@ -13,6 +13,7 @@ export declare const TacticsInputSchema: z.ZodObject<{
13
13
  }, z.core.$strip>;
14
14
  substitutionTolerance: z.ZodNumber;
15
15
  liberoReplacements: z.ZodArray<z.ZodCustom<Player, Player>>;
16
+ receiveRotationOffset: z.ZodDefault<z.ZodBoolean>;
16
17
  pinchServerSubs: z.ZodCustom<Map<Player, Player>, Map<Player, Player>>;
17
18
  }, z.core.$strip>;
18
19
  export type TacticsInput = z.infer<typeof TacticsInputSchema>;
@@ -47,6 +47,8 @@ exports.TacticsInputSchema = zod_1.z.object({
47
47
  lineup: lineupSchema,
48
48
  substitutionTolerance: zod_1.z.number().int().min(1).max(50),
49
49
  liberoReplacements: zod_1.z.array(playerInstanceSchema),
50
+ // Default false keeps every existing Tactics.create({...}) call (that omits this) valid.
51
+ receiveRotationOffset: zod_1.z.boolean().default(false),
50
52
  pinchServerSubs: zod_1.z.custom((v) => v instanceof Map &&
51
53
  Array.from(v.entries()).every(([k, val]) => k instanceof player_1.Player && val instanceof player_1.Player), { message: 'INVALID_PINCH_SERVER_SUBS' })
52
54
  });
@@ -22,6 +22,7 @@ export declare const TeamInputSchema: z.ZodObject<{
22
22
  }, z.core.$strip>;
23
23
  substitutionTolerance: z.ZodNumber;
24
24
  liberoReplacements: z.ZodArray<z.ZodCustom<Player, Player>>;
25
+ receiveRotationOffset: z.ZodDefault<z.ZodBoolean>;
25
26
  pinchServerSubs: z.ZodCustom<Map<Player, Player>, Map<Player, Player>>;
26
27
  }, z.core.$strip>>;
27
28
  }, z.core.$strip>;
@@ -15,12 +15,14 @@ export interface TacticsOpts {
15
15
  readonly substitutionTolerance: number;
16
16
  readonly liberoReplacements: Player[];
17
17
  readonly pinchServerSubs: Map<Player, Player>;
18
+ readonly receiveRotationOffset: boolean;
18
19
  }
19
20
  export declare class Tactics {
20
21
  readonly lineup: StartingLineup;
21
22
  readonly substitutionTolerance: number;
22
23
  readonly liberoReplacements: Player[];
23
24
  readonly pinchServerSubs: Map<Player, Player>;
25
+ readonly receiveRotationOffset: boolean;
24
26
  static create(input: unknown): Tactics;
25
27
  private constructor();
26
28
  findPlayerInLineup(id: string): CourtPosition | undefined;
@@ -16,11 +16,12 @@ class Tactics {
16
16
  }
17
17
  return new Tactics(result.data);
18
18
  }
19
- constructor({ lineup, pinchServerSubs, liberoReplacements, substitutionTolerance = 1 }) {
19
+ constructor({ lineup, pinchServerSubs, liberoReplacements, substitutionTolerance = 1, receiveRotationOffset = false }) {
20
20
  this.lineup = lineup;
21
21
  this.substitutionTolerance = substitutionTolerance;
22
22
  this.pinchServerSubs = pinchServerSubs;
23
23
  this.liberoReplacements = liberoReplacements;
24
+ this.receiveRotationOffset = receiveRotationOffset;
24
25
  }
25
26
  findPlayerInLineup(id) {
26
27
  if (this.lineup.bench.some(p => p.id === id))
@@ -61,6 +61,7 @@ export interface Tactics {
61
61
  substitutionTolerance: number;
62
62
  liberoReplacements: string[];
63
63
  pinchServerSubs: Record<string, string>;
64
+ receiveRotationOffset: boolean;
64
65
  }
65
66
  export type Team = Omit<DataProps<_Team>, 'roster' | '_rating' | 'rating' | 'tactics'> & {
66
67
  roster: Player[];
@@ -19,10 +19,11 @@ export interface TacticsAttributes {
19
19
  lineup: TacticsLineupAttributes;
20
20
  libero_replacements: PlayerId[];
21
21
  pinch_server_subs: PinchServerSubsAttributes;
22
+ receive_rotation_offset: boolean;
22
23
  }
23
24
  export type TacticsPk = 'team_id';
24
25
  export type TacticsId = TacticsModel[TacticsPk];
25
- export type TacticsOptionalAttributes = 'substitution_tolerance' | 'lineup' | 'libero_replacements' | 'pinch_server_subs';
26
+ export type TacticsOptionalAttributes = 'substitution_tolerance' | 'lineup' | 'libero_replacements' | 'pinch_server_subs' | 'receive_rotation_offset';
26
27
  export type TacticsCreationAttributes = Optional<TacticsAttributes, TacticsOptionalAttributes>;
27
28
  export declare class TacticsModel extends Model<TacticsAttributes, TacticsCreationAttributes> implements TacticsAttributes {
28
29
  team_id: string;
@@ -30,6 +31,7 @@ export declare class TacticsModel extends Model<TacticsAttributes, TacticsCreati
30
31
  lineup: TacticsLineupAttributes;
31
32
  libero_replacements: PlayerId[];
32
33
  pinch_server_subs: PinchServerSubsAttributes;
34
+ receive_rotation_offset: boolean;
33
35
  team: TeamModel;
34
36
  getTeam: Sequelize.BelongsToGetAssociationMixin<TeamModel>;
35
37
  setTeam: Sequelize.BelongsToSetAssociationMixin<TeamModel, TeamId>;
@@ -31,6 +31,11 @@ export class TacticsModel extends Model {
31
31
  type: DataTypes.JSONB,
32
32
  allowNull: false,
33
33
  defaultValue: {}
34
+ },
35
+ receive_rotation_offset: {
36
+ type: DataTypes.BOOLEAN,
37
+ allowNull: false,
38
+ defaultValue: false
34
39
  }
35
40
  }, {
36
41
  sequelize,
@@ -33,6 +33,7 @@ function transformToAttributes(tactics, teamId) {
33
33
  return {
34
34
  team_id: teamId,
35
35
  substitution_tolerance: tactics.substitutionTolerance ?? 0,
36
+ receive_rotation_offset: tactics.receiveRotationOffset ?? false,
36
37
  lineup: transformFromLineup(tactics.lineup),
37
38
  libero_replacements: tactics.liberoReplacements.map((lr) => lr.id),
38
39
  pinch_server_subs: Object.fromEntries([...tactics.pinchServerSubs].map(([k, v]) => [k.id, v.id]))
@@ -48,6 +49,7 @@ function buildPinchServerSubs(pinchServerSubs, roster) {
48
49
  function transformToObject(model, roster) {
49
50
  return Tactics.create({
50
51
  substitutionTolerance: model.substitution_tolerance,
52
+ receiveRotationOffset: model.receive_rotation_offset,
51
53
  lineup: transformToLineup(model.lineup, roster),
52
54
  liberoReplacements: model.libero_replacements.map((lr) => findPlayer(lr, roster)),
53
55
  pinchServerSubs: buildPinchServerSubs(model.pinch_server_subs, roster)
@@ -33,20 +33,28 @@ export class Standing {
33
33
  this.totalMatches = this.matchesWon + this.matchesLost;
34
34
  }
35
35
  static sortFn(matches) {
36
+ // A zero denominator (no sets/points lost) is the BEST possible ratio, not the worst. The stored
37
+ // setsRatio/pointsRatio fall back to 0 in that case (and the table shows "MAX"), so we compare an
38
+ // effective ratio here that treats "won with nothing lost" as +Infinity, ranking it highest.
39
+ const effectiveRatio = (won, lost) => lost > 0 ? won / lost : (won > 0 ? Infinity : 0);
36
40
  return (standingA, standingB) => {
37
41
  if (standingB.points > standingA.points)
38
42
  return 1;
39
- else if (standingA.points > standingB.points)
43
+ if (standingA.points > standingB.points)
40
44
  return -1;
41
- else if (standingB.setsRatio > standingA.setsRatio)
45
+ const setRatioA = effectiveRatio(standingA.setsWon, standingA.setsLost);
46
+ const setRatioB = effectiveRatio(standingB.setsWon, standingB.setsLost);
47
+ if (setRatioB > setRatioA)
42
48
  return 1;
43
- else if (standingA.setsRatio > standingB.setsRatio)
49
+ if (setRatioA > setRatioB)
44
50
  return -1;
45
- else if (standingB.pointsRatio > standingA.pointsRatio)
51
+ const pointRatioA = effectiveRatio(standingA.pointsWon, standingA.pointsLost);
52
+ const pointRatioB = effectiveRatio(standingB.pointsWon, standingB.pointsLost);
53
+ if (pointRatioB > pointRatioA)
46
54
  return 1;
47
- else if (standingA.pointsRatio > standingB.pointsRatio)
55
+ if (pointRatioA > pointRatioB)
48
56
  return -1;
49
- else if (matches.length > 0) {
57
+ if (matches.length > 0) {
50
58
  const ab = matches.find((match) => match.homeTeam.id === standingA.teamId && match.awayTeam.id === standingB.teamId);
51
59
  const ba = matches.find((match) => match.awayTeam.id === standingA.teamId && match.homeTeam.id === standingB.teamId);
52
60
  if (ba == null && ab == null)
@@ -63,8 +71,7 @@ export class Standing {
63
71
  const b = (ba.homeScore ?? 0) + (ab.awayScore ?? 0);
64
72
  return b - a;
65
73
  }
66
- else
67
- return 0;
74
+ return 0;
68
75
  };
69
76
  }
70
77
  calculateStanding(matches) {
@@ -134,6 +134,18 @@ describe('Standing', () => {
134
134
  const sorted = [b, a].sort(Standing.sortFn([]));
135
135
  expect(sorted[0].teamId).toBe(idA);
136
136
  });
137
+ it('ranks a team with no sets lost (MAX ratio) above a finite setsRatio when points tie', () => {
138
+ const idA = uuidv4();
139
+ const idB = uuidv4();
140
+ // Both 6 points. A swept its matches (won30=2 → setsWon=6, setsLost=0 → ratio is MAX/infinite);
141
+ // B also has 6 points but conceded sets (won31=2 → setsWon=6, setsLost=2 → setsRatio=3).
142
+ // The undefeated-in-sets team must rank FIRST, not last (the divide-by-zero bug ranked it last).
143
+ const a = makeStanding(idA, { won30: 2 });
144
+ const b = makeStanding(idB, { won31: 2 });
145
+ const sorted = [b, a].sort(Standing.sortFn([]));
146
+ expect(sorted[0].teamId).toBe(idA);
147
+ expect(sorted[1].teamId).toBe(idB);
148
+ });
137
149
  it('falls back to pointsRatio when points and setsRatio are equal', () => {
138
150
  const idA = uuidv4();
139
151
  const idB = uuidv4();
@@ -29,6 +29,7 @@ export declare enum CourtTarget {
29
29
  }
30
30
  export declare function getPositions(): CourtPosition[];
31
31
  export declare function rotatePosition(position: number): CourtPosition;
32
+ export declare function rotatePositionBack(position: number): CourtPosition;
32
33
  export declare function isFrontRow(position: number): boolean;
33
34
  export declare function isBackRow(position: number): boolean;
34
35
  export declare function getCourtRow(row: CourtRow): CourtPosition[];
@@ -45,6 +45,16 @@ export function rotatePosition(position) {
45
45
  else
46
46
  return CourtPosition.MIDDLE_BACK;
47
47
  }
48
+ // Inverse of rotatePosition (one rotation counter-clockwise): 1->2, 2->3, ... 5->6, 6->1.
49
+ // Used to start a receiving team "one rotation behind" so the chosen server serves first after side-out.
50
+ export function rotatePositionBack(position) {
51
+ if (position < 1 || position > 6)
52
+ throw new Error('POSITION_INDEX_OUT_OF_BOUNDS');
53
+ if (position === CourtPosition.MIDDLE_BACK)
54
+ return CourtPosition.RIGHT_BACK;
55
+ else
56
+ return position + 1;
57
+ }
48
58
  export function isFrontRow(position) {
49
59
  if (position < 1 || position > 6)
50
60
  throw new Error('POSITION_INDEX_OUT_OF_BOUNDS');
@@ -0,0 +1,23 @@
1
+ import { describe, it, expect } from '@jest/globals';
2
+ import { CourtPosition, rotatePosition, rotatePositionBack } from './court-position';
3
+ describe('rotatePositionBack', () => {
4
+ it('rotates one step counter-clockwise (1->2, 2->3, 3->4, 4->5, 5->6, 6->1)', () => {
5
+ expect(rotatePositionBack(CourtPosition.RIGHT_BACK)).toBe(CourtPosition.RIGHT_FRONT); // 1 -> 2
6
+ expect(rotatePositionBack(CourtPosition.RIGHT_FRONT)).toBe(CourtPosition.MIDDLE_FRONT); // 2 -> 3
7
+ expect(rotatePositionBack(CourtPosition.MIDDLE_FRONT)).toBe(CourtPosition.LEFT_FRONT); // 3 -> 4
8
+ expect(rotatePositionBack(CourtPosition.LEFT_FRONT)).toBe(CourtPosition.LEFT_BACK); // 4 -> 5
9
+ expect(rotatePositionBack(CourtPosition.LEFT_BACK)).toBe(CourtPosition.MIDDLE_BACK); // 5 -> 6
10
+ expect(rotatePositionBack(CourtPosition.MIDDLE_BACK)).toBe(CourtPosition.RIGHT_BACK); // 6 -> 1
11
+ });
12
+ it('is the exact inverse of rotatePosition for every court position', () => {
13
+ for (let p = 1; p <= 6; p++) {
14
+ expect(rotatePosition(rotatePositionBack(p))).toBe(p);
15
+ expect(rotatePositionBack(rotatePosition(p))).toBe(p);
16
+ }
17
+ });
18
+ it('throws POSITION_INDEX_OUT_OF_BOUNDS outside 1..6', () => {
19
+ expect(() => rotatePositionBack(0)).toThrow('POSITION_INDEX_OUT_OF_BOUNDS');
20
+ expect(() => rotatePositionBack(7)).toThrow('POSITION_INDEX_OUT_OF_BOUNDS');
21
+ expect(() => rotatePositionBack(-1)).toThrow('POSITION_INDEX_OUT_OF_BOUNDS');
22
+ });
23
+ });
@@ -13,6 +13,7 @@ export declare const TacticsInputSchema: z.ZodObject<{
13
13
  }, z.core.$strip>;
14
14
  substitutionTolerance: z.ZodNumber;
15
15
  liberoReplacements: z.ZodArray<z.ZodCustom<Player, Player>>;
16
+ receiveRotationOffset: z.ZodDefault<z.ZodBoolean>;
16
17
  pinchServerSubs: z.ZodCustom<Map<Player, Player>, Map<Player, Player>>;
17
18
  }, z.core.$strip>;
18
19
  export type TacticsInput = z.infer<typeof TacticsInputSchema>;
@@ -44,6 +44,8 @@ export const TacticsInputSchema = z.object({
44
44
  lineup: lineupSchema,
45
45
  substitutionTolerance: z.number().int().min(1).max(50),
46
46
  liberoReplacements: z.array(playerInstanceSchema),
47
+ // Default false keeps every existing Tactics.create({...}) call (that omits this) valid.
48
+ receiveRotationOffset: z.boolean().default(false),
47
49
  pinchServerSubs: z.custom((v) => v instanceof Map &&
48
50
  Array.from(v.entries()).every(([k, val]) => k instanceof Player && val instanceof Player), { message: 'INVALID_PINCH_SERVER_SUBS' })
49
51
  });
@@ -22,6 +22,7 @@ export declare const TeamInputSchema: z.ZodObject<{
22
22
  }, z.core.$strip>;
23
23
  substitutionTolerance: z.ZodNumber;
24
24
  liberoReplacements: z.ZodArray<z.ZodCustom<Player, Player>>;
25
+ receiveRotationOffset: z.ZodDefault<z.ZodBoolean>;
25
26
  pinchServerSubs: z.ZodCustom<Map<Player, Player>, Map<Player, Player>>;
26
27
  }, z.core.$strip>>;
27
28
  }, z.core.$strip>;
@@ -15,12 +15,14 @@ export interface TacticsOpts {
15
15
  readonly substitutionTolerance: number;
16
16
  readonly liberoReplacements: Player[];
17
17
  readonly pinchServerSubs: Map<Player, Player>;
18
+ readonly receiveRotationOffset: boolean;
18
19
  }
19
20
  export declare class Tactics {
20
21
  readonly lineup: StartingLineup;
21
22
  readonly substitutionTolerance: number;
22
23
  readonly liberoReplacements: Player[];
23
24
  readonly pinchServerSubs: Map<Player, Player>;
25
+ readonly receiveRotationOffset: boolean;
24
26
  static create(input: unknown): Tactics;
25
27
  private constructor();
26
28
  findPlayerInLineup(id: string): CourtPosition | undefined;
@@ -13,11 +13,12 @@ export class Tactics {
13
13
  }
14
14
  return new Tactics(result.data);
15
15
  }
16
- constructor({ lineup, pinchServerSubs, liberoReplacements, substitutionTolerance = 1 }) {
16
+ constructor({ lineup, pinchServerSubs, liberoReplacements, substitutionTolerance = 1, receiveRotationOffset = false }) {
17
17
  this.lineup = lineup;
18
18
  this.substitutionTolerance = substitutionTolerance;
19
19
  this.pinchServerSubs = pinchServerSubs;
20
20
  this.liberoReplacements = liberoReplacements;
21
+ this.receiveRotationOffset = receiveRotationOffset;
21
22
  }
22
23
  findPlayerInLineup(id) {
23
24
  if (this.lineup.bench.some(p => p.id === id))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "volleyballsimtypes",
3
- "version": "0.0.387",
3
+ "version": "0.0.389",
4
4
  "description": "vbsim types",
5
5
  "main": "./dist/cjs/src/index.js",
6
6
  "module": "./dist/esm/src/index.js",