Xây dựng giao diện người dùng
Nhiệm vụ 1: Chạy mọi thứ cục bộ
Phần tiêu đề “Nhiệm vụ 1: Chạy mọi thứ cục bộ”Khởi động toàn bộ stack cục bộ — máy chủ phát triển game-ui cùng với Game API cục bộ và AG-UI Story Agent cục bộ (lần lượt khởi động Inventory MCP server) — chỉ với một lệnh:
pnpm nx dev game-uiyarn nx dev game-uinpx nx dev game-uibunx nx dev game-uiTarget dev trên game-ui có dependsOn trên game-api:dev và dungeon_adventure.story:agent-dev, vì vậy Nx khởi động máy chủ cục bộ của mọi dự án song song. Thông qua các kết nối chúng ta đã thiết lập trong Module 1, chúng lần lượt khởi động Inventory MCP server và DynamoDB Local. Đảm bảo container engine của bạn đang chạy, sau đó mở máy chủ phát triển trong trình duyệt.
Nhiệm vụ 2: Nơi CopilotKit đã được kết nối
Phần tiêu đề “Nhiệm vụ 2: Nơi CopilotKit đã được kết nối”Khi bạn chạy generator connection cho game-ui → story trong Module 1, tích hợp AG-UI của trang web Shadcn đã được tạo cho bạn. Đáng để xem qua nhanh:
Thư mụcpackages/game-ui/src/
Thư mụccomponents/
- AguiProvider.tsx Single
CopilotKitProviderregistered with every AG-UI agent. Thư mụccopilot/
- index.tsx Re-exports themed
CopilotChat/CopilotSidebar/CopilotPopup. - ShadcnAssistantMessage.tsx, ShadcnUserMessage.tsx, ShadcnChatInput.tsx, ShadcnCursor.tsx, copilot.css
- index.tsx Re-exports themed
- AguiProvider.tsx Single
Thư mụchooks/
- useAguiStoryAgent.tsx Instantiates an
@ag-ui/clientHttpAgentpointing at the deployed Story Agent and padsthreadIdto AgentCore’s 33-character minimum session id.
- useAguiStoryAgent.tsx Instantiates an
- main.tsx Wraps
<App />in<AguiProvider>
Tất cả những gì chúng ta cần làm là thêm <CopilotChat agentId="agent" threadId={...} /> vào một route. Để biết thêm chi tiết về cách tích hợp được kết hợp, hãy xem hướng dẫn kết nối React → AG-UI.
Nhiệm vụ 3: Thay đổi giao diện cho hầm ngục
Phần tiêu đề “Nhiệm vụ 3: Thay đổi giao diện cho hầm ngục”Thay thế packages/game-ui/src/styles.css — đây là file duy nhất chúng ta thay đổi cho việc tạo kiểu. Nó import các global Shadcn được chia sẻ, ghi đè bảng màu thành theme hầm ngục được thắp sáng bởi ngọn đuốc, và làm cho CopilotKit kế thừa các màu đó:
@import '../../common/shadcn/src/styles/globals.css';@source './**/*.{ts,tsx}';
/* Dungeon theme — torch-lit parchment on stone. Applied to `:root` for the * page and to `[data-copilotkit][data-copilotkit]` for CopilotKit's chat * surface; CopilotKit ships a same-specificity `[data-copilotkit]` rule that * resets `--background` back to white, so we bump specificity with the * doubled selector. */:root,[data-copilotkit][data-copilotkit] { --background: oklch(0.18 0.02 60); --foreground: oklch(0.92 0.04 85); --card: oklch(0.22 0.03 60); --card-foreground: oklch(0.92 0.04 85); --popover: oklch(0.2 0.02 60); --popover-foreground: oklch(0.92 0.04 85); --primary: oklch(0.75 0.15 75); --primary-foreground: oklch(0.15 0.02 60); --secondary: oklch(0.28 0.04 60); --secondary-foreground: oklch(0.92 0.04 85); --muted: oklch(0.25 0.02 60); --muted-foreground: oklch(0.7 0.04 85); --accent: oklch(0.4 0.12 30); --accent-foreground: oklch(0.95 0.04 85); --destructive: oklch(0.55 0.22 25); --border: oklch(0.35 0.03 60); --input: oklch(0.3 0.03 60); --ring: oklch(0.75 0.15 75); --sidebar: oklch(0.15 0.02 60); --sidebar-foreground: oklch(0.88 0.04 85); --sidebar-primary: oklch(0.75 0.15 75); --sidebar-primary-foreground: oklch(0.15 0.02 60); --sidebar-accent: oklch(0.28 0.04 60); --sidebar-accent-foreground: oklch(0.92 0.04 85); --sidebar-border: oklch(0.3 0.03 60); --sidebar-ring: oklch(0.75 0.15 75);}
body { font-family: 'Georgia', 'Cambria', serif; background: radial-gradient(circle at 20% 10%, oklch(0.25 0.05 70 / 0.4), transparent 40%), radial-gradient(circle at 80% 90%, oklch(0.25 0.1 30 / 0.3), transparent 40%), var(--background);}
h1, h2, h3 { letter-spacing: 0.05em;}Nhiệm vụ 4: Tạo các route của trò chơi
Phần tiêu đề “Nhiệm vụ 4: Tạo các route của trò chơi”Chúng ta cần hai route — một để chọn nhân vật, một để chơi. Cả hai đều sử dụng các component shadcn và chat CopilotKit; không có giao diện chat tự viết.
import { useInfiniteQuery, useMutation } from '@tanstack/react-query';import { createFileRoute, useNavigate } from '@tanstack/react-router';import { useEffect, useMemo, useRef, useState } from 'react';import { Button } from ':dungeon-adventure/common-shadcn/components/ui/button';import { Input } from ':dungeon-adventure/common-shadcn/components/ui/input';import { Card, CardContent,} from ':dungeon-adventure/common-shadcn/components/ui/card';import { Spinner } from ':dungeon-adventure/common-shadcn/components/ui/spinner';import { useGameApi } from '../hooks/useGameApi';import type { IGame } from ':dungeon-adventure/game-api';
const GENRES = ['medieval', 'zombie', 'superhero'] as const;
export const Route = createFileRoute('/')({ component: RouteComponent });
function RouteComponent() { const [playerName, setPlayerName] = useState(''); const [pending, setPending] = useState<IGame['genre'] | null>(null); const navigate = useNavigate(); const gameApi = useGameApi(); const saveGame = useMutation(gameApi.games.save.mutationOptions()); const games = useInfiniteQuery( gameApi.games.query.infiniteQueryOptions( { limit: 10 }, { getNextPageParam: ({ cursor }) => cursor ?? undefined }, ), ); const savedGames = useMemo( () => games.data?.pages.flatMap((p) => p.items) ?? [], [games.data], );
// Auto-fetch subsequent pages when the sentinel at the bottom of the list // scrolls into view — keeps the homepage a simple infinite scroll without // a "Load more" button. const sentinel = useRef<HTMLDivElement | null>(null); useEffect(() => { const el = sentinel.current; if (!el || !games.hasNextPage) return; const io = new IntersectionObserver( (entries) => { if ( entries.some((e) => e.isIntersecting) && !games.isFetchingNextPage ) { void games.fetchNextPage(); } }, { rootMargin: '120px' }, ); io.observe(el); return () => io.disconnect(); }, [games.hasNextPage, games.isFetchingNextPage, games.fetchNextPage]);
const startGame = async (player: string, genre: IGame['genre']) => { if (!player.trim()) return; setPending(genre); try { if (!savedGames.find((g) => g.playerName === player)) { await saveGame.mutateAsync({ playerName: player, genre }); } await navigate({ to: '/game/$playerName', params: { playerName: player }, search: { genre }, }); } finally { setPending(null); } };
const busy = pending !== null; const firstLoad = games.isLoading;
return ( <div className="mx-auto flex w-full max-w-2xl flex-col gap-8"> <div className="text-center"> <h1 className="bg-gradient-to-r from-amber-300 to-rose-400 bg-clip-text text-5xl font-bold text-transparent"> AI Dungeon Adventure </h1> <p className="text-muted-foreground mt-2"> Pick a hero name, choose a genre, begin. </p> </div>
<Card> <CardContent className="flex flex-col gap-4 pt-6"> <Input placeholder="Your hero's name" value={playerName} disabled={busy} onChange={(e) => setPlayerName(e.target.value)} /> <div className="grid grid-cols-3 gap-3"> {GENRES.map((genre) => ( <Button key={genre} variant="secondary" disabled={!playerName.trim() || busy} onClick={() => startGame(playerName, genre)} > {pending === genre && <Spinner />} {genre[0].toUpperCase() + genre.slice(1)} </Button> ))} </div> </CardContent> </Card>
<div className="flex flex-col gap-2"> <h2 className="flex items-center gap-2 text-xl font-semibold"> Continue {(firstLoad || games.isFetching) && <Spinner className="size-4" />} </h2> {!firstLoad && savedGames.length === 0 && ( <p className="text-muted-foreground text-sm"> No saved games yet — start a new adventure above. </p> )} {savedGames.map((g) => ( <Button key={g.playerName} variant="outline" className="justify-between" disabled={busy} onClick={() => startGame(g.playerName, g.genre)} > <span>{g.playerName}</span> <span className="text-muted-foreground text-sm"> {g.genre[0].toUpperCase() + g.genre.slice(1)} </span> </Button> ))} <div ref={sentinel} aria-hidden className="h-1" /> {games.isFetchingNextPage && ( <div className="flex justify-center py-2"> <Spinner /> </div> )} </div> </div> );}import { createFileRoute } from '@tanstack/react-router';import { useInfiniteQuery, useMutation } from '@tanstack/react-query';import { createFileRoute, useNavigate } from '@tanstack/react-router';import { useEffect, useMemo, useRef, useState } from 'react';import { Button } from ':dungeon-adventure/common-shadcn/components/ui/button';import { Input } from ':dungeon-adventure/common-shadcn/components/ui/input';import { Card, CardContent,} from ':dungeon-adventure/common-shadcn/components/ui/card';import { Spinner } from ':dungeon-adventure/common-shadcn/components/ui/spinner';import { useGameApi } from '../hooks/useGameApi';import type { IGame } from ':dungeon-adventure/game-api';
export const Route = createFileRoute('/')({ component: RouteComponent,});const GENRES = ['medieval', 'zombie', 'superhero'] as const;
export const Route = createFileRoute('/')({ component: RouteComponent });
function RouteComponent() { const [playerName, setPlayerName] = useState(''); const [pending, setPending] = useState<IGame['genre'] | null>(null); const navigate = useNavigate(); const gameApi = useGameApi(); const saveGame = useMutation(gameApi.games.save.mutationOptions()); const games = useInfiniteQuery( gameApi.games.query.infiniteQueryOptions( { limit: 10 }, { getNextPageParam: ({ cursor }) => cursor ?? undefined }, ), ); const savedGames = useMemo( () => games.data?.pages.flatMap((p) => p.items) ?? [], [games.data], );
// Auto-fetch subsequent pages when the sentinel at the bottom of the list // scrolls into view — keeps the homepage a simple infinite scroll without // a "Load more" button. const sentinel = useRef<HTMLDivElement | null>(null); useEffect(() => { const el = sentinel.current; if (!el || !games.hasNextPage) return; const io = new IntersectionObserver( (entries) => { if ( entries.some((e) => e.isIntersecting) && !games.isFetchingNextPage ) { void games.fetchNextPage(); } }, { rootMargin: '120px' }, ); io.observe(el); return () => io.disconnect(); }, [games.hasNextPage, games.isFetchingNextPage, games.fetchNextPage]);
const startGame = async (player: string, genre: IGame['genre']) => { if (!player.trim()) return; setPending(genre); try { if (!savedGames.find((g) => g.playerName === player)) { await saveGame.mutateAsync({ playerName: player, genre }); } await navigate({ to: '/game/$playerName', params: { playerName: player }, search: { genre }, }); } finally { setPending(null); } };
const busy = pending !== null; const firstLoad = games.isLoading;
return ( <div className="text-center"> <header> <h1>Welcome</h1> <p>Welcome to your new React website!</p> </header> <div className="mx-auto flex w-full max-w-2xl flex-col gap-8"> <div className="text-center"> <h1 className="bg-gradient-to-r from-amber-300 to-rose-400 bg-clip-text text-5xl font-bold text-transparent"> AI Dungeon Adventure </h1> <p className="text-muted-foreground mt-2"> Pick a hero name, choose a genre, begin. </p> </div>
<Card> <CardContent className="flex flex-col gap-4 pt-6"> <Input placeholder="Your hero's name" value={playerName} disabled={busy} onChange={(e) => setPlayerName(e.target.value)} /> <div className="grid grid-cols-3 gap-3"> {GENRES.map((genre) => ( <Button key={genre} variant="secondary" disabled={!playerName.trim() || busy} onClick={() => startGame(playerName, genre)} > {pending === genre && <Spinner />} {genre[0].toUpperCase() + genre.slice(1)} </Button> ))} </div> </CardContent> </Card>
<div className="flex flex-col gap-2"> <h2 className="flex items-center gap-2 text-xl font-semibold"> Continue {(firstLoad || games.isFetching) && <Spinner className="size-4" />} </h2> {!firstLoad && savedGames.length === 0 && ( <p className="text-muted-foreground text-sm"> No saved games yet — start a new adventure above. </p> )} {savedGames.map((g) => ( <Button key={g.playerName} variant="outline" className="justify-between" disabled={busy} onClick={() => startGame(g.playerName, g.genre)} > <span>{g.playerName}</span> <span className="text-muted-foreground text-sm"> {g.genre[0].toUpperCase() + g.genre.slice(1)} </span> </Button> ))} <div ref={sentinel} aria-hidden className="h-1" /> {games.isFetchingNextPage && ( <div className="flex justify-center py-2"> <Spinner /> </div> )} </div> </div> );}Đây là bộ chọn trò chơi: form trò chơi mới (shadcn Input + Button + Card) cộng với danh sách “Tiếp tục” được cung cấp bởi useGameApi().games.query với useInfiniteQuery — một <div> sentinel ở dưới cùng được theo dõi bởi IntersectionObserver tự động gọi fetchNextPage() khi được cuộn vào tầm nhìn, và các spinner bên cạnh tiêu đề và bên dưới danh sách hiển thị trạng thái đang tải. Bắt đầu một trò chơi sẽ saveGame cặp (playerName, genre) (để nó hiển thị lần sau) và điều hướng đến route chơi.
import { UseAgentUpdate, useAgent } from '@copilotkit/react-core/v2';import { useQuery } from '@tanstack/react-query';import { createFileRoute } from '@tanstack/react-router';import { useEffect, useMemo, useRef } from 'react';import { CopilotChat } from '../../components/copilot';import { useGameApi } from '../../hooks/useGameApi';import type { IGame } from ':dungeon-adventure/game-api';
// AgentCore session ids must be at least 33 characters. The AG-UI hook pads// the threadId to this length before sending, so the thread id is stable for// a given (player, genre) pair — revisiting the URL continues the same story.const buildThreadId = (playerName: string, genre: string) => `${playerName}-${genre}`.padEnd(33, '0');
export const Route = createFileRoute('/game/$playerName')({ component: RouteComponent, validateSearch: (search: Record<string, unknown>) => ({ genre: search.genre as IGame['genre'], }),});
function RouteComponent() { const { playerName } = Route.useParams(); const { genre } = Route.useSearch(); const threadId = useMemo( () => buildThreadId(playerName, genre), [playerName, genre], );
const gameApi = useGameApi(); const inventory = useQuery( gameApi.inventory.query.queryOptions({ playerName, limit: 100 }), ); // Conversation history persisted by the agent's ``S3SessionManager``. Each // turn is stored as ``session_<threadId>/agents/agent_default/messages/…``. // // `staleTime: 0` + `refetchOnMount: 'always'` together force a fresh read // on every visit — the cached snapshot from the *first* time we loaded // this route (before the agent had written any turns back to S3) would // otherwise look like an empty thread on revisit and trigger re-priming. const pastActions = useQuery({ ...gameApi.actions.query.queryOptions({ sessionId: threadId }), staleTime: 0, refetchOnMount: 'always', });
const { agent } = useAgent({ agentId: 'agent', updates: [UseAgentUpdate.OnMessagesChanged], });
// Hydrate the chat once the history query resolves. For a fresh thread // (no stored messages) the agent's system prompt expects the player's // name and genre in the first user message, so send that priming line. // // We wait for `isFetching` to go false (rather than just `isLoading`) so // that revisits with a cached empty result from the first visit aren't // mistaken for a fresh thread — the background refetch is what sees the // turns the agent wrote since. const primedRef = useRef(false); useEffect(() => { if (!agent || primedRef.current) return; if (pastActions.isFetching || !pastActions.isSuccess) return; primedRef.current = true; const items = pastActions.data.items; if (items.length > 0) { agent.setMessages( items.map((a) => ({ id: `m-${a.messageId}`, role: a.role, content: a.content, })), ); return; } agent.addMessage({ id: crypto.randomUUID(), role: 'user', content: `My name is ${playerName}. Start my ${genre} adventure.`, }); void agent.runAgent(); }, [ agent, pastActions.data, pastActions.isFetching, pastActions.isSuccess, playerName, genre, ]);
// The agent's ``add-to-inventory`` tool calls mutate DynamoDB directly, so // the inventory query needs a nudge to refetch as turns complete. The // ``useAgent({ updates: [OnMessagesChanged] })`` subscription re-renders // this route on each message event — refetch whenever the message count // changes, which covers both the initial populate and every subsequent // turn. const seenMessages = useRef(0); useEffect(() => { if (!agent) return; if (agent.messages.length !== seenMessages.current) { seenMessages.current = agent.messages.length; void inventory.refetch(); } });
return ( <div className="relative flex h-[calc(100vh-10rem)] min-h-0 flex-col"> {!!inventory.data?.items.length && ( <aside className="bg-accent text-accent-foreground pointer-events-none absolute right-4 top-4 z-10 w-56 rounded-lg border p-3 shadow-lg"> <div className="mb-1 font-semibold">📦 Inventory</div> <ul className="flex flex-col gap-0.5 text-sm"> {inventory.data.items.map((item) => ( <li key={item.itemName}> {item.emoji ?? '•'} {item.itemName} {item.quantity > 1 ? ` (x${item.quantity})` : ''} </li> ))} </ul> </aside> )} <CopilotChat agentId="agent" threadId={threadId} labels={{ chatInputPlaceholder: 'What do you do?', welcomeMessageText: `${playerName}'s ${genre} adventure`, }} /> </div> );}Đây là route chơi. Nó xây dựng threadId xác định ({player}-{genre} được đệm đến 33 ký tự — hook AG-UI sẽ gửi nó nguyên văn làm session id của AgentCore), render <CopilotChat agentId="agent" threadId={threadId} />, và phủ lên trên inventory từ useGameApi().inventory.query. Khi mount, useGameApi().actions.query({ sessionId: threadId }) đọc lịch sử hội thoại mà agent đã lưu trữ trong S3 và — nếu có — gọi agent.setMessages(...) để rehydrate chat; nếu không nó gửi một tin nhắn người dùng khởi tạo để bắt đầu câu chuyện. agent.messages được đăng ký qua useAgent({ updates: [OnMessagesChanged] }) nên mỗi lượt mới cũng refetch lại inventory query (các lời gọi MCP tool thay đổi DynamoDB trực tiếp).
Sau khi lưu, máy chủ phát triển tại http://localhost:4200/ giờ đây sẽ cho phép bạn bắt đầu một cuộc phiêu lưu và trò chuyện với Story Agent.

