diff --git a/packages/engine/paima-rest/package.json b/packages/engine/paima-rest/package.json index c22fe5805..d3796f591 100644 --- a/packages/engine/paima-rest/package.json +++ b/packages/engine/paima-rest/package.json @@ -7,9 +7,9 @@ "types": "build/index.d.ts", "scripts": { "lint:eslint": "eslint .", - "build": "tsc --build tsconfig.build.json", - "prebuild": "npm run compile:api", - "compile:api": "npx tsoa spec-and-routes" + "build": "npm run compile:api && tsc --build tsconfig.build.json", + "prebuild": "", + "compile:api": "tsoa spec-and-routes" }, "author": "Paima Studios", "dependencies": { diff --git a/packages/engine/paima-rest/src/EngineService.ts b/packages/engine/paima-rest/src/EngineService.ts index c9fc6e7f2..2712049f2 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 = (): GameStateMachine => 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 new file mode 100644 index 000000000..428c58fbc --- /dev/null +++ b/packages/engine/paima-rest/src/controllers/AchievementsController.ts @@ -0,0 +1,143 @@ +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, +} from '@paima/utils-backend'; +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 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 { + 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(), + }; + } + + @Get('public/list') + public async public_list( + @Query() category?: string, + @Query() isActive?: boolean, + @Header('Accept-Language') acceptLanguage?: string + ): Promise { + // 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 === undefined || isActive === ach.isActive); + + this.setHeader('Content-Language', acceptLanguages[0]); + return { + ...(await this.validity()), + ...meta.game, + achievements: meta.languages + ? filtered.map(ach => applyLanguage(ach, meta.languages, acceptLanguages)) + : filtered, + }; + } + + @Get('wallet/{wallet}') + public async wallet( + @Path() wallet: string, + /** Comma-separated list. */ + @Query() name?: string + ): Promise { + const db = EngineService.INSTANCE.getSM().getReadonlyDbConn(); + const { address, id } = await getMainAddress(wallet, db); + + const player: Player = { + wallet: address, + userId: String(id), + // 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(',') : ['*']; + const rows = await getAchievementProgress.run({ wallet: id, names }, db); + + return { + ...(await this.validity()), + ...player, + 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, + })), + }; + } + + @Get('erc/{erc}/{cde}/{token_id}') + public async nft( + @Path() erc: string, + @Path() cde: string, + @Path() token_id: string, + @Query() name?: string + ): Promise { + const db = EngineService.INSTANCE.getSM().getReadonlyDbConn(); + + switch (erc) { + case 'erc721': + const wallet = await getNftOwner(db, cde, BigInt(token_id)); + if (wallet) { + return await this.wallet(wallet, name); + } else { + // 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 improvement: erc6551 + default: + this.setStatus(404); + throw new Error(`No support for /erc/${erc}`); + } + } +} 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-rest/src/tsoa/routes.ts b/packages/engine/paima-rest/src/tsoa/routes.ts index dbd0f38bb..ccf8eea4d 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":"string","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":"string","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,102 @@ 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"}, + 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 + + 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..518ca5b72 100644 --- a/packages/engine/paima-rest/src/tsoa/swagger.json +++ b/packages/engine/paima-rest/src/tsoa/swagger.json @@ -135,6 +135,213 @@ }, "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": "string", + "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": "string", + "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 +521,139 @@ } ] } + }, + "/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" + } + }, + { + "in": "header", + "name": "Accept-Language", + "required": false, + "schema": { + "type": "string" + } + } + ] + } + }, + "/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/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/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', }); 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 367152364..977bf27cf 100644 --- a/packages/engine/paima-standalone/src/utils/import.ts +++ b/packages/engine/paima-standalone/src/utils/import.ts @@ -1,35 +1,60 @@ +/** + * 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 fs from 'fs'; import type { GameStateTransitionFunctionRouter } from '@paima/sm'; import type { TsoaFunction } from '@paima/runtime'; +import type { AchievementMetadata } from '@paima/utils-backend'; -function importFile(file: string): T { +/** + * 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 // eslint-disable-next-line @typescript-eslint/no-var-requires - const { default: defaultExport } = require(`${process.cwd()}/${file}`); - - return defaultExport; + return require(`${process.cwd()}/${path}`); } -export const ROUTER_FILENAME = 'packaged/gameCode.cjs'; +export interface GameCodeImport { + default: GameStateTransitionFunctionRouter; +} +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 const API_FILENAME = 'packaged/endpoints.cjs'; +export interface EndpointsImport { + default: TsoaFunction; + achievements?: Promise; +} +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 importEndpoints(): EndpointsImport { + return importFile(API_FILENAME); +} -export const GAME_OPENAPI_FILENAME = 'packaged/openapi.json'; +export type OpenApiImport = object; +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; } -}; +} diff --git a/packages/engine/paima-standalone/src/utils/input.ts b/packages/engine/paima-standalone/src/utils/input.ts index 99c670107..8d3a79f5c 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'; @@ -108,13 +107,15 @@ 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()) { 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, @@ -122,12 +123,16 @@ export const runPaimaEngine = async (): Promise => { ); const engine = paimaRuntime.initialize(funnelFactory, stateMachine, ENV.GAME_NODE_VERSION); - EngineService.INSTANCE.updateSM(stateMachine); - engine.setPollingRate(ENV.POLLING_RATE); - engine.addEndpoints(importTsoaFunction()); - engine.addEndpoints(server => { - RegisterRoutes(server); + // 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); + engine.addEndpoints(RegisterRoutes); registerDocs(importOpenApiJson()); registerValidationErrorHandler(); 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`. 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/migrations/up.sql b/packages/node-sdk/paima-db/migrations/up.sql index f1eb0dd3d..20203eecf 100644 --- a/packages/node-sdk/paima-db/migrations/up.sql +++ b/packages/node-sdk/paima-db/migrations/up.sql @@ -247,3 +247,12 @@ CREATE TABLE mina_checkpoint ( network TEXT NOT NULL, PRIMARY KEY (network) ); + +CREATE TABLE achievement_progress( + wallet INTEGER NOT NULL REFERENCES addresses(id), + name TEXT NOT NULL, + completed_date TIMESTAMP, + progress INTEGER, + total INTEGER, + PRIMARY KEY (wallet, name) +); 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 } } 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); diff --git a/packages/node-sdk/paima-db/src/index.ts b/packages/node-sdk/paima-db/src/index.ts index e2f1f3cf5..311a9c28a 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'; +// https://github.com/adelsz/pgtyped/issues/565 +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/paima-tables.ts b/packages/node-sdk/paima-db/src/paima-tables.ts index 9b197d1c4..24c1207a6 100644 --- a/packages/node-sdk/paima-db/src/paima-tables.ts +++ b/packages/node-sdk/paima-db/src/paima-tables.ts @@ -595,6 +595,31 @@ const TABLE_DATA_MINA_CHECKPOINT: TableData = { creationQuery: QUERY_CREATE_TABLE_MINA_CHECKPOINT, }; +const QUERY_CREATE_TABLE_ACHIEVEMENT_PROGRESS = ` +CREATE TABLE achievement_progress( + wallet INTEGER NOT NULL REFERENCES addresses(id), + 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', 'integer', 'NO', ''], + ['name', 'text', 'NO', ''], + ['completed_date', 'timestamp without time zone', '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 @@ -683,4 +708,5 @@ export const TABLES: TableData[] = [ TABLE_DATA_CDE_CARDANO_TRANSFER, TABLE_DATA_CDE_CARDANO_MINT_BURN, TABLE_DATA_MINA_CHECKPOINT, + 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 new file mode 100644 index 000000000..a51a0d4a2 --- /dev/null +++ b/packages/node-sdk/paima-db/src/sql/achievements.queries.ts @@ -0,0 +1,74 @@ +/** 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)[]; + wallet: number; +} + +/** 'GetAchievementProgress' return type */ +export interface IGetAchievementProgressResult { + completed_date: Date | null; + name: string; + progress: number | null; + total: number | null; + wallet: number; +} + +/** '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); + + +/** 'SetAchievementProgress' parameters type */ +export interface ISetAchievementProgressParams { + completed_date?: DateOrString | null | void; + name: string; + progress?: number | null | void; + total?: number | null | void; + wallet: number; +} + +/** '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 new file mode 100644 index 000000000..cb4687c97 --- /dev/null +++ b/packages/node-sdk/paima-db/src/sql/achievements.sql @@ -0,0 +1,17 @@ +/* + @name getAchievementProgress + @param names -> (...) +*/ +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; 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..7d1f9e7a9 --- /dev/null +++ b/packages/node-sdk/paima-utils-backend/src/achievements.ts @@ -0,0 +1,114 @@ +// ---------------------------------------------------------------------------- +// 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; + /** CAIP-2 blockchain identifier */ + caip2: string; + /** 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?: string; // ex: 'cardano' | 'evm' | 'polkadot' | 'algorand' + /** (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; + /** (Optional) 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[]; +} + +// ---------------------------------------------------------------------------- +// 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; + }; + }; + }; +} diff --git a/packages/node-sdk/paima-utils-backend/src/index.ts b/packages/node-sdk/paima-utils-backend/src/index.ts index 0bfe68ff8..bfb40d0de 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.js'; export function hashTogether(data: string[]): string { return Crypto.createHash('sha256').update(data.join()).digest('base64');