volleyballsimtypes 0.0.529 → 0.0.531

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 (45) hide show
  1. package/dist/cjs/src/data/models/auth-user.d.ts +8 -2
  2. package/dist/cjs/src/data/models/auth-user.js +53 -5
  3. package/dist/cjs/src/data/transformers/rally-event-injury.test.js +39 -0
  4. package/dist/cjs/src/data/transformers/rally-event.d.ts +1 -0
  5. package/dist/cjs/src/data/transformers/rally-event.js +12 -8
  6. package/dist/cjs/src/service/event/in-play-event.d.ts +2 -1
  7. package/dist/cjs/src/service/event/schemas/block.z.d.ts +1 -0
  8. package/dist/cjs/src/service/event/schemas/incident.z.d.ts +1 -0
  9. package/dist/cjs/src/service/event/schemas/incident.z.js +4 -1
  10. package/dist/cjs/src/service/event/schemas/reception.z.d.ts +1 -0
  11. package/dist/cjs/src/service/event/schemas/serve.z.d.ts +1 -0
  12. package/dist/cjs/src/service/event/schemas/set.z.d.ts +1 -0
  13. package/dist/cjs/src/service/event/schemas/spike.z.d.ts +1 -0
  14. package/dist/cjs/src/service/match/match-rating.d.ts +13 -1
  15. package/dist/cjs/src/service/match/match-rating.js +29 -13
  16. package/dist/cjs/src/service/match/match-rating.test.js +53 -0
  17. package/dist/cjs/src/service/utils/email-utils.d.ts +12 -0
  18. package/dist/cjs/src/service/utils/email-utils.js +43 -0
  19. package/dist/cjs/src/service/utils/email-utils.test.d.ts +1 -0
  20. package/dist/cjs/src/service/utils/email-utils.test.js +52 -0
  21. package/dist/cjs/src/service/utils/index.d.ts +1 -0
  22. package/dist/cjs/src/service/utils/index.js +1 -0
  23. package/dist/esm/src/data/models/auth-user.d.ts +8 -2
  24. package/dist/esm/src/data/models/auth-user.js +20 -5
  25. package/dist/esm/src/data/transformers/rally-event-injury.test.js +39 -0
  26. package/dist/esm/src/data/transformers/rally-event.d.ts +1 -0
  27. package/dist/esm/src/data/transformers/rally-event.js +12 -8
  28. package/dist/esm/src/service/event/in-play-event.d.ts +2 -1
  29. package/dist/esm/src/service/event/schemas/block.z.d.ts +1 -0
  30. package/dist/esm/src/service/event/schemas/incident.z.d.ts +1 -0
  31. package/dist/esm/src/service/event/schemas/incident.z.js +4 -1
  32. package/dist/esm/src/service/event/schemas/reception.z.d.ts +1 -0
  33. package/dist/esm/src/service/event/schemas/serve.z.d.ts +1 -0
  34. package/dist/esm/src/service/event/schemas/set.z.d.ts +1 -0
  35. package/dist/esm/src/service/event/schemas/spike.z.d.ts +1 -0
  36. package/dist/esm/src/service/match/match-rating.d.ts +13 -1
  37. package/dist/esm/src/service/match/match-rating.js +29 -13
  38. package/dist/esm/src/service/match/match-rating.test.js +53 -0
  39. package/dist/esm/src/service/utils/email-utils.d.ts +12 -0
  40. package/dist/esm/src/service/utils/email-utils.js +40 -0
  41. package/dist/esm/src/service/utils/email-utils.test.d.ts +1 -0
  42. package/dist/esm/src/service/utils/email-utils.test.js +50 -0
  43. package/dist/esm/src/service/utils/index.d.ts +1 -0
  44. package/dist/esm/src/service/utils/index.js +1 -0
  45. package/package.json +1 -1
@@ -12,12 +12,16 @@ export interface AuthUserAttributes {
12
12
  last_login_at?: Date | null;
13
13
  created_at?: Date;
14
14
  updated_at?: Date;
15
+ /** Comparison form of `email` (see canonicalEmail). Uniqueness is enforced on THIS, not on `email`. */
16
+ canonical_email?: string | null;
17
+ /** When the account was terminated. Also the clock the 4-month data purge runs off. */
18
+ terminated_at?: Date | null;
15
19
  }
16
20
  export type AuthUserPk = 'user_id';
17
21
  export type AuthUserId = AuthUserModel[AuthUserPk];
18
22
  export type AuthUserRole = 'ADMIN' | 'PLAYER';
19
- export type AuthUserStatus = 'ACTIVE' | 'DISABLED' | 'PENDING';
20
- export type AuthUserOptionalAttributes = 'role' | 'display_name' | 'avatar_url' | 'status' | 'email_verified_at' | 'last_login_at' | 'created_at' | 'updated_at';
23
+ export type AuthUserStatus = 'ACTIVE' | 'DISABLED' | 'PENDING' | 'TERMINATED';
24
+ export type AuthUserOptionalAttributes = 'role' | 'display_name' | 'avatar_url' | 'status' | 'email_verified_at' | 'last_login_at' | 'created_at' | 'updated_at' | 'canonical_email' | 'terminated_at';
21
25
  export type AuthUserCreationAttributes = Optional<AuthUserAttributes, AuthUserOptionalAttributes>;
22
26
  export declare class AuthUserModel extends Model<AuthUserAttributes, AuthUserCreationAttributes> implements AuthUserAttributes {
23
27
  user_id: string;
@@ -30,6 +34,8 @@ export declare class AuthUserModel extends Model<AuthUserAttributes, AuthUserCre
30
34
  last_login_at?: Date | null;
31
35
  created_at?: Date;
32
36
  updated_at?: Date;
37
+ canonical_email?: string | null;
38
+ terminated_at?: Date | null;
33
39
  AuthIdentities: AuthIdentityModel[];
34
40
  getAuthIdentities: Sequelize.HasManyGetAssociationsMixin<AuthIdentityModel>;
35
41
  setAuthIdentities: Sequelize.HasManySetAssociationsMixin<AuthIdentityModel, AuthIdentityId>;
@@ -1,6 +1,40 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
2
35
  Object.defineProperty(exports, "__esModule", { value: true });
3
36
  exports.AuthUserModel = void 0;
37
+ const Sequelize = __importStar(require("sequelize"));
4
38
  const sequelize_1 = require("sequelize");
5
39
  class AuthUserModel extends sequelize_1.Model {
6
40
  static initModel(sequelize) {
@@ -12,8 +46,7 @@ class AuthUserModel extends sequelize_1.Model {
12
46
  },
13
47
  email: {
14
48
  type: sequelize_1.DataTypes.STRING,
15
- allowNull: false,
16
- unique: 'AuthUser_email_uq'
49
+ allowNull: false
17
50
  },
18
51
  role: {
19
52
  type: sequelize_1.DataTypes.ENUM('ADMIN', 'PLAYER'),
@@ -29,7 +62,7 @@ class AuthUserModel extends sequelize_1.Model {
29
62
  allowNull: true
30
63
  },
31
64
  status: {
32
- type: sequelize_1.DataTypes.ENUM('ACTIVE', 'DISABLED', 'PENDING'),
65
+ type: sequelize_1.DataTypes.ENUM('ACTIVE', 'DISABLED', 'PENDING', 'TERMINATED'),
33
66
  allowNull: false,
34
67
  defaultValue: 'PENDING'
35
68
  },
@@ -50,6 +83,14 @@ class AuthUserModel extends sequelize_1.Model {
50
83
  type: sequelize_1.DataTypes.DATE,
51
84
  allowNull: false,
52
85
  defaultValue: sequelize_1.DataTypes.NOW
86
+ },
87
+ canonical_email: {
88
+ type: sequelize_1.DataTypes.STRING,
89
+ allowNull: true
90
+ },
91
+ terminated_at: {
92
+ type: sequelize_1.DataTypes.DATE,
93
+ allowNull: true
53
94
  }
54
95
  }, {
55
96
  sequelize,
@@ -63,9 +104,16 @@ class AuthUserModel extends sequelize_1.Model {
63
104
  fields: [{ name: 'user_id' }]
64
105
  },
65
106
  {
66
- name: 'AuthUser_email_uq',
107
+ name: 'AuthUser_email_live_uq',
108
+ unique: true,
109
+ fields: [{ name: 'email' }],
110
+ where: { status: { [Sequelize.Op.ne]: 'TERMINATED' } }
111
+ },
112
+ {
113
+ name: 'AuthUser_canonical_email_live_uq',
67
114
  unique: true,
68
- fields: [{ name: 'email' }]
115
+ fields: [{ name: 'canonical_email' }],
116
+ where: { status: { [Sequelize.Op.ne]: 'TERMINATED' } }
69
117
  }
70
118
  ]
71
119
  });
@@ -103,6 +103,45 @@ const rally_event_1 = require("./rally-event");
103
103
  const decoded = (0, rally_event_1.transformToSpike)(compact, roster);
104
104
  (0, globals_1.expect)(decoded.incident).toEqual({ kind: 'INJURY', severity: 2 });
105
105
  });
