Spaces:
Paused
Paused
Initial deployment of Mini World game
Browse files- .gitignore +6 -0
- CLAUDE.md +82 -0
- Dockerfile +33 -0
- README.md +39 -5
- aiService.ts +129 -0
- bun.lockb +0 -0
- gameState.ts +401 -0
- index.html +69 -0
- package.json +14 -0
- server.ts +103 -0
.gitignore
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.parcel-cache
|
| 2 |
+
dist
|
| 3 |
+
node_modules
|
| 4 |
+
.aider*
|
| 5 |
+
.env
|
| 6 |
+
messages.json
|
CLAUDE.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# CLAUDE.md
|
| 2 |
+
|
| 3 |
+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
| 4 |
+
|
| 5 |
+
## Commands
|
| 6 |
+
|
| 7 |
+
```bash
|
| 8 |
+
# Install dependencies
|
| 9 |
+
bun install
|
| 10 |
+
|
| 11 |
+
# Start the server (requires HF_TOKEN env var)
|
| 12 |
+
HF_TOKEN=<your-token> bun server.ts
|
| 13 |
+
|
| 14 |
+
# Or using npm script
|
| 15 |
+
HF_TOKEN=<your-token> bun start
|
| 16 |
+
```
|
| 17 |
+
|
| 18 |
+
The server runs at http://localhost:7860 (or set `PORT` env var)
|
| 19 |
+
|
| 20 |
+
## Architecture
|
| 21 |
+
|
| 22 |
+
This is a real-time AI-powered exploration game where an AI agent navigates a procedurally-generated 2D emoji world.
|
| 23 |
+
|
| 24 |
+
### Core Files
|
| 25 |
+
|
| 26 |
+
- **server.ts** - HTTP server using Bun's native `serve()`. Handles SSE streaming via `/game-stream` endpoint and static file serving. Contains the main AI loop that calls `AIService.getNextAction()` every 1 second.
|
| 27 |
+
|
| 28 |
+
- **aiService.ts** - AI inference layer using OpenAI SDK with HuggingFace router (`https://router.huggingface.co/v1`). Manages system prompts, message history (limited to last 15 entries), and action validation. Returns `{ action: "move"|"pick", detail: "up"|"down"|"left"|"right" }`.
|
| 29 |
+
|
| 30 |
+
- **gameState.ts** - Game world and state management. Creates a 600x600 grid using Perlin noise for terrain generation (trees, rocks, empty spaces). Handles character movement, object picking, and broadcasts state updates to all connected SSE clients.
|
| 31 |
+
|
| 32 |
+
- **index.html** - Frontend using vanilla JS + Tailwind CSS. Connects via EventSource to `/game-stream` and renders the map, inventory, and action history in real-time.
|
| 33 |
+
|
| 34 |
+
### Data Flow
|
| 35 |
+
|
| 36 |
+
```
|
| 37 |
+
Client connects → /game-stream SSE
|
| 38 |
+
↓
|
| 39 |
+
GameState.sendUpdate() broadcasts current state
|
| 40 |
+
↓
|
| 41 |
+
Every 1 second: runAI() → AIService.getNextAction()
|
| 42 |
+
↓
|
| 43 |
+
AI returns JSON action → GameState.handleAction()
|
| 44 |
+
↓
|
| 45 |
+
State update broadcast to all clients
|
| 46 |
+
```
|
| 47 |
+
|
| 48 |
+
### Environment Variables
|
| 49 |
+
|
| 50 |
+
- `HF_TOKEN` (required) - HuggingFace API token for model access
|
| 51 |
+
- `HF_MODEL` or `OPENAI_MODEL` (optional) - Override default model (`zai-org/GLM-4.7-Flash:novita`)
|
| 52 |
+
|
| 53 |
+
### Map Symbols
|
| 54 |
+
|
| 55 |
+
- 🚶 Character (player)
|
| 56 |
+
- 💎 Diamond (collectible)
|
| 57 |
+
- 🌳 Tree (obstacle)
|
| 58 |
+
- 🪨 Rock (obstacle)
|
| 59 |
+
- ⬜ Empty space (walkable)
|
| 60 |
+
|
| 61 |
+
## Testing
|
| 62 |
+
|
| 63 |
+
The AI loop only starts when a client connects to `/game-stream`. To test:
|
| 64 |
+
|
| 65 |
+
```bash
|
| 66 |
+
# Start server in background
|
| 67 |
+
HF_TOKEN=<token> bun server.ts &
|
| 68 |
+
|
| 69 |
+
# Test config endpoint
|
| 70 |
+
curl http://localhost:3000/config
|
| 71 |
+
|
| 72 |
+
# Connect to game stream (triggers AI loop)
|
| 73 |
+
curl http://localhost:3000/game-stream
|
| 74 |
+
```
|
| 75 |
+
|
| 76 |
+
Server logs show `Raw AI response:` for each AI action and `Rendering around character at (x, y)` for position updates.
|
| 77 |
+
|
| 78 |
+
### AI Response Handling
|
| 79 |
+
|
| 80 |
+
- AI may return JSON wrapped in markdown code blocks (``` ```json...``` ```) - this is stripped automatically
|
| 81 |
+
- Invalid/malformed responses fall back to `{"action":"move","detail":"right"}`
|
| 82 |
+
- Valid actions: `move` or `pick` with direction `up`, `down`, `left`, or `right`
|
Dockerfile
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Use official Bun image
|
| 2 |
+
FROM oven/bun:1-alpine AS base
|
| 3 |
+
|
| 4 |
+
# Set up user with ID 1000 (required by Hugging Face Spaces)
|
| 5 |
+
RUN adduser -D -u 1000 user
|
| 6 |
+
|
| 7 |
+
# Switch to the user
|
| 8 |
+
USER user
|
| 9 |
+
|
| 10 |
+
# Set environment
|
| 11 |
+
ENV HOME=/home/user \
|
| 12 |
+
PATH=/home/user/.local/bin:$PATH
|
| 13 |
+
|
| 14 |
+
# Set working directory
|
| 15 |
+
WORKDIR $HOME/app
|
| 16 |
+
|
| 17 |
+
# Copy package files first for better caching
|
| 18 |
+
COPY --chown=user package.json bun.lockb ./
|
| 19 |
+
|
| 20 |
+
# Install dependencies
|
| 21 |
+
RUN bun install --frozen-lockfile --production
|
| 22 |
+
|
| 23 |
+
# Copy application files
|
| 24 |
+
COPY --chown=user . .
|
| 25 |
+
|
| 26 |
+
# Expose port 7860 (Hugging Face Spaces default)
|
| 27 |
+
EXPOSE 7860
|
| 28 |
+
|
| 29 |
+
# Set default port for Hugging Face Spaces
|
| 30 |
+
ENV PORT=7860
|
| 31 |
+
|
| 32 |
+
# Run the server
|
| 33 |
+
CMD ["bun", "server.ts"]
|
README.md
CHANGED
|
@@ -1,10 +1,44 @@
|
|
| 1 |
---
|
| 2 |
title: Mini World
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
-
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
title: Mini World
|
| 3 |
+
emoji: 🌍
|
| 4 |
+
colorFrom: green
|
| 5 |
+
colorTo: blue
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
---
|
| 9 |
|
| 10 |
+
# Mini World
|
| 11 |
+
|
| 12 |
+
A real-time AI-powered exploration game where an AI agent navigates a procedurally-generated 2D emoji world.
|
| 13 |
+
|
| 14 |
+
## Features
|
| 15 |
+
|
| 16 |
+
- 🚶 AI agent that autonomously explores a 600x600 world
|
| 17 |
+
- 💎 Collectible diamonds scattered across the map
|
| 18 |
+
- 🌳🪨 Procedurally generated terrain using Perlin noise
|
| 19 |
+
- Real-time updates via Server-Sent Events (SSE)
|
| 20 |
+
|
| 21 |
+
## Environment Variables
|
| 22 |
+
|
| 23 |
+
Set these in your Hugging Face Space settings:
|
| 24 |
+
|
| 25 |
+
- `HF_TOKEN` (required) - Your Hugging Face API token for model access
|
| 26 |
+
|
| 27 |
+
## Local Development
|
| 28 |
+
|
| 29 |
+
```bash
|
| 30 |
+
# Install dependencies
|
| 31 |
+
bun install
|
| 32 |
+
|
| 33 |
+
# Start the server
|
| 34 |
+
HF_TOKEN=<your-token> bun server.ts
|
| 35 |
+
```
|
| 36 |
+
|
| 37 |
+
The server runs at http://localhost:7860 (or set `PORT` env var)
|
| 38 |
+
|
| 39 |
+
## Architecture
|
| 40 |
+
|
| 41 |
+
- **server.ts** - HTTP server with SSE streaming
|
| 42 |
+
- **aiService.ts** - AI inference using OpenAI SDK with HuggingFace router
|
| 43 |
+
- **gameState.ts** - Game world and state management
|
| 44 |
+
- **index.html** - Frontend UI
|
aiService.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { OpenAI } from "openai";
|
| 2 |
+
|
| 3 |
+
// Configuration - use specified model with fallback to env vars
|
| 4 |
+
export const MODEL =
|
| 5 |
+
process.env.HF_MODEL ||
|
| 6 |
+
process.env.OPENAI_MODEL ||
|
| 7 |
+
"zai-org/GLM-4.7-Flash:novita";
|
| 8 |
+
|
| 9 |
+
const HF_TOKEN = process.env.HF_TOKEN;
|
| 10 |
+
if (!HF_TOKEN) {
|
| 11 |
+
throw new Error(
|
| 12 |
+
"HF_TOKEN environment variable is required for Hugging Face Router authentication."
|
| 13 |
+
);
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
const SYS_PROMPT = `
|
| 17 |
+
# Welcome to the you own exploration game
|
| 18 |
+
|
| 19 |
+
You are acting as an explorer in a 2D world game seen from a top-down perspective. Everything in this world, including objects and landmarks, is represented by emoji.
|
| 20 |
+
The most important emoji is 🚶 (it's you).
|
| 21 |
+
|
| 22 |
+
## Important concepts
|
| 23 |
+
|
| 24 |
+
### Turn-based
|
| 25 |
+
|
| 26 |
+
You can take one action per turn, then you'll get the result of your action before you can take another action.
|
| 27 |
+
|
| 28 |
+
### Goals
|
| 29 |
+
|
| 30 |
+
Your primary goal is to collect as many diamonds (💎) as possible. A good strategy is to explore efficiently and avoid revisiting the same tiles while searching for diamonds.
|
| 31 |
+
|
| 32 |
+
## Available Actions
|
| 33 |
+
|
| 34 |
+
You must respond your next action in JSON format with what action you want to take. Give no explanations just return the JSON.
|
| 35 |
+
|
| 36 |
+
### Move
|
| 37 |
+
|
| 38 |
+
This action allows you to move one emoji away from your current location, choosing between the four available direct tiles around you (🚶): "up", "right", "down", or "left".
|
| 39 |
+
You are only allowed to move on the empty ⬜ square, so always make sure that your target square is a ⬜.
|
| 40 |
+
|
| 41 |
+
{ "action": "move", "detail": move direction, can be "up", "right", "down", or "left" }
|
| 42 |
+
|
| 43 |
+
### Pick
|
| 44 |
+
|
| 45 |
+
This action allows you to pick up an object from an adjacent tile (up, right, down, or left).
|
| 46 |
+
The object will be added to your inventory and removed from the map.
|
| 47 |
+
|
| 48 |
+
{ "action": "pick", "detail": direction of the object to pick, can be "up", "right", "down", or "left" }`;
|
| 49 |
+
|
| 50 |
+
// OpenAI client configured for HuggingFace router
|
| 51 |
+
const client = new OpenAI({
|
| 52 |
+
baseURL: "https://router.huggingface.co/v1",
|
| 53 |
+
apiKey: HF_TOKEN,
|
| 54 |
+
});
|
| 55 |
+
|
| 56 |
+
export class AIService {
|
| 57 |
+
/**
|
| 58 |
+
* Get the next action from the AI agent
|
| 59 |
+
* Returns a validated action object { action: "move"|"pick", detail: "up"|"down"|"left"|"right" }
|
| 60 |
+
*/
|
| 61 |
+
static async getNextAction(
|
| 62 |
+
map: string,
|
| 63 |
+
history: any[]
|
| 64 |
+
): Promise<{ action: string; detail: string }> {
|
| 65 |
+
const messages: OpenAI.ChatCompletionMessageParam[] = [
|
| 66 |
+
{
|
| 67 |
+
role: "system",
|
| 68 |
+
content: SYS_PROMPT,
|
| 69 |
+
},
|
| 70 |
+
...history.slice(-15),
|
| 71 |
+
{
|
| 72 |
+
role: "user",
|
| 73 |
+
content: `Please decide on what action to do next and provide the corresponding JSON response. Here is the current map:
|
| 74 |
+
${map}
|
| 75 |
+
`,
|
| 76 |
+
},
|
| 77 |
+
];
|
| 78 |
+
|
| 79 |
+
// Log messages for debugging
|
| 80 |
+
Bun.write("messages.json", JSON.stringify(messages, null, 2));
|
| 81 |
+
|
| 82 |
+
try {
|
| 83 |
+
const chatCompletion = await client.chat.completions.create({
|
| 84 |
+
model: MODEL,
|
| 85 |
+
messages,
|
| 86 |
+
temperature: 0.7,
|
| 87 |
+
});
|
| 88 |
+
|
| 89 |
+
let content = chatCompletion.choices[0].message.content?.trim() ?? "";
|
| 90 |
+
console.log("Raw AI response:", content);
|
| 91 |
+
|
| 92 |
+
// Strip markdown code blocks if present
|
| 93 |
+
if (content.startsWith("```")) {
|
| 94 |
+
content = content.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "");
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
// Parse and validate the response
|
| 98 |
+
const parsed = JSON.parse(content);
|
| 99 |
+
|
| 100 |
+
if (this.isValidAction(parsed)) {
|
| 101 |
+
return {
|
| 102 |
+
action: parsed.action,
|
| 103 |
+
detail: parsed.detail.toLowerCase(),
|
| 104 |
+
};
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
throw new Error("Invalid action format");
|
| 108 |
+
} catch (error) {
|
| 109 |
+
console.error("Error getting AI action:", error);
|
| 110 |
+
// Return a safe fallback action if anything fails
|
| 111 |
+
return { action: "move", detail: "right" };
|
| 112 |
+
}
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
private static isValidAction(obj: any): boolean {
|
| 116 |
+
if (!obj || typeof obj !== "object" || typeof obj.action !== "string") {
|
| 117 |
+
return false;
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
if (obj.action === "move" || obj.action === "pick") {
|
| 121 |
+
return (
|
| 122 |
+
typeof obj.detail === "string" &&
|
| 123 |
+
["up", "down", "left", "right"].includes(obj.detail.toLowerCase())
|
| 124 |
+
);
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
return false;
|
| 128 |
+
}
|
| 129 |
+
}
|
bun.lockb
ADDED
|
Binary file (1.32 kB). View file
|
|
|
gameState.ts
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export const emojis = {
|
| 2 |
+
tree: "🌳",
|
| 3 |
+
rock: "🪨",
|
| 4 |
+
character: "🚶",
|
| 5 |
+
blank: "⬜",
|
| 6 |
+
diamond: "💎",
|
| 7 |
+
};
|
| 8 |
+
|
| 9 |
+
type World = {
|
| 10 |
+
width: number;
|
| 11 |
+
height: number;
|
| 12 |
+
data: string[];
|
| 13 |
+
};
|
| 14 |
+
|
| 15 |
+
type GameObject = {
|
| 16 |
+
x: number;
|
| 17 |
+
y: number;
|
| 18 |
+
emoji: string;
|
| 19 |
+
};
|
| 20 |
+
|
| 21 |
+
type Character = GameObject & {
|
| 22 |
+
inventory: GameObject[];
|
| 23 |
+
};
|
| 24 |
+
|
| 25 |
+
export class GameState {
|
| 26 |
+
private world: World;
|
| 27 |
+
private character: Character;
|
| 28 |
+
private objects: GameObject[] = [];
|
| 29 |
+
private controllers: Set<ReadableStreamDefaultController<string>> = new Set();
|
| 30 |
+
private history: any[] = [];
|
| 31 |
+
|
| 32 |
+
constructor() {
|
| 33 |
+
this.initializeGame();
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
private initializeGame() {
|
| 37 |
+
const worldWidth = 600;
|
| 38 |
+
const worldHeight = 600;
|
| 39 |
+
this.world = this.createWorld(worldWidth, worldHeight);
|
| 40 |
+
this.world = this.generateTerrain(
|
| 41 |
+
this.world,
|
| 42 |
+
[
|
| 43 |
+
{ emoji: "🌳", threshold: 0.4 },
|
| 44 |
+
{ emoji: "🪨", threshold: 0.5 },
|
| 45 |
+
],
|
| 46 |
+
0.1
|
| 47 |
+
);
|
| 48 |
+
|
| 49 |
+
const emptyLocation = this.getRandomEmptyLocation(this.world);
|
| 50 |
+
this.character = {
|
| 51 |
+
...emptyLocation,
|
| 52 |
+
emoji: emojis.character,
|
| 53 |
+
inventory: [],
|
| 54 |
+
};
|
| 55 |
+
|
| 56 |
+
// Spawn 500 diamonds at random locations
|
| 57 |
+
for (let i = 0; i < 500; i++) {
|
| 58 |
+
this.spawnAtRandom(emojis.diamond);
|
| 59 |
+
}
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
setController(controller: ReadableStreamDefaultController<string>) {
|
| 63 |
+
this.controllers.add(controller);
|
| 64 |
+
try {
|
| 65 |
+
// Send initial state immediately when controller is set
|
| 66 |
+
this.sendUpdate();
|
| 67 |
+
} catch (error) {
|
| 68 |
+
console.error("Error sending initial state:", error);
|
| 69 |
+
this.controllers.delete(controller);
|
| 70 |
+
}
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
removeController(controller: ReadableStreamDefaultController<string>) {
|
| 74 |
+
this.controllers.delete(controller);
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
getState() {
|
| 78 |
+
return {
|
| 79 |
+
map: this.renderAround(this.world, [this.character, ...this.objects], 6),
|
| 80 |
+
inventory: this.character.inventory.map((item) => item.emoji),
|
| 81 |
+
history: this.history,
|
| 82 |
+
};
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
async handleAction(action: any) {
|
| 86 |
+
try {
|
| 87 |
+
if (action.action === "move") {
|
| 88 |
+
this.moveObject(this.character, action.detail, this.world);
|
| 89 |
+
this.history.push({
|
| 90 |
+
role: "assistant",
|
| 91 |
+
content: JSON.stringify(action),
|
| 92 |
+
});
|
| 93 |
+
this.history.push({
|
| 94 |
+
role: "user",
|
| 95 |
+
content: `valid action - input your next action in JSON.\nCurrent map:\n\n${this.renderAround(
|
| 96 |
+
this.world,
|
| 97 |
+
[this.character, ...this.objects],
|
| 98 |
+
5
|
| 99 |
+
)}\n\nInventory: ${this.character.inventory
|
| 100 |
+
.map((item) => item.emoji)
|
| 101 |
+
.join(", ")}`,
|
| 102 |
+
});
|
| 103 |
+
} else if (action.action === "pick") {
|
| 104 |
+
this.pickObject(action.detail);
|
| 105 |
+
this.history.push({
|
| 106 |
+
role: "assistant",
|
| 107 |
+
content: JSON.stringify(action),
|
| 108 |
+
});
|
| 109 |
+
this.history.push({
|
| 110 |
+
role: "user",
|
| 111 |
+
content: `valid action - input your next action in JSON.\nCurrent map:\n\n${this.renderAround(
|
| 112 |
+
this.world,
|
| 113 |
+
[this.character, ...this.objects],
|
| 114 |
+
3
|
| 115 |
+
)}\n\nInventory: ${this.character.inventory
|
| 116 |
+
.map((item) => item.emoji)
|
| 117 |
+
.join(", ")}`,
|
| 118 |
+
});
|
| 119 |
+
}
|
| 120 |
+
} catch (err) {
|
| 121 |
+
this.history.push({
|
| 122 |
+
role: "user",
|
| 123 |
+
content: "This action is invalid. Please try something else.",
|
| 124 |
+
});
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
this.sendUpdate();
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
private sendUpdate() {
|
| 131 |
+
if (this.controllers.size === 0) return;
|
| 132 |
+
|
| 133 |
+
const state = this.getState();
|
| 134 |
+
const data = `data: ${JSON.stringify(state)}\n\n`;
|
| 135 |
+
|
| 136 |
+
for (const controller of this.controllers) {
|
| 137 |
+
try {
|
| 138 |
+
controller.enqueue(data);
|
| 139 |
+
} catch (error) {
|
| 140 |
+
console.error("Error sending update to client:", error);
|
| 141 |
+
this.controllers.delete(controller);
|
| 142 |
+
}
|
| 143 |
+
}
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
// Helper functions moved from main.js
|
| 147 |
+
private createWorld(width: number, height: number): World {
|
| 148 |
+
return {
|
| 149 |
+
width,
|
| 150 |
+
height,
|
| 151 |
+
data: new Array(width * height).fill(emojis.blank),
|
| 152 |
+
};
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
private getIndex(world: World, x: number, y: number): number {
|
| 156 |
+
return y * world.width + x;
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
private setTile(world: World, x: number, y: number, tile: string): World {
|
| 160 |
+
const newWorld = { ...world };
|
| 161 |
+
newWorld.data[this.getIndex(newWorld, x, y)] = tile;
|
| 162 |
+
return newWorld;
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
private getTile(world: World, x: number, y: number): string {
|
| 166 |
+
return world.data[this.getIndex(world, x, y)];
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
private isEmpty(world: World, x: number, y: number): boolean {
|
| 170 |
+
if (x < 0 || y < 0 || x >= world.width || y >= world.height) {
|
| 171 |
+
return false;
|
| 172 |
+
}
|
| 173 |
+
return this.getTile(world, x, y) === emojis.blank;
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
private getRandomEmptyLocation(world: World) {
|
| 177 |
+
let x = Math.floor(Math.random() * world.width);
|
| 178 |
+
let y = Math.floor(Math.random() * world.height);
|
| 179 |
+
|
| 180 |
+
while (!this.isEmpty(world, x, y)) {
|
| 181 |
+
x = Math.floor(Math.random() * world.width);
|
| 182 |
+
y = Math.floor(Math.random() * world.height);
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
return { x, y };
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
private moveObject(object: Character, direction: string, world: World) {
|
| 189 |
+
const moveMapping: Record<string, { dx: number; dy: number }> = {
|
| 190 |
+
up: { dx: 0, dy: -1 },
|
| 191 |
+
down: { dx: 0, dy: 1 },
|
| 192 |
+
left: { dx: -1, dy: 0 },
|
| 193 |
+
right: { dx: 1, dy: 0 },
|
| 194 |
+
};
|
| 195 |
+
|
| 196 |
+
const movement = moveMapping[direction];
|
| 197 |
+
if (
|
| 198 |
+
movement &&
|
| 199 |
+
this.isEmpty(world, object.x + movement.dx, object.y + movement.dy)
|
| 200 |
+
) {
|
| 201 |
+
object.x += movement.dx;
|
| 202 |
+
object.y += movement.dy;
|
| 203 |
+
} else {
|
| 204 |
+
throw new Error("invalid action");
|
| 205 |
+
}
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
private generateTerrain(
|
| 209 |
+
world: World,
|
| 210 |
+
emojiThresholds: Array<{ emoji: string; threshold: number }>,
|
| 211 |
+
noiseScale: number
|
| 212 |
+
): World {
|
| 213 |
+
const permutations = this.generatePermutations();
|
| 214 |
+
let newWorld = { ...world };
|
| 215 |
+
|
| 216 |
+
for (let y = 0; y < newWorld.height; y++) {
|
| 217 |
+
for (let x = 0; x < newWorld.width; x++) {
|
| 218 |
+
const noiseValue =
|
| 219 |
+
(this.perlin2d(x * noiseScale, y * noiseScale, permutations) + 1) / 2;
|
| 220 |
+
|
| 221 |
+
let found = false;
|
| 222 |
+
for (const { emoji, threshold } of emojiThresholds) {
|
| 223 |
+
if (noiseValue < threshold) {
|
| 224 |
+
newWorld = this.setTile(newWorld, x, y, emoji);
|
| 225 |
+
found = true;
|
| 226 |
+
break;
|
| 227 |
+
}
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
if (!found) {
|
| 231 |
+
newWorld = this.setTile(newWorld, x, y, emojis.blank);
|
| 232 |
+
}
|
| 233 |
+
}
|
| 234 |
+
}
|
| 235 |
+
return newWorld;
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
private generatePermutations(): number[] {
|
| 239 |
+
const p = new Array(256);
|
| 240 |
+
for (let i = 0; i < 256; i++) {
|
| 241 |
+
p[i] = Math.floor(Math.random() * 256);
|
| 242 |
+
}
|
| 243 |
+
return p.concat(p);
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
private fade(t: number): number {
|
| 247 |
+
return t * t * t * (t * (t * 6 - 15) + 10);
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
private lerp(t: number, a: number, b: number): number {
|
| 251 |
+
return a + t * (b - a);
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
private grad(hash: number, x: number, y: number): number {
|
| 255 |
+
const h = hash & 3;
|
| 256 |
+
const u = h < 2 ? x : y;
|
| 257 |
+
const v = h < 2 ? y : x;
|
| 258 |
+
return (h & 1 ? -u : u) + (h & 2 ? -2 * v : 2 * v);
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
private perlin2d(x: number, y: number, permutations: number[]): number {
|
| 262 |
+
const X = Math.floor(x) & 255;
|
| 263 |
+
const Y = Math.floor(y) & 255;
|
| 264 |
+
|
| 265 |
+
x -= Math.floor(x);
|
| 266 |
+
y -= Math.floor(y);
|
| 267 |
+
|
| 268 |
+
const u = this.fade(x);
|
| 269 |
+
const v = this.fade(y);
|
| 270 |
+
|
| 271 |
+
const A = permutations[X] + Y;
|
| 272 |
+
const B = permutations[X + 1] + Y;
|
| 273 |
+
|
| 274 |
+
return this.lerp(
|
| 275 |
+
v,
|
| 276 |
+
this.lerp(
|
| 277 |
+
u,
|
| 278 |
+
this.grad(permutations[A], x, y),
|
| 279 |
+
this.grad(permutations[B], x - 1, y)
|
| 280 |
+
),
|
| 281 |
+
this.lerp(
|
| 282 |
+
u,
|
| 283 |
+
this.grad(permutations[A + 1], x, y - 1),
|
| 284 |
+
this.grad(permutations[B + 1], x - 1, y - 1)
|
| 285 |
+
)
|
| 286 |
+
);
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
private renderAround(
|
| 290 |
+
world: World,
|
| 291 |
+
objects: GameObject[],
|
| 292 |
+
distance: number
|
| 293 |
+
): string {
|
| 294 |
+
if (!objects.length) return "No objects to render";
|
| 295 |
+
const mainObject = objects[0]; // Use first object (character) as reference point
|
| 296 |
+
console.log(
|
| 297 |
+
`Rendering around character at (${mainObject.x}, ${mainObject.y}) with ${objects.length} total objects`
|
| 298 |
+
);
|
| 299 |
+
const startPosition = {
|
| 300 |
+
x: mainObject.x - distance,
|
| 301 |
+
y: mainObject.y - distance,
|
| 302 |
+
};
|
| 303 |
+
|
| 304 |
+
const areaWidth = 2 * distance + 1;
|
| 305 |
+
const areaHeight = 2 * distance + 1;
|
| 306 |
+
|
| 307 |
+
return this.render(world, objects, startPosition, areaWidth, areaHeight);
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
public spawnObject(x: number, y: number, emoji: string): GameObject {
|
| 311 |
+
if (!this.isEmpty(this.world, x, y)) {
|
| 312 |
+
throw new Error("Cannot spawn object on non-empty space");
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
const newObject: GameObject = { x, y, emoji };
|
| 316 |
+
this.objects.push(newObject);
|
| 317 |
+
this.sendUpdate();
|
| 318 |
+
return newObject;
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
public spawnAtRandom(emoji: string): GameObject {
|
| 322 |
+
const location = this.getRandomEmptyLocation(this.world);
|
| 323 |
+
const obj = this.spawnObject(location.x, location.y, emoji);
|
| 324 |
+
console.log(
|
| 325 |
+
`Spawned ${emoji} at (${obj.x}, ${obj.y}). Total objects: ${this.objects.length}`
|
| 326 |
+
);
|
| 327 |
+
return obj;
|
| 328 |
+
}
|
| 329 |
+
|
| 330 |
+
private getAdjacentObject(
|
| 331 |
+
x: number,
|
| 332 |
+
y: number,
|
| 333 |
+
direction: string
|
| 334 |
+
): GameObject | null {
|
| 335 |
+
const moveMapping: Record<string, { dx: number; dy: number }> = {
|
| 336 |
+
up: { dx: 0, dy: -1 },
|
| 337 |
+
down: { dx: 0, dy: 1 },
|
| 338 |
+
left: { dx: -1, dy: 0 },
|
| 339 |
+
right: { dx: 1, dy: 0 },
|
| 340 |
+
};
|
| 341 |
+
|
| 342 |
+
const movement = moveMapping[direction];
|
| 343 |
+
if (!movement) return null;
|
| 344 |
+
|
| 345 |
+
const targetX = x + movement.dx;
|
| 346 |
+
const targetY = y + movement.dy;
|
| 347 |
+
|
| 348 |
+
return (
|
| 349 |
+
this.objects.find((obj) => obj.x === targetX && obj.y === targetY) || null
|
| 350 |
+
);
|
| 351 |
+
}
|
| 352 |
+
|
| 353 |
+
private pickObject(direction: string) {
|
| 354 |
+
const object = this.getAdjacentObject(
|
| 355 |
+
this.character.x,
|
| 356 |
+
this.character.y,
|
| 357 |
+
direction
|
| 358 |
+
);
|
| 359 |
+
|
| 360 |
+
if (!object) {
|
| 361 |
+
throw new Error("No object found in that direction");
|
| 362 |
+
}
|
| 363 |
+
|
| 364 |
+
// Add to inventory
|
| 365 |
+
this.character.inventory.push(object);
|
| 366 |
+
|
| 367 |
+
// Remove from world objects
|
| 368 |
+
this.objects = this.objects.filter((obj) => obj !== object);
|
| 369 |
+
}
|
| 370 |
+
|
| 371 |
+
private render(
|
| 372 |
+
world: World,
|
| 373 |
+
objects: Character[] = [],
|
| 374 |
+
startPosition = { x: 0, y: 0 },
|
| 375 |
+
areaWidth = world.width,
|
| 376 |
+
areaHeight = world.height
|
| 377 |
+
): string {
|
| 378 |
+
let output = "";
|
| 379 |
+
|
| 380 |
+
for (let y = startPosition.y; y < startPosition.y + areaHeight; y++) {
|
| 381 |
+
for (let x = startPosition.x; x < startPosition.x + areaWidth; x++) {
|
| 382 |
+
if (x >= 0 && y >= 0 && x < world.width && y < world.height) {
|
| 383 |
+
const objectAtPosition = objects.find(
|
| 384 |
+
(obj) => obj.x === x && obj.y === y
|
| 385 |
+
);
|
| 386 |
+
|
| 387 |
+
if (objectAtPosition) {
|
| 388 |
+
output += objectAtPosition.emoji;
|
| 389 |
+
} else {
|
| 390 |
+
output += this.getTile(world, x, y);
|
| 391 |
+
}
|
| 392 |
+
} else {
|
| 393 |
+
output += "🪨";
|
| 394 |
+
}
|
| 395 |
+
}
|
| 396 |
+
output += "\n";
|
| 397 |
+
}
|
| 398 |
+
|
| 399 |
+
return output;
|
| 400 |
+
}
|
| 401 |
+
}
|
index.html
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en" style="display: flex; align-items: center; justify-items: center; width: 100vw">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>Mini World</title>
|
| 7 |
+
<link href="https://unpkg.com/tailwindcss@^2/dist/tailwind.min.css" rel="stylesheet" />
|
| 8 |
+
</head>
|
| 9 |
+
<body class="flex gap-4 items-start p-12">
|
| 10 |
+
<div class="flex flex-col gap-4">
|
| 11 |
+
<div id="world-container" class="text-3xl flex-none whitespace-nowrap leading-none"></div>
|
| 12 |
+
<div id="inventory-container" class="text-2xl"></div>
|
| 13 |
+
</div>
|
| 14 |
+
<div class="flex flex-col pt-1 gap-4">
|
| 15 |
+
<p class="font-mono rounded-lg">
|
| 16 |
+
model: <span id="model-display" class="text-blue-600">loading…</span>
|
| 17 |
+
</p>
|
| 18 |
+
<div class="gap-2 flex flex-col">
|
| 19 |
+
Agent
|
| 20 |
+
<div class="flex items-center gap-2 rounded-lg border bg-gray-50 px-3 py-2">
|
| 21 |
+
<span class="text-3xl">🚶</span>
|
| 22 |
+
<span class="text-sm text-gray-600">Explorer</span>
|
| 23 |
+
</div>
|
| 24 |
+
</div>
|
| 25 |
+
<p>Goal: Explore as much as possible</p>
|
| 26 |
+
<div id="history-container" class="flex max-w-sm flex-wrap gap-2 pt-2"></div>
|
| 27 |
+
</div>
|
| 28 |
+
<script>
|
| 29 |
+
// Fetch runtime config (model name) and reflect in UI
|
| 30 |
+
fetch('/config')
|
| 31 |
+
.then(r => r.json())
|
| 32 |
+
.then(cfg => {
|
| 33 |
+
const el = document.getElementById('model-display');
|
| 34 |
+
if (el && cfg?.model) el.textContent = cfg.model;
|
| 35 |
+
})
|
| 36 |
+
.catch(() => {});
|
| 37 |
+
|
| 38 |
+
const eventSource = new EventSource('/game-stream');
|
| 39 |
+
|
| 40 |
+
eventSource.onmessage = (event) => {
|
| 41 |
+
const state = JSON.parse(event.data);
|
| 42 |
+
document.getElementById('world-container').innerText = state.map;
|
| 43 |
+
document.getElementById('inventory-container').innerText =
|
| 44 |
+
`Inventory: ${state.inventory.join(' ')}`;
|
| 45 |
+
|
| 46 |
+
const history = state.history
|
| 47 |
+
.filter(({ role }) => role === "assistant")
|
| 48 |
+
.map(({ content }) => {
|
| 49 |
+
const parsed = JSON.parse(content);
|
| 50 |
+
return `
|
| 51 |
+
<div class="flex text-sm">
|
| 52 |
+
<div class="flex items-baseline gap-2 rounded-full border bg-gray-100 px-2 py-1">
|
| 53 |
+
<div class="h-2 w-2 flex-none rounded-full bg-green-500"></div>
|
| 54 |
+
${parsed.action}: <span class="text-gray-500">${parsed.detail}</span>
|
| 55 |
+
</div>
|
| 56 |
+
</div>`;
|
| 57 |
+
})
|
| 58 |
+
.join("");
|
| 59 |
+
|
| 60 |
+
document.getElementById('history-container').innerHTML = history;
|
| 61 |
+
};
|
| 62 |
+
|
| 63 |
+
// Clean up when the page is closed
|
| 64 |
+
window.addEventListener('beforeunload', () => {
|
| 65 |
+
eventSource.close();
|
| 66 |
+
});
|
| 67 |
+
</script>
|
| 68 |
+
</body>
|
| 69 |
+
</html>
|
package.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "mini-world",
|
| 3 |
+
"version": "1.0.0",
|
| 4 |
+
"author": "",
|
| 5 |
+
"main": "main.js",
|
| 6 |
+
"dependencies": {
|
| 7 |
+
"openai": "^5.18.0"
|
| 8 |
+
},
|
| 9 |
+
"description": "",
|
| 10 |
+
"license": "ISC",
|
| 11 |
+
"scripts": {
|
| 12 |
+
"start": "bun server.ts"
|
| 13 |
+
}
|
| 14 |
+
}
|
server.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { serve } from "bun";
|
| 2 |
+
import { readFile } from "fs/promises";
|
| 3 |
+
import { join } from "path";
|
| 4 |
+
import { GameState } from "./gameState";
|
| 5 |
+
import { AIService, MODEL } from "./aiService";
|
| 6 |
+
|
| 7 |
+
const PORT = parseInt(process.env.PORT || "7860");
|
| 8 |
+
const ROOT_DIR = import.meta.dir;
|
| 9 |
+
const gameState = new GameState(); // Single shared game state
|
| 10 |
+
|
| 11 |
+
const mimeTypes: Record<string, string> = {
|
| 12 |
+
".html": "text/html",
|
| 13 |
+
".js": "application/javascript",
|
| 14 |
+
".css": "text/css",
|
| 15 |
+
".map": "application/octet-stream",
|
| 16 |
+
".ico": "image/x-icon",
|
| 17 |
+
".png": "image/png",
|
| 18 |
+
".jpg": "image/jpeg",
|
| 19 |
+
".jpeg": "image/jpeg",
|
| 20 |
+
".gif": "image/gif",
|
| 21 |
+
".svg": "image/svg+xml",
|
| 22 |
+
".webp": "image/webp",
|
| 23 |
+
".json": "application/json",
|
| 24 |
+
};
|
| 25 |
+
|
| 26 |
+
serve({
|
| 27 |
+
port: PORT,
|
| 28 |
+
async fetch(req) {
|
| 29 |
+
const url = new URL(req.url);
|
| 30 |
+
const pathname = url.pathname;
|
| 31 |
+
|
| 32 |
+
// Simple config endpoint to expose runtime info to the UI
|
| 33 |
+
if (pathname === "/config") {
|
| 34 |
+
return new Response(JSON.stringify({ model: MODEL }), {
|
| 35 |
+
headers: { "Content-Type": "application/json" },
|
| 36 |
+
});
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
// Handle SSE connection
|
| 40 |
+
if (pathname === "/game-stream") {
|
| 41 |
+
const stream = new ReadableStream<string>({
|
| 42 |
+
start(controller) {
|
| 43 |
+
gameState.setController(controller);
|
| 44 |
+
gameState.sendUpdate();
|
| 45 |
+
|
| 46 |
+
// Initial state will be sent by gameState
|
| 47 |
+
|
| 48 |
+
// Start AI agent loop
|
| 49 |
+
const runAI = async () => {
|
| 50 |
+
const state = gameState.getState();
|
| 51 |
+
const action = await AIService.getNextAction(
|
| 52 |
+
state.map,
|
| 53 |
+
state.history
|
| 54 |
+
);
|
| 55 |
+
await gameState.handleAction(action);
|
| 56 |
+
|
| 57 |
+
// Schedule next AI action
|
| 58 |
+
setTimeout(runAI, 1000);
|
| 59 |
+
};
|
| 60 |
+
|
| 61 |
+
runAI();
|
| 62 |
+
},
|
| 63 |
+
cancel() {
|
| 64 |
+
// No need to cleanup since we're using a shared state
|
| 65 |
+
},
|
| 66 |
+
});
|
| 67 |
+
|
| 68 |
+
return new Response(stream, {
|
| 69 |
+
headers: {
|
| 70 |
+
"Content-Type": "text/event-stream",
|
| 71 |
+
"Cache-Control": "no-cache",
|
| 72 |
+
Connection: "keep-alive",
|
| 73 |
+
},
|
| 74 |
+
});
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
// Serve static files
|
| 78 |
+
if (pathname === "/" || pathname === "/index.html") {
|
| 79 |
+
const data = await readFile(join(ROOT_DIR, "index.html"));
|
| 80 |
+
return new Response(data, {
|
| 81 |
+
headers: { "Content-Type": "text/html" },
|
| 82 |
+
});
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
const filePath = join(ROOT_DIR, pathname);
|
| 86 |
+
try {
|
| 87 |
+
const data = await readFile(filePath);
|
| 88 |
+
const ext = filePath.slice(filePath.lastIndexOf("."));
|
| 89 |
+
const mimeType = mimeTypes[ext] || "application/octet-stream";
|
| 90 |
+
|
| 91 |
+
return new Response(data, {
|
| 92 |
+
status: 200,
|
| 93 |
+
headers: {
|
| 94 |
+
"Content-Type": mimeType,
|
| 95 |
+
},
|
| 96 |
+
});
|
| 97 |
+
} catch (error) {
|
| 98 |
+
return new Response("Not Found", { status: 404 });
|
| 99 |
+
}
|
| 100 |
+
},
|
| 101 |
+
});
|
| 102 |
+
|
| 103 |
+
console.log(`Server is running at http://localhost:${PORT}`);
|