-
Notifications
You must be signed in to change notification settings - Fork 20
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add scaffolding for PRC-1 achievements API (#358)
* Tweak tsoa imports per todo comment * Add AchievementsController * Include time in getValidity * Log API errors to make debugging actually possible * Move AchievementService to utils-backend so it can be imported * Support games that export 'AchievementService' classes * Fix eslint errors * Interfaces too * Run pgtyped against a temporary Postgres instance * Add achievement tables and queries * Use new SQL queries in AchievementsController, remove AchievementService * Update for PRC-1 changes * Add table defs for new tables, clarify TODOs, fix issues * Update paima-db README to describe Docker * Use getMainAddress in /wallet/X * Add achievement_language table to back Accept-Language * TODO -> Future in AchievementsController * Improve import.ts documentation * Move checkedForPackedGameCode, rename importTsoa -> importEndpoints * Use import instead of DB for constant achievement definitions * Fix bugs and linter errors * Fix table definition mismatch * Add setAchievementProgress query * Use wallet ID instead of address to handle delegation
- Loading branch information
1 parent
9ee0fe3
commit 819e32e
Showing
25 changed files
with
1,005 additions
and
54 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 = (): 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; | ||
} | ||
} |
143 changes: 143 additions & 0 deletions
143
packages/engine/paima-rest/src/controllers/AchievementsController.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<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> { | ||
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<AchievementPublicList> { | ||
// 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<PlayerAchievements> { | ||
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<PlayerAchievements> { | ||
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}`); | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.