mirror of
https://github.com/Azgaar/Fantasy-Map-Generator.git
synced 2025-12-16 17:31:24 +01:00
Merge pull request #2 from LeieSistal/claude/external-tool-sync-011CUoZ9Qiqx4wmfXRdwr8c7
Add external API integration for wiki/web UI synchronization
This commit is contained in:
commit
456322ce0a
10 changed files with 4699 additions and 0 deletions
737
EXTERNAL_API_INTEGRATION.md
Normal file
737
EXTERNAL_API_INTEGRATION.md
Normal file
|
|
@ -0,0 +1,737 @@
|
|||
# External API Integration Guide
|
||||
|
||||
This guide explains how to integrate Fantasy Map Generator (FMG) with external tools like wikis, web UIs, or other applications.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Integration Methods](#integration-methods)
|
||||
- [1. PostMessage Bridge (iframe)](#1-postmessage-bridge-iframe)
|
||||
- [2. REST API Server](#2-rest-api-server)
|
||||
- [3. Direct JavaScript API](#3-direct-javascript-api)
|
||||
- [API Reference](#api-reference)
|
||||
- [Events](#events)
|
||||
- [Examples](#examples)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The Fantasy Map Generator now includes an **External API** that allows external applications to:
|
||||
|
||||
- Control map generation and loading
|
||||
- Access and modify map data (rivers, cultures, states, burgs, etc.)
|
||||
- Listen to real-time map changes
|
||||
- Export maps in various formats
|
||||
- Synchronize state bidirectionally
|
||||
|
||||
### Key Features
|
||||
|
||||
✅ **Event-driven architecture** - Subscribe to map changes
|
||||
✅ **PostMessage bridge** - Embed FMG in iframe and control it
|
||||
✅ **REST API server** - HTTP endpoints for server-side integration
|
||||
✅ **WebSocket support** - Real-time bidirectional communication
|
||||
✅ **Type-safe data access** - Clean API with error handling
|
||||
|
||||
---
|
||||
|
||||
## Integration Methods
|
||||
|
||||
### 1. PostMessage Bridge (iframe)
|
||||
|
||||
**Best for:** Web-based wikis, browser extensions, web apps on different domains
|
||||
|
||||
#### How It Works
|
||||
|
||||
1. Embed FMG in an `<iframe>`
|
||||
2. Send commands via `postMessage()`
|
||||
3. Receive responses and events automatically
|
||||
|
||||
#### Example
|
||||
|
||||
```html
|
||||
<!-- Your Wiki/Web UI -->
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<!-- Embed FMG -->
|
||||
<iframe id="mapFrame" src="https://your-fmg-instance.com/index.html"></iframe>
|
||||
|
||||
<script>
|
||||
const mapFrame = document.getElementById('mapFrame').contentWindow;
|
||||
|
||||
// Wait for iframe to load
|
||||
window.addEventListener('load', () => {
|
||||
// Create a new map
|
||||
mapFrame.postMessage({
|
||||
type: 'CREATE_MAP',
|
||||
payload: { seed: 'my-world' },
|
||||
requestId: 1
|
||||
}, '*');
|
||||
});
|
||||
|
||||
// Listen for responses
|
||||
window.addEventListener('message', (event) => {
|
||||
const { type, payload, requestId } = event.data;
|
||||
|
||||
if (type === 'RESPONSE' && requestId === 1) {
|
||||
console.log('Map created!', payload);
|
||||
}
|
||||
|
||||
if (type === 'EVENT' && payload.event === 'rivers:updated') {
|
||||
console.log('Rivers updated:', payload.data);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
#### Available Commands
|
||||
|
||||
Send these via `postMessage()`:
|
||||
|
||||
```javascript
|
||||
// Map Lifecycle
|
||||
{ type: 'CREATE_MAP', payload: { seed, width, height } }
|
||||
{ type: 'LOAD_MAP', payload: mapData }
|
||||
{ type: 'SAVE_MAP', payload: { format: 'data' | 'blob' } }
|
||||
|
||||
// Data Access
|
||||
{ type: 'GET_STATE' }
|
||||
{ type: 'GET_RIVERS' }
|
||||
{ type: 'GET_CULTURES' }
|
||||
{ type: 'GET_STATES' }
|
||||
{ type: 'GET_BURGS' }
|
||||
|
||||
// Mutations
|
||||
{ type: 'UPDATE_RIVERS', payload: [...] }
|
||||
{ type: 'UPDATE_CULTURES', payload: [...] }
|
||||
{ type: 'UPDATE_STATES', payload: [...] }
|
||||
{ type: 'ADD_BURG', payload: {name, x, y, ...} }
|
||||
|
||||
// Export
|
||||
{ type: 'EXPORT_SVG' }
|
||||
{ type: 'EXPORT_PNG', payload: {width, height} }
|
||||
{ type: 'EXPORT_JSON', payload: {key} }
|
||||
```
|
||||
|
||||
#### Demo
|
||||
|
||||
See `demos/postmessage-demo.html` for a full interactive example.
|
||||
|
||||
---
|
||||
|
||||
### 2. REST API Server
|
||||
|
||||
**Best for:** Server-side integration, microservices, backend systems
|
||||
|
||||
#### Setup
|
||||
|
||||
1. Install dependencies:
|
||||
```bash
|
||||
cd api-server
|
||||
npm install
|
||||
```
|
||||
|
||||
2. Start the server:
|
||||
```bash
|
||||
node server.js
|
||||
# Server runs on http://localhost:3000
|
||||
```
|
||||
|
||||
#### Endpoints
|
||||
|
||||
##### Health Check
|
||||
```
|
||||
GET /api/health
|
||||
```
|
||||
|
||||
##### Create Map
|
||||
```
|
||||
POST /api/maps
|
||||
Body: { seed?: string, width?: number, height?: number }
|
||||
Response: { success: true, mapId: string, pollUrl: string }
|
||||
```
|
||||
|
||||
##### Get Map
|
||||
```
|
||||
GET /api/maps/:id
|
||||
Response: { success: true, map: {...} }
|
||||
```
|
||||
|
||||
##### List Maps
|
||||
```
|
||||
GET /api/maps
|
||||
Response: { success: true, maps: [...] }
|
||||
```
|
||||
|
||||
##### Update Map
|
||||
```
|
||||
PUT /api/maps/:id
|
||||
Body: { data: {...} }
|
||||
```
|
||||
|
||||
##### Delete Map
|
||||
```
|
||||
DELETE /api/maps/:id
|
||||
```
|
||||
|
||||
##### Get/Update Rivers
|
||||
```
|
||||
GET /api/maps/:id/rivers
|
||||
PUT /api/maps/:id/rivers
|
||||
Body: { rivers: [...] }
|
||||
```
|
||||
|
||||
##### Import Rivers from CSV
|
||||
```
|
||||
POST /api/maps/:id/rivers/import
|
||||
Body: multipart/form-data with 'file' field
|
||||
```
|
||||
|
||||
##### Get Cultures/States/Burgs
|
||||
```
|
||||
GET /api/maps/:id/cultures
|
||||
GET /api/maps/:id/states
|
||||
GET /api/maps/:id/burgs
|
||||
```
|
||||
|
||||
##### Add Burg
|
||||
```
|
||||
POST /api/maps/:id/burgs
|
||||
Body: { name, x, y, cell, population, type }
|
||||
```
|
||||
|
||||
#### Example Usage
|
||||
|
||||
```javascript
|
||||
// Create a new map
|
||||
const response = await fetch('http://localhost:3000/api/maps', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ seed: 'my-world' })
|
||||
});
|
||||
|
||||
const { mapId } = await response.json();
|
||||
|
||||
// Get rivers
|
||||
const riversResponse = await fetch(`http://localhost:3000/api/maps/${mapId}/rivers`);
|
||||
const { rivers } = await riversResponse.json();
|
||||
|
||||
console.log('Rivers:', rivers);
|
||||
|
||||
// Update rivers
|
||||
await fetch(`http://localhost:3000/api/maps/${mapId}/rivers`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
rivers: [
|
||||
{ i: 1, name: 'Mystic River', type: 'River', discharge: 100 }
|
||||
]
|
||||
})
|
||||
});
|
||||
```
|
||||
|
||||
#### WebSocket Events
|
||||
|
||||
Connect to `ws://localhost:3000` for real-time updates:
|
||||
|
||||
```javascript
|
||||
import { io } from 'socket.io-client';
|
||||
|
||||
const socket = io('http://localhost:3000');
|
||||
|
||||
socket.on('connect', () => {
|
||||
console.log('Connected!');
|
||||
});
|
||||
|
||||
// Listen to events
|
||||
socket.on('map:created', (data) => {
|
||||
console.log('Map created:', data);
|
||||
});
|
||||
|
||||
socket.on('rivers:updated', (data) => {
|
||||
console.log('Rivers updated:', data);
|
||||
});
|
||||
|
||||
// Send events
|
||||
socket.emit('map:update', {
|
||||
mapId: 'map_123',
|
||||
updates: { /* ... */ }
|
||||
});
|
||||
```
|
||||
|
||||
#### Demo
|
||||
|
||||
See `demos/rest-api-demo.html` and `demos/websocket-demo.html` for interactive examples.
|
||||
|
||||
---
|
||||
|
||||
### 3. Direct JavaScript API
|
||||
|
||||
**Best for:** Same-origin applications, browser extensions with host permissions
|
||||
|
||||
#### Access the API
|
||||
|
||||
Once FMG is loaded, access the global API:
|
||||
|
||||
```javascript
|
||||
const api = window.FMG_API;
|
||||
```
|
||||
|
||||
#### Methods
|
||||
|
||||
##### Map Lifecycle
|
||||
|
||||
```javascript
|
||||
// Create new map
|
||||
const result = await api.createMap({ seed: 'my-seed' });
|
||||
if (result.success) {
|
||||
console.log('Map created:', result.state);
|
||||
}
|
||||
|
||||
// Load map from file or data
|
||||
const file = document.getElementById('fileInput').files[0];
|
||||
await api.loadMap(file);
|
||||
|
||||
// Or load from string
|
||||
await api.loadMap(mapDataString);
|
||||
|
||||
// Save map
|
||||
const saved = await api.saveMap('data'); // or 'blob'
|
||||
console.log('Map data:', saved.data);
|
||||
```
|
||||
|
||||
##### Data Access
|
||||
|
||||
```javascript
|
||||
// Get complete state
|
||||
const state = api.getMapState();
|
||||
console.log('Current state:', state);
|
||||
|
||||
// Get specific data
|
||||
const rivers = api.getRivers();
|
||||
const cultures = api.getCultures();
|
||||
const states = api.getStates();
|
||||
const burgs = api.getBurgs();
|
||||
const religions = api.getReligions();
|
||||
const markers = api.getMarkers();
|
||||
const grid = api.getGrid();
|
||||
|
||||
// Get any data by key
|
||||
const data = api.getData('rivers');
|
||||
```
|
||||
|
||||
##### Mutations
|
||||
|
||||
```javascript
|
||||
// Update rivers
|
||||
api.updateRivers([
|
||||
{ i: 1, name: 'New River', type: 'River', discharge: 50 },
|
||||
// ... more rivers
|
||||
]);
|
||||
|
||||
// Update cultures
|
||||
api.updateCultures([...]);
|
||||
|
||||
// Update states
|
||||
api.updateStates([...]);
|
||||
|
||||
// Update burgs
|
||||
api.updateBurgs([...]);
|
||||
|
||||
// Add a new burg
|
||||
const result = api.addBurg({
|
||||
name: 'New City',
|
||||
x: 500,
|
||||
y: 400,
|
||||
cell: 1234,
|
||||
population: 10,
|
||||
type: 'city',
|
||||
culture: 1,
|
||||
state: 1
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
console.log('New burg ID:', result.id);
|
||||
}
|
||||
```
|
||||
|
||||
##### Export
|
||||
|
||||
```javascript
|
||||
// Export SVG
|
||||
const svg = api.exportSVG();
|
||||
console.log('SVG:', svg);
|
||||
|
||||
// Export PNG (returns blob)
|
||||
const pngBlob = await api.exportPNG(2048, 2048);
|
||||
|
||||
// Export JSON
|
||||
const json = api.exportJSON(); // All data
|
||||
const riversJson = api.exportJSON('rivers'); // Specific key
|
||||
```
|
||||
|
||||
##### Events
|
||||
|
||||
```javascript
|
||||
// Subscribe to events
|
||||
const unsubscribe = api.on('map:changed', (state) => {
|
||||
console.log('Map changed:', state);
|
||||
});
|
||||
|
||||
// Unsubscribe
|
||||
unsubscribe();
|
||||
|
||||
// Or manually
|
||||
api.off('map:changed', callback);
|
||||
|
||||
// Subscribe once
|
||||
api.once('map:created', (state) => {
|
||||
console.log('Map created:', state);
|
||||
});
|
||||
|
||||
// Emit custom events
|
||||
api.emit('custom:event', { myData: 'test' });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Reference
|
||||
|
||||
### Data Structures
|
||||
|
||||
#### Map State
|
||||
|
||||
```typescript
|
||||
interface MapState {
|
||||
seed: string | null;
|
||||
mapId: string | null;
|
||||
timestamp: number;
|
||||
pack: {
|
||||
cultures: Culture[];
|
||||
states: State[];
|
||||
burgs: Burg[];
|
||||
rivers: River[];
|
||||
religions: Religion[];
|
||||
provinces: Province[];
|
||||
markers: Marker[];
|
||||
} | null;
|
||||
grid: {
|
||||
spacing: number;
|
||||
cellsX: number;
|
||||
cellsY: number;
|
||||
features: Feature[];
|
||||
} | null;
|
||||
options: object | null;
|
||||
}
|
||||
```
|
||||
|
||||
#### River
|
||||
|
||||
```typescript
|
||||
interface River {
|
||||
i: number; // ID
|
||||
name: string; // Name
|
||||
type: string; // 'River', 'Lake', etc.
|
||||
discharge: number; // m³/s
|
||||
length: number; // Distance
|
||||
width: number; // Visual width
|
||||
basin: number; // Parent river ID
|
||||
// ... more properties
|
||||
}
|
||||
```
|
||||
|
||||
#### Culture
|
||||
|
||||
```typescript
|
||||
interface Culture {
|
||||
i: number; // ID
|
||||
name: string; // Name
|
||||
base: number; // Name base ID
|
||||
shield: string; // Shield type
|
||||
expansionism: number; // Expansion rate
|
||||
color: string; // Color
|
||||
// ... more properties
|
||||
}
|
||||
```
|
||||
|
||||
#### State
|
||||
|
||||
```typescript
|
||||
interface State {
|
||||
i: number; // ID
|
||||
name: string; // Name
|
||||
color: string; // Color
|
||||
expansionism: number; // Expansion rate
|
||||
capital: number; // Capital burg ID
|
||||
culture: number; // Culture ID
|
||||
// ... more properties
|
||||
}
|
||||
```
|
||||
|
||||
#### Burg
|
||||
|
||||
```typescript
|
||||
interface Burg {
|
||||
i: number; // ID
|
||||
name: string; // Name
|
||||
x: number; // X coordinate
|
||||
y: number; // Y coordinate
|
||||
cell: number; // Cell ID
|
||||
population: number; // Population
|
||||
type: string; // 'city', 'town', etc.
|
||||
culture: number; // Culture ID
|
||||
state: number; // State ID
|
||||
// ... more properties
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Events
|
||||
|
||||
### Available Events
|
||||
|
||||
#### Map Events
|
||||
- `map:created` - New map created
|
||||
- `map:loaded` - Map loaded from file
|
||||
- `map:changed` - Map modified (throttled)
|
||||
|
||||
#### Data Events
|
||||
- `rivers:updated` - Rivers data updated
|
||||
- `cultures:updated` - Cultures data updated
|
||||
- `states:updated` - States data updated
|
||||
- `burgs:updated` - Burgs data updated
|
||||
- `burg:added` - New burg added
|
||||
|
||||
### Event Payload
|
||||
|
||||
All events include relevant data:
|
||||
|
||||
```javascript
|
||||
api.on('rivers:updated', (rivers) => {
|
||||
console.log('Updated rivers:', rivers);
|
||||
});
|
||||
|
||||
api.on('map:created', (state) => {
|
||||
console.log('Map state:', state);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Examples
|
||||
|
||||
### Example 1: Wiki Integration
|
||||
|
||||
Embed FMG in a wiki page and sync data:
|
||||
|
||||
```html
|
||||
<div id="wiki-map-section">
|
||||
<iframe id="fmg" src="/fmg/index.html" width="100%" height="600"></iframe>
|
||||
|
||||
<script>
|
||||
// Store map state in wiki
|
||||
window.addEventListener('message', (event) => {
|
||||
if (event.data.type === 'EVENT' && event.data.payload.event === 'map:changed') {
|
||||
// Save to wiki database
|
||||
saveToWiki(event.data.payload.data);
|
||||
}
|
||||
});
|
||||
|
||||
// Load stored map on page load
|
||||
fetch('/wiki/api/map-data')
|
||||
.then(r => r.json())
|
||||
.then(mapData => {
|
||||
document.getElementById('fmg').contentWindow.postMessage({
|
||||
type: 'LOAD_MAP',
|
||||
payload: mapData
|
||||
}, '*');
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Example 2: Custom River Editor
|
||||
|
||||
Create a custom UI to edit rivers:
|
||||
|
||||
```javascript
|
||||
// Get current rivers
|
||||
const rivers = await window.FMG_API.getRivers();
|
||||
|
||||
// Show in custom UI
|
||||
renderRiversTable(rivers);
|
||||
|
||||
// Update a river
|
||||
rivers[0].name = 'Renamed River';
|
||||
rivers[0].discharge = 150;
|
||||
|
||||
// Save changes
|
||||
window.FMG_API.updateRivers(rivers);
|
||||
|
||||
// Listen for updates
|
||||
window.FMG_API.on('rivers:updated', (updatedRivers) => {
|
||||
renderRiversTable(updatedRivers);
|
||||
});
|
||||
```
|
||||
|
||||
### Example 3: Batch Operations via REST API
|
||||
|
||||
```javascript
|
||||
// Create multiple maps
|
||||
async function createMapBatch(seeds) {
|
||||
const maps = [];
|
||||
|
||||
for (const seed of seeds) {
|
||||
const res = await fetch('http://localhost:3000/api/maps', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ seed })
|
||||
});
|
||||
|
||||
const { mapId } = await res.json();
|
||||
maps.push(mapId);
|
||||
}
|
||||
|
||||
return maps;
|
||||
}
|
||||
|
||||
// Use it
|
||||
const mapIds = await createMapBatch(['world1', 'world2', 'world3']);
|
||||
console.log('Created maps:', mapIds);
|
||||
```
|
||||
|
||||
### Example 4: Real-time Collaboration
|
||||
|
||||
Multiple users editing the same map:
|
||||
|
||||
```javascript
|
||||
// User A's browser
|
||||
const socket = io('http://localhost:3000');
|
||||
|
||||
// Listen for changes from other users
|
||||
socket.on('rivers:updated', (data) => {
|
||||
// Update local view
|
||||
window.FMG_API.updateRivers(data.rivers);
|
||||
});
|
||||
|
||||
// When local user makes changes
|
||||
window.FMG_API.on('rivers:updated', (rivers) => {
|
||||
// Broadcast to other users
|
||||
socket.emit('rivers:updated', {
|
||||
mapId: currentMapId,
|
||||
rivers
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### PostMessage not working
|
||||
|
||||
**Problem:** Messages not received in iframe
|
||||
|
||||
**Solution:**
|
||||
1. Ensure iframe has loaded: `iframe.addEventListener('load', ...)`
|
||||
2. Wait 1-2 seconds after load for API to initialize
|
||||
3. Check origin in postMessage: use `'*'` or specific origin
|
||||
4. Open browser console to check for errors
|
||||
|
||||
### CORS errors with REST API
|
||||
|
||||
**Problem:** `Access-Control-Allow-Origin` errors
|
||||
|
||||
**Solution:**
|
||||
- REST API server has CORS enabled by default
|
||||
- If using custom server, add CORS middleware
|
||||
- For production, configure specific origins
|
||||
|
||||
### API not available
|
||||
|
||||
**Problem:** `window.FMG_API is undefined`
|
||||
|
||||
**Solution:**
|
||||
1. Ensure `external-api.js` is loaded in index.html
|
||||
2. Wait for DOMContentLoaded event
|
||||
3. Check browser console for script errors
|
||||
|
||||
### Events not firing
|
||||
|
||||
**Problem:** Event listeners not receiving events
|
||||
|
||||
**Solution:**
|
||||
1. Subscribe to events BEFORE making changes
|
||||
2. Check event names (case-sensitive)
|
||||
3. Ensure change detection is enabled (automatic)
|
||||
|
||||
### WebSocket disconnects
|
||||
|
||||
**Problem:** Socket disconnects unexpectedly
|
||||
|
||||
**Solution:**
|
||||
1. Check server is running
|
||||
2. Implement reconnection logic
|
||||
3. Handle `disconnect` event and reconnect
|
||||
|
||||
---
|
||||
|
||||
## Advanced Configuration
|
||||
|
||||
### Disable PostMessage Bridge
|
||||
|
||||
If you don't need iframe integration:
|
||||
|
||||
```javascript
|
||||
// In your fork, remove from external-api.js:
|
||||
// PostMessageBridge.enable();
|
||||
```
|
||||
|
||||
### Custom Event Throttling
|
||||
|
||||
Adjust change detection throttle:
|
||||
|
||||
```javascript
|
||||
// In external-api.js, modify debounce time:
|
||||
const observer = new MutationObserver(debounce(() => {
|
||||
// ...
|
||||
}, 500)); // Change from 500ms to your preference
|
||||
```
|
||||
|
||||
### Enable Debug Logging
|
||||
|
||||
```javascript
|
||||
// Add to external-api.js for verbose logging:
|
||||
const DEBUG = true;
|
||||
|
||||
if (DEBUG) {
|
||||
eventEmitter.on('*', (event, data) => {
|
||||
console.log('[FMG API]', event, data);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Support
|
||||
|
||||
For issues, questions, or feature requests:
|
||||
|
||||
- GitHub Issues: https://github.com/Azgaar/Fantasy-Map-Generator/issues
|
||||
- Wiki: https://github.com/Azgaar/Fantasy-Map-Generator/wiki
|
||||
- Discord: [Join community]
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT License - same as Fantasy Map Generator
|
||||
|
||||
---
|
||||
|
||||
**Happy Mapping! 🗺️**
|
||||
233
api-server/README.md
Normal file
233
api-server/README.md
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
# Fantasy Map Generator - REST API Server
|
||||
|
||||
A Node.js/Express server that provides REST API endpoints and WebSocket support for the Fantasy Map Generator.
|
||||
|
||||
## Features
|
||||
|
||||
- ✅ RESTful API with full CRUD operations
|
||||
- ✅ WebSocket support for real-time updates
|
||||
- ✅ Map storage and management
|
||||
- ✅ CSV import for rivers and other data
|
||||
- ✅ Export support (SVG, PNG, JSON)
|
||||
- ✅ CORS enabled for cross-origin requests
|
||||
- ✅ File upload support
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### Run Server
|
||||
|
||||
```bash
|
||||
# Production
|
||||
npm start
|
||||
|
||||
# Development (with auto-reload)
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Server will start on `http://localhost:3000`
|
||||
|
||||
## API Documentation
|
||||
|
||||
Visit `http://localhost:3000` after starting the server to see the full API documentation.
|
||||
|
||||
### Quick Examples
|
||||
|
||||
#### Create a Map
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/maps \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"seed": "my-world"}'
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"mapId": "map_1234567890_abc123",
|
||||
"message": "Map creation initiated",
|
||||
"pollUrl": "/api/maps/map_1234567890_abc123"
|
||||
}
|
||||
```
|
||||
|
||||
#### Get Map Data
|
||||
|
||||
```bash
|
||||
curl http://localhost:3000/api/maps/map_1234567890_abc123
|
||||
```
|
||||
|
||||
#### Update Rivers
|
||||
|
||||
```bash
|
||||
curl -X PUT http://localhost:3000/api/maps/map_1234567890_abc123/rivers \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"rivers": [{"i":1,"name":"Mystic River","type":"River"}]}'
|
||||
```
|
||||
|
||||
#### Import Rivers from CSV
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/maps/map_1234567890_abc123/rivers/import \
|
||||
-F "file=@rivers.csv"
|
||||
```
|
||||
|
||||
## WebSocket
|
||||
|
||||
### Connect
|
||||
|
||||
```javascript
|
||||
import { io } from 'socket.io-client';
|
||||
|
||||
const socket = io('http://localhost:3000');
|
||||
|
||||
socket.on('connect', () => {
|
||||
console.log('Connected!');
|
||||
});
|
||||
```
|
||||
|
||||
### Events
|
||||
|
||||
**Server Events (listen):**
|
||||
- `map:creating` - Map creation started
|
||||
- `map:created` - Map creation completed
|
||||
- `map:updated` - Map data updated
|
||||
- `map:deleted` - Map deleted
|
||||
- `rivers:updated` - Rivers updated
|
||||
- `rivers:imported` - Rivers imported from CSV
|
||||
- `burg:added` - New burg added
|
||||
|
||||
**Client Events (emit):**
|
||||
- `map:update` - Update map data
|
||||
- `map:created` - Notify map creation complete
|
||||
- `export:completed` - Export completed
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```bash
|
||||
# Port (default: 3000)
|
||||
PORT=3000
|
||||
```
|
||||
|
||||
### Storage
|
||||
|
||||
By default, maps are stored in memory. For production, replace the `Map` with a database:
|
||||
|
||||
```javascript
|
||||
// Replace this in server.js
|
||||
const maps = new Map();
|
||||
|
||||
// With this (example with MongoDB)
|
||||
const maps = await db.collection('maps');
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
### Docker
|
||||
|
||||
```dockerfile
|
||||
FROM node:18
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
EXPOSE 3000
|
||||
CMD ["npm", "start"]
|
||||
```
|
||||
|
||||
Build and run:
|
||||
```bash
|
||||
docker build -t fmg-api .
|
||||
docker run -p 3000:3000 fmg-api
|
||||
```
|
||||
|
||||
### Production Considerations
|
||||
|
||||
1. **Database**: Replace in-memory storage with MongoDB, PostgreSQL, etc.
|
||||
2. **Authentication**: Add JWT or OAuth for protected endpoints
|
||||
3. **Rate Limiting**: Add express-rate-limit
|
||||
4. **Validation**: Add input validation with joi or express-validator
|
||||
5. **Logging**: Add morgan or winston
|
||||
6. **Monitoring**: Add health checks and metrics
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Client (Browser/Wiki)
|
||||
↓
|
||||
REST API / WebSocket
|
||||
↓
|
||||
Server (Express + Socket.IO)
|
||||
↓
|
||||
Storage (In-memory Map / Database)
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **express**: Web framework
|
||||
- **socket.io**: WebSocket server
|
||||
- **cors**: Cross-origin resource sharing
|
||||
- **multer**: File upload handling
|
||||
- **body-parser**: Request body parsing
|
||||
|
||||
## Development
|
||||
|
||||
### Add New Endpoint
|
||||
|
||||
```javascript
|
||||
// In server.js
|
||||
app.get('/api/maps/:id/custom', async (req, res) => {
|
||||
try {
|
||||
const {id} = req.params;
|
||||
// Your logic here
|
||||
res.json({success: true, data: {}});
|
||||
} catch (error) {
|
||||
res.status(500).json({success: false, error: error.message});
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Add WebSocket Event
|
||||
|
||||
```javascript
|
||||
// In server.js
|
||||
io.on('connection', (socket) => {
|
||||
socket.on('custom:event', (data) => {
|
||||
// Handle event
|
||||
io.emit('custom:response', data);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Manual Testing
|
||||
|
||||
Use the demo pages:
|
||||
- REST API: `http://localhost:3000/demos/rest-api-demo.html`
|
||||
- WebSocket: `http://localhost:3000/demos/websocket-demo.html`
|
||||
|
||||
### Automated Testing
|
||||
|
||||
```bash
|
||||
# Install test dependencies
|
||||
npm install --save-dev mocha chai supertest
|
||||
|
||||
# Run tests
|
||||
npm test
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT - Same as Fantasy Map Generator
|
||||
|
||||
## Support
|
||||
|
||||
For issues and questions, see the main repository.
|
||||
260
api-server/client.js
Normal file
260
api-server/client.js
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
/**
|
||||
* Fantasy Map Generator - JavaScript Client Library
|
||||
*
|
||||
* A simple client library for interacting with the FMG REST API
|
||||
*
|
||||
* Usage:
|
||||
* const client = new FMGClient('http://localhost:3000/api');
|
||||
* const map = await client.createMap({ seed: 'my-world' });
|
||||
*/
|
||||
|
||||
class FMGClient {
|
||||
constructor(baseUrl = 'http://localhost:3000/api') {
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make API request
|
||||
* @private
|
||||
*/
|
||||
async _request(endpoint, options = {}) {
|
||||
const url = `${this.baseUrl}${endpoint}`;
|
||||
const config = {
|
||||
method: options.method || 'GET',
|
||||
headers: {
|
||||
...options.headers
|
||||
}
|
||||
};
|
||||
|
||||
if (options.body) {
|
||||
if (options.body instanceof FormData) {
|
||||
config.body = options.body;
|
||||
} else {
|
||||
config.headers['Content-Type'] = 'application/json';
|
||||
config.body = JSON.stringify(options.body);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, config);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
throw new Error(`API request failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HEALTH
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Check API health
|
||||
* @returns {Promise<Object>} Health status
|
||||
*/
|
||||
async health() {
|
||||
return this._request('/health');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MAP OPERATIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create a new map
|
||||
* @param {Object} options - Map options
|
||||
* @param {string} options.seed - Map seed
|
||||
* @param {number} options.width - Map width
|
||||
* @param {number} options.height - Map height
|
||||
* @returns {Promise<Object>} Created map info
|
||||
*/
|
||||
async createMap(options = {}) {
|
||||
return this._request('/maps', {
|
||||
method: 'POST',
|
||||
body: options
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get map by ID
|
||||
* @param {string} mapId - Map ID
|
||||
* @returns {Promise<Object>} Map data
|
||||
*/
|
||||
async getMap(mapId) {
|
||||
return this._request(`/maps/${mapId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all maps
|
||||
* @returns {Promise<Object>} Map list
|
||||
*/
|
||||
async listMaps() {
|
||||
return this._request('/maps');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update map
|
||||
* @param {string} mapId - Map ID
|
||||
* @param {Object} data - Map data
|
||||
* @returns {Promise<Object>} Updated map
|
||||
*/
|
||||
async updateMap(mapId, data) {
|
||||
return this._request(`/maps/${mapId}`, {
|
||||
method: 'PUT',
|
||||
body: { data }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete map
|
||||
* @param {string} mapId - Map ID
|
||||
* @returns {Promise<Object>} Deletion result
|
||||
*/
|
||||
async deleteMap(mapId) {
|
||||
return this._request(`/maps/${mapId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load map from file
|
||||
* @param {string} mapId - Map ID
|
||||
* @param {File} file - Map file
|
||||
* @returns {Promise<Object>} Load result
|
||||
*/
|
||||
async loadMap(mapId, file) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
return this._request(`/maps/${mapId}/load`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// RIVERS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get rivers
|
||||
* @param {string} mapId - Map ID
|
||||
* @returns {Promise<Array>} Rivers array
|
||||
*/
|
||||
async getRivers(mapId) {
|
||||
const result = await this._request(`/maps/${mapId}/rivers`);
|
||||
return result.rivers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update rivers
|
||||
* @param {string} mapId - Map ID
|
||||
* @param {Array} rivers - Rivers array
|
||||
* @returns {Promise<Object>} Update result
|
||||
*/
|
||||
async updateRivers(mapId, rivers) {
|
||||
return this._request(`/maps/${mapId}/rivers`, {
|
||||
method: 'PUT',
|
||||
body: { rivers }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Import rivers from CSV
|
||||
* @param {string} mapId - Map ID
|
||||
* @param {File} csvFile - CSV file
|
||||
* @returns {Promise<Object>} Import result
|
||||
*/
|
||||
async importRiversCSV(mapId, csvFile) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', csvFile);
|
||||
|
||||
return this._request(`/maps/${mapId}/rivers/import`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CULTURES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get cultures
|
||||
* @param {string} mapId - Map ID
|
||||
* @returns {Promise<Array>} Cultures array
|
||||
*/
|
||||
async getCultures(mapId) {
|
||||
const result = await this._request(`/maps/${mapId}/cultures`);
|
||||
return result.cultures;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// STATES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get states
|
||||
* @param {string} mapId - Map ID
|
||||
* @returns {Promise<Array>} States array
|
||||
*/
|
||||
async getStates(mapId) {
|
||||
const result = await this._request(`/maps/${mapId}/states`);
|
||||
return result.states;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// BURGS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get burgs (cities/towns)
|
||||
* @param {string} mapId - Map ID
|
||||
* @returns {Promise<Array>} Burgs array
|
||||
*/
|
||||
async getBurgs(mapId) {
|
||||
const result = await this._request(`/maps/${mapId}/burgs`);
|
||||
return result.burgs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add new burg
|
||||
* @param {string} mapId - Map ID
|
||||
* @param {Object} burgData - Burg data
|
||||
* @returns {Promise<Object>} Created burg
|
||||
*/
|
||||
async addBurg(mapId, burgData) {
|
||||
return this._request(`/maps/${mapId}/burgs`, {
|
||||
method: 'POST',
|
||||
body: burgData
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// EXPORT
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Export map in specified format
|
||||
* @param {string} mapId - Map ID
|
||||
* @param {string} format - Export format (svg, png, json, data)
|
||||
* @returns {Promise<Object>} Export result
|
||||
*/
|
||||
async exportMap(mapId, format) {
|
||||
return this._request(`/maps/${mapId}/export/${format}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Export for Node.js and browser
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = FMGClient;
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.FMGClient = FMGClient;
|
||||
}
|
||||
31
api-server/package.json
Normal file
31
api-server/package.json
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"name": "fantasy-map-generator-api",
|
||||
"version": "1.0.0",
|
||||
"description": "REST API server for Fantasy Map Generator with WebSocket support",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "nodemon server.js",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [
|
||||
"fantasy",
|
||||
"map",
|
||||
"generator",
|
||||
"api",
|
||||
"rest",
|
||||
"websocket"
|
||||
],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"cors": "^2.8.5",
|
||||
"body-parser": "^1.20.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"socket.io": "^4.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.1"
|
||||
}
|
||||
}
|
||||
830
api-server/server.js
Normal file
830
api-server/server.js
Normal file
|
|
@ -0,0 +1,830 @@
|
|||
/**
|
||||
* Fantasy Map Generator - REST API Server
|
||||
* Provides HTTP endpoints for external tools to control the map generator
|
||||
*
|
||||
* Usage:
|
||||
* npm install
|
||||
* node server.js
|
||||
*
|
||||
* Then access via http://localhost:3000
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const bodyParser = require('body-parser');
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const { createServer } = require('http');
|
||||
const { Server: SocketIOServer } = require('socket.io');
|
||||
|
||||
// Initialize Express
|
||||
const app = express();
|
||||
const httpServer = createServer(app);
|
||||
const io = new SocketIOServer(httpServer, {
|
||||
cors: {
|
||||
origin: '*',
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH']
|
||||
}
|
||||
});
|
||||
|
||||
// Middleware
|
||||
app.use(cors());
|
||||
app.use(bodyParser.json({limit: '50mb'}));
|
||||
app.use(bodyParser.urlencoded({extended: true, limit: '50mb'}));
|
||||
|
||||
// Serve static files from FMG directory
|
||||
const FMG_ROOT = path.join(__dirname, '..');
|
||||
app.use('/fmg', express.static(FMG_ROOT));
|
||||
|
||||
// File upload configuration
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: {fileSize: 50 * 1024 * 1024} // 50MB
|
||||
});
|
||||
|
||||
// In-memory storage for maps (use database in production)
|
||||
const maps = new Map();
|
||||
|
||||
// ============================================================================
|
||||
// API ENDPOINTS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* GET /api/health
|
||||
* Health check endpoint
|
||||
*/
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
version: '1.0.0'
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/maps
|
||||
* Create a new map
|
||||
* Body: { seed?: string, width?: number, height?: number, ...options }
|
||||
*/
|
||||
app.post('/api/maps', async (req, res) => {
|
||||
try {
|
||||
const options = req.body;
|
||||
const mapId = generateMapId();
|
||||
|
||||
// Store map creation request
|
||||
maps.set(mapId, {
|
||||
id: mapId,
|
||||
status: 'pending',
|
||||
options,
|
||||
createdAt: new Date().toISOString()
|
||||
});
|
||||
|
||||
// Notify via WebSocket that map is being created
|
||||
io.emit('map:creating', {mapId, options});
|
||||
|
||||
res.status(202).json({
|
||||
success: true,
|
||||
mapId,
|
||||
message: 'Map creation initiated. Use WebSocket or polling to get updates.',
|
||||
pollUrl: `/api/maps/${mapId}`,
|
||||
websocketEvent: 'map:created'
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/maps/:id
|
||||
* Get map by ID
|
||||
*/
|
||||
app.get('/api/maps/:id', async (req, res) => {
|
||||
try {
|
||||
const {id} = req.params;
|
||||
const map = maps.get(id);
|
||||
|
||||
if (!map) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Map not found'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
map
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/maps
|
||||
* List all maps
|
||||
*/
|
||||
app.get('/api/maps', async (req, res) => {
|
||||
try {
|
||||
const allMaps = Array.from(maps.values());
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
count: allMaps.length,
|
||||
maps: allMaps
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/maps/:id
|
||||
* Update map data
|
||||
* Body: { data: mapData }
|
||||
*/
|
||||
app.put('/api/maps/:id', async (req, res) => {
|
||||
try {
|
||||
const {id} = req.params;
|
||||
const {data} = req.body;
|
||||
|
||||
if (!maps.has(id)) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Map not found'
|
||||
});
|
||||
}
|
||||
|
||||
const map = maps.get(id);
|
||||
map.data = data;
|
||||
map.updatedAt = new Date().toISOString();
|
||||
maps.set(id, map);
|
||||
|
||||
// Notify via WebSocket
|
||||
io.emit('map:updated', {mapId: id, data});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
map
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/maps/:id
|
||||
* Delete a map
|
||||
*/
|
||||
app.delete('/api/maps/:id', async (req, res) => {
|
||||
try {
|
||||
const {id} = req.params;
|
||||
|
||||
if (!maps.has(id)) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Map not found'
|
||||
});
|
||||
}
|
||||
|
||||
maps.delete(id);
|
||||
|
||||
// Notify via WebSocket
|
||||
io.emit('map:deleted', {mapId: id});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Map deleted'
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/maps/:id/load
|
||||
* Load map from file
|
||||
* Body: multipart/form-data with 'file' field
|
||||
*/
|
||||
app.post('/api/maps/:id/load', upload.single('file'), async (req, res) => {
|
||||
try {
|
||||
const {id} = req.params;
|
||||
|
||||
if (!req.file) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'No file provided'
|
||||
});
|
||||
}
|
||||
|
||||
const mapData = req.file.buffer.toString('utf-8');
|
||||
|
||||
if (!maps.has(id)) {
|
||||
maps.set(id, {
|
||||
id,
|
||||
createdAt: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
const map = maps.get(id);
|
||||
map.data = mapData;
|
||||
map.updatedAt = new Date().toISOString();
|
||||
maps.set(id, map);
|
||||
|
||||
// Notify via WebSocket
|
||||
io.emit('map:loaded', {mapId: id});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
map
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/maps/:id/export/:format
|
||||
* Export map in specified format
|
||||
* Formats: svg, png, json, data
|
||||
*/
|
||||
app.get('/api/maps/:id/export/:format', async (req, res) => {
|
||||
try {
|
||||
const {id, format} = req.params;
|
||||
const map = maps.get(id);
|
||||
|
||||
if (!map) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Map not found'
|
||||
});
|
||||
}
|
||||
|
||||
// Request export via WebSocket and wait for response
|
||||
io.emit('export:request', {mapId: id, format});
|
||||
|
||||
// In a real implementation, you'd wait for the export to complete
|
||||
// For now, return a pending response
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Export requested. Listen to WebSocket event for completion.',
|
||||
websocketEvent: `export:completed:${id}`
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/maps/:id/rivers
|
||||
* Get rivers data
|
||||
*/
|
||||
app.get('/api/maps/:id/rivers', async (req, res) => {
|
||||
try {
|
||||
const {id} = req.params;
|
||||
const map = maps.get(id);
|
||||
|
||||
if (!map || !map.data) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Map not found or no data available'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
rivers: map.data.pack?.rivers || []
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/maps/:id/rivers
|
||||
* Update rivers data
|
||||
* Body: { rivers: [...] }
|
||||
*/
|
||||
app.put('/api/maps/:id/rivers', async (req, res) => {
|
||||
try {
|
||||
const {id} = req.params;
|
||||
const {rivers} = req.body;
|
||||
const map = maps.get(id);
|
||||
|
||||
if (!map) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Map not found'
|
||||
});
|
||||
}
|
||||
|
||||
if (!map.data) {
|
||||
map.data = {pack: {}};
|
||||
}
|
||||
if (!map.data.pack) {
|
||||
map.data.pack = {};
|
||||
}
|
||||
|
||||
map.data.pack.rivers = rivers;
|
||||
map.updatedAt = new Date().toISOString();
|
||||
maps.set(id, map);
|
||||
|
||||
// Notify via WebSocket
|
||||
io.emit('rivers:updated', {mapId: id, rivers});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
rivers
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/maps/:id/rivers/import
|
||||
* Import rivers from CSV
|
||||
* Body: multipart/form-data with 'file' field
|
||||
*/
|
||||
app.post('/api/maps/:id/rivers/import', upload.single('file'), async (req, res) => {
|
||||
try {
|
||||
const {id} = req.params;
|
||||
|
||||
if (!req.file) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'No file provided'
|
||||
});
|
||||
}
|
||||
|
||||
const csvData = req.file.buffer.toString('utf-8');
|
||||
|
||||
// Parse CSV (basic implementation)
|
||||
const rows = csvData.split('\n');
|
||||
const headers = rows[0].split(',').map(h => h.trim().toLowerCase());
|
||||
const rivers = [];
|
||||
|
||||
for (let i = 1; i < rows.length; i++) {
|
||||
if (!rows[i].trim()) continue;
|
||||
|
||||
const values = rows[i].split(',');
|
||||
const river = {};
|
||||
|
||||
headers.forEach((header, index) => {
|
||||
river[header] = values[index]?.trim();
|
||||
});
|
||||
|
||||
rivers.push(river);
|
||||
}
|
||||
|
||||
// Update map
|
||||
const map = maps.get(id);
|
||||
if (!map) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Map not found'
|
||||
});
|
||||
}
|
||||
|
||||
if (!map.data) map.data = {pack: {}};
|
||||
if (!map.data.pack) map.data.pack = {};
|
||||
|
||||
map.data.pack.rivers = rivers;
|
||||
map.updatedAt = new Date().toISOString();
|
||||
maps.set(id, map);
|
||||
|
||||
// Notify via WebSocket
|
||||
io.emit('rivers:imported', {mapId: id, rivers});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
count: rivers.length,
|
||||
rivers
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/maps/:id/cultures
|
||||
* Get cultures data
|
||||
*/
|
||||
app.get('/api/maps/:id/cultures', async (req, res) => {
|
||||
try {
|
||||
const {id} = req.params;
|
||||
const map = maps.get(id);
|
||||
|
||||
if (!map || !map.data) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Map not found or no data available'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
cultures: map.data.pack?.cultures || []
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/maps/:id/states
|
||||
* Get states data
|
||||
*/
|
||||
app.get('/api/maps/:id/states', async (req, res) => {
|
||||
try {
|
||||
const {id} = req.params;
|
||||
const map = maps.get(id);
|
||||
|
||||
if (!map || !map.data) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Map not found or no data available'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
states: map.data.pack?.states || []
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/maps/:id/burgs
|
||||
* Get burgs (cities/towns) data
|
||||
*/
|
||||
app.get('/api/maps/:id/burgs', async (req, res) => {
|
||||
try {
|
||||
const {id} = req.params;
|
||||
const map = maps.get(id);
|
||||
|
||||
if (!map || !map.data) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Map not found or no data available'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
burgs: map.data.pack?.burgs || []
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/maps/:id/burgs
|
||||
* Add a new burg
|
||||
* Body: { name, x, y, cell, population, type, ... }
|
||||
*/
|
||||
app.post('/api/maps/:id/burgs', async (req, res) => {
|
||||
try {
|
||||
const {id} = req.params;
|
||||
const burgData = req.body;
|
||||
const map = maps.get(id);
|
||||
|
||||
if (!map) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Map not found'
|
||||
});
|
||||
}
|
||||
|
||||
if (!map.data) map.data = {pack: {}};
|
||||
if (!map.data.pack) map.data.pack = {};
|
||||
if (!map.data.pack.burgs) map.data.pack.burgs = [];
|
||||
|
||||
const newBurg = {
|
||||
i: map.data.pack.burgs.length,
|
||||
...burgData
|
||||
};
|
||||
|
||||
map.data.pack.burgs.push(newBurg);
|
||||
map.updatedAt = new Date().toISOString();
|
||||
maps.set(id, map);
|
||||
|
||||
// Notify via WebSocket
|
||||
io.emit('burg:added', {mapId: id, burg: newBurg});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
burg: newBurg
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// WEBSOCKET HANDLERS
|
||||
// ============================================================================
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
console.log('Client connected:', socket.id);
|
||||
|
||||
// Client can send updates directly
|
||||
socket.on('map:update', (data) => {
|
||||
const {mapId, updates} = data;
|
||||
const map = maps.get(mapId);
|
||||
|
||||
if (map) {
|
||||
Object.assign(map, updates);
|
||||
map.updatedAt = new Date().toISOString();
|
||||
maps.set(mapId, map);
|
||||
|
||||
// Broadcast to all clients
|
||||
io.emit('map:updated', {mapId, updates});
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('map:created', (data) => {
|
||||
const {mapId, mapData} = data;
|
||||
const map = maps.get(mapId);
|
||||
|
||||
if (map) {
|
||||
map.status = 'ready';
|
||||
map.data = mapData;
|
||||
map.readyAt = new Date().toISOString();
|
||||
maps.set(mapId, map);
|
||||
|
||||
// Broadcast to all clients
|
||||
io.emit('map:created', {mapId, mapData});
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('export:completed', (data) => {
|
||||
const {mapId, format, exportData} = data;
|
||||
|
||||
// Broadcast to all clients
|
||||
io.emit(`export:completed:${mapId}`, {format, exportData});
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
console.log('Client disconnected:', socket.id);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// STATIC PAGES
|
||||
// ============================================================================
|
||||
|
||||
// Serve demo page
|
||||
app.get('/', (req, res) => {
|
||||
res.send(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Fantasy Map Generator - API Server</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
h1 { color: #2c3e50; }
|
||||
h2 { color: #34495e; margin-top: 30px; }
|
||||
.endpoint {
|
||||
background: #f8f9fa;
|
||||
border-left: 4px solid #007bff;
|
||||
padding: 15px;
|
||||
margin: 10px 0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.method {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
.get { background: #28a745; }
|
||||
.post { background: #007bff; }
|
||||
.put { background: #ffc107; color: #000; }
|
||||
.delete { background: #dc3545; }
|
||||
code {
|
||||
background: #e9ecef;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
pre {
|
||||
background: #2d2d2d;
|
||||
color: #f8f8f2;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.status { color: #28a745; font-weight: bold; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Fantasy Map Generator - API Server</h1>
|
||||
<p class="status">✓ Server is running</p>
|
||||
|
||||
<h2>REST API Endpoints</h2>
|
||||
|
||||
<div class="endpoint">
|
||||
<span class="method get">GET</span>
|
||||
<code>/api/health</code>
|
||||
<p>Health check endpoint</p>
|
||||
</div>
|
||||
|
||||
<div class="endpoint">
|
||||
<span class="method post">POST</span>
|
||||
<code>/api/maps</code>
|
||||
<p>Create a new map</p>
|
||||
<pre>{
|
||||
"seed": "optional-seed",
|
||||
"width": 1920,
|
||||
"height": 1080
|
||||
}</pre>
|
||||
</div>
|
||||
|
||||
<div class="endpoint">
|
||||
<span class="method get">GET</span>
|
||||
<code>/api/maps</code>
|
||||
<p>List all maps</p>
|
||||
</div>
|
||||
|
||||
<div class="endpoint">
|
||||
<span class="method get">GET</span>
|
||||
<code>/api/maps/:id</code>
|
||||
<p>Get specific map by ID</p>
|
||||
</div>
|
||||
|
||||
<div class="endpoint">
|
||||
<span class="method put">PUT</span>
|
||||
<code>/api/maps/:id</code>
|
||||
<p>Update map data</p>
|
||||
</div>
|
||||
|
||||
<div class="endpoint">
|
||||
<span class="method delete">DELETE</span>
|
||||
<code>/api/maps/:id</code>
|
||||
<p>Delete a map</p>
|
||||
</div>
|
||||
|
||||
<div class="endpoint">
|
||||
<span class="method get">GET</span>
|
||||
<code>/api/maps/:id/rivers</code>
|
||||
<p>Get rivers data</p>
|
||||
</div>
|
||||
|
||||
<div class="endpoint">
|
||||
<span class="method put">PUT</span>
|
||||
<code>/api/maps/:id/rivers</code>
|
||||
<p>Update rivers data</p>
|
||||
</div>
|
||||
|
||||
<div class="endpoint">
|
||||
<span class="method post">POST</span>
|
||||
<code>/api/maps/:id/rivers/import</code>
|
||||
<p>Import rivers from CSV file</p>
|
||||
</div>
|
||||
|
||||
<div class="endpoint">
|
||||
<span class="method get">GET</span>
|
||||
<code>/api/maps/:id/cultures</code>
|
||||
<p>Get cultures data</p>
|
||||
</div>
|
||||
|
||||
<div class="endpoint">
|
||||
<span class="method get">GET</span>
|
||||
<code>/api/maps/:id/states</code>
|
||||
<p>Get states data</p>
|
||||
</div>
|
||||
|
||||
<div class="endpoint">
|
||||
<span class="method get">GET</span>
|
||||
<code>/api/maps/:id/burgs</code>
|
||||
<p>Get burgs (cities/towns) data</p>
|
||||
</div>
|
||||
|
||||
<div class="endpoint">
|
||||
<span class="method post">POST</span>
|
||||
<code>/api/maps/:id/burgs</code>
|
||||
<p>Add a new burg</p>
|
||||
</div>
|
||||
|
||||
<h2>WebSocket Events</h2>
|
||||
<p>Connect to WebSocket at: <code>ws://localhost:3000</code></p>
|
||||
|
||||
<div class="endpoint">
|
||||
<strong>Server Events:</strong>
|
||||
<ul>
|
||||
<li><code>map:creating</code> - Map creation started</li>
|
||||
<li><code>map:created</code> - Map creation completed</li>
|
||||
<li><code>map:updated</code> - Map data updated</li>
|
||||
<li><code>map:deleted</code> - Map deleted</li>
|
||||
<li><code>rivers:updated</code> - Rivers data updated</li>
|
||||
<li><code>rivers:imported</code> - Rivers imported from CSV</li>
|
||||
<li><code>burg:added</code> - New burg added</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<h2>Integration Examples</h2>
|
||||
<p>Visit these demo pages:</p>
|
||||
<ul>
|
||||
<li><a href="/demos/postmessage-demo.html">PostMessage Demo (iframe integration)</a></li>
|
||||
<li><a href="/demos/rest-api-demo.html">REST API Demo</a></li>
|
||||
<li><a href="/demos/websocket-demo.html">WebSocket Demo</a></li>
|
||||
</ul>
|
||||
|
||||
<h2>Quick Start</h2>
|
||||
<pre>// Create a new map
|
||||
curl -X POST http://localhost:3000/api/maps \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"seed": "my-custom-seed"}'
|
||||
|
||||
// Get map data
|
||||
curl http://localhost:3000/api/maps/MAP_ID
|
||||
|
||||
// Update rivers
|
||||
curl -X PUT http://localhost:3000/api/maps/MAP_ID/rivers \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"rivers": [...]}'</pre>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
function generateMapId() {
|
||||
return 'map_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// START SERVER
|
||||
// ============================================================================
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
httpServer.listen(PORT, () => {
|
||||
console.log('='.repeat(60));
|
||||
console.log('Fantasy Map Generator - API Server');
|
||||
console.log('='.repeat(60));
|
||||
console.log(`Server running on: http://localhost:${PORT}`);
|
||||
console.log(`WebSocket endpoint: ws://localhost:${PORT}`);
|
||||
console.log('');
|
||||
console.log('REST API: http://localhost:' + PORT + '/api');
|
||||
console.log('Documentation: http://localhost:' + PORT);
|
||||
console.log('='.repeat(60));
|
||||
});
|
||||
599
demos/postmessage-demo.html
Normal file
599
demos/postmessage-demo.html
Normal file
|
|
@ -0,0 +1,599 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>FMG PostMessage Integration Demo</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: #2c3e50;
|
||||
color: white;
|
||||
padding: 15px 20px;
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 350px;
|
||||
background: white;
|
||||
border-right: 1px solid #ddd;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.panel h2 {
|
||||
font-size: 16px;
|
||||
margin-bottom: 15px;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.control-group label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 5px;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="number"],
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
button {
|
||||
background: #3498db;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #2980b9;
|
||||
}
|
||||
|
||||
button:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
button.secondary {
|
||||
background: #95a5a6;
|
||||
}
|
||||
|
||||
button.secondary:hover {
|
||||
background: #7f8c8d;
|
||||
}
|
||||
|
||||
button.danger {
|
||||
background: #e74c3c;
|
||||
}
|
||||
|
||||
button.danger:hover {
|
||||
background: #c0392b;
|
||||
}
|
||||
|
||||
.map-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
background: #ecf0f1;
|
||||
}
|
||||
|
||||
#mapFrame {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.log {
|
||||
background: #2d2d2d;
|
||||
color: #f8f8f2;
|
||||
padding: 10px;
|
||||
font-family: monospace;
|
||||
font-size: 11px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px solid #3d3d3d;
|
||||
}
|
||||
|
||||
.log-entry.event {
|
||||
color: #50fa7b;
|
||||
}
|
||||
|
||||
.log-entry.response {
|
||||
color: #8be9fd;
|
||||
}
|
||||
|
||||
.log-entry.error {
|
||||
color: #ff5555;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 10px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: #f8f9fa;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
border-left: 3px solid #3498db;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 11px;
|
||||
color: #7f8c8d;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status.connected {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.status.disconnected {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>Fantasy Map Generator - PostMessage Integration Demo</h1>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<!-- Sidebar Controls -->
|
||||
<div class="sidebar">
|
||||
<!-- Connection Status -->
|
||||
<div class="panel">
|
||||
<h2>Connection Status</h2>
|
||||
<span id="status" class="status disconnected">Disconnected</span>
|
||||
</div>
|
||||
|
||||
<!-- Map Controls -->
|
||||
<div class="panel">
|
||||
<h2>Map Controls</h2>
|
||||
|
||||
<div class="control-group">
|
||||
<label>Seed (optional)</label>
|
||||
<input type="text" id="seedInput" placeholder="e.g., 1234567890">
|
||||
</div>
|
||||
|
||||
<button onclick="createMap()">Create New Map</button>
|
||||
<button onclick="saveMap()" class="secondary">Save Map</button>
|
||||
<button onclick="getMapState()" class="secondary">Get Map State</button>
|
||||
</div>
|
||||
|
||||
<!-- Data Management -->
|
||||
<div class="panel">
|
||||
<h2>Data Management</h2>
|
||||
|
||||
<button onclick="getRivers()">Get Rivers</button>
|
||||
<button onclick="getCultures()">Get Cultures</button>
|
||||
<button onclick="getStates()">Get States</button>
|
||||
<button onclick="getBurgs()">Get Burgs</button>
|
||||
</div>
|
||||
|
||||
<!-- Custom Data Update -->
|
||||
<div class="panel">
|
||||
<h2>Update Rivers</h2>
|
||||
|
||||
<div class="control-group">
|
||||
<label>Rivers JSON</label>
|
||||
<textarea id="riversInput" placeholder='[{"i":1,"name":"River Name",...}]'></textarea>
|
||||
</div>
|
||||
|
||||
<button onclick="updateRivers()">Update Rivers</button>
|
||||
</div>
|
||||
|
||||
<!-- Export -->
|
||||
<div class="panel">
|
||||
<h2>Export</h2>
|
||||
|
||||
<button onclick="exportSVG()">Export SVG</button>
|
||||
<button onclick="exportPNG()">Export PNG</button>
|
||||
<button onclick="exportJSON()">Export JSON</button>
|
||||
</div>
|
||||
|
||||
<!-- Statistics -->
|
||||
<div class="panel">
|
||||
<h2>Statistics</h2>
|
||||
<div class="stats">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Rivers</div>
|
||||
<div class="stat-value" id="riverCount">0</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Cultures</div>
|
||||
<div class="stat-value" id="cultureCount">0</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">States</div>
|
||||
<div class="stat-value" id="stateCount">0</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Burgs</div>
|
||||
<div class="stat-value" id="burgCount">0</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Event Log -->
|
||||
<div class="panel">
|
||||
<h2>Event Log</h2>
|
||||
<div class="log" id="eventLog"></div>
|
||||
<button onclick="clearLog()" class="secondary" style="margin-top: 10px;">Clear Log</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Map Frame -->
|
||||
<div class="map-container">
|
||||
<iframe id="mapFrame" src="../index.html"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const mapFrame = document.getElementById('mapFrame');
|
||||
const statusEl = document.getElementById('status');
|
||||
const eventLog = document.getElementById('eventLog');
|
||||
let requestId = 0;
|
||||
const pendingRequests = new Map();
|
||||
|
||||
// Initialize when iframe loads
|
||||
mapFrame.addEventListener('load', () => {
|
||||
log('Iframe loaded', 'event');
|
||||
statusEl.textContent = 'Connected';
|
||||
statusEl.className = 'status connected';
|
||||
|
||||
// Request initial state
|
||||
setTimeout(() => {
|
||||
sendMessage('GET_STATE');
|
||||
}, 2000);
|
||||
});
|
||||
|
||||
// Listen for messages from iframe
|
||||
window.addEventListener('message', (event) => {
|
||||
const {type, payload, requestId: respId, timestamp} = event.data;
|
||||
|
||||
if (!type) return;
|
||||
|
||||
log(`← ${type}`, type === 'ERROR' ? 'error' : type === 'RESPONSE' ? 'response' : 'event');
|
||||
|
||||
// Handle responses
|
||||
if (type === 'RESPONSE' && respId && pendingRequests.has(respId)) {
|
||||
const resolver = pendingRequests.get(respId);
|
||||
resolver(payload);
|
||||
pendingRequests.delete(respId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle events
|
||||
if (type === 'EVENT') {
|
||||
const {event, data} = payload;
|
||||
log(`Event: ${event}`, 'event');
|
||||
|
||||
// Update statistics based on events
|
||||
if (event === 'map:changed' || event === 'map:loaded' || event === 'map:created') {
|
||||
updateStatistics(data);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle errors
|
||||
if (type === 'ERROR') {
|
||||
console.error('Error from iframe:', payload);
|
||||
}
|
||||
});
|
||||
|
||||
// Send message to iframe
|
||||
function sendMessage(type, payload = null) {
|
||||
const reqId = ++requestId;
|
||||
|
||||
log(`→ ${type}`, 'response');
|
||||
|
||||
mapFrame.contentWindow.postMessage({
|
||||
type,
|
||||
payload,
|
||||
requestId: reqId
|
||||
}, '*');
|
||||
|
||||
// Return promise that resolves when response is received
|
||||
return new Promise((resolve) => {
|
||||
pendingRequests.set(reqId, resolve);
|
||||
|
||||
// Timeout after 30 seconds
|
||||
setTimeout(() => {
|
||||
if (pendingRequests.has(reqId)) {
|
||||
pendingRequests.delete(reqId);
|
||||
resolve({success: false, error: 'Request timeout'});
|
||||
}
|
||||
}, 30000);
|
||||
});
|
||||
}
|
||||
|
||||
// Map controls
|
||||
async function createMap() {
|
||||
const seed = document.getElementById('seedInput').value.trim();
|
||||
const options = seed ? {seed} : {};
|
||||
|
||||
log('Creating new map...', 'event');
|
||||
const result = await sendMessage('CREATE_MAP', options);
|
||||
|
||||
if (result.success) {
|
||||
log('Map created successfully', 'event');
|
||||
updateStatistics(result.state);
|
||||
} else {
|
||||
log(`Error: ${result.error}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveMap() {
|
||||
log('Saving map...', 'event');
|
||||
const result = await sendMessage('SAVE_MAP', {format: 'data'});
|
||||
|
||||
if (result.success) {
|
||||
log('Map saved', 'event');
|
||||
|
||||
// Download the map file
|
||||
const blob = new Blob([result.data], {type: 'text/plain'});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = result.filename || 'map.map';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} else {
|
||||
log(`Error: ${result.error}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function getMapState() {
|
||||
log('Getting map state...', 'event');
|
||||
const result = await sendMessage('GET_STATE');
|
||||
|
||||
if (result.success) {
|
||||
log('Map state retrieved', 'event');
|
||||
console.log('Map state:', result.data);
|
||||
updateStatistics(result.data);
|
||||
} else {
|
||||
log(`Error: ${result.error}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Data retrieval
|
||||
async function getRivers() {
|
||||
log('Getting rivers...', 'event');
|
||||
const result = await sendMessage('GET_RIVERS');
|
||||
|
||||
if (result.success) {
|
||||
log(`Retrieved ${result.data.length} rivers`, 'event');
|
||||
console.log('Rivers:', result.data);
|
||||
document.getElementById('riverCount').textContent = result.data.length;
|
||||
} else {
|
||||
log(`Error: ${result.error}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function getCultures() {
|
||||
log('Getting cultures...', 'event');
|
||||
const result = await sendMessage('GET_CULTURES');
|
||||
|
||||
if (result.success) {
|
||||
log(`Retrieved ${result.data.length} cultures`, 'event');
|
||||
console.log('Cultures:', result.data);
|
||||
document.getElementById('cultureCount').textContent = result.data.length;
|
||||
} else {
|
||||
log(`Error: ${result.error}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function getStates() {
|
||||
log('Getting states...', 'event');
|
||||
const result = await sendMessage('GET_STATES');
|
||||
|
||||
if (result.success) {
|
||||
log(`Retrieved ${result.data.length} states`, 'event');
|
||||
console.log('States:', result.data);
|
||||
document.getElementById('stateCount').textContent = result.data.length;
|
||||
} else {
|
||||
log(`Error: ${result.error}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function getBurgs() {
|
||||
log('Getting burgs...', 'event');
|
||||
const result = await sendMessage('GET_BURGS');
|
||||
|
||||
if (result.success) {
|
||||
log(`Retrieved ${result.data.length} burgs`, 'event');
|
||||
console.log('Burgs:', result.data);
|
||||
document.getElementById('burgCount').textContent = result.data.length;
|
||||
} else {
|
||||
log(`Error: ${result.error}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Data updates
|
||||
async function updateRivers() {
|
||||
const riversJSON = document.getElementById('riversInput').value.trim();
|
||||
|
||||
if (!riversJSON) {
|
||||
log('Error: No rivers data provided', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const rivers = JSON.parse(riversJSON);
|
||||
log('Updating rivers...', 'event');
|
||||
|
||||
const result = await sendMessage('UPDATE_RIVERS', rivers);
|
||||
|
||||
if (result.success) {
|
||||
log('Rivers updated successfully', 'event');
|
||||
} else {
|
||||
log(`Error: ${result.error}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
log(`Error: Invalid JSON - ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Export functions
|
||||
async function exportSVG() {
|
||||
log('Exporting SVG...', 'event');
|
||||
const result = await sendMessage('EXPORT_SVG');
|
||||
|
||||
if (result.success) {
|
||||
log('SVG exported', 'event');
|
||||
|
||||
// Download SVG
|
||||
const blob = new Blob([result.data], {type: 'image/svg+xml'});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'map.svg';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} else {
|
||||
log(`Error: ${result.error}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function exportPNG() {
|
||||
log('Exporting PNG (this may take a while)...', 'event');
|
||||
const result = await sendMessage('EXPORT_PNG', {width: 2048, height: 2048});
|
||||
|
||||
if (result.success) {
|
||||
log('PNG exported', 'event');
|
||||
|
||||
// result.data is a data URL
|
||||
const a = document.createElement('a');
|
||||
a.href = result.data;
|
||||
a.download = 'map.png';
|
||||
a.click();
|
||||
} else {
|
||||
log(`Error: ${result.error}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function exportJSON() {
|
||||
log('Exporting JSON...', 'event');
|
||||
const result = await sendMessage('EXPORT_JSON');
|
||||
|
||||
if (result.success) {
|
||||
log('JSON exported', 'event');
|
||||
|
||||
// Download JSON
|
||||
const blob = new Blob([result.data], {type: 'application/json'});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'map.json';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} else {
|
||||
log(`Error: ${result.error}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Update statistics display
|
||||
function updateStatistics(state) {
|
||||
if (!state || !state.pack) return;
|
||||
|
||||
document.getElementById('riverCount').textContent = state.pack.rivers?.length || 0;
|
||||
document.getElementById('cultureCount').textContent = state.pack.cultures?.length || 0;
|
||||
document.getElementById('stateCount').textContent = state.pack.states?.length || 0;
|
||||
document.getElementById('burgCount').textContent = state.pack.burgs?.length || 0;
|
||||
}
|
||||
|
||||
// Logging
|
||||
function log(message, type = 'event') {
|
||||
const entry = document.createElement('div');
|
||||
entry.className = `log-entry ${type}`;
|
||||
entry.textContent = `[${new Date().toLocaleTimeString()}] ${message}`;
|
||||
eventLog.appendChild(entry);
|
||||
eventLog.scrollTop = eventLog.scrollHeight;
|
||||
}
|
||||
|
||||
function clearLog() {
|
||||
eventLog.innerHTML = '';
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
619
demos/rest-api-demo.html
Normal file
619
demos/rest-api-demo.html
Normal file
|
|
@ -0,0 +1,619 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>FMG REST API Demo</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
padding: 20px;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #2c3e50;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #7f8c8d;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.panel h2 {
|
||||
font-size: 18px;
|
||||
margin-bottom: 15px;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.control-group label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 5px;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="number"],
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
min-height: 120px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
button {
|
||||
background: #3498db;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 20px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-right: 10px;
|
||||
margin-top: 10px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #2980b9;
|
||||
}
|
||||
|
||||
button:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
button.secondary {
|
||||
background: #95a5a6;
|
||||
}
|
||||
|
||||
button.secondary:hover {
|
||||
background: #7f8c8d;
|
||||
}
|
||||
|
||||
button.danger {
|
||||
background: #e74c3c;
|
||||
}
|
||||
|
||||
button.danger:hover {
|
||||
background: #c0392b;
|
||||
}
|
||||
|
||||
.response-area {
|
||||
background: #2d2d2d;
|
||||
color: #f8f8f2;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-block;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.status.online {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.status.offline {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.map-list {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.map-item {
|
||||
padding: 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.map-item:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.map-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.map-id {
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.map-date {
|
||||
font-size: 12px;
|
||||
color: #7f8c8d;
|
||||
}
|
||||
|
||||
.map-actions button {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
margin: 0 5px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid #3498db;
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
code {
|
||||
background: #e9ecef;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Fantasy Map Generator - REST API Demo</h1>
|
||||
<p class="subtitle">Interactive demo for testing the REST API endpoints</p>
|
||||
|
||||
<!-- Server Status -->
|
||||
<div class="panel">
|
||||
<h2>Server Status</h2>
|
||||
<div class="control-group">
|
||||
<label>API Base URL</label>
|
||||
<input type="text" id="apiBaseUrl" value="http://localhost:3000/api">
|
||||
</div>
|
||||
<button onclick="checkHealth()">Check Health</button>
|
||||
<div id="serverStatus"></div>
|
||||
</div>
|
||||
|
||||
<!-- Create Map -->
|
||||
<div class="panel">
|
||||
<h2>Create New Map</h2>
|
||||
<div class="control-group">
|
||||
<label>Seed (optional)</label>
|
||||
<input type="text" id="createSeed" placeholder="e.g., myseed123">
|
||||
</div>
|
||||
<button onclick="createMap()">Create Map</button>
|
||||
<div id="createResponse" class="response-area" style="margin-top: 15px; display: none;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Map List -->
|
||||
<div class="panel">
|
||||
<h2>Maps</h2>
|
||||
<button onclick="listMaps()">Refresh List</button>
|
||||
<ul id="mapList" class="map-list" style="margin-top: 15px;"></ul>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<!-- Get Map Data -->
|
||||
<div class="panel">
|
||||
<h2>Get Map Data</h2>
|
||||
<div class="control-group">
|
||||
<label>Map ID</label>
|
||||
<input type="text" id="getMapId" placeholder="map_...">
|
||||
</div>
|
||||
<button onclick="getMap()">Get Map</button>
|
||||
<div id="getMapResponse" class="response-area" style="margin-top: 15px; display: none;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Get Rivers -->
|
||||
<div class="panel">
|
||||
<h2>Get Rivers</h2>
|
||||
<div class="control-group">
|
||||
<label>Map ID</label>
|
||||
<input type="text" id="getRiversMapId" placeholder="map_...">
|
||||
</div>
|
||||
<button onclick="getRivers()">Get Rivers</button>
|
||||
<div id="getRiversResponse" class="response-area" style="margin-top: 15px; display: none;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Get Cultures -->
|
||||
<div class="panel">
|
||||
<h2>Get Cultures</h2>
|
||||
<div class="control-group">
|
||||
<label>Map ID</label>
|
||||
<input type="text" id="getCulturesMapId" placeholder="map_...">
|
||||
</div>
|
||||
<button onclick="getCultures()">Get Cultures</button>
|
||||
<div id="getCulturesResponse" class="response-area" style="margin-top: 15px; display: none;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Get States -->
|
||||
<div class="panel">
|
||||
<h2>Get States</h2>
|
||||
<div class="control-group">
|
||||
<label>Map ID</label>
|
||||
<input type="text" id="getStatesMapId" placeholder="map_...">
|
||||
</div>
|
||||
<button onclick="getStates()">Get States</button>
|
||||
<div id="getStatesResponse" class="response-area" style="margin-top: 15px; display: none;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Get Burgs -->
|
||||
<div class="panel">
|
||||
<h2>Get Burgs</h2>
|
||||
<div class="control-group">
|
||||
<label>Map ID</label>
|
||||
<input type="text" id="getBurgsMapId" placeholder="map_...">
|
||||
</div>
|
||||
<button onclick="getBurgs()">Get Burgs</button>
|
||||
<div id="getBurgsResponse" class="response-area" style="margin-top: 15px; display: none;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Update Rivers -->
|
||||
<div class="panel">
|
||||
<h2>Update Rivers</h2>
|
||||
<div class="control-group">
|
||||
<label>Map ID</label>
|
||||
<input type="text" id="updateRiversMapId" placeholder="map_...">
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<label>Rivers JSON</label>
|
||||
<textarea id="riversData" placeholder='[{"i":1,"name":"New River","type":"River",...}]'></textarea>
|
||||
</div>
|
||||
<button onclick="updateRivers()">Update Rivers</button>
|
||||
<div id="updateRiversResponse" class="response-area" style="margin-top: 15px; display: none;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Import Rivers CSV -->
|
||||
<div class="panel">
|
||||
<h2>Import Rivers from CSV</h2>
|
||||
<div class="control-group">
|
||||
<label>Map ID</label>
|
||||
<input type="text" id="importRiversMapId" placeholder="map_...">
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<label>CSV File</label>
|
||||
<input type="file" id="csvFile" accept=".csv">
|
||||
</div>
|
||||
<button onclick="importRiversCSV()">Import Rivers</button>
|
||||
<div id="importRiversResponse" class="response-area" style="margin-top: 15px; display: none;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Add Burg -->
|
||||
<div class="panel">
|
||||
<h2>Add New Burg</h2>
|
||||
<div class="control-group">
|
||||
<label>Map ID</label>
|
||||
<input type="text" id="addBurgMapId" placeholder="map_...">
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<label>Burg Data (JSON)</label>
|
||||
<textarea id="burgData" placeholder='{"name":"New City","x":100,"y":100,"cell":500,"population":10,"type":"city"}'></textarea>
|
||||
</div>
|
||||
<button onclick="addBurg()">Add Burg</button>
|
||||
<div id="addBurgResponse" class="response-area" style="margin-top: 15px; display: none;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const getBaseUrl = () => document.getElementById('apiBaseUrl').value.trim();
|
||||
|
||||
// Helper to make API calls
|
||||
async function apiCall(endpoint, method = 'GET', body = null) {
|
||||
const url = `${getBaseUrl()}${endpoint}`;
|
||||
|
||||
const options = {
|
||||
method,
|
||||
headers: {}
|
||||
};
|
||||
|
||||
if (body) {
|
||||
if (body instanceof FormData) {
|
||||
options.body = body;
|
||||
} else {
|
||||
options.headers['Content-Type'] = 'application/json';
|
||||
options.body = JSON.stringify(body);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, options);
|
||||
const data = await response.json();
|
||||
return {response, data};
|
||||
} catch (error) {
|
||||
return {error: error.message};
|
||||
}
|
||||
}
|
||||
|
||||
// Display response
|
||||
function displayResponse(elementId, data) {
|
||||
const el = document.getElementById(elementId);
|
||||
el.style.display = 'block';
|
||||
el.textContent = JSON.stringify(data, null, 2);
|
||||
}
|
||||
|
||||
// Server health check
|
||||
async function checkHealth() {
|
||||
const statusEl = document.getElementById('serverStatus');
|
||||
statusEl.innerHTML = '<div class="loading"></div> Checking...';
|
||||
|
||||
const {data, error} = await apiCall('/health');
|
||||
|
||||
if (error) {
|
||||
statusEl.innerHTML = `<div class="status offline">Offline</div><p>Error: ${error}</p>`;
|
||||
} else {
|
||||
statusEl.innerHTML = `<div class="status online">Online</div><pre>${JSON.stringify(data, null, 2)}</pre>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Create map
|
||||
async function createMap() {
|
||||
const seed = document.getElementById('createSeed').value.trim();
|
||||
const body = seed ? {seed} : {};
|
||||
|
||||
const {data, error} = await apiCall('/maps', 'POST', body);
|
||||
|
||||
if (error) {
|
||||
displayResponse('createResponse', {error});
|
||||
} else {
|
||||
displayResponse('createResponse', data);
|
||||
if (data.mapId) {
|
||||
// Auto-fill map IDs in other forms
|
||||
document.getElementById('getMapId').value = data.mapId;
|
||||
document.getElementById('getRiversMapId').value = data.mapId;
|
||||
document.getElementById('getCulturesMapId').value = data.mapId;
|
||||
document.getElementById('getStatesMapId').value = data.mapId;
|
||||
document.getElementById('getBurgsMapId').value = data.mapId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// List maps
|
||||
async function listMaps() {
|
||||
const {data, error} = await apiCall('/maps');
|
||||
const listEl = document.getElementById('mapList');
|
||||
|
||||
if (error) {
|
||||
listEl.innerHTML = `<li>Error: ${error}</li>`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data.maps || data.maps.length === 0) {
|
||||
listEl.innerHTML = '<li>No maps found</li>';
|
||||
return;
|
||||
}
|
||||
|
||||
listEl.innerHTML = data.maps.map(map => `
|
||||
<li class="map-item">
|
||||
<div class="map-info">
|
||||
<div class="map-id">${map.id}</div>
|
||||
<div class="map-date">Created: ${map.createdAt}</div>
|
||||
</div>
|
||||
<div class="map-actions">
|
||||
<button onclick="selectMap('${map.id}')" class="secondary">Select</button>
|
||||
<button onclick="deleteMap('${map.id}')" class="danger">Delete</button>
|
||||
</div>
|
||||
</li>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// Select map (fill in all forms)
|
||||
function selectMap(mapId) {
|
||||
document.getElementById('getMapId').value = mapId;
|
||||
document.getElementById('getRiversMapId').value = mapId;
|
||||
document.getElementById('getCulturesMapId').value = mapId;
|
||||
document.getElementById('getStatesMapId').value = mapId;
|
||||
document.getElementById('getBurgsMapId').value = mapId;
|
||||
document.getElementById('updateRiversMapId').value = mapId;
|
||||
document.getElementById('importRiversMapId').value = mapId;
|
||||
document.getElementById('addBurgMapId').value = mapId;
|
||||
}
|
||||
|
||||
// Delete map
|
||||
async function deleteMap(mapId) {
|
||||
if (!confirm(`Delete map ${mapId}?`)) return;
|
||||
|
||||
const {data, error} = await apiCall(`/maps/${mapId}`, 'DELETE');
|
||||
|
||||
if (error) {
|
||||
alert(`Error: ${error}`);
|
||||
} else {
|
||||
alert('Map deleted');
|
||||
listMaps();
|
||||
}
|
||||
}
|
||||
|
||||
// Get map
|
||||
async function getMap() {
|
||||
const mapId = document.getElementById('getMapId').value.trim();
|
||||
if (!mapId) {
|
||||
alert('Please enter a map ID');
|
||||
return;
|
||||
}
|
||||
|
||||
const {data, error} = await apiCall(`/maps/${mapId}`);
|
||||
displayResponse('getMapResponse', error ? {error} : data);
|
||||
}
|
||||
|
||||
// Get rivers
|
||||
async function getRivers() {
|
||||
const mapId = document.getElementById('getRiversMapId').value.trim();
|
||||
if (!mapId) {
|
||||
alert('Please enter a map ID');
|
||||
return;
|
||||
}
|
||||
|
||||
const {data, error} = await apiCall(`/maps/${mapId}/rivers`);
|
||||
displayResponse('getRiversResponse', error ? {error} : data);
|
||||
}
|
||||
|
||||
// Get cultures
|
||||
async function getCultures() {
|
||||
const mapId = document.getElementById('getCulturesMapId').value.trim();
|
||||
if (!mapId) {
|
||||
alert('Please enter a map ID');
|
||||
return;
|
||||
}
|
||||
|
||||
const {data, error} = await apiCall(`/maps/${mapId}/cultures`);
|
||||
displayResponse('getCulturesResponse', error ? {error} : data);
|
||||
}
|
||||
|
||||
// Get states
|
||||
async function getStates() {
|
||||
const mapId = document.getElementById('getStatesMapId').value.trim();
|
||||
if (!mapId) {
|
||||
alert('Please enter a map ID');
|
||||
return;
|
||||
}
|
||||
|
||||
const {data, error} = await apiCall(`/maps/${mapId}/states`);
|
||||
displayResponse('getStatesResponse', error ? {error} : data);
|
||||
}
|
||||
|
||||
// Get burgs
|
||||
async function getBurgs() {
|
||||
const mapId = document.getElementById('getBurgsMapId').value.trim();
|
||||
if (!mapId) {
|
||||
alert('Please enter a map ID');
|
||||
return;
|
||||
}
|
||||
|
||||
const {data, error} = await apiCall(`/maps/${mapId}/burgs`);
|
||||
displayResponse('getBurgsResponse', error ? {error} : data);
|
||||
}
|
||||
|
||||
// Update rivers
|
||||
async function updateRivers() {
|
||||
const mapId = document.getElementById('updateRiversMapId').value.trim();
|
||||
const riversJSON = document.getElementById('riversData').value.trim();
|
||||
|
||||
if (!mapId) {
|
||||
alert('Please enter a map ID');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!riversJSON) {
|
||||
alert('Please enter rivers data');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const rivers = JSON.parse(riversJSON);
|
||||
const {data, error} = await apiCall(`/maps/${mapId}/rivers`, 'PUT', {rivers});
|
||||
displayResponse('updateRiversResponse', error ? {error} : data);
|
||||
} catch (e) {
|
||||
displayResponse('updateRiversResponse', {error: `Invalid JSON: ${e.message}`});
|
||||
}
|
||||
}
|
||||
|
||||
// Import rivers from CSV
|
||||
async function importRiversCSV() {
|
||||
const mapId = document.getElementById('importRiversMapId').value.trim();
|
||||
const fileInput = document.getElementById('csvFile');
|
||||
|
||||
if (!mapId) {
|
||||
alert('Please enter a map ID');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fileInput.files[0]) {
|
||||
alert('Please select a CSV file');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', fileInput.files[0]);
|
||||
|
||||
const {data, error} = await apiCall(`/maps/${mapId}/rivers/import`, 'POST', formData);
|
||||
displayResponse('importRiversResponse', error ? {error} : data);
|
||||
}
|
||||
|
||||
// Add burg
|
||||
async function addBurg() {
|
||||
const mapId = document.getElementById('addBurgMapId').value.trim();
|
||||
const burgJSON = document.getElementById('burgData').value.trim();
|
||||
|
||||
if (!mapId) {
|
||||
alert('Please enter a map ID');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!burgJSON) {
|
||||
alert('Please enter burg data');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const burgData = JSON.parse(burgJSON);
|
||||
const {data, error} = await apiCall(`/maps/${mapId}/burgs`, 'POST', burgData);
|
||||
displayResponse('addBurgResponse', error ? {error} : data);
|
||||
} catch (e) {
|
||||
displayResponse('addBurgResponse', {error: `Invalid JSON: ${e.message}`});
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-check health on load
|
||||
window.addEventListener('load', () => {
|
||||
checkHealth();
|
||||
listMaps();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
617
demos/websocket-demo.html
Normal file
617
demos/websocket-demo.html
Normal file
|
|
@ -0,0 +1,617 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>FMG WebSocket Demo</title>
|
||||
<script src="https://cdn.socket.io/4.6.1/socket.io.min.js"></script>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: #2c3e50;
|
||||
color: white;
|
||||
padding: 15px 20px;
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 350px;
|
||||
background: white;
|
||||
border-right: 1px solid #ddd;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.panel h2 {
|
||||
font-size: 16px;
|
||||
margin-bottom: 15px;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.control-group label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 5px;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="number"],
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
background: #3498db;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #2980b9;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
background: #bdc3c7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
button.secondary {
|
||||
background: #95a5a6;
|
||||
}
|
||||
|
||||
button.secondary:hover {
|
||||
background: #7f8c8d;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.event-log {
|
||||
flex: 1;
|
||||
background: #2d2d2d;
|
||||
color: #f8f8f2;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
padding: 8px;
|
||||
border-left: 3px solid transparent;
|
||||
margin-bottom: 5px;
|
||||
background: rgba(255,255,255,0.05);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.log-entry.event {
|
||||
border-left-color: #50fa7b;
|
||||
}
|
||||
|
||||
.log-entry.send {
|
||||
border-left-color: #8be9fd;
|
||||
}
|
||||
|
||||
.log-entry.error {
|
||||
border-left-color: #ff5555;
|
||||
}
|
||||
|
||||
.log-entry.info {
|
||||
border-left-color: #f1fa8c;
|
||||
}
|
||||
|
||||
.log-time {
|
||||
color: #6272a4;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.log-type {
|
||||
font-weight: bold;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.event.log-type {
|
||||
color: #50fa7b;
|
||||
}
|
||||
|
||||
.send.log-type {
|
||||
color: #8be9fd;
|
||||
}
|
||||
|
||||
.error.log-type {
|
||||
color: #ff5555;
|
||||
}
|
||||
|
||||
.info.log-type {
|
||||
color: #f1fa8c;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-block;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status.connected {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.status.disconnected {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.status.connecting {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 10px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: #f8f9fa;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
border-left: 3px solid #3498db;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 11px;
|
||||
color: #7f8c8d;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.event-list {
|
||||
list-style: none;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.event-item {
|
||||
padding: 8px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 5px;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.event-name {
|
||||
font-family: monospace;
|
||||
color: #2c3e50;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.event-count {
|
||||
background: #3498db;
|
||||
color: white;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>Fantasy Map Generator - WebSocket Real-Time Demo</h1>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<!-- Sidebar Controls -->
|
||||
<div class="sidebar">
|
||||
<!-- Connection -->
|
||||
<div class="panel">
|
||||
<h2>WebSocket Connection</h2>
|
||||
|
||||
<div class="control-group">
|
||||
<label>Server URL</label>
|
||||
<input type="text" id="serverUrl" value="http://localhost:3000">
|
||||
</div>
|
||||
|
||||
<button onclick="connect()" id="connectBtn">Connect</button>
|
||||
<button onclick="disconnect()" id="disconnectBtn" class="secondary" disabled>Disconnect</button>
|
||||
|
||||
<div style="margin-top: 15px;">
|
||||
<span id="connectionStatus" class="status disconnected">Disconnected</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Send Custom Event -->
|
||||
<div class="panel">
|
||||
<h2>Send Custom Event</h2>
|
||||
|
||||
<div class="control-group">
|
||||
<label>Event Name</label>
|
||||
<input type="text" id="eventName" placeholder="e.g., map:update">
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label>Event Data (JSON)</label>
|
||||
<textarea id="eventData" placeholder='{"mapId":"map_123","updates":{...}}'></textarea>
|
||||
</div>
|
||||
|
||||
<button onclick="sendEvent()" id="sendBtn" disabled>Send Event</button>
|
||||
</div>
|
||||
|
||||
<!-- Statistics -->
|
||||
<div class="panel">
|
||||
<h2>Statistics</h2>
|
||||
<div class="stats">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Events Sent</div>
|
||||
<div class="stat-value" id="sentCount">0</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Events Received</div>
|
||||
<div class="stat-value" id="receivedCount">0</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Event Types -->
|
||||
<div class="panel">
|
||||
<h2>Event Types Received</h2>
|
||||
<ul id="eventTypes" class="event-list"></ul>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="panel">
|
||||
<h2>Quick Actions</h2>
|
||||
|
||||
<div class="control-group">
|
||||
<label>Map ID</label>
|
||||
<input type="text" id="quickMapId" placeholder="map_...">
|
||||
</div>
|
||||
|
||||
<button onclick="simulateMapCreated()" id="actionBtn1" disabled>Simulate: Map Created</button>
|
||||
<button onclick="simulateRiversUpdated()" id="actionBtn2" disabled>Simulate: Rivers Updated</button>
|
||||
<button onclick="simulateBurgAdded()" id="actionBtn3" disabled>Simulate: Burg Added</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="main-content">
|
||||
<div style="margin-bottom: 15px; display: flex; justify-content: space-between; align-items: center;">
|
||||
<h2 style="color: #2c3e50;">Real-Time Event Log</h2>
|
||||
<button onclick="clearEventLog()" class="secondary">Clear Log</button>
|
||||
</div>
|
||||
<div id="eventLog" class="event-log"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let socket = null;
|
||||
let sentCount = 0;
|
||||
let receivedCount = 0;
|
||||
let eventTypeCounts = {};
|
||||
|
||||
// Connect to WebSocket server
|
||||
function connect() {
|
||||
const serverUrl = document.getElementById('serverUrl').value.trim();
|
||||
|
||||
if (socket && socket.connected) {
|
||||
logEvent('Already connected', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
logEvent(`Connecting to ${serverUrl}...`, 'info');
|
||||
|
||||
document.getElementById('connectionStatus').textContent = 'Connecting...';
|
||||
document.getElementById('connectionStatus').className = 'status connecting';
|
||||
|
||||
socket = io(serverUrl);
|
||||
|
||||
// Connection events
|
||||
socket.on('connect', () => {
|
||||
logEvent(`Connected! Socket ID: ${socket.id}`, 'event');
|
||||
document.getElementById('connectionStatus').textContent = 'Connected';
|
||||
document.getElementById('connectionStatus').className = 'status connected';
|
||||
|
||||
document.getElementById('connectBtn').disabled = true;
|
||||
document.getElementById('disconnectBtn').disabled = false;
|
||||
document.getElementById('sendBtn').disabled = false;
|
||||
document.getElementById('actionBtn1').disabled = false;
|
||||
document.getElementById('actionBtn2').disabled = false;
|
||||
document.getElementById('actionBtn3').disabled = false;
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
logEvent('Disconnected from server', 'error');
|
||||
document.getElementById('connectionStatus').textContent = 'Disconnected';
|
||||
document.getElementById('connectionStatus').className = 'status disconnected';
|
||||
|
||||
document.getElementById('connectBtn').disabled = false;
|
||||
document.getElementById('disconnectBtn').disabled = true;
|
||||
document.getElementById('sendBtn').disabled = true;
|
||||
document.getElementById('actionBtn1').disabled = true;
|
||||
document.getElementById('actionBtn2').disabled = true;
|
||||
document.getElementById('actionBtn3').disabled = true;
|
||||
});
|
||||
|
||||
socket.on('connect_error', (error) => {
|
||||
logEvent(`Connection error: ${error.message}`, 'error');
|
||||
});
|
||||
|
||||
// Listen for all map-related events
|
||||
const eventNames = [
|
||||
'map:creating',
|
||||
'map:created',
|
||||
'map:updated',
|
||||
'map:deleted',
|
||||
'map:loaded',
|
||||
'rivers:updated',
|
||||
'rivers:imported',
|
||||
'cultures:updated',
|
||||
'states:updated',
|
||||
'burgs:updated',
|
||||
'burg:added',
|
||||
'export:request',
|
||||
'export:completed'
|
||||
];
|
||||
|
||||
eventNames.forEach(eventName => {
|
||||
socket.on(eventName, (data) => {
|
||||
receivedCount++;
|
||||
document.getElementById('receivedCount').textContent = receivedCount;
|
||||
|
||||
if (!eventTypeCounts[eventName]) {
|
||||
eventTypeCounts[eventName] = 0;
|
||||
}
|
||||
eventTypeCounts[eventName]++;
|
||||
updateEventTypes();
|
||||
|
||||
logEvent(`${eventName}: ${JSON.stringify(data, null, 2)}`, 'event');
|
||||
});
|
||||
});
|
||||
|
||||
// Catch-all for any other events
|
||||
socket.onAny((eventName, ...args) => {
|
||||
if (!eventNames.includes(eventName)) {
|
||||
receivedCount++;
|
||||
document.getElementById('receivedCount').textContent = receivedCount;
|
||||
|
||||
if (!eventTypeCounts[eventName]) {
|
||||
eventTypeCounts[eventName] = 0;
|
||||
}
|
||||
eventTypeCounts[eventName]++;
|
||||
updateEventTypes();
|
||||
|
||||
logEvent(`${eventName}: ${JSON.stringify(args, null, 2)}`, 'event');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Disconnect from WebSocket server
|
||||
function disconnect() {
|
||||
if (socket) {
|
||||
socket.disconnect();
|
||||
logEvent('Disconnecting...', 'info');
|
||||
}
|
||||
}
|
||||
|
||||
// Send custom event
|
||||
function sendEvent() {
|
||||
if (!socket || !socket.connected) {
|
||||
logEvent('Not connected!', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const eventName = document.getElementById('eventName').value.trim();
|
||||
const eventDataStr = document.getElementById('eventData').value.trim();
|
||||
|
||||
if (!eventName) {
|
||||
logEvent('Event name is required', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const eventData = eventDataStr ? JSON.parse(eventDataStr) : {};
|
||||
|
||||
socket.emit(eventName, eventData);
|
||||
|
||||
sentCount++;
|
||||
document.getElementById('sentCount').textContent = sentCount;
|
||||
|
||||
logEvent(`Sent ${eventName}: ${JSON.stringify(eventData, null, 2)}`, 'send');
|
||||
} catch (error) {
|
||||
logEvent(`Error: Invalid JSON - ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Quick actions
|
||||
function simulateMapCreated() {
|
||||
const mapId = document.getElementById('quickMapId').value.trim() || 'map_' + Date.now();
|
||||
|
||||
const data = {
|
||||
mapId,
|
||||
mapData: {
|
||||
seed: 'test-seed',
|
||||
pack: {
|
||||
cultures: [{i: 0, name: 'Test Culture'}],
|
||||
states: [{i: 0, name: 'Test State'}],
|
||||
burgs: [{i: 0, name: 'Test City'}],
|
||||
rivers: [{i: 0, name: 'Test River'}]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
socket.emit('map:created', data);
|
||||
|
||||
sentCount++;
|
||||
document.getElementById('sentCount').textContent = sentCount;
|
||||
logEvent(`Sent map:created: ${JSON.stringify(data, null, 2)}`, 'send');
|
||||
}
|
||||
|
||||
function simulateRiversUpdated() {
|
||||
const mapId = document.getElementById('quickMapId').value.trim() || 'map_' + Date.now();
|
||||
|
||||
const data = {
|
||||
mapId,
|
||||
rivers: [
|
||||
{i: 1, name: 'Mystic River', type: 'River', discharge: 100},
|
||||
{i: 2, name: 'Crystal Brook', type: 'River', discharge: 50}
|
||||
]
|
||||
};
|
||||
|
||||
socket.emit('rivers:updated', data);
|
||||
|
||||
sentCount++;
|
||||
document.getElementById('sentCount').textContent = sentCount;
|
||||
logEvent(`Sent rivers:updated: ${JSON.stringify(data, null, 2)}`, 'send');
|
||||
}
|
||||
|
||||
function simulateBurgAdded() {
|
||||
const mapId = document.getElementById('quickMapId').value.trim() || 'map_' + Date.now();
|
||||
|
||||
const data = {
|
||||
mapId,
|
||||
burg: {
|
||||
i: 10,
|
||||
name: 'New Settlement',
|
||||
x: 500,
|
||||
y: 400,
|
||||
cell: 1234,
|
||||
population: 5,
|
||||
type: 'town'
|
||||
}
|
||||
};
|
||||
|
||||
socket.emit('burg:added', data);
|
||||
|
||||
sentCount++;
|
||||
document.getElementById('sentCount').textContent = sentCount;
|
||||
logEvent(`Sent burg:added: ${JSON.stringify(data, null, 2)}`, 'send');
|
||||
}
|
||||
|
||||
// Update event types list
|
||||
function updateEventTypes() {
|
||||
const listEl = document.getElementById('eventTypes');
|
||||
const sorted = Object.entries(eventTypeCounts)
|
||||
.sort((a, b) => b[1] - a[1]);
|
||||
|
||||
listEl.innerHTML = sorted.map(([name, count]) => `
|
||||
<li class="event-item">
|
||||
<span class="event-name">${name}</span>
|
||||
<span class="event-count">${count}</span>
|
||||
</li>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// Logging
|
||||
function logEvent(message, type = 'info') {
|
||||
const logEl = document.getElementById('eventLog');
|
||||
const entry = document.createElement('div');
|
||||
entry.className = `log-entry ${type}`;
|
||||
|
||||
const time = new Date().toLocaleTimeString();
|
||||
entry.innerHTML = `
|
||||
<span class="log-time">${time}</span>
|
||||
<span class="log-type ${type}">${type.toUpperCase()}</span>
|
||||
<span>${escapeHtml(message)}</span>
|
||||
`;
|
||||
|
||||
logEl.appendChild(entry);
|
||||
logEl.scrollTop = logEl.scrollHeight;
|
||||
|
||||
// Keep only last 100 entries
|
||||
while (logEl.children.length > 100) {
|
||||
logEl.removeChild(logEl.firstChild);
|
||||
}
|
||||
}
|
||||
|
||||
function clearEventLog() {
|
||||
document.getElementById('eventLog').innerHTML = '';
|
||||
sentCount = 0;
|
||||
receivedCount = 0;
|
||||
eventTypeCounts = {};
|
||||
document.getElementById('sentCount').textContent = '0';
|
||||
document.getElementById('receivedCount').textContent = '0';
|
||||
updateEventTypes();
|
||||
logEvent('Log cleared', 'info');
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Auto-connect on load
|
||||
window.addEventListener('load', () => {
|
||||
logEvent('WebSocket demo loaded. Click "Connect" to start.', 'info');
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -8098,6 +8098,9 @@
|
|||
<script defer src="modules/io/export.js?v=1.95.05"></script>
|
||||
<script defer src="modules/io/formats.js"></script>
|
||||
|
||||
<!-- External API for wiki/web UI integration -->
|
||||
<script defer src="modules/external-api.js?v=1.0.0"></script>
|
||||
|
||||
<!-- Web Components -->
|
||||
<script defer src="components/fill-box.js"></script>
|
||||
</body>
|
||||
|
|
|
|||
770
modules/external-api.js
Normal file
770
modules/external-api.js
Normal file
|
|
@ -0,0 +1,770 @@
|
|||
/**
|
||||
* External API Bridge for Fantasy Map Generator
|
||||
* Provides a clean interface for external tools (wikis, web UIs) to interact with FMG
|
||||
* @module external-api
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// ============================================================================
|
||||
// EVENT EMITTER SYSTEM
|
||||
// ============================================================================
|
||||
|
||||
class EventEmitter {
|
||||
constructor() {
|
||||
this.events = {};
|
||||
}
|
||||
|
||||
on(event, callback) {
|
||||
if (!this.events[event]) {
|
||||
this.events[event] = [];
|
||||
}
|
||||
this.events[event].push(callback);
|
||||
return () => this.off(event, callback);
|
||||
}
|
||||
|
||||
off(event, callback) {
|
||||
if (!this.events[event]) return;
|
||||
this.events[event] = this.events[event].filter(cb => cb !== callback);
|
||||
}
|
||||
|
||||
emit(event, data) {
|
||||
if (!this.events[event]) return;
|
||||
this.events[event].forEach(callback => {
|
||||
try {
|
||||
callback(data);
|
||||
} catch (error) {
|
||||
console.error(`Error in event handler for ${event}:`, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
once(event, callback) {
|
||||
const wrapper = (data) => {
|
||||
callback(data);
|
||||
this.off(event, wrapper);
|
||||
};
|
||||
this.on(event, wrapper);
|
||||
}
|
||||
}
|
||||
|
||||
const eventEmitter = new EventEmitter();
|
||||
|
||||
// ============================================================================
|
||||
// STATE MANAGEMENT & CHANGE DETECTION
|
||||
// ============================================================================
|
||||
|
||||
let isInitialized = false;
|
||||
let lastState = null;
|
||||
let changeDetectionEnabled = true;
|
||||
|
||||
function initializeChangeDetection() {
|
||||
if (isInitialized) return;
|
||||
|
||||
// Observe SVG changes for map updates
|
||||
const mapElement = document.getElementById('map');
|
||||
if (mapElement) {
|
||||
const observer = new MutationObserver(debounce(() => {
|
||||
if (changeDetectionEnabled) {
|
||||
eventEmitter.emit('map:changed', getMapState());
|
||||
}
|
||||
}, 500));
|
||||
|
||||
observer.observe(mapElement, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['d', 'transform', 'points']
|
||||
});
|
||||
}
|
||||
|
||||
isInitialized = true;
|
||||
}
|
||||
|
||||
function debounce(func, wait) {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CORE API METHODS
|
||||
// ============================================================================
|
||||
|
||||
const API = {
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// MAP LIFECYCLE
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create a new map with optional parameters
|
||||
* @param {Object} options - Generation options
|
||||
* @returns {Promise<Object>} Map state
|
||||
*/
|
||||
async createMap(options = {}) {
|
||||
try {
|
||||
changeDetectionEnabled = false;
|
||||
|
||||
if (options.seed) {
|
||||
seed = options.seed;
|
||||
}
|
||||
|
||||
// Use existing generate function
|
||||
await generate(options);
|
||||
|
||||
changeDetectionEnabled = true;
|
||||
eventEmitter.emit('map:created', getMapState());
|
||||
|
||||
return {
|
||||
success: true,
|
||||
state: getMapState()
|
||||
};
|
||||
} catch (error) {
|
||||
changeDetectionEnabled = true;
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Load map from data
|
||||
* @param {String|Blob|File} mapData - Map data to load
|
||||
* @returns {Promise<Object>} Load result
|
||||
*/
|
||||
async loadMap(mapData) {
|
||||
try {
|
||||
changeDetectionEnabled = false;
|
||||
|
||||
let file;
|
||||
if (typeof mapData === 'string') {
|
||||
// Convert string data to Blob
|
||||
file = new Blob([mapData], {type: 'text/plain'});
|
||||
} else {
|
||||
file = mapData;
|
||||
}
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
uploadMap(file, (error) => {
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
|
||||
changeDetectionEnabled = true;
|
||||
eventEmitter.emit('map:loaded', getMapState());
|
||||
|
||||
return {
|
||||
success: true,
|
||||
state: getMapState()
|
||||
};
|
||||
} catch (error) {
|
||||
changeDetectionEnabled = true;
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Save current map
|
||||
* @param {String} format - 'data' or 'blob'
|
||||
* @returns {Promise<Object>} Saved map data
|
||||
*/
|
||||
async saveMap(format = 'data') {
|
||||
try {
|
||||
const mapData = await prepareMapData();
|
||||
|
||||
if (format === 'blob') {
|
||||
return {
|
||||
success: true,
|
||||
data: new Blob([mapData], {type: 'text/plain'}),
|
||||
filename: getFileName() + '.map'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: mapData,
|
||||
filename: getFileName() + '.map'
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// DATA ACCESS
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get complete map state
|
||||
* @returns {Object} Current map state
|
||||
*/
|
||||
getMapState() {
|
||||
return getMapState();
|
||||
},
|
||||
|
||||
/**
|
||||
* Get specific data structure
|
||||
* @param {String} key - Data key (rivers, cultures, states, burgs, etc.)
|
||||
* @returns {*} Requested data
|
||||
*/
|
||||
getData(key) {
|
||||
if (!pack || !pack[key]) {
|
||||
return null;
|
||||
}
|
||||
return JSON.parse(JSON.stringify(pack[key]));
|
||||
},
|
||||
|
||||
/**
|
||||
* Get rivers data
|
||||
* @returns {Array} Rivers array
|
||||
*/
|
||||
getRivers() {
|
||||
return pack.rivers ? JSON.parse(JSON.stringify(pack.rivers)) : [];
|
||||
},
|
||||
|
||||
/**
|
||||
* Get cultures data
|
||||
* @returns {Array} Cultures array
|
||||
*/
|
||||
getCultures() {
|
||||
return pack.cultures ? JSON.parse(JSON.stringify(pack.cultures)) : [];
|
||||
},
|
||||
|
||||
/**
|
||||
* Get states data
|
||||
* @returns {Array} States array
|
||||
*/
|
||||
getStates() {
|
||||
return pack.states ? JSON.parse(JSON.stringify(pack.states)) : [];
|
||||
},
|
||||
|
||||
/**
|
||||
* Get burgs (cities/towns) data
|
||||
* @returns {Array} Burgs array
|
||||
*/
|
||||
getBurgs() {
|
||||
return pack.burgs ? JSON.parse(JSON.stringify(pack.burgs)) : [];
|
||||
},
|
||||
|
||||
/**
|
||||
* Get religions data
|
||||
* @returns {Array} Religions array
|
||||
*/
|
||||
getReligions() {
|
||||
return pack.religions ? JSON.parse(JSON.stringify(pack.religions)) : [];
|
||||
},
|
||||
|
||||
/**
|
||||
* Get markers data
|
||||
* @returns {Array} Markers array
|
||||
*/
|
||||
getMarkers() {
|
||||
return pack.markers ? JSON.parse(JSON.stringify(pack.markers)) : [];
|
||||
},
|
||||
|
||||
/**
|
||||
* Get grid data
|
||||
* @returns {Object} Grid object with cells data
|
||||
*/
|
||||
getGrid() {
|
||||
if (!grid) return null;
|
||||
|
||||
return {
|
||||
spacing: grid.spacing,
|
||||
cellsX: grid.cellsX,
|
||||
cellsY: grid.cellsY,
|
||||
features: grid.features,
|
||||
boundary: grid.boundary
|
||||
};
|
||||
},
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// MUTATIONS
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Update rivers data
|
||||
* @param {Array} rivers - New rivers array
|
||||
* @returns {Object} Update result
|
||||
*/
|
||||
updateRivers(rivers) {
|
||||
try {
|
||||
changeDetectionEnabled = false;
|
||||
|
||||
pack.rivers = rivers;
|
||||
|
||||
// Redraw rivers
|
||||
if (window.Rivers && Rivers.specify) {
|
||||
Rivers.specify();
|
||||
}
|
||||
if (typeof drawRivers === 'function') {
|
||||
drawRivers();
|
||||
}
|
||||
|
||||
changeDetectionEnabled = true;
|
||||
eventEmitter.emit('rivers:updated', pack.rivers);
|
||||
|
||||
return {success: true};
|
||||
} catch (error) {
|
||||
changeDetectionEnabled = true;
|
||||
return {success: false, error: error.message};
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update cultures data
|
||||
* @param {Array} cultures - New cultures array
|
||||
* @returns {Object} Update result
|
||||
*/
|
||||
updateCultures(cultures) {
|
||||
try {
|
||||
changeDetectionEnabled = false;
|
||||
|
||||
pack.cultures = cultures;
|
||||
|
||||
// Redraw cultures
|
||||
if (typeof drawCultures === 'function') {
|
||||
drawCultures();
|
||||
}
|
||||
|
||||
changeDetectionEnabled = true;
|
||||
eventEmitter.emit('cultures:updated', pack.cultures);
|
||||
|
||||
return {success: true};
|
||||
} catch (error) {
|
||||
changeDetectionEnabled = true;
|
||||
return {success: false, error: error.message};
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update states data
|
||||
* @param {Array} states - New states array
|
||||
* @returns {Object} Update result
|
||||
*/
|
||||
updateStates(states) {
|
||||
try {
|
||||
changeDetectionEnabled = false;
|
||||
|
||||
pack.states = states;
|
||||
|
||||
// Redraw states
|
||||
if (typeof drawStates === 'function') {
|
||||
drawStates();
|
||||
}
|
||||
if (typeof drawBorders === 'function') {
|
||||
drawBorders();
|
||||
}
|
||||
|
||||
changeDetectionEnabled = true;
|
||||
eventEmitter.emit('states:updated', pack.states);
|
||||
|
||||
return {success: true};
|
||||
} catch (error) {
|
||||
changeDetectionEnabled = true;
|
||||
return {success: false, error: error.message};
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update burgs (cities/towns) data
|
||||
* @param {Array} burgs - New burgs array
|
||||
* @returns {Object} Update result
|
||||
*/
|
||||
updateBurgs(burgs) {
|
||||
try {
|
||||
changeDetectionEnabled = false;
|
||||
|
||||
pack.burgs = burgs;
|
||||
|
||||
// Redraw burgs
|
||||
if (typeof drawBurgs === 'function') {
|
||||
drawBurgs();
|
||||
}
|
||||
|
||||
changeDetectionEnabled = true;
|
||||
eventEmitter.emit('burgs:updated', pack.burgs);
|
||||
|
||||
return {success: true};
|
||||
} catch (error) {
|
||||
changeDetectionEnabled = true;
|
||||
return {success: false, error: error.message};
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Add a new burg (city/town)
|
||||
* @param {Object} burgData - Burg properties
|
||||
* @returns {Object} Result with new burg ID
|
||||
*/
|
||||
addBurg(burgData) {
|
||||
try {
|
||||
const newId = pack.burgs.length;
|
||||
const burg = {
|
||||
i: newId,
|
||||
cell: burgData.cell || 0,
|
||||
x: burgData.x || 0,
|
||||
y: burgData.y || 0,
|
||||
name: burgData.name || Names.getCulture(burgData.culture || 0),
|
||||
population: burgData.population || 1,
|
||||
type: burgData.type || 'town',
|
||||
...burgData
|
||||
};
|
||||
|
||||
pack.burgs.push(burg);
|
||||
|
||||
if (typeof drawBurgs === 'function') {
|
||||
drawBurgs();
|
||||
}
|
||||
|
||||
eventEmitter.emit('burg:added', burg);
|
||||
|
||||
return {success: true, id: newId, burg};
|
||||
} catch (error) {
|
||||
return {success: false, error: error.message};
|
||||
}
|
||||
},
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// EXPORT
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Export map as SVG
|
||||
* @returns {String} SVG string
|
||||
*/
|
||||
exportSVG() {
|
||||
const svgElement = document.getElementById('map');
|
||||
if (!svgElement) return null;
|
||||
return svgElement.outerHTML;
|
||||
},
|
||||
|
||||
/**
|
||||
* Export map as PNG
|
||||
* @param {Number} width - Image width
|
||||
* @param {Number} height - Image height
|
||||
* @returns {Promise<Blob>} PNG blob
|
||||
*/
|
||||
async exportPNG(width = 2048, height = 2048) {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const svgElement = document.getElementById('map');
|
||||
const svgString = new XMLSerializer().serializeToString(svgElement);
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
const img = new Image();
|
||||
const blob = new Blob([svgString], {type: 'image/svg+xml;charset=utf-8'});
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
img.onload = function() {
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
URL.revokeObjectURL(url);
|
||||
canvas.toBlob((blob) => {
|
||||
resolve(blob);
|
||||
}, 'image/png');
|
||||
};
|
||||
|
||||
img.onerror = reject;
|
||||
img.src = url;
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Export specific data as JSON
|
||||
* @param {String} key - Data key
|
||||
* @returns {String} JSON string
|
||||
*/
|
||||
exportJSON(key) {
|
||||
const data = key ? API.getData(key) : getMapState();
|
||||
return JSON.stringify(data, null, 2);
|
||||
},
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// EVENTS
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Subscribe to events
|
||||
* @param {String} event - Event name
|
||||
* @param {Function} callback - Callback function
|
||||
* @returns {Function} Unsubscribe function
|
||||
*/
|
||||
on(event, callback) {
|
||||
return eventEmitter.on(event, callback);
|
||||
},
|
||||
|
||||
/**
|
||||
* Unsubscribe from events
|
||||
* @param {String} event - Event name
|
||||
* @param {Function} callback - Callback function
|
||||
*/
|
||||
off(event, callback) {
|
||||
eventEmitter.off(event, callback);
|
||||
},
|
||||
|
||||
/**
|
||||
* Subscribe to event once
|
||||
* @param {String} event - Event name
|
||||
* @param {Function} callback - Callback function
|
||||
*/
|
||||
once(event, callback) {
|
||||
eventEmitter.once(event, callback);
|
||||
},
|
||||
|
||||
/**
|
||||
* Emit custom event
|
||||
* @param {String} event - Event name
|
||||
* @param {*} data - Event data
|
||||
*/
|
||||
emit(event, data) {
|
||||
eventEmitter.emit(event, data);
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
function getMapState() {
|
||||
return {
|
||||
seed: typeof seed !== 'undefined' ? seed : null,
|
||||
mapId: typeof mapId !== 'undefined' ? mapId : null,
|
||||
timestamp: Date.now(),
|
||||
pack: pack ? {
|
||||
cultures: pack.cultures || [],
|
||||
states: pack.states || [],
|
||||
burgs: pack.burgs || [],
|
||||
rivers: pack.rivers || [],
|
||||
religions: pack.religions || [],
|
||||
provinces: pack.provinces || [],
|
||||
markers: pack.markers || []
|
||||
} : null,
|
||||
grid: grid ? {
|
||||
spacing: grid.spacing,
|
||||
cellsX: grid.cellsX,
|
||||
cellsY: grid.cellsY,
|
||||
features: grid.features
|
||||
} : null,
|
||||
options: typeof options !== 'undefined' ? options : null
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// POSTMESSAGE BRIDGE
|
||||
// ============================================================================
|
||||
|
||||
const PostMessageBridge = {
|
||||
enabled: false,
|
||||
targetOrigin: '*',
|
||||
|
||||
/**
|
||||
* Enable PostMessage communication
|
||||
* @param {String} origin - Target origin for postMessage (default: '*')
|
||||
*/
|
||||
enable(origin = '*') {
|
||||
if (this.enabled) return;
|
||||
|
||||
this.targetOrigin = origin;
|
||||
this.enabled = true;
|
||||
|
||||
// Listen for messages from parent window
|
||||
window.addEventListener('message', this.handleMessage.bind(this));
|
||||
|
||||
// Forward all events to parent
|
||||
this.setupEventForwarding();
|
||||
|
||||
console.log('[FMG API] PostMessage bridge enabled');
|
||||
},
|
||||
|
||||
/**
|
||||
* Disable PostMessage communication
|
||||
*/
|
||||
disable() {
|
||||
this.enabled = false;
|
||||
window.removeEventListener('message', this.handleMessage.bind(this));
|
||||
console.log('[FMG API] PostMessage bridge disabled');
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle incoming messages
|
||||
*/
|
||||
async handleMessage(event) {
|
||||
if (!this.enabled) return;
|
||||
|
||||
const {type, payload, requestId} = event.data;
|
||||
if (!type) return;
|
||||
|
||||
console.log('[FMG API] Received message:', type, payload);
|
||||
|
||||
try {
|
||||
let result;
|
||||
|
||||
switch(type) {
|
||||
// Map lifecycle
|
||||
case 'CREATE_MAP':
|
||||
result = await API.createMap(payload);
|
||||
break;
|
||||
case 'LOAD_MAP':
|
||||
result = await API.loadMap(payload);
|
||||
break;
|
||||
case 'SAVE_MAP':
|
||||
result = await API.saveMap(payload?.format);
|
||||
break;
|
||||
|
||||
// Data access
|
||||
case 'GET_STATE':
|
||||
result = {success: true, data: API.getMapState()};
|
||||
break;
|
||||
case 'GET_DATA':
|
||||
result = {success: true, data: API.getData(payload?.key)};
|
||||
break;
|
||||
case 'GET_RIVERS':
|
||||
result = {success: true, data: API.getRivers()};
|
||||
break;
|
||||
case 'GET_CULTURES':
|
||||
result = {success: true, data: API.getCultures()};
|
||||
break;
|
||||
case 'GET_STATES':
|
||||
result = {success: true, data: API.getStates()};
|
||||
break;
|
||||
case 'GET_BURGS':
|
||||
result = {success: true, data: API.getBurgs()};
|
||||
break;
|
||||
|
||||
// Mutations
|
||||
case 'UPDATE_RIVERS':
|
||||
result = API.updateRivers(payload);
|
||||
break;
|
||||
case 'UPDATE_CULTURES':
|
||||
result = API.updateCultures(payload);
|
||||
break;
|
||||
case 'UPDATE_STATES':
|
||||
result = API.updateStates(payload);
|
||||
break;
|
||||
case 'UPDATE_BURGS':
|
||||
result = API.updateBurgs(payload);
|
||||
break;
|
||||
case 'ADD_BURG':
|
||||
result = API.addBurg(payload);
|
||||
break;
|
||||
|
||||
// Export
|
||||
case 'EXPORT_SVG':
|
||||
result = {success: true, data: API.exportSVG()};
|
||||
break;
|
||||
case 'EXPORT_PNG':
|
||||
const blob = await API.exportPNG(payload?.width, payload?.height);
|
||||
const reader = new FileReader();
|
||||
result = await new Promise((resolve) => {
|
||||
reader.onload = () => resolve({success: true, data: reader.result});
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
break;
|
||||
case 'EXPORT_JSON':
|
||||
result = {success: true, data: API.exportJSON(payload?.key)};
|
||||
break;
|
||||
|
||||
default:
|
||||
result = {success: false, error: `Unknown message type: ${type}`};
|
||||
}
|
||||
|
||||
// Send response
|
||||
this.sendMessage('RESPONSE', result, requestId);
|
||||
} catch (error) {
|
||||
this.sendMessage('ERROR', {
|
||||
success: false,
|
||||
error: error.message
|
||||
}, requestId);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Send message to parent window
|
||||
*/
|
||||
sendMessage(type, payload, requestId = null) {
|
||||
if (!this.enabled) return;
|
||||
if (window.parent === window) return; // Not in iframe
|
||||
|
||||
window.parent.postMessage({
|
||||
type,
|
||||
payload,
|
||||
requestId,
|
||||
timestamp: Date.now()
|
||||
}, this.targetOrigin);
|
||||
},
|
||||
|
||||
/**
|
||||
* Forward API events to parent window
|
||||
*/
|
||||
setupEventForwarding() {
|
||||
const events = [
|
||||
'map:created',
|
||||
'map:loaded',
|
||||
'map:changed',
|
||||
'rivers:updated',
|
||||
'cultures:updated',
|
||||
'states:updated',
|
||||
'burgs:updated',
|
||||
'burg:added'
|
||||
];
|
||||
|
||||
events.forEach(event => {
|
||||
API.on(event, (data) => {
|
||||
this.sendMessage('EVENT', {event, data});
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// INITIALIZATION
|
||||
// ============================================================================
|
||||
|
||||
// Initialize on DOMContentLoaded
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initializeChangeDetection();
|
||||
});
|
||||
} else {
|
||||
initializeChangeDetection();
|
||||
}
|
||||
|
||||
// Auto-enable PostMessage if in iframe
|
||||
if (window.self !== window.top) {
|
||||
setTimeout(() => PostMessageBridge.enable(), 1000);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// EXPORT API
|
||||
// ============================================================================
|
||||
|
||||
window.FMG_API = API;
|
||||
window.FMG_PostMessageBridge = PostMessageBridge;
|
||||
|
||||
console.log('[FMG API] External API initialized. Access via window.FMG_API');
|
||||
console.log('[FMG API] Available methods:', Object.keys(API));
|
||||
|
||||
})();
|
||||
Loading…
Add table
Add a link
Reference in a new issue