106
+ // The off-ball cause (2026-09-01). An attacker making a fake spike and a blocker whose block attempt never
107
+ // touched the ball both jump, both can get hurt, and neither emits an event, so their incident rides another
108
+ // event and 'n' is the only thing saying what they were actually doing. Lose it and the feed says they spiked.
109
+ (0, globals_1.it)('packs the off-ball cause under n and round-trips it alongside the hurt player', () => {
110
+ const hitter = (0, uuid_1.v4)();
111
+ const faker = (0, uuid_1.v4)();
112
+ const pairRoster = [hitter, faker];
113
+ const pairIndex = new Map(pairRoster.map((id, i) => [id, i]));
114
+ const spike = service_1.Spike.create({
115
+ playerId: hitter,
116
+ score: 62.5,
117
+ target: 4,
118
+ failure: service_1.SpikeFailureEnum.NO_FAILURE,
119
+ type: service_1.SpikeTypeEnum.SPIKE,
120
+ incident: { kind: 'INJURY', severity: 4, playerId: faker, cause: service_1.EventTypeEnum.SPIKE }
121
+ });
122
+ const compact = (0, rally_event_1.transformToCompact)(spike, pairIndex);
123
+ (0, globals_1.expect)(compact.h).toBe(1);
124
+ (0, globals_1.expect)(compact.n).toBe(service_1.EventTypeEnum.SPIKE);
125
+ const decoded = (0, rally_event_1.transformToSpike)(compact, pairRoster);
126
+ (0, globals_1.expect)(decoded.incident).toEqual({ kind: 'INJURY', severity: 4, playerId: faker, cause: service_1.EventTypeEnum.SPIKE });
127
+ });
128
+ (0, globals_1.it)('round-trips a BLOCK cause, which is the other action that emits no event', () => {
129
+ const compact = (0, rally_event_1.transformToCompact)(makeSpike({ kind: 'KNOCK', severity: 1, playerId, cause: service_1.EventTypeEnum.BLOCK }), playerIndex);
130
+ (0, globals_1.expect)(compact.n).toBe(service_1.EventTypeEnum.BLOCK);
131
+ const decoded = (0, rally_event_1.transformToSpike)(compact, roster);
132
+ (0, globals_1.expect)(decoded.incident?.cause).toBe(service_1.EventTypeEnum.BLOCK);
133
+ });
134
+ (0, globals_1.it)('omits n for an ordinary incident, so nothing grows for the common case', () => {
135
+ const compact = (0, rally_event_1.transformToCompact)(makeSpike({ kind: 'INJURY', severity: 2 }), playerIndex);
136
+ (0, globals_1.expect)('n' in compact).toBe(false);
137
+ const decoded = (0, rally_event_1.transformToSpike)(compact, roster);
138
+ (0, globals_1.expect)(decoded.incident?.cause).toBeUndefined();
139
+ });
140
+ (0, globals_1.it)('decodes a legacy compact event (no n key) with no cause', () => {
141
+ const legacy = { p: 0, e: service_1.EventTypeEnum.SPIKE, f: 0, t: 0, a: 4, s: 50, i: 3 };
142
+ const decoded = (0, rally_event_1.transformToSpike)(legacy, roster);
143
+ (0, globals_1.expect)(decoded.incident).toEqual({ kind: 'INJURY', severity: 3 });
144
+ });
106
145
  (0, globals_1.it)('rejects an out-of-range or malformed incident at the schema', () => {
107
146
  (0, globals_1.expect)(() => makeSpike({ kind: 'INJURY', severity: 5 })).toThrow(/INVALID_SPIKE/);
108
147
  (0, globals_1.expect)(() => makeSpike({ kind: 'INJURY', severity: 0 })).toThrow(/INVALID_SPIKE/);
@@ -12,6 +12,7 @@ export interface CompactEvent {
12
12
  j?: number;
13
13
  i?: number;
14
14
  h?: number;
15
+ n?: number;
15
16
  m?: number;
16
17
  c?: number[];
17
18
  g?: number[];
@@ -30,13 +30,15 @@ function incidentToCompact(incident) {
30
30
  return undefined;
31
31
  return incident.kind === 'KNOCK' ? 10 + incident.severity : incident.severity;
32
32
  }
33
- function incidentFromCompact(i, h, roster) {
33
+ function incidentFromCompact(e, roster) {
34
+ const i = e.i;
34
35
  if (i == null)
35
36
  return undefined;
36
- const playerId = h != null && roster != null ? roster[h] : undefined;
37
+ const playerId = e.h != null && roster != null ? roster[e.h] : undefined;
37
38
  return {
38
39
  ...(i >= 10 ? { kind: 'KNOCK', severity: i - 10 } : { kind: 'INJURY', severity: i }),
39
- ...(playerId != null ? { playerId } : {})
40
+ ...(playerId != null ? { playerId } : {}),
41
+ ...(e.n != null ? { cause: e.n } : {})
40
42
  };
41
43
  }
42
44
  function transformToCompact(evt, playerIndex) {
@@ -55,6 +57,8 @@ function transformToCompact(evt, playerIndex) {
55
57
  if (evt.incident?.playerId != null && evt.incident.playerId !== evt.playerId) {
56
58
  out.h = playerIndex.get(evt.incident.playerId);
57
59
  }
60
+ if (evt.incident?.cause != null)
61
+ out.n = evt.incident.cause;
58
62
  }
59
63
  if (evt.contact != null)
60
64
  out.c = [evt.contact.x, evt.contact.y];
@@ -90,7 +94,7 @@ function transformToBlock(event, roster) {
90
94
  target: event.a,
91
95
  blockers: (event.b ?? []).map(i => roster[i]),
92
96
  score: event.s,
93
- incident: incidentFromCompact(event.i, event.h, roster),
97
+ incident: incidentFromCompact(event, roster),
94
98
  ...positionalFromCompact(event)
95
99
  });
96
100
  }
@@ -108,7 +112,7 @@ function transformToReception(event, roster) {
108
112
  playerId: roster[event.p],
109
113
  target: event.a,
110
114
  score: event.s,
111
- incident: incidentFromCompact(event.i, event.h, roster),
115
+ incident: incidentFromCompact(event, roster),
112
116
  ...positionalFromCompact(event)
113
117
  });
114
118
  }
@@ -119,7 +123,7 @@ function transformToServe(event, roster) {
119
123
  playerId: roster[event.p],
120
124
  target: event.a,
121
125
  score: event.s,
122
- incident: incidentFromCompact(event.i, event.h, roster),
126
+ incident: incidentFromCompact(event, roster),
123
127
  ...positionalFromCompact(event)
124
128
  });
125
129
  }
@@ -130,7 +134,7 @@ function transformToSet(event, roster) {
130
134
  playerId: roster[event.p],
131
135
  target: event.a,
132
136
  score: event.s,
133
- incident: incidentFromCompact(event.i, event.h, roster),
137
+ incident: incidentFromCompact(event, roster),
134
138
  tempo: event.m,
135
139
  ...positionalFromCompact(event)
136
140
  });
@@ -144,7 +148,7 @@ function transformToSpike(event, roster) {
144
148
  playerId: roster[event.p],
145
149
  target: event.a,
146
150
  score: event.s,
147
- incident: incidentFromCompact(event.i, event.h, roster),
151
+ incident: incidentFromCompact(event, roster),
148
152
  ...positionalFromCompact(event)
149
153
  });
150
154
  }
@@ -1,10 +1,11 @@
1
1
  import { CourtTarget } from '../match';
2
- import { RallyEvent, RallyEventOpts } from './rally-event';
2
+ import { EventType, RallyEvent, RallyEventOpts } from './rally-event';
3
3
  import { Trait } from '../player';
4
4
  export interface EventIncident {
5
5
  readonly kind: 'KNOCK' | 'INJURY';
6
6
  readonly severity: number;
7
7
  readonly playerId?: string;
8
+ readonly cause?: EventType;
8
9
  }
9
10
  export declare enum DigQualityEnum {
10
11
  PERFECT = 0,
@@ -44,6 +44,7 @@ export declare const BlockInputSchema: z.ZodObject<{
44
44
  }>;
45
45
  severity: z.ZodNumber;
46
46
  playerId: z.ZodOptional<z.ZodUUID>;
47
+ cause: z.ZodOptional<z.ZodNumber>;
47
48
  }, z.core.$strip>>;
48
49
  }, z.core.$strip>;
49
50
  export type BlockInput = z.infer<typeof BlockInputSchema>;
@@ -6,5 +6,6 @@ export declare const EventIncidentSchema: z.ZodObject<{
6
6
  }>;
7
7
  severity: z.ZodNumber;
8
8
  playerId: z.ZodOptional<z.ZodUUID>;
9
+ cause: z.ZodOptional<z.ZodNumber>;
9
10
  }, z.core.$strip>;
