@voltro/plugin-matchmaking 0.0.0-stage → 0.88.0

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 (47) hide show
  1. package/CHANGELOG.md +24492 -0
  2. package/LICENSE +61 -0
  3. package/README.md +272 -2
  4. package/SECURITY.md +56 -0
  5. package/THIRD-PARTY-NOTICES.md +8333 -0
  6. package/dist/admission.entity.js +17 -0
  7. package/dist/assignment.js +125 -0
  8. package/dist/avoidPairs.js +4 -0
  9. package/dist/contract.js +105 -0
  10. package/dist/director-app/matchmaking.director.workflow.server.tsx +5 -0
  11. package/dist/director-app/matchmaking.director.workflow.tsx +13 -0
  12. package/dist/director.js +72 -0
  13. package/dist/directorContract.js +15 -0
  14. package/dist/directorWorkflow.js +5 -0
  15. package/dist/errors.d.ts +13 -0
  16. package/dist/errors.js +20 -0
  17. package/dist/index.d.ts +523 -0
  18. package/dist/index.js +12 -0
  19. package/dist/inspect.js +40 -0
  20. package/dist/packing.js +71 -0
  21. package/dist/parties.js +316 -0
  22. package/dist/party.entity.js +33 -0
  23. package/dist/partyComponents.js +176 -0
  24. package/dist/partyContract.js +24 -0
  25. package/dist/plugin.js +257 -0
  26. package/dist/port.d.ts +116 -0
  27. package/dist/port.js +4 -0
  28. package/dist/queue.d.ts +91 -0
  29. package/dist/queue.js +79 -0
  30. package/dist/queueDeclarations.js +27 -0
  31. package/dist/roomsAllocator.d.ts +67 -0
  32. package/dist/roomsAllocator.js +75 -0
  33. package/dist/rpc.d.ts +247 -0
  34. package/dist/rpc.js +201 -0
  35. package/dist/server.d.ts +337 -0
  36. package/dist/server.js +8 -0
  37. package/dist/service.js +369 -0
  38. package/dist/strategies.d.ts +97 -0
  39. package/dist/strategies.js +140 -0
  40. package/dist/testing.d.ts +296 -0
  41. package/dist/testing.js +151 -0
  42. package/dist/ticketContract.d.ts +52 -0
  43. package/dist/ticketContract.js +30 -0
  44. package/dist/tickets.entity.js +49 -0
  45. package/dist/web.d.ts +279 -0
  46. package/dist/web.js +7 -0
  47. package/package.json +104 -4
