Skip to content

Commit

Permalink
Use import instead of DB for constant achievement definitions
Browse files Browse the repository at this point in the history
  • Loading branch information
SpaceManiac committed May 2, 2024
1 parent 5fc34b0 commit 7d001f4
Show file tree
Hide file tree
Showing 9 changed files with 101 additions and 173 deletions.
34 changes: 23 additions & 11 deletions packages/engine/paima-rest/src/EngineService.ts
Original file line number Diff line number Diff line change
@@ -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<AchievementMetadata> | null;

private runtime: GameStateMachine | undefined = undefined;
constructor(alike: {
readonly stateMachine: GameStateMachine;
readonly achievements: Promise<AchievementMetadata> | 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;
}
}
Original file line number Diff line number Diff line change
@@ -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<Game> {
return {
id: 'TODO',
// TODO: name, version
};
private async meta(): Promise<AchievementMetadata> {
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<Validity> {
Expand All @@ -38,23 +59,22 @@ export class AchievementsController extends Controller {
@Query() isActive?: boolean,
@Header('Accept-Language') acceptLanguage?: string
): Promise<AchievementPublicList> {
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,
};
}

Expand Down
4 changes: 3 additions & 1 deletion packages/engine/paima-standalone/src/utils/import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -35,6 +36,7 @@ export function importGameStateTransitionRouter(): GameStateTransitionFunctionRo

export interface EndpointsImport {
default: TsoaFunction;
achievements?: Promise<AchievementMetadata>;
}
const API_FILENAME = 'packaged/endpoints.cjs';
/**
Expand Down
9 changes: 7 additions & 2 deletions packages/engine/paima-standalone/src/utils/input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,16 +114,21 @@ export const runPaimaEngine = async (): Promise<void> => {
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,
config.paimaL2ContractAddress
);
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);
Expand Down
16 changes: 0 additions & 16 deletions packages/node-sdk/paima-db/migrations/up.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
49 changes: 0 additions & 49 deletions packages/node-sdk/paima-db/src/paima-tables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
];
51 changes: 0 additions & 51 deletions packages/node-sdk/paima-db/src/sql/achievements.queries.ts
Original file line number Diff line number Diff line change
@@ -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<IGetAchievementTypesParams,IGetAchievementTypesResult>(getAchievementTypesIR);


/** 'GetAchievementProgress' parameters type */
export interface IGetAchievementProgressParams {
names: readonly (string | null | void)[];
Expand Down
18 changes: 0 additions & 18 deletions packages/node-sdk/paima-db/src/sql/achievements.sql
Original file line number Diff line number Diff line change
@@ -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 -> (...)
Expand Down
23 changes: 23 additions & 0 deletions packages/node-sdk/paima-utils-backend/src/achievements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
};
};
}

0 comments on commit 7d001f4

Please sign in to comment.