10
11
  export type EventIncidentInput = z.infer<typeof EventIncidentSchema>;
@@ -10,5 +10,8 @@ exports.EventIncidentSchema = zod_1.z.object({
10
10
  severity: zod_1.z.number().int().min(1).max(4),
11
11
  // Who got hurt, ONLY when it is not the event's actor (a secondary blocker on a multi-player block);
12
12
  // absent = the event's own playerId.
13
- playerId: zod_1.z.uuid().optional()
13
+ playerId: zod_1.z.uuid().optional(),
14
+ // The action the hurt player was performing (an EventType), set only when this event is not their own action
15
+ // and does not list them: SPIKE = a fake spike, BLOCK = a block attempt that never touched the ball.
16
+ cause: zod_1.z.number().int().min(0).max(7).optional()
14
17
  }).refine(v => v.kind !== 'KNOCK' || v.severity <= 3, { message: 'INVALID_KNOCK_TIER' });
@@ -43,6 +43,7 @@ export declare const ReceptionInputSchema: z.ZodObject<{
43
43
  }>;
44
44
  severity: z.ZodNumber;
45
45
  playerId: z.ZodOptional<z.ZodUUID>;
46
+ cause: z.ZodOptional<z.ZodNumber>;
46
47
  }, z.core.$strip>>;
47
48
  }, z.core.$strip>;
48
49
  export type ReceptionInput = z.infer<typeof ReceptionInputSchema>;
@@ -43,6 +43,7 @@ export declare const ServeInputSchema: z.ZodObject<{
43
43
  }>;
44
44
  severity: z.ZodNumber;
45
45
  playerId: z.ZodOptional<z.ZodUUID>;
46
+ cause: z.ZodOptional<z.ZodNumber>;
46
47
  }, z.core.$strip>>;
47
48
  }, z.core.$strip>;
48
49
  export type ServeInput = z.infer<typeof ServeInputSchema>;
@@ -43,6 +43,7 @@ export declare const SetInputSchema: z.ZodObject<{
43
43
  }>;
44
44
  severity: z.ZodNumber;
45
45
  playerId: z.ZodOptional<z.ZodUUID>;
46
+ cause: z.ZodOptional<z.ZodNumber>;
46
47
  }, z.core.$strip>>;
47
48
  tempo: z.ZodOptional<z.ZodNumber>;
48
49
  }, z.core.$strip>;
@@ -43,6 +43,7 @@ export declare const SpikeInputSchema: z.ZodObject<{
43
43
  }>;
44
44
  severity: z.ZodNumber;
45
45
  playerId: z.ZodOptional<z.ZodUUID>;
46
+ cause: z.ZodOptional<z.ZodNumber>;
46
47
  }, z.core.$strip>>;
47
48
  }, z.core.$strip>;
48
49
  export type SpikeInput = z.infer<typeof SpikeInputSchema>;
@@ -1,4 +1,4 @@
1
- import { Match, MatchTeam } from '.';
1
+ import { Match, MatchScore, MatchTeam } from '.';
2
2
  import { MatchContext } from './schemas/match-rating.z';
3
3
  export type { MatchContext };
4
4
  interface SetScoreVariant {
@@ -28,6 +28,18 @@ export declare class MatchRating {
28
28
  private static applyBotPenalty;
29
29
  private static computeWeight;
30
30
  static calculateProbability(z: number): number;
31
+ /**
32
+ * The FIVB model's probability of each of the six possible set scores, from the rating gap alone.
33
+ *
34
+ * getExpectedResult() has always computed these six numbers and immediately collapsed them into one weighted
35
+ * average. They are exposed here because the SAME distribution is what the Sim draws from to resolve a
36
+ * bot-vs-bot match without simulating it: the model that GRADES a result is then also the model that produces
37
+ * it, so a shortcut result can never disagree with the rating it earns. Extracted rather than duplicated,
38
+ * because a second copy of the cut-points would drift silently.
39
+ *
40
+ * Sums to 1 (P6 is the remainder), and is ordered best-to-worst FOR THE HOME TEAM.
41
+ */
42
+ static outcomeProbabilities(homeRating: number, awayRating: number): Record<MatchScore, number>;
31
43
  getExpectedResult(): number;
32
44
  getPoints(team: MatchTeam): number;
33
45
  }
@@ -42,20 +42,36 @@ class MatchRating {
42
42
  static calculateProbability(z) {
43
43
  return 0.5 * (1 + (0, utils_1.erf)(z / Math.SQRT2));
44
44
  }
45
+ /**
46
+ * The FIVB model's probability of each of the six possible set scores, from the rating gap alone.
47
+ *
48
+ * getExpectedResult() has always computed these six numbers and immediately collapsed them into one weighted
49
+ * average. They are exposed here because the SAME distribution is what the Sim draws from to resolve a
50
+ * bot-vs-bot match without simulating it: the model that GRADES a result is then also the model that produces
51
+ * it, so a shortcut result can never disagree with the rating it earns. Extracted rather than duplicated,
52
+ * because a second copy of the cut-points would drift silently.
53
+ *
54
+ * Sums to 1 (P6 is the remainder), and is ordered best-to-worst FOR THE HOME TEAM.
55
+ */
56
+ static outcomeProbabilities(homeRating, awayRating) {
57
+ const D = MatchRating.K * (homeRating - awayRating) / 1000;
58
+ return {
59
+ '3-0': MatchRating.calculateProbability(MatchRating.C1 + D),
60
+ '3-1': MatchRating.calculateProbability(MatchRating.C2 + D) - MatchRating.calculateProbability(MatchRating.C1 + D),
61
+ '3-2': MatchRating.calculateProbability(MatchRating.C3 + D) - MatchRating.calculateProbability(MatchRating.C2 + D),
62
+ '2-3': MatchRating.calculateProbability(MatchRating.C4 + D) - MatchRating.calculateProbability(MatchRating.C3 + D),
63
+ '1-3': MatchRating.calculateProbability(MatchRating.C5 + D) - MatchRating.calculateProbability(MatchRating.C4 + D),
64
+ '0-3': 1 - MatchRating.calculateProbability(MatchRating.C5 + D)
65
+ };
66
+ }
45
67
  getExpectedResult() {
46
- const D = MatchRating.K * (this.match.homeTeam.rating - this.match.awayTeam.rating) / 1000;
47
- const P1 = MatchRating.calculateProbability(MatchRating.C1 + D);
48
- const P2 = MatchRating.calculateProbability(MatchRating.C2 + D) - MatchRating.calculateProbability(MatchRating.C1 + D);
49
- const P3 = MatchRating.calculateProbability(MatchRating.C3 + D) - MatchRating.calculateProbability(MatchRating.C2 + D);
50
- const P4 = MatchRating.calculateProbability(MatchRating.C4 + D) - MatchRating.calculateProbability(MatchRating.C3 + D);
51
- const P5 = MatchRating.calculateProbability(MatchRating.C5 + D) - MatchRating.calculateProbability(MatchRating.C4 + D);
52
- const P6 = 1 - MatchRating.calculateProbability(MatchRating.C5 + D);
53
- return P1 * MatchRating.SSV['3-0'] +
54
- P2 * MatchRating.SSV['3-1'] +
55
- P3 * MatchRating.SSV['3-2'] +
56
- P4 * MatchRating.SSV['2-3'] +
57
- P5 * MatchRating.SSV['1-3'] +
58
- P6 * MatchRating.SSV['0-3'];
68
+ const P = MatchRating.outcomeProbabilities(this.match.homeTeam.rating, this.match.awayTeam.rating);
69
+ return P['3-0'] * MatchRating.SSV['3-0'] +
70
+ P['3-1'] * MatchRating.SSV['3-1'] +
71
+ P['3-2'] * MatchRating.SSV['3-2'] +
72
+ P['2-3'] * MatchRating.SSV['2-3'] +
73
+ P['1-3'] * MatchRating.SSV['1-3'] +
74
+ P['0-3'] * MatchRating.SSV['0-3'];
59
75
  }
60
76
  getPoints(team) {
61
77
  const homeScoreKey = this.match.getScore();
@@ -91,6 +91,59 @@ const number_utils_1 = require("../utils/number-utils");
91
91
  (0, globals_1.expect)(mr1.getExpectedResult()).toBeCloseTo(-mr2.getExpectedResult(), 8);
92
92
  });
93
93
  });