package/dist/plugin.js ADDED
@@ -0,0 +1,257 @@
1
+ import { MatchmakingError as e, matchmakingError as t } from "./errors.js";
2
+ import { MatchmakingDirectorPort as n, MatchmakingPort as r, PartyMembershipPort as i, PartyRosterPort as a } from "./port.js";
3
+ import { partyTables as o } from "./party.entity.js";
4
+ import { matchmakingAssignmentsTable as s, matchmakingTicketsTable as c } from "./tickets.entity.js";
5
+ import { matchmakingQueuesTable as l, matchmakingSubjectsTable as u } from "./admission.entity.js";
6
+ import { dataStoreParties as d, resolvePartyOptions as f } from "./parties.js";
7
+ import { dataStoreMatchmakingAssigner as p, readMatchmakingAssignments as m } from "./assignment.js";
8
+ import { dataStoreMatchmakingTickets as h, resolveTicketAdmissionOptions as g } from "./service.js";
9
+ import { BASE_NAME as _, assignmentsDescriptor as v, cancelDescriptor as y, enqueueDescriptor as b, estimateDescriptor as x, matchmakingRpcClientImports as ee, partyCancelSearchDescriptor as te, partyCreateDescriptor as ne, partyInviteDescriptor as re, partyInvitesDescriptor as ie, partyJoinDescriptor as ae, partyKickDescriptor as oe, partyLeaveDescriptor as se, partyMineDescriptor as S, partyRpcClientImports as C, partySearchDescriptor as w, partySetReadyDescriptor as T, partyTransferLeaderDescriptor as E, statusDescriptor as D } from "./rpc.js";
10
+ import { matchmakingStatus as O } from "./inspect.js";
11
+ import { Effect as k, Layer as A, Option as j } from "effect";
12
+ import { defineLicensedGamePlugin as M } from "@voltro/plugin-licensing/gate";
13
+ import { fileURLToPath as N } from "node:url";
14
+ import { and as P, eq as F, gt as I, queryFor as L } from "@voltro/database";
15
+ import { systemSubject as ce } from "@voltro/protocol";
16
+ // src/plugin.ts
17
+ var R = (e) => k.flatMap(k.serviceOption(r), (n) => j.isNone(n) ? k.fail(t("unavailable")) : e(n.value)), z = {
18
+ slots: { avoidPairs: {
19
+ defaultExport: "defaultMatchmakingAvoidPairs",
20
+ fixtureExport: "matchmakingAvoidPairsSlotFixture"
21
+ } },
22
+ ports: {
23
+ MatchmakingPort: {
24
+ tagExport: "MatchmakingPort",
25
+ fakeLayerExport: "matchmakingFakeLayer",
26
+ fixtureExport: "matchmakingPortFixture"
27
+ },
28
+ PartyMembershipPort: {
29
+ tagExport: "PartyMembershipPort",
30
+ fakeLayerExport: "partyMembershipFakeLayer",
31
+ fixtureExport: "partyMembershipPortFixture"
32
+ },
33
+ PartyRosterPort: {
34
+ tagExport: "PartyRosterPort",
35
+ fakeLayerExport: "partyRosterFakeLayer",
36
+ fixtureExport: "partyRosterPortFixture"
37
+ },
38
+ MatchmakingDirectorPort: {
39
+ tagExport: "MatchmakingDirectorPort",
40
+ fakeLayerExport: "matchmakingDirectorFakeLayer",
41
+ fixtureExport: "matchmakingDirectorPortFixture"
42
+ }
43
+ },
44
+ events: []
45
+ }, B = (j) => {
46
+ let z = g(j), B = f({
47
+ ...j.parties,
48
+ now: j.parties?.now ?? z.now ?? Date.now
49
+ }), V, H, U, W, G = j.allocator, K = j.maxAllocationAttempts, q = (e) => k.suspend(() => U === void 0 ? k.fail(t("unavailable")) : e(U.service)), le = (e, n) => {
50
+ let r = U;
51
+ if (r === void 0) return k.fail(t("unavailable"));
52
+ let i = r.readRoster(e, n);
53
+ return k.suspend(() => U === r ? i.pipe(k.flatMap((e) => U === r ? k.succeed(e) : k.fail(t("unavailable")))) : k.fail(t("unavailable")));
54
+ }, J = (e) => k.suspend(() => H === void 0 ? k.fail(t("unavailable")) : e(H)), Y = (e, n, r) => {
55
+ try {
56
+ let t = structuredClone(e), i = structuredClone(n);
57
+ return J((e) => r(e.service, t, i));
58
+ } catch {
59
+ return k.fail(t("invalid_input"));
60
+ }
61
+ }, ue = {
62
+ enqueue: (e, t) => Y(e, t, (e, t, n) => e.enqueue(t, n)),
63
+ cancel: (e, t) => Y(e, t, (e, t, n) => e.cancel(t, n)),
64
+ status: (e, t) => Y(e, t, (e, t, n) => e.status(t, n)),
65
+ estimate: (e, n) => Y(e, n, (e, n, r) => e.estimate ? e.estimate(n, r) : k.fail(t("unavailable")))
66
+ }, X = ce("matchmaking.director"), de = (e) => k.gen(function* () {
67
+ if (V === void 0 || W === void 0) return yield* k.fail(t("unavailable"));
68
+ let n = V, r = (z.now ?? Date.now)(), i = yield* k.tryPromise({
69
+ try: () => n.query(L(c).where(P(F("state", "open"), I("expiresAt", new Date(r)))).orderBy("createdAt", "asc").limit(e.maxPairs * 64).descriptor),
70
+ catch: () => t("unavailable")
71
+ }), a = [...new Map(i.map((e) => [JSON.stringify([e.tenantId, e.queue]), {
72
+ tenantId: String(e.tenantId),
73
+ queue: String(e.queue)
74
+ }])).values()].slice(0, e.maxPairs), o = 0, s = 0, l = 0;
75
+ for (let e of a) {
76
+ let t = yield* Q(X, e).pipe(k.flatMap((e) => W.assign(X, e)));
77
+ o += t.assigned.length, s += t.skipped, l += t.failed;
78
+ }
79
+ return {
80
+ cycles: a.length,
81
+ assigned: o,
82
+ skipped: s,
83
+ failed: l
84
+ };
85
+ }), Z = [
86
+ {
87
+ ...b,
88
+ execute: (e, t) => R((n) => n.enqueue(t.request.subject, e))
89
+ },
90
+ {
91
+ ...y,
92
+ execute: (e, t) => R((n) => n.cancel(t.request.subject, e.ticketId))
93
+ },
94
+ {
95
+ ...D,
96
+ execute: (e, t) => R((n) => n.status(t.request.subject, e.ticketId))
97
+ },
98
+ {
99
+ ...x,
100
+ execute: (e, n) => R((r) => r.estimate ? r.estimate(n.request.subject, e.queue) : k.fail(t("unavailable")))
101
+ },
102
+ {
103
+ ...v,
104
+ execute: (e, n) => k.suspend(() => {
105
+ let e = n.request.subject;
106
+ if (V === void 0) return k.fail(t("unavailable"));
107
+ if (e.type !== "user" || typeof e.tenantId != "string") return k.fail(t("identity_required"));
108
+ let r = V, i = e.tenantId, a = e.id;
109
+ return k.tryPromise({
110
+ try: () => m(r, i, a),
111
+ catch: () => t("unavailable")
112
+ });
113
+ })
114
+ },
115
+ {
116
+ ...ne,
117
+ execute: (e, t) => q((n) => n.create(t.request.subject, e))
118
+ },
119
+ {
120
+ ...re,
121
+ execute: (e, t) => q((n) => n.invite(t.request.subject, e))
122
+ },
123
+ {
124
+ ...ae,
125
+ execute: (e, t) => q((n) => n.join(t.request.subject, e.partyId))
126
+ },
127
+ {
128
+ ...se,
129
+ execute: (e, t) => q((n) => n.leave(t.request.subject, e.partyId))
130
+ },
131
+ {
132
+ ...oe,
133
+ execute: (e, t) => q((n) => n.kick(t.request.subject, e))
134
+ },
135
+ {
136
+ ...T,
137
+ execute: (e, t) => q((n) => n.setReady(t.request.subject, e))
138
+ },
139
+ {
140
+ ...E,
141
+ execute: (e, t) => q((n) => n.transferLeader(t.request.subject, e))
142
+ },
143
+ {
144
+ ...w,
145
+ execute: (e, n) => k.suspend(() => H === void 0 || U === void 0 ? k.fail(t("unavailable")) : H.enqueueGroup(n.request.subject, {
146
+ queue: e.queue,
147
+ requestId: e.requestId,
148
+ preferences: e.preferences
149
+ }, e.partyId, (t) => U.prepareSearch(t, n.request.subject, e.partyId), () => U.previewRoster(n.request.subject, e.partyId)))
150
+ },
151
+ {
152
+ ...te,
153
+ execute: (e, t) => q((n) => n.cancelSearch(t.request.subject, e.partyId))
154
+ },
155
+ {
156
+ ...S,
157
+ execute: (e, t) => q((e) => e.mine(t.request.subject))
158
+ },
159
+ {
160
+ ...ie,
161
+ execute: (e, t) => q((e) => e.invites(t.request.subject))
162
+ }
163
+ ], fe = M({
164
+ name: _,
165
+ description: "SQL-owned solo search admission with a replaceable ticket port.",
166
+ permissions: ["store:write", "inspect:read"],
167
+ routes: Z,
168
+ rpcClientDescriptors: [...ee, ...C],
169
+ inspectEndpoints: [{
170
+ method: "GET",
171
+ path: "/status",
172
+ scope: "process",
173
+ description: "Captured queue/admission budgets and process binding; no ticket or player enumeration.",
174
+ handler: () => k.try({
175
+ try: () => ({
176
+ kind: "json",
177
+ data: O(z, H !== void 0, (z.now ?? Date.now)())
178
+ }),
179
+ catch: () => t("unavailable")
180
+ })
181
+ }],
182
+ services: A.mergeAll(A.succeed(r, ue), A.succeed(i, { isMember: (e, t) => q((n) => n.isMember(e, t)) }), A.succeed(a, { read: le }), A.succeed(n, { run: (e) => de(e) })),
183
+ ...G === void 0 ? {} : { sources: N(new URL("./director-app", "" + import.meta.url)) },
184
+ errorSchemas: [{
185
+ schema: e,
186
+ import: {
187
+ module: "@voltro/plugin-matchmaking/errors",
188
+ name: "MatchmakingError"
189
+ }
190
+ }],
191
+ extendSchema: { tables: [
192
+ c,
193
+ u,
194
+ l,
195
+ s,
196
+ ...o
197
+ ] },
198
+ subjectPrivacy: {
199
+ tables: [
200
+ c.tableName,
201
+ u.tableName,
202
+ l.tableName,
203
+ s.tableName,
204
+ ...o.map((e) => e.tableName)
205
+ ],
206
+ kinds: ["user", "guest"]
207
+ },
208
+ bindDataStore: (e) => {
209
+ if (V !== void 0 && V !== e) throw Error("Matchmaking plugin instance cannot cross app stores");
210
+ V = e, H = h(e, z), U = d(e, B), W = G === void 0 ? void 0 : p(e, {
211
+ allocator: G,
212
+ ...K === void 0 ? {} : { maxAttempts: K },
213
+ now: z.now ?? Date.now
214
+ });
215
+ },
216
+ onExport: (e, n) => J((t) => t.exportSubject({
217
+ subjectId: e,
218
+ tenantId: n.tenantId,
219
+ kind: n.kind
220
+ })).pipe(k.zip(k.suspend(() => U === void 0 ? k.fail(t("unavailable")) : U.exportSubject({
221
+ subjectId: e,
222
+ tenantId: n.tenantId,
223
+ kind: n.kind
224
+ }))), k.map(([e, t]) => ({
225
+ ...e,
226
+ parties: t
227
+ }))),
228
+ onErase: (e, n) => k.suspend(() => U === void 0 ? k.fail(t("unavailable")) : U.eraseSubject({
229
+ subjectId: e,
230
+ tenantId: n.tenantId,
231
+ kind: n.kind
232
+ })).pipe(k.andThen(J((t) => t.eraseSubject({
233
+ subjectId: e,
234
+ tenantId: n.tenantId,
235
+ kind: n.kind
236
+ })))),
237
+ onDeactivate: () => k.sync(() => {
238
+ H = void 0, U = void 0, W = void 0, V = void 0;
239
+ })
240
+ }), Q = (e, n) => {
241
+ try {
242
+ let t = structuredClone(e), r = structuredClone(n);
243
+ return J((e) => e.director.plan(t, r));
244
+ } catch {
245
+ return k.fail(t("invalid_input"));
246
+ }
247
+ }, $ = (e, n) => k.suspend(() => W === void 0 ? k.fail(t("unavailable")) : W.assign(e, n)), pe = (e, t) => k.flatMap(Q(e, t), (t) => $(e, t));
248
+ return {
249
+ ...fe,
250
+ matchmaking: Object.freeze({
251
+ plan: Q,
252
+ assign: $,
253
+ cycle: pe
254
+ })
255
+ };
256
+ };
257
+ export { B as matchmakingPlugin, z as matchmakingSystemContract };
package/dist/port.d.ts ADDED
@@ -0,0 +1,116 @@
1
+ import { Context } from 'effect';
2
+ import { Effect } from 'effect';
3
+ import { Schema } from 'effect';
4
+ import { Subject } from '@voltro/protocol';
5
+ import { YieldableError } from 'effect/Cause';
6
+
7
+ /** Players send a queue preference intent; never skill, members, state or assignments. */
8
+ declare interface EnqueueIntent {
9
+ readonly queue: string;
10
+ readonly requestId: string;
11
+ readonly preferences: Readonly<Record<string, unknown>>;
12
+ }
13
+
14
+ /** A scheduled Director run: `runKey` makes the run idempotent; `maxPairs` bounds tenant×queue work per run. */
15
+ declare const MatchmakingDirectorPayload: Schema.Struct<{
16
+ readonly runKey: Schema.NonEmptyString;
17
+ readonly maxPairs: Schema.Number;
18
+ }>;
19
+
20
+ declare type MatchmakingDirectorPayload = typeof MatchmakingDirectorPayload.Type;
21
+
22
+ export declare class MatchmakingDirectorPort extends MatchmakingDirectorPort_base {
23
+ }
24
+
25
+ declare const MatchmakingDirectorPort_base: Context.ServiceClass<MatchmakingDirectorPort, "@voltro/MatchmakingDirectorPort", MatchmakingDirectorService>;
26
+
27
+ declare const MatchmakingDirectorResult: Schema.Struct<{
28
+ readonly cycles: Schema.Number;
29
+ readonly assigned: Schema.Number;
30
+ readonly skipped: Schema.Number;
31
+ readonly failed: Schema.Number;
32
+ }>;
33
+
34
+ declare type MatchmakingDirectorResult = typeof MatchmakingDirectorResult.Type;
35
+
36
+ /** One Director pass over every tenant and queue with open tickets: plan, then assign. */
37
+ export declare interface MatchmakingDirectorService {
38
+ readonly run: (input: MatchmakingDirectorPayload) => Effect.Effect<MatchmakingDirectorResult, MatchmakingError>;
39
+ }
40
+
41
+ declare class MatchmakingError extends MatchmakingError_base {
42
+ }
43
+
44
+ declare const MatchmakingError_base: Schema.Class<MatchmakingError, Schema.TaggedStruct<"MatchmakingError", {
45
+ readonly code: Schema.Literals<readonly ["invalid_input", "invalid_queue", "invalid_proposal", "unavailable", "identity_required", "not_found", "already_searching", "conflict", "queue_full", "erased", "export_limit", "forbidden", "party_full", "already_in_party", "party_not_ready"]>;
46
+ }>, YieldableError>;
47
+
48
+ declare type MatchmakingEstimate = typeof MatchmakingEstimateSchema.Type;
49
+
50
+ /** Expected wait in a queue: the median time recent searches of this tenant waited until matched, from up to
51
+ * `samples` searches matched in the last 30 minutes. `waitMs` is null with fewer than three such searches. */
52
+ declare const MatchmakingEstimateSchema: Schema.Struct<{
53
+ readonly queue: Schema.String;
54
+ readonly waitMs: Schema.NullOr<Schema.Number>;
55
+ readonly samples: Schema.Number;
56
+ }>;
57
+
58
+ export declare class MatchmakingPort extends MatchmakingPort_base {
59
+ }
60
+
61
+ declare const MatchmakingPort_base: Context.ServiceClass<MatchmakingPort, "@voltro/MatchmakingPort", MatchmakingService>;
62
+
63
+ /** Queries wrap status reactively; this port owns no transport or poll timer. */
64
+ export declare interface MatchmakingService {
65
+ readonly enqueue: (subject: Subject, input: EnqueueIntent) => Effect.Effect<MatchmakingTicket, MatchmakingError>;
66
+ readonly cancel: (subject: Subject, ticketId: string) => Effect.Effect<void, MatchmakingError>;
67
+ readonly status: (subject: Subject, ticketId: string) => Effect.Effect<MatchmakingTicket, MatchmakingError>;
68
+ /** Median recent wait in a declared queue for the caller's tenant. Optional for replacement ports: without it
69
+ * `matchmaking.estimate` answers `unavailable`. */
70
+ readonly estimate?: (subject: Subject, queue: string) => Effect.Effect<MatchmakingEstimate, MatchmakingError>;
71
+ }
72
+
73
+ declare type MatchmakingTicket = typeof MatchmakingTicketSchema.Type;
74
+
75
+ declare const MatchmakingTicketSchema: Schema.Struct<{
76
+ readonly id: Schema.String;
77
+ readonly queue: Schema.String;
78
+ readonly state: Schema.Literals<readonly ["open", "matched", "assigned", "cancelled", "expired"]>;
79
+ readonly members: Schema.$Array<Schema.String>;
80
+ readonly attributes: Schema.$Record<Schema.String, Schema.Unknown>;
81
+ readonly createdAt: Schema.Number;
82
+ readonly expiresAt: Schema.Number;
83
+ readonly closedAt: Schema.NullOr<Schema.Number>;
84
+ readonly metadata: Schema.$Record<Schema.String, Schema.Unknown>;
85
+ }>;
86
+
87
+ export declare class PartyMembershipPort extends PartyMembershipPort_base {
88
+ }
89
+
90
+ declare const PartyMembershipPort_base: Context.ServiceClass<PartyMembershipPort, "@voltro/PartyMembershipPort", PartyMembershipService>;
91
+
92
+ /** Chat and other plugins ask current party membership here, never party SQL. */
93
+ export declare interface PartyMembershipService {
94
+ readonly isMember: (subject: Subject, partyId: string) => Effect.Effect<boolean, MatchmakingError>;
95
+ }
96
+
97
+ /** A detached current SQL roster. It contains IDs, never inferred Subject kinds. */
98
+ export declare interface PartyRoster {
99
+ readonly tenantId: string;
100
+ readonly partyId: string;
101
+ readonly leaderId: string;
102
+ readonly memberIds: ReadonlyArray<string>;
103
+ }
104
+
105
+ export declare class PartyRosterPort extends PartyRosterPort_base {
106
+ }
107
+
108
+ declare const PartyRosterPort_base: Context.ServiceClass<PartyRosterPort, "@voltro/PartyRosterPort", PartyRosterService>;
109
+
110
+ /** Trusted server composition only. The caller must authorize the operation;
111
+ * this read is not a player route or authority to move the returned members. */
112
+ export declare interface PartyRosterService {
113
+ readonly read: (tenantId: string, partyId: string) => Effect.Effect<PartyRoster | null, MatchmakingError>;
114
+ }
115
+
116
+ export { }
package/dist/port.js ADDED
@@ -0,0 +1,4 @@
1
+ import { Context as e } from "effect";
2
+ // src/port.ts
3
+ var t = class extends e.Service()("@voltro/MatchmakingPort") {}, n = class extends e.Service()("@voltro/PartyMembershipPort") {}, r = class extends e.Service()("@voltro/PartyRosterPort") {}, i = class extends e.Service()("@voltro/MatchmakingDirectorPort") {};
4
+ export { i as MatchmakingDirectorPort, t as MatchmakingPort, n as PartyMembershipPort, r as PartyRosterPort };
@@ -0,0 +1,91 @@
1
+ import { Schema } from 'effect';
2
+
3
+ declare type AvoidPair = readonly [string, string];
4
+
5
+ export declare const defineQueue: <S extends Schema.Codec<unknown, unknown>>(input: QueueDefinition<S>) => QueueDescriptor<S>;
6
+
7
+ declare type MatchFunction<A = Readonly<Record<string, unknown>>> = (input: MatchInput<A>) => MatchOutput<A>;
8
+
9
+ declare interface MatchInput<A = Readonly<Record<string, unknown>>> {
10
+ readonly tickets: ReadonlyArray<MatchTicket<A>>;
11
+ readonly now: number;
12
+ readonly queue: MatchQueueRules;
13
+ readonly avoid: ReadonlyArray<AvoidPair>;
14
+ }
15
+
16
+ declare interface MatchOutput<A = Readonly<Record<string, unknown>>> {
17
+ readonly proposals: ReadonlyArray<MatchProposal<A>>;
18
+ readonly leftover: ReadonlyArray<MatchTicket<A>>;
19
+ /** Deterministic branch inspections, not a runtime or throughput measurement. */
20
+ readonly searchNodes: number;
21
+ readonly exhausted: boolean;
22
+ }
23
+
24
+ declare interface MatchProposal<A = Readonly<Record<string, unknown>>> {
25
+ readonly teams: ReadonlyArray<ReadonlyArray<MatchTicket<A>>>;
26
+ readonly quality: number;
27
+ }
28
+
29
+ declare interface MatchQueueRules {
30
+ readonly name: string;
31
+ readonly teams: {
32
+ readonly count: number;
33
+ readonly size: number;
34
+ };
35
+ readonly maxTickets: number;
36
+ readonly maxSearchNodes: number;
37
+ readonly maxAvoidPairs: number;
38
+ readonly maxAttributeBytes: number;
39
+ readonly minQuality: {
40
+ readonly initial: number;
41
+ readonly after: ReadonlyArray<readonly [number, number]>;
42
+ };
43
+ }
44
+
45
+ declare interface MatchTicket<A = Readonly<Record<string, unknown>>> {
46
+ readonly id: string;
47
+ readonly tenantId: string;
48
+ readonly queue: string;
49
+ readonly members: ReadonlyArray<string>;
50
+ readonly createdAt: number;
51
+ readonly attributes: A;
52
+ }
53
+
54
+ export declare interface QueueDefinition<S extends Schema.Codec<unknown, unknown>> {
55
+ readonly name: string;
56
+ readonly room: string;
57
+ readonly ticket: S;
58
+ readonly teams: MatchQueueRules['teams'];
59
+ readonly matchFunction: MatchFunction<(S)["Type"]>;
60
+ readonly pools?: {
61
+ readonly byRegion: (attributes: (S)["Type"]) => string;
62
+ } | undefined;
63
+ readonly minQuality?: MatchQueueRules['minQuality'];
64
+ readonly maxTickets?: number;
65
+ readonly maxSearchNodes?: number;
66
+ readonly maxAvoidPairs?: number;
67
+ readonly maxAttributeBytes?: number;
68
+ readonly cycleMs?: number;
69
+ readonly ticketTtlMs?: number;
70
+ }
71
+
72
+ export declare interface QueueDescriptor<S extends Schema.Codec<unknown, unknown>> extends MatchQueueRules {
73
+ readonly kind: 'matchmaking.queue';
74
+ readonly room: string;
75
+ readonly ticket: S;
76
+ readonly matchFunction: MatchFunction<(S)["Type"]>;
77
+ readonly pools: {
78
+ readonly byRegion: (attributes: (S)["Type"]) => string;
79
+ } | undefined;
80
+ readonly cycleMs: number;
81
+ readonly ticketTtlMs: number;
82
+ }
83
+
84
+ /** Pure schema-bound partition and slot admission. It does not acquire SQL tickets or reserve Rooms. */
85
+ export declare const runQueueMatch: <S extends Schema.Codec<unknown, unknown>>(queue: QueueDescriptor<S>, input: {
86
+ readonly tickets: ReadonlyArray<MatchTicket<S["Type"]>>;
87
+ readonly now: number;
88
+ readonly avoid: ReadonlyArray<AvoidPair>;
89
+ }) => MatchOutput<S["Type"]>;
90
+
91
+ export { }
package/dist/queue.js ADDED
@@ -0,0 +1,79 @@
1
+ import { matchmakingError as e } from "./errors.js";
2
+ import { captureMatchInput as t, captureQueueRules as n, sameJson as r } from "./contract.js";
3
+ import { validateMatchOutput as i } from "./strategies.js";
4
+ import { Schema as a } from "effect";
5
+ // src/queue.ts
6
+ var o = (t) => {
7
+ if (!t || typeof t != "object") throw e("invalid_queue");
8
+ let r = n({
9
+ name: t.name,
10
+ teams: t.teams,
11
+ maxTickets: t.maxTickets ?? 1e4,
12
+ maxSearchNodes: t.maxSearchNodes ?? 1e5,
13
+ maxAvoidPairs: t.maxAvoidPairs ?? 1e4,
14
+ maxAttributeBytes: t.maxAttributeBytes ?? 16384,
15
+ minQuality: t.minQuality ?? {
16
+ initial: 0,
17
+ after: []
18
+ }
19
+ }), i = t.cycleMs ?? 1e3, o = t.ticketTtlMs ?? 3e5;
20
+ if (typeof t.room != "string" || !t.room.trim() || t.room.length > 256 || t.room.includes("\0") || typeof t.matchFunction != "function" || t.pools !== void 0 && typeof t.pools.byRegion != "function" || !Number.isSafeInteger(i) || i < 1 || i > 864e5 || !Number.isSafeInteger(o) || o < i || o > 864e5) throw e("invalid_queue");
21
+ try {
22
+ a.decodeUnknownSync(t.ticket);
23
+ } catch {
24
+ throw e("invalid_queue");
25
+ }
26
+ return Object.freeze({
27
+ ...r,
28
+ kind: "matchmaking.queue",
29
+ room: t.room,
30
+ ticket: t.ticket,
31
+ matchFunction: t.matchFunction,
32
+ pools: t.pools === void 0 ? void 0 : Object.freeze({ byRegion: t.pools.byRegion }),
33
+ cycleMs: i,
34
+ ticketTtlMs: o
35
+ });
36
+ }, s = (n, o) => {
37
+ let s = t({
38
+ ...o,
39
+ queue: n
40
+ }), c = a.decodeUnknownSync(n.ticket, { onExcessProperty: "error" }), l = /* @__PURE__ */ new Map();
41
+ for (let t of s.tickets) {
42
+ let i, a;
43
+ try {
44
+ i = c(t.attributes), a = n.pools === void 0 ? "all" : n.pools.byRegion(t.attributes);
45
+ } catch {
46
+ throw e("invalid_input");
47
+ }
48
+ if (typeof a != "string" || !a.trim() || a.length > 256 || a.includes("\0") || !r(i, t.attributes)) throw e("invalid_input");
49
+ l.has(a) || l.set(a, []), l.get(a).push(t);
50
+ }
51
+ let u = [], d = [], f = 0, p = !1;
52
+ for (let t of l.values()) {
53
+ if (f === s.queue.maxSearchNodes) {
54
+ d.push(...t), p = !0;
55
+ continue;
56
+ }
57
+ let r = Object.freeze({
58
+ ...s,
59
+ tickets: Object.freeze(t),
60
+ queue: Object.freeze({
61
+ ...s.queue,
62
+ maxSearchNodes: s.queue.maxSearchNodes - f
63
+ })
64
+ }), a;
65
+ try {
66
+ a = i(r, n.matchFunction(r));
67
+ } catch {
68
+ throw e("invalid_proposal");
69
+ }
70
+ u.push(...a.proposals), d.push(...a.leftover), f += a.searchNodes, p ||= a.exhausted;
71
+ }
72
+ return d.sort((e, t) => e.createdAt - t.createdAt || (e.id < t.id ? -1 : +(e.id > t.id))), Object.freeze({
73
+ proposals: Object.freeze(u),
74
+ leftover: Object.freeze(d),
75
+ searchNodes: f,
76
+ exhausted: p
77
+ });
78
+ };
79
+ export { o as defineQueue, s as runQueueMatch };
@@ -0,0 +1,27 @@
1
+ import { matchmakingError as e } from "./errors.js";
2
+ import { defineQueue as t } from "./queue.js";
3
+ // src/queueDeclarations.ts
4
+ var n = (n) => {
5
+ if (!Array.isArray(n) || n.length > 256) throw e("invalid_queue");
6
+ let r = Array.from(n, (n) => {
7
+ if (!n) throw e("invalid_queue");
8
+ return t({
9
+ name: n.name,
10
+ room: n.room,
11
+ ticket: n.ticket,
12
+ teams: n.teams,
13
+ matchFunction: n.matchFunction,
14
+ maxTickets: n.maxTickets,
15
+ maxSearchNodes: n.maxSearchNodes,
16
+ maxAvoidPairs: n.maxAvoidPairs,
17
+ maxAttributeBytes: n.maxAttributeBytes,
18
+ minQuality: n.minQuality,
19
+ cycleMs: n.cycleMs,
20
+ ticketTtlMs: n.ticketTtlMs,
21
+ ...n.pools === void 0 ? {} : { pools: n.pools }
22
+ });
23
+ });
24
+ if (new Set(r.map((e) => e.name)).size !== r.length) throw e("invalid_queue");
25
+ return Object.freeze(r);
26
+ };
27
+ export { n as captureQueueDeclarations };
@@ -0,0 +1,67 @@
1
+ import { DataStore } from '@voltro/database';
2
+ import { Effect } from 'effect';
3
+ import { Layer } from 'effect';
4
+ import { resolveIdentityAuthority } from '@voltro/protocol';
5
+ import { RoomReservePort } from '@voltro/plugin-rooms/ports';
6
+ import { Schema } from 'effect';
7
+ import { YieldableError } from 'effect/Cause';
8
+
9
+ declare interface MatchmakingAllocation {
10
+ readonly roomId: string;
11
+ /** Every member's seat session id; tokens stay with Rooms. */
12
+ readonly seats: Readonly<Record<string, string>>;
13
+ }
14
+
15
+ /** What the allocator must create for one accepted proposal: a Room with a seat per member. */
16
+ declare interface MatchmakingAllocationRequest {
17
+ readonly tenantId: string;
18
+ readonly queue: string;
19
+ /** Stable per proposal: the same tickets always yield the same key, so allocation can be idempotent. */
20
+ readonly requestId: string;
21
+ /** Member subject ids per team, in proposal order. */
22
+ readonly teams: ReadonlyArray<ReadonlyArray<string>>;
23
+ }
24
+
25
+ declare class MatchmakingError extends MatchmakingError_base {
26
+ }
27
+
28
+ declare const MatchmakingError_base: Schema.Class<MatchmakingError, Schema.TaggedStruct<"MatchmakingError", {
29
+ readonly code: Schema.Literals<readonly ["invalid_input", "invalid_queue", "invalid_proposal", "unavailable", "identity_required", "not_found", "already_searching", "conflict", "queue_full", "erased", "export_limit", "forbidden", "party_full", "already_in_party", "party_not_ready"]>;
30
+ }>, YieldableError>;
31
+
32
+ /** Room seats for a match. `roomsAllocator` (`@voltro/plugin-matchmaking/rooms`) is the Rooms implementation. */
33
+ declare interface MatchmakingRoomAllocator {
34
+ readonly allocate: (request: MatchmakingAllocationRequest) => Effect.Effect<MatchmakingAllocation, MatchmakingError>;
35
+ /** Undo a completed allocation the assignment could not record. */
36
+ readonly release: (request: MatchmakingAllocationRequest, allocation: MatchmakingAllocation) => Effect.Effect<void, MatchmakingError>;
37
+ }
38
+
39
+ /**
40
+ * The Rooms allocator: the first member creates the Room, every other member reserves a seat
41
+ * in it by id. With a Rooms binding that prepares match Rooms (`prepareMatch`), the Room is
42
+ * created once per assignment request, so a retried allocation reuses it. Each reservation runs as that member, with authority from the app's own
43
+ * resolver, so Room guards apply per player. Seat tokens stay in memory for `release`;
44
+ * an allocation that is never released is not cleaned up here (unredeemed seats expire
45
+ * with the Rooms seat TTL).
46
+ */
47
+ export declare const roomsAllocator: (options: RoomsAllocatorOptions) => MatchmakingRoomAllocator;
48
+
49
+ export declare interface RoomsAllocatorOptions {
50
+ /** The installed Rooms plugin; its API binding serves the reservations. */
51
+ readonly rooms: {
52
+ readonly services: Layer.Layer<never> | Layer.Layer<RoomReservePort>;
53
+ };
54
+ /** The Room a match plays in, per queue. */
55
+ readonly room: (request: MatchmakingAllocationRequest) => {
56
+ readonly room: string;
57
+ readonly options: unknown;
58
+ readonly pool?: string;
59
+ };
60
+ /** The app's `auth` block: each member's authority is resolved for their own seat (origin `'workflow'`). */
61
+ readonly authority?: Parameters<typeof resolveIdentityAuthority>[2];
62
+ readonly getStore?: () => DataStore | undefined;
63
+ /** Allocations kept for `release` (their seat tokens live only here). Default 1024. */
64
+ readonly maxRetained?: number;
65
+ }
66
+
67
+ export { }