Nhiệm vụ 5: Triển khai lên AWS
Phần tiêu đề “Nhiệm vụ 5: Triển khai lên AWS”Trò chơi của bạn đã hoàn thành và bạn đã kiểm tra từng phần cục bộ. Bây giờ hãy triển khai nó lên AWS để bạn có thể chơi từ bất kỳ đâu.
Xây dựng mã nguồn của bạn
Phần tiêu đề “Xây dựng mã nguồn của bạn”pnpm buildyarn buildnpm run buildbun buildTriển khai ứng dụng của bạn
Phần tiêu đề “Triển khai ứng dụng của bạn”pnpm nx deploy infra "dungeon-adventure-infra-sandbox/*"yarn nx deploy infra "dungeon-adventure-infra-sandbox/*"npx nx deploy infra "dungeon-adventure-infra-sandbox/*"bunx nx deploy infra "dungeon-adventure-infra-sandbox/*"Lần triển khai đầu tiên của bạn sẽ mất khoảng 6 phút để hoàn thành vì nó đợi tất cả các tài nguyên ổn định hoàn toàn. Các lần triển khai tiếp theo sẽ nhanh hơn. Để tăng tốc độ lặp lại trong quá trình phát triển, bạn có thể chọn sử dụng CloudFormation express mode bằng cách truyền cờ --express, điều này hoàn thành mỗi thao tác tài nguyên ngay khi cấu hình của nó được áp dụng thay vì đợi ổn định hoàn toàn.
Sau khi triển khai hoàn tất, bạn sẽ thấy các đầu ra tương tự như sau:
dungeon-adventure-infra-sandbox-Applicationdungeon-adventure-infra-sandbox-Application: deploying... [2/2]
✅ dungeon-adventure-infra-sandbox-Application
✨ Deployment time: 354s
Outputs:dungeon-adventure-infra-sandbox-Application.GameApiEndpointXXX = https://xxx.execute-api.region.amazonaws.com/prod/dungeon-adventure-infra-sandbox-Application.GameUIDistributionDomainNameXXX = xxx.cloudfront.netdungeon-adventure-infra-sandbox-Application.InventoryMcpArn = arn:aws:bedrock-agentcore:region:xxxxxxx:runtime/dungeonadventureventoryMcpServerXXXX-YYYYdungeon-adventure-infra-sandbox-Application.RuntimeConfigApplicationId = xxxxdungeon-adventure-infra-sandbox-Application.StoryAgentArn = arn:aws:bedrock-agentcore:region:xxxxxxx:runtime/dungeonadventurecationStoryAgentXXXX-YYYYdungeon-adventure-infra-sandbox-Application.UserIdentityUserIdentityIdentityPoolIdXXX = region:xxxdungeon-adventure-infra-sandbox-Application.UserIdentityUserIdentityUserPoolClientIdXXX = xxxxxxxxxxdungeon-adventure-infra-sandbox-Application.UserIdentityUserIdentityUserPoolIdXXX = region_xxxĐiều hướng đến URL CloudFront của bạn (GameUIDistributionDomainName từ các đầu ra CDK), đăng ký tài khoản mới và chơi trò chơi của bạn chạy hoàn toàn trên AWS!
Nhiệm vụ 6: Kết hợp các thành phần cục bộ và đã triển khai
Phần tiêu đề “Nhiệm vụ 6: Kết hợp các thành phần cục bộ và đã triển khai”Bạn đã thấy hai đầu của phổ: mọi thứ cục bộ (dev) và mọi thứ đã triển khai. Trong quá trình phát triển hàng ngày, thường hữu ích khi kết hợp cả hai — ví dụ, lặp lại trên code website với API và agent thực đã triển khai, hoặc chạy API cục bộ với bảng DynamoDB thực.
Chìa khóa là biến môi trường RUNTIME_CONFIG_APP_ID. Khi một dự án chạy không có LOCAL_DEV=true, các tra cứu runtime config lấy cấu hình của chúng từ AWS AppConfig sử dụng application id này — giá trị RuntimeConfigApplicationId từ các đầu ra CDK của bạn.
Mỗi website có một target load:runtime-config tải xuống runtime-config.json đã triển khai (Cognito pool, API endpoints, agent ARN) vào máy chủ phát triển cục bộ. Một số kết hợp hữu ích:
-
Website cục bộ → backend đã triển khai. Tải config đã triển khai một lần, sau đó chạy target
servethông thường để UI giao tiếp với API và agent đã triển khai:Terminal window pnpm nx run game-ui:load:runtime-configTerminal window yarn nx run game-ui:load:runtime-configTerminal window npx nx run game-ui:load:runtime-configTerminal window bunx nx run game-ui:load:runtime-configTerminal window pnpm nx serve game-uiTerminal window yarn nx serve game-uiTerminal window npx nx serve game-uiTerminal window bunx nx serve game-ui -
API cục bộ → bảng DynamoDB thực. Chạy target
servethông thường vớiRUNTIME_CONFIG_APP_IDđược đặt thành application id đã triển khai:Terminal window RUNTIME_CONFIG_APP_ID=<RuntimeConfigApplicationId from CDK outputs> pnpm nx serve game-apiTerminal window RUNTIME_CONFIG_APP_ID=<RuntimeConfigApplicationId from CDK outputs> yarn nx serve game-apiTerminal window RUNTIME_CONFIG_APP_ID=<RuntimeConfigApplicationId from CDK outputs> npx nx serve game-apiTerminal window RUNTIME_CONFIG_APP_ID=<RuntimeConfigApplicationId from CDK outputs> bunx nx serve game-api
Tính linh hoạt này cho phép bạn kiểm tra chính xác phần bạn đang làm việc, với bất kỳ thành phần nào — cục bộ hoặc đã triển khai — có ý nghĩa.
Chúc mừng. Bạn đã xây dựng, kiểm tra và triển khai Trò chơi Phiêu lưu Hầm ngục với Agent của mình! 🎉🎉🎉