94
+ // ─── outcomeProbabilities ─────────────────────────────────────────────────────
95
+ (0, globals_1.describe)('MatchRating.outcomeProbabilities()', () => {
96
+ const SCORES = ['3-0', '3-1', '3-2', '2-3', '1-3', '0-3'];
97
+ (0, globals_1.it)('sums to 1 for any rating gap', () => {
98
+ for (const [h, a] of [[1000, 1000], [1500, 1000], [1000, 1500], [100, 100], [130, 70]]) {
99
+ const P = match_rating_1.MatchRating.outcomeProbabilities(h, a);
100
+ const total = SCORES.reduce((sum, key) => sum + P[key], 0);
101
+ (0, globals_1.expect)(total).toBeCloseTo(1, 10);
102
+ }
103
+ });
104
+ (0, globals_1.it)('is never negative', () => {
105
+ for (const [h, a] of [[1000, 1000], [5000, 100], [100, 5000]]) {
106
+ const P = match_rating_1.MatchRating.outcomeProbabilities(h, a);
107
+ for (const key of SCORES)
108
+ (0, globals_1.expect)(P[key]).toBeGreaterThanOrEqual(0);
109
+ }
110
+ });
111
+ (0, globals_1.it)('is symmetric between the two sides at equal rating', () => {
112
+ // 8 rather than 10 decimals: erf() is a series approximation, so the mirrored buckets agree to ~1e-9.
113
+ const P = match_rating_1.MatchRating.outcomeProbabilities(100, 100);
114
+ (0, globals_1.expect)(P['3-0']).toBeCloseTo(P['0-3'], 8);
115
+ (0, globals_1.expect)(P['3-1']).toBeCloseTo(P['1-3'], 8);
116
+ (0, globals_1.expect)(P['3-2']).toBeCloseTo(P['2-3'], 8);
117
+ });
118
+ (0, globals_1.it)('mirrors when the gap is reversed', () => {
119
+ const P = match_rating_1.MatchRating.outcomeProbabilities(130, 70);
120
+ const Q = match_rating_1.MatchRating.outcomeProbabilities(70, 130);
121
+ (0, globals_1.expect)(P['3-0']).toBeCloseTo(Q['0-3'], 8);
122
+ (0, globals_1.expect)(P['3-1']).toBeCloseTo(Q['1-3'], 8);
123
+ (0, globals_1.expect)(P['3-2']).toBeCloseTo(Q['2-3'], 8);
124
+ });
125
+ (0, globals_1.it)('shifts weight toward the stronger side as the gap widens', () => {
126
+ const even = match_rating_1.MatchRating.outcomeProbabilities(100, 100);
127
+ const wide = match_rating_1.MatchRating.outcomeProbabilities(160, 40);
128
+ (0, globals_1.expect)(wide['3-0']).toBeGreaterThan(even['3-0']);
129
+ (0, globals_1.expect)(wide['0-3']).toBeLessThan(even['0-3']);
130
+ });
131
+ // The extraction guarantee: getExpectedResult() must still be the SSV-weighted sum of exactly these six
132
+ // numbers. If someone changes one and not the other, this fails.
133
+ (0, globals_1.it)('reproduces getExpectedResult() as the SSV-weighted sum', () => {
134
+ for (const [h, a] of [[1000, 1000], [1500, 1000], [1000, 1500], [130, 70]]) {
135
+ const home = (0, test_helpers_1.makeTeam)({ rating: h });
136
+ const away = (0, test_helpers_1.makeTeam)({ rating: a });
137
+ const mr = match_rating_1.MatchRating.create({
138
+ match: (0, test_helpers_1.makeMatch30)(home, away),
139
+ context: { kind: 'LEAGUE', tier: 1, homeIsBot: false, awayIsBot: false }
140
+ });
141
+ const P = match_rating_1.MatchRating.outcomeProbabilities(h, a);
142
+ const expected = SCORES.reduce((sum, key) => sum + P[key] * match_rating_1.MatchRating.SSV[key], 0);
143
+ (0, globals_1.expect)(mr.getExpectedResult()).toBeCloseTo(expected, 10);
144
+ }
145
+ });
146
+ });
94
147
  // ─── getPoints ────────────────────────────────────────────────────────────────
