diff --git a/packages/engine/paima-rest/src/EngineService.ts b/packages/engine/paima-rest/src/EngineService.ts index c9fc6e7f..735ca32d 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 8696f520..ad49a042 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 59b2592c..f379f961 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 16eb4497..8d3a79f5 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 22c16ee7..62a3722b 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 8a2d0079..44e2226d 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 50ed6afc..92f954ba 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 4dbe715f..fee2a190 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 5f633370..7d1f9e7a 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; + }; + }; + }; +}