Salta ai contenuti

Gioco di Dungeon con IA Agentiva

Modulo 2: Implementazione dell’API del gioco e del server MCP per l’inventario

Sezione intitolata “Modulo 2: Implementazione dell’API del gioco e del server MCP per l’inventario”

Inizieremo implementando la nostra Game API. Per farlo, dobbiamo creare 5 API in totale:

  1. saveGame - crea o aggiorna un gioco.
  2. queryGames - restituisce una lista paginata di giochi salvati precedentemente.
  3. saveAction - salva un’azione per un determinato gioco.
  4. queryActions - restituisce una lista paginata di tutte le azioni relative a un gioco.
  5. queryInventory - restituisce una lista paginata degli oggetti nell’inventario di un giocatore.

Per definire gli input e gli output della nostra API, creiamo il nostro schema utilizzando Zod nella directory packages/game-api/src/schema come segue:

import { z } from 'zod';
export const ActionSchema = z.object({
playerName: z.string(),
timestamp: z.iso.datetime(),
role: z.enum(['assistant', 'user']),
content: z.string(),
});
export type IAction = z.TypeOf<typeof ActionSchema>;

Puoi anche eliminare il file packages/game-api/src/schema/echo.ts dato che non lo utilizzeremo in questo progetto.

Il diagramma ER per la nostra applicazione è il seguente:

dungeon-adventure-er.png

Implementeremo il nostro database in DynamoDB e utilizzeremo la libreria client ElectroDB per semplificare le operazioni. Per iniziare, dobbiamo prima installare electrodb e il DynamoDB Client eseguendo il seguente comando:

Terminal window
pnpm add -w electrodb@3.5.0 @aws-sdk/client-dynamodb@3.913.0

Ora creiamo i seguenti file nella nostra cartella packages/game-api/src/entities per definire le entità ElectroDB secondo il diagramma ER sopra:

import { Entity } from 'electrodb';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
export const createActionEntity = (client?: DynamoDBClient) =>
new Entity(
{
model: {
entity: 'Action',
version: '1',
service: 'game',
},
attributes: {
playerName: { type: 'string', required: true, readOnly: true },
timestamp: {
type: 'string',
required: true,
readOnly: true,
set: () => new Date().toISOString(),
default: () => new Date().toISOString(),
},
role: { type: 'string', required: true, readOnly: true },
content: { type: 'string', required: true, readOnly: true },
},
indexes: {
primary: {
pk: { field: 'pk', composite: ['playerName'] },
sk: { field: 'sk', composite: ['timestamp'] },
},
},
},
{ client, table: process.env.TABLE_NAME },
);

ElectroDB ci permette non solo di definire i nostri tipi, ma può anche fornire valori predefiniti per certi campi come i timestamp sopra. Inoltre, ElectroDB segue il single-table design, che è la best practice quando si utilizza DynamoDB.

Per preparare il server MCP a interagire con l’inventario, assicuriamoci di esportare l’entità dell’inventario in packages/game-api/src/index.ts:

export type { AppRouter } from './router.js';
export { appRouter } from './router.js';
export type { Context } from './init.js';
export * from './client/index.js';
export * from './schema/index.js';
export * from './entities/inventory.js';

Dato che abbiamo bisogno di accedere al client DynamoDB in ognuna delle nostre procedure, vogliamo creare un’unica istanza del client che possiamo passare tramite il contesto. Per farlo, apporta le seguenti modifiche in packages/game-api/src:

import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { initTRPC } from '@trpc/server';
export interface IDynamoDBContext {
dynamoDb?: DynamoDBClient;
}
export const createDynamoDBPlugin = () => {
const t = initTRPC.context<IDynamoDBContext>().create();
return t.procedure.use(async (opts) => {
const dynamoDb = new DynamoDBClient();
const response = await opts.next({
ctx: {
...opts.ctx,
dynamoDb,
},
});
return response;
});
};

Questo è un plugin che strumentiamo per creare il DynamoDBClient e iniettarlo nel contesto.

Ora è il momento di implementare i metodi dell’API. Per farlo, apporta le seguenti modifiche in packages/game-api/src/procedures:

import { createActionEntity } from '../entities/action.js';
import {
ActionSchema,
IAction,
QueryInputSchema,
createPaginatedQueryOutput,
} from '../schema/index.js';
import { publicProcedure } from '../init.js';
import { z } from 'zod';
export const queryActions = publicProcedure
.input(QueryInputSchema.extend({ playerName: z.string() }))
.output(createPaginatedQueryOutput(ActionSchema))
.query(async ({ input, ctx }) => {
const actionEntity = createActionEntity(ctx.dynamoDb);
const result = await actionEntity.query
.primary({ playerName: input.playerName })
.go({ cursor: input.cursor, count: input.limit });
return {
items: result.data as IAction[],
cursor: result.cursor,
};
});
import { ActionSchema, IAction } from '../schema/index.js';
import { publicProcedure } from '../init.js';
import { createActionEntity } from '../entities/action.js';
import { createGameEntity } from '../entities/game.js';
export const saveAction = publicProcedure
.input(ActionSchema.omit({ timestamp: true }))
.output(ActionSchema)
.mutation(async ({ input, ctx }) => {
const actionEntity = createActionEntity(ctx.dynamoDb);
const gameEntity = createGameEntity(ctx.dynamoDb);
const action = await actionEntity.put(input).go();
await gameEntity
.update({ playerName: input.playerName })
.set({ lastUpdated: action.data.timestamp })
.go();
return action.data as IAction;
});

Puoi anche eliminare il file echo.ts (da packages/game-api/src/procedures) dato che non lo utilizzeremo in questo progetto.

Ora che abbiamo definito le nostre procedure, colleghiamole alla nostra API. Per farlo, aggiorna il seguente file:

import {
awsLambdaRequestHandler,
CreateAWSLambdaContextOptions,
} from '@trpc/server/adapters/aws-lambda';
import { t } from './init.js';
import { APIGatewayProxyEvent } from 'aws-lambda';
import { queryActions } from './procedures/query-actions.js';
import { saveAction } from './procedures/save-action.js';
import { queryGames } from './procedures/query-games.js';
import { saveGame } from './procedures/save-game.js';
import { queryInventory } from './procedures/query-inventory.js';
export const router = t.router;
export const appRouter = router({
actions: router({
query: queryActions,
save: saveAction,
}),
games: router({
query: queryGames,
save: saveGame,
}),
inventory: router({
query: queryInventory,
}),
});
export const handler = awsLambdaRequestHandler({
router: appRouter,
createContext: (
ctx: CreateAWSLambdaContextOptions<APIGatewayProxyEvent>,
) => ctx,
responseMeta: () => ({
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': '*',
},
}),
});
export type AppRouter = typeof appRouter;

Ora creiamo un server MCP che permetterà al nostro agente di gestire gli oggetti nell’inventario di un giocatore.

Definiremo i seguenti strumenti per il nostro Agente:

  • list-inventory-items per recuperare gli oggetti correnti nell’inventario del giocatore
  • add-to-inventory per aggiungere oggetti all’inventario del giocatore
  • remove-from-inventory per rimuovere oggetti dall’inventario del giocatore

Per risparmiare tempo, definiremo tutti gli strumenti inline:

import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import z from 'zod-v3';
import { createInventoryEntity } from ':dungeon-adventure/game-api';
/**
* Create the MCP Server
*/
export const createServer = () => {
const server = new McpServer({
name: 'inventory-mcp-server',
version: '1.0.0',
});
const dynamoDb = new DynamoDBClient();
const inventory = createInventoryEntity(dynamoDb);
server.tool(
'list-inventory-items',
"List items in the player's inventory. Leave cursor blank unless you are requesting subsequent pages",
{
playerName: z.string(),
cursor: z.string().optional(),
},
async ({ playerName }) => {
const results = await inventory.query
.primary({
playerName,
})
.go();
return {
content: [{ type: 'text', text: JSON.stringify(results) }],
};
},
);
server.tool(
'add-to-inventory',
"Add an item to the player's inventory. Quantity defaults to 1 if omitted.",
{
playerName: z.string(),
itemName: z.string(),
emoji: z.string(),
quantity: z.number().optional().default(1),
},
async ({ playerName, itemName, emoji, quantity = 1 }) => {
await inventory
.put({
playerName,
itemName,
quantity,
emoji,
})
.go();
return {
content: [
{
type: 'text',
text: `Added ${itemName} (x${quantity}) to inventory`,
},
],
};
},
);
server.tool(
'remove-from-inventory',
"Remove an item from the player's inventory. If quantity is omitted, all items are removed.",
{
playerName: z.string(),
itemName: z.string(),
quantity: z.number().optional(),
},
async ({ playerName, itemName, quantity }) => {
// If quantity is omitted, remove the entire item
if (quantity === undefined) {
try {
await inventory.delete({ playerName, itemName }).go();
return {
content: [
{ type: 'text', text: `${itemName} removed from inventory.` },
],
} as const;
} catch {
return {
content: [
{ type: 'text', text: `${itemName} not found in inventory` },
],
} as const;
}
}
// If quantity is specified, fetch current quantity and update
const item = await inventory.get({ playerName, itemName }).go();
if (!item.data) {
return {
content: [
{ type: 'text', text: `${itemName} not found in inventory` },
],
} as const;
}
const newQuantity = item.data.quantity - quantity;
if (newQuantity <= 0) {
await inventory.delete({ playerName, itemName }).go();
return {
content: [
{ type: 'text', text: `${itemName} removed from inventory.` },
],
} as const;
}
await inventory
.put({
playerName,
itemName,
quantity: newQuantity,
emoji: item.data.emoji,
})
.go();
return {
content: [
{
type: 'text',
text: `Removed ${itemName} (x${quantity}) from inventory. ${newQuantity} remaining.`,
},
],
};
},
);
return server;
};