95
148
  (0, globals_1.describe)('MatchRating.getPoints()', () => {
96
149
  (0, globals_1.it)('home and away deltas are opposite signs', () => {
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The comparison form of an email address. Lowercased and trimmed; the local part loses any `+tag`; on Gmail it
3
+ * also loses its dots and the domain folds onto gmail.com.
4
+ *
5
+ * Split local from domain FIRST. Splitting the whole address on '+' looks equivalent and is not: an address
6
+ * with no tag comes back whole, and a later dot-strip then eats the dot in "gmail.com" too.
7
+ *
8
+ * Returns the trimmed lowercase input unchanged when it is not a single-@ address, so a malformed value is
9
+ * never silently reshaped into something that could collide with a real account. Format validation is the
10
+ * caller's job.
11
+ */
12
+ export declare function canonicalEmail(raw: string): string;
@@ -0,0 +1,43 @@
1
+ "use strict";
2
+ // Email identity helpers.
3
+ //
4
+ // A person must not be able to hold two accounts on one mailbox by spelling the same address differently. The
5
+ // canonical form is what uniqueness is enforced on; the raw address the user typed is still what we display and
6
+ // send mail to.
7
+ //
8
+ // Owner decision 2026-09-01: GMAIL RULES FOR GMAIL ONLY. Dots are insignificant and +tags are aliases on
9
+ // Gmail, so both are stripped there. Applying either rule to every provider would WRONGLY merge two real
10
+ // people on providers where the local part is taken literally, so everyone else gets lowercasing and +tag
11
+ // removal alone (+tag aliasing is near-universal; dot-insignificance is not).
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.canonicalEmail = canonicalEmail;
14
+ /** Domains that alias onto gmail.com and follow its dot/plus rules. */
15
+ const GMAIL_DOMAINS = new Set(['gmail.com', 'googlemail.com']);
16
+ /**
17
+ * The comparison form of an email address. Lowercased and trimmed; the local part loses any `+tag`; on Gmail it
18
+ * also loses its dots and the domain folds onto gmail.com.
19
+ *
20
+ * Split local from domain FIRST. Splitting the whole address on '+' looks equivalent and is not: an address
21
+ * with no tag comes back whole, and a later dot-strip then eats the dot in "gmail.com" too.
22
+ *
23
+ * Returns the trimmed lowercase input unchanged when it is not a single-@ address, so a malformed value is
24
+ * never silently reshaped into something that could collide with a real account. Format validation is the
25
+ * caller's job.
26
+ */
27
+ function canonicalEmail(raw) {
28
+ const trimmed = raw.trim().toLowerCase();
29
+ const at = trimmed.lastIndexOf('@');
30
+ if (at <= 0 || at === trimmed.length - 1 || trimmed.indexOf('@') !== at)
31
+ return trimmed;
32
+ const local = trimmed.slice(0, at);
33
+ const domain = trimmed.slice(at + 1);
34
+ const untagged = local.split('+')[0];
35
+ // An address that is nothing BUT a tag and/or dots has no local part left once they are stripped. Fall back to
36
+ // the ORIGINAL local part, never to the stripped one: an empty local would canonicalize every degenerate
37
+ // address to the same value and collide them with each other.
38
+ if (GMAIL_DOMAINS.has(domain)) {
39
+ const noDots = untagged.split('.').join('');
40
+ return `${noDots.length > 0 ? noDots : local}@gmail.com`;
41
+ }
42
+ return `${untagged.length > 0 ? untagged : local}@${domain}`;
43
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,52 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const globals_1 = require("@jest/globals");
4
+ const email_utils_1 = require("./email-utils");
5
+ // ─── canonicalEmail ──────────────────────────────────────────────────────────
6
+ (0, globals_1.describe)('canonicalEmail()', () => {
7
+ (0, globals_1.it)('lowercases and trims', () => {
8
+ (0, globals_1.expect)((0, email_utils_1.canonicalEmail)(' Foo.Bar@Example.COM ')).toBe('foo.bar@example.com');
9
+ });
10
+ (0, globals_1.it)('strips a +tag on any provider', () => {
11
+ (0, globals_1.expect)((0, email_utils_1.canonicalEmail)('user+anything@example.com')).toBe('user@example.com');
12
+ });
13
+ (0, globals_1.it)('keeps dots on non-Gmail providers, where they are significant', () => {
14
+ (0, globals_1.expect)((0, email_utils_1.canonicalEmail)('a.b@example.com')).toBe('a.b@example.com');
15
+ (0, globals_1.expect)((0, email_utils_1.canonicalEmail)('ab@example.com')).toBe('ab@example.com');
16
+ (0, globals_1.expect)((0, email_utils_1.canonicalEmail)('a.b@example.com')).not.toBe((0, email_utils_1.canonicalEmail)('ab@example.com'));
17
+ });
18
+ (0, globals_1.it)('strips dots AND +tags on gmail', () => {
19
+ (0, globals_1.expect)((0, email_utils_1.canonicalEmail)('a.b.c+tag@gmail.com')).toBe('abc@gmail.com');
20
+ (0, globals_1.expect)((0, email_utils_1.canonicalEmail)('abc@gmail.com')).toBe('abc@gmail.com');
21
+ (0, globals_1.expect)((0, email_utils_1.canonicalEmail)('a.b.c@gmail.com')).toBe((0, email_utils_1.canonicalEmail)('abc+anything@gmail.com'));
22
+ });
23
+ (0, globals_1.it)('folds googlemail.com onto gmail.com', () => {
24
+ (0, globals_1.expect)((0, email_utils_1.canonicalEmail)('a.b@googlemail.com')).toBe('ab@gmail.com');
25
+ (0, globals_1.expect)((0, email_utils_1.canonicalEmail)('ab@googlemail.com')).toBe((0, email_utils_1.canonicalEmail)('ab@gmail.com'));
26
+ });
27
+ // The exact bug the first prod duplicate report hit: splitting the WHOLE address on '+' returns the address
28
+ // intact when there is no tag, and the Gmail dot-strip then eats the dot in "gmail.com" as well.
29
+ (0, globals_1.it)('does not mangle a Gmail address that has no +tag', () => {
30
+ (0, globals_1.expect)((0, email_utils_1.canonicalEmail)('fariasfranciscoe@gmail.com')).toBe('fariasfranciscoe@gmail.com');
31
+ (0, globals_1.expect)((0, email_utils_1.canonicalEmail)('fariasfranciscoe@gmail.com')).not.toContain('gmailcom');
32
+ });
33
+ (0, globals_1.it)('matches the real prod collision it was written for', () => {
34
+ (0, globals_1.expect)((0, email_utils_1.canonicalEmail)('fariasfranciscoe+playreview@gmail.com'))
35
+ .toBe((0, email_utils_1.canonicalEmail)('fariasfranciscoe@gmail.com'));
36
+ });
37
+ (0, globals_1.it)('leaves a malformed address alone rather than reshaping it', () => {
38
+ (0, globals_1.expect)((0, email_utils_1.canonicalEmail)('not-an-email')).toBe('not-an-email');
39
+ (0, globals_1.expect)((0, email_utils_1.canonicalEmail)('@example.com')).toBe('@example.com');
40
+ (0, globals_1.expect)((0, email_utils_1.canonicalEmail)('user@')).toBe('user@');
41
+ (0, globals_1.expect)((0, email_utils_1.canonicalEmail)('two@ats@example.com')).toBe('two@ats@example.com');
42
+ });
43
+ (0, globals_1.it)('never returns an empty local part for a degenerate address', () => {
44
+ (0, globals_1.expect)((0, email_utils_1.canonicalEmail)('+tag@gmail.com')).toBe('+tag@gmail.com');
45
+ (0, globals_1.expect)((0, email_utils_1.canonicalEmail)('...@gmail.com')).toBe('...@gmail.com');
46
+ });
47
+ (0, globals_1.it)('is idempotent', () => {
48
+ for (const raw of ['A.B+x@Gmail.com', 'user+t@example.com', 'plain@example.com', 'weird']) {
49
+ (0, globals_1.expect)((0, email_utils_1.canonicalEmail)((0, email_utils_1.canonicalEmail)(raw))).toBe((0, email_utils_1.canonicalEmail)(raw));
50
+ }
51
+ });
52
+ });
@@ -1,4 +1,5 @@
1
1
  export * from './object-utils';
2
+ export * from './email-utils';
2
3
  export * from './number-utils';
3
4
  export * from './rng-utils';
4
5
  export * from './faker-generators';
@@ -15,6 +15,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
17
  __exportStar(require("./object-utils"), exports);
18
+ __exportStar(require("./email-utils"), exports);
18
19
  __exportStar(require("./number-utils"), exports);
19
20
  __exportStar(require("./rng-utils"), exports);
20
21
  __exportStar(require("./faker-generators"), exports);
@@ -12,12 +12,16 @@ export interface AuthUserAttributes {
12
12
  last_login_at?: Date | null;
13
13
  created_at?: Date;
14
14
  updated_at?: Date;
15
+ /** Comparison form of `email` (see canonicalEmail). Uniqueness is enforced on THIS, not on `email`. */
16
+ canonical_email?: string | null;
17
+ /** When the account was terminated. Also the clock the 4-month data purge runs off. */
18
+ terminated_at?: Date | null;
15
19
  }
16
20
  export type AuthUserPk = 'user_id';
17
21
  export type AuthUserId = AuthUserModel[AuthUserPk];
18
22
  export type AuthUserRole = 'ADMIN' | 'PLAYER';
19
- export type AuthUserStatus = 'ACTIVE' | 'DISABLED' | 'PENDING';
20
- export type AuthUserOptionalAttributes = 'role' | 'display_name' | 'avatar_url' | 'status' | 'email_verified_at' | 'last_login_at' | 'created_at' | 'updated_at';
23
+ export type AuthUserStatus = 'ACTIVE' | 'DISABLED' | 'PENDING' | 'TERMINATED';
24
+ export type AuthUserOptionalAttributes = 'role' | 'display_name' | 'avatar_url' | 'status' | 'email_verified_at' | 'last_login_at' | 'created_at' | 'updated_at' | 'canonical_email' | 'terminated_at';
21
25
  export type AuthUserCreationAttributes = Optional<AuthUserAttributes, AuthUserOptionalAttributes>;
22
26
  export declare class AuthUserModel extends Model<AuthUserAttributes, AuthUserCreationAttributes> implements AuthUserAttributes {
23
27
  user_id: string;
@@ -30,6 +34,8 @@ export declare class AuthUserModel extends Model<AuthUserAttributes, AuthUserCre
30
34
  last_login_at?: Date | null;
31
35
  created_at?: Date;
32
36
  updated_at?: Date;
37
+ canonical_email?: string | null;
38
+ terminated_at?: Date | null;
33
39
  AuthIdentities: AuthIdentityModel[];
34
40
  getAuthIdentities: Sequelize.HasManyGetAssociationsMixin<AuthIdentityModel>;
35
41
  setAuthIdentities: Sequelize.HasManySetAssociationsMixin<AuthIdentityModel, AuthIdentityId>;
@@ -1,3 +1,4 @@
1
+ import * as Sequelize from 'sequelize';
1
2
  import { DataTypes, Model } from 'sequelize';
2
3
  export class AuthUserModel extends Model {
3
4
  static initModel(sequelize) {
@@ -9,8 +10,7 @@ export class AuthUserModel extends Model {
9
10
  },
10
11
  email: {
11
12
  type: DataTypes.STRING,
12
- allowNull: false,
13
- unique: 'AuthUser_email_uq'
13
+ allowNull: false
14
14
  },
15
15
  role: {
16
16
  type: DataTypes.ENUM('ADMIN', 'PLAYER'),
@@ -26,7 +26,7 @@ export class AuthUserModel extends Model {
26
26
  allowNull: true
27
27
  },
28
28
  status: {
29
- type: DataTypes.ENUM('ACTIVE', 'DISABLED', 'PENDING'),
29
+ type: DataTypes.ENUM('ACTIVE', 'DISABLED', 'PENDING', 'TERMINATED'),
30
30
  allowNull: false,
31
31
  defaultValue: 'PENDING'
32
32
  },
@@ -47,6 +47,14 @@ export class AuthUserModel extends Model {
47
47
  type: DataTypes.DATE,
48
48
  allowNull: false,
49
49
  defaultValue: DataTypes.NOW
50
+ },
51
+ canonical_email: {
52
+ type: DataTypes.STRING,
53
+ allowNull: true
54
+ },
55
+ terminated_at: {
56
+ type: DataTypes.DATE,
57
+ allowNull: true
50
58
  }
51
59
  }, {
52
60
  sequelize,
@@ -60,9 +68,16 @@ export class AuthUserModel extends Model {
60
68
  fields: [{ name: 'user_id' }]
61
69
  },
62
70
  {
63
- name: 'AuthUser_email_uq',
71
+ name: 'AuthUser_email_live_uq',
72
+ unique: true,
73
+ fields: [{ name: 'email' }],
74
+ where: { status: { [Sequelize.Op.ne]: 'TERMINATED' } }
75
+ },
76
+ {
77
+ name: 'AuthUser_canonical_email_live_uq',
64
78
  unique: true,
65
- fields: [{ name: 'email' }]
79
+ fields: [{ name: 'canonical_email' }],
80
+ where: { status: { [Sequelize.Op.ne]: 'TERMINATED' } }
66
81
  }
67
82
  ]
68
83
  });
@@ -101,6 +101,45 @@ describe('rally-event compact round trip — incident marker on the causing even
101
101
  const decoded = transformToSpike(compact, roster);
102
102
  expect(decoded.incident).toEqual({ kind: 'INJURY', severity: 2 });
103
103
  });
104
+ // The off-ball cause (2026-09-01). An attacker making a fake spike and a blocker whose block attempt never
105
+ // touched the ball both jump, both can get hurt, and neither emits an event, so their incident rides another
106
+ // event and 'n' is the only thing saying what they were actually doing. Lose it and the feed says they spiked.
107
+ it('packs the off-ball cause under n and round-trips it alongside the hurt player', () => {
108
+ const hitter = uuidv4();
109
+ const faker = uuidv4();
110
+ const pairRoster = [hitter, faker];
111
+ const pairIndex = new Map(pairRoster.map((id, i) => [id, i]));
112
+ const spike = Spike.create({
113
+ playerId: hitter,
114
+ score: 62.5,
115
+ target: 4,
116
+ failure: SpikeFailureEnum.NO_FAILURE,
117
+ type: SpikeTypeEnum.SPIKE,
118
+ incident: { kind: 'INJURY', severity: 4, playerId: faker, cause: EventTypeEnum.SPIKE }
119
+ });
120
+ const compact = transformToCompact(spike, pairIndex);
121
+ expect(compact.h).toBe(1);
122
+ expect(compact.n).toBe(EventTypeEnum.SPIKE);
123
+ const decoded = transformToSpike(compact, pairRoster);
124
+ expect(decoded.incident).toEqual({ kind: 'INJURY', severity: 4, playerId: faker, cause: EventTypeEnum.SPIKE });
125
+ });
126
+ it('round-trips a BLOCK cause, which is the other action that emits no event', () => {
127
+ const compact = transformToCompact(makeSpike({ kind: 'KNOCK', severity: 1, playerId, cause: EventTypeEnum.BLOCK }), playerIndex);
128
+ expect(compact.n).toBe(EventTypeEnum.BLOCK);
129
+ const decoded = transformToSpike(compact, roster);
130
+ expect(decoded.incident?.cause).toBe(EventTypeEnum.BLOCK);
131
+ });
132
+ it('omits n for an ordinary incident, so nothing grows for the common case', () => {
133
+ const compact = transformToCompact(makeSpike({ kind: 'INJURY', severity: 2 }), playerIndex);
134
+ expect('n' in compact).toBe(false);
135
+ const decoded = transformToSpike(compact, roster);
136
+ expect(decoded.incident?.cause).toBeUndefined();
137
+ });
138
+ it('decodes a legacy compact event (no n key) with no cause', () => {
139
+ const legacy = { p: 0, e: EventTypeEnum.SPIKE, f: 0, t: 0, a: 4, s: 50, i: 3 };
140
+ const decoded = transformToSpike(legacy, roster);
141
+ expect(decoded.incident).toEqual({ kind: 'INJURY', severity: 3 });
142
+ });
104
143
  it('rejects an out-of-range or malformed incident at the schema', () => {
105
144
  expect(() => makeSpike({ kind: 'INJURY', severity: 5 })).toThrow(/INVALID_SPIKE/);
106
145
  expect(() => makeSpike({ kind: 'INJURY', severity: 0 })).toThrow(/INVALID_SPIKE/);
@@ -12,6 +12,7 @@ export interface CompactEvent {
12
12
  j?: number;
13
13
  i?: number;
14
14
  h?: number;
15
+ n?: number;
15
16
  m?: number;
16
17
  c?: number[];
17
18
  g?: number[];
@@ -19,13 +19,15 @@ function incidentToCompact(incident) {
19
19
  return undefined;
20
20
  return incident.kind === 'KNOCK' ? 10 + incident.severity : incident.severity;
21
21
  }
22
- function incidentFromCompact(i, h, roster) {
22
+ function incidentFromCompact(e, roster) {
23
+ const i = e.i;
23
24
  if (i == null)
24
25
  return undefined;
25
- const playerId = h != null && roster != null ? roster[h] : undefined;
26
+ const playerId = e.h != null && roster != null ? roster[e.h] : undefined;
26
27
  return {
27
28
  ...(i >= 10 ? { kind: 'KNOCK', severity: i - 10 } : { kind: 'INJURY', severity: i }),
28
- ...(playerId != null ? { playerId } : {})
29
+ ...(playerId != null ? { playerId } : {}),
30
+ ...(e.n != null ? { cause: e.n } : {})
29
31
  };
30
32
  }
31
33
  export function transformToCompact(evt, playerIndex) {
@@ -44,6 +46,8 @@ export function transformToCompact(evt, playerIndex) {
44
46
  if (evt.incident?.playerId != null && evt.incident.playerId !== evt.playerId) {
45
47
  out.h = playerIndex.get(evt.incident.playerId);
46
48
  }
49
+ if (evt.incident?.cause != null)
50
+ out.n = evt.incident.cause;
47
51
  }
48
52
  if (evt.contact != null)
49
53
  out.c = [evt.contact.x, evt.contact.y];
@@ -79,7 +83,7 @@ export function transformToBlock(event, roster) {
79
83
  target: event.a,
80
84
  blockers: (event.b ?? []).map(i => roster[i]),
81
85
  score: event.s,
82
- incident: incidentFromCompact(event.i, event.h, roster),
86
+ incident: incidentFromCompact(event, roster),
83
87
  ...positionalFromCompact(event)
84
88
  });
85
89
  }
@@ -97,7 +101,7 @@ export function transformToReception(event, roster) {
97
101
  playerId: roster[event.p],
98
102
  target: event.a,
99
103
  score: event.s,
100
- incident: incidentFromCompact(event.i, event.h, roster),
104
+ incident: incidentFromCompact(event, roster),
101
105
  ...positionalFromCompact(event)
102
106
  });
103
107
  }
@@ -108,7 +112,7 @@ export function transformToServe(event, roster) {
108
112
  playerId: roster[event.p],
109
113
  target: event.a,
110
114
  score: event.s,
111
- incident: incidentFromCompact(event.i, event.h, roster),
115
+ incident: incidentFromCompact(event, roster),
112
116
  ...positionalFromCompact(event)
113
117
  });
114
118
  }
@@ -119,7 +123,7 @@ export function transformToSet(event, roster) {
119
123
  playerId: roster[event.p],
120
124
  target: event.a,
121
125
  score: event.s,
122
- incident: incidentFromCompact(event.i, event.h, roster),
126
+ incident: incidentFromCompact(event, roster),
123
127
  tempo: event.m,
124
128
  ...positionalFromCompact(event)
125
129
  });
@@ -133,7 +137,7 @@ export function transformToSpike(event, roster) {
133
137
  playerId: roster[event.p],
134
138
  target: event.a,
135
139
  score: event.s,
136
- incident: incidentFromCompact(event.i, event.h, roster),
140
+ incident: incidentFromCompact(event, roster),
137
141
  ...positionalFromCompact(event)
138
142
  });
139
143
  }
@@ -1,10 +1,11 @@
1
1
  import { CourtTarget } from '../match';
2
- import { RallyEvent, RallyEventOpts } from './rally-event';
2
+ import { EventType, RallyEvent, RallyEventOpts } from './rally-event';
3
3
  import { Trait } from '../player';
4
4
  export interface EventIncident {
5
5
  readonly kind: 'KNOCK' | 'INJURY';
6
6
  readonly severity: number;
7
7
  readonly playerId?: string;
8
+ readonly cause?: EventType;
8
9
  }
9
10
  export declare enum DigQualityEnum {
10
11
  PERFECT = 0,
@@ -44,6 +44,7 @@ export declare const BlockInputSchema: z.ZodObject<{
44
44
  }>;
45
45
  severity: z.ZodNumber;
46
46
  playerId: z.ZodOptional<z.ZodUUID>;
47
+ cause: z.ZodOptional<z.ZodNumber>;
47
48
  }, z.core.$strip>>;
48
49
  }, z.core.$strip>;
