This commit is contained in:
2025-08-04 00:10:19 -07:00
parent e912b4f87d
commit 4cfc960b1c
7 changed files with 939 additions and 236 deletions
+82
View File
@@ -0,0 +1,82 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
This is a **SillyTavern extension** called "RTS Chat Mode (Modular)" that creates a real-time strategy-style interface for interactive storytelling. The extension transforms normal chat interactions into a game-like experience with maps, entities, resources, and narrative events.
## Key Architecture Components
### Core System Files (`src/`)
- **GameStateManager.js**: Central state management with enhanced tracking for casualties, escaped animals, incidents, personnel, and map entities. Handles state persistence and updates from AI responses.
- **LLMAdapter.js**: Processes user commands and LLM responses. Handles JSON extraction from AI responses and synchronizes AI state with the map canvas.
- **PresetManager.js**: Loads and manages game scenarios/presets from JSON files.
- **EventManager.js**: Manages narrative events based on zones and threat levels.
- **PromptCompressor.js**: Builds optimized prompts for the LLM including game state and context.
### UI Components (`ui/`)
- **RTSUIController.js**: Main UI controller that manages fullscreen interface, command input, quick actions, and status displays.
- **MapCanvas.js**: Interactive canvas-based map renderer for visualizing entities and game world.
- **ResourcePanel.js**: Status panel showing game statistics and information.
### Entry Point
- **index.js**: Main extension loader that registers slash commands, handles SillyTavern integration, and manages the top bar button.
## Game Preset System
The extension uses JSON preset files (`presets/` and `maps/`) that define:
- **Scenarios**: Story setup, character descriptions, initial messages
- **Maps**: Entity positions, terrain data, zone definitions
- **Narrative Engine**: Zone-based threat tables for dynamic events
- **Features**: UI options like quickActions, resources, fogOfWar
### Key Preset Properties
- `narrativeEngine.zones[]`: Defines map areas with associated threat tables
- `narrativeEngine.threatTables`: Collections of events by threat level (low/medium/high)
- `features.quickActions[]`: Configurable action buttons with commands
- `initialState`: Starting game conditions and threat levels
## Development Commands
The extension integrates with SillyTavern's existing systems and doesn't have separate build/test commands. Development workflow:
1. **Testing**: Load SillyTavern and activate the extension via the top bar button
2. **Debugging**: Check browser console for logs prefixed with "RTS-MODE:" or "RTS:"
3. **Preset Testing**: Modify JSON files in `presets/` and `maps/` directories
## Slash Commands
- `/rts-start` - Resets game state with selected preset
- `/rts-cmd <command>` - Sends action to the game master
- `/rts-ui` - Toggles fullscreen RTS interface
- `/rts-observe`, `/rts-move`, `/rts-hide`, `/rts-interact` - Quick actions
## State Management Architecture
The system uses a multi-layered state approach:
1. **Game State** (GameStateManager): Core game logic, entities, tracking systems
2. **Map State**: Visual representation with entity positions and visibility
3. **AI Response State**: Structured data from LLM containing narrative and updates
4. **UI State**: Display state for panels, logs, and user interactions
### State Synchronization Flow
1. User action → LLMAdapter → AI response → GameStateManager.setState()
2. State changes → UI updates → Map canvas refresh → Status panel updates
3. Entity updates → Map entity sync → Visual position updates
## Extension Integration
This extension follows SillyTavern's extension patterns:
- Uses `renderExtensionTemplateAsync()` for HTML templates
- Integrates with `generateQuietPrompt()` for LLM communication
- Respects SillyTavern's settings system via `extension_settings`
- Registers with the extension system through `manifest.json`
## Important Implementation Notes
- **Entity Management**: The system maintains entity data in both GameStateManager and map canvas, requiring careful synchronization
- **AI Response Parsing**: Handles multiple JSON formats and reasoning model outputs with thought blocks
- **Map Coordinate System**: Uses x,y grid coordinates for entity positioning
- **Event System**: Custom events (`rts-narrative-update`, `rts-map-update`) coordinate between UI components
- **Fullscreen Mode**: Completely replaces SillyTavern UI when active, managed by RTSUIController
-155
View File
@@ -1,155 +0,0 @@
<div id="rts-main-container" class="wide100p height100p">
<!-- RTS Header -->
<div id="rts-header" class="fa-solid fa-grip drag-grabber"></div>
<!-- Main RTS Game Area -->
<div id="rts-game-area" class="flex-container wide100p height100p">
<!-- Left Panel: Resources and Units -->
<div id="rts-left-panel" class="rts-panel flex-container flexFlowColumn">
<div class="rts-panel-header">
<h3>Game Status</h3>
</div>
<!-- Status Overview Section -->
<div class="rts-section">
<h4 class="rts-section-title">Status Overview</h4>
<div id="rts-overview-stats">
<div class="stat-item">Turn: <span id="turn-counter">1</span></div>
<div class="stat-item">Zone: <span id="current-zone">Unknown</span></div>
<div class="stat-item">Threat: <span id="threat-level">Low</span></div>
</div>
</div>
<!-- Casualties Section -->
<div class="rts-section">
<h4 class="rts-section-title">Casualties</h4>
<div id="rts-casualties-stats">
<div class="stat-item">Total Deaths: <span id="total-deaths">0</span></div>
<div class="stat-item">Recent: <span id="recent-deaths">0</span></div>
</div>
<div id="rts-recent-casualties" class="scrollable-list"></div>
</div>
<!-- Escaped Animals Section -->
<div class="rts-section">
<h4 class="rts-section-title">Escaped Animals</h4>
<div id="rts-animals-stats">
<div class="stat-item">Active Threats: <span id="active-animals">0</span></div>
</div>
<div id="rts-escaped-animals" class="scrollable-list"></div>
</div>
<!-- Personnel Section -->
<div class="rts-section">
<h4 class="rts-section-title">Personnel</h4>
<div id="rts-personnel-stats">
<div class="stat-item">Alive: <span id="alive-personnel">0</span></div>
<div class="stat-item">Injured: <span id="injured-personnel">0</span></div>
<div class="stat-item">Missing: <span id="missing-personnel">0</span></div>
</div>
<div id="rts-personnel-list" class="scrollable-list"></div>
</div>
<!-- Active Incidents Section -->
<div class="rts-section">
<h4 class="rts-section-title">Active Incidents</h4>
<div id="rts-incidents-stats">
<div class="stat-item">Emergency: <span id="emergency-incidents">0</span></div>
<div class="stat-item">Ongoing: <span id="ongoing-incidents">0</span></div>
</div>
<div id="rts-incidents-list" class="scrollable-list"></div>
</div>
<!-- Threat Section -->
<div class="rts-section">
<h4 class="rts-section-title">Threat</h4>
<div id="rts-threat" class="rts-threat-display">
<div class="rts-threat-item">
<i class="fa-solid fa-location-crosshairs"></i>
<span>Zone: <span id="rts-current-zone">Entrance</span></span>
</div>
<div class="rts-threat-item">
<i class="fa-solid fa-triangle-exclamation"></i>
<span>Threat: <span id="rts-threat-level">Low</span></span>
</div>
</div>
</div>
</div>
<!-- Center: Game Map -->
<div id="rts-map-container" class="flex1 flex-container flexFlowColumn">
<div class="rts-map-header">
<h3>Battle Map</h3>
<div class="rts-map-controls">
<button id="rts-zoom-in" class="menu_button menu_button_icon" title="Zoom In">
<i class="fa-solid fa-magnifying-glass-plus"></i>
</button>
<button id="rts-zoom-out" class="menu_button menu_button_icon" title="Zoom Out">
<i class="fa-solid fa-magnifying-glass-minus"></i>
</button>
<button id="rts-center-map" class="menu_button menu_button_icon" title="Center Map">
<i class="fa-solid fa-crosshairs"></i>
</button>
</div>
</div>
<div id="rts-map-wrapper" class="flex1 rts-map-wrapper">
<canvas id="rts-game-map" width="800" height="600"></canvas>
</div>
<div id="rts-map-overlay" class="rts-map-overlay">
<!-- Selected unit/building info will appear here -->
</div>
<div class="rts-map-footer">
<div class="rts-turn-info">
<span>Turn: <span id="rts-current-turn">1</span></span>
<span class="rts-separator">|</span>
<span>Season: <span id="rts-season">Spring</span></span>
</div>
</div>
</div>
<!-- Right Panel: Commands and Log -->
<div id="rts-right-panel" class="rts-panel flex-container flexFlowColumn">
<div class="rts-panel-header">
<h3>Command Center</h3>
</div>
<!-- Quick Actions -->
<div class="rts-section">
<h4 class="rts-section-title">Quick Actions</h4>
<div id="rts-quick-actions" class="rts-quick-actions">
<!-- Quick action buttons will be dynamically inserted here -->
</div>
</div>
<!-- Command Input -->
<div class="rts-section flex1">
<h4 class="rts-section-title">Command Input</h4>
<div class="rts-command-input-area">
<textarea id="rts-command-input"
placeholder="Enter your strategy commands here... (e.g., 'Build a barracks north of the town hall' or 'Send 3 warriors to explore the eastern forest')"
class="text_pole textarea_compact"
rows="3"></textarea>
<div class="rts-command-controls">
<button id="rts-execute-btn" class="menu_button menu_button_bold">
<i class="fa-solid fa-play"></i> Execute Turn
</button>
<button id="rts-clear-btn" class="menu_button menu_button_icon" title="Clear Input">
<i class="fa-solid fa-eraser"></i>
</button>
</div>
</div>
</div>
<!-- Game Log -->
<div class="rts-section flex1">
<h4 class="rts-section-title">Battle Log</h4>
<div id="rts-game-log" class="rts-game-log scrollY">
<div class="rts-log-entry rts-log-system">
<span class="rts-log-timestamp">[Turn 1]</span>
<span class="rts-log-message">Welcome to RTS Mode! Your settlement awaits your commands.</span>
</div>
</div>
</div>
</div>
</div>
</div>
+382 -23
View File
@@ -25,30 +25,272 @@ const extensionName = 'RTS Chat Mode';
// RTS-mode specific settings
const rtsSettings = {
structuredOutput: false,
autoSchemaUpdates: true,
contentFilter: true,
};
// Placeholder for the JSON schema
const RTS_JSON_SCHEMA = {
"type": "object",
"properties": {
"narrative": {
"type": "string",
"description": "The story portion of the turn."
},
"state": {
"type": "object",
"description": "The complete game state."
},
"entityUpdates": {
"type": "array",
"description": "A list of entities that have changed.",
"items": {
"type": "object"
// Export settings for use in other modules
export { rtsSettings };
/**
* Generates a comprehensive JSON schema based on the current game state structure
* @param {object} gameState - Current game state to base schema on
* @returns {object} Complete JSON schema for AI responses
*/
function generateRTSJSONSchema(gameState) {
// Ensure gameState is a valid object
const safeGameState = gameState && typeof gameState === 'object' ? gameState : {};
return {
"type": "object",
"properties": {
"narrative": {
"type": "string",
"description": "Brief atmospheric narrative (2-3 sentences max) focusing on immediate sensory details and events."
},
"state": {
"type": "object",
"description": "Complete updated game state",
"properties": {
"turn": {
"type": "integer",
"description": "Current turn number"
},
"currentZone": {
"type": "string",
"description": "Current zone/area the player is in"
},
"threatLevel": {
"type": "string",
"enum": ["none", "low", "medium", "high", "extreme"],
"description": "Current threat level"
},
"lastEvent": {
"type": ["string", "null"],
"description": "Description of the last significant event"
},
"casualties": {
"type": "object",
"properties": {
"total": { "type": "integer", "minimum": 0 },
"recent": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": { "type": "string" },
"cause": { "type": "string" },
"location": { "type": "string" },
"perpetrator": { "type": "string" },
"turn": { "type": "integer" },
"description": { "type": "string" }
},
"required": ["name", "cause", "location", "perpetrator", "turn"]
}
},
"byZone": { "type": "object" },
"byAnimal": { "type": "object" }
},
"required": ["total", "recent", "byZone", "byAnimal"]
},
"escapedAnimals": {
"type": "object",
"properties": {
"total": { "type": "integer", "minimum": 0 },
"active": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": { "type": "string" },
"name": { "type": "string" },
"type": { "type": "string" },
"currentLocation": { "type": "string" },
"escapedFrom": { "type": "string" },
"threat": { "type": "string", "enum": ["low", "medium", "high", "extreme"] },
"lastSeen": { "type": "integer" },
"behavior": { "type": "string" }
},
"required": ["id", "name", "type", "currentLocation", "threat", "behavior"]
}
},
"byType": { "type": "object" },
"byZone": { "type": "object" }
},
"required": ["total", "active", "byType", "byZone"]
},
"activeIncidents": {
"type": "object",
"properties": {
"emergency": {
"type": "array",
"items": {
"type": "object",
"properties": {
"type": { "type": "string" },
"description": { "type": "string" },
"location": { "type": "string" },
"priority": { "type": "string", "enum": ["emergency", "high", "medium", "low"] },
"turn": { "type": "integer" }
},
"required": ["type", "description", "location", "priority"]
}
},
"ongoing": {
"type": "array",
"items": {
"type": "object",
"properties": {
"type": { "type": "string" },
"description": { "type": "string" },
"location": { "type": "string" },
"priority": { "type": "string", "enum": ["emergency", "high", "medium", "low"] },
"turn": { "type": "integer" }
},
"required": ["type", "description", "location", "priority"]
}
},
"resolved": { "type": "array" }
},
"required": ["emergency", "ongoing", "resolved"]
},
"personnel": {
"type": "object",
"properties": {
"alive": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": { "type": "string" },
"name": { "type": "string" },
"type": { "type": "string", "enum": ["visitor", "staff", "keeper", "veterinarian"] },
"position": {
"type": "object",
"properties": {
"x": { "type": "number" },
"y": { "type": "number" }
},
"required": ["x", "y"]
},
"status": { "type": "string" },
"lastSeen": { "type": "integer" },
"description": { "type": "string" }
},
"required": ["id", "name", "type", "position", "status"]
}
},
"injured": { "type": "array" },
"missing": { "type": "array" },
"evacuated": { "type": "array" }
},
"required": ["alive", "injured", "missing", "evacuated"]
},
"playerPosition": {
"type": "object",
"properties": {
"x": { "type": "number" },
"y": { "type": "number" }
},
"required": ["x", "y"],
"description": "Player's current map coordinates"
},
"visibleEntities": {
"type": "array",
"description": "All entities currently visible to the player (within ~5 tiles)",
"items": {
"type": "object",
"properties": {
"id": { "type": "string" },
"name": { "type": "string" },
"type": { "type": "string" },
"position": {
"type": "object",
"properties": {
"x": { "type": "number" },
"y": { "type": "number" }
},
"required": ["x", "y"]
},
"status": { "type": "string" },
"action": { "type": "string", "description": "What the entity is currently doing" },
"description": { "type": "string" }
},
"required": ["id", "name", "type", "position"]
}
},
"environment": {
"type": "object",
"properties": {
"timeOfDay": { "type": "string", "enum": ["evening", "night", "dawn", "morning", "afternoon"] },
"weather": { "type": "string", "enum": ["clear", "rain", "storm", "fog"] },
"powerStatus": { "type": "string", "enum": ["full", "partial", "none"] },
"evacuationStatus": { "type": "string", "enum": ["open", "blocked", "chaos"] },
"zooStatus": { "type": "string", "enum": ["normal", "incident", "chaos", "lockdown"] }
},
"required": ["timeOfDay", "weather", "powerStatus", "evacuationStatus", "zooStatus"]
}
},
"required": ["turn", "currentZone", "threatLevel", "casualties", "escapedAnimals", "activeIncidents", "personnel", "playerPosition", "visibleEntities", "environment"]
},
"entityUpdates": {
"type": "array",
"description": "Specific entity position and status changes for map synchronization",
"items": {
"type": "object",
"properties": {
"id": { "type": "string" },
"x": { "type": "number" },
"y": { "type": "number" },
"status": { "type": "string" },
"action": { "type": "string" }
},
"required": ["id", "x", "y"]
}
}
}
},
"required": ["narrative", "state"]
};
},
"required": ["narrative", "state"],
"additionalProperties": false
};
}
// Dynamic schema - will be populated when needed
let RTS_JSON_SCHEMA = generateRTSJSONSchema();
function getModelName(settings) {
if (!settings) return undefined;
const source = settings.chat_completion_source;
if (!source) {
// Fallback for older structures or different contexts
return settings.model;
}
// Maps chat completion source to its corresponding model property name
const modelPropertyMap = {
'openai': 'openai_model',
'google': 'google_model',
'vertexai': 'vertexai_model',
'claude': 'claude_model',
'openrouter': 'openrouter_model',
'mistralai': 'mistralai_model',
'cohere': 'cohere_model',
'groq': 'groq_model',
'deepseek': 'deepseek_model',
'nanogpt': 'nanogpt_model',
'xai': 'xai_model',
'ai21': 'ai21_model',
'aimlapi': 'aimlapi_model',
'moonshot': 'moonshot_model',
'perplexity': 'perplexity_model',
'pollinations': 'pollinations_model',
'custom': 'custom_model',
};
const modelPropertyName = modelPropertyMap[source];
return modelPropertyName ? settings[modelPropertyName] : settings.model;
}
function isModelCompatible(chatCompletionSettings) {
@@ -59,8 +301,14 @@ function isModelCompatible(chatCompletionSettings) {
'gemini',
];
const modelName = chatCompletionSettings.model.toLowerCase();
return compatibleModels.some(m => modelName.includes(m));
const modelName = getModelName(chatCompletionSettings);
if (!modelName) {
console.warn('RTS: Could not determine model name from settings', chatCompletionSettings);
return false;
}
const modelNameLower = modelName.toLowerCase();
return compatibleModels.some(m => modelNameLower.includes(m));
}
async function loadRtsSettings() {
@@ -69,6 +317,14 @@ async function loadRtsSettings() {
// Set UI elements
$('#rts-structured-output-toggle').prop('checked', rtsSettings.structuredOutput);
$('#rts-content-filter-toggle').prop('checked', rtsSettings.contentFilter);
// Auto-schema is enabled by default
if (rtsSettings.autoSchemaUpdates === undefined) {
rtsSettings.autoSchemaUpdates = true;
extension_settings[extensionId].autoSchemaUpdates = true;
saveSettingsDebounced();
}
}
function onStructuredOutputToggle(event) {
@@ -78,6 +334,29 @@ function onStructuredOutputToggle(event) {
saveSettingsDebounced();
}
function onContentFilterToggle(event) {
const value = Boolean($(event.target).prop('checked'));
rtsSettings.contentFilter = value;
extension_settings[extensionId].contentFilter = value;
saveSettingsDebounced();
}
function onAutoSchemaToggle(enabled) {
rtsSettings.autoSchemaUpdates = enabled;
extension_settings[extensionId].autoSchemaUpdates = enabled;
saveSettingsDebounced();
console.log('RTS: Auto-schema updates', enabled ? 'enabled' : 'disabled');
}
// Function to get current settings (for dropdown sync)
function getRTSSettings() {
return { ...rtsSettings };
}
// Export functions for use in RTSUIController
export { getRTSSettings, onAutoSchemaToggle, onStructuredOutputToggle };
let root;
let topButton;
let uiMounted = false;
@@ -157,6 +436,9 @@ async function onRtsStartCommand() {
GameStateManager.reset();
// Update schema for fresh game state
updateSchemaForGameState(GameStateManager.getState());
// If RTS UI is active, update it
if (rtsUI.isActive()) {
rtsUI.updateUI();
@@ -209,6 +491,7 @@ jQuery(async function() {
// Add event listeners
$('#rts-structured-output-toggle').on('input', onStructuredOutputToggle);
$('#rts-content-filter-toggle').on('input', onContentFilterToggle);
// Register slash commands
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
@@ -257,13 +540,89 @@ jQuery(async function() {
helpString: 'Quick action: Interact with nearby objects or people.',
}));
// Listen for game state updates to keep schema current
document.addEventListener('rts-narrative-update', (event) => {
if (event instanceof CustomEvent && rtsSettings.autoSchemaUpdates && event.detail && event.detail.state) {
updateSchemaForGameState(event.detail.state);
}
});
console.log('RTS Chat Mode: Extension initialized successfully');
});
/**
* Updates the JSON schema based on current game state to optimize for what's actually needed
* @param {object} gameState - Current game state
*/
function updateSchemaForGameState(gameState) {
if (!gameState) {
RTS_JSON_SCHEMA = generateRTSJSONSchema();
return;
}
// Analyze current state to determine what fields are actually needed
const schemaOptimizations = {
// Always require core fields
coreFields: ["turn", "currentZone", "threatLevel", "playerPosition", "visibleEntities", "environment"],
// Conditionally require tracking fields based on game progression
conditionalFields: []
};
// If game has progressed beyond setup, require tracking systems
if (gameState.turn > 0) {
schemaOptimizations.conditionalFields.push("casualties", "escapedAnimals", "activeIncidents");
}
// Always require personnel since we track visitors and staff
schemaOptimizations.conditionalFields.push("personnel");
// Generate optimized schema
const baseSchema = generateRTSJSONSchema(gameState);
// Update required fields in state object based on analysis
const stateRequired = [...schemaOptimizations.coreFields, ...schemaOptimizations.conditionalFields];
baseSchema.properties.state.required = stateRequired;
// Add contextual descriptions based on current state
if (gameState.turn === 0) {
baseSchema.properties.narrative.description = "Brief description of the peaceful zoo atmosphere before any incidents occur.";
baseSchema.properties.state.properties.casualties.description = "Should remain at zero totals for pre-incident state.";
baseSchema.properties.state.properties.escapedAnimals.description = "Should remain empty for pre-incident state.";
} else {
baseSchema.properties.narrative.description = "Brief atmospheric narrative (2-3 sentences max) focusing on immediate danger, actions, and consequences.";
}
// Optimize entity schema based on visible entities
if (gameState.visibleEntities && gameState.visibleEntities.length > 0) {
// Add more specific validation for entity types we've seen
const entityTypes = [...new Set(gameState.visibleEntities.map(e => e.type))];
if (entityTypes.length > 0) {
baseSchema.properties.state.properties.visibleEntities.items.properties.type.enum = entityTypes;
}
}
RTS_JSON_SCHEMA = baseSchema;
console.log('RTS: Updated JSON schema for turn', gameState.turn, 'with required fields:', stateRequired);
}
eventSource.on(event_types.CHAT_COMPLETION_SETTINGS_READY, (data) => {
const { chatCompletionSettings } = SillyTavern.getContext();
console.log('RTS: Chat completion settings ready:', chatCompletionSettings);
console.log('RTS: Structured output setting:', rtsSettings.structuredOutput);
console.log('RTS: Model compatibility check:', isModelCompatible(chatCompletionSettings));
if (rtsSettings.structuredOutput && isModelCompatible(chatCompletionSettings)) {
// Update schema based on current game state before sending
try {
const currentState = GameStateManager.getState();
updateSchemaForGameState(currentState);
} catch (error) {
console.warn('RTS: Could not update schema with current state:', error);
RTS_JSON_SCHEMA = generateRTSJSONSchema();
}
data.responseMimeType = "application/json";
data.responseSchema = RTS_JSON_SCHEMA;
console.log('RTS: Using structured output with dynamic schema');
}
});
+45
View File
@@ -98,6 +98,51 @@
<h3>Action Center</h3>
</div>
<!-- Settings -->
<div class="rts-section">
<details>
<summary class="rts-section-title">Settings</summary>
<div style="padding: 10px 0;">
<div>
<h2>Preset Selection</h2>
<p>Select a scenario preset to begin.</p>
<select id="rts-preset-select">
<option value="/scripts/extensions/rts-mode/presets/zoo_escape.json">Zoo Escape</option>
<option value="/scripts/extensions/rts-mode/presets/classic_rts.json">Classic RTS</option>
</select>
</div>
</div>
<div style="padding: 10px 0;">
<div>
<h2>Content Filter</h2>
<p>Enable to allow graphic descriptions of violence and sexual themes.</p>
<div class="rts-settings-toggle">
<label for="rts-content-filter-toggle" class="rts-toggle-label">Explicit Content</label>
<label class="rts-switch">
<input type="checkbox" id="rts-content-filter-toggle" checked>
<span class="rts-slider round"></span>
</label>
</div>
</div>
</div>
<div style="padding: 10px 0;">
<div>
<h2>Model Settings</h2>
<p>Use structured output for models that support it (e.g., OpenAI, Gemini, Anthropic).</p>
<div class="rts-settings-toggle">
<label for="rts-structured-output-toggle" class="rts-toggle-label">Structured Output</label>
<label class="rts-switch">
<input type="checkbox" id="rts-structured-output-toggle">
<span class="rts-slider round"></span>
</label>
</div>
</div>
</div>
</details>
</div>
<!-- Quick Actions -->
<div class="rts-section">
<h4 class="rts-section-title">Quick Actions</h4>
+6 -1
View File
@@ -111,7 +111,12 @@ export async function sendTurn(userCmd) {
console.log('RTS: Entity updates in response:', responseJson.entityUpdates?.length || 0);
GameStateManager.setState(responseJson.state);
const narrative = responseJson.narrative.trim();
let narrative = responseJson.narrative;
if (typeof narrative !== 'string') {
// Coerce to string if it's not one, to prevent trim() error
narrative = String(narrative);
}
narrative = narrative.trim();
// Handle entity updates if provided
if (responseJson.entityUpdates && Array.isArray(responseJson.entityUpdates)) {
+149 -4
View File
@@ -13,14 +13,158 @@
flex-direction: column;
}
#rts-header {
height: 20px;
/* Updated Header Styling */
.rts-header-bar {
height: 40px;
background: var(--SmartThemeBlurTintColor);
cursor: move;
border-bottom: 1px solid var(--SmartThemeBorderColor);
display: flex;
align-items: center;
justify-content: center;
justify-content: space-between;
padding: 0 12px;
position: relative;
}
.rts-header-left {
display: flex;
align-items: center;
gap: 12px;
}
.rts-header-left .drag-grabber {
cursor: move;
color: var(--SmartThemeBodyColor);
opacity: 0.7;
}
.rts-header-title {
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--SmartThemeBodyColor);
}
.rts-header-right {
display: flex;
align-items: center;
gap: 8px;
}
/* Settings Dropdown */
.rts-settings-dropdown {
position: absolute;
top: 40px;
right: 12px;
background: var(--SmartThemeBlurTintColor);
border: 1px solid var(--SmartThemeBorderColor);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
z-index: 1001;
min-width: 320px;
max-width: 400px;
}
.rts-settings-content {
padding: 16px;
}
.rts-settings-content h3 {
margin: 0 0 16px 0;
font-size: 16px;
font-weight: 600;
color: var(--SmartThemeBodyColor);
border-bottom: 1px solid var(--SmartThemeBorderColor);
padding-bottom: 8px;
}
.rts-setting-group {
margin-bottom: 16px;
}
.rts-setting-label {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 4px;
font-weight: 500;
color: var(--SmartThemeBodyColor);
cursor: pointer;
}
.rts-setting-description {
margin: 4px 0 0 0;
font-size: 12px;
color: var(--SmartThemeQuoteColor);
line-height: 1.3;
}
.rts-setting-select {
width: 100%;
padding: 6px 8px;
border: 1px solid var(--SmartThemeBorderColor);
border-radius: 4px;
background: var(--SmartThemeEmColor);
color: var(--SmartThemeBodyColor);
font-size: 13px;
margin-bottom: 4px;
}
/* Custom Toggle Switch */
.rts-toggle-switch {
position: relative;
display: inline-block;
width: 44px;
height: 24px;
}
.rts-toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.rts-toggle-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--SmartThemeBorderColor);
transition: .3s;
border-radius: 24px;
}
.rts-toggle-slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background-color: white;
transition: .3s;
border-radius: 50%;
}
input:checked + .rts-toggle-slider {
background-color: var(--SmartThemeAccentColor);
}
input:checked + .rts-toggle-slider:before {
transform: translateX(20px);
}
.rts-setting-actions {
display: flex;
gap: 8px;
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid var(--SmartThemeBorderColor);
}
.rts-setting-actions button {
flex: 1;
}
#rts-game-area {
@@ -1140,3 +1284,4 @@ input:checked + .rts-slider:before {
.rts-slider.round:before {
border-radius: 50%;
}
+275 -53
View File
@@ -1,11 +1,23 @@
import { renderExtensionTemplateAsync } from '../../../extensions.js';
import { RTSMapCanvas } from './MapCanvas.js';
import {
renderExtensionTemplateAsync
} from '../../../extensions.js';
import {
RTSMapCanvas
} from './MapCanvas.js';
import GameStateManager from '../src/GameStateManager.js';
import PresetManager from '../src/PresetManager.js';
import EventManager from '../src/EventManager.js';
import { sendTurn } from '../src/LLMAdapter.js';
import { updateResourcePanel } from './ResourcePanel.js';
import {
sendTurn
} from '../src/LLMAdapter.js';
import {
updateResourcePanel
} from './ResourcePanel.js';
import {
getRTSSettings,
onAutoSchemaToggle,
onStructuredOutputToggle
} from '../index.js';
export class RTSUIController {
constructor() {
@@ -28,6 +40,11 @@ export class RTSUIController {
this.handleSelectionChange = this.handleSelectionChange.bind(this);
this.handleNarrativeUpdate = this.handleNarrativeUpdate.bind(this);
this.handleZoneChange = this.handleZoneChange.bind(this);
this.handleSettingsToggle = this.handleSettingsToggle.bind(this);
this.handleSettingChange = this.handleSettingChange.bind(this);
this.handleRestartGame = this.handleRestartGame.bind(this);
this.handleCloseSettings = this.handleCloseSettings.bind(this);
this.handleExitRTS = this.handleExitRTS.bind(this);
}
async enterFullscreen() {
@@ -73,8 +90,8 @@ export class RTSUIController {
this.setupEventListeners();
this.mapCanvas.canvas.addEventListener('selectionChanged', this.handleSelectionChange);
const presetSelect = /** @type {HTMLSelectElement} */ (document.getElementById('rts-preset-select'));
const selectedPreset = presetSelect ? presetSelect.value : '/scripts/extensions/rts-mode/presets/zoo_escape.json';
const presetSelect = /** @type {HTMLSelectElement} */ (document.getElementById('rts-dropdown-preset-select'));
const selectedPreset = (presetSelect && presetSelect.value) ? presetSelect.value : '/scripts/extensions/rts-mode/presets/zoo_escape.json';
await PresetManager.loadPreset(selectedPreset);
GameStateManager.reset();
const preset = PresetManager.getPreset();
@@ -111,6 +128,22 @@ export class RTSUIController {
document.getElementById('rts-execute-btn')?.addEventListener('click', this.handleExecuteCommand);
document.getElementById('rts-clear-btn')?.addEventListener('click', this.handleClearCommand);
// Header Controls
document.getElementById('rts-settings-btn')?.addEventListener('click', this.handleSettingsToggle);
document.getElementById('rts-exit-btn')?.addEventListener('click', this.handleExitRTS);
// Settings Dropdown Controls
document.getElementById('rts-close-settings')?.addEventListener('click', this.handleCloseSettings);
document.getElementById('rts-restart-game')?.addEventListener('click', this.handleRestartGame);
// Settings Toggle Listeners
document.getElementById('rts-structured-output-toggle')?.addEventListener('change', this.handleSettingChange);
document.getElementById('rts-content-filter-toggle')?.addEventListener('change', this.handleSettingChange);
document.getElementById('rts-dropdown-structured-output')?.addEventListener('change', this.handleSettingChange);
document.getElementById('rts-dropdown-content-filter')?.addEventListener('change', this.handleSettingChange);
document.getElementById('rts-dropdown-auto-schema')?.addEventListener('change', this.handleSettingChange);
document.getElementById('rts-dropdown-preset-select')?.addEventListener('change', this.handleSettingChange);
// Map Controls
document.getElementById('rts-zoom-in')?.addEventListener('click', () => this.handleMapControls('zoomIn'));
document.getElementById('rts-zoom-out')?.addEventListener('click', () => this.handleMapControls('zoomOut'));
@@ -127,6 +160,16 @@ export class RTSUIController {
});
}
// Click outside to close settings dropdown
document.addEventListener('click', (e) => {
const dropdown = document.getElementById('rts-settings-dropdown');
const settingsBtn = document.getElementById('rts-settings-btn');
if (dropdown && settingsBtn && dropdown.style.display === 'block' &&
!dropdown.contains(/** @type {Node} */ (e.target)) && !settingsBtn.contains(/** @type {Node} */ (e.target))) {
this.closeSettingsDropdown();
}
});
// Narrative and Zone Listeners
document.addEventListener('rts-narrative-update', this.handleNarrativeUpdate);
document.addEventListener('rts-zone-changed', this.handleZoneChange);
@@ -147,7 +190,7 @@ export class RTSUIController {
console.error('RTS: Quick actions not found in preset');
return;
}
const actionTemplate = quickActions.find(a => a.id === action);
if (actionTemplate && actionTemplate.command) {
commandInput.value = actionTemplate.command;
@@ -162,7 +205,7 @@ export class RTSUIController {
};
commandInput.value = fallbackCommands[action] || `Perform ${action} action`;
}
commandInput.focus();
commandInput.select();
} catch (error) {
@@ -186,13 +229,20 @@ export class RTSUIController {
if (!quickActions || !Array.isArray(quickActions)) {
console.warn('RTS: No quick actions found in preset, creating fallback actions');
// Create fallback actions
const fallbackActions = [
{ id: 'observe', label: 'Observe' },
{ id: 'move', label: 'Move' },
{ id: 'hide', label: 'Hide' },
{ id: 'interact', label: 'Interact' }
];
const fallbackActions = [{
id: 'observe',
label: 'Observe'
}, {
id: 'move',
label: 'Move'
}, {
id: 'hide',
label: 'Hide'
}, {
id: 'interact',
label: 'Interact'
}];
fallbackActions.forEach(action => {
const button = document.createElement('button');
button.id = `rts-${action.id}-btn`;
@@ -263,9 +313,15 @@ export class RTSUIController {
handleMapControls(action) {
if (!this.mapCanvas) return;
switch (action) {
case 'zoomIn': this.mapCanvas.zoomIn(); break;
case 'zoomOut': this.mapCanvas.zoomOut(); break;
case 'center': this.mapCanvas.centerMap(); break;
case 'zoomIn':
this.mapCanvas.zoomIn();
break;
case 'zoomOut':
this.mapCanvas.zoomOut();
break;
case 'center':
this.mapCanvas.centerMap();
break;
}
}
@@ -283,7 +339,7 @@ export class RTSUIController {
updateUI() {
if (!this.mapData) return;
// Update the new status panel with current game state
const gameState = GameStateManager.getState();
try {
@@ -291,7 +347,7 @@ export class RTSUIController {
} catch (error) {
console.warn('RTS: Error updating resource panel from RTSUIController:', error);
}
// Keep the existing zoo-specific updates
this.updateZooStatusDisplay();
this.updateEscapedAnimalsDisplay();
@@ -307,7 +363,10 @@ export class RTSUIController {
handleEntitySelection(entity) {
this.mapCanvas.clearSelection();
this.mapCanvas.selectElement({ type: 'entity', data: entity });
this.mapCanvas.selectElement({
type: 'entity',
data: entity
});
this.updateEscapedAnimalsDisplay();
}
@@ -374,14 +433,14 @@ export class RTSUIController {
const state = GameStateManager.getState();
if (state.mapState && state.mapState.mapData && state.mapState.mapData.entities) {
// Show visitors, staff, keepers, veterinarians - no guards
const people = state.mapState.mapData.entities.filter(e =>
const people = state.mapState.mapData.entities.filter(e =>
['visitor', 'staff', 'keeper', 'veterinarian'].includes(e.type)
);
people.forEach((person, index) => {
const element = document.createElement('div');
element.className = 'rts-person-item';
// Choose appropriate icon based on type
let icon = 'fa-user';
let color = '#6b7280';
@@ -398,14 +457,14 @@ export class RTSUIController {
icon = 'fa-user-doctor';
color = '#8b5cf6';
}
// Add escape status for visitors
let escapeStatus = '';
if (person.type === 'visitor') {
const escapeChance = this.calculateEscapeChance(person);
escapeStatus = `<span class="rts-escape-status rts-escape-${escapeChance.level}">${escapeChance.text}</span>`;
}
element.innerHTML = `
<i class="fa-solid ${icon}" style="color: ${color};"></i>
<span class="rts-person-name">${person.name || `${person.type} ${index + 1}`}</span>
@@ -425,50 +484,76 @@ export class RTSUIController {
element.textContent = value;
}
}
calculateEscapeChance(person) {
const state = GameStateManager.getState();
const threatLevel = state.threatLevel || 'none';
const playerPos = state.mapState?.playerPosition || { x: 0, y: 0 };
const playerPos = state.mapState?.playerPosition || {
x: 0,
y: 0
};
// Calculate distance from player
const distance = Math.sqrt(
Math.pow(person.x - playerPos.x, 2) +
Math.pow(person.x - playerPos.x, 2) +
Math.pow(person.y - playerPos.y, 2)
);
// Base escape chance based on threat level
let escapeScore = 0;
switch (threatLevel) {
case 'none': escapeScore = 95; break;
case 'low': escapeScore = 75; break;
case 'medium': escapeScore = 50; break;
case 'high': escapeScore = 25; break;
case 'extreme': escapeScore = 5; break;
default: escapeScore = 50;
case 'none':
escapeScore = 95;
break;
case 'low':
escapeScore = 75;
break;
case 'medium':
escapeScore = 50;
break;
case 'high':
escapeScore = 25;
break;
case 'extreme':
escapeScore = 5;
break;
default:
escapeScore = 50;
}
// Adjust based on distance from player (closer = safer)
if (distance <= 3) escapeScore += 20;
else if (distance >= 10) escapeScore -= 30;
// Adjust based on person status
if (person.status === 'injured') escapeScore -= 40;
else if (person.status === 'panicked') escapeScore -= 20;
else if (person.status === 'hiding') escapeScore += 10;
// Clamp between 5-95
escapeScore = Math.max(5, Math.min(95, escapeScore));
// Determine level and text
if (escapeScore >= 80) {
return { level: 'high', text: 'Safe' };
return {
level: 'high',
text: 'Safe'
};
} else if (escapeScore >= 60) {
return { level: 'medium', text: 'At Risk' };
return {
level: 'medium',
text: 'At Risk'
};
} else if (escapeScore >= 30) {
return { level: 'low', text: 'Danger' };
return {
level: 'low',
text: 'Danger'
};
} else {
return { level: 'critical', text: 'Critical' };
return {
level: 'critical',
text: 'Critical'
};
}
}
@@ -488,17 +573,21 @@ export class RTSUIController {
}
handleNarrativeUpdate(event) {
const { type, message, entityUpdates } = event.detail;
const {
type,
message,
entityUpdates
} = event.detail;
this.addLogEntry(type, message);
// Handle entity updates and refresh map
if (entityUpdates && entityUpdates.length > 0) {
this.refreshMapEntities();
}
this.updateUI();
}
refreshMapEntities() {
if (this.mapCanvas && this.mapData) {
// Get updated map data from GameStateManager
@@ -516,13 +605,13 @@ export class RTSUIController {
this.addLogEntry('system', `Entered ${zone.name}.`);
this.updateThreatDisplay();
}
handleMapUpdate(event) {
console.log('RTS: Map update event received:', event.detail);
this.refreshMapEntities();
this.updateUI();
}
handleCanvasRefresh(event) {
console.log('RTS: Canvas refresh event received:', event.detail);
if (this.mapCanvas) {
@@ -552,7 +641,7 @@ export class RTSUIController {
this.enterFullscreen();
}
}
async loadPreset(presetPath) {
async loadPreset(presetPath) {
await PresetManager.loadPreset(presetPath);
GameStateManager.reset();
if (this.isFullscreen) {
@@ -563,6 +652,139 @@ async loadPreset(presetPath) {
await this.enterFullscreen();
}
}
// Settings Dropdown Methods
handleSettingsToggle() {
const dropdown = document.getElementById('rts-settings-dropdown');
if (!dropdown) return;
if (dropdown.style.display === 'none' || !dropdown.style.display) {
this.openSettingsDropdown();
} else {
this.closeSettingsDropdown();
}
}
openSettingsDropdown() {
const dropdown = document.getElementById('rts-settings-dropdown');
if (!dropdown) return;
// Sync dropdown values with current settings
this.syncDropdownSettings();
dropdown.style.display = 'block';
console.log('RTS: Settings dropdown opened');
}
closeSettingsDropdown() {
const dropdown = document.getElementById('rts-settings-dropdown');
if (dropdown) dropdown.style.display = 'none';
}
syncDropdownSettings() {
try {
const rtsSettings = getRTSSettings();
// Sync structured output setting
const structuredOutputToggle = /** @type {HTMLInputElement} */ (document.getElementById('rts-dropdown-structured-output'));
if (structuredOutputToggle) {
structuredOutputToggle.checked = rtsSettings.structuredOutput || false;
}
// Sync content filter setting
const dropdownContentFilter = /** @type {HTMLInputElement} */ (document.getElementById('rts-dropdown-content-filter'));
if (dropdownContentFilter) {
dropdownContentFilter.checked = getRTSSettings().contentFilter;
}
// Sync auto-schema setting
const autoSchemaToggle = /** @type {HTMLInputElement} */ (document.getElementById('rts-dropdown-auto-schema'));
if (autoSchemaToggle) {
autoSchemaToggle.checked = rtsSettings.autoSchemaUpdates !== false;
}
// Sync preset selection
const presetSelect = /** @type {HTMLSelectElement} */ (document.getElementById('rts-preset-select'));
const dropdownPresetSelect = /** @type {HTMLSelectElement} */ (document.getElementById('rts-dropdown-preset-select'));
if (presetSelect && dropdownPresetSelect) {
dropdownPresetSelect.value = presetSelect.value;
}
} catch (error) {
console.warn('RTS: Could not sync settings:', error);
}
}
handleSettingChange(event) {
const setting = event.target.id;
const value = event.target.type === 'checkbox' ? event.target.checked : event.target.value;
console.log('RTS: Setting changed:', setting, value);
switch (setting) {
case 'rts-structured-output-toggle':
case 'rts-dropdown-structured-output':
this.updateStructuredOutputSetting(value);
this.addLogEntry('system', `Structured Output ${value ? 'enabled' : 'disabled'}`);
break;
case 'rts-content-filter-toggle':
case 'rts-dropdown-content-filter':
this.updateContentFilterSetting(value);
this.addLogEntry('system', `Explicit Content ${value ? 'enabled' : 'disabled'}`);
break;
case 'rts-dropdown-auto-schema':
onAutoSchemaToggle(value);
this.addLogEntry('system', `Auto Schema Updates ${value ? 'enabled' : 'disabled'}`);
break;
case 'rts-dropdown-preset-select':
this.addLogEntry('system', `Preset will change to: ${value.split('/').pop().replace('.json', '')}`);
break;
}
}
updateStructuredOutputSetting(enabled) {
// Update the original toggle
const originalToggle = /** @type {HTMLInputElement} */ (document.getElementById('rts-structured-output-toggle'));
if (originalToggle) {
originalToggle.checked = enabled;
// Directly call the settings update function
onStructuredOutputToggle({ target: originalToggle });
}
}
updateContentFilterSetting(enabled) {
// Update the original toggle
const originalToggle = /** @type {HTMLInputElement} */ (document.getElementById('rts-content-filter-toggle'));
if (originalToggle) {
originalToggle.checked = enabled;
originalToggle.dispatchEvent(new Event('input'));
}
}
async handleRestartGame() {
const presetSelect = /** @type {HTMLSelectElement} */ (document.getElementById('rts-dropdown-preset-select'));
const selectedPreset = presetSelect ? presetSelect.value : '/scripts/extensions/rts-mode/presets/zoo_escape.json';
this.addLogEntry('system', 'Restarting game with selected preset...');
try {
await this.loadPreset(selectedPreset);
this.addLogEntry('system', 'Game restarted successfully!');
this.closeSettingsDropdown();
} catch (error) {
console.error('RTS: Error restarting game:', error);
this.addLogEntry('error', `Failed to restart game: ${error.message}`);
}
}
handleCloseSettings() {
this.closeSettingsDropdown();
this.addLogEntry('system', 'Settings applied');
}
async handleExitRTS() {
await this.exitFullscreen();
}
}
export const rtsUI = new RTSUIController();