@moneypot/hub 1.0.0 → 1.1.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.
- package/README.md +14 -2
- package/dist/src/index.js +52 -10
- package/dist/src/plugins/hub-add-casino.js +6 -2
- package/dist/src/process-transfers.d.ts +5 -2
- package/dist/src/process-transfers.js +47 -10
- package/dist/src/server/graphile.config.d.ts +3 -1
- package/dist/src/server/graphile.config.js +2 -1
- package/dist/src/server/index.d.ts +7 -1
- package/dist/src/server/index.js +18 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
# @moneypot/hub
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
@moneypot/hub is our official game server that integrates with any number of Moneypot casinos.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
- Extend it with custom tables and game logic.
|
|
6
|
+
- Give it an api key for each controller you've registered on each casino.
|
|
7
|
+
- It will automatically sync users, their balances, deposits, and withdrawals.
|
|
6
8
|
|
|
7
9
|
Example implementations:
|
|
8
10
|
|
|
@@ -57,3 +59,13 @@ startAndListen(options)
|
|
|
57
59
|
})
|
|
58
60
|
.catch(console.error);
|
|
59
61
|
```
|
|
62
|
+
|
|
63
|
+
## Dashboard
|
|
64
|
+
|
|
65
|
+
When the server is running, visit its admin dashboard at the `/dashboard` route.
|
|
66
|
+
|
|
67
|
+
You'll need an api key from your hub database:
|
|
68
|
+
|
|
69
|
+
```sql
|
|
70
|
+
insert into hub.api_key default values returning key;
|
|
71
|
+
```
|
package/dist/src/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import PgUpgradeSchema, { DatabaseAheadError, } from "@moneypot/pg-upgrade-schema";
|
|
2
2
|
import * as db from "./db/index.js";
|
|
3
3
|
import config from "./config.js";
|
|
4
|
-
import
|
|
4
|
+
import { createHubServer } from "./server/index.js";
|
|
5
5
|
import { initializeTransferProcessors } from "./process-transfers.js";
|
|
6
6
|
import { join } from "path";
|
|
7
7
|
import { logger, setLogger } from "./logger.js";
|
|
@@ -10,6 +10,10 @@ async function initialize(options) {
|
|
|
10
10
|
if (options.logger) {
|
|
11
11
|
setLogger(options.logger);
|
|
12
12
|
}
|
|
13
|
+
if (options.signal.aborted) {
|
|
14
|
+
logger.info("Initialization aborted by graceful shutdown");
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
13
17
|
const pgClient = db.getPgClient(config.SUPERUSER_DATABASE_URL);
|
|
14
18
|
await pgClient.connect();
|
|
15
19
|
try {
|
|
@@ -28,6 +32,10 @@ async function initialize(options) {
|
|
|
28
32
|
process.exit(1);
|
|
29
33
|
}
|
|
30
34
|
}
|
|
35
|
+
if (options.signal.aborted) {
|
|
36
|
+
logger.info("Initialization aborted by graceful shutdown");
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
31
39
|
if (options.userDatabaseMigrationsPath) {
|
|
32
40
|
await PgUpgradeSchema.default({
|
|
33
41
|
pgClient,
|
|
@@ -36,7 +44,13 @@ async function initialize(options) {
|
|
|
36
44
|
});
|
|
37
45
|
}
|
|
38
46
|
await pgClient.end();
|
|
39
|
-
|
|
47
|
+
if (options.signal.aborted) {
|
|
48
|
+
logger.info("Initialization aborted by graceful shutdown");
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
initializeTransferProcessors({
|
|
52
|
+
signal: options.signal,
|
|
53
|
+
});
|
|
40
54
|
}
|
|
41
55
|
export async function startAndListen(options) {
|
|
42
56
|
if (options.userDatabaseMigrationsPath &&
|
|
@@ -46,18 +60,46 @@ export async function startAndListen(options) {
|
|
|
46
60
|
if (!options.exportSchemaSDLPath.startsWith("/")) {
|
|
47
61
|
throw new Error(`exportSchemaSDLPath must be an absolute path, got ${options.exportSchemaSDLPath}`);
|
|
48
62
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
});
|
|
53
|
-
return server
|
|
54
|
-
.listen({
|
|
63
|
+
const abortController = new AbortController();
|
|
64
|
+
let isShuttingDown = false;
|
|
65
|
+
const hubServer = createHubServer({
|
|
55
66
|
configureApp: options.configureApp,
|
|
56
67
|
plugins: options.plugins,
|
|
57
68
|
exportSchemaSDLPath: options.exportSchemaSDLPath,
|
|
58
69
|
extraPgSchemas: options.extraPgSchemas,
|
|
59
|
-
|
|
60
|
-
|
|
70
|
+
abortSignal: abortController.signal,
|
|
71
|
+
});
|
|
72
|
+
const gracefulShutdown = async () => {
|
|
73
|
+
if (isShuttingDown) {
|
|
74
|
+
console.warn("Already shutting down.");
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
isShuttingDown = true;
|
|
78
|
+
console.log("Shutting down gracefully...");
|
|
79
|
+
abortController.abort();
|
|
80
|
+
const cleanupTasks = [
|
|
81
|
+
db.postgraphilePool.end(),
|
|
82
|
+
db.superuserPool.end(),
|
|
83
|
+
db.notifier.close(),
|
|
84
|
+
hubServer.shutdown(),
|
|
85
|
+
];
|
|
86
|
+
try {
|
|
87
|
+
await Promise.all(cleanupTasks);
|
|
88
|
+
console.log("All cleanup tasks completed. Shutdown successful.");
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
console.error("Error during cleanup:", err);
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
process.on("SIGINT", gracefulShutdown);
|
|
96
|
+
process.on("SIGTERM", gracefulShutdown);
|
|
97
|
+
await initialize({
|
|
98
|
+
userDatabaseMigrationsPath: options.userDatabaseMigrationsPath,
|
|
99
|
+
logger: options.logger,
|
|
100
|
+
signal: abortController.signal,
|
|
101
|
+
});
|
|
102
|
+
return hubServer.listen().then(() => {
|
|
61
103
|
return {
|
|
62
104
|
port: config.PORT,
|
|
63
105
|
};
|
|
@@ -47,7 +47,8 @@ export const HubAddCasinoPlugin = makeExtendSchemaPlugin((build) => {
|
|
|
47
47
|
Mutation: {
|
|
48
48
|
hubAddCasino(_, { $input }) {
|
|
49
49
|
const $identity = context().get("identity");
|
|
50
|
-
const $
|
|
50
|
+
const $abortSignal = context().get("abortSignal");
|
|
51
|
+
const $casinoId = sideEffect([$input, $identity, $abortSignal], ([input, identity, abortSignal]) => {
|
|
51
52
|
return withPgPoolTransaction(superuserPool, async (pgClient) => {
|
|
52
53
|
if (identity?.kind !== "operator") {
|
|
53
54
|
throw new GraphQLError("Unauthorized");
|
|
@@ -140,7 +141,10 @@ export const HubAddCasinoPlugin = makeExtendSchemaPlugin((build) => {
|
|
|
140
141
|
graphqlClient,
|
|
141
142
|
casinoId: casino.id,
|
|
142
143
|
});
|
|
143
|
-
startTransferProcessor({
|
|
144
|
+
startTransferProcessor({
|
|
145
|
+
casinoId: casino.id,
|
|
146
|
+
signal: abortSignal,
|
|
147
|
+
});
|
|
144
148
|
return casino.id;
|
|
145
149
|
});
|
|
146
150
|
});
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import * as db from "./db/index.js";
|
|
2
2
|
export declare function casinoIdsInProcess(): string[];
|
|
3
|
-
export declare function startTransferProcessor({ casinoId, }: {
|
|
3
|
+
export declare function startTransferProcessor({ casinoId, signal, }: {
|
|
4
4
|
casinoId: db.DbCasino["id"];
|
|
5
|
+
signal: AbortSignal;
|
|
5
6
|
}): void;
|
|
6
7
|
export declare function stopTransferProcessor(casinoId: string): void;
|
|
7
|
-
export declare function initializeTransferProcessors(
|
|
8
|
+
export declare function initializeTransferProcessors({ signal, }: {
|
|
9
|
+
signal: AbortSignal;
|
|
10
|
+
}): void;
|
|
@@ -63,7 +63,7 @@ const MP_CLAIM_TRANSFER = gql(`
|
|
|
63
63
|
const casinoMap = new Map();
|
|
64
64
|
const MIN_BACKOFF_TIME = 5000;
|
|
65
65
|
const MAX_BACKOFF_TIME = 30 * 1000;
|
|
66
|
-
async function listenForNewCasinos() {
|
|
66
|
+
async function listenForNewCasinos({ signal }) {
|
|
67
67
|
const pgClient = new pg.Client(config.SUPERUSER_DATABASE_URL);
|
|
68
68
|
await pgClient.connect();
|
|
69
69
|
const NewCasinoPayload = z.object({
|
|
@@ -89,11 +89,15 @@ async function listenForNewCasinos() {
|
|
|
89
89
|
logger.error("Error parsing new casino notification:", result.error);
|
|
90
90
|
return;
|
|
91
91
|
}
|
|
92
|
-
startTransferProcessor({ casinoId: result.data.id });
|
|
92
|
+
startTransferProcessor({ casinoId: result.data.id, signal });
|
|
93
93
|
break;
|
|
94
94
|
}
|
|
95
95
|
}
|
|
96
96
|
});
|
|
97
|
+
signal.addEventListener("abort", () => {
|
|
98
|
+
pgClient.removeAllListeners("notification");
|
|
99
|
+
pgClient.end();
|
|
100
|
+
});
|
|
97
101
|
await pgClient.query("LISTEN new_casino");
|
|
98
102
|
}
|
|
99
103
|
async function processTransfer({ casinoId, controllerId, transfer, graphqlClient, }) {
|
|
@@ -191,14 +195,18 @@ async function processTransfer({ casinoId, controllerId, transfer, graphqlClient
|
|
|
191
195
|
}
|
|
192
196
|
}
|
|
193
197
|
}
|
|
194
|
-
async function processTransfersUntilEmpty({ afterCursor, graphqlClient, casinoInfo, }) {
|
|
198
|
+
async function processTransfersUntilEmpty({ afterCursor, graphqlClient, casinoInfo, signal, }) {
|
|
195
199
|
let hasNextPage = true;
|
|
196
200
|
const timeout = (ms) => new Promise((res) => setTimeout(res, ms));
|
|
197
|
-
while (hasNextPage) {
|
|
201
|
+
while (hasNextPage && !signal.aborted) {
|
|
198
202
|
await processWithdrawalRequests({
|
|
199
203
|
casinoId: casinoInfo.id,
|
|
200
204
|
graphqlClient,
|
|
201
205
|
});
|
|
206
|
+
if (signal.aborted) {
|
|
207
|
+
logger.info(`[processTransfersUntilEmpty] Aborted by graceful shutdown.`);
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
202
210
|
const data = await graphqlClient.request(PAGINATE_TRANSFERS, {
|
|
203
211
|
controllerId: casinoInfo.controller_id,
|
|
204
212
|
limit: 10,
|
|
@@ -219,6 +227,10 @@ async function processTransfersUntilEmpty({ afterCursor, graphqlClient, casinoIn
|
|
|
219
227
|
logger.error(`transfer ${transfer.id} is not an ExperienceTransfer, skipping`);
|
|
220
228
|
continue;
|
|
221
229
|
}
|
|
230
|
+
if (signal.aborted) {
|
|
231
|
+
logger.info(`[processTransfersUntilEmpty] Aborted by graceful shutdown.`);
|
|
232
|
+
break;
|
|
233
|
+
}
|
|
222
234
|
await processTransfer({
|
|
223
235
|
casinoId: casinoInfo.id,
|
|
224
236
|
controllerId: casinoInfo.controller_id,
|
|
@@ -232,6 +244,10 @@ async function processTransfersUntilEmpty({ afterCursor, graphqlClient, casinoIn
|
|
|
232
244
|
}
|
|
233
245
|
hasNextPage = data.transfersByHolder.pageInfo.hasNextPage;
|
|
234
246
|
afterCursor = data.transfersByHolder.pageInfo.endCursor || undefined;
|
|
247
|
+
if (signal.aborted) {
|
|
248
|
+
logger.info(`[processTransfersUntilEmpty] Aborted by graceful shutdown.`);
|
|
249
|
+
break;
|
|
250
|
+
}
|
|
235
251
|
await timeout(1000);
|
|
236
252
|
}
|
|
237
253
|
return afterCursor;
|
|
@@ -292,7 +308,11 @@ async function processWithdrawals({ casinoId, graphqlClient, }) {
|
|
|
292
308
|
export function casinoIdsInProcess() {
|
|
293
309
|
return Array.from(casinoMap.keys());
|
|
294
310
|
}
|
|
295
|
-
export function startTransferProcessor({ casinoId, }) {
|
|
311
|
+
export function startTransferProcessor({ casinoId, signal, }) {
|
|
312
|
+
if (signal.aborted) {
|
|
313
|
+
logger.info(`[startTransferProcessor] AbortSignal aborted. Not starting processor for casino ${casinoId}`);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
296
316
|
logger.info(`starting processor for casino ${casinoId}`);
|
|
297
317
|
if (casinoMap.has(casinoId)) {
|
|
298
318
|
throw new Error(`processor already running for casino ${casinoId}`);
|
|
@@ -321,7 +341,7 @@ export function startTransferProcessor({ casinoId, }) {
|
|
|
321
341
|
processorState.emitter.on("once", () => {
|
|
322
342
|
shouldStop = true;
|
|
323
343
|
});
|
|
324
|
-
while (!shouldStop) {
|
|
344
|
+
while (!shouldStop && !signal.aborted) {
|
|
325
345
|
try {
|
|
326
346
|
const now = Date.now();
|
|
327
347
|
const timeToWait = Math.max(0, processorState.backoffTime - (now - processorState.lastAttempt));
|
|
@@ -344,11 +364,19 @@ export function startTransferProcessor({ casinoId, }) {
|
|
|
344
364
|
graphqlUrl: casino.graphql_url,
|
|
345
365
|
apiKey: casinoSecret.api_key,
|
|
346
366
|
});
|
|
367
|
+
if (signal.aborted) {
|
|
368
|
+
logger.info(`[startTransferProcessor] Aborted by graceful shutdown.`);
|
|
369
|
+
break;
|
|
370
|
+
}
|
|
347
371
|
await jwtService.refreshCasinoJwksTask(superuserPool, {
|
|
348
372
|
casinoId: casino.id,
|
|
349
373
|
graphqlClient,
|
|
350
374
|
});
|
|
351
375
|
if (!upsertedCurrencies) {
|
|
376
|
+
if (signal.aborted) {
|
|
377
|
+
logger.info(`[startTransferProcessor] Aborted by graceful shutdown.`);
|
|
378
|
+
break;
|
|
379
|
+
}
|
|
352
380
|
const currencies = await graphqlClient
|
|
353
381
|
.request(GET_CURRENCIES)
|
|
354
382
|
.then((res) => {
|
|
@@ -362,11 +390,20 @@ export function startTransferProcessor({ casinoId, }) {
|
|
|
362
390
|
}
|
|
363
391
|
upsertedCurrencies = true;
|
|
364
392
|
}
|
|
393
|
+
if (signal.aborted) {
|
|
394
|
+
logger.info(`[startTransferProcessor] Aborted by graceful shutdown.`);
|
|
395
|
+
break;
|
|
396
|
+
}
|
|
365
397
|
cursor = await processTransfersUntilEmpty({
|
|
366
398
|
afterCursor: cursor,
|
|
367
399
|
graphqlClient,
|
|
368
400
|
casinoInfo: { ...casino, ...casinoSecret },
|
|
401
|
+
signal,
|
|
369
402
|
});
|
|
403
|
+
if (signal.aborted) {
|
|
404
|
+
logger.info(`[startTransferProcessor] Aborted by graceful shutdown.`);
|
|
405
|
+
break;
|
|
406
|
+
}
|
|
370
407
|
await processWithdrawals({ casinoId: casino.id, graphqlClient });
|
|
371
408
|
processorState.backoffTime = MIN_BACKOFF_TIME;
|
|
372
409
|
}
|
|
@@ -376,7 +413,7 @@ export function startTransferProcessor({ casinoId, }) {
|
|
|
376
413
|
logger.debug(`Next retry for casino ${casinoId} in ${processorState.backoffTime}ms`);
|
|
377
414
|
}
|
|
378
415
|
}
|
|
379
|
-
logger.
|
|
416
|
+
logger.info(`processor stopped for casino ${casinoId}`);
|
|
380
417
|
casinoMap.delete(casinoId);
|
|
381
418
|
})();
|
|
382
419
|
}
|
|
@@ -388,7 +425,7 @@ export function stopTransferProcessor(casinoId) {
|
|
|
388
425
|
}
|
|
389
426
|
processorState.emitter.emit("stop");
|
|
390
427
|
}
|
|
391
|
-
export function initializeTransferProcessors() {
|
|
428
|
+
export function initializeTransferProcessors({ signal, }) {
|
|
392
429
|
(async () => {
|
|
393
430
|
try {
|
|
394
431
|
const casinos = await db.listCasinos(superuserPool);
|
|
@@ -402,9 +439,9 @@ export function initializeTransferProcessors() {
|
|
|
402
439
|
logger.warn(`${casino.id} has localhost endpoint "${casino.graphql_url}" while NODE_ENV=production.`);
|
|
403
440
|
}
|
|
404
441
|
logger.info(`Starting casino processor for "${casino.name}" at "${casino.graphql_url}"`);
|
|
405
|
-
startTransferProcessor({ casinoId: casino.id });
|
|
442
|
+
startTransferProcessor({ casinoId: casino.id, signal });
|
|
406
443
|
}
|
|
407
|
-
await listenForNewCasinos();
|
|
444
|
+
await listenForNewCasinos({ signal });
|
|
408
445
|
}
|
|
409
446
|
catch (e) {
|
|
410
447
|
logger.error(`Error initializing transfer processors:`, e);
|
|
@@ -15,6 +15,7 @@ type PluginIdentity = {
|
|
|
15
15
|
};
|
|
16
16
|
export type PluginContext = Grafast.Context & {
|
|
17
17
|
identity?: PluginIdentity;
|
|
18
|
+
abortSignal: AbortSignal;
|
|
18
19
|
};
|
|
19
20
|
export declare const requiredPlugins: readonly GraphileConfig.Plugin[];
|
|
20
21
|
declare global {
|
|
@@ -25,9 +26,10 @@ declare global {
|
|
|
25
26
|
}
|
|
26
27
|
}
|
|
27
28
|
export declare const defaultPlugins: readonly GraphileConfig.Plugin[];
|
|
28
|
-
export declare function createPreset({ plugins, exportSchemaSDLPath, extraPgSchemas, }: {
|
|
29
|
+
export declare function createPreset({ plugins, exportSchemaSDLPath, extraPgSchemas, abortSignal, }: {
|
|
29
30
|
plugins: readonly GraphileConfig.Plugin[];
|
|
30
31
|
exportSchemaSDLPath: string;
|
|
31
32
|
extraPgSchemas: string[];
|
|
33
|
+
abortSignal: AbortSignal;
|
|
32
34
|
}): GraphileConfig.Preset;
|
|
33
35
|
export {};
|
|
@@ -38,7 +38,7 @@ export const defaultPlugins = [
|
|
|
38
38
|
HubClaimFaucetPlugin,
|
|
39
39
|
customPgOmitArchivedPlugin("deleted"),
|
|
40
40
|
];
|
|
41
|
-
export function createPreset({ plugins, exportSchemaSDLPath, extraPgSchemas, }) {
|
|
41
|
+
export function createPreset({ plugins, exportSchemaSDLPath, extraPgSchemas, abortSignal, }) {
|
|
42
42
|
if (!exportSchemaSDLPath.startsWith("/")) {
|
|
43
43
|
throw new Error("exportSchemaSDLPath must be an absolute path");
|
|
44
44
|
}
|
|
@@ -120,6 +120,7 @@ export function createPreset({ plugins, exportSchemaSDLPath, extraPgSchemas, })
|
|
|
120
120
|
return {
|
|
121
121
|
pgSettings,
|
|
122
122
|
identity: pluginIdentity,
|
|
123
|
+
abortSignal,
|
|
123
124
|
};
|
|
124
125
|
},
|
|
125
126
|
},
|
|
@@ -1,2 +1,8 @@
|
|
|
1
1
|
import { ServerOptions } from "../index.js";
|
|
2
|
-
export
|
|
2
|
+
export type HubServer = {
|
|
3
|
+
listen: () => Promise<void>;
|
|
4
|
+
shutdown: () => Promise<void>;
|
|
5
|
+
};
|
|
6
|
+
export declare function createHubServer({ configureApp, plugins, exportSchemaSDLPath, extraPgSchemas, abortSignal, }: Pick<ServerOptions, "plugins" | "exportSchemaSDLPath" | "extraPgSchemas" | "configureApp"> & {
|
|
7
|
+
abortSignal: AbortSignal;
|
|
8
|
+
}): HubServer;
|
package/dist/src/server/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createServer } from "node:http";
|
|
1
|
+
import { createServer as createNodeServer } from "node:http";
|
|
2
2
|
import { grafserv } from "grafserv/express/v4";
|
|
3
3
|
import postgraphile from "postgraphile";
|
|
4
4
|
import { createPreset, defaultPlugins } from "./graphile.config.js";
|
|
@@ -43,27 +43,37 @@ function createExpressServer() {
|
|
|
43
43
|
});
|
|
44
44
|
return app;
|
|
45
45
|
}
|
|
46
|
-
export
|
|
46
|
+
export function createHubServer({ configureApp, plugins, exportSchemaSDLPath, extraPgSchemas, abortSignal, }) {
|
|
47
47
|
const expressServer = createExpressServer();
|
|
48
48
|
const preset = createPreset({
|
|
49
49
|
plugins: plugins ?? defaultPlugins,
|
|
50
50
|
exportSchemaSDLPath,
|
|
51
51
|
extraPgSchemas: extraPgSchemas ?? [],
|
|
52
|
+
abortSignal,
|
|
52
53
|
});
|
|
53
54
|
const pgl = postgraphile.default(preset);
|
|
54
55
|
const serv = pgl.createServ(grafserv);
|
|
55
56
|
if (configureApp) {
|
|
56
57
|
configureApp(expressServer);
|
|
57
58
|
}
|
|
58
|
-
const
|
|
59
|
-
|
|
59
|
+
const nodeServer = createNodeServer(expressServer);
|
|
60
|
+
nodeServer.on("error", (e) => {
|
|
60
61
|
logger.error(e);
|
|
61
62
|
});
|
|
62
|
-
serv.addTo(expressServer,
|
|
63
|
+
serv.addTo(expressServer, nodeServer).catch((e) => {
|
|
63
64
|
logger.error(e);
|
|
64
65
|
process.exit(1);
|
|
65
66
|
});
|
|
66
|
-
return
|
|
67
|
-
|
|
68
|
-
|
|
67
|
+
return {
|
|
68
|
+
listen: () => {
|
|
69
|
+
return new Promise((resolve) => {
|
|
70
|
+
nodeServer.listen(config.PORT, resolve);
|
|
71
|
+
});
|
|
72
|
+
},
|
|
73
|
+
shutdown: () => {
|
|
74
|
+
return new Promise((resolve) => {
|
|
75
|
+
nodeServer.close(() => resolve());
|
|
76
|
+
});
|
|
77
|
+
},
|
|
78
|
+
};
|
|
69
79
|
}
|