49
50
  export type BlockInput = z.infer<typeof BlockInputSchema>;
@@ -6,5 +6,6 @@ export declare const EventIncidentSchema: z.ZodObject<{
6
6
  }>;
7
7
  severity: z.ZodNumber;
8
8
  playerId: z.ZodOptional<z.ZodUUID>;
9
+ cause: z.ZodOptional<z.ZodNumber>;
9
10
  }, z.core.$strip>;
10
11
  export type EventIncidentInput = z.infer<typeof EventIncidentSchema>;
@@ -7,5 +7,8 @@ export const EventIncidentSchema = z.object({
7
7
  severity: z.number().int().min(1).max(4),
8
8
  // Who got hurt, ONLY when it is not the event's actor (a secondary blocker on a multi-player block);
9
9
  // absent = the event's own playerId.
10
- playerId: z.uuid().optional()
10
+ playerId: z.uuid().optional(),
11
+ // The action the hurt player was performing (an EventType), set only when this event is not their own action
12
+ // and does not list them: SPIKE = a fake spike, BLOCK = a block attempt that never touched the ball.
13
+ cause: z.number().int().min(0).max(7).optional()
11
14
  }).refine(v => v.kind !== 'KNOCK' || v.severity <= 3, { message: 'INVALID_KNOCK_TIER' });
@@ -43,6 +43,7 @@ export declare const ReceptionInputSchema: z.ZodObject<{
43
43
  }>;
44
44
  severity: z.ZodNumber;
45
45
  playerId: z.ZodOptional<z.ZodUUID>;
46
+ cause: z.ZodOptional<z.ZodNumber>;
46
47
  }, z.core.$strip>>;
47
48
  }, z.core.$strip>;
48
49
  export type ReceptionInput = z.infer<typeof ReceptionInputSchema>;