Man mano che il numero di strumenti cresce, puoi eventualmente rifattorizzarli in file separati.

Ora puoi eliminare le directory tools e resources in packages/inventory/src/mcp-server poiché non sono utilizzate.

Il passo finale è aggiornare la nostra infrastruttura per creare la tabella DynamoDB e concedere i permessi per eseguire operazioni dalla Game API. Per farlo, aggiorna packages/infra/src come segue:

import { CfnOutput } from 'aws-cdk-lib';
import {
AttributeType,
BillingMode,
ProjectionType,
Table,
TableProps,
} from 'aws-cdk-lib/aws-dynamodb';
import { Construct } from 'constructs';
import { suppressRules } from ':dungeon-adventure/common-constructs';
export type ElectrodbDynamoTableProps = Omit<
TableProps,
'partitionKey' | 'sortKey' | 'billingMode'
>;
export class ElectrodbDynamoTable extends Table {
constructor(scope: Construct, id: string, props?: ElectrodbDynamoTableProps) {
super(scope, id, {
partitionKey: {
name: 'pk',
type: AttributeType.STRING,
},
sortKey: {
name: 'sk',
type: AttributeType.STRING,
},
billingMode: BillingMode.PAY_PER_REQUEST,
...props,
});
this.addGlobalSecondaryIndex({
indexName: 'gsi1pk-gsi1sk-index',
partitionKey: {
name: 'gsi1pk',
type: AttributeType.STRING,
},
sortKey: {
name: 'gsi1sk',
type: AttributeType.STRING,
},
projectionType: ProjectionType.ALL,
});
// Suppress checkov rules that expect a KMS customer managed key and backup to be enabled
suppressRules(this, ['CKV_AWS_119', 'CKV_AWS_28'], 'No need for custom encryption or backup');
new CfnOutput(this, 'TableName', { value: this.tableName });
}
}

Prima di tutto, compiliamo la codebase:

Terminal window
pnpm nx run-many --target build --all

La tua applicazione può ora essere deployata eseguendo il seguente comando:

Terminal window
pnpm nx deploy infra dungeon-adventure-infra-sandbox/*

Il primo deployment richiederà circa 8 minuti. I deployment successivi richiederanno circa 2 minuti.

Una volta completato il deployment, dovresti vedere degli output simili ai seguenti (alcuni valori sono stati oscurati):

Terminal window
dungeon-adventure-sandbox-Application
dungeon-adventure-sandbox-Application: deploying... [2/2]
dungeon-adventure-sandbox-Application
Deployment time: 354s
Outputs:
dungeon-adventure-sandbox-Application.ElectroDbTableTableNameXXX = dungeon-adventure-sandbox-Application-ElectroDbTableXXX-YYY
dungeon-adventure-sandbox-Application.GameApiEndpointXXX = https://xxx.execute-api.region.amazonaws.com/prod/
dungeon-adventure-sandbox-Application.GameUIDistributionDomainNameXXX = xxx.cloudfront.net
dungeon-adventure-sandbox-Application.StoryApiEndpointXXX = https://xxx.execute-api.region.amazonaws.com/prod/
dungeon-adventure-sandbox-Application.UserIdentityUserIdentityIdentityPoolIdXXX = region:xxx
dungeon-adventure-sandbox-Application.UserIdentityUserIdentityUserPoolIdXXX = region_xxx

Possiamo testare la nostra API in due modi:

  • Avviando un’istanza locale del backend tRPC e invocando le API con curl.
  • Chiamare l'API deployata utilizzando curl con Sigv4

Avvia il server locale game-api eseguendo:

Terminal window
TABLE_NAME=dungeon-adventure-infra-sandbox-Application-ElectroDbTableXXX-YYY pnpm nx run @dungeon-adventure/game-api:serve

Una volta avviato il server, puoi chiamarlo con:

Terminal window
curl -X GET 'http://localhost:2022/games.query?input=%7B%7D'

Se il comando viene eseguito con successo, dovresti vedere una risposta come:

{"result":{"data":{"items":[],"cursor":null}}}

Complimenti, hai costruito e deployato la tua prima API utilizzando tRPC! 🎉🎉🎉