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.
- package/dist/cjs/src/data/models/auth-user.d.ts +8 -2
- package/dist/cjs/src/data/models/auth-user.js +53 -5
- package/dist/cjs/src/data/transformers/rally-event-injury.test.js +39 -0
- package/dist/cjs/src/data/transformers/rally-event.d.ts +1 -0
- package/dist/cjs/src/data/transformers/rally-event.js +12 -8
- package/dist/cjs/src/service/event/in-play-event.d.ts +2 -1
- package/dist/cjs/src/service/event/schemas/block.z.d.ts +1 -0
- package/dist/cjs/src/service/event/schemas/incident.z.d.ts +1 -0
- package/dist/cjs/src/service/event/schemas/incident.z.js +4 -1
- package/dist/cjs/src/service/event/schemas/reception.z.d.ts +1 -0
- package/dist/cjs/src/service/event/schemas/serve.z.d.ts +1 -0
- package/dist/cjs/src/service/event/schemas/set.z.d.ts +1 -0
- package/dist/cjs/src/service/event/schemas/spike.z.d.ts +1 -0
- package/dist/cjs/src/service/match/match-rating.d.ts +13 -1
- package/dist/cjs/src/service/match/match-rating.js +29 -13
- package/dist/cjs/src/service/match/match-rating.test.js +53 -0
- package/dist/cjs/src/service/utils/email-utils.d.ts +12 -0
- package/dist/cjs/src/service/utils/email-utils.js +43 -0
- package/dist/cjs/src/service/utils/email-utils.test.d.ts +1 -0
- package/dist/cjs/src/service/utils/email-utils.test.js +52 -0
- package/dist/cjs/src/service/utils/index.d.ts +1 -0
- package/dist/cjs/src/service/utils/index.js +1 -0
- package/dist/esm/src/data/models/auth-user.d.ts +8 -2
- package/dist/esm/src/data/models/auth-user.js +20 -5
- package/dist/esm/src/data/transformers/rally-event-injury.test.js +39 -0
- package/dist/esm/src/data/transformers/rally-event.d.ts +1 -0
- package/dist/esm/src/data/transformers/rally-event.js +12 -8
- package/dist/esm/src/service/event/in-play-event.d.ts +2 -1
- package/dist/esm/src/service/event/schemas/block.z.d.ts +1 -0
- package/dist/esm/src/service/event/schemas/incident.z.d.ts +1 -0
- package/dist/esm/src/service/event/schemas/incident.z.js +4 -1
- package/dist/esm/src/service/event/schemas/reception.z.d.ts +1 -0
- package/dist/esm/src/service/event/schemas/serve.z.d.ts +1 -0
- package/dist/esm/src/service/event/schemas/set.z.d.ts +1 -0
- package/dist/esm/src/service/event/schemas/spike.z.d.ts +1 -0
- package/dist/esm/src/service/match/match-rating.d.ts +13 -1
- package/dist/esm/src/service/match/match-rating.js +29 -13
- package/dist/esm/src/service/match/match-rating.test.js +53 -0
- package/dist/esm/src/service/utils/email-utils.d.ts +12 -0
- package/dist/esm/src/service/utils/email-utils.js +40 -0
- package/dist/esm/src/service/utils/email-utils.test.d.ts +1 -0
- package/dist/esm/src/service/utils/email-utils.test.js +50 -0
- package/dist/esm/src/service/utils/index.d.ts +1 -0
- package/dist/esm/src/service/utils/index.js +1 -0
- 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: '
|
|
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: '
|
|
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/);
|
|
@@ -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(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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>;
|
|
@@ -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 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
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
+
});
|
|
@@ -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: '
|
|
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: '
|
|
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/);
|
|
@@ -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(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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>;
|
|
@@ -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 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
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
+
});
|