@@ -43,6 +43,7 @@ export declare const ServeInputSchema: z.ZodObject<{
43
43
  }>;
44
44
  severity: z.ZodNumber;
45
45
  playerId: z.ZodOptional<z.ZodUUID>;
46
+ cause: z.ZodOptional<z.ZodNumber>;
46
47
  }, z.core.$strip>>;
47
48
  }, z.core.$strip>;
48
49
  export type ServeInput = z.infer<typeof ServeInputSchema>;
@@ -43,6 +43,7 @@ export declare const SetInputSchema: z.ZodObject<{
43
43
  }>;
44
44
  severity: z.ZodNumber;
45
45
  playerId: z.ZodOptional<z.ZodUUID>;
46
+ cause: z.ZodOptional<z.ZodNumber>;
46
47
  }, z.core.$strip>>;
47
48
  tempo: z.ZodOptional<z.ZodNumber>;
48
49
  }, z.core.$strip>;
@@ -43,6 +43,7 @@ export declare const SpikeInputSchema: z.ZodObject<{
43
43
  }>;
44
44
  severity: z.ZodNumber;
45
45
  playerId: z.ZodOptional<z.ZodUUID>;
46
+ cause: z.ZodOptional<z.ZodNumber>;
46
47
  }, z.core.$strip>>;
47
48
  }, z.core.$strip>;
48
49
  export type SpikeInput = z.infer<typeof SpikeInputSchema>;
@@ -1,4 +1,4 @@
1
- import { Match, MatchTeam } from '.';
1
+ import { Match, MatchScore, MatchTeam } from '.';
2
2
  import { MatchContext } from './schemas/match-rating.z';
3
3
  export type { MatchContext };
