From 6184c11540c1208fd28a71085424ef92fa2e8098 Mon Sep 17 00:00:00 2001 From: Tad Hardesty Date: Fri, 26 Apr 2024 13:34:20 -0700 Subject: [PATCH 01/24] Tweak tsoa imports per todo comment --- packages/engine/paima-rest/package.json | 2 +- packages/engine/paima-rest/src/index.ts | 4 +--- packages/engine/paima-standalone/src/utils/input.ts | 4 +--- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/packages/engine/paima-rest/package.json b/packages/engine/paima-rest/package.json index c22fe5805..0fac814ad 100644 --- a/packages/engine/paima-rest/package.json +++ b/packages/engine/paima-rest/package.json @@ -9,7 +9,7 @@ "lint:eslint": "eslint .", "build": "tsc --build tsconfig.build.json", "prebuild": "npm run compile:api", - "compile:api": "npx tsoa spec-and-routes" + "compile:api": "tsoa spec-and-routes" }, "author": "Paima Studios", "dependencies": { diff --git a/packages/engine/paima-rest/src/index.ts b/packages/engine/paima-rest/src/index.ts index e01dec6f2..2ed20ea4f 100644 --- a/packages/engine/paima-rest/src/index.ts +++ b/packages/engine/paima-rest/src/index.ts @@ -1,7 +1,5 @@ import { RegisterRoutes } from './tsoa/routes.js'; -import * as basicControllerJson from './tsoa/swagger.json'; -// replace the above import with the one below once this is merged and released: https://github.com/prettier/prettier/issues/15699 -// import { default as basicControllerJson } from './tsoa/swagger.json' with { type: "json" }; +import { default as basicControllerJson } from './tsoa/swagger.json' with { type: 'json' }; export default RegisterRoutes; export { basicControllerJson }; export { EngineService } from './EngineService.js'; diff --git a/packages/engine/paima-standalone/src/utils/input.ts b/packages/engine/paima-standalone/src/utils/input.ts index 99c670107..533202465 100644 --- a/packages/engine/paima-standalone/src/utils/input.ts +++ b/packages/engine/paima-standalone/src/utils/input.ts @@ -125,9 +125,7 @@ export const runPaimaEngine = async (): Promise => { EngineService.INSTANCE.updateSM(stateMachine); engine.setPollingRate(ENV.POLLING_RATE); engine.addEndpoints(importTsoaFunction()); - engine.addEndpoints(server => { - RegisterRoutes(server); - }); + engine.addEndpoints(RegisterRoutes); registerDocs(importOpenApiJson()); registerValidationErrorHandler(); From 12fa90f39302ef7623915a898316eb100d239688 Mon Sep 17 00:00:00 2001 From: Tad Hardesty Date: Fri, 26 Apr 2024 13:34:33 -0700 Subject: [PATCH 02/24] Add AchievementsController --- .../src/controllers/AchievementsController.ts | 182 ++++++++++ packages/engine/paima-rest/src/tsoa/routes.ts | 155 ++++++++ .../engine/paima-rest/src/tsoa/swagger.json | 332 ++++++++++++++++++ 3 files changed, 669 insertions(+) create mode 100644 packages/engine/paima-rest/src/controllers/AchievementsController.ts diff --git a/packages/engine/paima-rest/src/controllers/AchievementsController.ts b/packages/engine/paima-rest/src/controllers/AchievementsController.ts new file mode 100644 index 000000000..674b6a6ee --- /dev/null +++ b/packages/engine/paima-rest/src/controllers/AchievementsController.ts @@ -0,0 +1,182 @@ +import { Controller, Get, Path, Query, Route } from 'tsoa'; +import { EngineService } from '../EngineService'; +import { ENV } from '@paima/utils'; + +// ---------------------------------------------------------------------------- +// PRC-1 definitions + +/** General game info */ +export interface Game { + /** Game ID */ + id: string; + /** Optional game name */ + name?: string; + /** Optional game version */ + version?: string; +} + +/** Data validity */ +export interface Validity { + /** Data block height (0 always valid) */ + block: number; + /** Data chain ID */ + chainId: number; + /** Optional date. ISO8601, like YYYY-MM-DDTHH:mm:ss.sssZ */ + time?: string; +} + +/** Player info */ +export interface Player { + /** e.g. addr1234... or 0x1234... */ + wallet: string; + /** Optional wallet-type */ + walletType?: 'cardano' | 'evm' | 'polkadot' | 'algorand' | string; + /** If data for specific user: e.g., "1", "player-1", "unique-name", etc. */ + userId?: string; + /** Player display name */ + userName?: string; +} + +export interface Achievement { + /** Unique Achievement String */ + name: string; + /** Optional: Relative Value of the Achievement */ + score?: number; + /** Optional: 'Gold' | 'Diamond' | 'Beginner' | 'Advanced' | 'Vendor' */ + category?: string; + /** Percent of players that have unlocked the achievement */ + percentCompleted?: number; + /** If achievement can be unlocked at the time. */ + isActive: boolean; + /** Achievement Display Name */ + displayName: string; + /** Achievement Description */ + description: string; + /** Hide entire achievement or description if not completed */ + spoiler?: 'all' | 'description'; + /** Optional Icon for Achievement */ + iconURI?: string; + /** Optional Icon for locked Achievement */ + iconGreyURI?: string; + /** Optional Date ISO8601 YYYY-MM-DDTHH:mm:ss.sssZ */ + startDate?: string; + /** Optional Date ISO8601 YYYY-MM-DDTHH:mm:ss.sssZ */ + endDate?: string; +} + +/** Result of "Get All Available Achievements" */ +export interface AchievementPublicList extends Game, Validity { + achievements: Achievement[]; +} + +export interface PlayerAchievement { + /** Unique Achievement String */ + name: string; + /** Is Achievement completed */ + completed: boolean; + /** Completed Date ISO8601 YYYY-MM-DDTHH:mm:ss.sssZ */ + completedDate?: Date; + /** If achievement has incremental progress */ + completedRate?: { + /** Current Progress */ + progress: number; + /** Total Progress */ + total: number; + }; +} + +/** Result of "Get Completed Achievements" */ +export interface PlayerAchievements extends Validity, Player { + /** Total number of completed achievements for the game */ + completed: number; + achievements: PlayerAchievement[]; +} + +// ---------------------------------------------------------------------------- +// Extension interface + +/** + * To implement the PRC-1 `/achievements` API, extend this class and set + * {@link achievementService} to your instance. At minimum you must override + * {@link getGame} for the API to function, and {@link getAllAchievements} to + * return a non-empty list for it to be useful. + */ +class AchievementService { + async getValidity(): Promise { + return { + chainId: ENV.CHAIN_ID, + block: await EngineService.INSTANCE.getSM().latestProcessedBlockHeight(), + // TODO? time + }; + } + + async getGame(): Promise { + throw new Error('Achievements not available for this game'); + } + + async getAllAchievements(): Promise { + return []; + } + + async getPlayer(wallet: string): Promise { + return { wallet }; + } + + async getNftOwner(nft_address: string): Promise { + throw new Error('No owner known for NFT address'); + } + + async getPlayerAchievements(wallet: string): Promise { + return []; + } +} + +/** Set this to actually implement the achievements API for your game. */ +export let achievementService: AchievementService = new AchievementService(); + +// ---------------------------------------------------------------------------- +// Controller and routes per PRC-1 + +@Route('achievements') +export class AchievementsController extends Controller { + @Get('public/list') + public async public_list( + @Query() category?: string, + @Query() isActive?: boolean + ): Promise { + return { + ...(await achievementService.getGame()), + ...(await achievementService.getValidity()), + achievements: (await achievementService.getAllAchievements()) + .filter(ach => !category || category === ach.category) + .filter(ach => !isActive || isActive === ach.isActive), + }; + } + + @Get('wallet/{wallet}') + public async wallet( + @Path() wallet: string, + /** Comma-separated list. */ + @Query() name?: string + ): Promise { + const player = await achievementService.getPlayer(wallet); + const achievements = await achievementService.getPlayerAchievements(wallet); + const nameSet = name ? new Set(name.split(',')) : null; + return { + ...(await achievementService.getValidity()), + ...player, + completed: achievements.reduce((n, ach) => n + (ach.completed ? 1 : 0), 0), + achievements: nameSet ? achievements.filter(ach => nameSet.has(ach.name)) : achievements, + }; + } + + @Get('nft/{nft_address}') + public async nft( + @Path() nft_address: string, + /** Comma-separated list. */ + @Query() name?: string + ): Promise { + const wallet = await achievementService.getNftOwner(nft_address); + return this.wallet(wallet, name); + } +} diff --git a/packages/engine/paima-rest/src/tsoa/routes.ts b/packages/engine/paima-rest/src/tsoa/routes.ts index dbd0f38bb..93ce631ec 100644 --- a/packages/engine/paima-rest/src/tsoa/routes.ts +++ b/packages/engine/paima-rest/src/tsoa/routes.ts @@ -14,6 +14,8 @@ import { EmulatedBlockActiveController } from './../controllers/BasicControllers import { DeploymentBlockheightToEmulatedController } from './../controllers/BasicControllers'; // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa import { ConfirmInputAcceptanceController } from './../controllers/BasicControllers'; +// WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa +import { AchievementsController } from './../controllers/AchievementsController'; import type { Request as ExRequest, Response as ExResponse, RequestHandler, Router } from 'express'; @@ -89,6 +91,66 @@ const models: TsoaRoute.Models = { "type": {"ref":"Result_boolean_","validators":{}}, }, // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + "Achievement": { + "dataType": "refObject", + "properties": { + "name": {"dataType":"string","required":true}, + "score": {"dataType":"double"}, + "category": {"dataType":"string"}, + "percentCompleted": {"dataType":"double"}, + "isActive": {"dataType":"boolean","required":true}, + "displayName": {"dataType":"string","required":true}, + "description": {"dataType":"string","required":true}, + "spoiler": {"dataType":"union","subSchemas":[{"dataType":"enum","enums":["all"]},{"dataType":"enum","enums":["description"]}]}, + "iconURI": {"dataType":"string"}, + "iconGreyURI": {"dataType":"string"}, + "startDate": {"dataType":"string"}, + "endDate": {"dataType":"string"}, + }, + "additionalProperties": false, + }, + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + "AchievementPublicList": { + "dataType": "refObject", + "properties": { + "id": {"dataType":"string","required":true}, + "name": {"dataType":"string"}, + "version": {"dataType":"string"}, + "block": {"dataType":"double","required":true}, + "chainId": {"dataType":"double","required":true}, + "time": {"dataType":"string"}, + "achievements": {"dataType":"array","array":{"dataType":"refObject","ref":"Achievement"},"required":true}, + }, + "additionalProperties": false, + }, + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + "PlayerAchievement": { + "dataType": "refObject", + "properties": { + "name": {"dataType":"string","required":true}, + "completed": {"dataType":"boolean","required":true}, + "completedDate": {"dataType":"datetime"}, + "completedRate": {"dataType":"nestedObjectLiteral","nestedProperties":{"total":{"dataType":"double","required":true},"progress":{"dataType":"double","required":true}}}, + }, + "additionalProperties": false, + }, + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + "PlayerAchievements": { + "dataType": "refObject", + "properties": { + "block": {"dataType":"double","required":true}, + "chainId": {"dataType":"double","required":true}, + "time": {"dataType":"string"}, + "wallet": {"dataType":"string","required":true}, + "walletType": {"dataType":"union","subSchemas":[{"dataType":"enum","enums":["cardano"]},{"dataType":"enum","enums":["evm"]},{"dataType":"enum","enums":["polkadot"]},{"dataType":"enum","enums":["algorand"]},{"dataType":"string"}]}, + "userId": {"dataType":"string"}, + "userName": {"dataType":"string"}, + "completed": {"dataType":"double","required":true}, + "achievements": {"dataType":"array","array":{"dataType":"refObject","ref":"PlayerAchievement"},"required":true}, + }, + "additionalProperties": false, + }, + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa }; const templateService = new ExpressTemplateService(models, {"noImplicitAdditionalProperties":"throw-on-extras","bodyCoercion":true}); @@ -279,6 +341,99 @@ export function RegisterRoutes(app: Router) { } }); // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + app.get('/achievements/public/list', + ...(fetchMiddlewares(AchievementsController)), + ...(fetchMiddlewares(AchievementsController.prototype.public_list)), + + function AchievementsController_public_list(request: ExRequest, response: ExResponse, next: any) { + const args: Record = { + category: {"in":"query","name":"category","dataType":"string"}, + isActive: {"in":"query","name":"isActive","dataType":"boolean"}, + }; + + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + + let validatedArgs: any[] = []; + try { + validatedArgs = templateService.getValidatedArgs({ args, request, response }); + + const controller = new AchievementsController(); + + templateService.apiHandler({ + methodName: 'public_list', + controller, + response, + next, + validatedArgs, + successStatus: undefined, + }); + } catch (err) { + return next(err); + } + }); + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + app.get('/achievements/wallet/:wallet', + ...(fetchMiddlewares(AchievementsController)), + ...(fetchMiddlewares(AchievementsController.prototype.wallet)), + + function AchievementsController_wallet(request: ExRequest, response: ExResponse, next: any) { + const args: Record = { + wallet: {"in":"path","name":"wallet","required":true,"dataType":"string"}, + name: {"in":"query","name":"name","dataType":"string"}, + }; + + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + + let validatedArgs: any[] = []; + try { + validatedArgs = templateService.getValidatedArgs({ args, request, response }); + + const controller = new AchievementsController(); + + templateService.apiHandler({ + methodName: 'wallet', + controller, + response, + next, + validatedArgs, + successStatus: undefined, + }); + } catch (err) { + return next(err); + } + }); + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + app.get('/achievements/nft/:nft_address', + ...(fetchMiddlewares(AchievementsController)), + ...(fetchMiddlewares(AchievementsController.prototype.nft)), + + function AchievementsController_nft(request: ExRequest, response: ExResponse, next: any) { + const args: Record = { + nft_address: {"in":"path","name":"nft_address","required":true,"dataType":"string"}, + name: {"in":"query","name":"name","dataType":"string"}, + }; + + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + + let validatedArgs: any[] = []; + try { + validatedArgs = templateService.getValidatedArgs({ args, request, response }); + + const controller = new AchievementsController(); + + templateService.apiHandler({ + methodName: 'nft', + controller, + response, + next, + validatedArgs, + successStatus: undefined, + }); + } catch (err) { + return next(err); + } + }); + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa diff --git a/packages/engine/paima-rest/src/tsoa/swagger.json b/packages/engine/paima-rest/src/tsoa/swagger.json index ba89b31cb..79963d3fd 100644 --- a/packages/engine/paima-rest/src/tsoa/swagger.json +++ b/packages/engine/paima-rest/src/tsoa/swagger.json @@ -135,6 +135,228 @@ }, "ConfirmInputAcceptanceResponse": { "$ref": "#/components/schemas/Result_boolean_" + }, + "Achievement": { + "properties": { + "name": { + "type": "string", + "description": "Unique Achievement String" + }, + "score": { + "type": "number", + "format": "double", + "description": "Optional: Relative Value of the Achievement" + }, + "category": { + "type": "string", + "description": "Optional: 'Gold' | 'Diamond' | 'Beginner' | 'Advanced' | 'Vendor'" + }, + "percentCompleted": { + "type": "number", + "format": "double", + "description": "Percent of players that have unlocked the achievement" + }, + "isActive": { + "type": "boolean", + "description": "If achievement can be unlocked at the time." + }, + "displayName": { + "type": "string", + "description": "Achievement Display Name" + }, + "description": { + "type": "string", + "description": "Achievement Description" + }, + "spoiler": { + "type": "string", + "enum": [ + "all", + "description" + ], + "description": "Hide entire achievement or description if not completed" + }, + "iconURI": { + "type": "string", + "description": "Optional Icon for Achievement" + }, + "iconGreyURI": { + "type": "string", + "description": "Optional Icon for locked Achievement" + }, + "startDate": { + "type": "string", + "description": "Optional Date ISO8601 YYYY-MM-DDTHH:mm:ss.sssZ" + }, + "endDate": { + "type": "string", + "description": "Optional Date ISO8601 YYYY-MM-DDTHH:mm:ss.sssZ" + } + }, + "required": [ + "name", + "isActive", + "displayName", + "description" + ], + "type": "object", + "additionalProperties": false + }, + "AchievementPublicList": { + "description": "Result of \"Get All Available Achievements\"", + "properties": { + "id": { + "type": "string", + "description": "Game ID" + }, + "name": { + "type": "string", + "description": "Optional game name" + }, + "version": { + "type": "string", + "description": "Optional game version" + }, + "block": { + "type": "number", + "format": "double", + "description": "Data block height (0 always valid)" + }, + "chainId": { + "type": "number", + "format": "double", + "description": "Data chain ID" + }, + "time": { + "type": "string", + "description": "Optional date. ISO8601, like YYYY-MM-DDTHH:mm:ss.sssZ" + }, + "achievements": { + "items": { + "$ref": "#/components/schemas/Achievement" + }, + "type": "array" + } + }, + "required": [ + "id", + "block", + "chainId", + "achievements" + ], + "type": "object", + "additionalProperties": false + }, + "PlayerAchievement": { + "properties": { + "name": { + "type": "string", + "description": "Unique Achievement String" + }, + "completed": { + "type": "boolean", + "description": "Is Achievement completed" + }, + "completedDate": { + "type": "string", + "format": "date-time", + "description": "Completed Date ISO8601 YYYY-MM-DDTHH:mm:ss.sssZ" + }, + "completedRate": { + "properties": { + "total": { + "type": "number", + "format": "double", + "description": "Total Progress" + }, + "progress": { + "type": "number", + "format": "double", + "description": "Current Progress" + } + }, + "required": [ + "total", + "progress" + ], + "type": "object", + "description": "If achievement has incremental progress" + } + }, + "required": [ + "name", + "completed" + ], + "type": "object", + "additionalProperties": false + }, + "PlayerAchievements": { + "description": "Result of \"Get Completed Achievements\"", + "properties": { + "block": { + "type": "number", + "format": "double", + "description": "Data block height (0 always valid)" + }, + "chainId": { + "type": "number", + "format": "double", + "description": "Data chain ID" + }, + "time": { + "type": "string", + "description": "Optional date. ISO8601, like YYYY-MM-DDTHH:mm:ss.sssZ" + }, + "wallet": { + "type": "string", + "description": "e.g. addr1234... or 0x1234..." + }, + "walletType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [ + "cardano", + "evm", + "polkadot", + "algorand" + ] + } + ], + "description": "Optional wallet-type" + }, + "userId": { + "type": "string", + "description": "If data for specific user: e.g., \"1\", \"player-1\", \"unique-name\", etc." + }, + "userName": { + "type": "string", + "description": "Player display name" + }, + "completed": { + "type": "number", + "format": "double", + "description": "Total number of completed achievements for the game" + }, + "achievements": { + "items": { + "$ref": "#/components/schemas/PlayerAchievement" + }, + "type": "array" + } + }, + "required": [ + "block", + "chainId", + "wallet", + "completed", + "achievements" + ], + "type": "object", + "additionalProperties": false } }, "securitySchemes": {} @@ -314,6 +536,116 @@ } ] } + }, + "/achievements/public/list": { + "get": { + "operationId": "AchievementsPublic_list", + "responses": { + "200": { + "description": "Ok", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AchievementPublicList" + } + } + } + } + }, + "security": [], + "parameters": [ + { + "in": "query", + "name": "category", + "required": false, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "isActive", + "required": false, + "schema": { + "type": "boolean" + } + } + ] + } + }, + "/achievements/wallet/{wallet}": { + "get": { + "operationId": "AchievementsWallet", + "responses": { + "200": { + "description": "Ok", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlayerAchievements" + } + } + } + } + }, + "security": [], + "parameters": [ + { + "in": "path", + "name": "wallet", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Comma-separated list.", + "in": "query", + "name": "name", + "required": false, + "schema": { + "type": "string" + } + } + ] + } + }, + "/achievements/nft/{nft_address}": { + "get": { + "operationId": "AchievementsNft", + "responses": { + "200": { + "description": "Ok", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlayerAchievements" + } + } + } + } + }, + "security": [], + "parameters": [ + { + "in": "path", + "name": "nft_address", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Comma-separated list.", + "in": "query", + "name": "name", + "required": false, + "schema": { + "type": "string" + } + } + ] + } } }, "servers": [ From 8f4f478f6372ff25ef4382fcda9ab9991a5f3745 Mon Sep 17 00:00:00 2001 From: Tad Hardesty Date: Fri, 26 Apr 2024 15:01:16 -0700 Subject: [PATCH 03/24] Include time in getValidity --- .../engine/paima-rest/src/controllers/AchievementsController.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/engine/paima-rest/src/controllers/AchievementsController.ts b/packages/engine/paima-rest/src/controllers/AchievementsController.ts index 674b6a6ee..62a0b6824 100644 --- a/packages/engine/paima-rest/src/controllers/AchievementsController.ts +++ b/packages/engine/paima-rest/src/controllers/AchievementsController.ts @@ -106,7 +106,7 @@ class AchievementService { return { chainId: ENV.CHAIN_ID, block: await EngineService.INSTANCE.getSM().latestProcessedBlockHeight(), - // TODO? time + time: new Date().toISOString(), }; } From b5bde8c11725ab72092bec4feec5c3bc7dd8da98 Mon Sep 17 00:00:00 2001 From: Tad Hardesty Date: Mon, 29 Apr 2024 11:33:51 -0700 Subject: [PATCH 04/24] Log API errors to make debugging actually possible --- packages/engine/paima-runtime/src/server.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/engine/paima-runtime/src/server.ts b/packages/engine/paima-runtime/src/server.ts index 5df2bc5d6..3d6f07fc7 100644 --- a/packages/engine/paima-runtime/src/server.ts +++ b/packages/engine/paima-runtime/src/server.ts @@ -53,6 +53,9 @@ function registerValidationErrorHandler(): void { }); } if (err instanceof Error) { + // Log rather than swallowing silently, otherwise difficult to debug. + console.warn(`${req.method} ${req.path}:`, err); + return res.status(500).json({ message: 'Internal Server Error', }); From f95430f4c4d80d2e1a9bba9598750cbfb68421fc Mon Sep 17 00:00:00 2001 From: Tad Hardesty Date: Mon, 29 Apr 2024 12:00:30 -0700 Subject: [PATCH 05/24] Move AchievementService to utils-backend so it can be imported --- packages/engine/paima-rest/package.json | 4 +- .../engine/paima-rest/src/EngineService.ts | 3 + .../src/controllers/AchievementsController.ts | 154 +++--------------- packages/engine/paima-rest/tsconfig.json | 1 + packages/engine/paima-rest/tsoa.json | 3 +- .../paima-utils-backend/src/achievements.ts | 130 +++++++++++++++ .../node-sdk/paima-utils-backend/src/index.ts | 1 + 7 files changed, 160 insertions(+), 136 deletions(-) create mode 100644 packages/node-sdk/paima-utils-backend/src/achievements.ts diff --git a/packages/engine/paima-rest/package.json b/packages/engine/paima-rest/package.json index 0fac814ad..d3796f591 100644 --- a/packages/engine/paima-rest/package.json +++ b/packages/engine/paima-rest/package.json @@ -7,8 +7,8 @@ "types": "build/index.d.ts", "scripts": { "lint:eslint": "eslint .", - "build": "tsc --build tsconfig.build.json", - "prebuild": "npm run compile:api", + "build": "npm run compile:api && tsc --build tsconfig.build.json", + "prebuild": "", "compile:api": "tsoa spec-and-routes" }, "author": "Paima Studios", diff --git a/packages/engine/paima-rest/src/EngineService.ts b/packages/engine/paima-rest/src/EngineService.ts index c9fc6e7f2..45f057901 100644 --- a/packages/engine/paima-rest/src/EngineService.ts +++ b/packages/engine/paima-rest/src/EngineService.ts @@ -1,10 +1,13 @@ import type { GameStateMachine } from '@paima/sm'; +import { AchievementService } from '@paima/utils-backend'; export class EngineService { public static INSTANCE = new EngineService(); private runtime: GameStateMachine | undefined = undefined; + public achievementService: AchievementService = new AchievementService(); + getSM = (): GameStateMachine => { if (this.runtime == null) { throw new Error('EngineService: SM not initialized'); diff --git a/packages/engine/paima-rest/src/controllers/AchievementsController.ts b/packages/engine/paima-rest/src/controllers/AchievementsController.ts index 62a0b6824..be34f260f 100644 --- a/packages/engine/paima-rest/src/controllers/AchievementsController.ts +++ b/packages/engine/paima-rest/src/controllers/AchievementsController.ts @@ -1,108 +1,23 @@ import { Controller, Get, Path, Query, Route } from 'tsoa'; import { EngineService } from '../EngineService'; import { ENV } from '@paima/utils'; +import { + AchievementService, + AchievementPublicList, + PlayerAchievements, + Validity, +} from '@paima/utils-backend'; // ---------------------------------------------------------------------------- -// PRC-1 definitions - -/** General game info */ -export interface Game { - /** Game ID */ - id: string; - /** Optional game name */ - name?: string; - /** Optional game version */ - version?: string; -} - -/** Data validity */ -export interface Validity { - /** Data block height (0 always valid) */ - block: number; - /** Data chain ID */ - chainId: number; - /** Optional date. ISO8601, like YYYY-MM-DDTHH:mm:ss.sssZ */ - time?: string; -} - -/** Player info */ -export interface Player { - /** e.g. addr1234... or 0x1234... */ - wallet: string; - /** Optional wallet-type */ - walletType?: 'cardano' | 'evm' | 'polkadot' | 'algorand' | string; - /** If data for specific user: e.g., "1", "player-1", "unique-name", etc. */ - userId?: string; - /** Player display name */ - userName?: string; -} - -export interface Achievement { - /** Unique Achievement String */ - name: string; - /** Optional: Relative Value of the Achievement */ - score?: number; - /** Optional: 'Gold' | 'Diamond' | 'Beginner' | 'Advanced' | 'Vendor' */ - category?: string; - /** Percent of players that have unlocked the achievement */ - percentCompleted?: number; - /** If achievement can be unlocked at the time. */ - isActive: boolean; - /** Achievement Display Name */ - displayName: string; - /** Achievement Description */ - description: string; - /** Hide entire achievement or description if not completed */ - spoiler?: 'all' | 'description'; - /** Optional Icon for Achievement */ - iconURI?: string; - /** Optional Icon for locked Achievement */ - iconGreyURI?: string; - /** Optional Date ISO8601 YYYY-MM-DDTHH:mm:ss.sssZ */ - startDate?: string; - /** Optional Date ISO8601 YYYY-MM-DDTHH:mm:ss.sssZ */ - endDate?: string; -} - -/** Result of "Get All Available Achievements" */ -export interface AchievementPublicList extends Game, Validity { - achievements: Achievement[]; -} - -export interface PlayerAchievement { - /** Unique Achievement String */ - name: string; - /** Is Achievement completed */ - completed: boolean; - /** Completed Date ISO8601 YYYY-MM-DDTHH:mm:ss.sssZ */ - completedDate?: Date; - /** If achievement has incremental progress */ - completedRate?: { - /** Current Progress */ - progress: number; - /** Total Progress */ - total: number; - }; -} +// Controller and routes per PRC-1 -/** Result of "Get Completed Achievements" */ -export interface PlayerAchievements extends Validity, Player { - /** Total number of completed achievements for the game */ - completed: number; - achievements: PlayerAchievement[]; +function service(): AchievementService { + return EngineService.INSTANCE.achievementService; } -// ---------------------------------------------------------------------------- -// Extension interface - -/** - * To implement the PRC-1 `/achievements` API, extend this class and set - * {@link achievementService} to your instance. At minimum you must override - * {@link getGame} for the API to function, and {@link getAllAchievements} to - * return a non-empty list for it to be useful. - */ -class AchievementService { - async getValidity(): Promise { +@Route('achievements') +export class AchievementsController extends Controller { + private async defaultValidity(): Promise { return { chainId: ENV.CHAIN_ID, block: await EngineService.INSTANCE.getSM().latestProcessedBlockHeight(), @@ -110,44 +25,16 @@ class AchievementService { }; } - async getGame(): Promise { - throw new Error('Achievements not available for this game'); - } - - async getAllAchievements(): Promise { - return []; - } - - async getPlayer(wallet: string): Promise { - return { wallet }; - } - - async getNftOwner(nft_address: string): Promise { - throw new Error('No owner known for NFT address'); - } - - async getPlayerAchievements(wallet: string): Promise { - return []; - } -} - -/** Set this to actually implement the achievements API for your game. */ -export let achievementService: AchievementService = new AchievementService(); - -// ---------------------------------------------------------------------------- -// Controller and routes per PRC-1 - -@Route('achievements') -export class AchievementsController extends Controller { @Get('public/list') public async public_list( @Query() category?: string, @Query() isActive?: boolean ): Promise { return { - ...(await achievementService.getGame()), - ...(await achievementService.getValidity()), - achievements: (await achievementService.getAllAchievements()) + ...(await service().getGame()), + ...(await this.defaultValidity()), + ...(await service().getValidity()), + achievements: (await service().getAllAchievements()) .filter(ach => !category || category === ach.category) .filter(ach => !isActive || isActive === ach.isActive), }; @@ -159,11 +46,12 @@ export class AchievementsController extends Controller { /** Comma-separated list. */ @Query() name?: string ): Promise { - const player = await achievementService.getPlayer(wallet); - const achievements = await achievementService.getPlayerAchievements(wallet); + const player = await service().getPlayer(wallet); + const achievements = await service().getPlayerAchievements(wallet); const nameSet = name ? new Set(name.split(',')) : null; return { - ...(await achievementService.getValidity()), + ...(await this.defaultValidity()), + ...(await service().getValidity()), ...player, completed: achievements.reduce((n, ach) => n + (ach.completed ? 1 : 0), 0), achievements: nameSet ? achievements.filter(ach => nameSet.has(ach.name)) : achievements, @@ -176,7 +64,7 @@ export class AchievementsController extends Controller { /** Comma-separated list. */ @Query() name?: string ): Promise { - const wallet = await achievementService.getNftOwner(nft_address); + const wallet = await service().getNftOwner(nft_address); return this.wallet(wallet, name); } } diff --git a/packages/engine/paima-rest/tsconfig.json b/packages/engine/paima-rest/tsconfig.json index 63c0723f6..55e86329e 100644 --- a/packages/engine/paima-rest/tsconfig.json +++ b/packages/engine/paima-rest/tsconfig.json @@ -8,6 +8,7 @@ "references": [ { "path": "../../paima-sdk/paima-utils/tsconfig.build.json" }, { "path": "../../node-sdk/paima-db" }, + { "path": "../../node-sdk/paima-utils-backend" }, { "path": "../paima-sm" }, ] } diff --git a/packages/engine/paima-rest/tsoa.json b/packages/engine/paima-rest/tsoa.json index c6dc0e4ed..a4b024e76 100644 --- a/packages/engine/paima-rest/tsoa.json +++ b/packages/engine/paima-rest/tsoa.json @@ -13,7 +13,8 @@ "compilerOptions": { "baseUrl": "./", "paths": { - "@paima/utils/*": ["../../paima-sdk/paima-utils/*"] + "@paima/utils": ["../../paima-sdk/paima-utils/build"], + "@paima/node-sdk/paima-utils-backend": ["../../node-sdk/paima-utils-backend/build"] } } } diff --git a/packages/node-sdk/paima-utils-backend/src/achievements.ts b/packages/node-sdk/paima-utils-backend/src/achievements.ts new file mode 100644 index 000000000..8c8cd3579 --- /dev/null +++ b/packages/node-sdk/paima-utils-backend/src/achievements.ts @@ -0,0 +1,130 @@ +// ---------------------------------------------------------------------------- +// PRC-1 definitions + +/** General game info */ +export interface Game { + /** Game ID */ + id: string; + /** Optional game name */ + name?: string; + /** Optional game version */ + version?: string; +} + +/** Data validity */ +export interface Validity { + /** Data block height (0 always valid) */ + block: number; + /** Data chain ID */ + chainId: number; + /** Optional date. ISO8601, like YYYY-MM-DDTHH:mm:ss.sssZ */ + time?: string; +} + +/** Player info */ +export interface Player { + /** e.g. addr1234... or 0x1234... */ + wallet: string; + /** Optional wallet-type */ + walletType?: 'cardano' | 'evm' | 'polkadot' | 'algorand' | string; + /** If data for specific user: e.g., "1", "player-1", "unique-name", etc. */ + userId?: string; + /** Player display name */ + userName?: string; +} + +export interface Achievement { + /** Unique Achievement String */ + name: string; + /** Optional: Relative Value of the Achievement */ + score?: number; + /** Optional: 'Gold' | 'Diamond' | 'Beginner' | 'Advanced' | 'Vendor' */ + category?: string; + /** Percent of players that have unlocked the achievement */ + percentCompleted?: number; + /** If achievement can be unlocked at the time. */ + isActive: boolean; + /** Achievement Display Name */ + displayName: string; + /** Achievement Description */ + description: string; + /** Hide entire achievement or description if not completed */ + spoiler?: 'all' | 'description'; + /** Optional Icon for Achievement */ + iconURI?: string; + /** Optional Icon for locked Achievement */ + iconGreyURI?: string; + /** Optional Date ISO8601 YYYY-MM-DDTHH:mm:ss.sssZ */ + startDate?: string; + /** Optional Date ISO8601 YYYY-MM-DDTHH:mm:ss.sssZ */ + endDate?: string; +} + +/** Result of "Get All Available Achievements" */ +export interface AchievementPublicList extends Game, Validity { + achievements: Achievement[]; +} + +export interface PlayerAchievement { + /** Unique Achievement String */ + name: string; + /** Is Achievement completed */ + completed: boolean; + /** Completed Date ISO8601 YYYY-MM-DDTHH:mm:ss.sssZ */ + completedDate?: Date; + /** If achievement has incremental progress */ + completedRate?: { + /** Current Progress */ + progress: number; + /** Total Progress */ + total: number; + }; +} + +/** Result of "Get Completed Achievements" */ +export interface PlayerAchievements extends Validity, Player { + /** Total number of completed achievements for the game */ + completed: number; + achievements: PlayerAchievement[]; +} + +// ---------------------------------------------------------------------------- +// Extension interface + +/** + * To implement the PRC-1 `/achievements` API, extend this class and export + * your child class as "AchievementService" from your API project. At minimum + * you must override {@link getGame} for the API to function, and + * {@link getAllAchievements} to return a non-empty list for it to be useful. + */ +export class AchievementService { + /** Override this to change the validity repsonse. The default likely suffices. */ + async getValidity(): Promise> { + // See AchievementsController.defaultValidity for default values. + // It's over there so it can access EngineService. + return {}; + } + + async getGame(): Promise { + throw new Error('Achievements not available for this game'); + } + + async getAllAchievements(): Promise { + return []; + } + + async getPlayer(wallet: string): Promise { + return { wallet }; + } + + async getNftOwner(nft_address: string): Promise { + throw new Error('No owner known for NFT address'); + } + + async getPlayerAchievements(wallet: string): Promise { + return []; + } + + /** Set this to actually implement the achievements API for your game. */ + static INSTANCE: AchievementService = new AchievementService(); +} diff --git a/packages/node-sdk/paima-utils-backend/src/index.ts b/packages/node-sdk/paima-utils-backend/src/index.ts index 0bfe68ff8..b66593750 100644 --- a/packages/node-sdk/paima-utils-backend/src/index.ts +++ b/packages/node-sdk/paima-utils-backend/src/index.ts @@ -3,6 +3,7 @@ import Crypto from 'crypto'; export { parseSecurityYaml } from './security.js'; export * from './cde-access.js'; export type * from './types.js'; +export * from './achievements'; export function hashTogether(data: string[]): string { return Crypto.createHash('sha256').update(data.join()).digest('base64'); From fdea349f90a9a54e18b198114e242f6e60668416 Mon Sep 17 00:00:00 2001 From: Tad Hardesty Date: Mon, 29 Apr 2024 13:40:41 -0700 Subject: [PATCH 06/24] Support games that export 'AchievementService' classes --- .../paima-standalone/src/utils/import.ts | 30 +++++++++++-------- .../paima-standalone/src/utils/input.ts | 10 +++++-- 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/packages/engine/paima-standalone/src/utils/import.ts b/packages/engine/paima-standalone/src/utils/import.ts index 367152364..d9c2d7e57 100644 --- a/packages/engine/paima-standalone/src/utils/import.ts +++ b/packages/engine/paima-standalone/src/utils/import.ts @@ -1,35 +1,39 @@ import type { GameStateTransitionFunctionRouter } from '@paima/sm'; import type { TsoaFunction } from '@paima/runtime'; - -function importFile(file: string): T { - // dynamic import cannot be used here due to PKG limitations - // eslint-disable-next-line @typescript-eslint/no-var-requires - const { default: defaultExport } = require(`${process.cwd()}/${file}`); - - return defaultExport; -} +import type { AchievementService } from '@paima/utils-backend'; export const ROUTER_FILENAME = 'packaged/gameCode.cjs'; +interface GameCodeCjs { + default: GameStateTransitionFunctionRouter; +} /** * Reads repackaged user's code placed next to the executable in `gameCode.cjs` file */ -export const importGameStateTransitionRouter = (): GameStateTransitionFunctionRouter => - importFile(ROUTER_FILENAME); +export function importGameStateTransitionRouter(): GameStateTransitionFunctionRouter { + return (require(`${process.cwd()}/${ROUTER_FILENAME}`) as GameCodeCjs).default; +} export const API_FILENAME = 'packaged/endpoints.cjs'; +export interface EndpointsCjs { + default: TsoaFunction; + AchievementService?: new () => AchievementService; +} /** * Reads repackaged user's code placed next to the executable in `endpoints.cjs` file */ -export const importTsoaFunction = (): TsoaFunction => importFile(API_FILENAME); +export function importEndpoints(): EndpointsCjs { + return require(`${process.cwd()}/${API_FILENAME}`); +} export const GAME_OPENAPI_FILENAME = 'packaged/openapi.json'; +export type OpenApiJson = object; /** * Reads repackaged user's code placed next to the executable in `endpoints.cjs` file */ -export const importOpenApiJson = (): undefined | object => { +export function importOpenApiJson(): OpenApiJson | undefined { try { return require(`${process.cwd()}/${GAME_OPENAPI_FILENAME}`); } catch (e) { return undefined; } -}; +} diff --git a/packages/engine/paima-standalone/src/utils/input.ts b/packages/engine/paima-standalone/src/utils/input.ts index 533202465..ce7234bd3 100644 --- a/packages/engine/paima-standalone/src/utils/input.ts +++ b/packages/engine/paima-standalone/src/utils/input.ts @@ -14,7 +14,7 @@ import { prepareDocumentation, prepareTemplate, } from './file.js'; -import { importOpenApiJson, importTsoaFunction } from './import.js'; +import { importOpenApiJson, importEndpoints } from './import.js'; import type { Template } from './types.js'; import RegisterRoutes, { EngineService } from '@paima/rest'; @@ -123,8 +123,14 @@ export const runPaimaEngine = async (): Promise => { const engine = paimaRuntime.initialize(funnelFactory, stateMachine, ENV.GAME_NODE_VERSION); EngineService.INSTANCE.updateSM(stateMachine); + + const endpoints = importEndpoints(); + if (endpoints.AchievementService) { + EngineService.INSTANCE.achievementService = new endpoints.AchievementService(); + } + engine.setPollingRate(ENV.POLLING_RATE); - engine.addEndpoints(importTsoaFunction()); + engine.addEndpoints(endpoints.default); engine.addEndpoints(RegisterRoutes); registerDocs(importOpenApiJson()); registerValidationErrorHandler(); From 334a1036fc0299c5992c7a169c3f7537618ef53d Mon Sep 17 00:00:00 2001 From: Tad Hardesty Date: Mon, 29 Apr 2024 15:34:47 -0700 Subject: [PATCH 07/24] Fix eslint errors --- .../paima-rest/src/controllers/AchievementsController.ts | 6 +++--- packages/engine/paima-standalone/src/utils/import.ts | 8 +++++--- packages/engine/paima-standalone/src/utils/input.ts | 2 +- packages/node-sdk/paima-utils-backend/src/achievements.ts | 2 +- packages/node-sdk/paima-utils-backend/src/index.ts | 2 +- 5 files changed, 11 insertions(+), 9 deletions(-) diff --git a/packages/engine/paima-rest/src/controllers/AchievementsController.ts b/packages/engine/paima-rest/src/controllers/AchievementsController.ts index be34f260f..9863f88b8 100644 --- a/packages/engine/paima-rest/src/controllers/AchievementsController.ts +++ b/packages/engine/paima-rest/src/controllers/AchievementsController.ts @@ -1,7 +1,7 @@ import { Controller, Get, Path, Query, Route } from 'tsoa'; -import { EngineService } from '../EngineService'; +import { EngineService } from '../EngineService.js'; import { ENV } from '@paima/utils'; -import { +import type { AchievementService, AchievementPublicList, PlayerAchievements, @@ -65,6 +65,6 @@ export class AchievementsController extends Controller { @Query() name?: string ): Promise { const wallet = await service().getNftOwner(nft_address); - return this.wallet(wallet, name); + return await this.wallet(wallet, name); } } diff --git a/packages/engine/paima-standalone/src/utils/import.ts b/packages/engine/paima-standalone/src/utils/import.ts index d9c2d7e57..234ed2a86 100644 --- a/packages/engine/paima-standalone/src/utils/import.ts +++ b/packages/engine/paima-standalone/src/utils/import.ts @@ -7,9 +7,11 @@ interface GameCodeCjs { default: GameStateTransitionFunctionRouter; } /** - * Reads repackaged user's code placed next to the executable in `gameCode.cjs` file + * Reads packaged user's code placed next to the executable in `gameCode.cjs` file. */ export function importGameStateTransitionRouter(): GameStateTransitionFunctionRouter { + // dynamic import cannot be used here due to PKG limitations + // eslint-disable-next-line @typescript-eslint/no-var-requires return (require(`${process.cwd()}/${ROUTER_FILENAME}`) as GameCodeCjs).default; } @@ -19,7 +21,7 @@ export interface EndpointsCjs { AchievementService?: new () => AchievementService; } /** - * Reads repackaged user's code placed next to the executable in `endpoints.cjs` file + * Reads packaged user's code placed next to the executable in `endpoints.cjs` file. */ export function importEndpoints(): EndpointsCjs { return require(`${process.cwd()}/${API_FILENAME}`); @@ -28,7 +30,7 @@ export function importEndpoints(): EndpointsCjs { export const GAME_OPENAPI_FILENAME = 'packaged/openapi.json'; export type OpenApiJson = object; /** - * Reads repackaged user's code placed next to the executable in `endpoints.cjs` file + * Reads packaged user's OpenAPI definitions placed next to the executable in `openapi.json` file. */ export function importOpenApiJson(): OpenApiJson | undefined { try { diff --git a/packages/engine/paima-standalone/src/utils/input.ts b/packages/engine/paima-standalone/src/utils/input.ts index ce7234bd3..36bc6650c 100644 --- a/packages/engine/paima-standalone/src/utils/input.ts +++ b/packages/engine/paima-standalone/src/utils/input.ts @@ -108,7 +108,7 @@ export const runPaimaEngine = async (): Promise => { process.exit(0); } - const [_, config] = await GlobalConfig.mainEvmConfig(); + const [, config] = await GlobalConfig.mainEvmConfig(); // Check that packed game code is available if (checkForPackedGameCode()) { diff --git a/packages/node-sdk/paima-utils-backend/src/achievements.ts b/packages/node-sdk/paima-utils-backend/src/achievements.ts index 8c8cd3579..c37a803ee 100644 --- a/packages/node-sdk/paima-utils-backend/src/achievements.ts +++ b/packages/node-sdk/paima-utils-backend/src/achievements.ts @@ -26,7 +26,7 @@ export interface Player { /** e.g. addr1234... or 0x1234... */ wallet: string; /** Optional wallet-type */ - walletType?: 'cardano' | 'evm' | 'polkadot' | 'algorand' | string; + walletType?: string; // ex: 'cardano' | 'evm' | 'polkadot' | 'algorand' /** If data for specific user: e.g., "1", "player-1", "unique-name", etc. */ userId?: string; /** Player display name */ diff --git a/packages/node-sdk/paima-utils-backend/src/index.ts b/packages/node-sdk/paima-utils-backend/src/index.ts index b66593750..bfb40d0de 100644 --- a/packages/node-sdk/paima-utils-backend/src/index.ts +++ b/packages/node-sdk/paima-utils-backend/src/index.ts @@ -3,7 +3,7 @@ import Crypto from 'crypto'; export { parseSecurityYaml } from './security.js'; export * from './cde-access.js'; export type * from './types.js'; -export * from './achievements'; +export * from './achievements.js'; export function hashTogether(data: string[]): string { return Crypto.createHash('sha256').update(data.join()).digest('base64'); From ba522548c985c7fff3fd70ae5bf3c2fe83bd2407 Mon Sep 17 00:00:00 2001 From: Tad Hardesty Date: Mon, 29 Apr 2024 15:43:43 -0700 Subject: [PATCH 08/24] Interfaces too --- packages/engine/paima-rest/src/tsoa/routes.ts | 2 +- packages/engine/paima-rest/src/tsoa/swagger.json | 15 +-------------- 2 files changed, 2 insertions(+), 15 deletions(-) diff --git a/packages/engine/paima-rest/src/tsoa/routes.ts b/packages/engine/paima-rest/src/tsoa/routes.ts index 93ce631ec..46f33370e 100644 --- a/packages/engine/paima-rest/src/tsoa/routes.ts +++ b/packages/engine/paima-rest/src/tsoa/routes.ts @@ -142,7 +142,7 @@ const models: TsoaRoute.Models = { "chainId": {"dataType":"double","required":true}, "time": {"dataType":"string"}, "wallet": {"dataType":"string","required":true}, - "walletType": {"dataType":"union","subSchemas":[{"dataType":"enum","enums":["cardano"]},{"dataType":"enum","enums":["evm"]},{"dataType":"enum","enums":["polkadot"]},{"dataType":"enum","enums":["algorand"]},{"dataType":"string"}]}, + "walletType": {"dataType":"string"}, "userId": {"dataType":"string"}, "userName": {"dataType":"string"}, "completed": {"dataType":"double","required":true}, diff --git a/packages/engine/paima-rest/src/tsoa/swagger.json b/packages/engine/paima-rest/src/tsoa/swagger.json index 79963d3fd..09144c8e5 100644 --- a/packages/engine/paima-rest/src/tsoa/swagger.json +++ b/packages/engine/paima-rest/src/tsoa/swagger.json @@ -312,20 +312,7 @@ "description": "e.g. addr1234... or 0x1234..." }, "walletType": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "string", - "enum": [ - "cardano", - "evm", - "polkadot", - "algorand" - ] - } - ], + "type": "string", "description": "Optional wallet-type" }, "userId": { From f22c8edb863b1742e6b607faa50c10d778c68567 Mon Sep 17 00:00:00 2001 From: Tad Hardesty Date: Tue, 30 Apr 2024 11:51:05 -0700 Subject: [PATCH 09/24] Run pgtyped against a temporary Postgres instance --- packages/node-sdk/paima-db/docker-compose.yml | 18 ++++++++++++++++++ packages/node-sdk/paima-db/docker-pgtyped.sh | 10 ++++++++++ packages/node-sdk/paima-db/package.json | 2 +- packages/node-sdk/paima-db/pgtypedconfig.json | 2 +- 4 files changed, 30 insertions(+), 2 deletions(-) create mode 100644 packages/node-sdk/paima-db/docker-compose.yml create mode 100755 packages/node-sdk/paima-db/docker-pgtyped.sh diff --git a/packages/node-sdk/paima-db/docker-compose.yml b/packages/node-sdk/paima-db/docker-compose.yml new file mode 100644 index 000000000..50f9e97d8 --- /dev/null +++ b/packages/node-sdk/paima-db/docker-compose.yml @@ -0,0 +1,18 @@ +# compose file for booting a temporary Postgres node for use with pgtyped +services: + database: + image: postgres:16 + environment: + POSTGRES_PASSWORD: postgres + volumes: + - "./migrations/up.sql:/docker-entrypoint-initdb.d/up.sql" + healthcheck: + # Use pg_isready to check postgres is running. + test: pg_isready -U postgres -p 5432 + interval: 20s + timeout: 5s + retries: 5 + start_period: 30s + start_interval: 1s + ports: + - "54322:5432" diff --git a/packages/node-sdk/paima-db/docker-pgtyped.sh b/packages/node-sdk/paima-db/docker-pgtyped.sh new file mode 100755 index 000000000..1684c9ba0 --- /dev/null +++ b/packages/node-sdk/paima-db/docker-pgtyped.sh @@ -0,0 +1,10 @@ +#!/bin/sh +set -eu + +trap "docker compose down" EXIT + +if ! docker compose up --wait; then + docker compose logs --no-log-prefix + exit 1 +fi +npx pgtyped "$@" diff --git a/packages/node-sdk/paima-db/package.json b/packages/node-sdk/paima-db/package.json index 1911831e2..9afe2e989 100644 --- a/packages/node-sdk/paima-db/package.json +++ b/packages/node-sdk/paima-db/package.json @@ -20,7 +20,7 @@ "homepage": "https://docs.paimastudios.com", "scripts": { "lint:eslint": "eslint .", - "compile": "npx pgtyped -w -c pgtypedconfig.json", + "compile": "./docker-pgtyped.sh -w -c pgtypedconfig.json", "build": "tsc" }, "devDependencies": { diff --git a/packages/node-sdk/paima-db/pgtypedconfig.json b/packages/node-sdk/paima-db/pgtypedconfig.json index 16e32b4ce..faf1d47de 100644 --- a/packages/node-sdk/paima-db/pgtypedconfig.json +++ b/packages/node-sdk/paima-db/pgtypedconfig.json @@ -19,7 +19,7 @@ "user": "postgres", "password": "postgres", "host": "localhost", - "port": 5432, + "port": 54322, "ssl": false } } From a3f43014bd8046c0ea74c76298b3d1d497353af1 Mon Sep 17 00:00:00 2001 From: Tad Hardesty Date: Tue, 30 Apr 2024 11:51:18 -0700 Subject: [PATCH 10/24] Add achievement tables and queries --- packages/node-sdk/paima-db/migrations/up.sql | 17 +++++ packages/node-sdk/paima-db/src/index.ts | 5 ++ .../paima-db/src/sql/achievements.queries.ts | 73 +++++++++++++++++++ .../paima-db/src/sql/achievements.sql | 14 ++++ 4 files changed, 109 insertions(+) create mode 100644 packages/node-sdk/paima-db/src/sql/achievements.queries.ts create mode 100644 packages/node-sdk/paima-db/src/sql/achievements.sql diff --git a/packages/node-sdk/paima-db/migrations/up.sql b/packages/node-sdk/paima-db/migrations/up.sql index 3bfc12861..bb75b1a5f 100644 --- a/packages/node-sdk/paima-db/migrations/up.sql +++ b/packages/node-sdk/paima-db/migrations/up.sql @@ -241,3 +241,20 @@ CREATE TABLE cde_cardano_mint_burn( output_addresses JSONB NOT NULL, PRIMARY KEY (cde_id, tx_id) ); + +CREATE TABLE achievement_type( + name TEXT NOT NULL PRIMARY KEY, + is_active BOOLEAN NOT NULL DEFAULT true, + display_name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + metadata JSONB NOT NULL DEFAULT '{}' +); + +CREATE TABLE achievement_progress( + wallet TEXT NOT NULL, + name TEXT NOT NULL, + completed_date TIMESTAMP, + progress INTEGER, + total INTEGER, + PRIMARY KEY (wallet, name) +);-- diff --git a/packages/node-sdk/paima-db/src/index.ts b/packages/node-sdk/paima-db/src/index.ts index 13a0639fa..f5a06d31a 100644 --- a/packages/node-sdk/paima-db/src/index.ts +++ b/packages/node-sdk/paima-db/src/index.ts @@ -6,6 +6,11 @@ import { DataMigrations } from './data-migrations.js'; export * from './delegate-wallet.js'; +// resolve ambiguity because pgtyped generates multiple Json types +export type { Json } from './sql/cde-generic.queries.js'; + +export * from './sql/achievements.queries.js'; +export type * from './sql/achievements.queries.js'; export * from './sql/block-heights.queries.js'; export type * from './sql/block-heights.queries.js'; export * from './sql/scheduled.queries.js'; diff --git a/packages/node-sdk/paima-db/src/sql/achievements.queries.ts b/packages/node-sdk/paima-db/src/sql/achievements.queries.ts new file mode 100644 index 000000000..a6ae66fc2 --- /dev/null +++ b/packages/node-sdk/paima-db/src/sql/achievements.queries.ts @@ -0,0 +1,73 @@ +/** Types generated for queries found in "src/sql/achievements.sql" */ +import { PreparedQuery } from '@pgtyped/runtime'; + +export type Json = null | boolean | number | string | Json[] | { [key: string]: Json }; + +/** 'GetAchievementTypes' parameters type */ +export interface IGetAchievementTypesParams { + category?: string | null | void; + is_active?: boolean | null | void; +} + +/** 'GetAchievementTypes' return type */ +export interface IGetAchievementTypesResult { + description: string; + display_name: string; + is_active: boolean; + metadata: Json; + name: string; +} + +/** 'GetAchievementTypes' query type */ +export interface IGetAchievementTypesQuery { + params: IGetAchievementTypesParams; + result: IGetAchievementTypesResult; +} + +const getAchievementTypesIR: any = {"usedParamSet":{"is_active":true,"category":true},"params":[{"name":"is_active","required":false,"transform":{"type":"scalar"},"locs":[{"a":38,"b":47},{"a":69,"b":78}]},{"name":"category","required":false,"transform":{"type":"scalar"},"locs":[{"a":98,"b":106},{"a":125,"b":133}]}],"statement":"SELECT * FROM achievement_type\nWHERE (:is_active::BOOLEAN IS NULL OR :is_active = is_active)\nAND (:category::TEXT IS NULL OR :category = metadata ->> 'category')"}; + +/** + * Query generated from SQL: + * ``` + * SELECT * FROM achievement_type + * WHERE (:is_active::BOOLEAN IS NULL OR :is_active = is_active) + * AND (:category::TEXT IS NULL OR :category = metadata ->> 'category') + * ``` + */ +export const getAchievementTypes = new PreparedQuery(getAchievementTypesIR); + + +/** 'GetAchievementProgress' parameters type */ +export interface IGetAchievementProgressParams { + names: readonly (string | null | void)[]; + wallet: string; +} + +/** 'GetAchievementProgress' return type */ +export interface IGetAchievementProgressResult { + completed_date: Date | null; + name: string; + progress: number | null; + total: number | null; + wallet: string; +} + +/** 'GetAchievementProgress' query type */ +export interface IGetAchievementProgressQuery { + params: IGetAchievementProgressParams; + result: IGetAchievementProgressResult; +} + +const getAchievementProgressIR: any = {"usedParamSet":{"wallet":true,"names":true},"params":[{"name":"names","required":false,"transform":{"type":"array_spread"},"locs":[{"a":71,"b":76},{"a":89,"b":94}]},{"name":"wallet","required":true,"transform":{"type":"scalar"},"locs":[{"a":50,"b":57}]}],"statement":"SELECT * FROM achievement_progress\nWHERE wallet = :wallet!\nAND ('*' in :names OR name IN :names)"}; + +/** + * Query generated from SQL: + * ``` + * SELECT * FROM achievement_progress + * WHERE wallet = :wallet! + * AND ('*' in :names OR name IN :names) + * ``` + */ +export const getAchievementProgress = new PreparedQuery(getAchievementProgressIR); + + diff --git a/packages/node-sdk/paima-db/src/sql/achievements.sql b/packages/node-sdk/paima-db/src/sql/achievements.sql new file mode 100644 index 000000000..84d969c46 --- /dev/null +++ b/packages/node-sdk/paima-db/src/sql/achievements.sql @@ -0,0 +1,14 @@ +/* @name getAchievementTypes */ +SELECT * FROM achievement_type +WHERE (:is_active::BOOLEAN IS NULL OR :is_active = is_active) +AND (:category::TEXT IS NULL OR :category = metadata ->> 'category') +; + +/* + @name getAchievementProgress + @param names -> (...) +*/ +SELECT * FROM achievement_progress +WHERE wallet = :wallet! +AND ('*' in :names OR name IN :names) +; From 20cb2bc33d398288d3ff8034263778d5d9a84794 Mon Sep 17 00:00:00 2001 From: Tad Hardesty Date: Tue, 30 Apr 2024 15:34:56 -0700 Subject: [PATCH 11/24] Use new SQL queries in AchievementsController, remove AchievementService --- .../engine/paima-rest/src/EngineService.ts | 3 - .../src/controllers/AchievementsController.ts | 63 ++-- packages/engine/paima-rest/src/tsoa/routes.ts | 155 --------- .../engine/paima-rest/src/tsoa/swagger.json | 319 ------------------ .../paima-standalone/src/utils/import.ts | 38 +-- .../paima-standalone/src/utils/input.ts | 10 +- .../paima-utils-backend/src/achievements.ts | 41 --- 7 files changed, 61 insertions(+), 568 deletions(-) diff --git a/packages/engine/paima-rest/src/EngineService.ts b/packages/engine/paima-rest/src/EngineService.ts index 45f057901..c9fc6e7f2 100644 --- a/packages/engine/paima-rest/src/EngineService.ts +++ b/packages/engine/paima-rest/src/EngineService.ts @@ -1,13 +1,10 @@ import type { GameStateMachine } from '@paima/sm'; -import { AchievementService } from '@paima/utils-backend'; export class EngineService { public static INSTANCE = new EngineService(); private runtime: GameStateMachine | undefined = undefined; - public achievementService: AchievementService = new AchievementService(); - getSM = (): GameStateMachine => { if (this.runtime == null) { throw new Error('EngineService: SM not initialized'); diff --git a/packages/engine/paima-rest/src/controllers/AchievementsController.ts b/packages/engine/paima-rest/src/controllers/AchievementsController.ts index 9863f88b8..8cafee53a 100644 --- a/packages/engine/paima-rest/src/controllers/AchievementsController.ts +++ b/packages/engine/paima-rest/src/controllers/AchievementsController.ts @@ -2,22 +2,24 @@ import { Controller, Get, Path, Query, Route } from 'tsoa'; import { EngineService } from '../EngineService.js'; import { ENV } from '@paima/utils'; import type { - AchievementService, AchievementPublicList, PlayerAchievements, Validity, + Game, + Player, } from '@paima/utils-backend'; +import { getAchievementTypes, getAchievementProgress } from '@paima/db'; // ---------------------------------------------------------------------------- // Controller and routes per PRC-1 -function service(): AchievementService { - return EngineService.INSTANCE.achievementService; -} - @Route('achievements') export class AchievementsController extends Controller { - private async defaultValidity(): Promise { + private async game(): Promise { + return { id: 'DERP' }; + } + + private async validity(): Promise { return { chainId: ENV.CHAIN_ID, block: await EngineService.INSTANCE.getSM().latestProcessedBlockHeight(), @@ -30,13 +32,20 @@ export class AchievementsController extends Controller { @Query() category?: string, @Query() isActive?: boolean ): Promise { + const db = EngineService.INSTANCE.getSM().getReadonlyDbConn(); + const rows = await getAchievementTypes.run({ category, is_active: isActive }, db); + return { - ...(await service().getGame()), - ...(await this.defaultValidity()), - ...(await service().getValidity()), - achievements: (await service().getAllAchievements()) - .filter(ach => !category || category === ach.category) - .filter(ach => !isActive || isActive === ach.isActive), + ...(await this.validity()), + ...(await this.game()), + achievements: rows.map(row => ({ + ...(typeof row.metadata === 'object' ? row.metadata : {}), + // Splat metadata first so that it can't override these: + name: row.name, + isActive: row.is_active, + displayName: row.display_name, + description: row.description, + })), }; } @@ -46,25 +55,39 @@ export class AchievementsController extends Controller { /** Comma-separated list. */ @Query() name?: string ): Promise { - const player = await service().getPlayer(wallet); - const achievements = await service().getPlayerAchievements(wallet); - const nameSet = name ? new Set(name.split(',')) : null; + const names = name ? name.split(',') : []; + const player: Player = { wallet }; + + const db = EngineService.INSTANCE.getSM().getReadonlyDbConn(); + const rows = await getAchievementProgress.run({ wallet, names }, db); + return { - ...(await this.defaultValidity()), - ...(await service().getValidity()), + ...(await this.validity()), ...player, - completed: achievements.reduce((n, ach) => n + (ach.completed ? 1 : 0), 0), - achievements: nameSet ? achievements.filter(ach => nameSet.has(ach.name)) : achievements, + completed: rows.reduce((n, row) => n + (row.completed_date ? 1 : 0), 0), + achievements: rows.map(row => ({ + name: row.name, + completed: Boolean(row.completed_date), + completedDate: row.completed_date ?? undefined, + completedRate: row.total + ? { + progress: row.progress ?? 0, + total: row.total, + } + : undefined, + })), }; } + /* TODO @Get('nft/{nft_address}') public async nft( @Path() nft_address: string, - /** Comma-separated list. */ + Comma-separated list. @Query() name?: string ): Promise { const wallet = await service().getNftOwner(nft_address); return await this.wallet(wallet, name); } + */ } diff --git a/packages/engine/paima-rest/src/tsoa/routes.ts b/packages/engine/paima-rest/src/tsoa/routes.ts index 46f33370e..dbd0f38bb 100644 --- a/packages/engine/paima-rest/src/tsoa/routes.ts +++ b/packages/engine/paima-rest/src/tsoa/routes.ts @@ -14,8 +14,6 @@ import { EmulatedBlockActiveController } from './../controllers/BasicControllers import { DeploymentBlockheightToEmulatedController } from './../controllers/BasicControllers'; // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa import { ConfirmInputAcceptanceController } from './../controllers/BasicControllers'; -// WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa -import { AchievementsController } from './../controllers/AchievementsController'; import type { Request as ExRequest, Response as ExResponse, RequestHandler, Router } from 'express'; @@ -91,66 +89,6 @@ const models: TsoaRoute.Models = { "type": {"ref":"Result_boolean_","validators":{}}, }, // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa - "Achievement": { - "dataType": "refObject", - "properties": { - "name": {"dataType":"string","required":true}, - "score": {"dataType":"double"}, - "category": {"dataType":"string"}, - "percentCompleted": {"dataType":"double"}, - "isActive": {"dataType":"boolean","required":true}, - "displayName": {"dataType":"string","required":true}, - "description": {"dataType":"string","required":true}, - "spoiler": {"dataType":"union","subSchemas":[{"dataType":"enum","enums":["all"]},{"dataType":"enum","enums":["description"]}]}, - "iconURI": {"dataType":"string"}, - "iconGreyURI": {"dataType":"string"}, - "startDate": {"dataType":"string"}, - "endDate": {"dataType":"string"}, - }, - "additionalProperties": false, - }, - // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa - "AchievementPublicList": { - "dataType": "refObject", - "properties": { - "id": {"dataType":"string","required":true}, - "name": {"dataType":"string"}, - "version": {"dataType":"string"}, - "block": {"dataType":"double","required":true}, - "chainId": {"dataType":"double","required":true}, - "time": {"dataType":"string"}, - "achievements": {"dataType":"array","array":{"dataType":"refObject","ref":"Achievement"},"required":true}, - }, - "additionalProperties": false, - }, - // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa - "PlayerAchievement": { - "dataType": "refObject", - "properties": { - "name": {"dataType":"string","required":true}, - "completed": {"dataType":"boolean","required":true}, - "completedDate": {"dataType":"datetime"}, - "completedRate": {"dataType":"nestedObjectLiteral","nestedProperties":{"total":{"dataType":"double","required":true},"progress":{"dataType":"double","required":true}}}, - }, - "additionalProperties": false, - }, - // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa - "PlayerAchievements": { - "dataType": "refObject", - "properties": { - "block": {"dataType":"double","required":true}, - "chainId": {"dataType":"double","required":true}, - "time": {"dataType":"string"}, - "wallet": {"dataType":"string","required":true}, - "walletType": {"dataType":"string"}, - "userId": {"dataType":"string"}, - "userName": {"dataType":"string"}, - "completed": {"dataType":"double","required":true}, - "achievements": {"dataType":"array","array":{"dataType":"refObject","ref":"PlayerAchievement"},"required":true}, - }, - "additionalProperties": false, - }, - // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa }; const templateService = new ExpressTemplateService(models, {"noImplicitAdditionalProperties":"throw-on-extras","bodyCoercion":true}); @@ -341,99 +279,6 @@ export function RegisterRoutes(app: Router) { } }); // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa - app.get('/achievements/public/list', - ...(fetchMiddlewares(AchievementsController)), - ...(fetchMiddlewares(AchievementsController.prototype.public_list)), - - function AchievementsController_public_list(request: ExRequest, response: ExResponse, next: any) { - const args: Record = { - category: {"in":"query","name":"category","dataType":"string"}, - isActive: {"in":"query","name":"isActive","dataType":"boolean"}, - }; - - // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa - - let validatedArgs: any[] = []; - try { - validatedArgs = templateService.getValidatedArgs({ args, request, response }); - - const controller = new AchievementsController(); - - templateService.apiHandler({ - methodName: 'public_list', - controller, - response, - next, - validatedArgs, - successStatus: undefined, - }); - } catch (err) { - return next(err); - } - }); - // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa - app.get('/achievements/wallet/:wallet', - ...(fetchMiddlewares(AchievementsController)), - ...(fetchMiddlewares(AchievementsController.prototype.wallet)), - - function AchievementsController_wallet(request: ExRequest, response: ExResponse, next: any) { - const args: Record = { - wallet: {"in":"path","name":"wallet","required":true,"dataType":"string"}, - name: {"in":"query","name":"name","dataType":"string"}, - }; - - // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa - - let validatedArgs: any[] = []; - try { - validatedArgs = templateService.getValidatedArgs({ args, request, response }); - - const controller = new AchievementsController(); - - templateService.apiHandler({ - methodName: 'wallet', - controller, - response, - next, - validatedArgs, - successStatus: undefined, - }); - } catch (err) { - return next(err); - } - }); - // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa - app.get('/achievements/nft/:nft_address', - ...(fetchMiddlewares(AchievementsController)), - ...(fetchMiddlewares(AchievementsController.prototype.nft)), - - function AchievementsController_nft(request: ExRequest, response: ExResponse, next: any) { - const args: Record = { - nft_address: {"in":"path","name":"nft_address","required":true,"dataType":"string"}, - name: {"in":"query","name":"name","dataType":"string"}, - }; - - // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa - - let validatedArgs: any[] = []; - try { - validatedArgs = templateService.getValidatedArgs({ args, request, response }); - - const controller = new AchievementsController(); - - templateService.apiHandler({ - methodName: 'nft', - controller, - response, - next, - validatedArgs, - successStatus: undefined, - }); - } catch (err) { - return next(err); - } - }); - // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa diff --git a/packages/engine/paima-rest/src/tsoa/swagger.json b/packages/engine/paima-rest/src/tsoa/swagger.json index 09144c8e5..ba89b31cb 100644 --- a/packages/engine/paima-rest/src/tsoa/swagger.json +++ b/packages/engine/paima-rest/src/tsoa/swagger.json @@ -135,215 +135,6 @@ }, "ConfirmInputAcceptanceResponse": { "$ref": "#/components/schemas/Result_boolean_" - }, - "Achievement": { - "properties": { - "name": { - "type": "string", - "description": "Unique Achievement String" - }, - "score": { - "type": "number", - "format": "double", - "description": "Optional: Relative Value of the Achievement" - }, - "category": { - "type": "string", - "description": "Optional: 'Gold' | 'Diamond' | 'Beginner' | 'Advanced' | 'Vendor'" - }, - "percentCompleted": { - "type": "number", - "format": "double", - "description": "Percent of players that have unlocked the achievement" - }, - "isActive": { - "type": "boolean", - "description": "If achievement can be unlocked at the time." - }, - "displayName": { - "type": "string", - "description": "Achievement Display Name" - }, - "description": { - "type": "string", - "description": "Achievement Description" - }, - "spoiler": { - "type": "string", - "enum": [ - "all", - "description" - ], - "description": "Hide entire achievement or description if not completed" - }, - "iconURI": { - "type": "string", - "description": "Optional Icon for Achievement" - }, - "iconGreyURI": { - "type": "string", - "description": "Optional Icon for locked Achievement" - }, - "startDate": { - "type": "string", - "description": "Optional Date ISO8601 YYYY-MM-DDTHH:mm:ss.sssZ" - }, - "endDate": { - "type": "string", - "description": "Optional Date ISO8601 YYYY-MM-DDTHH:mm:ss.sssZ" - } - }, - "required": [ - "name", - "isActive", - "displayName", - "description" - ], - "type": "object", - "additionalProperties": false - }, - "AchievementPublicList": { - "description": "Result of \"Get All Available Achievements\"", - "properties": { - "id": { - "type": "string", - "description": "Game ID" - }, - "name": { - "type": "string", - "description": "Optional game name" - }, - "version": { - "type": "string", - "description": "Optional game version" - }, - "block": { - "type": "number", - "format": "double", - "description": "Data block height (0 always valid)" - }, - "chainId": { - "type": "number", - "format": "double", - "description": "Data chain ID" - }, - "time": { - "type": "string", - "description": "Optional date. ISO8601, like YYYY-MM-DDTHH:mm:ss.sssZ" - }, - "achievements": { - "items": { - "$ref": "#/components/schemas/Achievement" - }, - "type": "array" - } - }, - "required": [ - "id", - "block", - "chainId", - "achievements" - ], - "type": "object", - "additionalProperties": false - }, - "PlayerAchievement": { - "properties": { - "name": { - "type": "string", - "description": "Unique Achievement String" - }, - "completed": { - "type": "boolean", - "description": "Is Achievement completed" - }, - "completedDate": { - "type": "string", - "format": "date-time", - "description": "Completed Date ISO8601 YYYY-MM-DDTHH:mm:ss.sssZ" - }, - "completedRate": { - "properties": { - "total": { - "type": "number", - "format": "double", - "description": "Total Progress" - }, - "progress": { - "type": "number", - "format": "double", - "description": "Current Progress" - } - }, - "required": [ - "total", - "progress" - ], - "type": "object", - "description": "If achievement has incremental progress" - } - }, - "required": [ - "name", - "completed" - ], - "type": "object", - "additionalProperties": false - }, - "PlayerAchievements": { - "description": "Result of \"Get Completed Achievements\"", - "properties": { - "block": { - "type": "number", - "format": "double", - "description": "Data block height (0 always valid)" - }, - "chainId": { - "type": "number", - "format": "double", - "description": "Data chain ID" - }, - "time": { - "type": "string", - "description": "Optional date. ISO8601, like YYYY-MM-DDTHH:mm:ss.sssZ" - }, - "wallet": { - "type": "string", - "description": "e.g. addr1234... or 0x1234..." - }, - "walletType": { - "type": "string", - "description": "Optional wallet-type" - }, - "userId": { - "type": "string", - "description": "If data for specific user: e.g., \"1\", \"player-1\", \"unique-name\", etc." - }, - "userName": { - "type": "string", - "description": "Player display name" - }, - "completed": { - "type": "number", - "format": "double", - "description": "Total number of completed achievements for the game" - }, - "achievements": { - "items": { - "$ref": "#/components/schemas/PlayerAchievement" - }, - "type": "array" - } - }, - "required": [ - "block", - "chainId", - "wallet", - "completed", - "achievements" - ], - "type": "object", - "additionalProperties": false } }, "securitySchemes": {} @@ -523,116 +314,6 @@ } ] } - }, - "/achievements/public/list": { - "get": { - "operationId": "AchievementsPublic_list", - "responses": { - "200": { - "description": "Ok", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AchievementPublicList" - } - } - } - } - }, - "security": [], - "parameters": [ - { - "in": "query", - "name": "category", - "required": false, - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "isActive", - "required": false, - "schema": { - "type": "boolean" - } - } - ] - } - }, - "/achievements/wallet/{wallet}": { - "get": { - "operationId": "AchievementsWallet", - "responses": { - "200": { - "description": "Ok", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PlayerAchievements" - } - } - } - } - }, - "security": [], - "parameters": [ - { - "in": "path", - "name": "wallet", - "required": true, - "schema": { - "type": "string" - } - }, - { - "description": "Comma-separated list.", - "in": "query", - "name": "name", - "required": false, - "schema": { - "type": "string" - } - } - ] - } - }, - "/achievements/nft/{nft_address}": { - "get": { - "operationId": "AchievementsNft", - "responses": { - "200": { - "description": "Ok", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PlayerAchievements" - } - } - } - } - }, - "security": [], - "parameters": [ - { - "in": "path", - "name": "nft_address", - "required": true, - "schema": { - "type": "string" - } - }, - { - "description": "Comma-separated list.", - "in": "query", - "name": "name", - "required": false, - "schema": { - "type": "string" - } - } - ] - } } }, "servers": [ diff --git a/packages/engine/paima-standalone/src/utils/import.ts b/packages/engine/paima-standalone/src/utils/import.ts index 234ed2a86..367152364 100644 --- a/packages/engine/paima-standalone/src/utils/import.ts +++ b/packages/engine/paima-standalone/src/utils/import.ts @@ -1,41 +1,35 @@ import type { GameStateTransitionFunctionRouter } from '@paima/sm'; import type { TsoaFunction } from '@paima/runtime'; -import type { AchievementService } from '@paima/utils-backend'; -export const ROUTER_FILENAME = 'packaged/gameCode.cjs'; -interface GameCodeCjs { - default: GameStateTransitionFunctionRouter; -} -/** - * Reads packaged user's code placed next to the executable in `gameCode.cjs` file. - */ -export function importGameStateTransitionRouter(): GameStateTransitionFunctionRouter { +function importFile(file: string): T { // dynamic import cannot be used here due to PKG limitations // eslint-disable-next-line @typescript-eslint/no-var-requires - return (require(`${process.cwd()}/${ROUTER_FILENAME}`) as GameCodeCjs).default; + const { default: defaultExport } = require(`${process.cwd()}/${file}`); + + return defaultExport; } +export const ROUTER_FILENAME = 'packaged/gameCode.cjs'; +/** + * Reads repackaged user's code placed next to the executable in `gameCode.cjs` file + */ +export const importGameStateTransitionRouter = (): GameStateTransitionFunctionRouter => + importFile(ROUTER_FILENAME); + export const API_FILENAME = 'packaged/endpoints.cjs'; -export interface EndpointsCjs { - default: TsoaFunction; - AchievementService?: new () => AchievementService; -} /** - * Reads packaged user's code placed next to the executable in `endpoints.cjs` file. + * Reads repackaged user's code placed next to the executable in `endpoints.cjs` file */ -export function importEndpoints(): EndpointsCjs { - return require(`${process.cwd()}/${API_FILENAME}`); -} +export const importTsoaFunction = (): TsoaFunction => importFile(API_FILENAME); export const GAME_OPENAPI_FILENAME = 'packaged/openapi.json'; -export type OpenApiJson = object; /** - * Reads packaged user's OpenAPI definitions placed next to the executable in `openapi.json` file. + * Reads repackaged user's code placed next to the executable in `endpoints.cjs` file */ -export function importOpenApiJson(): OpenApiJson | undefined { +export const importOpenApiJson = (): undefined | object => { try { return require(`${process.cwd()}/${GAME_OPENAPI_FILENAME}`); } catch (e) { return undefined; } -} +}; diff --git a/packages/engine/paima-standalone/src/utils/input.ts b/packages/engine/paima-standalone/src/utils/input.ts index 36bc6650c..7c75a9a57 100644 --- a/packages/engine/paima-standalone/src/utils/input.ts +++ b/packages/engine/paima-standalone/src/utils/input.ts @@ -14,7 +14,7 @@ import { prepareDocumentation, prepareTemplate, } from './file.js'; -import { importOpenApiJson, importEndpoints } from './import.js'; +import { importOpenApiJson, importTsoaFunction } from './import.js'; import type { Template } from './types.js'; import RegisterRoutes, { EngineService } from '@paima/rest'; @@ -123,14 +123,8 @@ export const runPaimaEngine = async (): Promise => { const engine = paimaRuntime.initialize(funnelFactory, stateMachine, ENV.GAME_NODE_VERSION); EngineService.INSTANCE.updateSM(stateMachine); - - const endpoints = importEndpoints(); - if (endpoints.AchievementService) { - EngineService.INSTANCE.achievementService = new endpoints.AchievementService(); - } - engine.setPollingRate(ENV.POLLING_RATE); - engine.addEndpoints(endpoints.default); + engine.addEndpoints(importTsoaFunction()); engine.addEndpoints(RegisterRoutes); registerDocs(importOpenApiJson()); registerValidationErrorHandler(); diff --git a/packages/node-sdk/paima-utils-backend/src/achievements.ts b/packages/node-sdk/paima-utils-backend/src/achievements.ts index c37a803ee..b243a7dbb 100644 --- a/packages/node-sdk/paima-utils-backend/src/achievements.ts +++ b/packages/node-sdk/paima-utils-backend/src/achievements.ts @@ -87,44 +87,3 @@ export interface PlayerAchievements extends Validity, Player { completed: number; achievements: PlayerAchievement[]; } - -// ---------------------------------------------------------------------------- -// Extension interface - -/** - * To implement the PRC-1 `/achievements` API, extend this class and export - * your child class as "AchievementService" from your API project. At minimum - * you must override {@link getGame} for the API to function, and - * {@link getAllAchievements} to return a non-empty list for it to be useful. - */ -export class AchievementService { - /** Override this to change the validity repsonse. The default likely suffices. */ - async getValidity(): Promise> { - // See AchievementsController.defaultValidity for default values. - // It's over there so it can access EngineService. - return {}; - } - - async getGame(): Promise { - throw new Error('Achievements not available for this game'); - } - - async getAllAchievements(): Promise { - return []; - } - - async getPlayer(wallet: string): Promise { - return { wallet }; - } - - async getNftOwner(nft_address: string): Promise { - throw new Error('No owner known for NFT address'); - } - - async getPlayerAchievements(wallet: string): Promise { - return []; - } - - /** Set this to actually implement the achievements API for your game. */ - static INSTANCE: AchievementService = new AchievementService(); -} From 369619a97fd2d77655a2176b3d816b28e5c4036a Mon Sep 17 00:00:00 2001 From: Tad Hardesty Date: Tue, 30 Apr 2024 15:57:29 -0700 Subject: [PATCH 12/24] Update for PRC-1 changes --- .../src/controllers/AchievementsController.ts | 47 ++- packages/engine/paima-rest/src/tsoa/routes.ts | 157 ++++++++ .../engine/paima-rest/src/tsoa/swagger.json | 334 ++++++++++++++++++ .../paima-utils-backend/src/achievements.ts | 12 +- 4 files changed, 530 insertions(+), 20 deletions(-) diff --git a/packages/engine/paima-rest/src/controllers/AchievementsController.ts b/packages/engine/paima-rest/src/controllers/AchievementsController.ts index 8cafee53a..e4fec143a 100644 --- a/packages/engine/paima-rest/src/controllers/AchievementsController.ts +++ b/packages/engine/paima-rest/src/controllers/AchievementsController.ts @@ -1,12 +1,13 @@ import { Controller, Get, Path, Query, Route } from 'tsoa'; import { EngineService } from '../EngineService.js'; import { ENV } from '@paima/utils'; -import type { - AchievementPublicList, - PlayerAchievements, - Validity, - Game, - Player, +import { + type AchievementPublicList, + type PlayerAchievements, + type Validity, + type Game, + type Player, + getNftOwner, } from '@paima/utils-backend'; import { getAchievementTypes, getAchievementProgress } from '@paima/db'; @@ -16,12 +17,12 @@ import { getAchievementTypes, getAchievementProgress } from '@paima/db'; @Route('achievements') export class AchievementsController extends Controller { private async game(): Promise { - return { id: 'DERP' }; + return { id: 'TODO' }; } private async validity(): Promise { return { - chainId: ENV.CHAIN_ID, + caip2: ENV.CHAIN_ID, block: await EngineService.INSTANCE.getSM().latestProcessedBlockHeight(), time: new Date().toISOString(), }; @@ -35,6 +36,7 @@ export class AchievementsController extends Controller { const db = EngineService.INSTANCE.getSM().getReadonlyDbConn(); const rows = await getAchievementTypes.run({ category, is_active: isActive }, db); + this.setHeader('Content-Language', 'en'); return { ...(await this.validity()), ...(await this.game()), @@ -61,6 +63,7 @@ export class AchievementsController extends Controller { const db = EngineService.INSTANCE.getSM().getReadonlyDbConn(); const rows = await getAchievementProgress.run({ wallet, names }, db); + this.setHeader('Content-Language', 'en'); return { ...(await this.validity()), ...player, @@ -79,15 +82,29 @@ export class AchievementsController extends Controller { }; } - /* TODO - @Get('nft/{nft_address}') + @Get('erc/{erc}/{cde}/{token_id}') public async nft( - @Path() nft_address: string, - Comma-separated list. + @Path() erc: string, + @Path() cde: string, + @Path() token_id: string, @Query() name?: string ): Promise { - const wallet = await service().getNftOwner(nft_address); - return await this.wallet(wallet, name); + const db = EngineService.INSTANCE.getSM().getReadonlyDbConn(); + this.setHeader('Content-Language', 'en'); + + switch (erc) { + case 'erc721': + const wallet = await getNftOwner(db, cde, BigInt(token_id)); + if (wallet) { + return await this.wallet(wallet, name); + } + break; + case 'erc6551': + // TODO + break; + } + + this.setStatus(404); + throw new Error('Not found'); } - */ } diff --git a/packages/engine/paima-rest/src/tsoa/routes.ts b/packages/engine/paima-rest/src/tsoa/routes.ts index dbd0f38bb..29d11799b 100644 --- a/packages/engine/paima-rest/src/tsoa/routes.ts +++ b/packages/engine/paima-rest/src/tsoa/routes.ts @@ -14,6 +14,8 @@ import { EmulatedBlockActiveController } from './../controllers/BasicControllers import { DeploymentBlockheightToEmulatedController } from './../controllers/BasicControllers'; // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa import { ConfirmInputAcceptanceController } from './../controllers/BasicControllers'; +// WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa +import { AchievementsController } from './../controllers/AchievementsController'; import type { Request as ExRequest, Response as ExResponse, RequestHandler, Router } from 'express'; @@ -89,6 +91,66 @@ const models: TsoaRoute.Models = { "type": {"ref":"Result_boolean_","validators":{}}, }, // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + "Achievement": { + "dataType": "refObject", + "properties": { + "name": {"dataType":"string","required":true}, + "score": {"dataType":"double"}, + "category": {"dataType":"string"}, + "percentCompleted": {"dataType":"double"}, + "isActive": {"dataType":"boolean","required":true}, + "displayName": {"dataType":"string","required":true}, + "description": {"dataType":"string","required":true}, + "spoiler": {"dataType":"union","subSchemas":[{"dataType":"enum","enums":["all"]},{"dataType":"enum","enums":["description"]}]}, + "iconURI": {"dataType":"string"}, + "iconGreyURI": {"dataType":"string"}, + "startDate": {"dataType":"string"}, + "endDate": {"dataType":"string"}, + }, + "additionalProperties": false, + }, + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + "AchievementPublicList": { + "dataType": "refObject", + "properties": { + "id": {"dataType":"string","required":true}, + "name": {"dataType":"string"}, + "version": {"dataType":"string"}, + "block": {"dataType":"double","required":true}, + "caip2": {"dataType":"double","required":true}, + "time": {"dataType":"string"}, + "achievements": {"dataType":"array","array":{"dataType":"refObject","ref":"Achievement"},"required":true}, + }, + "additionalProperties": false, + }, + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + "PlayerAchievement": { + "dataType": "refObject", + "properties": { + "name": {"dataType":"string","required":true}, + "completed": {"dataType":"boolean","required":true}, + "completedDate": {"dataType":"datetime"}, + "completedRate": {"dataType":"nestedObjectLiteral","nestedProperties":{"total":{"dataType":"double","required":true},"progress":{"dataType":"double","required":true}}}, + }, + "additionalProperties": false, + }, + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + "PlayerAchievements": { + "dataType": "refObject", + "properties": { + "block": {"dataType":"double","required":true}, + "caip2": {"dataType":"double","required":true}, + "time": {"dataType":"string"}, + "wallet": {"dataType":"string","required":true}, + "walletType": {"dataType":"string"}, + "userId": {"dataType":"string"}, + "userName": {"dataType":"string"}, + "completed": {"dataType":"double","required":true}, + "achievements": {"dataType":"array","array":{"dataType":"refObject","ref":"PlayerAchievement"},"required":true}, + }, + "additionalProperties": false, + }, + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa }; const templateService = new ExpressTemplateService(models, {"noImplicitAdditionalProperties":"throw-on-extras","bodyCoercion":true}); @@ -279,6 +341,101 @@ export function RegisterRoutes(app: Router) { } }); // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + app.get('/achievements/public/list', + ...(fetchMiddlewares(AchievementsController)), + ...(fetchMiddlewares(AchievementsController.prototype.public_list)), + + function AchievementsController_public_list(request: ExRequest, response: ExResponse, next: any) { + const args: Record = { + category: {"in":"query","name":"category","dataType":"string"}, + isActive: {"in":"query","name":"isActive","dataType":"boolean"}, + }; + + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + + let validatedArgs: any[] = []; + try { + validatedArgs = templateService.getValidatedArgs({ args, request, response }); + + const controller = new AchievementsController(); + + templateService.apiHandler({ + methodName: 'public_list', + controller, + response, + next, + validatedArgs, + successStatus: undefined, + }); + } catch (err) { + return next(err); + } + }); + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + app.get('/achievements/wallet/:wallet', + ...(fetchMiddlewares(AchievementsController)), + ...(fetchMiddlewares(AchievementsController.prototype.wallet)), + + function AchievementsController_wallet(request: ExRequest, response: ExResponse, next: any) { + const args: Record = { + wallet: {"in":"path","name":"wallet","required":true,"dataType":"string"}, + name: {"in":"query","name":"name","dataType":"string"}, + }; + + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + + let validatedArgs: any[] = []; + try { + validatedArgs = templateService.getValidatedArgs({ args, request, response }); + + const controller = new AchievementsController(); + + templateService.apiHandler({ + methodName: 'wallet', + controller, + response, + next, + validatedArgs, + successStatus: undefined, + }); + } catch (err) { + return next(err); + } + }); + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + app.get('/achievements/erc/:erc/:cde/:token_id', + ...(fetchMiddlewares(AchievementsController)), + ...(fetchMiddlewares(AchievementsController.prototype.nft)), + + function AchievementsController_nft(request: ExRequest, response: ExResponse, next: any) { + const args: Record = { + erc: {"in":"path","name":"erc","required":true,"dataType":"string"}, + cde: {"in":"path","name":"cde","required":true,"dataType":"string"}, + token_id: {"in":"path","name":"token_id","required":true,"dataType":"string"}, + name: {"in":"query","name":"name","dataType":"string"}, + }; + + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + + let validatedArgs: any[] = []; + try { + validatedArgs = templateService.getValidatedArgs({ args, request, response }); + + const controller = new AchievementsController(); + + templateService.apiHandler({ + methodName: 'nft', + controller, + response, + next, + validatedArgs, + successStatus: undefined, + }); + } catch (err) { + return next(err); + } + }); + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa diff --git a/packages/engine/paima-rest/src/tsoa/swagger.json b/packages/engine/paima-rest/src/tsoa/swagger.json index ba89b31cb..511a75083 100644 --- a/packages/engine/paima-rest/src/tsoa/swagger.json +++ b/packages/engine/paima-rest/src/tsoa/swagger.json @@ -135,6 +135,215 @@ }, "ConfirmInputAcceptanceResponse": { "$ref": "#/components/schemas/Result_boolean_" + }, + "Achievement": { + "properties": { + "name": { + "type": "string", + "description": "Unique Achievement String" + }, + "score": { + "type": "number", + "format": "double", + "description": "Optional: Relative Value of the Achievement" + }, + "category": { + "type": "string", + "description": "Optional: 'Gold' | 'Diamond' | 'Beginner' | 'Advanced' | 'Vendor'" + }, + "percentCompleted": { + "type": "number", + "format": "double", + "description": "Percent of players that have unlocked the achievement" + }, + "isActive": { + "type": "boolean", + "description": "If achievement can be unlocked at the time." + }, + "displayName": { + "type": "string", + "description": "Achievement Display Name" + }, + "description": { + "type": "string", + "description": "Achievement Description" + }, + "spoiler": { + "type": "string", + "enum": [ + "all", + "description" + ], + "description": "Hide entire achievement or description if not completed" + }, + "iconURI": { + "type": "string", + "description": "Optional Icon for Achievement" + }, + "iconGreyURI": { + "type": "string", + "description": "Optional Icon for locked Achievement" + }, + "startDate": { + "type": "string", + "description": "Optional Date ISO8601 YYYY-MM-DDTHH:mm:ss.sssZ" + }, + "endDate": { + "type": "string", + "description": "Optional Date ISO8601 YYYY-MM-DDTHH:mm:ss.sssZ" + } + }, + "required": [ + "name", + "isActive", + "displayName", + "description" + ], + "type": "object", + "additionalProperties": false + }, + "AchievementPublicList": { + "description": "Result of \"Get All Available Achievements\"", + "properties": { + "id": { + "type": "string", + "description": "Game ID" + }, + "name": { + "type": "string", + "description": "Optional game name" + }, + "version": { + "type": "string", + "description": "Optional game version" + }, + "block": { + "type": "number", + "format": "double", + "description": "Data block height (0 always valid)" + }, + "caip2": { + "type": "number", + "format": "double", + "description": "CAIP-2 blockchain identifier" + }, + "time": { + "type": "string", + "description": "Optional date. ISO8601, like YYYY-MM-DDTHH:mm:ss.sssZ" + }, + "achievements": { + "items": { + "$ref": "#/components/schemas/Achievement" + }, + "type": "array" + } + }, + "required": [ + "id", + "block", + "caip2", + "achievements" + ], + "type": "object", + "additionalProperties": false + }, + "PlayerAchievement": { + "properties": { + "name": { + "type": "string", + "description": "Unique Achievement String" + }, + "completed": { + "type": "boolean", + "description": "Is Achievement completed" + }, + "completedDate": { + "type": "string", + "format": "date-time", + "description": "Completed Date ISO8601 YYYY-MM-DDTHH:mm:ss.sssZ" + }, + "completedRate": { + "properties": { + "total": { + "type": "number", + "format": "double", + "description": "Total Progress" + }, + "progress": { + "type": "number", + "format": "double", + "description": "Current Progress" + } + }, + "required": [ + "total", + "progress" + ], + "type": "object", + "description": "If achievement has incremental progress" + } + }, + "required": [ + "name", + "completed" + ], + "type": "object", + "additionalProperties": false + }, + "PlayerAchievements": { + "description": "Result of \"Get Completed Achievements\"", + "properties": { + "block": { + "type": "number", + "format": "double", + "description": "Data block height (0 always valid)" + }, + "caip2": { + "type": "number", + "format": "double", + "description": "CAIP-2 blockchain identifier" + }, + "time": { + "type": "string", + "description": "Optional date. ISO8601, like YYYY-MM-DDTHH:mm:ss.sssZ" + }, + "wallet": { + "type": "string", + "description": "e.g. addr1234... or 0x1234..." + }, + "walletType": { + "type": "string", + "description": "(Optional) Wallet-type" + }, + "userId": { + "type": "string", + "description": "(Optional) User ID for a specific player account. This value should be\nimmutable and define a specific account, as the wallet might be migrated\nor updated." + }, + "userName": { + "type": "string", + "description": "(Optional) Player display name" + }, + "completed": { + "type": "number", + "format": "double", + "description": "Total number of completed achievements for the game" + }, + "achievements": { + "items": { + "$ref": "#/components/schemas/PlayerAchievement" + }, + "type": "array" + } + }, + "required": [ + "block", + "caip2", + "wallet", + "completed", + "achievements" + ], + "type": "object", + "additionalProperties": false } }, "securitySchemes": {} @@ -314,6 +523,131 @@ } ] } + }, + "/achievements/public/list": { + "get": { + "operationId": "AchievementsPublic_list", + "responses": { + "200": { + "description": "Ok", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AchievementPublicList" + } + } + } + } + }, + "security": [], + "parameters": [ + { + "in": "query", + "name": "category", + "required": false, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "isActive", + "required": false, + "schema": { + "type": "boolean" + } + } + ] + } + }, + "/achievements/wallet/{wallet}": { + "get": { + "operationId": "AchievementsWallet", + "responses": { + "200": { + "description": "Ok", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlayerAchievements" + } + } + } + } + }, + "security": [], + "parameters": [ + { + "in": "path", + "name": "wallet", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Comma-separated list.", + "in": "query", + "name": "name", + "required": false, + "schema": { + "type": "string" + } + } + ] + } + }, + "/achievements/erc/{erc}/{cde}/{token_id}": { + "get": { + "operationId": "AchievementsNft", + "responses": { + "200": { + "description": "Ok", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlayerAchievements" + } + } + } + } + }, + "security": [], + "parameters": [ + { + "in": "path", + "name": "erc", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "cde", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "token_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "name", + "required": false, + "schema": { + "type": "string" + } + } + ] + } } }, "servers": [ diff --git a/packages/node-sdk/paima-utils-backend/src/achievements.ts b/packages/node-sdk/paima-utils-backend/src/achievements.ts index b243a7dbb..a748c9909 100644 --- a/packages/node-sdk/paima-utils-backend/src/achievements.ts +++ b/packages/node-sdk/paima-utils-backend/src/achievements.ts @@ -15,8 +15,8 @@ export interface Game { export interface Validity { /** Data block height (0 always valid) */ block: number; - /** Data chain ID */ - chainId: number; + /** CAIP-2 blockchain identifier */ + caip2: number; /** Optional date. ISO8601, like YYYY-MM-DDTHH:mm:ss.sssZ */ time?: string; } @@ -25,11 +25,13 @@ export interface Validity { export interface Player { /** e.g. addr1234... or 0x1234... */ wallet: string; - /** Optional wallet-type */ + /** (Optional) Wallet-type */ walletType?: string; // ex: 'cardano' | 'evm' | 'polkadot' | 'algorand' - /** If data for specific user: e.g., "1", "player-1", "unique-name", etc. */ + /** (Optional) User ID for a specific player account. This value should be + * immutable and define a specific account, as the wallet might be migrated + * or updated. */ userId?: string; - /** Player display name */ + /** (Optional) Player display name */ userName?: string; } From 4609d3e6e947fe65c3f14bbebe71b301112d7298 Mon Sep 17 00:00:00 2001 From: Tad Hardesty Date: Tue, 30 Apr 2024 17:36:31 -0700 Subject: [PATCH 13/24] Add table defs for new tables, clarify TODOs, fix issues --- .../src/controllers/AchievementsController.ts | 11 ++-- packages/engine/paima-rest/src/tsoa/routes.ts | 4 +- .../engine/paima-rest/src/tsoa/swagger.json | 6 +-- packages/node-sdk/paima-db/migrations/up.sql | 2 +- packages/node-sdk/paima-db/src/index.ts | 2 +- .../node-sdk/paima-db/src/paima-tables.ts | 51 +++++++++++++++++++ .../paima-utils-backend/src/achievements.ts | 2 +- 7 files changed, 65 insertions(+), 13 deletions(-) diff --git a/packages/engine/paima-rest/src/controllers/AchievementsController.ts b/packages/engine/paima-rest/src/controllers/AchievementsController.ts index e4fec143a..2161224d4 100644 --- a/packages/engine/paima-rest/src/controllers/AchievementsController.ts +++ b/packages/engine/paima-rest/src/controllers/AchievementsController.ts @@ -17,12 +17,15 @@ import { getAchievementTypes, getAchievementProgress } from '@paima/db'; @Route('achievements') export class AchievementsController extends Controller { private async game(): Promise { - return { id: 'TODO' }; + return { + id: 'TODO', + // TODO: name, version + }; } private async validity(): Promise { return { - caip2: ENV.CHAIN_ID, + caip2: `eip155:${ENV.CHAIN_ID}`, block: await EngineService.INSTANCE.getSM().latestProcessedBlockHeight(), time: new Date().toISOString(), }; @@ -57,8 +60,8 @@ export class AchievementsController extends Controller { /** Comma-separated list. */ @Query() name?: string ): Promise { - const names = name ? name.split(',') : []; - const player: Player = { wallet }; + const names = name ? name.split(',') : ['*']; + const player: Player = { wallet }; // TODO: walletType, userId, userName const db = EngineService.INSTANCE.getSM().getReadonlyDbConn(); const rows = await getAchievementProgress.run({ wallet, names }, db); diff --git a/packages/engine/paima-rest/src/tsoa/routes.ts b/packages/engine/paima-rest/src/tsoa/routes.ts index 29d11799b..b842f4101 100644 --- a/packages/engine/paima-rest/src/tsoa/routes.ts +++ b/packages/engine/paima-rest/src/tsoa/routes.ts @@ -117,7 +117,7 @@ const models: TsoaRoute.Models = { "name": {"dataType":"string"}, "version": {"dataType":"string"}, "block": {"dataType":"double","required":true}, - "caip2": {"dataType":"double","required":true}, + "caip2": {"dataType":"string","required":true}, "time": {"dataType":"string"}, "achievements": {"dataType":"array","array":{"dataType":"refObject","ref":"Achievement"},"required":true}, }, @@ -139,7 +139,7 @@ const models: TsoaRoute.Models = { "dataType": "refObject", "properties": { "block": {"dataType":"double","required":true}, - "caip2": {"dataType":"double","required":true}, + "caip2": {"dataType":"string","required":true}, "time": {"dataType":"string"}, "wallet": {"dataType":"string","required":true}, "walletType": {"dataType":"string"}, diff --git a/packages/engine/paima-rest/src/tsoa/swagger.json b/packages/engine/paima-rest/src/tsoa/swagger.json index 511a75083..6e4bdf801 100644 --- a/packages/engine/paima-rest/src/tsoa/swagger.json +++ b/packages/engine/paima-rest/src/tsoa/swagger.json @@ -223,8 +223,7 @@ "description": "Data block height (0 always valid)" }, "caip2": { - "type": "number", - "format": "double", + "type": "string", "description": "CAIP-2 blockchain identifier" }, "time": { @@ -299,8 +298,7 @@ "description": "Data block height (0 always valid)" }, "caip2": { - "type": "number", - "format": "double", + "type": "string", "description": "CAIP-2 blockchain identifier" }, "time": { diff --git a/packages/node-sdk/paima-db/migrations/up.sql b/packages/node-sdk/paima-db/migrations/up.sql index bb75b1a5f..5bd833898 100644 --- a/packages/node-sdk/paima-db/migrations/up.sql +++ b/packages/node-sdk/paima-db/migrations/up.sql @@ -257,4 +257,4 @@ CREATE TABLE achievement_progress( progress INTEGER, total INTEGER, PRIMARY KEY (wallet, name) -);-- +); diff --git a/packages/node-sdk/paima-db/src/index.ts b/packages/node-sdk/paima-db/src/index.ts index f5a06d31a..563af261a 100644 --- a/packages/node-sdk/paima-db/src/index.ts +++ b/packages/node-sdk/paima-db/src/index.ts @@ -6,7 +6,7 @@ import { DataMigrations } from './data-migrations.js'; export * from './delegate-wallet.js'; -// resolve ambiguity because pgtyped generates multiple Json types +// https://github.com/adelsz/pgtyped/issues/565 export type { Json } from './sql/cde-generic.queries.js'; export * from './sql/achievements.queries.js'; diff --git a/packages/node-sdk/paima-db/src/paima-tables.ts b/packages/node-sdk/paima-db/src/paima-tables.ts index 6566161b9..b4a2b2e43 100644 --- a/packages/node-sdk/paima-db/src/paima-tables.ts +++ b/packages/node-sdk/paima-db/src/paima-tables.ts @@ -576,6 +576,55 @@ const TABLE_DATA_DELEGATIONS: TableData = { creationQuery: QUERY_CREATE_TABLE_DELEGATIONS, }; +const QUERY_CREATE_TABLE_ACHIEVEMENT_TYPE = ` +CREATE TABLE achievement_type( + name TEXT NOT NULL PRIMARY KEY, + is_active BOOLEAN NOT NULL DEFAULT true, + display_name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + metadata JSONB NOT NULL DEFAULT '{}' +); +`; + +const TABLE_DATA_ACHIEVEMENT_TYPE: TableData = { + tableName: 'achievement_type', + primaryKeyColumns: ['name'], + columnData: packTuples([ + ['name', 'text', 'NO', ''], + ['is_active', 'boolean', 'NO', 'true'], + ['display_name', 'text', 'NO', ''], + ['description', 'text', 'NO', "''"], + ['metadata', 'jsonb', 'NO', "'{}'"], + ]), + serialColumns: [], + creationQuery: QUERY_CREATE_TABLE_ACHIEVEMENT_TYPE, +}; + +const QUERY_CREATE_TABLE_ACHIEVEMENT_PROGRESS = ` +CREATE TABLE achievement_progress( + wallet TEXT NOT NULL, + name TEXT NOT NULL, + completed_date TIMESTAMP, + progress INTEGER, + total INTEGER, + PRIMARY KEY (wallet, name) +); +`; + +const TABLE_DATA_ACHIEVEMENT_PROGRESS: TableData = { + tableName: 'achievement_progress', + primaryKeyColumns: ['wallet', 'name'], + columnData: packTuples([ + ['wallet', 'text', 'NO', ''], + ['name', 'text', 'NO', ''], + ['completed_date', 'timestamp', 'YES', ''], + ['progress', 'integer', 'YES', ''], + ['total', 'integer', 'YES', ''], + ]), + serialColumns: [], + creationQuery: QUERY_CREATE_TABLE_ACHIEVEMENT_PROGRESS, +}; + const FUNCTION_NOTIFY_WALLET_CONNECT: string = ` create or replace function public.notify_wallet_connect() returns trigger @@ -663,4 +712,6 @@ export const TABLES: TableData[] = [ TABLE_DATA_CDE_TRACKING_CARDANO_PAGINATION, TABLE_DATA_CDE_CARDANO_TRANSFER, TABLE_DATA_CDE_CARDANO_MINT_BURN, + TABLE_DATA_ACHIEVEMENT_TYPE, + TABLE_DATA_ACHIEVEMENT_PROGRESS, ]; diff --git a/packages/node-sdk/paima-utils-backend/src/achievements.ts b/packages/node-sdk/paima-utils-backend/src/achievements.ts index a748c9909..5f6333702 100644 --- a/packages/node-sdk/paima-utils-backend/src/achievements.ts +++ b/packages/node-sdk/paima-utils-backend/src/achievements.ts @@ -16,7 +16,7 @@ export interface Validity { /** Data block height (0 always valid) */ block: number; /** CAIP-2 blockchain identifier */ - caip2: number; + caip2: string; /** Optional date. ISO8601, like YYYY-MM-DDTHH:mm:ss.sssZ */ time?: string; } From ff0c267f7f2a8b9c0615744375dec4ec83c74740 Mon Sep 17 00:00:00 2001 From: Tad Hardesty Date: Tue, 30 Apr 2024 17:55:45 -0700 Subject: [PATCH 14/24] Update paima-db README to describe Docker --- packages/node-sdk/paima-db/README.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/node-sdk/paima-db/README.md b/packages/node-sdk/paima-db/README.md index 5b5cddf9e..e31745c9f 100644 --- a/packages/node-sdk/paima-db/README.md +++ b/packages/node-sdk/paima-db/README.md @@ -15,6 +15,4 @@ Specific game databases do not need to contain these tables, as they will be cre ## Development -To re-generate the queries, you will need a database running with the same connection info as [the config](./pgtypedconfig.json) and then run `npm run compile`. - -Before running `npm run compile`, you will need to initialize the DB using `sudo -u postgres psql -d postgres -a -f migrations/up.sql` +To re-generate the queries, run `npm run compile`, which will use Docker to run a temporary Postgres database initialized with `migrations/up.sql` and then watch for query changes. Ctrl+C and restart to reflect changes to `up.sql`. From 7692a6c72b4792f91bd0d359b13e37a74ac452ef Mon Sep 17 00:00:00 2001 From: Tad Hardesty Date: Tue, 30 Apr 2024 18:01:48 -0700 Subject: [PATCH 15/24] Use getMainAddress in /wallet/X --- .../src/controllers/AchievementsController.ts | 29 ++++++++++++------- .../node-sdk/paima-db/src/delegate-wallet.ts | 3 +- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/packages/engine/paima-rest/src/controllers/AchievementsController.ts b/packages/engine/paima-rest/src/controllers/AchievementsController.ts index 2161224d4..9d0a75b64 100644 --- a/packages/engine/paima-rest/src/controllers/AchievementsController.ts +++ b/packages/engine/paima-rest/src/controllers/AchievementsController.ts @@ -9,7 +9,7 @@ import { type Player, getNftOwner, } from '@paima/utils-backend'; -import { getAchievementTypes, getAchievementProgress } from '@paima/db'; +import { getAchievementTypes, getAchievementProgress, getMainAddress } from '@paima/db'; // ---------------------------------------------------------------------------- // Controller and routes per PRC-1 @@ -60,10 +60,16 @@ export class AchievementsController extends Controller { /** Comma-separated list. */ @Query() name?: string ): Promise { - const names = name ? name.split(',') : ['*']; - const player: Player = { wallet }; // TODO: walletType, userId, userName - const db = EngineService.INSTANCE.getSM().getReadonlyDbConn(); + const { address, id } = await getMainAddress(wallet, db); + + const player: Player = { + wallet: address, + userId: String(id), + // TODO: walletType, userName + }; + + const names = name ? name.split(',') : ['*']; const rows = await getAchievementProgress.run({ wallet, names }, db); this.setHeader('Content-Language', 'en'); @@ -100,14 +106,15 @@ export class AchievementsController extends Controller { const wallet = await getNftOwner(db, cde, BigInt(token_id)); if (wallet) { return await this.wallet(wallet, name); + } else { + // TODO: throw a different error if no CDE with that name exists + this.setStatus(404); + throw new Error('No owner for that NFT'); } - break; - case 'erc6551': - // TODO - break; + // Future expansion: erc6551 + default: + this.setStatus(404); + throw new Error(`No support for /erc/${erc}`); } - - this.setStatus(404); - throw new Error('Not found'); } } diff --git a/packages/node-sdk/paima-db/src/delegate-wallet.ts b/packages/node-sdk/paima-db/src/delegate-wallet.ts index 9d14f8110..c0c13e070 100644 --- a/packages/node-sdk/paima-db/src/delegate-wallet.ts +++ b/packages/node-sdk/paima-db/src/delegate-wallet.ts @@ -10,6 +10,7 @@ import { getMainAddressFromAddress, } from './sql/wallet-delegation.queries.js'; import type { PoolClient, Notification, Client } from 'pg'; +import type { IDatabaseConnection } from '@pgtyped/runtime/lib/tag'; export type WalletDelegate = { address: string; id: number }; export const NO_USER_ID = -1; @@ -26,7 +27,7 @@ export const addressCache = new Map(); // If wallet does not exist, It will NOT be created in address table. export async function getMainAddress( _address: string, - DBConn: PoolClient + DBConn: IDatabaseConnection ): Promise { const address = _address.toLocaleLowerCase(); let addressMapping: WalletDelegate | undefined = addressCache.get(address); From 1a0cf859ca2c19a0360db31bce55b444a0438ced Mon Sep 17 00:00:00 2001 From: Tad Hardesty Date: Wed, 1 May 2024 13:50:12 -0700 Subject: [PATCH 16/24] Add achievement_language table to back Accept-Language --- .../src/controllers/AchievementsController.ts | 17 +++++++------ packages/engine/paima-rest/src/tsoa/routes.ts | 1 + .../engine/paima-rest/src/tsoa/swagger.json | 8 ++++++ packages/node-sdk/paima-db/migrations/up.sql | 8 ++++++ .../node-sdk/paima-db/src/paima-tables.ts | 24 ++++++++++++++++++ .../paima-db/src/sql/achievements.queries.ts | 25 +++++++++++++++---- .../paima-db/src/sql/achievements.sql | 14 ++++++++++- 7 files changed, 83 insertions(+), 14 deletions(-) diff --git a/packages/engine/paima-rest/src/controllers/AchievementsController.ts b/packages/engine/paima-rest/src/controllers/AchievementsController.ts index 9d0a75b64..ee6712f47 100644 --- a/packages/engine/paima-rest/src/controllers/AchievementsController.ts +++ b/packages/engine/paima-rest/src/controllers/AchievementsController.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Path, Query, Route } from 'tsoa'; +import { Controller, Get, Header, Path, Query, Route } from 'tsoa'; import { EngineService } from '../EngineService.js'; import { ENV } from '@paima/utils'; import { @@ -34,12 +34,15 @@ export class AchievementsController extends Controller { @Get('public/list') public async public_list( @Query() category?: string, - @Query() isActive?: boolean + @Query() isActive?: boolean, + @Header('Accept-Language') acceptLanguage?: string ): Promise { const db = EngineService.INSTANCE.getSM().getReadonlyDbConn(); - const rows = await getAchievementTypes.run({ category, is_active: isActive }, db); + // Future expansion: import a real Accept-Language parser so user can provide more than one, handle 'pt-BR' also implying 'pt', etc. + const languages = acceptLanguage ? [acceptLanguage] : []; + const rows = await getAchievementTypes.run({ category, is_active: isActive, languages }, db); - this.setHeader('Content-Language', 'en'); + this.setHeader('Content-Language', languages[0]); return { ...(await this.validity()), ...(await this.game()), @@ -48,8 +51,8 @@ export class AchievementsController extends Controller { // Splat metadata first so that it can't override these: name: row.name, isActive: row.is_active, - displayName: row.display_name, - description: row.description, + displayName: row.display_name ?? '', + description: row.description ?? '', })), }; } @@ -72,7 +75,6 @@ export class AchievementsController extends Controller { const names = name ? name.split(',') : ['*']; const rows = await getAchievementProgress.run({ wallet, names }, db); - this.setHeader('Content-Language', 'en'); return { ...(await this.validity()), ...player, @@ -99,7 +101,6 @@ export class AchievementsController extends Controller { @Query() name?: string ): Promise { const db = EngineService.INSTANCE.getSM().getReadonlyDbConn(); - this.setHeader('Content-Language', 'en'); switch (erc) { case 'erc721': diff --git a/packages/engine/paima-rest/src/tsoa/routes.ts b/packages/engine/paima-rest/src/tsoa/routes.ts index b842f4101..ccf8eea4d 100644 --- a/packages/engine/paima-rest/src/tsoa/routes.ts +++ b/packages/engine/paima-rest/src/tsoa/routes.ts @@ -349,6 +349,7 @@ export function RegisterRoutes(app: Router) { const args: Record = { category: {"in":"query","name":"category","dataType":"string"}, isActive: {"in":"query","name":"isActive","dataType":"boolean"}, + acceptLanguage: {"in":"header","name":"Accept-Language","dataType":"string"}, }; // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa diff --git a/packages/engine/paima-rest/src/tsoa/swagger.json b/packages/engine/paima-rest/src/tsoa/swagger.json index 6e4bdf801..518ca5b72 100644 --- a/packages/engine/paima-rest/src/tsoa/swagger.json +++ b/packages/engine/paima-rest/src/tsoa/swagger.json @@ -554,6 +554,14 @@ "schema": { "type": "boolean" } + }, + { + "in": "header", + "name": "Accept-Language", + "required": false, + "schema": { + "type": "string" + } } ] } diff --git a/packages/node-sdk/paima-db/migrations/up.sql b/packages/node-sdk/paima-db/migrations/up.sql index 5bd833898..22c16ee75 100644 --- a/packages/node-sdk/paima-db/migrations/up.sql +++ b/packages/node-sdk/paima-db/migrations/up.sql @@ -250,6 +250,14 @@ CREATE TABLE achievement_type( metadata JSONB NOT NULL DEFAULT '{}' ); +CREATE TABLE achievement_language( + name TEXT NOT NULL, + language TEXT NOT NULL, + display_name TEXT, + description TEXT, + PRIMARY KEY (name, language) +); + CREATE TABLE achievement_progress( wallet TEXT NOT NULL, name TEXT NOT NULL, diff --git a/packages/node-sdk/paima-db/src/paima-tables.ts b/packages/node-sdk/paima-db/src/paima-tables.ts index b4a2b2e43..8a2d0079b 100644 --- a/packages/node-sdk/paima-db/src/paima-tables.ts +++ b/packages/node-sdk/paima-db/src/paima-tables.ts @@ -600,6 +600,29 @@ const TABLE_DATA_ACHIEVEMENT_TYPE: TableData = { creationQuery: QUERY_CREATE_TABLE_ACHIEVEMENT_TYPE, }; +const QUERY_CREATE_TABLE_ACHIEVEMENT_LANGUAGE = ` +CREATE TABLE achievement_language( + name TEXT NOT NULL, + language TEXT NOT NULL, + display_name TEXT, + description TEXT, + PRIMARY KEY (name, language) +); +`; + +const TABLE_DATA_ACHIEVEMENT_LANGUAGE: TableData = { + tableName: 'achievement_language', + primaryKeyColumns: ['name', 'language'], + columnData: packTuples([ + ['name', 'text', 'NO', ''], + ['language', 'text', 'NO', ''], + ['display_name', 'text', 'YES', ''], + ['description', 'text', 'YES', ''], + ]), + serialColumns: [], + creationQuery: QUERY_CREATE_TABLE_ACHIEVEMENT_LANGUAGE, +}; + const QUERY_CREATE_TABLE_ACHIEVEMENT_PROGRESS = ` CREATE TABLE achievement_progress( wallet TEXT NOT NULL, @@ -713,5 +736,6 @@ export const TABLES: TableData[] = [ TABLE_DATA_CDE_CARDANO_TRANSFER, TABLE_DATA_CDE_CARDANO_MINT_BURN, TABLE_DATA_ACHIEVEMENT_TYPE, + TABLE_DATA_ACHIEVEMENT_LANGUAGE, TABLE_DATA_ACHIEVEMENT_PROGRESS, ]; diff --git a/packages/node-sdk/paima-db/src/sql/achievements.queries.ts b/packages/node-sdk/paima-db/src/sql/achievements.queries.ts index a6ae66fc2..50ed6afcb 100644 --- a/packages/node-sdk/paima-db/src/sql/achievements.queries.ts +++ b/packages/node-sdk/paima-db/src/sql/achievements.queries.ts @@ -3,16 +3,19 @@ import { PreparedQuery } from '@pgtyped/runtime'; export type Json = null | boolean | number | string | Json[] | { [key: string]: Json }; +export type stringArray = (string)[]; + /** 'GetAchievementTypes' parameters type */ export interface IGetAchievementTypesParams { category?: string | null | void; is_active?: boolean | null | void; + languages?: stringArray | null | void; } /** 'GetAchievementTypes' return type */ export interface IGetAchievementTypesResult { - description: string; - display_name: string; + description: string | null; + display_name: string | null; is_active: boolean; metadata: Json; name: string; @@ -24,12 +27,24 @@ export interface IGetAchievementTypesQuery { result: IGetAchievementTypesResult; } -const getAchievementTypesIR: any = {"usedParamSet":{"is_active":true,"category":true},"params":[{"name":"is_active","required":false,"transform":{"type":"scalar"},"locs":[{"a":38,"b":47},{"a":69,"b":78}]},{"name":"category","required":false,"transform":{"type":"scalar"},"locs":[{"a":98,"b":106},{"a":125,"b":133}]}],"statement":"SELECT * FROM achievement_type\nWHERE (:is_active::BOOLEAN IS NULL OR :is_active = is_active)\nAND (:category::TEXT IS NULL OR :category = metadata ->> 'category')"}; +const getAchievementTypesIR: any = {"usedParamSet":{"languages":true,"is_active":true,"category":true},"params":[{"name":"languages","required":false,"transform":{"type":"scalar"},"locs":[{"a":355,"b":364},{"a":429,"b":438}]},{"name":"is_active","required":false,"transform":{"type":"scalar"},"locs":[{"a":508,"b":517},{"a":539,"b":548}]},{"name":"category","required":false,"transform":{"type":"scalar"},"locs":[{"a":568,"b":576},{"a":595,"b":603}]}],"statement":"SELECT\n achievement_type.name,\n achievement_type.is_active,\n coalesce(sub.display_name, achievement_type.display_name) AS display_name,\n coalesce(sub.description, achievement_type.description) AS description,\n achievement_type.metadata\nFROM achievement_type\nLEFT JOIN (\n SELECT DISTINCT ON(name) *\n FROM achievement_language\n WHERE array_position(:languages::text[], language) IS NOT NULL\n ORDER BY name, array_position(:languages::text[], language)\n) sub ON achievement_type.name = sub.name\nWHERE (:is_active::BOOLEAN IS NULL OR :is_active = is_active)\nAND (:category::TEXT IS NULL OR :category = metadata ->> 'category')"}; /** * Query generated from SQL: - * ``` - * SELECT * FROM achievement_type + * ```sql + * SELECT + * achievement_type.name, + * achievement_type.is_active, + * coalesce(sub.display_name, achievement_type.display_name) AS display_name, + * coalesce(sub.description, achievement_type.description) AS description, + * achievement_type.metadata + * FROM achievement_type + * LEFT JOIN ( + * SELECT DISTINCT ON(name) * + * FROM achievement_language + * WHERE array_position(:languages::text[], language) IS NOT NULL + * ORDER BY name, array_position(:languages::text[], language) + * ) sub ON achievement_type.name = sub.name * WHERE (:is_active::BOOLEAN IS NULL OR :is_active = is_active) * AND (:category::TEXT IS NULL OR :category = metadata ->> 'category') * ``` diff --git a/packages/node-sdk/paima-db/src/sql/achievements.sql b/packages/node-sdk/paima-db/src/sql/achievements.sql index 84d969c46..4dbe715f2 100644 --- a/packages/node-sdk/paima-db/src/sql/achievements.sql +++ b/packages/node-sdk/paima-db/src/sql/achievements.sql @@ -1,5 +1,17 @@ /* @name getAchievementTypes */ -SELECT * FROM achievement_type +SELECT + achievement_type.name, + achievement_type.is_active, + coalesce(sub.display_name, achievement_type.display_name) AS display_name, + coalesce(sub.description, achievement_type.description) AS description, + achievement_type.metadata +FROM achievement_type +LEFT JOIN ( + SELECT DISTINCT ON(name) * + FROM achievement_language + WHERE array_position(:languages::text[], language) IS NOT NULL + ORDER BY name, array_position(:languages::text[], language) +) sub ON achievement_type.name = sub.name WHERE (:is_active::BOOLEAN IS NULL OR :is_active = is_active) AND (:category::TEXT IS NULL OR :category = metadata ->> 'category') ; From 6c3221d9645ec9e292b6c9f01e11ae3036aa0878 Mon Sep 17 00:00:00 2001 From: Tad Hardesty Date: Thu, 2 May 2024 15:01:41 -0700 Subject: [PATCH 17/24] TODO -> Future in AchievementsController --- .../paima-rest/src/controllers/AchievementsController.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/engine/paima-rest/src/controllers/AchievementsController.ts b/packages/engine/paima-rest/src/controllers/AchievementsController.ts index ee6712f47..8696f520d 100644 --- a/packages/engine/paima-rest/src/controllers/AchievementsController.ts +++ b/packages/engine/paima-rest/src/controllers/AchievementsController.ts @@ -25,6 +25,7 @@ export class AchievementsController extends Controller { private async validity(): Promise { return { + // Note: will need updating when we support non-EVM data availability layers. caip2: `eip155:${ENV.CHAIN_ID}`, block: await EngineService.INSTANCE.getSM().latestProcessedBlockHeight(), time: new Date().toISOString(), @@ -69,7 +70,8 @@ export class AchievementsController extends Controller { const player: Player = { wallet: address, userId: String(id), - // TODO: walletType, userName + // walletType and userName aren't fulfilled here because Paima Engine's + // own DB tables don't include them at the moment. }; const names = name ? name.split(',') : ['*']; @@ -108,11 +110,11 @@ export class AchievementsController extends Controller { if (wallet) { return await this.wallet(wallet, name); } else { - // TODO: throw a different error if no CDE with that name exists + // Future improvement: throw a different error if no CDE with that name exists this.setStatus(404); throw new Error('No owner for that NFT'); } - // Future expansion: erc6551 + // Future improvement: erc6551 default: this.setStatus(404); throw new Error(`No support for /erc/${erc}`); From 68103adea9749b372a0f26cde051afb884585f67 Mon Sep 17 00:00:00 2001 From: Tad Hardesty Date: Thu, 2 May 2024 15:02:27 -0700 Subject: [PATCH 18/24] Improve import.ts documentation --- .../paima-standalone/src/utils/import.ts | 35 +++++++++++++------ 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/packages/engine/paima-standalone/src/utils/import.ts b/packages/engine/paima-standalone/src/utils/import.ts index 367152364..cf821bb5f 100644 --- a/packages/engine/paima-standalone/src/utils/import.ts +++ b/packages/engine/paima-standalone/src/utils/import.ts @@ -1,35 +1,48 @@ +/** + * Canonical definition of what Paima Engine directly imports from the + * `packaged/` directory. The game code itself may load what it will, or + * configuration may also refer to file paths within `packaged/`. + */ import type { GameStateTransitionFunctionRouter } from '@paima/sm'; import type { TsoaFunction } from '@paima/runtime'; -function importFile(file: string): T { +function importFile(path: string): T { // dynamic import cannot be used here due to PKG limitations // eslint-disable-next-line @typescript-eslint/no-var-requires - const { default: defaultExport } = require(`${process.cwd()}/${file}`); - - return defaultExport; + return require(`${process.cwd}/${path}`); } +export interface GameCodeImport { + default: GameStateTransitionFunctionRouter; +} export const ROUTER_FILENAME = 'packaged/gameCode.cjs'; /** * Reads repackaged user's code placed next to the executable in `gameCode.cjs` file */ -export const importGameStateTransitionRouter = (): GameStateTransitionFunctionRouter => - importFile(ROUTER_FILENAME); +export function importGameStateTransitionRouter(): GameStateTransitionFunctionRouter { + return importFile(ROUTER_FILENAME).default; +} +export interface EndpointsImport { + default: TsoaFunction; +} export const API_FILENAME = 'packaged/endpoints.cjs'; /** * Reads repackaged user's code placed next to the executable in `endpoints.cjs` file */ -export const importTsoaFunction = (): TsoaFunction => importFile(API_FILENAME); +export function importTsoaFunction(): TsoaFunction { + return importFile(API_FILENAME).default; +} +export type OpenApiImport = object; export const GAME_OPENAPI_FILENAME = 'packaged/openapi.json'; /** - * Reads repackaged user's code placed next to the executable in `endpoints.cjs` file + * Reads OpenAPI definitions placed next to the executable in `openapi.json` file */ -export const importOpenApiJson = (): undefined | object => { +export function importOpenApiJson(): OpenApiImport | undefined { try { - return require(`${process.cwd()}/${GAME_OPENAPI_FILENAME}`); + return importFile(GAME_OPENAPI_FILENAME); } catch (e) { return undefined; } -}; +} From 5fc34b0aaf1434a22f80ed99b7ab798585c29de9 Mon Sep 17 00:00:00 2001 From: Tad Hardesty Date: Thu, 2 May 2024 15:13:36 -0700 Subject: [PATCH 19/24] Move checkedForPackedGameCode, rename importTsoa -> importEndpoints --- .../engine/paima-standalone/src/utils/file.ts | 8 -------- .../paima-standalone/src/utils/import.ts | 20 ++++++++++++++----- .../paima-standalone/src/utils/input.ts | 8 +++++--- 3 files changed, 20 insertions(+), 16 deletions(-) diff --git a/packages/engine/paima-standalone/src/utils/file.ts b/packages/engine/paima-standalone/src/utils/file.ts index fe9699ac7..c03a076b4 100644 --- a/packages/engine/paima-standalone/src/utils/file.ts +++ b/packages/engine/paima-standalone/src/utils/file.ts @@ -1,7 +1,6 @@ import { doLog } from '@paima/utils'; import fs from 'fs'; import path from 'path'; -import { API_FILENAME, ROUTER_FILENAME } from './import.js'; import type { Template } from './types.js'; export const PACKAGED_TEMPLATES_PATH = `${__dirname}/templates`; @@ -112,13 +111,6 @@ export const prepareDocumentation = (): void => { prepareFolder([packagedPath], FOLDER_PATH, success, failure); }; -// Checks that the user packed their game code and it is available for Paima Engine to use to run -export const checkForPackedGameCode = (): boolean => { - const GAME_CODE_PATH = `${process.cwd()}/${ROUTER_FILENAME}`; - const ENDPOINTS_PATH = `${process.cwd()}/${API_FILENAME}`; - return fs.existsSync(ENDPOINTS_PATH) && fs.existsSync(GAME_CODE_PATH); -}; - export const getPaimaEngineVersion = (): string => { const rawData = fs.readFileSync(path.join(__dirname, 'metadata.json'), 'utf-8'); const { version } = JSON.parse(rawData); diff --git a/packages/engine/paima-standalone/src/utils/import.ts b/packages/engine/paima-standalone/src/utils/import.ts index cf821bb5f..59b2592c7 100644 --- a/packages/engine/paima-standalone/src/utils/import.ts +++ b/packages/engine/paima-standalone/src/utils/import.ts @@ -5,6 +5,16 @@ */ import type { GameStateTransitionFunctionRouter } from '@paima/sm'; import type { TsoaFunction } from '@paima/runtime'; +import fs from 'fs'; + +/** + * Checks that the user packed their game code and it is available for Paima Engine to use to run + */ +export function checkForPackedGameCode(): boolean { + const GAME_CODE_PATH = `${process.cwd()}/${ROUTER_FILENAME}`; + const ENDPOINTS_PATH = `${process.cwd()}/${API_FILENAME}`; + return fs.existsSync(ENDPOINTS_PATH) && fs.existsSync(GAME_CODE_PATH); +} function importFile(path: string): T { // dynamic import cannot be used here due to PKG limitations @@ -15,7 +25,7 @@ function importFile(path: string): T { export interface GameCodeImport { default: GameStateTransitionFunctionRouter; } -export const ROUTER_FILENAME = 'packaged/gameCode.cjs'; +const ROUTER_FILENAME = 'packaged/gameCode.cjs'; /** * Reads repackaged user's code placed next to the executable in `gameCode.cjs` file */ @@ -26,16 +36,16 @@ export function importGameStateTransitionRouter(): GameStateTransitionFunctionRo export interface EndpointsImport { default: TsoaFunction; } -export const API_FILENAME = 'packaged/endpoints.cjs'; +const API_FILENAME = 'packaged/endpoints.cjs'; /** * Reads repackaged user's code placed next to the executable in `endpoints.cjs` file */ -export function importTsoaFunction(): TsoaFunction { - return importFile(API_FILENAME).default; +export function importEndpoints(): EndpointsImport { + return importFile(API_FILENAME); } export type OpenApiImport = object; -export const GAME_OPENAPI_FILENAME = 'packaged/openapi.json'; +const GAME_OPENAPI_FILENAME = 'packaged/openapi.json'; /** * Reads OpenAPI definitions placed next to the executable in `openapi.json` file */ diff --git a/packages/engine/paima-standalone/src/utils/input.ts b/packages/engine/paima-standalone/src/utils/input.ts index 7c75a9a57..16eb44979 100644 --- a/packages/engine/paima-standalone/src/utils/input.ts +++ b/packages/engine/paima-standalone/src/utils/input.ts @@ -6,7 +6,6 @@ import { createInterface } from 'readline'; import { gameSM } from '../sm.js'; import { PACKAGED_TEMPLATES_PATH, - checkForPackedGameCode, getFolderNames, getPaimaEngineVersion, prepareBatcher, @@ -14,7 +13,7 @@ import { prepareDocumentation, prepareTemplate, } from './file.js'; -import { importOpenApiJson, importTsoaFunction } from './import.js'; +import { checkForPackedGameCode, importOpenApiJson, importEndpoints } from './import.js'; import type { Template } from './types.js'; import RegisterRoutes, { EngineService } from '@paima/rest'; @@ -123,8 +122,11 @@ export const runPaimaEngine = async (): Promise => { const engine = paimaRuntime.initialize(funnelFactory, stateMachine, ENV.GAME_NODE_VERSION); EngineService.INSTANCE.updateSM(stateMachine); + + const endpointsJs = importEndpoints(); + engine.setPollingRate(ENV.POLLING_RATE); - engine.addEndpoints(importTsoaFunction()); + engine.addEndpoints(endpointsJs.default); engine.addEndpoints(RegisterRoutes); registerDocs(importOpenApiJson()); registerValidationErrorHandler(); From 7d001f435dc46e976e1482f9b2a0c2607ad0c5b3 Mon Sep 17 00:00:00 2001 From: Tad Hardesty Date: Thu, 2 May 2024 16:22:15 -0700 Subject: [PATCH 20/24] Use import instead of DB for constant achievement definitions --- .../engine/paima-rest/src/EngineService.ts | 34 ++++++--- .../src/controllers/AchievementsController.ts | 70 ++++++++++++------- .../paima-standalone/src/utils/import.ts | 4 +- .../paima-standalone/src/utils/input.ts | 9 ++- packages/node-sdk/paima-db/migrations/up.sql | 16 ----- .../node-sdk/paima-db/src/paima-tables.ts | 49 ------------- .../paima-db/src/sql/achievements.queries.ts | 51 -------------- .../paima-db/src/sql/achievements.sql | 18 ----- .../paima-utils-backend/src/achievements.ts | 23 ++++++ 9 files changed, 101 insertions(+), 173 deletions(-) diff --git a/packages/engine/paima-rest/src/EngineService.ts b/packages/engine/paima-rest/src/EngineService.ts index c9fc6e7f2..735ca32dd 100644 --- a/packages/engine/paima-rest/src/EngineService.ts +++ b/packages/engine/paima-rest/src/EngineService.ts @@ -1,18 +1,30 @@ import type { GameStateMachine } from '@paima/sm'; +import type { AchievementMetadata } from '@paima/utils-backend'; export class EngineService { - public static INSTANCE = new EngineService(); + // Useful stuff + readonly stateMachine: GameStateMachine; + readonly achievements: Promise | null; - private runtime: GameStateMachine | undefined = undefined; + constructor(alike: { + readonly stateMachine: GameStateMachine; + readonly achievements: Promise | null; + }) { + this.stateMachine = alike.stateMachine; + this.achievements = alike.achievements; + } - getSM = (): GameStateMachine => { - if (this.runtime == null) { - throw new Error('EngineService: SM not initialized'); - } - return this.runtime; - }; + getSM = () => this.stateMachine; - updateSM = (runtime: GameStateMachine): void => { - this.runtime = runtime; - }; + // Singleton + private static _instance?: EngineService; + static get INSTANCE(): EngineService { + if (!this._instance) { + throw new Error('EngineService not initialized'); + } + return this._instance; + } + static set INSTANCE(value: EngineService) { + this._instance = value; + } } diff --git a/packages/engine/paima-rest/src/controllers/AchievementsController.ts b/packages/engine/paima-rest/src/controllers/AchievementsController.ts index 8696f520d..ad49a0420 100644 --- a/packages/engine/paima-rest/src/controllers/AchievementsController.ts +++ b/packages/engine/paima-rest/src/controllers/AchievementsController.ts @@ -1,26 +1,47 @@ -import { Controller, Get, Header, Path, Query, Route } from 'tsoa'; -import { EngineService } from '../EngineService.js'; +import { getAchievementProgress, getMainAddress } from '@paima/db'; import { ENV } from '@paima/utils'; import { + getNftOwner, + type Achievement, + type AchievementMetadata, type AchievementPublicList, + type Player, type PlayerAchievements, type Validity, - type Game, - type Player, - getNftOwner, } from '@paima/utils-backend'; -import { getAchievementTypes, getAchievementProgress, getMainAddress } from '@paima/db'; +import { Controller, Get, Header, Path, Query, Route } from 'tsoa'; +import { EngineService } from '../EngineService.js'; // ---------------------------------------------------------------------------- // Controller and routes per PRC-1 +function applyLanguage( + achievement: Achievement, + languages: AchievementMetadata['languages'], + accept: string[] +): Achievement { + for (const acceptLang of accept) { + const override = languages?.[acceptLang]?.[achievement.name]; + if (override) { + return { + ...achievement, + displayName: override.displayName || achievement.displayName, + description: override.description || achievement.description, + }; + } + } + return achievement; +} + @Route('achievements') export class AchievementsController extends Controller { - private async game(): Promise { - return { - id: 'TODO', - // TODO: name, version - }; + private async meta(): Promise { + const meta = EngineService.INSTANCE.achievements; + if (!meta) { + this.setStatus(501); + throw new Error('Achievements are not supported by this game'); + } + return await meta; } private async validity(): Promise { @@ -38,23 +59,22 @@ export class AchievementsController extends Controller { @Query() isActive?: boolean, @Header('Accept-Language') acceptLanguage?: string ): Promise { - const db = EngineService.INSTANCE.getSM().getReadonlyDbConn(); - // Future expansion: import a real Accept-Language parser so user can provide more than one, handle 'pt-BR' also implying 'pt', etc. - const languages = acceptLanguage ? [acceptLanguage] : []; - const rows = await getAchievementTypes.run({ category, is_active: isActive, languages }, db); + // Future expansion: import a real Accept-Language parser so user can + // ask for more than one, handle 'pt-BR' also implying 'pt', etc. + const acceptLanguages = acceptLanguage ? [acceptLanguage] : []; + + const meta = await this.meta(); + const filtered = meta.list + .filter(ach => category === undefined || category === ach.category) + .filter(ach => isActive === null || isActive === ach.isActive); - this.setHeader('Content-Language', languages[0]); + this.setHeader('Content-Language', acceptLanguages[0]); return { ...(await this.validity()), - ...(await this.game()), - achievements: rows.map(row => ({ - ...(typeof row.metadata === 'object' ? row.metadata : {}), - // Splat metadata first so that it can't override these: - name: row.name, - isActive: row.is_active, - displayName: row.display_name ?? '', - description: row.description ?? '', - })), + ...meta.game, + achievements: meta.languages + ? filtered.map(ach => applyLanguage(ach, meta.languages, acceptLanguages)) + : filtered, }; } diff --git a/packages/engine/paima-standalone/src/utils/import.ts b/packages/engine/paima-standalone/src/utils/import.ts index 59b2592c7..f379f9611 100644 --- a/packages/engine/paima-standalone/src/utils/import.ts +++ b/packages/engine/paima-standalone/src/utils/import.ts @@ -3,9 +3,10 @@ * `packaged/` directory. The game code itself may load what it will, or * configuration may also refer to file paths within `packaged/`. */ +import fs from 'fs'; import type { GameStateTransitionFunctionRouter } from '@paima/sm'; import type { TsoaFunction } from '@paima/runtime'; -import fs from 'fs'; +import type { AchievementMetadata } from '@paima/utils-backend'; /** * Checks that the user packed their game code and it is available for Paima Engine to use to run @@ -35,6 +36,7 @@ export function importGameStateTransitionRouter(): GameStateTransitionFunctionRo export interface EndpointsImport { default: TsoaFunction; + achievements?: Promise; } const API_FILENAME = 'packaged/endpoints.cjs'; /** diff --git a/packages/engine/paima-standalone/src/utils/input.ts b/packages/engine/paima-standalone/src/utils/input.ts index 16eb44979..8d3a79f5c 100644 --- a/packages/engine/paima-standalone/src/utils/input.ts +++ b/packages/engine/paima-standalone/src/utils/input.ts @@ -114,6 +114,8 @@ export const runPaimaEngine = async (): Promise => { doLog(`Starting Game Node...`); doLog(`Using RPC: ${config.chainUri}`); doLog(`Targeting Smart Contact: ${config.paimaL2ContractAddress}`); + + // Import & initialize state machine const stateMachine = gameSM(); const funnelFactory = await FunnelFactory.initialize( config.chainUri, @@ -121,9 +123,12 @@ export const runPaimaEngine = async (): Promise => { ); const engine = paimaRuntime.initialize(funnelFactory, stateMachine, ENV.GAME_NODE_VERSION); - EngineService.INSTANCE.updateSM(stateMachine); - + // Import & initialize REST server const endpointsJs = importEndpoints(); + EngineService.INSTANCE = new EngineService({ + stateMachine, + achievements: endpointsJs.achievements || null, + }); engine.setPollingRate(ENV.POLLING_RATE); engine.addEndpoints(endpointsJs.default); diff --git a/packages/node-sdk/paima-db/migrations/up.sql b/packages/node-sdk/paima-db/migrations/up.sql index 22c16ee75..62a3722b5 100644 --- a/packages/node-sdk/paima-db/migrations/up.sql +++ b/packages/node-sdk/paima-db/migrations/up.sql @@ -242,22 +242,6 @@ CREATE TABLE cde_cardano_mint_burn( PRIMARY KEY (cde_id, tx_id) ); -CREATE TABLE achievement_type( - name TEXT NOT NULL PRIMARY KEY, - is_active BOOLEAN NOT NULL DEFAULT true, - display_name TEXT NOT NULL, - description TEXT NOT NULL DEFAULT '', - metadata JSONB NOT NULL DEFAULT '{}' -); - -CREATE TABLE achievement_language( - name TEXT NOT NULL, - language TEXT NOT NULL, - display_name TEXT, - description TEXT, - PRIMARY KEY (name, language) -); - CREATE TABLE achievement_progress( wallet TEXT NOT NULL, name TEXT NOT NULL, diff --git a/packages/node-sdk/paima-db/src/paima-tables.ts b/packages/node-sdk/paima-db/src/paima-tables.ts index 8a2d0079b..44e2226dd 100644 --- a/packages/node-sdk/paima-db/src/paima-tables.ts +++ b/packages/node-sdk/paima-db/src/paima-tables.ts @@ -576,53 +576,6 @@ const TABLE_DATA_DELEGATIONS: TableData = { creationQuery: QUERY_CREATE_TABLE_DELEGATIONS, }; -const QUERY_CREATE_TABLE_ACHIEVEMENT_TYPE = ` -CREATE TABLE achievement_type( - name TEXT NOT NULL PRIMARY KEY, - is_active BOOLEAN NOT NULL DEFAULT true, - display_name TEXT NOT NULL, - description TEXT NOT NULL DEFAULT '', - metadata JSONB NOT NULL DEFAULT '{}' -); -`; - -const TABLE_DATA_ACHIEVEMENT_TYPE: TableData = { - tableName: 'achievement_type', - primaryKeyColumns: ['name'], - columnData: packTuples([ - ['name', 'text', 'NO', ''], - ['is_active', 'boolean', 'NO', 'true'], - ['display_name', 'text', 'NO', ''], - ['description', 'text', 'NO', "''"], - ['metadata', 'jsonb', 'NO', "'{}'"], - ]), - serialColumns: [], - creationQuery: QUERY_CREATE_TABLE_ACHIEVEMENT_TYPE, -}; - -const QUERY_CREATE_TABLE_ACHIEVEMENT_LANGUAGE = ` -CREATE TABLE achievement_language( - name TEXT NOT NULL, - language TEXT NOT NULL, - display_name TEXT, - description TEXT, - PRIMARY KEY (name, language) -); -`; - -const TABLE_DATA_ACHIEVEMENT_LANGUAGE: TableData = { - tableName: 'achievement_language', - primaryKeyColumns: ['name', 'language'], - columnData: packTuples([ - ['name', 'text', 'NO', ''], - ['language', 'text', 'NO', ''], - ['display_name', 'text', 'YES', ''], - ['description', 'text', 'YES', ''], - ]), - serialColumns: [], - creationQuery: QUERY_CREATE_TABLE_ACHIEVEMENT_LANGUAGE, -}; - const QUERY_CREATE_TABLE_ACHIEVEMENT_PROGRESS = ` CREATE TABLE achievement_progress( wallet TEXT NOT NULL, @@ -735,7 +688,5 @@ export const TABLES: TableData[] = [ TABLE_DATA_CDE_TRACKING_CARDANO_PAGINATION, TABLE_DATA_CDE_CARDANO_TRANSFER, TABLE_DATA_CDE_CARDANO_MINT_BURN, - TABLE_DATA_ACHIEVEMENT_TYPE, - TABLE_DATA_ACHIEVEMENT_LANGUAGE, TABLE_DATA_ACHIEVEMENT_PROGRESS, ]; diff --git a/packages/node-sdk/paima-db/src/sql/achievements.queries.ts b/packages/node-sdk/paima-db/src/sql/achievements.queries.ts index 50ed6afcb..92f954bad 100644 --- a/packages/node-sdk/paima-db/src/sql/achievements.queries.ts +++ b/packages/node-sdk/paima-db/src/sql/achievements.queries.ts @@ -1,57 +1,6 @@ /** Types generated for queries found in "src/sql/achievements.sql" */ import { PreparedQuery } from '@pgtyped/runtime'; -export type Json = null | boolean | number | string | Json[] | { [key: string]: Json }; - -export type stringArray = (string)[]; - -/** 'GetAchievementTypes' parameters type */ -export interface IGetAchievementTypesParams { - category?: string | null | void; - is_active?: boolean | null | void; - languages?: stringArray | null | void; -} - -/** 'GetAchievementTypes' return type */ -export interface IGetAchievementTypesResult { - description: string | null; - display_name: string | null; - is_active: boolean; - metadata: Json; - name: string; -} - -/** 'GetAchievementTypes' query type */ -export interface IGetAchievementTypesQuery { - params: IGetAchievementTypesParams; - result: IGetAchievementTypesResult; -} - -const getAchievementTypesIR: any = {"usedParamSet":{"languages":true,"is_active":true,"category":true},"params":[{"name":"languages","required":false,"transform":{"type":"scalar"},"locs":[{"a":355,"b":364},{"a":429,"b":438}]},{"name":"is_active","required":false,"transform":{"type":"scalar"},"locs":[{"a":508,"b":517},{"a":539,"b":548}]},{"name":"category","required":false,"transform":{"type":"scalar"},"locs":[{"a":568,"b":576},{"a":595,"b":603}]}],"statement":"SELECT\n achievement_type.name,\n achievement_type.is_active,\n coalesce(sub.display_name, achievement_type.display_name) AS display_name,\n coalesce(sub.description, achievement_type.description) AS description,\n achievement_type.metadata\nFROM achievement_type\nLEFT JOIN (\n SELECT DISTINCT ON(name) *\n FROM achievement_language\n WHERE array_position(:languages::text[], language) IS NOT NULL\n ORDER BY name, array_position(:languages::text[], language)\n) sub ON achievement_type.name = sub.name\nWHERE (:is_active::BOOLEAN IS NULL OR :is_active = is_active)\nAND (:category::TEXT IS NULL OR :category = metadata ->> 'category')"}; - -/** - * Query generated from SQL: - * ```sql - * SELECT - * achievement_type.name, - * achievement_type.is_active, - * coalesce(sub.display_name, achievement_type.display_name) AS display_name, - * coalesce(sub.description, achievement_type.description) AS description, - * achievement_type.metadata - * FROM achievement_type - * LEFT JOIN ( - * SELECT DISTINCT ON(name) * - * FROM achievement_language - * WHERE array_position(:languages::text[], language) IS NOT NULL - * ORDER BY name, array_position(:languages::text[], language) - * ) sub ON achievement_type.name = sub.name - * WHERE (:is_active::BOOLEAN IS NULL OR :is_active = is_active) - * AND (:category::TEXT IS NULL OR :category = metadata ->> 'category') - * ``` - */ -export const getAchievementTypes = new PreparedQuery(getAchievementTypesIR); - - /** 'GetAchievementProgress' parameters type */ export interface IGetAchievementProgressParams { names: readonly (string | null | void)[]; diff --git a/packages/node-sdk/paima-db/src/sql/achievements.sql b/packages/node-sdk/paima-db/src/sql/achievements.sql index 4dbe715f2..fee2a1902 100644 --- a/packages/node-sdk/paima-db/src/sql/achievements.sql +++ b/packages/node-sdk/paima-db/src/sql/achievements.sql @@ -1,21 +1,3 @@ -/* @name getAchievementTypes */ -SELECT - achievement_type.name, - achievement_type.is_active, - coalesce(sub.display_name, achievement_type.display_name) AS display_name, - coalesce(sub.description, achievement_type.description) AS description, - achievement_type.metadata -FROM achievement_type -LEFT JOIN ( - SELECT DISTINCT ON(name) * - FROM achievement_language - WHERE array_position(:languages::text[], language) IS NOT NULL - ORDER BY name, array_position(:languages::text[], language) -) sub ON achievement_type.name = sub.name -WHERE (:is_active::BOOLEAN IS NULL OR :is_active = is_active) -AND (:category::TEXT IS NULL OR :category = metadata ->> 'category') -; - /* @name getAchievementProgress @param names -> (...) diff --git a/packages/node-sdk/paima-utils-backend/src/achievements.ts b/packages/node-sdk/paima-utils-backend/src/achievements.ts index 5f6333702..7d1f9e7a9 100644 --- a/packages/node-sdk/paima-utils-backend/src/achievements.ts +++ b/packages/node-sdk/paima-utils-backend/src/achievements.ts @@ -89,3 +89,26 @@ export interface PlayerAchievements extends Validity, Player { completed: number; achievements: PlayerAchievement[]; } + +// ---------------------------------------------------------------------------- +// Paima Engine types + +/** The type of the `achievements` export of `endpoints.cjs`. */ +export interface AchievementMetadata { + /** Game ID, name, and version. */ + game: Game; + /** Achievement types. */ + list: Achievement[]; + /** + * Per-language overrides for achievement display names and descriptions. + * Falls back to base definition whenever absent. + */ + languages?: { + [language: string]: { + [name: string]: { + displayName?: string; + description?: string; + }; + }; + }; +} From 03dfff7b4d2e0367bff9929c7fe0793769e57a64 Mon Sep 17 00:00:00 2001 From: Tad Hardesty Date: Thu, 2 May 2024 16:43:17 -0700 Subject: [PATCH 21/24] Fix bugs and linter errors --- packages/engine/paima-rest/src/EngineService.ts | 2 +- .../engine/paima-rest/src/controllers/AchievementsController.ts | 2 +- packages/engine/paima-standalone/src/utils/import.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/engine/paima-rest/src/EngineService.ts b/packages/engine/paima-rest/src/EngineService.ts index 735ca32dd..2712049f2 100644 --- a/packages/engine/paima-rest/src/EngineService.ts +++ b/packages/engine/paima-rest/src/EngineService.ts @@ -14,7 +14,7 @@ export class EngineService { this.achievements = alike.achievements; } - getSM = () => this.stateMachine; + getSM = (): GameStateMachine => this.stateMachine; // Singleton private static _instance?: EngineService; diff --git a/packages/engine/paima-rest/src/controllers/AchievementsController.ts b/packages/engine/paima-rest/src/controllers/AchievementsController.ts index ad49a0420..892659566 100644 --- a/packages/engine/paima-rest/src/controllers/AchievementsController.ts +++ b/packages/engine/paima-rest/src/controllers/AchievementsController.ts @@ -66,7 +66,7 @@ export class AchievementsController extends Controller { const meta = await this.meta(); const filtered = meta.list .filter(ach => category === undefined || category === ach.category) - .filter(ach => isActive === null || isActive === ach.isActive); + .filter(ach => isActive === undefined || isActive === ach.isActive); this.setHeader('Content-Language', acceptLanguages[0]); return { diff --git a/packages/engine/paima-standalone/src/utils/import.ts b/packages/engine/paima-standalone/src/utils/import.ts index f379f9611..977bf27cf 100644 --- a/packages/engine/paima-standalone/src/utils/import.ts +++ b/packages/engine/paima-standalone/src/utils/import.ts @@ -20,7 +20,7 @@ export function checkForPackedGameCode(): boolean { function importFile(path: string): T { // dynamic import cannot be used here due to PKG limitations // eslint-disable-next-line @typescript-eslint/no-var-requires - return require(`${process.cwd}/${path}`); + return require(`${process.cwd()}/${path}`); } export interface GameCodeImport { From 4513c71d703361c035fe1b8887480eef2296d218 Mon Sep 17 00:00:00 2001 From: Tad Hardesty Date: Fri, 3 May 2024 12:42:55 -0700 Subject: [PATCH 22/24] Fix table definition mismatch --- packages/node-sdk/paima-db/src/paima-tables.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/node-sdk/paima-db/src/paima-tables.ts b/packages/node-sdk/paima-db/src/paima-tables.ts index 44e2226dd..4a5844074 100644 --- a/packages/node-sdk/paima-db/src/paima-tables.ts +++ b/packages/node-sdk/paima-db/src/paima-tables.ts @@ -593,7 +593,7 @@ const TABLE_DATA_ACHIEVEMENT_PROGRESS: TableData = { columnData: packTuples([ ['wallet', 'text', 'NO', ''], ['name', 'text', 'NO', ''], - ['completed_date', 'timestamp', 'YES', ''], + ['completed_date', 'timestamp without time zone', 'YES', ''], ['progress', 'integer', 'YES', ''], ['total', 'integer', 'YES', ''], ]), From 21afec7ed048ddfe872fe5e035cf878ada2f4953 Mon Sep 17 00:00:00 2001 From: Tad Hardesty Date: Fri, 3 May 2024 15:20:23 -0700 Subject: [PATCH 23/24] Add setAchievementProgress query --- .../paima-db/src/sql/achievements.queries.ts | 37 +++++++++++++++++++ .../paima-db/src/sql/achievements.sql | 9 +++++ 2 files changed, 46 insertions(+) diff --git a/packages/node-sdk/paima-db/src/sql/achievements.queries.ts b/packages/node-sdk/paima-db/src/sql/achievements.queries.ts index 92f954bad..a174a400a 100644 --- a/packages/node-sdk/paima-db/src/sql/achievements.queries.ts +++ b/packages/node-sdk/paima-db/src/sql/achievements.queries.ts @@ -1,6 +1,8 @@ /** Types generated for queries found in "src/sql/achievements.sql" */ import { PreparedQuery } from '@pgtyped/runtime'; +export type DateOrString = Date | string; + /** 'GetAchievementProgress' parameters type */ export interface IGetAchievementProgressParams { names: readonly (string | null | void)[]; @@ -35,3 +37,38 @@ const getAchievementProgressIR: any = {"usedParamSet":{"wallet":true,"names":tru export const getAchievementProgress = new PreparedQuery(getAchievementProgressIR); +/** 'SetAchievementProgress' parameters type */ +export interface ISetAchievementProgressParams { + completed_date?: DateOrString | null | void; + name: string; + progress?: number | null | void; + total?: number | null | void; + wallet: string; +} + +/** 'SetAchievementProgress' return type */ +export type ISetAchievementProgressResult = void; + +/** 'SetAchievementProgress' query type */ +export interface ISetAchievementProgressQuery { + params: ISetAchievementProgressParams; + result: ISetAchievementProgressResult; +} + +const setAchievementProgressIR: any = {"usedParamSet":{"wallet":true,"name":true,"completed_date":true,"progress":true,"total":true},"params":[{"name":"wallet","required":true,"transform":{"type":"scalar"},"locs":[{"a":89,"b":96}]},{"name":"name","required":true,"transform":{"type":"scalar"},"locs":[{"a":99,"b":104}]},{"name":"completed_date","required":false,"transform":{"type":"scalar"},"locs":[{"a":107,"b":121}]},{"name":"progress","required":false,"transform":{"type":"scalar"},"locs":[{"a":124,"b":132}]},{"name":"total","required":false,"transform":{"type":"scalar"},"locs":[{"a":135,"b":140}]}],"statement":"INSERT INTO achievement_progress (wallet, name, completed_date, progress, total)\nVALUES (:wallet!, :name!, :completed_date, :progress, :total)\nON CONFLICT (wallet, name)\nDO UPDATE SET\n completed_date = EXCLUDED.completed_date,\n progress = EXCLUDED.progress,\n total = EXCLUDED.total"}; + +/** + * Query generated from SQL: + * ``` + * INSERT INTO achievement_progress (wallet, name, completed_date, progress, total) + * VALUES (:wallet!, :name!, :completed_date, :progress, :total) + * ON CONFLICT (wallet, name) + * DO UPDATE SET + * completed_date = EXCLUDED.completed_date, + * progress = EXCLUDED.progress, + * total = EXCLUDED.total + * ``` + */ +export const setAchievementProgress = new PreparedQuery(setAchievementProgressIR); + + diff --git a/packages/node-sdk/paima-db/src/sql/achievements.sql b/packages/node-sdk/paima-db/src/sql/achievements.sql index fee2a1902..cb4687c97 100644 --- a/packages/node-sdk/paima-db/src/sql/achievements.sql +++ b/packages/node-sdk/paima-db/src/sql/achievements.sql @@ -6,3 +6,12 @@ SELECT * FROM achievement_progress WHERE wallet = :wallet! AND ('*' in :names OR name IN :names) ; + +/* @name setAchievementProgress */ +INSERT INTO achievement_progress (wallet, name, completed_date, progress, total) +VALUES (:wallet!, :name!, :completed_date, :progress, :total) +ON CONFLICT (wallet, name) +DO UPDATE SET + completed_date = EXCLUDED.completed_date, + progress = EXCLUDED.progress, + total = EXCLUDED.total; From b546a1cc3f2f2f1f6bf8c45da191b5214c678544 Mon Sep 17 00:00:00 2001 From: Tad Hardesty Date: Wed, 8 May 2024 16:19:18 -0700 Subject: [PATCH 24/24] Use wallet ID instead of address to handle delegation --- .../paima-rest/src/controllers/AchievementsController.ts | 2 +- packages/node-sdk/paima-db/migrations/up.sql | 2 +- packages/node-sdk/paima-db/src/paima-tables.ts | 4 ++-- packages/node-sdk/paima-db/src/sql/achievements.queries.ts | 6 +++--- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/engine/paima-rest/src/controllers/AchievementsController.ts b/packages/engine/paima-rest/src/controllers/AchievementsController.ts index 892659566..428c58fbc 100644 --- a/packages/engine/paima-rest/src/controllers/AchievementsController.ts +++ b/packages/engine/paima-rest/src/controllers/AchievementsController.ts @@ -95,7 +95,7 @@ export class AchievementsController extends Controller { }; const names = name ? name.split(',') : ['*']; - const rows = await getAchievementProgress.run({ wallet, names }, db); + const rows = await getAchievementProgress.run({ wallet: id, names }, db); return { ...(await this.validity()), diff --git a/packages/node-sdk/paima-db/migrations/up.sql b/packages/node-sdk/paima-db/migrations/up.sql index e406877ae..20203eecf 100644 --- a/packages/node-sdk/paima-db/migrations/up.sql +++ b/packages/node-sdk/paima-db/migrations/up.sql @@ -249,7 +249,7 @@ CREATE TABLE mina_checkpoint ( ); CREATE TABLE achievement_progress( - wallet TEXT NOT NULL, + wallet INTEGER NOT NULL REFERENCES addresses(id), name TEXT NOT NULL, completed_date TIMESTAMP, progress INTEGER, diff --git a/packages/node-sdk/paima-db/src/paima-tables.ts b/packages/node-sdk/paima-db/src/paima-tables.ts index 32a7f5e03..24c1207a6 100644 --- a/packages/node-sdk/paima-db/src/paima-tables.ts +++ b/packages/node-sdk/paima-db/src/paima-tables.ts @@ -597,7 +597,7 @@ const TABLE_DATA_MINA_CHECKPOINT: TableData = { const QUERY_CREATE_TABLE_ACHIEVEMENT_PROGRESS = ` CREATE TABLE achievement_progress( - wallet TEXT NOT NULL, + wallet INTEGER NOT NULL REFERENCES addresses(id), name TEXT NOT NULL, completed_date TIMESTAMP, progress INTEGER, @@ -610,7 +610,7 @@ const TABLE_DATA_ACHIEVEMENT_PROGRESS: TableData = { tableName: 'achievement_progress', primaryKeyColumns: ['wallet', 'name'], columnData: packTuples([ - ['wallet', 'text', 'NO', ''], + ['wallet', 'integer', 'NO', ''], ['name', 'text', 'NO', ''], ['completed_date', 'timestamp without time zone', 'YES', ''], ['progress', 'integer', 'YES', ''], diff --git a/packages/node-sdk/paima-db/src/sql/achievements.queries.ts b/packages/node-sdk/paima-db/src/sql/achievements.queries.ts index a174a400a..a51a0d4a2 100644 --- a/packages/node-sdk/paima-db/src/sql/achievements.queries.ts +++ b/packages/node-sdk/paima-db/src/sql/achievements.queries.ts @@ -6,7 +6,7 @@ export type DateOrString = Date | string; /** 'GetAchievementProgress' parameters type */ export interface IGetAchievementProgressParams { names: readonly (string | null | void)[]; - wallet: string; + wallet: number; } /** 'GetAchievementProgress' return type */ @@ -15,7 +15,7 @@ export interface IGetAchievementProgressResult { name: string; progress: number | null; total: number | null; - wallet: string; + wallet: number; } /** 'GetAchievementProgress' query type */ @@ -43,7 +43,7 @@ export interface ISetAchievementProgressParams { name: string; progress?: number | null | void; total?: number | null | void; - wallet: string; + wallet: number; } /** 'SetAchievementProgress' return type */