4
4
  interface SetScoreVariant {
@@ -28,6 +28,18 @@ export declare class MatchRating {
28
28
  private static applyBotPenalty;
29
29
  private static computeWeight;
30
30
  static calculateProbability(z: number): number;
31
+ /**
32
+ * The FIVB model's probability of each of the six possible set scores, from the rating gap alone.
33
+ *
34
+ * getExpectedResult() has always computed these six numbers and immediately collapsed them into one weighted
35
+ * average. They are exposed here because the SAME distribution is what the Sim draws from to resolve a
36
+ * bot-vs-bot match without simulating it: the model that GRADES a result is then also the model that produces
37
+ * it, so a shortcut result can never disagree with the rating it earns. Extracted rather than duplicated,
38
+ * because a second copy of the cut-points would drift silently.
39
+ *
40
+ * Sums to 1 (P6 is the remainder), and is ordered best-to-worst FOR THE HOME TEAM.
41
+ */
42
+ static outcomeProbabilities(homeRating: number, awayRating: number): Record<MatchScore, number>;
31
43
  getExpectedResult(): number;
32
44
  getPoints(team: MatchTeam): number;
33
45
  }
@@ -39,20 +39,36 @@ export class MatchRating {
39
39
  static calculateProbability(z) {
40
40
  return 0.5 * (1 + erf(z / Math.SQRT2));
41
41
  }
42
+ /**
43
+ * The FIVB model's probability of each of the six possible set scores, from the rating gap alone.
44
+ *
45
+ * getExpectedResult() has always computed these six numbers and immediately collapsed them into one weighted
46
+ * average. They are exposed here because the SAME distribution is what the Sim draws from to resolve a
47
+ * bot-vs-bot match without simulating it: the model that GRADES a result is then also the model that produces
48
+ * it, so a shortcut result can never disagree with the rating it earns. Extracted rather than duplicated,
49
+ * because a second copy of the cut-points would drift silently.
50
+ *
51
+ * Sums to 1 (P6 is the remainder), and is ordered best-to-worst FOR THE HOME TEAM.
52
+ */
53
+ static outcomeProbabilities(homeRating, awayRating) {
54
+ const D = MatchRating.K * (homeRating - awayRating) / 1000;
55
+ return {
56
+ '3-0': MatchRating.calculateProbability(MatchRating.C1 + D),
57
+ '3-1': MatchRating.calculateProbability(MatchRating.C2 + D) - MatchRating.calculateProbability(MatchRating.C1 + D),
58
+ '3-2': MatchRating.calculateProbability(MatchRating.C3 + D) - MatchRating.calculateProbability(MatchRating.C2 + D),
59
+ '2-3': MatchRating.calculateProbability(MatchRating.C4 + D) - MatchRating.calculateProbability(MatchRating.C3 + D),
60
+ '1-3': MatchRating.calculateProbability(MatchRating.C5 + D) - MatchRating.calculateProbability(MatchRating.C4 + D),
61
+ '0-3': 1 - MatchRating.calculateProbability(MatchRating.C5 + D)
62
+ };
63
+ }
42
64
  getExpectedResult() {
43
- const D = MatchRating.K * (this.match.homeTeam.rating - this.match.awayTeam.rating) / 1000;
44
- const P1 = MatchRating.calculateProbability(MatchRating.C1 + D);
45
- const P2 = MatchRating.calculateProbability(MatchRating.C2 + D) - MatchRating.calculateProbability(MatchRating.C1 + D);
46
- const P3 = MatchRating.calculateProbability(MatchRating.C3 + D) - MatchRating.calculateProbability(MatchRating.C2 + D);
47
- const P4 = MatchRating.calculateProbability(MatchRating.C4 + D) - MatchRating.calculateProbability(MatchRating.C3 + D);
48
- const P5 = MatchRating.calculateProbability(MatchRating.C5 + D) - MatchRating.calculateProbability(MatchRating.C4 + D);
49
- const P6 = 1 - MatchRating.calculateProbability(MatchRating.C5 + D);
50
- return P1 * MatchRating.SSV['3-0'] +
51
- P2 * MatchRating.SSV['3-1'] +
52
- P3 * MatchRating.SSV['3-2'] +
53
- P4 * MatchRating.SSV['2-3'] +
54
- P5 * MatchRating.SSV['1-3'] +
55
- P6 * MatchRating.SSV['0-3'];
65
+ const P = MatchRating.outcomeProbabilities(this.match.homeTeam.rating, this.match.awayTeam.rating);
66
+ return P['3-0'] * MatchRating.SSV['3-0'] +
67
+ P['3-1'] * MatchRating.SSV['3-1'] +
68
+ P['3-2'] * MatchRating.SSV['3-2'] +
69
+ P['2-3'] * MatchRating.SSV['2-3'] +
70
+ P['1-3'] * MatchRating.SSV['1-3'] +
71
+ P['0-3'] * MatchRating.SSV['0-3'];
56
72
  }
57
73
  getPoints(team) {
58
74
  const homeScoreKey = this.match.getScore();
@@ -89,6 +89,59 @@ describe('MatchRating.getExpectedResult()', () => {
89
89
  expect(mr1.getExpectedResult()).toBeCloseTo(-mr2.getExpectedResult(), 8);
90
90
  });
91
91
  });
92
+ // ─── outcomeProbabilities ─────────────────────────────────────────────────────
93
+ describe('MatchRating.outcomeProbabilities()', () => {
94
+ const SCORES = ['3-0', '3-1', '3-2', '2-3', '1-3', '0-3'];
95
+ it('sums to 1 for any rating gap', () => {
96
+ for (const [h, a] of [[1000, 1000], [1500, 1000], [1000, 1500], [100, 100], [130, 70]]) {
97
+ const P = MatchRating.outcomeProbabilities(h, a);
98
+ const total = SCORES.reduce((sum, key) => sum + P[key], 0);
99
+ expect(total).toBeCloseTo(1, 10);
100
+ }
101
+ });
102
+ it('is never negative', () => {
103
+ for (const [h, a] of [[1000, 1000], [5000, 100], [100, 5000]]) {
104
+ const P = MatchRating.outcomeProbabilities(h, a);
105
+ for (const key of SCORES)
106
+ expect(P[key]).toBeGreaterThanOrEqual(0);
107
+ }
108
+ });
109
+ it('is symmetric between the two sides at equal rating', () => {
110
+ // 8 rather than 10 decimals: erf() is a series approximation, so the mirrored buckets agree to ~1e-9.
111
+ const P = MatchRating.outcomeProbabilities(100, 100);
112
+ expect(P['3-0']).toBeCloseTo(P['0-3'], 8);
113
+ expect(P['3-1']).toBeCloseTo(P['1-3'], 8);
114
+ expect(P['3-2']).toBeCloseTo(P['2-3'], 8);
115
+ });
116
+ it('mirrors when the gap is reversed', () => {
117
+ const P = MatchRating.outcomeProbabilities(130, 70);
118
+ const Q = MatchRating.outcomeProbabilities(70, 130);
119
+ expect(P['3-0']).toBeCloseTo(Q['0-3'], 8);
120
+ expect(P['3-1']).toBeCloseTo(Q['1-3'], 8);
121
+ expect(P['3-2']).toBeCloseTo(Q['2-3'], 8);
122
+ });
123
+ it('shifts weight toward the stronger side as the gap widens', () => {
124
+ const even = MatchRating.outcomeProbabilities(100, 100);
125
+ const wide = MatchRating.outcomeProbabilities(160, 40);
126
+ expect(wide['3-0']).toBeGreaterThan(even['3-0']);
127
+ expect(wide['0-3']).toBeLessThan(even['0-3']);
128
+ });
129
+ // The extraction guarantee: getExpectedResult() must still be the SSV-weighted sum of exactly these six
130
+ // numbers. If someone changes one and not the other, this fails.
131
+ it('reproduces getExpectedResult() as the SSV-weighted sum', () => {
132
+ for (const [h, a] of [[1000, 1000], [1500, 1000], [1000, 1500], [130, 70]]) {
133
+ const home = makeTeam({ rating: h });
134
+ const away = makeTeam({ rating: a });
135
+ const mr = MatchRating.create({
136
+ match: makeMatch30(home, away),
137
+ context: { kind: 'LEAGUE', tier: 1, homeIsBot: false, awayIsBot: false }
138
+ });
139
+ const P = MatchRating.outcomeProbabilities(h, a);
140
+ const expected = SCORES.reduce((sum, key) => sum + P[key] * MatchRating.SSV[key], 0);
141
+ expect(mr.getExpectedResult()).toBeCloseTo(expected, 10);
142
+ }
143
+ });
144
+ });
92
145
  // ─── getPoints ────────────────────────────────────────────────────────────────
93
146
  describe('MatchRating.getPoints()', () => {
94
147
  it('home and away deltas are opposite signs', () => {
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The comparison form of an email address. Lowercased and trimmed; the local part loses any `+tag`; on Gmail it
3
+ * also loses its dots and the domain folds onto gmail.com.
4
+ *
5
+ * Split local from domain FIRST. Splitting the whole address on '+' looks equivalent and is not: an address
6
+ * with no tag comes back whole, and a later dot-strip then eats the dot in "gmail.com" too.
7
+ *
8
+ * Returns the trimmed lowercase input unchanged when it is not a single-@ address, so a malformed value is
9
+ * never silently reshaped into something that could collide with a real account. Format validation is the
10
+ * caller's job.
11
+ */
12
+ export declare function canonicalEmail(raw: string): string;
@@ -0,0 +1,40 @@
1
+ // Email identity helpers.
2
+ //
3
+ // A person must not be able to hold two accounts on one mailbox by spelling the same address differently. The
4
+ // canonical form is what uniqueness is enforced on; the raw address the user typed is still what we display and
5
+ // send mail to.
6
+ //
7
+ // Owner decision 2026-09-01: GMAIL RULES FOR GMAIL ONLY. Dots are insignificant and +tags are aliases on
8
+ // Gmail, so both are stripped there. Applying either rule to every provider would WRONGLY merge two real
9
+ // people on providers where the local part is taken literally, so everyone else gets lowercasing and +tag
10
+ // removal alone (+tag aliasing is near-universal; dot-insignificance is not).
11
+ /** Domains that alias onto gmail.com and follow its dot/plus rules. */
12
+ const GMAIL_DOMAINS = new Set(['gmail.com', 'googlemail.com']);
13
+ /**
14
+ * The comparison form of an email address. Lowercased and trimmed; the local part loses any `+tag`; on Gmail it
15
+ * also loses its dots and the domain folds onto gmail.com.
16
+ *
17
+ * Split local from domain FIRST. Splitting the whole address on '+' looks equivalent and is not: an address
18
+ * with no tag comes back whole, and a later dot-strip then eats the dot in "gmail.com" too.
19
+ *
20
+ * Returns the trimmed lowercase input unchanged when it is not a single-@ address, so a malformed value is
21
+ * never silently reshaped into something that could collide with a real account. Format validation is the
22
+ * caller's job.
23
+ */
24
+ export function canonicalEmail(raw) {
25
+ const trimmed = raw.trim().toLowerCase();
26
+ const at = trimmed.lastIndexOf('@');
27
+ if (at <= 0 || at === trimmed.length - 1 || trimmed.indexOf('@') !== at)
28
+ return trimmed;
29
+ const local = trimmed.slice(0, at);
30
+ const domain = trimmed.slice(at + 1);
31
+ const untagged = local.split('+')[0];
32
+ // An address that is nothing BUT a tag and/or dots has no local part left once they are stripped. Fall back to
33
+ // the ORIGINAL local part, never to the stripped one: an empty local would canonicalize every degenerate
34
+ // address to the same value and collide them with each other.
35
+ if (GMAIL_DOMAINS.has(domain)) {
36
+ const noDots = untagged.split('.').join('');
37
+ return `${noDots.length > 0 ? noDots : local}@gmail.com`;
38
+ }
39
+ return `${untagged.length > 0 ? untagged : local}@${domain}`;
40
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,50 @@
1
+ import { describe, it, expect } from '@jest/globals';
2
+ import { canonicalEmail } from './email-utils';
3
+ // ─── canonicalEmail ──────────────────────────────────────────────────────────
4
+ describe('canonicalEmail()', () => {
5
+ it('lowercases and trims', () => {
6
+ expect(canonicalEmail(' Foo.Bar@Example.COM ')).toBe('foo.bar@example.com');
7
+ });
8
+ it('strips a +tag on any provider', () => {
9
+ expect(canonicalEmail('user+anything@example.com')).toBe('user@example.com');
10
+ });
11
+ it('keeps dots on non-Gmail providers, where they are significant', () => {
12
+ expect(canonicalEmail('a.b@example.com')).toBe('a.b@example.com');
13
+ expect(canonicalEmail('ab@example.com')).toBe('ab@example.com');
14
+ expect(canonicalEmail('a.b@example.com')).not.toBe(canonicalEmail('ab@example.com'));
15
+ });
16
+ it('strips dots AND +tags on gmail', () => {
17
+ expect(canonicalEmail('a.b.c+tag@gmail.com')).toBe('abc@gmail.com');
18
+ expect(canonicalEmail('abc@gmail.com')).toBe('abc@gmail.com');
19
+ expect(canonicalEmail('a.b.c@gmail.com')).toBe(canonicalEmail('abc+anything@gmail.com'));
20
+ });
21
+ it('folds googlemail.com onto gmail.com', () => {
22
+ expect(canonicalEmail('a.b@googlemail.com')).toBe('ab@gmail.com');
23
+ expect(canonicalEmail('ab@googlemail.com')).toBe(canonicalEmail('ab@gmail.com'));
24
+ });
25
+ // The exact bug the first prod duplicate report hit: splitting the WHOLE address on '+' returns the address
26
+ // intact when there is no tag, and the Gmail dot-strip then eats the dot in "gmail.com" as well.
27
+ it('does not mangle a Gmail address that has no +tag', () => {
28
+ expect(canonicalEmail('fariasfranciscoe@gmail.com')).toBe('fariasfranciscoe@gmail.com');
29
+ expect(canonicalEmail('fariasfranciscoe@gmail.com')).not.toContain('gmailcom');
30
+ });
31
+ it('matches the real prod collision it was written for', () => {
32
+ expect(canonicalEmail('fariasfranciscoe+playreview@gmail.com'))
33
+ .toBe(canonicalEmail('fariasfranciscoe@gmail.com'));
34
+ });
35
+ it('leaves a malformed address alone rather than reshaping it', () => {
36
+ expect(canonicalEmail('not-an-email')).toBe('not-an-email');
37
+ expect(canonicalEmail('@example.com')).toBe('@example.com');
38
+ expect(canonicalEmail('user@')).toBe('user@');
39
+ expect(canonicalEmail('two@ats@example.com')).toBe('two@ats@example.com');
40
+ });
41
+ it('never returns an empty local part for a degenerate address', () => {
42
+ expect(canonicalEmail('+tag@gmail.com')).toBe('+tag@gmail.com');
43
+ expect(canonicalEmail('...@gmail.com')).toBe('...@gmail.com');
44
+ });
45
+ it('is idempotent', () => {
46
+ for (const raw of ['A.B+x@Gmail.com', 'user+t@example.com', 'plain@example.com', 'weird']) {
47
+ expect(canonicalEmail(canonicalEmail(raw))).toBe(canonicalEmail(raw));
48
+ }
49
+ });
50
+ });
@@ -1,4 +1,5 @@
1
1
  export * from './object-utils';
2
+ export * from './email-utils';
2
3
  export * from './number-utils';
3
4
  export * from './rng-utils';
4
5
  export * from './faker-generators';
@@ -1,4 +1,5 @@
1
1
  export * from './object-utils';
2
+ export * from './email-utils';
2
3
  export * from './number-utils';
3
4
  export * from './rng-utils';
4
5
  export * from './faker-generators';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "volleyballsimtypes",
3
- "version": "0.0.529",
3
+ "version": "0.0.531",
4
4
  "description": "vbsim types",
5
5
  "main": "./dist/cjs/src/index.js",
6
6
  "module": "./dist/esm/src/index.js",